Files
versitygw/auth/access-control_test.go
niksis02 41783bd814 fix: combine bucket policy with ACL so a policy no longer locks the owner out
Once a bucket had any policy, `verifyResourceAccess` evaluated the policy alone and ignored the bucket ACL, so a non-root bucket owner kept only what the policy explicitly allowed. A Deny-only policy (e.g. Deny `*` `s3:GetObject` on one prefix) therefore locked the owner out of every operation, including `DeleteBucketPolicy`, and only root/admin could recover. ACL grants also authorized every bucket operation through the controller's `AclPermission`, so a non-owner with a `WRITE` grant could install a bucket policy that locked out the owner and every other grantee.

The policy and the ACL are now combined per action the way AWS does it: an explicit policy Deny wins, and otherwise the action is allowed by either a policy Allow or an ACL grant. The bucket owner keeps everything a policy doesn't explicitly deny and, like an AWS account root user, can always call `GetBucketPolicy`, `PutBucketPolicy` and `DeleteBucketPolicy` even past a Deny. The protection covers only those three APIs; `GetBucketPolicyStatus` and every other action stay deniable, matching AWS.

ACL grants to non-owners now follow AWS's ACL-to-action mapping through the new `aclActionPermissions` table: `READ` grants `s3:ListBucket`, `s3:ListBucketVersions`, `s3:ListBucketMultipartUploads`, `s3:GetObject`, `s3:GetObjectVersion` and `s3:GetObjectAttributes`; `WRITE` grants `s3:PutObject` and `s3:DeleteObject`; `READ_ACP` and `WRITE_ACP` grant reading and writing the bucket and object ACLs. Bucket configuration, the bucket policy, tagging, object lock settings and `DeleteBucket` are the owner's alone unless a policy grants them. `verifyACL` is replaced by `aclAllows`, which derives the required permission from the action, so `AccessOptions.AclPermission` now only feeds the readonly gate. The anonymous path uses the same table in place of `publicACLAllowedActions`, which leaves its behavior unchanged.

`GetBucketPolicyStatus` also reported a Deny-only `Principal: "*"` policy as public; only Allow statements now count toward `IsPublic`.
2026-10-09 00:25:18 +01:00

