From fcfe267a3fdff97f13565e3462da74f844fc91cb Mon Sep 17 00:00:00 2001 From: Damian Debkowski Date: Fri, 22 Sep 2023 12:26:08 -0700 Subject: [PATCH] refactor: switch from awsutil to awsutilv2 (#34) * fix(storage): Storage Assume Role Support This commit introduces fixes to enable operators to use AWS dynamic credentials on the storage side of this plugin, namely validation, improved credential lifecycle management to allow for storage buckets to be updated from static to dynamic credentials and vice versa, and a new way to determine credential types. It also introduces new tests for dynamic credential usage for the various functions. * fix(storage): Static rotated creds being deleted on plugin err On Storage Bucket update, when we determine if the credentials are to be updated to new ones, we call `credState.ReplaceCreds`. This call, if the old credentials were managed by us (rotated at create time), will delete the old credentials and set the new ones onto credState. We called this function without validating if the incoming credentials are valid. This could get the bucket in a state where where, while we'd return an update error if the incoming credentials weren't working with S3 (err in the dry run process that happens at the end of the update), we'd also silently delete the old credentials the bucket used, rendering the bucket completely broken. --------- Co-authored-by: Hugo --- go.mod | 42 +- go.sum | 93 ++- internal/credential/attributes.go | 44 +- internal/credential/attributes_test.go | 31 +- internal/credential/state.go | 119 ++-- internal/credential/state_test.go | 182 ++--- internal/credential/testing.go | 19 +- plugin/service/host/plugin.go | 35 +- plugin/service/host/plugin_test.go | 6 +- plugin/service/host/state.go | 75 +- plugin/service/host/state_test.go | 4 +- plugin/service/storage/plugin.go | 149 ++-- plugin/service/storage/plugin_test.go | 902 +++++++++++++++++++++++-- plugin/service/storage/state.go | 93 ++- plugin/service/storage/state_test.go | 12 +- plugin/service/storage/testing.go | 12 +- testing/e2e_storage_test.go | 2 +- testing/testing.go | 14 +- 18 files changed, 1319 insertions(+), 515 deletions(-) diff --git a/go.mod b/go.mod index 1091aeb..93f3bdc 100644 --- a/go.mod +++ b/go.mod @@ -4,37 +4,38 @@ go 1.20 require ( github.com/aws/aws-sdk-go v1.44.80 - github.com/aws/aws-sdk-go-v2 v1.18.0 - github.com/aws/aws-sdk-go-v2/config v1.18.19 - github.com/aws/aws-sdk-go-v2/credentials v1.13.18 + github.com/aws/aws-sdk-go-v2 v1.20.1 github.com/aws/aws-sdk-go-v2/service/ec2 v1.99.0 - github.com/aws/aws-sdk-go-v2/service/s3 v1.31.1 - github.com/aws/smithy-go v1.13.5 + github.com/aws/aws-sdk-go-v2/service/iam v1.22.2 + github.com/aws/aws-sdk-go-v2/service/s3 v1.38.2 + github.com/aws/aws-sdk-go-v2/service/sts v1.21.2 + github.com/aws/smithy-go v1.14.1 github.com/google/go-cmp v0.5.9 github.com/google/uuid v1.3.0 github.com/hashicorp/boundary/sdk v0.0.33 - github.com/hashicorp/go-secure-stdlib/awsutil v0.2.2 + github.com/hashicorp/go-secure-stdlib/awsutil/v2 v2.0.0 github.com/hashicorp/terraform-json v0.14.0 github.com/mitchellh/mapstructure v1.5.0 - github.com/stretchr/testify v1.8.2 + github.com/stretchr/testify v1.8.4 google.golang.org/grpc v1.53.0 google.golang.org/protobuf v1.28.1 ) require ( - github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.4.10 // indirect - github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.13.1 // indirect - github.com/aws/aws-sdk-go-v2/internal/configsources v1.1.33 // indirect - github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.4.27 // indirect - github.com/aws/aws-sdk-go-v2/internal/ini v1.3.32 // indirect - github.com/aws/aws-sdk-go-v2/internal/v4a v1.0.23 // indirect - github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.9.11 // indirect - github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.1.26 // indirect - github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.9.27 // indirect - github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.14.0 // indirect - github.com/aws/aws-sdk-go-v2/service/sso v1.12.6 // indirect - github.com/aws/aws-sdk-go-v2/service/ssooidc v1.14.6 // indirect - github.com/aws/aws-sdk-go-v2/service/sts v1.18.7 // indirect + github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.4.12 // indirect + github.com/aws/aws-sdk-go-v2/config v1.18.33 // indirect + github.com/aws/aws-sdk-go-v2/credentials v1.13.32 // indirect + github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.13.8 // indirect + github.com/aws/aws-sdk-go-v2/internal/configsources v1.1.38 // indirect + github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.4.32 // indirect + github.com/aws/aws-sdk-go-v2/internal/ini v1.3.39 // indirect + github.com/aws/aws-sdk-go-v2/internal/v4a v1.1.1 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.9.13 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.1.33 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.9.32 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.15.1 // indirect + github.com/aws/aws-sdk-go-v2/service/sso v1.13.2 // indirect + github.com/aws/aws-sdk-go-v2/service/ssooidc v1.15.2 // indirect github.com/davecgh/go-spew v1.1.1 // indirect github.com/fatih/color v1.14.1 // indirect github.com/golang/protobuf v1.5.2 // indirect @@ -53,7 +54,6 @@ require ( github.com/mitchellh/copystructure v1.2.0 // indirect github.com/mitchellh/pointerstructure v1.2.1 // indirect github.com/mitchellh/reflectwalk v1.0.2 // indirect - github.com/pkg/errors v0.9.1 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect github.com/zclconf/go-cty v1.10.0 // indirect golang.org/x/crypto v0.8.0 // indirect diff --git a/go.sum b/go.sum index 08e5d0e..ddf0c17 100644 --- a/go.sum +++ b/go.sum @@ -4,51 +4,53 @@ github.com/Masterminds/sprig/v3 v3.2.3 h1:eL2fZNezLomi0uOLqjQoN6BfsDD+fyLtgbJMAj github.com/apparentlymart/go-textseg v1.0.0/go.mod h1:z96Txxhf3xSFMPmb5X/1W05FF/Nj9VFpLOpjS5yuumk= github.com/apparentlymart/go-textseg/v13 v13.0.0/go.mod h1:ZK2fH7c4NqDTLtiYLvIkEghdlcqw7yxLeM89kiTRPUo= github.com/armon/go-radix v1.0.0 h1:F4z6KzEeeQIMeLFa97iZU6vupzoecKdU5TX24SNppXI= -github.com/aws/aws-sdk-go v1.34.0/go.mod h1:5zCpMtNQVjRREroY7sYe8lOMRSxkhG6MZveU8YkpAk0= github.com/aws/aws-sdk-go v1.44.80 h1:jEXGecSgPdvM5KnyDsSgFhZSm7WwaTp4h544Im4SfhI= github.com/aws/aws-sdk-go v1.44.80/go.mod h1:y4AeaBuwd2Lk+GepC1E9v0qOiTws0MIWAX4oIKwKHZo= -github.com/aws/aws-sdk-go-v2 v1.17.7/go.mod h1:uzbQtefpm44goOPmdKyAlXSNcwlRgF3ePWVW6EtJvvw= -github.com/aws/aws-sdk-go-v2 v1.18.0 h1:882kkTpSFhdgYRKVZ/VCgf7sd0ru57p2JCxz4/oN5RY= github.com/aws/aws-sdk-go-v2 v1.18.0/go.mod h1:uzbQtefpm44goOPmdKyAlXSNcwlRgF3ePWVW6EtJvvw= -github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.4.10 h1:dK82zF6kkPeCo8J1e+tGx4JdvDIQzj7ygIoLg8WMuGs= -github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.4.10/go.mod h1:VeTZetY5KRJLuD/7fkQXMU6Mw7H5m/KP2J5Iy9osMno= -github.com/aws/aws-sdk-go-v2/config v1.18.19 h1:AqFK6zFNtq4i1EYu+eC7lcKHYnZagMn6SW171la0bGw= -github.com/aws/aws-sdk-go-v2/config v1.18.19/go.mod h1:XvTmGMY8d52ougvakOv1RpiTLPz9dlG/OQHsKU/cMmY= -github.com/aws/aws-sdk-go-v2/credentials v1.13.18 h1:EQMdtHwz0ILTW1hoP+EwuWhwCG1hD6l3+RWFQABET4c= -github.com/aws/aws-sdk-go-v2/credentials v1.13.18/go.mod h1:vnwlwjIe+3XJPBYKu1et30ZPABG3VaXJYr8ryohpIyM= -github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.13.1 h1:gt57MN3liKiyGopcqgNzJb2+d9MJaKT/q1OksHNXVE4= -github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.13.1/go.mod h1:lfUx8puBRdM5lVVMQlwt2v+ofiG/X6Ms+dy0UkG/kXw= -github.com/aws/aws-sdk-go-v2/internal/configsources v1.1.31/go.mod h1:QT0BqUvX1Bh2ABdTGnjqEjvjzrCfIniM9Sc8zn9Yndo= -github.com/aws/aws-sdk-go-v2/internal/configsources v1.1.33 h1:kG5eQilShqmJbv11XL1VpyDbaEJzWxd4zRiCG30GSn4= +github.com/aws/aws-sdk-go-v2 v1.20.1 h1:rZBf5DWr7YGrnlTK4kgDQGn1ltqOg5orCYb/UhOFZkg= +github.com/aws/aws-sdk-go-v2 v1.20.1/go.mod h1:NU06lETsFm8fUC6ZjhgDpVBcGZTFQ6XM+LZWZxMI4ac= +github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.4.12 h1:lN6L3LrYHeZ6xCxaIYtoWCx4GMLk4nRknsh29OMSqHY= +github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.4.12/go.mod h1:TDCkEAkMTXxTs0oLBGBKpBZbk3NLh8EvAfF0Q3x8/0c= +github.com/aws/aws-sdk-go-v2/config v1.18.33 h1:JKcw5SFxFW/rpM4mOPjv0VQ11E2kxW13F3exWOy7VZU= +github.com/aws/aws-sdk-go-v2/config v1.18.33/go.mod h1:hXO/l9pgY3K5oZJldamP0pbZHdPqqk+4/maa7DSD3cA= +github.com/aws/aws-sdk-go-v2/credentials v1.13.32 h1:lIH1eKPcCY1ylR4B6PkBGRWMHO3aVenOKJHWiS4/G2w= +github.com/aws/aws-sdk-go-v2/credentials v1.13.32/go.mod h1:lL8U3v/Y79YRG69WlAho0OHIKUXCyFvSXaIvfo81sls= +github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.13.8 h1:DK/9C+UN/X+1+Wm8pqaDksQr2tSLzq+8X1/rI/ZxKEQ= +github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.13.8/go.mod h1:ce7BgLQfYr5hQFdy67oX2svto3ufGtm6oBvmsHScI1Q= github.com/aws/aws-sdk-go-v2/internal/configsources v1.1.33/go.mod h1:7i0PF1ME/2eUPFcjkVIwq+DOygHEoK92t5cDqNgYbIw= -github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.4.25/go.mod h1:zBHOPwhBc3FlQjQJE/D3IfPWiWaQmT06Vq9aNukDo0k= -github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.4.27 h1:vFQlirhuM8lLlpI7imKOMsjdQLuN9CPi+k44F/OFVsk= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.1.38 h1:c8ed/T9T2K5I+h/JzmF5tpI46+OODQ74dzmdo+QnaMg= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.1.38/go.mod h1:qggunOChCMu9ZF/UkAfhTz25+U2rLVb3ya0Ua6TTfCA= github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.4.27/go.mod h1:UrHnn3QV/d0pBZ6QBAEQcqFLf8FAzLmoUfPVIueOvoM= -github.com/aws/aws-sdk-go-v2/internal/ini v1.3.32 h1:p5luUImdIqywn6JpQsW3tq5GNOxKmOnEpybzPx+d1lk= -github.com/aws/aws-sdk-go-v2/internal/ini v1.3.32/go.mod h1:XGhIBZDEgfqmFIugclZ6FU7v75nHhBDtzuB4xB/tEi4= -github.com/aws/aws-sdk-go-v2/internal/v4a v1.0.23 h1:DWYZIsyqagnWL00f8M/SOr9fN063OEQWn9LLTbdYXsk= -github.com/aws/aws-sdk-go-v2/internal/v4a v1.0.23/go.mod h1:uIiFgURZbACBEQJfqTZPb/jxO7R+9LeoHUFudtIdeQI= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.4.32 h1:hNeAAymUY5gu11WrrmFb3CVIp9Dar9hbo44yzzcQpzA= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.4.32/go.mod h1:0ZXSqrty4FtQ7p8TEuRde/SZm9X05KT18LAUlR40Ln0= +github.com/aws/aws-sdk-go-v2/internal/ini v1.3.39 h1:fc0ukRAiP1syoSGZYu+DaE+FulSYhTiJ8WpVu5jElU4= +github.com/aws/aws-sdk-go-v2/internal/ini v1.3.39/go.mod h1:WLAW8PT7+JhjZfLSWe7WEJaJu0GNo0cKc2Zyo003RBs= +github.com/aws/aws-sdk-go-v2/internal/v4a v1.1.1 h1:vUh7dBFNS3oFCtVv6CiYKh5hP9ls8+kIpKLeFruIBLk= +github.com/aws/aws-sdk-go-v2/internal/v4a v1.1.1/go.mod h1:sFMeinkhj/SZKQM8BxtvNtSPjJEo0Xrz+w3g2e4FSKI= github.com/aws/aws-sdk-go-v2/service/ec2 v1.99.0 h1:NXi4pNJWjAaiI56P1Rl8DC9A4jMNRE00WNBsDua5WRg= github.com/aws/aws-sdk-go-v2/service/ec2 v1.99.0/go.mod h1:L3ZT0N/vBsw77mOAawXmRnREpEjcHd2v5Hzf7AkIH8M= -github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.9.11 h1:y2+VQzC6Zh2ojtV2LoC0MNwHWc6qXv/j2vrQtlftkdA= -github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.9.11/go.mod h1:iV4q2hsqtNECrfmlXyord9u4zyuFEJX9eLgLpSPzWA8= -github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.1.26 h1:CeuSeq/8FnYpPtnuIeLQEEvDv9zUjneuYi8EghMBdwQ= -github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.1.26/go.mod h1:2UqAAwMUXKeRkAHIlDJqvMVgOWkUi/AUXPk/YIe+Dg4= -github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.9.25/go.mod h1:/95IA+0lMnzW6XzqYJRpjjsAbKEORVeO0anQqjd2CNU= -github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.9.27 h1:0iKliEXAcCa2qVtRs7Ot5hItA2MsufrphbRFlz1Owxo= +github.com/aws/aws-sdk-go-v2/service/iam v1.22.2 h1:DPFxx/6Zwes/MiadlDteVqDKov7yQ5v9vuwfhZuJm1s= +github.com/aws/aws-sdk-go-v2/service/iam v1.22.2/go.mod h1:cQTMNdo/Z5t1DDRsUnx0a2j6cPnytMBidUYZw2zks28= +github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.9.13 h1:iV/W5OMBys+66OeXJi/7xIRrKZNsu0ylsLGu+6nbmQE= +github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.9.13/go.mod h1:ReJb6xYmtGyu9KoFtRreWegbN9dZqvZIIv4vWnhcsyI= +github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.1.33 h1:QviNkc+vGSuEHx8P+pVNKOdWLXBPIwMFv7p0fphgE4U= +github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.1.33/go.mod h1:fABTUmOrAgAalG2i9WJpjBvlnk7UK8YmnYaxN+Q2CwE= github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.9.27/go.mod h1:EOwBD4J4S5qYszS5/3DpkejfuK+Z5/1uzICfPaZLtqw= -github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.14.0 h1:e2ooMhpYGhDnBfSvIyusvAwX7KexuZaHbQY2Dyei7VU= -github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.14.0/go.mod h1:bh2E0CXKZsQN+faiKVqC40vfNMAWheoULBCnEgO9K+8= -github.com/aws/aws-sdk-go-v2/service/s3 v1.31.1 h1:PJH4I+qYjPXclKRbVCW47iYUvtXEh1u6YmDhn5J8VQE= -github.com/aws/aws-sdk-go-v2/service/s3 v1.31.1/go.mod h1:ncltU6n4Nof5uJttDtcNQ537uNuwYqsZZQcpkd2/GUQ= -github.com/aws/aws-sdk-go-v2/service/sso v1.12.6 h1:5V7DWLBd7wTELVz5bPpwzYy/sikk0gsgZfj40X+l5OI= -github.com/aws/aws-sdk-go-v2/service/sso v1.12.6/go.mod h1:Y1VOmit/Fn6Tz1uFAeCO6Q7M2fmfXSCLeL5INVYsLuY= -github.com/aws/aws-sdk-go-v2/service/ssooidc v1.14.6 h1:B8cauxOH1W1v7rd8RdI/MWnoR4Ze0wIHWrb90qczxj4= -github.com/aws/aws-sdk-go-v2/service/ssooidc v1.14.6/go.mod h1:Lh/bc9XUf8CfOY6Jp5aIkQtN+j1mc+nExc+KXj9jx2s= -github.com/aws/aws-sdk-go-v2/service/sts v1.18.7 h1:bWNgNdRko2x6gqa0blfATqAZKZokPIeM1vfmQt2pnvM= -github.com/aws/aws-sdk-go-v2/service/sts v1.18.7/go.mod h1:JuTnSoeePXmMVe9G8NcjjwgOKEfZ4cOjMuT2IBT/2eI= -github.com/aws/smithy-go v1.13.5 h1:hgz0X/DX0dGqTYpGALqXJoRKRj5oQ7150i5FdTePzO8= +github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.9.32 h1:dGAseBFEYxth10V23b5e2mAS+tX7oVbfYHD6dnDdAsg= +github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.9.32/go.mod h1:4jwAWKEkCR0anWk5+1RbfSg1R5Gzld7NLiuaq5bTR/Y= +github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.15.1 h1:PT6PBCycRwhpEW5hJnRiceCeoWJ+r3bdgXtV+VKG7Pk= +github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.15.1/go.mod h1:TqoxCLwT2nrxrBGA+z7t6OWM7LBkgRckK3gOjYE+7JA= +github.com/aws/aws-sdk-go-v2/service/s3 v1.38.2 h1:v346f1h8sUBKXnEbrv43L37MTBlFHyKXQPIZHNAaghA= +github.com/aws/aws-sdk-go-v2/service/s3 v1.38.2/go.mod h1:cwCATiyNrXK9P2FsWdZ89g9mpsYv2rhk0UA/KByl5fY= +github.com/aws/aws-sdk-go-v2/service/sso v1.13.2 h1:A2RlEMo4SJSwbNoUUgkxTAEMduAy/8wG3eB2b2lP4gY= +github.com/aws/aws-sdk-go-v2/service/sso v1.13.2/go.mod h1:ju+nNXUunfIFamXUIZQiICjnO/TPlOmWcYhZcSy7xaE= +github.com/aws/aws-sdk-go-v2/service/ssooidc v1.15.2 h1:OJELEgyaT2kmaBGZ+myyZbTTLobfe3ox3FSh5eYK9Qs= +github.com/aws/aws-sdk-go-v2/service/ssooidc v1.15.2/go.mod h1:ubDBBaDFs1GHijSOTi8ljppML15GLG0HxhILtbjNNYQ= +github.com/aws/aws-sdk-go-v2/service/sts v1.21.2 h1:ympg1+Lnq33XLhcK/xTG4yZHPs1Oyxu+6DEWbl7qOzA= +github.com/aws/aws-sdk-go-v2/service/sts v1.21.2/go.mod h1:FQ/DQcOfESELfJi5ED+IPPAjI5xC6nxtSolVVB773jM= github.com/aws/smithy-go v1.13.5/go.mod h1:Tg+OJXh4MB2R/uN61Ko2f6hTZwB/ZYGOtib8J3gBHzA= +github.com/aws/smithy-go v1.14.1 h1:EFKMUmH/iHMqLiwoEDx2rRjRQpI1YCn5jTysoaDujFs= +github.com/aws/smithy-go v1.14.1/go.mod h1:Tg+OJXh4MB2R/uN61Ko2f6hTZwB/ZYGOtib8J3gBHzA= github.com/bgentry/speakeasy v0.1.0 h1:ByYyxL9InA1OWqxJqqp2A5pYHUrCiAL6K3J+LKSsQkY= github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= @@ -57,7 +59,6 @@ github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSs github.com/fatih/color v1.13.0/go.mod h1:kLAiJbzzSOZDVNGyDpeOxJ47H46qBXwg5ILebYFFOfk= github.com/fatih/color v1.14.1 h1:qfhVLaG5s+nCROl1zJsZRxFeYrHLqWroPOQ8BWiNb4w= github.com/fatih/color v1.14.1/go.mod h1:2oHN61fhTpgcxD3TSWCgKDiH1+x4OiDVVGH8WlgGZGg= -github.com/go-sql-driver/mysql v1.5.0/go.mod h1:DCzpHaOWr8IXmIStZouvnhqoel9Qv2LBy8hT2VhHyBg= github.com/go-test/deep v1.0.4 h1:u2CU3YKy9I2pmu9pX0eq50wCgjfGIt539SqR7FbHiho= github.com/go-test/deep v1.0.4/go.mod h1:wGDj63lr65AM2AQyKZd/NYHGb0R+1RLqB8NKt3aSFNA= github.com/golang/protobuf v1.1.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= @@ -93,8 +94,8 @@ github.com/hashicorp/go-kms-wrapping/v2 v2.0.9/go.mod h1:NtMaPhqSlfQ72XWDD2g80o8 github.com/hashicorp/go-multierror v1.1.1 h1:H5DkEtf6CXdFp0N0Em5UCwQpXMWke8IA0+lD48awMYo= github.com/hashicorp/go-multierror v1.1.1/go.mod h1:iw975J/qwKPdAO1clOe2L8331t/9/fmwbPZ6JB6eMoM= github.com/hashicorp/go-plugin v1.4.9 h1:ESiK220/qE0aGxWdzKIvRH69iLiuN/PjoLTm69RoWtU= -github.com/hashicorp/go-secure-stdlib/awsutil v0.2.2 h1:kWg2vyKl7BRXrNxYziqDJ55n+vtOQ1QsGORjzoeB+uM= -github.com/hashicorp/go-secure-stdlib/awsutil v0.2.2/go.mod h1:oKHSQs4ivIfZ3fbXGQOop1XuDfdSb8RIsWTGaAanSfg= +github.com/hashicorp/go-secure-stdlib/awsutil/v2 v2.0.0 h1:ca5TSI4AgaOncPpyzLDtCGjVEtKukONpeM95vFxXCOQ= +github.com/hashicorp/go-secure-stdlib/awsutil/v2 v2.0.0/go.mod h1:7CUvZtfTp2U0CYQCLzMtS2ngckjAZePSfwrE2aeDP1M= github.com/hashicorp/go-secure-stdlib/base62 v0.1.1/go.mod h1:EdWO6czbmthiwZ3/PUsDV+UD1D5IRU4ActiaWGwt0Yw= github.com/hashicorp/go-secure-stdlib/base62 v0.1.2 h1:ET4pqyjiGmY09R5y+rSd70J2w45CtbWDNvGqWp/R3Ng= github.com/hashicorp/go-secure-stdlib/configutil/v2 v2.0.9 h1:YZ01/aiARPneDUryzjiPGvr3pmjCSC+1WBZKI1P7I7k= @@ -118,7 +119,6 @@ github.com/hashicorp/yamux v0.1.1 h1:yrQxtgseBDrq9Y652vSRDvsKCJKOUD+GzTS4Y0Y8pvE github.com/huandu/xstrings v1.4.0 h1:D17IlohoQq4UcpqD7fDk80P7l+lwAmlFaBHgOipl2FU= github.com/imdario/mergo v0.3.13 h1:lFzP57bqS/wsqKssCGmtLAb8A0wKjLGrve2q3PPVcBk= github.com/jefferai/isbadcipher v0.0.0-20190226160619-51d2077c035f h1:E87tDTVS5W65euzixn7clSzK66puSt1H4I5SC0EmHH4= -github.com/jmespath/go-jmespath v0.3.0/go.mod h1:9QtRXoHjLGCJ5IBSaohpXITPlowMeeYCZ7fLUTSywik= github.com/jmespath/go-jmespath v0.4.0 h1:BEgLn5cpjn8UN1mAw4NjwDrS35OdebyEtFe+9YPoQUg= github.com/jmespath/go-jmespath v0.4.0/go.mod h1:T8mJZnbsbmF+m6zOOFylbeCJqk5+pHWvzYPziyZiYoo= github.com/jmespath/go-jmespath/internal/testify v1.5.1 h1:shLQSRRSCCPj3f2gpwzGwWFoC7ycTf1rcQZHOlsJ6N8= @@ -126,7 +126,6 @@ github.com/jmespath/go-jmespath/internal/testify v1.5.1/go.mod h1:L3OGu8Wl2/fWfC github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= github.com/kr/pretty v0.2.0/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= github.com/kr/pretty v0.3.0 h1:WgNl7dwNpEZ6jJ9k1snq4pZsg7DOEN8hP9Xw0Tsjwk0= -github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk= github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= @@ -153,12 +152,10 @@ github.com/mitchellh/pointerstructure v1.2.1/go.mod h1:BRAsLI5zgXmw97Lf6s25bs8oh github.com/mitchellh/reflectwalk v1.0.2 h1:G2LzWKi524PWgd3mLHV8Y5k7s6XUvT0Gef6zxSIeXaQ= github.com/mitchellh/reflectwalk v1.0.2/go.mod h1:mSTlrgnPZtwu0c4WaC2kGObEpuNDbx0jmZXqmk4esnw= github.com/oklog/run v1.1.0 h1:GEenZ1cK0+q0+wsJew9qUg/DyD8k3JzYsZAi5gYi2mA= -github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/posener/complete v1.2.3 h1:NP0eAhjcjImqslEwo/1hq7gpajME0fTLTezBKDqfXqo= -github.com/rogpeppe/go-internal v1.6.1/go.mod h1:xXDCJY+GAPziupqXw64V24skbSoqbTEfhy4qGm1nDQc= github.com/rogpeppe/go-internal v1.6.2 h1:aIihoIOHCiLZHxyoNQ+ABL4NKhFTgKLBdMLyEAh98m0= github.com/rogpeppe/go-internal v1.6.2/go.mod h1:xXDCJY+GAPziupqXw64V24skbSoqbTEfhy4qGm1nDQc= github.com/ryanuber/go-glob v1.0.0 h1:iQh3xXAumdQ+4Ufa5b25cRpC5TYKlno6hsv6Cb3pkBk= @@ -167,17 +164,12 @@ github.com/sebdah/goldie v1.0.0/go.mod h1:jXP4hmWywNEwZzhMuv2ccnqTSFpuq8iyQhtQdk github.com/shopspring/decimal v1.3.1 h1:2Usl1nmF/WZucqkFZhnfFYxxxu8LG21F6nPQBE5gKV8= github.com/spf13/cast v1.5.0 h1:rj3WzYc11XZaIZMPKmwP96zkFEnnAmV8s6XbB2aY32w= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= -github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= -github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= -github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA= github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= -github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.7.2/go.mod h1:R6va5+xMeoiuVRoj+gSkQ7d3FALtqAAGI1FQKckRals= -github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= -github.com/stretchr/testify v1.8.2 h1:+h33VjcLVPDHtOdpUCuF+7gSuG3yGIftsP1YvFihtJ8= -github.com/stretchr/testify v1.8.2/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= +github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk= +github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= github.com/vmihailenco/msgpack v3.3.3+incompatible/go.mod h1:fy3FlTQTDXWkZ7Bh6AcGMlsjHatGryHQYUTf1ShIgkk= github.com/vmihailenco/msgpack/v4 v4.3.12/go.mod h1:gborTTJjAo/GWTqqRjrLCn9pgNN+NXzzngzBKDPIqw4= github.com/vmihailenco/tagparser v0.1.1/go.mod h1:OeAg3pn3UbLjkWt+rN9oFYB6u/cQgqMEUPoW2WPyhdI= @@ -204,7 +196,6 @@ golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= -golang.org/x/net v0.0.0-20200202094626-16171245cfb2/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20200301022130-244492dfa37a/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= golang.org/x/net v0.0.0-20220127200216-cd36cc0744dd/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk= diff --git a/internal/credential/attributes.go b/internal/credential/attributes.go index 689bf90..467861a 100644 --- a/internal/credential/attributes.go +++ b/internal/credential/attributes.go @@ -9,7 +9,7 @@ import ( "github.com/aws/aws-sdk-go/aws/endpoints" "github.com/hashicorp/boundary-plugin-aws/internal/errors" "github.com/hashicorp/boundary-plugin-aws/internal/values" - "github.com/hashicorp/go-secure-stdlib/awsutil" + "github.com/hashicorp/go-secure-stdlib/awsutil/v2" "google.golang.org/protobuf/types/known/structpb" ) @@ -68,25 +68,45 @@ func GetCredentialsConfig(secrets *structpb.Struct, attrs *CredentialAttributes, badFields[fmt.Sprintf("secrets.%s", s)] = "unrecognized field" } - if len(badFields) > 0 { - return nil, errors.InvalidArgumentError("Error in the secrets provided", badFields) - } - - opts := []awsutil.Option{ - awsutil.WithEnvironmentCredentials(true), - } - if accessKey != "" && secretKey != "" { + opts := []awsutil.Option{} + // logic for parsing the credential type + // supported types: + // - user static credential + // - ec2 assume role dynamic credential + // - enviornment variables credential + switch { + // static credentials and dynamic credentials cannot be used together + case accessKey != "" && secretKey != "" && attrs.RoleArn != "": + badFields[fmt.Sprintf("secrets.%s", ConstAccessKeyId)] = "conflicts with role_arn value" + badFields[fmt.Sprintf("secrets.%s", ConstSecretAccessKey)] = "conflicts with role_arn value" + badFields[fmt.Sprintf("attributes.%s", ConstRoleArn)] = "conflicts with access_key_id and secret_access_key values" + // static credential is missing it's secret_access_key + case accessKey != "" && secretKey == "": + badFields[fmt.Sprintf("secrets.%s", ConstSecretAccessKey)] = "missing required value" + // static credential is missing it's access_key_id + case accessKey == "" && secretKey != "": + badFields[fmt.Sprintf("secrets.%s", ConstAccessKeyId)] = "missing required value" + // dynamic credentials and credential rotation is not supported + case len(attrs.RoleArn) > 0 && !attrs.DisableCredentialRotation: + badFields[fmt.Sprintf("attributes.%s", ConstDisableCredentialRotation)] = "disable_credential_rotation attribute is required when providing a role_arn" + // add static credentials + case accessKey != "" && secretKey != "": opts = append(opts, awsutil.WithAccessKey(accessKey), awsutil.WithSecretKey(secretKey), ) + // add dynamic credentials + case attrs.RoleArn != "": + opts = append(opts, awsutil.WithRoleArn(attrs.RoleArn)) } + + if len(badFields) > 0 { + return nil, errors.InvalidArgumentError("Error in the secrets provided", badFields) + } + if attrs.Region != "" { opts = append(opts, awsutil.WithRegion(attrs.Region)) } - if attrs.RoleArn != "" { - opts = append(opts, awsutil.WithRoleArn(attrs.RoleArn)) - } if attrs.RoleExternalId != "" { opts = append(opts, awsutil.WithRoleExternalId(attrs.RoleExternalId)) } diff --git a/internal/credential/attributes_test.go b/internal/credential/attributes_test.go index 7bac81c..fa9ac2c 100644 --- a/internal/credential/attributes_test.go +++ b/internal/credential/attributes_test.go @@ -6,7 +6,7 @@ package credential import ( "testing" - "github.com/hashicorp/go-secure-stdlib/awsutil" + "github.com/hashicorp/go-secure-stdlib/awsutil/v2" "github.com/stretchr/testify/require" "google.golang.org/grpc/codes" "google.golang.org/grpc/status" @@ -118,7 +118,7 @@ func TestGetCredentialsConfig(t *testing.T) { expectedErrContains string }{ { - name: "no secrets", + name: "no credentials", attrs: &CredentialAttributes{ Region: "us-west-2", }, @@ -130,7 +130,7 @@ func TestGetCredentialsConfig(t *testing.T) { name: "with static credentials", secrets: &structpb.Struct{ Fields: map[string]*structpb.Value{ - ConstAccessKeyId: structpb.NewStringValue("foobar"), + ConstAccessKeyId: structpb.NewStringValue("AKIAfoobar"), ConstSecretAccessKey: structpb.NewStringValue("bazqux"), }, }, @@ -138,7 +138,7 @@ func TestGetCredentialsConfig(t *testing.T) { Region: "us-west-2", }, expected: &awsutil.CredentialsConfig{ - AccessKey: "foobar", + AccessKey: "AKIAfoobar", SecretKey: "bazqux", Region: "us-west-2", }, @@ -160,7 +160,7 @@ func TestGetCredentialsConfig(t *testing.T) { name: "missing secret key", secrets: &structpb.Struct{ Fields: map[string]*structpb.Value{ - ConstAccessKeyId: structpb.NewStringValue("foobar"), + ConstAccessKeyId: structpb.NewStringValue("AKIAfoobar"), }, }, required: true, @@ -173,7 +173,7 @@ func TestGetCredentialsConfig(t *testing.T) { name: "unknown fields", secrets: &structpb.Struct{ Fields: map[string]*structpb.Value{ - ConstAccessKeyId: structpb.NewStringValue("foobar"), + ConstAccessKeyId: structpb.NewStringValue("AKIAfoobar"), ConstSecretAccessKey: structpb.NewStringValue("bazqux"), "foo": structpb.NewBoolValue(true), "bar": structpb.NewBoolValue(true), @@ -188,7 +188,7 @@ func TestGetCredentialsConfig(t *testing.T) { name: "valid ignore creds_last_rotated_time", secrets: &structpb.Struct{ Fields: map[string]*structpb.Value{ - ConstAccessKeyId: structpb.NewStringValue("foobar"), + ConstAccessKeyId: structpb.NewStringValue("AKIAfoobar"), ConstSecretAccessKey: structpb.NewStringValue("bazqux"), ConstCredsLastRotatedTime: structpb.NewStringValue("2006-01-02T15:04:05+07:00"), }, @@ -197,7 +197,7 @@ func TestGetCredentialsConfig(t *testing.T) { Region: "us-west-2", }, expected: &awsutil.CredentialsConfig{ - AccessKey: "foobar", + AccessKey: "AKIAfoobar", SecretKey: "bazqux", Region: "us-west-2", }, @@ -215,6 +215,7 @@ func TestGetCredentialsConfig(t *testing.T) { RoleTags: map[string]string{ "foo": "bar", }, + DisableCredentialRotation: true, }, expected: &awsutil.CredentialsConfig{ Region: "us-west-2", @@ -230,7 +231,7 @@ func TestGetCredentialsConfig(t *testing.T) { name: "with static credential & assume role", secrets: &structpb.Struct{ Fields: map[string]*structpb.Value{ - ConstAccessKeyId: structpb.NewStringValue("foobar"), + ConstAccessKeyId: structpb.NewStringValue("AKIAfoobar"), ConstSecretAccessKey: structpb.NewStringValue("bazqux"), }, }, @@ -243,17 +244,21 @@ func TestGetCredentialsConfig(t *testing.T) { "foo": "bar", }, }, - expected: &awsutil.CredentialsConfig{ - AccessKey: "foobar", - SecretKey: "bazqux", + expectedErrContains: "attributes.role_arn: conflicts with access_key_id and secret_access_key values, secrets.access_key_id: conflicts with role_arn value, secrets.secret_access_key: conflicts with role_arn value", + }, + { + name: "with dynamic credentials and no disable credential rotation", + secrets: &structpb.Struct{Fields: map[string]*structpb.Value{}}, + attrs: &CredentialAttributes{ Region: "us-west-2", - RoleARN: "arn:aws:iam::123456789012:role/S3Access", + RoleArn: "arn:aws:iam::123456789012:role/S3Access", RoleExternalId: "1234567890", RoleSessionName: "test-session", RoleTags: map[string]string{ "foo": "bar", }, }, + expectedErrContains: "attributes.disable_credential_rotation: disable_credential_rotation attribute is required when providing a role_arn", }, } diff --git a/internal/credential/state.go b/internal/credential/state.go index d25ffd0..7286dda 100644 --- a/internal/credential/state.go +++ b/internal/credential/state.go @@ -4,19 +4,41 @@ package credential import ( + "context" "errors" "fmt" "strings" "time" - "github.com/aws/aws-sdk-go/aws/awserr" - "github.com/aws/aws-sdk-go/aws/session" - "github.com/aws/aws-sdk-go/service/iam" + "github.com/aws/aws-sdk-go-v2/aws" + iamTypes "github.com/aws/aws-sdk-go-v2/service/iam/types" "github.com/hashicorp/boundary-plugin-aws/internal/values" - "github.com/hashicorp/go-secure-stdlib/awsutil" + "github.com/hashicorp/go-secure-stdlib/awsutil/v2" "google.golang.org/protobuf/types/known/structpb" ) +type CredentialType int + +const ( + // StaticAWS denotes an Access Key Id that begins with "AKIA". These are + // long-term access keys, provided by AWS, for an IAM user or an AWS account + // root user. + // https://docs.aws.amazon.com/IAM/latest/UserGuide/security-creds.html#sec-access-keys-and-secret-access-keys + StaticAWS CredentialType = iota + + // DynamicAWS denotes the presence of a RoleARN, or an Access Key Id that + // begins with "ASIA". The latter are temporary credentials access keys that + // are created using AWS STS operations. + DynamicAWS + + // StaticOther denotes the presence of an Access Key Id that does not follow + // the AKIA/ASIA convention outlined by AWS. + StaticOther + + // Unknown is a catch-all for everything else. + Unknown +) + // rotationWaitTimeout controls the time we wait for credential rotation to // succeed. This is important to ensure that rotated credentials can be used // right away. @@ -85,11 +107,11 @@ func NewAwsCredentialPersistedState(opts ...AwsCredentialPersistedStateOption) ( // ValidateCreds takes the credentials configuration from the persisted state // and runs sts.GetCallerIdentity for the current credentials, which is done // to check that the credentials are valid. -func (s *AwsCredentialPersistedState) ValidateCreds() error { +func (s *AwsCredentialPersistedState) ValidateCreds(ctx context.Context) error { if s.CredentialsConfig == nil { return errors.New("missing credentials config") } - if _, err := s.CredentialsConfig.GetCallerIdentity(s.testOpts...); err != nil { + if _, err := s.CredentialsConfig.GetCallerIdentity(ctx, s.testOpts...); err != nil { return fmt.Errorf("error validating credentials: %w", err) } return nil @@ -100,11 +122,14 @@ func (s *AwsCredentialPersistedState) ValidateCreds() error { // written into the credentials config and the persisted state. On any error, the old credentials are not overwritten. // This ensures that any generated new secret key never leaves this function in case of an error, even though it will // still result in an extraneous access key existing. -func (s *AwsCredentialPersistedState) RotateCreds() error { +func (s *AwsCredentialPersistedState) RotateCreds(ctx context.Context) error { if s.CredentialsConfig == nil { return errors.New("missing credentials config") } - if err := s.CredentialsConfig.RotateKeys(append([]awsutil.Option{ + if GetCredentialType(s.CredentialsConfig) != StaticAWS { + return errors.New("invalid credential type") + } + if err := s.CredentialsConfig.RotateKeys(ctx, append([]awsutil.Option{ awsutil.WithValidityCheckTimeout(rotationWaitTimeout), }, s.testOpts...)...); err != nil { return fmt.Errorf("error rotating credentials: %w", err) @@ -116,7 +141,7 @@ func (s *AwsCredentialPersistedState) RotateCreds() error { // ReplaceCreds replaces the access key in the state with a new key. // If the existing key was rotated at any point in time, it is // deleted first, otherwise it's left alone. -func (s *AwsCredentialPersistedState) ReplaceCreds(credentialsConfig *awsutil.CredentialsConfig) error { +func (s *AwsCredentialPersistedState) ReplaceCreds(ctx context.Context, credentialsConfig *awsutil.CredentialsConfig) error { if credentialsConfig == nil { return errors.New("missing new credentials config") } @@ -124,11 +149,11 @@ func (s *AwsCredentialPersistedState) ReplaceCreds(credentialsConfig *awsutil.Cr return errors.New("missing credentials config") } - if !s.CredsLastRotatedTime.IsZero() { - // Delete old/existing credentials. This is done with the same - // credentials to ensure that it has the proper permissions to do - // it. - if err := s.DeleteCreds(); err != nil { + // Delete old/existing credentials. This action is only possible for static credential types. + // This is done with the same credentials to ensure that it has the proper permissions to do it. + ct := GetCredentialType(s.CredentialsConfig) + if !s.CredsLastRotatedTime.IsZero() && ct == StaticAWS { + if err := s.DeleteCreds(ctx); err != nil { return err } } @@ -142,21 +167,22 @@ func (s *AwsCredentialPersistedState) ReplaceCreds(credentialsConfig *awsutil.Cr // DeleteCreds deletes the credentials in the state. The access key // ID, secret access key, and rotation time fields are zeroed out in // the state just to ensure that they cannot be re-used after. -func (s *AwsCredentialPersistedState) DeleteCreds() error { +func (s *AwsCredentialPersistedState) DeleteCreds(ctx context.Context) error { if s.CredentialsConfig == nil { return errors.New("missing credentials config") } + if GetCredentialType(s.CredentialsConfig) != StaticAWS { + return errors.New("invalid credential type") + } - if err := s.CredentialsConfig.DeleteAccessKey(s.CredentialsConfig.AccessKey, s.testOpts...); err != nil { + if err := s.CredentialsConfig.DeleteAccessKey(ctx, s.CredentialsConfig.AccessKey, s.testOpts...); err != nil { // Determine if the deletion error was due to a missing // resource. If it was, just pass it. - var awsErr awserr.Error - if errors.As(err, &awsErr) { - if awsErr.Code() == iam.ErrCodeNoSuchEntityException { - s.CredentialsConfig = nil - s.CredsLastRotatedTime = time.Time{} - return nil - } + var awsErr *iamTypes.NoSuchEntityException + if errors.As(err, &awsErr) && awsErr.ErrorCode() == "NoSuchEntity" { + s.CredentialsConfig = nil + s.CredsLastRotatedTime = time.Time{} + return nil } // Otherwise treat it as an actual error. @@ -168,10 +194,9 @@ func (s *AwsCredentialPersistedState) DeleteCreds() error { return nil } -// GetSession returns a configured AWS session for the credentials in -// the state. -func (s *AwsCredentialPersistedState) GetSession() (*session.Session, error) { - return s.CredentialsConfig.GetSession(s.testOpts...) +// GenerateCredentialChain returns a AWS configuration for the credentials in the state. +func (s *AwsCredentialPersistedState) GenerateCredentialChain(ctx context.Context) (*aws.Config, error) { + return s.CredentialsConfig.GenerateCredentialChain(ctx, s.testOpts...) } // ToMap returns a map of the credentials stored in the persisted state. @@ -179,7 +204,15 @@ func (s *AwsCredentialPersistedState) GetSession() (*session.Session, error) { // return a map for long-term credentials with following keys: // access_key_id, secret_access_key & creds_last_rotated_time func (s *AwsCredentialPersistedState) ToMap() map[string]any { - if HasDynamicCredentials(s.CredentialsConfig.AccessKey) { + ct := GetCredentialType(s.CredentialsConfig) + // Dynamic AWS credentials are temporary credentials that expire within an hour. + // ToMap() returns an empty map here so that Boundary does not store temporary + // credentials into the database. + // + // Uknown credentials are not aws compatiable secrets that may have been provided + // for a non-aws s3 compatiable service. These credentials will not be returned + // because the plugin does not know how to manage non-aws credentials. + if ct == DynamicAWS || ct == Unknown { return map[string]any{} } return map[string]any{ @@ -256,18 +289,22 @@ func AwsCredentialPersistedStateFromProto(secrets *structpb.Struct, attrs *Crede return s, nil } -// HasDynamicCredentials returns true if the access key is a dynamic credential type. -// -// https://docs.aws.amazon.com/IAM/latest/UserGuide/security-creds.html#sec-access-keys-and-secret-access-keys -// Access key IDs beginning with ASIA are temporary credentials access keys that you create using AWS STS operations. -func HasDynamicCredentials(accessKey string) bool { - return strings.HasPrefix(accessKey, "ASIA") -} +// GetCredentialType returns the credential type based on the given +// AccessKey/RoleARN. See CredentialType definition for more information. +func GetCredentialType(cc *awsutil.CredentialsConfig) CredentialType { + if cc == nil { + return Unknown + } -// HasStaticCredentials returns true if the access key is a static credential type. -// -// https://docs.aws.amazon.com/IAM/latest/UserGuide/security-creds.html#sec-access-keys-and-secret-access-keys -// Access key IDs beginning with AKIA are long-term access keys for an IAM user or an AWS account root user. -func HasStaticCredentials(accessKey string) bool { - return strings.HasPrefix(accessKey, "AKIA") + if len(cc.RoleARN) > 0 || strings.HasPrefix(cc.AccessKey, "ASIA") { + return DynamicAWS + } + if strings.HasPrefix(cc.AccessKey, "AKIA") { + return StaticAWS + } + if len(cc.AccessKey) > 0 { + return StaticOther + } + + return Unknown } diff --git a/internal/credential/state_test.go b/internal/credential/state_test.go index 02467a9..e5c405c 100644 --- a/internal/credential/state_test.go +++ b/internal/credential/state_test.go @@ -4,15 +4,16 @@ package credential import ( + "context" "errors" "fmt" "testing" "time" - "github.com/aws/aws-sdk-go/aws" - "github.com/aws/aws-sdk-go/aws/awserr" - "github.com/aws/aws-sdk-go/service/iam" - "github.com/hashicorp/go-secure-stdlib/awsutil" + "github.com/aws/aws-sdk-go-v2/aws" + "github.com/aws/aws-sdk-go-v2/service/iam" + iamTypes "github.com/aws/aws-sdk-go-v2/service/iam/types" + "github.com/hashicorp/go-secure-stdlib/awsutil/v2" "github.com/stretchr/testify/require" "google.golang.org/protobuf/types/known/structpb" ) @@ -383,7 +384,7 @@ func TestAwsCatalogPersistedState_ValidateCreds(t *testing.T) { tc := tc t.Run(tc.name, func(t *testing.T) { require := require.New(t) - err := tc.in.ValidateCreds() + err := tc.in.ValidateCreds(context.Background()) if tc.expectedErr != "" { require.EqualError(err, tc.expectedErr) return @@ -407,11 +408,33 @@ func TestAwsCatalogPersistedState_RotateCreds(t *testing.T) { in: &AwsCredentialPersistedState{}, expectedErr: "missing credentials config", }, + { + name: "cannot delete dynamic credential type", + in: &AwsCredentialPersistedState{ + CredentialsConfig: &awsutil.CredentialsConfig{ + AccessKey: "ASIAfoobar", + SecretKey: "bazqux", + }, + CredsLastRotatedTime: time.Now(), + }, + expectedErr: "invalid credential type", + }, + { + name: "cannot delete unknown credential type", + in: &AwsCredentialPersistedState{ + CredentialsConfig: &awsutil.CredentialsConfig{ + AccessKey: "DNEfoobar", + SecretKey: "bazqux", + }, + CredsLastRotatedTime: time.Now(), + }, + expectedErr: "invalid credential type", + }, { name: "rotation error", in: &AwsCredentialPersistedState{ CredentialsConfig: &awsutil.CredentialsConfig{ - AccessKey: "foobar", + AccessKey: "AKIAfoobar", SecretKey: "bazqux", Region: "us-west-2", }, @@ -423,13 +446,13 @@ func TestAwsCatalogPersistedState_RotateCreds(t *testing.T) { ), }, }, - expectedErr: fmt.Sprintf("error rotating credentials: error calling CreateAccessKey: error calling aws.GetUser: %s", testGetUserErr), + expectedErr: fmt.Sprintf("error rotating credentials: error calling CreateAccessKey: error calling iam.GetUser: %s", testGetUserErr), }, { name: "good", in: &AwsCredentialPersistedState{ CredentialsConfig: &awsutil.CredentialsConfig{ - AccessKey: "foobar", + AccessKey: "AKIAfoobar", SecretKey: "bazqux", Region: "us-west-2", }, @@ -441,7 +464,7 @@ func TestAwsCatalogPersistedState_RotateCreds(t *testing.T) { awsutil.NewMockIAM( awsutil.WithGetUserOutput( &iam.GetUserOutput{ - User: &iam.User{ + User: &iamTypes.User{ Arn: aws.String("arn:aws:iam::123456789012:user/JohnDoe"), UserId: aws.String("AIDAJQABLZS4A3QDU576Q"), UserName: aws.String("JohnDoe"), @@ -450,7 +473,7 @@ func TestAwsCatalogPersistedState_RotateCreds(t *testing.T) { ), awsutil.WithCreateAccessKeyOutput( &iam.CreateAccessKeyOutput{ - AccessKey: &iam.AccessKey{ + AccessKey: &iamTypes.AccessKey{ AccessKeyId: aws.String("one"), SecretAccessKey: aws.String("two"), UserName: aws.String("JohnDoe"), @@ -474,7 +497,7 @@ func TestAwsCatalogPersistedState_RotateCreds(t *testing.T) { tc := tc t.Run(tc.name, func(t *testing.T) { require := require.New(t) - err := tc.in.RotateCreds() + err := tc.in.RotateCreds(context.Background()) if tc.expectedErr != "" { require.EqualError(err, tc.expectedErr) return @@ -514,7 +537,7 @@ func TestAwsCatalogPersistedState_ReplaceCreds(t *testing.T) { name: "deletion error", in: &AwsCredentialPersistedState{ CredentialsConfig: &awsutil.CredentialsConfig{ - AccessKey: "foobar", + AccessKey: "AKIAfoobar", SecretKey: "bazqux", }, CredsLastRotatedTime: time.Now(), @@ -533,7 +556,7 @@ func TestAwsCatalogPersistedState_ReplaceCreds(t *testing.T) { name: "good with delete of old rotated", in: &AwsCredentialPersistedState{ CredentialsConfig: &awsutil.CredentialsConfig{ - AccessKey: "foobar", + AccessKey: "AKIAfoobar", SecretKey: "bazqux", }, CredsLastRotatedTime: time.Now(), @@ -557,7 +580,7 @@ func TestAwsCatalogPersistedState_ReplaceCreds(t *testing.T) { name: "good without delete of old rotated", in: &AwsCredentialPersistedState{ CredentialsConfig: &awsutil.CredentialsConfig{ - AccessKey: "foobar", + AccessKey: "AKIAfoobar", SecretKey: "bazqux", }, testOpts: []awsutil.Option{ @@ -583,7 +606,7 @@ func TestAwsCatalogPersistedState_ReplaceCreds(t *testing.T) { t.Run(tc.name, func(t *testing.T) { require := require.New(t) state.Reset() - err := tc.in.ReplaceCreds(tc.credentialConfig) + err := tc.in.ReplaceCreds(context.Background(), tc.credentialConfig) if tc.expectedErr != "" { require.EqualError(err, tc.expectedErr) return @@ -609,11 +632,33 @@ func TestAwsCatalogPersistedState_DeleteCreds(t *testing.T) { in: &AwsCredentialPersistedState{}, expectedErr: "missing credentials config", }, + { + name: "cannot delete dynamic credential type", + in: &AwsCredentialPersistedState{ + CredentialsConfig: &awsutil.CredentialsConfig{ + AccessKey: "ASIAfoobar", + SecretKey: "bazqux", + }, + CredsLastRotatedTime: time.Now(), + }, + expectedErr: "invalid credential type", + }, + { + name: "cannot delete unknown credential type", + in: &AwsCredentialPersistedState{ + CredentialsConfig: &awsutil.CredentialsConfig{ + AccessKey: "DNEfoobar", + SecretKey: "bazqux", + }, + CredsLastRotatedTime: time.Now(), + }, + expectedErr: "invalid credential type", + }, { name: "deletion error", in: &AwsCredentialPersistedState{ CredentialsConfig: &awsutil.CredentialsConfig{ - AccessKey: "foobar", + AccessKey: "AKIAfoobar", SecretKey: "bazqux", }, testOpts: []awsutil.Option{ @@ -630,14 +675,14 @@ func TestAwsCatalogPersistedState_DeleteCreds(t *testing.T) { name: "deletion error, but OK because key was just gone", in: &AwsCredentialPersistedState{ CredentialsConfig: &awsutil.CredentialsConfig{ - AccessKey: "foobar", + AccessKey: "AKIAfoobar", SecretKey: "bazqux", }, testOpts: []awsutil.Option{ awsutil.WithIAMAPIFunc( awsutil.NewMockIAM( awsutil.WithDeleteAccessKeyError( - awserr.New(iam.ErrCodeNoSuchEntityException, "", nil), + &iamTypes.NoSuchEntityException{}, ), ), ), @@ -648,7 +693,7 @@ func TestAwsCatalogPersistedState_DeleteCreds(t *testing.T) { name: "good", in: &AwsCredentialPersistedState{ CredentialsConfig: &awsutil.CredentialsConfig{ - AccessKey: "foobar", + AccessKey: "AKIAfoobar", SecretKey: "bazqux", }, testOpts: []awsutil.Option{ @@ -664,7 +709,7 @@ func TestAwsCatalogPersistedState_DeleteCreds(t *testing.T) { tc := tc t.Run(tc.name, func(t *testing.T) { require := require.New(t) - err := tc.in.DeleteCreds() + err := tc.in.DeleteCreds(context.Background()) if tc.expectedErr != "" { require.EqualError(err, tc.expectedErr) return @@ -677,7 +722,7 @@ func TestAwsCatalogPersistedState_DeleteCreds(t *testing.T) { } } -func TestAwsCatalogPersistedState_GetSession(t *testing.T) { +func TestAwsCatalogPersistedState_GenerateCredentialChain(t *testing.T) { require := require.New(t) cases := []struct { name string @@ -693,7 +738,7 @@ func TestAwsCatalogPersistedState_GetSession(t *testing.T) { }, testOpts: []awsutil.Option{awsutil.MockOptionErr(errors.New(testOptionErr))}, }, - expectedErr: fmt.Sprintf("error reading options in GetSession: %s", testOptionErr), + expectedErr: fmt.Sprintf("error reading options in GenerateCredentialChain: %s", testOptionErr), }, { name: "static credentials", @@ -736,7 +781,7 @@ func TestAwsCatalogPersistedState_GetSession(t *testing.T) { for _, tc := range cases { tc := tc t.Run(tc.name, func(t *testing.T) { - _, err := tc.in.GetSession() + _, err := tc.in.GenerateCredentialChain(context.Background()) if tc.expectedErr != "" { require.EqualError(err, tc.expectedErr) return @@ -746,74 +791,53 @@ func TestAwsCatalogPersistedState_GetSession(t *testing.T) { } } -func TestHasStaticCredentials(t *testing.T) { - require := require.New(t) - cases := []struct { - name string - accessKey string - expected bool +func TestGetCredentialType(t *testing.T) { + tests := []struct { + name string + credConfig *awsutil.CredentialsConfig + expectedCredType CredentialType }{ { - name: "dynamic credential", - accessKey: "ASIAEXAMPLE", - expected: false, + name: "nil credential config", + credConfig: nil, + expectedCredType: Unknown, }, { - name: "static credential", - accessKey: "AKIAEXAMPLE", - expected: true, + name: "empty credential config", + credConfig: &awsutil.CredentialsConfig{}, + expectedCredType: Unknown, }, { - name: "other credential", - accessKey: "EXAMPLE", - expected: false, + name: "static aws", + credConfig: &awsutil.CredentialsConfig{AccessKey: "AKIAfoobar"}, + expectedCredType: StaticAWS, }, { - name: "empty", - accessKey: "", - expected: false, + name: "static other", + credConfig: &awsutil.CredentialsConfig{AccessKey: "cK3kNFa24foobar"}, + expectedCredType: StaticOther, + }, + { + name: "dynamic aws - role arn", + credConfig: &awsutil.CredentialsConfig{RoleARN: "arn:aws:iam::123456789012:role/S3Access"}, + expectedCredType: DynamicAWS, + }, + { + name: "dynamic aws - access key", + credConfig: &awsutil.CredentialsConfig{AccessKey: "ASIAfoobar"}, + expectedCredType: DynamicAWS, + }, + { + name: "dynamic aws - role arn and access key", + credConfig: &awsutil.CredentialsConfig{AccessKey: "ASIAfoobar", RoleARN: "arn:aws:iam::123456789012:role/S3Access"}, + expectedCredType: DynamicAWS, }, } - for _, tc := range cases { - tc := tc - t.Run(tc.name, func(t *testing.T) { - require.Equal(tc.expected, HasStaticCredentials(tc.accessKey)) - }) - } -} -func TestHasDynamicCredentials(t *testing.T) { - require := require.New(t) - cases := []struct { - name string - accessKey string - expected bool - }{ - { - name: "dynamic credential", - accessKey: "ASIAEXAMPLE", - expected: true, - }, - { - name: "static credential", - accessKey: "AKIAEXAMPLE", - expected: false, - }, - { - name: "other credential", - accessKey: "EXAMPLE", - expected: false, - }, - { - name: "empty", - accessKey: "", - expected: false, - }, - } - for _, tc := range cases { - tc := tc + for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { - require.Equal(tc.expected, HasDynamicCredentials(tc.accessKey)) + ct := GetCredentialType(tc.credConfig) + require.Equal(t, tc.expectedCredType, ct) }) } } diff --git a/internal/credential/testing.go b/internal/credential/testing.go index d7b753c..3fcb7c7 100644 --- a/internal/credential/testing.go +++ b/internal/credential/testing.go @@ -4,10 +4,11 @@ package credential import ( - "github.com/aws/aws-sdk-go/aws/session" - "github.com/aws/aws-sdk-go/service/iam" - "github.com/aws/aws-sdk-go/service/iam/iamiface" - "github.com/hashicorp/go-secure-stdlib/awsutil" + "context" + + "github.com/aws/aws-sdk-go-v2/aws" + "github.com/aws/aws-sdk-go-v2/service/iam" + "github.com/hashicorp/go-secure-stdlib/awsutil/v2" "google.golang.org/protobuf/types/known/structpb" ) @@ -27,25 +28,25 @@ func (s *testMockIAMState) Reset() { } type testMockIAM struct { - iamiface.IAMAPI + awsutil.IAMClient State *testMockIAMState } -func (m *testMockIAM) DeleteAccessKey(input *iam.DeleteAccessKeyInput) (*iam.DeleteAccessKeyOutput, error) { +func (m *testMockIAM) DeleteAccessKey(ctx context.Context, input *iam.DeleteAccessKeyInput, opts ...func(*iam.Options)) (*iam.DeleteAccessKeyOutput, error) { m.State.DeleteAccessKeyCalled = true - return m.IAMAPI.DeleteAccessKey(input) + return m.IAMClient.DeleteAccessKey(ctx, input, opts...) } func newTestMockIAM(state *testMockIAMState, opts ...awsutil.MockIAMOption) awsutil.IAMAPIFunc { - return func(sess *session.Session) (iamiface.IAMAPI, error) { + return func(awsConfig *aws.Config) (awsutil.IAMClient, error) { m := &testMockIAM{ State: state, } f := awsutil.NewMockIAM(opts...) var err error - m.IAMAPI, err = f(sess) + m.IAMClient, err = f(awsConfig) return m, err } } diff --git a/plugin/service/host/plugin.go b/plugin/service/host/plugin.go index 406a985..4ec8fa2 100644 --- a/plugin/service/host/plugin.go +++ b/plugin/service/host/plugin.go @@ -83,15 +83,14 @@ func (p *HostPlugin) OnCreateCatalog(ctx context.Context, req *pb.OnCreateCatalo return nil, status.Errorf(codes.InvalidArgument, "error setting up persisted state: %s", err) } - // Try to rotate static credentials - if cred.HasStaticCredentials(credState.CredentialsConfig.AccessKey) { + if cred.GetCredentialType(credState.CredentialsConfig) == cred.StaticAWS { if !catalogAttributes.DisableCredentialRotation { - if err := credState.RotateCreds(); err != nil { + if err := credState.RotateCreds(ctx); err != nil { return nil, status.Errorf(codes.InvalidArgument, "error during credential rotation: %s", err) } } else { // Simply validate if we aren't rotating. - if err := credState.ValidateCreds(); err != nil { + if err := credState.ValidateCreds(ctx); err != nil { return nil, status.Errorf(codes.InvalidArgument, "error during credential validation: %s", err) } } @@ -160,7 +159,7 @@ func (p *HostPlugin) OnUpdateCatalog(ctx context.Context, req *pb.OnUpdateCatalo return nil, status.Errorf(codes.InvalidArgument, "error loading persisted state: %s", err) } - if cred.HasStaticCredentials(credState.CredentialsConfig.AccessKey) { + if cred.GetCredentialType(credState.CredentialsConfig) == cred.StaticAWS { if catalogAttributes.DisableCredentialRotation && !updateSecrets { // This is a validate check to make sure that we aren't disabling // rotation for credentials currently being managed by rotation. @@ -179,7 +178,7 @@ func (p *HostPlugin) OnUpdateCatalog(ctx context.Context, req *pb.OnUpdateCatalo // Replace the credentials. This checks the timestamp on the last // rotation time as well and deletes the credentials if we are // managing them (ie: if we've rotated them before). - if err := credState.ReplaceCreds(catalogSecrets); err != nil { + if err := credState.ReplaceCreds(ctx, catalogSecrets); err != nil { return nil, status.Errorf(codes.InvalidArgument, "error attempting to replace credentials: %s", err) } } @@ -187,7 +186,7 @@ func (p *HostPlugin) OnUpdateCatalog(ctx context.Context, req *pb.OnUpdateCatalo if !catalogAttributes.DisableCredentialRotation && credState.CredsLastRotatedTime.IsZero() { // If we're enabling rotation now but didn't before, or have // freshly replaced credentials, we can rotate here. - if err := credState.RotateCreds(); err != nil { + if err := credState.RotateCreds(ctx); err != nil { return nil, status.Errorf(codes.InvalidArgument, "error during credential rotation: %s", err) } } @@ -244,12 +243,12 @@ func (p *HostPlugin) OnDeleteCatalog(ctx context.Context, req *pb.OnDeleteCatalo } // try to delete static credentials - if cred.HasStaticCredentials(credState.CredentialsConfig.AccessKey) { + if cred.GetCredentialType(credState.CredentialsConfig) == cred.StaticAWS { if !credState.CredsLastRotatedTime.IsZero() { // Delete old/existing credentials. This is done with the same // credentials to ensure that it has the proper permissions to do // it. - if err := credState.DeleteCreds(); err != nil { + if err := credState.DeleteCreds(ctx); err != nil { return nil, status.Errorf(codes.Aborted, "error removing rotated credentials during catalog deletion: %s", err) } } @@ -332,11 +331,7 @@ func (p *HostPlugin) OnCreateSet(ctx context.Context, req *pb.OnCreateSetRequest return nil, err } - opts := []ec2Option{} - if catalogAttributes.Region != "" { - opts = append(opts, WithRegion(catalogAttributes.Region)) - } - ec2Client, err := catalogState.EC2Client(ctx, opts...) + ec2Client, err := catalogState.EC2Client(ctx) if err != nil { return nil, status.Errorf(codes.InvalidArgument, "error getting EC2 client: %s", err) } @@ -409,11 +404,7 @@ func (p *HostPlugin) OnUpdateSet(ctx context.Context, req *pb.OnUpdateSetRequest return nil, err } - opts := []ec2Option{} - if catalogAttributes.Region != "" { - opts = append(opts, WithRegion(catalogAttributes.Region)) - } - ec2Client, err := catalogState.EC2Client(ctx, opts...) + ec2Client, err := catalogState.EC2Client(ctx) if err != nil { return nil, status.Errorf(codes.InvalidArgument, "error getting EC2 client: %s", err) } @@ -516,11 +507,7 @@ func (p *HostPlugin) ListHosts(ctx context.Context, req *pb.ListHostsRequest) (* } } - opts := []ec2Option{} - if catalogAttributes.Region != "" { - opts = append(opts, WithRegion(catalogAttributes.Region)) - } - ec2Client, err := catalogState.EC2Client(ctx, opts...) + ec2Client, err := catalogState.EC2Client(ctx) if err != nil { return nil, status.Errorf(codes.InvalidArgument, "error getting EC2 client: %s", err) } diff --git a/plugin/service/host/plugin_test.go b/plugin/service/host/plugin_test.go index 6d40581..20e0990 100644 --- a/plugin/service/host/plugin_test.go +++ b/plugin/service/host/plugin_test.go @@ -17,7 +17,7 @@ import ( "github.com/hashicorp/boundary/sdk/pbs/controller/api/resources/hostcatalogs" "github.com/hashicorp/boundary/sdk/pbs/controller/api/resources/hostsets" pb "github.com/hashicorp/boundary/sdk/pbs/plugin" - "github.com/hashicorp/go-secure-stdlib/awsutil" + "github.com/hashicorp/go-secure-stdlib/awsutil/v2" "github.com/stretchr/testify/require" "google.golang.org/grpc/codes" "google.golang.org/grpc/status" @@ -153,7 +153,7 @@ func TestPluginOnCreateCatalogErr(t *testing.T) { ), }), }, - expectedErrContains: fmt.Sprintf("error during credential rotation: error rotating credentials: error calling CreateAccessKey: error calling aws.GetUser: %s", testGetUserErr), + expectedErrContains: fmt.Sprintf("error during credential rotation: error rotating credentials: error calling CreateAccessKey: error calling iam.GetUser: %s", testGetUserErr), expectedErrCode: codes.InvalidArgument, }, { @@ -411,7 +411,7 @@ func TestPluginOnUpdateCatalogErr(t *testing.T) { ), }), }, - expectedErrContains: fmt.Sprintf("error during credential rotation: error rotating credentials: error calling CreateAccessKey: error calling aws.GetUser: %s", testGetUserErr), + expectedErrContains: fmt.Sprintf("error during credential rotation: error rotating credentials: error calling CreateAccessKey: error calling iam.GetUser: %s", testGetUserErr), expectedErrCode: codes.InvalidArgument, }, } diff --git a/plugin/service/host/state.go b/plugin/service/host/state.go index 534a8a4..523e74d 100644 --- a/plugin/service/host/state.go +++ b/plugin/service/host/state.go @@ -12,8 +12,6 @@ import ( "time" "github.com/aws/aws-sdk-go-v2/aws" - "github.com/aws/aws-sdk-go-v2/config" - "github.com/aws/aws-sdk-go-v2/credentials" "github.com/aws/aws-sdk-go-v2/service/ec2" "github.com/hashicorp/boundary-plugin-aws/internal/credential" pb "github.com/hashicorp/boundary/sdk/pbs/plugin" @@ -88,75 +86,16 @@ func (s *awsCatalogPersistedState) toProto() (*pb.HostCatalogPersisted, error) { // EC2Client returns a configured EC2 client based on the session // information stored in the state. -func (s *awsCatalogPersistedState) EC2Client(ctx context.Context, opt ...ec2Option) (EC2API, error) { - opts, err := getOpts(opt...) +func (s *awsCatalogPersistedState) EC2Client(ctx context.Context) (EC2API, error) { + awsCfg, err := s.GenerateCredentialChain(ctx) if err != nil { - return nil, fmt.Errorf("error parsing options when fetching EC2 client: %w", err) + return nil, fmt.Errorf("error getting AWS configuration when fetching EC2 client: %w", err) } - - awsConfigOpts := []func(*config.LoadOptions) error{} - sess, err := s.GetSession() - if err != nil { - return nil, fmt.Errorf("error getting AWS session when fetching EC2 client: %w", err) + if awsCfg == nil { + return nil, fmt.Errorf("nil aws configuration") } - creds, err := sess.Config.Credentials.Get() - if err != nil { - return nil, fmt.Errorf("error getting AWS session when fetching EC2 client: %w", err) - } - customCredentials := config.WithCredentialsProvider( - credentials.NewStaticCredentialsProvider(creds.AccessKeyID, creds.SecretAccessKey, creds.SessionToken), - ) - awsConfigOpts = append(awsConfigOpts, customCredentials) - if sess.Config.Region != nil { - awsConfigOpts = append(awsConfigOpts, config.WithRegion(*sess.Config.Region)) - } - if opts.withRegion != "" { - awsConfigOpts = append(awsConfigOpts, config.WithRegion(opts.withRegion)) - } - cfg, err := config.LoadDefaultConfig(ctx, awsConfigOpts...) - if err != nil { - return nil, fmt.Errorf("error loading aws configuration when fetching EC2 client: %w", err) - } - if s.testEC2APIFunc != nil { - return s.testEC2APIFunc(cfg) - } - - cfg.HTTPClient = customClient - - return ec2.NewFromConfig(cfg), nil -} - -// getOpts iterates the inbound ec2Options and returns a struct -func getOpts(opt ...ec2Option) (ec2Options, error) { - opts := getDefaultEC2Options() - for _, o := range opt { - if o == nil { - continue - } - if err := o(&opts); err != nil { - return ec2Options{}, err - } - } - return opts, nil -} - -// ec2Option - how ec2Options are passed as arguments -type ec2Option func(*ec2Options) error - -// options = how options are represented -type ec2Options struct { - withRegion string -} - -func getDefaultEC2Options() ec2Options { - return ec2Options{} -} - -// WithRegion contains the region to use -func WithRegion(with string) ec2Option { - return func(o *ec2Options) error { - o.withRegion = with - return nil + return s.testEC2APIFunc(*awsCfg) } + return ec2.NewFromConfig(*awsCfg), nil } diff --git a/plugin/service/host/state_test.go b/plugin/service/host/state_test.go index 79ca6c1..4be814f 100644 --- a/plugin/service/host/state_test.go +++ b/plugin/service/host/state_test.go @@ -8,7 +8,7 @@ import ( "testing" "github.com/hashicorp/boundary-plugin-aws/internal/credential" - "github.com/hashicorp/go-secure-stdlib/awsutil" + "github.com/hashicorp/go-secure-stdlib/awsutil/v2" "github.com/stretchr/testify/require" ) @@ -29,7 +29,7 @@ func TestAwsCatalogPersistedStateEC2Client(t *testing.T) { ) require.NoError(err) - clientRaw, err := state.EC2Client(context.Background(), WithRegion("us-west-2")) + clientRaw, err := state.EC2Client(context.Background()) require.NoError(err) require.NotNil(clientRaw) client, ok := clientRaw.(*testMockEC2) diff --git a/plugin/service/storage/plugin.go b/plugin/service/storage/plugin.go index f0269aa..8e6a8c8 100644 --- a/plugin/service/storage/plugin.go +++ b/plugin/service/storage/plugin.go @@ -76,17 +76,10 @@ func (p *StoragePlugin) OnCreateStorageBucket(ctx context.Context, req *pb.OnCre return nil, status.Errorf(codes.InvalidArgument, "error setting up persisted state: %s", err) } - // Try to rotate static credentials - if cred.HasStaticCredentials(credState.CredentialsConfig.AccessKey) { - if !storageAttributes.DisableCredentialRotation { - if err := credState.RotateCreds(); err != nil { - return nil, status.Errorf(codes.InvalidArgument, "error during credential rotation: %s", err) - } - } else { - // Simply validate if we aren't rotating. - if err := credState.ValidateCreds(); err != nil { - return nil, status.Errorf(codes.InvalidArgument, "error during credential validation: %s", err) - } + // Try to rotate AWS static credentials + if cred.GetCredentialType(credConfig) == cred.StaticAWS && !storageAttributes.DisableCredentialRotation { + if err := credState.RotateCreds(ctx); err != nil { + return nil, status.Errorf(codes.InvalidArgument, "error during credential rotation: %s", err) } } @@ -119,76 +112,100 @@ func (p *StoragePlugin) OnUpdateStorageBucket(ctx context.Context, req *pb.OnUpd // For storage buckets we only need to really validate what is // currently in the new copy of the storage bucket data, so skip // fetching the new copy to keep things a little less messy. - bucket := req.GetNewBucket() - if bucket == nil { + newBucket := req.GetNewBucket() + if newBucket == nil { return nil, status.Error(codes.InvalidArgument, "new bucket is required") } - if bucket.BucketName == "" { + if newBucket.BucketName == "" { return nil, status.Error(codes.InvalidArgument, "new bucketName is required") } - - var updateSecrets bool - secrets := bucket.GetSecrets() - if secrets != nil { - // We will be updating secrets this run, but what exactly that - // means will be determined later. - updateSecrets = true + currentBucket := req.GetCurrentBucket() + if currentBucket == nil { + return nil, status.Error(codes.InvalidArgument, "current bucket is required") } - attrs := bucket.GetAttributes() - if attrs == nil { + oldAttrs := currentBucket.GetAttributes() + if oldAttrs == nil { + return nil, status.Error(codes.InvalidArgument, "current bucket attributes is required") + } + oldStorageAttributes, err := getStorageAttributes(oldAttrs) + if err != nil { + return nil, err + } + newAttrs := newBucket.GetAttributes() + if newAttrs == nil { return nil, status.Error(codes.InvalidArgument, "new bucket attributes is required") } - - storageAttributes, err := getStorageAttributes(attrs) + newStorageAttributes, err := getStorageAttributes(newAttrs) if err != nil { return nil, err } - // Get the persisted data. - // NOTE: We might need to change this at a later time. I'm not too - // sure *exactly* what scenarios we might encounter that would - // ultimately mean that we would have to handle an empty or missing - // state in update, but what we are ultimately assuming here - // (implicitly through awsStoragePersistedStateFromProto) is that - // the state will exist and be populated. Personally I think this - // is fine and important, but this may change in the future. credState, err := cred.AwsCredentialPersistedStateFromProto( req.GetPersisted().GetData(), - storageAttributes.CredentialAttributes, + oldStorageAttributes.CredentialAttributes, p.testCredStateOpts...) if err != nil { return nil, status.Errorf(codes.InvalidArgument, "error loading persisted state: %s", err) } - if cred.HasStaticCredentials(credState.CredentialsConfig.AccessKey) { - if storageAttributes.DisableCredentialRotation && !updateSecrets { - // This is a validate check to make sure that we aren't disabling - // rotation for credentials currently being managed by rotation. - // This is not allowed. + // Verify the incoming credentials are valid and return any errors to the + // user if they're not. Note this doesn't validate the credentials against + // AWS - it only does logical validation on the fields. + updatedCredentials, err := cred.GetCredentialsConfig(newBucket.GetSecrets(), newStorageAttributes.CredentialAttributes, false) + if err != nil { + return nil, err + } + + // We will be updating credentials when one of two changes occur: + // 1. the new bucket has secrets for static credentials, in which case we need to delete the old static credentials + // 2. the new bucket has a different roleARN value for dynamic credentials + if newBucket.GetSecrets() != nil || newStorageAttributes.RoleArn != oldStorageAttributes.RoleArn { + // Ensure the incoming credentials are valid for interaction with the S3 + // Bucket before replacing them. + newCredState, err := cred.NewAwsCredentialPersistedState( + append([]cred.AwsCredentialPersistedStateOption{ + cred.WithCredentialsConfig(updatedCredentials), + }, p.testCredStateOpts...)..., + ) + if err != nil { + return nil, status.Errorf(codes.InvalidArgument, "error setting up new credential persisted state: %s", err) + } + newStorageState, err := newAwsStoragePersistedState( + append([]awsStoragePersistedStateOption{ + withCredentials(newCredState), + }, p.testStorageStateOpts...)..., + ) + if err != nil { + return nil, status.Errorf(codes.InvalidArgument, "error loading persisted state: %s", err) + } + if err := dryRunValidation(ctx, newStorageState, newStorageAttributes, newBucket); err != nil { + return nil, status.Errorf(codes.InvalidArgument, "failed to validate incoming credentials: %s", err) + } + + // Replace the existing credential state. + // This checks the timestamp on the last rotation time as well + // and deletes the credentials if we are managing them + // (ie: if we've rotated them before). + if err := credState.ReplaceCreds(ctx, updatedCredentials); err != nil { + return nil, status.Errorf(codes.InvalidArgument, "error attempting to replace credentials: %s", err) + } + } + + if cred.GetCredentialType(credState.CredentialsConfig) == cred.StaticAWS { + // This is a validate check to make sure that we aren't disabling + // rotation for credentials currently being managed by rotation. + // This is not allowed. + if newStorageAttributes.DisableCredentialRotation && newBucket.GetSecrets() == nil { if !credState.CredsLastRotatedTime.IsZero() { return nil, status.Error(codes.FailedPrecondition, "cannot disable rotation for already-rotated credentials") } } - if updateSecrets { - storageSecrets, err := cred.GetCredentialsConfig(secrets, storageAttributes.CredentialAttributes, false) - if err != nil { - return nil, err - } - - // Replace the credentials. This checks the timestamp on the last - // rotation time as well and deletes the credentials if we are - // managing them (ie: if we've rotated them before). - if err := credState.ReplaceCreds(storageSecrets); err != nil { - return nil, status.Errorf(codes.InvalidArgument, "error attempting to replace credentials: %s", err) - } - } - - if !storageAttributes.DisableCredentialRotation && credState.CredsLastRotatedTime.IsZero() { - // If we're enabling rotation now but didn't before, or have - // freshly replaced credentials, we can rotate here. - if err := credState.RotateCreds(); err != nil { + // If we're enabling rotation now but didn't before, or have + // freshly replaced credentials, we can rotate here. + if !newStorageAttributes.DisableCredentialRotation && credState.CredsLastRotatedTime.IsZero() { + if err := credState.RotateCreds(ctx); err != nil { return nil, status.Errorf(codes.InvalidArgument, "error during credential rotation: %s", err) } } @@ -204,7 +221,7 @@ func (p *StoragePlugin) OnUpdateStorageBucket(ctx context.Context, req *pb.OnUpd } // perform dry run to ensure we can interact with the bucket as expected. - if err := dryRunValidation(ctx, storageState, storageAttributes, bucket); err != nil { + if err := dryRunValidation(ctx, storageState, newStorageAttributes, newBucket); err != nil { return nil, err } @@ -259,12 +276,12 @@ func (p *StoragePlugin) OnDeleteStorageBucket(ctx context.Context, req *pb.OnDel } // try to delete static credentials - if cred.HasStaticCredentials(credState.CredentialsConfig.AccessKey) { + if cred.GetCredentialType(credState.CredentialsConfig) == cred.StaticAWS { if !credState.CredsLastRotatedTime.IsZero() { // Delete old/existing credentials. This is done with the same // credentials to ensure that it has the proper permissions to do // it. - if err := credState.DeleteCreds(); err != nil { + if err := credState.DeleteCreds(ctx); err != nil { return nil, status.Errorf(codes.Aborted, "error removing rotated credentials during storage bucket deletion: %s", err) } } @@ -323,9 +340,6 @@ func (p *StoragePlugin) HeadObject(ctx context.Context, req *pb.HeadObjectReques } opts := []s3Option{} - if storageAttributes.Region != "" { - opts = append(opts, WithRegion(storageAttributes.Region)) - } if storageAttributes.EndpointUrl != "" { opts = append(opts, WithEndpoint(storageAttributes.EndpointUrl)) } @@ -452,9 +466,6 @@ func (p *StoragePlugin) GetObject(req *pb.GetObjectRequest, stream pb.StoragePlu } opts := []s3Option{} - if storageAttributes.Region != "" { - opts = append(opts, WithRegion(storageAttributes.Region)) - } if storageAttributes.EndpointUrl != "" { opts = append(opts, WithEndpoint(storageAttributes.EndpointUrl)) } @@ -566,9 +577,6 @@ func (p *StoragePlugin) PutObject(ctx context.Context, req *pb.PutObjectRequest) } opts := []s3Option{} - if storageAttributes.Region != "" { - opts = append(opts, WithRegion(storageAttributes.Region)) - } if storageAttributes.EndpointUrl != "" { opts = append(opts, WithEndpoint(storageAttributes.EndpointUrl)) } @@ -632,9 +640,6 @@ func dryRunValidation(ctx context.Context, state *awsStoragePersistedState, attr } opts := []s3Option{} - if attrs.Region != "" { - opts = append(opts, WithRegion(attrs.Region)) - } if attrs.EndpointUrl != "" { opts = append(opts, WithEndpoint(attrs.EndpointUrl)) } diff --git a/plugin/service/storage/plugin_test.go b/plugin/service/storage/plugin_test.go index b1157df..7d47fa2 100644 --- a/plugin/service/storage/plugin_test.go +++ b/plugin/service/storage/plugin_test.go @@ -17,12 +17,15 @@ import ( "time" "github.com/aws/aws-sdk-go-v2/aws" + "github.com/aws/aws-sdk-go-v2/service/iam" + iamTypes "github.com/aws/aws-sdk-go-v2/service/iam/types" "github.com/aws/aws-sdk-go-v2/service/s3" - "github.com/aws/aws-sdk-go/service/iam" + "github.com/aws/aws-sdk-go-v2/service/sts" + "github.com/aws/aws-sdk-go-v2/service/sts/types" "github.com/hashicorp/boundary-plugin-aws/internal/credential" "github.com/hashicorp/boundary/sdk/pbs/controller/api/resources/storagebuckets" pb "github.com/hashicorp/boundary/sdk/pbs/plugin" - "github.com/hashicorp/go-secure-stdlib/awsutil" + "github.com/hashicorp/go-secure-stdlib/awsutil/v2" "github.com/stretchr/testify/require" "google.golang.org/grpc/codes" "google.golang.org/grpc/status" @@ -103,6 +106,23 @@ func TestStoragePlugin_OnCreateStorageBucket(t *testing.T) { expectedErrContains: "not a valid region: foobar", expectedErrCode: codes.InvalidArgument, }, + { + name: "dynamic credentials without disable credential rotation", + req: &pb.OnCreateStorageBucketRequest{ + Bucket: &storagebuckets.StorageBucket{ + BucketName: "foo", + Secrets: &structpb.Struct{Fields: map[string]*structpb.Value{}}, + Attributes: &structpb.Struct{ + Fields: map[string]*structpb.Value{ + credential.ConstRegion: structpb.NewStringValue("us-west-2"), + credential.ConstRoleArn: structpb.NewStringValue("arn:aws:iam::123456789012:role/S3Access"), + }, + }, + }, + }, + expectedErrContains: "disable_credential_rotation attribute is required when providing a role_arn", + expectedErrCode: codes.InvalidArgument, + }, { name: "persisted state setup error", req: validRequest(), @@ -137,22 +157,7 @@ func TestStoragePlugin_OnCreateStorageBucket(t *testing.T) { ), }), }, - expectedErrContains: fmt.Sprintf("error during credential rotation: error rotating credentials: error calling CreateAccessKey: error calling aws.GetUser: %s", testGetUserErr), - expectedErrCode: codes.InvalidArgument, - }, - { - name: "validation error", - req: validRequest(), - credOpts: []credential.AwsCredentialPersistedStateOption{ - credential.WithStateTestOpts([]awsutil.Option{ - awsutil.WithSTSAPIFunc( - awsutil.NewMockSTS( - awsutil.WithGetCallerIdentityError(errors.New(testGetCallerIdentityErr)), - ), - ), - }), - }, - expectedErrContains: fmt.Sprintf("error during credential validation: error validating credentials: %s", testGetCallerIdentityErr), + expectedErrContains: fmt.Sprintf("error during credential rotation: error rotating credentials: error calling CreateAccessKey: error calling iam.GetUser: %s", testGetUserErr), expectedErrCode: codes.InvalidArgument, }, { @@ -287,7 +292,7 @@ func TestStoragePlugin_OnCreateStorageBucket(t *testing.T) { awsutil.NewMockIAM( awsutil.WithGetUserOutput( &iam.GetUserOutput{ - User: &iam.User{ + User: &iamTypes.User{ Arn: aws.String("arn:aws:iam::123456789012:user/JohnDoe"), UserId: aws.String("AIDAJQABLZS4A3QDU576Q"), UserName: aws.String("JohnDoe"), @@ -296,7 +301,7 @@ func TestStoragePlugin_OnCreateStorageBucket(t *testing.T) { ), awsutil.WithCreateAccessKeyOutput( &iam.CreateAccessKeyOutput{ - AccessKey: &iam.AccessKey{ + AccessKey: &iamTypes.AccessKey{ AccessKeyId: aws.String("one"), SecretAccessKey: aws.String("two"), UserName: aws.String("JohnDoe"), @@ -323,6 +328,44 @@ func TestStoragePlugin_OnCreateStorageBucket(t *testing.T) { credential.ConstSecretAccessKey: "two", }, }, + { + name: "success with dynamic credentials", + req: &pb.OnCreateStorageBucketRequest{ + Bucket: &storagebuckets.StorageBucket{ + BucketName: "foo", + Secrets: &structpb.Struct{}, + Attributes: credential.MockAssumeRoleAttributes("us-west-2", true), + }, + }, + credOpts: []credential.AwsCredentialPersistedStateOption{ + credential.WithStateTestOpts( + []awsutil.Option{ + awsutil.WithSTSAPIFunc( + awsutil.NewMockSTS( + awsutil.WithAssumeRoleOutput(&sts.AssumeRoleOutput{ + Credentials: &types.Credentials{ + AccessKeyId: aws.String("ASIAfoobar"), + Expiration: aws.Time(time.Now().Add(time.Hour)), + SecretAccessKey: aws.String("secretkeyfoobar"), + }, + }), + ), + ), + }, + ), + }, + storageOpts: []awsStoragePersistedStateOption{ + withTestS3APIFunc( + newTestMockS3( + nil, + testMockS3WithPutObjectOutput(&s3.PutObjectOutput{}), + testMockS3WithGetObjectOutput(&s3.GetObjectOutput{}), + testMockS3WithHeadObjectOutput(&s3.HeadObjectOutput{}), + ), + ), + }, + expectedPersistedData: map[string]any{}, // No credentials sent back to Boundary as they are temporary. + }, } for _, tc := range cases { @@ -343,8 +386,10 @@ func TestStoragePlugin_OnCreateStorageBucket(t *testing.T) { require.NotNil(resp) require.NotNil(resp.GetPersisted()) require.NotNil(resp.GetPersisted().GetData()) + actualPersistedData := resp.GetPersisted().GetData().AsMap() - if len(actualPersistedData) != 0 { + if len(tc.expectedPersistedData) != 0 { + require.NotEmpty(actualPersistedData) require.Equal(tc.expectedPersistedData[credential.ConstAccessKeyId], actualPersistedData[credential.ConstAccessKeyId]) require.Equal(tc.expectedPersistedData[credential.ConstSecretAccessKey], actualPersistedData[credential.ConstSecretAccessKey]) require.NotEmpty(actualPersistedData[credential.ConstCredsLastRotatedTime]) @@ -353,6 +398,10 @@ func TestStoragePlugin_OnCreateStorageBucket(t *testing.T) { } } +// TODO: add tests for changing: +// roleARN values +// access/secret keys +// swithcing between static & dynamic credentials func TestStoragePlugin_OnUpdateStorageBucket(t *testing.T) { validRequest := func() *pb.OnUpdateStorageBucketRequest { return &pb.OnUpdateStorageBucketRequest{ @@ -365,18 +414,27 @@ func TestStoragePlugin_OnUpdateStorageBucket(t *testing.T) { }, }, }, + CurrentBucket: &storagebuckets.StorageBucket{ + Attributes: &structpb.Struct{ + Fields: map[string]*structpb.Value{ + credential.ConstRegion: structpb.NewStringValue("us-west-2"), + }, + }, + }, Persisted: &storagebuckets.StorageBucketPersisted{ Data: credential.MockStaticCredentialSecrets(), }, } } cases := []struct { - name string - req *pb.OnUpdateStorageBucketRequest - credOpts []credential.AwsCredentialPersistedStateOption - storageOpts []awsStoragePersistedStateOption - expectedErrContains string - expectedErrCode codes.Code + name string + req *pb.OnUpdateStorageBucketRequest + credOpts []credential.AwsCredentialPersistedStateOption + storageOpts []awsStoragePersistedStateOption + expectedPersistedData map[string]any + expectCredsLastRotatedTime bool + expectedErrContains string + expectedErrCode codes.Code }{ { name: "nil new bucket", @@ -392,6 +450,27 @@ func TestStoragePlugin_OnUpdateStorageBucket(t *testing.T) { expectedErrContains: "new bucketName is required", expectedErrCode: codes.InvalidArgument, }, + { + name: "nil current bucket", + req: &pb.OnUpdateStorageBucketRequest{ + NewBucket: &storagebuckets.StorageBucket{ + BucketName: "foo", + }, + }, + expectedErrContains: "current bucket is required", + expectedErrCode: codes.InvalidArgument, + }, + { + name: "nil current bucket attributes", + req: &pb.OnUpdateStorageBucketRequest{ + NewBucket: &storagebuckets.StorageBucket{ + BucketName: "foo", + }, + CurrentBucket: &storagebuckets.StorageBucket{}, + }, + expectedErrContains: "current bucket attributes is required", + expectedErrCode: codes.InvalidArgument, + }, { name: "nil new bucket attributes", req: &pb.OnUpdateStorageBucketRequest{ @@ -399,6 +478,13 @@ func TestStoragePlugin_OnUpdateStorageBucket(t *testing.T) { BucketName: "foo", Secrets: new(structpb.Struct), }, + CurrentBucket: &storagebuckets.StorageBucket{ + Attributes: &structpb.Struct{ + Fields: map[string]*structpb.Value{ + credential.ConstRegion: structpb.NewStringValue("us-west-2"), + }, + }, + }, }, expectedErrContains: "new bucket attributes is required", expectedErrCode: codes.InvalidArgument, @@ -411,6 +497,13 @@ func TestStoragePlugin_OnUpdateStorageBucket(t *testing.T) { Secrets: new(structpb.Struct), Attributes: new(structpb.Struct), }, + CurrentBucket: &storagebuckets.StorageBucket{ + Attributes: &structpb.Struct{ + Fields: map[string]*structpb.Value{ + credential.ConstRegion: structpb.NewStringValue("us-west-2"), + }, + }, + }, }, expectedErrContains: "missing required value \"region\"", expectedErrCode: codes.InvalidArgument, @@ -426,6 +519,13 @@ func TestStoragePlugin_OnUpdateStorageBucket(t *testing.T) { }, }, }, + CurrentBucket: &storagebuckets.StorageBucket{ + Attributes: &structpb.Struct{ + Fields: map[string]*structpb.Value{ + credential.ConstRegion: structpb.NewStringValue("us-west-2"), + }, + }, + }, }, expectedErrContains: "not a valid region: foobar", expectedErrCode: codes.InvalidArgument, @@ -441,6 +541,13 @@ func TestStoragePlugin_OnUpdateStorageBucket(t *testing.T) { }, }, }, + CurrentBucket: &storagebuckets.StorageBucket{ + Attributes: &structpb.Struct{ + Fields: map[string]*structpb.Value{ + credential.ConstRegion: structpb.NewStringValue("us-west-2"), + }, + }, + }, Persisted: &storagebuckets.StorageBucketPersisted{ Data: &structpb.Struct{ Fields: map[string]*structpb.Value{ @@ -471,6 +578,13 @@ func TestStoragePlugin_OnUpdateStorageBucket(t *testing.T) { }, }, }, + CurrentBucket: &storagebuckets.StorageBucket{ + Attributes: &structpb.Struct{ + Fields: map[string]*structpb.Value{ + credential.ConstRegion: structpb.NewStringValue("us-west-2"), + }, + }, + }, Persisted: &storagebuckets.StorageBucketPersisted{ Data: &structpb.Struct{ Fields: map[string]*structpb.Value{ @@ -484,6 +598,36 @@ func TestStoragePlugin_OnUpdateStorageBucket(t *testing.T) { expectedErrContains: "cannot disable rotation for already-rotated credentials", expectedErrCode: codes.FailedPrecondition, }, + { + name: "attempt to enable credential rotation with dynamic credentials", + req: &pb.OnUpdateStorageBucketRequest{ + CurrentBucket: &storagebuckets.StorageBucket{ + BucketName: "foo", + Attributes: &structpb.Struct{ + Fields: map[string]*structpb.Value{ + credential.ConstRegion: structpb.NewStringValue("us-west-2"), + credential.ConstDisableCredentialRotation: structpb.NewBoolValue(true), + credential.ConstRoleArn: structpb.NewStringValue("arn:aws:iam::123456789012:role/S3Access"), + }, + }, + }, + NewBucket: &storagebuckets.StorageBucket{ + BucketName: "foo", + Attributes: &structpb.Struct{ + Fields: map[string]*structpb.Value{ + credential.ConstRegion: structpb.NewStringValue("us-west-2"), + credential.ConstDisableCredentialRotation: structpb.NewBoolValue(false), + credential.ConstRoleArn: structpb.NewStringValue("arn:aws:iam::123456789012:role/S3Access"), + }, + }, + }, + Persisted: &storagebuckets.StorageBucketPersisted{ + Data: credential.MockAssumeRoleAttributes("us-west-2", true), + }, + }, + expectedErrContains: "disable_credential_rotation attribute is required when providing a role_arn", + expectedErrCode: codes.InvalidArgument, + }, { name: "updating secrets, replace error", req: &pb.OnUpdateStorageBucketRequest{ @@ -502,6 +646,13 @@ func TestStoragePlugin_OnUpdateStorageBucket(t *testing.T) { }, }, }, + CurrentBucket: &storagebuckets.StorageBucket{ + Attributes: &structpb.Struct{ + Fields: map[string]*structpb.Value{ + credential.ConstRegion: structpb.NewStringValue("us-west-2"), + }, + }, + }, Persisted: &storagebuckets.StorageBucketPersisted{ Data: &structpb.Struct{ Fields: map[string]*structpb.Value{ @@ -512,6 +663,16 @@ func TestStoragePlugin_OnUpdateStorageBucket(t *testing.T) { }, }, }, + storageOpts: []awsStoragePersistedStateOption{ + withTestS3APIFunc( + newTestMockS3( + nil, + testMockS3WithPutObjectOutput(&s3.PutObjectOutput{}), + testMockS3WithGetObjectOutput(&s3.GetObjectOutput{}), + testMockS3WithHeadObjectOutput(&s3.HeadObjectOutput{}), + ), + ), + }, credOpts: []credential.AwsCredentialPersistedStateOption{ credential.WithStateTestOpts([]awsutil.Option{ awsutil.WithIAMAPIFunc( @@ -535,6 +696,13 @@ func TestStoragePlugin_OnUpdateStorageBucket(t *testing.T) { }, }, }, + CurrentBucket: &storagebuckets.StorageBucket{ + Attributes: &structpb.Struct{ + Fields: map[string]*structpb.Value{ + credential.ConstRegion: structpb.NewStringValue("us-west-2"), + }, + }, + }, Persisted: &storagebuckets.StorageBucketPersisted{ Data: &structpb.Struct{ Fields: map[string]*structpb.Value{ @@ -553,7 +721,7 @@ func TestStoragePlugin_OnUpdateStorageBucket(t *testing.T) { ), }), }, - expectedErrContains: fmt.Sprintf("error during credential rotation: error rotating credentials: error calling CreateAccessKey: error calling aws.GetUser: %s", testGetUserErr), + expectedErrContains: fmt.Sprintf("error during credential rotation: error rotating credentials: error calling CreateAccessKey: error calling iam.GetUser: %s", testGetUserErr), expectedErrCode: codes.InvalidArgument, }, { @@ -659,6 +827,13 @@ func TestStoragePlugin_OnUpdateStorageBucket(t *testing.T) { }, }, }, + CurrentBucket: &storagebuckets.StorageBucket{ + Attributes: &structpb.Struct{ + Fields: map[string]*structpb.Value{ + credential.ConstRegion: structpb.NewStringValue("us-west-2"), + }, + }, + }, Persisted: &storagebuckets.StorageBucketPersisted{ Data: &structpb.Struct{ Fields: map[string]*structpb.Value{ @@ -677,7 +852,7 @@ func TestStoragePlugin_OnUpdateStorageBucket(t *testing.T) { awsutil.NewMockIAM( awsutil.WithGetUserOutput( &iam.GetUserOutput{ - User: &iam.User{ + User: &iamTypes.User{ Arn: aws.String("arn:aws:iam::123456789012:user/JohnDoe"), UserId: aws.String("AIDAJQABLZS4A3QDU576Q"), UserName: aws.String("JohnDoe"), @@ -686,7 +861,7 @@ func TestStoragePlugin_OnUpdateStorageBucket(t *testing.T) { ), awsutil.WithCreateAccessKeyOutput( &iam.CreateAccessKeyOutput{ - AccessKey: &iam.AccessKey{ + AccessKey: &iamTypes.AccessKey{ AccessKeyId: aws.String("AKIA_one"), SecretAccessKey: aws.String("two"), UserName: aws.String("JohnDoe"), @@ -707,6 +882,11 @@ func TestStoragePlugin_OnUpdateStorageBucket(t *testing.T) { ), ), }, + expectedPersistedData: map[string]any{ + credential.ConstAccessKeyId: "AKIA_one", + credential.ConstSecretAccessKey: "two", + }, + expectCredsLastRotatedTime: true, }, { name: "success", @@ -722,6 +902,508 @@ func TestStoragePlugin_OnUpdateStorageBucket(t *testing.T) { ), ), }, + expectedPersistedData: map[string]any{ + credential.ConstAccessKeyId: "AKIA_foobar", + credential.ConstSecretAccessKey: "bazqux", + }, + }, + { + name: "dry run fail on dynamic to dynamic credentials update", + req: &pb.OnUpdateStorageBucketRequest{ + CurrentBucket: &storagebuckets.StorageBucket{ + BucketName: "foo", + Attributes: &structpb.Struct{ + Fields: map[string]*structpb.Value{ + credential.ConstRegion: structpb.NewStringValue("us-west-2"), + credential.ConstRoleArn: structpb.NewStringValue("arn:aws:iam::123456789012:role/S3Access"), + credential.ConstDisableCredentialRotation: structpb.NewBoolValue(true), + }, + }, + }, + NewBucket: &storagebuckets.StorageBucket{ + BucketName: "foo", + Attributes: &structpb.Struct{ + Fields: map[string]*structpb.Value{ + credential.ConstRegion: structpb.NewStringValue("us-west-2"), + credential.ConstRoleArn: structpb.NewStringValue("arn:aws:iam::098765432109:role/S3Access"), + credential.ConstDisableCredentialRotation: structpb.NewBoolValue(true), + }, + }, + }, + Persisted: &storagebuckets.StorageBucketPersisted{Data: &structpb.Struct{}}, + }, + storageOpts: []awsStoragePersistedStateOption{ + withTestS3APIFunc( + newTestMockS3( + nil, + testMockS3WithHeadObjectError(fmt.Errorf("head object failed oops")), + testMockS3WithGetObjectOutput(&s3.GetObjectOutput{}), + testMockS3WithPutObjectOutput(&s3.PutObjectOutput{}), + ), + ), + }, + expectedErrContains: "head object failed oops", + expectedErrCode: codes.InvalidArgument, + }, + { + name: "dynamic to dynamic credentials success", + req: &pb.OnUpdateStorageBucketRequest{ + CurrentBucket: &storagebuckets.StorageBucket{ + BucketName: "foo", + Attributes: &structpb.Struct{ + Fields: map[string]*structpb.Value{ + credential.ConstRegion: structpb.NewStringValue("us-west-2"), + credential.ConstRoleArn: structpb.NewStringValue("arn:aws:iam::123456789012:role/S3Access"), + credential.ConstDisableCredentialRotation: structpb.NewBoolValue(true), + }, + }, + }, + NewBucket: &storagebuckets.StorageBucket{ + BucketName: "foo", + Attributes: &structpb.Struct{ + Fields: map[string]*structpb.Value{ + credential.ConstRegion: structpb.NewStringValue("us-west-2"), + credential.ConstRoleArn: structpb.NewStringValue("arn:aws:iam::098765432109:role/S3Access"), + credential.ConstDisableCredentialRotation: structpb.NewBoolValue(true), + }, + }, + }, + Persisted: &storagebuckets.StorageBucketPersisted{Data: &structpb.Struct{}}, + }, + storageOpts: []awsStoragePersistedStateOption{ + withTestS3APIFunc( + newTestMockS3( + nil, + testMockS3WithHeadObjectOutput(&s3.HeadObjectOutput{}), + testMockS3WithGetObjectOutput(&s3.GetObjectOutput{}), + testMockS3WithPutObjectOutput(&s3.PutObjectOutput{}), + ), + ), + }, + expectedPersistedData: map[string]any{}, + }, + { + name: "dry run fail on static non rotated to dynamic credentials update", + req: &pb.OnUpdateStorageBucketRequest{ + CurrentBucket: &storagebuckets.StorageBucket{ + BucketName: "foo", + Attributes: &structpb.Struct{ + Fields: map[string]*structpb.Value{ + credential.ConstRegion: structpb.NewStringValue("us-west-2"), + credential.ConstDisableCredentialRotation: structpb.NewBoolValue(true), + }, + }, + }, + NewBucket: &storagebuckets.StorageBucket{ + BucketName: "foo", + Attributes: &structpb.Struct{ + Fields: map[string]*structpb.Value{ + credential.ConstRegion: structpb.NewStringValue("us-west-2"), + credential.ConstRoleArn: structpb.NewStringValue("arn:aws:iam::098765432109:role/S3Access"), + credential.ConstDisableCredentialRotation: structpb.NewBoolValue(true), + }, + }, + }, + Persisted: &storagebuckets.StorageBucketPersisted{ + Data: &structpb.Struct{ + Fields: map[string]*structpb.Value{ + credential.ConstAccessKeyId: structpb.NewStringValue("AKIAfoobar"), + credential.ConstSecretAccessKey: structpb.NewStringValue("secretkey"), + }, + }, + }, + }, + storageOpts: []awsStoragePersistedStateOption{ + withTestS3APIFunc( + newTestMockS3( + nil, + testMockS3WithPutObjectError(fmt.Errorf("put object failed oops")), + testMockS3WithGetObjectOutput(&s3.GetObjectOutput{}), + testMockS3WithHeadObjectOutput(&s3.HeadObjectOutput{}), + ), + ), + }, + expectedErrContains: "put object failed oops", + expectedErrCode: codes.InvalidArgument, + }, + { + name: "success static non rotated to dynamic credentials", + req: &pb.OnUpdateStorageBucketRequest{ + CurrentBucket: &storagebuckets.StorageBucket{ + BucketName: "foo", + Attributes: &structpb.Struct{ + Fields: map[string]*structpb.Value{ + credential.ConstRegion: structpb.NewStringValue("us-west-2"), + credential.ConstDisableCredentialRotation: structpb.NewBoolValue(true), + }, + }, + }, + NewBucket: &storagebuckets.StorageBucket{ + BucketName: "foo", + Attributes: &structpb.Struct{ + Fields: map[string]*structpb.Value{ + credential.ConstRegion: structpb.NewStringValue("us-west-2"), + credential.ConstRoleArn: structpb.NewStringValue("arn:aws:iam::098765432109:role/S3Access"), + credential.ConstDisableCredentialRotation: structpb.NewBoolValue(true), + }, + }, + }, + Persisted: &storagebuckets.StorageBucketPersisted{ + Data: &structpb.Struct{ + Fields: map[string]*structpb.Value{ + credential.ConstAccessKeyId: structpb.NewStringValue("AKIAfoobar"), + credential.ConstSecretAccessKey: structpb.NewStringValue("secretkey"), + }, + }, + }, + }, + storageOpts: []awsStoragePersistedStateOption{ + withTestS3APIFunc( + newTestMockS3( + nil, + testMockS3WithPutObjectOutput(&s3.PutObjectOutput{}), + testMockS3WithGetObjectOutput(&s3.GetObjectOutput{}), + testMockS3WithHeadObjectOutput(&s3.HeadObjectOutput{}), + ), + ), + }, + expectedPersistedData: map[string]any{}, + }, + { + name: "DeleteAccessKey error on static rotated to dynamic credentials update", + req: &pb.OnUpdateStorageBucketRequest{ + CurrentBucket: &storagebuckets.StorageBucket{ + BucketName: "foo", + Attributes: &structpb.Struct{ + Fields: map[string]*structpb.Value{ + credential.ConstRegion: structpb.NewStringValue("us-west-2"), + }, + }, + }, + NewBucket: &storagebuckets.StorageBucket{ + BucketName: "foo", + Attributes: &structpb.Struct{ + Fields: map[string]*structpb.Value{ + credential.ConstRegion: structpb.NewStringValue("us-west-2"), + credential.ConstRoleArn: structpb.NewStringValue("arn:aws:iam::098765432109:role/S3Access"), + credential.ConstDisableCredentialRotation: structpb.NewBoolValue(true), + }, + }, + }, + Persisted: &storagebuckets.StorageBucketPersisted{ + Data: &structpb.Struct{ + Fields: map[string]*structpb.Value{ + credential.ConstAccessKeyId: structpb.NewStringValue("AKIAfoobar_rotated_accesskeyid"), + credential.ConstSecretAccessKey: structpb.NewStringValue("secretkey"), + credential.ConstCredsLastRotatedTime: structpb.NewStringValue(time.Now().Add(-time.Hour).Format(time.RFC3339Nano)), + }, + }, + }, + }, + storageOpts: []awsStoragePersistedStateOption{ + withTestS3APIFunc( + newTestMockS3( + nil, + testMockS3WithPutObjectOutput(&s3.PutObjectOutput{}), + testMockS3WithGetObjectOutput(&s3.GetObjectOutput{}), + testMockS3WithHeadObjectOutput(&s3.HeadObjectOutput{}), + ), + ), + }, + credOpts: []credential.AwsCredentialPersistedStateOption{ + credential.WithStateTestOpts([]awsutil.Option{ + awsutil.WithIAMAPIFunc( + awsutil.NewMockIAM( + awsutil.WithDeleteAccessKeyError(fmt.Errorf("delete access key fail oops")), + ), + ), + }), + }, + expectedErrContains: "delete access key fail oops", + expectedErrCode: codes.InvalidArgument, + }, + { + name: "success static rotated to dynamic credentials", + req: &pb.OnUpdateStorageBucketRequest{ + CurrentBucket: &storagebuckets.StorageBucket{ + BucketName: "foo", + Attributes: &structpb.Struct{ + Fields: map[string]*structpb.Value{ + credential.ConstRegion: structpb.NewStringValue("us-west-2"), + }, + }, + }, + NewBucket: &storagebuckets.StorageBucket{ + BucketName: "foo", + Attributes: &structpb.Struct{ + Fields: map[string]*structpb.Value{ + credential.ConstRegion: structpb.NewStringValue("us-west-2"), + credential.ConstRoleArn: structpb.NewStringValue("arn:aws:iam::098765432109:role/S3Access"), + credential.ConstDisableCredentialRotation: structpb.NewBoolValue(true), + }, + }, + }, + Persisted: &storagebuckets.StorageBucketPersisted{ + Data: &structpb.Struct{ + Fields: map[string]*structpb.Value{ + credential.ConstAccessKeyId: structpb.NewStringValue("AKIAfoobar_rotated_accesskeyid"), + credential.ConstSecretAccessKey: structpb.NewStringValue("secretkey"), + credential.ConstCredsLastRotatedTime: structpb.NewStringValue(time.Now().Add(-time.Hour).Format(time.RFC3339Nano)), + }, + }, + }, + }, + credOpts: []credential.AwsCredentialPersistedStateOption{ + credential.WithStateTestOpts([]awsutil.Option{ + awsutil.WithIAMAPIFunc( + awsutil.NewMockIAM(), // DeleteAccessKey Success + ), + }), + }, + storageOpts: []awsStoragePersistedStateOption{ + withTestS3APIFunc( + newTestMockS3( + nil, + testMockS3WithPutObjectOutput(&s3.PutObjectOutput{}), + testMockS3WithGetObjectOutput(&s3.GetObjectOutput{}), + testMockS3WithHeadObjectOutput(&s3.HeadObjectOutput{}), + ), + ), + }, + expectedPersistedData: map[string]any{}, + }, + { + name: "dry run fail on dynamic to static non-rotated credentials update", + req: &pb.OnUpdateStorageBucketRequest{ + CurrentBucket: &storagebuckets.StorageBucket{ + BucketName: "foo", + Attributes: &structpb.Struct{ + Fields: map[string]*structpb.Value{ + credential.ConstRegion: structpb.NewStringValue("us-west-2"), + credential.ConstRoleArn: structpb.NewStringValue("arn:aws:iam::098765432109:role/S3Access"), + credential.ConstDisableCredentialRotation: structpb.NewBoolValue(true), + }, + }, + }, + NewBucket: &storagebuckets.StorageBucket{ + BucketName: "foo", + Attributes: &structpb.Struct{ + Fields: map[string]*structpb.Value{ + credential.ConstRegion: structpb.NewStringValue("us-west-2"), + credential.ConstDisableCredentialRotation: structpb.NewBoolValue(true), + }, + }, + Secrets: &structpb.Struct{ + Fields: map[string]*structpb.Value{ + credential.ConstAccessKeyId: structpb.NewStringValue("AKIA_foobar"), + credential.ConstSecretAccessKey: structpb.NewStringValue("secretkey"), + }, + }, + }, + Persisted: &storagebuckets.StorageBucketPersisted{ + Data: &structpb.Struct{Fields: map[string]*structpb.Value{}}, + }, + }, + storageOpts: []awsStoragePersistedStateOption{ + withTestS3APIFunc( + newTestMockS3( + nil, + testMockS3WithPutObjectError(fmt.Errorf("put object fail oops")), + testMockS3WithGetObjectOutput(&s3.GetObjectOutput{}), + testMockS3WithHeadObjectOutput(&s3.HeadObjectOutput{}), + ), + ), + }, + expectedErrContains: "put object fail oops", + expectedErrCode: codes.InvalidArgument, + }, + { + name: "success dynamic to static non-rotated credentials", + req: &pb.OnUpdateStorageBucketRequest{ + CurrentBucket: &storagebuckets.StorageBucket{ + BucketName: "foo", + Attributes: &structpb.Struct{ + Fields: map[string]*structpb.Value{ + credential.ConstRegion: structpb.NewStringValue("us-west-2"), + credential.ConstRoleArn: structpb.NewStringValue("arn:aws:iam::098765432109:role/S3Access"), + credential.ConstDisableCredentialRotation: structpb.NewBoolValue(true), + }, + }, + }, + NewBucket: &storagebuckets.StorageBucket{ + BucketName: "foo", + Attributes: &structpb.Struct{ + Fields: map[string]*structpb.Value{ + credential.ConstRegion: structpb.NewStringValue("us-west-2"), + credential.ConstDisableCredentialRotation: structpb.NewBoolValue(true), + }, + }, + Secrets: &structpb.Struct{ + Fields: map[string]*structpb.Value{ + credential.ConstAccessKeyId: structpb.NewStringValue("AKIA_foobar"), + credential.ConstSecretAccessKey: structpb.NewStringValue("secretkey"), + }, + }, + }, + Persisted: &storagebuckets.StorageBucketPersisted{ + Data: &structpb.Struct{Fields: map[string]*structpb.Value{}}, + }, + }, + storageOpts: []awsStoragePersistedStateOption{ + withTestS3APIFunc( + newTestMockS3( + nil, + testMockS3WithPutObjectOutput(&s3.PutObjectOutput{}), + testMockS3WithGetObjectOutput(&s3.GetObjectOutput{}), + testMockS3WithHeadObjectOutput(&s3.HeadObjectOutput{}), + ), + ), + }, + expectedPersistedData: map[string]any{ + credential.ConstAccessKeyId: "AKIA_foobar", + credential.ConstSecretAccessKey: "secretkey", + }, + }, + { + name: "create access key error on dynamic to static rotated credentials update", + req: &pb.OnUpdateStorageBucketRequest{ + CurrentBucket: &storagebuckets.StorageBucket{ + BucketName: "foo", + Attributes: &structpb.Struct{ + Fields: map[string]*structpb.Value{ + credential.ConstRegion: structpb.NewStringValue("us-west-2"), + credential.ConstRoleArn: structpb.NewStringValue("arn:aws:iam::098765432109:role/S3Access"), + credential.ConstDisableCredentialRotation: structpb.NewBoolValue(true), + }, + }, + }, + NewBucket: &storagebuckets.StorageBucket{ + BucketName: "foo", + Attributes: &structpb.Struct{ + Fields: map[string]*structpb.Value{ + credential.ConstRegion: structpb.NewStringValue("us-west-2"), + }, + }, + Secrets: &structpb.Struct{ + Fields: map[string]*structpb.Value{ + credential.ConstAccessKeyId: structpb.NewStringValue("AKIA_foobar"), + credential.ConstSecretAccessKey: structpb.NewStringValue("secretkey"), + }, + }, + }, + Persisted: &storagebuckets.StorageBucketPersisted{ + Data: &structpb.Struct{Fields: map[string]*structpb.Value{}}, + }, + }, + storageOpts: []awsStoragePersistedStateOption{ + withTestS3APIFunc( + newTestMockS3( + nil, + testMockS3WithPutObjectOutput(&s3.PutObjectOutput{}), + testMockS3WithGetObjectOutput(&s3.GetObjectOutput{}), + testMockS3WithHeadObjectOutput(&s3.HeadObjectOutput{}), + ), + ), + }, + credOpts: []credential.AwsCredentialPersistedStateOption{ + credential.WithStateTestOpts([]awsutil.Option{ + awsutil.WithSTSAPIFunc( + awsutil.NewMockSTS(), + ), + awsutil.WithIAMAPIFunc( + awsutil.NewMockIAM( + awsutil.WithGetUserOutput( + &iam.GetUserOutput{ + User: &iamTypes.User{ + Arn: aws.String("arn:aws:iam::123456789012:user/JohnDoe"), + UserId: aws.String("AIDAJQABLZS4A3QDU576Q"), + UserName: aws.String("JohnDoe"), + }, + }, + ), + awsutil.WithCreateAccessKeyError(fmt.Errorf("create access key fail oops")), + ), + ), + }), + }, + expectedErrContains: "create access key fail oops", + expectedErrCode: codes.InvalidArgument, + }, + { + name: "success dynamic to static rotated credentials", + req: &pb.OnUpdateStorageBucketRequest{ + CurrentBucket: &storagebuckets.StorageBucket{ + BucketName: "foo", + Attributes: &structpb.Struct{ + Fields: map[string]*structpb.Value{ + credential.ConstRegion: structpb.NewStringValue("us-west-2"), + credential.ConstRoleArn: structpb.NewStringValue("arn:aws:iam::098765432109:role/S3Access"), + credential.ConstDisableCredentialRotation: structpb.NewBoolValue(true), + }, + }, + }, + NewBucket: &storagebuckets.StorageBucket{ + BucketName: "foo", + Attributes: &structpb.Struct{ + Fields: map[string]*structpb.Value{ + credential.ConstRegion: structpb.NewStringValue("us-west-2"), + }, + }, + Secrets: &structpb.Struct{ + Fields: map[string]*structpb.Value{ + credential.ConstAccessKeyId: structpb.NewStringValue("AKIA_foobar"), + credential.ConstSecretAccessKey: structpb.NewStringValue("secretkey"), + }, + }, + }, + Persisted: &storagebuckets.StorageBucketPersisted{ + Data: &structpb.Struct{Fields: map[string]*structpb.Value{}}, + }, + }, + credOpts: []credential.AwsCredentialPersistedStateOption{ + credential.WithStateTestOpts([]awsutil.Option{ + awsutil.WithSTSAPIFunc( + awsutil.NewMockSTS(), + ), + awsutil.WithIAMAPIFunc( + awsutil.NewMockIAM( + awsutil.WithGetUserOutput( + &iam.GetUserOutput{ + User: &iamTypes.User{ + Arn: aws.String("arn:aws:iam::123456789012:user/JohnDoe"), + UserId: aws.String("AIDAJQABLZS4A3QDU576Q"), + UserName: aws.String("JohnDoe"), + }, + }, + ), + awsutil.WithCreateAccessKeyOutput( + &iam.CreateAccessKeyOutput{ + AccessKey: &iamTypes.AccessKey{ + AccessKeyId: aws.String("AKIA_one"), + SecretAccessKey: aws.String("two"), + UserName: aws.String("JohnDoe"), + }, + }, + ), + ), + ), + }), + }, + storageOpts: []awsStoragePersistedStateOption{ + withTestS3APIFunc( + newTestMockS3( + nil, + testMockS3WithPutObjectOutput(&s3.PutObjectOutput{}), + testMockS3WithGetObjectOutput(&s3.GetObjectOutput{}), + testMockS3WithHeadObjectOutput(&s3.HeadObjectOutput{}), + ), + ), + }, + expectedPersistedData: map[string]any{ + credential.ConstAccessKeyId: "AKIA_one", + credential.ConstSecretAccessKey: "two", + }, + expectCredsLastRotatedTime: true, }, } @@ -735,6 +1417,7 @@ func TestStoragePlugin_OnUpdateStorageBucket(t *testing.T) { } resp, err := p.OnUpdateStorageBucket(context.Background(), tc.req) if tc.expectedErrContains != "" { + require.NotNil(err) require.Contains(err.Error(), tc.expectedErrContains) require.Equal(status.Code(err).String(), tc.expectedErrCode.String()) return @@ -744,18 +1427,18 @@ func TestStoragePlugin_OnUpdateStorageBucket(t *testing.T) { require.NotNil(resp.GetPersisted()) require.NotNil(resp.GetPersisted().GetData()) - var disableCredentialRotation bool - expectedAccessKey := tc.req.Persisted.Data.AsMap()[credential.ConstAccessKeyId] - expectedSecretKey := tc.req.Persisted.Data.AsMap()[credential.ConstSecretAccessKey] - if value, ok := tc.req.GetNewBucket().GetAttributes().AsMap()[credential.ConstDisableCredentialRotation]; ok { - disableCredentialRotation = value.(bool) + actualPersistedData := resp.GetPersisted().GetData().AsMap() + if len(actualPersistedData) > 0 && len(tc.expectedPersistedData) == 0 { + t.Fatalf("test case expected no persisted data, but got %#v", actualPersistedData) } - if !disableCredentialRotation { - expectedAccessKey = "AKIA_one" - expectedSecretKey = "two" + if len(tc.expectedPersistedData) != 0 { + require.NotEmpty(actualPersistedData) + require.Equal(tc.expectedPersistedData[credential.ConstAccessKeyId], actualPersistedData[credential.ConstAccessKeyId]) + require.Equal(tc.expectedPersistedData[credential.ConstSecretAccessKey], actualPersistedData[credential.ConstSecretAccessKey]) + if tc.expectCredsLastRotatedTime { + require.NotEmpty(actualPersistedData[credential.ConstCredsLastRotatedTime]) + } } - require.Equal(expectedAccessKey, resp.GetPersisted().GetData().AsMap()[credential.ConstAccessKeyId]) - require.Equal(expectedSecretKey, resp.GetPersisted().GetData().AsMap()[credential.ConstSecretAccessKey]) }) } } @@ -899,6 +1582,23 @@ func TestStoragePlugin_OnDeleteStorageBucket(t *testing.T) { }), }, }, + { + name: "success with dynamic credentials", + req: &pb.OnDeleteStorageBucketRequest{ + Bucket: &storagebuckets.StorageBucket{ + Attributes: &structpb.Struct{ + Fields: map[string]*structpb.Value{ + credential.ConstRegion: structpb.NewStringValue("us-west-2"), + credential.ConstRoleArn: structpb.NewStringValue("arn:aws:iam::098765432109:role/S3Access"), + credential.ConstDisableCredentialRotation: structpb.NewBoolValue(true), + }, + }, + }, + Persisted: &storagebuckets.StorageBucketPersisted{ + Data: &structpb.Struct{Fields: map[string]*structpb.Value{}}, + }, + }, + }, } for _, tc := range cases { @@ -1082,6 +1782,37 @@ func TestStoragePlugin_HeadObject(t *testing.T) { ), }, }, + { + name: "success with dynamic credentials", + req: &pb.HeadObjectRequest{ + Key: "/foo/bar/key", + Bucket: &storagebuckets.StorageBucket{ + BucketName: "foo", + Secrets: new(structpb.Struct), + Attributes: &structpb.Struct{ + Fields: map[string]*structpb.Value{ + credential.ConstRegion: structpb.NewStringValue("us-west-2"), + credential.ConstRoleArn: structpb.NewStringValue("arn:aws:iam::123456789012:role/S3Access"), + credential.ConstDisableCredentialRotation: structpb.NewBoolValue(true), + }, + }, + }, + }, + credOpts: validSTSMock(), + contentLength: 1024, + lastModified: createTime(t, "2006-01-02T15:04:05Z"), + storageOpts: []awsStoragePersistedStateOption{ + withTestS3APIFunc( + newTestMockS3( + nil, + testMockS3WithHeadObjectOutput(&s3.HeadObjectOutput{ + ContentLength: 1024, + LastModified: aws.Time(createTime(t, "2006-01-02T15:04:05Z")), + }), + ), + ), + }, + }, } for _, tc := range cases { @@ -1312,6 +2043,32 @@ func TestStoragePlugin_ValidatePermissions(t *testing.T) { ), }, }, + { + name: "success with dynamic credentials", + req: &pb.ValidatePermissionsRequest{ + Bucket: &storagebuckets.StorageBucket{ + BucketName: "foo", + Attributes: &structpb.Struct{ + Fields: map[string]*structpb.Value{ + credential.ConstRegion: structpb.NewStringValue("us-west-2"), + credential.ConstRoleArn: structpb.NewStringValue("arn:aws:iam::123456789012:role/S3Access"), + credential.ConstDisableCredentialRotation: structpb.NewBoolValue(true), + }, + }, + }, + }, + credOpts: validSTSMock(), + storageOpts: []awsStoragePersistedStateOption{ + withTestS3APIFunc( + newTestMockS3( + nil, + testMockS3WithPutObjectOutput(&s3.PutObjectOutput{}), + testMockS3WithGetObjectOutput(&s3.GetObjectOutput{}), + testMockS3WithHeadObjectOutput(&s3.HeadObjectOutput{}), + ), + ), + }, + }, } for _, tc := range cases { @@ -1513,6 +2270,34 @@ func TestStoragePlugin_GetObject(t *testing.T) { ), }, }, + { + name: "success with dynamic credentials", + req: &pb.GetObjectRequest{ + Key: "/foo/bar/key", + Bucket: &storagebuckets.StorageBucket{ + BucketName: "foo", + Attributes: &structpb.Struct{ + Fields: map[string]*structpb.Value{ + credential.ConstRegion: structpb.NewStringValue("us-west-2"), + credential.ConstRoleArn: structpb.NewStringValue("arn:aws:iam::123456789012:role/S3Access"), + credential.ConstDisableCredentialRotation: structpb.NewBoolValue(true), + }, + }, + }, + }, + credOpts: validSTSMock(), + objectData: []byte("test"), + storageOpts: []awsStoragePersistedStateOption{ + withTestS3APIFunc( + newTestMockS3( + nil, + testMockS3WithGetObjectOutput(&s3.GetObjectOutput{ + Body: ioutil.NopCloser(bytes.NewReader([]byte("test"))), + }), + ), + ), + }, + }, } for _, tc := range cases { @@ -1881,6 +2666,43 @@ func TestStoragePlugin_PutObject(t *testing.T) { } }(), }, + { + name: "valid file using dynamic credentials", + request: &pb.PutObjectRequest{ + Bucket: &storagebuckets.StorageBucket{ + BucketName: "external-obj-store", + Attributes: &structpb.Struct{ + Fields: map[string]*structpb.Value{ + credential.ConstRegion: structpb.NewStringValue("us-west-2"), + credential.ConstRoleArn: structpb.NewStringValue("arn:aws:iam::123456789012:role/S3Access"), + credential.ConstDisableCredentialRotation: structpb.NewBoolValue(true), + }, + }, + }, + Key: "mock-object", + Path: validFilePath, + }, + credOpts: validSTSMock(), + expectedObject: []byte("CONTENT CHECK"), + storageOpts: func() []awsStoragePersistedStateOption { + hash := sha256.New() + _, err := hash.Write([]byte("CONTENT CHECK")) + require.NoError(t, err) + checksum := base64.StdEncoding.EncodeToString(hash.Sum(nil)) + return []awsStoragePersistedStateOption{ + withTestS3APIFunc( + newTestMockS3( + &testMockS3State{ + PutObjectBody: []byte("CONTENT CHECK"), + }, + testMockS3WithPutObjectOutput(&s3.PutObjectOutput{ + ChecksumSHA256: aws.String(checksum), + }), + ), + ), + } + }(), + }, } for _, tc := range cases { diff --git a/plugin/service/storage/state.go b/plugin/service/storage/state.go index ed769f3..1a0032f 100644 --- a/plugin/service/storage/state.go +++ b/plugin/service/storage/state.go @@ -9,12 +9,12 @@ import ( "fmt" "net" "net/http" + "net/url" "time" "github.com/aws/aws-sdk-go-v2/aws" - "github.com/aws/aws-sdk-go-v2/config" - "github.com/aws/aws-sdk-go-v2/credentials" "github.com/aws/aws-sdk-go-v2/service/s3" + smithyEndpoints "github.com/aws/smithy-go/endpoints" "github.com/hashicorp/boundary-plugin-aws/internal/credential" "github.com/hashicorp/boundary/sdk/pbs/controller/api/resources/storagebuckets" "google.golang.org/protobuf/types/known/structpb" @@ -97,55 +97,23 @@ func (s *awsStoragePersistedState) S3Client(ctx context.Context, opt ...s3Option if err != nil { return nil, fmt.Errorf("error parsing options when fetching S3 client: %w", err) } - - awsConfigOpts := []func(*config.LoadOptions) error{} - sess, err := s.GetSession() + awsCfg, err := s.GenerateCredentialChain(ctx) if err != nil { - return nil, fmt.Errorf("error getting AWS session when fetching S3 client: %w", err) + return nil, fmt.Errorf("error getting AWS configuration when fetching S3 client: %w", err) } - creds, err := sess.Config.Credentials.Get() - if err != nil { - return nil, fmt.Errorf("error getting AWS session when fetching S3 client: %w", err) - } - customCredentials := config.WithCredentialsProvider( - credentials.NewStaticCredentialsProvider(creds.AccessKeyID, creds.SecretAccessKey, creds.SessionToken), - ) - awsConfigOpts = append(awsConfigOpts, customCredentials) - if sess.Config.Region != nil { - awsConfigOpts = append(awsConfigOpts, config.WithRegion(*sess.Config.Region)) - } - if opts.withRegion != "" { - awsConfigOpts = append(awsConfigOpts, config.WithRegion(opts.withRegion)) + if awsCfg == nil { + return nil, fmt.Errorf("nil aws configuration") } + var s3Opts []func(*s3.Options) if opts.withEndpoint != "" { - customResolver := aws.EndpointResolverWithOptionsFunc(func(service, region string, options ...interface{}) (aws.Endpoint, error) { - if service == s3.ServiceID { - if opts.withRegion != "" { - region = opts.withRegion - } - return aws.Endpoint{ - PartitionID: "aws", - URL: opts.withEndpoint, - SigningRegion: region, - }, nil - } - // returning EndpointNotFoundError will allow the service to fallback to it's default resolution - return aws.Endpoint{}, &aws.EndpointNotFoundError{} - }) - awsConfigOpts = append(awsConfigOpts, config.WithEndpointResolverWithOptions(customResolver)) + s3Opts = append(s3Opts, s3.WithEndpointResolverV2(&endpointResolver{ + endpoint: opts.withEndpoint, + })) } - cfg, err := config.LoadDefaultConfig(ctx, awsConfigOpts...) - if err != nil { - return nil, fmt.Errorf("error loading aws configuration when fetching S3 client: %w", err) - } - if s.testS3APIFunc != nil { - return s.testS3APIFunc(cfg) + return s.testS3APIFunc(*awsCfg) } - - cfg.HTTPClient = customClient - - return s3.NewFromConfig(cfg), nil + return s3.NewFromConfig(*awsCfg, s3Opts...), nil } // getOpts iterates the inbound s3Options and returns a struct @@ -167,7 +135,6 @@ type s3Option func(*s3Options) error // options = how options are represented type s3Options struct { - withRegion string withEndpoint string } @@ -175,14 +142,6 @@ func getDefaultS3Options() s3Options { return s3Options{} } -// WithRegion contains the region to use -func WithRegion(with string) s3Option { - return func(o *s3Options) error { - o.withRegion = with - return nil - } -} - // WithEndpoint contains the endpoint to use func WithEndpoint(with string) s3Option { return func(o *s3Options) error { @@ -190,3 +149,31 @@ func WithEndpoint(with string) s3Option { return nil } } + +type endpointResolver struct { + endpoint string +} + +func (e *endpointResolver) ResolveEndpoint(ctx context.Context, params s3.EndpointParameters) (resolver smithyEndpoints.Endpoint, err error) { + var uri *url.URL + resolver, err = s3.NewDefaultEndpointResolverV2().ResolveEndpoint(ctx, params) + if err != nil { + return + } + if e.endpoint == "" { + // TODO: not sure if we should set an error or ignore the missing endpoint value and continue with the default resolver + return + } + uri, err = url.Parse(e.endpoint) + if err != nil { + return + } + if uri == nil { + // TODO: not sure if we should set an error or ignore the missing uri value and continue with the default resolver + return + } + resolver = smithyEndpoints.Endpoint{ + URI: *uri, + } + return +} diff --git a/plugin/service/storage/state_test.go b/plugin/service/storage/state_test.go index 8efa3fc..a47831b 100644 --- a/plugin/service/storage/state_test.go +++ b/plugin/service/storage/state_test.go @@ -7,9 +7,8 @@ import ( "context" "testing" - "github.com/aws/aws-sdk-go-v2/service/s3" "github.com/hashicorp/boundary-plugin-aws/internal/credential" - "github.com/hashicorp/go-secure-stdlib/awsutil" + "github.com/hashicorp/go-secure-stdlib/awsutil/v2" "github.com/stretchr/testify/require" ) @@ -43,7 +42,6 @@ func TestAwsStoragePersistedStateS3Client(t *testing.T) { withTestS3APIFunc(newTestMockS3(nil)), }, s3Opts: []s3Option{ - WithRegion("us-west-2"), WithEndpoint("0.0.0.0"), }, expectedRegion: "us-west-2", @@ -73,14 +71,6 @@ func TestAwsStoragePersistedStateS3Client(t *testing.T) { require.True(ok) require.NotNil(client) require.Equal(tc.expectedRegion, client.Region) - - if tc.expectedEndpoint != "" { - endpoint, err := client.Endpoint.ResolveEndpoint(s3.ServiceID, client.Region) - require.NoError(err) - require.Equal(tc.expectedEndpoint, endpoint.URL) - require.Equal(tc.expectedRegion, endpoint.SigningRegion) - require.Equal("aws", endpoint.PartitionID) - } }) } } diff --git a/plugin/service/storage/testing.go b/plugin/service/storage/testing.go index 50e4521..33a3453 100644 --- a/plugin/service/storage/testing.go +++ b/plugin/service/storage/testing.go @@ -13,11 +13,11 @@ import ( "github.com/aws/aws-sdk-go-v2/aws" "github.com/aws/aws-sdk-go-v2/service/s3" - "github.com/aws/aws-sdk-go/service/sts" + "github.com/aws/aws-sdk-go-v2/service/sts" "github.com/hashicorp/boundary-plugin-aws/internal/credential" "github.com/hashicorp/boundary/sdk/pbs/controller/api/resources/storagebuckets" pb "github.com/hashicorp/boundary/sdk/pbs/plugin" - "github.com/hashicorp/go-secure-stdlib/awsutil" + "github.com/hashicorp/go-secure-stdlib/awsutil/v2" "github.com/stretchr/testify/require" "google.golang.org/grpc" "google.golang.org/protobuf/types/known/structpb" @@ -69,9 +69,8 @@ func (s *testMockS3State) Reset() { type testMockS3 struct { S3API - State *testMockS3State - Region string - Endpoint aws.EndpointResolverWithOptions + State *testMockS3State + Region string // mocked responses for getObject GetObjectOutput *s3.GetObjectOutput @@ -147,9 +146,6 @@ func newTestMockS3(state *testMockS3State, opts ...testMockS3Option) s3APIFunc { if cfg.Region != "" { m.Region = cfg.Region } - if cfg.EndpointResolverWithOptions != nil { - m.Endpoint = cfg.EndpointResolverWithOptions - } } return m, nil diff --git a/testing/e2e_storage_test.go b/testing/e2e_storage_test.go index dfa8238..f71deef 100644 --- a/testing/e2e_storage_test.go +++ b/testing/e2e_storage_test.go @@ -75,7 +75,7 @@ func TestStoragePlugin(t *testing.T) { iamSecretAccessKeyMissingPutObject, err := tf.GetOutputString("iam_secret_access_key_missing_put_obj") require.NoError(err) - // Start the workflow now. Set up the host catalog. Note that this + // Start the workflow now. Set up the storage bucket. Note that this // will cause the state to go out of drift above in the sense that // the access key ID/secret access key will no longer be valid. We // will assert this through the returned state. diff --git a/testing/testing.go b/testing/testing.go index e97cc92..682a64e 100644 --- a/testing/testing.go +++ b/testing/testing.go @@ -13,10 +13,10 @@ import ( "testing" "time" - "github.com/aws/aws-sdk-go/aws/awserr" + "github.com/aws/smithy-go" "github.com/hashicorp/boundary-plugin-aws/internal/credential" "github.com/hashicorp/boundary-plugin-aws/internal/values" - "github.com/hashicorp/go-secure-stdlib/awsutil" + "github.com/hashicorp/go-secure-stdlib/awsutil/v2" tfjson "github.com/hashicorp/terraform-json" "github.com/stretchr/testify/require" "google.golang.org/protobuf/types/known/structpb" @@ -293,7 +293,7 @@ func requireCredentialsInvalid(t *testing.T, accessKeyId, secretAccessKey string defer cancel() waitErr: for { - _, err = c.GetCallerIdentity() + _, err = c.GetCallerIdentity(context.Background()) if err != nil { break } @@ -308,9 +308,9 @@ waitErr: } require.NotNil(err) - awsErr, ok := err.(awserr.Error) - require.True(ok) - require.Equal("InvalidClientTokenId", awsErr.Code()) + var ae smithy.APIError + require.True(errors.As(err, &ae)) + require.Equal("InvalidClientTokenId", ae.ErrorCode()) } func requireCredentialsValid(t *testing.T, accessKeyId, secretAccessKey string) { @@ -322,7 +322,7 @@ func requireCredentialsValid(t *testing.T, accessKeyId, secretAccessKey string) awsutil.WithSecretKey(secretAccessKey), ) require.NoError(err) - _, err = c.GetCallerIdentity() + _, err = c.GetCallerIdentity(context.Background()) require.NoError(err) }