fix(auth): apply rbacGate to every state-changing + read handler (CRIT-1 closure)

Closes the wire-layer authorization gap surfaced by the 2026-05-10 audit
(CRIT-1). Before this commit only ~24 of ~140 routes carried rbacGate
enforcement — all of them admin-only fine-grained perms (auth.session.*,
auth.oidc.*, auth.breakglass.admin, cert.bulk_revoke, crl.admin, scep.admin,
est.admin, ca.hierarchy.manage). Every catalogued legacy-CRUD perm
(cert.read/issue/revoke/delete, profile.edit/delete, issuer.edit/delete,
target.*, agent.*, plus role-mgmt verbs) was declared in
internal/domain/auth/validate.go but never wired at the router. A r-viewer
Bearer was essentially r-admin minus five verbs at the wire layer (CWE-862).

This commit:

- Adds rbacGateScoped(checker, perm, scopeType, scopeFn, h) helper to
  internal/api/router/router.go for path-bound scope resolution. Per-profile
  and per-issuer grants (Decision 2) now reach the wire layer.
- Wraps every state-changing route AND every read endpoint in router.go
  with rbacGate (global) or rbacGateScoped (path-bound). The auth-management
  routes (POST /api/v1/auth/roles, etc.) gain router-level enforcement
  in addition to the existing service-layer Authorizer check — defense in
  depth (HIGH-9 of the same audit collapses into this closure).
- Auth-exempt surfaces stay un-gated by design: login, callback, BCL,
  logout, breakglass-login, bootstrap, health, auth-info, version. Allowlist
  is documented in TestRouterRBACGateCoverage.
- Extends internal/domain/auth/validate.go CanonicalPermissions with 30 new
  perms across 12 namespaces: cert.edit; job.read, job.cancel; approval.read,
  approval.approve, approval.reject; policy.read/edit/delete;
  team.read/edit/delete; owner.read/edit/delete; notification.read/edit;
  discovery.read/run/claim; network_scan.read/edit/run;
  healthcheck.read/edit/delete/acknowledge; digest.read, digest.send;
  verification.read, verification.run; stats.read; metrics.read.
- Updates DefaultRoles for r-admin / r-operator / r-viewer / r-mcp / r-cli /
  r-agent. r-auditor gets NOTHING new — the auditor pin
  (TestAuditorRoleHoldsExactlyAuditReadAndExport) stays invariant.
- Migration 000039_audit_crit1_perms seeds the new perm rows + role grants
  per the updated DefaultRoles map. Idempotent ON CONFLICT DO NOTHING.
  Reverse migration removes role_permissions before permissions
  (ON DELETE RESTRICT on the FK).
- AST-level CI guard TestRouterRBACGateCoverage in
  internal/api/router/router_rbac_coverage_test.go walks router.go and
  asserts every state-changing + read route is wrapped (or in the
  documented allowlist). Adding a new ungated route fails CI.
- Updates docs/operator/rbac.md permission-catalogue table with the new
  namespaces + footer link to the AST CI guard.
- Updates certctl/CHANGELOG.md v2.1.0 section with the closure narrative.

Audit doc cowork/auth-bundles-audit-2026-05-10.md CRIT-1 row annotated
CLOSED 2026-05-10. Bundle's exit-gate spec lives at
cowork/auth-bundles-fixes-2026-05-10/01-crit-1-rbac-gates.md.

CRIT-2 / CRIT-3 / CRIT-4 / CRIT-5 of the same audit remain open and
continue to block the v2.1.0 tag.

Verification gate green:
- gofmt -d (no diff after gofmt -w on the touched files)
- go vet ./...
- go test -short -count=1 ./...   (all packages pass including auditor pin)
- go build ./...

HIGH-9 of the audit closes via this commit's router-layer rbacGate on
POST /api/v1/auth/keys/{id}/roles + DELETE /api/v1/auth/keys/{id}/roles/{role_id}
(defense-in-depth on top of the existing service-layer privilege check).

