From bce88f440c32fc7c5a1d81c4ba7957900eb11ff1 Mon Sep 17 00:00:00 2001 From: Curtis Vogt Date: Fri, 30 Jun 2023 16:44:37 -0500 Subject: [PATCH] MFA integration tests for `assume_role` (#639) * Incomplete MFA tests * Working MFA tests * MFA cleanup * Use single MFA device * Rename skip to offset * Move get_assumed_role to role.jl * Update time_step to time_step_window * Consumed TOTP approach * Rough multi-MFA setup * Fully working MFA setup * Rename to mfa_device_pool * Refactor setup.jl * Add setup.jl Project/Manifest * Formatting * Final changes Co-authored-by: mattBrzezinski <3.brzezinski@gmail.com> --------- Co-authored-by: mattBrzezinski <3.brzezinski@gmail.com> --- Project.toml | 4 +- test/resources/Manifest.toml | 343 ++++++++++++++++++++++++++++++++ test/resources/Project.toml | 11 + test/resources/aws_jl_test.yaml | 57 +++++- test/resources/setup.jl | 201 +++++++++++++++++++ test/resources/totp.jl | 103 ++++++++++ test/role.jl | 35 ++++ test/runtests.jl | 1 + 8 files changed, 748 insertions(+), 7 deletions(-) create mode 100644 test/resources/Manifest.toml create mode 100644 test/resources/Project.toml create mode 100755 test/resources/setup.jl create mode 100644 test/resources/totp.jl diff --git a/Project.toml b/Project.toml index d63e38fa22..51ea3388bd 100644 --- a/Project.toml +++ b/Project.toml @@ -23,6 +23,7 @@ UUIDs = "cf7118a7-6976-5b1a-9a39-7adc72f591a4" XMLDict = "228000da-037f-5747-90a9-8195ccbf91a5" [compat] +CodecBase = "0.3" Compat = "3.32, 4" GitHub = "5" HTTP = "1" @@ -37,6 +38,7 @@ XMLDict = "0.3, 0.4" julia = "1.6" [extras] +CodecBase = "6c391c72-fb7b-5838-ba82-7cfb1bcfecbf" Pkg = "44cfe95a-1eb2-52ea-b672-e2afdf69b78f" StableRNGs = "860ef19b-820b-49d6-a774-d7a799459cd3" Suppressor = "fd094767-a336-5f1f-9728-57cf17d0bbfb" @@ -44,4 +46,4 @@ Test = "8dfed614-e22c-5e08-85e1-65c5234f0b40" UUIDs = "cf7118a7-6976-5b1a-9a39-7adc72f591a4" [targets] -test = ["Pkg", "Suppressor", "StableRNGs", "Test", "UUIDs"] +test = ["CodecBase", "Pkg", "Suppressor", "StableRNGs", "Test", "UUIDs"] diff --git a/test/resources/Manifest.toml b/test/resources/Manifest.toml new file mode 100644 index 0000000000..0c1133e13b --- /dev/null +++ b/test/resources/Manifest.toml @@ -0,0 +1,343 @@ +# This file is machine-generated - editing it directly is not advised + +julia_version = "1.8.5" +manifest_format = "2.0" +project_hash = "6ebccb6ef57ae53fbe093a1a79cc50c9baa120b8" + +[[deps.AWS]] +deps = ["Base64", "Compat", "Dates", "Downloads", "GitHub", "HTTP", "IniFile", "JSON", "MbedTLS", "Mocking", "OrderedCollections", "Random", "SHA", "Sockets", "URIs", "UUIDs", "XMLDict"] +git-tree-sha1 = "e113452555312a7d220214229479045daeaa7ac6" +uuid = "fbe9abb3-538b-5e4e-ba9e-bc94f4f92ebc" +version = "1.88.0" + +[[deps.ArgTools]] +uuid = "0dad84c5-d112-42e6-8d28-ef12dabb789f" +version = "1.1.1" + +[[deps.Artifacts]] +uuid = "56f22d72-fd6d-98f1-02f0-08ddc0907c33" + +[[deps.Base64]] +uuid = "2a0f44e3-6c83-55bd-87e4-b1978d98bd5f" + +[[deps.BitFlags]] +git-tree-sha1 = "43b1a4a8f797c1cddadf60499a8a077d4af2cd2d" +uuid = "d1d4a3ce-64b1-5f1a-9ba4-7e7e69966f35" +version = "0.1.7" + +[[deps.CodecBase]] +deps = ["TranscodingStreams"] +git-tree-sha1 = "744128fbfc6fe0739085d995b1756f1856964d4c" +uuid = "6c391c72-fb7b-5838-ba82-7cfb1bcfecbf" +version = "0.3.0" + +[[deps.CodecZlib]] +deps = ["TranscodingStreams", "Zlib_jll"] +git-tree-sha1 = "9c209fb7536406834aa938fb149964b985de6c83" +uuid = "944b1d66-785c-5afd-91f1-9de20f533193" +version = "0.7.1" + +[[deps.Compat]] +deps = ["Dates", "LinearAlgebra", "UUIDs"] +git-tree-sha1 = "4e88377ae7ebeaf29a047aa1ee40826e0b708a5d" +uuid = "34da2185-b29b-5c13-b0c7-acf172513d20" +version = "4.7.0" + +[[deps.CompilerSupportLibraries_jll]] +deps = ["Artifacts", "Libdl"] +uuid = "e66e0078-7015-5450-92f7-15fbd957f2ae" +version = "1.0.1+0" + +[[deps.ConcurrentUtilities]] +deps = ["Serialization", "Sockets"] +git-tree-sha1 = "96d823b94ba8d187a6d8f0826e731195a74b90e9" +uuid = "f0e56b4a-5159-44fe-b623-3e5288b988bb" +version = "2.2.0" + +[[deps.Dates]] +deps = ["Printf"] +uuid = "ade2ca70-3891-5945-98fb-dc099432e06a" + +[[deps.Downloads]] +deps = ["ArgTools", "FileWatching", "LibCURL", "NetworkOptions"] +uuid = "f43a241f-c20a-4ad4-852c-f6b1247861c6" +version = "1.6.0" + +[[deps.ExceptionUnwrapping]] +deps = ["Test"] +git-tree-sha1 = "e90caa41f5a86296e014e148ee061bd6c3edec96" +uuid = "460bff9d-24e4-43bc-9d9f-a8973cb893f4" +version = "0.1.9" + +[[deps.ExprTools]] +git-tree-sha1 = "c1d06d129da9f55715c6c212866f5b1bddc5fa00" +uuid = "e2ba6199-217a-4e67-a87a-7c52f15ade04" +version = "0.1.9" + +[[deps.EzXML]] +deps = ["Printf", "XML2_jll"] +git-tree-sha1 = "0fa3b52a04a4e210aeb1626def9c90df3ae65268" +uuid = "8f5d6c58-4d21-5cfd-889c-e3ad7ee6a615" +version = "1.1.0" + +[[deps.FileWatching]] +uuid = "7b1f6079-737a-58dc-b8bc-7a2ca5c1b5ee" + +[[deps.GitHub]] +deps = ["Base64", "Dates", "HTTP", "JSON", "MbedTLS", "Sockets", "SodiumSeal", "URIs"] +git-tree-sha1 = "5688002de970b9eee14b7af7bbbd1fdac10c9bbe" +uuid = "bc5e4493-9b4d-5f90-b8aa-2b2bcaad7a26" +version = "5.8.2" + +[[deps.HTTP]] +deps = ["Base64", "CodecZlib", "ConcurrentUtilities", "Dates", "ExceptionUnwrapping", "Logging", "LoggingExtras", "MbedTLS", "NetworkOptions", "OpenSSL", "Random", "SimpleBufferStream", "Sockets", "URIs", "UUIDs"] +git-tree-sha1 = "2613d054b0e18a3dea99ca1594e9a3960e025da4" +uuid = "cd3eb016-35fb-5094-929b-558a96fad6f3" +version = "1.9.7" + +[[deps.IniFile]] +git-tree-sha1 = "f550e6e32074c939295eb5ea6de31849ac2c9625" +uuid = "83e8ac13-25f8-5344-8a64-a9f2b223428f" +version = "0.5.1" + +[[deps.InteractiveUtils]] +deps = ["Markdown"] +uuid = "b77e0a4c-d291-57a0-90e8-8db25a27a240" + +[[deps.IterTools]] +git-tree-sha1 = "4ced6667f9974fc5c5943fa5e2ef1ca43ea9e450" +uuid = "c8e1da08-722c-5040-9ed9-7db0dc04731e" +version = "1.8.0" + +[[deps.JLLWrappers]] +deps = ["Preferences"] +git-tree-sha1 = "abc9885a7ca2052a736a600f7fa66209f96506e1" +uuid = "692b3bcd-3c85-4b1f-b108-f13ce0eb3210" +version = "1.4.1" + +[[deps.JSON]] +deps = ["Dates", "Mmap", "Parsers", "Unicode"] +git-tree-sha1 = "31e996f0a15c7b280ba9f76636b3ff9e2ae58c9a" +uuid = "682c06a0-de6a-54ab-a142-c8b1cf79cde6" +version = "0.21.4" + +[[deps.LibCURL]] +deps = ["LibCURL_jll", "MozillaCACerts_jll"] +uuid = "b27032c2-a3e7-50c8-80cd-2d36dbcbfd21" +version = "0.6.3" + +[[deps.LibCURL_jll]] +deps = ["Artifacts", "LibSSH2_jll", "Libdl", "MbedTLS_jll", "Zlib_jll", "nghttp2_jll"] +uuid = "deac9b47-8bc7-5906-a0fe-35ac56dc84c0" +version = "7.84.0+0" + +[[deps.LibGit2]] +deps = ["Base64", "NetworkOptions", "Printf", "SHA"] +uuid = "76f85450-5226-5b5a-8eaa-529ad045b433" + +[[deps.LibSSH2_jll]] +deps = ["Artifacts", "Libdl", "MbedTLS_jll"] +uuid = "29816b5a-b9ab-546f-933c-edad1886dfa8" +version = "1.10.2+0" + +[[deps.Libdl]] +uuid = "8f399da3-3557-5675-b5ff-fb832c97cbdb" + +[[deps.Libiconv_jll]] +deps = ["Artifacts", "JLLWrappers", "Libdl", "Pkg"] +git-tree-sha1 = "c7cb1f5d892775ba13767a87c7ada0b980ea0a71" +uuid = "94ce4f54-9a6c-5748-9c1c-f9c7231a4531" +version = "1.16.1+2" + +[[deps.LinearAlgebra]] +deps = ["Libdl", "libblastrampoline_jll"] +uuid = "37e2e46d-f89d-539d-b4ee-838fcccc9c8e" + +[[deps.Logging]] +uuid = "56ddb016-857b-54e1-b83d-db4d58db5568" + +[[deps.LoggingExtras]] +deps = ["Dates", "Logging"] +git-tree-sha1 = "cedb76b37bc5a6c702ade66be44f831fa23c681e" +uuid = "e6f89c97-d47a-5376-807f-9c37f3926c36" +version = "1.0.0" + +[[deps.Markdown]] +deps = ["Base64"] +uuid = "d6f4376e-aef5-505a-96c1-9c027394607a" + +[[deps.MbedTLS]] +deps = ["Dates", "MbedTLS_jll", "MozillaCACerts_jll", "Random", "Sockets"] +git-tree-sha1 = "03a9b9718f5682ecb107ac9f7308991db4ce395b" +uuid = "739be429-bea8-5141-9913-cc70e7f3736d" +version = "1.1.7" + +[[deps.MbedTLS_jll]] +deps = ["Artifacts", "Libdl"] +uuid = "c8ffd9c3-330d-5841-b78e-0817d7145fa1" +version = "2.28.0+0" + +[[deps.Mmap]] +uuid = "a63ad114-7e13-5084-954f-fe012c677804" + +[[deps.Mocking]] +deps = ["Compat", "ExprTools"] +git-tree-sha1 = "4cc0c5a83933648b615c36c2b956d94fda70641e" +uuid = "78c3b35d-d492-501b-9361-3d52fe80e533" +version = "0.7.7" + +[[deps.MozillaCACerts_jll]] +uuid = "14a3606d-f60d-562e-9121-12d972cd8159" +version = "2022.2.1" + +[[deps.NetworkOptions]] +uuid = "ca575930-c2e3-43a9-ace4-1e988b2c1908" +version = "1.2.0" + +[[deps.OpenBLAS_jll]] +deps = ["Artifacts", "CompilerSupportLibraries_jll", "Libdl"] +uuid = "4536629a-c528-5b80-bd46-f80d51c5b363" +version = "0.3.20+0" + +[[deps.OpenSSL]] +deps = ["BitFlags", "Dates", "MozillaCACerts_jll", "OpenSSL_jll", "Sockets"] +git-tree-sha1 = "51901a49222b09e3743c65b8847687ae5fc78eb2" +uuid = "4d8831e6-92b7-49fb-bdf8-b643e874388c" +version = "1.4.1" + +[[deps.OpenSSL_jll]] +deps = ["Artifacts", "JLLWrappers", "Libdl"] +git-tree-sha1 = "cae3153c7f6cf3f069a853883fd1919a6e5bab5b" +uuid = "458c3c95-2e84-50aa-8efc-19380b2a3a95" +version = "3.0.9+0" + +[[deps.OrderedCollections]] +git-tree-sha1 = "d321bf2de576bf25ec4d3e4360faca399afca282" +uuid = "bac558e1-5e72-5ebc-8fee-abe8a469f55d" +version = "1.6.0" + +[[deps.Parsers]] +deps = ["Dates", "PrecompileTools", "UUIDs"] +git-tree-sha1 = "4b2e829ee66d4218e0cef22c0a64ee37cf258c29" +uuid = "69de0a69-1ddd-5017-9359-2bf0b02dc9f0" +version = "2.7.1" + +[[deps.Pkg]] +deps = ["Artifacts", "Dates", "Downloads", "LibGit2", "Libdl", "Logging", "Markdown", "Printf", "REPL", "Random", "SHA", "Serialization", "TOML", "Tar", "UUIDs", "p7zip_jll"] +uuid = "44cfe95a-1eb2-52ea-b672-e2afdf69b78f" +version = "1.8.0" + +[[deps.PrecompileTools]] +deps = ["Preferences"] +git-tree-sha1 = "9673d39decc5feece56ef3940e5dafba15ba0f81" +uuid = "aea7be01-6a6a-4083-8856-8a6e6704d82a" +version = "1.1.2" + +[[deps.Preferences]] +deps = ["TOML"] +git-tree-sha1 = "7eb1686b4f04b82f96ed7a4ea5890a4f0c7a09f1" +uuid = "21216c6a-2e73-6563-6e65-726566657250" +version = "1.4.0" + +[[deps.Printf]] +deps = ["Unicode"] +uuid = "de0858da-6303-5e67-8744-51eddeeeb8d7" + +[[deps.REPL]] +deps = ["InteractiveUtils", "Markdown", "Sockets", "Unicode"] +uuid = "3fa0cd96-eef1-5676-8a61-b3b8758bbffb" + +[[deps.Random]] +deps = ["SHA", "Serialization"] +uuid = "9a3f8284-a2c9-5f02-9a11-845980a1fd5c" + +[[deps.SHA]] +uuid = "ea8e919c-243c-51af-8825-aaa63cd721ce" +version = "0.7.0" + +[[deps.Serialization]] +uuid = "9e88b42a-f829-5b0c-bbe9-9e923198166b" + +[[deps.SimpleBufferStream]] +git-tree-sha1 = "874e8867b33a00e784c8a7e4b60afe9e037b74e1" +uuid = "777ac1f9-54b0-4bf8-805c-2214025038e7" +version = "1.1.0" + +[[deps.Sockets]] +uuid = "6462fe0b-24de-5631-8697-dd941f90decc" + +[[deps.SodiumSeal]] +deps = ["Base64", "Libdl", "libsodium_jll"] +git-tree-sha1 = "80cef67d2953e33935b41c6ab0a178b9987b1c99" +uuid = "2133526b-2bfb-4018-ac12-889fb3908a75" +version = "0.1.1" + +[[deps.TOML]] +deps = ["Dates"] +uuid = "fa267f1f-6049-4f14-aa54-33bafae1ed76" +version = "1.0.0" + +[[deps.Tar]] +deps = ["ArgTools", "SHA"] +uuid = "a4e569a6-e804-4fa4-b0f3-eef7a1d5b13e" +version = "1.10.1" + +[[deps.Test]] +deps = ["InteractiveUtils", "Logging", "Random", "Serialization"] +uuid = "8dfed614-e22c-5e08-85e1-65c5234f0b40" + +[[deps.TranscodingStreams]] +deps = ["Random", "Test"] +git-tree-sha1 = "9a6ae7ed916312b41236fcef7e0af564ef934769" +uuid = "3bb67fe8-82b1-5028-8e26-92a6c54297fa" +version = "0.9.13" + +[[deps.URIs]] +git-tree-sha1 = "074f993b0ca030848b897beff716d93aca60f06a" +uuid = "5c2747f8-b7ea-4ff2-ba2e-563bfd36b1d4" +version = "1.4.2" + +[[deps.UUIDs]] +deps = ["Random", "SHA"] +uuid = "cf7118a7-6976-5b1a-9a39-7adc72f591a4" + +[[deps.Unicode]] +uuid = "4ec0a83e-493e-50e2-b9ac-8f72acf5a8f5" + +[[deps.XML2_jll]] +deps = ["Artifacts", "JLLWrappers", "Libdl", "Libiconv_jll", "Pkg", "Zlib_jll"] +git-tree-sha1 = "93c41695bc1c08c46c5899f4fe06d6ead504bb73" +uuid = "02c8fc9c-b97f-50b9-bbe4-9be30ff0a78a" +version = "2.10.3+0" + +[[deps.XMLDict]] +deps = ["EzXML", "IterTools", "OrderedCollections"] +git-tree-sha1 = "d9a3faf078210e477b291c79117676fca54da9dd" +uuid = "228000da-037f-5747-90a9-8195ccbf91a5" +version = "0.4.1" + +[[deps.Zlib_jll]] +deps = ["Libdl"] +uuid = "83775a58-1f1d-513f-b197-d71354ab007a" +version = "1.2.12+3" + +[[deps.libblastrampoline_jll]] +deps = ["Artifacts", "Libdl", "OpenBLAS_jll"] +uuid = "8e850b90-86db-534c-a0d3-1478176c7d93" +version = "5.1.1+0" + +[[deps.libsodium_jll]] +deps = ["Artifacts", "JLLWrappers", "Libdl", "Pkg"] +git-tree-sha1 = "848ab3d00fe39d6fbc2a8641048f8f272af1c51e" +uuid = "a9144af2-ca23-56d9-984f-0d03f7b5ccf8" +version = "1.0.20+0" + +[[deps.nghttp2_jll]] +deps = ["Artifacts", "Libdl"] +uuid = "8e850ede-7688-5339-a07c-302acd2aaf8d" +version = "1.48.0+0" + +[[deps.p7zip_jll]] +deps = ["Artifacts", "Libdl"] +uuid = "3f19e933-33d8-53b3-aaab-bd5110c3b7a0" +version = "17.4.0+0" diff --git a/test/resources/Project.toml b/test/resources/Project.toml new file mode 100644 index 0000000000..7c29cf6b3d --- /dev/null +++ b/test/resources/Project.toml @@ -0,0 +1,11 @@ +[deps] +AWS = "fbe9abb3-538b-5e4e-ba9e-bc94f4f92ebc" +CodecBase = "6c391c72-fb7b-5838-ba82-7cfb1bcfecbf" +Dates = "ade2ca70-3891-5945-98fb-dc099432e06a" +JSON = "682c06a0-de6a-54ab-a142-c8b1cf79cde6" +SHA = "ea8e919c-243c-51af-8825-aaa63cd721ce" + +[compat] +AWS = "1.88" +CodecBase = "0.3" +JSON = "0.21" diff --git a/test/resources/aws_jl_test.yaml b/test/resources/aws_jl_test.yaml index 3c66958ae0..5fdaf36f46 100644 --- a/test/resources/aws_jl_test.yaml +++ b/test/resources/aws_jl_test.yaml @@ -1,7 +1,4 @@ -# ``` -# aws cloudformation update-stack --stack-name AWS-jl-test --template-body file://aws_jl_test.yaml --capabilities CAPABILITY_NAMED_IAM --region us-east-1 -# ``` - +# Deploy CloudFormation template via `setup.jl` --- AWSTemplateFormatVersion: 2010-09-09 Description: >- @@ -20,7 +17,6 @@ Parameters: AllowedPattern: ^[\w.-]+$ Default: AWS.jl - Resources: PublicCIRole: Type: AWS::IAM::Role @@ -223,6 +219,11 @@ Resources: - Effect: Allow Action: sts:AssumeRole Resource: !GetAtt RoleA.Arn + - Effect: Allow + Action: secretsmanager:GetSecretValue + Resource: + - !Ref MFAUserAccessKeySecret + - !Sub arn:aws:secretsmanager:${AWS::Region}:${AWS::AccountId}:secret:${GitHubRepo}-mfa-user-virtual-mfa-devices-* # No permissions are required to perform the `sts:GetCallerIdentity` action # https://docs.aws.amazon.com/STS/latest/APIReference/API_GetCallerIdentity.html @@ -236,7 +237,9 @@ Resources: Statement: - Effect: Allow Principal: - AWS: !GetAtt AssumeRoleTestsetRole.Arn + AWS: + - !GetAtt AssumeRoleTestsetRole.Arn + - !GetAtt MFAUser.Arn Action: sts:AssumeRole RoleAPolicy: @@ -263,3 +266,45 @@ Resources: Principal: AWS: !GetAtt RoleA.Arn Action: sts:AssumeRole + + # !!! Important !!! + # Be extremely careful to limit the access of the MFAUser such that if the + # access keys for this user ever leaked there would be no harm done. + + MFAUser: + Type: AWS::IAM::User + Properties: + UserName: !Sub ${GitHubRepo}-mfa-user + + MFAUserAccessKey: + Type: AWS::IAM::AccessKey + Properties: + UserName: !Ref MFAUser + + MFAUserPolicy: + Type: AWS::IAM::Policy + Properties: + PolicyName: ManageMFA + Users: + - !Ref MFAUser + PolicyDocument: + Version: 2012-10-17 + Statement: + # Deny all actions when MFA is not passed in. A more restrictive version of: + # https://repost.aws/knowledge-center/mfa-iam-user-aws-cli + - Effect: Deny + NotAction: + - sts:GetCallerIdentity + Resource: "*" + Condition: + BoolIfExists: + aws:MultiFactorAuthPresent: false + - Effect: Allow + Action: sts:AssumeRole + Resource: !GetAtt RoleA.Arn + + MFAUserAccessKeySecret: + Type: AWS::SecretsManager::Secret + Properties: + Name: !Sub ${GitHubRepo}-mfa-user-credentials + SecretString: !Sub '{"username": "${MFAUser}", "access_key_id": "${MFAUserAccessKey}", "secret_access_key": "${MFAUserAccessKey.SecretAccessKey}"}' diff --git a/test/resources/setup.jl b/test/resources/setup.jl new file mode 100755 index 0000000000..810d22275f --- /dev/null +++ b/test/resources/setup.jl @@ -0,0 +1,201 @@ +#!/usr/bin/env julia --project + +# Using AWS.jl to bootstrap AWS.jl test resources. + +using AWS +using AWS: AWSException +using JSON + +@service CloudFormation use_response_type = true +@service IAM use_response_type = true +# TODO: Support PascalCase, https://github.com/JuliaCloud/AWS.jl/issues/642 +@service Secrets_Manager use_response_type = true + +global_aws_config(; region="us-east-1") + +include("totp.jl") + +function create_or_update_stack(args...; kwargs...) + response = nothing + result_key = nothing + try + response = CloudFormation.update_stack(args...; kwargs...) + result_key = "UpdateStackResult" + catch e + if ( + e isa AWSException && + e.code == "ValidationError" && + e.message == "No updates are to be performed." + ) + nothing + elseif ( + e isa AWSException && + e.code == "ValidationError" && + contains(e.message, r"^Stack .* does not exist$") + ) + response = CloudFormation.create_stack(args...; kwargs...) + result_key = "CreateStackResult" + else + rethrow() + end + end + + return response, result_key +end + +function create_or_update_secret(secret_id, params) + secret_exists = try + Secrets_Manager.get_secret_value(secret_id) + true + catch e + if e isa AWSException && e.code == "ResourceNotFoundException" + false + else + rethrow() + end + end + + r = if !secret_exists + Secrets_Manager.create_secret(secret_id, params) + else + Secrets_Manager.update_secret(secret_id, params) + end + + return r +end + +# TODO: Add timeout +function wait_for_user_to_exist(user_name) + while true + try + IAM.get_user(Dict("UserName" => user_name)) + break + catch e + if e isa AWSException && e.code == "NoSuchEntity" + sleep(5) + continue + else + rethrow() + end + end + end + + return nothing +end + +# Create multiple MFA devices for the `MFAUser`. Utilizing virtual MFA devices for our AWS.jl +# integration tests proved challenging for the following reasons: +# +# 1. A TOTP code can only be used once. +# 2. MFA devices can only be associated with users. See `iam:EnableMFADevice`: https://docs.aws.amazon.com/IAM/latest/APIReference/API_EnableMFADevice.html +# 3. Up to 8 MFA devices can be associated win a single user: https://docs.aws.amazon.com/IAM/latest/UserGuide/id_credentials_mfa_enable_virtual.html#replace-virt-mfa +# 4. AWS CloudFormation supports creating MFA devices and even assocaiting the device with a +# user but doesn't provide access to the seed. See `AWS::IAM::VirtualMFADevice`: https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-iam-role.html#aws-resource-iam-role-return-values +# 5. There is a lag between when you associating an MFA device with a user and when you can first use it (~10 seconds) +# +# When running integration tests in parallel having TOTP codes be consumed or having a +# limited amount of MFA devices per user can cause unwanted throttling when running tests. +# To mitigate this issue the following algorithm is employed: +# +# As part of our resource setup we'll create and associate 8 virtual MFA devices with our +# AWS user. When running a test that requires the MFA user iterate through a randomized list +# of the MFA devices and attempt the API call with the associated TOTP. If the TOTP has been +# consumed we'll try the next MFA device in the list until all MFA devices have been +# attempted. If all TOTPs have been consumed we'll wait until th next time window and try +# again with a new randomized list of MFA devices. +# +# The primary advantage of this approach is that it allows up to 8 API calls to occur +# concurrently during the same time window. Concurrent integration tests will only be +# throttled if all TOTP tokens have been consumed. +function create_or_update_mfa_devices(; user_name, secret_id, num_devices=8) + # Assumes user exists + # TODO: Should be `list_mfa_devices` instead of `list_mfadevices` + r = IAM.list_mfadevices(Dict("UserName" => user_name)) + existing_mfa_devices = get(parse(r)["ListMFADevicesResult"]["MFADevices"], "member", []) + + # When only a single MFA device is associated with the `user_name` then an + # `AbstractDict` will be returned instead of an `AbstractVector`. + if existing_mfa_devices isa AbstractDict + existing_mfa_devices = [existing_mfa_devices] + end + + if !isempty(existing_mfa_devices) + @info "Deleting MFA devices for $user_name" + for mfa_device in existing_mfa_devices + mfa_serial = mfa_device["SerialNumber"] + IAM.deactivate_mfadevice(mfa_serial, user_name) + IAM.delete_virtual_mfadevice(mfa_serial) + end + end + + # Under certain conditions (such as manually deleting a stack) the user may no longer + # exist but the MFA devices we want to create + mfa_device_names = ["$user_name-$i" for i in 1:8] + account_id = aws_account_number(AWSConfig()) + for mfa_device_name in mfa_device_names + mfa_serial = "arn:aws:iam::$account_id:mfa/$mfa_device_name" + try + IAM.delete_virtual_mfadevice(mfa_serial) + @warn "Deleting orphaned MFA device: $mfa_serial" + catch e + if e isa AWSException && e.code == "NoSuchEntity" + nothing + else + rethrow() + end + end + end + + @info "Creating $num_devices MFA devices for $user_name" + mfa_devices = NamedTuple{(:mfa_serial, :seed),Tuple{String,String}}[] + for mfa_device_name in mfa_device_names + r = IAM.create_virtual_mfadevice(mfa_device_name) + mfa_device = parse(r)["CreateVirtualMFADeviceResult"]["VirtualMFADevice"] + mfa_serial = mfa_device["SerialNumber"] + seed = String(transcode(Base64Decoder(), mfa_device["Base32StringSeed"])) + + # When a human sets up an virtual MFA device they prompted to enter "two consecutive + # authentication codes". Usually one would enter the currency OTP code and wait for + # next code to be generated. Entering the current and future OTP codes could result in + # issues. + # https://aws.amazon.com/blogs/security/how-to-enable-mfa-protection-on-your-aws-api-calls/ + # TODO: Argument ordering here is horrible + IAM.enable_mfadevice(totp(seed; offset=-1), totp(seed), mfa_serial, user_name) + + push!(mfa_devices, (; mfa_serial, seed)) + end + + @info "Storing MFA device details" + return create_or_update_secret( + secret_id, Dict("SecretString" => JSON.json(mfa_devices)) + ) +end + +if @__FILE__() == abspath(PROGRAM_FILE) + stack_name = "AWS-jl-test" + prefix = "AWS.jl" + stack_params = Dict("GitHubRepo" => prefix) + template_body = read("aws_jl_test.yaml", String) + + @info "Creating/updating stack: $stack_name" + parameters = [ + Dict("ParameterKey" => k, "ParameterValue" => v) for (k, v) in stack_params + ] + create_or_update_stack( + stack_name, + Dict( + "Capabilities" => ["CAPABILITY_NAMED_IAM"], + "TemplateBody" => template_body, + "Parameters" => parameters, + ), + ) + + # When the stack is first created we need to wait for the user to be created + mfa_user = "$prefix-mfa-user" + @info "Waiting for $mfa_user" + wait_for_user_to_exist(mfa_user) + + create_or_update_mfa_devices(; + user_name=mfa_user, secret_id="$prefix-mfa-user-virtual-mfa-devices" + ) +end diff --git a/test/resources/totp.jl b/test/resources/totp.jl new file mode 100644 index 0000000000..d6f08c01fe --- /dev/null +++ b/test/resources/totp.jl @@ -0,0 +1,103 @@ +using CodecBase: Base32Decoder, Base64Decoder, transcode +using Dates: UTC, now +using SHA: hmac_sha1 + +# As defined in https://datatracker.ietf.org/doc/html/rfc4226#section-5 +# Using fixed number of digits (6) +function hotp(k, c) + digits = 6 + hs = hmac_sha1(k, c) + dbc1 = dynamic_truncation(hs) + otp = Int32(dbc1 % 10^digits) + return lpad(otp, digits, '0') +end + +function dynamic_truncation(hmac_result::Vector{UInt8}) + offset = hmac_result[20] & 0x0f # lower 4-bits + return ( + UInt32(hmac_result[offset + 1] & 0x7f) << 24 | + UInt32(hmac_result[offset + 2] & 0xff) << 16 | + UInt32(hmac_result[offset + 3] & 0xff) << 8 | + UInt32(hmac_result[offset + 4] & 0xff) + ) # big-endian +end + +# As defined in https://datatracker.ietf.org/doc/html/rfc6238#section-4 +function totp(k::Vector{UInt8}; duration=30, offset=0) + t = time_step_window(; duration) + c = reinterpret(UInt8, [hton(t + offset)]) # Convert to big-endian + return hotp(k, c) +end + +totp(k::AbstractString; kwargs...) = totp(transcode(Base32Decoder(), k); kwargs...) + +function consumed_totp(k; duration=30, offset=0) + last_window = 0 + function () + t = time() + window = time_step_window(; duration, t) + + if window <= last_window + sleep(duration - (t % duration) + 1) + window += 1 + end + + last_window = window + return totp(k; duration, offset) + end +end + +""" + time_step_window(; duration=30, t=time(), t0=0) -> Int + +# Keywords +- `duration::Integer`: Time step in seconds. +- `t::Number=time()`: Number of seconds since midnight UTC of January 1, 1970 (UNIX epoch). +- `t0::Number=0`: UNIX time to start counting time steps (default 0 is the UNIX epoch). +""" +time_step_window(; duration=30, t=time(), t0=0) = div(floor(Int64, t - t0), duration) + +# Utilize all MFA devices associated with a user in order to reduce throttling due to TOTP +# tokens being consumed. +function mfa_device_pool(f, mfa_devices; duration=30, max_windows=3, debug=false) + num_windows = 0 + while num_windows < max_windows + num_windows += 1 + + # Attempt to authenticate with each MFA device associated with the user until one + # succeeds. If an invalid MFA OTP error is found then the OTP has been already + # consumed. + for d in shuffle(mfa_devices) + token = totp(d.seed; duration) + debug && println("$(now(UTC))Z - $(d.mfa_serial) - $token") + try + return f(d.mfa_serial, token) + catch e + # Examples of MFA token failures to retry: + # "MultiFactorAuthentication failed with invalid MFA one time pass code." + # "MultiFactorAuthentication failed, unable to validate MFA code. Please verify your MFA serial number is valid and associated with this user." + if ( + e isa AWSException && + contains(e.message, "MultiFactorAuthentication failed") + ) + debug && println("MFA token has been consumed") + continue + else + rethrow() + end + end + end + + # Wait until the next time step window as the MFA device's OTP codes have been + # consumed for this window. + debug && println("All MFA tokens have been consumed. Waiting for next window...") + sleep(duration - (time() % duration) + 1) + end + + error( + "Unable to find a working TOTP token after $(length(mfa_devices) * max_windows) " * + "attempts over $(duration * (max_windows - 1)) seconds.", + ) + + return nothing +end diff --git a/test/role.jl b/test/role.jl index 47cb471914..6e407edc98 100644 --- a/test/role.jl +++ b/test/role.jl @@ -16,6 +16,29 @@ end get_assumed_role(creds::AWSCredentials) = get_assumed_role(AWSConfig(; creds)) +function mfa_user_credentials(config::AbstractAWSConfig) + r = AWSServices.secrets_manager( + "GetSecretValue", + Dict("SecretId" => "AWS.jl-mfa-user-credentials"); + aws_config=config, + feature_set=AWS.FeatureSet(; use_response_type=true), + ) + json = JSON.parse(parse(r)["SecretString"]) + mfa_user_creds = AWSCredentials(json["access_key_id"], json["secret_access_key"]) + mfa_user_cfg = AWSConfig(; creds=mfa_user_creds) + + r = AWSServices.secrets_manager( + "GetSecretValue", + Dict("SecretId" => "AWS.jl-mfa-user-virtual-mfa-devices"); + aws_config=config, + feature_set=AWS.FeatureSet(; use_response_type=true), + ) + json = JSON.parse(parse(r)["SecretString"]) + mfa_devices = [(; mfa_serial=d["mfa_serial"], seed=d["seed"]) for d in json] + + return mfa_user_cfg, mfa_devices +end + @testset "_whoami" begin user = AWS._whoami() @test user isa AbstractString @@ -87,6 +110,18 @@ end @test get_assumed_role(creds) == role_a end + @testset "mfa_serial / token" begin + mfa_user_cfg, mfa_devices = mfa_user_credentials(config) + + # User policy should deny "sts:AssumeRole" when MFA is not present. + @test_throws AWSException assume_role_creds(mfa_user_cfg, role_a) + + creds = mfa_device_pool(mfa_devices) do mfa_serial, token + assume_role_creds(mfa_user_cfg, role_a; mfa_serial, token) + end + @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 diff --git a/test/runtests.jl b/test/runtests.jl index af8aa44ca6..08f078a042 100644 --- a/test/runtests.jl +++ b/test/runtests.jl @@ -38,6 +38,7 @@ using StableRNGs Mocking.activate() include("patch.jl") +include("resources/totp.jl") const TEST_MINIO = begin all(k -> haskey(ENV, k), ("MINIO_ACCESS_KEY", "MINIO_SECRET_KEY", "MINIO_REGION_NAME"))