carlos / platform Public

Clone
git clone https://amadan.net/carlos/platform

Plain git — no account needed to clone.

Download

Download this file

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.
6package objstore
7
8import (
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.
27const 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.
31type 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.
39type 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.
51type 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
61var _ 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.
81func 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.
100func 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
117type 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
129type 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.
180func 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.
239func 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.
248func 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.
271func (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.
293func (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.
318func (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.
357func (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).
391func (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.
427func (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.
438func (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.
453func (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.
493func (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