objstore: AWS provisioner (S3 + IAM, path-scoped)
Implements Provisioner against real AWS S3 and IAM behind narrow
unexported s3API/iamAPI seams, so idempotence mappings are unit-tested
against stubbed clients returning typed aws-sdk-go-v2 errors, no live
AWS or network required.
Idempotence mappings preserved (HARD constraints from Tasks 4-6):
- DeleteKey on an absent key (NoSuchEntityException) -> nil
- DeleteUser on an absent user -> nil; keys + inline policy deleted
before DeleteUser
- ReapKeys on an absent user (NoSuchEntity from ListAccessKeys) -> nil
- EmptyBucket/DeleteBucket on an absent bucket -> nil (NoSuchBucket,
matched both as the typed s3types.NoSuchBucket and as a generic
smithy.APIError code, since ListObjectsV2 surfaces the latter)
- EnsureBucket: BucketAlreadyOwnedByYou -> success + configured region
- EnsureUser: EntityAlreadyExists -> GetUser for ARN, PutUserPolicy
unconditional (idempotent overwrite)
EmptyBucket paginates ListObjectsV2 and batches DeleteObjects at
<=1000 keys, resumable across a crash. Policy document, name
("carlos-store") and IAM path (UserPath) match the brief exactly.
New dependency: aws-sdk-go-v2/service/iam.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
4 files changed,
+953
−12
go.mod+5 −4go.sum+108 −8internal/objstore/aws.go+356 −0internal/objstore/aws_test.go+484 −0
diff --git a/go.mod b/go.mod| index 2c15bd17c..7ed1528cc 100644 |
| --- a/go.mod |
| +++ b/go.mod |
| @@ -3,12 +3,13 @@ module github.com/carlosframework/platform |
| go 1.26 |
| require ( |
| - github.com/aws/aws-sdk-go-v2 v1.42.1 |
| + github.com/aws/aws-sdk-go-v2 v1.43.5 |
| github.com/aws/aws-sdk-go-v2/config v1.32.6 |
| github.com/aws/aws-sdk-go-v2/credentials v1.19.6 |
| + github.com/aws/aws-sdk-go-v2/service/iam v1.58.2 |
| github.com/aws/aws-sdk-go-v2/service/route53 v1.64.1 |
| github.com/aws/aws-sdk-go-v2/service/s3 v1.105.0 |
| - github.com/aws/smithy-go v1.27.3 |
| + github.com/aws/smithy-go v1.27.7 |
| github.com/benbjohnson/litestream v0.5.14 |
| golang.org/x/crypto v0.52.0 |
| golang.org/x/net v0.55.0 |
| @@ -19,8 +20,8 @@ require ( |
| github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.14 // indirect |
| github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.16 // indirect |
| github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.20.18 // indirect |
| - github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.30 // indirect |
| - github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.30 // indirect |
| + github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.36 // indirect |
| + github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.36 // indirect |
| github.com/aws/aws-sdk-go-v2/internal/ini v1.8.4 // indirect |
| github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.31 // indirect |
| github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.13 // indirect |
diff --git a/go.sum b/go.sum| index 5381a37b1..a6019d188 100644 |
| --- a/go.sum |
| +++ b/go.sum |
| @@ -1,5 +1,25 @@ |
| -github.com/aws/aws-sdk-go-v2 v1.42.1 h1:9eOTgu1z/dVtYpNZ3/8/XbbaX0x/BqE3HUzAzs6K0ek= |
| -github.com/aws/aws-sdk-go-v2 v1.42.1/go.mod h1:5pKeft2eJj+gElQ38Jqg4ibCqh+/AK33/0X3hip7IjM= |
| +cloud.google.com/go v0.112.0 h1:tpFCD7hpHFlQ8yPwT3x+QeXqc2T6+n6T+hmABHfDUSM= |
| +cloud.google.com/go v0.112.0/go.mod h1:3jEEVwZ/MHU4djK5t5RHuKOA/GbLddgTdVubX1qnPD4= |
| +cloud.google.com/go/compute/metadata v0.9.0 h1:pDUj4QMoPejqq20dK0Pg2N4yG9zIkYGdBtwLoEkH9Zs= |
| +cloud.google.com/go/compute/metadata v0.9.0/go.mod h1:E0bWwX5wTnLPedCKqk3pJmVgCBSM6qQI1yTBdEb3C10= |
| +cloud.google.com/go/iam v1.1.5 h1:1jTsCu4bcsNsE4iiqNT5SHwrDRCfRmIaaaVFhRveTJI= |
| +cloud.google.com/go/iam v1.1.5/go.mod h1:rB6P/Ic3mykPbFio+vo7403drjlgvoWfYpJhMXEbzv8= |
| +cloud.google.com/go/storage v1.36.0 h1:P0mOkAcaJxhCTvAkMhxMfrTKiNcub4YmmPBtlhAyTr8= |
| +cloud.google.com/go/storage v1.36.0/go.mod h1:M6M/3V/D3KpzMTJyPOR/HU6n2Si5QdaXYEsng2xgOs8= |
| +github.com/Azure/azure-sdk-for-go/sdk/azcore v1.18.2 h1:Hr5FTipp7SL07o2FvoVOX9HRiRH3CR3Mj8pxqCcdD5A= |
| +github.com/Azure/azure-sdk-for-go/sdk/azcore v1.18.2/go.mod h1:QyVsSSN64v5TGltphKLQ2sQxe4OBQg0J1eKRcVBnfgE= |
| +github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.11.0 h1:MhRfI58HblXzCtWEZCO0feHs8LweePB3s90r7WaR1KU= |
| +github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.11.0/go.mod h1:okZ+ZURbArNdlJ+ptXoyHNuOETzOl1Oww19rm8I2WLA= |
| +github.com/Azure/azure-sdk-for-go/sdk/internal v1.11.2 h1:9iefClla7iYpfYWdzPCRDozdmndjTm8DXdpCzPajMgA= |
| +github.com/Azure/azure-sdk-for-go/sdk/internal v1.11.2/go.mod h1:XtLgD3ZD34DAaVIIAyG3objl5DynM3CQ/vMcbBNJZGI= |
| +github.com/Azure/azure-sdk-for-go/sdk/storage/azblob v1.6.2 h1:FwladfywkNirM+FZYLBR2kBz5C8Tg0fw5w5Y7meRXWI= |
| +github.com/Azure/azure-sdk-for-go/sdk/storage/azblob v1.6.2/go.mod h1:vv5Ad0RrIoT1lJFdWBZwt4mB1+j+V8DUroixmKDTCdk= |
| +github.com/AzureAD/microsoft-authentication-library-for-go v1.4.2 h1:oygO0locgZJe7PpYPXT5A29ZkwJaPqcva7BVeemZOZs= |
| +github.com/AzureAD/microsoft-authentication-library-for-go v1.4.2/go.mod h1:wP83P5OoQ5p6ip3ScPr0BAq0BvuPAvacpEuSzyouqAI= |
| +github.com/aliyun/alibabacloud-oss-go-sdk-v2 v1.3.0 h1:wQlqotpyjYPjJz+Noh5bRu7Snmydk8SKC5Z6u1CR20Y= |
| +github.com/aliyun/alibabacloud-oss-go-sdk-v2 v1.3.0/go.mod h1:FTzydeQVmR24FI0D6XWUOMKckjXehM/jgMn1xC+DA9M= |
| +github.com/aws/aws-sdk-go-v2 v1.43.5 h1:yKT5GYnFWhuDo+DqKvE5ZPwVn3RjC4MAeBtZGlh6AVM= |
| +github.com/aws/aws-sdk-go-v2 v1.43.5/go.mod h1:wZjAJppCntyOGgVSmgVTfDyRJK5PHOasO6Wsy8U7Axk= |
| github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.14 h1:3IZY0XAJquT3aHzbkHfPzy4ACPcEjVG0x87KOwtpqGY= |
| github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.14/go.mod h1:zwM6veDkhGgQFqkBy+uT28AAYpLu+uFMlPl+rCg/73E= |
| github.com/aws/aws-sdk-go-v2/config v1.32.6 h1:hFLBGUKjmLAekvi1evLi5hVvFQtSo3GYwi+Bx4lpJf8= |
| @@ -10,14 +30,16 @@ github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.16 h1:80+uETIWS1BqjnN9uJ0dBU |
| github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.16/go.mod h1:wOOsYuxYuB/7FlnVtzeBYRcjSRtQpAW0hCP7tIULMwo= |
| github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.20.18 h1:9vWXHtaepwoAl/UuKzxwgOoJDXPCC3hvgNMfcmdS2Tk= |
| github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.20.18/go.mod h1:sKuUZ+MwUTuJbYvZ8pK0x10LvgcJK3Y4rmh63YBekwk= |
| -github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.30 h1:xM/Is9cKMHa8Jj8zkvWhvrFkZsXJV9E+BB4g0HW0duQ= |
| -github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.30/go.mod h1:WueJeNDZvK1fMYEWJIkcivBfEzUkTpBhzlrUKKY8EuA= |
| -github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.30 h1:jn46zC9LdsVR/ZpMIJqMqb8hHv31BlLx3ulVqNspUOk= |
| -github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.30/go.mod h1:1hTMsAgbdS/AtUi4bw8+gUuh1pceo+eXRLfpSuSQj3M= |
| +github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.36 h1:5CrzwxDqf4w3x1Vs3/NiZ0nsC34Hbm3pIDMWbsLebOE= |
| +github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.36/go.mod h1:A3gHdKZIvG/QXERzZwcxNS3RNDFcRCuhhTFBYp+V/nw= |
| +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.36 h1:A4N2f4YPcST0v+dWtX+xrpPPCL9VTBhoIFFUWYqbacE= |
| +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.36/go.mod h1:B/Qr859uxWUEfZeGotK5KAEoof4Q9YWgNtPSwV6jcyk= |
| github.com/aws/aws-sdk-go-v2/internal/ini v1.8.4 h1:WKuaxf++XKWlHWu9ECbMlha8WOEGm0OUEZqm4K/Gcfk= |
| github.com/aws/aws-sdk-go-v2/internal/ini v1.8.4/go.mod h1:ZWy7j6v1vWGmPReu0iSGvRiise4YI5SkR3OHKTZ6Wuc= |
| github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.31 h1:3GUprIsfmGcC5SACIyB0e7E0BM1O1b3Erl5CePYIAeQ= |
| github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.31/go.mod h1:7PuV1yl5e2xnUbm+RqvVg5i2iBM8EyijZNoI9wsOoOc= |
| +github.com/aws/aws-sdk-go-v2/service/iam v1.58.2 h1:/6iRcqrC6k1rMA6uCZMzFE9inOrBpNmhbrZ90X8qH50= |
| +github.com/aws/aws-sdk-go-v2/service/iam v1.58.2/go.mod h1:Wm74PIQWDrV2tGPFYtwLqh8jZ7JU/rnq/leiC6UI4dg= |
| github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.13 h1:mbRIur/BiHK6SKPjoBIXSE/hJ6g6JGRLuxQy1jGjlN4= |
| github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.13/go.mod h1:ITg9em2KbJx1s0y4aqRX5OYWG6HBZ5TVR//OdpEZ2CQ= |
| github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.23 h1:9Fjh6fi/U5JEStVZijmaMpUwE/gvBJj7x2B/PjbO9To= |
| @@ -38,21 +60,43 @@ github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.12 h1:AHDr0DaHIAo8c9t1emrzAlV |
| github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.12/go.mod h1:GQ73XawFFiWxyWXMHWfhiomvP3tXtdNar/fi8z18sx0= |
| github.com/aws/aws-sdk-go-v2/service/sts v1.41.5 h1:SciGFVNZ4mHdm7gpD1dgZYnCuVdX1s+lFTg4+4DOy70= |
| github.com/aws/aws-sdk-go-v2/service/sts v1.41.5/go.mod h1:iW40X4QBmUxdP+fZNOpfmkdMZqsovezbAeO+Ubiv2pk= |
| -github.com/aws/smithy-go v1.27.3 h1:F3Zb497UhhskkfpJmfkXswyo+t0sh9OTBnIHjogWbVY= |
| -github.com/aws/smithy-go v1.27.3/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc= |
| +github.com/aws/smithy-go v1.27.7 h1:Zgj5z4LfcDYoQIVk+n/yGdTkP/2y6ZT5vYxe0fp7bqE= |
| +github.com/aws/smithy-go v1.27.7/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc= |
| github.com/benbjohnson/litestream v0.5.14 h1:pu2RCUfEs2MfAvxSttNg/kVVzJspqg2sX/pPW3Elzxc= |
| github.com/benbjohnson/litestream v0.5.14/go.mod h1:aN5OtdkcCz07xVjCbmMWOGUSIdPumpqBYtdyrzNMhOI= |
| github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= |
| github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= |
| github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= |
| github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= |
| +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= |
| +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= |
| github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= |
| github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= |
| +github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= |
| +github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= |
| +github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= |
| +github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= |
| +github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= |
| +github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= |
| +github.com/golang-jwt/jwt/v5 v5.3.0 h1:pv4AsKCKKZuqlgs5sUmn4x8UlGa0kEVt/puTpKx9vvo= |
| +github.com/golang-jwt/jwt/v5 v5.3.0/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= |
| +github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da h1:oI5xCqsCo564l8iNU+DwB5epxmsaqB+rhGL0m5jtYqE= |
| +github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= |
| +github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= |
| +github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= |
| github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= |
| +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= |
| +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= |
| github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs= |
| github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA= |
| +github.com/google/s2a-go v0.1.7 h1:60BLSyTrOV4/haCDW4zb1guZItoSq8foHCXrAnjBo/o= |
| +github.com/google/s2a-go v0.1.7/go.mod h1:50CgR4k1jNlWBu4UfS4AcfhVe1r6pdZPygJ3R8F0Qdw= |
| github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= |
| github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= |
| +github.com/googleapis/enterprise-certificate-proxy v0.3.4 h1:XYIDZApgAnrN1c855gTgghdIA6Stxb52D5RnLI1SLyw= |
| +github.com/googleapis/enterprise-certificate-proxy v0.3.4/go.mod h1:YKe7cfqYXjKGpGvmSg28/fFvhNzinZQm8DGnaburhGA= |
| +github.com/googleapis/gax-go/v2 v2.12.0 h1:A+gCJKdRfqXkr+BIRGtZLibNXf0m1f9E4HG56etFpas= |
| +github.com/googleapis/gax-go/v2 v2.12.0/go.mod h1:y+aIqrI5eb1YGMVJfuV3185Ts/D7qKpsEkdD5+I6QGU= |
| github.com/hablullah/go-hijri v1.0.2 h1:drT/MZpSZJQXo7jftf5fthArShcaMtsal0Zf/dnmp6k= |
| github.com/hablullah/go-hijri v1.0.2/go.mod h1:OS5qyYLDjORXzK4O1adFw9Q5WfhOcMdAKglDkcTxgWQ= |
| github.com/hablullah/go-juliandays v1.0.0 h1:A8YM7wIj16SzlKT0SRJc9CD29iiaUzpBLzh5hr0/5p0= |
| @@ -61,6 +105,12 @@ github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs |
| github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= |
| github.com/jalaali/go-jalaali v0.0.0-20210801064154-80525e88d958 h1:qxLoi6CAcXVzjfvu+KXIXJOAsQB62LXjsfbOaErsVzE= |
| github.com/jalaali/go-jalaali v0.0.0-20210801064154-80525e88d958/go.mod h1:Wqfu7mjUHj9WDzSSPI5KfBclTTEnLveRUFr/ujWnTgE= |
| +github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo= |
| +github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ= |
| +github.com/kr/fs v0.1.0 h1:Jskdu9ieNAYnjxsi0LbQp1ulIKZV1LAFgK1tWhpZgl8= |
| +github.com/kr/fs v0.1.0/go.mod h1:FFnZGqtBN9Gxj7eW1uZ42v5BccTP0vu6NEaFoC2HwRg= |
| +github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= |
| +github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= |
| github.com/lmittmann/tint v1.1.3 h1:Hv4EaHWXQr+GTFnOU4VKf8UvAtZgn0VuKT+G0wFlO3I= |
| github.com/lmittmann/tint v1.1.3/go.mod h1:HIS3gSy7qNwGCj+5oRjAutErFBl4BzdQP6cJZ0NfMwE= |
| github.com/magefile/mage v1.14.0 h1:6QDX3g6z1YvJ4olPhT1wksUcSa/V0a1B+pJb73fBjyo= |
| @@ -70,12 +120,26 @@ github.com/markusmobius/go-dateparser v1.2.4/go.mod h1:CBAUADJuMNhJpyM6IYaWAoFht |
| github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= |
| github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= |
| github.com/mattn/go-sqlite3 v1.14.8/go.mod h1:NyWgC/yNuGj7Q9rpYnZvas74GogHl5/Z4A/KQRfk6bU= |
| +github.com/mattn/go-sqlite3 v1.14.19 h1:fhGleo2h1p8tVChob4I9HpmVFIAkKGpiukdrgQbWfGI= |
| +github.com/mattn/go-sqlite3 v1.14.19/go.mod h1:2eHXhiwb8IkHr+BDWZGa96P6+rkvnG63S2DGjv9HUNg= |
| github.com/matttproud/golang_protobuf_extensions/v2 v2.0.0 h1:jWpvCLoY8Z/e3VKvlsiIGKtc+UG6U5vzxaoagmhXfyg= |
| github.com/matttproud/golang_protobuf_extensions/v2 v2.0.0/go.mod h1:QUyp042oQthUoa9bqDv0ER0wrtXnBruoNd7aNjkbP+k= |
| +github.com/nats-io/nats.go v1.44.0 h1:ECKVrDLdh/kDPV1g0gAQ+2+m2KprqZK5O/eJAyAnH2M= |
| +github.com/nats-io/nats.go v1.44.0/go.mod h1:iRWIPokVIFbVijxuMQq4y9ttaBTMe0SFdlZfMDd+33g= |
| +github.com/nats-io/nkeys v0.4.11 h1:q44qGV008kYd9W1b1nEBkNzvnWxtRSQ7A8BoqRrcfa0= |
| +github.com/nats-io/nkeys v0.4.11/go.mod h1:szDimtgmfOi9n25JpfIdGw12tZFYXqhGxjhVxsatHVE= |
| +github.com/nats-io/nuid v1.0.1 h1:5iA8DT8V7q8WK2EScv2padNa/rTESc1KdnPw4TC2paw= |
| +github.com/nats-io/nuid v1.0.1/go.mod h1:19wcPz3Ph3q0Jbyiqsd0kePYG7A95tJPxeL+1OSON2c= |
| github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w= |
| github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= |
| github.com/pierrec/lz4/v4 v4.1.22 h1:cKFw6uJDK+/gfw5BcDL0JL5aBsAFdsIT18eRtLj7VIU= |
| github.com/pierrec/lz4/v4 v4.1.22/go.mod h1:gZWDp/Ze/IJXGXf23ltt2EXimqmTUXEy0GFuRQyBid4= |
| +github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c h1:+mdjkGKdHQG3305AYmdv1U2eRNDiU2ErMBj1gwrq8eQ= |
| +github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c/go.mod h1:7rwL4CYBLnjLxUqIJNnCWiEdr3bn6IUYi15bNlnbCCU= |
| +github.com/pkg/sftp v1.13.6 h1:JFZT4XbOU7l77xGSpOdW+pwIMqP044IyjXX6FGyEKFo= |
| +github.com/pkg/sftp v1.13.6/go.mod h1:tz1ryNURKu77RL+GuCzmoJYxQczL3wLNNpPWagdg4Qk= |
| +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/prometheus/client_golang v1.17.0 h1:rl2sfwZMtSthVU752MqfjQozy7blglC+1SOtjMAMh+Q= |
| github.com/prometheus/client_golang v1.17.0/go.mod h1:VeL+gMmOAxkS2IqfCq0ZmHSL+LjWfWDUmp1mBz9JgUY= |
| github.com/prometheus/client_model v0.5.0 h1:VQw1hfvPvk3Uv6Qf29VrPF32JB6rtbgI6cYPYQjL0Qw= |
| @@ -88,18 +152,40 @@ github.com/psanford/sqlite3vfs v0.0.0-20251127171934-4e34e03a991a h1:r4YWl0uVObC |
| github.com/psanford/sqlite3vfs v0.0.0-20251127171934-4e34e03a991a/go.mod h1:iW4cSew5PAb1sMZiTEkVJAIBNrepaB6jTYjeP47WtI0= |
| github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= |
| github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= |
| +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= |
| +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= |
| +github.com/studio-b12/gowebdav v0.11.0 h1:qbQzq4USxY28ZYsGJUfO5jR+xkFtcnwWgitp4Zp1irU= |
| +github.com/studio-b12/gowebdav v0.11.0/go.mod h1:bHA7t77X/QFExdeAnDzK6vKM34kEZAcE1OX4MfiwjkE= |
| github.com/superfly/ltx v0.5.1 h1:vGUeBhKvBKZ2s2TsTrOSahz+m0PswfqOWDAD+ICiiYY= |
| github.com/superfly/ltx v0.5.1/go.mod h1:Nf50QAIXU/ET4ua3AuQ2fh31MbgNQZA7r/DYx6Os77s= |
| github.com/tetratelabs/wazero v1.2.1 h1:J4X2hrGzJvt+wqltuvcSjHQ7ujQxA9gb6PeMs4qlUWs= |
| github.com/tetratelabs/wazero v1.2.1/go.mod h1:wYx2gNRg8/WihJfSDxA1TIL8H+GkfLYm+bIfbblu9VQ= |
| github.com/wasilibs/go-re2 v1.3.0 h1:LFhBNzoStM3wMie6rN2slD1cuYH2CGiHpvNL3UtcsMw= |
| github.com/wasilibs/go-re2 v1.3.0/go.mod h1:AafrCXVvGRJJOImMajgJ2M7rVmWyisVK7sFshbxnVrg= |
| +github.com/wasilibs/nottinygc v0.4.0 h1:h1TJMihMC4neN6Zq+WKpLxgd9xCFMw7O9ETLwY2exJQ= |
| +github.com/wasilibs/nottinygc v0.4.0/go.mod h1:oDcIotskuYNMpqMF23l7Z8uzD4TC0WXHK8jetlB3HIo= |
| +go.opencensus.io v0.24.0 h1:y73uSU6J157QMP2kn2r30vwW1A2W2WFwSCGnAVxeaD0= |
| +go.opencensus.io v0.24.0/go.mod h1:vNK8G9p7aAivkbmorf4v+7Hgx+Zs0yY+0fOtgBfjQKo= |
| +go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= |
| +go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= |
| +go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.54.0 h1:r6I7RJCN86bpD/FQwedZ0vSixDpwuWREjW9oRMsmqDc= |
| +go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.54.0/go.mod h1:B9yO6b04uB80CzjedvewuqDhxJxi11s7/GtiGa8bAjI= |
| +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.54.0 h1:TT4fX+nBOA/+LUkobKGW1ydGcn+G3vRw9+g5HwCphpk= |
| +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.54.0/go.mod h1:L7UH0GbB0p47T4Rri3uHjbpCFYrVrwc1I25QhNPiGK8= |
| +go.opentelemetry.io/otel v1.39.0 h1:8yPrr/S0ND9QEfTfdP9V+SiwT4E0G7Y5MO7p85nis48= |
| +go.opentelemetry.io/otel v1.39.0/go.mod h1:kLlFTywNWrFyEdH0oj2xK0bFYZtHRYUdv1NklR/tgc8= |
| +go.opentelemetry.io/otel/metric v1.39.0 h1:d1UzonvEZriVfpNKEVmHXbdf909uGTOQjA0HF0Ls5Q0= |
| +go.opentelemetry.io/otel/metric v1.39.0/go.mod h1:jrZSWL33sD7bBxg1xjrqyDjnuzTUB0x1nBERXd7Ftcs= |
| +go.opentelemetry.io/otel/trace v1.39.0 h1:2d2vfpEDmCJ5zVYz7ijaJdOF59xLomrvj7bjt6/qCJI= |
| +go.opentelemetry.io/otel/trace v1.39.0/go.mod h1:88w4/PnZSazkGzz/w84VHpQafiU4EtqqlVdxWy+rNOA= |
| golang.org/x/crypto v0.52.0 h1:RMs7fP2rXdep0CftQlK8Uf+kibLm7qkCcradZWYz988= |
| golang.org/x/crypto v0.52.0/go.mod h1:1QgfPxDqh0T2M/elOJtp9RvuR95kVjir0e6/BvEmGbc= |
| golang.org/x/mod v0.37.0 h1:vF1DjpVEshcIqoEaauuHebaLk1O1forxjxBaVn884JQ= |
| golang.org/x/mod v0.37.0/go.mod h1:m8S8VeM9r4dzDwjrKO0a1sZP3YjeMamRRlD+fmR2Q/0= |
| golang.org/x/net v0.55.0 h1:bcvxaJn3e1U6InsFWt1JUq1aSjnRxLzT2rtD2KfkDF8= |
| golang.org/x/net v0.55.0/go.mod h1:L5U2KuzuOe1lY7Z+aWVIKK6qEeJXnXV9yzGA+WCHJww= |
| +golang.org/x/oauth2 v0.34.0 h1:hqK/t4AKgbqWkdkcAeI8XLmbK+4m4G5YeQRrmiotGlw= |
| +golang.org/x/oauth2 v0.34.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA= |
| golang.org/x/sync v0.21.0 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM= |
| golang.org/x/sync v0.21.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= |
| golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= |
| @@ -107,11 +193,25 @@ golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw= |
| golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= |
| golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc= |
| golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38= |
| +golang.org/x/time v0.7.0 h1:ntUhktv3OPE6TgYxXWv9vKvUSJyIFJlyohwbkEwPrKQ= |
| +golang.org/x/time v0.7.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM= |
| golang.org/x/tools v0.47.0 h1:7Kn5x/d1svx/PzryTsqeoZN4TZwqeH5pGWjefhLi/1Q= |
| golang.org/x/tools v0.47.0/go.mod h1:dFHnyTvFWY212G+h7ZY4Vsp/K3U4/7W9TyVaAul8uCA= |
| golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= |
| +google.golang.org/api v0.155.0 h1:vBmGhCYs0djJttDNynWo44zosHlPvHmA0XiN2zP2DtA= |
| +google.golang.org/api v0.155.0/go.mod h1:GI5qK5f40kCpHfPn6+YzGAByIKWv8ujFnmoWm7Igduk= |
| +google.golang.org/genproto v0.0.0-20240116215550-a9fa1716bcac h1:ZL/Teoy/ZGnzyrqK/Optxxp2pmVh+fmJ97slxSRyzUg= |
| +google.golang.org/genproto v0.0.0-20240116215550-a9fa1716bcac/go.mod h1:+Rvu7ElI+aLzyDQhpHMFMMltsD6m7nqpuWDd2CwJw3k= |
| +google.golang.org/genproto/googleapis/api v0.0.0-20251202230838-ff82c1b0f217 h1:fCvbg86sFXwdrl5LgVcTEvNC+2txB5mgROGmRL5mrls= |
| +google.golang.org/genproto/googleapis/api v0.0.0-20251202230838-ff82c1b0f217/go.mod h1:+rXWjjaukWZun3mLfjmVnQi18E1AsFbDN9QdJ5YXLto= |
| +google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217 h1:gRkg/vSppuSQoDjxyiGfN4Upv/h/DQmIR10ZU8dh4Ww= |
| +google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217/go.mod h1:7i2o+ce6H/6BluujYR+kqX3GKH+dChPTQU19wjRPiGk= |
| +google.golang.org/grpc v1.79.3 h1:sybAEdRIEtvcD68Gx7dmnwjZKlyfuc61Dyo9pGXXkKE= |
| +google.golang.org/grpc v1.79.3/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ= |
| google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE= |
| google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= |
| +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= |
| +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= |
| modernc.org/cc/v4 v4.29.0 h1:CXgwL8cvxmyzBQZzbSl/6xFtMCryb6u8IOqDci39cgc= |
| modernc.org/cc/v4 v4.29.0/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI= |
| modernc.org/ccgo/v4 v4.34.6 h1:sBgfIwyN0TQ9C5hwIeuqyeAKyMWnbvj2fvpF4L11uzU= |
diff --git a/internal/objstore/aws.go b/internal/objstore/aws.go| new file mode 100644 |
| index 000000000..df6e75cbe |
| --- /dev/null |
| +++ b/internal/objstore/aws.go |
| @@ -0,0 +1,356 @@ |
| +// The AWS provisioner (§3): S3 + IAM behind the Provisioner seam, path-scoped |
| +// to exactly the bucket a store's user owns. Every idempotence mapping here is |
| +// load-bearing — the grant, rotate and teardown walks in this package are |
| +// re-runnable after a half-failure only because each verb below treats |
| +// "already exists" and "already gone" as success rather than error. |
| +package objstore |
| + |
| +import ( |
| + "context" |
| + "encoding/json" |
| + "errors" |
| + "fmt" |
| + |
| + "github.com/aws/aws-sdk-go-v2/aws" |
| + awsconfig "github.com/aws/aws-sdk-go-v2/config" |
| + "github.com/aws/aws-sdk-go-v2/service/iam" |
| + iamtypes "github.com/aws/aws-sdk-go-v2/service/iam/types" |
| + awss3 "github.com/aws/aws-sdk-go-v2/service/s3" |
| + s3types "github.com/aws/aws-sdk-go-v2/service/s3/types" |
| + "github.com/aws/smithy-go" |
| +) |
| + |
| +// policyName is the inline policy every store user carries. One name, one |
| +// document per user — PutUserPolicy overwrites it wholesale, so there is |
| +// nothing to merge or version. |
| +const policyName = "carlos-store" |
| + |
| +// s3API is the subset of *s3.Client awsProvisioner calls, narrowed so a test |
| +// can stand a stub in for a live client without a network or credentials. |
| +type s3API interface { |
| + CreateBucket(ctx context.Context, in *awss3.CreateBucketInput, optFns ...func(*awss3.Options)) (*awss3.CreateBucketOutput, error) |
| + DeleteBucket(ctx context.Context, in *awss3.DeleteBucketInput, optFns ...func(*awss3.Options)) (*awss3.DeleteBucketOutput, error) |
| + ListObjectsV2(ctx context.Context, in *awss3.ListObjectsV2Input, optFns ...func(*awss3.Options)) (*awss3.ListObjectsV2Output, error) |
| + DeleteObjects(ctx context.Context, in *awss3.DeleteObjectsInput, optFns ...func(*awss3.Options)) (*awss3.DeleteObjectsOutput, error) |
| +} |
| + |
| +// iamAPI is the subset of *iam.Client awsProvisioner calls. |
| +type iamAPI interface { |
| + CreateUser(ctx context.Context, in *iam.CreateUserInput, optFns ...func(*iam.Options)) (*iam.CreateUserOutput, error) |
| + GetUser(ctx context.Context, in *iam.GetUserInput, optFns ...func(*iam.Options)) (*iam.GetUserOutput, error) |
| + PutUserPolicy(ctx context.Context, in *iam.PutUserPolicyInput, optFns ...func(*iam.Options)) (*iam.PutUserPolicyOutput, error) |
| + DeleteUserPolicy(ctx context.Context, in *iam.DeleteUserPolicyInput, optFns ...func(*iam.Options)) (*iam.DeleteUserPolicyOutput, error) |
| + DeleteUser(ctx context.Context, in *iam.DeleteUserInput, optFns ...func(*iam.Options)) (*iam.DeleteUserOutput, error) |
| + CreateAccessKey(ctx context.Context, in *iam.CreateAccessKeyInput, optFns ...func(*iam.Options)) (*iam.CreateAccessKeyOutput, error) |
| + DeleteAccessKey(ctx context.Context, in *iam.DeleteAccessKeyInput, optFns ...func(*iam.Options)) (*iam.DeleteAccessKeyOutput, error) |
| + ListAccessKeys(ctx context.Context, in *iam.ListAccessKeysInput, optFns ...func(*iam.Options)) (*iam.ListAccessKeysOutput, error) |
| +} |
| + |
| +// awsProvisioner implements Provisioner against real AWS S3 + IAM. |
| +type awsProvisioner struct { |
| + s3 s3API |
| + iam iamAPI |
| + region string |
| +} |
| + |
| +var _ Provisioner = (*awsProvisioner)(nil) |
| + |
| +// NewAWSProvisioner builds a Provisioner against AWS S3 and IAM in region. |
| +// The S3 client applies s3compat-style path-style addressing plus a base |
| +// endpoint override when endpoint is non-empty (mirroring |
| +// internal/s3compat.Options, hand-rolled here rather than reused so this |
| +// package stays independent of CARLOS_S3_ENDPOINT — the deployment's |
| +// Endpoint is threaded in explicitly instead). |
| +// |
| +// The IAM client never takes an endpoint override: IAM has no S3-compatible |
| +// twin, so a non-AWS backend (endpoint set, "aws" backend) still means |
| +// IAM-against-real-AWS in v1 — there is nothing else for it to point at. |
| +func NewAWSProvisioner(ctx context.Context, region, endpoint string) (Provisioner, error) { |
| + cfg, err := awsconfig.LoadDefaultConfig(ctx, awsconfig.WithRegion(region)) |
| + if err != nil { |
| + return nil, fmt.Errorf("objstore: load aws config: %w", err) |
| + } |
| + s3Client := awss3.NewFromConfig(cfg, func(o *awss3.Options) { |
| + if endpoint != "" { |
| + o.BaseEndpoint = aws.String(endpoint) |
| + o.UsePathStyle = true |
| + } |
| + }) |
| + iamClient := iam.NewFromConfig(cfg) |
| + return &awsProvisioner{s3: s3Client, iam: iamClient, region: region}, nil |
| +} |
| + |
| +// ---- policy document -------------------------------------------------- |
| + |
| +type policyStatement struct { |
| + Effect string `json:"Effect"` |
| + Action []string `json:"Action"` |
| + Resource string `json:"Resource"` |
| +} |
| + |
| +type policyDocument struct { |
| + Version string `json:"Version"` |
| + Statement []policyStatement `json:"Statement"` |
| +} |
| + |
| +// storePolicyJSON is the exact document every store user's inline policy |
| +// carries: ListBucket on the bucket itself, object verbs scoped under it. |
| +func storePolicyJSON(bucketARN string) string { |
| + doc := policyDocument{ |
| + Version: "2012-10-17", |
| + Statement: []policyStatement{ |
| + {Effect: "Allow", Action: []string{"s3:ListBucket"}, Resource: bucketARN}, |
| + {Effect: "Allow", Action: []string{"s3:GetObject", "s3:PutObject", "s3:DeleteObject"}, Resource: bucketARN + "/*"}, |
| + }, |
| + } |
| + b, err := json.Marshal(doc) |
| + if err != nil { |
| + // doc is a static struct of strings/string-slices; Marshal cannot fail. |
| + panic(fmt.Sprintf("objstore: marshal store policy: %v", err)) |
| + } |
| + return string(b) |
| +} |
| + |
| +// ---- error classification ---------------------------------------------- |
| + |
| +// isNoSuchEntity reports whether err is IAM's NoSuchEntityException — the |
| +// "already gone" answer ListAccessKeys, DeleteAccessKey, DeleteUserPolicy and |
| +// DeleteUser all use for their respective absent target. |
| +func isNoSuchEntity(err error) bool { |
| + var nse *iamtypes.NoSuchEntityException |
| + return errors.As(err, &nse) |
| +} |
| + |
| +// isNoSuchBucket reports whether err is S3's "bucket does not exist" answer. |
| +// DeleteBucket and DeleteObjects surface the typed s3types.NoSuchBucket, but |
| +// ListObjectsV2 has been observed to surface it only as a generic smithy |
| +// APIError with code "NoSuchBucket" — so both forms are checked. |
| +func isNoSuchBucket(err error) bool { |
| + var nsb *s3types.NoSuchBucket |
| + if errors.As(err, &nsb) { |
| + return true |
| + } |
| + var apiErr smithy.APIError |
| + if errors.As(err, &apiErr) && apiErr.ErrorCode() == "NoSuchBucket" { |
| + return true |
| + } |
| + return false |
| +} |
| + |
| +// ---- S3 ------------------------------------------------------------------- |
| + |
| +// EnsureBucket creates name if absent and answers the region it was |
| +// provisioned in. AWS returns BucketAlreadyOwnedByYou (not us-east-1) or a |
| +// bare success (us-east-1's historical quirk) when the caller's own prior |
| +// CreateBucket already succeeded, so that shape is success too — this is what |
| +// makes the grant walk's bucket step re-runnable. BucketAlreadyExists (a |
| +// different account owns the global name) is a real conflict, not treated as |
| +// idempotent: this provisioner has no way to tell "you already made this" |
| +// from "someone else got the name first", and only the former is safe to |
| +// swallow. |
| +func (p *awsProvisioner) EnsureBucket(ctx context.Context, name string) (string, error) { |
| + in := &awss3.CreateBucketInput{Bucket: aws.String(name)} |
| + if p.region != "" && p.region != "us-east-1" { |
| + in.CreateBucketConfiguration = &s3types.CreateBucketConfiguration{ |
| + LocationConstraint: s3types.BucketLocationConstraint(p.region), |
| + } |
| + } |
| + if _, err := p.s3.CreateBucket(ctx, in); err != nil { |
| + var owned *s3types.BucketAlreadyOwnedByYou |
| + if !errors.As(err, &owned) { |
| + return "", fmt.Errorf("objstore: create bucket %q: %w", name, err) |
| + } |
| + } |
| + return p.region, nil |
| +} |
| + |
| +// EmptyBucket deletes every object in name, paginating ListObjectsV2 and |
| +// batching DeleteObjects at <=1000 keys (the API's own ceiling) so a crash |
| +// mid-empty resumes cleanly on the next page. An absent bucket — including |
| +// discovering that only partway through, on a later ListObjectsV2 page — is |
| +// success: teardown resumed after a crash between DeleteBucket and DeleteUser |
| +// re-runs this against a bucket that is already gone. |
| +func (p *awsProvisioner) EmptyBucket(ctx context.Context, name string) error { |
| + var token *string |
| + for { |
| + out, err := p.s3.ListObjectsV2(ctx, &awss3.ListObjectsV2Input{ |
| + Bucket: aws.String(name), |
| + ContinuationToken: token, |
| + }) |
| + if err != nil { |
| + if isNoSuchBucket(err) { |
| + return nil |
| + } |
| + return fmt.Errorf("objstore: list objects in %q: %w", name, err) |
| + } |
| + if err := p.deleteObjectBatches(ctx, name, out.Contents); err != nil { |
| + return err |
| + } |
| + if !aws.ToBool(out.IsTruncated) { |
| + return nil |
| + } |
| + token = out.NextContinuationToken |
| + } |
| +} |
| + |
| +// deleteObjectBatches issues DeleteObjects in chunks of <=1000 keys, the |
| +// API's own limit per call. |
| +func (p *awsProvisioner) deleteObjectBatches(ctx context.Context, bucket string, objs []s3types.Object) error { |
| + const batchSize = 1000 |
| + for start := 0; start < len(objs); start += batchSize { |
| + end := start + batchSize |
| + if end > len(objs) { |
| + end = len(objs) |
| + } |
| + ids := make([]s3types.ObjectIdentifier, end-start) |
| + for i, obj := range objs[start:end] { |
| + ids[i] = s3types.ObjectIdentifier{Key: obj.Key} |
| + } |
| + _, err := p.s3.DeleteObjects(ctx, &awss3.DeleteObjectsInput{ |
| + Bucket: aws.String(bucket), |
| + Delete: &s3types.Delete{Objects: ids}, |
| + }) |
| + if err != nil { |
| + if isNoSuchBucket(err) { |
| + return nil |
| + } |
| + return fmt.Errorf("objstore: delete objects in %q: %w", bucket, err) |
| + } |
| + } |
| + return nil |
| +} |
| + |
| +// DeleteBucket removes name. Absent is success: it is the re-run shape after |
| +// a crash right after a prior DeleteBucket succeeded but before that was |
| +// recorded. |
| +func (p *awsProvisioner) DeleteBucket(ctx context.Context, name string) error { |
| + if _, err := p.s3.DeleteBucket(ctx, &awss3.DeleteBucketInput{Bucket: aws.String(name)}); err != nil { |
| + if isNoSuchBucket(err) { |
| + return nil |
| + } |
| + return fmt.Errorf("objstore: delete bucket %q: %w", name, err) |
| + } |
| + return nil |
| +} |
| + |
| +// ---- IAM -------------------------------------------------------------- |
| + |
| +// EnsureUser creates the principal at path if absent, scoped to bucketARN by |
| +// an inline policy that is written unconditionally either way. A user that |
| +// already exists (EntityAlreadyExistsException — the grant walk re-run after |
| +// a crash between CreateUser and PutUserPolicy) is resolved to its ARN via |
| +// GetUser rather than treated as an error; PutUserPolicy is an idempotent |
| +// overwrite, so it always runs regardless of which branch produced the ARN. |
| +func (p *awsProvisioner) EnsureUser(ctx context.Context, name, path, bucketARN string) (string, error) { |
| + var arn string |
| + out, err := p.iam.CreateUser(ctx, &iam.CreateUserInput{ |
| + UserName: aws.String(name), |
| + Path: aws.String(path), |
| + }) |
| + if err != nil { |
| + var exists *iamtypes.EntityAlreadyExistsException |
| + if !errors.As(err, &exists) { |
| + return "", fmt.Errorf("objstore: create user %q: %w", name, err) |
| + } |
| + got, gerr := p.iam.GetUser(ctx, &iam.GetUserInput{UserName: aws.String(name)}) |
| + if gerr != nil { |
| + return "", fmt.Errorf("objstore: get existing user %q: %w", name, gerr) |
| + } |
| + arn = aws.ToString(got.User.Arn) |
| + } else { |
| + arn = aws.ToString(out.User.Arn) |
| + } |
| + |
| + _, err = p.iam.PutUserPolicy(ctx, &iam.PutUserPolicyInput{ |
| + UserName: aws.String(name), |
| + PolicyName: aws.String(policyName), |
| + PolicyDocument: aws.String(storePolicyJSON(bucketARN)), |
| + }) |
| + if err != nil { |
| + return "", fmt.Errorf("objstore: put policy for user %q: %w", name, err) |
| + } |
| + return arn, nil |
| +} |
| + |
| +// MintKey creates a fresh access key for user. |
| +func (p *awsProvisioner) MintKey(ctx context.Context, user string) (string, string, error) { |
| + out, err := p.iam.CreateAccessKey(ctx, &iam.CreateAccessKeyInput{UserName: aws.String(user)}) |
| + if err != nil { |
| + return "", "", fmt.Errorf("objstore: create access key for %q: %w", user, err) |
| + } |
| + return aws.ToString(out.AccessKey.AccessKeyId), aws.ToString(out.AccessKey.SecretAccessKey), nil |
| +} |
| + |
| +// DeleteKey removes access key id from user. Absent is success: it is the |
| +// shape Rotate step-1 and RotateFinish's PrevKeyID escape hatch both produce |
| +// on a re-run after the key was already deleted. |
| +func (p *awsProvisioner) DeleteKey(ctx context.Context, user, id string) error { |
| + _, err := p.iam.DeleteAccessKey(ctx, &iam.DeleteAccessKeyInput{ |
| + UserName: aws.String(user), |
| + AccessKeyId: aws.String(id), |
| + }) |
| + if err != nil && !isNoSuchEntity(err) { |
| + return fmt.Errorf("objstore: delete access key %q for %q: %w", id, user, err) |
| + } |
| + return nil |
| +} |
| + |
| +// ReapKeys deletes every access key of user not named in keep. A user that no |
| +// longer exists (NoSuchEntity from ListAccessKeys) is success with nothing to |
| +// reap — the grant walk's crash-safety depends on this list-and-delete being |
| +// safe to re-run against a user that got torn down out from under it. |
| +func (p *awsProvisioner) ReapKeys(ctx context.Context, user string, keep []string) error { |
| + keepSet := make(map[string]bool, len(keep)) |
| + for _, id := range keep { |
| + keepSet[id] = true |
| + } |
| + var marker *string |
| + for { |
| + out, err := p.iam.ListAccessKeys(ctx, &iam.ListAccessKeysInput{ |
| + UserName: aws.String(user), |
| + Marker: marker, |
| + }) |
| + if err != nil { |
| + if isNoSuchEntity(err) { |
| + return nil |
| + } |
| + return fmt.Errorf("objstore: list access keys for %q: %w", user, err) |
| + } |
| + for _, k := range out.AccessKeyMetadata { |
| + id := aws.ToString(k.AccessKeyId) |
| + if keepSet[id] { |
| + continue |
| + } |
| + if err := p.DeleteKey(ctx, user, id); err != nil { |
| + return err |
| + } |
| + } |
| + if !out.IsTruncated { |
| + return nil |
| + } |
| + marker = out.Marker |
| + } |
| +} |
| + |
| +// DeleteUser removes user entirely: every access key, then the inline |
| +// policy, then the user itself — IAM refuses to delete a user that still |
| +// owns either. A pending-finish PrevKeyID (a key Rotate minted but no |
| +// RotateFinish ever reaped) is caught here by ReapKeys(keep=nil) during |
| +// teardown even if nothing else ever reaped it. Absent at any step |
| +// (NoSuchEntity) is success throughout, including a bare re-run against a |
| +// user already fully deleted. |
| +func (p *awsProvisioner) DeleteUser(ctx context.Context, user string) error { |
| + if err := p.ReapKeys(ctx, user, nil); err != nil { |
| + return err |
| + } |
| + if _, err := p.iam.DeleteUserPolicy(ctx, &iam.DeleteUserPolicyInput{ |
| + UserName: aws.String(user), |
| + PolicyName: aws.String(policyName), |
| + }); err != nil && !isNoSuchEntity(err) { |
| + return fmt.Errorf("objstore: delete policy for user %q: %w", user, err) |
| + } |
| + if _, err := p.iam.DeleteUser(ctx, &iam.DeleteUserInput{UserName: aws.String(user)}); err != nil && !isNoSuchEntity(err) { |
| + return fmt.Errorf("objstore: delete user %q: %w", user, err) |
| + } |
| + return nil |
| +} |
diff --git a/internal/objstore/aws_test.go b/internal/objstore/aws_test.go| new file mode 100644 |
| index 000000000..da7a89d06 |
| --- /dev/null |
| +++ b/internal/objstore/aws_test.go |
| @@ -0,0 +1,484 @@ |
| +package objstore |
| + |
| +import ( |
| + "context" |
| + "encoding/json" |
| + "testing" |
| + |
| + "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" |
| + awss3 "github.com/aws/aws-sdk-go-v2/service/s3" |
| + s3types "github.com/aws/aws-sdk-go-v2/service/s3/types" |
| + "github.com/aws/smithy-go" |
| +) |
| + |
| +// TestNewAWSProvisioner is construction-only: it must succeed offline (the |
| +// default credential chain loads without ever making a call), and the |
| +// returned value must satisfy Provisioner. |
| +func TestNewAWSProvisioner(t *testing.T) { |
| + p, err := NewAWSProvisioner(context.Background(), "eu-west-1", "") |
| + if err != nil { |
| + t.Fatalf("NewAWSProvisioner: %v", err) |
| + } |
| + if p == nil { |
| + t.Fatal("NewAWSProvisioner returned nil Provisioner") |
| + } |
| +} |
| + |
| +// ---- stub clients ------------------------------------------------------- |
| +// |
| +// Each stub implements the narrow s3API/iamAPI seam with per-method hooks. |
| +// An un-set hook fails the test loudly (via t.Fatalf) rather than panicking |
| +// on a nil func — a test that calls a method it didn't mean to exercise |
| +// should say so. |
| + |
| +type stubS3 struct { |
| + t *testing.T |
| + |
| + createBucket func(*awss3.CreateBucketInput) (*awss3.CreateBucketOutput, error) |
| + deleteBucket func(*awss3.DeleteBucketInput) (*awss3.DeleteBucketOutput, error) |
| + listObjectsV2 func(*awss3.ListObjectsV2Input) (*awss3.ListObjectsV2Output, error) |
| + deleteObjects func(*awss3.DeleteObjectsInput) (*awss3.DeleteObjectsOutput, error) |
| +} |
| + |
| +func (s *stubS3) CreateBucket(_ context.Context, in *awss3.CreateBucketInput, _ ...func(*awss3.Options)) (*awss3.CreateBucketOutput, error) { |
| + if s.createBucket == nil { |
| + s.t.Fatal("unexpected CreateBucket call") |
| + } |
| + return s.createBucket(in) |
| +} |
| + |
| +func (s *stubS3) DeleteBucket(_ context.Context, in *awss3.DeleteBucketInput, _ ...func(*awss3.Options)) (*awss3.DeleteBucketOutput, error) { |
| + if s.deleteBucket == nil { |
| + s.t.Fatal("unexpected DeleteBucket call") |
| + } |
| + return s.deleteBucket(in) |
| +} |
| + |
| +func (s *stubS3) ListObjectsV2(_ context.Context, in *awss3.ListObjectsV2Input, _ ...func(*awss3.Options)) (*awss3.ListObjectsV2Output, error) { |
| + if s.listObjectsV2 == nil { |
| + s.t.Fatal("unexpected ListObjectsV2 call") |
| + } |
| + return s.listObjectsV2(in) |
| +} |
| + |
| +func (s *stubS3) DeleteObjects(_ context.Context, in *awss3.DeleteObjectsInput, _ ...func(*awss3.Options)) (*awss3.DeleteObjectsOutput, error) { |
| + if s.deleteObjects == nil { |
| + s.t.Fatal("unexpected DeleteObjects call") |
| + } |
| + return s.deleteObjects(in) |
| +} |
| + |
| +type stubIAM struct { |
| + t *testing.T |
| + |
| + createUser func(*iam.CreateUserInput) (*iam.CreateUserOutput, error) |
| + getUser func(*iam.GetUserInput) (*iam.GetUserOutput, error) |
| + putUserPolicy func(*iam.PutUserPolicyInput) (*iam.PutUserPolicyOutput, error) |
| + deleteUserPolicy func(*iam.DeleteUserPolicyInput) (*iam.DeleteUserPolicyOutput, error) |
| + deleteUser func(*iam.DeleteUserInput) (*iam.DeleteUserOutput, error) |
| + createAccessKey func(*iam.CreateAccessKeyInput) (*iam.CreateAccessKeyOutput, error) |
| + deleteAccessKey func(*iam.DeleteAccessKeyInput) (*iam.DeleteAccessKeyOutput, error) |
| + listAccessKeys func(*iam.ListAccessKeysInput) (*iam.ListAccessKeysOutput, error) |
| +} |
| + |
| +func (s *stubIAM) CreateUser(_ context.Context, in *iam.CreateUserInput, _ ...func(*iam.Options)) (*iam.CreateUserOutput, error) { |
| + if s.createUser == nil { |
| + s.t.Fatal("unexpected CreateUser call") |
| + } |
| + return s.createUser(in) |
| +} |
| + |
| +func (s *stubIAM) GetUser(_ context.Context, in *iam.GetUserInput, _ ...func(*iam.Options)) (*iam.GetUserOutput, error) { |
| + if s.getUser == nil { |
| + s.t.Fatal("unexpected GetUser call") |
| + } |
| + return s.getUser(in) |
| +} |
| + |
| +func (s *stubIAM) PutUserPolicy(_ context.Context, in *iam.PutUserPolicyInput, _ ...func(*iam.Options)) (*iam.PutUserPolicyOutput, error) { |
| + if s.putUserPolicy == nil { |
| + s.t.Fatal("unexpected PutUserPolicy call") |
| + } |
| + return s.putUserPolicy(in) |
| +} |
| + |
| +func (s *stubIAM) DeleteUserPolicy(_ context.Context, in *iam.DeleteUserPolicyInput, _ ...func(*iam.Options)) (*iam.DeleteUserPolicyOutput, error) { |
| + if s.deleteUserPolicy == nil { |
| + s.t.Fatal("unexpected DeleteUserPolicy call") |
| + } |
| + return s.deleteUserPolicy(in) |
| +} |
| + |
| +func (s *stubIAM) DeleteUser(_ context.Context, in *iam.DeleteUserInput, _ ...func(*iam.Options)) (*iam.DeleteUserOutput, error) { |
| + if s.deleteUser == nil { |
| + s.t.Fatal("unexpected DeleteUser call") |
| + } |
| + return s.deleteUser(in) |
| +} |
| + |
| +func (s *stubIAM) CreateAccessKey(_ context.Context, in *iam.CreateAccessKeyInput, _ ...func(*iam.Options)) (*iam.CreateAccessKeyOutput, error) { |
| + if s.createAccessKey == nil { |
| + s.t.Fatal("unexpected CreateAccessKey call") |
| + } |
| + return s.createAccessKey(in) |
| +} |
| + |
| +func (s *stubIAM) DeleteAccessKey(_ context.Context, in *iam.DeleteAccessKeyInput, _ ...func(*iam.Options)) (*iam.DeleteAccessKeyOutput, error) { |
| + if s.deleteAccessKey == nil { |
| + s.t.Fatal("unexpected DeleteAccessKey call") |
| + } |
| + return s.deleteAccessKey(in) |
| +} |
| + |
| +func (s *stubIAM) ListAccessKeys(_ context.Context, in *iam.ListAccessKeysInput, _ ...func(*iam.Options)) (*iam.ListAccessKeysOutput, error) { |
| + if s.listAccessKeys == nil { |
| + s.t.Fatal("unexpected ListAccessKeys call") |
| + } |
| + return s.listAccessKeys(in) |
| +} |
| + |
| +// ---- idempotence mappings (HARD constraints from Tasks 4-6) ------------- |
| + |
| +// DeleteKey on an absent key (NoSuchEntityException) must be nil: both |
| +// RotateFinish's PrevKeyID escape hatch and Rotate step-1 re-run this against |
| +// a key that is already gone. |
| +func TestDeleteKeyAbsentIsNil(t *testing.T) { |
| + p := &awsProvisioner{iam: &stubIAM{t: t, |
| + deleteAccessKey: func(*iam.DeleteAccessKeyInput) (*iam.DeleteAccessKeyOutput, error) { |
| + return nil, &iamtypes.NoSuchEntityException{Message: aws.String("gone")} |
| + }, |
| + }} |
| + if err := p.DeleteKey(context.Background(), "u1", "AKIAABSENT"); err != nil { |
| + t.Fatalf("DeleteKey on absent key: %v", err) |
| + } |
| +} |
| + |
| +// DeleteUser on an absent user must be nil, and must delete access keys and |
| +// the inline policy BEFORE DeleteUser — IAM refuses to delete a user that |
| +// still owns either, and this is also where a pending-finish PrevKeyID gets |
| +// reaped during teardown. |
| +func TestDeleteUserAbsentIsNilAndOrdered(t *testing.T) { |
| + var order []string |
| + stub := &stubIAM{t: t, |
| + listAccessKeys: func(*iam.ListAccessKeysInput) (*iam.ListAccessKeysOutput, error) { |
| + order = append(order, "list") |
| + return &iam.ListAccessKeysOutput{ |
| + AccessKeyMetadata: []iamtypes.AccessKeyMetadata{ |
| + {AccessKeyId: aws.String("AKIAPENDING")}, |
| + }, |
| + IsTruncated: false, |
| + }, nil |
| + }, |
| + deleteAccessKey: func(in *iam.DeleteAccessKeyInput) (*iam.DeleteAccessKeyOutput, error) { |
| + order = append(order, "deleteKey:"+aws.ToString(in.AccessKeyId)) |
| + return &iam.DeleteAccessKeyOutput{}, nil |
| + }, |
| + deleteUserPolicy: func(*iam.DeleteUserPolicyInput) (*iam.DeleteUserPolicyOutput, error) { |
| + order = append(order, "deletePolicy") |
| + return nil, &iamtypes.NoSuchEntityException{Message: aws.String("gone")} |
| + }, |
| + deleteUser: func(*iam.DeleteUserInput) (*iam.DeleteUserOutput, error) { |
| + order = append(order, "deleteUser") |
| + return nil, &iamtypes.NoSuchEntityException{Message: aws.String("gone")} |
| + }, |
| + } |
| + p := &awsProvisioner{iam: stub} |
| + if err := p.DeleteUser(context.Background(), "u1"); err != nil { |
| + t.Fatalf("DeleteUser on absent user: %v", err) |
| + } |
| + want := []string{"list", "deleteKey:AKIAPENDING", "deletePolicy", "deleteUser"} |
| + if len(order) != len(want) { |
| + t.Fatalf("call order = %v, want %v", order, want) |
| + } |
| + for i := range want { |
| + if order[i] != want[i] { |
| + t.Fatalf("call order = %v, want %v", order, want) |
| + } |
| + } |
| +} |
| + |
| +// ReapKeys on an absent user (NoSuchEntity from ListAccessKeys) must be nil. |
| +func TestReapKeysAbsentUserIsNil(t *testing.T) { |
| + p := &awsProvisioner{iam: &stubIAM{t: t, |
| + listAccessKeys: func(*iam.ListAccessKeysInput) (*iam.ListAccessKeysOutput, error) { |
| + return nil, &iamtypes.NoSuchEntityException{Message: aws.String("gone")} |
| + }, |
| + }} |
| + if err := p.ReapKeys(context.Background(), "ghost", []string{"keep-me"}); err != nil { |
| + t.Fatalf("ReapKeys on absent user: %v", err) |
| + } |
| +} |
| + |
| +// ReapKeys deletes every key not in keep and leaves the kept ones alone. |
| +func TestReapKeysDeletesUnkept(t *testing.T) { |
| + deleted := map[string]bool{} |
| + p := &awsProvisioner{iam: &stubIAM{t: t, |
| + listAccessKeys: func(*iam.ListAccessKeysInput) (*iam.ListAccessKeysOutput, error) { |
| + return &iam.ListAccessKeysOutput{ |
| + AccessKeyMetadata: []iamtypes.AccessKeyMetadata{ |
| + {AccessKeyId: aws.String("KEEP")}, |
| + {AccessKeyId: aws.String("REAP1")}, |
| + {AccessKeyId: aws.String("REAP2")}, |
| + }, |
| + IsTruncated: false, |
| + }, nil |
| + }, |
| + deleteAccessKey: func(in *iam.DeleteAccessKeyInput) (*iam.DeleteAccessKeyOutput, error) { |
| + deleted[aws.ToString(in.AccessKeyId)] = true |
| + return &iam.DeleteAccessKeyOutput{}, nil |
| + }, |
| + }} |
| + if err := p.ReapKeys(context.Background(), "u1", []string{"KEEP"}); err != nil { |
| + t.Fatal(err) |
| + } |
| + if !deleted["REAP1"] || !deleted["REAP2"] { |
| + t.Fatalf("expected REAP1 and REAP2 deleted, got %v", deleted) |
| + } |
| + if deleted["KEEP"] { |
| + t.Fatal("KEEP should not have been deleted") |
| + } |
| +} |
| + |
| +// EmptyBucket on an absent bucket must be nil, including when the NoSuchBucket |
| +// only surfaces on the ListObjectsV2 leg (as a smithy APIError, not the typed |
| +// s3types.NoSuchBucket) — teardown resume after a crash between DeleteBucket |
| +// and DeleteUser re-runs EmptyBucket on a gone bucket. |
| +func TestEmptyBucketAbsentIsNil(t *testing.T) { |
| + p := &awsProvisioner{s3: &stubS3{t: t, |
| + listObjectsV2: func(*awss3.ListObjectsV2Input) (*awss3.ListObjectsV2Output, error) { |
| + return nil, &smithyAPIErrorStub{code: "NoSuchBucket", msg: "The specified bucket does not exist"} |
| + }, |
| + }} |
| + if err := p.EmptyBucket(context.Background(), "gone-bucket"); err != nil { |
| + t.Fatalf("EmptyBucket on absent bucket: %v", err) |
| + } |
| +} |
| + |
| +// Same, but via the typed s3types.NoSuchBucket rather than a generic |
| +// smithy.APIError — both forms must be recognized. |
| +func TestEmptyBucketAbsentTypedErrorIsNil(t *testing.T) { |
| + p := &awsProvisioner{s3: &stubS3{t: t, |
| + listObjectsV2: func(*awss3.ListObjectsV2Input) (*awss3.ListObjectsV2Output, error) { |
| + return nil, &s3types.NoSuchBucket{Message: aws.String("gone")} |
| + }, |
| + }} |
| + if err := p.EmptyBucket(context.Background(), "gone-bucket"); err != nil { |
| + t.Fatalf("EmptyBucket on absent bucket (typed): %v", err) |
| + } |
| +} |
| + |
| +// EmptyBucket batches DeleteObjects at <=1000 keys per call and follows |
| +// ListObjectsV2 pagination. |
| +func TestEmptyBucketBatchesAndPaginates(t *testing.T) { |
| + page1 := make([]s3types.Object, 1500) // exceeds the 1000 batch ceiling |
| + for i := range page1 { |
| + page1[i] = s3types.Object{Key: aws.String("obj1500")} |
| + } |
| + page2 := []s3types.Object{{Key: aws.String("obj-page2")}} |
| + |
| + var listCalls int |
| + var deleteBatchSizes []int |
| + p := &awsProvisioner{s3: &stubS3{t: t, |
| + listObjectsV2: func(in *awss3.ListObjectsV2Input) (*awss3.ListObjectsV2Output, error) { |
| + listCalls++ |
| + if listCalls == 1 { |
| + return &awss3.ListObjectsV2Output{ |
| + Contents: page1, |
| + IsTruncated: aws.Bool(true), |
| + NextContinuationToken: aws.String("page2"), |
| + }, nil |
| + } |
| + if aws.ToString(in.ContinuationToken) != "page2" { |
| + t.Fatalf("expected continuation token page2, got %q", aws.ToString(in.ContinuationToken)) |
| + } |
| + return &awss3.ListObjectsV2Output{ |
| + Contents: page2, |
| + IsTruncated: aws.Bool(false), |
| + }, nil |
| + }, |
| + deleteObjects: func(in *awss3.DeleteObjectsInput) (*awss3.DeleteObjectsOutput, error) { |
| + deleteBatchSizes = append(deleteBatchSizes, len(in.Delete.Objects)) |
| + return &awss3.DeleteObjectsOutput{}, nil |
| + }, |
| + }} |
| + if err := p.EmptyBucket(context.Background(), "b1"); err != nil { |
| + t.Fatal(err) |
| + } |
| + if listCalls != 2 { |
| + t.Fatalf("listCalls = %d, want 2", listCalls) |
| + } |
| + want := []int{1000, 500, 1} |
| + if len(deleteBatchSizes) != len(want) { |
| + t.Fatalf("delete batch sizes = %v, want %v", deleteBatchSizes, want) |
| + } |
| + for i := range want { |
| + if deleteBatchSizes[i] != want[i] { |
| + t.Fatalf("delete batch sizes = %v, want %v", deleteBatchSizes, want) |
| + } |
| + } |
| +} |
| + |
| +// DeleteBucket on an absent bucket must be nil. |
| +func TestDeleteBucketAbsentIsNil(t *testing.T) { |
| + p := &awsProvisioner{s3: &stubS3{t: t, |
| + deleteBucket: func(*awss3.DeleteBucketInput) (*awss3.DeleteBucketOutput, error) { |
| + return nil, &s3types.NoSuchBucket{Message: aws.String("gone")} |
| + }, |
| + }} |
| + if err := p.DeleteBucket(context.Background(), "gone-bucket"); err != nil { |
| + t.Fatalf("DeleteBucket on absent bucket: %v", err) |
| + } |
| +} |
| + |
| +// EnsureBucket treats BucketAlreadyOwnedByYou as success and answers the |
| +// configured region. |
| +func TestEnsureBucketAlreadyOwnedByYou(t *testing.T) { |
| + p := &awsProvisioner{region: "eu-west-1", s3: &stubS3{t: t, |
| + createBucket: func(*awss3.CreateBucketInput) (*awss3.CreateBucketOutput, error) { |
| + return nil, &s3types.BucketAlreadyOwnedByYou{Message: aws.String("already yours")} |
| + }, |
| + }} |
| + region, err := p.EnsureBucket(context.Background(), "b1") |
| + if err != nil { |
| + t.Fatalf("EnsureBucket on already-owned bucket: %v", err) |
| + } |
| + if region != "eu-west-1" { |
| + t.Fatalf("region = %q, want eu-west-1", region) |
| + } |
| +} |
| + |
| +// EnsureBucket does NOT set a location constraint for us-east-1 (the region |
| +// with no LocationConstraint value of its own). |
| +func TestEnsureBucketUsEast1NoLocationConstraint(t *testing.T) { |
| + var gotConstraint bool |
| + p := &awsProvisioner{region: "us-east-1", s3: &stubS3{t: t, |
| + createBucket: func(in *awss3.CreateBucketInput) (*awss3.CreateBucketOutput, error) { |
| + gotConstraint = in.CreateBucketConfiguration != nil |
| + return &awss3.CreateBucketOutput{}, nil |
| + }, |
| + }} |
| + if _, err := p.EnsureBucket(context.Background(), "b1"); err != nil { |
| + t.Fatal(err) |
| + } |
| + if gotConstraint { |
| + t.Fatal("us-east-1 must not set CreateBucketConfiguration") |
| + } |
| +} |
| + |
| +// EnsureUser: EntityAlreadyExists resolves the ARN via GetUser, and |
| +// PutUserPolicy always runs (idempotent overwrite) regardless of which |
| +// branch produced the ARN. |
| +func TestEnsureUserAlreadyExists(t *testing.T) { |
| + var putPolicyCalled bool |
| + var putDoc string |
| + p := &awsProvisioner{iam: &stubIAM{t: t, |
| + createUser: func(*iam.CreateUserInput) (*iam.CreateUserOutput, error) { |
| + return nil, &iamtypes.EntityAlreadyExistsException{Message: aws.String("exists")} |
| + }, |
| + getUser: func(in *iam.GetUserInput) (*iam.GetUserOutput, error) { |
| + return &iam.GetUserOutput{User: &iamtypes.User{ |
| + Arn: aws.String("arn:aws:iam::123456789012:user/carlos-store/u1"), |
| + UserName: in.UserName, |
| + }}, nil |
| + }, |
| + putUserPolicy: func(in *iam.PutUserPolicyInput) (*iam.PutUserPolicyOutput, error) { |
| + putPolicyCalled = true |
| + putDoc = aws.ToString(in.PolicyDocument) |
| + return &iam.PutUserPolicyOutput{}, nil |
| + }, |
| + }} |
| + arn, err := p.EnsureUser(context.Background(), "u1", UserPath, "arn:aws:s3:::b1") |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if arn != "arn:aws:iam::123456789012:user/carlos-store/u1" { |
| + t.Fatalf("arn = %q", arn) |
| + } |
| + if !putPolicyCalled { |
| + t.Fatal("PutUserPolicy must run unconditionally, even on the already-exists branch") |
| + } |
| + assertStorePolicyDocument(t, putDoc, "arn:aws:s3:::b1") |
| +} |
| + |
| +// EnsureUser on a fresh user: ARN comes from CreateUser's own response, and |
| +// the policy document matches the brief exactly. |
| +func TestEnsureUserFresh(t *testing.T) { |
| + var putDoc string |
| + p := &awsProvisioner{iam: &stubIAM{t: t, |
| + createUser: func(in *iam.CreateUserInput) (*iam.CreateUserOutput, error) { |
| + return &iam.CreateUserOutput{User: &iamtypes.User{ |
| + Arn: aws.String("arn:aws:iam::123456789012:user/carlos-store/u1"), |
| + UserName: in.UserName, |
| + Path: in.Path, |
| + }}, nil |
| + }, |
| + putUserPolicy: func(in *iam.PutUserPolicyInput) (*iam.PutUserPolicyOutput, error) { |
| + if aws.ToString(in.PolicyName) != policyName { |
| + t.Fatalf("policy name = %q, want %q", aws.ToString(in.PolicyName), policyName) |
| + } |
| + putDoc = aws.ToString(in.PolicyDocument) |
| + return &iam.PutUserPolicyOutput{}, nil |
| + }, |
| + }} |
| + arn, err := p.EnsureUser(context.Background(), "u1", UserPath, "arn:aws:s3:::b1") |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if arn != "arn:aws:iam::123456789012:user/carlos-store/u1" { |
| + t.Fatalf("arn = %q", arn) |
| + } |
| + assertStorePolicyDocument(t, putDoc, "arn:aws:s3:::b1") |
| +} |
| + |
| +// assertStorePolicyDocument checks doc matches the brief's policy shape |
| +// exactly: ListBucket on bucketARN, Get/Put/DeleteObject on bucketARN+"/*". |
| +func assertStorePolicyDocument(t *testing.T, doc, bucketARN string) { |
| + t.Helper() |
| + var parsed struct { |
| + Version string `json:"Version"` |
| + Statement []struct { |
| + Effect string `json:"Effect"` |
| + Action []string `json:"Action"` |
| + Resource string `json:"Resource"` |
| + } `json:"Statement"` |
| + } |
| + if err := json.Unmarshal([]byte(doc), &parsed); err != nil { |
| + t.Fatalf("policy document not valid JSON: %v\n%s", err, doc) |
| + } |
| + if parsed.Version != "2012-10-17" { |
| + t.Fatalf("Version = %q", parsed.Version) |
| + } |
| + if len(parsed.Statement) != 2 { |
| + t.Fatalf("Statement has %d entries, want 2:\n%s", len(parsed.Statement), doc) |
| + } |
| + list, objects := parsed.Statement[0], parsed.Statement[1] |
| + if list.Effect != "Allow" || len(list.Action) != 1 || list.Action[0] != "s3:ListBucket" || list.Resource != bucketARN { |
| + t.Fatalf("ListBucket statement = %+v", list) |
| + } |
| + wantActions := map[string]bool{"s3:GetObject": true, "s3:PutObject": true, "s3:DeleteObject": true} |
| + if objects.Effect != "Allow" || len(objects.Action) != 3 || objects.Resource != bucketARN+"/*" { |
| + t.Fatalf("object statement = %+v", objects) |
| + } |
| + for _, a := range objects.Action { |
| + if !wantActions[a] { |
| + t.Fatalf("unexpected action %q in object statement", a) |
| + } |
| + } |
| +} |
| + |
| +// smithyAPIErrorStub is a minimal smithy.APIError for exercising the code |
| +// path where ListObjectsV2 answers NoSuchBucket as a generic API error |
| +// rather than the typed s3types.NoSuchBucket. |
| +type smithyAPIErrorStub struct { |
| + code string |
| + msg string |
| +} |
| + |
| +func (e *smithyAPIErrorStub) Error() string { return e.code + ": " + e.msg } |
| +func (e *smithyAPIErrorStub) ErrorCode() string { return e.code } |
| +func (e *smithyAPIErrorStub) ErrorMessage() string { return e.msg } |
| +func (e *smithyAPIErrorStub) ErrorFault() smithy.ErrorFault { return smithy.FaultUnknown } |
| + |
| +var _ smithy.APIError = (*smithyAPIErrorStub)(nil) |