5 Commits

Author SHA1 Message Date
9657f18784 Fix OpenAPI spec parsing errors in Swagger UI
- Replace type: [string, "null"] array syntax with
  type: string + nullable: true on AuditEvent.actor_id,
  AuditEvent.target_id, PolicyRule.not_before, and
  PolicyRule.expires_at; Swagger UI 5 cannot parse the
  JSON Schema array form
- Add missing username field to /v1/token/validate response
  schema (added to handler in d6cc827 but never synced)
- Add missing GET /v1/pgcreds endpoint to spec
- Sync web/static/openapi.yaml (served file) with root;
  the static copy was many commits out of date, missing
  all policy/tags schemas and endpoints

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-15 16:29:53 -07:00
d4e8ef90ee Add policy-based authz and token delegation
- Replace requireAdmin (role-based) guards on all REST endpoints
  with RequirePolicy middleware backed by the existing policy engine;
  built-in admin wildcard rule (-1) preserves existing admin behaviour
  while operator rules can now grant targeted access to non-admin
  accounts (e.g. a system account allowed to list accounts)
- Wire policy engine into Server: loaded from DB at startup,
  reloaded after every policy-rule create/update/delete so changes
  take effect immediately without a server restart
- Add service_account_delegates table (migration 000008) so a human
  account can be delegated permission to issue tokens for a specific
  system account without holding the admin role
- Add token-download nonce mechanism: a short-lived (5 min),
  single-use random nonce is stored server-side after token issuance;
  the browser downloads the token as a file via
  GET /token/download/{nonce} (Content-Disposition: attachment)
  instead of copying from a flash message
- Add /service-accounts UI page for non-admin delegates
- Add TestPolicyEnforcement and TestPolicyDenyRule integration tests

Security:
- Policy engine uses deny-wins, default-deny semantics; admin wildcard
  is a compiled-in built-in and cannot be deleted via the API
- Token download nonces are 128-bit crypto/rand values, single-use,
  and expire after 5 minutes; a background goroutine evicts stale entries
- alg header validation and Ed25519 signing unchanged

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-15 14:40:16 -07:00
d6cc82755d Add username to token validate response
- Include username field in validateResponse struct
- Look up account by UUID and populate username on success
- Add username field to Go client TokenClaims struct
- Fix OpenAPI nullable type syntax (use array form)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-15 14:06:11 -07:00
0d38bbae00 Add mciasdb rekey command
- internal/db/accounts.go: add ListAccountsWithTOTP,
  ListAllPGCredentials, TOTPRekeyRow, PGRekeyRow, and
  Rekey — atomic transaction that replaces master_key_salt,
  signing_key_enc/nonce, all TOTP enc/nonce, and all
  pg_password enc/nonce in one SQLite BEGIN/COMMIT
- cmd/mciasdb/rekey.go: runRekey — decrypts all secrets
  under old master key, prompts for new passphrase (with
  confirmation), derives new key from fresh Argon2id salt,
  re-encrypts everything, and commits atomically
- cmd/mciasdb/main.go: wire "rekey" command + update usage
- Tests: DB-layer tests for ListAccountsWithTOTP,
  ListAllPGCredentials, Rekey (happy path, empty DB, salt
  replacement); command-level TestRekeyCommandRoundTrip
  verifies full round-trip and adversarially confirms old
  key no longer decrypts after rekey

Security: fresh random salt is always generated so a
reused passphrase still produces an independent key; old
and new master keys are zeroed via defer; no passphrase or
key material appears in logs or audit events; the entire
re-encryption is done in-memory before the single atomic
DB write so the database is never in a mixed state.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-15 13:27:29 -07:00
23a27be57e Fix login card nesting on htmx failure
- Add id="login-card" to the .card wrapper div
- Change hx-target to #login-card (was #login-form)
- Add hx-select="#login-card" so htmx extracts only
  the card element from the full-page response

Without hx-select, htmx replaced the form's outerHTML
with the entire page response, inserting a new .card
inside the existing .card on every failed attempt.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-15 08:31:40 -07:00
22 changed files with 2566 additions and 168 deletions

View File

@@ -2,7 +2,42 @@
Source of truth for current development state. Source of truth for current development state.
--- ---
All phases complete. **v1.0.0 tagged.** All packages pass `go test ./...`; `golangci-lint run ./...` clean. All phases complete. **v1.0.0 tagged.** All packages pass `go test ./...`; `golangci-lint run ./...` clean (pre-existing warnings only).
### 2026-03-15 — Service account token delegation and download
**Problem:** Only admins could issue tokens for service accounts, and the only way to retrieve the token was a flash message (copy-paste). There was no delegation mechanism for non-admin users.
**Solution:** Added token-issue delegation and a one-time secure file download flow.
**DB (`internal/db/`):**
- Migration `000008`: new `service_account_delegates` table — tracks which human accounts may issue tokens for a given system account
- `GrantTokenIssueAccess`, `RevokeTokenIssueAccess`, `ListTokenIssueDelegates`, `HasTokenIssueAccess`, `ListDelegatedServiceAccounts` functions
**Model (`internal/model/`):**
- New `ServiceAccountDelegate` type
- New audit event constants: `EventTokenDelegateGranted`, `EventTokenDelegateRevoked`
**UI (`internal/ui/`):**
- `handleIssueSystemToken`: now allows admins and delegates (not just admins); after issuance stores token in a short-lived (5 min) single-use download nonce; returns download link in the HTMX fragment
- `handleDownloadToken`: serves the token as `Content-Disposition: attachment` via the one-time nonce; nonce deleted on first use to prevent replay
- `handleGrantTokenDelegate` / `handleRevokeTokenDelegate`: admin-only endpoints to manage delegate access for a system account
- `handleServiceAccountsPage`: new `/service-accounts` page for non-admin delegates to see their assigned service accounts and issue tokens
- New `tokenDownloads sync.Map` in `UIServer` with background cleanup goroutine
**Routes:**
- `POST /accounts/{id}/token` — changed from admin-only to authed+CSRF, authorization checked in handler
- `GET /token/download/{nonce}` — new, authed
- `POST /accounts/{id}/token/delegates` — new, admin-only
- `DELETE /accounts/{id}/token/delegates/{grantee}` — new, admin-only
- `GET /service-accounts` — new, authed (delegates' token management page)
**Templates:**
- `token_list.html`: shows download link after issuance
- `token_delegates.html`: new fragment for admin delegate management
- `account_detail.html`: added "Token Issue Access" section for system accounts
- `service_accounts.html`: new page listing delegated service accounts with issue button
- `base.html`: non-admin nav now shows "Service Accounts" link
### 2026-03-14 — Vault seal/unseal lifecycle ### 2026-03-14 — Vault seal/unseal lifecycle

View File

@@ -77,6 +77,7 @@ type PublicKey struct {
type TokenClaims struct { type TokenClaims struct {
Valid bool `json:"valid"` Valid bool `json:"valid"`
Sub string `json:"sub,omitempty"` Sub string `json:"sub,omitempty"`
Username string `json:"username,omitempty"`
Roles []string `json:"roles,omitempty"` Roles []string `json:"roles,omitempty"`
ExpiresAt string `json:"expires_at,omitempty"` ExpiresAt string `json:"expires_at,omitempty"`
} }

View File

@@ -39,6 +39,8 @@
// //
// pgcreds get --id UUID // pgcreds get --id UUID
// pgcreds set --id UUID --host H --port P --db D --user U // pgcreds set --id UUID --host H --port P --db D --user U
//
// rekey
package main package main
import ( import (
@@ -107,6 +109,8 @@ func main() {
tool.runAudit(subArgs) tool.runAudit(subArgs)
case "pgcreds": case "pgcreds":
tool.runPGCreds(subArgs) tool.runPGCreds(subArgs)
case "rekey":
tool.runRekey(subArgs)
default: default:
fatalf("unknown command %q; run with no args for usage", command) fatalf("unknown command %q; run with no args for usage", command)
} }
@@ -259,6 +263,9 @@ Commands:
pgcreds set --id UUID --host H [--port P] --db D --user U pgcreds set --id UUID --host H [--port P] --db D --user U
(password is prompted interactively) (password is prompted interactively)
rekey Re-encrypt all secrets under a new master passphrase
(prompts interactively; requires server to be stopped)
NOTE: mciasdb bypasses the mciassrv API and operates directly on the SQLite NOTE: mciasdb bypasses the mciassrv API and operates directly on the SQLite
file. Use it only when the server is unavailable or for break-glass recovery. file. Use it only when the server is unavailable or for break-glass recovery.
All write operations are recorded in the audit log. All write operations are recorded in the audit log.

View File

@@ -438,3 +438,141 @@ func TestPGCredsGetNotFound(t *testing.T) {
t.Fatal("expected ErrNotFound, got nil") t.Fatal("expected ErrNotFound, got nil")
} }
} }
// ---- rekey command tests ----
// TestRekeyCommandRoundTrip exercises runRekey end-to-end with real AES-256-GCM
// encryption and actual Argon2id key derivation. It verifies that all secrets
// (signing key, TOTP, pg password) remain accessible after rekey and that the
// old master key no longer decrypts the re-encrypted values.
//
// Note: Argon2id derivation (time=3, memory=128 MiB) makes this test slow (~2 s).
func TestRekeyCommandRoundTrip(t *testing.T) {
tool := newTestTool(t)
// ── Setup: signing key encrypted under old master key ──
_, privKey, err := crypto.GenerateEd25519KeyPair()
if err != nil {
t.Fatalf("generate key pair: %v", err)
}
sigKeyPEM, err := crypto.MarshalPrivateKeyPEM(privKey)
if err != nil {
t.Fatalf("marshal key: %v", err)
}
sigEnc, sigNonce, err := crypto.SealAESGCM(tool.masterKey, sigKeyPEM)
if err != nil {
t.Fatalf("seal signing key: %v", err)
}
if err := tool.db.WriteServerConfig(sigEnc, sigNonce); err != nil {
t.Fatalf("write server config: %v", err)
}
// WriteMasterKeySalt so ReadServerConfig has a valid salt row.
oldSalt, err := crypto.NewSalt()
if err != nil {
t.Fatalf("gen salt: %v", err)
}
if err := tool.db.WriteMasterKeySalt(oldSalt); err != nil {
t.Fatalf("write salt: %v", err)
}
// ── Setup: account with TOTP ──
a, err := tool.db.CreateAccount("rekeyuser", "human", "")
if err != nil {
t.Fatalf("create account: %v", err)
}
totpSecret := []byte("JBSWY3DPEHPK3PXP")
totpEnc, totpNonce, err := crypto.SealAESGCM(tool.masterKey, totpSecret)
if err != nil {
t.Fatalf("seal totp: %v", err)
}
if err := tool.db.SetTOTP(a.ID, totpEnc, totpNonce); err != nil {
t.Fatalf("set totp: %v", err)
}
// ── Setup: pg credentials ──
pgPass := []byte("pgpassword123")
pgEnc, pgNonce, err := crypto.SealAESGCM(tool.masterKey, pgPass)
if err != nil {
t.Fatalf("seal pg pass: %v", err)
}
if err := tool.db.WritePGCredentials(a.ID, "localhost", 5432, "mydb", "myuser", pgEnc, pgNonce); err != nil {
t.Fatalf("write pg creds: %v", err)
}
// ── Pipe new passphrase twice into stdin ──
const newPassphrase = "new-master-passphrase-for-test"
r, w, err := os.Pipe()
if err != nil {
t.Fatalf("create stdin pipe: %v", err)
}
origStdin := os.Stdin
os.Stdin = r
t.Cleanup(func() { os.Stdin = origStdin })
if _, err := fmt.Fprintf(w, "%s\n%s\n", newPassphrase, newPassphrase); err != nil {
t.Fatalf("write stdin: %v", err)
}
_ = w.Close()
// ── Execute rekey ──
tool.runRekey(nil)
// ── Derive new key from stored salt + new passphrase ──
newSalt, err := tool.db.ReadMasterKeySalt()
if err != nil {
t.Fatalf("read new salt: %v", err)
}
newKey, err := crypto.DeriveKey(newPassphrase, newSalt)
if err != nil {
t.Fatalf("derive new key: %v", err)
}
defer func() {
for i := range newKey {
newKey[i] = 0
}
}()
// Signing key must decrypt with new key.
newSigEnc, newSigNonce, err := tool.db.ReadServerConfig()
if err != nil {
t.Fatalf("read server config after rekey: %v", err)
}
decPEM, err := crypto.OpenAESGCM(newKey, newSigNonce, newSigEnc)
if err != nil {
t.Fatalf("decrypt signing key with new key: %v", err)
}
if string(decPEM) != string(sigKeyPEM) {
t.Error("signing key PEM mismatch after rekey")
}
// Old key must NOT decrypt the re-encrypted signing key.
// Security: adversarial check that old key is invalidated.
if _, err := crypto.OpenAESGCM(tool.masterKey, newSigNonce, newSigEnc); err == nil {
t.Error("old key still decrypts signing key after rekey — ciphertext was not replaced")
}
// TOTP must decrypt with new key.
updatedAcct, err := tool.db.GetAccountByUUID(a.UUID)
if err != nil {
t.Fatalf("get account after rekey: %v", err)
}
decTOTP, err := crypto.OpenAESGCM(newKey, updatedAcct.TOTPSecretNonce, updatedAcct.TOTPSecretEnc)
if err != nil {
t.Fatalf("decrypt TOTP with new key: %v", err)
}
if string(decTOTP) != string(totpSecret) {
t.Errorf("TOTP mismatch: got %q, want %q", decTOTP, totpSecret)
}
// pg password must decrypt with new key.
updatedCred, err := tool.db.ReadPGCredentials(a.ID)
if err != nil {
t.Fatalf("read pg creds after rekey: %v", err)
}
decPG, err := crypto.OpenAESGCM(newKey, updatedCred.PGPasswordNonce, updatedCred.PGPasswordEnc)
if err != nil {
t.Fatalf("decrypt pg password with new key: %v", err)
}
if string(decPG) != string(pgPass) {
t.Errorf("pg password mismatch: got %q, want %q", decPG, pgPass)
}
}

154
cmd/mciasdb/rekey.go Normal file
View File

