Files
versitygw/auth/access-control_test.go
T
niksis02 11e10b45a8 feat: integrate standalone IAM service with S3 gateway for identity-based policy enforcement
Fixes #1327
Fixes #1567
Closes #2264

Wires the S3 gateway up to the standalone IAM service so identity policies, not just bucket policies and ACLs, are enforced on the S3 data plane. The gateway authenticates SigV4 requests by calling new private derive-signing-key and resolve-identity endpoints on the IAM service instead of holding secrets itself, and evaluates identity policy through the same PolicyEvaluator path added to auth.VerifyAccess, combined with the bucket policy using explicit-deny-wins precedence. The private endpoints are served over their own mTLS listener (new iamapi/private package, genmtlscerts.sh to generate test material, and client-cert support in internal/netutil), separate from the public IAM API. As part of this the vendored aws/signer/v4 package is deleted and replaced by a pure-Go SigV4 implementation in internal/sigv4auth, which now reads canonical request data directly off the fiber.Ctx instead of reconstructing an http.Request, and is shared by both the S3 request-signing verification and the new private-endpoint signing.

DeleteObjects moves from an all-or-nothing authorization check to true partial success: VerifyObjectsAccess evaluates every object in a batch independently against both the identity policy and any object lock, so a denial or a locked object only removes that key from the batch instead of failing the whole request. It also batches the identity-policy round trip and the bucket-policy fetch once per request rather than once per object, and separates plain deletes from versioned ones since a versioned delete needs s3:DeleteObjectVersion rather than s3:DeleteObject. Object lock handling got a few correctness fixes alongside this: a bypass is now modeled as BypassNone/BypassRequested/BypassOverwrite rather than a single bool, because root's blanket ability to override a GOVERNANCE retention should only apply when the client actually asked to bypass it (DeleteObject/DeleteObjects/PutObjectRetention), not when the gateway is silently replacing a locked object via an overwrite, which needs the permission from everyone including root. Retention changes are now correctly classified as an extension (allowed under plain s3:PutObjectRetention) versus a weakening (date or mode change, which needs the bypass permission), and a COMPLIANCE lock can never be weakened by anyone regardless of permissions, matching AWS. Separately, VerifyObjectCopyAccess had a readonly-mode gap: it returned early for root/admin before ever calling VerifyAccess, so the readonly check inside VerifyAccess never ran for them on CopyObject; access checks are now ordered so the readonly gate always applies before any root/admin bypass, for copy as well as every other write path.

Bucket policies also gained Condition block support, via a new shared internal/condition package moved out of the IAM policy package since both bucket and identity policies share the same evaluation semantics. It implements the full AWS operator set — String{Equals,NotEquals,EqualsIgnoreCase,NotEqualsIgnoreCase,Like,NotLike}, Numeric{Equals,NotEquals,LessThan,LessThanEquals,GreaterThan,GreaterThanEquals}, Date{Equals,NotEquals,LessThan,LessThanEquals,GreaterThan,GreaterThanEquals}, Bool, BinaryEquals, Arn{Equals,Like,NotEquals,NotLike}, IpAddress/NotIpAddress, and Null — along with the ForAllValues/ForAnyValue set qualifiers and the IfExists modifier. A new requestConditionContext builds the per-request keys a bucket policy's Condition block can reference — aws:SourceIp, aws:SecureTransport, aws:CurrentTime, aws:EpochTime, aws:UserAgent, aws:Referer, s3:prefix, s3:delimiter, s3:max-keys, s3:x-amz-acl, s3:VersionId — following AWS's own per-action rules for which keys a given S3 operation actually populates. Identity-derived keys such as aws:PrincipalArn and aws:username are deliberately left unwired here, since the gateway has no way to know them; the standalone IAM service fills those in itself when it evaluates an identity policy.

Also added new integration test suites for S3-side IAM: s3_iam_access_control.go and s3_iam_session_access_control.go cover identity-policy enforcement and session-credential requests against real S3 operations, alongside expanded OIDC/web-identity coverage and a new runoidctests.sh runner wired into the OIDC GitHub Actions workflow.
2026-08-15 18:27:50 +04:00

724 lines
26 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")
}
// 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)
}
}
func strPtr(s string) *string { return &s }