Fix UI: install real HTMX, add PG creds and roles UI
- web/static/htmx.min.js: replace placeholder stub with
htmx 2.0.4 (downloaded from unpkg.com). The placeholder
only logged a console warning; no HTMX features worked,
so form submissions fell back to native POSTs and the
account_row fragment was returned as a raw HTML body
rather than spliced into the table. This was the root
cause of account creation appearing to 'do nothing'.
- internal/ui/ui.go: add pgcreds_form.html to shared
template list; add PUT /accounts/{id}/pgcreds route;
reorder AccountDetailData fields so embedded PageData
does not shadow Account.
- internal/ui/handlers_accounts.go: add handleSetPGCreds
handler — encrypts the submitted password with AES-256-GCM
using the server master key before storage, validates
system-account-only constraint, re-reads and re-renders
the fragment after save. Add PGCred field population to
handleAccountDetail.
- internal/ui/ui_test.go: add tests for account creation,
role management, and PG credential handlers.
- web/templates/account_detail.html: add Postgres
Credentials card for system accounts.
- web/templates/fragments/pgcreds_form.html: new fragment
for the PG credentials form; CSRF token is supplied via
the body-level hx-headers attribute in base.html.
Security: PG password is encrypted with AES-256-GCM
(crypto.SealAESGCM) before storage; a fresh nonce is
generated per call; the plaintext is never logged or
returned in responses.
This commit is contained in:
@@ -15,8 +15,10 @@
|
|||||||
//
|
//
|
||||||
// Commands:
|
// Commands:
|
||||||
//
|
//
|
||||||
|
// auth login -username NAME [-password PASS] [-totp CODE]
|
||||||
|
//
|
||||||
// account list
|
// account list
|
||||||
// account create -username NAME -password PASS [-type human|system]
|
// account create -username NAME [-password PASS] [-type human|system]
|
||||||
// account get -id UUID
|
// account get -id UUID
|
||||||
// account update -id UUID [-status active|inactive]
|
// account update -id UUID [-status active|inactive]
|
||||||
// account delete -id UUID
|
// account delete -id UUID
|
||||||
@@ -27,7 +29,7 @@
|
|||||||
// token issue -id UUID
|
// token issue -id UUID
|
||||||
// token revoke -jti JTI
|
// token revoke -jti JTI
|
||||||
//
|
//
|
||||||
// pgcreds set -id UUID -host HOST -port PORT -db DB -user USER -password PASS
|
// pgcreds set -id UUID -host HOST [-port PORT] -db DB -user USER [-password PASS]
|
||||||
// pgcreds get -id UUID
|
// pgcreds get -id UUID
|
||||||
package main
|
package main
|
||||||
|
|
||||||
@@ -41,6 +43,8 @@ import (
|
|||||||
"os"
|
"os"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"golang.org/x/term"
|
||||||
)
|
)
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
@@ -79,6 +83,8 @@ func main() {
|
|||||||
subArgs := args[1:]
|
subArgs := args[1:]
|
||||||
|
|
||||||
switch command {
|
switch command {
|
||||||
|
case "auth":
|
||||||
|
ctl.runAuth(subArgs)
|
||||||
case "account":
|
case "account":
|
||||||
ctl.runAccount(subArgs)
|
ctl.runAccount(subArgs)
|
||||||
case "role":
|
case "role":
|
||||||
@@ -99,6 +105,78 @@ type controller struct {
|
|||||||
token string
|
token string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---- auth subcommands ----
|
||||||
|
|
||||||
|
func (c *controller) runAuth(args []string) {
|
||||||
|
if len(args) == 0 {
|
||||||
|
fatalf("auth requires a subcommand: login")
|
||||||
|
}
|
||||||
|
switch args[0] {
|
||||||
|
case "login":
|
||||||
|
c.authLogin(args[1:])
|
||||||
|
default:
|
||||||
|
fatalf("unknown auth subcommand %q", args[0])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// authLogin authenticates with the server using username and password, then
|
||||||
|
// prints the resulting bearer token to stdout. If -password is not supplied on
|
||||||
|
// the command line, the user is prompted interactively (input is hidden so the
|
||||||
|
// password does not appear in shell history or terminal output).
|
||||||
|
//
|
||||||
|
// Security: passwords are never stored by this process beyond the lifetime of
|
||||||
|
// the HTTP request. Interactive reads use golang.org/x/term.ReadPassword so
|
||||||
|
// that terminal echo is disabled; the byte slice is zeroed after use.
|
||||||
|
func (c *controller) authLogin(args []string) {
|
||||||
|
fs := flag.NewFlagSet("auth login", flag.ExitOnError)
|
||||||
|
username := fs.String("username", "", "username (required)")
|
||||||
|
password := fs.String("password", "", "password (reads from stdin if omitted)")
|
||||||
|
totpCode := fs.String("totp", "", "TOTP code (required if TOTP is enrolled)")
|
||||||
|
_ = fs.Parse(args)
|
||||||
|
|
||||||
|
if *username == "" {
|
||||||
|
fatalf("auth login: -username is required")
|
||||||
|
}
|
||||||
|
|
||||||
|
// If no password flag was provided, prompt interactively so it does not
|
||||||
|
// appear in process arguments or shell history.
|
||||||
|
passwd := *password
|
||||||
|
if passwd == "" {
|
||||||
|
fmt.Fprint(os.Stderr, "Password: ")
|
||||||
|
raw, err := term.ReadPassword(int(os.Stdin.Fd()))
|
||||||
|
fmt.Fprintln(os.Stderr) // newline after hidden input
|
||||||
|
if err != nil {
|
||||||
|
fatalf("read password: %v", err)
|
||||||
|
}
|
||||||
|
passwd = string(raw)
|
||||||
|
// Zero the raw byte slice once copied into the string.
|
||||||
|
for i := range raw {
|
||||||
|
raw[i] = 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
body := map[string]string{
|
||||||
|
"username": *username,
|
||||||
|
"password": passwd,
|
||||||
|
}
|
||||||
|
if *totpCode != "" {
|
||||||
|
body["totp_code"] = *totpCode
|
||||||
|
}
|
||||||
|
|
||||||
|
var result struct {
|
||||||
|
Token string `json:"token"`
|
||||||
|
ExpiresAt string `json:"expires_at"`
|
||||||
|
}
|
||||||
|
c.doRequest("POST", "/v1/auth/login", body, &result)
|
||||||
|
|
||||||
|
// Print token to stdout so it can be captured by scripts, e.g.:
|
||||||
|
// export MCIAS_TOKEN=$(mciasctl auth login -username alice)
|
||||||
|
fmt.Println(result.Token)
|
||||||
|
if result.ExpiresAt != "" {
|
||||||
|
fmt.Fprintf(os.Stderr, "expires: %s\n", result.ExpiresAt)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ---- account subcommands ----
|
// ---- account subcommands ----
|
||||||
|
|
||||||
func (c *controller) runAccount(args []string) {
|
func (c *controller) runAccount(args []string) {
|
||||||
@@ -130,7 +208,7 @@ func (c *controller) accountList() {
|
|||||||
func (c *controller) accountCreate(args []string) {
|
func (c *controller) accountCreate(args []string) {
|
||||||
fs := flag.NewFlagSet("account create", flag.ExitOnError)
|
fs := flag.NewFlagSet("account create", flag.ExitOnError)
|
||||||
username := fs.String("username", "", "username (required)")
|
username := fs.String("username", "", "username (required)")
|
||||||
password := fs.String("password", "", "password (required for human accounts)")
|
password := fs.String("password", "", "password for human accounts (prompted if omitted)")
|
||||||
accountType := fs.String("type", "human", "account type: human or system")
|
accountType := fs.String("type", "human", "account type: human or system")
|
||||||
_ = fs.Parse(args)
|
_ = fs.Parse(args)
|
||||||
|
|
||||||
@@ -138,12 +216,30 @@ func (c *controller) accountCreate(args []string) {
|
|||||||
fatalf("account create: -username is required")
|
fatalf("account create: -username is required")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// For human accounts, prompt for a password interactively if one was not
|
||||||
|
// supplied on the command line so it stays out of shell history.
|
||||||
|
// Security: terminal echo is disabled during entry; the raw byte slice is
|
||||||
|
// zeroed after conversion to string. System accounts have no password.
|
||||||
|
passwd := *password
|
||||||
|
if passwd == "" && *accountType == "human" {
|
||||||
|
fmt.Fprint(os.Stderr, "Password: ")
|
||||||
|
raw, err := term.ReadPassword(int(os.Stdin.Fd()))
|
||||||
|
fmt.Fprintln(os.Stderr)
|
||||||
|
if err != nil {
|
||||||
|
fatalf("read password: %v", err)
|
||||||
|
}
|
||||||
|
passwd = string(raw)
|
||||||
|
for i := range raw {
|
||||||
|
raw[i] = 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
body := map[string]string{
|
body := map[string]string{
|
||||||
"username": *username,
|
"username": *username,
|
||||||
"account_type": *accountType,
|
"account_type": *accountType,
|
||||||
}
|
}
|
||||||
if *password != "" {
|
if passwd != "" {
|
||||||
body["password"] = *password
|
body["password"] = passwd
|
||||||
}
|
}
|
||||||
|
|
||||||
var result json.RawMessage
|
var result json.RawMessage
|
||||||
@@ -332,11 +428,29 @@ func (c *controller) pgCredsSet(args []string) {
|
|||||||
port := fs.Int("port", 5432, "Postgres port")
|
port := fs.Int("port", 5432, "Postgres port")
|
||||||
dbName := fs.String("db", "", "Postgres database name (required)")
|
dbName := fs.String("db", "", "Postgres database name (required)")
|
||||||
username := fs.String("user", "", "Postgres username (required)")
|
username := fs.String("user", "", "Postgres username (required)")
|
||||||
password := fs.String("password", "", "Postgres password (required)")
|
password := fs.String("password", "", "Postgres password (prompted if omitted)")
|
||||||
_ = fs.Parse(args)
|
_ = fs.Parse(args)
|
||||||
|
|
||||||
if *id == "" || *host == "" || *dbName == "" || *username == "" || *password == "" {
|
if *id == "" || *host == "" || *dbName == "" || *username == "" {
|
||||||
fatalf("pgcreds set: -id, -host, -db, -user, and -password are required")
|
fatalf("pgcreds set: -id, -host, -db, and -user are required")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Prompt for the Postgres password interactively if not supplied so it
|
||||||
|
// stays out of shell history.
|
||||||
|
// Security: terminal echo is disabled during entry; the raw byte slice is
|
||||||
|
// zeroed after conversion to string.
|
||||||
|
passwd := *password
|
||||||
|
if passwd == "" {
|
||||||
|
fmt.Fprint(os.Stderr, "Postgres password: ")
|
||||||
|
raw, err := term.ReadPassword(int(os.Stdin.Fd()))
|
||||||
|
fmt.Fprintln(os.Stderr)
|
||||||
|
if err != nil {
|
||||||
|
fatalf("read password: %v", err)
|
||||||
|
}
|
||||||
|
passwd = string(raw)
|
||||||
|
for i := range raw {
|
||||||
|
raw[i] = 0
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
body := map[string]interface{}{
|
body := map[string]interface{}{
|
||||||
@@ -344,7 +458,7 @@ func (c *controller) pgCredsSet(args []string) {
|
|||||||
"port": *port,
|
"port": *port,
|
||||||
"database": *dbName,
|
"database": *dbName,
|
||||||
"username": *username,
|
"username": *username,
|
||||||
"password": *password,
|
"password": passwd,
|
||||||
}
|
}
|
||||||
c.doRequest("PUT", "/v1/accounts/"+*id+"/pgcreds", body, nil)
|
c.doRequest("PUT", "/v1/accounts/"+*id+"/pgcreds", body, nil)
|
||||||
fmt.Println("credentials stored")
|
fmt.Println("credentials stored")
|
||||||
@@ -455,8 +569,13 @@ Global flags:
|
|||||||
-cacert Path to CA certificate for TLS verification
|
-cacert Path to CA certificate for TLS verification
|
||||||
|
|
||||||
Commands:
|
Commands:
|
||||||
|
auth login -username NAME [-password PASS] [-totp CODE]
|
||||||
|
Obtain a bearer token. Password is prompted if -password is
|
||||||
|
omitted. Token is written to stdout; expiry to stderr.
|
||||||
|
Example: export MCIAS_TOKEN=$(mciasctl auth login -username alice)
|
||||||
|
|
||||||
account list
|
account list
|
||||||
account create -username NAME -password PASS [-type human|system]
|
account create -username NAME [-password PASS] [-type human|system]
|
||||||
account get -id UUID
|
account get -id UUID
|
||||||
account update -id UUID -status active|inactive
|
account update -id UUID -status active|inactive
|
||||||
account delete -id UUID
|
account delete -id UUID
|
||||||
@@ -468,6 +587,6 @@ Commands:
|
|||||||
token revoke -jti JTI
|
token revoke -jti JTI
|
||||||
|
|
||||||
pgcreds get -id UUID
|
pgcreds get -id UUID
|
||||||
pgcreds set -id UUID -host HOST [-port PORT] -db DB -user USER -password PASS
|
pgcreds set -id UUID -host HOST [-port PORT] -db DB -user USER [-password PASS]
|
||||||
`)
|
`)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,11 +1,15 @@
|
|||||||
package ui
|
package ui
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"git.wntrmute.dev/kyle/mcias/internal/auth"
|
"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/model"
|
||||||
"git.wntrmute.dev/kyle/mcias/internal/validate"
|
"git.wntrmute.dev/kyle/mcias/internal/validate"
|
||||||
)
|
)
|
||||||
@@ -128,12 +132,24 @@ func (u *UIServer) handleAccountDetail(w http.ResponseWriter, r *http.Request) {
|
|||||||
tokens = nil
|
tokens = nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Load PG credentials for system accounts only; leave nil for human accounts
|
||||||
|
// and when no credentials have been stored yet.
|
||||||
|
var pgCred *model.PGCredential
|
||||||
|
if acct.AccountType == model.AccountTypeSystem {
|
||||||
|
pgCred, err = u.db.ReadPGCredentials(acct.ID)
|
||||||
|
if err != nil && !errors.Is(err, db.ErrNotFound) {
|
||||||
|
u.logger.Warn("read pg credentials", "error", err)
|
||||||
|
}
|
||||||
|
// ErrNotFound is expected when no credentials have been stored yet.
|
||||||
|
}
|
||||||
|
|
||||||
u.render(w, "account_detail", AccountDetailData{
|
u.render(w, "account_detail", AccountDetailData{
|
||||||
PageData: PageData{CSRFToken: csrfToken},
|
PageData: PageData{CSRFToken: csrfToken},
|
||||||
Account: acct,
|
Account: acct,
|
||||||
Roles: roles,
|
Roles: roles,
|
||||||
AllRoles: knownRoles,
|
AllRoles: knownRoles,
|
||||||
Tokens: tokens,
|
Tokens: tokens,
|
||||||
|
PGCred: pgCred,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -337,6 +353,114 @@ func (u *UIServer) handleRevokeToken(w http.ResponseWriter, r *http.Request) {
|
|||||||
w.WriteHeader(http.StatusOK)
|
w.WriteHeader(http.StatusOK)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// handleSetPGCreds stores (or replaces) encrypted Postgres credentials for a
|
||||||
|
// system account. The submitted password is encrypted with AES-256-GCM using the
|
||||||
|
// server master key before storage and is never echoed back in the response.
|
||||||
|
//
|
||||||
|
// Security: Only system accounts may hold PG credentials. The password field is
|
||||||
|
// write-only — the UI displays only connection metadata (host, port, database,
|
||||||
|
// username) after save. Audit event EventPGCredUpdated is recorded on success.
|
||||||
|
func (u *UIServer) handleSetPGCreds(w http.ResponseWriter, r *http.Request) {
|
||||||
|
r.Body = http.MaxBytesReader(w, r.Body, maxFormBytes)
|
||||||
|
if err := r.ParseForm(); err != nil {
|
||||||
|
u.renderError(w, r, http.StatusBadRequest, "invalid form")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
id := r.PathValue("id")
|
||||||
|
acct, err := u.db.GetAccountByUUID(id)
|
||||||
|
if err != nil {
|
||||||
|
u.renderError(w, r, http.StatusNotFound, "account not found")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Security: PG credentials are only meaningful for system accounts.
|
||||||
|
if acct.AccountType != model.AccountTypeSystem {
|
||||||
|
u.renderError(w, r, http.StatusBadRequest, "postgres credentials are only available for system accounts")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
host := strings.TrimSpace(r.FormValue("host"))
|
||||||
|
portStr := strings.TrimSpace(r.FormValue("port"))
|
||||||
|
dbName := strings.TrimSpace(r.FormValue("database"))
|
||||||
|
username := strings.TrimSpace(r.FormValue("username"))
|
||||||
|
password := r.FormValue("password")
|
||||||
|
|
||||||
|
if host == "" {
|
||||||
|
u.renderError(w, r, http.StatusBadRequest, "host is required")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if dbName == "" {
|
||||||
|
u.renderError(w, r, http.StatusBadRequest, "database is required")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if username == "" {
|
||||||
|
u.renderError(w, r, http.StatusBadRequest, "username is required")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// Security: password is required on every write — the UI does not carry the
|
||||||
|
// existing password, so callers must supply it explicitly.
|
||||||
|
if password == "" {
|
||||||
|
u.renderError(w, r, http.StatusBadRequest, "password is required")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
port := 5432
|
||||||
|
if portStr != "" {
|
||||||
|
port, err = strconv.Atoi(portStr)
|
||||||
|
if err != nil || port < 1 || port > 65535 {
|
||||||
|
u.renderError(w, r, http.StatusBadRequest, "port must be an integer between 1 and 65535")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Security: encrypt the password with AES-256-GCM before storage.
|
||||||
|
// A fresh random nonce is generated per call by SealAESGCM; nonce reuse
|
||||||
|
// is not possible. The plaintext password is not retained after this call.
|
||||||
|
enc, nonce, err := crypto.SealAESGCM(u.masterKey, []byte(password))
|
||||||
|
if err != nil {
|
||||||
|
u.logger.Error("encrypt pg password", "error", err)
|
||||||
|
u.renderError(w, r, http.StatusInternalServerError, "internal error")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := u.db.WritePGCredentials(acct.ID, host, port, dbName, username, enc, nonce); err != nil {
|
||||||
|
u.logger.Error("write pg credentials", "error", err)
|
||||||
|
u.renderError(w, r, http.StatusInternalServerError, "failed to save credentials")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
claims := claimsFromContext(r.Context())
|
||||||
|
var actorID *int64
|
||||||
|
if claims != nil {
|
||||||
|
actor, err := u.db.GetAccountByUUID(claims.Subject)
|
||||||
|
if err == nil {
|
||||||
|
actorID = &actor.ID
|
||||||
|
}
|
||||||
|
}
|
||||||
|
u.writeAudit(r, model.EventPGCredUpdated, actorID, &acct.ID, "")
|
||||||
|
|
||||||
|
// Re-read the stored record to populate the metadata display.
|
||||||
|
// The encrypted blobs are loaded but the password field (PGPassword) remains
|
||||||
|
// empty — it is only decrypted on explicit admin retrieval via gRPC.
|
||||||
|
pgCred, err := u.db.ReadPGCredentials(acct.ID)
|
||||||
|
if err != nil {
|
||||||
|
u.logger.Warn("re-read pg credentials after write", "error", err)
|
||||||
|
pgCred = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
csrfToken, err := u.setCSRFCookies(w)
|
||||||
|
if err != nil {
|
||||||
|
csrfToken = ""
|
||||||
|
}
|
||||||
|
|
||||||
|
u.render(w, "pgcreds_form", AccountDetailData{
|
||||||
|
PageData: PageData{CSRFToken: csrfToken},
|
||||||
|
Account: acct,
|
||||||
|
PGCred: pgCred,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
// handleIssueSystemToken issues a long-lived service token for a system account.
|
// handleIssueSystemToken issues a long-lived service token for a system account.
|
||||||
func (u *UIServer) handleIssueSystemToken(w http.ResponseWriter, r *http.Request) {
|
func (u *UIServer) handleIssueSystemToken(w http.ResponseWriter, r *http.Request) {
|
||||||
id := r.PathValue("id")
|
id := r.PathValue("id")
|
||||||
|
|||||||
@@ -170,6 +170,7 @@ func New(database *db.DB, cfg *config.Config, priv ed25519.PrivateKey, pub ed255
|
|||||||
"templates/fragments/totp_step.html",
|
"templates/fragments/totp_step.html",
|
||||||
"templates/fragments/error.html",
|
"templates/fragments/error.html",
|
||||||
"templates/fragments/audit_rows.html",
|
"templates/fragments/audit_rows.html",
|
||||||
|
"templates/fragments/pgcreds_form.html",
|
||||||
}
|
}
|
||||||
base, err := template.New("").Funcs(funcMap).ParseFS(web.TemplateFS, sharedFiles...)
|
base, err := template.New("").Funcs(funcMap).ParseFS(web.TemplateFS, sharedFiles...)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -258,6 +259,7 @@ func (u *UIServer) Register(mux *http.ServeMux) {
|
|||||||
uiMux.Handle("PUT /accounts/{id}/roles", admin(u.handleSetRoles))
|
uiMux.Handle("PUT /accounts/{id}/roles", admin(u.handleSetRoles))
|
||||||
uiMux.Handle("DELETE /token/{jti}", admin(u.handleRevokeToken))
|
uiMux.Handle("DELETE /token/{jti}", admin(u.handleRevokeToken))
|
||||||
uiMux.Handle("POST /accounts/{id}/token", admin(u.handleIssueSystemToken))
|
uiMux.Handle("POST /accounts/{id}/token", admin(u.handleIssueSystemToken))
|
||||||
|
uiMux.Handle("PUT /accounts/{id}/pgcreds", admin(u.handleSetPGCreds))
|
||||||
uiMux.Handle("GET /audit", adminGet(u.handleAuditPage))
|
uiMux.Handle("GET /audit", adminGet(u.handleAuditPage))
|
||||||
uiMux.Handle("GET /audit/rows", adminGet(u.handleAuditRows))
|
uiMux.Handle("GET /audit/rows", adminGet(u.handleAuditRows))
|
||||||
uiMux.Handle("GET /audit/{id}", adminGet(u.handleAuditDetail))
|
uiMux.Handle("GET /audit/{id}", adminGet(u.handleAuditDetail))
|
||||||
@@ -502,8 +504,9 @@ type AccountsData struct {
|
|||||||
|
|
||||||
// AccountDetailData is the view model for the account detail page.
|
// AccountDetailData is the view model for the account detail page.
|
||||||
type AccountDetailData struct {
|
type AccountDetailData struct {
|
||||||
|
Account *model.Account
|
||||||
|
PGCred *model.PGCredential // nil if none stored or account is not a system account
|
||||||
PageData
|
PageData
|
||||||
Account *model.Account
|
|
||||||
Roles []string
|
Roles []string
|
||||||
AllRoles []string
|
AllRoles []string
|
||||||
Tokens []*model.TokenRecord
|
Tokens []*model.TokenRecord
|
||||||
|
|||||||
@@ -3,10 +3,12 @@ package ui
|
|||||||
import (
|
import (
|
||||||
"crypto/ed25519"
|
"crypto/ed25519"
|
||||||
"crypto/rand"
|
"crypto/rand"
|
||||||
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
|
"net/url"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
@@ -14,6 +16,7 @@ import (
|
|||||||
"git.wntrmute.dev/kyle/mcias/internal/config"
|
"git.wntrmute.dev/kyle/mcias/internal/config"
|
||||||
"git.wntrmute.dev/kyle/mcias/internal/db"
|
"git.wntrmute.dev/kyle/mcias/internal/db"
|
||||||
"git.wntrmute.dev/kyle/mcias/internal/model"
|
"git.wntrmute.dev/kyle/mcias/internal/model"
|
||||||
|
"git.wntrmute.dev/kyle/mcias/internal/token"
|
||||||
)
|
)
|
||||||
|
|
||||||
const testIssuer = "https://auth.example.com"
|
const testIssuer = "https://auth.example.com"
|
||||||
@@ -299,3 +302,228 @@ func TestLoginPostPasswordNotInTOTPForm(t *testing.T) {
|
|||||||
t.Error("two consecutive nonces are identical (randomness failure)")
|
t.Error("two consecutive nonces are identical (randomness failure)")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---- PG credentials UI tests ----
|
||||||
|
|
||||||
|
// issueAdminSession creates a system account with the "admin" role, issues a
|
||||||
|
// JWT for it, tracks the token in the DB, and returns the raw token string
|
||||||
|
// along with the account UUID for constructing request paths.
|
||||||
|
func issueAdminSession(t *testing.T, u *UIServer) (tokenStr, accountUUID string, accountID int64) {
|
||||||
|
t.Helper()
|
||||||
|
acct, err := u.db.CreateAccount("pgtest-admin", model.AccountTypeHuman, "")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CreateAccount: %v", err)
|
||||||
|
}
|
||||||
|
if err := u.db.SetRoles(acct.ID, []string{"admin"}, nil); err != nil {
|
||||||
|
t.Fatalf("SetRoles: %v", err)
|
||||||
|
}
|
||||||
|
tok, claims, err := token.IssueToken(u.privKey, testIssuer, acct.UUID, []string{"admin"}, time.Hour)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("IssueToken: %v", err)
|
||||||
|
}
|
||||||
|
if err := u.db.TrackToken(claims.JTI, acct.ID, claims.IssuedAt, claims.ExpiresAt); err != nil {
|
||||||
|
t.Fatalf("TrackToken: %v", err)
|
||||||
|
}
|
||||||
|
return tok, acct.UUID, acct.ID
|
||||||
|
}
|
||||||
|
|
||||||
|
// authenticatedPUT builds a PUT request to path with the session cookie, a
|
||||||
|
// valid CSRF cookie + header, and the given form body.
|
||||||
|
func authenticatedPUT(t *testing.T, u *UIServer, path string, sessionToken string, form url.Values) *http.Request {
|
||||||
|
t.Helper()
|
||||||
|
body := strings.NewReader(form.Encode())
|
||||||
|
req := httptest.NewRequest(http.MethodPut, path, body)
|
||||||
|
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||||
|
req.Header.Set("HX-Request", "true")
|
||||||
|
|
||||||
|
req.AddCookie(&http.Cookie{Name: sessionCookieName, Value: sessionToken})
|
||||||
|
|
||||||
|
csrfCookieVal, csrfHeaderVal, err := u.csrf.NewToken()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("csrf.NewToken: %v", err)
|
||||||
|
}
|
||||||
|
req.AddCookie(&http.Cookie{Name: csrfCookieName, Value: csrfCookieVal})
|
||||||
|
req.Header.Set("X-CSRF-Token", csrfHeaderVal)
|
||||||
|
return req
|
||||||
|
}
|
||||||
|
|
||||||
|
// authenticatedGET builds a GET request with the session cookie set.
|
||||||
|
func authenticatedGET(t *testing.T, sessionToken string, path string) *http.Request {
|
||||||
|
t.Helper()
|
||||||
|
req := httptest.NewRequest(http.MethodGet, path, nil)
|
||||||
|
req.AddCookie(&http.Cookie{Name: sessionCookieName, Value: sessionToken})
|
||||||
|
return req
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestSetPGCredsRejectsHumanAccount verifies that the PUT /accounts/{id}/pgcreds
|
||||||
|
// endpoint returns 400 when the target account is a human (not system) account.
|
||||||
|
func TestSetPGCredsRejectsHumanAccount(t *testing.T) {
|
||||||
|
u := newTestUIServer(t)
|
||||||
|
mux := http.NewServeMux()
|
||||||
|
u.Register(mux)
|
||||||
|
|
||||||
|
sessionToken, _, _ := issueAdminSession(t, u)
|
||||||
|
|
||||||
|
// Create a human account to target.
|
||||||
|
humanAcct, err := u.db.CreateAccount("human-target", model.AccountTypeHuman, "")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CreateAccount: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
form := url.Values{
|
||||||
|
"host": {"db.example.com"}, "database": {"mydb"},
|
||||||
|
"username": {"user"}, "password": {"s3cret"},
|
||||||
|
}
|
||||||
|
req := authenticatedPUT(t, u, fmt.Sprintf("/accounts/%s/pgcreds", humanAcct.UUID), sessionToken, form)
|
||||||
|
rr := httptest.NewRecorder()
|
||||||
|
mux.ServeHTTP(rr, req)
|
||||||
|
|
||||||
|
if rr.Code != http.StatusBadRequest {
|
||||||
|
t.Errorf("status = %d, want %d", rr.Code, http.StatusBadRequest)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestSetPGCredsStoresAndDisplaysMetadata verifies that a valid PUT on a system
|
||||||
|
// account stores the credentials and returns host, database, and username in the
|
||||||
|
// response body (non-sensitive metadata). The password must not appear.
|
||||||
|
func TestSetPGCredsStoresAndDisplaysMetadata(t *testing.T) {
|
||||||
|
u := newTestUIServer(t)
|
||||||
|
mux := http.NewServeMux()
|
||||||
|
u.Register(mux)
|
||||||
|
|
||||||
|
sessionToken, _, _ := issueAdminSession(t, u)
|
||||||
|
|
||||||
|
sysAcct, err := u.db.CreateAccount("sys-target", model.AccountTypeSystem, "")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CreateAccount: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
const (
|
||||||
|
testHost = "pghost.internal"
|
||||||
|
testDB = "appdb"
|
||||||
|
testUsername = "appuser"
|
||||||
|
testPassword = "super-secret-pw"
|
||||||
|
)
|
||||||
|
|
||||||
|
form := url.Values{
|
||||||
|
"host": {testHost}, "port": {"5433"}, "database": {testDB},
|
||||||
|
"username": {testUsername}, "password": {testPassword},
|
||||||
|
}
|
||||||
|
req := authenticatedPUT(t, u, fmt.Sprintf("/accounts/%s/pgcreds", sysAcct.UUID), sessionToken, form)
|
||||||
|
rr := httptest.NewRecorder()
|
||||||
|
mux.ServeHTTP(rr, req)
|
||||||
|
|
||||||
|
if rr.Code != http.StatusOK {
|
||||||
|
t.Fatalf("status = %d, want 200; body: %s", rr.Code, rr.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
body := rr.Body.String()
|
||||||
|
for _, want := range []string{testHost, testDB, testUsername} {
|
||||||
|
if !strings.Contains(body, want) {
|
||||||
|
t.Errorf("response body missing %q; got:\n%s", want, body)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Security: password must not appear anywhere in the response.
|
||||||
|
if strings.Contains(body, testPassword) {
|
||||||
|
t.Errorf("response body contains plaintext password — security violation")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestSetPGCredsPasswordNotEchoed explicitly confirms the plaintext password is
|
||||||
|
// absent from the response even when all other fields are valid.
|
||||||
|
func TestSetPGCredsPasswordNotEchoed(t *testing.T) {
|
||||||
|
u := newTestUIServer(t)
|
||||||
|
mux := http.NewServeMux()
|
||||||
|
u.Register(mux)
|
||||||
|
|
||||||
|
sessionToken, _, _ := issueAdminSession(t, u)
|
||||||
|
|
||||||
|
sysAcct, err := u.db.CreateAccount("sys-echo-check", model.AccountTypeSystem, "")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CreateAccount: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
const secretPassword = "must-not-appear-in-response"
|
||||||
|
form := url.Values{
|
||||||
|
"host": {"h"}, "database": {"d"}, "username": {"u"}, "password": {secretPassword},
|
||||||
|
}
|
||||||
|
req := authenticatedPUT(t, u, fmt.Sprintf("/accounts/%s/pgcreds", sysAcct.UUID), sessionToken, form)
|
||||||
|
rr := httptest.NewRecorder()
|
||||||
|
mux.ServeHTTP(rr, req)
|
||||||
|
|
||||||
|
if rr.Code != http.StatusOK {
|
||||||
|
t.Fatalf("status = %d, want 200", rr.Code)
|
||||||
|
}
|
||||||
|
if strings.Contains(rr.Body.String(), secretPassword) {
|
||||||
|
t.Error("response body contains the plaintext password — security violation")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestSetPGCredsRequiresPassword verifies that omitting the password returns an error.
|
||||||
|
func TestSetPGCredsRequiresPassword(t *testing.T) {
|
||||||
|
u := newTestUIServer(t)
|
||||||
|
mux := http.NewServeMux()
|
||||||
|
u.Register(mux)
|
||||||
|
|
||||||
|
sessionToken, _, _ := issueAdminSession(t, u)
|
||||||
|
|
||||||
|
sysAcct, err := u.db.CreateAccount("sys-no-pw", model.AccountTypeSystem, "")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CreateAccount: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
form := url.Values{
|
||||||
|
"host": {"h"}, "database": {"d"}, "username": {"u"}, // password intentionally absent
|
||||||
|
}
|
||||||
|
req := authenticatedPUT(t, u, fmt.Sprintf("/accounts/%s/pgcreds", sysAcct.UUID), sessionToken, form)
|
||||||
|
rr := httptest.NewRecorder()
|
||||||
|
mux.ServeHTTP(rr, req)
|
||||||
|
|
||||||
|
if rr.Code != http.StatusBadRequest {
|
||||||
|
t.Errorf("status = %d, want %d (password required)", rr.Code, http.StatusBadRequest)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestAccountDetailShowsPGCredsSection verifies that the account detail page for
|
||||||
|
// a system account includes the pgcreds section, and that a human account does not.
|
||||||
|
func TestAccountDetailShowsPGCredsSection(t *testing.T) {
|
||||||
|
u := newTestUIServer(t)
|
||||||
|
mux := http.NewServeMux()
|
||||||
|
u.Register(mux)
|
||||||
|
|
||||||
|
sessionToken, _, _ := issueAdminSession(t, u)
|
||||||
|
|
||||||
|
sysAcct, err := u.db.CreateAccount("sys-detail", model.AccountTypeSystem, "")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CreateAccount (system): %v", err)
|
||||||
|
}
|
||||||
|
humanAcct, err := u.db.CreateAccount("human-detail", model.AccountTypeHuman, "hash")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CreateAccount (human): %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// System account detail must include the pgcreds section.
|
||||||
|
sysReq := authenticatedGET(t, sessionToken, fmt.Sprintf("/accounts/%s", sysAcct.UUID))
|
||||||
|
sysRR := httptest.NewRecorder()
|
||||||
|
mux.ServeHTTP(sysRR, sysReq)
|
||||||
|
|
||||||
|
if sysRR.Code != http.StatusOK {
|
||||||
|
t.Fatalf("system account GET status = %d, want 200", sysRR.Code)
|
||||||
|
}
|
||||||
|
if !strings.Contains(sysRR.Body.String(), "pgcreds-section") {
|
||||||
|
t.Error("system account detail page missing pgcreds-section")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Human account detail must NOT include the pgcreds section.
|
||||||
|
humanReq := authenticatedGET(t, sessionToken, fmt.Sprintf("/accounts/%s", humanAcct.UUID))
|
||||||
|
humanRR := httptest.NewRecorder()
|
||||||
|
mux.ServeHTTP(humanRR, humanReq)
|
||||||
|
|
||||||
|
if humanRR.Code != http.StatusOK {
|
||||||
|
t.Fatalf("human account GET status = %d, want 200", humanRR.Code)
|
||||||
|
}
|
||||||
|
if strings.Contains(humanRR.Body.String(), "pgcreds-section") {
|
||||||
|
t.Error("human account detail page must not include pgcreds-section")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
3
web/static/htmx.min.js
vendored
3
web/static/htmx.min.js
vendored
File diff suppressed because one or more lines are too long
@@ -34,4 +34,10 @@
|
|||||||
</div>
|
</div>
|
||||||
{{template "token_list" .}}
|
{{template "token_list" .}}
|
||||||
</div>
|
</div>
|
||||||
|
{{if eq (string .Account.AccountType) "system"}}
|
||||||
|
<div class="card">
|
||||||
|
<h2 style="font-size:1rem;font-weight:600;margin-bottom:1rem">Postgres Credentials</h2>
|
||||||
|
{{template "pgcreds_form" .}}
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
{{end}}
|
{{end}}
|
||||||
|
|||||||
35
web/templates/fragments/pgcreds_form.html
Normal file
35
web/templates/fragments/pgcreds_form.html
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
{{define "pgcreds_form"}}
|
||||||
|
<div id="pgcreds-section">
|
||||||
|
{{if .PGCred}}
|
||||||
|
<dl style="display:grid;grid-template-columns:140px 1fr;gap:.5rem .75rem;font-size:.9rem;margin-bottom:1rem">
|
||||||
|
<dt class="text-muted">Host</dt><dd>{{.PGCred.PGHost}}:{{.PGCred.PGPort}}</dd>
|
||||||
|
<dt class="text-muted">Database</dt><dd>{{.PGCred.PGDatabase}}</dd>
|
||||||
|
<dt class="text-muted">Username</dt><dd>{{.PGCred.PGUsername}}</dd>
|
||||||
|
<dt class="text-muted">Password</dt><dd><em class="text-muted">stored (not shown)</em></dd>
|
||||||
|
<dt class="text-muted">Updated</dt><dd class="text-small">{{formatTime .PGCred.UpdatedAt}}</dd>
|
||||||
|
</dl>
|
||||||
|
{{else}}
|
||||||
|
<p class="text-muted text-small" style="margin-bottom:1rem">No credentials stored.</p>
|
||||||
|
{{end}}
|
||||||
|
<form hx-put="/accounts/{{.Account.UUID}}/pgcreds"
|
||||||
|
hx-target="#pgcreds-section" hx-swap="outerHTML">
|
||||||
|
<div style="display:grid;grid-template-columns:1fr 1fr;gap:.5rem;margin-bottom:.5rem">
|
||||||
|
<input class="form-control" type="text" name="host" placeholder="Host" required
|
||||||
|
value="{{if .PGCred}}{{.PGCred.PGHost}}{{end}}">
|
||||||
|
<input class="form-control" type="number" name="port" placeholder="Port (5432)"
|
||||||
|
min="1" max="65535"
|
||||||
|
value="{{if .PGCred}}{{.PGCred.PGPort}}{{end}}">
|
||||||
|
</div>
|
||||||
|
<div style="display:grid;grid-template-columns:1fr 1fr;gap:.5rem;margin-bottom:.5rem">
|
||||||
|
<input class="form-control" type="text" name="database" placeholder="Database" required
|
||||||
|
value="{{if .PGCred}}{{.PGCred.PGDatabase}}{{end}}">
|
||||||
|
<input class="form-control" type="text" name="username" placeholder="Username" required
|
||||||
|
value="{{if .PGCred}}{{.PGCred.PGUsername}}{{end}}">
|
||||||
|
</div>
|
||||||
|
<input class="form-control" type="password" name="password"
|
||||||
|
placeholder="Password (required to update)" required
|
||||||
|
style="margin-bottom:.5rem">
|
||||||
|
<button class="btn btn-sm btn-secondary" type="submit">Save Credentials</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
Reference in New Issue
Block a user