Files
mcr/internal/server/policy_test.go
Kyle Isom f5e67bd4aa 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>
2026-03-19 15:05:28 -07:00

127 lines
3.9 KiB
Go

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)
}
}