feat(V2.2): bulk revocation — filter-based fleet-wide certificate revocation

Add POST /api/v1/certificates/bulk-revoke with filter criteria (profile_id,
owner_id, agent_id, issuer_id, team_id, certificate_ids), partial-failure
tolerance, and audit trail. Includes MCP tool, CLI command (certs bulk-revoke),
server-side bulk modal in GUI replacing client-side sequential loop, OpenAPI
spec, compliance mapping updates, and 21 new tests (12 service, 7 handler,
1 CLI, 1 frontend).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Shankar
2026-04-16 00:06:34 -04:00
parent cdb448dfe5
commit 4e3927e8b4
25 changed files with 1264 additions and 39 deletions
+10
View File
@@ -11,6 +11,7 @@ import {
updateCertificate,
archiveCertificate,
revokeCertificate,
bulkRevokeCertificates,
exportCertificatePEM,
downloadCertificatePEM,
exportCertificatePKCS12,
@@ -288,6 +289,15 @@ describe('API Client', () => {
expect(init.method).toBe('POST');
expect(JSON.parse(init.body)).toEqual({ reason: 'keyCompromise' });
});
it('bulkRevokeCertificates sends POST with criteria', async () => {
mockFetch.mockReturnValueOnce(mockJsonResponse({ total_matched: 3, total_revoked: 2, total_skipped: 1, total_failed: 0 }));
await bulkRevokeCertificates({ reason: 'keyCompromise', profile_id: 'prof-tls', certificate_ids: ['mc-1', 'mc-2'] });
const [url, init] = mockFetch.mock.calls[0];
expect(url).toBe('/api/v1/certificates/bulk-revoke');
expect(init.method).toBe('POST');
expect(JSON.parse(init.body)).toEqual({ reason: 'keyCompromise', profile_id: 'prof-tls', certificate_ids: ['mc-1', 'mc-2'] });
});
});
// ─── Agents ─────────────────────────────────────────
+24
View File
@@ -95,6 +95,30 @@ export const revokeCertificate = (id: string, reason: string) =>
body: JSON.stringify({ reason }),
});
export interface BulkRevokeCriteria {
reason: string;
profile_id?: string;
owner_id?: string;
agent_id?: string;
issuer_id?: string;
team_id?: string;
certificate_ids?: string[];
}
export interface BulkRevokeResult {
total_matched: number;
total_revoked: number;
total_skipped: number;
total_failed: number;
errors?: { certificate_id: string; error: string }[];
}
export const bulkRevokeCertificates = (criteria: BulkRevokeCriteria) =>
fetchJSON<BulkRevokeResult>(`${BASE}/certificates/bulk-revoke`, {
method: 'POST',
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`);
+31 -26
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, getProfiles, getIssuers } from '../api/client';
import { getCertificates, createCertificate, triggerRenewal, revokeCertificate, updateCertificate, getOwners, getProfiles, getIssuers, bulkRevokeCertificates } from '../api/client';
import { REVOCATION_REASONS } from '../api/types';
import PageHeader from '../components/PageHeader';
import DataTable from '../components/DataTable';
@@ -188,25 +188,24 @@ function CreateCertificateModal({ onClose, onSuccess }: { onClose: () => void; o
function BulkRevokeModal({ ids, onClose, onSuccess }: { ids: string[]; onClose: () => void; onSuccess: () => void }) {
const [reason, setReason] = useState('unspecified');
const [progress, setProgress] = useState(0);
const [error, setError] = useState('');
const [running, setRunning] = useState(false);
const [result, setResult] = useState<{ total_matched: number; total_revoked: number; total_skipped: number; total_failed: number; errors?: { certificate_id: string; error: string }[] } | null>(null);
const handleRevoke = async () => {
setRunning(true);
setError('');
let succeeded = 0;
for (const id of ids) {
try {
await revokeCertificate(id, reason);
succeeded++;
setProgress(succeeded);
} catch (err) {
setError(`Failed on ${id}: ${err instanceof Error ? err.message : 'Unknown error'}`);
break;
try {
const res = await bulkRevokeCertificates({ reason, certificate_ids: ids });
setResult(res);
if (res.total_failed === 0) {
onSuccess();
}
} catch (err) {
setError(err instanceof Error ? err.message : 'Bulk revocation failed');
} finally {
setRunning(false);
}
if (!error) onSuccess();
};
return (
@@ -217,32 +216,38 @@ function BulkRevokeModal({ ids, onClose, onSuccess }: { ids: string[]; onClose:
Revoke {ids.length} certificate{ids.length > 1 ? 's' : ''}. This cannot be undone.
</p>
{error && <div className="bg-red-50 border border-red-200 text-red-700 rounded px-3 py-2 text-sm mb-3">{error}</div>}
{running && (
<div className="mb-3">
<div className="flex justify-between text-xs text-ink-muted mb-1">
<span>Progress</span>
<span>{progress}/{ids.length}</span>
</div>
<div className="w-full bg-surface-border rounded-full h-2">
<div className="bg-red-500 h-2 rounded-full transition-all" style={{ width: `${(progress / ids.length) * 100}%` }} />
{result && (
<div className="mb-3 bg-gray-50 border border-gray-200 rounded px-3 py-2 text-sm">
<div className="grid grid-cols-2 gap-1">
<span className="text-ink-muted">Matched:</span><span className="font-medium">{result.total_matched}</span>
<span className="text-ink-muted">Revoked:</span><span className="font-medium text-red-600">{result.total_revoked}</span>
<span className="text-ink-muted">Skipped:</span><span className="font-medium text-yellow-600">{result.total_skipped}</span>
<span className="text-ink-muted">Failed:</span><span className="font-medium text-red-700">{result.total_failed}</span>
</div>
{result.errors && result.errors.length > 0 && (
<div className="mt-2 text-xs text-red-600">
{result.errors.map((e, i) => <div key={i}>{e.certificate_id}: {e.error}</div>)}
</div>
)}
</div>
)}
<label className="text-xs text-ink-muted block mb-2">Revocation Reason (RFC 5280)</label>
<select value={reason} onChange={e => setReason(e.target.value)}
className="w-full bg-white border border-surface-border rounded px-3 py-2 text-sm text-ink mb-4"
disabled={running}
disabled={running || result !== null}
>
{REVOCATION_REASONS.map(r => (
<option key={r.value} value={r.value}>{r.label}</option>
))}
</select>
<div className="flex justify-end gap-3">
<button onClick={onClose} className="btn btn-ghost text-sm" disabled={running}>Cancel</button>
<button onClick={handleRevoke} disabled={running}
className="btn text-sm bg-red-600 hover:bg-red-500 text-white disabled:opacity-50">
{running ? `Revoking (${progress}/${ids.length})...` : `Revoke ${ids.length} Certificates`}
</button>
<button onClick={onClose} className="btn btn-ghost text-sm">{result ? 'Close' : 'Cancel'}</button>
{!result && (
<button onClick={handleRevoke} disabled={running}
className="btn text-sm bg-red-600 hover:bg-red-500 text-white disabled:opacity-50">
{running ? 'Revoking...' : `Revoke ${ids.length} Certificates`}
</button>
)}
</div>
</div>
</div>