- 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.
381 lines
10 KiB
Go
381 lines
10 KiB
Go
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")
|
|
}
|
|
}
|