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:
- **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`,
`role_permissions`, `actor_roles` tables (migration 000029); 33-permission
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.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) |
| `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
+196 -140
View File
@@ -9,9 +9,17 @@ import (
)
// rbacGate wraps a handler with auth.RequirePermission(checker, perm,
// nil). Used by RegisterHandlers to gate the legacy admin routes
// (Bundle 1 Phase 3.5). When checker is nil the wrap is a no-op so
// tests / demo deployments without the RBAC stack continue to work.
// nil) — i.e. a GLOBAL-SCOPE permission check. Used by RegisterHandlers
// to gate every state-changing + read endpoint. When checker is nil the
// 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 {
if checker == nil {
return h
@@ -19,6 +27,40 @@ func rbacGate(checker auth.PermissionChecker, perm string, h http.HandlerFunc) 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.
type Router struct {
mux *http.ServeMux
@@ -307,23 +349,25 @@ func (r *Router) RegisterHandlers(reg HandlerRegistry) {
middleware.ContentType,
))
// RBAC management routes (Bundle 1 Phase 4). Permission gates are
// enforced inside each handler via the service layer; the Phase 3
// auth.RequirePermission middleware factory will wrap these in a
// Phase 3.5 router-level pass once the legacy admin handlers are
// converted in lockstep.
// RBAC management routes (Bundle 1 Phase 4 + audit 2026-05-10 CRIT-1
// closure). Permission gates are now ALSO enforced at the router
// level via rbacGate — Bundle 1 Phase 4 left these handler-only
// (service-layer Authorizer check), which was a defense-in-depth
// 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/permissions", http.HandlerFunc(reg.Auth.ListPermissions))
r.Register("GET /api/v1/auth/roles", http.HandlerFunc(reg.Auth.ListRoles))
r.Register("POST /api/v1/auth/roles", http.HandlerFunc(reg.Auth.CreateRole))
r.Register("GET /api/v1/auth/roles/{id}", http.HandlerFunc(reg.Auth.GetRole))
r.Register("PUT /api/v1/auth/roles/{id}", http.HandlerFunc(reg.Auth.UpdateRole))
r.Register("DELETE /api/v1/auth/roles/{id}", http.HandlerFunc(reg.Auth.DeleteRole))
r.Register("POST /api/v1/auth/roles/{id}/permissions", http.HandlerFunc(reg.Auth.AddRolePermission))
r.Register("DELETE /api/v1/auth/roles/{id}/permissions/{perm}", http.HandlerFunc(reg.Auth.RemoveRolePermission))
r.Register("GET /api/v1/auth/keys", http.HandlerFunc(reg.Auth.ListKeys))
r.Register("POST /api/v1/auth/keys/{id}/roles", http.HandlerFunc(reg.Auth.AssignRoleToKey))
r.Register("DELETE /api/v1/auth/keys/{id}/roles/{role_id}", http.HandlerFunc(reg.Auth.RevokeRoleFromKey))
r.Register("GET /api/v1/auth/roles", rbacGate(reg.Checker, "auth.role.list", reg.Auth.ListRoles))
r.Register("POST /api/v1/auth/roles", rbacGate(reg.Checker, "auth.role.create", reg.Auth.CreateRole))
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}", rbacGate(reg.Checker, "auth.role.edit", reg.Auth.UpdateRole))
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", rbacGate(reg.Checker, "auth.role.edit", reg.Auth.AddRolePermission))
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", rbacGate(reg.Checker, "auth.key.list", reg.Auth.ListKeys))
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}", rbacGate(reg.Checker, "auth.role.revoke", reg.Auth.RevokeRoleFromKey))
// =========================================================================
// 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
// 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/certificates/bulk-renew", http.HandlerFunc(reg.BulkRenewal.BulkRenew))
r.Register("POST /api/v1/certificates/bulk-reassign", http.HandlerFunc(reg.BulkReassignment.BulkReassign))
r.Register("GET /api/v1/certificates", http.HandlerFunc(reg.Certificates.ListCertificates))
r.Register("POST /api/v1/certificates", http.HandlerFunc(reg.Certificates.CreateCertificate))
r.Register("GET /api/v1/certificates/{id}", http.HandlerFunc(reg.Certificates.GetCertificate))
r.Register("PUT /api/v1/certificates/{id}", http.HandlerFunc(reg.Certificates.UpdateCertificate))
r.Register("DELETE /api/v1/certificates/{id}", http.HandlerFunc(reg.Certificates.ArchiveCertificate))
r.Register("GET /api/v1/certificates/{id}/versions", http.HandlerFunc(reg.Certificates.GetCertificateVersions))
r.Register("GET /api/v1/certificates/{id}/deployments", http.HandlerFunc(reg.Certificates.GetCertificateDeployments))
r.Register("POST /api/v1/certificates/{id}/renew", http.HandlerFunc(reg.Certificates.TriggerRenewal))
r.Register("POST /api/v1/certificates/{id}/deploy", http.HandlerFunc(reg.Certificates.TriggerDeployment))
r.Register("POST /api/v1/certificates/{id}/revoke", http.HandlerFunc(reg.Certificates.RevokeCertificate))
r.Register("POST /api/v1/certificates/bulk-renew", rbacGate(reg.Checker, "cert.issue", reg.BulkRenewal.BulkRenew))
r.Register("POST /api/v1/certificates/bulk-reassign", rbacGate(reg.Checker, "cert.edit", reg.BulkReassignment.BulkReassign))
r.Register("GET /api/v1/certificates", rbacGate(reg.Checker, "cert.read", reg.Certificates.ListCertificates))
r.Register("POST /api/v1/certificates", rbacGate(reg.Checker, "cert.issue", reg.Certificates.CreateCertificate))
r.Register("GET /api/v1/certificates/{id}", rbacGate(reg.Checker, "cert.read", reg.Certificates.GetCertificate))
r.Register("PUT /api/v1/certificates/{id}", rbacGate(reg.Checker, "cert.edit", reg.Certificates.UpdateCertificate))
r.Register("DELETE /api/v1/certificates/{id}", rbacGate(reg.Checker, "cert.delete", reg.Certificates.ArchiveCertificate))
r.Register("GET /api/v1/certificates/{id}/versions", rbacGate(reg.Checker, "cert.read", reg.Certificates.GetCertificateVersions))
r.Register("GET /api/v1/certificates/{id}/deployments", rbacGate(reg.Checker, "cert.read", reg.Certificates.GetCertificateDeployments))
r.Register("POST /api/v1/certificates/{id}/renew", rbacGate(reg.Checker, "cert.issue", reg.Certificates.TriggerRenewal))
r.Register("POST /api/v1/certificates/{id}/deploy", rbacGate(reg.Checker, "cert.edit", reg.Certificates.TriggerDeployment))
r.Register("POST /api/v1/certificates/{id}/revoke", rbacGate(reg.Checker, "cert.revoke", reg.Certificates.RevokeCertificate))
// Export endpoints: /api/v1/certificates/{id}/export/{format}
r.Register("GET /api/v1/certificates/{id}/export/pem", http.HandlerFunc(reg.Export.ExportPEM))
r.Register("POST /api/v1/certificates/{id}/export/pkcs12", http.HandlerFunc(reg.Export.ExportPKCS12))
// Export endpoints: /api/v1/certificates/{id}/export/{format}.
// Reading bytes — gated by cert.read.
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
// 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).
// Issuers routes: /api/v1/issuers
r.Register("GET /api/v1/issuers", http.HandlerFunc(reg.Issuers.ListIssuers))
r.Register("POST /api/v1/issuers", http.HandlerFunc(reg.Issuers.CreateIssuer))
r.Register("GET /api/v1/issuers/{id}", http.HandlerFunc(reg.Issuers.GetIssuer))
r.Register("PUT /api/v1/issuers/{id}", http.HandlerFunc(reg.Issuers.UpdateIssuer))
r.Register("DELETE /api/v1/issuers/{id}", http.HandlerFunc(reg.Issuers.DeleteIssuer))
r.Register("POST /api/v1/issuers/{id}/test", http.HandlerFunc(reg.Issuers.TestConnection))
// Path-scoped: PUT / DELETE / test on /{id} honor per-issuer
// scope-bound role-permission grants. Operators who grant
// issuer.edit scope_type=issuer scope_id=iss-internal-ca only
// authorize edits to that specific issuer.
r.Register("GET /api/v1/issuers", rbacGate(reg.Checker, "issuer.read", reg.Issuers.ListIssuers))
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
r.Register("GET /api/v1/targets", http.HandlerFunc(reg.Targets.ListTargets))
r.Register("POST /api/v1/targets", http.HandlerFunc(reg.Targets.CreateTarget))
r.Register("GET /api/v1/targets/{id}", http.HandlerFunc(reg.Targets.GetTarget))
r.Register("PUT /api/v1/targets/{id}", http.HandlerFunc(reg.Targets.UpdateTarget))
r.Register("DELETE /api/v1/targets/{id}", http.HandlerFunc(reg.Targets.DeleteTarget))
r.Register("POST /api/v1/targets/{id}/test", http.HandlerFunc(reg.Targets.TestTargetConnection))
r.Register("GET /api/v1/targets", rbacGate(reg.Checker, "target.read", reg.Targets.ListTargets))
r.Register("POST /api/v1/targets", rbacGate(reg.Checker, "target.edit", reg.Targets.CreateTarget))
r.Register("GET /api/v1/targets/{id}", rbacGate(reg.Checker, "target.read", reg.Targets.GetTarget))
r.Register("PUT /api/v1/targets/{id}", rbacGate(reg.Checker, "target.edit", reg.Targets.UpdateTarget))
r.Register("DELETE /api/v1/targets/{id}", rbacGate(reg.Checker, "target.delete", reg.Targets.DeleteTarget))
r.Register("POST /api/v1/targets/{id}/test", rbacGate(reg.Checker, "target.edit", reg.Targets.TestTargetConnection))
// 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
// hard-delete; the underlying repo does a soft-retire with
// optional cascade.
r.Register("GET /api/v1/agents", http.HandlerFunc(reg.Agents.ListAgents))
r.Register("POST /api/v1/agents", http.HandlerFunc(reg.Agents.RegisterAgent))
r.Register("GET /api/v1/agents/retired", http.HandlerFunc(reg.Agents.ListRetiredAgents))
r.Register("GET /api/v1/agents/{id}", http.HandlerFunc(reg.Agents.GetAgent))
r.Register("DELETE /api/v1/agents/{id}", http.HandlerFunc(reg.Agents.RetireAgent))
r.Register("POST /api/v1/agents/{id}/heartbeat", http.HandlerFunc(reg.Agents.Heartbeat))
r.Register("POST /api/v1/agents/{id}/csr", http.HandlerFunc(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}/work", http.HandlerFunc(reg.Agents.AgentGetWork))
r.Register("POST /api/v1/agents/{id}/jobs/{job_id}/status", http.HandlerFunc(reg.Agents.AgentReportJobStatus))
r.Register("GET /api/v1/agents", rbacGate(reg.Checker, "agent.read", reg.Agents.ListAgents))
r.Register("POST /api/v1/agents", rbacGate(reg.Checker, "agent.edit", reg.Agents.RegisterAgent))
r.Register("GET /api/v1/agents/retired", rbacGate(reg.Checker, "agent.read", reg.Agents.ListRetiredAgents))
r.Register("GET /api/v1/agents/{id}", rbacGate(reg.Checker, "agent.read", reg.Agents.GetAgent))
r.Register("DELETE /api/v1/agents/{id}", rbacGate(reg.Checker, "agent.retire", reg.Agents.RetireAgent))
r.Register("POST /api/v1/agents/{id}/heartbeat", rbacGate(reg.Checker, "agent.heartbeat", reg.Agents.Heartbeat))
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}", rbacGate(reg.Checker, "cert.read", reg.Agents.AgentCertificatePickup))
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", rbacGate(reg.Checker, "agent.job.complete", reg.Agents.AgentReportJobStatus))
// Jobs routes: /api/v1/jobs
r.Register("GET /api/v1/jobs", http.HandlerFunc(reg.Jobs.ListJobs))
r.Register("GET /api/v1/jobs/{id}", http.HandlerFunc(reg.Jobs.GetJob))
r.Register("POST /api/v1/jobs/{id}/cancel", http.HandlerFunc(reg.Jobs.CancelJob))
r.Register("POST /api/v1/jobs/{id}/approve", http.HandlerFunc(reg.Jobs.ApproveJob))
r.Register("POST /api/v1/jobs/{id}/reject", http.HandlerFunc(reg.Jobs.RejectJob))
r.Register("GET /api/v1/jobs", rbacGate(reg.Checker, "job.read", reg.Jobs.ListJobs))
r.Register("GET /api/v1/jobs/{id}", rbacGate(reg.Checker, "job.read", reg.Jobs.GetJob))
r.Register("POST /api/v1/jobs/{id}/cancel", rbacGate(reg.Checker, "job.cancel", reg.Jobs.CancelJob))
r.Register("POST /api/v1/jobs/{id}/approve", rbacGate(reg.Checker, "approval.approve", reg.Jobs.ApproveJob))
r.Register("POST /api/v1/jobs/{id}/reject", rbacGate(reg.Checker, "approval.reject", reg.Jobs.RejectJob))
// Policies routes: /api/v1/policies
r.Register("GET /api/v1/policies", http.HandlerFunc(reg.Policies.ListPolicies))
r.Register("POST /api/v1/policies", http.HandlerFunc(reg.Policies.CreatePolicy))
r.Register("GET /api/v1/policies/{id}", http.HandlerFunc(reg.Policies.GetPolicy))
r.Register("PUT /api/v1/policies/{id}", http.HandlerFunc(reg.Policies.UpdatePolicy))
r.Register("DELETE /api/v1/policies/{id}", http.HandlerFunc(reg.Policies.DeletePolicy))
r.Register("GET /api/v1/policies/{id}/violations", http.HandlerFunc(reg.Policies.ListViolations))
r.Register("GET /api/v1/policies", rbacGate(reg.Checker, "policy.read", reg.Policies.ListPolicies))
r.Register("POST /api/v1/policies", rbacGate(reg.Checker, "policy.edit", reg.Policies.CreatePolicy))
r.Register("GET /api/v1/policies/{id}", rbacGate(reg.Checker, "policy.read", reg.Policies.GetPolicy))
r.Register("PUT /api/v1/policies/{id}", rbacGate(reg.Checker, "policy.edit", reg.Policies.UpdatePolicy))
r.Register("DELETE /api/v1/policies/{id}", rbacGate(reg.Checker, "policy.delete", reg.Policies.DeletePolicy))
r.Register("GET /api/v1/policies/{id}/violations", rbacGate(reg.Checker, "policy.read", reg.Policies.ListViolations))
// Renewal Policies routes: /api/v1/renewal-policies
// 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 →
// renewal_policies(id) ON DELETE RESTRICT. This block is the backend half; the
// frontend half swaps getPolicies → getRenewalPolicies at 3 call sites.
r.Register("GET /api/v1/renewal-policies", http.HandlerFunc(reg.RenewalPolicies.ListRenewalPolicies))
r.Register("POST /api/v1/renewal-policies", http.HandlerFunc(reg.RenewalPolicies.CreateRenewalPolicy))
r.Register("GET /api/v1/renewal-policies/{id}", http.HandlerFunc(reg.RenewalPolicies.GetRenewalPolicy))
r.Register("PUT /api/v1/renewal-policies/{id}", http.HandlerFunc(reg.RenewalPolicies.UpdateRenewalPolicy))
r.Register("DELETE /api/v1/renewal-policies/{id}", http.HandlerFunc(reg.RenewalPolicies.DeleteRenewalPolicy))
// Reuses the policy.* permission catalogue entry (renewal policies are a
// subtype of policy from the operator's perspective).
r.Register("GET /api/v1/renewal-policies", rbacGate(reg.Checker, "policy.read", reg.RenewalPolicies.ListRenewalPolicies))
r.Register("POST /api/v1/renewal-policies", rbacGate(reg.Checker, "policy.edit", reg.RenewalPolicies.CreateRenewalPolicy))
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
r.Register("GET /api/v1/profiles", http.HandlerFunc(reg.Profiles.ListProfiles))
r.Register("POST /api/v1/profiles", http.HandlerFunc(reg.Profiles.CreateProfile))
r.Register("GET /api/v1/profiles/{id}", http.HandlerFunc(reg.Profiles.GetProfile))
r.Register("PUT /api/v1/profiles/{id}", http.HandlerFunc(reg.Profiles.UpdateProfile))
r.Register("DELETE /api/v1/profiles/{id}", http.HandlerFunc(reg.Profiles.DeleteProfile))
// Path-scoped: PUT / DELETE on /{id} honor per-profile scope-bound
// role-permission grants. Operators who grant profile.edit
// scope_type=profile scope_id=p-finance only authorize edits to
// that specific profile.
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
r.Register("GET /api/v1/teams", http.HandlerFunc(reg.Teams.ListTeams))
r.Register("POST /api/v1/teams", http.HandlerFunc(reg.Teams.CreateTeam))
r.Register("GET /api/v1/teams/{id}", http.HandlerFunc(reg.Teams.GetTeam))
r.Register("PUT /api/v1/teams/{id}", http.HandlerFunc(reg.Teams.UpdateTeam))
r.Register("DELETE /api/v1/teams/{id}", http.HandlerFunc(reg.Teams.DeleteTeam))
r.Register("GET /api/v1/teams", rbacGate(reg.Checker, "team.read", reg.Teams.ListTeams))
r.Register("POST /api/v1/teams", rbacGate(reg.Checker, "team.edit", reg.Teams.CreateTeam))
r.Register("GET /api/v1/teams/{id}", rbacGate(reg.Checker, "team.read", reg.Teams.GetTeam))
r.Register("PUT /api/v1/teams/{id}", rbacGate(reg.Checker, "team.edit", reg.Teams.UpdateTeam))
r.Register("DELETE /api/v1/teams/{id}", rbacGate(reg.Checker, "team.delete", reg.Teams.DeleteTeam))
// Owners routes: /api/v1/owners
r.Register("GET /api/v1/owners", http.HandlerFunc(reg.Owners.ListOwners))
r.Register("POST /api/v1/owners", http.HandlerFunc(reg.Owners.CreateOwner))
r.Register("GET /api/v1/owners/{id}", http.HandlerFunc(reg.Owners.GetOwner))
r.Register("PUT /api/v1/owners/{id}", http.HandlerFunc(reg.Owners.UpdateOwner))
r.Register("DELETE /api/v1/owners/{id}", http.HandlerFunc(reg.Owners.DeleteOwner))
r.Register("GET /api/v1/owners", rbacGate(reg.Checker, "owner.read", reg.Owners.ListOwners))
r.Register("POST /api/v1/owners", rbacGate(reg.Checker, "owner.edit", reg.Owners.CreateOwner))
r.Register("GET /api/v1/owners/{id}", rbacGate(reg.Checker, "owner.read", reg.Owners.GetOwner))
r.Register("PUT /api/v1/owners/{id}", rbacGate(reg.Checker, "owner.edit", reg.Owners.UpdateOwner))
r.Register("DELETE /api/v1/owners/{id}", rbacGate(reg.Checker, "owner.delete", reg.Owners.DeleteOwner))
// Agent Groups routes: /api/v1/agent-groups
r.Register("GET /api/v1/agent-groups", http.HandlerFunc(reg.AgentGroups.ListAgentGroups))
r.Register("POST /api/v1/agent-groups", http.HandlerFunc(reg.AgentGroups.CreateAgentGroup))
r.Register("GET /api/v1/agent-groups/{id}", http.HandlerFunc(reg.AgentGroups.GetAgentGroup))
r.Register("PUT /api/v1/agent-groups/{id}", http.HandlerFunc(reg.AgentGroups.UpdateAgentGroup))
r.Register("DELETE /api/v1/agent-groups/{id}", http.HandlerFunc(reg.AgentGroups.DeleteAgentGroup))
r.Register("GET /api/v1/agent-groups/{id}/members", http.HandlerFunc(reg.AgentGroups.ListAgentGroupMembers))
// Reuses agent.* permissions (agent-groups are an organizational
// view on top of the agent resource).
r.Register("GET /api/v1/agent-groups", rbacGate(reg.Checker, "agent.read", reg.AgentGroups.ListAgentGroups))
r.Register("POST /api/v1/agent-groups", rbacGate(reg.Checker, "agent.edit", reg.AgentGroups.CreateAgentGroup))
r.Register("GET /api/v1/agent-groups/{id}", rbacGate(reg.Checker, "agent.read", reg.AgentGroups.GetAgentGroup))
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
r.Register("GET /api/v1/audit", http.HandlerFunc(reg.Audit.ListAuditEvents))
r.Register("GET /api/v1/audit/{id}", http.HandlerFunc(reg.Audit.GetAuditEvent))
r.Register("GET /api/v1/audit", rbacGate(reg.Checker, "audit.read", reg.Audit.ListAuditEvents))
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
// 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))
// Notifications routes: /api/v1/notifications
r.Register("GET /api/v1/notifications", http.HandlerFunc(reg.Notifications.ListNotifications))
r.Register("GET /api/v1/notifications/{id}", http.HandlerFunc(reg.Notifications.GetNotification))
r.Register("POST /api/v1/notifications/{id}/read", http.HandlerFunc(reg.Notifications.MarkAsRead))
r.Register("GET /api/v1/notifications", rbacGate(reg.Checker, "notification.read", reg.Notifications.ListNotifications))
r.Register("GET /api/v1/notifications/{id}", rbacGate(reg.Checker, "notification.read", reg.Notifications.GetNotification))
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
// picks it up again. Go 1.22 ServeMux resolves the literal /requeue segment
// 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).
// Same Go 1.22 ServeMux precedence as the notifications block — literal
// /approve and /reject segments resolve before the {id} pattern-var
// route. Same-actor RBAC enforced at the service layer; the handler
// surfaces ErrApproveBySameActor as HTTP 403.
r.Register("GET /api/v1/approvals", http.HandlerFunc(reg.Approvals.ListApprovals))
r.Register("GET /api/v1/approvals/{id}", http.HandlerFunc(reg.Approvals.GetApproval))
r.Register("POST /api/v1/approvals/{id}/approve", http.HandlerFunc(reg.Approvals.Approve))
r.Register("POST /api/v1/approvals/{id}/reject", http.HandlerFunc(reg.Approvals.Reject))
// surfaces ErrApproveBySameActor as HTTP 403. Router-level gates
// added in the 2026-05-10 audit CRIT-1 closure (defense in depth).
r.Register("GET /api/v1/approvals", rbacGate(reg.Checker, "approval.read", reg.Approvals.ListApprovals))
r.Register("GET /api/v1/approvals/{id}", rbacGate(reg.Checker, "approval.read", reg.Approvals.GetApproval))
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
// 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))
// Stats routes: /api/v1/stats
r.Register("GET /api/v1/stats/summary", http.HandlerFunc(reg.Stats.GetDashboardSummary))
r.Register("GET /api/v1/stats/certificates-by-status", http.HandlerFunc(reg.Stats.GetCertificatesByStatus))
r.Register("GET /api/v1/stats/expiration-timeline", http.HandlerFunc(reg.Stats.GetExpirationTimeline))
r.Register("GET /api/v1/stats/job-trends", http.HandlerFunc(reg.Stats.GetJobTrends))
r.Register("GET /api/v1/stats/issuance-rate", http.HandlerFunc(reg.Stats.GetIssuanceRate))
r.Register("GET /api/v1/stats/summary", rbacGate(reg.Checker, "stats.read", reg.Stats.GetDashboardSummary))
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", rbacGate(reg.Checker, "stats.read", reg.Stats.GetExpirationTimeline))
r.Register("GET /api/v1/stats/job-trends", rbacGate(reg.Checker, "stats.read", reg.Stats.GetJobTrends))
r.Register("GET /api/v1/stats/issuance-rate", rbacGate(reg.Checker, "stats.read", reg.Stats.GetIssuanceRate))
// Metrics routes: /api/v1/metrics
r.Register("GET /api/v1/metrics", http.HandlerFunc(reg.Metrics.GetMetrics))
r.Register("GET /api/v1/metrics/prometheus", http.HandlerFunc(reg.Metrics.GetPrometheusMetrics))
r.Register("GET /api/v1/metrics", rbacGate(reg.Checker, "metrics.read", reg.Metrics.GetMetrics))
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
r.Register("POST /api/v1/agents/{id}/discoveries", http.HandlerFunc(reg.Discovery.SubmitDiscoveryReport))
r.Register("GET /api/v1/discovered-certificates", http.HandlerFunc(reg.Discovery.ListDiscovered))
r.Register("GET /api/v1/discovered-certificates/{id}", http.HandlerFunc(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}/dismiss", http.HandlerFunc(reg.Discovery.DismissDiscovered))
r.Register("GET /api/v1/discovery-scans", http.HandlerFunc(reg.Discovery.ListScans))
r.Register("GET /api/v1/discovery-summary", http.HandlerFunc(reg.Discovery.GetDiscoverySummary))
r.Register("POST /api/v1/agents/{id}/discoveries", rbacGate(reg.Checker, "discovery.run", reg.Discovery.SubmitDiscoveryReport))
r.Register("GET /api/v1/discovered-certificates", rbacGate(reg.Checker, "discovery.read", reg.Discovery.ListDiscovered))
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", rbacGate(reg.Checker, "discovery.claim", reg.Discovery.ClaimDiscovered))
r.Register("POST /api/v1/discovered-certificates/{id}/dismiss", rbacGate(reg.Checker, "discovery.claim", reg.Discovery.DismissDiscovered))
r.Register("GET /api/v1/discovery-scans", rbacGate(reg.Checker, "discovery.read", reg.Discovery.ListScans))
r.Register("GET /api/v1/discovery-summary", rbacGate(reg.Checker, "discovery.read", reg.Discovery.GetDiscoverySummary))
// Network scan routes: /api/v1/network-scan-targets
r.Register("GET /api/v1/network-scan-targets", http.HandlerFunc(reg.NetworkScan.ListNetworkScanTargets))
r.Register("POST /api/v1/network-scan-targets", http.HandlerFunc(reg.NetworkScan.CreateNetworkScanTarget))
r.Register("GET /api/v1/network-scan-targets/{id}", http.HandlerFunc(reg.NetworkScan.GetNetworkScanTarget))
r.Register("PUT /api/v1/network-scan-targets/{id}", http.HandlerFunc(reg.NetworkScan.UpdateNetworkScanTarget))
r.Register("DELETE /api/v1/network-scan-targets/{id}", http.HandlerFunc(reg.NetworkScan.DeleteNetworkScanTarget))
r.Register("POST /api/v1/network-scan-targets/{id}/scan", http.HandlerFunc(reg.NetworkScan.TriggerNetworkScan))
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", rbacGate(reg.Checker, "network_scan.edit", reg.NetworkScan.CreateNetworkScanTarget))
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}", rbacGate(reg.Checker, "network_scan.edit", reg.NetworkScan.UpdateNetworkScanTarget))
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", rbacGate(reg.Checker, "network_scan.run", reg.NetworkScan.TriggerNetworkScan))
// SCEP RFC 8894 + Intune master bundle Phase 11.5 — SCEP probe.
// Bearer-auth gated by the standard middleware chain; not admin-
// only because the probe is read-only against operator-supplied
// URLs and reuses the existing SafeHTTPDialContext SSRF defense.
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))
// Now RBAC-gated by network_scan.run (was Bearer-only pre-audit).
r.Register("POST /api/v1/network-scan/scep-probe", rbacGate(reg.Checker, "network_scan.run", reg.NetworkScan.ProbeSCEP))
r.Register("GET /api/v1/network-scan/scep-probes", rbacGate(reg.Checker, "network_scan.read", reg.NetworkScan.ListSCEPProbes))
// 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("GET /api/v1/jobs/{id}/verification", http.HandlerFunc(reg.Verification.GetVerificationStatus))
r.Register("POST /api/v1/jobs/{id}/verify", rbacGate(reg.Checker, "verification.run", reg.Verification.VerifyDeployment))
r.Register("GET /api/v1/jobs/{id}/verification", rbacGate(reg.Checker, "verification.read", reg.Verification.GetVerificationStatus))
// Digest routes: /api/v1/digest
r.Register("GET /api/v1/digest/preview", http.HandlerFunc(reg.Digest.PreviewDigest))
r.Register("POST /api/v1/digest/send", http.HandlerFunc(reg.Digest.SendDigest))
r.Register("GET /api/v1/digest/preview", rbacGate(reg.Checker, "digest.read", reg.Digest.PreviewDigest))
r.Register("POST /api/v1/digest/send", rbacGate(reg.Checker, "digest.send", reg.Digest.SendDigest))
// Health check routes: /api/v1/health-checks
// 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", http.HandlerFunc(reg.HealthChecks.ListHealthChecks))
r.Register("POST /api/v1/health-checks", http.HandlerFunc(reg.HealthChecks.CreateHealthCheck))
r.Register("GET /api/v1/health-checks/{id}", http.HandlerFunc(reg.HealthChecks.GetHealthCheck))
r.Register("PUT /api/v1/health-checks/{id}", http.HandlerFunc(reg.HealthChecks.UpdateHealthCheck))
r.Register("DELETE /api/v1/health-checks/{id}", http.HandlerFunc(reg.HealthChecks.DeleteHealthCheck))
r.Register("GET /api/v1/health-checks/{id}/history", http.HandlerFunc(reg.HealthChecks.GetHealthCheckHistory))
r.Register("POST /api/v1/health-checks/{id}/acknowledge", http.HandlerFunc(reg.HealthChecks.AcknowledgeHealthCheck))
r.Register("GET /api/v1/health-checks/summary", rbacGate(reg.Checker, "healthcheck.read", reg.HealthChecks.GetHealthCheckSummary))
r.Register("GET /api/v1/health-checks", rbacGate(reg.Checker, "healthcheck.read", reg.HealthChecks.ListHealthChecks))
r.Register("POST /api/v1/health-checks", rbacGate(reg.Checker, "healthcheck.edit", reg.HealthChecks.CreateHealthCheck))
r.Register("GET /api/v1/health-checks/{id}", rbacGate(reg.Checker, "healthcheck.read", reg.HealthChecks.GetHealthCheck))
r.Register("PUT /api/v1/health-checks/{id}", rbacGate(reg.Checker, "healthcheck.edit", reg.HealthChecks.UpdateHealthCheck))
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", rbacGate(reg.Checker, "healthcheck.read", reg.HealthChecks.GetHealthCheckHistory))
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
// 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.
const DemoAnonActorID = "actor-demo-anon"
// CanonicalPermissions is the canonical Bundle 1 permission catalog,
// seeded by migration 000029_rbac.up.sql. Bundle 2 extends with
// auth.session.* and auth.oidc.* permissions (those land in Bundle 2
// Phase 5's migration).
// CanonicalPermissions is the canonical permission catalog seeded by
// migrations 000029 / 000030 / 000037 / 000038 / 000039. Bundle 2
// extended with auth.session.* and auth.oidc.* permissions; the
// 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
// `<resource>.read`; mutations use `.create`, `.edit`, `.delete`,
// `.assign`, `.revoke`, `.use`, `.export`, etc. The catalog is the
// 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)
// - 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{
// Certificate lifecycle
"cert.read",
"cert.issue",
"cert.edit", // metadata updates, deploy triggers, bulk-reassign (Audit CRIT-1)
"cert.revoke",
"cert.delete",
@@ -129,22 +138,101 @@ var CanonicalPermissions = []string{
// (Service.Enabled() short-circuits every operation when false).
"auth.breakglass.admin",
"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
// migration, mapped to the permissions each role holds at global
// scope. Permissions not in CanonicalPermissions cause the migration
// 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{
RoleIDAdmin: CanonicalPermissions, // admin gets every permission
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",
"issuer.read", "issuer.edit",
"target.read", "target.edit", "target.delete",
"agent.read", "agent.edit",
// 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: {
@@ -154,6 +242,20 @@ var DefaultRoles = map[string][]string{
"target.read",
"agent.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: {
@@ -162,37 +264,66 @@ var DefaultRoles = map[string][]string{
"agent.job.poll",
"agent.job.complete",
"agent.job.report",
// Agents submit discovery reports.
"discovery.run",
},
RoleIDMCP: {
// MCP gets operator-equivalent minus destructive ops.
// Defense in depth for Claude / IDE integrations where
// destructive verbs warrant additional scrutiny.
"cert.read", "cert.issue", "cert.revoke",
"cert.read", "cert.issue", "cert.edit", "cert.revoke",
"profile.read", "profile.edit",
"issuer.read", "issuer.edit",
"target.read", "target.edit",
"agent.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: {
// CLI = operator-equivalent. Operators can scope down via
// `certctl auth keys scope-down` if they want narrower CLI
// 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",
"issuer.read", "issuer.edit",
"target.read", "target.edit", "target.delete",
"agent.read", "agent.edit",
"audit.read",
"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: {
// Phase 8 ships the auditor split. Phase 1 reserves the
// 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.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;