diff --git a/README.md b/README.md index bed0e3ac..0397d183 100644 --- a/README.md +++ b/README.md @@ -55,13 +55,14 @@ the equivalent of: ```elixir config :ex_aws, - access_key_id: [{:system, "AWS_ACCESS_KEY_ID"}, :instance_role], - secret_access_key: [{:system, "AWS_SECRET_ACCESS_KEY"}, :instance_role] + access_key_id: [{:system, "AWS_ACCESS_KEY_ID"}, :pod_identity, :instance_role], + secret_access_key: [{:system, "AWS_SECRET_ACCESS_KEY"}, :pod_identity, :instance_role] ``` This means it will try to resolve credentials in order: * Look for the AWS standard `AWS_ACCESS_KEY_ID` and `AWS_SECRET_ACCESS_KEY` environment variables +* Try to use [EKS Pod Identity](https://docs.aws.amazon.com/eks/latest/userguide/pod-identities.html) if running on EKS with Pod Identity configured * Resolve credentials with IAM * If running inside ECS and a [task role](https://docs.aws.amazon.com/AmazonECS/latest/developerguide/task-iam-roles.html) has been assigned it will use it * Otherwise it will fall back to the [instance role](https://docs.aws.amazon.com/AWSEC2/latest/UserGuide/iam-roles-for-amazon-ec2.html) @@ -80,10 +81,19 @@ variable, you can use that with `{:awscli, :system, timeout}` ```elixir config :ex_aws, - access_key_id: [{:system, "AWS_ACCESS_KEY_ID"}, {:awscli, "default", 30}, :instance_role], - secret_access_key: [{:system, "AWS_SECRET_ACCESS_KEY"}, {:awscli, "default", 30}, :instance_role] + access_key_id: [{:system, "AWS_ACCESS_KEY_ID"}, {:awscli, "default", 30}, :pod_identity, :instance_role], + secret_access_key: [{:system, "AWS_SECRET_ACCESS_KEY"}, {:awscli, "default", 30}, :pod_identity, :instance_role] ``` +### EKS Pod Identity configuration + +For applications running on Amazon EKS, ExAws supports [EKS Pod Identity](https://docs.aws.amazon.com/eks/latest/userguide/pod-identities.html) for credential resolution. Pod Identity automatically injects the required environment variables into your pods when properly configured: + +* `AWS_CONTAINER_CREDENTIALS_FULL_URI` - The endpoint URL for credential retrieval +* `AWS_CONTAINER_AUTHORIZATION_TOKEN_FILE` - Path to the JWT token file + +No additional configuration is required in ExAws - it will automatically detect and use Pod Identity credentials when these environment variables are present. Pod Identity provides improved security and isolation compared to instance roles by providing pod-level credential scoping. + For role based authentication via `role_arn` and `source_profile` an additional dependency is required: diff --git a/lib/ex_aws/config.ex b/lib/ex_aws/config.ex index 7d6fd36e..41e6e523 100644 --- a/lib/ex_aws/config.ex +++ b/lib/ex_aws/config.ex @@ -50,6 +50,12 @@ defmodule ExAws.Config do :external_id ] + @pod_identity_config [ + :access_key_id, + :secret_access_key, + :security_token + ] + @type t :: %{} | Keyword.t() @doc """ @@ -107,6 +113,7 @@ defmodule ExAws.Config do Enum.reduce(refreshable, overrides, fn :awscli, overrides -> Map.drop(overrides, @awscli_config) :instance_role, overrides -> Map.drop(overrides, @instance_role_config) + :pod_identity, overrides -> Map.drop(overrides, @pod_identity_config) end) else overrides @@ -115,7 +122,7 @@ defmodule ExAws.Config do Map.merge(config, overrides) end - # :awscli and :instance_role both read creds from ExAws.Config.AuthCache which + # :awscli, :instance_role, and :pod_identity all read creds from ExAws.Config.AuthCache which # is "refreshable". This is useful for long running streams where the creds can # change while the stream is still running. defp add_refreshable_metadata(config, %{refreshable: true}) do @@ -124,6 +131,7 @@ defmodule ExAws.Config do |> Enum.reduce([], fn {:awscli, _, _}, acc -> [:awscli | acc] :instance_role, acc -> [:instance_role | acc] + :pod_identity, acc -> [:pod_identity | acc] _, acc -> acc end) |> Enum.uniq() @@ -181,6 +189,12 @@ defmodule ExAws.Config do |> valid_map_or_nil end + def retrieve_runtime_value(:pod_identity, config) do + ExAws.Config.AuthCache.get(:pod_identity, config) + |> Map.take(@pod_identity_config) + |> valid_map_or_nil + end + def retrieve_runtime_value({:awscli, profile, expiration}, _) do ExAws.Config.AuthCache.get(profile, expiration * 1000) |> Map.take(@awscli_config) diff --git a/lib/ex_aws/config/auth_cache.ex b/lib/ex_aws/config/auth_cache.ex index 31c95d6e..cc32b350 100644 --- a/lib/ex_aws/config/auth_cache.ex +++ b/lib/ex_aws/config/auth_cache.ex @@ -7,6 +7,7 @@ defmodule ExAws.Config.AuthCache do @refresh_lead_time 300_000 @instance_auth_key :aws_instance_auth + @pod_identity_auth_key :aws_pod_identity_auth defmodule AuthConfigAdapter do @moduledoc false @@ -24,6 +25,11 @@ defmodule ExAws.Config.AuthCache do |> refresh_auth_if_required(config) end + def get(:pod_identity, config) do + :ets.lookup(__MODULE__, @pod_identity_auth_key) + |> refresh_pod_identity_if_required(config) + end + def get(profile, expiration) do case :ets.lookup(__MODULE__, {:awscli, profile}) do [{{:awscli, ^profile}, auth_config}] -> @@ -46,6 +52,11 @@ defmodule ExAws.Config.AuthCache do {:reply, auth, ets} end + def handle_call({:refresh_pod_identity_auth, config}, _from, ets) do + auth = refresh_pod_identity_auth(config, ets) + {:reply, auth, ets} + end + def handle_call({:refresh_awscli_config, profile, expiration}, _from, ets) do auth = refresh_awscli_config(profile, expiration, ets) {:reply, auth, ets} @@ -56,6 +67,11 @@ defmodule ExAws.Config.AuthCache do {:noreply, ets} end + def handle_info({:refresh_pod_identity_auth, config}, ets) do + refresh_pod_identity_auth(config, ets) + {:noreply, ets} + end + def handle_info({:refresh_awscli_config, profile, expiration}, ets) do refresh_awscli_config(profile, expiration, ets) {:noreply, ets} @@ -135,6 +151,50 @@ defmodule ExAws.Config.AuthCache do auth end + defp refresh_pod_identity_if_required([], config) do + GenServer.call(__MODULE__, {:refresh_pod_identity_auth, config}, 30_000) + end + + defp refresh_pod_identity_if_required([{_key, cached_auth}], config) do + if next_refresh_in(cached_auth) > 0 do + cached_auth + else + GenServer.call(__MODULE__, {:refresh_pod_identity_auth, config}, 30_000) + end + end + + defp refresh_pod_identity_auth(config, ets) do + :ets.lookup(__MODULE__, @pod_identity_auth_key) + |> refresh_pod_identity_if_stale(config, ets) + end + + defp refresh_pod_identity_if_stale([], config, ets) do + refresh_pod_identity_now(config, ets) + end + + defp refresh_pod_identity_if_stale([{_key, cached_auth}], config, ets) do + if next_refresh_in(cached_auth) > @refresh_lead_time do + cached_auth + else + refresh_pod_identity_now(config, ets) + end + end + + defp refresh_pod_identity_if_stale(_, config, ets), do: refresh_pod_identity_now(config, ets) + + defp refresh_pod_identity_now(config, ets) do + # Only attempt pod identity if the environment is properly configured + if ExAws.PodIdentity.available?() do + auth = ExAws.PodIdentity.security_credentials(config) + :ets.insert(ets, {@pod_identity_auth_key, auth}) + Process.send_after(__MODULE__, {:refresh_pod_identity_auth, config}, next_refresh_in(auth)) + auth + else + # Return empty map if pod identity is not available + %{} + end + end + defp next_refresh_in(%{expiration: expiration}) do try do expires_in_ms = diff --git a/lib/ex_aws/config/defaults.ex b/lib/ex_aws/config/defaults.ex index 10d60c08..6965bbcf 100644 --- a/lib/ex_aws/config/defaults.ex +++ b/lib/ex_aws/config/defaults.ex @@ -4,8 +4,8 @@ defmodule ExAws.Config.Defaults do """ @common %{ - access_key_id: [{:system, "AWS_ACCESS_KEY_ID"}, :instance_role], - secret_access_key: [{:system, "AWS_SECRET_ACCESS_KEY"}, :instance_role], + access_key_id: [{:system, "AWS_ACCESS_KEY_ID"}, :pod_identity, :instance_role], + secret_access_key: [{:system, "AWS_SECRET_ACCESS_KEY"}, :pod_identity, :instance_role], http_client: ExAws.Request.Hackney, json_codec: Jason, retries: [ diff --git a/lib/ex_aws/pod_identity.ex b/lib/ex_aws/pod_identity.ex new file mode 100644 index 00000000..8ac74875 --- /dev/null +++ b/lib/ex_aws/pod_identity.ex @@ -0,0 +1,99 @@ +defmodule ExAws.PodIdentity do + @moduledoc false + + # Provides access to AWS credentials via EKS Pod Identity + # https://docs.aws.amazon.com/eks/latest/userguide/pod-identities.html + + # Environment variables used by EKS Pod Identity + @credentials_uri_env "AWS_CONTAINER_CREDENTIALS_FULL_URI" + @authorization_token_file_env "AWS_CONTAINER_AUTHORIZATION_TOKEN_FILE" + + def available? do + case {System.get_env(@credentials_uri_env), System.get_env(@authorization_token_file_env)} do + {uri, token_file} when is_binary(uri) and is_binary(token_file) -> true + _ -> false + end + end + + def security_credentials(config) do + with {:ok, token} <- read_authorization_token(), + {:ok, credentials} <- fetch_credentials(config, token) do + parse_credentials(credentials) + else + {:error, reason} -> + raise """ + Pod Identity Error: #{inspect(reason)} + + You tried to access AWS credentials via EKS Pod Identity, but it failed. + This happens when the pod is not properly configured with Pod Identity + or the required environment variables are not set. + + Required environment variables: + - #{@credentials_uri_env} + - #{@authorization_token_file_env} + + Please check your EKS Pod Identity configuration. + """ + end + end + + defp read_authorization_token do + case System.get_env(@authorization_token_file_env) do + nil -> + {:error, "#{@authorization_token_file_env} environment variable not set"} + + token_file_path -> + case File.read(token_file_path) do + {:ok, token} -> + {:ok, String.trim(token)} + + {:error, reason} -> + {:error, "Failed to read authorization token from #{token_file_path}: #{reason}"} + end + end + end + + defp fetch_credentials(config, token) do + case System.get_env(@credentials_uri_env) do + nil -> + {:error, "#{@credentials_uri_env} environment variable not set"} + + credentials_uri -> + headers = [{"Authorization", token}] + + case config.http_client.request(:get, credentials_uri, "", headers, http_opts()) + |> ExAws.Request.maybe_transform_response() do + {:ok, %{status_code: 200, body: body}} -> + case config.json_codec.decode(body) do + {:ok, credentials} -> {:ok, credentials} + {:error, reason} -> {:error, "Failed to decode credentials JSON: #{reason}"} + end + + {:ok, %{status_code: status_code, body: body}} -> + {:error, "HTTP #{status_code}: #{body}"} + + {:error, reason} -> + {:error, "HTTP request failed: #{reason}"} + end + end + end + + defp parse_credentials(credentials) do + %{ + access_key_id: credentials["AccessKeyId"], + secret_access_key: credentials["SecretAccessKey"], + security_token: credentials["Token"], + expiration: credentials["Expiration"] + } + end + + defp http_opts do + defaults = [follow_redirect: false, recv_timeout: 5_000] + + overrides = + Application.get_env(:ex_aws, :pod_identity, []) + |> Keyword.get(:http_opts, []) + + Keyword.merge(defaults, overrides) + end +end \ No newline at end of file