diff --git a/PROGRESS.md b/PROGRESS.md index 2f0bb50..f543662 100644 --- a/PROGRESS.md +++ b/PROGRESS.md @@ -6,7 +6,7 @@ See `PROJECT_PLAN.md` for the implementation roadmap and ## Current State -**Phase:** Batch A complete (Phases 2 + 3), ready for Phase 4 (policy engine) +**Phase:** 4 complete, ready for Batch B (Phase 5 + Phase 8) **Last updated:** 2026-03-19 ### Completed @@ -15,6 +15,7 @@ See `PROJECT_PLAN.md` for the implementation roadmap and - Phase 1: Configuration & database (all 3 steps) - Phase 2: Blob storage layer (all 2 steps) - Phase 3: MCIAS authentication (all 4 steps) +- Phase 4: Policy engine (all 4 steps) - `ARCHITECTURE.md` — Full design specification (18 sections) - `CLAUDE.md` — AI development guidance - `PROJECT_PLAN.md` — Implementation plan (14 phases, 40+ steps) @@ -22,13 +23,77 @@ See `PROJECT_PLAN.md` for the implementation roadmap and ### Next Steps -1. Phase 4: Policy engine (depends on Phase 3) -2. After Phase 4, Batch B: Phase 5 (OCI pull) and Phase 8 (admin REST) +1. Batch B: Phase 5 (OCI pull) and Phase 8 (admin REST) — independent, + can be done in parallel +2. After Phase 5, Phase 6 (OCI push) then Phase 7 (OCI delete) --- ## Log +### 2026-03-19 — Phase 4: Policy engine + +**Task:** Implement the registry-specific authorization engine with +priority-based, deny-wins, default-deny evaluation per ARCHITECTURE.md §4. + +**Changes:** + +Step 4.1 — `internal/policy/` core types and evaluation: +- `policy.go`: `Action` (6 constants), `Effect` (Allow/Deny), `PolicyInput`, + `Rule` types per ARCHITECTURE.md §4 +- `Evaluate(input, rules)` — stateless evaluation: sort by priority (stable), + collect all matching rules, deny-wins, default-deny +- Rule matching: all populated fields ANDed; empty fields are wildcards; + `Repositories` glob matching via `path.Match`; empty repo (global ops) + only matches rules with empty Repositories list + +Step 4.2 — `internal/policy/` built-in defaults: +- `defaults.go`: `DefaultRules()` returns 3 built-in rules (negative IDs, + priority 0): admin wildcard (all actions), human user content access + (pull/push/delete/catalog), version check (always accessible) + +Step 4.3 — `internal/policy/` engine wrapper with DB integration: +- `engine.go`: `Engine` struct with `sync.RWMutex`-protected rule cache; + `NewEngine()` pre-loaded with defaults; `SetRules()` merges with defaults; + `Evaluate()` thread-safe evaluation; `Reload(RuleStore)` loads from DB +- `RuleStore` interface: `LoadEnabledPolicyRules() ([]Rule, error)` +- `internal/db/policy.go`: `LoadEnabledPolicyRules()` on `*DB` — loads + enabled rules from `policy_rules` table, parses `rule_json` JSON column, + returns `[]policy.Rule` ordered by priority + +Step 4.4 — `internal/server/` policy middleware: +- `policy.go`: `PolicyEvaluator` interface, `AuditFunc` callback type, + `RequirePolicy(evaluator, action, auditFn)` middleware — extracts claims + from context, repo name from chi URL param, assembles `PolicyInput`, + returns OCI DENIED (403) on deny with optional audit callback + +**Verification:** +- `make all` passes: vet clean, lint 0 issues, 69 tests passing + (17 policy + 14 server + 15 db + 9 auth + 7 config + 14 storage - some + overlap from updated packages), all 3 binaries built +- Policy evaluation tests: admin wildcard, user allow, system account deny, + exact repo match (allow + deny on different repo), glob match + (production/* matches production/myapp, not production/team/myapp), + deny-wins over allow, priority ordering, empty repo global operation + (admin catalog allowed, repo-scoped rule doesn't match), multiple + matching rules (highest-priority allow returned) +- Default rules tests: admin allowed for all 6 actions, user allowed for + pull/push/delete/catalog but denied policy:manage, system account denied + for all except version_check, version_check allowed for both human and + system accounts +- Engine tests: defaults-only (admin allow, system deny), custom rules + (matching subject allowed, different subject denied), reload picks up new + rules (old rules gone), reload with empty store (disabled rules excluded, + falls back to defaults) +- DB tests: LoadEnabledPolicyRules returns only enabled rules ordered by + priority, parses rule_json correctly (effect, subject_uuid, actions, + repositories), empty table returns nil +- Middleware tests: admin allowed, user allowed, system denied (403 with + OCI DENIED error), system with matching rule allowed, explicit deny + rule blocks access (403) + +--- + ### 2026-03-19 — Batch A: Phase 2 (blob storage) + Phase 3 (MCIAS auth) **Task:** Implement content-addressed blob storage and MCIAS authentication diff --git a/PROJECT_PLAN.md b/PROJECT_PLAN.md index c8f1211..7e00e2e 100644 --- a/PROJECT_PLAN.md +++ b/PROJECT_PLAN.md @@ -13,7 +13,7 @@ design specification. | 1 | Configuration & database | **Complete** | | 2 | Blob storage layer | **Complete** | | 3 | MCIAS authentication | **Complete** | -| 4 | Policy engine | Not started | +| 4 | Policy engine | **Complete** | | 5 | OCI API — pull path | Not started | | 6 | OCI API — push path | Not started | | 7 | OCI API — delete path | Not started | diff --git a/internal/db/policy.go b/internal/db/policy.go new file mode 100644 index 0000000..207d121 --- /dev/null +++ b/internal/db/policy.go @@ -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 +} diff --git a/internal/db/policy_test.go b/internal/db/policy_test.go new file mode 100644 index 0000000..1d5fb49 --- /dev/null +++ b/internal/db/policy_test.go @@ -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) + } +} diff --git a/internal/policy/defaults.go b/internal/policy/defaults.go new file mode 100644 index 0000000..ee9a590 --- /dev/null +++ b/internal/policy/defaults.go @@ -0,0 +1,47 @@ +package policy + +// allActions lists every Action constant for the admin wildcard rule. +var allActions = []Action{ + ActionVersionCheck, + ActionPull, + ActionPush, + ActionDelete, + ActionCatalog, + ActionPolicyManage, +} + +// DefaultRules returns the built-in policy rules per ARCHITECTURE.md §4. +// Default rules use negative IDs and priority 0. +func DefaultRules() []Rule { + return []Rule{ + { + ID: -1, + Priority: 0, + Description: "admin wildcard", + Effect: Allow, + Roles: []string{"admin"}, + Actions: allActions, + }, + { + ID: -2, + Priority: 0, + Description: "human users have full content access", + Effect: Allow, + Roles: []string{"user"}, + AccountTypes: []string{"human"}, + Actions: []Action{ + ActionPull, + ActionPush, + ActionDelete, + ActionCatalog, + }, + }, + { + ID: -3, + Priority: 0, + Description: "version check always accessible", + Effect: Allow, + Actions: []Action{ActionVersionCheck}, + }, + } +} diff --git a/internal/policy/engine.go b/internal/policy/engine.go new file mode 100644 index 0000000..b13c862 --- /dev/null +++ b/internal/policy/engine.go @@ -0,0 +1,55 @@ +package policy + +import ( + "fmt" + "sync" +) + +// RuleStore loads policy rules from a backing store. +type RuleStore interface { + LoadEnabledPolicyRules() ([]Rule, error) +} + +// Engine wraps stateless Evaluate with an in-memory rule cache and +// thread-safe access. +type Engine struct { + mu sync.RWMutex + rules []Rule +} + +// NewEngine creates an Engine pre-loaded with the built-in default rules. +func NewEngine() *Engine { + return &Engine{rules: DefaultRules()} +} + +// SetRules replaces the cached rule set. The provided rules are merged +// with the built-in defaults. +func (e *Engine) SetRules(rules []Rule) { + merged := make([]Rule, 0, len(DefaultRules())+len(rules)) + merged = append(merged, DefaultRules()...) + merged = append(merged, rules...) + + e.mu.Lock() + e.rules = merged + e.mu.Unlock() +} + +// Evaluate runs the policy engine against the cached rule set. +func (e *Engine) Evaluate(input PolicyInput) (Effect, *Rule) { + e.mu.RLock() + rules := make([]Rule, len(e.rules)) + copy(rules, e.rules) + e.mu.RUnlock() + + return Evaluate(input, rules) +} + +// Reload loads enabled rules from the store and updates the cache. +func (e *Engine) Reload(store RuleStore) error { + rules, err := store.LoadEnabledPolicyRules() + if err != nil { + return fmt.Errorf("policy: reload rules: %w", err) + } + e.SetRules(rules) + return nil +} diff --git a/internal/policy/engine_test.go b/internal/policy/engine_test.go new file mode 100644 index 0000000..8019e09 --- /dev/null +++ b/internal/policy/engine_test.go @@ -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) + } +} diff --git a/internal/policy/policy.go b/internal/policy/policy.go new file mode 100644 index 0000000..c07a5d8 --- /dev/null +++ b/internal/policy/policy.go @@ -0,0 +1,173 @@ +package policy + +import ( + "path" + "sort" +) + +// Action represents a registry operation subject to policy evaluation. +type Action string + +const ( + ActionVersionCheck Action = "registry:version_check" + ActionPull Action = "registry:pull" + ActionPush Action = "registry:push" + ActionDelete Action = "registry:delete" + ActionCatalog Action = "registry:catalog" + ActionPolicyManage Action = "policy:manage" +) + +// Effect is the outcome of a policy decision. +type Effect string + +const ( + Allow Effect = "allow" + Deny Effect = "deny" +) + +// PolicyInput describes the request being evaluated against policy rules. +type PolicyInput struct { + Subject string // MCIAS account UUID + AccountType string // "human" or "system" + Roles []string // roles from MCIAS JWT + + Action Action + Repository string // target repository name; empty for global operations +} + +// Rule is a policy rule that grants or denies access. +type Rule struct { + ID int64 + Priority int + Description string + Effect Effect + + // Principal conditions (all populated fields are ANDed). + Roles []string // principal must hold at least one + AccountTypes []string // "human", "system", or both + SubjectUUID string // exact principal UUID + + // Action condition. + Actions []Action + + // Resource condition. + Repositories []string // glob patterns via path.Match; empty = wildcard +} + +// Evaluate applies rules to an input and returns the resulting effect and +// the decisive rule (nil when the default deny applies). It implements +// deny-wins, default-deny semantics per ARCHITECTURE.md §4. +func Evaluate(input PolicyInput, rules []Rule) (Effect, *Rule) { + // Sort by priority ascending (stable). + sorted := make([]Rule, len(rules)) + copy(sorted, rules) + sort.SliceStable(sorted, func(i, j int) bool { + return sorted[i].Priority < sorted[j].Priority + }) + + // Collect all matching rules. + var matched []*Rule + for i := range sorted { + if ruleMatches(&sorted[i], &input) { + matched = append(matched, &sorted[i]) + } + } + + // Deny-wins: if any matching rule denies, return deny. + for _, r := range matched { + if r.Effect == Deny { + return Deny, r + } + } + + // First allow wins. + for _, r := range matched { + if r.Effect == Allow { + return Allow, r + } + } + + // Default deny. + return Deny, nil +} + +// ruleMatches checks whether a single rule matches the given input. +// All populated fields are ANDed; empty fields are wildcards. +func ruleMatches(rule *Rule, input *PolicyInput) bool { + if !matchRoles(rule.Roles, input.Roles) { + return false + } + if !matchAccountTypes(rule.AccountTypes, input.AccountType) { + return false + } + if rule.SubjectUUID != "" && rule.SubjectUUID != input.Subject { + return false + } + if !matchActions(rule.Actions, input.Action) { + return false + } + return matchRepositories(rule.Repositories, input.Repository) +} + +// matchRoles returns true if the rule's Roles field is empty (wildcard) +// or if the principal holds at least one of the listed roles. +func matchRoles(ruleRoles, inputRoles []string) bool { + if len(ruleRoles) == 0 { + return true + } + for _, rr := range ruleRoles { + for _, ir := range inputRoles { + if rr == ir { + return true + } + } + } + return false +} + +// matchAccountTypes returns true if the rule's AccountTypes field is empty +// (wildcard) or if the principal's account type is in the list. +func matchAccountTypes(ruleTypes []string, inputType string) bool { + if len(ruleTypes) == 0 { + return true + } + for _, rt := range ruleTypes { + if rt == inputType { + return true + } + } + return false +} + +// matchActions returns true if the rule's Actions field is empty (wildcard) +// or if the request action is in the list. +func matchActions(ruleActions []Action, inputAction Action) bool { + if len(ruleActions) == 0 { + return true + } + for _, ra := range ruleActions { + if ra == inputAction { + return true + } + } + return false +} + +// matchRepositories returns true if the rule's Repositories field is empty +// (wildcard) or if the request repository matches at least one glob pattern. +// When input.Repository is empty (global operations), only rules with an +// empty Repositories field match. +func matchRepositories(patterns []string, repo string) bool { + if len(patterns) == 0 { + return true + } + if repo == "" { + return false + } + for _, p := range patterns { + if matched, err := path.Match(p, repo); err == nil && matched { + return true + } + } + return false +} diff --git a/internal/policy/policy_test.go b/internal/policy/policy_test.go new file mode 100644 index 0000000..7977d64 --- /dev/null +++ b/internal/policy/policy_test.go @@ -0,0 +1,322 @@ +package policy + +import "testing" + +func TestEvaluateAdminWildcard(t *testing.T) { + rules := DefaultRules() + input := PolicyInput{ + Subject: "admin-uuid", + AccountType: "human", + Roles: []string{"admin"}, + Action: ActionPush, + Repository: "myapp", + } + effect, rule := Evaluate(input, rules) + if effect != Allow { + t.Fatalf("admin push: got %s, want allow", effect) + } + if rule == nil || rule.ID != -1 { + t.Fatalf("admin push: expected admin wildcard rule (ID -1), got %+v", rule) + } +} + +func TestEvaluateUserAllow(t *testing.T) { + rules := DefaultRules() + input := PolicyInput{ + Subject: "user-uuid", + AccountType: "human", + Roles: []string{"user"}, + Action: ActionPull, + Repository: "myapp", + } + effect, rule := Evaluate(input, rules) + if effect != Allow { + t.Fatalf("user pull: got %s, want allow", effect) + } + if rule == nil || rule.ID != -2 { + t.Fatalf("user pull: expected user content rule (ID -2), got %+v", rule) + } +} + +func TestEvaluateSystemAccountDeny(t *testing.T) { + rules := DefaultRules() + input := PolicyInput{ + Subject: "system-uuid", + AccountType: "system", + Action: ActionPull, + Repository: "myapp", + } + effect, rule := Evaluate(input, rules) + if effect != Deny { + t.Fatalf("system pull: got %s, want deny", effect) + } + if rule != nil { + t.Fatalf("system pull: expected default deny (nil rule), got %+v", rule) + } +} + +func TestEvaluateExactRepoMatch(t *testing.T) { + rules := append(DefaultRules(), Rule{ + ID: 1, + Priority: 50, + Effect: Allow, + SubjectUUID: "ci-uuid", + Actions: []Action{ActionPull}, + Repositories: []string{"myapp"}, + }) + input := PolicyInput{ + Subject: "ci-uuid", + AccountType: "system", + Action: ActionPull, + Repository: "myapp", + } + effect, _ := Evaluate(input, rules) + if effect != Allow { + t.Fatalf("exact repo match: got %s, want allow", effect) + } + + // Different repo should deny. + input.Repository = "other" + effect, _ = Evaluate(input, rules) + if effect != Deny { + t.Fatalf("different repo: got %s, want deny", effect) + } +} + +func TestEvaluateGlobMatch(t *testing.T) { + rules := append(DefaultRules(), Rule{ + ID: 1, + Priority: 50, + Effect: Allow, + SubjectUUID: "ci-uuid", + Actions: []Action{ActionPush, ActionPull}, + Repositories: []string{"production/*"}, + }) + input := PolicyInput{ + Subject: "ci-uuid", + AccountType: "system", + Action: ActionPush, + Repository: "production/myapp", + } + effect, _ := Evaluate(input, rules) + if effect != Allow { + t.Fatalf("glob match production/myapp: got %s, want allow", effect) + } + + // Nested repo should not match (path.Match: * doesn't cross /). + input.Repository = "production/team/myapp" + effect, _ = Evaluate(input, rules) + if effect != Deny { + t.Fatalf("glob no-match production/team/myapp: got %s, want deny", effect) + } +} + +func TestEvaluateDenyWins(t *testing.T) { + rules := []Rule{ + { + ID: 1, + Priority: 50, + Effect: Allow, + SubjectUUID: "agent-uuid", + Actions: []Action{ActionPull}, + }, + { + ID: 2, + Priority: 10, + Effect: Deny, + SubjectUUID: "agent-uuid", + Actions: []Action{ActionPull}, + }, + } + input := PolicyInput{ + Subject: "agent-uuid", + AccountType: "system", + Action: ActionPull, + Repository: "myapp", + } + effect, rule := Evaluate(input, rules) + if effect != Deny { + t.Fatalf("deny-wins: got %s, want deny", effect) + } + if rule == nil || rule.ID != 2 { + t.Fatalf("deny-wins: expected deny rule (ID 2), got %+v", rule) + } +} + +func TestEvaluatePriorityOrdering(t *testing.T) { + rules := []Rule{ + { + ID: 1, + Priority: 100, + Effect: Allow, + Actions: []Action{ActionPull}, + }, + { + ID: 2, + Priority: 10, + Effect: Deny, + Actions: []Action{ActionPull}, + }, + } + input := PolicyInput{ + Subject: "any", + AccountType: "system", + Action: ActionPull, + Repository: "myapp", + } + effect, _ := Evaluate(input, rules) + if effect != Deny { + t.Fatalf("priority: got %s, want deny", effect) + } +} + +func TestEvaluateEmptyRepoGlobalOperation(t *testing.T) { + rules := DefaultRules() + + // Admin should be allowed for catalog (admin wildcard, empty Repositories = wildcard). + input := PolicyInput{ + Subject: "admin-uuid", + AccountType: "human", + Roles: []string{"admin"}, + Action: ActionCatalog, + } + effect, _ := Evaluate(input, rules) + if effect != Allow { + t.Fatalf("admin catalog: got %s, want allow", effect) + } + + // System account with a repo-scoped rule should be denied for catalog + // (global op doesn't match repo-scoped rules). + rules = append(rules, Rule{ + ID: 1, + Priority: 50, + Effect: Allow, + SubjectUUID: "ci-uuid", + Actions: []Action{ActionCatalog}, + Repositories: []string{"myapp"}, + }) + input = PolicyInput{ + Subject: "ci-uuid", + AccountType: "system", + Action: ActionCatalog, + } + effect, _ = Evaluate(input, rules) + if effect != Deny { + t.Fatalf("system catalog with repo-scoped rule: got %s, want deny", effect) + } +} + +func TestEvaluateMultipleMatchingRules(t *testing.T) { + rules := []Rule{ + { + ID: 1, + Priority: 50, + Effect: Allow, + Actions: []Action{ActionPull}, + }, + { + ID: 2, + Priority: 100, + Effect: Allow, + Actions: []Action{ActionPull, ActionPush}, + }, + } + input := PolicyInput{ + Subject: "any", + AccountType: "system", + Action: ActionPull, + Repository: "myapp", + } + effect, rule := Evaluate(input, rules) + if effect != Allow { + t.Fatalf("multiple allow: got %s, want allow", effect) + } + if rule == nil || rule.ID != 1 { + t.Fatalf("multiple allow: expected rule ID 1 (higher priority), got %+v", rule) + } +} + +// --- Default rules tests (Step 4.2) --- + +func TestDefaultRulesAdminAllActions(t *testing.T) { + rules := DefaultRules() + actions := []Action{ + ActionVersionCheck, ActionPull, ActionPush, + ActionDelete, ActionCatalog, ActionPolicyManage, + } + for _, a := range actions { + input := PolicyInput{ + Subject: "admin-uuid", + AccountType: "human", + Roles: []string{"admin"}, + Action: a, + Repository: "myapp", + } + effect, _ := Evaluate(input, rules) + if effect != Allow { + t.Errorf("admin %s: got %s, want allow", a, effect) + } + } +} + +func TestDefaultRulesUserContentAccess(t *testing.T) { + rules := DefaultRules() + allowed := []Action{ActionPull, ActionPush, ActionDelete, ActionCatalog} + for _, a := range allowed { + input := PolicyInput{ + Subject: "user-uuid", + AccountType: "human", + Roles: []string{"user"}, + Action: a, + Repository: "myapp", + } + effect, _ := Evaluate(input, rules) + if effect != Allow { + t.Errorf("user %s: got %s, want allow", a, effect) + } + } + + // policy:manage should be denied for regular user. + input := PolicyInput{ + Subject: "user-uuid", + AccountType: "human", + Roles: []string{"user"}, + Action: ActionPolicyManage, + } + effect, _ := Evaluate(input, rules) + if effect != Deny { + t.Errorf("user policy:manage: got %s, want deny", effect) + } +} + +func TestDefaultRulesSystemAccountDeny(t *testing.T) { + rules := DefaultRules() + actions := []Action{ActionPull, ActionPush, ActionDelete, ActionCatalog, ActionPolicyManage} + for _, a := range actions { + input := PolicyInput{ + Subject: "system-uuid", + AccountType: "system", + Action: a, + Repository: "myapp", + } + effect, _ := Evaluate(input, rules) + if effect != Deny { + t.Errorf("system %s: got %s, want deny", a, effect) + } + } +} + +func TestDefaultRulesVersionCheckAlwaysAllowed(t *testing.T) { + rules := DefaultRules() + for _, acctType := range []string{"human", "system"} { + input := PolicyInput{ + Subject: "any-uuid", + AccountType: acctType, + Action: ActionVersionCheck, + } + effect, _ := Evaluate(input, rules) + if effect != Allow { + t.Errorf("%s version_check: got %s, want allow", acctType, effect) + } + } +} diff --git a/internal/server/policy.go b/internal/server/policy.go new file mode 100644 index 0000000..cda6d0b --- /dev/null +++ b/internal/server/policy.go @@ -0,0 +1,57 @@ +package server + +import ( + "net/http" + + "github.com/go-chi/chi/v5" + + "git.wntrmute.dev/kyle/mcr/internal/auth" + "git.wntrmute.dev/kyle/mcr/internal/policy" +) + +// PolicyEvaluator abstracts the policy engine for testability. +type PolicyEvaluator interface { + Evaluate(input policy.PolicyInput) (policy.Effect, *policy.Rule) +} + +// AuditFunc is an optional callback for recording policy deny audit events. +// It follows the same signature as db.WriteAuditEvent but without an error +// return — audit failures should not block request processing. +type AuditFunc func(eventType, actorID, repository, digest, ip string, details map[string]string) + +// RequirePolicy returns middleware that checks the policy engine for the +// given action. Claims must already be in the context (set by RequireAuth). +// The repository name is extracted from the chi "name" URL parameter; +// global operations (catalog, version check) have an empty repository. +func RequirePolicy(evaluator PolicyEvaluator, action policy.Action, auditFn AuditFunc) func(http.Handler) http.Handler { + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + claims := auth.ClaimsFromContext(r.Context()) + if claims == nil { + writeOCIError(w, "UNAUTHORIZED", http.StatusUnauthorized, "authentication required") + return + } + + input := policy.PolicyInput{ + Subject: claims.Subject, + AccountType: claims.AccountType, + Roles: claims.Roles, + Action: action, + Repository: chi.URLParam(r, "name"), + } + + effect, _ := evaluator.Evaluate(input) + if effect == policy.Deny { + if auditFn != nil { + auditFn("policy_deny", claims.Subject, input.Repository, "", r.RemoteAddr, map[string]string{ + "action": string(action), + }) + } + writeOCIError(w, "DENIED", http.StatusForbidden, "access denied by policy") + return + } + + next.ServeHTTP(w, r) + }) + } +} diff --git a/internal/server/policy_test.go b/internal/server/policy_test.go new file mode 100644 index 0000000..3480a1d --- /dev/null +++ b/internal/server/policy_test.go @@ -0,0 +1,126 @@ +package server + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/go-chi/chi/v5" + + "git.wntrmute.dev/kyle/mcr/internal/auth" + "git.wntrmute.dev/kyle/mcr/internal/policy" +) + +type fakePolicyEvaluator struct { + effect policy.Effect + rule *policy.Rule +} + +func (f *fakePolicyEvaluator) Evaluate(_ policy.PolicyInput) (policy.Effect, *policy.Rule) { + return f.effect, f.rule +} + +// newPolicyTestRouter creates a chi router with a repo-scoped route +// protected by RequirePolicy. The handler returns 200 on success. +func newPolicyTestRouter(evaluator PolicyEvaluator, action policy.Action, auditFn AuditFunc) *chi.Mux { + r := chi.NewRouter() + r.Route("/v2/{name}", func(sub chi.Router) { + sub.Use(RequirePolicy(evaluator, action, auditFn)) + sub.Get("/test", func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusOK) + }) + }) + return r +} + +func TestRequirePolicyAdminAllowed(t *testing.T) { + evaluator := &fakePolicyEvaluator{effect: policy.Allow} + router := newPolicyTestRouter(evaluator, policy.ActionPull, nil) + + claims := &auth.Claims{Subject: "admin-uuid", AccountType: "human", Roles: []string{"admin"}} + req := httptest.NewRequest("GET", "/v2/myrepo/test", nil) + req = req.WithContext(auth.ContextWithClaims(req.Context(), claims)) + + rr := httptest.NewRecorder() + router.ServeHTTP(rr, req) + + if rr.Code != http.StatusOK { + t.Fatalf("admin allowed: got %d, want 200", rr.Code) + } +} + +func TestRequirePolicyUserAllowed(t *testing.T) { + evaluator := &fakePolicyEvaluator{effect: policy.Allow} + router := newPolicyTestRouter(evaluator, policy.ActionPull, nil) + + claims := &auth.Claims{Subject: "user-uuid", AccountType: "human", Roles: []string{"user"}} + req := httptest.NewRequest("GET", "/v2/myrepo/test", nil) + req = req.WithContext(auth.ContextWithClaims(req.Context(), claims)) + + rr := httptest.NewRecorder() + router.ServeHTTP(rr, req) + + if rr.Code != http.StatusOK { + t.Fatalf("user allowed: got %d, want 200", rr.Code) + } +} + +func TestRequirePolicySystemDenied(t *testing.T) { + evaluator := &fakePolicyEvaluator{effect: policy.Deny} + router := newPolicyTestRouter(evaluator, policy.ActionPull, nil) + + claims := &auth.Claims{Subject: "system-uuid", AccountType: "system"} + req := httptest.NewRequest("GET", "/v2/myrepo/test", nil) + req = req.WithContext(auth.ContextWithClaims(req.Context(), claims)) + + rr := httptest.NewRecorder() + router.ServeHTTP(rr, req) + + if rr.Code != http.StatusForbidden { + t.Fatalf("system denied: got %d, want 403", rr.Code) + } + + var body ociErrorResponse + if err := json.NewDecoder(rr.Body).Decode(&body); err != nil { + t.Fatalf("decode error body: %v", err) + } + if len(body.Errors) != 1 || body.Errors[0].Code != "DENIED" { + t.Fatalf("error code: got %+v, want DENIED", body.Errors) + } +} + +func TestRequirePolicySystemWithRuleAllowed(t *testing.T) { + evaluator := &fakePolicyEvaluator{effect: policy.Allow} + router := newPolicyTestRouter(evaluator, policy.ActionPull, nil) + + claims := &auth.Claims{Subject: "ci-uuid", AccountType: "system"} + req := httptest.NewRequest("GET", "/v2/myrepo/test", nil) + req = req.WithContext(auth.ContextWithClaims(req.Context(), claims)) + + rr := httptest.NewRecorder() + router.ServeHTTP(rr, req) + + if rr.Code != http.StatusOK { + t.Fatalf("system with rule: got %d, want 200", rr.Code) + } +} + +func TestRequirePolicyDenyRuleBlocks(t *testing.T) { + evaluator := &fakePolicyEvaluator{ + effect: policy.Deny, + rule: &policy.Rule{ID: 99, Effect: policy.Deny, Description: "explicit deny"}, + } + router := newPolicyTestRouter(evaluator, policy.ActionDelete, nil) + + claims := &auth.Claims{Subject: "user-uuid", AccountType: "human", Roles: []string{"user"}} + req := httptest.NewRequest("GET", "/v2/myrepo/test", nil) + req = req.WithContext(auth.ContextWithClaims(req.Context(), claims)) + + rr := httptest.NewRecorder() + router.ServeHTTP(rr, req) + + if rr.Code != http.StatusForbidden { + t.Fatalf("deny rule blocks: got %d, want 403", rr.Code) + } +}