mirror of
https://github.com/shankar0123/certctl.git
synced 2026-06-07 16:01:30 +00:00
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:
@@ -129,6 +129,64 @@ export const bulkRevokeCertificates = (criteria: BulkRevokeCriteria) =>
|
||||
body: JSON.stringify(criteria),
|
||||
});
|
||||
|
||||
// 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
|
||||
export const exportCertificatePEM = (id: string) =>
|
||||
fetchJSON<{ cert_pem: string; chain_pem: string; full_pem: string }>(`${BASE}/certificates/${id}/export/pem`);
|
||||
|
||||
@@ -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 (
|
||||
@@ -405,20 +421,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 × ~50–200ms each = 5–20s 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>[] = [
|
||||
|
||||
Reference in New Issue
Block a user