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:
|
||||
//
|
||||
// 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]
|
||||
`)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user