Skip to content

Commit

Permalink
Create assume_role function (#638)
Browse files Browse the repository at this point in the history
* Create assume_role function

* Better support for role chaining

* Add tests

* Formatting

* Set project version to 1.89.0
  • Loading branch information
omus authored Jun 26, 2023
1 parent 1ca4e53 commit bd39b39
Show file tree
Hide file tree
Showing 6 changed files with 332 additions and 5 deletions.
2 changes: 1 addition & 1 deletion Project.toml
Original file line number Diff line number Diff line change
@@ -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"
Expand Down
3 changes: 2 additions & 1 deletion src/AWS.jl
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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")

Expand Down
130 changes: 130 additions & 0 deletions src/utilities/role.jl
Original file line number Diff line number Diff line change
@@ -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
92 changes: 91 additions & 1 deletion test/resources/aws_jl_test.yaml
Original file line number Diff line number Diff line change
@@ -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: >-
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
104 changes: 104 additions & 0 deletions test/role.jl
Original file line number Diff line number Diff line change
@@ -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/(?<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
6 changes: 4 additions & 2 deletions test/runtests.jl
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -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")
Expand All @@ -62,6 +63,7 @@ end
AWS.DEFAULT_BACKEND[] = backend()
include("AWS.jl")
include("AWSCredentials.jl")
include("role.jl")
include("issues.jl")

if TEST_MINIO
Expand Down

2 comments on commit bd39b39

@omus
Copy link
Member Author

@omus omus commented on bd39b39 Jun 26, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@JuliaRegistrator
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Registration pull request created: JuliaRegistries/General/86290

After the above pull request is merged, it is recommended that a tag is created on this repository for the registered package version.

This will be done automatically if the Julia TagBot GitHub Action is installed, or can be done manually through the github interface, or via:

git tag -a v1.89.0 -m "<description of version>" bd39b395914bfdec3f2a4025864ebb8792902849
git push origin v1.89.0

Please sign in to comment.