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
+21
View File
@@ -34,6 +34,27 @@
What else changed in v2.1.0: What else changed in v2.1.0:
- **Audit 2026-05-10 CRIT-1 closure — wire-layer RBAC enforcement.**
The Bundle 1 + Bundle 2 audit surfaced that the permission catalogue
was enforced on ~24 admin-only routes only; 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` + `POST /api/v1/auth/keys/{id}/roles`) had
no `rbacGate` wrap. A `r-viewer` Bearer was essentially `r-admin`
minus five fine-grained verbs at the wire layer (CWE-862). This
release wraps every state-changing + read endpoint with
`rbacGate` (global scope) or `rbacGateScoped` (per-profile / per-
issuer scope-bound grants), and adds an AST-level CI guard
(`TestRouterRBACGateCoverage`) that fails when a new route is
registered without enforcement. Catalogue extended via migration
000039 with 30 permissions covering `cert.edit`, `job.*`,
`approval.*`, `policy.*`, `team.*`, `owner.*`, `notification.*`,
`discovery.*`, `network_scan.*`, `healthcheck.*`, `digest.*`,
`verification.*`, `stats.read`, `metrics.read`. **AUDIT YOUR
KEYS** (the scope-down call-out above) now translates to real
reduction in blast radius. Auditor pin preserved at exactly
`{audit.read, audit.export}`.
- **RBAC primitive shipped.** `tenants`, `roles`, `permissions`, - **RBAC primitive shipped.** `tenants`, `roles`, `permissions`,
`role_permissions`, `actor_roles` tables (migration 000029); 33-permission `role_permissions`, `actor_roles` tables (migration 000029); 33-permission
canonical catalogue; 7 default roles (`admin`, `operator`, `viewer`, canonical catalogue; 7 default roles (`admin`, `operator`, `viewer`,
+20
View File
@@ -82,6 +82,26 @@ for the live catalogue.
| `auth.key.*` | `auth.key.list`, `auth.key.create`, `auth.key.rotate`, `auth.key.delete` | API key management | | `auth.key.*` | `auth.key.list`, `auth.key.create`, `auth.key.rotate`, `auth.key.delete` | API key management |
| `auth.bootstrap.*` | `auth.bootstrap.use` | Day-0 first-admin path | | `auth.bootstrap.*` | `auth.bootstrap.use` | Day-0 first-admin path |
| `crl.admin`, `scep.admin`, `est.admin`, `ca.hierarchy.manage` | (single perms) | The five admin-only fine-grained perms (see above) | | `crl.admin`, `scep.admin`, `est.admin`, `ca.hierarchy.manage` | (single perms) | The five admin-only fine-grained perms (see above) |
| `job.*` | `job.read`, `job.cancel` | Deployment job lifecycle |
| `approval.*` | `approval.read`, `approval.approve`, `approval.reject` | Two-person approval workflow (cert-issuance + profile-edit) |
| `policy.*` | `policy.read`, `policy.edit`, `policy.delete` | Compliance policies + renewal policies |
| `team.*`, `owner.*` | `team.read`, `team.edit`, `team.delete`, `owner.*` | Organizational metadata |
| `notification.*` | `notification.read`, `notification.edit` | Notification queue + requeue |
| `discovery.*` | `discovery.read`, `discovery.run`, `discovery.claim` | Agent + cloud-secret-store discovery |
| `network_scan.*` | `network_scan.read`, `network_scan.edit`, `network_scan.run` | TLS network scanning + SCEP probing |
| `healthcheck.*` | `healthcheck.read`, `healthcheck.edit`, `healthcheck.delete`, `healthcheck.acknowledge` | Uptime monitors |
| `digest.*` | `digest.read`, `digest.send` | Operator-summary digest emails |
| `verification.*` | `verification.read`, `verification.run` | Post-deploy verification |
| `stats.read`, `metrics.read` | (single perms) | Dashboard summary + Prometheus exposition |
The full catalogue lives in
[`internal/domain/auth/validate.go`](../../internal/domain/auth/validate.go).
The router-level enforcement sits in
[`internal/api/router/router.go`](../../internal/api/router/router.go);
the AST-level CI guard
[`TestRouterRBACGateCoverage`](../../internal/api/router/router_rbac_coverage_test.go)
pins the contract — adding a new state-changing or read endpoint
without an `rbacGate` / `rbacGateScoped` wrap fails CI.
## Scope semantics ## Scope semantics
+196 -140
View File
@@ -9,9 +9,17 @@ import (
) )
// rbacGate wraps a handler with auth.RequirePermission(checker, perm, // rbacGate wraps a handler with auth.RequirePermission(checker, perm,
// nil). Used by RegisterHandlers to gate the legacy admin routes // nil) — i.e. a GLOBAL-SCOPE permission check. Used by RegisterHandlers
// (Bundle 1 Phase 3.5). When checker is nil the wrap is a no-op so // to gate every state-changing + read endpoint. When checker is nil the
// tests / demo deployments without the RBAC stack continue to work. // wrap is a no-op so tests / demo deployments without the RBAC stack
// continue to work.
//
// Every state-changing handler in this file MUST be wrapped by either
// rbacGate or rbacGateScoped (or appear in the AuthExemptRouterRoutes
// allowlist). The TestRouterRBACGateCoverage AST-level CI guard pins
// this contract; adding a new POST/PUT/PATCH/DELETE without an rbacGate
// wrap fails CI. See cowork/auth-bundles-audit-2026-05-10.md CRIT-1 for
// the closure history.
func rbacGate(checker auth.PermissionChecker, perm string, h http.HandlerFunc) http.Handler { func rbacGate(checker auth.PermissionChecker, perm string, h http.HandlerFunc) http.Handler {
if checker == nil { if checker == nil {
return h return h
@@ -19,6 +27,40 @@ func rbacGate(checker auth.PermissionChecker, perm string, h http.HandlerFunc) h
return auth.RequirePermission(checker, perm, nil)(h) return auth.RequirePermission(checker, perm, nil)(h)
} }
// rbacGateScoped wraps a handler with a per-request scope-resolving
// permission check. The scopeFn extracts a scope identifier from the
// *http.Request (typically a path value, e.g. r.PathValue("id")) so
// the underlying permission check can match a profile- or issuer-
// scoped role-permission grant. When scopeFn returns an empty scope
// id the gate falls back to global checking — consistent with the
// rbacGate semantics — so unscoped grants continue to authorize.
//
// Used for path-bound state-changing routes such as
// PUT /api/v1/profiles/{id} (scope_type=profile, scope_id=<path id>)
// and PUT /api/v1/issuers/{id} (scope_type=issuer, scope_id=<path id>).
//
// When checker is nil the wrap is a no-op (test / demo path).
func rbacGateScoped(checker auth.PermissionChecker, perm, scopeType string,
scopeFn func(*http.Request) string, h http.HandlerFunc) http.Handler {
if checker == nil {
return h
}
return auth.RequirePermission(checker, perm, func(r *http.Request) (string, *string) {
id := scopeFn(r)
if id == "" {
return "global", nil
}
return scopeType, &id
})(h)
}
// pathScope returns a scope extractor that reads a path parameter
// directly. Helper to keep the route registration block readable:
// rbacGateScoped(checker, "profile.edit", "profile", pathScope("id"), h).
func pathScope(param string) func(*http.Request) string {
return func(r *http.Request) string { return r.PathValue(param) }
}
// Router wraps http.ServeMux and manages route registration with middleware. // Router wraps http.ServeMux and manages route registration with middleware.
type Router struct { type Router struct {
mux *http.ServeMux mux *http.ServeMux
@@ -307,23 +349,25 @@ func (r *Router) RegisterHandlers(reg HandlerRegistry) {
middleware.ContentType, middleware.ContentType,
)) ))
// RBAC management routes (Bundle 1 Phase 4). Permission gates are // RBAC management routes (Bundle 1 Phase 4 + audit 2026-05-10 CRIT-1
// enforced inside each handler via the service layer; the Phase 3 // closure). Permission gates are now ALSO enforced at the router
// auth.RequirePermission middleware factory will wrap these in a // level via rbacGate — Bundle 1 Phase 4 left these handler-only
// Phase 3.5 router-level pass once the legacy admin handlers are // (service-layer Authorizer check), which was a defense-in-depth
// converted in lockstep. // gap (HIGH-9 of the 2026-05-10 audit). /api/v1/auth/me and
// /api/v1/auth/permissions remain ungated because every authenticated
// caller is allowed to read their own identity / catalogue.
r.Register("GET /api/v1/auth/me", http.HandlerFunc(reg.Auth.Me)) r.Register("GET /api/v1/auth/me", http.HandlerFunc(reg.Auth.Me))
r.Register("GET /api/v1/auth/permissions", http.HandlerFunc(reg.Auth.ListPermissions)) r.Register("GET /api/v1/auth/permissions", http.HandlerFunc(reg.Auth.ListPermissions))
r.Register("GET /api/v1/auth/roles", http.HandlerFunc(reg.Auth.ListRoles)) r.Register("GET /api/v1/auth/roles", rbacGate(reg.Checker, "auth.role.list", reg.Auth.ListRoles))
r.Register("POST /api/v1/auth/roles", http.HandlerFunc(reg.Auth.CreateRole)) r.Register("POST /api/v1/auth/roles", rbacGate(reg.Checker, "auth.role.create", reg.Auth.CreateRole))
r.Register("GET /api/v1/auth/roles/{id}", http.HandlerFunc(reg.Auth.GetRole)) r.Register("GET /api/v1/auth/roles/{id}", rbacGate(reg.Checker, "auth.role.list", reg.Auth.GetRole))
r.Register("PUT /api/v1/auth/roles/{id}", http.HandlerFunc(reg.Auth.UpdateRole)) r.Register("PUT /api/v1/auth/roles/{id}", rbacGate(reg.Checker, "auth.role.edit", reg.Auth.UpdateRole))
r.Register("DELETE /api/v1/auth/roles/{id}", http.HandlerFunc(reg.Auth.DeleteRole)) r.Register("DELETE /api/v1/auth/roles/{id}", rbacGate(reg.Checker, "auth.role.delete", reg.Auth.DeleteRole))
r.Register("POST /api/v1/auth/roles/{id}/permissions", http.HandlerFunc(reg.Auth.AddRolePermission)) r.Register("POST /api/v1/auth/roles/{id}/permissions", rbacGate(reg.Checker, "auth.role.edit", reg.Auth.AddRolePermission))
r.Register("DELETE /api/v1/auth/roles/{id}/permissions/{perm}", http.HandlerFunc(reg.Auth.RemoveRolePermission)) r.Register("DELETE /api/v1/auth/roles/{id}/permissions/{perm}", rbacGate(reg.Checker, "auth.role.edit", reg.Auth.RemoveRolePermission))
r.Register("GET /api/v1/auth/keys", http.HandlerFunc(reg.Auth.ListKeys)) r.Register("GET /api/v1/auth/keys", rbacGate(reg.Checker, "auth.key.list", reg.Auth.ListKeys))
r.Register("POST /api/v1/auth/keys/{id}/roles", http.HandlerFunc(reg.Auth.AssignRoleToKey)) r.Register("POST /api/v1/auth/keys/{id}/roles", rbacGate(reg.Checker, "auth.role.assign", reg.Auth.AssignRoleToKey))
r.Register("DELETE /api/v1/auth/keys/{id}/roles/{role_id}", http.HandlerFunc(reg.Auth.RevokeRoleFromKey)) r.Register("DELETE /api/v1/auth/keys/{id}/roles/{role_id}", rbacGate(reg.Checker, "auth.role.revoke", reg.Auth.RevokeRoleFromKey))
// ========================================================================= // =========================================================================
// Auth Bundle 2 Phase 5 — OIDC + session HTTP surface. // Auth Bundle 2 Phase 5 — OIDC + session HTTP surface.
@@ -434,22 +478,23 @@ func (r *Router) RegisterHandlers(reg HandlerRegistry) {
// Same handler instance + same admin gate; the BulkRevokeEST method // Same handler instance + same admin gate; the BulkRevokeEST method
// pins Source=EST so the operation only affects EST-issued certs. // pins Source=EST so the operation only affects EST-issued certs.
r.Register("POST /api/v1/est/certificates/bulk-revoke", rbacGate(reg.Checker, "cert.bulk_revoke", reg.BulkRevocation.BulkRevokeEST)) r.Register("POST /api/v1/est/certificates/bulk-revoke", rbacGate(reg.Checker, "cert.bulk_revoke", reg.BulkRevocation.BulkRevokeEST))
r.Register("POST /api/v1/certificates/bulk-renew", http.HandlerFunc(reg.BulkRenewal.BulkRenew)) r.Register("POST /api/v1/certificates/bulk-renew", rbacGate(reg.Checker, "cert.issue", reg.BulkRenewal.BulkRenew))
r.Register("POST /api/v1/certificates/bulk-reassign", http.HandlerFunc(reg.BulkReassignment.BulkReassign)) r.Register("POST /api/v1/certificates/bulk-reassign", rbacGate(reg.Checker, "cert.edit", reg.BulkReassignment.BulkReassign))
r.Register("GET /api/v1/certificates", http.HandlerFunc(reg.Certificates.ListCertificates)) r.Register("GET /api/v1/certificates", rbacGate(reg.Checker, "cert.read", reg.Certificates.ListCertificates))
r.Register("POST /api/v1/certificates", http.HandlerFunc(reg.Certificates.CreateCertificate)) r.Register("POST /api/v1/certificates", rbacGate(reg.Checker, "cert.issue", reg.Certificates.CreateCertificate))
r.Register("GET /api/v1/certificates/{id}", http.HandlerFunc(reg.Certificates.GetCertificate)) r.Register("GET /api/v1/certificates/{id}", rbacGate(reg.Checker, "cert.read", reg.Certificates.GetCertificate))
r.Register("PUT /api/v1/certificates/{id}", http.HandlerFunc(reg.Certificates.UpdateCertificate)) r.Register("PUT /api/v1/certificates/{id}", rbacGate(reg.Checker, "cert.edit", reg.Certificates.UpdateCertificate))
r.Register("DELETE /api/v1/certificates/{id}", http.HandlerFunc(reg.Certificates.ArchiveCertificate)) r.Register("DELETE /api/v1/certificates/{id}", rbacGate(reg.Checker, "cert.delete", reg.Certificates.ArchiveCertificate))
r.Register("GET /api/v1/certificates/{id}/versions", http.HandlerFunc(reg.Certificates.GetCertificateVersions)) r.Register("GET /api/v1/certificates/{id}/versions", rbacGate(reg.Checker, "cert.read", reg.Certificates.GetCertificateVersions))
r.Register("GET /api/v1/certificates/{id}/deployments", http.HandlerFunc(reg.Certificates.GetCertificateDeployments)) r.Register("GET /api/v1/certificates/{id}/deployments", rbacGate(reg.Checker, "cert.read", reg.Certificates.GetCertificateDeployments))
r.Register("POST /api/v1/certificates/{id}/renew", http.HandlerFunc(reg.Certificates.TriggerRenewal)) r.Register("POST /api/v1/certificates/{id}/renew", rbacGate(reg.Checker, "cert.issue", reg.Certificates.TriggerRenewal))
r.Register("POST /api/v1/certificates/{id}/deploy", http.HandlerFunc(reg.Certificates.TriggerDeployment)) r.Register("POST /api/v1/certificates/{id}/deploy", rbacGate(reg.Checker, "cert.edit", reg.Certificates.TriggerDeployment))
r.Register("POST /api/v1/certificates/{id}/revoke", http.HandlerFunc(reg.Certificates.RevokeCertificate)) r.Register("POST /api/v1/certificates/{id}/revoke", rbacGate(reg.Checker, "cert.revoke", reg.Certificates.RevokeCertificate))
// Export endpoints: /api/v1/certificates/{id}/export/{format} // Export endpoints: /api/v1/certificates/{id}/export/{format}.
r.Register("GET /api/v1/certificates/{id}/export/pem", http.HandlerFunc(reg.Export.ExportPEM)) // Reading bytes — gated by cert.read.
r.Register("POST /api/v1/certificates/{id}/export/pkcs12", http.HandlerFunc(reg.Export.ExportPKCS12)) r.Register("GET /api/v1/certificates/{id}/export/pem", rbacGate(reg.Checker, "cert.read", reg.Export.ExportPEM))
r.Register("POST /api/v1/certificates/{id}/export/pkcs12", rbacGate(reg.Checker, "cert.read", reg.Export.ExportPKCS12))
// NOTE: RFC 5280 CRL and RFC 6960 OCSP endpoints are registered separately // NOTE: RFC 5280 CRL and RFC 6960 OCSP endpoints are registered separately
// via RegisterPKIHandlers under /.well-known/pki/ so relying parties can // via RegisterPKIHandlers under /.well-known/pki/ so relying parties can
@@ -457,20 +502,24 @@ func (r *Router) RegisterHandlers(reg HandlerRegistry) {
// /api/v1/crl and /api/v1/ocsp paths have been retired (see M-006). // /api/v1/crl and /api/v1/ocsp paths have been retired (see M-006).
// Issuers routes: /api/v1/issuers // Issuers routes: /api/v1/issuers
r.Register("GET /api/v1/issuers", http.HandlerFunc(reg.Issuers.ListIssuers)) // Path-scoped: PUT / DELETE / test on /{id} honor per-issuer
r.Register("POST /api/v1/issuers", http.HandlerFunc(reg.Issuers.CreateIssuer)) // scope-bound role-permission grants. Operators who grant
r.Register("GET /api/v1/issuers/{id}", http.HandlerFunc(reg.Issuers.GetIssuer)) // issuer.edit scope_type=issuer scope_id=iss-internal-ca only
r.Register("PUT /api/v1/issuers/{id}", http.HandlerFunc(reg.Issuers.UpdateIssuer)) // authorize edits to that specific issuer.
r.Register("DELETE /api/v1/issuers/{id}", http.HandlerFunc(reg.Issuers.DeleteIssuer)) r.Register("GET /api/v1/issuers", rbacGate(reg.Checker, "issuer.read", reg.Issuers.ListIssuers))
r.Register("POST /api/v1/issuers/{id}/test", http.HandlerFunc(reg.Issuers.TestConnection)) r.Register("POST /api/v1/issuers", rbacGate(reg.Checker, "issuer.edit", reg.Issuers.CreateIssuer))
r.Register("GET /api/v1/issuers/{id}", rbacGateScoped(reg.Checker, "issuer.read", "issuer", pathScope("id"), reg.Issuers.GetIssuer))
r.Register("PUT /api/v1/issuers/{id}", rbacGateScoped(reg.Checker, "issuer.edit", "issuer", pathScope("id"), reg.Issuers.UpdateIssuer))
r.Register("DELETE /api/v1/issuers/{id}", rbacGateScoped(reg.Checker, "issuer.delete", "issuer", pathScope("id"), reg.Issuers.DeleteIssuer))
r.Register("POST /api/v1/issuers/{id}/test", rbacGateScoped(reg.Checker, "issuer.edit", "issuer", pathScope("id"), reg.Issuers.TestConnection))
// Targets routes: /api/v1/targets // Targets routes: /api/v1/targets
r.Register("GET /api/v1/targets", http.HandlerFunc(reg.Targets.ListTargets)) r.Register("GET /api/v1/targets", rbacGate(reg.Checker, "target.read", reg.Targets.ListTargets))
r.Register("POST /api/v1/targets", http.HandlerFunc(reg.Targets.CreateTarget)) r.Register("POST /api/v1/targets", rbacGate(reg.Checker, "target.edit", reg.Targets.CreateTarget))
r.Register("GET /api/v1/targets/{id}", http.HandlerFunc(reg.Targets.GetTarget)) r.Register("GET /api/v1/targets/{id}", rbacGate(reg.Checker, "target.read", reg.Targets.GetTarget))
r.Register("PUT /api/v1/targets/{id}", http.HandlerFunc(reg.Targets.UpdateTarget)) r.Register("PUT /api/v1/targets/{id}", rbacGate(reg.Checker, "target.edit", reg.Targets.UpdateTarget))
r.Register("DELETE /api/v1/targets/{id}", http.HandlerFunc(reg.Targets.DeleteTarget)) r.Register("DELETE /api/v1/targets/{id}", rbacGate(reg.Checker, "target.delete", reg.Targets.DeleteTarget))
r.Register("POST /api/v1/targets/{id}/test", http.HandlerFunc(reg.Targets.TestTargetConnection)) r.Register("POST /api/v1/targets/{id}/test", rbacGate(reg.Checker, "target.edit", reg.Targets.TestTargetConnection))
// Agents routes: /api/v1/agents // Agents routes: /api/v1/agents
// //
@@ -483,31 +532,31 @@ func (r *Router) RegisterHandlers(reg HandlerRegistry) {
// * DELETE /api/v1/agents/{id} — RetireAgent. Replaces the pre-I-004 // * DELETE /api/v1/agents/{id} — RetireAgent. Replaces the pre-I-004
// hard-delete; the underlying repo does a soft-retire with // hard-delete; the underlying repo does a soft-retire with
// optional cascade. // optional cascade.
r.Register("GET /api/v1/agents", http.HandlerFunc(reg.Agents.ListAgents)) r.Register("GET /api/v1/agents", rbacGate(reg.Checker, "agent.read", reg.Agents.ListAgents))
r.Register("POST /api/v1/agents", http.HandlerFunc(reg.Agents.RegisterAgent)) r.Register("POST /api/v1/agents", rbacGate(reg.Checker, "agent.edit", reg.Agents.RegisterAgent))
r.Register("GET /api/v1/agents/retired", http.HandlerFunc(reg.Agents.ListRetiredAgents)) r.Register("GET /api/v1/agents/retired", rbacGate(reg.Checker, "agent.read", reg.Agents.ListRetiredAgents))
r.Register("GET /api/v1/agents/{id}", http.HandlerFunc(reg.Agents.GetAgent)) r.Register("GET /api/v1/agents/{id}", rbacGate(reg.Checker, "agent.read", reg.Agents.GetAgent))
r.Register("DELETE /api/v1/agents/{id}", http.HandlerFunc(reg.Agents.RetireAgent)) r.Register("DELETE /api/v1/agents/{id}", rbacGate(reg.Checker, "agent.retire", reg.Agents.RetireAgent))
r.Register("POST /api/v1/agents/{id}/heartbeat", http.HandlerFunc(reg.Agents.Heartbeat)) r.Register("POST /api/v1/agents/{id}/heartbeat", rbacGate(reg.Checker, "agent.heartbeat", reg.Agents.Heartbeat))
r.Register("POST /api/v1/agents/{id}/csr", http.HandlerFunc(reg.Agents.AgentCSRSubmit)) r.Register("POST /api/v1/agents/{id}/csr", rbacGate(reg.Checker, "agent.job.poll", reg.Agents.AgentCSRSubmit))
r.Register("GET /api/v1/agents/{id}/certificates/{cert_id}", http.HandlerFunc(reg.Agents.AgentCertificatePickup)) r.Register("GET /api/v1/agents/{id}/certificates/{cert_id}", rbacGate(reg.Checker, "cert.read", reg.Agents.AgentCertificatePickup))
r.Register("GET /api/v1/agents/{id}/work", http.HandlerFunc(reg.Agents.AgentGetWork)) r.Register("GET /api/v1/agents/{id}/work", rbacGate(reg.Checker, "agent.job.poll", reg.Agents.AgentGetWork))
r.Register("POST /api/v1/agents/{id}/jobs/{job_id}/status", http.HandlerFunc(reg.Agents.AgentReportJobStatus)) r.Register("POST /api/v1/agents/{id}/jobs/{job_id}/status", rbacGate(reg.Checker, "agent.job.complete", reg.Agents.AgentReportJobStatus))
// Jobs routes: /api/v1/jobs // Jobs routes: /api/v1/jobs
r.Register("GET /api/v1/jobs", http.HandlerFunc(reg.Jobs.ListJobs)) r.Register("GET /api/v1/jobs", rbacGate(reg.Checker, "job.read", reg.Jobs.ListJobs))
r.Register("GET /api/v1/jobs/{id}", http.HandlerFunc(reg.Jobs.GetJob)) r.Register("GET /api/v1/jobs/{id}", rbacGate(reg.Checker, "job.read", reg.Jobs.GetJob))
r.Register("POST /api/v1/jobs/{id}/cancel", http.HandlerFunc(reg.Jobs.CancelJob)) r.Register("POST /api/v1/jobs/{id}/cancel", rbacGate(reg.Checker, "job.cancel", reg.Jobs.CancelJob))
r.Register("POST /api/v1/jobs/{id}/approve", http.HandlerFunc(reg.Jobs.ApproveJob)) r.Register("POST /api/v1/jobs/{id}/approve", rbacGate(reg.Checker, "approval.approve", reg.Jobs.ApproveJob))
r.Register("POST /api/v1/jobs/{id}/reject", http.HandlerFunc(reg.Jobs.RejectJob)) r.Register("POST /api/v1/jobs/{id}/reject", rbacGate(reg.Checker, "approval.reject", reg.Jobs.RejectJob))
// Policies routes: /api/v1/policies // Policies routes: /api/v1/policies
r.Register("GET /api/v1/policies", http.HandlerFunc(reg.Policies.ListPolicies)) r.Register("GET /api/v1/policies", rbacGate(reg.Checker, "policy.read", reg.Policies.ListPolicies))
r.Register("POST /api/v1/policies", http.HandlerFunc(reg.Policies.CreatePolicy)) r.Register("POST /api/v1/policies", rbacGate(reg.Checker, "policy.edit", reg.Policies.CreatePolicy))
r.Register("GET /api/v1/policies/{id}", http.HandlerFunc(reg.Policies.GetPolicy)) r.Register("GET /api/v1/policies/{id}", rbacGate(reg.Checker, "policy.read", reg.Policies.GetPolicy))
r.Register("PUT /api/v1/policies/{id}", http.HandlerFunc(reg.Policies.UpdatePolicy)) r.Register("PUT /api/v1/policies/{id}", rbacGate(reg.Checker, "policy.edit", reg.Policies.UpdatePolicy))
r.Register("DELETE /api/v1/policies/{id}", http.HandlerFunc(reg.Policies.DeletePolicy)) r.Register("DELETE /api/v1/policies/{id}", rbacGate(reg.Checker, "policy.delete", reg.Policies.DeletePolicy))
r.Register("GET /api/v1/policies/{id}/violations", http.HandlerFunc(reg.Policies.ListViolations)) r.Register("GET /api/v1/policies/{id}/violations", rbacGate(reg.Checker, "policy.read", reg.Policies.ListViolations))
// Renewal Policies routes: /api/v1/renewal-policies // Renewal Policies routes: /api/v1/renewal-policies
// G-1: fixes frontend FK drift — OnboardingWizard + CertificatesPage dropdowns // G-1: fixes frontend FK drift — OnboardingWizard + CertificatesPage dropdowns
@@ -515,44 +564,52 @@ func (r *Router) RegisterHandlers(reg HandlerRegistry) {
// rules, pol-* IDs), violating FK managed_certificates.renewal_policy_id → // rules, pol-* IDs), violating FK managed_certificates.renewal_policy_id →
// renewal_policies(id) ON DELETE RESTRICT. This block is the backend half; the // renewal_policies(id) ON DELETE RESTRICT. This block is the backend half; the
// frontend half swaps getPolicies → getRenewalPolicies at 3 call sites. // frontend half swaps getPolicies → getRenewalPolicies at 3 call sites.
r.Register("GET /api/v1/renewal-policies", http.HandlerFunc(reg.RenewalPolicies.ListRenewalPolicies)) // Reuses the policy.* permission catalogue entry (renewal policies are a
r.Register("POST /api/v1/renewal-policies", http.HandlerFunc(reg.RenewalPolicies.CreateRenewalPolicy)) // subtype of policy from the operator's perspective).
r.Register("GET /api/v1/renewal-policies/{id}", http.HandlerFunc(reg.RenewalPolicies.GetRenewalPolicy)) r.Register("GET /api/v1/renewal-policies", rbacGate(reg.Checker, "policy.read", reg.RenewalPolicies.ListRenewalPolicies))
r.Register("PUT /api/v1/renewal-policies/{id}", http.HandlerFunc(reg.RenewalPolicies.UpdateRenewalPolicy)) r.Register("POST /api/v1/renewal-policies", rbacGate(reg.Checker, "policy.edit", reg.RenewalPolicies.CreateRenewalPolicy))
r.Register("DELETE /api/v1/renewal-policies/{id}", http.HandlerFunc(reg.RenewalPolicies.DeleteRenewalPolicy)) r.Register("GET /api/v1/renewal-policies/{id}", rbacGate(reg.Checker, "policy.read", reg.RenewalPolicies.GetRenewalPolicy))
r.Register("PUT /api/v1/renewal-policies/{id}", rbacGate(reg.Checker, "policy.edit", reg.RenewalPolicies.UpdateRenewalPolicy))
r.Register("DELETE /api/v1/renewal-policies/{id}", rbacGate(reg.Checker, "policy.delete", reg.RenewalPolicies.DeleteRenewalPolicy))
// Profiles routes: /api/v1/profiles // Profiles routes: /api/v1/profiles
r.Register("GET /api/v1/profiles", http.HandlerFunc(reg.Profiles.ListProfiles)) // Path-scoped: PUT / DELETE on /{id} honor per-profile scope-bound
r.Register("POST /api/v1/profiles", http.HandlerFunc(reg.Profiles.CreateProfile)) // role-permission grants. Operators who grant profile.edit
r.Register("GET /api/v1/profiles/{id}", http.HandlerFunc(reg.Profiles.GetProfile)) // scope_type=profile scope_id=p-finance only authorize edits to
r.Register("PUT /api/v1/profiles/{id}", http.HandlerFunc(reg.Profiles.UpdateProfile)) // that specific profile.
r.Register("DELETE /api/v1/profiles/{id}", http.HandlerFunc(reg.Profiles.DeleteProfile)) r.Register("GET /api/v1/profiles", rbacGate(reg.Checker, "profile.read", reg.Profiles.ListProfiles))
r.Register("POST /api/v1/profiles", rbacGate(reg.Checker, "profile.edit", reg.Profiles.CreateProfile))
r.Register("GET /api/v1/profiles/{id}", rbacGateScoped(reg.Checker, "profile.read", "profile", pathScope("id"), reg.Profiles.GetProfile))
r.Register("PUT /api/v1/profiles/{id}", rbacGateScoped(reg.Checker, "profile.edit", "profile", pathScope("id"), reg.Profiles.UpdateProfile))
r.Register("DELETE /api/v1/profiles/{id}", rbacGateScoped(reg.Checker, "profile.delete", "profile", pathScope("id"), reg.Profiles.DeleteProfile))
// Teams routes: /api/v1/teams // Teams routes: /api/v1/teams
r.Register("GET /api/v1/teams", http.HandlerFunc(reg.Teams.ListTeams)) r.Register("GET /api/v1/teams", rbacGate(reg.Checker, "team.read", reg.Teams.ListTeams))
r.Register("POST /api/v1/teams", http.HandlerFunc(reg.Teams.CreateTeam)) r.Register("POST /api/v1/teams", rbacGate(reg.Checker, "team.edit", reg.Teams.CreateTeam))
r.Register("GET /api/v1/teams/{id}", http.HandlerFunc(reg.Teams.GetTeam)) r.Register("GET /api/v1/teams/{id}", rbacGate(reg.Checker, "team.read", reg.Teams.GetTeam))
r.Register("PUT /api/v1/teams/{id}", http.HandlerFunc(reg.Teams.UpdateTeam)) r.Register("PUT /api/v1/teams/{id}", rbacGate(reg.Checker, "team.edit", reg.Teams.UpdateTeam))
r.Register("DELETE /api/v1/teams/{id}", http.HandlerFunc(reg.Teams.DeleteTeam)) r.Register("DELETE /api/v1/teams/{id}", rbacGate(reg.Checker, "team.delete", reg.Teams.DeleteTeam))
// Owners routes: /api/v1/owners // Owners routes: /api/v1/owners
r.Register("GET /api/v1/owners", http.HandlerFunc(reg.Owners.ListOwners)) r.Register("GET /api/v1/owners", rbacGate(reg.Checker, "owner.read", reg.Owners.ListOwners))
r.Register("POST /api/v1/owners", http.HandlerFunc(reg.Owners.CreateOwner)) r.Register("POST /api/v1/owners", rbacGate(reg.Checker, "owner.edit", reg.Owners.CreateOwner))
r.Register("GET /api/v1/owners/{id}", http.HandlerFunc(reg.Owners.GetOwner)) r.Register("GET /api/v1/owners/{id}", rbacGate(reg.Checker, "owner.read", reg.Owners.GetOwner))
r.Register("PUT /api/v1/owners/{id}", http.HandlerFunc(reg.Owners.UpdateOwner)) r.Register("PUT /api/v1/owners/{id}", rbacGate(reg.Checker, "owner.edit", reg.Owners.UpdateOwner))
r.Register("DELETE /api/v1/owners/{id}", http.HandlerFunc(reg.Owners.DeleteOwner)) r.Register("DELETE /api/v1/owners/{id}", rbacGate(reg.Checker, "owner.delete", reg.Owners.DeleteOwner))
// Agent Groups routes: /api/v1/agent-groups // Agent Groups routes: /api/v1/agent-groups
r.Register("GET /api/v1/agent-groups", http.HandlerFunc(reg.AgentGroups.ListAgentGroups)) // Reuses agent.* permissions (agent-groups are an organizational
r.Register("POST /api/v1/agent-groups", http.HandlerFunc(reg.AgentGroups.CreateAgentGroup)) // view on top of the agent resource).
r.Register("GET /api/v1/agent-groups/{id}", http.HandlerFunc(reg.AgentGroups.GetAgentGroup)) r.Register("GET /api/v1/agent-groups", rbacGate(reg.Checker, "agent.read", reg.AgentGroups.ListAgentGroups))
r.Register("PUT /api/v1/agent-groups/{id}", http.HandlerFunc(reg.AgentGroups.UpdateAgentGroup)) r.Register("POST /api/v1/agent-groups", rbacGate(reg.Checker, "agent.edit", reg.AgentGroups.CreateAgentGroup))
r.Register("DELETE /api/v1/agent-groups/{id}", http.HandlerFunc(reg.AgentGroups.DeleteAgentGroup)) r.Register("GET /api/v1/agent-groups/{id}", rbacGate(reg.Checker, "agent.read", reg.AgentGroups.GetAgentGroup))
r.Register("GET /api/v1/agent-groups/{id}/members", http.HandlerFunc(reg.AgentGroups.ListAgentGroupMembers)) r.Register("PUT /api/v1/agent-groups/{id}", rbacGate(reg.Checker, "agent.edit", reg.AgentGroups.UpdateAgentGroup))
r.Register("DELETE /api/v1/agent-groups/{id}", rbacGate(reg.Checker, "agent.edit", reg.AgentGroups.DeleteAgentGroup))
r.Register("GET /api/v1/agent-groups/{id}/members", rbacGate(reg.Checker, "agent.read", reg.AgentGroups.ListAgentGroupMembers))
// Audit routes: /api/v1/audit // Audit routes: /api/v1/audit
r.Register("GET /api/v1/audit", http.HandlerFunc(reg.Audit.ListAuditEvents)) r.Register("GET /api/v1/audit", rbacGate(reg.Checker, "audit.read", reg.Audit.ListAuditEvents))
r.Register("GET /api/v1/audit/{id}", http.HandlerFunc(reg.Audit.GetAuditEvent)) r.Register("GET /api/v1/audit/{id}", rbacGate(reg.Checker, "audit.read", reg.Audit.GetAuditEvent))
// Bundle CRL/OCSP-Responder Phase 5: admin observability for the // Bundle CRL/OCSP-Responder Phase 5: admin observability for the
// scheduler-driven CRL pre-generation cache. Admin-gated inside // scheduler-driven CRL pre-generation cache. Admin-gated inside
@@ -571,23 +628,24 @@ func (r *Router) RegisterHandlers(reg HandlerRegistry) {
r.Register("POST /api/v1/admin/est/reload-trust", rbacGate(reg.Checker, "est.admin", reg.AdminEST.ReloadTrust)) r.Register("POST /api/v1/admin/est/reload-trust", rbacGate(reg.Checker, "est.admin", reg.AdminEST.ReloadTrust))
// Notifications routes: /api/v1/notifications // Notifications routes: /api/v1/notifications
r.Register("GET /api/v1/notifications", http.HandlerFunc(reg.Notifications.ListNotifications)) r.Register("GET /api/v1/notifications", rbacGate(reg.Checker, "notification.read", reg.Notifications.ListNotifications))
r.Register("GET /api/v1/notifications/{id}", http.HandlerFunc(reg.Notifications.GetNotification)) r.Register("GET /api/v1/notifications/{id}", rbacGate(reg.Checker, "notification.read", reg.Notifications.GetNotification))
r.Register("POST /api/v1/notifications/{id}/read", http.HandlerFunc(reg.Notifications.MarkAsRead)) r.Register("POST /api/v1/notifications/{id}/read", rbacGate(reg.Checker, "notification.read", reg.Notifications.MarkAsRead))
// I-005: requeue a dead notification back to pending so the retry sweep // I-005: requeue a dead notification back to pending so the retry sweep
// picks it up again. Go 1.22 ServeMux resolves the literal /requeue segment // picks it up again. Go 1.22 ServeMux resolves the literal /requeue segment
// before falling back to the {id} path-variable route above. // before falling back to the {id} path-variable route above.
r.Register("POST /api/v1/notifications/{id}/requeue", http.HandlerFunc(reg.Notifications.RequeueNotification)) r.Register("POST /api/v1/notifications/{id}/requeue", rbacGate(reg.Checker, "notification.edit", reg.Notifications.RequeueNotification))
// Approvals routes: /api/v1/approvals (Rank 7). // Approvals routes: /api/v1/approvals (Rank 7).
// Same Go 1.22 ServeMux precedence as the notifications block — literal // Same Go 1.22 ServeMux precedence as the notifications block — literal
// /approve and /reject segments resolve before the {id} pattern-var // /approve and /reject segments resolve before the {id} pattern-var
// route. Same-actor RBAC enforced at the service layer; the handler // route. Same-actor RBAC enforced at the service layer; the handler
// surfaces ErrApproveBySameActor as HTTP 403. // surfaces ErrApproveBySameActor as HTTP 403. Router-level gates
r.Register("GET /api/v1/approvals", http.HandlerFunc(reg.Approvals.ListApprovals)) // added in the 2026-05-10 audit CRIT-1 closure (defense in depth).
r.Register("GET /api/v1/approvals/{id}", http.HandlerFunc(reg.Approvals.GetApproval)) r.Register("GET /api/v1/approvals", rbacGate(reg.Checker, "approval.read", reg.Approvals.ListApprovals))
r.Register("POST /api/v1/approvals/{id}/approve", http.HandlerFunc(reg.Approvals.Approve)) r.Register("GET /api/v1/approvals/{id}", rbacGate(reg.Checker, "approval.read", reg.Approvals.GetApproval))
r.Register("POST /api/v1/approvals/{id}/reject", http.HandlerFunc(reg.Approvals.Reject)) r.Register("POST /api/v1/approvals/{id}/approve", rbacGate(reg.Checker, "approval.approve", reg.Approvals.Approve))
r.Register("POST /api/v1/approvals/{id}/reject", rbacGate(reg.Checker, "approval.reject", reg.Approvals.Reject))
// IntermediateCA hierarchy routes (Rank 8). Admin-gated inside the // IntermediateCA hierarchy routes (Rank 8). Admin-gated inside the
// handler (M-003 pattern); non-admin Bearer callers get 403. The // handler (M-003 pattern); non-admin Bearer callers get 403. The
@@ -600,57 +658,55 @@ func (r *Router) RegisterHandlers(reg HandlerRegistry) {
r.Register("GET /api/v1/intermediates/{id}", rbacGate(reg.Checker, "ca.hierarchy.manage", reg.IntermediateCAs.Get)) r.Register("GET /api/v1/intermediates/{id}", rbacGate(reg.Checker, "ca.hierarchy.manage", reg.IntermediateCAs.Get))
// Stats routes: /api/v1/stats // Stats routes: /api/v1/stats
r.Register("GET /api/v1/stats/summary", http.HandlerFunc(reg.Stats.GetDashboardSummary)) r.Register("GET /api/v1/stats/summary", rbacGate(reg.Checker, "stats.read", reg.Stats.GetDashboardSummary))
r.Register("GET /api/v1/stats/certificates-by-status", http.HandlerFunc(reg.Stats.GetCertificatesByStatus)) r.Register("GET /api/v1/stats/certificates-by-status", rbacGate(reg.Checker, "stats.read", reg.Stats.GetCertificatesByStatus))
r.Register("GET /api/v1/stats/expiration-timeline", http.HandlerFunc(reg.Stats.GetExpirationTimeline)) r.Register("GET /api/v1/stats/expiration-timeline", rbacGate(reg.Checker, "stats.read", reg.Stats.GetExpirationTimeline))
r.Register("GET /api/v1/stats/job-trends", http.HandlerFunc(reg.Stats.GetJobTrends)) r.Register("GET /api/v1/stats/job-trends", rbacGate(reg.Checker, "stats.read", reg.Stats.GetJobTrends))
r.Register("GET /api/v1/stats/issuance-rate", http.HandlerFunc(reg.Stats.GetIssuanceRate)) r.Register("GET /api/v1/stats/issuance-rate", rbacGate(reg.Checker, "stats.read", reg.Stats.GetIssuanceRate))
// Metrics routes: /api/v1/metrics // Metrics routes: /api/v1/metrics
r.Register("GET /api/v1/metrics", http.HandlerFunc(reg.Metrics.GetMetrics)) r.Register("GET /api/v1/metrics", rbacGate(reg.Checker, "metrics.read", reg.Metrics.GetMetrics))
r.Register("GET /api/v1/metrics/prometheus", http.HandlerFunc(reg.Metrics.GetPrometheusMetrics)) r.Register("GET /api/v1/metrics/prometheus", rbacGate(reg.Checker, "metrics.read", reg.Metrics.GetPrometheusMetrics))
// Discovery routes: /api/v1/discovered-certificates, /api/v1/discovery-scans // Discovery routes: /api/v1/discovered-certificates, /api/v1/discovery-scans
r.Register("POST /api/v1/agents/{id}/discoveries", http.HandlerFunc(reg.Discovery.SubmitDiscoveryReport)) r.Register("POST /api/v1/agents/{id}/discoveries", rbacGate(reg.Checker, "discovery.run", reg.Discovery.SubmitDiscoveryReport))
r.Register("GET /api/v1/discovered-certificates", http.HandlerFunc(reg.Discovery.ListDiscovered)) r.Register("GET /api/v1/discovered-certificates", rbacGate(reg.Checker, "discovery.read", reg.Discovery.ListDiscovered))
r.Register("GET /api/v1/discovered-certificates/{id}", http.HandlerFunc(reg.Discovery.GetDiscovered)) r.Register("GET /api/v1/discovered-certificates/{id}", rbacGate(reg.Checker, "discovery.read", reg.Discovery.GetDiscovered))
r.Register("POST /api/v1/discovered-certificates/{id}/claim", http.HandlerFunc(reg.Discovery.ClaimDiscovered)) r.Register("POST /api/v1/discovered-certificates/{id}/claim", rbacGate(reg.Checker, "discovery.claim", reg.Discovery.ClaimDiscovered))
r.Register("POST /api/v1/discovered-certificates/{id}/dismiss", http.HandlerFunc(reg.Discovery.DismissDiscovered)) r.Register("POST /api/v1/discovered-certificates/{id}/dismiss", rbacGate(reg.Checker, "discovery.claim", reg.Discovery.DismissDiscovered))
r.Register("GET /api/v1/discovery-scans", http.HandlerFunc(reg.Discovery.ListScans)) r.Register("GET /api/v1/discovery-scans", rbacGate(reg.Checker, "discovery.read", reg.Discovery.ListScans))
r.Register("GET /api/v1/discovery-summary", http.HandlerFunc(reg.Discovery.GetDiscoverySummary)) r.Register("GET /api/v1/discovery-summary", rbacGate(reg.Checker, "discovery.read", reg.Discovery.GetDiscoverySummary))
// Network scan routes: /api/v1/network-scan-targets // Network scan routes: /api/v1/network-scan-targets
r.Register("GET /api/v1/network-scan-targets", http.HandlerFunc(reg.NetworkScan.ListNetworkScanTargets)) r.Register("GET /api/v1/network-scan-targets", rbacGate(reg.Checker, "network_scan.read", reg.NetworkScan.ListNetworkScanTargets))
r.Register("POST /api/v1/network-scan-targets", http.HandlerFunc(reg.NetworkScan.CreateNetworkScanTarget)) r.Register("POST /api/v1/network-scan-targets", rbacGate(reg.Checker, "network_scan.edit", reg.NetworkScan.CreateNetworkScanTarget))
r.Register("GET /api/v1/network-scan-targets/{id}", http.HandlerFunc(reg.NetworkScan.GetNetworkScanTarget)) r.Register("GET /api/v1/network-scan-targets/{id}", rbacGate(reg.Checker, "network_scan.read", reg.NetworkScan.GetNetworkScanTarget))
r.Register("PUT /api/v1/network-scan-targets/{id}", http.HandlerFunc(reg.NetworkScan.UpdateNetworkScanTarget)) r.Register("PUT /api/v1/network-scan-targets/{id}", rbacGate(reg.Checker, "network_scan.edit", reg.NetworkScan.UpdateNetworkScanTarget))
r.Register("DELETE /api/v1/network-scan-targets/{id}", http.HandlerFunc(reg.NetworkScan.DeleteNetworkScanTarget)) r.Register("DELETE /api/v1/network-scan-targets/{id}", rbacGate(reg.Checker, "network_scan.edit", reg.NetworkScan.DeleteNetworkScanTarget))
r.Register("POST /api/v1/network-scan-targets/{id}/scan", http.HandlerFunc(reg.NetworkScan.TriggerNetworkScan)) r.Register("POST /api/v1/network-scan-targets/{id}/scan", rbacGate(reg.Checker, "network_scan.run", reg.NetworkScan.TriggerNetworkScan))
// SCEP RFC 8894 + Intune master bundle Phase 11.5 — SCEP probe. // SCEP RFC 8894 + Intune master bundle Phase 11.5 — SCEP probe.
// Bearer-auth gated by the standard middleware chain; not admin- // Now RBAC-gated by network_scan.run (was Bearer-only pre-audit).
// only because the probe is read-only against operator-supplied r.Register("POST /api/v1/network-scan/scep-probe", rbacGate(reg.Checker, "network_scan.run", reg.NetworkScan.ProbeSCEP))
// URLs and reuses the existing SafeHTTPDialContext SSRF defense. r.Register("GET /api/v1/network-scan/scep-probes", rbacGate(reg.Checker, "network_scan.read", reg.NetworkScan.ListSCEPProbes))
r.Register("POST /api/v1/network-scan/scep-probe", http.HandlerFunc(reg.NetworkScan.ProbeSCEP))
r.Register("GET /api/v1/network-scan/scep-probes", http.HandlerFunc(reg.NetworkScan.ListSCEPProbes))
// Verification routes: /api/v1/jobs/{id}/verify and /api/v1/jobs/{id}/verification // Verification routes: /api/v1/jobs/{id}/verify and /api/v1/jobs/{id}/verification
r.Register("POST /api/v1/jobs/{id}/verify", http.HandlerFunc(reg.Verification.VerifyDeployment)) r.Register("POST /api/v1/jobs/{id}/verify", rbacGate(reg.Checker, "verification.run", reg.Verification.VerifyDeployment))
r.Register("GET /api/v1/jobs/{id}/verification", http.HandlerFunc(reg.Verification.GetVerificationStatus)) r.Register("GET /api/v1/jobs/{id}/verification", rbacGate(reg.Checker, "verification.read", reg.Verification.GetVerificationStatus))
// Digest routes: /api/v1/digest // Digest routes: /api/v1/digest
r.Register("GET /api/v1/digest/preview", http.HandlerFunc(reg.Digest.PreviewDigest)) r.Register("GET /api/v1/digest/preview", rbacGate(reg.Checker, "digest.read", reg.Digest.PreviewDigest))
r.Register("POST /api/v1/digest/send", http.HandlerFunc(reg.Digest.SendDigest)) r.Register("POST /api/v1/digest/send", rbacGate(reg.Checker, "digest.send", reg.Digest.SendDigest))
// Health check routes: /api/v1/health-checks // Health check routes: /api/v1/health-checks
// Summary endpoint must be registered before {id} routes // Summary endpoint must be registered before {id} routes
r.Register("GET /api/v1/health-checks/summary", http.HandlerFunc(reg.HealthChecks.GetHealthCheckSummary)) r.Register("GET /api/v1/health-checks/summary", rbacGate(reg.Checker, "healthcheck.read", reg.HealthChecks.GetHealthCheckSummary))
r.Register("GET /api/v1/health-checks", http.HandlerFunc(reg.HealthChecks.ListHealthChecks)) r.Register("GET /api/v1/health-checks", rbacGate(reg.Checker, "healthcheck.read", reg.HealthChecks.ListHealthChecks))
r.Register("POST /api/v1/health-checks", http.HandlerFunc(reg.HealthChecks.CreateHealthCheck)) r.Register("POST /api/v1/health-checks", rbacGate(reg.Checker, "healthcheck.edit", reg.HealthChecks.CreateHealthCheck))
r.Register("GET /api/v1/health-checks/{id}", http.HandlerFunc(reg.HealthChecks.GetHealthCheck)) r.Register("GET /api/v1/health-checks/{id}", rbacGate(reg.Checker, "healthcheck.read", reg.HealthChecks.GetHealthCheck))
r.Register("PUT /api/v1/health-checks/{id}", http.HandlerFunc(reg.HealthChecks.UpdateHealthCheck)) r.Register("PUT /api/v1/health-checks/{id}", rbacGate(reg.Checker, "healthcheck.edit", reg.HealthChecks.UpdateHealthCheck))
r.Register("DELETE /api/v1/health-checks/{id}", http.HandlerFunc(reg.HealthChecks.DeleteHealthCheck)) r.Register("DELETE /api/v1/health-checks/{id}", rbacGate(reg.Checker, "healthcheck.delete", reg.HealthChecks.DeleteHealthCheck))
r.Register("GET /api/v1/health-checks/{id}/history", http.HandlerFunc(reg.HealthChecks.GetHealthCheckHistory)) r.Register("GET /api/v1/health-checks/{id}/history", rbacGate(reg.Checker, "healthcheck.read", reg.HealthChecks.GetHealthCheckHistory))
r.Register("POST /api/v1/health-checks/{id}/acknowledge", http.HandlerFunc(reg.HealthChecks.AcknowledgeHealthCheck)) r.Register("POST /api/v1/health-checks/{id}/acknowledge", rbacGate(reg.Checker, "healthcheck.acknowledge", reg.HealthChecks.AcknowledgeHealthCheck))
// ACME (RFC 8555 + RFC 9773 ARI) server endpoints. Phase 1a wires // ACME (RFC 8555 + RFC 9773 ARI) server endpoints. Phase 1a wires
// directory + new-nonce only; Phases 1b-4 extend with the JWS- // directory + new-nonce only; Phases 1b-4 extend with the JWS-
@@ -0,0 +1,161 @@
package router
import (
"go/ast"
"go/parser"
"go/token"
"sort"
"strings"
"testing"
)
// TestRouterRBACGateCoverage AST-walks router.go and asserts that every
// state-changing handler registration goes through rbacGate or
// rbacGateScoped, excepting (a) protocol endpoints (ACME / SCEP / EST /
// CRL / OCSP) that authenticate via their own protocol primitives,
// (b) the bootstrap endpoint which is auth-exempt by design,
// (c) auth-info / login / logout / break-glass-login / health surfaces
// that establish identity rather than carry it.
//
// This is the ratchet that prevents 2026-05-10 audit CRIT-1 from
// regressing. A developer who registers a new state-changing handler
// (or a list endpoint) without rbacGate / rbacGateScoped fails this
// test. Update authExemptRoutes ONLY when registering a new
// auth-exempt surface, and document the addition in the commit body.
//
// See cowork/auth-bundles-audit-2026-05-10.md CRIT-1 for the closure
// history.
func TestRouterRBACGateCoverage(t *testing.T) {
// Routes whose handlers MUST stay ungated. Every entry here is a
// surface that establishes identity or is RFC-mandated unauth.
// Adding a new entry requires a justification comment.
authExemptRoutes := map[string]string{
// Identity-bearing surfaces (the gate would be circular):
"GET /api/v1/auth/me": "every caller may read their own identity",
"GET /api/v1/auth/permissions": "every caller may read the global permission catalogue",
"GET /api/v1/auth/check": "identity-probe; gating would be circular",
// Auth handshake surfaces (no identity at request time):
"GET /auth/oidc/login": "OIDC handshake start; no Bearer at this point",
"GET /auth/oidc/callback": "IdP redirects here pre-auth; cookie+state validated inside",
"POST /auth/oidc/back-channel-logout": "IdP-initiated; auth via IdP-signed logout_token in body",
"POST /auth/logout": "caller session-cookie is checked inside the handler",
"POST /auth/breakglass/login": "local-password recovery; surface invisible when disabled",
"GET /api/v1/auth/bootstrap": "day-0 admin probe; pre-admin by definition",
"POST /api/v1/auth/bootstrap": "consumes one-shot bootstrap token from body",
// Health / version / info:
"GET /health": "K8s/Docker liveness probe; cannot carry Bearer",
"GET /ready": "K8s/Docker readiness probe; cannot carry Bearer",
"GET /api/v1/auth/info": "GUI reads before login to detect auth mode",
"GET /api/v1/version": "rollout probes; pre-auth allowed",
}
// Protocol-endpoint prefixes — every r.Register against one of these
// is intentionally ungated (protocol-level auth via JWS / mTLS / CSR-
// embedded credentials). Mirrors AuthExemptDispatchPrefixes plus the
// in-router ACME paths.
protocolPrefixes := []string{
"/acme/",
"/scep",
"/.well-known/pki",
"/.well-known/est",
}
fset := token.NewFileSet()
f, err := parser.ParseFile(fset, "router.go", nil, parser.ParseComments)
if err != nil {
t.Fatalf("parse router.go: %v", err)
}
var unguarded []string
ast.Inspect(f, func(n ast.Node) bool {
call, ok := n.(*ast.CallExpr)
if !ok {
return true
}
sel, ok := call.Fun.(*ast.SelectorExpr)
if !ok || sel.Sel.Name != "Register" {
return true
}
// Reject calls that aren't r.Register (e.g. mux.Handle is filtered out
// by the SelectorExpr.X check below). The router type is `*Router`;
// we accept any selector since RegisterFunc also wraps Register.
_ = sel
if len(call.Args) < 2 {
return true
}
routeLit, ok := call.Args[0].(*ast.BasicLit)
if !ok || routeLit.Kind != token.STRING {
return true
}
route := strings.Trim(routeLit.Value, `"`)
// Only inspect routes that should be gated: state-changing
// (POST/PUT/PATCH/DELETE) or any read endpoint (GET).
if !isHTTPMethodRoute(route) {
return true
}
// Auth-exempt allowlist?
if _, ok := authExemptRoutes[route]; ok {
return true
}
// Protocol prefix?
if hasProtocolPrefix(route, protocolPrefixes) {
return true
}
// Inspect arg 1: must be rbacGate(...) or rbacGateScoped(...).
wrap, ok := call.Args[1].(*ast.CallExpr)
if !ok {
unguarded = append(unguarded, route)
return true
}
wrapName := ""
switch fn := wrap.Fun.(type) {
case *ast.Ident:
wrapName = fn.Name
case *ast.SelectorExpr:
wrapName = fn.Sel.Name
}
if wrapName != "rbacGate" && wrapName != "rbacGateScoped" {
unguarded = append(unguarded, route)
}
return true
})
if len(unguarded) > 0 {
sort.Strings(unguarded)
t.Fatalf("router.go: %d routes registered without rbacGate / rbacGateScoped (and not in authExemptRoutes / protocolPrefixes):\n %s\n\n"+
"If a new auth-exempt surface is intentional, add it to authExemptRoutes (or protocolPrefixes) "+
"with a justification comment. Otherwise wrap with rbacGate(reg.Checker, \"<perm>\", <handler>).\n\n"+
"This test pins the 2026-05-10 audit CRIT-1 closure. Removing an existing rbacGate wrap requires "+
"either (a) moving the route to authExemptRoutes here, or (b) demonstrating the new approach in "+
"the commit body.",
len(unguarded), strings.Join(unguarded, "\n "))
}
}
func isHTTPMethodRoute(route string) bool {
for _, prefix := range []string{"GET ", "POST ", "PUT ", "PATCH ", "DELETE ", "HEAD "} {
if strings.HasPrefix(route, prefix) {
return true
}
}
return false
}
func hasProtocolPrefix(route string, prefixes []string) bool {
// Strip the method token to compare against URL prefixes.
idx := strings.Index(route, " ")
if idx == -1 {
return false
}
urlPart := route[idx+1:]
for _, p := range prefixes {
if strings.HasPrefix(urlPart, p) {
return true
}
}
return false
}
+140 -9
View File
@@ -30,22 +30,31 @@ const (
// actor: the API rejects mutations / deletions targeting this id. // actor: the API rejects mutations / deletions targeting this id.
const DemoAnonActorID = "actor-demo-anon" const DemoAnonActorID = "actor-demo-anon"
// CanonicalPermissions is the canonical Bundle 1 permission catalog, // CanonicalPermissions is the canonical permission catalog seeded by
// seeded by migration 000029_rbac.up.sql. Bundle 2 extends with // migrations 000029 / 000030 / 000037 / 000038 / 000039. Bundle 2
// auth.session.* and auth.oidc.* permissions (those land in Bundle 2 // extended with auth.session.* and auth.oidc.* permissions; the
// Phase 5's migration). // 2026-05-10 audit (CRIT-1 closure) seeded the legacy-CRUD perms
// (policy/team/owner/job/approval/notification/discovery/network_scan/
// healthcheck/digest/verification/stats/metrics + cert.edit) via
// migration 000039.
// //
// Naming convention: <namespace>.<verb>. Read permissions use // Naming convention: <namespace>.<verb>. Read permissions use
// `<resource>.read`; mutations use `.create`, `.edit`, `.delete`, // `<resource>.read`; mutations use `.create`, `.edit`, `.delete`,
// `.assign`, `.revoke`, `.use`, `.export`, etc. The catalog is the // `.assign`, `.revoke`, `.use`, `.export`, etc. The catalog is the
// single source of truth referenced by: // single source of truth referenced by:
// - migration 000029_rbac.up.sql (seeds the rows) // - migration 000029_rbac.up.sql + 000030 + 000037 + 000038 + 000039 (seed the rows)
// - service layer (RoleService.Create rejects unknown permissions) // - service layer (RoleService.Create rejects unknown permissions)
// - handler layer (auth.RequirePermission perm string) // - handler layer (auth.RequirePermission perm string)
// - router layer (rbacGate(reg.Checker, "<perm>", ...) at every
// state-changing route + read endpoints)
//
// TestRouterRBACGateCoverage in internal/api/router/router_test.go is
// the AST-level CI guard that pins router enforcement to this catalogue.
var CanonicalPermissions = []string{ var CanonicalPermissions = []string{
// Certificate lifecycle // Certificate lifecycle
"cert.read", "cert.read",
"cert.issue", "cert.issue",
"cert.edit", // metadata updates, deploy triggers, bulk-reassign (Audit CRIT-1)
"cert.revoke", "cert.revoke",
"cert.delete", "cert.delete",
@@ -129,22 +138,101 @@ var CanonicalPermissions = []string{
// (Service.Enabled() short-circuits every operation when false). // (Service.Enabled() short-circuits every operation when false).
"auth.breakglass.admin", "auth.breakglass.admin",
"auth.breakglass.login", "auth.breakglass.login",
// Audit 2026-05-10 CRIT-1 closure — legacy-CRUD permission set.
// Seeded by migration 000039 + wrapped at the router level by
// rbacGate / rbacGateScoped on every state-changing + read route.
// Job lifecycle.
"job.read",
"job.cancel",
// Approval workflow (Rank 7 primitive — was previously ungated).
"approval.read",
"approval.approve",
"approval.reject",
// Policy management (compliance rules).
"policy.read",
"policy.edit",
"policy.delete",
// Team management.
"team.read",
"team.edit",
"team.delete",
// Owner management.
"owner.read",
"owner.edit",
"owner.delete",
// Notifications.
"notification.read",
"notification.edit", // mark-read, requeue
// Discovery (agent-submitted + cloud-secret-store scans).
"discovery.read",
"discovery.run", // agents submit discovery reports
"discovery.claim", // claim/dismiss discovered certs
// Network scan + SCEP probing.
"network_scan.read",
"network_scan.edit",
"network_scan.run",
// Health checks (uptime monitors).
"healthcheck.read",
"healthcheck.edit",
"healthcheck.delete",
"healthcheck.acknowledge",
// Digest (operator-summary emails).
"digest.read",
"digest.send",
// Verification (post-deploy probe).
"verification.read",
"verification.run",
// Read-only observability.
"stats.read",
"metrics.read",
} }
// DefaultRoles describes the seven default roles seeded by the // DefaultRoles describes the seven default roles seeded by the
// migration, mapped to the permissions each role holds at global // migration, mapped to the permissions each role holds at global
// scope. Permissions not in CanonicalPermissions cause the migration // scope. Permissions not in CanonicalPermissions cause the migration
// to fail-closed. // to fail-closed.
//
// r-auditor is invariant: exactly {audit.read, audit.export} per the
// auditor_test.go pin. Adding a new permission here that ends up in
// r-auditor breaks the pin — by design.
var DefaultRoles = map[string][]string{ var DefaultRoles = map[string][]string{
RoleIDAdmin: CanonicalPermissions, // admin gets every permission RoleIDAdmin: CanonicalPermissions, // admin gets every permission
RoleIDOperator: { RoleIDOperator: {
"cert.read", "cert.issue", "cert.revoke", "cert.delete", // Cert lifecycle (full)
"cert.read", "cert.issue", "cert.edit", "cert.revoke", "cert.delete",
// Profile / issuer / target / agent — read + edit (no delete on issuer)
"profile.read", "profile.edit", "profile.read", "profile.edit",
"issuer.read", "issuer.edit", "issuer.read", "issuer.edit",
"target.read", "target.edit", "target.delete", "target.read", "target.edit", "target.delete",
"agent.read", "agent.edit", "agent.read", "agent.edit",
// Audit read
"audit.read", "audit.read",
// New CRIT-1 perms — operator-level CRUD
"job.read", "job.cancel",
"approval.read", "approval.approve", "approval.reject",
"policy.read", "policy.edit", "policy.delete",
"team.read", "team.edit", "team.delete",
"owner.read", "owner.edit", "owner.delete",
"notification.read", "notification.edit",
"discovery.read", "discovery.run", "discovery.claim",
"network_scan.read", "network_scan.edit", "network_scan.run",
"healthcheck.read", "healthcheck.edit", "healthcheck.delete", "healthcheck.acknowledge",
"digest.read", "digest.send",
"verification.read", "verification.run",
"stats.read", "metrics.read",
}, },
RoleIDViewer: { RoleIDViewer: {
@@ -154,6 +242,20 @@ var DefaultRoles = map[string][]string{
"target.read", "target.read",
"agent.read", "agent.read",
"audit.read", "audit.read",
// New CRIT-1 read-only perms
"job.read",
"approval.read",
"policy.read",
"team.read",
"owner.read",
"notification.read",
"discovery.read",
"network_scan.read",
"healthcheck.read",
"digest.read",
"verification.read",
"stats.read",
"metrics.read",
}, },
RoleIDAgent: { RoleIDAgent: {
@@ -162,37 +264,66 @@ var DefaultRoles = map[string][]string{
"agent.job.poll", "agent.job.poll",
"agent.job.complete", "agent.job.complete",
"agent.job.report", "agent.job.report",
// Agents submit discovery reports.
"discovery.run",
}, },
RoleIDMCP: { RoleIDMCP: {
// MCP gets operator-equivalent minus destructive ops. // MCP gets operator-equivalent minus destructive ops.
// Defense in depth for Claude / IDE integrations where // Defense in depth for Claude / IDE integrations where
// destructive verbs warrant additional scrutiny. // destructive verbs warrant additional scrutiny.
"cert.read", "cert.issue", "cert.revoke", "cert.read", "cert.issue", "cert.edit", "cert.revoke",
"profile.read", "profile.edit", "profile.read", "profile.edit",
"issuer.read", "issuer.edit", "issuer.read", "issuer.edit",
"target.read", "target.edit", "target.read", "target.edit",
"agent.read", "agent.read",
"audit.read", "audit.read",
// New CRIT-1 — read + non-destructive verbs
"job.read", "job.cancel",
"approval.read", "approval.approve", "approval.reject",
"policy.read",
"team.read", "owner.read",
"notification.read", "notification.edit",
"discovery.read", "discovery.claim",
"network_scan.read", "network_scan.run",
"healthcheck.read", "healthcheck.acknowledge",
"digest.read",
"verification.read", "verification.run",
"stats.read", "metrics.read",
}, },
RoleIDCLI: { RoleIDCLI: {
// CLI = operator-equivalent. Operators can scope down via // CLI = operator-equivalent. Operators can scope down via
// `certctl auth keys scope-down` if they want narrower CLI // `certctl auth keys scope-down` if they want narrower CLI
// access in production. // access in production.
"cert.read", "cert.issue", "cert.revoke", "cert.delete", "cert.read", "cert.issue", "cert.edit", "cert.revoke", "cert.delete",
"profile.read", "profile.edit", "profile.read", "profile.edit",
"issuer.read", "issuer.edit", "issuer.read", "issuer.edit",
"target.read", "target.edit", "target.delete", "target.read", "target.edit", "target.delete",
"agent.read", "agent.edit", "agent.read", "agent.edit",
"audit.read", "audit.read",
"auth.key.list", "auth.key.create", "auth.key.rotate", "auth.key.list", "auth.key.create", "auth.key.rotate",
// New CRIT-1 — CLI gets operator-tier
"job.read", "job.cancel",
"approval.read", "approval.approve", "approval.reject",
"policy.read", "policy.edit", "policy.delete",
"team.read", "team.edit",
"owner.read", "owner.edit",
"notification.read", "notification.edit",
"discovery.read", "discovery.run", "discovery.claim",
"network_scan.read", "network_scan.edit", "network_scan.run",
"healthcheck.read", "healthcheck.edit", "healthcheck.acknowledge",
"digest.read", "digest.send",
"verification.read", "verification.run",
"stats.read", "metrics.read",
}, },
RoleIDAuditor: { RoleIDAuditor: {
// Phase 8 ships the auditor split. Phase 1 reserves the // Phase 8 ships the auditor split. Phase 1 reserves the
// role id + the read-only permission set so subsequent // role id + the read-only permission set so subsequent
// phases don't have to renumber. // phases don't have to renumber. Audit 2026-05-10 CRIT-1
// closure intentionally adds NOTHING here — auditor pins
// stay invariant at audit.read + audit.export.
"audit.read", "audit.read",
"audit.export", "audit.export",
}, },
@@ -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;