|
| 1 | +// Copyright (c) 2026 Feng Ruohang |
| 2 | +// SPDX-License-Identifier: AGPL-3.0-or-later |
| 3 | + |
| 4 | +package policy |
| 5 | + |
| 6 | +import ( |
| 7 | + "bytes" |
| 8 | + "encoding/json" |
| 9 | + "fmt" |
| 10 | + "io" |
| 11 | + "testing" |
| 12 | + |
| 13 | + "github.com/pgsty/silo-pkg/v3/policy/condition" |
| 14 | +) |
| 15 | + |
| 16 | +func TestPolicyParsingPreservesNotResourceDenies(t *testing.T) { |
| 17 | + for _, count := range []int{4, 10, 11, 20} { |
| 18 | + p := Policy{ |
| 19 | + Version: DefaultVersion, |
| 20 | + Statements: []Statement{ |
| 21 | + {Effect: Allow, Actions: NewActionSet(GetObjectAction), Resources: NewResourceSet(NewResource("*"))}, |
| 22 | + {Effect: Deny, Actions: NewActionSet(GetObjectAction), NotResources: NewResourceSet(NewResource("public/*"), NewResource("shared/*"))}, |
| 23 | + {Effect: Deny, Actions: NewActionSet(GetObjectAction), NotResources: NewResourceSet(NewResource("other/*"), NewResource("shared/*"))}, |
| 24 | + }, |
| 25 | + } |
| 26 | + for len(p.Statements) < count { |
| 27 | + p.Statements = append(p.Statements, Statement{ |
| 28 | + Effect: Allow, Actions: NewActionSet(PutObjectAction), |
| 29 | + Resources: NewResourceSet(NewResource(fmt.Sprintf("filler%d/*", len(p.Statements)))), |
| 30 | + }) |
| 31 | + } |
| 32 | + data, err := json.Marshal(p) |
| 33 | + if err != nil { |
| 34 | + t.Fatal(err) |
| 35 | + } |
| 36 | + for _, parser := range []struct { |
| 37 | + name string |
| 38 | + parse func(io.Reader) (*Policy, error) |
| 39 | + }{ |
| 40 | + {"read", ParseConfig}, |
| 41 | + {"write", ParseConfigStrict}, |
| 42 | + } { |
| 43 | + t.Run(fmt.Sprintf("%s/%d", parser.name, count), func(t *testing.T) { |
| 44 | + parsed, err := parser.parse(bytes.NewReader(data)) |
| 45 | + if err != nil { |
| 46 | + t.Fatal(err) |
| 47 | + } |
| 48 | + if got := len(parsed.Statements); got != count { |
| 49 | + t.Errorf("kept %d statements, want %d", got, count) |
| 50 | + } |
| 51 | + for _, bucket := range []string{"public", "other", "shared"} { |
| 52 | + want := bucket == "shared" |
| 53 | + if got := parsed.IsAllowed(Args{Action: GetObjectAction, BucketName: bucket, ObjectName: "file"}); got != want { |
| 54 | + t.Errorf("GetObject %s/file = %v, want %v", bucket, got, want) |
| 55 | + } |
| 56 | + } |
| 57 | + }) |
| 58 | + } |
| 59 | + } |
| 60 | +} |
| 61 | + |
| 62 | +func TestPolicyDeduplicationPreservesConditionValues(t *testing.T) { |
| 63 | + for _, count := range []int{3, 10, 11, 20} { |
| 64 | + p := Policy{Version: DefaultVersion, Statements: []Statement{{ |
| 65 | + Effect: Allow, Actions: NewActionSet(ListBucketAction), Resources: NewResourceSet(NewResource("bucket")), |
| 66 | + }}} |
| 67 | + for _, values := range [][]string{{"a b"}, {"a", "b"}} { |
| 68 | + f, err := condition.NewStringEqualsFunc("", condition.S3Prefix.ToKey(), values...) |
| 69 | + if err != nil { |
| 70 | + t.Fatal(err) |
| 71 | + } |
| 72 | + p.Statements = append(p.Statements, Statement{ |
| 73 | + Effect: Deny, Actions: NewActionSet(ListBucketAction), Resources: NewResourceSet(NewResource("bucket")), |
| 74 | + Conditions: condition.NewFunctions(f), |
| 75 | + }) |
| 76 | + } |
| 77 | + for len(p.Statements) < count { |
| 78 | + p.Statements = append(p.Statements, Statement{ |
| 79 | + Effect: Allow, Actions: NewActionSet(GetObjectAction), |
| 80 | + Resources: NewResourceSet(NewResource(fmt.Sprintf("filler%d/*", len(p.Statements)))), |
| 81 | + }) |
| 82 | + } |
| 83 | + data, err := json.Marshal(p) |
| 84 | + if err != nil { |
| 85 | + t.Fatal(err) |
| 86 | + } |
| 87 | + for _, mode := range []string{"read", "write", "merge"} { |
| 88 | + t.Run(fmt.Sprintf("%s/%d", mode, count), func(t *testing.T) { |
| 89 | + var parsed *Policy |
| 90 | + var err error |
| 91 | + switch mode { |
| 92 | + case "read": |
| 93 | + parsed, err = ParseConfig(bytes.NewReader(data)) |
| 94 | + case "write": |
| 95 | + parsed, err = ParseConfigStrict(bytes.NewReader(data)) |
| 96 | + case "merge": |
| 97 | + merged := MergePolicies(p, Policy{Version: DefaultVersion, Statements: []Statement{p.Statements[1].Clone()}}) |
| 98 | + parsed = &merged |
| 99 | + } |
| 100 | + if err != nil { |
| 101 | + t.Fatal(err) |
| 102 | + } |
| 103 | + if got := len(parsed.Statements); got != count { |
| 104 | + t.Errorf("kept %d statements, want %d", got, count) |
| 105 | + } |
| 106 | + for _, prefix := range []string{"a", "b", "a b", "other"} { |
| 107 | + args := Args{Action: ListBucketAction, BucketName: "bucket", ConditionValues: map[string][]string{"prefix": {prefix}}} |
| 108 | + if got, want := parsed.IsAllowed(args), prefix == "other"; got != want { |
| 109 | + t.Errorf("ListBucket prefix %q = %v, want %v", prefix, got, want) |
| 110 | + } |
| 111 | + } |
| 112 | + }) |
| 113 | + } |
| 114 | + } |
| 115 | +} |
| 116 | + |
| 117 | +func TestMergePoliciesPreservesNotResourceDenies(t *testing.T) { |
| 118 | + allow := Statement{Effect: Allow, Actions: NewActionSet(GetObjectAction), Resources: NewResourceSet(NewResource("*"))} |
| 119 | + public := Statement{Effect: Deny, Actions: NewActionSet(GetObjectAction), NotResources: NewResourceSet(NewResource("public/*"), NewResource("shared/*"))} |
| 120 | + other := Statement{Effect: Deny, Actions: NewActionSet(GetObjectAction), NotResources: NewResourceSet(NewResource("other/*"), NewResource("shared/*"))} |
| 121 | + merged := MergePolicies( |
| 122 | + Policy{Version: DefaultVersion, Statements: []Statement{allow, public}}, |
| 123 | + Policy{Version: DefaultVersion, Statements: []Statement{other, public.Clone()}}, |
| 124 | + ) |
| 125 | + if got := len(merged.Statements); got != 3 { |
| 126 | + t.Errorf("kept %d statements, want 3 distinct statements", got) |
| 127 | + } |
| 128 | + for _, bucket := range []string{"public", "other", "shared"} { |
| 129 | + want := bucket == "shared" |
| 130 | + if got := merged.IsAllowed(Args{Action: GetObjectAction, BucketName: bucket, ObjectName: "file"}); got != want { |
| 131 | + t.Errorf("GetObject %s/file = %v, want %v", bucket, got, want) |
| 132 | + } |
| 133 | + } |
| 134 | +} |
| 135 | + |
| 136 | +func TestHasDenyStatementWithoutParsing(t *testing.T) { |
| 137 | + for _, tt := range []struct { |
| 138 | + name string |
| 139 | + effects []Effect |
| 140 | + want bool |
| 141 | + }{ |
| 142 | + {"empty", nil, false}, |
| 143 | + {"allow", []Effect{Allow}, false}, |
| 144 | + {"deny", []Effect{Deny}, true}, |
| 145 | + {"allow-then-deny", []Effect{Allow, Deny}, true}, |
| 146 | + } { |
| 147 | + t.Run(tt.name, func(t *testing.T) { |
| 148 | + p := Policy{Version: DefaultVersion} |
| 149 | + for _, effect := range tt.effects { |
| 150 | + p.Statements = append(p.Statements, Statement{ |
| 151 | + Effect: effect, Actions: NewActionSet(GetObjectAction), |
| 152 | + Resources: NewResourceSet(NewResource("*")), |
| 153 | + }) |
| 154 | + } |
| 155 | + if got := p.HasDenyStatement(); got != tt.want { |
| 156 | + t.Errorf("HasDenyStatement() = %v, want %v", got, tt.want) |
| 157 | + } |
| 158 | + p.updateActionIndex() |
| 159 | + if got := p.HasDenyStatement(); got != tt.want { |
| 160 | + t.Errorf("indexed HasDenyStatement() = %v, want %v", got, tt.want) |
| 161 | + } |
| 162 | + }) |
| 163 | + } |
| 164 | + for _, p := range DefaultPolicies { |
| 165 | + if p.Name == "readonly" { |
| 166 | + if !p.Definition.HasDenyStatement() { |
| 167 | + t.Error("readonly's explicit Deny must be reported before parsing") |
| 168 | + } |
| 169 | + return |
| 170 | + } |
| 171 | + } |
| 172 | + t.Fatal("readonly policy not found") |
| 173 | +} |
0 commit comments