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

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