mirror of
https://github.com/versity/versitygw.git
synced 2026-09-22 07:54:14 +00:00
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:
+14
-4
@@ -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
|
||||
|
||||
@@ -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
@@ -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:
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -134,6 +134,7 @@ type standaloneIAMExtensions interface {
|
||||
auth.SigningKeyProvider
|
||||
auth.PolicyEvaluator
|
||||
auth.FixedBucketOwner
|
||||
auth.PrincipalResolver
|
||||
}
|
||||
|
||||
type shutdownOnceService struct {
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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"`
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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
@@ -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 — 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’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",
|
||||
|
||||
Reference in New Issue
Block a user