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:
2026-03-19 15:05:28 -07:00
parent 3314b7a618
commit f5e67bd4aa
11 changed files with 1158 additions and 4 deletions

68
internal/db/policy.go Normal file
View File

@@ -0,0 +1,68 @@
package db
import (
"encoding/json"
"fmt"
"git.wntrmute.dev/kyle/mcr/internal/policy"
)
// ruleBody is the JSON structure stored in the rule_json column.
type ruleBody struct {
Effect string `json:"effect"`
Roles []string `json:"roles,omitempty"`
AccountTypes []string `json:"account_types,omitempty"`
SubjectUUID string `json:"subject_uuid,omitempty"`
Actions []string `json:"actions"`
Repositories []string `json:"repositories,omitempty"`
}
// LoadEnabledPolicyRules returns all enabled policy rules from the database,
// ordered by priority ascending. It implements policy.RuleStore.
func (d *DB) LoadEnabledPolicyRules() ([]policy.Rule, error) {
rows, err := d.Query(
`SELECT id, priority, description, rule_json
FROM policy_rules
WHERE enabled = 1
ORDER BY priority ASC`,
)
if err != nil {
return nil, fmt.Errorf("db: load policy rules: %w", err)
}
defer func() { _ = rows.Close() }()
var rules []policy.Rule
for rows.Next() {
var id int64
var priority int
var description, ruleJSON string
if err := rows.Scan(&id, &priority, &description, &ruleJSON); err != nil {
return nil, fmt.Errorf("db: scan policy rule: %w", err)
}
var body ruleBody
if err := json.Unmarshal([]byte(ruleJSON), &body); err != nil {
return nil, fmt.Errorf("db: parse rule_json for rule %d: %w", id, err)
}
rule := policy.Rule{
ID: id,
Priority: priority,
Description: description,
Effect: policy.Effect(body.Effect),
Roles: body.Roles,
AccountTypes: body.AccountTypes,
SubjectUUID: body.SubjectUUID,
Repositories: body.Repositories,
}
for _, a := range body.Actions {
rule.Actions = append(rule.Actions, policy.Action(a))
}
rules = append(rules, rule)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("db: iterate policy rules: %w", err)
}
return rules, nil
}

View 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)
}
}