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

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