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

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

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

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

159 lines
6.1 KiB
Go

// Copyright 2026 Versity Software
// This file is licensed under the Apache License, Version 2.0
// (the "License"); you may not use this file except in compliance
// with the License. You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing,
// software distributed under the License is distributed on an
// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
// KIND, either express or implied. See the License for the
// specific language governing permissions and limitations
// under the License.
package auth
import (
"testing"
"github.com/stretchr/testify/assert"
)
func TestBucketPolicyDecision_Condition(t *testing.T) {
tests := []struct {
name string
policy string
action Action
object string
condCtx map[string][]string
want policyDecision
}{
{
name: "Allow with matching Condition grants access",
policy: `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Principal":{"AWS":"*"},
"Action":"s3:GetObject","Resource":"arn:aws:s3:::mybucket/*",
"Condition":{"StringEquals":{"aws:UserAgent":"good-agent"}}}]}`,
action: GetObjectAction,
object: "key",
condCtx: map[string][]string{"aws:UserAgent": {"good-agent"}},
want: policyDecisionAllow,
},
{
name: "Allow with non-matching Condition does not grant access",
policy: `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Principal":{"AWS":"*"},
"Action":"s3:GetObject","Resource":"arn:aws:s3:::mybucket/*",
"Condition":{"StringEquals":{"aws:UserAgent":"good-agent"}}}]}`,
action: GetObjectAction,
object: "key",
condCtx: map[string][]string{"aws:UserAgent": {"bad-agent"}},
want: policyDecisionNoMatch,
},
{
name: "Allow with no matching context key does not grant access",
policy: `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Principal":{"AWS":"*"},
"Action":"s3:GetObject","Resource":"arn:aws:s3:::mybucket/*",
"Condition":{"StringEquals":{"aws:UserAgent":"good-agent"}}}]}`,
action: GetObjectAction,
object: "key",
condCtx: nil,
want: policyDecisionNoMatch,
},
{
name: "Deny with matching Condition wins over an unconditional Allow",
policy: `{"Version":"2012-10-17","Statement":[
{"Effect":"Allow","Principal":{"AWS":"*"},"Action":"s3:GetObject","Resource":"arn:aws:s3:::mybucket/*"},
{"Effect":"Deny","Principal":{"AWS":"*"},"Action":"s3:GetObject","Resource":"arn:aws:s3:::mybucket/*",
"Condition":{"IpAddress":{"aws:SourceIp":"10.0.0.0/8"}}}]}`,
action: GetObjectAction,
object: "key",
condCtx: map[string][]string{"aws:SourceIp": {"10.1.2.3"}},
want: policyDecisionDeny,
},
{
name: "Deny with non-matching Condition leaves the unconditional Allow standing",
policy: `{"Version":"2012-10-17","Statement":[
{"Effect":"Allow","Principal":{"AWS":"*"},"Action":"s3:GetObject","Resource":"arn:aws:s3:::mybucket/*"},
{"Effect":"Deny","Principal":{"AWS":"*"},"Action":"s3:GetObject","Resource":"arn:aws:s3:::mybucket/*",
"Condition":{"IpAddress":{"aws:SourceIp":"10.0.0.0/8"}}}]}`,
action: GetObjectAction,
object: "key",
condCtx: map[string][]string{"aws:SourceIp": {"203.0.113.5"}},
want: policyDecisionAllow,
},
{
// s3:prefix, wired up from the request's "prefix" query param by
// the S3 auth middleware, is exercised end to end against a
// ListBucket-shaped policy.
name: "s3:prefix condition key matches against ListBucket",
policy: `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Principal":{"AWS":"*"},
"Action":"s3:ListBucket","Resource":"arn:aws:s3:::mybucket",
"Condition":{"StringEquals":{"s3:prefix":"photos/"}}}]}`,
action: ListBucketAction,
object: "",
condCtx: map[string][]string{"s3:prefix": {"photos/"}},
want: policyDecisionAllow,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
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)
})
}
}
func TestBucketPolicyDecision_UnevaluableConditionFailsClosed(t *testing.T) {
// This shape (an unrecognized operator) can no longer be written via
// PutBucketPolicy once write-time validation rejects it - this test
// exercises the defense-in-depth fallback for a document that reached
// storage some other way (a legacy write, a migration, ...), the same
// scenario iamapi/policy.EvaluateIdentityPolicies guards against.
policy := `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Principal":{"AWS":"*"},
"Action":"s3:GetObject","Resource":"arn:aws:s3:::mybucket/*",
"Condition":{"SomeFutureOperator":{"aws:UserAgent":"good-agent"}}}]}`
decision, _, err := verifyBucketPolicy([]byte(policy), Account{Access: "someaccess"}, "mybucket", "key", nil, nil, GetObjectAction)
assert.NoError(t, err)
assert.Equal(t, policyDecisionDeny, decision)
}
func TestVerifyPublicBucketPolicy_Condition(t *testing.T) {
tests := []struct {
name string
policy string
condCtx map[string][]string
wantErr error
}{
{
name: "public Allow with matching Condition grants access",
policy: `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Principal":"*",
"Action":"s3:GetObject","Resource":"arn:aws:s3:::mybucket/*",
"Condition":{"StringEquals":{"aws:UserAgent":"good-agent"}}}]}`,
condCtx: map[string][]string{"aws:UserAgent": {"good-agent"}},
wantErr: nil,
},
{
name: "public Allow with non-matching Condition denies access",
policy: `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Principal":"*",
"Action":"s3:GetObject","Resource":"arn:aws:s3:::mybucket/*",
"Condition":{"StringEquals":{"aws:UserAgent":"good-agent"}}}]}`,
condCtx: map[string][]string{"aws:UserAgent": {"bad-agent"}},
wantErr: errAccessDenied,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := VerifyPublicBucketPolicy([]byte(tt.policy), "mybucket", "key", tt.condCtx, nil, GetObjectAction)
if tt.wantErr == nil {
assert.NoError(t, err)
return
}
assert.Equal(t, tt.wantErr, err)
})
}
}