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:
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user