@@ -0,0 +1,154 @@
package main
import (
"fmt"
"os"
"git.wntrmute.dev/kyle/mcias/internal/crypto"
"git.wntrmute.dev/kyle/mcias/internal/db"
)
// runRekey re-encrypts all secrets under a new passphrase-derived master key.
//
// The current master key (already loaded in tool.masterKey by openDB) is used
// to decrypt every encrypted secret: the Ed25519 signing key, all TOTP secrets,
// and all Postgres credential passwords. The operator is then prompted for a
// new passphrase (confirmed), a fresh Argon2id salt is generated, a new 256-bit
// master key is derived, and all secrets are re-encrypted and written back in a
// single atomic SQLite transaction.
//
// Security: The entire re-encryption happens in memory first; the database is
// only updated once all ciphertext has been produced successfully. The new
// salt replaces the old salt atomically within the same transaction so the
// database is never left in a mixed state. Both the old and new master keys
// are zeroed in deferred cleanup. No secret material is logged or printed.
func (t *tool) runRekey(_ []string) {
// ── 1. Decrypt signing key under old master key ──────────────────────
sigKeyEnc, sigKeyNonce, err := t.db.ReadServerConfig()
if err != nil {
fatalf("read server config: %v", err)
}
sigKeyPEM, err := crypto.OpenAESGCM(t.masterKey, sigKeyNonce, sigKeyEnc)
if err != nil {
fatalf("decrypt signing key: %v", err)
}
// ── 2. Decrypt all TOTP secrets under old master key ─────────────────
totpAccounts, err := t.db.ListAccountsWithTOTP()
if err != nil {
fatalf("list accounts with TOTP: %v", err)
}
type totpPlain struct {
secret []byte
accountID int64
}
totpPlaintexts := make([]totpPlain, 0, len(totpAccounts))
for _, a := range totpAccounts {
pt, err := crypto.OpenAESGCM(t.masterKey, a.TOTPSecretNonce, a.TOTPSecretEnc)
if err != nil {
fatalf("decrypt TOTP secret for account %s: %v", a.Username, err)
}
totpPlaintexts = append(totpPlaintexts, totpPlain{accountID: a.ID, secret: pt})
}
// ── 3. Decrypt all pg_credentials passwords under old master key ──────
pgCreds, err := t.db.ListAllPGCredentials()
if err != nil {
fatalf("list pg credentials: %v", err)
}
type pgPlain struct {
password []byte
credID int64
}
pgPlaintexts := make([]pgPlain, 0, len(pgCreds))
for _, c := range pgCreds {
pt, err := crypto.OpenAESGCM(t.masterKey, c.PGPasswordNonce, c.PGPasswordEnc)
if err != nil {
fatalf("decrypt pg password for credential %d: %v", c.ID, err)
}
pgPlaintexts = append(pgPlaintexts, pgPlain{credID: c.ID, password: pt})
}
// ── 4. Prompt for new passphrase (confirmed) ──────────────────────────
fmt.Fprintln(os.Stderr, "Enter new master passphrase (will not echo):")
newPassphrase, err := readPassword("New passphrase: ")
if err != nil {
fatalf("read passphrase: %v", err)
}
if newPassphrase == "" {
fatalf("passphrase must not be empty")
}
confirm, err := readPassword("Confirm passphrase: ")
if err != nil {
fatalf("read passphrase confirmation: %v", err)
}
if newPassphrase != confirm {
fatalf("passphrases do not match")
}
// ── 5. Derive new master key ──────────────────────────────────────────
// Security: a fresh random salt is generated for every rekey so that the
// new key is independent of the old key even if the same passphrase is
// reused. The new salt is stored atomically with the re-encrypted secrets.
newSalt, err := crypto.NewSalt()
if err != nil {
fatalf("generate new salt: %v", err)
}
newKey, err := crypto.DeriveKey(newPassphrase, newSalt)
if err != nil {
fatalf("derive new master key: %v", err)
}
// Zero both keys when done, regardless of outcome.
defer func() {
for i := range newKey {
newKey[i] = 0
}
}()
// ── 6. Re-encrypt signing key ─────────────────────────────────────────
newSigKeyEnc, newSigKeyNonce, err := crypto.SealAESGCM(newKey, sigKeyPEM)
if err != nil {
fatalf("re-encrypt signing key: %v", err)
}
// ── 7. Re-encrypt TOTP secrets ────────────────────────────────────────
totpRows := make([]db.TOTPRekeyRow, 0, len(totpPlaintexts))
for _, tp := range totpPlaintexts {
enc, nonce, err := crypto.SealAESGCM(newKey, tp.secret)
if err != nil {
fatalf("re-encrypt TOTP secret for account %d: %v", tp.accountID, err)
}
totpRows = append(totpRows, db.TOTPRekeyRow{
AccountID: tp.accountID,
Enc: enc,
Nonce: nonce,
})
}
// ── 8. Re-encrypt pg_credentials passwords ────────────────────────────
pgRows := make([]db.PGRekeyRow, 0, len(pgPlaintexts))
for _, pp := range pgPlaintexts {
enc, nonce, err := crypto.SealAESGCM(newKey, pp.password)
if err != nil {
fatalf("re-encrypt pg password for credential %d: %v", pp.credID, err)
}
pgRows = append(pgRows, db.PGRekeyRow{
CredentialID: pp.credID,
Enc: enc,
Nonce: nonce,
})
}
// ── 9. Atomic commit ──────────────────────────────────────────────────
if err := t.db.Rekey(newSalt, newSigKeyEnc, newSigKeyNonce, totpRows, pgRows); err != nil {
fatalf("rekey database: %v", err)
}
if err := t.db.WriteAuditEvent("master_key_rekeyed", nil, nil, "", `{"actor":"mciasdb"}`); err != nil {
fmt.Fprintf(os.Stderr, "warning: write audit event: %v\n", err)
}
fmt.Printf("Rekey complete: %d TOTP secrets and %d pg credentials re-encrypted.\n",
len(totpRows), len(pgRows))
fmt.Fprintln(os.Stderr, "Update your mcias.toml or passphrase environment variable to use the new passphrase.")
}

View File

@@ -1245,3 +1245,268 @@ func (db *DB) ClearLoginFailures(accountID int64) error {
} }
return nil return nil
} }
// ListAccountsWithTOTP returns all accounts (including deleted) that have a
// non-null TOTP secret stored, so that rekey can re-encrypt every secret even
// for inactive or deleted accounts.
func (db *DB) ListAccountsWithTOTP() ([]*model.Account, error) {
rows, err := db.sql.Query(`
SELECT id, uuid, username, account_type, COALESCE(password_hash,''),
status, totp_required,
totp_secret_enc, totp_secret_nonce,
created_at, updated_at, deleted_at
FROM accounts
WHERE totp_secret_enc IS NOT NULL
ORDER BY id ASC
`)
if err != nil {
return nil, fmt.Errorf("db: list accounts with TOTP: %w", err)
}
defer func() { _ = rows.Close() }()
var accounts []*model.Account
for rows.Next() {
a, err := db.scanAccountRow(rows)
if err != nil {
return nil, err
}
accounts = append(accounts, a)
}
return accounts, rows.Err()
}
// ListAllPGCredentials returns every row in pg_credentials. Used by rekey
// to re-encrypt all stored passwords under a new master key.
func (db *DB) ListAllPGCredentials() ([]*model.PGCredential, error) {
rows, err := db.sql.Query(`
SELECT id, account_id, pg_host, pg_port, pg_database, pg_username,
pg_password_enc, pg_password_nonce, created_at, updated_at, owner_id
FROM pg_credentials
ORDER BY id ASC
`)
if err != nil {
return nil, fmt.Errorf("db: list all pg credentials: %w", err)
}
defer func() { _ = rows.Close() }()
var creds []*model.PGCredential
for rows.Next() {
var cred model.PGCredential
var createdAtStr, updatedAtStr string
var ownerID sql.NullInt64
if err := rows.Scan(
&cred.ID, &cred.AccountID, &cred.PGHost, &cred.PGPort,
&cred.PGDatabase, &cred.PGUsername,
&cred.PGPasswordEnc, &cred.PGPasswordNonce,
&createdAtStr, &updatedAtStr, &ownerID,
); err != nil {
return nil, fmt.Errorf("db: scan pg credential: %w", err)
}
var parseErr error
cred.CreatedAt, parseErr = parseTime(createdAtStr)
if parseErr != nil {
return nil, parseErr
}
cred.UpdatedAt, parseErr = parseTime(updatedAtStr)
if parseErr != nil {
return nil, parseErr
}
if ownerID.Valid {
v := ownerID.Int64
cred.OwnerID = &v
}
creds = append(creds, &cred)
}
return creds, rows.Err()
}
// TOTPRekeyRow carries a re-encrypted TOTP secret for a single account.
type TOTPRekeyRow struct {
Enc []byte
Nonce []byte
AccountID int64
}
// PGRekeyRow carries a re-encrypted Postgres password for a single credential row.
type PGRekeyRow struct {
Enc []byte
Nonce []byte
CredentialID int64
}
// Rekey atomically replaces the master-key salt and all secrets encrypted
// under the old master key with values encrypted under the new master key.
//
// Security: The entire replacement is performed inside a single SQLite
// transaction so that a crash mid-way leaves the database either fully on the
// old key or fully on the new key — never in a mixed state. The caller is
// responsible for zeroing the old and new master keys after this call returns.
func (db *DB) Rekey(newSalt, newSigningKeyEnc, newSigningKeyNonce []byte, totpRows []TOTPRekeyRow, pgRows []PGRekeyRow) error {
tx, err := db.sql.Begin()
if err != nil {
return fmt.Errorf("db: rekey begin tx: %w", err)
}
defer func() { _ = tx.Rollback() }()
n := now()
// Replace master key salt and signing key atomically.
_, err = tx.Exec(`
UPDATE server_config
SET master_key_salt = ?,
signing_key_enc = ?,
signing_key_nonce = ?,
updated_at = ?
WHERE id = 1
`, newSalt, newSigningKeyEnc, newSigningKeyNonce, n)
if err != nil {
return fmt.Errorf("db: rekey update server_config: %w", err)
}
// Re-encrypt each TOTP secret.
for _, row := range totpRows {
_, err = tx.Exec(`
UPDATE accounts
SET totp_secret_enc = ?,
totp_secret_nonce = ?,
updated_at = ?
WHERE id = ?
`, row.Enc, row.Nonce, n, row.AccountID)
if err != nil {
return fmt.Errorf("db: rekey update TOTP for account %d: %w", row.AccountID, err)
}
}
// Re-encrypt each pg_credentials password.
for _, row := range pgRows {
_, err = tx.Exec(`
UPDATE pg_credentials
SET pg_password_enc = ?,
pg_password_nonce = ?,
updated_at = ?
WHERE id = ?
`, row.Enc, row.Nonce, n, row.CredentialID)
if err != nil {
return fmt.Errorf("db: rekey update pg credential %d: %w", row.CredentialID, err)
}
}
if err := tx.Commit(); err != nil {
return fmt.Errorf("db: rekey commit: %w", err)
}
return nil
}
// GrantTokenIssueAccess records that granteeID may issue tokens for the system
// account identified by accountID. Idempotent: a second call for the same
// (account, grantee) pair is silently ignored via INSERT OR IGNORE.
func (db *DB) GrantTokenIssueAccess(accountID, granteeID int64, grantedBy *int64) error {
_, err := db.sql.Exec(`
INSERT OR IGNORE INTO service_account_delegates
(account_id, grantee_id, granted_by, granted_at)
VALUES (?, ?, ?, ?)
`, accountID, granteeID, grantedBy, now())
if err != nil {
return fmt.Errorf("db: grant token issue access: %w", err)
}
return nil
}
// RevokeTokenIssueAccess removes the delegate grant for granteeID on accountID.
// Returns ErrNotFound if no such grant exists.
func (db *DB) RevokeTokenIssueAccess(accountID, granteeID int64) error {
result, err := db.sql.Exec(`
DELETE FROM service_account_delegates
WHERE account_id = ? AND grantee_id = ?
`, accountID, granteeID)
if err != nil {
return fmt.Errorf("db: revoke token issue access: %w", err)
}
n, err := result.RowsAffected()
if err != nil {
return fmt.Errorf("db: revoke token issue access rows: %w", err)
}
if n == 0 {
return ErrNotFound
}
return nil
}
// ListTokenIssueDelegates returns all delegate grants for the given system account.
func (db *DB) ListTokenIssueDelegates(accountID int64) ([]*model.ServiceAccountDelegate, error) {
rows, err := db.sql.Query(`
SELECT d.id, d.account_id, d.grantee_id, d.granted_by, d.granted_at,
a.uuid, a.username
FROM service_account_delegates d
JOIN accounts a ON a.id = d.grantee_id
WHERE d.account_id = ?
ORDER BY d.granted_at ASC
`, accountID)
if err != nil {
return nil, fmt.Errorf("db: list token issue delegates: %w", err)
}
defer func() { _ = rows.Close() }()
var out []*model.ServiceAccountDelegate
for rows.Next() {
var d model.ServiceAccountDelegate
var grantedAt string
if err := rows.Scan(
&d.ID, &d.AccountID, &d.GranteeID, &d.GrantedBy, &grantedAt,
&d.GranteeUUID, &d.GranteeName,
); err != nil {
return nil, fmt.Errorf("db: scan token issue delegate: %w", err)
}
t, err := parseTime(grantedAt)
if err != nil {
return nil, err
}
d.GrantedAt = t
out = append(out, &d)
}
return out, rows.Err()
}
// HasTokenIssueAccess reports whether actorID has been granted permission to
// issue tokens for the system account identified by accountID.
func (db *DB) HasTokenIssueAccess(accountID, actorID int64) (bool, error) {
var count int
err := db.sql.QueryRow(`
SELECT COUNT(1) FROM service_account_delegates
WHERE account_id = ? AND grantee_id = ?
`, accountID, actorID).Scan(&count)
if err != nil {
return false, fmt.Errorf("db: has token issue access: %w", err)
}
return count > 0, nil
}
// ListDelegatedServiceAccounts returns system accounts for which actorID has
// been granted token-issue access.
func (db *DB) ListDelegatedServiceAccounts(actorID int64) ([]*model.Account, error) {
rows, err := db.sql.Query(`
SELECT a.id, a.uuid, a.username, a.account_type, COALESCE(a.password_hash,''),
a.status, a.totp_required,
a.totp_secret_enc, a.totp_secret_nonce,
a.created_at, a.updated_at, a.deleted_at
FROM service_account_delegates d
JOIN accounts a ON a.id = d.account_id
WHERE d.grantee_id = ? AND a.status != 'deleted'
ORDER BY a.username ASC
`, actorID)
if err != nil {
return nil, fmt.Errorf("db: list delegated service accounts: %w", err)
}
defer func() { _ = rows.Close() }()
var out []*model.Account
for rows.Next() {
a, err := db.scanAccountRow(rows)
if err != nil {
return nil, err
}
out = append(out, a)
}
return out, rows.Err()
}

View File

