Implement Phase 1: core framework, operational tooling, and runbook

Core packages: crypto (Argon2id/AES-256-GCM), config (TOML/viper),
db (SQLite/migrations), barrier (encrypted storage), seal (state machine
with rate-limited unseal), auth (MCIAS integration with token cache),
policy (priority-based ACL engine), engine (interface + registry).

Server: HTTPS with TLS 1.2+, REST API, auth/admin middleware, htmx web UI
(init, unseal, login, dashboard pages).

CLI: cobra/viper subcommands (server, init, status, snapshot) with env
var override support (METACRYPT_ prefix).

Operational tooling: Dockerfile (multi-stage, non-root), docker-compose,
hardened systemd units (service + daily backup timer), install script,
backup script with retention pruning, production config examples.

Runbook covering installation, configuration, daily operations,
backup/restore, monitoring, troubleshooting, and security procedures.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-03-14 20:43:11 -07:00
commit 4ddd32b117
60 changed files with 4644 additions and 0 deletions

188
internal/policy/policy.go Normal file
View File

@@ -0,0 +1,188 @@
// 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"`
Priority int `json:"priority"`
Effect Effect `json:"effect"`
Usernames []string `json:"usernames,omitempty"` // match specific users
Roles []string `json:"roles,omitempty"` // match roles
Resources []string `json:"resources,omitempty"` // glob patterns for engine mounts/paths
Actions []string `json:"actions,omitempty"` // e.g., "read", "write", "admin"
}
// Request represents an authorization request.
type Request struct {
Username string
Roles []string
Resource string // e.g., "engine/transit/default/encrypt"
Action string // e.g., "write"
}
// 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) {
// Admin bypass.
for _, r := range req.Roles {
if r == "admin" {
return EffectAllow, nil
}
}
rules, err := e.listRules(ctx)
if err != nil {
return EffectDeny, 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, nil
}
}
return EffectDeny, nil // default deny
}
// 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
}

View File

@@ -0,0 +1,177 @@
package policy
import (
"context"
"path/filepath"
"testing"
"git.wntrmute.dev/kyle/metacrypt/internal/barrier"
"git.wntrmute.dev/kyle/metacrypt/internal/crypto"
"git.wntrmute.dev/kyle/metacrypt/internal/db"
)
func setupPolicy(t *testing.T) (*Engine, func()) {
t.Helper()
dir := t.TempDir()
database, err := db.Open(filepath.Join(dir, "test.db"))
if err != nil {
t.Fatalf("open db: %v", err)
}
if err := db.Migrate(database); err != nil {
t.Fatalf("migrate: %v", err)
}
b := barrier.NewAESGCMBarrier(database)
mek, _ := crypto.GenerateKey()
b.Unseal(mek)
e := NewEngine(b)
return e, func() { database.Close() }
}
func TestAdminBypass(t *testing.T) {
e, cleanup := setupPolicy(t)
defer cleanup()
effect, err := e.Evaluate(context.Background(), &Request{
Username: "admin-user",
Roles: []string{"admin"},
Resource: "engine/transit/default/encrypt",
Action: "write",
})
if err != nil {
t.Fatalf("Evaluate: %v", err)
}
if effect != EffectAllow {
t.Fatalf("admin should always be allowed, got: %s", effect)
}
}
func TestDefaultDeny(t *testing.T) {
e, cleanup := setupPolicy(t)
defer cleanup()
effect, err := e.Evaluate(context.Background(), &Request{
Username: "user1",
Roles: []string{"viewer"},
Resource: "engine/transit/default/encrypt",
Action: "write",
})
if err != nil {
t.Fatalf("Evaluate: %v", err)
}
if effect != EffectDeny {
t.Fatalf("default should deny, got: %s", effect)
}
}
func TestPolicyRuleCRUD(t *testing.T) {
e, cleanup := setupPolicy(t)
defer cleanup()
ctx := context.Background()
rule := &Rule{
ID: "test-rule",
Priority: 100,
Effect: EffectAllow,
Roles: []string{"operator"},
Resources: []string{"engine/transit/*"},
Actions: []string{"read", "write"},
}
if err := e.CreateRule(ctx, rule); err != nil {
t.Fatalf("CreateRule: %v", err)
}
got, err := e.GetRule(ctx, "test-rule")
if err != nil {
t.Fatalf("GetRule: %v", err)
}
if got.Priority != 100 {
t.Errorf("priority: got %d, want 100", got.Priority)
}
rules, err := e.ListRules(ctx)
if err != nil {
t.Fatalf("ListRules: %v", err)
}
if len(rules) != 1 {
t.Fatalf("ListRules: got %d rules, want 1", len(rules))
}
if err := e.DeleteRule(ctx, "test-rule"); err != nil {
t.Fatalf("DeleteRule: %v", err)
}
rules, _ = e.ListRules(ctx)
if len(rules) != 0 {
t.Fatalf("after delete: got %d rules, want 0", len(rules))
}
}
func TestPolicyPriorityOrder(t *testing.T) {
e, cleanup := setupPolicy(t)
defer cleanup()
ctx := context.Background()
// Lower priority number = higher priority. Deny should win.
e.CreateRule(ctx, &Rule{
ID: "allow-rule",
Priority: 200,
Effect: EffectAllow,
Roles: []string{"operator"},
Resources: []string{"engine/transit/*"},
Actions: []string{"write"},
})
e.CreateRule(ctx, &Rule{
ID: "deny-rule",
Priority: 100,
Effect: EffectDeny,
Roles: []string{"operator"},
Resources: []string{"engine/transit/*"},
Actions: []string{"write"},
})
effect, _ := e.Evaluate(ctx, &Request{
Username: "user1",
Roles: []string{"operator"},
Resource: "engine/transit/default",
Action: "write",
})
if effect != EffectDeny {
t.Fatalf("higher priority deny should win, got: %s", effect)
}
}
func TestPolicyUsernameMatch(t *testing.T) {
e, cleanup := setupPolicy(t)
defer cleanup()
ctx := context.Background()
e.CreateRule(ctx, &Rule{
ID: "user-specific",
Priority: 100,
Effect: EffectAllow,
Usernames: []string{"alice"},
Resources: []string{"engine/*"},
Actions: []string{"read"},
})
effect, _ := e.Evaluate(ctx, &Request{
Username: "alice",
Roles: []string{"user"},
Resource: "engine/ca",
Action: "read",
})
if effect != EffectAllow {
t.Fatalf("alice should be allowed, got: %s", effect)
}
effect, _ = e.Evaluate(ctx, &Request{
Username: "bob",
Roles: []string{"user"},
Resource: "engine/ca",
Action: "read",
})
if effect != EffectDeny {
t.Fatalf("bob should be denied, got: %s", effect)
}
}