diff --git a/Dockerfile b/Dockerfile index a922284..0575b17 100644 --- a/Dockerfile +++ b/Dockerfile @@ -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 diff --git a/Readme.md b/Readme.md index ec61c95..d9baf50 100644 --- a/Readme.md +++ b/Readme.md @@ -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 /` 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. diff --git a/aws-mfa-session b/aws-mfa-session new file mode 100755 index 0000000..b8d6b50 --- /dev/null +++ b/aws-mfa-session @@ -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 +set -euo pipefail + +serial="${1:-}" +code="${2:-}" +if [ -z "$serial" ] || [ -z "$code" ]; then + echo "Usage: aws-mfa-session " >&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" diff --git a/mfa.sh b/mfa.sh index 4694293..af0e445 100644 --- a/mfa.sh +++ b/mfa.sh @@ -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 @@ -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 diff --git a/script.sh b/script.sh index 8662add..eccfef0 100644 --- a/script.sh +++ b/script.sh @@ -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/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}/*) ;;