carlos / platform Public

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

Plain git — no account needed to clone.

Download

Download this file

1package objstore
2
3import (
4 "context"
5 "encoding/json"
6 "testing"
7
8 "github.com/aws/aws-sdk-go-v2/aws"
9 "github.com/aws/aws-sdk-go-v2/service/iam"
10 iamtypes "github.com/aws/aws-sdk-go-v2/service/iam/types"
11 awss3 "github.com/aws/aws-sdk-go-v2/service/s3"
12 s3types "github.com/aws/aws-sdk-go-v2/service/s3/types"
13 "github.com/aws/smithy-go"
14)
15
16// TestNewAWSProvisioner is construction-only: it must succeed offline (the
17// default credential chain loads without ever making a call), and the
18// returned value must satisfy Provisioner.
19func TestNewAWSProvisioner(t *testing.T) {
20 p, err := NewAWSProvisioner(context.Background(), "eu-west-1", "", "")
21 if err != nil {
22 t.Fatalf("NewAWSProvisioner: %v", err)
23 }
24 if p == nil {
25 t.Fatal("NewAWSProvisioner returned nil Provisioner")
26 }
27}
28
29// TestNewAWSProvisionerEndpointOptions checks the four s3compat-mirrored
30// options land on the real client when endpoint is set: BaseEndpoint +
31// UsePathStyle (addressing) and both checksum-WhenRequired settings (the
32// pair that keeps default-on flexible checksums from wrapping bodies in
33// aws-chunked framing some S3-compatibles reject — DeleteObjects, EmptyBucket's
34// batch delete, is exactly a checksummed request). White-box: reaches into
35// the unexported s3 field because this is the only seam that observes what
36// awss3.NewFromConfig actually built, short of a live request.
37func TestNewAWSProvisionerEndpointOptions(t *testing.T) {
38 prov, err := NewAWSProvisioner(context.Background(), "eu-west-1", "http://127.0.0.1:9000", "")
39 if err != nil {
40 t.Fatal(err)
41 }
42 p, ok := prov.(*awsProvisioner)
43 if !ok {
44 t.Fatalf("NewAWSProvisioner returned %T, want *awsProvisioner", prov)
45 }
46 client, ok := p.s3.(*awss3.Client)
47 if !ok {
48 t.Fatalf("p.s3 is %T, want *s3.Client", p.s3)
49 }
50 opts := client.Options()
51 if opts.BaseEndpoint == nil || *opts.BaseEndpoint != "http://127.0.0.1:9000" {
52 t.Fatalf("BaseEndpoint = %v, want http://127.0.0.1:9000", opts.BaseEndpoint)
53 }
54 if !opts.UsePathStyle {
55 t.Fatal("UsePathStyle = false, want true")
56 }
57 if opts.RequestChecksumCalculation != aws.RequestChecksumCalculationWhenRequired {
58 t.Fatalf("RequestChecksumCalculation = %v, want WhenRequired", opts.RequestChecksumCalculation)
59 }
60 if opts.ResponseChecksumValidation != aws.ResponseChecksumValidationWhenRequired {
61 t.Fatalf("ResponseChecksumValidation = %v, want WhenRequired", opts.ResponseChecksumValidation)
62 }
63}
64
65// TestNewAWSProvisionerNoEndpointOptions checks the stock-AWS path (empty
66// endpoint) leaves those four options untouched, matching s3compat.Options'
67// own no-op guarantee for a client that talks to real S3.
68func TestNewAWSProvisionerNoEndpointOptions(t *testing.T) {
69 prov, err := NewAWSProvisioner(context.Background(), "eu-west-1", "", "")
70 if err != nil {
71 t.Fatal(err)
72 }
73 p := prov.(*awsProvisioner)
74 client := p.s3.(*awss3.Client)
75 opts := client.Options()
76 if opts.BaseEndpoint != nil {
77 t.Fatalf("BaseEndpoint = %v, want nil", *opts.BaseEndpoint)
78 }
79 if opts.UsePathStyle {
80 t.Fatal("UsePathStyle = true, want false")
81 }
82}
83
84// ---- stub clients -------------------------------------------------------
85//
86// Each stub implements the narrow s3API/iamAPI seam with per-method hooks.
87// An un-set hook fails the test loudly (via t.Fatalf) rather than panicking
88// on a nil func — a test that calls a method it didn't mean to exercise
89// should say so.
90
91type stubS3 struct {
92 t *testing.T
93
94 createBucket func(*awss3.CreateBucketInput) (*awss3.CreateBucketOutput, error)
95 deleteBucket func(*awss3.DeleteBucketInput) (*awss3.DeleteBucketOutput, error)
96 listObjectsV2 func(*awss3.ListObjectsV2Input) (*awss3.ListObjectsV2Output, error)
97 deleteObjects func(*awss3.DeleteObjectsInput) (*awss3.DeleteObjectsOutput, error)
98}
99
100func (s *stubS3) CreateBucket(_ context.Context, in *awss3.CreateBucketInput, _ ...func(*awss3.Options)) (*awss3.CreateBucketOutput, error) {
101 if s.createBucket == nil {
102 s.t.Fatal("unexpected CreateBucket call")
103 }
104 return s.createBucket(in)
105}
106
107func (s *stubS3) DeleteBucket(_ context.Context, in *awss3.DeleteBucketInput, _ ...func(*awss3.Options)) (*awss3.DeleteBucketOutput, error) {
108 if s.deleteBucket == nil {
109 s.t.Fatal("unexpected DeleteBucket call")
110 }
111 return s.deleteBucket(in)
112}
113
114func (s *stubS3) ListObjectsV2(_ context.Context, in *awss3.ListObjectsV2Input, _ ...func(*awss3.Options)) (*awss3.ListObjectsV2Output, error) {
115 if s.listObjectsV2 == nil {
116 s.t.Fatal("unexpected ListObjectsV2 call")
117 }
118 return s.listObjectsV2(in)
119}
120
121func (s *stubS3) DeleteObjects(_ context.Context, in *awss3.DeleteObjectsInput, _ ...func(*awss3.Options)) (*awss3.DeleteObjectsOutput, error) {
122 if s.deleteObjects == nil {
123 s.t.Fatal("unexpected DeleteObjects call")
124 }
125 return s.deleteObjects(in)
126}
127
128type stubIAM struct {
129 t *testing.T
130
131 createUser func(*iam.CreateUserInput) (*iam.CreateUserOutput, error)
132 getUser func(*iam.GetUserInput) (*iam.GetUserOutput, error)
133 putUserPolicy func(*iam.PutUserPolicyInput) (*iam.PutUserPolicyOutput, error)
134 deleteUserPolicy func(*iam.DeleteUserPolicyInput) (*iam.DeleteUserPolicyOutput, error)
135 deleteUser func(*iam.DeleteUserInput) (*iam.DeleteUserOutput, error)
136 createAccessKey func(*iam.CreateAccessKeyInput) (*iam.CreateAccessKeyOutput, error)
137 deleteAccessKey func(*iam.DeleteAccessKeyInput) (*iam.DeleteAccessKeyOutput, error)
138 listAccessKeys func(*iam.ListAccessKeysInput) (*iam.ListAccessKeysOutput, error)
139}
140
141func (s *stubIAM) CreateUser(_ context.Context, in *iam.CreateUserInput, _ ...func(*iam.Options)) (*iam.CreateUserOutput, error) {
142 if s.createUser == nil {
143 s.t.Fatal("unexpected CreateUser call")
144 }
145 return s.createUser(in)
146}
147
148func (s *stubIAM) GetUser(_ context.Context, in *iam.GetUserInput, _ ...func(*iam.Options)) (*iam.GetUserOutput, error) {
149 if s.getUser == nil {
150 s.t.Fatal("unexpected GetUser call")
151 }
152 return s.getUser(in)
153}
154
155func (s *stubIAM) PutUserPolicy(_ context.Context, in *iam.PutUserPolicyInput, _ ...func(*iam.Options)) (*iam.PutUserPolicyOutput, error) {
156 if s.putUserPolicy == nil {
157 s.t.Fatal("unexpected PutUserPolicy call")
158 }
159 return s.putUserPolicy(in)
160}
161
162func (s *stubIAM) DeleteUserPolicy(_ context.Context, in *iam.DeleteUserPolicyInput, _ ...func(*iam.Options)) (*iam.DeleteUserPolicyOutput, error) {
163 if s.deleteUserPolicy == nil {
164 s.t.Fatal("unexpected DeleteUserPolicy call")
165 }
166 return s.deleteUserPolicy(in)
167}
168
169func (s *stubIAM) DeleteUser(_ context.Context, in *iam.DeleteUserInput, _ ...func(*iam.Options)) (*iam.DeleteUserOutput, error) {
170 if s.deleteUser == nil {
171 s.t.Fatal("unexpected DeleteUser call")
172 }
173 return s.deleteUser(in)
174}
175
176func (s *stubIAM) CreateAccessKey(_ context.Context, in *iam.CreateAccessKeyInput, _ ...func(*iam.Options)) (*iam.CreateAccessKeyOutput, error) {
177 if s.createAccessKey == nil {
178 s.t.Fatal("unexpected CreateAccessKey call")
179 }
180 return s.createAccessKey(in)
181}
182
183func (s *stubIAM) DeleteAccessKey(_ context.Context, in *iam.DeleteAccessKeyInput, _ ...func(*iam.Options)) (*iam.DeleteAccessKeyOutput, error) {
184 if s.deleteAccessKey == nil {
185 s.t.Fatal("unexpected DeleteAccessKey call")
186 }
187 return s.deleteAccessKey(in)
188}
189
190func (s *stubIAM) ListAccessKeys(_ context.Context, in *iam.ListAccessKeysInput, _ ...func(*iam.Options)) (*iam.ListAccessKeysOutput, error) {
191 if s.listAccessKeys == nil {
192 s.t.Fatal("unexpected ListAccessKeys call")
193 }
194 return s.listAccessKeys(in)
195}
196
197// ---- idempotence mappings (HARD constraints from Tasks 4-6) -------------
198
199// DeleteKey on an absent key (NoSuchEntityException) must be nil: both
200// RotateFinish's PrevKeyID escape hatch and Rotate step-1 re-run this against
201// a key that is already gone.
202func TestDeleteKeyAbsentIsNil(t *testing.T) {
203 p := &awsProvisioner{iam: &stubIAM{t: t,
204 deleteAccessKey: func(*iam.DeleteAccessKeyInput) (*iam.DeleteAccessKeyOutput, error) {
205 return nil, &iamtypes.NoSuchEntityException{Message: aws.String("gone")}
206 },
207 }}
208 if err := p.DeleteKey(context.Background(), "u1", "AKIAABSENT"); err != nil {
209 t.Fatalf("DeleteKey on absent key: %v", err)
210 }
211}
212
213// DeleteUser on an absent user must be nil, and must delete access keys and
214// the inline policy BEFORE DeleteUser — IAM refuses to delete a user that
215// still owns either, and this is also where a pending-finish PrevKeyID gets
216// reaped during teardown.
217func TestDeleteUserAbsentIsNilAndOrdered(t *testing.T) {
218 var order []string
219 stub := &stubIAM{t: t,
220 listAccessKeys: func(*iam.ListAccessKeysInput) (*iam.ListAccessKeysOutput, error) {
221 order = append(order, "list")
222 return &iam.ListAccessKeysOutput{
223 AccessKeyMetadata: []iamtypes.AccessKeyMetadata{
224 {AccessKeyId: aws.String("AKIAPENDING")},
225 },
226 IsTruncated: false,
227 }, nil
228 },
229 deleteAccessKey: func(in *iam.DeleteAccessKeyInput) (*iam.DeleteAccessKeyOutput, error) {
230 order = append(order, "deleteKey:"+aws.ToString(in.AccessKeyId))
231 return &iam.DeleteAccessKeyOutput{}, nil
232 },
233 deleteUserPolicy: func(*iam.DeleteUserPolicyInput) (*iam.DeleteUserPolicyOutput, error) {
234 order = append(order, "deletePolicy")
235 return nil, &iamtypes.NoSuchEntityException{Message: aws.String("gone")}
236 },
237 deleteUser: func(*iam.DeleteUserInput) (*iam.DeleteUserOutput, error) {
238 order = append(order, "deleteUser")
239 return nil, &iamtypes.NoSuchEntityException{Message: aws.String("gone")}
240 },
241 }
242 p := &awsProvisioner{iam: stub}
243 if err := p.DeleteUser(context.Background(), "u1"); err != nil {
244 t.Fatalf("DeleteUser on absent user: %v", err)
245 }
246 want := []string{"list", "deleteKey:AKIAPENDING", "deletePolicy", "deleteUser"}
247 if len(order) != len(want) {
248 t.Fatalf("call order = %v, want %v", order, want)
249 }
250 for i := range want {
251 if order[i] != want[i] {
252 t.Fatalf("call order = %v, want %v", order, want)
253 }
254 }
255}
256
257// ReapKeys on an absent user (NoSuchEntity from ListAccessKeys) must be nil.
258func TestReapKeysAbsentUserIsNil(t *testing.T) {
259 p := &awsProvisioner{iam: &stubIAM{t: t,
260 listAccessKeys: func(*iam.ListAccessKeysInput) (*iam.ListAccessKeysOutput, error) {
261 return nil, &iamtypes.NoSuchEntityException{Message: aws.String("gone")}
262 },
263 }}
264 if err := p.ReapKeys(context.Background(), "ghost", []string{"keep-me"}); err != nil {
265 t.Fatalf("ReapKeys on absent user: %v", err)
266 }
267}
268
269// ReapKeys deletes every key not in keep and leaves the kept ones alone.
270func TestReapKeysDeletesUnkept(t *testing.T) {
271 deleted := map[string]bool{}
272 p := &awsProvisioner{iam: &stubIAM{t: t,
273 listAccessKeys: func(*iam.ListAccessKeysInput) (*iam.ListAccessKeysOutput, error) {
274 return &iam.ListAccessKeysOutput{
275 AccessKeyMetadata: []iamtypes.AccessKeyMetadata{
276 {AccessKeyId: aws.String("KEEP")},
277 {AccessKeyId: aws.String("REAP1")},
278 {AccessKeyId: aws.String("REAP2")},
279 },
280 IsTruncated: false,
281 }, nil
282 },
283 deleteAccessKey: func(in *iam.DeleteAccessKeyInput) (*iam.DeleteAccessKeyOutput, error) {
284 deleted[aws.ToString(in.AccessKeyId)] = true
285 return &iam.DeleteAccessKeyOutput{}, nil
286 },
287 }}
288 if err := p.ReapKeys(context.Background(), "u1", []string{"KEEP"}); err != nil {
289 t.Fatal(err)
290 }
291 if !deleted["REAP1"] || !deleted["REAP2"] {
292 t.Fatalf("expected REAP1 and REAP2 deleted, got %v", deleted)
293 }
294 if deleted["KEEP"] {
295 t.Fatal("KEEP should not have been deleted")
296 }
297}
298
299// EmptyBucket on an absent bucket must be nil, including when the NoSuchBucket
300// only surfaces on the ListObjectsV2 leg (as a smithy APIError, not the typed
301// s3types.NoSuchBucket) — teardown resume after a crash between DeleteBucket
302// and DeleteUser re-runs EmptyBucket on a gone bucket.
303func TestEmptyBucketAbsentIsNil(t *testing.T) {
304 p := &awsProvisioner{s3: &stubS3{t: t,
305 listObjectsV2: func(*awss3.ListObjectsV2Input) (*awss3.ListObjectsV2Output, error) {
306 return nil, &smithyAPIErrorStub{code: "NoSuchBucket", msg: "The specified bucket does not exist"}
307 },
308 }}
309 if err := p.EmptyBucket(context.Background(), "gone-bucket"); err != nil {
310 t.Fatalf("EmptyBucket on absent bucket: %v", err)
311 }
312}
313
314// Same, but via the typed s3types.NoSuchBucket rather than a generic
315// smithy.APIError — both forms must be recognized.
316func TestEmptyBucketAbsentTypedErrorIsNil(t *testing.T) {
317 p := &awsProvisioner{s3: &stubS3{t: t,
318 listObjectsV2: func(*awss3.ListObjectsV2Input) (*awss3.ListObjectsV2Output, error) {
319 return nil, &s3types.NoSuchBucket{Message: aws.String("gone")}
320 },
321 }}
322 if err := p.EmptyBucket(context.Background(), "gone-bucket"); err != nil {
323 t.Fatalf("EmptyBucket on absent bucket (typed): %v", err)
324 }
325}
326
327// EmptyBucket batches DeleteObjects at <=1000 keys per call and follows
328// ListObjectsV2 pagination.
329func TestEmptyBucketBatchesAndPaginates(t *testing.T) {
330 page1 := make([]s3types.Object, 1500) // exceeds the 1000 batch ceiling
331 for i := range page1 {
332 page1[i] = s3types.Object{Key: aws.String("obj1500")}
333 }
334 page2 := []s3types.Object{{Key: aws.String("obj-page2")}}
335
336 var listCalls int
337 var deleteBatchSizes []int
338 p := &awsProvisioner{s3: &stubS3{t: t,
339 listObjectsV2: func(in *awss3.ListObjectsV2Input) (*awss3.ListObjectsV2Output, error) {
340 listCalls++
341 if listCalls == 1 {
342 return &awss3.ListObjectsV2Output{
343 Contents: page1,
344 IsTruncated: aws.Bool(true),
345 NextContinuationToken: aws.String("page2"),
346 }, nil
347 }
348 if aws.ToString(in.ContinuationToken) != "page2" {
349 t.Fatalf("expected continuation token page2, got %q", aws.ToString(in.ContinuationToken))
350 }
351 return &awss3.ListObjectsV2Output{
352 Contents: page2,
353 IsTruncated: aws.Bool(false),
354 }, nil
355 },
356 deleteObjects: func(in *awss3.DeleteObjectsInput) (*awss3.DeleteObjectsOutput, error) {
357 deleteBatchSizes = append(deleteBatchSizes, len(in.Delete.Objects))
358 return &awss3.DeleteObjectsOutput{}, nil
359 },
360 }}
361 if err := p.EmptyBucket(context.Background(), "b1"); err != nil {
362 t.Fatal(err)
363 }
364 if listCalls != 2 {
365 t.Fatalf("listCalls = %d, want 2", listCalls)
366 }
367 want := []int{1000, 500, 1}
368 if len(deleteBatchSizes) != len(want) {
369 t.Fatalf("delete batch sizes = %v, want %v", deleteBatchSizes, want)
370 }
371 for i := range want {
372 if deleteBatchSizes[i] != want[i] {
373 t.Fatalf("delete batch sizes = %v, want %v", deleteBatchSizes, want)
374 }
375 }
376}
377
378// DeleteObjects is a 200-with-body API: a per-key failure lands in
379// out.Errors while the call itself reports no err. EmptyBucket must surface
380// that as an error naming the count and the first failing key, rather than
381// silently returning nil with objects still in the bucket — a caller that
382// swallowed this would hang forever retrying DeleteBucket's later
383// BucketNotEmpty with no clue which object is stuck.
384func TestEmptyBucketSurfacesPerKeyDeleteErrors(t *testing.T) {
385 p := &awsProvisioner{s3: &stubS3{t: t,
386 listObjectsV2: func(*awss3.ListObjectsV2Input) (*awss3.ListObjectsV2Output, error) {
387 return &awss3.ListObjectsV2Output{
388 Contents: []s3types.Object{
389 {Key: aws.String("locked-obj")},
390 {Key: aws.String("other-obj")},
391 },
392 IsTruncated: aws.Bool(false),
393 }, nil
394 },
395 deleteObjects: func(*awss3.DeleteObjectsInput) (*awss3.DeleteObjectsOutput, error) {
396 return &awss3.DeleteObjectsOutput{
397 Errors: []s3types.Error{
398 {Key: aws.String("locked-obj"), Code: aws.String("AccessDenied"), Message: aws.String("object is under legal hold")},
399 },
400 }, nil
401 },
402 }}
403 err := p.EmptyBucket(context.Background(), "b1")
404 if err == nil {
405 t.Fatal("expected an error from a DeleteObjects batch with populated Errors, got nil")
406 }
407 const want = `objstore: delete objects in "b1": 1 failed, first locked-obj: AccessDenied (object is under legal hold)`
408 if err.Error() != want {
409 t.Fatalf("error = %q, want %q", err.Error(), want)
410 }
411}
412
413// DeleteBucket on an absent bucket must be nil.
414func TestDeleteBucketAbsentIsNil(t *testing.T) {
415 p := &awsProvisioner{s3: &stubS3{t: t,
416 deleteBucket: func(*awss3.DeleteBucketInput) (*awss3.DeleteBucketOutput, error) {
417 return nil, &s3types.NoSuchBucket{Message: aws.String("gone")}
418 },
419 }}
420 if err := p.DeleteBucket(context.Background(), "gone-bucket"); err != nil {
421 t.Fatalf("DeleteBucket on absent bucket: %v", err)
422 }
423}
424
425// EnsureBucket treats BucketAlreadyOwnedByYou as success and answers the
426// configured region.
427func TestEnsureBucketAlreadyOwnedByYou(t *testing.T) {
428 p := &awsProvisioner{region: "eu-west-1", s3: &stubS3{t: t,
429 createBucket: func(*awss3.CreateBucketInput) (*awss3.CreateBucketOutput, error) {
430 return nil, &s3types.BucketAlreadyOwnedByYou{Message: aws.String("already yours")}
431 },
432 }}
433 region, err := p.EnsureBucket(context.Background(), "b1")
434 if err != nil {
435 t.Fatalf("EnsureBucket on already-owned bucket: %v", err)
436 }
437 if region != "eu-west-1" {
438 t.Fatalf("region = %q, want eu-west-1", region)
439 }
440}
441
442// EnsureBucket does NOT set a location constraint for us-east-1 (the region
443// with no LocationConstraint value of its own).
444func TestEnsureBucketUsEast1NoLocationConstraint(t *testing.T) {
445 var gotConstraint bool
446 p := &awsProvisioner{region: "us-east-1", s3: &stubS3{t: t,
447 createBucket: func(in *awss3.CreateBucketInput) (*awss3.CreateBucketOutput, error) {
448 gotConstraint = in.CreateBucketConfiguration != nil
449 return &awss3.CreateBucketOutput{}, nil
450 },
451 }}
452 if _, err := p.EnsureBucket(context.Background(), "b1"); err != nil {
453 t.Fatal(err)
454 }
455 if gotConstraint {
456 t.Fatal("us-east-1 must not set CreateBucketConfiguration")
457 }
458}
459
460// EnsureUser: EntityAlreadyExists resolves the ARN via GetUser, and
461// PutUserPolicy always runs (idempotent overwrite) regardless of which
462// branch produced the ARN.
463func TestEnsureUserAlreadyExists(t *testing.T) {
464 var putPolicyCalled bool
465 var putDoc string
466 p := &awsProvisioner{iam: &stubIAM{t: t,
467 createUser: func(*iam.CreateUserInput) (*iam.CreateUserOutput, error) {
468 return nil, &iamtypes.EntityAlreadyExistsException{Message: aws.String("exists")}
469 },
470 getUser: func(in *iam.GetUserInput) (*iam.GetUserOutput, error) {
471 return &iam.GetUserOutput{User: &iamtypes.User{
472 Arn: aws.String("arn:aws:iam::123456789012:user/carlos-store/u1"),
473 UserName: in.UserName,
474 }}, nil
475 },
476 putUserPolicy: func(in *iam.PutUserPolicyInput) (*iam.PutUserPolicyOutput, error) {
477 putPolicyCalled = true
478 putDoc = aws.ToString(in.PolicyDocument)
479 return &iam.PutUserPolicyOutput{}, nil
480 },
481 }}
482 arn, err := p.EnsureUser(context.Background(), "u1", UserPath, "arn:aws:s3:::b1", nil)
483 if err != nil {
484 t.Fatal(err)
485 }
486 if arn != "arn:aws:iam::123456789012:user/carlos-store/u1" {
487 t.Fatalf("arn = %q", arn)
488 }
489 if !putPolicyCalled {
490 t.Fatal("PutUserPolicy must run unconditionally, even on the already-exists branch")
491 }
492 assertStorePolicyDocument(t, putDoc, "arn:aws:s3:::b1")
493}
494
495// EnsureUser on a fresh user: ARN comes from CreateUser's own response, and
496// the policy document matches the brief exactly.
497func TestEnsureUserFresh(t *testing.T) {
498 var putDoc string
499 p := &awsProvisioner{iam: &stubIAM{t: t,
500 createUser: func(in *iam.CreateUserInput) (*iam.CreateUserOutput, error) {
501 return &iam.CreateUserOutput{User: &iamtypes.User{
502 Arn: aws.String("arn:aws:iam::123456789012:user/carlos-store/u1"),
503 UserName: in.UserName,
504 Path: in.Path,
505 }}, nil
506 },
507 putUserPolicy: func(in *iam.PutUserPolicyInput) (*iam.PutUserPolicyOutput, error) {
508 if aws.ToString(in.PolicyName) != policyName {
509 t.Fatalf("policy name = %q, want %q", aws.ToString(in.PolicyName), policyName)
510 }
511 putDoc = aws.ToString(in.PolicyDocument)
512 return &iam.PutUserPolicyOutput{}, nil
513 },
514 }}
515 arn, err := p.EnsureUser(context.Background(), "u1", UserPath, "arn:aws:s3:::b1", nil)
516 if err != nil {
517 t.Fatal(err)
518 }
519 if arn != "arn:aws:iam::123456789012:user/carlos-store/u1" {
520 t.Fatalf("arn = %q", arn)
521 }
522 assertStorePolicyDocument(t, putDoc, "arn:aws:s3:::b1")
523}
524
525// assertStorePolicyDocument checks doc matches the brief's policy shape
526// exactly: ListBucket on bucketARN, Get/Put/DeleteObject on bucketARN+"/*".
527func assertStorePolicyDocument(t *testing.T, doc, bucketARN string) {
528 t.Helper()
529 var parsed struct {
530 Version string `json:"Version"`
531 Statement []struct {
532 Effect string `json:"Effect"`
533 Action []string `json:"Action"`
534 Resource string `json:"Resource"`
535 } `json:"Statement"`
536 }
537 if err := json.Unmarshal([]byte(doc), &parsed); err != nil {
538 t.Fatalf("policy document not valid JSON: %v\n%s", err, doc)
539 }
540 if parsed.Version != "2012-10-17" {
541 t.Fatalf("Version = %q", parsed.Version)
542 }
543 // Two Allows, then the two anti-forgery Denys that every store carries
544 // whether or not enforcement is on. This helper checks the Allow half
545 // exactly; assertNoPutObjectTagging (enforce_test.go) owns the Deny half,
546 // including the x-amz-tagging route that an action-name search cannot see.
547 if len(parsed.Statement) != 4 {
548 t.Fatalf("Statement has %d entries, want 4 (two Allows, two anti-forgery Denys):\n%s", len(parsed.Statement), doc)
549 }
550 list, objects := parsed.Statement[0], parsed.Statement[1]
551 if list.Effect != "Allow" || len(list.Action) != 1 || list.Action[0] != "s3:ListBucket" || list.Resource != bucketARN {
552 t.Fatalf("ListBucket statement = %+v", list)
553 }
554 // s3:GetObjectTagging is here so an app can read the scan verdict on its
555 // own object. s3:PutObjectTagging is deliberately absent and must stay
556 // absent — but absence alone was never the whole property, and believing
557 // it was left the x-amz-tagging upload route open. storePolicyJSON's
558 // comment has the story; the Denys are what actually close it.
559 wantActions := map[string]bool{
560 "s3:GetObject": true, "s3:PutObject": true, "s3:DeleteObject": true,
561 "s3:GetObjectTagging": true,
562 }
563 if objects.Effect != "Allow" || len(objects.Action) != 4 || objects.Resource != bucketARN+"/*" {
564 t.Fatalf("object statement = %+v", objects)
565 }
566 for _, a := range objects.Action {
567 if !wantActions[a] {
568 t.Fatalf("unexpected action %q in object statement", a)
569 }
570 }
571 assertNoPutObjectTagging(t, doc)
572}
573
574// smithyAPIErrorStub is a minimal smithy.APIError for exercising the code
575// path where ListObjectsV2 answers NoSuchBucket as a generic API error
576// rather than the typed s3types.NoSuchBucket.
577type smithyAPIErrorStub struct {
578 code string
579 msg string
580}
581
582func (e *smithyAPIErrorStub) Error() string { return e.code + ": " + e.msg }
583func (e *smithyAPIErrorStub) ErrorCode() string { return e.code }
584func (e *smithyAPIErrorStub) ErrorMessage() string { return e.msg }
585func (e *smithyAPIErrorStub) ErrorFault() smithy.ErrorFault { return smithy.FaultUnknown }
586
587var _ smithy.APIError = (*smithyAPIErrorStub)(nil)
588
589// TestEnsureUserStampsThePermissionsBoundary: the boundary is the only thing
590// standing between "this role may create IAM users" and "this role may create
591// an IAM user with any policy at all", so it has to reach CreateUser on the
592// exact call that makes the principal — not be attached afterwards, where a
593// crash in between leaves an unbounded user holding a live access key.
594func TestEnsureUserStampsThePermissionsBoundary(t *testing.T) {
595 const boundary = "arn:aws:iam::1:policy/carlos-store-boundary"
596 var seen *string
597 iamStub := &stubIAM{
598 t: t,
599 createUser: func(in *iam.CreateUserInput) (*iam.CreateUserOutput, error) {
600 seen = in.PermissionsBoundary
601 return &iam.CreateUserOutput{User: &iamtypes.User{
602 Arn: aws.String("arn:aws:iam::1:user/carlos-store/carlos-store-bab-titogo"),
603 }}, nil
604 },
605 putUserPolicy: func(*iam.PutUserPolicyInput) (*iam.PutUserPolicyOutput, error) {
606 return &iam.PutUserPolicyOutput{}, nil
607 },
608 }
609 p := &awsProvisioner{iam: iamStub, region: "eu-west-1", boundary: boundary}
610 if _, err := p.EnsureUser(context.Background(), "carlos-store-bab-titogo", UserPath, "arn:aws:s3:::carlos-bab-titogo", nil); err != nil {
611 t.Fatalf("EnsureUser: %v", err)
612 }
613 if seen == nil {
614 t.Fatal("CreateUser was called with NO permissions boundary — the deployment's IAM will refuse this call, and if it ever stops refusing, the user is unbounded")
615 }
616 if *seen != boundary {
617 t.Fatalf("PermissionsBoundary = %q, want %q", *seen, boundary)
618 }
619}
620
621// An empty boundary must send no field at all rather than an empty string:
622// MemProvisioner and the contract tests construct provisioners with no IAM,
623// and CreateUser rejects an empty PermissionsBoundary outright.
624func TestEnsureUserOmitsAnEmptyBoundary(t *testing.T) {
625 sent := true
626 iamStub := &stubIAM{
627 t: t,
628 createUser: func(in *iam.CreateUserInput) (*iam.CreateUserOutput, error) {
629 sent = in.PermissionsBoundary != nil
630 return &iam.CreateUserOutput{User: &iamtypes.User{Arn: aws.String("arn:aws:iam::1:user/x")}}, nil
631 },
632 putUserPolicy: func(*iam.PutUserPolicyInput) (*iam.PutUserPolicyOutput, error) {
633 return &iam.PutUserPolicyOutput{}, nil
634 },
635 }
636 p := &awsProvisioner{iam: iamStub, region: "eu-west-1"}
637 if _, err := p.EnsureUser(context.Background(), "u", UserPath, "arn:aws:s3:::b", nil); err != nil {
638 t.Fatalf("EnsureUser: %v", err)
639 }
640 if sent {
641 t.Fatal("PermissionsBoundary was set from an empty boundary; want the field omitted")
642 }
643}
644