mirror of
https://github.com/shankar0123/certctl.git
synced 2026-06-07 17:51:29 +00:00
68ca42fef1
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
222 lines
9.7 KiB
PL/PgSQL
222 lines
9.7 KiB
PL/PgSQL
-- 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;
|