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:
@@ -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")
|
||||
|
||||
Reference in New Issue
Block a user