- Add requireAdminRole middleware to web UI that checks
claims.HasRole("admin") and returns 403 if absent
- Apply middleware to all admin routes (accounts, policies,
audit, dashboard, credentials)
- Remove redundant inline admin check from handleAdminResetPassword
- Profile routes correctly require only authentication, not admin
Security: The admin/adminGet middleware wrappers only called
requireCookieAuth (JWT validation) but never verified the admin
role. Any authenticated user could access admin endpoints
including role assignment. Fixed by inserting requireAdminRole
into the middleware chain for all admin routes.
1080 lines
34 KiB
Go
1080 lines
34 KiB
Go
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{
|
|
model.RoleAdmin,
|
|
model.RoleUser,
|
|
model.RoleGuest,
|
|
model.RoleViewer,
|
|
model.RoleEditor,
|
|
model.RoleCommenter,
|
|
}
|
|
|
|
// 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 <tr>; 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: caller must hold the admin role; the check is performed server-side
|
|
// against the JWT claims so it cannot be bypassed by client-side tricks.
|
|
// 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) {
|
|
// Security: admin role is enforced by the requireAdminRole middleware in
|
|
// the route registration (ui.go); no inline check needed here.
|
|
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,
|
|
})
|
|
}
|