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:
2026-03-11 23:24:03 -07:00
parent 5a8698e199
commit 052d3ed1b8
27 changed files with 3609 additions and 10 deletions

View File

@@ -0,0 +1,83 @@
package policy
// defaultRules are the compiled-in authorization rules. They cannot be
// modified or deleted via the API. They reproduce the previous binary
// admin/non-admin behavior exactly when no operator rules exist, so wiring
// the policy engine alongside RequireRole("admin") produces identical results.
//
// All defaults use Priority 0 so they are evaluated before any operator rule
// (which defaults to Priority 100). Within priority 0, deny-wins still applies,
// but the defaults contain no Deny rules — they only grant the minimum required
// for self-service and admin operations.
//
// Security rationale for each rule is documented inline.
var defaultRules = []Rule{
{
// Admin wildcard: an account bearing the "admin" role is permitted to
// perform any action on any resource. This mirrors the previous
// RequireRole("admin") check and is the root of all administrative trust.
ID: -1,
Description: "Admin wildcard: admin role allows all actions",
Priority: 0,
Roles: []string{"admin"},
Effect: Allow,
},
{
// Self-service logout and token renewal: any authenticated principal may
// revoke or renew their own token. No resource scoping is needed because
// the handler independently verifies that the JTI belongs to the caller.
ID: -2,
Description: "Self-service: any principal may logout or renew their own token",
Priority: 0,
Actions: []Action{ActionLogout, ActionRenewToken},
Effect: Allow,
},
{
// Self-service TOTP enrollment: any authenticated human account may
// initiate and confirm their own TOTP enrollment. The handler verifies
// the subject matches before writing.
ID: -3,
Description: "Self-service: any principal may enroll their own TOTP",
Priority: 0,
Actions: []Action{ActionEnrollTOTP},
Effect: Allow,
},
{
// System accounts reading their own pgcreds: a service that has already
// authenticated (e.g. via its bearer service token) may retrieve its own
// Postgres credentials without admin privilege. OwnerMatchesSubject
// ensures the service can only reach its own row — not another service's.
ID: -4,
Description: "System accounts may read their own pg_credentials",
Priority: 0,
AccountTypes: []string{"system"},
Actions: []Action{ActionReadPGCreds},
ResourceType: ResourcePGCreds,
OwnerMatchesSubject: true,
Effect: Allow,
},
{
// System accounts issuing or renewing their own service token: a system
// account may rotate its own bearer token. OwnerMatchesSubject ensures
// it cannot issue tokens for other accounts.
ID: -5,
Description: "System accounts may issue or renew their own service token",
Priority: 0,
AccountTypes: []string{"system"},
Actions: []Action{ActionIssueToken, ActionRenewToken},
ResourceType: ResourceToken,
OwnerMatchesSubject: true,
Effect: Allow,
},
{
// Public endpoints: token validation and login do not require
// authentication. The middleware exempts them from RequireAuth entirely;
// this rule exists so that if a policy check is accidentally applied to
// these paths, it does not block them.
ID: -6,
Description: "Public: token validation and login are always permitted",
Priority: 0,
Actions: []Action{ActionValidateToken, ActionLogin},
Effect: Allow,
},
}

150
internal/policy/engine.go Normal file
View 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
}

View File

