Files
metacrypt/internal/policy/policy.go
Kyle Isom ac4577f778 Add CRL endpoint, sign-CSR web route, and policy-based issuance authorization
- Register handleSignCSR route in webserver (was dead code)
- Add GET /v1/pki/{mount}/issuer/{name}/crl REST endpoint and
  PKIService.GetCRL gRPC RPC for DER-encoded CRL generation
- Replace admin-only gates on issue/renew/sign-csr with policy-based
  access control: admins grant-all, authenticated users subject to
  identifier ownership (CN/SANs not held by another user's active cert)
  and optional policy overrides via ca/{mount}/id/{identifier} resources
- Add PolicyChecker to engine.Request and policy.Match() method to
  distinguish matched rules from default deny
- Update and expand CA engine tests for ownership, revocation freeing,
  and policy override scenarios

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-16 15:22:04 -07:00

196 lines
4.7 KiB
Go

// Package policy implements the Metacrypt policy engine with priority-based ACL rules.
package policy
import (
"context"
"encoding/json"
"fmt"
"path/filepath"
"sort"
"strings"
"git.wntrmute.dev/kyle/metacrypt/internal/barrier"
)
const rulesPrefix = "policy/rules/"
// Effect represents a policy decision.
type Effect string
const (
EffectAllow Effect = "allow"
EffectDeny Effect = "deny"
)
// Rule is a policy rule stored in the barrier.
type Rule struct {
ID string `json:"id"`
Effect Effect `json:"effect"`
Usernames []string `json:"usernames,omitempty"`
Roles []string `json:"roles,omitempty"`
Resources []string `json:"resources,omitempty"`
Actions []string `json:"actions,omitempty"`
Priority int `json:"priority"`
}
// Request represents an authorization request.
type Request struct {
Username string
Resource string
Action string
Roles []string
}
// Engine evaluates policy rules from the barrier.
type Engine struct {
barrier barrier.Barrier
}
// NewEngine creates a new policy engine.
func NewEngine(b barrier.Barrier) *Engine {
return &Engine{barrier: b}
}
// Evaluate checks if the request is allowed. Admin role always allows.
// Otherwise: collect matching rules, sort by priority (lower = higher priority),
// first match wins, default deny.
func (e *Engine) Evaluate(ctx context.Context, req *Request) (Effect, error) {
effect, _, err := e.Match(ctx, req)
return effect, err
}
// Match checks whether a policy rule matches the request.
// Returns the effect, whether a rule actually matched (vs default deny), and any error.
func (e *Engine) Match(ctx context.Context, req *Request) (Effect, bool, error) {
// Admin bypass.
for _, r := range req.Roles {
if r == "admin" {
return EffectAllow, true, nil
}
}
rules, err := e.listRules(ctx)
if err != nil {
return EffectDeny, false, err
}
// Sort by priority ascending (lower number = higher priority).
sort.Slice(rules, func(i, j int) bool {
return rules[i].Priority < rules[j].Priority
})
for _, rule := range rules {
if matchesRule(&rule, req) {
return rule.Effect, true, nil
}
}
return EffectDeny, false, nil // default deny, no matching rule
}
// CreateRule stores a new policy rule.
func (e *Engine) CreateRule(ctx context.Context, rule *Rule) error {
data, err := json.Marshal(rule)
if err != nil {
return fmt.Errorf("policy: marshal rule: %w", err)
}
return e.barrier.Put(ctx, rulesPrefix+rule.ID, data)
}
// GetRule retrieves a policy rule by ID.
func (e *Engine) GetRule(ctx context.Context, id string) (*Rule, error) {
data, err := e.barrier.Get(ctx, rulesPrefix+id)
if err != nil {
return nil, err
}
var rule Rule
if err := json.Unmarshal(data, &rule); err != nil {
return nil, fmt.Errorf("policy: unmarshal rule: %w", err)
}
return &rule, nil
}
// DeleteRule removes a policy rule.
func (e *Engine) DeleteRule(ctx context.Context, id string) error {
return e.barrier.Delete(ctx, rulesPrefix+id)
}
// ListRules returns all policy rules.
func (e *Engine) ListRules(ctx context.Context) ([]Rule, error) {
return e.listRules(ctx)
}
func (e *Engine) listRules(ctx context.Context) ([]Rule, error) {
paths, err := e.barrier.List(ctx, rulesPrefix)
if err != nil {
return nil, fmt.Errorf("policy: list rules: %w", err)
}
var rules []Rule
for _, p := range paths {
data, err := e.barrier.Get(ctx, rulesPrefix+p)
if err != nil {
return nil, fmt.Errorf("policy: get rule %q: %w", p, err)
}
var rule Rule
if err := json.Unmarshal(data, &rule); err != nil {
return nil, fmt.Errorf("policy: unmarshal rule %q: %w", p, err)
}
rules = append(rules, rule)
}
return rules, nil
}
func matchesRule(rule *Rule, req *Request) bool {
// Check username match.
if len(rule.Usernames) > 0 && !containsString(rule.Usernames, req.Username) {
return false
}
// Check role match.
if len(rule.Roles) > 0 && !hasAnyRole(rule.Roles, req.Roles) {
return false
}
// Check resource match (glob patterns).
if len(rule.Resources) > 0 && !matchesAnyGlob(rule.Resources, req.Resource) {
return false
}
// Check action match.
if len(rule.Actions) > 0 && !containsString(rule.Actions, req.Action) {
return false
}
return true
}
func containsString(haystack []string, needle string) bool {
for _, s := range haystack {
if strings.EqualFold(s, needle) {
return true
}
}
return false
}
func hasAnyRole(required, actual []string) bool {
for _, r := range required {
for _, a := range actual {
if strings.EqualFold(r, a) {
return true
}
}
}
return false
}
func matchesAnyGlob(patterns []string, value string) bool {
for _, p := range patterns {
if matched, _ := filepath.Match(p, value); matched {
return true
}
}
return false
}