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:
2026-03-11 22:30:13 -07:00
parent 9b0adfdde4
commit 5a8698e199
7 changed files with 528 additions and 14 deletions

View File

@@ -15,8 +15,10 @@
//
// Commands:
//
// auth login -username NAME [-password PASS] [-totp CODE]
//
// 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 update -id UUID [-status active|inactive]
// account delete -id UUID
@@ -27,7 +29,7 @@
// token issue -id UUID
// 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
package main
@@ -41,6 +43,8 @@ import (
"os"
"strings"
"time"
"golang.org/x/term"
)
func main() {
@@ -79,6 +83,8 @@ func main() {
subArgs := args[1:]
switch command {
case "auth":
ctl.runAuth(subArgs)
case "account":
ctl.runAccount(subArgs)
case "role":
@@ -99,6 +105,78 @@ type controller struct {
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 ----
func (c *controller) runAccount(args []string) {
@@ -130,7 +208,7 @@ func (c *controller) accountList() {
func (c *controller) accountCreate(args []string) {
fs := flag.NewFlagSet("account create", flag.ExitOnError)
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")
_ = fs.Parse(args)
@@ -138,12 +216,30 @@ func (c *controller) accountCreate(args []string) {
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{
"username": *username,
"account_type": *accountType,
}
if *password != "" {
body["password"] = *password
if passwd != "" {
body["password"] = passwd
}
var result json.RawMessage
@@ -332,11 +428,29 @@ func (c *controller) pgCredsSet(args []string) {
port := fs.Int("port", 5432, "Postgres port")
dbName := fs.String("db", "", "Postgres database name (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)
if *id == "" || *host == "" || *dbName == "" || *username == "" || *password == "" {
fatalf("pgcreds set: -id, -host, -db, -user, and -password are required")
if *id == "" || *host == "" || *dbName == "" || *username == "" {
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{}{
@@ -344,7 +458,7 @@ func (c *controller) pgCredsSet(args []string) {
"port": *port,
"database": *dbName,
"username": *username,
"password": *password,
"password": passwd,
}
c.doRequest("PUT", "/v1/accounts/"+*id+"/pgcreds", body, nil)
fmt.Println("credentials stored")
@@ -455,8 +569,13 @@ Global flags:
-cacert Path to CA certificate for TLS verification
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 create -username NAME -password PASS [-type human|system]
account create -username NAME [-password PASS] [-type human|system]
account get -id UUID
account update -id UUID -status active|inactive
account delete -id UUID
@@ -468,6 +587,6 @@ Commands:
token revoke -jti JTI
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]
`)
}