@@ -0,0 +1,380 @@
package policy
import (
"testing"
)
// adminInput is a convenience helper for building admin PolicyInputs.
func adminInput(action Action, resType ResourceType) PolicyInput {
return PolicyInput{
Subject: "admin-uuid",
AccountType: "human",
Roles: []string{"admin"},
Action: action,
Resource: Resource{Type: resType},
}
}
func TestEvaluate_DefaultDeny(t *testing.T) {
// No operator rules, non-admin subject: should hit default-deny for an
// action that is not covered by built-in self-service defaults.
input := PolicyInput{
Subject: "user-uuid",
AccountType: "human",
Roles: []string{},
Action: ActionListAccounts,
Resource: Resource{Type: ResourceAccount},
}
effect, rule := Evaluate(input, nil)
if effect != Deny {
t.Errorf("expected Deny, got %s", effect)
}
if rule != nil {
t.Errorf("expected nil rule on default-deny, got %+v", rule)
}
}
func TestEvaluate_AdminWildcard(t *testing.T) {
actions := []Action{
ActionListAccounts, ActionCreateAccount, ActionReadPGCreds,
ActionWritePGCreds, ActionReadAudit, ActionManageRules,
}
for _, a := range actions {
t.Run(string(a), func(t *testing.T) {
effect, rule := Evaluate(adminInput(a, ResourceAccount), nil)
if effect != Allow {
t.Errorf("admin should be allowed %s, got Deny", a)
}
if rule == nil || rule.ID != -1 {
t.Errorf("expected admin wildcard rule (-1), got %v", rule)
}
})
}
}
func TestEvaluate_SelfServiceLogout(t *testing.T) {
input := PolicyInput{
Subject: "user-uuid",
AccountType: "human",
Roles: []string{},
Action: ActionLogout,
Resource: Resource{Type: ResourceToken},
}
effect, _ := Evaluate(input, nil)
if effect != Allow {
t.Error("expected any authenticated user to be allowed to logout")
}
}
func TestEvaluate_SelfServiceRenew(t *testing.T) {
input := PolicyInput{
Subject: "user-uuid",
AccountType: "human",
Roles: []string{},
Action: ActionRenewToken,
Resource: Resource{Type: ResourceToken},
}
effect, _ := Evaluate(input, nil)
if effect != Allow {
t.Error("expected any authenticated user to be allowed to renew token")
}
}
func TestEvaluate_SystemOwnPGCreds(t *testing.T) {
input := PolicyInput{
Subject: "svc-uuid",
AccountType: "system",
Roles: []string{},
Action: ActionReadPGCreds,
Resource: Resource{
Type: ResourcePGCreds,
OwnerUUID: "svc-uuid", // owner matches subject
},
}
effect, rule := Evaluate(input, nil)
if effect != Allow {
t.Errorf("system account should be allowed to read own pgcreds, got Deny")
}
if rule == nil || rule.ID != -4 {
t.Errorf("expected built-in rule -4, got %v", rule)
}
}
func TestEvaluate_SystemOtherPGCreds_Denied(t *testing.T) {
// System account trying to read another system account's pgcreds.
input := PolicyInput{
Subject: "svc-uuid",
AccountType: "system",
Roles: []string{},
Action: ActionReadPGCreds,
Resource: Resource{
Type: ResourcePGCreds,
OwnerUUID: "other-svc-uuid", // different owner
},
}
effect, _ := Evaluate(input, nil)
if effect != Allow {
// This is the expected behavior: default-deny.
return
}
t.Error("system account must not read another account's pgcreds without an explicit rule")
}
func TestEvaluate_DenyWins(t *testing.T) {
// Operator adds a Deny rule for a specific subject; a broader Allow rule
// also matches. Deny must win regardless of order.
operatorRules := []Rule{
{
ID: 1,
Description: "broad allow",
Priority: 100,
Actions: []Action{ActionReadPGCreds},
ResourceType: ResourcePGCreds,
Effect: Allow,
},
{
ID: 2,
Description: "specific deny",
Priority: 50, // higher precedence than the allow
SubjectUUID: "bad-actor-uuid",
ResourceType: ResourcePGCreds,
Effect: Deny,
},
}
input := PolicyInput{
Subject: "bad-actor-uuid",
AccountType: "human",
Roles: []string{},
Action: ActionReadPGCreds,
Resource: Resource{Type: ResourcePGCreds},
}
effect, rule := Evaluate(input, operatorRules)
if effect != Deny {
t.Errorf("deny rule should win over allow rule, got Allow")
}
if rule == nil || rule.ID != 2 {
t.Errorf("expected deny rule ID 2, got %v", rule)
}
}
func TestEvaluate_ServiceNameGating(t *testing.T) {
operatorRules := []Rule{
{
ID: 3,
Description: "alice may read payments-api pgcreds",
Priority: 50,
Roles: []string{"svc:payments-api"},
Actions: []Action{ActionReadPGCreds},
ResourceType: ResourcePGCreds,
ServiceNames: []string{"payments-api"},
Effect: Allow,
},
}
alice := PolicyInput{
Subject: "alice-uuid",
AccountType: "human",
Roles: []string{"svc:payments-api"},
Action: ActionReadPGCreds,
Resource: Resource{
Type: ResourcePGCreds,
ServiceName: "payments-api",
},
}
effect, _ := Evaluate(alice, operatorRules)
if effect != Allow {
t.Error("alice should be allowed to read payments-api pgcreds")
}
// Same principal, wrong service — should be denied.
alice.Resource.ServiceName = "user-service"
effect, _ = Evaluate(alice, operatorRules)
if effect != Deny {
t.Error("alice should be denied access to user-service pgcreds")
}
}
func TestEvaluate_MachineTagGating(t *testing.T) {
operatorRules := []Rule{
{
ID: 4,
Description: "deploy-agent: staging only",
Priority: 50,
SubjectUUID: "deploy-agent-uuid",
Actions: []Action{ActionReadPGCreds},
ResourceType: ResourcePGCreds,
RequiredTags: []string{"env:staging"},
Effect: Allow,
},
{
ID: 5,
Description: "deploy-agent: deny production (belt-and-suspenders)",
Priority: 10, // evaluated before the allow
SubjectUUID: "deploy-agent-uuid",
ResourceType: ResourcePGCreds,
RequiredTags: []string{"env:production"},
Effect: Deny,
},
}
staging := PolicyInput{
Subject: "deploy-agent-uuid",
AccountType: "system",
Roles: []string{},
Action: ActionReadPGCreds,
Resource: Resource{
Type: ResourcePGCreds,
Tags: []string{"env:staging", "svc:payments-api"},
},
}
effect, _ := Evaluate(staging, operatorRules)
if effect != Allow {
t.Error("deploy-agent should be allowed to read staging pgcreds")
}
production := staging
production.Resource.Tags = []string{"env:production", "svc:payments-api"}
effect, rule := Evaluate(production, operatorRules)
if effect != Deny {
t.Error("deploy-agent should be denied access to production pgcreds")
}
if rule == nil || rule.ID != 5 {
t.Errorf("expected deny rule ID 5 for production, got %v", rule)
}
}
func TestEvaluate_OwnerMatchesSubject(t *testing.T) {
// Operator rule: a user may read account details for accounts they own.
operatorRules := []Rule{
{
ID: 6,
Description: "principals may read their own account",
Priority: 50,
Actions: []Action{ActionReadAccount},
ResourceType: ResourceAccount,
OwnerMatchesSubject: true,
Effect: Allow,
},
}
// Reading own account — should be allowed.
own := PolicyInput{
Subject: "user-uuid",
AccountType: "human",
Roles: []string{},
Action: ActionReadAccount,
Resource: Resource{
Type: ResourceAccount,
OwnerUUID: "user-uuid",
},
}
effect, _ := Evaluate(own, operatorRules)
if effect != Allow {
t.Error("user should be allowed to read their own account")
}
// Reading another user's account — should be denied.
other := own
other.Resource.OwnerUUID = "other-uuid"
effect, _ = Evaluate(other, operatorRules)
if effect != Deny {
t.Error("user must not read another user's account without an explicit rule")
}
}
func TestEvaluate_PriorityOrder(t *testing.T) {
// Two Allow rules at different priorities: the lower-priority number wins.
operatorRules := []Rule{
{ID: 10, Description: "low priority allow", Priority: 200, Actions: []Action{ActionReadAudit}, Effect: Allow},
{ID: 11, Description: "high priority allow", Priority: 10, Actions: []Action{ActionReadAudit}, Effect: Allow},
}
input := PolicyInput{
Subject: "user-uuid",
AccountType: "human",
Roles: []string{},
Action: ActionReadAudit,
Resource: Resource{Type: ResourceAuditLog},
}
_, rule := Evaluate(input, operatorRules)
if rule == nil || rule.ID != 11 {
t.Errorf("expected higher-priority rule (ID 11) to match first, got %v", rule)
}
}
func TestEvaluate_MultipleRequiredTags(t *testing.T) {
// RequiredTags requires ALL tags to be present.
operatorRules := []Rule{
{
ID: 20,
Description: "allow if both env:staging and svc:payments-api tags present",
Priority: 50,
Actions: []Action{ActionReadPGCreds},
ResourceType: ResourcePGCreds,
RequiredTags: []string{"env:staging", "svc:payments-api"},
Effect: Allow,
},
}
// Both tags present — allowed.
input := PolicyInput{
Subject: "user-uuid",
AccountType: "human",
Roles: []string{},
Action: ActionReadPGCreds,
Resource: Resource{
Type: ResourcePGCreds,
Tags: []string{"env:staging", "svc:payments-api", "extra:tag"},
},
}
effect, _ := Evaluate(input, operatorRules)
if effect != Allow {
t.Error("both required tags present: should be allowed")
}
// Only one tag present — denied (default-deny).
input.Resource.Tags = []string{"env:staging"}
effect, _ = Evaluate(input, operatorRules)
if effect != Deny {
t.Error("only one required tag present: should be denied")
}
// No tags — denied.
input.Resource.Tags = nil
effect, _ = Evaluate(input, operatorRules)
if effect != Deny {
t.Error("no tags: should be denied")
}
}
func TestEvaluate_AccountTypeGating(t *testing.T) {
// Rule only applies to system accounts.
operatorRules := []Rule{
{
ID: 30,
Description: "system accounts may list accounts",
Priority: 50,
AccountTypes: []string{"system"},
Actions: []Action{ActionListAccounts},
Effect: Allow,
},
}
sysInput := PolicyInput{
Subject: "svc-uuid",
AccountType: "system",
Roles: []string{},
Action: ActionListAccounts,
Resource: Resource{Type: ResourceAccount},
}
effect, _ := Evaluate(sysInput, operatorRules)
if effect != Allow {
t.Error("system account should be allowed by account-type rule")
}
humanInput := sysInput
humanInput.AccountType = "human"
effect, _ = Evaluate(humanInput, operatorRules)
if effect != Deny {
t.Error("human account should not match system-only rule")
}
}

