mirror of
https://github.com/seaweedfs/seaweedfs.git
synced 2026-09-11 00:50:43 +02:00
* s3: support AWS object form for bucket policy Principal, add NotPrincipal
Bucket policy statements only accepted a bare string or array of strings for
the Principal element, so the AWS-documented object form was rejected:
"Principal": { "AWS": "arn:aws:iam::123456789012:root" }
"Principal": { "AWS": ["arn:...", "999999999999"] }
Add a PolicyPrincipal type that parses the bare string, the bare array
(retained for backward compatibility), and the object form keyed by AWS,
Service, Federated or CanonicalUser (each value a string or array). All keyed
values are flattened for principal matching, and the original JSON is preserved
so PutBucketPolicy/GetBucketPolicy returns the exact shape submitted - keeping
infrastructure-as-code tools (Terraform, Ansible) idempotent.
Also add NotPrincipal support (a statement applies to every principal except the
ones named), compiled and evaluated in both policy evaluators, and reject
statements that specify both Principal and NotPrincipal.
* s3: address review - validate principal object form, honor dynamic NotPrincipal
- Reject unsupported Principal object keys (only AWS/Service/Federated/
CanonicalUser) and empty values, so a form like {"AWS":[]} no longer compiles
to zero matchers and silently relies on the match-all fallback.
- Detect both Principal and NotPrincipal by field presence, not by flattened
length, so a present-but-empty field is still rejected.
- Honor dynamic (policy-variable) NotPrincipal/Principal patterns in the
compiled evaluator; previously a NotPrincipal made only of variables was
treated as absent and its exclusion bypassed.
- Add regression tests for the object-form validation and dynamic NotPrincipal.
256 lines
10 KiB
Go
256 lines
10 KiB
Go
package policy_engine
|
|
|
|
import (
|
|
"encoding/json"
|
|
"reflect"
|
|
"slices"
|
|
"testing"
|
|
)
|
|
|
|
// TestPolicyPrincipalUnmarshal covers every Principal shape AWS documents, plus
|
|
// the bare string/array forms SeaweedFS accepts for backward compatibility.
|
|
func TestPolicyPrincipalUnmarshal(t *testing.T) {
|
|
cases := []struct {
|
|
name string
|
|
in string
|
|
want []string
|
|
}{
|
|
{"bare wildcard", `"*"`, []string{"*"}},
|
|
{"bare arn", `"arn:aws:iam::123456789012:user/alice"`, []string{"arn:aws:iam::123456789012:user/alice"}},
|
|
{"bare array", `["arn:aws:iam::123:root","arn:aws:iam::456:root"]`, []string{"arn:aws:iam::123:root", "arn:aws:iam::456:root"}},
|
|
{"AWS single", `{"AWS":"arn:aws:iam::123456789012:root"}`, []string{"arn:aws:iam::123456789012:root"}},
|
|
{"AWS array", `{"AWS":["arn:aws:iam::123:root","999999999999"]}`, []string{"arn:aws:iam::123:root", "999999999999"}},
|
|
{"AWS wildcard", `{"AWS":"*"}`, []string{"*"}},
|
|
{"AWS array wildcard (public read)", `{"AWS":["*"]}`, []string{"*"}},
|
|
{"service", `{"Service":"s3.amazonaws.com"}`, []string{"s3.amazonaws.com"}},
|
|
{"canonical user", `{"CanonicalUser":"79a59df900b949e55d96a1e698fbace"}`, []string{"79a59df900b949e55d96a1e698fbace"}},
|
|
// Object keys are flattened in sorted-key order: AWS < CanonicalUser.
|
|
{"mixed keys", `{"CanonicalUser":"79a59","AWS":"arn:aws:iam::123:root"}`, []string{"arn:aws:iam::123:root", "79a59"}},
|
|
}
|
|
for _, c := range cases {
|
|
t.Run(c.name, func(t *testing.T) {
|
|
var p PolicyPrincipal
|
|
if err := json.Unmarshal([]byte(c.in), &p); err != nil {
|
|
t.Fatalf("unmarshal %s: %v", c.in, err)
|
|
}
|
|
if got := p.Strings(); !slices.Equal(got, c.want) {
|
|
t.Errorf("Strings() = %v, want %v", got, c.want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestPolicyPrincipalUnmarshalInvalid(t *testing.T) {
|
|
// Includes object-form abuses: unknown key, empty array, empty string -- all
|
|
// of which must error rather than compile to zero matchers (which would let
|
|
// the match-all fallback silently make an Allow statement public).
|
|
for _, in := range []string{`123`, `{}`, `{"AWS":123}`, `{"Foo":"bar"}`, `{"AWS":[]}`, `{"AWS":""}`} {
|
|
var p PolicyPrincipal
|
|
if err := json.Unmarshal([]byte(in), &p); err == nil {
|
|
t.Errorf("expected error unmarshaling %s, got values %v", in, p.Strings())
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestNotPrincipalDynamicCompiledPath guards the bug where a NotPrincipal made
|
|
// only of policy variables (no static matchers) was treated as absent in the
|
|
// compiled evaluator, bypassing the exclusion. aws:username is pinned via the
|
|
// condition context so the substituted value is independent of the principal.
|
|
func TestNotPrincipalDynamicCompiledPath(t *testing.T) {
|
|
policyJSON := `{"Version":"2012-10-17","Statement":[
|
|
{"Effect":"Allow","Principal":"*","Action":"s3:*","Resource":"arn:aws:s3:::b/*"},
|
|
{"Effect":"Deny","NotPrincipal":{"AWS":"${aws:username}"},"Action":"s3:*","Resource":"arn:aws:s3:::b/*"}
|
|
]}`
|
|
doc, err := ParsePolicy(policyJSON)
|
|
if err != nil {
|
|
t.Fatalf("ParsePolicy: %v", err)
|
|
}
|
|
compiled, err := CompilePolicy(doc)
|
|
if err != nil {
|
|
t.Fatalf("CompilePolicy: %v", err)
|
|
}
|
|
conds := map[string][]string{"aws:username": {"arn:aws:iam::111:user/alice"}}
|
|
allowed := func(p string) bool {
|
|
allow, _ := compiled.EvaluatePolicy(&PolicyEvaluationArgs{
|
|
Action: "s3:GetObject", Resource: "arn:aws:s3:::b/x", Principal: p, Conditions: conds,
|
|
})
|
|
return allow
|
|
}
|
|
// alice matches the substituted NotPrincipal -> deny excluded -> allowed.
|
|
if !allowed("arn:aws:iam::111:user/alice") {
|
|
t.Errorf("alice should be allowed (matched dynamic NotPrincipal)")
|
|
}
|
|
// bob does not match -> deny applies.
|
|
if allowed("arn:aws:iam::111:user/bob") {
|
|
t.Errorf("bob should be denied (dynamic NotPrincipal did not match)")
|
|
}
|
|
}
|
|
|
|
// TestPolicyPrincipalRoundTrip guards idempotency for IaC tools (Terraform,
|
|
// Ansible): GetBucketPolicy must return the same shape PutBucketPolicy received,
|
|
// otherwise those tools see perpetual drift (cf. NotResource normalization bug).
|
|
func TestPolicyPrincipalRoundTrip(t *testing.T) {
|
|
inputs := []string{
|
|
`"*"`,
|
|
`{"AWS":"arn:aws:iam::123456789012:root"}`,
|
|
`{"AWS":["arn:aws:iam::123456789012:root","arn:aws:iam::555555555555:root"]}`,
|
|
`{"AWS":["*"]}`,
|
|
`["arn:aws:iam::123:root","arn:aws:iam::456:root"]`,
|
|
}
|
|
for _, in := range inputs {
|
|
var p PolicyPrincipal
|
|
if err := json.Unmarshal([]byte(in), &p); err != nil {
|
|
t.Fatalf("unmarshal %s: %v", in, err)
|
|
}
|
|
out, err := json.Marshal(&p)
|
|
if err != nil {
|
|
t.Fatalf("marshal: %v", err)
|
|
}
|
|
if !jsonSemanticEqual(t, string(out), in) {
|
|
t.Errorf("round-trip mismatch: got %s, want %s", out, in)
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestBucketPolicyAWSObjectPrincipalEndToEnd parses, compiles and evaluates a
|
|
// public-read policy written with the AWS object form {"AWS":["*"]} — the
|
|
// AWS-documented shape that previously failed to parse.
|
|
func TestBucketPolicyAWSObjectPrincipalEndToEnd(t *testing.T) {
|
|
policyJSON := `{
|
|
"Version": "2012-10-17",
|
|
"Statement": [{
|
|
"Sid": "AllowPublicRead",
|
|
"Effect": "Allow",
|
|
"Principal": {"AWS": ["*"]},
|
|
"Action": ["s3:GetObject"],
|
|
"Resource": ["arn:aws:s3:::my-bucket/*"]
|
|
}]
|
|
}`
|
|
doc, err := ParsePolicy(policyJSON)
|
|
if err != nil {
|
|
t.Fatalf("ParsePolicy: %v", err)
|
|
}
|
|
compiled, err := CompilePolicy(doc)
|
|
if err != nil {
|
|
t.Fatalf("CompilePolicy: %v", err)
|
|
}
|
|
|
|
for _, principal := range []string{"arn:aws:iam::123456789012:user/bob", "*"} {
|
|
allow, effect := compiled.EvaluatePolicy(&PolicyEvaluationArgs{
|
|
Action: "s3:GetObject",
|
|
Resource: "arn:aws:s3:::my-bucket/object.txt",
|
|
Principal: principal,
|
|
})
|
|
if !allow || effect != PolicyEffectAllow {
|
|
t.Errorf("principal %q: allow=%v effect=%v, want allow", principal, allow, effect)
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestBucketPolicySpecificAWSPrincipal verifies a named-principal object form
|
|
// only matches the named principal.
|
|
func TestBucketPolicySpecificAWSPrincipal(t *testing.T) {
|
|
policyJSON := `{"Version":"2012-10-17","Statement":[{"Effect":"Allow",` +
|
|
`"Principal":{"AWS":"arn:aws:iam::123456789012:user/alice"},` +
|
|
`"Action":"s3:*","Resource":"arn:aws:s3:::b/*"}]}`
|
|
doc, err := ParsePolicy(policyJSON)
|
|
if err != nil {
|
|
t.Fatalf("ParsePolicy: %v", err)
|
|
}
|
|
compiled, err := CompilePolicy(doc)
|
|
if err != nil {
|
|
t.Fatalf("CompilePolicy: %v", err)
|
|
}
|
|
args := func(p string) *PolicyEvaluationArgs {
|
|
return &PolicyEvaluationArgs{Action: "s3:GetObject", Resource: "arn:aws:s3:::b/x", Principal: p}
|
|
}
|
|
if allow, _ := compiled.EvaluatePolicy(args("arn:aws:iam::123456789012:user/alice")); !allow {
|
|
t.Errorf("alice should be allowed")
|
|
}
|
|
if allow, _ := compiled.EvaluatePolicy(args("arn:aws:iam::123456789012:user/bob")); allow {
|
|
t.Errorf("bob should NOT be allowed")
|
|
}
|
|
}
|
|
|
|
// TestBucketPolicyNotPrincipalDeny exercises the common "deny everyone except X"
|
|
// pattern through the real bucket-policy evaluator (PolicyEngine). A Deny with
|
|
// NotPrincipal applies to every principal NOT named, so the named principal is
|
|
// spared and everyone else is denied.
|
|
func TestBucketPolicyNotPrincipalDeny(t *testing.T) {
|
|
engine := NewPolicyEngine()
|
|
policyJSON := `{
|
|
"Version": "2012-10-17",
|
|
"Statement": [
|
|
{"Sid":"AllowAll","Effect":"Allow","Principal":"*","Action":"s3:*","Resource":"arn:aws:s3:::b/*"},
|
|
{"Sid":"DenyExceptAlice","Effect":"Deny","NotPrincipal":{"AWS":"arn:aws:iam::123456789012:user/alice"},"Action":"s3:*","Resource":"arn:aws:s3:::b/*"}
|
|
]
|
|
}`
|
|
if err := engine.SetBucketPolicy("b", policyJSON); err != nil {
|
|
t.Fatalf("SetBucketPolicy: %v", err)
|
|
}
|
|
eval := func(p string) PolicyEvaluationResult {
|
|
return engine.EvaluatePolicy("b", &PolicyEvaluationArgs{Action: "s3:GetObject", Resource: "arn:aws:s3:::b/x", Principal: p})
|
|
}
|
|
// alice is excluded from the NotPrincipal deny, so the Allow wins.
|
|
if got := eval("arn:aws:iam::123456789012:user/alice"); got != PolicyResultAllow {
|
|
t.Errorf("alice: got %v, want Allow", got)
|
|
}
|
|
// bob is not named in NotPrincipal, so the Deny applies.
|
|
if got := eval("arn:aws:iam::123456789012:user/bob"); got != PolicyResultDeny {
|
|
t.Errorf("bob: got %v, want Deny", got)
|
|
}
|
|
}
|
|
|
|
// TestBucketPolicyNotPrincipalCompiledPath covers the matcher-based evaluator
|
|
// (CompiledPolicy). An Allow-all paired with a Deny/NotPrincipal makes the
|
|
// "excluded vs denied" difference observable (a lone Deny would yield implicit
|
|
// deny for both).
|
|
func TestBucketPolicyNotPrincipalCompiledPath(t *testing.T) {
|
|
policyJSON := `{"Version":"2012-10-17","Statement":[
|
|
{"Effect":"Allow","Principal":"*","Action":"s3:*","Resource":"arn:aws:s3:::b/*"},
|
|
{"Effect":"Deny","NotPrincipal":{"AWS":["arn:aws:iam::123456789012:user/alice"]},"Action":"s3:*","Resource":"arn:aws:s3:::b/*"}
|
|
]}`
|
|
doc, err := ParsePolicy(policyJSON)
|
|
if err != nil {
|
|
t.Fatalf("ParsePolicy: %v", err)
|
|
}
|
|
compiled, err := CompilePolicy(doc)
|
|
if err != nil {
|
|
t.Fatalf("CompilePolicy: %v", err)
|
|
}
|
|
args := func(p string) *PolicyEvaluationArgs {
|
|
return &PolicyEvaluationArgs{Action: "s3:GetObject", Resource: "arn:aws:s3:::b/x", Principal: p}
|
|
}
|
|
// alice is named in NotPrincipal -> deny excluded -> allowed.
|
|
if allow, _ := compiled.EvaluatePolicy(args("arn:aws:iam::123456789012:user/alice")); !allow {
|
|
t.Errorf("alice should be allowed (excluded from NotPrincipal deny)")
|
|
}
|
|
// bob is not named -> deny applies.
|
|
if allow, effect := compiled.EvaluatePolicy(args("arn:aws:iam::123456789012:user/bob")); allow || effect != PolicyEffectDeny {
|
|
t.Errorf("bob should be denied, got allow=%v effect=%v", allow, effect)
|
|
}
|
|
}
|
|
|
|
// TestPolicyBothPrincipalAndNotPrincipalRejected verifies AWS's rule that a
|
|
// single statement may not contain both Principal and NotPrincipal.
|
|
func TestPolicyBothPrincipalAndNotPrincipalRejected(t *testing.T) {
|
|
policyJSON := `{"Version":"2012-10-17","Statement":[{"Effect":"Deny",` +
|
|
`"Principal":"*","NotPrincipal":{"AWS":"arn:aws:iam::1:user/a"},` +
|
|
`"Action":"s3:*","Resource":"arn:aws:s3:::b/*"}]}`
|
|
if _, err := ParsePolicy(policyJSON); err == nil {
|
|
t.Errorf("expected error when both Principal and NotPrincipal are set")
|
|
}
|
|
}
|
|
|
|
func jsonSemanticEqual(t *testing.T, a, b string) bool {
|
|
t.Helper()
|
|
var av, bv interface{}
|
|
if err := json.Unmarshal([]byte(a), &av); err != nil {
|
|
t.Fatalf("unmarshal %s: %v", a, err)
|
|
}
|
|
if err := json.Unmarshal([]byte(b), &bv); err != nil {
|
|
t.Fatalf("unmarshal %s: %v", b, err)
|
|
}
|
|
return reflect.DeepEqual(av, bv)
|
|
}
|