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:
347
internal/ui/handlers_policy.go
Normal file
347
internal/ui/handlers_policy.go
Normal file
@@ -0,0 +1,347 @@
|
||||
package ui
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"git.wntrmute.dev/kyle/mcias/internal/db"
|
||||
"git.wntrmute.dev/kyle/mcias/internal/model"
|
||||
"git.wntrmute.dev/kyle/mcias/internal/policy"
|
||||
)
|
||||
|
||||
// ---- Policies page ----
|
||||
|
||||
// allActionStrings is the list of all policy action constants for the form UI.
|
||||
var allActionStrings = []string{
|
||||
string(policy.ActionListAccounts),
|
||||
string(policy.ActionCreateAccount),
|
||||
string(policy.ActionReadAccount),
|
||||
string(policy.ActionUpdateAccount),
|
||||
string(policy.ActionDeleteAccount),
|
||||
string(policy.ActionReadRoles),
|
||||
string(policy.ActionWriteRoles),
|
||||
string(policy.ActionReadTags),
|
||||
string(policy.ActionWriteTags),
|
||||
string(policy.ActionIssueToken),
|
||||
string(policy.ActionRevokeToken),
|
||||
string(policy.ActionValidateToken),
|
||||
string(policy.ActionRenewToken),
|
||||
string(policy.ActionReadPGCreds),
|
||||
string(policy.ActionWritePGCreds),
|
||||
string(policy.ActionReadAudit),
|
||||
string(policy.ActionEnrollTOTP),
|
||||
string(policy.ActionRemoveTOTP),
|
||||
string(policy.ActionLogin),
|
||||
string(policy.ActionLogout),
|
||||
string(policy.ActionListRules),
|
||||
string(policy.ActionManageRules),
|
||||
}
|
||||
|
||||
func (u *UIServer) handlePoliciesPage(w http.ResponseWriter, r *http.Request) {
|
||||
csrfToken, err := u.setCSRFCookies(w)
|
||||
if err != nil {
|
||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
rules, err := u.db.ListPolicyRules(false)
|
||||
if err != nil {
|
||||
u.renderError(w, r, http.StatusInternalServerError, "failed to load policy rules")
|
||||
return
|
||||
}
|
||||
|
||||
views := make([]*PolicyRuleView, 0, len(rules))
|
||||
for _, rec := range rules {
|
||||
views = append(views, policyRuleToView(rec))
|
||||
}
|
||||
|
||||
data := PoliciesData{
|
||||
PageData: PageData{CSRFToken: csrfToken},
|
||||
Rules: views,
|
||||
AllActions: allActionStrings,
|
||||
}
|
||||
u.render(w, "policies", data)
|
||||
}
|
||||
|
||||
// policyRuleToView converts a DB record to a template-friendly view.
|
||||
func policyRuleToView(rec *model.PolicyRuleRecord) *PolicyRuleView {
|
||||
pretty := prettyJSONStr(rec.RuleJSON)
|
||||
return &PolicyRuleView{
|
||||
ID: rec.ID,
|
||||
Priority: rec.Priority,
|
||||
Description: rec.Description,
|
||||
RuleJSON: pretty,
|
||||
Enabled: rec.Enabled,
|
||||
CreatedAt: rec.CreatedAt.Format("2006-01-02 15:04 UTC"),
|
||||
UpdatedAt: rec.UpdatedAt.Format("2006-01-02 15:04 UTC"),
|
||||
}
|
||||
}
|
||||
|
||||
func prettyJSONStr(s string) string {
|
||||
var v json.RawMessage
|
||||
if err := json.Unmarshal([]byte(s), &v); err != nil {
|
||||
return s
|
||||
}
|
||||
b, err := json.MarshalIndent(v, "", " ")
|
||||
if err != nil {
|
||||
return s
|
||||
}
|
||||
return string(b)
|
||||
}
|
||||
|
||||
// handleCreatePolicyRule handles POST /policies — creates a new policy rule.
|
||||
func (u *UIServer) handleCreatePolicyRule(w http.ResponseWriter, r *http.Request) {
|
||||
r.Body = http.MaxBytesReader(w, r.Body, maxFormBytes)
|
||||
if err := r.ParseForm(); err != nil {
|
||||
u.renderError(w, r, http.StatusBadRequest, "invalid form")
|
||||
return
|
||||
}
|
||||
|
||||
description := strings.TrimSpace(r.FormValue("description"))
|
||||
if description == "" {
|
||||
u.renderError(w, r, http.StatusBadRequest, "description is required")
|
||||
return
|
||||
}
|
||||
|
||||
priorityStr := r.FormValue("priority")
|
||||
priority := 100
|
||||
if priorityStr != "" {
|
||||
p, err := strconv.Atoi(priorityStr)
|
||||
if err != nil || p < 0 {
|
||||
u.renderError(w, r, http.StatusBadRequest, "priority must be a non-negative integer")
|
||||
return
|
||||
}
|
||||
priority = p
|
||||
}
|
||||
|
||||
effectStr := r.FormValue("effect")
|
||||
if effectStr != string(policy.Allow) && effectStr != string(policy.Deny) {
|
||||
u.renderError(w, r, http.StatusBadRequest, "effect must be 'allow' or 'deny'")
|
||||
return
|
||||
}
|
||||
|
||||
body := policy.RuleBody{
|
||||
Effect: policy.Effect(effectStr),
|
||||
}
|
||||
|
||||
// Multi-value fields.
|
||||
if roles := r.Form["roles"]; len(roles) > 0 {
|
||||
body.Roles = roles
|
||||
}
|
||||
if types := r.Form["account_types"]; len(types) > 0 {
|
||||
body.AccountTypes = types
|
||||
}
|
||||
if actions := r.Form["actions"]; len(actions) > 0 {
|
||||
acts := make([]policy.Action, len(actions))
|
||||
for i, a := range actions {
|
||||
acts[i] = policy.Action(a)
|
||||
}
|
||||
body.Actions = acts
|
||||
}
|
||||
if resType := r.FormValue("resource_type"); resType != "" {
|
||||
body.ResourceType = policy.ResourceType(resType)
|
||||
}
|
||||
body.SubjectUUID = strings.TrimSpace(r.FormValue("subject_uuid"))
|
||||
body.OwnerMatchesSubject = r.FormValue("owner_matches_subject") == "1"
|
||||
if svcNames := r.FormValue("service_names"); svcNames != "" {
|
||||
body.ServiceNames = splitCommas(svcNames)
|
||||
}
|
||||
if tags := r.FormValue("required_tags"); tags != "" {
|
||||
body.RequiredTags = splitCommas(tags)
|
||||
}
|
||||
|
||||
ruleJSON, err := json.Marshal(body)
|
||||
if err != nil {
|
||||
u.renderError(w, r, http.StatusInternalServerError, "internal error")
|
||||
return
|
||||
}
|
||||
|
||||
claims := claimsFromContext(r.Context())
|
||||
var actorID *int64
|
||||
if claims != nil {
|
||||
if actor, err := u.db.GetAccountByUUID(claims.Subject); err == nil {
|
||||
actorID = &actor.ID
|
||||
}
|
||||
}
|
||||
|
||||
rec, err := u.db.CreatePolicyRule(description, priority, string(ruleJSON), actorID)
|
||||
if err != nil {
|
||||
u.renderError(w, r, http.StatusInternalServerError, fmt.Sprintf("create policy rule: %v", err))
|
||||
return
|
||||
}
|
||||
|
||||
u.writeAudit(r, model.EventPolicyRuleCreated, actorID, nil,
|
||||
fmt.Sprintf(`{"rule_id":%d,"description":%q}`, rec.ID, rec.Description))
|
||||
|
||||
u.render(w, "policy_row", policyRuleToView(rec))
|
||||
}
|
||||
|
||||
// handleTogglePolicyRule handles PATCH /policies/{id}/enabled — enable or disable.
|
||||
func (u *UIServer) handleTogglePolicyRule(w http.ResponseWriter, r *http.Request) {
|
||||
rec, ok := u.loadPolicyRule(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
r.Body = http.MaxBytesReader(w, r.Body, maxFormBytes)
|
||||
if err := r.ParseForm(); err != nil {
|
||||
u.renderError(w, r, http.StatusBadRequest, "invalid form")
|
||||
return
|
||||
}
|
||||
|
||||
enabledStr := r.FormValue("enabled")
|
||||
enabled := enabledStr == "1" || enabledStr == "true"
|
||||
|
||||
if err := u.db.SetPolicyRuleEnabled(rec.ID, enabled); err != nil {
|
||||
u.renderError(w, r, http.StatusInternalServerError, "update failed")
|
||||
return
|
||||
}
|
||||
|
||||
claims := claimsFromContext(r.Context())
|
||||
var actorID *int64
|
||||
if claims != nil {
|
||||
if actor, err := u.db.GetAccountByUUID(claims.Subject); err == nil {
|
||||
actorID = &actor.ID
|
||||
}
|
||||
}
|
||||
u.writeAudit(r, model.EventPolicyRuleUpdated, actorID, nil,
|
||||
fmt.Sprintf(`{"rule_id":%d,"enabled":%v}`, rec.ID, enabled))
|
||||
|
||||
rec.Enabled = enabled
|
||||
u.render(w, "policy_row", policyRuleToView(rec))
|
||||
}
|
||||
|
||||
// handleDeletePolicyRule handles DELETE /policies/{id}.
|
||||
func (u *UIServer) handleDeletePolicyRule(w http.ResponseWriter, r *http.Request) {
|
||||
rec, ok := u.loadPolicyRule(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
if err := u.db.DeletePolicyRule(rec.ID); err != nil {
|
||||
u.renderError(w, r, http.StatusInternalServerError, "delete failed")
|
||||
return
|
||||
}
|
||||
|
||||
claims := claimsFromContext(r.Context())
|
||||
var actorID *int64
|
||||
if claims != nil {
|
||||
if actor, err := u.db.GetAccountByUUID(claims.Subject); err == nil {
|
||||
actorID = &actor.ID
|
||||
}
|
||||
}
|
||||
u.writeAudit(r, model.EventPolicyRuleDeleted, actorID, nil,
|
||||
fmt.Sprintf(`{"rule_id":%d}`, rec.ID))
|
||||
|
||||
// Return empty string to remove the row from the DOM.
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}
|
||||
|
||||
// ---- Tag management ----
|
||||
|
||||
// handleSetAccountTags handles PUT /accounts/{id}/tags from the UI.
|
||||
func (u *UIServer) handleSetAccountTags(w http.ResponseWriter, r *http.Request) {
|
||||
id := r.PathValue("id")
|
||||
acct, err := u.db.GetAccountByUUID(id)
|
||||
if err != nil {
|
||||
u.renderError(w, r, http.StatusNotFound, "account not found")
|
||||
return
|
||||
}
|
||||
|
||||
r.Body = http.MaxBytesReader(w, r.Body, maxFormBytes)
|
||||
if err := r.ParseForm(); err != nil {
|
||||
u.renderError(w, r, http.StatusBadRequest, "invalid form")
|
||||
return
|
||||
}
|
||||
|
||||
tagsRaw := strings.TrimSpace(r.FormValue("tags_text"))
|
||||
var tags []string
|
||||
if tagsRaw != "" {
|
||||
tags = splitLines(tagsRaw)
|
||||
}
|
||||
|
||||
// Validate: no empty tags.
|
||||
for _, tag := range tags {
|
||||
if tag == "" {
|
||||
u.renderError(w, r, http.StatusBadRequest, "tag values must not be empty")
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if err := u.db.SetAccountTags(acct.ID, tags); err != nil {
|
||||
u.renderError(w, r, http.StatusInternalServerError, "update failed")
|
||||
return
|
||||
}
|
||||
|
||||
claims := claimsFromContext(r.Context())
|
||||
var actorID *int64
|
||||
if claims != nil {
|
||||
if actor, err := u.db.GetAccountByUUID(claims.Subject); err == nil {
|
||||
actorID = &actor.ID
|
||||
}
|
||||
}
|
||||
u.writeAudit(r, model.EventTagAdded, actorID, &acct.ID,
|
||||
fmt.Sprintf(`{"account":%q,"tags":%d}`, acct.UUID, len(tags)))
|
||||
|
||||
csrfToken, _ := u.setCSRFCookies(w)
|
||||
u.render(w, "tags_editor", AccountDetailData{
|
||||
PageData: PageData{CSRFToken: csrfToken},
|
||||
Account: acct,
|
||||
Tags: tags,
|
||||
})
|
||||
}
|
||||
|
||||
// ---- Helpers ----
|
||||
|
||||
func (u *UIServer) loadPolicyRule(w http.ResponseWriter, r *http.Request) (*model.PolicyRuleRecord, bool) {
|
||||
idStr := r.PathValue("id")
|
||||
if idStr == "" {
|
||||
u.renderError(w, r, http.StatusBadRequest, "rule id is required")
|
||||
return nil, false
|
||||
}
|
||||
id, err := strconv.ParseInt(idStr, 10, 64)
|
||||
if err != nil {
|
||||
u.renderError(w, r, http.StatusBadRequest, "rule id must be an integer")
|
||||
return nil, false
|
||||
}
|
||||
rec, err := u.db.GetPolicyRule(id)
|
||||
if err != nil {
|
||||
if errors.Is(err, db.ErrNotFound) {
|
||||
u.renderError(w, r, http.StatusNotFound, "policy rule not found")
|
||||
return nil, false
|
||||
}
|
||||
u.renderError(w, r, http.StatusInternalServerError, "internal error")
|
||||
return nil, false
|
||||
}
|
||||
return rec, true
|
||||
}
|
||||
|
||||
// splitCommas splits a comma-separated string and trims whitespace from each element.
|
||||
func splitCommas(s string) []string {
|
||||
parts := strings.Split(s, ",")
|
||||
out := make([]string, 0, len(parts))
|
||||
for _, p := range parts {
|
||||
p = strings.TrimSpace(p)
|
||||
if p != "" {
|
||||
out = append(out, p)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// splitLines splits a newline-separated string and trims whitespace from each element.
|
||||
func splitLines(s string) []string {
|
||||
parts := strings.Split(s, "\n")
|
||||
out := make([]string, 0, len(parts))
|
||||
for _, p := range parts {
|
||||
p = strings.TrimSpace(p)
|
||||
if p != "" {
|
||||
out = append(out, p)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
Reference in New Issue
Block a user