View File

@@ -0,0 +1,83 @@
package policy
import (
"encoding/json"
"fmt"
"sync"
)
// Engine wraps the stateless Evaluate function with an in-memory cache of
// operator rules loaded from the database. Built-in default rules are always
// merged in at evaluation time; they do not appear in the cache.
//
// The Engine is safe for concurrent use. Call Reload() after any change to the
// policy_rules table to refresh the cached rule set without restarting.
type Engine struct {
rules []Rule
mu sync.RWMutex
}
// NewEngine creates an Engine with an initially empty operator rule set.
// Call Reload (or load rules directly) before use in production.
func NewEngine() *Engine {
return &Engine{}
}
// SetRules atomically replaces the cached operator rule set.
// records is a slice of PolicyRuleRecord values (from the database layer).
// Only enabled records are converted to Rule values.
//
// Security: rule_json is decoded into a RuleBody struct before being merged
// into a Rule. This prevents the database from injecting values into the ID or
// Description fields that are stored as dedicated columns.
func (e *Engine) SetRules(records []PolicyRecord) error {
rules := make([]Rule, 0, len(records))
for _, rec := range records {
if !rec.Enabled {
continue
}
var body RuleBody
if err := json.Unmarshal([]byte(rec.RuleJSON), &body); err != nil {
return fmt.Errorf("policy: decode rule %d %q: %w", rec.ID, rec.Description, err)
}
rules = append(rules, Rule{
ID: rec.ID,
Description: rec.Description,
Priority: rec.Priority,
Roles: body.Roles,
AccountTypes: body.AccountTypes,
SubjectUUID: body.SubjectUUID,
Actions: body.Actions,
ResourceType: body.ResourceType,
OwnerMatchesSubject: body.OwnerMatchesSubject,
ServiceNames: body.ServiceNames,
RequiredTags: body.RequiredTags,
Effect: body.Effect,
})
}
e.mu.Lock()
e.rules = rules
e.mu.Unlock()
return nil
}
// Evaluate runs the policy engine against the given input using the cached
// operator rules plus compiled-in defaults.
func (e *Engine) Evaluate(input PolicyInput) (Effect, *Rule) {
e.mu.RLock()
rules := e.rules
e.mu.RUnlock()
return Evaluate(input, rules)
}
// PolicyRecord is the minimal interface the Engine needs from the DB layer.
// Using a local struct avoids importing the db or model packages from policy,
// which would create a dependency cycle.
type PolicyRecord struct {
Description string
RuleJSON string
ID int64
Priority int
Enabled bool
}

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

