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

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