Files
versitygw/auth/access-control_test.go
T
niksis02 c84c5f645a feat: accept principal ARNs in bucket policies under standalone IAM
Bucket policy `Principal` named callers by access key id. Under the standalone IAM service it now names them by AWS-style ARN, as real S3 does: a user ARN, a role ARN (covering every session of that role), an assumed-role ARN (covering one session), the account root ARN or bare account id, or `*`. Every other IAM backend has no ARNs to name anything by and keeps access-key principals unchanged, gated on a new `auth.PrincipalResolver` capability interface that only the standalone client implements.

`auth.Account` carries `Arn` and `RoleArn`, filled at authentication time, so a session can be matched against both its own ARN and its role's. Principals are validated at PutBucketPolicy time through a new `/private/resolve-principals` endpoint, which rejects anything that does not name a live identity with `MalformedPolicy: Invalid principal in policy`.

An `Allow` naming the account root ARN or bare account id delegates to the account's own IAM rather than granting on its own, while a `Deny` naming it denies every principal in the account outright. Denial messages now name the caller by ARN wherever one exists.

Also fixes `aws:PrincipalArn` for assumed-role sessions, which reported the session ARN where AWS reports the role's, and stops an unreachable IAM service being reported as a malformed policy.
2026-09-03 23:51:55 +04:00

1104 lines
42 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,
},
},
}
}
// 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, 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, 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, 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 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)
}
// actionSplitPolicyEvaluator denies exactly one action and allows every
// other, recording each EvaluatePolicy call it receives — for asserting not
// just the outcome but that DeleteObjects' mixed batch was split into one
// call per action rather than evaluated as a single undifferentiated batch.
type actionSplitPolicyEvaluator struct {
IAMService
denyAction Action
calls []evaluatePolicyCall
}
func (m *actionSplitPolicyEvaluator) 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, a := range actions {
if a == m.denyAction {
decisions[i][j] = policyDecisionNoMatch
} else {
decisions[i][j] = policyDecisionAllow
}
}
}
return PolicyEvaluation{Decisions: decisions}, nil
}
func TestVerifyObjectsAccess_VersionedDeleteNeedsSeparatePermission(t *testing.T) {
be := noObjectLockBackend{noBucketPolicyBackend{srcAcl: ACL{Owner: "someone-else"}}}
pe := &actionSplitPolicyEvaluator{denyAction: DeleteObjectVersionAction}
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)
}