Phase 4: policy engine with deny-wins, default-deny evaluation
internal/policy/: Priority-based policy engine per ARCHITECTURE.md §4. Stateless Evaluate() sorts rules by priority, collects all matches, deny-wins over allow, default-deny if no match. Rule matching: all populated fields ANDed, empty fields are wildcards, repository glob via path.Match. Built-in defaults: admin wildcard (all actions), human user content access (pull/push/delete/catalog), version check (always accessible). Engine wrapper with sync.RWMutex-protected cache, SetRules merges with defaults, Reload loads from RuleStore. internal/db/: LoadEnabledPolicyRules() parses rule_json column from policy_rules table into []policy.Rule, filtered by enabled=1, ordered by priority. internal/server/: RequirePolicy middleware extracts claims from context, repo from chi URL param, evaluates policy, returns OCI DENIED (403) on deny with optional audit callback. 69 tests passing across all packages. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
162
internal/policy/engine_test.go
Normal file
162
internal/policy/engine_test.go
Normal file
@@ -0,0 +1,162 @@
|
||||
package policy
|
||||
|
||||
import "testing"
|
||||
|
||||
// fakeRuleStore implements RuleStore for testing.
|
||||
type fakeRuleStore struct {
|
||||
rules []Rule
|
||||
err error
|
||||
}
|
||||
|
||||
func (f *fakeRuleStore) LoadEnabledPolicyRules() ([]Rule, error) {
|
||||
return f.rules, f.err
|
||||
}
|
||||
|
||||
func TestEngineDefaultsOnly(t *testing.T) {
|
||||
e := NewEngine()
|
||||
|
||||
// Admin should be allowed.
|
||||
effect, _ := e.Evaluate(PolicyInput{
|
||||
Subject: "admin-uuid",
|
||||
AccountType: "human",
|
||||
Roles: []string{"admin"},
|
||||
Action: ActionPush,
|
||||
Repository: "myapp",
|
||||
})
|
||||
if effect != Allow {
|
||||
t.Fatalf("admin push with defaults: got %s, want allow", effect)
|
||||
}
|
||||
|
||||
// System account should be denied.
|
||||
effect, _ = e.Evaluate(PolicyInput{
|
||||
Subject: "system-uuid",
|
||||
AccountType: "system",
|
||||
Action: ActionPull,
|
||||
Repository: "myapp",
|
||||
})
|
||||
if effect != Deny {
|
||||
t.Fatalf("system pull with defaults: got %s, want deny", effect)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEngineWithCustomRules(t *testing.T) {
|
||||
e := NewEngine()
|
||||
e.SetRules([]Rule{
|
||||
{
|
||||
ID: 1,
|
||||
Priority: 50,
|
||||
Effect: Allow,
|
||||
SubjectUUID: "ci-uuid",
|
||||
Actions: []Action{ActionPull, ActionPush},
|
||||
},
|
||||
})
|
||||
|
||||
// System account with matching rule should be allowed.
|
||||
effect, _ := e.Evaluate(PolicyInput{
|
||||
Subject: "ci-uuid",
|
||||
AccountType: "system",
|
||||
Action: ActionPull,
|
||||
Repository: "myapp",
|
||||
})
|
||||
if effect != Allow {
|
||||
t.Fatalf("ci pull with custom rule: got %s, want allow", effect)
|
||||
}
|
||||
|
||||
// Different subject should still be denied.
|
||||
effect, _ = e.Evaluate(PolicyInput{
|
||||
Subject: "other-uuid",
|
||||
AccountType: "system",
|
||||
Action: ActionPull,
|
||||
Repository: "myapp",
|
||||
})
|
||||
if effect != Deny {
|
||||
t.Fatalf("other pull: got %s, want deny", effect)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEngineReload(t *testing.T) {
|
||||
e := NewEngine()
|
||||
|
||||
store := &fakeRuleStore{
|
||||
rules: []Rule{
|
||||
{
|
||||
ID: 1,
|
||||
Priority: 50,
|
||||
Effect: Allow,
|
||||
SubjectUUID: "ci-uuid",
|
||||
Actions: []Action{ActionPull},
|
||||
},
|
||||
},
|
||||
}
|
||||
if err := e.Reload(store); err != nil {
|
||||
t.Fatalf("Reload: %v", err)
|
||||
}
|
||||
|
||||
// ci-uuid should now be allowed.
|
||||
effect, _ := e.Evaluate(PolicyInput{
|
||||
Subject: "ci-uuid",
|
||||
AccountType: "system",
|
||||
Action: ActionPull,
|
||||
Repository: "myapp",
|
||||
})
|
||||
if effect != Allow {
|
||||
t.Fatalf("ci pull after reload: got %s, want allow", effect)
|
||||
}
|
||||
|
||||
// Reload again with new rules (simulating DB change).
|
||||
store.rules = []Rule{
|
||||
{
|
||||
ID: 2,
|
||||
Priority: 50,
|
||||
Effect: Allow,
|
||||
SubjectUUID: "deploy-uuid",
|
||||
Actions: []Action{ActionPull},
|
||||
},
|
||||
}
|
||||
if err := e.Reload(store); err != nil {
|
||||
t.Fatalf("Reload (second): %v", err)
|
||||
}
|
||||
|
||||
// ci-uuid should now be denied (old rule gone).
|
||||
effect, _ = e.Evaluate(PolicyInput{
|
||||
Subject: "ci-uuid",
|
||||
AccountType: "system",
|
||||
Action: ActionPull,
|
||||
Repository: "myapp",
|
||||
})
|
||||
if effect != Deny {
|
||||
t.Fatalf("ci pull after second reload: got %s, want deny", effect)
|
||||
}
|
||||
|
||||
// deploy-uuid should now be allowed.
|
||||
effect, _ = e.Evaluate(PolicyInput{
|
||||
Subject: "deploy-uuid",
|
||||
AccountType: "system",
|
||||
Action: ActionPull,
|
||||
Repository: "myapp",
|
||||
})
|
||||
if effect != Allow {
|
||||
t.Fatalf("deploy pull after reload: got %s, want allow", effect)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEngineReloadDisabledExcluded(t *testing.T) {
|
||||
e := NewEngine()
|
||||
|
||||
// Store returns no rules (all disabled or none exist).
|
||||
store := &fakeRuleStore{rules: nil}
|
||||
if err := e.Reload(store); err != nil {
|
||||
t.Fatalf("Reload: %v", err)
|
||||
}
|
||||
|
||||
// No custom rules, so system account should be denied.
|
||||
effect, _ := e.Evaluate(PolicyInput{
|
||||
Subject: "ci-uuid",
|
||||
AccountType: "system",
|
||||
Action: ActionPull,
|
||||
Repository: "myapp",
|
||||
})
|
||||
if effect != Deny {
|
||||
t.Fatalf("system pull with no custom rules: got %s, want deny", effect)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user