Merge feature/runtime-hardening: license enforcement (36 tasks)

Delivers a signed-token license tier system with 8 enforced cap
surfaces (envs/apps/agents/users/outbound/alert-rules/compute/jar-
retention), per-tenant validation, daily revalidation, ClickHouse
TTL recompute on license change, audit trail, and Prometheus
metrics. Plus runtime container-hardening pre-work and a stand-
alone cameleer-license-minter Maven module (test-scope only on
the server).

40 license commits + 2 prior runtime-hardening commits +
3 design/spec/plan + 3 docs (minter README, operator guide,
SaaS handoff).

Range: ec51aef8..5864553f
Tasks: 1-36 of docs/superpowers/plans/2026-04-25-license-enforcement.md

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
hsiegeln
2026-04-26 16:33:44 +02:00
131 changed files with 11179 additions and 176 deletions

View File

@@ -102,7 +102,8 @@ Env-scoped read-path controllers (`AlertController`, `AlertRuleController`, `Ale
- `OutboundConnectionAdminController``/api/v1/admin/outbound-connections`. GET list / POST create / GET `{id}` / PUT `{id}` / DELETE `{id}` / POST `{id}/test` / GET `{id}/usage`. RBAC: list/get/usage ADMIN|OPERATOR; mutations + test ADMIN.
- `SensitiveKeysAdminController` — GET/PUT `/api/v1/admin/sensitive-keys`. GET returns 200 or 204 if not configured. PUT accepts `{ keys: [...] }` with optional `?pushToAgents=true`. Fan-out iterates every distinct `(application, environment)` slice — intentional global baseline + per-env overrides.
- `ClaimMappingAdminController` — CRUD `/api/v1/admin/claim-mappings`, POST `/test`.
- `LicenseAdminController` — GET/POST `/api/v1/admin/license`.
- `LicenseAdminController` — GET/POST `/api/v1/admin/license`. ADMIN only. GET returns `{state, invalidReason, envelope, lastValidatedAt?}` — the raw token is deliberately omitted; only the parsed `LicenseInfo` envelope is exposed. POST delegates to `LicenseService.install(token, userId, "api")` (acting userId resolved via the `user:` prefix-strip convention) — install/replace/reject all flow through `LicenseService` so audit, persistence, and `LicenseChangedEvent` publishing are uniform.
- `LicenseUsageController` — GET `/api/v1/admin/license/usage`. Returns license `state`, `expiresAt`/`daysRemaining`/`gracePeriodDays`/`tenantId`/`label`/`lastValidatedAt`, the `LicenseMessageRenderer.forState(...)` message, and a `limits[]` array (`{key, current, cap, source}`) covering every effective-limits key. `source` is `"license"` when the cap came from the license override map, `"default"` otherwise. `max_agents` reads from `AgentRegistryService.liveCount()`; all other counts come from `LicenseUsageReader.snapshot()`.
- `ThresholdAdminController` — CRUD `/api/v1/admin/thresholds`.
- `AuditLogController` — GET `/api/v1/admin/audit`.
- `RbacStatsController` — GET `/api/v1/admin/rbac/stats`.
@@ -119,7 +120,7 @@ Env-scoped read-path controllers (`AlertController`, `AlertRuleController`, `Ale
## runtime/ — Docker orchestration
- `DockerRuntimeOrchestrator` — implements RuntimeOrchestrator; Docker Java client (zerodep transport), container lifecycle
- `DeploymentExecutor`@Async staged deploy: PRE_FLIGHT -> PULL_IMAGE -> CREATE_NETWORK -> START_REPLICAS -> HEALTH_CHECK -> SWAP_TRAFFIC -> COMPLETE. Container names are `{tenantId}-{envSlug}-{appSlug}-{replicaIndex}-{generation}`, where `generation` is the first 8 chars of the deployment UUID — old and new replicas coexist during a blue/green swap. Per-replica `CAMELEER_AGENT_INSTANCEID` env var is `{envSlug}-{appSlug}-{replicaIndex}-{generation}`. Branches on `DeploymentStrategy.fromWire(config.deploymentStrategy())`: **blue-green** (default) starts all N → waits for all healthy → stops old (partial health = FAILED, preserves old untouched); **rolling** replaces replicas one at a time with rollback only for in-flight new containers (already-replaced old stay stopped; un-replaced old keep serving). DEGRADED is now only set by `DockerEventMonitor` post-deploy, never by the executor.
- `DeploymentExecutor`@Async staged deploy: PRE_FLIGHT -> PULL_IMAGE -> CREATE_NETWORK -> START_REPLICAS -> HEALTH_CHECK -> SWAP_TRAFFIC -> COMPLETE. Container names are `{tenantId}-{envSlug}-{appSlug}-{replicaIndex}-{generation}`, where `generation` is the first 8 chars of the deployment UUID — old and new replicas coexist during a blue/green swap. Per-replica `CAMELEER_AGENT_INSTANCEID` env var is `{envSlug}-{appSlug}-{replicaIndex}-{generation}`. Branches on `DeploymentStrategy.fromWire(config.deploymentStrategy())`: **blue-green** (default) starts all N → waits for all healthy → stops old (partial health = FAILED, preserves old untouched); **rolling** replaces replicas one at a time with rollback only for in-flight new containers (already-replaced old stay stopped; un-replaced old keep serving). DEGRADED is now only set by `DockerEventMonitor` post-deploy, never by the executor. **License compute caps**: at PRE_FLIGHT (after `ConfigMerger.resolve`, before image pull / container creation) the executor consults `LicenseUsageReader.computeUsage()` (PG aggregate over non-stopped deployments) and runs three `LicenseEnforcer.assertWithinCap(...)` checks for `max_total_cpu_millis`, `max_total_memory_mb`, and `max_total_replicas`. A `LicenseCapExceededException` propagates to the surrounding `try/catch` which marks the deployment FAILED with the cap message in `deployments.error_message`.
- `DockerNetworkManager` — ensures bridge networks (cameleer-traefik, cameleer-env-{slug}), connects containers
- `DockerEventMonitor` — persistent Docker event stream listener (die, oom, start, stop), updates deployment status
- `TraefikLabelBuilder` — generates Traefik Docker labels for path-based or subdomain routing. Per-container identity labels: `cameleer.replica` (index), `cameleer.generation` (deployment-scoped 8-char id — for Prometheus/Grafana deploy-boundary annotations), `cameleer.instance-id` (`{envSlug}-{appSlug}-{replicaIndex}-{generation}`). Router/service label keys are generation-agnostic so load balancing spans old + new replicas during a blue/green overlap.
@@ -201,10 +202,27 @@ Env-scoped read-path controllers (`AlertController`, `AlertRuleController`, `Ale
- `dto/OutboundConnectionTestResult` — result of POST `/{id}/test`: status, latencyMs, responseSnippet (first 512 chars), tlsProtocol/cipherSuite/peerCertSubject (protocol is "TLS" stub; enriched in Plan 02 follow-up), error (nullable).
- `config/OutboundBeanConfig` — registers `OutboundConnectionRepository`, `SecretCipher`, `OutboundConnectionService` beans.
## license/ — License enforcement & lifecycle
- `LicenseService` — install / replace / revalidate mediator. `install(token, installedBy, source)` validates via `LicenseValidator`, on failure marks the gate INVALID + audits `reject_license` + publishes `LicenseChangedEvent` and rethrows; on success persists via `LicenseRepository.upsert(...)`, mutates `LicenseGate`, audits `install_license` or `replace_license` (detects existing row), and publishes `LicenseChangedEvent`. `loadInitial(envToken, fileToken)` boot precedence env > file > DB; ABSENT publishes a `LicenseChangedEvent(ABSENT, null)`. `revalidate()` re-runs validation against the persisted token, on success bumps `last_validated_at`; on failure marks INVALID and audits `revalidate_license` FAILURE. `getTenantId()` exposes the tenant for downstream lookups.
- `LicenseRepository` — interface in `app/license`. `Optional<LicenseRecord> findByTenantId(String)`, `void upsert(LicenseRecord)`, `int touchValidated(String tenantId, Instant)`, `int delete(String)`.
- `LicenseRecord` — record persisted in PG `license` table: `(String tenantId, String token, UUID licenseId, Instant installedAt, String installedBy, Instant expiresAt, Instant lastValidatedAt)`.
- `PostgresLicenseRepository` — JdbcTemplate impl of `LicenseRepository`. Targets PG `license` table (V5). Upsert via `INSERT ... ON CONFLICT (tenant_id) DO UPDATE`.
- `LicenseChangedEvent` — Spring application event: `(LicenseState state, LicenseInfo current)`. Published on every install / replace / revalidate / boot-time ABSENT path so downstream listeners (retention policy, metrics, etc.) react uniformly.
- `LicenseEnforcer``@Component`. `assertWithinCap(String limitKey, long currentUsage, long requestedDelta)` consults `LicenseGate.getEffectiveLimits()`. On overflow increments `cameleer_license_cap_rejections_total{limit=...}`, emits an `AuditCategory.LICENSE / cap_exceeded` audit row when `AuditService` is wired (try/catch + log.warn so audit-write failures don't suppress the 403), and throws `LicenseCapExceededException`. Unknown limit keys propagate `IllegalArgumentException` from `LicenseLimits.get(...)` (programmer error, not a 403).
- `LicenseUsageReader``@Component` over PG. `snapshot()` returns a `Map<String,Long>` of (max_environments, max_apps, max_users, max_outbound_connections, max_alert_rules, max_total_cpu_millis, max_total_memory_mb, max_total_replicas) from PG row counts and a SUM over non-stopped deployments' `deployed_config_snapshot.containerConfig` (replicas × cpuLimit / memoryLimitMb). `computeUsage()` returns the typed `ComputeUsage(cpuMillis, memoryMb, replicas)` tuple consumed by `DeploymentExecutor` PRE_FLIGHT cap checks. `agentCount(int)` echoes a registry-supplied live count (registry is in-memory; not stored in PG).
- `LicenseCapExceededException` — typed `RuntimeException(limitKey, current, cap)` with accessors. Mapped to HTTP 403 by `LicenseExceptionAdvice`.
- `LicenseExceptionAdvice``@ControllerAdvice` mapping `LicenseCapExceededException` → 403 with body `{error:"license cap reached", limit, current, cap, state, message}` where `message` is `LicenseMessageRenderer.forCap(state, info, limit, current, cap, invalidReason)`.
- `LicenseMessageRenderer` — pure formatter (utility class, no DI). `forCap(state, info, limit, current, cap[, invalidReason])` per-state human messages for cap-rejection responses; `forState(state, info[, invalidReason])` shorter state-only messages for the `/usage` endpoint and metrics surfaces.
- `RetentionPolicyApplier``@EventListener(LicenseChangedEvent.class) @Async`. For each environment × table in the static `SPECS` list (`executions`, `processor_executions`, `logs`, `agent_metrics`, `agent_events`) computes `effective = min(licenseCap, env.configuredRetentionDays)` and emits `ALTER TABLE <t> MODIFY TTL toDateTime(<col>) + INTERVAL <n> DAY DELETE WHERE environment = '<slug>'`. ClickHouse failures are logged and swallowed (best-effort; never propagates to the originating license install/revalidate). `route_diagrams` (no TTL clause) and `server_metrics` (no environment column) are intentionally excluded.
- `LicenseRevalidationJob``@Component`. `@Scheduled(cron = "0 0 3 * * *")` daily revalidation; `@EventListener(ApplicationReadyEvent.class) @Async` 60-second post-startup tick to catch ABSENT→ACTIVE when a license was inserted between server starts. Both paths call `LicenseService.revalidate()` and swallow scheduler-thread crashes.
- `LicenseMetrics``@Component`. Registers Micrometer gauges: `cameleer_license_state{state=...}` (one-hot per `LicenseState`), `cameleer_license_days_remaining` (negative when ABSENT/INVALID), `cameleer_license_last_validated_age_seconds` (0 when no DB row). Refreshed eagerly on `LicenseChangedEvent` via `@EventListener` and lazily every 60s via `@Scheduled(fixedDelay = 60_000)`.
## config/ — Spring beans
- `RuntimeOrchestratorAutoConfig` — conditional Docker/Disabled orchestrator + NetworkManager + EventMonitor
- `RuntimeBeanConfig` — DeploymentExecutor, AppService, EnvironmentService
- `RuntimeBeanConfig` — DeploymentExecutor, AppService, EnvironmentService. Wires `CreateGuard` instances per service from `LicenseEnforcer.assertWithinCap(...)` so creation paths (Environment, App, Agent) consult license caps without core depending on the app module.
- `SecurityBeanConfig` — JwtService, Ed25519, BootstrapTokenValidator
- `StorageBeanConfig` — all repositories
- `ClickHouseConfig` — ClickHouse JdbcTemplate, schema initializer
- `LicenseBeanConfig` — license bean topology in dependency order: `LicenseGate``LicenseValidator` (when `cameleer.server.license.publickey` is unset, an always-failing override is returned so any loaded token still routes through `install()` and is audited as INVALID, never silently dropped) → `LicenseService``LicenseBootLoader` (`@PostConstruct` drives `loadInitial(envToken, fileToken)` once the context is ready; resolution order env var > license file > persisted DB row).

View File

@@ -26,7 +26,7 @@ paths:
- `App` — record: id, environmentId, slug, displayName, containerConfig (JSONB)
- `AppVersion` — record: id, appId, version, jarPath, detectedRuntimeType, detectedMainClass
- `Environment` — record: id, slug, displayName, production, enabled, defaultContainerConfig, jarRetentionCount, color, createdAt. `color` is one of the 8 preset palette values validated by `EnvironmentColor.VALUES` and CHECK-constrained in PostgreSQL (V2 migration).
- `Environment` — record: id, slug, displayName, production, enabled, defaultContainerConfig, jarRetentionCount, color, createdAt, executionRetentionDays, logRetentionDays, metricRetentionDays. `color` is one of the 8 preset palette values validated by `EnvironmentColor.VALUES` and CHECK-constrained in PostgreSQL (V2 migration). The 3 retention day fields (V5) are `int`-typed (not nullable, since unlimited has no use-case), default to 1 day per the V5 `NOT NULL DEFAULT 1`, validated >= 1 in the canonical constructor.
- `EnvironmentColor` — constants: `DEFAULT = "slate"`, `VALUES = {slate,red,amber,green,teal,blue,purple,pink}`, `isValid(String)`.
- `Deployment` — record: id, appId, appVersionId, environmentId, status, targetState, deploymentStrategy, replicaStates (JSONB), deployStage, containerId, containerName, createdBy (String, user_id reference; nullable for pre-V4 historical rows)
- `DeploymentStatus` — enum: STOPPED, STARTING, RUNNING, DEGRADED, STOPPING, FAILED. `DEGRADED` is reserved for post-deploy drift (a replica died after RUNNING); `DeploymentExecutor` now marks partial-healthy deploys FAILED, not DEGRADED.
@@ -43,6 +43,17 @@ paths:
- `RuntimeOrchestrator` — interface: startContainer, stopContainer, getContainerStatus, getLogs, startLogCapture, stopLogCapture
- `AppRepository`, `AppVersionRepository`, `EnvironmentRepository`, `DeploymentRepository` — repository interfaces
- `AppService`, `EnvironmentService` — domain services
- `CreateGuard``@FunctionalInterface`. `void check(long current)` — implementations throw to abort creation. `NOOP` constant is the default. Consulted by `EnvironmentService.create`, `AppService.createApp`, and `AgentRegistryService.register` so license caps can be enforced from the app module without leaking Spring or app-only types into core. Wired in `LicenseBeanConfig` to a `LicenseEnforcer.assertWithinCap(...)` call per limit key.
## license/ — License domain (signed-token tier system)
- `LicenseInfo` — record: `(UUID licenseId, String tenantId, String label, Map<String,Integer> limits, Instant issuedAt, Instant expiresAt, int gracePeriodDays)`. `isExpired()` true once `now > expiresAt + gracePeriodDays`; `isAfterRawExpiry()` true once `now > expiresAt`. Constructed via `LicenseValidator`; canonical ctor null-checks all required fields and rejects blank tenantId / negative grace.
- `LicenseLimits` — typed limits container backed by `Map<String,Integer>`. `defaultsOnly()` returns the `DefaultTierLimits.DEFAULTS` view; `mergeOverDefaults(overrides)` produces the license-overrides UNION default tier. `get(String key)` returns the cap; throws `IllegalArgumentException` for unknown keys (programmer error). `isDefaultSourced(key, license)` reports whether a key fell through to the default tier.
- `DefaultTierLimits` — immutable `LinkedHashMap` of constants for the no-license fallback tier: `max_environments=1, max_apps=3, max_agents=5, max_users=3, max_outbound_connections=1, max_alert_rules=2, max_total_cpu_millis=2000, max_total_memory_mb=2048, max_total_replicas=5, max_execution_retention_days=1, max_log_retention_days=1, max_metric_retention_days=1, max_jar_retention_count=3`.
- `LicenseValidator` — verifies signed token. Constructor `(String publicKeyBase64, String expectedTenantId)` decodes an X.509 Ed25519 public key. `validate(String token)` splits `payload.signature`, verifies the Ed25519 signature, parses the JSON payload, enforces `tenantId == expectedTenantId`, and returns `LicenseInfo`. Throws `SecurityException` on signature mismatch / `IllegalArgumentException` on parse failure / expired payload.
- `LicenseGate` — runtime state holder (thread-safe via `AtomicReference<Snapshot>`). `getCurrent()` returns the current `LicenseInfo` (null when ABSENT/INVALID); `getState()` delegates to `LicenseStateMachine.classify(...)`; `getEffectiveLimits()` returns license-overrides UNION defaults in `ACTIVE`/`GRACE`, defaults-only otherwise. `getInvalidReason()`, `load(LicenseInfo)`, `markInvalid(String reason)`, `clear()` are the mutators. `getLimit(key, defaultValue)` shorthand swallows unknown-key errors.
- `LicenseStateMachine` — pure classifier. `classify(LicenseInfo, String invalidReason)` returns `INVALID` if a reason is set, `ABSENT` if no license, `ACTIVE` if `now <= expiresAt`, `GRACE` if expired but within grace window, `EXPIRED` otherwise.
- `LicenseState` — enum: `ABSENT, ACTIVE, GRACE, EXPIRED, INVALID`.
## search/ — Execution search and stats
@@ -81,7 +92,7 @@ paths:
- `AppSettings`, `AppSettingsRepository` — per-app-per-env settings config and persistence. Record carries `(applicationId, environment, …)`; repository methods are `findByApplicationAndEnvironment`, `findByEnvironment`, `save`, `delete(appId, env)`. `AppSettings.defaults(appId, env)` produces a default instance scoped to an environment.
- `ThresholdConfig`, `ThresholdRepository` — alerting threshold config and persistence
- `AuditService` — audit logging facade
- `AuditRecord`, `AuditResult`, `AuditCategory` (enum: `INFRA, AUTH, USER_MGMT, CONFIG, RBAC, AGENT, OUTBOUND_CONNECTION_CHANGE, OUTBOUND_HTTP_TRUST_CHANGE, ALERT_RULE_CHANGE, ALERT_SILENCE_CHANGE, DEPLOYMENT`), `AuditRepository` — audit trail records and persistence
- `AuditRecord`, `AuditResult`, `AuditCategory` (enum: `INFRA, AUTH, USER_MGMT, CONFIG, RBAC, AGENT, OUTBOUND_CONNECTION_CHANGE, OUTBOUND_HTTP_TRUST_CHANGE, ALERT_RULE_CHANGE, ALERT_SILENCE_CHANGE, DEPLOYMENT, LICENSE`), `AuditRepository` — audit trail records and persistence
## http/ — Outbound HTTP primitives (cross-cutting)

View File

@@ -23,6 +23,18 @@ When deployed via the cameleer-saas platform, this server orchestrates customer
- **ContainerLogForwarder** (`app/runtime/ContainerLogForwarder.java`) — streams Docker container stdout/stderr to ClickHouse `logs` table with `source='container'`. Uses `docker logs --follow` per container, batches lines every 2s or 50 lines. Parses Docker timestamp prefix, infers log level via regex. `DeploymentExecutor` starts capture after each replica launches with the replica's `instanceId` (`{envSlug}-{appSlug}-{replicaIndex}-{generation}`); `DockerEventMonitor` stops capture on die/oom. 60-second max capture timeout with 30s cleanup scheduler. Thread pool of 10 daemon threads. Container logs use the same `instanceId` as the agent (set via `CAMELEER_AGENT_INSTANCEID` env var) for unified log correlation at the instance level. Instance-id changes per deployment — cross-deploy queries aggregate on `application + environment` (and optionally `replica_index`).
- **StartupLogPanel** (`ui/src/components/StartupLogPanel.tsx`) — collapsible log panel rendered below `DeploymentProgress`. Queries `/api/v1/logs?source=container&application={appSlug}&environment={envSlug}`. Auto-polls every 3s while deployment is STARTING; shows green "live" badge during polling, red "stopped" badge on FAILED. Uses `useStartupLogs` hook and `LogViewer` (design system).
## Container Hardening (issue #152)
`DockerRuntimeOrchestrator.startContainer` applies an unconditional hardening contract to every tenant container — Java 17 has no SecurityManager so the JVM is not a security boundary, and isolation must live below it. Defaults are fail-closed and have no opt-out:
- `cap_drop` = every `Capability.values()` (effectively ALL — docker-java's enum has no `ALL` constant). Outbound TCP still works (no caps needed); raw sockets, ptrace, mounts, and bind <1024 are denied.
- `security_opt`: `no-new-privileges:true`, `apparmor=docker-default`. Default seccomp profile is applied implicitly when `seccomp=` is absent.
- `read_only` rootfs = true.
- `pids_limit` = 512 (`PIDS_LIMIT` constant).
- `tmpfs` mount: `/tmp` with `rw,nosuid,size=256m`. **No `noexec`** — Netty/tcnative, Snappy, LZ4, Zstd dlopen native libs from `/tmp` via `mmap(PROT_EXEC)` which `noexec` blocks. Issue #153 will add per-app `writeableVolumes` for stateful tenants (Kafka Streams etc.).
**Sandboxed runtime auto-detect**: at construction the orchestrator calls `dockerClient.infoCmd().exec().getRuntimes()` and uses `runsc` (gVisor) when present. Override with `cameleer.server.runtime.dockerruntime` (e.g. `kata` to force Kata Containers, or any other registered runtime). Empty/blank = auto. The override always wins over auto-detect. The `DockerRuntimeOrchestrator(DockerClient, String)` constructor is the canonical entry point; the single-arg constructor exists only as a convenience for tests that don't need an override.
## DeploymentExecutor Details
Primary network for app containers is set via `CAMELEER_SERVER_RUNTIME_DOCKERNETWORK` env var (in SaaS mode: `cameleer-tenant-{slug}`); apps also connect to `cameleer-traefik` (routing) and `cameleer-env-{tenantId}-{envSlug}` (per-environment discovery) as additional networks. Resolves `runtimeType: auto` to concrete type from `AppVersion.detectedRuntimeType` at PRE_FLIGHT (fails deployment if unresolvable). Builds Docker entrypoint per runtime type (all JVM types use `-javaagent:/app/agent.jar -jar`, plain Java uses `-cp` with main class, native runs binary directly). Sets per-replica `CAMELEER_AGENT_INSTANCEID` env var to `{envSlug}-{appSlug}-{replicaIndex}-{generation}` so container logs and agent logs share the same instance identity. Sets `CAMELEER_AGENT_*` env vars from `ResolvedContainerConfig` (routeControlEnabled, replayEnabled, health port). These are startup-only agent properties — changing them requires redeployment.

View File

@@ -494,6 +494,7 @@ Key settings in `cameleer-server-app/src/main/resources/application.yml`. All cu
| `cameleer.server.runtime.enabled` | `true` | `CAMELEER_SERVER_RUNTIME_ENABLED` | Enable Docker orchestration |
| `cameleer.server.runtime.baseimage` | `cameleer-runtime-base:latest` | `CAMELEER_SERVER_RUNTIME_BASEIMAGE` | Base Docker image for app containers |
| `cameleer.server.runtime.dockernetwork` | `cameleer` | `CAMELEER_SERVER_RUNTIME_DOCKERNETWORK` | Primary Docker network |
| `cameleer.server.runtime.dockerruntime` | *(empty = auto)* | `CAMELEER_SERVER_RUNTIME_DOCKERRUNTIME` | Container runtime override. Empty auto-detects gVisor (`runsc`) when registered with the daemon and falls back to the daemon default. Set to e.g. `kata` to force a specific runtime, or `runc` to force the default even if `runsc` is installed. |
| `cameleer.server.runtime.jarstoragepath` | `/data/jars` | `CAMELEER_SERVER_RUNTIME_JARSTORAGEPATH` | JAR file storage directory |
| `cameleer.server.runtime.jardockervolume` | *(empty)* | `CAMELEER_SERVER_RUNTIME_JARDOCKERVOLUME` | Docker volume for JAR sharing |
| `cameleer.server.runtime.routingmode` | `path` | `CAMELEER_SERVER_RUNTIME_ROUTINGMODE` | `path` or `subdomain` Traefik routing |

View File

@@ -0,0 +1,287 @@
# cameleer-license-minter
Standalone vendor-side tool for producing signed Ed25519 license tokens consumed by `cameleer-server`. The minter is intentionally **not** a runtime or compile-scope dependency of the server — the server only ships with the matching public key and validates tokens via `LicenseValidator`. The private signing key never leaves the vendor's environment.
- Module GAV: `com.cameleer:cameleer-license-minter:1.0-SNAPSHOT`
- Maven coordinates of the runtime server (does **not** transitively pull this module): `com.cameleer:cameleer-server-app:1.0-SNAPSHOT`
- Build artifacts (after `mvn -pl cameleer-license-minter package`):
- `target/cameleer-license-minter-1.0-SNAPSHOT.jar` — plain library JAR (consumable as a Maven `test` dependency or via the `LicenseMinter` API in custom tooling)
- `target/cameleer-license-minter-1.0-SNAPSHOT-cli.jar` — fat CLI JAR with main class `com.cameleer.license.minter.cli.LicenseMinterCli`
## Table of contents
## Audience
## Build
## Public Java API
## CLI usage
## Token format
## LicenseInfo schema
## Limits dictionary
## Generating an Ed25519 key pair
## Worked example
## Security guidance
## Compatibility / runtime separation
---
## Audience
Vendors / SaaS operators issuing licenses to customers who run `cameleer-server`. End-customer operators looking for *how to install* a token should read `docs/license-enforcement.md` instead.
## Build
```bash
# From the repo root
mvn -pl cameleer-license-minter package
```
Two JARs land in `cameleer-license-minter/target/`:
| Artifact | Purpose |
|---|---|
| `cameleer-license-minter-1.0-SNAPSHOT.jar` | Plain library (the `repackage` execution for the main artifact is disabled; see `pom.xml:50-54`). Use this when embedding the minter inside your own tooling or a unit test that needs a fresh signed token. |
| `cameleer-license-minter-1.0-SNAPSHOT-cli.jar` | Fat CLI JAR. Repackaged by Spring Boot's `spring-boot-maven-plugin` with classifier `cli`; main class is `com.cameleer.license.minter.cli.LicenseMinterCli`. |
## Public Java API
`com.cameleer.license.minter.LicenseMinter` is the only entry point for the library. It is a final, stateless utility class:
```java
import com.cameleer.license.minter.LicenseMinter;
import com.cameleer.server.core.license.LicenseInfo;
LicenseInfo info = new LicenseInfo(
java.util.UUID.randomUUID(),
"acme-prod", // tenantId — must match server's CAMELEER_SERVER_TENANT_ID
"Acme Production (Tier B)", // human label, optional
java.util.Map.of(
"max_environments", 3,
"max_apps", 25,
"max_agents", 50,
"max_users", 20,
"max_total_replicas", 30
),
java.time.Instant.now(), // issuedAt
java.time.Instant.parse("2027-01-01T00:00:00Z"), // expiresAt
7 // gracePeriodDays
);
String token = LicenseMinter.mint(info, ed25519PrivateKey);
```
Source: `cameleer-license-minter/src/main/java/com/cameleer/license/minter/LicenseMinter.java:20`.
The method is thread-safe; the underlying Jackson `ObjectMapper` is configured once with `ORDER_MAP_ENTRIES_BY_KEYS` so canonical-JSON serialization is deterministic across runs and process boundaries.
`LicenseMinter.mint` will throw `IllegalStateException` if the JCE provider rejects the private key or the payload cannot be serialized.
## CLI usage
The CLI entry point is `com.cameleer.license.minter.cli.LicenseMinterCli`. Run it from the fat JAR produced by the build:
```bash
java -jar cameleer-license-minter/target/cameleer-license-minter-1.0-SNAPSHOT-cli.jar \
--private-key=/secure/keys/cameleer-license-priv.pem \
--tenant=acme-prod \
--label="Acme Production (Tier B)" \
--expires=2027-01-01 \
--grace-days=7 \
--max-environments=3 \
--max-apps=25 \
--max-agents=50 \
--max-users=20 \
--max-total-replicas=30 \
--output=/secure/out/acme-prod.lic \
--public-key=/secure/keys/cameleer-license-pub.b64 \
--verify
```
### Flag reference
Source of truth: `cameleer-license-minter/src/main/java/com/cameleer/license/minter/cli/LicenseMinterCli.java:26`.
| Flag | Required | Meaning |
|---|---|---|
| `--private-key=<path>` | yes | Path to a PKCS#8-encoded Ed25519 private key. Both PEM (`-----BEGIN PRIVATE KEY-----`) and raw base64 are accepted (`LicenseMinterCli.readEd25519PrivateKey`). |
| `--tenant=<tenantId>` | yes | The exact `tenantId` the server will compare against `CAMELEER_SERVER_TENANT_ID`. Mismatch causes the validator to throw at install / revalidation. |
| `--expires=<YYYY-MM-DD>` | yes | Expiration date interpreted as midnight UTC. The validator considers tokens expired once `now > exp + gracePeriodDays`. |
| `--label=<text>` | no | Human-readable label, surfaced via `GET /api/v1/admin/license` and `/api/v1/admin/license/usage`. |
| `--grace-days=<int>` | no | Number of days the license stays usable after `--expires`. Defaults to `0`. |
| `--max-<limitkey>=<int>` | no, repeatable | Each `--max-foo-bar` flag becomes the limit key `max_foo_bar`. See the limits dictionary below. Unknown keys are accepted by the minter (the server side ignores keys it does not understand and falls through to defaults). |
| `--output=<path>` | no | Write the token to a file. When omitted, the token is printed to stdout. On `--verify` failure the file is deleted. |
| `--public-key=<path>` | no, required for `--verify` | Path to the matching base64 X.509 SPKI public key file (one line, no PEM markers). |
| `--verify` | no | After minting, parse + signature-check the token using `--public-key` and `--tenant`. Exits non-zero if verification fails. |
Exit codes: `0` on success, `1` on minting / IO failure, `2` on argument validation failure, `3` on `--verify` failure.
## Token format
A token is the concatenation of two **standard** base64 segments joined by a literal `.`:
```
base64(canonicalJson) + "." + base64(ed25519Signature)
```
- The canonical JSON payload is produced by `LicenseMinter.canonicalPayload(...)` with keys sorted lexicographically and `limits` rendered as a sorted object. This makes the byte sequence deterministic given a fixed `LicenseInfo`.
- The signature is computed with `Signature.getInstance("Ed25519")` over the canonical payload bytes (not over the base64-encoded form).
- Encoding is `Base64.getEncoder()` (RFC 4648 §4 — *not* base64url). The validator decodes with the matching `Base64.getDecoder()`.
`LicenseValidator.validate(...)` (`cameleer-server-core/src/main/java/com/cameleer/server/core/license/LicenseValidator.java:42`) splits on the first `.`, decodes both halves, verifies the signature, then deserializes the payload.
## LicenseInfo schema
Source: `cameleer-server-core/src/main/java/com/cameleer/server/core/license/LicenseInfo.java`. Field-by-field:
| Field | Type | Required | Semantics |
|---|---|---|---|
| `licenseId` | `UUID` | yes | Stable identifier for this token. The server's audit trail records install/replace transitions by license id; renewals must use a fresh UUID so audit history is non-ambiguous. |
| `tenantId` | `String` | yes | Must equal the server's `CAMELEER_SERVER_TENANT_ID`. The validator throws `IllegalArgumentException` on mismatch. Blank values are rejected by the canonical record constructor. |
| `label` | `String` | no | Free-form human label. Surfaced on the admin/usage endpoints and the operator UI. Has no enforcement semantics. |
| `limits` | `Map<String,Integer>` | yes (may be empty) | License-specific overrides. Any key that appears here is unioned over `DefaultTierLimits.DEFAULTS` to form the effective caps in `ACTIVE` / `GRACE` states. Keys not present fall through to defaults. |
| `issuedAt` | `Instant` (epoch seconds in JSON `iat`) | yes | Stamped by the minter; not currently consulted by the validator beyond informational logging. |
| `expiresAt` | `Instant` (epoch seconds in JSON `exp`) | yes | The validator throws if `now > expiresAt + gracePeriodDays * 86400` at install or revalidation. |
| `gracePeriodDays` | `int` | yes (>= 0) | Window after `expiresAt` during which the gate transitions to `GRACE` (license still grants its caps) before flipping to `EXPIRED`. Negative values are rejected at construction. |
## Limits dictionary
Canonical key set: `cameleer-server-core/src/main/java/com/cameleer/server/core/license/DefaultTierLimits.java`. Any key not listed here is silently ignored by the server's `LicenseGate.getEffectiveLimits()`.
| CLI flag | Key | Default | What the server enforces |
|---|---|---|---|
| `--max-environments` | `max_environments` | 1 | `EnvironmentService.create(...)` consults `LicenseEnforcer.assertWithinCap("max_environments", currentCount, 1)`. |
| `--max-apps` | `max_apps` | 3 | `AppService.createApp(...)` checks total app count across all envs. |
| `--max-agents` | `max_agents` | 5 | `AgentRegistryService.register(...)` checks live agent count. |
| `--max-users` | `max_users` | 3 | User creation paths (`UserAdminController`, `UiAuthController` self-signup, `OidcAuthController` first-login). |
| `--max-outbound-connections` | `max_outbound_connections` | 1 | `OutboundConnectionServiceImpl.create(...)`. |
| `--max-alert-rules` | `max_alert_rules` | 2 | `AlertRuleController.create(...)`. |
| `--max-total-cpu-millis` | `max_total_cpu_millis` | 2000 | `DeploymentExecutor` PRE_FLIGHT compute cap (sum of `replicas * cpuLimit` over non-stopped deployments). |
| `--max-total-memory-mb` | `max_total_memory_mb` | 2048 | `DeploymentExecutor` PRE_FLIGHT compute cap (sum of `replicas * memoryLimitMb`). |
| `--max-total-replicas` | `max_total_replicas` | 5 | `DeploymentExecutor` PRE_FLIGHT compute cap (sum of `replicas`). |
| `--max-execution-retention-days` | `max_execution_retention_days` | 1 | ClickHouse TTL cap for `executions`, `processor_executions`. Effective TTL = `min(cap, env.executionRetentionDays)`. |
| `--max-log-retention-days` | `max_log_retention_days` | 1 | ClickHouse TTL cap for `logs`. |
| `--max-metric-retention-days` | `max_metric_retention_days` | 1 | ClickHouse TTL cap for `agent_metrics`, `agent_events`. |
| `--max-jar-retention-count` | `max_jar_retention_count` | 3 | `EnvironmentAdminController` PUT `/{envSlug}/jar-retention` rejects requests above this cap. Also bounds the daily `JarRetentionJob`. |
## Generating an Ed25519 key pair
The minter and validator both rely on the JCE `Ed25519` algorithm shipped with JDK 17+. No external crypto library is needed.
```java
import java.security.KeyPair;
import java.security.KeyPairGenerator;
import java.util.Base64;
KeyPair kp = KeyPairGenerator.getInstance("Ed25519").generateKeyPair();
// 32-byte public key, X.509 SubjectPublicKeyInfo wrapped — this is what the server expects.
String publicKeyB64 = Base64.getEncoder().encodeToString(kp.getPublic().getEncoded());
// PKCS#8 private key — the CLI's --private-key reader accepts this either as raw base64
// or PEM-wrapped (`-----BEGIN PRIVATE KEY-----`).
String privateKeyB64 = Base64.getEncoder().encodeToString(kp.getPrivate().getEncoded());
```
A one-liner using the JDK's `keytool` is **not** sufficient — `keytool` cannot produce raw Ed25519 PKCS#8 in a directly-usable shape for our reader. Generating via the API above (or `openssl genpkey -algorithm ed25519`) is the supported path.
For OpenSSL:
```bash
openssl genpkey -algorithm ed25519 -out cameleer-license-priv.pem
openssl pkey -in cameleer-license-priv.pem -pubout -outform DER \
| base64 -w0 > cameleer-license-pub.b64
```
The resulting `cameleer-license-pub.b64` is the value to put into `CAMELEER_SERVER_LICENSE_PUBLICKEY`.
## Worked example
End-to-end: generate a key pair, mint a license, install it on a running server, verify enforcement.
```bash
# 1. Vendor side — generate the keypair
openssl genpkey -algorithm ed25519 -out /secrets/cameleer-priv.pem
openssl pkey -in /secrets/cameleer-priv.pem -pubout -outform DER \
| base64 -w0 > /secrets/cameleer-pub.b64
# 2. Vendor side — distribute the public key (commit to deployment config / Vault / k8s Secret)
cat /secrets/cameleer-pub.b64
# MCowBQYDK2VwAyEAxxxxx...
# 3. Vendor side — mint a license for a customer tenant
mvn -pl cameleer-license-minter package -DskipTests
java -jar cameleer-license-minter/target/cameleer-license-minter-1.0-SNAPSHOT-cli.jar \
--private-key=/secrets/cameleer-priv.pem \
--public-key=/secrets/cameleer-pub.b64 \
--tenant=acme-prod \
--label="Acme Production" \
--expires=2027-01-01 \
--grace-days=14 \
--max-environments=3 \
--max-apps=25 \
--max-agents=50 \
--max-users=20 \
--max-total-replicas=30 \
--max-total-cpu-millis=15000 \
--max-total-memory-mb=16384 \
--max-execution-retention-days=30 \
--max-log-retention-days=14 \
--max-metric-retention-days=14 \
--max-jar-retention-count=10 \
--output=/tmp/acme.lic \
--verify
# 4. Customer side — server boots with public key + tenant id matching the mint
export CAMELEER_SERVER_TENANT_ID=acme-prod
export CAMELEER_SERVER_LICENSE_PUBLICKEY=$(cat /secrets/cameleer-pub.b64)
# 5. Customer side — install via the admin API after boot
curl -X POST https://server.example.com/api/v1/admin/license \
-H "Authorization: Bearer ${ADMIN_JWT}" \
-H "Content-Type: application/json" \
-d "{\"token\": \"$(cat /tmp/acme.lic)\"}"
# 6. Customer side — verify it was accepted
curl https://server.example.com/api/v1/admin/license \
-H "Authorization: Bearer ${ADMIN_JWT}"
# {"state":"ACTIVE","invalidReason":null,"envelope":{...},"lastValidatedAt":"..."}
curl https://server.example.com/api/v1/admin/license/usage \
-H "Authorization: Bearer ${ADMIN_JWT}"
# Shows current/cap/source per limit key
```
For boot-time installation (preferred for SaaS-managed deployments), set `CAMELEER_SERVER_LICENSE_TOKEN` instead of POSTing — see `docs/license-enforcement.md`.
## Security guidance
- **The Ed25519 private key is the trust root.** Anyone who holds it can mint licenses for any tenant. Treat it like a code-signing key.
- **Storage.** Production private keys belong in an HSM, KMS (e.g. AWS KMS / GCP KMS with non-exportable signing), or a sealed Vault transit backend. A sealed file on a laptop is acceptable for low-volume / pre-production minting only and should never be committed to git or shared via chat.
- **Rotation.** Rotation is destructive: every customer running with the *old* public key will reject all new tokens signed with the *new* private key. The pragmatic procedure is:
1. Generate the new keypair.
2. Distribute the new public key (`CAMELEER_SERVER_LICENSE_PUBLICKEY`) to every tenant's server config.
3. Once tenants confirm they are running with the new public key, re-mint and re-issue every active license under the new key.
4. Decommission the old private key.
Practical revocation flows through expiry — keep license terms short enough (12 months or less) that planned rotations stay aligned with renewal cadence.
- **Auditing.** The server records every install/replace/reject under `AuditCategory.LICENSE`. The minter itself does not write audit rows; if you need a vendor-side audit trail of mint operations, wrap `LicenseMinter.mint(...)` in your own ticketing pipeline.
- **Never commit private keys.** `.gitignore` does not block them by name — use a `secrets/` directory excluded by your repository's policy, or store them entirely outside the working tree.
## Compatibility / runtime separation
The minter is intentionally absent from `cameleer-server-app`'s production classpath. To verify after a build:
```bash
mvn -pl cameleer-server-app dependency:tree | grep license-minter
# expected: empty output (or, in development branches, a single line scoped 'test')
```
`cameleer-license-minter/pom.xml` depends on `cameleer-server-core` for `LicenseInfo` and the validator round-trip used by `--verify`. The server app intentionally does not depend on the minter — vendors mint outside the customer-deployed runtime, and a compromised customer cannot leverage server code to forge tokens.

View File

@@ -0,0 +1,69 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>com.cameleer</groupId>
<artifactId>cameleer-server-parent</artifactId>
<version>1.0-SNAPSHOT</version>
</parent>
<artifactId>cameleer-license-minter</artifactId>
<name>Cameleer License Minter</name>
<description>Vendor-only Ed25519 license signing library + CLI</description>
<dependencies>
<dependency>
<groupId>com.cameleer</groupId>
<artifactId>cameleer-server-core</artifactId>
</dependency>
<dependency>
<groupId>com.fasterxml.jackson.core</groupId>
<artifactId>jackson-databind</artifactId>
</dependency>
<dependency>
<groupId>org.slf4j</groupId>
<artifactId>slf4j-api</artifactId>
</dependency>
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.assertj</groupId>
<artifactId>assertj-core</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
<executions>
<!-- Disable the default repackage so the main artifact stays as a plain library
JAR consumable as a Maven test-scope dependency by cameleer-server-app. -->
<execution>
<id>repackage</id>
<phase>none</phase>
</execution>
<execution>
<id>repackage-cli</id>
<goals>
<goal>repackage</goal>
</goals>
<configuration>
<classifier>cli</classifier>
<mainClass>com.cameleer.license.minter.cli.LicenseMinterCli</mainClass>
</configuration>
</execution>
</executions>
</plugin>
</plugins>
</build>
</project>

View File

@@ -0,0 +1,52 @@
package com.cameleer.license.minter;
import com.cameleer.server.core.license.LicenseInfo;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.SerializationFeature;
import com.fasterxml.jackson.databind.node.ObjectNode;
import java.security.PrivateKey;
import java.security.Signature;
import java.util.Base64;
import java.util.TreeMap;
public final class LicenseMinter {
private static final ObjectMapper MAPPER = new ObjectMapper()
.configure(SerializationFeature.ORDER_MAP_ENTRIES_BY_KEYS, true);
private LicenseMinter() {}
public static String mint(LicenseInfo info, PrivateKey ed25519PrivateKey) {
byte[] payload = canonicalPayload(info);
try {
Signature signer = Signature.getInstance("Ed25519");
signer.initSign(ed25519PrivateKey);
signer.update(payload);
byte[] sig = signer.sign();
return Base64.getEncoder().encodeToString(payload) + "." + Base64.getEncoder().encodeToString(sig);
} catch (Exception e) {
throw new IllegalStateException("Failed to sign license", e);
}
}
static byte[] canonicalPayload(LicenseInfo info) {
ObjectNode root = MAPPER.createObjectNode();
root.put("exp", info.expiresAt().getEpochSecond());
root.put("gracePeriodDays", info.gracePeriodDays());
root.put("iat", info.issuedAt().getEpochSecond());
if (info.label() != null) {
root.put("label", info.label());
}
root.put("licenseId", info.licenseId().toString());
ObjectNode limits = MAPPER.createObjectNode();
new TreeMap<>(info.limits()).forEach(limits::put);
root.set("limits", limits);
root.put("tenantId", info.tenantId());
try {
return MAPPER.writeValueAsBytes(root);
} catch (Exception e) {
throw new IllegalStateException("Failed to serialize license payload", e);
}
}
}

View File

@@ -0,0 +1,136 @@
package com.cameleer.license.minter.cli;
import com.cameleer.license.minter.LicenseMinter;
import com.cameleer.server.core.license.LicenseInfo;
import java.io.PrintStream;
import java.nio.file.Files;
import java.nio.file.Path;
import java.security.KeyFactory;
import java.security.PrivateKey;
import java.security.spec.PKCS8EncodedKeySpec;
import java.time.Instant;
import java.time.LocalDate;
import java.time.ZoneOffset;
import java.util.Base64;
import java.util.Collections;
import java.util.HashSet;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.Set;
import java.util.TreeMap;
import java.util.UUID;
public final class LicenseMinterCli {
private static final Set<String> KNOWN_FLAGS = Set.of(
"--private-key", "--public-key", "--tenant", "--label",
"--expires", "--grace-days", "--output", "--verify"
);
public static void main(String[] args) {
System.exit(run(args));
}
public static int run(String[] args) {
return run(args, System.out, System.err);
}
public static int run(String[] args, PrintStream out, PrintStream err) {
Map<String, String> flags = new LinkedHashMap<>();
Set<String> bool = new HashSet<>();
Map<String, Integer> limits = new TreeMap<>();
for (String arg : args) {
if (!arg.startsWith("--")) {
err.println("unexpected positional argument: " + arg);
return 2;
}
int eq = arg.indexOf('=');
String key = eq < 0 ? arg : arg.substring(0, eq);
String value = eq < 0 ? null : arg.substring(eq + 1);
if (key.startsWith("--max-")) {
String limitKey = "max_" + key.substring("--max-".length()).replace('-', '_');
if (value == null) {
err.println("missing value for " + key);
return 2;
}
limits.put(limitKey, Integer.parseInt(value));
continue;
}
if (!KNOWN_FLAGS.contains(key)) {
err.println("unknown flag: " + key);
return 2;
}
if (value == null) {
bool.add(key);
} else {
flags.put(key, value);
}
}
String privPath = flags.get("--private-key");
String tenant = flags.get("--tenant");
String expiresIso = flags.get("--expires");
if (privPath == null || tenant == null || expiresIso == null) {
err.println("required: --private-key --tenant --expires");
return 2;
}
try {
PrivateKey privateKey = readEd25519PrivateKey(Path.of(privPath));
int graceDays = Integer.parseInt(flags.getOrDefault("--grace-days", "0"));
Instant exp = LocalDate.parse(expiresIso).atStartOfDay(ZoneOffset.UTC).toInstant();
LicenseInfo info = new LicenseInfo(
UUID.randomUUID(),
tenant,
flags.get("--label"),
Collections.unmodifiableMap(limits),
Instant.now(),
exp,
graceDays
);
String token = LicenseMinter.mint(info, privateKey);
String outPath = flags.get("--output");
if (outPath != null) {
Files.writeString(Path.of(outPath), token);
out.println("wrote " + outPath);
} else {
out.println(token);
}
if (bool.contains("--verify")) {
String pubPath = flags.get("--public-key");
if (pubPath == null) {
err.println("--verify requires --public-key");
if (outPath != null) Files.deleteIfExists(Path.of(outPath));
return 2;
}
try {
String pubB64 = Files.readString(Path.of(pubPath)).trim();
new com.cameleer.server.core.license.LicenseValidator(pubB64, tenant).validate(token);
out.println("verified ok");
} catch (Exception ve) {
err.println("VERIFY FAILED: " + ve.getMessage());
if (outPath != null) Files.deleteIfExists(Path.of(outPath));
return 3;
}
}
return 0;
} catch (Exception e) {
err.println("ERROR: " + e.getMessage());
return 1;
}
}
private static PrivateKey readEd25519PrivateKey(Path path) throws Exception {
String s = Files.readString(path).trim();
if (s.startsWith("-----BEGIN")) {
s = s.replaceAll("-----BEGIN [A-Z ]+-----", "")
.replaceAll("-----END [A-Z ]+-----", "")
.replaceAll("\\s", "");
}
byte[] der = Base64.getDecoder().decode(s);
return KeyFactory.getInstance("Ed25519")
.generatePrivate(new PKCS8EncodedKeySpec(der));
}
}

View File

@@ -0,0 +1,53 @@
package com.cameleer.license.minter;
import com.cameleer.server.core.license.LicenseInfo;
import com.cameleer.server.core.license.LicenseValidator;
import org.junit.jupiter.api.Test;
import java.security.KeyPair;
import java.security.KeyPairGenerator;
import java.time.Instant;
import java.util.Base64;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.UUID;
import static org.assertj.core.api.Assertions.assertThat;
class LicenseMinterTest {
@Test
void roundTrip_validatorAcceptsMintedToken() throws Exception {
KeyPair kp = KeyPairGenerator.getInstance("Ed25519").generateKeyPair();
String publicB64 = Base64.getEncoder().encodeToString(kp.getPublic().getEncoded());
LicenseInfo info = new LicenseInfo(
UUID.randomUUID(), "acme", "ACME prod",
Map.of("max_apps", 50, "max_agents", 100),
Instant.now(), Instant.now().plusSeconds(86400), 7);
String token = LicenseMinter.mint(info, kp.getPrivate());
LicenseInfo parsed = new LicenseValidator(publicB64, "acme").validate(token);
assertThat(parsed.licenseId()).isEqualTo(info.licenseId());
assertThat(parsed.tenantId()).isEqualTo("acme");
assertThat(parsed.limits().get("max_apps")).isEqualTo(50);
assertThat(parsed.gracePeriodDays()).isEqualTo(7);
}
@Test
void canonicalJson_isStableAcrossRuns() throws Exception {
KeyPair kp = KeyPairGenerator.getInstance("Ed25519").generateKeyPair();
UUID id = UUID.randomUUID();
Instant now = Instant.parse("2026-04-25T10:00:00Z");
Instant exp = Instant.parse("2027-04-25T10:00:00Z");
LinkedHashMap<String, Integer> limits = new LinkedHashMap<>();
limits.put("max_apps", 5);
limits.put("max_agents", 10);
LicenseInfo info = new LicenseInfo(id, "acme", "label", limits, now, exp, 0);
String t1 = LicenseMinter.mint(info, kp.getPrivate());
String t2 = LicenseMinter.mint(info, kp.getPrivate());
assertThat(t1).isEqualTo(t2);
}
}

View File

@@ -0,0 +1,112 @@
package com.cameleer.license.minter.cli;
import com.cameleer.server.core.license.LicenseValidator;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import java.nio.file.Files;
import java.nio.file.Path;
import java.security.KeyPair;
import java.security.KeyPairGenerator;
import java.util.Base64;
import static org.assertj.core.api.Assertions.assertThat;
class LicenseMinterCliTest {
@TempDir Path tmp;
@Test
void mints_validToken_validatorAccepts() throws Exception {
KeyPair kp = KeyPairGenerator.getInstance("Ed25519").generateKeyPair();
Path priv = tmp.resolve("priv.b64");
Path pub = tmp.resolve("pub.b64");
Files.writeString(priv, Base64.getEncoder().encodeToString(kp.getPrivate().getEncoded()));
Files.writeString(pub, Base64.getEncoder().encodeToString(kp.getPublic().getEncoded()));
Path out = tmp.resolve("license.tok");
int code = LicenseMinterCli.run(new String[]{
"--private-key=" + priv,
"--tenant=acme",
"--label=ACME",
"--expires=2099-12-31",
"--grace-days=30",
"--max-apps=50",
"--output=" + out
});
assertThat(code).isEqualTo(0);
String token = Files.readString(out).trim();
var info = new LicenseValidator(Files.readString(pub).trim(), "acme").validate(token);
assertThat(info.tenantId()).isEqualTo("acme");
assertThat(info.limits().get("max_apps")).isEqualTo(50);
assertThat(info.gracePeriodDays()).isEqualTo(30);
}
@Test
void unknownFlag_failsFast() {
int code = LicenseMinterCli.run(new String[]{"--frobnicate=yes"});
assertThat(code).isNotZero();
}
@Test
void verify_happyPath_succeeds() throws Exception {
KeyPair kp = KeyPairGenerator.getInstance("Ed25519").generateKeyPair();
Path priv = tmp.resolve("priv.b64");
Path pub = tmp.resolve("pub.b64");
Files.writeString(priv, Base64.getEncoder().encodeToString(kp.getPrivate().getEncoded()));
Files.writeString(pub, Base64.getEncoder().encodeToString(kp.getPublic().getEncoded()));
Path out = tmp.resolve("license.tok");
int code = LicenseMinterCli.run(new String[]{
"--private-key=" + priv,
"--public-key=" + pub,
"--tenant=acme",
"--expires=2099-12-31",
"--output=" + out,
"--verify"
});
assertThat(code).isEqualTo(0);
assertThat(out).exists();
}
@Test
void verify_wrongPublicKey_deletesOutputAndExitsNonZero() throws Exception {
KeyPair signing = KeyPairGenerator.getInstance("Ed25519").generateKeyPair();
KeyPair other = KeyPairGenerator.getInstance("Ed25519").generateKeyPair();
Path priv = tmp.resolve("priv.b64");
Path pub = tmp.resolve("pub.b64");
Files.writeString(priv, Base64.getEncoder().encodeToString(signing.getPrivate().getEncoded()));
Files.writeString(pub, Base64.getEncoder().encodeToString(other.getPublic().getEncoded()));
Path out = tmp.resolve("license.tok");
int code = LicenseMinterCli.run(new String[]{
"--private-key=" + priv,
"--public-key=" + pub,
"--tenant=acme",
"--expires=2099-12-31",
"--output=" + out,
"--verify"
});
assertThat(code).isNotZero();
assertThat(out).doesNotExist();
}
@Test
void verify_withoutPublicKey_fails() throws Exception {
KeyPair kp = KeyPairGenerator.getInstance("Ed25519").generateKeyPair();
Path priv = tmp.resolve("priv.b64");
Files.writeString(priv, Base64.getEncoder().encodeToString(kp.getPrivate().getEncoded()));
int code = LicenseMinterCli.run(new String[]{
"--private-key=" + priv,
"--tenant=acme",
"--expires=2099-12-31",
"--verify"
});
assertThat(code).isNotZero();
}
}

View File

@@ -19,6 +19,12 @@
<groupId>com.cameleer</groupId>
<artifactId>cameleer-server-core</artifactId>
</dependency>
<dependency>
<groupId>com.cameleer</groupId>
<artifactId>cameleer-license-minter</artifactId>
<version>${project.version}</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>

View File

@@ -12,6 +12,7 @@ import com.cameleer.server.app.alerting.eval.EvalContext;
import com.cameleer.server.app.alerting.eval.EvalResult;
import com.cameleer.server.app.alerting.eval.TickCache;
import com.cameleer.server.app.alerting.notify.MustacheRenderer;
import com.cameleer.server.app.license.LicenseEnforcer;
import com.cameleer.server.app.web.EnvPath;
import com.cameleer.server.core.admin.AuditCategory;
import com.cameleer.server.core.admin.AuditResult;
@@ -78,6 +79,7 @@ public class AlertRuleController {
private final Map<ConditionKind, ConditionEvaluator<?>> evaluators;
private final Clock clock;
private final String tenantId;
private final LicenseEnforcer licenseEnforcer;
@SuppressWarnings("SpringJavaInjectionPointsAutowiringInspection")
public AlertRuleController(AlertRuleRepository ruleRepo,
@@ -86,7 +88,8 @@ public class AlertRuleController {
MustacheRenderer renderer,
List<ConditionEvaluator<?>> evaluatorList,
Clock alertingClock,
@Value("${cameleer.server.tenant.id:default}") String tenantId) {
@Value("${cameleer.server.tenant.id:default}") String tenantId,
LicenseEnforcer licenseEnforcer) {
this.ruleRepo = ruleRepo;
this.connectionService = connectionService;
this.auditService = auditService;
@@ -97,6 +100,7 @@ public class AlertRuleController {
}
this.clock = alertingClock;
this.tenantId = tenantId;
this.licenseEnforcer = licenseEnforcer;
}
// -------------------------------------------------------------------------
@@ -126,6 +130,8 @@ public class AlertRuleController {
@Valid @RequestBody AlertRuleRequest req,
HttpServletRequest httpRequest) {
licenseEnforcer.assertWithinCap("max_alert_rules", ruleRepo.count(), 1);
validateAttributeKeys(req.condition());
validateBusinessRules(req);
validateWebhooks(req.webhooks(), env.id());

View File

@@ -113,6 +113,12 @@ public class PostgresAlertRuleRepository implements AlertRuleRepository {
jdbc.update("DELETE FROM alert_rules WHERE id = ?", id);
}
@Override
public long count() {
Long n = jdbc.queryForObject("SELECT COUNT(*) FROM alert_rules", Long.class);
return n == null ? 0L : n;
}
@Override
public List<AlertRule> claimDueRules(String instanceId, int batchSize, int claimTtlSeconds) {
String sql = """

View File

@@ -17,11 +17,13 @@ import org.springframework.context.annotation.Configuration;
public class AgentRegistryBeanConfig {
@Bean
public AgentRegistryService agentRegistryService(AgentRegistryConfig config) {
public AgentRegistryService agentRegistryService(AgentRegistryConfig config,
com.cameleer.server.app.license.LicenseEnforcer enforcer) {
return new AgentRegistryService(
config.getStaleThresholdMs(),
config.getDeadThresholdMs(),
config.getCommandExpiryMs()
config.getCommandExpiryMs(),
current -> enforcer.assertWithinCap("max_agents", current, 1)
);
}

View File

@@ -1,22 +1,48 @@
package com.cameleer.server.app.config;
import com.cameleer.server.app.license.LicenseRepository;
import com.cameleer.server.app.license.LicenseService;
import com.cameleer.server.core.admin.AuditService;
import com.cameleer.server.core.license.LicenseGate;
import com.cameleer.server.core.license.LicenseInfo;
import com.cameleer.server.core.license.LicenseValidator;
import jakarta.annotation.PostConstruct;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.ApplicationEventPublisher;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import java.nio.file.Files;
import java.nio.file.Path;
import java.security.KeyPair;
import java.security.KeyPairGenerator;
import java.util.Base64;
import java.util.Optional;
/**
* License bean topology (4 beans, in dependency order):
*
* <ol>
* <li>{@link LicenseGate} — always present, mutated by {@link LicenseService}.</li>
* <li>{@link LicenseValidator} — always present. When no public key is configured, returns an
* always-failing override so any loaded token routes through {@code install()} and is
* audited as INVALID rather than silently ignored.</li>
* <li>{@link LicenseService} — single mediation point for install / replace / revalidate;
* audits + persists + publishes {@code LicenseChangedEvent}.</li>
* <li>{@link LicenseBootLoader} — {@code @PostConstruct} drives {@code loadInitial} after the
* Spring context is ready. Resolution order: env var &gt; license file &gt; persisted DB row.</li>
* </ol>
*/
@Configuration
public class LicenseBeanConfig {
private static final Logger log = LoggerFactory.getLogger(LicenseBeanConfig.class);
@Value("${cameleer.server.tenant.id:default}")
private String tenantId;
@Value("${cameleer.server.license.token:}")
private String licenseToken;
@@ -28,41 +54,77 @@ public class LicenseBeanConfig {
@Bean
public LicenseGate licenseGate() {
LicenseGate gate = new LicenseGate();
String token = resolveLicenseToken();
if (token == null || token.isBlank()) {
log.info("No license configured — running in open mode (all features enabled)");
return gate;
}
if (licensePublicKey == null || licensePublicKey.isBlank()) {
log.warn("License token provided but no public key configured (CAMELEER_SERVER_LICENSE_PUBLICKEY). Running in open mode.");
return gate;
}
try {
LicenseValidator validator = new LicenseValidator(licensePublicKey);
LicenseInfo info = validator.validate(token);
gate.load(info);
} catch (Exception e) {
log.error("Failed to validate license: {}. Running in open mode.", e.getMessage());
}
return gate;
return new LicenseGate();
}
private String resolveLicenseToken() {
if (licenseToken != null && !licenseToken.isBlank()) {
return licenseToken;
}
if (licenseFile != null && !licenseFile.isBlank()) {
@Bean
public LicenseValidator licenseValidator() {
if (licensePublicKey == null || licensePublicKey.isBlank()) {
log.warn("CAMELEER_SERVER_LICENSE_PUBLICKEY not set — all licenses will be rejected as INVALID");
// Generate a throwaway, structurally-valid Ed25519 keypair just to satisfy the
// parent constructor's X.509 SubjectPublicKeyInfo decode + Ed25519 point validation.
// The overridden validate(...) always throws, so the dummy key is never used to
// verify anything — it only exists so the bean is constructable in misconfigured
// installs and any token that is loaded routes to INVALID via install()'s catch.
try {
return Files.readString(Path.of(licenseFile)).trim();
KeyPair throwaway = KeyPairGenerator.getInstance("Ed25519").generateKeyPair();
String dummyPub = Base64.getEncoder().encodeToString(throwaway.getPublic().getEncoded());
return new LicenseValidator(dummyPub, tenantId) {
@Override
public LicenseInfo validate(String token) {
throw new IllegalStateException("license public key not configured");
}
};
} catch (Exception e) {
log.warn("Failed to read license file {}: {}", licenseFile, e.getMessage());
throw new IllegalStateException("Failed to construct fallback license validator", e);
}
}
return null;
return new LicenseValidator(licensePublicKey, tenantId);
}
@Bean
public LicenseService licenseService(LicenseRepository repo,
LicenseGate gate,
LicenseValidator validator,
AuditService audit,
ApplicationEventPublisher events) {
return new LicenseService(tenantId, repo, gate, validator, audit, events);
}
@Bean
public LicenseBootLoader licenseBootLoader(LicenseService svc) {
return new LicenseBootLoader(svc, licenseToken, licenseFile);
}
/**
* {@code @PostConstruct} bridge that converts env-var/file values into the
* {@code Optional<String>} pair {@link LicenseService#loadInitial} expects, so
* env-var, file, and DB paths share the same audit + event-publish code path.
*/
public static class LicenseBootLoader {
private final LicenseService svc;
private final String envToken;
private final String filePath;
public LicenseBootLoader(LicenseService svc, String envToken, String filePath) {
this.svc = svc;
this.envToken = envToken;
this.filePath = filePath;
}
@PostConstruct
public void load() {
Optional<String> env = (envToken != null && !envToken.isBlank())
? Optional.of(envToken) : Optional.empty();
Optional<String> file = Optional.empty();
if (filePath != null && !filePath.isBlank()) {
try {
file = Optional.of(Files.readString(Path.of(filePath)).trim());
} catch (Exception e) {
log.warn("Failed to read license file {}: {}", filePath, e.getMessage());
}
}
svc.loadInitial(env, file);
}
}
}

View File

@@ -50,14 +50,18 @@ public class RuntimeBeanConfig {
}
@Bean
public EnvironmentService environmentService(EnvironmentRepository repo) {
return new EnvironmentService(repo);
public EnvironmentService environmentService(EnvironmentRepository repo,
com.cameleer.server.app.license.LicenseEnforcer enforcer) {
return new EnvironmentService(repo, current ->
enforcer.assertWithinCap("max_environments", current, 1));
}
@Bean
public AppService appService(AppRepository appRepo, AppVersionRepository versionRepo,
@Value("${cameleer.server.runtime.jarstoragepath:/data/jars}") String jarStoragePath) {
return new AppService(appRepo, versionRepo, jarStoragePath);
@Value("${cameleer.server.runtime.jarstoragepath:/data/jars}") String jarStoragePath,
com.cameleer.server.app.license.LicenseEnforcer enforcer) {
return new AppService(appRepo, versionRepo, jarStoragePath,
current -> enforcer.assertWithinCap("max_apps", current, 1));
}
@Bean

View File

@@ -203,4 +203,12 @@ public class StorageBeanConfig {
ClickHouseUsageTracker usageTracker) {
return new com.cameleer.server.app.analytics.UsageFlushScheduler(usageTracker);
}
// ── License Repository ───────────────────────────────────────────
@Bean
public com.cameleer.server.app.license.LicenseRepository licenseRepository(
JdbcTemplate jdbcTemplate) {
return new com.cameleer.server.app.license.PostgresLicenseRepository(jdbcTemplate);
}
}

View File

@@ -1,5 +1,6 @@
package com.cameleer.server.app.controller;
import com.cameleer.server.core.license.LicenseGate;
import com.cameleer.server.core.runtime.Environment;
import com.cameleer.server.core.runtime.EnvironmentColor;
import com.cameleer.server.core.runtime.EnvironmentService;
@@ -7,9 +8,11 @@ import com.cameleer.server.core.runtime.RuntimeType;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.responses.ApiResponse;
import io.swagger.v3.oas.annotations.tags.Tag;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.server.ResponseStatusException;
import java.util.List;
import java.util.Map;
@@ -21,9 +24,11 @@ import java.util.Map;
public class EnvironmentAdminController {
private final EnvironmentService environmentService;
private final LicenseGate licenseGate;
public EnvironmentAdminController(EnvironmentService environmentService) {
public EnvironmentAdminController(EnvironmentService environmentService, LicenseGate licenseGate) {
this.environmentService = environmentService;
this.licenseGate = licenseGate;
}
@GetMapping
@@ -141,11 +146,24 @@ public class EnvironmentAdminController {
@Operation(summary = "Update JAR retention policy for an environment")
@ApiResponse(responseCode = "200", description = "Retention policy updated")
@ApiResponse(responseCode = "404", description = "Environment not found")
@ApiResponse(responseCode = "422", description = "jarRetentionCount exceeds license cap")
public ResponseEntity<?> updateJarRetention(@PathVariable String envSlug,
@RequestBody JarRetentionRequest request) {
try {
Environment current = environmentService.getBySlug(envSlug);
environmentService.updateJarRetentionCount(current.id(), request.jarRetentionCount());
// License cap check: only fires when a non-null value is supplied (null = unlimited).
// 422 (not 403) because this is a value-out-of-range, not a creation-quota rejection;
// therefore we do NOT route through LicenseEnforcer / LicenseExceptionAdvice.
Integer requested = request.jarRetentionCount();
if (requested != null) {
int cap = licenseGate.getEffectiveLimits().get("max_jar_retention_count");
if (requested > cap) {
throw new ResponseStatusException(HttpStatus.UNPROCESSABLE_ENTITY,
"jarRetentionCount " + requested + " exceeds license cap "
+ cap + " (max_jar_retention_count)");
}
}
environmentService.updateJarRetentionCount(current.id(), requested);
return ResponseEntity.ok(environmentService.getBySlug(envSlug));
} catch (IllegalArgumentException e) {
if (e.getMessage().contains("not found")) {

View File

@@ -1,51 +1,71 @@
package com.cameleer.server.app.controller;
import com.cameleer.server.app.license.LicenseRepository;
import com.cameleer.server.app.license.LicenseService;
import com.cameleer.server.core.license.LicenseGate;
import com.cameleer.server.core.license.LicenseInfo;
import com.cameleer.server.core.license.LicenseValidator;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.ResponseEntity;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.*;
import org.springframework.security.core.Authentication;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import java.util.LinkedHashMap;
import java.util.Map;
/**
* License management for ADMIN users. All mutation goes through {@link LicenseService} so that
* install / replace flows are uniformly audited, persisted, and published to listeners (retention
* policy, license metrics, etc.).
*
* <p>GET returns {@code {state, invalidReason, envelope, lastValidatedAt?}}. The raw JWT-style
* token is deliberately omitted from the response — only the parsed {@link LicenseInfo} is
* exposed.</p>
*/
@RestController
@RequestMapping("/api/v1/admin/license")
@PreAuthorize("hasRole('ADMIN')")
@Tag(name = "License Admin", description = "License management")
public class LicenseAdminController {
private final LicenseGate licenseGate;
private final String licensePublicKey;
private final LicenseService licenseService;
private final LicenseGate gate;
private final LicenseRepository repo;
public LicenseAdminController(LicenseGate licenseGate,
@Value("${cameleer.server.license.publickey:}") String licensePublicKey) {
this.licenseGate = licenseGate;
this.licensePublicKey = licensePublicKey;
public LicenseAdminController(LicenseService svc, LicenseGate gate, LicenseRepository repo) {
this.licenseService = svc;
this.gate = gate;
this.repo = repo;
}
@GetMapping
@Operation(summary = "Get current license info")
public ResponseEntity<LicenseInfo> getCurrent() {
return ResponseEntity.ok(licenseGate.getCurrent());
@Operation(summary = "Get current license state, invalid reason, and parsed envelope")
public ResponseEntity<Map<String, Object>> getCurrent() {
Map<String, Object> body = new LinkedHashMap<>();
body.put("state", gate.getState().name());
body.put("invalidReason", gate.getInvalidReason());
body.put("envelope", gate.getCurrent()); // null when ABSENT/INVALID; raw token deliberately omitted
repo.findByTenantId(licenseService.getTenantId()).ifPresent(rec ->
body.put("lastValidatedAt", rec.lastValidatedAt().toString()));
return ResponseEntity.ok(body);
}
record UpdateLicenseRequest(String token) {}
public record UpdateLicenseRequest(String token) {}
@PostMapping
@Operation(summary = "Update license token at runtime")
public ResponseEntity<?> update(@RequestBody UpdateLicenseRequest request) {
if (licensePublicKey == null || licensePublicKey.isBlank()) {
return ResponseEntity.badRequest().body(Map.of("error", "No license public key configured"));
}
@Operation(summary = "Install or replace the license token at runtime")
public ResponseEntity<?> update(@RequestBody UpdateLicenseRequest request, Authentication auth) {
String userId = auth == null ? "system" : auth.getName().replaceFirst("^user:", "");
try {
LicenseValidator validator = new LicenseValidator(licensePublicKey);
LicenseInfo info = validator.validate(request.token());
licenseGate.load(info);
return ResponseEntity.ok(info);
LicenseInfo info = licenseService.install(request.token(), userId, "api");
return ResponseEntity.ok(Map.of(
"state", gate.getState().name(),
"envelope", info));
} catch (Exception e) {
return ResponseEntity.badRequest().body(Map.of("error", e.getMessage()));
}

View File

@@ -0,0 +1,97 @@
package com.cameleer.server.app.controller;
import com.cameleer.server.app.license.LicenseMessageRenderer;
import com.cameleer.server.app.license.LicenseRepository;
import com.cameleer.server.app.license.LicenseService;
import com.cameleer.server.app.license.LicenseUsageReader;
import com.cameleer.server.core.agent.AgentRegistryService;
import com.cameleer.server.core.license.LicenseGate;
import org.springframework.http.ResponseEntity;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import java.time.Duration;
import java.time.Instant;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
/**
* Read-only operator surface returning current license state, key timestamps, the
* human-readable message produced by {@link LicenseMessageRenderer}, and a per-limit
* usage/cap/source table covering every key exposed by the effective limits map.
*
* <p>Each limit row carries:
* <ul>
* <li>{@code key} — the limit key (e.g. {@code max_apps})</li>
* <li>{@code current} — current usage (0 when not measured server-side)</li>
* <li>{@code cap} — effective cap (license override or default-tier value)</li>
* <li>{@code source} — {@code "license"} when the cap came from the license override map,
* {@code "default"} otherwise</li>
* </ul>
*
* <p>{@code max_agents} is sourced from the in-memory {@link AgentRegistryService} since the
* registry is not persisted; all other counts come from PostgreSQL via
* {@link LicenseUsageReader#snapshot()}.</p>
*/
@RestController
@RequestMapping("/api/v1/admin/license/usage")
@PreAuthorize("hasRole('ADMIN')")
public class LicenseUsageController {
private final LicenseGate gate;
private final LicenseUsageReader reader;
private final AgentRegistryService agents;
private final LicenseService svc;
private final LicenseRepository repo;
public LicenseUsageController(LicenseGate gate,
LicenseUsageReader reader,
AgentRegistryService agents,
LicenseService svc,
LicenseRepository repo) {
this.gate = gate;
this.reader = reader;
this.agents = agents;
this.svc = svc;
this.repo = repo;
}
@GetMapping
public ResponseEntity<Map<String, Object>> get() {
var state = gate.getState();
var info = gate.getCurrent();
var effective = gate.getEffectiveLimits();
Map<String, Long> usage = new HashMap<>(reader.snapshot());
usage.put("max_agents", (long) agents.liveCount());
List<Map<String, Object>> limitRows = new ArrayList<>();
for (var key : effective.values().keySet()) {
Map<String, Object> row = new LinkedHashMap<>();
row.put("key", key);
row.put("current", usage.getOrDefault(key, 0L));
row.put("cap", effective.get(key));
row.put("source", info != null && info.limits().containsKey(key) ? "license" : "default");
limitRows.add(row);
}
Map<String, Object> body = new LinkedHashMap<>();
body.put("state", state.name());
body.put("expiresAt", info == null ? null : info.expiresAt().toString());
body.put("daysRemaining", info == null ? null
: Duration.between(Instant.now(), info.expiresAt()).toDays());
body.put("gracePeriodDays", info == null ? 0 : info.gracePeriodDays());
body.put("tenantId", info == null ? null : info.tenantId());
body.put("label", info == null ? null : info.label());
repo.findByTenantId(svc.getTenantId()).ifPresent(rec ->
body.put("lastValidatedAt", rec.lastValidatedAt().toString()));
body.put("message", LicenseMessageRenderer.forState(state, info, gate.getInvalidReason()));
body.put("limits", limitRows);
return ResponseEntity.ok(body);
}
}

View File

@@ -1,6 +1,7 @@
package com.cameleer.server.app.controller;
import com.cameleer.server.app.dto.SetPasswordRequest;
import com.cameleer.server.app.license.LicenseEnforcer;
import com.cameleer.server.core.admin.AuditCategory;
import com.cameleer.server.core.admin.AuditResult;
import com.cameleer.server.core.admin.AuditService;
@@ -52,13 +53,16 @@ public class UserAdminController {
private final RbacService rbacService;
private final UserRepository userRepository;
private final AuditService auditService;
private final LicenseEnforcer licenseEnforcer;
private final boolean oidcEnabled;
public UserAdminController(RbacService rbacService, UserRepository userRepository,
AuditService auditService, SecurityProperties securityProperties) {
AuditService auditService, SecurityProperties securityProperties,
LicenseEnforcer licenseEnforcer) {
this.rbacService = rbacService;
this.userRepository = userRepository;
this.auditService = auditService;
this.licenseEnforcer = licenseEnforcer;
String issuer = securityProperties.getOidc().getIssuerUri();
this.oidcEnabled = issuer != null && !issuer.isBlank();
}
@@ -89,6 +93,9 @@ public class UserAdminController {
@ApiResponse(responseCode = "400", description = "Disabled in OIDC mode")
public ResponseEntity<?> createUser(@RequestBody CreateUserRequest request,
HttpServletRequest httpRequest) {
// License cap fires first so over-cap creates short-circuit before any other validation.
// Audit emission for the rejection is handled inside LicenseEnforcer (3-arg ctor wires AuditService).
licenseEnforcer.assertWithinCap("max_users", userRepository.count(), 1);
if (oidcEnabled) {
return ResponseEntity.badRequest()
.body(Map.of("error", "Local user creation is disabled when OIDC is enabled. Users are provisioned automatically via SSO."));

View File

@@ -0,0 +1,18 @@
package com.cameleer.server.app.license;
public class LicenseCapExceededException extends RuntimeException {
private final String limitKey;
private final long current;
private final long cap;
public LicenseCapExceededException(String limitKey, long current, long cap) {
super("license cap reached: " + limitKey + " current=" + current + " cap=" + cap);
this.limitKey = limitKey;
this.current = current;
this.cap = cap;
}
public String limitKey() { return limitKey; }
public long current() { return current; }
public long cap() { return cap; }
}

View File

@@ -0,0 +1,12 @@
package com.cameleer.server.app.license;
import com.cameleer.server.core.license.LicenseInfo;
import com.cameleer.server.core.license.LicenseState;
import java.util.Objects;
public record LicenseChangedEvent(LicenseState state, LicenseInfo current) {
public LicenseChangedEvent {
Objects.requireNonNull(state);
}
}

View File

@@ -0,0 +1,80 @@
package com.cameleer.server.app.license;
import com.cameleer.server.core.admin.AuditCategory;
import com.cameleer.server.core.admin.AuditResult;
import com.cameleer.server.core.admin.AuditService;
import com.cameleer.server.core.license.LicenseGate;
import com.cameleer.server.core.license.LicenseLimits;
import io.micrometer.core.instrument.Counter;
import io.micrometer.core.instrument.MeterRegistry;
import io.micrometer.core.instrument.simple.SimpleMeterRegistry;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ConcurrentMap;
/**
* Single entry point for license cap enforcement (spec §4).
*
* <p>Consults {@link LicenseGate#getEffectiveLimits()} (license-overrides UNION default tier when
* ACTIVE/GRACE; defaults-only otherwise) and rejects calls whose projected usage would exceed the
* cap. Rejections increment a per-limit Micrometer counter and, when an {@link AuditService} is
* wired, emit an {@link AuditCategory#LICENSE} {@code cap_exceeded} audit row.</p>
*
* <p>Unknown limit keys are treated as programmer errors and surface as
* {@link IllegalArgumentException} (propagated from {@link LicenseLimits#get(String)}), not
* {@link LicenseCapExceededException}.</p>
*/
@Component
public class LicenseEnforcer {
private static final Logger log = LoggerFactory.getLogger(LicenseEnforcer.class);
private static final String COUNTER_NAME = "cameleer_license_cap_rejections_total";
private final LicenseGate gate;
private final MeterRegistry meters;
private final AuditService audit;
private final ConcurrentMap<String, Counter> rejectionCounters = new ConcurrentHashMap<>();
@Autowired
public LicenseEnforcer(LicenseGate gate, MeterRegistry meters, AuditService audit) {
this.gate = gate;
this.meters = meters;
this.audit = audit;
}
/** Test-only ctor with no metrics or audit. */
public LicenseEnforcer(LicenseGate gate) {
this(gate, new SimpleMeterRegistry(), null);
}
public void assertWithinCap(String limitKey, long currentUsage, long requestedDelta) {
LicenseLimits effective = gate.getEffectiveLimits();
int cap = effective.get(limitKey); // throws IllegalArgumentException if unknown key
long projected = currentUsage + requestedDelta;
if (projected > cap) {
rejectionCounters.computeIfAbsent(limitKey, k -> Counter.builder(COUNTER_NAME)
.tag("limit", k).register(meters)).increment();
if (audit != null) {
try {
Map<String, Object> detail = new LinkedHashMap<>();
detail.put("limit", limitKey);
detail.put("current", currentUsage);
detail.put("requested", requestedDelta);
detail.put("cap", cap);
detail.put("state", gate.getState().name());
audit.log("system", "cap_exceeded", AuditCategory.LICENSE, limitKey, detail, AuditResult.FAILURE, null);
} catch (RuntimeException e) {
// Audit storage degraded; log and continue so the cap rejection still surfaces as 403.
log.warn("Failed to write cap_exceeded audit row for limit={}: {}", limitKey, e.toString());
}
}
throw new LicenseCapExceededException(limitKey, projected, cap);
}
}
}

View File

@@ -0,0 +1,36 @@
package com.cameleer.server.app.license;
import com.cameleer.server.core.license.LicenseGate;
import com.cameleer.server.core.license.LicenseInfo;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.ControllerAdvice;
import org.springframework.web.bind.annotation.ExceptionHandler;
import java.util.LinkedHashMap;
import java.util.Map;
@ControllerAdvice
public class LicenseExceptionAdvice {
private final LicenseGate gate;
public LicenseExceptionAdvice(LicenseGate gate) {
this.gate = gate;
}
@ExceptionHandler(LicenseCapExceededException.class)
public ResponseEntity<Map<String, Object>> handle(LicenseCapExceededException e) {
var state = gate.getState();
LicenseInfo info = gate.getCurrent();
String reason = gate.getInvalidReason();
Map<String, Object> body = new LinkedHashMap<>();
body.put("error", "license cap reached");
body.put("limit", e.limitKey());
body.put("current", e.current());
body.put("cap", e.cap());
body.put("state", state.name());
body.put("message", LicenseMessageRenderer.forCap(state, info, e.limitKey(), e.current(), e.cap(), reason));
return ResponseEntity.status(HttpStatus.FORBIDDEN).body(body);
}
}

View File

@@ -0,0 +1,83 @@
package com.cameleer.server.app.license;
import com.cameleer.server.core.license.LicenseInfo;
import com.cameleer.server.core.license.LicenseState;
import java.time.Duration;
import java.time.Instant;
public final class LicenseMessageRenderer {
private LicenseMessageRenderer() {}
public static String forCap(LicenseState state, LicenseInfo info, String limit, long current, long cap) {
return forCap(state, info, limit, current, cap, null);
}
public static String forCap(LicenseState state, LicenseInfo info, String limit, long current, long cap, String invalidReason) {
switch (state) {
case ABSENT:
return "No license installed: default tier applies (cap = " + cap + " for " + limit
+ "). Install a license to raise this.";
case ACTIVE:
return "License cap reached: " + limit + " = " + cap + ". Current usage is " + current
+ ". Contact your vendor to raise the cap.";
case GRACE: {
long expiredDaysAgo = info == null ? 0 : daysSince(info.expiresAt());
long graceRemaining = info == null ? 0
: Math.max(0, info.gracePeriodDays() - expiredDaysAgo);
return "License expired " + expiredDaysAgo + " day(s) ago and is in its grace period "
+ "(ends in " + graceRemaining + " days). Cap unchanged at " + cap
+ ". Renew before grace ends.";
}
case EXPIRED: {
long expiredDaysAgo = info == null ? 0 : daysSince(info.expiresAt());
return "License expired " + expiredDaysAgo + " days ago: system reverted to default tier (cap = "
+ cap + " for " + limit + "). Current usage is " + current
+ ". Renew the license to lift the cap.";
}
case INVALID:
return "License rejected (" + (invalidReason == null ? "unknown reason" : invalidReason)
+ "): default tier applies (cap = " + cap + " for " + limit + "). Fix the license to raise this.";
default:
return "License cap reached: " + limit + " = " + cap;
}
}
/**
* State-only message used by the /usage endpoint and metrics surfaces where no specific
* cap is being checked. Mirrors forCap() phrasing but omits limit/current/cap details.
*/
public static String forState(LicenseState state, LicenseInfo info) {
return forState(state, info, null);
}
public static String forState(LicenseState state, LicenseInfo info, String invalidReason) {
switch (state) {
case ABSENT:
return "No license installed: default tier applies. Install a license to raise the caps.";
case ACTIVE:
return "License is active.";
case GRACE: {
long expiredDaysAgo = info == null ? 0 : daysSince(info.expiresAt());
long graceRemaining = info == null ? 0
: Math.max(0, info.gracePeriodDays() - expiredDaysAgo);
return "License expired " + expiredDaysAgo + " day(s) ago and is in its grace period "
+ "(ends in " + graceRemaining + " days). Renew before grace ends.";
}
case EXPIRED: {
long expiredDaysAgo = info == null ? 0 : daysSince(info.expiresAt());
return "License expired " + expiredDaysAgo + " days ago: system reverted to default tier. Renew the license to lift the caps.";
}
case INVALID:
return "License rejected (" + (invalidReason == null ? "unknown reason" : invalidReason)
+ "): default tier applies. Fix the license to raise the caps.";
default:
return "License state: " + state.name();
}
}
private static long daysSince(Instant t) {
return Math.max(0, Duration.between(t, Instant.now()).toDays());
}
}

View File

@@ -0,0 +1,77 @@
package com.cameleer.server.app.license;
import com.cameleer.server.core.license.LicenseGate;
import com.cameleer.server.core.license.LicenseState;
import io.micrometer.core.instrument.Gauge;
import io.micrometer.core.instrument.MeterRegistry;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.event.EventListener;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;
import java.time.Duration;
import java.time.Instant;
import java.util.EnumMap;
import java.util.Map;
import java.util.concurrent.atomic.AtomicReference;
/**
* Prometheus gauges that track the live license posture.
*
* <ul>
* <li>{@code cameleer_license_state{state=...}} — one-hot per {@link LicenseState}, exactly
* one tag value carries 1.0 at any time.</li>
* <li>{@code cameleer_license_days_remaining} — days until {@code expiresAt}; negative
* (-1.0) when ABSENT/INVALID (no license loaded).</li>
* <li>{@code cameleer_license_last_validated_age_seconds} — seconds since the persisted
* {@code last_validated_at}; 0 when there is no DB row.</li>
* </ul>
*
* <p>Refreshed eagerly on {@link LicenseChangedEvent} and lazily every 60 seconds so values
* stay current even without explicit state changes (e.g. days_remaining ticks down across
* the day, validated_age grows monotonically).</p>
*/
@Component
public class LicenseMetrics {
private final LicenseGate gate;
private final LicenseRepository repo;
private final String tenantId;
private final Map<LicenseState, AtomicReference<Double>> stateGauges = new EnumMap<>(LicenseState.class);
private final AtomicReference<Double> daysRemaining = new AtomicReference<>(0.0);
private final AtomicReference<Double> validatedAge = new AtomicReference<>(0.0);
public LicenseMetrics(LicenseGate gate, LicenseRepository repo, MeterRegistry meters,
@Value("${cameleer.server.tenant.id:default}") String tenantId) {
this.gate = gate;
this.repo = repo;
this.tenantId = tenantId;
for (var s : LicenseState.values()) {
var ref = new AtomicReference<>(0.0);
stateGauges.put(s, ref);
Gauge.builder("cameleer_license_state", ref, AtomicReference::get)
.tag("state", s.name())
.register(meters);
}
Gauge.builder("cameleer_license_days_remaining", daysRemaining, AtomicReference::get)
.register(meters);
Gauge.builder("cameleer_license_last_validated_age_seconds", validatedAge, AtomicReference::get)
.register(meters);
}
@EventListener(LicenseChangedEvent.class)
@Scheduled(fixedDelay = 60_000)
public void refresh() {
var state = gate.getState();
for (var s : LicenseState.values()) {
stateGauges.get(s).set(s == state ? 1.0 : 0.0);
}
var info = gate.getCurrent();
daysRemaining.set(info == null
? -1.0
: (double) Duration.between(Instant.now(), info.expiresAt()).toDays());
repo.findByTenantId(tenantId).ifPresent(rec ->
validatedAge.set((double) Duration.between(rec.lastValidatedAt(), Instant.now()).toSeconds()));
}
}

View File

@@ -0,0 +1,14 @@
package com.cameleer.server.app.license;
import java.time.Instant;
import java.util.UUID;
public record LicenseRecord(
String tenantId,
String token,
UUID licenseId,
Instant installedAt,
String installedBy,
Instant expiresAt,
Instant lastValidatedAt
) {}

View File

@@ -0,0 +1,17 @@
package com.cameleer.server.app.license;
import java.time.Instant;
import java.util.Optional;
public interface LicenseRepository {
Optional<LicenseRecord> findByTenantId(String tenantId);
/** Insert or replace the row for tenantId. */
void upsert(LicenseRecord record);
/** Update last_validated_at to `now` and return rows affected (0 = no row). */
int touchValidated(String tenantId, Instant now);
/** Delete the row (used when the operator clears a license; not a public API in v1). */
int delete(String tenantId);
}

View File

@@ -0,0 +1,58 @@
package com.cameleer.server.app.license;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.boot.context.event.ApplicationReadyEvent;
import org.springframework.context.event.EventListener;
import org.springframework.scheduling.annotation.Async;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;
/**
* Daily revalidation cron + on-startup revalidation 60s after {@link ApplicationReadyEvent}.
*
* <p>The startup tick catches ABSENT-&gt;ACTIVE transitions when the license was written to
* PostgreSQL between server starts (e.g. SaaS provisioning), and gives slow downstream
* components time to come up before the first license event fires. The daily cron ensures
* expirations and clock drift are caught even in long-running deployments.</p>
*
* <p>Both invocations call {@link LicenseService#revalidate()} which is internally idempotent
* and exception-safe; this class additionally swallows any escape so a misbehaving validator
* cannot crash the scheduler thread.</p>
*/
@Component
public class LicenseRevalidationJob {
private static final Logger log = LoggerFactory.getLogger(LicenseRevalidationJob.class);
private final LicenseService svc;
public LicenseRevalidationJob(LicenseService svc) {
this.svc = svc;
}
@EventListener(ApplicationReadyEvent.class)
@Async
public void onStartup() {
try {
Thread.sleep(60_000);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
return;
}
revalidate();
}
@Scheduled(cron = "0 0 3 * * *")
public void daily() {
revalidate();
}
private void revalidate() {
try {
svc.revalidate();
} catch (Exception e) {
log.error("Revalidation crashed: {}", e.getMessage());
}
}
}

View File

@@ -0,0 +1,133 @@
package com.cameleer.server.app.license;
import com.cameleer.server.core.admin.AuditCategory;
import com.cameleer.server.core.admin.AuditResult;
import com.cameleer.server.core.admin.AuditService;
import com.cameleer.server.core.license.LicenseGate;
import com.cameleer.server.core.license.LicenseInfo;
import com.cameleer.server.core.license.LicenseValidator;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.context.ApplicationEventPublisher;
import java.time.Instant;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.Optional;
/**
* Single mediation point for license token install / replace / revalidate.
*
* <p>Audits under {@link AuditCategory#LICENSE}, persists to PostgreSQL via
* {@link LicenseRepository}, mutates the in-memory {@link LicenseGate}, and publishes a
* {@link LicenseChangedEvent} so downstream listeners (retention policy, license metrics,
* etc.) react uniformly to every state change.</p>
*/
public class LicenseService {
private static final Logger log = LoggerFactory.getLogger(LicenseService.class);
private final String tenantId;
private final LicenseRepository repo;
private final LicenseGate gate;
private final LicenseValidator validator;
private final AuditService audit;
private final ApplicationEventPublisher events;
public LicenseService(String tenantId, LicenseRepository repo, LicenseGate gate,
LicenseValidator validator, AuditService audit,
ApplicationEventPublisher events) {
this.tenantId = tenantId;
this.repo = repo;
this.gate = gate;
this.validator = validator;
this.audit = audit;
this.events = events;
}
/** Install a token from any source (env, file, api, db). */
public LicenseInfo install(String token, String installedBy, String source) {
LicenseInfo info;
try {
info = validator.validate(token);
} catch (Exception e) {
String reason = e.getMessage();
gate.markInvalid(reason);
Map<String, Object> detail = new LinkedHashMap<>();
detail.put("reason", reason);
detail.put("source", source);
audit.log(installedBy, "reject_license", AuditCategory.LICENSE,
tenantId, detail, AuditResult.FAILURE, null);
events.publishEvent(new LicenseChangedEvent(gate.getState(), gate.getCurrent()));
throw e instanceof RuntimeException re ? re : new IllegalArgumentException(e);
}
Optional<LicenseRecord> existing = repo.findByTenantId(tenantId);
Instant now = Instant.now();
repo.upsert(new LicenseRecord(
tenantId, token, info.licenseId(),
now, installedBy, info.expiresAt(), now));
gate.load(info);
Map<String, Object> detail = new LinkedHashMap<>();
detail.put("licenseId", info.licenseId().toString());
detail.put("expiresAt", info.expiresAt().toString());
detail.put("installedBy", installedBy);
detail.put("source", source);
if (existing.isPresent()) {
detail.put("previousLicenseId", existing.get().licenseId().toString());
audit.log(installedBy, "replace_license", AuditCategory.LICENSE,
info.licenseId().toString(), detail, AuditResult.SUCCESS, null);
} else {
audit.log(installedBy, "install_license", AuditCategory.LICENSE,
info.licenseId().toString(), detail, AuditResult.SUCCESS, null);
}
events.publishEvent(new LicenseChangedEvent(gate.getState(), info));
return info;
}
/** Boot-time load: prefer env/file overrides; falls back to DB; ABSENT if none. */
public void loadInitial(Optional<String> envToken, Optional<String> fileToken) {
if (envToken.isPresent()) {
try { install(envToken.get(), "system", "env"); return; }
catch (Exception e) { log.error("env-var license rejected: {}", e.getMessage()); }
}
if (fileToken.isPresent()) {
try { install(fileToken.get(), "system", "file"); return; }
catch (Exception e) { log.error("file license rejected: {}", e.getMessage()); }
}
Optional<LicenseRecord> persisted = repo.findByTenantId(tenantId);
if (persisted.isPresent()) {
try { install(persisted.get().token(), persisted.get().installedBy(), "db"); }
catch (Exception e) { log.error("DB license rejected: {}", e.getMessage()); }
} else {
log.info("No license configured - running in default tier");
events.publishEvent(new LicenseChangedEvent(gate.getState(), null));
}
}
/** Re-run validation against the persisted token (daily job). */
public void revalidate() {
Optional<LicenseRecord> persisted = repo.findByTenantId(tenantId);
if (persisted.isEmpty()) return;
try {
LicenseInfo info = validator.validate(persisted.get().token());
repo.touchValidated(tenantId, Instant.now());
gate.load(info);
events.publishEvent(new LicenseChangedEvent(gate.getState(), info));
} catch (Exception e) {
String reason = e.getMessage();
gate.markInvalid(reason);
Map<String, Object> detail = new LinkedHashMap<>();
detail.put("licenseId", persisted.get().licenseId().toString());
detail.put("reason", reason);
audit.log("system", "revalidate_license", AuditCategory.LICENSE,
persisted.get().licenseId().toString(), detail, AuditResult.FAILURE, null);
events.publishEvent(new LicenseChangedEvent(gate.getState(), null));
log.error("Revalidation failed: {}", reason);
}
}
public String getTenantId() { return tenantId; }
}

View File

@@ -0,0 +1,88 @@
package com.cameleer.server.app.license;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Component;
import java.util.LinkedHashMap;
import java.util.Map;
/**
* Read-side usage snapshot used by the /api/v1/admin/license/usage endpoint and license metrics.
*
* <p>Counts come straight from PostgreSQL row counts; compute aggregates SUM over
* non-stopped deployments and read replica/cpu/memory from the
* {@code deployed_config_snapshot.containerConfig} JSONB sub-object. Pre-RUNNING deployments
* (STARTING with no snapshot yet) contribute defaults (1 replica, 0 cpu, 0 memory) until they
* roll forward.</p>
*
* <p>{@code max_agents} is not in PG — the registry is in-memory; callers feed the live count
* into {@link #agentCount(int)} which echoes it for assembly into the snapshot map.</p>
*/
@Component
public class LicenseUsageReader {
private final JdbcTemplate jdbc;
public LicenseUsageReader(JdbcTemplate jdbc) {
this.jdbc = jdbc;
}
public Map<String, Long> snapshot() {
Map<String, Long> out = new LinkedHashMap<>();
out.put("max_environments", count("environments"));
out.put("max_apps", count("apps"));
out.put("max_users", count("users"));
out.put("max_outbound_connections", count("outbound_connections"));
out.put("max_alert_rules", count("alert_rules"));
Map<String, Long> compute = jdbc.queryForObject(
"SELECT " +
" COALESCE(SUM(replicas * cpu_millis), 0) AS cpu, " +
" COALESCE(SUM(replicas * memory_mb), 0) AS mem, " +
" COALESCE(SUM(replicas), 0) AS reps " +
"FROM ( " +
" SELECT " +
" COALESCE((d.deployed_config_snapshot->'containerConfig'->>'replicas')::int, 1) AS replicas, " +
" COALESCE((d.deployed_config_snapshot->'containerConfig'->>'cpuLimit')::int, 0) AS cpu_millis, " +
" COALESCE((d.deployed_config_snapshot->'containerConfig'->>'memoryLimitMb')::int, 0) AS memory_mb " +
" FROM deployments d " +
" WHERE d.status IN ('STARTING','RUNNING','DEGRADED','STOPPING') " +
") s",
(rs, n) -> Map.of(
"max_total_cpu_millis", rs.getLong("cpu"),
"max_total_memory_mb", rs.getLong("mem"),
"max_total_replicas", rs.getLong("reps")
));
out.putAll(compute);
return out;
}
/**
* Compute-cap usage tuple consumed by {@code DeploymentExecutor} pre-flight enforcement.
* Sums over all non-stopped deployments.
*/
public record ComputeUsage(long cpuMillis, long memoryMb, long replicas) {}
/**
* Convenience accessor over {@link #snapshot()} that returns just the three compute
* aggregates as a typed tuple. Used by {@code DeploymentExecutor.executeAsync} to feed
* {@code LicenseEnforcer.assertWithinCap} for the {@code max_total_cpu_millis} /
* {@code max_total_memory_mb} / {@code max_total_replicas} caps. Each call re-reads PG
* — there is no caching, so cap checks always see the latest committed state.
*/
public ComputeUsage computeUsage() {
Map<String, Long> snap = snapshot();
return new ComputeUsage(
snap.getOrDefault("max_total_cpu_millis", 0L),
snap.getOrDefault("max_total_memory_mb", 0L),
snap.getOrDefault("max_total_replicas", 0L));
}
/** Echoes the live agent count fed in by the controller (registry is in-memory). */
public long agentCount(int liveAgents) {
return liveAgents;
}
private long count(String table) {
return jdbc.queryForObject("SELECT COUNT(*) FROM " + table, Long.class);
}
}

View File

@@ -0,0 +1,66 @@
package com.cameleer.server.app.license;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.jdbc.core.RowMapper;
import java.sql.Timestamp;
import java.time.Instant;
import java.util.Optional;
import java.util.UUID;
public class PostgresLicenseRepository implements LicenseRepository {
private final JdbcTemplate jdbc;
public PostgresLicenseRepository(JdbcTemplate jdbc) {
this.jdbc = jdbc;
}
private static final RowMapper<LicenseRecord> MAPPER = (rs, n) -> new LicenseRecord(
rs.getString("tenant_id"),
rs.getString("token"),
(UUID) rs.getObject("license_id"),
rs.getTimestamp("installed_at").toInstant(),
rs.getString("installed_by"),
rs.getTimestamp("expires_at").toInstant(),
rs.getTimestamp("last_validated_at").toInstant()
);
@Override
public Optional<LicenseRecord> findByTenantId(String tenantId) {
return jdbc.query(
"SELECT tenant_id, token, license_id, installed_at, installed_by, expires_at, last_validated_at " +
"FROM license WHERE tenant_id = ?",
MAPPER, tenantId).stream().findFirst();
}
@Override
public void upsert(LicenseRecord r) {
jdbc.update(
"INSERT INTO license (tenant_id, token, license_id, installed_at, installed_by, expires_at, last_validated_at) " +
"VALUES (?, ?, ?, ?, ?, ?, ?) " +
"ON CONFLICT (tenant_id) DO UPDATE SET " +
" token = EXCLUDED.token, " +
" license_id = EXCLUDED.license_id, " +
" installed_at = EXCLUDED.installed_at, " +
" installed_by = EXCLUDED.installed_by, " +
" expires_at = EXCLUDED.expires_at, " +
" last_validated_at = EXCLUDED.last_validated_at",
r.tenantId(), r.token(), r.licenseId(),
Timestamp.from(r.installedAt()), r.installedBy(),
Timestamp.from(r.expiresAt()), Timestamp.from(r.lastValidatedAt())
);
}
@Override
public int touchValidated(String tenantId, Instant now) {
return jdbc.update(
"UPDATE license SET last_validated_at = ? WHERE tenant_id = ?",
Timestamp.from(now), tenantId);
}
@Override
public int delete(String tenantId) {
return jdbc.update("DELETE FROM license WHERE tenant_id = ?", tenantId);
}
}

View File

@@ -0,0 +1,119 @@
package com.cameleer.server.app.license;
import com.cameleer.server.core.license.LicenseGate;
import com.cameleer.server.core.license.LicenseLimits;
import com.cameleer.server.core.runtime.Environment;
import com.cameleer.server.core.runtime.EnvironmentRepository;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.context.event.EventListener;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.scheduling.annotation.Async;
import org.springframework.stereotype.Component;
import java.util.List;
/**
* Recomputes ClickHouse per-environment TTL on every {@link LicenseChangedEvent}.
*
* <p>Spec §4.3 — when a license is installed, replaced, or expires, the effective
* retention cap may change. For each (table, env) pair this listener emits one
* {@code ALTER TABLE … MODIFY TTL <expr> WHERE environment = '<slug>'} statement
* with {@code effective = min(licenseCap, env.configuredRetentionDays)}.</p>
*
* <p>ClickHouse 22.3+ supports per-row TTL via the {@code WHERE} predicate; the
* project's CH version (24.12) is well above that floor. ClickHouse failures are
* logged and swallowed — TTL recompute is best-effort and must not propagate
* to the originating license install/revalidate path.</p>
*
* <p>NOTE: {@code route_diagrams} has no TTL clause in {@code init.sql} — it's a
* {@code ReplacingMergeTree} keyed on content_hash, not a time-series table —
* so it is intentionally excluded here. {@code server_metrics} has no
* {@code environment} column (server-wide) so it is also excluded; its 90-day
* cap is fixed in the schema.</p>
*/
@Component
public class RetentionPolicyApplier {
private static final Logger log = LoggerFactory.getLogger(RetentionPolicyApplier.class);
/** (table, time column, license cap key, env-configured-days extractor). */
private record TableSpec(String table, String timeCol, String capKey, Extractor extractor) {}
@FunctionalInterface
private interface Extractor {
int days(Environment env);
}
/**
* Tables with a TTL clause AND an {@code environment} column in {@code init.sql}.
* Verified against the schema at task time — keep in sync if new retention-bound
* tables are added.
*/
static final List<TableSpec> SPECS = List.of(
new TableSpec("executions", "start_time", "max_execution_retention_days", Environment::executionRetentionDays),
new TableSpec("processor_executions", "start_time", "max_execution_retention_days", Environment::executionRetentionDays),
new TableSpec("logs", "timestamp", "max_log_retention_days", Environment::logRetentionDays),
new TableSpec("agent_metrics", "collected_at", "max_metric_retention_days", Environment::metricRetentionDays),
new TableSpec("agent_events", "timestamp", "max_metric_retention_days", Environment::metricRetentionDays)
);
private final LicenseGate gate;
private final EnvironmentRepository envRepo;
private final JdbcTemplate clickhouseJdbc;
public RetentionPolicyApplier(LicenseGate gate,
EnvironmentRepository envRepo,
@Qualifier("clickHouseJdbcTemplate") JdbcTemplate clickhouseJdbc) {
this.gate = gate;
this.envRepo = envRepo;
this.clickhouseJdbc = clickhouseJdbc;
}
@EventListener(LicenseChangedEvent.class)
@Async
public void onLicenseChanged(LicenseChangedEvent event) {
LicenseLimits limits;
try {
limits = gate.getEffectiveLimits();
} catch (Exception e) {
log.warn("Skipping TTL recompute — could not read effective limits: {}", e.getMessage());
return;
}
List<Environment> envs;
try {
envs = envRepo.findAll();
} catch (Exception e) {
log.warn("Skipping TTL recompute — could not load environments: {}", e.getMessage());
return;
}
log.info("License changed (state={}) — recomputing TTL across {} environment(s) and {} table(s)",
event.state(), envs.size(), SPECS.size());
for (Environment env : envs) {
for (TableSpec spec : SPECS) {
int cap = limits.get(spec.capKey);
int configured = spec.extractor.days(env);
int effective = Math.min(cap, configured);
// Slugs are regex-validated `^[a-z0-9][a-z0-9-]{0,63}$`, so the replacement
// is defense-in-depth — single quotes can never be present.
String envLiteral = env.slug().replace("'", "''");
String sql = "ALTER TABLE " + spec.table
+ " MODIFY TTL toDateTime(" + spec.timeCol
+ ") + INTERVAL " + effective + " DAY DELETE"
+ " WHERE environment = '" + envLiteral + "'";
try {
clickhouseJdbc.execute(sql);
log.info("Applied TTL: table={} env={} days={} (cap={}, configured={})",
spec.table, env.slug(), effective, cap, configured);
} catch (Exception e) {
log.warn("Failed to apply TTL for table={} env={}: {}",
spec.table, env.slug(), e.getMessage());
}
}
}
}
}

View File

@@ -1,5 +1,6 @@
package com.cameleer.server.app.outbound;
import com.cameleer.server.app.license.LicenseEnforcer;
import com.cameleer.server.core.alerting.AlertRuleRepository;
import com.cameleer.server.core.outbound.OutboundConnection;
import com.cameleer.server.core.outbound.OutboundConnectionRepository;
@@ -18,21 +19,25 @@ public class OutboundConnectionServiceImpl implements OutboundConnectionService
private final OutboundConnectionRepository repo;
private final AlertRuleRepository ruleRepo;
private final SsrfGuard ssrfGuard;
private final LicenseEnforcer licenseEnforcer;
private final String tenantId;
public OutboundConnectionServiceImpl(
OutboundConnectionRepository repo,
AlertRuleRepository ruleRepo,
SsrfGuard ssrfGuard,
LicenseEnforcer licenseEnforcer,
String tenantId) {
this.repo = repo;
this.ruleRepo = ruleRepo;
this.ssrfGuard = ssrfGuard;
this.licenseEnforcer = licenseEnforcer;
this.tenantId = tenantId;
}
@Override
public OutboundConnection create(OutboundConnection draft, String actingUserId) {
licenseEnforcer.assertWithinCap("max_outbound_connections", repo.listByTenant(tenantId).size(), 1);
assertNameUnique(draft.name(), null);
validateUrl(draft.url());
OutboundConnection c = new OutboundConnection(

View File

@@ -1,5 +1,6 @@
package com.cameleer.server.app.outbound.config;
import com.cameleer.server.app.license.LicenseEnforcer;
import com.cameleer.server.app.outbound.OutboundConnectionServiceImpl;
import com.cameleer.server.app.outbound.SsrfGuard;
import com.cameleer.server.app.outbound.crypto.SecretCipher;
@@ -33,7 +34,8 @@ public class OutboundBeanConfig {
OutboundConnectionRepository repo,
AlertRuleRepository ruleRepo,
SsrfGuard ssrfGuard,
LicenseEnforcer licenseEnforcer,
@Value("${cameleer.server.tenant.id:default}") String tenantId) {
return new OutboundConnectionServiceImpl(repo, ruleRepo, ssrfGuard, tenantId);
return new OutboundConnectionServiceImpl(repo, ruleRepo, ssrfGuard, licenseEnforcer, tenantId);
}
}

View File

@@ -1,6 +1,8 @@
package com.cameleer.server.app.runtime;
import com.cameleer.common.model.ApplicationConfig;
import com.cameleer.server.app.license.LicenseEnforcer;
import com.cameleer.server.app.license.LicenseUsageReader;
import com.cameleer.server.app.metrics.ServerMetrics;
import com.cameleer.server.app.storage.PostgresApplicationConfigRepository;
import com.cameleer.server.app.storage.PostgresDeploymentRepository;
@@ -28,6 +30,8 @@ public class DeploymentExecutor {
private final DeploymentRepository deploymentRepository;
private final PostgresDeploymentRepository pgDeployRepo;
private final PostgresApplicationConfigRepository applicationConfigRepository;
private final LicenseEnforcer licenseEnforcer;
private final LicenseUsageReader licenseUsageReader;
@Autowired(required = false)
private DockerNetworkManager networkManager;
@@ -82,7 +86,9 @@ public class DeploymentExecutor {
AppService appService,
EnvironmentService envService,
DeploymentRepository deploymentRepository,
PostgresApplicationConfigRepository applicationConfigRepository) {
PostgresApplicationConfigRepository applicationConfigRepository,
LicenseEnforcer licenseEnforcer,
LicenseUsageReader licenseUsageReader) {
this.orchestrator = orchestrator;
this.deploymentService = deploymentService;
this.appService = appService;
@@ -90,6 +96,8 @@ public class DeploymentExecutor {
this.deploymentRepository = deploymentRepository;
this.pgDeployRepo = (PostgresDeploymentRepository) deploymentRepository;
this.applicationConfigRepository = applicationConfigRepository;
this.licenseEnforcer = licenseEnforcer;
this.licenseUsageReader = licenseUsageReader;
}
/** Deployment-scoped id suffix — distinguishes container names and
@@ -147,6 +155,19 @@ public class DeploymentExecutor {
updateStage(deployment.id(), DeployStage.PRE_FLIGHT);
preFlightChecks(jarPath, config);
// === LICENSE COMPUTE CAPS ===
// Spec §4.1: sum cpu/memory/replicas across non-stopped deployments + new request
// must fit within the effective tier caps. Throws LicenseCapExceededException, which
// the surrounding try/catch turns into a FAILED deployment with the cap message
// landing in deployments.error_message.
int reqCpu = config.cpuLimit() == null ? 0 : config.cpuLimit();
int reqMem = config.memoryLimitMb();
int reqReps = config.replicas();
LicenseUsageReader.ComputeUsage usage = licenseUsageReader.computeUsage();
licenseEnforcer.assertWithinCap("max_total_cpu_millis", usage.cpuMillis(), (long) reqCpu * reqReps);
licenseEnforcer.assertWithinCap("max_total_memory_mb", usage.memoryMb(), (long) reqMem * reqReps);
licenseEnforcer.assertWithinCap("max_total_replicas", usage.replicas(), reqReps);
// Resolve runtime type
String resolvedRuntimeType = config.runtimeType();
String mainClass = null;

View File

@@ -7,6 +7,7 @@ import com.github.dockerjava.api.DockerClient;
import com.github.dockerjava.api.async.ResultCallback;
import com.github.dockerjava.api.model.AccessMode;
import com.github.dockerjava.api.model.Bind;
import com.github.dockerjava.api.model.Capability;
import com.github.dockerjava.api.model.Frame;
import com.github.dockerjava.api.model.HealthCheck;
import com.github.dockerjava.api.model.HostConfig;
@@ -25,12 +26,58 @@ import java.util.stream.Stream;
public class DockerRuntimeOrchestrator implements RuntimeOrchestrator {
private static final Logger log = LoggerFactory.getLogger(DockerRuntimeOrchestrator.class);
/** Sandboxed runtime we prefer when the daemon has it registered. */
private static final String SANDBOX_RUNTIME = "runsc";
/** Hard cap on processes/threads per tenant container. Spring Boot + Camel
* + a Kafka client comfortably fits in 512; raise via daemon-wide limits if
* a tenant legitimately needs more (and revisit the multi-tenancy threat
* model when that happens). */
private static final long PIDS_LIMIT = 512L;
/** /tmp must be writeable for JVM tmpdir, JIT scratch, and JNI native lib
* unpacking (Netty tcnative, Snappy, LZ4, Zstd all dlopen from here).
* `noexec` would block dlopen via mmap(PROT_EXEC) — keep it off. */
private static final String TMPFS_TMP_OPTS = "rw,nosuid,size=256m";
private final DockerClient dockerClient;
private final String dockerRuntime;
private ContainerLogForwarder logForwarder;
public DockerRuntimeOrchestrator(DockerClient dockerClient) {
this(dockerClient, "");
}
public DockerRuntimeOrchestrator(DockerClient dockerClient, String runtimeOverride) {
this.dockerClient = dockerClient;
this.dockerRuntime = resolveRuntime(runtimeOverride);
}
private String resolveRuntime(String override) {
if (override != null && !override.isBlank()) {
log.info("Container runtime forced to '{}' via cameleer.server.runtime.dockerruntime", override);
return override;
}
try {
Map<String, ?> runtimes = dockerClient.infoCmd().exec().getRuntimes();
if (runtimes != null && runtimes.containsKey(SANDBOX_RUNTIME)) {
log.info("gVisor ({}) detected — sandboxed runtime will be used for tenant containers",
SANDBOX_RUNTIME);
return SANDBOX_RUNTIME;
}
} catch (Exception e) {
log.warn("Could not query Docker runtimes: {} — falling back to daemon default", e.getMessage());
}
log.info("No sandboxed runtime detected — using Docker default (runc). Install gVisor on the host "
+ "for tenant kernel isolation; see issue #152.");
return "";
}
/** Visible for tests / introspection. Empty string = let Docker pick its default. */
String getDockerRuntime() {
return dockerRuntime;
}
public void setLogForwarder(ContainerLogForwarder logForwarder) {
@@ -68,12 +115,36 @@ public class DockerRuntimeOrchestrator implements RuntimeOrchestrator {
List<String> envList = request.envVars().entrySet().stream()
.map(e -> e.getKey() + "=" + e.getValue()).toList();
// Tenant containers run untrusted user JVMs — every tenant JAR can call
// Runtime.exec, reflective bean dispatch, MVEL/Groovy templating. Java 17
// has no SecurityManager, so isolation MUST live below the JVM.
// See issue #152 for the full threat model. Defaults are fail-closed:
// - cap_drop ALL: outbound TCP still works (no caps needed); raw sockets,
// ptrace, mounts, and bind <1024 are all denied.
// - no-new-privileges: setuid binaries cannot escalate.
// - apparmor=docker-default: Docker's stock MAC profile.
// Daemon's default seccomp profile is applied implicitly when no
// `seccomp=` override is set — no need to declare it.
// - readonly rootfs + /tmp tmpfs: persistence-via-write defeated; apps
// needing durable state declare writeableVolumes (issue #153).
// - pids-limit: fork bombs cannot exhaust the host PID namespace.
HostConfig hostConfig = HostConfig.newHostConfig()
.withMemory(request.memoryLimitBytes())
.withMemorySwap(request.memoryLimitBytes())
.withCpuShares(request.cpuShares())
.withNetworkMode(request.network())
.withRestartPolicy(RestartPolicy.onFailureRestart(request.restartPolicyMaxRetries()));
.withRestartPolicy(RestartPolicy.onFailureRestart(request.restartPolicyMaxRetries()))
.withCapDrop(Capability.values())
.withSecurityOpts(List.of(
"no-new-privileges:true",
"apparmor=docker-default"))
.withReadonlyRootfs(true)
.withPidsLimit(PIDS_LIMIT)
.withTmpFs(Map.of("/tmp", TMPFS_TMP_OPTS));
if (!dockerRuntime.isBlank()) {
hostConfig.withRuntime(dockerRuntime);
}
// JAR mounting: volume mount (Docker-in-Docker) or bind mount (host path)
if (request.jarVolumeName() != null && !request.jarVolumeName().isBlank()) {

View File

@@ -11,6 +11,7 @@ import com.github.dockerjava.zerodep.ZerodepDockerHttpClient;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
@@ -41,10 +42,12 @@ public class RuntimeOrchestratorAutoConfig {
@Bean
public RuntimeOrchestrator runtimeOrchestrator(
@Autowired(required = false) DockerClient dockerClient,
@Autowired(required = false) ContainerLogForwarder logForwarder) {
@Autowired(required = false) ContainerLogForwarder logForwarder,
@Value("${cameleer.server.runtime.dockerruntime:}") String dockerRuntimeOverride) {
if (dockerClient != null) {
log.info("Docker socket detected - enabling Docker runtime orchestrator");
DockerRuntimeOrchestrator orchestrator = new DockerRuntimeOrchestrator(dockerClient);
DockerRuntimeOrchestrator orchestrator =
new DockerRuntimeOrchestrator(dockerClient, dockerRuntimeOverride);
if (logForwarder != null) {
orchestrator.setLogForwarder(logForwarder);
}

View File

@@ -3,6 +3,7 @@ package com.cameleer.server.app.security;
import com.cameleer.server.app.dto.AuthTokenResponse;
import com.cameleer.server.app.dto.ErrorResponse;
import com.cameleer.server.app.dto.OidcPublicConfigResponse;
import com.cameleer.server.app.license.LicenseEnforcer;
import com.cameleer.server.core.admin.AuditCategory;
import com.cameleer.server.core.admin.AuditResult;
import com.cameleer.server.core.admin.AuditService;
@@ -63,6 +64,7 @@ public class OidcAuthController {
private final ClaimMappingService claimMappingService;
private final ClaimMappingRepository claimMappingRepository;
private final GroupRepository groupRepository;
private final LicenseEnforcer licenseEnforcer;
public OidcAuthController(OidcTokenExchanger tokenExchanger,
OidcConfigRepository configRepository,
@@ -72,7 +74,8 @@ public class OidcAuthController {
RbacService rbacService,
ClaimMappingService claimMappingService,
ClaimMappingRepository claimMappingRepository,
GroupRepository groupRepository) {
GroupRepository groupRepository,
LicenseEnforcer licenseEnforcer) {
this.tokenExchanger = tokenExchanger;
this.configRepository = configRepository;
this.jwtService = jwtService;
@@ -82,6 +85,7 @@ public class OidcAuthController {
this.claimMappingService = claimMappingService;
this.claimMappingRepository = claimMappingRepository;
this.groupRepository = groupRepository;
this.licenseEnforcer = licenseEnforcer;
}
/**
@@ -154,6 +158,13 @@ public class OidcAuthController {
"Account not provisioned. Contact your administrator.");
}
// Auto-signup branch: when the user does not yet exist and the IdP is allowed to
// provision new accounts, enforce the max_users license cap before persisting.
// The global LicenseExceptionAdvice maps this to a structured 403 envelope.
if (existingUser.isEmpty() && config.get().autoSignup()) {
licenseEnforcer.assertWithinCap("max_users", userRepository.count(), 1);
}
userRepository.upsert(new UserInfo(
userId, provider, oidcUser.email(), oidcUser.name(), Instant.now()));

View File

@@ -70,6 +70,12 @@ public class PostgresAppRepository implements AppRepository {
(rs, rowNum) -> mapRow(rs));
}
@Override
public long count() {
Long n = jdbc.queryForObject("SELECT COUNT(*) FROM apps", Long.class);
return n == null ? 0L : n;
}
@Override
public void updateContainerConfig(UUID id, Map<String, Object> containerConfig) {
try {

View File

@@ -26,7 +26,8 @@ public class PostgresEnvironmentRepository implements EnvironmentRepository {
}
private static final String SELECT_COLS =
"id, slug, display_name, production, enabled, default_container_config, jar_retention_count, color, created_at";
"id, slug, display_name, production, enabled, default_container_config, jar_retention_count, color, created_at, "
+ "execution_retention_days, log_retention_days, metric_retention_days";
@Override
public List<Environment> findAll() {
@@ -35,6 +36,11 @@ public class PostgresEnvironmentRepository implements EnvironmentRepository {
(rs, rowNum) -> mapRow(rs));
}
@Override
public long count() {
return jdbc.queryForObject("SELECT COUNT(*) FROM environments", Long.class);
}
@Override
public Optional<Environment> findById(UUID id) {
var results = jdbc.query(
@@ -108,7 +114,10 @@ public class PostgresEnvironmentRepository implements EnvironmentRepository {
config,
jarRetentionCount,
color,
rs.getTimestamp("created_at").toInstant()
rs.getTimestamp("created_at").toInstant(),
rs.getInt("execution_retention_days"),
rs.getInt("log_retention_days"),
rs.getInt("metric_retention_days")
);
}
}

View File

@@ -101,6 +101,12 @@ public class PostgresUserRepository implements UserRepository {
java.sql.Timestamp.from(timestamp), userId);
}
@Override
public long count() {
Long n = jdbc.queryForObject("SELECT COUNT(*) FROM users", Long.class);
return n == null ? 0L : n;
}
private UserInfo mapUser(java.sql.ResultSet rs) throws java.sql.SQLException {
java.sql.Timestamp ts = rs.getTimestamp("created_at");
java.time.Instant createdAt = ts != null ? ts.toInstant() : null;

View File

@@ -47,6 +47,11 @@ cameleer:
jarstoragepath: ${CAMELEER_SERVER_RUNTIME_JARSTORAGEPATH:/data/jars}
baseimage: ${CAMELEER_SERVER_RUNTIME_BASEIMAGE:gitea.siegeln.net/cameleer/cameleer-runtime-base:latest}
dockernetwork: ${CAMELEER_SERVER_RUNTIME_DOCKERNETWORK:cameleer}
# Container runtime override. Empty (default) auto-detects: uses runsc
# (gVisor) if the daemon has it registered, otherwise the daemon default
# (runc). Set to a registered runtime name (e.g. "kata", "runc") to
# force a specific runtime. See issue #152 for the threat model.
dockerruntime: ${CAMELEER_SERVER_RUNTIME_DOCKERRUNTIME:}
agenthealthport: 9464
healthchecktimeout: 60
container:

View File

@@ -0,0 +1,17 @@
-- Per-tenant license row (one server = one tenant)
CREATE TABLE license (
tenant_id TEXT PRIMARY KEY,
token TEXT NOT NULL,
license_id UUID NOT NULL,
installed_at TIMESTAMPTZ NOT NULL,
installed_by TEXT NOT NULL,
expires_at TIMESTAMPTZ NOT NULL,
last_validated_at TIMESTAMPTZ NOT NULL
);
-- Per-env retention; defaults to default-tier values (1 day) so a fresh
-- server lands inside the cap without operator intervention.
ALTER TABLE environments
ADD COLUMN execution_retention_days INTEGER NOT NULL DEFAULT 1,
ADD COLUMN log_retention_days INTEGER NOT NULL DEFAULT 1,
ADD COLUMN metric_retention_days INTEGER NOT NULL DEFAULT 1;

View File

@@ -1,13 +1,19 @@
package com.cameleer.server.app;
import com.cameleer.server.core.agent.AgentRegistryService;
import com.cameleer.server.core.license.LicenseGate;
import com.cameleer.server.core.license.LicenseInfo;
import com.cameleer.server.core.security.JwtService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType;
import org.springframework.stereotype.Component;
import java.time.Instant;
import java.time.temporal.ChronoUnit;
import java.util.List;
import java.util.Map;
import java.util.UUID;
/**
* Test utility for creating JWT-authenticated requests in integration tests.
@@ -20,10 +26,39 @@ public class TestSecurityHelper {
private final JwtService jwtService;
private final AgentRegistryService agentRegistryService;
private final LicenseGate licenseGate;
public TestSecurityHelper(JwtService jwtService, AgentRegistryService agentRegistryService) {
@Autowired
public TestSecurityHelper(JwtService jwtService,
AgentRegistryService agentRegistryService,
LicenseGate licenseGate) {
this.jwtService = jwtService;
this.agentRegistryService = agentRegistryService;
this.licenseGate = licenseGate;
}
/**
* Loads a synthetic, signature-bypassing license into {@link LicenseGate} so the test can
* exercise paths that would otherwise be rejected by default-tier caps. The license is
* always-ACTIVE (1 day from now, no grace) and limits are merged over defaults — only
* supply the keys you want to lift. Use this from {@code @BeforeEach} in ITs that need to
* create more than the default-tier allowance of envs/apps/users/etc.
*/
public void installSyntheticUnsignedLicense(Map<String, Integer> caps) {
LicenseInfo info = new LicenseInfo(
UUID.randomUUID(),
"default",
"test-license",
Map.copyOf(caps),
Instant.now(),
Instant.now().plus(1, ChronoUnit.DAYS),
0);
licenseGate.load(info);
}
/** Clears any test license previously installed via {@link #installSyntheticUnsignedLicense}. */
public void clearTestLicense() {
licenseGate.clear();
}
/**

View File

@@ -105,6 +105,11 @@ class AlertingFullLifecycleIT extends AbstractPostgresIT {
.dynamicHttpsPort());
wm.start();
// Lift the default-tier max_alert_rules cap (=2). This lifecycle test creates
// multiple rules via REST + repo across @Test methods (PER_CLASS lifecycle) and
// is not exercising the license cap. Synthetic license is ACTIVE-state.
securityHelper.installSyntheticUnsignedLicense(java.util.Map.of("max_alert_rules", 100));
// Default clock behaviour: delegate to simulatedNow
stubClock();
@@ -145,6 +150,7 @@ class AlertingFullLifecycleIT extends AbstractPostgresIT {
@AfterAll
void cleanupFixtures() {
securityHelper.clearTestLicense();
if (wm != null) wm.stop();
jdbcTemplate.update("DELETE FROM alert_silences WHERE environment_id = ?", envId);
jdbcTemplate.update("DELETE FROM alert_notifications WHERE alert_instance_id IN (SELECT id FROM alert_instances WHERE environment_id = ?)", envId);

View File

@@ -56,6 +56,13 @@ class OutboundConnectionAllowedEnvIT extends AbstractPostgresIT {
void setUp() throws Exception {
when(agentRegistryService.findAll()).thenReturn(List.of());
// Lift caps so this connection-allowed-env test, which creates one alert rule per
// method, is never gated by the default-tier max_alert_rules=2 + sibling residue.
// Also lift max_outbound_connections (default=1) — every test creates one connection.
securityHelper.installSyntheticUnsignedLicense(java.util.Map.of(
"max_alert_rules", 100,
"max_outbound_connections", 100));
adminJwt = securityHelper.adminToken();
operatorJwt = securityHelper.operatorToken();
@@ -93,6 +100,7 @@ class OutboundConnectionAllowedEnvIT extends AbstractPostgresIT {
@AfterEach
void cleanUp() {
securityHelper.clearTestLicense();
jdbcTemplate.update("DELETE FROM alert_rules WHERE environment_id IN (?, ?, ?)", envIdA, envIdB, envIdC);
jdbcTemplate.update("DELETE FROM outbound_connections WHERE id = ?", connId);
jdbcTemplate.update("DELETE FROM environments WHERE id IN (?, ?, ?)", envIdA, envIdB, envIdC);

View File

@@ -44,6 +44,11 @@ class AlertRuleControllerIT extends AbstractPostgresIT {
seedUser("test-operator");
seedUser("test-viewer");
// Lift the default-tier max_alert_rules cap (=2) so this suite — which exercises rule
// creation independent of the cap — is not gated by sibling-test residue in the
// shared Spring context's Postgres tables. The synthetic license is ACTIVE-state.
securityHelper.installSyntheticUnsignedLicense(java.util.Map.of("max_alert_rules", 100));
// Create a test environment
envSlug = "test-env-" + UUID.randomUUID().toString().substring(0, 8);
envId = UUID.randomUUID();
@@ -54,6 +59,7 @@ class AlertRuleControllerIT extends AbstractPostgresIT {
@AfterEach
void cleanUp() {
securityHelper.clearTestLicense();
jdbcTemplate.update("DELETE FROM alert_rules WHERE environment_id = ?", envId);
jdbcTemplate.update("DELETE FROM environments WHERE id = ?", envId);
jdbcTemplate.update("DELETE FROM users WHERE user_id IN ('test-operator','test-viewer')");

View File

@@ -37,7 +37,7 @@ class AgentLifecycleEvaluatorTest {
events = mock(AgentEventRepository.class);
envRepo = mock(EnvironmentRepository.class);
when(envRepo.findById(ENV_ID)).thenReturn(Optional.of(
new Environment(ENV_ID, ENV_SLUG, "Prod", true, true, Map.of(), 5, "slate", Instant.EPOCH)));
new Environment(ENV_ID, ENV_SLUG, "Prod", true, true, Map.of(), 5, "slate", Instant.EPOCH, 1, 1, 1)));
eval = new AgentLifecycleEvaluator(events, envRepo);
}

View File

@@ -41,7 +41,7 @@ class ExchangeMatchEvaluatorTest {
null, null, null, null, null, null, null, null, null, null, null, null, null, null);
eval = new ExchangeMatchEvaluator(searchIndex, envRepo, props);
var env = new Environment(ENV_ID, "prod", "Production", false, true, null, null, "slate", null);
var env = new Environment(ENV_ID, "prod", "Production", false, true, null, null, "slate", null, 1, 1, 1);
when(envRepo.findById(ENV_ID)).thenReturn(Optional.of(env));
}

View File

@@ -35,7 +35,7 @@ class LogPatternEvaluatorTest {
envRepo = mock(EnvironmentRepository.class);
eval = new LogPatternEvaluator(logStore, envRepo);
var env = new Environment(ENV_ID, "prod", "Production", false, true, null, null, "slate", null);
var env = new Environment(ENV_ID, "prod", "Production", false, true, null, null, "slate", null, 1, 1, 1);
when(envRepo.findById(ENV_ID)).thenReturn(Optional.of(env));
}

View File

@@ -36,7 +36,7 @@ class RouteMetricEvaluatorTest {
envRepo = mock(EnvironmentRepository.class);
eval = new RouteMetricEvaluator(statsStore, envRepo);
var env = new Environment(ENV_ID, "prod", "Production", false, true, null, null, "slate", null);
var env = new Environment(ENV_ID, "prod", "Production", false, true, null, null, "slate", null, 1, 1, 1);
when(envRepo.findById(ENV_ID)).thenReturn(Optional.of(env));
}

View File

@@ -28,7 +28,7 @@ class NotificationContextBuilderTest {
// ---- helpers ----
private Environment env() {
return new Environment(ENV_ID, "prod", "Production", true, true, Map.of(), 5, "slate", Instant.EPOCH);
return new Environment(ENV_ID, "prod", "Production", true, true, Map.of(), 5, "slate", Instant.EPOCH, 1, 1, 1);
}
private AlertRule rule(ConditionKind kind) {

View File

@@ -115,6 +115,91 @@ class SchemaBootstrapIT extends AbstractPostgresIT {
assertThat(isUnique).isTrue();
}
@Test
void licenseTableExists() {
// V5 migration: per-tenant license row, PK on tenant_id (one server = one tenant).
var rows = jdbcTemplate.queryForList("""
SELECT column_name, data_type, is_nullable
FROM information_schema.columns
WHERE table_name = 'license'
AND table_schema = current_schema()
""");
var byName = new java.util.HashMap<String, java.util.Map<String, Object>>();
for (var row : rows) {
byName.put((String) row.get("column_name"), row);
}
assertThat(byName).containsKeys(
"tenant_id", "license_id", "token", "installed_at",
"installed_by", "expires_at", "last_validated_at");
assertThat(byName.get("tenant_id").get("data_type")).isEqualTo("text");
assertThat(byName.get("tenant_id").get("is_nullable")).isEqualTo("NO");
assertThat(byName.get("license_id").get("data_type")).isEqualTo("uuid");
assertThat(byName.get("license_id").get("is_nullable")).isEqualTo("NO");
assertThat(byName.get("token").get("data_type")).isEqualTo("text");
assertThat(byName.get("token").get("is_nullable")).isEqualTo("NO");
assertThat(byName.get("installed_at").get("data_type"))
.isEqualTo("timestamp with time zone");
assertThat(byName.get("installed_at").get("is_nullable")).isEqualTo("NO");
assertThat(byName.get("installed_by").get("data_type")).isEqualTo("text");
assertThat(byName.get("installed_by").get("is_nullable")).isEqualTo("NO");
assertThat(byName.get("expires_at").get("data_type"))
.isEqualTo("timestamp with time zone");
assertThat(byName.get("expires_at").get("is_nullable")).isEqualTo("NO");
assertThat(byName.get("last_validated_at").get("data_type"))
.isEqualTo("timestamp with time zone");
assertThat(byName.get("last_validated_at").get("is_nullable")).isEqualTo("NO");
// PK: tenant_id (one row per tenant).
var pkCols = jdbcTemplate.queryForList("""
SELECT a.attname AS column_name
FROM pg_index i
JOIN pg_class c ON c.oid = i.indrelid
JOIN pg_namespace n ON n.oid = c.relnamespace
JOIN pg_attribute a ON a.attrelid = c.oid AND a.attnum = ANY(i.indkey)
WHERE c.relname = 'license'
AND n.nspname = current_schema()
AND i.indisprimary
""", String.class);
assertThat(pkCols).containsExactly("tenant_id");
}
@Test
void environmentsHasRetentionColumns() {
// V5 migration adds three retention day columns, NOT NULL DEFAULT 1.
var rows = jdbcTemplate.queryForList("""
SELECT column_name, data_type, is_nullable, column_default
FROM information_schema.columns
WHERE table_name = 'environments'
AND table_schema = current_schema()
AND column_name IN
('execution_retention_days','log_retention_days','metric_retention_days')
""");
var byName = new java.util.HashMap<String, java.util.Map<String, Object>>();
for (var row : rows) {
byName.put((String) row.get("column_name"), row);
}
assertThat(byName).containsKeys(
"execution_retention_days", "log_retention_days", "metric_retention_days");
for (var col : java.util.List.of(
"execution_retention_days", "log_retention_days", "metric_retention_days")) {
assertThat(byName.get(col).get("data_type"))
.as("%s data_type", col).isEqualTo("integer");
assertThat(byName.get(col).get("is_nullable"))
.as("%s is_nullable", col).isEqualTo("NO");
assertThat((String) byName.get(col).get("column_default"))
.as("%s column_default", col).isEqualTo("1");
}
}
@Test
void deleting_environment_cascades_alerting_rows() {
testEnvId = UUID.randomUUID();

View File

@@ -4,6 +4,7 @@ import com.cameleer.server.app.AbstractPostgresIT;
import com.cameleer.server.app.TestSecurityHelper;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
@@ -13,6 +14,7 @@ import org.springframework.http.HttpMethod;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import java.util.Map;
import java.util.UUID;
import static org.assertj.core.api.Assertions.assertThat;
@@ -33,10 +35,18 @@ class AgentCommandControllerIT extends AbstractPostgresIT {
@BeforeEach
void setUp() {
// Lift max_agents cap so this IT (which registers many agents per test) isn't gated
// by license enforcement. Cap behaviour itself is exercised by AgentCapEnforcementIT.
securityHelper.installSyntheticUnsignedLicense(Map.of("max_agents", 100));
agentJwt = securityHelper.registerTestAgent("test-agent-command-it");
operatorJwt = securityHelper.operatorToken();
}
@AfterEach
void tearDown() {
securityHelper.clearTestLicense();
}
private ResponseEntity<String> registerAgent(String agentId, String name, String application) {
String json = """
{

View File

@@ -4,6 +4,7 @@ import com.cameleer.server.app.AbstractPostgresIT;
import com.cameleer.server.app.TestSecurityHelper;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
@@ -13,6 +14,8 @@ import org.springframework.http.HttpMethod;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import java.util.Map;
import static org.assertj.core.api.Assertions.assertThat;
class AgentRegistrationControllerIT extends AbstractPostgresIT {
@@ -31,10 +34,18 @@ class AgentRegistrationControllerIT extends AbstractPostgresIT {
@BeforeEach
void setUp() {
// Lift max_agents cap so this IT (which registers many agents per test) isn't gated
// by license enforcement. Cap behaviour itself is exercised by AgentCapEnforcementIT.
securityHelper.installSyntheticUnsignedLicense(Map.of("max_agents", 100));
jwt = securityHelper.registerTestAgent("test-agent-registration-it");
viewerJwt = securityHelper.viewerToken();
}
@AfterEach
void tearDown() {
securityHelper.clearTestLicense();
}
private ResponseEntity<String> registerAgent(String agentId, String name) {
String json = """
{

View File

@@ -3,6 +3,7 @@ package com.cameleer.server.app.controller;
import com.cameleer.server.app.AbstractPostgresIT;
import com.cameleer.server.app.TestSecurityHelper;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
@@ -20,6 +21,7 @@ import java.net.http.HttpResponse;
import java.time.Duration;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.UUID;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.CountDownLatch;
@@ -48,10 +50,18 @@ class AgentSseControllerIT extends AbstractPostgresIT {
@BeforeEach
void setUp() {
// Lift max_agents cap so this IT (which registers many agents per test) isn't gated
// by license enforcement. Cap behaviour itself is exercised by AgentCapEnforcementIT.
securityHelper.installSyntheticUnsignedLicense(Map.of("max_agents", 100));
jwt = securityHelper.registerTestAgent("test-agent-sse-it");
operatorJwt = securityHelper.operatorToken();
}
@AfterEach
void tearDown() {
securityHelper.clearTestLicense();
}
private ResponseEntity<String> registerAgent(String agentId, String name, String application) {
String json = """
{

View File

@@ -3,6 +3,7 @@ package com.cameleer.server.app.controller;
import com.cameleer.server.app.AbstractPostgresIT;
import com.cameleer.server.app.TestSecurityHelper;
import com.cameleer.server.core.ingestion.IngestionService;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
@@ -13,6 +14,8 @@ import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.test.context.TestPropertySource;
import java.util.Map;
import static org.assertj.core.api.Assertions.assertThat;
/**
@@ -45,10 +48,18 @@ class BackpressureIT extends AbstractPostgresIT {
@BeforeEach
void setUp() {
// Lift max_agents cap so this IT (which registers an agent) isn't gated by license
// enforcement. Cap behaviour itself is exercised by AgentCapEnforcementIT.
securityHelper.installSyntheticUnsignedLicense(Map.of("max_agents", 100));
String jwt = securityHelper.registerTestAgent("test-agent-backpressure-it");
authHeaders = securityHelper.authHeaders(jwt);
}
@AfterEach
void tearDown() {
securityHelper.clearTestLicense();
}
@Test
void whenMetricsBufferFull_returns503WithRetryAfter() {
// Fill the metrics buffer completely with a batch of 5

View File

@@ -46,6 +46,16 @@ class DeploymentControllerAuditIT extends AbstractPostgresIT {
aliceJwt = securityHelper.createToken("user:alice", "user", List.of("OPERATOR"));
adminJwt = securityHelper.adminToken();
// Lift default-tier caps so the promote-target env + apps can be created via the API,
// and lift compute caps so the async DeploymentExecutor PRE_FLIGHT cap check (T24)
// doesn't fail the deployment before audit assertions complete on long-running runs.
securityHelper.installSyntheticUnsignedLicense(Map.of(
"max_environments", 100,
"max_apps", 100,
"max_total_cpu_millis", 100_000,
"max_total_memory_mb", 100_000,
"max_total_replicas", 100));
// Clean up deployment-related tables and test-created environments
jdbcTemplate.update("DELETE FROM deployments");
jdbcTemplate.update("DELETE FROM app_versions");
@@ -90,6 +100,11 @@ class DeploymentControllerAuditIT extends AbstractPostgresIT {
versionId = objectMapper.readTree(versionResponse.getBody()).path("id").asText();
}
@org.junit.jupiter.api.AfterEach
void tearDown() {
securityHelper.clearTestLicense();
}
@Test
void deploy_writes_audit_row_with_DEPLOYMENT_category_and_alice_actor() throws Exception {
String json = String.format("""

View File

@@ -4,6 +4,7 @@ import com.cameleer.server.app.AbstractPostgresIT;
import com.cameleer.server.app.TestSecurityHelper;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.AfterAll;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.TestInstance;
@@ -15,6 +16,8 @@ import org.springframework.http.HttpMethod;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import java.util.Map;
import static java.util.concurrent.TimeUnit.SECONDS;
import static org.assertj.core.api.Assertions.assertThat;
import static org.awaitility.Awaitility.await;
@@ -49,6 +52,9 @@ class DetailControllerIT extends AbstractPostgresIT {
*/
@BeforeAll
void seedTestData() {
// Lift max_agents cap so this IT (which registers an agent) isn't gated by license
// enforcement. Cap behaviour itself is exercised by AgentCapEnforcementIT.
securityHelper.installSyntheticUnsignedLicense(Map.of("max_agents", 100));
jwt = securityHelper.registerTestAgent("test-agent-detail-it");
viewerJwt = securityHelper.viewerToken();
@@ -231,4 +237,9 @@ class DetailControllerIT extends AbstractPostgresIT {
new HttpEntity<>(headers),
String.class);
}
@AfterAll
void tearDown() {
securityHelper.clearTestLicense();
}
}

View File

@@ -2,6 +2,7 @@ package com.cameleer.server.app.controller;
import com.cameleer.server.app.AbstractPostgresIT;
import com.cameleer.server.app.TestSecurityHelper;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
@@ -12,6 +13,8 @@ import org.springframework.http.HttpMethod;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import java.util.Map;
import static java.util.concurrent.TimeUnit.SECONDS;
import static org.assertj.core.api.Assertions.assertThat;
import static org.awaitility.Awaitility.await;
@@ -29,11 +32,19 @@ class DiagramControllerIT extends AbstractPostgresIT {
@BeforeEach
void setUp() {
// Lift max_agents cap so this IT (which registers an agent) isn't gated by license
// enforcement. Cap behaviour itself is exercised by AgentCapEnforcementIT.
securityHelper.installSyntheticUnsignedLicense(Map.of("max_agents", 100));
String jwt = securityHelper.registerTestAgent("test-agent-diagram-it");
authHeaders = securityHelper.authHeaders(jwt);
viewerHeaders = securityHelper.authHeadersNoBody(securityHelper.viewerToken());
}
@AfterEach
void tearDown() {
securityHelper.clearTestLicense();
}
@Test
void postSingleDiagram_returns202() {
String json = """

View File

@@ -4,6 +4,7 @@ import com.cameleer.server.app.AbstractPostgresIT;
import com.cameleer.server.app.TestSecurityHelper;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
@@ -14,6 +15,8 @@ import org.springframework.http.HttpMethod;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import java.util.Map;
import static java.util.concurrent.TimeUnit.SECONDS;
import static org.assertj.core.api.Assertions.assertThat;
import static org.awaitility.Awaitility.await;
@@ -41,6 +44,9 @@ class DiagramRenderControllerIT extends AbstractPostgresIT {
@BeforeEach
void seedDiagram() {
// Lift max_agents cap so this IT (which registers an agent) isn't gated by license
// enforcement. Cap behaviour itself is exercised by AgentCapEnforcementIT.
securityHelper.installSyntheticUnsignedLicense(Map.of("max_agents", 100));
jwt = securityHelper.registerTestAgent("test-agent-diagram-render-it");
viewerJwt = securityHelper.viewerToken();
@@ -115,6 +121,11 @@ class DiagramRenderControllerIT extends AbstractPostgresIT {
});
}
@AfterEach
void tearDown() {
securityHelper.clearTestLicense();
}
@Test
void getSvg_withAcceptHeader_returnsSvg() {
HttpHeaders headers = securityHelper.authHeadersNoBody(viewerJwt);

View File

@@ -35,8 +35,21 @@ class EnvironmentAdminControllerIT extends AbstractPostgresIT {
adminJwt = securityHelper.adminToken();
viewerJwt = securityHelper.viewerToken();
operatorJwt = securityHelper.operatorToken();
// Clean up test environments (keep default)
// Clean up test environments (keep default). Strip dependents first — sibling ITs
// (e.g., DeploymentControllerAuditIT) may have left deployments/apps that FK back to
// their non-default envs when the testcontainer is reused across runs.
jdbcTemplate.update("DELETE FROM deployments");
jdbcTemplate.update("DELETE FROM app_versions");
jdbcTemplate.update("DELETE FROM apps");
jdbcTemplate.update("DELETE FROM environments WHERE slug != 'default'");
// Lift max_environments cap so existing IT scenarios that POST envs through the
// controller succeed; the cap itself is exercised by EnvironmentCapEnforcementIT.
securityHelper.installSyntheticUnsignedLicense(java.util.Map.of("max_environments", 100));
}
@org.junit.jupiter.api.AfterEach
void tearDown() {
securityHelper.clearTestLicense();
}
@Test
@@ -92,6 +105,25 @@ class EnvironmentAdminControllerIT extends AbstractPostgresIT {
assertThat(body.has("id")).isTrue();
}
@Test
void createEnvironment_surfacesRetentionDefaults() throws Exception {
// V5 columns default to 1 (matching the default-tier license cap). T26 surfaces
// them as int fields on the Environment record; the read DTO must expose them.
String json = """
{"slug": "retention-defaults", "displayName": "Retention", "production": false}
""";
ResponseEntity<String> response = restTemplate.exchange(
"/api/v1/admin/environments", HttpMethod.POST,
new HttpEntity<>(json, securityHelper.authHeaders(adminJwt)),
String.class);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.CREATED);
JsonNode body = objectMapper.readTree(response.getBody());
assertThat(body.path("executionRetentionDays").asInt()).isEqualTo(1);
assertThat(body.path("logRetentionDays").asInt()).isEqualTo(1);
assertThat(body.path("metricRetentionDays").asInt()).isEqualTo(1);
}
@Test
void updateEnvironment_withValidColor_persists() throws Exception {
restTemplate.exchange(

View File

@@ -4,6 +4,7 @@ import com.cameleer.server.app.AbstractPostgresIT;
import com.cameleer.server.app.TestSecurityHelper;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
@@ -14,6 +15,8 @@ import org.springframework.http.HttpMethod;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import java.util.Map;
import static java.util.concurrent.TimeUnit.SECONDS;
import static org.assertj.core.api.Assertions.assertThat;
import static org.awaitility.Awaitility.await;
@@ -38,11 +41,19 @@ class ExecutionControllerIT extends AbstractPostgresIT {
@BeforeEach
void setUp() {
// Lift max_agents cap so this IT (which registers an agent) isn't gated by license
// enforcement. Cap behaviour itself is exercised by AgentCapEnforcementIT.
securityHelper.installSyntheticUnsignedLicense(Map.of("max_agents", 100));
String jwt = securityHelper.registerTestAgent("test-agent-execution-it");
authHeaders = securityHelper.authHeaders(jwt);
viewerHeaders = securityHelper.authHeadersNoBody(securityHelper.viewerToken());
}
@AfterEach
void tearDown() {
securityHelper.clearTestLicense();
}
@Test
void postSingleExecution_returns202() {
String json = """

View File

@@ -2,6 +2,7 @@ package com.cameleer.server.app.controller;
import com.cameleer.server.app.AbstractPostgresIT;
import com.cameleer.server.app.TestSecurityHelper;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
@@ -10,6 +11,8 @@ import org.springframework.http.HttpEntity;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpMethod;
import java.util.Map;
import static org.assertj.core.api.Assertions.assertThat;
/**
@@ -28,9 +31,17 @@ class ForwardCompatIT extends AbstractPostgresIT {
@BeforeEach
void setUp() {
// Lift max_agents cap so this IT (which registers an agent) isn't gated by license
// enforcement. Cap behaviour itself is exercised by AgentCapEnforcementIT.
securityHelper.installSyntheticUnsignedLicense(Map.of("max_agents", 100));
jwt = securityHelper.registerTestAgent("test-agent-forward-compat-it");
}
@AfterEach
void tearDown() {
securityHelper.clearTestLicense();
}
@Test
void unknownFieldsInRequestBodyDoNotCauseError() {
// Valid ExecutionChunk plus extra fields a future agent version

View File

@@ -0,0 +1,130 @@
package com.cameleer.server.app.controller;
import com.cameleer.server.app.AbstractPostgresIT;
import com.cameleer.server.app.TestSecurityHelper;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.web.client.TestRestTemplate;
import org.springframework.http.HttpEntity;
import org.springframework.http.HttpMethod;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import java.util.Map;
import static org.assertj.core.api.Assertions.assertThat;
/**
* IT for {@code GET /api/v1/admin/license/usage}.
*
* <p>Installs a synthetic license with a couple of cap overrides (so the {@code source}
* column is exercised in both branches), then verifies the response shape.</p>
*/
class LicenseUsageControllerIT extends AbstractPostgresIT {
@Autowired
private TestRestTemplate restTemplate;
@Autowired
private ObjectMapper objectMapper;
@Autowired
private TestSecurityHelper securityHelper;
private String adminJwt;
@BeforeEach
void setUp() {
adminJwt = securityHelper.adminToken();
// Defensive: a sibling IT may have left a license installed.
securityHelper.clearTestLicense();
}
@AfterEach
void tearDown() {
securityHelper.clearTestLicense();
}
@Test
void getUsage_withSyntheticLicense_returnsStateAndLimits() throws Exception {
// Override two keys; the rest stay default.
securityHelper.installSyntheticUnsignedLicense(Map.of(
"max_apps", 50,
"max_users", 100));
ResponseEntity<String> response = restTemplate.exchange(
"/api/v1/admin/license/usage", HttpMethod.GET,
new HttpEntity<>(securityHelper.authHeadersNoBody(adminJwt)),
String.class);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
JsonNode body = objectMapper.readTree(response.getBody());
assertThat(body.path("state").asText()).isEqualTo("ACTIVE");
assertThat(body.path("tenantId").asText()).isEqualTo("default");
assertThat(body.path("label").asText()).isEqualTo("test-license");
assertThat(body.path("message").asText()).isNotBlank();
JsonNode limits = body.path("limits");
assertThat(limits.isArray()).isTrue();
assertThat(limits.size()).isGreaterThan(0);
boolean sawLicenseSource = false;
boolean sawDefaultSource = false;
boolean sawAppsRow = false;
boolean sawUsersRow = false;
for (JsonNode row : limits) {
assertThat(row.has("key")).isTrue();
assertThat(row.has("current")).isTrue();
assertThat(row.has("cap")).isTrue();
assertThat(row.has("source")).isTrue();
String src = row.path("source").asText();
if ("license".equals(src)) sawLicenseSource = true;
if ("default".equals(src)) sawDefaultSource = true;
if ("max_apps".equals(row.path("key").asText())) {
sawAppsRow = true;
assertThat(row.path("source").asText()).isEqualTo("license");
assertThat(row.path("cap").asInt()).isEqualTo(50);
}
if ("max_users".equals(row.path("key").asText())) {
sawUsersRow = true;
assertThat(row.path("source").asText()).isEqualTo("license");
assertThat(row.path("cap").asInt()).isEqualTo(100);
}
}
assertThat(sawLicenseSource).as("at least one license-sourced row").isTrue();
assertThat(sawDefaultSource).as("at least one default-sourced row").isTrue();
assertThat(sawAppsRow).as("max_apps row present").isTrue();
assertThat(sawUsersRow).as("max_users row present").isTrue();
}
@Test
void getUsage_absent_returnsAbsentStateAndDefaultSources() throws Exception {
// No license installed (cleared in @BeforeEach).
ResponseEntity<String> response = restTemplate.exchange(
"/api/v1/admin/license/usage", HttpMethod.GET,
new HttpEntity<>(securityHelper.authHeadersNoBody(adminJwt)),
String.class);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
JsonNode body = objectMapper.readTree(response.getBody());
assertThat(body.path("state").asText()).isEqualTo("ABSENT");
assertThat(body.path("tenantId").isNull()).isTrue();
assertThat(body.path("expiresAt").isNull()).isTrue();
assertThat(body.path("message").asText()).isNotBlank();
JsonNode limits = body.path("limits");
assertThat(limits.isArray()).isTrue();
assertThat(limits.size()).isGreaterThan(0);
for (JsonNode row : limits) {
assertThat(row.path("source").asText()).isEqualTo("default");
}
}
}

View File

@@ -4,6 +4,7 @@ import com.cameleer.server.app.AbstractPostgresIT;
import com.cameleer.server.app.TestSecurityHelper;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
@@ -14,6 +15,8 @@ import org.springframework.http.HttpMethod;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import java.util.Map;
import static java.util.concurrent.TimeUnit.SECONDS;
import static org.assertj.core.api.Assertions.assertThat;
import static org.awaitility.Awaitility.await;
@@ -34,12 +37,20 @@ class MetricsControllerIT extends AbstractPostgresIT {
@BeforeEach
void setUp() {
// Lift max_agents cap so this IT (which registers an agent) isn't gated by license
// enforcement. Cap behaviour itself is exercised by AgentCapEnforcementIT.
securityHelper.installSyntheticUnsignedLicense(Map.of("max_agents", 100));
agentId = "test-agent-metrics-it";
String jwt = securityHelper.registerTestAgent(agentId);
authHeaders = securityHelper.authHeaders(jwt);
viewerHeaders = securityHelper.authHeadersNoBody(securityHelper.viewerToken());
}
@AfterEach
void tearDown() {
securityHelper.clearTestLicense();
}
@Test
void postMetrics_returns202() {
String json = """

View File

@@ -14,6 +14,8 @@ import org.springframework.http.HttpMethod;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import java.util.Map;
import static java.util.concurrent.TimeUnit.SECONDS;
import static org.assertj.core.api.Assertions.assertThat;
import static org.awaitility.Awaitility.await;
@@ -42,6 +44,10 @@ class SearchControllerIT extends AbstractPostgresIT {
*/
@BeforeEach
void seedTestData() {
// Lift max_agents cap unconditionally so this IT (which registers an agent on first
// seed) isn't gated by license enforcement on this run or any sibling that follows.
// Cap behaviour itself is exercised by AgentCapEnforcementIT.
securityHelper.installSyntheticUnsignedLicense(Map.of("max_agents", 100));
if (seeded) return;
seeded = true;
jwt = securityHelper.registerTestAgent("test-agent-search-it");

View File

@@ -2,6 +2,7 @@ package com.cameleer.server.app.interceptor;
import com.cameleer.server.app.AbstractPostgresIT;
import com.cameleer.server.app.TestSecurityHelper;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
@@ -11,6 +12,8 @@ import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpMethod;
import org.springframework.http.MediaType;
import java.util.Map;
import static org.assertj.core.api.Assertions.assertThat;
/**
@@ -30,9 +33,17 @@ class ProtocolVersionIT extends AbstractPostgresIT {
@BeforeEach
void setUp() {
// Lift max_agents cap so this IT (which registers an agent) isn't gated by license
// enforcement. Cap behaviour itself is exercised by AgentCapEnforcementIT.
securityHelper.installSyntheticUnsignedLicense(Map.of("max_agents", 100));
jwt = securityHelper.registerTestAgent("test-agent-protocol-it");
}
@AfterEach
void tearDown() {
securityHelper.clearTestLicense();
}
@Test
void requestWithoutProtocolHeaderReturns400() {
HttpHeaders headers = new HttpHeaders();

View File

@@ -0,0 +1,127 @@
package com.cameleer.server.app.license;
import com.cameleer.server.app.AbstractPostgresIT;
import com.cameleer.server.app.TestSecurityHelper;
import com.cameleer.server.core.agent.AgentInfo;
import com.cameleer.server.core.agent.AgentRegistryService;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.web.client.TestRestTemplate;
import org.springframework.http.HttpEntity;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import java.util.List;
import java.util.Map;
import static org.assertj.core.api.Assertions.assertThat;
/**
* Verifies that the {@code max_agents} cap is enforced at
* {@code POST /api/v1/agents/register} via the {@link com.cameleer.server.core.runtime.CreateGuard}
* wired into {@link AgentRegistryService}. The cap fires only on NEW registrations — re-registers
* of an already-registered agent bypass the check (they don't grow the registry).
*
* <p>This IT installs a synthetic license that lowers {@code max_agents} to {@code 2} so the cap
* can be exercised in a few HTTP calls. The default tier ({@code max_agents = 5}) is intentionally
* not exercised here because sibling agent ITs share the Spring context's in-memory registry and
* would interfere; the structured 403 envelope (also produced by
* {@link LicenseExceptionAdvice}) is identical regardless of the underlying limit value.</p>
*/
class AgentCapEnforcementIT extends AbstractPostgresIT {
@Autowired
private TestRestTemplate restTemplate;
@Autowired
private ObjectMapper objectMapper;
@Autowired
private TestSecurityHelper securityHelper;
@Autowired
private AgentRegistryService agentRegistryService;
@BeforeEach
void setUp() {
// The registry is in-memory and shared across @SpringBootTest reuse boundaries; sibling
// ITs (AgentRegistrationControllerIT, AgentSseControllerIT, …) leave residue here.
clearRegistry();
// Lower max_agents to 2 so the cap rejection lands on the third register call.
securityHelper.installSyntheticUnsignedLicense(Map.of("max_agents", 2));
}
@AfterEach
void tearDown() {
securityHelper.clearTestLicense();
clearRegistry();
}
private void clearRegistry() {
List<AgentInfo> all = agentRegistryService.findAll();
for (AgentInfo a : all) {
agentRegistryService.deregister(a.instanceId());
}
}
private ResponseEntity<String> register(String agentId) {
String json = """
{
"instanceId": "%s",
"applicationId": "test-cap",
"environmentId": "default",
"version": "1.0.0",
"routeIds": [],
"capabilities": {}
}
""".formatted(agentId);
return restTemplate.postForEntity(
"/api/v1/agents/register",
new HttpEntity<>(json, securityHelper.bootstrapHeaders()),
String.class);
}
@Test
void registerBeyondCap_returns403WithStateAndMessage() throws Exception {
// Two registrations succeed (cap = 2).
assertThat(register("cap-it-agent-1").getStatusCode()).isEqualTo(HttpStatus.OK);
assertThat(register("cap-it-agent-2").getStatusCode()).isEqualTo(HttpStatus.OK);
// The third registration must be rejected with the structured 403 envelope.
ResponseEntity<String> third = register("cap-it-agent-3");
assertThat(third.getStatusCode()).isEqualTo(HttpStatus.FORBIDDEN);
JsonNode body = objectMapper.readTree(third.getBody());
assertThat(body.path("error").asText()).isEqualTo("license cap reached");
assertThat(body.path("limit").asText()).isEqualTo("max_agents");
assertThat(body.path("cap").asInt()).isEqualTo(2);
// We installed a synthetic license, so state is ACTIVE (not ABSENT).
assertThat(body.path("state").asText()).isEqualTo("ACTIVE");
assertThat(body.has("message")).isTrue();
assertThat(body.path("message").asText()).isNotBlank();
// And the third agent must NOT be present in the registry.
assertThat(agentRegistryService.findById("cap-it-agent-3")).isNull();
}
@Test
void reRegisterAtCap_bypassesGuardAndReturns200() {
// Fill to the cap.
assertThat(register("cap-it-rereg-1").getStatusCode()).isEqualTo(HttpStatus.OK);
assertThat(register("cap-it-rereg-2").getStatusCode()).isEqualTo(HttpStatus.OK);
// Re-register an existing agent — must succeed even though we're at-cap, because
// re-registers don't grow the registry. This is the explicit design of the guard
// placement inside the (existing == null) branch of agents.compute(...).
ResponseEntity<String> reRegister = register("cap-it-rereg-1");
assertThat(reRegister.getStatusCode()).isEqualTo(HttpStatus.OK);
// And a fresh registration is still rejected.
ResponseEntity<String> third = register("cap-it-rereg-3");
assertThat(third.getStatusCode()).isEqualTo(HttpStatus.FORBIDDEN);
}
}

View File

@@ -0,0 +1,133 @@
package com.cameleer.server.app.license;
import com.cameleer.server.app.AbstractPostgresIT;
import com.cameleer.server.app.TestSecurityHelper;
import com.cameleer.server.app.search.ClickHouseLogStore;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.mock.mockito.MockBean;
import org.springframework.boot.test.web.client.TestRestTemplate;
import org.springframework.http.HttpEntity;
import org.springframework.http.HttpMethod;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import static org.assertj.core.api.Assertions.assertThat;
/**
* Verifies that the {@code max_alert_rules} cap from the default tier is enforced at
* {@code POST /api/v1/environments/{envSlug}/alerts/rules}. Default tier
* {@code max_alert_rules = 2}; with no license installed the gate is in
* {@link com.cameleer.server.core.license.LicenseState#ABSENT} and the defaults are
* authoritative. The first two creates succeed; the third must be rejected with the
* structured 403 envelope produced by {@link LicenseExceptionAdvice}.
*/
class AlertRuleCapEnforcementIT extends AbstractPostgresIT {
// ExchangeMatchEvaluator and LogPatternEvaluator depend on the concrete CH log store
// bean. Mock it so the Spring context wires up without real ClickHouse log behaviour.
@MockBean(name = "clickHouseLogStore") ClickHouseLogStore clickHouseLogStore;
@Autowired
private TestRestTemplate restTemplate;
@Autowired
private ObjectMapper objectMapper;
@Autowired
private TestSecurityHelper securityHelper;
private String adminJwt;
private String envSlug;
@BeforeEach
void setUp() {
adminJwt = securityHelper.adminToken();
// Defensive: a sibling IT may have left a license installed (LicenseGate is a singleton
// per Spring context; @SpringBootTest reuses contexts across ITs).
securityHelper.clearTestLicense();
// Strip alert-rule dependents first, then the rules themselves — the cap is per-tenant
// (tenant-wide count, not env-scoped).
jdbcTemplate.update("DELETE FROM alert_notifications");
jdbcTemplate.update("DELETE FROM alert_instances");
jdbcTemplate.update("DELETE FROM alert_silences");
jdbcTemplate.update("DELETE FROM alert_rule_targets");
jdbcTemplate.update("DELETE FROM alert_rules");
// Seed user row for the JWT subject — alert_rules.created_by FKs to users(user_id).
jdbcTemplate.update(
"INSERT INTO users (user_id, provider, email, display_name) VALUES (?, 'test', ?, ?) ON CONFLICT (user_id) DO NOTHING",
"test-admin", "test-admin@example.com", "test-admin");
// Use the seeded "default" environment.
envSlug = "default";
}
@AfterEach
void tearDown() {
// Defensive cleanup — we never installed a license, but make sure later ITs see ABSENT.
securityHelper.clearTestLicense();
jdbcTemplate.update("DELETE FROM alert_notifications");
jdbcTemplate.update("DELETE FROM alert_instances");
jdbcTemplate.update("DELETE FROM alert_silences");
jdbcTemplate.update("DELETE FROM alert_rule_targets");
jdbcTemplate.update("DELETE FROM alert_rules");
jdbcTemplate.update("DELETE FROM users WHERE user_id = 'test-admin'");
}
@Test
void createBeyondCap_returns403WithStateAndMessage() throws Exception {
// Default tier: max_alert_rules = 2. First two creates succeed; the third rejects.
ResponseEntity<String> first = restTemplate.exchange(
"/api/v1/environments/" + envSlug + "/alerts/rules", HttpMethod.POST,
new HttpEntity<>(ruleBody("rule-1"), securityHelper.authHeaders(adminJwt)),
String.class);
assertThat(first.getStatusCode()).isEqualTo(HttpStatus.CREATED);
ResponseEntity<String> second = restTemplate.exchange(
"/api/v1/environments/" + envSlug + "/alerts/rules", HttpMethod.POST,
new HttpEntity<>(ruleBody("rule-2"), securityHelper.authHeaders(adminJwt)),
String.class);
assertThat(second.getStatusCode()).isEqualTo(HttpStatus.CREATED);
ResponseEntity<String> response = restTemplate.exchange(
"/api/v1/environments/" + envSlug + "/alerts/rules", HttpMethod.POST,
new HttpEntity<>(ruleBody("rule-3"), securityHelper.authHeaders(adminJwt)),
String.class);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.FORBIDDEN);
JsonNode body = objectMapper.readTree(response.getBody());
assertThat(body.path("error").asText()).isEqualTo("license cap reached");
assertThat(body.path("limit").asText()).isEqualTo("max_alert_rules");
assertThat(body.path("cap").asInt()).isEqualTo(2);
assertThat(body.path("state").asText()).isEqualTo("ABSENT");
assertThat(body.has("message")).isTrue();
assertThat(body.path("message").asText()).isNotBlank();
// And the third rule was NOT persisted.
Integer count = jdbcTemplate.queryForObject(
"SELECT COUNT(*) FROM alert_rules WHERE name = 'rule-3'", Integer.class);
assertThat(count).isZero();
// Total rules still 2 — the rejection short-circuited before any insert.
Integer total = jdbcTemplate.queryForObject(
"SELECT COUNT(*) FROM alert_rules", Integer.class);
assertThat(total).isEqualTo(2);
}
/**
* Minimal valid alert-rule request body: a ROUTE_METRIC condition with a USER target so
* the controller's "at least one webhook or target" guard passes. The rule is otherwise
* inert — it does not need to evaluate or fire to exercise the license cap.
*/
private static String ruleBody(String name) {
return """
{"name":"%s","severity":"WARNING","conditionKind":"ROUTE_METRIC",
"condition":{"kind":"ROUTE_METRIC","scope":{},
"metric":"ERROR_RATE","comparator":"GT","threshold":0.05,"windowSeconds":60},
"targets":[{"kind":"USER","targetId":"test-admin"}]}
""".formatted(name);
}
}

View File

@@ -0,0 +1,98 @@
package com.cameleer.server.app.license;
import com.cameleer.server.app.AbstractPostgresIT;
import com.cameleer.server.app.TestSecurityHelper;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.web.client.TestRestTemplate;
import org.springframework.http.HttpEntity;
import org.springframework.http.HttpMethod;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import static org.assertj.core.api.Assertions.assertThat;
/**
* Verifies that the {@code max_apps} cap from the default tier is enforced at
* {@code POST /api/v1/environments/{envSlug}/apps}. Default tier {@code max_apps = 3}; with no
* license installed the gate is in {@link com.cameleer.server.core.license.LicenseState#ABSENT}
* and the defaults are authoritative. The fourth create attempt must be rejected with the
* structured 403 envelope produced by {@link LicenseExceptionAdvice}.
*/
class AppCapEnforcementIT extends AbstractPostgresIT {
@Autowired
private TestRestTemplate restTemplate;
@Autowired
private ObjectMapper objectMapper;
@Autowired
private TestSecurityHelper securityHelper;
private String adminJwt;
@BeforeEach
void setUp() {
adminJwt = securityHelper.adminToken();
// Defensive: a sibling IT may have left a license installed (LicenseGate is a singleton
// per Spring context; @SpringBootTest reuses contexts across ITs).
securityHelper.clearTestLicense();
// Strip dependents first, then the apps themselves. Keep the seeded "default" environment.
jdbcTemplate.update("DELETE FROM deployments");
jdbcTemplate.update("DELETE FROM app_versions");
jdbcTemplate.update("DELETE FROM apps");
}
@AfterEach
void tearDown() {
// Defensive cleanup — we never installed a license, but make sure later ITs see ABSENT.
securityHelper.clearTestLicense();
jdbcTemplate.update("DELETE FROM deployments");
jdbcTemplate.update("DELETE FROM app_versions");
jdbcTemplate.update("DELETE FROM apps");
}
@Test
void createBeyondCap_returns403WithStateAndMessage() throws Exception {
// Default tier: max_apps = 3. Three creates succeed; the fourth rejects.
for (int i = 1; i <= 3; i++) {
String json = String.format("""
{"slug":"a%d","displayName":"A%d"}
""", i, i);
ResponseEntity<String> ok = restTemplate.exchange(
"/api/v1/environments/default/apps", HttpMethod.POST,
new HttpEntity<>(json, securityHelper.authHeaders(adminJwt)),
String.class);
assertThat(ok.getStatusCode())
.as("create #%d should succeed", i)
.isEqualTo(HttpStatus.CREATED);
}
String fourth = """
{"slug":"a4","displayName":"A4"}
""";
ResponseEntity<String> response = restTemplate.exchange(
"/api/v1/environments/default/apps", HttpMethod.POST,
new HttpEntity<>(fourth, securityHelper.authHeaders(adminJwt)),
String.class);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.FORBIDDEN);
JsonNode body = objectMapper.readTree(response.getBody());
assertThat(body.path("error").asText()).isEqualTo("license cap reached");
assertThat(body.path("limit").asText()).isEqualTo("max_apps");
assertThat(body.path("cap").asInt()).isEqualTo(3);
assertThat(body.path("state").asText()).isEqualTo("ABSENT");
assertThat(body.has("message")).isTrue();
assertThat(body.path("message").asText()).isNotBlank();
// And the fourth app was not persisted.
Integer count = jdbcTemplate.queryForObject(
"SELECT COUNT(*) FROM apps WHERE slug = 'a4'", Integer.class);
assertThat(count).isZero();
}
}

View File

@@ -0,0 +1,246 @@
package com.cameleer.server.app.license;
import com.cameleer.server.app.AbstractPostgresIT;
import com.cameleer.server.app.TestSecurityHelper;
import com.cameleer.server.app.storage.PostgresDeploymentRepository;
import com.cameleer.server.core.runtime.Deployment;
import com.cameleer.server.core.runtime.DeploymentStatus;
import com.cameleer.server.core.runtime.RuntimeOrchestrator;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.mock.mockito.MockBean;
import org.springframework.boot.test.web.client.TestRestTemplate;
import org.springframework.core.io.ByteArrayResource;
import org.springframework.http.HttpEntity;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpMethod;
import org.springframework.http.MediaType;
import org.springframework.test.context.TestPropertySource;
import org.springframework.util.LinkedMultiValueMap;
import org.springframework.util.MultiValueMap;
import java.util.UUID;
import java.util.concurrent.TimeUnit;
import static org.assertj.core.api.Assertions.assertThat;
import static org.awaitility.Awaitility.await;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
/**
* Verifies that {@code DeploymentExecutor} consults
* {@link com.cameleer.server.app.license.LicenseEnforcer} for the three compute caps
* ({@code max_total_cpu_millis}, {@code max_total_memory_mb}, {@code max_total_replicas})
* during {@code PRE_FLIGHT} and that a violation marks the deployment FAILED with the cap
* message in {@code deployments.error_message}.
*
* <p><b>IT design</b>: HTTP-driven (matches the sibling {@code BlueGreenStrategyIT} /
* {@code RollingStrategyIT} pattern). We {@code @MockBean} the {@code RuntimeOrchestrator}
* so no Docker calls happen, but we never need the mock to do anything because the cap
* check fires <i>before</i> any orchestrator invocation — a successful rejection short-
* circuits the executor inside the {@code try} block, the catch turns the
* {@link LicenseCapExceededException} into a FAILED deployment, and the mock stays untouched.</p>
*
* <p>Scenario: install a synthetic license that lifts {@code max_apps} / {@code max_environments}
* (so we can create the env+app), but leaves the compute caps at default
* ({@code max_total_cpu_millis = 2000}). Configure the app's containerConfig to exceed the
* default CPU cap (e.g. {@code cpuLimit = 3000}) and trigger a deployment via the HTTP API.
* Poll until the deployment lands in FAILED with an error message that contains the cap key.</p>
*/
@TestPropertySource(properties = "cameleer.server.runtime.healthchecktimeout=2")
class ComputeCapEnforcementIT extends AbstractPostgresIT {
@MockBean
RuntimeOrchestrator runtimeOrchestrator;
@Autowired private TestRestTemplate restTemplate;
@Autowired private ObjectMapper objectMapper;
@Autowired private TestSecurityHelper securityHelper;
@Autowired private PostgresDeploymentRepository deploymentRepository;
private String operatorJwt;
@BeforeEach
void setUp() {
operatorJwt = securityHelper.operatorToken();
// Defensive: prior IT may have left a license installed; we want defaults for compute caps.
securityHelper.clearTestLicense();
jdbcTemplate.update("DELETE FROM deployments");
jdbcTemplate.update("DELETE FROM app_versions");
jdbcTemplate.update("DELETE FROM apps");
jdbcTemplate.update("DELETE FROM application_config WHERE environment = 'default'");
// Ensure test-operator exists in users table (deployments.created_by FK).
jdbcTemplate.update(
"INSERT INTO users (user_id, provider, display_name) VALUES " +
"('test-operator', 'local', 'Test Operator') ON CONFLICT (user_id) DO NOTHING");
// Mock orchestrator stays passive — cap check rejects before any of these are called,
// but isEnabled() is consulted by some bean wiring during context startup.
when(runtimeOrchestrator.isEnabled()).thenReturn(true);
}
@AfterEach
void tearDown() {
securityHelper.clearTestLicense();
}
@Test
void cpuMillisOverCap_marksDeploymentFailedWithCapMessage() throws Exception {
// Default tier: max_total_cpu_millis = 2000. Configure cpuLimit = 3000 with replicas = 1
// so the requested delta (3000) on its own already exceeds the cap.
String appSlug = "cpucap-" + UUID.randomUUID().toString().substring(0, 8);
post("/api/v1/environments/default/apps", String.format("""
{"slug": "%s", "displayName": "CPU Cap App"}
""", appSlug), operatorJwt);
put("/api/v1/environments/default/apps/" + appSlug + "/container-config", """
{"runtimeType": "spring-boot", "appPort": 8081,
"replicas": 1, "cpuLimit": 3000,
"deploymentStrategy": "blue-green"}
""", operatorJwt);
String versionId = uploadJar(appSlug, ("cpucap-jar-" + appSlug).getBytes());
String deployId = triggerDeploy(appSlug, versionId);
awaitStatus(deployId, DeploymentStatus.FAILED);
Deployment d = deploymentRepository.findById(UUID.fromString(deployId)).orElseThrow();
assertThat(d.errorMessage())
.as("FAILED deployment should carry the cap key in its error_message")
.contains("max_total_cpu_millis");
// Cap rejection happens before any container start — orchestrator must never be touched.
verify(runtimeOrchestrator, never()).startContainer(any());
}
@Test
void replicasOverCap_marksDeploymentFailedWithCapMessage() throws Exception {
// Default tier: max_total_replicas = 5. Use cpuLimit = 0 so cpu cap doesn't trip first;
// memoryLimitMb defaults to global (~512 MB), so 6 replicas = 3072 MB — under the
// default 2048 MB cap is FALSE, but max_total_memory_mb fires AFTER cpu and BEFORE
// replicas so we'd hit memory. Set memoryLimitMb low (16 MB * 6 = 96 MB) so memory
// cap stays well under 2048, isolating the replica cap.
String appSlug = "repcap-" + UUID.randomUUID().toString().substring(0, 8);
post("/api/v1/environments/default/apps", String.format("""
{"slug": "%s", "displayName": "Replica Cap App"}
""", appSlug), operatorJwt);
put("/api/v1/environments/default/apps/" + appSlug + "/container-config", """
{"runtimeType": "spring-boot", "appPort": 8081,
"replicas": 6, "memoryLimitMb": 16,
"deploymentStrategy": "blue-green"}
""", operatorJwt);
String versionId = uploadJar(appSlug, ("repcap-jar-" + appSlug).getBytes());
String deployId = triggerDeploy(appSlug, versionId);
awaitStatus(deployId, DeploymentStatus.FAILED);
Deployment d = deploymentRepository.findById(UUID.fromString(deployId)).orElseThrow();
assertThat(d.errorMessage())
.as("FAILED deployment should carry the cap key in its error_message")
.contains("max_total_replicas");
verify(runtimeOrchestrator, never()).startContainer(any());
}
@Test
void withinCap_succeedsAndDeployStarts() throws Exception {
// Default tier: max_total_cpu_millis = 2000, max_total_memory_mb = 2048,
// max_total_replicas = 5. A single replica with cpuLimit = 1000, memoryLimitMb = 512
// is well within all three.
String appSlug = "okcap-" + UUID.randomUUID().toString().substring(0, 8);
post("/api/v1/environments/default/apps", String.format("""
{"slug": "%s", "displayName": "OK Cap App"}
""", appSlug), operatorJwt);
put("/api/v1/environments/default/apps/" + appSlug + "/container-config", """
{"runtimeType": "spring-boot", "appPort": 8081,
"replicas": 1, "cpuLimit": 1000, "memoryLimitMb": 512,
"deploymentStrategy": "blue-green"}
""", operatorJwt);
String versionId = uploadJar(appSlug, ("okcap-jar-" + appSlug).getBytes());
// Mock the orchestrator to make the deploy reach a terminal state quickly.
// We don't care which state — only that the cap check did NOT short-circuit.
when(runtimeOrchestrator.startContainer(any())).thenReturn("c-0");
// getContainerStatus default returns null -> NPE in waitForAllHealthy; stub a starting state
// so the health check times out (healthchecktimeout=2s) and the deploy lands in FAILED for
// a different reason (health-check failure, not cap rejection).
when(runtimeOrchestrator.getContainerStatus(any()))
.thenReturn(new com.cameleer.server.core.runtime.ContainerStatus("starting", true, 0, null));
String deployId = triggerDeploy(appSlug, versionId);
// Either RUNNING (mock made it healthy somehow) or FAILED for a NON-cap reason.
await().atMost(20, TimeUnit.SECONDS)
.pollInterval(500, TimeUnit.MILLISECONDS)
.untilAsserted(() -> {
Deployment d = deploymentRepository.findById(UUID.fromString(deployId)).orElseThrow();
assertThat(d.status()).isIn(DeploymentStatus.RUNNING, DeploymentStatus.FAILED);
});
Deployment d = deploymentRepository.findById(UUID.fromString(deployId)).orElseThrow();
// Whatever terminal state we hit, the cap check did not flag any compute key.
if (d.errorMessage() != null) {
assertThat(d.errorMessage()).doesNotContain("max_total_cpu_millis");
assertThat(d.errorMessage()).doesNotContain("max_total_memory_mb");
assertThat(d.errorMessage()).doesNotContain("max_total_replicas");
}
// And the orchestrator was actually invoked — proving cap check did not short-circuit.
verify(runtimeOrchestrator).startContainer(any());
}
// ---- helpers (cribbed from BlueGreenStrategyIT) ----
private String triggerDeploy(String appSlug, String versionId) throws Exception {
JsonNode deployResponse = post(
"/api/v1/environments/default/apps/" + appSlug + "/deployments",
String.format("{\"appVersionId\": \"%s\"}", versionId), operatorJwt);
return deployResponse.path("id").asText();
}
private void awaitStatus(String deployId, DeploymentStatus expected) {
await().atMost(15, TimeUnit.SECONDS)
.pollInterval(250, TimeUnit.MILLISECONDS)
.untilAsserted(() -> {
Deployment d = deploymentRepository.findById(UUID.fromString(deployId))
.orElseThrow(() -> new AssertionError("Deployment not found: " + deployId));
assertThat(d.status()).isEqualTo(expected);
});
}
private JsonNode post(String path, String json, String jwt) throws Exception {
HttpHeaders headers = securityHelper.authHeaders(jwt);
var response = restTemplate.exchange(path, HttpMethod.POST,
new HttpEntity<>(json, headers), String.class);
return objectMapper.readTree(response.getBody());
}
private void put(String path, String json, String jwt) {
HttpHeaders headers = securityHelper.authHeaders(jwt);
restTemplate.exchange(path, HttpMethod.PUT,
new HttpEntity<>(json, headers), String.class);
}
private String uploadJar(String appSlug, byte[] content) throws Exception {
ByteArrayResource resource = new ByteArrayResource(content) {
@Override public String getFilename() { return "app.jar"; }
};
MultiValueMap<String, Object> body = new LinkedMultiValueMap<>();
body.add("file", resource);
HttpHeaders headers = new HttpHeaders();
headers.set("Authorization", "Bearer " + operatorJwt);
headers.set("X-Cameleer-Protocol-Version", "1");
headers.setContentType(MediaType.MULTIPART_FORM_DATA);
var response = restTemplate.exchange(
"/api/v1/environments/default/apps/" + appSlug + "/versions",
HttpMethod.POST, new HttpEntity<>(body, headers), String.class);
JsonNode versionNode = objectMapper.readTree(response.getBody());
return versionNode.path("id").asText();
}
}

View File

@@ -0,0 +1,79 @@
package com.cameleer.server.app.license;
import com.cameleer.server.app.AbstractPostgresIT;
import com.cameleer.server.app.TestSecurityHelper;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.web.client.TestRestTemplate;
import org.springframework.http.HttpEntity;
import org.springframework.http.HttpMethod;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import static org.assertj.core.api.Assertions.assertThat;
/**
* Verifies that the {@code max_environments} cap from the default tier is enforced at
* {@code POST /api/v1/admin/environments}. Default tier {@code max_environments = 1}; the V1
* baseline migration seeds a single {@code default} environment, so the very next create
* attempt must be rejected with the structured 403 envelope produced by
* {@link LicenseExceptionAdvice}.
*/
class EnvironmentCapEnforcementIT extends AbstractPostgresIT {
@Autowired
private TestRestTemplate restTemplate;
@Autowired
private ObjectMapper objectMapper;
@Autowired
private TestSecurityHelper securityHelper;
private String adminJwt;
@BeforeEach
void setUp() {
adminJwt = securityHelper.adminToken();
// Defensive: clear any license a previous IT may have left installed (each IT gets its own
// Spring context only on first @SpringBootTest reuse boundary; LicenseGate is a singleton).
securityHelper.clearTestLicense();
// Ensure starting state: only the seeded "default" env (count = 1, equals the cap).
// Strip dependents first — sibling ITs may have left deployments/apps that FK back to
// non-default envs when the testcontainer is reused across runs.
jdbcTemplate.update("DELETE FROM deployments");
jdbcTemplate.update("DELETE FROM app_versions");
jdbcTemplate.update("DELETE FROM apps");
jdbcTemplate.update("DELETE FROM environments WHERE slug != 'default'");
}
@Test
void createBeyondCap_returns403WithStateAndMessage() throws Exception {
// Default tier: max_environments = 1; V1 seeds the default env, so the next create rejects.
String json = """
{"slug":"prod","displayName":"Prod","production":true}
""";
ResponseEntity<String> response = restTemplate.exchange(
"/api/v1/admin/environments", HttpMethod.POST,
new HttpEntity<>(json, securityHelper.authHeaders(adminJwt)),
String.class);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.FORBIDDEN);
JsonNode body = objectMapper.readTree(response.getBody());
assertThat(body.path("error").asText()).isEqualTo("license cap reached");
assertThat(body.path("limit").asText()).isEqualTo("max_environments");
assertThat(body.path("cap").asInt()).isEqualTo(1);
assertThat(body.path("state").asText()).isEqualTo("ABSENT");
assertThat(body.has("message")).isTrue();
assertThat(body.path("message").asText()).isNotBlank();
// And the env was not created.
Integer count = jdbcTemplate.queryForObject(
"SELECT COUNT(*) FROM environments WHERE slug = 'prod'", Integer.class);
assertThat(count).isZero();
}
}

View File

@@ -0,0 +1,272 @@
package com.cameleer.server.app.license;
import com.cameleer.server.app.AbstractPostgresIT;
import com.cameleer.server.app.TestSecurityHelper;
import com.cameleer.server.app.search.ClickHouseLogStore;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.mock.mockito.MockBean;
import org.springframework.boot.test.web.client.TestRestTemplate;
import org.springframework.http.HttpEntity;
import org.springframework.http.HttpMethod;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import java.util.Map;
import static org.assertj.core.api.Assertions.assertThat;
/**
* Cross-cutting smoke regression for the license cap framework (spec §10).
*
* <p>This IT installs a SINGLE synthetic license with all caps lowered to the smallest useful
* value (1 each, except {@code max_users = 2} so the seeded admin write doesn't immediately
* exhaust the cap) and verifies that five different cap surfaces fire under the SAME license.
* The point is not to exhaustively test each cap — per-limit ITs already do that — but to catch
* the regression where one of the wiring tasks is accidentally backed out without a per-limit
* test failing. If the consolidated tripwire here fires, we know the framework is uniformly
* wired across all surfaces.</p>
*
* <p>Each {@link Nested} test:</p>
* <ol>
* <li>Pushes the endpoint up to the cap (the outer {@link BeforeEach} pre-cleans state).</li>
* <li>Pushes once more — expects 403 with the standard envelope produced by
* {@link LicenseExceptionAdvice}: {@code error="license cap reached"}, {@code limit},
* {@code current}, {@code cap}, {@code state="ACTIVE"}, non-blank {@code message}.</li>
* <li>Verifies {@code audit_log} has at least one row with {@code category='LICENSE'},
* {@code action='cap_exceeded'}, {@code result='FAILURE'}, {@code target=<key>}.</li>
* </ol>
*
* <p>Out of scope (already covered by per-limit ITs):</p>
* <ul>
* <li>Agent registration cap — see {@code AgentCapEnforcementIT}.</li>
* <li>Compute caps (cpu/memory/replicas) — see {@code ComputeCapEnforcementIT}; the deploy
* endpoint requires a real artifact and runtime orchestration.</li>
* <li>JAR retention cap — see {@code RetentionCapEnforcementIT}; that is a 422 not a 403,
* shaped differently from the cap envelope.</li>
* </ul>
*/
class LicenseEnforcementIT extends AbstractPostgresIT {
// The alert-rule controller wires evaluators that touch the CH log store bean. Mocking it
// mirrors {@code AlertRuleCapEnforcementIT}'s pattern and avoids requiring real CH log
// behaviour for a smoke regression that never evaluates rules.
@MockBean(name = "clickHouseLogStore") ClickHouseLogStore clickHouseLogStore;
@Autowired
private TestRestTemplate restTemplate;
@Autowired
private ObjectMapper objectMapper;
@Autowired
private TestSecurityHelper securityHelper;
private String adminJwt;
@BeforeEach
void installLicense() {
adminJwt = securityHelper.adminToken();
// Defensive: clear any license a previous IT may have left installed (LicenseGate is a
// singleton across @SpringBootTest context reuse).
securityHelper.clearTestLicense();
// Strip dependents in FK order before the parent rows.
jdbcTemplate.update("DELETE FROM alert_notifications");
jdbcTemplate.update("DELETE FROM alert_instances");
jdbcTemplate.update("DELETE FROM alert_silences");
jdbcTemplate.update("DELETE FROM alert_rule_targets");
jdbcTemplate.update("DELETE FROM alert_rules");
jdbcTemplate.update("DELETE FROM deployments");
jdbcTemplate.update("DELETE FROM app_versions");
jdbcTemplate.update("DELETE FROM apps");
jdbcTemplate.update("DELETE FROM environments WHERE slug != 'default'");
jdbcTemplate.update("DELETE FROM outbound_connections");
jdbcTemplate.update("DELETE FROM user_roles");
jdbcTemplate.update("DELETE FROM user_groups");
jdbcTemplate.update("DELETE FROM users");
jdbcTemplate.update("DELETE FROM audit_log");
// Seed user row for the JWT subject — alert_rules.created_by and
// outbound_connections.created_by FK to users(user_id).
jdbcTemplate.update(
"INSERT INTO users (user_id, provider, email, display_name) VALUES (?, 'test', ?, ?) ON CONFLICT (user_id) DO NOTHING",
"test-admin", "test-admin@example.com", "test-admin");
// Single synthetic license used by all @Nested tests. Caps set to the minimum useful
// values so the cap rejection lands on a small number of HTTP calls.
// NB: max_users = 2 because the seeded test-admin row already counts toward the cap;
// creating one more user succeeds, the second additional create rejects.
securityHelper.installSyntheticUnsignedLicense(Map.of(
"max_environments", 1,
"max_apps", 1,
"max_outbound_connections", 1,
"max_alert_rules", 1,
"max_users", 2));
}
@AfterEach
void clearLicense() {
securityHelper.clearTestLicense();
}
// ---------- shared helpers ----------
private void assert403CapEnvelope(ResponseEntity<String> response, String expectedLimit, int expectedCap) throws Exception {
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.FORBIDDEN);
JsonNode body = objectMapper.readTree(response.getBody());
assertThat(body.path("error").asText()).isEqualTo("license cap reached");
assertThat(body.path("limit").asText()).isEqualTo(expectedLimit);
assertThat(body.path("cap").asInt()).isEqualTo(expectedCap);
assertThat(body.path("state").asText()).isEqualTo("ACTIVE");
assertThat(body.has("message")).isTrue();
assertThat(body.path("message").asText()).isNotBlank();
}
private long auditCount(String target) {
Long count = jdbcTemplate.queryForObject("""
SELECT COUNT(*) FROM audit_log
WHERE category = 'LICENSE'
AND action = 'cap_exceeded'
AND result = 'FAILURE'
AND target = ?
""", Long.class, target);
return count == null ? 0L : count;
}
// ---------- nested cap tests ----------
@Nested
class EnvironmentCap {
@Test
void secondCreate_rejectedWith403AndAuditRow() throws Exception {
// V1 seeds the "default" env, so even a single create exceeds max_environments=1.
String json = """
{"slug":"prod","displayName":"Prod","production":true}
""";
ResponseEntity<String> response = restTemplate.exchange(
"/api/v1/admin/environments", HttpMethod.POST,
new HttpEntity<>(json, securityHelper.authHeaders(adminJwt)),
String.class);
assert403CapEnvelope(response, "max_environments", 1);
assertThat(auditCount("max_environments")).isGreaterThanOrEqualTo(1);
}
}
@Nested
class AppCap {
@Test
void secondCreate_rejectedWith403AndAuditRow() throws Exception {
// First create succeeds (count: 0 -> 1, cap=1).
ResponseEntity<String> first = restTemplate.exchange(
"/api/v1/environments/default/apps", HttpMethod.POST,
new HttpEntity<>("""
{"slug":"a1","displayName":"A1"}
""", securityHelper.authHeaders(adminJwt)),
String.class);
assertThat(first.getStatusCode()).isEqualTo(HttpStatus.CREATED);
// Second create rejects.
ResponseEntity<String> second = restTemplate.exchange(
"/api/v1/environments/default/apps", HttpMethod.POST,
new HttpEntity<>("""
{"slug":"a2","displayName":"A2"}
""", securityHelper.authHeaders(adminJwt)),
String.class);
assert403CapEnvelope(second, "max_apps", 1);
assertThat(auditCount("max_apps")).isGreaterThanOrEqualTo(1);
}
}
@Nested
class OutboundConnectionCap {
@Test
void secondCreate_rejectedWith403AndAuditRow() throws Exception {
ResponseEntity<String> first = restTemplate.exchange(
"/api/v1/admin/outbound-connections", HttpMethod.POST,
new HttpEntity<>("""
{"name":"hook-1","url":"https://hooks.example.com/1","method":"POST",
"tlsTrustMode":"SYSTEM_DEFAULT","auth":{}}""",
securityHelper.authHeaders(adminJwt)),
String.class);
assertThat(first.getStatusCode()).isEqualTo(HttpStatus.CREATED);
ResponseEntity<String> second = restTemplate.exchange(
"/api/v1/admin/outbound-connections", HttpMethod.POST,
new HttpEntity<>("""
{"name":"hook-2","url":"https://hooks.example.com/2","method":"POST",
"tlsTrustMode":"SYSTEM_DEFAULT","auth":{}}""",
securityHelper.authHeaders(adminJwt)),
String.class);
assert403CapEnvelope(second, "max_outbound_connections", 1);
assertThat(auditCount("max_outbound_connections")).isGreaterThanOrEqualTo(1);
}
}
@Nested
class AlertRuleCap {
@Test
void secondCreate_rejectedWith403AndAuditRow() throws Exception {
ResponseEntity<String> first = restTemplate.exchange(
"/api/v1/environments/default/alerts/rules", HttpMethod.POST,
new HttpEntity<>(ruleBody("rule-1"), securityHelper.authHeaders(adminJwt)),
String.class);
assertThat(first.getStatusCode()).isEqualTo(HttpStatus.CREATED);
ResponseEntity<String> second = restTemplate.exchange(
"/api/v1/environments/default/alerts/rules", HttpMethod.POST,
new HttpEntity<>(ruleBody("rule-2"), securityHelper.authHeaders(adminJwt)),
String.class);
assert403CapEnvelope(second, "max_alert_rules", 1);
assertThat(auditCount("max_alert_rules")).isGreaterThanOrEqualTo(1);
}
private String ruleBody(String name) {
// Mirrors AlertRuleCapEnforcementIT — minimal valid body that passes the
// controller's "at least one webhook or target" guard.
return """
{"name":"%s","severity":"WARNING","conditionKind":"ROUTE_METRIC",
"condition":{"kind":"ROUTE_METRIC","scope":{},
"metric":"ERROR_RATE","comparator":"GT","threshold":0.05,"windowSeconds":60},
"targets":[{"kind":"USER","targetId":"test-admin"}]}
""".formatted(name);
}
}
@Nested
class UserCap {
@Test
void thirdCreate_rejectedWith403AndAuditRow() throws Exception {
// The outer @BeforeEach truncates users and seeds only "test-admin" (count = 1).
// max_users = 2, so the FIRST create succeeds (count: 1 -> 2) and the SECOND rejects.
ResponseEntity<String> first = createUser("alice");
assertThat(first.getStatusCode()).isEqualTo(HttpStatus.OK);
ResponseEntity<String> second = createUser("bob");
assert403CapEnvelope(second, "max_users", 2);
assertThat(auditCount("max_users")).isGreaterThanOrEqualTo(1);
}
private ResponseEntity<String> createUser(String username) {
// Password meets the policy: 12+ chars, 3-of-4 char classes, doesn't match username.
String body = """
{
"username": "%s",
"displayName": "User %s",
"email": "%s@example.com",
"password": "Sup3rSecret-Pass!"
}
""".formatted(username, username, username);
return restTemplate.exchange(
"/api/v1/admin/users", HttpMethod.POST,
new HttpEntity<>(body, securityHelper.authHeaders(adminJwt)),
String.class);
}
}
}

View File

@@ -0,0 +1,51 @@
package com.cameleer.server.app.license;
import com.cameleer.server.core.license.LicenseGate;
import com.cameleer.server.core.license.LicenseInfo;
import org.junit.jupiter.api.Test;
import java.time.Instant;
import java.util.Map;
import java.util.UUID;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
class LicenseEnforcerTest {
@Test
void underCap_passes() {
LicenseGate gate = new LicenseGate();
gate.load(license(Map.of("max_apps", 10), 0));
new LicenseEnforcer(gate).assertWithinCap("max_apps", 9, 1);
}
@Test
void atCap_throws() {
LicenseGate gate = new LicenseGate();
gate.load(license(Map.of("max_apps", 10), 0));
assertThatThrownBy(() -> new LicenseEnforcer(gate).assertWithinCap("max_apps", 10, 1))
.isInstanceOf(LicenseCapExceededException.class)
.hasMessageContaining("max_apps");
}
@Test
void absent_usesDefaultTier() {
LicenseGate gate = new LicenseGate();
// default max_apps = 3; current 3 + 1 > 3 -> reject
assertThatThrownBy(() -> new LicenseEnforcer(gate).assertWithinCap("max_apps", 3, 1))
.isInstanceOf(LicenseCapExceededException.class);
}
@Test
void unknownLimitKey_throwsIllegalArgument() {
LicenseGate gate = new LicenseGate();
assertThatThrownBy(() -> new LicenseEnforcer(gate).assertWithinCap("max_xyz", 0, 1))
.isInstanceOf(IllegalArgumentException.class);
}
private LicenseInfo license(Map<String, Integer> limits, int grace) {
return new LicenseInfo(UUID.randomUUID(), "acme", null,
limits, Instant.now(), Instant.now().plusSeconds(86400), grace);
}
}

View File

@@ -0,0 +1,223 @@
package com.cameleer.server.app.license;
import com.cameleer.license.minter.LicenseMinter;
import com.cameleer.server.app.AbstractPostgresIT;
import com.cameleer.server.app.TestSecurityHelper;
import com.cameleer.server.core.license.LicenseGate;
import com.cameleer.server.core.license.LicenseInfo;
import com.cameleer.server.core.license.LicenseState;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.web.client.TestRestTemplate;
import org.springframework.http.HttpEntity;
import org.springframework.http.HttpMethod;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.test.context.DynamicPropertyRegistry;
import org.springframework.test.context.DynamicPropertySource;
import java.security.KeyPair;
import java.security.KeyPairGenerator;
import java.security.NoSuchAlgorithmException;
import java.time.Instant;
import java.time.temporal.ChronoUnit;
import java.util.Base64;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.UUID;
import static org.assertj.core.api.Assertions.assertThat;
/**
* End-to-end integration test for the license install / persist / revalidate / reject lifecycle.
*
* <p>Mints a real Ed25519-signed token via {@code cameleer-license-minter} (test scope), POSTs
* it through {@code /api/v1/admin/license}, then verifies:
* <ol>
* <li>Gate transitions ABSENT &rarr; ACTIVE.</li>
* <li>Row persists in the {@code license} PostgreSQL table.</li>
* <li>After {@code gate.clear()}, {@code revalidate()} restores ACTIVE from the persisted token.</li>
* <li>A token with a tampered signature is rejected (HTTP 400) and audited as FAILURE
* under {@code AuditCategory.LICENSE} without mutating the gate.</li>
* </ol>
*
* <p>The Ed25519 keypair is generated once per JVM and the public key is published as a Spring
* property via {@code @DynamicPropertySource} (which composes with the JDBC overrides in
* {@link AbstractPostgresIT}). Scenario 3 from the plan
* (revalidateAfterPublicKeyChange_marksInvalid) is intentionally skipped — it would require
* mid-context re-binding of the validator bean, which is more complex than the value warrants.</p>
*/
class LicenseLifecycleIT extends AbstractPostgresIT {
private static final KeyPair KEY_PAIR = generateKeyPair();
private static KeyPair generateKeyPair() {
try {
return KeyPairGenerator.getInstance("Ed25519").generateKeyPair();
} catch (NoSuchAlgorithmException e) {
throw new IllegalStateException("Ed25519 not available", e);
}
}
@DynamicPropertySource
static void licensePublicKey(DynamicPropertyRegistry registry) {
registry.add("cameleer.server.license.publickey", () ->
Base64.getEncoder().encodeToString(KEY_PAIR.getPublic().getEncoded()));
}
@Autowired
private TestRestTemplate restTemplate;
@Autowired
private ObjectMapper objectMapper;
@Autowired
private TestSecurityHelper securityHelper;
@Autowired
private LicenseGate gate;
@Autowired
private LicenseService licenseService;
@Autowired
private LicenseRepository licenseRepository;
private String adminJwt;
@BeforeEach
void setUp() {
adminJwt = securityHelper.adminToken();
// Sibling ITs may have left state behind.
gate.clear();
licenseRepository.delete("default");
jdbcTemplate.update("DELETE FROM audit_log WHERE category = 'LICENSE'");
}
@AfterEach
void tearDown() {
gate.clear();
licenseRepository.delete("default");
}
/** Scenario 1 — install via REST, verify gate + DB, clear, revalidate, gate restored. */
@Test
void install_persists_andSurvivesGateClear() throws Exception {
Instant now = Instant.now();
UUID licenseId = UUID.randomUUID();
LicenseInfo info = new LicenseInfo(
licenseId,
"default",
"lifecycle-it",
Map.of("max_apps", 25),
now,
now.plus(1, ChronoUnit.DAYS),
0);
String token = LicenseMinter.mint(info, KEY_PAIR.getPrivate());
// POST to /api/v1/admin/license
Map<String, Object> body = new LinkedHashMap<>();
body.put("token", token);
ResponseEntity<String> response = restTemplate.exchange(
"/api/v1/admin/license", HttpMethod.POST,
new HttpEntity<>(body, securityHelper.authHeaders(adminJwt)),
String.class);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
JsonNode payload = objectMapper.readTree(response.getBody());
assertThat(payload.path("state").asText()).isEqualTo("ACTIVE");
assertThat(payload.path("envelope").path("licenseId").asText()).isEqualTo(licenseId.toString());
// Gate is ACTIVE, parsed envelope matches.
assertThat(gate.getState()).isEqualTo(LicenseState.ACTIVE);
assertThat(gate.getCurrent()).isNotNull();
assertThat(gate.getCurrent().licenseId()).isEqualTo(licenseId);
// Row persisted in PostgreSQL.
Integer rowCount = jdbcTemplate.queryForObject(
"SELECT COUNT(*) FROM license WHERE tenant_id = ? AND license_id = ?",
Integer.class, "default", licenseId);
assertThat(rowCount).isEqualTo(1);
// Audit row written under LICENSE category.
Integer auditCount = jdbcTemplate.queryForObject(
"SELECT COUNT(*) FROM audit_log WHERE category = 'LICENSE' AND result = 'SUCCESS'",
Integer.class);
assertThat(auditCount).isGreaterThanOrEqualTo(1);
// Now simulate a server restart effect: clear in-memory gate, then revalidate from DB.
gate.clear();
assertThat(gate.getState()).isEqualTo(LicenseState.ABSENT);
licenseService.revalidate();
assertThat(gate.getState()).isEqualTo(LicenseState.ACTIVE);
assertThat(gate.getCurrent()).isNotNull();
assertThat(gate.getCurrent().licenseId()).isEqualTo(licenseId);
}
/** Scenario 2 — tampered signature → 400 + LICENSE/FAILURE audit row, gate stays ABSENT. */
@Test
void postWithBadSignature_returns400_andDoesNotMutateGate() throws Exception {
Instant now = Instant.now();
LicenseInfo info = new LicenseInfo(
UUID.randomUUID(),
"default",
"tamper-it",
Map.of(),
now,
now.plus(1, ChronoUnit.DAYS),
0);
String token = LicenseMinter.mint(info, KEY_PAIR.getPrivate());
String tampered = tamperSignature(token);
// Sanity: gate starts ABSENT.
assertThat(gate.getState()).isEqualTo(LicenseState.ABSENT);
Map<String, Object> body = new LinkedHashMap<>();
body.put("token", tampered);
ResponseEntity<String> response = restTemplate.exchange(
"/api/v1/admin/license", HttpMethod.POST,
new HttpEntity<>(body, securityHelper.authHeaders(adminJwt)),
String.class);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST);
JsonNode payload = objectMapper.readTree(response.getBody());
assertThat(payload.path("error").asText()).isNotBlank();
// Gate moved to INVALID (LicenseService.install() calls gate.markInvalid on validation
// failure, then re-throws — the controller converts to 400). The DB stays empty.
assertThat(gate.getState()).isEqualTo(LicenseState.INVALID);
assertThat(gate.getCurrent()).isNull();
assertThat(gate.getInvalidReason()).isNotBlank();
// No persisted row.
assertThat(licenseRepository.findByTenantId("default")).isEmpty();
// Exactly one audit row, LICENSE/FAILURE for action 'reject_license'.
Integer failureCount = jdbcTemplate.queryForObject(
"SELECT COUNT(*) FROM audit_log " +
"WHERE category = 'LICENSE' AND result = 'FAILURE' AND action = 'reject_license'",
Integer.class);
assertThat(failureCount).isEqualTo(1);
}
/**
* Flips a single byte in the signature segment of a {@code base64(payload).base64(sig)} token
* so the Ed25519 verifier fails. Stays decodable as base64 so the parse-format check passes
* and the failure is reported as a signature-mismatch SecurityException, not a parse error.
*/
private static String tamperSignature(String token) {
int dot = token.indexOf('.');
String payloadB64 = token.substring(0, dot);
String sigB64 = token.substring(dot + 1);
byte[] sig = Base64.getDecoder().decode(sigB64);
sig[0] ^= 0x01;
return payloadB64 + "." + Base64.getEncoder().encodeToString(sig);
}
}

View File

@@ -0,0 +1,86 @@
package com.cameleer.server.app.license;
import com.cameleer.server.core.license.LicenseInfo;
import com.cameleer.server.core.license.LicenseState;
import org.junit.jupiter.api.Test;
import java.time.Instant;
import java.util.Map;
import java.util.UUID;
import static org.assertj.core.api.Assertions.assertThat;
class LicenseMessageRendererTest {
@Test
void absent_message() {
var msg = LicenseMessageRenderer.forCap(LicenseState.ABSENT, null, "max_apps", 0, 3);
assertThat(msg).contains("No license").contains("max_apps").contains("3");
}
@Test
void active_message() {
LicenseInfo info = info(Instant.now().plusSeconds(86400 * 100), 0);
var msg = LicenseMessageRenderer.forCap(LicenseState.ACTIVE, info, "max_apps", 50, 50);
assertThat(msg).contains("cap reached").contains("50");
}
@Test
void grace_message_includesDayCount() {
LicenseInfo info = info(Instant.now().minusSeconds(86400L * 5), 30);
var msg = LicenseMessageRenderer.forCap(LicenseState.GRACE, info, "max_apps", 10, 10);
assertThat(msg).contains("expired").contains("5").contains("grace");
}
@Test
void expired_message_explainsRevert() {
LicenseInfo info = info(Instant.now().minusSeconds(86400L * 60), 30);
var msg = LicenseMessageRenderer.forCap(LicenseState.EXPIRED, info, "max_apps", 40, 3);
assertThat(msg).contains("expired").contains("default tier").contains("3");
}
@Test
void invalid_message_includesReason() {
var msg = LicenseMessageRenderer.forCap(LicenseState.INVALID, null,
"max_apps", 0, 3, "signature failed");
assertThat(msg).contains("rejected").contains("signature failed");
}
@Test
void forState_absent() {
var msg = LicenseMessageRenderer.forState(LicenseState.ABSENT, null);
assertThat(msg).contains("No license").contains("default tier");
}
@Test
void forState_active() {
LicenseInfo info = info(Instant.now().plusSeconds(86400 * 100), 0);
var msg = LicenseMessageRenderer.forState(LicenseState.ACTIVE, info);
assertThat(msg).contains("License is active");
}
@Test
void forState_grace() {
LicenseInfo info = info(Instant.now().minusSeconds(86400L * 5), 30);
var msg = LicenseMessageRenderer.forState(LicenseState.GRACE, info);
assertThat(msg).contains("expired").contains("grace");
}
@Test
void forState_expired() {
LicenseInfo info = info(Instant.now().minusSeconds(86400L * 60), 30);
var msg = LicenseMessageRenderer.forState(LicenseState.EXPIRED, info);
assertThat(msg).contains("expired").contains("default tier");
}
@Test
void forState_invalid_includesReason() {
var msg = LicenseMessageRenderer.forState(LicenseState.INVALID, null, "signature failed");
assertThat(msg).contains("rejected").contains("signature failed");
}
private LicenseInfo info(Instant exp, int graceDays) {
return new LicenseInfo(UUID.randomUUID(), "acme", null, Map.of(),
Instant.now().minusSeconds(86400L * 365), exp, graceDays);
}
}

View File

@@ -0,0 +1,60 @@
package com.cameleer.server.app.license;
import com.cameleer.server.core.license.LicenseGate;
import com.cameleer.server.core.license.LicenseInfo;
import io.micrometer.core.instrument.simple.SimpleMeterRegistry;
import org.junit.jupiter.api.Test;
import java.time.Instant;
import java.time.temporal.ChronoUnit;
import java.util.Map;
import java.util.Optional;
import java.util.UUID;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
class LicenseMetricsTest {
@Test
void absentState_setsAbsentGaugeTo1AndDaysRemainingTo_minusOne() {
LicenseGate gate = new LicenseGate();
LicenseRepository repo = mock(LicenseRepository.class);
when(repo.findByTenantId("default")).thenReturn(Optional.empty());
SimpleMeterRegistry meters = new SimpleMeterRegistry();
var metrics = new LicenseMetrics(gate, repo, meters, "default");
metrics.refresh();
assertThat(meters.find("cameleer_license_state").tag("state", "ABSENT").gauge().value())
.isEqualTo(1.0);
assertThat(meters.find("cameleer_license_state").tag("state", "ACTIVE").gauge().value())
.isEqualTo(0.0);
assertThat(meters.find("cameleer_license_days_remaining").gauge().value())
.isEqualTo(-1.0);
}
@Test
void activeState_reportsDaysRemaining() {
LicenseGate gate = new LicenseGate();
gate.load(new LicenseInfo(UUID.randomUUID(), "default", "test",
Map.of(),
Instant.now().minusSeconds(86400),
Instant.now().plus(10, ChronoUnit.DAYS),
0));
LicenseRepository repo = mock(LicenseRepository.class);
when(repo.findByTenantId("default")).thenReturn(Optional.empty());
SimpleMeterRegistry meters = new SimpleMeterRegistry();
var metrics = new LicenseMetrics(gate, repo, meters, "default");
metrics.refresh();
assertThat(meters.find("cameleer_license_state").tag("state", "ACTIVE").gauge().value())
.isEqualTo(1.0);
assertThat(meters.find("cameleer_license_state").tag("state", "ABSENT").gauge().value())
.isEqualTo(0.0);
assertThat(meters.find("cameleer_license_days_remaining").gauge().value())
.isBetween(9.0, 10.5);
}
}

View File

@@ -0,0 +1,26 @@
package com.cameleer.server.app.license;
import org.junit.jupiter.api.Test;
import static org.mockito.Mockito.doThrow;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verify;
class LicenseRevalidationJobTest {
@Test
void daily_callsService() {
LicenseService svc = mock(LicenseService.class);
new LicenseRevalidationJob(svc).daily();
verify(svc).revalidate();
}
@Test
void daily_swallowsServiceException() {
LicenseService svc = mock(LicenseService.class);
doThrow(new RuntimeException("revalidate failed")).when(svc).revalidate();
// No exception escapes
new LicenseRevalidationJob(svc).daily();
verify(svc).revalidate();
}
}

View File

@@ -0,0 +1,100 @@
package com.cameleer.server.app.license;
import com.cameleer.server.core.admin.AuditCategory;
import com.cameleer.server.core.admin.AuditResult;
import com.cameleer.server.core.admin.AuditService;
import com.cameleer.server.core.license.LicenseGate;
import com.cameleer.server.core.license.LicenseInfo;
import com.cameleer.server.core.license.LicenseState;
import com.cameleer.server.core.license.LicenseValidator;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.context.ApplicationEventPublisher;
import java.time.Instant;
import java.util.Map;
import java.util.Optional;
import java.util.UUID;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.ArgumentMatchers.isNull;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
class LicenseServiceTest {
LicenseRepository repo;
LicenseGate gate;
AuditService audit;
ApplicationEventPublisher events;
LicenseValidator validator;
LicenseService svc;
@BeforeEach
void setUp() {
repo = mock(LicenseRepository.class);
gate = new LicenseGate();
audit = mock(AuditService.class);
events = mock(ApplicationEventPublisher.class);
validator = mock(LicenseValidator.class);
svc = new LicenseService("default", repo, gate, validator, audit, events);
}
@Test
void install_validToken_persistsAndPublishes() {
LicenseInfo info = new LicenseInfo(UUID.randomUUID(), "default", null,
Map.of("max_apps", 5), Instant.now(),
Instant.now().plusSeconds(86400), 0);
when(validator.validate("tok")).thenReturn(info);
when(repo.findByTenantId("default")).thenReturn(Optional.empty());
svc.install("tok", "alice", "api");
assertThat(gate.getCurrent()).isEqualTo(info);
assertThat(gate.getState()).isEqualTo(LicenseState.ACTIVE);
verify(repo).upsert(any(LicenseRecord.class));
verify(events).publishEvent(any(LicenseChangedEvent.class));
verify(audit).log(eq("alice"), eq("install_license"), eq(AuditCategory.LICENSE),
any(), any(), eq(AuditResult.SUCCESS), isNull());
}
@Test
void install_invalidToken_marksGateInvalidAndAudits() {
when(validator.validate("bad")).thenThrow(new SecurityException("signature failed"));
try {
svc.install("bad", "alice", "api");
} catch (Exception ignored) {}
assertThat(gate.getState()).isEqualTo(LicenseState.INVALID);
assertThat(gate.getInvalidReason()).contains("signature failed");
verify(repo, never()).upsert(any());
verify(audit).log(eq("alice"), eq("reject_license"), eq(AuditCategory.LICENSE),
any(), any(), eq(AuditResult.FAILURE), isNull());
}
@Test
void install_replacingExistingLicense_auditsReplace() {
LicenseInfo old = new LicenseInfo(UUID.randomUUID(), "default", null,
Map.of(), Instant.now(),
Instant.now().plusSeconds(86400), 0);
gate.load(old);
when(repo.findByTenantId("default")).thenReturn(Optional.of(
new LicenseRecord("default", "old", old.licenseId(),
Instant.now(), "system",
Instant.now().plusSeconds(86400), Instant.now())));
LicenseInfo fresh = new LicenseInfo(UUID.randomUUID(), "default", null,
Map.of(), Instant.now(),
Instant.now().plusSeconds(86400), 0);
when(validator.validate("new")).thenReturn(fresh);
svc.install("new", "alice", "api");
verify(audit).log(eq("alice"), eq("replace_license"), eq(AuditCategory.LICENSE),
any(), any(), eq(AuditResult.SUCCESS), isNull());
}
}

View File

@@ -0,0 +1,31 @@
package com.cameleer.server.app.license;
import com.cameleer.server.app.AbstractPostgresIT;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import static org.assertj.core.api.Assertions.assertThat;
class LicenseUsageReaderIT extends AbstractPostgresIT {
@Autowired LicenseUsageReader reader;
@BeforeEach
void cleanDb() {
// Defensive cleanup so the test is order-independent under Testcontainer reuse — sibling
// ITs may have left envs/apps that would otherwise inflate the snapshot counts.
jdbcTemplate.update("DELETE FROM deployments");
jdbcTemplate.update("DELETE FROM app_versions");
jdbcTemplate.update("DELETE FROM apps");
jdbcTemplate.update("DELETE FROM environments WHERE slug != 'default'");
}
@Test
void emptyDb_returnsZeros() {
var snap = reader.snapshot();
assertThat(snap.get("max_apps")).isEqualTo(0L);
assertThat(snap.get("max_environments")).isLessThanOrEqualTo(1L); // V1 seeds default env
assertThat(snap.get("max_total_cpu_millis")).isEqualTo(0L);
}
}

View File

@@ -0,0 +1,101 @@
package com.cameleer.server.app.license;
import com.cameleer.server.app.AbstractPostgresIT;
import com.cameleer.server.app.TestSecurityHelper;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.web.client.TestRestTemplate;
import org.springframework.http.HttpEntity;
import org.springframework.http.HttpMethod;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import static org.assertj.core.api.Assertions.assertThat;
/**
* Verifies that the {@code max_outbound_connections} cap from the default tier is enforced at
* {@code POST /api/v1/admin/outbound-connections}. Default tier
* {@code max_outbound_connections = 1}; with no license installed the gate is in
* {@link com.cameleer.server.core.license.LicenseState#ABSENT} and the defaults are
* authoritative. The first create succeeds; the second must be rejected with the structured
* 403 envelope produced by {@link LicenseExceptionAdvice}.
*/
class OutboundCapEnforcementIT extends AbstractPostgresIT {
@Autowired
private TestRestTemplate restTemplate;
@Autowired
private ObjectMapper objectMapper;
@Autowired
private TestSecurityHelper securityHelper;
private String adminJwt;
@BeforeEach
void setUp() {
adminJwt = securityHelper.adminToken();
// Defensive: a sibling IT may have left a license installed (LicenseGate is a singleton
// per Spring context; @SpringBootTest reuses contexts across ITs).
securityHelper.clearTestLicense();
// Strip outbound_connections so we start at zero — the cap is per-tenant.
jdbcTemplate.update("DELETE FROM outbound_connections");
// Seed user row for the JWT subject — outbound_connections.created_by FKs to users(user_id).
jdbcTemplate.update(
"INSERT INTO users (user_id, provider, email, display_name) VALUES (?, 'test', ?, ?) ON CONFLICT (user_id) DO NOTHING",
"test-admin", "test-admin@example.com", "test-admin");
}
@AfterEach
void tearDown() {
// Defensive cleanup — we never installed a license, but make sure later ITs see ABSENT.
securityHelper.clearTestLicense();
jdbcTemplate.update("DELETE FROM outbound_connections");
jdbcTemplate.update("DELETE FROM users WHERE user_id = 'test-admin'");
}
@Test
void createBeyondCap_returns403WithStateAndMessage() throws Exception {
// Default tier: max_outbound_connections = 1. First create succeeds; the second rejects.
String first = """
{"name":"hook-1","url":"https://hooks.example.com/1","method":"POST",
"tlsTrustMode":"SYSTEM_DEFAULT","auth":{}}""";
ResponseEntity<String> ok = restTemplate.exchange(
"/api/v1/admin/outbound-connections", HttpMethod.POST,
new HttpEntity<>(first, securityHelper.authHeaders(adminJwt)),
String.class);
assertThat(ok.getStatusCode()).isEqualTo(HttpStatus.CREATED);
String second = """
{"name":"hook-2","url":"https://hooks.example.com/2","method":"POST",
"tlsTrustMode":"SYSTEM_DEFAULT","auth":{}}""";
ResponseEntity<String> response = restTemplate.exchange(
"/api/v1/admin/outbound-connections", HttpMethod.POST,
new HttpEntity<>(second, securityHelper.authHeaders(adminJwt)),
String.class);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.FORBIDDEN);
JsonNode body = objectMapper.readTree(response.getBody());
assertThat(body.path("error").asText()).isEqualTo("license cap reached");
assertThat(body.path("limit").asText()).isEqualTo("max_outbound_connections");
assertThat(body.path("cap").asInt()).isEqualTo(1);
assertThat(body.path("state").asText()).isEqualTo("ABSENT");
assertThat(body.has("message")).isTrue();
assertThat(body.path("message").asText()).isNotBlank();
// And the second connection was NOT persisted.
Integer count = jdbcTemplate.queryForObject(
"SELECT COUNT(*) FROM outbound_connections WHERE name = 'hook-2'", Integer.class);
assertThat(count).isZero();
// Total connections still 1 — the rejection short-circuited before any insert.
Integer total = jdbcTemplate.queryForObject(
"SELECT COUNT(*) FROM outbound_connections", Integer.class);
assertThat(total).isEqualTo(1);
}
}

View File

@@ -0,0 +1,45 @@
package com.cameleer.server.app.license;
import com.cameleer.server.app.AbstractPostgresIT;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import java.time.Instant;
import java.time.temporal.ChronoUnit;
import java.util.UUID;
import static org.assertj.core.api.Assertions.assertThat;
class PostgresLicenseRepositoryIT extends AbstractPostgresIT {
@Autowired LicenseRepository repo;
@Test
void roundTrip() {
UUID id = UUID.randomUUID();
Instant now = Instant.now().truncatedTo(ChronoUnit.MILLIS);
LicenseRecord rec = new LicenseRecord(
"default", "tok.sig", id, now, "system",
now.plus(365, ChronoUnit.DAYS), now);
repo.upsert(rec);
var loaded = repo.findByTenantId("default").orElseThrow();
assertThat(loaded.licenseId()).isEqualTo(id);
assertThat(loaded.installedBy()).isEqualTo("system");
}
@Test
void touchValidated_updatesTimestamp() throws Exception {
UUID id = UUID.randomUUID();
Instant t0 = Instant.now().truncatedTo(ChronoUnit.MILLIS);
repo.upsert(new LicenseRecord("default", "tok.sig", id, t0, "system",
t0.plus(7, ChronoUnit.DAYS), t0));
Thread.sleep(10);
Instant t1 = Instant.now().truncatedTo(ChronoUnit.MILLIS);
int affected = repo.touchValidated("default", t1);
assertThat(affected).isEqualTo(1);
assertThat(repo.findByTenantId("default").orElseThrow().lastValidatedAt())
.isAfterOrEqualTo(t1);
}
}

View File

@@ -0,0 +1,112 @@
package com.cameleer.server.app.license;
import com.cameleer.server.app.AbstractPostgresIT;
import com.cameleer.server.app.TestSecurityHelper;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.web.client.TestRestTemplate;
import org.springframework.http.HttpEntity;
import org.springframework.http.HttpMethod;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import static org.assertj.core.api.Assertions.assertThat;
/**
* Verifies that the {@code max_jar_retention_count} cap is enforced at
* {@code PUT /api/v1/admin/environments/{envSlug}/jar-retention}. The default tier cap is 3,
* so no synthetic license is installed — the rejection is exercised against the baseline.
*
* <p>Returns 422 UNPROCESSABLE_ENTITY (not 403) because retention is a value-out-of-range
* rejection, not a creation-quota rejection — so {@code LicenseExceptionAdvice} is intentionally
* bypassed in favour of {@link org.springframework.web.server.ResponseStatusException}.
*/
class RetentionCapEnforcementIT extends AbstractPostgresIT {
@Autowired
private TestRestTemplate restTemplate;
@Autowired
private ObjectMapper objectMapper;
@Autowired
private TestSecurityHelper securityHelper;
private String adminJwt;
@BeforeEach
void setUp() {
adminJwt = securityHelper.adminToken();
// Defensive: a sibling IT may have left a license installed.
securityHelper.clearTestLicense();
// Strip non-default envs (and FK dependents) so we land on a clean baseline.
jdbcTemplate.update("DELETE FROM deployments");
jdbcTemplate.update("DELETE FROM app_versions");
jdbcTemplate.update("DELETE FROM apps");
jdbcTemplate.update("DELETE FROM environments WHERE slug != 'default'");
}
@AfterEach
void tearDown() {
securityHelper.clearTestLicense();
jdbcTemplate.update("DELETE FROM deployments");
jdbcTemplate.update("DELETE FROM app_versions");
jdbcTemplate.update("DELETE FROM apps");
jdbcTemplate.update("DELETE FROM environments WHERE slug != 'default'");
}
@Test
void putJarRetention_aboveCap_returns422() throws Exception {
// Default tier cap = 3; request 30.
String body = """
{"jarRetentionCount": 30}
""";
ResponseEntity<String> response = restTemplate.exchange(
"/api/v1/admin/environments/default/jar-retention", HttpMethod.PUT,
new HttpEntity<>(body, securityHelper.authHeaders(adminJwt)),
String.class);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.UNPROCESSABLE_ENTITY);
// ResponseStatusException renders into Spring Boot's default error JSON; the reason
// appears in the "message" field (or the body somewhere). Don't pin to a specific shape;
// just verify the diagnostic text reached the wire.
String responseBody = response.getBody() == null ? "" : response.getBody();
assertThat(responseBody)
.containsAnyOf("max_jar_retention_count", "license cap");
}
@Test
void putJarRetention_atCap_returns200() throws Exception {
// Default tier cap = 3; request exactly 3.
String body = """
{"jarRetentionCount": 3}
""";
ResponseEntity<String> response = restTemplate.exchange(
"/api/v1/admin/environments/default/jar-retention", HttpMethod.PUT,
new HttpEntity<>(body, securityHelper.authHeaders(adminJwt)),
String.class);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
JsonNode envBody = objectMapper.readTree(response.getBody());
assertThat(envBody.path("jarRetentionCount").asInt()).isEqualTo(3);
}
@Test
void putJarRetention_nullValue_returns200_unlimited() throws Exception {
// null = unlimited (no cap check fires). Important regression: ensure the cap-check
// guard remains a `requested != null` short-circuit and doesn't reject unlimited mode.
String body = """
{"jarRetentionCount": null}
""";
ResponseEntity<String> response = restTemplate.exchange(
"/api/v1/admin/environments/default/jar-retention", HttpMethod.PUT,
new HttpEntity<>(body, securityHelper.authHeaders(adminJwt)),
String.class);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
}
}

View File

@@ -0,0 +1,194 @@
package com.cameleer.server.app.license;
import com.cameleer.server.core.license.LicenseGate;
import com.cameleer.server.core.license.LicenseInfo;
import com.cameleer.server.core.license.LicenseState;
import com.cameleer.server.core.runtime.Environment;
import com.cameleer.server.core.runtime.EnvironmentRepository;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.mockito.ArgumentCaptor;
import org.springframework.jdbc.core.JdbcTemplate;
import java.time.Instant;
import java.util.List;
import java.util.Map;
import java.util.UUID;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatCode;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.Mockito.doThrow;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
class RetentionPolicyApplierTest {
EnvironmentRepository envRepo;
JdbcTemplate ch;
LicenseGate gate;
RetentionPolicyApplier applier;
@BeforeEach
void setUp() {
envRepo = mock(EnvironmentRepository.class);
ch = mock(JdbcTemplate.class);
gate = new LicenseGate();
applier = new RetentionPolicyApplier(gate, envRepo, ch);
}
private static Environment env(String slug, int execDays, int logDays, int metricDays) {
return new Environment(
UUID.randomUUID(), slug, slug, false, true,
Map.of(), null, "slate", Instant.now(),
execDays, logDays, metricDays);
}
private void licenseWithCaps(int execCap, int logCap, int metricCap) {
LicenseInfo info = new LicenseInfo(
UUID.randomUUID(), "default", null,
Map.of(
"max_execution_retention_days", execCap,
"max_log_retention_days", logCap,
"max_metric_retention_days", metricCap
),
Instant.now(),
Instant.now().plusSeconds(86400),
0);
gate.load(info);
}
private static LicenseChangedEvent ev() {
return new LicenseChangedEvent(LicenseState.ACTIVE, null);
}
@Test
void onChange_emitsAlterPerTablePerEnv() {
licenseWithCaps(30, 30, 30);
Environment dev = env("dev", 30, 30, 30);
when(envRepo.findAll()).thenReturn(List.of(dev));
applier.onLicenseChanged(ev());
// 5 retention-bound tables defined in SPECS
ArgumentCaptor<String> sql = ArgumentCaptor.forClass(String.class);
verify(ch, times(RetentionPolicyApplier.SPECS.size())).execute(sql.capture());
List<String> all = sql.getAllValues();
// every statement is an ALTER TABLE … MODIFY TTL … WHERE environment = 'dev'
assertThat(all).allSatisfy(s -> {
assertThat(s).startsWith("ALTER TABLE ");
assertThat(s).contains("MODIFY TTL toDateTime(");
assertThat(s).contains(" DAY DELETE");
assertThat(s).endsWith(" WHERE environment = 'dev'");
});
// Sanity-check the per-table time column wiring
assertThat(findFor(all, "executions"))
.contains("toDateTime(start_time)");
assertThat(findFor(all, "processor_executions"))
.contains("toDateTime(start_time)");
assertThat(findFor(all, "logs"))
.contains("toDateTime(timestamp)");
assertThat(findFor(all, "agent_metrics"))
.contains("toDateTime(collected_at)");
assertThat(findFor(all, "agent_events"))
.contains("toDateTime(timestamp)");
}
@Test
void chFailure_doesNotPropagate() {
licenseWithCaps(30, 30, 30);
when(envRepo.findAll()).thenReturn(List.of(env("dev", 30, 30, 30)));
doThrow(new RuntimeException("ch down")).when(ch).execute(anyString());
assertThatCode(() -> applier.onLicenseChanged(ev()))
.doesNotThrowAnyException();
// listener still attempted every (env, table) pair despite failures
verify(ch, times(RetentionPolicyApplier.SPECS.size())).execute(anyString());
}
@Test
void multipleEnvs_emitsPerEnvAlter() {
licenseWithCaps(30, 30, 30);
when(envRepo.findAll()).thenReturn(List.of(
env("dev", 30, 30, 30),
env("prod", 30, 30, 30)
));
applier.onLicenseChanged(ev());
ArgumentCaptor<String> sql = ArgumentCaptor.forClass(String.class);
int expected = RetentionPolicyApplier.SPECS.size() * 2;
verify(ch, times(expected)).execute(sql.capture());
long devCount = sql.getAllValues().stream()
.filter(s -> s.endsWith("WHERE environment = 'dev'")).count();
long prodCount = sql.getAllValues().stream()
.filter(s -> s.endsWith("WHERE environment = 'prod'")).count();
assertThat(devCount).isEqualTo(RetentionPolicyApplier.SPECS.size());
assertThat(prodCount).isEqualTo(RetentionPolicyApplier.SPECS.size());
}
@Test
void effectiveDaysIsMinOfCapAndConfigured_capWins() {
// env wants 30 days but license caps at 7 → expect INTERVAL 7 DAY
licenseWithCaps(7, 7, 7);
when(envRepo.findAll()).thenReturn(List.of(env("dev", 30, 30, 30)));
applier.onLicenseChanged(ev());
ArgumentCaptor<String> sql = ArgumentCaptor.forClass(String.class);
verify(ch, times(RetentionPolicyApplier.SPECS.size())).execute(sql.capture());
assertThat(sql.getAllValues())
.allSatisfy(s -> assertThat(s).contains("INTERVAL 7 DAY DELETE"));
}
@Test
void effectiveDaysIsMinOfCapAndConfigured_configuredWins() {
// env wants 3 days, license allows up to 7 → expect INTERVAL 3 DAY
licenseWithCaps(7, 7, 7);
when(envRepo.findAll()).thenReturn(List.of(env("dev", 3, 3, 3)));
applier.onLicenseChanged(ev());
ArgumentCaptor<String> sql = ArgumentCaptor.forClass(String.class);
verify(ch, times(RetentionPolicyApplier.SPECS.size())).execute(sql.capture());
assertThat(sql.getAllValues())
.allSatisfy(s -> assertThat(s).contains("INTERVAL 3 DAY DELETE"));
}
@Test
void mixedCapAndConfigured_perTable() {
// distinct caps per axis; env exec=10, log=2, metric=50
// exec : min(20, 10) = 10
// log : min(20, 2) = 2
// metric: min(20, 50) = 20
licenseWithCaps(20, 20, 20);
when(envRepo.findAll()).thenReturn(List.of(env("dev", 10, 2, 50)));
applier.onLicenseChanged(ev());
ArgumentCaptor<String> sql = ArgumentCaptor.forClass(String.class);
verify(ch, times(RetentionPolicyApplier.SPECS.size())).execute(sql.capture());
List<String> all = sql.getAllValues();
assertThat(findFor(all, "executions")).contains("INTERVAL 10 DAY");
assertThat(findFor(all, "processor_executions")).contains("INTERVAL 10 DAY");
assertThat(findFor(all, "logs")).contains("INTERVAL 2 DAY");
assertThat(findFor(all, "agent_metrics")).contains("INTERVAL 20 DAY");
assertThat(findFor(all, "agent_events")).contains("INTERVAL 20 DAY");
}
/** Pick the single SQL statement that targets the given table. */
private static String findFor(List<String> all, String table) {
String prefix = "ALTER TABLE " + table + " ";
return all.stream()
.filter(s -> s.startsWith(prefix))
.findFirst()
.orElseThrow(() -> new AssertionError("no SQL for table " + table + " in: " + all));
}
}

View File

@@ -0,0 +1,128 @@
package com.cameleer.server.app.license;
import com.cameleer.server.app.AbstractPostgresIT;
import com.cameleer.server.app.TestSecurityHelper;
import com.cameleer.server.core.license.LicenseGate;
import com.cameleer.server.core.license.LicenseState;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.jdbc.core.JdbcTemplate;
import java.util.Map;
import static org.assertj.core.api.Assertions.assertThat;
/**
* Integration test for Spec §4.3 — when a license changes, {@link RetentionPolicyApplier}
* re-issues {@code ALTER TABLE … MODIFY TTL … WHERE environment = '<slug>'} statements
* against ClickHouse so per-env retention reflects the new {@code min(licenseCap, env.configured)}.
*
* <p><b>Strategy:</b> the applier is an {@code @Async @EventListener}. To avoid timing
* flakiness, this IT calls {@link RetentionPolicyApplier#onLicenseChanged} synchronously
* after seeding the {@link LicenseGate} via
* {@link TestSecurityHelper#installSyntheticUnsignedLicense(Map)}. The async dispatch
* and the SQL-shape correctness are already covered by {@code RetentionPolicyApplierTest}
* (unit, mocked CH); this IT adds the missing real-CH ALTER round-trip.</p>
*
* <p><b>Env retention setup:</b> V1 seeds the default env with retention=1 day. To make
* the license cap the binding constraint of {@code min(cap, configured)}, this test
* raises the {@code default} env's {@code log_retention_days} to 60 in {@code @BeforeEach}
* via raw JDBC (the {@code EnvironmentRepository} interface deliberately does not expose
* retention-day setters — admin endpoints exist for them, but the controller plumbing is
* out of scope here). Restored to 1 in {@code @AfterEach}.</p>
*
* <p><b>Assertion target:</b> {@code system.tables.create_table_query} in ClickHouse
* reflects the most recent {@code MODIFY TTL} clause as a row-level TTL with WHERE
* predicate (CH 22.3+; project runs 24.12). Polls up to 5s for any other ALTERs that
* the runtime may have queued.</p>
*/
class RetentionRuntimeRecomputeIT extends AbstractPostgresIT {
@Autowired
@Qualifier("clickHouseJdbcTemplate")
JdbcTemplate clickHouseJdbc;
@Autowired
TestSecurityHelper securityHelper;
@Autowired
LicenseGate gate;
@Autowired
RetentionPolicyApplier applier;
@BeforeEach
void seedHighEnvRetention() {
// Sibling ITs may have left a license in the gate — clear so default tier is the baseline.
securityHelper.clearTestLicense();
// Raise default env's log retention to 60 so license cap becomes the binding constraint.
jdbcTemplate.update(
"UPDATE environments SET log_retention_days = 60 WHERE slug = 'default'");
}
@AfterEach
void restoreDefaults() {
securityHelper.clearTestLicense();
jdbcTemplate.update(
"UPDATE environments SET log_retention_days = 1 WHERE slug = 'default'");
// Re-fire applier with cleared gate (default tier, log cap = 1) so subsequent ITs
// observe the schema's seeded TTL, not whatever this test last set.
applier.onLicenseChanged(new LicenseChangedEvent(gate.getState(), gate.getCurrent()));
}
@Test
void changingLicenseRecomputesLogsTtl() throws Exception {
// (1) Install license with cap = 30. With env.configured = 60, effective = min(30, 60) = 30.
securityHelper.installSyntheticUnsignedLicense(Map.of("max_log_retention_days", 30));
assertThat(gate.getState()).isEqualTo(LicenseState.ACTIVE);
assertThat(gate.getEffectiveLimits().get("max_log_retention_days")).isEqualTo(30);
applier.onLicenseChanged(new LicenseChangedEvent(gate.getState(), gate.getCurrent()));
awaitTtlInterval("logs", 30, 5_000);
// (2) Replace license: cap = 7. effective = min(7, 60) = 7.
securityHelper.clearTestLicense();
securityHelper.installSyntheticUnsignedLicense(Map.of("max_log_retention_days", 7));
assertThat(gate.getEffectiveLimits().get("max_log_retention_days")).isEqualTo(7);
applier.onLicenseChanged(new LicenseChangedEvent(gate.getState(), gate.getCurrent()));
awaitTtlInterval("logs", 7, 5_000);
}
/**
* Polls {@code system.tables.create_table_query} until it contains the expected
* day-interval fragment for the given environment, or the deadline elapses. The
* synchronous applier call already completed before this is invoked — the poll
* just guards against any latent ClickHouse internal propagation delay between
* the ALTER returning and {@code system.tables} reflecting the change.
*
* <p>ClickHouse normalises {@code INTERVAL N DAY} in the stored {@code TTL}
* clause to {@code toIntervalDay(N)} when serialising back to {@code create_table_query},
* so we match the canonical form. The {@code WHERE environment = '<slug>'}
* predicate is included so a stale TTL for a different env can't satisfy the
* assertion.</p>
*/
private void awaitTtlInterval(String table, int days, long timeoutMs) throws InterruptedException {
long deadline = System.currentTimeMillis() + timeoutMs;
String fragment = "toIntervalDay(" + days + ") WHERE environment = 'default'";
String last = null;
while (System.currentTimeMillis() < deadline) {
last = clickHouseJdbc.queryForObject(
"SELECT create_table_query FROM system.tables "
+ "WHERE name = ? AND database = currentDatabase()",
String.class, table);
if (last != null && last.contains(fragment)) {
return;
}
Thread.sleep(100);
}
throw new AssertionError(
"Timed out waiting for " + table + " TTL to contain '" + fragment
+ "'. Last create_table_query:\n" + last);
}
}

View File

@@ -0,0 +1,119 @@
package com.cameleer.server.app.license;
import com.cameleer.server.app.AbstractPostgresIT;
import com.cameleer.server.app.TestSecurityHelper;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.web.client.TestRestTemplate;
import org.springframework.http.HttpEntity;
import org.springframework.http.HttpMethod;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import java.util.Map;
import static org.assertj.core.api.Assertions.assertThat;
/**
* Verifies that the {@code max_users} cap is enforced at
* {@code POST /api/v1/admin/users}. The IT installs a synthetic license that lowers the cap to
* {@code 2} so the rejection lands on a small number of HTTP calls. The structured 403 envelope
* is produced by {@link LicenseExceptionAdvice}; the {@code cap_exceeded} audit row is written
* by {@link LicenseEnforcer} when its 3-arg ctor wires {@code AuditService} (T16).
*/
class UserCapEnforcementIT extends AbstractPostgresIT {
@Autowired
private TestRestTemplate restTemplate;
@Autowired
private ObjectMapper objectMapper;
@Autowired
private TestSecurityHelper securityHelper;
private String adminJwt;
@BeforeEach
void setUp() {
adminJwt = securityHelper.adminToken();
// Defensive: a sibling IT may have left a license installed (LicenseGate is a singleton
// per Spring context; @SpringBootTest reuses contexts across ITs).
securityHelper.clearTestLicense();
// Strip user_roles (FK to users) before users themselves.
jdbcTemplate.update("DELETE FROM user_roles");
jdbcTemplate.update("DELETE FROM user_groups");
jdbcTemplate.update("DELETE FROM users");
// Lower max_users to 2 so the cap rejection lands on the third create call.
securityHelper.installSyntheticUnsignedLicense(Map.of("max_users", 2));
// Clear stale audit rows so the cap_exceeded assertion is unambiguous.
jdbcTemplate.update("DELETE FROM audit_log WHERE category = 'LICENSE'");
}
@AfterEach
void tearDown() {
securityHelper.clearTestLicense();
jdbcTemplate.update("DELETE FROM user_roles");
jdbcTemplate.update("DELETE FROM user_groups");
jdbcTemplate.update("DELETE FROM users");
}
private ResponseEntity<String> createUser(String username) {
// Password meets the policy (12+ chars, 3-of-4 character classes, doesn't match username).
String body = """
{
"username": "%s",
"displayName": "User %s",
"email": "%s@example.com",
"password": "Sup3rSecret-Pass!"
}
""".formatted(username, username, username);
return restTemplate.exchange(
"/api/v1/admin/users", HttpMethod.POST,
new HttpEntity<>(body, securityHelper.authHeaders(adminJwt)),
String.class);
}
@Test
void createBeyondCap_returns403WithStateAndMessage() throws Exception {
// Synthetic license: max_users = 2. Two creates succeed; the third rejects.
assertThat(createUser("alice").getStatusCode()).isEqualTo(HttpStatus.OK);
assertThat(createUser("bob").getStatusCode()).isEqualTo(HttpStatus.OK);
ResponseEntity<String> third = createUser("charlie");
assertThat(third.getStatusCode()).isEqualTo(HttpStatus.FORBIDDEN);
JsonNode body = objectMapper.readTree(third.getBody());
assertThat(body.path("error").asText()).isEqualTo("license cap reached");
assertThat(body.path("limit").asText()).isEqualTo("max_users");
assertThat(body.path("cap").asInt()).isEqualTo(2);
// We installed a synthetic license, so the gate is ACTIVE.
assertThat(body.path("state").asText()).isEqualTo("ACTIVE");
assertThat(body.has("message")).isTrue();
assertThat(body.path("message").asText()).isNotBlank();
// The third user was NOT persisted.
Integer count = jdbcTemplate.queryForObject(
"SELECT COUNT(*) FROM users WHERE user_id = 'charlie'", Integer.class);
assertThat(count).isZero();
// Total users still 2 — the rejection short-circuited before any upsert.
Integer total = jdbcTemplate.queryForObject(
"SELECT COUNT(*) FROM users", Integer.class);
assertThat(total).isEqualTo(2);
// LicenseEnforcer (3-arg ctor from T16) wrote the cap_exceeded audit row.
Integer auditCount = jdbcTemplate.queryForObject("""
SELECT COUNT(*) FROM audit_log
WHERE category = 'LICENSE'
AND action = 'cap_exceeded'
AND target = 'max_users'
AND result = 'FAILURE'
""", Integer.class);
assertThat(auditCount).isEqualTo(1);
}
}

View File

@@ -39,6 +39,8 @@ class OutboundConnectionAdminControllerIT extends AbstractPostgresIT {
jdbcTemplate.update(
"DELETE FROM deployments WHERE created_by IN ('test-admin','test-operator','test-viewer')");
jdbcTemplate.update("DELETE FROM users WHERE user_id IN ('test-admin','test-operator','test-viewer')");
// Clear the lifted license so later ITs see ABSENT state.
securityHelper.clearTestLicense();
}
@BeforeEach
@@ -51,6 +53,10 @@ class OutboundConnectionAdminControllerIT extends AbstractPostgresIT {
seedUser("test-operator");
seedUser("test-viewer");
jdbcTemplate.update("DELETE FROM outbound_connections WHERE tenant_id = 'default'");
// Lift the max_outbound_connections cap — duplicateNameReturns409 needs 2 creates,
// and the default tier caps at 1. Other tests in this class don't exceed the cap
// but lifting at the class level keeps the suite robust against ordering surprises.
securityHelper.installSyntheticUnsignedLicense(java.util.Map.of("max_outbound_connections", 100));
}
private void seedUser(String userId) {

View File

@@ -66,6 +66,14 @@ class BlueGreenStrategyIT extends AbstractPostgresIT {
jdbcTemplate.update(
"INSERT INTO users (user_id, provider, display_name) VALUES ('test-operator', 'local', 'Test Operator') ON CONFLICT (user_id) DO NOTHING");
// Lift compute caps so the two sequential deploys (2 reps each, 512 MB default) plus any
// residual non-stopped row from a sibling IT under testcontainer reuse don't trip the
// license enforcer at PRE_FLIGHT.
securityHelper.installSyntheticUnsignedLicense(java.util.Map.of(
"max_total_cpu_millis", 100_000,
"max_total_memory_mb", 100_000,
"max_total_replicas", 100));
when(runtimeOrchestrator.isEnabled()).thenReturn(true);
appSlug = "bg-" + UUID.randomUUID().toString().substring(0, 8);

View File

@@ -73,6 +73,14 @@ class DeploymentSnapshotIT extends AbstractPostgresIT {
// Ensure test-operator exists in users table (required for deployments.created_by FK)
jdbcTemplate.update(
"INSERT INTO users (user_id, provider, display_name) VALUES ('test-operator', 'local', 'Test Operator') ON CONFLICT (user_id) DO NOTHING");
// Lift compute caps — guarantees these snapshot tests are not derailed by license
// enforcement when residual non-stopped deploys from a sibling IT inflate aggregates
// under testcontainer reuse.
securityHelper.installSyntheticUnsignedLicense(java.util.Map.of(
"max_total_cpu_millis", 100_000,
"max_total_memory_mb", 100_000,
"max_total_replicas", 100));
}
// -----------------------------------------------------------------------

View File

@@ -0,0 +1,207 @@
package com.cameleer.server.app.runtime;
import com.cameleer.server.core.runtime.ContainerRequest;
import com.github.dockerjava.api.DockerClient;
import com.github.dockerjava.api.command.CreateContainerCmd;
import com.github.dockerjava.api.command.CreateContainerResponse;
import com.github.dockerjava.api.command.InfoCmd;
import com.github.dockerjava.api.command.StartContainerCmd;
import com.github.dockerjava.api.model.Capability;
import com.github.dockerjava.api.model.HostConfig;
import com.github.dockerjava.api.model.Info;
import org.junit.jupiter.api.Test;
import org.mockito.Answers;
import org.mockito.ArgumentCaptor;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
/**
* Verifies the multi-tenant hardening contract from issue #152: every tenant
* container is launched with cap_drop ALL, no-new-privileges, AppArmor profile,
* read-only rootfs, a pids limit, and a writeable /tmp tmpfs. Also verifies the
* runsc auto-detect via `docker info` and the explicit override.
*/
class DockerRuntimeOrchestratorHardeningTest {
private static ContainerRequest sampleRequest() {
return new ContainerRequest(
"tenant-app-0-abcd1234",
"registry.example/runtime:latest",
"/data/jars/app.jar",
null, null,
"tenant-net",
List.of(),
Map.of("CAMELEER_AGENT_APPLICATION", "myapp"),
Map.of(),
512L * 1024 * 1024,
null,
512,
null,
List.of(8080),
9464,
"on-failure",
3,
"spring-boot",
"",
null);
}
private static DockerClient mockDockerClientWithRuntimes(Map<String, ?> runtimes) {
DockerClient dockerClient = mock(DockerClient.class);
InfoCmd infoCmd = mock(InfoCmd.class);
Info info = mock(Info.class);
when(dockerClient.infoCmd()).thenReturn(infoCmd);
when(infoCmd.exec()).thenReturn(info);
when(info.getRuntimes()).thenReturn((Map) runtimes);
return dockerClient;
}
@Test
void resolveRuntime_picksRunscWhenDaemonHasIt() {
DockerClient dockerClient = mockDockerClientWithRuntimes(Map.of(
"runc", new Object(),
"runsc", new Object()));
DockerRuntimeOrchestrator orchestrator = new DockerRuntimeOrchestrator(dockerClient, "");
assertThat(orchestrator.getDockerRuntime()).isEqualTo("runsc");
}
@Test
void resolveRuntime_returnsEmptyWhenSandboxedRuntimeMissing() {
DockerClient dockerClient = mockDockerClientWithRuntimes(Map.of("runc", new Object()));
DockerRuntimeOrchestrator orchestrator = new DockerRuntimeOrchestrator(dockerClient, "");
assertThat(orchestrator.getDockerRuntime()).isEmpty();
}
@Test
void resolveRuntime_overrideWinsOverAutoDetect() {
DockerClient dockerClient = mockDockerClientWithRuntimes(Map.of(
"runc", new Object(),
"runsc", new Object()));
DockerRuntimeOrchestrator orchestrator = new DockerRuntimeOrchestrator(dockerClient, "kata");
assertThat(orchestrator.getDockerRuntime()).isEqualTo("kata");
}
@Test
void resolveRuntime_blankOverrideTreatedAsAuto() {
DockerClient dockerClient = mockDockerClientWithRuntimes(Map.of("runsc", new Object()));
DockerRuntimeOrchestrator orchestrator = new DockerRuntimeOrchestrator(dockerClient, " ");
assertThat(orchestrator.getDockerRuntime()).isEqualTo("runsc");
}
@Test
void resolveRuntime_swallowsDockerInfoFailure() {
DockerClient dockerClient = mock(DockerClient.class);
InfoCmd infoCmd = mock(InfoCmd.class);
when(dockerClient.infoCmd()).thenReturn(infoCmd);
when(infoCmd.exec()).thenThrow(new RuntimeException("docker daemon unreachable"));
DockerRuntimeOrchestrator orchestrator = new DockerRuntimeOrchestrator(dockerClient, "");
assertThat(orchestrator.getDockerRuntime()).isEmpty();
}
@Test
void startContainer_appliesHardeningContractToHostConfig() {
DockerClient dockerClient = mockDockerClientWithRuntimes(new HashMap<>());
CreateContainerCmd createCmd = mock(CreateContainerCmd.class, Answers.RETURNS_SELF);
when(dockerClient.createContainerCmd(anyString())).thenReturn(createCmd);
CreateContainerResponse createResponse = mock(CreateContainerResponse.class);
when(createResponse.getId()).thenReturn("container-id-1");
when(createCmd.exec()).thenReturn(createResponse);
StartContainerCmd startCmd = mock(StartContainerCmd.class);
when(dockerClient.startContainerCmd(anyString())).thenReturn(startCmd);
DockerRuntimeOrchestrator orchestrator = new DockerRuntimeOrchestrator(dockerClient, "");
orchestrator.startContainer(sampleRequest());
ArgumentCaptor<HostConfig> hostCaptor = ArgumentCaptor.forClass(HostConfig.class);
org.mockito.Mockito.verify(createCmd).withHostConfig(hostCaptor.capture());
HostConfig hc = hostCaptor.getValue();
// cap_drop ALL — every capability the SDK knows about
assertThat(hc.getCapDrop())
.as("cap_drop should drop every capability")
.containsExactlyInAnyOrder(Capability.values());
// no-new-privileges + apparmor stock profile
assertThat(hc.getSecurityOpts())
.as("security_opt must include no-new-privileges and apparmor=docker-default")
.contains("no-new-privileges:true", "apparmor=docker-default");
// readonly rootfs
assertThat(hc.getReadonlyRootfs())
.as("read_only rootfs must be enabled")
.isTrue();
// pids-limit applied
assertThat(hc.getPidsLimit())
.as("pids_limit must be set to bound fork-bomb damage")
.isNotNull()
.isPositive();
// /tmp tmpfs writable, nosuid, no `noexec` (would break JNI dlopen)
assertThat(hc.getTmpFs())
.as("/tmp must be a writeable tmpfs")
.containsKey("/tmp");
String tmpOpts = hc.getTmpFs().get("/tmp");
assertThat(tmpOpts).contains("rw").contains("nosuid").doesNotContain("noexec");
}
@Test
void startContainer_doesNotForceRuntimeWhenAutoDetectFindsNothing() {
DockerClient dockerClient = mockDockerClientWithRuntimes(Map.of("runc", new Object()));
CreateContainerCmd createCmd = mock(CreateContainerCmd.class, Answers.RETURNS_SELF);
when(dockerClient.createContainerCmd(anyString())).thenReturn(createCmd);
CreateContainerResponse createResponse = mock(CreateContainerResponse.class);
when(createResponse.getId()).thenReturn("c");
when(createCmd.exec()).thenReturn(createResponse);
when(dockerClient.startContainerCmd(anyString())).thenReturn(mock(StartContainerCmd.class));
DockerRuntimeOrchestrator orchestrator = new DockerRuntimeOrchestrator(dockerClient, "");
orchestrator.startContainer(sampleRequest());
ArgumentCaptor<HostConfig> hostCaptor = ArgumentCaptor.forClass(HostConfig.class);
org.mockito.Mockito.verify(createCmd).withHostConfig(hostCaptor.capture());
// When daemon has no sandboxed runtime, we leave runtime null/empty so Docker picks its default.
String runtime = hostCaptor.getValue().getRuntime();
assertThat(runtime == null || runtime.isBlank())
.as("no runtime should be forced when sandboxed runtime unavailable")
.isTrue();
}
@Test
void startContainer_appliesRunscWhenAvailable() {
DockerClient dockerClient = mockDockerClientWithRuntimes(Map.of("runsc", new Object()));
CreateContainerCmd createCmd = mock(CreateContainerCmd.class, Answers.RETURNS_SELF);
when(dockerClient.createContainerCmd(anyString())).thenReturn(createCmd);
CreateContainerResponse createResponse = mock(CreateContainerResponse.class);
when(createResponse.getId()).thenReturn("c");
when(createCmd.exec()).thenReturn(createResponse);
when(dockerClient.startContainerCmd(anyString())).thenReturn(mock(StartContainerCmd.class));
DockerRuntimeOrchestrator orchestrator = new DockerRuntimeOrchestrator(dockerClient, "");
orchestrator.startContainer(sampleRequest());
ArgumentCaptor<HostConfig> hostCaptor = ArgumentCaptor.forClass(HostConfig.class);
org.mockito.Mockito.verify(createCmd).withHostConfig(hostCaptor.capture());
assertThat(hostCaptor.getValue().getRuntime()).isEqualTo("runsc");
}
}

View File

@@ -69,6 +69,14 @@ class RollingStrategyIT extends AbstractPostgresIT {
jdbcTemplate.update(
"INSERT INTO users (user_id, provider, display_name) VALUES ('test-operator', 'local', 'Test Operator') ON CONFLICT (user_id) DO NOTHING");
// Lift compute caps so the two sequential deploys (2 reps each, 512 MB default) plus any
// residual non-stopped row from a sibling IT under testcontainer reuse don't trip the
// license enforcer at PRE_FLIGHT.
securityHelper.installSyntheticUnsignedLicense(java.util.Map.of(
"max_total_cpu_millis", 100_000,
"max_total_memory_mb", 100_000,
"max_total_replicas", 100));
when(runtimeOrchestrator.isEnabled()).thenReturn(true);
appSlug = "roll-" + UUID.randomUUID().toString().substring(0, 8);

View File

@@ -5,6 +5,8 @@ import com.cameleer.server.app.TestSecurityHelper;
import com.cameleer.server.core.security.JwtService;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.web.client.TestRestTemplate;
@@ -15,6 +17,8 @@ import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import java.util.Map;
import static org.assertj.core.api.Assertions.assertThat;
/**
@@ -34,6 +38,18 @@ class JwtRefreshIT extends AbstractPostgresIT {
@Autowired
private JwtService jwtService;
@BeforeEach
void setUp() {
// Lift max_agents cap so this IT (which registers agents per test) isn't gated
// by license enforcement. Cap behaviour itself is exercised by AgentCapEnforcementIT.
securityHelper.installSyntheticUnsignedLicense(Map.of("max_agents", 100));
}
@AfterEach
void tearDown() {
securityHelper.clearTestLicense();
}
private JsonNode registerAndGetTokens(String agentId) throws Exception {
String json = """
{

View File

@@ -1,8 +1,11 @@
package com.cameleer.server.app.security;
import com.cameleer.server.app.AbstractPostgresIT;
import com.cameleer.server.app.TestSecurityHelper;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.web.client.TestRestTemplate;
@@ -13,6 +16,8 @@ import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import java.util.Map;
import static org.assertj.core.api.Assertions.assertThat;
/**
@@ -27,6 +32,21 @@ class RegistrationSecurityIT extends AbstractPostgresIT {
@Autowired
private ObjectMapper objectMapper;
@Autowired
private TestSecurityHelper securityHelper;
@BeforeEach
void setUp() {
// Lift max_agents cap so this IT (which registers agents per test) isn't gated
// by license enforcement. Cap behaviour itself is exercised by AgentCapEnforcementIT.
securityHelper.installSyntheticUnsignedLicense(Map.of("max_agents", 100));
}
@AfterEach
void tearDown() {
securityHelper.clearTestLicense();
}
private ResponseEntity<String> registerAgent(String agentId) {
String json = """
{

View File

@@ -2,6 +2,7 @@ package com.cameleer.server.app.security;
import com.cameleer.server.app.AbstractPostgresIT;
import com.cameleer.server.app.TestSecurityHelper;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
@@ -13,6 +14,8 @@ import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import java.util.Map;
import static org.assertj.core.api.Assertions.assertThat;
/**
@@ -31,10 +34,18 @@ class SecurityFilterIT extends AbstractPostgresIT {
@BeforeEach
void setUp() {
// Lift max_agents cap so this IT (which registers agents per test) isn't gated
// by license enforcement. Cap behaviour itself is exercised by AgentCapEnforcementIT.
securityHelper.installSyntheticUnsignedLicense(Map.of("max_agents", 100));
securityHelper.registerTestAgent("test-agent-security-filter-it");
viewerJwt = securityHelper.viewerToken();
}
@AfterEach
void tearDown() {
securityHelper.clearTestLicense();
}
@Test
void protectedEndpoint_withoutJwt_returns401or403() {
HttpHeaders headers = new HttpHeaders();

View File

@@ -5,6 +5,8 @@ import com.cameleer.server.app.TestSecurityHelper;
import com.cameleer.server.core.security.Ed25519SigningService;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.web.client.TestRestTemplate;
@@ -29,6 +31,7 @@ import java.time.Duration;
import java.util.ArrayList;
import java.util.Base64;
import java.util.List;
import java.util.Map;
import java.util.UUID;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.CountDownLatch;
@@ -62,6 +65,18 @@ class SseSigningIT extends AbstractPostgresIT {
@LocalServerPort
private int port;
@BeforeEach
void setUp() {
// Lift max_agents cap so this IT (which registers agents per test) isn't gated
// by license enforcement. Cap behaviour itself is exercised by AgentCapEnforcementIT.
securityHelper.installSyntheticUnsignedLicense(Map.of("max_agents", 100));
}
@AfterEach
void tearDown() {
securityHelper.clearTestLicense();
}
private HttpHeaders protocolHeaders() {
HttpHeaders headers = new HttpHeaders();
headers.setContentType(MediaType.APPLICATION_JSON);

View File

@@ -4,6 +4,7 @@ import com.cameleer.server.app.AbstractPostgresIT;
import com.cameleer.server.app.TestSecurityHelper;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
@@ -14,6 +15,8 @@ import org.springframework.http.HttpMethod;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import java.util.Map;
import static java.util.concurrent.TimeUnit.SECONDS;
import static org.assertj.core.api.Assertions.assertThat;
import static org.awaitility.Awaitility.await;
@@ -40,11 +43,19 @@ class DiagramLinkingIT extends AbstractPostgresIT {
@BeforeEach
void setUp() {
// Lift max_agents cap so this IT (which registers an agent) isn't gated by license
// enforcement. Cap behaviour itself is exercised by AgentCapEnforcementIT.
securityHelper.installSyntheticUnsignedLicense(Map.of("max_agents", 100));
String jwt = securityHelper.registerTestAgent(agentId);
authHeaders = securityHelper.authHeaders(jwt);
viewerHeaders = securityHelper.authHeadersNoBody(securityHelper.viewerToken());
}
@AfterEach
void tearDown() {
securityHelper.clearTestLicense();
}
@Test
void diagramHashPopulated_whenRouteGraphExistsBeforeExecution() throws Exception {
String graphJson = """

View File

@@ -4,6 +4,7 @@ import com.cameleer.server.app.AbstractPostgresIT;
import com.cameleer.server.app.TestSecurityHelper;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
@@ -14,6 +15,8 @@ import org.springframework.http.HttpMethod;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import java.util.Map;
import static java.util.concurrent.TimeUnit.SECONDS;
import static org.assertj.core.api.Assertions.assertThat;
import static org.awaitility.Awaitility.await;
@@ -41,11 +44,19 @@ class IngestionSchemaIT extends AbstractPostgresIT {
@BeforeEach
void setUp() {
// Lift max_agents cap so this IT (which registers an agent) isn't gated by license
// enforcement. Cap behaviour itself is exercised by AgentCapEnforcementIT.
securityHelper.installSyntheticUnsignedLicense(Map.of("max_agents", 100));
String jwt = securityHelper.registerTestAgent(agentId);
authHeaders = securityHelper.authHeaders(jwt);
viewerHeaders = securityHelper.authHeadersNoBody(securityHelper.viewerToken());
}
@AfterEach
void tearDown() {
securityHelper.clearTestLicense();
}
@Test
void processorTreeMetadata_depthsAndParentIdsCorrect() throws Exception {
String json = """

Some files were not shown because too many files have changed in this diff Show More