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:
188
internal/policy/policy.go
Normal file
188
internal/policy/policy.go
Normal 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
|
||||
}
|
||||
177
internal/policy/policy_test.go
Normal file
177
internal/policy/policy_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user