// Copyright 2026 Versity Software // This file is licensed under the Apache License, Version 2.0 // (the "License"); you may not use this file except in compliance // with the License. You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, // software distributed under the License is distributed on an // "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY // KIND, either express or implied. See the License for the // specific language governing permissions and limitations // under the License. package integration import ( "bytes" "context" "encoding/json" "fmt" "strings" "time" "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" "github.com/aws/aws-sdk-go-v2/service/s3" "github.com/aws/aws-sdk-go-v2/service/s3/types" "github.com/versity/versitygw/s3err" ) // S3IAMAccessControl_no_policy_denies verifies a caller with no identity // policy and no bucket policy is denied by default — there is no implicit // grant anywhere for an ordinary IAM user. func S3IAMAccessControl_no_policy_denies(s *S3Conf) error { testName := "S3IAMAccessControl_no_policy_denies" return s3IAMActionHandler(s, testName, func(root *iam.Client, bucket string) error { user, cleanup, err := newS3IAMUser(root, s, nil) if err != nil { return err } defer cleanup() ctx, cancel := context.WithTimeout(context.Background(), shortTimeout) _, err = user.client.GetObject(ctx, &s3.GetObjectInput{Bucket: &bucket, Key: getPtr("obj")}) cancel() return checkApiErr(err, wantImplicitDeny(user.arn, actS3GetObject, objectArn(bucket, "obj"))) }) } // S3IAMAccessControl_root_bypasses_policies verifies the gateway's root // credential is authorized regardless of any policy, including a bucket // policy that explicitly denies everyone. func S3IAMAccessControl_root_bypasses_policies(s *S3Conf) error { testName := "S3IAMAccessControl_root_bypasses_policies" return s3IAMActionHandler(s, testName, func(root *iam.Client, bucket string) error { if err := putBucketPolicyDoc(s, bucket, bucketStatement{ Effect: "Deny", Principal: "*", Action: "s3:*", Resource: []string{bucketArn(bucket), objectsArn(bucket)}, }); err != nil { return err } ctx, cancel := context.WithTimeout(context.Background(), shortTimeout) _, err := s.GetClient().PutObject(ctx, &s3.PutObjectInput{Bucket: &bucket, Key: getPtr("obj")}) cancel() return err }) } // S3IAMAccessControl_identity_policy_allows_without_bucket_policy is the // core same-account behavior: an identity-policy Allow grants the request on // its own, with no bucket policy and no ACL grant involved at all. func S3IAMAccessControl_identity_policy_allows_without_bucket_policy(s *S3Conf) error { testName := "S3IAMAccessControl_identity_policy_allows_without_bucket_policy" return s3IAMActionHandler(s, testName, func(root *iam.Client, bucket string) error { user, cleanup, err := newS3IAMUser(root, s, map[string]string{ "p": policyDoc(accessStatement{Effect: "Allow", Action: actS3PutObject, Resource: objectsArn(bucket)}), }) if err != nil { return err } defer cleanup() ctx, cancel := context.WithTimeout(context.Background(), shortTimeout) _, err = user.client.PutObject(ctx, &s3.PutObjectInput{Bucket: &bucket, Key: getPtr("obj")}) cancel() if err != nil { return fmt.Errorf("expected PutObject to be allowed: %w", err) } // The same policy grants nothing beyond the action it names. ctx, cancel = context.WithTimeout(context.Background(), shortTimeout) _, err = user.client.GetObject(ctx, &s3.GetObjectInput{Bucket: &bucket, Key: getPtr("obj")}) cancel() return checkApiErr(err, wantImplicitDeny(user.arn, actS3GetObject, objectArn(bucket, "obj"))) }) } // S3IAMAccessControl_identity_policy_action_wildcards verifies "s3:*" and // prefix wildcards ("s3:Get*") match the way an exact action name does. func S3IAMAccessControl_identity_policy_action_wildcards(s *S3Conf) error { testName := "S3IAMAccessControl_identity_policy_action_wildcards" return s3IAMActionHandler(s, testName, func(root *iam.Client, bucket string) error { ctx, cancel := context.WithTimeout(context.Background(), shortTimeout) _, err := s.GetClient().PutObject(ctx, &s3.PutObjectInput{Bucket: &bucket, Key: getPtr("obj")}) cancel() if err != nil { return err } cases := []struct { name string action any wantGetOK bool wantPutOK bool }{ {name: "full wildcard", action: "s3:*", wantGetOK: true, wantPutOK: true}, {name: "prefix wildcard", action: "s3:Get*", wantGetOK: true, wantPutOK: false}, {name: "bare wildcard", action: "*", wantGetOK: true, wantPutOK: true}, } for _, tc := range cases { if err := func() error { user, cleanup, err := newS3IAMUser(root, s, map[string]string{ "p": policyDoc(accessStatement{ Effect: "Allow", Action: tc.action, Resource: []string{bucketArn(bucket), objectsArn(bucket)}, }), }) if err != nil { return err } defer cleanup() ctx, cancel = context.WithTimeout(context.Background(), shortTimeout) _, err = user.client.GetObject(ctx, &s3.GetObjectInput{Bucket: &bucket, Key: getPtr("obj")}) cancel() if tc.wantGetOK { if err != nil { return fmt.Errorf("expected GetObject to be allowed: %w", err) } } else if err := checkApiErr(err, wantImplicitDeny(user.arn, actS3GetObject, objectArn(bucket, "obj"))); err != nil { return err } ctx, cancel = context.WithTimeout(context.Background(), shortTimeout) _, err = user.client.PutObject(ctx, &s3.PutObjectInput{Bucket: &bucket, Key: getPtr("obj2")}) cancel() if tc.wantPutOK { if err != nil { return fmt.Errorf("expected PutObject to be allowed: %w", err) } return nil } return checkApiErr(err, wantImplicitDeny(user.arn, actS3PutObject, objectArn(bucket, "obj2"))) }(); err != nil { return fmt.Errorf("%s: %w", tc.name, err) } } return nil }) } // S3IAMAccessControl_identity_policy_resource_scoping verifies a Resource // pattern scopes a grant to matching keys only. func S3IAMAccessControl_identity_policy_resource_scoping(s *S3Conf) error { testName := "S3IAMAccessControl_identity_policy_resource_scoping" return s3IAMActionHandler(s, testName, func(root *iam.Client, bucket string) error { for _, key := range []string{"allowed/obj", "denied/obj"} { ctx, cancel := context.WithTimeout(context.Background(), shortTimeout) _, err := s.GetClient().PutObject(ctx, &s3.PutObjectInput{Bucket: &bucket, Key: getPtr(key)}) cancel() if err != nil { return err } } user, cleanup, err := newS3IAMUser(root, s, map[string]string{ "p": policyDoc(accessStatement{ Effect: "Allow", Action: actS3GetObject, Resource: objectArn(bucket, "allowed/*"), }), }) if err != nil { return err } defer cleanup() ctx, cancel := context.WithTimeout(context.Background(), shortTimeout) _, err = user.client.GetObject(ctx, &s3.GetObjectInput{Bucket: &bucket, Key: getPtr("allowed/obj")}) cancel() if err != nil { return fmt.Errorf("expected GetObject on the matching key to be allowed: %w", err) } ctx, cancel = context.WithTimeout(context.Background(), shortTimeout) _, err = user.client.GetObject(ctx, &s3.GetObjectInput{Bucket: &bucket, Key: getPtr("denied/obj")}) cancel() return checkApiErr(err, wantImplicitDeny(user.arn, actS3GetObject, objectArn(bucket, "denied/obj"))) }) } // S3IAMAccessControl_identity_policy_bucket_vs_object_arn verifies a // bucket-level action evaluates against the bucket ARN, so an object-ARN // grant ("bucket/*") does not cover it and vice versa. func S3IAMAccessControl_identity_policy_bucket_vs_object_arn(s *S3Conf) error { testName := "S3IAMAccessControl_identity_policy_bucket_vs_object_arn" return s3IAMActionHandler(s, testName, func(root *iam.Client, bucket string) error { objectOnly, cleanupObj, err := newS3IAMUser(root, s, map[string]string{ "p": policyDoc(accessStatement{Effect: "Allow", Action: actS3ListBucket, Resource: objectsArn(bucket)}), }) if err != nil { return err } defer cleanupObj() ctx, cancel := context.WithTimeout(context.Background(), shortTimeout) _, err = objectOnly.client.ListObjectsV2(ctx, &s3.ListObjectsV2Input{Bucket: &bucket}) cancel() if err := checkApiErr(err, wantImplicitDeny(objectOnly.arn, actS3ListBucket, bucketArn(bucket))); err != nil { return fmt.Errorf("an object-ARN grant must not cover a bucket-level action: %w", err) } bucketScoped, cleanupBucket, err := newS3IAMUser(root, s, map[string]string{ "p": policyDoc(accessStatement{Effect: "Allow", Action: actS3ListBucket, Resource: bucketArn(bucket)}), }) if err != nil { return err } defer cleanupBucket() ctx, cancel = context.WithTimeout(context.Background(), shortTimeout) _, err = bucketScoped.client.ListObjectsV2(ctx, &s3.ListObjectsV2Input{Bucket: &bucket}) cancel() if err != nil { return fmt.Errorf("expected ListObjects to be allowed by a bucket-ARN grant: %w", err) } return nil }) } // S3IAMAccessControl_identity_policy_not_action_and_not_resource verifies // NotAction and NotResource grant everything *except* what they name. func S3IAMAccessControl_identity_policy_not_action_and_not_resource(s *S3Conf) error { testName := "S3IAMAccessControl_identity_policy_not_action_and_not_resource" return s3IAMActionHandler(s, testName, func(root *iam.Client, bucket string) error { ctx, cancel := context.WithTimeout(context.Background(), shortTimeout) _, err := s.GetClient().PutObject(ctx, &s3.PutObjectInput{Bucket: &bucket, Key: getPtr("obj")}) cancel() if err != nil { return err } notAction, cleanupAction, err := newS3IAMUser(root, s, map[string]string{ "p": policyDoc(accessStatement{ Effect: "Allow", NotAction: actS3GetObject, Resource: []string{bucketArn(bucket), objectsArn(bucket)}, }), }) if err != nil { return err } defer cleanupAction() ctx, cancel = context.WithTimeout(context.Background(), shortTimeout) _, err = notAction.client.PutObject(ctx, &s3.PutObjectInput{Bucket: &bucket, Key: getPtr("other")}) cancel() if err != nil { return fmt.Errorf("NotAction must grant an action it does not name: %w", err) } ctx, cancel = context.WithTimeout(context.Background(), shortTimeout) _, err = notAction.client.GetObject(ctx, &s3.GetObjectInput{Bucket: &bucket, Key: getPtr("obj")}) cancel() if err := checkApiErr(err, wantImplicitDeny(notAction.arn, actS3GetObject, objectArn(bucket, "obj"))); err != nil { return fmt.Errorf("NotAction must not grant the action it names: %w", err) } notResource, cleanupResource, err := newS3IAMUser(root, s, map[string]string{ "p": policyDoc(accessStatement{ Effect: "Allow", Action: actS3GetObject, NotResource: objectArn(bucket, "obj"), }), }) if err != nil { return err } defer cleanupResource() ctx, cancel = context.WithTimeout(context.Background(), shortTimeout) _, err = notResource.client.GetObject(ctx, &s3.GetObjectInput{Bucket: &bucket, Key: getPtr("other")}) cancel() if err != nil { return fmt.Errorf("NotResource must grant a resource it does not name: %w", err) } ctx, cancel = context.WithTimeout(context.Background(), shortTimeout) _, err = notResource.client.GetObject(ctx, &s3.GetObjectInput{Bucket: &bucket, Key: getPtr("obj")}) cancel() return checkApiErr(err, wantImplicitDeny(notResource.arn, actS3GetObject, objectArn(bucket, "obj"))) }) } // S3IAMAccessControl_identity_policy_explicit_deny_wins verifies an explicit // Deny beats a matching Allow regardless of statement order or of whether // the two live in the same inline policy document. func S3IAMAccessControl_identity_policy_explicit_deny_wins(s *S3Conf) error { testName := "S3IAMAccessControl_identity_policy_explicit_deny_wins" return s3IAMActionHandler(s, testName, func(root *iam.Client, bucket string) error { ctx, cancel := context.WithTimeout(context.Background(), shortTimeout) _, err := s.GetClient().PutObject(ctx, &s3.PutObjectInput{Bucket: &bucket, Key: getPtr("obj")}) cancel() if err != nil { return err } allow := accessStatement{Effect: "Allow", Action: actS3GetObject, Resource: objectsArn(bucket)} deny := accessStatement{Effect: "Deny", Action: actS3GetObject, Resource: objectsArn(bucket)} cases := []struct { name string policies map[string]string }{ {"deny after allow, same document", map[string]string{"p": policyDoc(allow, deny)}}, {"deny before allow, same document", map[string]string{"p": policyDoc(deny, allow)}}, {"allow and deny in separate documents", map[string]string{"a": policyDoc(allow), "d": policyDoc(deny)}}, } for _, tc := range cases { if err := func() error { user, cleanup, err := newS3IAMUser(root, s, tc.policies) if err != nil { return err } defer cleanup() ctx, cancel = context.WithTimeout(context.Background(), shortTimeout) _, err = user.client.GetObject(ctx, &s3.GetObjectInput{Bucket: &bucket, Key: getPtr("obj")}) cancel() return checkApiErr(err, wantExplicitIdentityDeny(user.arn, actS3GetObject, objectArn(bucket, "obj"))) }(); err != nil { return fmt.Errorf("%s: %w", tc.name, err) } } return nil }) } // S3IAMAccessControl_multiple_inline_policies_combine verifies separate // inline policy documents are unioned, so an action allowed by either one is // allowed overall. func S3IAMAccessControl_multiple_inline_policies_combine(s *S3Conf) error { testName := "S3IAMAccessControl_multiple_inline_policies_combine" return s3IAMActionHandler(s, testName, func(root *iam.Client, bucket string) error { user, cleanup, err := newS3IAMUser(root, s, map[string]string{ "reader": policyDoc(accessStatement{Effect: "Allow", Action: actS3GetObject, Resource: objectsArn(bucket)}), "writer": policyDoc(accessStatement{Effect: "Allow", Action: actS3PutObject, Resource: objectsArn(bucket)}), }) if err != nil { return err } defer cleanup() ctx, cancel := context.WithTimeout(context.Background(), shortTimeout) _, err = user.client.PutObject(ctx, &s3.PutObjectInput{Bucket: &bucket, Key: getPtr("obj")}) cancel() if err != nil { return fmt.Errorf("expected PutObject to be allowed by the second document: %w", err) } ctx, cancel = context.WithTimeout(context.Background(), shortTimeout) _, err = user.client.GetObject(ctx, &s3.GetObjectInput{Bucket: &bucket, Key: getPtr("obj")}) cancel() if err != nil { return fmt.Errorf("expected GetObject to be allowed by the first document: %w", err) } return nil }) } // S3IAMAccessControl_bucket_policy_allows_without_identity_policy verifies // the resource side is independently sufficient too: a bucket policy naming // the user's ARN grants the request with no identity policy at all. func S3IAMAccessControl_bucket_policy_allows_without_identity_policy(s *S3Conf) error { testName := "S3IAMAccessControl_bucket_policy_allows_without_identity_policy" return s3IAMActionHandler(s, testName, func(root *iam.Client, bucket string) error { user, cleanup, err := newS3IAMUser(root, s, nil) if err != nil { return err } defer cleanup() if err := putBucketPolicyDoc(s, bucket, bucketStatement{ Effect: "Allow", Principal: user.arn, Action: actS3PutObject, Resource: objectsArn(bucket), }); err != nil { return err } ctx, cancel := context.WithTimeout(context.Background(), shortTimeout) _, err = user.client.PutObject(ctx, &s3.PutObjectInput{Bucket: &bucket, Key: getPtr("obj")}) cancel() if err != nil { return fmt.Errorf("expected PutObject to be allowed by the bucket policy: %w", err) } ctx, cancel = context.WithTimeout(context.Background(), shortTimeout) _, err = user.client.GetObject(ctx, &s3.GetObjectInput{Bucket: &bucket, Key: getPtr("obj")}) cancel() return checkApiErr(err, wantImplicitDeny(user.arn, actS3GetObject, objectArn(bucket, "obj"))) }) } // S3IAMAccessControl_bucket_policy_explicit_deny verifies a bucket-policy // Deny denies on its own, and reports the resource-based-policy message. func S3IAMAccessControl_bucket_policy_explicit_deny(s *S3Conf) error { testName := "S3IAMAccessControl_bucket_policy_explicit_deny" return s3IAMActionHandler(s, testName, func(root *iam.Client, bucket string) error { user, cleanup, err := newS3IAMUser(root, s, nil) if err != nil { return err } defer cleanup() if err := putBucketPolicyDoc(s, bucket, bucketStatement{ Effect: "Deny", Principal: user.arn, Action: actS3GetObject, Resource: objectsArn(bucket), }); err != nil { return err } ctx, cancel := context.WithTimeout(context.Background(), shortTimeout) _, err = user.client.GetObject(ctx, &s3.GetObjectInput{Bucket: &bucket, Key: getPtr("obj")}) cancel() return checkApiErr(err, wantExplicitResourceDeny(user.arn, actS3GetObject, objectArn(bucket, "obj"))) }) } // S3IAMAccessControl_policy_combinations walks the full precedence matrix // between the identity policy and the bucket policy for one action, checking // the exact outcome and message for each of the nine combinations that // matter. func S3IAMAccessControl_policy_combinations(s *S3Conf) error { testName := "S3IAMAccessControl_policy_combinations" return s3IAMActionHandler(s, testName, func(root *iam.Client, bucket string) error { ctx, cancel := context.WithTimeout(context.Background(), shortTimeout) _, err := s.GetClient().PutObject(ctx, &s3.PutObjectInput{Bucket: &bucket, Key: getPtr("obj")}) cancel() if err != nil { return err } const ( silent = "silent" allow = "allow" deny = "deny" ) cases := []struct { identity string resource string // wantErr builds the expected error, or is nil when the request // must succeed. wantErr func(user *s3IAMPrincipal) s3err.S3Error }{ {identity: silent, resource: silent, wantErr: func(u *s3IAMPrincipal) s3err.S3Error { return wantImplicitDeny(u.arn, actS3GetObject, objectArn(bucket, "obj")) }}, {identity: allow, resource: silent}, {identity: silent, resource: allow}, {identity: allow, resource: allow}, {identity: deny, resource: silent, wantErr: func(u *s3IAMPrincipal) s3err.S3Error { return wantExplicitIdentityDeny(u.arn, actS3GetObject, objectArn(bucket, "obj")) }}, {identity: deny, resource: allow, wantErr: func(u *s3IAMPrincipal) s3err.S3Error { return wantExplicitIdentityDeny(u.arn, actS3GetObject, objectArn(bucket, "obj")) }}, {identity: silent, resource: deny, wantErr: func(u *s3IAMPrincipal) s3err.S3Error { return wantExplicitResourceDeny(u.arn, actS3GetObject, objectArn(bucket, "obj")) }}, {identity: allow, resource: deny, wantErr: func(u *s3IAMPrincipal) s3err.S3Error { return wantExplicitResourceDeny(u.arn, actS3GetObject, objectArn(bucket, "obj")) }}, // A Deny on both sides is reported as the resource-based one: // VerifyAccess evaluates the bucket policy first and returns // immediately, which also saves an IAM round trip. {identity: deny, resource: deny, wantErr: func(u *s3IAMPrincipal) s3err.S3Error { return wantExplicitResourceDeny(u.arn, actS3GetObject, objectArn(bucket, "obj")) }}, } for _, tc := range cases { if err := func() error { policies := map[string]string{} if tc.identity != silent { effect := "Allow" if tc.identity == deny { effect = "Deny" } policies["p"] = policyDoc(accessStatement{ Effect: effect, Action: actS3GetObject, Resource: objectsArn(bucket), }) } user, cleanup, err := newS3IAMUser(root, s, policies) if err != nil { return err } defer cleanup() if tc.resource == silent { if err := deleteBucketPolicyIfAny(s, bucket); err != nil { return err } } else { effect := "Allow" if tc.resource == deny { effect = "Deny" } if err := putBucketPolicyDoc(s, bucket, bucketStatement{ Effect: effect, Principal: user.arn, Action: actS3GetObject, Resource: objectsArn(bucket), }); err != nil { return err } } ctx, cancel = context.WithTimeout(context.Background(), shortTimeout) _, err = user.client.GetObject(ctx, &s3.GetObjectInput{Bucket: &bucket, Key: getPtr("obj")}) cancel() if tc.wantErr == nil { if err != nil { return fmt.Errorf("expected the request to be allowed: %w", err) } return nil } return checkApiErr(err, tc.wantErr(user)) }(); err != nil { return fmt.Errorf("identity=%s resource=%s: %w", tc.identity, tc.resource, err) } } return nil }) } // S3IAMAccessControl_copy_object_requires_both_sides verifies a CopyObject // is authorized against both its source (GetObject) and its destination // (PutObject), so a policy granting only one of the two is not enough. func S3IAMAccessControl_copy_object_requires_both_sides(s *S3Conf) error { testName := "S3IAMAccessControl_copy_object_requires_both_sides" return s3IAMActionHandler(s, testName, func(root *iam.Client, bucket string) error { ctx, cancel := context.WithTimeout(context.Background(), shortTimeout) _, err := s.GetClient().PutObject(ctx, &s3.PutObjectInput{Bucket: &bucket, Key: getPtr("src")}) cancel() if err != nil { return err } cases := []struct { name string action any wantAction string wantArn string }{ {name: "destination only", action: actS3PutObject, wantAction: actS3GetObject, wantArn: objectArn(bucket, "src")}, {name: "source only", action: actS3GetObject, wantAction: actS3PutObject, wantArn: objectArn(bucket, "dst")}, } for _, tc := range cases { if err := func() error { user, cleanup, err := newS3IAMUser(root, s, map[string]string{ "p": policyDoc(accessStatement{Effect: "Allow", Action: tc.action, Resource: objectsArn(bucket)}), }) if err != nil { return err } defer cleanup() ctx, cancel := context.WithTimeout(context.Background(), shortTimeout) _, err = user.client.CopyObject(ctx, &s3.CopyObjectInput{ Bucket: &bucket, Key: aws.String("dst"), CopySource: aws.String(bucket + "/src"), }) cancel() return checkApiErr(err, wantImplicitDeny(user.arn, tc.wantAction, tc.wantArn)) }(); err != nil { return fmt.Errorf("%s: %w", tc.name, err) } } // Granting both sides completes the copy. user, cleanup, err := newS3IAMUser(root, s, map[string]string{ "p": policyDoc(accessStatement{ Effect: "Allow", Action: []string{actS3GetObject, actS3PutObject}, Resource: objectsArn(bucket), }), }) if err != nil { return err } defer cleanup() ctx, cancel = context.WithTimeout(context.Background(), shortTimeout) defer cancel() if _, err := user.client.CopyObject(ctx, &s3.CopyObjectInput{ Bucket: &bucket, Key: aws.String("dst"), CopySource: aws.String(bucket + "/src"), }); err != nil { return fmt.Errorf("expected CopyObject to be allowed once both sides are granted: %w", err) } return nil }) } // S3IAMAccessControl_create_bucket verifies s3:CreateBucket is gated by the // identity policy alone — the bucket doesn't exist yet, so there is no // bucket policy or ACL to consult — and that the grant is resource-scoped to // the bucket name. func S3IAMAccessControl_create_bucket(s *S3Conf) error { testName := "S3IAMAccessControl_create_bucket" return actionHandlerNoSetup(s, testName, func(_ *s3.Client, _ string) error { root := s.GetIAMClient() allowedName, otherName := getBucketName(), getBucketName() cases := []struct { name string policy func() string bucket string wantErr func(user *s3IAMPrincipal, bucket string) s3err.S3Error }{ { name: "no policy denies", policy: func() string { return "" }, bucket: otherName, wantErr: func(u *s3IAMPrincipal, b string) s3err.S3Error { return wantImplicitDeny(u.arn, actS3CreateBucket, bucketArn(b)) }, }, { name: "explicit deny", policy: func() string { return policyDoc(accessStatement{Effect: "Deny", Action: actS3CreateBucket, Resource: "*"}) }, bucket: otherName, wantErr: func(u *s3IAMPrincipal, b string) s3err.S3Error { return wantExplicitIdentityDeny(u.arn, actS3CreateBucket, bucketArn(b)) }, }, { name: "scoped grant allows the named bucket", policy: func() string { return policyDoc(accessStatement{Effect: "Allow", Action: actS3CreateBucket, Resource: bucketArn(allowedName)}) }, bucket: allowedName, }, { name: "scoped grant denies another bucket", policy: func() string { return policyDoc(accessStatement{Effect: "Allow", Action: actS3CreateBucket, Resource: bucketArn(allowedName)}) }, bucket: otherName, wantErr: func(u *s3IAMPrincipal, b string) s3err.S3Error { return wantImplicitDeny(u.arn, actS3CreateBucket, bucketArn(b)) }, }, { name: "wildcard grant allows any bucket", policy: func() string { return policyDoc(accessStatement{Effect: "Allow", Action: actS3CreateBucket, Resource: "arn:aws:s3:::*"}) }, bucket: otherName, }, } for _, tc := range cases { if err := func() error { policies := map[string]string{} if doc := tc.policy(); doc != "" { policies["p"] = doc } user, cleanup, err := newS3IAMUser(root, s, policies) if err != nil { return err } defer cleanup() ctx, cancel := context.WithTimeout(context.Background(), shortTimeout) _, err = user.client.CreateBucket(ctx, &s3.CreateBucketInput{Bucket: &tc.bucket}) cancel() if tc.wantErr == nil { if err != nil { return fmt.Errorf("expected CreateBucket to be allowed: %w", err) } return teardown(s, tc.bucket) } return checkApiErr(err, tc.wantErr(user, tc.bucket)) }(); err != nil { return fmt.Errorf("%s: %w", tc.name, err) } } return nil }) } // S3IAMAccessControl_list_buckets verifies ListBuckets is gated by // s3:ListAllMyBuckets under the standalone IAM service: root always lists, // an ordinary user needs an identity-policy Allow for the action. func S3IAMAccessControl_list_buckets(s *S3Conf) error { testName := "S3IAMAccessControl_list_buckets" return s3IAMActionHandler(s, testName, func(root *iam.Client, bucket string) error { allBuckets := "arn:aws:s3:::*" cases := []struct { name string policy string wantErr func(user *s3IAMPrincipal) s3err.S3Error }{ { name: "no policy denies", wantErr: func(u *s3IAMPrincipal) s3err.S3Error { return wantImplicitDeny(u.arn, actS3ListAllMyBuckets, allBuckets) }, }, { name: "another action's grant doesn't allow", policy: policyDoc(accessStatement{Effect: "Allow", Action: actS3ListBucket, Resource: bucketArn(bucket)}), wantErr: func(u *s3IAMPrincipal) s3err.S3Error { return wantImplicitDeny(u.arn, actS3ListAllMyBuckets, allBuckets) }, }, { name: "explicit deny", policy: policyDoc(accessStatement{Effect: "Deny", Action: actS3ListAllMyBuckets, Resource: "*"}), wantErr: func(u *s3IAMPrincipal) s3err.S3Error { return wantExplicitIdentityDeny(u.arn, actS3ListAllMyBuckets, allBuckets) }, }, { name: "grant on the bucket wildcard arn allows", policy: policyDoc(accessStatement{Effect: "Allow", Action: actS3ListAllMyBuckets, Resource: allBuckets}), }, { name: "grant on a bare wildcard resource allows", policy: policyDoc(accessStatement{Effect: "Allow", Action: actS3ListAllMyBuckets, Resource: "*"}), }, { name: "grant scoped to one bucket doesn't allow", policy: policyDoc(accessStatement{Effect: "Allow", Action: actS3ListAllMyBuckets, Resource: bucketArn(bucket)}), wantErr: func(u *s3IAMPrincipal) s3err.S3Error { return wantImplicitDeny(u.arn, actS3ListAllMyBuckets, allBuckets) }, }, } for _, tc := range cases { if err := func() error { policies := map[string]string{} if tc.policy != "" { policies["p"] = tc.policy } user, cleanup, err := newS3IAMUser(root, s, policies) if err != nil { return err } defer cleanup() ctx, cancel := context.WithTimeout(context.Background(), shortTimeout) out, err := user.client.ListBuckets(ctx, &s3.ListBucketsInput{}) cancel() if tc.wantErr != nil { return checkApiErr(err, tc.wantErr(user)) } if err != nil { return fmt.Errorf("expected ListBuckets to be allowed: %w", err) } // Ownership is fixed to root here, so an allowed user sees // every bucket, including the one this test created. if !containsBucket(out.Buckets, bucket) { return fmt.Errorf("expected the listing to contain %q, got %v", bucket, out.Buckets) } return nil }(); err != nil { return fmt.Errorf("%s: %w", tc.name, err) } } // Root lists with no policy of its own, and is never subject to one. ctx, cancel := context.WithTimeout(context.Background(), shortTimeout) out, err := s.GetClient().ListBuckets(ctx, &s3.ListBucketsInput{}) cancel() if err != nil { return fmt.Errorf("root ListBuckets: %w", err) } if !containsBucket(out.Buckets, bucket) { return fmt.Errorf("root: expected the listing to contain %q, got %v", bucket, out.Buckets) } return nil }) } // S3IAMAccessControl_governance_bypass_sources verifies s3:BypassGovernance // Retention follows the same precedence as any other action: an Allow from // either the identity policy or the bucket policy is enough on its own, and // an explicit Deny from either wins over the other's Allow. func S3IAMAccessControl_governance_bypass_sources(s *S3Conf) error { testName := "S3IAMAccessControl_governance_bypass_sources" return s3IAMActionHandler(s, testName, func(root *iam.Client, bucket string) error { const ( silent = "silent" allow = "allow" deny = "deny" ) cases := []struct { identity string resource string wantDenied bool }{ {identity: allow, resource: silent}, {identity: silent, resource: allow}, {identity: allow, resource: allow}, {identity: silent, resource: silent, wantDenied: true}, {identity: deny, resource: allow, wantDenied: true}, {identity: allow, resource: deny, wantDenied: true}, } for i, tc := range cases { if err := func() error { key := fmt.Sprintf("locked-%d", i) if err := putGovernanceLockedObject(s, bucket, key); err != nil { return err } // Deleting the object always needs s3:DeleteObject as well; // only the bypass permission is what varies per case. statements := []accessStatement{ {Effect: "Allow", Action: actS3DeleteObject, Resource: objectsArn(bucket)}, } if tc.identity != silent { effect := "Allow" if tc.identity == deny { effect = "Deny" } statements = append(statements, accessStatement{ Effect: effect, Action: actS3BypassGovernance, Resource: objectsArn(bucket), }) } user, cleanup, err := newS3IAMUser(root, s, map[string]string{"p": policyDoc(statements...)}) if err != nil { return err } defer cleanup() if tc.resource == silent { if err := deleteBucketPolicyIfAny(s, bucket); err != nil { return err } } else { effect := "Allow" if tc.resource == deny { effect = "Deny" } if err := putBucketPolicyDoc(s, bucket, bucketStatement{ Effect: effect, Principal: user.arn, Action: actS3BypassGovernance, Resource: objectsArn(bucket), }); err != nil { return err } } ctx, cancel := context.WithTimeout(context.Background(), shortTimeout) _, err = user.client.DeleteObject(ctx, &s3.DeleteObjectInput{ Bucket: &bucket, Key: &key, BypassGovernanceRetention: aws.Bool(true), }) cancel() if !tc.wantDenied { if err != nil { return fmt.Errorf("expected the governance-bypassing delete to be allowed: %w", err) } return nil } if err == nil { return fmt.Errorf("expected the governance-bypassing delete to be denied") } // Whichever way bypass was denied, the error names the // bypass action specifically — not the generic // "object protected by object lock" message, which the // gateway reserves for a request with no bypass header. if err := checkSdkApiErr(err, "AccessDenied"); err != nil { return err } if !strings.Contains(err.Error(), actS3BypassGovernance) { return fmt.Errorf("expected the denial to name %s, got: %v", actS3BypassGovernance, err) } return nil }(); err != nil { return fmt.Errorf("identity=%s resource=%s: %w", tc.identity, tc.resource, err) } } // Release the keys still under retention: the cases that expected a // denial left theirs locked, and teardown cannot remove those. var locked []objToDelete for i, tc := range cases { if tc.wantDenied { locked = append(locked, objToDelete{key: fmt.Sprintf("locked-%d", i)}) } } return cleanupLockedObjects(s.GetClient(), bucket, locked) }, withLock()) } // S3IAMAccessControl_governance_without_bypass_header verifies the bypass // permission is irrelevant when the request doesn't ask to bypass: the // object stays protected, and the error is the generic object-lock one. func S3IAMAccessControl_governance_without_bypass_header(s *S3Conf) error { testName := "S3IAMAccessControl_governance_without_bypass_header" return s3IAMActionHandler(s, testName, func(root *iam.Client, bucket string) error { const key = "locked" if err := putGovernanceLockedObject(s, bucket, key); err != nil { return err } user, cleanup, err := newS3IAMUser(root, s, map[string]string{ "p": policyDoc(accessStatement{ Effect: "Allow", Action: []string{actS3DeleteObject, actS3BypassGovernance}, Resource: objectsArn(bucket), }), }) if err != nil { return err } defer cleanup() ctx, cancel := context.WithTimeout(context.Background(), shortTimeout) _, err = user.client.DeleteObject(ctx, &s3.DeleteObjectInput{Bucket: &bucket, Key: aws.String(key)}) cancel() if err := checkApiErr(err, s3err.GetAPIError(s3err.ErrObjectLocked)); err != nil { return err } return cleanupLockedObjects(s.GetClient(), bucket, []objToDelete{{key: key}}) }, withLock()) } // S3IAMAccessControl_compliance_mode_not_bypassable verifies COMPLIANCE // retention is absolute: no identity or bucket policy can grant a bypass of // it, unlike GOVERNANCE. func S3IAMAccessControl_compliance_mode_not_bypassable(s *S3Conf) error { testName := "S3IAMAccessControl_compliance_mode_not_bypassable" return s3IAMComplianceActionHandler(s, testName, func(root *iam.Client, bucket string) error { const key = "compliance-locked" retainUntil := time.Now().UTC().Add(time.Hour) if _, err := putObjectWithData(0, &s3.PutObjectInput{ Bucket: &bucket, Key: aws.String(key), ObjectLockMode: types.ObjectLockModeCompliance, ObjectLockRetainUntilDate: &retainUntil, }, s.GetClient()); err != nil { return err } user, cleanup, err := newS3IAMUser(root, s, map[string]string{ "p": policyDoc(accessStatement{ Effect: "Allow", Action: []string{actS3DeleteObject, actS3BypassGovernance}, Resource: objectsArn(bucket), }), }) if err != nil { return err } defer cleanup() ctx, cancel := context.WithTimeout(context.Background(), shortTimeout) _, err = user.client.DeleteObject(ctx, &s3.DeleteObjectInput{ Bucket: &bucket, Key: aws.String(key), BypassGovernanceRetention: aws.Bool(true), }) cancel() return checkApiErr(err, s3err.GetAPIError(s3err.ErrObjectLocked)) }) } // S3IAMAccessControl_delete_objects_authorizes_each_key verifies the batch // DeleteObjects path authorizes s3:DeleteObject against each object's own // ARN, the way real AWS does — a policy naming only "bucket/*" is // sufficient — and that it supports partial success: a key outside the granted // prefix denies only that key, reported in the response's Errors list, while // every other key in the same batch is still deleted and reported in // Deleted. Both lists preserve the order the keys were requested in. func S3IAMAccessControl_delete_objects_authorizes_each_key(s *S3Conf) error { testName := "S3IAMAccessControl_delete_objects_authorizes_each_key" return s3IAMActionHandler(s, testName, func(root *iam.Client, bucket string) error { for _, key := range []string{"allowed/one", "allowed/two", "denied/three"} { ctx, cancel := context.WithTimeout(context.Background(), shortTimeout) _, err := s.GetClient().PutObject(ctx, &s3.PutObjectInput{Bucket: &bucket, Key: getPtr(key)}) cancel() if err != nil { return err } } user, cleanup, err := newS3IAMUser(root, s, map[string]string{ "p": policyDoc(accessStatement{ Effect: "Allow", Action: actS3DeleteObject, Resource: objectArn(bucket, "allowed/*"), }), }) if err != nil { return err } defer cleanup() // A key outside the grant, mixed in with two that aren't, denies // only that key — the request as a whole succeeds. out, err := deleteObjectsWithBypass(user.client, bucket, "allowed/one", "denied/three", "allowed/two") if err != nil { return fmt.Errorf("expected DeleteObjects to succeed with a per-object denial, not fail outright: %w", err) } wantErr := wantImplicitDeny(user.arn, actS3DeleteObject, objectArn(bucket, "denied/three")) if len(out.Errors) != 1 { return fmt.Errorf("expected exactly 1 per-object error, got %+v", out.Errors) } if err := checkDeleteObjectsErr(out.Errors[0], "denied/three", wantErr); err != nil { return err } wantDeleted := []string{"allowed/one", "allowed/two"} if err := checkDeletedKeysInOrder(out.Deleted, wantDeleted); err != nil { return err } // Every key inside the grant succeeds, with no bucket-ARN grant // anywhere, and no per-object errors. out, err = deleteObjectsWithBypass(user.client, bucket, "allowed/one") if err != nil { return fmt.Errorf("expected DeleteObjects to be allowed by an object-ARN-only grant: %w", err) } if len(out.Errors) != 0 { return fmt.Errorf("expected no per-object errors, got %+v", out.Errors) } return nil }) } // S3IAMAccessControl_delete_objects_version_needs_separate_permission // verifies that naming a VersionId in a DeleteObjects entry is authorized // against s3:DeleteObjectVersion, a distinct permission from the // s3:DeleteObject a keyed (unversioned) delete needs - a policy granting only // s3:DeleteObject denies the versioned deletes in a batch while its keyed // deletes in the same batch still succeed, each independently, matching the // single-object DELETE path's existing behavior for the same distinction. func S3IAMAccessControl_delete_objects_version_needs_separate_permission(s *S3Conf) error { testName := "S3IAMAccessControl_delete_objects_version_needs_separate_permission" return s3IAMActionHandler(s, testName, func(root *iam.Client, bucket string) error { ctx, cancel := context.WithTimeout(context.Background(), shortTimeout) _, err := s.GetClient().PutObject(ctx, &s3.PutObjectInput{Bucket: &bucket, Key: getPtr("obj")}) cancel() if err != nil { return err } user, cleanup, err := newS3IAMUser(root, s, map[string]string{ "p": policyDoc(accessStatement{ Effect: "Allow", Action: actS3DeleteObject, Resource: objectsArn(bucket), }), }) if err != nil { return err } defer cleanup() ctx, cancel = context.WithTimeout(context.Background(), shortTimeout) defer cancel() out, err := user.client.DeleteObjects(ctx, &s3.DeleteObjectsInput{ Bucket: &bucket, Delete: &types.Delete{ Objects: []types.ObjectIdentifier{ {Key: aws.String("obj")}, {Key: aws.String("versioned-obj"), VersionId: aws.String("some-version-id")}, }, }, }) if err != nil { return fmt.Errorf("expected DeleteObjects to succeed with a per-object denial, not fail outright: %w", err) } wantErr := wantImplicitDeny(user.arn, actS3DeleteObjectVersion, objectArn(bucket, "versioned-obj")) if len(out.Errors) != 1 { return fmt.Errorf("expected exactly 1 per-object error, got %+v", out.Errors) } if err := checkDeleteObjectsErr(out.Errors[0], "versioned-obj", wantErr); err != nil { return err } if len(out.Deleted) != 1 || out.Deleted[0].Key == nil || *out.Deleted[0].Key != "obj" { return fmt.Errorf("expected the keyed delete to succeed, got %+v", out.Deleted) } return nil }) } // S3IAMAccessControl_delete_objects_bucket_policy_deny_per_key verifies a // bucket-policy Deny settles only the key it names. DeleteObjects is a // partial-success API, so every key is authorized on its own: a Deny on one // key is reported against that key and leaves every other key to be // evaluated on its own merits — an explicitly denied key never lets the // keys after it through unauthorized. func S3IAMAccessControl_delete_objects_bucket_policy_deny_per_key(s *S3Conf) error { testName := "S3IAMAccessControl_delete_objects_bucket_policy_deny_per_key" return s3IAMActionHandler(s, testName, func(root *iam.Client, bucket string) error { user, cleanup, err := newS3IAMUser(root, s, map[string]string{ "p": policyDoc(accessStatement{ Effect: "Allow", Action: actS3DeleteObject, Resource: objectArn(bucket, "allowed/*"), }), }) if err != nil { return err } defer cleanup() // Two Deny statements, so a batch can carry two explicitly denied // keys — the second one proves the first didn't end the evaluation. if err := putBucketPolicyDoc(s, bucket, bucketStatement{ Effect: "Deny", Principal: user.arn, Action: actS3DeleteObject, Resource: objectArn(bucket, "protected/*"), }, bucketStatement{ Effect: "Deny", Principal: user.arn, Action: actS3DeleteObject, Resource: objectArn(bucket, "vault/*"), }, ); err != nil { return err } // The three denial shapes this group's keys produce: "protected/" // and "vault/" are explicitly denied by the bucket policy, "secret/" // is denied for want of any grant, and "allowed/" is the only prefix // the identity policy permits. resourceDeny := func(key string) keyDenial { return keyDenial{key, wantExplicitResourceDeny(user.arn, actS3DeleteObject, objectArn(bucket, key))} } implicitDeny := func(key string) keyDenial { return keyDenial{key, wantImplicitDeny(user.arn, actS3DeleteObject, objectArn(bucket, key))} } for _, tc := range []struct { name string keys []string wantErrs []keyDenial wantDeleted []string }{ { name: "a bucket-policy deny and an unauthorized key", keys: []string{"protected/x", "secret/z"}, wantErrs: []keyDenial{resourceDeny("protected/x"), implicitDeny("secret/z")}, }, { name: "a bucket-policy deny and an authorized key", keys: []string{"protected/x", "allowed/w"}, wantErrs: []keyDenial{resourceDeny("protected/x")}, wantDeleted: []string{"allowed/w"}, }, { name: "two bucket-policy denies", keys: []string{"protected/x", "vault/y"}, wantErrs: []keyDenial{resourceDeny("protected/x"), resourceDeny("vault/y")}, }, // The same batches with the keys reversed: the outcome depends // on each key, never on where in the batch a denial first // appeared. { name: "an unauthorized key before a bucket-policy deny", keys: []string{"secret/z", "protected/x"}, wantErrs: []keyDenial{implicitDeny("secret/z"), resourceDeny("protected/x")}, }, { name: "an authorized key before a bucket-policy deny", keys: []string{"allowed/w", "protected/x"}, wantErrs: []keyDenial{resourceDeny("protected/x")}, wantDeleted: []string{"allowed/w"}, }, { name: "two bucket-policy denies, reversed", keys: []string{"vault/y", "protected/x"}, wantErrs: []keyDenial{resourceDeny("vault/y"), resourceDeny("protected/x")}, }, } { if err := func() error { // Root rewrites every key before each case: a case that // deletes one must not change what the next one sees. if _, err := putObjects(s.GetClient(), []string{"protected/x", "vault/y", "secret/z", "allowed/w"}, bucket); err != nil { return err } out, err := deleteObjectsBatch(user.client, bucket, tc.keys...) if err != nil { return fmt.Errorf("expected DeleteObjects to succeed with per-object denials, not fail outright: %w", err) } if err := checkDeletedKeysInOrder(out.Deleted, tc.wantDeleted); err != nil { return err } return checkDeleteObjectsErrsInOrder(out.Errors, tc.wantErrs) }(); err != nil { return fmt.Errorf("%s: %w", tc.name, err) } } return nil }) } // S3IAMAccessControl_delete_objects_deny_across_versioned_split verifies a // denial in one half of a batch doesn't authorize the other half. A batch is // split by action — keyed entries are authorized against s3:DeleteObject, // entries naming a VersionId against s3:DeleteObjectVersion — and each half // is evaluated separately, so a denial has to settle its own key in its own // half and nothing else: not the keys after it, and not the keys in the // other half. func S3IAMAccessControl_delete_objects_deny_across_versioned_split(s *S3Conf) error { testName := "S3IAMAccessControl_delete_objects_deny_across_versioned_split" return s3IAMActionHandler(s, testName, func(root *iam.Client, bucket string) error { if _, err := putObjects(s.GetClient(), []string{"protected/x", "secret/z", "allowed/w"}, bucket); err != nil { return err } user, cleanup, err := newS3IAMUser(root, s, map[string]string{ "p": policyDoc(accessStatement{ Effect: "Allow", Action: actS3DeleteObject, Resource: objectArn(bucket, "allowed/*"), }), }) if err != nil { return err } defer cleanup() if err := putBucketPolicyDoc(s, bucket, bucketStatement{ Effect: "Deny", Principal: user.arn, Action: []string{actS3DeleteObject, actS3DeleteObjectVersion}, Resource: objectArn(bucket, "protected/*"), }); err != nil { return err } // Both halves interleaved, each led by its explicitly denied key, so // that a leak in either half shows up as a key deleted or missing // from Errors. ctx, cancel := context.WithTimeout(context.Background(), shortTimeout) out, err := user.client.DeleteObjects(ctx, &s3.DeleteObjectsInput{ Bucket: &bucket, Delete: &types.Delete{Objects: []types.ObjectIdentifier{ {Key: aws.String("protected/x")}, {Key: aws.String("protected/v"), VersionId: aws.String("some-version-id")}, {Key: aws.String("secret/z")}, {Key: aws.String("other/v"), VersionId: aws.String("some-version-id")}, {Key: aws.String("allowed/w")}, }}, }) cancel() if err != nil { return fmt.Errorf("expected DeleteObjects to succeed with per-object denials, not fail outright: %w", err) } if err := checkDeletedKeysInOrder(out.Deleted, []string{"allowed/w"}); err != nil { return err } return checkDeleteObjectsErrsInOrder(out.Errors, []keyDenial{ {"protected/x", wantExplicitResourceDeny(user.arn, actS3DeleteObject, objectArn(bucket, "protected/x"))}, {"protected/v", wantExplicitResourceDeny(user.arn, actS3DeleteObjectVersion, objectArn(bucket, "protected/v"))}, {"secret/z", wantImplicitDeny(user.arn, actS3DeleteObject, objectArn(bucket, "secret/z"))}, {"other/v", wantImplicitDeny(user.arn, actS3DeleteObjectVersion, objectArn(bucket, "other/v"))}, }) }) } // S3IAMAccessControl_governance_bypass_delete_objects verifies the batch // DeleteObjects path enforces the bypass permission per object, the same way // the single-object delete does. func S3IAMAccessControl_governance_bypass_delete_objects(s *S3Conf) error { testName := "S3IAMAccessControl_governance_bypass_delete_objects" return s3IAMActionHandler(s, testName, func(root *iam.Client, bucket string) error { const key = "locked" if err := putGovernanceLockedObject(s, bucket, key); err != nil { return err } withoutBypass, cleanupWithout, err := newS3IAMUser(root, s, map[string]string{ "p": policyDoc(accessStatement{Effect: "Allow", Action: actS3DeleteObject, Resource: objectsArn(bucket)}), }) if err != nil { return err } defer cleanupWithout() out, err := deleteObjectsWithBypass(withoutBypass.client, bucket, key) if err != nil { return fmt.Errorf("expected DeleteObjects to succeed with a per-object denial, not fail outright: %w", err) } if err := checkDeleteObjectsErr(out.Errors[0], key, wantImplicitDeny(withoutBypass.arn, actS3BypassGovernance, objectArn(bucket, key))); err != nil { return fmt.Errorf("expected DeleteObjects to be denied without the bypass permission: %w", err) } withBypass, cleanupWith, err := newS3IAMUser(root, s, map[string]string{ "p": policyDoc(accessStatement{ Effect: "Allow", Action: []string{actS3DeleteObject, actS3BypassGovernance}, Resource: objectsArn(bucket), }), }) if err != nil { return err } defer cleanupWith() out, err = deleteObjectsWithBypass(withBypass.client, bucket, key) if err != nil { return fmt.Errorf("expected DeleteObjects to be allowed with the bypass permission: %w", err) } if len(out.Errors) != 0 { return fmt.Errorf("expected no per-object errors, got %+v", out.Errors) } return nil }, withLock()) } // S3IAMAccessControl_retention_extension_needs_no_bypass verifies the // direction of the change is what decides whether a bypass is needed: // pushing a retention date further out only strengthens the lock, so it // needs nothing beyond s3:PutObjectRetention — in either mode. Shortening // is the case that needs a bypass, covered by the test below. func S3IAMAccessControl_retention_extension_needs_no_bypass(s *S3Conf) error { testName := "S3IAMAccessControl_retention_extension_needs_no_bypass" return s3IAMComplianceActionHandler(s, testName, func(root *iam.Client, bucket string) error { user, cleanup, err := newS3IAMUser(root, s, map[string]string{ "p": policyDoc(accessStatement{ Effect: "Allow", Action: "s3:PutObjectRetention", Resource: objectsArn(bucket), }), }) if err != nil { return err } defer cleanup() modes := []types.ObjectLockRetentionMode{ types.ObjectLockRetentionModeGovernance, types.ObjectLockRetentionModeCompliance, } for _, mode := range modes { key := "extend-" + strings.ToLower(string(mode)) retainUntil := time.Now().UTC().Add(time.Minute) if _, err := putObjectWithData(0, &s3.PutObjectInput{ Bucket: &bucket, Key: &key, ObjectLockMode: types.ObjectLockMode(mode), ObjectLockRetainUntilDate: &retainUntil, }, s.GetClient()); err != nil { return err } extended := retainUntil.Add(time.Minute) ctx, cancel := context.WithTimeout(context.Background(), shortTimeout) _, err := user.client.PutObjectRetention(ctx, &s3.PutObjectRetentionInput{ Bucket: &bucket, Key: &key, Retention: &types.ObjectLockRetention{Mode: mode, RetainUntilDate: &extended}, }) cancel() if err != nil { return fmt.Errorf("%s: expected extending a retention to need no bypass: %w", mode, err) } } return nil }) } // S3IAMAccessControl_governance_bypass_put_object_retention verifies the // bypass permission gates weakening a GOVERNANCE retention through // PutObjectRetention — here by switching its mode to COMPLIANCE, which the // gateway only permits with the bypass header. func S3IAMAccessControl_governance_bypass_put_object_retention(s *S3Conf) error { testName := "S3IAMAccessControl_governance_bypass_put_object_retention" return s3IAMComplianceActionHandler(s, testName, func(root *iam.Client, bucket string) error { retainUntil := time.Now().UTC().Add(time.Hour) toCompliance := func(client *s3.Client, key string) error { ctx, cancel := context.WithTimeout(context.Background(), shortTimeout) defer cancel() _, err := client.PutObjectRetention(ctx, &s3.PutObjectRetentionInput{ Bucket: &bucket, Key: aws.String(key), BypassGovernanceRetention: aws.Bool(true), Retention: &types.ObjectLockRetention{ Mode: types.ObjectLockRetentionModeCompliance, RetainUntilDate: &retainUntil, }, }) return err } withoutBypass, cleanupWithout, err := newS3IAMUser(root, s, map[string]string{ "p": policyDoc(accessStatement{Effect: "Allow", Action: "s3:PutObjectRetention", Resource: objectsArn(bucket)}), }) if err != nil { return err } defer cleanupWithout() if err := putGovernanceLockedObject(s, bucket, "no-bypass"); err != nil { return err } if err := toCompliance(withoutBypass.client, "no-bypass"); err == nil { return fmt.Errorf("expected the retention mode change to be denied without the bypass permission") } withBypass, cleanupWith, err := newS3IAMUser(root, s, map[string]string{ "p": policyDoc(accessStatement{ Effect: "Allow", Action: []string{"s3:PutObjectRetention", actS3BypassGovernance}, Resource: objectsArn(bucket), }), }) if err != nil { return err } defer cleanupWith() if err := putGovernanceLockedObject(s, bucket, "with-bypass"); err != nil { return err } if err := toCompliance(withBypass.client, "with-bypass"); err != nil { return fmt.Errorf("expected the retention mode change to be allowed with the bypass permission: %w", err) } return nil }) } // S3IAMAccessControl_retention_shortening_needs_bypass verifies that moving // a retention date earlier — weakening the lock without changing its mode — // needs both the bypass header and s3:BypassGovernanceRetention for // GOVERNANCE, and is refused outright for COMPLIANCE however the caller // asks. // // Extending is the control: it only ever strengthens the lock, so it needs // neither, in either mode. func S3IAMAccessControl_retention_shortening_needs_bypass(s *S3Conf) error { testName := "S3IAMAccessControl_retention_shortening_needs_bypass" return s3IAMComplianceActionHandler(s, testName, func(root *iam.Client, bucket string) error { withBypass, cleanupWith, err := newS3IAMUser(root, s, map[string]string{ "p": policyDoc(accessStatement{ Effect: "Allow", Action: []string{"s3:PutObjectRetention", actS3BypassGovernance}, Resource: objectsArn(bucket), }), }) if err != nil { return err } defer cleanupWith() withoutBypass, cleanupWithout, err := newS3IAMUser(root, s, map[string]string{ "p": policyDoc(accessStatement{ Effect: "Allow", Action: "s3:PutObjectRetention", Resource: objectsArn(bucket), }), }) if err != nil { return err } defer cleanupWithout() cases := []struct { name string mode types.ObjectLockRetentionMode user *s3IAMPrincipal shorten bool sendHeader bool // wantErr is nil when the change must be allowed. wantErr func(user *s3IAMPrincipal, key string) s3err.S3Error }{ { name: "governance extended needs nothing", mode: types.ObjectLockRetentionModeGovernance, user: withoutBypass, }, { name: "compliance extended needs nothing", mode: types.ObjectLockRetentionModeCompliance, user: withoutBypass, }, { // No header at all: the object is simply reported as locked, // with no mention of a permission the caller never invoked. name: "governance shortened without the bypass header", mode: types.ObjectLockRetentionModeGovernance, user: withBypass, shorten: true, wantErr: func(*s3IAMPrincipal, string) s3err.S3Error { return s3err.GetAPIError(s3err.ErrObjectLocked) }, }, { // Header sent but the permission missing: the denial names // the permission that was needed. name: "governance shortened without the bypass permission", mode: types.ObjectLockRetentionModeGovernance, user: withoutBypass, shorten: true, sendHeader: true, wantErr: func(u *s3IAMPrincipal, key string) s3err.S3Error { return wantImplicitDeny(u.arn, actS3BypassGovernance, objectArn(bucket, key)) }, }, { name: "governance shortened with header and permission", mode: types.ObjectLockRetentionModeGovernance, user: withBypass, shorten: true, sendHeader: true, }, { // COMPLIANCE is absolute: neither the header nor the // permission can weaken it. name: "compliance shortened even with header and permission", mode: types.ObjectLockRetentionModeCompliance, user: withBypass, shorten: true, sendHeader: true, wantErr: func(*s3IAMPrincipal, string) s3err.S3Error { return s3err.GetAPIError(s3err.ErrObjectLocked) }, }, } for i, tc := range cases { if err := func() error { key := fmt.Sprintf("retained-%d", i) original := time.Now().UTC().Add(time.Hour) if _, err := putObjectWithData(0, &s3.PutObjectInput{ Bucket: &bucket, Key: &key, ObjectLockMode: types.ObjectLockMode(tc.mode), ObjectLockRetainUntilDate: &original, }, s.GetClient()); err != nil { return err } want := original.Add(time.Minute) if tc.shorten { want = original.Add(-30 * time.Second) } input := &s3.PutObjectRetentionInput{ Bucket: &bucket, Key: &key, Retention: &types.ObjectLockRetention{ Mode: tc.mode, RetainUntilDate: &want, }, } if tc.sendHeader { input.BypassGovernanceRetention = aws.Bool(true) } ctx, cancel := context.WithTimeout(context.Background(), shortTimeout) _, err := tc.user.client.PutObjectRetention(ctx, input) cancel() if tc.wantErr == nil { if err != nil { return fmt.Errorf("expected the retention change to be allowed: %w", err) } return nil } return checkApiErr(err, tc.wantErr(tc.user, key)) }(); err != nil { return fmt.Errorf("%s: %w", tc.name, err) } } return nil }) } // S3IAMAccessControl_condition_source_ip verifies aws:SourceIp is populated // from the real request, both as a grant that matches and as one that // doesn't. func S3IAMAccessControl_condition_source_ip(s *S3Conf) error { testName := "S3IAMAccessControl_condition_source_ip" return s3IAMActionHandler(s, testName, func(root *iam.Client, bucket string) error { ctx, cancel := context.WithTimeout(context.Background(), shortTimeout) _, err := s.GetClient().PutObject(ctx, &s3.PutObjectInput{Bucket: &bucket, Key: getPtr("obj")}) cancel() if err != nil { return err } callerIP, err := callerSourceIP(s) if err != nil { return err } return runS3ConditionCases(root, s, bucket, "obj", []s3ConditionCase{ { name: "matching source ip", condition: cond("IpAddress", "aws:SourceIp", callerIP+"/32"), wantAllowed: true, }, { name: "non-matching source ip", condition: cond("IpAddress", "aws:SourceIp", "203.0.113.0/24"), }, { name: "negated operator with a matching key", condition: cond("NotIpAddress", "aws:SourceIp", "203.0.113.0/24"), wantAllowed: true, }, }) }) } // S3IAMAccessControl_condition_negated_operator_needs_context is a // regression test for a fail-open bug: the gateway used to send no condition // context at all for S3 requests, and iamapi/policy treats a negated // operator over an absent key as vacuously true — so a Deny guarded by // NotIpAddress silently never fired, and an Allow guarded by one fired for // everybody. With the context populated, a NotIpAddress Deny naming the // caller's own address must actually deny. func S3IAMAccessControl_condition_negated_operator_needs_context(s *S3Conf) error { testName := "S3IAMAccessControl_condition_negated_operator_needs_context" return s3IAMActionHandler(s, testName, func(root *iam.Client, bucket string) error { ctx, cancel := context.WithTimeout(context.Background(), shortTimeout) _, err := s.GetClient().PutObject(ctx, &s3.PutObjectInput{Bucket: &bucket, Key: getPtr("obj")}) cancel() if err != nil { return err } callerIP, err := callerSourceIP(s) if err != nil { return err } user, cleanup, err := newS3IAMUser(root, s, map[string]string{ "p": policyDoc( accessStatement{Effect: "Allow", Action: actS3GetObject, Resource: objectsArn(bucket)}, accessStatement{ Effect: "Deny", Action: actS3GetObject, Resource: objectsArn(bucket), Condition: cond("NotIpAddress", "aws:SourceIp", "203.0.113.0/24"), }, ), }) if err != nil { return err } defer cleanup() ctx, cancel = context.WithTimeout(context.Background(), shortTimeout) _, err = user.client.GetObject(ctx, &s3.GetObjectInput{Bucket: &bucket, Key: getPtr("obj")}) cancel() if err := checkApiErr(err, wantExplicitIdentityDeny(user.arn, actS3GetObject, objectArn(bucket, "obj"))); err != nil { return fmt.Errorf("a NotIpAddress Deny must fire when the caller's address (%s) is outside the named range: %w", callerIP, err) } return nil }) } // S3IAMAccessControl_condition_request_keys covers the remaining condition // keys the gateway derives from the request itself. func S3IAMAccessControl_condition_request_keys(s *S3Conf) error { testName := "S3IAMAccessControl_condition_request_keys" return s3IAMActionHandler(s, testName, func(root *iam.Client, bucket string) error { ctx, cancel := context.WithTimeout(context.Background(), shortTimeout) _, err := s.GetClient().PutObject(ctx, &s3.PutObjectInput{Bucket: &bucket, Key: getPtr("obj")}) cancel() if err != nil { return err } // The integration harness always drives the gateway over plain // HTTP or TLS, never both in one run, so aws:SecureTransport is // asserted against whichever this run actually uses rather than // hardcoded. secure := strings.HasPrefix(s.endpoint, "https") return runS3ConditionCases(root, s, bucket, "obj", []s3ConditionCase{ { name: "secure transport matches the endpoint scheme", condition: cond("Bool", "aws:SecureTransport", fmt.Sprintf("%t", secure)), wantAllowed: true, }, { name: "secure transport mismatch", condition: cond("Bool", "aws:SecureTransport", fmt.Sprintf("%t", !secure)), }, { name: "current time inside a broad window", condition: cond("DateLessThan", "aws:CurrentTime", "2999-01-01T00:00:00Z"), wantAllowed: true, }, { name: "current time outside the window", condition: cond("DateLessThan", "aws:CurrentTime", "2000-01-01T00:00:00Z"), }, { name: "epoch time inside a broad window", condition: cond("NumericGreaterThan", "aws:EpochTime", "1000000000"), wantAllowed: true, }, { name: "user agent is present", condition: cond("Null", "aws:UserAgent", "false"), wantAllowed: true, }, }) }) } // S3IAMAccessControl_condition_identity_keys verifies the identity-derived // condition keys — which the IAM service fills in, since the gateway never // learns who an access key belongs to — reach policy evaluation. func S3IAMAccessControl_condition_identity_keys(s *S3Conf) error { testName := "S3IAMAccessControl_condition_identity_keys" return s3IAMActionHandler(s, testName, func(root *iam.Client, bucket string) error { ctx, cancel := context.WithTimeout(context.Background(), shortTimeout) _, err := s.GetClient().PutObject(ctx, &s3.PutObjectInput{Bucket: &bucket, Key: getPtr("obj")}) cancel() if err != nil { return err } user, cleanup, err := newS3IAMUser(root, s, nil) if err != nil { return err } defer cleanup() cases := []struct { name string condition func() []byte wantAllowed bool }{ { name: "principal arn matches", condition: func() []byte { return cond("StringEquals", "aws:PrincipalArn", user.arn) }, wantAllowed: true, }, { name: "principal arn mismatch", condition: func() []byte { return cond("StringEquals", "aws:PrincipalArn", "arn:aws:iam::000000000000:user/somebodyelse") }, }, { name: "username matches", condition: func() []byte { return cond("StringEquals", "aws:username", user.name) }, wantAllowed: true, }, { name: "principal type is User", condition: func() []byte { return cond("StringEquals", "aws:PrincipalType", "User") }, wantAllowed: true, }, { name: "principal account matches", condition: func() []byte { return cond("StringEquals", "aws:PrincipalAccount", testAccountID) }, wantAllowed: true, }, } for _, tc := range cases { if err := func() error { if err := putS3IAMUserPolicy(root, user, "p", policyDoc(accessStatement{ Effect: "Allow", Action: actS3GetObject, Resource: objectsArn(bucket), Condition: tc.condition(), })); err != nil { return err } ctx, cancel = context.WithTimeout(context.Background(), shortTimeout) _, err = user.client.GetObject(ctx, &s3.GetObjectInput{Bucket: &bucket, Key: getPtr("obj")}) cancel() if tc.wantAllowed { if err != nil { return fmt.Errorf("expected the request to be allowed: %w", err) } return nil } return checkApiErr(err, wantImplicitDeny(user.arn, actS3GetObject, objectArn(bucket, "obj"))) }(); err != nil { return fmt.Errorf("%s: %w", tc.name, err) } } return nil }) } // S3IAMAccessControl_condition_principal_tag verifies aws:PrincipalTag/ // is populated from the calling user's own IAM tags, and that a tag the user // doesn't carry is treated as absent rather than as an empty match. func S3IAMAccessControl_condition_principal_tag(s *S3Conf) error { testName := "S3IAMAccessControl_condition_principal_tag" return s3IAMActionHandler(s, testName, func(root *iam.Client, bucket string) error { ctx, cancel := context.WithTimeout(context.Background(), shortTimeout) _, err := s.GetClient().PutObject(ctx, &s3.PutObjectInput{Bucket: &bucket, Key: getPtr("obj")}) cancel() if err != nil { return err } userName := newIAMUserName() createOut, err := createIAMUser(root, &iam.CreateUserInput{ UserName: aws.String(userName), Tags: []iamtypes.Tag{{Key: aws.String("team"), Value: aws.String("storage")}}, }) if err != nil { return err } defer deleteS3IAMUser(root, userName) keyOut, err := createIAMAccessKey(root, &iam.CreateAccessKeyInput{UserName: aws.String(userName)}) if err != nil { return err } conf := *s conf.awsID = aws.ToString(keyOut.AccessKey.AccessKeyId) conf.awsSecret = aws.ToString(keyOut.AccessKey.SecretAccessKey) user := &s3IAMPrincipal{name: userName, arn: aws.ToString(createOut.User.Arn), conf: conf, client: conf.GetClient()} cases := []struct { name string condition []byte wantAllowed bool }{ {name: "matching tag value", condition: cond("StringEquals", "aws:PrincipalTag/team", "storage"), wantAllowed: true}, {name: "wrong tag value", condition: cond("StringEquals", "aws:PrincipalTag/team", "networking")}, {name: "tag the user does not carry", condition: cond("StringEquals", "aws:PrincipalTag/other", "anything")}, {name: "absent tag reported by Null", condition: cond("Null", "aws:PrincipalTag/other", "true"), wantAllowed: true}, } for _, tc := range cases { if err := func() error { if err := putS3IAMUserPolicy(root, user, "p", policyDoc(accessStatement{ Effect: "Allow", Action: actS3GetObject, Resource: objectsArn(bucket), Condition: tc.condition, })); err != nil { return err } ctx, cancel = context.WithTimeout(context.Background(), shortTimeout) _, err = user.client.GetObject(ctx, &s3.GetObjectInput{Bucket: &bucket, Key: getPtr("obj")}) cancel() if tc.wantAllowed { if err != nil { return fmt.Errorf("expected the request to be allowed: %w", err) } return nil } return checkApiErr(err, wantImplicitDeny(user.arn, actS3GetObject, objectArn(bucket, "obj"))) }(); err != nil { return fmt.Errorf("%s: %w", tc.name, err) } } return nil }) } // S3IAMAccessControl_condition_on_deny_statement verifies a Condition // attached to a Deny narrows that Deny — when the condition doesn't hold, // the statement contributes nothing and an unconditional Allow still stands. func S3IAMAccessControl_condition_on_deny_statement(s *S3Conf) error { testName := "S3IAMAccessControl_condition_on_deny_statement" return s3IAMActionHandler(s, testName, func(root *iam.Client, bucket string) error { ctx, cancel := context.WithTimeout(context.Background(), shortTimeout) _, err := s.GetClient().PutObject(ctx, &s3.PutObjectInput{Bucket: &bucket, Key: getPtr("obj")}) cancel() if err != nil { return err } callerIP, err := callerSourceIP(s) if err != nil { return err } user, cleanup, err := newS3IAMUser(root, s, nil) if err != nil { return err } defer cleanup() allow := accessStatement{Effect: "Allow", Action: actS3GetObject, Resource: objectsArn(bucket)} // Deny conditioned on an address the caller does not have: it must // not fire, leaving the Allow in force. if err := putS3IAMUserPolicy(root, user, "p", policyDoc(allow, accessStatement{ Effect: "Deny", Action: actS3GetObject, Resource: objectsArn(bucket), Condition: cond("IpAddress", "aws:SourceIp", "203.0.113.0/24"), })); err != nil { return err } ctx, cancel = context.WithTimeout(context.Background(), shortTimeout) _, err = user.client.GetObject(ctx, &s3.GetObjectInput{Bucket: &bucket, Key: getPtr("obj")}) cancel() if err != nil { return fmt.Errorf("a Deny whose condition does not hold must not block an unconditional Allow: %w", err) } // Deny conditioned on the caller's real address: it must fire and // override the Allow. if err := putS3IAMUserPolicy(root, user, "p", policyDoc(allow, accessStatement{ Effect: "Deny", Action: actS3GetObject, Resource: objectsArn(bucket), Condition: cond("IpAddress", "aws:SourceIp", callerIP+"/32"), })); err != nil { return err } ctx, cancel = context.WithTimeout(context.Background(), shortTimeout) _, err = user.client.GetObject(ctx, &s3.GetObjectInput{Bucket: &bucket, Key: getPtr("obj")}) cancel() return checkApiErr(err, wantExplicitIdentityDeny(user.arn, actS3GetObject, objectArn(bucket, "obj"))) }) } // S3IAMAccessControl_condition_multiple_keys_anded verifies multiple keys // within one Condition block must all hold, while multiple values for one // key are ORed. func S3IAMAccessControl_condition_multiple_keys_anded(s *S3Conf) error { testName := "S3IAMAccessControl_condition_multiple_keys_anded" return s3IAMActionHandler(s, testName, func(root *iam.Client, bucket string) error { ctx, cancel := context.WithTimeout(context.Background(), shortTimeout) _, err := s.GetClient().PutObject(ctx, &s3.PutObjectInput{Bucket: &bucket, Key: getPtr("obj")}) cancel() if err != nil { return err } callerIP, err := callerSourceIP(s) if err != nil { return err } user, cleanup, err := newS3IAMUser(root, s, nil) if err != nil { return err } defer cleanup() cases := []struct { name string condition []byte wantAllowed bool }{ { name: "both keys hold", condition: condAll(map[string]map[string]any{ "IpAddress": {"aws:SourceIp": callerIP + "/32"}, "StringEquals": {"aws:username": user.name}, }), wantAllowed: true, }, { name: "one key fails", condition: condAll(map[string]map[string]any{ "IpAddress": {"aws:SourceIp": callerIP + "/32"}, "StringEquals": {"aws:username": "somebodyelse"}, }), }, { name: "one of several values for a key matches", condition: cond("StringEquals", "aws:username", []string{"somebodyelse", user.name}), wantAllowed: true, }, } for _, tc := range cases { if err := func() error { if err := putS3IAMUserPolicy(root, user, "p", policyDoc(accessStatement{ Effect: "Allow", Action: actS3GetObject, Resource: objectsArn(bucket), Condition: tc.condition, })); err != nil { return err } ctx, cancel = context.WithTimeout(context.Background(), shortTimeout) _, err = user.client.GetObject(ctx, &s3.GetObjectInput{Bucket: &bucket, Key: getPtr("obj")}) cancel() if tc.wantAllowed { if err != nil { return fmt.Errorf("expected the request to be allowed: %w", err) } return nil } return checkApiErr(err, wantImplicitDeny(user.arn, actS3GetObject, objectArn(bucket, "obj"))) }(); err != nil { return fmt.Errorf("%s: %w", tc.name, err) } } return nil }) } // S3IAMAccessControl_inactive_and_deleted_credentials verifies the gateway // stops accepting an access key as soon as the IAM service stops vouching // for it — whether it was deactivated, deleted, or its user was removed. func S3IAMAccessControl_inactive_and_deleted_credentials(s *S3Conf) error { testName := "S3IAMAccessControl_inactive_and_deleted_credentials" return s3IAMActionHandler(s, testName, func(root *iam.Client, bucket string) error { grantAll := map[string]string{ "p": policyDoc(accessStatement{ Effect: "Allow", Action: "s3:*", Resource: []string{bucketArn(bucket), objectsArn(bucket)}, }), } cases := []struct { name string disable func(user *s3IAMPrincipal) error }{ { name: "deactivated access key", disable: func(user *s3IAMPrincipal) error { ctx, cancel := context.WithTimeout(context.Background(), shortTimeout) defer cancel() _, err := root.UpdateAccessKey(ctx, &iam.UpdateAccessKeyInput{ UserName: aws.String(user.name), AccessKeyId: aws.String(user.conf.awsID), Status: iamtypes.StatusTypeInactive, }) return err }, }, { name: "deleted access key", disable: func(user *s3IAMPrincipal) error { return deleteIAMAccessKey(root, user.name, user.conf.awsID) }, }, { name: "deleted user", disable: func(user *s3IAMPrincipal) error { return deleteS3IAMUser(root, user.name) }, }, } for _, tc := range cases { if err := func() error { user, cleanup, err := newS3IAMUser(root, s, grantAll) if err != nil { return err } defer cleanup() ctx, cancel := context.WithTimeout(context.Background(), shortTimeout) _, err = user.client.ListObjectsV2(ctx, &s3.ListObjectsV2Input{Bucket: &bucket}) cancel() if err != nil { return fmt.Errorf("expected the credential to work before being disabled: %w", err) } if err := tc.disable(user); err != nil { return err } ctx, cancel = context.WithTimeout(context.Background(), shortTimeout) _, err = user.client.ListObjectsV2(ctx, &s3.ListObjectsV2Input{Bucket: &bucket}) cancel() return checkApiErr(err, s3err.GetInvalidAccessKeyIdErr(user.conf.awsID)) }(); err != nil { return fmt.Errorf("%s: %w", tc.name, err) } } return nil }) } // S3IAMAccessControl_access_key_last_used_records_s3 covers last-used // tracking for the S3 data plane: an IAM user's S3 request is recorded // against the access key that signed it, with the "s3" service name and the // gateway's region — neither of which the IAM control plane can produce. func S3IAMAccessControl_access_key_last_used_records_s3(s *S3Conf) error { testName := "S3IAMAccessControl_access_key_last_used_records_s3" return s3IAMActionHandler(s, testName, func(root *iam.Client, bucket string) error { user, cleanup, err := newS3IAMUser(root, s, map[string]string{ "p": policyDoc(accessStatement{ Effect: "Allow", Action: actS3ListBucket, Resource: []string{bucketArn(bucket)}, }), }) if err != nil { return err } defer cleanup() accessKeyID := user.conf.awsID before, err := getIAMAccessKeyLastUsed(root, accessKeyID) if err != nil { return err } if before.AccessKeyLastUsed.LastUsedDate != nil { return fmt.Errorf("expected a freshly created access key to be unused, instead got %v", before.AccessKeyLastUsed.LastUsedDate) } start := time.Now().UTC().Add(-time.Second) ctx, cancel := context.WithTimeout(context.Background(), shortTimeout) _, err = user.client.ListObjectsV2(ctx, &s3.ListObjectsV2Input{Bucket: &bucket}) cancel() if err != nil { return fmt.Errorf("expected ListObjects to be allowed by the identity policy: %w", err) } after, err := getIAMAccessKeyLastUsed(root, accessKeyID) if err != nil { return err } lastUsed := after.AccessKeyLastUsed if lastUsed.LastUsedDate == nil { return fmt.Errorf("expected the s3 request to record an access key last used date") } if lastUsed.LastUsedDate.Before(start) { return fmt.Errorf("expected access key last used date to be at or after %v, instead got %v", start, *lastUsed.LastUsedDate) } if aws.ToString(lastUsed.ServiceName) != "s3" { return fmt.Errorf("expected access key last used service name to be %q, instead got %q", "s3", aws.ToString(lastUsed.ServiceName)) } if aws.ToString(lastUsed.Region) != s.awsRegion { return fmt.Errorf("expected access key last used region to be %q, instead got %q", s.awsRegion, aws.ToString(lastUsed.Region)) } return nil }) } // S3IAMAccessControl_condition_if_none_match_required is AWS's documented // "enforce conditional writes" pattern expressed as an identity policy: an // unconditional Allow paired with a Deny that fires whenever the key is // absent. func S3IAMAccessControl_condition_if_none_match_required(s *S3Conf) error { testName := "S3IAMAccessControl_condition_if_none_match_required" return s3IAMActionHandler(s, testName, func(root *iam.Client, bucket string) error { user, cleanup, err := newS3IAMUser(root, s, map[string]string{ "p": policyDoc( accessStatement{Effect: "Allow", Action: actS3PutObject, Resource: objectsArn(bucket)}, accessStatement{ Effect: "Deny", Action: actS3PutObject, Resource: objectsArn(bucket), Condition: cond("Null", "s3:if-none-match", "true"), }, ), }) if err != nil { return err } defer cleanup() ctx, cancel := context.WithTimeout(context.Background(), shortTimeout) _, err = user.client.PutObject(ctx, &s3.PutObjectInput{ Bucket: &bucket, Key: getPtr("unconditional"), Body: bytes.NewReader([]byte("data")), }) cancel() if err := checkApiErr(err, wantExplicitIdentityDeny(user.arn, actS3PutObject, objectArn(bucket, "unconditional"))); err != nil { return fmt.Errorf("an upload without If-None-Match must be denied: %w", err) } ctx, cancel = context.WithTimeout(context.Background(), shortTimeout) _, err = user.client.PutObject(ctx, &s3.PutObjectInput{ Bucket: &bucket, Key: getPtr("conditional"), Body: bytes.NewReader([]byte("data")), IfNoneMatch: getPtr("*"), }) cancel() if err != nil { return fmt.Errorf("an upload carrying If-None-Match must be allowed: %w", err) } // Still authorized once the key exists; only the precondition // itself fails now, which the policy has no say in. ctx, cancel = context.WithTimeout(context.Background(), shortTimeout) _, err = user.client.PutObject(ctx, &s3.PutObjectInput{ Bucket: &bucket, Key: getPtr("conditional"), Body: bytes.NewReader([]byte("data")), IfNoneMatch: getPtr("*"), }) cancel() return checkApiErr(err, s3err.GetAPIError(s3err.ErrPreconditionFailed)) }) } // S3IAMAccessControl_condition_conditional_write_values covers both keys' // values on an upload: s3:if-match carries the ETag with its surrounding // quotes stripped, and s3:if-none-match carries the only value S3 accepts // on a write, the literal "*". func S3IAMAccessControl_condition_conditional_write_values(s *S3Conf) error { testName := "S3IAMAccessControl_condition_conditional_write_values" return s3IAMActionHandler(s, testName, func(root *iam.Client, bucket string) error { etag, err := putObjectAndGetETag(s.GetClient(), bucket, "obj") if err != nil { return err } bare := strings.Trim(etag, `"`) user, cleanup, err := newS3IAMUser(root, s, nil) if err != nil { return err } defer cleanup() for i, tc := range []struct { name string condition json.RawMessage // ifMatch and ifNoneMatch are the headers the client sends; // S3 rejects a request carrying both. ifMatch *string ifNoneMatch *string wantAllow bool }{ { name: "quoted If-Match header matches a bare policy value", condition: cond("StringEquals", "s3:if-match", bare), ifMatch: &etag, wantAllow: true, }, { name: "unquoted If-Match header matches the same bare policy value", condition: cond("StringEquals", "s3:if-match", bare), ifMatch: &bare, wantAllow: true, }, { name: "a policy value carrying the quotes never matches", condition: cond("StringEquals", "s3:if-match", etag), ifMatch: &etag, }, { name: "a different ETag does not match", condition: cond("StringEquals", "s3:if-match", "0123456789abcdef0123456789abcdef"), ifMatch: &etag, }, { name: "s3:if-match is absent without the header", condition: cond("Null", "s3:if-match", "true"), wantAllow: true, }, { name: "an absent s3:if-match cannot satisfy StringEquals", condition: cond("StringEquals", "s3:if-match", bare), }, { // Key names are case-insensitive, values are not. name: "key name case is ignored", condition: cond("StringEquals", "S3:IF-MATCH", bare), ifMatch: &etag, wantAllow: true, }, { name: "s3:if-none-match carries the literal wildcard", condition: cond("StringEquals", "s3:if-none-match", "*"), ifNoneMatch: getPtr("*"), wantAllow: true, }, { // "*" is a literal under StringEquals, so it cannot stand // in for an arbitrary value. name: "s3:if-none-match is absent without the header", condition: cond("StringEquals", "s3:if-none-match", "*"), }, { name: "one key is absent while the other is present", condition: cond("Null", "s3:if-match", "true"), ifNoneMatch: getPtr("*"), wantAllow: true, }, } { // An If-None-Match write has to target a key that doesn't exist // yet, or it is authorized and then fails the precondition - // every other case conditions on "obj"'s own ETag. key := "obj" if tc.ifNoneMatch != nil { key = fmt.Sprintf("absent-%v", i) } if err := func() error { if err := putS3IAMUserPolicy(root, user, "p", policyDoc(accessStatement{ Effect: "Allow", Action: actS3PutObject, Resource: objectsArn(bucket), Condition: tc.condition, })); err != nil { return err } ctx, cancel := context.WithTimeout(context.Background(), shortTimeout) _, err := user.client.PutObject(ctx, &s3.PutObjectInput{ Bucket: &bucket, Key: &key, Body: bytes.NewReader([]byte("data")), IfMatch: tc.ifMatch, IfNoneMatch: tc.ifNoneMatch, }) cancel() if !tc.wantAllow { return checkApiErr(err, wantImplicitDeny(user.arn, actS3PutObject, objectArn(bucket, key))) } if err != nil { return fmt.Errorf("expected the request to be allowed: %w", err) } // An overwrite may have changed the ETag, so the remaining // cases have to condition on the current one. if etag, err = headObjectETag(s.GetClient(), bucket, "obj"); err != nil { return err } bare = strings.Trim(etag, `"`) return nil }(); err != nil { return fmt.Errorf("%s: %w", tc.name, err) } } return nil }) } // S3IAMAccessControl_condition_if_match_delete_object covers the half of // s3:if-match that isn't an upload: S3's conditional delete. func S3IAMAccessControl_condition_if_match_delete_object(s *S3Conf) error { testName := "S3IAMAccessControl_condition_if_match_delete_object" return s3IAMActionHandler(s, testName, func(root *iam.Client, bucket string) error { etag, err := putObjectAndGetETag(s.GetClient(), bucket, "obj") if err != nil { return err } user, cleanup, err := newS3IAMUser(root, s, map[string]string{ "p": policyDoc(accessStatement{ Effect: "Allow", Action: actS3DeleteObject, Resource: objectsArn(bucket), Condition: cond("StringEquals", "s3:if-match", strings.Trim(etag, `"`)), }), }) if err != nil { return err } defer cleanup() ctx, cancel := context.WithTimeout(context.Background(), shortTimeout) _, err = user.client.DeleteObject(ctx, &s3.DeleteObjectInput{Bucket: &bucket, Key: getPtr("obj")}) cancel() if err := checkApiErr(err, wantImplicitDeny(user.arn, actS3DeleteObject, objectArn(bucket, "obj"))); err != nil { return fmt.Errorf("an unconditional delete must be denied: %w", err) } ctx, cancel = context.WithTimeout(context.Background(), shortTimeout) _, err = user.client.DeleteObject(ctx, &s3.DeleteObjectInput{ Bucket: &bucket, Key: getPtr("obj"), IfMatch: &etag, }) cancel() if err != nil { return fmt.Errorf("a delete naming the object's ETag must be allowed: %w", err) } return nil }) } // S3IAMAccessControl_condition_if_match_versioned_delete pins the edge of // s3:if-match's action set on the identity-policy side, where nothing // validates a Condition's key against the action it names. A delete naming // a version is authorized as s3:DeleteObjectVersion, which the key doesn't // apply to, so it stays absent and a statement demanding it can never be // satisfied func S3IAMAccessControl_condition_if_match_versioned_delete(s *S3Conf) error { testName := "S3IAMAccessControl_condition_if_match_versioned_delete" return s3IAMActionHandler(s, testName, func(root *iam.Client, bucket string) error { etag, err := putObjectAndGetETag(s.GetClient(), bucket, "obj") if err != nil { return err } user, cleanup, err := newS3IAMUser(root, s, map[string]string{ "p": policyDoc(accessStatement{ Effect: "Allow", Action: actS3DeleteObjectVersion, Resource: objectsArn(bucket), Condition: cond("StringEquals", "s3:if-match", strings.Trim(etag, `"`)), }), }) if err != nil { return err } defer cleanup() // "null" is the version id every object carries until versioning is // enabled, so this is a versioned delete on any backend. deleteVersion := func(ifMatch string) error { ctx, cancel := context.WithTimeout(context.Background(), shortTimeout) defer cancel() _, err := user.client.DeleteObject(ctx, &s3.DeleteObjectInput{ Bucket: &bucket, Key: getPtr("obj"), VersionId: getPtr("null"), IfMatch: &ifMatch, }) return err } if err := checkApiErr(deleteVersion(etag), wantImplicitDeny(user.arn, actS3DeleteObjectVersion, objectArn(bucket, "obj"))); err != nil { return fmt.Errorf("a versioned delete must leave s3:if-match absent: %w", err) } // Absent, not merely different: the same request satisfies a // statement requiring the key to be absent. if err := putS3IAMUserPolicy(root, user, "p", policyDoc(accessStatement{ Effect: "Allow", Action: actS3DeleteObjectVersion, Resource: objectsArn(bucket), Condition: cond("Null", "s3:if-match", "true"), })); err != nil { return err } // Authorized now, and the header still decides the delete: a // mismatch fails the precondition the policy had no say in. if err := checkApiErr(deleteVersion("0123456789abcdef0123456789abcdef"), s3err.GetAPIError(s3err.ErrPreconditionFailed)); err != nil { return fmt.Errorf("the precondition itself must still be enforced: %w", err) } if err := deleteVersion(etag); err != nil { return fmt.Errorf("a versioned delete naming the object's ETag must be allowed: %w", err) } return nil }) } // S3IAMAccessControl_condition_if_match_bucket_level_write pins the last // shape a bare PUT can take. A bucket sub-resource write carries none of // the object sub-resources, so the request alone looks exactly like an // upload; only the action it is authorized under separates the two. The // gateway reads no If-Match there, so the key has to stay absent rather // than satisfy a statement demanding a conditional write. func S3IAMAccessControl_condition_if_match_bucket_level_write(s *S3Conf) error { testName := "S3IAMAccessControl_condition_if_match_bucket_level_write" return s3IAMActionHandler(s, testName, func(root *iam.Client, bucket string) error { etag, err := putObjectAndGetETag(s.GetClient(), bucket, "obj") if err != nil { return err } bareETag := strings.Trim(etag, `"`) user, cleanup, err := newS3IAMUser(root, s, map[string]string{ "p": policyDoc(accessStatement{ Effect: "Allow", Action: actS3PutBucketOwnershipControls, Resource: bucketArn(bucket), Condition: cond("StringEquals", "s3:if-match", bareETag), }), }) if err != nil { return err } defer cleanup() putOwnership := func() error { ctx, cancel := context.WithTimeout(context.Background(), shortTimeout) defer cancel() _, err := user.client.PutBucketOwnershipControls(ctx, &s3.PutBucketOwnershipControlsInput{ Bucket: &bucket, OwnershipControls: &types.OwnershipControls{ Rules: []types.OwnershipControlsRule{ {ObjectOwnership: types.ObjectOwnershipBucketOwnerPreferred}, }, }, }, withRequestHeader("If-Match", etag)) return err } if err := checkApiErr(putOwnership(), wantImplicitDeny(user.arn, actS3PutBucketOwnershipControls, bucketArn(bucket))); err != nil { return fmt.Errorf("a bucket-level write must leave s3:if-match absent: %w", err) } // Absent, not merely different: the same request satisfies a // statement requiring the key to be absent. if err := putS3IAMUserPolicy(root, user, "p", policyDoc(accessStatement{ Effect: "Allow", Action: actS3PutBucketOwnershipControls, Resource: bucketArn(bucket), Condition: cond("Null", "s3:if-match", "true"), })); err != nil { return err } if err := putOwnership(); err != nil { return fmt.Errorf("a bucket-level write must be allowed once the statement stops demanding the key: %w", err) } return nil }) } // S3IAMAccessControl_condition_conditional_write_keys_ignore_copies pins // the other half of that rule. A copy takes its preconditions from the // x-amz-copy-source-if-* headers, so the gateway ignores a plain If-Match or // If-None-Match on one — and a policy demanding a conditional write must // therefore keep denying copies rather than be satisfied by a header that // changes nothing. func S3IAMAccessControl_condition_conditional_write_keys_ignore_copies(s *S3Conf) error { testName := "S3IAMAccessControl_condition_conditional_write_keys_ignore_copies" return s3IAMActionHandler(s, testName, func(root *iam.Client, bucket string) error { if _, err := putObjectAndGetETag(s.GetClient(), bucket, "src"); err != nil { return err } user, cleanup, err := newS3IAMUser(root, s, map[string]string{ "p": policyDoc( accessStatement{Effect: "Allow", Action: actS3GetObject, Resource: objectsArn(bucket)}, accessStatement{Effect: "Allow", Action: actS3PutObject, Resource: objectsArn(bucket)}, accessStatement{ Effect: "Deny", Action: actS3PutObject, Resource: objectsArn(bucket), Condition: cond("Null", "s3:if-none-match", "true"), }, ), }) if err != nil { return err } defer cleanup() for _, tc := range []struct { name string ifNoneMatch *string }{ {"a copy without the header", nil}, {"a copy carrying the header", getPtr("*")}, } { ctx, cancel := context.WithTimeout(context.Background(), shortTimeout) _, err := user.client.CopyObject(ctx, &s3.CopyObjectInput{ Bucket: &bucket, Key: getPtr("dst"), CopySource: getPtr(bucket + "/src"), IfNoneMatch: tc.ifNoneMatch, }) cancel() if err := checkApiErr(err, wantExplicitIdentityDeny(user.arn, actS3PutObject, objectArn(bucket, "dst"))); err != nil { return fmt.Errorf("%s: %w", tc.name, err) } } return nil }) } // S3IAMAccessControl_condition_if_match_ignores_delete_objects covers the // batch delete. DeleteObjects carries one request-level header for the // whole batch and the gateway never applies it to any key, so letting it // populate s3:if-match would authorize deleting every object in the batch // against an ETag nothing checks. func S3IAMAccessControl_condition_if_match_ignores_delete_objects(s *S3Conf) error { testName := "S3IAMAccessControl_condition_if_match_ignores_delete_objects" return s3IAMActionHandler(s, testName, func(root *iam.Client, bucket string) error { etag, err := putObjectAndGetETag(s.GetClient(), bucket, "obj") if err != nil { return err } user, cleanup, err := newS3IAMUser(root, s, map[string]string{ "p": policyDoc(accessStatement{ Effect: "Allow", Action: actS3DeleteObject, Resource: objectsArn(bucket), Condition: cond("StringEquals", "s3:if-match", strings.Trim(etag, `"`)), }), }) if err != nil { return err } defer cleanup() ctx, cancel := context.WithTimeout(context.Background(), shortTimeout) res, err := user.client.DeleteObjects(ctx, &s3.DeleteObjectsInput{ Bucket: &bucket, Delete: &types.Delete{Objects: objectIdentifiers("obj")}, }) cancel() if err != nil { return err } if len(res.Errors) != 1 || getString(res.Errors[0].Code) != "AccessDenied" { return fmt.Errorf("expected the batch delete to be denied, instead got %+v", res) } return nil }) } // S3IAMAccessControl_condition_conditional_write_keys_ignore_reads pins the // keys to writes. GET and HEAD take If-Match/If-None-Match too, as ordinary // HTTP cache preconditions, and neither may populate the condition context // there — otherwise a browser revalidating its cache would decide whether a // read is authorized. func S3IAMAccessControl_condition_conditional_write_keys_ignore_reads(s *S3Conf) error { testName := "S3IAMAccessControl_condition_conditional_write_keys_ignore_reads" return s3IAMActionHandler(s, testName, func(root *iam.Client, bucket string) error { etag, err := putObjectAndGetETag(s.GetClient(), bucket, "obj") if err != nil { return err } user, cleanup, err := newS3IAMUser(root, s, nil) if err != nil { return err } defer cleanup() for _, tc := range []struct { name string condition json.RawMessage wantAllow bool }{ {"s3:if-match stays absent on a conditional read", cond("Null", "s3:if-match", "true"), true}, {"s3:if-none-match stays absent on a conditional read", cond("Null", "s3:if-none-match", "true"), true}, {"a read can never satisfy a present-key condition", cond("Null", "s3:if-match", "false"), false}, {"nor an equality against the ETag it sent", cond("StringEquals", "s3:if-match", strings.Trim(etag, `"`)), false}, } { if err := func() error { if err := putS3IAMUserPolicy(root, user, "p", policyDoc(accessStatement{ Effect: "Allow", Action: actS3GetObject, Resource: objectsArn(bucket), Condition: tc.condition, })); err != nil { return err } ctx, cancel := context.WithTimeout(context.Background(), shortTimeout) _, err := user.client.GetObject(ctx, &s3.GetObjectInput{ Bucket: &bucket, Key: getPtr("obj"), IfMatch: &etag, }) cancel() if !tc.wantAllow { return checkApiErr(err, wantImplicitDeny(user.arn, actS3GetObject, objectArn(bucket, "obj"))) } if err != nil { return fmt.Errorf("expected the request to be allowed: %w", err) } return nil }(); err != nil { return fmt.Errorf("%s: %w", tc.name, err) } } return nil }) } // containsBucket reports whether buckets names bucket, so a listing can be // asserted without depending on what else other tests left behind. func containsBucket(buckets []types.Bucket, bucket string) bool { for _, b := range buckets { if b.Name != nil && *b.Name == bucket { return true } } return false }