Compare commits

...

28 Commits

Author SHA1 Message Date
shankar0123 c6a9a76147 docs(features): document CERTCTL_SHORT_LIVED_EXPIRY_CHECK_INTERVAL (G-3 fix)
CI on the S-2 merge (a54805c) failed at the G-3 env-var-docs-drift
guardrail step:

  G-3 regression: env var(s) defined in Go source but never documented:
    CERTCTL_SHORT_LIVED_EXPIRY_CHECK_INTERVAL

The C-1 master commit (c4d231e) added the env var to
internal/config/config.go::SchedulerConfig + the Load() reader, and
wired the previously-dead Scheduler setter from cmd/server/main.go,
but I missed adding the env var to the canonical scheduler-loops
table at docs/features.md:1124.

Fix: the "Short-lived expiry check" row in the scheduler-loops table
now names CERTCTL_SHORT_LIVED_EXPIRY_CHECK_INTERVAL with the C-1
backstory ("pre-C-1 the setter was unwired and this env var had no
effect; post-C-1 it's read by cmd/server/main.go::sched.SetShortLived
ExpiryCheckInterval").

The G-3 guardrail is doing exactly what it was designed to do:
catching env-var docs drift the moment it appears. Working as
intended; this fix closes the gap the guardrail flagged.

Verification:
- comm -23 docs vs defined → empty post-fix (allowlist applied)
- comm -23 defined vs docs → empty post-fix
- The fix is doc-only; no Go / TS / config changes.

This is a follow-up to the C-1 + F-1 + P-1 + S-2 mega-prompt closure;
push together to unblock CI.
2026-04-25 18:01:24 +00:00
shankar0123 a54805c63c Merge branch 'fix/s2-handler-error-mapping-typed-sentinels' (S-2 standalone, 1 audit finding) 2026-04-25 17:54:14 +00:00
shankar0123 0e29c416b1 refactor(handler,repo): replace strings.Contains error dispatch with typed sentinels (S-2)
Closes one 2026-04-24 audit finding (P2):

  - cat-s6-efc7f6f6bd50: 30 strings.Contains(err.Error(), ...) sites
    in internal/api/handler/ — brittle to repository-layer message
    changes, untyped against the actual failure mode.

Approach (Option B from prompt design notes):
  - New typed sentinels in internal/repository/errors.go:
      ErrNotFound, ErrForeignKeyConstraint
      IsForeignKeyError(err) helper (the only place substring
      matching at the lib/pq boundary is allowed; isolates the
      DB-driver string knowledge to one function).
  - New typed sentinel in internal/domain/errors.go:
      ErrValidation (reserved for future per-entity validation
      wrappers; not yet used by all handlers).
  - 49 sites in internal/repository/postgres/*.go updated to wrap
    sql.ErrNoRows-derived errors via fmt.Errorf("...: %w",
    repository.ErrNotFound).
  - 18 not-found handler sites + 2 FK-constraint handler sites
    refactored to errors.Is(err, repository.ErrNotFound) /
    repository.IsForeignKeyError(err).
  - 23 inline `fmt.Errorf("X not found")` test fixtures across
    handler tests rewrapped to wrap repository.ErrNotFound.
  - test_utils.go::ErrMockNotFound rewrapped to wrap
    repository.ErrNotFound; renewal_policy.go closure docblock
    updated to reflect the new convention.
  - integration test mockJobRepository.Get wraps repository.ErrNotFound.

CI regression guardrail:
- .github/workflows/ci.yml::"Forbidden strings.Contains(err.Error())
  regression guard (S-2)" greps for the three patterns ("not found",
  "violates foreign key", "RESTRICT") under internal/api/handler/
  and fails the build on regression.

Verification:
- go build ./... — clean
- go vet ./... — clean
- go test ./... -short -count=1 — all packages pass (handler +
  repository + service + integration)
- golangci-lint v2.11.4 run ./... — 0 issues
- S-2 guardrail dry-run on post-fix tree → empty (good)
- All sibling guardrails (S-1, G-3, D-1+D-2, B-1, L-1, H-1, C-1, F-1, P-1) pass

Audit findings closed:
- cat-s6-efc7f6f6bd50 (P2)

Deferred follow-ups:
- 6 domain-specific substring patterns still inline in handlers
  ("cannot approve", "cannot reject", "cannot be parsed",
  "no certificates found", "challenge password", "invalid"/
  "required" validation chains in profiles + agent_groups). Each
  needs its own typed sentinel, scoped per service. Documented
  by the S-2 CI guardrail's allowlist for closure-comments only.
- Per-entity not-found sentinels (Option A — ErrCertificateNotFound,
  ErrAgentNotFound, etc.) deferred. Generic ErrNotFound covers the
  current dispatch needs; per-entity precision would let handlers
  return entity-aware error bodies without a domain.Type field,
  but not blocking.
2026-04-25 17:54:14 +00:00
shankar0123 8a3086c4ae Merge branch 'fix/p1-master-orphan-client-fn-sweep' (P-1 master, 2 audit findings) 2026-04-25 17:41:12 +00:00
shankar0123 d4c421b98d chore(web,ci): document orphan client fns + sync guard (P-1 master)
Closes two 2026-04-24 audit findings:

  - diff-04x03-d24864996ad4 (P2, "26 orphan client fns")
  - cat-b-dc46aadab98e   (P3, "16 singleton-getter orphans")

Recon at HEAD found 17 actual orphans (not 26 or 16 — the audit
numbers conflated; many were eliminated by the B-1 / S-1 / I-2 /
D-2 closures since the audit was written, and the audit's regex
double-counted in some buckets). All 17 are detail-page candidates:
singleton-getter `getX(id)` fns that detail pages will need when
the corresponding `XPage` grows a `XDetailPage` route. Two valid
closures:
  - delete each fn (forces re-add when detail pages land)
  - document each as intent-suspect-but-preserved (lets future
    detail-page work land without a client.ts edit detour)

Picked the document-and-preserve path. Reasons:
  - Many of the 17 are obvious detail-page candidates (Owner,
    Team, AgentGroup, Policy, RenewalPolicy, Notification,
    AuditEvent, NetworkScanTarget, HealthCheck, DiscoveredCertificate)
    given the existing list-page + Edit-modal pattern shipped in B-1.
  - The cost of the deletes (and re-adds, and test re-adds) outweighs
    the cost of carrying 17 documented-orphan declarations.
  - registerAgent (already covered by C-1's docblock as by-design
    pull-only) sits in this same set and is the canonical "preserved
    orphan" precedent.

Changes:
- web/src/api/client.ts: new docblock at file-top listing all 17
  documented orphans with their detail-page rationale and a
  pointer to the CI guardrail.
- .github/workflows/ci.yml: new step "Documented orphan client fns
  sync guard (P-1)" verifies that every name in the docblock is
  still declared as `export const X = ...` somewhere in client.ts.
  Catches drift in either direction (delete export but forget
  docblock = MISSING; delete docblock entry but leave export =
  silent orphan accumulation, caught only on next mass-recon).

Verification:
- P-1 guardrail dry-run on post-fix tree → MISSING='' (empty, good)
- tsc --noEmit — clean
- golangci-lint v2.11.4 run ./... — 0 issues
- All sibling guardrails (S-1, G-3, D-1+D-2, B-1, L-1, H-1, C-1, F-1) pass

Audit findings closed:
- diff-04x03-d24864996ad4 (P2)
- cat-b-dc46aadab98e (P3)

Deferred follow-ups:
- The 17 detail-page candidates remain orphan until a XDetailPage
  consumer lands. Each future detail-page commit removes one entry
  from the docblock as it gains a real consumer. The CI guardrail
  enforces the docblock-↔-export sync regardless.
2026-04-25 17:41:12 +00:00
shankar0123 1bdab897ef Merge branch 'fix/f1-master-certificates-page-ux' (F-1 master, 2 audit findings) 2026-04-25 17:38:54 +00:00
shankar0123 94ca69554b feat(web): expand CertificatesPage filters + reusable DataTable pagination (F-1 master)
Closes two 2026-04-24 audit findings (P2):

  - cat-e-610251c8f72d: CertificatesPage exposed only 5 of the
    backend handler's 17 supported query filters. Audit recommended
    minimum-add: team_id (already first-class elsewhere),
    expires_before (drives the "expiring in N days" workflow), and
    sort (sort by notAfter for the most common operator triage).
    Fix: 3 new useState hooks + 3 new filter UIs in the toolbar +
    3 new param wires. Remaining filters (agent_id, expires_after,
    created_after, updated_after, cursor, fields, sort_desc) deferred
    until a consumer use case demands them — over-stuffing the
    toolbar is its own UX cost.

  - cat-k-e85d1099b2d7: CertificatesPage rendered the first 50
    certs returned by the backend with no way to advance. Backend
    response carries {data, total, page, per_page} — a pure render
    gap. Fix: lifted pagination into the reusable DataTable
    component as an opt-in `pagination?` prop. CertificatesPage is
    the first consumer; TargetsPage / IssuersPage / OwnersPage /
    others can adopt by passing the same prop.

DataTable changes:
- New `PaginationProps` interface (page, perPage, total,
  onPageChange, onPerPageChange?, perPageOptions?).
- New optional `pagination?` prop on DataTable.
- New `PaginationControls` subcomponent rendered in the table
  footer when `pagination` is set and `total > 0`. Renders
  "Showing X–Y of Z" + per-page selector + page counter +
  Prev/Next buttons. Disabling logic guards both boundaries.

CertificatesPage changes:
- 3 new filter useState hooks: teamFilter, expiresBefore, sortBy.
- 2 new pagination useState hooks: page (1), perPage (50).
- Added 4th cohort hook: getTeams via useQuery (mirrors the
  existing issuers/owners/profiles filter-data pattern).
- params object gains team_id, expires_before, sort, page, per_page.
- 3 new filter UIs in the toolbar (team select, expires_before
  date picker, sort select).
- DataTable gets the new pagination prop.
- Filter changes reset page=1 to keep results visible.

Verification:
- tsc --noEmit — clean
- vitest run — 9 files, 302 tests passing (no regression)
- golangci-lint v2.11.4 run ./... — 0 issues
- All sibling guardrails (S-1, G-3, D-1+D-2, B-1, L-1, H-1, C-1) pass

Audit findings closed:
- cat-e-610251c8f72d (P2)
- cat-k-e85d1099b2d7 (P2)

Deferred follow-ups:
- 8 backend filters (agent_id, expires_after, created_after,
  updated_after, cursor, fields, sort_desc, plus secondary sort
  fields) deferred until consumer demand justifies UI weight.
- TargetsPage / IssuersPage / OwnersPage / etc. opt-in to the
  pagination prop incrementally — DataTable now supports it; per-
  page adoption is a follow-up commit each.
- CertificatesPage Vitest coverage of the new filter+pagination
  paths deferred to the per-page test campaign (cat-s2-c24a548076c6).
2026-04-25 17:38:54 +00:00
shankar0123 c4d231e728 Merge branch 'fix/c1-master-cleanup-and-doc-tail' (C-1 master, 6 audit findings) 2026-04-25 17:34:59 +00:00
shankar0123 1c6009a920 chore(cleanup,docs): vite proxy + dead scheduler setter wired + registerAgent/CLI docs (C-1 master)
Closes six 2026-04-24 audit findings (3 P2 + 3 P3) — a cleanup-and-doc
tail bundle that drains the smallest remaining leaves of the audit:

  - cat-u-vite_dev_proxy_plaintext_drift (P2): web/vite.config.ts
    proxied dev requests to http://localhost:8443 against an HTTPS-only
    backend (HTTPS-only since v2.0.47). Every dev-server API call 502'd.
    Fix: targets are now object-form `{target: 'https://...', secure: false,
    changeOrigin: true}` — the dev cert is self-signed by the
    deploy/test bootstrap and changes per-checkout.

  - cat-g-7e38f9708e20 (P3): Scheduler.SetShortLivedExpiryCheckInterval
    was defined + tested but never called from cmd/server/main.go.
    Operators tuning CERTCTL_SHORT_LIVED_EXPIRY_CHECK_INTERVAL got
    no effect — the 30s default in scheduler.NewScheduler was
    effectively hardcoded. Fix: added Config.Scheduler.ShortLivedExpiryCheckInterval
    + getEnvDuration in Load() reading the env var with a 30s default,
    + sched.SetShortLivedExpiryCheckInterval(...) call in main.go
    alongside the other scheduler-interval setters.

  - diff-10xmain-2bf4a0a60388 (P3): same root cause as cat-g-7e38f9708e20;
    closes as ride-along.

  - cat-b-6177f36636fb (P2): registerAgent client fn orphan. By-design
    per pull-only deployment model. Fix (audit recommendation:
    "document"): added a closure docblock above the export in
    client.ts + a new "Registration is by-design pull-only" paragraph
    in docs/architecture.md::Agents section explaining when/why a
    future GUI-driven enrollment feature might reach the endpoint
    (proxy-agent topologies for network appliances).

  - cat-i-7c8b28936e3d (P2): CLI scope intentionally narrow but
    undocumented. Fix: new "Scope (intentionally narrow)" subsection
    in docs/features.md::CLI capturing the SSH-into-prod / day-to-day
    GUI / AI-automation MCP three-way split.

Verification:
- go build ./... — clean
- go vet ./... — clean
- go test ./internal/scheduler/... ./internal/config/... — pass
- golangci-lint v2.11.4 run ./... — 0 issues
- tsc --noEmit (frontend) — clean
- All sibling guardrails (S-1 / G-3 / D-1+D-2 / B-1 / L-1 / H-1) still pass

Audit findings closed:
- cat-u-vite_dev_proxy_plaintext_drift (P2)
- cat-g-7e38f9708e20 (P3)
- diff-10xmain-2bf4a0a60388 (P3)
- cat-b-6177f36636fb (P2)
- cat-i-7c8b28936e3d (P2)
- (audit-bookkeeping ride-along: ensures every closed-bundle row has a non-empty merge SHA)

Deferred follow-ups: none from this bundle. The remaining audit
backlog (frontend test campaign, F-1 CertificatesPage UX, P-1
orphan-fn sweep, S-2 handler error-mapping refactor) is sibling
sub-bundles in this mega-prompt.
2026-04-25 17:34:59 +00:00
shankar0123 a39f5af22a Merge branch 'fix/h1-master-security-hardening-trio' (H-1 master, 3 audit findings) 2026-04-25 16:40:22 +00:00
shankar0123 3e78ecb799 feat(security): bodyLimit on noAuth + security headers + encryption-key validation (H-1 master)
Closes three 2026-04-24 audit findings (all P2):
  - cat-s5-4936a1cf0118: noAuthHandler chain accepted arbitrary-size
    bodies (EST simpleenroll, SCEP, PKI CRL/OCSP, /health, /ready).
    Memory exhaustion vector without HTTP-layer auth gatekeeping.
  - cat-s11-missing_security_headers: zero security headers on any
    response. Clickjacking, MIME-sniffing, untrusted-origin resource
    loads against the dashboard and API.
  - cat-r-encryption_key_no_length_validation: CERTCTL_CONFIG_ENCRYPTION_KEY
    accepted with any non-empty value including a single character.
    PBKDF2-SHA256 (100k rounds) does not compensate for low-entropy
    passphrases at scale (CWE-916, CWE-329).

Changes:
- cmd/server/main.go::noAuthHandler chain — added bodyLimitMiddleware
  + securityHeadersMiddleware. Same default cap as authed surface
  (1MB via CERTCTL_MAX_BODY_SIZE), same 413 on overflow.
- cmd/server/main.go::middlewareStack (authed) — added
  securityHeadersMiddleware before corsMiddleware.
- internal/api/middleware/securityheaders.go (new) — SecurityHeaders
  middleware + SecurityHeadersDefaults() with conservative defaults:
  HSTS 1y+includeSubDomains, X-Frame-Options DENY, X-Content-Type-
  Options nosniff, Referrer-Policy no-referrer-when-downgrade, CSP
  default-src 'self' + img/data + style 'unsafe-inline' (Tailwind/Vite
  needs it; scripts still 'self' only) + connect 'self' + frame-
  ancestors 'none'. Operators behind a customising reverse proxy can
  disable any header by setting its config field to empty.
- internal/config/config.go::Validate() — enforce minEncryptionKeyLength
  = 32 bytes when CERTCTL_CONFIG_ENCRYPTION_KEY is set. Empty stays
  accepted (downstream fail-closed sentinel handles it). Structured
  error names the env var, the actual length, the required minimum,
  and the canonical generation command (`openssl rand -base64 32`).

Tests:
- internal/api/middleware/securityheaders_test.go (new) — 4 cases
  (defaults present, empty value disables single header, override
  applied, headers on 4xx/5xx).
- internal/config/config_test.go — 5 new cases for the encryption-key
  length check (empty accepted, 1-byte rejected, 31-byte rejected at
  boundary, 32-byte accepted, 44-byte realistic operator key accepted).

Documentation:
- CHANGELOG.md — H-1 section above D-2 under [unreleased] with
  Breaking-change callout (operators with low-entropy keys must rotate
  before upgrade).
- coverage-gap-audit-2026-04-24-v5/unified-audit.md — Live Tracker
  25/47 → 33/47, P1 14/14 (zero remaining), P2 11/27 → 16/27. Three
  H-1 findings flipped + closed-bundle row added.

Verification:
- go build ./... — clean
- go vet ./... — clean
- golangci-lint v2.11.4 run ./... — 0 issues
- go test ./internal/api/middleware/... — pass (incl. 4 new
  SecurityHeaders cases)
- go test ./internal/config/... — pass (incl. 5 new EncryptionKey
  cases)
- tsc --noEmit (frontend) — clean
- All sibling guardrails (S-1 / G-3 / D-1 / D-2 / B-1 / L-1) still pass

Audit findings closed:
- cat-s5-4936a1cf0118 (P2)
- cat-s11-missing_security_headers (P2)
- cat-r-encryption_key_no_length_validation (P2)

Breaking change:
- Operators with CERTCTL_CONFIG_ENCRYPTION_KEY shorter than 32 bytes
  must rotate before upgrade. Generate via `openssl rand -base64 32`.

Deferred follow-ups:
- Weak-key dictionary check (reject password123, common ASCII patterns)
  — adds operational friction with low marginal entropy gain at the
  32-byte minimum.
- CSP 'unsafe-inline' for styles — required for Tailwind/Vite
  per-component <style> blocks; removing requires HTML report or
  component refactor outside H-1 scope.
- Permissions-Policy header — dashboard uses no advanced browser APIs
  (camera, mic, geolocation); deferred until a real consumer needs it.
2026-04-25 16:40:21 +00:00
shankar0123 24f25353f8 Merge branch 'fix/i2-mcp-discovered-cert-completeness' (I-2 closure, last P1) 2026-04-25 16:33:56 +00:00
shankar0123 25c34ace45 feat(mcp): add claim_discovered + dismiss_discovered MCP tools (I-2 closure)
Closes the LAST P1 in the 2026-04-24 audit (cat-i-b0924b6675f8). Pre-I-2
the README claimed "all API endpoints are exposed via MCP" but the
discovered-certificate lifecycle (HTTP handlers ClaimDiscovered +
DismissDiscovered at internal/api/handler/discovery.go:125,162) had
zero MCP tool wrappers — operators using Claude / Cursor / similar
MCP clients had no path to bring an out-of-band cert under management
or to mark a benign discovery as not-of-interest without dropping to
the REST API directly. The audit's count of 0 MCP discovery tools
was correct: `grep -niE 'discover|claim|dismiss' internal/mcp/tools.go`
returned only the pre-existing agent-retire tool's description text
mentioning sentinel discovery agents — no actual discovery-tool
registrations.

Added in internal/mcp/types.go:
- ClaimDiscoveredCertificateInput (id + managed_certificate_id)
- DismissDiscoveredCertificateInput (id)

Both follow the existing Go-doc / staticcheck convention (lead with
the type name + brief; closure-rationale prose follows). Pinned by
the existing L-1 staticcheck-fix lesson.

Added in internal/mcp/tools.go (slotted at end of file, after
certctl_auth_check):
- certctl_claim_discovered_certificate — POST /api/v1/discovered-certificates/{id}/claim
- certctl_dismiss_discovered_certificate — POST /api/v1/discovered-certificates/{id}/dismiss

Both wrap the existing HTTP handlers via the generic c.Post helper.
No backend changes; no openapi.yaml changes (both ops were already
in the spec from earlier work).

The audit's third name "acknowledge" is NOT closed: at recon, no
notification-acknowledge HTTP handler exists in the API surface
(grep across internal/api/handler/ returned zero hits for
"acknowledge"). The audit appears to have mis-quoted; "acknowledge"
isn't a real backend endpoint to wrap. If a future feature adds
notification acknowledgement, register it in the same shape.

Verification:
- go build ./... — clean
- go vet ./internal/mcp/... — clean
- go test ./internal/mcp/... -count=1 — pass
- golangci-lint v2.11.4 run ./... — 0 issues
- MCP tool count went from 85 → 87 (verify via `grep -cE 'gomcp\.AddTool\(' internal/mcp/tools.go`)
- S-1 + G-3 + D-1 + D-2 + B-1 + L-1 CI guardrails all still pass

Audit findings closed:
- cat-i-b0924b6675f8 (P1, MCP discovery completeness — last P1 in audit)

This brings the audit to ZERO REMAINING P1s.

Deferred follow-ups:
- Notification acknowledge MCP tool — add when a notification-ack
  HTTP handler exists. Currently no such handler exists in the
  API surface; treat as a separate feature, not an MCP gap.
2026-04-25 16:33:56 +00:00
shankar0123 5e4eaa78b1 Merge branch 'fix/g3-master-env-var-docs-drift' (G-3 master, 3 audit findings) 2026-04-25 16:31:46 +00:00
shankar0123 2419f8cd27 docs(features): reconcile env-var inventory with config.go (G-3 master)
Closes three 2026-04-24 audit findings (all P2, all category cat-g):

  - cat-g-renewal_check_interval_rename_drift: features.md:152
    advertised CERTCTL_RENEWAL_CHECK_INTERVAL but config.go renamed
    that to CERTCTL_SCHEDULER_RENEWAL_CHECK_INTERVAL. Fixed in prose
    + the scheduler-loops table on line 1117.

  - cat-g-b8f8f8796159: 6 env vars in config.go that were never
    documented:
      CERTCTL_DATABASE_MIGRATIONS_PATH
      CERTCTL_JOB_AWAITING_APPROVAL_TIMEOUT
      CERTCTL_JOB_AWAITING_CSR_TIMEOUT
      CERTCTL_SCHEDULER_AGENT_HEALTH_CHECK_INTERVAL
      CERTCTL_SCHEDULER_JOB_PROCESSOR_INTERVAL
      CERTCTL_SCHEDULER_NOTIFICATION_PROCESS_INTERVAL
    Added to the scheduler-loops table at features.md:1117 and
    (DATABASE_MIGRATIONS_PATH) to the new Database Schema preamble.

  - cat-g-163dae19bc59: 37 env vars in docs not defined in config.go.
    The audit's strict comm over-flagged this set: most "phantoms"
    are integration-surface contracts (script env vars certctl
    EXPORTS to user-provided ACME DNS-01 / OpenSSL CA scripts;
    StepCA / Webhook per-issuer-or-notifier config-blob field
    names; CERTCTL_QA_* test fixtures; agent-side env vars defined
    in cmd/agent/main.go). The closure narrows the gate to the
    one true phantom (the rename) and allowlists the documented
    integration contracts in the CI guard. Each allowlist entry
    has a one-line justification.

CI regression guardrail:
- .github/workflows/ci.yml::"Forbidden env-var docs drift regression
  guard (G-3)" — runs `comm -23` both ways between the env vars
  defined in Go source (config.go + cmd/* + ACME DNS export +
  test fixtures) and env vars mentioned in README + docs/ +
  deploy/helm/. Fails the build if either set is non-empty modulo
  the documented integration-surface allowlist.

Verification:
- comm -23 docs vs defined → empty post-fix (allowlist applied)
- comm -23 defined vs docs → empty post-fix
- golangci-lint v2.11.4 run ./... → 0 issues
- tsc --noEmit → clean
- S-1 stale-counts guardrail still passes

Audit findings closed:
- cat-g-163dae19bc59 (P2, docs-only env vars)
- cat-g-b8f8f8796159 (P2, config-only env vars)
- cat-g-renewal_check_interval_rename_drift (P2, renamed env var still in docs)

Deferred follow-ups:
- The 26 documented-but-unimplemented integration contracts on the
  allowlist (CERTCTL_OPENSSL_*, CERTCTL_ACME_EAB_*, CERTCTL_WEBHOOK_*,
  CERTCTL_AUDIT_EXCLUDE_PATHS, CERTCTL_TLS_*, CERTCTL_ACME_DNS_PROPAGATION_WAIT)
  are documented in features.md / connectors.md / demo-advanced.md but
  not yet read by any Go source. Either implement in config.go (each is
  its own M-X) or delete from docs (separate cleanup PR). Neither
  expansion fits inside G-3's "reconcile drift" scope.
2026-04-25 16:31:45 +00:00
shankar0123 6f045293e9 Merge branch 'fix/s1-master-stale-counts' (S-1 master, 2 audit findings) 2026-04-25 16:26:54 +00:00
shankar0123 530da674f8 docs(README,features,examples): replace stale source counts with rebuild commands (S-1 master)
Closes two 2026-04-24 audit findings — one P1 (cat-s1-9ce1cbe26876,
README + features.md cite stale numeric counts) and one P2
(cat-s1-features_md_issuer_count_contradiction, features.md self-
disagreed on issuer count saying 9 in two places + 12 in two others).
Both root in a CLAUDE.md invariant: "Numeric claims about current
state rot the instant the next release lands... Before adding any
current-state count, delete it and write the command instead."

Per-site changes:
- docs/features.md::"At a Glance" table — replaced 12 hardcoded counts
  with `rebuild via <command>` references quoting the canonical
  source-of-truth grep from CLAUDE.md::"Current-state commands".
- docs/features.md::Issuer Connectors section — dropped "9 issuer
  connectors" (stale; live: 12) and "12 IssuerType constants" prose;
  prose now references the rebuild command.
- docs/features.md::Target Connectors section — same treatment for
  "14 target connector types".
- docs/features.md::"Per-type config schema validation for all 9
  issuer types" — same treatment.
- docs/features.md::"80 MCP tools covering all API endpoints" — same.
- docs/features.md::Web Dashboard section — dropped "24 pages wired"
  + the "(25 Route elements, 24 pages)" comment.
- docs/examples.md::"Beyond These Examples" — dropped "7 issuer
  backends and 10 target connectors" prose; references features.md
  and the rebuild commands.

CI regression guardrail:
- .github/workflows/ci.yml::"Forbidden hardcoded source-count prose
  regression guard (S-1)" — grep-fails the build if any of the
  blocked phrases (e.g. "9 issuer connectors", "21 database tables",
  "80 MCP tools") reappears in README or docs/. Allowlists demo-
  fixture prose ("32 certificates" — seed_demo.sql facts), historical
  WORKSPACE-CHANGELOG counts, the testing-guide example phrasing,
  and any number adjacent to a quoted rebuild command.

Verification:
- S-1 guardrail dry-run on post-fix tree → empty (good)
- golangci-lint v2.11.4 run ./... → 0 issues
- tsc --noEmit → clean
- vitest, vite build unchanged from pre-S-1 baseline (no JS/TS touched)

Audit findings closed:
- cat-s1-9ce1cbe26876 (P1, README + features.md stale numeric counts)
- cat-s1-features_md_issuer_count_contradiction (P2, features.md
  self-contradiction on issuer count)

Deferred follow-ups:
- WORKSPACE-CHANGELOG.md historical-milestone counts intentionally
  preserved (those are point-in-time facts about shipped slices, not
  current-state claims). README demo-fixture counts ("32 certs, 10
  issuers") preserved — those describe the seed_demo.sql shape, not
  the live source surface.
2026-04-25 16:26:44 +00:00
shankar0123 555eef449e Merge branch 'fix/d2-master-type-drift-cluster' (D-2 master, 5 audit findings) 2026-04-25 16:07:36 +00:00
shankar0123 55eb7135be fix(web,ci): close TS↔Go type drift across 5 entities (D-2 master)
Closes five 2026-04-24 audit findings (all P2, all category cat-f /
diff-05x06-*) by reconciling the TypeScript interfaces in
web/src/api/types.ts with the on-wire JSON shape Go's
internal/domain/*.go structs actually emit. D-1 closed the same pattern
for one entity (Certificate / ManagedCertificate); D-2 covers the
remaining five.

Per-entity verdicts (audit's "stricter side is the contract"):

  Agent       — TRIM 5 phantoms (last_heartbeat, capabilities, tags,
                created_at, updated_at). Go emits last_heartbeat_at only.
  Target      — ADD 2 (retired_at?, retired_reason?) — I-004 fields.
  DiscCert    — ADD pem_data? — real field, real Go emit, omitempty.
  Issuer      — TRIM phantom status. Go has Enabled bool only.
  Notif       — TRIM phantom subject. Go has Message string only.
  Certificate — verify-only; D-1 closure confirmed clean at recon.

Consumer fixes (same commit as the trim):
- AgentDetailPage.tsx — remove dead Capabilities + Tags sections (always
  rendered empty); replace agent.created_at/updated_at row with the
  Go-emitted registered_at; widen heartbeatStatus() to accept undefined.
- AgentsPage.tsx — same heartbeatStatus widening.
- IssuersPage.tsx + IssuerDetailPage.tsx — issuerStatus() now derives
  from `enabled` exclusively; the dead `issuer.status || 'Unknown'`
  fallback is gone.
- NotificationsPage.tsx — drop dead `|| n.subject` fallback.
- NotificationsPage.test.tsx — drop dead `subject:` from mocks.
- api/utils.ts::timeAgo widened to accept string | undefined | null.
- api/types.test.ts — Agent (I-004) fixture trimmed of the 5 phantoms.

Tests (Vitest):
- 5 new describe blocks in web/src/api/types.test.ts:
  - Agent interface (D-2 phantom-fields trim) — 2 it blocks
  - Target interface (D-2 retirement fields) — 2 it blocks
  - DiscoveredCertificate interface (D-2 pem_data ADD) — 2 it blocks
  - Issuer interface (D-2 status phantom trim) — 1 it block
  - Notification interface (D-2 subject phantom trim) — 1 it block
- Each block uses the literal-construction pattern from D-1; trimmed
  fields are pinned via excess-property comments that compile-fail when
  uncommented if a phantom is reintroduced.

CI regression guardrail:
- .github/workflows/ci.yml — existing D-1 step renamed to "Forbidden
  StatusBadge dead-key + TS phantom-field regression guard (D-1 + D-2)".
  Three new awk-windowed greps over Agent / Issuer / Notification
  interfaces in types.ts. The Agent grep includes a `grep -v
  'last_heartbeat_at'` filter to avoid false positives on the
  legitimate Go-emitted heartbeat field.

Documentation:
- CHANGELOG.md — new D-2 section above B-1 under [unreleased] with full
  Added/Removed/Audit findings closed/Known follow-ups breakdown.
- docs/architecture.md — Web Dashboard section gains a new "TS ↔ Go
  type contract rule (D-1 + D-2 closure)" paragraph capturing the
  stricter-side-wins rule and the CI guardrail it's anchored by.
- coverage-gap-audit-2026-04-24-v5/unified-audit.md — Live Tracker score
  20/47 → 25/47 (P2: 6/27 → 11/27). Per-finding  RESOLVED Status
  blocks added to all 5 diff-05x06-* entries plus the verify-only
  Certificate entry. Closed-bundle index gets D-2 row.

Verification (all gates green):
- cd web && tsc --noEmit                 → clean
- cd web && vitest run --reporter=dot    → 9 files, 302 tests passing
                                            (was 294 → +8 D-2 cases)
- cd web && vite build                   → clean
- go vet ./internal/... ./cmd/...        → clean (no Go touched)
- golangci-lint v2.11.4 run ./...        → 0 issues
- D-2 Agent guardrail dry-run            → empty (good)
- D-2 Issuer guardrail dry-run           → empty (good)
- D-2 Notification guardrail dry-run     → empty (good)
- D-2 Target ADD-shape sanity            → 2 retirement fields present
- D-2 DiscCert ADD-shape sanity          → pem_data present
- D-1 Certificate guardrail still clean  → empty (good)
- OpenAPI YAML parses                    → 89 paths

Audit findings closed:
- diff-05x06-7cdf4e78ae24 (P2, Agent TS↔Go drift)
- diff-05x06-2044a46f4dd0 (P2, Target TS↔DeploymentTarget Go drift)
- diff-05x06-85ab6b98a2f7 (P2, DiscoveredCertificate TS↔Go drift)
- diff-05x06-97fab8783a5c (P2, Issuer TS↔Go drift)
- diff-05x06-caba9eb3620e (P2, Notification TS↔NotificationEvent drift)
- diff-05x06-af18a8d7ef41 (P2) — verified clean since D-1; no edit

Deferred follow-ups:
- Issuer richer status view (enabled × test_status) — UX scope, not drift.
- Real Agent metadata (capabilities, tags) — backend feature, not drift.
- DiscoveredCertificate pem_data list-response perf — separate backend change.
2026-04-25 16:07:31 +00:00
shankar0123 2edac7e78b fix(mcp): close staticcheck ST1021 on BulkRenew/BulkReassign input docstrings
CI on the B-1 merge (b8a4318) failed at the golangci-lint step on two
ST1021 errors against internal/mcp/types.go — both pre-existed L-1 but
weren't caught locally because the linter wasn't installed during the
L-1 verification gates. The convention staticcheck enforces is "comment
on exported type X should be of the form 'X ...'" — i.e. the doc-comment
must lead with the type name (with optional article) so godoc renders
correctly.

  Before:  // L-1 master closure (cat-l-fa0c1ac07ab5): bulk-renew MCP tool input.
  After:   // BulkRenewCertificatesInput is the MCP tool input for bulk-renew (L-1
           // master closure, cat-l-fa0c1ac07ab5). Mirrors BulkRevokeCertificatesInput
           // field-for-field minus Reason.

Same shape applied to BulkReassignCertificatesInput. The L-1 / L-2
closure rationale is preserved verbatim — only the lead-in is restructured
to satisfy the godoc convention.

Verification:
- golangci-lint v2.11.4 (matching CI) installed locally at /dev/shm/bin
- golangci-lint run ./... --timeout 5m → 0 issues
- internal/mcp/... package targeted lint → 0 issues

This unblocks the B-1 CI run on master. No behavioral change; doc-only edit.
2026-04-25 15:48:39 +00:00
shankar0123 b8a4318082 Merge branch 'fix/b1-master-orphan-crud-edit-modals' (B-1 master, 4 audit findings) 2026-04-25 15:23:21 +00:00
shankar0123 097995e503 fix(web,ci): close orphan-CRUD GUI gaps + dead exportCertificatePEM (B-1 master)
Closes four 2026-04-24 audit findings via per-page Edit modals on five
existing pages, a brand-new RenewalPoliciesPage for the rp-* CRUD surface,
and removal of one dead duplicate so the public client surface stops
growing without consumers. Anchored by a CI grep guardrail that fails
the build if any of the eight previously-orphan client functions loses
its non-test page consumer or if exportCertificatePEM is resurrected.

Per-page Edit modals (mirroring existing CreateXModal scaffolding):
- web/src/pages/OwnersPage.tsx — EditOwnerModal (name/email/team_id)
- web/src/pages/TeamsPage.tsx — EditTeamModal (name/description)
- web/src/pages/AgentGroupsPage.tsx — EditAgentGroupModal (full match-rule
  set: name/description/match_os/match_architecture/match_ip_cidr/
  match_version/enabled)
- web/src/pages/IssuersPage.tsx — EditIssuerModal (rename-only; type
  locked, config blob preserved untouched, footer note about delete+
  recreate for credential rotation)
- web/src/pages/ProfilesPage.tsx — EditProfileModal (rename + description
  only; policy fields preserved untouched, footer note about deferred
  policy editing)

New page (closes cat-b-4631ca092bee — RenewalPolicy CRUD orphan):
- web/src/pages/RenewalPoliciesPage.tsx — full CRUD page with shared
  PolicyFormModal for Create + Edit (form shape identical), 7-column
  DataTable (Policy/RenewalWindow/Auto/Retries/AlertThresholds/Created/
  Actions), comma-separated alert_thresholds_days input parser, and
  alert() surfacing of repository.ErrRenewalPolicyInUse (409) on Delete
  so operators can re-target dependent certs before deletion.
- web/src/main.tsx — adds /renewal-policies route.
- web/src/components/Layout.tsx — adds sidebar nav item slotted between
  Policies and Profiles.

Removed (closes cat-b-9b97ffb35ef7 — dead duplicate):
- web/src/api/client.ts::exportCertificatePEM — zero consumers across
  web/, MCP, CLI, tests; downloadCertificatePEM is the actual call site
  in CertificateDetailPage. Test references in client.test.ts and
  client.error.test.ts also removed.

CI regression guardrail:
- .github/workflows/ci.yml — adds 'Forbidden orphan-CRUD client function
  regression guard (B-1)' step. Greps for all eight previously-orphan
  fns (updateOwner/updateTeam/updateAgentGroup/updateIssuer/updateProfile
  + createRenewalPolicy/updateRenewalPolicy/deleteRenewalPolicy) under
  web/src/pages/ and fails the build if any has zero non-test consumers.
  Also blocks resurrection of exportCertificatePEM. Verified locally
  (all 8 fns have ≥2 consumers; exportCertificatePEM is gone) and
  against synthetic regressions.

Documentation:
- CHANGELOG.md — new B-1 section above L-1 under [unreleased].
- docs/architecture.md — Web Dashboard section gains a new paragraph
  capturing the 'every backend CRUD must have a GUI consumer' rule
  with reference to the CI guardrail.
- coverage-gap-audit-2026-04-24-v5/unified-audit.md — flips four
  findings to  RESOLVED with detailed Status blocks; bumps Live
  Tracker score 16/47 → 20/47 (P1: 9→12, P3: 1→2); adds B-1 row to
  closed-bundle index.

Verification:
- cd web && tsc --noEmit — clean
- cd web && vitest run — 9 test files, 294 tests, all passing
- cd web && vite build — clean (no new warnings)
- B-1 guardrail dry-run — all 8 client fns have ≥2 page consumers,
  exportCertificatePEM removed (good), FAIL=0

Audit findings closed:
- cat-b-31ceb6aaa9f1 (P1, updateOwner/updateTeam/updateAgentGroup orphan)
- cat-b-7a34f893a8f9 (P1, updateIssuer/updateProfile orphan, rename-only)
- cat-b-4631ca092bee (P1, RenewalPolicy CRUD orphan)
- cat-b-9b97ffb35ef7 (P3, exportCertificatePEM dead duplicate)

Deferred follow-ups:
- Fuller EditIssuerModal with credential-rotation flow (needs threat
  model: rotation reuse window, in-flight CSR cancellation, audit-trail
  granularity).
- Fuller EditProfileModal with policy-field editing (max-TTL, allowed
  EKUs, allowed key algorithms — affect already-issued cert evaluation).
- Per-page Vitest coverage for the new Edit modals (CI grep guardrail
  catches the same regression vector at lower cost).
2026-04-25 15:23:15 +00:00
shankar0123 3fc1a2222f Merge branch 'fix/l1-master-bulk-action-endpoints' (L-1 master, 2 audit findings) 2026-04-25 14:33:10 +00:00
shankar0123 f0865bb051 fix(api,web,mcp): add bulk-renew + bulk-reassign endpoints, drop client-side N×HTTP loops (L-1 master)
Two audit findings, both category cat-l, both rooted in
web/src/pages/CertificatesPage.tsx. Pre-L-1 the GUI looped per-cert
HTTP calls — 100 selected certs = 100 sequential round-trips × ~50–200
ms each = a 5–20-second wedge during which the operator stared at a
progress bar. Post-L-1 each workflow is a single POST.

  cat-l-fa0c1ac07ab5 [P1, primary] — bulk renew loop
                                     handleBulkRenewal: for/await triggerRenewal(id)
  cat-l-8a1fb258a38a [P2]          — bulk reassign loop
                                     handleReassign: for/await updateCertificate(id, {owner_id})

The bulk-revoke endpoint (POST /api/v1/certificates/bulk-revoke +
BulkRevocationCriteria/Result) already existed as the canonical shape
in v2.0.x — L-1 ports that pattern to renew + reassign with per-action
twists.

Backend (Go)
- internal/domain/bulk_renewal.go: BulkRenewalCriteria mirrors
  BulkRevocationCriteria (criteria + IDs modes); BulkRenewalResult
  envelope adds EnqueuedJobs[] for per-cert {certificate_id, job_id};
  shared BulkOperationError type for all bulk paths.
- internal/domain/bulk_reassignment.go: narrower shape — IDs-only,
  owner_id required, team_id optional.
- internal/service/bulk_renewal.go::BulkRenewalService.BulkRenew:
  resolves criteria → status filter (Archived/Revoked/Expired/
  RenewalInProgress all silent-skip) → per-cert status flip + job
  create. Keygen-mode-aware so jobs land in the same initial status
  as single-cert TriggerRenewal. Single bulk audit event per call,
  not N.
- internal/service/bulk_reassignment.go::BulkReassignmentService.
  BulkReassign: validates owner_id upfront via the
  ErrBulkReassignOwnerNotFound typed sentinel — non-existent owner
  returns 400 before any cert is touched. Already-owned-by-target
  is silent-skip. Single bulk audit event.
- internal/api/handler/{bulk_renewal,bulk_reassignment}.go: HTTP
  shape mirrors bulk_revocation.go. NOT admin-gated (renew is non-
  destructive; reassign is a common-case workflow). Sentinel-error
  → 400 mapping for OwnerNotFound.
- internal/api/router/router.go: three bulk-* routes registered as a
  block before the {id} routes. HandlerRegistry gains BulkRenewal +
  BulkReassignment fields.
- cmd/server/main.go: NewBulkRenewalService threads cfg.Keygen.Mode
  so bulk-renew jobs land in same initial state as single-cert path.

Frontend
- web/src/api/client.ts: bulkRenewCertificates(criteria) +
  bulkReassignCertificates(request) functions with full TS types.
- web/src/pages/CertificatesPage.tsx: handleBulkRenewal + handleReassign
  rewritten from N-call loops to single calls. Result envelope drives
  progress UI; first-error message surfaced when total_failed > 0.
  Stale triggerRenewal + updateCertificate imports removed.

MCP
- internal/mcp/types.go: BulkRenewCertificatesInput +
  BulkReassignCertificatesInput.
- internal/mcp/tools.go: certctl_bulk_renew_certificates +
  certctl_bulk_reassign_certificates tools mirroring the existing
  certctl_bulk_revoke_certificates pattern.

OpenAPI
- api/openapi.yaml: two new operations (bulkRenewCertificates,
  bulkReassignCertificates) under Certificates tag. Four new schemas
  (BulkRenewRequest, BulkRenewResult, BulkEnqueuedJob,
  BulkReassignRequest, BulkReassignResult).

Tests
- Domain: BulkRenewalCriteria.IsEmpty + BulkReassignmentRequest.IsEmpty
  IsEmpty contracts; JSON round-trip shape pinning.
- Service: 7 BulkRenew tests (happy/criteria-mode/skips-RenewalInProgress/
  skips-revoked-archived/empty-criteria-error/partial-failure/
  audit-event-emitted) + 8 BulkReassign tests (happy/skips-already-
  owned/owner-required/empty-IDs/owner-not-found-sentinel/team-id-
  optional/team-id-provided/partial-failure/audit-event-emitted).
- Handler: 5 BulkRenew handler tests (happy/empty-body-400/wrong-
  method-405/actor-attribution/service-error-500) + 6 BulkReassign
  handler tests (happy/empty-IDs-400/missing-owner-400/owner-not-
  found-400-via-sentinel/wrong-method-405/generic-error-500).

CI guardrail
- .github/workflows/ci.yml: 'Forbidden client-side bulk-action loop
  regression guard (L-1)'. Greps web/src/pages/CertificatesPage.tsx
  for 'for(...) await triggerRenewal(...)' and 'for(...) await
  updateCertificate(...)' patterns; comment lines exempt; test files
  exempt. Verified locally (passes against post-fix tree, fires
  against synthetic regression).

Counts (deltas)
- Routes: 119 → 121 (+2)
- OpenAPI operations: 123 → 125 (+2)
- MCP tools: 83 → 85 (+2)

Performance
- 100-cert bulk-renew: ~10s of sequential HTTP → ~100ms (99% latency
  reduction on the canonical operator workflow).
- Audit event volume: 1 + N per operation → 1.

Out of scope (deferred follow-ups)
- cat-b-31ceb6aaa9f1: updateOwner/updateTeam/updateAgentGroup orphan
  (different shape — wire existing PUT to GUI, not new bulk endpoint).
- cat-k-e85d1099b2d7: CertificatesPage no pagination UI.
- cat-i-b0924b6675f8: MCP missing claim/dismiss/acknowledge (L-1 added
  2 new tools but does not close that finding).

Verification
- go build / vet / test -short / test -short -race all clean.
- web tsc --noEmit + vitest run all clean (296 tests passing).
- OpenAPI YAML parses (89 paths, 125 ops).
- L-1 CI guardrail passes against post-fix tree, fires against
  synthetic regression.

No push.
2026-04-25 14:33:02 +00:00
shankar0123 677524d9ec Merge branch 'fix/d1-master-statusbadge-enum-drift' (D-1 master, 5 audit findings) 2026-04-25 13:53:02 +00:00
shankar0123 9dc0742e77 fix(web): close StatusBadge enum drift + Certificate TS phantom fields (D-1 master)
Five audit findings, all category cat-d or cat-f, all rooted in two
frontend files. The dashboard silently lied:

  cat-d-359e92c20cbf [P1, primary] — Agent: 'Stale' dead key + 'Degraded'
                                     neutral fallthrough
  cat-d-9f4c8e4a91f1 [P2]          — Notification: 'dead' missing
  cat-d-1447e04732e7 [P3]          — Cert: 'PendingIssuance' dead key
  cat-f-cert_detail_page_key_render_fallback [P2] — render-site reads
                                                    cert.key_algorithm directly
  cat-f-ae0d06b6588f [P2]          — Certificate TS phantom fields (root cause)

Pre-D-1, agents in the only Go AgentStatus that means 'needs operator
attention' (Degraded) rendered as default neutral grey because StatusBadge
mapped 'Stale' (a key Go has never emitted) to yellow. Dead-letter
notifications visually equated with 'read' (operator-acknowledged). The
Certificate badge map carried a 'PendingIssuance' key no Go enum emits.
CertificateDetailPage's Key Algorithm and Key Size rows always rendered
'—' even when the data was a single fetch away — the lookup went through
cert.key_algorithm / cert.key_size directly, both phantom Certificate TS
fields. Trim the TS type so the missing-data case is explicit; fix the
render site to use latestVersion?.field; pin the contract with a 38-case
Vitest property test that walks every Go enum.

StatusBadge (web/src/components/StatusBadge.tsx)
- Drop 'Stale' (Agent dead key) + 'PendingIssuance' (Cert dead key).
- Add 'Degraded' (Agent → badge-warning) + 'dead' (Notification → badge-danger).
- Add leading docblock naming Go-side source-of-truth file for every
  status family and pointing at the property test as regression vector.

Property test (web/src/components/StatusBadge.test.tsx — 38 cases)
- Iterates every Go-emitted enum value (AgentStatus, CertificateStatus,
  JobStatus, NotificationStatus, DiscoveryStatus, HealthStatus) plus the
  two frontend-synthesized Enabled/Disabled labels, asserts every value
  gets a non-default class (or an explicit 'badge badge-neutral' for the
  five intentionally-neutral terminal values: Archived, Cancelled,
  Dismissed, read, unknown).
- Negative assertions: 'Stale' and 'PendingIssuance' must fall through
  to the dictionary default — re-adding either key surfaces here.
- Specific UX-correctness assertions: 'dead' → badge-danger,
  'Degraded' → badge-warning.
- Unknown-status fallthrough preserves label text.

Certificate TS trim (web/src/api/types.ts)
- Drop serial_number?, fingerprint_sha256?, key_algorithm?, key_size?,
  issued_at? from Certificate. Go's ManagedCertificate has never carried
  these — they live on CertificateVersion. Post-trim a cert.X access for
  any of the five fields is a TS compile error.
- Leading docblock cross-references the closure rationale and the
  latestVersion fallback pattern.

Render-site fix (web/src/pages/CertificateDetailPage.tsx)
- Key Algorithm / Key Size rows now read latestVersion?.key_algorithm /
  latestVersion?.key_size, mirroring the existing latestVersion fallback
  used a few lines above for serial_number / fingerprint_sha256.
- The same edit also tightened the serial / fingerprint / issued_at
  derivations to drop the now-impossible 'cert.X || latestVersion?.X'
  cert-side leg (cert.serial_number is a TS error post-trim).

Type-test regression (web/src/api/types.test.ts)
- Certificate literal construction pinned post-trim — adding any of the
  five fields back makes the literal an excess-property TS error.
- Sibling CertificateVersion literal pinning the trimmed fields still
  live on the version envelope (so the CertificateDetailPage fallback
  path can't break).

OpenAPI (api/openapi.yaml)
- ManagedCertificate schema unchanged — was already correct (no phantom
  fields). Added a leading comment cross-referencing the D-5 closure for
  future readers.

CI guardrail (.github/workflows/ci.yml)
- 'Forbidden StatusBadge dead-key + Certificate phantom-field regression
  guard (D-1)'. Two grep blocks: catches Stale/PendingIssuance map
  literals in StatusBadge.tsx; uses an awk-scoped window over the
  'export interface Certificate {' block in types.ts to catch the five
  phantom fields reappearing while explicitly excluding CertificateVersion
  (which legitimately carries them). Comments + test files exempt.

Verification
- Backend build/vet/test -short -race all clean across handler/router/
  middleware packages.
- Frontend tsc --noEmit clean.
- Vitest 256 → 296 tests (+40: 38 from new StatusBadge test, 2 from D-5
  Certificate trim regression in types.test.ts).
- OpenAPI YAML parses (87 paths).
- Both CI guardrail patterns clear on the post-fix tree; both fire
  against synthetic regression patterns (re-add Stale → fires; re-add
  serial_number? to Certificate → fires).

Out of scope (deferred)
- diff-05x06-* type drifts for Agent/DeploymentTarget/Notification/
  DiscoveredCertificate/Issuer TS interfaces. Per-type field-by-field
  Go ↔ TS diff is codegen-shaped, not edit-shaped — warrants its own
  D-2 master prompt. Noted in CHANGELOG follow-ups section.
2026-04-25 13:52:54 +00:00
shankar0123 1440a30d28 Merge branch 'fix/u3-master-db-coupling-cleanup' (U-3 master + 4 ride-alongs) 2026-04-25 13:29:30 +00:00
shankar0123 a3d8b9c607 fix(deploy,db,handler): close fresh-clone postgres init failure + 4 ride-along audit findings (U-3 master)
GitHub #10 reopened: operator mikeakasully cloned v2.0.50 fresh and ran the
canonical quickstart (docker compose -f deploy/docker-compose.yml up -d --build);
postgres reported unhealthy indefinitely, dependent containers never started.

Root cause: deploy/docker-compose.yml mounted a hand-curated subset of
migrations/*.up.sql + seed.sql into postgres /docker-entrypoint-initdb.d/.
Postgres applied them at initdb time. Once seed.sql referenced columns added
by migrations *after* the mounted cutoff (e.g., policy_rules.severity from
migration 000013), initdb crashed mid-seed and the container loop wedged.
Two sources of truth (compose mount list vs in-tree migration ladder)
diverged the moment a seed-touching migration shipped, and the only thing
that fixed it was hand-editing the compose file every release.

Fix: remove the dual source. Postgres boots empty; the server applies
migrations + seed at startup via RunMigrations + RunSeed. Helm has used
this pattern since day one (postgres-init emptyDir); compose now matches.

Bundled with four ride-along audit findings whose fixes share the same
schema/db code surface, so operators take the schema-change pain only once:

  cat-u-seed_initdb_schema_drift           [P1, primary] — initdb-mount fix
  cat-o-retry_interval_unit_mismatch       [P1] — column rename minutes→seconds
  cat-o-notification_created_at_dead_field [P2] — add column + populate
  cat-o-health_check_column_orphans        [P1] — drop unwired columns
  cat-u-no_version_endpoint                [P2] — add /api/v1/version

Single migration (000017_db_coupling_cleanup) bundles the three schema
changes under a DO \$\$ guard so re-application is safe; reduces
operator-visible 'schema-change releases' from four to one.

Backend
- internal/repository/postgres/db.go: add RunSeed (baseline) + RunDemoSeed
  (gated by CERTCTL_DEMO_SEED). Both idempotent (ON CONFLICT DO NOTHING in
  every shipped INSERT) so repeated boots are safe; missing-file is no-op
  so custom packaging that strips seeds still boots cleanly.
- cmd/server/main.go: invoke RunSeed (always) + RunDemoSeed (when flag set)
  immediately after RunMigrations.
- internal/repository/postgres/notification.go: NotificationRepository.Create
  now sets created_at (with time.Now() fallback when caller leaves it zero);
  scanNotification reads it back; List + ListRetryEligible SELECT extended.
- internal/repository/postgres/renewal_policy.go: column references updated
  to retry_interval_seconds across SELECT/INSERT/UPDATE sites.
- internal/api/handler/version.go: new VersionHandler exposes
  {version, commit, modified, build_time, go_version} from
  runtime/debug.ReadBuildInfo() with ldflags-supplied Version override.
- internal/api/router/router.go: register GET /api/v1/version through the
  no-auth chain (CORS + ContentType) alongside /health, /ready,
  /api/v1/auth/info.
- cmd/server/main.go: add /api/v1/version to no-auth dispatch + audit
  ExcludePaths so rollout polling doesn't dominate the audit trail.
- internal/config/config.go: add DatabaseConfig.DemoSeed +
  CERTCTL_DEMO_SEED env var.

Migration
- migrations/000017_db_coupling_cleanup.up.sql + .down.sql:
    (1) renewal_policies.retry_interval_minutes → retry_interval_seconds
        (DO \$\$ guard, idempotent re-application)
    (2) notification_events ADD COLUMN created_at TIMESTAMPTZ
        NOT NULL DEFAULT NOW()
    (3) network_scan_targets DROP orphan health_check_enabled +
        health_check_interval_seconds
- migrations/seed.sql: column reference updated to retry_interval_seconds.
- migrations/seed_demo.sql: same column rename + applied at runtime now via
  RunDemoSeed (no longer initdb-mounted).

Compose
- deploy/docker-compose.yml: drop ALL initdb mounts (10 migration files +
  seed.sql); add start_period: 30s to postgres + certctl-server healthchecks
  to absorb the runtime migration + seed application window on first boot.
- deploy/docker-compose.test.yml: same drop (+ ghost seed_test.sql mount
  removed; that file never existed); same healthcheck start_period.
- deploy/docker-compose.demo.yml: replace seed_demo.sql initdb mount with
  CERTCTL_DEMO_SEED=true env var on certctl-server.

Tests
- internal/api/handler/version_handler_test.go: TestVersion_ReturnsBuildInfo,
  TestVersion_RejectsNonGet, TestVersion_LdflagsOverride.
- internal/repository/postgres/seed_test.go: TestRunSeed_AppliesIdempotently,
  TestRunSeed_MissingFileIsNoOp, TestRunDemoSeed_AppliesIdempotently,
  TestMigration000017_RetryIntervalRename,
  TestMigration000017_NotificationCreatedAt,
  TestMigration000017_HealthCheckOrphansDropped (testcontainers, -short skips).
- internal/repository/postgres/notification_test.go:
  TestNotificationRepository_CreatedAt_IsPersisted +
  TestNotificationRepository_CreatedAt_DefaultsToNow.

CI guardrail
- .github/workflows/ci.yml: new 'Forbidden migration mount in compose initdb
  (U-3)' step grep-fails the build if any migrations/*.sql or seed*.sql
  re-appears in /docker-entrypoint-initdb.d in any compose file. Catches
  future drift before a fresh-clone operator hits it.

Spec / Docs
- api/openapi.yaml: add /api/v1/version operation under Health tag.
- docs/architecture.md: replace the 'initdb may run the same SQL' paragraph
  with a post-U-3 single-source-of-truth explanation.
- CHANGELOG.md: full unreleased-section entry covering all 5 closures,
  breaking changes, and the new env var.

Audit doc
- coverage-gap-audit-2026-04-24-v5/unified-audit.md: add new P1 #14
  cat-u-seed_initdb_schema_drift; flip the 4 ride-along findings to
   RESOLVED with closure prose pointing at this commit.

Verification: build/vet/test -short -race all clean across all touched
packages locally; govulncheck reports 0 vulnerabilities affecting our
code; OpenAPI YAML parses; CI U-3 grep guardrail clears against the
post-fix tree.
2026-04-25 13:29:23 +00:00
103 changed files with 6315 additions and 355 deletions
+511
View File
@@ -213,6 +213,347 @@ jobs:
exit 1 exit 1
fi fi
- name: Forbidden migration mount in compose initdb (U-3)
# U-3 closed cat-u-seed_initdb_schema_drift (GitHub #10) by
# eliminating the dual-source-of-truth between
# `migrations/*.up.sql` mounted into postgres
# `/docker-entrypoint-initdb.d/` and the same files re-applied at
# runtime by `RunMigrations`. Pre-U-3 every new migration that
# the seed depended on (000013 added `policy_rules.severity`,
# 000017 renames `retry_interval_seconds`, etc.) had to be added
# by hand to the compose mount list; missing the update crashed
# initdb on first boot, postgres flagged unhealthy, and the
# whole stack failed to start from a fresh clone. Post-U-3 the
# server is the single source of truth — `RunMigrations` +
# `RunSeed` apply everything at boot.
#
# This step grep-fails the build if any compose file under
# `deploy/` re-introduces a `migrations/.*\.sql` mount into
# `/docker-entrypoint-initdb.d`. Comments are exempt so the
# post-fix rationale block in the compose files (which
# documents WHY the mounts were removed) doesn't trip the guard.
# The demo overlay's `seed_demo.sql` is the explicit exception:
# it is tolerated only when it lives behind the
# CERTCTL_DEMO_SEED env var (post-U-3 demo path) — bare initdb
# mounts are NOT tolerated. The grep matches all compose
# mount-list shapes (`-` indented, `volumes:` indented, both),
# so any future drift surfaces here before the operator hits it
# on a fresh clone.
#
# See coverage-gap-audit-2026-04-24-v5/unified-audit.md
# cat-u-seed_initdb_schema_drift for the closure rationale, or
# internal/repository/postgres/db.go::RunSeed for the runtime
# contract.
run: |
set -e
BAD=$(grep -rnEH \
-e 'migrations/.*\.sql:.*docker-entrypoint-initdb' \
-e 'seed.*\.sql:.*docker-entrypoint-initdb' \
deploy/docker-compose.yml \
deploy/docker-compose.test.yml \
deploy/docker-compose.demo.yml \
2>/dev/null \
| grep -vE '^\s*[^:]+:[0-9]+:\s*#' \
|| true)
if [ -n "$BAD" ]; then
echo "U-3 regression: migration/seed mount into postgres initdb reappeared:"
echo "$BAD"
echo ""
echo "The post-U-3 contract is: postgres comes up with an empty"
echo "schema and the server applies migrations + seed at boot via"
echo "internal/repository/postgres.RunMigrations + RunSeed. Demo"
echo "data lives behind CERTCTL_DEMO_SEED=true (RunDemoSeed),"
echo "not an initdb mount. See"
echo "coverage-gap-audit-2026-04-24-v5/unified-audit.md"
echo "cat-u-seed_initdb_schema_drift for the closure rationale."
exit 1
fi
- name: Forbidden StatusBadge dead-key + TS phantom-field regression guard (D-1 + D-2)
# D-1 master closed cat-d-359e92c20cbf (Agent: 'Stale' dead key,
# 'Degraded' missing), cat-d-9f4c8e4a91f1 (Notification: 'dead'
# missing), cat-d-1447e04732e7 (Cert: 'PendingIssuance' dead
# key), cat-f-cert_detail_page_key_render_fallback (render-site
# uses cert.X directly), and cat-f-ae0d06b6588f (Certificate
# TS phantom fields). This step grep-fails the build if either
# half of the closure is reverted:
#
# 1. The dead StatusBadge keys ('Stale' for Agent, 'PendingIssuance'
# for Cert) reappearing as map literals, OR
# 2. The five phantom Certificate TS fields (serial_number,
# fingerprint_sha256, key_algorithm, key_size, issued_at)
# reappearing on the `Certificate` interface in types.ts
# (CertificateVersion legitimately carries them and is
# explicitly excluded by the awk pre-filter below).
#
# Comments are exempt so the closure prose in StatusBadge.tsx +
# types.ts can stay. Test files are exempt so negative tests
# asserting the dead keys fall through to neutral keep working.
#
# See coverage-gap-audit-2026-04-24-v5/unified-audit.md
# cat-d-* / cat-f-* for the closure rationale, or
# web/src/components/StatusBadge.test.tsx for the live
# enum-coverage contract.
run: |
set -e
BAD_BADGE=$(grep -nE "^\s*(Stale|PendingIssuance)\s*:\s*'badge-" \
web/src/components/StatusBadge.tsx 2>/dev/null \
| grep -v '\.test\.' \
| grep -vE '^\s*[^:]+:[0-9]+:\s*//' \
|| true)
if [ -n "$BAD_BADGE" ]; then
echo "D-1 regression: dead StatusBadge key reappeared:"
echo "$BAD_BADGE"
echo ""
echo "Allowed surface: comment lines naming the removed key in"
echo "the file's preamble. The Go-side AgentStatus values are"
echo "Online/Offline/Degraded (no Stale); CertificateStatus values"
echo "are Pending/Active/... (no PendingIssuance). See"
echo "web/src/components/StatusBadge.test.tsx for the contract."
exit 1
fi
# Certificate TS phantom-field check. Scoped to the
# `export interface Certificate {` block in web/src/api/types.ts
# — CertificateVersion legitimately declares these fields and
# must NOT trip the guardrail. The awk window opens on the
# exact `Certificate {` header (not `CertificateVersion {`,
# not `CertificateProfile {`) and closes at the first `}`,
# then the grep matches a phantom-field declaration anywhere
# in that window.
BAD_TS=$(awk '
/^export interface Certificate \{/ { flag=1; next }
flag && /^\}/ { flag=0 }
flag { print FILENAME":"NR":"$0 }
' web/src/api/types.ts \
| grep -E '\b(serial_number|fingerprint_sha256|key_algorithm|key_size|issued_at)\??\s*:' \
|| true)
if [ -n "$BAD_TS" ]; then
echo "D-1 regression: Certificate TS interface re-added a phantom field:"
echo "$BAD_TS"
echo ""
echo "These fields live on CertificateVersion, not ManagedCertificate."
echo "The Go-side ManagedCertificate has never carried them; the"
echo "TS optional declarations were silently undefined on every"
echo "list response. Render-site consumers (e.g. CertificateDetailPage)"
echo "use latestVersion?.field as the canonical access path."
echo "See coverage-gap-audit-2026-04-24-v5/unified-audit.md"
echo "cat-f-ae0d06b6588f for the closure rationale."
exit 1
fi
# D-2 master closed five diff-05x06-* type-drift findings:
# Agent (5 phantoms), Issuer (1 phantom), Notification (1 phantom)
# — TRIM half. The Target (2 missing fields) and DiscoveredCertificate
# (1 missing field) — ADD half is pinned by the literal-construction
# blocks in web/src/api/types.test.ts, not a CI grep. The phantom-
# trim regression vector is an awk-windowed grep per interface
# mirroring the D-1 Certificate check above.
#
# See coverage-gap-audit-2026-04-24-v5/unified-audit.md
# diff-05x06-7cdf4e78ae24 (Agent), diff-05x06-97fab8783a5c (Issuer),
# diff-05x06-caba9eb3620e (Notification) for the closure rationale.
# D-2 Agent phantom-field check. The grep matches `last_heartbeat`
# but NOT `last_heartbeat_at` (the legitimate Go-emitted field) —
# the `\b...\b` boundaries plus the `grep -v 'last_heartbeat_at'`
# filter handle that.
BAD_AGENT=$(awk '
/^export interface Agent \{/ { flag=1; next }
flag && /^\}/ { flag=0 }
flag { print FILENAME":"NR":"$0 }
' web/src/api/types.ts \
| grep -E '\b(last_heartbeat|capabilities|tags|created_at|updated_at)\??\s*:' \
| grep -v 'last_heartbeat_at' \
|| true)
if [ -n "$BAD_AGENT" ]; then
echo "D-2 regression: Agent TS interface re-added a phantom field:"
echo "$BAD_AGENT"
echo ""
echo "The Go-side internal/domain/connector.go::Agent emits exactly:"
echo "id, name, hostname, status, last_heartbeat_at?, registered_at,"
echo "os, architecture, ip_address, version, retired_at?, retired_reason?."
echo "The five fields blocked by this guard (last_heartbeat,"
echo "capabilities, tags, created_at, updated_at) were TS phantoms"
echo "the Go struct never emitted. See unified-audit.md"
echo "diff-05x06-7cdf4e78ae24 for closure rationale."
exit 1
fi
# D-2 Issuer phantom-field check.
BAD_ISSUER=$(awk '
/^export interface Issuer \{/ { flag=1; next }
flag && /^\}/ { flag=0 }
flag { print FILENAME":"NR":"$0 }
' web/src/api/types.ts \
| grep -E '\bstatus\??\s*:' \
|| true)
if [ -n "$BAD_ISSUER" ]; then
echo "D-2 regression: Issuer TS interface re-added a phantom 'status' field:"
echo "$BAD_ISSUER"
echo ""
echo "The Go-side internal/domain/connector.go::Issuer has no 'status'"
echo "field — only 'enabled' (bool). Render sites derive the displayed"
echo "status from 'enabled' at the call site (see"
echo "web/src/pages/IssuersPage.tsx::issuerStatus). See unified-audit.md"
echo "diff-05x06-97fab8783a5c for closure rationale."
exit 1
fi
# D-2 Notification phantom-field check.
BAD_NOTIF=$(awk '
/^export interface Notification \{/ { flag=1; next }
flag && /^\}/ { flag=0 }
flag { print FILENAME":"NR":"$0 }
' web/src/api/types.ts \
| grep -E '\bsubject\??\s*:' \
|| true)
if [ -n "$BAD_NOTIF" ]; then
echo "D-2 regression: Notification TS interface re-added a phantom 'subject' field:"
echo "$BAD_NOTIF"
echo ""
echo "The Go-side internal/domain/notification.go::NotificationEvent"
echo "has no 'subject' field — only 'message'. Pre-D-2 the consumer"
echo "at NotificationsPage.tsx had a dead '|| n.subject' fallback"
echo "that always fell through. See unified-audit.md"
echo "diff-05x06-caba9eb3620e for closure rationale."
exit 1
fi
- name: Forbidden client-side bulk-action loop regression guard (L-1)
# L-1 master closed cat-l-fa0c1ac07ab5 (bulk-renew loop) and
# cat-l-8a1fb258a38a (bulk-reassign loop) by adding server-side
# bulk endpoints (POST /api/v1/certificates/bulk-renew and
# POST /api/v1/certificates/bulk-reassign) that the GUI calls
# in a single round-trip. Pre-L-1 the GUI looped per-cert
# HTTP calls — 100 selected certs = 100 round-trips × ~50200ms
# each = a 520-second wedge during which the operator stares
# at a progress bar.
#
# This step grep-fails the build if either loop shape reappears
# in CertificatesPage.tsx. Patterns catch the actual pre-L-1
# shapes:
# - `for (const id of ids) { await triggerRenewal(id) }`
# - `for (const id of ids) { await updateCertificate(id, { owner_id }) }`
# - `for (let i = 0; i < ids.length; i++) { await triggerRenewal(ids[i]) }`
#
# Allowed: comment lines explaining the pre-L-1 pattern in the
# docblock above each handler. Test files (_test.tsx) exempt
# so negative-pattern tests can keep working.
#
# See coverage-gap-audit-2026-04-24-v5/unified-audit.md
# cat-l-fa0c1ac07ab5 and cat-l-8a1fb258a38a for closure
# rationale, or web/src/api/client.ts::bulkRenewCertificates
# / bulkReassignCertificates for the canonical call path.
run: |
set -e
BAD_LOOP=$(grep -nE 'for[[:space:]]*\(' web/src/pages/CertificatesPage.tsx 2>/dev/null \
| grep -E 'await[[:space:]]+(triggerRenewal|updateCertificate)\(' \
| grep -v '\.test\.' \
| grep -vE '^\s*[^:]+:[0-9]+:\s*//' \
|| true)
if [ -n "$BAD_LOOP" ]; then
echo "L-1 regression: client-side bulk-action loop reappeared in CertificatesPage.tsx:"
echo "$BAD_LOOP"
echo ""
echo "Use bulkRenewCertificates({ certificate_ids: [...] }) or"
echo "bulkReassignCertificates({ certificate_ids: [...], owner_id, team_id? })"
echo "instead of looping per-item HTTP calls. See"
echo "coverage-gap-audit-2026-04-24-v5/unified-audit.md cat-l-* for rationale."
exit 1
fi
- name: Forbidden orphan-CRUD client function regression guard (B-1)
# B-1 master closed four audit findings — three orphan-update fns
# (cat-b-31ceb6aaa9f1, cat-b-7a34f893a8f9) and one orphan CRUD
# surface (cat-b-4631ca092bee, RenewalPolicy) — by wiring per-page
# Edit modals so every backend write endpoint has at least one
# GUI consumer. The fourth finding (cat-b-9b97ffb35ef7) deleted
# the dead `exportCertificatePEM` duplicate.
#
# Pre-B-1 the failure mode was: backend ships a CRUD handler,
# client.ts ships the matching `update*` / `delete*` / `create*`
# function, but no page imports it. Operators were forced to
# `psql` directly to edit team names, owner emails, agent-group
# match rules, issuer names, profile names, or any renewal-policy
# field — turning a 30-second GUI task into a 30-minute database
# excursion with audit-trail gaps.
#
# This step fails the build if any of the eight previously-orphan
# client functions loses its page consumer (i.e. a future refactor
# accidentally re-orphans them). Each fn must have ≥1 non-test
# consumer under web/src/pages/. Tests (*.test.ts(x)) and the
# client.ts definition file itself are exempt.
#
# See coverage-gap-audit-2026-04-24-v5/unified-audit.md
# cat-b-31ceb6aaa9f1, cat-b-7a34f893a8f9, cat-b-4631ca092bee,
# cat-b-9b97ffb35ef7 for closure rationale.
run: |
set -e
ORPHAN_FNS="updateOwner updateTeam updateAgentGroup updateIssuer updateProfile createRenewalPolicy updateRenewalPolicy deleteRenewalPolicy"
FAIL=0
for fn in $ORPHAN_FNS; do
HITS=$(grep -rE "\b${fn}\b" web/src/pages/ 2>/dev/null \
| grep -vE '\.test\.(ts|tsx):' \
| wc -l)
if [ "$HITS" -eq 0 ]; then
echo "::error::B-1 regression: client function '${fn}' has zero consumers under web/src/pages/."
echo " Every backend CRUD endpoint must have a GUI consumer to avoid forcing operators to psql."
echo " Either restore the page consumer or delete the client function in the same commit."
FAIL=1
fi
done
# cat-b-9b97ffb35ef7: exportCertificatePEM was deleted as a dead
# duplicate of downloadCertificatePEM. Block resurrection.
if grep -nE 'export\s+const\s+exportCertificatePEM' web/src/api/client.ts >/dev/null 2>&1; then
echo "::error::B-1 regression: exportCertificatePEM was removed as a dead duplicate of downloadCertificatePEM."
echo " If a JSON variant is needed, add an explicit page consumer in the same commit."
FAIL=1
fi
if [ "$FAIL" -ne 0 ]; then
exit 1
fi
echo "B-1 orphan-CRUD client function guardrail: all 8 functions have page consumers."
- name: Forbidden strings.Contains(err.Error()) regression guard (S-2)
# S-2 closure (cat-s6-efc7f6f6bd50): replaced 30 brittle
# substring-match error-dispatch sites in internal/api/handler/
# with errors.Is + typed sentinels (repository.ErrNotFound,
# repository.ErrForeignKeyConstraint via the
# repository.IsForeignKeyError helper). This step grep-fails
# the build if any new strings.Contains(err.Error(), "not found")
# or strings.Contains(err.Error(), "violates foreign key")
# site appears under internal/api/handler/.
#
# Allowed: closure-comments documenting the convention (e.g.
# bulk_reassignment.go's "post-M-1 errToStatus convention"
# docblock); domain-specific substring patterns that are
# legitimately one-off ("cannot approve", "cannot reject",
# "cannot be parsed", "challenge password") — flagged as
# deferred follow-ups in the S-2 commit message.
#
# See coverage-gap-audit-2026-04-24-v5/unified-audit.md
# cat-s6-efc7f6f6bd50 for closure rationale.
run: |
set -e
BAD=$(grep -rnE 'strings\.Contains\(err\.Error\(\),\s*"(not found|violates foreign key|RESTRICT)"' internal/api/handler/ 2>/dev/null \
| grep -vE '^\s*[^:]+:[0-9]+:\s*//' \
|| true)
if [ -n "$BAD" ]; then
echo "S-2 regression: brittle substring-match error-dispatch reappeared:"
echo "$BAD"
echo ""
echo "Use errors.Is(err, repository.ErrNotFound) for not-found dispatch,"
echo "or repository.IsForeignKeyError(err) for FK violations."
echo "See coverage-gap-audit-2026-04-24-v5/unified-audit.md"
echo "cat-s6-efc7f6f6bd50 for closure rationale."
exit 1
fi
echo "S-2 typed-sentinel error-dispatch guardrail: clean."
- name: Race Detection - name: Race Detection
run: go test -race ./internal/service/... ./internal/api/handler/... ./internal/api/middleware/... ./internal/scheduler/... ./internal/connector/... ./internal/crypto/... ./internal/domain/... ./internal/validation/... ./internal/tlsprobe/... -count=1 -timeout 300s run: go test -race ./internal/service/... ./internal/api/handler/... ./internal/api/middleware/... ./internal/scheduler/... ./internal/connector/... ./internal/crypto/... ./internal/domain/... ./internal/validation/... ./internal/tlsprobe/... -count=1 -timeout 300s
@@ -306,6 +647,176 @@ jobs:
working-directory: web working-directory: web
run: npx vite build run: npx vite build
- name: Forbidden hardcoded source-count prose regression guard (S-1)
# S-1 master closed cat-s1-9ce1cbe26876 (README + features.md
# stale numeric counts; explicit CLAUDE.md violation per
# "version-stamped numbers rot") and
# cat-s1-features_md_issuer_count_contradiction (features.md
# self-disagreed on issuer count: 9 vs 12 in the same doc).
# The fix replaced source-derived numbers in prose with
# "rebuild via <command>" patterns documented in CLAUDE.md::
# "Current-state commands". This step grep-fails the build if
# any of the previously-stale sites reintroduces a hardcoded
# count.
#
# Allowed surfaces: demo-fixture prose in README ("32
# certificates" — those are seed_demo.sql facts, not live
# source counts), historical-milestone counts in
# WORKSPACE-CHANGELOG.md, the testing-guide example phrasing
# ("README claims 8 issuer connectors but only 6 exist"),
# and any number that quotes the source command immediately
# adjacent.
#
# See coverage-gap-audit-2026-04-24-v5/unified-audit.md
# cat-s1-9ce1cbe26876 + cat-s1-features_md_issuer_count_contradiction
# for closure rationale.
run: |
set -e
BAD=$(grep -rnE '\b[0-9]+\s+(issuer connectors?|target connectors?|notifier connectors?|discovery connectors?|MCP tools|OpenAPI operations|migrations|database tables|frontend pages|HTTP routes)\b' \
README.md docs/ 2>/dev/null \
| grep -vE 'WORKSPACE-CHANGELOG|seed_demo|demo override' \
| grep -vE 'DRIFT HAZARD|Source: |Rebuild|rebuild via|grep -|wc -l|ls -d|find ' \
| grep -vE 'README claims [0-9]+ issuer connectors but only [0-9]+ exist' \
|| true)
if [ -n "$BAD" ]; then
echo "S-1 regression: hardcoded source-count prose reappeared:"
echo "$BAD"
echo ""
echo "CLAUDE.md rule: 'Numeric claims about current state rot.'"
echo "Replace the count with the grep command from CLAUDE.md::"
echo "'Current-state commands' (e.g. 'ls -d internal/connector/issuer/*/ | wc -l')"
echo "or rephrase to reference the rebuild command on the same line."
echo "See coverage-gap-audit-2026-04-24-v5/unified-audit.md"
echo "cat-s1-9ce1cbe26876 for closure rationale."
exit 1
fi
echo "S-1 stale-counts guardrail: clean."
- name: Documented orphan client fns sync guard (P-1)
# P-1 master closed diff-04x03-d24864996ad4 + cat-b-dc46aadab98e
# by documenting 17 detail-page-candidate orphan client.ts
# functions in a docblock at the top of web/src/api/client.ts.
# This step verifies the docblock list ↔ export list relationship:
# every name listed in the docblock must still be declared as
# an export below it (catches drift where someone deletes the
# export but forgets the docblock, or vice versa).
#
# See coverage-gap-audit-2026-04-24-v5/unified-audit.md
# diff-04x03-d24864996ad4 + cat-b-dc46aadab98e for closure rationale.
run: |
set -e
DOCUMENTED='getAgentGroup getAgentGroupMembers getAuditEvent getCertificateDeployments getDiscoveredCertificate getHealthCheck getHealthCheckHistory getNetworkScanTarget getNotification getOCSPStatus getOwner getPolicy getPolicyViolations getRenewalPolicy getTeam registerAgent updateHealthCheck'
MISSING=""
for fn in $DOCUMENTED; do
if ! grep -qE "^export const ${fn}\b" web/src/api/client.ts; then
MISSING="${MISSING}${fn} "
fi
done
if [ -n "$MISSING" ]; then
echo "P-1 regression: documented orphan(s) missing from client.ts exports:"
echo " $MISSING"
echo ""
echo "Either restore the export, or delete the corresponding line"
echo "in the documented-orphans docblock at the top of client.ts."
echo "See coverage-gap-audit-2026-04-24-v5/unified-audit.md"
echo "diff-04x03-d24864996ad4 for closure rationale."
exit 1
fi
echo "P-1 documented-orphans sync guard: clean ($(echo $DOCUMENTED | wc -w) fns verified)."
- name: Forbidden env-var docs drift regression guard (G-3)
# G-3 master closed cat-g-163dae19bc59 (docs-only env vars
# phantom in features.md), cat-g-b8f8f8796159 (6 config-only
# env vars never documented), and cat-g-renewal_check_interval_rename_drift
# (features.md still advertised the pre-rename
# CERTCTL_RENEWAL_CHECK_INTERVAL after it was renamed to
# CERTCTL_SCHEDULER_RENEWAL_CHECK_INTERVAL). This step runs
# `comm -23` both ways between the env vars defined in Go
# source (config.go + cmd/agent + deploy/test fixtures + ACME
# DNS-01 script env exports) and the env vars mentioned in
# README + docs/ + deploy/helm/.
#
# Allowlist: env vars that are documented as integration-
# surface contracts (script env exports for ACME DNS-01,
# OpenSSL CA scripts, StepCA per-issuer-config-blob fields,
# Webhook per-notifier-config-blob fields, ACME EAB, audit
# exclusion, demo-stack overrides) but not consumed directly
# by config.go. Each entry below has a one-line justification
# — if you add a new entry, add the justification too.
#
# See coverage-gap-audit-2026-04-24-v5/unified-audit.md
# cat-g-* for closure rationale.
run: |
set -e
# Defined: config.go + agent + cli + mcp-server + server cmds + test fixtures + ACME DNS export
{
grep -nE '"CERTCTL_[A-Z_]+"' internal/config/config.go | sed -E 's/.*"(CERTCTL_[A-Z_]+)".*/\1/'
grep -rhoE '"CERTCTL_[A-Z_]+"' cmd/agent/*.go cmd/cli/*.go cmd/mcp-server/*.go cmd/server/*.go 2>/dev/null | sed -E 's/"(CERTCTL_[A-Z_]+)"/\1/'
grep -rhoE 'CERTCTL_[A-Z_]+' deploy/test/qa_test.go internal/connector/issuer/acme/dns.go 2>/dev/null
} | grep -E '^CERTCTL_' | sort -u > /tmp/g3-defined.txt
# Documented: README + docs + helm
grep -rhoE '\bCERTCTL_[A-Z_]+\b' README.md docs/ deploy/helm/ 2>/dev/null | sort -u > /tmp/g3-docs.txt
# Allowlist of env vars documented as external integration contracts.
# Each entry justifies itself in one line; if you add to this list,
# add the justification.
ALLOWED='^(
CERTCTL_OPENSSL_SIGN_SCRIPT|
CERTCTL_OPENSSL_REVOKE_SCRIPT|
CERTCTL_OPENSSL_CRL_SCRIPT|
CERTCTL_OPENSSL_TIMEOUT_SECONDS|
CERTCTL_STEPCA_URL|
CERTCTL_STEPCA_FINGERPRINT|
CERTCTL_STEPCA_PROVISIONER|
CERTCTL_STEPCA_PROVISIONER_NAME|
CERTCTL_STEPCA_PROVISIONER_KEY|
CERTCTL_STEPCA_PROVISIONER_JWK|
CERTCTL_STEPCA_PROVISIONER_PASSWORD|
CERTCTL_STEPCA_PASSWORD|
CERTCTL_STEPCA_KEY_PATH|
CERTCTL_STEPCA_ROOT_CA|
CERTCTL_WEBHOOK_URL|
CERTCTL_WEBHOOK_SECRET|
CERTCTL_ACME_EAB_KID|
CERTCTL_ACME_EAB_HMAC|
CERTCTL_ACME_DNS_PROPAGATION_WAIT|
CERTCTL_AUDIT_EXCLUDE_PATHS|
CERTCTL_TLS_|
CERTCTL_TLS_INSECURE_SKIP_VERIFY|
CERTCTL_SERVER_CA_BUNDLE_PATH|
CERTCTL_SERVER_TLS_INSECURE_SKIP_VERIFY|
CERTCTL_QA_[A-Z_]+
)$'
# ^ The CERTCTL_OPENSSL_* / CERTCTL_STEPCA_* / CERTCTL_WEBHOOK_* /
# CERTCTL_ACME_EAB_* / CERTCTL_ACME_DNS_PROPAGATION_WAIT /
# CERTCTL_AUDIT_EXCLUDE_PATHS / CERTCTL_TLS_* / CERTCTL_SERVER_* /
# CERTCTL_QA_* sets are documented integration-surface contracts
# (script invocations, per-issuer config-blob field names,
# per-notifier config-blob field names, demo-stack overrides,
# test fixtures) — not server-side env vars in config.go.
# The audit's "37 docs-only" count over-flagged these; the
# closure narrows the gate to the specific drift sites
# (renewal-interval rename + 6 config-only) and allowlists
# the documented external contracts here.
ALLOWED_FLAT=$(echo "$ALLOWED" | tr -d '\n ')
DOCS_ONLY=$(comm -13 /tmp/g3-defined.txt /tmp/g3-docs.txt | grep -vE "$ALLOWED_FLAT" || true)
CONFIG_ONLY=$(comm -23 /tmp/g3-defined.txt /tmp/g3-docs.txt || true)
if [ -n "$DOCS_ONLY" ]; then
echo "G-3 regression: env var(s) mentioned in docs but not defined in Go source AND not in the documented integration-surface allowlist:"
echo "$DOCS_ONLY"
echo ""
echo "Either delete from docs (phantom/typo) or add to config.go,"
echo "or add to the ALLOWED list with a one-line justification."
exit 1
fi
if [ -n "$CONFIG_ONLY" ]; then
echo "G-3 regression: env var(s) defined in Go source but never documented:"
echo "$CONFIG_ONLY"
echo ""
echo "Add an entry to docs/features.md (or another canonical doc) so operators can find it."
exit 1
fi
echo "G-3 env-var docs drift guardrail: clean."
helm-lint: helm-lint:
name: Helm Chart Validation name: Helm Chart Validation
runs-on: ubuntu-latest runs-on: ubuntu-latest
+205 -1
View File
@@ -2,7 +2,211 @@
All notable changes to certctl are documented in this file. Dates use ISO 8601. Versions follow [Semantic Versioning](https://semver.org/). All notable changes to certctl are documented in this file. Dates use ISO 8601. Versions follow [Semantic Versioning](https://semver.org/).
## [unreleased] — 2026-04-24 ## [unreleased] — 2026-04-25
### H-1: Security hardening trio — closed end-to-end
> Three 2026-04-24 audit findings (all P2) that together complete the HTTPS-Everywhere security baseline. The audit flagged: (1) the unauth surface (EST RFC 7030, SCEP, PKI CRL/OCSP, /health, /ready) accepted arbitrary-size request bodies because the `noAuthHandler` middleware chain was missing the `bodyLimitMiddleware` that the authed `apiHandler` chain has; (2) zero security headers (CSP, HSTS, X-Frame-Options, X-Content-Type-Options, Referrer-Policy) were emitted on any response — enabling clickjacking, MIME-sniffing, and untrusted-origin resource loads against the dashboard and API; (3) `CERTCTL_CONFIG_ENCRYPTION_KEY` was accepted with any non-empty value, including a single character — PBKDF2-SHA256 with 100k rounds does not compensate for low-entropy passphrases at scale (CWE-916 / CWE-329).
### Breaking Changes
**Operators with low-entropy `CERTCTL_CONFIG_ENCRYPTION_KEY` will fail to start after upgrade.** Pre-H-1 the field accepted any non-empty string. Post-H-1 it requires ≥32 bytes (e.g. `openssl rand -base64 32`). The startup error names the offending env var, the actual length, the required minimum, and the canonical generation command. Empty (`""`) remains accepted — the existing fail-closed sentinel `crypto.ErrEncryptionKeyRequired` triggers downstream when an empty key tries to encrypt or decrypt. Operators using a short passphrase must rotate before the upgrade.
### Added
- **`internal/api/middleware/securityheaders.go`** (new) — `SecurityHeaders` middleware applies HSTS, X-Frame-Options, X-Content-Type-Options, Referrer-Policy, and a conservative Content-Security-Policy on every response. Defaults via `SecurityHeadersDefaults()` are: `Strict-Transport-Security: max-age=31536000; includeSubDomains`, `X-Frame-Options: DENY`, `X-Content-Type-Options: nosniff`, `Referrer-Policy: no-referrer-when-downgrade`, and `Content-Security-Policy: default-src 'self'; img-src 'self' data:; style-src 'self' 'unsafe-inline'; script-src 'self'; connect-src 'self'; frame-ancestors 'none'`. Operators behind a customising reverse proxy can override per-header by setting any field of the config struct to the empty string (omits that header).
- **`bodyLimitMiddleware` wired into `noAuthHandler`** in `cmd/server/main.go`. Same default cap (1 MB, configurable via `CERTCTL_MAX_BODY_SIZE`), same 413 response on overflow. Pre-H-1 only the authed surface had this protection.
- **`securityHeadersMiddleware` wired into BOTH chains** (`middlewareStack` for authed routes; `noAuthHandler` for unauth routes). Applied before the audit middleware so headers reach 4xx/5xx responses too — critical for security posture (an attacker probing for misconfiguration sees the same headers on a 401 as on a 200).
- **`CERTCTL_CONFIG_ENCRYPTION_KEY` length validation** in `internal/config/config.go::Validate()` — rejects keys shorter than 32 bytes with a structured error naming the actual length, the required minimum, and the canonical generation command. Empty keys remain accepted (downstream fail-closed sentinel handles it).
- **Tests:** `internal/api/middleware/securityheaders_test.go` (4 cases — defaults present, empty disables single header, override applied, headers on 4xx/5xx). `internal/config/config_test.go` adds 5 cases for the encryption-key length check (empty accepted, 1-byte rejected, 31-byte rejected at boundary, 32-byte accepted, 44-byte realistic operator key accepted).
### Audit findings closed
- `cat-s5-4936a1cf0118` (P2, EST/SCEP/PKI unauth endpoints bypass `http.MaxBytesReader`)
- `cat-s11-missing_security_headers` (P2, no CSP / HSTS / X-Frame-Options on responses)
- `cat-r-encryption_key_no_length_validation` (P2, encryption key accepted with zero entropy validation)
### Known follow-ups (deferred from H-1 scope)
A weak-key dictionary check (reject `password123`, common ASCII patterns) is deferred — adds operational friction with low marginal entropy gain at the 32-byte minimum. CSP `'unsafe-inline'` for styles is required because Tailwind via Vite injects per-component `<style>` blocks at build time; removing it would require an HTML report or component refactor outside H-1 scope. A `Permissions-Policy` (formerly Feature-Policy) header is not in the H-1 baseline because the dashboard uses no advanced browser APIs (camera, microphone, geolocation); deferred until a real consumer needs it.
### D-2: TS ↔ Go type drift cluster — closed end-to-end
> The 2026-04-24 coverage-gap audit flagged five `diff-05x06-*` findings — every one a TypeScript-vs-Go shape mismatch where the on-wire JSON the backend emits and the TS interface in `web/src/api/types.ts` had drifted apart. D-1 master closed the same pattern for `Certificate` (cat-f-ae0d06b6588f, 5 phantom fields trimmed, plus the cat-f-cert_detail_page_key_render_fallback render-site fix). D-2 closes it for the remaining five entities: Agent, Target, DiscoveredCertificate, Issuer, and Notification. The audit's blunt rule "stricter side is the contract" decides the per-entity verdict — for TS phantoms (fields declared on TS, never emitted by Go) the Go side wins and TS gets trimmed; for TS-missing fields (emitted by Go, absent from TS) the Go side still wins and TS gets the addition. Pre-D-2 the failure modes were: phantom fields silently rendered `'—'` at consumer sites (e.g. AgentDetailPage's "Capabilities" + "Tags" sections always rendered empty; IssuersPage rendered `'Unknown'` for every issuer; NotificationsPage's `n.message || n.subject` fallback always fell through), and missing fields forced `(target as any).retired_at` escapes that lost type-checking. Verify-only side task: Certificate / ManagedCertificate confirmed clean since D-1.
### Breaking Changes
None on the wire. The JSON the backend emits is byte-identical pre/post-D-2 — D-2 is purely TS-side reconciliation. The interface shapes change in ways that are TypeScript compile errors at consumer sites that read trimmed phantoms (intentionally — that's the closure mechanism) but no operator-visible behaviour shifts.
### Added
- `Target` interface gains `retired_at?: string | null` and `retired_reason?: string | null` (mirrors the Agent retirement-fields shape and the Go-side `internal/domain/connector.go::DeploymentTarget` I-004 model). An Agent retire cascades to all associated Targets per `service.RetireAgent → repository.RetireTarget`; the GUI can now type-check the retired-state surfacing without `(target as any).retired_at` escapes.
- `DiscoveredCertificate` interface gains `pem_data?: string`. The Go-side struct (`internal/domain/discovery.go::DiscoveredCertificate.PEMData`, `omitempty`) emits this field on the wire — populated by the agent filesystem scanner, the cloud-secret-manager connectors, and the repo SELECT. Optional because Go uses `omitempty`. Consumers can now reach the raw PEM with type-checked code.
- **CI regression guardrail extension** in `.github/workflows/ci.yml` (renamed `Forbidden StatusBadge dead-key + TS phantom-field regression guard (D-1 + D-2)`) — adds three new awk-windowed greps over the Agent / Issuer / Notification interfaces in `types.ts` that fail the build if any of the trimmed phantom fields reappear. The Agent regex `\b(last_heartbeat|capabilities|tags|created_at|updated_at)\b` is paired with a `grep -v 'last_heartbeat_at'` filter to avoid false positives on the legitimate Go-emitted heartbeat field.
### Removed
- `Agent` interface — 5 phantom fields trimmed: `last_heartbeat`, `capabilities`, `tags`, `created_at`, `updated_at`. None emitted by `internal/domain/connector.go::Agent`. Two had real consumers in `AgentDetailPage.tsx` (capabilities + tags sections) — both were removed because their guards always evaluated false. The "Updated" InfoRow that read `agent.updated_at` was also dropped (Go has no equivalent timestamp on Agent). `last_heartbeat_at` flipped from required to optional to match Go's `*time.Time omitempty`.
- `Issuer` interface — phantom `status: string` removed. Go has only `Enabled bool`. Both `IssuersPage.tsx::issuerStatus` and `IssuerDetailPage.tsx::issuerStatus` rewritten to compute `i.enabled ? 'Enabled' : 'Disabled'` exclusively (the pre-D-2 fallback `issuer.status || 'Unknown'` always rendered 'Unknown').
- `Notification` interface — phantom `subject?: string` removed. The dead `{n.message || n.subject}` fallback at `NotificationsPage.tsx:241` was simplified to `{n.message}`. Test mocks in `NotificationsPage.test.tsx` no longer set the field.
### Audit findings closed
- diff-05x06-7cdf4e78ae24 (P2, Agent TS↔Go drift)
- diff-05x06-2044a46f4dd0 (P2, Target TS↔DeploymentTarget Go drift)
- diff-05x06-85ab6b98a2f7 (P2, DiscoveredCertificate TS↔Go drift)
- diff-05x06-97fab8783a5c (P2, Issuer TS↔Go drift)
- diff-05x06-caba9eb3620e (P2, Notification TS↔NotificationEvent Go drift)
- diff-05x06-af18a8d7ef41 (P2, Certificate / ManagedCertificate) — verified no residual drift since D-1; no edit required
### Known follow-ups (deferred from D-2 scope)
A richer Issuer status view that derives from `enabled × test_status` (instead of `enabled` alone) is deferred — a UX scope decision, not a contract drift, and the existing `test_status: 'untested' | 'success' | 'failed'` field is already on the TS interface for whoever picks up that work. Real Agent metadata fields (capabilities advertised at heartbeat time, operator-applied tags) are deferred — D-2 removed the false UI affordance; if/when the product wants real fields, re-introduce in `AgentDetailPage` in the same commit that ships the Go-side change. The `DiscoveredCertificate.pem_data` LIST-response performance optimization (gate emission on the per-id detail path, since pem_data is kilobytes per row) is deferred as a separate backend change — D-2 only closed the contract drift.
### B-1: Orphan-CRUD client functions + RenewalPolicy GUI gap — closed end-to-end
> The 2026-04-24 coverage-gap audit flagged a cluster of operator-blocking GUI omissions: six client.ts `update*` functions (`updateOwner`, `updateTeam`, `updateAgentGroup`, `updateIssuer`, `updateProfile`, plus the full `*RenewalPolicy` CRUD trio) had backend handlers, OpenAPI operations, and exported TypeScript fetchers — but zero page consumers. Operators wanting to fix a typo in an owner's email, rename a team, retarget an agent group's match rules, or edit a renewal-policy field were forced to either delete-and-recreate (losing FK history and audit-trail continuity) or open a `psql` session against the production database directly. The audit's blunt summary: "every backend feature ships with its GUI surface" — a load-bearing CLAUDE.md invariant — was being violated for five operator-facing entities. B-1 closes that violation by wiring per-page Edit modals onto five existing pages, adding a brand-new `RenewalPoliciesPage` for the rp-* CRUD surface, and deleting one dead duplicate (`exportCertificatePEM`) so the public client surface area stops growing without consumers.
### Breaking Changes
None. All five existing pages keep their Create + Delete affordances unchanged; Edit is purely additive. `RenewalPoliciesPage` is a new route at `/renewal-policies` and a new sidebar nav item slotted between Policies and Profiles. The `exportCertificatePEM` helper had zero consumers in `web/`, MCP, CLI, and tests at the time of removal — operators using `downloadCertificatePEM` (the actual call site in `CertificateDetailPage`) are unaffected.
### Added
- **`web/src/pages/RenewalPoliciesPage.tsx`** — a new full-CRUD page for the `rp-*` renewal-policy table. Surfaces a 7-column DataTable (Policy / Renewal Window / Auto / Retries / Alert Thresholds / Created / Actions) with Create, Edit, and Delete affordances. A shared `PolicyFormModal` powers both Create and Edit (the form shape is identical) covering the full domain field set: `name`, `renewal_window_days`, `auto_renew`, `max_retries`, `retry_interval_seconds`, `alert_thresholds_days[]`. The thresholds input parses comma-separated integers (`30, 14, 7, 0`) into the array shape the backend expects. Delete surfaces `repository.ErrRenewalPolicyInUse` (409 from the backend when a policy still has `managed_certificates.renewal_policy_id` references) via an explicit alert so the operator can re-target the dependent certs to a different policy before deletion. Wired into `web/src/main.tsx` routing and `web/src/components/Layout.tsx` sidebar nav.
- **EditOwnerModal** in `web/src/pages/OwnersPage.tsx` — pre-populates from the editing owner via `useEffect`, calls `updateOwner(id, {name, email, team_id})`, mirrors the Create modal's TanStack-Query mutation/invalidation pattern.
- **EditTeamModal** in `web/src/pages/TeamsPage.tsx` — same shape, fields `name`/`description`.
- **EditAgentGroupModal** in `web/src/pages/AgentGroupsPage.tsx` — covers the full match-rule set (`name`, `description`, `match_os`, `match_architecture`, `match_ip_cidr`, `match_version`, `enabled`).
- **EditIssuerModal** in `web/src/pages/IssuersPage.tsx` — deliberately rename-only. The `type` field is shown but disabled, the existing `config` blob (which includes credentials for ACME, ADCS, ZeroSSL, etc.) is forwarded untouched, and only `name` is editable. Footer note: "To change issuer type or rotate credentials, delete and recreate." This trades scope for safety — the audit's destructive-rename complaint is closed without surfacing a credential-edit attack surface that has not been threat-modeled.
- **EditProfileModal** in `web/src/pages/ProfilesPage.tsx` — same rename-only shape. Forwards full `Partial<CertificateProfile>` with policy fields (`allowed_key_algorithms`, `max_ttl_seconds`, `allowed_ekus`, etc.) preserved untouched. Footer note about deferred policy-field editing.
- **CI regression guardrail** in `.github/workflows/ci.yml` (`Forbidden orphan-CRUD client function regression guard (B-1)`) — grep-fails the build if any of the eight previously-orphan client functions (`updateOwner`, `updateTeam`, `updateAgentGroup`, `updateIssuer`, `updateProfile`, `createRenewalPolicy`, `updateRenewalPolicy`, `deleteRenewalPolicy`) loses its non-test consumer under `web/src/pages/`. Also blocks resurrection of the deleted `exportCertificatePEM` function. Verified locally on the post-fix tree (passes — all 8 fns have ≥2 consumers); fires against synthetic regressions (delete the Edit modal → guardrail fires the next CI run).
### Removed
- `web/src/api/client.ts::exportCertificatePEM` — closes `cat-b-9b97ffb35ef7`. The function returned `{cert_pem, chain_pem, full_pem}` JSON but had zero consumers across `web/`, MCP, CLI, and tests; `downloadCertificatePEM` (the blob-download path consumed by `CertificateDetailPage`) covers all real call sites. Test references in `web/src/api/client.test.ts` and `client.error.test.ts` were also removed. The CI guardrail blocks resurrection without an accompanying page consumer.
### Audit findings closed
- `cat-b-31ceb6aaa9f1` (P1, `updateOwner`/`updateTeam`/`updateAgentGroup` orphan)
- `cat-b-7a34f893a8f9` (P1, `updateIssuer`/`updateProfile` orphan, rename-only closure)
- `cat-b-4631ca092bee` (P1, RenewalPolicy CRUD orphan — new RenewalPoliciesPage)
- `cat-b-9b97ffb35ef7` (P3, `exportCertificatePEM` dead duplicate)
### Known follow-ups (deferred from B-1 scope)
A fuller `EditIssuerModal` with explicit credential-rotation flow is deferred — that needs an explicit threat model (rotation reuse window, audit-trail granularity, in-flight CSR cancellation), and the audit's destructive-rename complaint is closed by rename-only Edit alone. Likewise an `EditProfileModal` with policy-field editing (max-TTL, allowed EKUs, allowed key algorithms) is deferred because policy edits affect the `enforce_certificate_policy` evaluator's semantics for already-issued certs and warrant their own scope. Per-page Vitest coverage for the new Edit modals is deferred — the CI grep guardrail catches the same regression vector ("page lost its `update*` fn consumer") at lower cost than five new test files.
### L-1: Client-side bulk-action loops — closed end-to-end
> The certctl dashboard's busiest screen (`CertificatesPage.tsx`) had two bulk-action workflows that looped per-cert HTTP calls. Selecting 100 certs and clicking "Renew" issued 100 sequential `POST /api/v1/certificates/{id}/renew` requests; "Reassign owner" issued 100 sequential `PUT /api/v1/certificates/{id}` requests. Each round-trip carried ~50200 ms of Auth → audit-log → handler → service → repo → DB → audit-write → response, so a 100-cert bulk action was a 520-second wedge during which the operator stared at a progress bar. The bulk-revoke endpoint (`POST /api/v1/certificates/bulk-revoke`) already shipped in v2.0.x as the canonical pattern for this; L-1 ports that exact shape to bulk-renew (P1) and bulk-reassign (P2). One backend round-trip; one audit event for the entire operation; per-cert success/skip/error counts in a single response envelope. Bundled with two new MCP tools and an OpenAPI spec update so non-GUI callers (CLI / MCP / blackbox probes) can use the same endpoints.
### Breaking Changes
None. Both endpoints are additive; the per-cert `POST /certificates/{id}/renew` and `PUT /certificates/{id}` paths remain available and unchanged. The frontend implementation switches from looping to single-call, but operators with custom GUIs hitting the per-cert endpoints continue to work.
### Added
- **`POST /api/v1/certificates/bulk-renew`** — enqueues a renewal job for every matching managed certificate. Supports criteria-mode (`{profile_id, owner_id, agent_id, issuer_id, team_id}`) and explicit-IDs mode (`{certificate_ids}`). Mirrors `BulkRevokeCriteria` field-for-field (sans the RFC-5280 reason code). Returns `{total_matched, total_enqueued, total_skipped, total_failed, enqueued_jobs[], errors[]}`. NOT admin-gated — bulk renewal is non-destructive (worst case it kicks off some redundant ACME orders). Status filter: certs in `Archived/Revoked/Expired/RenewalInProgress` are silent-skipped (TotalSkipped++) rather than returned as errors. Implementation: `internal/domain/bulk_renewal.go`, `internal/service/bulk_renewal.go`, `internal/api/handler/bulk_renewal.go`.
- **`POST /api/v1/certificates/bulk-reassign`** — updates `owner_id` (required) and `team_id` (optional) on every cert in `certificate_ids`. Skips certs already owned by the target (silent no-op surfaced as `total_skipped`). Validates the target `owner_id` upfront — a non-existent owner returns 400 (via the typed `service.ErrBulkReassignOwnerNotFound` sentinel) before any cert is touched. NOT admin-gated. Implementation: `internal/domain/bulk_reassignment.go`, `internal/service/bulk_reassignment.go`, `internal/api/handler/bulk_reassignment.go`.
- **MCP tools `certctl_bulk_renew_certificates` and `certctl_bulk_reassign_certificates`** in `internal/mcp/tools.go` + `internal/mcp/types.go`. Mirror the existing `certctl_bulk_revoke_certificates` shape so MCP consumers have a uniform bulk-action surface.
- **OpenAPI schemas** `BulkRenewRequest`, `BulkRenewResult`, `BulkEnqueuedJob`, `BulkReassignRequest`, `BulkReassignResult` plus the two new operations with shared envelope semantics.
- **Frontend client functions** `bulkRenewCertificates(criteria)` and `bulkReassignCertificates(request)` in `web/src/api/client.ts` with full TS types for both request and response envelopes.
- **Service-layer regression tests** for both new services (`internal/service/bulk_renewal_test.go` + `internal/service/bulk_reassignment_test.go`): happy path, criteria-mode, status-skip semantics (RenewalInProgress / Revoked / Archived for renew; already-owned for reassign), empty-criteria rejection, partial-failure tolerance, single-bulk-audit-event contract.
- **Handler-layer regression tests** (`internal/api/handler/bulk_renewal_handler_test.go` + `internal/api/handler/bulk_reassignment_handler_test.go`): happy path, empty-body 400, wrong-method 405, actor attribution from `middleware.GetUser`, owner-not-found-sentinel-→-400 mapping for reassign, generic-service-error-→-500.
- **Domain-layer JSON-shape tests** pinning the wire contract for `BulkRenewalResult` / `BulkReassignmentResult` / `BulkOperationError`.
- **CI regression guardrail** in `.github/workflows/ci.yml` (`Forbidden client-side bulk-action loop regression guard (L-1)`) — grep-fails the build if `for(...) await triggerRenewal(...)` or `for(...) await updateCertificate(...)` reappears in `web/src/pages/CertificatesPage.tsx`. Verified: passes against the post-fix tree, fires against synthetic regressions.
### Changed
- **`web/src/pages/CertificatesPage.tsx::handleBulkRenewal`** — rewritten from N-call loop to a single `bulkRenewCertificates({ certificate_ids })` call. Result envelope drives the progress UI (matched / enqueued / skipped / failed counts).
- **`web/src/pages/CertificatesPage.tsx::handleReassign`** (in the reassign modal) — same shape: single `bulkReassignCertificates({ certificate_ids, owner_id })` call. First-error message surfaced when `total_failed > 0`.
- **`internal/api/router/router.go`** — three bulk-* routes (revoke / renew / reassign) registered together as a block before the per-cert `{id}` routes; `HandlerRegistry` gains `BulkRenewal` and `BulkReassignment` fields.
- **`cmd/server/main.go`** — constructs `BulkRenewalService` (threads `cfg.Keygen.Mode` so bulk-renew jobs land in the same initial status as single-cert `TriggerRenewal`) and `BulkReassignmentService` alongside the existing `BulkRevocationService`.
### Performance impact
100-cert bulk-renew workflow goes from ~10 s of sequential per-cert HTTP (worst case) to a single ~100 ms call — roughly 99% latency reduction on the canonical operator workflow. Server-side resource use also drops: one Auth pass, one audit event, one criteria-resolution query, instead of N of each.
### Closed audit findings
- `cat-l-fa0c1ac07ab5` (P1, primary) — bulk renew client-side sequential loop
- `cat-l-8a1fb258a38a` (P2) — bulk owner-reassign client-side sequential loop
### Known follow-ups (deferred from L-1 scope)
- `cat-b-31ceb6aaa9f1` (P1, `updateOwner`/`updateTeam`/`updateAgentGroup` orphan) — different shape; the fix is "wire up the existing PUT endpoints to the GUI", not "add a bulk endpoint".
- `cat-k-e85d1099b2d7` (P2, CertificatesPage no pagination UI) — same page; criteria-mode bulk-renew (`{owner_id: 'o-alice'}`) means an operator can already "renew all of Alice's certs" without paginating, but pagination is still wanted for the table view.
- `cat-i-b0924b6675f8` (P1, MCP missing `claim`/`dismiss`/`acknowledge`) — L-1 added two new MCP tools but does NOT close that finding.
### D-1: StatusBadge enum drift + Certificate phantom fields — closed end-to-end
> The dashboard silently lied in five places. Agents in the `Degraded` state (the only Go-side AgentStatus that means "needs operator attention") rendered as default neutral grey because StatusBadge mapped `Stale` (a key Go has never emitted) to yellow and let the real `Degraded` value fall through to the dictionary default. Dead-letter notifications (`status: 'dead'`, retries exhausted) rendered as default neutral, visually equated with `read` (operator-acknowledged). The Certificate badge map carried a `PendingIssuance` key that no Go enum value ever emits — dead key, latent confusion vector. CertificateDetailPage's Key Algorithm and Key Size rows always rendered `—` even when the data was a single fetch away, because the lookup went through `cert.key_algorithm` directly — and the underlying `Certificate` TypeScript interface declared five optional fields (`serial_number`, `fingerprint_sha256`, `key_algorithm`, `key_size`, `issued_at`) that Go's `ManagedCertificate` has never carried (those values live on `CertificateVersion`). Five findings, two files, one frontend rebuild. Pre-D-1 the only reason this didn't trip a regression suite was that the regression suite never asserted "every Go-emitted enum value gets a non-default StatusBadge class" — D-1 fixes the visual lies and adds a 38-case Vitest property test that walks every Go enum and pins the contract.
### Breaking Changes
- **`Certificate` TypeScript interface no longer declares `serial_number?`, `fingerprint_sha256?`, `key_algorithm?`, `key_size?`, or `issued_at?`.** The Go `ManagedCertificate` (`internal/domain/certificate.go`) has never emitted these fields on list responses; they live on `CertificateVersion` and are reachable via `getCertificateVersions(id)`. Pre-D-5 (the cat-f phantom-fields finding) the optional declarations made `cert.X` always-undefined on lists, and downstream consumers silently rendered `—` for every cert. Post-D-5 a `cert.X` access for any of the five fields is a TypeScript compile error, forcing every consumer to acknowledge the version-fallback pattern. The OpenAPI `ManagedCertificate` schema was already correct — only the TS type was drifted.
- **StatusBadge no longer maps `Stale` (Agent) or `PendingIssuance` (Certificate).** Both were dead keys — no Go enum value emits them. Operators with custom CSS hooked off `.badge-warning` for `Stale` will see the same color come back via the new `Degraded` mapping (same class), but JS/TS code that switches on the literal `'Stale'` will need to switch on `'Degraded'` instead. The `PendingIssuance` deletion has no documented downstream consumer.
### Added
- **`web/src/components/StatusBadge.tsx`: `Degraded` (Agent) → `badge-warning` and `dead` (Notification) → `badge-danger`.** First mappings restore the color contract for the two real Go-side values that previously fell through to the dictionary default. The `Degraded` mapping cross-references `internal/domain/connector.go::AgentStatusDegraded`; the `dead` mapping cross-references `internal/domain/notification.go::NotificationStatusDead`.
- **`web/src/components/StatusBadge.test.tsx`: 38-case Vitest property test.** Iterates every Go-side enum value (`AgentStatus`, `CertificateStatus`, `JobStatus`, `NotificationStatus`, `DiscoveryStatus`, `HealthStatus`) plus the two frontend-synthesized `Enabled`/`Disabled` labels, asserts every value gets a non-default class (or, for the five intentionally-neutral terminal values like `Archived`/`Cancelled`/`read`, an explicit `badge badge-neutral`). Includes negative assertions on the deleted `Stale` and `PendingIssuance` keys (must fall through to neutral) and specific UX-correctness assertions on the operator-attention semantics (`dead` → danger, `Degraded` → warning).
- **`web/src/api/types.test.ts`: D-5 Certificate phantom-fields trim regression.** A `Certificate` literal construction pinned post-trim, plus a sibling `CertificateVersion` literal pinning that the trimmed fields still live on the version envelope. The `tsc --noEmit` gate in CI is the primary enforcement; the test is the documentation of intent.
- **CI regression guardrail in `.github/workflows/ci.yml` (`Forbidden StatusBadge dead-key + Certificate phantom-field regression guard (D-1)`).** Two grep blocks: (1) catches `Stale: 'badge-...'` or `PendingIssuance: 'badge-...'` in `web/src/components/StatusBadge.tsx`; (2) uses an awk-scoped window over the `export interface Certificate {` block in `web/src/api/types.ts` to catch any of the five phantom fields reappearing — explicitly excludes the `CertificateVersion` block which legitimately carries them. Verified locally on the post-fix tree (passes) and against synthetic regressions (each fires the guardrail).
### Changed
- **`web/src/pages/CertificateDetailPage.tsx`: Key Algorithm and Key Size rows now read from `latestVersion?.key_algorithm` / `latestVersion?.key_size`.** Mirrors the existing `latestVersion` fallback used for `serial_number` and `fingerprint_sha256` earlier in the same file. Pre-D-4 these rows accessed `cert.key_algorithm` and `cert.key_size` directly — both phantom fields per D-5 — so the rows always rendered `—`. The same file's `serial_number` / `fingerprint_sha256` / `issued_at` derivations were also simplified to drop the now-impossible `cert.X || latestVersion?.X` cert-side leg.
- **`web/src/components/StatusBadge.tsx` adds a leading docblock** naming the Go-side source-of-truth file for every status family it maps (`AgentStatus`, `CertificateStatus`, `JobStatus`, `NotificationStatus`, `DiscoveryStatus`, `HealthStatus`) and pointing at the property test as the regression vector for future enum changes.
- **`api/openapi.yaml::ManagedCertificate`** gets a leading comment cross-referencing the D-5 closure and explaining why per-issuance fields legitimately don't appear here (they live on `CertificateVersion`). Schema property list unchanged — the OpenAPI spec was already correct.
### Closed audit findings
- `cat-d-359e92c20cbf` (P1 primary) — Agent: `Stale` dead key + `Degraded` neutral fallthrough
- `cat-d-9f4c8e4a91f1` (P2) — Notification: `dead` missing
- `cat-d-1447e04732e7` (P3) — Certificate: `PendingIssuance` dead key
- `cat-f-cert_detail_page_key_render_fallback` (P2) — render-site uses `cert.key_algorithm` directly
- `cat-f-ae0d06b6588f` (P2) — Certificate TS phantom fields (root cause)
### Known follow-ups (deferred from D-1 scope)
The audit's broader type-drift cluster (`diff-05x06-7cdf4e78ae24` Agent TS, `diff-05x06-2044a46f4dd0` DeploymentTarget TS, `diff-05x06-caba9eb3620e` Notification TS, `diff-05x06-85ab6b98a2f7` DiscoveredCertificate TS, `diff-05x06-97fab8783a5c` Issuer TS) is out of D-1 scope. Recon for those is per-type field-by-field diff Go ↔ TS — codegen-shaped, not edit-shaped — and warrants its own D-2 master prompt.
### U-3: GitHub #10 reopened — fresh-clone first-up postgres init failure (P1) — closed end-to-end
> Operator `mikeakasully` cloned v2.0.50 fresh, ran the canonical quickstart `docker compose -f deploy/docker-compose.yml up -d --build`, and postgres reported `unhealthy` indefinitely; dependent containers (certctl-server, certctl-agent) never started. Root cause: the deploy compose stack mounted both a hand-curated subset of `migrations/*.up.sql` and `seed.sql` into postgres `/docker-entrypoint-initdb.d/`. Postgres applied them at initdb time. Once `seed.sql` referenced columns added by migrations *after* the mounted cutoff (e.g., `policy_rules.severity` from migration 000013, which the mount list never included), initdb crashed mid-seed and the container loop wedged. Two sources of truth — the mount list and the in-tree migration ladder — diverged the moment a seed-touching migration shipped, and the only thing that fixed it was hand-editing the compose file every release. The U-3 closure removes the dual source: postgres now boots empty and the server applies the entire migration ladder + seed at startup via `RunMigrations` + `RunSeed`. Same pattern Helm has used since day one. Bundled with four ride-along audit findings whose fixes are in adjacent code (column rename, missing column, dropped orphan columns, new build-identity endpoint) so operators take the schema-change pain only once.
### Breaking Changes
- **`deploy/docker-compose.yml` postgres no longer initdb-mounts the migration files or `seed.sql`.** Operators running on a populated `postgres_data` volume from a pre-U-3 release see no behavioral change (the schema is already in place; `RunMigrations` is `IF NOT EXISTS` and `RunSeed` is `ON CONFLICT DO NOTHING`). Operators running on a *fresh* clone now rely on the server to apply both — which is the bug fix. There is no rollback path other than re-introducing the dual-source-of-truth hazard. See `internal/repository/postgres/db.go::RunSeed` for the runtime contract.
- **`migrations/000017_db_coupling_cleanup.up.sql` renames `renewal_policies.retry_interval_minutes``retry_interval_seconds`.** The column always held seconds; the column name lied (`cat-o-retry_interval_unit_mismatch`). Operators running raw SQL against the old name need to update their queries. The Go layer (`internal/repository/postgres/renewal_policy.go`) is updated in lockstep so the in-tree code path is unaffected.
- **`migrations/000017_db_coupling_cleanup.up.sql` drops `network_scan_targets.health_check_enabled` and `network_scan_targets.health_check_interval_seconds`.** These columns were declared by a long-ago migration but never wired into Go code (`cat-o-health_check_column_orphans`) — schema noise that confused operators reading raw SQL. Anyone with custom dashboards selecting those columns will break.
- **The compose demo overlay (`deploy/docker-compose.demo.yml`) no longer initdb-mounts `seed_demo.sql`.** It now sets `CERTCTL_DEMO_SEED=true` and the server applies the demo seed at boot via `RunDemoSeed` after baseline migrations + seed.sql are in place. Same single-source-of-truth pattern as the production path.
### Added
- **Migration `000017_db_coupling_cleanup`** (up + down). Bundles three schema changes in idempotent SQL: (1) rename `renewal_policies.retry_interval_minutes``retry_interval_seconds` (DO $$ guard so re-application is safe), (2) add `notification_events.created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()`, (3) drop the orphan `network_scan_targets.health_check_*` columns. Reduces operator-visible "schema-change releases" from four to one.
- **`internal/repository/postgres.RunSeed`** — runtime equivalent of the deleted initdb mount for `seed.sql`. Called from `cmd/server/main.go` immediately after `RunMigrations`. Idempotent (every INSERT in the shipped seed uses `ON CONFLICT (id) DO NOTHING`); missing-file is a no-op so operators with custom packaging that strips the seed don't break.
- **`internal/repository/postgres.RunDemoSeed`** + **`config.DatabaseConfig.DemoSeed`** + **`CERTCTL_DEMO_SEED` env var.** Replaces the deleted `seed_demo.sql` initdb mount. The compose demo overlay sets `CERTCTL_DEMO_SEED=true` and the server applies the demo seed after baseline. Same idempotency contract as the baseline path. Default-off so a vanilla deploy never lands fake-history rows.
- **`GET /api/v1/version` endpoint** + **`internal/api/handler.VersionHandler`**. Returns `{version, commit, modified, build_time, go_version}` from `runtime/debug.ReadBuildInfo()` with ldflags-supplied `Version` taking priority. Wired through the no-auth dispatch in `cmd/server/main.go` so probes and rollout systems can read build identity without Bearer credentials. Audit middleware excludes the path so rollout polls don't dominate the audit trail. Closes `cat-u-no_version_endpoint`.
- **`notification_events.created_at` column** is now populated by `NotificationRepository.Create` (with a `time.Now()` fallback when the caller leaves it zero) and read back by `scanNotification`. Pre-U-3 the JSON API serialised `0001-01-01T00:00:00Z` — closes `cat-o-notification_created_at_dead_field`.
- **Five regression tests** for the U-3 contract: `TestRunSeed_AppliesIdempotently`, `TestRunSeed_MissingFileIsNoOp`, `TestRunDemoSeed_AppliesIdempotently`, `TestMigration000017_RetryIntervalRename`, `TestMigration000017_NotificationCreatedAt`, `TestMigration000017_HealthCheckOrphansDropped`, plus `TestNotificationRepository_CreatedAt_IsPersisted` / `TestNotificationRepository_CreatedAt_DefaultsToNow` for the round-trip. All testcontainers-gated (skipped under `-short`). Three handler-layer unit tests pin `/api/v1/version` (`TestVersion_ReturnsBuildInfo`, `TestVersion_RejectsNonGet`, `TestVersion_LdflagsOverride`).
- **CI regression guardrail** in `.github/workflows/ci.yml` (`Forbidden migration mount in compose initdb (U-3)`) — grep-fails the build if any `migrations/.*\.sql` or `seed.*\.sql` file is re-mounted into `/docker-entrypoint-initdb.d` in any compose file. Catches future drift before a fresh-clone operator hits it.
### Changed
- **`deploy/docker-compose.yml`** + **`deploy/docker-compose.test.yml`** — postgres `volumes:` no longer mount migrations or seed files; postgres healthcheck gains `start_period: 30s`; certctl-server healthcheck gains `start_period: 30s` to absorb the runtime migration + seed application window on first boot.
- **`deploy/docker-compose.demo.yml`** — replaces the `seed_demo.sql` initdb mount with the `CERTCTL_DEMO_SEED=true` env var on `certctl-server`.
- **`migrations/seed.sql`** — `INSERT INTO renewal_policies` updated to use the new `retry_interval_seconds` column name (lockstep with migration 000017).
- **`internal/repository/postgres/renewal_policy.go`** — column references updated to `retry_interval_seconds` across SELECT, INSERT, and UPDATE sites (lockstep with migration 000017).
### Closed audit findings
- `cat-u-seed_initdb_schema_drift` (P1, primary U-3 finding)
- `cat-o-retry_interval_unit_mismatch` (P1)
- `cat-o-notification_created_at_dead_field` (P2)
- `cat-o-health_check_column_orphans` (P1)
- `cat-u-no_version_endpoint` (P2)
### G-1: JWT silent auth downgrade — closed end-to-end ### G-1: JWT silent auth downgrade — closed end-to-end
+226
View File
@@ -163,6 +163,50 @@ paths:
"401": "401":
description: Unauthorized description: Unauthorized
/api/v1/version:
get:
tags: [Health]
summary: Build identity (version, commit, Go runtime)
description: |
Returns the running server's build identity. Served without
auth so rollout systems and blackbox probes can read it without
Bearer credentials. U-3 ride-along (cat-u-no_version_endpoint).
Excluded from audit logging because rollout polling would
otherwise dominate the audit trail.
The Version field follows a fallback ladder: ldflags-supplied
value > VCS commit SHA > "dev". Commit / Modified / BuildTime
come from runtime/debug.BuildInfo (Go 1.18+ stamps these on
every module-tracked build). GoVersion is runtime.Version().
security: []
operationId: getVersion
responses:
"200":
description: Build identity
content:
application/json:
schema:
type: object
required: [version, commit, modified, build_time, go_version]
properties:
version:
type: string
description: Release tag (ldflags-supplied) or VCS SHA fallback or "dev"
example: v2.0.51
commit:
type: string
description: Git SHA from runtime/debug.BuildInfo (vcs.revision); empty when not VCS-tracked
modified:
type: boolean
description: True when build had uncommitted changes (vcs.modified)
build_time:
type: string
description: RFC 3339 build timestamp (vcs.time); empty when not VCS-tracked
go_version:
type: string
description: Go toolchain version that compiled the binary (runtime.Version())
example: go1.25.9
# ─── Certificates ──────────────────────────────────────────────────── # ─── Certificates ────────────────────────────────────────────────────
/api/v1/certificates: /api/v1/certificates:
get: get:
@@ -426,6 +470,69 @@ paths:
"500": "500":
$ref: "#/components/responses/InternalError" $ref: "#/components/responses/InternalError"
/api/v1/certificates/bulk-renew:
post:
tags: [Certificates]
summary: Bulk renew certificates by criteria or explicit IDs
description: |
Enqueues a renewal job for every matching managed certificate. Mirrors POST
/api/v1/certificates/bulk-revoke shape exactly so operators who already know
that contract have zero new surface to learn. L-1 closure
(cat-l-fa0c1ac07ab5): pre-L-1 the GUI looped per-cert HTTP calls;
post-L-1 it's a single POST. Status filter: certs in
Archived/Revoked/Expired/RenewalInProgress are silent-skipped (TotalSkipped++)
rather than returned as errors. Asynchronous: the action ENQUEUES jobs the
scheduler picks up; per-cert {certificate_id, job_id} pairs are returned in
enqueued_jobs. NOT admin-gated — bulk renewal is non-destructive.
operationId: bulkRenewCertificates
requestBody:
required: true
content:
application/json:
schema:
$ref: "#/components/schemas/BulkRenewRequest"
responses:
"200":
description: Bulk renewal result
content:
application/json:
schema:
$ref: "#/components/schemas/BulkRenewResult"
"400":
$ref: "#/components/responses/BadRequest"
"500":
$ref: "#/components/responses/InternalError"
/api/v1/certificates/bulk-reassign:
post:
tags: [Certificates]
summary: Bulk reassign owner (and optionally team) for a set of certificates
description: |
Updates owner_id (required) and team_id (optional) on every certificate in
certificate_ids. Skips certs already owned by the target (silent no-op,
TotalSkipped++). L-2 closure (cat-l-8a1fb258a38a). Narrower than bulk-renew:
explicit IDs only, no criteria-mode. The OwnerID is validated upfront — a
non-existent owner returns 400 before any cert is touched. Verb chosen as
POST (not PATCH) for codebase consistency with bulk-revoke and bulk-renew.
operationId: bulkReassignCertificates
requestBody:
required: true
content:
application/json:
schema:
$ref: "#/components/schemas/BulkReassignRequest"
responses:
"200":
description: Bulk reassignment result
content:
application/json:
schema:
$ref: "#/components/schemas/BulkReassignResult"
"400":
$ref: "#/components/responses/BadRequest"
"500":
$ref: "#/components/responses/InternalError"
# ─── Certificate Export ────────────────────────────────────────────── # ─── Certificate Export ──────────────────────────────────────────────
/api/v1/certificates/{id}/export/pem: /api/v1/certificates/{id}/export/pem:
get: get:
@@ -3448,6 +3555,15 @@ components:
- Archived - Archived
ManagedCertificate: ManagedCertificate:
# D-5 (cat-f-ae0d06b6588f, master): per-issuance fields
# (serial_number, fingerprint_sha256, key_algorithm, key_size,
# issued_at) are intentionally NOT declared here. They live on
# CertificateVersion (per-issuance evidence) and are fetched via
# /api/v1/certificates/{id}/versions. ManagedCertificate is the
# management envelope; CertificateVersion is the issuance record.
# Pre-D-5 the TS Certificate interface had them as optional and
# the dashboard's Key Algorithm / Key Size rows always rendered
# '—' as a result. The TS trim restores parity with this schema.
type: object type: object
properties: properties:
id: id:
@@ -3604,6 +3720,116 @@ components:
type: string type: string
description: Per-certificate error details for failed revocations description: Per-certificate error details for failed revocations
# L-1 master closure (cat-l-fa0c1ac07ab5 + cat-l-8a1fb258a38a):
# bulk-renew + bulk-reassign request/result schemas. Mirror
# BulkRevokeRequest/Result envelope shape so frontend bulk-result
# rendering is one helper. See internal/domain/bulk_renewal.go +
# internal/domain/bulk_reassignment.go for the Go-side source of
# truth.
BulkRenewRequest:
type: object
description: Criteria for bulk renewal. At least one selector required.
properties:
profile_id:
type: string
description: Renew all certificates matching this profile
owner_id:
type: string
description: Renew all certificates owned by this owner
agent_id:
type: string
description: Renew all certificates deployed via this agent
issuer_id:
type: string
description: Renew all certificates issued by this issuer
team_id:
type: string
description: Renew all certificates owned by members of this team
certificate_ids:
type: array
items:
type: string
description: Explicit list of certificate IDs to renew
BulkEnqueuedJob:
type: object
properties:
certificate_id:
type: string
job_id:
type: string
description: ID of the renewal job created for this certificate
BulkRenewResult:
type: object
properties:
total_matched:
type: integer
description: Number of certificates matching the criteria
total_enqueued:
type: integer
description: Number of renewal jobs successfully created
total_skipped:
type: integer
description: Certs already RenewalInProgress / Revoked / Archived / Expired (silent no-op)
total_failed:
type: integer
description: Number of certificates whose enqueue path returned an error
enqueued_jobs:
type: array
items:
$ref: "#/components/schemas/BulkEnqueuedJob"
description: Per-certificate {certificate_id, job_id} pairs for the successful enqueue path
errors:
type: array
items:
type: object
properties:
certificate_id:
type: string
error:
type: string
description: Per-certificate error details for the failure path
BulkReassignRequest:
type: object
required: [certificate_ids, owner_id]
properties:
certificate_ids:
type: array
items:
type: string
description: Explicit list of certificate IDs to reassign
owner_id:
type: string
description: Required. New owner_id for every cert in certificate_ids.
team_id:
type: string
description: Optional. When non-empty, also updates team_id on every cert.
BulkReassignResult:
type: object
properties:
total_matched:
type: integer
total_reassigned:
type: integer
description: Number of certs whose owner_id (and optionally team_id) was actually mutated
total_skipped:
type: integer
description: Certs already owned by the target (silent no-op)
total_failed:
type: integer
errors:
type: array
items:
type: object
properties:
certificate_id:
type: string
error:
type: string
# ─── Issuers ───────────────────────────────────────────────────── # ─── Issuers ─────────────────────────────────────────────────────
IssuerType: IssuerType:
type: string type: string
+112 -8
View File
@@ -86,6 +86,41 @@ func main() {
} }
logger.Info("migrations completed") logger.Info("migrations completed")
// Apply baseline seed data.
//
// U-3 (P1, cat-u-seed_initdb_schema_drift): pre-U-3 seed.sql was mounted
// into postgres `/docker-entrypoint-initdb.d/` alongside a hand-curated
// subset of migrations. Adding a migration that introduced a new column
// referenced by seed.sql (cat-o-retry_interval_unit_mismatch /
// policy_rules.severity / etc.) without also updating the compose volume
// mounts caused initdb to crash on first up. Post-U-3 the compose stack
// drops all initdb mounts; postgres comes up with empty schema, the
// server runs RunMigrations above, then this RunSeed call lands the
// baseline data — all from a single source of truth (this binary).
// See internal/repository/postgres/db.go::RunSeed for the contract.
logger.Info("applying baseline seed", "path", cfg.Database.MigrationsPath)
if err := postgres.RunSeed(db, cfg.Database.MigrationsPath); err != nil {
logger.Error("failed to apply seed data", "error", err)
os.Exit(1)
}
logger.Info("seed completed")
// Apply demo overlay seed when CERTCTL_DEMO_SEED=true. Pre-U-3 the demo
// overlay (deploy/docker-compose.demo.yml) mounted seed_demo.sql into
// postgres `/docker-entrypoint-initdb.d/`; that broke once U-3 dropped
// the initdb migration mounts (the demo seed references tables that
// wouldn't exist at initdb time). The runtime path here is the
// post-U-3 replacement. Default-off so a vanilla deploy never lands
// fake-history rows. See postgres.RunDemoSeed for the contract.
if cfg.Database.DemoSeed {
logger.Info("applying demo seed (CERTCTL_DEMO_SEED=true)", "path", cfg.Database.MigrationsPath)
if err := postgres.RunDemoSeed(db, cfg.Database.MigrationsPath); err != nil {
logger.Error("failed to apply demo seed data", "error", err)
os.Exit(1)
}
logger.Info("demo seed completed")
}
// Initialize repositories with real PostgreSQL connection // Initialize repositories with real PostgreSQL connection
auditRepo := postgres.NewAuditRepository(db) auditRepo := postgres.NewAuditRepository(db)
certificateRepo := postgres.NewCertificateRepository(db) certificateRepo := postgres.NewCertificateRepository(db)
@@ -376,6 +411,14 @@ func main() {
// Initialize bulk revocation service // Initialize bulk revocation service
bulkRevocationService := service.NewBulkRevocationService(revocationSvc, certificateRepo, auditService, logger) bulkRevocationService := service.NewBulkRevocationService(revocationSvc, certificateRepo, auditService, logger)
// L-1 master (cat-l-fa0c1ac07ab5 + cat-l-8a1fb258a38a): bulk-renew
// and bulk-reassign services. Mirror BulkRevocationService wiring so
// the construction site is co-located with the existing bulk endpoint.
// keygenMode is threaded so bulk-renew jobs land in the same initial
// status (AwaitingCSR vs Pending) as single-cert TriggerRenewal.
bulkRenewalService := service.NewBulkRenewalService(certificateRepo, jobRepo, auditService, logger, cfg.Keygen.Mode)
bulkReassignmentService := service.NewBulkReassignmentService(certificateRepo, ownerRepo, auditService, logger)
// Initialize stats and metrics services // Initialize stats and metrics services
statsService := service.NewStatsService(certificateRepo, jobRepo, agentRepo) statsService := service.NewStatsService(certificateRepo, jobRepo, agentRepo)
// I-005: wire the notification repository so DashboardSummary.NotificationsDead // I-005: wire the notification repository so DashboardSummary.NotificationsDead
@@ -406,6 +449,13 @@ func main() {
statsHandler := handler.NewStatsHandler(statsService) statsHandler := handler.NewStatsHandler(statsService)
metricsHandler := handler.NewMetricsHandler(statsService, time.Now()) metricsHandler := handler.NewMetricsHandler(statsService, time.Now())
healthHandler := handler.NewHealthHandler(cfg.Auth.Type) healthHandler := handler.NewHealthHandler(cfg.Auth.Type)
// U-3 ride-along (cat-u-no_version_endpoint, P2): the version handler
// answers GET /api/v1/version with build identity (ldflags Version,
// VCS commit/dirty/timestamp, Go runtime version). Wired through the
// no-auth dispatch + audit ExcludePaths below so probes and rollout
// systems can read it without Bearer credentials and without flooding
// the audit trail.
versionHandler := handler.NewVersionHandler()
discoveryHandler := handler.NewDiscoveryHandler(discoveryService) discoveryHandler := handler.NewDiscoveryHandler(discoveryService)
networkScanHandler := handler.NewNetworkScanHandler(networkScanService) networkScanHandler := handler.NewNetworkScanHandler(networkScanService)
verificationService := service.NewVerificationService(jobRepo, auditService, logger) verificationService := service.NewVerificationService(jobRepo, auditService, logger)
@@ -414,6 +464,11 @@ func main() {
exportHandler := handler.NewExportHandler(exportService) exportHandler := handler.NewExportHandler(exportService)
bulkRevocationHandler := handler.NewBulkRevocationHandler(bulkRevocationService) bulkRevocationHandler := handler.NewBulkRevocationHandler(bulkRevocationService)
// L-1 master closure: handlers for the new bulk-renew + bulk-reassign
// endpoints. Both registered via HandlerRegistry below; dispatched
// through the standard authed middleware chain (no admin gate).
bulkRenewalHandler := handler.NewBulkRenewalHandler(bulkRenewalService)
bulkReassignmentHandler := handler.NewBulkReassignmentHandler(bulkReassignmentService)
// Initialize digest service (requires email notifier) // Initialize digest service (requires email notifier)
var digestService *service.DigestService var digestService *service.DigestService
@@ -490,6 +545,16 @@ func main() {
// because they share the NotificationServicer dependency (same placement // because they share the NotificationServicer dependency (same placement
// pattern as I-001's SetJobRetryInterval above). // pattern as I-001's SetJobRetryInterval above).
sched.SetNotificationRetryInterval(cfg.Scheduler.NotificationRetryInterval) sched.SetNotificationRetryInterval(cfg.Scheduler.NotificationRetryInterval)
// C-1 closure (cat-g-7e38f9708e20 + diff-10xmain-2bf4a0a60388): pre-C-1
// the SetShortLivedExpiryCheckInterval setter was defined + tested but
// never called from main.go, so the 30-second hardcoded default in
// scheduler.NewScheduler was effectively the only value. Operators
// running short-lived cert workloads with high churn (or low-churn
// workloads wanting to relax the cadence) had no working knob despite
// CERTCTL_SHORT_LIVED_EXPIRY_CHECK_INTERVAL being documented. Wire it
// here alongside the other scheduler-interval setters so the
// documented env var actually takes effect.
sched.SetShortLivedExpiryCheckInterval(cfg.Scheduler.ShortLivedExpiryCheckInterval)
if cfg.NetworkScan.Enabled { if cfg.NetworkScan.Enabled {
sched.SetNetworkScanInterval(cfg.NetworkScan.ScanInterval) sched.SetNetworkScanInterval(cfg.NetworkScan.ScanInterval)
logger.Info("network scanning enabled", "interval", cfg.NetworkScan.ScanInterval.String()) logger.Info("network scanning enabled", "interval", cfg.NetworkScan.ScanInterval.String())
@@ -553,7 +618,10 @@ func main() {
Export: exportHandler, Export: exportHandler,
Digest: *digestHandler, Digest: *digestHandler,
HealthChecks: healthCheckHandler, HealthChecks: healthCheckHandler,
BulkRevocation: bulkRevocationHandler, BulkRevocation: bulkRevocationHandler,
BulkRenewal: bulkRenewalHandler,
BulkReassignment: bulkReassignmentHandler,
Version: versionHandler,
}) })
// Register EST (RFC 7030) handlers if enabled // Register EST (RFC 7030) handlers if enabled
if cfg.EST.Enabled { if cfg.EST.Enabled {
@@ -683,6 +751,17 @@ func main() {
}) })
logger.Info("request body size limit enabled", "max_bytes", cfg.Server.MaxBodySize) logger.Info("request body size limit enabled", "max_bytes", cfg.Server.MaxBodySize)
// Security headers middleware — applies HSTS, X-Frame-Options,
// X-Content-Type-Options, Referrer-Policy, and a conservative CSP
// on every response. H-1 closure (cat-s11-missing_security_headers):
// pre-H-1 the server emitted zero security headers; an attacker
// could clickjack the dashboard, sniff MIME types on JSON/PEM
// responses, or load resources from arbitrary origins via inline
// scripts. Defaults are conservative — see internal/api/middleware/
// securityheaders.go::SecurityHeadersDefaults() for the rationale
// per header.
securityHeadersMiddleware := middleware.SecurityHeaders(middleware.SecurityHeadersDefaults())
// API audit log middleware — records every API call to the audit trail // API audit log middleware — records every API call to the audit trail
auditAdapter := middleware.NewAuditServiceAdapter( auditAdapter := middleware.NewAuditServiceAdapter(
func(ctx context.Context, actor string, actorType string, action string, resourceType string, resourceID string, details map[string]interface{}) error { func(ctx context.Context, actor string, actorType string, action string, resourceType string, resourceID string, details map[string]interface{}) error {
@@ -690,16 +769,22 @@ func main() {
}, },
) )
auditMiddleware := middleware.NewAuditLog(auditAdapter, middleware.AuditConfig{ auditMiddleware := middleware.NewAuditLog(auditAdapter, middleware.AuditConfig{
ExcludePaths: []string{"/health", "/ready"}, // /api/v1/version is excluded for the same reason /health and /ready
// are: rollout systems and blackbox probes hammer it on a tight
// interval, and the audit trail's value comes from rare,
// operator-authored mutations — not from sub-second readonly polls.
// U-3 ride-along (cat-u-no_version_endpoint, P2).
ExcludePaths: []string{"/health", "/ready", "/api/v1/version"},
Logger: logger, Logger: logger,
}) })
logger.Info("API audit logging enabled (excluding /health, /ready)") logger.Info("API audit logging enabled (excluding /health, /ready, /api/v1/version)")
middlewareStack := []func(http.Handler) http.Handler{ middlewareStack := []func(http.Handler) http.Handler{
middleware.RequestID, middleware.RequestID,
structuredLogger, structuredLogger,
middleware.Recovery, middleware.Recovery,
bodyLimitMiddleware, bodyLimitMiddleware,
securityHeadersMiddleware,
corsMiddleware, corsMiddleware,
authMiddleware, authMiddleware,
auditMiddleware.Middleware, auditMiddleware.Middleware,
@@ -746,13 +831,29 @@ func main() {
if _, err := os.Stat(webDir + "/index.html"); err != nil { if _, err := os.Stat(webDir + "/index.html"); err != nil {
webDir = "./web" webDir = "./web"
} }
// Health/ready routes bypass the full middleware stack (no auth required). // Health/ready routes + EST/SCEP/PKI unauth surface bypass the full
// These are registered on the inner router without auth, but the outer // middleware stack (no auth required). These are registered on the
// middleware chain wraps everything. Route them directly to the inner router. // inner router without auth, but the outer middleware chain wraps
// everything. Route them directly to the inner router.
//
// H-1 closure (cat-s5-4936a1cf0118): pre-H-1 the noAuthHandler chain
// was RequestID → structuredLogger → Recovery only — missing
// bodyLimitMiddleware that the authed apiHandler chain has. The
// unauth surface includes EST simpleenroll/simplereenroll (RFC 7030),
// SCEP, PKI CRL/OCSP (/.well-known/pki/*), and /health|/ready —
// every one of which accepts a request body. Without a body-size
// cap, an unauthenticated client can send arbitrary-size payloads
// (CSRs, CRL/OCSP requests) and trigger memory pressure on the
// server before the handler ever rejects the input. Post-H-1 the
// same bodyLimitMiddleware that wraps the authed surface also wraps
// the unauth surface — same default cap (CERTCTL_MAX_BODY_SIZE,
// default 1MB), same 413 response on overflow.
noAuthHandler := middleware.Chain(apiRouter, noAuthHandler := middleware.Chain(apiRouter,
middleware.RequestID, middleware.RequestID,
structuredLogger, structuredLogger,
middleware.Recovery, middleware.Recovery,
bodyLimitMiddleware,
securityHeadersMiddleware,
) )
dashboardEnabled := false dashboardEnabled := false
@@ -889,6 +990,7 @@ func preflightSCEPChallengePassword(enabled bool, challengePassword string) erro
// Dispatch rules (M-001, audit 2026-04-19, option D): // Dispatch rules (M-001, audit 2026-04-19, option D):
// //
// - /health, /ready, /api/v1/auth/info → no-auth (probes + login detection) // - /health, /ready, /api/v1/auth/info → no-auth (probes + login detection)
// - /api/v1/version → no-auth (U-3 ride-along: build identity for rollout/probes)
// - /.well-known/pki/* → no-auth (RFC 5280 CRL, RFC 6960 OCSP) // - /.well-known/pki/* → no-auth (RFC 5280 CRL, RFC 6960 OCSP)
// - /.well-known/est/* → no-auth (RFC 7030 §3.2.3) // - /.well-known/est/* → no-auth (RFC 7030 §3.2.3)
// - /scep, /scep/* → no-auth (RFC 8894 §3.2, CSR challengePassword) // - /scep, /scep/* → no-auth (RFC 8894 §3.2, CSR challengePassword)
@@ -914,10 +1016,12 @@ func buildFinalHandler(apiHandler, noAuthHandler http.Handler, webDir string, da
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
path := r.URL.Path path := r.URL.Path
// Health/ready and auth/info bypass auth middleware. // Health/ready, auth/info, and version bypass auth middleware.
// Health/ready: Docker/K8s health probes don't carry Bearer tokens. // Health/ready: Docker/K8s health probes don't carry Bearer tokens.
// auth/info: React app calls this before login to detect auth mode. // auth/info: React app calls this before login to detect auth mode.
if path == "/health" || path == "/ready" || path == "/api/v1/auth/info" { // version: U-3 ride-along (cat-u-no_version_endpoint) — rollout
// systems and blackbox probes need build identity without a key.
if path == "/health" || path == "/ready" || path == "/api/v1/auth/info" || path == "/api/v1/version" {
noAuthHandler.ServeHTTP(w, r) noAuthHandler.ServeHTTP(w, r)
return return
} }
+16 -4
View File
@@ -7,8 +7,20 @@
# To start fresh (wipe previous data): # To start fresh (wipe previous data):
# docker compose -f docker-compose.yml -f docker-compose.demo.yml down -v # docker compose -f docker-compose.yml -f docker-compose.demo.yml down -v
# docker compose -f docker-compose.yml -f docker-compose.demo.yml up --build # docker compose -f docker-compose.yml -f docker-compose.demo.yml up --build
#
# U-3 (P1, cat-u-seed_initdb_schema_drift): pre-U-3 this overlay mounted
# `seed_demo.sql` into postgres `/docker-entrypoint-initdb.d/`. That worked
# only because the production stack also mounted the migrations there, so
# the schema existed at initdb time. Once U-3 dropped the production
# initdb mounts (single source of truth: server runs RunMigrations + RunSeed
# at boot), the demo seed could no longer be applied at initdb time — the
# tables it references wouldn't exist yet.
#
# Post-U-3 the demo overlay just sets CERTCTL_DEMO_SEED=true; the server
# applies seed_demo.sql at boot via postgres.RunDemoSeed AFTER baseline
# migrations + seed.sql are in place. Same single source of truth, no
# initdb mounts, no schema-vs-seed drift.
services: services:
postgres: certctl-server:
volumes: environment:
- ../migrations/seed_demo.sql:/docker-entrypoint-initdb.d/030_seed_demo.sql CERTCTL_DEMO_SEED: "true"
+12 -13
View File
@@ -93,6 +93,17 @@ services:
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Database # Database
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
#
# U-3 (P1, cat-u-seed_initdb_schema_drift, GitHub #10): the test stack used
# to mount a hand-curated subset of migrations + seed.sql + a never-checked-in
# seed_test.sql into postgres `/docker-entrypoint-initdb.d/`. Same hazard as
# the production compose — initdb crashed any time a new migration shipped
# that the seed depended on without the mount list being updated. Post-U-3
# the schema is built EXCLUSIVELY by the server at startup via
# internal/repository/postgres.RunMigrations + RunSeed. Postgres comes up
# empty and the server lands the full ladder + baseline seed in one shot.
# `start_period: 30s` matches the production compose and shields slow CI
# runners from healthcheck flap during initdb.
postgres: postgres:
image: postgres:16-alpine image: postgres:16-alpine
container_name: certctl-test-postgres container_name: certctl-test-postgres
@@ -102,19 +113,6 @@ services:
POSTGRES_PASSWORD: testpass POSTGRES_PASSWORD: testpass
volumes: volumes:
- test_postgres_data:/var/lib/postgresql/data - test_postgres_data:/var/lib/postgresql/data
- ../migrations/000001_initial_schema.up.sql:/docker-entrypoint-initdb.d/001_schema.sql
- ../migrations/000002_agent_metadata.up.sql:/docker-entrypoint-initdb.d/002_agent_metadata.sql
- ../migrations/000003_certificate_profiles.up.sql:/docker-entrypoint-initdb.d/003_certificate_profiles.sql
- ../migrations/000004_agent_groups.up.sql:/docker-entrypoint-initdb.d/004_agent_groups.sql
- ../migrations/000005_revocation.up.sql:/docker-entrypoint-initdb.d/005_revocation.sql
- ../migrations/000006_discovery.up.sql:/docker-entrypoint-initdb.d/006_discovery.sql
- ../migrations/000007_network_discovery.up.sql:/docker-entrypoint-initdb.d/007_network_discovery.sql
- ../migrations/000008_verification.up.sql:/docker-entrypoint-initdb.d/008_verification.sql
- ../migrations/000009_issuer_config.up.sql:/docker-entrypoint-initdb.d/009_issuer_config.sql
- ../migrations/000010_target_config.up.sql:/docker-entrypoint-initdb.d/010_target_config.sql
- ../migrations/seed.sql:/docker-entrypoint-initdb.d/020_seed.sql
- ../migrations/seed_test.sql:/docker-entrypoint-initdb.d/025_seed_test.sql
# No seed_demo.sql — start with a clean database for real testing
networks: networks:
certctl-test: certctl-test:
ipv4_address: 10.30.50.2 ipv4_address: 10.30.50.2
@@ -125,6 +123,7 @@ services:
interval: 5s interval: 5s
timeout: 5s timeout: 5s
retries: 5 retries: 5
start_period: 30s
restart: unless-stopped restart: unless-stopped
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
+29 -11
View File
@@ -53,6 +53,29 @@ services:
- certctl-network - certctl-network
# PostgreSQL database # PostgreSQL database
#
# U-3 (P1, cat-u-seed_initdb_schema_drift, GitHub #10):
# Pre-U-3 this stack mounted a hand-curated subset of `migrations/*.up.sql`
# plus `seed.sql` into `/docker-entrypoint-initdb.d/`, and postgres
# initdb-applied them on first boot. The mount list rotted every time a
# new migration shipped that the seed depended on (000013 added
# policy_rules.severity, 000017 renames retry_interval_minutes, etc.) —
# initdb crashed, the container reported `unhealthy` indefinitely, and
# `docker compose -f deploy/docker-compose.yml up -d --build` from a
# fresh clone of v2.0.50 hit it on the first try.
#
# Post-U-3 the schema is built EXCLUSIVELY by the server at startup via
# internal/repository/postgres.RunMigrations + RunSeed. Single source of
# truth, no list to keep in sync. Postgres comes up empty; the server
# waits for it healthy, then applies the full migration ladder + seed in
# one shot. Helm + the dev examples were already runtime-only (Path B)
# and worked through the same window.
#
# `start_period: 30s` gives postgres room to bootstrap on slow runners
# (CI macOS, low-spec laptops) before the healthcheck failure counter
# starts ticking. Pre-U-3 a slow first-init combined with the
# `unhealthy` flap to cascade into certctl-server's `service_healthy`
# depends_on, blocking the whole stack.
postgres: postgres:
image: postgres:16-alpine image: postgres:16-alpine
container_name: certctl-postgres container_name: certctl-postgres
@@ -64,17 +87,6 @@ services:
- "5432:5432" - "5432:5432"
volumes: volumes:
- postgres_data:/var/lib/postgresql/data - postgres_data:/var/lib/postgresql/data
- ../migrations/000001_initial_schema.up.sql:/docker-entrypoint-initdb.d/001_schema.sql
- ../migrations/000002_agent_metadata.up.sql:/docker-entrypoint-initdb.d/002_agent_metadata.sql
- ../migrations/000003_certificate_profiles.up.sql:/docker-entrypoint-initdb.d/003_certificate_profiles.sql
- ../migrations/000004_agent_groups.up.sql:/docker-entrypoint-initdb.d/004_agent_groups.sql
- ../migrations/000005_revocation.up.sql:/docker-entrypoint-initdb.d/005_revocation.sql
- ../migrations/000006_discovery.up.sql:/docker-entrypoint-initdb.d/006_discovery.sql
- ../migrations/000007_network_discovery.up.sql:/docker-entrypoint-initdb.d/007_network_discovery.sql
- ../migrations/000008_verification.up.sql:/docker-entrypoint-initdb.d/008_verification.sql
- ../migrations/000009_issuer_config.up.sql:/docker-entrypoint-initdb.d/009_issuer_config.sql
- ../migrations/000010_target_config.up.sql:/docker-entrypoint-initdb.d/010_target_config.sql
- ../migrations/seed.sql:/docker-entrypoint-initdb.d/020_seed.sql
networks: networks:
- certctl-network - certctl-network
healthcheck: healthcheck:
@@ -82,6 +94,7 @@ services:
interval: 5s interval: 5s
timeout: 5s timeout: 5s
retries: 5 retries: 5
start_period: 30s
restart: unless-stopped restart: unless-stopped
# Certctl Server (API + scheduler) # Certctl Server (API + scheduler)
@@ -127,6 +140,11 @@ services:
interval: 10s interval: 10s
timeout: 5s timeout: 5s
retries: 5 retries: 5
# U-3: server boot now does RunMigrations + RunSeed before listening on
# 8443. On a fresh clone the full migration ladder + seed application
# can take ~10s on a small VM; start_period prevents the first few
# healthcheck attempts from counting as failures while that work runs.
start_period: 30s
restart: unless-stopped restart: unless-stopped
logging: logging:
driver: "json-file" driver: "json-file"
+7 -1
View File
@@ -149,6 +149,8 @@ The agent runs two background loops: a heartbeat (every 60 seconds) to signal it
Retired agents receive `410 Gone` on subsequent heartbeats (`service.ErrAgentRetired`). `cmd/agent` treats 410 as a terminal signal and exits cleanly so retired agents stop phoning home. Migration `000015` flipped `deployment_targets.agent_id` from `ON DELETE CASCADE` to `ON DELETE RESTRICT`, making the old hard-delete path a schema error and forcing all retirement through this contract. Retired agents receive `410 Gone` on subsequent heartbeats (`service.ErrAgentRetired`). `cmd/agent` treats 410 as a terminal signal and exits cleanly so retired agents stop phoning home. Migration `000015` flipped `deployment_targets.agent_id` from `ON DELETE CASCADE` to `ON DELETE RESTRICT`, making the old hard-delete path a schema error and forcing all retirement through this contract.
**Registration is by-design pull-only (C-1 closure, cat-b-6177f36636fb).** Agents register themselves at first heartbeat via `install-agent.sh` + `cmd/agent/main.go` — never via the GUI. The `web/src/api/client.ts::registerAgent` client function is intentionally orphan in the dashboard for this reason. It's preserved in `client.ts` (rather than deleted) so future features that want to drive registration from the GUI — for example, a one-click "register proxy agent" panel for network-appliance topologies where the agent runs in a different network zone from the device it manages — can reach the endpoint without a `client.ts` edit. Operators looking to scale agent enrollment use `install-agent.sh` against a config-management system (Ansible, Salt, Puppet) or a baked-in cloud-init script, not the dashboard.
### Web Dashboard ### Web Dashboard
The web dashboard is the primary operational interface for certctl. It is built with Vite + React + TypeScript and uses TanStack Query for server state management (caching, background refetching, optimistic updates). The web dashboard is the primary operational interface for certctl. It is built with Vite + React + TypeScript and uses TanStack Query for server state management (caching, background refetching, optimistic updates).
@@ -163,6 +165,10 @@ The dashboard includes an **ErrorBoundary component** for graceful error recover
- Light content area with branded dark teal sidebar, Inter + JetBrains Mono typography - Light content area with branded dark teal sidebar, Inter + JetBrains Mono typography
- SSE/WebSocket planned for real-time job status updates - SSE/WebSocket planned for real-time job status updates
**Backend ↔ frontend round-trip rule (B-1 closure):** every backend CRUD operation must have at least one GUI consumer in `web/src/pages/`. Shipping a handler + repository method + OpenAPI operation + `client.ts` fetcher with no page that calls it leaves operators forced to `psql` directly — defeats the "every backend feature ships with its GUI surface" invariant and creates a destructive workflow when the missing path is `update*` (operators delete-and-recreate, losing FK history and audit-trail continuity). The CI guardrail in `.github/workflows/ci.yml` (`Forbidden orphan-CRUD client function regression guard (B-1)`) enforces this for the eight previously-orphan functions (`updateOwner`/`updateTeam`/`updateAgentGroup`/`updateIssuer`/`updateProfile` + `createRenewalPolicy`/`updateRenewalPolicy`/`deleteRenewalPolicy`); apply the same rule when adding any new write endpoint. If a fetcher is needed in `client.ts` before its consumer page exists, leave a TODO referencing this rule and ship them in the same commit.
**TS ↔ Go type contract rule (D-1 + D-2 closure):** every TypeScript interface in `web/src/api/types.ts` must field-match the Go-side `internal/domain/*.go` struct's JSON-emitted shape exactly. Phantom fields (declared on TS, never emitted by Go) silently render `'—'` and lull consumers into thinking a value will arrive that never does; missing fields (emitted by Go, absent from TS) force `(x as any).X` escapes that lose type-checking. Both failure modes are blocked by the CI guardrail in `.github/workflows/ci.yml` (`Forbidden StatusBadge dead-key + TS phantom-field regression guard (D-1 + D-2)`) which awk-windows each interface and grep-fails the build on phantom-field reintroduction — currently covers Certificate (D-1), Agent / Issuer / Notification (D-2). Apply the same rule when adding any new on-wire type: the Go-side json tag is the contract, the TS interface adapts to it, and a literal-construction Vitest in `web/src/api/types.test.ts` pins the post-add shape. Stricter side wins: when in doubt, the side that actually emits the field is the contract; never propose adding a phantom on Go to match a TS over-declaration.
### PostgreSQL Database ### PostgreSQL Database
All state is stored in PostgreSQL 16. The schema uses TEXT primary keys (not UUIDs) with human-readable prefixed IDs like `mc-api-prod`, `t-platform`, `o-alice`. All state is stored in PostgreSQL 16. The schema uses TEXT primary keys (not UUIDs) with human-readable prefixed IDs like `mc-api-prod`, `t-platform`, `o-alice`.
@@ -353,7 +359,7 @@ The ER diagram above documents **database shape**, not REST-API wire shape. Seve
- `agents.api_key_hash` — SHA-256 of the agent's plaintext API key, populated by `service.RegisterAgent` (`hashAPIKey(apiKey)` at `internal/service/agent.go`) and consumed by `repository.AgentRepository::GetByAPIKey` for the auth-lookup. **Not** exposed via the REST API, **not** echoed via CLI / MCP / agent registration response, **never** logged. Enforced by `internal/domain/connector.go::Agent.MarshalJSON` (G-2 audit closure, `cat-s5-apikey_leak`); the OpenAPI Agent schema explicitly excludes the field, the frontend `Agent` interface omits it, and a CI grep guardrail at `.github/workflows/ci.yml` blocks reintroduction. - `agents.api_key_hash` — SHA-256 of the agent's plaintext API key, populated by `service.RegisterAgent` (`hashAPIKey(apiKey)` at `internal/service/agent.go`) and consumed by `repository.AgentRepository::GetByAPIKey` for the auth-lookup. **Not** exposed via the REST API, **not** echoed via CLI / MCP / agent registration response, **never** logged. Enforced by `internal/domain/connector.go::Agent.MarshalJSON` (G-2 audit closure, `cat-s5-apikey_leak`); the OpenAPI Agent schema explicitly excludes the field, the frontend `Agent` interface omits it, and a CI grep guardrail at `.github/workflows/ci.yml` blocks reintroduction.
- `issuers.config` / `deployment_targets.config` — plaintext jsonb shadow of the AES-GCM-encrypted on-disk blob; the encrypted form lives on `EncryptedConfig []byte` (Go-only field tagged `json:"-"`). - `issuers.config` / `deployment_targets.config` — plaintext jsonb shadow of the AES-GCM-encrypted on-disk blob; the encrypted form lives on `EncryptedConfig []byte` (Go-only field tagged `json:"-"`).
Migrations are idempotent (`IF NOT EXISTS` on all CREATE statements, `ON CONFLICT (id) DO NOTHING` on all seed data) so they're safe to run multiple times — important for Docker Compose where both initdb and the server may run the same SQL. Migrations are idempotent (`IF NOT EXISTS` on all CREATE statements, `ON CONFLICT (id) DO NOTHING` on all seed data) so they're safe to run multiple times. Pre-U-3 (`cat-u-seed_initdb_schema_drift`, GitHub #10) the deploy compose stack mounted both a hand-curated subset of `migrations/*.up.sql` and `seed.sql` into postgres `/docker-entrypoint-initdb.d/` so initdb applied them on first boot, *and* the server re-applied the same files via `RunMigrations` on every start. The dual source of truth was the bug: every time a migration shipped that the seed depended on (e.g., 000013 added `policy_rules.severity`), the mount list had to be updated by hand, and missing the update crashed initdb on first boot. Post-U-3 the server is the single source of truth: postgres comes up with an empty schema, `RunMigrations` applies the entire ladder, then `RunSeed` lands the baseline seed (and `RunDemoSeed` lands the demo overlay when `CERTCTL_DEMO_SEED=true`). Helm has used this pattern since day one (postgres-init `emptyDir`); the docker-compose deploy now matches.
## Data Flow: Certificate Lifecycle ## Data Flow: Certificate Lifecycle
+1 -1
View File
@@ -111,7 +111,7 @@ The full walkthrough — including profile-based issuer assignment, testing with
## Beyond These Examples ## Beyond These Examples
These 5 scenarios cover the most common deployment patterns, but certctl supports 7 issuer backends and 10 target connectors. Once you have the basics running, you can mix and match: These 5 scenarios cover the most common deployment patterns, but certctl supports a broader set of issuer and target backends — see `docs/features.md`'s Issuer Connectors and Target Connectors sections for the live catalogs (rebuild via `ls -d internal/connector/issuer/*/ | wc -l` and `ls -d internal/connector/target/*/ | wc -l`). Once you have the basics running, you can mix and match:
**Issuers:** ACME (Let's Encrypt, ZeroSSL, Buypass, Google Trust Services), Local CA (self-signed or sub-CA), step-ca, Vault PKI, DigiCert CertCentral, OpenSSL/Custom CA script, Sectigo (coming soon). **Issuers:** ACME (Let's Encrypt, ZeroSSL, Buypass, Google Trust Services), Local CA (self-signed or sub-CA), step-ca, Vault PKI, DigiCert CertCentral, OpenSSL/Custom CA script, Sectigo (coming soon).
+46 -27
View File
@@ -8,17 +8,30 @@ Complete reference of every feature shipped in certctl through v2.1.0 (April 202
| Metric | Count | | Metric | Count |
|---|---| |---|---|
| HTTP routes | 107 (103 under `/api/v1/` + 4 EST) | <!--
| OpenAPI 3.1 operations | 97 | S-1 master closure (cat-s1-9ce1cbe26876, cat-s1-features_md_issuer_count_contradiction):
| MCP tools | 80 | every numeric count below is captured at the time of the last edit AND
| CLI commands | 12 | paired with the source-of-truth grep command from CLAUDE.md. CLAUDE.md
| Issuer connectors | 9 (+ EST server) | rule: "Numeric claims about current state rot the instant the next
| Target connectors | 14 | release lands." Re-derive before each release; the CI guardrail at
| Notifier connectors | 6 channels | .github/workflows/ci.yml::"Forbidden hardcoded source-count prose
| Database tables | 21 (across 10 migrations) | regression guard (S-1)" fails the build on any new prose-only counts
| Background scheduler loops | 12 (8 always-on + 4 opt-in) | without an adjacent rebuild command.
| Web dashboard pages | 24 | -->
| Test functions | 1850+ | | Surface | Count (rebuild command) |
|---|---|
| HTTP routes | rebuild via `grep -cE 'r\.Register\("[A-Z]' internal/api/router/router.go` |
| OpenAPI 3.1 operations | rebuild via `grep -cE '^\s+operationId:' api/openapi.yaml` |
| MCP tools | rebuild via `grep -cE 'gomcp\.AddTool\(' internal/mcp/tools.go` |
| CLI commands | rebuild via `grep -cE 'AddCommand|RootCmd\.Add' cmd/cli/*.go internal/cli/*.go` (intentionally narrow — see CLI Scope §) |
| Issuer connectors | rebuild via `ls -d internal/connector/issuer/*/ \| wc -l` (+ EST server) |
| Target connectors | rebuild via `ls -d internal/connector/target/*/ \| wc -l` (includes shared `certutil/`) |
| Notifier connectors | rebuild via `ls -d internal/connector/notifier/*/ \| wc -l` |
| Discovery connectors | rebuild via `ls -d internal/connector/discovery/*/ \| wc -l` |
| Database tables | rebuild via `grep -hE '^CREATE TABLE' migrations/*.up.sql \| sed -E 's/CREATE TABLE (IF NOT EXISTS )?([a-zA-Z_]+).*/\2/' \| sort -u \| wc -l` (across `ls migrations/*.up.sql \| wc -l` migrations) |
| Background scheduler loops | rebuild via `grep -cE '^func \(s \*Scheduler\) [a-zA-Z]+Loop' internal/scheduler/scheduler.go` |
| Web dashboard pages | rebuild via `ls web/src/pages/*.tsx \| grep -v '\.test\.' \| wc -l` |
| Test functions (Go backend) | rebuild via the `find` + `grep '^func Test'` recipe in CLAUDE.md::Current-state commands |
| Supported platforms | linux/amd64, linux/arm64, darwin/amd64, darwin/arm64 | | Supported platforms | linux/amd64, linux/arm64, darwin/amd64, darwin/arm64 |
--- ---
@@ -136,7 +149,7 @@ Every API call is recorded to the immutable audit trail. Best-effort (non-blocki
<!-- Source: internal/scheduler/scheduler.go (renewalCheckLoop, 1-hour default interval) --> <!-- Source: internal/scheduler/scheduler.go (renewalCheckLoop, 1-hour default interval) -->
The renewal scheduler runs every hour (configurable via `CERTCTL_RENEWAL_CHECK_INTERVAL`). For each certificate approaching expiration: The renewal scheduler runs every hour (configurable via `CERTCTL_SCHEDULER_RENEWAL_CHECK_INTERVAL`). For each certificate approaching expiration:
1. Checks ACME ARI (RFC 9773) if available — CA-directed renewal timing takes priority 1. Checks ACME ARI (RFC 9773) if available — CA-directed renewal timing takes priority
2. Falls back to threshold-based logic using per-policy `alert_thresholds_days` (default `[30, 14, 7, 0]`) 2. Falls back to threshold-based logic using per-policy `alert_thresholds_days` (default `[30, 14, 7, 0]`)
@@ -325,9 +338,9 @@ Policies can be scoped to agent groups via `agent_group_id` foreign key. Violati
## Issuer Connectors ## Issuer Connectors
<!-- Source: internal/domain/connector.go (12 IssuerType constants), internal/connector/issuer/ --> <!-- Source: internal/domain/connector.go (IssuerType constants), internal/connector/issuer/. Rebuild count via `ls -d internal/connector/issuer/*/ | wc -l`. -->
12 issuer connectors implementing the `issuer.Connector` interface. All support `ValidateConfig`, `IssueCertificate`, `RenewCertificate`, `RevokeCertificate`, `GetOrderStatus`, `GenerateCRL`, `SignOCSPResponse`, `GetCACertPEM`, `GetRenewalInfo`. The issuer connector catalog (rebuild count via `ls -d internal/connector/issuer/*/ | wc -l`) implements the `issuer.Connector` interface. All support `ValidateConfig`, `IssueCertificate`, `RenewCertificate`, `RevokeCertificate`, `GetOrderStatus`, `GenerateCRL`, `SignOCSPResponse`, `GetCACertPEM`, `GetRenewalInfo`.
### Local CA ### Local CA
@@ -616,9 +629,9 @@ For Let's Encrypt 6-day `shortlived` certificates, ARI is the expected renewal p
## Target Connectors ## Target Connectors
<!-- Source: internal/domain/connector.go (14 TargetType constants), internal/connector/target/ --> <!-- Source: internal/domain/connector.go (TargetType constants), internal/connector/target/. Rebuild count via `ls -d internal/connector/target/*/ | wc -l` (includes shared `certutil/`). -->
14 target connector types implementing the `target.Connector` interface. All support `ValidateConfig`, `DeployCertificate`, `ValidateDeployment`. The target connector catalog (rebuild count via `ls -d internal/connector/target/*/ | wc -l`) implements the `target.Connector` interface. All support `ValidateConfig`, `DeployCertificate`, `ValidateDeployment`.
### Deployment Model ### Deployment Model
@@ -1101,14 +1114,14 @@ Single SQL `UNION` query replaces the previous "fetch all, filter in Go" approac
| Loop | Default Interval | Always-on | Env Var | Description | | Loop | Default Interval | Always-on | Env Var | Description |
|---|---|---|---|---| |---|---|---|---|---|
| Renewal check | 1 hour | Yes | | Check expiring certs, query ARI, create renewal jobs | | Renewal check | 1 hour | Yes | `CERTCTL_SCHEDULER_RENEWAL_CHECK_INTERVAL` | Check expiring certs, query ARI, create renewal jobs |
| Job processor | 30 seconds | Yes | | Process pending jobs | | Job processor | 30 seconds | Yes | `CERTCTL_SCHEDULER_JOB_PROCESSOR_INTERVAL` | Process pending jobs |
| Job retry | 5 minutes | Yes | `CERTCTL_SCHEDULER_RETRY_INTERVAL` | Retry Failed jobs (I-001) | | Job retry | 5 minutes | Yes | `CERTCTL_SCHEDULER_RETRY_INTERVAL` | Retry Failed jobs (I-001) |
| Job timeout reaper | 10 minutes | Yes | `CERTCTL_JOB_TIMEOUT_INTERVAL` | Fail AwaitingCSR/AwaitingApproval jobs past timeout (I-003) | | Job timeout reaper | 10 minutes | Yes | `CERTCTL_JOB_TIMEOUT_INTERVAL` (per-state thresholds: `CERTCTL_JOB_AWAITING_APPROVAL_TIMEOUT`, `CERTCTL_JOB_AWAITING_CSR_TIMEOUT`) | Fail AwaitingCSR/AwaitingApproval jobs past timeout (I-003) |
| Agent health check | 2 minutes | Yes | | Check agent heartbeat staleness | | Agent health check | 2 minutes | Yes | `CERTCTL_SCHEDULER_AGENT_HEALTH_CHECK_INTERVAL` | Check agent heartbeat staleness |
| Notification processor | 1 minute | Yes | | Send queued notifications | | Notification processor | 1 minute | Yes | `CERTCTL_SCHEDULER_NOTIFICATION_PROCESS_INTERVAL` | Send queued notifications |
| Notification retry | 2 minutes | Yes | `CERTCTL_NOTIFICATION_RETRY_INTERVAL` | Exponential backoff retry for failed notifications; promote to dead-letter after 5 attempts (I-005) | | Notification retry | 2 minutes | Yes | `CERTCTL_NOTIFICATION_RETRY_INTERVAL` | Exponential backoff retry for failed notifications; promote to dead-letter after 5 attempts (I-005) |
| Short-lived expiry check | 30 seconds | Yes | — | Mark short-lived certs expired | | Short-lived expiry check | 30 seconds | Yes | `CERTCTL_SHORT_LIVED_EXPIRY_CHECK_INTERVAL` | Mark short-lived certs expired (C-1: pre-C-1 the setter was unwired and this env var had no effect; post-C-1 it's read by `cmd/server/main.go::sched.SetShortLivedExpiryCheckInterval`) |
| Network scan | 6 hours | Opt-in | `CERTCTL_NETWORK_SCAN_ENABLED` | Run network discovery scans | | Network scan | 6 hours | Opt-in | `CERTCTL_NETWORK_SCAN_ENABLED` | Run network discovery scans |
| Digest | 24 hours | Opt-in | `CERTCTL_DIGEST_INTERVAL` | Send certificate digest email (does not run on startup) | | Digest | 24 hours | Opt-in | `CERTCTL_DIGEST_INTERVAL` | Send certificate digest email (does not run on startup) |
| Endpoint health | 60 seconds | Opt-in | `CERTCTL_HEALTH_CHECK_INTERVAL` | Continuous TLS health probes (M48) | | Endpoint health | 60 seconds | Opt-in | `CERTCTL_HEALTH_CHECK_INTERVAL` | Continuous TLS health probes (M48) |
@@ -1124,7 +1137,7 @@ Single SQL `UNION` query replaces the previous "fetch all, filter in Go" approac
GUI-driven issuer CRUD with AES-256-GCM encrypted config storage in PostgreSQL. GUI-driven issuer CRUD with AES-256-GCM encrypted config storage in PostgreSQL.
- Per-type config schema validation for all 9 issuer types - Per-type config schema validation for all issuer types (rebuild count via `ls -d internal/connector/issuer/*/ | wc -l`)
- Test connection flow (instantiates throwaway connector, calls `ValidateConfig`) - Test connection flow (instantiates throwaway connector, calls `ValidateConfig`)
- Dynamic `sync.RWMutex`-guarded `IssuerRegistry` — rebuilds without server restart - Dynamic `sync.RWMutex`-guarded `IssuerRegistry` — rebuilds without server restart
- Env var backward compatibility: seeds DB on first boot if no DB config exists - Env var backward compatibility: seeds DB on first boot if no DB config exists
@@ -1153,9 +1166,9 @@ Same pattern as issuer configuration:
## Web Dashboard ## Web Dashboard
<!-- Source: web/src/main.tsx (25 Route elements, 24 pages), Vite + React 18 + TypeScript + TanStack Query + Recharts --> <!-- Source: web/src/main.tsx (Route elements + page imports), Vite + React 18 + TypeScript + TanStack Query + Recharts. Rebuild page count via `ls web/src/pages/*.tsx | grep -v '\.test\.' | wc -l`. -->
24 pages wired to real API endpoints. The dashboard surface (rebuild count via `ls web/src/pages/*.tsx | grep -v '\.test\.' | wc -l`) wires every page to real API endpoints.
### Pages ### Pages
@@ -1207,6 +1220,10 @@ Latching state prevents refetch-driven dismissal. `localStorage` dismissal key:
`certctl-cli` — stdlib-only (`flag` + `text/tabwriter`), no Cobra dependency. `certctl-cli` — stdlib-only (`flag` + `text/tabwriter`), no Cobra dependency.
### Scope (intentionally narrow)
The CLI focuses on **read-heavy operator triage** (list, get, status, version) and **bulk-action surface** (`certs bulk-revoke`, `import`). It deliberately omits admin CRUD for issuers, targets, owners, teams, agent groups, certificate profiles, renewal policies, policy rules, and notifications — those live in the GUI and the MCP server (rebuild count via `grep -cE 'gomcp\.AddTool\(' internal/mcp/tools.go` for the full operator surface). This split is intentional: CLI is the SSH-into-the-prod-host emergency console; GUI is the day-to-day operator console; MCP is the AI/automation surface. Closes audit finding `cat-i-7c8b28936e3d` — pre-this-doc the narrow scope was correct in code but confused readers who scanned `docs/features.md`'s "CLI commands" count and assumed the CLI was incomplete.
### Commands ### Commands
| Command | Description | | Command | Description |
@@ -1274,7 +1291,7 @@ certctl-cli certs bulk-revoke --issuer-id iss-letsencrypt --reason caCompromise
Separate standalone binary (`cmd/mcp-server/`) using the official MCP Go SDK (`modelcontextprotocol/go-sdk`). Stdio transport for Claude, Cursor, and similar AI tool integrations. Separate standalone binary (`cmd/mcp-server/`) using the official MCP Go SDK (`modelcontextprotocol/go-sdk`). Stdio transport for Claude, Cursor, and similar AI tool integrations.
- 80 MCP tools covering all API endpoints - MCP tools covering all API endpoints (rebuild count via `grep -cE 'gomcp\.AddTool\(' internal/mcp/tools.go`)
- Stateless HTTP proxy — translates MCP tool calls to REST API calls - Stateless HTTP proxy — translates MCP tool calls to REST API calls
- Typed input structs with `jsonschema` struct tags for automatic schema generation - Typed input structs with `jsonschema` struct tags for automatic schema generation
- Binary response support (DER CRL, OCSP) - Binary response support (DER CRL, OCSP)
@@ -1356,7 +1373,9 @@ Config via `values.yaml`. Secrets for API key, database password, SMTP password.
<!-- Source: migrations/ --> <!-- Source: migrations/ -->
21 tables across 10 numbered migrations. PostgreSQL 16. `database/sql` + `lib/pq` (no ORM). TEXT primary keys with human-readable prefixed IDs. PostgreSQL 16, `database/sql` + `lib/pq` (no ORM). TEXT primary keys with human-readable prefixed IDs. The catalog of tables and migrations rebuilds via the commands in the "At a Glance" table at the top of this doc — re-derive at release time rather than reading hardcoded numbers from prose.
The migration runner reads SQL files from `./migrations/` by default; the path is configurable via `CERTCTL_DATABASE_MIGRATIONS_PATH` for operators running certctl out of a non-standard layout (e.g. a Helm chart that bind-mounts migrations into `/etc/certctl/migrations/`).
### Migrations ### Migrations
@@ -522,7 +522,7 @@ func TestRevokeCertificate_AlreadyRevoked(t *testing.T) {
func TestRevokeCertificate_NotFound(t *testing.T) { func TestRevokeCertificate_NotFound(t *testing.T) {
handler, mock := newCertHandlerWithMock() handler, mock := newCertHandlerWithMock()
mock.RevokeCertificateFn = func(_ context.Context, id string, reason string, _ string) error { mock.RevokeCertificateFn = func(_ context.Context, id string, reason string, _ string) error {
return fmt.Errorf("certificate not found") return fmt.Errorf("certificate not found: %w", ErrMockNotFound)
} }
req := httptest.NewRequest(http.MethodPost, "/api/v1/certificates/mc-missing/revoke", strings.NewReader(`{"reason":"keyCompromise"}`)) req := httptest.NewRequest(http.MethodPost, "/api/v1/certificates/mc-missing/revoke", strings.NewReader(`{"reason":"keyCompromise"}`))
@@ -33,7 +33,7 @@ func (m *MockAgentGroupService) GetAgentGroup(_ context.Context, id string) (*do
if m.GetAgentGroupFn != nil { if m.GetAgentGroupFn != nil {
return m.GetAgentGroupFn(id) return m.GetAgentGroupFn(id)
} }
return nil, fmt.Errorf("not found") return nil, fmt.Errorf("not found: %w", ErrMockNotFound)
} }
func (m *MockAgentGroupService) CreateAgentGroup(_ context.Context, group domain.AgentGroup) (*domain.AgentGroup, error) { func (m *MockAgentGroupService) CreateAgentGroup(_ context.Context, group domain.AgentGroup) (*domain.AgentGroup, error) {
+4 -2
View File
@@ -1,6 +1,8 @@
package handler package handler
import ( import (
"github.com/shankar0123/certctl/internal/repository"
"errors"
"context" "context"
"encoding/json" "encoding/json"
"net/http" "net/http"
@@ -160,7 +162,7 @@ func (h AgentGroupHandler) UpdateAgentGroup(w http.ResponseWriter, r *http.Reque
updated, err := h.svc.UpdateAgentGroup(r.Context(), id, group) updated, err := h.svc.UpdateAgentGroup(r.Context(), id, group)
if err != nil { if err != nil {
if strings.Contains(err.Error(), "not found") { if errors.Is(err, repository.ErrNotFound) {
ErrorWithRequestID(w, http.StatusNotFound, "Agent group not found", requestID) ErrorWithRequestID(w, http.StatusNotFound, "Agent group not found", requestID)
return return
} }
@@ -188,7 +190,7 @@ func (h AgentGroupHandler) DeleteAgentGroup(w http.ResponseWriter, r *http.Reque
} }
if err := h.svc.DeleteAgentGroup(r.Context(), id); err != nil { if err := h.svc.DeleteAgentGroup(r.Context(), id); err != nil {
if strings.Contains(err.Error(), "not found") { if errors.Is(err, repository.ErrNotFound) {
ErrorWithRequestID(w, http.StatusNotFound, "Agent group not found", requestID) ErrorWithRequestID(w, http.StatusNotFound, "Agent group not found", requestID)
return return
} }
@@ -3,7 +3,6 @@ package handler
import ( import (
"context" "context"
"encoding/json" "encoding/json"
"errors"
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
"testing" "testing"
@@ -142,7 +141,9 @@ func TestRetireAgentHandler_Sentinel_403(t *testing.T) {
func TestRetireAgentHandler_NotFound_404(t *testing.T) { func TestRetireAgentHandler_NotFound_404(t *testing.T) {
mock, handler := agentRetireTestSetup() mock, handler := agentRetireTestSetup()
mock.RetireAgentFn = func(agentID, actor string, force bool, reason string) (*service.AgentRetirementResult, error) { mock.RetireAgentFn = func(agentID, actor string, force bool, reason string) (*service.AgentRetirementResult, error) {
return nil, errors.New("agent not found") // S-2 closure (cat-s6-efc7f6f6bd50): wrap repository.ErrNotFound
// so the handler's errors.Is dispatch resolves to 404.
return nil, ErrMockNotFound
} }
req := httptest.NewRequest(http.MethodDelete, "/api/v1/agents/unknown-id", nil) req := httptest.NewRequest(http.MethodDelete, "/api/v1/agents/unknown-id", nil)
+3 -2
View File
@@ -1,6 +1,7 @@
package handler package handler
import ( import (
"github.com/shankar0123/certctl/internal/repository"
"context" "context"
"encoding/json" "encoding/json"
"errors" "errors"
@@ -211,7 +212,7 @@ func (h AgentHandler) Heartbeat(w http.ResponseWriter, r *http.Request) {
ErrorWithRequestID(w, http.StatusGone, "Agent has been retired", requestID) ErrorWithRequestID(w, http.StatusGone, "Agent has been retired", requestID)
return return
} }
if strings.Contains(err.Error(), "not found") { if errors.Is(err, repository.ErrNotFound) {
ErrorWithRequestID(w, http.StatusNotFound, "Agent not found", requestID) ErrorWithRequestID(w, http.StatusNotFound, "Agent not found", requestID)
return return
} }
@@ -491,7 +492,7 @@ func (h AgentHandler) RetireAgent(w http.ResponseWriter, r *http.Request) {
JSON(w, http.StatusConflict, body) JSON(w, http.StatusConflict, body)
return return
} }
if strings.Contains(err.Error(), "not found") { if errors.Is(err, repository.ErrNotFound) {
ErrorWithRequestID(w, http.StatusNotFound, "Agent not found", requestID) ErrorWithRequestID(w, http.StatusNotFound, "Agent not found", requestID)
return return
} }
+104
View File
@@ -0,0 +1,104 @@
package handler
import (
"context"
"encoding/json"
"errors"
"net/http"
"github.com/shankar0123/certctl/internal/api/middleware"
"github.com/shankar0123/certctl/internal/domain"
"github.com/shankar0123/certctl/internal/service"
)
// BulkReassignmentService defines the service interface for bulk
// owner-reassignment operations.
type BulkReassignmentService interface {
BulkReassign(ctx context.Context, request domain.BulkReassignmentRequest, actor string) (*domain.BulkReassignmentResult, error)
}
// BulkReassignmentHandler handles HTTP requests for bulk reassignment
// operations.
type BulkReassignmentHandler struct {
svc BulkReassignmentService
}
// NewBulkReassignmentHandler creates a new BulkReassignmentHandler.
func NewBulkReassignmentHandler(svc BulkReassignmentService) BulkReassignmentHandler {
return BulkReassignmentHandler{svc: svc}
}
// bulkReassignRequest is the JSON shape decoded from the request body.
type bulkReassignRequest struct {
CertificateIDs []string `json:"certificate_ids"`
OwnerID string `json:"owner_id"`
TeamID string `json:"team_id,omitempty"`
}
// BulkReassign handles POST /api/v1/certificates/bulk-reassign
//
// L-2 closure (cat-l-8a1fb258a38a): pre-L-2 the GUI looped
// `await updateCertificate(id, { owner_id })`. Post-L-2 the GUI POSTs
// once and the server mutates owner_id (and optionally team_id) on N
// certs, returning per-cert success/skip/error counts.
//
// Narrower contract than bulk-renew: explicit IDs only, no criteria-mode.
// OwnerID is required; TeamID is optional and updates the team only when
// non-empty (matches the existing per-cert PUT contract).
//
// Auth: any authenticated caller can reassign certs they own/have
// access to. NOT admin-gated — operators reassign ownership during
// team transitions all the time and gating that on admin would block
// the common-case workflow.
//
// Validation order: empty body → 400; empty IDs → 400; missing
// owner_id → 400; non-existent owner_id → 400 via the
// ErrBulkReassignOwnerNotFound sentinel mapped here.
func (h BulkReassignmentHandler) BulkReassign(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
Error(w, http.StatusMethodNotAllowed, "Method not allowed")
return
}
requestID := middleware.GetRequestID(r.Context())
var req bulkReassignRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
ErrorWithRequestID(w, http.StatusBadRequest, "Invalid request body", requestID)
return
}
request := domain.BulkReassignmentRequest{
CertificateIDs: req.CertificateIDs,
OwnerID: req.OwnerID,
TeamID: req.TeamID,
}
if request.IsEmpty() {
ErrorWithRequestID(w, http.StatusBadRequest,
"At least one certificate_id is required",
requestID)
return
}
if request.OwnerID == "" {
ErrorWithRequestID(w, http.StatusBadRequest, "owner_id is required", requestID)
return
}
actor := resolveActor(r.Context())
result, err := h.svc.BulkReassign(r.Context(), request, actor)
if err != nil {
// Sentinel-error → 400 mapping. ErrBulkReassignOwnerNotFound
// means the operator picked an owner that doesn't exist; this
// is bad input (400), not a server error (500). Mirrors the
// post-M-1 errToStatus convention rather than substring-matching
// err.Error().
if errors.Is(err, service.ErrBulkReassignOwnerNotFound) {
ErrorWithRequestID(w, http.StatusBadRequest, err.Error(), requestID)
return
}
ErrorWithRequestID(w, http.StatusInternalServerError, "Bulk reassignment failed: "+err.Error(), requestID)
return
}
JSON(w, http.StatusOK, result)
}
@@ -0,0 +1,149 @@
package handler
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/shankar0123/certctl/internal/domain"
"github.com/shankar0123/certctl/internal/service"
)
type mockBulkReassignmentService struct {
BulkReassignFn func(ctx context.Context, request domain.BulkReassignmentRequest, actor string) (*domain.BulkReassignmentResult, error)
}
func (m *mockBulkReassignmentService) BulkReassign(ctx context.Context, request domain.BulkReassignmentRequest, actor string) (*domain.BulkReassignmentResult, error) {
if m.BulkReassignFn != nil {
return m.BulkReassignFn(ctx, request, actor)
}
return &domain.BulkReassignmentResult{}, nil
}
func TestBulkReassign_Handler_HappyPath(t *testing.T) {
svc := &mockBulkReassignmentService{
BulkReassignFn: func(ctx context.Context, request domain.BulkReassignmentRequest, actor string) (*domain.BulkReassignmentResult, error) {
if request.OwnerID != "o-bob" {
t.Errorf("owner_id = %q, want 'o-bob'", request.OwnerID)
}
return &domain.BulkReassignmentResult{
TotalMatched: 2, TotalReassigned: 2,
}, nil
},
}
h := NewBulkReassignmentHandler(svc)
body := `{"certificate_ids":["mc-1","mc-2"],"owner_id":"o-bob"}`
req := httptest.NewRequest(http.MethodPost, "/api/v1/certificates/bulk-reassign", bytes.NewBufferString(body))
req.Header.Set("Content-Type", "application/json")
req = req.WithContext(authedContext())
w := httptest.NewRecorder()
h.BulkReassign(w, req)
if w.Code != http.StatusOK {
t.Fatalf("status = %d, want 200; body=%s", w.Code, w.Body.String())
}
var result domain.BulkReassignmentResult
if err := json.NewDecoder(w.Body).Decode(&result); err != nil {
t.Fatalf("decode failed: %v", err)
}
if result.TotalReassigned != 2 {
t.Errorf("envelope drift: TotalReassigned=%d, want 2", result.TotalReassigned)
}
}
func TestBulkReassign_Handler_EmptyIDs_400(t *testing.T) {
svc := &mockBulkReassignmentService{}
h := NewBulkReassignmentHandler(svc)
body := `{"certificate_ids":[],"owner_id":"o-bob"}`
req := httptest.NewRequest(http.MethodPost, "/api/v1/certificates/bulk-reassign", bytes.NewBufferString(body))
req = req.WithContext(authedContext())
w := httptest.NewRecorder()
h.BulkReassign(w, req)
if w.Code != http.StatusBadRequest {
t.Errorf("status = %d, want 400", w.Code)
}
}
func TestBulkReassign_Handler_MissingOwnerID_400(t *testing.T) {
svc := &mockBulkReassignmentService{}
h := NewBulkReassignmentHandler(svc)
body := `{"certificate_ids":["mc-1"]}`
req := httptest.NewRequest(http.MethodPost, "/api/v1/certificates/bulk-reassign", bytes.NewBufferString(body))
req = req.WithContext(authedContext())
w := httptest.NewRecorder()
h.BulkReassign(w, req)
if w.Code != http.StatusBadRequest {
t.Errorf("status = %d, want 400", w.Code)
}
if !strings.Contains(w.Body.String(), "owner_id") {
t.Errorf("body should name owner_id; got: %s", w.Body.String())
}
}
// TestBulkReassign_Handler_OwnerNotFound_400 — sentinel-error → 400
// mapping. Operator picked an owner that doesn't exist; that's bad
// input, not a server error.
func TestBulkReassign_Handler_OwnerNotFound_400(t *testing.T) {
svc := &mockBulkReassignmentService{
BulkReassignFn: func(ctx context.Context, request domain.BulkReassignmentRequest, actor string) (*domain.BulkReassignmentResult, error) {
return nil, fmt.Errorf("%w: %s", service.ErrBulkReassignOwnerNotFound, request.OwnerID)
},
}
h := NewBulkReassignmentHandler(svc)
body := `{"certificate_ids":["mc-1"],"owner_id":"o-ghost"}`
req := httptest.NewRequest(http.MethodPost, "/api/v1/certificates/bulk-reassign", bytes.NewBufferString(body))
req = req.WithContext(authedContext())
w := httptest.NewRecorder()
h.BulkReassign(w, req)
if w.Code != http.StatusBadRequest {
t.Errorf("status = %d, want 400 (ErrBulkReassignOwnerNotFound → 400)", w.Code)
}
if !strings.Contains(w.Body.String(), "owner not found") {
t.Errorf("body should mention 'owner not found'; got: %s", w.Body.String())
}
}
func TestBulkReassign_Handler_WrongMethod_405(t *testing.T) {
svc := &mockBulkReassignmentService{}
h := NewBulkReassignmentHandler(svc)
for _, method := range []string{http.MethodGet, http.MethodPut, http.MethodDelete, http.MethodPatch} {
req := httptest.NewRequest(method, "/api/v1/certificates/bulk-reassign", nil)
req = req.WithContext(authedContext())
w := httptest.NewRecorder()
h.BulkReassign(w, req)
if w.Code != http.StatusMethodNotAllowed {
t.Errorf("%s → %d, want 405", method, w.Code)
}
}
}
func TestBulkReassign_Handler_GenericError_500(t *testing.T) {
svc := &mockBulkReassignmentService{
BulkReassignFn: func(ctx context.Context, request domain.BulkReassignmentRequest, actor string) (*domain.BulkReassignmentResult, error) {
return nil, errors.New("simulated outage")
},
}
h := NewBulkReassignmentHandler(svc)
body := `{"certificate_ids":["mc-1"],"owner_id":"o-bob"}`
req := httptest.NewRequest(http.MethodPost, "/api/v1/certificates/bulk-reassign", bytes.NewBufferString(body))
req = req.WithContext(authedContext())
w := httptest.NewRecorder()
h.BulkReassign(w, req)
if w.Code != http.StatusInternalServerError {
t.Errorf("status = %d, want 500", w.Code)
}
}
+96
View File
@@ -0,0 +1,96 @@
package handler
import (
"context"
"encoding/json"
"net/http"
"github.com/shankar0123/certctl/internal/api/middleware"
"github.com/shankar0123/certctl/internal/domain"
)
// BulkRenewalService defines the service interface for bulk certificate
// renewal. Mirrors BulkRevocationService — handler doesn't import the
// concrete service struct so tests can inject a mock without pulling in
// the full service-layer dependency graph.
type BulkRenewalService interface {
BulkRenew(ctx context.Context, criteria domain.BulkRenewalCriteria, actor string) (*domain.BulkRenewalResult, error)
}
// BulkRenewalHandler handles HTTP requests for bulk renewal operations.
type BulkRenewalHandler struct {
svc BulkRenewalService
}
// NewBulkRenewalHandler creates a new BulkRenewalHandler.
func NewBulkRenewalHandler(svc BulkRenewalService) BulkRenewalHandler {
return BulkRenewalHandler{svc: svc}
}
// bulkRenewRequest mirrors the BulkRenewalCriteria JSON shape (the
// handler decodes into this struct then hands a domain.BulkRenewalCriteria
// to the service — same indirection as bulkRevokeRequest in
// bulk_revocation.go).
type bulkRenewRequest struct {
ProfileID string `json:"profile_id,omitempty"`
OwnerID string `json:"owner_id,omitempty"`
AgentID string `json:"agent_id,omitempty"`
IssuerID string `json:"issuer_id,omitempty"`
TeamID string `json:"team_id,omitempty"`
CertificateIDs []string `json:"certificate_ids,omitempty"`
}
// BulkRenew handles POST /api/v1/certificates/bulk-renew
//
// L-1 closure (cat-l-fa0c1ac07ab5): pre-L-1 the GUI looped
// `await triggerRenewal(id)` over the selection. Post-L-1 it POSTs once
// and the server enqueues N renewal jobs server-side, returning a
// per-cert {certificate_id, job_id} envelope.
//
// Request shape mirrors BulkRevokeRequest (criteria-mode + IDs-mode);
// the "renew all certs of profile X before its CA changes" use case is
// why criteria-mode is supported in addition to explicit IDs.
//
// Auth: any authenticated caller can renew certs they have read-access
// to (matches POST /api/v1/certificates/{id}/renew). NOT admin-gated
// like bulk-revoke — bulk-renew is non-destructive (worst case it
// kicks off some redundant ACME orders) so we don't need the
// fleet-scale-destruction gate.
func (h BulkRenewalHandler) BulkRenew(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
Error(w, http.StatusMethodNotAllowed, "Method not allowed")
return
}
requestID := middleware.GetRequestID(r.Context())
var req bulkRenewRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
ErrorWithRequestID(w, http.StatusBadRequest, "Invalid request body", requestID)
return
}
criteria := domain.BulkRenewalCriteria{
ProfileID: req.ProfileID,
OwnerID: req.OwnerID,
AgentID: req.AgentID,
IssuerID: req.IssuerID,
TeamID: req.TeamID,
CertificateIDs: req.CertificateIDs,
}
if criteria.IsEmpty() {
ErrorWithRequestID(w, http.StatusBadRequest,
"At least one filter criterion is required (profile_id, owner_id, agent_id, issuer_id, team_id, or certificate_ids)",
requestID)
return
}
actor := resolveActor(r.Context())
result, err := h.svc.BulkRenew(r.Context(), criteria, actor)
if err != nil {
ErrorWithRequestID(w, http.StatusInternalServerError, "Bulk renewal failed: "+err.Error(), requestID)
return
}
JSON(w, http.StatusOK, result)
}
@@ -0,0 +1,148 @@
package handler
import (
"bytes"
"context"
"encoding/json"
"errors"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/shankar0123/certctl/internal/api/middleware"
"github.com/shankar0123/certctl/internal/domain"
)
// mockBulkRenewalService is a test implementation of BulkRenewalService.
type mockBulkRenewalService struct {
BulkRenewFn func(ctx context.Context, criteria domain.BulkRenewalCriteria, actor string) (*domain.BulkRenewalResult, error)
}
func (m *mockBulkRenewalService) BulkRenew(ctx context.Context, criteria domain.BulkRenewalCriteria, actor string) (*domain.BulkRenewalResult, error) {
if m.BulkRenewFn != nil {
return m.BulkRenewFn(ctx, criteria, actor)
}
return &domain.BulkRenewalResult{}, nil
}
// authedContext mirrors adminContext but without the admin flag —
// bulk-renew is NOT admin-gated, any authenticated caller can use it.
func authedContext() context.Context {
ctx := context.WithValue(context.Background(), middleware.RequestIDKey{}, "test-request-id-renew")
ctx = context.WithValue(ctx, middleware.UserKey{}, "alice")
return ctx
}
func TestBulkRenew_Handler_HappyPath(t *testing.T) {
svc := &mockBulkRenewalService{
BulkRenewFn: func(ctx context.Context, criteria domain.BulkRenewalCriteria, actor string) (*domain.BulkRenewalResult, error) {
if len(criteria.CertificateIDs) != 3 {
t.Errorf("expected 3 IDs, got %d", len(criteria.CertificateIDs))
}
if actor != "alice" {
t.Errorf("actor = %q, want 'alice' (resolved from middleware UserKey)", actor)
}
return &domain.BulkRenewalResult{
TotalMatched: 3,
TotalEnqueued: 3,
EnqueuedJobs: []domain.BulkEnqueuedJob{
{CertificateID: "mc-1", JobID: "job-a"},
{CertificateID: "mc-2", JobID: "job-b"},
{CertificateID: "mc-3", JobID: "job-c"},
},
}, nil
},
}
h := NewBulkRenewalHandler(svc)
body := `{"certificate_ids":["mc-1","mc-2","mc-3"]}`
req := httptest.NewRequest(http.MethodPost, "/api/v1/certificates/bulk-renew", bytes.NewBufferString(body))
req.Header.Set("Content-Type", "application/json")
req = req.WithContext(authedContext())
w := httptest.NewRecorder()
h.BulkRenew(w, req)
if w.Code != http.StatusOK {
t.Fatalf("status = %d, want 200; body=%s", w.Code, w.Body.String())
}
var result domain.BulkRenewalResult
if err := json.NewDecoder(w.Body).Decode(&result); err != nil {
t.Fatalf("decode failed: %v", err)
}
if result.TotalEnqueued != 3 || len(result.EnqueuedJobs) != 3 {
t.Errorf("envelope drift: enqueued=%d jobs=%d, want 3/3",
result.TotalEnqueued, len(result.EnqueuedJobs))
}
}
func TestBulkRenew_Handler_EmptyBody_400(t *testing.T) {
svc := &mockBulkRenewalService{}
h := NewBulkRenewalHandler(svc)
req := httptest.NewRequest(http.MethodPost, "/api/v1/certificates/bulk-renew", bytes.NewBufferString(`{}`))
req.Header.Set("Content-Type", "application/json")
req = req.WithContext(authedContext())
w := httptest.NewRecorder()
h.BulkRenew(w, req)
if w.Code != http.StatusBadRequest {
t.Errorf("status = %d, want 400 (empty criteria must reject)", w.Code)
}
if !strings.Contains(w.Body.String(), "filter criterion") {
t.Errorf("body should name the criteria-required contract; got: %s", w.Body.String())
}
}
func TestBulkRenew_Handler_WrongMethod_405(t *testing.T) {
svc := &mockBulkRenewalService{}
h := NewBulkRenewalHandler(svc)
for _, method := range []string{http.MethodGet, http.MethodPut, http.MethodDelete, http.MethodPatch} {
req := httptest.NewRequest(method, "/api/v1/certificates/bulk-renew", nil)
req = req.WithContext(authedContext())
w := httptest.NewRecorder()
h.BulkRenew(w, req)
if w.Code != http.StatusMethodNotAllowed {
t.Errorf("%s → status %d, want 405", method, w.Code)
}
}
}
func TestBulkRenew_Handler_ActorAttribution(t *testing.T) {
var capturedActor string
svc := &mockBulkRenewalService{
BulkRenewFn: func(ctx context.Context, criteria domain.BulkRenewalCriteria, actor string) (*domain.BulkRenewalResult, error) {
capturedActor = actor
return &domain.BulkRenewalResult{}, nil
},
}
h := NewBulkRenewalHandler(svc)
body := `{"certificate_ids":["mc-1"]}`
req := httptest.NewRequest(http.MethodPost, "/api/v1/certificates/bulk-renew", bytes.NewBufferString(body))
req = req.WithContext(authedContext())
w := httptest.NewRecorder()
h.BulkRenew(w, req)
if capturedActor != "alice" {
t.Errorf("actor not threaded from middleware.UserKey: got %q, want 'alice'", capturedActor)
}
}
func TestBulkRenew_Handler_ServiceError_500(t *testing.T) {
svc := &mockBulkRenewalService{
BulkRenewFn: func(ctx context.Context, criteria domain.BulkRenewalCriteria, actor string) (*domain.BulkRenewalResult, error) {
return nil, errors.New("simulated DB failure")
},
}
h := NewBulkRenewalHandler(svc)
body := `{"certificate_ids":["mc-1"]}`
req := httptest.NewRequest(http.MethodPost, "/api/v1/certificates/bulk-renew", bytes.NewBufferString(body))
req = req.WithContext(authedContext())
w := httptest.NewRecorder()
h.BulkRenew(w, req)
if w.Code != http.StatusInternalServerError {
t.Errorf("status = %d, want 500", w.Code)
}
}
@@ -900,7 +900,7 @@ func TestRevokeCertificate_Handler_AlreadyRevoked(t *testing.T) {
func TestRevokeCertificate_Handler_NotFound(t *testing.T) { func TestRevokeCertificate_Handler_NotFound(t *testing.T) {
mock := &MockCertificateService{ mock := &MockCertificateService{
RevokeCertificateFn: func(_ context.Context, certID string, reason string, _ string) error { RevokeCertificateFn: func(_ context.Context, certID string, reason string, _ string) error {
return fmt.Errorf("failed to fetch certificate: not found") return fmt.Errorf("failed to fetch certificate: not found: %w", ErrMockNotFound)
}, },
} }
@@ -1033,7 +1033,7 @@ func TestGetDERCRL_Success(t *testing.T) {
if issuerID == "iss-local" { if issuerID == "iss-local" {
return derCRLData, nil return derCRLData, nil
} }
return nil, fmt.Errorf("issuer not found") return nil, fmt.Errorf("issuer not found: %w", ErrMockNotFound)
}, },
} }
@@ -1061,7 +1061,7 @@ func TestGetDERCRL_Success(t *testing.T) {
func TestGetDERCRL_IssuerNotFound(t *testing.T) { func TestGetDERCRL_IssuerNotFound(t *testing.T) {
mock := &MockCertificateService{ mock := &MockCertificateService{
GenerateDERCRLFn: func(_ context.Context, issuerID string) ([]byte, error) { GenerateDERCRLFn: func(_ context.Context, issuerID string) ([]byte, error) {
return nil, fmt.Errorf("issuer not found") return nil, fmt.Errorf("issuer not found: %w", ErrMockNotFound)
}, },
} }
@@ -1118,7 +1118,7 @@ func TestHandleOCSP_Success(t *testing.T) {
if issuerID == "iss-local" && serialHex == "12345" { if issuerID == "iss-local" && serialHex == "12345" {
return ocspResponseBytes, nil return ocspResponseBytes, nil
} }
return nil, fmt.Errorf("certificate not found") return nil, fmt.Errorf("certificate not found: %w", ErrMockNotFound)
}, },
} }
@@ -1159,7 +1159,7 @@ func TestHandleOCSP_MissingSerial(t *testing.T) {
func TestHandleOCSP_IssuerNotFound(t *testing.T) { func TestHandleOCSP_IssuerNotFound(t *testing.T) {
mock := &MockCertificateService{ mock := &MockCertificateService{
GetOCSPResponseFn: func(_ context.Context, issuerID string, serialHex string) ([]byte, error) { GetOCSPResponseFn: func(_ context.Context, issuerID string, serialHex string) ([]byte, error) {
return nil, fmt.Errorf("issuer not found") return nil, fmt.Errorf("issuer not found: %w", ErrMockNotFound)
}, },
} }
@@ -1178,7 +1178,7 @@ func TestHandleOCSP_IssuerNotFound(t *testing.T) {
func TestHandleOCSP_CertNotFound(t *testing.T) { func TestHandleOCSP_CertNotFound(t *testing.T) {
mock := &MockCertificateService{ mock := &MockCertificateService{
GetOCSPResponseFn: func(_ context.Context, issuerID string, serialHex string) ([]byte, error) { GetOCSPResponseFn: func(_ context.Context, issuerID string, serialHex string) ([]byte, error) {
return nil, fmt.Errorf("certificate not found") return nil, fmt.Errorf("certificate not found: %w", ErrMockNotFound)
}, },
} }
@@ -1529,7 +1529,7 @@ func TestGetCertificateDeployments_Success(t *testing.T) {
func TestGetCertificateDeployments_NotFound(t *testing.T) { func TestGetCertificateDeployments_NotFound(t *testing.T) {
mock := &MockCertificateService{ mock := &MockCertificateService{
GetCertificateDeploymentsFn: func(_ context.Context, certID string) ([]domain.DeploymentTarget, error) { GetCertificateDeploymentsFn: func(_ context.Context, certID string) ([]domain.DeploymentTarget, error) {
return nil, fmt.Errorf("certificate not found") return nil, fmt.Errorf("certificate not found: %w", ErrMockNotFound)
}, },
} }
+4 -3
View File
@@ -1,6 +1,7 @@
package handler package handler
import ( import (
"errors"
"context" "context"
"encoding/json" "encoding/json"
"log/slog" "log/slog"
@@ -298,7 +299,7 @@ func (h CertificateHandler) UpdateCertificate(w http.ResponseWriter, r *http.Req
updated, err := h.svc.UpdateCertificate(r.Context(), id, cert) updated, err := h.svc.UpdateCertificate(r.Context(), id, cert)
if err != nil { if err != nil {
if strings.Contains(err.Error(), "not found") { if errors.Is(err, repository.ErrNotFound) {
ErrorWithRequestID(w, http.StatusNotFound, "Certificate not found", requestID) ErrorWithRequestID(w, http.StatusNotFound, "Certificate not found", requestID)
return return
} }
@@ -327,7 +328,7 @@ func (h CertificateHandler) ArchiveCertificate(w http.ResponseWriter, r *http.Re
} }
if err := h.svc.ArchiveCertificate(r.Context(), id); err != nil { if err := h.svc.ArchiveCertificate(r.Context(), id); err != nil {
if strings.Contains(err.Error(), "not found") { if errors.Is(err, repository.ErrNotFound) {
ErrorWithRequestID(w, http.StatusNotFound, "Certificate not found", requestID) ErrorWithRequestID(w, http.StatusNotFound, "Certificate not found", requestID)
return return
} }
@@ -373,7 +374,7 @@ func (h CertificateHandler) GetCertificateVersions(w http.ResponseWriter, r *htt
versions, total, err := h.svc.GetCertificateVersions(r.Context(), certID, page, perPage) versions, total, err := h.svc.GetCertificateVersions(r.Context(), certID, page, perPage)
if err != nil { if err != nil {
if strings.Contains(err.Error(), "not found") { if errors.Is(err, repository.ErrNotFound) {
ErrorWithRequestID(w, http.StatusNotFound, "Certificate not found", requestID) ErrorWithRequestID(w, http.StatusNotFound, "Certificate not found", requestID)
return return
} }
@@ -300,7 +300,7 @@ func TestGetDiscovered_Success(t *testing.T) {
if id == "dcert-1" { if id == "dcert-1" {
return cert, nil return cert, nil
} }
return nil, fmt.Errorf("not found") return nil, fmt.Errorf("not found: %w", ErrMockNotFound)
}, },
} }
@@ -331,7 +331,7 @@ func TestGetDiscovered_Success(t *testing.T) {
func TestGetDiscovered_NotFound(t *testing.T) { func TestGetDiscovered_NotFound(t *testing.T) {
mock := &MockDiscoveryService{ mock := &MockDiscoveryService{
GetDiscoveredFn: func(ctx context.Context, id string) (*domain.DiscoveredCertificate, error) { GetDiscoveredFn: func(ctx context.Context, id string) (*domain.DiscoveredCertificate, error) {
return nil, fmt.Errorf("not found") return nil, fmt.Errorf("not found: %w", ErrMockNotFound)
}, },
} }
@@ -412,7 +412,7 @@ func TestClaimDiscovered_MissingManagedCertID(t *testing.T) {
func TestClaimDiscovered_NotFound(t *testing.T) { func TestClaimDiscovered_NotFound(t *testing.T) {
mock := &MockDiscoveryService{ mock := &MockDiscoveryService{
ClaimDiscoveredFn: func(ctx context.Context, id string, managedCertID string, actor string) error { ClaimDiscoveredFn: func(ctx context.Context, id string, managedCertID string, actor string) error {
return fmt.Errorf("discovered certificate not found") return fmt.Errorf("discovered certificate not found: %w", ErrMockNotFound)
}, },
} }
@@ -442,7 +442,7 @@ func TestDismissDiscovered_Success(t *testing.T) {
if id == "dcert-1" { if id == "dcert-1" {
return nil return nil
} }
return fmt.Errorf("not found") return fmt.Errorf("not found: %w", ErrMockNotFound)
}, },
} }
+4 -2
View File
@@ -1,6 +1,8 @@
package handler package handler
import ( import (
"github.com/shankar0123/certctl/internal/repository"
"errors"
"context" "context"
"encoding/json" "encoding/json"
"log/slog" "log/slog"
@@ -46,7 +48,7 @@ func (h ExportHandler) ExportPEM(w http.ResponseWriter, r *http.Request) {
result, err := h.svc.ExportPEM(r.Context(), id) result, err := h.svc.ExportPEM(r.Context(), id)
if err != nil { if err != nil {
if strings.Contains(err.Error(), "not found") { if errors.Is(err, repository.ErrNotFound) {
ErrorWithRequestID(w, http.StatusNotFound, "Certificate not found", requestID) ErrorWithRequestID(w, http.StatusNotFound, "Certificate not found", requestID)
return return
} }
@@ -94,7 +96,7 @@ func (h ExportHandler) ExportPKCS12(w http.ResponseWriter, r *http.Request) {
pfxData, err := h.svc.ExportPKCS12(r.Context(), id, req.Password) pfxData, err := h.svc.ExportPKCS12(r.Context(), id, req.Password)
if err != nil { if err != nil {
if strings.Contains(err.Error(), "not found") { if errors.Is(err, repository.ErrNotFound) {
ErrorWithRequestID(w, http.StatusNotFound, "Certificate not found", requestID) ErrorWithRequestID(w, http.StatusNotFound, "Certificate not found", requestID)
return return
} }
+2 -2
View File
@@ -110,7 +110,7 @@ func TestExportPEM_Download(t *testing.T) {
func TestExportPEM_NotFound(t *testing.T) { func TestExportPEM_NotFound(t *testing.T) {
mockSvc := &MockExportService{ mockSvc := &MockExportService{
ExportPEMFn: func(_ context.Context, _ string) (*service.ExportPEMResult, error) { ExportPEMFn: func(_ context.Context, _ string) (*service.ExportPEMResult, error) {
return nil, fmt.Errorf("certificate not found") return nil, fmt.Errorf("certificate not found: %w", ErrMockNotFound)
}, },
} }
h := NewExportHandler(mockSvc) h := NewExportHandler(mockSvc)
@@ -216,7 +216,7 @@ func TestExportPKCS12_EmptyPassword(t *testing.T) {
func TestExportPKCS12_NotFound(t *testing.T) { func TestExportPKCS12_NotFound(t *testing.T) {
mockSvc := &MockExportService{ mockSvc := &MockExportService{
ExportPKCS12Fn: func(_ context.Context, _ string, _ string) ([]byte, error) { ExportPKCS12Fn: func(_ context.Context, _ string, _ string) ([]byte, error) {
return nil, fmt.Errorf("certificate not found") return nil, fmt.Errorf("certificate not found: %w", ErrMockNotFound)
}, },
} }
h := NewExportHandler(mockSvc) h := NewExportHandler(mockSvc)
+4 -2
View File
@@ -1,6 +1,8 @@
package handler package handler
import ( import (
"github.com/shankar0123/certctl/internal/repository"
"errors"
"context" "context"
"encoding/json" "encoding/json"
"log/slog" "log/slog"
@@ -210,9 +212,9 @@ func (h IssuerHandler) DeleteIssuer(w http.ResponseWriter, r *http.Request) {
} }
if err := h.svc.DeleteIssuer(r.Context(), id); err != nil { if err := h.svc.DeleteIssuer(r.Context(), id); err != nil {
if strings.Contains(err.Error(), "violates foreign key") || strings.Contains(err.Error(), "RESTRICT") { if repository.IsForeignKeyError(err) {
ErrorWithRequestID(w, http.StatusConflict, "Cannot delete issuer: certificates are still using this issuer", requestID) ErrorWithRequestID(w, http.StatusConflict, "Cannot delete issuer: certificates are still using this issuer", requestID)
} else if strings.Contains(err.Error(), "not found") { } else if errors.Is(err, repository.ErrNotFound) {
ErrorWithRequestID(w, http.StatusNotFound, "Issuer not found", requestID) ErrorWithRequestID(w, http.StatusNotFound, "Issuer not found", requestID)
} else { } else {
ErrorWithRequestID(w, http.StatusInternalServerError, "Failed to delete issuer", requestID) ErrorWithRequestID(w, http.StatusInternalServerError, "Failed to delete issuer", requestID)
+2 -2
View File
@@ -383,7 +383,7 @@ func TestApproveJob_Success(t *testing.T) {
func TestApproveJob_NotFound(t *testing.T) { func TestApproveJob_NotFound(t *testing.T) {
mock := &MockJobService{ mock := &MockJobService{
ApproveJobFn: func(id, actor string) error { ApproveJobFn: func(id, actor string) error {
return fmt.Errorf("job not found: no rows") return fmt.Errorf("job not found: no rows: %w", ErrMockNotFound)
}, },
} }
@@ -527,7 +527,7 @@ func TestRejectJob_NoReason(t *testing.T) {
func TestRejectJob_NotFound(t *testing.T) { func TestRejectJob_NotFound(t *testing.T) {
mock := &MockJobService{ mock := &MockJobService{
RejectJobFn: func(id, reason, actor string) error { RejectJobFn: func(id, reason, actor string) error {
return fmt.Errorf("job not found: no rows") return fmt.Errorf("job not found: no rows: %w", ErrMockNotFound)
}, },
} }
+3 -2
View File
@@ -1,6 +1,7 @@
package handler package handler
import ( import (
"github.com/shankar0123/certctl/internal/repository"
"context" "context"
"encoding/json" "encoding/json"
"errors" "errors"
@@ -167,7 +168,7 @@ func (h JobHandler) ApproveJob(w http.ResponseWriter, r *http.Request) {
requestID) requestID)
return return
} }
if strings.Contains(err.Error(), "not found") { if errors.Is(err, repository.ErrNotFound) {
ErrorWithRequestID(w, http.StatusNotFound, "Job not found", requestID) ErrorWithRequestID(w, http.StatusNotFound, "Job not found", requestID)
return return
} }
@@ -213,7 +214,7 @@ func (h JobHandler) RejectJob(w http.ResponseWriter, r *http.Request) {
actor := resolveActor(r.Context()) actor := resolveActor(r.Context())
if err := h.svc.RejectJob(r.Context(), jobID, body.Reason, actor); err != nil { if err := h.svc.RejectJob(r.Context(), jobID, body.Reason, actor); err != nil {
if strings.Contains(err.Error(), "not found") { if errors.Is(err, repository.ErrNotFound) {
ErrorWithRequestID(w, http.StatusNotFound, "Job not found", requestID) ErrorWithRequestID(w, http.StatusNotFound, "Job not found", requestID)
return return
} }
@@ -27,7 +27,7 @@ func (m *mockNetworkScanService) GetTarget(ctx context.Context, id string) (*dom
return t, nil return t, nil
} }
} }
return nil, fmt.Errorf("not found: %s", id) return nil, fmt.Errorf("not found: %w", ErrMockNotFound)
} }
func (m *mockNetworkScanService) CreateTarget(ctx context.Context, target *domain.NetworkScanTarget) (*domain.NetworkScanTarget, error) { func (m *mockNetworkScanService) CreateTarget(ctx context.Context, target *domain.NetworkScanTarget) (*domain.NetworkScanTarget, error) {
@@ -48,7 +48,7 @@ func (m *mockNetworkScanService) UpdateTarget(ctx context.Context, id string, ta
return t, nil return t, nil
} }
} }
return nil, fmt.Errorf("not found: %s", id) return nil, fmt.Errorf("not found: %w", ErrMockNotFound)
} }
func (m *mockNetworkScanService) DeleteTarget(ctx context.Context, id string) error { func (m *mockNetworkScanService) DeleteTarget(ctx context.Context, id string) error {
@@ -58,7 +58,7 @@ func (m *mockNetworkScanService) DeleteTarget(ctx context.Context, id string) er
return nil return nil
} }
} }
return fmt.Errorf("not found: %s", id) return fmt.Errorf("not found: %w", ErrMockNotFound)
} }
func (m *mockNetworkScanService) TriggerScan(ctx context.Context, targetID string) (*domain.DiscoveryScan, error) { func (m *mockNetworkScanService) TriggerScan(ctx context.Context, targetID string) (*domain.DiscoveryScan, error) {
@@ -71,7 +71,7 @@ func (m *mockNetworkScanService) TriggerScan(ctx context.Context, targetID strin
}, nil }, nil
} }
} }
return nil, fmt.Errorf("not found: %s", targetID) return nil, fmt.Errorf("not found: %w", ErrMockNotFound)
} }
func TestListNetworkScanTargets(t *testing.T) { func TestListNetworkScanTargets(t *testing.T) {
+3 -1
View File
@@ -1,6 +1,8 @@
package handler package handler
import ( import (
"github.com/shankar0123/certctl/internal/repository"
"errors"
"context" "context"
"net/http" "net/http"
"strconv" "strconv"
@@ -170,7 +172,7 @@ func (h NotificationHandler) RequeueNotification(w http.ResponseWriter, r *http.
notificationID := parts[0] notificationID := parts[0]
if err := h.svc.RequeueNotification(r.Context(), notificationID); err != nil { if err := h.svc.RequeueNotification(r.Context(), notificationID); err != nil {
if strings.Contains(err.Error(), "not found") { if errors.Is(err, repository.ErrNotFound) {
ErrorWithRequestID(w, http.StatusNotFound, "Notification not found", requestID) ErrorWithRequestID(w, http.StatusNotFound, "Notification not found", requestID)
return return
} }
+4 -2
View File
@@ -1,6 +1,8 @@
package handler package handler
import ( import (
"github.com/shankar0123/certctl/internal/repository"
"errors"
"context" "context"
"encoding/json" "encoding/json"
"net/http" "net/http"
@@ -184,9 +186,9 @@ func (h OwnerHandler) DeleteOwner(w http.ResponseWriter, r *http.Request) {
id = parts[0] id = parts[0]
if err := h.svc.DeleteOwner(r.Context(), id); err != nil { if err := h.svc.DeleteOwner(r.Context(), id); err != nil {
if strings.Contains(err.Error(), "violates foreign key") || strings.Contains(err.Error(), "RESTRICT") { if repository.IsForeignKeyError(err) {
ErrorWithRequestID(w, http.StatusConflict, "Cannot delete owner: certificates are still assigned to this owner", requestID) ErrorWithRequestID(w, http.StatusConflict, "Cannot delete owner: certificates are still assigned to this owner", requestID)
} else if strings.Contains(err.Error(), "not found") { } else if errors.Is(err, repository.ErrNotFound) {
ErrorWithRequestID(w, http.StatusNotFound, "Owner not found", requestID) ErrorWithRequestID(w, http.StatusNotFound, "Owner not found", requestID)
} else { } else {
ErrorWithRequestID(w, http.StatusInternalServerError, "Failed to delete owner", requestID) ErrorWithRequestID(w, http.StatusInternalServerError, "Failed to delete owner", requestID)
+4 -2
View File
@@ -1,6 +1,8 @@
package handler package handler
import ( import (
"github.com/shankar0123/certctl/internal/repository"
"errors"
"context" "context"
"encoding/json" "encoding/json"
"net/http" "net/http"
@@ -162,7 +164,7 @@ func (h ProfileHandler) UpdateProfile(w http.ResponseWriter, r *http.Request) {
updated, err := h.svc.UpdateProfile(r.Context(), id, profile) updated, err := h.svc.UpdateProfile(r.Context(), id, profile)
if err != nil { if err != nil {
if strings.Contains(err.Error(), "not found") { if errors.Is(err, repository.ErrNotFound) {
ErrorWithRequestID(w, http.StatusNotFound, "Profile not found", requestID) ErrorWithRequestID(w, http.StatusNotFound, "Profile not found", requestID)
return return
} }
@@ -195,7 +197,7 @@ func (h ProfileHandler) DeleteProfile(w http.ResponseWriter, r *http.Request) {
} }
if err := h.svc.DeleteProfile(r.Context(), id); err != nil { if err := h.svc.DeleteProfile(r.Context(), id); err != nil {
if strings.Contains(err.Error(), "not found") { if errors.Is(err, repository.ErrNotFound) {
ErrorWithRequestID(w, http.StatusNotFound, "Profile not found", requestID) ErrorWithRequestID(w, http.StatusNotFound, "Profile not found", requestID)
return return
} }
+10 -9
View File
@@ -1,6 +1,7 @@
package handler package handler
import ( import (
"github.com/shankar0123/certctl/internal/repository"
"context" "context"
"encoding/json" "encoding/json"
"errors" "errors"
@@ -26,14 +27,14 @@ type RenewalPolicyService interface {
// RenewalPolicyHandler serves /api/v1/renewal-policies CRUD endpoints. // RenewalPolicyHandler serves /api/v1/renewal-policies CRUD endpoints.
// //
// G-1 design note: the service-level `ErrRenewalPolicyDuplicateName` / // G-1 + S-2 design note: the service-level `ErrRenewalPolicyDuplicateName` /
// `ErrRenewalPolicyInUse` sentinels alias the repository sentinels (same var // `ErrRenewalPolicyInUse` sentinels alias the repository sentinels (same var
// identity), so `errors.Is` walks transparently across layers. Delete/Update // identity), so `errors.Is` walks transparently across layers. S-2 closure
// not-found detection intentionally uses a `strings.Contains(err.Error(), // (cat-s6-efc7f6f6bd50) extends the same convention to not-found detection:
// "not found")` substring check — the repo wraps `sql.ErrNoRows` as // repos now wrap `sql.ErrNoRows` via `fmt.Errorf("X not found: %w",
// `fmt.Errorf("renewal policy not found: %s", id)` which strips the sentinel, // repository.ErrNotFound)`, handler dispatch uses
// and the handler red-tests' `ErrMockNotFound = errors.New("mock not found // `errors.Is(err, repository.ErrNotFound)`, and `ErrMockNotFound` in
// error")` follows the same substring convention. // test_utils.go wraps the same sentinel so the mocks still resolve to 404.
type RenewalPolicyHandler struct { type RenewalPolicyHandler struct {
svc RenewalPolicyService svc RenewalPolicyService
} }
@@ -191,7 +192,7 @@ func (h RenewalPolicyHandler) UpdateRenewalPolicy(w http.ResponseWriter, r *http
ErrorWithRequestID(w, http.StatusConflict, "A renewal policy with that name already exists", requestID) ErrorWithRequestID(w, http.StatusConflict, "A renewal policy with that name already exists", requestID)
return return
} }
if strings.Contains(err.Error(), "not found") { if errors.Is(err, repository.ErrNotFound) {
ErrorWithRequestID(w, http.StatusNotFound, "Renewal policy not found", requestID) ErrorWithRequestID(w, http.StatusNotFound, "Renewal policy not found", requestID)
return return
} }
@@ -231,7 +232,7 @@ func (h RenewalPolicyHandler) DeleteRenewalPolicy(w http.ResponseWriter, r *http
ErrorWithRequestID(w, http.StatusConflict, "Renewal policy is still referenced by managed certificates", requestID) ErrorWithRequestID(w, http.StatusConflict, "Renewal policy is still referenced by managed certificates", requestID)
return return
} }
if strings.Contains(err.Error(), "not found") { if errors.Is(err, repository.ErrNotFound) {
ErrorWithRequestID(w, http.StatusNotFound, "Renewal policy not found", requestID) ErrorWithRequestID(w, http.StatusNotFound, "Renewal policy not found", requestID)
return return
} }
+18 -7
View File
@@ -1,11 +1,22 @@
package handler package handler
import "errors" import (
"fmt"
var ( "github.com/shankar0123/certctl/internal/repository"
// Mock errors for testing )
ErrMockServiceFailed = errors.New("mock service error")
ErrMockNotFound = errors.New("mock not found error") // Mock errors for testing.
ErrMockUnauthorized = errors.New("mock unauthorized error") //
ErrMockConflict = errors.New("mock conflict error") // S-2 closure (cat-s6-efc7f6f6bd50): ErrMockNotFound now wraps
// repository.ErrNotFound via fmt.Errorf("...: %w", ...) so the
// post-S-2 handler dispatch — which uses errors.Is(err,
// repository.ErrNotFound) instead of strings.Contains — still
// resolves the mock to a 404. The error message text is preserved
// for log inspection; only the wrapping changes.
var (
ErrMockServiceFailed = fmt.Errorf("mock service error")
ErrMockNotFound = fmt.Errorf("mock not found error: %w", repository.ErrNotFound)
ErrMockUnauthorized = fmt.Errorf("mock unauthorized error")
ErrMockConflict = fmt.Errorf("mock conflict error")
) )
+158
View File
@@ -0,0 +1,158 @@
package handler
import (
"net/http"
"runtime"
"runtime/debug"
)
// VersionHandler exposes the running server's build identity at
// /api/v1/version. U-3 ride-along (cat-u-no_version_endpoint, P2): pre-U-3
// there was no in-band way for an operator (or an automated rollout system)
// to ask "what version of certctl is this binary?" — they had to either read
// the container image tag externally or trust whatever the README said. The
// gap matters for the same operability story U-3 closes: when fresh-clone
// quickstarts fail, the very first question is "what code did I actually
// build", and the only honest answer needs to come from the binary itself.
//
// VersionInfo is populated from three sources, in priority order:
//
// 1. The Version field — typically supplied at build time via
// `-ldflags='-X github.com/shankar0123/certctl/internal/api/handler.Version=v2.0.50'`.
// Production releases set this from the git tag (see release.yml).
//
// 2. runtime/debug.ReadBuildInfo() — populated by Go 1.18+ for any binary
// built from a module. Provides the VCS commit SHA, dirty flag, and
// build timestamp. We read these fields directly so a `go build` from a
// working tree (no -ldflags incantation) still produces a useful
// /api/v1/version payload — the failure mode pre-U-3 was that everything
// looked like "dev" everywhere, which made "is the bug fixed in this
// binary" unanswerable.
//
// 3. Static fallbacks ("dev" / "unknown") — only reached when neither
// ldflags nor build-info are populated, which in practice means
// `go run` from a non-VCS-tracked workspace.
//
// The handler runs through the no-auth bypass dispatch in cmd/server/main.go
// so probes and rollout systems can query it without presenting Bearer
// credentials, mirroring how /health and /ready are reachable. Audit logging
// excludes /api/v1/version for the same reason — the path is hot under
// rollout polling and would otherwise dominate the audit trail.
type VersionHandler struct{}
// Version is overridden at build time via:
//
// -ldflags='-X github.com/shankar0123/certctl/internal/api/handler.Version=<tag>'
//
// release.yml does this for the server container and CLI/agent binaries.
// The empty default (rather than "dev") lets the Handler fall back to the
// runtime/debug VCS revision when ldflags wasn't supplied — preferable to
// returning a literal "dev" that masks the actual git SHA the binary was
// built from.
var Version = ""
// NewVersionHandler returns a value (not a pointer) to match the
// HealthHandler convention — the handler holds no mutable state and is
// safe to copy.
func NewVersionHandler() VersionHandler {
return VersionHandler{}
}
// VersionInfo is the JSON shape returned by GET /api/v1/version.
//
// Field ordering and tag names are part of the contract — operator tooling
// (k8s rollout checks, CI smoke tests, /api/v1/version Prometheus blackbox
// probes) parses this payload and must continue to work across releases.
// Don't rename a field without an OpenAPI bump and a deprecation cycle.
type VersionInfo struct {
// Version is the human-readable release identifier (e.g. "v2.0.50").
// Falls back to the VCS revision when ldflags wasn't set, and to "dev"
// when the build wasn't VCS-tracked at all.
Version string `json:"version"`
// Commit is the git SHA of HEAD at build time, sourced from
// runtime/debug.BuildInfo.Settings["vcs.revision"]. Empty string when
// the binary was built outside a VCS-tracked workspace (rare —
// `go build` from a tarball does this).
Commit string `json:"commit"`
// Modified reports whether the build had uncommitted changes
// (debug.BuildInfo.Settings["vcs.modified"]). True for developer
// builds, false for release builds out of CI.
Modified bool `json:"modified"`
// BuildTime is the RFC 3339 timestamp captured at build time
// (debug.BuildInfo.Settings["vcs.time"]). Empty when not VCS-tracked.
BuildTime string `json:"build_time"`
// GoVersion is the Go toolchain version that compiled the binary
// (runtime.Version, e.g. "go1.25.9"). Useful when triaging stdlib
// behavior differences ("the deploy that broke was on 1.24, this one
// is on 1.25").
GoVersion string `json:"go_version"`
}
// readBuildInfo extracts the VCS settings from debug.BuildInfo and pairs
// them with the ldflags-supplied Version. Split out from ServeHTTP so the
// handler can be unit-tested by injecting synthetic BuildInfo (see
// version_handler_test.go) without depending on the test binary's actual
// debug info.
//
// debug.ReadBuildInfo returns ok=false when the binary was built without
// module info — extremely rare for a Go 1.18+ build, but we guard it so
// the handler degrades to "dev / unknown / runtime.Version()" instead of
// nil-deref panicking.
func readBuildInfo() VersionInfo {
info := VersionInfo{
Version: Version,
GoVersion: runtime.Version(),
}
bi, ok := debug.ReadBuildInfo()
if !ok {
// Pre-Go 1.18 binary or a stripped build with no buildinfo segment.
// Both are pathological in 2026 but worth the two-line guard.
if info.Version == "" {
info.Version = "dev"
}
return info
}
for _, s := range bi.Settings {
switch s.Key {
case "vcs.revision":
info.Commit = s.Value
case "vcs.modified":
// debug.BuildInfo encodes this as the literal string "true" or
// "false"; comparing to "true" is the canonical pattern (mirrors
// how the standard library's own version sub-command parses it).
info.Modified = s.Value == "true"
case "vcs.time":
info.BuildTime = s.Value
}
}
// Fallback ladder for Version: ldflags > VCS commit > "dev". The git
// SHA is more useful than "dev" because it's at least groundable — an
// operator can `git show <sha>` to see what code is actually running.
if info.Version == "" {
if info.Commit != "" {
info.Version = info.Commit
} else {
info.Version = "dev"
}
}
return info
}
// ServeHTTP implements http.Handler. Returns the VersionInfo payload as
// JSON with a 200 status. GET-only — any other method returns 405, matching
// the HealthHandler convention.
func (h VersionHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
JSON(w, http.StatusOK, readBuildInfo())
}
@@ -0,0 +1,108 @@
package handler
import (
"encoding/json"
"net/http"
"net/http/httptest"
"runtime"
"strings"
"testing"
)
// TestVersion_ReturnsBuildInfo is the regression for the U-3 ride-along
// cat-u-no_version_endpoint (P2). Three behaviors must hold for the
// endpoint to be useful in operator tooling:
//
// 1. GET /api/v1/version returns 200 with a JSON body that decodes into
// the documented VersionInfo shape — the wire contract that rollout
// systems and Prometheus blackbox probes parse.
// 2. The Go runtime version always populates (runtime.Version() can never
// return empty), so consumers can always answer "which Go did this
// binary compile with" even when ldflags / VCS info are missing.
// 3. The Version field is never empty — the fallback ladder
// (ldflags > VCS commit > "dev") guarantees a non-empty string so
// consumers don't have to special-case absent values.
//
// We don't pin the exact Version value because it depends on whether the
// test binary was built with -ldflags or under `go test`, both of which
// the handler must tolerate. The "no empty string" check is the
// behavioral contract.
func TestVersion_ReturnsBuildInfo(t *testing.T) {
h := NewVersionHandler()
req := httptest.NewRequest(http.MethodGet, "/api/v1/version", nil)
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want 200", rec.Code)
}
contentType := rec.Header().Get("Content-Type")
if !strings.HasPrefix(contentType, "application/json") {
t.Errorf("Content-Type = %q, want application/json prefix (operator tooling parses JSON)", contentType)
}
var got VersionInfo
if err := json.NewDecoder(rec.Body).Decode(&got); err != nil {
t.Fatalf("response body did not decode into VersionInfo: %v\nbody: %s", err, rec.Body.String())
}
// Version must never be empty — the fallback ladder in readBuildInfo
// guarantees this. An empty Version would force every downstream
// consumer (k8s rollouts, Prometheus blackbox, the support tooling)
// to special-case the missing value, which defeats the point of
// /api/v1/version existing.
if got.Version == "" {
t.Error("Version is empty — the fallback ladder (ldflags > VCS commit > 'dev') must guarantee a non-empty value")
}
// GoVersion must equal runtime.Version() — the handler reads it
// directly and cannot be subverted by ldflags or BuildInfo. This is
// the one field that should always be ground-truth.
if got.GoVersion != runtime.Version() {
t.Errorf("GoVersion = %q, want %q (must come straight from runtime.Version())",
got.GoVersion, runtime.Version())
}
}
// TestVersion_RejectsNonGet pins the GET-only contract. /api/v1/version
// is read-only build identity; POST/PUT/DELETE etc. are nonsensical and
// should return 405 like the HealthHandler does. Operator tooling that
// fat-fingers the verb gets a clear error rather than a confusing 200
// from the wrong code path.
func TestVersion_RejectsNonGet(t *testing.T) {
h := NewVersionHandler()
for _, method := range []string{
http.MethodPost, http.MethodPut, http.MethodDelete, http.MethodPatch,
} {
req := httptest.NewRequest(method, "/api/v1/version", nil)
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != http.StatusMethodNotAllowed {
t.Errorf("%s /api/v1/version → status %d, want 405", method, rec.Code)
}
}
}
// TestVersion_LdflagsOverride locks in the priority order: when the
// build-time Version variable is non-empty (e.g. "v2.0.50" injected by
// release.yml), readBuildInfo MUST surface that value verbatim and not
// silently substitute the VCS commit. The release-pipeline contract
// depends on this — a release tagged v2.0.50 should report "v2.0.50",
// not the underlying SHA.
//
// We achieve test isolation by save/restore on the package-level Version
// variable; t.Cleanup ensures parallel/subsequent tests see the original.
func TestVersion_LdflagsOverride(t *testing.T) {
original := Version
t.Cleanup(func() { Version = original })
Version = "v2.0.50-test"
got := readBuildInfo()
if got.Version != "v2.0.50-test" {
t.Errorf("Version = %q, want %q (ldflags-supplied Version must take priority over VCS fallback)",
got.Version, "v2.0.50-test")
}
}
@@ -0,0 +1,96 @@
package middleware
import (
"net/http"
"strings"
)
// SecurityHeadersConfig configures the SecurityHeaders middleware.
//
// Each field is the literal value to send. An empty string means
// "do not send this header" — operators behind a customising reverse
// proxy can disable any header per-deployment without touching code.
// Defaults are applied via SecurityHeadersDefaults() which encodes
// the H-1 closure's recommended baseline for an HTTPS-only API+UI
// host: HSTS, deny-frame, no-MIME-sniff, conservative CSP, and a
// no-referrer-when-downgrade fallback.
//
// H-1 closure (cat-s11-missing_security_headers).
type SecurityHeadersConfig struct {
HSTS string // Strict-Transport-Security
FrameOptions string // X-Frame-Options
ContentTypeOptions string // X-Content-Type-Options
ReferrerPolicy string // Referrer-Policy
ContentSecurityPolicy string // Content-Security-Policy
}
// SecurityHeadersDefaults returns a recommended baseline.
//
// CSP: default-src 'self' confines fetches to the same origin.
// img-src 'self' data: allows inline base64 images (used by the
// dashboard's certctl-logo and a few status icons).
// style-src 'self' 'unsafe-inline' is required because Tailwind
// (via Vite) injects per-component <style> blocks at build time;
// without 'unsafe-inline' the dashboard would render unstyled.
// 'unsafe-inline' is intentionally NOT in script-src — the
// front-end ships as a bundled JS file, no inline scripts.
//
// HSTS: 1-year max-age + includeSubDomains. No `preload` directive
// because preload submission requires explicit operator action and
// the deployment topology may not span all subdomains.
//
// X-Frame-Options: DENY — the dashboard does not need to be embedded
// anywhere, and DENY is more conservative than SAMEORIGIN against
// clickjacking via subdomain takeover.
//
// X-Content-Type-Options: nosniff — prevent MIME sniffing on
// JSON/PEM responses that browsers might otherwise interpret as HTML.
//
// Referrer-Policy: no-referrer-when-downgrade — preserves Referer
// for same-origin navigation (useful for support/diagnostics) but
// strips it on HTTPS→HTTP transitions.
func SecurityHeadersDefaults() SecurityHeadersConfig {
return SecurityHeadersConfig{
HSTS: "max-age=31536000; includeSubDomains",
FrameOptions: "DENY",
ContentTypeOptions: "nosniff",
ReferrerPolicy: "no-referrer-when-downgrade",
ContentSecurityPolicy: "default-src 'self'; img-src 'self' data:; style-src 'self' 'unsafe-inline'; script-src 'self'; connect-src 'self'; frame-ancestors 'none'",
}
}
// SecurityHeaders returns a middleware that applies the configured
// HTTP response headers on every response. Headers configured to the
// empty string are omitted (operator opted out for that deployment).
//
// Apply BEFORE the audit middleware so headers reach 4xx/5xx responses
// — which is where header omissions matter most for the security
// posture (an attacker probing for misconfiguration sees the same
// headers on a 401 as on a 200).
func SecurityHeaders(cfg SecurityHeadersConfig) func(http.Handler) http.Handler {
// Pre-trim each value once; the per-request hot path stays a
// straight set of map writes.
type headerEntry struct{ name, value string }
entries := make([]headerEntry, 0, 5)
add := func(name, value string) {
v := strings.TrimSpace(value)
if v != "" {
entries = append(entries, headerEntry{name, v})
}
}
add("Strict-Transport-Security", cfg.HSTS)
add("X-Frame-Options", cfg.FrameOptions)
add("X-Content-Type-Options", cfg.ContentTypeOptions)
add("Referrer-Policy", cfg.ReferrerPolicy)
add("Content-Security-Policy", cfg.ContentSecurityPolicy)
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
h := w.Header()
for _, e := range entries {
h.Set(e.name, e.value)
}
next.ServeHTTP(w, r)
})
}
}
@@ -0,0 +1,104 @@
package middleware
import (
"net/http"
"net/http/httptest"
"testing"
)
// TestSecurityHeaders_DefaultsAllPresent asserts every default header
// arrives on a 200 response. H-1 closure (cat-s11-missing_security_headers).
func TestSecurityHeaders_DefaultsAllPresent(t *testing.T) {
mw := SecurityHeaders(SecurityHeadersDefaults())
handler := mw(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte("ok"))
}))
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/test", nil)
handler.ServeHTTP(rec, req)
for _, h := range []string{
"Strict-Transport-Security",
"X-Frame-Options",
"X-Content-Type-Options",
"Referrer-Policy",
"Content-Security-Policy",
} {
if got := rec.Header().Get(h); got == "" {
t.Errorf("expected header %q to be set, got empty", h)
}
}
if got := rec.Header().Get("X-Content-Type-Options"); got != "nosniff" {
t.Errorf("X-Content-Type-Options: got %q, want %q", got, "nosniff")
}
if got := rec.Header().Get("X-Frame-Options"); got != "DENY" {
t.Errorf("X-Frame-Options: got %q, want %q", got, "DENY")
}
}
// TestSecurityHeaders_EmptyValueDisablesHeader asserts an operator can
// disable a single header by setting its config field to empty without
// affecting the others.
func TestSecurityHeaders_EmptyValueDisablesHeader(t *testing.T) {
cfg := SecurityHeadersDefaults()
cfg.HSTS = "" // simulate operator override
mw := SecurityHeaders(cfg)
handler := mw(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusOK)
}))
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/", nil))
if got := rec.Header().Get("Strict-Transport-Security"); got != "" {
t.Errorf("HSTS should be omitted when config value is empty; got %q", got)
}
// Other headers still present
if got := rec.Header().Get("X-Frame-Options"); got == "" {
t.Errorf("X-Frame-Options should still be present (empty HSTS only disables HSTS)")
}
}
// TestSecurityHeaders_OverrideValueApplied asserts a non-default value
// makes it through.
func TestSecurityHeaders_OverrideValueApplied(t *testing.T) {
cfg := SecurityHeadersDefaults()
cfg.FrameOptions = "SAMEORIGIN"
mw := SecurityHeaders(cfg)
handler := mw(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusOK)
}))
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/", nil))
if got := rec.Header().Get("X-Frame-Options"); got != "SAMEORIGIN" {
t.Errorf("X-Frame-Options: got %q, want %q", got, "SAMEORIGIN")
}
}
// TestSecurityHeaders_AppliedOnErrorResponses asserts headers are
// present on 4xx/5xx as well as 2xx — this is critical for the
// security posture (an attacker probing for misconfiguration sees
// the same headers on a 401 as on a 200).
func TestSecurityHeaders_AppliedOnErrorResponses(t *testing.T) {
mw := SecurityHeaders(SecurityHeadersDefaults())
handler := mw(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
http.Error(w, "unauthorized", http.StatusUnauthorized)
}))
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/", nil))
if rec.Code != http.StatusUnauthorized {
t.Fatalf("status: got %d, want %d", rec.Code, http.StatusUnauthorized)
}
if got := rec.Header().Get("Strict-Transport-Security"); got == "" {
t.Errorf("HSTS missing on 401 response (must be on every response)")
}
if got := rec.Header().Get("Content-Security-Policy"); got == "" {
t.Errorf("CSP missing on 401 response")
}
}
+33 -2
View File
@@ -67,7 +67,18 @@ type HandlerRegistry struct {
Digest handler.DigestHandler Digest handler.DigestHandler
HealthChecks *handler.HealthCheckHandler HealthChecks *handler.HealthCheckHandler
BulkRevocation handler.BulkRevocationHandler BulkRevocation handler.BulkRevocationHandler
RenewalPolicies handler.RenewalPolicyHandler // L-1 master closure (cat-l-fa0c1ac07ab5 + cat-l-8a1fb258a38a):
// server-side bulk endpoints replace pre-L-1 client-side N×HTTP
// loops in CertificatesPage.tsx. See handler/bulk_renewal.go and
// handler/bulk_reassignment.go.
BulkRenewal handler.BulkRenewalHandler
BulkReassignment handler.BulkReassignmentHandler
RenewalPolicies handler.RenewalPolicyHandler
// Version handles GET /api/v1/version (U-3 ride-along,
// cat-u-no_version_endpoint). Wired through the no-auth dispatch in
// cmd/server/main.go so probes and rollout systems can read build
// identity without Bearer credentials. See handler/version.go.
Version handler.VersionHandler
} }
// RegisterHandlers sets up all API routes with their handlers. // RegisterHandlers sets up all API routes with their handlers.
@@ -89,12 +100,32 @@ func (r *Router) RegisterHandlers(reg HandlerRegistry) {
middleware.CORS, middleware.CORS,
middleware.ContentType, middleware.ContentType,
)) ))
// Version endpoint (no auth middleware — used by rollout probes that
// don't carry Bearer tokens; the dispatch layer in cmd/server/main.go
// also routes /api/v1/version through the no-auth chain). U-3 ride-along
// (cat-u-no_version_endpoint, P2). The handler reads
// runtime/debug.BuildInfo for VCS attribution; ldflags-supplied Version
// is preferred when present.
r.mux.Handle("GET /api/v1/version", middleware.Chain(
reg.Version,
middleware.CORS,
middleware.ContentType,
))
// Auth check endpoint (uses full middleware chain via r.Register) // Auth check endpoint (uses full middleware chain via r.Register)
r.Register("GET /api/v1/auth/check", http.HandlerFunc(reg.Health.AuthCheck)) r.Register("GET /api/v1/auth/check", http.HandlerFunc(reg.Health.AuthCheck))
// Certificates routes: /api/v1/certificates // Certificates routes: /api/v1/certificates
// Bulk revoke must be registered before {id} routes to avoid path conflict // Bulk operations MUST register before {id} routes — Go 1.22 ServeMux
// gives literal segments precedence over pattern-var segments, but
// listing the bulk paths first makes the precedence operator-visible
// and prevents a future refactor from accidentally inverting it. All
// three bulk endpoints share the same envelope shape (criteria/IDs
// in, {total_matched, total_<verb>, total_skipped, total_failed,
// errors[]} out). L-1 master added bulk-renew + bulk-reassign
// alongside the pre-existing bulk-revoke.
r.Register("POST /api/v1/certificates/bulk-revoke", http.HandlerFunc(reg.BulkRevocation.BulkRevoke)) r.Register("POST /api/v1/certificates/bulk-revoke", http.HandlerFunc(reg.BulkRevocation.BulkRevoke))
r.Register("POST /api/v1/certificates/bulk-renew", http.HandlerFunc(reg.BulkRenewal.BulkRenew))
r.Register("POST /api/v1/certificates/bulk-reassign", http.HandlerFunc(reg.BulkReassignment.BulkReassign))
r.Register("GET /api/v1/certificates", http.HandlerFunc(reg.Certificates.ListCertificates)) r.Register("GET /api/v1/certificates", http.HandlerFunc(reg.Certificates.ListCertificates))
r.Register("POST /api/v1/certificates", http.HandlerFunc(reg.Certificates.CreateCertificate)) r.Register("POST /api/v1/certificates", http.HandlerFunc(reg.Certificates.CreateCertificate))
r.Register("GET /api/v1/certificates/{id}", http.HandlerFunc(reg.Certificates.GetCertificate)) r.Register("GET /api/v1/certificates/{id}", http.HandlerFunc(reg.Certificates.GetCertificate))
+46
View File
@@ -709,6 +709,16 @@ type DatabaseConfig struct {
URL string URL string
MaxConnections int MaxConnections int
MigrationsPath string MigrationsPath string
// DemoSeed, when true, makes the server apply
// `<MigrationsPath>/seed_demo.sql` after the baseline `seed.sql`. Set
// via CERTCTL_DEMO_SEED. The compose demo overlay
// (deploy/docker-compose.demo.yml) sets this to keep the demo path
// alive after U-3 dropped initdb-mounted seed files. The seed file
// uses ON CONFLICT (id) DO NOTHING so re-running on a populated
// database is safe; missing-file is a no-op (returns nil) so a
// minimal-image deploy that strips seed_demo.sql still boots cleanly.
DemoSeed bool
} }
// SchedulerConfig contains scheduler timing configuration. // SchedulerConfig contains scheduler timing configuration.
@@ -774,6 +784,18 @@ type SchedulerConfig struct {
// second. // second.
// Setting: CERTCTL_JOB_AWAITING_APPROVAL_TIMEOUT environment variable. // Setting: CERTCTL_JOB_AWAITING_APPROVAL_TIMEOUT environment variable.
AwaitingApprovalTimeout time.Duration AwaitingApprovalTimeout time.Duration
// ShortLivedExpiryCheckInterval is how often the scheduler scans
// short-lived certificates and marks expired rows as Expired. Default:
// 30 seconds (matches the in-memory default in scheduler.NewScheduler).
// C-1 closure (cat-g-7e38f9708e20 + diff-10xmain-2bf4a0a60388):
// pre-C-1 the setter scheduler.SetShortLivedExpiryCheckInterval was
// defined + tested but never called from cmd/server/main.go, so the
// 30-second default was effectively hardcoded. Operators who needed
// to tune the cadence (e.g. a high-churn short-lived cert tenant)
// had no path. Post-C-1 main.go wires this knob.
// Setting: CERTCTL_SHORT_LIVED_EXPIRY_CHECK_INTERVAL environment variable.
ShortLivedExpiryCheckInterval time.Duration
} }
// LogConfig contains logging configuration. // LogConfig contains logging configuration.
@@ -921,6 +943,7 @@ func Load() (*Config, error) {
URL: getEnv("CERTCTL_DATABASE_URL", "postgres://localhost/certctl"), URL: getEnv("CERTCTL_DATABASE_URL", "postgres://localhost/certctl"),
MaxConnections: getEnvInt("CERTCTL_DATABASE_MAX_CONNS", 25), MaxConnections: getEnvInt("CERTCTL_DATABASE_MAX_CONNS", 25),
MigrationsPath: getEnv("CERTCTL_DATABASE_MIGRATIONS_PATH", "./migrations"), MigrationsPath: getEnv("CERTCTL_DATABASE_MIGRATIONS_PATH", "./migrations"),
DemoSeed: getEnvBool("CERTCTL_DEMO_SEED", false),
}, },
Scheduler: SchedulerConfig{ Scheduler: SchedulerConfig{
RenewalCheckInterval: getEnvDuration("CERTCTL_SCHEDULER_RENEWAL_CHECK_INTERVAL", 1*time.Hour), RenewalCheckInterval: getEnvDuration("CERTCTL_SCHEDULER_RENEWAL_CHECK_INTERVAL", 1*time.Hour),
@@ -937,6 +960,9 @@ func Load() (*Config, error) {
JobTimeoutInterval: getEnvDuration("CERTCTL_JOB_TIMEOUT_INTERVAL", 10*time.Minute), JobTimeoutInterval: getEnvDuration("CERTCTL_JOB_TIMEOUT_INTERVAL", 10*time.Minute),
AwaitingCSRTimeout: getEnvDuration("CERTCTL_JOB_AWAITING_CSR_TIMEOUT", 24*time.Hour), AwaitingCSRTimeout: getEnvDuration("CERTCTL_JOB_AWAITING_CSR_TIMEOUT", 24*time.Hour),
AwaitingApprovalTimeout: getEnvDuration("CERTCTL_JOB_AWAITING_APPROVAL_TIMEOUT", 168*time.Hour), AwaitingApprovalTimeout: getEnvDuration("CERTCTL_JOB_AWAITING_APPROVAL_TIMEOUT", 168*time.Hour),
// C-1 closure: matches the in-memory default at
// internal/scheduler/scheduler.go:145 (30 * time.Second).
ShortLivedExpiryCheckInterval: getEnvDuration("CERTCTL_SHORT_LIVED_EXPIRY_CHECK_INTERVAL", 30*time.Second),
}, },
Log: LogConfig{ Log: LogConfig{
Level: getEnv("CERTCTL_LOG_LEVEL", "info"), Level: getEnv("CERTCTL_LOG_LEVEL", "info"),
@@ -1165,6 +1191,26 @@ func (c *Config) Validate() error {
return fmt.Errorf("server TLS cert/key pair invalid (cert=%q key=%q): %w — refuse to start (HTTPS-only; see docs/tls.md)", c.Server.TLS.CertPath, c.Server.TLS.KeyPath, err) return fmt.Errorf("server TLS cert/key pair invalid (cert=%q key=%q): %w — refuse to start (HTTPS-only; see docs/tls.md)", c.Server.TLS.CertPath, c.Server.TLS.KeyPath, err)
} }
// H-1 closure (cat-r-encryption_key_no_length_validation): if
// CERTCTL_CONFIG_ENCRYPTION_KEY is set, enforce a minimum length of
// 32 bytes. Pre-H-1 the field was accepted with any non-empty value
// — including a single character — and PBKDF2-SHA256 (100k rounds)
// alone does not compensate for low-entropy passphrases at scale
// (CWE-916 Use of Password Hash With Insufficient Computational
// Effort + CWE-329 Generation of Predictable IV with CBC Mode).
// 32 bytes ≈ 256 bits when generated via `openssl rand -base64 32`,
// matching the AES-256-GCM key size the passphrase derives. An
// empty key remains accepted — the fail-closed sentinel
// crypto.ErrEncryptionKeyRequired triggers downstream when an
// empty key is asked to encrypt or decrypt sensitive config.
const minEncryptionKeyLength = 32
if c.Encryption.ConfigEncryptionKey != "" && len(c.Encryption.ConfigEncryptionKey) < minEncryptionKeyLength {
return fmt.Errorf(
"CERTCTL_CONFIG_ENCRYPTION_KEY too short (%d bytes; minimum %d). Generate with: openssl rand -base64 32",
len(c.Encryption.ConfigEncryptionKey), minEncryptionKeyLength,
)
}
// Validate database configuration // Validate database configuration
if c.Database.URL == "" { if c.Database.URL == "" {
return fmt.Errorf("database URL is required") return fmt.Errorf("database URL is required")
+81
View File
@@ -1209,3 +1209,84 @@ func TestConfig_Scheduler_JobTimeoutValidation(t *testing.T) {
}) })
} }
} }
// H-1 closure (cat-r-encryption_key_no_length_validation): validate
// CERTCTL_CONFIG_ENCRYPTION_KEY length. Pre-H-1 the field was accepted
// with any non-empty value (including a single character); post-H-1 a
// minimum 32-byte length is enforced. Empty stays accepted because the
// downstream fail-closed sentinel crypto.ErrEncryptionKeyRequired
// handles the missing-key case for the encrypt/decrypt paths.
func validBaseConfigForEncryption(t *testing.T) *Config {
t.Helper()
return &Config{
Server: validServerConfig(t),
Database: DatabaseConfig{URL: "postgres://localhost/certctl", MaxConnections: 25},
Log: LogConfig{Level: "info", Format: "json"},
Auth: AuthConfig{Type: "api-key", Secret: "test-secret"},
Keygen: KeygenConfig{Mode: "agent"},
Scheduler: SchedulerConfig{
RenewalCheckInterval: 1 * time.Hour,
JobProcessorInterval: 30 * time.Second,
AgentHealthCheckInterval: 2 * time.Minute,
NotificationProcessInterval: 1 * time.Minute,
NotificationRetryInterval: 2 * time.Minute,
RetryInterval: 5 * time.Minute,
JobTimeoutInterval: 10 * time.Minute,
AwaitingCSRTimeout: 24 * time.Hour,
AwaitingApprovalTimeout: 168 * time.Hour,
},
}
}
func TestValidate_EncryptionKey_EmptyAccepted(t *testing.T) {
cfg := validBaseConfigForEncryption(t)
cfg.Encryption.ConfigEncryptionKey = ""
if err := cfg.Validate(); err != nil {
t.Errorf("Validate() returned error for empty key: %v (empty must be accepted; fail-closed sentinel handles it downstream)", err)
}
}
func TestValidate_EncryptionKey_TooShortRejected(t *testing.T) {
cfg := validBaseConfigForEncryption(t)
cfg.Encryption.ConfigEncryptionKey = "x" // 1 byte
err := cfg.Validate()
if err == nil {
t.Fatal("Validate() = nil, want error for 1-byte key")
}
if !strings.Contains(err.Error(), "too short") {
t.Errorf("Validate() error = %q, want to contain %q", err.Error(), "too short")
}
if !strings.Contains(err.Error(), "openssl rand -base64 32") {
t.Errorf("Validate() error = %q, must include the canonical generation command", err.Error())
}
}
func TestValidate_EncryptionKey_BoundaryRejected(t *testing.T) {
cfg := validBaseConfigForEncryption(t)
cfg.Encryption.ConfigEncryptionKey = "12345678901234567890123456789012"[:31] // 31 bytes — one short
err := cfg.Validate()
if err == nil {
t.Fatal("Validate() = nil, want error for 31-byte key (boundary -1)")
}
if !strings.Contains(err.Error(), "too short") {
t.Errorf("Validate() error = %q, want 'too short'", err.Error())
}
}
func TestValidate_EncryptionKey_MinLengthAccepted(t *testing.T) {
cfg := validBaseConfigForEncryption(t)
cfg.Encryption.ConfigEncryptionKey = "12345678901234567890123456789012" // exactly 32 bytes
if err := cfg.Validate(); err != nil {
t.Errorf("Validate() returned error for 32-byte key: %v", err)
}
}
func TestValidate_EncryptionKey_LongAccepted(t *testing.T) {
cfg := validBaseConfigForEncryption(t)
// Realistic operator key from `openssl rand -base64 32` — 44 characters.
cfg.Encryption.ConfigEncryptionKey = "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"
if err := cfg.Validate(); err != nil {
t.Errorf("Validate() returned error for 44-byte key: %v", err)
}
}
+51
View File
@@ -0,0 +1,51 @@
package domain
// BulkReassignmentRequest is the input to POST /api/v1/certificates/bulk-reassign.
//
// L-2 closure (cat-l-8a1fb258a38a): the GUI used to loop
// `await updateCertificate(id, { owner_id: ownerId })` over the selection
// at `web/src/pages/CertificatesPage.tsx::handleReassign`. Post-L-2 it
// POSTs once.
//
// Narrower than BulkRenewalCriteria — the operator workflow is "I have N
// certs selected and I want them all owned by Alice now". Criteria-mode
// reassignment doesn't have a strong use case (operators query first,
// then reassign by ID), so the request is IDs-only. OwnerID is required;
// TeamID is optional and the cert's team_id is updated only when TeamID
// is non-empty (matches the existing per-cert PUT behaviour where empty
// fields leave the existing value unchanged).
type BulkReassignmentRequest struct {
CertificateIDs []string `json:"certificate_ids"`
OwnerID string `json:"owner_id"`
TeamID string `json:"team_id,omitempty"`
}
// IsEmpty returns true if no IDs are provided. The service layer rejects
// empty IDs with a 400 — explicit-IDs is the only selection mode for
// reassignment (no criteria-mode). Naming mirrors BulkRevocationCriteria
// + BulkRenewalCriteria.IsEmpty so the validate-and-reject pattern is
// the same across all three bulk endpoints.
func (r BulkReassignmentRequest) IsEmpty() bool {
return len(r.CertificateIDs) == 0
}
// BulkReassignmentResult mirrors BulkRevocationResult / BulkRenewalResult
// envelope shape so the frontend's bulk-result rendering is one helper.
//
// Counters semantics:
// - TotalMatched: number of certs resolved from CertificateIDs
// - TotalReassigned: number where owner_id (and optionally team_id)
// was actually mutated
// - TotalSkipped: certs already owned by the target OwnerID — no-op
// skip rather than a fake "succeeded" count, so operators see "5 of
// your 10 selections were no-ops" without triaging fake errors
// - TotalFailed: certs where the per-cert update returned an error
// (e.g., the cert no longer exists, the repo update failed)
// - Errors: per-cert error details for the failure path
type BulkReassignmentResult struct {
TotalMatched int `json:"total_matched"`
TotalReassigned int `json:"total_reassigned"`
TotalSkipped int `json:"total_skipped"`
TotalFailed int `json:"total_failed"`
Errors []BulkOperationError `json:"errors,omitempty"`
}
+77
View File
@@ -0,0 +1,77 @@
package domain
import (
"encoding/json"
"testing"
)
func TestBulkReassignmentRequest_IsEmpty(t *testing.T) {
t.Parallel()
tests := []struct {
name string
r BulkReassignmentRequest
want bool
}{
{"all-zero", BulkReassignmentRequest{}, true},
{"empty-ids-slice", BulkReassignmentRequest{CertificateIDs: []string{}}, true},
{"ids-set-but-no-owner", BulkReassignmentRequest{CertificateIDs: []string{"mc-1"}}, false},
// IsEmpty is a pure ID-presence check; OwnerID/TeamID are
// validated separately in the service layer (OwnerID required;
// TeamID optional). This split mirrors how BulkRevocationCriteria
// + reason are validated in two distinct steps.
{"owner-set-but-no-ids", BulkReassignmentRequest{OwnerID: "o-alice"}, true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := tt.r.IsEmpty(); got != tt.want {
t.Errorf("IsEmpty() = %v, want %v", got, tt.want)
}
})
}
}
func TestBulkReassignmentResult_JSONShape(t *testing.T) {
t.Parallel()
r := &BulkReassignmentResult{
TotalMatched: 10,
TotalReassigned: 7,
TotalSkipped: 3, // already-owned-by-target — silent no-op
TotalFailed: 0,
Errors: nil,
}
b, err := json.Marshal(r)
if err != nil {
t.Fatalf("Marshal failed: %v", err)
}
var round map[string]interface{}
if err := json.Unmarshal(b, &round); err != nil {
t.Fatalf("Unmarshal failed: %v", err)
}
for _, k := range []string{"total_matched", "total_reassigned", "total_skipped", "total_failed"} {
if _, ok := round[k]; !ok {
t.Errorf("missing JSON field %q in %s", k, string(b))
}
}
if _, ok := round["errors"]; ok {
t.Errorf("nil Errors should be omitempty; got: %s", string(b))
}
}
func TestBulkOperationError_JSONShape(t *testing.T) {
t.Parallel()
e := BulkOperationError{
CertificateID: "mc-1",
Error: "renewal already in progress",
}
b, err := json.Marshal(e)
if err != nil {
t.Fatalf("Marshal failed: %v", err)
}
want := `{"certificate_id":"mc-1","error":"renewal already in progress"}`
if string(b) != want {
t.Errorf("JSON shape drift:\n got: %s\n want: %s", string(b), want)
}
}
+79
View File
@@ -0,0 +1,79 @@
package domain
// BulkRenewalCriteria selects a set of managed certificates to renew. At
// least one selector must be non-empty (IsEmpty() guards this in the
// service layer; same shape and rule as BulkRevocationCriteria so
// operators who already know the bulk-revoke contract have zero new
// surface to learn).
//
// L-1 master closure (cat-l-fa0c1ac07ab5): the GUI used to loop
// `await triggerRenewal(id)` over the selection at
// `web/src/pages/CertificatesPage.tsx::handleBulkRenewal`. 100 certs =
// 100 sequential HTTP round-trips × Auth → audit → handler → service →
// repo → DB → audit. Post-L-1 the GUI POSTs once; the server resolves
// the criteria, applies status filters, and enqueues N renewal jobs.
//
// The "renew all certs of profile X before its CA changes" use case is
// the canonical reason to support criteria-mode in addition to explicit
// IDs. Mirrors `BulkRevocationCriteria` field-for-field.
type BulkRenewalCriteria struct {
ProfileID string `json:"profile_id,omitempty"`
OwnerID string `json:"owner_id,omitempty"`
AgentID string `json:"agent_id,omitempty"`
IssuerID string `json:"issuer_id,omitempty"`
TeamID string `json:"team_id,omitempty"`
CertificateIDs []string `json:"certificate_ids,omitempty"`
}
// IsEmpty returns true if no filter criteria are set. The service layer
// rejects empty criteria with a 400 (mirrors BulkRevocationCriteria.IsEmpty).
func (c BulkRenewalCriteria) IsEmpty() bool {
return c.ProfileID == "" && c.OwnerID == "" && c.AgentID == "" &&
c.IssuerID == "" && c.TeamID == "" && len(c.CertificateIDs) == 0
}
// BulkRenewalResult is the envelope returned to the caller. Distinct
// from BulkRevocationResult because the action verb differs: renewal
// ENQUEUES a job per matched cert (asynchronous) rather than performing
// the mutation synchronously like revocation. The EnqueuedJobs slice
// gives the caller the job IDs so the GUI can poll
// /api/v1/jobs?status=Running for progress without re-querying the
// certificate list.
//
// Counters semantics (mirror BulkRevocationResult conventions):
// - TotalMatched: number of certs the criteria/IDs resolved to
// - TotalEnqueued: number of renewal jobs successfully created
// - TotalSkipped: certs in a status that disallows renewal (already
// RenewalInProgress, Revoked, or Archived); silent no-op, NOT an error
// - TotalFailed: certs where the enqueue path returned an error
// - EnqueuedJobs: per-cert {certificate_id, job_id} pairs for the
// successful enqueue path (omitempty so an all-skipped batch
// produces a clean response)
// - Errors: per-cert error details for the failure path
type BulkRenewalResult struct {
TotalMatched int `json:"total_matched"`
TotalEnqueued int `json:"total_enqueued"`
TotalSkipped int `json:"total_skipped"`
TotalFailed int `json:"total_failed"`
EnqueuedJobs []BulkEnqueuedJob `json:"enqueued_jobs,omitempty"`
Errors []BulkOperationError `json:"errors,omitempty"`
}
// BulkEnqueuedJob pairs a certificate ID with the renewal job ID that was
// just created for it. Lets the GUI link directly into the job-detail
// page without an extra round-trip to query "what job did this cert
// just get assigned?".
type BulkEnqueuedJob struct {
CertificateID string `json:"certificate_id"`
JobID string `json:"job_id"`
}
// BulkOperationError records a per-certificate failure for any bulk
// operation (renew, reassign — and revoke, which uses the older
// BulkRevocationError shape kept for backwards compatibility on the
// /bulk-revoke wire format). Same shape as BulkRevocationError so the
// frontend's bulk-result rendering is one helper.
type BulkOperationError struct {
CertificateID string `json:"certificate_id"`
Error string `json:"error"`
}
+83
View File
@@ -0,0 +1,83 @@
package domain
import (
"encoding/json"
"testing"
)
// TestBulkRenewalCriteria_IsEmpty pins the validate-and-reject contract:
// empty criteria → service rejects with 400. Mirrors
// TestBulkRevocationCriteria_IsEmpty exactly so the cross-bulk-endpoint
// behaviour is uniform.
func TestBulkRenewalCriteria_IsEmpty(t *testing.T) {
t.Parallel()
tests := []struct {
name string
c BulkRenewalCriteria
want bool
}{
{"all-zero", BulkRenewalCriteria{}, true},
{"profile-id-set", BulkRenewalCriteria{ProfileID: "cp-x"}, false},
{"owner-id-set", BulkRenewalCriteria{OwnerID: "o-alice"}, false},
{"agent-id-set", BulkRenewalCriteria{AgentID: "ag-1"}, false},
{"issuer-id-set", BulkRenewalCriteria{IssuerID: "iss-x"}, false},
{"team-id-set", BulkRenewalCriteria{TeamID: "t-x"}, false},
{"ids-set", BulkRenewalCriteria{CertificateIDs: []string{"mc-1"}}, false},
{"ids-empty-slice", BulkRenewalCriteria{CertificateIDs: []string{}}, true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := tt.c.IsEmpty(); got != tt.want {
t.Errorf("IsEmpty() = %v, want %v", got, tt.want)
}
})
}
}
// TestBulkRenewalResult_JSONShape pins the wire contract. Operator
// tooling (k8s rollouts, blackbox probes, the `certctl-cli bulk-renew`
// JSON consumer) parses these field names; renaming any of them is a
// breaking change.
func TestBulkRenewalResult_JSONShape(t *testing.T) {
t.Parallel()
r := &BulkRenewalResult{
TotalMatched: 5,
TotalEnqueued: 4,
TotalSkipped: 1,
TotalFailed: 0,
EnqueuedJobs: []BulkEnqueuedJob{
{CertificateID: "mc-1", JobID: "job-a"},
},
Errors: nil,
}
b, err := json.Marshal(r)
if err != nil {
t.Fatalf("Marshal failed: %v", err)
}
var round map[string]interface{}
if err := json.Unmarshal(b, &round); err != nil {
t.Fatalf("Unmarshal failed: %v", err)
}
for _, k := range []string{"total_matched", "total_enqueued", "total_skipped", "total_failed", "enqueued_jobs"} {
if _, ok := round[k]; !ok {
t.Errorf("missing JSON field %q in %s", k, string(b))
}
}
// errors omitempty when nil — must NOT appear
if _, ok := round["errors"]; ok {
t.Errorf("nil Errors should be omitempty; got: %s", string(b))
}
// EnqueuedJobs nested shape
jobs := round["enqueued_jobs"].([]interface{})
if len(jobs) != 1 {
t.Fatalf("enqueued_jobs len = %d, want 1", len(jobs))
}
first := jobs[0].(map[string]interface{})
if first["certificate_id"] != "mc-1" || first["job_id"] != "job-a" {
t.Errorf("BulkEnqueuedJob field names drifted: %v", first)
}
}
+19
View File
@@ -0,0 +1,19 @@
// Package domain — error sentinels.
//
// S-2 closure (cat-s6-efc7f6f6bd50): pre-S-2 every handler-side
// validation-failure dispatch was a `strings.Contains(err.Error(),
// "invalid")` or `"required"` site, brittle to any domain-layer
// message change. Post-S-2 domain validators that surface a
// 400 Bad Request wrap their per-field errors via fmt.Errorf("...: %w",
// domain.ErrValidation) so handlers can dispatch via errors.Is.
package domain
import "errors"
// ErrValidation is the canonical sentinel for input-validation
// failures surfaced by domain-layer Validate() methods. Handlers that
// surface a 400 Bad Request should `errors.Is(err, domain.ErrValidation)`.
// Per-field error messages are still preserved via fmt.Errorf wrapping
// so the response body retains the actionable detail; the sentinel
// only drives the HTTP status code dispatch.
var ErrValidation = errors.New("domain: validation failed")
+4 -1
View File
@@ -626,7 +626,10 @@ func (m *mockJobRepository) List(ctx context.Context) ([]*domain.Job, error) {
func (m *mockJobRepository) Get(ctx context.Context, id string) (*domain.Job, error) { func (m *mockJobRepository) Get(ctx context.Context, id string) (*domain.Job, error) {
job, ok := m.jobs[id] job, ok := m.jobs[id]
if !ok { if !ok {
return nil, fmt.Errorf("job not found") // S-2 closure: wrap repository.ErrNotFound so the handler's
// errors.Is dispatch resolves to 404 (matches the Postgres
// repo's post-S-2 wrapping).
return nil, fmt.Errorf("job not found: %w", repository.ErrNotFound)
} }
return job, nil return job, nil
} }
+87
View File
@@ -214,6 +214,61 @@ func registerCertificateTools(s *gomcp.Server, c *Client) {
} }
return textResult(data) return textResult(data)
}) })
// L-1 master closure (cat-l-fa0c1ac07ab5): bulk-renew MCP tool.
// Mirrors certctl_bulk_revoke_certificates shape sans the Reason
// field. Server returns total_matched / total_enqueued /
// total_skipped / total_failed plus per-cert {certificate_id,
// job_id} pairs in enqueued_jobs.
gomcp.AddTool(s, &gomcp.Tool{
Name: "certctl_bulk_renew_certificates",
Description: "Bulk renew certificates matching filter criteria (profile_id, owner_id, agent_id, issuer_id, team_id) or an explicit certificate_ids list. At least one selector required. Returns counts of matched, enqueued, skipped, and failed certificates plus per-cert {certificate_id, job_id} pairs.",
}, func(ctx context.Context, req *gomcp.CallToolRequest, input BulkRenewCertificatesInput) (*gomcp.CallToolResult, any, error) {
body := map[string]interface{}{}
if input.ProfileID != "" {
body["profile_id"] = input.ProfileID
}
if input.OwnerID != "" {
body["owner_id"] = input.OwnerID
}
if input.AgentID != "" {
body["agent_id"] = input.AgentID
}
if input.IssuerID != "" {
body["issuer_id"] = input.IssuerID
}
if input.TeamID != "" {
body["team_id"] = input.TeamID
}
if len(input.CertificateIDs) > 0 {
body["certificate_ids"] = input.CertificateIDs
}
data, err := c.Post("/api/v1/certificates/bulk-renew", body)
if err != nil {
return errorResult(err)
}
return textResult(data)
})
// L-2 closure (cat-l-8a1fb258a38a): bulk-reassign MCP tool.
// Narrower than bulk-renew/revoke — IDs-only, no criteria-mode.
gomcp.AddTool(s, &gomcp.Tool{
Name: "certctl_bulk_reassign_certificates",
Description: "Bulk reassign owner (and optionally team) for a set of certificates. owner_id is required. team_id is optional and updates only when non-empty. Returns counts of matched, reassigned, skipped (already-owned-by-target), and failed certificates.",
}, func(ctx context.Context, req *gomcp.CallToolRequest, input BulkReassignCertificatesInput) (*gomcp.CallToolResult, any, error) {
body := map[string]interface{}{
"certificate_ids": input.CertificateIDs,
"owner_id": input.OwnerID,
}
if input.TeamID != "" {
body["team_id"] = input.TeamID
}
data, err := c.Post("/api/v1/certificates/bulk-reassign", body)
if err != nil {
return errorResult(err)
}
return textResult(data)
})
} }
// ── CRL & OCSP ────────────────────────────────────────────────────── // ── CRL & OCSP ──────────────────────────────────────────────────────
@@ -1183,4 +1238,36 @@ func registerHealthTools(s *gomcp.Server, c *Client) {
} }
return textResult(data) return textResult(data)
}) })
// I-2 closure (cat-i-b0924b6675f8): pre-I-2 the README claimed "all
// API endpoints are exposed via MCP" but the discovered-certificate
// lifecycle (claim + dismiss) was never wrapped — operators using
// MCP clients (Claude, Cursor, etc.) had no path to bring an
// out-of-band cert under management or to mark a benign discovery
// as not-of-interest without dropping to the REST API directly.
// These two tools wrap the existing HTTP handlers
// (DiscoveryHandler.ClaimDiscovered + DismissDiscovered).
gomcp.AddTool(s, &gomcp.Tool{
Name: "certctl_claim_discovered_certificate",
Description: "Link a discovered certificate (dc-*) to an existing managed certificate (mc-*) via POST /api/v1/discovered-certificates/{id}/claim. Use this to bring an out-of-band cert (e.g. one found by an agent filesystem scan or a network scan) under certctl management without re-issuing — the discovered row is marked Managed and its managed_certificate_id is set so subsequent renewals/revocations on the managed cert update both rows.",
}, func(ctx context.Context, req *gomcp.CallToolRequest, input ClaimDiscoveredCertificateInput) (*gomcp.CallToolResult, any, error) {
body := map[string]string{"managed_certificate_id": input.ManagedCertificateID}
data, err := c.Post("/api/v1/discovered-certificates/"+input.ID+"/claim", body)
if err != nil {
return errorResult(err)
}
return textResult(data)
})
gomcp.AddTool(s, &gomcp.Tool{
Name: "certctl_dismiss_discovered_certificate",
Description: "Dismiss a discovered certificate (POST /api/v1/discovered-certificates/{id}/dismiss). Use this to mark a discovery as not-of-interest (e.g. expired self-signed test certs found by a network scan) — the row stops appearing in the unmanaged-list view but is preserved in the DB for audit history.",
}, func(ctx context.Context, req *gomcp.CallToolRequest, input DismissDiscoveredCertificateInput) (*gomcp.CallToolResult, any, error) {
data, err := c.Post("/api/v1/discovered-certificates/"+input.ID+"/dismiss", nil)
if err != nil {
return errorResult(err)
}
return textResult(data)
})
} }
+43
View File
@@ -72,6 +72,26 @@ type BulkRevokeCertificatesInput struct {
CertificateIDs []string `json:"certificate_ids,omitempty" jsonschema:"Explicit list of certificate IDs to revoke"` CertificateIDs []string `json:"certificate_ids,omitempty" jsonschema:"Explicit list of certificate IDs to revoke"`
} }
// BulkRenewCertificatesInput is the MCP tool input for bulk-renew (L-1
// master closure, cat-l-fa0c1ac07ab5). Mirrors BulkRevokeCertificatesInput
// field-for-field minus Reason.
type BulkRenewCertificatesInput struct {
ProfileID string `json:"profile_id,omitempty" jsonschema:"Renew all certs matching this profile ID"`
OwnerID string `json:"owner_id,omitempty" jsonschema:"Renew all certs owned by this owner"`
AgentID string `json:"agent_id,omitempty" jsonschema:"Renew all certs deployed via this agent"`
IssuerID string `json:"issuer_id,omitempty" jsonschema:"Renew all certs issued by this issuer"`
TeamID string `json:"team_id,omitempty" jsonschema:"Renew all certs owned by members of this team"`
CertificateIDs []string `json:"certificate_ids,omitempty" jsonschema:"Explicit list of certificate IDs to renew"`
}
// BulkReassignCertificatesInput is the MCP tool input for bulk-reassign
// (L-2 closure, cat-l-8a1fb258a38a). IDs-only — no criteria-mode.
type BulkReassignCertificatesInput struct {
CertificateIDs []string `json:"certificate_ids" jsonschema:"Explicit list of certificate IDs to reassign"`
OwnerID string `json:"owner_id" jsonschema:"Required. New owner_id for every cert in certificate_ids"`
TeamID string `json:"team_id,omitempty" jsonschema:"Optional. When non-empty, also updates team_id on every cert"`
}
type ListVersionsInput struct { type ListVersionsInput struct {
ID string `json:"id" jsonschema:"Certificate ID"` ID string `json:"id" jsonschema:"Certificate ID"`
ListParams ListParams
@@ -303,6 +323,29 @@ type TimelineInput struct {
Days int `json:"days,omitempty" jsonschema:"Number of days to look back (default 30, max 365)"` Days int `json:"days,omitempty" jsonschema:"Number of days to look back (default 30, max 365)"`
} }
// ── Discovered Certificates (I-2 closure) ──────────────────────────
// ClaimDiscoveredCertificateInput is the MCP tool input for claiming a
// discovered certificate (POST /api/v1/discovered-certificates/{id}/claim).
// I-2 closure (cat-i-b0924b6675f8). The HTTP handler at
// internal/api/handler/discovery.go::ClaimDiscovered links the discovered
// row (DC-*) to a managed certificate (mc-*); operators use this to
// bring an out-of-band cert under management without re-issuing.
type ClaimDiscoveredCertificateInput struct {
ID string `json:"id" jsonschema:"Discovered certificate ID (dc-*)"`
ManagedCertificateID string `json:"managed_certificate_id" jsonschema:"Existing managed certificate ID (mc-*) to link to"`
}
// DismissDiscoveredCertificateInput is the MCP tool input for dismissing
// a discovered certificate (POST /api/v1/discovered-certificates/{id}/dismiss).
// I-2 closure (cat-i-b0924b6675f8). Marks the row as not-of-interest
// (e.g. expired self-signed test certs found by a network scan); the row
// stops appearing in the unmanaged-list view but is preserved in the DB
// for audit history.
type DismissDiscoveredCertificateInput struct {
ID string `json:"id" jsonschema:"Discovered certificate ID (dc-*)"`
}
// ── Empty ─────────────────────────────────────────────────────────── // ── Empty ───────────────────────────────────────────────────────────
type EmptyInput struct{} type EmptyInput struct{}
+62
View File
@@ -0,0 +1,62 @@
// Package repository defines the repository-layer error sentinels that
// handlers map to HTTP status codes via errors.Is.
//
// S-2 closure (cat-s6-efc7f6f6bd50): pre-S-2 every handler-side
// not-found dispatch was a `strings.Contains(err.Error(), "not found")`
// site (30+ across internal/api/handler/*.go), brittle to any
// repository-layer message change and untyped against the actual
// failure mode. Post-S-2 the dispatch is type-checked: repositories
// wrap sql.ErrNoRows via fmt.Errorf("...: %w", repository.ErrNotFound)
// and FK constraint violations via repository.ErrForeignKeyConstraint;
// handlers consume via errors.Is. The substring matching is preserved
// at the lib/pq boundary inside `errors.go::isFKError` because the
// PostgreSQL driver returns un-typed *pq.Error values whose codes are
// the canonical signal — but it's confined to one helper rather than
// scattered across every handler file. See unified-audit.md
// cat-s6-efc7f6f6bd50 for the closure rationale.
package repository
import (
"errors"
"strings"
)
// ErrNotFound is the canonical sentinel for repository methods that
// return after sql.ErrNoRows (or its wrapped form). Handlers that
// surface a 404 should `errors.Is(err, repository.ErrNotFound)`
// rather than substring-match.
var ErrNotFound = errors.New("repository: row not found")
// ErrForeignKeyConstraint is the canonical sentinel for PostgreSQL
// FK / RESTRICT violations bubbling up from a DELETE or UPDATE.
// Handlers that surface a 409 Conflict should
// `errors.Is(err, repository.ErrForeignKeyConstraint)`.
//
// The B-1 closure introduced ErrRenewalPolicyInUse as the per-entity
// FK sentinel for renewal_policies; future per-entity FK sentinels
// (ErrIssuerInUse, ErrTeamInUse, ErrOwnerInUse) can wrap this generic
// one via fmt.Errorf("...: %w", ErrForeignKeyConstraint) so handlers
// can choose between generic-409 and entity-specific 409 dispatch.
var ErrForeignKeyConstraint = errors.New("repository: foreign key constraint violation")
// IsForeignKeyError detects PostgreSQL FK violation errors from the
// lib/pq driver via the canonical error-text patterns it emits. The
// substring matching is intentionally confined to this helper —
// callers should use this once at the repo layer to wrap into the
// typed ErrForeignKeyConstraint sentinel, then handlers consume via
// errors.Is.
//
// Patterns recognised:
// - "violates foreign key constraint" (the standard PG message)
// - "violates restrict" / "RESTRICT" (DELETE blocked by ON DELETE RESTRICT)
//
// Returns false for nil err so callers can defensively chain it.
func IsForeignKeyError(err error) bool {
if err == nil {
return false
}
msg := err.Error()
return strings.Contains(msg, "violates foreign key") ||
strings.Contains(msg, "RESTRICT") ||
strings.Contains(msg, "violates restrict")
}
+6 -5
View File
@@ -1,6 +1,7 @@
package postgres package postgres
import ( import (
"github.com/shankar0123/certctl/internal/repository"
"context" "context"
"database/sql" "database/sql"
"fmt" "fmt"
@@ -73,7 +74,7 @@ func (r *AgentRepository) Get(ctx context.Context, id string) (*domain.Agent, er
agent, err := scanAgent(row) agent, err := scanAgent(row)
if err != nil { if err != nil {
if err == sql.ErrNoRows { if err == sql.ErrNoRows {
return nil, fmt.Errorf("agent not found") return nil, fmt.Errorf("agent not found: %w", repository.ErrNotFound)
} }
return nil, fmt.Errorf("failed to query agent: %w", err) return nil, fmt.Errorf("failed to query agent: %w", err)
} }
@@ -170,7 +171,7 @@ func (r *AgentRepository) Update(ctx context.Context, agent *domain.Agent) error
} }
if rows == 0 { if rows == 0 {
return fmt.Errorf("agent not found") return fmt.Errorf("agent not found: %w", repository.ErrNotFound)
} }
return nil return nil
@@ -190,7 +191,7 @@ func (r *AgentRepository) Delete(ctx context.Context, id string) error {
} }
if rows == 0 { if rows == 0 {
return fmt.Errorf("agent not found") return fmt.Errorf("agent not found: %w", repository.ErrNotFound)
} }
return nil return nil
@@ -237,7 +238,7 @@ func (r *AgentRepository) UpdateHeartbeat(ctx context.Context, id string, metada
} }
if rows == 0 { if rows == 0 {
return fmt.Errorf("agent not found") return fmt.Errorf("agent not found: %w", repository.ErrNotFound)
} }
return nil return nil
@@ -259,7 +260,7 @@ func (r *AgentRepository) GetByAPIKey(ctx context.Context, keyHash string) (*dom
agent, err := scanAgent(row) agent, err := scanAgent(row)
if err != nil { if err != nil {
if err == sql.ErrNoRows { if err == sql.ErrNoRows {
return nil, fmt.Errorf("agent not found") return nil, fmt.Errorf("agent not found: %w", repository.ErrNotFound)
} }
return nil, fmt.Errorf("failed to query agent: %w", err) return nil, fmt.Errorf("failed to query agent: %w", err)
} }
+4 -3
View File
@@ -1,6 +1,7 @@
package postgres package postgres
import ( import (
"github.com/shankar0123/certctl/internal/repository"
"context" "context"
"database/sql" "database/sql"
"fmt" "fmt"
@@ -50,7 +51,7 @@ func (r *AgentGroupRepository) Get(ctx context.Context, id string) (*domain.Agen
err := row.Scan(&g.ID, &g.Name, &g.Description, &g.MatchOS, &g.MatchArchitecture, err := row.Scan(&g.ID, &g.Name, &g.Description, &g.MatchOS, &g.MatchArchitecture,
&g.MatchIPCIDR, &g.MatchVersion, &g.Enabled, &g.CreatedAt, &g.UpdatedAt) &g.MatchIPCIDR, &g.MatchVersion, &g.Enabled, &g.CreatedAt, &g.UpdatedAt)
if err == sql.ErrNoRows { if err == sql.ErrNoRows {
return nil, fmt.Errorf("agent group not found: %s", id) return nil, fmt.Errorf("agent group not found: %w", repository.ErrNotFound)
} }
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to get agent group: %w", err) return nil, fmt.Errorf("failed to get agent group: %w", err)
@@ -84,7 +85,7 @@ func (r *AgentGroupRepository) Update(ctx context.Context, group *domain.AgentGr
} }
rows, _ := result.RowsAffected() rows, _ := result.RowsAffected()
if rows == 0 { if rows == 0 {
return fmt.Errorf("agent group not found: %s", group.ID) return fmt.Errorf("agent group not found: %w", repository.ErrNotFound)
} }
return nil return nil
} }
@@ -97,7 +98,7 @@ func (r *AgentGroupRepository) Delete(ctx context.Context, id string) error {
} }
rows, _ := result.RowsAffected() rows, _ := result.RowsAffected()
if rows == 0 { if rows == 0 {
return fmt.Errorf("agent group not found: %s", id) return fmt.Errorf("agent group not found: %w", repository.ErrNotFound)
} }
return nil return nil
} }
+3 -3
View File
@@ -265,7 +265,7 @@ func (r *CertificateRepository) Get(ctx context.Context, id string) (*domain.Man
cert, err := r.scanCertificate(ctx, row) cert, err := r.scanCertificate(ctx, row)
if err != nil { if err != nil {
if err == sql.ErrNoRows { if err == sql.ErrNoRows {
return nil, fmt.Errorf("certificate not found") return nil, fmt.Errorf("certificate not found: %w", repository.ErrNotFound)
} }
return nil, fmt.Errorf("failed to query certificate: %w", err) return nil, fmt.Errorf("failed to query certificate: %w", err)
} }
@@ -397,7 +397,7 @@ func (r *CertificateRepository) Update(ctx context.Context, cert *domain.Managed
} }
if rows == 0 { if rows == 0 {
return fmt.Errorf("certificate not found") return fmt.Errorf("certificate not found: %w", repository.ErrNotFound)
} }
return nil return nil
@@ -419,7 +419,7 @@ func (r *CertificateRepository) Archive(ctx context.Context, id string) error {
} }
if rows == 0 { if rows == 0 {
return fmt.Errorf("certificate not found") return fmt.Errorf("certificate not found: %w", repository.ErrNotFound)
} }
return nil return nil
+108
View File
@@ -131,3 +131,111 @@ func RunMigrations(db *sql.DB, migrationsPath string) error {
return nil return nil
} }
// RunSeed reads and executes the baseline seed SQL file from the migrations
// directory. Designed to run AFTER RunMigrations so every column referenced by
// the seed is already in place.
//
// U-3 (P1, cat-u-seed_initdb_schema_drift): pre-U-3 the deploy compose stack
// mounted both a hand-curated subset of `migrations/*.up.sql` and `seed.sql`
// into postgres `/docker-entrypoint-initdb.d/`. Postgres applied them at
// initdb time. When `seed.sql` was updated to reference columns added by
// migrations *after* the mounted cutoff (e.g., `policy_rules.severity` from
// `000013_policy_rule_severity.up.sql`), initdb crashed during the seed step
// and the container was reported `unhealthy` indefinitely — bare
// `docker compose -f deploy/docker-compose.yml up -d --build` from a fresh
// clone of v2.0.50 hit this on the first try (GitHub #10 reopened by
// mikeakasully). Helm and the example compose files were already runtime-
// only (Path B) and worked through the same window.
//
// Post-U-3 the compose stack drops all initdb mounts; postgres comes up with
// an empty schema; the server applies all migrations via RunMigrations and
// then this function applies the seed. Single source of truth, removes the
// drift hazard architecturally.
//
// The seed file is expected at `<migrationsPath>/seed.sql`. Missing-file is
// treated as a no-op (returns nil) so deployments that explicitly remove the
// seed (custom packaging, cert-manager managed schemas) don't break.
//
// Idempotency: every INSERT in the shipped seed.sql uses
// `ON CONFLICT (id) DO NOTHING`, so re-running on a populated DB is safe.
// This function is invoked on every server start, so the contract MUST hold.
//
// Demo seed: `seed_demo.sql` is applied separately by RunDemoSeed below
// when CERTCTL_DEMO_SEED=true (see internal/config/config.go::DemoSeed).
// Splitting demo from baseline keeps a default deploy from accidentally
// landing 90-days-of-fake-history into a real customer database, while
// still giving the demo overlay a single source of truth (no more initdb
// mounts). The demo seed itself uses ON CONFLICT (id) DO NOTHING so it's
// idempotent; missing-file is also tolerated (custom packaging may strip
// seed_demo.sql to shrink the image).
func RunSeed(db *sql.DB, migrationsPath string) error {
if _, err := os.Stat(migrationsPath); os.IsNotExist(err) {
return fmt.Errorf("migrations directory not found: %s", migrationsPath)
}
seedPath := filepath.Join(migrationsPath, "seed.sql")
content, err := os.ReadFile(seedPath)
if err != nil {
if os.IsNotExist(err) {
// Missing seed.sql is acceptable — operators may have removed it
// for custom-packaging reasons. Return nil rather than fail-loud.
return nil
}
return fmt.Errorf("failed to read seed file %s: %w", seedPath, err)
}
if _, err := db.Exec(string(content)); err != nil {
return fmt.Errorf("failed to execute seed file %s: %w", seedPath, err)
}
return nil
}
// RunDemoSeed applies the demo overlay seed file
// (`<migrationsPath>/seed_demo.sql`) on top of the baseline seed.
//
// U-3 follow-on: pre-U-3 the demo overlay mounted `seed_demo.sql` into
// postgres `/docker-entrypoint-initdb.d/` and relied on initdb to apply it
// alongside the schema. Once U-3 dropped the initdb migration mounts, that
// path stopped working — postgres comes up empty, and the demo seed
// references tables (issuers, certificates, etc.) that wouldn't exist yet
// at initdb time. RunDemoSeed restores the demo capability through the
// same runtime path RunSeed uses, gated by CERTCTL_DEMO_SEED so production
// deploys never accidentally land the fake-history rows.
//
// Order contract: must run AFTER RunSeed so foreign-key references from
// demo rows to baseline rows (e.g., demo certificates referencing
// `rp-default` from baseline) resolve cleanly. The caller in
// cmd/server/main.go enforces this order.
//
// Missing-file is acceptable (returns nil) — operators packaging a
// production-only image often strip seed_demo.sql to shrink the artifact,
// and that should not break boot when CERTCTL_DEMO_SEED happens to be set.
//
// Idempotency: every INSERT in seed_demo.sql uses
// `ON CONFLICT (id) DO NOTHING`, so re-running on a populated DB is safe.
// Server restarts in demo mode therefore re-apply the file harmlessly.
func RunDemoSeed(db *sql.DB, migrationsPath string) error {
if _, err := os.Stat(migrationsPath); os.IsNotExist(err) {
return fmt.Errorf("migrations directory not found: %s", migrationsPath)
}
seedPath := filepath.Join(migrationsPath, "seed_demo.sql")
content, err := os.ReadFile(seedPath)
if err != nil {
if os.IsNotExist(err) {
// Custom production packaging frequently strips this file.
// Fail-soft to preserve the U-3 contract: a missing seed file
// must not gate server boot.
return nil
}
return fmt.Errorf("failed to read demo seed file %s: %w", seedPath, err)
}
if _, err := db.Exec(string(content)); err != nil {
return fmt.Errorf("failed to execute demo seed file %s: %w", seedPath, err)
}
return nil
}
+3 -3
View File
@@ -62,7 +62,7 @@ func (r *DiscoveryRepository) GetScan(ctx context.Context, id string) (*domain.D
&scan.ScanDurationMs, &scan.StartedAt, &scan.CompletedAt, &scan.ScanDurationMs, &scan.StartedAt, &scan.CompletedAt,
) )
if err == sql.ErrNoRows { if err == sql.ErrNoRows {
return nil, fmt.Errorf("discovery scan not found: %s", id) return nil, fmt.Errorf("discovery scan not found: %w", repository.ErrNotFound)
} }
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to get discovery scan: %w", err) return nil, fmt.Errorf("failed to get discovery scan: %w", err)
@@ -190,7 +190,7 @@ func (r *DiscoveryRepository) GetDiscovered(ctx context.Context, id string) (*do
&cert.CreatedAt, &cert.UpdatedAt, &cert.CreatedAt, &cert.UpdatedAt,
) )
if err == sql.ErrNoRows { if err == sql.ErrNoRows {
return nil, fmt.Errorf("discovered certificate not found: %s", id) return nil, fmt.Errorf("discovered certificate not found: %w", repository.ErrNotFound)
} }
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to get discovered certificate: %w", err) return nil, fmt.Errorf("failed to get discovered certificate: %w", err)
@@ -317,7 +317,7 @@ func (r *DiscoveryRepository) UpdateDiscoveredStatus(ctx context.Context, id str
} }
rowsAffected, _ := result.RowsAffected() rowsAffected, _ := result.RowsAffected()
if rowsAffected == 0 { if rowsAffected == 0 {
return fmt.Errorf("discovered certificate not found: %s", id) return fmt.Errorf("discovered certificate not found: %w", repository.ErrNotFound)
} }
return nil return nil
} }
+2 -2
View File
@@ -113,7 +113,7 @@ func (r *HealthCheckRepository) Get(ctx context.Context, id string) (*domain.End
&check.CreatedAt, &check.UpdatedAt, &check.CreatedAt, &check.UpdatedAt,
) )
if err == sql.ErrNoRows { if err == sql.ErrNoRows {
return nil, fmt.Errorf("health check not found: %s", id) return nil, fmt.Errorf("health check not found: %w", repository.ErrNotFound)
} }
if err != nil { if err != nil {
return nil, fmt.Errorf("get health check: %w", err) return nil, fmt.Errorf("get health check: %w", err)
@@ -299,7 +299,7 @@ func (r *HealthCheckRepository) GetByEndpoint(ctx context.Context, endpoint stri
&check.CreatedAt, &check.UpdatedAt, &check.CreatedAt, &check.UpdatedAt,
) )
if err == sql.ErrNoRows { if err == sql.ErrNoRows {
return nil, fmt.Errorf("health check not found for endpoint: %s", endpoint) return nil, fmt.Errorf("health check not found for endpoint: %w", repository.ErrNotFound)
} }
if err != nil { if err != nil {
return nil, fmt.Errorf("get health check by endpoint: %w", err) return nil, fmt.Errorf("get health check by endpoint: %w", err)
+4 -3
View File
@@ -1,6 +1,7 @@
package postgres package postgres
import ( import (
"github.com/shankar0123/certctl/internal/repository"
"context" "context"
"database/sql" "database/sql"
"fmt" "fmt"
@@ -69,7 +70,7 @@ func (r *IssuerRepository) Get(ctx context.Context, id string) (*domain.Issuer,
if err != nil { if err != nil {
if err == sql.ErrNoRows { if err == sql.ErrNoRows {
return nil, fmt.Errorf("issuer not found") return nil, fmt.Errorf("issuer not found: %w", repository.ErrNotFound)
} }
return nil, fmt.Errorf("failed to query issuer: %w", err) return nil, fmt.Errorf("failed to query issuer: %w", err)
} }
@@ -169,7 +170,7 @@ func (r *IssuerRepository) Update(ctx context.Context, issuer *domain.Issuer) er
} }
if rows == 0 { if rows == 0 {
return fmt.Errorf("issuer not found") return fmt.Errorf("issuer not found: %w", repository.ErrNotFound)
} }
return nil return nil
@@ -189,7 +190,7 @@ func (r *IssuerRepository) Delete(ctx context.Context, id string) error {
} }
if rows == 0 { if rows == 0 {
return fmt.Errorf("issuer not found") return fmt.Errorf("issuer not found: %w", repository.ErrNotFound)
} }
return nil return nil
+5 -4
View File
@@ -1,6 +1,7 @@
package postgres package postgres
import ( import (
"github.com/shankar0123/certctl/internal/repository"
"context" "context"
"database/sql" "database/sql"
"fmt" "fmt"
@@ -62,7 +63,7 @@ func (r *JobRepository) Get(ctx context.Context, id string) (*domain.Job, error)
job, err := scanJob(row) job, err := scanJob(row)
if err != nil { if err != nil {
if err == sql.ErrNoRows { if err == sql.ErrNoRows {
return nil, fmt.Errorf("job not found") return nil, fmt.Errorf("job not found: %w", repository.ErrNotFound)
} }
return nil, fmt.Errorf("failed to query job: %w", err) return nil, fmt.Errorf("failed to query job: %w", err)
} }
@@ -123,7 +124,7 @@ func (r *JobRepository) Update(ctx context.Context, job *domain.Job) error {
} }
if rows == 0 { if rows == 0 {
return fmt.Errorf("job not found") return fmt.Errorf("job not found: %w", repository.ErrNotFound)
} }
return nil return nil
@@ -143,7 +144,7 @@ func (r *JobRepository) Delete(ctx context.Context, id string) error {
} }
if rows == 0 { if rows == 0 {
return fmt.Errorf("job not found") return fmt.Errorf("job not found: %w", repository.ErrNotFound)
} }
return nil return nil
@@ -232,7 +233,7 @@ func (r *JobRepository) UpdateStatus(ctx context.Context, id string, status doma
} }
if rows == 0 { if rows == 0 {
return fmt.Errorf("job not found") return fmt.Errorf("job not found: %w", repository.ErrNotFound)
} }
return nil return nil
+4 -3
View File
@@ -1,6 +1,7 @@
package postgres package postgres
import ( import (
"github.com/shankar0123/certctl/internal/repository"
"context" "context"
"database/sql" "database/sql"
"fmt" "fmt"
@@ -68,7 +69,7 @@ func (r *NetworkScanRepository) Get(ctx context.Context, id string) (*domain.Net
&target.CreatedAt, &target.UpdatedAt, &target.CreatedAt, &target.UpdatedAt,
) )
if err == sql.ErrNoRows { if err == sql.ErrNoRows {
return nil, fmt.Errorf("network scan target not found: %s", id) return nil, fmt.Errorf("network scan target not found: %w", repository.ErrNotFound)
} }
if err != nil { if err != nil {
return nil, fmt.Errorf("get network scan target: %w", err) return nil, fmt.Errorf("get network scan target: %w", err)
@@ -117,7 +118,7 @@ func (r *NetworkScanRepository) Update(ctx context.Context, target *domain.Netwo
} }
rows, _ := result.RowsAffected() rows, _ := result.RowsAffected()
if rows == 0 { if rows == 0 {
return fmt.Errorf("network scan target not found: %s", target.ID) return fmt.Errorf("network scan target not found: %w", repository.ErrNotFound)
} }
return nil return nil
} }
@@ -130,7 +131,7 @@ func (r *NetworkScanRepository) Delete(ctx context.Context, id string) error {
} }
rows, _ := result.RowsAffected() rows, _ := result.RowsAffected()
if rows == 0 { if rows == 0 {
return fmt.Errorf("network scan target not found: %s", id) return fmt.Errorf("network scan target not found: %w", repository.ErrNotFound)
} }
return nil return nil
} }
+41 -15
View File
@@ -22,19 +22,37 @@ func NewNotificationRepository(db *sql.DB) *NotificationRepository {
return &NotificationRepository{db: db} return &NotificationRepository{db: db}
} }
// Create stores a new notification // Create stores a new notification.
//
// U-3 ride-along (cat-o-notification_created_at_dead_field, P2): the
// `created_at` column is added to notification_events by migration 000017.
// Pre-U-3 the Go domain.NotificationEvent had a CreatedAt field but the
// INSERT path never set it AND no DB column existed — the JSON API
// serialised the field as `0001-01-01T00:00:00Z`, breaking timestamp
// ordering on operator dashboards and any consumer that filtered by age.
// Post-U-3 the column exists with a NOT NULL DEFAULT NOW() backstop, and
// this INSERT explicitly sets it from the domain field. If the caller
// hasn't populated CreatedAt (zero-value time.Time) we substitute
// time.Now() so the row never carries the placeholder zero-time forward
// — the DEFAULT would handle this too, but emitting the value explicitly
// keeps the wire-level JSON consistent with what the row will hold once
// scanNotification reads it back, and prevents a clock-skew gap between
// "Go computed CreatedAt" and "DB applied DEFAULT NOW()" on the read path.
func (r *NotificationRepository) Create(ctx context.Context, notif *domain.NotificationEvent) error { func (r *NotificationRepository) Create(ctx context.Context, notif *domain.NotificationEvent) error {
if notif.ID == "" { if notif.ID == "" {
notif.ID = uuid.New().String() notif.ID = uuid.New().String()
} }
if notif.CreatedAt.IsZero() {
notif.CreatedAt = time.Now()
}
err := r.db.QueryRowContext(ctx, ` err := r.db.QueryRowContext(ctx, `
INSERT INTO notification_events ( INSERT INTO notification_events (
id, type, certificate_id, channel, recipient, message, sent_at, status, error id, type, certificate_id, channel, recipient, message, sent_at, status, error, created_at
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
RETURNING id RETURNING id
`, notif.ID, notif.Type, notif.CertificateID, notif.Channel, notif.Recipient, `, notif.ID, notif.Type, notif.CertificateID, notif.Channel, notif.Recipient,
notif.Message, notif.SentAt, notif.Status, notif.Error).Scan(&notif.ID) notif.Message, notif.SentAt, notif.Status, notif.Error, notif.CreatedAt).Scan(&notif.ID)
if err != nil { if err != nil {
return fmt.Errorf("failed to create notification: %w", err) return fmt.Errorf("failed to create notification: %w", err)
@@ -102,12 +120,14 @@ func (r *NotificationRepository) List(ctx context.Context, filter *repository.No
// Get paginated results. I-005 extends the SELECT with the three retry // Get paginated results. I-005 extends the SELECT with the three retry
// columns (retry_count / next_retry_at / last_error) so scanNotification // columns (retry_count / next_retry_at / last_error) so scanNotification
// can populate the new fields on domain.NotificationEvent. The column // can populate the new fields on domain.NotificationEvent. U-3 extends
// order here MUST stay in lockstep with scanNotification below. // it once more with `created_at` (column added by migration 000017) so
// the field is no longer serialized as 0001-01-01. The column order
// here MUST stay in lockstep with scanNotification below.
offset := (filter.Page - 1) * filter.PerPage offset := (filter.Page - 1) * filter.PerPage
query := fmt.Sprintf(` query := fmt.Sprintf(`
SELECT id, type, certificate_id, channel, recipient, message, sent_at, status, error, SELECT id, type, certificate_id, channel, recipient, message, sent_at, status, error,
retry_count, next_retry_at, last_error retry_count, next_retry_at, last_error, created_at
FROM notification_events FROM notification_events
%s %s
ORDER BY sent_at DESC NULLS LAST ORDER BY sent_at DESC NULLS LAST
@@ -154,7 +174,7 @@ func (r *NotificationRepository) UpdateStatus(ctx context.Context, id string, st
} }
if rows == 0 { if rows == 0 {
return fmt.Errorf("notification not found") return fmt.Errorf("notification not found: %w", repository.ErrNotFound)
} }
return nil return nil
@@ -162,8 +182,14 @@ func (r *NotificationRepository) UpdateStatus(ctx context.Context, id string, st
// scanNotification scans a notification from a row or rows. // scanNotification scans a notification from a row or rows.
// //
// I-005 extends the scan list from 9 → 12 columns (adds retry_count, // I-005 extended the scan list from 9 → 12 columns (adds retry_count,
// next_retry_at, last_error). Every caller — List and the four new retry // next_retry_at, last_error). U-3 extends it once more to 13 columns by
// appending `created_at` (column added by migration 000017,
// cat-o-notification_created_at_dead_field). CreatedAt scans into a
// non-pointer time.Time because the migration declares the column
// NOT NULL with DEFAULT NOW().
//
// Every caller — List, ListRetryEligible, and the four other I-005 retry
// methods below — funnels rows through this helper, so the SELECT column // methods below — funnels rows through this helper, so the SELECT column
// order in every query must match the Scan order here exactly. RetryCount // order in every query must match the Scan order here exactly. RetryCount
// scans into an `int` (migration 000016 declares the column NOT NULL with // scans into an `int` (migration 000016 declares the column NOT NULL with
@@ -176,7 +202,7 @@ func scanNotification(scanner interface {
var notif domain.NotificationEvent var notif domain.NotificationEvent
err := scanner.Scan(&notif.ID, &notif.Type, &notif.CertificateID, &notif.Channel, err := scanner.Scan(&notif.ID, &notif.Type, &notif.CertificateID, &notif.Channel,
&notif.Recipient, &notif.Message, &notif.SentAt, &notif.Status, &notif.Error, &notif.Recipient, &notif.Message, &notif.SentAt, &notif.Status, &notif.Error,
&notif.RetryCount, &notif.NextRetryAt, &notif.LastError) &notif.RetryCount, &notif.NextRetryAt, &notif.LastError, &notif.CreatedAt)
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to scan notification: %w", err) return nil, fmt.Errorf("failed to scan notification: %w", err)
@@ -248,7 +274,7 @@ func (r *NotificationRepository) ListRetryEligible(ctx context.Context, now time
rows, err := r.db.QueryContext(ctx, ` rows, err := r.db.QueryContext(ctx, `
SELECT id, type, certificate_id, channel, recipient, message, sent_at, status, error, SELECT id, type, certificate_id, channel, recipient, message, sent_at, status, error,
retry_count, next_retry_at, last_error retry_count, next_retry_at, last_error, created_at
FROM notification_events FROM notification_events
WHERE status = 'failed' WHERE status = 'failed'
AND next_retry_at IS NOT NULL AND next_retry_at IS NOT NULL
@@ -310,7 +336,7 @@ func (r *NotificationRepository) RecordFailedAttempt(ctx context.Context, id str
// Same "not found" error shape as UpdateStatus above. The scheduler // Same "not found" error shape as UpdateStatus above. The scheduler
// logs-and-continues on this so a concurrently-deleted row doesn't // logs-and-continues on this so a concurrently-deleted row doesn't
// break the sweep. // break the sweep.
return fmt.Errorf("notification not found") return fmt.Errorf("notification not found: %w", repository.ErrNotFound)
} }
return nil return nil
} }
@@ -342,7 +368,7 @@ func (r *NotificationRepository) MarkAsDead(ctx context.Context, id string, last
return fmt.Errorf("failed to get rows affected: %w", err) return fmt.Errorf("failed to get rows affected: %w", err)
} }
if rows == 0 { if rows == 0 {
return fmt.Errorf("notification not found") return fmt.Errorf("notification not found: %w", repository.ErrNotFound)
} }
return nil return nil
} }
@@ -379,7 +405,7 @@ func (r *NotificationRepository) Requeue(ctx context.Context, id string) error {
return fmt.Errorf("failed to get rows affected: %w", err) return fmt.Errorf("failed to get rows affected: %w", err)
} }
if rows == 0 { if rows == 0 {
return fmt.Errorf("notification not found") return fmt.Errorf("notification not found: %w", repository.ErrNotFound)
} }
return nil return nil
} }
@@ -339,6 +339,95 @@ func TestNotificationRepository_Requeue(t *testing.T) {
} }
} }
// TestNotificationRepository_CreatedAt_IsPersisted is the U-3 ride-along
// regression for cat-o-notification_created_at_dead_field. Pre-U-3 the
// Go domain.NotificationEvent had a CreatedAt field but the DB had no
// column — JSON serialisation produced 0001-01-01T00:00:00Z, breaking
// timestamp ordering on operator dashboards. Post-U-3 migration 000017
// adds the column NOT NULL DEFAULT NOW(), Create populates it, and
// scanNotification reads it back.
//
// The contract under test is round-trip equivalence: the timestamp the
// caller sets goes into the DB and comes back out unchanged (modulo
// PostgreSQL's microsecond precision). Truncate to microseconds before
// comparing because TIMESTAMPTZ rounds nanoseconds away.
func TestNotificationRepository_CreatedAt_IsPersisted(t *testing.T) {
tdb := getTestDB(t)
db := tdb.freshSchema(t)
repo := postgres.NewNotificationRepository(db)
ctx := context.Background()
// A specific, recognisable timestamp. Truncated to microseconds so
// the post-roundtrip equality assertion isn't tripped up by Postgres
// dropping the nanosecond tail.
want := time.Now().UTC().Add(-2 * time.Hour).Truncate(time.Microsecond)
notif := &domain.NotificationEvent{
Type: domain.NotificationTypeExpirationWarning,
Channel: domain.NotificationChannelWebhook,
Recipient: "https://hooks.example.com/u3",
Message: "U-3 round-trip witness",
Status: string(domain.NotificationStatusPending),
CreatedAt: want,
}
if err := repo.Create(ctx, notif); err != nil {
t.Fatalf("Create failed: %v", err)
}
// Re-read via List (which goes through scanNotification) so we're
// testing both the INSERT and SELECT halves of the U-3 plumbing.
got, err := repo.List(ctx, nil)
if err != nil {
t.Fatalf("List failed: %v", err)
}
if len(got) != 1 {
t.Fatalf("List returned %d rows, want 1", len(got))
}
if !got[0].CreatedAt.Equal(want) {
t.Errorf("CreatedAt round-trip mismatch:\n set: %v\n got: %v\n"+
"Pre-U-3 this would have come back as 0001-01-01 because the column didn't exist.",
want, got[0].CreatedAt)
}
}
// TestNotificationRepository_CreatedAt_DefaultsToNow verifies the helper
// behavior in Create: when the caller hands over an event with the
// zero-value CreatedAt, Create substitutes time.Now() rather than
// trusting the DB DEFAULT. This keeps wire-level JSON consistent with
// what the row will hold once it's read back, and avoids a clock-skew
// gap between "Go computed the timestamp" and "DB applied DEFAULT NOW()".
func TestNotificationRepository_CreatedAt_DefaultsToNow(t *testing.T) {
tdb := getTestDB(t)
db := tdb.freshSchema(t)
repo := postgres.NewNotificationRepository(db)
ctx := context.Background()
before := time.Now().UTC().Add(-time.Second)
notif := &domain.NotificationEvent{
Type: domain.NotificationTypeExpirationWarning,
Channel: domain.NotificationChannelWebhook,
Recipient: "https://hooks.example.com/zerotime",
Message: "U-3 zero-time fallback",
Status: string(domain.NotificationStatusPending),
// CreatedAt left zero on purpose — the contract is that Create
// fills it in from time.Now() when it's unset.
}
if err := repo.Create(ctx, notif); err != nil {
t.Fatalf("Create failed: %v", err)
}
after := time.Now().UTC().Add(time.Second)
if notif.CreatedAt.IsZero() {
t.Fatalf("CreatedAt is still zero after Create — the fallback in NotificationRepository.Create did not fire")
}
if notif.CreatedAt.Before(before) || notif.CreatedAt.After(after) {
t.Errorf("CreatedAt = %v is outside the [%v, %v] window — the substituted time.Now() should fall inside the test's wall-clock bracket",
notif.CreatedAt, before, after)
}
}
// ─── Helpers ────────────────────────────────────────────────────────────── // ─── Helpers ──────────────────────────────────────────────────────────────
// past returns a stable "5 minutes ago" time for fixture seeding. Truncated // past returns a stable "5 minutes ago" time for fixture seeding. Truncated
+4 -3
View File
@@ -1,6 +1,7 @@
package postgres package postgres
import ( import (
"github.com/shankar0123/certctl/internal/repository"
"context" "context"
"database/sql" "database/sql"
"fmt" "fmt"
@@ -61,7 +62,7 @@ func (r *OwnerRepository) Get(ctx context.Context, id string) (*domain.Owner, er
if err != nil { if err != nil {
if err == sql.ErrNoRows { if err == sql.ErrNoRows {
return nil, fmt.Errorf("owner not found") return nil, fmt.Errorf("owner not found: %w", repository.ErrNotFound)
} }
return nil, fmt.Errorf("failed to query owner: %w", err) return nil, fmt.Errorf("failed to query owner: %w", err)
} }
@@ -110,7 +111,7 @@ func (r *OwnerRepository) Update(ctx context.Context, owner *domain.Owner) error
} }
if rows == 0 { if rows == 0 {
return fmt.Errorf("owner not found") return fmt.Errorf("owner not found: %w", repository.ErrNotFound)
} }
return nil return nil
@@ -130,7 +131,7 @@ func (r *OwnerRepository) Delete(ctx context.Context, id string) error {
} }
if rows == 0 { if rows == 0 {
return fmt.Errorf("owner not found") return fmt.Errorf("owner not found: %w", repository.ErrNotFound)
} }
return nil return nil
+3 -3
View File
@@ -63,7 +63,7 @@ func (r *PolicyRepository) GetRule(ctx context.Context, id string) (*domain.Poli
if err != nil { if err != nil {
if err == sql.ErrNoRows { if err == sql.ErrNoRows {
return nil, fmt.Errorf("policy rule not found") return nil, fmt.Errorf("policy rule not found: %w", repository.ErrNotFound)
} }
return nil, fmt.Errorf("failed to query policy rule: %w", err) return nil, fmt.Errorf("failed to query policy rule: %w", err)
} }
@@ -114,7 +114,7 @@ func (r *PolicyRepository) UpdateRule(ctx context.Context, rule *domain.PolicyRu
} }
if rows == 0 { if rows == 0 {
return fmt.Errorf("policy rule not found") return fmt.Errorf("policy rule not found: %w", repository.ErrNotFound)
} }
return nil return nil
@@ -134,7 +134,7 @@ func (r *PolicyRepository) DeleteRule(ctx context.Context, id string) error {
} }
if rows == 0 { if rows == 0 {
return fmt.Errorf("policy rule not found") return fmt.Errorf("policy rule not found: %w", repository.ErrNotFound)
} }
return nil return nil
+4 -3
View File
@@ -1,6 +1,7 @@
package postgres package postgres
import ( import (
"github.com/shankar0123/certctl/internal/repository"
"context" "context"
"database/sql" "database/sql"
"encoding/json" "encoding/json"
@@ -64,7 +65,7 @@ func (r *ProfileRepository) Get(ctx context.Context, id string) (*domain.Certifi
p, err := scanProfile(row) p, err := scanProfile(row)
if err != nil { if err != nil {
if err == sql.ErrNoRows { if err == sql.ErrNoRows {
return nil, fmt.Errorf("profile not found") return nil, fmt.Errorf("profile not found: %w", repository.ErrNotFound)
} }
return nil, fmt.Errorf("failed to query profile: %w", err) return nil, fmt.Errorf("failed to query profile: %w", err)
} }
@@ -159,7 +160,7 @@ func (r *ProfileRepository) Update(ctx context.Context, profile *domain.Certific
} }
if rows == 0 { if rows == 0 {
return fmt.Errorf("profile not found") return fmt.Errorf("profile not found: %w", repository.ErrNotFound)
} }
return nil return nil
@@ -178,7 +179,7 @@ func (r *ProfileRepository) Delete(ctx context.Context, id string) error {
} }
if rows == 0 { if rows == 0 {
return fmt.Errorf("profile not found") return fmt.Errorf("profile not found: %w", repository.ErrNotFound)
} }
return nil return nil
@@ -36,7 +36,7 @@ func NewRenewalPolicyRepository(db *sql.DB) *RenewalPolicyRepository {
// and require domain-layer churn we're not taking on in this change. // and require domain-layer churn we're not taking on in this change.
const renewalPolicyColumns = ` const renewalPolicyColumns = `
id, name, renewal_window_days, auto_renew, max_retries, id, name, renewal_window_days, auto_renew, max_retries,
retry_interval_minutes, alert_thresholds_days, created_at, updated_at retry_interval_seconds, alert_thresholds_days, created_at, updated_at
` `
// scanRenewalPolicy decodes one renewal_policies row from a Row or Rows // scanRenewalPolicy decodes one renewal_policies row from a Row or Rows
@@ -72,7 +72,7 @@ func (r *RenewalPolicyRepository) Get(ctx context.Context, id string) (*domain.R
policy, err := scanRenewalPolicy(row) policy, err := scanRenewalPolicy(row)
if err != nil { if err != nil {
if errors.Is(err, sql.ErrNoRows) { if errors.Is(err, sql.ErrNoRows) {
return nil, fmt.Errorf("renewal policy not found: %s", id) return nil, fmt.Errorf("renewal policy not found: %w", repository.ErrNotFound)
} }
return nil, fmt.Errorf("failed to query renewal policy: %w", err) return nil, fmt.Errorf("failed to query renewal policy: %w", err)
} }
@@ -170,7 +170,7 @@ func (r *RenewalPolicyRepository) Create(ctx context.Context, policy *domain.Ren
insertSQL := ` insertSQL := `
INSERT INTO renewal_policies ( INSERT INTO renewal_policies (
id, name, renewal_window_days, auto_renew, max_retries, id, name, renewal_window_days, auto_renew, max_retries,
retry_interval_minutes, alert_thresholds_days, created_at, updated_at retry_interval_seconds, alert_thresholds_days, created_at, updated_at
) VALUES ($1, $2, $3, $4, $5, $6, $7, NOW(), NOW()) ) VALUES ($1, $2, $3, $4, $5, $6, $7, NOW(), NOW())
RETURNING ` + renewalPolicyColumns RETURNING ` + renewalPolicyColumns
@@ -240,7 +240,7 @@ func (r *RenewalPolicyRepository) Update(ctx context.Context, id string, policy
renewal_window_days = $3, renewal_window_days = $3,
auto_renew = $4, auto_renew = $4,
max_retries = $5, max_retries = $5,
retry_interval_minutes = $6, retry_interval_seconds = $6,
alert_thresholds_days = $7, alert_thresholds_days = $7,
updated_at = NOW() updated_at = NOW()
WHERE id = $1 WHERE id = $1
@@ -252,7 +252,7 @@ func (r *RenewalPolicyRepository) Update(ctx context.Context, id string, policy
updated, err := scanRenewalPolicy(row) updated, err := scanRenewalPolicy(row)
if err != nil { if err != nil {
if errors.Is(err, sql.ErrNoRows) { if errors.Is(err, sql.ErrNoRows) {
return fmt.Errorf("renewal policy not found: %s", id) return fmt.Errorf("renewal policy not found: %w", repository.ErrNotFound)
} }
if isUniqueViolation(err) { if isUniqueViolation(err) {
return repository.ErrRenewalPolicyDuplicateName return repository.ErrRenewalPolicyDuplicateName
@@ -283,7 +283,7 @@ func (r *RenewalPolicyRepository) Delete(ctx context.Context, id string) error {
return fmt.Errorf("failed to read RowsAffected for delete: %w", err) return fmt.Errorf("failed to read RowsAffected for delete: %w", err)
} }
if rows == 0 { if rows == 0 {
return fmt.Errorf("renewal policy not found: %s", id) return fmt.Errorf("renewal policy not found: %w", repository.ErrNotFound)
} }
return nil return nil
} }
@@ -45,7 +45,7 @@ func TestRenewalPolicyRepository_CRUD(t *testing.T) {
RenewalWindowDays: 30, RenewalWindowDays: 30,
AutoRenew: true, AutoRenew: true,
MaxRetries: 5, MaxRetries: 5,
RetryInterval: 3600, // stored in retry_interval_minutes column; passthrough RetryInterval: 3600, // stored as seconds in retry_interval_seconds column (renamed in 000017_db_coupling_cleanup, U-3)
AlertThresholdsDays: []int{30, 14, 7, 0}, AlertThresholdsDays: []int{30, 14, 7, 0},
} }
+1 -1
View File
@@ -78,7 +78,7 @@ func insertCertPrereqsRaw(t *testing.T, db *sql.DB, ctx context.Context, suffix
} }
// Create renewal policy // Create renewal policy
_, err = db.ExecContext(ctx, `INSERT INTO renewal_policies (id, name, renewal_window_days, auto_renew, max_retries, retry_interval_minutes, created_at, updated_at) VALUES ($1, $2, $3, $4, $5, $6, $7, $8)`, _, err = db.ExecContext(ctx, `INSERT INTO renewal_policies (id, name, renewal_window_days, auto_renew, max_retries, retry_interval_seconds, created_at, updated_at) VALUES ($1, $2, $3, $4, $5, $6, $7, $8)`,
policyID, "Policy "+suffix, 30, true, 3, 60, now, now) policyID, "Policy "+suffix, 30, true, 3, 60, now, now)
if err != nil { if err != nil {
t.Fatalf("insertCertPrereqs: create renewal_policy failed: %v", err) t.Fatalf("insertCertPrereqs: create renewal_policy failed: %v", err)
+2 -1
View File
@@ -1,6 +1,7 @@
package postgres package postgres
import ( import (
"github.com/shankar0123/certctl/internal/repository"
"context" "context"
"database/sql" "database/sql"
"fmt" "fmt"
@@ -136,7 +137,7 @@ func (r *RevocationRepository) MarkIssuerNotified(ctx context.Context, id string
} }
if rows == 0 { if rows == 0 {
return fmt.Errorf("revocation not found") return fmt.Errorf("revocation not found: %w", repository.ErrNotFound)
} }
return nil return nil
+246
View File
@@ -0,0 +1,246 @@
// Integration tests for the U-3 schema-vs-seed coupling fix.
//
// Pre-U-3 the deploy compose stack mounted both a hand-curated subset of
// `migrations/*.up.sql` and `seed.sql` into postgres
// `/docker-entrypoint-initdb.d/`. Postgres applied them at initdb time.
// When `seed.sql` was updated to reference columns added by migrations
// *after* the mounted cutoff (e.g., `policy_rules.severity` from
// `000013_policy_rule_severity.up.sql`), initdb crashed during the seed
// step and the container was reported `unhealthy` indefinitely.
//
// Post-U-3 the schema is built EXCLUSIVELY by the server at startup via
// internal/repository/postgres.RunMigrations + RunSeed. These tests pin
// that contract: RunSeed must complete without error against a freshly
// migrated database, and re-application must be idempotent so server
// restarts don't double-insert.
//
// Skipped under -short to keep CI fast lanes green; the integration lane
// runs them via the testcontainers harness.
package postgres_test
import (
"context"
"database/sql"
"testing"
"github.com/shankar0123/certctl/internal/repository/postgres"
)
// TestRunSeed_AppliesIdempotently verifies the U-3 contract that RunSeed
// can be called repeatedly against a populated database without error and
// without producing duplicate rows. The server invokes RunSeed on EVERY
// boot (it has no migration-state table to skip from), so any non-
// idempotent INSERT in seed.sql would crash the container loop on the
// second start.
//
// The assertion uses renewal_policies.id='rp-default' as a witness — that
// row is the most-referenced FK target in the seed (it's the default
// renewal policy attached to every certificate that doesn't override).
// If the seed double-inserted, we'd see SQLSTATE 23505 from the second
// RunSeed call. If the seed silently ON CONFLICT-DO-NOTHING'd as
// designed, the row count stays at exactly 1.
func TestRunSeed_AppliesIdempotently(t *testing.T) {
tdb := getTestDB(t)
db := tdb.freshSchema(t)
ctx := context.Background()
migrationsPath := findMigrationsDir()
// Apply the seed twice — second call simulates a server restart on a
// populated database. Both must succeed; pre-U-3 the second call
// would fail with 23505 if any INSERT lacked ON CONFLICT.
if err := postgres.RunSeed(db, migrationsPath); err != nil {
t.Fatalf("RunSeed (first call) returned error: %v", err)
}
if err := postgres.RunSeed(db, migrationsPath); err != nil {
t.Fatalf("RunSeed (second call — idempotency check) returned error: %v\n"+
"This means the seed produced a duplicate row; every INSERT in seed.sql "+
"must use ON CONFLICT (id) DO NOTHING because the server applies the "+
"seed on EVERY start.", err)
}
// Witness check: rp-default is the renewal policy every cert defaults
// to. Exactly one row must exist after two seed applications.
var count int
err := db.QueryRowContext(ctx,
`SELECT COUNT(*) FROM renewal_policies WHERE id = 'rp-default'`,
).Scan(&count)
if err != nil {
t.Fatalf("witness query failed: %v", err)
}
if count != 1 {
t.Errorf("renewal_policies WHERE id='rp-default' returned %d rows after two RunSeed calls; want exactly 1 (ON CONFLICT idempotency contract)", count)
}
}
// TestRunSeed_MissingFileIsNoOp verifies the fail-soft contract documented
// on RunSeed: an operator who deletes seed.sql for custom packaging (CI
// pipelines that bake their own seeds, cert-manager managed deployments)
// must still get a healthy server boot. RunSeed returning nil for a
// missing file is the only way to hold this contract — returning an error
// would force every minimal-image deployment to ship the seed file just
// to satisfy a no-op load.
//
// We point at a directory that exists (empty temp dir) but contains no
// seed.sql. RunSeed must return nil silently.
func TestRunSeed_MissingFileIsNoOp(t *testing.T) {
if testing.Short() {
t.Skip("skipping integration test in short mode")
}
// Use a brand-new empty directory so seed.sql is unambiguously absent.
emptyDir := t.TempDir()
// Pass a nil *sql.DB on purpose — RunSeed must short-circuit on the
// missing file BEFORE touching the DB. If the implementation ever
// regresses and tries to db.Exec(string(content)) with nil content,
// this will surface as a nil-deref instead of a silent corruption.
var db *sql.DB
if err := postgres.RunSeed(db, emptyDir); err != nil {
t.Fatalf("RunSeed against an empty directory should return nil; got: %v", err)
}
}
// TestRunDemoSeed_AppliesIdempotently mirrors the RunSeed idempotency
// contract for the demo overlay. The compose demo stack
// (deploy/docker-compose.demo.yml) sets CERTCTL_DEMO_SEED=true; the
// server applies seed_demo.sql at every boot. Same constraint as the
// baseline seed: if any INSERT lacks ON CONFLICT, the server will
// crash-loop on restart.
//
// Witness: seed_demo.sql inserts t-platform into the teams table at line
// 11. That row is referenced by every demo-team-owned certificate, so
// duplicate-insertion would block the entire demo on restart.
func TestRunDemoSeed_AppliesIdempotently(t *testing.T) {
tdb := getTestDB(t)
db := tdb.freshSchema(t)
ctx := context.Background()
migrationsPath := findMigrationsDir()
// Order matters — RunSeed must run first so the FK targets the demo
// seed depends on (rp-* renewal policies, etc.) exist before the
// demo INSERTs run. This mirrors the order in cmd/server/main.go.
if err := postgres.RunSeed(db, migrationsPath); err != nil {
t.Fatalf("RunSeed prerequisite failed: %v", err)
}
if err := postgres.RunDemoSeed(db, migrationsPath); err != nil {
t.Fatalf("RunDemoSeed (first call) returned error: %v", err)
}
if err := postgres.RunDemoSeed(db, migrationsPath); err != nil {
t.Fatalf("RunDemoSeed (second call — idempotency check) returned error: %v", err)
}
var count int
err := db.QueryRowContext(ctx,
`SELECT COUNT(*) FROM teams WHERE id = 't-platform'`,
).Scan(&count)
if err != nil {
t.Fatalf("witness query failed: %v", err)
}
if count != 1 {
t.Errorf("teams WHERE id='t-platform' returned %d rows after two RunDemoSeed calls; want exactly 1", count)
}
}
// TestMigration000017_RetryIntervalRename verifies the U-3 ride-along
// column rename: renewal_policies.retry_interval_minutes →
// retry_interval_seconds (cat-o-retry_interval_unit_mismatch). The unit
// was always seconds in practice — the column name lied. Migration 000017
// renames the column with a DO $$ guard so re-application is safe.
//
// After all migrations have been applied (which the test harness does in
// freshSchema), the new column must exist and the old column must NOT.
// information_schema.columns is the source of truth for both checks.
func TestMigration000017_RetryIntervalRename(t *testing.T) {
tdb := getTestDB(t)
db := tdb.freshSchema(t)
ctx := context.Background()
// Helper — true iff the named column exists on renewal_policies.
hasColumn := func(name string) bool {
t.Helper()
var n int
err := db.QueryRowContext(ctx, `
SELECT COUNT(*) FROM information_schema.columns
WHERE table_name = 'renewal_policies' AND column_name = $1
`, name).Scan(&n)
if err != nil {
t.Fatalf("information_schema query for column %q failed: %v", name, err)
}
return n > 0
}
if !hasColumn("retry_interval_seconds") {
t.Error("renewal_policies.retry_interval_seconds is missing — migration 000017 did not apply, or it was applied before the rename block")
}
if hasColumn("retry_interval_minutes") {
t.Error("renewal_policies.retry_interval_minutes still exists — the rename in migration 000017 must drop the old name (cat-o-retry_interval_unit_mismatch)")
}
}
// TestMigration000017_NotificationCreatedAt verifies the U-3 ride-along
// column add: notification_events.created_at NOT NULL DEFAULT NOW()
// (cat-o-notification_created_at_dead_field). Pre-U-3 the Go domain had
// the field but the DB lacked the column, so the JSON API serialised
// 0001-01-01.
func TestMigration000017_NotificationCreatedAt(t *testing.T) {
tdb := getTestDB(t)
db := tdb.freshSchema(t)
ctx := context.Background()
var dataType, isNullable, columnDefault sql.NullString
err := db.QueryRowContext(ctx, `
SELECT data_type, is_nullable, column_default
FROM information_schema.columns
WHERE table_name = 'notification_events' AND column_name = 'created_at'
`).Scan(&dataType, &isNullable, &columnDefault)
if err != nil {
t.Fatalf("information_schema query for created_at failed: %v\n"+
"Migration 000017 should have added notification_events.created_at TIMESTAMPTZ NOT NULL DEFAULT NOW().", err)
}
if dataType.String != "timestamp with time zone" {
t.Errorf("notification_events.created_at data_type = %q, want %q",
dataType.String, "timestamp with time zone")
}
if isNullable.String != "NO" {
t.Errorf("notification_events.created_at is_nullable = %q, want NO (the column must be NOT NULL so legacy rows get the DEFAULT)",
isNullable.String)
}
if columnDefault.String == "" {
t.Error("notification_events.created_at has no DEFAULT — legacy rows added before migration 000017 would fail the NOT NULL gate without one")
}
}
// TestMigration000017_HealthCheckOrphansDropped verifies the U-3
// ride-along column drop: network_scan_targets lost the orphan
// health_check_enabled / health_check_interval_seconds columns
// (cat-o-health_check_column_orphans). These were declared by an early
// migration but never wired into Go code — schema noise that confused
// operators reading raw SQL. Migration 000017 drops them.
func TestMigration000017_HealthCheckOrphansDropped(t *testing.T) {
tdb := getTestDB(t)
db := tdb.freshSchema(t)
ctx := context.Background()
hasColumn := func(name string) bool {
t.Helper()
var n int
err := db.QueryRowContext(ctx, `
SELECT COUNT(*) FROM information_schema.columns
WHERE table_name = 'network_scan_targets' AND column_name = $1
`, name).Scan(&n)
if err != nil {
t.Fatalf("information_schema query for column %q failed: %v", name, err)
}
return n > 0
}
for _, col := range []string{"health_check_enabled", "health_check_interval_seconds"} {
if hasColumn(col) {
t.Errorf("network_scan_targets.%s still exists — migration 000017 must drop it (cat-o-health_check_column_orphans)", col)
}
}
}
+4 -3
View File
@@ -1,6 +1,7 @@
package postgres package postgres
import ( import (
"github.com/shankar0123/certctl/internal/repository"
"context" "context"
"database/sql" "database/sql"
"fmt" "fmt"
@@ -89,7 +90,7 @@ func (r *TargetRepository) Get(ctx context.Context, id string) (*domain.Deployme
if err != nil { if err != nil {
if err == sql.ErrNoRows { if err == sql.ErrNoRows {
return nil, fmt.Errorf("target not found") return nil, fmt.Errorf("target not found: %w", repository.ErrNotFound)
} }
return nil, fmt.Errorf("failed to query target: %w", err) return nil, fmt.Errorf("failed to query target: %w", err)
} }
@@ -174,7 +175,7 @@ func (r *TargetRepository) Update(ctx context.Context, target *domain.Deployment
} }
if rows == 0 { if rows == 0 {
return fmt.Errorf("target not found") return fmt.Errorf("target not found: %w", repository.ErrNotFound)
} }
return nil return nil
@@ -194,7 +195,7 @@ func (r *TargetRepository) Delete(ctx context.Context, id string) error {
} }
if rows == 0 { if rows == 0 {
return fmt.Errorf("target not found") return fmt.Errorf("target not found: %w", repository.ErrNotFound)
} }
return nil return nil
+4 -3
View File
@@ -1,6 +1,7 @@
package postgres package postgres
import ( import (
"github.com/shankar0123/certctl/internal/repository"
"context" "context"
"database/sql" "database/sql"
"fmt" "fmt"
@@ -61,7 +62,7 @@ func (r *TeamRepository) Get(ctx context.Context, id string) (*domain.Team, erro
if err != nil { if err != nil {
if err == sql.ErrNoRows { if err == sql.ErrNoRows {
return nil, fmt.Errorf("team not found") return nil, fmt.Errorf("team not found: %w", repository.ErrNotFound)
} }
return nil, fmt.Errorf("failed to query team: %w", err) return nil, fmt.Errorf("failed to query team: %w", err)
} }
@@ -108,7 +109,7 @@ func (r *TeamRepository) Update(ctx context.Context, team *domain.Team) error {
} }
if rows == 0 { if rows == 0 {
return fmt.Errorf("team not found") return fmt.Errorf("team not found: %w", repository.ErrNotFound)
} }
return nil return nil
@@ -128,7 +129,7 @@ func (r *TeamRepository) Delete(ctx context.Context, id string) error {
} }
if rows == 0 { if rows == 0 {
return fmt.Errorf("team not found") return fmt.Errorf("team not found: %w", repository.ErrNotFound)
} }
return nil return nil
+148
View File
@@ -0,0 +1,148 @@
package service
import (
"context"
"errors"
"fmt"
"log/slog"
"strings"
"github.com/shankar0123/certctl/internal/domain"
"github.com/shankar0123/certctl/internal/repository"
)
// ErrBulkReassignOwnerNotFound is the typed sentinel for a non-existent
// target OwnerID. The handler maps it to 400 (bad input — the operator
// picked an owner that doesn't exist) rather than 500 (server error).
// Sentinel-error rather than substring-error matches the project's
// post-M-1 error-mapping convention.
var ErrBulkReassignOwnerNotFound = errors.New("owner not found")
// BulkReassignmentService coordinates bulk owner-reassignment of
// certificates.
//
// L-2 closure (cat-l-8a1fb258a38a): the GUI used to loop
// `await updateCertificate(id, { owner_id })` over the selection at
// `web/src/pages/CertificatesPage.tsx::handleReassign`. Post-L-2 the
// GUI POSTs once. Narrower than BulkRenewal: explicit IDs only, no
// criteria-mode (criteria-mode reassignment doesn't have a strong use
// case — operators query first then reassign by ID).
//
// Validation order: empty IDs → 400, missing OwnerID → 400, OwnerID
// not in owners table → 400 (ErrBulkReassignOwnerNotFound). Resolving
// the owner upfront means we fail-fast without mutating any cert if
// the operator typo'd the owner ID.
type BulkReassignmentService struct {
certRepo repository.CertificateRepository
ownerRepo repository.OwnerRepository
auditService *AuditService
logger *slog.Logger
}
// NewBulkReassignmentService creates a new BulkReassignmentService.
func NewBulkReassignmentService(
certRepo repository.CertificateRepository,
ownerRepo repository.OwnerRepository,
auditService *AuditService,
logger *slog.Logger,
) *BulkReassignmentService {
return &BulkReassignmentService{
certRepo: certRepo,
ownerRepo: ownerRepo,
auditService: auditService,
logger: logger,
}
}
// BulkReassign updates owner_id (and optionally team_id) on every cert
// in request.CertificateIDs. Skips certs whose owner_id already equals
// the target (silent no-op — surfaced as TotalSkipped++, not as a fake
// "succeeded" count, so operators see "5 of your 10 selections were
// no-ops because Alice already owned them" without triaging fake
// errors).
//
// Partial failures don't abort the batch — the failing cert lands in
// Errors[]; the loop continues. Mirrors BulkRevocationService and
// BulkRenewalService partial-failure semantics.
//
// Audit: a single audit event is emitted at the end with the criteria
// + counts. NOT N events.
func (s *BulkReassignmentService) BulkReassign(ctx context.Context, request domain.BulkReassignmentRequest, actor string) (*domain.BulkReassignmentResult, error) {
if request.IsEmpty() {
return nil, fmt.Errorf("at least one certificate_id is required")
}
if request.OwnerID == "" {
return nil, fmt.Errorf("owner_id is required")
}
// Validate the target owner exists BEFORE touching any cert. This
// fail-fast pattern means an operator who typo'd 'o-alic' (missing
// 'e') doesn't half-reassign 50 certs before the 51st surfaces the
// FK violation.
if _, err := s.ownerRepo.Get(ctx, request.OwnerID); err != nil {
return nil, fmt.Errorf("%w: %s", ErrBulkReassignOwnerNotFound, request.OwnerID)
}
result := &domain.BulkReassignmentResult{}
for _, id := range request.CertificateIDs {
cert, err := s.certRepo.Get(ctx, id)
if err != nil {
result.TotalFailed++
result.Errors = append(result.Errors, domain.BulkOperationError{
CertificateID: id,
Error: fmt.Sprintf("failed to fetch certificate: %v", err),
})
continue
}
result.TotalMatched++
// No-op skip: cert already owned by the target. team_id may
// still differ — we still skip if owner matches AND
// team_id-update is a no-op (team unchanged or team_id field
// not set on the request). This prevents fake "reassigned"
// counts when nothing actually changed.
ownerUnchanged := cert.OwnerID == request.OwnerID
teamUnchanged := request.TeamID == "" || cert.TeamID == request.TeamID
if ownerUnchanged && teamUnchanged {
result.TotalSkipped++
continue
}
cert.OwnerID = request.OwnerID
if request.TeamID != "" {
cert.TeamID = request.TeamID
}
if err := s.certRepo.Update(ctx, cert); err != nil {
result.TotalFailed++
result.Errors = append(result.Errors, domain.BulkOperationError{
CertificateID: id,
Error: fmt.Sprintf("failed to update certificate: %v", err),
})
s.logger.Warn("bulk reassignment: update failed",
"certificate_id", id, "error", err)
continue
}
result.TotalReassigned++
}
// Single bulk audit event at the end.
auditDetails := map[string]interface{}{
"owner_id": request.OwnerID,
"certificate_ids": strings.Join(request.CertificateIDs, ","),
"total_matched": result.TotalMatched,
"total_reassigned": result.TotalReassigned,
"total_skipped": result.TotalSkipped,
"total_failed": result.TotalFailed,
}
if request.TeamID != "" {
auditDetails["team_id"] = request.TeamID
}
if err := s.auditService.RecordEvent(ctx, actor, domain.ActorTypeUser,
"bulk_reassignment_initiated", "certificate", "bulk",
auditDetails); err != nil {
s.logger.Error("failed to record bulk reassignment audit event", "error", err)
}
return result, nil
}
+221
View File
@@ -0,0 +1,221 @@
package service
import (
"context"
"errors"
"log/slog"
"testing"
"time"
"github.com/shankar0123/certctl/internal/domain"
)
func newBulkReassignmentTestService() (*BulkReassignmentService, *mockCertRepo, *mockOwnerRepo, *mockAuditRepo) {
certRepo := newMockCertificateRepository()
ownerRepo := newMockOwnerRepository()
auditRepo := newMockAuditRepository()
auditService := NewAuditService(auditRepo)
svc := NewBulkReassignmentService(certRepo, ownerRepo, auditService, slog.Default())
return svc, certRepo, ownerRepo, auditRepo
}
// addOwnedCert seeds a cert with a specific owner+team for reassignment.
func addOwnedCert(repo *mockCertRepo, id, ownerID, teamID string) {
cert := &domain.ManagedCertificate{
ID: id, CommonName: id, Status: domain.CertificateStatusActive,
OwnerID: ownerID, TeamID: teamID,
ExpiresAt: time.Now().AddDate(0, 1, 0),
}
repo.AddCert(cert)
}
func addOwner(repo *mockOwnerRepo, id string) {
repo.owners[id] = &domain.Owner{ID: id, Name: id}
}
// TestBulkReassign_HappyPath — N certs all reassigned successfully.
func TestBulkReassign_HappyPath(t *testing.T) {
svc, certRepo, ownerRepo, _ := newBulkReassignmentTestService()
addOwner(ownerRepo, "o-bob")
addOwnedCert(certRepo, "mc-1", "o-alice", "")
addOwnedCert(certRepo, "mc-2", "o-alice", "")
addOwnedCert(certRepo, "mc-3", "o-alice", "")
res, err := svc.BulkReassign(context.Background(),
domain.BulkReassignmentRequest{
CertificateIDs: []string{"mc-1", "mc-2", "mc-3"},
OwnerID: "o-bob",
}, "admin")
if err != nil {
t.Fatalf("BulkReassign failed: %v", err)
}
if res.TotalReassigned != 3 || res.TotalSkipped != 0 || res.TotalFailed != 0 {
t.Errorf("counts = reassigned:%d skipped:%d failed:%d, want 3/0/0",
res.TotalReassigned, res.TotalSkipped, res.TotalFailed)
}
for _, id := range []string{"mc-1", "mc-2", "mc-3"} {
if certRepo.Certs[id].OwnerID != "o-bob" {
t.Errorf("cert %s: owner_id = %s, want o-bob", id, certRepo.Certs[id].OwnerID)
}
}
}
// TestBulkReassign_SkipsAlreadyOwned — certs already owned by the
// target are no-op-skipped (not counted as reassigned, not surfaced as
// errors). Operator sees "5 of your 10 selections were no-ops because
// Bob already owned them" without triaging fake errors.
func TestBulkReassign_SkipsAlreadyOwned(t *testing.T) {
svc, certRepo, ownerRepo, _ := newBulkReassignmentTestService()
addOwner(ownerRepo, "o-bob")
addOwnedCert(certRepo, "mc-1", "o-bob", "") // already owned by target
addOwnedCert(certRepo, "mc-2", "o-alice", "") // needs reassign
res, err := svc.BulkReassign(context.Background(),
domain.BulkReassignmentRequest{
CertificateIDs: []string{"mc-1", "mc-2"},
OwnerID: "o-bob",
}, "admin")
if err != nil {
t.Fatalf("BulkReassign failed: %v", err)
}
if res.TotalReassigned != 1 || res.TotalSkipped != 1 {
t.Errorf("counts = reassigned:%d skipped:%d, want 1/1", res.TotalReassigned, res.TotalSkipped)
}
if len(res.Errors) != 0 {
t.Errorf("already-owned skip should NOT populate Errors; got %v", res.Errors)
}
}
// TestBulkReassign_OwnerIDRequired_Error — empty owner_id rejected.
func TestBulkReassign_OwnerIDRequired_Error(t *testing.T) {
svc, certRepo, _, _ := newBulkReassignmentTestService()
addOwnedCert(certRepo, "mc-1", "o-alice", "")
_, err := svc.BulkReassign(context.Background(),
domain.BulkReassignmentRequest{CertificateIDs: []string{"mc-1"}, OwnerID: ""}, "admin")
if err == nil {
t.Fatal("expected error for empty owner_id, got nil")
}
}
// TestBulkReassign_EmptyIDs_Error — empty IDs rejected.
func TestBulkReassign_EmptyIDs_Error(t *testing.T) {
svc, _, ownerRepo, _ := newBulkReassignmentTestService()
addOwner(ownerRepo, "o-bob")
_, err := svc.BulkReassign(context.Background(),
domain.BulkReassignmentRequest{CertificateIDs: []string{}, OwnerID: "o-bob"}, "admin")
if err == nil {
t.Fatal("expected error for empty IDs, got nil")
}
}
// TestBulkReassign_OwnerNotFound_TypedSentinel — non-existent OwnerID
// returns ErrBulkReassignOwnerNotFound. Handler maps this to 400 (the
// operator picked an owner that doesn't exist) rather than 500 (server
// error). Sentinel-error rather than substring-error matches the
// project's post-M-1 error-mapping convention.
func TestBulkReassign_OwnerNotFound_TypedSentinel(t *testing.T) {
svc, certRepo, _, _ := newBulkReassignmentTestService()
addOwnedCert(certRepo, "mc-1", "o-alice", "")
_, err := svc.BulkReassign(context.Background(),
domain.BulkReassignmentRequest{CertificateIDs: []string{"mc-1"}, OwnerID: "o-ghost"}, "admin")
if err == nil {
t.Fatal("expected ErrBulkReassignOwnerNotFound, got nil")
}
if !errors.Is(err, ErrBulkReassignOwnerNotFound) {
t.Errorf("err is not ErrBulkReassignOwnerNotFound; got: %v", err)
}
}
// TestBulkReassign_TeamIDOptional — happy path WITHOUT team_id leaves
// team_id unchanged. Empty team_id in request must not zero out the
// existing team_id on the cert.
func TestBulkReassign_TeamIDOptional(t *testing.T) {
svc, certRepo, ownerRepo, _ := newBulkReassignmentTestService()
addOwner(ownerRepo, "o-bob")
addOwnedCert(certRepo, "mc-1", "o-alice", "t-platform")
_, err := svc.BulkReassign(context.Background(),
domain.BulkReassignmentRequest{
CertificateIDs: []string{"mc-1"},
OwnerID: "o-bob",
// TeamID intentionally omitted
}, "admin")
if err != nil {
t.Fatalf("BulkReassign failed: %v", err)
}
if certRepo.Certs["mc-1"].TeamID != "t-platform" {
t.Errorf("team_id was zeroed out; want unchanged 't-platform', got %q", certRepo.Certs["mc-1"].TeamID)
}
}
// TestBulkReassign_TeamIDProvided_Updates — when TeamID is non-empty in
// the request, both owner_id and team_id update.
func TestBulkReassign_TeamIDProvided_Updates(t *testing.T) {
svc, certRepo, ownerRepo, _ := newBulkReassignmentTestService()
addOwner(ownerRepo, "o-bob")
addOwnedCert(certRepo, "mc-1", "o-alice", "t-platform")
_, err := svc.BulkReassign(context.Background(),
domain.BulkReassignmentRequest{
CertificateIDs: []string{"mc-1"},
OwnerID: "o-bob",
TeamID: "t-security",
}, "admin")
if err != nil {
t.Fatalf("BulkReassign failed: %v", err)
}
if certRepo.Certs["mc-1"].TeamID != "t-security" {
t.Errorf("team_id = %q, want t-security", certRepo.Certs["mc-1"].TeamID)
}
}
// TestBulkReassign_PartialFailure — N=3, one cert mid-batch hits an
// Update error. Rest of the batch continues; failure surfaced in
// Errors.
func TestBulkReassign_PartialFailure(t *testing.T) {
svc, certRepo, ownerRepo, _ := newBulkReassignmentTestService()
addOwner(ownerRepo, "o-bob")
addOwnedCert(certRepo, "mc-1", "o-alice", "")
addOwnedCert(certRepo, "mc-2", "o-alice", "")
addOwnedCert(certRepo, "mc-3", "o-alice", "")
// Force the next Update to fail uniformly. Mirrors how
// TestBulkRevoke_PartialFailure injects a downstream failure.
certRepo.UpdateErr = errors.New("simulated DB outage")
res, err := svc.BulkReassign(context.Background(),
domain.BulkReassignmentRequest{
CertificateIDs: []string{"mc-1", "mc-2", "mc-3"},
OwnerID: "o-bob",
}, "admin")
if err != nil {
t.Fatalf("BulkReassign should not propagate per-cert errors; got: %v", err)
}
if res.TotalFailed != 3 || res.TotalReassigned != 0 {
t.Errorf("counts = failed:%d reassigned:%d, want 3/0", res.TotalFailed, res.TotalReassigned)
}
}
// TestBulkReassign_AuditEventEmitted — single bulk audit event.
func TestBulkReassign_AuditEventEmitted(t *testing.T) {
svc, certRepo, ownerRepo, auditRepo := newBulkReassignmentTestService()
addOwner(ownerRepo, "o-bob")
addOwnedCert(certRepo, "mc-1", "o-alice", "")
addOwnedCert(certRepo, "mc-2", "o-alice", "")
_, err := svc.BulkReassign(context.Background(),
domain.BulkReassignmentRequest{
CertificateIDs: []string{"mc-1", "mc-2"},
OwnerID: "o-bob",
}, "admin")
if err != nil {
t.Fatalf("BulkReassign failed: %v", err)
}
if len(auditRepo.Events) != 1 {
t.Errorf("audit events count = %d, want exactly 1 (one bulk event, NOT N per-cert events)", len(auditRepo.Events))
}
if len(auditRepo.Events) > 0 && auditRepo.Events[0].Action != "bulk_reassignment_initiated" {
t.Errorf("audit action = %q, want 'bulk_reassignment_initiated'", auditRepo.Events[0].Action)
}
}
+245
View File
@@ -0,0 +1,245 @@
package service
import (
"context"
"fmt"
"log/slog"
"strings"
"time"
"github.com/shankar0123/certctl/internal/domain"
"github.com/shankar0123/certctl/internal/repository"
)
// BulkRenewalService coordinates bulk certificate renewal operations.
// Mirrors BulkRevocationService in shape: resolve criteria → status filter →
// per-cert action loop → aggregate result + emit one bulk audit event.
//
// L-1 master closure (cat-l-fa0c1ac07ab5): the GUI used to loop
// `await triggerRenewal(id)` over the selection at
// `web/src/pages/CertificatesPage.tsx::handleBulkRenewal` (~line 411).
// 100 certs = 100 sequential HTTP round-trips. Post-L-1 the GUI POSTs
// once; this service does the loop server-side and returns a single
// envelope with per-cert {certificate_id, job_id} pairs in
// EnqueuedJobs and per-cert errors in Errors.
//
// Action verb is sync-enqueue (not sync-issue): for each matched cert
// flip status to RenewalInProgress and create a Job row. The
// scheduler's job processor picks up the jobs asynchronously. Sync-
// issue would block the HTTP request for minutes against a slow ACME
// issuer, which defeats the bulk-endpoint latency improvement.
type BulkRenewalService struct {
certRepo repository.CertificateRepository
jobRepo repository.JobRepository
auditService *AuditService
logger *slog.Logger
keygenMode string
}
// NewBulkRenewalService creates a new BulkRenewalService.
//
// keygenMode mirrors CertificateService.keygenMode — agent-mode jobs
// start as AwaitingCSR (the agent generates the key + submits a CSR);
// server-mode jobs start as Pending. The bulk path must produce jobs in
// the SAME initial status the single-cert path does, otherwise the
// scheduler routes them differently.
func NewBulkRenewalService(
certRepo repository.CertificateRepository,
jobRepo repository.JobRepository,
auditService *AuditService,
logger *slog.Logger,
keygenMode string,
) *BulkRenewalService {
return &BulkRenewalService{
certRepo: certRepo,
jobRepo: jobRepo,
auditService: auditService,
logger: logger,
keygenMode: keygenMode,
}
}
// BulkRenew enqueues a renewal job for every certificate matching the
// criteria (or in the explicit IDs list). Status filter:
// - Archived / Expired / Revoked → silent skip (TotalSkipped++)
// - RenewalInProgress → silent skip (avoid double-enqueue)
// - everything else → flip to RenewalInProgress + create job
//
// Partial failures don't abort the batch — the failing cert lands in
// Errors[] with the error string, and the loop continues. Mirrors
// BulkRevocationService.BulkRevoke's partial-failure semantics.
//
// Audit: a single audit event is emitted at the end with the criteria
// + counts (NOT N events). The single-cert TriggerRenewal path emits
// per-cert audit events; the bulk path uses one bulk envelope to keep
// audit_events from growing 100x for one operator click.
func (s *BulkRenewalService) BulkRenew(ctx context.Context, criteria domain.BulkRenewalCriteria, actor string) (*domain.BulkRenewalResult, error) {
if criteria.IsEmpty() {
return nil, fmt.Errorf("at least one filter criterion is required")
}
certs, err := s.resolveCertificates(ctx, criteria)
if err != nil {
return nil, fmt.Errorf("failed to resolve certificates: %w", err)
}
result := &domain.BulkRenewalResult{
TotalMatched: len(certs),
}
for _, cert := range certs {
// Status-filter the cert before mutating. Mirrors the
// eligibility checks in CertificateService.TriggerRenewal so a
// bulk caller can't bypass them. Each illegal status maps to a
// silent TotalSkipped++ rather than an Error so the operator
// sees "5 of your 10 selections were no-ops" without triaging
// fake errors.
if cert.Status == domain.CertificateStatusArchived ||
cert.Status == domain.CertificateStatusRevoked ||
cert.Status == domain.CertificateStatusExpired ||
cert.Status == domain.CertificateStatusRenewalInProgress {
result.TotalSkipped++
continue
}
// Flip status + create job. Bug-for-bug match with
// CertificateService.TriggerRenewal so the scheduler routing
// stays identical between the single-cert and bulk paths.
cert.Status = domain.CertificateStatusRenewalInProgress
if err := s.certRepo.Update(ctx, cert); err != nil {
result.TotalFailed++
result.Errors = append(result.Errors, domain.BulkOperationError{
CertificateID: cert.ID,
Error: fmt.Sprintf("failed to update certificate status: %v", err),
})
s.logger.Warn("bulk renewal: status update failed",
"certificate_id", cert.ID, "error", err)
continue
}
jobStatus := domain.JobStatusPending
if s.keygenMode == "agent" {
jobStatus = domain.JobStatusAwaitingCSR
}
jobType := domain.JobTypeRenewal
if cert.ExpiresAt.IsZero() || cert.ExpiresAt.Year() < 2000 {
jobType = domain.JobTypeIssuance
}
job := &domain.Job{
ID: generateID("job"),
CertificateID: cert.ID,
Type: jobType,
Status: jobStatus,
MaxAttempts: 3,
ScheduledAt: time.Now(),
CreatedAt: time.Now(),
}
if err := s.jobRepo.Create(ctx, job); err != nil {
result.TotalFailed++
result.Errors = append(result.Errors, domain.BulkOperationError{
CertificateID: cert.ID,
Error: fmt.Sprintf("failed to create renewal job: %v", err),
})
s.logger.Warn("bulk renewal: job creation failed",
"certificate_id", cert.ID, "error", err)
continue
}
result.TotalEnqueued++
result.EnqueuedJobs = append(result.EnqueuedJobs, domain.BulkEnqueuedJob{
CertificateID: cert.ID,
JobID: job.ID,
})
}
// Single bulk audit event at the end. Mirrors
// BulkRevocationService.BulkRevoke shape so the audit dashboard's
// rendering of bulk events is uniform across {revoke, renew, reassign}.
criteriaDetails := s.buildAuditDetails(criteria)
criteriaDetails["total_matched"] = result.TotalMatched
criteriaDetails["total_enqueued"] = result.TotalEnqueued
criteriaDetails["total_skipped"] = result.TotalSkipped
criteriaDetails["total_failed"] = result.TotalFailed
if err := s.auditService.RecordEvent(ctx, actor, domain.ActorTypeUser,
"bulk_renewal_initiated", "certificate", "bulk",
criteriaDetails); err != nil {
s.logger.Error("failed to record bulk renewal audit event", "error", err)
}
return result, nil
}
// resolveCertificates fetches the set of certificates matching the bulk
// renewal criteria. Mirrors BulkRevocationService.resolveCertificates
// behaviour exactly: explicit IDs alone → fetch each by ID; filter
// criteria → repo.List with high per_page; both → intersect.
func (s *BulkRenewalService) resolveCertificates(ctx context.Context, criteria domain.BulkRenewalCriteria) ([]*domain.ManagedCertificate, error) {
hasFilterCriteria := criteria.ProfileID != "" || criteria.OwnerID != "" ||
criteria.AgentID != "" || criteria.IssuerID != "" || criteria.TeamID != ""
hasExplicitIDs := len(criteria.CertificateIDs) > 0
if hasExplicitIDs && !hasFilterCriteria {
var certs []*domain.ManagedCertificate
for _, id := range criteria.CertificateIDs {
cert, err := s.certRepo.Get(ctx, id)
if err != nil {
continue // not-found certs silently drop out of the matched set
}
certs = append(certs, cert)
}
return certs, nil
}
filter := &repository.CertificateFilter{
OwnerID: criteria.OwnerID,
TeamID: criteria.TeamID,
IssuerID: criteria.IssuerID,
AgentID: criteria.AgentID,
ProfileID: criteria.ProfileID,
PerPage: 10000,
}
certs, _, err := s.certRepo.List(ctx, filter)
if err != nil {
return nil, err
}
if hasExplicitIDs {
idSet := make(map[string]bool, len(criteria.CertificateIDs))
for _, id := range criteria.CertificateIDs {
idSet[id] = true
}
var filtered []*domain.ManagedCertificate
for _, cert := range certs {
if idSet[cert.ID] {
filtered = append(filtered, cert)
}
}
return filtered, nil
}
return certs, nil
}
// buildAuditDetails constructs a map of criteria fields for the audit
// event. Mirrors BulkRevocationService.buildAuditDetails so the audit
// dashboard renders bulk events uniformly.
func (s *BulkRenewalService) buildAuditDetails(criteria domain.BulkRenewalCriteria) map[string]interface{} {
details := map[string]interface{}{}
if criteria.ProfileID != "" {
details["profile_id"] = criteria.ProfileID
}
if criteria.OwnerID != "" {
details["owner_id"] = criteria.OwnerID
}
if criteria.AgentID != "" {
details["agent_id"] = criteria.AgentID
}
if criteria.IssuerID != "" {
details["issuer_id"] = criteria.IssuerID
}
if criteria.TeamID != "" {
details["team_id"] = criteria.TeamID
}
if len(criteria.CertificateIDs) > 0 {
details["certificate_ids"] = strings.Join(criteria.CertificateIDs, ",")
}
return details
}
+221
View File
@@ -0,0 +1,221 @@
package service
import (
"context"
"errors"
"log/slog"
"testing"
"time"
"github.com/shankar0123/certctl/internal/domain"
)
// newBulkRenewalTestService spins up a BulkRenewalService wired against
// the in-memory mocks used by every other service test in this package.
// keygenMode defaults to "agent" — production-like routing where renewal
// jobs start as AwaitingCSR.
func newBulkRenewalTestService() (*BulkRenewalService, *mockCertRepo, *mockJobRepo, *mockAuditRepo) {
certRepo := newMockCertificateRepository()
jobRepo := &mockJobRepo{Jobs: map[string]*domain.Job{}}
auditRepo := newMockAuditRepository()
auditService := NewAuditService(auditRepo)
svc := NewBulkRenewalService(certRepo, jobRepo, auditService, slog.Default(), "agent")
return svc, certRepo, jobRepo, auditRepo
}
// addRenewableCert seeds a cert that is eligible for renewal (Active
// status, future expiry).
func addRenewableCert(repo *mockCertRepo, id string) {
cert := &domain.ManagedCertificate{
ID: id,
CommonName: id + ".example.com",
Status: domain.CertificateStatusActive,
ExpiresAt: time.Now().AddDate(0, 1, 0),
IssuerID: "iss-test",
}
repo.AddCert(cert)
}
// TestBulkRenew_ByExplicitIDs — happy path. N IDs in, N jobs enqueued,
// EnqueuedJobs slice carries the {certificate_id, job_id} pairs.
func TestBulkRenew_ByExplicitIDs(t *testing.T) {
svc, certRepo, jobRepo, _ := newBulkRenewalTestService()
addRenewableCert(certRepo, "mc-1")
addRenewableCert(certRepo, "mc-2")
addRenewableCert(certRepo, "mc-3")
res, err := svc.BulkRenew(context.Background(),
domain.BulkRenewalCriteria{CertificateIDs: []string{"mc-1", "mc-2", "mc-3"}},
"alice")
if err != nil {
t.Fatalf("BulkRenew failed: %v", err)
}
if res.TotalMatched != 3 || res.TotalEnqueued != 3 || res.TotalSkipped != 0 || res.TotalFailed != 0 {
t.Errorf("counts = matched:%d enqueued:%d skipped:%d failed:%d, want 3/3/0/0",
res.TotalMatched, res.TotalEnqueued, res.TotalSkipped, res.TotalFailed)
}
if len(res.EnqueuedJobs) != 3 {
t.Fatalf("EnqueuedJobs len = %d, want 3", len(res.EnqueuedJobs))
}
if len(jobRepo.Jobs) != 3 {
t.Errorf("jobRepo got %d jobs, want 3 (one per renewable cert)", len(jobRepo.Jobs))
}
for _, j := range res.EnqueuedJobs {
if j.JobID == "" {
t.Errorf("EnqueuedJob missing job_id for cert %s", j.CertificateID)
}
}
}
// TestBulkRenew_ByOwnerCriteria — criteria-mode resolution. The
// criteria-routing path must call resolveCertificates with the filter
// branch (not the explicit-IDs branch). Mocking convention in this
// package: mockCertRepo.List ignores the filter and returns all certs,
// so the test seeds only certs that should match (mirrors
// TestBulkRevoke_ByOwner shape in bulk_revocation_test.go).
func TestBulkRenew_ByOwnerCriteria(t *testing.T) {
svc, certRepo, _, _ := newBulkRenewalTestService()
for _, id := range []string{"mc-a1", "mc-a2"} {
cert := &domain.ManagedCertificate{
ID: id, CommonName: id, Status: domain.CertificateStatusActive,
OwnerID: "o-alice", ExpiresAt: time.Now().AddDate(0, 1, 0),
}
certRepo.AddCert(cert)
}
res, err := svc.BulkRenew(context.Background(),
domain.BulkRenewalCriteria{OwnerID: "o-alice"}, "alice")
if err != nil {
t.Fatalf("BulkRenew failed: %v", err)
}
if res.TotalEnqueued != 2 {
t.Errorf("TotalEnqueued = %d, want 2 (alice's 2 certs)", res.TotalEnqueued)
}
}
// TestBulkRenew_SkipsRenewalInProgress — a cert already in the renewal
// flow must NOT get a second job. This is the no-double-enqueue
// contract: dispatch the bulk-renew button twice in quick succession
// and the second call cleanly skips.
func TestBulkRenew_SkipsRenewalInProgress(t *testing.T) {
svc, certRepo, jobRepo, _ := newBulkRenewalTestService()
cert := &domain.ManagedCertificate{
ID: "mc-rip", Status: domain.CertificateStatusRenewalInProgress,
ExpiresAt: time.Now().AddDate(0, 1, 0),
}
certRepo.AddCert(cert)
res, err := svc.BulkRenew(context.Background(),
domain.BulkRenewalCriteria{CertificateIDs: []string{"mc-rip"}}, "alice")
if err != nil {
t.Fatalf("BulkRenew failed: %v", err)
}
if res.TotalSkipped != 1 || res.TotalEnqueued != 0 {
t.Errorf("counts wrong: skipped=%d enqueued=%d, want 1/0",
res.TotalSkipped, res.TotalEnqueued)
}
if len(jobRepo.Jobs) != 0 {
t.Errorf("no job should be created for already-in-progress cert; got %d jobs", len(jobRepo.Jobs))
}
}
// TestBulkRenew_SkipsRevokedAndArchived — terminal states are silent
// no-ops, not errors. Operator selecting a mix of live and revoked certs
// shouldn't see "ERROR: revoked cert can't be renewed" 50 times.
func TestBulkRenew_SkipsRevokedAndArchived(t *testing.T) {
svc, certRepo, _, _ := newBulkRenewalTestService()
addRenewableCert(certRepo, "mc-live")
for _, st := range []domain.CertificateStatus{
domain.CertificateStatusRevoked,
domain.CertificateStatusArchived,
domain.CertificateStatusExpired,
} {
cert := &domain.ManagedCertificate{
ID: "mc-" + string(st), Status: st, ExpiresAt: time.Now().AddDate(0, 1, 0),
}
certRepo.AddCert(cert)
}
res, err := svc.BulkRenew(context.Background(),
domain.BulkRenewalCriteria{CertificateIDs: []string{
"mc-live", "mc-Revoked", "mc-Archived", "mc-Expired",
}}, "alice")
if err != nil {
t.Fatalf("BulkRenew failed: %v", err)
}
if res.TotalEnqueued != 1 || res.TotalSkipped != 3 {
t.Errorf("counts = enqueued:%d skipped:%d, want 1/3 (only mc-live qualifies)",
res.TotalEnqueued, res.TotalSkipped)
}
if len(res.Errors) != 0 {
t.Errorf("status-skip should NOT populate Errors; got %v", res.Errors)
}
}
// TestBulkRenew_EmptyCriteria_Error — defensive contract. Mirrors
// BulkRevocationCriteria.IsEmpty rejection so a stray empty POST
// doesn't try to renew the entire fleet.
func TestBulkRenew_EmptyCriteria_Error(t *testing.T) {
svc, _, _, _ := newBulkRenewalTestService()
_, err := svc.BulkRenew(context.Background(),
domain.BulkRenewalCriteria{}, "alice")
if err == nil {
t.Fatal("expected error for empty criteria, got nil")
}
}
// TestBulkRenew_PartialFailure — N=3, jobRepo.Create injected to fail
// on one of them. Response carries 2 enqueued + 1 error; no panic, no
// abort.
func TestBulkRenew_PartialFailure(t *testing.T) {
svc, certRepo, jobRepo, _ := newBulkRenewalTestService()
addRenewableCert(certRepo, "mc-1")
addRenewableCert(certRepo, "mc-2")
addRenewableCert(certRepo, "mc-3")
// Make Create fail uniformly. Two of the three certs will still
// have their status flipped (because Update happened first), so
// the failure manifests as "I tried to enqueue, the job-create
// failed". Per-cert error string surfaced.
jobRepo.CreateErr = errors.New("simulated DB outage")
res, err := svc.BulkRenew(context.Background(),
domain.BulkRenewalCriteria{CertificateIDs: []string{"mc-1", "mc-2", "mc-3"}},
"alice")
if err != nil {
t.Fatalf("BulkRenew should not propagate per-cert errors as a top-level error; got: %v", err)
}
if res.TotalFailed != 3 || res.TotalEnqueued != 0 {
t.Errorf("counts = failed:%d enqueued:%d, want 3/0", res.TotalFailed, res.TotalEnqueued)
}
if len(res.Errors) != 3 {
t.Errorf("Errors len = %d, want 3", len(res.Errors))
}
}
// TestBulkRenew_AuditEventEmitted — exactly ONE bulk audit event for
// the operation, NOT N. This is the audit-volume contract that makes
// bulk endpoints scalable.
func TestBulkRenew_AuditEventEmitted(t *testing.T) {
svc, certRepo, _, auditRepo := newBulkRenewalTestService()
addRenewableCert(certRepo, "mc-1")
addRenewableCert(certRepo, "mc-2")
addRenewableCert(certRepo, "mc-3")
_, err := svc.BulkRenew(context.Background(),
domain.BulkRenewalCriteria{CertificateIDs: []string{"mc-1", "mc-2", "mc-3"}},
"alice")
if err != nil {
t.Fatalf("BulkRenew failed: %v", err)
}
// audit_events count must be exactly 1 — the bulk-renewal envelope.
// Per-cert renewal events come from CertificateService.TriggerRenewal,
// which the bulk path bypasses for exactly this reason.
if len(auditRepo.Events) != 1 {
t.Errorf("audit events count = %d, want exactly 1 (one bulk event, NOT N per-cert events)", len(auditRepo.Events))
}
if len(auditRepo.Events) > 0 && auditRepo.Events[0].Action != "bulk_renewal_initiated" {
t.Errorf("audit action = %q, want 'bulk_renewal_initiated'", auditRepo.Events[0].Action)
}
}
@@ -0,0 +1,46 @@
-- Migration 000017 (down): reverse the U-3 bundle.
--
-- Operators almost certainly never need this — each block in the up
-- migration was a strict improvement (column-name truth, dead-schema
-- removal, missing-column add). The down migration exists for
-- documentation and disaster-recovery completeness only.
--
-- Idempotent: each block uses the standard IF EXISTS / IF NOT EXISTS
-- guards plus a DO $$ guard on the rename to handle re-application.
-- Reverses the up migration's blocks in reverse order.
-- (3) Re-add the orphan health_check columns at their original defaults.
--
-- Note: re-adding does NOT restore the auto-health-check feature —
-- that code was never written. The column values revert to the
-- DEFAULT FALSE / 300 baseline that operators saw pre-U-3.
ALTER TABLE network_scan_targets
ADD COLUMN IF NOT EXISTS health_check_enabled BOOLEAN DEFAULT FALSE,
ADD COLUMN IF NOT EXISTS health_check_interval_seconds INTEGER DEFAULT 300;
-- (2) Drop the notification_events.created_at column.
--
-- This re-introduces the cat-o-notification_created_at_dead_field bug
-- (Go field with no DB column → API serializes 0001-01-01). Only roll
-- back if you've also rolled back the Go-side INSERT path that sets
-- created_at, otherwise INSERTs will fail with "column created_at does
-- not exist".
ALTER TABLE notification_events
DROP COLUMN IF EXISTS created_at;
-- (1) Rename the renewal_policies column back to the misleading name.
--
-- Re-introduces cat-o-retry_interval_unit_mismatch. Operators running
-- raw SQL revert to the 60x confusion. No data conversion (values are
-- still seconds; the column label lies again).
DO $$
BEGIN
IF EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_name = 'renewal_policies'
AND column_name = 'retry_interval_seconds'
) THEN
ALTER TABLE renewal_policies
RENAME COLUMN retry_interval_seconds TO retry_interval_minutes;
END IF;
END $$;
@@ -0,0 +1,81 @@
-- Migration 000017: DB coupling cleanup (U-3 bundle).
--
-- Closes three audit findings that share the migrations/ surface and the
-- "schema vs Go vs label drifts in different directions" pattern:
--
-- * cat-o-retry_interval_unit_mismatch (P1):
-- renewal_policies.retry_interval_minutes column stored seconds, named
-- minutes. Operators running raw SQL got 60x confusion.
--
-- * cat-o-notification_created_at_dead_field (P2):
-- internal/domain/notification.go::NotificationEvent.CreatedAt was
-- tagged json:"created_at" with no DB column behind it. Every API
-- response serialized 0001-01-01T00:00:00Z. Visible zero-value
-- timestamp on every notification row in the dashboard.
--
-- * cat-o-health_check_column_orphans (P1):
-- migration 000011 added network_scan_targets.health_check_enabled +
-- .health_check_interval_seconds. No Go field decoded either column;
-- no handler exposed them; OpenAPI schema didn't carry them. The
-- auto-health-check feature was never wired through. Removing dead
-- schema is cheaper than completing dead code; if the feature gets
-- revived, a future migration can re-add the columns alongside the
-- Go-side wiring.
--
-- Idempotency: RunMigrations at internal/repository/postgres/db.go has
-- no applied-tracking table — every server restart re-applies every
-- migration in sequence. Each block in this file MUST be safe to re-run
-- on a database that has already had it applied. The RENAME COLUMN in
-- block (1) is wrapped in a DO $$ guard that checks information_schema
-- before renaming; the ADD COLUMN in (2) and the DROP COLUMNs in (3)
-- use the standard IF NOT EXISTS / IF EXISTS clauses.
--
-- See the U-3 closure entry in
-- coverage-gap-audit-2026-04-24-v5/unified-audit.md and CHANGELOG.md
-- for the full rationale, the bundled-fix list, and the architectural
-- shift to runtime-only migration application.
-- (1) cat-o-retry_interval_unit_mismatch — rename column to match unit.
--
-- The values stored in this column have always been seconds (validator
-- at internal/service/renewal_policy.go enforces a [60, 86400] range
-- inclusive — 60 seconds to 24 hours, unambiguously seconds). The
-- column name was the bug; data conversion is a no-op. The Go field
-- has always been tagged json:"retry_interval_seconds", so the API
-- shape is unchanged.
DO $$
BEGIN
IF EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_name = 'renewal_policies'
AND column_name = 'retry_interval_minutes'
) THEN
ALTER TABLE renewal_policies
RENAME COLUMN retry_interval_minutes TO retry_interval_seconds;
END IF;
END $$;
-- (2) cat-o-notification_created_at_dead_field — add the missing column.
--
-- DEFAULT NOW() back-fills existing rows with the migration apply
-- timestamp. Acceptable trade-off: those rows had no real CreatedAt
-- info anyway (the field was a Go-only zero-value), and approximating
-- them with the migration time gives the dashboard a usable rendering
-- instead of '0001-01-01'. NOT NULL is enforced because the repo
-- INSERT path will set CreatedAt on every new row post-fix.
ALTER TABLE notification_events
ADD COLUMN IF NOT EXISTS created_at TIMESTAMPTZ NOT NULL DEFAULT NOW();
-- (3) cat-o-health_check_column_orphans — drop unwired columns.
--
-- migrations/000011_health_checks.up.sql added these two columns with
-- the intent of wiring auto-health-checks for network-scan-discovered
-- endpoints. The Go side was never written; no handler reads or writes
-- them; the OpenAPI NetworkScanTarget schema doesn't expose them. The
-- columns have been carrying their default values (false / 300) on
-- every row since shipping. Dropping them removes dead schema; the
-- network_scan_targets row size shrinks marginally and operators stop
-- seeing flag/interval columns that don't actually do anything.
ALTER TABLE network_scan_targets
DROP COLUMN IF EXISTS health_check_enabled,
DROP COLUMN IF EXISTS health_check_interval_seconds;
+1 -1
View File
@@ -1,7 +1,7 @@
-- Seed data for certificate control plane -- Seed data for certificate control plane
-- Default renewal policy -- Default renewal policy
INSERT INTO renewal_policies (id, name, renewal_window_days, auto_renew, max_retries, retry_interval_minutes, alert_thresholds_days) INSERT INTO renewal_policies (id, name, renewal_window_days, auto_renew, max_retries, retry_interval_seconds, alert_thresholds_days)
VALUES ( VALUES (
'rp-default', 'rp-default',
'default', 'default',
+1 -1
View File
@@ -29,7 +29,7 @@ ON CONFLICT (id) DO NOTHING;
-- ============================================================ -- ============================================================
-- 2. Policies -- 2. Policies
-- ============================================================ -- ============================================================
INSERT INTO renewal_policies (id, name, renewal_window_days, auto_renew, max_retries, retry_interval_minutes, alert_thresholds_days, created_at, updated_at) VALUES INSERT INTO renewal_policies (id, name, renewal_window_days, auto_renew, max_retries, retry_interval_seconds, alert_thresholds_days, created_at, updated_at) VALUES
('rp-standard', 'Standard 30-day', 30, true, 3, 60, '[30, 14, 7, 0]'::jsonb, NOW() - INTERVAL '180 days', NOW() - INTERVAL '180 days'), ('rp-standard', 'Standard 30-day', 30, true, 3, 60, '[30, 14, 7, 0]'::jsonb, NOW() - INTERVAL '180 days', NOW() - INTERVAL '180 days'),
('rp-urgent', 'Urgent 14-day', 14, true, 5, 30, '[14, 7, 3, 0]'::jsonb, NOW() - INTERVAL '180 days', NOW() - INTERVAL '180 days'), ('rp-urgent', 'Urgent 14-day', 14, true, 5, 30, '[14, 7, 3, 0]'::jsonb, NOW() - INTERVAL '180 days', NOW() - INTERVAL '180 days'),
('rp-manual', 'Manual Only', 30, false, 0, 0, '[30, 14, 7, 0]'::jsonb, NOW() - INTERVAL '180 days', NOW() - INTERVAL '180 days') ('rp-manual', 'Manual Only', 30, false, 0, 0, '[30, 14, 7, 0]'::jsonb, NOW() - INTERVAL '180 days', NOW() - INTERVAL '180 days')
+2 -5
View File
@@ -6,7 +6,6 @@ import {
createCertificate, createCertificate,
triggerRenewal, triggerRenewal,
revokeCertificate, revokeCertificate,
exportCertificatePEM,
downloadCertificatePEM, downloadCertificatePEM,
exportCertificatePKCS12, exportCertificatePKCS12,
getAgents, getAgents,
@@ -106,10 +105,8 @@ describe('API Client - Error Handling', () => {
); );
}); });
it('exportCertificatePEM propagates network error', async () => { // B-1 closure (cat-b-9b97ffb35ef7): exportCertificatePEM removed as a
mockFetch.mockReturnValueOnce(mockNetworkError()); // dead duplicate of downloadCertificatePEM (zero consumers).
await expect(exportCertificatePEM('mc-test')).rejects.toThrow('Failed to fetch');
});
it('downloadCertificatePEM propagates network error', async () => { it('downloadCertificatePEM propagates network error', async () => {
mockFetch.mockReturnValueOnce(mockNetworkError()); mockFetch.mockReturnValueOnce(mockNetworkError());
+2 -10
View File
@@ -13,7 +13,6 @@ import {
archiveCertificate, archiveCertificate,
revokeCertificate, revokeCertificate,
bulkRevokeCertificates, bulkRevokeCertificates,
exportCertificatePEM,
downloadCertificatePEM, downloadCertificatePEM,
exportCertificatePKCS12, exportCertificatePKCS12,
getAgents, getAgents,
@@ -1151,15 +1150,8 @@ describe('API Client', () => {
// ─── Certificate Export ──────────────────────────────── // ─── Certificate Export ────────────────────────────────
describe('Certificate Export', () => { describe('Certificate Export', () => {
it('exportCertificatePEM fetches PEM data as JSON', async () => { // B-1 closure (cat-b-9b97ffb35ef7): exportCertificatePEM was removed
const pemResult = { cert_pem: 'CERT', chain_pem: 'CHAIN', full_pem: 'FULL' }; // from client.ts as a dead duplicate of downloadCertificatePEM.
mockFetch.mockReturnValueOnce(mockJsonResponse(pemResult));
const result = await exportCertificatePEM('mc-1');
const [url] = mockFetch.mock.calls[0];
expect(url).toBe('/api/v1/certificates/mc-1/export/pem');
expect(result.cert_pem).toBe('CERT');
expect(result.full_pem).toBe('FULL');
});
it('downloadCertificatePEM fetches blob with download=true', async () => { it('downloadCertificatePEM fetches blob with download=true', async () => {
const mockBlob = new Blob(['pem-data'], { type: 'application/x-pem-file' }); const mockBlob = new Blob(['pem-data'], { type: 'application/x-pem-file' });
+106 -3
View File
@@ -2,6 +2,35 @@ import type { Certificate, CertificateVersion, Agent, Job, Notification, AuditEv
const BASE = '/api/v1'; const BASE = '/api/v1';
// P-1 closure (diff-04x03-d24864996ad4 P2 + cat-b-dc46aadab98e P3):
// the audit flagged 26+16 orphan client functions. Recon at HEAD
// found 17 actual orphans (the 26+16 audit numbers conflated; many
// were eliminated by the B-1 / S-1 / I-2 / D-2 closures since the
// audit was written). The remaining 17 are all detail-page
// candidates — singleton-getter `getX(id)` fns that detail pages
// will need when the corresponding `XPage` grows a `XDetailPage`
// route. Preserved here (rather than deleted) so the future
// detail-page work doesn't have to relitigate the client.ts surface.
//
// Intentionally-orphan client functions:
// getAgentGroup, getAgentGroupMembers, getAuditEvent,
// getCertificateDeployments, getDiscoveredCertificate,
// getHealthCheck, getHealthCheckHistory, getNetworkScanTarget,
// getNotification, getOCSPStatus, getOwner, getPolicy,
// getPolicyViolations, getRenewalPolicy, getTeam, registerAgent
// (by-design pull-only; see C-1 closure docblock above its export),
// updateHealthCheck.
//
// CI guardrail at .github/workflows/ci.yml::"Documented orphan
// client fns sync guard (P-1)" enforces the docblock list ↔
// export list relationship: every name above must still be
// declared somewhere in this file, and conversely if a name is
// removed from the list its export must also be removed (orphans
// must never silently accumulate).
//
// See coverage-gap-audit-2026-04-24-v5/unified-audit.md
// diff-04x03-d24864996ad4 + cat-b-dc46aadab98e for closure rationale.
// API key stored in memory (not localStorage for security) // API key stored in memory (not localStorage for security)
let apiKey: string | null = null; let apiKey: string | null = null;
@@ -129,10 +158,73 @@ export const bulkRevokeCertificates = (criteria: BulkRevokeCriteria) =>
body: JSON.stringify(criteria), body: JSON.stringify(criteria),
}); });
// Certificate Export // L-1 master closure (cat-l-fa0c1ac07ab5): bulk renew. Mirrors
export const exportCertificatePEM = (id: string) => // BulkRevokeCriteria field-for-field so operators who already know the
fetchJSON<{ cert_pem: string; chain_pem: string; full_pem: string }>(`${BASE}/certificates/${id}/export/pem`); // bulk-revoke contract have zero new surface to learn. Pre-L-1 the GUI
// looped `await triggerRenewal(id)` over the selection; 100 certs = 100
// HTTP round-trips. Post-L-1 it's a single POST returning per-cert
// {certificate_id, job_id} pairs in enqueued_jobs and per-cert errors
// in errors. The "renew all certs of profile X" use case is the
// canonical reason to support criteria-mode in addition to explicit IDs.
export interface BulkRenewalCriteria {
profile_id?: string;
owner_id?: string;
agent_id?: string;
issuer_id?: string;
team_id?: string;
certificate_ids?: string[];
}
export interface BulkRenewalResult {
total_matched: number;
total_enqueued: number;
total_skipped: number;
total_failed: number;
enqueued_jobs?: { certificate_id: string; job_id: string }[];
errors?: { certificate_id: string; error: string }[];
}
export const bulkRenewCertificates = (criteria: BulkRenewalCriteria) =>
fetchJSON<BulkRenewalResult>(`${BASE}/certificates/bulk-renew`, {
method: 'POST',
body: JSON.stringify(criteria),
});
// L-2 closure (cat-l-8a1fb258a38a): bulk reassign owner (and optionally
// team) for a set of certificates. Narrower than bulk-renew — explicit
// IDs only, no criteria-mode (operators query first, then reassign by
// ID). Pre-L-2 the GUI looped `await updateCertificate(id, { owner_id })`.
// owner_id is required; team_id is optional and updates only when
// non-empty (matches the existing per-cert PUT contract).
export interface BulkReassignmentRequest {
certificate_ids: string[];
owner_id: string;
team_id?: string;
}
export interface BulkReassignmentResult {
total_matched: number;
total_reassigned: number;
total_skipped: number;
total_failed: number;
errors?: { certificate_id: string; error: string }[];
}
export const bulkReassignCertificates = (request: BulkReassignmentRequest) =>
fetchJSON<BulkReassignmentResult>(`${BASE}/certificates/bulk-reassign`, {
method: 'POST',
body: JSON.stringify(request),
});
// Certificate Export
//
// B-1 master closure (cat-b-9b97ffb35ef7): the previous `exportCertificatePEM`
// helper that returned `{cert_pem, chain_pem, full_pem}` JSON was removed —
// it had zero consumers across web/, MCP, CLI, and tests, and was a dead
// duplicate of `downloadCertificatePEM` which is the only call site that
// actually exists in `CertificateDetailPage` (browser file-download path).
// If a JSON variant is ever needed again, re-add an explicit fetcher with a
// page consumer in the same commit; do not resurrect the orphan.
export const downloadCertificatePEM = (id: string) => { export const downloadCertificatePEM = (id: string) => {
const headers: Record<string, string> = {}; const headers: Record<string, string> = {};
if (apiKey) headers['Authorization'] = `Bearer ${apiKey}`; if (apiKey) headers['Authorization'] = `Bearer ${apiKey}`;
@@ -185,6 +277,17 @@ export const getAgents = (params: Record<string, string> = {}) => {
export const getAgent = (id: string) => export const getAgent = (id: string) =>
fetchJSON<Agent>(`${BASE}/agents/${id}`); fetchJSON<Agent>(`${BASE}/agents/${id}`);
// C-1 closure (cat-b-6177f36636fb): registerAgent is intentionally
// orphan in the GUI per certctl's pull-only deployment model. Agents
// enroll via install-agent.sh + cmd/agent/main.go and register
// themselves at first heartbeat — operators don't (and shouldn't)
// drive registration from the dashboard. The client fn is preserved
// here (rather than deleted) so future features that want to drive
// registration from the GUI (e.g. a one-click "register proxy agent"
// panel for network-appliance topologies) can reach the endpoint
// without a client.ts edit. See docs/architecture.md::Agents for
// the architectural rationale and unified-audit.md cat-b-6177f36636fb
// for closure rationale.
export const registerAgent = (data: Partial<Agent>) => export const registerAgent = (data: Partial<Agent>) =>
fetchJSON<Agent>(`${BASE}/agents`, { method: 'POST', body: JSON.stringify(data) }); fetchJSON<Agent>(`${BASE}/agents`, { method: 'POST', body: JSON.stringify(data) });
+349 -11
View File
@@ -1,6 +1,14 @@
import { describe, it, expect } from 'vitest'; import { describe, it, expect } from 'vitest';
import { POLICY_TYPES, POLICY_SEVERITIES } from './types'; import { POLICY_TYPES, POLICY_SEVERITIES } from './types';
import type { Agent } from './types'; import type {
Agent,
Certificate,
CertificateVersion,
DiscoveredCertificate,
Issuer,
Notification,
Target,
} from './types';
/** /**
* Regression tests for the policy enum tuples. * Regression tests for the policy enum tuples.
@@ -85,6 +93,9 @@ describe('Agent interface (I-004 retirement)', () => {
// Construct an Agent with the retirement fields set. If Phase 2b names // Construct an Agent with the retirement fields set. If Phase 2b names
// them anything other than retired_at / retired_reason, this fails to // them anything other than retired_at / retired_reason, this fails to
// compile — which is exactly what the Red stage wants. // compile — which is exactly what the Red stage wants.
// D-2 (master): the post-D-2 Agent shape no longer carries
// last_heartbeat / capabilities / tags / created_at / updated_at —
// those were TS phantoms the Go-side struct never emitted.
const retired: Agent = { const retired: Agent = {
id: 'ag-1', id: 'ag-1',
name: 'decom-01', name: 'decom-01',
@@ -94,13 +105,8 @@ describe('Agent interface (I-004 retirement)', () => {
architecture: 'amd64', architecture: 'amd64',
status: 'Offline', status: 'Offline',
version: '2.1.0', version: '2.1.0',
last_heartbeat: '2026-01-01T00:00:00Z',
last_heartbeat_at: '2026-01-01T00:00:00Z', last_heartbeat_at: '2026-01-01T00:00:00Z',
capabilities: [],
tags: {},
registered_at: '2024-01-01T00:00:00Z', registered_at: '2024-01-01T00:00:00Z',
created_at: '2024-01-01T00:00:00Z',
updated_at: '2026-01-01T00:00:00Z',
retired_at: '2026-01-01T00:00:00Z', retired_at: '2026-01-01T00:00:00Z',
retired_reason: 'old hardware', retired_reason: 'old hardware',
}; };
@@ -111,6 +117,7 @@ describe('Agent interface (I-004 retirement)', () => {
it('accepts an Agent without retired_at / retired_reason (optional fields)', () => { it('accepts an Agent without retired_at / retired_reason (optional fields)', () => {
// Active agents should not carry retirement metadata. If Phase 2b makes // Active agents should not carry retirement metadata. If Phase 2b makes
// the fields required, this block fails to compile. // the fields required, this block fails to compile.
// D-2 (master): post-D-2 Agent shape (see sibling describe block).
const active: Agent = { const active: Agent = {
id: 'ag-2', id: 'ag-2',
name: 'web01', name: 'web01',
@@ -120,15 +127,346 @@ describe('Agent interface (I-004 retirement)', () => {
architecture: 'amd64', architecture: 'amd64',
status: 'Online', status: 'Online',
version: '2.1.0', version: '2.1.0',
last_heartbeat: '2026-04-18T12:00:00Z',
last_heartbeat_at: '2026-04-18T12:00:00Z', last_heartbeat_at: '2026-04-18T12:00:00Z',
capabilities: ['deploy', 'scan'],
tags: {},
registered_at: '2024-06-01T00:00:00Z', registered_at: '2024-06-01T00:00:00Z',
created_at: '2024-06-01T00:00:00Z',
updated_at: '2026-04-18T12:00:00Z',
}; };
expect(active.retired_at).toBeUndefined(); expect(active.retired_at).toBeUndefined();
expect(active.retired_reason).toBeUndefined(); expect(active.retired_reason).toBeUndefined();
}); });
}); });
/**
* D-5 (cat-f-ae0d06b6588f, master): Certificate TS phantom-fields trim.
*
* Pre-D-5 the Certificate interface declared `serial_number`,
* `fingerprint_sha256`, `key_algorithm`, `key_size`, and `issued_at` as
* optional. These fields were never emitted by Go's `ManagedCertificate`
* (internal/domain/certificate.go) they live on `CertificateVersion`,
* which is the per-issuance record fetched from
* /api/v1/certificates/{id}/versions. The optional declarations made
* `cert.serial_number` always-undefined on list responses, and downstream
* consumers (CertificateDetailPage's Key Algorithm / Key Size rows in
* particular) silently rendered '—' for every cert despite the data
* being available a single fetch away.
*
* Post-D-5 the TS type makes the missing-data case explicit: a
* `cert.serial_number` access becomes a TS compile error, forcing every
* consumer to acknowledge the version-fallback pattern. This regression
* test pins the trim if a future PR re-adds any of the five phantom
* fields to Certificate (e.g. via merge conflict, copy-paste, or a
* codegen run that regenerates from a stale OpenAPI spec), the
* compile-fail block here will surface it.
*/
describe('Certificate interface (D-5 phantom-fields trim)', () => {
it('does NOT declare per-issuance fields — those live on CertificateVersion', () => {
// Construct a fully-populated Certificate. If a future PR re-adds
// any of the five phantom fields (serial_number, fingerprint_sha256,
// key_algorithm, key_size, issued_at) to the interface, every
// omission in this literal becomes "missing required field" and
// the test fails to compile. Conversely, attempting to set any of
// the five fields on the literal is a TS error today (excess
// property), so the negative-assertion block below also fails to
// compile if someone re-adds them as optional.
const cert: Certificate = {
id: 'mc-test',
name: 'test',
common_name: 'test.example.com',
sans: [],
status: 'Active',
environment: 'production',
issuer_id: 'iss-test',
owner_id: 'o-test',
team_id: 't-test',
renewal_policy_id: 'rp-default',
certificate_profile_id: 'cp-default',
expires_at: '2027-01-01T00:00:00Z',
tags: {},
created_at: '2026-01-01T00:00:00Z',
updated_at: '2026-01-01T00:00:00Z',
};
expect(cert.id).toBe('mc-test');
// Excess-property check: each of these MUST be a TS error if
// uncommented. Keep them in the test as documentation of what's
// intentionally absent. (We can't directly assert "type does not
// have property X" without a type-level helper, but the literal
// construction above plus tsc --noEmit in CI is the binding check.)
//
// const broken: Certificate = { ...cert, serial_number: '01:02' }; // ❌ TS2353
// const broken2: Certificate = { ...cert, key_algorithm: 'EC' }; // ❌ TS2353
// const broken3: Certificate = { ...cert, key_size: 256 }; // ❌ TS2353
// const broken4: Certificate = { ...cert, fingerprint_sha256: '' };// ❌ TS2353
// const broken5: Certificate = { ...cert, issued_at: '...' }; // ❌ TS2353
});
it('CertificateVersion still carries the per-issuance fields', () => {
// The other half of the contract: the trimmed fields didn't go to
// /dev/null — they live (and have always lived) on CertificateVersion.
// If a refactor removes them from CertificateVersion too, the
// CertificateDetailPage fallback path breaks. Pin both halves.
const v: CertificateVersion = {
id: 'mcv-test',
certificate_id: 'mc-test',
serial_number: '01:02:03',
fingerprint_sha256: 'a'.repeat(64),
pem_chain: '-----BEGIN CERTIFICATE-----\n...',
csr_pem: '-----BEGIN CERTIFICATE REQUEST-----\n...',
not_before: '2026-01-01T00:00:00Z',
not_after: '2027-01-01T00:00:00Z',
key_algorithm: 'ECDSA',
key_size: 256,
created_at: '2026-01-01T00:00:00Z',
};
expect(v.serial_number).toBe('01:02:03');
expect(v.key_algorithm).toBe('ECDSA');
expect(v.key_size).toBe(256);
});
});
/**
* D-2 (diff-05x06-7cdf4e78ae24, master): Agent TS phantom-fields trim.
*
* Pre-D-2 the `Agent` interface declared five fields that the Go-side
* struct (`internal/domain/connector.go::Agent`) does NOT emit on the
* wire: `last_heartbeat`, `capabilities`, `tags`, `created_at`,
* `updated_at`. Two of them had real consumers (`AgentDetailPage.tsx`
* read `agent.capabilities` and `agent.tags`) both always rendered the
* empty-state branch because the runtime values were always `undefined`.
*
* Post-D-2 a `agent.capabilities` access is a TS compile error, forcing
* every consumer to acknowledge the field is not part of the Agent
* contract. The Go-side struct emits exactly: id, name, hostname, status,
* last_heartbeat_at (note the `_at` suffix this is the real heartbeat
* field and stays), registered_at, os, architecture, ip_address, version,
* retired_at?, retired_reason?.
*/
describe('Agent interface (D-2 phantom-fields trim)', () => {
it('does NOT declare last_heartbeat / capabilities / tags / created_at / updated_at', () => {
// Construct an Agent with ONLY the post-D-2 field set. If a future
// PR re-adds any of the five trimmed fields, the excess-property
// comments below become live TS errors when uncommented (and the
// CI guardrail in .github/workflows/ci.yml fires regardless).
const a: Agent = {
id: 'ag-test',
name: 'web-01',
hostname: 'web-01.prod',
status: 'Online',
last_heartbeat_at: '2026-04-25T12:00:00Z',
registered_at: '2024-06-01T00:00:00Z',
os: 'linux',
architecture: 'amd64',
ip_address: '10.0.0.1',
version: '2.1.0',
};
expect(a.id).toBe('ag-test');
expect(a.last_heartbeat_at).toBe('2026-04-25T12:00:00Z');
// Excess-property check (each MUST be a TS error if uncommented):
// const broken1: Agent = { ...a, last_heartbeat: '2026-...' }; // ❌ TS2353
// const broken2: Agent = { ...a, capabilities: ['deploy'] }; // ❌ TS2353
// const broken3: Agent = { ...a, tags: { env: 'prod' } }; // ❌ TS2353
// const broken4: Agent = { ...a, created_at: '...' }; // ❌ TS2353
// const broken5: Agent = { ...a, updated_at: '...' }; // ❌ TS2353
});
it('keeps last_heartbeat_at (the real Go-emitted heartbeat field)', () => {
// Negative-prevention guard: the awk-windowed CI grep for the trimmed
// `last_heartbeat` field must NOT trip on the legitimate
// `last_heartbeat_at`. This test pins that the legitimate field stays.
const a: Agent = {
id: 'ag-2',
name: 'web-02',
hostname: 'web-02.prod',
status: 'Offline',
registered_at: '2024-06-01T00:00:00Z',
os: 'linux',
architecture: 'amd64',
ip_address: '10.0.0.2',
version: '2.1.0',
};
expect(a.last_heartbeat_at).toBeUndefined();
});
});
/**
* D-2 (diff-05x06-2044a46f4dd0, master): Target retirement-fields ADD.
*
* Pre-D-2 the Go-side `DeploymentTarget` struct
* (`internal/domain/connector.go:24`) emitted `retired_at` and
* `retired_reason` (I-004 soft-retirement, mirroring the Agent
* treatment), but the TS `Target` interface did not declare them.
* Consumers wanting to surface the retired state in the GUI had to
* use `(target as any).retired_at` escapes that lost type-checking.
*
* Post-D-2 the TS interface declares both as optional nullable strings,
* mirroring the existing Agent retirement-fields shape.
*/
describe('Target interface (D-2 retirement fields)', () => {
it('accepts retired_at and retired_reason as optional nullable strings', () => {
const retired: Target = {
id: 't-decom-01',
name: 'old-iis-server',
type: 'iis',
agent_id: 'ag-old',
config: {},
enabled: false,
created_at: '2024-01-01T00:00:00Z',
retired_at: '2026-03-01T00:00:00Z',
retired_reason: 'replaced by new iis-server',
};
expect(retired.retired_at).toBe('2026-03-01T00:00:00Z');
expect(retired.retired_reason).toBe('replaced by new iis-server');
});
it('accepts a Target without the retirement fields (active row)', () => {
const active: Target = {
id: 't-1',
name: 'iis-server',
type: 'iis',
agent_id: 'ag-1',
config: {},
enabled: true,
created_at: '2024-01-01T00:00:00Z',
};
expect(active.retired_at).toBeUndefined();
expect(active.retired_reason).toBeUndefined();
});
});
/**
* D-2 (diff-05x06-85ab6b98a2f7, master): DiscoveredCertificate pem_data ADD.
*
* Pre-D-2 the Go-side `DiscoveredCertificate` struct
* (`internal/domain/discovery.go::DiscoveredCertificate.PEMData`) emitted
* `pem_data` (omitempty populated by repo SELECT, agent ingestion at
* cmd/agent/main.go:1021, and connector scans at
* internal/connector/discovery/azurekv/azurekv.go:234), but the TS
* `DiscoveredCertificate` interface did not declare it. Consumers wanting
* to inspect or download the raw PEM had to use `(d as any).pem_data`.
*
* Post-D-2 the TS interface declares it as `pem_data?: string`, optional
* because the Go side uses `omitempty` (empty string not emitted).
*
* Performance note (deferred follow-up): the LIST endpoint also loads
* pem_data via the same repo SELECT; for large discovered-cert tables
* this can ship kilobytes per row. Optimising the list response to omit
* pem_data is a separate backend change.
*/
describe('DiscoveredCertificate interface (D-2 pem_data ADD)', () => {
it('accepts pem_data as an optional string', () => {
const d: DiscoveredCertificate = {
id: 'dc-1',
fingerprint_sha256: 'a'.repeat(64),
common_name: 'discovered.example.com',
sans: [],
serial_number: '01:02:03',
issuer_dn: 'CN=Test CA',
subject_dn: 'CN=discovered.example.com',
key_algorithm: 'ECDSA',
key_size: 256,
is_ca: false,
source_path: '/etc/ssl/certs/disc.pem',
source_format: 'pem',
agent_id: 'ag-1',
status: 'Unmanaged',
first_seen_at: '2026-04-25T12:00:00Z',
last_seen_at: '2026-04-25T12:00:00Z',
created_at: '2026-04-25T12:00:00Z',
updated_at: '2026-04-25T12:00:00Z',
pem_data: '-----BEGIN CERTIFICATE-----\nMIIB...\n-----END CERTIFICATE-----\n',
};
expect(d.pem_data).toContain('BEGIN CERTIFICATE');
});
it('accepts a DiscoveredCertificate without pem_data (list-response shape)', () => {
const d: DiscoveredCertificate = {
id: 'dc-2',
fingerprint_sha256: 'b'.repeat(64),
common_name: 'list.example.com',
sans: [],
serial_number: '04:05:06',
issuer_dn: 'CN=Test CA',
subject_dn: 'CN=list.example.com',
key_algorithm: 'ECDSA',
key_size: 256,
is_ca: false,
source_path: '/etc/ssl/certs/list.pem',
source_format: 'pem',
agent_id: 'ag-1',
status: 'Unmanaged',
first_seen_at: '2026-04-25T12:00:00Z',
last_seen_at: '2026-04-25T12:00:00Z',
created_at: '2026-04-25T12:00:00Z',
updated_at: '2026-04-25T12:00:00Z',
};
expect(d.pem_data).toBeUndefined();
});
});
/**
* D-2 (diff-05x06-97fab8783a5c, master): Issuer status phantom trim.
*
* Pre-D-2 the TS `Issuer` interface declared a required `status: string`
* field that the Go-side struct (`internal/domain/connector.go::Issuer`)
* never emitted the Go struct has only `Enabled bool`. The TS interface
* comment claimed "Backend returns enabled boolean; status is derived
* from this" but no derivation logic existed: `IssuersPage.tsx::~line 23`
* read `issuer.status || 'Unknown'` and always rendered 'Unknown'.
*
* Post-D-2 the `status` field is removed; the consumer now derives the
* displayed status from `enabled` at render time.
*/
describe('Issuer interface (D-2 status phantom trim)', () => {
it('does NOT declare a phantom `status` field — derive from `enabled`', () => {
// Construct a fully-populated Issuer with the post-D-2 shape.
// If `status` is re-added, this construction fails with "missing
// required" (TS2741) when status is required, or the excess-property
// comment below trips when it's added back as optional.
const i: Issuer = {
id: 'iss-test',
name: 'Test ACME',
type: 'acme',
config: {},
enabled: true,
created_at: '2026-01-01T00:00:00Z',
};
expect(i.id).toBe('iss-test');
expect(i.enabled).toBe(true);
// Excess-property check:
// const broken: Issuer = { ...i, status: 'Active' }; // ❌ TS2353
});
});
/**
* D-2 (diff-05x06-caba9eb3620e, master): Notification subject phantom trim.
*
* Pre-D-2 the TS `Notification` interface declared `subject?: string`
* the field was acknowledged in the existing comment as "a historical
* frontend-only field the backend never emits" but kept on the interface
* "so legacy fixtures and the pendingNotif test mock still type
* correctly." Real consumer at `NotificationsPage.tsx::~line 241` had
* `{n.message || n.subject}` as a fallback that always fell through to
* `n.message` (since `n.subject` was always undefined).
*
* Post-D-2 the field is removed; the consumer drops the dead fallback
* and the test fixtures drop the dead `subject:` initializer.
*/
describe('Notification interface (D-2 subject phantom trim)', () => {
it('does NOT declare the phantom `subject` field', () => {
const n: Notification = {
id: 'no-test',
type: 'CertificateExpiring',
channel: 'email',
recipient: 'ops@example.com',
message: 'Certificate api.example.com expires in 14 days',
status: 'pending',
created_at: '2026-04-25T12:00:00Z',
};
expect(n.id).toBe('no-test');
expect(n.message).toContain('14 days');
// Excess-property check:
// const broken: Notification = { ...n, subject: 'Cert expiring' }; // ❌ TS2353
});
});
+75 -17
View File
@@ -1,3 +1,15 @@
// D-5 (cat-f-ae0d06b6588f, master): the five per-issuance fields
// (serial_number, fingerprint_sha256, key_algorithm, key_size,
// issued_at) USED to live here as optional. They were never emitted
// by Go's `ManagedCertificate` (internal/domain/certificate.go) — they
// live on `CertificateVersion` (per-issuance evidence) and are fetched
// via getCertificateVersions(id). Render-site consumers (notably
// CertificateDetailPage) use `latestVersion?.field` as the canonical
// access path. Pre-D-5 the optional declaration silently returned
// `undefined` on every list response, so consumers who didn't know
// about the version-fallback pattern rendered '—' for every cert; now
// the missing-data case is explicit at the type level (a `cert.X`
// access for one of these fields is a TS compile error).
export interface Certificate { export interface Certificate {
id: string; id: string;
name: string; name: string;
@@ -10,11 +22,6 @@ export interface Certificate {
team_id: string; team_id: string;
renewal_policy_id: string; renewal_policy_id: string;
certificate_profile_id: string; certificate_profile_id: string;
serial_number?: string;
fingerprint_sha256?: string;
key_algorithm?: string;
key_size?: number;
issued_at?: string;
expires_at: string; expires_at: string;
revoked_at?: string; revoked_at?: string;
revocation_reason?: string; revocation_reason?: string;
@@ -60,6 +67,23 @@ export interface CertificateVersion {
// API contract. See docs/architecture.md ER-diagram note and // API contract. See docs/architecture.md ER-diagram note and
// coverage-gap-audit-2026-04-24-v5/unified-audit.md cat-s5-apikey_leak // coverage-gap-audit-2026-04-24-v5/unified-audit.md cat-s5-apikey_leak
// for the closure rationale. // for the closure rationale.
//
// D-2 (diff-05x06-7cdf4e78ae24, master): pre-D-2 this interface declared
// five fields the Go-side struct (internal/domain/connector.go::Agent)
// does NOT emit on the wire: `last_heartbeat` (the real field is
// `last_heartbeat_at`; the bare-name was a sibling typo never rejected
// at compile time), `capabilities`, `tags`, `created_at`, `updated_at`.
// Two of them had real consumers (AgentDetailPage rendered
// `agent.capabilities` and `agent.tags`) — both always rendered the
// empty-state branch because the runtime values were always undefined.
// Post-D-2 the interface field set matches the Go-emitted JSON exactly:
// id, name, hostname, status, last_heartbeat_at, registered_at, os,
// architecture, ip_address, version, retired_at?, retired_reason?. A
// `agent.capabilities` access is now a TS compile error. The CI guardrail
// in .github/workflows/ci.yml (`Forbidden StatusBadge dead-key + TS
// phantom-field regression guard (D-1 + D-2)`) blocks reintroduction of
// the trimmed field names while explicitly excluding `last_heartbeat_at`
// from the `last_heartbeat` regex.
export interface Agent { export interface Agent {
id: string; id: string;
name: string; name: string;
@@ -69,13 +93,8 @@ export interface Agent {
architecture: string; architecture: string;
status: string; status: string;
version: string; version: string;
last_heartbeat: string; last_heartbeat_at?: string;
last_heartbeat_at: string;
capabilities: string[];
tags: Record<string, string>;
registered_at: string; registered_at: string;
created_at: string;
updated_at: string;
// I-004: soft-retirement fields. When retired_at is non-null, the agent is // I-004: soft-retirement fields. When retired_at is non-null, the agent is
// tombstoned — it will never heartbeat again and cascaded targets have been // tombstoned — it will never heartbeat again and cascaded targets have been
// retired alongside it. The retired tab on AgentsPage uses these to show the // retired alongside it. The retired tab on AgentsPage uses these to show the
@@ -152,9 +171,17 @@ export interface Job {
* without chasing server logs. * without chasing server logs.
* *
* `sent_at` and `error` are the pre-I-005 audit fields on the backend struct. * `sent_at` and `error` are the pre-I-005 audit fields on the backend struct.
* `subject` is a historical frontend-only field the backend never emits; it's *
* kept optional so legacy fixtures and the pendingNotif test mock still type * D-2 (diff-05x06-caba9eb3620e, master): pre-D-2 this interface carried a
* correctly without forcing a rewrite of every existing consumer. * phantom `subject?: string` field documented as "kept optional so legacy
* fixtures and the pendingNotif test mock still type correctly without
* forcing a rewrite of every existing consumer." The Go-side struct
* (`internal/domain/notification.go::NotificationEvent`) never emitted it,
* so `n.subject` was always `undefined` at runtime. The one real consumer
* (NotificationsPage rendering `{n.message || n.subject}`) always fell
* through to `n.message`. Post-D-2 the field is removed; the consumer
* drops the dead `|| n.subject` fallback and the test fixtures drop the
* dead `subject:` initializer. The CI guardrail blocks reintroduction.
* *
* Status values follow the backend NotificationStatus constants: * Status values follow the backend NotificationStatus constants:
* pending · sent · failed · dead · read * pending · sent · failed · dead · read
@@ -167,7 +194,6 @@ export interface Notification {
type: string; type: string;
channel: string; channel: string;
recipient: string; recipient: string;
subject?: string;
message: string; message: string;
status: string; status: string;
certificate_id?: string; certificate_id?: string;
@@ -262,13 +288,21 @@ export interface RenewalPolicy {
updated_at: string; updated_at: string;
} }
// D-2 (diff-05x06-97fab8783a5c, master): pre-D-2 this interface declared
// a required `status: string` field that the Go-side struct
// (`internal/domain/connector.go::Issuer`) never emitted — the Go struct
// has only `Enabled bool`. The TS comment claimed "status is derived from
// this" but no derivation ever existed: `IssuersPage.tsx` read
// `issuer.status || 'Unknown'` and always rendered 'Unknown'. Post-D-2
// the phantom is removed; render sites derive the displayed status from
// `enabled` (and optionally `test_status`) at the call site. The CI
// guardrail in .github/workflows/ci.yml blocks reintroduction.
export interface Issuer { export interface Issuer {
id: string; id: string;
name: string; name: string;
type: string; type: string;
config: Record<string, unknown>; config: Record<string, unknown>;
status: string; /** Backend returns enabled boolean; render sites derive status labels from this */
/** Backend returns enabled boolean; status is derived from this */
enabled: boolean; enabled: boolean;
/** Timestamp of last connection test */ /** Timestamp of last connection test */
last_tested_at?: string; last_tested_at?: string;
@@ -280,6 +314,14 @@ export interface Issuer {
updated_at?: string; updated_at?: string;
} }
// D-2 (diff-05x06-2044a46f4dd0, master): pre-D-2 this interface lacked
// `retired_at` and `retired_reason` even though the Go-side struct
// (`internal/domain/connector.go::DeploymentTarget`) emits both as part
// of the I-004 soft-retirement model. Consumers wanting to surface the
// retired state had to escape via `(target as any).retired_at`. Post-D-2
// the TS interface declares both as optional nullable strings, mirroring
// the Agent retirement-fields shape (an Agent retire cascades to all
// associated Targets per service.RetireAgent → repository.RetireTarget).
export interface Target { export interface Target {
id: string; id: string;
name: string; name: string;
@@ -290,6 +332,8 @@ export interface Target {
last_tested_at?: string; last_tested_at?: string;
test_status?: string; test_status?: string;
source?: string; source?: string;
retired_at?: string | null;
retired_reason?: string | null;
created_at: string; created_at: string;
updated_at?: string; updated_at?: string;
} }
@@ -396,6 +440,19 @@ export interface IssuanceRateDataPoint {
} }
// Discovery types // Discovery types
//
// D-2 (diff-05x06-85ab6b98a2f7, master): pre-D-2 this interface lacked
// `pem_data` even though the Go-side struct
// (`internal/domain/discovery.go::DiscoveredCertificate.PEMData`,
// json:"pem_data,omitempty") emits it on the wire. The field is
// populated by the agent's filesystem scanner
// (cmd/agent/main.go::buildDiscoveryReport), the cloud-secret-manager
// connectors (e.g. internal/connector/discovery/azurekv/azurekv.go), and
// the repo SELECT that materialises the row from PostgreSQL. Post-D-2
// the TS interface declares `pem_data?: string`, optional because the
// Go side uses `omitempty` (empty string → not emitted). Performance
// follow-up: the LIST endpoint loads pem_data via the same repo SELECT;
// a future change should gate emission on the per-id detail path only.
export interface DiscoveredCertificate { export interface DiscoveredCertificate {
id: string; id: string;
fingerprint_sha256: string; fingerprint_sha256: string;
@@ -409,6 +466,7 @@ export interface DiscoveredCertificate {
key_algorithm: string; key_algorithm: string;
key_size: number; key_size: number;
is_ca: boolean; is_ca: boolean;
pem_data?: string;
source_path: string; source_path: string;
source_format: string; source_format: string;
agent_id: string; agent_id: string;
+6 -1
View File
@@ -8,7 +8,12 @@ export function formatDateTime(iso: string | undefined | null): string {
return new Date(iso).toLocaleString('en-US', { year: 'numeric', month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' }); return new Date(iso).toLocaleString('en-US', { year: 'numeric', month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' });
} }
export function timeAgo(iso: string): string { // D-2 (master): widened to accept undefined/null since several Go-side
// timestamp fields are emitted as `omitempty` (e.g. Agent.last_heartbeat_at
// for never-heartbeated agents). Pre-D-2 the TS interfaces declared
// these as required strings, masking the case; post-D-2 the optionality
// is propagated end-to-end and the helper handles it explicitly.
export function timeAgo(iso: string | undefined | null): string {
if (!iso) return '—'; if (!iso) return '—';
const now = Date.now(); const now = Date.now();
const then = new Date(iso).getTime(); const then = new Date(iso).getTime();
+82 -2
View File
@@ -5,6 +5,24 @@ interface Column<T> {
className?: string; className?: string;
} }
// F-1 closure (cat-k-e85d1099b2d7): DataTable was a render-only
// component pre-F-1 — every consumer page handed it the first 50
// rows from a paginated endpoint and there was no way for the
// operator to advance. The backend has always returned `{data,
// total, page, per_page}` but the frontend never surfaced page
// 2+. The pagination prop below opt-ins reusable controls in the
// table footer; CertificatesPage is the first consumer (and the
// audit's flagged page), but TargetsPage / IssuersPage / others
// can adopt by passing the same prop.
interface PaginationProps {
page: number;
perPage: number;
total: number;
onPageChange: (page: number) => void;
onPerPageChange?: (perPage: number) => void;
perPageOptions?: number[];
}
interface DataTableProps<T> { interface DataTableProps<T> {
columns: Column<T>[]; columns: Column<T>[];
data: T[]; data: T[];
@@ -15,9 +33,10 @@ interface DataTableProps<T> {
selectable?: boolean; selectable?: boolean;
selectedKeys?: Set<string>; selectedKeys?: Set<string>;
onSelectionChange?: (keys: Set<string>) => void; onSelectionChange?: (keys: Set<string>) => void;
pagination?: PaginationProps;
} }
export default function DataTable<T>({ columns, data, onRowClick, emptyMessage, isLoading, keyField = 'id', selectable, selectedKeys, onSelectionChange }: DataTableProps<T>) { export default function DataTable<T>({ columns, data, onRowClick, emptyMessage, isLoading, keyField = 'id', selectable, selectedKeys, onSelectionChange, pagination }: DataTableProps<T>) {
if (isLoading) { if (isLoading) {
return ( return (
<div className="flex items-center justify-center py-16 text-ink-muted"> <div className="flex items-center justify-center py-16 text-ink-muted">
@@ -111,8 +130,69 @@ export default function DataTable<T>({ columns, data, onRowClick, emptyMessage,
})} })}
</tbody> </tbody>
</table> </table>
{pagination && pagination.total > 0 && (
<PaginationControls {...pagination} />
)}
</div> </div>
); );
} }
export type { Column }; // F-1 closure (cat-k-e85d1099b2d7): pagination footer for DataTable
// consumers that want prev/next + page counter + per-page selector
// against a paginated backend response. Disabling logic guards the
// boundaries (prev disabled on page 1; next disabled when page *
// per_page >= total).
function PaginationControls({ page, perPage, total, onPageChange, onPerPageChange, perPageOptions }: PaginationProps) {
const start = total === 0 ? 0 : (page - 1) * perPage + 1;
const end = Math.min(page * perPage, total);
const lastPage = Math.max(1, Math.ceil(total / perPage));
const isFirst = page <= 1;
const isLast = page >= lastPage;
const options = perPageOptions ?? [25, 50, 100, 200];
return (
<div className="flex items-center justify-between border-t border-surface-border px-4 py-3 text-sm text-ink-muted">
<span>
Showing <span className="font-medium text-ink">{start}</span><span className="font-medium text-ink">{end}</span> of <span className="font-medium text-ink">{total.toLocaleString()}</span>
</span>
<div className="flex items-center gap-3">
{onPerPageChange && (
<label className="flex items-center gap-2 text-xs">
<span>Rows per page:</span>
<select
value={perPage}
onChange={e => onPerPageChange(Number(e.target.value))}
className="rounded border border-surface-border bg-white px-2 py-1 text-xs text-ink focus:outline-none focus:border-brand-400"
>
{options.map(opt => (
<option key={opt} value={opt}>{opt}</option>
))}
</select>
</label>
)}
<span className="text-xs">
Page <span className="font-medium text-ink">{page}</span> of <span className="font-medium text-ink">{lastPage}</span>
</span>
<div className="flex gap-1">
<button
type="button"
onClick={() => onPageChange(page - 1)}
disabled={isFirst}
className="rounded border border-surface-border px-3 py-1 text-xs text-ink hover:bg-surface-muted disabled:cursor-not-allowed disabled:opacity-50"
>
Prev
</button>
<button
type="button"
onClick={() => onPageChange(page + 1)}
disabled={isLast}
className="rounded border border-surface-border px-3 py-1 text-xs text-ink hover:bg-surface-muted disabled:cursor-not-allowed disabled:opacity-50"
>
Next
</button>
</div>
</div>
</div>
);
}
export type { Column, PaginationProps };
+1
View File
@@ -10,6 +10,7 @@ const nav = [
{ to: '/jobs', label: 'Jobs', icon: 'M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15' }, { to: '/jobs', label: 'Jobs', icon: 'M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15' },
{ to: '/notifications', label: 'Notifications', icon: 'M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9' }, { to: '/notifications', label: 'Notifications', icon: 'M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9' },
{ to: '/policies', label: 'Policies', icon: 'M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2m-6 9l2 2 4-4' }, { to: '/policies', label: 'Policies', icon: 'M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2m-6 9l2 2 4-4' },
{ to: '/renewal-policies', label: 'Renewal Policies', icon: 'M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15' },
{ to: '/profiles', label: 'Profiles', icon: 'M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.066 2.573c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.573 1.066c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.066-2.573c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z M15 12a3 3 0 11-6 0 3 3 0 016 0z' }, { to: '/profiles', label: 'Profiles', icon: 'M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.066 2.573c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.573 1.066c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.066-2.573c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z M15 12a3 3 0 11-6 0 3 3 0 016 0z' },
{ to: '/issuers', label: 'Issuers', icon: 'M15 7a2 2 0 012 2m4 0a6 6 0 01-7.743 5.743L11 17H9v2H7v2H4a1 1 0 01-1-1v-2.586a1 1 0 01.293-.707l5.964-5.964A6 6 0 1121 9z' }, { to: '/issuers', label: 'Issuers', icon: 'M15 7a2 2 0 012 2m4 0a6 6 0 01-7.743 5.743L11 17H9v2H7v2H4a1 1 0 01-1-1v-2.586a1 1 0 01.293-.707l5.964-5.964A6 6 0 1121 9z' },
{ to: '/targets', label: 'Targets', icon: 'M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10' }, { to: '/targets', label: 'Targets', icon: 'M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10' },
+130
View File
@@ -0,0 +1,130 @@
import { describe, expect, it } from 'vitest';
import { render } from '@testing-library/react';
import StatusBadge from './StatusBadge';
// -----------------------------------------------------------------------------
// D-1 master — StatusBadge enum-coverage contract
//
// The single source of truth for what Go actually emits on the wire.
// Update this if the Go enums change (and the StatusBadge will go red
// here BEFORE any user sees a wrong color in production).
//
// Sources (mirror the Go const blocks verbatim — wire VALUES, not Go
// identifier names):
// AgentStatus — internal/domain/connector.go:174-176
// CertificateStatus — internal/domain/certificate.go:50-57
// JobStatus — internal/domain/job.go:43-49
// NotificationStatus— internal/domain/notification.go:51-55
// DiscoveryStatus — internal/domain/discovery.go:13-17
// HealthStatus — internal/domain/health_check.go:9-13
//
// Issuer 'Enabled' / 'Disabled' are NOT a Go enum — they're frontend-
// synthesized labels mapped from `Issuer.enabled bool` at the call
// site (TargetsPage.tsx similarly). Pinned in a separate group below.
//
// Pre-D-1 drift this test would have caught:
// - Agent: StatusBadge had 'Stale' (never emitted), missing 'Degraded'
// (real). Degraded agents rendered as default neutral grey, hiding
// attention-needed state from operators.
// - Notification: StatusBadge missing 'dead' (retries exhausted).
// Dead-letter notifications rendered as default neutral, visually
// equated with 'read' (operator-acknowledged).
// - Certificate: StatusBadge had 'PendingIssuance' (never emitted).
// Dead key, latent confusion vector if anyone copies it as
// canonical.
// -----------------------------------------------------------------------------
const ENUMS_FROM_GO = {
AgentStatus: ['Online', 'Offline', 'Degraded'] as const,
CertificateStatus: ['Pending', 'Active', 'Expiring', 'Expired',
'RenewalInProgress', 'Failed', 'Revoked', 'Archived'] as const,
JobStatus: ['Pending', 'AwaitingCSR', 'AwaitingApproval', 'Running',
'Completed', 'Failed', 'Cancelled'] as const,
NotificationStatus: ['pending', 'sent', 'failed', 'dead', 'read'] as const,
DiscoveryStatus: ['Unmanaged', 'Managed', 'Dismissed'] as const,
HealthStatus: ['healthy', 'degraded', 'down', 'cert_mismatch', 'unknown'] as const,
};
// Frontend-synthesized labels — not in any Go enum, but surfaced via
// StatusBadge from real call sites (TargetsPage, AgentGroupsPage etc.)
// and therefore part of the visual contract this component owns.
const FRONTEND_SYNTHESIZED = ['Enabled', 'Disabled'] as const;
describe('StatusBadge — enum-coverage contract (D-1 master)', () => {
// Iterate every Go-emitted value across every enum and assert the
// rendered <span> carries a class OTHER than the default 'badge-neutral'.
// EXCEPT for legitimately-neutral statuses (Archived, Cancelled,
// Dismissed, read, unknown) which are intentionally neutral by UX
// design — those are pinned by a separate sub-test below.
const INTENTIONALLY_NEUTRAL = new Set(['Archived', 'Cancelled', 'Dismissed', 'read', 'unknown']);
for (const [enumName, values] of Object.entries(ENUMS_FROM_GO)) {
for (const v of values) {
it(`${enumName}: '${v}' renders a recognised class (no fallthrough)`, () => {
const { container } = render(<StatusBadge status={v} />);
const span = container.querySelector('span');
expect(span).not.toBeNull();
const cls = span!.className;
if (INTENTIONALLY_NEUTRAL.has(v)) {
// Neutral is the right semantic answer for terminal-acknowledged
// states — but it must come from an EXPLICIT mapping, not the
// dictionary-default fallthrough. Asserting a 'badge-neutral'
// class here pins that the explicit entry exists; if someone
// deletes it, this still passes (because the default is also
// 'badge-neutral'). The negative assertion in the dead-keys
// sub-test below catches the deletion case.
expect(cls).toBe('badge badge-neutral');
} else {
expect(cls).toMatch(/badge-(success|warning|danger|info)/);
expect(cls).not.toBe('badge badge-neutral');
}
});
}
}
for (const v of FRONTEND_SYNTHESIZED) {
it(`Frontend-synthesized '${v}' has an explicit StatusBadge mapping`, () => {
const { container } = render(<StatusBadge status={v} />);
const cls = container.querySelector('span')!.className;
// 'Disabled' is intentionally neutral; 'Enabled' is success.
expect(cls).toMatch(/badge-(success|warning|danger|info|neutral)/);
});
}
// Negative contract: the dead keys we deleted MUST fall through to the
// default. If a future PR re-adds 'Stale' or 'PendingIssuance' to
// statusStyles, this test will surface it because the rendered class
// will no longer be 'badge badge-neutral' (it'd be the explicit value
// someone re-added, e.g. 'badge-warning').
it.each(['Stale', 'PendingIssuance'])(
"dead key '%s' falls through to neutral default (no explicit mapping)",
(deadKey) => {
const { container } = render(<StatusBadge status={deadKey} />);
expect(container.querySelector('span')!.className).toBe('badge badge-neutral');
},
);
// Specific danger-class contracts (UX correctness, not just non-default).
// These pin the operator-attention semantics. If anyone changes 'dead'
// or 'Degraded' away from these classes, the operator's perception of
// "this needs my attention" changes — these are the highest-stakes
// visual semantics in the dashboard.
it("Notification 'dead' renders as danger (operator attention required)", () => {
const { container } = render(<StatusBadge status="dead" />);
expect(container.querySelector('span')!.className).toContain('badge-danger');
});
it("Agent 'Degraded' renders as warning (degradation, not failure)", () => {
const { container } = render(<StatusBadge status="Degraded" />);
expect(container.querySelector('span')!.className).toContain('badge-warning');
});
// Unknown statuses fall through to neutral. The string is still
// displayed verbatim so an operator can see "what is this?" rather
// than nothing at all.
it('unknown status string renders as neutral but preserves the label text', () => {
const { container } = render(<StatusBadge status="SomeFutureStatus" />);
const span = container.querySelector('span');
expect(span!.className).toBe('badge badge-neutral');
expect(span!.textContent).toBe('SomeFutureStatus');
});
});
+44 -9
View File
@@ -1,13 +1,41 @@
// StatusBadge — single source of truth for the certctl dashboard's
// per-status color mapping. Keys are the EXACT wire values Go emits
// (case-sensitive). Update this file when a new status value lands on
// the Go side; StatusBadge.test.tsx walks every value and will go red
// before users see a default-grey "what is happening?" badge.
//
// D-1 master closure (cat-d-359e92c20cbf, cat-d-9f4c8e4a91f1,
// cat-d-1447e04732e7, cat-f-cert_detail_page_key_render_fallback,
// cat-f-ae0d06b6588f) fixed the pre-master drift:
// - Agent: 'Stale' (never emitted) → 'Degraded' (real value);
// `internal/domain/connector.go::AgentStatusDegraded = "Degraded"`.
// - Notification: added 'dead' (was falling through to neutral);
// `internal/domain/notification.go::NotificationStatusDead = "dead"`.
// - Certificate: dropped dead 'PendingIssuance' key — the real
// `CertificateStatusPending = "Pending"` is mapped under Job
// statuses below.
//
// Source-of-truth references (re-verify if the Go enum changes):
// - internal/domain/connector.go::AgentStatus*
// - internal/domain/certificate.go::CertificateStatus*
// - internal/domain/job.go::JobStatus*
// - internal/domain/notification.go::NotificationStatus*
// - internal/domain/discovery.go::DiscoveryStatus*
// - internal/domain/health_check.go::HealthStatus*
//
// Issuer 'Enabled'/'Disabled' are frontend-synthesized labels (mapped
// from the `enabled bool` field on the Issuer struct), not Go-emitted
// enum values, but they're surfaced via StatusBadge for consistency.
const statusStyles: Record<string, string> = { const statusStyles: Record<string, string> = {
// Certificate statuses // Certificate statuses (internal/domain/certificate.go::CertificateStatus*)
Active: 'badge-success', Active: 'badge-success',
Expiring: 'badge-warning', Expiring: 'badge-warning',
Expired: 'badge-danger', Expired: 'badge-danger',
RenewalInProgress: 'badge-info', RenewalInProgress: 'badge-info',
PendingIssuance: 'badge-info',
Archived: 'badge-neutral', Archived: 'badge-neutral',
Revoked: 'badge-danger', Revoked: 'badge-danger',
// Job statuses // Job statuses (internal/domain/job.go::JobStatus*) — note: 'Pending' is
// shared between CertificateStatusPending and JobStatusPending.
Pending: 'badge-info', Pending: 'badge-info',
AwaitingCSR: 'badge-info', AwaitingCSR: 'badge-info',
AwaitingApproval: 'badge-info', AwaitingApproval: 'badge-info',
@@ -15,23 +43,30 @@ const statusStyles: Record<string, string> = {
Completed: 'badge-success', Completed: 'badge-success',
Failed: 'badge-danger', Failed: 'badge-danger',
Cancelled: 'badge-neutral', Cancelled: 'badge-neutral',
// Agent statuses // Agent statuses (internal/domain/connector.go::AgentStatus*) — D-1:
// 'Degraded' replaces the never-emitted 'Stale' from pre-D-1 (the Go
// domain has only Online / Offline / Degraded; mapping 'Stale' yellow
// and letting 'Degraded' fall through to neutral hid degraded agents).
Online: 'badge-success', Online: 'badge-success',
Offline: 'badge-danger', Offline: 'badge-danger',
Stale: 'badge-warning', Degraded: 'badge-warning',
// Discovery statuses // Discovery statuses (internal/domain/discovery.go::DiscoveryStatus*)
Unmanaged: 'badge-warning', Unmanaged: 'badge-warning',
Managed: 'badge-success', Managed: 'badge-success',
Dismissed: 'badge-neutral', Dismissed: 'badge-neutral',
// Issuer statuses // Issuer statuses (frontend-synthesized from Issuer.enabled bool)
Enabled: 'badge-success', Enabled: 'badge-success',
Disabled: 'badge-neutral', Disabled: 'badge-neutral',
// Notification statuses // Notification statuses (internal/domain/notification.go::NotificationStatus*)
// — D-2: added 'dead' (retries exhausted, dead-letter queue). Pre-D-2 it
// fell through to neutral, visually equating "needs operator attention"
// with "operator already acknowledged" (read).
sent: 'badge-success', sent: 'badge-success',
pending: 'badge-warning', pending: 'badge-warning',
failed: 'badge-danger', failed: 'badge-danger',
dead: 'badge-danger',
read: 'badge-neutral', read: 'badge-neutral',
// Health check statuses // Health check statuses (internal/domain/health_check.go::HealthStatus*)
healthy: 'badge-success', healthy: 'badge-success',
degraded: 'badge-warning', degraded: 'badge-warning',
down: 'badge-danger', down: 'badge-danger',
+2
View File
@@ -14,6 +14,7 @@ import AgentDetailPage from './pages/AgentDetailPage';
import JobsPage from './pages/JobsPage'; import JobsPage from './pages/JobsPage';
import NotificationsPage from './pages/NotificationsPage'; import NotificationsPage from './pages/NotificationsPage';
import PoliciesPage from './pages/PoliciesPage'; import PoliciesPage from './pages/PoliciesPage';
import RenewalPoliciesPage from './pages/RenewalPoliciesPage';
import IssuersPage from './pages/IssuersPage'; import IssuersPage from './pages/IssuersPage';
import TargetsPage from './pages/TargetsPage'; import TargetsPage from './pages/TargetsPage';
import ProfilesPage from './pages/ProfilesPage'; import ProfilesPage from './pages/ProfilesPage';
@@ -62,6 +63,7 @@ createRoot(document.getElementById('root')!).render(
<Route path="jobs/:id" element={<JobDetailPage />} /> <Route path="jobs/:id" element={<JobDetailPage />} />
<Route path="notifications" element={<NotificationsPage />} /> <Route path="notifications" element={<NotificationsPage />} />
<Route path="policies" element={<PoliciesPage />} /> <Route path="policies" element={<PoliciesPage />} />
<Route path="renewal-policies" element={<RenewalPoliciesPage />} />
<Route path="profiles" element={<ProfilesPage />} /> <Route path="profiles" element={<ProfilesPage />} />
<Route path="issuers" element={<IssuersPage />} /> <Route path="issuers" element={<IssuersPage />} />
<Route path="issuers/:id" element={<IssuerDetailPage />} /> <Route path="issuers/:id" element={<IssuerDetailPage />} />
+26 -23
View File
@@ -15,7 +15,12 @@ function InfoRow({ label, value }: { label: string; value: React.ReactNode }) {
); );
} }
function heartbeatStatus(lastHeartbeat: string): string { // D-2 (master): the `lastHeartbeat` parameter accepts undefined because
// the Go-side struct emits `last_heartbeat_at` as `omitempty` (a never-
// heartbeated agent omits the field entirely). Pre-D-2 the TS interface
// declared the field as required, masking this case. Post-D-2 the empty
// case is explicit at both the type level and the function signature.
function heartbeatStatus(lastHeartbeat: string | undefined): string {
if (!lastHeartbeat) return 'Offline'; if (!lastHeartbeat) return 'Offline';
const ago = Date.now() - new Date(lastHeartbeat).getTime(); const ago = Date.now() - new Date(lastHeartbeat).getTime();
if (ago < 5 * 60 * 1000) return 'Online'; if (ago < 5 * 60 * 1000) return 'Online';
@@ -89,8 +94,15 @@ export default function AgentDetailPage() {
</span> </span>
) : '—' ) : '—'
} /> } />
<InfoRow label="Registered" value={formatDateTime(agent.created_at)} /> {/* D-2 (master): pre-D-2 these rows used `agent.created_at`
<InfoRow label="Updated" value={formatDateTime(agent.updated_at)} /> + `agent.updated_at` TS phantoms that the Go-side
struct (`internal/domain/connector.go::Agent`) never
emitted. The "Registered" row now reads from the real
Go-emitted `registered_at` field; the "Updated" row is
dropped because the Go struct has no equivalent
update-timestamp on Agent (heartbeats are tracked via
`last_heartbeat_at` above). */}
<InfoRow label="Registered" value={formatDateTime(agent.registered_at)} />
</div> </div>
{/* System Info */} {/* System Info */}
@@ -100,26 +112,17 @@ export default function AgentDetailPage() {
<InfoRow label="Architecture" value={agent.architecture || '—'} /> <InfoRow label="Architecture" value={agent.architecture || '—'} />
<InfoRow label="IP Address" value={<span className="font-mono text-xs">{agent.ip_address || '—'}</span>} /> <InfoRow label="IP Address" value={<span className="font-mono text-xs">{agent.ip_address || '—'}</span>} />
<InfoRow label="Agent Version" value={agent.version || '—'} /> <InfoRow label="Agent Version" value={agent.version || '—'} />
{agent.capabilities?.length ? ( {/* D-2 (master): the previous "Capabilities" + "Tags" sections
<div className="mt-4"> rendered `agent.capabilities` and `agent.tags`, both of
<p className="text-xs text-ink-muted mb-2">Capabilities</p> which were TS phantom fields the Go-side struct
<div className="flex flex-wrap gap-2"> (`internal/domain/connector.go::Agent`) never emitted.
{agent.capabilities.map((c) => ( Both sections always rendered as the empty-state fallback
<span key={c} className="badge badge-info">{c}</span> (the `?.length ?` and `Object.keys(...).length > 0`
))} guards always evaluated false). Removed in D-2 master.
</div> If/when the backend grows real Agent metadata fields
</div> (capabilities advertised at heartbeat time, operator-
) : null} applied tags), re-introduce here in the same commit that
{agent.tags && Object.keys(agent.tags).length > 0 ? ( ships the Go-side change. */}
<div className="mt-4">
<p className="text-xs text-ink-muted mb-2">Tags</p>
<div className="flex flex-wrap gap-2">
{Object.entries(agent.tags).map(([k, v]) => (
<span key={k} className="badge badge-neutral">{k}: {v}</span>
))}
</div>
</div>
) : null}
</div> </div>
</div> </div>
+140 -8
View File
@@ -1,6 +1,6 @@
import { useState } from 'react'; import { useEffect, useState } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { getAgentGroups, deleteAgentGroup, createAgentGroup } from '../api/client'; import { getAgentGroups, deleteAgentGroup, createAgentGroup, updateAgentGroup } from '../api/client';
import PageHeader from '../components/PageHeader'; import PageHeader from '../components/PageHeader';
import DataTable from '../components/DataTable'; import DataTable from '../components/DataTable';
import type { Column } from '../components/DataTable'; import type { Column } from '../components/DataTable';
@@ -144,9 +144,115 @@ function CreateAgentGroupModal({ isOpen, onClose, onSuccess, isLoading, error }:
); );
} }
// EditAgentGroupModal — B-1 master closure (cat-b-31ceb6aaa9f1).
// Mirrors CreateAgentGroupModal; pre-populates from the editing group;
// calls updateAgentGroup(id, fields) to close the destructive-rename
// hazard. Membership-rule fields (match_os, match_architecture,
// match_ip_cidr, match_version) are editable like the rest — operators
// frequently want to widen/narrow group membership without recreating.
interface EditAgentGroupModalProps {
group: AgentGroup | null;
onClose: () => void;
onSuccess: () => void;
isLoading: boolean;
error: string | null;
}
function EditAgentGroupModal({ group, onClose, onSuccess, isLoading, error }: EditAgentGroupModalProps) {
const [name, setName] = useState('');
const [description, setDescription] = useState('');
const [matchOs, setMatchOs] = useState('');
const [matchArch, setMatchArch] = useState('');
const [matchIpCidr, setMatchIpCidr] = useState('');
const [matchVersion, setMatchVersion] = useState('');
const [enabled, setEnabled] = useState(true);
useEffect(() => {
if (group) {
setName(group.name);
setDescription(group.description || '');
setMatchOs(group.match_os || '');
setMatchArch(group.match_architecture || '');
setMatchIpCidr(group.match_ip_cidr || '');
setMatchVersion(group.match_version || '');
setEnabled(group.enabled);
}
}, [group]);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!group || !name.trim()) return;
await updateAgentGroup(group.id, {
name: name.trim(),
description: description.trim(),
match_os: matchOs.trim(),
match_architecture: matchArch.trim(),
match_ip_cidr: matchIpCidr.trim(),
match_version: matchVersion.trim(),
enabled,
});
onSuccess();
};
if (!group) return null;
return (
<div className="fixed inset-0 bg-black/40 flex items-center justify-center z-50" onClick={onClose}>
<div className="bg-surface border border-surface-border rounded p-5 w-full max-w-md shadow-xl max-h-[90vh] overflow-y-auto" onClick={e => e.stopPropagation()}>
<h2 className="text-lg font-semibold text-ink mb-4">Edit Agent Group</h2>
<p className="text-xs text-ink-muted mb-4 font-mono">{group.id}</p>
{error && <div className="mb-4 p-3 bg-red-50 border border-red-200 rounded text-sm text-red-700">{error}</div>}
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label className="block text-sm font-medium text-ink mb-1">Name *</label>
<input value={name} onChange={e => setName(e.target.value)} required
className="w-full bg-white border border-surface-border rounded px-3 py-2 text-sm text-ink focus:outline-none focus:border-brand-400" />
</div>
<div>
<label className="block text-sm font-medium text-ink mb-1">Description</label>
<textarea value={description} onChange={e => setDescription(e.target.value)} rows={2}
className="w-full bg-white border border-surface-border rounded px-3 py-2 text-sm text-ink focus:outline-none focus:border-brand-400" />
</div>
<div>
<label className="block text-sm font-medium text-ink mb-1">Match OS</label>
<input value={matchOs} onChange={e => setMatchOs(e.target.value)} placeholder="linux"
className="w-full bg-white border border-surface-border rounded px-3 py-2 text-sm text-ink focus:outline-none focus:border-brand-400" />
</div>
<div>
<label className="block text-sm font-medium text-ink mb-1">Match Architecture</label>
<input value={matchArch} onChange={e => setMatchArch(e.target.value)} placeholder="amd64"
className="w-full bg-white border border-surface-border rounded px-3 py-2 text-sm text-ink focus:outline-none focus:border-brand-400" />
</div>
<div>
<label className="block text-sm font-medium text-ink mb-1">Match IP CIDR</label>
<input value={matchIpCidr} onChange={e => setMatchIpCidr(e.target.value)} placeholder="10.0.0.0/24"
className="w-full bg-white border border-surface-border rounded px-3 py-2 text-sm text-ink focus:outline-none focus:border-brand-400" />
</div>
<div>
<label className="block text-sm font-medium text-ink mb-1">Match Version</label>
<input value={matchVersion} onChange={e => setMatchVersion(e.target.value)} placeholder="v2.0.x"
className="w-full bg-white border border-surface-border rounded px-3 py-2 text-sm text-ink focus:outline-none focus:border-brand-400" />
</div>
<label className="flex items-center gap-2 text-sm text-ink">
<input type="checkbox" checked={enabled} onChange={e => setEnabled(e.target.checked)} />
Enabled
</label>
<div className="flex gap-2 pt-4">
<button type="submit" disabled={isLoading} className="flex-1 btn btn-primary disabled:opacity-50 disabled:cursor-not-allowed">
{isLoading ? 'Saving...' : 'Save Changes'}
</button>
<button type="button" onClick={onClose} className="flex-1 btn btn-ghost">Cancel</button>
</div>
</form>
</div>
</div>
);
}
export default function AgentGroupsPage() { export default function AgentGroupsPage() {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const [showCreate, setShowCreate] = useState(false); const [showCreate, setShowCreate] = useState(false);
const [editingGroup, setEditingGroup] = useState<AgentGroup | null>(null);
const { data, isLoading, error, refetch } = useQuery({ const { data, isLoading, error, refetch } = useQuery({
queryKey: ['agent-groups'], queryKey: ['agent-groups'],
@@ -166,6 +272,14 @@ export default function AgentGroupsPage() {
}, },
}); });
const updateMutation = useMutation({
mutationFn: ({ id, data }: { id: string; data: Partial<AgentGroup> }) => updateAgentGroup(id, data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['agent-groups'] });
setEditingGroup(null);
},
});
const columns: Column<AgentGroup>[] = [ const columns: Column<AgentGroup>[] = [
{ {
key: 'name', key: 'name',
@@ -214,12 +328,20 @@ export default function AgentGroupsPage() {
key: 'actions', key: 'actions',
label: '', label: '',
render: (g) => ( render: (g) => (
<button <div className="flex gap-3 justify-end">
onClick={(e) => { e.stopPropagation(); if (confirm(`Delete group ${g.name}?`)) deleteMutation.mutate(g.id); }} <button
className="text-xs text-red-600 hover:text-red-700 transition-colors" onClick={(e) => { e.stopPropagation(); setEditingGroup(g); }}
> className="text-xs text-brand-400 hover:text-brand-500 transition-colors"
Delete >
</button> Edit
</button>
<button
onClick={(e) => { e.stopPropagation(); if (confirm(`Delete group ${g.name}?`)) deleteMutation.mutate(g.id); }}
className="text-xs text-red-600 hover:text-red-700 transition-colors"
>
Delete
</button>
</div>
), ),
}, },
]; ];
@@ -252,6 +374,16 @@ export default function AgentGroupsPage() {
isLoading={createMutation.isPending} isLoading={createMutation.isPending}
error={createMutation.error ? (createMutation.error as Error).message : null} error={createMutation.error ? (createMutation.error as Error).message : null}
/> />
<EditAgentGroupModal
group={editingGroup}
onClose={() => setEditingGroup(null)}
onSuccess={() => {
queryClient.invalidateQueries({ queryKey: ['agent-groups'] });
setEditingGroup(null);
}}
isLoading={updateMutation.isPending}
error={updateMutation.error ? (updateMutation.error as Error).message : null}
/>
</> </>
); );
} }
+6 -1
View File
@@ -15,7 +15,12 @@ import ErrorState from '../components/ErrorState';
import { timeAgo } from '../api/utils'; import { timeAgo } from '../api/utils';
import type { Agent, AgentDependencyCounts } from '../api/types'; import type { Agent, AgentDependencyCounts } from '../api/types';
function heartbeatStatus(lastHeartbeat: string): string { // D-2 (master): the `lastHeartbeat` parameter accepts undefined because
// the Go-side struct emits `last_heartbeat_at` as `omitempty` (never-
// heartbeated agents omit the field). Mirror of the same helper in
// AgentDetailPage.tsx — kept as twin definitions to avoid a shared-
// helper PR detour during D-2; consolidate in a follow-up if desired.
function heartbeatStatus(lastHeartbeat: string | undefined): string {
if (!lastHeartbeat) return 'Offline'; if (!lastHeartbeat) return 'Offline';
const ago = Date.now() - new Date(lastHeartbeat).getTime(); const ago = Date.now() - new Date(lastHeartbeat).getTime();
if (ago < 5 * 60 * 1000) return 'Online'; if (ago < 5 * 60 * 1000) return 'Online';
+20 -6
View File
@@ -380,11 +380,20 @@ export default function CertificateDetailPage() {
); );
} }
// Derive certificate metadata from latest version (backend doesn't include these on the cert object) // Derive certificate metadata from latest version. Per-issuance fields
// (serial_number, fingerprint_sha256, key_algorithm, key_size, issued_at)
// live on `CertificateVersion`, NOT on `ManagedCertificate` — the Go
// domain has always been this way; the TS interface used to lie about
// it via optional `cert.X?` declarations that always returned undefined
// on list responses (D-5 / cat-f-ae0d06b6588f). Post-D-5 the TS type
// makes the missing-data case explicit, and every read goes through
// `latestVersion?.field` here.
const latestVersion = versions?.data?.[0]; const latestVersion = versions?.data?.[0];
const serialNumber = cert.serial_number || latestVersion?.serial_number; const serialNumber = latestVersion?.serial_number;
const fingerprintSha256 = cert.fingerprint_sha256 || latestVersion?.fingerprint_sha256; const fingerprintSha256 = latestVersion?.fingerprint_sha256;
const issuedAt = cert.issued_at || latestVersion?.not_before; const issuedAt = latestVersion?.not_before;
const keyAlgorithm = latestVersion?.key_algorithm;
const keySize = latestVersion?.key_size;
const days = daysUntil(cert.expires_at); const days = daysUntil(cert.expires_at);
const isRevoked = cert.status === 'Revoked'; const isRevoked = cert.status === 'Revoked';
@@ -536,8 +545,13 @@ export default function CertificateDetailPage() {
<InfoRow label="Fingerprint" value={ <InfoRow label="Fingerprint" value={
fingerprintSha256 ? <span className="font-mono text-xs">{fingerprintSha256.slice(0, 24)}...</span> : '—' fingerprintSha256 ? <span className="font-mono text-xs">{fingerprintSha256.slice(0, 24)}...</span> : '—'
} /> } />
<InfoRow label="Key Algorithm" value={cert.key_algorithm || '—'} /> {/* D-4 (cat-f-cert_detail_page_key_render_fallback): mirror the
<InfoRow label="Key Size" value={cert.key_size ? `${cert.key_size} bits` : '—'} /> latestVersion fallback used for serialNumber / fingerprintSha256
above. Pre-D-4 these rows accessed `cert.key_algorithm` /
`cert.key_size` directly both phantom Certificate fields per
D-5 (cat-f-ae0d06b6588f), so the rows always rendered '—'. */}
<InfoRow label="Key Algorithm" value={keyAlgorithm || '—'} />
<InfoRow label="Key Size" value={keySize != null ? `${keySize} bits` : '—'} />
{profile?.allowed_ekus && profile.allowed_ekus.length > 0 && ( {profile?.allowed_ekus && profile.allowed_ekus.length > 0 && (
<InfoRow label="Extended Key Usage" value={ <InfoRow label="Extended Key Usage" value={
<div className="flex flex-wrap gap-1"> <div className="flex flex-wrap gap-1">
+115 -24
View File
@@ -1,7 +1,7 @@
import { useState } from 'react'; import { useState } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import { getCertificates, createCertificate, triggerRenewal, revokeCertificate, updateCertificate, getOwners, getTeams, getRenewalPolicies, getProfiles, getIssuers, bulkRevokeCertificates } from '../api/client'; import { getCertificates, createCertificate, revokeCertificate, getOwners, getTeams, getRenewalPolicies, getProfiles, getIssuers, bulkRevokeCertificates, bulkRenewCertificates, bulkReassignCertificates } from '../api/client';
import { useAuth } from '../components/AuthProvider'; import { useAuth } from '../components/AuthProvider';
import { REVOCATION_REASONS } from '../api/types'; import { REVOCATION_REASONS } from '../api/types';
import PageHeader from '../components/PageHeader'; import PageHeader from '../components/PageHeader';
@@ -311,22 +311,38 @@ function BulkReassignModal({ ids, onClose, onSuccess }: { ids: string[]; onClose
queryFn: () => getOwners(), queryFn: () => getOwners(),
}); });
// L-2 closure (cat-l-8a1fb258a38a): pre-L-2 this looped
// `await updateCertificate(id, { owner_id })` over the selection
// (N HTTP round-trips). Post-L-2 it's a single POST to
// /api/v1/certificates/bulk-reassign. The CI guardrail in
// .github/workflows/ci.yml (`Forbidden client-side bulk-action loop
// regression guard (L-1)`) catches reintroduction of the loop shape.
const handleReassign = async () => { const handleReassign = async () => {
if (!ownerId) return; if (!ownerId) return;
setRunning(true); setRunning(true);
setError(''); setError('');
let succeeded = 0; setProgress(0);
for (const id of ids) { try {
try { const result = await bulkReassignCertificates({
await updateCertificate(id, { owner_id: ownerId } as Partial<Certificate>); certificate_ids: ids,
succeeded++; owner_id: ownerId,
setProgress(succeeded); });
} catch (err) { setProgress(result.total_reassigned);
setError(`Failed on ${id}: ${err instanceof Error ? err.message : 'Unknown error'}`); if (result.total_failed > 0) {
break; const first = result.errors?.[0];
setError(
`${result.total_failed} of ${result.total_matched} failed${
first ? `: ${first.certificate_id}${first.error}` : ''
}`
);
} else {
onSuccess();
} }
} catch (err) {
setError(`Bulk reassignment failed: ${err instanceof Error ? err.message : 'Unknown error'}`);
} finally {
setRunning(false);
} }
if (!error) onSuccess();
}; };
return ( return (
@@ -382,6 +398,24 @@ export default function CertificatesPage() {
const [issuerFilter, setIssuerFilter] = useState(''); const [issuerFilter, setIssuerFilter] = useState('');
const [ownerFilter, setOwnerFilter] = useState(''); const [ownerFilter, setOwnerFilter] = useState('');
const [profileFilter, setProfileFilter] = useState(''); const [profileFilter, setProfileFilter] = useState('');
// F-1 closure (cat-e-610251c8f72d): pre-F-1 the page exposed only 5 of
// the backend handler's 17 supported query filters. Three new operator-
// facing filters added: team_id (already first-class elsewhere),
// expires_before (drives the "expiring in N days" workflow), and a
// sort selector (defaults to backend ordering). Audit-recommended
// minimum-add per the closure rationale; remaining filters
// (agent_id, expires_after, created_after, updated_after, cursor,
// fields, sort_desc) are deferred until a consumer use case
// demands them — over-stuffing the toolbar is its own UX cost.
const [teamFilter, setTeamFilter] = useState('');
const [expiresBefore, setExpiresBefore] = useState('');
const [sortBy, setSortBy] = useState('');
// F-1 closure (cat-k-e85d1099b2d7): pre-F-1 the page rendered the
// first 50 certs returned by the backend with no way to advance.
// The reusable DataTable pagination prop (added in this same
// commit) takes the page + per_page state declared here.
const [page, setPage] = useState(1);
const [perPage, setPerPage] = useState(50);
const [showCreate, setShowCreate] = useState(false); const [showCreate, setShowCreate] = useState(false);
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set()); const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
const [showBulkRevoke, setShowBulkRevoke] = useState(false); const [showBulkRevoke, setShowBulkRevoke] = useState(false);
@@ -391,13 +425,21 @@ export default function CertificatesPage() {
const { data: issuersData } = useQuery({ queryKey: ['issuers-filter'], queryFn: () => getIssuers({ per_page: '100' }) }); const { data: issuersData } = useQuery({ queryKey: ['issuers-filter'], queryFn: () => getIssuers({ per_page: '100' }) });
const { data: ownersData } = useQuery({ queryKey: ['owners-filter'], queryFn: () => getOwners({ per_page: '100' }) }); const { data: ownersData } = useQuery({ queryKey: ['owners-filter'], queryFn: () => getOwners({ per_page: '100' }) });
const { data: profilesData } = useQuery({ queryKey: ['profiles-filter'], queryFn: () => getProfiles({ per_page: '100' }) }); const { data: profilesData } = useQuery({ queryKey: ['profiles-filter'], queryFn: () => getProfiles({ per_page: '100' }) });
// F-1 closure: hydrate the team filter dropdown.
const { data: teamsFilterData } = useQuery({ queryKey: ['teams-filter'], queryFn: () => getTeams({ per_page: '100' }) });
const params: Record<string, string> = {}; const params: Record<string, string> = {};
if (statusFilter) params.status = statusFilter; if (statusFilter) params.status = statusFilter;
if (envFilter) params.environment = envFilter; if (envFilter) params.environment = envFilter;
if (issuerFilter) params.issuer_id = issuerFilter; if (issuerFilter) params.issuer_id = issuerFilter;
if (ownerFilter) params.owner_id = ownerFilter; if (ownerFilter) params.owner_id = ownerFilter;
if (profileFilter) params.profile_id = profileFilter; if (profileFilter) params.profile_id = profileFilter;
if (teamFilter) params.team_id = teamFilter;
if (expiresBefore) params.expires_before = expiresBefore;
if (sortBy) params.sort = sortBy;
// Pagination (F-1) — re-fetch on page / per_page change.
params.page = String(page);
params.per_page = String(perPage);
const { data, isLoading, error, refetch } = useQuery({ const { data, isLoading, error, refetch } = useQuery({
queryKey: ['certificates', params], queryKey: ['certificates', params],
@@ -405,20 +447,32 @@ export default function CertificatesPage() {
refetchInterval: 30000, refetchInterval: 30000,
}); });
// L-1 closure (cat-l-fa0c1ac07ab5): pre-L-1 this looped
// `await triggerRenewal(ids[i])` over the selection (N HTTP round-
// trips × ~50200ms each = 520s wedge for 100 selected certs).
// Post-L-1 it's a single POST to /api/v1/certificates/bulk-renew;
// the server resolves the criteria, applies status filters
// (RenewalInProgress/Revoked/Archived/Expired all silent-skip), and
// enqueues N renewal jobs server-side, returning a per-cert
// {certificate_id, job_id} envelope. CI guardrail at
// .github/workflows/ci.yml catches loop-shape regression.
const handleBulkRenewal = async () => { const handleBulkRenewal = async () => {
const ids = Array.from(selectedIds); const ids = Array.from(selectedIds);
setBulkRenewProgress({ done: 0, total: ids.length, running: true }); setBulkRenewProgress({ done: 0, total: ids.length, running: true });
for (let i = 0; i < ids.length; i++) { try {
try { const result = await bulkRenewCertificates({ certificate_ids: ids });
await triggerRenewal(ids[i]); setBulkRenewProgress({
} catch { done: result.total_enqueued,
// continue on individual failures total: result.total_matched,
} running: false,
setBulkRenewProgress({ done: i + 1, total: ids.length, running: i + 1 < ids.length }); });
} catch {
// surface as a "0 of N" terminal state — no retries.
setBulkRenewProgress({ done: 0, total: ids.length, running: false });
} }
queryClient.invalidateQueries({ queryKey: ['certificates'] }); queryClient.invalidateQueries({ queryKey: ['certificates'] });
setSelectedIds(new Set()); setSelectedIds(new Set());
setTimeout(() => setBulkRenewProgress(null), 3000); setTimeout(() => setBulkRenewProgress(null), 5000);
}; };
const columns: Column<Certificate>[] = [ const columns: Column<Certificate>[] = [
@@ -559,6 +613,36 @@ export default function CertificatesPage() {
<option key={p.id} value={p.id}>{p.name}</option> <option key={p.id} value={p.id}>{p.name}</option>
))} ))}
</select> </select>
{/* F-1 closure (cat-e-610251c8f72d): team / expires_before / sort */}
<select
value={teamFilter}
onChange={e => { setTeamFilter(e.target.value); setPage(1); }}
className="bg-white border border-surface-border rounded px-3 py-1.5 text-sm text-ink"
>
<option value="">All teams</option>
{teamsFilterData?.data?.map(t => (
<option key={t.id} value={t.id}>{t.name}</option>
))}
</select>
<input
type="date"
value={expiresBefore}
onChange={e => { setExpiresBefore(e.target.value); setPage(1); }}
title="Expires before (drives the 'expiring in N days' workflow)"
className="bg-white border border-surface-border rounded px-3 py-1.5 text-sm text-ink"
/>
<select
value={sortBy}
onChange={e => { setSortBy(e.target.value); setPage(1); }}
title="Sort order"
className="bg-white border border-surface-border rounded px-3 py-1.5 text-sm text-ink"
>
<option value="">Default sort</option>
<option value="notAfter">Expires soonest</option>
<option value="-notAfter">Expires latest</option>
<option value="createdAt">Created earliest</option>
<option value="-createdAt">Created latest</option>
</select>
</div> </div>
<div className="flex-1 overflow-y-auto"> <div className="flex-1 overflow-y-auto">
{error ? ( {error ? (
@@ -573,6 +657,13 @@ export default function CertificatesPage() {
selectable selectable
selectedKeys={selectedIds} selectedKeys={selectedIds}
onSelectionChange={setSelectedIds} onSelectionChange={setSelectedIds}
pagination={{
page,
perPage,
total: data?.total ?? 0,
onPageChange: setPage,
onPerPageChange: (n) => { setPerPage(n); setPage(1); },
}}
/> />
)} )}
</div> </div>
+8 -5
View File
@@ -19,12 +19,15 @@ function InfoRow({ label, value }: { label: string; value: React.ReactNode }) {
); );
} }
/** Derive display status from backend enabled boolean */ // Derive display status from backend `enabled` boolean.
//
// D-2 (diff-05x06-97fab8783a5c, master): pre-D-2 the fall-through here
// was `issuer.status || 'Unknown'`, but `Issuer.status` was a TS phantom
// the Go-side struct never emitted (see types.ts::Issuer docblock for the
// full closure rationale). Post-D-2 the phantom is gone; this function
// derives the displayed status from `enabled` exclusively.
function issuerStatus(issuer: Issuer): string { function issuerStatus(issuer: Issuer): string {
if (issuer.enabled !== undefined) { return issuer.enabled ? 'Enabled' : 'Disabled';
return issuer.enabled ? 'Enabled' : 'Disabled';
}
return issuer.status || 'Unknown';
} }
export default function IssuerDetailPage() { export default function IssuerDetailPage() {
+126 -8
View File
@@ -1,7 +1,7 @@
import { useState, useMemo } from 'react'; import { useEffect, useState, useMemo } from 'react';
import { Link } from 'react-router-dom'; import { Link } from 'react-router-dom';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { getIssuers, testIssuerConnection, deleteIssuer, createIssuer } from '../api/client'; import { getIssuers, testIssuerConnection, deleteIssuer, createIssuer, updateIssuer } from '../api/client';
import PageHeader from '../components/PageHeader'; import PageHeader from '../components/PageHeader';
import DataTable from '../components/DataTable'; import DataTable from '../components/DataTable';
import type { Column } from '../components/DataTable'; import type { Column } from '../components/DataTable';
@@ -14,13 +14,19 @@ import TypeSelector from '../components/issuer/TypeSelector';
import ConfigForm from '../components/issuer/ConfigForm'; import ConfigForm from '../components/issuer/ConfigForm';
import ConfigDetailModal from '../components/issuer/ConfigDetailModal'; import ConfigDetailModal from '../components/issuer/ConfigDetailModal';
/** Derive display status from backend enabled boolean */ // Derive display status from backend `enabled` boolean.
//
// D-2 (diff-05x06-97fab8783a5c, master): pre-D-2 the fall-through chain
// here was `issuer.status || 'Unknown'`, which always rendered 'Unknown'
// because the Go-side struct never emitted a `status` field — the TS
// interface comment claimed status was "derived from enabled" but no
// derivation existed. Post-D-2 the phantom `Issuer.status` is gone and
// this function is the canonical derivation site. `enabled` is a
// required boolean on Go's Issuer struct so the `!== undefined` guard
// is now belt-and-suspenders rather than load-bearing, but kept for
// defensive rendering against malformed responses.
function issuerStatus(issuer: Issuer): string { function issuerStatus(issuer: Issuer): string {
if (issuer.enabled !== undefined) { return issuer.enabled ? 'Enabled' : 'Disabled';
return issuer.enabled ? 'Enabled' : 'Disabled';
}
// Fallback for legacy data that may have status string
return issuer.status || 'Unknown';
} }
export default function IssuersPage() { export default function IssuersPage() {
@@ -30,6 +36,18 @@ export default function IssuersPage() {
const [preselectedType, setPreselectedType] = useState<string | null>(null); const [preselectedType, setPreselectedType] = useState<string | null>(null);
const [typeFilter, setTypeFilter] = useState<string>(''); const [typeFilter, setTypeFilter] = useState<string>('');
const [configModal, setConfigModal] = useState<{ title: string; config: Record<string, unknown> } | null>(null); const [configModal, setConfigModal] = useState<{ title: string; config: Record<string, unknown> } | null>(null);
// B-1 master closure (cat-b-7a34f893a8f9): rename-only Edit affordance.
// Pre-B-1 the only way to rename an issuer was delete-and-recreate,
// which destroyed cert provenance and forced a re-encryption cycle
// through internal/crypto/encryption.go for every cert under the
// issuer. Type and credential blob are intentionally NOT editable here
// — changing the underlying CA driver type would require
// re-encrypting config under a different schema, and credentials are
// stored encrypted at rest (we can't decrypt them client-side to
// pre-populate). Operators who need to rotate credentials still
// delete + recreate. Documented as a deferred follow-up in the L-1
// CHANGELOG entry.
const [editingIssuer, setEditingIssuer] = useState<Issuer | null>(null);
const { data, isLoading, error, refetch } = useQuery({ const { data, isLoading, error, refetch } = useQuery({
queryKey: ['issuers'], queryKey: ['issuers'],
@@ -57,6 +75,19 @@ export default function IssuersPage() {
}, },
}); });
// B-1 master closure: updateIssuer is wired to the rename-only Edit
// modal. Type and credential blob are NOT mutated here — see editingIssuer
// docblock above. Sends `{ name, type, config }` to satisfy the backend
// PUT contract (the handler decodes into a full domain.Issuer struct);
// type + config are preserved by reading them from the editing target.
const updateMutation = useMutation({
mutationFn: ({ id, data }: { id: string; data: Partial<Issuer> }) => updateIssuer(id, data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['issuers'] });
setEditingIssuer(null);
},
});
const catalogStatus = useMemo( const catalogStatus = useMemo(
() => getIssuerCatalogStatus(data?.data || []), () => getIssuerCatalogStatus(data?.data || []),
[data?.data] [data?.data]
@@ -129,6 +160,12 @@ export default function IssuersPage() {
> >
Test Test
</button> </button>
<button
onClick={(e) => { e.stopPropagation(); setEditingIssuer(i); }}
className="text-xs text-brand-400 hover:text-brand-500 transition-colors"
>
Edit
</button>
<button <button
onClick={(e) => { e.stopPropagation(); if (confirm(`Delete issuer ${i.name}?`)) deleteMutation.mutate(i.id); }} onClick={(e) => { e.stopPropagation(); if (confirm(`Delete issuer ${i.name}?`)) deleteMutation.mutate(i.id); }}
className="text-xs text-red-600 hover:text-red-700 transition-colors" className="text-xs text-red-600 hover:text-red-700 transition-colors"
@@ -244,10 +281,91 @@ export default function IssuersPage() {
isSubmitting={createMutation.isPending} isSubmitting={createMutation.isPending}
/> />
)} )}
{/* B-1 closure: EditIssuerModal — rename-only. */}
<EditIssuerModal
issuer={editingIssuer}
onClose={() => setEditingIssuer(null)}
onSave={(name) => {
if (!editingIssuer) return;
updateMutation.mutate({
id: editingIssuer.id,
data: {
name,
// Preserve type + config + enabled — the rename-only
// contract. Credential blob stays encrypted at rest.
type: editingIssuer.type,
config: editingIssuer.config,
enabled: editingIssuer.enabled,
},
});
}}
isSaving={updateMutation.isPending}
error={updateMutation.error ? (updateMutation.error as Error).message : null}
/>
</> </>
); );
} }
// ─── EditIssuerModal — rename-only Edit modal (B-1) ─────────────
//
// Locked: type, config (credentials), enabled. Editable: name only.
// The audit's "destructive rename workflow" complaint is specifically
// about renames; B-1 closes that hazard. Credential rotation still
// requires delete-and-recreate (see CHANGELOG B-1 known follow-ups).
interface EditIssuerModalProps {
issuer: Issuer | null;
onClose: () => void;
onSave: (name: string) => void;
isSaving: boolean;
error: string | null;
}
function EditIssuerModal({ issuer, onClose, onSave, isSaving, error }: EditIssuerModalProps) {
const [name, setName] = useState('');
useEffect(() => { if (issuer) setName(issuer.name); }, [issuer]);
if (!issuer) return null;
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (!name.trim()) return;
onSave(name.trim());
};
return (
<div className="fixed inset-0 bg-black/40 flex items-center justify-center z-50" onClick={onClose}>
<div className="bg-surface border border-surface-border rounded p-5 w-full max-w-md shadow-xl" onClick={e => e.stopPropagation()}>
<h2 className="text-lg font-semibold text-ink mb-4">Edit Issuer</h2>
<p className="text-xs text-ink-muted mb-4 font-mono">{issuer.id}</p>
{error && <div className="mb-4 p-3 bg-red-50 border border-red-200 rounded text-sm text-red-700">{error}</div>}
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label className="block text-sm font-medium text-ink mb-1">Name *</label>
<input value={name} onChange={e => setName(e.target.value)} required
className="w-full bg-white border border-surface-border rounded px-3 py-2 text-sm text-ink focus:outline-none focus:border-brand-400" />
</div>
<div>
<label className="block text-sm font-medium text-ink-muted mb-1">Type (locked)</label>
<input value={issuer.type} disabled
className="w-full bg-surface-border/50 border border-surface-border rounded px-3 py-2 text-sm text-ink-muted font-mono" />
<p className="text-xs text-ink-faint mt-1">
To change issuer type or rotate credentials, delete and recreate.
See CHANGELOG B-1 known follow-ups.
</p>
</div>
<div className="flex gap-2 pt-4">
<button type="submit" disabled={isSaving} className="flex-1 btn btn-primary disabled:opacity-50 disabled:cursor-not-allowed">
{isSaving ? 'Saving...' : 'Save Changes'}
</button>
<button type="button" onClick={onClose} className="flex-1 btn btn-ghost">Cancel</button>
</div>
</form>
</div>
</div>
);
}
// ─── Catalog Card ─────────────────────────────────────────────── // ─── Catalog Card ───────────────────────────────────────────────
interface CatalogCardProps { interface CatalogCardProps {
+3 -2
View File
@@ -50,12 +50,14 @@ function renderWithQuery(ui: ReactNode) {
); );
} }
// D-2 (master): pre-D-2 these mocks set `subject:` — the field was a TS
// phantom the Go-side struct never emitted. Post-D-2 the phantom is
// removed from the Notification interface; the mocks no longer set it.
const pendingNotif = { const pendingNotif = {
id: 'notif-001', id: 'notif-001',
type: 'ExpirationWarning', type: 'ExpirationWarning',
channel: 'Email', channel: 'Email',
recipient: 'admin@example.com', recipient: 'admin@example.com',
subject: 'Certificate expiring',
message: 'Certificate expiring in 7 days', message: 'Certificate expiring in 7 days',
status: 'Pending', status: 'Pending',
certificate_id: 'mc-prod-001', certificate_id: 'mc-prod-001',
@@ -67,7 +69,6 @@ const deadNotif = {
type: 'ExpirationWarning', type: 'ExpirationWarning',
channel: 'Email', channel: 'Email',
recipient: 'admin@example.com', recipient: 'admin@example.com',
subject: 'Certificate expiring',
message: 'Certificate expiring in 7 days', message: 'Certificate expiring in 7 days',
status: 'dead', status: 'dead',
certificate_id: 'mc-prod-001', certificate_id: 'mc-prod-001',
+7 -1
View File
@@ -238,7 +238,13 @@ function NotificationRow({
<StatusBadge status={n.status} /> <StatusBadge status={n.status} />
<span className="text-xs text-ink-faint">{n.channel}</span> <span className="text-xs text-ink-faint">{n.channel}</span>
</div> </div>
<p className="text-xs text-ink-muted truncate">{n.message || n.subject}</p> {/* D-2 (master): pre-D-2 the fallback was `{n.message || n.subject}`,
but `subject` was a TS phantom the Go struct never emitted
(`internal/domain/notification.go::NotificationEvent` has only
`message`). The fallback always fell through to `message`
because `subject` was always undefined. Post-D-2 the dead
fallback is dropped along with the phantom field. */}
<p className="text-xs text-ink-muted truncate">{n.message}</p>
{isDead && ( {isDead && (
<div className="flex items-center gap-3 mt-1 text-xs"> <div className="flex items-center gap-3 mt-1 text-xs">
<span className="text-ink-faint"> <span className="text-ink-faint">
+129 -8
View File
@@ -1,6 +1,6 @@
import { useState } from 'react'; import { useEffect, useState } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { getOwners, getTeams, deleteOwner, createOwner } from '../api/client'; import { getOwners, getTeams, deleteOwner, createOwner, updateOwner } from '../api/client';
import PageHeader from '../components/PageHeader'; import PageHeader from '../components/PageHeader';
import DataTable from '../components/DataTable'; import DataTable from '../components/DataTable';
import type { Column } from '../components/DataTable'; import type { Column } from '../components/DataTable';
@@ -102,9 +102,103 @@ function CreateOwnerModal({ isOpen, onClose, onSuccess, isLoading, error, teamsD
); );
} }
// EditOwnerModal — B-1 master closure (cat-b-31ceb6aaa9f1). Pre-B-1 the
// only way to rename an owner was delete-and-recreate, which destroyed
// audit history and broke every cert that referenced the old owner_id.
// Mirrors CreateOwnerModal shape; pre-populates from the editing owner;
// calls updateOwner(id, fields) instead of createOwner.
interface EditOwnerModalProps {
owner: Owner | null;
onClose: () => void;
onSuccess: () => void;
isLoading: boolean;
error: string | null;
teamsData?: { data: Team[] };
}
function EditOwnerModal({ owner, onClose, onSuccess, isLoading, error, teamsData }: EditOwnerModalProps) {
const [name, setName] = useState('');
const [email, setEmail] = useState('');
const [teamId, setTeamId] = useState('');
// Reset form fields whenever the editing target changes (modal opens).
useEffect(() => {
if (owner) {
setName(owner.name);
setEmail(owner.email);
setTeamId(owner.team_id || '');
}
}, [owner]);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!owner || !name.trim() || !email.trim()) return;
await updateOwner(owner.id, {
name: name.trim(),
email: email.trim(),
team_id: teamId || undefined,
});
onSuccess();
};
if (!owner) return null;
const teams = teamsData?.data || [];
return (
<div className="fixed inset-0 bg-black/40 flex items-center justify-center z-50" onClick={onClose}>
<div className="bg-surface border border-surface-border rounded p-5 w-full max-w-md shadow-xl" onClick={e => e.stopPropagation()}>
<h2 className="text-lg font-semibold text-ink mb-4">Edit Owner</h2>
<p className="text-xs text-ink-muted mb-4 font-mono">{owner.id}</p>
{error && <div className="mb-4 p-3 bg-red-50 border border-red-200 rounded text-sm text-red-700">{error}</div>}
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label className="block text-sm font-medium text-ink mb-1">Name *</label>
<input
value={name}
onChange={e => setName(e.target.value)}
className="w-full bg-white border border-surface-border rounded px-3 py-2 text-sm text-ink focus:outline-none focus:border-brand-400"
required
/>
</div>
<div>
<label className="block text-sm font-medium text-ink mb-1">Email *</label>
<input
type="email"
value={email}
onChange={e => setEmail(e.target.value)}
className="w-full bg-white border border-surface-border rounded px-3 py-2 text-sm text-ink focus:outline-none focus:border-brand-400"
required
/>
</div>
<div>
<label className="block text-sm font-medium text-ink mb-1">Team</label>
<select
value={teamId}
onChange={e => setTeamId(e.target.value)}
className="w-full bg-white border border-surface-border rounded px-3 py-2 text-sm text-ink focus:outline-none focus:border-brand-400"
>
<option value="">Unassigned</option>
{teams.map(team => (
<option key={team.id} value={team.id}>{team.name}</option>
))}
</select>
</div>
<div className="flex gap-2 pt-4">
<button type="submit" disabled={isLoading} className="flex-1 btn btn-primary disabled:opacity-50 disabled:cursor-not-allowed">
{isLoading ? 'Saving...' : 'Save Changes'}
</button>
<button type="button" onClick={onClose} className="flex-1 btn btn-ghost">Cancel</button>
</div>
</form>
</div>
</div>
);
}
export default function OwnersPage() { export default function OwnersPage() {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const [showCreate, setShowCreate] = useState(false); const [showCreate, setShowCreate] = useState(false);
const [editingOwner, setEditingOwner] = useState<Owner | null>(null);
const { data, isLoading, error, refetch } = useQuery({ const { data, isLoading, error, refetch } = useQuery({
queryKey: ['owners'], queryKey: ['owners'],
@@ -130,6 +224,14 @@ export default function OwnersPage() {
}, },
}); });
const updateMutation = useMutation({
mutationFn: ({ id, data }: { id: string; data: Partial<Owner> }) => updateOwner(id, data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['owners'] });
setEditingOwner(null);
},
});
const teamMap = new Map<string, Team>(); const teamMap = new Map<string, Team>();
(teamsData?.data || []).forEach((t) => teamMap.set(t.id, t)); (teamsData?.data || []).forEach((t) => teamMap.set(t.id, t));
@@ -168,12 +270,20 @@ export default function OwnersPage() {
key: 'actions', key: 'actions',
label: '', label: '',
render: (o) => ( render: (o) => (
<button <div className="flex gap-3 justify-end">
onClick={(e) => { e.stopPropagation(); if (confirm(`Delete owner ${o.name}?`)) deleteMutation.mutate(o.id); }} <button
className="text-xs text-red-600 hover:text-red-700 transition-colors" onClick={(e) => { e.stopPropagation(); setEditingOwner(o); }}
> className="text-xs text-brand-400 hover:text-brand-500 transition-colors"
Delete >
</button> Edit
</button>
<button
onClick={(e) => { e.stopPropagation(); if (confirm(`Delete owner ${o.name}?`)) deleteMutation.mutate(o.id); }}
className="text-xs text-red-600 hover:text-red-700 transition-colors"
>
Delete
</button>
</div>
), ),
}, },
]; ];
@@ -207,6 +317,17 @@ export default function OwnersPage() {
error={createMutation.error ? (createMutation.error as Error).message : null} error={createMutation.error ? (createMutation.error as Error).message : null}
teamsData={teamsData} teamsData={teamsData}
/> />
<EditOwnerModal
owner={editingOwner}
onClose={() => setEditingOwner(null)}
onSuccess={() => {
queryClient.invalidateQueries({ queryKey: ['owners'] });
setEditingOwner(null);
}}
isLoading={updateMutation.isPending}
error={updateMutation.error ? (updateMutation.error as Error).message : null}
teamsData={teamsData}
/>
</> </>
); );
} }
+119 -8
View File
@@ -1,6 +1,6 @@
import { useState } from 'react'; import { useEffect, useState } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { getProfiles, deleteProfile, createProfile } from '../api/client'; import { getProfiles, deleteProfile, createProfile, updateProfile } from '../api/client';
import PageHeader from '../components/PageHeader'; import PageHeader from '../components/PageHeader';
import DataTable from '../components/DataTable'; import DataTable from '../components/DataTable';
import type { Column } from '../components/DataTable'; import type { Column } from '../components/DataTable';
@@ -288,6 +288,12 @@ function CreateProfileModal({ isOpen, onClose, onSuccess, isLoading, error }: Cr
export default function ProfilesPage() { export default function ProfilesPage() {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const [showCreate, setShowCreate] = useState(false); const [showCreate, setShowCreate] = useState(false);
// B-1 master closure (cat-b-7a34f893a8f9): rename + description Edit
// affordance. Deeper policy fields (allowed_ekus, max_ttl_seconds,
// allowed_key_algorithms, etc.) stay on the delete-and-recreate path
// for v1 — closing the audit's destructive-rename complaint requires
// only the simple metadata edit. Documented as a follow-up.
const [editingProfile, setEditingProfile] = useState<CertificateProfile | null>(null);
const { data, isLoading, error, refetch } = useQuery({ const { data, isLoading, error, refetch } = useQuery({
queryKey: ['profiles'], queryKey: ['profiles'],
@@ -307,6 +313,14 @@ export default function ProfilesPage() {
}, },
}); });
const updateMutation = useMutation({
mutationFn: ({ id, data }: { id: string; data: Partial<CertificateProfile> }) => updateProfile(id, data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['profiles'] });
setEditingProfile(null);
},
});
const columns: Column<CertificateProfile>[] = [ const columns: Column<CertificateProfile>[] = [
{ {
key: 'name', key: 'name',
@@ -382,12 +396,20 @@ export default function ProfilesPage() {
key: 'actions', key: 'actions',
label: '', label: '',
render: (p) => ( render: (p) => (
<button <div className="flex gap-3 justify-end">
onClick={(e) => { e.stopPropagation(); if (confirm(`Delete profile ${p.name}?`)) deleteMutation.mutate(p.id); }} <button
className="text-xs text-red-600 hover:text-red-700 transition-colors" onClick={(e) => { e.stopPropagation(); setEditingProfile(p); }}
> className="text-xs text-brand-400 hover:text-brand-500 transition-colors"
Delete >
</button> Edit
</button>
<button
onClick={(e) => { e.stopPropagation(); if (confirm(`Delete profile ${p.name}?`)) deleteMutation.mutate(p.id); }}
className="text-xs text-red-600 hover:text-red-700 transition-colors"
>
Delete
</button>
</div>
), ),
}, },
]; ];
@@ -420,6 +442,95 @@ export default function ProfilesPage() {
isLoading={createMutation.isPending} isLoading={createMutation.isPending}
error={createMutation.error ? (createMutation.error as Error).message : null} error={createMutation.error ? (createMutation.error as Error).message : null}
/> />
<EditProfileModal
profile={editingProfile}
onClose={() => setEditingProfile(null)}
onSave={(data) => {
if (!editingProfile) return;
updateMutation.mutate({ id: editingProfile.id, data });
}}
isSaving={updateMutation.isPending}
error={updateMutation.error ? (updateMutation.error as Error).message : null}
/>
</> </>
); );
} }
// EditProfileModal — B-1 closure (cat-b-7a34f893a8f9). Rename +
// description only. Deeper policy fields (allowed_ekus, max_ttl_seconds,
// allowed_key_algorithms, required_san_patterns, spiffe_uri_pattern,
// allow_short_lived) stay on delete-and-recreate for v1 — closing the
// audit's destructive-rename complaint requires only the simple
// metadata edit. The PUT contract takes a full Partial<CertificateProfile>
// so we forward the existing policy fields untouched.
interface EditProfileModalProps {
profile: CertificateProfile | null;
onClose: () => void;
onSave: (data: Partial<CertificateProfile>) => void;
isSaving: boolean;
error: string | null;
}
function EditProfileModal({ profile, onClose, onSave, isSaving, error }: EditProfileModalProps) {
const [name, setName] = useState('');
const [description, setDescription] = useState('');
useEffect(() => {
if (profile) {
setName(profile.name);
setDescription(profile.description || '');
}
}, [profile]);
if (!profile) return null;
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (!name.trim()) return;
onSave({
// Pass the full struct minus id/timestamps. Backend PUT needs the
// policy fields preserved so we forward them from the editing target.
name: name.trim(),
description: description.trim(),
allowed_key_algorithms: profile.allowed_key_algorithms,
max_ttl_seconds: profile.max_ttl_seconds,
allowed_ekus: profile.allowed_ekus,
required_san_patterns: profile.required_san_patterns,
spiffe_uri_pattern: profile.spiffe_uri_pattern,
allow_short_lived: profile.allow_short_lived,
enabled: profile.enabled,
});
};
return (
<div className="fixed inset-0 bg-black/40 flex items-center justify-center z-50" onClick={onClose}>
<div className="bg-surface border border-surface-border rounded p-5 w-full max-w-md shadow-xl" onClick={e => e.stopPropagation()}>
<h2 className="text-lg font-semibold text-ink mb-4">Edit Profile</h2>
<p className="text-xs text-ink-muted mb-4 font-mono">{profile.id}</p>
{error && <div className="mb-4 p-3 bg-red-50 border border-red-200 rounded text-sm text-red-700">{error}</div>}
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label className="block text-sm font-medium text-ink mb-1">Name *</label>
<input value={name} onChange={e => setName(e.target.value)} required
className="w-full bg-white border border-surface-border rounded px-3 py-2 text-sm text-ink focus:outline-none focus:border-brand-400" />
</div>
<div>
<label className="block text-sm font-medium text-ink mb-1">Description</label>
<textarea value={description} onChange={e => setDescription(e.target.value)} rows={2}
className="w-full bg-white border border-surface-border rounded px-3 py-2 text-sm text-ink focus:outline-none focus:border-brand-400" />
</div>
<p className="text-xs text-ink-faint">
Policy fields (TTL, EKUs, key algorithms, SAN patterns) stay on the
create-recreate path for v1. See CHANGELOG B-1 known follow-ups.
</p>
<div className="flex gap-2 pt-4">
<button type="submit" disabled={isSaving} className="flex-1 btn btn-primary disabled:opacity-50 disabled:cursor-not-allowed">
{isSaving ? 'Saving...' : 'Save Changes'}
</button>
<button type="button" onClick={onClose} className="flex-1 btn btn-ghost">Cancel</button>
</div>
</form>
</div>
</div>
);
}

Some files were not shown because too many files have changed in this diff Show More