| 1 | // The AWS provisioner (§3): S3 + IAM behind the Provisioner seam, path-scoped |
| 2 | // to exactly the bucket a store's user owns. Every idempotence mapping here is |
| 3 | // load-bearing — the grant, rotate and teardown walks in this package are |
| 4 | // re-runnable after a half-failure only because each verb below treats |
| 5 | // "already exists" and "already gone" as success rather than error. |
| 6 | package objstore |
| 7 | |
| 8 | import ( |
| 9 | "context" |
| 10 | "encoding/json" |
| 11 | "errors" |
| 12 | "fmt" |
| 13 | |
| 14 | "github.com/aws/aws-sdk-go-v2/aws" |
| 15 | awsconfig "github.com/aws/aws-sdk-go-v2/config" |
| 16 | "github.com/aws/aws-sdk-go-v2/service/iam" |
| 17 | iamtypes "github.com/aws/aws-sdk-go-v2/service/iam/types" |
| 18 | awss3 "github.com/aws/aws-sdk-go-v2/service/s3" |
| 19 | s3types "github.com/aws/aws-sdk-go-v2/service/s3/types" |
| 20 | "github.com/aws/smithy-go" |
| 21 | "github.com/carlosframework/platform/internal/rootaws" |
| 22 | ) |
| 23 | |
| 24 | // policyName is the inline policy every store user carries. One name, one |
| 25 | // document per user — PutUserPolicy overwrites it wholesale, so there is |
| 26 | // nothing to merge or version. |
| 27 | const policyName = "carlos-store" |
| 28 | |
| 29 | // s3API is the subset of *s3.Client awsProvisioner calls, narrowed so a test |
| 30 | // can stand a stub in for a live client without a network or credentials. |
| 31 | type s3API interface { |
| 32 | CreateBucket(ctx context.Context, in *awss3.CreateBucketInput, optFns ...func(*awss3.Options)) (*awss3.CreateBucketOutput, error) |
| 33 | DeleteBucket(ctx context.Context, in *awss3.DeleteBucketInput, optFns ...func(*awss3.Options)) (*awss3.DeleteBucketOutput, error) |
| 34 | ListObjectsV2(ctx context.Context, in *awss3.ListObjectsV2Input, optFns ...func(*awss3.Options)) (*awss3.ListObjectsV2Output, error) |
| 35 | DeleteObjects(ctx context.Context, in *awss3.DeleteObjectsInput, optFns ...func(*awss3.Options)) (*awss3.DeleteObjectsOutput, error) |
| 36 | } |
| 37 | |
| 38 | // iamAPI is the subset of *iam.Client awsProvisioner calls. |
| 39 | type iamAPI interface { |
| 40 | CreateUser(ctx context.Context, in *iam.CreateUserInput, optFns ...func(*iam.Options)) (*iam.CreateUserOutput, error) |
| 41 | GetUser(ctx context.Context, in *iam.GetUserInput, optFns ...func(*iam.Options)) (*iam.GetUserOutput, error) |
| 42 | PutUserPolicy(ctx context.Context, in *iam.PutUserPolicyInput, optFns ...func(*iam.Options)) (*iam.PutUserPolicyOutput, error) |
| 43 | DeleteUserPolicy(ctx context.Context, in *iam.DeleteUserPolicyInput, optFns ...func(*iam.Options)) (*iam.DeleteUserPolicyOutput, error) |
| 44 | DeleteUser(ctx context.Context, in *iam.DeleteUserInput, optFns ...func(*iam.Options)) (*iam.DeleteUserOutput, error) |
| 45 | CreateAccessKey(ctx context.Context, in *iam.CreateAccessKeyInput, optFns ...func(*iam.Options)) (*iam.CreateAccessKeyOutput, error) |
| 46 | DeleteAccessKey(ctx context.Context, in *iam.DeleteAccessKeyInput, optFns ...func(*iam.Options)) (*iam.DeleteAccessKeyOutput, error) |
| 47 | ListAccessKeys(ctx context.Context, in *iam.ListAccessKeysInput, optFns ...func(*iam.Options)) (*iam.ListAccessKeysOutput, error) |
| 48 | } |
| 49 | |
| 50 | // awsProvisioner implements Provisioner against real AWS S3 + IAM. |
| 51 | type awsProvisioner struct { |
| 52 | s3 s3API |
| 53 | iam iamAPI |
| 54 | region string |
| 55 | // boundary is the permissions-boundary policy ARN stamped on every store |
| 56 | // user this provisioner creates. See EnsureUser for why it is not |
| 57 | // optional in any deployment whose console role can reach IAM. |
| 58 | boundary string |
| 59 | } |
| 60 | |
| 61 | var _ Provisioner = (*awsProvisioner)(nil) |
| 62 | |
| 63 | // NewAWSProvisioner builds a Provisioner against AWS S3 and IAM in region. |
| 64 | // The S3 client applies s3compat-style options when endpoint is non-empty |
| 65 | // (hand-rolled here rather than calling internal/s3compat.Options directly, |
| 66 | // so this package stays independent of CARLOS_S3_ENDPOINT — the |
| 67 | // deployment's Endpoint is threaded in explicitly instead). These four lines |
| 68 | // mirror s3compat.Options' universal (non-AWS) block line for line and MUST |
| 69 | // move in lockstep with it: BaseEndpoint + UsePathStyle are the addressing |
| 70 | // fix, and the checksum pair exists because default-on flexible checksums |
| 71 | // wrap bodies in aws-chunked framing some S3-compatibles reject — DeleteObjects |
| 72 | // (EmptyBucket's batch delete) is exactly a checksummed request, so dropping |
| 73 | // either checksum line silently breaks teardown against those stores. Not |
| 74 | // applied: s3compat's per-provider Quirks table (e.g. the Hetzner If-Match |
| 75 | // unquoting) — none of it is conditional writes, which this provisioner |
| 76 | // never issues. |
| 77 | // |
| 78 | // The IAM client never takes an endpoint override: IAM has no S3-compatible |
| 79 | // twin, so a non-AWS backend (endpoint set, "aws" backend) still means |
| 80 | // IAM-against-real-AWS in v1 — there is nothing else for it to point at. |
| 81 | func NewAWSProvisioner(ctx context.Context, region, endpoint, boundary string) (Provisioner, error) { |
| 82 | s3Client, err := S3ClientFor(ctx, region, endpoint) |
| 83 | if err != nil { |
| 84 | return nil, err |
| 85 | } |
| 86 | cfg, err := rootaws.LoadDefaultConfig(ctx, awsconfig.WithRegion(region)) |
| 87 | if err != nil { |
| 88 | return nil, fmt.Errorf("objstore: load aws config: %w", err) |
| 89 | } |
| 90 | iamClient := iam.NewFromConfig(cfg) |
| 91 | return &awsProvisioner{s3: s3Client, iam: iamClient, region: region, boundary: boundary}, nil |
| 92 | } |
| 93 | |
| 94 | // S3ClientFor builds a bare *s3.Client the same way NewAWSProvisioner builds |
| 95 | // its own — region + the s3compat-style option layering (BaseEndpoint, |
| 96 | // UsePathStyle, the checksum pair) applied only when endpoint is non-empty — |
| 97 | // for callers that need the raw client rather than the Provisioner seam. |
| 98 | // The economics storage sweep (console.sizeProvisionedStore) is the first: |
| 99 | // it sizes a tenant's provisioned bucket directly, with no IAM involved. |
| 100 | func S3ClientFor(ctx context.Context, region, endpoint string) (*awss3.Client, error) { |
| 101 | cfg, err := rootaws.LoadDefaultConfig(ctx, awsconfig.WithRegion(region)) |
| 102 | if err != nil { |
| 103 | return nil, fmt.Errorf("objstore: load aws config: %w", err) |
| 104 | } |
| 105 | return awss3.NewFromConfig(cfg, func(o *awss3.Options) { |
| 106 | if endpoint != "" { |
| 107 | o.BaseEndpoint = aws.String(endpoint) |
| 108 | o.UsePathStyle = true |
| 109 | o.RequestChecksumCalculation = aws.RequestChecksumCalculationWhenRequired |
| 110 | o.ResponseChecksumValidation = aws.ResponseChecksumValidationWhenRequired |
| 111 | } |
| 112 | }), nil |
| 113 | } |
| 114 | |
| 115 | // ---- policy document -------------------------------------------------- |
| 116 | |
| 117 | type policyStatement struct { |
| 118 | Effect string `json:"Effect"` |
| 119 | Action []string `json:"Action"` |
| 120 | Resource string `json:"Resource"` |
| 121 | // Condition is omitted entirely when nil. The value side is `any` because |
| 122 | // IAM condition values are sometimes a string (StringEquals on one tag) |
| 123 | // and sometimes a list (ForAnyValue:StringEquals over tag KEYS); a |
| 124 | // map[string]string could only express the first, and the second is what |
| 125 | // closes the upload-tagging route below. |
| 126 | Condition map[string]map[string]any `json:"Condition,omitempty"` |
| 127 | } |
| 128 | |
| 129 | type policyDocument struct { |
| 130 | Version string `json:"Version"` |
| 131 | Statement []policyStatement `json:"Statement"` |
| 132 | } |
| 133 | |
| 134 | // storePolicyJSON is the exact document every store user's inline policy |
| 135 | // carries: ListBucket on the bucket itself, object verbs scoped under it, and |
| 136 | // — when deny is non-nil — a Deny on reading an object the scanner has tagged |
| 137 | // infected (spec §8). |
| 138 | // |
| 139 | // s3:GetObjectTagging is in the Allow so an app can read the verdict on its |
| 140 | // own object. s3:PutObjectTagging is NOT, and MUST NEVER BE: every verdict |
| 141 | // this platform reports is only worth anything while an app cannot mark its |
| 142 | // own object clean. |
| 143 | // |
| 144 | // ABSENCE IS NOT ENOUGH, which cost this design a real hole. There are TWO |
| 145 | // ways to put a tag on an object, and leaving the permission out of the Allow |
| 146 | // only closes one of them: PutObjectTagging, the explicit call. The other is |
| 147 | // the `x-amz-tagging` header on PutObject itself, which sets tags during the |
| 148 | // upload without ever naming the tagging action. AWS documents that header as |
| 149 | // requiring s3:PutObjectTagging; a gateway that does not enforce that reading |
| 150 | // hands any app with s3:PutObject the ability to upload a payload already |
| 151 | // marked clean — and the sweep, finding a version-matching clean tag, will |
| 152 | // never scan it. `carlos store scan status` then reports it clean forever. |
| 153 | // |
| 154 | // This is not hypothetical. Against MinIO RELEASE.2025-09-07T16-13-09Z, run |
| 155 | // from CI's own contract fixture (.amadan/ci.d/30-minio-contract), the upload |
| 156 | // succeeded with the tag attached. Adding an explicit Deny on |
| 157 | // s3:PutObjectTagging — the obvious fix, and the first one tried — did NOT |
| 158 | // close it, because the gateway never attributed the header to that action. |
| 159 | // Only denying s3:PutObject itself when the request carries one of our tag |
| 160 | // keys did. Both Denys are therefore below, unconditionally, for every store |
| 161 | // whether or not enforcement is on: an explicit Deny costs nothing where the |
| 162 | // permission was already absent, and it is the only form that travels to a |
| 163 | // backend whose IAM does not read the header the way AWS's does. |
| 164 | // |
| 165 | // TestDeliveredContractMinio holds the live half of this against a real |
| 166 | // server on every push. Do not weaken either statement without watching that |
| 167 | // test fail first. |
| 168 | // |
| 169 | // Adding GetObjectTagging changes this document for every existing store, |
| 170 | // which is picked up on the next grant re-walk. That is a deliberate one-time |
| 171 | // rewrite, not a regression: PutUserPolicy overwrites wholesale and is |
| 172 | // idempotent, so the cost is one extra IAM call per re-walk. |
| 173 | // |
| 174 | // The deny is weaker than AWS's own tag-based access control example, which |
| 175 | // denies reads UNLESS an object is tagged clean and so blocks unscanned |
| 176 | // objects too. This one blocks only known-infected objects; unscanned ones |
| 177 | // stay readable. That is the right trade when the goal is evidence rather |
| 178 | // than enforcement, but anyone quoting it in a compliance answer needs to |
| 179 | // know which of the two they have. |
| 180 | func storePolicyJSON(bucketARN string, deny *DenyTag) string { |
| 181 | doc := policyDocument{ |
| 182 | Version: "2012-10-17", |
| 183 | Statement: []policyStatement{ |
| 184 | {Effect: "Allow", Action: []string{"s3:ListBucket"}, Resource: bucketARN}, |
| 185 | {Effect: "Allow", Action: []string{ |
| 186 | "s3:GetObject", "s3:PutObject", "s3:DeleteObject", "s3:GetObjectTagging", |
| 187 | }, Resource: bucketARN + "/*"}, |
| 188 | }, |
| 189 | } |
| 190 | // The two anti-forgery Denys, always. See above for why the second one |
| 191 | // exists and why the first is not sufficient on its own. |
| 192 | doc.Statement = append(doc.Statement, |
| 193 | policyStatement{ |
| 194 | Effect: "Deny", |
| 195 | Action: []string{ |
| 196 | "s3:PutObjectTagging", "s3:DeleteObjectTagging", |
| 197 | // Nothing here ever calls PutBucketVersioning, so these two |
| 198 | // are unreachable today. They cost nothing and close the shape |
| 199 | // before a bucket is ever versioned — which is the cheap half |
| 200 | // of the lesson this policy has already learned once. |
| 201 | "s3:PutObjectVersionTagging", "s3:DeleteObjectVersionTagging", |
| 202 | }, |
| 203 | Resource: bucketARN + "/*", |
| 204 | }, |
| 205 | policyStatement{ |
| 206 | Effect: "Deny", |
| 207 | Action: []string{"s3:PutObject"}, |
| 208 | Resource: bucketARN + "/*", |
| 209 | Condition: map[string]map[string]any{ |
| 210 | "ForAnyValue:StringEquals": { |
| 211 | "s3:RequestObjectTagKeys": []string{TagVerdict, TagVersion, TagAt}, |
| 212 | }, |
| 213 | }, |
| 214 | }, |
| 215 | ) |
| 216 | if deny != nil { |
| 217 | doc.Statement = append(doc.Statement, policyStatement{ |
| 218 | Effect: "Deny", |
| 219 | Action: []string{"s3:GetObject"}, |
| 220 | Resource: bucketARN + "/*", |
| 221 | Condition: map[string]map[string]any{ |
| 222 | "StringEquals": {"s3:ExistingObjectTag/" + deny.Key: deny.Value}, |
| 223 | }, |
| 224 | }) |
| 225 | } |
| 226 | b, err := json.Marshal(doc) |
| 227 | if err != nil { |
| 228 | // doc is a static struct of strings/string-slices; Marshal cannot fail. |
| 229 | panic(fmt.Sprintf("objstore: marshal store policy: %v", err)) |
| 230 | } |
| 231 | return string(b) |
| 232 | } |
| 233 | |
| 234 | // ---- error classification ---------------------------------------------- |
| 235 | |
| 236 | // isNoSuchEntity reports whether err is IAM's NoSuchEntityException — the |
| 237 | // "already gone" answer ListAccessKeys, DeleteAccessKey, DeleteUserPolicy and |
| 238 | // DeleteUser all use for their respective absent target. |
| 239 | func isNoSuchEntity(err error) bool { |
| 240 | var nse *iamtypes.NoSuchEntityException |
| 241 | return errors.As(err, &nse) |
| 242 | } |
| 243 | |
| 244 | // isNoSuchBucket reports whether err is S3's "bucket does not exist" answer. |
| 245 | // DeleteBucket and DeleteObjects surface the typed s3types.NoSuchBucket, but |
| 246 | // ListObjectsV2 has been observed to surface it only as a generic smithy |
| 247 | // APIError with code "NoSuchBucket" — so both forms are checked. |
| 248 | func isNoSuchBucket(err error) bool { |
| 249 | var nsb *s3types.NoSuchBucket |
| 250 | if errors.As(err, &nsb) { |
| 251 | return true |
| 252 | } |
| 253 | var apiErr smithy.APIError |
| 254 | if errors.As(err, &apiErr) && apiErr.ErrorCode() == "NoSuchBucket" { |
| 255 | return true |
| 256 | } |
| 257 | return false |
| 258 | } |
| 259 | |
| 260 | // ---- S3 ------------------------------------------------------------------- |
| 261 | |
| 262 | // EnsureBucket creates name if absent and answers the region it was |
| 263 | // provisioned in. AWS returns BucketAlreadyOwnedByYou (not us-east-1) or a |
| 264 | // bare success (us-east-1's historical quirk) when the caller's own prior |
| 265 | // CreateBucket already succeeded, so that shape is success too — this is what |
| 266 | // makes the grant walk's bucket step re-runnable. BucketAlreadyExists (a |
| 267 | // different account owns the global name) is a real conflict, not treated as |
| 268 | // idempotent: this provisioner has no way to tell "you already made this" |
| 269 | // from "someone else got the name first", and only the former is safe to |
| 270 | // swallow. |
| 271 | func (p *awsProvisioner) EnsureBucket(ctx context.Context, name string) (string, error) { |
| 272 | in := &awss3.CreateBucketInput{Bucket: aws.String(name)} |
| 273 | if p.region != "" && p.region != "us-east-1" { |
| 274 | in.CreateBucketConfiguration = &s3types.CreateBucketConfiguration{ |
| 275 | LocationConstraint: s3types.BucketLocationConstraint(p.region), |
| 276 | } |
| 277 | } |
| 278 | if _, err := p.s3.CreateBucket(ctx, in); err != nil { |
| 279 | var owned *s3types.BucketAlreadyOwnedByYou |
| 280 | if !errors.As(err, &owned) { |
| 281 | return "", fmt.Errorf("objstore: create bucket %q: %w", name, err) |
| 282 | } |
| 283 | } |
| 284 | return p.region, nil |
| 285 | } |
| 286 | |
| 287 | // EmptyBucket deletes every object in name, paginating ListObjectsV2 and |
| 288 | // batching DeleteObjects at <=1000 keys (the API's own ceiling) so a crash |
| 289 | // mid-empty resumes cleanly on the next page. An absent bucket — including |
| 290 | // discovering that only partway through, on a later ListObjectsV2 page — is |
| 291 | // success: teardown resumed after a crash between DeleteBucket and DeleteUser |
| 292 | // re-runs this against a bucket that is already gone. |
| 293 | func (p *awsProvisioner) EmptyBucket(ctx context.Context, name string) error { |
| 294 | var token *string |
| 295 | for { |
| 296 | out, err := p.s3.ListObjectsV2(ctx, &awss3.ListObjectsV2Input{ |
| 297 | Bucket: aws.String(name), |
| 298 | ContinuationToken: token, |
| 299 | }) |
| 300 | if err != nil { |
| 301 | if isNoSuchBucket(err) { |
| 302 | return nil |
| 303 | } |
| 304 | return fmt.Errorf("objstore: list objects in %q: %w", name, err) |
| 305 | } |
| 306 | if err := p.deleteObjectBatches(ctx, name, out.Contents); err != nil { |
| 307 | return err |
| 308 | } |
| 309 | if !aws.ToBool(out.IsTruncated) { |
| 310 | return nil |
| 311 | } |
| 312 | token = out.NextContinuationToken |
| 313 | } |
| 314 | } |
| 315 | |
| 316 | // deleteObjectBatches issues DeleteObjects in chunks of <=1000 keys, the |
| 317 | // API's own limit per call. |
| 318 | func (p *awsProvisioner) deleteObjectBatches(ctx context.Context, bucket string, objs []s3types.Object) error { |
| 319 | const batchSize = 1000 |
| 320 | for start := 0; start < len(objs); start += batchSize { |
| 321 | end := start + batchSize |
| 322 | if end > len(objs) { |
| 323 | end = len(objs) |
| 324 | } |
| 325 | ids := make([]s3types.ObjectIdentifier, end-start) |
| 326 | for i, obj := range objs[start:end] { |
| 327 | ids[i] = s3types.ObjectIdentifier{Key: obj.Key} |
| 328 | } |
| 329 | out, err := p.s3.DeleteObjects(ctx, &awss3.DeleteObjectsInput{ |
| 330 | Bucket: aws.String(bucket), |
| 331 | Delete: &s3types.Delete{Objects: ids}, |
| 332 | }) |
| 333 | if err != nil { |
| 334 | if isNoSuchBucket(err) { |
| 335 | return nil |
| 336 | } |
| 337 | return fmt.Errorf("objstore: delete objects in %q: %w", bucket, err) |
| 338 | } |
| 339 | // DeleteObjects is a 200-with-body API: a per-key failure (AccessDenied, |
| 340 | // object-lock, ...) never surfaces as err — it lands in out.Errors while |
| 341 | // the call itself reports success. Left unchecked, EmptyBucket returns |
| 342 | // nil with objects still in the bucket, and Teardown then dies later at |
| 343 | // DeleteBucket's BucketNotEmpty with no key and no reason, retrying |
| 344 | // forever without ever naming the object that's actually stuck. |
| 345 | if len(out.Errors) > 0 { |
| 346 | first := out.Errors[0] |
| 347 | return fmt.Errorf("objstore: delete objects in %q: %d failed, first %s: %s (%s)", |
| 348 | bucket, len(out.Errors), aws.ToString(first.Key), aws.ToString(first.Code), aws.ToString(first.Message)) |
| 349 | } |
| 350 | } |
| 351 | return nil |
| 352 | } |
| 353 | |
| 354 | // DeleteBucket removes name. Absent is success: it is the re-run shape after |
| 355 | // a crash right after a prior DeleteBucket succeeded but before that was |
| 356 | // recorded. |
| 357 | func (p *awsProvisioner) DeleteBucket(ctx context.Context, name string) error { |
| 358 | if _, err := p.s3.DeleteBucket(ctx, &awss3.DeleteBucketInput{Bucket: aws.String(name)}); err != nil { |
| 359 | if isNoSuchBucket(err) { |
| 360 | return nil |
| 361 | } |
| 362 | return fmt.Errorf("objstore: delete bucket %q: %w", name, err) |
| 363 | } |
| 364 | return nil |
| 365 | } |
| 366 | |
| 367 | // ---- IAM -------------------------------------------------------------- |
| 368 | |
| 369 | // EnsureUser creates the principal at path if absent, scoped to bucketARN by |
| 370 | // an inline policy that is written unconditionally either way. A user that |
| 371 | // already exists (EntityAlreadyExistsException — the grant walk re-run after |
| 372 | // a crash between CreateUser and PutUserPolicy) is resolved to its ARN via |
| 373 | // GetUser rather than treated as an error; PutUserPolicy is an idempotent |
| 374 | // overwrite, so it always runs regardless of which branch produced the ARN. |
| 375 | // The user is created UNDER A PERMISSIONS BOUNDARY, and that is the load |
| 376 | // bearing half of this function rather than a hardening extra. |
| 377 | // |
| 378 | // A role that may call CreateUser and PutUserPolicy can, without a boundary, |
| 379 | // mint a principal carrying any policy it likes — including one far wider |
| 380 | // than the role itself. Scoping those two actions to a path is not a fix: |
| 381 | // the path constrains WHICH user is made, never what that user may do. So |
| 382 | // the deployment's own IAM conditions both calls on this exact boundary ARN |
| 383 | // (infra/modules/region-host), and the boundary permits nothing but S3 on |
| 384 | // the deployment's own bucket prefix. The inline policy below then narrows |
| 385 | // that ceiling to one bucket; effective permissions are the intersection, |
| 386 | // and the ceiling holds even if this code one day writes the wrong document. |
| 387 | // |
| 388 | // An empty boundary is legal here only because MemProvisioner and the |
| 389 | // contract tests construct provisioners with no IAM at all. Every real |
| 390 | // console refuses to boot without one (cmd/console/main.go, storeBackend). |
| 391 | func (p *awsProvisioner) EnsureUser(ctx context.Context, name, path, bucketARN string, deny *DenyTag) (string, error) { |
| 392 | var arn string |
| 393 | in := &iam.CreateUserInput{ |
| 394 | UserName: aws.String(name), |
| 395 | Path: aws.String(path), |
| 396 | } |
| 397 | if p.boundary != "" { |
| 398 | in.PermissionsBoundary = aws.String(p.boundary) |
| 399 | } |
| 400 | out, err := p.iam.CreateUser(ctx, in) |
| 401 | if err != nil { |
| 402 | var exists *iamtypes.EntityAlreadyExistsException |
| 403 | if !errors.As(err, &exists) { |
| 404 | return "", fmt.Errorf("objstore: create user %q: %w", name, err) |
| 405 | } |
| 406 | got, gerr := p.iam.GetUser(ctx, &iam.GetUserInput{UserName: aws.String(name)}) |
| 407 | if gerr != nil { |
| 408 | return "", fmt.Errorf("objstore: get existing user %q: %w", name, gerr) |
| 409 | } |
| 410 | arn = aws.ToString(got.User.Arn) |
| 411 | } else { |
| 412 | arn = aws.ToString(out.User.Arn) |
| 413 | } |
| 414 | |
| 415 | _, err = p.iam.PutUserPolicy(ctx, &iam.PutUserPolicyInput{ |
| 416 | UserName: aws.String(name), |
| 417 | PolicyName: aws.String(policyName), |
| 418 | PolicyDocument: aws.String(storePolicyJSON(bucketARN, deny)), |
| 419 | }) |
| 420 | if err != nil { |
| 421 | return "", fmt.Errorf("objstore: put policy for user %q: %w", name, err) |
| 422 | } |
| 423 | return arn, nil |
| 424 | } |
| 425 | |
| 426 | // MintKey creates a fresh access key for user. |
| 427 | func (p *awsProvisioner) MintKey(ctx context.Context, user string) (string, string, error) { |
| 428 | out, err := p.iam.CreateAccessKey(ctx, &iam.CreateAccessKeyInput{UserName: aws.String(user)}) |
| 429 | if err != nil { |
| 430 | return "", "", fmt.Errorf("objstore: create access key for %q: %w", user, err) |
| 431 | } |
| 432 | return aws.ToString(out.AccessKey.AccessKeyId), aws.ToString(out.AccessKey.SecretAccessKey), nil |
| 433 | } |
| 434 | |
| 435 | // DeleteKey removes access key id from user. Absent is success: it is the |
| 436 | // shape Rotate step-1 and RotateFinish's PrevKeyID escape hatch both produce |
| 437 | // on a re-run after the key was already deleted. |
| 438 | func (p *awsProvisioner) DeleteKey(ctx context.Context, user, id string) error { |
| 439 | _, err := p.iam.DeleteAccessKey(ctx, &iam.DeleteAccessKeyInput{ |
| 440 | UserName: aws.String(user), |
| 441 | AccessKeyId: aws.String(id), |
| 442 | }) |
| 443 | if err != nil && !isNoSuchEntity(err) { |
| 444 | return fmt.Errorf("objstore: delete access key %q for %q: %w", id, user, err) |
| 445 | } |
| 446 | return nil |
| 447 | } |
| 448 | |
| 449 | // ReapKeys deletes every access key of user not named in keep. A user that no |
| 450 | // longer exists (NoSuchEntity from ListAccessKeys) is success with nothing to |
| 451 | // reap — the grant walk's crash-safety depends on this list-and-delete being |
| 452 | // safe to re-run against a user that got torn down out from under it. |
| 453 | func (p *awsProvisioner) ReapKeys(ctx context.Context, user string, keep []string) error { |
| 454 | keepSet := make(map[string]bool, len(keep)) |
| 455 | for _, id := range keep { |
| 456 | keepSet[id] = true |
| 457 | } |
| 458 | var marker *string |
| 459 | for { |
| 460 | out, err := p.iam.ListAccessKeys(ctx, &iam.ListAccessKeysInput{ |
| 461 | UserName: aws.String(user), |
| 462 | Marker: marker, |
| 463 | }) |
| 464 | if err != nil { |
| 465 | if isNoSuchEntity(err) { |
| 466 | return nil |
| 467 | } |
| 468 | return fmt.Errorf("objstore: list access keys for %q: %w", user, err) |
| 469 | } |
| 470 | for _, k := range out.AccessKeyMetadata { |
| 471 | id := aws.ToString(k.AccessKeyId) |
| 472 | if keepSet[id] { |
| 473 | continue |
| 474 | } |
| 475 | if err := p.DeleteKey(ctx, user, id); err != nil { |
| 476 | return err |
| 477 | } |
| 478 | } |
| 479 | if !out.IsTruncated { |
| 480 | return nil |
| 481 | } |
| 482 | marker = out.Marker |
| 483 | } |
| 484 | } |
| 485 | |
| 486 | // DeleteUser removes user entirely: every access key, then the inline |
| 487 | // policy, then the user itself — IAM refuses to delete a user that still |
| 488 | // owns either. A pending-finish PrevKeyID (a key Rotate minted but no |
| 489 | // RotateFinish ever reaped) is caught here by ReapKeys(keep=nil) during |
| 490 | // teardown even if nothing else ever reaped it. Absent at any step |
| 491 | // (NoSuchEntity) is success throughout, including a bare re-run against a |
| 492 | // user already fully deleted. |
| 493 | func (p *awsProvisioner) DeleteUser(ctx context.Context, user string) error { |
| 494 | if err := p.ReapKeys(ctx, user, nil); err != nil { |
| 495 | return err |
| 496 | } |
| 497 | if _, err := p.iam.DeleteUserPolicy(ctx, &iam.DeleteUserPolicyInput{ |
| 498 | UserName: aws.String(user), |
| 499 | PolicyName: aws.String(policyName), |
| 500 | }); err != nil && !isNoSuchEntity(err) { |
| 501 | return fmt.Errorf("objstore: delete policy for user %q: %w", user, err) |
| 502 | } |
| 503 | if _, err := p.iam.DeleteUser(ctx, &iam.DeleteUserInput{UserName: aws.String(user)}); err != nil && !isNoSuchEntity(err) { |
| 504 | return fmt.Errorf("objstore: delete user %q: %w", user, err) |
| 505 | } |
| 506 | return nil |
| 507 | } |
| 508 | |