Files
mcr/internal/policy/policy.go
Kyle Isom f5e67bd4aa 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>
2026-03-19 15:05:28 -07:00

174 lines
4.3 KiB
Go

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
}