feat: tenant CA certificate management with staging
Some checks failed
CI / build (push) Successful in 1m7s
CI / docker (push) Has been cancelled

Tenants can upload multiple CA certificates for enterprise SSO providers
that use private certificate authorities.

- New tenant_ca_certs table (V013) with PEM storage in DB
- Stage/activate/delete lifecycle per CA cert
- Aggregated ca.pem rebuild on activate/delete (atomic .wip swap)
- REST API: GET/POST/DELETE on /api/tenant/ca
- UI: CA Certificates section on SSO page with upload, activate, remove

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
hsiegeln
2026-04-10 19:35:04 +02:00
parent a3a6f99958
commit dd30ee77d4
8 changed files with 650 additions and 3 deletions

45
ui/src/api/ca-hooks.ts Normal file
View File

@@ -0,0 +1,45 @@
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { api } from './client';
export interface CaCertResponse {
id: string;
status: string;
label: string | null;
subject: string;
issuer: string;
fingerprint: string;
notBefore: string;
notAfter: string;
createdAt: string;
}
export function useTenantCaCerts() {
return useQuery<CaCertResponse[]>({
queryKey: ['tenant', 'ca'],
queryFn: () => api.get('/tenant/ca'),
});
}
export function useStageCaCert() {
const qc = useQueryClient();
return useMutation<CaCertResponse, Error, FormData>({
mutationFn: (formData) => api.post('/tenant/ca', formData),
onSuccess: () => qc.invalidateQueries({ queryKey: ['tenant', 'ca'] }),
});
}
export function useActivateCaCert() {
const qc = useQueryClient();
return useMutation<CaCertResponse, Error, string>({
mutationFn: (id) => api.post(`/tenant/ca/${id}/activate`),
onSuccess: () => qc.invalidateQueries({ queryKey: ['tenant', 'ca'] }),
});
}
export function useDeleteCaCert() {
const qc = useQueryClient();
return useMutation<void, Error, string>({
mutationFn: (id) => api.delete(`/tenant/ca/${id}`),
onSuccess: () => qc.invalidateQueries({ queryKey: ['tenant', 'ca'] }),
});
}

View File

