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
3 changes: 2 additions & 1 deletion Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -50,5 +50,6 @@ WORKDIR /work
COPY ./script.sh /
COPY ./mfa.sh /usr/local/bin/mfa.sh
COPY ./aws-role-credentials /usr/local/bin/aws-role-credentials
RUN chmod u+x /script.sh /usr/local/bin/mfa.sh /usr/local/bin/aws-role-credentials \
COPY ./aws-mfa-session /usr/local/bin/aws-mfa-session
RUN chmod u+x /script.sh /usr/local/bin/mfa.sh /usr/local/bin/aws-role-credentials /usr/local/bin/aws-mfa-session \
&& printf '\nmfa() { source /usr/local/bin/mfa.sh "$@"; }\n' >> /root/.bashrc
6 changes: 5 additions & 1 deletion Readme.md
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,11 @@ dev

## AWS role

If the Portunus project has `AWS_REGION`, `AWS_ACCESS_KEY_ID`, `AWS_SECRET_ACCESS_KEY`, and `AWS_ROLE_TO_ASSUME`, the container assumes that role before the shell opens and prints the role and session expiry. The IAM user keys are not put in the environment or the default AWS profile. Commands use the assumed role, and if that role cannot be assumed the command fails instead of running as the IAM user. The AWS CLI assumes the role again after the session expires.
`dev <project>/<stage>` loads whatever that Portunus project defines. Nothing here is tied to one account or role.

If that project has `AWS_REGION`, `AWS_ACCESS_KEY_ID`, `AWS_SECRET_ACCESS_KEY`, and `AWS_ROLE_TO_ASSUME`, the container assumes that role before the shell opens. The IAM user keys are not put in the environment or the default AWS profile. Commands use the assumed role, and if that role cannot be assumed the container exits instead of running as the IAM user.

If that same project also has `AWS_MFA_SERIAL`, startup asks for an MFA code before the assume. The serial stays in Portunus. The code is typed each time. A different project or stage can omit any of these variables.

`dev` with no project does not load Portunus, so that container does not assume a role. Keys without `AWS_ROLE_TO_ASSUME` still configure the default profile as the IAM user.

Expand Down
75 changes: 75 additions & 0 deletions aws-mfa-session
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
#!/usr/bin/env bash
# Turn long-lived IAM user keys into an MFA session, then store that session
# as the only credentials allowed to call AssumeRole.
# Usage: aws-mfa-session <mfa-serial> <mfa-code>
set -euo pipefail

serial="${1:-}"
code="${2:-}"
if [ -z "$serial" ] || [ -z "$code" ]; then
echo "Usage: aws-mfa-session <mfa-serial> <mfa-code>" >&2
exit 1
fi

aws_dir="${HOME}/.aws"
source_file="${aws_dir}/role-source.json"
orig_file="${aws_dir}/role-source-orig.json"
tmp_file="${aws_dir}/tempcreds"
session_duration=129600

if [ ! -f "$orig_file" ]; then
if [ ! -f "$source_file" ]; then
echo "Missing ${source_file}" >&2
exit 1
fi
cp "$source_file" "$orig_file"
chmod 600 "$orig_file"
fi

role_arn=$(jq -r '.RoleArn // empty' "$orig_file")
access_key_id=$(jq -r '.AccessKeyId // empty' "$orig_file")
secret_access_key=$(jq -r '.SecretAccessKey // empty' "$orig_file")
base_session_token=$(jq -r '.SessionToken // empty' "$orig_file")
if [ -z "$role_arn" ] || [ -z "$access_key_id" ] || [ -z "$secret_access_key" ]; then
echo "Role source file is incomplete." >&2
exit 1
fi

echo "Requesting an MFA session." >&2
sts_json=$(
unset AWS_PROFILE AWS_DEFAULT_PROFILE AWS_CONFIG_FILE AWS_SHARED_CREDENTIALS_FILE \
AWS_CONTAINER_CREDENTIALS_RELATIVE_URI AWS_CONTAINER_CREDENTIALS_FULL_URI \
AWS_WEB_IDENTITY_TOKEN_FILE AWS_ROLE_ARN
export AWS_ACCESS_KEY_ID="$access_key_id"
export AWS_SECRET_ACCESS_KEY="$secret_access_key"
if [ -n "$base_session_token" ]; then
export AWS_SESSION_TOKEN="$base_session_token"
else
unset AWS_SESSION_TOKEN
fi
aws sts get-session-token \
--duration-seconds "$session_duration" \
--serial-number "$serial" \
--token-code "$code" \
--output json
) || exit 1

