Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 9657f18784 | |||
| d4e8ef90ee | |||
| d6cc82755d | |||
| 0d38bbae00 | |||
| 23a27be57e |
37
PROGRESS.md
37
PROGRESS.md
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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"`
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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
154
cmd/mciasdb/rekey.go
Normal 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.")
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
@@ -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.
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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))
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
74
openapi.yaml
74
openapi.yaml
@@ -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)
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
47
web/templates/fragments/token_delegates.html
Normal file
47
web/templates/fragments/token_delegates.html
Normal 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}}
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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 & Access System</div>
|
<div class="brand-subtitle">Metacircular Identity & 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"
|
||||||
|
|||||||
47
web/templates/service_accounts.html
Normal file
47
web/templates/service_accounts.html
Normal 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}}
|
||||||
Reference in New Issue
Block a user