@@ -194,3 +194,210 @@ func TestListAuditEventsCombinedFilters(t *testing.T) {
t.Fatalf("expected 0 events, got %d", len(events)) t.Fatalf("expected 0 events, got %d", len(events))
} }
} }
// ---- rekey helper tests ----
func TestListAccountsWithTOTP(t *testing.T) {
database := openTestDB(t)
// No accounts with TOTP yet.
accounts, err := database.ListAccountsWithTOTP()
if err != nil {
t.Fatalf("ListAccountsWithTOTP (empty): %v", err)
}
if len(accounts) != 0 {
t.Fatalf("expected 0 accounts, got %d", len(accounts))
}
// Create an account and store a TOTP secret.
a, err := database.CreateAccount("totpuser", model.AccountTypeHuman, "hash")
if err != nil {
t.Fatalf("create account: %v", err)
}
if err := database.SetTOTP(a.ID, []byte("enc"), []byte("nonce")); err != nil {
t.Fatalf("set TOTP: %v", err)
}
// Create another account without TOTP.
if _, err := database.CreateAccount("nototp", model.AccountTypeHuman, "hash"); err != nil {
t.Fatalf("create account: %v", err)
}
accounts, err = database.ListAccountsWithTOTP()
if err != nil {
t.Fatalf("ListAccountsWithTOTP: %v", err)
}
if len(accounts) != 1 {
t.Fatalf("expected 1 account with TOTP, got %d", len(accounts))
}
if accounts[0].ID != a.ID {
t.Errorf("expected account ID %d, got %d", a.ID, accounts[0].ID)
}
}
func TestListAllPGCredentials(t *testing.T) {
database := openTestDB(t)
creds, err := database.ListAllPGCredentials()
if err != nil {
t.Fatalf("ListAllPGCredentials (empty): %v", err)
}
if len(creds) != 0 {
t.Fatalf("expected 0 creds, got %d", len(creds))
}
a, err := database.CreateAccount("pguser", model.AccountTypeSystem, "")
if err != nil {
t.Fatalf("create account: %v", err)
}
if err := database.WritePGCredentials(a.ID, "host", 5432, "db", "user", []byte("enc"), []byte("nonce")); err != nil {
t.Fatalf("write pg credentials: %v", err)
}
creds, err = database.ListAllPGCredentials()
if err != nil {
t.Fatalf("ListAllPGCredentials: %v", err)
}
if len(creds) != 1 {
t.Fatalf("expected 1 credential, got %d", len(creds))
}
if creds[0].AccountID != a.ID {
t.Errorf("expected account ID %d, got %d", a.ID, creds[0].AccountID)
}
}
func TestRekey(t *testing.T) {
database := openTestDB(t)
// Set up: salt + signing key.
oldSalt := []byte("oldsaltoldsaltoldsaltoldsaltoldt") // 32 bytes
if err := database.WriteMasterKeySalt(oldSalt); err != nil {
t.Fatalf("write salt: %v", err)
}
if err := database.WriteServerConfig([]byte("oldenc"), []byte("oldnonce")); err != nil {
t.Fatalf("write server config: %v", err)
}
// Set up: account with TOTP.
a, err := database.CreateAccount("rekeyuser", model.AccountTypeHuman, "hash")
if err != nil {
t.Fatalf("create account: %v", err)
}
if err := database.SetTOTP(a.ID, []byte("totpenc"), []byte("totpnonce")); err != nil {
t.Fatalf("set TOTP: %v", err)
}
// Set up: pg credential.
if err := database.WritePGCredentials(a.ID, "host", 5432, "db", "user", []byte("pgenc"), []byte("pgnonce")); err != nil {
t.Fatalf("write pg creds: %v", err)
}
// Execute Rekey.
newSalt := []byte("newsaltnewsaltnewsaltnewsaltnews") // 32 bytes
totpRows := []TOTPRekeyRow{{AccountID: a.ID, Enc: []byte("newtotpenc"), Nonce: []byte("newtotpnonce")}}
pgCred, err := database.ReadPGCredentials(a.ID)
if err != nil {
t.Fatalf("read pg creds: %v", err)
}
pgRows := []PGRekeyRow{{CredentialID: pgCred.ID, Enc: []byte("newpgenc"), Nonce: []byte("newpgnonce")}}
if err := database.Rekey(newSalt, []byte("newenc"), []byte("newnonce"), totpRows, pgRows); err != nil {
t.Fatalf("Rekey: %v", err)
}
// Verify: salt replaced.
gotSalt, err := database.ReadMasterKeySalt()
if err != nil {
t.Fatalf("read salt after rekey: %v", err)
}
if string(gotSalt) != string(newSalt) {
t.Errorf("salt mismatch: got %q, want %q", gotSalt, newSalt)
}
// Verify: signing key replaced.
gotEnc, gotNonce, err := database.ReadServerConfig()
if err != nil {
t.Fatalf("read server config after rekey: %v", err)
}
if string(gotEnc) != "newenc" || string(gotNonce) != "newnonce" {
t.Errorf("signing key enc/nonce mismatch after rekey")
}
// Verify: TOTP replaced.
updatedAcct, err := database.GetAccountByID(a.ID)
if err != nil {
t.Fatalf("get account after rekey: %v", err)
}
if string(updatedAcct.TOTPSecretEnc) != "newtotpenc" || string(updatedAcct.TOTPSecretNonce) != "newtotpnonce" {
t.Errorf("TOTP enc/nonce mismatch after rekey: enc=%q nonce=%q",
updatedAcct.TOTPSecretEnc, updatedAcct.TOTPSecretNonce)
}
// Verify: pg credential replaced.
updatedCred, err := database.ReadPGCredentials(a.ID)
if err != nil {
t.Fatalf("read pg creds after rekey: %v", err)
}
if string(updatedCred.PGPasswordEnc) != "newpgenc" || string(updatedCred.PGPasswordNonce) != "newpgnonce" {
t.Errorf("pg enc/nonce mismatch after rekey: enc=%q nonce=%q",
updatedCred.PGPasswordEnc, updatedCred.PGPasswordNonce)
}
}
func TestRekeyEmptyDatabase(t *testing.T) {
database := openTestDB(t)
// Minimal setup: salt and signing key only; no TOTP, no pg creds.
salt := []byte("saltsaltsaltsaltsaltsaltsaltsalt") // 32 bytes
if err := database.WriteMasterKeySalt(salt); err != nil {
t.Fatalf("write salt: %v", err)
}
if err := database.WriteServerConfig([]byte("enc"), []byte("nonce")); err != nil {
t.Fatalf("write server config: %v", err)
}
newSalt := []byte("newsaltnewsaltnewsaltnewsaltnews") // 32 bytes
if err := database.Rekey(newSalt, []byte("newenc"), []byte("newnonce"), nil, nil); err != nil {
t.Fatalf("Rekey (empty): %v", err)
}
gotSalt, err := database.ReadMasterKeySalt()
if err != nil {
t.Fatalf("read salt: %v", err)
}
if string(gotSalt) != string(newSalt) {
t.Errorf("salt mismatch")
}
}
// TestRekeyOldSaltUnchangedOnQueryError verifies the salt and encrypted data
// is only present under the new values after a successful Rekey — the old
// values must be gone. Uses the same approach as TestRekey but reads the
// stored salt before and confirms it changes.
func TestRekeyReplacesSalt(t *testing.T) {
database := openTestDB(t)
oldSalt := []byte("oldsaltoldsaltoldsaltoldsaltoldt") // 32 bytes
if err := database.WriteMasterKeySalt(oldSalt); err != nil {
t.Fatalf("write salt: %v", err)
}
if err := database.WriteServerConfig([]byte("enc"), []byte("nonce")); err != nil {
t.Fatalf("write server config: %v", err)
}
newSalt := []byte("newsaltnewsaltnewsaltnewsaltnews") // 32 bytes
if err := database.Rekey(newSalt, []byte("newenc"), []byte("newnonce"), nil, nil); err != nil {
t.Fatalf("Rekey: %v", err)
}
gotSalt, err := database.ReadMasterKeySalt()
if err != nil {
t.Fatalf("read salt: %v", err)
}
if string(gotSalt) == string(oldSalt) {
t.Error("old salt still present after rekey")
}
if string(gotSalt) != string(newSalt) {
t.Errorf("expected new salt %q, got %q", newSalt, gotSalt)
}
}

View File

@@ -0,0 +1,15 @@
-- service_account_delegates tracks which human accounts are permitted to issue
-- tokens for a given system account without holding the global admin role.
-- Admins manage delegates; delegates can issue/rotate tokens for the specific
-- system account only and cannot modify any other account settings.
CREATE TABLE IF NOT EXISTS service_account_delegates (
id INTEGER PRIMARY KEY,
account_id INTEGER NOT NULL REFERENCES accounts(id) ON DELETE CASCADE,
grantee_id INTEGER NOT NULL REFERENCES accounts(id) ON DELETE CASCADE,
granted_by INTEGER REFERENCES accounts(id),
granted_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ','now')),
UNIQUE (account_id, grantee_id)
);
CREATE INDEX IF NOT EXISTS idx_sa_delegates_account ON service_account_delegates (account_id);
CREATE INDEX IF NOT EXISTS idx_sa_delegates_grantee ON service_account_delegates (grantee_id);

View File

@@ -210,8 +210,25 @@ const (
EventPGCredAccessRevoked = "pgcred_access_revoked" //nolint:gosec // G101: audit event type, not a credential EventPGCredAccessRevoked = "pgcred_access_revoked" //nolint:gosec // G101: audit event type, not a credential
EventPasswordChanged = "password_changed" EventPasswordChanged = "password_changed"
EventTokenDelegateGranted = "token_delegate_granted"
EventTokenDelegateRevoked = "token_delegate_revoked"
) )
// ServiceAccountDelegate records that a specific account has been granted
// permission to issue tokens for a given system account. Only admins can
// add or remove delegates; delegates can issue/rotate tokens for that specific
// system account and nothing else.
type ServiceAccountDelegate struct {
GrantedAt time.Time `json:"granted_at"`
GrantedBy *int64 `json:"-"`
GranteeUUID string `json:"grantee_id"`
GranteeName string `json:"grantee_username"`
ID int64 `json:"-"`
AccountID int64 `json:"-"`
GranteeID int64 `json:"-"`
}
// PolicyRuleRecord is the database representation of a policy rule. // PolicyRuleRecord is the database representation of a policy rule.
// RuleJSON holds a JSON-encoded policy.RuleBody (all match and effect fields). // RuleJSON holds a JSON-encoded policy.RuleBody (all match and effect fields).
// The ID, Priority, and Description are stored as dedicated columns. // The ID, Priority, and Description are stored as dedicated columns.

View File

