From d4e8ef90ee19f7cf15a8d5716881ab832696e59b Mon Sep 17 00:00:00 2001 From: Kyle Isom Date: Sun, 15 Mar 2026 14:40:16 -0700 Subject: [PATCH] Add policy-based authz and token delegation - Replace requireAdmin (role-based) guards on all REST endpoints with RequirePolicy middleware backed by the existing policy engine; built-in admin wildcard rule (-1) preserves existing admin behaviour while operator rules can now grant targeted access to non-admin accounts (e.g. a system account allowed to list accounts) - Wire policy engine into Server: loaded from DB at startup, reloaded after every policy-rule create/update/delete so changes take effect immediately without a server restart - Add service_account_delegates table (migration 000008) so a human account can be delegated permission to issue tokens for a specific system account without holding the admin role - Add token-download nonce mechanism: a short-lived (5 min), single-use random nonce is stored server-side after token issuance; the browser downloads the token as a file via GET /token/download/{nonce} (Content-Disposition: attachment) instead of copying from a flash message - Add /service-accounts UI page for non-admin delegates - Add TestPolicyEnforcement and TestPolicyDenyRule integration tests Security: - Policy engine uses deny-wins, default-deny semantics; admin wildcard is a compiled-in built-in and cannot be deleted via the API - Token download nonces are 128-bit crypto/rand values, single-use, and expire after 5 minutes; a background goroutine evicts stale entries - alg header validation and Ed25519 signing unchanged Co-Authored-By: Claude Sonnet 4.6 --- PROGRESS.md | 37 ++- internal/db/accounts.go | 113 +++++++ .../000008_service_account_delegates.up.sql | 15 + internal/model/model.go | 17 + internal/server/handlers_policy.go | 9 + internal/server/server.go | 247 +++++++++++++-- internal/server/server_test.go | 151 ++++++++- internal/ui/handlers_accounts.go | 298 ++++++++++++++++-- internal/ui/ui.go | 143 +++++++-- web/templates/account_detail.html | 6 +- web/templates/base.html | 2 +- web/templates/fragments/token_delegates.html | 47 +++ web/templates/fragments/token_list.html | 11 + web/templates/service_accounts.html | 47 +++ 14 files changed, 1066 insertions(+), 77 deletions(-) create mode 100644 internal/db/migrations/000008_service_account_delegates.up.sql create mode 100644 web/templates/fragments/token_delegates.html create mode 100644 web/templates/service_accounts.html diff --git a/PROGRESS.md b/PROGRESS.md index 9f7c285..9e8df2f 100644 --- a/PROGRESS.md +++ b/PROGRESS.md @@ -2,7 +2,42 @@ 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 diff --git a/internal/db/accounts.go b/internal/db/accounts.go index 7f3431b..81309b8 100644 --- a/internal/db/accounts.go +++ b/internal/db/accounts.go @@ -1397,3 +1397,116 @@ func (db *DB) Rekey(newSalt, newSigningKeyEnc, newSigningKeyNonce []byte, totpRo } 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() +} diff --git a/internal/db/migrations/000008_service_account_delegates.up.sql b/internal/db/migrations/000008_service_account_delegates.up.sql new file mode 100644 index 0000000..be9e7b5 --- /dev/null +++ b/internal/db/migrations/000008_service_account_delegates.up.sql @@ -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); diff --git a/internal/model/model.go b/internal/model/model.go index b96faad..0882999 100644 --- a/internal/model/model.go +++ b/internal/model/model.go @@ -210,8 +210,25 @@ const ( EventPGCredAccessRevoked = "pgcred_access_revoked" //nolint:gosec // G101: audit event type, not a credential 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. // RuleJSON holds a JSON-encoded policy.RuleBody (all match and effect fields). // The ID, Priority, and Description are stored as dedicated columns. diff --git a/internal/server/handlers_policy.go b/internal/server/handlers_policy.go index 1857856..86a000f 100644 --- a/internal/server/handlers_policy.go +++ b/internal/server/handlers_policy.go @@ -217,6 +217,9 @@ func (s *Server) handleCreatePolicyRule(w http.ResponseWriter, r *http.Request) s.writeAudit(r, model.EventPolicyRuleCreated, createdBy, nil, 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) if err != nil { 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, 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) if err != nil { 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, 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) } diff --git a/internal/server/server.go b/internal/server/server.go index 2404c84..ca2207d 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -13,6 +13,7 @@ import ( "encoding/json" "errors" "fmt" + "io" "io/fs" "log/slog" "net" @@ -27,6 +28,7 @@ import ( "git.wntrmute.dev/kyle/mcias/internal/db" "git.wntrmute.dev/kyle/mcias/internal/middleware" "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/ui" "git.wntrmute.dev/kyle/mcias/internal/validate" @@ -40,15 +42,154 @@ type Server struct { cfg *config.Config logger *slog.Logger vault *vault.Vault + polEng *policy.Engine } // 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 { + 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{ db: database, cfg: cfg, vault: v, 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. 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/renew", requireAuth(http.HandlerFunc(s.handleRenew))) mux.Handle("POST /v1/auth/totp/enroll", requireAuth(http.HandlerFunc(s.handleTOTPEnroll))) mux.Handle("POST /v1/auth/totp/confirm", requireAuth(http.HandlerFunc(s.handleTOTPConfirm))) - // Admin-only endpoints. - mux.Handle("DELETE /v1/auth/totp", requireAdmin(http.HandlerFunc(s.handleTOTPRemove))) - mux.Handle("POST /v1/token/issue", requireAdmin(http.HandlerFunc(s.handleTokenIssue))) - mux.Handle("DELETE /v1/token/{jti}", requireAdmin(http.HandlerFunc(s.handleTokenRevoke))) - mux.Handle("GET /v1/accounts", requireAdmin(http.HandlerFunc(s.handleListAccounts))) - mux.Handle("POST /v1/accounts", requireAdmin(http.HandlerFunc(s.handleCreateAccount))) - mux.Handle("GET /v1/accounts/{id}", requireAdmin(http.HandlerFunc(s.handleGetAccount))) - mux.Handle("PATCH /v1/accounts/{id}", requireAdmin(http.HandlerFunc(s.handleUpdateAccount))) - mux.Handle("DELETE /v1/accounts/{id}", requireAdmin(http.HandlerFunc(s.handleDeleteAccount))) - mux.Handle("GET /v1/accounts/{id}/roles", requireAdmin(http.HandlerFunc(s.handleGetRoles))) - mux.Handle("PUT /v1/accounts/{id}/roles", requireAdmin(http.HandlerFunc(s.handleSetRoles))) - mux.Handle("POST /v1/accounts/{id}/roles", requireAdmin(http.HandlerFunc(s.handleGrantRole))) - mux.Handle("DELETE /v1/accounts/{id}/roles/{role}", requireAdmin(http.HandlerFunc(s.handleRevokeRole))) + // Policy-gated endpoints (formerly admin-only; now controlled by the engine). + mux.Handle("DELETE /v1/auth/totp", + requirePolicy(policy.ActionRemoveTOTP, policy.ResourceTOTP, buildAcct)(http.HandlerFunc(s.handleTOTPRemove))) + mux.Handle("POST /v1/token/issue", + requirePolicy(policy.ActionIssueToken, policy.ResourceToken, buildToken)(http.HandlerFunc(s.handleTokenIssue))) + mux.Handle("DELETE /v1/token/{jti}", + requirePolicy(policy.ActionRevokeToken, policy.ResourceToken, buildJTI)(http.HandlerFunc(s.handleTokenRevoke))) + mux.Handle("GET /v1/accounts", + requirePolicy(policy.ActionListAccounts, policy.ResourceAccount, nil)(http.HandlerFunc(s.handleListAccounts))) + mux.Handle("POST /v1/accounts", + requirePolicy(policy.ActionCreateAccount, policy.ResourceAccount, nil)(http.HandlerFunc(s.handleCreateAccount))) + mux.Handle("GET /v1/accounts/{id}", + 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/accounts/{id}/pgcreds", requireAdmin(http.HandlerFunc(s.handleGetPGCreds))) - mux.Handle("PUT /v1/accounts/{id}/pgcreds", requireAdmin(http.HandlerFunc(s.handleSetPGCreds))) - mux.Handle("GET /v1/audit", requireAdmin(http.HandlerFunc(s.handleListAudit))) - mux.Handle("GET /v1/accounts/{id}/tags", requireAdmin(http.HandlerFunc(s.handleGetTags))) - mux.Handle("PUT /v1/accounts/{id}/tags", requireAdmin(http.HandlerFunc(s.handleSetTags))) - mux.Handle("PUT /v1/accounts/{id}/password", requireAdmin(http.HandlerFunc(s.handleAdminSetPassword))) + mux.Handle("GET /v1/accounts/{id}/pgcreds", + requirePolicy(policy.ActionReadPGCreds, policy.ResourcePGCreds, buildAcct)(http.HandlerFunc(s.handleGetPGCreds))) + mux.Handle("PUT /v1/accounts/{id}/pgcreds", + requirePolicy(policy.ActionWritePGCreds, policy.ResourcePGCreds, buildAcct)(http.HandlerFunc(s.handleSetPGCreds))) + mux.Handle("GET /v1/audit", + 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). mux.Handle("PUT /v1/auth/password", requireAuth(http.HandlerFunc(s.handleChangePassword))) - mux.Handle("GET /v1/policy/rules", requireAdmin(http.HandlerFunc(s.handleListPolicyRules))) - mux.Handle("POST /v1/policy/rules", requireAdmin(http.HandlerFunc(s.handleCreatePolicyRule))) - mux.Handle("GET /v1/policy/rules/{id}", requireAdmin(http.HandlerFunc(s.handleGetPolicyRule))) - mux.Handle("PATCH /v1/policy/rules/{id}", requireAdmin(http.HandlerFunc(s.handleUpdatePolicyRule))) - mux.Handle("DELETE /v1/policy/rules/{id}", requireAdmin(http.HandlerFunc(s.handleDeletePolicyRule))) + mux.Handle("GET /v1/policy/rules", + requirePolicy(policy.ActionListRules, policy.ResourcePolicy, nil)(http.HandlerFunc(s.handleListPolicyRules))) + mux.Handle("POST /v1/policy/rules", + requirePolicy(policy.ActionManageRules, policy.ResourcePolicy, nil)(http.HandlerFunc(s.handleCreatePolicyRule))) + 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). uiSrv, err := ui.New(s.db, s.cfg, s.vault, s.logger) @@ -1320,13 +1509,13 @@ func (s *Server) handleListAccessiblePGCreds(w http.ResponseWriter, r *http.Requ type pgCredResponse struct { CreatedAt time.Time `json:"created_at"` UpdatedAt time.Time `json:"updated_at"` - ID int64 `json:"id"` - Port int `json:"port"` Host string `json:"host"` Database string `json:"database"` Username string `json:"username"` ServiceAccountID string `json:"service_account_id"` ServiceAccountName string `json:"service_account_name,omitempty"` + ID int64 `json:"id"` + Port int `json:"port"` } response := make([]pgCredResponse, len(creds)) diff --git a/internal/server/server_test.go b/internal/server/server_test.go index 910c8b0..2690209 100644 --- a/internal/server/server_test.go +++ b/internal/server/server_test.go @@ -2,10 +2,10 @@ package server import ( "bytes" - "crypto/hmac" - "crypto/sha1" "crypto/ed25519" + "crypto/hmac" "crypto/rand" + "crypto/sha1" //nolint:gosec // G505: SHA1 required by RFC 6238 TOTP (HMAC-SHA1) "encoding/binary" "encoding/json" "fmt" @@ -23,6 +23,7 @@ import ( "git.wntrmute.dev/kyle/mcias/internal/config" "git.wntrmute.dev/kyle/mcias/internal/db" "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/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)") } } + +// 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) + } +} diff --git a/internal/ui/handlers_accounts.go b/internal/ui/handlers_accounts.go index c832d78..e81ffa0 100644 --- a/internal/ui/handlers_accounts.go +++ b/internal/ui/handlers_accounts.go @@ -182,17 +182,35 @@ func (u *UIServer) handleAccountDetail(w http.ResponseWriter, r *http.Request) { 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{ - PageData: PageData{CSRFToken: csrfToken, ActorName: u.actorName(r), IsAdmin: isAdmin(r)}, - Account: acct, - Roles: roles, - AllRoles: knownRoles, - Tokens: tokens, - PGCred: pgCred, - PGCredGrants: pgCredGrants, - GrantableAccounts: grantableAccounts, - ActorID: actorID, - Tags: tags, + PageData: PageData{CSRFToken: csrfToken, ActorName: u.actorName(r), IsAdmin: isAdmin(r)}, + Account: acct, + Roles: roles, + AllRoles: knownRoles, + Tokens: tokens, + PGCred: pgCred, + PGCredGrants: pgCredGrants, + GrantableAccounts: grantableAccounts, + ActorID: actorID, + 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. +// 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) { id := r.PathValue("id") acct, err := u.db.GetAccountByUUID(id) @@ -1021,6 +1046,32 @@ func (u *UIServer) handleIssueSystemToken(w http.ResponseWriter, r *http.Request 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) if err != nil { 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) } - 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, 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. tokens, err := u.db.ListTokensForAccount(acct.ID) if err != nil { @@ -1077,13 +1129,209 @@ func (u *UIServer) handleIssueSystemToken(w http.ResponseWriter, r *http.Request 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{ - PageData: PageData{ - CSRFToken: csrfToken, - Flash: fmt.Sprintf("Token issued. Copy now — it will not be shown again: %s", tokenStr), - }, - Account: acct, - Tokens: tokens, + PageData: PageData{CSRFToken: csrfToken, Flash: flash}, + Account: acct, + Tokens: tokens, + DownloadNonce: downloadNonce, + }) +} + +// 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, }) } diff --git a/internal/ui/ui.go b/internal/ui/ui.go index 8ea9e60..1632dae 100644 --- a/internal/ui/ui.go +++ b/internal/ui/ui.go @@ -54,15 +54,31 @@ type pendingLogin struct { 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. type UIServer struct { - pendingLogins sync.Map // nonce (string) → *pendingLogin - tmpls map[string]*template.Template // page name → template set - db *db.DB - cfg *config.Config - logger *slog.Logger - csrf *CSRFManager - vault *vault.Vault + tmpls map[string]*template.Template // page name → template set + db *db.DB + cfg *config.Config + logger *slog.Logger + csrf *CSRFManager + 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 @@ -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/password_reset_form.html", "templates/fragments/password_change_form.html", + "templates/fragments/token_delegates.html", } base, err := template.New("").Funcs(funcMap).ParseFS(web.TemplateFS, sharedFiles...) 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 // into separate clones prevents the last-defined block from winning. pageFiles := map[string]string{ - "login": "templates/login.html", - "dashboard": "templates/dashboard.html", - "accounts": "templates/accounts.html", - "account_detail": "templates/account_detail.html", - "audit": "templates/audit.html", - "audit_detail": "templates/audit_detail.html", - "policies": "templates/policies.html", - "pgcreds": "templates/pgcreds.html", - "profile": "templates/profile.html", - "unseal": "templates/unseal.html", + "login": "templates/login.html", + "dashboard": "templates/dashboard.html", + "accounts": "templates/accounts.html", + "account_detail": "templates/account_detail.html", + "audit": "templates/audit.html", + "audit_detail": "templates/audit_detail.html", + "policies": "templates/policies.html", + "pgcreds": "templates/pgcreds.html", + "profile": "templates/profile.html", + "unseal": "templates/unseal.html", + "service_accounts": "templates/service_accounts.html", } tmpls := make(map[string]*template.Template, len(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 // accumulate indefinitely, enabling a memory-exhaustion attack. go srv.cleanupPendingLogins() + go srv.cleanupTokenDownloads() 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. // All UI responses (pages, fragments, redirects, static assets) carry the // 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("PUT /accounts/{id}/roles", admin(u.handleSetRoles)) 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("POST /accounts/{id}/pgcreds/access", admin(u.handleGrantPGCredAccess)) 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}/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). uiMux.Handle("GET /profile", authed(http.HandlerFunc(u.handleProfilePage))) 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 // to decide whether to show the owner-only management controls. 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 Roles []string AllRoles []string Tags []string 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. diff --git a/web/templates/account_detail.html b/web/templates/account_detail.html index 9f04935..70a06a8 100644 --- a/web/templates/account_detail.html +++ b/web/templates/account_detail.html @@ -26,7 +26,7 @@

Tokens

- {{if eq (string .Account.AccountType) "system"}} + {{if and (eq (string .Account.AccountType) "system") .CanIssueToken}} @@ -39,6 +39,10 @@

Postgres Credentials

{{template "pgcreds_form" .}}
+
+

Token Issue Access

+
{{template "token_delegates" .}}
+
{{end}}

Tags

diff --git a/web/templates/base.html b/web/templates/base.html index 60b62df..d2b3926 100644 --- a/web/templates/base.html +++ b/web/templates/base.html @@ -15,7 +15,7 @@ {{if .IsAdmin}}
  • Accounts
  • Audit
  • Policies
  • -
  • PG Creds
  • {{end}} +
  • PG Creds
  • {{else}}
  • Service Accounts
  • {{end}} {{if .ActorName}}
  • {{.ActorName}}
  • {{end}}
  • diff --git a/web/templates/fragments/token_delegates.html b/web/templates/fragments/token_delegates.html new file mode 100644 index 0000000..27d3400 --- /dev/null +++ b/web/templates/fragments/token_delegates.html @@ -0,0 +1,47 @@ +{{define "token_delegates"}} +
    +

    Token Issue Delegates

    +

    + Delegates can issue and rotate tokens for this service account without holding the admin role. +

    + {{if .TokenDelegates}} + + + + + + {{range .TokenDelegates}} + + + + + + {{end}} + +
    AccountGranted
    {{.GranteeName}}{{formatTime .GrantedAt}} + +
    + {{else}} +

    No delegates.

    + {{end}} + + {{if .DelegatableAccounts}} +
    + + +
    + {{end}} +
    +{{end}} diff --git a/web/templates/fragments/token_list.html b/web/templates/fragments/token_list.html index eb57cf7..9cfa62c 100644 --- a/web/templates/fragments/token_list.html +++ b/web/templates/fragments/token_list.html @@ -1,5 +1,16 @@ {{define "token_list"}}
    + {{if .Flash}} + + {{end}} {{if .Tokens}}
    diff --git a/web/templates/service_accounts.html b/web/templates/service_accounts.html new file mode 100644 index 0000000..b84efb1 --- /dev/null +++ b/web/templates/service_accounts.html @@ -0,0 +1,47 @@ +{{define "service_accounts"}}{{template "base" .}}{{end}} +{{define "title"}}Service Accounts — MCIAS{{end}} +{{define "content"}} + +{{if .DownloadNonce}} + +{{end}} +{{if .Accounts}} +
    +
    + + + + + {{range .Accounts}} + + + + + + + + + {{end}} + +
    NameStatusAction
    {{.Username}}{{string .Status}} + +
    +
    +
    +
    +{{else}} +
    +

    You have not been granted access to any service accounts.

    +
    +{{end}} +{{end}}