@@ -1,14 +1,19 @@
import { useState } from 'react';
import { useState, useRef } from 'react';
import {
Alert, AlertDialog, Badge, Button, Card, DataTable,
EmptyState, FormField, Input, Spinner, useToast,
} from '@cameleer/design-system';
import type { Column } from '@cameleer/design-system';
import { Shield, Plus, Trash2, FlaskConical } from 'lucide-react';
import { Shield, Plus, Trash2, FlaskConical, Upload, ShieldCheck } from 'lucide-react';
import {
useSsoConnectors, useCreateSsoConnector, useDeleteSsoConnector, useTestSsoConnector,
} from '../../api/tenant-hooks';
import {
useTenantCaCerts, useStageCaCert, useActivateCaCert, useDeleteCaCert,
} from '../../api/ca-hooks';
import type { CaCertResponse } from '../../api/ca-hooks';
import type { SsoConnector } from '../../types/api';
import styles from '../../styles/platform.module.css';
const PROVIDERS = [
{ value: 'OIDC', label: 'OIDC', type: 'oidc' },
@@ -301,6 +306,219 @@ export function SsoPage() {
variant="danger"
loading={deleteConnector.isPending}
/>
<CaCertificatesSection />
</div>
);
}
// --- CA Certificates Section ---
function formatDate(iso: string | null | undefined): string {
if (!iso) return '—';
return new Date(iso).toLocaleDateString(undefined, { year: 'numeric', month: 'short', day: 'numeric' });
}
function shortenFingerprint(fp: string | null | undefined): string {
if (!fp) return '—';
return fp.length > 23 ? fp.slice(0, 23) + '...' : fp;
}
function CaCertificatesSection() {
const { data: certs, isLoading } = useTenantCaCerts();
const stageMutation = useStageCaCert();
const activateMutation = useActivateCaCert();
const deleteMutation = useDeleteCaCert();
const { toast } = useToast();
const certInputRef = useRef<HTMLInputElement>(null);
const [label, setLabel] = useState('');
const [deleteTarget, setDeleteTarget] = useState<CaCertResponse | null>(null);
async function handleUpload() {
const file = certInputRef.current?.files?.[0];
if (!file) {
toast({ title: 'Select a CA certificate file', variant: 'error' });
return;
}
const formData = new FormData();
formData.append('cert', file);
if (label.trim()) formData.append('label', label.trim());
try {
await stageMutation.mutateAsync(formData);
toast({ title: 'CA certificate staged', variant: 'success' });
if (certInputRef.current) certInputRef.current.value = '';
setLabel('');
} catch (err) {
toast({ title: 'Upload failed', description: String(err), variant: 'error' });
}
}
async function handleActivate(id: string) {
try {
await activateMutation.mutateAsync(id);
toast({ title: 'CA certificate activated', variant: 'success' });
} catch (err) {
toast({ title: 'Activation failed', description: String(err), variant: 'error' });
}
}
async function handleDelete() {
if (!deleteTarget) return;
try {
await deleteMutation.mutateAsync(deleteTarget.id);
toast({ title: 'CA certificate removed', variant: 'success' });
setDeleteTarget(null);
} catch (err) {
toast({ title: 'Delete failed', description: String(err), variant: 'error' });
setDeleteTarget(null);
}
}
const active = certs?.filter((c) => c.status === 'ACTIVE') ?? [];
const staged = certs?.filter((c) => c.status === 'STAGED') ?? [];
return (
<>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginTop: 12 }}>
<h2 style={{ margin: 0, fontSize: '1.1rem', fontWeight: 600 }}>CA Certificates</h2>
</div>
<p style={{ fontSize: '0.875rem', color: 'var(--text-secondary)', margin: 0 }}>
Upload CA certificates if your identity provider uses a private or internal certificate authority.
Certificates are staged first, then activated to take effect.
</p>
{isLoading ? (
<div style={{ display: 'flex', justifyContent: 'center', padding: 32 }}>
<Spinner />
</div>
) : (
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(340px, 1fr))', gap: 16 }}>
{/* Upload card */}
<Card title="Upload CA Certificate">
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
<div>
<label style={{ fontSize: 13, color: 'var(--text-secondary)', display: 'block', marginBottom: 4 }}>
Label (optional)
</label>
<Input
placeholder="e.g. Acme Corp Root CA"
value={label}
onChange={(e) => setLabel(e.target.value)}
/>
</div>
<div>
<label style={{ fontSize: 13, color: 'var(--text-secondary)', display: 'block', marginBottom: 4 }}>
CA Certificate (PEM) *
</label>
<input
ref={certInputRef}
type="file"
accept=".pem,.crt,.cer"
style={{
fontSize: 13, color: 'var(--text-primary)',
background: 'var(--bg-inset)', border: '1px solid var(--border)',
borderRadius: 6, padding: '6px 8px', width: '100%',
}}
/>
</div>
<Button variant="primary" onClick={handleUpload} loading={stageMutation.isPending}>
<Upload size={14} style={{ marginRight: 6 }} />
Stage Certificate
</Button>
</div>
</Card>
{/* Staged certs */}
{staged.map((cert) => (
<Card key={cert.id} title={cert.label || 'Staged CA'}>
<div className={styles.dividerList}>
<div className={styles.kvRow}>
<span className={styles.kvLabel}>Status</span>
<Badge label="Staged" color="warning" />
</div>
<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}>Expires</span>
<span className={styles.kvValue}>{formatDate(cert.notAfter)}</span>
</div>
<div className={styles.kvRow}>
<span className={styles.kvLabel}>Fingerprint</span>
<span className={styles.kvValueMono} title={cert.fingerprint} style={{ fontSize: '0.7rem' }}>
{shortenFingerprint(cert.fingerprint)}
</span>
</div>
<div style={{ paddingTop: 8, display: 'flex', gap: 8 }}>
<Button variant="primary" onClick={() => handleActivate(cert.id)} loading={activateMutation.isPending}>
<ShieldCheck size={14} style={{ marginRight: 6 }} />
Activate
</Button>
<Button variant="danger" onClick={() => setDeleteTarget(cert)}>
<Trash2 size={14} />
</Button>
</div>
</div>
</Card>
))}
{/* Active certs */}
{active.map((cert) => (
<Card key={cert.id} title={cert.label || 'Active CA'}>
<div className={styles.dividerList}>
<div className={styles.kvRow}>
<span className={styles.kvLabel}>Status</span>
<Badge label="Active" color="success" />
</div>
<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}>Expires</span>
<span className={styles.kvValue}>{formatDate(cert.notAfter)}</span>
</div>
<div className={styles.kvRow}>
<span className={styles.kvLabel}>Fingerprint</span>
<span className={styles.kvValueMono} title={cert.fingerprint} style={{ fontSize: '0.7rem' }}>
{shortenFingerprint(cert.fingerprint)}
</span>
</div>
<div style={{ paddingTop: 8 }}>
<Button variant="danger" onClick={() => setDeleteTarget(cert)}>
<Trash2 size={14} style={{ marginRight: 6 }} />
Remove
</Button>
</div>
</div>
</Card>
))}
</div>
)}
<AlertDialog
open={deleteTarget !== null}
onClose={() => setDeleteTarget(null)}
onConfirm={handleDelete}
title="Remove CA Certificate"
description={`Remove "${deleteTarget?.label || deleteTarget?.subject || ''}"? Services using this CA for trust may lose connectivity.`}
confirmLabel="Remove"
cancelLabel="Cancel"
variant="danger"
loading={deleteMutation.isPending}
/>
</>
);
}