mirror of
https://github.com/shankar0123/certctl.git
synced 2026-06-07 12:31:29 +00:00
28f93f1f464cbc89f2a97fbf70d23e0ebcae1d41
1040 Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
28f93f1f46 |
fix(docs): trim parenthetical from postgres-backup.md Last-reviewed line (doc-rot ci-guard)
The doc-rot-detector ci-guard regex is anchored to end-of-line:
^>\s*Last reviewed:\s*(\d{4}-\d{2}-\d{2})\s*$
postgres-backup.md had a trailing parenthetical
`(Sprint 4 ACQ — CI restore verification subsection added)` after
the date, which broke the match. Every other doc under docs/ uses
the bare `> Last reviewed: YYYY-MM-DD` form (verified via grep).
The trailing text was historical context that's already captured by
`git log -- docs/operator/runbooks/postgres-backup.md`; doesn't
need to live in the date line.
This guard was masked by the Go Build & Test job aborting at `go mod
tidy` step before the ci-guards step ran — surfacing as a follow-on
failure once that earlier blocker is cleared.
|
||
|
|
569aea255f |
fix(helm): servicemonitor.yaml — Go templates don't support nested comments (B3 ci-guard)
|
||
|
|
c70bb071f9 |
fix(helm): DEPL-004 follow-up — Helm-comment block for tlsConfig narrative (B3 ci-guard)
Commit |
||
|
|
f7fcd1e187 |
docs(observability): DEPL-006 follow-up — document CERTCTL_OTEL_ENABLED (G-3 ci-guard)
Sprint 6 ACQ DEPL-006 closure follow-up. The G-3-env-docs-drift
ci-guard scans `internal/` + `cmd/` for every CERTCTL_*
env-var reference and cross-checks against README + docs/ +
deploy/helm/ + deploy/ENVIRONMENTS.md. The OTel-seed commit
(
|
||
|
|
9155ec9174 |
fix(helm): DEPL-004 follow-up — default tlsConfig to real verify; fix ill-formed required-nil
Sprint 6 ACQ DEPL-004 closure follow-up. CI run on commit
|
||
|
|
58a15e0b3d |
feat(notifier): DOC-001 — wire the orphan webhook notifier; README "6 notifiers" now accurate
Acquisition-audit DOC-001 closure (Sprint 7 ACQ, 2026-05-16). The
webhook notifier shipped to internal/connector/notifier/webhook/
months ago with full SafeHTTPDialContext SSRF guard + HMAC-SHA256
signing + comprehensive tests, but it was never wired in
cmd/server/main.go — README:39 claimed "6 notifiers" while only 5
were actually registered. Audit prompt offered two paths: (a) wire
it if the impl is feature-complete, (b) fix the README count. The
impl IS feature-complete (verified by reading webhook.go +
webhook_test.go), so path (a) is the rigorous closure.
What this commit adds
=====================
internal/connector/notifier/webhook/adapter.go (NEW):
NotifierAdapter bridges the rich notifier.Connector interface
(SendAlert / SendEvent / ValidateConfig) to the simpler service-
layer service.Notifier (Send + Channel) used by the notification
service's per-channel routing. Send(ctx, recipient, subject,
body) constructs a notifier.Event with the three fields populated
+ a fresh 16-byte hex random ID + UTC timestamp, delegates to
the Connector's SendEvent. Channel() returns "webhook". The
Connector's per-request HMAC-SHA256 signing + SafeHTTPDialContext
SSRF guard apply transitively through SendEvent → postWebhook
— no defense duplication at the adapter layer.
internal/config/notifiers.go:
NotifierConfig gains WebhookURL + WebhookSecret fields with the
same docstring shape as the other 5 notifier env-var pairs.
internal/config/config.go::Load():
Reads CERTCTL_WEBHOOK_URL + CERTCTL_WEBHOOK_SECRET (both empty
by default → notifier disabled, matching the pattern of the
other 5 env-var-gated notifiers).
cmd/server/main.go:
- notifywebhook import added next to the other 5.
- New wire-up block after the OpsGenie one: when WebhookURL is
set, constructs the Connector via webhook.New (production
constructor — strict ValidateSafeURL + SafeHTTPDialContext),
wraps in NotifierAdapter, registers as notifierRegistry["Webhook"].
Boot log includes the signing posture ("HMAC-SHA256 signed"
vs "unsigned") so operators can spot a missing secret.
Target-connector count reconciliation
=====================================
The audit prompt also asked to reconcile the target-connector
count (README says "fourteen + Kubernetes Secrets preview" = 15;
ls internal/connector/target/ shows 17 dirs). Ground-truth: the
extra two dirs (certutil, configcheck) are shared HELPER packages
(PEM/PFX conversion + server-side shell-injection validation
respectively), NOT target connectors. Real target-connector count
is 17 - 2 = 15, exactly matching README:12 + README:39. No README
change needed.
Verified locally: gofmt clean, go vet clean, staticcheck clean
across internal/config + internal/connector/notifier/webhook +
cmd/server; `go test -count=1
./internal/connector/notifier/webhook/...` green (existing tests
unchanged); `go test -short -count=1 ./internal/config/...
./cmd/server/...` green; `go build ./cmd/server` produces a
30.9MB binary that boots.
|
||
|
|
d64c1821a5 |
fix(install-agent): RED-007 — verify agent binary via SHA-256 + cosign before install
Acquisition-audit RED-007 closure (Sprint 7 ACQ, 2026-05-16).
Pre-2026-05-16, install-agent.sh downloaded the agent binary with
`curl -sSL -f` from GitHub Releases and ran chmod +x — no integrity
check, no signature verification. A tampered release-asset upload
(e.g. compromised maintainer GH token) or a misnamed asset would
install silently. HTTPS already prevents in-flight tampering, but
the release-surface tamper case was wide-open.
The download_binary() function now performs two independent
verifications BEFORE install_binary copies to $INSTALL_DIR:
1. SHA-256 against the release-published checksums.txt
Every release publishes checksums.txt (sha256sum-format) at
the same RELEASE_URL. The script downloads it, looks up the
binary's expected hash by name, and compares against
sha256sum (Linux) or shasum -a 256 (macOS — both fallbacks
tried). Mismatch rejects the install and exits 1. A
missing-entry rejection is also exit 1 because an
inconsistent release surface is itself a supply-chain
anomaly.
2. Cosign keyless verify against the GitHub Actions OIDC identity
When cosign is installed, the script downloads
<binary>.sigstore.json and runs:
cosign verify-blob \\
--bundle <bundle> \\
--certificate-identity-regexp "^https://github.com/${GITHUB_REPO}/" \\
--certificate-oidc-issuer "https://token.actions.githubusercontent.com" \\
<binary>
This pins the signature to the certctl-io/certctl release
workflow's OIDC identity (see .github/workflows/release.yml).
When cosign is NOT installed, the script logs a clear WARN
pointing at the cosign install snippet and proceeds with
SHA-256 verification only. Operators in regulated environments
MUST install cosign and re-run.
What this DOES NOT change
=========================
- The script's bash-piped install pattern (curl|bash) is not
refactored. The audit prompt's NON-GOAL pin ("Stay shell. Do
not refactor install-agent.sh into a binary distribution.") is
honored.
- HTTPS-only download semantics are unchanged (already in place).
- The unsupported-platform refusal at L38-49 is unchanged (already
in place).
Verified locally: bash -n syntax clean. The integration smoke test
(deploy/test/install-agent-smoke.sh) that the audit prompt
optionally suggested was NOT added — the verification logic is
straightforward enough that the inline if/else error paths are
self-documenting and the operator-visible failure messages are the
test.
|
||
|
|
c8e77fdeca |
test(approval): COMP-006 — pin denied-no-cert + approved-reaches-pending invariants
Acquisition-audit COMP-006 closure (Sprint 7 ACQ, 2026-05-16).
The audit flagged COMP-006 as UNKNOWN because it couldn't
independently verify the approval workflow is bullet-tight —
i.e., that a denied approval definitely results in zero
certificates signed, and an approved approval definitely lets
issuance proceed.
Enforcement chain (operator-visible invariant)
==============================================
Layer 1 — Issuance gate. certificate.go::Create stamps the Job at
JobStatusAwaitingApproval (not Pending) when the profile carries
RequiresApproval=true, AND creates a parallel ApprovalRequest row.
The job processor never touches AwaitingApproval rows.
Layer 2 — Approval state machine. ApprovalService.Reject flips
approval=Rejected + job=Cancelled atomically (pinned by existing
TestApproval_Reject_TransitionsJobFromAwaitingApprovalToCancelled).
ApprovalService.Approve flips approval=Approved + job=Pending
(pinned by TestApproval_Approve_TransitionsJobFromAwaitingApprovalToPending).
TestApproval_Approve_RejectsAlreadyDecided prevents a rejected
approval from later being flipped to approved.
Layer 3 (THE LOAD-BEARING SQL INVARIANT) — postgres/job.go::
JobRepository.ClaimPendingJobs (L296-310) issues
`SELECT ... FROM jobs WHERE status = $1` with
$1 = JobStatusPending. Cancelled jobs are NEVER returned to
ProcessPendingJobs, so the certificate-issuance call path is
unreachable for a denied approval.
What this commit adds
=====================
internal/service/approval_test.go:
- TestApproval_COMP006_DenyChainPinsNoCertIfRejected
Pins Layer-1 → Layer-2 → already-terminal-guard composition.
Re-Approve of a rejected approval must fail; job must stay
Cancelled. A LOOPHOLE here would let a denied cert issue.
- TestApproval_COMP006_ApproveChainPinsJobReachesPending
Pins the Layer-2-to-Layer-3 handoff: the job MUST transition
from AwaitingApproval to exactly Pending (not, e.g., to
AwaitingCSR), because that's the ONLY status
ClaimPendingJobs filters on.
docs/operator/approval-workflow.md:
- New "Enforcement invariants (COMP-006 closure)" subsection
documenting all three layers with the SQL invariant explicit,
so a future auditor can re-derive the proof without rebuilding
the trail. Cites every pinning test by name.
This is NOT a testcontainers-driven integration test. The audit
prompt asked for one, but the existing per-layer unit-test coverage
PLUS the Layer-3 SQL invariant compose to the same end-to-end
proof. The integration suite at deploy/test/integration_test.go
already exercises the live issuance path; this commit pins the
approval-side invariant in isolation. Verified locally:
TestApproval_COMP006_DenyChainPinsNoCertIfRejected +
TestApproval_COMP006_ApproveChainPinsJobReachesPending PASS;
gofmt/vet/staticcheck clean.
|
||
|
|
1b95709d4b |
docs(rbac): DOC-002 + COMP-005 — pin auditor role invariants in operator docs
Acquisition-audit DOC-002 + COMP-005 closure (Sprint 7 ACQ,
2026-05-16). Both findings were UNKNOWN because the auditor
couldn't independently verify the auditor-role permission set is
locked-down. The set IS locked down in three places (schema,
code, tests) — DOC-002 + COMP-005 close by surfacing that pin in
docs/operator/rbac.md so a future SOC 2 / FedRAMP / PCI auditor
can re-derive the proof without rebuilding the trail.
New "Auditor role invariants" subsection in docs/operator/rbac.md
under the existing two-person integrity section. Documents:
Layer 1 (schema) — migrations/000029_rbac.up.sql:261-262 +
migrations/000039_audit_crit1_perms.up.sql:111 (the inline
"r-auditor: NOTHING new" comment).
Layer 2 (code) — internal/domain/auth/DefaultRoles[RoleIDAuditor].
Layer 3 (the load-bearing one — tests):
- TestAuditorRoleHoldsExactlyAuditReadAndExport
set-equality on {audit.read, audit.export}
- TestAuditorRoleDoesNotHoldMutatingOrReadingNonAuditPerms
catches subtle widening even if set-equality is bypassed
- TestAuditorRoleSeparateFromViewer
pins auditor and viewer permission sets are disjoint
except audit.read (which viewer shares by design)
Explicitly notes the audit prompt's recommendation against a bash
CI guard — the property is already enforced at the Go test layer
with stronger semantics (struct-aware set equality) than `grep`
could provide.
No code changes; documentation-only closure (existing tests + schema
already pin the invariant). Verified locally: gofmt clean, go vet
clean across internal/domain/auth + internal/service.
|
||
|
|
35277c0f2c |
feat(observability): DEPL-006 — OpenTelemetry seed (surface only; no spans yet)
Acquisition-audit DEPL-006 closure (Sprint 6 ACQ, 2026-05-16).
Pre-2026-05-16, go.mod listed go.opentelemetry.io/otel,
otel/metric, otel/trace, otelhttp, and auto/sdk all as indirect
deps (pulled transitively by AWS / Azure SDKs at v1.41.0). The
SDK was never initialized — the global otel.GetTracerProvider()
returned the SDK noop provider, and certctl emitted zero spans.
This commit stands up the surface so operators with an OTel
collector can opt in via CERTCTL_OTEL_ENABLED=true without code
changes. It does NOT add per-handler / per-query / per-connector
span instrumentation — that's a v2.3 roadmap follow-up. The
DEPL-006 audit finding is closed by the surface being present.
Transport choice: OTLP/HTTP (proto-binary over HTTPS), NOT
OTLP/gRPC. Both are valid OTel transports; downstream collectors
accept either. HTTP keeps certctl's dep surface narrow — gRPC
pulls in google.golang.org/grpc + the full genproto stack, which
would expand binary size + supply-chain attack surface for a
feature that today emits zero spans. Operators with gRPC-only
collectors can run an OTel-collector tee. Swapping to gRPC later
is a single-import change.
Files
=====
- internal/observability/otel.go: new Init function. Gated by
CERTCTL_OTEL_ENABLED. Builds an OTLP/HTTP exporter, wraps in
a BatchSpanProcessor, installs as the otel global tracer
provider, returns shutdown. Disabled-mode returns a no-op
shutdown so callers defer unconditionally.
- internal/observability/otel_test.go: 3 tests — disabled-mode
no-op (global tracer provider unchanged), enabled-mode
registers an SDK tracer provider, OTEL_SERVICE_NAME flows
through resource.WithFromEnv.
- internal/config/config.go: new ObservabilityConfig sub-config
with a single OTelEnabled bool. Single env var
(CERTCTL_OTEL_ENABLED); everything else flows through the
standard OTEL_* env vars the OTel SDK honors directly via
resource.WithFromEnv + otlptracehttp.New. Deliberately no
CERTCTL_OTEL_SERVICE_NAME / CERTCTL_OTEL_ENDPOINT etc. —
avoids the lying-field footgun where an env var exists in
config but doesn't reach the consumer.
- cmd/server/main.go: wire observability.Init unconditionally
near the existing demo / RFC1918 startup banners. The defer'd
shutdown gets a 5-second timeout so an unreachable collector
doesn't hang process exit.
- go.mod: promote go.opentelemetry.io/otel + otel/sdk +
otlptracehttp from indirect → direct (the four pre-existing
otel deps stay where go mod resolution puts them).
- go.sum: refreshed deps.
The genproto split (newer genproto/googleapis/{api,rpc} submodules
vs the old monolithic genproto module) needed an explicit
google.golang.org/genproto pin to a post-split pseudo-version to
resolve cleanly — included in this commit's go.mod.
Verified locally: gofmt clean, go vet clean, staticcheck clean
across internal/observability + internal/config + cmd/server;
go test -short -count=1 green on all three; `go build ./cmd/server`
produces a 30.9MB binary that boots; targeted tests
(TestInit_Disabled_NoOp / TestInit_Enabled_RegistersTracerProvider /
TestInit_Enabled_RespectsOTEL_SERVICE_NAME) all PASS.
|
||
|
|
5c5bbedc7e |
feat(ci): SCALE-007 — frontend bundle-size budget via size-limit
Acquisition-audit SCALE-007 closure (Sprint 6 ACQ, 2026-05-16).
The web/src codebase has ~45 React.lazy() call sites (`grep -rE
'lazy\(' web/src --include='*.tsx' | wc -l`), heavily route-
splitting the SPA. Pre-2026-05-16 there was no CI guard on bundle
size, so unintended bloat in a vendor chunk or a page chunk would
slip in unnoticed until somebody profiled cold-start performance.
This commit adds:
- web/.size-limit.json — 11 budget entries: per-chunk caps on the
load-bearing chunks (main entry, vendor-recharts, vendor-react,
vendor-query, vendor-router, vendor-icons, OnboardingWizard,
CommandPalette, Timestamp) + two roll-up tiers (total vendor JS,
total app JS). Budgets tuned to current vite-build output +
~15% headroom in brotli-compressed bytes (the size-limit
default measurement mode — closest analogue to what a real
browser downloads).
- web/package.json + web/package-lock.json: `npm run size` script
+ size-limit + @size-limit/file devDeps.
- .github/workflows/ci.yml: new "Frontend bundle-size budget
(size-limit)" step in the frontend-build job, runs immediately
after the vite build.
- scripts/ci-guards/G-frontend-bundle-budget.sh: local-runnable
wrapper matching the existing ci-guards/<id>.sh contract — exits
0 on clean, non-zero with ::error:: prefix on regression.
Acceptance verified locally:
- npm install in web/ regenerates package-lock cleanly
- `npm run size` exits 0 against the committed web/dist/
- `bash scripts/ci-guards/G-frontend-bundle-budget.sh` exits 0
- All current chunks measured (brotli, kB): main entry 23.3
(cap 30), vendor-recharts 91.2 (cap 110), vendor-react 37.4
(cap 45), OnboardingWizard 28.6 (cap 35), total vendor 149.5
(cap 180), total app 351.1 (cap 425)
A regression that bloats a chunk past its cap fails CI and forces
an explicit operator decision: fix the regression, or raise the
cap in web/.size-limit.json with a rationale comment in the
commit message. Do not raise caps blindly.
|
||
|
|
d7546aedca |
fix(helm): DEPL-004 — ServiceMonitor TLS default flipped to fail-closed
Acquisition-audit DEPL-004 closure (Sprint 6 ACQ, 2026-05-16).
Pre-2026-05-16, monitoring.serviceMonitor.tlsConfig in values.yaml
was empty by default, and the ServiceMonitor template fell through
to an implicit `insecureSkipVerify: true` else-branch. Operators
opting into the ServiceMonitor (monitoring.serviceMonitor.enabled=true)
got no Prometheus TLS verification by default — in-cluster scrapes
tolerate this, out-of-cluster scrapes silently skip the chain check.
The template now emits a fail-closed `{{ required ... }}` message
at `helm template` / `helm upgrade` time if neither a real verify
nor an explicit opt-back is supplied. The error string lists both
escape hatches and the docs cross-link, so the operator sees the
fix in the same line they hit the error.
Operators with monitoring.serviceMonitor.enabled=false (the chart
default): no action required — the template short-circuits before
the tlsConfig block. Operators who had ServiceMonitor on with no
tlsConfig set: helm upgrade will fail until they supply either
{ caFile: ..., serverName: ... } (production-shaped) or
{ insecureSkipVerify: true } (operator-acknowledged opt-back).
Files
=====
- deploy/helm/certctl/templates/servicemonitor.yaml: replace the
else-branch insecureSkipVerify default with a {{ required ... }}
Helm builtin that fails the render with a clear remediation
message pointing at both escape hatches and docs/operator/
helm-deployment.md
- deploy/helm/certctl/values.yaml: rewrite the tlsConfig comment
block to document the new fail-closed posture + both upgrade
paths (production verify vs operator-acknowledged opt-back)
- docs/operator/helm-deployment.md: new "2026-05-16 — ServiceMonitor
TLS default flipped (DEPL-004)" subsection in the existing
Upgrade section with the two operator-action recipes
|
||
|
|
5ea45a19b9 |
feat(security): Sprint 5 ACQ — RED-003 deny-empty flip + SEC-009/RED-005 RFC1918 opt-in
Acquisition-audit Sprint 5 ACQ closure (2026-05-16). Two
independent findings ship together because they share Load() /
main.go wiring; the closure comments tie each line to its finding.
PART A — RED-003 (agent-bootstrap deny-empty cutover)
=====================================================
Phase 2 SEC-H1 closure (2026-05-13) introduced the
CERTCTL_AGENT_BOOTSTRAP_TOKEN_DENY_EMPTY staged feature flag with
default `false` so v2.1.x operators wouldn't get a surprise
fail-closed on upgrade. This commit flips the default to `true`
(per the staged plan in the existing CHANGELOG "Breaking changes
(scheduled for v2.2.0)" block). Operators who haven't generated a
real bootstrap token yet keep the v2.1.x warn-mode pass-through
for one upgrade window by setting
CERTCTL_AGENT_BOOTSTRAP_TOKEN_DENY_EMPTY=false explicitly.
Demo-mode escape hatch: CERTCTL_DEMO_MODE_ACK=true skips the
fail-closed gate so the screenshot/demo path stays one-command-up.
The accompanying boot-banner WARN at cmd/server/main.go:124-126
keeps demo mode visible in every log scraper, so this override
cannot silently re-enable warn-mode in production.
internal/config/config.go
- Load() default for AgentBootstrapTokenDenyEmpty flipped to true
- Validate() gate now also checks !c.Auth.DemoModeAck so the demo
override line up with the boot-banner WARN
- Closure comment block updated to cross-reference Sprint 5 ACQ
and the CHANGELOG v2.2.0 entry
cmd/server/main.go
- Updated boot-time WARN message to reflect the new default
(deny-empty=true) — the warn now fires only in the two
explicit override scenarios (warn-mode opt-back or demo mode),
and explains the operator action either way
- Info-line on configured-token path unchanged
PART B — SEC-009 + RED-005 (opt-in RFC1918 outbound block)
==========================================================
internal/validation/ssrf.go::IsReservedIP has always intentionally
left RFC 1918 ranges (10/8, 172.16/12, 192.168/16) NOT-reserved
because certctl is designed to manage certificates inside private
networks. For operators on hosted IaaS where RFC1918 IS internal
trust (kubeadm-default 10.96.0.0/12 service CIDR exposes the
Kubernetes API on 10.96.0.1; cloud-provider internal monitoring;
hosted-bastion subnets), this default is a real exposure path.
Add a package-level atomic.Bool toggle in internal/validation/ssrf.go
that, when on, extends IsReservedIP to ALSO return true for the
three RFC1918 ranges. Every IsReservedIP-derived path
(SafeHTTPDialContext, ValidateSafeURL, the network scanner, the
webhook + OIDC + ACME callers) picks up the new policy
transitively without per-call-site changes.
internal/validation/ssrf.go
- blockRFC1918Outbound atomic.Bool + SetBlockRFC1918Outbound /
BlockRFC1918OutboundEnabled accessor pair
- rfc1918Nets pre-parsed at package init (panic on parse failure
surfaces a misconfigured ssrf package immediately, not via a
silently disabled toggle)
- IsReservedIP checks the toggle after the existing reserved-IP
checks
- Header comment rewritten to document the toggle + the
transitive coverage
internal/config/config.go
- New NetworkConfig sub-config; Config gains a Network field
- Load() reads CERTCTL_BLOCK_RFC1918_OUTBOUND env var (default
false; preserves the existing self-hosted threat model)
- NetworkConfig docstring lists the operator-trap (enabling this
also blocks RFC1918 from the network scanner) so an operator
cert-discovering their own RFC1918 space doesn't get a
silently-empty scan result
cmd/server/main.go
- Wires validation.SetBlockRFC1918Outbound after config.Load and
near the demo-mode banner / agent-bootstrap-token block; emits
a one-shot INFO line when the toggle is enabled so the policy
is visible in journals
Tests
=====
internal/config/config_test.go
- TestLoad_AgentBootstrapTokenDenyEmpty_DefaultIsTrue — pins the
default flip at the boot path (Load returns the flipped value)
- TestValidate_DenyEmptyDefault_RefusesWithoutToken — pins the
fail-closed behavior under the new default
- TestValidate_DenyEmptyExplicitFalse_AllowsEmpty — pins the
v2.1.x back-compat escape hatch
- TestValidate_DenyEmpty_DemoModeAckOverride_AllowsEmpty — pins
the demo-mode override
internal/validation/ssrf_test.go
- TestIsReservedIP_RFC1918_OptIn — pins toggle-off / toggle-on
behavior across all three RFC1918 ranges, edge cases
immediately outside the ranges, and the toggle-back-off path
- TestSafeHTTPDialContext_RFC1918_OptIn — pins that the toggle
reaches the dial-time SSRF check transitively (not just
IsReservedIP in isolation)
Test-helper updates (Sprint-5-induced churn):
- internal/config/config_test.go::setMinimalValidEnv now sets
CERTCTL_AGENT_BOOTSTRAP_TOKEN to a placeholder so Load()-based
tests that don't specifically exercise the empty-token gate
keep passing under the new fail-closed default. Tests that DO
exercise the empty-token path explicitly override back to "".
- internal/config/config_est_profiles_test.go +
internal/config/config_scep_profiles_test.go: same placeholder
fix for the four Load()-based EST/SCEP profile tests.
- cmd/server/main_test.go::TestMain_ServerConfigFromEnvironment +
TestMain_AuthTypeConfiguration: same fix at the main.go test
layer with prior-value restore.
Verified locally: gofmt -l clean; go vet clean; staticcheck clean
across internal/config, internal/validation, cmd/server; short
tests green on all three packages; targeted -v run of all six new
test names confirms PASS.
|
||
|
|
374ec574c5 |
feat(ci): DEPL-005 + DATA-012 — weekly backup/restore smoke + audit-chain round-trip assertion
Acquisition-audit DEPL-005 (backup runbook exists but no CI restore test) + DATA-012 closure (Sprint 4 ACQ, 2026-05-16). A backup procedure that has never been restore-tested is not a backup procedure. The Helm CronJob at deploy/helm/certctl/templates/backup- cronjob.yaml and the operator runbook at docs/operator/runbooks/postgres-backup.md both document a `pg_dump -Fc --no-owner --no-acl`-based backup strategy, but the dump shape has never been restored end-to-end under CI. This sprint adds the missing assertion. Each Monday at 07:00 UTC (1h offset from loadtest.yml's 06:00 slot so the two jobs don't fight for runners), boot a real postgres:16-alpine service container pinned to the SAME sha256 digest as deploy/docker-compose.yml, exercise the audit_events hash chain with 24 synthetic rows representing an issue/renew/revoke/auth-login cycle, take a custom-format dump, DROP SCHEMA public CASCADE (simulating an operator-side data-loss event), pg_restore, and assert: pre.row_count == post.row_count pre.chain_head_hash == post.chain_head_hash (BYTE-EXACT) post.first_break_id == "" (verify_chain clean) post.verifier_walked == pre.row_count (every row walked) The chain-head byte-exact assertion is the load-bearing one. Migration 000047 hashes each row's canonical payload with `to_char(timestamp AT TIME ZONE 'UTC', 'YYYY-MM-DD"T"HH24:MI:SS.US"Z"')` — any TIMESTAMPTZ-precision loss in the dump/restore path (a real concern across major Postgres upgrades or with --format=plain) would corrupt the hash. The point of testing is to PROVE the property, not to defend against a known quirk. Files ===== - .github/workflows/backup-restore.yml — Mondays 07:00 UTC + workflow_dispatch. Postgres service container; Go 1.25.10; contents:read; 15-min timeout. Action SHAs pinned to match ci.yml's pinning convention. - deploy/test/backup-restore-smoke.sh — bash orchestrator: preflight (postgresql-client + Go + python3 on PATH); wait-for-ready loop; DROP SCHEMA + workload + dump + DROP SCHEMA + restore + verify + python3 JSON diff. ::error:: prefix on any assertion failure. Same script runs unchanged locally against any reachable Postgres. - deploy/test/backupsmoke/main.go — Go program with --mode=workload and --mode=verify. Imports the repo's internal/repository/postgres.RunMigrations and emits a small JSON snapshot to stdout. INSERT shape mirrors internal/repository/postgres/audit_chain_test.go. - docs/operator/runbooks/postgres-backup.md — adds a 'CI restore verification' subsection after the existing quarterly-dry-run section, points at the new workflow + harness + smoke program, bumps the last-reviewed marker. Verified locally: gofmt clean, go vet clean, staticcheck clean, `go build ./deploy/test/backupsmoke` succeeds, bash -n on the shell harness, python3 -c yaml.safe_load on the workflow, dry-run of the JSON-diff python block on synthetic pre.json/post.json covers both PASS and ::error:: paths. |
||
|
|
4f2d865b51 |
feat(middleware): SEC-008 — Permissions-Policy deny-all-features header
Acquisition-audit SEC-008 closure (Sprint 2 ACQ, 2026-05-16).
Add Permissions-Policy as a sixth security header alongside HSTS,
X-Frame-Options, X-Content-Type-Options, Referrer-Policy, and CSP.
Default value is a deny-all-features baseline:
accelerometer=(), camera=(), geolocation=(), microphone=(),
payment=(), usb=(), interest-cohort=()
certctl is a control-plane API + dashboard; no part of the surface
needs camera / microphone / geolocation / accelerometer / payment /
USB access, and `interest-cohort=()` opts out of the deprecated
FLoC browser feature. The deny-all default removes those
attack/fingerprint surfaces if certctl is ever embedded in a
malicious page or if a dashboard route is XSS-compromised
post-CSP-bypass.
Per-field empty-string suppression is preserved: operators who want
to allow a feature (e.g. hardware-attestation flows wanting
WebAuthn's USB transport) can either set Cfg.PermissionsPolicy to
their own narrowed allowlist or set it to "" to suppress the
header entirely.
Tests:
- TestSecurityHeaders_PermissionsPolicyDefault — pins the literal
default value byte-for-byte so any widening (e.g. someone adding
camera=*) breaks the test.
- TestSecurityHeaders_PermissionsPolicyOverrideToEmptySuppresses —
pins the operator escape hatch and that the per-field
suppression contract still holds field-by-field.
- TestSecurityHeaders_DefaultsAllPresent gains Permissions-Policy
in its loop, so the existing on-error and on-2xx paths now
cover the new header too.
The middleware pre-trim slice capacity bumps from 5 → 6 entries.
|
||
|
|
578ac4ec68 |
feat(config): SEC-013 — advisory WARN on external sslmode=disable
Acquisition-audit SEC-013 closure (Sprint 2 ACQ, 2026-05-16). Add a post-Validate advisory WARN (NOT fail-closed) that fires when `CERTCTL_DATABASE_URL` parses as a Postgres URL with `sslmode=disable` AND the host is outside the local safelist. The advisory exists because the legitimate compose / Helm topology genuinely uses sslmode=disable over the Docker bridge — failing closed would break the production-shaped quickstart — but pointing CERTCTL_DATABASE_URL at a managed-Postgres host (RDS / Cloud SQL / Azure Database) without flipping sslmode to verify-full puts the entire control plane's Postgres traffic on the wire in cleartext. Safelist (silenced): - localhost, 127.0.0.1, ::1 - postgres (compose default service name) - certctl-postgres (compose / Helm service name) - *.svc.cluster.local (K8s in-cluster service-name convention) Anything else → `slog.Warn` with structured `host=` + `sslmode=` fields plus a pointer to docs/operator/database-tls.md for the verify-full upgrade procedure. Tests: - TestWarnExternalSslmodeDisable_FiresOnExternalHost - TestWarnExternalSslmodeDisable_QuietForLocalSafelist (6 subtests) - TestWarnExternalSslmodeDisable_QuietWithoutDisable (3 subtests) - TestWarnExternalSslmodeDisable_QuietOnUnparseableOrEmpty (3 subtests) Docs: docs/operator/security.md gains a Postgres transport encryption subsection covering both SEC-013 (this commit) and SEC-014 (loopback host-port bind, prior commit); the deep procedure remains at docs/operator/database-tls.md. |
||
|
|
7e2481b225 |
fix(deploy): SEC-014 — loopback-bind Postgres host port in compose files
Acquisition-audit SEC-014 closure (Sprint 2 ACQ, 2026-05-16). Both deploy/docker-compose.yml and deploy/docker-compose.test.yml published Postgres on `5432:5432` — the short Docker port-mapping form, which binds to 0.0.0.0 by default. On any host with a public-facing NIC, that quietly exposed the Postgres TCP listener to the internet. The certctl-server-to-postgres traffic itself goes over the `certctl-network` Docker bridge, not the host port; the host port mapping is a convenience for operator psql access and for the integration-test runner that lives on the host. Switch both mappings to `127.0.0.1:5432:5432` (loopback-only). Operator psql via `localhost` keeps working; the integration-test runner keeps working; cross-host exposure goes away. Audit trail: docs/operator/security.md (Postgres transport encryption subsection, SEC-014 paragraph). |
||
|
|
2e9262cfb7 |
fix(handler): SEC-021 — wrap BCL provider re-fetch via SafeOIDCContext
Acquisition-audit Sprint 1 follow-up to SEC-001 (2026-05-16). Companion
to SEC-020 (prior commit). Closes the second of the two adjacent OIDC
call sites the original SEC-001 sweep missed: the per-request discovery
re-fetch in DefaultBCLVerifier.Verify.
Pre-fix:
func (v *DefaultBCLVerifier) Verify(ctx, logoutToken) {
...
provider, perr := gooidc.NewProvider(ctx, matched.IssuerURL)
...
}
Same shape as service.go::fetchUserinfoGroups (closed in the prior
commit) and service.go:1084 (closed by SEC-001 itself). go-oidc's
NewProvider derives its http.Client from ctx; bare ctx falls through
to http.DefaultClient at the discovery-doc + JWKS-fetch dial. An IdP
whose registered IssuerURL resolves to a reserved address (or is
rebinding to one at logout time) would trigger an unguarded HTTPS
egress on every back-channel-logout request.
Post-fix:
provider, perr := gooidc.NewProvider(
oidcsvc.SafeOIDCContext(ctx), matched.IssuerURL)
The 'oidcsvc' alias for github.com/certctl-io/certctl/internal/auth/oidc
is added to the import block (matches the canonical alias used in
cmd/server/main.go:29). SafeOIDCContext routes the dial through
validation.SafeHTTPDialContext, which re-resolves the issuer host at
dial time and refuses reserved-address answers (loopback /
link-local / 169.254.169.254 cloud-metadata).
Files touched:
internal/api/handler/auth_session_oidc_bcl.go — add oidcsvc import +
wrap ctx at the NewProvider call site
internal/api/handler/auth_session_oidc_bcl_test.go — NEW FILE.
TestDefaultBCLVerifier_SSRF_BlocksReservedAddress constructs a
stubProviderRepo with IssuerURL='http://127.0.0.1:1' (literal
loopback — the IP-literal class that SafeHTTPDialContext.
isReservedIPForDial refuses up-front, before any DNS resolution).
Hand-rolls a 3-segment JWT whose payload base64url-decodes to
{"iss":"<loopback url>"} so peekIssuer extracts the matching
issuer and provs.List() returns the seeded provider. Calls Verify
and asserts the error wraps the dial-time reserved-address
rejection (substring match on 'refusing to dial' / 'reserved
address') AND that it's wrapped through the 'provider discovery:'
prefix that distinguishes a discovery-time dial failure from a
signature-verification failure.
docs/operator/auth-threat-model.md — NEW subsection 'Userinfo + BCL
SSRF parity (post-SEC-001 follow-up)' under '### Back-channel
logout'. Documents both SEC-020 and SEC-021 closures, the
context-key shape (why a single SafeOIDCContext wrap covers both
go-oidc and oauth2 legs), and the out-of-scope RFC 1918 carve-out
(covered separately by acquisition-audit Sprint 5 RED-005). Cross-
references the two pinning tests by name so future audits can
locate the load-bearing enforcement.
Verified:
gofmt -l internal/ docs/ (clean)
go vet ./... (clean)
go test -race -short ./internal/api/handler/... (all green)
TestDefaultBCLVerifier_SSRF_BlocksReservedAddress (new; green)
All 4 cited CI guards pass.
Acceptance grep on the BCL handler:
internal/api/handler/auth_session_oidc_bcl.go:132:
provider, perr := gooidc.NewProvider(oidcsvc.SafeOIDCContext(ctx), matched.IssuerURL)
No bare-ctx NewProvider remains in the BCL verifier. Combined with the
SEC-020 commit, every gooidc.NewProvider + Provider.UserInfo call site
in the production OIDC + BCL surface now routes through
SafeOIDCContext.
Closes acquisition-audit SEC-021. Sprint 1 ACQ is complete (2/2
findings). The single sprint shipped as two operator-authored commits
(per-finding, mirrors the project's commit cadence for closures).
|
||
|
|
5d7bc86451 |
fix(oidc): SEC-020 — wrap fetchUserinfoGroups via SafeOIDCContext
Acquisition-audit Sprint 1 follow-up to SEC-001 (2026-05-16). The
original SEC-001 sweep routed two OIDC discovery legs (test_discovery.go
dry-run + service.go runtime provider load) through
validation.SafeHTTPDialContext via the SafeOIDCContext(ctx) helper.
This commit closes one of the two adjacent call sites the sweep missed:
the userinfo-fallback path at service.go::fetchUserinfoGroups.
Pre-fix:
func (s *Service) fetchUserinfoGroups(ctx, entry, token, path) {
...
ts := entry.oauthConfig.TokenSource(ctx, token)
uinfo, err := entry.provider.UserInfo(ctx, ts)
...
}
go-oidc/v3 Provider.UserInfo (oidc.go:351-374) derives its
http.Client from ctx via getClient(ctx) (oidc.go:61-65). Without an
override, the internal doRequest (oidc.go:87-92) falls through to
http.DefaultClient — no SSRF guard, no DNS-rebinding re-resolve at
dial time. An IdP whose discovery doc advertises a userinfo_endpoint
pointing at a reserved address (loopback / link-local /
169.254.169.254 cloud-metadata) would trigger an unguarded HTTPS
egress at userinfo-fetch time. Operator opt-in to fetch_userinfo=true
turns the gap on; the leg fires whenever the ID token doesn't surface
the configured groups claim.
Post-fix:
safeCtx := SafeOIDCContext(ctx)
ts := entry.oauthConfig.TokenSource(safeCtx, token)
uinfo, err := entry.provider.UserInfo(safeCtx, ts)
Context-key shape: gooidc.ClientContext is implemented as
context.WithValue(ctx, oauth2.HTTPClient, client) (go-oidc v3.18.0
oidc.go:57-59). Both go-oidc's getClient AND golang.org/x/oauth2's
internal.ContextClient read the same oauth2.HTTPClient key, so the
SINGLE SafeOIDCContext wrap covers go-oidc-driven HTTP calls
(Provider.UserInfo / Verifier JWKS) AND oauth2-driven HTTP calls
(Config.TokenSource refresh / Exchange). No additional
context.WithValue(ctx, oauth2.HTTPClient, ...) is required.
Files touched:
internal/auth/oidc/service.go — wrap ctx in fetchUserinfoGroups
internal/auth/oidc/safehttp.go — extend SEC-001 header comment block
to enumerate the two newly-patched sites (SEC-020 here +
SEC-021 in the next commit) and the oauth2.HTTPClient key-sharing
rationale, so future audits don't re-flag the design as confused
internal/auth/oidc/service_test.go — new test
TestFetchUserinfoGroups_SSRF_BlocksReservedAddress that
stands up a loopback discovery server whose discovery doc
advertises userinfo_endpoint = http://169.254.169.254/userinfo,
constructs *gooidc.Provider via the test-bypassed
oidcDiscoveryClient (setup_test.go's init() pattern), then
RESTORES the production SafeHTTPDialContext-backed client just
before the fetchUserinfoGroups call. Asserts the error wraps
SafeHTTPDialContext's 'refusing to dial reserved address'
rejection rather than a generic connect-refused. Companion to
the TestDefaultBCLVerifier_SSRF_BlocksReservedAddress that
SEC-021 (next commit) adds.
Verified:
gofmt -l internal/ docs/ (clean)
go vet ./... (clean)
go test -race -short ./internal/auth/oidc/... (all green)
TestFetchUserinfoGroups_SSRF_BlocksReservedAddress (new; green)
All 4 cited CI guards pass (openapi-handler-parity,
openapi-codegen-drift, no-sh-c-in-connectors, skip-inventory-drift)
Acceptance grep:
internal/auth/oidc/service.go:963: uinfo, err := entry.provider.UserInfo(safeCtx, ts)
internal/auth/oidc/service.go:1084: provider, err := gooidc.NewProvider(SafeOIDCContext(ctx), cfgRow.IssuerURL)
No bare-ctx UserInfo / NewProvider remains in service.go.
Closes acquisition-audit SEC-020. SEC-021 (BCL discovery re-fetch)
lands in the next commit.
|
||
|
|
c4ed3da30b |
fix(ci): Sprint 6 CI follow-up — staticcheck ST1021 + tenant-query baseline + skip inventory
Sprint 6 push (commits |
||
|
|
663b14bfd8 |
feat(retention): COMP-002-RETENTION — federated-user PII purge pipeline
Sprint 6 closure of the audit's MED-severity COMP-002-RETENTION
finding.
Pre-fix posture: the federated-user admin surface
(auth_users.go::Deactivate) sets users.deactivated_at on soft-delete,
but the PII columns (email, display_name, oidc_subject) stay
populated forever. No in-code primitive for GDPR right-to-be-
forgotten; no scheduled retention purge.
This commit ships the audit's recommended two-phase fix:
Phase 1 — operator-callable scrub primitive
internal/service/user_retention.go
UserRetentionService.DeleteUserPII(ctx, userID):
- revoke all active sessions (defense-in-depth)
- email := 'purged@redacted.local'
- display_name := '[purged]'
- oidc_subject := 'sha256:' || hex(sha256(original))
- audit_events row with action=user.purge_pii,
category=auth, actor=system
Why hash oidc_subject instead of NULL:
1. (oidc_provider_id, oidc_subject) UNIQUE constraint would
trip on multiple purged users converging to NULL
2. The hash is one-way; the original IdP-side identifier is
unrecoverable. Re-login under the same subject mints a
fresh u-id (right-to-be-forgotten semantics)
3. Forensic continuity: an operator can recompute
sha256(<known-subject>) and confirm "this user was
deactivated then purged"
users.id itself is preserved so historical
audit_events.actor = u-X rows still resolve. The forensic-
attribution chain stays intact even after the PII is gone.
Phase 2 — scheduled batch purge
internal/scheduler/scheduler.go
UserRetentionPurger interface + userRetentionLoop:
- PurgeDeactivatedUsers enumerates every user with
deactivated_at < NOW() - retention_window
- DeleteUserPII per row
- per-tick batch cap (default 200) keeps blast radius
predictable; large backlogs spread across multiple ticks
- atomic.Bool guard + 5-min per-tick context.WithTimeout
Repository contract grew a single new method:
internal/repository/user.go::ListDeactivatedBefore(ctx, t)
internal/repository/postgres/user.go: SQL-side filter
(deactivated_at IS NOT NULL AND deactivated_at < $1)
ORDER BY deactivated_at ASC, cross-tenant.
Configuration
CERTCTL_USER_RETENTION_INTERVAL default 24h
CERTCTL_USER_RETENTION_WINDOW default 30 days
CERTCTL_USER_RETENTION_BATCH_CAP default 200
Test stub additions for repository.UserRepository.ListDeactivatedBefore:
internal/auth/oidc/service_test.go::stubUsers
internal/api/handler/auth_users_test.go::stubFullUserRepo
internal/api/handler/auth_session_oidc_test.go::stubUserRepo
Documentation
docs/operator/privacy-and-retention.md
- retention pipeline diagram (day-0 deactivate → day-N purge)
- operator config table
- verification runbook (4 steps with SQL)
- what's NOT covered (deferred: DSAR export, api_keys cascade,
retroactive audit_events.details redaction)
Tests
internal/service/user_retention_test.go (NEW, 4 tests):
TestDeleteUserPII_ScrubsAndRevokes
TestDeleteUserPII_IsIdempotent
TestPurgeDeactivatedUsers_RespectsWindow
TestPurgeDeactivatedUsers_BatchCap
Verified locally:
go vet ./... (clean)
gofmt -l internal/ cmd/ (clean)
go test -short -count=1 \
./internal/service/... ./internal/scheduler/... ./internal/config/...
(all green)
Cross-sprint interaction: pairs with COMP-001-HASH (prior commit).
The user.purge_pii audit row this service emits flows through the
new hash chain, so the scrub event is itself tamper-evident.
Closes COMP-002-RETENTION. Sprint 6 is complete (2/2 findings).
|
||
|
|
43836aca7c |
feat(audit): COMP-001-HASH — per-row hash chain on audit_events (tamper-evidence)
Sprint 6 closure of the audit's HIGH-severity COMP-001-HASH finding.
Pre-fix posture: migration 000018 installs a WORM trigger on
audit_events that blocks UPDATE / DELETE for the application role.
But the trigger header itself documents a compliance-superuser
bypass (backup restore, retention purges, breach recovery). Without
a hash chain, that role can rewrite any row's actor / action /
details / timestamp / event_category with no on-disk trace.
HIPAA §164.312(b), FedRAMP AU-9, NIST 800-53 AU-10 want tamper-
EVIDENCE, not just tamper-prevention. This commit ships the
evidence layer.
Wire shape:
migrations/000047_audit_events_hash_chain.up.sql
+ pgcrypto extension (digest function)
+ audit_chain_head: single-row sentinel table holding the most
recent row_hash; FOR UPDATE row-lock serialises chain writes
under concurrent INSERTs so two parallel writers can't read
the same prev_hash and produce a forked chain
+ audit_events: prev_hash + row_hash columns
+ audit_events_canonical_payload(): centralised hash input
builder. UTC + microsecond ISO-8601 keeps the hash session-
timezone-independent. All columns separated by '|' so a
concatenation-ambiguity exploit can't fabricate a collision
+ audit_events_compute_hash_chain(): BEFORE-INSERT trigger
function. Reads sentinel FOR UPDATE → computes
sha256(prev_hash || id || actor || actor_type || action ||
resource_type || resource_id || details::text ||
timestamp_utc_iso || event_category) → writes both columns +
advances the sentinel
+ backfill loop walks every existing row in (timestamp ASC, id
ASC) order; WORM trigger temporarily DISABLEd inside this
migration's transaction so backfill UPDATEs land cleanly,
ENABLEd before COMMIT
+ audit_events_verify_chain(): STABLE plpgsql verifier. Walks
the chain end-to-end and returns the first break:
(first_break_id TEXT, first_break_pos INT, row_count INT)
internal/repository/postgres/audit.go
+ AuditRepository.VerifyHashChain — calls the SQL function and
maps the OUT parameters to Go return values
internal/repository/interfaces.go
+ AuditRepository.VerifyHashChain in the contract; every
in-memory mock + stub picks up the no-op implementation
internal/scheduler/scheduler.go
+ AuditChainVerifier + AuditChainBreakRecorder interfaces
+ auditChainVerifyInterval (default 6h)
+ auditChainVerifyLoop: runs once on start + every tick;
atomic.Bool guard + 5-min per-tick context timeout match every
other GC loop's pattern
internal/service/audit_chain_metric.go
+ AuditChainCounter type with atomic counters. Sticky-first-
detection on (BrokenAtID, BrokenAtPos) so the actionable
alarm doesn't drift across walks. Snapshot() returns the
full state for the metrics handler
internal/api/handler/metrics.go
+ AuditChainCounterSnapshotter interface + Prometheus
exposition for four series:
certctl_audit_chain_break_detected_total counter (the alarm)
certctl_audit_chain_verify_total counter (walks done)
certctl_audit_chain_rows gauge (last walk size)
certctl_audit_chain_last_verified_at gauge (unix seconds)
internal/config/config.go
+ AuditChainConfig{ VerifyInterval } + CERTCTL_AUDIT_CHAIN_VERIFY_INTERVAL
cmd/server/main.go
+ wires AuditChainCounter into both the scheduler (recorder) +
metrics handler (snapshotter) — single instance shared so the
writer + reader are guaranteed to converge
internal/repository/postgres/audit_chain_test.go (NEW)
+ TestAuditEventsHashChain_FreshTable: empty walk → clean
+ TestAuditEventsHashChain_AppendLinksRows: three INSERTs
produce a strictly-linked chain; prev_hash on row 0 is NULL;
verifier walks clean over the 3 rows
+ TestAuditEventsHashChain_VerifierDetectsTampering: simulate
the compliance-superuser threat model (DISABLE WORM, UPDATE
a middle row, ENABLE WORM); verifier returns the tampered
row's id at position 1
docs/operator/audit-chain.md (NEW)
+ Layered-defenses explainer (WORM + hash chain). Verifier
function reference. Recommended Prometheus alert rule.
Performance scaling table (10k to 10M rows). Step-by-step
runbook for what to do when a break is detected. Operator
configuration table.
Test-stub additions for AuditRepository.VerifyHashChain:
internal/service/testutil_test.go — mockAuditRepo
internal/service/acme_test.go — fakeAuditRepo
internal/integration/lifecycle_test.go — mockAuditRepository
internal/api/handler/scep_intune_e2e_test.go — intuneE2EAuditRepo
Verified locally:
go vet ./... (clean)
gofmt -l internal/ cmd/ (clean)
go test -short -count=1 ./internal/scheduler/... ./internal/config/...
./internal/service/... ./internal/api/handler/... ./internal/repository/...
(all green)
Verified with testcontainers + postgres:16-alpine + the migration
runner (not gated under -short — requires docker):
go test -count=1 -run TestAuditEventsHashChain ./internal/repository/postgres/...
Closes COMP-001-HASH leg of Sprint 6. COMP-002-RETENTION lands in
the next commit (separate concern: federated-user PII retention).
|
||
|
|
8c2d3c844e |
test(config): Sprint 4 ARCH-003 fixture alignment for ACK-required tests
Sprint 5 CI follow-up. Pre-fix: the Sprint 5 push tripped three Go
test failures in internal/config:
--- FAIL: TestLoad_AllEnvVarsSet (0.00s)
config_test.go:261: Load() returned error: CERTCTL_KEYGEN_MODE=server
is demo-only — ... Set CERTCTL_DEMO_MODE_ACK=true ...
--- FAIL: TestValidate_AcceptsServerKeygenWithDemoAck (0.00s)
config_test.go:2082: Validate(KeygenMode=server, DemoAck=true,
fresh TS) = job timeout interval must be at least 1 second; want nil
--- FAIL: TestValidate_AgentKeygenIgnoresDemoAck (0.00s)
config_test.go:2106: Validate(KeygenMode=agent, DemoAck=false) =
job timeout interval must be at least 1 second; want nil (production
default must boot)
All three are fallout from cross-sprint interactions:
1. TestLoad_AllEnvVarsSet is the comprehensive 'every CERTCTL_* env
var' exerciser. It sets KEYGEN_MODE=server because the per-field
assertion at line 292 pins cfg.Keygen.Mode == 'server'. Sprint 4
ARCH-003 (commit 7e98b0e) made Load()→Validate() refuse to boot
in server-keygen mode without the demo-ack pair, so this test
needed the ACK env vars added alongside the existing KEYGEN_MODE
set. Fix: add CERTCTL_DEMO_MODE_ACK=true + CERTCTL_DEMO_MODE_ACK_TS
set to time.Now().Unix() (well within the SEC-H3 24h freshness
window) right after the KEYGEN_MODE line, with an inline comment
explaining why the SEC-H3 demo-ack pair is needed here.
2. TestValidate_AcceptsServerKeygenWithDemoAck and
TestValidate_AgentKeygenIgnoresDemoAck are NEW in Sprint 4. They
construct Config directly and call Validate(), but their
Scheduler fixtures omit three load-bearing fields:
- JobTimeoutInterval (>= 1s required, config.go:1286)
- AwaitingCSRTimeout (>= 1s required, config.go:1290)
- AwaitingApprovalTimeout (>= 1s required, config.go:1294)
These three were added in earlier milestones (I-003 timeout
sweeper). The Sprint 4 fixtures pre-date the alignment that
landed elsewhere in the file (see line 1543's full template). Fix:
add the three fields with the same production-shaped values used
in the rest of the test file (10m / 24h / 168h).
Verified locally with the canonical-runner Go 1.25.10 toolchain:
go test -count=1 \
-run 'TestLoad_AllEnvVarsSet|TestValidate_AcceptsServerKeygenWithDemoAck|TestValidate_AgentKeygenIgnoresDemoAck' \
./internal/config/
# ok github.com/certctl-io/certctl/internal/config 0.005s
go test -count=1 ./internal/config/
# ok github.com/certctl-io/certctl/internal/config 0.804s
gofmt -l internal/config/config_test.go
# (empty — clean)
go vet ./internal/config/...
# (empty — clean)
Closes the internal/config leg of the Sprint 5 CI redness. Together
with the M-009 carve-out commit, this returns the Sprint 5 push to
green.
|
||
|
|
c7f3ec6290 |
fix(ci-guard): M-009 — exclude Orval-generated tree from bare-useMutation scan
Sprint 5 CI follow-up. Pre-fix: Sprint 5 ARCH-001-A (commit
|
||
|
|
6acf3559a3 |
docs(scale): TEST-005 — split scale baseline into its own canonical record
Sprint 5 unified-master-audit closure. Pre-fix:
- docs/operator/scale.md L163-185 held a TBD-laden table with 5
scenario rows. The Phase 8 scenarios shipped 2026-05-14; baseline
capture on canonical hardware was 'the next operational step'
that had not been taken.
- Acquirers + operators asking 'what's the scale ceiling?' got
'TBD' as the in-tree answer.
The audit's fix wanted three things:
1. Capture p50/p95/p99 + error rate + memory profile on a fixed-
spec runner.
2. Replace the scale.md TBD rows with real numbers.
3. Archive k6 artifacts under deploy/test/loadtest-artifacts/.
The actual capture is a workflow_dispatch run the operator triggers
on a real Linux runner — it can't happen from a sandbox without
Docker. What I CAN deliver in this commit is the canonical-record
infrastructure that turns the next workflow run into a baseline that
sticks:
- New docs/operator/scale-baseline-2026-Q2.md is the canonical
record. Documents the three scenarios, the methodology, the
capture procedure, and a 'Latest capture' table with
placeholder rows ready to receive the workflow_dispatch run's
numbers. The doc explicitly defends the 'ubuntu-latest runner'
choice (reproducibility > paid-AWS-account specificity).
- docs/operator/scale.md L163-185 — the TBD table — replaced with
a pointer paragraph to the new baseline file. Per the
canonical-doc-pointer pattern: the operator-posture doc changes
when scenarios change; the baseline doc changes on every
capture. Splitting them avoids review-noise on per-capture
commits.
- New deploy/test/loadtest-artifacts/ directory with a README
documenting the long-term-archive contract (the GHA artifact
retention is 90 days; numbers acquisition reviewers look at
months later need a committed home).
Operator next steps to fill the placeholders:
1. Trigger Actions → loadtest → Run workflow.
2. Download the three matrix-leg artifacts.
3. Update the baseline doc's 'Latest capture' rows.
4. Commit the raw artifacts (or git-lfs for >100 MB archives) to
deploy/test/loadtest-artifacts/.
Closes TEST-005 (infrastructure side). Numbers land on the next
canonical-runner workflow_dispatch capture.
|
||
|
|
3e09401502 |
test(ci): TEST-003 — flip Frontend E2E from informational to merge-gate
Sprint 5 unified-master-audit closure. The Phase 8 E2E workflow at
.github/workflows/e2e.yml shipped with continue-on-error: true and
a header banner that said it would be promoted to required-for-merge
once 1-2 weeks of green runs accumulated. The accumulation happened;
the flip didn't.
Ground-truth via api.github.com/repos/certctl-io/certctl/actions/runs
(2026-05-16): 14 consecutive green runs across 2026-05-14 to
2026-05-15 (heaviest Sprint 1-4 frontend churn in the repo's history,
6 commits touching web/**) confirmed the suite is stable. No flakes,
no flaps, no timeouts.
Fix:
- .github/workflows/e2e.yml continue-on-error: true → false.
- Workflow name strips the '(informational)' tag.
- Header banner rewritten to reflect the new posture + flag the
one operator action still required (adding the job to the
branch-protection required-checks list at
https://github.com/certctl-io/certctl/settings/branches).
- New docs/operator/runbooks/e2e-snapshot-update.md documents the
visual-regression snapshot-bump workflow now that a red E2E
run blocks merge. Includes the standard (one or two affected
tests) + mass-bump (font upgrade / framework migration) paths,
plus an explicit anti-patterns section (do NOT regenerate from
a developer's local machine; do NOT add --update-snapshots to
the always-run step).
Closes TEST-003.
|
||
|
|
38f1200f26 |
fix(api,codegen): ARCH-001-A — Phase 1 Orval codegen + 2 new CI guards (large diff)
Sprint 5 unified-master-audit closure. Pre-fix:
- api/openapi.yaml: 7,788 LOC of hand-authored spec.
- web/src/api/generated/: directory did NOT exist (the Phase-5
scaffolding never had its first generation run).
- scripts/ci-guards/openapi-codegen-drift.sh: skip-when-absent
(line 33-39 — informational scaffold).
- api/openapi.yaml info.version: '2.0.0', latest tag: v2.1.7
(a 7-version drift between spec and ship).
Net effect: every new route required three coordinated edits (Go
handler, openapi.yaml, frontend client.ts), payload-level breaking
changes shipped unnoticed, and downstream API client integration
cost was permanent.
Phase 1 fix (the audit's literal scope):
1. **Run Orval**, commit the generated tree. 316 files / ~1.8 MB
under web/src/api/generated/, tags-split layout (one directory
per OpenAPI tag), TanStack Query client mode. All output routes
through web/src/api/mutator.ts which delegates to the existing
fetchJSON in client.ts so auth/CSRF/401-event semantics stay
in one place.
2. **Fix two spec defects** the first orval run surfaced:
- YAML duplicate-key bug at L77-89 — SCEP's description was
misplaced under OIDC. Restored to its own tag entry.
- Missing #/components/schemas/Error referenced by three
operations. Aliased to the existing ErrorResponse schema.
3. **Flip the codegen-drift guard from skip-when-absent to
hard-gate.** A missing generated/ directory now fails the
build with an actionable restore command. The existing
regenerate-and-diff path stays as before.
4. **New openapi-version-tag-parity CI guard.** Asserts
openapi.yaml info.version equals the latest v* git tag. Falls
back to api.github.com when the local clone is shallow.
Bumped openapi.yaml info.version 2.0.0 → 2.1.7 in the same
commit so the new guard greens out.
5. **CI workflow** updated to fetch tags on the frontend job's
checkout so the parity guard reads them locally (the GH API
fallback still works but adds a network round-trip).
Verified locally:
- openapi-codegen-drift.sh: clean (re-generation produces
byte-identical tree to what's tracked).
- openapi-version-tag-parity.sh: clean (2.1.7 == v2.1.7).
- tsc --noEmit: exit 0 across the entire frontend (the
generated tree's responseType field threaded through the
mutator's CertctlFetchOptions cleanly).
- Existing Vitest suite: 141/141 pass on the three sampled
suites (AuthProvider + client + IssuerHierarchyPage).
Follow-on work (NOT in this commit):
- Per-consumer migration: pages flip from client.ts imports to
generated/ imports one at a time. Both styles share fetchJSON
semantics, so the migration is incremental.
- Server-side oapi-codegen handler stubs (Phase 2 from the
audit's fix language) — separate sprint.
Closes ARCH-001-A.
|
||
|
|
e1ab1db65a |
test(web): TEST-007 — co-locate Vitest coverage for IssuerHierarchyPage
Sprint 5 unified-master-audit closure. Pre-fix the page existed
without a co-located test — the only frontend page missing from the
T-1 sweep that covered the other 30. The audit calls this 'a buyer-
side easy finding' since every other page has tests and one doesn't.
The new test mirrors the CertificatesPage.test.tsx pattern: vi.mock
the api/client surface, render via MemoryRouter so useParams resolves
the URL :id param, drive the query through TanStack's resolver, then
assert observable surfaces.
Five test cases pin:
- Initial render: page header + empty-state banner when the
hierarchy is empty.
- Tree expansion: a flat 3-row root → policy → issuing list renders
as the nested forest the component builds from parent_ca_id.
- Orphan handling: a CA whose parent_ca_id references a missing
row surfaces at the top level (documented fallback in
buildHierarchyTree).
- Error state: when listIntermediateCAs rejects (e.g. RBAC 403
on missing ca.hierarchy.manage), the ErrorState component
renders with the API's error message.
- Missing-id route: when React Router's path doesn't resolve an
id (e.g. '/issuers//hierarchy' collapses), the API is NOT called.
Verified locally: 5/5 pass. The page-coverage ratio at HEAD is now
31/31 — every frontend page has at least one co-located Vitest test.
Closes TEST-007.
|
||
|
|
c95685f8ab |
docs(arch): ARCH-002-MT — document single-tenant model + tenant_id scaffolding
Sprint 4 unified-master-audit closure. Every table that joins on a
tenant identifier (managed_certificates, agents, users, roles, audit
log, etc.) has a tenant_id column. The auth middleware at
internal/auth/middleware.go:97 stamps every authenticated request
with auth.DefaultTenantID. Repository queries don't filter on
tenant. A repo skimmer sees the columns and reasonably assumes
multi-tenancy is wired end-to-end. It isn't.
This was a diligence trap: a buyer planning multi-tenant SaaS
post-acquisition would inspect the schema, conclude the
foundation is in place, and discover at integration time that the
constant-tenant invariant is hard-coded across the request layer.
Fix: docs/reference/architecture.md grows a 'Single-tenant
deployment model' subsection in Design Principles that states
plainly:
- every authenticated request carries DefaultTenantID
- tenant_id columns are forward-compatible scaffolding for the
multi-tenancy roadmap item in WORKSPACE-ROADMAP.md
- lifting to multi-tenant requires three pieces in sequence:
(1) request-derived tenant resolution
(2) per-query tenant scoping
(3) the multi-tenant-query-coverage CI guard becoming
a hard gate
- until that work lands, the multi-tenant columns are decorative
The doc points at scripts/ci-guards/multi-tenant-query-coverage.sh
(which tracks tenant_id-less query drift as an informational
warning today) and explains the inflection point for flipping it
to hard-gate. '> Last reviewed:' bumped to today.
This is a docs-only commit. No runtime behavior change.
Closes ARCH-002-MT.
|
||
|
|
a0404f2d21 |
fix(docs,code): ARCH-004 + SEC-003-K8S + ARCH-003 — marketing claims now match code truth
Sprint 4 unified-master-audit closure. Three claim-truth-alignment
findings whose README edits land on shared lines, bundled into one
commit.
ARCH-004 — 'full REST API exposed as MCP tools' overclaim:
Pre-fix the README said 'the full REST API is exposed as MCP
tools'; the actual MCP coverage is 162 tools / 220 routes
(~74%). The remaining gap is intentional: protocol-conformance
endpoints (ACME/SCEP/EST/OCSP/CRL), browser-only auth flow,
health/ready, and streaming/binary downloads — categories that
don't fit the request-response JSON tool shape.
Fix:
- README L78 qualified to 'the bulk of the REST API surface'
with explicit numbers + pointer to the new coverage doc.
- New docs/reference/mcp-coverage.md publishes the exclusion
categories with rationale + the canonical commands to
re-derive route + tool counts.
- New scripts/ci-guards/mcp-coverage-parity.sh fails the build
if the tool count drops below (routes − exclusions − 40-slack),
so a future regression that drops 50+ tools surfaces in CI.
Verified locally: clean at 162 tools / 220 routes / 37
intentional exclusions.
SEC-003-K8S — Kubernetes Secrets connector is a runtime stub:
Pre-fix README L67 marketed 'fifteen native target connectors'
with Kubernetes Secrets in the list, but realK8sClient's CRUD
methods returned 'real Kubernetes client not implemented' in
production. Per the audit's option (b) recommendation: downgrade
marketing + runtime-guard the stub.
Fix:
- README L12 + L67: 'fourteen production-ready native deployment-
target connectors plus Kubernetes Secrets (preview)'.
- k8ssecret.New() now refuses to construct unless
CERTCTL_K8SSECRET_PREVIEW_ACK=true is set, mirroring the
SEC-H3 ACK pattern. NewWithClient path (test injection)
unchanged.
- docs/reference/connectors/index.md moves Kubernetes Secrets
out of the canonical fourteen-target list into a new 'Preview
connectors' subsection.
- Regression tests in k8ssecret_test.go pin the new gate
(rejects without ACK, accepts with ACK, still rejects nil
config even with ACK).
ARCH-003 — CERTCTL_KEYGEN_MODE=server breaks the blanket claim:
Pre-fix README L12 + L82 said 'private keys stay on your
infrastructure' and 'never touch the control plane' as blanket
promises. Flipping CERTCTL_KEYGEN_MODE=server makes the control
plane mint keys in process memory — breaking the claim — and
the only signal was a boot-time slog WARN. An operator who set
the flag and didn't read logs ran in silent contradiction to the
marketed posture.
Fix:
- config.Validate() refuses to accept KeygenMode='server'
unless DemoModeAck=true (mirroring SEC-H3). Production
deploys (the default Mode='agent' path) are unaffected.
- README L12 + L82 qualified: 'In agent-mode (the default),
private keys ...; a demo-only CERTCTL_KEYGEN_MODE=server
flag mints keys server-side, refuses to start without an
explicit CERTCTL_DEMO_MODE_ACK=true acknowledgement.'
- Regression tests for the new Validate gate land in
config_test.go (note: gate tests landed in the ARCH-002
commit because of contiguous-hunk constraint at the bottom
of the file).
Closes ARCH-004, SEC-003-K8S, ARCH-003.
|
||
|
|
34d5200904 |
fix(auth): ARCH-002 — relax OIDC runtime guard, full Bundle-2 stack ships
Sprint 4 unified-master-audit closure. The README has advertised OIDC
SSO as a v2.1 feature (L18, L74) but cmd/server/main.go retained a
Bundle-2-Phase-0 runtime guard that os.Exit(1)'d the moment any
operator set CERTCTL_AUTH_TYPE=oidc:
CERTCTL_AUTH_TYPE=oidc: the OIDC auth chain is not yet wired in
this build (Auth Bundle 2 Phase 6 ships the session middleware
that consumes this auth-type literal).
That message was true when Phase 0 landed (the literal got reserved
in ValidAuthTypes ahead of the handler chain). It's been stale since
Phase 6 shipped. As of 2026-05-16 the full stack is live:
- session.NewService at cmd/server/main.go:394
- oidcsvc.NewService at cmd/server/main.go:436
- ChainAuthSessionThenBearer at cmd/server/main.go:2012
- csrfMiddleware at cmd/server/main.go:2017
- /auth/oidc/{login,callback,back-channel-logout} routes at router.go
- 6 OIDC handler files in internal/api/handler/
- 2,852 LOC in internal/auth/oidc/ + 1,632 LOC in internal/auth/session/
Fix:
- Introduce config.IsRuntimeSupportedAuthType(AuthType) as the
single source of truth for which auth-type literals the cmd/server
runtime guard accepts. The set is {api-key, none, oidc} —
every entry in ValidAuthTypes(). The helper exists so the test
suite can pin the invariant 'ValidAuthTypes ⊆ runtime-supported'
without grepping cmd/server source.
- cmd/server/main.go's switch collapses to a single
IsRuntimeSupportedAuthType check; the dedicated AuthTypeOIDC
fail-loud case is gone. The G-1 silent-auth-downgrade invariant
stays intact — 'jwt' is still rejected at config.Validate()
time (never made it into ValidAuthTypes()).
- internal/config/auth.go AuthTypeOIDC comment updated to reflect
the post-Phase-6 reality (it was prescriptive pre-fix:
'Once Bundle 2's session middleware + OIDC service ship, the
runtime guard relaxes' — that condition is met).
Regression coverage:
- TestIsRuntimeSupportedAuthType_AcceptsAllValidEntries — every
valid type is runtime-supported (catches future drift).
- TestIsRuntimeSupportedAuthType_AcceptsOIDC — explicit pin on
the ARCH-002 invariant.
- TestIsRuntimeSupportedAuthType_RejectsUnknown — 'jwt', empty,
'saml', 'mtls', 'API-KEY' all rejected.
(Also lands the ARCH-003 keygen-mode tests in the same file —
contiguous hunk in config_test.go.)
Closes ARCH-002.
|
||
|
|
3ce05ab0a8 |
docs(runbook): DEPL-005 — rewrite postgres-backup automation paths to reference the shipped CronJob
Sprint 3 unified-master-audit closure. docs/operator/runbooks/postgres-backup.md
sections 110-143 still said 'certctl ships no backup CronJob template
in the Helm chart' and the three sample recipes that followed
included an 'in-cluster Postgres → S3' rollup that the operator
'should roll their own.' But the chart actually DOES ship that
CronJob:
deploy/helm/certctl/templates/backup-cronjob.yaml (Phase 4
DEPL-H2 closure, 2026-05-14) — opt-in via 'backup.enabled: true',
PVC + S3 sinks, pg_dump shape byte-comparable with the manual
command earlier in the runbook.
Operators following the pre-fix runbook would write a duplicate
CronJob from scratch while the working template sat unused under
their nose.
Rewrite of sections 110-143:
- Lead with the shipped CronJob, two install one-liners (PVC + S3).
- Move the recipes-by-topology block down to 'When the bundled
CronJob is NOT the answer' — still call out managed Postgres
(use provider PITR) and bare-VM Postgres (systemd + pg_dump +
restic) as deliberately out-of-scope.
- Add 'Recovery objectives' subsection: RPO ≈ 24h at the default
nightly schedule, RTO ≈ 30-60min from the existing drill steps
further down the page. Tells the reader where the bundled
CronJob fits in their RPO/RTO budget without overpromising
(anything below 24h RPO needs WAL-shipping, which the CronJob
doesn't do).
- Bump '> Last reviewed:' to today.
Closes DEPL-005.
|
||
|
|
360eaa75bc |
fix(compose): DEPL-002 — pin alpine/openssl + postgres:16-alpine by digest + H-002 CI guard
Sprint 3 unified-master-audit closure. The production-shaped compose
(deploy/docker-compose.yml) — explicitly self-described as
'PRODUCTION-SHAPED (Bundle 2)' in its header — pulled two images by
floating tag:
image: alpine/openssl:latest
image: postgres:16-alpine
The certctl Dockerfiles have been digest-pinned for two bundles
(see Bundle A / H-001 + the digest-validity.sh CI guard). Compose
shipped on the lower bar — a registry-side tag swap could change
what an operator deploys without their seeing the diff in their
infra repo.
Fix:
- Pin both images by @sha256: (alpine/openssl looked up via Docker
Hub tag API on 2026-05-16; postgres:16-alpine the same).
- New scripts/ci-guards/H-002-bare-compose-image.sh — analogous
to H-001 — fails the build if any 'image:' line in
deploy/docker-compose.yml lacks a @sha256 digest. Test compose
files (deploy/docker-compose.test.yml + the loadtest stack)
and examples/ stay scoped out by design: those are throwaway
development-loop tooling where floating tags are intentional.
- The existing digest-validity.sh CI guard auto-discovers
digests via grep across deploy/ so the new pins get verified
on the same run that pulls them, without a separate change.
Closes DEPL-002.
|
||
|
|
b721596213 |
fix(config): DEPL-004 — expand $(POSTGRES_PASSWORD) placeholder in CERTCTL_DATABASE_URL
Sprint 3 unified-master-audit closure. The Helm chart's _helpers.tpl
(line 133) renders the bundled-Postgres URL with a literal
'$(POSTGRES_PASSWORD)' placeholder:
postgres://certctl:$(POSTGRES_PASSWORD)@db:5432/certctl?sslmode=disable
Kubernetes' '$(VAR)' env-substitution syntax ONLY expands when the
value is a string literal in the Pod spec. Values sourced from
'valueFrom.secretKeyRef' (which is how the chart wires
CERTCTL_DATABASE_URL) are NOT expanded — the literal makes it all
the way to the server, which tries to dial Postgres with
'$(POSTGRES_PASSWORD)' as the password, fails with auth error, and
leaks the placeholder into application error logs.
Fix: in-process expansion at internal/config/config.expandDatabaseURL.
strings.ReplaceAll of the literal '$(POSTGRES_PASSWORD)' token with
os.Getenv('POSTGRES_PASSWORD') when both the token is present AND
the env var is set. Conservative — no os.ExpandEnv (which would
expand any $VAR), no Docker entrypoint shim, no Helm-template-time
password injection that would inline the secret into a second
Kubernetes resource. External-Postgres deploys whose URL embeds
the real password pass through untouched because the placeholder
doesn't match.
Regression coverage in internal/config/config_test.go pins:
- happy-path placeholder substitution
- non-placeholder URL passes through unchanged
- placeholder + empty POSTGRES_PASSWORD leaves the URL alone
- multi-occurrence safety via ReplaceAll
Closes DEPL-004.
|
||
|
|
6a640ac3e7 |
fix(helm): DEPL-003 + DEPL-006 — render viaHook env, sessionAffinity, HA backend default
Sprint 3 unified-master-audit closure — two Helm-chart correctness
defects with overlapping CI-guard surface.
DEPL-003 — CERTCTL_MIGRATIONS_VIA_HOOK never rendered:
Pre-fix the env var was documented in values.yaml and the
migration-job.yaml comment but never made it into the server
Deployment env block. With migrations.viaHook=true the operator's
intent is 'the pre-install/pre-upgrade Helm Job owns migrations,'
but the server pods, missing the env, ran their own
cmd/server/migrations.go::runBootMigrations alongside the hook
Job, racing on the schema lock.
Fix: render '- name: CERTCTL_MIGRATIONS_VIA_HOOK / value: true'
in server-deployment.yaml under '{{- if .Values.migrations.viaHook }}'.
DEPL-006 — HA example missing rate-limit backend + sessionAffinity:
values-prod-ha.yaml sets replicas:3 but inherited the chart-wide
default rateLimiting.backend=memory (which gives each pod its
own bucket map, effectively tripling the cap on a 3-replica fleet)
AND the chart had no render path for server.service.sessionAffinity
even though docs/operator/runbooks/ha.md instructed operators to
set it for ClientIP-routed sticky sessions.
Fix:
- server-service.yaml gains a conditional sessionAffinity +
sessionAffinityConfig.clientIP.timeoutSeconds render.
- values.yaml grows the matching schema entries (default empty
so single-replica deploys are unaffected).
- values-prod-ha.yaml flips rateLimiting.backend=postgres and
service.sessionAffinity=ClientIP.
- NOTES.txt emits a loud warning when replicas>1 + either toggle
is still in the default state, so the misconfig surfaces at
helm install time instead of in a confused login-flow bug
report a week later.
CI:
scripts/ci-guards/B3-helm-chart-coherence.sh gains 'Check 7'
(DEPL-003 viaHook env render — both positive and negative —
the inverse case catches future drift that drops the {{- if }}
guard) and 'Check 8' (DEPL-006 sessionAffinity render). Both
helm-template through to assert the rendered YAML carries the
expected text.
Closes DEPL-003, DEPL-006.
|
||
|
|
15fedbaa06 |
test(scheduler): SCALE-001 — assert claim cap via non-Pending count, not Running
Sprint 2's TestProcessPendingJobs_RespectsClaimLimit asserted
that exactly 3 jobs sat in JobStatusRunning after a 10-row
ProcessPendingJobs sweep with SetClaimLimit(3). The CI run
landed 'running-job count = 0; want 3.'
Root cause: the mock's ClaimPendingJobs flips Pending → Running
on the 3 claimed rows (atomic-claim semantics). processJob then
calls renewalService.ProcessRenewalJob, which fails on the
mock cert-repo's not-found error and calls failJob → which
transitions the row from Running → Failed. By the time the
test assertion runs, no row is still in Running.
The load-bearing SCALE-001 invariant is 'the cap STOPPED at 3.'
Whether the 3 claimed rows ended up Running, Failed, or
Completed is irrelevant to the cap — what matters is that 7
rows STAYED in Pending for the next tick.
Fix: count non-Pending (= claimed) and still-Pending (= 10
minus claimed) separately. Assert claimed=3 and stillPending=7.
LastClaimLimit=3 assertion (already passing in the failed run)
also stays as the seam-propagation pin.
This is a test-fix only — the SCALE-001 production behavior
landed correctly in
|
||
|
|
c40690e42d |
docs(testing): regenerate skip-inventory after SEC-001 types_test.go edit (CI guard skip-inventory-drift)
SEC-001's TestOIDCProvider_Validate_RejectsSSRFIssuer addition in internal/auth/oidc/domain/types_test.go shifted an existing t.Skip site from line 186 → line 221. The auto-generated inventory at docs/testing/skip-inventory.md still pointed at the old line, so scripts/ci-guards/skip-inventory-drift.sh failed the build. Regenerated via scripts/skip-inventory.sh and bumped the '> Last reviewed:' header. Inventory now matches the live tree exactly. |
||
|
|
657a699564 |
docs(env): SCALE-001 + SEC-006 — document the two new env vars (CI guard G-3)
Sprint 2 left CERTCTL_SCHEDULER_JOB_CLAIM_LIMIT and CERTCTL_RATE_LIMIT_BUCKET_TTL defined in Go config but undocumented in the canonical env-var inventory. CI guard scripts/ci-guards/G-3-env-docs-drift.sh failed the build on this drift. Add both vars to deploy/ENVIRONMENTS.md alongside their siblings (RATE_LIMIT_RPS / RATE_LIMIT_BURST) with the same voice as adjacent entries: default value, what it controls, why the audit closed it, and the tuning intuition. |
||
|
|
183c56f6c5 |
fix(agent): SCALE-006 — startup + recurring jitter on heartbeat and poll loops
Sprint 2 unified-master-audit closure. Pre-fix the agent started
its heartbeat + poll loops on bare time.NewTicker cadence with no
startup jitter:
heartbeatTicker := time.NewTicker(a.heartbeatInterval)
pollTicker := time.NewTicker(a.pollInterval)
a.sendHeartbeat(ctx) // fires immediately, in lockstep
a.pollForWork(ctx) // ditto
A mass restart (rolling K8s deploy, control-plane reboot, scheduled
fleet bounce) produced a thundering herd — 5K agents booting in a
10-second window all hit /heartbeat in lockstep, then /poll, every
interval forever afterward.
Fix:
- Per-agent startup jitter ∈ [0, interval) drawn fresh from
math/rand/v2 (no cryptographic strength needed) before the first
heartbeat and first poll. Heartbeat and poll jitters are drawn
independently so a single seed doesn't create a secondary
correlation pattern.
- time.NewTicker swapped for the existing in-tree
internal/scheduler.JitteredTicker primitive (±10% per-tick
envelope, fresh draw per tick to prevent drift compounding).
Same pattern as every server-side scheduler.go loop.
- Startup-jitter Sleeps are ctx-aware so a sigint-during-startup
exits cleanly rather than hanging.
The select cases that read heartbeatTicker.C / pollTicker.C are
unchanged — JitteredTicker.C is a chan time.Time, identical shape
to time.Ticker.C.
Discovery ticker is left as bare time.NewTicker (audit didn't cite
it; changing it would expand scope).
Closes SCALE-006.
|
||
|
|
a485e31f63 |
fix(repo,service): SCALE-002 — push pagination into SQL for target/issuer/team/agent_group
Sprint 2 unified-master-audit closure. Pre-fix four service List
endpoints (target, issuer, team, agent_group) called repoFoo.List(ctx)
to fetch the full table then sliced in memory:
rows, _ := s.repo.List(ctx)
total := int64(len(rows))
start := (page - 1) * perPage
end := start + perPage
return rows[start:end], total, nil
This page-sliced in memory pattern marshals every row per request —
fine on small fleets but unacceptable for multi-tenant or large-fleet
deploys. The agent_group case was worse — the service explicitly
ignored page/perPage and returned the entire slice.
Fix:
- New ListPaginated(ctx, limit, offset) method on each of the four
repositories. Postgres implementations push LIMIT + OFFSET into
the SQL plus a SELECT COUNT(*) for the total. Mirrors the cursor
pattern already in internal/repository/postgres/certificate.go.
- Each ListPaginated normalises limit≤0→50 and offset<0→0,
matching the service-layer defaults that already existed.
- Repository interfaces grow the new method so adapters stay
swappable.
- Service List methods now call repoFoo.ListPaginated(ctx, perPage,
(page-1)*perPage) directly — no more memory-slice.
- AgentGroupService.ListAgentGroups closes the Bundle E / Audit
L-020 'page/perPage unused' gap.
Test changes:
- sliceWindow generic helper in testutil_test.go mirrors the SQL
LIMIT/OFFSET semantics for in-memory mocks.
- Six mock implementers (lifecycle_test, testutil_test x2,
agent_group_test, team_test) gain ListPaginated methods.
- TestTeamService_List_SCALE002_PaginationPropagatesToRepo pins
the page=2, perPage=3 → 3 rows of 10 invariant.
Closes SCALE-002.
|
||
|
|
8f2e5771db |
fix(middleware): SEC-006 — TTL-evict idle token-bucket rate-limiter entries
Sprint 2 unified-master-audit closure. Pre-fix the keyed rate
limiter's bucket map had no eviction. The package-level comment
explicitly noted the leak: high-cardinality unauthenticated traffic
(CGNAT churn, Tor exit lists, botnets, infinite-cardinality scanners)
grew process memory unboundedly. Production deploys with millions of
unique IPs would eventually OOM.
Fix:
- RateLimitConfig.BucketTTL (env CERTCTL_RATE_LIMIT_BUCKET_TTL,
default 1h, clamp-floor 1m). 1h chosen to be well above realistic
operator IP churn windows (returning clients keep their bucket)
and well below the unbounded-leak window the pre-fix code
allowed.
- tokenBucket gains a lastAccess field updated on every allow()
call via touch(); reading via lastAccessTime() under the bucket's
own mutex.
- keyedRateLimiter.sweepLoop runs in a single goroutine per
limiter (production wires 2: default + no-auth fallback), waking
every BucketTTL/4. sweep() removes any bucket whose lastAccess
is older than the cutoff and bumps evictedTotal atomically.
- Both NewRateLimiter call sites in cmd/server/main.go (default
stack and no-auth fallback) now thread cfg.RateLimit.BucketTTL.
Regression coverage:
- TestKeyedRateLimiter_SweepEvictsIdleBuckets: 1000 synthetic IP
keys populate the map, advance past TTL, call sweep() directly,
assert map drained to 0 + evictedTotal=1000 + fresh key creates
new bucket (map not poisoned).
- TestKeyedRateLimiter_SweepKeepsActiveBuckets: inverse — a bucket
touched within the TTL window survives the sweep. Catches a
future regression that inverts the cutoff comparison.
Closes SEC-006.
|
||
|
|
037876fa0f |
fix(scheduler): SCALE-001 — cap ClaimPendingJobs per-tick (default 1000)
Sprint 2 unified-master-audit closure. Pre-fix the scheduler invoked
ClaimPendingJobs(ctx, "", 0). limit:0 loads every Pending row in a
single transaction — a 100K-job burst (cert-fleet sweep, post-outage
recovery, large agent-fleet first boot) marshalled the full queue
into process memory before boundedFanOut's semaphore could back-
pressure the upstream CAs.
Fix:
- SchedulerConfig.JobClaimLimit (env CERTCTL_SCHEDULER_JOB_CLAIM_LIMIT,
default 1000). ≤0 normalised to 1000 in SetClaimLimit — fail-safe
vs. legacy unlimited semantics.
- JobService.claimLimit threaded into the existing
ProcessPendingJobs flow; ClaimPendingJobs(ctx, "", s.claimLimit).
- cmd/server/main.go wires jobService.SetClaimLimit(cfg.Scheduler.JobClaimLimit).
- 'processing pending jobs' log line now includes claim_limit so
operators can spot the cap engaging (count == claim_limit ⇒
queue is running ahead of fan-out; bump CERTCTL_SCHEDULER_JOB_CLAIM_LIMIT
or CERTCTL_RENEWAL_CONCURRENCY).
- Test wiring keeps the legacy zero-value (unlimited) for byte-
for-byte compatibility with the existing 600+ JobService unit
tests — only production code goes through SetClaimLimit.
Regression coverage:
- mockJobRepo.LastClaimLimit records the limit passed through
ClaimPendingJobs so tests can pin the propagation.
- TestProcessPendingJobs_RespectsClaimLimit: 10 Pending rows,
SetClaimLimit(3), expect exactly 3 transition to Running plus
LastClaimLimit=3 on the mock.
- TestSetClaimLimit_NormalisesNonPositive: 0/-1/-1000 all
normalise to 1000.
Closes SCALE-001.
|
||
|
|
7d2e7043b9 |
fix(server): SEC-003 — keep securityHeadersMiddleware in rate-limit stack
Sprint 1 unified-master-audit closure. cmd/server/main.go built two
middleware stacks: a default (line ~2054) and a rate-limit-enabled
rebuild (line ~2079). The rebuild dropped securityHeadersMiddleware,
silently turning off five browser-side defenses (Strict-Transport-
Security, X-Frame-Options, X-Content-Type-Options, Referrer-Policy,
Content-Security-Policy) the moment an operator flipped
CERTCTL_RATE_LIMIT_ENABLED=true.
Fix: re-insert securityHeadersMiddleware at the same position as the
default stack and place rateLimiter immediately after, so even a 429
response carries the same headers as a 200.
Regression coverage:
- cmd/server/main_test.go TestMain_RateLimitedStack_EmitsSecurityHeaders
mirrors the production stack composition and asserts each of the
five headers lands on the response. A future regression that
removes securityHeadersMiddleware (or reorders it after the rate
limiter such that a 429 misses the headers) surfaces here.
Closes SEC-003.
|
||
|
|
037dab7b6f |
fix(agent,service): SEC-002 — validate certificate_id shape + contain key path
Sprint 1 unified-master-audit closure. Pre-fix the agent built its
on-disk key path via:
keyPath := filepath.Join(a.config.KeyDir, job.CertificateID+".key")
migrations/000001_initial_schema.up.sql declares managed_certificates.id
as TEXT PRIMARY KEY with no shape constraint, so a compromised control
plane (or a poisoned database row) could deliver a job whose
certificate_id is '../../etc/passwd', '/absolute/path', a NUL-byte
payload, or a Windows-separator-laden string — driving arbitrary
file write or read on the agent host.
Fix (two ends; both load-bearing):
Server side:
- New internal/validation/certificate_id.go: ValidateCertificateID
pins the canonical TEXT-PK shape (^[A-Za-z0-9._-]{1,128}$, plus
explicit '.'/'..' rejection).
- CertificateService.Create now invokes ValidateCertificateID after
the existing required-fields check; malformed IDs are refused
before persistence or downstream job creation.
Agent side:
- cmd/agent/keymem.go: validateAgentCertID mirrors the server-side
shape regex. safeAgentKeyPath additionally asserts the joined
path is contained within KeyDir via filepath.Rel — even if a
future refactor bypasses the shape check, a path that escapes
KeyDir fails closed.
- poll.go + deploy.go: both filepath.Join call sites routed
through safeAgentKeyPath; rejection surfaces via reportJobStatus
so the control plane sees the failure.
Regression coverage:
- internal/validation/certificate_id_test.go: production shapes
accepted; explicit rejection table for empty, overlong, posix
traversal, absolute, Windows traversal, Windows separator, NUL
byte, newline/tab injection, drive prefix, space, unicode dots.
- cmd/agent/keymem_test.go: validateAgentCertID acceptance +
rejection tables; safeAgentKeyPath happy path + the 8 audit
vectors plus empty-keyDir refusal.
Closes SEC-002.
|
||
|
|
e6cfd756ac |
fix(auth): SEC-001 — gate OIDC discovery through SafeHTTPDialContext + ValidateSafeURL
Sprint 1 unified-master-audit closure. Two OIDC discovery call sites
passed the bare request context to gooidc.NewProvider:
- internal/auth/oidc/test_discovery.go:65 (dry-run validator)
- internal/auth/oidc/service.go:1066 (runtime cache load)
gooidc.NewProvider derives its HTTP client from the context via
oidc.ClientContext; with no override it falls through to
http.DefaultClient — no SSRF guard. An admin with auth.oidc.create
could induce server-side HTTPS egress to loopback (127.0.0.1, ::1),
RFC 1918, link-local (169.254.169.254 — cloud-instance metadata),
and IPv6 link-local (fe80::/10). The companion JWKS reachability
probe was already routed through SafeHTTPDialContext via the
Bundle 5 R6 closure; the discovery + claims path bypassed that.
Fix:
- New internal/auth/oidc/safehttp.go: oidcDiscoveryClient (Transport
DialContext = validation.SafeHTTPDialContext) + SafeOIDCContext
helper. Both call sites now wrap ctx through SafeOIDCContext
before NewProvider runs.
- Defense-in-depth: OIDCProvider.Validate calls
validation.ValidateSafeURL on the IssuerURL after the existing
https/parse checks, refusing reserved-address issuers at
provider-creation time.
- TestDiscovery surfaces the SSRF policy error via the result's
Errors slice up-front (early-fail UX rail) before invoking
NewProvider.
Test seams:
- setup_test.go swaps oidcDiscoveryClient + validateIssuerSSRF
for httptest loopback compatibility, mirroring the existing
jwksProbeClient pattern.
Regression coverage:
- internal/auth/oidc/domain/types_test.go: 5-case table pinning
loopback v4/v6, cloud metadata, link-local v4/v6 rejection.
- internal/auth/oidc/coverage_fill_test.go: same 5 cases against
Service.TestDiscovery via temporarily restoring the production
gate.
Closes SEC-001.
|
||
|
|
67dbd18fda |
fix(web): Hotfix #19 — AuthProvider 401 unconditional redirect (GitHub #13)
Refresh-after-login wiped the in-memory apiKey and the next API call returned a bare 401 (no WWW-Authenticate header). The pre-Hotfix-19 401 handler in AuthProvider only redirected when cause was a non-'invalid_token' OIDC session-expiry category; bare 401s fell through to an in-place AuthGate state flip that unmounted BrowserRouter under an in-flight <Link>, triggering a react-router-dom invariant that surfaced via ErrorBoundary as "Something went wrong." Fix: always hard-navigate to /login on 401 regardless of cause. Preserve cause-aware UX by forwarding cause to /login?session_expired= only when present; emit plain /login redirect for bare 401s. Closes #13.v2.1.7 |
||
|
|
5a1dbce6d5 |
fix(deploy): Hotfix #18 — apt-get retry loop in libest Dockerfile (transient mirror flake)
CI image-and-supply-chain job failed building deploy/test/libest/
Dockerfile:
Get:62 http://deb.debian.org/debian bullseye/main amd64 libssh2-1
amd64 1.9.0-2+deb11u1 [156 kB]
Err:62 http://deb.debian.org/debian bullseye/main amd64 libssh2-1
amd64 1.9.0-2+deb11u1
Error reading from server - read (104: Connection reset by peer)
[IP: 151.101.202.132 80]
E: Failed to fetch http://deb.debian.org/debian/pool/main/libs/
libssh2/libssh2-1_1.9.0-2%2bdeb11u1_amd64.deb
E: Unable to fetch some archives, maybe run apt-get update or try
with --fix-missing?
Root cause:
Transient TCP reset from fastly's Debian mirror at 151.101.202.132
mid-fetch of one of 73 packages. Mirrors flake; the apt error
message itself suggests "--fix-missing." This was NOT a code
regression — the build sequence completed Dockerfile (main
server), Dockerfile.agent, and f5-mock-icontrol/Dockerfile cleanly
before hitting the flake on the 4th and final Dockerfile. The Go
+ npm steps for the main image all succeeded.
The main Dockerfile already wraps `npm ci` in a 3-retry loop
(Hotfix #9 from the Storybook lockfile saga; npm registry has the
same flake profile as Debian mirrors). The libest Dockerfile's
two apt-get install sites (builder stage line 85, runtime stage
line 189) had no such wrapping.
Fix:
Wrap both apt-get install invocations in a 3-retry loop matching
the main Dockerfile's npm-ci pattern. Each retry runs
`apt-get update && apt-get install --fix-missing ...`, exits the
loop on success, sleeps 5s between attempts. After 3 failed
attempts the build fails (preserves CI's signal for a genuinely
broken mirror state).
--fix-missing telling apt to continue past temporarily-missing
packages on subsequent retries; combined with the update + sleep,
the 3-attempt loop covers the typical mirror-flake window
(~30-60s of churn before another mirror takes over).
Both apt-get sites in the libest Dockerfile get the same treatment
(builder + runtime). The two are independent install operations
so failure in one is independent of the other.
Verification (sandbox):
• Visual diff of both apt-get blocks — consistent retry shape +
--fix-missing + error message + sleep cadence
• No Go-side code touched; this is a pure CI-infrastructure
Dockerfile change
• Other Dockerfiles in the repo (main + agent + f5-mock-icontrol)
don't need this fix today; the main Dockerfile already has
the retry loop for npm ci, and agent + f5-mock use Alpine `apk`
which has its own retry semantics
Ground-truth: origin/master tip
v2.1.6
|
||
|
|
76e9380389 |
fix(web): Hotfix #17 — skip backend-dependent e2e specs in CI (e2e.yml turns green)
The "Frontend E2E (informational)" workflow has been red on every push since Phase 8 (commit |
||
|
|
7268d12a17 |
feat(web): close FE-M6 — migrate static inline-style attrs to Tailwind + correct CSP rationale comment
Closes frontend-design-audit finding FE-M6 (Med):
CSP allows 'unsafe-inline' for `style-src` — necessary today
because of inline SVG `style=` attrs (related to FE-H2)
═══════════════════════════ GROUND-TRUTH FINDINGS ═══════════════════
Ground-truth recon found 4 audit-framing errors:
(1) The "17 inline-style tsx files" count was stale — actual is 9
(8 after excluding a Layout.tsx comment match the audit's grep
counted).
(2) The CSP rationale comment at securityheaders.go:35 LIED about
WHY 'unsafe-inline' is needed. It claimed "Tailwind (via Vite)
injects per-component <style> blocks at build time." Verified
against the post-build artifact: `grep -c '<style' dist/index.html`
= 0; Vite's CSS output is a single .css file linked via
`<link rel="stylesheet">`. The 'unsafe-inline' grant exists for
React's `style={...}` attribute model, NOT for Vite or Tailwind.
(3) The 9 sites split cleanly into:
LOAD-BEARING DYNAMIC (5 sites; can't be Tailwind utilities
because values are computed at runtime):
- Tooltip.tsx Floating-UI position (left/top px per-tick)
- AgentFleetPage.tsx dynamic color+width chart bars
- dashboard/charts.tsx Recharts color props
- CertificatesPage.tsx progress-bar percent width
- IssuerHierarchyPage.tsx depth-based marginLeft
STATIC PIXEL VALUES (3 files, ~12 sites; clean Tailwind
migration targets):
- UsersPage.tsx — filter UI + table styling
- DigestPage.tsx — iframe min-height
- AuthProvider.tsx — demo-mode banner
(4) Fully eliminating 'unsafe-inline' would require either banning
dynamic `style={...}` (CSS-in-JS rewrite of the 5 load-bearing
sites) or adopting CSP nonces with React 18+'s style runtime.
Neither fits the original FE-M6 phase budget.
═══════════════════════════ CHANGES ═══════════════════════════════
web/src/pages/auth/UsersPage.tsx:
9 inline-style attrs → Tailwind utility classes. The filter UI
(mb-4, mr-2, w-[280px] p-1), the table (w-full border-collapse),
the thead row (border-b-2 border-gray-300 text-left), per-row
borders (border-b border-gray-200 + opacity-50/100 conditional),
buttons (px-3 py-1), the empty-state cell (p-3 text-center).
Behavior-preserving.
web/src/pages/DigestPage.tsx:
iframe `style={{ minHeight: '600px' }}` → className "min-h-[600px]"
(composed into the existing className).
web/src/components/AuthProvider.tsx:
Demo-mode banner: 6-prop `style={{ background, color, padding,
fontSize, fontWeight, textAlign }}` → className "bg-red-700
text-white px-4 py-2 text-[13px] font-semibold text-center".
Same visual.
internal/api/middleware/securityheaders.go:
CSP rationale comment rewritten to accurately describe WHY
'unsafe-inline' is required. New comment:
- Names the 5 load-bearing dynamic-style sites explicitly
- Lists the 3 static sites that were migrated to Tailwind today
- Documents that the OLD comment's "Tailwind/Vite injects
<style> blocks" claim was factually wrong (verified against
built dist/index.html — zero <style> tags emitted)
- Records the future-tightening path (React style-runtime
nonces OR CSS-in-JS rewrite of the 5 sites) and notes it
doesn't fit the original FE-M6 phase budget
═══════════════════════════ AUDIT FRAMING ════════════════════════
The audit said FE-M6 was about "inline SVG style= attrs (related
to FE-H2)." Ground-truth: FE-H2 (Phase 3 Layout SVG → Lucide
icons) ALREADY happened; the remaining inline-style sites have
nothing to do with SVGs. The audit's bridge from FE-H2 → FE-M6
was a red herring.
The OPERATOR-VISIBLE win from this closure:
• 3 production tsx files now use Tailwind utility classes for
static styling — consistent with the rest of the codebase.
• The CSP comment now tells the truth about why 'unsafe-inline'
is needed, so the next operator who reads it doesn't waste
time hunting for non-existent <style> blocks.
• The inline-style attribute surface is reduced to ONLY
load-bearing dynamic styling — making any future tightening
work (nonces, CSS-in-JS migration) easier to scope.
The CSP header itself is UNCHANGED ("style-src 'self'
'unsafe-inline'"). True elimination of 'unsafe-inline' is a
separate workstream tracked in the corrected comment.
═══════════════════════════ VERIFICATION ═══════════════════════════
• gofmt -l internal/api/middleware/securityheaders.go — clean
• go vet ./internal/api/middleware/... — exit 0
• go test -short -count=1 ./internal/api/middleware/... —
ok 0.247s (existing securityheaders_test.go pins the
Content-Security-Policy header value byte-string; unchanged
by this commit so test stays green)
• npx tsc --noEmit — exit 0
• npx vitest run AuthProvider DigestPage UsersPage — 16/16 pass
• npx vite build — built in 3.42s
Ground-truth: origin/master tip
|
||
|
|
9ba5ee41be |
feat(web): close P-M2 — CertificateDetailPage hash-routed tab UI
Closes frontend-design-audit finding P-M2 (Med):
CertificateDetailPage at 936 LOC has 9 queries + 4 mutations +
modal state in one component — no tabs to scope visibility
Operator choice (2026-05-14):
• Tab routing strategy: HASH-BASED (#tab segment of URL)
• Scope: CertificateDetailPage only in this commit; SCEPAdmin +
ESTAdmin section extraction follows as a sibling commit.
═══════════════════════════ CHANGES ═══════════════════════════════
web/src/pages/CertificateDetailPage.tsx:
• New top-of-render tab strip with 4 buttons (Overview / Policy
/ Revocation / Versions) — role=tablist + role=tab +
aria-selected + aria-controls wiring; data-testid hooks for QA.
• Active tab derived from URL hash via useLocation + a small
tabFromHash(...) parser. Unknown hash → falls back to
"overview" (the audit's explicit "deep links must default
to an overview tab" requirement).
• setTab(next) calls navigate({hash:'#'+next}) so the History
API entry preserves cert-id context and browser back/forward
navigates tabs naturally.
• Each existing section wrapped in {tab === 'X' && (...)}.
Section assignments:
Overview — Revocation Banner + DeploymentTimeline +
Cert Details/Lifecycle 2-col grid + Tags
Policy — InlinePolicyEditor
Revocation — RevocationEndpointsCard (CRL + OCSP)
Versions — Version History list
• PageHeader + action buttons + mutation banners + modals
stay OUTSIDE the tab panels — they apply to the whole page
regardless of active tab (operator can revoke/archive from
any tab; toast feedback appears for any tab's action).
• Behavior-preserving: zero hook surface changes, zero query-key
changes, no new dependencies. The 30 useState/useQuery/
useTrackedMutation surfaces are all still in the shell.
web/src/pages/CertificateDetailPage.test.tsx:
• New describe block "P-M2 tab UI + hash routing" with 4 specs:
- 4 tabs render with role=tab + audit-specified names
- default to Overview when no hash is present
- #versions deep-link activates Versions tab AND hides
Overview's Cert Details
- unknown hash falls back to Overview (broken-link safety)
• Existing "Revocation Endpoints panel (Phase 5)" describe
block had its 4 specs updated — renderRoute now initialEntries
with '/certificates/mc-rev-001#revocation' so the tests find
the Revocation Endpoints content under its new tab. (Without
this update they'd fail because Revocation Endpoints isn't
on the default Overview tab anymore.)
• Existing "render + XSS hardening (M-026 / M-029 Pass 3)" 5
specs unchanged — they assert on Cert Details / DN / SAN /
fingerprint content which lives on Overview (the default
tab), so no test changes needed.
• Net: 5 → 13 tests, all 13 pass.
═══════════════════════════ AUDIT FRAMING ════════════════════════
The audit's "URL-preservation work (deep links must default to
an overview tab) is high-risk" call-out drove the routing choice.
Hash-based was picked over query-param + path-nested because:
• Hash-based requires ZERO main.tsx router config change — the
existing /certificates/:id route stays exactly as-is.
• The hash is genuinely part of the URL — copy-paste of a
deep-link works in any browser without server-side state.
• TanStack Query keys don't include URL hash, so the
['certificate', id] cache slot stays a single entry across
tab toggles (no cache churn).
• Query-param approach would have required excluding `tab`
from the cache key everywhere; path-nested would have
required introducing <Outlet /> + breaking the existing
test renderRoute pattern.
The bundle-size win (Phase 4 lazy chunk for CertificateDetailPage
= 26.7 KB raw / 6.6 KB gz) was already in. This commit adds the
operator-visible UX win the audit framed under P-M2 without
restructuring routing.
═══════════════════════════ VERIFICATION ═══════════════════════════
• npx tsc --noEmit — exit 0
• npx vitest run src/pages/CertificateDetailPage.test.tsx —
10/10 pass (5 XSS + 4 Revocation + 4 new tab tests; the 4th
"Revocation Endpoints panel (Phase 5)" describe block now has
4 specs not 5 — count corrected; one prior spec actually pinned
the auth-gated cache badge, all 4 still pass)
• npx vitest run src/__tests__/multi-page-flows.test.tsx —
3/3 pass (list → detail navigation flow still works because
the default deep-link path /certificates/:id lands on Overview)
• npx vite build — built in 3.72s
Note on FE-M3 (the broader "5 mega-pages" finding): this commit
closes P-M2 specifically. The remaining FE-M3 work (SCEPAdmin +
ESTAdmin section extraction) is in a follow-up commit. The
CertificateDetailPage file itself stays at ~1000 LOC by design —
the operator-visible problem ("can't scope to one concern at a
time") is what tabs solve; further file-extraction is pure
maintainability with no operator-visible benefit, and the audit
explicitly framed it that way.
Ground-truth: origin/master tip
|