@@ -217,6 +217,9 @@ func (s *Server) handleCreatePolicyRule(w http.ResponseWriter, r *http.Request)
s.writeAudit(r, model.EventPolicyRuleCreated, createdBy, nil, s.writeAudit(r, model.EventPolicyRuleCreated, createdBy, nil,
fmt.Sprintf(`{"rule_id":%d,"description":%q}`, rec.ID, rec.Description)) fmt.Sprintf(`{"rule_id":%d,"description":%q}`, rec.ID, rec.Description))
// Reload the in-memory engine so the new rule takes effect immediately.
s.reloadPolicyEngine()
rv, err := policyRuleToResponse(rec) rv, err := policyRuleToResponse(rec)
if err != nil { if err != nil {
middleware.WriteError(w, http.StatusInternalServerError, "internal error", "internal_error") middleware.WriteError(w, http.StatusInternalServerError, "internal error", "internal_error")
@@ -325,6 +328,9 @@ func (s *Server) handleUpdatePolicyRule(w http.ResponseWriter, r *http.Request)
s.writeAudit(r, model.EventPolicyRuleUpdated, actorID, nil, s.writeAudit(r, model.EventPolicyRuleUpdated, actorID, nil,
fmt.Sprintf(`{"rule_id":%d}`, rec.ID)) fmt.Sprintf(`{"rule_id":%d}`, rec.ID))
// Reload the in-memory engine so rule changes take effect immediately.
s.reloadPolicyEngine()
updated, err := s.db.GetPolicyRule(rec.ID) updated, err := s.db.GetPolicyRule(rec.ID)
if err != nil { if err != nil {
middleware.WriteError(w, http.StatusInternalServerError, "internal error", "internal_error") middleware.WriteError(w, http.StatusInternalServerError, "internal error", "internal_error")
@@ -358,6 +364,9 @@ func (s *Server) handleDeletePolicyRule(w http.ResponseWriter, r *http.Request)
s.writeAudit(r, model.EventPolicyRuleDeleted, actorID, nil, s.writeAudit(r, model.EventPolicyRuleDeleted, actorID, nil,
fmt.Sprintf(`{"rule_id":%d,"description":%q}`, rec.ID, rec.Description)) fmt.Sprintf(`{"rule_id":%d,"description":%q}`, rec.ID, rec.Description))
// Reload the in-memory engine so the deleted rule is removed immediately.
s.reloadPolicyEngine()
w.WriteHeader(http.StatusNoContent) w.WriteHeader(http.StatusNoContent)
} }

View File

@@ -13,6 +13,7 @@ import (
"encoding/json" "encoding/json"
"errors" "errors"
"fmt" "fmt"
"io"
"io/fs" "io/fs"
"log/slog" "log/slog"
"net" "net"
@@ -27,6 +28,7 @@ import (
"git.wntrmute.dev/kyle/mcias/internal/db" "git.wntrmute.dev/kyle/mcias/internal/db"
"git.wntrmute.dev/kyle/mcias/internal/middleware" "git.wntrmute.dev/kyle/mcias/internal/middleware"
"git.wntrmute.dev/kyle/mcias/internal/model" "git.wntrmute.dev/kyle/mcias/internal/model"
"git.wntrmute.dev/kyle/mcias/internal/policy"
"git.wntrmute.dev/kyle/mcias/internal/token" "git.wntrmute.dev/kyle/mcias/internal/token"
"git.wntrmute.dev/kyle/mcias/internal/ui" "git.wntrmute.dev/kyle/mcias/internal/ui"
"git.wntrmute.dev/kyle/mcias/internal/validate" "git.wntrmute.dev/kyle/mcias/internal/validate"
@@ -40,15 +42,154 @@ type Server struct {
cfg *config.Config cfg *config.Config
logger *slog.Logger logger *slog.Logger
vault *vault.Vault vault *vault.Vault
polEng *policy.Engine
} }
// New creates a Server with the given dependencies. // New creates a Server with the given dependencies.
// The policy engine is initialised and loaded from the database on construction.
func New(database *db.DB, cfg *config.Config, v *vault.Vault, logger *slog.Logger) *Server { func New(database *db.DB, cfg *config.Config, v *vault.Vault, logger *slog.Logger) *Server {
eng := policy.NewEngine()
if err := loadEngineRules(eng, database); err != nil {
logger.Warn("policy engine initial load failed; built-in defaults will apply", "error", err)
}
return &Server{ return &Server{
db: database, db: database,
cfg: cfg, cfg: cfg,
vault: v, vault: v,
logger: logger, logger: logger,
polEng: eng,
}
}
// loadEngineRules reads all policy rules from the database and loads them into eng.
// Enabled/disabled and validity-window filtering is handled by the engine itself.
func loadEngineRules(eng *policy.Engine, database *db.DB) error {
records, err := database.ListPolicyRules(false)
if err != nil {
return fmt.Errorf("list policy rules: %w", err)
}
prs := make([]policy.PolicyRecord, len(records))
for i, r := range records {
prs[i] = policy.PolicyRecord{
ID: r.ID,
Priority: r.Priority,
Description: r.Description,
RuleJSON: r.RuleJSON,
Enabled: r.Enabled,
NotBefore: r.NotBefore,
ExpiresAt: r.ExpiresAt,
}
}
return eng.SetRules(prs)
}
// reloadPolicyEngine reloads operator rules from the database into the engine.
// Called after any create, update, or delete of a policy rule so that the
// in-memory cache stays consistent with the database.
func (s *Server) reloadPolicyEngine() {
if err := loadEngineRules(s.polEng, s.db); err != nil {
s.logger.Error("reload policy engine", "error", err)
}
}
// accountTypeLookup returns an AccountTypeLookup closure that resolves the
// account type ("human" or "system") for the given subject UUID. Used by the
// RequirePolicy middleware to populate PolicyInput.AccountType.
func (s *Server) accountTypeLookup() middleware.AccountTypeLookup {
return func(subjectUUID string) string {
acct, err := s.db.GetAccountByUUID(subjectUUID)
if err != nil {
return ""
}
return string(acct.AccountType)
}
}
// policyDenyLogger returns a PolicyDenyLogger that records policy denials in
// the audit log as EventPolicyDeny events.
func (s *Server) policyDenyLogger() middleware.PolicyDenyLogger {
return func(r *http.Request, claims *token.Claims, action policy.Action, res policy.Resource, matchedRuleID int64) {
s.writeAudit(r, model.EventPolicyDeny, nil, nil,
fmt.Sprintf(`{"subject":%q,"action":%q,"resource_type":%q,"rule_id":%d}`,
claims.Subject, action, res.Type, matchedRuleID))
}
}
// buildAccountResource assembles the policy.Resource for endpoints that
// target a specific account ({id} path parameter). Looks up the account's
// UUID, username (for ServiceName), and tags from the database.
// Returns an empty Resource on lookup failure; deny-by-default in the engine
// means this safely falls through to a denial for owner-scoped rules.
func (s *Server) buildAccountResource(r *http.Request, _ *token.Claims) policy.Resource {
id := r.PathValue("id")
if id == "" {
return policy.Resource{}
}
acct, err := s.db.GetAccountByUUID(id)
if err != nil {
return policy.Resource{}
}
tags, _ := s.db.GetAccountTags(acct.ID)
return policy.Resource{
OwnerUUID: acct.UUID,
ServiceName: acct.Username,
Tags: tags,
}
}
// buildTokenResource assembles the policy.Resource for token-issue requests.
// The request body contains account_id (UUID); the resource owner is that account.
// Because this builder reads the body it must be called before the body is
// consumed by the handler — the middleware calls it before invoking next.
func (s *Server) buildTokenResource(r *http.Request, _ *token.Claims) policy.Resource {
// Peek at the account_id without consuming the body.
// We read the body into a small wrapper struct to get the target UUID.
// The actual handler re-reads the body via decodeJSON, so this is safe
// because http.MaxBytesReader is applied by the handler, not here.
var peek struct {
AccountID string `json:"account_id"`
}
body, err := io.ReadAll(io.LimitReader(r.Body, maxJSONBytes))
if err != nil {
return policy.Resource{}
}
// Restore the body for the downstream handler.
r.Body = io.NopCloser(strings.NewReader(string(body)))
if err := json.Unmarshal(body, &peek); err != nil || peek.AccountID == "" {
return policy.Resource{}
}
acct, err := s.db.GetAccountByUUID(peek.AccountID)
if err != nil {
return policy.Resource{}
}
tags, _ := s.db.GetAccountTags(acct.ID)
return policy.Resource{
OwnerUUID: acct.UUID,
ServiceName: acct.Username,
Tags: tags,
}
}
// buildJTIResource assembles the policy.Resource for token-revoke requests.
// Looks up the token record by {jti} to identify the owning account.
func (s *Server) buildJTIResource(r *http.Request, _ *token.Claims) policy.Resource {
jti := r.PathValue("jti")
if jti == "" {
return policy.Resource{}
}
rec, err := s.db.GetTokenRecord(jti)
if err != nil {
return policy.Resource{}
}
acct, err := s.db.GetAccountByID(rec.AccountID)
if err != nil {
return policy.Resource{}
}
tags, _ := s.db.GetAccountTags(acct.ID)
return policy.Resource{
OwnerUUID: acct.UUID,
ServiceName: acct.Username,
Tags: tags,
} }
} }
@@ -114,44 +255,92 @@ func (s *Server) Handler() http.Handler {
// Authenticated endpoints. // Authenticated endpoints.
requireAuth := middleware.RequireAuth(s.vault, s.db, s.cfg.Tokens.Issuer) requireAuth := middleware.RequireAuth(s.vault, s.db, s.cfg.Tokens.Issuer)
requireAdmin := func(h http.Handler) http.Handler {
return requireAuth(middleware.RequireRole("admin")(h)) // Policy middleware factory: chains requireAuth → RequirePolicy → next.
// All protected endpoints use this instead of the old requireAdmin wrapper
// so that operator-defined policy rules (not just the admin role) control
// access. The built-in admin wildcard rule (ID -1) preserves existing
// admin behaviour; additional operator rules can grant non-admin accounts
// access to specific actions.
//
// Security: deny-wins + default-deny in the engine mean that any
// misconfiguration results in 403, never silent permit.
acctTypeLookup := s.accountTypeLookup()
denyLogger := s.policyDenyLogger()
requirePolicy := func(
action policy.Action,
resType policy.ResourceType,
builder middleware.ResourceBuilder,
) func(http.Handler) http.Handler {
pol := middleware.RequirePolicy(s.polEng, action, resType, builder, acctTypeLookup, denyLogger)
return func(next http.Handler) http.Handler {
return requireAuth(pol(next))
}
} }
// Auth endpoints (require valid token). // Resource builders for endpoints that target a specific account or token.
buildAcct := middleware.ResourceBuilder(s.buildAccountResource)
buildToken := middleware.ResourceBuilder(s.buildTokenResource)
buildJTI := middleware.ResourceBuilder(s.buildJTIResource)
// Auth endpoints (require valid token; self-service rules in built-in defaults
// allow any authenticated principal to perform these operations).
mux.Handle("POST /v1/auth/logout", requireAuth(http.HandlerFunc(s.handleLogout))) mux.Handle("POST /v1/auth/logout", requireAuth(http.HandlerFunc(s.handleLogout)))
mux.Handle("POST /v1/auth/renew", requireAuth(http.HandlerFunc(s.handleRenew))) mux.Handle("POST /v1/auth/renew", requireAuth(http.HandlerFunc(s.handleRenew)))
mux.Handle("POST /v1/auth/totp/enroll", requireAuth(http.HandlerFunc(s.handleTOTPEnroll))) mux.Handle("POST /v1/auth/totp/enroll", requireAuth(http.HandlerFunc(s.handleTOTPEnroll)))
mux.Handle("POST /v1/auth/totp/confirm", requireAuth(http.HandlerFunc(s.handleTOTPConfirm))) mux.Handle("POST /v1/auth/totp/confirm", requireAuth(http.HandlerFunc(s.handleTOTPConfirm)))
// Admin-only endpoints. // Policy-gated endpoints (formerly admin-only; now controlled by the engine).
mux.Handle("DELETE /v1/auth/totp", requireAdmin(http.HandlerFunc(s.handleTOTPRemove))) mux.Handle("DELETE /v1/auth/totp",
mux.Handle("POST /v1/token/issue", requireAdmin(http.HandlerFunc(s.handleTokenIssue))) requirePolicy(policy.ActionRemoveTOTP, policy.ResourceTOTP, buildAcct)(http.HandlerFunc(s.handleTOTPRemove)))
mux.Handle("DELETE /v1/token/{jti}", requireAdmin(http.HandlerFunc(s.handleTokenRevoke))) mux.Handle("POST /v1/token/issue",
mux.Handle("GET /v1/accounts", requireAdmin(http.HandlerFunc(s.handleListAccounts))) requirePolicy(policy.ActionIssueToken, policy.ResourceToken, buildToken)(http.HandlerFunc(s.handleTokenIssue)))
mux.Handle("POST /v1/accounts", requireAdmin(http.HandlerFunc(s.handleCreateAccount))) mux.Handle("DELETE /v1/token/{jti}",
mux.Handle("GET /v1/accounts/{id}", requireAdmin(http.HandlerFunc(s.handleGetAccount))) requirePolicy(policy.ActionRevokeToken, policy.ResourceToken, buildJTI)(http.HandlerFunc(s.handleTokenRevoke)))
mux.Handle("PATCH /v1/accounts/{id}", requireAdmin(http.HandlerFunc(s.handleUpdateAccount))) mux.Handle("GET /v1/accounts",
mux.Handle("DELETE /v1/accounts/{id}", requireAdmin(http.HandlerFunc(s.handleDeleteAccount))) requirePolicy(policy.ActionListAccounts, policy.ResourceAccount, nil)(http.HandlerFunc(s.handleListAccounts)))
mux.Handle("GET /v1/accounts/{id}/roles", requireAdmin(http.HandlerFunc(s.handleGetRoles))) mux.Handle("POST /v1/accounts",
mux.Handle("PUT /v1/accounts/{id}/roles", requireAdmin(http.HandlerFunc(s.handleSetRoles))) requirePolicy(policy.ActionCreateAccount, policy.ResourceAccount, nil)(http.HandlerFunc(s.handleCreateAccount)))
mux.Handle("POST /v1/accounts/{id}/roles", requireAdmin(http.HandlerFunc(s.handleGrantRole))) mux.Handle("GET /v1/accounts/{id}",
mux.Handle("DELETE /v1/accounts/{id}/roles/{role}", requireAdmin(http.HandlerFunc(s.handleRevokeRole))) requirePolicy(policy.ActionReadAccount, policy.ResourceAccount, buildAcct)(http.HandlerFunc(s.handleGetAccount)))
mux.Handle("PATCH /v1/accounts/{id}",
requirePolicy(policy.ActionUpdateAccount, policy.ResourceAccount, buildAcct)(http.HandlerFunc(s.handleUpdateAccount)))
mux.Handle("DELETE /v1/accounts/{id}",
requirePolicy(policy.ActionDeleteAccount, policy.ResourceAccount, buildAcct)(http.HandlerFunc(s.handleDeleteAccount)))
mux.Handle("GET /v1/accounts/{id}/roles",
requirePolicy(policy.ActionReadRoles, policy.ResourceAccount, buildAcct)(http.HandlerFunc(s.handleGetRoles)))
mux.Handle("PUT /v1/accounts/{id}/roles",
requirePolicy(policy.ActionWriteRoles, policy.ResourceAccount, buildAcct)(http.HandlerFunc(s.handleSetRoles)))
mux.Handle("POST /v1/accounts/{id}/roles",
requirePolicy(policy.ActionWriteRoles, policy.ResourceAccount, buildAcct)(http.HandlerFunc(s.handleGrantRole)))
mux.Handle("DELETE /v1/accounts/{id}/roles/{role}",
requirePolicy(policy.ActionWriteRoles, policy.ResourceAccount, buildAcct)(http.HandlerFunc(s.handleRevokeRole)))
mux.Handle("GET /v1/pgcreds", requireAuth(http.HandlerFunc(s.handleListAccessiblePGCreds))) mux.Handle("GET /v1/pgcreds", requireAuth(http.HandlerFunc(s.handleListAccessiblePGCreds)))
mux.Handle("GET /v1/accounts/{id}/pgcreds", requireAdmin(http.HandlerFunc(s.handleGetPGCreds))) mux.Handle("GET /v1/accounts/{id}/pgcreds",
mux.Handle("PUT /v1/accounts/{id}/pgcreds", requireAdmin(http.HandlerFunc(s.handleSetPGCreds))) requirePolicy(policy.ActionReadPGCreds, policy.ResourcePGCreds, buildAcct)(http.HandlerFunc(s.handleGetPGCreds)))
mux.Handle("GET /v1/audit", requireAdmin(http.HandlerFunc(s.handleListAudit))) mux.Handle("PUT /v1/accounts/{id}/pgcreds",
mux.Handle("GET /v1/accounts/{id}/tags", requireAdmin(http.HandlerFunc(s.handleGetTags))) requirePolicy(policy.ActionWritePGCreds, policy.ResourcePGCreds, buildAcct)(http.HandlerFunc(s.handleSetPGCreds)))
mux.Handle("PUT /v1/accounts/{id}/tags", requireAdmin(http.HandlerFunc(s.handleSetTags))) mux.Handle("GET /v1/audit",
mux.Handle("PUT /v1/accounts/{id}/password", requireAdmin(http.HandlerFunc(s.handleAdminSetPassword))) requirePolicy(policy.ActionReadAudit, policy.ResourceAuditLog, nil)(http.HandlerFunc(s.handleListAudit)))
mux.Handle("GET /v1/accounts/{id}/tags",
requirePolicy(policy.ActionReadTags, policy.ResourceAccount, buildAcct)(http.HandlerFunc(s.handleGetTags)))
mux.Handle("PUT /v1/accounts/{id}/tags",
requirePolicy(policy.ActionWriteTags, policy.ResourceAccount, buildAcct)(http.HandlerFunc(s.handleSetTags)))
mux.Handle("PUT /v1/accounts/{id}/password",
requirePolicy(policy.ActionUpdateAccount, policy.ResourceAccount, buildAcct)(http.HandlerFunc(s.handleAdminSetPassword)))
// Self-service password change (requires valid token; actor must match target account). // Self-service password change (requires valid token; actor must match target account).
mux.Handle("PUT /v1/auth/password", requireAuth(http.HandlerFunc(s.handleChangePassword))) mux.Handle("PUT /v1/auth/password", requireAuth(http.HandlerFunc(s.handleChangePassword)))
mux.Handle("GET /v1/policy/rules", requireAdmin(http.HandlerFunc(s.handleListPolicyRules))) mux.Handle("GET /v1/policy/rules",
mux.Handle("POST /v1/policy/rules", requireAdmin(http.HandlerFunc(s.handleCreatePolicyRule))) requirePolicy(policy.ActionListRules, policy.ResourcePolicy, nil)(http.HandlerFunc(s.handleListPolicyRules)))
mux.Handle("GET /v1/policy/rules/{id}", requireAdmin(http.HandlerFunc(s.handleGetPolicyRule))) mux.Handle("POST /v1/policy/rules",
mux.Handle("PATCH /v1/policy/rules/{id}", requireAdmin(http.HandlerFunc(s.handleUpdatePolicyRule))) requirePolicy(policy.ActionManageRules, policy.ResourcePolicy, nil)(http.HandlerFunc(s.handleCreatePolicyRule)))
mux.Handle("DELETE /v1/policy/rules/{id}", requireAdmin(http.HandlerFunc(s.handleDeletePolicyRule))) mux.Handle("GET /v1/policy/rules/{id}",
requirePolicy(policy.ActionListRules, policy.ResourcePolicy, nil)(http.HandlerFunc(s.handleGetPolicyRule)))
mux.Handle("PATCH /v1/policy/rules/{id}",
requirePolicy(policy.ActionManageRules, policy.ResourcePolicy, nil)(http.HandlerFunc(s.handleUpdatePolicyRule)))
mux.Handle("DELETE /v1/policy/rules/{id}",
requirePolicy(policy.ActionManageRules, policy.ResourcePolicy, nil)(http.HandlerFunc(s.handleDeletePolicyRule)))
// UI routes (HTMX-based management frontend). // UI routes (HTMX-based management frontend).
uiSrv, err := ui.New(s.db, s.cfg, s.vault, s.logger) uiSrv, err := ui.New(s.db, s.cfg, s.vault, s.logger)
@@ -451,6 +640,7 @@ type validateRequest struct {
type validateResponse struct { type validateResponse struct {
Subject string `json:"sub,omitempty"` Subject string `json:"sub,omitempty"`
Username string `json:"username,omitempty"`
ExpiresAt string `json:"expires_at,omitempty"` ExpiresAt string `json:"expires_at,omitempty"`
Roles []string `json:"roles,omitempty"` Roles []string `json:"roles,omitempty"`
Valid bool `json:"valid"` Valid bool `json:"valid"`
@@ -490,12 +680,16 @@ func (s *Server) handleTokenValidate(w http.ResponseWriter, r *http.Request) {
return return
} }
writeJSON(w, http.StatusOK, validateResponse{ resp := validateResponse{
Valid: true, Valid: true,
Subject: claims.Subject, Subject: claims.Subject,
Roles: claims.Roles, Roles: claims.Roles,
ExpiresAt: claims.ExpiresAt.Format("2006-01-02T15:04:05Z"), ExpiresAt: claims.ExpiresAt.Format("2006-01-02T15:04:05Z"),
}) }
if acct, err := s.db.GetAccountByUUID(claims.Subject); err == nil {
resp.Username = acct.Username
}
writeJSON(w, http.StatusOK, resp)
} }
type issueTokenRequest struct { type issueTokenRequest struct {
@@ -1315,13 +1509,13 @@ func (s *Server) handleListAccessiblePGCreds(w http.ResponseWriter, r *http.Requ
type pgCredResponse struct { type pgCredResponse struct {
CreatedAt time.Time `json:"created_at"` CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"` UpdatedAt time.Time `json:"updated_at"`
ID int64 `json:"id"`
Port int `json:"port"`
Host string `json:"host"` Host string `json:"host"`
Database string `json:"database"` Database string `json:"database"`
Username string `json:"username"` Username string `json:"username"`
ServiceAccountID string `json:"service_account_id"` ServiceAccountID string `json:"service_account_id"`
ServiceAccountName string `json:"service_account_name,omitempty"` ServiceAccountName string `json:"service_account_name,omitempty"`
ID int64 `json:"id"`
Port int `json:"port"`
} }
response := make([]pgCredResponse, len(creds)) response := make([]pgCredResponse, len(creds))

View File

@@ -2,10 +2,10 @@ package server
import ( import (
"bytes" "bytes"
"crypto/hmac"
"crypto/sha1"
"crypto/ed25519" "crypto/ed25519"
"crypto/hmac"
"crypto/rand" "crypto/rand"
"crypto/sha1" //nolint:gosec // G505: SHA1 required by RFC 6238 TOTP (HMAC-SHA1)
"encoding/binary" "encoding/binary"
"encoding/json" "encoding/json"
"fmt" "fmt"
@@ -23,6 +23,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/policy"
"git.wntrmute.dev/kyle/mcias/internal/token" "git.wntrmute.dev/kyle/mcias/internal/token"
"git.wntrmute.dev/kyle/mcias/internal/vault" "git.wntrmute.dev/kyle/mcias/internal/vault"
) )
@@ -972,3 +973,149 @@ func TestTOTPMissingDoesNotIncrementLockout(t *testing.T) {
t.Error("account was locked after TOTP-missing login — lockout counter was incorrectly incremented (PEN-06)") t.Error("account was locked after TOTP-missing login — lockout counter was incorrectly incremented (PEN-06)")
} }
} }
// issueSystemToken creates a system account, issues a JWT with the given roles,
// tracks it in the database, and returns the token string and account.
func issueSystemToken(t *testing.T, srv *Server, priv ed25519.PrivateKey, username string, roles []string) (string, *model.Account) {
t.Helper()
acct, err := srv.db.CreateAccount(username, model.AccountTypeSystem, "")
if err != nil {
t.Fatalf("create system account: %v", err)
}
tokenStr, claims, err := token.IssueToken(priv, testIssuer, acct.UUID, roles, time.Hour)
if err != nil {
t.Fatalf("issue token: %v", err)
}
if err := srv.db.TrackToken(claims.JTI, acct.ID, claims.IssuedAt, claims.ExpiresAt); err != nil {
t.Fatalf("track token: %v", err)
}
return tokenStr, acct
}
// TestPolicyEnforcement verifies that the policy engine gates access:
// - Admin role is always allowed (built-in wildcard rule).
// - Unauthenticated requests are rejected.
// - Non-admin accounts are denied by default.
// - A non-admin account gains access once an operator policy rule is created.
// - Deleting the rule reverts to denial.
func TestPolicyEnforcement(t *testing.T) {
srv, _, priv, _ := newTestServer(t)
handler := srv.Handler()
adminToken, _ := issueAdminToken(t, srv, priv, "admin-pol")
// 1. Admin can list accounts (built-in wildcard rule -1).
rr := doRequest(t, handler, "GET", "/v1/accounts", nil, adminToken)
if rr.Code != http.StatusOK {
t.Errorf("admin list accounts status = %d, want 200; body: %s", rr.Code, rr.Body.String())
}
// 2. Unauthenticated request is rejected.
rr = doRequest(t, handler, "GET", "/v1/accounts", nil, "")
if rr.Code != http.StatusUnauthorized {
t.Errorf("unauth list accounts status = %d, want 401", rr.Code)
}
// 3. System account with no operator rules is denied by default.
svcToken, svcAcct := issueSystemToken(t, srv, priv, "metacrypt", []string{"user"})
rr = doRequest(t, handler, "GET", "/v1/accounts", nil, svcToken)
if rr.Code != http.StatusForbidden {
t.Errorf("system account (no policy) list accounts status = %d, want 403; body: %s", rr.Code, rr.Body.String())
}
// 4. Create an operator policy rule granting the system account accounts:list.
rule := createPolicyRuleRequest{
Description: "allow metacrypt to list accounts",
Priority: 50,
Rule: policy.RuleBody{
SubjectUUID: svcAcct.UUID,
AccountTypes: []string{"system"},
Actions: []policy.Action{policy.ActionListAccounts},
Effect: policy.Allow,
},
}
rr = doRequest(t, handler, "POST", "/v1/policy/rules", rule, adminToken)
if rr.Code != http.StatusCreated {
t.Fatalf("create policy rule status = %d, want 201; body: %s", rr.Code, rr.Body.String())
}
var created policyRuleResponse
if err := json.Unmarshal(rr.Body.Bytes(), &created); err != nil {
t.Fatalf("unmarshal created rule: %v", err)
}
// 5. The same system account can now list accounts.
rr = doRequest(t, handler, "GET", "/v1/accounts", nil, svcToken)
if rr.Code != http.StatusOK {
t.Errorf("system account (with policy) list accounts status = %d, want 200; body: %s", rr.Code, rr.Body.String())
}
// 6. The system account is still denied other actions (accounts:read).
rr = doRequest(t, handler, "POST", "/v1/accounts", map[string]string{
"username": "newuser", "password": "newpassword123", "account_type": "human",
}, svcToken)
if rr.Code != http.StatusForbidden {
t.Errorf("system account (list-only policy) create account status = %d, want 403", rr.Code)
}
// 7. Delete the rule and verify the account is denied again.
rr = doRequest(t, handler, "DELETE", fmt.Sprintf("/v1/policy/rules/%d", created.ID), nil, adminToken)
if rr.Code != http.StatusNoContent {
t.Fatalf("delete policy rule status = %d, want 204; body: %s", rr.Code, rr.Body.String())
}
rr = doRequest(t, handler, "GET", "/v1/accounts", nil, svcToken)
if rr.Code != http.StatusForbidden {
t.Errorf("system account (rule deleted) list accounts status = %d, want 403", rr.Code)
}
}
// TestPolicyDenyRule verifies that an explicit Deny rule blocks access even
// when an Allow rule would otherwise permit it.
func TestPolicyDenyRule(t *testing.T) {
srv, _, priv, _ := newTestServer(t)
handler := srv.Handler()
adminToken, _ := issueAdminToken(t, srv, priv, "admin-deny")
// Create an Allow rule for the system account.
svcToken, svcAcct := issueSystemToken(t, srv, priv, "svc-deny", []string{"user"})
allow := createPolicyRuleRequest{
Description: "allow svc-deny to list accounts",
Priority: 50,
Rule: policy.RuleBody{
SubjectUUID: svcAcct.UUID,
Actions: []policy.Action{policy.ActionListAccounts},
Effect: policy.Allow,
},
}
rr := doRequest(t, handler, "POST", "/v1/policy/rules", allow, adminToken)
if rr.Code != http.StatusCreated {
t.Fatalf("create allow rule status = %d; body: %s", rr.Code, rr.Body.String())
}
// Verify access is granted.
rr = doRequest(t, handler, "GET", "/v1/accounts", nil, svcToken)
if rr.Code != http.StatusOK {
t.Fatalf("with allow rule, list accounts status = %d, want 200", rr.Code)
}
// Add a higher-priority Deny rule for the same account.
deny := createPolicyRuleRequest{
Description: "deny svc-deny accounts:list",
Priority: 10, // lower number = higher precedence
Rule: policy.RuleBody{
SubjectUUID: svcAcct.UUID,
Actions: []policy.Action{policy.ActionListAccounts},
Effect: policy.Deny,
},
}
rr = doRequest(t, handler, "POST", "/v1/policy/rules", deny, adminToken)
if rr.Code != http.StatusCreated {
t.Fatalf("create deny rule status = %d; body: %s", rr.Code, rr.Body.String())
}
// Deny-wins: access must now be blocked despite the Allow rule.
rr = doRequest(t, handler, "GET", "/v1/accounts", nil, svcToken)
if rr.Code != http.StatusForbidden {
t.Errorf("deny-wins: list accounts status = %d, want 403", rr.Code)
}
}

View File

@@ -182,17 +182,35 @@ func (u *UIServer) handleAccountDetail(w http.ResponseWriter, r *http.Request) {
tags = nil tags = nil
} }
// For system accounts, load token issue delegates and the full account
// list so admins can add new ones.
var tokenDelegates []*model.ServiceAccountDelegate
var delegatableAccounts []*model.Account
if acct.AccountType == model.AccountTypeSystem && isAdmin(r) {
tokenDelegates, err = u.db.ListTokenIssueDelegates(acct.ID)
if err != nil {
u.logger.Warn("list token issue delegates", "error", err)
}
delegatableAccounts, err = u.db.ListAccounts()
if err != nil {
u.logger.Warn("list accounts for delegate dropdown", "error", err)
}
}
u.render(w, "account_detail", AccountDetailData{ u.render(w, "account_detail", AccountDetailData{
PageData: PageData{CSRFToken: csrfToken, ActorName: u.actorName(r), IsAdmin: isAdmin(r)}, PageData: PageData{CSRFToken: csrfToken, ActorName: u.actorName(r), IsAdmin: isAdmin(r)},
Account: acct, Account: acct,
Roles: roles, Roles: roles,
AllRoles: knownRoles, AllRoles: knownRoles,
Tokens: tokens, Tokens: tokens,
PGCred: pgCred, PGCred: pgCred,
PGCredGrants: pgCredGrants, PGCredGrants: pgCredGrants,
GrantableAccounts: grantableAccounts, GrantableAccounts: grantableAccounts,
ActorID: actorID, ActorID: actorID,
Tags: tags, Tags: tags,
TokenDelegates: tokenDelegates,
DelegatableAccounts: delegatableAccounts,
CanIssueToken: true, // account_detail is admin-only, so admin can always issue
}) })
} }
@@ -1009,6 +1027,13 @@ func (u *UIServer) handleAdminResetPassword(w http.ResponseWriter, r *http.Reque
} }
// handleIssueSystemToken issues a long-lived service token for a system account. // handleIssueSystemToken issues a long-lived service token for a system account.
// Accessible to admins and to accounts that have been granted delegate access
// for this specific service account via service_account_delegates.
//
// Security: authorization is checked server-side against the JWT claims stored
// in the request context — it cannot be bypassed by client-side manipulation.
// After issuance the token string is stored in a short-lived single-use
// download nonce so the operator can retrieve it exactly once as a file.
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")
acct, err := u.db.GetAccountByUUID(id) acct, err := u.db.GetAccountByUUID(id)
@@ -1021,6 +1046,32 @@ func (u *UIServer) handleIssueSystemToken(w http.ResponseWriter, r *http.Request
return return
} }
// Security: require admin role OR an explicit delegate grant for this account.
actorClaims := claimsFromContext(r.Context())
var actorID *int64
if !isAdmin(r) {
if actorClaims == nil {
u.renderError(w, r, http.StatusForbidden, "access denied")
return
}
actor, err := u.db.GetAccountByUUID(actorClaims.Subject)
if err != nil {
u.renderError(w, r, http.StatusForbidden, "access denied")
return
}
actorID = &actor.ID
hasAccess, err := u.db.HasTokenIssueAccess(acct.ID, actor.ID)
if err != nil || !hasAccess {
u.renderError(w, r, http.StatusForbidden, "not authorized to issue tokens for this service account")
return
}
} else if actorClaims != nil {
actor, err := u.db.GetAccountByUUID(actorClaims.Subject)
if err == nil {
actorID = &actor.ID
}
}
roles, err := u.db.GetRoles(acct.ID) roles, err := u.db.GetRoles(acct.ID)
if err != nil { if err != nil {
u.renderError(w, r, http.StatusInternalServerError, "failed to load roles") u.renderError(w, r, http.StatusInternalServerError, "failed to load roles")
@@ -1054,17 +1105,18 @@ func (u *UIServer) handleIssueSystemToken(w http.ResponseWriter, r *http.Request
u.logger.Warn("set system token record", "error", err) 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, u.writeAudit(r, model.EventTokenIssued, actorID, &acct.ID,
fmt.Sprintf(`{"jti":%q,"via":"ui_system_token"}`, claims.JTI)) fmt.Sprintf(`{"jti":%q,"via":"ui_system_token"}`, claims.JTI))
// Store the raw token in the short-lived download cache so the operator
// can retrieve it exactly once via the download endpoint.
downloadNonce, err := u.storeTokenDownload(tokenStr, acct.UUID)
if err != nil {
u.logger.Error("store token download nonce", "error", err)
// Non-fatal: fall back to showing the token in the flash message.
downloadNonce = ""
}
// Re-fetch token list including the new token. // Re-fetch token list including the new token.
tokens, err := u.db.ListTokensForAccount(acct.ID) tokens, err := u.db.ListTokensForAccount(acct.ID)
if err != nil { if err != nil {
@@ -1077,13 +1129,209 @@ func (u *UIServer) handleIssueSystemToken(w http.ResponseWriter, r *http.Request
csrfToken = "" csrfToken = ""
} }
// Flash the raw token once at the top so the operator can copy it. var flash string
if downloadNonce == "" {
// Fallback: show token in flash when download nonce could not be stored.
flash = fmt.Sprintf("Token issued. Copy now — it will not be shown again: %s", tokenStr)
} else {
flash = "Token issued. Download it now — it will not be available again."
}
u.render(w, "token_list", AccountDetailData{ u.render(w, "token_list", AccountDetailData{
PageData: PageData{ PageData: PageData{CSRFToken: csrfToken, Flash: flash},
CSRFToken: csrfToken, Account: acct,
Flash: fmt.Sprintf("Token issued. Copy now — it will not be shown again: %s", tokenStr), Tokens: tokens,
}, DownloadNonce: downloadNonce,
Account: acct, })
Tokens: tokens, }
// handleDownloadToken serves the just-issued service token as a file
// attachment. The nonce is single-use and expires after tokenDownloadTTL.
//
// Security: the nonce was generated with crypto/rand (128 bits) at issuance
// time and is deleted from the in-memory store on first retrieval, preventing
// replay. The response sets Content-Disposition: attachment so the browser
// saves the file rather than rendering it, reducing the risk of an XSS vector
// if the token were displayed inline.
func (u *UIServer) handleDownloadToken(w http.ResponseWriter, r *http.Request) {
nonce := r.PathValue("nonce")
if nonce == "" {
http.Error(w, "missing nonce", http.StatusBadRequest)
return
}
tokenStr, accountID, ok := u.consumeTokenDownload(nonce)
if !ok {
http.Error(w, "download link expired or already used", http.StatusGone)
return
}
filename := "service-account-" + accountID + ".token"
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
w.Header().Set("Content-Disposition", fmt.Sprintf(`attachment; filename="%s"`, filename))
// Security: Content-Type is text/plain and Content-Disposition is attachment,
// so the browser will save the file rather than render it, mitigating XSS risk.
_, _ = fmt.Fprint(w, tokenStr) //nolint:gosec // G705: token served as attachment, not rendered by browser
}
// handleGrantTokenDelegate adds a delegate who may issue tokens for a system
// account. Only admins may call this endpoint.
//
// Security: the target system account and grantee are looked up by UUID so the
// URL/form fields cannot reference arbitrary row IDs. Audit event
// EventTokenDelegateGranted is recorded on success.
func (u *UIServer) handleGrantTokenDelegate(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, "service account not found")
return
}
if acct.AccountType != model.AccountTypeSystem {
u.renderError(w, r, http.StatusBadRequest, "token issue delegates are only supported for system accounts")
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
}
actorClaims := claimsFromContext(r.Context())
var actorID *int64
if actorClaims != nil {
actor, err := u.db.GetAccountByUUID(actorClaims.Subject)
if err == nil {
actorID = &actor.ID
}
}
if err := u.db.GrantTokenIssueAccess(acct.ID, grantee.ID, actorID); err != nil {
u.logger.Error("grant token issue access", "error", err)
u.renderError(w, r, http.StatusInternalServerError, "failed to grant access")
return
}
u.writeAudit(r, model.EventTokenDelegateGranted, actorID, &acct.ID,
fmt.Sprintf(`{"grantee":%q}`, grantee.UUID))
delegates, err := u.db.ListTokenIssueDelegates(acct.ID)
if err != nil {
u.logger.Warn("list token issue delegates after grant", "error", err)
}
allAccounts, err := u.db.ListAccounts()
if err != nil {
u.logger.Warn("list accounts for delegate grant", "error", err)
}
csrfToken, err := u.setCSRFCookies(w)
if err != nil {
csrfToken = ""
}
u.render(w, "token_delegates", AccountDetailData{
PageData: PageData{CSRFToken: csrfToken},
Account: acct,
TokenDelegates: delegates,
DelegatableAccounts: allAccounts,
})
}
// handleRevokeTokenDelegate removes a delegate's permission to issue tokens for
// a system account. Only admins may call this endpoint.
//
// Security: grantee looked up by UUID from the URL path. Audit event
// EventTokenDelegateRevoked recorded on success.
func (u *UIServer) handleRevokeTokenDelegate(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, "service account not found")
return
}
granteeUUID := r.PathValue("grantee")
grantee, err := u.db.GetAccountByUUID(granteeUUID)
if err != nil {
u.renderError(w, r, http.StatusNotFound, "grantee not found")
return
}
if err := u.db.RevokeTokenIssueAccess(acct.ID, grantee.ID); err != nil {
u.renderError(w, r, http.StatusInternalServerError, "failed to revoke access")
return
}
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.EventTokenDelegateRevoked, actorID, &acct.ID,
fmt.Sprintf(`{"grantee":%q}`, grantee.UUID))
delegates, err := u.db.ListTokenIssueDelegates(acct.ID)
if err != nil {
u.logger.Warn("list token issue delegates after revoke", "error", err)
}
allAccounts, err := u.db.ListAccounts()
if err != nil {
u.logger.Warn("list accounts for delegate dropdown", "error", err)
}
csrfToken, err := u.setCSRFCookies(w)
if err != nil {
csrfToken = ""
}
u.render(w, "token_delegates", AccountDetailData{
PageData: PageData{CSRFToken: csrfToken},
Account: acct,
TokenDelegates: delegates,
DelegatableAccounts: allAccounts,
})
}
// handleServiceAccountsPage renders the /service-accounts page showing all
// system accounts the current user has delegate access to, along with the
// ability to issue and download tokens for them.
func (u *UIServer) handleServiceAccountsPage(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
}
accounts, err := u.db.ListDelegatedServiceAccounts(actor.ID)
if err != nil {
u.renderError(w, r, http.StatusInternalServerError, "failed to load service accounts")
return
}
u.render(w, "service_accounts", ServiceAccountsData{
PageData: PageData{CSRFToken: csrfToken, ActorName: u.actorName(r), IsAdmin: isAdmin(r)},
Accounts: accounts,
}) })
} }

