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.
This commit is contained in:
shankar0123
2026-04-25 14:33:02 +00:00
parent 677524d9ec
commit f0865bb051
21 changed files with 2094 additions and 23 deletions
+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