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:
37
PROGRESS.md
37
PROGRESS.md
@@ -2,7 +2,42 @@
|
||||
|
||||
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
|
||||
|
||||
|
||||
@@ -1397,3 +1397,116 @@ func (db *DB) Rekey(newSalt, newSigningKeyEnc, newSigningKeyNonce []byte, totpRo
|
||||
}
|
||||
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()
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
@@ -210,8 +210,25 @@ const (
|
||||
EventPGCredAccessRevoked = "pgcred_access_revoked" //nolint:gosec // G101: audit event type, not a credential
|
||||
|
||||
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.
|
||||
// RuleJSON holds a JSON-encoded policy.RuleBody (all match and effect fields).
|
||||
// The ID, Priority, and Description are stored as dedicated columns.
|
||||
|
||||
@@ -217,6 +217,9 @@ func (s *Server) handleCreatePolicyRule(w http.ResponseWriter, r *http.Request)
|
||||
s.writeAudit(r, model.EventPolicyRuleCreated, createdBy, nil,
|
||||
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)
|
||||
if err != nil {
|
||||
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,
|
||||
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)
|
||||
if err != nil {
|
||||
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,
|
||||
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)
|
||||
}
|
||||
|
||||
|
||||
@@ -13,6 +13,7 @@ import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"io/fs"
|
||||
"log/slog"
|
||||
"net"
|
||||
@@ -27,6 +28,7 @@ import (
|
||||
"git.wntrmute.dev/kyle/mcias/internal/db"
|
||||
"git.wntrmute.dev/kyle/mcias/internal/middleware"
|
||||
"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/ui"
|
||||
"git.wntrmute.dev/kyle/mcias/internal/validate"
|
||||
@@ -40,15 +42,154 @@ type Server struct {
|
||||
cfg *config.Config
|
||||
logger *slog.Logger
|
||||
vault *vault.Vault
|
||||
polEng *policy.Engine
|
||||
}
|
||||
|
||||
// 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 {
|
||||
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{
|
||||
db: database,
|
||||
cfg: cfg,
|
||||
vault: v,
|
||||
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.
|
||||
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/renew", requireAuth(http.HandlerFunc(s.handleRenew)))
|
||||
mux.Handle("POST /v1/auth/totp/enroll", requireAuth(http.HandlerFunc(s.handleTOTPEnroll)))
|
||||
mux.Handle("POST /v1/auth/totp/confirm", requireAuth(http.HandlerFunc(s.handleTOTPConfirm)))
|
||||
|
||||
// Admin-only endpoints.
|
||||
mux.Handle("DELETE /v1/auth/totp", requireAdmin(http.HandlerFunc(s.handleTOTPRemove)))
|
||||
mux.Handle("POST /v1/token/issue", requireAdmin(http.HandlerFunc(s.handleTokenIssue)))
|
||||
mux.Handle("DELETE /v1/token/{jti}", requireAdmin(http.HandlerFunc(s.handleTokenRevoke)))
|
||||
mux.Handle("GET /v1/accounts", requireAdmin(http.HandlerFunc(s.handleListAccounts)))
|
||||
mux.Handle("POST /v1/accounts", requireAdmin(http.HandlerFunc(s.handleCreateAccount)))
|
||||
mux.Handle("GET /v1/accounts/{id}", requireAdmin(http.HandlerFunc(s.handleGetAccount)))
|
||||
mux.Handle("PATCH /v1/accounts/{id}", requireAdmin(http.HandlerFunc(s.handleUpdateAccount)))
|
||||
mux.Handle("DELETE /v1/accounts/{id}", requireAdmin(http.HandlerFunc(s.handleDeleteAccount)))
|
||||
mux.Handle("GET /v1/accounts/{id}/roles", requireAdmin(http.HandlerFunc(s.handleGetRoles)))
|
||||
mux.Handle("PUT /v1/accounts/{id}/roles", requireAdmin(http.HandlerFunc(s.handleSetRoles)))
|
||||
mux.Handle("POST /v1/accounts/{id}/roles", requireAdmin(http.HandlerFunc(s.handleGrantRole)))
|
||||
mux.Handle("DELETE /v1/accounts/{id}/roles/{role}", requireAdmin(http.HandlerFunc(s.handleRevokeRole)))
|
||||
// Policy-gated endpoints (formerly admin-only; now controlled by the engine).
|
||||
mux.Handle("DELETE /v1/auth/totp",
|
||||
requirePolicy(policy.ActionRemoveTOTP, policy.ResourceTOTP, buildAcct)(http.HandlerFunc(s.handleTOTPRemove)))
|
||||
mux.Handle("POST /v1/token/issue",
|
||||
requirePolicy(policy.ActionIssueToken, policy.ResourceToken, buildToken)(http.HandlerFunc(s.handleTokenIssue)))
|
||||
mux.Handle("DELETE /v1/token/{jti}",
|
||||
requirePolicy(policy.ActionRevokeToken, policy.ResourceToken, buildJTI)(http.HandlerFunc(s.handleTokenRevoke)))
|
||||
mux.Handle("GET /v1/accounts",
|
||||
requirePolicy(policy.ActionListAccounts, policy.ResourceAccount, nil)(http.HandlerFunc(s.handleListAccounts)))
|
||||
mux.Handle("POST /v1/accounts",
|
||||
requirePolicy(policy.ActionCreateAccount, policy.ResourceAccount, nil)(http.HandlerFunc(s.handleCreateAccount)))
|
||||
mux.Handle("GET /v1/accounts/{id}",
|
||||
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/accounts/{id}/pgcreds", requireAdmin(http.HandlerFunc(s.handleGetPGCreds)))
|
||||
mux.Handle("PUT /v1/accounts/{id}/pgcreds", requireAdmin(http.HandlerFunc(s.handleSetPGCreds)))
|
||||
mux.Handle("GET /v1/audit", requireAdmin(http.HandlerFunc(s.handleListAudit)))
|
||||
mux.Handle("GET /v1/accounts/{id}/tags", requireAdmin(http.HandlerFunc(s.handleGetTags)))
|
||||
mux.Handle("PUT /v1/accounts/{id}/tags", requireAdmin(http.HandlerFunc(s.handleSetTags)))
|
||||
mux.Handle("PUT /v1/accounts/{id}/password", requireAdmin(http.HandlerFunc(s.handleAdminSetPassword)))
|
||||
mux.Handle("GET /v1/accounts/{id}/pgcreds",
|
||||
requirePolicy(policy.ActionReadPGCreds, policy.ResourcePGCreds, buildAcct)(http.HandlerFunc(s.handleGetPGCreds)))
|
||||
mux.Handle("PUT /v1/accounts/{id}/pgcreds",
|
||||
requirePolicy(policy.ActionWritePGCreds, policy.ResourcePGCreds, buildAcct)(http.HandlerFunc(s.handleSetPGCreds)))
|
||||
mux.Handle("GET /v1/audit",
|
||||
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).
|
||||
mux.Handle("PUT /v1/auth/password", requireAuth(http.HandlerFunc(s.handleChangePassword)))
|
||||
mux.Handle("GET /v1/policy/rules", requireAdmin(http.HandlerFunc(s.handleListPolicyRules)))
|
||||
mux.Handle("POST /v1/policy/rules", requireAdmin(http.HandlerFunc(s.handleCreatePolicyRule)))
|
||||
mux.Handle("GET /v1/policy/rules/{id}", requireAdmin(http.HandlerFunc(s.handleGetPolicyRule)))
|
||||
mux.Handle("PATCH /v1/policy/rules/{id}", requireAdmin(http.HandlerFunc(s.handleUpdatePolicyRule)))
|
||||
mux.Handle("DELETE /v1/policy/rules/{id}", requireAdmin(http.HandlerFunc(s.handleDeletePolicyRule)))
|
||||
mux.Handle("GET /v1/policy/rules",
|
||||
requirePolicy(policy.ActionListRules, policy.ResourcePolicy, nil)(http.HandlerFunc(s.handleListPolicyRules)))
|
||||
mux.Handle("POST /v1/policy/rules",
|
||||
requirePolicy(policy.ActionManageRules, policy.ResourcePolicy, nil)(http.HandlerFunc(s.handleCreatePolicyRule)))
|
||||
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).
|
||||
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 {
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
ID int64 `json:"id"`
|
||||
Port int `json:"port"`
|
||||
Host string `json:"host"`
|
||||
Database string `json:"database"`
|
||||
Username string `json:"username"`
|
||||
ServiceAccountID string `json:"service_account_id"`
|
||||
ServiceAccountName string `json:"service_account_name,omitempty"`
|
||||
ID int64 `json:"id"`
|
||||
Port int `json:"port"`
|
||||
}
|
||||
|
||||
response := make([]pgCredResponse, len(creds))
|
||||
|
||||
@@ -2,10 +2,10 @@ package server
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/hmac"
|
||||
"crypto/sha1"
|
||||
"crypto/ed25519"
|
||||
"crypto/hmac"
|
||||
"crypto/rand"
|
||||
"crypto/sha1" //nolint:gosec // G505: SHA1 required by RFC 6238 TOTP (HMAC-SHA1)
|
||||
"encoding/binary"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
@@ -23,6 +23,7 @@ import (
|
||||
"git.wntrmute.dev/kyle/mcias/internal/config"
|
||||
"git.wntrmute.dev/kyle/mcias/internal/db"
|
||||
"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/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)")
|
||||
}
|
||||
}
|
||||
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -26,7 +26,7 @@
|
||||
<div class="card">
|
||||
<div class="d-flex align-center justify-between" style="margin-bottom:1rem">
|
||||
<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"
|
||||
hx-post="/accounts/{{.Account.UUID}}/token"
|
||||
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>
|
||||
{{template "pgcreds_form" .}}
|
||||
</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}}
|
||||
<div class="card">
|
||||
<h2 style="font-size:1rem;font-weight:600;margin-bottom:1rem">Tags</h2>
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
{{if .IsAdmin}}<li><a href="/accounts">Accounts</a></li>
|
||||
<li><a href="/audit">Audit</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}}
|
||||
<li><form method="POST" action="/logout" style="margin:0"><button class="btn btn-sm btn-secondary" type="submit">Logout</button></form></li>
|
||||
</ul>
|
||||
|
||||
47
web/templates/fragments/token_delegates.html
Normal file
47
web/templates/fragments/token_delegates.html
Normal 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}}
|
||||
@@ -1,5 +1,16 @@
|
||||
{{define "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}}
|
||||
<div class="table-wrapper">
|
||||
<table>
|
||||
|
||||
47
web/templates/service_accounts.html
Normal file
47
web/templates/service_accounts.html
Normal 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}}
|
||||
Reference in New Issue
Block a user