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:
173
internal/policy/policy.go
Normal file
173
internal/policy/policy.go
Normal 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
|
||||
}
|
||||
Reference in New Issue
Block a user