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:
@@ -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);
|
||||
Reference in New Issue
Block a user