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

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

View File

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