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.
This commit is contained in:
niksis02
2026-09-03 23:51:55 +04:00
parent 4a22b6d9d7
commit c84c5f645a
27 changed files with 2453 additions and 131 deletions
+14 -4
View File
@@ -291,7 +291,7 @@ func objectsAccessErrors(ctx context.Context, be backend.Backend, opts AccessOpt
allDenied := true
for i, rd := range resourceDecisions {
if rd.Decision == policyDecisionDeny {
errs[i] = s3err.GetExplicitDenyAccessErr(opts.Acc.Access, string(rd.Action), objectPolicyArn(opts.Bucket, keys[i], be.NormalizeObjectKey), "a resource-based policy")
errs[i] = s3err.GetExplicitDenyAccessErr(principalName(opts.Acc), string(rd.Action), objectPolicyArn(opts.Bucket, keys[i], be.NormalizeObjectKey), "a resource-based policy")
continue
}
allDenied = false
@@ -329,7 +329,7 @@ func objectsAccessErrors(ctx context.Context, be backend.Backend, opts AccessOpt
principal := identity.PrincipalArn
if principal == "" {
principal = opts.Acc.Access
principal = principalName(opts.Acc)
}
for i := range keys {
@@ -380,6 +380,16 @@ func objectsAccessErrors(ctx context.Context, be backend.Backend, opts AccessOpt
return errs, nil
}
// principalName is how a denial message names acc: by its principal ARN
// where the IAM backend gives it one, and by its access key id otherwise —
// the only name the other backends have for it.
func principalName(acc Account) string {
if acc.Arn != "" {
return acc.Arn
}
return acc.Access
}
// decisionForResource is one resource's tri-state decision plus, for
// Deny/NoMatch, the specific action responsible — so the caller can build an
// AWS-shaped message naming it.
@@ -413,7 +423,7 @@ func verifyResourceAccess(ctx context.Context, be backend.Backend, opts AccessOp
}
for i, object := range objects {
decision, action, err := verifyBucketPolicy(policy, opts.Acc.Access, opts.Bucket, object, condCtx, be.NormalizeObjectKey, opts.Actions...)
decision, action, err := verifyBucketPolicy(policy, opts.Acc, opts.Bucket, object, condCtx, be.NormalizeObjectKey, opts.Actions...)
if err != nil {
return nil, err
}
@@ -616,7 +626,7 @@ func verifyIdentityOnlyAccess(ctx fiber.Ctx, pe PolicyEvaluator, acc Account, ac
principal := identity.PrincipalArn
if principal == "" {
principal = acc.Access
principal = principalName(acc)
}
// A session policy narrows what the session may do; there is no resource
+183
View File
@@ -918,3 +918,186 @@ func TestVerifyObjectsAccess_ResourceDenyNoPolicyEvaluator(t *testing.T) {
}
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)
}
+39 -7
View File
@@ -110,7 +110,7 @@ func (bp *BucketPolicy) Validate(bucket string, iam IAMService) error {
return nil
}
// decisionFor evaluates a single action against bp for principal/resource,
// decisionFor evaluates a single action against bp for acc/resource,
// returning the tri-state policyDecision. A statement whose principal/action/resource
// otherwise matches but whose Condition block can't be evaluated
// denies the whole decision immediately, regardless of that statement's own
@@ -121,10 +121,10 @@ func (bp *BucketPolicy) Validate(bucket string, iam IAMService) error {
// Condition write-time validation existed — it only guards a document
// stored before that validation existed, or naming a future operator the
// gateway doesn't yet recognize.
func (bp *BucketPolicy) decisionFor(principal string, action Action, resource string, condCtx map[string][]string, normalizeObjectKey objectKeyNormalizer) policyDecision {
func (bp *BucketPolicy) decisionFor(acc Account, action Action, resource string, condCtx map[string][]string, normalizeObjectKey objectKeyNormalizer) policyDecision {
var isAllowed bool
for _, statement := range bp.Statement {
matched, evaluable := statement.findMatch(principal, action, resource, condCtx, bp.Version, normalizeObjectKey)
matched, evaluable := statement.findMatch(acc, action, resource, condCtx, bp.Version, normalizeObjectKey)
if !evaluable {
return policyDecisionDeny
}
@@ -236,13 +236,38 @@ func (bpi *BucketPolicyItem) Validate(bucket string, iam IAMService) error {
// this request, and — only when they do — whether its Condition block holds
// against condCtx. matched is only meaningful when evaluable is true; see
// condition.Evaluate and decisionFor's fail-closed handling of evaluable =false.
func (bpi *BucketPolicyItem) findMatch(principal string, action Action, resource string, condCtx map[string][]string, version PolicyVersion, normalizeObjectKey objectKeyNormalizer) (matched bool, evaluable bool) {
if !(bpi.Principals.Contains(principal) && bpi.Actions.FindMatch(action) && bpi.Resources.FindMatch(resource, normalizeObjectKey)) {
func (bpi *BucketPolicyItem) findMatch(acc Account, action Action, resource string, condCtx map[string][]string, version PolicyVersion, normalizeObjectKey objectKeyNormalizer) (matched bool, evaluable bool) {
if !(bpi.matchesPrincipal(acc) && bpi.Actions.FindMatch(action) && bpi.Resources.FindMatch(resource, normalizeObjectKey)) {
return false, true
}
return condition.Evaluate(bpi.Condition, condCtx, string(version))
}
// matchesPrincipal reports whether this statement's Principal element
// covers acc, given what this statement's Effect makes of an account-level
// match.
//
// A statement naming the account — its root ARN, or the bare account id —
// only delegates to the account: it says the account's own IAM may grant
// this, not that this is granted. So it allows nothing by itself, and a
// caller under it is authorized only if an identity policy independently
// allows the request, which VerifyAccess already covers by treating either
// source's Allow as sufficient. Skipping the statement here is what makes
// the account-level Allow contribute nothing.
//
// Deny is not symmetric with that: a statement denying the account denies
// every principal in it outright, delegating nothing.
func (bpi *BucketPolicyItem) matchesPrincipal(acc Account) bool {
switch bpi.Principals.matchFor(acc) {
case principalDirectMatch:
return true
case principalAccountMatch:
return bpi.Effect == BucketPolicyAccessTypeDeny
default:
return false
}
}
// isPublicFor checks if the bucket policy statement grants public access
// for given resource and action, and — only when it otherwise matches —
// whether its Condition block holds against condCtx. A public statement's
@@ -297,6 +322,13 @@ func ValidatePolicyDocument(policyBin []byte, bucket string, iam IAMService) err
}
if err := policy.Validate(bucket, iam); err != nil {
var lookupErr principalLookupError
if errors.As(err, &lookupErr) {
// Not a defect in the document: the IAM service could not be
// asked whether its principals exist. Report that as itself
// rather than telling the caller their policy is malformed.
return lookupErr.err
}
return getMalformedPolicyError(err)
}
@@ -310,7 +342,7 @@ func ValidatePolicyDocument(policyBin []byte, bucket string, iam IAMService) err
// Allow only if every action has a matching Allow; otherwise NoMatch,
// paired with the first action that lacked one. Zero actions is
// conservatively NoMatch, not vacuously Allow.
func verifyBucketPolicy(policyBytes []byte, access, bucket, object string, condCtx map[string][]string, normalizeObjectKey objectKeyNormalizer, actions ...Action) (policyDecision, Action, error) {
func verifyBucketPolicy(policyBytes []byte, acc Account, bucket, object string, condCtx map[string][]string, normalizeObjectKey objectKeyNormalizer, actions ...Action) (policyDecision, Action, error) {
if len(actions) == 0 {
return policyDecisionNoMatch, "", nil
}
@@ -325,7 +357,7 @@ func verifyBucketPolicy(policyBytes []byte, access, bucket, object string, condC
result := policyDecisionAllow
var blamed Action
for _, action := range actions {
switch d := bp.decisionFor(access, action, resource, condCtx, normalizeObjectKey); d {
switch d := bp.decisionFor(acc, action, resource, condCtx, normalizeObjectKey); d {
case policyDecisionDeny:
return policyDecisionDeny, action, nil
case policyDecisionNoMatch:
+122 -8
View File
@@ -16,10 +16,29 @@ package auth
import (
"encoding/json"
"strings"
)
type Principals map[string]struct{}
// principalMatch is how strongly a Principal element matched a caller. The
// three cases are not interchangeable: a statement naming the account
// (principalAccountMatch) delegates rather than grants, so it means different things
// under Allow and under Deny
type principalMatch int
const (
// principalNoMatch means the statement does not cover this caller.
principalNoMatch principalMatch = iota
// principalAccountMatch means the statement names the caller's account
// — its root ARN, or the bare account id — and so covers the caller
// only in the delegating sense.
principalAccountMatch
// principalDirectMatch means the statement names the caller itself, the
// role it assumed, or every principal ("*").
principalDirectMatch
)
func (p Principals) Add(key string) {
p[key] = struct{}{}
}
@@ -90,7 +109,13 @@ func (p Principals) ToSlice() []string {
return principals
}
// Validates Principals by checking user account access keys existence
// Validate checks that every principal named here exists, so a policy can
// never be stored naming somebody who cannot be matched. What a principal
// *is* depends on the IAM backend: an AWS-style ARN for a backend that
// implements PrincipalResolver, and an access key id for every other one.
//
// The wildcard is checked the same way either way: "*" is only valid alone,
// never mixed with named principals.
func (p Principals) Validate(iam IAMService) error {
_, containsWildCard := p["*"]
if containsWildCard {
@@ -100,6 +125,17 @@ func (p Principals) Validate(iam IAMService) error {
return policyErrInvalidPrincipal
}
if pr, ok := iam.(PrincipalResolver); ok {
invalid, err := pr.ResolvePrincipals(p.ToSlice())
if err != nil {
return principalLookupError{err}
}
if len(invalid) > 0 {
return policyErrInvalidPrincipal
}
return nil
}
accs, err := iam.ResolveAccounts(p.ToSlice())
if err != nil {
return err
@@ -111,15 +147,74 @@ func (p Principals) Validate(iam IAMService) error {
return nil
}
func (p Principals) Contains(userAccess string) bool {
// "*" means it matches for any user account
_, ok := p["*"]
if ok {
return true
// principalLookupError reports that the IAM service could not be asked
// whether a policy's principals exist, as distinct from its answering that
// one of them does not. Validating an ARN principal is a network call, so
// this is the difference between telling an operator their IAM service is
// unreachable and telling a user their policy is malformed
type principalLookupError struct{ err error }
func (e principalLookupError) Error() string { return e.err.Error() }
func (e principalLookupError) Unwrap() error { return e.err }
// matchFor reports how this Principal element covers acc.
//
// An account whose IAM backend gives it an ARN (acc.Arn set, i.e. the
// standalone IAM service) is matched by ARN, the way real S3 does it:
//
// - "*" matches everyone, authenticated or not.
// - The caller's own ARN matches it and nothing else. For an assumed-role
// session that is arn:aws:sts::…:assumed-role/<role>/<session>, so a
// statement naming one session does not cover another session of the
// same role.
// - A session's role ARN matches every session of that role, which is the
// only way to name them all: no wildcard is allowed inside an ARN.
// - The account's root ARN, and the bare account id, match as a
// delegation rather than as a grant. The gateway's own root account is
// the exception, and not a special case: the account root ARN is its
// own ARN, so it matches root directly and the account only by
// delegation, which is what the same string means for each of them.
//
// Everything else is left to the IAM backend to have rejected at
// PutBucketPolicy time; matching is a plain string comparison, and
// deliberately so — no case folding, no whitespace trimming, no wildcards
// within an ARN.
//
// An account with no ARN — every other IAM backend — is matched by access
// key id exactly as it always has been.
func (p Principals) matchFor(acc Account) principalMatch {
if _, ok := p["*"]; ok {
return principalDirectMatch
}
_, found := p[userAccess]
return found
if acc.Arn == "" {
if _, found := p[acc.Access]; found {
return principalDirectMatch
}
return principalNoMatch
}
if _, found := p[acc.Arn]; found {
return principalDirectMatch
}
if acc.RoleArn != "" {
if _, found := p[acc.RoleArn]; found {
return principalDirectMatch
}
}
accountID := accountIDFromArn(acc.Arn)
if accountID == "" {
return principalNoMatch
}
if _, found := p[accountID]; found {
return principalAccountMatch
}
if _, found := p[accountRootArn(accountID)]; found {
return principalAccountMatch
}
return principalNoMatch
}
// Bucket policy grants public access, if it contains
@@ -128,3 +223,22 @@ func (p Principals) isPublic() bool {
_, ok := p["*"]
return ok
}
// accountIDFromArn returns the account id field of arn, or "" if arn isn't
// shaped like one. It reads the caller's own ARN, which the IAM service
// built, so it needs to recognize no more than the two shapes that service
// produces: arn:<partition>:<service>::<account>:<resource>.
func accountIDFromArn(arn string) string {
const fields = 6
parts := strings.SplitN(arn, ":", fields)
if len(parts) != fields || parts[0] != "arn" {
return ""
}
return parts[4]
}
// accountRootArn builds the ARN naming an account itself, the principal
// form that delegates to it.
func accountRootArn(accountID string) string {
return "arn:aws:iam::" + accountID + ":root"
}
+190 -6
View File
@@ -91,16 +91,200 @@ func TestPrincipals_Validate(t *testing.T) {
}
}
func TestPrincipals_Contains(t *testing.T) {
p := Principals{"user1": {}}
assert.True(t, p.Contains("user1"))
assert.False(t, p.Contains("user2"))
// mockPrincipalResolver is an IAMService that names its identities by ARN,
// standing in for the standalone IAM service client: it reports every
// principal not in valid as unresolvable, the way ResolvePrincipals does.
type mockPrincipalResolver struct {
IAMService
valid map[string]bool
err error
calls [][]string
}
p = Principals{"*": {}}
assert.True(t, p.Contains("anyuser"))
func (m *mockPrincipalResolver) ResolvePrincipals(principals []string) ([]string, error) {
m.calls = append(m.calls, principals)
if m.err != nil {
return nil, m.err
}
invalid := []string{}
for _, p := range principals {
if !m.valid[p] {
invalid = append(invalid, p)
}
}
return invalid, nil
}
func newMockPrincipalResolver(valid ...string) *mockPrincipalResolver {
set := make(map[string]bool, len(valid))
for _, v := range valid {
set[v] = true
}
return &mockPrincipalResolver{IAMService: NewIAMServiceSingle(Account{}), valid: set}
}
const (
testUserArn = "arn:aws:iam::000000000000:user/alice"
testRoleArn = "arn:aws:iam::000000000000:role/reader"
testSessionArn = "arn:aws:sts::000000000000:assumed-role/reader/sess1"
testRootArn = "arn:aws:iam::000000000000:root"
)
// TestPrincipals_ValidateWithPrincipalResolver covers the write-time check
// for an ARN-naming backend: the resolver decides, ResolveAccounts is never
// consulted, and the wildcard rules are unchanged.
func TestPrincipals_ValidateWithPrincipalResolver(t *testing.T) {
tests := []struct {
name string
principals Principals
valid []string
err error
}{
{"resolvable user arn", Principals{testUserArn: {}}, []string{testUserArn}, nil},
{"unresolvable arn", Principals{testUserArn: {}}, nil, policyErrInvalidPrincipal},
{"access key id is not a principal", Principals{"AKIAEXAMPLE": {}}, []string{testUserArn}, policyErrInvalidPrincipal},
{"one of several unresolvable", Principals{testUserArn: {}, testRoleArn: {}}, []string{testUserArn}, policyErrInvalidPrincipal},
{"all resolvable", Principals{testUserArn: {}, testRoleArn: {}}, []string{testUserArn, testRoleArn}, nil},
{"only wildcard", Principals{"*": {}}, nil, nil},
{"wildcard mixed with an arn", Principals{"*": {}, testUserArn: {}}, []string{testUserArn}, policyErrInvalidPrincipal},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
resolver := newMockPrincipalResolver(tt.valid...)
assert.EqualValues(t, tt.err, tt.principals.Validate(resolver))
})
}
}
// TestPrincipals_ValidateResolverErrorPropagates pins that a resolver
// failure surfaces as itself rather than as an invalid-principal verdict:
// an unreachable IAM service must not read as "this principal does not
// exist".
func TestPrincipals_ValidateResolverErrorPropagates(t *testing.T) {
resolver := newMockPrincipalResolver()
resolver.err = assert.AnError
err := Principals{testUserArn: {}}.Validate(resolver)
assert.ErrorIs(t, err, assert.AnError)
}
// TestPrincipals_ValidateWildcardSkipsResolver pins that a lone "*" costs no
// round trip - it names everyone, so there is nothing to resolve.
func TestPrincipals_ValidateWildcardSkipsResolver(t *testing.T) {
resolver := newMockPrincipalResolver()
assert.NoError(t, Principals{"*": {}}.Validate(resolver))
assert.Empty(t, resolver.calls)
}
func TestPrincipals_matchForAccessKey(t *testing.T) {
user := Account{Access: "user1"}
assert.Equal(t, principalDirectMatch, Principals{"user1": {}}.matchFor(user))
assert.Equal(t, principalNoMatch, Principals{"user2": {}}.matchFor(user))
assert.Equal(t, principalDirectMatch, Principals{"*": {}}.matchFor(user))
// An ARN means nothing to a backend whose identities are access keys.
assert.Equal(t, principalNoMatch, Principals{testUserArn: {}}.matchFor(user))
}
func TestPrincipals_matchForArn(t *testing.T) {
user := Account{Access: "AKIAEXAMPLE", Arn: testUserArn}
session := Account{Access: "ASIAEXAMPLE", Arn: testSessionArn, RoleArn: testRoleArn, IsSession: true}
root := Account{Access: "root", Arn: testRootArn}
tests := []struct {
name string
principals Principals
acc Account
want principalMatch
}{
{"user named by its own arn", Principals{testUserArn: {}}, user, principalDirectMatch},
{"user not named", Principals{testRoleArn: {}}, user, principalNoMatch},
{"wildcard", Principals{"*": {}}, user, principalDirectMatch},
{"access key id no longer matches", Principals{"AKIAEXAMPLE": {}}, user, principalNoMatch},
{"account id delegates", Principals{"000000000000": {}}, user, principalAccountMatch},
{"account root arn delegates", Principals{testRootArn: {}}, user, principalAccountMatch},
{"another account does not match", Principals{"arn:aws:iam::111111111111:root": {}}, user, principalNoMatch},
{"session named by its role arn", Principals{testRoleArn: {}}, session, principalDirectMatch},
{"session named by its own arn", Principals{testSessionArn: {}}, session, principalDirectMatch},
{"another session of the same role", Principals{"arn:aws:sts::000000000000:assumed-role/reader/sess2": {}}, session, principalNoMatch},
{"session under account delegation", Principals{testRootArn: {}}, session, principalAccountMatch},
{"root matches the account root arn directly", Principals{testRootArn: {}}, root, principalDirectMatch},
{"matching is case sensitive", Principals{"arn:aws:iam::000000000000:user/ALICE": {}}, user, principalNoMatch},
{"no wildcard within an arn", Principals{"arn:aws:iam::000000000000:user/*": {}}, user, principalNoMatch},
{"no whitespace trimming", Principals{" " + testUserArn: {}}, user, principalNoMatch},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
assert.Equal(t, tt.want, tt.principals.matchFor(tt.acc))
})
}
}
// TestMatchesPrincipal_AccountDelegation pins the asymmetry between Allow
// and Deny for an account-level principal: it delegates to the account's own
// IAM under Allow, granting nothing by itself, but denies the account's
// principals outright under Deny.
func TestMatchesPrincipal_AccountDelegation(t *testing.T) {
user := Account{Access: "AKIAEXAMPLE", Arn: testUserArn}
allow := &BucketPolicyItem{Effect: BucketPolicyAccessTypeAllow, Principals: Principals{testRootArn: {}}}
deny := &BucketPolicyItem{Effect: BucketPolicyAccessTypeDeny, Principals: Principals{testRootArn: {}}}
assert.False(t, allow.matchesPrincipal(user))
assert.True(t, deny.matchesPrincipal(user))
// A statement naming the user itself grants under either effect.
allow.Principals = Principals{testUserArn: {}}
deny.Principals = Principals{testUserArn: {}}
assert.True(t, allow.matchesPrincipal(user))
assert.True(t, deny.matchesPrincipal(user))
}
func TestAccountIDFromArn(t *testing.T) {
tests := []struct {
arn string
want string
}{
{testUserArn, "000000000000"},
{testSessionArn, "000000000000"},
{testRootArn, "000000000000"},
{"arn:aws:iam::123456789012:role/some/path/name", "123456789012"},
{"", ""},
{"not-an-arn", ""},
{"arn:aws:iam::000000000000", ""},
{"xrn:aws:iam::000000000000:root", ""},
}
for _, tt := range tests {
t.Run(tt.arn, func(t *testing.T) {
assert.Equal(t, tt.want, accountIDFromArn(tt.arn))
})
}
}
func TestPrincipals_isPublic(t *testing.T) {
assert.True(t, Principals{"*": {}}.isPublic())
assert.False(t, Principals{"user1": {}}.isPublic())
}
// TestValidatePolicyDocument_ResolverFailureIsNotMalformed pins that a
// policy whose principals could not be checked — because the IAM service
// failed, not because the document is wrong — is not reported as a
// malformed policy. Principal validation is a network call for an
// ARN-naming backend, so this is the difference between telling an operator
// their IAM service is down and telling a user their policy is invalid.
func TestValidatePolicyDocument_ResolverFailureIsNotMalformed(t *testing.T) {
resolver := newMockPrincipalResolver()
resolver.err = assert.AnError
doc := []byte(`{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Principal":{"AWS":"` +
testUserArn + `"},"Action":"s3:GetObject","Resource":"arn:aws:s3:::bucket/*"}]}`)
err := ValidatePolicyDocument(doc, "bucket", resolver)
assert.ErrorIs(t, err, assert.AnError)
// A genuine document defect still reports as MalformedPolicy.
resolver.err = nil
err = ValidatePolicyDocument(doc, "bucket", resolver)
assert.Equal(t, getMalformedPolicyError(policyErrInvalidPrincipal), err)
}
+2 -2
View File
@@ -98,7 +98,7 @@ func TestBucketPolicyDecision_Condition(t *testing.T) {
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
decision, _, err := verifyBucketPolicy([]byte(tt.policy), "someaccess", "mybucket", tt.object, tt.condCtx, nil, tt.action)
decision, _, err := verifyBucketPolicy([]byte(tt.policy), Account{Access: "someaccess"}, "mybucket", tt.object, tt.condCtx, nil, tt.action)
assert.NoError(t, err)
assert.Equal(t, tt.want, decision)
})
@@ -115,7 +115,7 @@ func TestBucketPolicyDecision_UnevaluableConditionFailsClosed(t *testing.T) {
"Action":"s3:GetObject","Resource":"arn:aws:s3:::mybucket/*",
"Condition":{"SomeFutureOperator":{"aws:UserAgent":"good-agent"}}}]}`
decision, _, err := verifyBucketPolicy([]byte(policy), "someaccess", "mybucket", "key", nil, nil, GetObjectAction)
decision, _, err := verifyBucketPolicy([]byte(policy), Account{Access: "someaccess"}, "mybucket", "key", nil, nil, GetObjectAction)
assert.NoError(t, err)
assert.Equal(t, policyDecisionDeny, decision)
}
+7
View File
@@ -47,6 +47,12 @@ func ResolveFixedBucketOwner(iam IAMService) (Account, bool) {
// object writes then land with the same ownership as the buckets root owns.
// Backends that do not fix ownership resolve a real per-account uid/gid for
// every other account and keep root exactly as it was.
//
// The same backend is also the one that knows root's principal ARN, which
// the S3 request path likewise cannot derive: root is the only identity
// resolved locally rather than through the IAM service, so without this it
// would reach bucket-policy matching unnamed and a statement naming the
// account root ARN would miss it.
func rootIdentity(iam IAMService, root Account) Account {
owner, fixed := ResolveFixedBucketOwner(iam)
if !fixed || owner.Access != root.Access {
@@ -56,5 +62,6 @@ func rootIdentity(iam IAMService, root Account) Account {
root.UserID = owner.UserID
root.GroupID = owner.GroupID
root.ProjectID = owner.ProjectID
root.Arn = owner.Arn
return root
}
+17 -2
View File
@@ -84,6 +84,21 @@ type Account struct {
// IAM backend nor echoed by the admin API.
SessionToken string `json:"-"`
IsSession bool `json:"-"`
// Arn and RoleArn name this account the way a bucket policy's Principal
// element does, and are set only by IAM backends whose identities have
// ARNs at all — currently just the standalone IAM service client. Arn is
// the caller's own ARN (an IAM user's, or a session's
// arn:aws:sts::…:assumed-role/<role>/<session>); RoleArn is the ARN of
// the role a session assumed, and is empty for everything else.
//
// Both are needed to match a session, because a Principal naming a role
// matches every session of that role while one naming a session matches
// only that session. When Arn is empty the gateway matches principals by
// access key id instead, which is what every other backend has always
// done — see Principals.matchFor.
Arn string `json:"-"`
RoleArn string `json:"-"`
}
// String elides the two credential-bearing fields so an Account can't leak
@@ -91,8 +106,8 @@ type Account struct {
// X-Amz-Security-Token *header*, which does nothing for a struct printed
// after the token has been parsed out of it.
func (a Account) String() string {
return fmt.Sprintf("Account{Access:%s, Secret:REDACTED, Role:%s, UserID:%d, GroupID:%d, ProjectID:%d, SessionToken:REDACTED, IsSession:%t}",
a.Access, a.Role, a.UserID, a.GroupID, a.ProjectID, a.IsSession)
return fmt.Sprintf("Account{Access:%s, Secret:REDACTED, Role:%s, UserID:%d, GroupID:%d, ProjectID:%d, SessionToken:REDACTED, IsSession:%t, Arn:%s, RoleArn:%s}",
a.Access, a.Role, a.UserID, a.GroupID, a.ProjectID, a.IsSession, a.Arn, a.RoleArn)
}
type ListUserAccountsResult struct {
+40 -3
View File
@@ -122,6 +122,12 @@ type IAMServiceStandalone struct {
secret string
rootAcc Account
cfg IAMServiceStandaloneConfig
// accountID is the AWS account id the IAM service reported at startup,
// the one every ARN it mints belongs to. It is only used to name the
// gateway's own root account, which the service has no record of and so
// cannot name itself. Written once by probeProtocol before this client
// serves anything, and read-only afterwards.
accountID string
}
var (
@@ -129,6 +135,7 @@ var (
_ SigningKeyProvider = (*IAMServiceStandalone)(nil)
_ PolicyEvaluator = (*IAMServiceStandalone)(nil)
_ FixedBucketOwner = (*IAMServiceStandalone)(nil)
_ PrincipalResolver = (*IAMServiceStandalone)(nil)
)
// NewIAMServiceStandalone constructs the standalone IAM service client.
@@ -201,6 +208,7 @@ func (s *IAMServiceStandalone) probeProtocol() error {
}
if err == nil {
s.accountID = resp.AccountID
serverVersion := resp.ServerVersion
if serverVersion == "" {
serverVersion = "unknown"
@@ -437,11 +445,16 @@ func (s *IAMServiceStandalone) DeriveSigningKey(access, sessionToken, date, regi
return nil, Account{}, err
}
return resp.DerivedKey, s.accountFor(access, sessionToken), nil
acc := s.accountFor(access, sessionToken)
acc.Arn = resp.PrincipalArn
acc.RoleArn = resp.RoleArn
return resp.DerivedKey, acc, nil
}
// accountFor builds the Account metadata DeriveSigningKey/GetUserAccount
// return for a resolved non-root identity
// return for a resolved non-root identity. Arn/RoleArn are left to the
// caller: only the endpoints that resolved the identity know them.
func (s *IAMServiceStandalone) accountFor(access, sessionToken string) Account {
return Account{
Access: access,
@@ -454,6 +467,25 @@ func (s *IAMServiceStandalone) accountFor(access, sessionToken string) Account {
}
}
// ResolvePrincipals implements PrincipalResolver, validating a bucket
// policy's principals in a single round trip regardless of how many it
// names.
func (s *IAMServiceStandalone) ResolvePrincipals(principals []string) ([]string, error) {
if len(principals) == 0 {
return nil, nil
}
var resp private.ResolvePrincipalsResponse
err := s.doPrivateRequest(private.ResolvePrincipalsPath, private.ResolvePrincipalsRequest{
Principals: principals,
}, &resp)
if err != nil {
return nil, fmt.Errorf("resolve policy principals: %w", err)
}
return resp.Invalid, nil
}
// EvaluatePolicy implements PolicyEvaluator, evaluating every action in
// actions against resource in a single request rather than one round trip
// per action.
@@ -622,13 +654,15 @@ func (s *IAMServiceStandalone) resolveAccountDetails(accesses []string) ([]resol
if !identity.Found {
continue
}
acc := s.accountFor(remote[i], "")
acc.Arn = identity.PrincipalArn
out[remoteIdx[i]] = resolvedAccount{
Found: true,
IsSession: identity.Kind == private.KindSession,
// No session token is known here, and none is needed: this
// Account answers "who is this" for validation, never
// authenticates a request.
Account: s.accountFor(remote[i], ""),
Account: acc,
}
}
return out, nil
@@ -665,6 +699,9 @@ func (s *IAMServiceStandalone) rootAccount() Account {
acc.UserID = s.cfg.DefaultUserID
acc.GroupID = s.cfg.DefaultGroupID
acc.ProjectID = s.cfg.DefaultProjectID
if s.accountID != "" {
acc.Arn = accountRootArn(s.accountID)
}
return acc
}
+268 -1
View File
@@ -107,7 +107,15 @@ func createStandaloneTestUser(t *testing.T, store storage.Storer, userName, acce
t.Helper()
ctx := context.Background()
if _, err := store.CreateUser(ctx, types.User{UserName: userName, Path: "/", CreateDate: time.Now().UTC()}); err != nil {
// Arn is set explicitly, as the control-plane controller does before
// calling storage.CreateUser: it is what a bucket policy names the user
// by, and storage.CreateUser never populates it.
if _, err := store.CreateUser(ctx, types.User{
UserName: userName,
Path: "/",
Arn: standaloneUserArn(userName),
CreateDate: time.Now().UTC(),
}); err != nil {
t.Fatalf("CreateUser: %v", err)
}
if _, err := store.CreateAccessKey(ctx, storage.CreateAccessKeyInput{
@@ -689,3 +697,262 @@ func TestIAMServiceStandaloneRootCarriesPosixIdentity(t *testing.T) {
t.Errorf("root identity lost its credentials or role: %+v", acc)
}
}
// standaloneTestAccountID is the account every ARN the IAM service mints
// belongs to, and the one it reports on the version endpoint.
const standaloneTestAccountID = "000000000000"
func standaloneUserArn(userName string) string {
return "arn:aws:iam::" + standaloneTestAccountID + ":user/" + userName
}
func standaloneRoleArn(roleName string) string {
return "arn:aws:iam::" + standaloneTestAccountID + ":role/" + roleName
}
// createStandaloneTestSession creates a role and a live session of it,
// returning the session's credentials — the shape AssumeRoleWithWebIdentity
// produces, built directly against the store the way
// createStandaloneTestUser does.
func createStandaloneTestSession(t *testing.T, store storage.Storer, roleName, accessKeyID, secret, token string) *types.Session {
t.Helper()
ctx := context.Background()
role, err := store.CreateRole(ctx, types.Role{
RoleName: roleName,
Path: "/",
RoleID: "AROA" + roleName,
Arn: standaloneRoleArn(roleName),
CreateDate: time.Now().UTC(),
})
if err != nil {
t.Fatalf("CreateRole: %v", err)
}
session, err := store.CreateSession(ctx, types.Session{
AccessKeyId: accessKeyID,
SecretAccessKey: secret,
SessionToken: token,
RoleArn: role.Arn,
RoleName: role.RoleName,
RoleID: role.RoleID,
RoleSessionName: "sess1",
CreateDate: time.Now().UTC(),
Expiration: time.Now().UTC().Add(time.Hour),
})
if err != nil {
t.Fatalf("CreateSession: %v", err)
}
return session
}
func newStandaloneTestClient(t *testing.T, sock string) *IAMServiceStandalone {
t.Helper()
client, err := NewIAMServiceStandalone(
Account{Access: standaloneTestRootAccess, Secret: standaloneTestRootSecret, Role: RoleAdmin},
IAMServiceStandaloneConfig{Endpoint: sock})
if err != nil {
t.Fatalf("NewIAMServiceStandalone: %v", err)
}
t.Cleanup(func() { client.Shutdown() })
return client
}
// TestIAMServiceStandaloneAuthenticatedAccountsCarryArns covers what a
// bucket policy's Principal element is matched against: a user is named by
// its own ARN, and a session by both its assumed-role ARN and the ARN of the
// role it assumed, since a policy naming either one matches it.
func TestIAMServiceStandaloneAuthenticatedAccountsCarryArns(t *testing.T) {
store, sock := standaloneTestServer(t)
createStandaloneTestUser(t, store, "alice", "AKIAALICE", "alicesecret", "")
session := createStandaloneTestSession(t, store, "reader", "ASIASESSION", "sesssecret", "tok")
client := newStandaloneTestClient(t, sock)
yyyymmdd := time.Now().UTC().Format(sigv4auth.YYYYMMDD)
_, user, err := client.DeriveSigningKey("AKIAALICE", "", yyyymmdd, "us-east-1", "s3")
if err != nil {
t.Fatalf("DeriveSigningKey(user): %v", err)
}
if want := standaloneUserArn("alice"); user.Arn != want {
t.Errorf("user Arn = %q, want %q", user.Arn, want)
}
if user.RoleArn != "" {
t.Errorf("user RoleArn = %q, want it empty: a user assumed no role", user.RoleArn)
}
_, sess, err := client.DeriveSigningKey(session.AccessKeyId, "tok", yyyymmdd, "us-east-1", "s3")
if err != nil {
t.Fatalf("DeriveSigningKey(session): %v", err)
}
wantSessionArn := "arn:aws:sts::" + standaloneTestAccountID + ":assumed-role/reader/sess1"
if sess.Arn != wantSessionArn {
t.Errorf("session Arn = %q, want %q", sess.Arn, wantSessionArn)
}
if want := standaloneRoleArn("reader"); sess.RoleArn != want {
t.Errorf("session RoleArn = %q, want %q", sess.RoleArn, want)
}
}
// TestIAMServiceStandaloneRootCarriesAccountArn pins that the gateway's own
// root account is named by the account root ARN. The IAM service holds no
// record of root, so the account id comes from the startup probe.
func TestIAMServiceStandaloneRootCarriesAccountArn(t *testing.T) {
_, sock := standaloneTestServer(t)
client := newStandaloneTestClient(t, sock)
want := "arn:aws:iam::" + standaloneTestAccountID + ":root"
owner, fixed := ResolveFixedBucketOwner(client)
if !fixed {
t.Fatal("ResolveFixedBucketOwner: standalone client must fix bucket ownership")
}
if owner.Arn != want {
t.Errorf("BucketOwner().Arn = %q, want %q", owner.Arn, want)
}
acc, err := client.GetUserAccount(standaloneTestRootAccess)
if err != nil {
t.Fatalf("GetUserAccount(root): %v", err)
}
if acc.Arn != want {
t.Errorf("GetUserAccount(root).Arn = %q, want %q", acc.Arn, want)
}
}
// TestIAMServiceStandaloneResolvePrincipals covers the write-time principal
// check PutBucketPolicy makes, end to end against a real IAM service: every
// form a bucket policy may name resolves, and an access key id — what this
// gateway's other IAM backends name principals by — does not.
func TestIAMServiceStandaloneResolvePrincipals(t *testing.T) {
store, sock := standaloneTestServer(t)
createStandaloneTestUser(t, store, "alice", "AKIAALICE", "alicesecret", "")
createStandaloneTestSession(t, store, "reader", "ASIASESSION", "sesssecret", "tok")
client := newStandaloneTestClient(t, sock)
valid := []string{
standaloneTestAccountID,
"arn:aws:iam::" + standaloneTestAccountID + ":root",
standaloneUserArn("alice"),
standaloneRoleArn("reader"),
"arn:aws:sts::" + standaloneTestAccountID + ":assumed-role/reader/sess1",
"arn:aws:sts::" + standaloneTestAccountID + ":assumed-role/reader/never-assumed",
}
invalid := []string{
"AKIAALICE",
"ASIASESSION",
"alice",
standaloneUserArn("bob"),
standaloneRoleArn("writer"),
standaloneUserArn("*"),
"arn:aws:iam::111111111111:root",
}
got, err := client.ResolvePrincipals(append(append([]string{}, valid...), invalid...))
if err != nil {
t.Fatalf("ResolvePrincipals: %v", err)
}
reported := map[string]bool{}
for _, p := range got {
reported[p] = true
}
for _, p := range valid {
if reported[p] {
t.Errorf("principal %q reported invalid, want it to resolve", p)
}
}
for _, p := range invalid {
if !reported[p] {
t.Errorf("principal %q reported valid, want it rejected", p)
}
}
}
// TestIAMServiceStandaloneResolvePrincipalsEmpty pins that validating a
// policy whose only principal is the wildcard — which never reaches here —
// costs no round trip.
func TestIAMServiceStandaloneResolvePrincipalsEmpty(t *testing.T) {
_, sock := standaloneTestServer(t)
client := newStandaloneTestClient(t, sock)
invalid, err := client.ResolvePrincipals(nil)
if err != nil {
t.Fatalf("ResolvePrincipals(nil): %v", err)
}
if len(invalid) != 0 {
t.Errorf("ResolvePrincipals(nil) = %v, want none", invalid)
}
}
// TestIAMServiceStandalonePrincipalsValidate ties the two halves together:
// a bucket policy validated against the standalone client names principals
// by ARN, and the same document naming an access key id is rejected.
func TestIAMServiceStandalonePrincipalsValidate(t *testing.T) {
store, sock := standaloneTestServer(t)
createStandaloneTestUser(t, store, "alice", "AKIAALICE", "alicesecret", "")
client := newStandaloneTestClient(t, sock)
if err := (Principals{standaloneUserArn("alice"): {}}).Validate(client); err != nil {
t.Errorf("Validate(user arn) = %v, want it accepted", err)
}
if err := (Principals{"AKIAALICE": {}}).Validate(client); err != policyErrInvalidPrincipal {
t.Errorf("Validate(access key id) = %v, want %v", err, policyErrInvalidPrincipal)
}
if err := (Principals{"*": {}}).Validate(client); err != nil {
t.Errorf("Validate(wildcard) = %v, want it accepted", err)
}
}
// TestIAMServiceStandaloneRootIdentityCarriesArn pins that root reaches the
// S3 request path named. Root is the one identity resolved locally rather
// than through the IAM service, so ResolveDerivedKey never sees an ARN for
// it — rootIdentity has to carry the one the backend defines, or a bucket
// policy naming the account root ARN would miss root entirely.
func TestIAMServiceStandaloneRootIdentityCarriesArn(t *testing.T) {
_, sock := standaloneTestServer(t)
client := newStandaloneTestClient(t, sock)
rootAcc := Account{Access: standaloneTestRootAccess, Secret: standaloneTestRootSecret, Role: RoleAdmin}
yyyymmdd := time.Now().UTC().Format(sigv4auth.YYYYMMDD)
_, acc, err := ResolveDerivedKey(client, rootAcc, standaloneTestRootAccess, "", yyyymmdd, "us-east-1", "s3")
if err != nil {
t.Fatalf("ResolveDerivedKey(root): %v", err)
}
want := "arn:aws:iam::" + standaloneTestAccountID + ":root"
if acc.Arn != want {
t.Errorf("root Arn = %q, want %q", acc.Arn, want)
}
if acc.Secret != standaloneTestRootSecret || acc.Role != RoleAdmin {
t.Errorf("root identity lost its credentials or role: %+v", acc)
}
}
// TestIAMServiceStandaloneCapabilityInterfaces pins the full set of
// capability interfaces the standalone client implements. Every one is
// resolved by type assertion on the live IAMService, so anything that wraps
// it (auth.IAMCache, cmd/vgwrdma's shutdown-once wrapper) has to re-expose
// all of them — a capability silently dropped by a wrapper is a
// capability switched off gateway-wide.
func TestIAMServiceStandaloneCapabilityInterfaces(t *testing.T) {
_, sock := standaloneTestServer(t)
var iam IAMService = newStandaloneTestClient(t, sock)
if _, ok := iam.(SigningKeyProvider); !ok {
t.Error("standalone client must implement SigningKeyProvider")
}
if _, ok := iam.(PolicyEvaluator); !ok {
t.Error("standalone client must implement PolicyEvaluator")
}
if _, ok := iam.(FixedBucketOwner); !ok {
t.Error("standalone client must implement FixedBucketOwner")
}
if _, ok := iam.(PrincipalResolver); !ok {
t.Error("standalone client must implement PrincipalResolver")
}
}
+4 -4
View File
@@ -291,7 +291,7 @@ func verifyBypassGovernancePermission(ctx context.Context, be backend.Backend, i
case err != nil:
return err
default:
resourceDecision, _, err = verifyBucketPolicy(policy, acc.Access, bucket, key, condCtx, be.NormalizeObjectKey, BypassGovernanceRetentionAction)
resourceDecision, _, err = verifyBucketPolicy(policy, acc, bucket, key, condCtx, be.NormalizeObjectKey, BypassGovernanceRetentionAction)
if err != nil {
return err
}
@@ -300,7 +300,7 @@ func verifyBypassGovernancePermission(ctx context.Context, be backend.Backend, i
resourceArn := objectPolicyArn(bucket, key, be.NormalizeObjectKey)
if resourceDecision == policyDecisionDeny {
return s3err.GetExplicitDenyAccessErr(acc.Access, string(BypassGovernanceRetentionAction), resourceArn, "a resource-based policy")
return s3err.GetExplicitDenyAccessErr(principalName(acc), string(BypassGovernanceRetentionAction), resourceArn, "a resource-based policy")
}
pe, hasPolicyEvaluator := iam.(PolicyEvaluator)
@@ -340,7 +340,7 @@ func verifyBypassGovernancePermission(ctx context.Context, be backend.Backend, i
if identityDecision == policyDecisionDeny || sessionDenies {
principal := identity.PrincipalArn
if principal == "" {
principal = acc.Access
principal = principalName(acc)
}
return s3err.GetExplicitDenyAccessErr(principal, string(BypassGovernanceRetentionAction), resourceArn, "an identity-based policy")
}
@@ -351,7 +351,7 @@ func verifyBypassGovernancePermission(ctx context.Context, be backend.Backend, i
principal := identity.PrincipalArn
if principal == "" {
principal = acc.Access
principal = principalName(acc)
}
return s3err.GetImplicitDenyAccessErr(principal, string(BypassGovernanceRetentionAction), resourceArn)
}
+63
View File
@@ -316,3 +316,66 @@ func (b *objectRetentionBackend) GetObjectRetention(_ context.Context, _, _, _ s
func (b *objectRetentionBackend) GetBucketPolicy(_ context.Context, _ string) ([]byte, error) {
return nil, s3err.GetAPIError(s3err.ErrNoSuchBucketPolicy)
}
// TestVerifyBypassGovernancePermission_ArnPrincipals covers the
// governance-bypass path's own bucket-policy evaluation under an IAM backend
// whose identities have ARNs. It is a separate evaluation from VerifyAccess's
// and has to agree with it: the same principal forms match, the account
// principal delegates under Allow but not under Deny, and a denial names the
// caller by its ARN.
func TestVerifyBypassGovernancePermission_ArnPrincipals(t *testing.T) {
const (
userArn = "arn:aws:iam::000000000000:user/alice"
rootArn = "arn:aws:iam::000000000000:root"
)
user := Account{Access: "AKIAALICE", Role: RoleUser, Arn: userArn}
bypassPolicy := func(effect, principal string) []byte {
return []byte(`{"Statement":[{"Effect":"` + effect + `","Principal":{"AWS":"` + principal +
`"},"Action":"s3:BypassGovernanceRetention","Resource":"arn:aws:s3:::bucket/*"}]}`)
}
t.Run("user arn allows the bypass", func(t *testing.T) {
be := &publicBucketPolicyBackend{policy: bypassPolicy("Allow", userArn)}
err := verifyBypassGovernancePermission(context.Background(), be,
newMockPolicyEvaluator(policyDecisionNoMatch), user, "bucket", "key.txt", BypassRequested, false, nil)
assert.NoError(t, err)
})
t.Run("account arn delegates and so allows nothing", func(t *testing.T) {
be := &publicBucketPolicyBackend{policy: bypassPolicy("Allow", rootArn)}
err := verifyBypassGovernancePermission(context.Background(), be,
newMockPolicyEvaluator(policyDecisionNoMatch), user, "bucket", "key.txt", BypassRequested, false, nil)
apiErr, ok := err.(s3err.APIError)
assert.True(t, ok, "err = %#v, want s3err.APIError", err)
assert.Contains(t, apiErr.Description, "because no identity-based policy allows")
})
t.Run("account arn deny names the caller by arn", func(t *testing.T) {
be := &publicBucketPolicyBackend{policy: bypassPolicy("Deny", rootArn)}
err := verifyBypassGovernancePermission(context.Background(), be,
newMockPolicyEvaluator(policyDecisionAllow), user, "bucket", "key.txt", BypassRequested, false, nil)
apiErr, ok := err.(s3err.APIError)
assert.True(t, ok, "err = %#v, want s3err.APIError", err)
assert.Contains(t, apiErr.Description, userArn)
assert.Contains(t, apiErr.Description, "with an explicit deny in a resource-based policy")
})
t.Run("root is named by the account arn it carries", func(t *testing.T) {
// Root reaches here only for BypassOverwrite; a requested bypass
// returns earlier. rootIdentity gives it the account root ARN, which
// is how a Deny naming the account reaches it at all.
root := Account{Access: "root", Role: RoleAdmin, Arn: rootArn}
be := &publicBucketPolicyBackend{policy: bypassPolicy("Deny", rootArn)}
err := verifyBypassGovernancePermission(context.Background(), be,
newMockPolicyEvaluator(policyDecisionAllow), root, "bucket", "key.txt", BypassOverwrite, false, nil)
apiErr, ok := err.(s3err.APIError)
assert.True(t, ok, "err = %#v, want s3err.APIError", err)
assert.Contains(t, apiErr.Description, rootArn)
assert.Contains(t, apiErr.Description, "with an explicit deny in a resource-based policy")
})
}
+32
View File
@@ -0,0 +1,32 @@
// 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
// PrincipalResolver is implemented by IAM backends whose identities are
// named by AWS-style principal ARNs — currently only the standalone IAM
// service client. Its presence is what switches a bucket policy's Principal
// element from naming access key ids to naming ARNs, the way real S3 does.
//
// Every other backend (internal, LDAP, Vault, IPA, S3, single) has no ARNs
// to name anything by: its accounts are access keys and nothing else. Those
// backends do not implement this, and their bucket policies keep naming
// access key ids exactly as before.
type PrincipalResolver interface {
// ResolvePrincipals returns the subset of principals that do not name
// anything — the write-time check behind PutBucketPolicy, mirroring
// ResolveAccounts' "return what does not exist" contract for access
// keys. The wildcard "*" is handled by the caller and never reaches
// here.
ResolvePrincipals(principals []string) ([]string, error)
}
+1
View File
@@ -134,6 +134,7 @@ type standaloneIAMExtensions interface {
auth.SigningKeyProvider
auth.PolicyEvaluator
auth.FixedBucketOwner
auth.PrincipalResolver
}
type shutdownOnceService struct {
+24 -2
View File
@@ -406,17 +406,23 @@ func requestConditionContext(ctx fiber.Ctx, identity types.Identity, action stri
// though it authenticates as root.
func IdentityConditionContext(identity types.Identity) map[string][]string {
condCtx := map[string][]string{}
if arn := CallerArn(identity); arn != "" {
if arn := PrincipalConditionArn(identity); arn != "" {
condCtx["aws:PrincipalArn"] = []string{arn}
condCtx["aws:PrincipalAccount"] = []string{iamutil.DefaultAccountID}
}
// aws:PrincipalAccount describes the caller's account rather than the
// caller, so it is set for any non-root identity — deliberately not
// keyed off aws:PrincipalArn above, which answers a different question
// and could in principle come back empty for an identity that still has
// an account.
switch {
case identity.User != nil:
condCtx["aws:PrincipalAccount"] = []string{iamutil.DefaultAccountID}
condCtx["aws:PrincipalType"] = []string{"User"}
condCtx["aws:username"] = []string{identity.User.UserName}
condCtx["aws:userid"] = []string{identity.User.UserID}
addPrincipalTagContext(condCtx, identity.User.Tags)
case identity.Session != nil:
condCtx["aws:PrincipalAccount"] = []string{iamutil.DefaultAccountID}
condCtx["aws:PrincipalType"] = []string{"AssumedRole"}
condCtx["aws:userid"] = []string{identity.Session.RoleID + ":" + identity.Session.RoleSessionName}
if identity.Role != nil {
@@ -497,3 +503,19 @@ func CallerArn(identity types.Identity) string {
}
return ""
}
// PrincipalConditionArn identifies identity the way the aws:PrincipalArn
// condition key does, which is not the way an error message does: for a
// session it is the assumed *role's* ARN, not the session's own. A
// Condition on aws:PrincipalArn therefore applies to every session of a
// role and can never single one out — aws:userid, which carries
// <role id>:<session name>, is the key that can.
func PrincipalConditionArn(identity types.Identity) string {
if identity.Session != nil {
return identity.Session.RoleArn
}
if identity.User != nil {
return identity.User.Arn
}
return ""
}
@@ -0,0 +1,155 @@
// 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 iammiddleware
import (
"testing"
"github.com/versity/versitygw/iamapi/internal/iamutil"
"github.com/versity/versitygw/iamapi/types"
)
func testIdentityUser() types.Identity {
return types.Identity{User: &types.User{
UserName: "alice",
UserID: "AIDAALICE",
Arn: iamutil.BuildUserArn(iamutil.DefaultAccountID, "/", "alice"),
}}
}
func testIdentitySession() types.Identity {
role := &types.Role{
RoleName: "reader",
RoleID: "AROAREADER",
Arn: iamutil.BuildRoleArn(iamutil.DefaultAccountID, "/", "reader"),
}
return types.Identity{
Role: role,
Session: &types.Session{
RoleArn: role.Arn,
RoleName: role.RoleName,
RoleID: role.RoleID,
RoleSessionName: "sess1",
},
}
}
// TestCallerArnAndPrincipalConditionArn pins the one place the two differ: a
// session is named by its assumed-role ARN in an error message, and by its
// role's own ARN as aws:PrincipalArn.
func TestCallerArnAndPrincipalConditionArn(t *testing.T) {
tests := []struct {
name string
identity types.Identity
wantCaller string
wantCondition string
}{
{
name: "user",
identity: testIdentityUser(),
wantCaller: "arn:aws:iam::000000000000:user/alice",
wantCondition: "arn:aws:iam::000000000000:user/alice",
},
{
name: "session",
identity: testIdentitySession(),
wantCaller: "arn:aws:sts::000000000000:assumed-role/reader/sess1",
wantCondition: "arn:aws:iam::000000000000:role/reader",
},
{
name: "root",
identity: types.Identity{IsRoot: true},
wantCaller: "",
wantCondition: "",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := CallerArn(tt.identity); got != tt.wantCaller {
t.Errorf("CallerArn() = %q, want %q", got, tt.wantCaller)
}
if got := PrincipalConditionArn(tt.identity); got != tt.wantCondition {
t.Errorf("PrincipalConditionArn() = %q, want %q", got, tt.wantCondition)
}
})
}
}
// TestPrincipalConditionArnOutlivesRole pins that a session whose role has
// since been deleted is still named by the role it was minted against: the
// session carries the ARN, so nothing has to be looked up to name it.
func TestPrincipalConditionArnOutlivesRole(t *testing.T) {
identity := testIdentitySession()
identity.Role = nil
want := "arn:aws:iam::000000000000:role/reader"
if got := PrincipalConditionArn(identity); got != want {
t.Errorf("PrincipalConditionArn() = %q, want %q", got, want)
}
}
// TestIdentityConditionContext covers the identity-derived condition keys
// only this service can supply. aws:PrincipalArn for a session is the role
// ARN, so a Condition on it covers every session of the role and can never
// single one out — aws:userid, which carries <role id>:<session name>, is
// the key that can.
func TestIdentityConditionContext(t *testing.T) {
tests := []struct {
name string
identity types.Identity
want map[string]string
}{
{
name: "user",
identity: testIdentityUser(),
want: map[string]string{
"aws:PrincipalArn": "arn:aws:iam::000000000000:user/alice",
"aws:PrincipalAccount": iamutil.DefaultAccountID,
"aws:PrincipalType": "User",
"aws:username": "alice",
"aws:userid": "AIDAALICE",
},
},
{
name: "session",
identity: testIdentitySession(),
want: map[string]string{
"aws:PrincipalArn": "arn:aws:iam::000000000000:role/reader",
"aws:PrincipalAccount": iamutil.DefaultAccountID,
"aws:PrincipalType": "AssumedRole",
"aws:userid": "AROAREADER:sess1",
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := IdentityConditionContext(tt.identity)
for key, want := range tt.want {
vals, ok := got[key]
if !ok {
t.Errorf("%s missing, want %q", key, want)
continue
}
if len(vals) != 1 || vals[0] != want {
t.Errorf("%s = %v, want [%q]", key, vals, want)
}
}
if _, ok := got["aws:username"]; ok && tt.identity.Session != nil {
t.Error("aws:username set for a session, which has no user name")
}
})
}
}
+41 -2
View File
@@ -22,6 +22,7 @@ import (
"github.com/gofiber/fiber/v3"
"github.com/versity/versitygw/debuglogger"
"github.com/versity/versitygw/iamapi/internal/iammiddleware"
"github.com/versity/versitygw/iamapi/internal/iamutil"
"github.com/versity/versitygw/iamapi/policy"
"github.com/versity/versitygw/iamapi/types"
"github.com/versity/versitygw/internal/sigv4auth"
@@ -38,6 +39,7 @@ func (p *PrivateAPI) handleVersion(ctx fiber.Ctx) error {
Protocol: ProtocolVersion,
MinClient: MinClientProtocol,
ServerVersion: p.serverVersion,
AccountID: iamutil.DefaultAccountID,
})
}
@@ -47,14 +49,25 @@ func (p *PrivateAPI) handleDeriveSigningKey(ctx fiber.Ctx) error {
return errMalformedRequestBody
}
_, secret, err := resolvePrivateIdentity(ctx.Context(), p.store, req.AccessKeyID, req.SessionToken)
identity, secret, err := resolvePrivateIdentity(ctx.Context(), p.store, req.AccessKeyID, req.SessionToken)
if err != nil {
return mapResolveError(err)
}
derivedKey := sigv4auth.DeriveKey(secret, req.Date, req.Region, req.Service)
return ctx.JSON(DeriveSigningKeyResponse{DerivedKey: derivedKey})
resp := DeriveSigningKeyResponse{
DerivedKey: derivedKey,
PrincipalArn: iammiddleware.CallerArn(*identity),
}
// The role ARN comes from the session rather than from the role the
// store holds now: a session outliving its role keeps authenticating,
// and it still belongs to the role it was minted against.
if identity.Session != nil {
resp.RoleArn = identity.Session.RoleArn
}
return ctx.JSON(resp)
}
// recordDataPlaneUsage records this S3 request as a use of the credential
@@ -120,6 +133,32 @@ func (p *PrivateAPI) handleResolveIdentity(ctx fiber.Ctx) error {
return ctx.JSON(ResolveIdentityResponse{Identities: identities})
}
// handleResolvePrincipals answers, for each string a bucket policy names as
// a Principal, whether it resolves to something that exists — reporting only
// the ones that do not, so a valid policy's principals disclose nothing.
func (p *PrivateAPI) handleResolvePrincipals(ctx fiber.Ctx) error {
var req ResolvePrincipalsRequest
if err := json.Unmarshal(ctx.Body(), &req); err != nil {
return errMalformedRequestBody
}
invalid := []string{}
for _, principal := range req.Principals {
resolves, err := principalResolves(ctx.Context(), p.store, iamutil.DefaultAccountID, principal)
if err != nil {
// A store fault, not a verdict on the principal — errorHandler
// renders it as a 500 so the gateway reports the service as
// broken rather than the policy as malformed.
return err
}
if !resolves {
invalid = append(invalid, principal)
}
}
return ctx.JSON(ResolvePrincipalsResponse{Invalid: invalid})
}
// identityKindWireValue converts identityKind to its wire representation.
func identityKindWireValue(k identityKind) string {
if k == identityKindSession {
+167
View File
@@ -0,0 +1,167 @@
// 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 private
import (
"context"
"errors"
"strings"
"github.com/versity/versitygw/iamapi/iamerr"
"github.com/versity/versitygw/iamapi/internal/iamutil"
)
// accountIDLen is the length of an AWS account id, and the length the bare
// account-id principal form must have.
const accountIDLen = 12
// principalResolves reports whether principal names an identity that exists
// right now, in the form an S3 bucket policy's Principal element may take.
// It is the write-time check behind PutBucketPolicy: a statement naming
// something that cannot be resolved is rejected rather than stored as one
// that can never match.
//
// The accepted forms, all scoped to accountID — this service serves exactly
// one account, and an ARN naming another cannot name anything it knows:
//
// arn:aws:iam::<account>:root the account itself
// <account> the same, in the bare account-id form
// arn:aws:iam::<account>:user<path><name> an existing IAM user
// arn:aws:iam::<account>:role<path><name> an existing IAM role
// arn:aws:sts::<account>:assumed-role/<role>/<session>
// any session of an existing role
//
// A user's or role's ARN embeds its IAM path, so an ARN that omits the path
// of a path-bearing identity does not resolve — the comparison below is
// against the identity's own stored ARN, which enforces that, and the
// case-sensitivity of the whole string, without either being a rule of its
// own.
//
// The session name in an assumed-role ARN is not checked for EXISTENCE —
// it names a session that need not have been minted yet, and commonly
// won't have been when the policy is written — but it must still be a
// session name that could exist: the same grammar
// AssumeRoleWithWebIdentity enforces on RoleSessionName. Without that,
// "assumed-role/reader/*" would be stored as a statement that no session
// can ever match, since a caller's session ARN carries a literal name and
// nothing here pattern-matches.
//
// Everything else — a wildcard inside an ARN, a group/policy/
// instance-profile ARN, another partition, another account, an access key
// id — does not resolve. Wildcards in particular: only a whole Principal of
// "*" is special, and that never reaches here.
func principalResolves(ctx context.Context, store iamutil.IdentityStore, accountID, principal string) (bool, error) {
if principal == accountID && isAccountID(principal) {
return true, nil
}
resource, ok := principalArnResource(principal, "iam", accountID)
if ok {
switch {
case resource == "root":
return true, nil
case strings.HasPrefix(resource, "user/"):
user, err := store.GetUser(ctx, lastArnSegment(resource))
if err != nil {
return false, ignoreNoSuchEntity(err)
}
return user != nil && user.Arn == principal, nil
case strings.HasPrefix(resource, "role/"):
role, err := store.GetRole(ctx, lastArnSegment(resource))
if err != nil {
return false, ignoreNoSuchEntity(err)
}
return role != nil && role.Arn == principal, nil
}
return false, nil
}
resource, ok = principalArnResource(principal, "sts", accountID)
if !ok || !strings.HasPrefix(resource, "assumed-role/") {
return false, nil
}
// An assumed-role ARN is arn:aws:sts::<acct>:assumed-role/<role>/<session>
// with exactly those two segments: unlike the role's own ARN it never
// carries the role's path, so a third segment is not a deeper path but a
// malformed principal.
roleName, sessionName, found := strings.Cut(strings.TrimPrefix(resource, "assumed-role/"), "/")
if !found || roleName == "" {
return false, nil
}
if err := iamutil.ValidateRoleSessionName(sessionName); err != nil {
return false, nil
}
role, err := store.GetRole(ctx, roleName)
if err != nil {
return false, ignoreNoSuchEntity(err)
}
// Role lookup is case-insensitive, so compare against the ARN a session
// of that role would actually authenticate as. Without this a
// wrong-case role name would be accepted and then match nothing — the
// same reason the user and role branches above compare the stored ARN
// rather than trusting the lookup.
return role != nil && iamutil.BuildAssumedRoleArn(accountID, role.RoleName, sessionName) == principal, nil
}
// ignoreNoSuchEntity returns nil for the store's "this identity does not
// exist" error — the answer principalResolves is asking for — and err
// itself for anything else, which is a fault in the service rather than a
// verdict on the principal.
func ignoreNoSuchEntity(err error) error {
var apiErr iamerr.Error
if errors.As(err, &apiErr) && apiErr.Code == "NoSuchEntity" {
return nil
}
return err
}
// principalArnResource splits principal into the resource part of an
// arn:aws:<service>::<accountID>:<resource> ARN, reporting ok=false for any
// other shape. The region field must be empty, as it is in every IAM and STS
// ARN, and the partition must be "aws": this service has no other.
func principalArnResource(principal, service, accountID string) (resource string, ok bool) {
prefix := "arn:aws:" + service + "::" + accountID + ":"
if !strings.HasPrefix(principal, prefix) {
return "", false
}
resource = strings.TrimPrefix(principal, prefix)
if resource == "" {
return "", false
}
return resource, true
}
// lastArnSegment returns the identity name from an ARN resource such as
// "user/team/sub/alice" — the last segment, everything before it being the
// identity's IAM path.
func lastArnSegment(resource string) string {
if idx := strings.LastIndex(resource, "/"); idx >= 0 {
return resource[idx+1:]
}
return resource
}
// isAccountID reports whether s is an AWS account id: exactly twelve
// digits.
func isAccountID(s string) bool {
if len(s) != accountIDLen {
return false
}
for i := 0; i < len(s); i++ {
if s[i] < '0' || s[i] > '9' {
return false
}
}
return true
}
+212
View File
@@ -636,6 +636,24 @@ func TestPrivateAPIRejectsNonRootCredential(t *testing.T) {
}
}
// TestPrivateAPIEveryRouteRequiresRootCredential walks the whole route
// table rather than one route: every endpoint here is root-signed by
// construction, and the way that breaks is a new route registered without
// the auth middleware wrapped around it.
func TestPrivateAPIEveryRouteRequiresRootCredential(t *testing.T) {
p, store := newTestServer(t)
createTestUser(t, store, "alice", "AKIAALICE", "alicesecret", "")
for _, path := range []string{VersionPath, DerivePath, EvaluatePath, ResolveIdentityPath, ResolvePrincipalsPath} {
t.Run(path, func(t *testing.T) {
resp := doPrivateRequest(t, p, http.MethodPost, path, "AKIAALICE", "alicesecret", []byte("{}"))
if resp.StatusCode != http.StatusForbidden {
t.Errorf("status = %d, want %d; body=%s", resp.StatusCode, http.StatusForbidden, readBody(t, resp))
}
})
}
}
func TestPrivateAPIRejectsMalformedBody(t *testing.T) {
p, _ := newTestServer(t)
@@ -1020,3 +1038,197 @@ func createTestSessionForRole(t *testing.T, store storage.Storer, role *types.Ro
}
return session
}
// TestPrivateAPIDeriveSigningKeyPrincipalArn covers the identity a derived
// key belongs to, which the S3 gateway matches bucket-policy principals
// against: a user carries only its own ARN, while a session carries both its
// assumed-role ARN and the role's, since a policy naming either matches it.
func TestPrivateAPIDeriveSigningKeyPrincipalArn(t *testing.T) {
p, store := newTestServer(t)
createTestUser(t, store, "alice", "AKIAALICE", "alicesecret", "")
role := createTestRole(t, store, "testrole", "")
session := createTestSessionForRole(t, store, role, "ASIASESSION", "sessionsecret", "tok", "")
yyyymmdd := time.Now().UTC().Format(sigv4auth.YYYYMMDD)
for _, tc := range []struct {
name string
req DeriveSigningKeyRequest
principalArn string
roleArn string
}{
{
name: "user",
req: DeriveSigningKeyRequest{AccessKeyID: "AKIAALICE"},
principalArn: iamutil.BuildUserArn(iamutil.DefaultAccountID, "/", "alice"),
},
{
name: "session",
req: DeriveSigningKeyRequest{AccessKeyID: session.AccessKeyId, SessionToken: "tok"},
principalArn: iamutil.BuildAssumedRoleArn(iamutil.DefaultAccountID, role.RoleName, session.RoleSessionName),
roleArn: role.Arn,
},
} {
t.Run(tc.name, func(t *testing.T) {
tc.req.Date, tc.req.Region, tc.req.Service = yyyymmdd, "us-east-1", "s3"
body, _ := json.Marshal(tc.req)
resp := doPrivateRequest(t, p, http.MethodPost, DerivePath, testRoot.Access, testRoot.Secret, body)
raw := readBody(t, resp)
if resp.StatusCode != http.StatusOK {
t.Fatalf("status = %d, body=%s", resp.StatusCode, raw)
}
var out DeriveSigningKeyResponse
if err := json.Unmarshal([]byte(raw), &out); err != nil {
t.Fatalf("unmarshal %s: %v", raw, err)
}
if len(out.DerivedKey) == 0 {
t.Error("DerivedKey is empty")
}
if out.PrincipalArn != tc.principalArn {
t.Errorf("PrincipalArn = %q, want %q", out.PrincipalArn, tc.principalArn)
}
if out.RoleArn != tc.roleArn {
t.Errorf("RoleArn = %q, want %q", out.RoleArn, tc.roleArn)
}
})
}
}
// TestPrivateAPIResolvePrincipals covers the write-time principal check
// behind PutBucketPolicy across every form a bucket policy may name, and the
// forms it may not. Only the unresolvable ones come back.
func TestPrivateAPIResolvePrincipals(t *testing.T) {
p, store := newTestServer(t)
createTestUser(t, store, "alice", "AKIAALICE", "alicesecret", "")
createTestRole(t, store, "testrole", "")
acct := iamutil.DefaultAccountID
valid := []string{
acct,
"arn:aws:iam::" + acct + ":root",
"arn:aws:iam::" + acct + ":user/alice",
"arn:aws:iam::" + acct + ":role/testrole",
// The session name is not checked against anything: it names a
// session that need not exist when the policy is written.
"arn:aws:sts::" + acct + ":assumed-role/testrole/never-assumed",
}
// A session name must be one that could exist — the same grammar
// AssumeRoleWithWebIdentity enforces — or the statement could never
// match anything.
invalidSessions := []string{
"arn:aws:sts::" + acct + ":assumed-role/testrole/*",
"arn:aws:sts::" + acct + ":assumed-role/testrole/s",
"arn:aws:sts::" + acct + ":assumed-role/testrole/sess with spaces",
"arn:aws:sts::" + acct + ":assumed-role/testrole/" + strings.Repeat("s", 65),
// Role lookup is case-insensitive, so a wrong-case role name would
// otherwise resolve and then match no session.
"arn:aws:sts::" + acct + ":assumed-role/TESTROLE/sess",
}
invalid := []string{
"AKIAALICE",
"alice",
"arn:aws:iam::" + acct + ":user/bob",
"arn:aws:iam::" + acct + ":role/norole",
"arn:aws:iam::" + acct + ":user/ALICE",
"arn:aws:iam::" + acct + ":user/*",
"arn:aws:iam::" + acct + ":user/team/alice",
"arn:aws:iam::" + acct + ":group/admins",
"arn:aws:iam::" + acct + ":user/alice ",
"arn:aws:iam::111111111111:root",
"arn:aws:iam:us-east-1:" + acct + ":root",
"arn:aws-cn:iam::" + acct + ":root",
"arn:aws:sts::" + acct + ":assumed-role/norole/sess",
"arn:aws:sts::" + acct + ":assumed-role/testrole",
"arn:aws:sts::" + acct + ":assumed-role/testrole/a/b",
"arn:aws:sts::" + acct + ":federated-user/alice",
"arn:aws:s3:::mybucket",
"",
}
invalid = append(invalid, invalidSessions...)
body, _ := json.Marshal(ResolvePrincipalsRequest{Principals: append(append([]string{}, valid...), invalid...)})
resp := doPrivateRequest(t, p, http.MethodPost, ResolvePrincipalsPath, testRoot.Access, testRoot.Secret, body)
raw := readBody(t, resp)
if resp.StatusCode != http.StatusOK {
t.Fatalf("status = %d, want 200; body=%s", resp.StatusCode, raw)
}
var out ResolvePrincipalsResponse
if err := json.Unmarshal([]byte(raw), &out); err != nil {
t.Fatalf("unmarshal %s: %v", raw, err)
}
got := map[string]bool{}
for _, p := range out.Invalid {
got[p] = true
}
for _, p := range valid {
if got[p] {
t.Errorf("principal %q reported invalid, want it to resolve", p)
}
}
for _, p := range invalid {
if !got[p] {
t.Errorf("principal %q reported valid, want it rejected", p)
}
}
}
// TestPrivateAPIResolvePrincipalsPathedIdentity pins that a user's or role's
// IAM path is part of the ARN naming it: the path-less form of a path-bearing
// identity resolves to nothing, and the full form resolves.
func TestPrivateAPIResolvePrincipalsPathedIdentity(t *testing.T) {
p, store := newTestServer(t)
acct := iamutil.DefaultAccountID
if _, err := store.CreateUser(context.Background(), types.User{
UserName: "pathed",
Path: "/team/sub/",
Arn: iamutil.BuildUserArn(acct, "/team/sub/", "pathed"),
CreateDate: time.Now().UTC(),
}); err != nil {
t.Fatalf("CreateUser: %v", err)
}
body, _ := json.Marshal(ResolvePrincipalsRequest{Principals: []string{
"arn:aws:iam::" + acct + ":user/team/sub/pathed",
"arn:aws:iam::" + acct + ":user/pathed",
}})
resp := doPrivateRequest(t, p, http.MethodPost, ResolvePrincipalsPath, testRoot.Access, testRoot.Secret, body)
raw := readBody(t, resp)
if resp.StatusCode != http.StatusOK {
t.Fatalf("status = %d, want 200; body=%s", resp.StatusCode, raw)
}
var out ResolvePrincipalsResponse
if err := json.Unmarshal([]byte(raw), &out); err != nil {
t.Fatalf("unmarshal %s: %v", raw, err)
}
want := []string{"arn:aws:iam::" + acct + ":user/pathed"}
if len(out.Invalid) != 1 || out.Invalid[0] != want[0] {
t.Errorf("Invalid = %v, want %v", out.Invalid, want)
}
}
// TestPrivateAPIVersionReportsAccountID pins that the version endpoint
// carries the account id every ARN this service mints belongs to — the
// gateway names its own root account with it, having no other source for it.
func TestPrivateAPIVersionReportsAccountID(t *testing.T) {
p, _ := newTestServer(t)
resp := doPrivateRequest(t, p, http.MethodPost, VersionPath, testRoot.Access, testRoot.Secret, []byte("{}"))
raw := readBody(t, resp)
if resp.StatusCode != http.StatusOK {
t.Fatalf("status = %d, want 200; body=%s", resp.StatusCode, raw)
}
var out VersionResponse
if err := json.Unmarshal([]byte(raw), &out); err != nil {
t.Fatalf("unmarshal %s: %v", raw, err)
}
if out.AccountID != iamutil.DefaultAccountID {
t.Errorf("AccountID = %q, want %q", out.AccountID, iamutil.DefaultAccountID)
}
}
+6 -4
View File
@@ -42,10 +42,11 @@ const (
// These are exported so auth.IAMServiceStandalone (the S3-side client)
// shares one source of truth for the routes rather than duplicating the
// literal path strings.
DerivePath = "/private/derive-signing-key"
EvaluatePath = "/private/evaluate-policy"
ResolveIdentityPath = "/private/resolve-identity"
VersionPath = "/private/version"
DerivePath = "/private/derive-signing-key"
EvaluatePath = "/private/evaluate-policy"
ResolveIdentityPath = "/private/resolve-identity"
ResolvePrincipalsPath = "/private/resolve-principals"
VersionPath = "/private/version"
// ProtocolHeader carries the private protocol version each peer speaks.
// Both send it: the S3 gateway on every request, this service on every
@@ -152,6 +153,7 @@ func New(store storage.Storer, root iammiddleware.RootCredentials, opts ...Priva
app.Post(DerivePath, chainHandlers(rootAuth, p.handleDeriveSigningKey))
app.Post(EvaluatePath, chainHandlers(rootAuth, p.handleEvaluatePolicy))
app.Post(ResolveIdentityPath, chainHandlers(rootAuth, p.handleResolveIdentity))
app.Post(ResolvePrincipalsPath, chainHandlers(rootAuth, p.handleResolvePrincipals))
return p, nil
}
+32 -2
View File
@@ -30,9 +30,32 @@ type DeriveSigningKeyRequest struct {
}
// DeriveSigningKeyResponse carries the derived signing key (kSigning) —
// never the underlying secret.
// never the underlying secret — and the identity it belongs to, named the
// way a bucket policy's Principal element names it.
//
// PrincipalArn is the caller's own ARN: an IAM user's, or an assumed-role
// session's arn:aws:sts::…:assumed-role/<role>/<session>. RoleArn is that
// session's role ARN and is set only for a session, because a bucket policy
// Principal naming a role matches every session of it
type DeriveSigningKeyResponse struct {
DerivedKey []byte `json:"derivedKey"`
DerivedKey []byte `json:"derivedKey"`
PrincipalArn string `json:"principalArn,omitempty"`
RoleArn string `json:"roleArn,omitempty"`
}
// ResolvePrincipalsRequest asks whether each string is a principal an S3
// bucket policy may name — the write-time check behind PutBucketPolicy.
type ResolvePrincipalsRequest struct {
Principals []string `json:"principals"`
}
// ResolvePrincipalsResponse answers with only the principals that do not
// resolve, so an empty list means the whole policy's principals are valid.
// It reports no detail about the ones that do: a principal's existence is
// all PutBucketPolicy validation needs, and answering more would make this
// endpoint an identity enumerator.
type ResolvePrincipalsResponse struct {
Invalid []string `json:"invalid"`
}
// EvaluatePolicyRequest is the evaluate-policy request body.
@@ -132,8 +155,15 @@ type EvaluatePolicyResponse struct {
// gateway to draw that conclusion itself from this field. Protocol duplicates
// the ProtocolHeader every response carries, and ServerVersion is the build
// tag — what maps a protocol number back to an image during a rollout.
// AccountID is the single AWS account id every identity this service holds
// belongs to. The gateway learns it here rather than assuming a compile-time
// constant the two builds happen to share: it is what a bucket policy's
// account-level Principal forms (the account root ARN, and the bare account
// id) are matched against, and getting it from the service that mints the
// ARNs keeps one source of truth for it.
type VersionResponse struct {
Protocol int `json:"protocol"`
MinClient int `json:"minClient"`
ServerVersion string `json:"serverVersion,omitempty"`
AccountID string `json:"accountId,omitempty"`
}
+26 -2
View File
@@ -1745,8 +1745,16 @@ func TestS3IAMAccessControl(ts *TestState) {
ts.Run(S3IAMAccessControl_condition_on_deny_statement)
ts.Run(S3IAMAccessControl_condition_multiple_keys_anded)
ts.Run(S3IAMAccessControl_inactive_and_deleted_credentials)
ts.Run(S3IAMAccessControl_bucket_policy_unknown_principal_rejected)
ts.Run(S3IAMAccessControl_access_key_last_used_records_s3)
ts.Run(S3IAMPrincipal_accepted_forms)
ts.Run(S3IAMPrincipal_rejected_forms)
ts.Run(S3IAMPrincipal_empty_forms)
ts.Run(S3IAMPrincipal_user_arn_names_only_that_user)
ts.Run(S3IAMPrincipal_pathed_user_arn)
ts.Run(S3IAMPrincipal_account_delegates_but_does_not_grant)
ts.Run(S3IAMPrincipal_role_arn_does_not_match_a_user)
ts.Run(S3IAMPrincipal_deleted_user_arn_rejected)
ts.Run(S3IAMPrincipal_wildcard_grants_everyone)
}
// TestS3IAMSessionAccessControl is the one group the OIDC workflow runs, so
@@ -1765,6 +1773,10 @@ func TestS3IAMSessionAccessControl(ts *TestState) {
ts.Run(S3IAMSession_role_policy_deny_overrides_session_allow)
ts.Run(S3IAMSession_session_policy_without_role_policy_denied)
ts.Run(S3IAMSession_bucket_policy_allows_without_role_policy)
ts.Run(S3IAMSession_bucket_policy_role_arn_covers_every_session)
ts.Run(S3IAMSession_bucket_policy_names_one_session)
ts.Run(S3IAMSession_bucket_policy_account_delegates)
ts.Run(S3IAMSession_bucket_policy_session_principal_forms)
ts.Run(S3IAMSession_session_policy_filters_bucket_policy_grant)
ts.Run(S3IAMSession_bucket_policy_deny_overrides_role_allow)
ts.Run(S3IAMSession_missing_and_wrong_security_token)
@@ -2190,7 +2202,19 @@ func GetIntTests() IntTests {
"S3IAMAccessControl_condition_on_deny_statement": S3IAMAccessControl_condition_on_deny_statement,
"S3IAMAccessControl_condition_multiple_keys_anded": S3IAMAccessControl_condition_multiple_keys_anded,
"S3IAMAccessControl_inactive_and_deleted_credentials": S3IAMAccessControl_inactive_and_deleted_credentials,
"S3IAMAccessControl_bucket_policy_unknown_principal_rejected": S3IAMAccessControl_bucket_policy_unknown_principal_rejected,
"S3IAMSession_bucket_policy_role_arn_covers_every_session": S3IAMSession_bucket_policy_role_arn_covers_every_session,
"S3IAMSession_bucket_policy_names_one_session": S3IAMSession_bucket_policy_names_one_session,
"S3IAMSession_bucket_policy_account_delegates": S3IAMSession_bucket_policy_account_delegates,
"S3IAMSession_bucket_policy_session_principal_forms": S3IAMSession_bucket_policy_session_principal_forms,
"S3IAMPrincipal_accepted_forms": S3IAMPrincipal_accepted_forms,
"S3IAMPrincipal_rejected_forms": S3IAMPrincipal_rejected_forms,
"S3IAMPrincipal_empty_forms": S3IAMPrincipal_empty_forms,
"S3IAMPrincipal_user_arn_names_only_that_user": S3IAMPrincipal_user_arn_names_only_that_user,
"S3IAMPrincipal_pathed_user_arn": S3IAMPrincipal_pathed_user_arn,
"S3IAMPrincipal_account_delegates_but_does_not_grant": S3IAMPrincipal_account_delegates_but_does_not_grant,
"S3IAMPrincipal_role_arn_does_not_match_a_user": S3IAMPrincipal_role_arn_does_not_match_a_user,
"S3IAMPrincipal_deleted_user_arn_rejected": S3IAMPrincipal_deleted_user_arn_rejected,
"S3IAMPrincipal_wildcard_grants_everyone": S3IAMPrincipal_wildcard_grants_everyone,
"S3IAMAccessControl_access_key_last_used_records_s3": S3IAMAccessControl_access_key_last_used_records_s3,
"Authentication_invalid_auth_header": Authentication_invalid_auth_header,
"Authentication_unsupported_signature_version": Authentication_unsupported_signature_version,
+15 -36
View File
@@ -374,7 +374,7 @@ func S3IAMAccessControl_multiple_inline_policies_combine(s *S3Conf) error {
// S3IAMAccessControl_bucket_policy_allows_without_identity_policy verifies
// the resource side is independently sufficient too: a bucket policy naming
// the user's access key grants the request with no identity policy at all.
// the user's ARN grants the request with no identity policy at all.
func S3IAMAccessControl_bucket_policy_allows_without_identity_policy(s *S3Conf) error {
testName := "S3IAMAccessControl_bucket_policy_allows_without_identity_policy"
return s3IAMActionHandler(s, testName, func(root *iam.Client, bucket string) error {
@@ -385,7 +385,7 @@ func S3IAMAccessControl_bucket_policy_allows_without_identity_policy(s *S3Conf)
defer cleanup()
if err := putBucketPolicyDoc(s, bucket, bucketStatement{
Effect: "Allow", Principal: user.conf.awsID, Action: actS3PutObject, Resource: objectsArn(bucket),
Effect: "Allow", Principal: user.arn, Action: actS3PutObject, Resource: objectsArn(bucket),
}); err != nil {
return err
}
@@ -416,7 +416,7 @@ func S3IAMAccessControl_bucket_policy_explicit_deny(s *S3Conf) error {
defer cleanup()
if err := putBucketPolicyDoc(s, bucket, bucketStatement{
Effect: "Deny", Principal: user.conf.awsID, Action: actS3GetObject, Resource: objectsArn(bucket),
Effect: "Deny", Principal: user.arn, Action: actS3GetObject, Resource: objectsArn(bucket),
}); err != nil {
return err
}
@@ -424,10 +424,7 @@ func S3IAMAccessControl_bucket_policy_explicit_deny(s *S3Conf) error {
ctx, cancel := context.WithTimeout(context.Background(), shortTimeout)
_, err = user.client.GetObject(ctx, &s3.GetObjectInput{Bucket: &bucket, Key: getPtr("obj")})
cancel()
// The resource-based denial names the access key, not the ARN:
// bucket-policy principals are access-key-based for every backend,
// so the gateway has no ARN in hand at that point.
return checkApiErr(err, wantExplicitResourceDeny(user.conf.awsID, actS3GetObject, objectArn(bucket, "obj")))
return checkApiErr(err, wantExplicitResourceDeny(user.arn, actS3GetObject, objectArn(bucket, "obj")))
})
}
@@ -470,16 +467,16 @@ func S3IAMAccessControl_policy_combinations(s *S3Conf) error {
return wantExplicitIdentityDeny(u.arn, actS3GetObject, objectArn(bucket, "obj"))
}},
{identity: silent, resource: deny, wantErr: func(u *s3IAMPrincipal) s3err.S3Error {
return wantExplicitResourceDeny(u.conf.awsID, actS3GetObject, objectArn(bucket, "obj"))
return wantExplicitResourceDeny(u.arn, actS3GetObject, objectArn(bucket, "obj"))
}},
{identity: allow, resource: deny, wantErr: func(u *s3IAMPrincipal) s3err.S3Error {
return wantExplicitResourceDeny(u.conf.awsID, actS3GetObject, objectArn(bucket, "obj"))
return wantExplicitResourceDeny(u.arn, actS3GetObject, objectArn(bucket, "obj"))
}},
// A Deny on both sides is reported as the resource-based one:
// VerifyAccess evaluates the bucket policy first and returns
// immediately, which also saves an IAM round trip.
{identity: deny, resource: deny, wantErr: func(u *s3IAMPrincipal) s3err.S3Error {
return wantExplicitResourceDeny(u.conf.awsID, actS3GetObject, objectArn(bucket, "obj"))
return wantExplicitResourceDeny(u.arn, actS3GetObject, objectArn(bucket, "obj"))
}},
}
@@ -512,7 +509,7 @@ func S3IAMAccessControl_policy_combinations(s *S3Conf) error {
effect = "Deny"
}
if err := putBucketPolicyDoc(s, bucket, bucketStatement{
Effect: effect, Principal: user.conf.awsID, Action: actS3GetObject, Resource: objectsArn(bucket),
Effect: effect, Principal: user.arn, Action: actS3GetObject, Resource: objectsArn(bucket),
}); err != nil {
return err
}
@@ -857,7 +854,7 @@ func S3IAMAccessControl_governance_bypass_sources(s *S3Conf) error {
effect = "Deny"
}
if err := putBucketPolicyDoc(s, bucket, bucketStatement{
Effect: effect, Principal: user.conf.awsID,
Effect: effect, Principal: user.arn,
Action: actS3BypassGovernance, Resource: objectsArn(bucket),
}); err != nil {
return err
@@ -1123,11 +1120,11 @@ func S3IAMAccessControl_delete_objects_bucket_policy_deny_per_key(s *S3Conf) err
// keys — the second one proves the first didn't end the evaluation.
if err := putBucketPolicyDoc(s, bucket,
bucketStatement{
Effect: "Deny", Principal: user.conf.awsID,
Effect: "Deny", Principal: user.arn,
Action: actS3DeleteObject, Resource: objectArn(bucket, "protected/*"),
},
bucketStatement{
Effect: "Deny", Principal: user.conf.awsID,
Effect: "Deny", Principal: user.arn,
Action: actS3DeleteObject, Resource: objectArn(bucket, "vault/*"),
},
); err != nil {
@@ -1139,7 +1136,7 @@ func S3IAMAccessControl_delete_objects_bucket_policy_deny_per_key(s *S3Conf) err
// is denied for want of any grant, and "allowed/" is the only prefix
// the identity policy permits.
resourceDeny := func(key string) keyDenial {
return keyDenial{key, wantExplicitResourceDeny(user.conf.awsID, actS3DeleteObject, objectArn(bucket, key))}
return keyDenial{key, wantExplicitResourceDeny(user.arn, actS3DeleteObject, objectArn(bucket, key))}
}
implicitDeny := func(key string) keyDenial {
return keyDenial{key, wantImplicitDeny(user.arn, actS3DeleteObject, objectArn(bucket, key))}
@@ -1236,7 +1233,7 @@ func S3IAMAccessControl_delete_objects_deny_across_versioned_split(s *S3Conf) er
defer cleanup()
if err := putBucketPolicyDoc(s, bucket, bucketStatement{
Effect: "Deny", Principal: user.conf.awsID,
Effect: "Deny", Principal: user.arn,
Action: []string{actS3DeleteObject, actS3DeleteObjectVersion},
Resource: objectArn(bucket, "protected/*"),
}); err != nil {
@@ -1266,8 +1263,8 @@ func S3IAMAccessControl_delete_objects_deny_across_versioned_split(s *S3Conf) er
return err
}
return checkDeleteObjectsErrsInOrder(out.Errors, []keyDenial{
{"protected/x", wantExplicitResourceDeny(user.conf.awsID, actS3DeleteObject, objectArn(bucket, "protected/x"))},
{"protected/v", wantExplicitResourceDeny(user.conf.awsID, actS3DeleteObjectVersion, objectArn(bucket, "protected/v"))},
{"protected/x", wantExplicitResourceDeny(user.arn, actS3DeleteObject, objectArn(bucket, "protected/x"))},
{"protected/v", wantExplicitResourceDeny(user.arn, actS3DeleteObjectVersion, objectArn(bucket, "protected/v"))},
{"secret/z", wantImplicitDeny(user.arn, actS3DeleteObject, objectArn(bucket, "secret/z"))},
{"other/v", wantImplicitDeny(user.arn, actS3DeleteObjectVersion, objectArn(bucket, "other/v"))},
})
@@ -2050,24 +2047,6 @@ func S3IAMAccessControl_inactive_and_deleted_credentials(s *S3Conf) error {
})
}
// S3IAMAccessControl_bucket_policy_unknown_principal_rejected verifies
// PutBucketPolicy validates its principals against the IAM service, so a
// policy naming somebody who doesn't exist is rejected instead of being
// stored as a statement that can never match.
func S3IAMAccessControl_bucket_policy_unknown_principal_rejected(s *S3Conf) error {
testName := "S3IAMAccessControl_bucket_policy_unknown_principal_rejected"
return s3IAMActionHandler(s, testName, func(root *iam.Client, bucket string) error {
err := putBucketPolicyDoc(s, bucket, bucketStatement{
Effect: "Allow", Principal: "AKIADOESNOTEXIST", Action: actS3GetObject, Resource: objectsArn(bucket),
})
return checkApiErr(err, s3err.APIError{
Code: "MalformedPolicy",
Description: "Invalid principal in policy",
HTTPStatusCode: 400,
})
})
}
// S3IAMAccessControl_access_key_last_used_records_s3 covers last-used
// tracking for the S3 data plane: an IAM user's S3 request is recorded
// against the access key that signed it, with the "s3" service name and the
+475
View File
@@ -0,0 +1,475 @@
// Copyright 2026 Versity Software
// This file is licensed under the Apache License, Version 2.0
// (the "License"); you may not use this file except in compliance
// with the License. You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing,
// software distributed under the License is distributed on an
// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
// KIND, either express or implied. See the License for the
// specific language governing permissions and limitations
// under the License.
package integration
import (
"context"
"fmt"
"strings"
"github.com/aws/aws-sdk-go-v2/aws"
"github.com/aws/aws-sdk-go-v2/service/iam"
"github.com/aws/aws-sdk-go-v2/service/s3"
"github.com/versity/versitygw/s3err"
)
// wantInvalidPrincipal is the error PutBucketPolicy reports for a Principal
// that names nothing this IAM service knows.
func wantInvalidPrincipal() s3err.APIError {
return getMalformedPolicyError("Invalid principal in policy")
}
// S3IAMPrincipal_accepted_forms covers every Principal form PutBucketPolicy
// accepts: the wildcard in both its shapes, an existing user's ARN, an
// existing role's ARN, an assumed-role ARN naming a session of an existing
// role, the account root ARN, the bare account id, and a list mixing them.
func S3IAMPrincipal_accepted_forms(s *S3Conf) error {
testName := "S3IAMPrincipal_accepted_forms"
return s3IAMActionHandler(s, testName, func(root *iam.Client, bucket string) error {
user, cleanup, err := newS3IAMUser(root, s, nil)
if err != nil {
return err
}
defer cleanup()
roleName, roleCleanup, err := newS3IAMBareRole(root)
if err != nil {
return err
}
defer roleCleanup()
cases := []struct {
name string
principal any
}{
{"wildcard string", "*"},
{"wildcard aws struct", map[string]any{"AWS": "*"}},
{"user arn", user.arn},
{"user arn in an aws struct", map[string]any{"AWS": user.arn}},
{"role arn", roleArnFor(roleName)},
// The session name is never validated: it names a session that
// need not have been minted when the policy is written.
{"assumed role arn", assumedRoleArnFor(roleName, "any-session-name")},
{"account root arn", accountArn()},
{"bare account id", testAccountID},
{"list of arns", []string{user.arn, roleArnFor(roleName)}},
{"aws struct with a list", map[string]any{"AWS": []string{user.arn, accountArn()}}},
}
for _, tc := range cases {
err := putBucketPolicyDoc(s, bucket, bucketStatement{
Effect: "Allow", Principal: tc.principal, Action: actS3GetObject, Resource: objectsArn(bucket),
})
if err != nil {
return fmt.Errorf("%s: expected the policy to be accepted: %w", tc.name, err)
}
}
return nil
})
}
// S3IAMPrincipal_rejected_forms covers what PutBucketPolicy refuses to
// store, all of it with the same MalformedPolicy error real S3 reports. The
// two that matter most are an access key id — what every other IAM backend
// names principals by, and what this one no longer accepts — and any
// wildcard inside an ARN: only a whole Principal of "*" is a wildcard, so
// there is no pattern form of a principal at all.
func S3IAMPrincipal_rejected_forms(s *S3Conf) error {
testName := "S3IAMPrincipal_rejected_forms"
return s3IAMActionHandler(s, testName, func(root *iam.Client, bucket string) error {
user, cleanup, err := newS3IAMUser(root, s, nil)
if err != nil {
return err
}
defer cleanup()
roleName, roleCleanup, err := newS3IAMBareRole(root)
if err != nil {
return err
}
defer roleCleanup()
cases := []struct {
name string
principal any
}{
{"access key id", user.conf.awsID},
{"bare user name", user.name},
{"non existing user arn", userArnFor("no-such-user")},
{"non existing role arn", roleArnFor("no-such-role")},
{"assumed role arn of a non existing role", assumedRoleArnFor("no-such-role", "sess")},
{"assumed role arn missing the session name", "arn:aws:sts::" + testAccountID + ":assumed-role/" + roleName},
// A session name is not checked for existence, but it must be
// one that could exist: a form no session can ever carry would
// be stored as a statement that can never match.
{"wildcard session name", assumedRoleArnFor(roleName, "*")},
{"session name too short", assumedRoleArnFor(roleName, "s")},
{"session name with a space", assumedRoleArnFor(roleName, "sess name")},
{"session name too long", assumedRoleArnFor(roleName, strings.Repeat("s", 65))},
{"wrong case role name in an assumed role arn", assumedRoleArnFor(strings.ToUpper(roleName), "sess")},
{"wildcard within an arn", userArnFor("*")},
{"wildcard suffix within an arn", userArnFor(user.name[:4] + "*")},
{"wildcard account in an arn", "arn:aws:iam::*:user/" + user.name},
{"wrong case resource type", "arn:aws:iam::" + testAccountID + ":USER/" + user.name},
{"wrong case user name", userArnFor(strings.ToUpper(user.name))},
{"trailing slash after the user name", user.arn + "/"},
{"leading whitespace", " " + user.arn},
{"trailing whitespace", user.arn + " "},
{"another account", "arn:aws:iam::111111111111:root"},
{"another account user", "arn:aws:iam::111111111111:user/" + user.name},
{"account id of another account", "111111111111"},
{"region bearing arn", "arn:aws:iam:us-east-1:" + testAccountID + ":root"},
{"wrong partition", "arn:aws-cn:iam::" + testAccountID + ":root"},
{"group arn", "arn:aws:iam::" + testAccountID + ":group/admins"},
{"non iam arn", bucketArn(bucket)},
{"not an arn", "not-an-arn"},
{"wildcard mixed with an arn", []string{"*", user.arn}},
{"list with one unresolvable entry", []string{user.arn, userArnFor("no-such-user")}},
}
for _, tc := range cases {
err := putBucketPolicyDoc(s, bucket, bucketStatement{
Effect: "Allow", Principal: tc.principal, Action: actS3GetObject, Resource: objectsArn(bucket),
})
if err := checkApiErr(err, wantInvalidPrincipal()); err != nil {
return fmt.Errorf("%s: %w", tc.name, err)
}
}
return nil
})
}
// S3IAMPrincipal_empty_forms covers the Principal shapes that carry no
// principal at all. They are rejected the same way they were before
// principals were ARNs — the check is on the element's shape, not on what it
// names — so this pins that the ARN change left them alone.
func S3IAMPrincipal_empty_forms(s *S3Conf) error {
testName := "S3IAMPrincipal_empty_forms"
return s3IAMActionHandler(s, testName, func(root *iam.Client, bucket string) error {
cases := []struct {
name string
principal any
}{
{"empty string", ""},
{"empty list", []string{}},
{"empty aws struct", map[string]any{"AWS": ""}},
{"empty aws list", map[string]any{"AWS": []string{}}},
}
for _, tc := range cases {
err := putBucketPolicyDoc(s, bucket, bucketStatement{
Effect: "Allow", Principal: tc.principal, Action: actS3GetObject, Resource: objectsArn(bucket),
})
if err := checkApiErr(err, wantInvalidPrincipal()); err != nil {
return fmt.Errorf("%s: %w", tc.name, err)
}
}
return nil
})
}
// S3IAMPrincipal_user_arn_names_only_that_user verifies a user ARN grants
// the user it names and nobody else — neither another user, nor a caller
// named by the access key that used to be the principal form.
func S3IAMPrincipal_user_arn_names_only_that_user(s *S3Conf) error {
testName := "S3IAMPrincipal_user_arn_names_only_that_user"
return s3IAMActionHandler(s, testName, func(root *iam.Client, bucket string) error {
if err := putObjectAsRoot(s, bucket, "obj"); err != nil {
return err
}
granted, cleanupGranted, err := newS3IAMUser(root, s, nil)
if err != nil {
return err
}
defer cleanupGranted()
other, cleanupOther, err := newS3IAMUser(root, s, nil)
if err != nil {
return err
}
defer cleanupOther()
if err := putBucketPolicyDoc(s, bucket, bucketStatement{
Effect: "Allow", Principal: granted.arn, Action: actS3GetObject, Resource: objectsArn(bucket),
}); err != nil {
return err
}
if err := getObjectAllowed(granted, bucket, "obj"); err != nil {
return err
}
return getObjectDenied(other, bucket, "obj", wantImplicitDeny(other.arn, actS3GetObject, objectArn(bucket, "obj")))
})
}
// S3IAMPrincipal_pathed_user_arn verifies a user's IAM path is part of the
// ARN naming it: only the full-path form resolves at all, and it matches the
// user it names.
func S3IAMPrincipal_pathed_user_arn(s *S3Conf) error {
testName := "S3IAMPrincipal_pathed_user_arn"
return s3IAMActionHandler(s, testName, func(root *iam.Client, bucket string) error {
if err := putObjectAsRoot(s, bucket, "obj"); err != nil {
return err
}
userName := newIAMUserName()
createOut, err := createIAMUser(root, &iam.CreateUserInput{
UserName: aws.String(userName),
Path: aws.String("/team/sub/"),
})
if err != nil {
return err
}
defer deleteS3IAMUser(root, userName)
user, err := s3IAMUserWithNewKey(root, s, userName, aws.ToString(createOut.User.Arn))
if err != nil {
return err
}
// The path-less form names no identity, so it never reaches
// storage.
err = putBucketPolicyDoc(s, bucket, bucketStatement{
Effect: "Allow", Principal: userArnFor(userName), Action: actS3GetObject, Resource: objectsArn(bucket),
})
if err := checkApiErr(err, wantInvalidPrincipal()); err != nil {
return fmt.Errorf("path-less user arn: %w", err)
}
if err := putBucketPolicyDoc(s, bucket, bucketStatement{
Effect: "Allow", Principal: user.arn, Action: actS3GetObject, Resource: objectsArn(bucket),
}); err != nil {
return fmt.Errorf("full user arn: %w", err)
}
return getObjectAllowed(user, bucket, "obj")
})
}
// S3IAMPrincipal_account_delegates_but_does_not_grant is the asymmetry the
// account-level principal forms carry: naming the account in an Allow
// delegates to the account's own IAM rather than granting anything, so a
// user with no identity policy is still denied and the same user with one is
// allowed. Naming it in a Deny delegates nothing and denies outright.
func S3IAMPrincipal_account_delegates_but_does_not_grant(s *S3Conf) error {
testName := "S3IAMPrincipal_account_delegates_but_does_not_grant"
return s3IAMActionHandler(s, testName, func(root *iam.Client, bucket string) error {
if err := putObjectAsRoot(s, bucket, "obj"); err != nil {
return err
}
// Both account forms are the same principal and must behave
// identically.
for _, principal := range []string{accountArn(), testAccountID} {
if err := func() error {
user, cleanup, err := newS3IAMUser(root, s, nil)
if err != nil {
return err
}
defer cleanup()
if err := putBucketPolicyDoc(s, bucket, bucketStatement{
Effect: "Allow", Principal: principal, Action: actS3GetObject, Resource: objectsArn(bucket),
}); err != nil {
return err
}
// Delegation grants nothing on its own.
if err := getObjectDenied(user, bucket, "obj",
wantImplicitDeny(user.arn, actS3GetObject, objectArn(bucket, "obj"))); err != nil {
return fmt.Errorf("allow without an identity policy: %w", err)
}
// ...and the identity policy it delegates to does.
if err := putS3IAMUserPolicy(root, user, "p", policyDoc(accessStatement{
Effect: "Allow", Action: actS3GetObject, Resource: objectsArn(bucket),
})); err != nil {
return err
}
if err := getObjectAllowed(user, bucket, "obj"); err != nil {
return fmt.Errorf("allow with an identity policy: %w", err)
}
// A Deny naming the account is not a delegation: it denies
// the same user its own identity policy just allowed.
if err := putBucketPolicyDoc(s, bucket, bucketStatement{
Effect: "Deny", Principal: principal, Action: actS3GetObject, Resource: objectsArn(bucket),
}); err != nil {
return err
}
return getObjectDenied(user, bucket, "obj",
wantExplicitResourceDeny(user.arn, actS3GetObject, objectArn(bucket, "obj")))
}(); err != nil {
return fmt.Errorf("principal %q: %w", principal, err)
}
}
return nil
})
}
// S3IAMPrincipal_role_arn_does_not_match_a_user verifies a role ARN names
// sessions of that role and nothing else: a long-term user is not covered by
// it, however the role is otherwise configured. The session side — that a
// role ARN does match every session of the role — needs a real OIDC token
// and lives in the s3-iam-session group.
func S3IAMPrincipal_role_arn_does_not_match_a_user(s *S3Conf) error {
testName := "S3IAMPrincipal_role_arn_does_not_match_a_user"
return s3IAMActionHandler(s, testName, func(root *iam.Client, bucket string) error {
if err := putObjectAsRoot(s, bucket, "obj"); err != nil {
return err
}
user, cleanup, err := newS3IAMUser(root, s, nil)
if err != nil {
return err
}
defer cleanup()
roleName, roleCleanup, err := newS3IAMBareRole(root)
if err != nil {
return err
}
defer roleCleanup()
if err := putBucketPolicyDoc(s, bucket, bucketStatement{
Effect: "Allow", Principal: roleArnFor(roleName), Action: actS3GetObject, Resource: objectsArn(bucket),
}); err != nil {
return err
}
return getObjectDenied(user, bucket, "obj",
wantImplicitDeny(user.arn, actS3GetObject, objectArn(bucket, "obj")))
})
}
// S3IAMPrincipal_deleted_user_arn_rejected verifies the write-time check
// tracks the IAM service rather than a snapshot of it: an ARN that resolved
// when a policy naming it was written no longer resolves once the user is
// gone, so the same document can no longer be stored.
func S3IAMPrincipal_deleted_user_arn_rejected(s *S3Conf) error {
testName := "S3IAMPrincipal_deleted_user_arn_rejected"
return s3IAMActionHandler(s, testName, func(root *iam.Client, bucket string) error {
user, cleanup, err := newS3IAMUser(root, s, nil)
if err != nil {
return err
}
// The test deletes the user itself; cleanup tolerates that and
// still runs if an earlier step fails first.
defer cleanup()
statement := bucketStatement{
Effect: "Allow", Principal: user.arn, Action: actS3GetObject, Resource: objectsArn(bucket),
}
if err := putBucketPolicyDoc(s, bucket, statement); err != nil {
return fmt.Errorf("expected a live user's arn to be accepted: %w", err)
}
if err := deleteS3IAMUser(root, user.name); err != nil {
return err
}
err = putBucketPolicyDoc(s, bucket, statement)
return checkApiErr(err, wantInvalidPrincipal())
})
}
// S3IAMPrincipal_wildcard_grants_everyone verifies "*" still names every
// caller, the one principal form that does not have to resolve to anything.
func S3IAMPrincipal_wildcard_grants_everyone(s *S3Conf) error {
testName := "S3IAMPrincipal_wildcard_grants_everyone"
return s3IAMActionHandler(s, testName, func(root *iam.Client, bucket string) error {
if err := putObjectAsRoot(s, bucket, "obj"); err != nil {
return err
}
user, cleanup, err := newS3IAMUser(root, s, nil)
if err != nil {
return err
}
defer cleanup()
if err := putBucketPolicyDoc(s, bucket, bucketStatement{
Effect: "Allow", Principal: "*", Action: actS3GetObject, Resource: objectsArn(bucket),
}); err != nil {
return err
}
return getObjectAllowed(user, bucket, "obj")
})
}
// newS3IAMBareRole creates a role nothing ever assumes: these tests only
// need one to exist, so that a Principal naming it resolves. Its trust
// policy names the account rather than an OIDC provider, which keeps the
// fixture free of the whole web-identity setup a session needs — those tests
// live in the s3-iam-session group.
func newS3IAMBareRole(root *iam.Client) (roleName string, cleanup func(), err error) {
roleName = newIAMRoleName()
trust := `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Principal":{"AWS":"` +
accountArn() + `"},"Action":"sts:AssumeRole"}]}`
if _, err := createIAMRole(root, &iam.CreateRoleInput{
RoleName: aws.String(roleName),
AssumeRolePolicyDocument: aws.String(trust),
}); err != nil {
return "", nil, fmt.Errorf("create role: %w", err)
}
return roleName, func() { deleteIAMRole(root, roleName) }, nil
}
// s3IAMUserWithNewKey mints an access key for an already-created user and
// returns it as an s3IAMPrincipal, for the tests that need a user
// newS3IAMUser cannot create — one with an IAM path, for instance.
func s3IAMUserWithNewKey(root *iam.Client, s *S3Conf, userName, userArn string) (*s3IAMPrincipal, error) {
keyOut, err := createIAMAccessKey(root, &iam.CreateAccessKeyInput{UserName: aws.String(userName)})
if err != nil {
return nil, fmt.Errorf("create access key: %w", err)
}
conf := *s
conf.awsID = aws.ToString(keyOut.AccessKey.AccessKeyId)
conf.awsSecret = aws.ToString(keyOut.AccessKey.SecretAccessKey)
return &s3IAMPrincipal{name: userName, arn: userArn, conf: conf, client: conf.GetClient()}, nil
}
// putObjectAsRoot puts an object the test's principals then read, so what a
// test measures is their authorization rather than their ability to set the
// scene.
func putObjectAsRoot(s *S3Conf, bucket, key string) error {
ctx, cancel := context.WithTimeout(context.Background(), shortTimeout)
defer cancel()
_, err := s.GetClient().PutObject(ctx, &s3.PutObjectInput{Bucket: &bucket, Key: &key})
return err
}
// getObjectAllowed and getObjectDenied assert the two outcomes a principal
// test cares about, so each case reads as the one line it is.
func getObjectAllowed(p *s3IAMPrincipal, bucket, key string) error {
ctx, cancel := context.WithTimeout(context.Background(), shortTimeout)
defer cancel()
if _, err := p.client.GetObject(ctx, &s3.GetObjectInput{Bucket: &bucket, Key: &key}); err != nil {
return fmt.Errorf("expected GetObject to be allowed for %v: %w", p.arn, err)
}
return nil
}
func getObjectDenied(p *s3IAMPrincipal, bucket, key string, want s3err.S3Error) error {
ctx, cancel := context.WithTimeout(context.Background(), shortTimeout)
defer cancel()
_, err := p.client.GetObject(ctx, &s3.GetObjectInput{Bucket: &bucket, Key: &key})
return checkApiErr(err, want)
}
@@ -309,11 +309,11 @@ func S3IAMSession_session_policy_without_role_policy_denied(s *S3Conf) error {
// policy is independently sufficient for a session too, exactly as it is for
// a long-term user.
//
// The bucket policy names "*" rather than the session: this gateway matches
// bucket-policy principals against the caller's access key, and a session's
// key is ephemeral, so auth.CheckIfAccountsExist rejects one as a principal
// outright rather than let a policy come to reference a principal that stops
// existing. See bucketStatement.
// The policy names the session's role ARN, which is how a bucket policy
// names every session of a role: no wildcard is allowed inside a principal
// ARN, so the role ARN is the only form that covers sessions the policy was
// written before. Naming one specific session is
// S3IAMSession_bucket_policy_names_one_session.
func S3IAMSession_bucket_policy_allows_without_role_policy(s *S3Conf) error {
testName := "S3IAMSession_bucket_policy_allows_without_role_policy"
return s3IAMSessionActionHandler(s, testName, func(root *iam.Client, bucket string) error {
@@ -323,11 +323,6 @@ func S3IAMSession_bucket_policy_allows_without_role_policy(s *S3Conf) error {
if err != nil {
return err
}
if err := putBucketPolicyDoc(s, bucket, bucketStatement{
Effect: "Allow", Principal: "*", Action: actS3GetObject, Resource: objectsArn(bucket),
}); err != nil {
return err
}
session, cleanup, err := newGitHubSession(root, s, nil, "")
if err != nil {
@@ -335,6 +330,12 @@ func S3IAMSession_bucket_policy_allows_without_role_policy(s *S3Conf) error {
}
defer cleanup()
if err := putBucketPolicyDoc(s, bucket, bucketStatement{
Effect: "Allow", Principal: roleArnFor(session.name), Action: actS3GetObject, Resource: objectsArn(bucket),
}); err != nil {
return err
}
ctx, cancel = context.WithTimeout(context.Background(), shortTimeout)
_, err = session.client.GetObject(ctx, &s3.GetObjectInput{Bucket: &bucket, Key: getPtr("obj")})
cancel()
@@ -367,13 +368,6 @@ func S3IAMSession_session_policy_filters_bucket_policy_grant(s *S3Conf) error {
if err != nil {
return err
}
if err := putBucketPolicyDoc(s, bucket, bucketStatement{
Effect: "Allow", Principal: "*",
Action: []string{actS3GetObject, actS3PutObject}, Resource: objectsArn(bucket),
}); err != nil {
return err
}
session, cleanup, err := newGitHubSession(root, s, nil,
policyDoc(accessStatement{Effect: "Allow", Action: actS3GetObject, Resource: objectsArn(bucket)}))
if err != nil {
@@ -381,6 +375,13 @@ func S3IAMSession_session_policy_filters_bucket_policy_grant(s *S3Conf) error {
}
defer cleanup()
if err := putBucketPolicyDoc(s, bucket, bucketStatement{
Effect: "Allow", Principal: roleArnFor(session.name),
Action: []string{actS3GetObject, actS3PutObject}, Resource: objectsArn(bucket),
}); err != nil {
return err
}
ctx, cancel = context.WithTimeout(context.Background(), shortTimeout)
_, err = session.client.GetObject(ctx, &s3.GetObjectInput{Bucket: &bucket, Key: getPtr("obj")})
cancel()
@@ -407,12 +408,6 @@ func S3IAMSession_bucket_policy_deny_overrides_role_allow(s *S3Conf) error {
if err != nil {
return err
}
if err := putBucketPolicyDoc(s, bucket, bucketStatement{
Effect: "Deny", Principal: "*", Action: actS3GetObject, Resource: objectsArn(bucket),
}); err != nil {
return err
}
session, cleanup, err := newGitHubSession(root, s, map[string]string{
"p": policyDoc(accessStatement{Effect: "Allow", Action: "s3:*", Resource: objectsArn(bucket)}),
}, "")
@@ -421,13 +416,18 @@ func S3IAMSession_bucket_policy_deny_overrides_role_allow(s *S3Conf) error {
}
defer cleanup()
if err := putBucketPolicyDoc(s, bucket, bucketStatement{
Effect: "Deny", Principal: roleArnFor(session.name), Action: actS3GetObject, Resource: objectsArn(bucket),
}); err != nil {
return err
}
ctx, cancel = context.WithTimeout(context.Background(), shortTimeout)
_, err = session.client.GetObject(ctx, &s3.GetObjectInput{Bucket: &bucket, Key: getPtr("obj")})
cancel()
// A resource-based denial names the raw access key: bucket-policy
// principals are access-key-based for every backend, so no ARN is in
// hand at that point.
return checkApiErr(err, wantExplicitResourceDeny(session.conf.awsID, actS3GetObject, objectArn(bucket, "obj")))
// A session is named by its assumed-role ARN in a denial message,
// which is what real S3 reports too.
return checkApiErr(err, wantExplicitResourceDeny(session.arn, actS3GetObject, objectArn(bucket, "obj")))
})
}
@@ -685,9 +685,12 @@ func S3IAMSession_delete_objects_authorizes_each_key(s *S3Conf) error {
}
// S3IAMSession_condition_identity_keys verifies the identity-derived
// condition keys describe the *session*, not the underlying role: aws:userid
// carries the role id and session name, and aws:PrincipalArn the
// assumed-role ARN.
// condition keys for a session. They do not all describe the same thing:
// aws:userid carries the role id and the session name, and so pins one
// session, while aws:PrincipalArn is the assumed *role's* ARN and therefore
// covers every session of it — a Condition on it can never single one out.
// A denial message names the session by its assumed-role ARN, which is a
// different thing from aws:PrincipalArn and deliberately so.
func S3IAMSession_condition_identity_keys(s *S3Conf) error {
testName := "S3IAMSession_condition_identity_keys"
return s3IAMSessionActionHandler(s, testName, func(root *iam.Client, bucket string) error {
@@ -704,10 +707,14 @@ func S3IAMSession_condition_identity_keys(s *S3Conf) error {
wantAllowed bool
}{
{
name: "principal arn matches the assumed-role session",
condition: func(p *s3IAMPrincipal) []byte { return cond("StringEquals", "aws:PrincipalArn", p.arn) },
name: "principal arn is the assumed role's arn",
condition: func(p *s3IAMPrincipal) []byte { return cond("StringEquals", "aws:PrincipalArn", roleArnFor(p.name)) },
wantAllowed: true,
},
{
name: "principal arn is not the assumed-role session arn",
condition: func(p *s3IAMPrincipal) []byte { return cond("StringEquals", "aws:PrincipalArn", p.arn) },
},
{
name: "principal type is AssumedRole",
condition: func(p *s3IAMPrincipal) []byte { return cond("StringEquals", "aws:PrincipalType", "AssumedRole") },
@@ -1031,3 +1038,210 @@ func S3IAMSession_role_last_used_records_s3(s *S3Conf) error {
return nil
})
}
// S3IAMSession_bucket_policy_role_arn_covers_every_session verifies a
// Principal naming a role covers sessions of it that did not exist when the
// policy was written. That is the only way to express "any session of this
// role": no wildcard is allowed inside a principal ARN.
func S3IAMSession_bucket_policy_role_arn_covers_every_session(s *S3Conf) error {
testName := "S3IAMSession_bucket_policy_role_arn_covers_every_session"
return s3IAMSessionActionHandler(s, testName, func(root *iam.Client, bucket string) error {
ctx, cancel := context.WithTimeout(context.Background(), shortTimeout)
_, err := s.GetClient().PutObject(ctx, &s3.PutObjectInput{Bucket: &bucket, Key: getPtr("obj")})
cancel()
if err != nil {
return err
}
session, cleanup, err := newGitHubSession(root, s, nil, "")
if err != nil {
return err
}
defer cleanup()
if err := putBucketPolicyDoc(s, bucket, bucketStatement{
Effect: "Allow", Principal: roleArnFor(session.name), Action: actS3GetObject, Resource: objectsArn(bucket),
}); err != nil {
return err
}
// A session minted after the policy was written is covered by it
// just as the first one is.
later, err := anotherSessionOfRole(s, session.name)
if err != nil {
return err
}
for _, p := range []*s3IAMPrincipal{session, later} {
ctx, cancel = context.WithTimeout(context.Background(), shortTimeout)
_, err = p.client.GetObject(ctx, &s3.GetObjectInput{Bucket: &bucket, Key: getPtr("obj")})
cancel()
if err != nil {
return fmt.Errorf("expected GetObject to be allowed for %v: %w", p.arn, err)
}
}
return nil
})
}
// S3IAMSession_bucket_policy_names_one_session verifies the other half:
// a Principal naming one assumed-role session covers that session and no
// other session of the same role.
func S3IAMSession_bucket_policy_names_one_session(s *S3Conf) error {
testName := "S3IAMSession_bucket_policy_names_one_session"
return s3IAMSessionActionHandler(s, testName, func(root *iam.Client, bucket string) error {
ctx, cancel := context.WithTimeout(context.Background(), shortTimeout)
_, err := s.GetClient().PutObject(ctx, &s3.PutObjectInput{Bucket: &bucket, Key: getPtr("obj")})
cancel()
if err != nil {
return err
}
named, cleanup, err := newGitHubSession(root, s, nil, "")
if err != nil {
return err
}
defer cleanup()
other, err := anotherSessionOfRole(s, named.name)
if err != nil {
return err
}
if err := putBucketPolicyDoc(s, bucket, bucketStatement{
Effect: "Allow", Principal: named.arn, Action: actS3GetObject, Resource: objectsArn(bucket),
}); err != nil {
return err
}
ctx, cancel = context.WithTimeout(context.Background(), shortTimeout)
_, err = named.client.GetObject(ctx, &s3.GetObjectInput{Bucket: &bucket, Key: getPtr("obj")})
cancel()
if err != nil {
return fmt.Errorf("expected GetObject to be allowed for the named session: %w", err)
}
ctx, cancel = context.WithTimeout(context.Background(), shortTimeout)
_, err = other.client.GetObject(ctx, &s3.GetObjectInput{Bucket: &bucket, Key: getPtr("obj")})
cancel()
return checkApiErr(err, wantImplicitDeny(other.arn, actS3GetObject, objectArn(bucket, "obj")))
})
}
// S3IAMSession_bucket_policy_account_delegates verifies the account-level
// principal forms delegate rather than grant for a session too: naming the
// account allows nothing without a role policy, and denies everything under
// a Deny.
func S3IAMSession_bucket_policy_account_delegates(s *S3Conf) error {
testName := "S3IAMSession_bucket_policy_account_delegates"
return s3IAMSessionActionHandler(s, testName, func(root *iam.Client, bucket string) error {
ctx, cancel := context.WithTimeout(context.Background(), shortTimeout)
_, err := s.GetClient().PutObject(ctx, &s3.PutObjectInput{Bucket: &bucket, Key: getPtr("obj")})
cancel()
if err != nil {
return err
}
session, cleanup, err := newGitHubSession(root, s, nil, "")
if err != nil {
return err
}
defer cleanup()
if err := putBucketPolicyDoc(s, bucket, bucketStatement{
Effect: "Allow", Principal: accountArn(), Action: actS3GetObject, Resource: objectsArn(bucket),
}); err != nil {
return err
}
ctx, cancel = context.WithTimeout(context.Background(), shortTimeout)
_, err = session.client.GetObject(ctx, &s3.GetObjectInput{Bucket: &bucket, Key: getPtr("obj")})
cancel()
if err := checkApiErr(err, wantImplicitDeny(session.arn, actS3GetObject, objectArn(bucket, "obj"))); err != nil {
return fmt.Errorf("allow naming the account: %w", err)
}
// The role policy the account principal delegates to is what
// actually grants. It has to be removed again before teardown:
// DeleteRole refuses a role that still carries an inline policy.
if _, err := putIAMRolePolicy(root, &iam.PutRolePolicyInput{
RoleName: aws.String(session.name),
PolicyName: aws.String("p"),
PolicyDocument: aws.String(policyDoc(accessStatement{
Effect: "Allow", Action: actS3GetObject, Resource: objectsArn(bucket),
})),
}); err != nil {
return err
}
defer deleteIAMRolePolicy(root, session.name, "p")
ctx, cancel = context.WithTimeout(context.Background(), shortTimeout)
_, err = session.client.GetObject(ctx, &s3.GetObjectInput{Bucket: &bucket, Key: getPtr("obj")})
cancel()
if err != nil {
return fmt.Errorf("expected the role policy to grant what the account principal delegated: %w", err)
}
// A Deny naming the account is not a delegation.
if err := putBucketPolicyDoc(s, bucket, bucketStatement{
Effect: "Deny", Principal: accountArn(), Action: actS3GetObject, Resource: objectsArn(bucket),
}); err != nil {
return err
}
ctx, cancel = context.WithTimeout(context.Background(), shortTimeout)
_, err = session.client.GetObject(ctx, &s3.GetObjectInput{Bucket: &bucket, Key: getPtr("obj")})
cancel()
return checkApiErr(err, wantExplicitResourceDeny(session.arn, actS3GetObject, objectArn(bucket, "obj")))
})
}
// S3IAMSession_bucket_policy_session_principal_forms covers what
// PutBucketPolicy makes of the session-shaped principal forms: an
// assumed-role ARN resolves as long as its role does, whatever session name
// it carries, and the forms that name no role do not resolve at all.
func S3IAMSession_bucket_policy_session_principal_forms(s *S3Conf) error {
testName := "S3IAMSession_bucket_policy_session_principal_forms"
return s3IAMSessionActionHandler(s, testName, func(root *iam.Client, bucket string) error {
session, cleanup, err := newGitHubSession(root, s, nil, "")
if err != nil {
return err
}
defer cleanup()
accepted := []struct {
name string
principal string
}{
{"the live session's arn", session.arn},
{"a session name never assumed", assumedRoleArnFor(session.name, "never-assumed")},
}
for _, tc := range accepted {
if err := putBucketPolicyDoc(s, bucket, bucketStatement{
Effect: "Allow", Principal: tc.principal, Action: actS3GetObject, Resource: objectsArn(bucket),
}); err != nil {
return fmt.Errorf("%s: expected the policy to be accepted: %w", tc.name, err)
}
}
rejected := []struct {
name string
principal string
}{
{"session access key id", session.conf.awsID},
{"wildcard session name", assumedRoleArnFor(session.name, "*")},
{"assumed-role arn of a non existing role", assumedRoleArnFor("no-such-role", "sess")},
{"assumed-role arn with the iam service", "arn:aws:iam::" + testAccountID + ":assumed-role/" + session.name + "/sess"},
{"role arn with the sts service", "arn:aws:sts::" + testAccountID + ":role/" + session.name},
}
for _, tc := range rejected {
err := putBucketPolicyDoc(s, bucket, bucketStatement{
Effect: "Allow", Principal: tc.principal, Action: actS3GetObject, Resource: objectsArn(bucket),
})
if err := checkApiErr(err, wantInvalidPrincipal()); err != nil {
return fmt.Errorf("%s: %w", tc.name, err)
}
}
return nil
})
}
+59 -8
View File
@@ -227,14 +227,14 @@ func putBucketPolicyDoc(s *S3Conf, bucket string, statements ...bucketStatement)
// the difference is Principal, which bucket policies require and identity
// policies forbid.
//
// Principal is matched against the caller's raw access key by this gateway
// (auth.Principals.Contains) — deliberately not against an ARN, for
// compatibility with the non-IAM backends that have no ARNs at all. A
// long-term user is therefore named by its AKIA… access key. An assumed-role
// session cannot be named at all: its ASIA… key is ephemeral, so
// auth.IAMService.ResolveAccounts rejects it outright rather than let a bucket
// policy come to reference a principal that stops existing. Session tests
// use "*" for that reason.
// Under the standalone IAM service — the only backend these groups run
// against — Principal names an AWS-style ARN, the way real S3 does
// (auth.Principals.matchFor): a user by its own ARN, a role by its role ARN,
// which covers every session of it, and one session by its
// arn:aws:sts::…:assumed-role/<role>/<session> ARN. The account root ARN and
// the bare account id name the account, which delegates rather than grants.
// The gateway's other IAM backends have no ARNs at all and keep naming
// principals by access key id; those are the Access_Control tests, not these.
type bucketStatement struct {
Sid string `json:"Sid,omitempty"`
Effect string `json:"Effect"`
@@ -545,6 +545,39 @@ func newGitHubSession(root *iam.Client, s *S3Conf, rolePolicies map[string]strin
return principal, cleanup, nil
}
// anotherSessionOfRole mints a second, independent session of a role a test
// already holds one of — the fixture for the difference between a bucket
// policy naming a role, which covers every session of it, and one naming a
// single session, which covers only that one.
func anotherSessionOfRole(s *S3Conf, roleName string) (*s3IAMPrincipal, error) {
token, ok := gitHubOIDCToken()
if !ok {
return nil, fmt.Errorf("no GitHub OIDC token available")
}
sessionName := "s3-sess-" + genRandString(8)
out, err := assumeRoleWithWebIdentitySessionPolicy(s, roleArnFor(roleName), sessionName, token, "")
if err != nil {
return nil, fmt.Errorf("AssumeRoleWithWebIdentity: %w", err)
}
access := aws.ToString(out.Credentials.AccessKeyId)
secret := aws.ToString(out.Credentials.SecretAccessKey)
sessionToken := aws.ToString(out.Credentials.SessionToken)
conf := *s
conf.awsID = access
conf.awsSecret = secret
return &s3IAMPrincipal{
name: roleName,
arn: aws.ToString(out.AssumedRoleUser.Arn),
conf: conf,
client: s3ClientWithSessionCreds(s, access, secret, sessionToken),
sessionToken: sessionToken,
}, nil
}
// assumeRoleWithWebIdentitySessionPolicy is assumeRoleWithWebIdentity with
// the optional inline session-policy parameter, which no other test in this
// package needs.
@@ -569,6 +602,24 @@ func roleArnFor(roleName string) string {
return "arn:aws:iam::" + testAccountID + ":role/" + roleName
}
// userArnFor and assumedRoleArnFor build the remaining principal ARNs a
// bucket policy can name in this gateway's single fixed account.
// assumedRoleArnFor deliberately takes the role's plain name: unlike a role's
// own ARN, an assumed-role ARN never carries the role's IAM path.
func userArnFor(userName string) string {
return "arn:aws:iam::" + testAccountID + ":user/" + userName
}
func assumedRoleArnFor(roleName, sessionName string) string {
return "arn:aws:sts::" + testAccountID + ":assumed-role/" + roleName + "/" + sessionName
}
// accountArn is the principal naming the account itself — the delegating
// form, which grants nothing on its own but denies everything under Deny.
func accountArn() string {
return "arn:aws:iam::" + testAccountID + ":root"
}
// sessionNameFor recovers the session name from an assumed-role ARN, whose
// last path element it is.
func sessionNameFor(p *s3IAMPrincipal) string {
+13 -6
View File
@@ -878,7 +878,9 @@ under the License.
</svg>
<div class="text-sm text-blue-800">
<p class="font-medium">About Bucket Policies</p>
<p class="mt-1">Bucket policies define who can access your bucket and what actions they can perform. The <strong>Principal</strong> is the access key of the user account, and the <strong>Resource</strong> uses standard bucket ARN format.</p>
<p class="mt-1">Bucket policies define who can access your bucket and what actions they can perform. The <strong>Resource</strong> uses standard bucket ARN format.</p>
<p class="mt-1" data-iam-only>The <strong>Principal</strong> is a principal ARN &mdash; a user (<code>arn:aws:iam::000000000000:user/alice</code>), a role, which covers every session of it (<code>arn:aws:iam::000000000000:role/reader</code>), one assumed-role session (<code>arn:aws:sts::000000000000:assumed-role/reader/session</code>), or the account itself (<code>arn:aws:iam::000000000000:root</code>), which delegates to the account&rsquo;s IAM rather than granting on its own. <code>"*"</code> names everyone. Wildcards are not allowed inside an ARN.</p>
<p class="mt-1" data-admin-users-only>The <strong>Principal</strong> is the access key of the user account.</p>
</div>
</div>
</div>
@@ -976,7 +978,8 @@ under the License.
<ul class="list-disc list-inside space-y-1 text-charcoal-400 ml-2">
<li><strong>Sid:</strong> Statement identifier (optional, but recommended)</li>
<li><strong>Effect:</strong> "Allow" or "Deny"</li>
<li><strong>Principal:</strong> Array of access keys (e.g., ["user001", "user002"])</li>
<li data-iam-only><strong>Principal:</strong> Array of principal ARNs (e.g., ["arn:aws:iam::000000000000:user/alice"]), or "*"</li>
<li data-admin-users-only><strong>Principal:</strong> Array of access keys (e.g., ["user001", "user002"])</li>
<li><strong>Action:</strong> Array of S3 actions (e.g., ["s3:GetObject", "s3:PutObject"])</li>
<li><strong>Resource:</strong> Array of ARNs (e.g., ["arn:aws:s3:::bucket/*"])</li>
</ul>
@@ -4205,16 +4208,20 @@ under the License.
function loadExamplePolicy() {
if (!currentPolicyBucket) return;
// The Principal form depends on the IAM backend behind the gateway: a
// standalone IAM service names principals by ARN, the gateway's own
// account store by access key.
const examplePrincipals = api.hasIAM()
? ["arn:aws:iam::000000000000:user/user001", "arn:aws:iam::000000000000:user/user002"]
: ["user001", "user002"];
const examplePolicy = {
"Version": "2012-10-17",
"Statement": [
{
"Sid": "FullS3Access",
"Effect": "Allow",
"Principal": [
"user001",
"user002"
],
"Principal": examplePrincipals,
"Action": [
"s3:AbortMultipartUpload",
"s3:DeleteObject",