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:
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user