Compare commits

...

24 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
86 changed files with 4748 additions and 285 deletions
+381 -1
View File
@@ -270,7 +270,7 @@ jobs:
exit 1
fi
- name: Forbidden StatusBadge dead-key + Certificate phantom-field regression guard (D-1)
- 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
@@ -344,6 +344,216 @@ jobs:
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
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
@@ -437,6 +647,176 @@ jobs:
working-directory: web
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:
name: Helm Chart Validation
runs-on: ubuntu-latest
+134
View File
@@ -4,6 +4,140 @@ All notable changes to certctl are documented in this file. Dates use ISO 8601.
## [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.
+173
View File
@@ -470,6 +470,69 @@ paths:
"500":
$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 ──────────────────────────────────────────────
/api/v1/certificates/{id}/export/pem:
get:
@@ -3657,6 +3720,116 @@ components:
type: string
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 ─────────────────────────────────────────────────────
IssuerType:
type: string
+58 -5
View File
@@ -411,6 +411,14 @@ func main() {
// Initialize bulk revocation service
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
statsService := service.NewStatsService(certificateRepo, jobRepo, agentRepo)
// I-005: wire the notification repository so DashboardSummary.NotificationsDead
@@ -456,6 +464,11 @@ func main() {
exportHandler := handler.NewExportHandler(exportService)
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)
var digestService *service.DigestService
@@ -532,6 +545,16 @@ func main() {
// because they share the NotificationServicer dependency (same placement
// pattern as I-001's SetJobRetryInterval above).
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 {
sched.SetNetworkScanInterval(cfg.NetworkScan.ScanInterval)
logger.Info("network scanning enabled", "interval", cfg.NetworkScan.ScanInterval.String())
@@ -595,8 +618,10 @@ func main() {
Export: exportHandler,
Digest: *digestHandler,
HealthChecks: healthCheckHandler,
BulkRevocation: bulkRevocationHandler,
Version: versionHandler,
BulkRevocation: bulkRevocationHandler,
BulkRenewal: bulkRenewalHandler,
BulkReassignment: bulkReassignmentHandler,
Version: versionHandler,
})
// Register EST (RFC 7030) handlers if enabled
if cfg.EST.Enabled {
@@ -726,6 +751,17 @@ func main() {
})
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
auditAdapter := middleware.NewAuditServiceAdapter(
func(ctx context.Context, actor string, actorType string, action string, resourceType string, resourceID string, details map[string]interface{}) error {
@@ -748,6 +784,7 @@ func main() {
structuredLogger,
middleware.Recovery,
bodyLimitMiddleware,
securityHeadersMiddleware,
corsMiddleware,
authMiddleware,
auditMiddleware.Middleware,
@@ -794,13 +831,29 @@ func main() {
if _, err := os.Stat(webDir + "/index.html"); err != nil {
webDir = "./web"
}
// Health/ready routes bypass the full middleware stack (no auth required).
// These are registered on the inner router without auth, but the outer
// middleware chain wraps everything. Route them directly to the inner router.
// Health/ready routes + EST/SCEP/PKI unauth surface bypass the full
// middleware stack (no auth required). These are registered on the
// 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,
middleware.RequestID,
structuredLogger,
middleware.Recovery,
bodyLimitMiddleware,
securityHeadersMiddleware,
)
dashboardEnabled := false
+6
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.
**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
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
- 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
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`.
+1 -1
View File
@@ -111,7 +111,7 @@ The full walkthrough — including profile-based issuer assignment, testing with
## 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).
+46 -27
View File
@@ -8,17 +8,30 @@ Complete reference of every feature shipped in certctl through v2.1.0 (April 202
| Metric | Count |
|---|---|
| HTTP routes | 107 (103 under `/api/v1/` + 4 EST) |
| OpenAPI 3.1 operations | 97 |
| MCP tools | 80 |
| CLI commands | 12 |
| Issuer connectors | 9 (+ EST server) |
| Target connectors | 14 |
| Notifier connectors | 6 channels |
| Database tables | 21 (across 10 migrations) |
| Background scheduler loops | 12 (8 always-on + 4 opt-in) |
| Web dashboard pages | 24 |
| Test functions | 1850+ |
<!--
S-1 master closure (cat-s1-9ce1cbe26876, cat-s1-features_md_issuer_count_contradiction):
every numeric count below is captured at the time of the last edit AND
paired with the source-of-truth grep command from CLAUDE.md. CLAUDE.md
rule: "Numeric claims about current state rot the instant the next
release lands." Re-derive before each release; the CI guardrail at
.github/workflows/ci.yml::"Forbidden hardcoded source-count prose
regression guard (S-1)" fails the build on any new prose-only counts
without an adjacent rebuild command.
-->
| 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 |
---
@@ -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) -->
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
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
<!-- 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
@@ -616,9 +629,9 @@ For Let's Encrypt 6-day `shortlived` certificates, ARI is the expected renewal p
## 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
@@ -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 |
|---|---|---|---|---|
| Renewal check | 1 hour | Yes | | Check expiring certs, query ARI, create renewal jobs |
| Job processor | 30 seconds | Yes | | Process pending jobs |
| Renewal check | 1 hour | Yes | `CERTCTL_SCHEDULER_RENEWAL_CHECK_INTERVAL` | Check expiring certs, query ARI, create renewal 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 timeout reaper | 10 minutes | Yes | `CERTCTL_JOB_TIMEOUT_INTERVAL` | Fail AwaitingCSR/AwaitingApproval jobs past timeout (I-003) |
| Agent health check | 2 minutes | Yes | | Check agent heartbeat staleness |
| Notification processor | 1 minute | Yes | | Send queued notifications |
| 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 | `CERTCTL_SCHEDULER_AGENT_HEALTH_CHECK_INTERVAL` | Check agent heartbeat staleness |
| 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) |
| 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 |
| 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) |
@@ -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.
- 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`)
- Dynamic `sync.RWMutex`-guarded `IssuerRegistry` — rebuilds without server restart
- 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
<!-- 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
@@ -1207,6 +1220,10 @@ Latching state prevents refetch-driven dismissal. `localStorage` dismissal key:
`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
| 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.
- 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
- Typed input structs with `jsonschema` struct tags for automatic schema generation
- Binary response support (DER CRL, OCSP)
@@ -1356,7 +1373,9 @@ Config via `values.yaml`. Secrets for API key, database password, SMTP password.
<!-- 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
@@ -522,7 +522,7 @@ func TestRevokeCertificate_AlreadyRevoked(t *testing.T) {
func TestRevokeCertificate_NotFound(t *testing.T) {
handler, mock := newCertHandlerWithMock()
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"}`))
@@ -33,7 +33,7 @@ func (m *MockAgentGroupService) GetAgentGroup(_ context.Context, id string) (*do
if m.GetAgentGroupFn != nil {
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) {
+4 -2
View File
@@ -1,6 +1,8 @@
package handler
import (
"github.com/shankar0123/certctl/internal/repository"
"errors"
"context"
"encoding/json"
"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)
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)
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 strings.Contains(err.Error(), "not found") {
if errors.Is(err, repository.ErrNotFound) {
ErrorWithRequestID(w, http.StatusNotFound, "Agent group not found", requestID)
return
}
@@ -3,7 +3,6 @@ package handler
import (
"context"
"encoding/json"
"errors"
"net/http"
"net/http/httptest"
"testing"
@@ -142,7 +141,9 @@ func TestRetireAgentHandler_Sentinel_403(t *testing.T) {
func TestRetireAgentHandler_NotFound_404(t *testing.T) {
mock, handler := agentRetireTestSetup()
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)
+3 -2
View File
@@ -1,6 +1,7 @@
package handler
import (
"github.com/shankar0123/certctl/internal/repository"
"context"
"encoding/json"
"errors"
@@ -211,7 +212,7 @@ func (h AgentHandler) Heartbeat(w http.ResponseWriter, r *http.Request) {
ErrorWithRequestID(w, http.StatusGone, "Agent has been retired", requestID)
return
}
if strings.Contains(err.Error(), "not found") {
if errors.Is(err, repository.ErrNotFound) {
ErrorWithRequestID(w, http.StatusNotFound, "Agent not found", requestID)
return
}
@@ -491,7 +492,7 @@ func (h AgentHandler) RetireAgent(w http.ResponseWriter, r *http.Request) {
JSON(w, http.StatusConflict, body)
return
}
if strings.Contains(err.Error(), "not found") {
if errors.Is(err, repository.ErrNotFound) {
ErrorWithRequestID(w, http.StatusNotFound, "Agent not found", requestID)
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) {
mock := &MockCertificateService{
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" {
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) {
mock := &MockCertificateService{
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" {
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) {
mock := &MockCertificateService{
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) {
mock := &MockCertificateService{
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) {
mock := &MockCertificateService{
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
import (
"errors"
"context"
"encoding/json"
"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)
if err != nil {
if strings.Contains(err.Error(), "not found") {
if errors.Is(err, repository.ErrNotFound) {
ErrorWithRequestID(w, http.StatusNotFound, "Certificate not found", requestID)
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 strings.Contains(err.Error(), "not found") {
if errors.Is(err, repository.ErrNotFound) {
ErrorWithRequestID(w, http.StatusNotFound, "Certificate not found", requestID)
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)
if err != nil {
if strings.Contains(err.Error(), "not found") {
if errors.Is(err, repository.ErrNotFound) {
ErrorWithRequestID(w, http.StatusNotFound, "Certificate not found", requestID)
return
}
@@ -300,7 +300,7 @@ func TestGetDiscovered_Success(t *testing.T) {
if id == "dcert-1" {
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) {
mock := &MockDiscoveryService{
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) {
mock := &MockDiscoveryService{
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" {
return nil
}
return fmt.Errorf("not found")
return fmt.Errorf("not found: %w", ErrMockNotFound)
},
}
+4 -2
View File
@@ -1,6 +1,8 @@
package handler
import (
"github.com/shankar0123/certctl/internal/repository"
"errors"
"context"
"encoding/json"
"log/slog"
@@ -46,7 +48,7 @@ func (h ExportHandler) ExportPEM(w http.ResponseWriter, r *http.Request) {
result, err := h.svc.ExportPEM(r.Context(), id)
if err != nil {
if strings.Contains(err.Error(), "not found") {
if errors.Is(err, repository.ErrNotFound) {
ErrorWithRequestID(w, http.StatusNotFound, "Certificate not found", requestID)
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)
if err != nil {
if strings.Contains(err.Error(), "not found") {
if errors.Is(err, repository.ErrNotFound) {
ErrorWithRequestID(w, http.StatusNotFound, "Certificate not found", requestID)
return
}
+2 -2
View File
@@ -110,7 +110,7 @@ func TestExportPEM_Download(t *testing.T) {
func TestExportPEM_NotFound(t *testing.T) {
mockSvc := &MockExportService{
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)
@@ -216,7 +216,7 @@ func TestExportPKCS12_EmptyPassword(t *testing.T) {
func TestExportPKCS12_NotFound(t *testing.T) {
mockSvc := &MockExportService{
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)
+4 -2
View File
@@ -1,6 +1,8 @@
package handler
import (
"github.com/shankar0123/certctl/internal/repository"
"errors"
"context"
"encoding/json"
"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 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)
} else if strings.Contains(err.Error(), "not found") {
} else if errors.Is(err, repository.ErrNotFound) {
ErrorWithRequestID(w, http.StatusNotFound, "Issuer not found", requestID)
} else {
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) {
mock := &MockJobService{
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) {
mock := &MockJobService{
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
import (
"github.com/shankar0123/certctl/internal/repository"
"context"
"encoding/json"
"errors"
@@ -167,7 +168,7 @@ func (h JobHandler) ApproveJob(w http.ResponseWriter, r *http.Request) {
requestID)
return
}
if strings.Contains(err.Error(), "not found") {
if errors.Is(err, repository.ErrNotFound) {
ErrorWithRequestID(w, http.StatusNotFound, "Job not found", requestID)
return
}
@@ -213,7 +214,7 @@ func (h JobHandler) RejectJob(w http.ResponseWriter, r *http.Request) {
actor := resolveActor(r.Context())
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)
return
}
@@ -27,7 +27,7 @@ func (m *mockNetworkScanService) GetTarget(ctx context.Context, id string) (*dom
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) {
@@ -48,7 +48,7 @@ func (m *mockNetworkScanService) UpdateTarget(ctx context.Context, id string, ta
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 {
@@ -58,7 +58,7 @@ func (m *mockNetworkScanService) DeleteTarget(ctx context.Context, id string) er
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) {
@@ -71,7 +71,7 @@ func (m *mockNetworkScanService) TriggerScan(ctx context.Context, targetID strin
}, nil
}
}
return nil, fmt.Errorf("not found: %s", targetID)
return nil, fmt.Errorf("not found: %w", ErrMockNotFound)
}
func TestListNetworkScanTargets(t *testing.T) {
+3 -1
View File
@@ -1,6 +1,8 @@
package handler
import (
"github.com/shankar0123/certctl/internal/repository"
"errors"
"context"
"net/http"
"strconv"
@@ -170,7 +172,7 @@ func (h NotificationHandler) RequeueNotification(w http.ResponseWriter, r *http.
notificationID := parts[0]
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)
return
}
+4 -2
View File
@@ -1,6 +1,8 @@
package handler
import (
"github.com/shankar0123/certctl/internal/repository"
"errors"
"context"
"encoding/json"
"net/http"
@@ -184,9 +186,9 @@ func (h OwnerHandler) DeleteOwner(w http.ResponseWriter, r *http.Request) {
id = parts[0]
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)
} else if strings.Contains(err.Error(), "not found") {
} else if errors.Is(err, repository.ErrNotFound) {
ErrorWithRequestID(w, http.StatusNotFound, "Owner not found", requestID)
} else {
ErrorWithRequestID(w, http.StatusInternalServerError, "Failed to delete owner", requestID)
+4 -2
View File
@@ -1,6 +1,8 @@
package handler
import (
"github.com/shankar0123/certctl/internal/repository"
"errors"
"context"
"encoding/json"
"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)
if err != nil {
if strings.Contains(err.Error(), "not found") {
if errors.Is(err, repository.ErrNotFound) {
ErrorWithRequestID(w, http.StatusNotFound, "Profile not found", requestID)
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 strings.Contains(err.Error(), "not found") {
if errors.Is(err, repository.ErrNotFound) {
ErrorWithRequestID(w, http.StatusNotFound, "Profile not found", requestID)
return
}
+10 -9
View File
@@ -1,6 +1,7 @@
package handler
import (
"github.com/shankar0123/certctl/internal/repository"
"context"
"encoding/json"
"errors"
@@ -26,14 +27,14 @@ type RenewalPolicyService interface {
// 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
// identity), so `errors.Is` walks transparently across layers. Delete/Update
// not-found detection intentionally uses a `strings.Contains(err.Error(),
// "not found")` substring check — the repo wraps `sql.ErrNoRows` as
// `fmt.Errorf("renewal policy not found: %s", id)` which strips the sentinel,
// and the handler red-tests' `ErrMockNotFound = errors.New("mock not found
// error")` follows the same substring convention.
// identity), so `errors.Is` walks transparently across layers. S-2 closure
// (cat-s6-efc7f6f6bd50) extends the same convention to not-found detection:
// repos now wrap `sql.ErrNoRows` via `fmt.Errorf("X not found: %w",
// repository.ErrNotFound)`, handler dispatch uses
// `errors.Is(err, repository.ErrNotFound)`, and `ErrMockNotFound` in
// test_utils.go wraps the same sentinel so the mocks still resolve to 404.
type RenewalPolicyHandler struct {
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)
return
}
if strings.Contains(err.Error(), "not found") {
if errors.Is(err, repository.ErrNotFound) {
ErrorWithRequestID(w, http.StatusNotFound, "Renewal policy not found", requestID)
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)
return
}
if strings.Contains(err.Error(), "not found") {
if errors.Is(err, repository.ErrNotFound) {
ErrorWithRequestID(w, http.StatusNotFound, "Renewal policy not found", requestID)
return
}
+18 -7
View File
@@ -1,11 +1,22 @@
package handler
import "errors"
import (
"fmt"
var (
// Mock errors for testing
ErrMockServiceFailed = errors.New("mock service error")
ErrMockNotFound = errors.New("mock not found error")
ErrMockUnauthorized = errors.New("mock unauthorized error")
ErrMockConflict = errors.New("mock conflict error")
"github.com/shankar0123/certctl/internal/repository"
)
// Mock errors for testing.
//
// 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")
)
@@ -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")
}
}
+17 -2
View File
@@ -67,7 +67,13 @@ type HandlerRegistry struct {
Digest handler.DigestHandler
HealthChecks *handler.HealthCheckHandler
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
@@ -109,8 +115,17 @@ func (r *Router) RegisterHandlers(reg HandlerRegistry) {
r.Register("GET /api/v1/auth/check", http.HandlerFunc(reg.Health.AuthCheck))
// 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-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("POST /api/v1/certificates", http.HandlerFunc(reg.Certificates.CreateCertificate))
r.Register("GET /api/v1/certificates/{id}", http.HandlerFunc(reg.Certificates.GetCertificate))
+35
View File
@@ -784,6 +784,18 @@ type SchedulerConfig struct {
// second.
// Setting: CERTCTL_JOB_AWAITING_APPROVAL_TIMEOUT environment variable.
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.
@@ -948,6 +960,9 @@ func Load() (*Config, error) {
JobTimeoutInterval: getEnvDuration("CERTCTL_JOB_TIMEOUT_INTERVAL", 10*time.Minute),
AwaitingCSRTimeout: getEnvDuration("CERTCTL_JOB_AWAITING_CSR_TIMEOUT", 24*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{
Level: getEnv("CERTCTL_LOG_LEVEL", "info"),
@@ -1176,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)
}
// 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
if c.Database.URL == "" {
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) {
job, ok := m.jobs[id]
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
}
+87
View File
@@ -214,6 +214,61 @@ func registerCertificateTools(s *gomcp.Server, c *Client) {
}
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 ──────────────────────────────────────────────────────
@@ -1183,4 +1238,36 @@ func registerHealthTools(s *gomcp.Server, c *Client) {
}
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"`
}
// 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 {
ID string `json:"id" jsonschema:"Certificate ID"`
ListParams
@@ -303,6 +323,29 @@ type TimelineInput struct {
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 ───────────────────────────────────────────────────────────
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
import (
"github.com/shankar0123/certctl/internal/repository"
"context"
"database/sql"
"fmt"
@@ -73,7 +74,7 @@ func (r *AgentRepository) Get(ctx context.Context, id string) (*domain.Agent, er
agent, err := scanAgent(row)
if err != nil {
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)
}
@@ -170,7 +171,7 @@ func (r *AgentRepository) Update(ctx context.Context, agent *domain.Agent) error
}
if rows == 0 {
return fmt.Errorf("agent not found")
return fmt.Errorf("agent not found: %w", repository.ErrNotFound)
}
return nil
@@ -190,7 +191,7 @@ func (r *AgentRepository) Delete(ctx context.Context, id string) error {
}
if rows == 0 {
return fmt.Errorf("agent not found")
return fmt.Errorf("agent not found: %w", repository.ErrNotFound)
}
return nil
@@ -237,7 +238,7 @@ func (r *AgentRepository) UpdateHeartbeat(ctx context.Context, id string, metada
}
if rows == 0 {
return fmt.Errorf("agent not found")
return fmt.Errorf("agent not found: %w", repository.ErrNotFound)
}
return nil
@@ -259,7 +260,7 @@ func (r *AgentRepository) GetByAPIKey(ctx context.Context, keyHash string) (*dom
agent, err := scanAgent(row)
if err != nil {
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)
}
+4 -3
View File
@@ -1,6 +1,7 @@
package postgres
import (
"github.com/shankar0123/certctl/internal/repository"
"context"
"database/sql"
"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,
&g.MatchIPCIDR, &g.MatchVersion, &g.Enabled, &g.CreatedAt, &g.UpdatedAt)
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 {
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()
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
}
@@ -97,7 +98,7 @@ func (r *AgentGroupRepository) Delete(ctx context.Context, id string) error {
}
rows, _ := result.RowsAffected()
if rows == 0 {
return fmt.Errorf("agent group not found: %s", id)
return fmt.Errorf("agent group not found: %w", repository.ErrNotFound)
}
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)
if err != nil {
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)
}
@@ -397,7 +397,7 @@ func (r *CertificateRepository) Update(ctx context.Context, cert *domain.Managed
}
if rows == 0 {
return fmt.Errorf("certificate not found")
return fmt.Errorf("certificate not found: %w", repository.ErrNotFound)
}
return nil
@@ -419,7 +419,7 @@ func (r *CertificateRepository) Archive(ctx context.Context, id string) error {
}
if rows == 0 {
return fmt.Errorf("certificate not found")
return fmt.Errorf("certificate not found: %w", repository.ErrNotFound)
}
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,
)
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 {
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,
)
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 {
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()
if rowsAffected == 0 {
return fmt.Errorf("discovered certificate not found: %s", id)
return fmt.Errorf("discovered certificate not found: %w", repository.ErrNotFound)
}
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,
)
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 {
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,
)
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 {
return nil, fmt.Errorf("get health check by endpoint: %w", err)
+4 -3
View File
@@ -1,6 +1,7 @@
package postgres
import (
"github.com/shankar0123/certctl/internal/repository"
"context"
"database/sql"
"fmt"
@@ -69,7 +70,7 @@ func (r *IssuerRepository) Get(ctx context.Context, id string) (*domain.Issuer,
if err != nil {
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)
}
@@ -169,7 +170,7 @@ func (r *IssuerRepository) Update(ctx context.Context, issuer *domain.Issuer) er
}
if rows == 0 {
return fmt.Errorf("issuer not found")
return fmt.Errorf("issuer not found: %w", repository.ErrNotFound)
}
return nil
@@ -189,7 +190,7 @@ func (r *IssuerRepository) Delete(ctx context.Context, id string) error {
}
if rows == 0 {
return fmt.Errorf("issuer not found")
return fmt.Errorf("issuer not found: %w", repository.ErrNotFound)
}
return nil
+5 -4
View File
@@ -1,6 +1,7 @@
package postgres
import (
"github.com/shankar0123/certctl/internal/repository"
"context"
"database/sql"
"fmt"
@@ -62,7 +63,7 @@ func (r *JobRepository) Get(ctx context.Context, id string) (*domain.Job, error)
job, err := scanJob(row)
if err != nil {
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)
}
@@ -123,7 +124,7 @@ func (r *JobRepository) Update(ctx context.Context, job *domain.Job) error {
}
if rows == 0 {
return fmt.Errorf("job not found")
return fmt.Errorf("job not found: %w", repository.ErrNotFound)
}
return nil
@@ -143,7 +144,7 @@ func (r *JobRepository) Delete(ctx context.Context, id string) error {
}
if rows == 0 {
return fmt.Errorf("job not found")
return fmt.Errorf("job not found: %w", repository.ErrNotFound)
}
return nil
@@ -232,7 +233,7 @@ func (r *JobRepository) UpdateStatus(ctx context.Context, id string, status doma
}
if rows == 0 {
return fmt.Errorf("job not found")
return fmt.Errorf("job not found: %w", repository.ErrNotFound)
}
return nil
+4 -3
View File
@@ -1,6 +1,7 @@
package postgres
import (
"github.com/shankar0123/certctl/internal/repository"
"context"
"database/sql"
"fmt"
@@ -68,7 +69,7 @@ func (r *NetworkScanRepository) Get(ctx context.Context, id string) (*domain.Net
&target.CreatedAt, &target.UpdatedAt,
)
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 {
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()
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
}
@@ -130,7 +131,7 @@ func (r *NetworkScanRepository) Delete(ctx context.Context, id string) error {
}
rows, _ := result.RowsAffected()
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
}
+4 -4
View File
@@ -174,7 +174,7 @@ func (r *NotificationRepository) UpdateStatus(ctx context.Context, id string, st
}
if rows == 0 {
return fmt.Errorf("notification not found")
return fmt.Errorf("notification not found: %w", repository.ErrNotFound)
}
return nil
@@ -336,7 +336,7 @@ func (r *NotificationRepository) RecordFailedAttempt(ctx context.Context, id str
// Same "not found" error shape as UpdateStatus above. The scheduler
// logs-and-continues on this so a concurrently-deleted row doesn't
// break the sweep.
return fmt.Errorf("notification not found")
return fmt.Errorf("notification not found: %w", repository.ErrNotFound)
}
return nil
}
@@ -368,7 +368,7 @@ func (r *NotificationRepository) MarkAsDead(ctx context.Context, id string, last
return fmt.Errorf("failed to get rows affected: %w", err)
}
if rows == 0 {
return fmt.Errorf("notification not found")
return fmt.Errorf("notification not found: %w", repository.ErrNotFound)
}
return nil
}
@@ -405,7 +405,7 @@ func (r *NotificationRepository) Requeue(ctx context.Context, id string) error {
return fmt.Errorf("failed to get rows affected: %w", err)
}
if rows == 0 {
return fmt.Errorf("notification not found")
return fmt.Errorf("notification not found: %w", repository.ErrNotFound)
}
return nil
}
+4 -3
View File
@@ -1,6 +1,7 @@
package postgres
import (
"github.com/shankar0123/certctl/internal/repository"
"context"
"database/sql"
"fmt"
@@ -61,7 +62,7 @@ func (r *OwnerRepository) Get(ctx context.Context, id string) (*domain.Owner, er
if err != nil {
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)
}
@@ -110,7 +111,7 @@ func (r *OwnerRepository) Update(ctx context.Context, owner *domain.Owner) error
}
if rows == 0 {
return fmt.Errorf("owner not found")
return fmt.Errorf("owner not found: %w", repository.ErrNotFound)
}
return nil
@@ -130,7 +131,7 @@ func (r *OwnerRepository) Delete(ctx context.Context, id string) error {
}
if rows == 0 {
return fmt.Errorf("owner not found")
return fmt.Errorf("owner not found: %w", repository.ErrNotFound)
}
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 == 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)
}
@@ -114,7 +114,7 @@ func (r *PolicyRepository) UpdateRule(ctx context.Context, rule *domain.PolicyRu
}
if rows == 0 {
return fmt.Errorf("policy rule not found")
return fmt.Errorf("policy rule not found: %w", repository.ErrNotFound)
}
return nil
@@ -134,7 +134,7 @@ func (r *PolicyRepository) DeleteRule(ctx context.Context, id string) error {
}
if rows == 0 {
return fmt.Errorf("policy rule not found")
return fmt.Errorf("policy rule not found: %w", repository.ErrNotFound)
}
return nil
+4 -3
View File
@@ -1,6 +1,7 @@
package postgres
import (
"github.com/shankar0123/certctl/internal/repository"
"context"
"database/sql"
"encoding/json"
@@ -64,7 +65,7 @@ func (r *ProfileRepository) Get(ctx context.Context, id string) (*domain.Certifi
p, err := scanProfile(row)
if err != nil {
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)
}
@@ -159,7 +160,7 @@ func (r *ProfileRepository) Update(ctx context.Context, profile *domain.Certific
}
if rows == 0 {
return fmt.Errorf("profile not found")
return fmt.Errorf("profile not found: %w", repository.ErrNotFound)
}
return nil
@@ -178,7 +179,7 @@ func (r *ProfileRepository) Delete(ctx context.Context, id string) error {
}
if rows == 0 {
return fmt.Errorf("profile not found")
return fmt.Errorf("profile not found: %w", repository.ErrNotFound)
}
return nil
@@ -72,7 +72,7 @@ func (r *RenewalPolicyRepository) Get(ctx context.Context, id string) (*domain.R
policy, err := scanRenewalPolicy(row)
if err != nil {
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)
}
@@ -252,7 +252,7 @@ func (r *RenewalPolicyRepository) Update(ctx context.Context, id string, policy
updated, err := scanRenewalPolicy(row)
if err != nil {
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) {
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)
}
if rows == 0 {
return fmt.Errorf("renewal policy not found: %s", id)
return fmt.Errorf("renewal policy not found: %w", repository.ErrNotFound)
}
return nil
}
+2 -1
View File
@@ -1,6 +1,7 @@
package postgres
import (
"github.com/shankar0123/certctl/internal/repository"
"context"
"database/sql"
"fmt"
@@ -136,7 +137,7 @@ func (r *RevocationRepository) MarkIssuerNotified(ctx context.Context, id string
}
if rows == 0 {
return fmt.Errorf("revocation not found")
return fmt.Errorf("revocation not found: %w", repository.ErrNotFound)
}
return nil
+4 -3
View File
@@ -1,6 +1,7 @@
package postgres
import (
"github.com/shankar0123/certctl/internal/repository"
"context"
"database/sql"
"fmt"
@@ -89,7 +90,7 @@ func (r *TargetRepository) Get(ctx context.Context, id string) (*domain.Deployme
if err != nil {
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)
}
@@ -174,7 +175,7 @@ func (r *TargetRepository) Update(ctx context.Context, target *domain.Deployment
}
if rows == 0 {
return fmt.Errorf("target not found")
return fmt.Errorf("target not found: %w", repository.ErrNotFound)
}
return nil
@@ -194,7 +195,7 @@ func (r *TargetRepository) Delete(ctx context.Context, id string) error {
}
if rows == 0 {
return fmt.Errorf("target not found")
return fmt.Errorf("target not found: %w", repository.ErrNotFound)
}
return nil
+4 -3
View File
@@ -1,6 +1,7 @@
package postgres
import (
"github.com/shankar0123/certctl/internal/repository"
"context"
"database/sql"
"fmt"
@@ -61,7 +62,7 @@ func (r *TeamRepository) Get(ctx context.Context, id string) (*domain.Team, erro
if err != nil {
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)
}
@@ -108,7 +109,7 @@ func (r *TeamRepository) Update(ctx context.Context, team *domain.Team) error {
}
if rows == 0 {
return fmt.Errorf("team not found")
return fmt.Errorf("team not found: %w", repository.ErrNotFound)
}
return nil
@@ -128,7 +129,7 @@ func (r *TeamRepository) Delete(ctx context.Context, id string) error {
}
if rows == 0 {
return fmt.Errorf("team not found")
return fmt.Errorf("team not found: %w", repository.ErrNotFound)
}
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)
}
}
+2 -5
View File
@@ -6,7 +6,6 @@ import {
createCertificate,
triggerRenewal,
revokeCertificate,
exportCertificatePEM,
downloadCertificatePEM,
exportCertificatePKCS12,
getAgents,
@@ -106,10 +105,8 @@ describe('API Client - Error Handling', () => {
);
});
it('exportCertificatePEM propagates network error', async () => {
mockFetch.mockReturnValueOnce(mockNetworkError());
await expect(exportCertificatePEM('mc-test')).rejects.toThrow('Failed to fetch');
});
// B-1 closure (cat-b-9b97ffb35ef7): exportCertificatePEM removed as a
// dead duplicate of downloadCertificatePEM (zero consumers).
it('downloadCertificatePEM propagates network error', async () => {
mockFetch.mockReturnValueOnce(mockNetworkError());
+2 -10
View File
@@ -13,7 +13,6 @@ import {
archiveCertificate,
revokeCertificate,
bulkRevokeCertificates,
exportCertificatePEM,
downloadCertificatePEM,
exportCertificatePKCS12,
getAgents,
@@ -1151,15 +1150,8 @@ describe('API Client', () => {
// ─── Certificate Export ────────────────────────────────
describe('Certificate Export', () => {
it('exportCertificatePEM fetches PEM data as JSON', async () => {
const pemResult = { cert_pem: 'CERT', chain_pem: 'CHAIN', full_pem: 'FULL' };
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');
});
// B-1 closure (cat-b-9b97ffb35ef7): exportCertificatePEM was removed
// from client.ts as a dead duplicate of downloadCertificatePEM.
it('downloadCertificatePEM fetches blob with download=true', async () => {
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';
// 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)
let apiKey: string | null = null;
@@ -129,10 +158,73 @@ export const bulkRevokeCertificates = (criteria: BulkRevokeCriteria) =>
body: JSON.stringify(criteria),
});
// Certificate Export
export const exportCertificatePEM = (id: string) =>
fetchJSON<{ cert_pem: string; chain_pem: string; full_pem: string }>(`${BASE}/certificates/${id}/export/pem`);
// L-1 master closure (cat-l-fa0c1ac07ab5): bulk renew. Mirrors
// BulkRevokeCriteria field-for-field so operators who already know the
// 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) => {
const headers: Record<string, string> = {};
if (apiKey) headers['Authorization'] = `Bearer ${apiKey}`;
@@ -185,6 +277,17 @@ export const getAgents = (params: Record<string, string> = {}) => {
export const getAgent = (id: string) =>
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>) =>
fetchJSON<Agent>(`${BASE}/agents`, { method: 'POST', body: JSON.stringify(data) });
+261 -11
View File
@@ -1,6 +1,14 @@
import { describe, it, expect } from 'vitest';
import { POLICY_TYPES, POLICY_SEVERITIES } from './types';
import type { Agent, Certificate, CertificateVersion } from './types';
import type {
Agent,
Certificate,
CertificateVersion,
DiscoveredCertificate,
Issuer,
Notification,
Target,
} from './types';
/**
* 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
// them anything other than retired_at / retired_reason, this fails to
// 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 = {
id: 'ag-1',
name: 'decom-01',
@@ -94,13 +105,8 @@ describe('Agent interface (I-004 retirement)', () => {
architecture: 'amd64',
status: 'Offline',
version: '2.1.0',
last_heartbeat: '2026-01-01T00:00:00Z',
last_heartbeat_at: '2026-01-01T00:00:00Z',
capabilities: [],
tags: {},
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_reason: 'old hardware',
};
@@ -111,6 +117,7 @@ describe('Agent interface (I-004 retirement)', () => {
it('accepts an Agent without retired_at / retired_reason (optional fields)', () => {
// Active agents should not carry retirement metadata. If Phase 2b makes
// the fields required, this block fails to compile.
// D-2 (master): post-D-2 Agent shape (see sibling describe block).
const active: Agent = {
id: 'ag-2',
name: 'web01',
@@ -120,13 +127,8 @@ describe('Agent interface (I-004 retirement)', () => {
architecture: 'amd64',
status: 'Online',
version: '2.1.0',
last_heartbeat: '2026-04-18T12:00:00Z',
last_heartbeat_at: '2026-04-18T12:00:00Z',
capabilities: ['deploy', 'scan'],
tags: {},
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_reason).toBeUndefined();
@@ -220,3 +222,251 @@ describe('Certificate interface (D-5 phantom-fields trim)', () => {
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
});
});
+63 -12
View File
@@ -67,6 +67,23 @@ export interface CertificateVersion {
// API contract. See docs/architecture.md ER-diagram note and
// coverage-gap-audit-2026-04-24-v5/unified-audit.md cat-s5-apikey_leak
// 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 {
id: string;
name: string;
@@ -76,13 +93,8 @@ export interface Agent {
architecture: string;
status: string;
version: string;
last_heartbeat: string;
last_heartbeat_at: string;
capabilities: string[];
tags: Record<string, string>;
last_heartbeat_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
// tombstoned — it will never heartbeat again and cascaded targets have been
// retired alongside it. The retired tab on AgentsPage uses these to show the
@@ -159,9 +171,17 @@ export interface Job {
* without chasing server logs.
*
* `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
* correctly without forcing a rewrite of every existing consumer.
*
* D-2 (diff-05x06-caba9eb3620e, master): pre-D-2 this interface carried a
* 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:
* pending · sent · failed · dead · read
@@ -174,7 +194,6 @@ export interface Notification {
type: string;
channel: string;
recipient: string;
subject?: string;
message: string;
status: string;
certificate_id?: string;
@@ -269,13 +288,21 @@ export interface RenewalPolicy {
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 {
id: string;
name: string;
type: string;
config: Record<string, unknown>;
status: string;
/** Backend returns enabled boolean; status is derived from this */
/** Backend returns enabled boolean; render sites derive status labels from this */
enabled: boolean;
/** Timestamp of last connection test */
last_tested_at?: string;
@@ -287,6 +314,14 @@ export interface Issuer {
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 {
id: string;
name: string;
@@ -297,6 +332,8 @@ export interface Target {
last_tested_at?: string;
test_status?: string;
source?: string;
retired_at?: string | null;
retired_reason?: string | null;
created_at: string;
updated_at?: string;
}
@@ -403,6 +440,19 @@ export interface IssuanceRateDataPoint {
}
// 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 {
id: string;
fingerprint_sha256: string;
@@ -416,6 +466,7 @@ export interface DiscoveredCertificate {
key_algorithm: string;
key_size: number;
is_ca: boolean;
pem_data?: string;
source_path: string;
source_format: 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' });
}
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 '—';
const now = Date.now();
const then = new Date(iso).getTime();
+82 -2
View File
@@ -5,6 +5,24 @@ interface Column<T> {
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> {
columns: Column<T>[];
data: T[];
@@ -15,9 +33,10 @@ interface DataTableProps<T> {
selectable?: boolean;
selectedKeys?: Set<string>;
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) {
return (
<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>
</table>
{pagination && pagination.total > 0 && (
<PaginationControls {...pagination} />
)}
</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: '/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: '/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: '/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' },
+2
View File
@@ -14,6 +14,7 @@ import AgentDetailPage from './pages/AgentDetailPage';
import JobsPage from './pages/JobsPage';
import NotificationsPage from './pages/NotificationsPage';
import PoliciesPage from './pages/PoliciesPage';
import RenewalPoliciesPage from './pages/RenewalPoliciesPage';
import IssuersPage from './pages/IssuersPage';
import TargetsPage from './pages/TargetsPage';
import ProfilesPage from './pages/ProfilesPage';
@@ -62,6 +63,7 @@ createRoot(document.getElementById('root')!).render(
<Route path="jobs/:id" element={<JobDetailPage />} />
<Route path="notifications" element={<NotificationsPage />} />
<Route path="policies" element={<PoliciesPage />} />
<Route path="renewal-policies" element={<RenewalPoliciesPage />} />
<Route path="profiles" element={<ProfilesPage />} />
<Route path="issuers" element={<IssuersPage />} />
<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';
const ago = Date.now() - new Date(lastHeartbeat).getTime();
if (ago < 5 * 60 * 1000) return 'Online';
@@ -89,8 +94,15 @@ export default function AgentDetailPage() {
</span>
) : '—'
} />
<InfoRow label="Registered" value={formatDateTime(agent.created_at)} />
<InfoRow label="Updated" value={formatDateTime(agent.updated_at)} />
{/* D-2 (master): pre-D-2 these rows used `agent.created_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>
{/* System Info */}
@@ -100,26 +112,17 @@ export default function AgentDetailPage() {
<InfoRow label="Architecture" value={agent.architecture || '—'} />
<InfoRow label="IP Address" value={<span className="font-mono text-xs">{agent.ip_address || '—'}</span>} />
<InfoRow label="Agent Version" value={agent.version || '—'} />
{agent.capabilities?.length ? (
<div className="mt-4">
<p className="text-xs text-ink-muted mb-2">Capabilities</p>
<div className="flex flex-wrap gap-2">
{agent.capabilities.map((c) => (
<span key={c} className="badge badge-info">{c}</span>
))}
</div>
</div>
) : null}
{agent.tags && Object.keys(agent.tags).length > 0 ? (
<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}
{/* D-2 (master): the previous "Capabilities" + "Tags" sections
rendered `agent.capabilities` and `agent.tags`, both of
which were TS phantom fields the Go-side struct
(`internal/domain/connector.go::Agent`) never emitted.
Both sections always rendered as the empty-state fallback
(the `?.length ?` and `Object.keys(...).length > 0`
guards always evaluated false). Removed in D-2 master.
If/when the backend grows real Agent metadata fields
(capabilities advertised at heartbeat time, operator-
applied tags), re-introduce here in the same commit that
ships the Go-side change. */}
</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 { getAgentGroups, deleteAgentGroup, createAgentGroup } from '../api/client';
import { getAgentGroups, deleteAgentGroup, createAgentGroup, updateAgentGroup } from '../api/client';
import PageHeader from '../components/PageHeader';
import DataTable 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() {
const queryClient = useQueryClient();
const [showCreate, setShowCreate] = useState(false);
const [editingGroup, setEditingGroup] = useState<AgentGroup | null>(null);
const { data, isLoading, error, refetch } = useQuery({
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>[] = [
{
key: 'name',
@@ -214,12 +328,20 @@ export default function AgentGroupsPage() {
key: 'actions',
label: '',
render: (g) => (
<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 className="flex gap-3 justify-end">
<button
onClick={(e) => { e.stopPropagation(); setEditingGroup(g); }}
className="text-xs text-brand-400 hover:text-brand-500 transition-colors"
>
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}
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 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';
const ago = Date.now() - new Date(lastHeartbeat).getTime();
if (ago < 5 * 60 * 1000) return 'Online';
+115 -24
View File
@@ -1,7 +1,7 @@
import { useState } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
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 { REVOCATION_REASONS } from '../api/types';
import PageHeader from '../components/PageHeader';
@@ -311,22 +311,38 @@ function BulkReassignModal({ ids, onClose, onSuccess }: { ids: string[]; onClose
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 () => {
if (!ownerId) return;
setRunning(true);
setError('');
let succeeded = 0;
for (const id of ids) {
try {
await updateCertificate(id, { owner_id: ownerId } as Partial<Certificate>);
succeeded++;
setProgress(succeeded);
} catch (err) {
setError(`Failed on ${id}: ${err instanceof Error ? err.message : 'Unknown error'}`);
break;
setProgress(0);
try {
const result = await bulkReassignCertificates({
certificate_ids: ids,
owner_id: ownerId,
});
setProgress(result.total_reassigned);
if (result.total_failed > 0) {
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 (
@@ -382,6 +398,24 @@ export default function CertificatesPage() {
const [issuerFilter, setIssuerFilter] = useState('');
const [ownerFilter, setOwnerFilter] = 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 [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
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: ownersData } = useQuery({ queryKey: ['owners-filter'], queryFn: () => getOwners({ 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> = {};
if (statusFilter) params.status = statusFilter;
if (envFilter) params.environment = envFilter;
if (issuerFilter) params.issuer_id = issuerFilter;
if (ownerFilter) params.owner_id = ownerFilter;
if (profileFilter) params.profile_id = profileFilter;
if (statusFilter) params.status = statusFilter;
if (envFilter) params.environment = envFilter;
if (issuerFilter) params.issuer_id = issuerFilter;
if (ownerFilter) params.owner_id = ownerFilter;
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({
queryKey: ['certificates', params],
@@ -405,20 +447,32 @@ export default function CertificatesPage() {
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 ids = Array.from(selectedIds);
setBulkRenewProgress({ done: 0, total: ids.length, running: true });
for (let i = 0; i < ids.length; i++) {
try {
await triggerRenewal(ids[i]);
} catch {
// continue on individual failures
}
setBulkRenewProgress({ done: i + 1, total: ids.length, running: i + 1 < ids.length });
try {
const result = await bulkRenewCertificates({ certificate_ids: ids });
setBulkRenewProgress({
done: result.total_enqueued,
total: result.total_matched,
running: false,
});
} catch {
// surface as a "0 of N" terminal state — no retries.
setBulkRenewProgress({ done: 0, total: ids.length, running: false });
}
queryClient.invalidateQueries({ queryKey: ['certificates'] });
setSelectedIds(new Set());
setTimeout(() => setBulkRenewProgress(null), 3000);
setTimeout(() => setBulkRenewProgress(null), 5000);
};
const columns: Column<Certificate>[] = [
@@ -559,6 +613,36 @@ export default function CertificatesPage() {
<option key={p.id} value={p.id}>{p.name}</option>
))}
</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 className="flex-1 overflow-y-auto">
{error ? (
@@ -573,6 +657,13 @@ export default function CertificatesPage() {
selectable
selectedKeys={selectedIds}
onSelectionChange={setSelectedIds}
pagination={{
page,
perPage,
total: data?.total ?? 0,
onPageChange: setPage,
onPerPageChange: (n) => { setPerPage(n); setPage(1); },
}}
/>
)}
</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 {
if (issuer.enabled !== undefined) {
return issuer.enabled ? 'Enabled' : 'Disabled';
}
return issuer.status || 'Unknown';
return issuer.enabled ? 'Enabled' : 'Disabled';
}
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 { 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 DataTable 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 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 {
if (issuer.enabled !== undefined) {
return issuer.enabled ? 'Enabled' : 'Disabled';
}
// Fallback for legacy data that may have status string
return issuer.status || 'Unknown';
return issuer.enabled ? 'Enabled' : 'Disabled';
}
export default function IssuersPage() {
@@ -30,6 +36,18 @@ export default function IssuersPage() {
const [preselectedType, setPreselectedType] = useState<string | null>(null);
const [typeFilter, setTypeFilter] = useState<string>('');
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({
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(
() => getIssuerCatalogStatus(data?.data || []),
[data?.data]
@@ -129,6 +160,12 @@ export default function IssuersPage() {
>
Test
</button>
<button
onClick={(e) => { e.stopPropagation(); setEditingIssuer(i); }}
className="text-xs text-brand-400 hover:text-brand-500 transition-colors"
>
Edit
</button>
<button
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"
@@ -244,10 +281,91 @@ export default function IssuersPage() {
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 ───────────────────────────────────────────────
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 = {
id: 'notif-001',
type: 'ExpirationWarning',
channel: 'Email',
recipient: 'admin@example.com',
subject: 'Certificate expiring',
message: 'Certificate expiring in 7 days',
status: 'Pending',
certificate_id: 'mc-prod-001',
@@ -67,7 +69,6 @@ const deadNotif = {
type: 'ExpirationWarning',
channel: 'Email',
recipient: 'admin@example.com',
subject: 'Certificate expiring',
message: 'Certificate expiring in 7 days',
status: 'dead',
certificate_id: 'mc-prod-001',
+7 -1
View File
@@ -238,7 +238,13 @@ function NotificationRow({
<StatusBadge status={n.status} />
<span className="text-xs text-ink-faint">{n.channel}</span>
</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 && (
<div className="flex items-center gap-3 mt-1 text-xs">
<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 { getOwners, getTeams, deleteOwner, createOwner } from '../api/client';
import { getOwners, getTeams, deleteOwner, createOwner, updateOwner } from '../api/client';
import PageHeader from '../components/PageHeader';
import DataTable 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() {
const queryClient = useQueryClient();
const [showCreate, setShowCreate] = useState(false);
const [editingOwner, setEditingOwner] = useState<Owner | null>(null);
const { data, isLoading, error, refetch } = useQuery({
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>();
(teamsData?.data || []).forEach((t) => teamMap.set(t.id, t));
@@ -168,12 +270,20 @@ export default function OwnersPage() {
key: 'actions',
label: '',
render: (o) => (
<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 className="flex gap-3 justify-end">
<button
onClick={(e) => { e.stopPropagation(); setEditingOwner(o); }}
className="text-xs text-brand-400 hover:text-brand-500 transition-colors"
>
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}
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 { getProfiles, deleteProfile, createProfile } from '../api/client';
import { getProfiles, deleteProfile, createProfile, updateProfile } from '../api/client';
import PageHeader from '../components/PageHeader';
import DataTable 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() {
const queryClient = useQueryClient();
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({
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>[] = [
{
key: 'name',
@@ -382,12 +396,20 @@ export default function ProfilesPage() {
key: 'actions',
label: '',
render: (p) => (
<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 className="flex gap-3 justify-end">
<button
onClick={(e) => { e.stopPropagation(); setEditingProfile(p); }}
className="text-xs text-brand-400 hover:text-brand-500 transition-colors"
>
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}
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>
);
}
+327
View File
@@ -0,0 +1,327 @@
import { useEffect, useState } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import {
getRenewalPolicies,
createRenewalPolicy,
updateRenewalPolicy,
deleteRenewalPolicy,
} from '../api/client';
import PageHeader from '../components/PageHeader';
import DataTable from '../components/DataTable';
import type { Column } from '../components/DataTable';
import ErrorState from '../components/ErrorState';
import { formatDateTime } from '../api/utils';
import type { RenewalPolicy } from '../api/types';
// RenewalPoliciesPage — B-1 master closure (cat-b-4631ca092bee).
// Pre-B-1 the backend had full CRUD at /api/v1/renewal-policies but
// there was no GUI page. Operators wanting to edit the seeded
// `rp-default` policy or create custom `rp-*` policies for short-lived
// certs had to go through `psql` directly. This page exposes the table
// + Create + Edit + Delete affordances. Renewal policies are referenced
// by managed certificates via `renewal_policy_id`; the backend's
// repository.ErrRenewalPolicyInUse sentinel surfaces a 409 on Delete
// when a policy still has cert references — surfaced as an alert here.
//
// Field set per `internal/domain/certificate.go::RenewalPolicy`:
// - renewal_window_days: int (when to start renewal — usually 30)
// - auto_renew: bool (whether the scheduler renews automatically)
// - max_retries: int
// - retry_interval_seconds: int (post-U-3 column rename;
// cat-o-retry_interval_unit_mismatch closed)
// - alert_thresholds_days: int[] (notification days before expiry)
interface PolicyFormFields {
name: string;
renewal_window_days: number;
auto_renew: boolean;
max_retries: number;
retry_interval_seconds: number;
alert_thresholds_days: number[];
}
function defaultFields(): PolicyFormFields {
return {
name: '',
renewal_window_days: 30,
auto_renew: true,
max_retries: 3,
retry_interval_seconds: 60,
alert_thresholds_days: [30, 14, 7, 0],
};
}
function policyToFields(p: RenewalPolicy): PolicyFormFields {
return {
name: p.name,
renewal_window_days: p.renewal_window_days,
auto_renew: p.auto_renew,
max_retries: p.max_retries,
retry_interval_seconds: p.retry_interval_seconds,
alert_thresholds_days: p.alert_thresholds_days || [],
};
}
// PolicyFormModal — shared scaffolding for Create + Edit. The only
// shape difference between the two flows is which mutationFn the
// caller supplies + the modal title; everything else mirrors.
interface PolicyFormModalProps {
title: string;
initial: PolicyFormFields;
isOpen: boolean;
onClose: () => void;
onSubmit: (fields: PolicyFormFields) => void;
isSaving: boolean;
error: string | null;
}
function PolicyFormModal({ title, initial, isOpen, onClose, onSubmit, isSaving, error }: PolicyFormModalProps) {
const [fields, setFields] = useState<PolicyFormFields>(initial);
useEffect(() => {
if (isOpen) setFields(initial);
}, [isOpen, initial]);
if (!isOpen) return null;
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (!fields.name.trim()) return;
onSubmit({ ...fields, name: fields.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 max-h-[90vh] overflow-y-auto" onClick={e => e.stopPropagation()}>
<h2 className="text-lg font-semibold text-ink mb-4">{title}</h2>
{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={fields.name}
onChange={e => setFields({ ...fields, name: 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"
placeholder="e.g., Standard 30-day"
required
/>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block text-sm font-medium text-ink mb-1">Renewal Window (days)</label>
<input
type="number"
value={fields.renewal_window_days}
onChange={e => setFields({ ...fields, renewal_window_days: Number(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"
min={1}
/>
</div>
<div>
<label className="block text-sm font-medium text-ink mb-1">Max Retries</label>
<input
type="number"
value={fields.max_retries}
onChange={e => setFields({ ...fields, max_retries: Number(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"
min={0}
/>
</div>
</div>
<div>
<label className="block text-sm font-medium text-ink mb-1">Retry Interval (seconds)</label>
<input
type="number"
value={fields.retry_interval_seconds}
onChange={e => setFields({ ...fields, retry_interval_seconds: Number(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"
min={0}
/>
</div>
<div>
<label className="block text-sm font-medium text-ink mb-1">Alert Thresholds (days, comma-separated)</label>
<input
value={fields.alert_thresholds_days.join(', ')}
onChange={e => {
const parts = e.target.value
.split(',')
.map(s => Number(s.trim()))
.filter(n => !isNaN(n));
setFields({ ...fields, alert_thresholds_days: parts });
}}
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"
placeholder="30, 14, 7, 0"
/>
</div>
<label className="flex items-center gap-2 text-sm text-ink">
<input
type="checkbox"
checked={fields.auto_renew}
onChange={e => setFields({ ...fields, auto_renew: e.target.checked })}
/>
Auto-renew
</label>
<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'}
</button>
<button type="button" onClick={onClose} className="flex-1 btn btn-ghost">Cancel</button>
</div>
</form>
</div>
</div>
);
}
export default function RenewalPoliciesPage() {
const queryClient = useQueryClient();
const [showCreate, setShowCreate] = useState(false);
const [editing, setEditing] = useState<RenewalPolicy | null>(null);
const { data, isLoading, error, refetch } = useQuery({
queryKey: ['renewal-policies'],
queryFn: () => getRenewalPolicies(),
});
const createMutation = useMutation({
mutationFn: createRenewalPolicy,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['renewal-policies'] });
setShowCreate(false);
},
});
const updateMutation = useMutation({
mutationFn: ({ id, data }: { id: string; data: Partial<RenewalPolicy> }) => updateRenewalPolicy(id, data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['renewal-policies'] });
setEditing(null);
},
});
const deleteMutation = useMutation({
mutationFn: deleteRenewalPolicy,
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['renewal-policies'] }),
// Backend surfaces ErrRenewalPolicyInUse as a 409. We surface as an
// alert so the operator sees "this policy is still attached to N
// certificates" and can re-target those certs to another policy
// before deleting.
onError: (err: Error) => alert(`Delete failed: ${err.message}`),
});
const columns: Column<RenewalPolicy>[] = [
{
key: 'name',
label: 'Policy',
render: (p) => (
<div>
<div className="font-medium text-ink">{p.name}</div>
<div className="text-xs text-ink-faint font-mono">{p.id}</div>
</div>
),
},
{
key: 'window',
label: 'Renewal Window',
render: (p) => <span className="text-sm text-ink">{p.renewal_window_days} days</span>,
},
{
key: 'auto_renew',
label: 'Auto',
render: (p) => (
<span className={p.auto_renew ? 'text-brand-400' : 'text-ink-faint'}>
{p.auto_renew ? 'on' : 'manual'}
</span>
),
},
{
key: 'retries',
label: 'Retries',
render: (p) => <span className="text-sm text-ink-muted">{p.max_retries}× / {p.retry_interval_seconds}s</span>,
},
{
key: 'alerts',
label: 'Alert Thresholds',
render: (p) => (
<span className="text-xs text-ink-muted font-mono">
{(p.alert_thresholds_days || []).join(', ') || '—'}
</span>
),
},
{
key: 'created',
label: 'Created',
render: (p) => <span className="text-xs text-ink-muted">{formatDateTime(p.created_at)}</span>,
},
{
key: 'actions',
label: '',
render: (p) => (
<div className="flex gap-3 justify-end">
<button
onClick={(e) => { e.stopPropagation(); setEditing(p); }}
className="text-xs text-brand-400 hover:text-brand-500 transition-colors"
>
Edit
</button>
<button
onClick={(e) => {
e.stopPropagation();
if (confirm(`Delete renewal policy ${p.name}?`)) deleteMutation.mutate(p.id);
}}
className="text-xs text-red-600 hover:text-red-700 transition-colors"
>
Delete
</button>
</div>
),
},
];
return (
<>
<PageHeader
title="Renewal Policies"
subtitle={data ? `${data.total} policies` : undefined}
action={
<button onClick={() => setShowCreate(true)} className="btn btn-primary">
+ New Policy
</button>
}
/>
<div className="flex-1 overflow-y-auto">
{error ? (
<ErrorState error={error as Error} onRetry={() => refetch()} />
) : (
<DataTable
columns={columns}
data={data?.data || []}
isLoading={isLoading}
emptyMessage="No renewal policies configured"
/>
)}
</div>
<PolicyFormModal
title="Create Renewal Policy"
initial={defaultFields()}
isOpen={showCreate}
onClose={() => setShowCreate(false)}
onSubmit={(fields) => createMutation.mutate(fields)}
isSaving={createMutation.isPending}
error={createMutation.error ? (createMutation.error as Error).message : null}
/>
<PolicyFormModal
title="Edit Renewal Policy"
initial={editing ? policyToFields(editing) : defaultFields()}
isOpen={!!editing}
onClose={() => setEditing(null)}
onSubmit={(fields) => {
if (!editing) return;
updateMutation.mutate({ id: editing.id, data: fields });
}}
isSaving={updateMutation.isPending}
error={updateMutation.error ? (updateMutation.error as Error).message : null}
/>
</>
);
}
+95 -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 { getTeams, deleteTeam, createTeam } from '../api/client';
import { getTeams, deleteTeam, createTeam, updateTeam } from '../api/client';
import PageHeader from '../components/PageHeader';
import DataTable from '../components/DataTable';
import type { Column } from '../components/DataTable';
@@ -82,9 +82,70 @@ function CreateTeamModal({ isOpen, onClose, onSuccess, isLoading, error }: Creat
);
}
// EditTeamModal — B-1 master closure (cat-b-31ceb6aaa9f1). Mirrors
// CreateTeamModal; pre-populates from the editing team; calls
// updateTeam(id, fields) to close the destructive-rename hazard.
interface EditTeamModalProps {
team: Team | null;
onClose: () => void;
onSuccess: () => void;
isLoading: boolean;
error: string | null;
}
function EditTeamModal({ team, onClose, onSuccess, isLoading, error }: EditTeamModalProps) {
const [name, setName] = useState('');
const [description, setDescription] = useState('');
useEffect(() => {
if (team) {
setName(team.name);
setDescription(team.description || '');
}
}, [team]);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!team || !name.trim()) return;
await updateTeam(team.id, { name: name.trim(), description: description.trim() });
onSuccess();
};
if (!team) 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" onClick={e => e.stopPropagation()}>
<h2 className="text-lg font-semibold text-ink mb-4">Edit Team</h2>
<p className="text-xs text-ink-muted mb-4 font-mono">{team.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 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 TeamsPage() {
const queryClient = useQueryClient();
const [showCreate, setShowCreate] = useState(false);
const [editingTeam, setEditingTeam] = useState<Team | null>(null);
const { data, isLoading, error, refetch } = useQuery({
queryKey: ['teams'],
@@ -105,6 +166,14 @@ export default function TeamsPage() {
},
});
const updateMutation = useMutation({
mutationFn: ({ id, data }: { id: string; data: Partial<Team> }) => updateTeam(id, data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['teams'] });
setEditingTeam(null);
},
});
const columns: Column<Team>[] = [
{
key: 'name',
@@ -132,12 +201,20 @@ export default function TeamsPage() {
key: 'actions',
label: '',
render: (t) => (
<button
onClick={(e) => { e.stopPropagation(); if (confirm(`Delete team ${t.name}?`)) deleteMutation.mutate(t.id); }}
className="text-xs text-red-600 hover:text-red-700 transition-colors"
>
Delete
</button>
<div className="flex gap-3 justify-end">
<button
onClick={(e) => { e.stopPropagation(); setEditingTeam(t); }}
className="text-xs text-brand-400 hover:text-brand-500 transition-colors"
>
Edit
</button>
<button
onClick={(e) => { e.stopPropagation(); if (confirm(`Delete team ${t.name}?`)) deleteMutation.mutate(t.id); }}
className="text-xs text-red-600 hover:text-red-700 transition-colors"
>
Delete
</button>
</div>
),
},
];
@@ -170,6 +247,16 @@ export default function TeamsPage() {
isLoading={createMutation.isPending}
error={createMutation.error ? (createMutation.error as Error).message : null}
/>
<EditTeamModal
team={editingTeam}
onClose={() => setEditingTeam(null)}
onSuccess={() => {
queryClient.invalidateQueries({ queryKey: ['teams'] });
setEditingTeam(null);
}}
isLoading={updateMutation.isPending}
error={updateMutation.error ? (updateMutation.error as Error).message : null}
/>
</>
);
}
+9 -2
View File
@@ -1,13 +1,20 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
// C-1 closure (cat-u-vite_dev_proxy_plaintext_drift): pre-C-1 the dev
// proxy targeted http://localhost:8443 against an HTTPS-only backend
// (HTTPS-only since v2.0.47 — see docs/tls.md). Every dev-server API
// call 502'd. Post-C-1 the proxy targets https:// with secure:false
// because the dev cert is self-signed by deploy/test bootstrap and
// changes per-checkout — production stops validation at the reverse
// proxy or load balancer, not the Vite dev server.
export default defineConfig({
plugins: [react()],
server: {
port: 5173,
proxy: {
'/api': 'http://localhost:8443',
'/health': 'http://localhost:8443',
'/api': { target: 'https://localhost:8443', secure: false, changeOrigin: true },
'/health': { target: 'https://localhost:8443', secure: false, changeOrigin: true },
}
},
build: {