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:
2026-03-19 15:05:28 -07:00
parent 3314b7a618
commit f5e67bd4aa
11 changed files with 1158 additions and 4 deletions

57
internal/server/policy.go Normal file
View 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)
})
}
}