Add policy-based authz and token delegation

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

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

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-15 14:40:16 -07:00
parent d6cc82755d
commit d4e8ef90ee
14 changed files with 1066 additions and 77 deletions

View File

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

View File

@@ -1397,3 +1397,116 @@ func (db *DB) Rekey(newSalt, newSigningKeyEnc, newSigningKeyNonce []byte, totpRo
} }
return nil return nil
} }
// GrantTokenIssueAccess records that granteeID may issue tokens for the system
// account identified by accountID. Idempotent: a second call for the same
// (account, grantee) pair is silently ignored via INSERT OR IGNORE.
func (db *DB) GrantTokenIssueAccess(accountID, granteeID int64, grantedBy *int64) error {
_, err := db.sql.Exec(`
INSERT OR IGNORE INTO service_account_delegates
(account_id, grantee_id, granted_by, granted_at)
VALUES (?, ?, ?, ?)
`, accountID, granteeID, grantedBy, now())
if err != nil {
return fmt.Errorf("db: grant token issue access: %w", err)
}
return nil
}
// RevokeTokenIssueAccess removes the delegate grant for granteeID on accountID.
// Returns ErrNotFound if no such grant exists.
func (db *DB) RevokeTokenIssueAccess(accountID, granteeID int64) error {
result, err := db.sql.Exec(`
DELETE FROM service_account_delegates
WHERE account_id = ? AND grantee_id = ?
`, accountID, granteeID)
if err != nil {
return fmt.Errorf("db: revoke token issue access: %w", err)
}
n, err := result.RowsAffected()
if err != nil {
return fmt.Errorf("db: revoke token issue access rows: %w", err)
}
if n == 0 {
return ErrNotFound
}
return nil
}
// ListTokenIssueDelegates returns all delegate grants for the given system account.
func (db *DB) ListTokenIssueDelegates(accountID int64) ([]*model.ServiceAccountDelegate, error) {
rows, err := db.sql.Query(`
SELECT d.id, d.account_id, d.grantee_id, d.granted_by, d.granted_at,
a.uuid, a.username
FROM service_account_delegates d
JOIN accounts a ON a.id = d.grantee_id
WHERE d.account_id = ?
ORDER BY d.granted_at ASC
`, accountID)
if err != nil {
return nil, fmt.Errorf("db: list token issue delegates: %w", err)
}
defer func() { _ = rows.Close() }()
var out []*model.ServiceAccountDelegate
for rows.Next() {
var d model.ServiceAccountDelegate
var grantedAt string
if err := rows.Scan(
&d.ID, &d.AccountID, &d.GranteeID, &d.GrantedBy, &grantedAt,
&d.GranteeUUID, &d.GranteeName,
); err != nil {
return nil, fmt.Errorf("db: scan token issue delegate: %w", err)
}
t, err := parseTime(grantedAt)
if err != nil {
return nil, err
}
d.GrantedAt = t
out = append(out, &d)
}
return out, rows.Err()
}
// HasTokenIssueAccess reports whether actorID has been granted permission to
// issue tokens for the system account identified by accountID.
func (db *DB) HasTokenIssueAccess(accountID, actorID int64) (bool, error) {
var count int
err := db.sql.QueryRow(`
SELECT COUNT(1) FROM service_account_delegates
WHERE account_id = ? AND grantee_id = ?
`, accountID, actorID).Scan(&count)
if err != nil {
return false, fmt.Errorf("db: has token issue access: %w", err)
}
return count > 0, nil
}
// ListDelegatedServiceAccounts returns system accounts for which actorID has
// been granted token-issue access.
func (db *DB) ListDelegatedServiceAccounts(actorID int64) ([]*model.Account, error) {
rows, err := db.sql.Query(`
SELECT a.id, a.uuid, a.username, a.account_type, COALESCE(a.password_hash,''),
a.status, a.totp_required,
a.totp_secret_enc, a.totp_secret_nonce,
a.created_at, a.updated_at, a.deleted_at
FROM service_account_delegates d
JOIN accounts a ON a.id = d.account_id
WHERE d.grantee_id = ? AND a.status != 'deleted'
ORDER BY a.username ASC
`, actorID)
if err != nil {
return nil, fmt.Errorf("db: list delegated service accounts: %w", err)
}
defer func() { _ = rows.Close() }()
var out []*model.Account
for rows.Next() {
a, err := db.scanAccountRow(rows)
if err != nil {
return nil, err
}
out = append(out, a)
}
return out, rows.Err()
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -182,6 +182,21 @@ 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,
@@ -193,6 +208,9 @@ func (u *UIServer) handleAccountDetail(w http.ResponseWriter, r *http.Request) {
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,
Flash: fmt.Sprintf("Token issued. Copy now — it will not be shown again: %s", tokenStr),
},
Account: acct, Account: acct,
Tokens: tokens, 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,
}) })
} }

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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}}