Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 14 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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:

Expand Down
16 changes: 15 additions & 1 deletion lib/ex_aws/config.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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 """
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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()
Expand Down Expand Up @@ -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)
Expand Down
60 changes: 60 additions & 0 deletions lib/ex_aws/config/auth_cache.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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}] ->
Expand All @@ -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}
Expand All @@ -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}
Expand Down Expand Up @@ -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 =
Expand Down
4 changes: 2 additions & 2 deletions lib/ex_aws/config/defaults.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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: [
Expand Down
99 changes: 99 additions & 0 deletions lib/ex_aws/pod_identity.ex
Original file line number Diff line number Diff line change
@@ -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