session_access_key_id=$(jq -r '.Credentials.AccessKeyId // empty' <<<"$sts_json")
session_secret_access_key=$(jq -r '.Credentials.SecretAccessKey // empty' <<<"$sts_json")
session_token=$(jq -r '.Credentials.SessionToken // empty' <<<"$sts_json")
expiry=$(jq -r '.Credentials.Expiration // empty' <<<"$sts_json")
rm -f "$tmp_file"
if [ -z "$session_access_key_id" ] || [ -z "$session_secret_access_key" ] || [ -z "$session_token" ]; then
echo "MFA session response was incomplete." >&2
exit 1
fi

jq -n \
--arg RoleArn "$role_arn" \
--arg AccessKeyId "$session_access_key_id" \
--arg SecretAccessKey "$session_secret_access_key" \
--arg SessionToken "$session_token" \
'{RoleArn:$RoleArn, AccessKeyId:$AccessKeyId, SecretAccessKey:$SecretAccessKey, SessionToken:$SessionToken}' \
> "$source_file"
chmod 600 "$source_file"
printf '%s\n' "$expiry"
86 changes: 33 additions & 53 deletions mfa.sh
Original file line number Diff line number Diff line change
Expand Up @@ -26,70 +26,46 @@ role_source_orig_file="${aws_dir}/role-source-orig.json"
# the keys that call AssumeRole and does not export the IAM user into this shell.
if [ -f "$role_source_file" ]; then
mkdir -p "$aws_dir"
if [ ! -f "$role_source_orig_file" ]; then
cp "$role_source_file" "$role_source_orig_file"
chmod 600 "$role_source_orig_file"
base_file="$role_source_file"
if [ -f "$role_source_orig_file" ]; then
base_file="$role_source_orig_file"
fi

role_arn=$(jq -r '.RoleArn // empty' "$role_source_orig_file")
base_access_key_id=$(jq -r '.AccessKeyId // empty' "$role_source_orig_file")
base_secret_access_key=$(jq -r '.SecretAccessKey // empty' "$role_source_orig_file")
base_session_token=$(jq -r '.SessionToken // empty' "$role_source_orig_file")
role_arn=$(jq -r '.RoleArn // empty' "$role_source_file")
base_access_key_id=$(jq -r '.AccessKeyId // empty' "$base_file")
base_secret_access_key=$(jq -r '.SecretAccessKey // empty' "$base_file")
base_session_token=$(jq -r '.SessionToken // empty' "$base_file")
if [ -z "$role_arn" ] || [ -z "$base_access_key_id" ] || [ -z "$base_secret_access_key" ]; then
echo "Role source file is incomplete." >&2
return 1 2>/dev/null || exit 1
fi

run_as_user() {
unset AWS_PROFILE AWS_DEFAULT_PROFILE AWS_CONFIG_FILE AWS_SHARED_CREDENTIALS_FILE
export AWS_ACCESS_KEY_ID="$base_access_key_id"
export AWS_SECRET_ACCESS_KEY="$base_secret_access_key"
if [ -n "$base_session_token" ]; then
export AWS_SESSION_TOKEN="$base_session_token"
else
unset AWS_SESSION_TOKEN
fi
aws "$@"
}

mfa_device_code=$(run_as_user iam list-mfa-devices | jq -r '.MFADevices[0].SerialNumber // empty')
if [ -z "$mfa_device_code" ]; then
if [ -n "${AWS_MFA_SERIAL:-}" ]; then
mfa_serial="$AWS_MFA_SERIAL"
else
run_as_user() {
unset AWS_PROFILE AWS_DEFAULT_PROFILE AWS_CONFIG_FILE AWS_SHARED_CREDENTIALS_FILE
export AWS_ACCESS_KEY_ID="$base_access_key_id"
export AWS_SECRET_ACCESS_KEY="$base_secret_access_key"
if [ -n "$base_session_token" ]; then
export AWS_SESSION_TOKEN="$base_session_token"
else
unset AWS_SESSION_TOKEN
fi
aws "$@"
}
mfa_serial=$(run_as_user iam list-mfa-devices | jq -r '.MFADevices[0].SerialNumber // empty')
unset -f run_as_user
echo "Failed to retrieve an MFA device. Check that the long-lived IAM user keys are valid." >&2
return 1 2>/dev/null || exit 1
if [ -z "$mfa_serial" ]; then
echo "Failed to retrieve an MFA device. Set AWS_MFA_SERIAL on this Portunus project." >&2
return 1 2>/dev/null || exit 1
fi
fi

echo "aws sts get-session-token --duration-seconds ${session_duration} --serial-number ${mfa_device_code} --token-code ${mfa_code}"
if ! (
run_as_user sts get-session-token \
--duration-seconds "$session_duration" \
--serial-number "$mfa_device_code" \
--token-code "$mfa_code" > "$tmp_creds_file"
); then
unset -f run_as_user
echo "Request failed" >&2
return 1 2>/dev/null || exit 1
fi
unset -f run_as_user

