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:
79
internal/db/policy_test.go
Normal file
79
internal/db/policy_test.go
Normal file
@@ -0,0 +1,79 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"git.wntrmute.dev/kyle/mcr/internal/policy"
|
||||
)
|
||||
|
||||
func TestLoadEnabledPolicyRules(t *testing.T) {
|
||||
d := openTestDB(t)
|
||||
if err := d.Migrate(); err != nil {
|
||||
t.Fatalf("Migrate: %v", err)
|
||||
}
|
||||
|
||||
// Insert two enabled rules and one disabled rule.
|
||||
_, err := d.Exec(`INSERT INTO policy_rules (priority, description, rule_json, enabled)
|
||||
VALUES (50, 'CI push/pull', '{"effect":"allow","subject_uuid":"ci-uuid","actions":["registry:push","registry:pull"],"repositories":["production/*"]}', 1)`)
|
||||
if err != nil {
|
||||
t.Fatalf("insert rule 1: %v", err)
|
||||
}
|
||||
|
||||
_, err = d.Exec(`INSERT INTO policy_rules (priority, description, rule_json, enabled)
|
||||
VALUES (10, 'deny delete', '{"effect":"deny","subject_uuid":"ci-uuid","actions":["registry:delete"]}', 1)`)
|
||||
if err != nil {
|
||||
t.Fatalf("insert rule 2: %v", err)
|
||||
}
|
||||
|
||||
_, err = d.Exec(`INSERT INTO policy_rules (priority, description, rule_json, enabled)
|
||||
VALUES (50, 'disabled rule', '{"effect":"allow","actions":["registry:catalog"]}', 0)`)
|
||||
if err != nil {
|
||||
t.Fatalf("insert rule 3 (disabled): %v", err)
|
||||
}
|
||||
|
||||
rules, err := d.LoadEnabledPolicyRules()
|
||||
if err != nil {
|
||||
t.Fatalf("LoadEnabledPolicyRules: %v", err)
|
||||
}
|
||||
|
||||
// Only 2 enabled rules should be returned.
|
||||
if len(rules) != 2 {
|
||||
t.Fatalf("rule count: got %d, want 2", len(rules))
|
||||
}
|
||||
|
||||
// Rules should be ordered by priority ascending.
|
||||
if rules[0].Priority != 10 {
|
||||
t.Fatalf("first rule priority: got %d, want 10", rules[0].Priority)
|
||||
}
|
||||
if rules[0].Effect != policy.Deny {
|
||||
t.Fatalf("first rule effect: got %s, want deny", rules[0].Effect)
|
||||
}
|
||||
|
||||
if rules[1].Priority != 50 {
|
||||
t.Fatalf("second rule priority: got %d, want 50", rules[1].Priority)
|
||||
}
|
||||
if rules[1].SubjectUUID != "ci-uuid" {
|
||||
t.Fatalf("second rule subject: got %q, want %q", rules[1].SubjectUUID, "ci-uuid")
|
||||
}
|
||||
if len(rules[1].Actions) != 2 {
|
||||
t.Fatalf("second rule actions: got %d, want 2", len(rules[1].Actions))
|
||||
}
|
||||
if len(rules[1].Repositories) != 1 || rules[1].Repositories[0] != "production/*" {
|
||||
t.Fatalf("second rule repositories: got %v, want [production/*]", rules[1].Repositories)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadEnabledPolicyRulesEmpty(t *testing.T) {
|
||||
d := openTestDB(t)
|
||||
if err := d.Migrate(); err != nil {
|
||||
t.Fatalf("Migrate: %v", err)
|
||||
}
|
||||
|
||||
rules, err := d.LoadEnabledPolicyRules()
|
||||
if err != nil {
|
||||
t.Fatalf("LoadEnabledPolicyRules: %v", err)
|
||||
}
|
||||
if rules != nil {
|
||||
t.Fatalf("rules: got %v, want nil", rules)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user