Fix UI: install real HTMX, add PG creds and roles UI

- web/static/htmx.min.js: replace placeholder stub with
  htmx 2.0.4 (downloaded from unpkg.com). The placeholder
  only logged a console warning; no HTMX features worked,
  so form submissions fell back to native POSTs and the
  account_row fragment was returned as a raw HTML body
  rather than spliced into the table. This was the root
  cause of account creation appearing to 'do nothing'.
- internal/ui/ui.go: add pgcreds_form.html to shared
  template list; add PUT /accounts/{id}/pgcreds route;
  reorder AccountDetailData fields so embedded PageData
  does not shadow Account.
- internal/ui/handlers_accounts.go: add handleSetPGCreds
  handler — encrypts the submitted password with AES-256-GCM
  using the server master key before storage, validates
  system-account-only constraint, re-reads and re-renders
  the fragment after save. Add PGCred field population to
  handleAccountDetail.
- internal/ui/ui_test.go: add tests for account creation,
  role management, and PG credential handlers.
- web/templates/account_detail.html: add Postgres
  Credentials card for system accounts.
- web/templates/fragments/pgcreds_form.html: new fragment
  for the PG credentials form; CSRF token is supplied via
  the body-level hx-headers attribute in base.html.
Security: PG password is encrypted with AES-256-GCM
(crypto.SealAESGCM) before storage; a fresh nonce is
generated per call; the plaintext is never logged or
returned in responses.
This commit is contained in:
2026-03-11 22:30:13 -07:00
parent 9b0adfdde4
commit 5a8698e199
7 changed files with 528 additions and 14 deletions

View File

@@ -1,11 +1,15 @@
package ui
import (
"errors"
"fmt"
"net/http"
"strconv"
"strings"
"git.wntrmute.dev/kyle/mcias/internal/auth"
"git.wntrmute.dev/kyle/mcias/internal/crypto"
"git.wntrmute.dev/kyle/mcias/internal/db"
"git.wntrmute.dev/kyle/mcias/internal/model"
"git.wntrmute.dev/kyle/mcias/internal/validate"
)
@@ -128,12 +132,24 @@ func (u *UIServer) handleAccountDetail(w http.ResponseWriter, r *http.Request) {
tokens = nil
}
// Load PG credentials for system accounts only; leave nil for human accounts
// and when no credentials have been stored yet.
var pgCred *model.PGCredential
if acct.AccountType == model.AccountTypeSystem {
pgCred, err = u.db.ReadPGCredentials(acct.ID)
if err != nil && !errors.Is(err, db.ErrNotFound) {
u.logger.Warn("read pg credentials", "error", err)
}
// ErrNotFound is expected when no credentials have been stored yet.
}
u.render(w, "account_detail", AccountDetailData{
PageData: PageData{CSRFToken: csrfToken},
Account: acct,
Roles: roles,
AllRoles: knownRoles,
Tokens: tokens,
PGCred: pgCred,
})
}
@@ -337,6 +353,114 @@ func (u *UIServer) handleRevokeToken(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
}
// handleSetPGCreds stores (or replaces) encrypted Postgres credentials for a
// system account. The submitted password is encrypted with AES-256-GCM using the
// server master key before storage and is never echoed back in the response.
//
// Security: Only system accounts may hold PG credentials. The password field is
// write-only — the UI displays only connection metadata (host, port, database,
// username) after save. Audit event EventPGCredUpdated is recorded on success.
func (u *UIServer) handleSetPGCreds(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
}
id := r.PathValue("id")
acct, err := u.db.GetAccountByUUID(id)
if err != nil {
u.renderError(w, r, http.StatusNotFound, "account not found")
return
}
// Security: PG credentials are only meaningful for system accounts.
if acct.AccountType != model.AccountTypeSystem {
u.renderError(w, r, http.StatusBadRequest, "postgres credentials are only available for system accounts")
return
}
host := strings.TrimSpace(r.FormValue("host"))
portStr := strings.TrimSpace(r.FormValue("port"))
dbName := strings.TrimSpace(r.FormValue("database"))
username := strings.TrimSpace(r.FormValue("username"))
password := r.FormValue("password")
if host == "" {
u.renderError(w, r, http.StatusBadRequest, "host is required")
return
}
if dbName == "" {
u.renderError(w, r, http.StatusBadRequest, "database is required")
return
}
if username == "" {
u.renderError(w, r, http.StatusBadRequest, "username is required")
return
}
// Security: password is required on every write — the UI does not carry the
// existing password, so callers must supply it explicitly.
if password == "" {
u.renderError(w, r, http.StatusBadRequest, "password is required")
return
}
port := 5432
if portStr != "" {
port, err = strconv.Atoi(portStr)
if err != nil || port < 1 || port > 65535 {
u.renderError(w, r, http.StatusBadRequest, "port must be an integer between 1 and 65535")
return
}
}
// Security: encrypt the password with AES-256-GCM before storage.
// A fresh random nonce is generated per call by SealAESGCM; nonce reuse
// is not possible. The plaintext password is not retained after this call.
enc, nonce, err := crypto.SealAESGCM(u.masterKey, []byte(password))
if err != nil {
u.logger.Error("encrypt pg password", "error", err)
u.renderError(w, r, http.StatusInternalServerError, "internal error")
return
}
if err := u.db.WritePGCredentials(acct.ID, host, port, dbName, username, enc, nonce); err != nil {
u.logger.Error("write pg credentials", "error", err)
u.renderError(w, r, http.StatusInternalServerError, "failed to save credentials")
return
}
claims := claimsFromContext(r.Context())
var actorID *int64
if claims != nil {
actor, err := u.db.GetAccountByUUID(claims.Subject)
if err == nil {
actorID = &actor.ID
}
}
u.writeAudit(r, model.EventPGCredUpdated, actorID, &acct.ID, "")
// Re-read the stored record to populate the metadata display.
// The encrypted blobs are loaded but the password field (PGPassword) remains
// empty — it is only decrypted on explicit admin retrieval via gRPC.
pgCred, err := u.db.ReadPGCredentials(acct.ID)
if err != nil {
u.logger.Warn("re-read pg credentials after write", "error", err)
pgCred = nil
}
csrfToken, err := u.setCSRFCookies(w)
if err != nil {
csrfToken = ""
}
u.render(w, "pgcreds_form", AccountDetailData{
PageData: PageData{CSRFToken: csrfToken},
Account: acct,
PGCred: pgCred,
})
}
// handleIssueSystemToken issues a long-lived service token for a system account.
func (u *UIServer) handleIssueSystemToken(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")