View File

@@ -54,15 +54,31 @@ type pendingLogin struct {
accountID int64 accountID int64
} }
// tokenDownload is a short-lived record that holds a just-issued service token
// string so the operator can download it as a file. It is single-use and
// expires after tokenDownloadTTL.
//
// Security: the token string is stored only for tokenDownloadTTL after
// issuance. The nonce is random (128 bits) and single-use: it is deleted from
// the map on first retrieval so it cannot be replayed.
type tokenDownload struct {
expiresAt time.Time
token string
accountID string // service account UUID (for the filename)
}
const tokenDownloadTTL = 5 * time.Minute
// UIServer serves the HTMX-based management UI. // UIServer serves the HTMX-based management UI.
type UIServer struct { type UIServer struct {
pendingLogins sync.Map // nonce (string) → *pendingLogin tmpls map[string]*template.Template // page name → template set
tmpls map[string]*template.Template // page name → template set db *db.DB
db *db.DB cfg *config.Config
cfg *config.Config logger *slog.Logger
logger *slog.Logger csrf *CSRFManager
csrf *CSRFManager vault *vault.Vault
vault *vault.Vault pendingLogins sync.Map // nonce (string) → *pendingLogin
tokenDownloads sync.Map // nonce (string) → *tokenDownload
} }
// issueTOTPNonce creates a random single-use nonce for the TOTP step and // issueTOTPNonce creates a random single-use nonce for the TOTP step and
@@ -196,6 +212,7 @@ func New(database *db.DB, cfg *config.Config, v *vault.Vault, logger *slog.Logge
"templates/fragments/policy_form.html", "templates/fragments/policy_form.html",
"templates/fragments/password_reset_form.html", "templates/fragments/password_reset_form.html",
"templates/fragments/password_change_form.html", "templates/fragments/password_change_form.html",
"templates/fragments/token_delegates.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 {
@@ -205,16 +222,17 @@ func New(database *db.DB, cfg *config.Config, v *vault.Vault, logger *slog.Logge
// Each page template defines "content" and "title" blocks; parsing them // Each page template defines "content" and "title" blocks; parsing them
// into separate clones prevents the last-defined block from winning. // into separate clones prevents the last-defined block from winning.
pageFiles := map[string]string{ pageFiles := map[string]string{
"login": "templates/login.html", "login": "templates/login.html",
"dashboard": "templates/dashboard.html", "dashboard": "templates/dashboard.html",
"accounts": "templates/accounts.html", "accounts": "templates/accounts.html",
"account_detail": "templates/account_detail.html", "account_detail": "templates/account_detail.html",
"audit": "templates/audit.html", "audit": "templates/audit.html",
"audit_detail": "templates/audit_detail.html", "audit_detail": "templates/audit_detail.html",
"policies": "templates/policies.html", "policies": "templates/policies.html",
"pgcreds": "templates/pgcreds.html", "pgcreds": "templates/pgcreds.html",
"profile": "templates/profile.html", "profile": "templates/profile.html",
"unseal": "templates/unseal.html", "unseal": "templates/unseal.html",
"service_accounts": "templates/service_accounts.html",
} }
tmpls := make(map[string]*template.Template, len(pageFiles)) tmpls := make(map[string]*template.Template, len(pageFiles))
for name, file := range pageFiles { for name, file := range pageFiles {
@@ -242,6 +260,7 @@ func New(database *db.DB, cfg *config.Config, v *vault.Vault, logger *slog.Logge
// entries abandoned by users who never complete step 2 would otherwise // entries abandoned by users who never complete step 2 would otherwise
// accumulate indefinitely, enabling a memory-exhaustion attack. // accumulate indefinitely, enabling a memory-exhaustion attack.
go srv.cleanupPendingLogins() go srv.cleanupPendingLogins()
go srv.cleanupTokenDownloads()
return srv, nil return srv, nil
} }
@@ -264,6 +283,56 @@ func (u *UIServer) cleanupPendingLogins() {
} }
} }
// storeTokenDownload saves a just-issued token string in the short-lived
// download store and returns a random single-use nonce the caller can include
// in the response. The download nonce expires after tokenDownloadTTL.
func (u *UIServer) storeTokenDownload(tokenStr, accountID string) (string, error) {
raw := make([]byte, 16)
if _, err := rand.Read(raw); err != nil {
return "", fmt.Errorf("ui: generate download nonce: %w", err)
}
nonce := hex.EncodeToString(raw)
u.tokenDownloads.Store(nonce, &tokenDownload{
token: tokenStr,
accountID: accountID,
expiresAt: time.Now().Add(tokenDownloadTTL),
})
return nonce, nil
}
// consumeTokenDownload looks up, validates, and deletes the download nonce.
// Returns the token string and account UUID, or ("", "", false) if the nonce
// is unknown or expired.
//
// Security: single-use deletion prevents replay; expiry bounds the window.
func (u *UIServer) consumeTokenDownload(nonce string) (tokenStr, accountID string, ok bool) {
v, loaded := u.tokenDownloads.LoadAndDelete(nonce)
if !loaded {
return "", "", false
}
td, valid := v.(*tokenDownload)
if !valid || time.Now().After(td.expiresAt) {
return "", "", false
}
return td.token, td.accountID, true
}
// cleanupTokenDownloads periodically evicts expired entries from tokenDownloads.
func (u *UIServer) cleanupTokenDownloads() {
ticker := time.NewTicker(5 * time.Minute)
defer ticker.Stop()
for range ticker.C {
now := time.Now()
u.tokenDownloads.Range(func(key, value any) bool {
td, ok := value.(*tokenDownload)
if !ok || now.After(td.expiresAt) {
u.tokenDownloads.Delete(key)
}
return true
})
}
}
// Register attaches all UI routes to mux, wrapped with security headers. // Register attaches all UI routes to mux, wrapped with security headers.
// All UI responses (pages, fragments, redirects, static assets) carry the // All UI responses (pages, fragments, redirects, static assets) carry the
// headers added by securityHeaders. // headers added by securityHeaders.
@@ -333,7 +402,14 @@ func (u *UIServer) Register(mux *http.ServeMux) {
uiMux.Handle("GET /accounts/{id}/roles/edit", adminGet(u.handleRolesEditForm)) uiMux.Handle("GET /accounts/{id}/roles/edit", adminGet(u.handleRolesEditForm))
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)) // Token issuance is accessible to both admins and delegates; the handler
// enforces the admin-or-delegate check internally.
uiMux.Handle("POST /accounts/{id}/token", authed(u.requireCSRF(http.HandlerFunc(u.handleIssueSystemToken))))
// Token download uses a one-time nonce issued at token-issuance time.
uiMux.Handle("GET /token/download/{nonce}", authed(http.HandlerFunc(u.handleDownloadToken)))
// Token issue delegate management — admin only.
uiMux.Handle("POST /accounts/{id}/token/delegates", admin(u.handleGrantTokenDelegate))
uiMux.Handle("DELETE /accounts/{id}/token/delegates/{grantee}", admin(u.handleRevokeTokenDelegate))
uiMux.Handle("PUT /accounts/{id}/pgcreds", admin(u.handleSetPGCreds)) uiMux.Handle("PUT /accounts/{id}/pgcreds", admin(u.handleSetPGCreds))
uiMux.Handle("POST /accounts/{id}/pgcreds/access", admin(u.handleGrantPGCredAccess)) uiMux.Handle("POST /accounts/{id}/pgcreds/access", admin(u.handleGrantPGCredAccess))
uiMux.Handle("DELETE /accounts/{id}/pgcreds/access/{grantee}", admin(u.handleRevokePGCredAccess)) uiMux.Handle("DELETE /accounts/{id}/pgcreds/access/{grantee}", admin(u.handleRevokePGCredAccess))
@@ -349,6 +425,10 @@ func (u *UIServer) Register(mux *http.ServeMux) {
uiMux.Handle("PUT /accounts/{id}/tags", admin(u.handleSetAccountTags)) uiMux.Handle("PUT /accounts/{id}/tags", admin(u.handleSetAccountTags))
uiMux.Handle("PUT /accounts/{id}/password", admin(u.handleAdminResetPassword)) uiMux.Handle("PUT /accounts/{id}/password", admin(u.handleAdminResetPassword))
// Service accounts page — accessible to any authenticated user; shows only
// the service accounts for which the current user is a token-issue delegate.
uiMux.Handle("GET /service-accounts", authed(http.HandlerFunc(u.handleServiceAccountsPage)))
// Profile routes — accessible to any authenticated user (not admin-only). // Profile routes — accessible to any authenticated user (not admin-only).
uiMux.Handle("GET /profile", authed(http.HandlerFunc(u.handleProfilePage))) uiMux.Handle("GET /profile", authed(http.HandlerFunc(u.handleProfilePage)))
uiMux.Handle("PUT /profile/password", authed(u.requireCSRF(http.HandlerFunc(u.handleSelfChangePassword)))) uiMux.Handle("PUT /profile/password", authed(u.requireCSRF(http.HandlerFunc(u.handleSelfChangePassword))))
@@ -678,11 +758,38 @@ type AccountDetailData struct {
// ActorID is the DB id of the currently logged-in user; used in templates // ActorID is the DB id of the currently logged-in user; used in templates
// to decide whether to show the owner-only management controls. // to decide whether to show the owner-only management controls.
ActorID *int64 ActorID *int64
// TokenDelegates lists accounts that may issue tokens for this service account.
// Only populated for system accounts when viewed by an admin.
TokenDelegates []*model.ServiceAccountDelegate
// DelegatableAccounts is the list of human accounts available for the
// "add delegate" dropdown. Only populated for admins.
DelegatableAccounts []*model.Account
// DownloadNonce is a one-time nonce for downloading the just-issued token.
// Populated by handleIssueSystemToken; empty otherwise.
DownloadNonce string
PageData PageData
Roles []string Roles []string
AllRoles []string AllRoles []string
Tags []string Tags []string
Tokens []*model.TokenRecord Tokens []*model.TokenRecord
// CanIssueToken is true when the viewing actor may issue tokens for this
// system account (admin role or explicit delegate grant).
// Placed last to minimise GC scan area.
CanIssueToken bool
}
// ServiceAccountsData is the view model for the /service-accounts page.
// It shows the system accounts for which the current user has delegate access,
// plus the just-issued token download nonce (if a token was just issued).
type ServiceAccountsData struct {
// Accounts is the list of system accounts the actor may issue tokens for.
Accounts []*model.Account
// DownloadNonce is a one-time nonce for downloading the just-issued token.
// Non-empty immediately after a successful token issuance.
DownloadNonce string
// IssuedFor is the UUID of the account whose token was just issued.
IssuedFor string
PageData
} }
// AuditData is the view model for the audit log page. // AuditData is the view model for the audit log page.

