UI: pgcreds create button; show logged-in user
* web/templates/pgcreds.html: New Credentials card is now always rendered; Add Credentials toggle button reveals the create form (hidden by default). Shows a message when all system accounts already have credentials. Previously the card was hidden when UncredentialedAccounts was empty. * internal/ui/ui.go: added ActorName string field to PageData; added actorName(r) helper resolving username from JWT claims via DB lookup, returns empty string if unauthenticated. * internal/ui/handlers_*.go: all full-page PageData constructors now pass ActorName: u.actorName(r). * web/templates/base.html: nav bar renders actor username as a muted label before the Logout button when logged in. * web/static/style.css: added .nav-actor rule (muted grey, 0.85rem).
This commit is contained in:
@@ -32,7 +32,7 @@ func (u *UIServer) handleAccountsList(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
u.render(w, "accounts", AccountsData{
|
||||
PageData: PageData{CSRFToken: csrfToken},
|
||||
PageData: PageData{CSRFToken: csrfToken, ActorName: u.actorName(r)},
|
||||
Accounts: accounts,
|
||||
})
|
||||
}
|
||||
@@ -132,15 +132,41 @@ func (u *UIServer) handleAccountDetail(w http.ResponseWriter, r *http.Request) {
|
||||
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)
|
||||
@@ -150,13 +176,16 @@ func (u *UIServer) handleAccountDetail(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
u.render(w, "account_detail", AccountDetailData{
|
||||
PageData: PageData{CSRFToken: csrfToken},
|
||||
Account: acct,
|
||||
Roles: roles,
|
||||
AllRoles: knownRoles,
|
||||
Tokens: tokens,
|
||||
PGCred: pgCred,
|
||||
Tags: tags,
|
||||
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,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -456,18 +485,417 @@ func (u *UIServer) handleSetPGCreds(w http.ResponseWriter, r *http.Request) {
|
||||
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,
|
||||
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)
|
||||
}
|
||||
|
||||
// handleIssueSystemToken issues a long-lived service token for a system account.
|
||||
func (u *UIServer) handleIssueSystemToken(w http.ResponseWriter, r *http.Request) {
|
||||
id := r.PathValue("id")
|
||||
|
||||
Reference in New Issue
Block a user