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