Refs: cowork/auth-bundles-audit-2026-05-10.md CRIT-1 HIGH-9
This commit is contained in:
shankar0123
2026-05-10 19:56:15 +00:00
parent c03d18bb1c
commit 68ca42fef1
7 changed files with 801 additions and 149 deletions
@@ -0,0 +1,42 @@
-- 000039_audit_crit1_perms.down.sql
-- Reverse of 000039_audit_crit1_perms.up.sql.
--
-- role_permissions.permission_id is ON DELETE RESTRICT, so the down
-- migration explicitly removes the role grants first, then the
-- permission rows themselves. Wrapped in a single transaction.
BEGIN;
DELETE FROM role_permissions WHERE permission_id IN (
'p-cert-edit',
'p-job-read', 'p-job-cancel',
'p-approval-read', 'p-approval-approve', 'p-approval-reject',
'p-policy-read', 'p-policy-edit', 'p-policy-delete',
'p-team-read', 'p-team-edit', 'p-team-delete',
'p-owner-read', 'p-owner-edit', 'p-owner-delete',
'p-notification-read', 'p-notification-edit',
'p-discovery-read', 'p-discovery-run', 'p-discovery-claim',
'p-network-scan-read', 'p-network-scan-edit', 'p-network-scan-run',
'p-healthcheck-read', 'p-healthcheck-edit', 'p-healthcheck-delete', 'p-healthcheck-acknowledge',
'p-digest-read', 'p-digest-send',
'p-verification-read', 'p-verification-run',
'p-stats-read', 'p-metrics-read'
);
DELETE FROM permissions WHERE id IN (
'p-cert-edit',
'p-job-read', 'p-job-cancel',
'p-approval-read', 'p-approval-approve', 'p-approval-reject',
'p-policy-read', 'p-policy-edit', 'p-policy-delete',
'p-team-read', 'p-team-edit', 'p-team-delete',
'p-owner-read', 'p-owner-edit', 'p-owner-delete',
'p-notification-read', 'p-notification-edit',
'p-discovery-read', 'p-discovery-run', 'p-discovery-claim',
'p-network-scan-read', 'p-network-scan-edit', 'p-network-scan-run',
'p-healthcheck-read', 'p-healthcheck-edit', 'p-healthcheck-delete', 'p-healthcheck-acknowledge',
'p-digest-read', 'p-digest-send',
'p-verification-read', 'p-verification-run',
'p-stats-read', 'p-metrics-read'
);
COMMIT;
+221
View File
@@ -0,0 +1,221 @@
-- 000039_audit_crit1_perms.up.sql
-- Audit 2026-05-10 CRIT-1 closure: legacy-CRUD permission set.
--
-- The Bundle 1 + Bundle 2 audit surfaced that the RBAC permission
-- catalogue declared at internal/domain/auth/validate.go was being
-- enforced on roughly 24 admin-only routes — the bulk of state-
-- changing routes (POST /api/v1/certificates, PUT /api/v1/profiles/{id},
-- DELETE /api/v1/issuers/{id}, POST /api/v1/agents/{id}/csr, even
-- POST /api/v1/auth/roles and POST /api/v1/auth/keys/{id}/roles) were
-- registered as plain http.HandlerFunc with no rbacGate wrap. A
-- r-viewer Bearer was essentially r-admin minus five fine-grained
-- verbs at the wire layer. CWE-862.
--
-- This migration adds the 30 missing catalogue permissions and seeds
-- them into the default roles per internal/domain/auth/validate.go's
-- DefaultRoles map. The router-level enforcement lands in the same
-- commit via rbacGate / rbacGateScoped on every state-changing route
-- + every list/read endpoint. An AST-level CI guard
-- (TestRouterRBACGateCoverage) pins the enforcement going forward.
--
-- Auditor pin (audit.read + audit.export ONLY) preserved — the
-- TestAuditorRoleHoldsExactlyAuditReadAndExport regression test
-- continues to pass.
--
-- All operations idempotent. Wrapped in a single transaction.
BEGIN;
-- =============================================================================
-- Catalogue additions (30 permissions across 12 namespaces)
-- =============================================================================
INSERT INTO permissions (id, name, namespace) VALUES
-- Cert metadata edit (PUT, deploy trigger, bulk-reassign)
('p-cert-edit', 'cert.edit', 'cert'),
-- Job lifecycle
('p-job-read', 'job.read', 'job'),
('p-job-cancel', 'job.cancel', 'job'),
-- Approval workflow (Rank 7 primitive — was ungated pre-fix)
('p-approval-read', 'approval.read', 'approval'),
('p-approval-approve', 'approval.approve', 'approval'),
('p-approval-reject', 'approval.reject', 'approval'),
-- Policies (compliance rules)
('p-policy-read', 'policy.read', 'policy'),
('p-policy-edit', 'policy.edit', 'policy'),
('p-policy-delete', 'policy.delete', 'policy'),
-- Teams
('p-team-read', 'team.read', 'team'),
('p-team-edit', 'team.edit', 'team'),
('p-team-delete', 'team.delete', 'team'),
-- Owners
('p-owner-read', 'owner.read', 'owner'),
('p-owner-edit', 'owner.edit', 'owner'),
('p-owner-delete', 'owner.delete', 'owner'),
-- Notifications
('p-notification-read', 'notification.read', 'notification'),
('p-notification-edit', 'notification.edit', 'notification'),
-- Discovery (agent + cloud-secret-store)
('p-discovery-read', 'discovery.read', 'discovery'),
('p-discovery-run', 'discovery.run', 'discovery'),
('p-discovery-claim', 'discovery.claim', 'discovery'),
-- Network scan + SCEP probing
('p-network-scan-read', 'network_scan.read', 'network_scan'),
('p-network-scan-edit', 'network_scan.edit', 'network_scan'),
('p-network-scan-run', 'network_scan.run', 'network_scan'),
-- Health checks (uptime monitors)
('p-healthcheck-read', 'healthcheck.read', 'healthcheck'),
('p-healthcheck-edit', 'healthcheck.edit', 'healthcheck'),
('p-healthcheck-delete', 'healthcheck.delete', 'healthcheck'),
('p-healthcheck-acknowledge', 'healthcheck.acknowledge', 'healthcheck'),
-- Digest (operator-summary emails)
('p-digest-read', 'digest.read', 'digest'),
('p-digest-send', 'digest.send', 'digest'),
-- Verification (post-deploy probe)
('p-verification-read', 'verification.read', 'verification'),
('p-verification-run', 'verification.run', 'verification'),
-- Read-only observability
('p-stats-read', 'stats.read', 'stats'),
('p-metrics-read', 'metrics.read', 'metrics')
ON CONFLICT (id) DO NOTHING;
-- =============================================================================
-- Role grants
--
-- r-admin: every new permission (admin gets all catalogued perms).
-- r-operator: full new CRUD set (operator-tier).
-- r-viewer: read-only set + audit.read (already held).
-- r-mcp: operator-equivalent minus destructive ops (delete / config delete).
-- r-cli: operator-tier with policy CRUD + notification edit.
-- r-agent: just discovery.run (agents submit discovery reports).
-- r-auditor: NOTHING new — pinned at {audit.read, audit.export}.
-- =============================================================================
-- r-admin: every new perm.
INSERT INTO role_permissions (role_id, permission_id, scope_type, scope_id)
SELECT 'r-admin', id, 'global', NULL
FROM permissions
WHERE id IN (
'p-cert-edit',
'p-job-read', 'p-job-cancel',
'p-approval-read', 'p-approval-approve', 'p-approval-reject',
'p-policy-read', 'p-policy-edit', 'p-policy-delete',
'p-team-read', 'p-team-edit', 'p-team-delete',
'p-owner-read', 'p-owner-edit', 'p-owner-delete',
'p-notification-read', 'p-notification-edit',
'p-discovery-read', 'p-discovery-run', 'p-discovery-claim',
'p-network-scan-read', 'p-network-scan-edit', 'p-network-scan-run',
'p-healthcheck-read', 'p-healthcheck-edit', 'p-healthcheck-delete', 'p-healthcheck-acknowledge',
'p-digest-read', 'p-digest-send',
'p-verification-read', 'p-verification-run',
'p-stats-read', 'p-metrics-read'
)
ON CONFLICT (role_id, permission_id, scope_type, scope_id) DO NOTHING;
-- r-operator: full operator-tier set.
INSERT INTO role_permissions (role_id, permission_id, scope_type, scope_id)
SELECT 'r-operator', id, 'global', NULL
FROM permissions
WHERE id IN (
'p-cert-edit',
'p-job-read', 'p-job-cancel',
'p-approval-read', 'p-approval-approve', 'p-approval-reject',
'p-policy-read', 'p-policy-edit', 'p-policy-delete',
'p-team-read', 'p-team-edit', 'p-team-delete',
'p-owner-read', 'p-owner-edit', 'p-owner-delete',
'p-notification-read', 'p-notification-edit',
'p-discovery-read', 'p-discovery-run', 'p-discovery-claim',
'p-network-scan-read', 'p-network-scan-edit', 'p-network-scan-run',
'p-healthcheck-read', 'p-healthcheck-edit', 'p-healthcheck-delete', 'p-healthcheck-acknowledge',
'p-digest-read', 'p-digest-send',
'p-verification-read', 'p-verification-run',
'p-stats-read', 'p-metrics-read'
)
ON CONFLICT (role_id, permission_id, scope_type, scope_id) DO NOTHING;
-- r-viewer: read-only across the new surface (+ already-held audit.read).
INSERT INTO role_permissions (role_id, permission_id, scope_type, scope_id)
SELECT 'r-viewer', id, 'global', NULL
FROM permissions
WHERE id IN (
'p-job-read',
'p-approval-read',
'p-policy-read',
'p-team-read',
'p-owner-read',
'p-notification-read',
'p-discovery-read',
'p-network-scan-read',
'p-healthcheck-read',
'p-digest-read',
'p-verification-read',
'p-stats-read', 'p-metrics-read'
)
ON CONFLICT (role_id, permission_id, scope_type, scope_id) DO NOTHING;
-- r-mcp: operator-equivalent minus destructive ops.
INSERT INTO role_permissions (role_id, permission_id, scope_type, scope_id)
SELECT 'r-mcp', id, 'global', NULL
FROM permissions
WHERE id IN (
'p-cert-edit',
'p-job-read', 'p-job-cancel',
'p-approval-read', 'p-approval-approve', 'p-approval-reject',
'p-policy-read',
'p-team-read',
'p-owner-read',
'p-notification-read', 'p-notification-edit',
'p-discovery-read', 'p-discovery-claim',
'p-network-scan-read', 'p-network-scan-run',
'p-healthcheck-read', 'p-healthcheck-acknowledge',
'p-digest-read',
'p-verification-read', 'p-verification-run',
'p-stats-read', 'p-metrics-read'
)
ON CONFLICT (role_id, permission_id, scope_type, scope_id) DO NOTHING;
-- r-cli: operator-tier (matches r-operator new perms).
INSERT INTO role_permissions (role_id, permission_id, scope_type, scope_id)
SELECT 'r-cli', id, 'global', NULL
FROM permissions
WHERE id IN (
'p-cert-edit',
'p-job-read', 'p-job-cancel',
'p-approval-read', 'p-approval-approve', 'p-approval-reject',
'p-policy-read', 'p-policy-edit', 'p-policy-delete',
'p-team-read', 'p-team-edit',
'p-owner-read', 'p-owner-edit',
'p-notification-read', 'p-notification-edit',
'p-discovery-read', 'p-discovery-run', 'p-discovery-claim',
'p-network-scan-read', 'p-network-scan-edit', 'p-network-scan-run',
'p-healthcheck-read', 'p-healthcheck-edit', 'p-healthcheck-acknowledge',
'p-digest-read', 'p-digest-send',
'p-verification-read', 'p-verification-run',
'p-stats-read', 'p-metrics-read'
)
ON CONFLICT (role_id, permission_id, scope_type, scope_id) DO NOTHING;
-- r-agent: agents submit discovery reports (network scan + cert findings).
INSERT INTO role_permissions (role_id, permission_id, scope_type, scope_id)
SELECT 'r-agent', id, 'global', NULL
FROM permissions
WHERE id IN (
'p-discovery-run'
)
ON CONFLICT (role_id, permission_id, scope_type, scope_id) DO NOTHING;
-- r-auditor: NOTHING new. Pin enforced by TestAuditorRoleHoldsExactly...
COMMIT;