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