mirror of
https://github.com/shankar0123/certctl.git
synced 2026-06-07 13:41:30 +00:00
68ca42fef1
Closes the wire-layer authorization gap surfaced by the 2026-05-10 audit
(CRIT-1). Before this commit only ~24 of ~140 routes carried rbacGate
enforcement — all of them admin-only fine-grained perms (auth.session.*,
auth.oidc.*, auth.breakglass.admin, cert.bulk_revoke, crl.admin, scep.admin,
est.admin, ca.hierarchy.manage). Every catalogued legacy-CRUD perm
(cert.read/issue/revoke/delete, profile.edit/delete, issuer.edit/delete,
target.*, agent.*, plus role-mgmt verbs) was declared in
internal/domain/auth/validate.go but never wired at the router. A r-viewer
Bearer was essentially r-admin minus five verbs at the wire layer (CWE-862).
This commit:
- Adds rbacGateScoped(checker, perm, scopeType, scopeFn, h) helper to
internal/api/router/router.go for path-bound scope resolution. Per-profile
and per-issuer grants (Decision 2) now reach the wire layer.
- Wraps every state-changing route AND every read endpoint in router.go
with rbacGate (global) or rbacGateScoped (path-bound). The auth-management
routes (POST /api/v1/auth/roles, etc.) gain router-level enforcement
in addition to the existing service-layer Authorizer check — defense in
depth (HIGH-9 of the same audit collapses into this closure).
- Auth-exempt surfaces stay un-gated by design: login, callback, BCL,
logout, breakglass-login, bootstrap, health, auth-info, version. Allowlist
is documented in TestRouterRBACGateCoverage.
- Extends internal/domain/auth/validate.go CanonicalPermissions with 30 new
perms across 12 namespaces: cert.edit; job.read, job.cancel; approval.read,
approval.approve, approval.reject; policy.read/edit/delete;
team.read/edit/delete; owner.read/edit/delete; notification.read/edit;
discovery.read/run/claim; network_scan.read/edit/run;
healthcheck.read/edit/delete/acknowledge; digest.read, digest.send;
verification.read, verification.run; stats.read; metrics.read.
- Updates DefaultRoles for r-admin / r-operator / r-viewer / r-mcp / r-cli /
r-agent. r-auditor gets NOTHING new — the auditor pin
(TestAuditorRoleHoldsExactlyAuditReadAndExport) stays invariant.
- Migration 000039_audit_crit1_perms seeds the new perm rows + role grants
per the updated DefaultRoles map. Idempotent ON CONFLICT DO NOTHING.
Reverse migration removes role_permissions before permissions
(ON DELETE RESTRICT on the FK).
- AST-level CI guard TestRouterRBACGateCoverage in
internal/api/router/router_rbac_coverage_test.go walks router.go and
asserts every state-changing + read route is wrapped (or in the
documented allowlist). Adding a new ungated route fails CI.
- Updates docs/operator/rbac.md permission-catalogue table with the new
namespaces + footer link to the AST CI guard.
- Updates certctl/CHANGELOG.md v2.1.0 section with the closure narrative.
Audit doc cowork/auth-bundles-audit-2026-05-10.md CRIT-1 row annotated
CLOSED 2026-05-10. Bundle's exit-gate spec lives at
cowork/auth-bundles-fixes-2026-05-10/01-crit-1-rbac-gates.md.
CRIT-2 / CRIT-3 / CRIT-4 / CRIT-5 of the same audit remain open and
continue to block the v2.1.0 tag.
Verification gate green:
- gofmt -d (no diff after gofmt -w on the touched files)
- go vet ./...
- go test -short -count=1 ./... (all packages pass including auditor pin)
- go build ./...
HIGH-9 of the audit closes via this commit's router-layer rbacGate on
POST /api/v1/auth/keys/{id}/roles + DELETE /api/v1/auth/keys/{id}/roles/{role_id}
(defense-in-depth on top of the existing service-layer privilege check).
Refs: cowork/auth-bundles-audit-2026-05-10.md CRIT-1 HIGH-9
331 lines
10 KiB
Go
331 lines
10 KiB
Go
package auth
|
|
|
|
// Seed identifiers and constants used by the Phase 1 migration and the
|
|
// service / handler layers. Centralised here so production code, tests,
|
|
// and migration SQL stay in lockstep on the canonical role / permission
|
|
// names.
|
|
|
|
// DefaultTenantID is the seeded tenant created by migration
|
|
// 000029_rbac.up.sql. Bundle 1 ships single-tenant; every actor_role
|
|
// row carries this tenant_id by default.
|
|
const DefaultTenantID = "t-default"
|
|
|
|
// Seeded role IDs. Stable identifiers used by the migration backfill
|
|
// and the demo-mode synthetic-actor seed.
|
|
const (
|
|
RoleIDAdmin = "r-admin"
|
|
RoleIDOperator = "r-operator"
|
|
RoleIDViewer = "r-viewer"
|
|
RoleIDAgent = "r-agent"
|
|
RoleIDMCP = "r-mcp"
|
|
RoleIDCLI = "r-cli"
|
|
RoleIDAuditor = "r-auditor"
|
|
)
|
|
|
|
// DemoAnonActorID is the synthetic actor used when
|
|
// CERTCTL_AUTH_TYPE=none is configured (the demo path). Phase 1
|
|
// migration seeds the actor + admin role assignment unconditionally;
|
|
// Phase 3 of Bundle 1 wires the middleware to inject this actor into
|
|
// the request context when no-auth mode is active. Reserved system
|
|
// actor: the API rejects mutations / deletions targeting this id.
|
|
const DemoAnonActorID = "actor-demo-anon"
|
|
|
|
// 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 + 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",
|
|
|
|
// Profile management
|
|
"profile.read",
|
|
"profile.edit",
|
|
"profile.delete",
|
|
|
|
// Issuer management
|
|
"issuer.read",
|
|
"issuer.edit",
|
|
"issuer.delete",
|
|
|
|
// Target management
|
|
"target.read",
|
|
"target.edit",
|
|
"target.delete",
|
|
|
|
// Agent management
|
|
"agent.read",
|
|
"agent.edit",
|
|
"agent.retire",
|
|
"agent.heartbeat",
|
|
"agent.job.poll",
|
|
"agent.job.complete",
|
|
"agent.job.report",
|
|
|
|
// Audit access (Phase 8 introduces the auditor split)
|
|
"audit.read",
|
|
"audit.export",
|
|
|
|
// RBAC primitive (Phase 4 surfaces these via /v1/auth/roles)
|
|
"auth.role.list",
|
|
"auth.role.create",
|
|
"auth.role.edit",
|
|
"auth.role.delete",
|
|
"auth.role.assign",
|
|
"auth.role.revoke",
|
|
|
|
// API-key management (Phase 4 + Phase 7 scope-down)
|
|
"auth.key.list",
|
|
"auth.key.create",
|
|
"auth.key.rotate",
|
|
"auth.key.delete",
|
|
|
|
// Bootstrap path (Phase 6)
|
|
"auth.bootstrap.use",
|
|
|
|
// Bundle 1 Phase 3.5: admin-only fine-grained perms for the
|
|
// legacy admin handlers, seeded by migration 000030. Wrapped at
|
|
// the router level via auth.RequirePermission middleware; the
|
|
// in-handler auth.IsAdmin checks have been removed in Phase 3.5.
|
|
"cert.bulk_revoke",
|
|
"crl.admin",
|
|
"scep.admin",
|
|
"est.admin",
|
|
"ca.hierarchy.manage",
|
|
|
|
// Bundle 2 Phase 5 — session + OIDC management permissions
|
|
// seeded by migration 000037. auth.session.list / .revoke gate
|
|
// "list/revoke any session in tenant" (own-session paths bypass
|
|
// the gate via "is path.actor_id == ctx.actor_id?" check at the
|
|
// handler layer); auth.session.list.all gates the all-actors
|
|
// admin view. auth.oidc.{list,create,edit,delete} gates the
|
|
// OIDC-provider-config + group-mapping CRUD endpoints.
|
|
"auth.session.list",
|
|
"auth.session.list.all",
|
|
"auth.session.revoke",
|
|
"auth.oidc.list",
|
|
"auth.oidc.create",
|
|
"auth.oidc.edit",
|
|
"auth.oidc.delete",
|
|
|
|
// Bundle 2 Phase 7.5 — break-glass admin permissions seeded by
|
|
// migration 000038. auth.breakglass.admin gates set/rotate/unlock/
|
|
// remove operations on any actor's break-glass credential.
|
|
// auth.breakglass.login is granted to each actor when their
|
|
// break-glass credential is set, so they can use the local-
|
|
// password recovery path during SSO outages. The whole surface
|
|
// is gated on CERTCTL_BREAKGLASS_ENABLED at the service layer
|
|
// (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 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: {
|
|
"cert.read",
|
|
"profile.read",
|
|
"issuer.read",
|
|
"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: {
|
|
"cert.read",
|
|
"agent.heartbeat",
|
|
"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.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.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. Audit 2026-05-10 CRIT-1
|
|
// closure intentionally adds NOTHING here — auditor pins
|
|
// stay invariant at audit.read + audit.export.
|
|
"audit.read",
|
|
"audit.export",
|
|
},
|
|
}
|