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"
)
// knownRoles lists the built-in roles shown as checkboxes in the roles editor.
var knownRoles = []string{"admin", "user", "service"}
// handleAccountsList renders the accounts list page.
func (u *UIServer) handleAccountsList(w http.ResponseWriter, r *http.Request) {
csrfToken, err := u.setCSRFCookies(w)
if err != nil {
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
accounts, err := u.db.ListAccounts()
if err != nil {
u.renderError(w, r, http.StatusInternalServerError, "failed to load accounts")
return
}
u.render(w, "accounts", AccountsData{
PageData: PageData{CSRFToken: csrfToken, ActorName: u.actorName(r)},
Accounts: accounts,
})
}
// handleCreateAccount creates a new account and returns the account_row fragment.
func (u *UIServer) handleCreateAccount(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
}
username := strings.TrimSpace(r.FormValue("username"))
password := r.FormValue("password")
accountTypeStr := r.FormValue("account_type")
// Security (F-12): validate username length and character set.
if err := validate.Username(username); err != nil {
u.renderError(w, r, http.StatusBadRequest, err.Error())
return
}
accountType := model.AccountTypeHuman
if accountTypeStr == string(model.AccountTypeSystem) {
accountType = model.AccountTypeSystem
}
var passwordHash string
if password != "" {
// Security (F-13): enforce minimum length before hashing.
if err := validate.Password(password); err != nil {
u.renderError(w, r, http.StatusBadRequest, err.Error())
return
}
argonCfg := auth.ArgonParams{
Time: u.cfg.Argon2.Time,
Memory: u.cfg.Argon2.Memory,
Threads: u.cfg.Argon2.Threads,
}
var err error
passwordHash, err = auth.HashPassword(password, argonCfg)
if err != nil {
u.logger.Error("hash password", "error", err)
u.renderError(w, r, http.StatusInternalServerError, "internal error")
return
}
} else if accountType == model.AccountTypeHuman {
u.renderError(w, r, http.StatusBadRequest, "password is required for human accounts")
return
}
claims := claimsFromContext(r.Context())
var actorID *int64
if claims != nil {
acct, err := u.db.GetAccountByUUID(claims.Subject)
if err == nil {
actorID = &acct.ID
}
}
acct, err := u.db.CreateAccount(username, accountType, passwordHash)
if err != nil {
u.renderError(w, r, http.StatusInternalServerError, fmt.Sprintf("create account: %v", err))
return
}
u.writeAudit(r, model.EventAccountCreated, actorID, &acct.ID,
fmt.Sprintf(`{"username":%q,"type":%q}`, username, accountType))
u.render(w, "account_row", acct)
}
// handleAccountDetail renders the account detail page.
func (u *UIServer) handleAccountDetail(w http.ResponseWriter, r *http.Request) {
csrfToken, err := u.setCSRFCookies(w)
if err != nil {
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
id := r.PathValue("id")
acct, err := u.db.GetAccountByUUID(id)
if err != nil {
u.renderError(w, r, http.StatusNotFound, "account not found")
return
}
roles, err := u.db.GetRoles(acct.ID)
if err != nil {
u.renderError(w, r, http.StatusInternalServerError, "failed to load roles")
return
}
tokens, err := u.db.ListTokensForAccount(acct.ID)
if err != nil {
u.logger.Warn("list tokens for account", "error", err)
tokens = nil
}
// Resolve the currently logged-in actor.
claims := claimsFromContext(r.Context())
var actorID *int64
if claims != nil {
if actor, err := u.db.GetAccountByUUID(claims.Subject); err == nil {
actorID = &actor.ID
}
}
// Load PG credentials for system accounts only; leave nil for human accounts
// and when no credentials have been stored yet.
var pgCred *model.PGCredential
var pgCredGrants []*model.PGCredAccessGrant
var grantableAccounts []*model.Account
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.
// Load access grants; only show management controls when the actor is owner.
if pgCred != nil {
pgCredGrants, err = u.db.ListPGCredAccess(pgCred.ID)
if err != nil {
u.logger.Warn("list pg cred access", "error", err)
}
// Populate the "add grantee" dropdown only for the credential owner.
if actorID != nil && pgCred.OwnerID != nil && *pgCred.OwnerID == *actorID {
grantableAccounts, err = u.db.ListAccounts()
if err != nil {
u.logger.Warn("list accounts for pgcred grant", "error", err)
}
}
}
}
tags, err := u.db.GetAccountTags(acct.ID)
if err != nil {
u.logger.Warn("get account tags", "error", err)
tags = nil
}
u.render(w, "account_detail", AccountDetailData{
PageData: PageData{CSRFToken: csrfToken, ActorName: u.actorName(r)},
Account: acct,
Roles: roles,
AllRoles: knownRoles,
Tokens: tokens,
PGCred: pgCred,
PGCredGrants: pgCredGrants,
GrantableAccounts: grantableAccounts,
ActorID: actorID,
Tags: tags,
})
}
// handleUpdateAccountStatus toggles an account between active and inactive.
func (u *UIServer) handleUpdateAccountStatus(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
}
statusStr := r.FormValue("status")
var newStatus model.AccountStatus
switch statusStr {
case string(model.AccountStatusActive):
newStatus = model.AccountStatusActive
case string(model.AccountStatusInactive):
newStatus = model.AccountStatusInactive
default:
u.renderError(w, r, http.StatusBadRequest, "invalid status")
return
}
if err := u.db.UpdateAccountStatus(acct.ID, newStatus); err != nil {
u.renderError(w, r, http.StatusInternalServerError, "update failed")
return
}
acct.Status = newStatus
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.EventAccountUpdated, actorID, &acct.ID,
fmt.Sprintf(`{"status":%q}`, newStatus))
// Respond with the updated row (for HTMX outerHTML swap on accounts list)
// or updated status cell (for HTMX innerHTML swap on account detail).
// The hx-target in accounts.html targets the whole
; in account_detail.html
// it targets #status-cell. We detect which by checking the request path context.
if strings.Contains(r.Header.Get("HX-Target"), "status-cell") || r.Header.Get("HX-Target") == "status-cell" {
data := AccountDetailData{
PageData: PageData{CSRFToken: ""},
Account: acct,
}
u.render(w, "account_status", data)
} else {
u.render(w, "account_row", acct)
}
}
// handleDeleteAccount soft-deletes an account and returns empty body (HTMX removes row).
func (u *UIServer) handleDeleteAccount(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
}
if err := u.db.UpdateAccountStatus(acct.ID, model.AccountStatusDeleted); err != nil {
u.renderError(w, r, http.StatusInternalServerError, "delete failed")
return
}
// Revoke all active tokens for the deleted account.
if err := u.db.RevokeAllUserTokens(acct.ID, "account_deleted"); err != nil {
u.logger.Warn("revoke tokens for deleted account", "account_id", acct.ID, "error", err)
}
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.EventAccountDeleted, actorID, &acct.ID, "")
// Return empty body; HTMX will remove the row via hx-swap="outerHTML".
w.WriteHeader(http.StatusOK)
}
// handleRolesEditForm returns the roles editor fragment (GET — no CSRF check needed).
func (u *UIServer) handleRolesEditForm(w http.ResponseWriter, r *http.Request) {
csrfToken, err := u.setCSRFCookies(w)
if err != nil {
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
id := r.PathValue("id")
acct, err := u.db.GetAccountByUUID(id)
if err != nil {
u.renderError(w, r, http.StatusNotFound, "account not found")
return
}
roles, err := u.db.GetRoles(acct.ID)
if err != nil {
u.renderError(w, r, http.StatusInternalServerError, "failed to load roles")
return
}
u.render(w, "roles_editor", AccountDetailData{
PageData: PageData{CSRFToken: csrfToken},
Account: acct,
Roles: roles,
AllRoles: knownRoles,
})
}
// handleSetRoles replaces the full role set for an account.
func (u *UIServer) handleSetRoles(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
}
// Collect checked roles + optional custom role.
roles := r.Form["roles"] // multi-value from checkboxes
if custom := strings.TrimSpace(r.FormValue("custom_role")); custom != "" {
roles = append(roles, custom)
}
claims := claimsFromContext(r.Context())
var actorID *int64
if claims != nil {
actor, err := u.db.GetAccountByUUID(claims.Subject)
if err == nil {
actorID = &actor.ID
}
}
if err := u.db.SetRoles(acct.ID, roles, actorID); err != nil {
u.renderError(w, r, http.StatusInternalServerError, "failed to set roles")
return
}
u.writeAudit(r, model.EventRoleGranted, actorID, &acct.ID,
fmt.Sprintf(`{"roles":%q}`, strings.Join(roles, ",")))
csrfToken, err := u.setCSRFCookies(w)
if err != nil {
csrfToken = ""
}
u.render(w, "roles_editor", AccountDetailData{
PageData: PageData{CSRFToken: csrfToken},
Account: acct,
Roles: roles,
AllRoles: knownRoles,
})
}
// handleRevokeToken revokes a specific token by JTI.
func (u *UIServer) handleRevokeToken(w http.ResponseWriter, r *http.Request) {
jti := r.PathValue("jti")
if jti == "" {
u.renderError(w, r, http.StatusBadRequest, "missing JTI")
return
}
if err := u.db.RevokeToken(jti, "ui_revoke"); err != nil {
u.renderError(w, r, http.StatusInternalServerError, "revoke failed")
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.EventTokenRevoked, actorID, nil,
fmt.Sprintf(`{"jti":%q,"reason":"ui_revoke"}`, jti))
// Return empty body; HTMX removes the row.
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
}
// Security: set the credential owner to the actor on first write so that
// subsequent grant/revoke operations can enforce ownership. If no actor
// is present (e.g. bootstrap), the owner remains nil.
if pgCred != nil && pgCred.OwnerID == nil && actorID != nil {
if err := u.db.SetPGCredentialOwner(pgCred.ID, *actorID); err != nil {
u.logger.Warn("set pg credential owner", "error", err)
} else {
pgCred.OwnerID = actorID
}
}
// Load existing access grants to re-render the full section.
var grants []*model.PGCredAccessGrant
if pgCred != nil {
grants, err = u.db.ListPGCredAccess(pgCred.ID)
if err != nil {
u.logger.Warn("list pg cred access after write", "error", err)
}
}
// Load non-system accounts available to grant access to.
grantableAccounts, err := u.db.ListAccounts()
if err != nil {
u.logger.Warn("list accounts for pgcred grant", "error", err)
}
csrfToken, err := u.setCSRFCookies(w)
if err != nil {
csrfToken = ""
}
u.render(w, "pgcreds_form", AccountDetailData{
PageData: PageData{CSRFToken: csrfToken},
Account: acct,
PGCred: pgCred,
PGCredGrants: grants,
GrantableAccounts: grantableAccounts,
ActorID: actorID,
})
}
// handleGrantPGCredAccess grants another account read access to a pg_credentials
// set owned by the actor. Only the credential owner may grant access; this is
// enforced by comparing the stored owner_id with the logged-in actor.
//
// Security: ownership is re-verified server-side on every request; the form
// field grantee_uuid is looked up in the accounts table (no ID injection).
// Audit event EventPGCredAccessGranted is recorded on success.
func (u *UIServer) handleGrantPGCredAccess(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
}
if acct.AccountType != model.AccountTypeSystem {
u.renderError(w, r, http.StatusBadRequest, "postgres credentials are only available for system accounts")
return
}
pgCred, err := u.db.ReadPGCredentials(acct.ID)
if err != nil {
u.renderError(w, r, http.StatusNotFound, "no credentials stored for this account")
return
}
// Resolve the currently logged-in actor.
claims := claimsFromContext(r.Context())
var actorID *int64
if claims != nil {
if actor, err := u.db.GetAccountByUUID(claims.Subject); err == nil {
actorID = &actor.ID
}
}
// Security: only the credential owner may grant access.
if actorID == nil || pgCred.OwnerID == nil || *pgCred.OwnerID != *actorID {
u.renderError(w, r, http.StatusForbidden, "only the credential owner may grant access")
return
}
granteeUUID := strings.TrimSpace(r.FormValue("grantee_uuid"))
if granteeUUID == "" {
u.renderError(w, r, http.StatusBadRequest, "grantee is required")
return
}
grantee, err := u.db.GetAccountByUUID(granteeUUID)
if err != nil {
u.renderError(w, r, http.StatusNotFound, "grantee account not found")
return
}
if err := u.db.GrantPGCredAccess(pgCred.ID, grantee.ID, actorID); err != nil {
u.logger.Error("grant pg cred access", "error", err)
u.renderError(w, r, http.StatusInternalServerError, "failed to grant access")
return
}
u.writeAudit(r, model.EventPGCredAccessGranted, actorID, &grantee.ID,
fmt.Sprintf(`{"credential_id":%d,"grantee":%q}`, pgCred.ID, grantee.UUID))
// If the caller requested a redirect (e.g. from the /pgcreds page), honour it.
if next := r.FormValue("_next"); next == "/pgcreds" {
http.Redirect(w, r, "/pgcreds", http.StatusSeeOther)
return
}
// Re-render the full pgcreds section so the new grant appears.
grants, err := u.db.ListPGCredAccess(pgCred.ID)
if err != nil {
u.logger.Warn("list pg cred access after grant", "error", err)
}
grantableAccounts, err := u.db.ListAccounts()
if err != nil {
u.logger.Warn("list accounts for pgcred grant", "error", err)
}
csrfToken, err := u.setCSRFCookies(w)
if err != nil {
csrfToken = ""
}
u.render(w, "pgcreds_form", AccountDetailData{
PageData: PageData{CSRFToken: csrfToken},
Account: acct,
PGCred: pgCred,
PGCredGrants: grants,
GrantableAccounts: grantableAccounts,
ActorID: actorID,
})
}
// handleRevokePGCredAccess removes a grantee's read access to a pg_credentials set.
// Only the credential owner may revoke grants; this is enforced server-side.
//
// Security: ownership re-verified on every request. grantee_uuid looked up
// in accounts table — not taken from URL path to prevent enumeration.
// Audit event EventPGCredAccessRevoked is recorded on success.
func (u *UIServer) handleRevokePGCredAccess(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
}
if acct.AccountType != model.AccountTypeSystem {
u.renderError(w, r, http.StatusBadRequest, "postgres credentials are only available for system accounts")
return
}
pgCred, err := u.db.ReadPGCredentials(acct.ID)
if err != nil {
u.renderError(w, r, http.StatusNotFound, "no credentials stored for this account")
return
}
// Resolve the currently logged-in actor.
claims := claimsFromContext(r.Context())
var actorID *int64
if claims != nil {
if actor, err := u.db.GetAccountByUUID(claims.Subject); err == nil {
actorID = &actor.ID
}
}
// Security: only the credential owner may revoke access.
if actorID == nil || pgCred.OwnerID == nil || *pgCred.OwnerID != *actorID {
u.renderError(w, r, http.StatusForbidden, "only the credential owner may revoke access")
return
}
granteeUUID := r.PathValue("grantee")
if granteeUUID == "" {
u.renderError(w, r, http.StatusBadRequest, "grantee is required")
return
}
grantee, err := u.db.GetAccountByUUID(granteeUUID)
if err != nil {
u.renderError(w, r, http.StatusNotFound, "grantee account not found")
return
}
if err := u.db.RevokePGCredAccess(pgCred.ID, grantee.ID); err != nil {
u.logger.Error("revoke pg cred access", "error", err)
u.renderError(w, r, http.StatusInternalServerError, "failed to revoke access")
return
}
u.writeAudit(r, model.EventPGCredAccessRevoked, actorID, &grantee.ID,
fmt.Sprintf(`{"credential_id":%d,"grantee":%q}`, pgCred.ID, grantee.UUID))
// If the caller requested a redirect (e.g. from the /pgcreds page), honour it.
if r.URL.Query().Get("_next") == "/pgcreds" {
if isHTMX(r) {
w.Header().Set("HX-Redirect", "/pgcreds")
w.WriteHeader(http.StatusOK)
return
}
http.Redirect(w, r, "/pgcreds", http.StatusSeeOther)
return
}
// Re-render the full pgcreds section with the grant removed.
grants, err := u.db.ListPGCredAccess(pgCred.ID)
if err != nil {
u.logger.Warn("list pg cred access after revoke", "error", err)
}
grantableAccounts, err := u.db.ListAccounts()
if err != nil {
u.logger.Warn("list accounts for pgcred grant", "error", err)
}
csrfToken, err := u.setCSRFCookies(w)
if err != nil {
csrfToken = ""
}
u.render(w, "pgcreds_form", AccountDetailData{
PageData: PageData{CSRFToken: csrfToken},
Account: acct,
PGCred: pgCred,
PGCredGrants: grants,
GrantableAccounts: grantableAccounts,
ActorID: actorID,
})
}
// handlePGCredsList renders the "My PG Credentials" page, showing all
// pg_credentials accessible to the currently logged-in user (owned + granted),
// plus a create form for system accounts that have no credentials yet.
func (u *UIServer) handlePGCredsList(w http.ResponseWriter, r *http.Request) {
csrfToken, err := u.setCSRFCookies(w)
if err != nil {
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
claims := claimsFromContext(r.Context())
if claims == nil {
u.redirectToLogin(w, r)
return
}
actor, err := u.db.GetAccountByUUID(claims.Subject)
if err != nil {
u.renderError(w, r, http.StatusInternalServerError, "could not resolve actor")
return
}
creds, err := u.db.ListAccessiblePGCreds(actor.ID)
if err != nil {
u.renderError(w, r, http.StatusInternalServerError, "failed to load credentials")
return
}
// Build the list of system accounts that have no credentials at all
// (not just those absent from this actor's accessible set) so the
// create form remains available even when the actor has no existing creds.
credAcctIDs, err := u.db.ListCredentialedAccountIDs()
if err != nil {
u.logger.Warn("list credentialed account ids", "error", err)
credAcctIDs = map[int64]struct{}{}
}
allAccounts, err := u.db.ListAccounts()
if err != nil {
u.logger.Warn("list accounts for pgcreds create form", "error", err)
}
var uncredentialed []*model.Account
for _, a := range allAccounts {
if a.AccountType == model.AccountTypeSystem {
if _, hasCredential := credAcctIDs[a.ID]; !hasCredential {
uncredentialed = append(uncredentialed, a)
}
}
}
// For each credential owned by the actor, load its access grants so the
// /pgcreds page can render inline grant management controls.
credGrants := make(map[int64][]*model.PGCredAccessGrant)
for _, c := range creds {
if c.OwnerID != nil && *c.OwnerID == actor.ID {
grants, err := u.db.ListPGCredAccess(c.ID)
if err != nil {
u.logger.Warn("list pg cred access for owned cred", "cred_id", c.ID, "error", err)
continue
}
credGrants[c.ID] = grants
}
}
u.render(w, "pgcreds", PGCredsData{
PageData: PageData{CSRFToken: csrfToken, ActorName: u.actorName(r)},
Creds: creds,
UncredentialedAccounts: uncredentialed,
CredGrants: credGrants,
AllAccounts: allAccounts,
ActorID: &actor.ID,
})
}
// handleCreatePGCreds creates a new PG credential set from the /pgcreds page.
// The submitter selects a system account from the uncredentialed list and
// provides connection details; on success they become the credential owner.
//
// Security: only system accounts may hold PG credentials; the submitted account
// UUID is validated server-side. Password is encrypted with AES-256-GCM before
// storage; the plaintext is never logged or included in any response.
// Audit event EventPGCredUpdated is recorded on success.
func (u *UIServer) handleCreatePGCreds(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
}
accountUUID := strings.TrimSpace(r.FormValue("account_uuid"))
if accountUUID == "" {
u.renderError(w, r, http.StatusBadRequest, "account is required")
return
}
acct, err := u.db.GetAccountByUUID(accountUUID)
if err != nil {
u.renderError(w, r, http.StatusNotFound, "account not found")
return
}
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 never carries an
// 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 with AES-256-GCM; fresh nonce per 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 {
if actor, err := u.db.GetAccountByUUID(claims.Subject); err == nil {
actorID = &actor.ID
}
}
u.writeAudit(r, model.EventPGCredUpdated, actorID, &acct.ID, "")
// Security: set the credential owner to the actor on creation.
pgCred, err := u.db.ReadPGCredentials(acct.ID)
if err != nil {
u.logger.Warn("re-read pg credentials after create", "error", err)
}
if pgCred != nil && pgCred.OwnerID == nil && actorID != nil {
if err := u.db.SetPGCredentialOwner(pgCred.ID, *actorID); err != nil {
u.logger.Warn("set pg credential owner", "error", err)
} else {
pgCred.OwnerID = actorID
}
}
// Redirect to the pgcreds list so the new entry appears in context.
http.Redirect(w, r, "/pgcreds", http.StatusSeeOther)
}
// handleAdminResetPassword allows an admin to set a new password for any human
// account without requiring the current password. On success all active tokens
// for the target account are revoked so a compromised account is fully
// invalidated.
//
// Security: new password is validated (minimum 12 chars) and hashed with
// Argon2id before storage. The plaintext is never logged or included in any
// response. Audit event EventPasswordChanged is recorded on success.
func (u *UIServer) handleAdminResetPassword(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
}
if acct.AccountType != model.AccountTypeHuman {
u.renderError(w, r, http.StatusBadRequest, "password can only be reset for human accounts")
return
}
newPassword := r.FormValue("new_password")
confirmPassword := r.FormValue("confirm_password")
if newPassword == "" {
u.renderError(w, r, http.StatusBadRequest, "new password is required")
return
}
// Server-side equality check mirrors the client-side guard; defends against
// direct POST requests that bypass the JavaScript confirmation.
if newPassword != confirmPassword {
u.renderError(w, r, http.StatusBadRequest, "passwords do not match")
return
}
// Security (F-13): enforce minimum length before hashing.
if err := validate.Password(newPassword); err != nil {
u.renderError(w, r, http.StatusBadRequest, err.Error())
return
}
hash, err := auth.HashPassword(newPassword, auth.ArgonParams{
Time: u.cfg.Argon2.Time,
Memory: u.cfg.Argon2.Memory,
Threads: u.cfg.Argon2.Threads,
})
if err != nil {
u.logger.Error("hash password (admin reset)", "error", err)
u.renderError(w, r, http.StatusInternalServerError, "internal error")
return
}
if err := u.db.UpdatePasswordHash(acct.ID, hash); err != nil {
u.logger.Error("update password hash", "error", err)
u.renderError(w, r, http.StatusInternalServerError, "failed to update password")
return
}
// Security: revoke all active sessions for the target account so an
// attacker who held a valid token cannot continue to use it after reset.
// Render an error fragment rather than silently claiming success if
// revocation fails.
if err := u.db.RevokeAllUserTokens(acct.ID, "password_reset"); err != nil {
u.logger.Error("revoke tokens on admin password reset", "account_id", acct.ID, "error", err)
u.renderError(w, r, http.StatusInternalServerError, "password updated but session revocation failed; revoke tokens manually")
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.EventPasswordChanged, actorID, &acct.ID, `{"via":"admin_reset"}`)
// Return a success fragment so HTMX can display confirmation inline.
csrfToken, _ := u.setCSRFCookies(w)
u.render(w, "password_reset_result", AccountDetailData{
PageData: PageData{
CSRFToken: csrfToken,
Flash: "Password updated and all active sessions revoked.",
},
Account: acct,
})
}
// 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")
acct, err := u.db.GetAccountByUUID(id)
if err != nil {
u.renderError(w, r, http.StatusNotFound, "account not found")
return
}
if acct.AccountType != model.AccountTypeSystem {
u.renderError(w, r, http.StatusBadRequest, "only system accounts can have service tokens")
return
}
roles, err := u.db.GetRoles(acct.ID)
if err != nil {
u.renderError(w, r, http.StatusInternalServerError, "failed to load roles")
return
}
// Security: revoke the previous system token before issuing a new one (F-16).
// This matches the pattern in the REST handleTokenIssue and gRPC IssueServiceToken
// so that old tokens do not remain valid after rotation.
existing, err := u.db.GetSystemToken(acct.ID)
if err == nil {
_ = u.db.RevokeToken(existing.JTI, "rotated")
}
expiry := u.cfg.ServiceExpiry()
tokenStr, claims, err := u.issueToken(acct.UUID, roles, expiry)
if err != nil {
u.logger.Error("issue system token", "error", err)
u.renderError(w, r, http.StatusInternalServerError, "failed to issue token")
return
}
if err := u.db.TrackToken(claims.JTI, acct.ID, claims.IssuedAt, claims.ExpiresAt); err != nil {
u.logger.Error("track system token", "error", err)
u.renderError(w, r, http.StatusInternalServerError, "failed to track token")
return
}
// Store as system token for easy retrieval.
if err := u.db.SetSystemToken(acct.ID, claims.JTI, claims.ExpiresAt); err != nil {
u.logger.Warn("set system token record", "error", err)
}
actorClaims := claimsFromContext(r.Context())
var actorID *int64
if actorClaims != nil {
actor, err := u.db.GetAccountByUUID(actorClaims.Subject)
if err == nil {
actorID = &actor.ID
}
}
u.writeAudit(r, model.EventTokenIssued, actorID, &acct.ID,
fmt.Sprintf(`{"jti":%q,"via":"ui_system_token"}`, claims.JTI))
// Re-fetch token list including the new token.
tokens, err := u.db.ListTokensForAccount(acct.ID)
if err != nil {
u.logger.Warn("list tokens after issue", "error", err)
tokens = nil
}
csrfToken, err := u.setCSRFCookies(w)
if err != nil {
csrfToken = ""
}
// Flash the raw token once at the top so the operator can copy it.
u.render(w, "token_list", AccountDetailData{
PageData: PageData{
CSRFToken: csrfToken,
Flash: fmt.Sprintf("Token issued. Copy now — it will not be shown again: %s", tokenStr),
},
Account: acct,
Tokens: tokens,
})
}