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:
68
internal/db/policy.go
Normal file
68
internal/db/policy.go
Normal 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
|
||||
}
|
||||
79
internal/db/policy_test.go
Normal file
79
internal/db/policy_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
47
internal/policy/defaults.go
Normal file
47
internal/policy/defaults.go
Normal 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
55
internal/policy/engine.go
Normal 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
|
||||
}
|
||||
162
internal/policy/engine_test.go
Normal file
162
internal/policy/engine_test.go
Normal 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
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
|
||||
}
|
||||
322
internal/policy/policy_test.go
Normal file
322
internal/policy/policy_test.go
Normal 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
57
internal/server/policy.go
Normal 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)
|
||||
})
|
||||
}
|
||||
}
|
||||
126
internal/server/policy_test.go
Normal file
126
internal/server/policy_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user