Add PG creds + policy/tags UI; fix lint and build
- internal/ui/ui.go: add PGCred, Tags to AccountDetailData; register
PUT /accounts/{id}/pgcreds and PUT /accounts/{id}/tags routes; add
pgcreds_form.html and tags_editor.html to shared template set; remove
unused AccountTagsData; fix fieldalignment on PolicyRuleView, PoliciesData
- internal/ui/handlers_accounts.go: add handleSetPGCreds — encrypts
password via crypto.SealAESGCM, writes audit EventPGCredUpdated, renders
pgcreds_form fragment; password never echoed; load PG creds and tags in
handleAccountDetail
- internal/ui/handlers_policy.go: fix handleSetAccountTags to render with
AccountDetailData instead of removed AccountTagsData
- internal/ui/ui_test.go: add 5 PG credential UI tests
- web/templates/fragments/pgcreds_form.html: new fragment — metadata display
+ set/replace form; system accounts only; password write-only
- web/templates/fragments/tags_editor.html: new fragment — textarea editor
with HTMX PUT for atomic tag replacement
- web/templates/fragments/policy_form.html: rewrite to use structured fields
matching handleCreatePolicyRule (roles/account_types/actions multi-select,
resource_type, subject_uuid, service_names, required_tags, checkbox)
- web/templates/policies.html: new policies management page
- web/templates/fragments/policy_row.html: new HTMX table row with toggle
and delete
- web/templates/account_detail.html: add Tags card and PG Credentials card
- web/templates/base.html: add Policies nav link
- internal/server/server.go: remove ~220 lines of duplicate tag/policy
handler code (real implementations are in handlers_policy.go)
- internal/policy/engine_wrapper.go: fix corrupted source; use errors.New
- internal/db/policy_test.go: use model.AccountTypeHuman constant
- cmd/mciasctl/main.go: add nolint:gosec to int(os.Stdin.Fd()) calls
- gofmt/goimports: db/policy_test.go, policy/defaults.go,
policy/engine_test.go, ui/ui.go, cmd/mciasctl/main.go
- fieldalignment: model.PolicyRuleRecord, policy.Engine, policy.Rule,
policy.RuleBody, ui.PolicyRuleView
Security: PG password encrypted AES-256-GCM with fresh random nonce before
storage; plaintext never logged or returned in any response; audit event
written on every credential write.
This commit is contained in:
150
internal/policy/engine.go
Normal file
150
internal/policy/engine.go
Normal file
@@ -0,0 +1,150 @@
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user