feat: tenant CA certificate management with staging
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:
45
ui/src/api/ca-hooks.ts
Normal file
45
ui/src/api/ca-hooks.ts
Normal 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'] }),
|
||||
});
|
||||
}
|
||||
@@ -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}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user