1497 lines
55 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 auth
import (
"context"
"encoding/json"
"errors"
"net/http"
"path/filepath"
"testing"
"github.com/aws/aws-sdk-go-v2/service/s3"
"github.com/aws/aws-sdk-go-v2/service/s3/types"
"github.com/gofiber/fiber/v3"
"github.com/stretchr/testify/assert"
"github.com/valyala/fasthttp"
"github.com/versity/versitygw/backend"
"github.com/versity/versitygw/s3err"
)
// testFiberCtx returns a fiber.Ctx for tests to pass to functions that read
// request-derived data (e.g. the condition context) off it, released
// automatically when the test ends.
func testFiberCtx(t *testing.T) fiber.Ctx {
t.Helper()
app := fiber.New()
ctx := app.AcquireCtx(&fasthttp.RequestCtx{})
t.Cleanup(func() {
app.ReleaseCtx(ctx)
})
return ctx
}
// noBucketPolicyBackend is a test stub that returns ErrNoSuchBucketPolicy for
// GetBucketPolicy and serves a configurable ACL for GetBucketAcl.
type noBucketPolicyBackend struct {
backend.BackendUnsupported
srcAcl ACL
}
func (b noBucketPolicyBackend) GetBucketPolicy(_ context.Context, _ string) ([]byte, error) {
return nil, s3err.GetAPIError(s3err.ErrNoSuchBucketPolicy)
}
func (b noBucketPolicyBackend) GetBucketAcl(_ context.Context, _ *s3.GetBucketAclInput) ([]byte, error) {
return json.Marshal(b.srcAcl)
}
type publicBucketPolicyBackend struct {
backend.BackendUnsupported
policy []byte
acl ACL
aclCalls int
normalizeFn objectKeyNormalizer
}
func (b *publicBucketPolicyBackend) GetBucketPolicy(_ context.Context, _ string) ([]byte, error) {
return b.policy, nil
}
func (b *publicBucketPolicyBackend) GetBucketAcl(_ context.Context, _ *s3.GetBucketAclInput) ([]byte, error) {
b.aclCalls++
return json.Marshal(b.acl)
}
func (b *publicBucketPolicyBackend) NormalizeObjectKey(bucket, key string) string {
if b.normalizeFn == nil {
return b.BackendUnsupported.NormalizeObjectKey(bucket, key)
}
return b.normalizeFn(bucket, key)
}
func testNormalizeObjectKey(bucket, key string) string {
fullPath := filepath.Join(bucket, key)
normalizedKey, err := filepath.Rel(filepath.Clean(bucket), fullPath)
if err != nil {
return fullPath
}
if normalizedKey == "." {
return ""
}
return normalizedKey
}
func publicReadACL() ACL {
return ACL{
Owner: "owner",
Grantees: []Grantee{
{
Permission: PermissionRead,
Access: "all-users",
Type: types.TypeGroup,
},
},
}
}
func publicWriteACL() ACL {
return ACL{
Owner: "owner",
Grantees: []Grantee{
{
Permission: PermissionWrite,
Access: "all-users",
Type: types.TypeGroup,
},
},
}
}
// mockPolicyEvaluator implements IAMService (via the embedded
// IAMServiceSingle, whose methods are never exercised here) and
// PolicyEvaluator, recording every EvaluatePolicy call so tests can assert
// both the outcome and exactly what VerifyAccess asked it to evaluate.
type mockPolicyEvaluator struct {
IAMService
decision policyDecision
principalArn string
err error
calls []evaluatePolicyCall
}
type evaluatePolicyCall struct {
access, sessionToken string
resources []string
actions []Action
condition map[string][]string
}
func (m *mockPolicyEvaluator) EvaluatePolicy(access, sessionToken string, actions []Action, resources []string, condition map[string][]string) (PolicyEvaluation, error) {
m.calls = append(m.calls, evaluatePolicyCall{
access: access,
sessionToken: sessionToken,
actions: actions,
resources: resources,
condition: condition,
})
decisions := make([][]policyDecision, len(resources))
for i := range resources {
decisions[i] = make([]policyDecision, len(actions))
for j := range actions {
decisions[i][j] = m.decision
}
}
return PolicyEvaluation{Decisions: decisions, PrincipalArn: m.principalArn}, m.err
}
func newMockPolicyEvaluator(decision policyDecision) *mockPolicyEvaluator {
return &mockPolicyEvaluator{IAMService: NewIAMServiceSingle(Account{}), decision: decision}
}
// requireAccessDeniedAPIError asserts err is an s3err.APIError with the AWS
// AccessDenied shape (Code, HTTP 403) and returns it for the caller to
// inspect the dynamic Description text further.
func requireAccessDeniedAPIError(t *testing.T, err error) s3err.APIError {
t.Helper()
apiErr, ok := err.(s3err.APIError)
if !ok {
t.Fatalf("err = %#v (%T), want s3err.APIError", err, err)
}
assert.Equal(t, "AccessDenied", apiErr.Code)
assert.Equal(t, http.StatusForbidden, apiErr.HTTPStatusCode)
return apiErr
}
// TestVerifyAccess_ResourceAllowStillChecksIdentityForExplicitDeny confirms
// the fix for the core bug: a bucket policy Allow used to short-circuit
// before the identity-policy layer was ever consulted, so an identity
// policy's explicit Deny was silently ignored whenever the bucket policy
// already allowed. Now the identity policy is always consulted too — here
// it has no opinion (NoMatch), so the bucket policy's Allow still stands,
// but EvaluatePolicy must actually have been called for that to be a real
// verdict rather than a skipped check.
func TestVerifyAccess_ResourceAllowStillChecksIdentityForExplicitDeny(t *testing.T) {
be := &publicBucketPolicyBackend{
policy: []byte(`{
"Statement": [{
"Effect": "Allow",
"Principal": "testuser",
"Action": "s3:GetObject",
"Resource": "arn:aws:s3:::bucket/*"
}]
}`),
}
pe := newMockPolicyEvaluator(policyDecisionNoMatch)
err := VerifyAccess(testFiberCtx(t), be, AccessOptions{
Acc: Account{Access: "testuser", Role: RoleUser},
Bucket: "bucket",
Object: "key.txt",
Actions: []Action{GetObjectAction},
Iam: pe,
})
assert.NoError(t, err)
assert.Len(t, pe.calls, 1, "EvaluatePolicy must now be called even when the resource-level check already allows, so an explicit identity-policy Deny can still override it")
}
// TestVerifyAccess_IdentityExplicitDenyOverridesResourceAllow is the
// explicit-deny-wins fix: a bucket policy Allow does not save a request the
// caller's own identity policy explicitly denies. The Message names the
// resolved principal ARN and calls out "an identity-based policy" —
// matching what real AWS returns for this case.
func TestVerifyAccess_IdentityExplicitDenyOverridesResourceAllow(t *testing.T) {
be := &publicBucketPolicyBackend{
policy: []byte(`{
"Statement": [{
"Effect": "Allow",
"Principal": "testuser",
"Action": "s3:GetObject",
"Resource": "arn:aws:s3:::bucket/*"
}]
}`),
}
pe := newMockPolicyEvaluator(policyDecisionDeny)
pe.principalArn = "arn:aws:iam::000000000000:user/testuser"
err := VerifyAccess(testFiberCtx(t), be, AccessOptions{
Acc: Account{Access: "testuser", Role: RoleUser},
Bucket: "bucket",
Object: "key.txt",
Actions: []Action{GetObjectAction},
Iam: pe,
})
apiErr := requireAccessDeniedAPIError(t, err)
assert.Contains(t, apiErr.Description, "arn:aws:iam::000000000000:user/testuser")
assert.Contains(t, apiErr.Description, "s3:GetObject")
assert.Contains(t, apiErr.Description, "with an explicit deny in an identity-based policy")
}
// TestVerifyAccess_ResourceExplicitDenyOverridesIdentityAllow is the
// reverse case: an identity policy Allow does not save a request the
// bucket policy explicitly denies. The resource-level Deny short-circuits
// before the identity policy is even consulted (it can't change the
// outcome, and it saves the standalone IAM service round trip), and the
// Message calls out "a resource-based policy".
func TestVerifyAccess_ResourceExplicitDenyOverridesIdentityAllow(t *testing.T) {
be := &publicBucketPolicyBackend{
policy: []byte(`{
"Statement": [{
"Effect": "Deny",
"Principal": "testuser",
"Action": "s3:GetObject",
"Resource": "arn:aws:s3:::bucket/*"
}]
}`),
}
pe := newMockPolicyEvaluator(policyDecisionAllow)
err := VerifyAccess(testFiberCtx(t), be, AccessOptions{
Acc: Account{Access: "testuser", Role: RoleUser},
Bucket: "bucket",
Object: "key.txt",
Actions: []Action{GetObjectAction},
Iam: pe,
})
apiErr := requireAccessDeniedAPIError(t, err)
assert.Contains(t, apiErr.Description, "testuser")
assert.Contains(t, apiErr.Description, "with an explicit deny in a resource-based policy")
assert.Empty(t, pe.calls, "a resource-level explicit deny should short-circuit before consulting the identity policy")
}
// TestVerifyAccess_IdentityPolicyAllowsWhenResourceDenies is the core
// same-account fix: a private bucket with no ACL grant and no bucket policy
// still allows access when the caller's IAM identity policy grants it —
// matching real AWS, where a bucket policy is only *required* for
// cross-account access; within the same account (this gateway is always
// single-account) an identity-based Allow alone is sufficient.
func TestVerifyAccess_IdentityPolicyAllowsWhenResourceDenies(t *testing.T) {
be := noBucketPolicyBackend{srcAcl: ACL{Owner: "someone-else"}}
pe := newMockPolicyEvaluator(policyDecisionAllow)
err := VerifyAccess(testFiberCtx(t), be, AccessOptions{
Acc: Account{Access: "testuser", Role: RoleUser},
Bucket: "bucket",
Object: "key.txt",
Actions: []Action{GetObjectAction},
AclPermission: PermissionRead,
Iam: pe,
})
assert.NoError(t, err)
assert.Len(t, pe.calls, 1)
assert.Equal(t, []string{"arn:aws:s3:::bucket/key.txt"}, pe.calls[0].resources)
assert.Equal(t, []Action{GetObjectAction}, pe.calls[0].actions)
assert.Equal(t, "testuser", pe.calls[0].access)
}
// TestVerifyAccess_DeniedWhenNeitherResourceNorIdentityPolicyAllows confirms
// access is denied — with the AWS-shaped implicit-deny message, since a
// PolicyEvaluator is configured — when neither the resource-level check
// (ACL owned by someone else, no bucket policy) nor the identity policy has
// any opinion at all (NoMatch, not an explicit Deny from either side). It
// also pins that the message names the resolved principal ARN, not the
// access key — matching real AWS's implicit-deny message shape (previously
// this fell back to the access key even when the PolicyEvaluator resolved
// an ARN, since identityPolicyDecision only threaded PrincipalArn through
// on its Deny branch).
func TestVerifyAccess_DeniedWhenNeitherResourceNorIdentityPolicyAllows(t *testing.T) {
be := noBucketPolicyBackend{srcAcl: ACL{Owner: "someone-else"}}
pe := newMockPolicyEvaluator(policyDecisionNoMatch)
pe.principalArn = "arn:aws:iam::000000000000:user/testuser"
err := VerifyAccess(testFiberCtx(t), be, AccessOptions{
Acc: Account{Access: "testuser", Role: RoleUser},
Bucket: "bucket",
Object: "key.txt",
Actions: []Action{GetObjectAction},
AclPermission: PermissionRead,
Iam: pe,
})
apiErr := requireAccessDeniedAPIError(t, err)
assert.Contains(t, apiErr.Description, "arn:aws:iam::000000000000:user/testuser")
assert.Contains(t, apiErr.Description, "because no identity-based policy allows the s3:GetObject action")
assert.Len(t, pe.calls, 1)
}
// TestVerifyAccess_NoPolicyEvaluatorIsANoOp confirms backends that don't
// implement PolicyEvaluator (every backend except the standalone IAM
// client) are entirely unaffected by this layer — backward compatibility
// via the type assertion, not a config flag.
func TestVerifyAccess_NoPolicyEvaluatorIsANoOp(t *testing.T) {
be := &publicBucketPolicyBackend{
policy: []byte(`{
"Statement": [{
"Effect": "Allow",
"Principal": "testuser",
"Action": "s3:GetObject",
"Resource": "arn:aws:s3:::bucket/*"
}]
}`),
}
err := VerifyAccess(testFiberCtx(t), be, AccessOptions{
Acc: Account{Access: "testuser", Role: RoleUser},
Bucket: "bucket",
Object: "key.txt",
Actions: []Action{GetObjectAction},
Iam: NewIAMServiceSingle(Account{}),
})
assert.NoError(t, err)
}
// TestVerifyAccess_NoPolicyEvaluatorDeniedKeepsGenericMessage pins that,
// with no PolicyEvaluator configured, a denied request's error stays
// byte-for-byte today's generic message — the dynamic AWS-shaped messages
// above only ever appear once a PolicyEvaluator is actually in play, so
// every internal/LDAP/Vault/IPA/S3-IAM deployment sees no message change
// from this fix at all.
func TestVerifyAccess_NoPolicyEvaluatorDeniedKeepsGenericMessage(t *testing.T) {
be := noBucketPolicyBackend{srcAcl: ACL{Owner: "someone-else"}}
err := VerifyAccess(testFiberCtx(t), be, AccessOptions{
Acc: Account{Access: "testuser", Role: RoleUser},
Bucket: "bucket",
Object: "key.txt",
Actions: []Action{GetObjectAction},
AclPermission: PermissionRead,
Iam: NewIAMServiceSingle(Account{}),
})
assert.Equal(t, s3err.GetAPIError(s3err.ErrAccessDenied), err)
}
func TestVerifyAccess_NormalizesObjectKeyBeforePolicyMatch(t *testing.T) {
be := &publicBucketPolicyBackend{
normalizeFn: testNormalizeObjectKey,
policy: []byte(`{
"Statement": [{
"Effect": "Allow",
"Principal": "testuser",
"Action": "s3:GetObject",
"Resource": "arn:aws:s3:::bucket/public/*"
}]
}`),
}
err := VerifyAccess(testFiberCtx(t), be, AccessOptions{
Acc: Account{Access: "testuser", Role: RoleUser},
Bucket: "bucket",
Object: "public/../private.txt",
Actions: []Action{GetObjectAction},
})
assert.Error(t, err)
assert.True(t, errors.Is(err, s3err.GetAPIError(s3err.ErrAccessDenied)))
}
func TestVerifyAccess_NormalizesPolicyResourceBeforeMatch(t *testing.T) {
be := &publicBucketPolicyBackend{
normalizeFn: testNormalizeObjectKey,
policy: []byte(`{
"Statement": [{
"Effect": "Allow",
"Principal": "testuser",
"Action": "s3:GetObject",
"Resource": "arn:aws:s3:::bucket/public/../private.txt"
}]
}`),
}
err := VerifyAccess(testFiberCtx(t), be, AccessOptions{
Acc: Account{Access: "testuser", Role: RoleUser},
Bucket: "bucket",
Object: "private.txt",
Actions: []Action{GetObjectAction},
})
assert.NoError(t, err)
}
func TestVerifyPublicAccess_PublicPolicyDenyStopsACLFallback(t *testing.T) {
be := &publicBucketPolicyBackend{
policy: []byte(`{
"Statement": [{
"Effect": "Deny",
"Principal": "*",
"Action": "s3:GetObject",
"Resource": "arn:aws:s3:::bucket/private/*"
}]
}`),
acl: publicReadACL(),
}
err := VerifyPublicAccess(testFiberCtx(t), be, []Action{GetObjectAction}, PermissionRead, "bucket", "private/secret.txt")
assert.Error(t, err)
assert.True(t, errors.Is(err, s3err.GetAPIError(s3err.ErrAccessDenied)))
assert.Equal(t, 0, be.aclCalls)
}
func TestVerifyPublicAccess_PublicPolicyNoMatchFallsBackToACL(t *testing.T) {
be := &publicBucketPolicyBackend{
policy: []byte(`{
"Statement": [{
"Effect": "Deny",
"Principal": "*",
"Action": "s3:GetObject",
"Resource": "arn:aws:s3:::bucket/private/*"
}]
}`),
acl: publicReadACL(),
}
err := VerifyPublicAccess(testFiberCtx(t), be, []Action{GetObjectAction}, PermissionRead, "bucket", "public/object.txt")
assert.NoError(t, err)
assert.Equal(t, 1, be.aclCalls)
}
func TestVerifyPublicAccess_NormalizedDenyStopsACLFallback(t *testing.T) {
be := &publicBucketPolicyBackend{
normalizeFn: testNormalizeObjectKey,
policy: []byte(`{
"Statement": [{
"Effect": "Deny",
"Principal": "*",
"Action": "s3:GetObject",
"Resource": "arn:aws:s3:::bucket/private/*"
}]
}`),
acl: publicReadACL(),
}
err := VerifyPublicAccess(testFiberCtx(t), be, []Action{GetObjectAction}, PermissionRead, "bucket", "public/../private/secret.txt")
assert.Error(t, err)
assert.True(t, errors.Is(err, s3err.GetAPIError(s3err.ErrAccessDenied)))
assert.Equal(t, 0, be.aclCalls)
}
func TestVerifyPublicAccess_ActionsGrantedBySeparateSources(t *testing.T) {
be := &publicBucketPolicyBackend{
policy: []byte(`{
"Statement": [{
"Effect": "Allow",
"Principal": "*",
"Action": "s3:PutObjectTagging",
"Resource": "arn:aws:s3:::bucket/*"
}]
}`),
acl: publicWriteACL(),
}
err := VerifyPublicAccess(testFiberCtx(t), be, []Action{PutObjectAction, PutObjectTaggingAction}, PermissionWrite, "bucket", "object")
assert.NoError(t, err)
assert.Equal(t, 1, be.aclCalls)
}
func TestVerifyPublicAccess_ACLDoesNotGrantTagging(t *testing.T) {
be := &publicBucketPolicyBackend{
policy: []byte(`{
"Statement": [{
"Effect": "Allow",
"Principal": "*",
"Action": "s3:GetObject",
"Resource": "arn:aws:s3:::bucket/*"
}]
}`),
acl: publicWriteACL(),
}
err := VerifyPublicAccess(testFiberCtx(t), be, []Action{PutObjectAction, PutObjectTaggingAction}, PermissionWrite, "bucket", "object")
assert.Error(t, err)
assert.True(t, errors.Is(err, s3err.GetAPIError(s3err.ErrAccessDenied)))
assert.Equal(t, 0, be.aclCalls)
}
func TestVerifyObjectCopyAccess_URLEncodedSlashSeparator(t *testing.T) {
const testUser = "testuser"
// Source and destination bucket ACLs: testUser owns both. opts sets
// DisableACL, which now applies uniformly to the source-bucket check
// VerifyObjectCopyAccess performs internally as well as the
// destination's, collapsing both to an owner-only check — a grantee
// entry alone (without ownership) would no longer be sufficient.
srcAcl := ACL{Owner: testUser}
be := noBucketPolicyBackend{srcAcl: srcAcl}
opts := AccessOptions{
Acl: ACL{Owner: testUser},
AclPermission: PermissionWrite,
Acc: Account{Access: testUser, Role: RoleUser},
Bucket: "dst-bucket",
Object: "dst-key",
Actions: []Action{PutObjectAction},
DisableACL: true,
}
tests := []struct {
name string
copySource string
}{
{
name: "percent-encoded slash (%2F) as bucket/key separator",
copySource: "my-namespace-test-container%2Ftest-blob",
},
{
name: "%2F separator with encoded chars in key",
copySource: "src-bucket%2Fmy%20folder%2Fmy-key",
},
{
name: "%2F separator with versionId",
copySource: "src-bucket%2Fsrc-key?versionId=abc123",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := VerifyObjectCopyAccess(testFiberCtx(t), be, tt.copySource, opts)
assert.NoError(t, err,
"should accept %%2F as the bucket/key separator in x-amz-copy-source")
})
}
}
func TestVerifyObjectCopyAccess_LiteralSlashSeparator(t *testing.T) {
const testUser = "testuser"
// testUser owns both source and destination buckets — see the comment
// in TestVerifyObjectCopyAccess_URLEncodedSlashSeparator on why
// DisableACL requires ownership here rather than a grantee entry.
srcAcl := ACL{Owner: testUser}
be := noBucketPolicyBackend{srcAcl: srcAcl}
opts := AccessOptions{
Acl: ACL{Owner: testUser},
AclPermission: PermissionWrite,
Acc: Account{Access: testUser, Role: RoleUser},
Bucket: "dst-bucket",
Object: "dst-key",
Actions: []Action{PutObjectAction},
DisableACL: true,
}
err := VerifyObjectCopyAccess(testFiberCtx(t), be, "src-bucket/src-key", opts)
assert.NoError(t, err, "literal slash separator should work")
}
// TestVerifyCreateBucketAccess_RootAndAdminBypass confirms root and admin
// accounts may always create a bucket, with no iam backend consulted at
// all — CreateBucket has no existing bucket to check a policy or ACL
// against, so this bypass (unlike VerifyAccess's, which still runs the
// resource-policy check first) is the entire decision.
func TestVerifyCreateBucketAccess_RootAndAdminBypass(t *testing.T) {
err := VerifyCreateBucketAccess(testFiberCtx(t), NewIAMServiceSingle(Account{}), true, Account{Access: "testuser", Role: RoleUser}, "bucket")
assert.NoError(t, err)
err = VerifyCreateBucketAccess(testFiberCtx(t), NewIAMServiceSingle(Account{}), false, Account{Access: "testuser", Role: RoleAdmin}, "bucket")
assert.NoError(t, err)
}
// TestVerifyCreateBucketAccess_NoPolicyEvaluatorUsesLegacyRoleGate confirms
// that for every backend without an identity-policy layer (internal, LDAP,
// Vault, IPA, S3-IAM) bucket creation keeps working exactly as it always
// has: userplus is allowed, a plain user is denied with the generic
// AccessDenied error, and EvaluatePolicy is never a factor since these
// backends don't implement PolicyEvaluator at all.
func TestVerifyCreateBucketAccess_NoPolicyEvaluatorUsesLegacyRoleGate(t *testing.T) {
iam := NewIAMServiceSingle(Account{})
err := VerifyCreateBucketAccess(testFiberCtx(t), iam, false, Account{Access: "testuser", Role: RoleUserPlus}, "bucket")
assert.NoError(t, err)
err = VerifyCreateBucketAccess(testFiberCtx(t), iam, false, Account{Access: "testuser", Role: RoleUser}, "bucket")
assert.Equal(t, s3err.GetAPIError(s3err.ErrAccessDenied), err)
}
// TestVerifyCreateBucketAccess_PolicyEvaluatorAllow confirms the core fix:
// a standalone-IAM-service user, who is always Role RoleUser regardless of
// their attached IAM policy, can create a bucket when that policy grants
// s3:CreateBucket — the identity-policy Allow is what grants access, not
// the role.
func TestVerifyCreateBucketAccess_PolicyEvaluatorAllow(t *testing.T) {
pe := newMockPolicyEvaluator(policyDecisionAllow)
err := VerifyCreateBucketAccess(testFiberCtx(t), pe, false, Account{Access: "testuser", Role: RoleUser}, "bucket")
assert.NoError(t, err)
assert.Len(t, pe.calls, 1)
assert.Equal(t, "testuser", pe.calls[0].access)
assert.Equal(t, []string{"arn:aws:s3:::bucket"}, pe.calls[0].resources)
assert.Equal(t, []Action{CreateBucketAction}, pe.calls[0].actions)
}
// TestVerifyCreateBucketAccess_PolicyEvaluatorNoMatchDenies confirms a
// standalone-IAM-service user with no policy granting s3:CreateBucket is
// denied — with the AWS-shaped implicit-deny message — even though the
// legacy role gate alone would have denied them anyway; this pins that the
// policy layer, not the role, is now what's actually being asked.
func TestVerifyCreateBucketAccess_PolicyEvaluatorNoMatchDenies(t *testing.T) {
pe := newMockPolicyEvaluator(policyDecisionNoMatch)
pe.principalArn = "arn:aws:iam::000000000000:user/testuser"
err := VerifyCreateBucketAccess(testFiberCtx(t), pe, false, Account{Access: "testuser", Role: RoleUser}, "bucket")
apiErr := requireAccessDeniedAPIError(t, err)
assert.Contains(t, apiErr.Description, "arn:aws:iam::000000000000:user/testuser")
assert.Contains(t, apiErr.Description, "s3:CreateBucket")
assert.Contains(t, apiErr.Description, "because no identity-based policy allows the s3:CreateBucket action")
}
// TestVerifyCreateBucketAccess_PolicyEvaluatorExplicitDenyWins confirms an
// explicit Deny in the identity policy is reported with the AWS-shaped
// explicit-deny message, naming the resolved principal ARN when the
// PolicyEvaluator reports one.
func TestVerifyCreateBucketAccess_PolicyEvaluatorExplicitDenyWins(t *testing.T) {
pe := newMockPolicyEvaluator(policyDecisionDeny)
pe.principalArn = "arn:aws:iam::000000000000:user/testuser"
err := VerifyCreateBucketAccess(testFiberCtx(t), pe, false, Account{Access: "testuser", Role: RoleUser}, "bucket")
apiErr := requireAccessDeniedAPIError(t, err)
assert.Contains(t, apiErr.Description, "arn:aws:iam::000000000000:user/testuser")
assert.Contains(t, apiErr.Description, "s3:CreateBucket")
assert.Contains(t, apiErr.Description, "with an explicit deny in an identity-based policy")
}
// TestVerifyCreateBucketAccess_PolicyEvaluatorIgnoresUserPlus confirms the
// legacy userplus bypass does not leak into the PolicyEvaluator path: once
// a backend implements identity-policy evaluation, that policy is the sole
// gate for non-admin accounts, matching the standalone IAM service's real
// behavior (its accounts are always Role RoleUser, never RoleUserPlus, so
// this also documents why the bypass would be a no-op there in practice).
func TestVerifyCreateBucketAccess_PolicyEvaluatorIgnoresUserPlus(t *testing.T) {
pe := newMockPolicyEvaluator(policyDecisionNoMatch)
err := VerifyCreateBucketAccess(testFiberCtx(t), pe, false, Account{Access: "testuser", Role: RoleUserPlus}, "bucket")
assert.Error(t, err)
assert.Len(t, pe.calls, 1, "EvaluatePolicy must be consulted even for a userplus account once a PolicyEvaluator is configured")
}
// Root and admin always list buckets, with no iam backend consulted.
func TestVerifyListAllMyBucketsAccess_RootAndAdminBypass(t *testing.T) {
pe := newMockPolicyEvaluator(policyDecisionDeny)
err := VerifyListAllMyBucketsAccess(testFiberCtx(t), pe, true, Account{Access: "testuser", Role: RoleUser})
assert.NoError(t, err)
err = VerifyListAllMyBucketsAccess(testFiberCtx(t), pe, false, Account{Access: "testuser", Role: RoleAdmin})
assert.NoError(t, err)
assert.Empty(t, pe.calls, "root/admin bypass before any policy evaluation")
}
// Backends without an identity-policy layer keep listing buckets as before:
// the listing is already narrowed to the caller's own buckets.
func TestVerifyListAllMyBucketsAccess_NoPolicyEvaluatorIsUnrestricted(t *testing.T) {
err := VerifyListAllMyBucketsAccess(testFiberCtx(t), NewIAMServiceSingle(Account{}), false, Account{Access: "testuser", Role: RoleUser})
assert.NoError(t, err)
}
// A policy granting s3:ListAllMyBuckets allows the listing, evaluated
// against "arn:aws:s3:::*".
func TestVerifyListAllMyBucketsAccess_PolicyEvaluatorAllow(t *testing.T) {
pe := newMockPolicyEvaluator(policyDecisionAllow)
err := VerifyListAllMyBucketsAccess(testFiberCtx(t), pe, false, Account{Access: "testuser", Role: RoleUser})
assert.NoError(t, err)
assert.Len(t, pe.calls, 1)
assert.Equal(t, "testuser", pe.calls[0].access)
assert.Equal(t, []string{"arn:aws:s3:::*"}, pe.calls[0].resources)
assert.Equal(t, []Action{ListAllMyBucketsAction}, pe.calls[0].actions)
}
// No matching policy denies with the AWS-shaped implicit-deny message.
func TestVerifyListAllMyBucketsAccess_PolicyEvaluatorNoMatchDenies(t *testing.T) {
pe := newMockPolicyEvaluator(policyDecisionNoMatch)
pe.principalArn = "arn:aws:iam::000000000000:user/testuser"
err := VerifyListAllMyBucketsAccess(testFiberCtx(t), pe, false, Account{Access: "testuser", Role: RoleUser})
apiErr := requireAccessDeniedAPIError(t, err)
assert.Contains(t, apiErr.Description, "arn:aws:iam::000000000000:user/testuser")
assert.Contains(t, apiErr.Description, "because no identity-based policy allows the s3:ListAllMyBuckets action")
}
// An explicit Deny is reported with the AWS-shaped explicit-deny message.
func TestVerifyListAllMyBucketsAccess_PolicyEvaluatorExplicitDenyWins(t *testing.T) {
pe := newMockPolicyEvaluator(policyDecisionDeny)
pe.principalArn = "arn:aws:iam::000000000000:user/testuser"
err := VerifyListAllMyBucketsAccess(testFiberCtx(t), pe, false, Account{Access: "testuser", Role: RoleUser})
apiErr := requireAccessDeniedAPIError(t, err)
assert.Contains(t, apiErr.Description, "s3:ListAllMyBuckets")
assert.Contains(t, apiErr.Description, "with an explicit deny in an identity-based policy")
}
// noObjectLockBackend answers "no lock configuration" for
// GetObjectLockConfiguration, so VerifyObjectsAccess's lock check is a no-op
// and only the policy/ACL half of the result is under test — matching what
// loadObjectLockState treats as "object lock was never configured on this
// bucket", not the BackendUnsupported stub's ErrNotImplemented, which would
// otherwise fail the whole request before either object was authorized.
type noObjectLockBackend struct {
noBucketPolicyBackend
}
func (b noObjectLockBackend) GetObjectLockConfiguration(_ context.Context, _ string) ([]byte, error) {
return nil, s3err.GetAPIError(s3err.ErrObjectLockConfigurationNotFound)
}
// actionPolicyEvaluator answers each action with its own decision from
// identity, and from session when that is set, NoMatch for any action they
// don't list. It records each EvaluatePolicy call it receives — for
// asserting not just the outcome but which actions were asked about in
// which call.
type actionPolicyEvaluator struct {
IAMService
identity map[Action]policyDecision
session map[Action]policyDecision
principalArn string
calls []evaluatePolicyCall
}
func (m *actionPolicyEvaluator) EvaluatePolicy(access, sessionToken string, actions []Action, resources []string, condition map[string][]string) (PolicyEvaluation, error) {
m.calls = append(m.calls, evaluatePolicyCall{
access: access,
sessionToken: sessionToken,
actions: actions,
resources: resources,
condition: condition,
})
matrix := func(byAction map[Action]policyDecision) [][]policyDecision {
decisions := make([][]policyDecision, len(resources))
for i := range resources {
decisions[i] = make([]policyDecision, len(actions))
for j, a := range actions {
decisions[i][j] = byAction[a]
}
}
return decisions
}
eval := PolicyEvaluation{Decisions: matrix(m.identity), PrincipalArn: m.principalArn}
if m.session != nil {
eval.HasSessionPolicy = true
eval.SessionDecisions = matrix(m.session)
}
return eval, nil
}
func TestVerifyObjectsAccess_VersionedDeleteNeedsSeparatePermission(t *testing.T) {
be := noObjectLockBackend{noBucketPolicyBackend{srcAcl: ACL{Owner: "someone-else"}}}
pe := &actionPolicyEvaluator{identity: map[Action]policyDecision{DeleteObjectAction: policyDecisionAllow}}
objects := []types.ObjectIdentifier{
{Key: strPtr("plain.txt")},
{Key: strPtr("versioned.txt"), VersionId: strPtr("v1")},
}
errs, err := VerifyObjectsAccess(testFiberCtx(t), be, AccessOptions{
Acc: Account{Access: "testuser", Role: RoleUser},
Bucket: "bucket",
AclPermission: PermissionWrite,
Iam: pe,
}, objects, BypassNone)
assert.NoError(t, err)
if assert.Len(t, errs, 2) {
assert.NoError(t, errs[0], "the keyed delete should be authorized against s3:DeleteObject, which is allowed")
apiErr := requireAccessDeniedAPIError(t, errs[1])
assert.Contains(t, apiErr.Description, "s3:DeleteObjectVersion")
assert.Contains(t, apiErr.Description, "because no identity-based policy allows the s3:DeleteObjectVersion action")
}
if assert.Len(t, pe.calls, 2, "the batch should split into one EvaluatePolicy call per distinct action") {
assert.Equal(t, []Action{DeleteObjectAction}, pe.calls[0].actions)
assert.Equal(t, []string{"arn:aws:s3:::bucket/plain.txt"}, pe.calls[0].resources)
assert.Equal(t, []Action{DeleteObjectVersionAction}, pe.calls[1].actions)
assert.Equal(t, []string{"arn:aws:s3:::bucket/versioned.txt"}, pe.calls[1].resources)
}
}
// bucketPolicyNoLockBackend serves a fixed bucket policy and answers "no
// lock configuration", so VerifyObjectsAccess' lock check is a no-op and
// only the policy half of the per-object result is under test.
type bucketPolicyNoLockBackend struct {
backend.BackendUnsupported
policy []byte
}
func (b bucketPolicyNoLockBackend) GetBucketPolicy(_ context.Context, _ string) ([]byte, error) {
return b.policy, nil
}
func (b bucketPolicyNoLockBackend) GetObjectLockConfiguration(_ context.Context, _ string) ([]byte, error) {
return nil, s3err.GetAPIError(s3err.ErrObjectLockConfigurationNotFound)
}
// denyProtectedPrefixBackend is a bucket policy denying s3:DeleteObject on
// one prefix and saying nothing about anything else, so a batch can mix
// explicitly denied keys with keys the bucket policy leaves undecided.
func denyProtectedPrefixBackend() bucketPolicyNoLockBackend {
return bucketPolicyNoLockBackend{policy: []byte(`{
"Statement": [{
"Effect": "Deny",
"Principal": "testuser",
"Action": "s3:DeleteObject",
"Resource": "arn:aws:s3:::bucket/protected/*"
}]
}`)}
}
func deleteObjectIdentifiers(keys ...string) []types.ObjectIdentifier {
objects := make([]types.ObjectIdentifier, len(keys))
for i, key := range keys {
objects[i] = types.ObjectIdentifier{Key: strPtr(key)}
}
return objects
}
// TestVerifyObjectsAccess_ResourceDenyDoesNotAuthorizeLaterKeys is the
// authorization-bypass regression: a bucket-policy Deny used to end the
// whole batch's evaluation at the first denied key, leaving every later key
// with a nil result — which VerifyObjectsAccess' caller reads as
// "authorized" and sends straight to the backend. Every key must be settled
// on its own instead: the denied one explicitly, the rest by the identity
// policy, which here allows neither.
func TestVerifyObjectsAccess_ResourceDenyDoesNotAuthorizeLaterKeys(t *testing.T) {
pe := newMockPolicyEvaluator(policyDecisionNoMatch)
pe.principalArn = "arn:aws:iam::000000000000:user/testuser"
errs, err := VerifyObjectsAccess(testFiberCtx(t), denyProtectedPrefixBackend(), AccessOptions{
Acc: Account{Access: "testuser", Role: RoleUser},
Bucket: "bucket",
AclPermission: PermissionWrite,
Iam: pe,
}, deleteObjectIdentifiers("protected/x", "secret/y"), BypassNone)
assert.NoError(t, err)
if assert.Len(t, errs, 2) {
denied := requireAccessDeniedAPIError(t, errs[0])
assert.Contains(t, denied.Description, "with an explicit deny in a resource-based policy")
later := requireAccessDeniedAPIError(t, errs[1])
assert.Contains(t, later.Description, "because no identity-based policy allows the s3:DeleteObject action")
}
assert.Len(t, pe.calls, 1, "a key the bucket policy left undecided still needs the identity policy consulted for it")
}
// TestVerifyObjectsAccess_ResourceDenyKeptOverIdentityAllow confirms an
// explicit deny still wins per key once every key is evaluated: the denied
// key keeps its resource-based denial even though the identity policy
// allows it, while the key the bucket policy said nothing about is
// authorized by that same identity Allow.
func TestVerifyObjectsAccess_ResourceDenyKeptOverIdentityAllow(t *testing.T) {
pe := newMockPolicyEvaluator(policyDecisionAllow)
errs, err := VerifyObjectsAccess(testFiberCtx(t), denyProtectedPrefixBackend(), AccessOptions{
Acc: Account{Access: "testuser", Role: RoleUser},
Bucket: "bucket",
AclPermission: PermissionWrite,
Iam: pe,
}, deleteObjectIdentifiers("protected/x", "allowed/y"), BypassNone)
assert.NoError(t, err)
if assert.Len(t, errs, 2) {
denied := requireAccessDeniedAPIError(t, errs[0])
assert.Contains(t, denied.Description, "with an explicit deny in a resource-based policy")
assert.NoError(t, errs[1], "the identity policy's Allow stands for the key the bucket policy didn't deny")
}
}
// TestVerifyObjectsAccess_AllKeysResourceDeniedSkipsIdentityPolicy covers
// the round trip the short-circuit was there to save: it is still skipped,
// but only when the bucket policy denied every key in the batch, since then
// no identity-policy answer could change any result.
func TestVerifyObjectsAccess_AllKeysResourceDeniedSkipsIdentityPolicy(t *testing.T) {
pe := newMockPolicyEvaluator(policyDecisionAllow)
errs, err := VerifyObjectsAccess(testFiberCtx(t), denyProtectedPrefixBackend(), AccessOptions{
Acc: Account{Access: "testuser", Role: RoleUser},
Bucket: "bucket",
AclPermission: PermissionWrite,
Iam: pe,
}, deleteObjectIdentifiers("protected/x", "protected/y"), BypassNone)
assert.NoError(t, err)
if assert.Len(t, errs, 2) {
for i, e := range errs {
denied := requireAccessDeniedAPIError(t, e)
assert.Containsf(t, denied.Description, "with an explicit deny in a resource-based policy", "key %d", i)
}
}
assert.Empty(t, pe.calls, "with every key already explicitly denied there is nothing left for the identity policy to decide")
}
// TestVerifyObjectsAccess_ResourceDenyNoPolicyEvaluator covers the same
// bypass for the IAM backends with no identity-policy layer at all
// (internal, LDAP, Vault, IPA): the denied key keeps its specific message
// and every other key falls to the generic AccessDenied those backends have
// always returned — none of them silently authorized.
func TestVerifyObjectsAccess_ResourceDenyNoPolicyEvaluator(t *testing.T) {
errs, err := VerifyObjectsAccess(testFiberCtx(t), denyProtectedPrefixBackend(), AccessOptions{
Acc: Account{Access: "testuser", Role: RoleUser},
Bucket: "bucket",
AclPermission: PermissionWrite,
Iam: NewIAMServiceSingle(Account{}),
}, deleteObjectIdentifiers("protected/x", "secret/y"), BypassNone)
assert.NoError(t, err)
if assert.Len(t, errs, 2) {
denied := requireAccessDeniedAPIError(t, errs[0])
assert.Contains(t, denied.Description, "with an explicit deny in a resource-based policy")
later := requireAccessDeniedAPIError(t, errs[1])
assert.Equal(t, s3err.GetAPIError(s3err.ErrAccessDenied).Description, later.Description,
"backends with no identity-policy layer keep their generic message")
}
}
func strPtr(s string) *string { return &s }
// arnPolicyBackend serves one bucket policy, for the ARN-principal tests
// below. It is publicBucketPolicyBackend without the ACL half, which none of
// them reach.
type arnPolicyBackend struct {
backend.BackendUnsupported
policy string
}
func (b arnPolicyBackend) GetBucketPolicy(_ context.Context, _ string) ([]byte, error) {
return []byte(b.policy), nil
}
// arnPolicy builds a one-statement bucket policy granting or denying
// s3:GetObject on the test bucket to principal.
func arnPolicy(effect, principal string) string {
return `{"Version":"2012-10-17","Statement":[{"Effect":"` + effect + `","Principal":{"AWS":` +
principal + `},"Action":"s3:GetObject","Resource":"arn:aws:s3:::bucket/*"}]}`
}
const (
acPolicyUserArn = `"arn:aws:iam::000000000000:user/alice"`
acPolicyRoleArn = `"arn:aws:iam::000000000000:role/reader"`
acPolicySessionArn = `"arn:aws:sts::000000000000:assumed-role/reader/sess1"`
acPolicyRootArn = `"arn:aws:iam::000000000000:root"`
)
func acUser() Account {
return Account{Access: "AKIAALICE", Role: RoleUser, Arn: "arn:aws:iam::000000000000:user/alice"}
}
func acSession() Account {
return Account{
Access: "ASIASESSION",
Role: RoleUser,
IsSession: true,
Arn: "arn:aws:sts::000000000000:assumed-role/reader/sess1",
RoleArn: "arn:aws:iam::000000000000:role/reader",
}
}
// TestVerifyAccess_ArnPrincipalMatching walks every combination of principal
// form and caller that a bucket policy can express under an IAM backend
// whose identities have ARNs, with the identity policy silent throughout so
// that what each case measures is the Principal element alone.
func TestVerifyAccess_ArnPrincipalMatching(t *testing.T) {
tests := []struct {
name string
effect string
principal string
acc Account
wantAllow bool
wantDenyBy string
}{
{
name: "user named by its own arn", effect: "Allow", principal: acPolicyUserArn,
acc: acUser(), wantAllow: true,
},
{
name: "user not named", effect: "Allow", principal: acPolicyRoleArn,
acc: acUser(), wantDenyBy: "because no identity-based policy allows",
},
{
name: "access key id is no longer a principal", effect: "Allow", principal: `"AKIAALICE"`,
acc: acUser(), wantDenyBy: "because no identity-based policy allows",
},
{
name: "session named by its role arn", effect: "Allow", principal: acPolicyRoleArn,
acc: acSession(), wantAllow: true,
},
{
name: "session named by its own arn", effect: "Allow", principal: acPolicySessionArn,
acc: acSession(), wantAllow: true,
},
{
name: "another session of the same role", effect: "Allow",
principal: `"arn:aws:sts::000000000000:assumed-role/reader/sess2"`,
acc: acSession(), wantDenyBy: "because no identity-based policy allows",
},
{
name: "a user is not covered by a role arn", effect: "Allow", principal: acPolicyRoleArn,
acc: acUser(), wantDenyBy: "because no identity-based policy allows",
},
{
// The account principal delegates to the account's own IAM
// rather than granting, and the identity policy is silent here.
name: "account root arn allows nothing on its own", effect: "Allow", principal: acPolicyRootArn,
acc: acUser(), wantDenyBy: "because no identity-based policy allows",
},
{
name: "bare account id allows nothing on its own", effect: "Allow", principal: `"000000000000"`,
acc: acUser(), wantDenyBy: "because no identity-based policy allows",
},
{
name: "wildcard allows everyone", effect: "Allow", principal: `"*"`,
acc: acUser(), wantAllow: true,
},
{
name: "deny naming the user", effect: "Deny", principal: acPolicyUserArn,
acc: acUser(), wantDenyBy: "with an explicit deny in a resource-based policy",
},
{
// Deny is not a delegation: naming the account denies every
// principal in it outright.
name: "deny naming the account", effect: "Deny", principal: acPolicyRootArn,
acc: acUser(), wantDenyBy: "with an explicit deny in a resource-based policy",
},
{
name: "deny naming the account hits a session too", effect: "Deny", principal: acPolicyRootArn,
acc: acSession(), wantDenyBy: "with an explicit deny in a resource-based policy",
},
{
name: "deny naming the role hits its session", effect: "Deny", principal: acPolicyRoleArn,
acc: acSession(), wantDenyBy: "with an explicit deny in a resource-based policy",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
be := arnPolicyBackend{policy: arnPolicy(tt.effect, tt.principal)}
pe := newMockPolicyEvaluator(policyDecisionNoMatch)
pe.principalArn = tt.acc.Arn
err := VerifyAccess(testFiberCtx(t), be, AccessOptions{
Acc: tt.acc,
Bucket: "bucket",
Object: "key.txt",
Actions: []Action{GetObjectAction},
Iam: pe,
})
if tt.wantAllow {
assert.NoError(t, err)
return
}
apiErr := requireAccessDeniedAPIError(t, err)
assert.Contains(t, apiErr.Description, tt.wantDenyBy)
assert.Contains(t, apiErr.Description, tt.acc.Arn,
"a denial names the caller by its ARN once the IAM backend gives it one")
})
}
}
// TestVerifyAccess_AccountPrincipalDelegatesToIdentityPolicy is the other
// half of the account-principal rule: what it delegates to is the identity
// policy, so the same policy that granted nothing above grants once the
// identity policy allows.
func TestVerifyAccess_AccountPrincipalDelegatesToIdentityPolicy(t *testing.T) {
be := arnPolicyBackend{policy: arnPolicy("Allow", acPolicyRootArn)}
pe := newMockPolicyEvaluator(policyDecisionAllow)
err := VerifyAccess(testFiberCtx(t), be, AccessOptions{
Acc: acUser(),
Bucket: "bucket",
Object: "key.txt",
Actions: []Action{GetObjectAction},
Iam: pe,
})
assert.NoError(t, err)
}
// TestVerifyAccess_AccessKeyPrincipalsStillWorkWithoutArns pins the
// backward-compatible half: an account with no ARN — every IAM backend but
// the standalone service — is still matched by its access key id, and an ARN
// principal means nothing to it.
func TestVerifyAccess_AccessKeyPrincipalsStillWorkWithoutArns(t *testing.T) {
acc := Account{Access: "testuser", Role: RoleUser}
allowed := arnPolicyBackend{policy: arnPolicy("Allow", `"testuser"`)}
err := VerifyAccess(testFiberCtx(t), allowed, AccessOptions{
Acc: acc, Bucket: "bucket", Object: "key.txt",
Actions: []Action{GetObjectAction}, Iam: NewIAMServiceSingle(Account{}),
})
assert.NoError(t, err)
denied := arnPolicyBackend{policy: arnPolicy("Allow", acPolicyUserArn)}
err = VerifyAccess(testFiberCtx(t), denied, AccessOptions{
Acc: acc, Bucket: "bucket", Object: "key.txt",
Actions: []Action{GetObjectAction}, Iam: NewIAMServiceSingle(Account{}),
})
assert.Equal(t, s3err.GetAPIError(s3err.ErrAccessDenied), err)
}
// splitPolicy builds a bucket policy for acUser on every object in the test
// bucket: one Allow statement for allow and one Deny statement for deny,
// each left out when its list is empty.
func splitPolicy(allow, deny []Action) string {
var statements []map[string]any
for effect, actions := range map[string][]Action{"Allow": allow, "Deny": deny} {
if len(actions) > 0 {
statements = append(statements, map[string]any{
"Effect": effect,
"Principal": map[string]string{"AWS": acUser().Arn},
"Action": actions,
"Resource": "arn:aws:s3:::bucket/*",
})
}
}
b, err := json.Marshal(map[string]any{"Version": "2012-10-17", "Statement": statements})
if err != nil {
panic(err)
}
return string(b)
}
// TestVerifyAccess_ActionsAuthorizedIndependently covers a request needing
// several actions on one object, like a tagged PutObject. Each action is
// allowed by either the bucket policy or the identity policy on its own, so
// the two can split the actions between them. A session policy must also
// allow each action, and a denial names the first action not allowed.
func TestVerifyAccess_ActionsAuthorizedIndependently(t *testing.T) {
const resourceArn = "arn:aws:s3:::bucket/key.txt"
arn := acUser().Arn
put, tagging := PutObjectAction, PutObjectTaggingAction
tests := []struct {
name string
bucketAllow []Action
bucketDeny []Action
identity map[Action]policyDecision
session map[Action]policyDecision
want error
}{
{
name: "bucket policy grants s3:PutObject, identity policy the tagging",
bucketAllow: []Action{put},
identity: map[Action]policyDecision{tagging: policyDecisionAllow},
},
{
name: "identity policy grants s3:PutObject, bucket policy the tagging",
bucketAllow: []Action{tagging},
identity: map[Action]policyDecision{put: policyDecisionAllow},
},
{
name: "neither grants the tagging",
bucketAllow: []Action{put},
want: s3err.GetImplicitDenyAccessErr(arn, string(tagging), resourceArn),
},
{
name: "neither grants s3:PutObject",
bucketAllow: []Action{tagging},
want: s3err.GetImplicitDenyAccessErr(arn, string(put), resourceArn),
},
{
name: "neither grants either names the first action",
bucketAllow: []Action{GetObjectAction},
want: s3err.GetImplicitDenyAccessErr(arn, string(put), resourceArn),
},
{
name: "identity deny on the tagging overrides the bucket grant",
bucketAllow: []Action{put, tagging},
identity: map[Action]policyDecision{tagging: policyDecisionDeny},
want: s3err.GetExplicitDenyAccessErr(arn, string(tagging), resourceArn, "an identity-based policy"),
},
{
name: "bucket deny on the tagging overrides the identity grant",
bucketAllow: []Action{put},
bucketDeny: []Action{tagging},
identity: map[Action]policyDecision{put: policyDecisionAllow, tagging: policyDecisionAllow},
want: s3err.GetExplicitDenyAccessErr(arn, string(tagging), resourceArn, "a resource-based policy"),
},
{
name: "session policy filters each action of a split grant",
bucketAllow: []Action{put},
identity: map[Action]policyDecision{tagging: policyDecisionAllow},
session: map[Action]policyDecision{put: policyDecisionAllow},
want: s3err.GetImplicitDenyAccessErr(arn, string(tagging), resourceArn),
},
{
name: "session policy allowing both keeps a split grant",
bucketAllow: []Action{put},
identity: map[Action]policyDecision{tagging: policyDecisionAllow},
session: map[Action]policyDecision{put: policyDecisionAllow, tagging: policyDecisionAllow},
},
{
name: "session deny on the tagging overrides both grants",
bucketAllow: []Action{put, tagging},
session: map[Action]policyDecision{put: policyDecisionAllow, tagging: policyDecisionDeny},
want: s3err.GetExplicitDenyAccessErr(arn, string(tagging), resourceArn, "an identity-based policy"),
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
be := arnPolicyBackend{policy: splitPolicy(tt.bucketAllow, tt.bucketDeny)}
pe := &actionPolicyEvaluator{identity: tt.identity, session: tt.session, principalArn: arn}
err := VerifyAccess(testFiberCtx(t), be, AccessOptions{
Acc: acUser(),
Bucket: "bucket",
Object: "key.txt",
Actions: []Action{put, tagging},
Iam: pe,
})
if tt.want == nil {
assert.NoError(t, err)
} else {
assert.Equal(t, tt.want, err)
}
if len(tt.bucketDeny) == 0 && assert.Len(t, pe.calls, 1, "every action is evaluated in one round trip") {
assert.Equal(t, []Action{put, tagging}, pe.calls[0].actions)
}
})
}
}
// TestVerifyAccess_NoActionsFailsClosed pins that a request naming no
// action is refused rather than allowed because no action was denied.
func TestVerifyAccess_NoActionsFailsClosed(t *testing.T) {
be := arnPolicyBackend{policy: splitPolicy([]Action{GetObjectAction}, nil)}
err := VerifyAccess(testFiberCtx(t), be, AccessOptions{
Acc: acUser(),
Bucket: "bucket",
Object: "key.txt",
Iam: &actionPolicyEvaluator{},
})
assert.Error(t, err)
}
// TestVerifyAccess_BucketPolicyAndACL pins how a bucket's policy and ACL
// combine for a backend with no identity-policy layer: an explicit policy
// Deny wins, and otherwise each action is allowed by either the policy or
// the ACL. The owner may do anything the policy doesn't deny, and may
// always manage the policy itself. Any other grantee may do only what its
// ACL grant maps to.
func TestVerifyAccess_BucketPolicyAndACL(t *testing.T) {
const bucketArn = "arn:aws:s3:::bucket"
grant := func(access string, permission Permission) Grantee {
return Grantee{Access: access, Permission: permission, Type: types.TypeCanonicalUser}
}
acl := ACL{
Owner: "owner",
Grantees: []Grantee{
grant("owner", PermissionFullControl),
grant("reader", PermissionRead),
grant("writer", PermissionWrite),
grant("full", PermissionFullControl),
},
}
denyProbePolicy := `{"Statement":[{"Effect":"Deny","Principal":"*","Action":"s3:GetObject","Resource":"arn:aws:s3:::bucket/deny-probe/*"}]}`
denyAllPolicy := `{"Statement":[{"Effect":"Deny","Principal":"*","Action":"s3:*","Resource":["arn:aws:s3:::bucket","arn:aws:s3:::bucket/*"]}]}`
unrelatedPolicy := `{"Statement":[{"Effect":"Allow","Principal":{"AWS":["someone"]},"Action":"s3:GetObject","Resource":"arn:aws:s3:::bucket/*"}]}`
denyReaderListPolicy := `{"Statement":[{"Effect":"Deny","Principal":{"AWS":["reader"]},"Action":"s3:ListBucket","Resource":"arn:aws:s3:::bucket"}]}`
allowWriterPolicyPolicy := `{"Statement":[{"Effect":"Allow","Principal":{"AWS":["writer"]},"Action":"s3:PutBucketPolicy","Resource":"arn:aws:s3:::bucket"}]}`
accessDenied := s3err.GetAPIError(s3err.ErrAccessDenied)
tests := []struct {
name string
policy string
access string
object string
action Action
disableACL bool
want error
}{
{
name: "owner keeps actions a deny-only policy doesn't name",
policy: denyProbePolicy, access: "owner", object: "key", action: GetObjectAction,
},
{
name: "owner keeps bucket configuration under a deny-only policy",
policy: denyProbePolicy, access: "owner", action: GetBucketTaggingAction,
},
{
name: "owner is bound by an explicit deny",
policy: denyProbePolicy, access: "owner", object: "deny-probe/key", action: GetObjectAction,
want: s3err.GetExplicitDenyAccessErr("owner", string(GetObjectAction), "arn:aws:s3:::bucket/deny-probe/key", "a resource-based policy"),
},
{
name: "owner is bound by a deny of every action",
policy: denyAllPolicy, access: "owner", action: ListBucketAction,
want: s3err.GetExplicitDenyAccessErr("owner", string(ListBucketAction), bucketArn, "a resource-based policy"),
},
{
name: "owner reads the policy past a deny of every action",
policy: denyAllPolicy, access: "owner", action: GetBucketPolicyAction,
},
{
name: "owner replaces the policy past a deny of every action",
policy: denyAllPolicy, access: "owner", action: PutBucketPolicyAction,
},
{
name: "owner deletes the policy past a deny of every action",
policy: denyAllPolicy, access: "owner", action: DeleteBucketPolicyAction,
},
{
name: "owner's policy status read is bound by a deny of every action",
policy: denyAllPolicy, access: "owner", action: GetBucketPolicyStatusAction,
want: s3err.GetExplicitDenyAccessErr("owner", string(GetBucketPolicyStatusAction), bucketArn, "a resource-based policy"),
},
{
name: "owner without any ACL grant",
access: "owner", action: PutBucketVersioningAction, disableACL: true,
},
{
name: "ACL grant counts when the policy doesn't name the grantee",
policy: unrelatedPolicy, access: "reader", action: ListBucketAction,
},
{
name: "ACL grant counts on objects when the policy doesn't name the grantee",
policy: unrelatedPolicy, access: "reader", object: "key", action: GetObjectAction,
},
{
name: "explicit deny overrides an ACL grant",
policy: denyReaderListPolicy, access: "reader", action: ListBucketAction,
want: s3err.GetExplicitDenyAccessErr("reader", string(ListBucketAction), bucketArn, "a resource-based policy"),
},
{
name: "READ grants no s3:GetObjectAttributes",
access: "reader", object: "key", action: GetObjectAttributesAction,
want: accessDenied,
},
{
name: "READ grants no s3:GetObjectVersionAttributes",
access: "reader", object: "key", action: GetObjectVersionAttributesAction,
want: accessDenied,
},
{
name: "READ grants no bucket configuration",
access: "reader", action: GetBucketVersioningAction,
want: accessDenied,
},
{
name: "WRITE grants s3:PutObject",
access: "writer", object: "key", action: PutObjectAction,
},
{
name: "WRITE grants s3:DeleteObject",
access: "writer", object: "key", action: DeleteObjectAction,
},
{
name: "WRITE grants no s3:DeleteObjectVersion to a non-owner",
access: "writer", object: "key", action: DeleteObjectVersionAction,
want: accessDenied,
},
{
name: "WRITE grants no s3:PutBucketPolicy",
access: "writer", action: PutBucketPolicyAction,
want: accessDenied,
},
{
name: "WRITE grants no s3:PutObjectAcl",
access: "writer", object: "key", action: PutObjectAclAction,
want: accessDenied,
},
{
name: "WRITE grants no bucket deletion",
access: "writer", action: DeleteBucketAction,
want: accessDenied,
},
{
name: "policy grants s3:PutBucketPolicy to a non-owner",
policy: allowWriterPolicyPolicy, access: "writer", action: PutBucketPolicyAction,
},
{
name: "FULL_CONTROL grants s3:PutBucketAcl",
access: "full", action: PutBucketAclAction,
},
{
name: "FULL_CONTROL grants s3:GetObjectAcl",
access: "full", object: "key", action: GetObjectAclAction,
},
{
name: "FULL_CONTROL grants no s3:GetBucketPolicy",
access: "full", action: GetBucketPolicyAction,
want: accessDenied,
},
{
name: "FULL_CONTROL grants no s3:DeleteBucketPolicy past an unrelated policy",
policy: unrelatedPolicy, access: "full", action: DeleteBucketPolicyAction,
want: accessDenied,
},
{
name: "FULL_CONTROL grants no object tagging",
access: "full", object: "key", action: PutObjectTaggingAction,
want: accessDenied,
},
{
name: "disabled ACLs ignore every grant",
access: "full", object: "key", action: GetObjectAction, disableACL: true,
want: accessDenied,
},
{
name: "no grant and no policy",
access: "stranger", object: "key", action: GetObjectAction,
want: accessDenied,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
var be backend.Backend = noBucketPolicyBackend{}
if tt.policy != "" {
be = arnPolicyBackend{policy: tt.policy}
}
err := VerifyAccess(testFiberCtx(t), be, AccessOptions{
Acl: acl,
Acc: Account{Access: tt.access, Role: RoleUser},
Bucket: "bucket",
Object: tt.object,
Actions: []Action{tt.action},
DisableACL: tt.disableACL,
})
if tt.want == nil {
assert.NoError(t, err)
} else {
assert.Equal(t, tt.want, err)
}
})
}
}