// 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") } 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 }