package policy import "sort" // Evaluate determines whether the given input should be allowed or denied, // using the provided rule set. Built-in default rules (from defaults.go) are // always merged in before evaluation. // // The rules slice passed by the caller contains only DB-backed operator rules; // defaultRules are appended internally so callers do not need to know about them. // // Return values: // - effect: Allow or Deny // - matched: the Rule that produced the decision, or nil on default-deny // // Security: evaluation is purely functional — no I/O, no globals mutated. The // deny-wins and default-deny semantics ensure that a misconfigured or empty // operator rule set falls back to the built-in defaults, which reproduce the // previous binary admin/non-admin behavior exactly. func Evaluate(input PolicyInput, operatorRules []Rule) (Effect, *Rule) { // Merge operator rules with built-in defaults. Defaults have priority 0; // operator rules default to 100. Sort is stable so same-priority rules // maintain their original order (defaults before operator rules on ties). all := make([]Rule, 0, len(operatorRules)+len(defaultRules)) all = append(all, defaultRules...) all = append(all, operatorRules...) sort.SliceStable(all, func(i, j int) bool { return all[i].Priority < all[j].Priority }) var matched []Rule for _, r := range all { if matches(input, r) { matched = append(matched, r) } } // Deny-wins: first matching Deny terminates evaluation. for i := range matched { if matched[i].Effect == Deny { return Deny, &matched[i] } } // First matching Allow permits. for i := range matched { if matched[i].Effect == Allow { return Allow, &matched[i] } } // Default-deny: no rule matched. return Deny, nil } // matches reports whether rule r applies to the given input. Every non-zero // field on the rule is treated as an AND condition; empty slices and zero // strings are wildcards. func matches(input PolicyInput, r Rule) bool { // Principal: roles (at least one must match) if len(r.Roles) > 0 && !anyIn(input.Roles, r.Roles) { return false } // Principal: account type if len(r.AccountTypes) > 0 && !stringIn(input.AccountType, r.AccountTypes) { return false } // Principal: exact subject UUID if r.SubjectUUID != "" && input.Subject != r.SubjectUUID { return false } // Action if len(r.Actions) > 0 && !actionIn(input.Action, r.Actions) { return false } // Resource type if r.ResourceType != "" && input.Resource.Type != r.ResourceType { return false } // Resource: owner must equal subject if r.OwnerMatchesSubject && input.Resource.OwnerUUID != input.Subject { return false } // Resource: service name must be in the allowed list if len(r.ServiceNames) > 0 && !stringIn(input.Resource.ServiceName, r.ServiceNames) { return false } // Resource: resource must carry ALL required tags if len(r.RequiredTags) > 0 && !allTagsPresent(input.Resource.Tags, r.RequiredTags) { return false } return true } // anyIn reports whether any element of needle appears in haystack. func anyIn(needle, haystack []string) bool { for _, n := range needle { for _, h := range haystack { if n == h { return true } } } return false } // stringIn reports whether s is in list. func stringIn(s string, list []string) bool { for _, v := range list { if s == v { return true } } return false } // actionIn reports whether a is in list. func actionIn(a Action, list []Action) bool { for _, v := range list { if a == v { return true } } return false } // allTagsPresent reports whether resourceTags contains every tag in required. func allTagsPresent(resourceTags, required []string) bool { for _, req := range required { found := false for _, rt := range resourceTags { if rt == req { found = true break } } if !found { return false } } return true }