Skip to content

Commit bd39b39

Browse files
authored
Create assume_role function (#638)
* Create assume_role function * Better support for role chaining * Add tests * Formatting * Set project version to 1.89.0
1 parent 1ca4e53 commit bd39b39

File tree

6 files changed

+332
-5
lines changed

6 files changed

+332
-5
lines changed

Diff for: Project.toml

+1-1
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
name = "AWS"
22
uuid = "fbe9abb3-538b-5e4e-ba9e-bc94f4f92ebc"
33
license = "MIT"
4-
version = "1.88.0"
4+
version = "1.89.0"
55

66
[deps]
77
Base64 = "2a0f44e3-6c83-55bd-87e4-b1978d98bd5f"

Diff for: src/AWS.jl

+2-1
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ export @service
1919
export _merge
2020
export AbstractAWSConfig, AWSConfig, AWSExceptions, AWSServices, Request
2121
export ec2_instance_metadata, ec2_instance_region
22-
export generate_service_url, global_aws_config, set_user_agent
22+
export assume_role, generate_service_url, global_aws_config, set_user_agent
2323
export sign!, sign_aws2!, sign_aws4!
2424
export JSONService, RestJSONService, RestXMLService, QueryService, set_features
2525

@@ -36,6 +36,7 @@ include(joinpath("utilities", "request.jl"))
3636
include(joinpath("utilities", "response.jl"))
3737
include(joinpath("utilities", "sign.jl"))
3838
include(joinpath("utilities", "downloads_backend.jl"))
39+
include(joinpath("utilities", "role.jl"))
3940

4041
include("deprecated.jl")
4142

Diff for: src/utilities/role.jl

+130
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,130 @@
1+
"""
2+
assume_role(principal::AbstractAWSConfig, role; kwargs...) -> AbstractAWSConfig
3+
4+
Assumes the IAM `role` via temporary credentials via the `principal` entity. The `principal`
5+
entity must be included in the trust policy of the `role`.
6+
7+
[Role chaining](https://docs.aws.amazon.com/IAM/latest/UserGuide/id_roles_terms-and-concepts.html#iam-term-role-chaining)
8+
must be manually specified by multiple `assume_role` calls (e.g. "role-a" has permissions to
9+
assume "role-b": `assume_role(assume_role(AWSConfig(), "role-a"), "role-b")`).
10+
11+
# Arguments
12+
- `principal::AbstractAWSConfig`: The AWS configuration and credentials of the principal
13+
entity (user or role) performing the `sts:AssumeRole` action.
14+
- `role::AbstractString`: The AWS IAM role to assume. Either a full role ARN or just the
15+
role name. If only the role name is specified the role will be assumed to reside in the
16+
same account used in the `principal` argument.
17+
18+
# Keywords
19+
- `duration::Integer` (optional): Role session duration in seconds.
20+
- `mfa_serial::AbstractString` (optional): The identification number of the MFA device that
21+
is associated with the user making the `AssumeRole` API call. Either a serial number for a
22+
hardware device ("GAHT12345678") or an ARN for a virtual device
23+
("arn:aws:iam::123456789012:mfa/user"). When specified a MFA token must be provided via
24+
`token` or an interactive prompt.
25+
- `token::AbstractString` (optional): The value provided by the MFA device. Only can be
26+
specified when `mfa_serial` is set.
27+
- `session_name::AbstractString` (optional): The unique role session name associated with
28+
this API request.
29+
"""
30+
function assume_role(principal::AWSConfig, role; kwargs...)
31+
creds = assume_role_creds(principal, role; kwargs...)
32+
return AWSConfig(creds, principal.region, principal.output, principal.max_attempts)
33+
end
34+
35+
"""
36+
assume_role(role; kwargs...) -> Function
37+
38+
Create a function that assumes the IAM `role` via a deferred principal entity, i.e. a
39+
function equivalent to `principal -> assume_role(principal, role; kwargs...)`. Useful for
40+
[role chaining](https://docs.aws.amazon.com/IAM/latest/UserGuide/id_roles_terms-and-concepts.html#iam-term-role-chaining).
41+
42+
# Examples
43+
44+
Assume "role-a" which in turn assumes "role-b":
45+
46+
```julia
47+
AWSConfig() |> assume_role("role-a") |> assume_role("role-b")
48+
```
49+
"""
50+
assume_role(role; kwargs...) = principal -> assume_role(principal, role; kwargs...)
51+
52+
"""
53+
assume_role_creds(principal, role; kwargs...) -> AWSCredentials
54+
55+
Assumes the IAM `role` via temporary credentials via the `principal` entity and returns
56+
`AWSCredentials`. Typically, end-users should use [`assume_role`](@ref) instead.
57+
58+
Details on the arguments and keywords for `assume_role_creds` can be found in the docstring
59+
for [`assume_role`](@ref).
60+
"""
61+
function assume_role_creds(
62+
principal::AbstractAWSConfig,
63+
role::AbstractString;
64+
duration::Union{Integer,Nothing}=nothing,
65+
mfa_serial::Union{AbstractString,Nothing}=nothing,
66+
token::Union{AbstractString,Nothing}=nothing,
67+
session_name::Union{AbstractString,Nothing}=nothing,
68+
)
69+
if startswith(role, "arn:aws:iam")
70+
# Avoiding unnecessary parsing the role ARN or performing an expensive API call
71+
account_id = ""
72+
role_arn = role
73+
else
74+
account_id = aws_account_number(principal)
75+
role_arn = "arn:aws:iam::$account_id:role/$role"
76+
end
77+
78+
params = Dict{String,Any}("RoleArn" => role_arn)
79+
if session_name !== nothing
80+
params["RoleSessionName"] = session_name
81+
else
82+
params["RoleSessionName"] = _role_session_name(
83+
"AWS.jl-",
84+
ENV["USER"],
85+
"-" * Dates.format(now(UTC), dateformat"yyyymmdd\THHMMSS\Z"),
86+
)
87+
end
88+
89+
if duration !== nothing
90+
params["DurationSeconds"] = duration
91+
end
92+
93+
if mfa_serial !== nothing && token !== nothing
94+
params["SerialNumber"] = mfa_serial
95+
params["TokenCode"] = token
96+
elseif mfa_serial !== nothing && token === nothing
97+
params["SerialNumber"] = mfa_serial
98+
token = Base.getpass("Enter MFA code for $mfa_serial")
99+
params["TokenCode"] = Base.shred!(token) do t
100+
read(t, String)
101+
end
102+
elseif mfa_serial === nothing && token !== nothing
103+
msg = "Keyword `token` cannot be be specified when `mfa_serial` is not set"
104+
throw(ArgumentError(msg))
105+
end
106+
107+
response = AWSServices.sts(
108+
"AssumeRole",
109+
params;
110+
aws_config=principal,
111+
feature_set=AWS.FeatureSet(; use_response_type=true),
112+
)
113+
body = parse(response)
114+
role_creds = body["AssumeRoleResult"]["Credentials"]
115+
role_user = body["AssumeRoleResult"]["AssumedRoleUser"]
116+
renew = function ()
117+
# Avoid passing the `token` into the credential renew function as it will be expired
118+
return assume_role_creds(principal, role_arn; duration, mfa_serial, session_name)
119+
end
120+
121+
return AWSCredentials(
122+
role_creds["AccessKeyId"],
123+
role_creds["SecretAccessKey"],
124+
role_creds["SessionToken"],
125+
role_user["Arn"],
126+
account_id; # May as well populate "account_number" field when we have it
127+
expiry=DateTime(rstrip(role_creds["Expiration"], 'Z')),
128+
renew,
129+
)
130+
end

Diff for: test/resources/aws_jl_test.yaml

+91-1
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,7 @@
1-
# `aws cloudformation create-stack --stack-name AWS-jl-test --template-body file://aws_jl_test.yaml --capabilities CAPABILITY_NAMED_IAM`
1+
# ```
2+
# aws cloudformation update-stack --stack-name AWS-jl-test --template-body file://aws_jl_test.yaml --capabilities CAPABILITY_NAMED_IAM --region us-east-1
3+
# ```
4+
25
---
36
AWSTemplateFormatVersion: 2010-09-09
47
Description: >-
@@ -43,6 +46,24 @@ Resources:
4346
- !Sub repo:${GitHubOrg}/${GitHubRepo}:pull_request
4447
- !Sub repo:${GitHubOrg}/${GitHubRepo}:ref:refs/heads/master
4548
- !Sub repo:${GitHubOrg}/${GitHubRepo}:ref:refs/tags/*
49+
# - Effect: Allow
50+
# Principal:
51+
# AWS: !Sub arn:aws:iam::${AWS::AccountId}:root
52+
# Action: sts:AssumeRole
53+
54+
PublicCIAssumePolicy:
55+
Type: AWS::IAM::Policy
56+
Properties:
57+
PolicyName: PublicCIAssumeRoles
58+
Roles:
59+
- !Ref PublicCIRole
60+
PolicyDocument:
61+
Version: 2012-10-17
62+
Statement:
63+
- Effect: Allow
64+
Action:
65+
- sts:AssumeRole
66+
Resource: !Sub arn:aws:iam::${AWS::AccountId}:role/*
4667

4768
StackInfoPolicy:
4869
Type: AWS::IAM::ManagedPolicy
@@ -173,3 +194,72 @@ Resources:
173194
- sqs:SendMessageBatch
174195
- sqs:SetQueueAttributes
175196
Resource: !Sub arn:aws:sqs:*:${AWS::AccountId}:aws-jl-test-*
197+
198+
###
199+
### Testset specific roles/policies
200+
###
201+
202+
AssumeRoleTestsetRole:
203+
Type: AWS::IAM::Role
204+
Properties:
205+
RoleName: !Sub ${GitHubRepo}-AssumeRoleTestset
206+
AssumeRolePolicyDocument:
207+
Version: 2012-10-17
208+
Statement:
209+
- Effect: Allow
210+
Principal:
211+
AWS: !GetAtt PublicCIRole.Arn
212+
Action: sts:AssumeRole
213+
214+
AssumeRoleTestsetPolicy:
215+
Type: AWS::IAM::Policy
216+
Properties:
217+
PolicyName: !Sub ${GitHubRepo}-AssumeRoleTestset
218+
Roles:
219+
- !Ref AssumeRoleTestsetRole
220+
PolicyDocument:
221+
Version: 2012-10-17
222+
Statement:
223+
- Effect: Allow
224+
Action: sts:AssumeRole
225+
Resource: !GetAtt RoleA.Arn
226+
227+
# No permissions are required to perform the `sts:GetCallerIdentity` action
228+
# https://docs.aws.amazon.com/STS/latest/APIReference/API_GetCallerIdentity.html
229+
230+
RoleA:
231+
Type: AWS::IAM::Role
232+
Properties:
233+
RoleName: !Sub ${GitHubRepo}-RoleA
234+
AssumeRolePolicyDocument:
235+
Version: 2012-10-17
236+
Statement:
237+
- Effect: Allow
238+
Principal:
239+
AWS: !GetAtt AssumeRoleTestsetRole.Arn
240+
Action: sts:AssumeRole
241+
242+
RoleAPolicy:
243+
Type: AWS::IAM::Policy
244+
Properties:
245+
PolicyName: !Sub ${GitHubRepo}-RoleA
246+
Roles:
247+
- !Ref RoleA
248+
PolicyDocument:
249+
Version: 2012-10-17
250+
Statement:
251+
- Effect: Allow
252+
Action: sts:AssumeRole
253+
Resource: !GetAtt RoleB.Arn
254+
255+
RoleB:
256+
Type: AWS::IAM::Role
257+
Properties:
258+
RoleName: !Sub ${GitHubRepo}-RoleB
259+
AssumeRolePolicyDocument:
260+
Version: 2012-10-17
261+
Statement:
262+
- Effect: Allow
263+
Principal:
264+
AWS: !GetAtt RoleA.Arn
265+
Action: sts:AssumeRole

Diff for: test/role.jl

+104
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
1+
function get_assumed_role(aws_config::AbstractAWSConfig=global_aws_config())
2+
r = AWSServices.sts(
3+
"GetCallerIdentity";
4+
aws_config,
5+
feature_set=AWS.FeatureSet(; use_response_type=true),
6+
)
7+
result = parse(r)
8+
arn = result["GetCallerIdentityResult"]["Arn"]
9+
m = match(r":assumed-role/(?<role>[^/]+)", arn)
10+
if m !== nothing
11+
return m["role"]
12+
else
13+
error("Caller Identity ARN is not an assumed role: $arn")
14+
end
15+
end
16+
17+
get_assumed_role(creds::AWSCredentials) = get_assumed_role(AWSConfig(; creds))
18+
19+
@testset "assume_role / assume_role_creds" begin
20+
# In order to mitigate the effects of using `assume_role` in order to test itself we'll
21+
# use the lowest-level call with as many defaults as possible.
22+
base_config = aws
23+
creds = assume_role_creds(base_config, testset_role("AssumeRoleTestset"))
24+
config = AWSConfig(; creds)
25+
@test get_assumed_role(config) == testset_role("AssumeRoleTestset")
26+
27+
role_a = testset_role("RoleA")
28+
role_b = testset_role("RoleB")
29+
30+
@testset "basic" begin
31+
creds = assume_role_creds(config, role_a)
32+
@test creds isa AWSCredentials
33+
@test creds.token != "" # Temporary credentials
34+
@test creds.renew !== nothing
35+
36+
cfg = assume_role(config, role_a)
37+
@test cfg isa AWSConfig
38+
@test cfg.credentials isa AWSCredentials
39+
@test cfg.region == config.region
40+
@test cfg.output == config.output
41+
@test cfg.max_attempts == config.max_attempts
42+
end
43+
44+
@testset "role name/ARN" begin
45+
account_id = aws_account_number(config)
46+
47+
creds = assume_role_creds(config, role_a)
48+
@test contains(creds.user_arn, r":assumed-role/" * (role_a * '/'))
49+
@test creds.account_number == account_id
50+
51+
creds = assume_role_creds(config, "arn:aws:iam::$account_id:role/$role_a")
52+
@test contains(creds.user_arn, r":assumed-role/" * (role_a * '/'))
53+
@test creds.account_number == ""
54+
end
55+
56+
@testset "duration" begin
57+
drift = Second(1)
58+
59+
creds = assume_role_creds(config, role_a; duration=nothing)
60+
t = floor(now(UTC), Second)
61+
@test t <= creds.expiry <= t + Second(3600) + drift
62+
63+
creds = assume_role_creds(config, role_a; duration=900)
64+
t = floor(now(UTC), Second)
65+
@test t <= creds.expiry <= t + Second(900) + drift
66+
end
67+
68+
@testset "session_name" begin
69+
session_prefix = "AWS.jl-" * ENV["USER"]
70+
creds = assume_role_creds(config, role_a; session_name=nothing)
71+
regex = r":assumed-role/" * (role_a * '/' * session_prefix) * r"-\d{8}T\d{6}Z$"
72+
@test contains(creds.user_arn, regex)
73+
@test get_assumed_role(creds) == role_a
74+
75+
session_name = "assume-role-session-name-testset-" * randstring(5)
76+
creds = assume_role_creds(config, role_a; session_name)
77+
regex = r":assumed-role/" * (role_a * '/' * session_name) * r"$"
78+
@test contains(creds.user_arn, regex)
79+
@test get_assumed_role(creds) == role_a
80+
end
81+
82+
@testset "renew" begin
83+
creds = assume_role_creds(config, role_a; duration=nothing)
84+
@test creds.renew isa Function
85+
@test get_assumed_role(creds) == role_a
86+
87+
new_creds = creds.renew()
88+
@test new_creds isa AWSCredentials
89+
@test get_assumed_role(new_creds) == role_a
90+
@test new_creds.access_key_id != creds.access_key_id
91+
@test new_creds.secret_key != creds.secret_key
92+
@test new_creds.expiry >= creds.expiry
93+
end
94+
95+
@testset "role chaining" begin
96+
cfg = assume_role(assume_role(config, role_a), role_b)
97+
@test get_assumed_role(cfg) == role_b
98+
99+
#! format: off
100+
cfg = config |> assume_role(role_a) |> assume_role(role_b)
101+
#! format: on
102+
@test get_assumed_role(cfg) == role_b
103+
end
104+
end

Diff for: test/runtests.jl

+4-2
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
11
using AWS
2-
using AWS: AWSCredentials
3-
using AWS: AWSServices
2+
using AWS: AWSCredentials, AWSServices, assume_role_creds
43
using AWS.AWSExceptions: AWSException, InvalidFileName, NoCredentials, ProtocolNotDefined
54
using AWS.AWSMetadata:
65
ServiceFile,
@@ -50,6 +49,8 @@ function _now_formatted()
5049
return lowercase(Dates.format(now(Dates.UTC), dateformat"yyyymmdd\THHMMSSsss\Z"))
5150
end
5251

52+
testset_role(role_name) = "AWS.jl-$role_name"
53+
5354
@testset "AWS.jl" begin
5455
include("AWSExceptions.jl")
5556
include("AWSMetadataUtilities.jl")
@@ -62,6 +63,7 @@ end
6263
AWS.DEFAULT_BACKEND[] = backend()
6364
include("AWS.jl")
6465
include("AWSCredentials.jl")
66+
include("role.jl")
6567
include("issues.jl")
6668

6769
if TEST_MINIO

0 commit comments

Comments
 (0)