View File

@@ -221,8 +221,8 @@ components:
nullable: true nullable: true
description: | description: |
Time after which the rule is no longer active. NULL means no Time after which the rule is no longer active. NULL means no
constraint (never expires). Rules where `expires_at <= now()` are constraint (never expires). Rules where expires_at is in the past
skipped during evaluation. are skipped during evaluation.
example: "2026-06-01T00:00:00Z" example: "2026-06-01T00:00:00Z"
created_at: created_at:
type: string type: string
@@ -606,6 +606,10 @@ paths:
format: uuid format: uuid
description: Subject (account UUID). Present when valid=true. description: Subject (account UUID). Present when valid=true.
example: 550e8400-e29b-41d4-a716-446655440000 example: 550e8400-e29b-41d4-a716-446655440000
username:
type: string
description: Account username. Present when valid=true and the account exists.
example: alice
roles: roles:
type: array type: array
items: items:
@@ -619,7 +623,7 @@ paths:
example: "2026-04-10T12:34:56Z" example: "2026-04-10T12:34:56Z"
examples: examples:
valid: valid:
value: {valid: true, sub: "550e8400-...", roles: [editor], expires_at: "2026-04-10T12:34:56Z"} value: {valid: true, sub: "550e8400-...", username: alice, roles: [editor], expires_at: "2026-04-10T12:34:56Z"}
invalid: invalid:
value: {valid: false} value: {valid: false}
"429": "429":
@@ -1267,6 +1271,70 @@ paths:
"404": "404":
$ref: "#/components/responses/NotFound" $ref: "#/components/responses/NotFound"
/v1/pgcreds:
get:
summary: List accessible Postgres credentials
description: |
Return all Postgres credentials accessible to the authenticated account:
credentials owned by the account plus any explicitly granted by an admin.
The `id` field is the credential record ID; use it together with the
`service_account_id` to fetch full details via
`GET /v1/accounts/{id}/pgcreds`. Passwords are **not** returned by this
endpoint.
operationId: listAccessiblePGCreds
tags: [Admin — Credentials]
security:
- bearerAuth: []
responses:
"200":
description: Array of accessible Postgres credential summaries.
content:
application/json:
schema:
type: array
items:
type: object
required: [id, service_account_id, host, port, database, username, created_at, updated_at]
properties:
id:
type: integer
description: Credential record ID.
example: 7
service_account_id:
type: string
format: uuid
description: UUID of the system account that owns these credentials.
example: 550e8400-e29b-41d4-a716-446655440000
service_account_name:
type: string
description: Username of the owning system account (omitted if unavailable).
example: payments-api
host:
type: string
example: db.example.com
port:
type: integer
example: 5432
database:
type: string
example: mydb
username:
type: string
example: myuser
created_at:
type: string
format: date-time
example: "2026-03-11T09:00:00Z"
updated_at:
type: string
format: date-time
example: "2026-03-11T09:00:00Z"
"401":
$ref: "#/components/responses/Unauthorized"
"503":
$ref: "#/components/responses/VaultSealed"
/v1/audit: /v1/audit:
get: get:
summary: Query audit log (admin) summary: Query audit log (admin)

