Web UI: Added browser-based management for all three remaining engines (SSH CA, Transit, User E2E). Includes gRPC client wiring, handler files, 7 HTML templates, dashboard mount forms, and conditional navigation links. Fixed REST API routes to match design specs (SSH CA cert singular paths, Transit PATCH for update-key-config). Security audit: Conducted full-system audit covering crypto core, all engine implementations, API servers, policy engine, auth, deployment, and documentation. Identified 42 new findings (#39-#80) across all severity levels. Remediation of all 8 High findings: - #68: Replaced 14 JSON-injection-vulnerable error responses with safe json.Encoder via writeJSONError helper - #48: Added two-layer path traversal defense (barrier validatePath rejects ".." segments; engine ValidateName enforces safe name pattern) - #39: Extended RLock through entire crypto operations in barrier Get/Put/Delete/List to eliminate TOCTOU race with Seal - #40: Unified ReWrapKeys and seal_config UPDATE into single SQLite transaction to prevent irrecoverable data loss on crash during MEK rotation - #49: Added resolveTTL to CA engine enforcing issuer MaxTTL ceiling on handleIssue and handleSignCSR - #61: Store raw ECDH private key bytes in userState for effective zeroization on Seal - #62: Fixed user engine policy resource path from mountPath to mountName() so policy rules match correctly - #69: Added newPolicyChecker helper and passed service-level policy evaluation to all 25 typed REST handler engine.Request structs Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
270 lines
7.0 KiB
Go
270 lines
7.0 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"
|
|
)
|
|
|
|
// Action constants for policy evaluation.
|
|
const (
|
|
ActionAny = "any" // matches all non-admin actions
|
|
ActionRead = "read" // retrieve/list operations
|
|
ActionWrite = "write" // create/update/delete operations
|
|
ActionEncrypt = "encrypt" // encrypt data
|
|
ActionDecrypt = "decrypt" // decrypt data
|
|
ActionSign = "sign" // sign data
|
|
ActionVerify = "verify" // verify signatures
|
|
ActionHMAC = "hmac" // compute HMAC
|
|
ActionAdmin = "admin" // administrative operations (never matched by "any")
|
|
)
|
|
|
|
// validEffects is the set of recognized effects.
|
|
var validEffects = map[Effect]bool{
|
|
EffectAllow: true,
|
|
EffectDeny: true,
|
|
}
|
|
|
|
// validActions is the set of recognized actions.
|
|
var validActions = map[string]bool{
|
|
ActionAny: true,
|
|
ActionRead: true,
|
|
ActionWrite: true,
|
|
ActionEncrypt: true,
|
|
ActionDecrypt: true,
|
|
ActionSign: true,
|
|
ActionVerify: true,
|
|
ActionHMAC: true,
|
|
ActionAdmin: true,
|
|
}
|
|
|
|
// 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
|
|
}
|
|
|
|
// LintRule validates a rule's effect and actions. It returns a list of problems
|
|
// (empty if the rule is valid). This does not check resource patterns or other
|
|
// fields — only the enumerated values that must come from a known set.
|
|
func LintRule(rule *Rule) []string {
|
|
var problems []string
|
|
|
|
if rule.ID == "" {
|
|
problems = append(problems, "rule ID is required")
|
|
} else if strings.Contains(rule.ID, "/") || strings.Contains(rule.ID, "..") {
|
|
problems = append(problems, "rule ID must not contain '/' or '..'")
|
|
}
|
|
|
|
if !validEffects[rule.Effect] {
|
|
problems = append(problems, fmt.Sprintf("invalid effect %q (must be %q or %q)", rule.Effect, EffectAllow, EffectDeny))
|
|
}
|
|
|
|
for _, a := range rule.Actions {
|
|
if !validActions[strings.ToLower(a)] {
|
|
problems = append(problems, fmt.Sprintf("invalid action %q", a))
|
|
}
|
|
}
|
|
|
|
return problems
|
|
}
|
|
|
|
// CreateRule validates and stores a new policy rule.
|
|
func (e *Engine) CreateRule(ctx context.Context, rule *Rule) error {
|
|
if problems := LintRule(rule); len(problems) > 0 {
|
|
return fmt.Errorf("policy: invalid rule: %s", strings.Join(problems, "; "))
|
|
}
|
|
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. The "any" action matches all non-admin actions.
|
|
if len(rule.Actions) > 0 && !matchesAction(rule.Actions, req.Action) {
|
|
return false
|
|
}
|
|
|
|
return true
|
|
}
|
|
|
|
// matchesAction checks whether any of the rule's actions match the requested action.
|
|
// The special "any" action matches all actions except "admin".
|
|
func matchesAction(ruleActions []string, reqAction string) bool {
|
|
for _, a := range ruleActions {
|
|
if strings.EqualFold(a, reqAction) {
|
|
return true
|
|
}
|
|
if strings.EqualFold(a, ActionAny) && !strings.EqualFold(reqAction, ActionAdmin) {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
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
|
|
}
|