access_key_id=$(jq -r '.Credentials.AccessKeyId // empty' "$tmp_creds_file")
secret_access_key=$(jq -r '.Credentials.SecretAccessKey // empty' "$tmp_creds_file")
session_token=$(jq -r '.Credentials.SessionToken // empty' "$tmp_creds_file")
expiry=$(jq -r '.Credentials.Expiration // empty' "$tmp_creds_file")
rm -f "$tmp_creds_file"
if [ -z "$access_key_id" ] || [ -z "$secret_access_key" ] || [ -z "$session_token" ]; then
if ! expiry=$(/usr/local/bin/aws-mfa-session "$mfa_serial" "$mfa_code"); then
echo "Request failed" >&2
return 1 2>/dev/null || exit 1
fi

jq -n \
--arg RoleArn "$role_arn" \
--arg AccessKeyId "$access_key_id" \
--arg SecretAccessKey "$secret_access_key" \
--arg SessionToken "$session_token" \
'{RoleArn:$RoleArn, AccessKeyId:$AccessKeyId, SecretAccessKey:$SecretAccessKey, SessionToken:$SessionToken}' \
> "$role_source_file"
chmod 600 "$role_source_file"
rm -f "$aws_creds_file"
unset AWS_ACCESS_KEY_ID AWS_SECRET_ACCESS_KEY AWS_SESSION_TOKEN \
AWS_PROFILE AWS_DEFAULT_PROFILE AWS_SHARED_CREDENTIALS_FILE AWS_CONFIG_FILE
Expand Down Expand Up @@ -134,9 +110,13 @@ fi
cp "$orig_creds_file" "$aws_creds_file"
chmod 600 "$aws_creds_file"

mfa_device_code=$(aws iam list-mfa-devices | jq -r '.MFADevices[0].SerialNumber // empty')
if [ -n "${AWS_MFA_SERIAL:-}" ]; then
mfa_device_code="$AWS_MFA_SERIAL"
else
mfa_device_code=$(aws iam list-mfa-devices | jq -r '.MFADevices[0].SerialNumber // empty')
fi
if [ -z "$mfa_device_code" ]; then
echo "Failed to retrieve an MFA device. Check that the AWS CLI is using the long-lived credentials." >&2
echo "Failed to retrieve an MFA device. Set AWS_MFA_SERIAL on this Portunus project, or check that the AWS CLI is using the long-lived credentials." >&2
return 1 2>/dev/null || exit 1
fi

Expand Down
16 changes: 15 additions & 1 deletion script.sh
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,21 @@ EOF
AWS_CONTAINER_CREDENTIALS_RELATIVE_URI AWS_CONTAINER_CREDENTIALS_FULL_URI \
AWS_CONTAINER_AUTHORIZATION_TOKEN AWS_CONTAINER_AUTHORIZATION_TOKEN_FILE \
AWS_WEB_IDENTITY_TOKEN_FILE AWS_ROLE_ARN AWS_ROLE_SESSION_NAME AWS_CREDENTIAL_EXPIRATION
caller_arn=$(aws sts get-caller-identity --query Arn --output text) || error_exit "Failed to assume role: ${AWS_ROLE_TO_ASSUME}"
# AWS_MFA_SERIAL comes from this Portunus project. The one-time code does not.
if [ -n "${AWS_MFA_SERIAL:-}" ]; then
echo "MFA is required before assuming ${AWS_ROLE_TO_ASSUME}."
if ! read -r -s -p "MFA code: " mfa_code </dev/tty 2>/dev/null; then
read -r -s -p "MFA code: " mfa_code || error_exit "AWS_MFA_SERIAL is set for this project, but there is no terminal to read an MFA code."
printf '\n'
else
printf '\n' >/dev/tty
fi
if [ -z "${mfa_code}" ]; then
error_exit "An MFA code is required to assume ${AWS_ROLE_TO_ASSUME}."
fi
/usr/local/bin/aws-mfa-session "$AWS_MFA_SERIAL" "$mfa_code" >/dev/null || error_exit "Failed to create an MFA session for ${AWS_ROLE_TO_ASSUME}."
fi
caller_arn=$(aws sts get-caller-identity --query Arn --output text) || error_exit "Failed to assume role: ${AWS_ROLE_TO_ASSUME}. If this account requires MFA, set AWS_MFA_SERIAL on this Portunus project."
role_name=${AWS_ROLE_TO_ASSUME##*/}
case "$caller_arn" in
arn:aws:sts::*:assumed-role/${role_name}/*) ;;
Expand Down
Loading