defmodule PhoenixKit.AWS.CredentialsVerifier do @moduledoc """ AWS credentials verification module. This module provides functionality to: - Validate AWS Access Key ID and Secret Access Key format - Verify credential connectivity via AWS STS GetCallerIdentity - List available AWS regions - Check minimal required permissions for email operations ## Features - **Credential Validation**: Basic format validation for access key and secret - **Connectivity Testing**: Verify credentials can make AWS API calls - **Region Discovery**: List available regions for the AWS account - **Permission Checks**: Validate access to SQS, SNS, and SES services - **Error Handling**: Detailed error messages for common issues ## Usage # Basic credential verification PhoenixKit.AWS.CredentialsVerifier.verify_credentials( access_key_id: "AKIA...", secret_access_key: "****************", region: "eu-north-1" ) # Get available regions PhoenixKit.AWS.CredentialsVerifier.get_available_regions( access_key_id: "AKIA...", secret_access_key: "****************", region: "eu-north-1" ) """ require Logger alias ExAws.{EC2, SNS, SQS, STS} # Note: SES doesn't have ExAws library, will use direct HTTP calls via ExAws.Operation.Query @doc """ Verifies AWS credentials using STS GetCallerIdentity. ## Parameters - `access_key_id`: AWS Access Key ID (string) - `secret_access_key`: AWS Secret Access Key (string) - `region`: AWS region (string) ## Returns - `{:ok, %{access_key_id: string, aws_user_id: string, account_id: string, arn: string}}` on success - `{:error, :invalid_credentials}` for format issues - `{:error, :authentication_failed}` for invalid credentials - `{:error, :network_error}` for connectivity issues - `{:error, rate_limited}` for AWS rate limiting """ def verify_credentials(access_key_id, secret_access_key, region) do # Validate credential format first with {:format_ok, true} <- {:format_ok, validate_credentials_format(access_key_id, secret_access_key)}, {:config, {:ok, config}} <- {:config, create_config(access_key_id, secret_access_key, region)}, {:sts_call, {:ok, %{body: body}}} <- {:sts_call, STS.get_caller_identity() |> ExAws.request(config)}, {:parse, {:ok, aws_user_id, account_id, arn}} <- {:parse, parse_sts_response(body)} do {:ok, %{ access_key_id: access_key_id, aws_user_id: aws_user_id, account_id: account_id, arn: arn }} else {:format_ok, false} -> {:error, :invalid_credentials, "Invalid credential format. Access key should be 20 characters, secret key should not be empty."} {:config, {:error, reason}} -> {:error, :configuration_error, "Failed to create AWS configuration: #{inspect(reason)}"} {:sts_call, {:error, %{status_code: 403}}} -> {:error, :authentication_failed, "AWS authentication failed. Please check your access key and secret key."} {:sts_call, {:error, %{status_code: 404}}} -> {:error, :authentication_failed, "AWS authentication failed. Region not found or incorrect."} {:sts_call, {:error, %{status_code: 429}}} -> {:error, :rate_limited, "AWS API rate limit exceeded. Please try again later."} {:sts_call, {:error, reason}} -> {:error, :network_error, "Network or AWS API error: #{inspect(reason)}"} {:parse, {:error, reason}} -> {:error, :response_error, "Failed to parse AWS response: #{reason}"} end end @doc """ Gets list of available AWS regions for the account. ## Parameters - `access_key_id`: AWS Access Key ID (string) - `secret_access_key`: AWS Secret Access Key (string) - `region`: AWS region (string) ## Returns - `{:ok, [region_names]}` on success - `{:error, reason}` on failure """ def get_available_regions(access_key_id, secret_access_key, region) do case create_config(access_key_id, secret_access_key, region) do {:ok, config} -> # Try to get regions from EC2 API first case get_regions_from_ec2(config) do {:ok, regions} when is_list(regions) and regions != [] -> {:ok, regions} {:error, :permission_denied} -> # EC2 permission missing - fallback to common regions Logger.warning( "EC2 DescribeRegions permission missing. Using common regions list. " <> "Add 'ec2:DescribeRegions' to IAM policy for accurate region list." ) {:ok, list_common_regions(nil)} {:error, reason} -> # Other error - also fallback but log error Logger.error( "Failed to get regions from EC2 API: #{inspect(reason)}. Using fallback." ) {:ok, list_common_regions(nil)} end {:error, reason} -> {:error, reason} end end # Private function to get regions from EC2 API defp get_regions_from_ec2(config) do case EC2.describe_regions() |> ExAws.request(config) do {:ok, %{body: body}} -> parse_ec2_regions(body) {:error, {:http_error, 403, _}} -> {:error, :permission_denied} {:error, %{status_code: 403}} -> {:error, :permission_denied} {:error, reason} -> {:error, reason} end rescue e -> Logger.error("Exception in EC2 describe_regions: #{inspect(e)}") {:error, :ec2_api_error} end # Parse EC2 DescribeRegions response defp parse_ec2_regions(body) when is_map(body) do # ExAws parses XML to map structure # Response structure: %{regions_set: [%{region_name: "us-east-1", ...}, ...]} regions = body |> Map.get(:regions_set, []) |> Enum.map(fn region -> Map.get(region, :region_name) end) |> Enum.filter(&is_binary/1) |> Enum.sort() if Enum.empty?(regions) do {:error, :empty_regions_list} else {:ok, regions} end rescue e -> Logger.error("Failed to parse EC2 regions response: #{inspect(e)}") {:error, :parse_error} end defp parse_ec2_regions(_body) do {:error, :invalid_response_format} end @doc """ Performs basic AWS permissions check using List operations. ⚠️ **Important Disclaimer:** - This checks READ permissions (List operations), NOT CREATE permissions - `ListQueues` does NOT guarantee `CreateQueue` permission - `ListTopics` does NOT guarantee `CreateTopic` permission - Actual CREATE permissions are verified during "Setup AWS Infrastructure" This provides a basic sanity check that credentials have SOME access to required services. ## Checked Operations - SQS: `ListQueues` (indicates basic SQS access) - SNS: `ListTopics` (indicates basic SNS access) - SES: `ListConfigurationSets` (indicates basic SES access) - EC2: `DescribeRegions` (optional - for auto-loading regions feature) ## Parameters - `access_key_id`: AWS Access Key ID (string) - `secret_access_key`: AWS Secret Access Key (string) - `region`: AWS region (string) ## Returns - `{:ok, permissions_map}` where permissions_map is: ``` %{ sqs: %{"ListQueues" => :granted | :denied}, sns: %{"ListTopics" => :granted | :denied}, ses: %{"ListConfigurationSets" => :granted | :denied}, ec2: %{"DescribeRegions" => :granted | :denied, optional: true} } ``` - `{:error, reason}` if configuration fails """ def check_permissions(access_key_id, secret_access_key, region) do case create_config(access_key_id, secret_access_key, region) do {:ok, config} -> permissions = %{ sqs: check_sqs_permissions(config), sns: check_sns_permissions(config), ses: check_ses_permissions(config, region), ec2: check_ec2_permissions(config) } {:ok, permissions} {:error, reason} -> {:error, reason} end end # Check SQS permissions - only ListQueues (basic access indicator) defp check_sqs_permissions(config) do %{ "ListQueues" => check_sqs_list_queues(config) } end # Check SNS permissions - only ListTopics (basic access indicator) defp check_sns_permissions(config) do %{ "ListTopics" => check_sns_list_topics(config) } end # Check SES permissions - only ListConfigurationSets (basic access indicator) defp check_ses_permissions(config, region) do %{ "ListConfigurationSets" => check_ses_list_configuration_sets(config, region) } end # Check EC2 permissions - optional feature for auto-loading regions defp check_ec2_permissions(config) do %{ "DescribeRegions" => check_ec2_describe_regions(config), optional: true } end # SQS permission checks - ListQueues only defp check_sqs_list_queues(config) do case SQS.list_queues() |> ExAws.request(config) do {:ok, _} -> :granted {:error, {:http_error, 403, _}} -> :denied {:error, %{status_code: 403}} -> :denied _ -> :denied end rescue _ -> :denied end # SNS permission checks - ListTopics only defp check_sns_list_topics(config) do case SNS.list_topics() |> ExAws.request(config) do {:ok, _} -> :granted {:error, {:http_error, 403, _}} -> :denied {:error, %{status_code: 403}} -> :denied _ -> :denied end rescue _ -> :denied end # SES permission checks - ListConfigurationSets only defp check_ses_list_configuration_sets(config, region) do case call_ses_api("ListConfigurationSets", %{}, config, region) do {:ok, _} -> :granted {:error, {:http_error, 403, _}} -> :denied {:error, %{status_code: 403}} -> :denied _ -> :denied end rescue _ -> :denied end # EC2 permission checks - DescribeRegions (optional feature) defp check_ec2_describe_regions(config) do case EC2.describe_regions() |> ExAws.request(config) do {:ok, _} -> :granted {:error, {:http_error, 403, _}} -> :denied {:error, %{status_code: 403}} -> :denied _ -> :denied end rescue _ -> :denied end # Helper to call SES API directly (ExAws doesn't have SES module) defp call_ses_api(action, params, config, region) do operation = %ExAws.Operation.Query{ path: "/", params: params |> Map.put("Action", action) |> Map.put("Version", "2010-12-01"), service: :email, action: action, parser: &ExAws.Utils.identity/2 } ExAws.request(operation, Keyword.put(config, :region, region)) end # Private helper functions defp validate_credentials_format(access_key_id, secret_access_key) do access_key_valid? = String.length(String.trim(access_key_id)) == 20 secret_key_valid? = String.length(String.trim(secret_access_key)) > 0 access_key_valid? and secret_key_valid? end defp create_config(access_key_id, secret_access_key, region) do config = [ access_key_id: String.trim(access_key_id), secret_access_key: String.trim(secret_access_key), region: String.trim(region) ] {:ok, config} rescue e -> {:error, "Failed to create config: #{inspect(e)}"} end # Handle already parsed map response from ExAws (modern behavior) defp parse_sts_response(body) when is_map(body) do # ExAws automatically parses the XML response into a map # Structure: %{user_id: "...", account: "...", arn: "..."} with {:aws_user_id, aws_user_id} when is_binary(aws_user_id) <- {:aws_user_id, Map.get(body, :user_id)}, {:account, account} when is_binary(account) <- {:account, Map.get(body, :account)}, {:arn, arn} when is_binary(arn) <- {:arn, Map.get(body, :arn)} do {:ok, aws_user_id, account, arn} else {:aws_user_id, _} -> {:error, "Missing or invalid user_id in STS response"} {:account, _} -> {:error, "Missing or invalid account in STS response"} {:arn, _} -> {:error, "Missing or invalid arn in STS response"} end rescue e -> {:error, "Map parsing error: #{inspect(e)}"} end # Handle XML string response (legacy/fallback) defp parse_sts_response(body) when is_binary(body) do # Parse XML response from STS # Example structure: # # # AIDACKEVSAMPLE... # 123456789012 # arn:aws:sts::123456789012:assumed-role/... # # with true <- String.contains?(body, ""), {:ok, aws_user_id} <- extract_xml_value(body, "UserId"), {:ok, account_id} <- extract_xml_value(body, "Account"), {:ok, arn} <- extract_xml_value(body, "Arn") do {:ok, aws_user_id, account_id, arn} else false -> {:error, "Could not find UserId in STS response"} {:error, _} -> {:error, "Could not parse all required fields from STS response"} end rescue e -> {:error, "XML parsing error: #{inspect(e)}"} end defp parse_sts_response(_body) do {:error, "Invalid STS response format"} end # Helper to extract XML tag value defp extract_xml_value(body, tag_name) do open_tag = "<#{tag_name}>" close_tag = "" case String.split(body, open_tag) do [_, rest] -> case String.split(rest, close_tag) do [value, _] -> {:ok, String.trim(value)} _ -> {:error, :tag_not_closed} end _ -> {:error, :tag_not_found} end end defp list_common_regions(_account_id) do # List of commonly used AWS regions. In a production environment, # you would call EC2 DescribeRegions API to get actual regions for the account. [ "us-east-1", "us-east-2", "us-west-1", "us-west-2", "af-south-1", "ap-east-1", "ap-northeast-1", "ap-northeast-2", "ap-northeast-3", "ap-south-1", "ap-southeast-1", "ap-southeast-2", "ca-central-1", "eu-central-1", "eu-north-1", "eu-south-1", "eu-south-2", "eu-west-1", "eu-west-2", "eu-west-3", "me-south-1", "sa-east-1" ] end end