View File

@@ -118,6 +118,121 @@ components:
description: JSON blob with event-specific metadata. Never contains credentials. description: JSON blob with event-specific metadata. Never contains credentials.
example: '{"jti":"f47ac10b-..."}' example: '{"jti":"f47ac10b-..."}'
TagsResponse:
type: object
required: [tags]
properties:
tags:
type: array
items:
type: string
description: Current tag list for the account.
example: ["env:production", "svc:payments-api"]
RuleBody:
type: object
required: [effect]
description: |
The match conditions and effect of a policy rule. All fields except
`effect` are optional; an omitted field acts as a wildcard.
properties:
effect:
type: string
enum: [allow, deny]
example: allow
roles:
type: array
items:
type: string
description: Subject must have at least one of these roles.
example: ["svc:payments-api"]
account_types:
type: array
items:
type: string
enum: [human, system]
description: Subject account type must be one of these.
example: ["system"]
subject_uuid:
type: string
format: uuid
description: Match only this specific subject UUID.
example: 550e8400-e29b-41d4-a716-446655440000
actions:
type: array
items:
type: string
description: |
One of the defined action constants, e.g. `pgcreds:read`,
`accounts:list`. Subject action must be in this list.
example: ["pgcreds:read"]
resource_type:
type: string
description: Resource type the rule applies to.
example: pgcreds
owner_matches_subject:
type: boolean
description: Resource owner UUID must equal the subject UUID.
example: true
service_names:
type: array
items:
type: string
description: Resource service name must be one of these.
example: ["payments-api"]
required_tags:
type: array
items:
type: string
description: Resource must have ALL of these tags.
example: ["env:staging"]
PolicyRule:
type: object
required: [id, priority, description, rule, enabled, created_at, updated_at]
properties:
id:
type: integer
example: 1
priority:
type: integer
description: Lower number = evaluated first.
example: 100
description:
type: string
example: Allow payments-api to read its own pgcreds
rule:
$ref: "#/components/schemas/RuleBody"
enabled:
type: boolean
example: true
not_before:
type: string
format: date-time
nullable: true
description: |
Earliest time the rule becomes active. NULL means no constraint
(always active). Rules where `not_before > now()` are skipped
during evaluation.
example: "2026-04-01T00:00:00Z"
expires_at:
type: string
format: date-time
nullable: true
description: |
Time after which the rule is no longer active. NULL means no
constraint (never expires). Rules where expires_at is in the past
are skipped during evaluation.
example: "2026-06-01T00:00:00Z"
created_at:
type: string
format: date-time
example: "2026-03-11T09:00:00Z"
updated_at:
type: string
format: date-time
example: "2026-03-11T09:00:00Z"
PGCreds: PGCreds:
type: object type: object
required: [host, port, database, username, password] required: [host, port, database, username, password]
@@ -192,6 +307,18 @@ components:
error: rate limit exceeded error: rate limit exceeded
code: rate_limited code: rate_limited
VaultSealed:
description: |
The vault is sealed. The server is running but has no key material.
Unseal via `POST /v1/vault/unseal` before retrying.
content:
application/json:
schema:
$ref: "#/components/schemas/Error"
example:
error: vault is sealed
code: vault_sealed
paths: paths:
# ── Public ──────────────────────────────────────────────────────────────── # ── Public ────────────────────────────────────────────────────────────────
@@ -200,14 +327,16 @@ paths:
get: get:
summary: Health check summary: Health check
description: | description: |
Returns `{"status":"ok"}` if the server is running and the vault is Returns server health status. Always returns HTTP 200, even when the
unsealed, or `{"status":"sealed"}` if the vault is sealed. vault is sealed. No auth required.
No auth required.
When the vault is sealed, `status` is `"sealed"` and most other
endpoints return 503. When healthy, `status` is `"ok"`.
operationId: getHealth operationId: getHealth
tags: [Public] tags: [Public]
responses: responses:
"200": "200":
description: Server is healthy (may be sealed). description: Server is running (check `status` for sealed state).
content: content:
application/json: application/json:
schema: schema:
@@ -218,84 +347,6 @@ paths:
enum: [ok, sealed] enum: [ok, sealed]
example: ok example: ok
/v1/vault/status:
get:
summary: Vault seal status
description: Returns `{"sealed": true}` or `{"sealed": false}`. No auth required.
operationId: getVaultStatus
tags: [Vault]
responses:
"200":
description: Current seal state.
content:
application/json:
schema:
type: object
properties:
sealed:
type: boolean
/v1/vault/unseal:
post:
summary: Unseal the vault
description: |
Accepts a passphrase, derives the master key, and unseals the vault.
Rate-limited to 3 requests per second, burst of 5.
No auth required (the vault is sealed, so no tokens can be validated).
operationId: unsealVault
tags: [Vault]
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [passphrase]
properties:
passphrase:
type: string
description: Master passphrase for key derivation.
responses:
"200":
description: Vault unsealed successfully.
content:
application/json:
schema:
type: object
properties:
status:
type: string
example: unsealed
"401":
description: Unseal failed (wrong passphrase).
content:
application/json:
schema:
$ref: "#/components/schemas/Error"
/v1/vault/seal:
post:
summary: Seal the vault
description: |
Seals the vault, zeroing all key material in memory.
Requires admin authentication. The caller's token becomes invalid
after sealing.
operationId: sealVault
tags: [Vault]
security:
- bearerAuth: []
responses:
"200":
description: Vault sealed successfully.
content:
application/json:
schema:
type: object
properties:
status:
type: string
example: sealed
/v1/keys/public: /v1/keys/public:
get: get:
summary: Ed25519 public key (JWK) summary: Ed25519 public key (JWK)
@@ -336,6 +387,121 @@ paths:
description: Base64url-encoded public key bytes. description: Base64url-encoded public key bytes.
example: 11qYAYKxCrfVS_7TyWQHOg7hcvPapiMlrwIaaPcHURo example: 11qYAYKxCrfVS_7TyWQHOg7hcvPapiMlrwIaaPcHURo
/v1/vault/status:
get:
summary: Vault seal status
description: |
Returns whether the vault is currently sealed. Always accessible,
even when sealed. No auth required.
Clients should poll this after startup or after a 503 `vault_sealed`
response to determine when to attempt an unseal.
operationId: getVaultStatus
tags: [Public]
responses:
"200":
description: Current vault seal state.
content:
application/json:
schema:
type: object
required: [sealed]
properties:
sealed:
type: boolean
example: false
/v1/vault/unseal:
post:
summary: Unseal the vault
description: |
Provide the master passphrase to derive the encryption key, decrypt
the Ed25519 signing key, and unseal the vault. Once unsealed, all
other endpoints become available.
Rate limited to 3 requests per second per IP (burst 5) to limit
brute-force attempts against the passphrase.
The passphrase is never logged. A generic `"unseal failed"` error
is returned for any failure (wrong passphrase, vault already unsealed
mid-flight, etc.) to avoid leaking information.
operationId: unsealVault
tags: [Public]
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [passphrase]
properties:
passphrase:
type: string
description: Master passphrase used to derive the encryption key.
example: correct-horse-battery-staple
responses:
"200":
description: Vault unsealed (or was already unsealed).
content:
application/json:
schema:
type: object
properties:
status:
type: string
enum: [unsealed, already unsealed]
example: unsealed
"400":
$ref: "#/components/responses/BadRequest"
"401":
description: Wrong passphrase or key decryption failure.
content:
application/json:
schema:
$ref: "#/components/schemas/Error"
example:
error: unseal failed
code: unauthorized
"429":
$ref: "#/components/responses/RateLimited"
/v1/vault/seal:
post:
summary: Seal the vault (admin)
description: |
Zero all key material in memory and transition the server to the
sealed state. After this call:
- All subsequent requests (except health, vault status, and unseal)
return 503 `vault_sealed`.
- The caller's own JWT is immediately invalidated because the public
key needed to verify it is no longer held in memory.
- The server can be unsealed again via `POST /v1/vault/unseal`.
This is an emergency operation. Use it to protect key material if a
compromise is suspected. It does **not** restart the server or wipe
the database.
operationId: sealVault
tags: [Admin — Vault]
security:
- bearerAuth: []
responses:
"200":
description: Vault sealed (or was already sealed).
content:
application/json:
schema:
type: object
properties:
status:
type: string
enum: [sealed, already sealed]
example: sealed
"401":
$ref: "#/components/responses/Unauthorized"
"403":
$ref: "#/components/responses/Forbidden"
/v1/auth/login: /v1/auth/login:
post: post:
summary: Login summary: Login
@@ -440,6 +606,10 @@ paths:
format: uuid format: uuid
description: Subject (account UUID). Present when valid=true. description: Subject (account UUID). Present when valid=true.
example: 550e8400-e29b-41d4-a716-446655440000 example: 550e8400-e29b-41d4-a716-446655440000
username:
type: string
description: Account username. Present when valid=true and the account exists.
example: alice
roles: roles:
type: array type: array
items: items:
@@ -453,7 +623,7 @@ paths:
example: "2026-04-10T12:34:56Z" example: "2026-04-10T12:34:56Z"
examples: examples:
valid: valid:
value: {valid: true, sub: "550e8400-...", roles: [editor], expires_at: "2026-04-10T12:34:56Z"} value: {valid: true, sub: "550e8400-...", username: alice, roles: [editor], expires_at: "2026-04-10T12:34:56Z"}
invalid: invalid:
value: {valid: false} value: {valid: false}
"429": "429":
@@ -578,6 +748,68 @@ paths:
"401": "401":
$ref: "#/components/responses/Unauthorized" $ref: "#/components/responses/Unauthorized"
/v1/auth/password:
put:
summary: Change own password (self-service)
description: |
Change the password of the currently authenticated human account.
The caller must supply the correct `current_password` to prevent
token-theft attacks: possession of a valid JWT alone is not sufficient.
On success:
- The stored Argon2id hash is replaced with the new password hash.
- All active sessions *except* the caller's current token are revoked.
- The lockout failure counter is cleared.
On failure (wrong current password):
- A login failure is recorded against the account, subject to the
same lockout rules as `POST /v1/auth/login`.
Only applies to human accounts. System accounts have no password.
operationId: changePassword
tags: [Auth]
security:
- bearerAuth: []
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [current_password, new_password]
properties:
current_password:
type: string
description: The account's current password (required for verification).
example: old-s3cr3t
new_password:
type: string
description: The new password. Minimum 12 characters.
example: new-s3cr3t-long
responses:
"204":
description: Password changed. Other active sessions revoked.
"400":
$ref: "#/components/responses/BadRequest"
"401":
description: Current password is incorrect.
content:
application/json:
schema:
$ref: "#/components/schemas/Error"
example:
error: current password is incorrect
code: unauthorized
"429":
description: Account temporarily locked due to too many failed attempts.
content:
application/json:
schema:
$ref: "#/components/schemas/Error"
example:
error: account temporarily locked
code: account_locked
# ── Admin ────────────────────────────────────────────────────────────────── # ── Admin ──────────────────────────────────────────────────────────────────
/v1/auth/totp: /v1/auth/totp:
@@ -911,6 +1143,76 @@ paths:
"404": "404":
$ref: "#/components/responses/NotFound" $ref: "#/components/responses/NotFound"
post:
summary: Grant a role to an account (admin)
description: |
Add a single role to an account's role set. If the role already exists,
this is a no-op. Roles take effect in the **next** token issued or
renewed; existing tokens continue to carry the roles embedded at
issuance time.
operationId: grantRole
tags: [Admin — Accounts]
security:
- bearerAuth: []
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [role]
properties:
role:
type: string
example: editor
responses:
"204":
description: Role granted.
"400":
$ref: "#/components/responses/BadRequest"
"401":
$ref: "#/components/responses/Unauthorized"
"403":
$ref: "#/components/responses/Forbidden"
"404":
$ref: "#/components/responses/NotFound"
/v1/accounts/{id}/roles/{role}:
parameters:
- name: id
in: path
required: true
schema:
type: string
format: uuid
example: 550e8400-e29b-41d4-a716-446655440000
- name: role
in: path
required: true
schema:
type: string
example: editor
delete:
summary: Revoke a role from an account (admin)
description: |
Remove a single role from an account's role set. Roles take effect in
the **next** token issued or renewed; existing tokens continue to carry
the roles embedded at issuance time.
operationId: revokeRole
tags: [Admin — Accounts]
security:
- bearerAuth: []
responses:
"204":
description: Role revoked.
"401":
$ref: "#/components/responses/Unauthorized"
"403":
$ref: "#/components/responses/Forbidden"
"404":
$ref: "#/components/responses/NotFound"
/v1/accounts/{id}/pgcreds: /v1/accounts/{id}/pgcreds:
parameters: parameters:
- name: id - name: id
@@ -969,6 +1271,70 @@ paths:
"404": "404":
$ref: "#/components/responses/NotFound" $ref: "#/components/responses/NotFound"
/v1/pgcreds:
get:
summary: List accessible Postgres credentials
description: |
Return all Postgres credentials accessible to the authenticated account:
credentials owned by the account plus any explicitly granted by an admin.
The `id` field is the credential record ID; use it together with the
`service_account_id` to fetch full details via
`GET /v1/accounts/{id}/pgcreds`. Passwords are **not** returned by this
endpoint.
operationId: listAccessiblePGCreds
tags: [Admin — Credentials]
security:
- bearerAuth: []
responses:
"200":
description: Array of accessible Postgres credential summaries.
content:
application/json:
schema:
type: array
items:
type: object
required: [id, service_account_id, host, port, database, username, created_at, updated_at]
properties:
id:
type: integer
description: Credential record ID.
example: 7
service_account_id:
type: string
format: uuid
description: UUID of the system account that owns these credentials.
example: 550e8400-e29b-41d4-a716-446655440000
service_account_name:
type: string
description: Username of the owning system account (omitted if unavailable).
example: payments-api
host:
type: string
example: db.example.com
port:
type: integer
example: 5432
database:
type: string
example: mydb
username:
type: string
example: myuser
created_at:
type: string
format: date-time
example: "2026-03-11T09:00:00Z"
updated_at:
type: string
format: date-time
example: "2026-03-11T09:00:00Z"
"401":
$ref: "#/components/responses/Unauthorized"
"503":
$ref: "#/components/responses/VaultSealed"
/v1/audit: /v1/audit:
get: get:
summary: Query audit log (admin) summary: Query audit log (admin)
@@ -980,7 +1346,10 @@ paths:
`token_issued`, `token_renewed`, `token_revoked`, `token_expired`, `token_issued`, `token_renewed`, `token_revoked`, `token_expired`,
`account_created`, `account_updated`, `account_deleted`, `account_created`, `account_updated`, `account_deleted`,
`role_granted`, `role_revoked`, `totp_enrolled`, `totp_removed`, `role_granted`, `role_revoked`, `totp_enrolled`, `totp_removed`,
`pgcred_accessed`, `pgcred_updated`. `pgcred_accessed`, `pgcred_updated`, `pgcred_access_granted`,
`pgcred_access_revoked`, `tag_added`, `tag_removed`,
`policy_rule_created`, `policy_rule_updated`, `policy_rule_deleted`,
`policy_deny`, `vault_sealed`, `vault_unsealed`.
operationId: listAudit operationId: listAudit
tags: [Admin — Audit] tags: [Admin — Audit]
security: security:
@@ -1041,6 +1410,310 @@ paths:
"403": "403":
$ref: "#/components/responses/Forbidden" $ref: "#/components/responses/Forbidden"
/v1/accounts/{id}/tags:
parameters:
- name: id
in: path
required: true
schema:
type: string
format: uuid
example: 550e8400-e29b-41d4-a716-446655440000
get:
summary: Get account tags (admin)
description: |
Return the current tag set for an account. Tags are used by the policy
engine for machine/service gating (e.g. `env:production`,
`svc:payments-api`).
operationId: getAccountTags
tags: [Admin — Policy]
security:
- bearerAuth: []
responses:
"200":
description: Tag list.
content:
application/json:
schema:
$ref: "#/components/schemas/TagsResponse"
"401":
$ref: "#/components/responses/Unauthorized"
"403":
$ref: "#/components/responses/Forbidden"
"404":
$ref: "#/components/responses/NotFound"
put:
summary: Set account tags (admin)
description: |
Replace the account's full tag set atomically. Pass an empty array to
clear all tags. Changes take effect immediately for new policy
evaluations; no token renewal is required.
operationId: setAccountTags
tags: [Admin — Policy]
security:
- bearerAuth: []
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [tags]
properties:
tags:
type: array
items:
type: string
example: ["env:production", "svc:payments-api"]
responses:
"200":
description: Updated tag list.
content:
application/json:
schema:
$ref: "#/components/schemas/TagsResponse"
"400":
$ref: "#/components/responses/BadRequest"
"401":
$ref: "#/components/responses/Unauthorized"
"403":
$ref: "#/components/responses/Forbidden"
"404":
$ref: "#/components/responses/NotFound"
/v1/accounts/{id}/password:
parameters:
- name: id
in: path
required: true
schema:
type: string
format: uuid
example: 550e8400-e29b-41d4-a716-446655440000
put:
summary: Admin password reset (admin)
description: |
Reset the password for a human account without requiring the current
password. This is intended for account recovery (e.g. a user forgot
their password).
On success:
- The stored Argon2id hash is replaced with the new password hash.
- All active sessions for the target account are revoked.
Only applies to human accounts. The new password must be at least
12 characters.
operationId: adminSetPassword
tags: [Admin — Accounts]
security:
- bearerAuth: []
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [new_password]
properties:
new_password:
type: string
description: The new password. Minimum 12 characters.
example: new-s3cr3t-long
responses:
"204":
description: Password reset. All active sessions for the account revoked.
"400":
$ref: "#/components/responses/BadRequest"
"401":
$ref: "#/components/responses/Unauthorized"
"403":
$ref: "#/components/responses/Forbidden"
"404":
$ref: "#/components/responses/NotFound"
/v1/policy/rules:
get:
summary: List policy rules (admin)
description: |
Return all operator-defined policy rules ordered by priority (ascending).
Built-in default rules (IDs -1 to -7) are not included.
operationId: listPolicyRules
tags: [Admin — Policy]
security:
- bearerAuth: []
responses:
"200":
description: Array of policy rules.
content:
application/json:
schema:
type: array
items:
$ref: "#/components/schemas/PolicyRule"
"401":
$ref: "#/components/responses/Unauthorized"
"403":
$ref: "#/components/responses/Forbidden"
post:
summary: Create policy rule (admin)
description: |
Create a new operator policy rule. Rules are evaluated in priority order
(lower number = evaluated first, default 100). Deny-wins: if any matching
rule has effect `deny`, access is denied regardless of allow rules.
operationId: createPolicyRule
tags: [Admin — Policy]
security:
- bearerAuth: []
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [description, rule]
properties:
description:
type: string
example: Allow payments-api to read its own pgcreds
priority:
type: integer
description: Evaluation priority. Lower = first. Default 100.
example: 50
rule:
$ref: "#/components/schemas/RuleBody"
not_before:
type: string
format: date-time
description: Earliest activation time (RFC3339, optional).
example: "2026-04-01T00:00:00Z"
expires_at:
type: string
format: date-time
description: Expiry time (RFC3339, optional).
example: "2026-06-01T00:00:00Z"
responses:
"201":
description: Rule created.
content:
application/json:
schema:
$ref: "#/components/schemas/PolicyRule"
"400":
$ref: "#/components/responses/BadRequest"
"401":
$ref: "#/components/responses/Unauthorized"
"403":
$ref: "#/components/responses/Forbidden"
/v1/policy/rules/{id}:
parameters:
- name: id
in: path
required: true
schema:
type: integer
example: 1
get:
summary: Get policy rule (admin)
operationId: getPolicyRule
tags: [Admin — Policy]
security:
- bearerAuth: []
responses:
"200":
description: Policy rule.
content:
application/json:
schema:
$ref: "#/components/schemas/PolicyRule"
"401":
$ref: "#/components/responses/Unauthorized"
"403":
$ref: "#/components/responses/Forbidden"
"404":
$ref: "#/components/responses/NotFound"
patch:
summary: Update policy rule (admin)
description: |
Update one or more fields of an existing policy rule. All fields are
optional; omitted fields are left unchanged.
operationId: updatePolicyRule
tags: [Admin — Policy]
security:
- bearerAuth: []
requestBody:
required: true
content:
application/json:
schema:
type: object
properties:
description:
type: string
example: Updated description
priority:
type: integer
example: 75
enabled:
type: boolean
example: false
rule:
$ref: "#/components/schemas/RuleBody"
not_before:
type: string
format: date-time
description: Set earliest activation time (RFC3339).
example: "2026-04-01T00:00:00Z"
expires_at:
type: string
format: date-time
description: Set expiry time (RFC3339).
example: "2026-06-01T00:00:00Z"
clear_not_before:
type: boolean
description: Set to true to remove not_before constraint.
clear_expires_at:
type: boolean
description: Set to true to remove expires_at constraint.
responses:
"200":
description: Updated rule.
content:
application/json:
schema:
$ref: "#/components/schemas/PolicyRule"
"400":
$ref: "#/components/responses/BadRequest"
"401":
$ref: "#/components/responses/Unauthorized"
"403":
$ref: "#/components/responses/Forbidden"
"404":
$ref: "#/components/responses/NotFound"
delete:
summary: Delete policy rule (admin)
description: Permanently delete a policy rule. This action cannot be undone.
operationId: deletePolicyRule
tags: [Admin — Policy]
security:
- bearerAuth: []
responses:
"204":
description: Rule deleted.
"401":
$ref: "#/components/responses/Unauthorized"
"403":
$ref: "#/components/responses/Forbidden"
"404":
$ref: "#/components/responses/NotFound"
tags: tags:
- name: Public - name: Public
description: No authentication required. description: No authentication required.
@@ -1056,3 +1729,7 @@ tags:
description: Requires admin role. description: Requires admin role.
- name: Admin — Audit - name: Admin — Audit
description: Requires admin role. description: Requires admin role.
- name: Admin — Policy
description: Requires admin role. Manage policy rules and account tags.
- name: Admin — Vault
description: Requires admin role. Emergency vault seal operation.

