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

View File

@@ -6,7 +6,7 @@ See `PROJECT_PLAN.md` for the implementation roadmap and
## Current State ## 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 **Last updated:** 2026-03-19
### Completed ### Completed
@@ -15,6 +15,7 @@ See `PROJECT_PLAN.md` for the implementation roadmap and
- Phase 1: Configuration & database (all 3 steps) - Phase 1: Configuration & database (all 3 steps)
- Phase 2: Blob storage layer (all 2 steps) - Phase 2: Blob storage layer (all 2 steps)
- Phase 3: MCIAS authentication (all 4 steps) - Phase 3: MCIAS authentication (all 4 steps)
- Phase 4: Policy engine (all 4 steps)
- `ARCHITECTURE.md` — Full design specification (18 sections) - `ARCHITECTURE.md` — Full design specification (18 sections)
- `CLAUDE.md` — AI development guidance - `CLAUDE.md` — AI development guidance
- `PROJECT_PLAN.md` — Implementation plan (14 phases, 40+ steps) - `PROJECT_PLAN.md` — Implementation plan (14 phases, 40+ steps)
@@ -22,13 +23,77 @@ See `PROJECT_PLAN.md` for the implementation roadmap and
### Next Steps ### Next Steps
1. Phase 4: Policy engine (depends on Phase 3) 1. Batch B: Phase 5 (OCI pull) and Phase 8 (admin REST) — independent,
2. After Phase 4, Batch B: Phase 5 (OCI pull) and Phase 8 (admin REST) can be done in parallel
2. After Phase 5, Phase 6 (OCI push) then Phase 7 (OCI delete)
--- ---
## Log ## 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) ### 2026-03-19 — Batch A: Phase 2 (blob storage) + Phase 3 (MCIAS auth)
**Task:** Implement content-addressed blob storage and MCIAS authentication **Task:** Implement content-addressed blob storage and MCIAS authentication

View File

@@ -13,7 +13,7 @@ design specification.
| 1 | Configuration & database | **Complete** | | 1 | Configuration & database | **Complete** |
| 2 | Blob storage layer | **Complete** | | 2 | Blob storage layer | **Complete** |
| 3 | MCIAS authentication | **Complete** | | 3 | MCIAS authentication | **Complete** |
| 4 | Policy engine | Not started | | 4 | Policy engine | **Complete** |
| 5 | OCI API — pull path | Not started | | 5 | OCI API — pull path | Not started |
| 6 | OCI API — push path | Not started | | 6 | OCI API — push path | Not started |
| 7 | OCI API — delete path | Not started | | 7 | OCI API — delete path | Not started |

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

View File

@@ -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},
},
}
}

55
internal/policy/engine.go Normal file
View File

@@ -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
}

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

173
internal/policy/policy.go Normal file
View File

@@ -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
}

View File

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

57
internal/server/policy.go Normal file
View File

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

View File

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