From bd39b395914bfdec3f2a4025864ebb8792902849 Mon Sep 17 00:00:00 2001 From: Curtis Vogt Date: Mon, 26 Jun 2023 10:31:38 -0500 Subject: [PATCH] Create `assume_role` function (#638) * Create assume_role function * Better support for role chaining * Add tests * Formatting * Set project version to 1.89.0 --- Project.toml | 2 +- src/AWS.jl | 3 +- src/utilities/role.jl | 130 ++++++++++++++++++++++++++++++++ test/resources/aws_jl_test.yaml | 92 +++++++++++++++++++++- test/role.jl | 104 +++++++++++++++++++++++++ test/runtests.jl | 6 +- 6 files changed, 332 insertions(+), 5 deletions(-) create mode 100644 src/utilities/role.jl create mode 100644 test/role.jl diff --git a/Project.toml b/Project.toml index 0e2c55d785..87ecfdf242 100644 --- a/Project.toml +++ b/Project.toml @@ -1,7 +1,7 @@ name = "AWS" uuid = "fbe9abb3-538b-5e4e-ba9e-bc94f4f92ebc" license = "MIT" -version = "1.88.0" +version = "1.89.0" [deps] Base64 = "2a0f44e3-6c83-55bd-87e4-b1978d98bd5f" diff --git a/src/AWS.jl b/src/AWS.jl index a08a30a6c4..b774a9e70c 100644 --- a/src/AWS.jl +++ b/src/AWS.jl @@ -19,7 +19,7 @@ export @service export _merge export AbstractAWSConfig, AWSConfig, AWSExceptions, AWSServices, Request export ec2_instance_metadata, ec2_instance_region -export generate_service_url, global_aws_config, set_user_agent +export assume_role, generate_service_url, global_aws_config, set_user_agent export sign!, sign_aws2!, sign_aws4! export JSONService, RestJSONService, RestXMLService, QueryService, set_features @@ -36,6 +36,7 @@ include(joinpath("utilities", "request.jl")) include(joinpath("utilities", "response.jl")) include(joinpath("utilities", "sign.jl")) include(joinpath("utilities", "downloads_backend.jl")) +include(joinpath("utilities", "role.jl")) include("deprecated.jl") diff --git a/src/utilities/role.jl b/src/utilities/role.jl new file mode 100644 index 0000000000..6997abc42d --- /dev/null +++ b/src/utilities/role.jl @@ -0,0 +1,130 @@ +""" + assume_role(principal::AbstractAWSConfig, role; kwargs...) -> AbstractAWSConfig + +Assumes the IAM `role` via temporary credentials via the `principal` entity. The `principal` +entity must be included in the trust policy of the `role`. + +[Role chaining](https://docs.aws.amazon.com/IAM/latest/UserGuide/id_roles_terms-and-concepts.html#iam-term-role-chaining) +must be manually specified by multiple `assume_role` calls (e.g. "role-a" has permissions to +assume "role-b": `assume_role(assume_role(AWSConfig(), "role-a"), "role-b")`). + +# Arguments +- `principal::AbstractAWSConfig`: The AWS configuration and credentials of the principal + entity (user or role) performing the `sts:AssumeRole` action. +- `role::AbstractString`: The AWS IAM role to assume. Either a full role ARN or just the + role name. If only the role name is specified the role will be assumed to reside in the + same account used in the `principal` argument. + +# Keywords +- `duration::Integer` (optional): Role session duration in seconds. +- `mfa_serial::AbstractString` (optional): The identification number of the MFA device that + is associated with the user making the `AssumeRole` API call. Either a serial number for a + hardware device ("GAHT12345678") or an ARN for a virtual device + ("arn:aws:iam::123456789012:mfa/user"). When specified a MFA token must be provided via + `token` or an interactive prompt. +- `token::AbstractString` (optional): The value provided by the MFA device. Only can be + specified when `mfa_serial` is set. +- `session_name::AbstractString` (optional): The unique role session name associated with + this API request. +""" +function assume_role(principal::AWSConfig, role; kwargs...) + creds = assume_role_creds(principal, role; kwargs...) + return AWSConfig(creds, principal.region, principal.output, principal.max_attempts) +end + +""" + assume_role(role; kwargs...) -> Function + +Create a function that assumes the IAM `role` via a deferred principal entity, i.e. a +function equivalent to `principal -> assume_role(principal, role; kwargs...)`. Useful for +[role chaining](https://docs.aws.amazon.com/IAM/latest/UserGuide/id_roles_terms-and-concepts.html#iam-term-role-chaining). + +# Examples + +Assume "role-a" which in turn assumes "role-b": + +```julia +AWSConfig() |> assume_role("role-a") |> assume_role("role-b") +``` +""" +assume_role(role; kwargs...) = principal -> assume_role(principal, role; kwargs...) + +""" + assume_role_creds(principal, role; kwargs...) -> AWSCredentials + +Assumes the IAM `role` via temporary credentials via the `principal` entity and returns +`AWSCredentials`. Typically, end-users should use [`assume_role`](@ref) instead. + +Details on the arguments and keywords for `assume_role_creds` can be found in the docstring +for [`assume_role`](@ref). +""" +function assume_role_creds( + principal::AbstractAWSConfig, + role::AbstractString; + duration::Union{Integer,Nothing}=nothing, + mfa_serial::Union{AbstractString,Nothing}=nothing, + token::Union{AbstractString,Nothing}=nothing, + session_name::Union{AbstractString,Nothing}=nothing, +) + if startswith(role, "arn:aws:iam") + # Avoiding unnecessary parsing the role ARN or performing an expensive API call + account_id = "" + role_arn = role + else + account_id = aws_account_number(principal) + role_arn = "arn:aws:iam::$account_id:role/$role" + end + + params = Dict{String,Any}("RoleArn" => role_arn) + if session_name !== nothing + params["RoleSessionName"] = session_name + else + params["RoleSessionName"] = _role_session_name( + "AWS.jl-", + ENV["USER"], + "-" * Dates.format(now(UTC), dateformat"yyyymmdd\THHMMSS\Z"), + ) + end + + if duration !== nothing + params["DurationSeconds"] = duration + end + + if mfa_serial !== nothing && token !== nothing + params["SerialNumber"] = mfa_serial + params["TokenCode"] = token + elseif mfa_serial !== nothing && token === nothing + params["SerialNumber"] = mfa_serial + token = Base.getpass("Enter MFA code for $mfa_serial") + params["TokenCode"] = Base.shred!(token) do t + read(t, String) + end + elseif mfa_serial === nothing && token !== nothing + msg = "Keyword `token` cannot be be specified when `mfa_serial` is not set" + throw(ArgumentError(msg)) + end + + response = AWSServices.sts( + "AssumeRole", + params; + aws_config=principal, + feature_set=AWS.FeatureSet(; use_response_type=true), + ) + body = parse(response) + role_creds = body["AssumeRoleResult"]["Credentials"] + role_user = body["AssumeRoleResult"]["AssumedRoleUser"] + renew = function () + # Avoid passing the `token` into the credential renew function as it will be expired + return assume_role_creds(principal, role_arn; duration, mfa_serial, session_name) + end + + return AWSCredentials( + role_creds["AccessKeyId"], + role_creds["SecretAccessKey"], + role_creds["SessionToken"], + role_user["Arn"], + account_id; # May as well populate "account_number" field when we have it + expiry=DateTime(rstrip(role_creds["Expiration"], 'Z')), + renew, + ) +end diff --git a/test/resources/aws_jl_test.yaml b/test/resources/aws_jl_test.yaml index a7897025a4..3c66958ae0 100644 --- a/test/resources/aws_jl_test.yaml +++ b/test/resources/aws_jl_test.yaml @@ -1,4 +1,7 @@ -# `aws cloudformation create-stack --stack-name AWS-jl-test --template-body file://aws_jl_test.yaml --capabilities CAPABILITY_NAMED_IAM` +# ``` +# aws cloudformation update-stack --stack-name AWS-jl-test --template-body file://aws_jl_test.yaml --capabilities CAPABILITY_NAMED_IAM --region us-east-1 +# ``` + --- AWSTemplateFormatVersion: 2010-09-09 Description: >- @@ -43,6 +46,24 @@ Resources: - !Sub repo:${GitHubOrg}/${GitHubRepo}:pull_request - !Sub repo:${GitHubOrg}/${GitHubRepo}:ref:refs/heads/master - !Sub repo:${GitHubOrg}/${GitHubRepo}:ref:refs/tags/* + # - Effect: Allow + # Principal: + # AWS: !Sub arn:aws:iam::${AWS::AccountId}:root + # Action: sts:AssumeRole + + PublicCIAssumePolicy: + Type: AWS::IAM::Policy + Properties: + PolicyName: PublicCIAssumeRoles + Roles: + - !Ref PublicCIRole + PolicyDocument: + Version: 2012-10-17 + Statement: + - Effect: Allow + Action: + - sts:AssumeRole + Resource: !Sub arn:aws:iam::${AWS::AccountId}:role/* StackInfoPolicy: Type: AWS::IAM::ManagedPolicy @@ -173,3 +194,72 @@ Resources: - sqs:SendMessageBatch - sqs:SetQueueAttributes Resource: !Sub arn:aws:sqs:*:${AWS::AccountId}:aws-jl-test-* + + ### + ### Testset specific roles/policies + ### + + AssumeRoleTestsetRole: + Type: AWS::IAM::Role + Properties: + RoleName: !Sub ${GitHubRepo}-AssumeRoleTestset + AssumeRolePolicyDocument: + Version: 2012-10-17 + Statement: + - Effect: Allow + Principal: + AWS: !GetAtt PublicCIRole.Arn + Action: sts:AssumeRole + + AssumeRoleTestsetPolicy: + Type: AWS::IAM::Policy + Properties: + PolicyName: !Sub ${GitHubRepo}-AssumeRoleTestset + Roles: + - !Ref AssumeRoleTestsetRole + PolicyDocument: + Version: 2012-10-17 + Statement: + - Effect: Allow + Action: sts:AssumeRole + Resource: !GetAtt RoleA.Arn + + # No permissions are required to perform the `sts:GetCallerIdentity` action + # https://docs.aws.amazon.com/STS/latest/APIReference/API_GetCallerIdentity.html + + RoleA: + Type: AWS::IAM::Role + Properties: + RoleName: !Sub ${GitHubRepo}-RoleA + AssumeRolePolicyDocument: + Version: 2012-10-17 + Statement: + - Effect: Allow + Principal: + AWS: !GetAtt AssumeRoleTestsetRole.Arn + Action: sts:AssumeRole + + RoleAPolicy: + Type: AWS::IAM::Policy + Properties: + PolicyName: !Sub ${GitHubRepo}-RoleA + Roles: + - !Ref RoleA + PolicyDocument: + Version: 2012-10-17 + Statement: + - Effect: Allow + Action: sts:AssumeRole + Resource: !GetAtt RoleB.Arn + + RoleB: + Type: AWS::IAM::Role + Properties: + RoleName: !Sub ${GitHubRepo}-RoleB + AssumeRolePolicyDocument: + Version: 2012-10-17 + Statement: + - Effect: Allow + Principal: + AWS: !GetAtt RoleA.Arn + Action: sts:AssumeRole diff --git a/test/role.jl b/test/role.jl new file mode 100644 index 0000000000..986257a018 --- /dev/null +++ b/test/role.jl @@ -0,0 +1,104 @@ +function get_assumed_role(aws_config::AbstractAWSConfig=global_aws_config()) + r = AWSServices.sts( + "GetCallerIdentity"; + aws_config, + feature_set=AWS.FeatureSet(; use_response_type=true), + ) + result = parse(r) + arn = result["GetCallerIdentityResult"]["Arn"] + m = match(r":assumed-role/(?[^/]+)", arn) + if m !== nothing + return m["role"] + else + error("Caller Identity ARN is not an assumed role: $arn") + end +end + +get_assumed_role(creds::AWSCredentials) = get_assumed_role(AWSConfig(; creds)) + +@testset "assume_role / assume_role_creds" begin + # In order to mitigate the effects of using `assume_role` in order to test itself we'll + # use the lowest-level call with as many defaults as possible. + base_config = aws + creds = assume_role_creds(base_config, testset_role("AssumeRoleTestset")) + config = AWSConfig(; creds) + @test get_assumed_role(config) == testset_role("AssumeRoleTestset") + + role_a = testset_role("RoleA") + role_b = testset_role("RoleB") + + @testset "basic" begin + creds = assume_role_creds(config, role_a) + @test creds isa AWSCredentials + @test creds.token != "" # Temporary credentials + @test creds.renew !== nothing + + cfg = assume_role(config, role_a) + @test cfg isa AWSConfig + @test cfg.credentials isa AWSCredentials + @test cfg.region == config.region + @test cfg.output == config.output + @test cfg.max_attempts == config.max_attempts + end + + @testset "role name/ARN" begin + account_id = aws_account_number(config) + + creds = assume_role_creds(config, role_a) + @test contains(creds.user_arn, r":assumed-role/" * (role_a * '/')) + @test creds.account_number == account_id + + creds = assume_role_creds(config, "arn:aws:iam::$account_id:role/$role_a") + @test contains(creds.user_arn, r":assumed-role/" * (role_a * '/')) + @test creds.account_number == "" + end + + @testset "duration" begin + drift = Second(1) + + creds = assume_role_creds(config, role_a; duration=nothing) + t = floor(now(UTC), Second) + @test t <= creds.expiry <= t + Second(3600) + drift + + creds = assume_role_creds(config, role_a; duration=900) + t = floor(now(UTC), Second) + @test t <= creds.expiry <= t + Second(900) + drift + end + + @testset "session_name" begin + session_prefix = "AWS.jl-" * ENV["USER"] + creds = assume_role_creds(config, role_a; session_name=nothing) + regex = r":assumed-role/" * (role_a * '/' * session_prefix) * r"-\d{8}T\d{6}Z$" + @test contains(creds.user_arn, regex) + @test get_assumed_role(creds) == role_a + + session_name = "assume-role-session-name-testset-" * randstring(5) + creds = assume_role_creds(config, role_a; session_name) + regex = r":assumed-role/" * (role_a * '/' * session_name) * r"$" + @test contains(creds.user_arn, regex) + @test get_assumed_role(creds) == role_a + end + + @testset "renew" begin + creds = assume_role_creds(config, role_a; duration=nothing) + @test creds.renew isa Function + @test get_assumed_role(creds) == role_a + + new_creds = creds.renew() + @test new_creds isa AWSCredentials + @test get_assumed_role(new_creds) == role_a + @test new_creds.access_key_id != creds.access_key_id + @test new_creds.secret_key != creds.secret_key + @test new_creds.expiry >= creds.expiry + end + + @testset "role chaining" begin + cfg = assume_role(assume_role(config, role_a), role_b) + @test get_assumed_role(cfg) == role_b + + #! format: off + cfg = config |> assume_role(role_a) |> assume_role(role_b) + #! format: on + @test get_assumed_role(cfg) == role_b + end +end diff --git a/test/runtests.jl b/test/runtests.jl index 3df8afe121..af8aa44ca6 100644 --- a/test/runtests.jl +++ b/test/runtests.jl @@ -1,6 +1,5 @@ using AWS -using AWS: AWSCredentials -using AWS: AWSServices +using AWS: AWSCredentials, AWSServices, assume_role_creds using AWS.AWSExceptions: AWSException, InvalidFileName, NoCredentials, ProtocolNotDefined using AWS.AWSMetadata: ServiceFile, @@ -50,6 +49,8 @@ function _now_formatted() return lowercase(Dates.format(now(Dates.UTC), dateformat"yyyymmdd\THHMMSSsss\Z")) end +testset_role(role_name) = "AWS.jl-$role_name" + @testset "AWS.jl" begin include("AWSExceptions.jl") include("AWSMetadataUtilities.jl") @@ -62,6 +63,7 @@ end AWS.DEFAULT_BACKEND[] = backend() include("AWS.jl") include("AWSCredentials.jl") + include("role.jl") include("issues.jl") if TEST_MINIO