Files
versitygw/tests/integration/s3_iam_access_control.go
T
niksis02 afbee5be01 fix: role last-used tracking, and record S3 requests in last-used metadata
Role last-used tracking was missing entirely - `GetRole` returned a `RoleLastUsed` element that nothing ever wrote, rendering the zero time instead of the empty element AWS returns for an unused role - and access key last-used only ever saw the `IAM`/`STS` control plane, so a credential used exclusively against the S3 gateway reported as never used. Roles now record a use whenever a request authenticates with one of their session credentials, through a new `Storer.RecordRoleUsage` mirroring `RecordAccessKeyUsage`, gated on the session's role still being the one it was minted against so a session outliving its role can't attribute its use to a same-named replacement. `LastUsedDate` became a `*time.Time` so an unused role renders as an empty element.

Both records now cover the S3 data plane as well: the gateway sends its configured region and `s3` on evaluate-policy and the IAM service records the caller there, so `GetAccessKeyLastUsed's` `ServiceName` is now iam, sts or s3. That call was chosen over derive-signing-key, which runs before signature verification and takes its region and service from the caller's own `Authorization` header - recording there would let anyone who knows an access key id refresh and poison another identity's audit record. Requests denied by a bucket policy or made against a public bucket are not recorded, since neither reaches identity-policy evaluation. To keep per-request recording affordable, an update is skipped while the stored record has the same service and region and is under a minute old; a change of either is written through immediately.

Assuming a role is not a use, a request denied by an identity policy is, and both successful and denied S3 requests update the record. Also moves the `OIDC-dependent` tests into the `s3-iam-session` group so runoidctests.sh runs a single group.
2026-09-01 19:55:27 +04:00

1971 lines
71 KiB
Go

// 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 (
"context"
"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 access key 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.conf.awsID, 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.conf.awsID, 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()
// The resource-based denial names the access key, not the ARN:
// bucket-policy principals are access-key-based for every backend,
// so the gateway has no ARN in hand at that point.
return checkApiErr(err, wantExplicitResourceDeny(user.conf.awsID, 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.conf.awsID, actS3GetObject, objectArn(bucket, "obj"))
}},
{identity: allow, resource: deny, wantErr: func(u *s3IAMPrincipal) s3err.S3Error {
return wantExplicitResourceDeny(u.conf.awsID, 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.conf.awsID, 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.conf.awsID, 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.conf.awsID,
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: verified live against
// real AWS (niksis02, account 792168558830), 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 — verified live against
// real AWS (niksis02, account 792168558830): 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.
//
// The denial happens at authorization, before the backend ever resolves the
// named version, so this doesn't need a real object version (and the
// gateway this test group runs against has no --versioning-dir configured
// to produce one): an arbitrary VersionId is enough to exercise the
// s3:DeleteObjectVersion check and prove the batch still partially
// succeeds.
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_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/<key>
// 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_bucket_policy_unknown_principal_rejected verifies
// PutBucketPolicy validates its principals against the IAM service, so a
// policy naming somebody who doesn't exist is rejected instead of being
// stored as a statement that can never match.
func S3IAMAccessControl_bucket_policy_unknown_principal_rejected(s *S3Conf) error {
testName := "S3IAMAccessControl_bucket_policy_unknown_principal_rejected"
return s3IAMActionHandler(s, testName, func(root *iam.Client, bucket string) error {
err := putBucketPolicyDoc(s, bucket, bucketStatement{
Effect: "Allow", Principal: "AKIADOESNOTEXIST", Action: actS3GetObject, Resource: objectsArn(bucket),
})
return checkApiErr(err, s3err.APIError{
Code: "MalformedPolicy",
Description: "Invalid principal in policy",
HTTPStatusCode: 400,
})
})
}
// 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
})
}
// 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
}