auth-bundle-1 Phase 13 follow-up: em-dash sweep + broken-link fix

Self-audit on ba68f9a 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.
This commit is contained in:
shankar0123
2026-05-10 00:15:30 +00:00
parent ba68f9a994
commit f4cdce764c
6 changed files with 115 additions and 115 deletions
+6 -6
View File
@@ -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}:<tag>` 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}:<tag>` 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 <prev-tag>..<this-tag> --oneline`** same content, locally.
- **`git log <prev-tag>..<this-tag> --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
+26 -26
View File
@@ -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, "<perm>", 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:<tag>
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
+36 -36
View File
@@ -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
+28 -28
View File
@@ -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 <key-id> --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
+13 -13
View File
@@ -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
+6 -6
View File
@@ -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)