feat: certificate management with stage/activate/restore lifecycle
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:
@@ -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}
|
||||
|
||||
@@ -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 |
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
) {}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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) {}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"));
|
||||
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
}
|
||||
68
ui/src/api/certificate-hooks.ts
Normal file
68
ui/src/api/certificate-hooks.ts
Normal 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'] }),
|
||||
});
|
||||
}
|
||||
@@ -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
285
ui/src/pages/vendor/CertificatesPage.tsx
vendored
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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 />} />
|
||||
|
||||
Reference in New Issue
Block a user