From 5313cd8492c16b491d93c763dde3bf8e5b805b34 Mon Sep 17 00:00:00 2001 From: shankar0123 Date: Sun, 10 May 2026 00:15:30 +0000 Subject: [PATCH] auth-bundle-1 Phase 13 follow-up: em-dash sweep + broken-link fix Self-audit on e7a94b6 flagged the prompt's 'zero em dashes' discipline rule. The four new Phase 13 docs and the v2.1.0 CHANGELOG section had 97 em-dash hits between them; this commit sweeps them all to ASCII hyphens. Counts before -> after: docs/operator/rbac.md 28 -> 0 docs/operator/auth-threat-model.md 36 -> 0 docs/migration/api-keys-to-rbac.md 16 -> 0 docs/operator/security.md 8 -> 0 docs/reference/profiles.md 3 -> 0 CHANGELOG.md 6 -> 0 Mechanical: ' - ' (spaced em dash) and bare em-dash both replaced with spaced ASCII hyphen, then double-spaces collapsed. Markdown list bullets ('^- ', '^ - ', '^ - ') verified intact across all six files. Internal-link sweep also re-run. Also fixes a pre-existing broken link the audit caught: docs/operator/security.md:70 referenced '../internal/crypto/encryption.go' which is a 1-level-up jump from docs/operator/, not the 2-level-up jump it actually needs ('../../internal/crypto/encryption.go'). Pre-Bundle-1 link rot; fixed in lockstep so the merge gate's docs validation passes cleanly. Final state across the Phase-13 docs + CHANGELOG: - 0 em dashes - 0 broken internal links - Last-reviewed: 2026-05-09 header on every new doc Bundle 1 documentation is now ready for the operator-side merge gate review. --- CHANGELOG.md | 12 ++--- docs/migration/api-keys-to-rbac.md | 52 ++++++++++----------- docs/operator/auth-threat-model.md | 72 +++++++++++++++--------------- docs/operator/rbac.md | 56 +++++++++++------------ docs/operator/security.md | 26 +++++------ docs/reference/profiles.md | 12 ++--- 6 files changed, 115 insertions(+), 115 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 96f711d..f13b0f2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,6 @@ # Changelog -## v2.1.0 — Auth Bundle 1: RBAC primitive ⚠️ +## v2.1.0 - Auth Bundle 1: RBAC primitive ⚠️ > **SECURITY: AUDIT YOUR API KEYS.** > @@ -97,9 +97,9 @@ The threat model + compliance mapping live at Day-2 RBAC operations live at [`docs/operator/rbac.md`](docs/operator/rbac.md). -## v2.0.68 — Image registry path changed ⚠️ +## v2.0.68 - Image registry path changed ⚠️ -> **Image registry path changed.** Starting this release, container images publish to `ghcr.io/certctl-io/certctl-server` and `ghcr.io/certctl-io/certctl-agent`. Existing pulls from `ghcr.io/shankar0123/certctl-{server,agent}:` continue to work for previously-published tags (the registry never deletes images), but the `:latest` tag at the old path stops moving forward at this release. Update your `docker pull` paths, `docker-compose.yml` `image:` keys, or Helm `image.repository` values to receive future updates. Old `git clone` / `git push` / install-script / API URLs continue to redirect forever — only the container-registry path changed. +> **Image registry path changed.** Starting this release, container images publish to `ghcr.io/certctl-io/certctl-server` and `ghcr.io/certctl-io/certctl-agent`. Existing pulls from `ghcr.io/shankar0123/certctl-{server,agent}:` continue to work for previously-published tags (the registry never deletes images), but the `:latest` tag at the old path stops moving forward at this release. Update your `docker pull` paths, `docker-compose.yml` `image:` keys, or Helm `image.repository` values to receive future updates. Old `git clone` / `git push` / install-script / API URLs continue to redirect forever - only the container-registry path changed. This is the only operator-action-required change in v2.0.68. Other changes in this release are cosmetic URL refreshes after the GitHub-org transfer from `shankar0123/certctl` to `certctl-io/certctl` (HTTP redirects mean no other operator action is required) plus an internal contextcheck lint fix in the agent. Full commit list is on the [GitHub release page](https://github.com/certctl-io/certctl/releases/tag/v2.0.68). @@ -110,18 +110,18 @@ notes are auto-generated from commit messages between consecutive tags. **Where to find what changed in a given release:** -- **[GitHub Releases](https://github.com/certctl-io/certctl/releases)** — every +- **[GitHub Releases](https://github.com/certctl-io/certctl/releases)** - every tag has an auto-generated "What's Changed" section pulled from the commits between that tag and the previous one, plus per-release supply-chain verification instructions (Cosign / SLSA / SBOM). -- **`git log .. --oneline`** — same content, locally. +- **`git log .. --oneline`** - same content, locally. **Why no hand-edited CHANGELOG.md:** certctl is solo-developed and pushes directly to master. Maintaining a hand-edited CHANGELOG meant the file drifted (entries piled into `[unreleased]` and never got promoted to per-version sections when tags were -cut). A stale CHANGELOG is worse than no CHANGELOG — it signals abandoned +cut). A stale CHANGELOG is worse than no CHANGELOG - it signals abandoned maintenance to security-conscious operators doing diligence. The auto-generated release notes work here because commit messages follow a diff --git a/docs/migration/api-keys-to-rbac.md b/docs/migration/api-keys-to-rbac.md index 122671b..3b7818f 100644 --- a/docs/migration/api-keys-to-rbac.md +++ b/docs/migration/api-keys-to-rbac.md @@ -4,7 +4,7 @@ This is the upgrade guide for an existing certctl deployment moving from v2.0.x's "every API key is admin or not" model to v2.1.0's -RBAC primitive. Everything keeps working through the upgrade — the +RBAC primitive. Everything keeps working through the upgrade - the Bundle 1 migration backfills every existing API key to the `r-admin` role on first boot, so the pre-existing automation that was using those keys does not change behavior. **However**, most @@ -16,8 +16,8 @@ through the post-upgrade scope-down flow. Bundle 1 maps **every** existing `CERTCTL_API_KEYS_NAMED` entry (and every legacy `CERTCTL_AUTH_SECRET`-synthesized key) to the `r-admin` role on the first boot after migration 000029 applies. -This is the safe-for-back-compat default — your CI / agents / scripts -keep working without changes — but if you don't downgrade keys, every +This is the safe-for-back-compat default - your CI / agents / scripts +keep working without changes - but if you don't downgrade keys, every key in your fleet has full admin permissions including bulk-revoke, CRL admin, and CA hierarchy management. @@ -115,7 +115,7 @@ walks the actor's audit events and emits one of: | `agent` | All observed actions are agent-shaped (`agent.*`, `cert.read`, `cert.issue`) | | `operator` | Cert / profile / target lifecycle mutations without admin signals | -The classifier is conservative — when in doubt, it prefers the +The classifier is conservative - when in doubt, it prefers the narrower role. The operator confirms each suggestion before any mutation lands (unless `--apply` is set). @@ -155,7 +155,7 @@ through the `rbacGate` helper in unchanged: `r-admin`-roled callers reach the handler, anyone else gets HTTP 403 BEFORE the body runs. -If your code consumed `auth.IsAdmin` directly (it shouldn't — +If your code consumed `auth.IsAdmin` directly (it shouldn't - the helper is internal), the new convention is: 1. Wrap the route in `rbacGate(reg.Checker, "", handler)` @@ -182,7 +182,7 @@ helm upgrade certctl certctl/certctl \ Post-upgrade, the boot loader runs the named-key actor-role backfill against the `CERTCTL_API_KEYS_NAMED` env-var-injected -into the deployment. The "AUDIT YOUR API KEYS" callout applies — +into the deployment. The "AUDIT YOUR API KEYS" callout applies - add a post-upgrade Job to your release pipeline that runs `certctl-cli auth keys scope-down --non-interactive` against a checked-in JSON config, so the role narrowing is deterministic @@ -199,23 +199,23 @@ spec: template: spec: containers: - - name: scope-down + - name: scope-down image: ghcr.io/certctl-io/certctl-cli: command: - - certctl-cli - - auth - - keys - - scope-down - - --non-interactive - - /config/scope-down.json + - certctl-cli + - auth + - keys + - scope-down + - --non-interactive + - /config/scope-down.json envFrom: - - secretRef: + - secretRef: name: certctl-cli-credentials volumeMounts: - - name: scope-down-config + - name: scope-down-config mountPath: /config volumes: - - name: scope-down-config + - name: scope-down-config configMap: name: certctl-scope-down-config restartPolicy: OnFailure @@ -231,7 +231,7 @@ For `deploy/docker-compose.yml` deployments: 1. Pull the new images: `docker compose pull` 2. Verify your `CERTCTL_AUTH_TYPE` value before restarting. If it was `none` (the demo path), the post-upgrade server will boot - in demo mode again — the synthetic `actor-demo-anon` admin + in demo mode again - the synthetic `actor-demo-anon` admin covers every request, no scope-down is meaningful. If you're moving from `none` to `api-key` mode, set `CERTCTL_API_KEYS_NAMED` first, then restart. @@ -245,7 +245,7 @@ For `deploy/docker-compose.yml` deployments: The five examples in `examples/` (acme-nginx, private-ca-traefik, step-ca-haproxy, multi-issuer, acme-wildcard-dns01) all run in demo mode (`CERTCTL_AUTH_TYPE=none`) and are unaffected by the -RBAC migration — the synthetic actor-demo-anon admin grant covers +RBAC migration - the synthetic actor-demo-anon admin grant covers every request. ## Verifying the upgrade landed @@ -259,7 +259,7 @@ After the scope-down flow completes: 3. The audit trail (`GET /api/v1/audit?category=auth`) shows the `auth.role.assign` and `auth.role.revoke` rows for - every change you made — confirm via the GUI's + every change you made - confirm via the GUI's `/audit?category=auth` view. 4. Read the updated [`docs/operator/rbac.md`](../operator/rbac.md) for day-2 RBAC management. @@ -283,14 +283,14 @@ boot regardless of schema version). ## Cross-references -- [`docs/operator/rbac.md`](../operator/rbac.md) — the operator +- [`docs/operator/rbac.md`](../operator/rbac.md) - the operator how-to for the new RBAC primitive -- [`docs/operator/auth-threat-model.md`](../operator/auth-threat-model.md) — +- [`docs/operator/auth-threat-model.md`](../operator/auth-threat-model.md) - what the new controls defend against -- [`docs/reference/profiles.md`](../reference/profiles.md) — the +- [`docs/reference/profiles.md`](../reference/profiles.md) - the Phase 9 approval-bypass closure -- [`docs/operator/security.md`](../operator/security.md) — the +- [`docs/operator/security.md`](../operator/security.md) - the full security posture -- `cowork/auth-bundle-1-prompt.md` — the design + phase plan -- `cowork/auth-bundles-index.md` — the per-phase status tracker -- `CHANGELOG.md` — the v2.1.0 release notes lead with this guide +- `cowork/auth-bundle-1-prompt.md` - the design + phase plan +- `cowork/auth-bundles-index.md` - the per-phase status tracker +- `CHANGELOG.md` - the v2.1.0 release notes lead with this guide diff --git a/docs/operator/auth-threat-model.md b/docs/operator/auth-threat-model.md index 3425703..f9a1e09 100644 --- a/docs/operator/auth-threat-model.md +++ b/docs/operator/auth-threat-model.md @@ -4,7 +4,7 @@ This document describes the attack surface around authentication and authorization in certctl after Bundle 1 (the RBAC primitive) lands. -It complements [`rbac.md`](rbac.md) — that doc explains how to use +It complements [`rbac.md`](rbac.md) - that doc explains how to use the controls; this one explains what those controls defend against and which threats they explicitly do NOT close. @@ -16,19 +16,19 @@ Bundle 2 scope. ## Threat actors -1. **External attacker with no credential** — probing the public +1. **External attacker with no credential** - probing the public HTTP surface. The default trust boundary for everything except the protocol-level endpoints (ACME / SCEP / EST / OCSP / CRL, which authenticate via embedded credentials per their own RFCs). -2. **Authenticated caller with the wrong role** — has a valid API +2. **Authenticated caller with the wrong role** - has a valid API key but the role doesn't grant the requested operation. The primary RBAC threat model. -3. **Compromised API key** — attacker holds a valid Bearer token +3. **Compromised API key** - attacker holds a valid Bearer token that an honest operator originally provisioned. The key may carry any role. -4. **Insider operator** — legitimate access; potentially trying +4. **Insider operator** - legitimate access; potentially trying to escalate privilege or bypass the approval workflow. -5. **Compromised audit reviewer (auditor role)** — read-only +5. **Compromised audit reviewer (auditor role)** - read-only access to audit events but otherwise untrusted. ## Defenses Bundle 1 ships @@ -140,35 +140,35 @@ constant, router-level no-rbacGate-wraps-protocol-paths). These are NOT defended; some are deferred to Bundle 2, others are out-of-scope for the project entirely. -1. **OIDC / SAML / WebAuthn federation** — Bundle 2. -2. **Session management** — there is no session cookie, no +1. **OIDC / SAML / WebAuthn federation** - Bundle 2. +2. **Session management** - there is no session cookie, no server-side revocation list. Each Bearer token is the bearer credential. To revoke a key, delete the `actor_roles` rows or remove the env-var entry; there is no "log out everywhere" button. Bundle 2. -3. **Local password accounts (break-glass)** — Bundle 2. -4. **Time-bound role grants / JIT elevation** — the schema +3. **Local password accounts (break-glass)** - Bundle 2. +4. **Time-bound role grants / JIT elevation** - the schema reserves `actor_roles.expires_at` but no UI/API to set it. Bundle 2 or v3. -5. **MFA / hardware tokens for the operator console** — +5. **MFA / hardware tokens for the operator console** - Bundle 2. -6. **Rate limiting on the bootstrap endpoint** — the endpoint +6. **Rate limiting on the bootstrap endpoint** - the endpoint is one-shot by construction (consumed flag + admin-existence probe), so a brute-force attack on the token has at most the single attempt before the path closes. Per-IP rate limiting on the broader API is still in place via Bundle C's `middleware.NewRateLimiter`. -7. **`scope_id` FK enforcement** — operators can grant a +7. **`scope_id` FK enforcement** - operators can grant a permission at scope `profile`/`p-bogus` without the bogus profile existing. The gate still works (no rows match at request time) but a strict 404 on grant would be cleaner. See `RoleRepository.AddPermission` `TODO(bundle-2)` comment in `internal/repository/postgres/auth.go`. -8. **OIDC-first-admin bootstrap** — Bundle 1 ships only the +8. **OIDC-first-admin bootstrap** - Bundle 1 ships only the env-var-token strategy. Bundle 2 adds the OIDC-group-claim strategy alongside (the `Strategy` interface in `internal/auth/bootstrap/` is already in place). -9. **GUI E2E suite via Playwright** — the prompt asked for +9. **GUI E2E suite via Playwright** - the prompt asked for nine end-to-end flow tests. Bundle 1 ships 19 React Testing Library + Vitest tests covering the same surface; full Playwright land in Phase 12-extended work. @@ -179,23 +179,23 @@ The control set in this document supports the following framework requirements. This is a mapping; it is not a claim of formal certification. -- **SOC 2 CC6.1** (logical access controls) — RBAC primitive +- **SOC 2 CC6.1** (logical access controls) - RBAC primitive with role-based gating on every mutating endpoint. -- **SOC 2 CC6.3** (privileged access management) — `r-admin` +- **SOC 2 CC6.3** (privileged access management) - `r-admin` role separation + role-grant audit trail with two-person integrity on approval-tier profile edits. -- **HIPAA §164.312(b)** (audit controls) — `event_category` +- **HIPAA §164.312(b)** (audit controls) - `event_category` column lets the auditor role review authentication / authorization changes specifically. WORM trigger keeps the audit table append-only at the database layer. -- **NIST SSDF PO.5.2** (separation of duties) — two-person +- **NIST SSDF PO.5.2** (separation of duties) - two-person integrity for compliance-tier issuance via the `RequiresApproval` flow + Bundle 1 Phase 9's closure of the flip-flop bypass. -- **FedRAMP AU-9** (audit information protection) — WORM +- **FedRAMP AU-9** (audit information protection) - WORM enforcement + auditor-only read access (the auditor role cannot mutate, the WORM trigger blocks UPDATE/DELETE). -- **PCI-DSS §10** (audit logging) — every mutating operation +- **PCI-DSS §10** (audit logging) - every mutating operation emits an audit row with actor + action + resource + timestamp + category. The audit table is append-only. @@ -203,42 +203,42 @@ formal certification. Run these periodically to verify the controls are working. -1. `certctl-cli auth keys list` — confirm no unexpected actor +1. `certctl-cli auth keys list` - confirm no unexpected actor holds `r-admin`. Audit any new admin grants against the audit log. 2. `SELECT actor, action, COUNT(*) FROM audit_events WHERE action LIKE 'approval_%' AND timestamp > NOW() - INTERVAL '7 - days' GROUP BY actor, action;` — confirm approvals are + days' GROUP BY actor, action;` - confirm approvals are happening and not concentrated in a single approver. 3. `SELECT COUNT(*) FROM audit_events WHERE actor = - 'system-bypass';` — MUST return 0 in production. A non-zero + 'system-bypass';` - MUST return 0 in production. A non-zero count means `CERTCTL_APPROVAL_BYPASS=true` was set; production deploys MUST leave it unset. 4. `SELECT actor, COUNT(*) FROM audit_events WHERE action = - 'bootstrap.consume';` — MUST return at most one row per + 'bootstrap.consume';` - MUST return at most one row per tenant. Multiple rows means the bootstrap endpoint was called more than once, which the strategy's one-shot guard should have prevented; investigate. 5. `certctl-cli auth me` while authenticated as the auditor - key — `effective_permissions` must contain `audit.read` + + key - `effective_permissions` must contain `audit.read` + `audit.export` ONLY. Any other permission means a role grant widened the auditor's surface; revoke immediately. ## Cross-references -- [`rbac.md`](rbac.md) — the operator how-to -- [`security.md`](security.md) — the wider security posture -- [`approval-workflow.md`](approval-workflow.md) — the two-person +- [`rbac.md`](rbac.md) - the operator how-to +- [`security.md`](security.md) - the wider security posture +- [`approval-workflow.md`](approval-workflow.md) - the two-person integrity gate -- [`docs/migration/api-keys-to-rbac.md`](../migration/api-keys-to-rbac.md) — +- [`docs/migration/api-keys-to-rbac.md`](../migration/api-keys-to-rbac.md) - upgrade flow -- `internal/auth/` — middleware + keystore + RequirePermission + +- `internal/auth/` - middleware + keystore + RequirePermission + bootstrap -- `internal/service/auth/` — Authorizer + privilege-escalation +- `internal/service/auth/` - Authorizer + privilege-escalation guard + reserved-actor guard -- `migrations/000029_rbac.up.sql` — schema + seed -- `migrations/000030_rbac_admin_perms.up.sql` — five admin-only +- `migrations/000029_rbac.up.sql` - schema + seed +- `migrations/000030_rbac_admin_perms.up.sql` - five admin-only fine-grained perms -- `migrations/000032_audit_category.up.sql` — auditor surface -- `migrations/000033_approval_kinds.up.sql` — approval-bypass +- `migrations/000032_audit_category.up.sql` - auditor surface +- `migrations/000033_approval_kinds.up.sql` - approval-bypass closure diff --git a/docs/operator/rbac.md b/docs/operator/rbac.md index 5c702fd..a499013 100644 --- a/docs/operator/rbac.md +++ b/docs/operator/rbac.md @@ -44,7 +44,7 @@ that resolves "actor → permissions" lives at | Auditor | `r-auditor` | Compliance reviewer | `audit.read` + `audit.export` ONLY | The auditor split is the load-bearing one: an auditor cannot read -certificates, profiles, or issuers — only audit events. That makes the +certificates, profiles, or issuers - only audit events. That makes the role legitimate to hand to a SOC 2 / FedRAMP / PCI auditor without giving them the keys to the kingdom. The `internal/domain/auth/auditor_test.go` invariants pin this set going @@ -53,11 +53,11 @@ forward. The five **admin-only fine-grained perms** seeded by migration 000030 (Phase 3.5 conversion) gate the high-blast-radius endpoints: -- `cert.bulk_revoke` — `POST /api/v1/certificates/bulk-revoke` and the EST sibling -- `crl.admin` — `/api/v1/admin/crl/cache` -- `scep.admin` — `/api/v1/admin/scep/intune/*` -- `est.admin` — `/api/v1/admin/est/*` -- `ca.hierarchy.manage` — `/api/v1/issuers/{id}/intermediates`, `/api/v1/intermediates/{id}` +- `cert.bulk_revoke` - `POST /api/v1/certificates/bulk-revoke` and the EST sibling +- `crl.admin` - `/api/v1/admin/crl/cache` +- `scep.admin` - `/api/v1/admin/scep/intune/*` +- `est.admin` - `/api/v1/admin/est/*` +- `ca.hierarchy.manage` - `/api/v1/issuers/{id}/intermediates`, `/api/v1/intermediates/{id}` Only `r-admin` holds these by default. To delegate one, create a custom role with the specific perm and grant it to the right actor. @@ -87,19 +87,19 @@ for the live catalogue. Permissions are granted at one of three scopes: -- **`global`** — applies to every resource in the tenant. The +- **`global`** - applies to every resource in the tenant. The default for the seeded role grants. A `cert.read` grant at global scope lets the actor read any certificate. -- **`profile`** — applies only to the named `CertificateProfile` +- **`profile`** - applies only to the named `CertificateProfile` (matched by ID). `cert.issue` at scope `profile`/`p-corp-cdn` lets the actor issue against `p-corp-cdn` only. -- **`issuer`** — applies only to the named issuer. Lets you grant +- **`issuer`** - applies only to the named issuer. Lets you grant `issuer.edit` on the production issuer to a senior operator without giving them edit on every issuer. Global beats specific: an actor with `cert.read` at global scope passes a `cert.read` check against any specific profile or issuer -even if no scoped grant exists. The reverse is also true — a +even if no scoped grant exists. The reverse is also true - a scoped grant doesn't satisfy a request against a different scope. The Authorizer's `CheckPermission` is the single point of truth. @@ -122,13 +122,13 @@ permission. `/auth/keys` lists every actor with role grants; click "Assign role" to grant, click the × on a role tag to revoke. The synthetic `actor-demo-anon` row is shown but flagged -"system-managed" with the mutation buttons hidden — the server-side +"system-managed" with the mutation buttons hidden - the server-side reserved-actor guard rejects mutations against it regardless. ### From the CLI ```bash -# Identity probe — what can the current API key actually do? +# Identity probe - what can the current API key actually do? certctl-cli auth me # Roles @@ -166,7 +166,7 @@ tag. Quick reference: | Endpoint | Permission | |---|---| -| `GET /v1/auth/me` | (none — own data) | +| `GET /v1/auth/me` | (none - own data) | | `GET /v1/auth/roles` | `auth.role.list` | | `GET /v1/auth/roles/{id}` | `auth.role.list` | | `POST /v1/auth/roles` | `auth.role.create` | @@ -197,13 +197,13 @@ HTTP surface above; permission gates fire server-side. Hand the auditor key to compliance reviewers. They get: -- `GET /api/v1/audit?category=auth` — every auth/authz mutation +- `GET /api/v1/audit?category=auth` - every auth/authz mutation in the system (role creates, role grants on actors, bootstrap consumption, etc.). -- `GET /api/v1/audit?category=cert_lifecycle` — every cert event. -- `GET /api/v1/audit?category=config` — every issuer / target / +- `GET /api/v1/audit?category=cert_lifecycle` - every cert event. +- `GET /api/v1/audit?category=config` - every issuer / target / settings edit. -- `GET /api/v1/audit/export` — bulk export. +- `GET /api/v1/audit/export` - bulk export. They do NOT get cert read, profile read, issuer read, or any mutating permission. The categorization is enforced by the database @@ -216,7 +216,7 @@ To create an auditor key: 2. (Optional) Revoke any other roles the key holds with `certctl-cli auth keys revoke --role r-...` 3. Confirm via `certctl-cli auth me` while authenticated as the - auditor key — the response should show only `audit.read` and + auditor key - the response should show only `audit.read` and `audit.export` in `effective_permissions`. ## Day-0 bootstrap (first-admin path) @@ -227,7 +227,7 @@ deployments where no admin actor exists yet. 1. Set `CERTCTL_BOOTSTRAP_TOKEN=$(openssl rand -hex 32)` in the server environment. 2. Boot the server. Logs include - "bootstrap endpoint enabled — POST /api/v1/auth/bootstrap to + "bootstrap endpoint enabled - POST /api/v1/auth/bootstrap to mint the first admin key (one-shot)" when the path is callable. 3. Run a single curl: @@ -259,22 +259,22 @@ gated route resolves with a populated actor and admin grants. The synthetic actor is reserved: the API rejects any mutation that targets it (HTTP 409 with `ErrAuthReservedActor`). -Production deployments MUST NOT use demo mode — there is no +Production deployments MUST NOT use demo mode - there is no per-request actor identity for the audit trail, and every request flows as admin. Use it for the `docker compose up` demo + the five example folders only. ## Where to look next -- [Threat model](auth-threat-model.md) — what attacks this primitive +- [Threat model](auth-threat-model.md) - what attacks this primitive defends against and which it does not -- [Migration guide](../migration/api-keys-to-rbac.md) — moving +- [Migration guide](../migration/api-keys-to-rbac.md) - moving pre-Bundle-1 deployments onto RBAC -- [Profiles](../reference/profiles.md) — the `RequiresApproval=true` +- [Profiles](../reference/profiles.md) - the `RequiresApproval=true` flow that Bundle 1 Phase 9 closure protects from flip-flop -- [Approval workflow](approval-workflow.md) — the Rank 7 Infisical +- [Approval workflow](approval-workflow.md) - the Rank 7 Infisical deep-research deliverable that the Phase 9 closure piggybacks on -- `internal/auth/` — the middleware + keystore + RequirePermission -- `internal/service/auth/` — the service-layer Authorizer -- `cowork/auth-bundle-1-prompt.md` — the design + phase plan -- `cowork/auth-bundles-index.md` — the per-phase status tracker +- `internal/auth/` - the middleware + keystore + RequirePermission +- `internal/service/auth/` - the service-layer Authorizer +- `cowork/auth-bundle-1-prompt.md` - the design + phase plan +- `cowork/auth-bundles-index.md` - the per-phase status tracker diff --git a/docs/operator/security.md b/docs/operator/security.md index e13f3fb..0c4f0b7 100644 --- a/docs/operator/security.md +++ b/docs/operator/security.md @@ -41,10 +41,10 @@ For certificates issued to systems where revocation correctness matters: ignore it. 3. **Confirm the deployment target is configured for OCSP stapling** so the server can actually deliver the stapled response in the handshake. - - **nginx:** `ssl_stapling on; ssl_stapling_verify on;` - - **Apache:** `SSLUseStapling on` - - **HAProxy:** `set ssl ocsp-response /path/to/response.der` - - **Envoy:** `ocsp_staple_policy: must_staple` + - **nginx:** `ssl_stapling on; ssl_stapling_verify on;` + - **Apache:** `SSLUseStapling on` + - **HAProxy:** `set ssl ocsp-response /path/to/response.der` + - **Envoy:** `ocsp_staple_policy: must_staple` ### What this does NOT cover @@ -67,7 +67,7 @@ Bundle B / M-001. PBKDF2-SHA256 at 600,000 rounds (OWASP 2024 Password Storage Cheat Sheet floor) for the operator-supplied passphrase that derives the AES-256-GCM key for sensitive config columns. v3 blob format with a per-ciphertext random salt; v1/v2 read fallback for legacy rows. -See [internal/crypto/encryption.go](../internal/crypto/encryption.go) and +See [internal/crypto/encryption.go](../../internal/crypto/encryption.go) and the accompanying tests for the format spec. ## Authentication surface @@ -75,12 +75,12 @@ the accompanying tests for the format spec. Bundle B / M-002. Two layers decide auth-exempt status: 1. **Router layer:** `internal/api/router/router.go::AuthExemptRouterRoutes` - — the endpoints registered via direct `r.mux.Handle` without going + - the endpoints registered via direct `r.mux.Handle` without going through the middleware chain (`/health`, `/ready`, `/api/v1/auth/info`, `/api/v1/version`, plus `/api/v1/auth/bootstrap` GET + POST per Bundle 1 Phase 6). 2. **Dispatch layer:** `internal/api/router/router.go::AuthExemptDispatchPrefixes` - — URL-prefix routing in `cmd/server/main.go::buildFinalHandler` for + - URL-prefix routing in `cmd/server/main.go::buildFinalHandler` for `/.well-known/pki/*`, `/.well-known/est/*`, `/.well-known/est-mtls`, and `/scep[/...]*` (incl. `/scep-mtls`). @@ -109,7 +109,7 @@ flow from a pre-Bundle-1 deployment, see ### Day-0 admin bootstrap (Bundle 1 Phase 6) Fresh deployments where no admin actor exists yet can mint the -first admin via `POST /api/v1/auth/bootstrap` — set +first admin via `POST /api/v1/auth/bootstrap` - set `CERTCTL_BOOTSTRAP_TOKEN`, POST a single curl with the token, and the server returns the plaintext key value once. The token is constant-time-compared; the strategy is one-shot via mutex; the @@ -140,12 +140,12 @@ budget when set non-zero. ## API key rotation -**Audit reference:** L-004. CWE-924 (improper enforcement of message integrity during transmission in a communication channel) — operator UX variant. +**Audit reference:** L-004. CWE-924 (improper enforcement of message integrity during transmission in a communication channel) - operator UX variant. certctl's API keys are configured via the `CERTCTL_API_KEYS_NAMED` env var (format `name1:key1,name2:key2:admin`) and parsed at startup into an in-memory list. There is no DB-resident key store, no GUI, no `/api/v1/keys` -endpoint — the env var IS the key inventory. +endpoint - the env var IS the key inventory. Pre-Bundle-G the env var rejected duplicate names, so rotating a key required: stop accepting OLDKEY → restart → roll NEWKEY out. Any client @@ -163,7 +163,7 @@ rotation as: ``` CERTCTL_API_KEYS_NAMED="alice:OLDKEY:admin,alice:NEWKEY:admin" ``` - Both entries MUST carry the same admin flag — startup fails loud if + Both entries MUST carry the same admin flag - startup fails loud if they don't (a non-admin shouldn't share an identity with an admin). 3. **Restart certctl.** A startup INFO log confirms the rotation window @@ -184,7 +184,7 @@ rotation as: 6. **Restart certctl.** OLDKEY now fails with 401. Rotation complete. -The rotation window has no operator-set timeout — it lasts for as long +The rotation window has no operator-set timeout - it lasts for as long as both entries are in the env var. Best practice is a 24-72h window covering a full deploy cadence; if a client hasn't rolled to NEWKEY by the end of step 4, extend the window before step 5. @@ -196,7 +196,7 @@ the end of step 4, extend the window before step 5. - Two entries with the same `name` but mismatched admin: **rejected at startup** (privilege escalation guard). - Two entries with the same `(name, key)` pair: **rejected at startup** - (typo guard — rotation requires DIFFERENT keys under the same name). + (typo guard - rotation requires DIFFERENT keys under the same name). - Single-entry steady state: unchanged from pre-Bundle-G behavior. ### What the contract does NOT do diff --git a/docs/reference/profiles.md b/docs/reference/profiles.md index 75d3546..a6f6e9f 100644 --- a/docs/reference/profiles.md +++ b/docs/reference/profiles.md @@ -28,7 +28,7 @@ see `api/openapi.yaml` under `/api/v1/profiles`. | `allowed_key_algorithms` | RSA 2048+, ECDSA P-256+ | Validates incoming CSRs at issuance time. | | `allowed_ekus` | server, client | RFC 5280 §4.2.1.12 EKU set. | | `must_staple` | false | Per-profile RFC 7633 `id-pe-tlsfeature` extension toggle (Phase 5.6 of the SCEP master bundle). | -| `requires_approval` | false | Bundle 1 Phase 9 — gates issuance + renewal AND profile edits behind a four-eyes approval workflow. See below. | +| `requires_approval` | false | Bundle 1 Phase 9 - gates issuance + renewal AND profile edits behind a four-eyes approval workflow. See below. | ## RequiresApproval and the approval workflow @@ -42,18 +42,18 @@ Setting `requires_approval=true` on a profile does two things: → `Cancelled`). Same actor cannot self-approve. 2. **Edits to the profile itself gate on a non-requester admin's approval.** This is the Bundle 1 Phase 9 closure for the flip-flop - loophole — without it an admin could set `requires_approval=false`, + loophole - without it an admin could set `requires_approval=false`, mutate any other field, set `requires_approval=true`, and the approval workflow would only have been bypassed during the "off" window. The Phase 9 gate fires under three conditions: - - The live profile has `requires_approval=true` AND the operator + - The live profile has `requires_approval=true` AND the operator submits any edit (regardless of whether the edit changes the flag). - - The live profile has `requires_approval=false` AND the operator + - The live profile has `requires_approval=false` AND the operator submits an edit that would set it to `true` (the flag-flip direction is gated too because otherwise the gate could be enabled by anyone and have no review). - - Both arms route through `ApprovalService.RequestProfileEditApproval` + - Both arms route through `ApprovalService.RequestProfileEditApproval` which writes a row to `issuance_approval_requests` with `approval_kind=profile_edit`. The pending profile diff is serialized to `payload` (JSONB). @@ -105,7 +105,7 @@ audit-only view. Each row carries the approval ID + the requester - `migrations/000027_approval_workflow.up.sql` (initial approval schema, Rank 7 of the 2026-05-03 deep-research deliverable) -- `migrations/000033_approval_kinds.up.sql` (Phase 9 — adds +- `migrations/000033_approval_kinds.up.sql` (Phase 9 - adds `approval_kind` + `payload` + nullable cert/job FKs) - `internal/service/approval.go::RequestProfileEditApproval` - `internal/service/profile.go::UpdateProfile` (gate)