diff --git a/CHANGELOG.md b/CHANGELOG.md index 6c2f96f..2beadf3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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`, diff --git a/docs/operator/rbac.md b/docs/operator/rbac.md index a499013..52eb3f2 100644 --- a/docs/operator/rbac.md +++ b/docs/operator/rbac.md @@ -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 diff --git a/internal/api/router/router.go b/internal/api/router/router.go index 9cef374..5b834f4 100644 --- a/internal/api/router/router.go +++ b/internal/api/router/router.go @@ -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=) +// and PUT /api/v1/issuers/{id} (scope_type=issuer, scope_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- diff --git a/internal/api/router/router_rbac_coverage_test.go b/internal/api/router/router_rbac_coverage_test.go new file mode 100644 index 0000000..cddc82d --- /dev/null +++ b/internal/api/router/router_rbac_coverage_test.go @@ -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, \"\", ).\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 +} diff --git a/internal/domain/auth/validate.go b/internal/domain/auth/validate.go index 15123fd..aff793d 100644 --- a/internal/domain/auth/validate.go +++ b/internal/domain/auth/validate.go @@ -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: .. Read permissions use // `.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, "", ...) 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", }, diff --git a/migrations/000039_audit_crit1_perms.down.sql b/migrations/000039_audit_crit1_perms.down.sql new file mode 100644 index 0000000..14b5a31 --- /dev/null +++ b/migrations/000039_audit_crit1_perms.down.sql @@ -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; diff --git a/migrations/000039_audit_crit1_perms.up.sql b/migrations/000039_audit_crit1_perms.up.sql new file mode 100644 index 0000000..e0e7ee4 --- /dev/null +++ b/migrations/000039_audit_crit1_perms.up.sql @@ -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;