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:
@@ -25,6 +25,7 @@ import (
|
||||
"time"
|
||||
|
||||
"git.wntrmute.dev/kyle/mcias/internal/db"
|
||||
"git.wntrmute.dev/kyle/mcias/internal/policy"
|
||||
"git.wntrmute.dev/kyle/mcias/internal/token"
|
||||
)
|
||||
|
||||
@@ -297,3 +298,98 @@ func minFloat64(a, b float64) float64 {
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
// ResourceBuilder is a function that assembles the policy.Resource for a
|
||||
// specific request. The middleware calls it after claims are extracted.
|
||||
// Implementations typically read the path parameter (e.g. account UUID) and
|
||||
// look up the target account's owner UUID, service name, and tags from the DB.
|
||||
//
|
||||
// A nil ResourceBuilder is equivalent to a function that returns an empty
|
||||
// Resource (no owner, no service name, no tags).
|
||||
type ResourceBuilder func(r *http.Request, claims *token.Claims) policy.Resource
|
||||
|
||||
// AccountTypeLookup resolves the account type ("human" or "system") for the
|
||||
// given account UUID. The middleware calls this to populate PolicyInput when
|
||||
// the AccountTypes match condition is used in any rule.
|
||||
//
|
||||
// Callers supply an implementation backed by db.GetAccountByUUID; the
|
||||
// middleware does not import the db package directly to avoid a cycle.
|
||||
// Returning an empty string is safe — it simply will not match any
|
||||
// AccountTypes condition on rules.
|
||||
type AccountTypeLookup func(subjectUUID string) string
|
||||
|
||||
// PolicyDenyLogger is a function that records a policy denial in the audit log.
|
||||
// Callers supply an implementation that calls db.WriteAuditEvent; the middleware
|
||||
// itself does not import the db package directly for the audit write, keeping
|
||||
// the dependency on policy and db separate.
|
||||
type PolicyDenyLogger func(r *http.Request, claims *token.Claims, action policy.Action, res policy.Resource, matchedRuleID int64)
|
||||
|
||||
// RequirePolicy returns middleware that evaluates the policy engine for the
|
||||
// given action and resource type. Must be used after RequireAuth.
|
||||
//
|
||||
// Security: deny-wins and default-deny semantics mean that any misconfiguration
|
||||
// (missing rule, engine error) results in a 403, never silent permit. The
|
||||
// matched rule ID is included in the audit event for traceability.
|
||||
//
|
||||
// AccountType is not stored in the JWT to avoid a signature-breaking change to
|
||||
// IssueToken. It is resolved lazily via lookupAccountType (a DB-backed closure
|
||||
// provided by the caller). Returning "" from lookupAccountType is safe: no
|
||||
// AccountTypes rule condition will match an empty string.
|
||||
//
|
||||
// RequirePolicy is intended to coexist with RequireRole("admin") during the
|
||||
// migration period. Once full policy coverage is validated, RequireRole can be
|
||||
// removed. During the transition both checks must pass.
|
||||
func RequirePolicy(
|
||||
eng *policy.Engine,
|
||||
action policy.Action,
|
||||
resType policy.ResourceType,
|
||||
buildResource ResourceBuilder,
|
||||
lookupAccountType AccountTypeLookup,
|
||||
logDeny PolicyDenyLogger,
|
||||
) func(http.Handler) http.Handler {
|
||||
return func(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
claims := ClaimsFromContext(r.Context())
|
||||
if claims == nil {
|
||||
// RequireAuth was not applied upstream; fail closed.
|
||||
writeError(w, http.StatusForbidden, "forbidden", "forbidden")
|
||||
return
|
||||
}
|
||||
|
||||
var res policy.Resource
|
||||
res.Type = resType
|
||||
if buildResource != nil {
|
||||
res = buildResource(r, claims)
|
||||
res.Type = resType // ensure type is always set even if builder overrides
|
||||
}
|
||||
|
||||
accountType := ""
|
||||
if lookupAccountType != nil {
|
||||
accountType = lookupAccountType(claims.Subject)
|
||||
}
|
||||
|
||||
input := policy.PolicyInput{
|
||||
Subject: claims.Subject,
|
||||
AccountType: accountType,
|
||||
Roles: claims.Roles,
|
||||
Action: action,
|
||||
Resource: res,
|
||||
}
|
||||
|
||||
effect, matched := eng.Evaluate(input)
|
||||
if effect == policy.Deny {
|
||||
var ruleID int64
|
||||
if matched != nil {
|
||||
ruleID = matched.ID
|
||||
}
|
||||
if logDeny != nil {
|
||||
logDeny(r, claims, action, res, ruleID)
|
||||
}
|
||||
writeError(w, http.StatusForbidden, "insufficient privileges", "forbidden")
|
||||
return
|
||||
}
|
||||
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user