feat: certificate management with stage/activate/restore lifecycle
All checks were successful
CI / build (push) Successful in 1m8s
CI / docker (push) Successful in 45s

Provider-based architecture (Docker now, K8s later):
- CertificateManager interface + DockerCertificateManager (file-based)
- Atomic swap via .wip files for safe cert replacement
- Stage -> Activate -> Archive lifecycle with one-deep rollback
- Bootstrap supports user-supplied certs via CERT_FILE/KEY_FILE/CA_FILE
- CA bundle aggregates platform + tenant CAs, distributed to containers
- Vendor UI: Certificates page with upload, activate, restore, discard
- Stale tenant tracking (ca_applied_at) with restart banner
- Conditional TLS skip removal when CA bundle exists

Includes design spec, migration V012, service + controller tests.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
hsiegeln
2026-04-10 18:29:02 +02:00
parent 51a1aef10e
commit 45bcc954ac
23 changed files with 2035 additions and 7 deletions

View File

@@ -0,0 +1,68 @@
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { api } from './client';
export interface CertificateResponse {
id: string;
status: string;
subject: string;
issuer: string;
notBefore: string;
notAfter: string;
fingerprint: string;
hasCa: boolean;
selfSigned: boolean;
activatedAt: string | null;
archivedAt: string | null;
}
export interface CertificateOverview {
active: CertificateResponse | null;
staged: CertificateResponse | null;
archived: CertificateResponse | null;
staleTenantCount: number;
}
export interface StageResponse {
valid: boolean;
errors: string[];
certificate: CertificateResponse | null;
}
export function useVendorCertificates() {
return useQuery<CertificateOverview>({
queryKey: ['vendor', 'certificates'],
queryFn: () => api.get('/vendor/certificates'),
});
}
export function useStageCertificate() {
const qc = useQueryClient();
return useMutation<StageResponse, Error, FormData>({
mutationFn: (formData) => api.post('/vendor/certificates/stage', formData),
onSuccess: () => qc.invalidateQueries({ queryKey: ['vendor', 'certificates'] }),
});
}
export function useActivateCertificate() {
const qc = useQueryClient();
return useMutation<void, Error, void>({
mutationFn: () => api.post('/vendor/certificates/activate'),
onSuccess: () => qc.invalidateQueries({ queryKey: ['vendor', 'certificates'] }),
});
}
export function useRestoreCertificate() {
const qc = useQueryClient();
return useMutation<void, Error, void>({
mutationFn: () => api.post('/vendor/certificates/restore'),
onSuccess: () => qc.invalidateQueries({ queryKey: ['vendor', 'certificates'] }),
});
}
export function useDiscardStaged() {
const qc = useQueryClient();
return useMutation<void, Error, void>({
mutationFn: () => api.delete('/vendor/certificates/staged'),
onSuccess: () => qc.invalidateQueries({ queryKey: ['vendor', 'certificates'] }),
});
}

View File

