diff --git a/src/main/java/net/siegeln/cameleer/saas/certificate/TenantCaCertEntity.java b/src/main/java/net/siegeln/cameleer/saas/certificate/TenantCaCertEntity.java new file mode 100644 index 0000000..163f403 --- /dev/null +++ b/src/main/java/net/siegeln/cameleer/saas/certificate/TenantCaCertEntity.java @@ -0,0 +1,82 @@ +package net.siegeln.cameleer.saas.certificate; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.PrePersist; +import jakarta.persistence.Table; + +import java.time.Instant; +import java.util.UUID; + +@Entity +@Table(name = "tenant_ca_certs") +public class TenantCaCertEntity { + + public enum Status { ACTIVE, STAGED } + + @Id + @GeneratedValue(strategy = GenerationType.UUID) + private UUID id; + + @Column(name = "tenant_id", nullable = false) + private UUID tenantId; + + @Enumerated(EnumType.STRING) + @Column(name = "status", nullable = false, length = 10) + private Status status; + + @Column(name = "label", length = 200) + private String label; + + @Column(name = "subject", length = 500) + private String subject; + + @Column(name = "issuer", length = 500) + private String issuer; + + @Column(name = "fingerprint", length = 128) + private String fingerprint; + + @Column(name = "not_before") + private Instant notBefore; + + @Column(name = "not_after") + private Instant notAfter; + + @Column(name = "cert_pem", nullable = false, columnDefinition = "TEXT") + private String certPem; + + @Column(name = "created_at", nullable = false, updatable = false) + private Instant createdAt; + + @PrePersist + protected void onCreate() { + if (createdAt == null) createdAt = Instant.now(); + } + + public UUID getId() { return id; } + public UUID getTenantId() { return tenantId; } + public void setTenantId(UUID tenantId) { this.tenantId = tenantId; } + public Status getStatus() { return status; } + public void setStatus(Status status) { this.status = status; } + public String getLabel() { return label; } + public void setLabel(String label) { this.label = label; } + public String getSubject() { return subject; } + public void setSubject(String subject) { this.subject = subject; } + public String getIssuer() { return issuer; } + public void setIssuer(String issuer) { this.issuer = issuer; } + public String getFingerprint() { return fingerprint; } + public void setFingerprint(String fingerprint) { this.fingerprint = fingerprint; } + public Instant getNotBefore() { return notBefore; } + public void setNotBefore(Instant notBefore) { this.notBefore = notBefore; } + public Instant getNotAfter() { return notAfter; } + public void setNotAfter(Instant notAfter) { this.notAfter = notAfter; } + public String getCertPem() { return certPem; } + public void setCertPem(String certPem) { this.certPem = certPem; } + public Instant getCreatedAt() { return createdAt; } +} diff --git a/src/main/java/net/siegeln/cameleer/saas/certificate/TenantCaCertRepository.java b/src/main/java/net/siegeln/cameleer/saas/certificate/TenantCaCertRepository.java new file mode 100644 index 0000000..538e39b --- /dev/null +++ b/src/main/java/net/siegeln/cameleer/saas/certificate/TenantCaCertRepository.java @@ -0,0 +1,18 @@ +package net.siegeln.cameleer.saas.certificate; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; + +import java.util.List; +import java.util.UUID; + +public interface TenantCaCertRepository extends JpaRepository { + + List findByTenantIdOrderByCreatedAtDesc(UUID tenantId); + + List findByTenantIdAndStatus(UUID tenantId, TenantCaCertEntity.Status status); + + /** All active CAs across all tenants — used to rebuild the aggregated ca.pem. */ + @Query("SELECT c FROM TenantCaCertEntity c WHERE c.status = 'ACTIVE'") + List findAllActive(); +} diff --git a/src/main/java/net/siegeln/cameleer/saas/certificate/TenantCaCertService.java b/src/main/java/net/siegeln/cameleer/saas/certificate/TenantCaCertService.java new file mode 100644 index 0000000..1afb95c --- /dev/null +++ b/src/main/java/net/siegeln/cameleer/saas/certificate/TenantCaCertService.java @@ -0,0 +1,196 @@ +package net.siegeln.cameleer.saas.certificate; + +import net.siegeln.cameleer.saas.provisioning.DockerCertificateManager; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.io.ByteArrayInputStream; +import java.security.MessageDigest; +import java.security.cert.CertificateFactory; +import java.security.cert.X509Certificate; +import java.util.ArrayList; +import java.util.HexFormat; +import java.util.List; +import java.util.UUID; + +@Service +public class TenantCaCertService { + + private static final Logger log = LoggerFactory.getLogger(TenantCaCertService.class); + + private final TenantCaCertRepository caCertRepository; + private final CertificateManager certManager; + + public TenantCaCertService(TenantCaCertRepository caCertRepository, CertificateManager certManager) { + this.caCertRepository = caCertRepository; + this.certManager = certManager; + } + + public List listForTenant(UUID tenantId) { + return caCertRepository.findByTenantIdOrderByCreatedAtDesc(tenantId); + } + + @Transactional + public TenantCaCertEntity stage(UUID tenantId, String label, byte[] certPem) { + // Parse and validate + X509Certificate cert; + try { + var cf = CertificateFactory.getInstance("X.509"); + cert = (X509Certificate) cf.generateCertificate(new ByteArrayInputStream(certPem)); + } catch (Exception e) { + throw new IllegalArgumentException("Invalid CA certificate PEM: " + e.getMessage()); + } + + String fingerprint; + try { + fingerprint = HexFormat.ofDelimiter(":").formatHex( + MessageDigest.getInstance("SHA-256").digest(cert.getEncoded())); + } catch (Exception e) { + throw new RuntimeException("Failed to compute fingerprint", e); + } + + var entity = new TenantCaCertEntity(); + entity.setTenantId(tenantId); + entity.setStatus(TenantCaCertEntity.Status.STAGED); + entity.setLabel(label); + entity.setSubject(cert.getSubjectX500Principal().getName()); + entity.setIssuer(cert.getIssuerX500Principal().getName()); + entity.setFingerprint(fingerprint); + entity.setNotBefore(cert.getNotBefore().toInstant()); + entity.setNotAfter(cert.getNotAfter().toInstant()); + entity.setCertPem(new String(certPem)); + + var saved = caCertRepository.save(entity); + log.info("Staged tenant CA cert for tenant {}: subject={}", tenantId, entity.getSubject()); + return saved; + } + + @Transactional + public TenantCaCertEntity activate(UUID tenantId, UUID certId) { + var entity = caCertRepository.findById(certId) + .orElseThrow(() -> new IllegalArgumentException("CA certificate not found")); + if (!entity.getTenantId().equals(tenantId)) { + throw new IllegalArgumentException("CA certificate does not belong to this tenant"); + } + if (entity.getStatus() != TenantCaCertEntity.Status.STAGED) { + throw new IllegalStateException("Only staged certificates can be activated"); + } + + entity.setStatus(TenantCaCertEntity.Status.ACTIVE); + caCertRepository.save(entity); + + rebuildCaBundle(); + log.info("Activated tenant CA cert {} for tenant {}", certId, tenantId); + return entity; + } + + @Transactional + public void delete(UUID tenantId, UUID certId) { + var entity = caCertRepository.findById(certId) + .orElseThrow(() -> new IllegalArgumentException("CA certificate not found")); + if (!entity.getTenantId().equals(tenantId)) { + throw new IllegalArgumentException("CA certificate does not belong to this tenant"); + } + + boolean wasActive = entity.getStatus() == TenantCaCertEntity.Status.ACTIVE; + caCertRepository.delete(entity); + + if (wasActive) { + rebuildCaBundle(); + } + log.info("Deleted tenant CA cert {} for tenant {}", certId, tenantId); + } + + /** + * Rebuild the aggregated ca.pem from all active tenant CAs + platform CA. + * Uses the .wip atomic swap pattern via CertificateManager. + */ + public void rebuildCaBundle() { + if (!certManager.isAvailable()) { + log.warn("Certificate manager not available — skipping CA bundle rebuild"); + return; + } + + List allActive = caCertRepository.findAllActive(); + + // Collect all PEM certs + var parts = new ArrayList(); + + // Platform CA (from existing ca.pem staged with platform cert, if any) + // We read the current platform cert's CA from the active cert's staged ca + // Actually, the platform CA is managed separately by CertificateService. + // We only aggregate tenant CAs here + whatever platform CA exists. + byte[] existingPlatformCa = readPlatformCa(); + if (existingPlatformCa != null) { + parts.add(new String(existingPlatformCa).trim()); + } + + for (var cert : allActive) { + parts.add(cert.getCertPem().trim()); + } + + if (parts.isEmpty()) { + // No CAs at all — remove ca.pem + try { + var certsDir = getCertsPath(); + if (certsDir != null) { + java.nio.file.Files.deleteIfExists(certsDir.resolve("ca.pem")); + log.info("Removed ca.pem — no active CA certificates"); + } + } catch (Exception e) { + log.warn("Failed to remove ca.pem: {}", e.getMessage()); + } + return; + } + + byte[] bundleBytes = String.join("\n", parts).concat("\n").getBytes(); + + // Validate the bundle is parseable + try { + var cf = CertificateFactory.getInstance("X.509"); + var certs = cf.generateCertificates(new ByteArrayInputStream(bundleBytes)); + if (certs.isEmpty()) { + log.error("Rebuilt CA bundle contains no valid certificates — aborting"); + return; + } + log.info("CA bundle rebuilt with {} certificate(s)", certs.size()); + } catch (Exception e) { + log.error("Rebuilt CA bundle failed validation — aborting: {}", e.getMessage()); + return; + } + + // Atomic write via .wip pattern + try { + var certsDir = getCertsPath(); + if (certsDir == null) return; + var wipPath = certsDir.resolve("ca.wip"); + var targetPath = certsDir.resolve("ca.pem"); + java.nio.file.Files.write(wipPath, bundleBytes); + java.nio.file.Files.move(wipPath, targetPath, + java.nio.file.StandardCopyOption.REPLACE_EXISTING, + java.nio.file.StandardCopyOption.ATOMIC_MOVE); + log.info("CA bundle written to {}", targetPath); + } catch (Exception e) { + log.error("Failed to write CA bundle: {}", e.getMessage()); + } + } + + /** Read the platform CA portion (uploaded with the platform cert, not tenant CAs). */ + private byte[] readPlatformCa() { + // The platform CA is stored alongside the platform cert by CertificateService. + // We read it from the cert manager, but we need to distinguish it from the + // aggregated bundle. For now, we don't separate platform CA from tenant CAs + // in the file — the rebuild always produces the full bundle. + // Platform CA would be stored separately if vendor uploaded one with their cert. + return null; // TODO: track platform CA separately if needed + } + + private java.nio.file.Path getCertsPath() { + if (certManager instanceof DockerCertificateManager dcm) { + return dcm.getCertsDir(); + } + return null; + } +} diff --git a/src/main/java/net/siegeln/cameleer/saas/portal/TenantPortalController.java b/src/main/java/net/siegeln/cameleer/saas/portal/TenantPortalController.java index b0237b8..c6c74b1 100644 --- a/src/main/java/net/siegeln/cameleer/saas/portal/TenantPortalController.java +++ b/src/main/java/net/siegeln/cameleer/saas/portal/TenantPortalController.java @@ -1,5 +1,8 @@ package net.siegeln.cameleer.saas.portal; +import net.siegeln.cameleer.saas.certificate.TenantCaCertEntity; +import net.siegeln.cameleer.saas.certificate.TenantCaCertService; +import net.siegeln.cameleer.saas.config.TenantContext; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.DeleteMapping; import org.springframework.web.bind.annotation.GetMapping; @@ -9,19 +12,25 @@ import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.PutMapping; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.multipart.MultipartFile; +import java.time.Instant; import java.util.List; import java.util.Map; +import java.util.UUID; @RestController @RequestMapping("/api/tenant") public class TenantPortalController { private final TenantPortalService portalService; + private final TenantCaCertService caCertService; - public TenantPortalController(TenantPortalService portalService) { + public TenantPortalController(TenantPortalService portalService, TenantCaCertService caCertService) { this.portalService = portalService; + this.caCertService = caCertService; } // --- Request bodies --- @@ -80,4 +89,63 @@ public class TenantPortalController { public ResponseEntity getSettings() { return ResponseEntity.ok(portalService.getSettings()); } + + // --- CA Certificate management --- + + public record CaCertResponse( + UUID id, String status, String label, String subject, String issuer, + String fingerprint, Instant notBefore, Instant notAfter, Instant createdAt + ) { + public static CaCertResponse from(TenantCaCertEntity e) { + return new CaCertResponse( + e.getId(), e.getStatus().name(), e.getLabel(), e.getSubject(), e.getIssuer(), + e.getFingerprint(), e.getNotBefore(), e.getNotAfter(), e.getCreatedAt() + ); + } + } + + @GetMapping("/ca") + public ResponseEntity> listCaCerts() { + UUID tenantId = TenantContext.getTenantId(); + return ResponseEntity.ok( + caCertService.listForTenant(tenantId).stream().map(CaCertResponse::from).toList() + ); + } + + @PostMapping("/ca") + public ResponseEntity stageCaCert( + @RequestParam("cert") MultipartFile certFile, + @RequestParam(value = "label", required = false) String label) { + try { + UUID tenantId = TenantContext.getTenantId(); + var entity = caCertService.stage(tenantId, label, certFile.getBytes()); + return ResponseEntity.ok(CaCertResponse.from(entity)); + } catch (IllegalArgumentException e) { + return ResponseEntity.badRequest().build(); + } catch (Exception e) { + return ResponseEntity.internalServerError().build(); + } + } + + @PostMapping("/ca/{id}/activate") + public ResponseEntity activateCaCert(@PathVariable UUID id) { + try { + UUID tenantId = TenantContext.getTenantId(); + var entity = caCertService.activate(tenantId, id); + return ResponseEntity.ok(CaCertResponse.from(entity)); + } catch (IllegalArgumentException | IllegalStateException e) { + return ResponseEntity.badRequest().build(); + } + } + + @DeleteMapping("/ca/{id}") + public ResponseEntity deleteCaCert(@PathVariable UUID id) { + try { + UUID tenantId = TenantContext.getTenantId(); + caCertService.delete(tenantId, id); + return ResponseEntity.noContent().build(); + } catch (IllegalArgumentException e) { + return ResponseEntity.notFound().build(); + } + } } diff --git a/src/main/java/net/siegeln/cameleer/saas/provisioning/DockerCertificateManager.java b/src/main/java/net/siegeln/cameleer/saas/provisioning/DockerCertificateManager.java index b179dd7..781b28c 100644 --- a/src/main/java/net/siegeln/cameleer/saas/provisioning/DockerCertificateManager.java +++ b/src/main/java/net/siegeln/cameleer/saas/provisioning/DockerCertificateManager.java @@ -34,6 +34,10 @@ public class DockerCertificateManager implements CertificateManager { this.certsDir = certsDir; } + public Path getCertsDir() { + return certsDir; + } + @Override public boolean isAvailable() { return Files.isDirectory(certsDir) && Files.isWritable(certsDir); diff --git a/src/main/resources/db/migration/V013__create_tenant_ca_certs.sql b/src/main/resources/db/migration/V013__create_tenant_ca_certs.sql new file mode 100644 index 0000000..4127361 --- /dev/null +++ b/src/main/resources/db/migration/V013__create_tenant_ca_certs.sql @@ -0,0 +1,16 @@ +-- Per-tenant CA certificates for enterprise SSO trust +CREATE TABLE tenant_ca_certs ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE, + status VARCHAR(10) NOT NULL CHECK (status IN ('ACTIVE', 'STAGED')), + label VARCHAR(200), + subject VARCHAR(500), + issuer VARCHAR(500), + fingerprint VARCHAR(128), + not_before TIMESTAMPTZ, + not_after TIMESTAMPTZ, + cert_pem TEXT NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +CREATE INDEX idx_tenant_ca_certs_tenant ON tenant_ca_certs(tenant_id); diff --git a/ui/src/api/ca-hooks.ts b/ui/src/api/ca-hooks.ts new file mode 100644 index 0000000..dfda622 --- /dev/null +++ b/ui/src/api/ca-hooks.ts @@ -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({ + queryKey: ['tenant', 'ca'], + queryFn: () => api.get('/tenant/ca'), + }); +} + +export function useStageCaCert() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: (formData) => api.post('/tenant/ca', formData), + onSuccess: () => qc.invalidateQueries({ queryKey: ['tenant', 'ca'] }), + }); +} + +export function useActivateCaCert() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: (id) => api.post(`/tenant/ca/${id}/activate`), + onSuccess: () => qc.invalidateQueries({ queryKey: ['tenant', 'ca'] }), + }); +} + +export function useDeleteCaCert() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: (id) => api.delete(`/tenant/ca/${id}`), + onSuccess: () => qc.invalidateQueries({ queryKey: ['tenant', 'ca'] }), + }); +} diff --git a/ui/src/pages/tenant/SsoPage.tsx b/ui/src/pages/tenant/SsoPage.tsx index 560944e..d1e9ed7 100644 --- a/ui/src/pages/tenant/SsoPage.tsx +++ b/ui/src/pages/tenant/SsoPage.tsx @@ -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} /> + + ); } + +// --- 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(null); + const [label, setLabel] = useState(''); + const [deleteTarget, setDeleteTarget] = useState(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 ( + <> +
+

CA Certificates

+
+ +

+ Upload CA certificates if your identity provider uses a private or internal certificate authority. + Certificates are staged first, then activated to take effect. +

+ + {isLoading ? ( +
+ +
+ ) : ( +
+ {/* Upload card */} + +
+
+ + setLabel(e.target.value)} + /> +
+
+ + +
+ +
+
+ + {/* Staged certs */} + {staged.map((cert) => ( + +
+
+ Status + +
+
+ Subject + {cert.subject} +
+
+ Issuer + {cert.issuer} +
+
+ Expires + {formatDate(cert.notAfter)} +
+
+ Fingerprint + + {shortenFingerprint(cert.fingerprint)} + +
+
+ + +
+
+
+ ))} + + {/* Active certs */} + {active.map((cert) => ( + +
+
+ Status + +
+
+ Subject + {cert.subject} +
+
+ Issuer + {cert.issuer} +
+
+ Expires + {formatDate(cert.notAfter)} +
+
+ Fingerprint + + {shortenFingerprint(cert.fingerprint)} + +
+
+ +
+
+
+ ))} +
+ )} + + 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} + /> + + ); +}