@@ -0,0 +1,141 @@
// Package policy implements an in-process, attribute-based authorization
// policy engine for MCIAS. Evaluation is a pure function: given a PolicyInput
// and a slice of Rules it returns an Effect (Allow or Deny) and the Rule that
// produced the decision. The caller is responsible for assembling PolicyInput
// from JWT claims and database lookups; the engine never touches the database.
//
// Evaluation order:
// 1. Rules are sorted by Priority (ascending; lower = higher precedence).
// 2. Deny-wins: the first matching Deny rule terminates evaluation.
// 3. If no Deny matched, the first matching Allow rule permits the request.
// 4. Default-deny: if no rule matches, the request is denied.
package policy
// Action is a structured action identifier of the form "resource:verb".
// Security: using typed constants prevents callers from passing arbitrary
// strings, making it harder to accidentally bypass a policy check.
type Action string
const (
ActionListAccounts Action = "accounts:list"
ActionCreateAccount Action = "accounts:create"
ActionReadAccount Action = "accounts:read"
ActionUpdateAccount Action = "accounts:update"
ActionDeleteAccount Action = "accounts:delete"
ActionReadRoles Action = "roles:read"
ActionWriteRoles Action = "roles:write"
ActionReadTags Action = "tags:read"
ActionWriteTags Action = "tags:write"
ActionIssueToken Action = "tokens:issue"
ActionRevokeToken Action = "tokens:revoke"
ActionValidateToken Action = "tokens:validate" // public endpoint
ActionRenewToken Action = "tokens:renew" // self-service
ActionReadPGCreds Action = "pgcreds:read"
ActionWritePGCreds Action = "pgcreds:write"
ActionReadAudit Action = "audit:read"
ActionEnrollTOTP Action = "totp:enroll" // self-service
ActionRemoveTOTP Action = "totp:remove" // admin
ActionLogin Action = "auth:login" // public
ActionLogout Action = "auth:logout" // self-service
ActionListRules Action = "policy:list"
ActionManageRules Action = "policy:manage"
)
// ResourceType identifies what kind of object a request targets.
type ResourceType string
const (
ResourceAccount ResourceType = "account"
ResourceToken ResourceType = "token"
ResourcePGCreds ResourceType = "pgcreds"
ResourceAuditLog ResourceType = "audit_log"
ResourceTOTP ResourceType = "totp"
ResourcePolicy ResourceType = "policy"
)
// Effect is the outcome of policy evaluation.
type Effect string
const (
Allow Effect = "allow"
Deny Effect = "deny"
)
// Resource describes the object the principal is attempting to act on. Tags
// are loaded from the account_tags table by the middleware before evaluation.
type Resource struct {
Type ResourceType
// OwnerUUID is the UUID of the account that owns this resource (e.g. the
// system account whose pg_credentials are being requested). Empty when the
// resource is not account-owned (e.g. an audit log listing).
OwnerUUID string
// ServiceName is the username of the system account that owns the resource.
// Used for service-name-based gating rules (ServiceNames field on Rule).
ServiceName string
// Tags are the account_tags values on the target account. The engine
// checks RequiredTags against this slice.
Tags []string
}
// PolicyInput is assembled by the middleware from JWT claims and the current
// request context. The engine accepts a PolicyInput and a rule set; it never
// queries the database directly.
type PolicyInput struct {
// Principal fields — from JWT claims
Subject string // account UUID ("sub")
AccountType string // "human" or "system"
Roles []string // role strings from "roles" claim
Action Action
Resource Resource
}
// Rule is a single policy statement. All non-zero fields are AND-ed together
// as match conditions. A zero/empty field is a wildcard.
//
// Security: rules from the database are decoded and merged with compiled-in
// defaults before evaluation. Neither the JSON encoding nor the DB storage is
// trusted to produce sensible rules; the engine validates each condition
// independently using set membership — there is no string interpolation or
// code execution involved.
type Rule struct {
Description string
SubjectUUID string
ResourceType ResourceType
Effect Effect
Roles []string
AccountTypes []string
Actions []Action
ServiceNames []string
RequiredTags []string
ID int64
Priority int
OwnerMatchesSubject bool
}
// RuleBody is the subset of Rule that is stored as JSON in the policy_rules
// table. ID, Description, and Priority are stored as dedicated columns.
// Security: the JSON blob is decoded into a RuleBody before being merged into
// a full Rule, so the database cannot inject ID or Description values.
type RuleBody struct {
SubjectUUID string `json:"subject_uuid,omitempty"`
ResourceType ResourceType `json:"resource_type,omitempty"`
Effect Effect `json:"effect"`
Roles []string `json:"roles,omitempty"`
AccountTypes []string `json:"account_types,omitempty"`
Actions []Action `json:"actions,omitempty"`
ServiceNames []string `json:"service_names,omitempty"`
RequiredTags []string `json:"required_tags,omitempty"`
OwnerMatchesSubject bool `json:"owner_matches_subject,omitempty"`
}