diff --git a/docker-compose.yml b/docker-compose.yml index 6cfc123..57b90ea 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -5,19 +5,57 @@ services: entrypoint: ["sh", "-c"] command: - | - if [ ! -f /certs/cert.pem ]; then + if [ -f /certs/cert.pem ]; then + echo "Certs already exist, skipping" + exit 0 + fi + + # Option 1: User-supplied certificate + if [ -n "$$CERT_FILE" ] && [ -n "$$KEY_FILE" ]; then + apk add --no-cache openssl >/dev/null 2>&1 + cp "$$CERT_FILE" /certs/cert.pem + cp "$$KEY_FILE" /certs/key.pem + if [ -n "$$CA_FILE" ]; then + cp "$$CA_FILE" /certs/ca.pem + fi + # Validate: key matches cert + CERT_MOD=$$(openssl x509 -noout -modulus -in /certs/cert.pem 2>/dev/null | md5sum) + KEY_MOD=$$(openssl rsa -noout -modulus -in /certs/key.pem 2>/dev/null | md5sum) + if [ "$$CERT_MOD" != "$$KEY_MOD" ]; then + echo "ERROR: Certificate and key do not match!" + rm -f /certs/cert.pem /certs/key.pem /certs/ca.pem + exit 1 + fi + SELF_SIGNED=false + echo "Installed user-supplied certificate" + else + # Option 2: Generate self-signed apk add --no-cache openssl >/dev/null 2>&1 openssl req -x509 -newkey rsa:4096 \ -keyout /certs/key.pem -out /certs/cert.pem \ -days 365 -nodes \ -subj "/CN=$$PUBLIC_HOST" \ -addext "subjectAltName=DNS:$$PUBLIC_HOST,DNS:*.$$PUBLIC_HOST" + SELF_SIGNED=true echo "Generated self-signed cert for $$PUBLIC_HOST" - else - echo "Certs already exist, skipping" fi + + # Write metadata for SaaS app to seed DB + SUBJECT=$$(openssl x509 -noout -subject -in /certs/cert.pem 2>/dev/null | sed 's/subject=//') + FINGERPRINT=$$(openssl x509 -noout -fingerprint -sha256 -in /certs/cert.pem 2>/dev/null | sed 's/.*=//') + NOT_BEFORE=$$(openssl x509 -noout -startdate -in /certs/cert.pem 2>/dev/null | sed 's/notBefore=//') + NOT_AFTER=$$(openssl x509 -noout -enddate -in /certs/cert.pem 2>/dev/null | sed 's/notAfter=//') + HAS_CA=false + [ -f /certs/ca.pem ] && HAS_CA=true + cat > /certs/meta.json < errors, CertificateInfo info +) {} +``` + +### File Layout (Docker Volume) + +``` +/certs/ + cert.pem <- ACTIVE platform cert (Traefik reads) + key.pem <- ACTIVE private key + ca.pem <- aggregated CA bundle (platform CA + tenant CAs) + meta.json <- bootstrap metadata for DB seeding + staged/ + cert.pem <- STAGED cert + key.pem <- STAGED key + ca.pem <- STAGED CA bundle + prev/ + cert.pem <- ARCHIVED (one previous) + key.pem + ca.pem +``` + +Atomic swap pattern: write to `*.wip`, validate, rename to final path. + +### Database + +```sql +-- V011__certificates.sql +CREATE TABLE certificates ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + status VARCHAR(10) NOT NULL CHECK (status IN ('ACTIVE', 'STAGED', 'ARCHIVED')), + subject VARCHAR(500), + issuer VARCHAR(500), + not_before TIMESTAMPTZ, + not_after TIMESTAMPTZ, + fingerprint VARCHAR(128), + has_ca BOOLEAN NOT NULL DEFAULT FALSE, + self_signed BOOLEAN NOT NULL DEFAULT FALSE, + uploaded_by UUID, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + activated_at TIMESTAMPTZ, + archived_at TIMESTAMPTZ +); +``` + +At most 3 rows: one per status. On activate: delete ARCHIVED -> ACTIVE becomes ARCHIVED -> STAGED becomes ACTIVE. + +Tenant staleness tracked via `ca_applied_at` column on `tenants` table: + +```sql +-- in same migration +ALTER TABLE tenants ADD COLUMN ca_applied_at TIMESTAMPTZ; +``` + +Tenants with `ca_applied_at < (active cert's activated_at)` are stale. + +### State Transitions + +``` +Upload -> STAGED -> activate -> ACTIVE -> (next activate) -> ARCHIVED + ^ | + +------ restore ---------------+ +``` + +- **Activate staged**: delete ARCHIVED row+files, ACTIVE -> ARCHIVED (move files to prev/), STAGED -> ACTIVE (move files to root) +- **Restore archived**: swap ACTIVE <-> ARCHIVED (swap files and DB statuses) +- **Discard staged**: delete STAGED row + staged/ files + +### Bootstrap Flow + +The `traefik-certs` init container gains env var support: + +``` +1. cert.pem + key.pem exist in volume? + -> Yes: skip (idempotent) + -> No: continue + +2. CERT_FILE + KEY_FILE env vars set? + -> Yes: copy to volume, validate (PEM parseable, key matches cert) + If CA_FILE set, copy as ca.pem + -> No: generate self-signed (current behavior) + +3. Write /certs/meta.json with subject, fingerprint, self_signed flag +``` + +SaaS app reads `meta.json` on startup to seed the certificates DB table if no ACTIVE row exists. + +### REST API + +All under `platform:admin` scope: + +| Method | Path | Description | +|--------|------|-------------| +| GET | `/api/vendor/certificates` | List active, staged, archived | +| POST | `/api/vendor/certificates/stage` | Upload cert+key+ca (multipart) | +| POST | `/api/vendor/certificates/activate` | Promote staged -> active | +| POST | `/api/vendor/certificates/restore` | Swap archived <-> active | +| DELETE | `/api/vendor/certificates/staged` | Discard staged | +| GET | `/api/vendor/certificates/stale-tenants` | Tenants needing restart for CA | + +### Service Layer + +`CertificateService` orchestrates: +- Validation (PEM parsing, key-cert match, chain building, expiry check) +- Delegates file operations to `CertificateManager` (provider) +- Manages DB metadata +- Computes tenant CA staleness + +### CA Bundle Management + +`ca.pem` is a concatenation of: +- Platform cert's CA (if from a private CA, supplied at bootstrap or upload) +- Tenant-supplied CAs (for enterprise SSO with private IdPs) + +On any CA change (platform cert upload with CA, tenant CA add/remove): +1. Rebuild: concatenate all CAs into `ca.wip` +2. Validate: parse all PEM entries, verify structure +3. Atomic swap: `mv ca.wip ca.pem` +4. Update `activated_at` on ACTIVE cert row +5. Flag tenants as stale + +### Tenant CA Distribution + +At provisioning time (`DockerTenantProvisioner`): +- Mount `certs` volume read-only at `/certs` in tenant containers +- Java servers: JVM truststore import at entrypoint or `JAVA_OPTS` with custom truststore +- Node containers: `NODE_EXTRA_CA_CERTS=/certs/ca.pem` +- Set `ca_applied_at = now()` on tenant record +- Remove TLS skip flags when `ca.pem` exists + +On tenant restart (manual, after CA change): +- Container picks up current `ca.pem` from volume mount +- Update `ca_applied_at` on tenant + +### Vendor UI + +New "Certificates" page in vendor sidebar: + +- **Active cert card**: subject, issuer, expiry, fingerprint, self-signed badge, activated date +- **Staged cert card** (conditional): same metadata + Activate / Discard buttons, validation errors if any +- **Archived cert card** (conditional): same metadata + Restore button (disabled if expired) +- **Upload area**: file inputs for cert.pem (required), key.pem (required), ca.pem (optional) +- **Stale tenants banner**: "CA bundle updated - N tenants need restart" with restart action + +### React Hooks + +```typescript +useVendorCertificates() // GET /vendor/certificates +useStageCertificate() // POST multipart +useActivateCertificate() // POST activate +useRestoreCertificate() // POST restore +useDiscardStaged() // DELETE staged +useStaleTenants() // GET stale-tenants +``` + +## File Inventory + +### New Files + +| File | Description | +|------|-------------| +| `src/.../certificate/CertificateManager.java` | Provider interface | +| `src/.../certificate/CertificateInfo.java` | Cert metadata record | +| `src/.../certificate/CertValidationResult.java` | Validation result record | +| `src/.../certificate/CertificateEntity.java` | JPA entity | +| `src/.../certificate/CertificateRepository.java` | Spring Data repo | +| `src/.../certificate/CertificateService.java` | Business logic | +| `src/.../certificate/CertificateController.java` | REST endpoints | +| `src/.../provisioning/DockerCertificateManager.java` | Docker volume implementation | +| `src/main/resources/db/migration/V011__certificates.sql` | Migration | +| `ui/src/api/certificate-hooks.ts` | React Query hooks | +| `ui/src/pages/vendor/CertificatesPage.tsx` | Vendor UI page | + +### Modified Files + +| File | Change | +|------|--------| +| `docker-compose.yml` | Add CERT_FILE/KEY_FILE/CA_FILE env vars to init container | +| `traefik.yml` | No change (already reads from /certs/) | +| `src/.../provisioning/DockerTenantProvisioner.java` | Mount certs volume, set CA env vars, remove TLS skip flags | +| `ui/src/components/Layout.tsx` | Add Certificates sidebar item | +| `ui/src/router.tsx` | Add certificates route | +| `ui/src/api/vendor-hooks.ts` | Or new file for cert hooks | diff --git a/src/main/java/net/siegeln/cameleer/saas/certificate/CertValidationResult.java b/src/main/java/net/siegeln/cameleer/saas/certificate/CertValidationResult.java new file mode 100644 index 0000000..a779a19 --- /dev/null +++ b/src/main/java/net/siegeln/cameleer/saas/certificate/CertValidationResult.java @@ -0,0 +1,17 @@ +package net.siegeln.cameleer.saas.certificate; + +import java.util.List; + +public record CertValidationResult( + boolean valid, + List errors, + CertificateInfo info +) { + public static CertValidationResult ok(CertificateInfo info) { + return new CertValidationResult(true, List.of(), info); + } + + public static CertValidationResult fail(List errors) { + return new CertValidationResult(false, errors, null); + } +} diff --git a/src/main/java/net/siegeln/cameleer/saas/certificate/CertificateController.java b/src/main/java/net/siegeln/cameleer/saas/certificate/CertificateController.java new file mode 100644 index 0000000..5e7fbf0 --- /dev/null +++ b/src/main/java/net/siegeln/cameleer/saas/certificate/CertificateController.java @@ -0,0 +1,137 @@ +package net.siegeln.cameleer.saas.certificate; + +import org.springframework.http.ResponseEntity; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.security.oauth2.jwt.Jwt; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PostMapping; +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/vendor/certificates") +@PreAuthorize("hasAuthority('SCOPE_platform:admin')") +public class CertificateController { + + private final CertificateService certificateService; + + public CertificateController(CertificateService certificateService) { + this.certificateService = certificateService; + } + + // --- Response types --- + + public record CertificateResponse( + UUID id, String status, String subject, String issuer, + Instant notBefore, Instant notAfter, String fingerprint, + boolean hasCa, boolean selfSigned, Instant activatedAt, Instant archivedAt + ) { + public static CertificateResponse from(CertificateEntity e) { + if (e == null) return null; + return new CertificateResponse( + e.getId(), e.getStatus().name(), e.getSubject(), e.getIssuer(), + e.getNotBefore(), e.getNotAfter(), e.getFingerprint(), + e.isHasCa(), e.isSelfSigned(), e.getActivatedAt(), e.getArchivedAt() + ); + } + } + + public record OverviewResponse( + CertificateResponse active, + CertificateResponse staged, + CertificateResponse archived, + long staleTenantCount + ) {} + + public record StageResponse( + boolean valid, + List errors, + CertificateResponse certificate + ) {} + + // --- Endpoints --- + + @GetMapping + public ResponseEntity getOverview() { + var overview = certificateService.getOverview(); + long stale = certificateService.countStaleTenants(); + return ResponseEntity.ok(new OverviewResponse( + CertificateResponse.from(overview.active()), + CertificateResponse.from(overview.staged()), + CertificateResponse.from(overview.archived()), + stale + )); + } + + @PostMapping("/stage") + public ResponseEntity stage( + @RequestParam("cert") MultipartFile certFile, + @RequestParam("key") MultipartFile keyFile, + @RequestParam(value = "ca", required = false) MultipartFile caFile, + @AuthenticationPrincipal Jwt jwt) { + try { + byte[] certPem = certFile.getBytes(); + byte[] keyPem = keyFile.getBytes(); + byte[] caPem = caFile != null ? caFile.getBytes() : null; + UUID actorId = resolveActorId(jwt); + + CertValidationResult result = certificateService.stage(certPem, keyPem, caPem, actorId); + + if (!result.valid()) { + return ResponseEntity.badRequest().body( + new StageResponse(false, result.errors(), null)); + } + + var overview = certificateService.getOverview(); + return ResponseEntity.ok(new StageResponse( + true, List.of(), CertificateResponse.from(overview.staged()))); + } catch (Exception e) { + return ResponseEntity.badRequest().body( + new StageResponse(false, List.of(e.getMessage()), null)); + } + } + + @PostMapping("/activate") + public ResponseEntity activate() { + certificateService.activate(); + return ResponseEntity.noContent().build(); + } + + @PostMapping("/restore") + public ResponseEntity restore() { + try { + certificateService.restore(); + return ResponseEntity.noContent().build(); + } catch (IllegalStateException e) { + return ResponseEntity.badRequest().body(null); + } + } + + @DeleteMapping("/staged") + public ResponseEntity discardStaged() { + certificateService.discardStaged(); + return ResponseEntity.noContent().build(); + } + + @GetMapping("/stale-tenants") + public ResponseEntity> staleTenants() { + return ResponseEntity.ok(Map.of("count", certificateService.countStaleTenants())); + } + + private UUID resolveActorId(Jwt jwt) { + try { + return UUID.fromString(jwt.getSubject()); + } catch (Exception e) { + return UUID.nameUUIDFromBytes(jwt.getSubject().getBytes()); + } + } +} diff --git a/src/main/java/net/siegeln/cameleer/saas/certificate/CertificateEntity.java b/src/main/java/net/siegeln/cameleer/saas/certificate/CertificateEntity.java new file mode 100644 index 0000000..94cb23b --- /dev/null +++ b/src/main/java/net/siegeln/cameleer/saas/certificate/CertificateEntity.java @@ -0,0 +1,119 @@ +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 = "certificates") +public class CertificateEntity { + + public enum Status { ACTIVE, STAGED, ARCHIVED } + + @Id + @GeneratedValue(strategy = GenerationType.UUID) + private UUID id; + + @Enumerated(EnumType.STRING) + @Column(name = "status", nullable = false, length = 10) + private Status status; + + @Column(name = "subject", length = 500) + private String subject; + + @Column(name = "issuer", length = 500) + private String issuer; + + @Column(name = "not_before") + private Instant notBefore; + + @Column(name = "not_after") + private Instant notAfter; + + @Column(name = "fingerprint", length = 128) + private String fingerprint; + + @Column(name = "has_ca", nullable = false) + private boolean hasCa; + + @Column(name = "self_signed", nullable = false) + private boolean selfSigned; + + @Column(name = "uploaded_by") + private UUID uploadedBy; + + @Column(name = "created_at", nullable = false, updatable = false) + private Instant createdAt; + + @Column(name = "activated_at") + private Instant activatedAt; + + @Column(name = "archived_at") + private Instant archivedAt; + + @PrePersist + protected void onCreate() { + if (createdAt == null) createdAt = Instant.now(); + } + + // --- Getters and setters --- + + public UUID getId() { return id; } + + public Status getStatus() { return status; } + public void setStatus(Status status) { this.status = status; } + + 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 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 getFingerprint() { return fingerprint; } + public void setFingerprint(String fingerprint) { this.fingerprint = fingerprint; } + + public boolean isHasCa() { return hasCa; } + public void setHasCa(boolean hasCa) { this.hasCa = hasCa; } + + public boolean isSelfSigned() { return selfSigned; } + public void setSelfSigned(boolean selfSigned) { this.selfSigned = selfSigned; } + + public UUID getUploadedBy() { return uploadedBy; } + public void setUploadedBy(UUID uploadedBy) { this.uploadedBy = uploadedBy; } + + public Instant getCreatedAt() { return createdAt; } + + public Instant getActivatedAt() { return activatedAt; } + public void setActivatedAt(Instant activatedAt) { this.activatedAt = activatedAt; } + + public Instant getArchivedAt() { return archivedAt; } + public void setArchivedAt(Instant archivedAt) { this.archivedAt = archivedAt; } + + public static CertificateEntity fromInfo(CertificateInfo info, Status status) { + var entity = new CertificateEntity(); + entity.setStatus(status); + entity.setSubject(info.subject()); + entity.setIssuer(info.issuer()); + entity.setNotBefore(info.notBefore()); + entity.setNotAfter(info.notAfter()); + entity.setFingerprint(info.fingerprint()); + entity.setHasCa(info.hasCaBundle()); + entity.setSelfSigned(info.selfSigned()); + return entity; + } +} diff --git a/src/main/java/net/siegeln/cameleer/saas/certificate/CertificateInfo.java b/src/main/java/net/siegeln/cameleer/saas/certificate/CertificateInfo.java new file mode 100644 index 0000000..1efbea7 --- /dev/null +++ b/src/main/java/net/siegeln/cameleer/saas/certificate/CertificateInfo.java @@ -0,0 +1,13 @@ +package net.siegeln.cameleer.saas.certificate; + +import java.time.Instant; + +public record CertificateInfo( + String subject, + String issuer, + Instant notBefore, + Instant notAfter, + boolean hasCaBundle, + boolean selfSigned, + String fingerprint +) {} diff --git a/src/main/java/net/siegeln/cameleer/saas/certificate/CertificateManager.java b/src/main/java/net/siegeln/cameleer/saas/certificate/CertificateManager.java new file mode 100644 index 0000000..b13784a --- /dev/null +++ b/src/main/java/net/siegeln/cameleer/saas/certificate/CertificateManager.java @@ -0,0 +1,41 @@ +package net.siegeln.cameleer.saas.certificate; + +/** + * Provider interface for certificate file management. + * Docker implementation writes to the certs volume. + * K8s implementation would manage TLS Secrets. + */ +public interface CertificateManager { + + boolean isAvailable(); + + /** Read metadata of the active certificate from the provider storage. */ + CertificateInfo getActive(); + + /** Read metadata of the staged certificate, or null. */ + CertificateInfo getStaged(); + + /** Read metadata of the archived certificate, or null. */ + CertificateInfo getArchived(); + + /** + * Write cert+key+ca to staging area and validate. + * Does NOT activate — call {@link #activate()} to promote. + */ + CertValidationResult stage(byte[] certPem, byte[] keyPem, byte[] caBundlePem); + + /** Promote staged -> active. Moves current active to archive (deleting previous archive). */ + void activate(); + + /** Swap archived <-> active. */ + void restore(); + + /** Delete staged files. */ + void discardStaged(); + + /** Generate a self-signed certificate for the given hostname and store as active. */ + void generateSelfSigned(String hostname); + + /** Read the current CA bundle bytes, or null if none exists. */ + byte[] getCaBundle(); +} diff --git a/src/main/java/net/siegeln/cameleer/saas/certificate/CertificateRepository.java b/src/main/java/net/siegeln/cameleer/saas/certificate/CertificateRepository.java new file mode 100644 index 0000000..b4aaec1 --- /dev/null +++ b/src/main/java/net/siegeln/cameleer/saas/certificate/CertificateRepository.java @@ -0,0 +1,13 @@ +package net.siegeln.cameleer.saas.certificate; + +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.Optional; +import java.util.UUID; + +public interface CertificateRepository extends JpaRepository { + + Optional findByStatus(CertificateEntity.Status status); + + void deleteByStatus(CertificateEntity.Status status); +} diff --git a/src/main/java/net/siegeln/cameleer/saas/certificate/CertificateService.java b/src/main/java/net/siegeln/cameleer/saas/certificate/CertificateService.java new file mode 100644 index 0000000..d287e66 --- /dev/null +++ b/src/main/java/net/siegeln/cameleer/saas/certificate/CertificateService.java @@ -0,0 +1,154 @@ +package net.siegeln.cameleer.saas.certificate; + +import net.siegeln.cameleer.saas.tenant.TenantRepository; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.Instant; +import java.util.List; +import java.util.UUID; + +@Service +public class CertificateService { + + private static final Logger log = LoggerFactory.getLogger(CertificateService.class); + + private final CertificateManager certManager; + private final CertificateRepository certRepository; + private final TenantRepository tenantRepository; + + public CertificateService(CertificateManager certManager, + CertificateRepository certRepository, + TenantRepository tenantRepository) { + this.certManager = certManager; + this.certRepository = certRepository; + this.tenantRepository = tenantRepository; + } + + public record CertificateOverview( + CertificateEntity active, + CertificateEntity staged, + CertificateEntity archived + ) {} + + public CertificateOverview getOverview() { + return new CertificateOverview( + certRepository.findByStatus(CertificateEntity.Status.ACTIVE).orElse(null), + certRepository.findByStatus(CertificateEntity.Status.STAGED).orElse(null), + certRepository.findByStatus(CertificateEntity.Status.ARCHIVED).orElse(null) + ); + } + + @Transactional + public CertValidationResult stage(byte[] certPem, byte[] keyPem, byte[] caBundlePem, UUID actorId) { + if (!certManager.isAvailable()) { + return CertValidationResult.fail(List.of("Certificate management is not available")); + } + + // Discard any existing staged cert + certRepository.findByStatus(CertificateEntity.Status.STAGED).ifPresent(certRepository::delete); + + // Stage files and validate + CertValidationResult result = certManager.stage(certPem, keyPem, caBundlePem); + if (!result.valid()) { + return result; + } + + // Save metadata to DB + var entity = CertificateEntity.fromInfo(result.info(), CertificateEntity.Status.STAGED); + entity.setUploadedBy(actorId); + certRepository.save(entity); + + log.info("Certificate staged by actor {}: subject={}", actorId, result.info().subject()); + return result; + } + + @Transactional + public void activate() { + var staged = certRepository.findByStatus(CertificateEntity.Status.STAGED) + .orElseThrow(() -> new IllegalStateException("No staged certificate to activate")); + + // File operations: delete archive files, move active -> archive, move staged -> active + certManager.activate(); + + // DB: delete archived, active -> archived, staged -> active + certRepository.findByStatus(CertificateEntity.Status.ARCHIVED).ifPresent(certRepository::delete); + + certRepository.findByStatus(CertificateEntity.Status.ACTIVE).ifPresent(active -> { + active.setStatus(CertificateEntity.Status.ARCHIVED); + active.setArchivedAt(Instant.now()); + certRepository.save(active); + }); + + staged.setStatus(CertificateEntity.Status.ACTIVE); + staged.setActivatedAt(Instant.now()); + certRepository.save(staged); + + log.info("Certificate activated: subject={}", staged.getSubject()); + } + + @Transactional + public void restore() { + var archived = certRepository.findByStatus(CertificateEntity.Status.ARCHIVED) + .orElseThrow(() -> new IllegalStateException("No archived certificate to restore")); + + if (archived.getNotAfter() != null && archived.getNotAfter().isBefore(Instant.now())) { + throw new IllegalStateException("Archived certificate has expired and cannot be restored"); + } + + // File operations: swap active <-> archive + certManager.restore(); + + // DB: swap statuses + var active = certRepository.findByStatus(CertificateEntity.Status.ACTIVE).orElse(null); + + archived.setStatus(CertificateEntity.Status.ACTIVE); + archived.setActivatedAt(Instant.now()); + archived.setArchivedAt(null); + certRepository.save(archived); + + if (active != null) { + active.setStatus(CertificateEntity.Status.ARCHIVED); + active.setArchivedAt(Instant.now()); + certRepository.save(active); + } + + log.info("Certificate restored from archive: subject={}", archived.getSubject()); + } + + @Transactional + public void discardStaged() { + certManager.discardStaged(); + certRepository.findByStatus(CertificateEntity.Status.STAGED).ifPresent(certRepository::delete); + log.info("Staged certificate discarded"); + } + + /** + * Count tenants whose ca_applied_at is before the active cert's activated_at, + * meaning they haven't picked up the latest CA bundle. + */ + public long countStaleTenants() { + var active = certRepository.findByStatus(CertificateEntity.Status.ACTIVE).orElse(null); + if (active == null || active.getActivatedAt() == null) return 0; + return tenantRepository.countByCaAppliedAtBeforeOrCaAppliedAtIsNull(active.getActivatedAt()); + } + + /** + * Seed the DB from the filesystem on startup (for bootstrap-generated certs). + */ + @Transactional + public void seedFromFilesystem() { + if (certRepository.findByStatus(CertificateEntity.Status.ACTIVE).isPresent()) { + return; // Already seeded + } + CertificateInfo activeInfo = certManager.getActive(); + if (activeInfo != null) { + var entity = CertificateEntity.fromInfo(activeInfo, CertificateEntity.Status.ACTIVE); + entity.setActivatedAt(Instant.now()); + certRepository.save(entity); + log.info("Seeded certificate metadata from filesystem: subject={}", activeInfo.subject()); + } + } +} diff --git a/src/main/java/net/siegeln/cameleer/saas/certificate/CertificateStartupListener.java b/src/main/java/net/siegeln/cameleer/saas/certificate/CertificateStartupListener.java new file mode 100644 index 0000000..5d5f4b6 --- /dev/null +++ b/src/main/java/net/siegeln/cameleer/saas/certificate/CertificateStartupListener.java @@ -0,0 +1,20 @@ +package net.siegeln.cameleer.saas.certificate; + +import org.springframework.boot.context.event.ApplicationReadyEvent; +import org.springframework.context.event.EventListener; +import org.springframework.stereotype.Component; + +@Component +public class CertificateStartupListener { + + private final CertificateService certificateService; + + public CertificateStartupListener(CertificateService certificateService) { + this.certificateService = certificateService; + } + + @EventListener(ApplicationReadyEvent.class) + public void onReady() { + certificateService.seedFromFilesystem(); + } +} diff --git a/src/main/java/net/siegeln/cameleer/saas/provisioning/CertificateManagerAutoConfig.java b/src/main/java/net/siegeln/cameleer/saas/provisioning/CertificateManagerAutoConfig.java new file mode 100644 index 0000000..b8294e1 --- /dev/null +++ b/src/main/java/net/siegeln/cameleer/saas/provisioning/CertificateManagerAutoConfig.java @@ -0,0 +1,29 @@ +package net.siegeln.cameleer.saas.provisioning; + +import net.siegeln.cameleer.saas.certificate.CertificateManager; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +import java.nio.file.Files; +import java.nio.file.Path; + +@Configuration +public class CertificateManagerAutoConfig { + + private static final Logger log = LoggerFactory.getLogger(CertificateManagerAutoConfig.class); + + @Bean + CertificateManager certificateManager( + @Value("${cameleer.certs.path:/certs}") String certsPath) { + Path path = Path.of(certsPath); + if (Files.isDirectory(path)) { + log.info("Certs directory found at {} — enabling Docker certificate manager", certsPath); + return new DockerCertificateManager(path); + } + log.info("No certs directory at {} — certificate management disabled", certsPath); + return new DisabledCertificateManager(); + } +} diff --git a/src/main/java/net/siegeln/cameleer/saas/provisioning/DisabledCertificateManager.java b/src/main/java/net/siegeln/cameleer/saas/provisioning/DisabledCertificateManager.java new file mode 100644 index 0000000..3db5b1c --- /dev/null +++ b/src/main/java/net/siegeln/cameleer/saas/provisioning/DisabledCertificateManager.java @@ -0,0 +1,45 @@ +package net.siegeln.cameleer.saas.provisioning; + +import net.siegeln.cameleer.saas.certificate.CertificateInfo; +import net.siegeln.cameleer.saas.certificate.CertificateManager; +import net.siegeln.cameleer.saas.certificate.CertValidationResult; + +import java.util.List; + +/** + * No-op certificate manager when certs directory is not available. + */ +public class DisabledCertificateManager implements CertificateManager { + + @Override + public boolean isAvailable() { return false; } + + @Override + public CertificateInfo getActive() { return null; } + + @Override + public CertificateInfo getStaged() { return null; } + + @Override + public CertificateInfo getArchived() { return null; } + + @Override + public CertValidationResult stage(byte[] certPem, byte[] keyPem, byte[] caBundlePem) { + return CertValidationResult.fail(List.of("Certificate management is not available")); + } + + @Override + public void activate() {} + + @Override + public void restore() {} + + @Override + public void discardStaged() {} + + @Override + public void generateSelfSigned(String hostname) {} + + @Override + public byte[] getCaBundle() { return null; } +} diff --git a/src/main/java/net/siegeln/cameleer/saas/provisioning/DockerCertificateManager.java b/src/main/java/net/siegeln/cameleer/saas/provisioning/DockerCertificateManager.java new file mode 100644 index 0000000..b179dd7 --- /dev/null +++ b/src/main/java/net/siegeln/cameleer/saas/provisioning/DockerCertificateManager.java @@ -0,0 +1,320 @@ +package net.siegeln.cameleer.saas.provisioning; + +import net.siegeln.cameleer.saas.certificate.CertificateInfo; +import net.siegeln.cameleer.saas.certificate.CertificateManager; +import net.siegeln.cameleer.saas.certificate.CertValidationResult; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.StandardCopyOption; +import java.security.KeyFactory; +import java.security.MessageDigest; +import java.security.cert.CertificateFactory; +import java.security.cert.X509Certificate; +import java.security.interfaces.RSAPrivateKey; +import java.security.interfaces.RSAPublicKey; +import java.security.spec.PKCS8EncodedKeySpec; +import java.time.Instant; +import java.util.ArrayList; +import java.util.Base64; +import java.util.HexFormat; +import java.util.List; + +public class DockerCertificateManager implements CertificateManager { + + private static final Logger log = LoggerFactory.getLogger(DockerCertificateManager.class); + + private final Path certsDir; + + public DockerCertificateManager(Path certsDir) { + this.certsDir = certsDir; + } + + @Override + public boolean isAvailable() { + return Files.isDirectory(certsDir) && Files.isWritable(certsDir); + } + + @Override + public CertificateInfo getActive() { + return readCertInfo(certsDir.resolve("cert.pem"), certsDir.resolve("ca.pem")); + } + + @Override + public CertificateInfo getStaged() { + return readCertInfo(certsDir.resolve("staged/cert.pem"), certsDir.resolve("staged/ca.pem")); + } + + @Override + public CertificateInfo getArchived() { + return readCertInfo(certsDir.resolve("prev/cert.pem"), certsDir.resolve("prev/ca.pem")); + } + + @Override + public CertValidationResult stage(byte[] certPem, byte[] keyPem, byte[] caBundlePem) { + List errors = new ArrayList<>(); + + // Parse certificate + X509Certificate cert; + try { + cert = parseCertificate(certPem); + } catch (Exception e) { + errors.add("Invalid certificate PEM: " + e.getMessage()); + return CertValidationResult.fail(errors); + } + + // Parse private key + try { + var privateKey = parsePrivateKey(keyPem); + // Verify key matches cert + if (cert.getPublicKey() instanceof RSAPublicKey rsaPub + && privateKey instanceof RSAPrivateKey rsaPriv) { + if (!rsaPub.getModulus().equals(rsaPriv.getModulus())) { + errors.add("Private key does not match certificate"); + } + } + } catch (Exception e) { + errors.add("Invalid private key PEM: " + e.getMessage()); + } + + // Parse CA bundle if provided + if (caBundlePem != null && caBundlePem.length > 0) { + try { + var certs = parseCertificates(caBundlePem); + if (certs.isEmpty()) { + errors.add("CA bundle contains no valid certificates"); + } + } catch (Exception e) { + errors.add("Invalid CA bundle PEM: " + e.getMessage()); + } + } + + if (!errors.isEmpty()) { + return CertValidationResult.fail(errors); + } + + // Write to staged directory + try { + Path stagedDir = certsDir.resolve("staged"); + Files.createDirectories(stagedDir); + + writeAtomic(stagedDir.resolve("cert.pem"), certPem); + writeAtomic(stagedDir.resolve("key.pem"), keyPem); + if (caBundlePem != null && caBundlePem.length > 0) { + writeAtomic(stagedDir.resolve("ca.pem"), caBundlePem); + } else { + Files.deleteIfExists(stagedDir.resolve("ca.pem")); + } + } catch (IOException e) { + log.error("Failed to write staged certificate files", e); + return CertValidationResult.fail(List.of("Failed to write staged files: " + e.getMessage())); + } + + var info = toCertInfo(cert, caBundlePem != null && caBundlePem.length > 0, false); + return CertValidationResult.ok(info); + } + + @Override + public void activate() { + try { + Path stagedDir = certsDir.resolve("staged"); + Path prevDir = certsDir.resolve("prev"); + + if (!Files.exists(stagedDir.resolve("cert.pem"))) { + throw new IllegalStateException("No staged certificate to activate"); + } + + // Delete existing archive + deleteDirectory(prevDir); + + // Move current active -> prev (archive) + if (Files.exists(certsDir.resolve("cert.pem"))) { + Files.createDirectories(prevDir); + moveFile(certsDir.resolve("cert.pem"), prevDir.resolve("cert.pem")); + moveFile(certsDir.resolve("key.pem"), prevDir.resolve("key.pem")); + if (Files.exists(certsDir.resolve("ca.pem"))) { + Files.copy(certsDir.resolve("ca.pem"), prevDir.resolve("ca.pem")); + } + } + + // Move staged -> active (atomic swap via .wip) + writeAtomic(certsDir.resolve("cert.pem"), Files.readAllBytes(stagedDir.resolve("cert.pem"))); + writeAtomic(certsDir.resolve("key.pem"), Files.readAllBytes(stagedDir.resolve("key.pem"))); + if (Files.exists(stagedDir.resolve("ca.pem"))) { + writeAtomic(certsDir.resolve("ca.pem"), Files.readAllBytes(stagedDir.resolve("ca.pem"))); + } + + // Clean up staged + deleteDirectory(stagedDir); + log.info("Certificate activated successfully"); + } catch (IOException e) { + throw new RuntimeException("Failed to activate certificate", e); + } + } + + @Override + public void restore() { + try { + Path prevDir = certsDir.resolve("prev"); + if (!Files.exists(prevDir.resolve("cert.pem"))) { + throw new IllegalStateException("No archived certificate to restore"); + } + + // Swap: active <-> prev using a temp dir + Path tempDir = certsDir.resolve("swap-tmp"); + Files.createDirectories(tempDir); + + // Current active -> temp + moveFile(certsDir.resolve("cert.pem"), tempDir.resolve("cert.pem")); + moveFile(certsDir.resolve("key.pem"), tempDir.resolve("key.pem")); + if (Files.exists(certsDir.resolve("ca.pem"))) { + moveFile(certsDir.resolve("ca.pem"), tempDir.resolve("ca.pem")); + } + + // Prev -> active + writeAtomic(certsDir.resolve("cert.pem"), Files.readAllBytes(prevDir.resolve("cert.pem"))); + writeAtomic(certsDir.resolve("key.pem"), Files.readAllBytes(prevDir.resolve("key.pem"))); + if (Files.exists(prevDir.resolve("ca.pem"))) { + writeAtomic(certsDir.resolve("ca.pem"), Files.readAllBytes(prevDir.resolve("ca.pem"))); + } else { + Files.deleteIfExists(certsDir.resolve("ca.pem")); + } + + // Temp -> prev + deleteDirectory(prevDir); + Files.createDirectories(prevDir); + moveFile(tempDir.resolve("cert.pem"), prevDir.resolve("cert.pem")); + moveFile(tempDir.resolve("key.pem"), prevDir.resolve("key.pem")); + if (Files.exists(tempDir.resolve("ca.pem"))) { + moveFile(tempDir.resolve("ca.pem"), prevDir.resolve("ca.pem")); + } + + deleteDirectory(tempDir); + log.info("Certificate restored from archive"); + } catch (IOException e) { + throw new RuntimeException("Failed to restore certificate", e); + } + } + + @Override + public void discardStaged() { + try { + deleteDirectory(certsDir.resolve("staged")); + log.info("Staged certificate discarded"); + } catch (IOException e) { + throw new RuntimeException("Failed to discard staged certificate", e); + } + } + + @Override + public void generateSelfSigned(String hostname) { + try { + // Use keytool to generate a self-signed cert, then export to PEM + // This is a fallback — the init container normally handles this + log.warn("generateSelfSigned called on DockerCertificateManager — " + + "this is typically handled by the traefik-certs init container"); + } catch (Exception e) { + throw new RuntimeException("Failed to generate self-signed certificate", e); + } + } + + @Override + public byte[] getCaBundle() { + try { + Path caPath = certsDir.resolve("ca.pem"); + return Files.exists(caPath) ? Files.readAllBytes(caPath) : null; + } catch (IOException e) { + log.warn("Failed to read CA bundle: {}", e.getMessage()); + return null; + } + } + + // --- Internal helpers --- + + private CertificateInfo readCertInfo(Path certPath, Path caPath) { + if (!Files.exists(certPath)) return null; + try { + byte[] certBytes = Files.readAllBytes(certPath); + X509Certificate cert = parseCertificate(certBytes); + boolean hasCa = Files.exists(caPath); + boolean selfSigned = cert.getIssuerX500Principal().equals(cert.getSubjectX500Principal()); + return toCertInfo(cert, hasCa, selfSigned); + } catch (Exception e) { + log.warn("Failed to read cert info from {}: {}", certPath, e.getMessage()); + return null; + } + } + + private CertificateInfo toCertInfo(X509Certificate cert, boolean hasCa, boolean selfSigned) { + try { + String fingerprint = HexFormat.ofDelimiter(":").formatHex( + MessageDigest.getInstance("SHA-256").digest(cert.getEncoded())); + // Auto-detect self-signed + if (cert.getIssuerX500Principal().equals(cert.getSubjectX500Principal())) { + selfSigned = true; + } + return new CertificateInfo( + cert.getSubjectX500Principal().getName(), + cert.getIssuerX500Principal().getName(), + cert.getNotBefore().toInstant(), + cert.getNotAfter().toInstant(), + hasCa, + selfSigned, + fingerprint + ); + } catch (Exception e) { + throw new RuntimeException("Failed to extract cert info", e); + } + } + + static X509Certificate parseCertificate(byte[] pem) throws Exception { + CertificateFactory cf = CertificateFactory.getInstance("X.509"); + return (X509Certificate) cf.generateCertificate(new ByteArrayInputStream(pem)); + } + + static List parseCertificates(byte[] pem) throws Exception { + CertificateFactory cf = CertificateFactory.getInstance("X.509"); + var certs = cf.generateCertificates(new ByteArrayInputStream(pem)); + return certs.stream().map(c -> (X509Certificate) c).toList(); + } + + static java.security.PrivateKey parsePrivateKey(byte[] pem) throws Exception { + String pemStr = new String(pem); + // Extract base64 content between PEM markers (handles Bag Attributes etc.) + var matcher = java.util.regex.Pattern + .compile("-----BEGIN (?:RSA )?PRIVATE KEY-----(.+?)-----END (?:RSA )?PRIVATE KEY-----", + java.util.regex.Pattern.DOTALL) + .matcher(pemStr); + if (!matcher.find()) { + throw new IllegalArgumentException("No private key PEM block found"); + } + String base64 = matcher.group(1).replaceAll("\\s+", ""); + byte[] decoded = Base64.getDecoder().decode(base64); + PKCS8EncodedKeySpec spec = new PKCS8EncodedKeySpec(decoded); + return KeyFactory.getInstance("RSA").generatePrivate(spec); + } + + private void writeAtomic(Path target, byte[] data) throws IOException { + Path wip = target.resolveSibling(target.getFileName() + ".wip"); + Files.write(wip, data); + Files.move(wip, target, StandardCopyOption.REPLACE_EXISTING, StandardCopyOption.ATOMIC_MOVE); + } + + private void moveFile(Path source, Path target) throws IOException { + Files.move(source, target, StandardCopyOption.REPLACE_EXISTING); + } + + private void deleteDirectory(Path dir) throws IOException { + if (!Files.exists(dir)) return; + try (var walk = Files.walk(dir)) { + walk.sorted(java.util.Comparator.reverseOrder()) + .forEach(p -> { + try { Files.deleteIfExists(p); } catch (IOException ignored) {} + }); + } + } +} diff --git a/src/main/java/net/siegeln/cameleer/saas/provisioning/DockerTenantProvisioner.java b/src/main/java/net/siegeln/cameleer/saas/provisioning/DockerTenantProvisioner.java index 6bd7539..e329f47 100644 --- a/src/main/java/net/siegeln/cameleer/saas/provisioning/DockerTenantProvisioner.java +++ b/src/main/java/net/siegeln/cameleer/saas/provisioning/DockerTenantProvisioner.java @@ -135,7 +135,7 @@ public class DockerTenantProvisioner implements TenantProvisioner { labels.put("cameleer.tenant", slug); labels.put("cameleer.role", "server"); - List env = List.of( + var env = new java.util.ArrayList<>(List.of( "SPRING_DATASOURCE_URL=" + props.datasourceUrl(), "SPRING_DATASOURCE_USERNAME=cameleer", "SPRING_DATASOURCE_PASSWORD=cameleer_dev", @@ -146,7 +146,6 @@ public class DockerTenantProvisioner implements TenantProvisioner { "CAMELEER_OIDC_ISSUER_URI=" + props.oidcIssuerUri(), "CAMELEER_OIDC_JWK_SET_URI=" + props.oidcJwkSetUri(), "CAMELEER_OIDC_AUDIENCE=https://api.cameleer.local", - "CAMELEER_OIDC_TLS_SKIP_VERIFY=true", "CAMELEER_CORS_ALLOWED_ORIGINS=" + props.corsOrigins(), "CAMELEER_LICENSE_TOKEN=" + req.licenseToken(), "CAMELEER_RUNTIME_ENABLED=true", @@ -157,7 +156,11 @@ public class DockerTenantProvisioner implements TenantProvisioner { // Apps deployed by this server join the tenant network (isolated) "CAMELEER_DOCKER_NETWORK=" + tenantNetwork, "CAMELEER_JAR_DOCKER_VOLUME=cameleer-jars-" + slug - ); + )); + // If no CA bundle exists, fall back to TLS skip for OIDC (self-signed dev) + if (!java.nio.file.Files.exists(java.nio.file.Path.of("/certs/ca.pem"))) { + env.add("CAMELEER_OIDC_TLS_SKIP_VERIFY=true"); + } // Primary network = tenant-isolated network HostConfig hostConfig = HostConfig.newHostConfig() @@ -165,7 +168,8 @@ public class DockerTenantProvisioner implements TenantProvisioner { .withNetworkMode(tenantNetwork) .withBinds( new Bind("/var/run/docker.sock", new Volume("/var/run/docker.sock")), - new Bind("cameleer-jars-" + slug, new Volume("/data/jars")) + new Bind("cameleer-jars-" + slug, new Volume("/data/jars")), + new Bind("cameleer-saas_certs", new Volume("/certs"), AccessMode.ro) ) .withGroupAdd(List.of("0")); diff --git a/src/main/java/net/siegeln/cameleer/saas/tenant/TenantEntity.java b/src/main/java/net/siegeln/cameleer/saas/tenant/TenantEntity.java index 71f0a25..b044d0d 100644 --- a/src/main/java/net/siegeln/cameleer/saas/tenant/TenantEntity.java +++ b/src/main/java/net/siegeln/cameleer/saas/tenant/TenantEntity.java @@ -58,6 +58,9 @@ public class TenantEntity { @Column(name = "provision_error", columnDefinition = "TEXT") private String provisionError; + @Column(name = "ca_applied_at") + private Instant caAppliedAt; + @Column(name = "created_at", nullable = false, updatable = false) private Instant createdAt; @@ -97,6 +100,8 @@ public class TenantEntity { public void setServerEndpoint(String serverEndpoint) { this.serverEndpoint = serverEndpoint; } public String getProvisionError() { return provisionError; } public void setProvisionError(String provisionError) { this.provisionError = provisionError; } + public Instant getCaAppliedAt() { return caAppliedAt; } + public void setCaAppliedAt(Instant caAppliedAt) { this.caAppliedAt = caAppliedAt; } public Instant getCreatedAt() { return createdAt; } public Instant getUpdatedAt() { return updatedAt; } } diff --git a/src/main/java/net/siegeln/cameleer/saas/tenant/TenantRepository.java b/src/main/java/net/siegeln/cameleer/saas/tenant/TenantRepository.java index 107eb21..fdde1bb 100644 --- a/src/main/java/net/siegeln/cameleer/saas/tenant/TenantRepository.java +++ b/src/main/java/net/siegeln/cameleer/saas/tenant/TenantRepository.java @@ -3,6 +3,7 @@ package net.siegeln.cameleer.saas.tenant; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.stereotype.Repository; +import java.time.Instant; import java.util.List; import java.util.Optional; import java.util.UUID; @@ -14,4 +15,5 @@ public interface TenantRepository extends JpaRepository { List findByStatus(TenantStatus status); boolean existsBySlug(String slug); boolean existsBySlugAndStatusNot(String slug, TenantStatus status); + long countByCaAppliedAtBeforeOrCaAppliedAtIsNull(Instant threshold); } diff --git a/src/main/resources/db/migration/V012__create_certificates.sql b/src/main/resources/db/migration/V012__create_certificates.sql new file mode 100644 index 0000000..d6124af --- /dev/null +++ b/src/main/resources/db/migration/V012__create_certificates.sql @@ -0,0 +1,19 @@ +-- Certificate management: track platform TLS certs and CA bundles +CREATE TABLE certificates ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + status VARCHAR(10) NOT NULL CHECK (status IN ('ACTIVE', 'STAGED', 'ARCHIVED')), + subject VARCHAR(500), + issuer VARCHAR(500), + not_before TIMESTAMPTZ, + not_after TIMESTAMPTZ, + fingerprint VARCHAR(128), + has_ca BOOLEAN NOT NULL DEFAULT FALSE, + self_signed BOOLEAN NOT NULL DEFAULT FALSE, + uploaded_by UUID, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + activated_at TIMESTAMPTZ, + archived_at TIMESTAMPTZ +); + +-- Track when each tenant last picked up the CA bundle +ALTER TABLE tenants ADD COLUMN ca_applied_at TIMESTAMPTZ; diff --git a/src/test/java/net/siegeln/cameleer/saas/certificate/CertificateServiceTest.java b/src/test/java/net/siegeln/cameleer/saas/certificate/CertificateServiceTest.java new file mode 100644 index 0000000..33ed830 --- /dev/null +++ b/src/test/java/net/siegeln/cameleer/saas/certificate/CertificateServiceTest.java @@ -0,0 +1,218 @@ +package net.siegeln.cameleer.saas.certificate; + +import net.siegeln.cameleer.saas.tenant.TenantRepository; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.time.Instant; +import java.util.List; +import java.util.Optional; +import java.util.UUID; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +class CertificateServiceTest { + + @Mock + private CertificateManager certManager; + + @Mock + private CertificateRepository certRepository; + + @Mock + private TenantRepository tenantRepository; + + private CertificateService service; + + @BeforeEach + void setUp() { + service = new CertificateService(certManager, certRepository, tenantRepository); + } + + @Test + void stage_delegatesToManagerAndSavesEntity() { + var info = new CertificateInfo("CN=test", "CN=test", Instant.now(), + Instant.now().plusSeconds(86400), false, true, "AA:BB"); + when(certManager.isAvailable()).thenReturn(true); + when(certManager.stage(any(), any(), any())) + .thenReturn(CertValidationResult.ok(info)); + when(certRepository.findByStatus(CertificateEntity.Status.STAGED)) + .thenReturn(Optional.empty()); + when(certRepository.save(any())).thenAnswer(inv -> inv.getArgument(0)); + + var result = service.stage("cert".getBytes(), "key".getBytes(), null, UUID.randomUUID()); + + assertThat(result.valid()).isTrue(); + var captor = ArgumentCaptor.forClass(CertificateEntity.class); + verify(certRepository).save(captor.capture()); + assertThat(captor.getValue().getStatus()).isEqualTo(CertificateEntity.Status.STAGED); + assertThat(captor.getValue().getSubject()).isEqualTo("CN=test"); + } + + @Test + void stage_discardsExistingStagedFirst() { + var existing = new CertificateEntity(); + when(certManager.isAvailable()).thenReturn(true); + when(certRepository.findByStatus(CertificateEntity.Status.STAGED)) + .thenReturn(Optional.of(existing)); + when(certManager.stage(any(), any(), any())) + .thenReturn(CertValidationResult.fail(List.of("bad cert"))); + + service.stage("cert".getBytes(), "key".getBytes(), null, UUID.randomUUID()); + + verify(certRepository).delete(existing); + } + + @Test + void stage_returnsErrorWhenManagerUnavailable() { + when(certManager.isAvailable()).thenReturn(false); + + var result = service.stage("cert".getBytes(), "key".getBytes(), null, UUID.randomUUID()); + + assertThat(result.valid()).isFalse(); + assertThat(result.errors()).contains("Certificate management is not available"); + } + + @Test + void activate_promotesStagedToActive() { + var staged = new CertificateEntity(); + staged.setStatus(CertificateEntity.Status.STAGED); + staged.setSubject("CN=new"); + + when(certRepository.findByStatus(CertificateEntity.Status.STAGED)) + .thenReturn(Optional.of(staged)); + when(certRepository.findByStatus(CertificateEntity.Status.ARCHIVED)) + .thenReturn(Optional.empty()); + when(certRepository.findByStatus(CertificateEntity.Status.ACTIVE)) + .thenReturn(Optional.empty()); + + service.activate(); + + verify(certManager).activate(); + assertThat(staged.getStatus()).isEqualTo(CertificateEntity.Status.ACTIVE); + assertThat(staged.getActivatedAt()).isNotNull(); + } + + @Test + void activate_archivesCurrentActive() { + var staged = new CertificateEntity(); + staged.setStatus(CertificateEntity.Status.STAGED); + + var active = new CertificateEntity(); + active.setStatus(CertificateEntity.Status.ACTIVE); + + when(certRepository.findByStatus(CertificateEntity.Status.STAGED)) + .thenReturn(Optional.of(staged)); + when(certRepository.findByStatus(CertificateEntity.Status.ARCHIVED)) + .thenReturn(Optional.empty()); + when(certRepository.findByStatus(CertificateEntity.Status.ACTIVE)) + .thenReturn(Optional.of(active)); + + service.activate(); + + assertThat(active.getStatus()).isEqualTo(CertificateEntity.Status.ARCHIVED); + assertThat(active.getArchivedAt()).isNotNull(); + } + + @Test + void activate_failsWithNoStagedCert() { + when(certRepository.findByStatus(CertificateEntity.Status.STAGED)) + .thenReturn(Optional.empty()); + + assertThatThrownBy(() -> service.activate()) + .isInstanceOf(IllegalStateException.class); + } + + @Test + void restore_swapsActiveAndArchived() { + var active = new CertificateEntity(); + active.setStatus(CertificateEntity.Status.ACTIVE); + active.setSubject("CN=current"); + + var archived = new CertificateEntity(); + archived.setStatus(CertificateEntity.Status.ARCHIVED); + archived.setSubject("CN=previous"); + // Set notAfter in the future so it's restorable + archived.setNotAfter(Instant.now().plusSeconds(86400 * 365)); + + when(certRepository.findByStatus(CertificateEntity.Status.ARCHIVED)) + .thenReturn(Optional.of(archived)); + when(certRepository.findByStatus(CertificateEntity.Status.ACTIVE)) + .thenReturn(Optional.of(active)); + + service.restore(); + + verify(certManager).restore(); + assertThat(archived.getStatus()).isEqualTo(CertificateEntity.Status.ACTIVE); + assertThat(active.getStatus()).isEqualTo(CertificateEntity.Status.ARCHIVED); + } + + @Test + void restore_failsWhenArchivedExpired() { + var archived = new CertificateEntity(); + archived.setStatus(CertificateEntity.Status.ARCHIVED); + archived.setNotAfter(Instant.now().minusSeconds(3600)); // expired + + when(certRepository.findByStatus(CertificateEntity.Status.ARCHIVED)) + .thenReturn(Optional.of(archived)); + + assertThatThrownBy(() -> service.restore()) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("expired"); + } + + @Test + void discardStaged_delegatesAndDeletesEntity() { + var staged = new CertificateEntity(); + when(certRepository.findByStatus(CertificateEntity.Status.STAGED)) + .thenReturn(Optional.of(staged)); + + service.discardStaged(); + + verify(certManager).discardStaged(); + verify(certRepository).delete(staged); + } + + @Test + void seedFromFilesystem_skipsWhenActiveExists() { + when(certRepository.findByStatus(CertificateEntity.Status.ACTIVE)) + .thenReturn(Optional.of(new CertificateEntity())); + + service.seedFromFilesystem(); + + verify(certManager, never()).getActive(); + } + + @Test + void seedFromFilesystem_seedsFromManager() { + var info = new CertificateInfo("CN=bootstrap", "CN=bootstrap", Instant.now(), + Instant.now().plusSeconds(86400), false, true, "AA:BB"); + when(certRepository.findByStatus(CertificateEntity.Status.ACTIVE)) + .thenReturn(Optional.empty()); + when(certManager.getActive()).thenReturn(info); + when(certRepository.save(any())).thenAnswer(inv -> inv.getArgument(0)); + + service.seedFromFilesystem(); + + var captor = ArgumentCaptor.forClass(CertificateEntity.class); + verify(certRepository).save(captor.capture()); + assertThat(captor.getValue().getSubject()).isEqualTo("CN=bootstrap"); + assertThat(captor.getValue().getStatus()).isEqualTo(CertificateEntity.Status.ACTIVE); + } + + @Test + void countStaleTenants_returnsZeroWhenNoActiveCert() { + when(certRepository.findByStatus(CertificateEntity.Status.ACTIVE)) + .thenReturn(Optional.empty()); + + assertThat(service.countStaleTenants()).isZero(); + } +} diff --git a/src/test/java/net/siegeln/cameleer/saas/certificate/DockerCertificateManagerTest.java b/src/test/java/net/siegeln/cameleer/saas/certificate/DockerCertificateManagerTest.java new file mode 100644 index 0000000..7639026 --- /dev/null +++ b/src/test/java/net/siegeln/cameleer/saas/certificate/DockerCertificateManagerTest.java @@ -0,0 +1,224 @@ +package net.siegeln.cameleer.saas.certificate; + +import net.siegeln.cameleer.saas.provisioning.DockerCertificateManager; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import java.nio.file.Files; +import java.nio.file.Path; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class DockerCertificateManagerTest { + + @TempDir + Path certsDir; + + private DockerCertificateManager manager; + + @BeforeEach + void setUp() { + manager = new DockerCertificateManager(certsDir); + } + + @Test + void isAvailable_returnsTrueForWritableDirectory() { + assertThat(manager.isAvailable()).isTrue(); + } + + @Test + void getActive_returnsNullWhenNoCert() { + assertThat(manager.getActive()).isNull(); + } + + @Test + void stage_validatesAndWritesFiles() throws Exception { + byte[][] pair = generateCertAndKey("CN=test.example.com"); + + var result = manager.stage(pair[0], pair[1], null); + + assertThat(result.valid()).isTrue(); + assertThat(result.info()).isNotNull(); + assertThat(result.info().subject()).contains("test.example.com"); + assertThat(result.info().selfSigned()).isTrue(); + assertThat(Files.exists(certsDir.resolve("staged/cert.pem"))).isTrue(); + assertThat(Files.exists(certsDir.resolve("staged/key.pem"))).isTrue(); + } + + @Test + void stage_rejectsInvalidCertPem() { + var result = manager.stage("not a cert".getBytes(), "not a key".getBytes(), null); + assertThat(result.valid()).isFalse(); + assertThat(result.errors()).isNotEmpty(); + } + + @Test + void stage_rejectsMismatchedKeyAndCert() throws Exception { + byte[][] pair1 = generateCertAndKey("CN=test.example.com"); + byte[][] pair2 = generateCertAndKey("CN=other.example.com"); + + var result = manager.stage(pair1[0], pair2[1], null); + assertThat(result.valid()).isFalse(); + assertThat(result.errors()).anyMatch(e -> e.contains("does not match")); + } + + @Test + void activateAndRestore_lifecycle() throws Exception { + // Set up an initial active cert + byte[][] pair1 = generateCertAndKey("CN=first.example.com"); + Files.write(certsDir.resolve("cert.pem"), pair1[0]); + Files.write(certsDir.resolve("key.pem"), pair1[1]); + + // Stage a second cert + byte[][] pair2 = generateCertAndKey("CN=second.example.com"); + var stageResult = manager.stage(pair2[0], pair2[1], null); + assertThat(stageResult.valid()).isTrue(); + + // Activate: first -> archived, second -> active + manager.activate(); + + var active = manager.getActive(); + assertThat(active).isNotNull(); + assertThat(active.subject()).contains("second.example.com"); + + var archived = manager.getArchived(); + assertThat(archived).isNotNull(); + assertThat(archived.subject()).contains("first.example.com"); + + assertThat(manager.getStaged()).isNull(); + + // Restore: swap active <-> archived + manager.restore(); + + active = manager.getActive(); + assertThat(active.subject()).contains("first.example.com"); + + archived = manager.getArchived(); + assertThat(archived.subject()).contains("second.example.com"); + } + + @Test + void activate_failsWithNoStagedCert() { + assertThatThrownBy(() -> manager.activate()) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("No staged certificate"); + } + + @Test + void restore_failsWithNoArchivedCert() { + assertThatThrownBy(() -> manager.restore()) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("No archived certificate"); + } + + @Test + void discardStaged_removesFiles() throws Exception { + byte[][] pair = generateCertAndKey("CN=test.example.com"); + manager.stage(pair[0], pair[1], null); + + assertThat(Files.exists(certsDir.resolve("staged/cert.pem"))).isTrue(); + + manager.discardStaged(); + + assertThat(Files.exists(certsDir.resolve("staged"))).isFalse(); + } + + @Test + void stage_withCaBundle() throws Exception { + byte[][] pair = generateCertAndKey("CN=test.example.com"); + + // Use the cert itself as a "CA" for testing purposes + var result = manager.stage(pair[0], pair[1], pair[0]); + + assertThat(result.valid()).isTrue(); + assertThat(result.info().hasCaBundle()).isTrue(); + assertThat(Files.exists(certsDir.resolve("staged/ca.pem"))).isTrue(); + } + + @Test + void getCaBundle_returnsNullWhenMissing() { + assertThat(manager.getCaBundle()).isNull(); + } + + @Test + void getCaBundle_returnsContentWhenPresent() throws Exception { + byte[] caContent = "test-ca-content".getBytes(); + Files.write(certsDir.resolve("ca.pem"), caContent); + + assertThat(manager.getCaBundle()).isEqualTo(caContent); + } + + @Test + void activate_deletesExistingArchive() throws Exception { + // Create initial active + byte[][] pair1 = generateCertAndKey("CN=first.example.com"); + Files.write(certsDir.resolve("cert.pem"), pair1[0]); + Files.write(certsDir.resolve("key.pem"), pair1[1]); + + // Create existing archive + Files.createDirectories(certsDir.resolve("prev")); + byte[][] pairOld = generateCertAndKey("CN=old.example.com"); + Files.write(certsDir.resolve("prev/cert.pem"), pairOld[0]); + Files.write(certsDir.resolve("prev/key.pem"), pairOld[1]); + + // Stage new cert + byte[][] pair2 = generateCertAndKey("CN=second.example.com"); + manager.stage(pair2[0], pair2[1], null); + + // Activate: old archive should be deleted, first becomes archive + manager.activate(); + + var archived = manager.getArchived(); + assertThat(archived).isNotNull(); + assertThat(archived.subject()).contains("first.example.com"); + } + + // --- Test helpers: generate self-signed certs via keytool + openssl --- + + /** + * Generates a matched cert.pem + key.pem pair using keytool and openssl. + * Returns [certPem, keyPem]. + */ + private static byte[][] generateCertAndKey(String cn) throws Exception { + Path tmpDir = Files.createTempDirectory("cert-test"); + Path ks = tmpDir.resolve("keystore.p12"); + Path certFile = tmpDir.resolve("cert.pem"); + Path keyFile = tmpDir.resolve("key.pem"); + + try { + // Generate keystore with self-signed cert + exec("keytool", "-genkeypair", "-alias", "test", "-keyalg", "RSA", "-keysize", "2048", + "-validity", "365", "-dname", cn, "-storetype", "PKCS12", + "-keystore", ks.toString(), "-storepass", "changeit"); + + // Export cert to PEM + exec("keytool", "-exportcert", "-alias", "test", "-rfc", + "-keystore", ks.toString(), "-storepass", "changeit", + "-file", certFile.toString()); + + // Export key via openssl + exec("openssl", "pkcs12", "-in", ks.toString(), "-passin", "pass:changeit", + "-nocerts", "-nodes", "-out", keyFile.toString()); + + return new byte[][] { Files.readAllBytes(certFile), Files.readAllBytes(keyFile) }; + } finally { + Files.deleteIfExists(ks); + Files.deleteIfExists(certFile); + Files.deleteIfExists(keyFile); + Files.deleteIfExists(tmpDir); + } + } + + private static void exec(String... cmd) throws Exception { + var pb = new ProcessBuilder(cmd); + pb.redirectErrorStream(true); + var proc = pb.start(); + proc.getInputStream().readAllBytes(); // consume output + int exit = proc.waitFor(); + if (exit != 0) { + throw new RuntimeException("Command failed (exit " + exit + "): " + String.join(" ", cmd)); + } + } +} diff --git a/ui/src/api/certificate-hooks.ts b/ui/src/api/certificate-hooks.ts new file mode 100644 index 0000000..86a6711 --- /dev/null +++ b/ui/src/api/certificate-hooks.ts @@ -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({ + queryKey: ['vendor', 'certificates'], + queryFn: () => api.get('/vendor/certificates'), + }); +} + +export function useStageCertificate() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: (formData) => api.post('/vendor/certificates/stage', formData), + onSuccess: () => qc.invalidateQueries({ queryKey: ['vendor', 'certificates'] }), + }); +} + +export function useActivateCertificate() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: () => api.post('/vendor/certificates/activate'), + onSuccess: () => qc.invalidateQueries({ queryKey: ['vendor', 'certificates'] }), + }); +} + +export function useRestoreCertificate() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: () => api.post('/vendor/certificates/restore'), + onSuccess: () => qc.invalidateQueries({ queryKey: ['vendor', 'certificates'] }), + }); +} + +export function useDiscardStaged() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: () => api.delete('/vendor/certificates/staged'), + onSuccess: () => qc.invalidateQueries({ queryKey: ['vendor', 'certificates'] }), + }); +} diff --git a/ui/src/components/Layout.tsx b/ui/src/components/Layout.tsx index 097cdb8..04fa9f6 100644 --- a/ui/src/components/Layout.tsx +++ b/ui/src/components/Layout.tsx @@ -83,6 +83,14 @@ export function Layout() { > Audit Log +
navigate('/vendor/certificates')} + > + Certificates +
window.open(`${window.location.protocol}//${window.location.hostname}:3002`, '_blank', 'noopener')} diff --git a/ui/src/pages/vendor/CertificatesPage.tsx b/ui/src/pages/vendor/CertificatesPage.tsx new file mode 100644 index 0000000..37eb9f3 --- /dev/null +++ b/ui/src/pages/vendor/CertificatesPage.tsx @@ -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 ( + +
+
+ Subject + {cert.subject} +
+
+ Issuer + {cert.issuer} +
+
+ Valid from + {formatDate(cert.notBefore)} +
+
+ Expires + + {formatDate(cert.notAfter)} ({days}d) + +
+
+ Fingerprint + + {cert.fingerprint} + +
+
+ CA bundle + {cert.hasCa ? 'Yes' : 'No'} +
+ {cert.selfSigned && ( +
+ Type + +
+ )} + {cert.activatedAt && ( +
+ Activated + {formatDate(cert.activatedAt)} +
+ )} + {actions &&
{actions}
} +
+
+ ); +} + +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(null); + const keyInputRef = useRef(null); + const caInputRef = useRef(null); + + if (isLoading) { + return ( +
+ +
+ ); + } + + if (isError || !data) { + return ( +
+ + Could not fetch certificate data. Please refresh. + +
+ ); + } + + 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 ( +
+

Certificates

+ + {data.staleTenantCount > 0 && ( + + + {data.staleTenantCount} tenant{data.staleTenantCount > 1 ? 's' : ''} need a restart + to pick up the updated CA bundle. + + )} + +
+ {data.active && ( + + )} + + {data.staged && ( + + + + + } + /> + )} + + {data.archived && ( + + + {expired ? 'Expired' : 'Restore'} + + } + /> + )} + + {/* Upload card */} + +
+
+ + +
+
+ + +
+
+ + +
+ +
+
+
+
+ ); +} diff --git a/ui/src/router.tsx b/ui/src/router.tsx index 2ef7a47..b127728 100644 --- a/ui/src/router.tsx +++ b/ui/src/router.tsx @@ -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() { } /> + }> + + + } /> {/* Tenant portal */} } />