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

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

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

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

View File

@@ -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 <<METAEOF
{"subject":"$$SUBJECT","fingerprint":"$$FINGERPRINT","selfSigned":$$SELF_SIGNED,"hasCa":$$HAS_CA,"notBefore":"$$NOT_BEFORE","notAfter":"$$NOT_AFTER"}
METAEOF
mkdir -p /certs/staged /certs/prev
environment:
PUBLIC_HOST: ${PUBLIC_HOST:-localhost}
CERT_FILE: ${CERT_FILE:-}
KEY_FILE: ${KEY_FILE:-}
CA_FILE: ${CA_FILE:-}
volumes:
- certs:/certs
@@ -133,6 +171,7 @@ services:
condition: service_completed_successfully
volumes:
- bootstrapdata:/data/bootstrap:ro
- certs:/certs
environment:
SPRING_DATASOURCE_URL: jdbc:postgresql://postgres:5432/${POSTGRES_DB:-cameleer_saas}
SPRING_DATASOURCE_USERNAME: ${POSTGRES_USER:-cameleer}

View File

@@ -0,0 +1,242 @@
# Certificate Management Design
## Problem
The platform currently generates a self-signed TLS certificate at bootstrap time via an Alpine init container. There is no way to supply a real certificate at bootstrap, replace it at runtime, or manage CA trust bundles for tenant enterprise SSO providers. Internal services bypass TLS verification with hardcoded flags (`CAMELEER_OIDC_TLS_SKIP_VERIFY=true`, `NODE_TLS_REJECT_UNAUTHORIZED=0`).
## Goals
1. Supply a cert+key at bootstrap time (env vars pointing to files)
2. Replace the platform TLS certificate at runtime via vendor UI
3. Manage a CA trust bundle (`ca.pem`) aggregating platform CA + tenant enterprise CAs
4. Stage certificates before activation (shadow certs)
5. Roll back to the previous certificate if activation causes issues
6. Flag tenants that need restart after CA bundle changes
7. Provider-based architecture: Docker now, K8s later
## Non-Goals
- ACME/Let's Encrypt integration (separate future work)
- Per-tenant TLS certificates (all tenants share the platform cert via Traefik)
- Client certificate authentication (mTLS)
## Architecture
### Provider Interface
```java
package net.siegeln.cameleer.saas.certificate;
public interface CertificateManager {
boolean isAvailable();
CertificateInfo getActive();
CertificateInfo getStaged();
CertificateInfo getArchived();
CertValidationResult stage(byte[] certPem, byte[] keyPem, byte[] caBundlePem);
void activate();
void restore();
void discardStaged();
void generateSelfSigned(String hostname);
byte[] getCaBundle();
}
```
Lives in `net.siegeln.cameleer.saas.certificate`. Implementation in `net.siegeln.cameleer.saas.provisioning` alongside `DockerTenantProvisioner`.
`DockerCertificateManager` writes to the Docker `certs` volume. Future `K8sCertificateManager` would manage K8s TLS Secrets + cert-manager CRDs.
### Records
```java
public record CertificateInfo(
String subject, String issuer, Instant notBefore, Instant notAfter,
boolean hasCaBundle, boolean selfSigned, String fingerprint
) {}
public record CertValidationResult(
boolean valid, List<String> 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 |

View File

@@ -0,0 +1,17 @@
package net.siegeln.cameleer.saas.certificate;
import java.util.List;
public record CertValidationResult(
boolean valid,
List<String> errors,
CertificateInfo info
) {
public static CertValidationResult ok(CertificateInfo info) {
return new CertValidationResult(true, List.of(), info);
}
public static CertValidationResult fail(List<String> errors) {
return new CertValidationResult(false, errors, null);
}
}

View File

@@ -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<String> errors,
CertificateResponse certificate
) {}
// --- Endpoints ---
@GetMapping
public ResponseEntity<OverviewResponse> 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<StageResponse> 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<Void> activate() {
certificateService.activate();
return ResponseEntity.noContent().build();
}
@PostMapping("/restore")
public ResponseEntity<Void> restore() {
try {
certificateService.restore();
return ResponseEntity.noContent().build();
} catch (IllegalStateException e) {
return ResponseEntity.badRequest().body(null);
}
}
@DeleteMapping("/staged")
public ResponseEntity<Void> discardStaged() {
certificateService.discardStaged();
return ResponseEntity.noContent().build();
}
@GetMapping("/stale-tenants")
public ResponseEntity<Map<String, Long>> 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());
}
}
}

View File

@@ -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;
}
}

View File

@@ -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
) {}

View File

@@ -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();
}

View File

@@ -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<CertificateEntity, UUID> {
Optional<CertificateEntity> findByStatus(CertificateEntity.Status status);
void deleteByStatus(CertificateEntity.Status status);
}

View File

@@ -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());
}
}
}

View File

@@ -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();
}
}

View File

@@ -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();
}
}

View File

@@ -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; }
}

View File

@@ -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<String> 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<X509Certificate> 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) {}
});
}
}
}

View File

@@ -135,7 +135,7 @@ public class DockerTenantProvisioner implements TenantProvisioner {
labels.put("cameleer.tenant", slug);
labels.put("cameleer.role", "server");
List<String> 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"));

View File

@@ -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; }
}

View File

@@ -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<TenantEntity, UUID> {
List<TenantEntity> findByStatus(TenantStatus status);
boolean existsBySlug(String slug);
boolean existsBySlugAndStatusNot(String slug, TenantStatus status);
long countByCaAppliedAtBeforeOrCaAppliedAtIsNull(Instant threshold);
}

View File

@@ -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;

View File

@@ -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();
}
}

View File

@@ -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));
}
}
}

View File

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

View File

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

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

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

View File

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