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>
174 lines
4.3 KiB
Go
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
|
|
}
|