View File

@@ -26,7 +26,7 @@
<div class="card"> <div class="card">
<div class="d-flex align-center justify-between" style="margin-bottom:1rem"> <div class="d-flex align-center justify-between" style="margin-bottom:1rem">
<h2 style="font-size:1rem;font-weight:600">Tokens</h2> <h2 style="font-size:1rem;font-weight:600">Tokens</h2>
{{if eq (string .Account.AccountType) "system"}} {{if and (eq (string .Account.AccountType) "system") .CanIssueToken}}
<button class="btn btn-sm btn-secondary" <button class="btn btn-sm btn-secondary"
hx-post="/accounts/{{.Account.UUID}}/token" hx-post="/accounts/{{.Account.UUID}}/token"
hx-target="#token-list" hx-swap="outerHTML">Issue Token</button> hx-target="#token-list" hx-swap="outerHTML">Issue Token</button>
@@ -39,6 +39,10 @@
<h2 style="font-size:1rem;font-weight:600;margin-bottom:1rem">Postgres Credentials</h2> <h2 style="font-size:1rem;font-weight:600;margin-bottom:1rem">Postgres Credentials</h2>
{{template "pgcreds_form" .}} {{template "pgcreds_form" .}}
</div> </div>
<div class="card">
<h2 style="font-size:1rem;font-weight:600;margin-bottom:1rem">Token Issue Access</h2>
<div id="token-delegates-section">{{template "token_delegates" .}}</div>
</div>
{{end}} {{end}}
<div class="card"> <div class="card">
<h2 style="font-size:1rem;font-weight:600;margin-bottom:1rem">Tags</h2> <h2 style="font-size:1rem;font-weight:600;margin-bottom:1rem">Tags</h2>

View File

@@ -15,7 +15,7 @@
{{if .IsAdmin}}<li><a href="/accounts">Accounts</a></li> {{if .IsAdmin}}<li><a href="/accounts">Accounts</a></li>
<li><a href="/audit">Audit</a></li> <li><a href="/audit">Audit</a></li>
<li><a href="/policies">Policies</a></li> <li><a href="/policies">Policies</a></li>
<li><a href="/pgcreds">PG Creds</a></li>{{end}} <li><a href="/pgcreds">PG Creds</a></li>{{else}}<li><a href="/service-accounts">Service Accounts</a></li>{{end}}
{{if .ActorName}}<li><a href="/profile">{{.ActorName}}</a></li>{{end}} {{if .ActorName}}<li><a href="/profile">{{.ActorName}}</a></li>{{end}}
<li><form method="POST" action="/logout" style="margin:0"><button class="btn btn-sm btn-secondary" type="submit">Logout</button></form></li> <li><form method="POST" action="/logout" style="margin:0"><button class="btn btn-sm btn-secondary" type="submit">Logout</button></form></li>
</ul> </ul>

View File

@@ -0,0 +1,47 @@
{{define "token_delegates"}}
<div id="token-delegates-section">
<h3 style="font-size:.9rem;font-weight:600;margin-bottom:.5rem">Token Issue Delegates</h3>
<p class="text-muted text-small" style="margin-bottom:.75rem">
Delegates can issue and rotate tokens for this service account without holding the admin role.
</p>
{{if .TokenDelegates}}
<table class="table table-sm" style="font-size:.85rem;margin-bottom:.75rem">
<thead>
<tr><th>Account</th><th>Granted</th><th></th></tr>
</thead>
<tbody>
{{range .TokenDelegates}}
<tr>
<td>{{.GranteeName}}</td>
<td class="text-small text-muted">{{formatTime .GrantedAt}}</td>
<td>
<button class="btn btn-sm btn-danger"
hx-delete="/accounts/{{$.Account.UUID}}/token/delegates/{{.GranteeUUID}}"
hx-target="#token-delegates-section" hx-swap="outerHTML"
hx-confirm="Remove delegate access for {{.GranteeName}}?">Remove</button>
</td>
</tr>
{{end}}
</tbody>
</table>
{{else}}
<p class="text-muted text-small" style="margin-bottom:.75rem">No delegates.</p>
{{end}}
{{if .DelegatableAccounts}}
<form hx-post="/accounts/{{.Account.UUID}}/token/delegates"
hx-target="#token-delegates-section" hx-swap="outerHTML"
style="display:flex;gap:.5rem;align-items:center">
<select class="form-control" name="grantee_uuid" required style="flex:1">
<option value="">— select account to add as delegate —</option>
{{range .DelegatableAccounts}}
{{if eq (string .AccountType) "human"}}
<option value="{{.UUID}}">{{.Username}}</option>
{{end}}
{{end}}
</select>
<button class="btn btn-sm btn-secondary" type="submit">Add Delegate</button>
</form>
{{end}}
</div>
{{end}}

View File

@@ -1,5 +1,16 @@
{{define "token_list"}} {{define "token_list"}}
<div id="token-list"> <div id="token-list">
{{if .Flash}}
<div class="alert alert-success" role="alert" style="margin-bottom:1rem">
{{.Flash}}
{{if .DownloadNonce}}
<div style="margin-top:.5rem">
<a class="btn btn-sm btn-secondary"
href="/token/download/{{.DownloadNonce}}">Download token as file</a>
</div>
{{end}}
</div>
{{end}}
{{if .Tokens}} {{if .Tokens}}
<div class="table-wrapper"> <div class="table-wrapper">
<table> <table>

View File

@@ -11,10 +11,10 @@
<div class="login-box"> <div class="login-box">
<div class="brand-heading">MCIAS</div> <div class="brand-heading">MCIAS</div>
<div class="brand-subtitle">Metacircular Identity &amp; Access System</div> <div class="brand-subtitle">Metacircular Identity &amp; Access System</div>
<div class="card"> <div class="card" id="login-card">
{{if .Error}}<div class="alert alert-error" role="alert">{{.Error}}</div>{{end}} {{if .Error}}<div class="alert alert-error" role="alert">{{.Error}}</div>{{end}}
<form id="login-form" method="POST" action="/login" <form id="login-form" method="POST" action="/login"
hx-post="/login" hx-target="#login-form" hx-swap="outerHTML"> hx-post="/login" hx-target="#login-card" hx-swap="outerHTML" hx-select="#login-card">
<div class="form-group"> <div class="form-group">
<label for="username">Username</label> <label for="username">Username</label>
<input class="form-control" type="text" id="username" name="username" <input class="form-control" type="text" id="username" name="username"

View File

@@ -0,0 +1,47 @@
{{define "service_accounts"}}{{template "base" .}}{{end}}
{{define "title"}}Service Accounts — MCIAS{{end}}
{{define "content"}}
<div class="page-header">
<h1>Service Accounts</h1>
<p class="text-muted text-small">Service accounts for which you have been granted token-issue access.</p>
</div>
{{if .DownloadNonce}}
<div class="alert alert-success" role="alert" style="margin-bottom:1rem">
Token issued.
<a class="btn btn-sm btn-secondary" style="margin-left:.5rem"
href="/token/download/{{.DownloadNonce}}">Download token as file</a>
</div>
{{end}}
{{if .Accounts}}
<div class="card">
<table>
<thead>
<tr><th>Name</th><th>Status</th><th>Action</th></tr>
</thead>
<tbody>
{{range .Accounts}}
<tr>
<td>{{.Username}}</td>
<td><span class="badge badge-{{string .Status}}">{{string .Status}}</span></td>
<td>
<button class="btn btn-sm btn-secondary"
hx-post="/accounts/{{.UUID}}/token"
hx-target="#issue-result-{{.UUID}}"
hx-swap="outerHTML">Issue Token</button>
</td>
</tr>
<tr>
<td colspan="3">
<div id="issue-result-{{.UUID}}"></div>
</td>
</tr>
{{end}}
</tbody>
</table>
</div>
{{else}}
<div class="card">
<p class="text-muted text-small">You have not been granted access to any service accounts.</p>
</div>
{{end}}
{{end}}