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, }) }