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

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