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>
127 lines
3.9 KiB
Go
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)
|
|
}
|
|
}
|