@@ -83,6 +83,14 @@ export function Layout() {
>
Audit Log
</div>
<div
style={{ padding: '6px 12px 6px 36px', fontSize: 13, cursor: 'pointer',
fontWeight: isActive(location, '/vendor/certificates') ? 600 : 400,
color: isActive(location, '/vendor/certificates') ? 'var(--amber)' : 'var(--text-muted)' }}
onClick={() => navigate('/vendor/certificates')}
>
Certificates
</div>
<div
style={{ padding: '6px 12px 6px 36px', fontSize: 13, cursor: 'pointer', color: 'var(--text-muted)' }}
onClick={() => window.open(`${window.location.protocol}//${window.location.hostname}:3002`, '_blank', 'noopener')}

285
ui/src/pages/vendor/CertificatesPage.tsx vendored Normal file
View File

@@ -0,0 +1,285 @@
import { useRef } from 'react';
import {
Alert,
Badge,
Button,
Card,
Spinner,
useToast,
} from '@cameleer/design-system';
import { Upload, ShieldCheck, RotateCcw, Trash2, AlertTriangle } from 'lucide-react';
import {
useVendorCertificates,
useStageCertificate,
useActivateCertificate,
useRestoreCertificate,
useDiscardStaged,
} from '../../api/certificate-hooks';
import type { CertificateResponse } from '../../api/certificate-hooks';
import styles from '../../styles/platform.module.css';
function formatDate(iso: string | null | undefined): string {
if (!iso) return '—';
return new Date(iso).toLocaleDateString(undefined, { year: 'numeric', month: 'short', day: 'numeric' });
}
function daysUntil(iso: string | null | undefined): number {
if (!iso) return 0;
return Math.ceil((new Date(iso).getTime() - Date.now()) / 86_400_000);
}
function expiryColor(iso: string | null | undefined): string | undefined {
const days = daysUntil(iso);
if (days <= 0) return 'var(--error)';
if (days <= 30) return 'var(--warning)';
return undefined;
}
function CertCard({
cert,
title,
actions,
}: {
cert: CertificateResponse;
title: string;
actions?: React.ReactNode;
}) {
const days = daysUntil(cert.notAfter);
return (
<Card title={title}>
<div className={styles.dividerList}>
<div className={styles.kvRow}>
<span className={styles.kvLabel}>Subject</span>
<span className={styles.kvValue}>{cert.subject}</span>
</div>
<div className={styles.kvRow}>
<span className={styles.kvLabel}>Issuer</span>
<span className={styles.kvValue}>{cert.issuer}</span>
</div>
<div className={styles.kvRow}>
<span className={styles.kvLabel}>Valid from</span>
<span className={styles.kvValue}>{formatDate(cert.notBefore)}</span>
</div>
<div className={styles.kvRow}>
<span className={styles.kvLabel}>Expires</span>
<span className={styles.kvValue} style={{ color: expiryColor(cert.notAfter) }}>
{formatDate(cert.notAfter)} ({days}d)
</span>
</div>
<div className={styles.kvRow}>
<span className={styles.kvLabel}>Fingerprint</span>
<span className={styles.kvValueMono} style={{ fontSize: '0.7rem', maxWidth: 220, textAlign: 'right' }}>
{cert.fingerprint}
</span>
</div>
<div className={styles.kvRow}>
<span className={styles.kvLabel}>CA bundle</span>
<span className={styles.kvValue}>{cert.hasCa ? 'Yes' : 'No'}</span>
</div>
{cert.selfSigned && (
<div className={styles.kvRow}>
<span className={styles.kvLabel}>Type</span>
<Badge label="Self-signed" color="warning" />
</div>
)}
{cert.activatedAt && (
<div className={styles.kvRow}>
<span className={styles.kvLabel}>Activated</span>
<span className={styles.kvValue}>{formatDate(cert.activatedAt)}</span>
</div>
)}
{actions && <div style={{ paddingTop: 8, display: 'flex', gap: 8 }}>{actions}</div>}
</div>
</Card>
);
}
export function CertificatesPage() {
const { toast } = useToast();
const { data, isLoading, isError } = useVendorCertificates();
const stageMutation = useStageCertificate();
const activateMutation = useActivateCertificate();
const restoreMutation = useRestoreCertificate();
const discardMutation = useDiscardStaged();
const certInputRef = useRef<HTMLInputElement>(null);
const keyInputRef = useRef<HTMLInputElement>(null);
const caInputRef = useRef<HTMLInputElement>(null);
if (isLoading) {
return (
<div style={{ display: 'flex', justifyContent: 'center', padding: 64 }}>
<Spinner />
</div>
);
}
if (isError || !data) {
return (
<div style={{ padding: 24 }}>
<Alert variant="error" title="Failed to load certificates">
Could not fetch certificate data. Please refresh.
</Alert>
</div>
);
}
async function handleUpload() {
const certFile = certInputRef.current?.files?.[0];
const keyFile = keyInputRef.current?.files?.[0];
const caFile = caInputRef.current?.files?.[0];
if (!certFile || !keyFile) {
toast({ title: 'Certificate and key files are required', variant: 'error' });
return;
}
const formData = new FormData();
formData.append('cert', certFile);
formData.append('key', keyFile);
if (caFile) formData.append('ca', caFile);
try {
const result = await stageMutation.mutateAsync(formData);
if (result.valid) {
toast({ title: 'Certificate staged successfully', variant: 'success' });
// Clear file inputs
if (certInputRef.current) certInputRef.current.value = '';
if (keyInputRef.current) keyInputRef.current.value = '';
if (caInputRef.current) caInputRef.current.value = '';
} else {
toast({ title: 'Validation failed', description: result.errors.join(', '), variant: 'error' });
}
} catch (err) {
toast({ title: 'Upload failed', description: String(err), variant: 'error' });
}
}
async function handleActivate() {
try {
await activateMutation.mutateAsync();
toast({ title: 'Certificate activated', variant: 'success' });
} catch (err) {
toast({ title: 'Activation failed', description: String(err), variant: 'error' });
}
}
async function handleRestore() {
try {
await restoreMutation.mutateAsync();
toast({ title: 'Certificate restored from archive', variant: 'success' });
} catch (err) {
toast({ title: 'Restore failed', description: String(err), variant: 'error' });
}
}
async function handleDiscard() {
try {
await discardMutation.mutateAsync();
toast({ title: 'Staged certificate discarded', variant: 'success' });
} catch (err) {
toast({ title: 'Discard failed', description: String(err), variant: 'error' });
}
}
const expired = data.archived
? daysUntil(data.archived.notAfter) <= 0
: false;
return (
<div style={{ padding: 24, display: 'flex', flexDirection: 'column', gap: 20 }}>
<h1 className={styles.heading}>Certificates</h1>
{data.staleTenantCount > 0 && (
<Alert variant="warning" title="CA bundle updated">
<AlertTriangle size={14} style={{ verticalAlign: 'middle', marginRight: 6 }} />
{data.staleTenantCount} tenant{data.staleTenantCount > 1 ? 's' : ''} need a restart
to pick up the updated CA bundle.
</Alert>
)}
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(320px, 1fr))', gap: 16 }}>
{data.active && (
<CertCard cert={data.active} title="Active Certificate" />
)}
{data.staged && (
<CertCard
cert={data.staged}
title="Staged Certificate"
actions={
<>
<Button
variant="primary"
onClick={handleActivate}
loading={activateMutation.isPending}
>
<ShieldCheck size={14} style={{ marginRight: 6 }} />
Activate
</Button>
<Button
variant="secondary"
onClick={handleDiscard}
loading={discardMutation.isPending}
>
<Trash2 size={14} style={{ marginRight: 6 }} />
Discard
</Button>
</>
}
/>
)}
{data.archived && (
<CertCard
cert={data.archived}
title="Previous Certificate"
actions={
<Button
variant="secondary"
onClick={handleRestore}
loading={restoreMutation.isPending}
disabled={expired}
>
<RotateCcw size={14} style={{ marginRight: 6 }} />
{expired ? 'Expired' : 'Restore'}
</Button>
}
/>
)}
{/* Upload card */}
<Card title="Upload Certificate">
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
<div>
<label style={{ fontSize: 13, color: 'var(--text-secondary)', display: 'block', marginBottom: 4 }}>
Certificate (PEM) *
</label>
<input ref={certInputRef} type="file" accept=".pem,.crt,.cer" />
</div>
<div>
<label style={{ fontSize: 13, color: 'var(--text-secondary)', display: 'block', marginBottom: 4 }}>
Private Key (PEM) *
</label>
<input ref={keyInputRef} type="file" accept=".pem,.key" />
</div>
<div>
<label style={{ fontSize: 13, color: 'var(--text-secondary)', display: 'block', marginBottom: 4 }}>
CA Bundle (PEM, optional)
</label>
<input ref={caInputRef} type="file" accept=".pem,.crt,.cer" />
</div>
<Button
variant="primary"
onClick={handleUpload}
loading={stageMutation.isPending}
>
<Upload size={14} style={{ marginRight: 6 }} />
Stage Certificate
</Button>
</div>
</Card>
</div>
</div>
);
}

View File

@@ -12,6 +12,7 @@ import { VendorTenantsPage } from './pages/vendor/VendorTenantsPage';
import { CreateTenantPage } from './pages/vendor/CreateTenantPage';
import { TenantDetailPage } from './pages/vendor/TenantDetailPage';
import { VendorAuditPage } from './pages/vendor/VendorAuditPage';
import { CertificatesPage } from './pages/vendor/CertificatesPage';
import { TenantDashboardPage } from './pages/tenant/TenantDashboardPage';
import { TenantLicensePage } from './pages/tenant/TenantLicensePage';
import { SsoPage } from './pages/tenant/SsoPage';
@@ -73,6 +74,11 @@ export function AppRouter() {
<VendorAuditPage />
</RequireScope>
} />
<Route path="/vendor/certificates" element={
<RequireScope scope="platform:admin" fallback={<Navigate to="/tenant" replace />}>
<CertificatesPage />
</RequireScope>
} />
{/* Tenant portal */}
<Route path="/tenant" element={<TenantDashboardPage />} />