carlos / platform Public

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>
Paul Campbell 5846ad00d01cae6964082473af2436dd79939566 parent 7886ba6
4 files changed, +953 −12
  • go.mod +5 −4
  • go.sum +108 −8
  • internal/objstore/aws.go +356 −0
  • internal/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)