diff --git a/.claude/rules/app-classes.md b/.claude/rules/app-classes.md index a6a6840b..562b233d 100644 --- a/.claude/rules/app-classes.md +++ b/.claude/rules/app-classes.md @@ -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 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` 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 MODIFY TTL toDateTime() + INTERVAL DAY DELETE WHERE environment = ''`. 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). diff --git a/.claude/rules/core-classes.md b/.claude/rules/core-classes.md index c7b7ee64..8d6db545 100644 --- a/.claude/rules/core-classes.md +++ b/.claude/rules/core-classes.md @@ -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 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`. `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`). `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) diff --git a/.claude/rules/docker-orchestration.md b/.claude/rules/docker-orchestration.md index c79959f7..89ecbc72 100644 --- a/.claude/rules/docker-orchestration.md +++ b/.claude/rules/docker-orchestration.md @@ -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. diff --git a/HOWTO.md b/HOWTO.md index b67eeb1e..932e97d6 100644 --- a/HOWTO.md +++ b/HOWTO.md @@ -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 | diff --git a/cameleer-license-minter/README.md b/cameleer-license-minter/README.md new file mode 100644 index 00000000..dea3ef50 --- /dev/null +++ b/cameleer-license-minter/README.md @@ -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=` | yes | Path to a PKCS#8-encoded Ed25519 private key. Both PEM (`-----BEGIN PRIVATE KEY-----`) and raw base64 are accepted (`LicenseMinterCli.readEd25519PrivateKey`). | +| `--tenant=` | yes | The exact `tenantId` the server will compare against `CAMELEER_SERVER_TENANT_ID`. Mismatch causes the validator to throw at install / revalidation. | +| `--expires=` | yes | Expiration date interpreted as midnight UTC. The validator considers tokens expired once `now > exp + gracePeriodDays`. | +| `--label=` | no | Human-readable label, surfaced via `GET /api/v1/admin/license` and `/api/v1/admin/license/usage`. | +| `--grace-days=` | no | Number of days the license stays usable after `--expires`. Defaults to `0`. | +| `--max-=` | 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=` | no | Write the token to a file. When omitted, the token is printed to stdout. On `--verify` failure the file is deleted. | +| `--public-key=` | 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` | 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. diff --git a/cameleer-license-minter/pom.xml b/cameleer-license-minter/pom.xml new file mode 100644 index 00000000..354c8ec2 --- /dev/null +++ b/cameleer-license-minter/pom.xml @@ -0,0 +1,69 @@ + + + 4.0.0 + + + com.cameleer + cameleer-server-parent + 1.0-SNAPSHOT + + + cameleer-license-minter + Cameleer License Minter + Vendor-only Ed25519 license signing library + CLI + + + + com.cameleer + cameleer-server-core + + + com.fasterxml.jackson.core + jackson-databind + + + org.slf4j + slf4j-api + + + + org.junit.jupiter + junit-jupiter + test + + + org.assertj + assertj-core + test + + + + + + + org.springframework.boot + spring-boot-maven-plugin + + + + repackage + none + + + repackage-cli + + repackage + + + cli + com.cameleer.license.minter.cli.LicenseMinterCli + + + + + + + diff --git a/cameleer-license-minter/src/main/java/com/cameleer/license/minter/LicenseMinter.java b/cameleer-license-minter/src/main/java/com/cameleer/license/minter/LicenseMinter.java new file mode 100644 index 00000000..72c3caf8 --- /dev/null +++ b/cameleer-license-minter/src/main/java/com/cameleer/license/minter/LicenseMinter.java @@ -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); + } + } +} diff --git a/cameleer-license-minter/src/main/java/com/cameleer/license/minter/cli/LicenseMinterCli.java b/cameleer-license-minter/src/main/java/com/cameleer/license/minter/cli/LicenseMinterCli.java new file mode 100644 index 00000000..927cd5e7 --- /dev/null +++ b/cameleer-license-minter/src/main/java/com/cameleer/license/minter/cli/LicenseMinterCli.java @@ -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 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 flags = new LinkedHashMap<>(); + Set bool = new HashSet<>(); + Map 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)); + } +} diff --git a/cameleer-license-minter/src/test/java/com/cameleer/license/minter/LicenseMinterTest.java b/cameleer-license-minter/src/test/java/com/cameleer/license/minter/LicenseMinterTest.java new file mode 100644 index 00000000..c2fddcd6 --- /dev/null +++ b/cameleer-license-minter/src/test/java/com/cameleer/license/minter/LicenseMinterTest.java @@ -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 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); + } +} diff --git a/cameleer-license-minter/src/test/java/com/cameleer/license/minter/cli/LicenseMinterCliTest.java b/cameleer-license-minter/src/test/java/com/cameleer/license/minter/cli/LicenseMinterCliTest.java new file mode 100644 index 00000000..36f3dd2f --- /dev/null +++ b/cameleer-license-minter/src/test/java/com/cameleer/license/minter/cli/LicenseMinterCliTest.java @@ -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(); + } +} diff --git a/cameleer-server-app/pom.xml b/cameleer-server-app/pom.xml index d80a01e8..a40820f5 100644 --- a/cameleer-server-app/pom.xml +++ b/cameleer-server-app/pom.xml @@ -19,6 +19,12 @@ com.cameleer cameleer-server-core + + com.cameleer + cameleer-license-minter + ${project.version} + test + org.springframework.boot spring-boot-starter-web diff --git a/cameleer-server-app/src/main/java/com/cameleer/server/app/alerting/controller/AlertRuleController.java b/cameleer-server-app/src/main/java/com/cameleer/server/app/alerting/controller/AlertRuleController.java index 792b87ca..24f23a6f 100644 --- a/cameleer-server-app/src/main/java/com/cameleer/server/app/alerting/controller/AlertRuleController.java +++ b/cameleer-server-app/src/main/java/com/cameleer/server/app/alerting/controller/AlertRuleController.java @@ -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> 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> 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()); diff --git a/cameleer-server-app/src/main/java/com/cameleer/server/app/alerting/storage/PostgresAlertRuleRepository.java b/cameleer-server-app/src/main/java/com/cameleer/server/app/alerting/storage/PostgresAlertRuleRepository.java index 9c13852f..020f3171 100644 --- a/cameleer-server-app/src/main/java/com/cameleer/server/app/alerting/storage/PostgresAlertRuleRepository.java +++ b/cameleer-server-app/src/main/java/com/cameleer/server/app/alerting/storage/PostgresAlertRuleRepository.java @@ -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 claimDueRules(String instanceId, int batchSize, int claimTtlSeconds) { String sql = """ diff --git a/cameleer-server-app/src/main/java/com/cameleer/server/app/config/AgentRegistryBeanConfig.java b/cameleer-server-app/src/main/java/com/cameleer/server/app/config/AgentRegistryBeanConfig.java index aeda9f9e..93bed25d 100644 --- a/cameleer-server-app/src/main/java/com/cameleer/server/app/config/AgentRegistryBeanConfig.java +++ b/cameleer-server-app/src/main/java/com/cameleer/server/app/config/AgentRegistryBeanConfig.java @@ -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) ); } diff --git a/cameleer-server-app/src/main/java/com/cameleer/server/app/config/LicenseBeanConfig.java b/cameleer-server-app/src/main/java/com/cameleer/server/app/config/LicenseBeanConfig.java index 41717c88..5d6bb834 100644 --- a/cameleer-server-app/src/main/java/com/cameleer/server/app/config/LicenseBeanConfig.java +++ b/cameleer-server-app/src/main/java/com/cameleer/server/app/config/LicenseBeanConfig.java @@ -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): + * + *
    + *
  1. {@link LicenseGate} — always present, mutated by {@link LicenseService}.
  2. + *
  3. {@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.
  4. + *
  5. {@link LicenseService} — single mediation point for install / replace / revalidate; + * audits + persists + publishes {@code LicenseChangedEvent}.
  6. + *
  7. {@link LicenseBootLoader} — {@code @PostConstruct} drives {@code loadInitial} after the + * Spring context is ready. Resolution order: env var > license file > persisted DB row.
  8. + *
+ */ @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} 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 env = (envToken != null && !envToken.isBlank()) + ? Optional.of(envToken) : Optional.empty(); + Optional 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); + } } } diff --git a/cameleer-server-app/src/main/java/com/cameleer/server/app/config/RuntimeBeanConfig.java b/cameleer-server-app/src/main/java/com/cameleer/server/app/config/RuntimeBeanConfig.java index ca78caba..a7fd5941 100644 --- a/cameleer-server-app/src/main/java/com/cameleer/server/app/config/RuntimeBeanConfig.java +++ b/cameleer-server-app/src/main/java/com/cameleer/server/app/config/RuntimeBeanConfig.java @@ -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 diff --git a/cameleer-server-app/src/main/java/com/cameleer/server/app/config/StorageBeanConfig.java b/cameleer-server-app/src/main/java/com/cameleer/server/app/config/StorageBeanConfig.java index 6df6a51b..e4cf16b5 100644 --- a/cameleer-server-app/src/main/java/com/cameleer/server/app/config/StorageBeanConfig.java +++ b/cameleer-server-app/src/main/java/com/cameleer/server/app/config/StorageBeanConfig.java @@ -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); + } } diff --git a/cameleer-server-app/src/main/java/com/cameleer/server/app/controller/EnvironmentAdminController.java b/cameleer-server-app/src/main/java/com/cameleer/server/app/controller/EnvironmentAdminController.java index 1740df5d..e409d954 100644 --- a/cameleer-server-app/src/main/java/com/cameleer/server/app/controller/EnvironmentAdminController.java +++ b/cameleer-server-app/src/main/java/com/cameleer/server/app/controller/EnvironmentAdminController.java @@ -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")) { diff --git a/cameleer-server-app/src/main/java/com/cameleer/server/app/controller/LicenseAdminController.java b/cameleer-server-app/src/main/java/com/cameleer/server/app/controller/LicenseAdminController.java index 05557017..3c5152c3 100644 --- a/cameleer-server-app/src/main/java/com/cameleer/server/app/controller/LicenseAdminController.java +++ b/cameleer-server-app/src/main/java/com/cameleer/server/app/controller/LicenseAdminController.java @@ -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.). + * + *

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.

+ */ @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 getCurrent() { - return ResponseEntity.ok(licenseGate.getCurrent()); + @Operation(summary = "Get current license state, invalid reason, and parsed envelope") + public ResponseEntity> getCurrent() { + Map 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())); } diff --git a/cameleer-server-app/src/main/java/com/cameleer/server/app/controller/LicenseUsageController.java b/cameleer-server-app/src/main/java/com/cameleer/server/app/controller/LicenseUsageController.java new file mode 100644 index 00000000..79483aee --- /dev/null +++ b/cameleer-server-app/src/main/java/com/cameleer/server/app/controller/LicenseUsageController.java @@ -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. + * + *

Each limit row carries: + *

    + *
  • {@code key} — the limit key (e.g. {@code max_apps})
  • + *
  • {@code current} — current usage (0 when not measured server-side)
  • + *
  • {@code cap} — effective cap (license override or default-tier value)
  • + *
  • {@code source} — {@code "license"} when the cap came from the license override map, + * {@code "default"} otherwise
  • + *
+ * + *

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

+ */ +@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> get() { + var state = gate.getState(); + var info = gate.getCurrent(); + var effective = gate.getEffectiveLimits(); + + Map usage = new HashMap<>(reader.snapshot()); + usage.put("max_agents", (long) agents.liveCount()); + + List> limitRows = new ArrayList<>(); + for (var key : effective.values().keySet()) { + Map 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 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); + } +} diff --git a/cameleer-server-app/src/main/java/com/cameleer/server/app/controller/UserAdminController.java b/cameleer-server-app/src/main/java/com/cameleer/server/app/controller/UserAdminController.java index 063219b1..b90f4c7a 100644 --- a/cameleer-server-app/src/main/java/com/cameleer/server/app/controller/UserAdminController.java +++ b/cameleer-server-app/src/main/java/com/cameleer/server/app/controller/UserAdminController.java @@ -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.")); diff --git a/cameleer-server-app/src/main/java/com/cameleer/server/app/license/LicenseCapExceededException.java b/cameleer-server-app/src/main/java/com/cameleer/server/app/license/LicenseCapExceededException.java new file mode 100644 index 00000000..81563a35 --- /dev/null +++ b/cameleer-server-app/src/main/java/com/cameleer/server/app/license/LicenseCapExceededException.java @@ -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; } +} diff --git a/cameleer-server-app/src/main/java/com/cameleer/server/app/license/LicenseChangedEvent.java b/cameleer-server-app/src/main/java/com/cameleer/server/app/license/LicenseChangedEvent.java new file mode 100644 index 00000000..59df6a7a --- /dev/null +++ b/cameleer-server-app/src/main/java/com/cameleer/server/app/license/LicenseChangedEvent.java @@ -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); + } +} diff --git a/cameleer-server-app/src/main/java/com/cameleer/server/app/license/LicenseEnforcer.java b/cameleer-server-app/src/main/java/com/cameleer/server/app/license/LicenseEnforcer.java new file mode 100644 index 00000000..14cf71c4 --- /dev/null +++ b/cameleer-server-app/src/main/java/com/cameleer/server/app/license/LicenseEnforcer.java @@ -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). + * + *

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.

+ * + *

Unknown limit keys are treated as programmer errors and surface as + * {@link IllegalArgumentException} (propagated from {@link LicenseLimits#get(String)}), not + * {@link LicenseCapExceededException}.

+ */ +@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 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 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); + } + } +} diff --git a/cameleer-server-app/src/main/java/com/cameleer/server/app/license/LicenseExceptionAdvice.java b/cameleer-server-app/src/main/java/com/cameleer/server/app/license/LicenseExceptionAdvice.java new file mode 100644 index 00000000..d994d678 --- /dev/null +++ b/cameleer-server-app/src/main/java/com/cameleer/server/app/license/LicenseExceptionAdvice.java @@ -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> handle(LicenseCapExceededException e) { + var state = gate.getState(); + LicenseInfo info = gate.getCurrent(); + String reason = gate.getInvalidReason(); + Map 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); + } +} diff --git a/cameleer-server-app/src/main/java/com/cameleer/server/app/license/LicenseMessageRenderer.java b/cameleer-server-app/src/main/java/com/cameleer/server/app/license/LicenseMessageRenderer.java new file mode 100644 index 00000000..d1d9fbaa --- /dev/null +++ b/cameleer-server-app/src/main/java/com/cameleer/server/app/license/LicenseMessageRenderer.java @@ -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()); + } +} diff --git a/cameleer-server-app/src/main/java/com/cameleer/server/app/license/LicenseMetrics.java b/cameleer-server-app/src/main/java/com/cameleer/server/app/license/LicenseMetrics.java new file mode 100644 index 00000000..2dbf5e28 --- /dev/null +++ b/cameleer-server-app/src/main/java/com/cameleer/server/app/license/LicenseMetrics.java @@ -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. + * + *
    + *
  • {@code cameleer_license_state{state=...}} — one-hot per {@link LicenseState}, exactly + * one tag value carries 1.0 at any time.
  • + *
  • {@code cameleer_license_days_remaining} — days until {@code expiresAt}; negative + * (-1.0) when ABSENT/INVALID (no license loaded).
  • + *
  • {@code cameleer_license_last_validated_age_seconds} — seconds since the persisted + * {@code last_validated_at}; 0 when there is no DB row.
  • + *
+ * + *

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).

+ */ +@Component +public class LicenseMetrics { + + private final LicenseGate gate; + private final LicenseRepository repo; + private final String tenantId; + + private final Map> stateGauges = new EnumMap<>(LicenseState.class); + private final AtomicReference daysRemaining = new AtomicReference<>(0.0); + private final AtomicReference 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())); + } +} diff --git a/cameleer-server-app/src/main/java/com/cameleer/server/app/license/LicenseRecord.java b/cameleer-server-app/src/main/java/com/cameleer/server/app/license/LicenseRecord.java new file mode 100644 index 00000000..fc1abe56 --- /dev/null +++ b/cameleer-server-app/src/main/java/com/cameleer/server/app/license/LicenseRecord.java @@ -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 +) {} diff --git a/cameleer-server-app/src/main/java/com/cameleer/server/app/license/LicenseRepository.java b/cameleer-server-app/src/main/java/com/cameleer/server/app/license/LicenseRepository.java new file mode 100644 index 00000000..b2ce3018 --- /dev/null +++ b/cameleer-server-app/src/main/java/com/cameleer/server/app/license/LicenseRepository.java @@ -0,0 +1,17 @@ +package com.cameleer.server.app.license; + +import java.time.Instant; +import java.util.Optional; + +public interface LicenseRepository { + Optional 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); +} diff --git a/cameleer-server-app/src/main/java/com/cameleer/server/app/license/LicenseRevalidationJob.java b/cameleer-server-app/src/main/java/com/cameleer/server/app/license/LicenseRevalidationJob.java new file mode 100644 index 00000000..fc05ac6b --- /dev/null +++ b/cameleer-server-app/src/main/java/com/cameleer/server/app/license/LicenseRevalidationJob.java @@ -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}. + * + *

The startup tick catches ABSENT->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.

+ * + *

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.

+ */ +@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()); + } + } +} diff --git a/cameleer-server-app/src/main/java/com/cameleer/server/app/license/LicenseService.java b/cameleer-server-app/src/main/java/com/cameleer/server/app/license/LicenseService.java new file mode 100644 index 00000000..7d3509af --- /dev/null +++ b/cameleer-server-app/src/main/java/com/cameleer/server/app/license/LicenseService.java @@ -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. + * + *

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.

+ */ +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 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 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 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 envToken, Optional 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 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 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 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; } +} diff --git a/cameleer-server-app/src/main/java/com/cameleer/server/app/license/LicenseUsageReader.java b/cameleer-server-app/src/main/java/com/cameleer/server/app/license/LicenseUsageReader.java new file mode 100644 index 00000000..5132ac89 --- /dev/null +++ b/cameleer-server-app/src/main/java/com/cameleer/server/app/license/LicenseUsageReader.java @@ -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. + * + *

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.

+ * + *

{@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.

+ */ +@Component +public class LicenseUsageReader { + + private final JdbcTemplate jdbc; + + public LicenseUsageReader(JdbcTemplate jdbc) { + this.jdbc = jdbc; + } + + public Map snapshot() { + Map 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 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 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); + } +} diff --git a/cameleer-server-app/src/main/java/com/cameleer/server/app/license/PostgresLicenseRepository.java b/cameleer-server-app/src/main/java/com/cameleer/server/app/license/PostgresLicenseRepository.java new file mode 100644 index 00000000..b0b3369c --- /dev/null +++ b/cameleer-server-app/src/main/java/com/cameleer/server/app/license/PostgresLicenseRepository.java @@ -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 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 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); + } +} diff --git a/cameleer-server-app/src/main/java/com/cameleer/server/app/license/RetentionPolicyApplier.java b/cameleer-server-app/src/main/java/com/cameleer/server/app/license/RetentionPolicyApplier.java new file mode 100644 index 00000000..33cd4afd --- /dev/null +++ b/cameleer-server-app/src/main/java/com/cameleer/server/app/license/RetentionPolicyApplier.java @@ -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}. + * + *

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 WHERE environment = ''} statement + * with {@code effective = min(licenseCap, env.configuredRetentionDays)}.

+ * + *

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.

+ * + *

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.

+ */ +@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 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 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()); + } + } + } + } +} diff --git a/cameleer-server-app/src/main/java/com/cameleer/server/app/outbound/OutboundConnectionServiceImpl.java b/cameleer-server-app/src/main/java/com/cameleer/server/app/outbound/OutboundConnectionServiceImpl.java index 81d6719f..6875ffd4 100644 --- a/cameleer-server-app/src/main/java/com/cameleer/server/app/outbound/OutboundConnectionServiceImpl.java +++ b/cameleer-server-app/src/main/java/com/cameleer/server/app/outbound/OutboundConnectionServiceImpl.java @@ -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( diff --git a/cameleer-server-app/src/main/java/com/cameleer/server/app/outbound/config/OutboundBeanConfig.java b/cameleer-server-app/src/main/java/com/cameleer/server/app/outbound/config/OutboundBeanConfig.java index 6c8a2182..646bccf8 100644 --- a/cameleer-server-app/src/main/java/com/cameleer/server/app/outbound/config/OutboundBeanConfig.java +++ b/cameleer-server-app/src/main/java/com/cameleer/server/app/outbound/config/OutboundBeanConfig.java @@ -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); } } diff --git a/cameleer-server-app/src/main/java/com/cameleer/server/app/runtime/DeploymentExecutor.java b/cameleer-server-app/src/main/java/com/cameleer/server/app/runtime/DeploymentExecutor.java index f6a2e6ee..8e2f0a59 100644 --- a/cameleer-server-app/src/main/java/com/cameleer/server/app/runtime/DeploymentExecutor.java +++ b/cameleer-server-app/src/main/java/com/cameleer/server/app/runtime/DeploymentExecutor.java @@ -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; diff --git a/cameleer-server-app/src/main/java/com/cameleer/server/app/runtime/DockerRuntimeOrchestrator.java b/cameleer-server-app/src/main/java/com/cameleer/server/app/runtime/DockerRuntimeOrchestrator.java index 01c2f3e9..702a6602 100644 --- a/cameleer-server-app/src/main/java/com/cameleer/server/app/runtime/DockerRuntimeOrchestrator.java +++ b/cameleer-server-app/src/main/java/com/cameleer/server/app/runtime/DockerRuntimeOrchestrator.java @@ -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 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 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()) { diff --git a/cameleer-server-app/src/main/java/com/cameleer/server/app/runtime/RuntimeOrchestratorAutoConfig.java b/cameleer-server-app/src/main/java/com/cameleer/server/app/runtime/RuntimeOrchestratorAutoConfig.java index 67f7b6c2..b1388f94 100644 --- a/cameleer-server-app/src/main/java/com/cameleer/server/app/runtime/RuntimeOrchestratorAutoConfig.java +++ b/cameleer-server-app/src/main/java/com/cameleer/server/app/runtime/RuntimeOrchestratorAutoConfig.java @@ -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); } diff --git a/cameleer-server-app/src/main/java/com/cameleer/server/app/security/OidcAuthController.java b/cameleer-server-app/src/main/java/com/cameleer/server/app/security/OidcAuthController.java index 72d8298d..694283bc 100644 --- a/cameleer-server-app/src/main/java/com/cameleer/server/app/security/OidcAuthController.java +++ b/cameleer-server-app/src/main/java/com/cameleer/server/app/security/OidcAuthController.java @@ -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())); diff --git a/cameleer-server-app/src/main/java/com/cameleer/server/app/storage/PostgresAppRepository.java b/cameleer-server-app/src/main/java/com/cameleer/server/app/storage/PostgresAppRepository.java index cfaffdf6..3b45ae43 100644 --- a/cameleer-server-app/src/main/java/com/cameleer/server/app/storage/PostgresAppRepository.java +++ b/cameleer-server-app/src/main/java/com/cameleer/server/app/storage/PostgresAppRepository.java @@ -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 containerConfig) { try { diff --git a/cameleer-server-app/src/main/java/com/cameleer/server/app/storage/PostgresEnvironmentRepository.java b/cameleer-server-app/src/main/java/com/cameleer/server/app/storage/PostgresEnvironmentRepository.java index 32376fcf..5067c27b 100644 --- a/cameleer-server-app/src/main/java/com/cameleer/server/app/storage/PostgresEnvironmentRepository.java +++ b/cameleer-server-app/src/main/java/com/cameleer/server/app/storage/PostgresEnvironmentRepository.java @@ -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 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 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") ); } } diff --git a/cameleer-server-app/src/main/java/com/cameleer/server/app/storage/PostgresUserRepository.java b/cameleer-server-app/src/main/java/com/cameleer/server/app/storage/PostgresUserRepository.java index 64ee73a7..f00bac83 100644 --- a/cameleer-server-app/src/main/java/com/cameleer/server/app/storage/PostgresUserRepository.java +++ b/cameleer-server-app/src/main/java/com/cameleer/server/app/storage/PostgresUserRepository.java @@ -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; diff --git a/cameleer-server-app/src/main/resources/application.yml b/cameleer-server-app/src/main/resources/application.yml index 3ded9adf..a7e29444 100644 --- a/cameleer-server-app/src/main/resources/application.yml +++ b/cameleer-server-app/src/main/resources/application.yml @@ -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: diff --git a/cameleer-server-app/src/main/resources/db/migration/V5__license_table_and_environment_retention.sql b/cameleer-server-app/src/main/resources/db/migration/V5__license_table_and_environment_retention.sql new file mode 100644 index 00000000..aa53ed3e --- /dev/null +++ b/cameleer-server-app/src/main/resources/db/migration/V5__license_table_and_environment_retention.sql @@ -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; diff --git a/cameleer-server-app/src/test/java/com/cameleer/server/app/TestSecurityHelper.java b/cameleer-server-app/src/test/java/com/cameleer/server/app/TestSecurityHelper.java index d24d5945..2cdaec9e 100644 --- a/cameleer-server-app/src/test/java/com/cameleer/server/app/TestSecurityHelper.java +++ b/cameleer-server-app/src/test/java/com/cameleer/server/app/TestSecurityHelper.java @@ -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 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(); } /** diff --git a/cameleer-server-app/src/test/java/com/cameleer/server/app/alerting/AlertingFullLifecycleIT.java b/cameleer-server-app/src/test/java/com/cameleer/server/app/alerting/AlertingFullLifecycleIT.java index 6043cca6..9969ca74 100644 --- a/cameleer-server-app/src/test/java/com/cameleer/server/app/alerting/AlertingFullLifecycleIT.java +++ b/cameleer-server-app/src/test/java/com/cameleer/server/app/alerting/AlertingFullLifecycleIT.java @@ -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); diff --git a/cameleer-server-app/src/test/java/com/cameleer/server/app/alerting/OutboundConnectionAllowedEnvIT.java b/cameleer-server-app/src/test/java/com/cameleer/server/app/alerting/OutboundConnectionAllowedEnvIT.java index f4452ed6..2937537c 100644 --- a/cameleer-server-app/src/test/java/com/cameleer/server/app/alerting/OutboundConnectionAllowedEnvIT.java +++ b/cameleer-server-app/src/test/java/com/cameleer/server/app/alerting/OutboundConnectionAllowedEnvIT.java @@ -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); diff --git a/cameleer-server-app/src/test/java/com/cameleer/server/app/alerting/controller/AlertRuleControllerIT.java b/cameleer-server-app/src/test/java/com/cameleer/server/app/alerting/controller/AlertRuleControllerIT.java index 56d7b9a2..6daad7c0 100644 --- a/cameleer-server-app/src/test/java/com/cameleer/server/app/alerting/controller/AlertRuleControllerIT.java +++ b/cameleer-server-app/src/test/java/com/cameleer/server/app/alerting/controller/AlertRuleControllerIT.java @@ -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')"); diff --git a/cameleer-server-app/src/test/java/com/cameleer/server/app/alerting/eval/AgentLifecycleEvaluatorTest.java b/cameleer-server-app/src/test/java/com/cameleer/server/app/alerting/eval/AgentLifecycleEvaluatorTest.java index a46afc02..7af41c31 100644 --- a/cameleer-server-app/src/test/java/com/cameleer/server/app/alerting/eval/AgentLifecycleEvaluatorTest.java +++ b/cameleer-server-app/src/test/java/com/cameleer/server/app/alerting/eval/AgentLifecycleEvaluatorTest.java @@ -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); } diff --git a/cameleer-server-app/src/test/java/com/cameleer/server/app/alerting/eval/ExchangeMatchEvaluatorTest.java b/cameleer-server-app/src/test/java/com/cameleer/server/app/alerting/eval/ExchangeMatchEvaluatorTest.java index e1b6b913..ddcf54c8 100644 --- a/cameleer-server-app/src/test/java/com/cameleer/server/app/alerting/eval/ExchangeMatchEvaluatorTest.java +++ b/cameleer-server-app/src/test/java/com/cameleer/server/app/alerting/eval/ExchangeMatchEvaluatorTest.java @@ -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)); } diff --git a/cameleer-server-app/src/test/java/com/cameleer/server/app/alerting/eval/LogPatternEvaluatorTest.java b/cameleer-server-app/src/test/java/com/cameleer/server/app/alerting/eval/LogPatternEvaluatorTest.java index c113b3c3..210777cd 100644 --- a/cameleer-server-app/src/test/java/com/cameleer/server/app/alerting/eval/LogPatternEvaluatorTest.java +++ b/cameleer-server-app/src/test/java/com/cameleer/server/app/alerting/eval/LogPatternEvaluatorTest.java @@ -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)); } diff --git a/cameleer-server-app/src/test/java/com/cameleer/server/app/alerting/eval/RouteMetricEvaluatorTest.java b/cameleer-server-app/src/test/java/com/cameleer/server/app/alerting/eval/RouteMetricEvaluatorTest.java index eae39800..718e7fca 100644 --- a/cameleer-server-app/src/test/java/com/cameleer/server/app/alerting/eval/RouteMetricEvaluatorTest.java +++ b/cameleer-server-app/src/test/java/com/cameleer/server/app/alerting/eval/RouteMetricEvaluatorTest.java @@ -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)); } diff --git a/cameleer-server-app/src/test/java/com/cameleer/server/app/alerting/notify/NotificationContextBuilderTest.java b/cameleer-server-app/src/test/java/com/cameleer/server/app/alerting/notify/NotificationContextBuilderTest.java index c3e30e14..453f7759 100644 --- a/cameleer-server-app/src/test/java/com/cameleer/server/app/alerting/notify/NotificationContextBuilderTest.java +++ b/cameleer-server-app/src/test/java/com/cameleer/server/app/alerting/notify/NotificationContextBuilderTest.java @@ -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) { diff --git a/cameleer-server-app/src/test/java/com/cameleer/server/app/alerting/storage/SchemaBootstrapIT.java b/cameleer-server-app/src/test/java/com/cameleer/server/app/alerting/storage/SchemaBootstrapIT.java index 4ec6fe08..dffce3f1 100644 --- a/cameleer-server-app/src/test/java/com/cameleer/server/app/alerting/storage/SchemaBootstrapIT.java +++ b/cameleer-server-app/src/test/java/com/cameleer/server/app/alerting/storage/SchemaBootstrapIT.java @@ -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>(); + 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>(); + 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(); diff --git a/cameleer-server-app/src/test/java/com/cameleer/server/app/controller/AgentCommandControllerIT.java b/cameleer-server-app/src/test/java/com/cameleer/server/app/controller/AgentCommandControllerIT.java index 22fe45be..85199896 100644 --- a/cameleer-server-app/src/test/java/com/cameleer/server/app/controller/AgentCommandControllerIT.java +++ b/cameleer-server-app/src/test/java/com/cameleer/server/app/controller/AgentCommandControllerIT.java @@ -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 registerAgent(String agentId, String name, String application) { String json = """ { diff --git a/cameleer-server-app/src/test/java/com/cameleer/server/app/controller/AgentRegistrationControllerIT.java b/cameleer-server-app/src/test/java/com/cameleer/server/app/controller/AgentRegistrationControllerIT.java index fdd904d3..1822957f 100644 --- a/cameleer-server-app/src/test/java/com/cameleer/server/app/controller/AgentRegistrationControllerIT.java +++ b/cameleer-server-app/src/test/java/com/cameleer/server/app/controller/AgentRegistrationControllerIT.java @@ -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 registerAgent(String agentId, String name) { String json = """ { diff --git a/cameleer-server-app/src/test/java/com/cameleer/server/app/controller/AgentSseControllerIT.java b/cameleer-server-app/src/test/java/com/cameleer/server/app/controller/AgentSseControllerIT.java index abf03741..f6d33d15 100644 --- a/cameleer-server-app/src/test/java/com/cameleer/server/app/controller/AgentSseControllerIT.java +++ b/cameleer-server-app/src/test/java/com/cameleer/server/app/controller/AgentSseControllerIT.java @@ -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 registerAgent(String agentId, String name, String application) { String json = """ { diff --git a/cameleer-server-app/src/test/java/com/cameleer/server/app/controller/BackpressureIT.java b/cameleer-server-app/src/test/java/com/cameleer/server/app/controller/BackpressureIT.java index 79c0b53e..2815089a 100644 --- a/cameleer-server-app/src/test/java/com/cameleer/server/app/controller/BackpressureIT.java +++ b/cameleer-server-app/src/test/java/com/cameleer/server/app/controller/BackpressureIT.java @@ -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 diff --git a/cameleer-server-app/src/test/java/com/cameleer/server/app/controller/DeploymentControllerAuditIT.java b/cameleer-server-app/src/test/java/com/cameleer/server/app/controller/DeploymentControllerAuditIT.java index 16298486..79c6a517 100644 --- a/cameleer-server-app/src/test/java/com/cameleer/server/app/controller/DeploymentControllerAuditIT.java +++ b/cameleer-server-app/src/test/java/com/cameleer/server/app/controller/DeploymentControllerAuditIT.java @@ -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(""" diff --git a/cameleer-server-app/src/test/java/com/cameleer/server/app/controller/DetailControllerIT.java b/cameleer-server-app/src/test/java/com/cameleer/server/app/controller/DetailControllerIT.java index 9fdbb4eb..ddd144e7 100644 --- a/cameleer-server-app/src/test/java/com/cameleer/server/app/controller/DetailControllerIT.java +++ b/cameleer-server-app/src/test/java/com/cameleer/server/app/controller/DetailControllerIT.java @@ -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(); + } } diff --git a/cameleer-server-app/src/test/java/com/cameleer/server/app/controller/DiagramControllerIT.java b/cameleer-server-app/src/test/java/com/cameleer/server/app/controller/DiagramControllerIT.java index 5b1f3827..ce8939d0 100644 --- a/cameleer-server-app/src/test/java/com/cameleer/server/app/controller/DiagramControllerIT.java +++ b/cameleer-server-app/src/test/java/com/cameleer/server/app/controller/DiagramControllerIT.java @@ -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 = """ diff --git a/cameleer-server-app/src/test/java/com/cameleer/server/app/controller/DiagramRenderControllerIT.java b/cameleer-server-app/src/test/java/com/cameleer/server/app/controller/DiagramRenderControllerIT.java index 7ba1074f..11e863e9 100644 --- a/cameleer-server-app/src/test/java/com/cameleer/server/app/controller/DiagramRenderControllerIT.java +++ b/cameleer-server-app/src/test/java/com/cameleer/server/app/controller/DiagramRenderControllerIT.java @@ -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); diff --git a/cameleer-server-app/src/test/java/com/cameleer/server/app/controller/EnvironmentAdminControllerIT.java b/cameleer-server-app/src/test/java/com/cameleer/server/app/controller/EnvironmentAdminControllerIT.java index b20eb8ab..98e072ee 100644 --- a/cameleer-server-app/src/test/java/com/cameleer/server/app/controller/EnvironmentAdminControllerIT.java +++ b/cameleer-server-app/src/test/java/com/cameleer/server/app/controller/EnvironmentAdminControllerIT.java @@ -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 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( diff --git a/cameleer-server-app/src/test/java/com/cameleer/server/app/controller/ExecutionControllerIT.java b/cameleer-server-app/src/test/java/com/cameleer/server/app/controller/ExecutionControllerIT.java index 7c818119..763d733e 100644 --- a/cameleer-server-app/src/test/java/com/cameleer/server/app/controller/ExecutionControllerIT.java +++ b/cameleer-server-app/src/test/java/com/cameleer/server/app/controller/ExecutionControllerIT.java @@ -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 = """ diff --git a/cameleer-server-app/src/test/java/com/cameleer/server/app/controller/ForwardCompatIT.java b/cameleer-server-app/src/test/java/com/cameleer/server/app/controller/ForwardCompatIT.java index 74b6f268..0dcd440c 100644 --- a/cameleer-server-app/src/test/java/com/cameleer/server/app/controller/ForwardCompatIT.java +++ b/cameleer-server-app/src/test/java/com/cameleer/server/app/controller/ForwardCompatIT.java @@ -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 diff --git a/cameleer-server-app/src/test/java/com/cameleer/server/app/controller/LicenseUsageControllerIT.java b/cameleer-server-app/src/test/java/com/cameleer/server/app/controller/LicenseUsageControllerIT.java new file mode 100644 index 00000000..dadb7b43 --- /dev/null +++ b/cameleer-server-app/src/test/java/com/cameleer/server/app/controller/LicenseUsageControllerIT.java @@ -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}. + * + *

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.

+ */ +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 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 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"); + } + } +} diff --git a/cameleer-server-app/src/test/java/com/cameleer/server/app/controller/MetricsControllerIT.java b/cameleer-server-app/src/test/java/com/cameleer/server/app/controller/MetricsControllerIT.java index 977a2a5a..89f55947 100644 --- a/cameleer-server-app/src/test/java/com/cameleer/server/app/controller/MetricsControllerIT.java +++ b/cameleer-server-app/src/test/java/com/cameleer/server/app/controller/MetricsControllerIT.java @@ -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 = """ diff --git a/cameleer-server-app/src/test/java/com/cameleer/server/app/controller/SearchControllerIT.java b/cameleer-server-app/src/test/java/com/cameleer/server/app/controller/SearchControllerIT.java index 49eed7fb..33923c6f 100644 --- a/cameleer-server-app/src/test/java/com/cameleer/server/app/controller/SearchControllerIT.java +++ b/cameleer-server-app/src/test/java/com/cameleer/server/app/controller/SearchControllerIT.java @@ -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"); diff --git a/cameleer-server-app/src/test/java/com/cameleer/server/app/interceptor/ProtocolVersionIT.java b/cameleer-server-app/src/test/java/com/cameleer/server/app/interceptor/ProtocolVersionIT.java index 910a780c..0228fe9b 100644 --- a/cameleer-server-app/src/test/java/com/cameleer/server/app/interceptor/ProtocolVersionIT.java +++ b/cameleer-server-app/src/test/java/com/cameleer/server/app/interceptor/ProtocolVersionIT.java @@ -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(); diff --git a/cameleer-server-app/src/test/java/com/cameleer/server/app/license/AgentCapEnforcementIT.java b/cameleer-server-app/src/test/java/com/cameleer/server/app/license/AgentCapEnforcementIT.java new file mode 100644 index 00000000..be111541 --- /dev/null +++ b/cameleer-server-app/src/test/java/com/cameleer/server/app/license/AgentCapEnforcementIT.java @@ -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). + * + *

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.

+ */ +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 all = agentRegistryService.findAll(); + for (AgentInfo a : all) { + agentRegistryService.deregister(a.instanceId()); + } + } + + private ResponseEntity 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 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 reRegister = register("cap-it-rereg-1"); + assertThat(reRegister.getStatusCode()).isEqualTo(HttpStatus.OK); + + // And a fresh registration is still rejected. + ResponseEntity third = register("cap-it-rereg-3"); + assertThat(third.getStatusCode()).isEqualTo(HttpStatus.FORBIDDEN); + } +} diff --git a/cameleer-server-app/src/test/java/com/cameleer/server/app/license/AlertRuleCapEnforcementIT.java b/cameleer-server-app/src/test/java/com/cameleer/server/app/license/AlertRuleCapEnforcementIT.java new file mode 100644 index 00000000..3a203d48 --- /dev/null +++ b/cameleer-server-app/src/test/java/com/cameleer/server/app/license/AlertRuleCapEnforcementIT.java @@ -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 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 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 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); + } +} diff --git a/cameleer-server-app/src/test/java/com/cameleer/server/app/license/AppCapEnforcementIT.java b/cameleer-server-app/src/test/java/com/cameleer/server/app/license/AppCapEnforcementIT.java new file mode 100644 index 00000000..46db41bd --- /dev/null +++ b/cameleer-server-app/src/test/java/com/cameleer/server/app/license/AppCapEnforcementIT.java @@ -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 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 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(); + } +} diff --git a/cameleer-server-app/src/test/java/com/cameleer/server/app/license/ComputeCapEnforcementIT.java b/cameleer-server-app/src/test/java/com/cameleer/server/app/license/ComputeCapEnforcementIT.java new file mode 100644 index 00000000..90b3aa49 --- /dev/null +++ b/cameleer-server-app/src/test/java/com/cameleer/server/app/license/ComputeCapEnforcementIT.java @@ -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}. + * + *

IT design: 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 before 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.

+ * + *

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.

+ */ +@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 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(); + } +} diff --git a/cameleer-server-app/src/test/java/com/cameleer/server/app/license/EnvironmentCapEnforcementIT.java b/cameleer-server-app/src/test/java/com/cameleer/server/app/license/EnvironmentCapEnforcementIT.java new file mode 100644 index 00000000..8965c3ea --- /dev/null +++ b/cameleer-server-app/src/test/java/com/cameleer/server/app/license/EnvironmentCapEnforcementIT.java @@ -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 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(); + } +} diff --git a/cameleer-server-app/src/test/java/com/cameleer/server/app/license/LicenseEnforcementIT.java b/cameleer-server-app/src/test/java/com/cameleer/server/app/license/LicenseEnforcementIT.java new file mode 100644 index 00000000..4fd0c739 --- /dev/null +++ b/cameleer-server-app/src/test/java/com/cameleer/server/app/license/LicenseEnforcementIT.java @@ -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). + * + *

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.

+ * + *

Each {@link Nested} test:

+ *
    + *
  1. Pushes the endpoint up to the cap (the outer {@link BeforeEach} pre-cleans state).
  2. + *
  3. 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}.
  4. + *
  5. Verifies {@code audit_log} has at least one row with {@code category='LICENSE'}, + * {@code action='cap_exceeded'}, {@code result='FAILURE'}, {@code target=}.
  6. + *
+ * + *

Out of scope (already covered by per-limit ITs):

+ *
    + *
  • Agent registration cap — see {@code AgentCapEnforcementIT}.
  • + *
  • Compute caps (cpu/memory/replicas) — see {@code ComputeCapEnforcementIT}; the deploy + * endpoint requires a real artifact and runtime orchestration.
  • + *
  • JAR retention cap — see {@code RetentionCapEnforcementIT}; that is a 422 not a 403, + * shaped differently from the cap envelope.
  • + *
+ */ +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 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 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 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 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 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 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 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 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 first = createUser("alice"); + assertThat(first.getStatusCode()).isEqualTo(HttpStatus.OK); + + ResponseEntity second = createUser("bob"); + assert403CapEnvelope(second, "max_users", 2); + assertThat(auditCount("max_users")).isGreaterThanOrEqualTo(1); + } + + private ResponseEntity 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); + } + } +} diff --git a/cameleer-server-app/src/test/java/com/cameleer/server/app/license/LicenseEnforcerTest.java b/cameleer-server-app/src/test/java/com/cameleer/server/app/license/LicenseEnforcerTest.java new file mode 100644 index 00000000..db23264a --- /dev/null +++ b/cameleer-server-app/src/test/java/com/cameleer/server/app/license/LicenseEnforcerTest.java @@ -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 limits, int grace) { + return new LicenseInfo(UUID.randomUUID(), "acme", null, + limits, Instant.now(), Instant.now().plusSeconds(86400), grace); + } +} diff --git a/cameleer-server-app/src/test/java/com/cameleer/server/app/license/LicenseLifecycleIT.java b/cameleer-server-app/src/test/java/com/cameleer/server/app/license/LicenseLifecycleIT.java new file mode 100644 index 00000000..2706a3e3 --- /dev/null +++ b/cameleer-server-app/src/test/java/com/cameleer/server/app/license/LicenseLifecycleIT.java @@ -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. + * + *

Mints a real Ed25519-signed token via {@code cameleer-license-minter} (test scope), POSTs + * it through {@code /api/v1/admin/license}, then verifies: + *

    + *
  1. Gate transitions ABSENT → ACTIVE.
  2. + *
  3. Row persists in the {@code license} PostgreSQL table.
  4. + *
  5. After {@code gate.clear()}, {@code revalidate()} restores ACTIVE from the persisted token.
  6. + *
  7. A token with a tampered signature is rejected (HTTP 400) and audited as FAILURE + * under {@code AuditCategory.LICENSE} without mutating the gate.
  8. + *
+ * + *

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.

+ */ +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 body = new LinkedHashMap<>(); + body.put("token", token); + ResponseEntity 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 body = new LinkedHashMap<>(); + body.put("token", tampered); + ResponseEntity 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); + } +} diff --git a/cameleer-server-app/src/test/java/com/cameleer/server/app/license/LicenseMessageRendererTest.java b/cameleer-server-app/src/test/java/com/cameleer/server/app/license/LicenseMessageRendererTest.java new file mode 100644 index 00000000..ec7961f9 --- /dev/null +++ b/cameleer-server-app/src/test/java/com/cameleer/server/app/license/LicenseMessageRendererTest.java @@ -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); + } +} diff --git a/cameleer-server-app/src/test/java/com/cameleer/server/app/license/LicenseMetricsTest.java b/cameleer-server-app/src/test/java/com/cameleer/server/app/license/LicenseMetricsTest.java new file mode 100644 index 00000000..0ba28eb1 --- /dev/null +++ b/cameleer-server-app/src/test/java/com/cameleer/server/app/license/LicenseMetricsTest.java @@ -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); + } +} diff --git a/cameleer-server-app/src/test/java/com/cameleer/server/app/license/LicenseRevalidationJobTest.java b/cameleer-server-app/src/test/java/com/cameleer/server/app/license/LicenseRevalidationJobTest.java new file mode 100644 index 00000000..95f65c06 --- /dev/null +++ b/cameleer-server-app/src/test/java/com/cameleer/server/app/license/LicenseRevalidationJobTest.java @@ -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(); + } +} diff --git a/cameleer-server-app/src/test/java/com/cameleer/server/app/license/LicenseServiceTest.java b/cameleer-server-app/src/test/java/com/cameleer/server/app/license/LicenseServiceTest.java new file mode 100644 index 00000000..7f54da41 --- /dev/null +++ b/cameleer-server-app/src/test/java/com/cameleer/server/app/license/LicenseServiceTest.java @@ -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()); + } +} diff --git a/cameleer-server-app/src/test/java/com/cameleer/server/app/license/LicenseUsageReaderIT.java b/cameleer-server-app/src/test/java/com/cameleer/server/app/license/LicenseUsageReaderIT.java new file mode 100644 index 00000000..c6131c13 --- /dev/null +++ b/cameleer-server-app/src/test/java/com/cameleer/server/app/license/LicenseUsageReaderIT.java @@ -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); + } +} diff --git a/cameleer-server-app/src/test/java/com/cameleer/server/app/license/OutboundCapEnforcementIT.java b/cameleer-server-app/src/test/java/com/cameleer/server/app/license/OutboundCapEnforcementIT.java new file mode 100644 index 00000000..01994871 --- /dev/null +++ b/cameleer-server-app/src/test/java/com/cameleer/server/app/license/OutboundCapEnforcementIT.java @@ -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 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 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); + } +} diff --git a/cameleer-server-app/src/test/java/com/cameleer/server/app/license/PostgresLicenseRepositoryIT.java b/cameleer-server-app/src/test/java/com/cameleer/server/app/license/PostgresLicenseRepositoryIT.java new file mode 100644 index 00000000..04b5af4e --- /dev/null +++ b/cameleer-server-app/src/test/java/com/cameleer/server/app/license/PostgresLicenseRepositoryIT.java @@ -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); + } +} diff --git a/cameleer-server-app/src/test/java/com/cameleer/server/app/license/RetentionCapEnforcementIT.java b/cameleer-server-app/src/test/java/com/cameleer/server/app/license/RetentionCapEnforcementIT.java new file mode 100644 index 00000000..ab9e95d5 --- /dev/null +++ b/cameleer-server-app/src/test/java/com/cameleer/server/app/license/RetentionCapEnforcementIT.java @@ -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. + * + *

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 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 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 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); + } +} diff --git a/cameleer-server-app/src/test/java/com/cameleer/server/app/license/RetentionPolicyApplierTest.java b/cameleer-server-app/src/test/java/com/cameleer/server/app/license/RetentionPolicyApplierTest.java new file mode 100644 index 00000000..566755a4 --- /dev/null +++ b/cameleer-server-app/src/test/java/com/cameleer/server/app/license/RetentionPolicyApplierTest.java @@ -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 sql = ArgumentCaptor.forClass(String.class); + verify(ch, times(RetentionPolicyApplier.SPECS.size())).execute(sql.capture()); + + List 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 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 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 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 sql = ArgumentCaptor.forClass(String.class); + verify(ch, times(RetentionPolicyApplier.SPECS.size())).execute(sql.capture()); + List 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 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)); + } +} diff --git a/cameleer-server-app/src/test/java/com/cameleer/server/app/license/RetentionRuntimeRecomputeIT.java b/cameleer-server-app/src/test/java/com/cameleer/server/app/license/RetentionRuntimeRecomputeIT.java new file mode 100644 index 00000000..52d7062f --- /dev/null +++ b/cameleer-server-app/src/test/java/com/cameleer/server/app/license/RetentionRuntimeRecomputeIT.java @@ -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 = ''} statements + * against ClickHouse so per-env retention reflects the new {@code min(licenseCap, env.configured)}. + * + *

Strategy: 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.

+ * + *

Env retention setup: 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}.

+ * + *

Assertion target: {@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.

+ */ +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. + * + *

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 = ''} + * predicate is included so a stale TTL for a different env can't satisfy the + * assertion.

+ */ + 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); + } +} diff --git a/cameleer-server-app/src/test/java/com/cameleer/server/app/license/UserCapEnforcementIT.java b/cameleer-server-app/src/test/java/com/cameleer/server/app/license/UserCapEnforcementIT.java new file mode 100644 index 00000000..12bacec6 --- /dev/null +++ b/cameleer-server-app/src/test/java/com/cameleer/server/app/license/UserCapEnforcementIT.java @@ -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 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 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); + } +} diff --git a/cameleer-server-app/src/test/java/com/cameleer/server/app/outbound/controller/OutboundConnectionAdminControllerIT.java b/cameleer-server-app/src/test/java/com/cameleer/server/app/outbound/controller/OutboundConnectionAdminControllerIT.java index dd81092e..2d4dce84 100644 --- a/cameleer-server-app/src/test/java/com/cameleer/server/app/outbound/controller/OutboundConnectionAdminControllerIT.java +++ b/cameleer-server-app/src/test/java/com/cameleer/server/app/outbound/controller/OutboundConnectionAdminControllerIT.java @@ -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) { diff --git a/cameleer-server-app/src/test/java/com/cameleer/server/app/runtime/BlueGreenStrategyIT.java b/cameleer-server-app/src/test/java/com/cameleer/server/app/runtime/BlueGreenStrategyIT.java index 874f663d..a7cd0c09 100644 --- a/cameleer-server-app/src/test/java/com/cameleer/server/app/runtime/BlueGreenStrategyIT.java +++ b/cameleer-server-app/src/test/java/com/cameleer/server/app/runtime/BlueGreenStrategyIT.java @@ -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); diff --git a/cameleer-server-app/src/test/java/com/cameleer/server/app/runtime/DeploymentSnapshotIT.java b/cameleer-server-app/src/test/java/com/cameleer/server/app/runtime/DeploymentSnapshotIT.java index fd1df53d..fb5681b1 100644 --- a/cameleer-server-app/src/test/java/com/cameleer/server/app/runtime/DeploymentSnapshotIT.java +++ b/cameleer-server-app/src/test/java/com/cameleer/server/app/runtime/DeploymentSnapshotIT.java @@ -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)); } // ----------------------------------------------------------------------- diff --git a/cameleer-server-app/src/test/java/com/cameleer/server/app/runtime/DockerRuntimeOrchestratorHardeningTest.java b/cameleer-server-app/src/test/java/com/cameleer/server/app/runtime/DockerRuntimeOrchestratorHardeningTest.java new file mode 100644 index 00000000..b865a6dd --- /dev/null +++ b/cameleer-server-app/src/test/java/com/cameleer/server/app/runtime/DockerRuntimeOrchestratorHardeningTest.java @@ -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 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 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 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 hostCaptor = ArgumentCaptor.forClass(HostConfig.class); + org.mockito.Mockito.verify(createCmd).withHostConfig(hostCaptor.capture()); + + assertThat(hostCaptor.getValue().getRuntime()).isEqualTo("runsc"); + } +} diff --git a/cameleer-server-app/src/test/java/com/cameleer/server/app/runtime/RollingStrategyIT.java b/cameleer-server-app/src/test/java/com/cameleer/server/app/runtime/RollingStrategyIT.java index c7012ccf..fd6a9050 100644 --- a/cameleer-server-app/src/test/java/com/cameleer/server/app/runtime/RollingStrategyIT.java +++ b/cameleer-server-app/src/test/java/com/cameleer/server/app/runtime/RollingStrategyIT.java @@ -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); diff --git a/cameleer-server-app/src/test/java/com/cameleer/server/app/security/JwtRefreshIT.java b/cameleer-server-app/src/test/java/com/cameleer/server/app/security/JwtRefreshIT.java index 8492e9c4..4dc9820c 100644 --- a/cameleer-server-app/src/test/java/com/cameleer/server/app/security/JwtRefreshIT.java +++ b/cameleer-server-app/src/test/java/com/cameleer/server/app/security/JwtRefreshIT.java @@ -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 = """ { diff --git a/cameleer-server-app/src/test/java/com/cameleer/server/app/security/RegistrationSecurityIT.java b/cameleer-server-app/src/test/java/com/cameleer/server/app/security/RegistrationSecurityIT.java index d85e0294..63095bf7 100644 --- a/cameleer-server-app/src/test/java/com/cameleer/server/app/security/RegistrationSecurityIT.java +++ b/cameleer-server-app/src/test/java/com/cameleer/server/app/security/RegistrationSecurityIT.java @@ -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 registerAgent(String agentId) { String json = """ { diff --git a/cameleer-server-app/src/test/java/com/cameleer/server/app/security/SecurityFilterIT.java b/cameleer-server-app/src/test/java/com/cameleer/server/app/security/SecurityFilterIT.java index 4b1832b3..00737ad9 100644 --- a/cameleer-server-app/src/test/java/com/cameleer/server/app/security/SecurityFilterIT.java +++ b/cameleer-server-app/src/test/java/com/cameleer/server/app/security/SecurityFilterIT.java @@ -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(); diff --git a/cameleer-server-app/src/test/java/com/cameleer/server/app/security/SseSigningIT.java b/cameleer-server-app/src/test/java/com/cameleer/server/app/security/SseSigningIT.java index 35992e60..aa887b79 100644 --- a/cameleer-server-app/src/test/java/com/cameleer/server/app/security/SseSigningIT.java +++ b/cameleer-server-app/src/test/java/com/cameleer/server/app/security/SseSigningIT.java @@ -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); diff --git a/cameleer-server-app/src/test/java/com/cameleer/server/app/storage/DiagramLinkingIT.java b/cameleer-server-app/src/test/java/com/cameleer/server/app/storage/DiagramLinkingIT.java index b44b634d..1b75e040 100644 --- a/cameleer-server-app/src/test/java/com/cameleer/server/app/storage/DiagramLinkingIT.java +++ b/cameleer-server-app/src/test/java/com/cameleer/server/app/storage/DiagramLinkingIT.java @@ -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 = """ diff --git a/cameleer-server-app/src/test/java/com/cameleer/server/app/storage/IngestionSchemaIT.java b/cameleer-server-app/src/test/java/com/cameleer/server/app/storage/IngestionSchemaIT.java index 3219a191..83b07852 100644 --- a/cameleer-server-app/src/test/java/com/cameleer/server/app/storage/IngestionSchemaIT.java +++ b/cameleer-server-app/src/test/java/com/cameleer/server/app/storage/IngestionSchemaIT.java @@ -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 = """ diff --git a/cameleer-server-app/src/test/java/com/cameleer/server/core/license/LicenseGateTest.java b/cameleer-server-app/src/test/java/com/cameleer/server/core/license/LicenseGateTest.java index 95186fdd..47804c4e 100644 --- a/cameleer-server-app/src/test/java/com/cameleer/server/core/license/LicenseGateTest.java +++ b/cameleer-server-app/src/test/java/com/cameleer/server/core/license/LicenseGateTest.java @@ -3,39 +3,62 @@ package com.cameleer.server.core.license; import org.junit.jupiter.api.Test; import java.time.Instant; -import java.time.temporal.ChronoUnit; import java.util.Map; -import java.util.Set; +import java.util.UUID; import static org.assertj.core.api.Assertions.assertThat; class LicenseGateTest { @Test - void noLicense_allFeaturesEnabled() { + void absent_byDefault() { LicenseGate gate = new LicenseGate(); - // No license loaded -> open mode - - assertThat(gate.isEnabled(Feature.debugger)).isTrue(); - assertThat(gate.isEnabled(Feature.replay)).isTrue(); - assertThat(gate.isEnabled(Feature.lineage)).isTrue(); - assertThat(gate.getTier()).isEqualTo("open"); + assertThat(gate.getState()).isEqualTo(LicenseState.ABSENT); + assertThat(gate.getEffectiveLimits().get("max_apps")) + .isEqualTo(DefaultTierLimits.DEFAULTS.get("max_apps")); + assertThat(gate.getCurrent()).isNull(); + assertThat(gate.getInvalidReason()).isNull(); } @Test - void withLicense_onlyLicensedFeaturesEnabled() { + void load_setsActiveAndMergesLimits() { LicenseGate gate = new LicenseGate(); - LicenseInfo license = new LicenseInfo("MID", - Set.of(Feature.topology, Feature.lineage, Feature.correlation), - Map.of("max_agents", 10, "retention_days", 30), - Instant.now(), Instant.now().plus(365, ChronoUnit.DAYS)); - gate.load(license); + LicenseInfo info = new LicenseInfo(UUID.randomUUID(), "acme", "label", + Map.of("max_apps", 50), Instant.now(), + Instant.now().plusSeconds(86400), 0); + gate.load(info); - assertThat(gate.isEnabled(Feature.topology)).isTrue(); - assertThat(gate.isEnabled(Feature.lineage)).isTrue(); - assertThat(gate.isEnabled(Feature.debugger)).isFalse(); - assertThat(gate.isEnabled(Feature.replay)).isFalse(); - assertThat(gate.getTier()).isEqualTo("MID"); - assertThat(gate.getLimit("max_agents", 0)).isEqualTo(10); + assertThat(gate.getState()).isEqualTo(LicenseState.ACTIVE); + assertThat(gate.getEffectiveLimits().get("max_apps")).isEqualTo(50); + assertThat(gate.getEffectiveLimits().get("max_users")) + .isEqualTo(DefaultTierLimits.DEFAULTS.get("max_users")); + } + + @Test + void markInvalid_overridesActive() { + LicenseGate gate = new LicenseGate(); + LicenseInfo info = new LicenseInfo(UUID.randomUUID(), "acme", null, + Map.of("max_apps", 50), Instant.now(), + Instant.now().plusSeconds(86400), 0); + gate.load(info); + + gate.markInvalid("signature failed"); + assertThat(gate.getState()).isEqualTo(LicenseState.INVALID); + assertThat(gate.getEffectiveLimits().get("max_apps")) + .isEqualTo(DefaultTierLimits.DEFAULTS.get("max_apps")); + assertThat(gate.getInvalidReason()).isEqualTo("signature failed"); + } + + @Test + void clear_returnsToAbsent() { + LicenseGate gate = new LicenseGate(); + LicenseInfo info = new LicenseInfo(UUID.randomUUID(), "acme", null, + Map.of(), Instant.now(), + Instant.now().plusSeconds(86400), 0); + gate.load(info); + gate.clear(); + + assertThat(gate.getState()).isEqualTo(LicenseState.ABSENT); + assertThat(gate.getCurrent()).isNull(); } } diff --git a/cameleer-server-app/src/test/java/com/cameleer/server/core/license/LicenseValidatorTest.java b/cameleer-server-app/src/test/java/com/cameleer/server/core/license/LicenseValidatorTest.java index d321a2b0..54e43835 100644 --- a/cameleer-server-app/src/test/java/com/cameleer/server/core/license/LicenseValidatorTest.java +++ b/cameleer-server-app/src/test/java/com/cameleer/server/core/license/LicenseValidatorTest.java @@ -2,11 +2,14 @@ package com.cameleer.server.core.license; import org.junit.jupiter.api.Test; -import java.security.*; -import java.security.spec.NamedParameterSpec; +import java.security.KeyPair; +import java.security.KeyPairGenerator; +import java.security.PrivateKey; +import java.security.Signature; import java.time.Instant; import java.time.temporal.ChronoUnit; import java.util.Base64; +import java.util.UUID; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; @@ -29,34 +32,34 @@ class LicenseValidatorTest { void validate_validLicense_returnsLicenseInfo() throws Exception { KeyPair kp = generateKeyPair(); String publicKeyBase64 = Base64.getEncoder().encodeToString(kp.getPublic().getEncoded()); - LicenseValidator validator = new LicenseValidator(publicKeyBase64); + LicenseValidator validator = new LicenseValidator(publicKeyBase64, "acme"); Instant expires = Instant.now().plus(365, ChronoUnit.DAYS); String payload = """ - {"tier":"HIGH","features":["topology","lineage","debugger"],"limits":{"max_agents":50,"retention_days":90},"iat":%d,"exp":%d} - """.formatted(Instant.now().getEpochSecond(), expires.getEpochSecond()).trim(); + {"licenseId":"%s","tenantId":"acme","label":"HIGH","tier":"HIGH","limits":{"max_agents":50,"retention_days":90},"iat":%d,"exp":%d,"gracePeriodDays":7} + """.formatted(UUID.randomUUID(), Instant.now().getEpochSecond(), expires.getEpochSecond()).trim(); String signature = sign(kp.getPrivate(), payload); String token = Base64.getEncoder().encodeToString(payload.getBytes()) + "." + signature; LicenseInfo info = validator.validate(token); - assertThat(info.tier()).isEqualTo("HIGH"); - assertThat(info.hasFeature(Feature.debugger)).isTrue(); - assertThat(info.hasFeature(Feature.replay)).isFalse(); + assertThat(info.label()).isEqualTo("HIGH"); assertThat(info.getLimit("max_agents", 0)).isEqualTo(50); assertThat(info.isExpired()).isFalse(); + assertThat(info.tenantId()).isEqualTo("acme"); + assertThat(info.gracePeriodDays()).isEqualTo(7); } @Test void validate_expiredLicense_throwsException() throws Exception { KeyPair kp = generateKeyPair(); String publicKeyBase64 = Base64.getEncoder().encodeToString(kp.getPublic().getEncoded()); - LicenseValidator validator = new LicenseValidator(publicKeyBase64); + LicenseValidator validator = new LicenseValidator(publicKeyBase64, "acme"); Instant past = Instant.now().minus(1, ChronoUnit.DAYS); String payload = """ - {"tier":"LOW","features":["topology"],"limits":{},"iat":%d,"exp":%d} - """.formatted(past.minus(30, ChronoUnit.DAYS).getEpochSecond(), past.getEpochSecond()).trim(); + {"licenseId":"%s","tenantId":"acme","tier":"LOW","limits":{},"iat":%d,"exp":%d} + """.formatted(UUID.randomUUID(), past.minus(30, ChronoUnit.DAYS).getEpochSecond(), past.getEpochSecond()).trim(); String signature = sign(kp.getPrivate(), payload); String token = Base64.getEncoder().encodeToString(payload.getBytes()) + "." + signature; @@ -69,11 +72,11 @@ class LicenseValidatorTest { void validate_tamperedPayload_throwsException() throws Exception { KeyPair kp = generateKeyPair(); String publicKeyBase64 = Base64.getEncoder().encodeToString(kp.getPublic().getEncoded()); - LicenseValidator validator = new LicenseValidator(publicKeyBase64); + LicenseValidator validator = new LicenseValidator(publicKeyBase64, "acme"); String payload = """ - {"tier":"LOW","features":["topology"],"limits":{},"iat":0,"exp":9999999999} - """.trim(); + {"licenseId":"%s","tenantId":"acme","tier":"LOW","limits":{},"iat":0,"exp":9999999999} + """.formatted(UUID.randomUUID()).trim(); String signature = sign(kp.getPrivate(), payload); // Tamper with payload @@ -84,4 +87,55 @@ class LicenseValidatorTest { .isInstanceOf(SecurityException.class) .hasMessageContaining("signature"); } + + @Test + void validate_missingTenantId_throws() throws Exception { + KeyPair kp = generateKeyPair(); + String publicKeyBase64 = Base64.getEncoder().encodeToString(kp.getPublic().getEncoded()); + LicenseValidator validator = new LicenseValidator(publicKeyBase64, "acme"); + + Instant exp = Instant.now().plus(30, ChronoUnit.DAYS); + String payload = """ + {"licenseId":"%s","tier":"X","limits":{},"iat":%d,"exp":%d} + """.formatted(UUID.randomUUID(), Instant.now().getEpochSecond(), exp.getEpochSecond()).trim(); + String token = Base64.getEncoder().encodeToString(payload.getBytes()) + "." + sign(kp.getPrivate(), payload); + + assertThatThrownBy(() -> validator.validate(token)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("tenantId"); + } + + @Test + void validate_tenantIdMismatch_throws() throws Exception { + KeyPair kp = generateKeyPair(); + String publicKeyBase64 = Base64.getEncoder().encodeToString(kp.getPublic().getEncoded()); + LicenseValidator validator = new LicenseValidator(publicKeyBase64, "beta"); + + Instant exp = Instant.now().plus(30, ChronoUnit.DAYS); + String payload = """ + {"licenseId":"%s","tenantId":"acme","tier":"X","limits":{},"iat":%d,"exp":%d} + """.formatted(UUID.randomUUID(), Instant.now().getEpochSecond(), exp.getEpochSecond()).trim(); + String token = Base64.getEncoder().encodeToString(payload.getBytes()) + "." + sign(kp.getPrivate(), payload); + + assertThatThrownBy(() -> validator.validate(token)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("tenantId"); + } + + @Test + void validate_missingLicenseId_throws() throws Exception { + KeyPair kp = generateKeyPair(); + String publicKeyBase64 = Base64.getEncoder().encodeToString(kp.getPublic().getEncoded()); + LicenseValidator validator = new LicenseValidator(publicKeyBase64, "acme"); + + Instant exp = Instant.now().plus(30, ChronoUnit.DAYS); + String payload = """ + {"tenantId":"acme","tier":"X","limits":{},"iat":%d,"exp":%d} + """.formatted(Instant.now().getEpochSecond(), exp.getEpochSecond()).trim(); + String token = Base64.getEncoder().encodeToString(payload.getBytes()) + "." + sign(kp.getPrivate(), payload); + + assertThatThrownBy(() -> validator.validate(token)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("licenseId"); + } } diff --git a/cameleer-server-core/src/main/java/com/cameleer/server/core/admin/AuditCategory.java b/cameleer-server-core/src/main/java/com/cameleer/server/core/admin/AuditCategory.java index 7219f321..48316421 100644 --- a/cameleer-server-core/src/main/java/com/cameleer/server/core/admin/AuditCategory.java +++ b/cameleer-server-core/src/main/java/com/cameleer/server/core/admin/AuditCategory.java @@ -4,5 +4,6 @@ public enum AuditCategory { INFRA, AUTH, USER_MGMT, CONFIG, RBAC, AGENT, OUTBOUND_CONNECTION_CHANGE, OUTBOUND_HTTP_TRUST_CHANGE, ALERT_RULE_CHANGE, ALERT_SILENCE_CHANGE, - DEPLOYMENT + DEPLOYMENT, + LICENSE } diff --git a/cameleer-server-core/src/main/java/com/cameleer/server/core/agent/AgentRegistryService.java b/cameleer-server-core/src/main/java/com/cameleer/server/core/agent/AgentRegistryService.java index 014e503d..4e2c6383 100644 --- a/cameleer-server-core/src/main/java/com/cameleer/server/core/agent/AgentRegistryService.java +++ b/cameleer-server-core/src/main/java/com/cameleer/server/core/agent/AgentRegistryService.java @@ -1,5 +1,6 @@ package com.cameleer.server.core.agent; +import com.cameleer.server.core.runtime.CreateGuard; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -29,6 +30,7 @@ public class AgentRegistryService { private final long staleThresholdMs; private final long deadThresholdMs; private final long commandExpiryMs; + private final CreateGuard registerGuard; private final ConcurrentHashMap agents = new ConcurrentHashMap<>(); private final ConcurrentHashMap> commands = new ConcurrentHashMap<>(); @@ -36,10 +38,18 @@ public class AgentRegistryService { private volatile AgentEventListener eventListener; + /** Backwards-compatible 3-arg ctor (no enforcement). Used by tests. */ public AgentRegistryService(long staleThresholdMs, long deadThresholdMs, long commandExpiryMs) { + this(staleThresholdMs, deadThresholdMs, commandExpiryMs, CreateGuard.NOOP); + } + + /** Production ctor with license-cap enforcement on new registrations. */ + public AgentRegistryService(long staleThresholdMs, long deadThresholdMs, long commandExpiryMs, + CreateGuard registerGuard) { this.staleThresholdMs = staleThresholdMs; this.deadThresholdMs = deadThresholdMs; this.commandExpiryMs = commandExpiryMs; + this.registerGuard = registerGuard; } /** @@ -55,7 +65,9 @@ public class AgentRegistryService { return agents.compute(id, (key, existing) -> { if (existing != null) { - // Re-registration: update metadata, reset to LIVE + // Re-registration: update metadata, reset to LIVE. + // Re-registers bypass the license cap check — they don't grow the registry, + // and rejecting them would orphan an agent that already counts against the cap. log.info("Agent {} re-registering (was {})", id, existing.state()); return existing .withMetadata(name, application, environmentId, version, List.copyOf(routeIds), Map.copyOf(capabilities)) @@ -64,11 +76,25 @@ public class AgentRegistryService { .withRegisteredAt(now) .withStaleTransitionTime(null); } + // NEW registration — consult the cap. ConcurrentHashMap.compute propagates the + // exception thrown here, so the controller advice (LicenseExceptionAdvice) maps + // LicenseCapExceededException to 403 with the structured envelope. + registerGuard.check(liveCount()); log.info("Agent {} registered (name={}, application={}, env={})", id, name, application, environmentId); return newAgent; }); } + /** + * Live-only agent count for license-cap enforcement and the {@code /admin/license/usage} + * surface. STALE/DEAD/SHUTDOWN agents are excluded so the cap reflects the working fleet, + * not historical residue. Re-registers of an existing agent revive it via the + * {@code existing != null} branch in {@link #register}, so this count never double-counts. + */ + public int liveCount() { + return (int) agents.values().stream().filter(a -> a.state() == AgentState.LIVE).count(); + } + /** * Process a heartbeat from an agent. * Updates lastHeartbeat, routeIds (if provided), capabilities (if provided), diff --git a/cameleer-server-core/src/main/java/com/cameleer/server/core/alerting/AlertRuleRepository.java b/cameleer-server-core/src/main/java/com/cameleer/server/core/alerting/AlertRuleRepository.java index eecd8b1f..cc4da580 100644 --- a/cameleer-server-core/src/main/java/com/cameleer/server/core/alerting/AlertRuleRepository.java +++ b/cameleer-server-core/src/main/java/com/cameleer/server/core/alerting/AlertRuleRepository.java @@ -14,6 +14,9 @@ public interface AlertRuleRepository { List findRuleIdsByOutboundConnectionId(UUID connectionId); // used by rulesReferencing() void delete(UUID id); + /** Tenant-wide rule count — feeds the {@code max_alert_rules} license cap. */ + long count(); + /** Claim up to batchSize rules whose next_evaluation_at <= now AND (claimed_until IS NULL OR claimed_until < now). * Atomically sets claimed_by + claimed_until = now + ttl. Returns claimed rules. */ List claimDueRules(String instanceId, int batchSize, int claimTtlSeconds); diff --git a/cameleer-server-core/src/main/java/com/cameleer/server/core/license/DefaultTierLimits.java b/cameleer-server-core/src/main/java/com/cameleer/server/core/license/DefaultTierLimits.java new file mode 100644 index 00000000..48b4c655 --- /dev/null +++ b/cameleer-server-core/src/main/java/com/cameleer/server/core/license/DefaultTierLimits.java @@ -0,0 +1,30 @@ +package com.cameleer.server.core.license; + +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.Map; + +public final class DefaultTierLimits { + + public static final Map DEFAULTS; + + static { + Map m = new LinkedHashMap<>(); + m.put("max_environments", 1); + m.put("max_apps", 3); + m.put("max_agents", 5); + m.put("max_users", 3); + m.put("max_outbound_connections", 1); + m.put("max_alert_rules", 2); + m.put("max_total_cpu_millis", 2000); + m.put("max_total_memory_mb", 2048); + m.put("max_total_replicas", 5); + m.put("max_execution_retention_days", 1); + m.put("max_log_retention_days", 1); + m.put("max_metric_retention_days", 1); + m.put("max_jar_retention_count", 3); + DEFAULTS = Collections.unmodifiableMap(m); + } + + private DefaultTierLimits() {} +} diff --git a/cameleer-server-core/src/main/java/com/cameleer/server/core/license/Feature.java b/cameleer-server-core/src/main/java/com/cameleer/server/core/license/Feature.java deleted file mode 100644 index 72e74df4..00000000 --- a/cameleer-server-core/src/main/java/com/cameleer/server/core/license/Feature.java +++ /dev/null @@ -1,9 +0,0 @@ -package com.cameleer.server.core.license; - -public enum Feature { - topology, - lineage, - correlation, - debugger, - replay -} diff --git a/cameleer-server-core/src/main/java/com/cameleer/server/core/license/LicenseGate.java b/cameleer-server-core/src/main/java/com/cameleer/server/core/license/LicenseGate.java index b6ca83fa..be6b2af3 100644 --- a/cameleer-server-core/src/main/java/com/cameleer/server/core/license/LicenseGate.java +++ b/cameleer-server-core/src/main/java/com/cameleer/server/core/license/LicenseGate.java @@ -9,27 +9,58 @@ public class LicenseGate { private static final Logger log = LoggerFactory.getLogger(LicenseGate.class); - private final AtomicReference current = new AtomicReference<>(LicenseInfo.open()); + private static final class Snapshot { + final LicenseInfo license; // null when ABSENT or INVALID + final String invalidReason; // null unless INVALID + Snapshot(LicenseInfo l, String r) { this.license = l; this.invalidReason = r; } + } + + private final AtomicReference snap = new AtomicReference<>(new Snapshot(null, null)); public void load(LicenseInfo license) { - current.set(license); - log.info("License loaded: tier={}, features={}, expires={}", - license.tier(), license.features(), license.expiresAt()); + snap.set(new Snapshot(license, null)); + log.info("License loaded: licenseId={}, tenantId={}, exp={}, gracePeriodDays={}", + license.licenseId(), license.tenantId(), license.expiresAt(), license.gracePeriodDays()); } - public boolean isEnabled(Feature feature) { - return current.get().hasFeature(feature); + public void markInvalid(String reason) { + snap.set(new Snapshot(null, reason)); + log.error("License marked INVALID: {}", reason); } - public String getTier() { - return current.get().tier(); - } - - public int getLimit(String key, int defaultValue) { - return current.get().getLimit(key, defaultValue); + public void clear() { + snap.set(new Snapshot(null, null)); + log.info("License cleared"); } public LicenseInfo getCurrent() { - return current.get(); + return snap.get().license; + } + + public String getInvalidReason() { + return snap.get().invalidReason; + } + + public LicenseState getState() { + Snapshot s = snap.get(); + return LicenseStateMachine.classify(s.license, s.invalidReason); + } + + /** Effective limits = defaults UNION license.limits, except in EXPIRED/ABSENT/INVALID where defaults win. */ + public LicenseLimits getEffectiveLimits() { + Snapshot s = snap.get(); + LicenseState state = LicenseStateMachine.classify(s.license, s.invalidReason); + if (state == LicenseState.ACTIVE || state == LicenseState.GRACE) { + return LicenseLimits.mergeOverDefaults(s.license.limits()); + } + return LicenseLimits.defaultsOnly(); + } + + public int getLimit(String key, int defaultValue) { + try { + return getEffectiveLimits().get(key); + } catch (IllegalArgumentException e) { + return defaultValue; + } } } diff --git a/cameleer-server-core/src/main/java/com/cameleer/server/core/license/LicenseInfo.java b/cameleer-server-core/src/main/java/com/cameleer/server/core/license/LicenseInfo.java index 2940e3e6..d18354c8 100644 --- a/cameleer-server-core/src/main/java/com/cameleer/server/core/license/LicenseInfo.java +++ b/cameleer-server-core/src/main/java/com/cameleer/server/core/license/LicenseInfo.java @@ -2,29 +2,45 @@ package com.cameleer.server.core.license; import java.time.Instant; import java.util.Map; -import java.util.Set; +import java.util.Objects; +import java.util.UUID; +/** A parsed and signature-verified license. Construct via {@link LicenseValidator}. */ public record LicenseInfo( - String tier, - Set features, + UUID licenseId, + String tenantId, + String label, Map limits, Instant issuedAt, - Instant expiresAt + Instant expiresAt, + int gracePeriodDays ) { - public boolean isExpired() { - return expiresAt != null && Instant.now().isAfter(expiresAt); + public LicenseInfo { + Objects.requireNonNull(licenseId, "licenseId is required"); + Objects.requireNonNull(tenantId, "tenantId is required"); + Objects.requireNonNull(limits, "limits is required"); + Objects.requireNonNull(issuedAt, "issuedAt is required"); + Objects.requireNonNull(expiresAt, "expiresAt is required"); + if (tenantId.isBlank()) { + throw new IllegalArgumentException("tenantId must not be blank"); + } + if (gracePeriodDays < 0) { + throw new IllegalArgumentException("gracePeriodDays must be >= 0"); + } } - public boolean hasFeature(Feature feature) { - return features.contains(feature); + /** True iff now > expiresAt + gracePeriodDays. */ + public boolean isExpired() { + Instant deadline = expiresAt.plusSeconds((long) gracePeriodDays * 86400); + return Instant.now().isAfter(deadline); + } + + /** True iff now > expiresAt (regardless of grace). Used by the state machine to distinguish ACTIVE from GRACE. */ + public boolean isAfterRawExpiry() { + return Instant.now().isAfter(expiresAt); } public int getLimit(String key, int defaultValue) { return limits.getOrDefault(key, defaultValue); } - - /** Open license — all features enabled, no limits. Used when no license is configured. */ - public static LicenseInfo open() { - return new LicenseInfo("open", Set.of(Feature.values()), Map.of(), Instant.now(), null); - } } diff --git a/cameleer-server-core/src/main/java/com/cameleer/server/core/license/LicenseLimits.java b/cameleer-server-core/src/main/java/com/cameleer/server/core/license/LicenseLimits.java new file mode 100644 index 00000000..48e29f18 --- /dev/null +++ b/cameleer-server-core/src/main/java/com/cameleer/server/core/license/LicenseLimits.java @@ -0,0 +1,36 @@ +package com.cameleer.server.core.license; + +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.Objects; + +public record LicenseLimits(Map values) { + + public LicenseLimits { + Objects.requireNonNull(values, "values"); + } + + public static LicenseLimits defaultsOnly() { + return new LicenseLimits(DefaultTierLimits.DEFAULTS); + } + + public static LicenseLimits mergeOverDefaults(Map overrides) { + Map merged = new LinkedHashMap<>(DefaultTierLimits.DEFAULTS); + if (overrides != null) merged.putAll(overrides); + return new LicenseLimits(Collections.unmodifiableMap(merged)); + } + + public int get(String key) { + Integer v = values.get(key); + if (v == null) { + throw new IllegalArgumentException("Unknown license limit key: " + key); + } + return v; + } + + public boolean isDefaultSourced(String key, LicenseInfo license) { + if (license == null) return true; + return !license.limits().containsKey(key); + } +} diff --git a/cameleer-server-core/src/main/java/com/cameleer/server/core/license/LicenseState.java b/cameleer-server-core/src/main/java/com/cameleer/server/core/license/LicenseState.java new file mode 100644 index 00000000..711c367a --- /dev/null +++ b/cameleer-server-core/src/main/java/com/cameleer/server/core/license/LicenseState.java @@ -0,0 +1,9 @@ +package com.cameleer.server.core.license; + +public enum LicenseState { + ABSENT, + ACTIVE, + GRACE, + EXPIRED, + INVALID +} diff --git a/cameleer-server-core/src/main/java/com/cameleer/server/core/license/LicenseStateMachine.java b/cameleer-server-core/src/main/java/com/cameleer/server/core/license/LicenseStateMachine.java new file mode 100644 index 00000000..12f12a3f --- /dev/null +++ b/cameleer-server-core/src/main/java/com/cameleer/server/core/license/LicenseStateMachine.java @@ -0,0 +1,26 @@ +package com.cameleer.server.core.license; + +public final class LicenseStateMachine { + + private LicenseStateMachine() {} + + /** + * @param license parsed license, or null if no license is loaded + * @param invalidReason non-null if the last validation attempt failed + */ + public static LicenseState classify(LicenseInfo license, String invalidReason) { + if (invalidReason != null) { + return LicenseState.INVALID; + } + if (license == null) { + return LicenseState.ABSENT; + } + if (!license.isAfterRawExpiry()) { + return LicenseState.ACTIVE; + } + if (!license.isExpired()) { + return LicenseState.GRACE; + } + return LicenseState.EXPIRED; + } +} diff --git a/cameleer-server-core/src/main/java/com/cameleer/server/core/license/LicenseValidator.java b/cameleer-server-core/src/main/java/com/cameleer/server/core/license/LicenseValidator.java index 07e29043..09067ae4 100644 --- a/cameleer-server-core/src/main/java/com/cameleer/server/core/license/LicenseValidator.java +++ b/cameleer-server-core/src/main/java/com/cameleer/server/core/license/LicenseValidator.java @@ -5,10 +5,16 @@ import com.fasterxml.jackson.databind.ObjectMapper; import org.slf4j.Logger; import org.slf4j.LoggerFactory; -import java.security.*; +import java.security.KeyFactory; +import java.security.PublicKey; +import java.security.Signature; import java.security.spec.X509EncodedKeySpec; import java.time.Instant; -import java.util.*; +import java.util.Base64; +import java.util.HashMap; +import java.util.Map; +import java.util.Objects; +import java.util.UUID; public class LicenseValidator { @@ -16,8 +22,13 @@ public class LicenseValidator { private static final ObjectMapper objectMapper = new ObjectMapper(); private final PublicKey publicKey; + private final String expectedTenantId; - public LicenseValidator(String publicKeyBase64) { + public LicenseValidator(String publicKeyBase64, String expectedTenantId) { + Objects.requireNonNull(expectedTenantId, "expectedTenantId is required"); + if (expectedTenantId.isBlank()) { + throw new IllegalArgumentException("expectedTenantId must not be blank"); + } try { byte[] keyBytes = Base64.getDecoder().decode(publicKeyBase64); KeyFactory kf = KeyFactory.getInstance("Ed25519"); @@ -25,6 +36,7 @@ public class LicenseValidator { } catch (Exception e) { throw new IllegalStateException("Failed to load license public key", e); } + this.expectedTenantId = expectedTenantId; } public LicenseInfo validate(String token) { @@ -36,7 +48,6 @@ public class LicenseValidator { byte[] payloadBytes = Base64.getDecoder().decode(parts[0]); byte[] signatureBytes = Base64.getDecoder().decode(parts[1]); - // Verify signature try { Signature verifier = Signature.getInstance("Ed25519"); verifier.initVerify(publicKey); @@ -50,23 +61,25 @@ public class LicenseValidator { throw new SecurityException("License signature verification failed", e); } - // Parse payload try { JsonNode root = objectMapper.readTree(payloadBytes); - String tier = root.get("tier").asText(); - - Set features = new HashSet<>(); - if (root.has("features")) { - for (JsonNode f : root.get("features")) { - try { - features.add(Feature.valueOf(f.asText())); - } catch (IllegalArgumentException e) { - log.warn("Unknown feature in license: {}", f.asText()); - } - } + String licenseIdStr = textOrThrow(root, "licenseId"); + UUID licenseId; + try { + licenseId = UUID.fromString(licenseIdStr); + } catch (IllegalArgumentException e) { + throw new IllegalArgumentException("licenseId is not a valid UUID: " + licenseIdStr); } + String tenantId = textOrThrow(root, "tenantId"); + if (!tenantId.equals(expectedTenantId)) { + throw new IllegalArgumentException( + "License tenantId '" + tenantId + "' does not match server tenant '" + expectedTenantId + "'"); + } + + String label = root.has("label") ? root.get("label").asText() : null; + Map limits = new HashMap<>(); if (root.has("limits")) { root.get("limits").fields().forEachRemaining(entry -> @@ -74,12 +87,17 @@ public class LicenseValidator { } Instant issuedAt = root.has("iat") ? Instant.ofEpochSecond(root.get("iat").asLong()) : Instant.now(); - Instant expiresAt = root.has("exp") ? Instant.ofEpochSecond(root.get("exp").asLong()) : null; + if (!root.has("exp")) { + throw new IllegalArgumentException("exp is required"); + } + Instant expiresAt = Instant.ofEpochSecond(root.get("exp").asLong()); + int gracePeriodDays = root.has("gracePeriodDays") ? root.get("gracePeriodDays").asInt() : 0; - LicenseInfo info = new LicenseInfo(tier, features, limits, issuedAt, expiresAt); + LicenseInfo info = new LicenseInfo(licenseId, tenantId, label, limits, issuedAt, expiresAt, gracePeriodDays); if (info.isExpired()) { - throw new IllegalArgumentException("License expired at " + expiresAt); + throw new IllegalArgumentException("License expired at " + expiresAt + + " (grace period " + gracePeriodDays + " days)"); } return info; @@ -89,4 +107,11 @@ public class LicenseValidator { throw new IllegalArgumentException("Failed to parse license payload", e); } } + + private static String textOrThrow(JsonNode root, String field) { + if (!root.has(field) || root.get(field).asText().isBlank()) { + throw new IllegalArgumentException(field + " is required"); + } + return root.get(field).asText(); + } } diff --git a/cameleer-server-core/src/main/java/com/cameleer/server/core/runtime/AppRepository.java b/cameleer-server-core/src/main/java/com/cameleer/server/core/runtime/AppRepository.java index ec4ea778..c972ceec 100644 --- a/cameleer-server-core/src/main/java/com/cameleer/server/core/runtime/AppRepository.java +++ b/cameleer-server-core/src/main/java/com/cameleer/server/core/runtime/AppRepository.java @@ -8,6 +8,7 @@ import java.util.UUID; public interface AppRepository { List findByEnvironmentId(UUID environmentId); List findAll(); + long count(); Optional findById(UUID id); Optional findByEnvironmentIdAndSlug(UUID environmentId, String slug); Optional findBySlug(String slug); diff --git a/cameleer-server-core/src/main/java/com/cameleer/server/core/runtime/AppService.java b/cameleer-server-core/src/main/java/com/cameleer/server/core/runtime/AppService.java index 9a4c5a9b..884b844a 100644 --- a/cameleer-server-core/src/main/java/com/cameleer/server/core/runtime/AppService.java +++ b/cameleer-server-core/src/main/java/com/cameleer/server/core/runtime/AppService.java @@ -23,11 +23,18 @@ public class AppService { private final AppRepository appRepo; private final AppVersionRepository versionRepo; private final String jarStoragePath; + private final CreateGuard createGuard; public AppService(AppRepository appRepo, AppVersionRepository versionRepo, String jarStoragePath) { + this(appRepo, versionRepo, jarStoragePath, CreateGuard.NOOP); + } + + public AppService(AppRepository appRepo, AppVersionRepository versionRepo, String jarStoragePath, + CreateGuard createGuard) { this.appRepo = appRepo; this.versionRepo = versionRepo; this.jarStoragePath = jarStoragePath; + this.createGuard = createGuard; } public List listAll() { return appRepo.findAll(); } @@ -55,6 +62,7 @@ public class AppService { throw new IllegalArgumentException( "Invalid app slug: must match ^[a-z0-9][a-z0-9-]{0,63}$ (lowercase letters, digits, hyphens)"); } + createGuard.check(appRepo.count()); if (appRepo.findByEnvironmentIdAndSlug(environmentId, slug).isPresent()) { throw new IllegalArgumentException("App with slug '" + slug + "' already exists in this environment"); } diff --git a/cameleer-server-core/src/main/java/com/cameleer/server/core/runtime/CreateGuard.java b/cameleer-server-core/src/main/java/com/cameleer/server/core/runtime/CreateGuard.java new file mode 100644 index 00000000..470d51a8 --- /dev/null +++ b/cameleer-server-core/src/main/java/com/cameleer/server/core/runtime/CreateGuard.java @@ -0,0 +1,19 @@ +package com.cameleer.server.core.runtime; + +/** + * Hook called by domain services before creating a new entity. Implementations enforce + * environment-level policy (e.g., license caps) without dragging Spring or app-module types + * into core. + * + *

The guard is consulted with the current count; implementations throw to abort creation. + * The {@link #NOOP} singleton is the default for tests and for boot configurations that haven't + * wired enforcement yet.

+ */ +@FunctionalInterface +public interface CreateGuard { + + /** Throw to reject creation; otherwise return normally. */ + void check(long current); + + CreateGuard NOOP = c -> { }; +} diff --git a/cameleer-server-core/src/main/java/com/cameleer/server/core/runtime/Environment.java b/cameleer-server-core/src/main/java/com/cameleer/server/core/runtime/Environment.java index e4b53f94..5ee4c838 100644 --- a/cameleer-server-core/src/main/java/com/cameleer/server/core/runtime/Environment.java +++ b/cameleer-server-core/src/main/java/com/cameleer/server/core/runtime/Environment.java @@ -13,5 +13,23 @@ public record Environment( Map defaultContainerConfig, Integer jarRetentionCount, String color, - Instant createdAt -) {} + Instant createdAt, + int executionRetentionDays, + int logRetentionDays, + int metricRetentionDays +) { + public Environment { + if (executionRetentionDays < 1) { + throw new IllegalArgumentException( + "executionRetentionDays must be >= 1 (got " + executionRetentionDays + ")"); + } + if (logRetentionDays < 1) { + throw new IllegalArgumentException( + "logRetentionDays must be >= 1 (got " + logRetentionDays + ")"); + } + if (metricRetentionDays < 1) { + throw new IllegalArgumentException( + "metricRetentionDays must be >= 1 (got " + metricRetentionDays + ")"); + } + } +} diff --git a/cameleer-server-core/src/main/java/com/cameleer/server/core/runtime/EnvironmentRepository.java b/cameleer-server-core/src/main/java/com/cameleer/server/core/runtime/EnvironmentRepository.java index e1157005..54ee6fe7 100644 --- a/cameleer-server-core/src/main/java/com/cameleer/server/core/runtime/EnvironmentRepository.java +++ b/cameleer-server-core/src/main/java/com/cameleer/server/core/runtime/EnvironmentRepository.java @@ -7,6 +7,7 @@ import java.util.UUID; public interface EnvironmentRepository { List findAll(); + long count(); Optional findById(UUID id); Optional findBySlug(String slug); UUID create(String slug, String displayName, boolean production); diff --git a/cameleer-server-core/src/main/java/com/cameleer/server/core/runtime/EnvironmentService.java b/cameleer-server-core/src/main/java/com/cameleer/server/core/runtime/EnvironmentService.java index 4941ab70..650e1d26 100644 --- a/cameleer-server-core/src/main/java/com/cameleer/server/core/runtime/EnvironmentService.java +++ b/cameleer-server-core/src/main/java/com/cameleer/server/core/runtime/EnvironmentService.java @@ -17,9 +17,15 @@ public class EnvironmentService { private static final Pattern SLUG_PATTERN = Pattern.compile("^[a-z0-9][a-z0-9-]{0,63}$"); private final EnvironmentRepository repo; + private final CreateGuard createGuard; public EnvironmentService(EnvironmentRepository repo) { + this(repo, CreateGuard.NOOP); + } + + public EnvironmentService(EnvironmentRepository repo, CreateGuard createGuard) { this.repo = repo; + this.createGuard = createGuard; } public List listAll() { return repo.findAll(); } @@ -37,6 +43,7 @@ public class EnvironmentService { throw new IllegalArgumentException( "Invalid slug: must match ^[a-z0-9][a-z0-9-]{0,63}$ (lowercase letters, digits, hyphens)"); } + createGuard.check(repo.count()); if (repo.findBySlug(slug).isPresent()) { throw new IllegalArgumentException("Environment with slug '" + slug + "' already exists"); } diff --git a/cameleer-server-core/src/main/java/com/cameleer/server/core/security/UserRepository.java b/cameleer-server-core/src/main/java/com/cameleer/server/core/security/UserRepository.java index c8ff86f4..e39c6920 100644 --- a/cameleer-server-core/src/main/java/com/cameleer/server/core/security/UserRepository.java +++ b/cameleer-server-core/src/main/java/com/cameleer/server/core/security/UserRepository.java @@ -32,4 +32,7 @@ public interface UserRepository { /** Mark all tokens issued before {@code timestamp} as revoked for the given user. */ void revokeTokensBefore(String userId, Instant timestamp); + + /** Total user count, used for {@code max_users} license cap enforcement. */ + long count(); } diff --git a/cameleer-server-core/src/test/java/com/cameleer/server/core/license/DefaultTierLimitsTest.java b/cameleer-server-core/src/test/java/com/cameleer/server/core/license/DefaultTierLimitsTest.java new file mode 100644 index 00000000..398fe60b --- /dev/null +++ b/cameleer-server-core/src/test/java/com/cameleer/server/core/license/DefaultTierLimitsTest.java @@ -0,0 +1,30 @@ +package com.cameleer.server.core.license; + +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +class DefaultTierLimitsTest { + + @Test + void allDocumentedKeysHaveDefaults() { + for (String key : new String[]{ + "max_environments", "max_apps", "max_agents", "max_users", + "max_outbound_connections", "max_alert_rules", + "max_total_cpu_millis", "max_total_memory_mb", "max_total_replicas", + "max_execution_retention_days", "max_log_retention_days", + "max_metric_retention_days", "max_jar_retention_count" + }) { + assertThat(DefaultTierLimits.DEFAULTS).containsKey(key); + } + } + + @Test + void specificValues() { + assertThat(DefaultTierLimits.DEFAULTS.get("max_environments")).isEqualTo(1); + assertThat(DefaultTierLimits.DEFAULTS.get("max_apps")).isEqualTo(3); + assertThat(DefaultTierLimits.DEFAULTS.get("max_agents")).isEqualTo(5); + assertThat(DefaultTierLimits.DEFAULTS.get("max_total_cpu_millis")).isEqualTo(2000); + assertThat(DefaultTierLimits.DEFAULTS.get("max_log_retention_days")).isEqualTo(1); + } +} diff --git a/cameleer-server-core/src/test/java/com/cameleer/server/core/license/LicenseInfoTest.java b/cameleer-server-core/src/test/java/com/cameleer/server/core/license/LicenseInfoTest.java new file mode 100644 index 00000000..22017e08 --- /dev/null +++ b/cameleer-server-core/src/test/java/com/cameleer/server/core/license/LicenseInfoTest.java @@ -0,0 +1,64 @@ +package com.cameleer.server.core.license; + +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 LicenseInfoTest { + + @Test + void requiresLicenseId() { + assertThatThrownBy(() -> new LicenseInfo( + null, "acme", "label", + Map.of(), Instant.now(), Instant.now().plusSeconds(60), 0)) + .isInstanceOf(NullPointerException.class) + .hasMessageContaining("licenseId"); + } + + @Test + void requiresTenantId() { + assertThatThrownBy(() -> new LicenseInfo( + UUID.randomUUID(), null, "label", + Map.of(), Instant.now(), Instant.now().plusSeconds(60), 0)) + .isInstanceOf(NullPointerException.class) + .hasMessageContaining("tenantId"); + } + + @Test + void emptyTenantIdRejected() { + assertThatThrownBy(() -> new LicenseInfo( + UUID.randomUUID(), " ", "label", + Map.of(), Instant.now(), Instant.now().plusSeconds(60), 0)) + .isInstanceOf(IllegalArgumentException.class); + } + + @Test + void getLimit_returnsDefaultWhenMissing() { + LicenseInfo info = new LicenseInfo( + UUID.randomUUID(), "acme", null, + Map.of("max_apps", 5), Instant.now(), + Instant.now().plusSeconds(60), 0); + assertThat(info.getLimit("max_apps", 99)).isEqualTo(5); + assertThat(info.getLimit("max_users", 99)).isEqualTo(99); + } + + @Test + void isExpired_honoursGracePeriod() { + Instant pastByTen = Instant.now().minusSeconds(10 * 86400); + LicenseInfo withinGrace = new LicenseInfo( + UUID.randomUUID(), "acme", null, Map.of(), + Instant.now().minusSeconds(40 * 86400), + pastByTen, 30); + assertThat(withinGrace.isExpired()).isFalse(); // 10 days into a 30-day grace + LicenseInfo pastGrace = new LicenseInfo( + UUID.randomUUID(), "acme", null, Map.of(), + Instant.now().minusSeconds(40 * 86400), + pastByTen, 5); + assertThat(pastGrace.isExpired()).isTrue(); // 10 days is past the 5-day grace + } +} diff --git a/cameleer-server-core/src/test/java/com/cameleer/server/core/license/LicenseStateMachineTest.java b/cameleer-server-core/src/test/java/com/cameleer/server/core/license/LicenseStateMachineTest.java new file mode 100644 index 00000000..09429499 --- /dev/null +++ b/cameleer-server-core/src/test/java/com/cameleer/server/core/license/LicenseStateMachineTest.java @@ -0,0 +1,57 @@ +package com.cameleer.server.core.license; + +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 LicenseStateMachineTest { + + @Test + void noLicense_isAbsent() { + assertThat(LicenseStateMachine.classify(null, null)).isEqualTo(LicenseState.ABSENT); + } + + @Test + void invalidReason_isInvalid() { + assertThat(LicenseStateMachine.classify(null, "signature failed")).isEqualTo(LicenseState.INVALID); + } + + @Test + void activeBeforeExp() { + LicenseInfo info = info(Instant.now().plusSeconds(86400), 0); + assertThat(LicenseStateMachine.classify(info, null)).isEqualTo(LicenseState.ACTIVE); + } + + @Test + void graceWithinGracePeriod() { + LicenseInfo info = info(Instant.now().minusSeconds(86400), 7); + assertThat(LicenseStateMachine.classify(info, null)).isEqualTo(LicenseState.GRACE); + } + + @Test + void expiredAfterGrace() { + LicenseInfo info = info(Instant.now().minusSeconds(8L * 86400), 7); + assertThat(LicenseStateMachine.classify(info, null)).isEqualTo(LicenseState.EXPIRED); + } + + @Test + void expiredImmediatelyWithZeroGrace() { + LicenseInfo info = info(Instant.now().minusSeconds(60), 0); + assertThat(LicenseStateMachine.classify(info, null)).isEqualTo(LicenseState.EXPIRED); + } + + @Test + void invalidWinsOverPresentLicense() { + LicenseInfo info = info(Instant.now().plusSeconds(86400), 0); + assertThat(LicenseStateMachine.classify(info, "tenant mismatch")).isEqualTo(LicenseState.INVALID); + } + + private LicenseInfo info(Instant exp, int graceDays) { + return new LicenseInfo(UUID.randomUUID(), "acme", null, Map.of(), + Instant.now().minusSeconds(3600), exp, graceDays); + } +} diff --git a/docs/SERVER-CAPABILITIES.md b/docs/SERVER-CAPABILITIES.md index d4f0e25d..529a5220 100644 --- a/docs/SERVER-CAPABILITIES.md +++ b/docs/SERVER-CAPABILITIES.md @@ -34,6 +34,24 @@ Each server instance serves exactly one tenant. Multiple tenants share infrastru --- +## Multi-Tenant Runtime Sandboxing + +When the server orchestrates tenant containers (SaaS / managed mode), every container is launched with an unconditional hardening contract — Java 17 has no `SecurityManager`, so isolation must live below the JVM. Camel ships components that turn a header into shell (`camel-exec`, `camel-bean`, `camel-groovy`, `camel-mvel`, `camel-velocity`), so tenant JARs are treated as hostile by default. + +| Layer | What is enforced | +|---|---| +| Capabilities | `cap_drop` every Linux capability the SDK enumerates (effectively ALL — outbound TCP needs none). | +| Privilege escalation | `no-new-privileges` — setuid binaries cannot escalate. | +| MAC profile | `apparmor=docker-default`. The Docker daemon's default seccomp profile is applied implicitly. | +| Filesystem | `read_only` rootfs. `/tmp` is a 256m tmpfs (`rw,nosuid` — `noexec` is intentionally **not** set so JNI native libs from Netty/Snappy/LZ4/Zstd can `dlopen`). | +| Resource caps | `pids_limit=512` per container; CPU and memory limits per tenant config. | +| Container runtime | Auto-detects gVisor (`runsc`) via `docker info` and uses it when registered with the daemon. Override with `CAMELEER_SERVER_RUNTIME_DOCKERRUNTIME` (e.g. `kata`, or `runc` to force the default). | +| Network | Per-tenant Docker bridge `cameleer-tenant-{slug}` + per-env discovery network `cameleer-env-{tenantId}-{envSlug}`. Tenants cannot reach each other's containers. | + +**Implication for tenants writing on-disk state**: with `read_only` rootfs, anything that needs durable disk (Kafka Streams RocksDB stores, Hibernate L2 cache, log files outside stdout) must be on a writeable volume. Per-app `containerConfig.writeableVolumes` support is tracked separately — see issue #153. + +--- + ## Agent Protocol ### Lifecycle diff --git a/docs/handoff/2026-04-26-license-saas-handoff.md b/docs/handoff/2026-04-26-license-saas-handoff.md new file mode 100644 index 00000000..10a719e1 --- /dev/null +++ b/docs/handoff/2026-04-26-license-saas-handoff.md @@ -0,0 +1,377 @@ +# License Enforcement — SaaS Handoff (2026-04-26) + +Handoff for the cameleer-saas team and customer-success engineers operating customer-facing cameleer-server deployments. Covers issuing, renewing, revoking, and operationally observing licenses. + +For end-customer operator docs, see `docs/license-enforcement.md`. For minting tooling, see `cameleer-license-minter/README.md`. For the original design + plan, see: + +- `docs/superpowers/specs/2026-04-25-license-enforcement-design.md` +- `docs/superpowers/plans/2026-04-25-license-enforcement.md` + +## Table of contents + +## Session context + +## What this delivers + +## Trust model architecture + +## Operational playbook + +## Key management + +## Cap matrix (plan tiers) + +## Telemetry the SaaS team can observe + +## Failure modes & runbook + +## Edge cases the SaaS team should know + +## Testing guidance + +## Pointers + +--- + +## Session context + +- **Branch:** `feature/runtime-hardening` +- **Commit range:** `ec51aef8..140ea884` — 40 commits delivering the full feature (3 doc/spec/plan commits + 14 implementation commits + 23 follow-ons covering enforcement, retention, metrics, REST surface, integration tests, and rules updates). +- **Plan tasks:** 36 of 36 complete. Tests green: core (122), minter (7), app unit (230), key ITs (`PostgresLicenseRepositoryIT`, `LicenseLifecycleIT`, `LicenseEnforcementIT`, `RetentionRuntimeRecomputeIT`, `SchemaBootstrapIT`). +- **Persisted state:** Flyway migration **V5** — adds the `license` table and three retention columns on `environments` (`execution_retention_days`, `log_retention_days`, `metric_retention_days`). + +### Key SHAs + +| SHA | Subject | +|---|---| +| `ec51aef8` | start of plan (above this is unrelated runtime-hardening work) | +| `551a7f12` | refactor(license): remove dead Feature enum and isEnabled scaffolding | +| `2ebe4989..0499a54e` | LicenseInfo / Validator / Limits / Gate redesign | +| `896b7e6e..f6657f81` | Standalone `cameleer-license-minter` module | +| `20aefd5b..b95e80a2` | PG schema, repository, service, boot wiring | +| `2bad9c3e..e198c13e` | Enforcement points, retention applier, REST surface, metrics, ITs | +| `140ea884` | docs(rules): document license enforcement classes + endpoints (head) | + +## What this delivers + +- **Cap enforcement** at 8 surfaces (env/app/agent/user/outbound/alert-rule creation, deploy-time compute caps, jar retention). +- **License lifecycle**: install (env > file > DB > API), daily revalidation cron + 60s post-startup tick, grace period, full state machine (ABSENT/ACTIVE/GRACE/EXPIRED/INVALID). +- **Retention enforcement**: ClickHouse TTL recomputed on every license change for `executions`, `processor_executions`, `logs`, `agent_metrics`, `agent_events`. Effective TTL = `min(licenseCap, env.configured)`. +- **Standalone `cameleer-license-minter` Maven module** for vendor-side license generation. **Not** in the server runtime/compile classpath. +- **Audit trail**: every install/replace/cap_exceeded/revalidate event under `AuditCategory.LICENSE`. +- **Observability**: 3 Prometheus gauges + 1 counter (see [Telemetry](#telemetry-the-saas-team-can-observe)). +- **Default tier**: small fixed caps when no license is installed; intentionally restrictive. + +## Trust model architecture + +``` + VENDOR / SaaS CUSTOMER (cameleer-server) + +-------------------------+ +------------------------------------+ + | cameleer-license- | | CAMELEER_SERVER_LICENSE_PUBLICKEY | + | minter (CLI/Java) | | CAMELEER_SERVER_TENANT_ID | + | | | | + | Ed25519 PRIVATE key | | Ed25519 PUBLIC key (matching) | + | (HSM / KMS / Vault) | | | + | | | | ^ | + | v | | | validate | + | LicenseMinter.mint | | | | + | | | token (HTTPS) | LicenseValidator | + | +-----token----+----------------->+ | | + | | env-var or POST | v | + +-------------------------+ | LicenseGate (state + limits) | + | | | + | v | + | LicenseEnforcer (cap checks) | + +------------------------------------+ +``` + +The vendor holds the **only** copy of the private key. Customers receive only the public key (over deployment-config channels) and the signed token. A compromised customer can read tokens but cannot forge new ones. + +The minter module physically lives in the cameleer-server repo for shared `LicenseInfo` types but is intentionally absent from the runtime classpath of the server. Verify with: + +```bash +mvn dependency:tree -pl cameleer-server-app | grep license-minter +# expected: empty (or test-scope only on dev branches) +``` + +## Operational playbook + +### Onboarding a new tenant + +1. Choose the tenant id (must match the customer's `CAMELEER_SERVER_TENANT_ID`; lowercase alphanumeric + dashes; immutable). +2. Decide whether to use the shared SaaS signing key or a dedicated per-tenant key. Shared is simpler and standard; per-tenant only if a customer has compliance requirements that mandate isolation. +3. Mint the initial license: + ```bash + java -jar cameleer-license-minter-1.0-SNAPSHOT-cli.jar \ + --private-key=/cameleer-license-priv.pem \ + --tenant= \ + --label=" ()" \ + --expires=2027-04-26 \ + --grace-days=14 \ + --max-environments= \ + --max-apps= \ + --max-agents= \ + --max-users= \ + --max-outbound-connections= \ + --max-alert-rules= \ + --max-total-cpu-millis= \ + --max-total-memory-mb= \ + --max-total-replicas= \ + --max-execution-retention-days= \ + --max-log-retention-days= \ + --max-metric-retention-days= \ + --max-jar-retention-count= \ + --output=/tmp/.lic \ + --public-key=/cameleer-license-pub.b64 \ + --verify + ``` +4. Deliver to the customer's server via either: + - **Container env var** (preferred for SaaS-managed deployments): `CAMELEER_SERVER_LICENSE_TOKEN=` set on the deploy descriptor. Activates at next boot. + - **Admin REST POST** (for hot install on a running server): `POST /api/v1/admin/license` with `{"token": "..."}`. Confirms successful installation in the response body. +5. Confirm acceptance: `GET /api/v1/admin/license` returns `state=ACTIVE`, the audit log shows `install_license`/`SUCCESS`, and `cameleer_license_state{state="ACTIVE"} == 1.0` in Prometheus. + +### Renewing a license + +1. Mint a new token with a later `--expires`. Use a **fresh `licenseId`** so the audit trail clearly distinguishes the renewal from the prior license. +2. Install via admin POST. The PG `license` row is updated in place (one row per tenant, upserted on `tenant_id`); the audit row records `replace_license` with `previousLicenseId`. +3. Confirm `lastValidatedAt` advances on the next 03:00 cron tick (or trigger by restart / `POST /admin/license`). + +### Adjusting caps mid-term + +Same as renewal: mint a new token with the new limits and install. The `limits` map of the new license replaces the prior one entirely (no merging — only `DefaultTierLimits` provides fallback for keys the new license omits). + +If the customer is **lowering** caps below current usage, there is no automatic enforcement against existing entities — only future creates are rejected. Communicate the implication clearly. The `/api/v1/admin/license/usage` endpoint after install will show `current > cap` rows, which is the operator's signal to clean up. + +### Revoking a license + +There is no remote revocation. Practical options: + +1. **Wait for expiry.** Short license terms (12 months max) keep this honest. +2. **Rotate the public key.** Push a new `CAMELEER_SERVER_LICENSE_PUBLICKEY` to the customer's server config and restart. All existing tokens become `INVALID` because the signature no longer verifies. This is destructive (all customers sharing this signing key need a re-issue), so reserve for true compromise scenarios. +3. **Deploy a corrupted token.** If the customer cooperates, set `CAMELEER_SERVER_LICENSE_TOKEN` to garbage; the boot loader marks it `INVALID`, default-tier caps apply. + +In all cases the customer falls to default-tier caps (1 env, 3 apps, 5 agents). They can continue running for evaluation; new creates fail with 403. + +### Migrating a license between server instances + +Tokens are bound to `tenantId`, not to a particular server instance. A token works on any server configured for the same tenant. To migrate: + +1. Provision the new server with `CAMELEER_SERVER_TENANT_ID=` and `CAMELEER_SERVER_LICENSE_PUBLICKEY=`. +2. Install the existing token on the new server (env var or POST). PG state is fresh on the new instance — usage starts at zero. +3. Decommission the old server. + +If both run simultaneously they both pass validation (same token, same key, same tenant id) and both apply the caps independently against their own local state — usage is **not** federated. + +## Key management + +### Where the signing key lives + +The SaaS team's Ed25519 private key is the trust root. Place it in: + +- **Production:** AWS KMS, GCP KMS, Azure Key Vault (with a non-exportable signing key) **or** HashiCorp Vault Transit. The minter API supports signing via a `PrivateKey` instance, so a custom integration that asks the KMS to sign canonicalized payload bytes is straightforward to build on top of `LicenseMinter.canonicalPayload(...)` (it's `static`-accessible for that purpose). +- **Pre-production / dev:** sealed file in a single privileged operator's home directory. Never on a CI server, never in the repo. + +For high-security environments, the minter CLI's `--private-key=` is the wrong fit — it requires the key bytes to be readable. Use the Java API directly: + +```java +PrivateKey kmsKey = kmsClient.getSigningKey("cameleer-license-prod"); +String token = LicenseMinter.mint(info, kmsKey); +``` + +The JCE provider for the KMS handles signing; the private bytes never leave the KMS. + +### Public key distribution + +Each tenant's server reads the public key from `CAMELEER_SERVER_LICENSE_PUBLICKEY` (base64-encoded X.509 SPKI). Distribute via: + +- **Helm values / Kubernetes Secret** for k8s-orchestrated tenants. +- **Docker compose env file** for self-hosted tenants. +- **Bare environment variable on the host** for VM tenants. + +A typo or whitespace difference will cause every license to be rejected. Build a smoke test that boots a sandbox server with the candidate public key and POSTs a known-good test token. + +### Rotation playbook + +Rotation is the trickiest part. The validator does not support multiple public keys — exactly one is configured. Procedure: + +1. **Generate the new keypair** in production storage (KMS / Vault). +2. **Coordinate downtime windows** with each customer running on the old key. There is no overlap-period mechanism; you must: + - Push the new public key to all tenants (config rollout, restart). + - Re-mint and re-deliver every active license under the new key. + - Each customer's server is `INVALID` between the public-key change and the new token install. +3. **Decommission the old private key** only after every active license has been re-issued. + +To avoid emergency rotations, sign with a **fresh** keypair every 24 months on a planned schedule. License terms shorter than the rotation interval keep customer impact bounded — at most one re-issue per customer per rotation. + +## Cap matrix (plan tiers) + +These are suggested values — adjust to your pricing model. Caps not listed fall through to defaults. + +| Limit key | Default (no license) | Starter | Team | Business | Enterprise | +|---|---|---|---|---|---| +| `max_environments` | 1 | 2 | 5 | 10 | 50 | +| `max_apps` | 3 | 10 | 50 | 200 | 1000 | +| `max_agents` | 5 | 20 | 100 | 500 | 5000 | +| `max_users` | 3 | 5 | 25 | 100 | 1000 | +| `max_outbound_connections` | 1 | 5 | 25 | 100 | 500 | +| `max_alert_rules` | 2 | 10 | 50 | 200 | 1000 | +| `max_total_cpu_millis` | 2000 | 8000 | 32000 | 128000 | 512000 | +| `max_total_memory_mb` | 2048 | 8192 | 32768 | 131072 | 524288 | +| `max_total_replicas` | 5 | 25 | 100 | 500 | 2000 | +| `max_execution_retention_days` | 1 | 7 | 30 | 90 | 365 | +| `max_log_retention_days` | 1 | 7 | 30 | 90 | 180 | +| `max_metric_retention_days` | 1 | 7 | 30 | 90 | 180 | +| `max_jar_retention_count` | 3 | 5 | 10 | 25 | 50 | + +## Telemetry the SaaS team can observe + +### Audit log + +Every license event lives in `audit_log` with `category=LICENSE`. Useful queries: + +```sql +-- Last 30 license events for tenant X +SELECT timestamp, username, action, target, result, detail +FROM audit_log +WHERE category = 'LICENSE' +ORDER BY timestamp DESC +LIMIT 30; + +-- Customers hitting caps in the last 24h +SELECT target AS limit, COUNT(*) AS rejections +FROM audit_log +WHERE category = 'LICENSE' AND action = 'cap_exceeded' + AND timestamp > now() - INTERVAL '24 hours' +GROUP BY target +ORDER BY rejections DESC; + +-- Customers running with rejected licenses +SELECT timestamp, detail->>'reason' AS reason, detail->>'source' AS source +FROM audit_log +WHERE category = 'LICENSE' AND action = 'reject_license' +ORDER BY timestamp DESC; +``` + +### Prometheus metrics + +| Metric | Type | Labels | Use | +|---|---|---|---| +| `cameleer_license_state` | gauge | `state` | Dashboard tile: which state is each tenant in. One-hot per state. | +| `cameleer_license_days_remaining` | gauge | (none) | Renewal alerting. Recommended thresholds: warn at 30 days, page at 7 days, critical at 1 day. `-1.0` means no license. | +| `cameleer_license_last_validated_age_seconds` | gauge | (none) | Detect stuck schedulers. Alert at >86400. | +| `cameleer_license_cap_rejections_total` | counter | `limit` | Account-management signal — customers consistently hitting caps are upgrade prospects. | + +### REST API + +`/api/v1/admin/license/usage` returns the per-limit current/cap/source table — wire this into your SaaS-side admin UI for at-a-glance per-tenant view. The endpoint requires an ADMIN-role JWT; SaaS-side automation can mint short-lived ADMIN tokens scoped per tenant or use a shared service account. + +## Failure modes & runbook + +### "Customer reports 403s after upgrade" + +1. Pull `/api/v1/admin/license/usage`. Identify which `limit` row has `current >= cap`. +2. If `state = ACTIVE` and a higher-tier license is owed, mint and install it. +3. If `state = EXPIRED`/`INVALID`/`ABSENT`, fix the license-state issue first — the cap rejection is downstream of that. +4. Confirm by replaying the failing operation; the 403 should clear. + +### "Customer reports state=INVALID" + +1. Pull `/api/v1/admin/license` — note `invalidReason`. +2. Most likely causes: + - Public-key mismatch — the customer's `CAMELEER_SERVER_LICENSE_PUBLICKEY` differs from the key used to mint. Diff the two values byte-for-byte. + - Tenant mismatch — `CAMELEER_SERVER_TENANT_ID` on the server differs from the `--tenant` used when minting. The customer must restart with the correct tenant id (it's immutable for the lifetime of the deployment because it appears in PG schema names and CH partition keys — coordinate carefully). + - Token tampering — base64-decode the payload portion (`.`), confirm the JSON looks well-formed. +3. Re-mint or fix config; re-install. + +### "License will expire in N days" + +1. Alert on `cameleer_license_days_remaining < 30`. +2. Mint a renewal license (new `licenseId`, later `expiresAt`). +3. Install via the customer's preferred channel (env-var on next deploy, or hot via POST). + +### "Audit table fills up with cap_exceeded rows" + +Customer is hammering a creation path. Either: +- They genuinely outgrew their tier — upgrade conversation. +- Their automation has a runaway loop creating environments/apps. Coordinate with the customer to throttle and clean up. + +The `cameleer_license_cap_rejections_total{limit=...}` counter is more efficient for monitoring this than scanning audit; use audit only for forensic detail. + +### "TTL recompute logs WARN: Failed to apply TTL" + +`RetentionPolicyApplier` could not run `ALTER TABLE ... MODIFY TTL` on ClickHouse. The license install itself succeeded; only the retention update failed. Check: +- ClickHouse user has `ALTER` privilege on the cameleer DB. +- ClickHouse version is >= 22.3 (required for `WHERE` predicate on TTL). +- ClickHouse cluster health. + +## Edge cases the SaaS team should know + +- **Default tier is restrictive on purpose.** A customer on default tier cannot stand up a real production workload (1 env, 3 apps, 5 agents, 1-day retention). Onboarding should always include license install before the customer adds any real workload. +- **Grace period defaults to 0.** If you want a buffer between `expiresAt` and capability loss, set `--grace-days=N` at mint time. We recommend 14 days for paid plans so a slipped renewal doesn't immediately drop the customer to default-tier caps. +- **Public key change invalidates all installed tokens immediately on next revalidation.** Daily revalidation runs at 03:00 server-local time, with a 60-second post-startup tick. A surprise public-key rollout will surface as `state=INVALID` for every customer running on the old key on the next tick or restart. +- **Caps reduce on revalidation, not just install.** A token whose `expiresAt` lapses will, at the next revalidation, transition `ACTIVE → GRACE → EXPIRED` automatically, dropping caps to default-tier on the EXPIRED transition. The state change is announced via `LicenseChangedEvent` and triggers TTL recompute. +- **Compute caps are evaluated at deploy time, not at runtime.** A deployment that successfully started under a high-tier license will keep running unchanged when the license downgrades. Only the *next* deploy attempt will see the new cap. +- **Agent count is in-memory.** `max_agents` is enforced against the `AgentRegistryService.liveCount()` (LIVE state agents). Restarts reset the count to zero until agents re-register; this is by design — DEAD agents shouldn't pin a license slot. +- **License id changes on every renewal.** Always use a fresh `UUID.randomUUID()` when minting a renewal. The audit `previousLicenseId` field then tells you which token superseded which. + +## Testing guidance + +Three approaches for dry-running licenses without touching a customer server: + +### 1. Pure unit test — `LicenseMinter` round-trip with `LicenseValidator` + +```java +KeyPair kp = KeyPairGenerator.getInstance("Ed25519").generateKeyPair(); +String pubB64 = Base64.getEncoder().encodeToString(kp.getPublic().getEncoded()); + +LicenseInfo info = new LicenseInfo( + UUID.randomUUID(), "test-tenant", "Test", Map.of("max_apps", 50), + Instant.now(), Instant.now().plus(365, ChronoUnit.DAYS), 0 +); + +String token = LicenseMinter.mint(info, kp.getPrivate()); + +LicenseValidator validator = new LicenseValidator(pubB64, "test-tenant"); +LicenseInfo parsed = validator.validate(token); +assertEquals(info.licenseId(), parsed.licenseId()); +``` + +This is the model already used in `LicenseMinterTest` and `LicenseValidatorTest` in the repo — copy from there. + +### 2. CLI dry-run — mint and self-verify + +```bash +java -jar cameleer-license-minter-1.0-SNAPSHOT-cli.jar \ + --private-key=test-priv.pem \ + --public-key=test-pub.b64 \ + --tenant=test-tenant \ + --expires=2027-12-31 \ + --max-apps=50 \ + --output=/tmp/test.lic \ + --verify +``` + +`--verify` runs the full `LicenseValidator.validate(...)` round-trip and exits 3 on failure. Useful for shaking out wrong-key / wrong-tenant before sending to a customer. + +### 3. Test server with a test public key + +Spin up a sandbox cameleer-server (docker-compose or k8s-test-namespace) with: + +```yaml +environment: + CAMELEER_SERVER_TENANT_ID: test-tenant + CAMELEER_SERVER_LICENSE_PUBLICKEY: +``` + +Install the test license, exercise the customer's reported scenario, observe `state` transitions and audit rows. The `LicenseLifecycleIT` and `LicenseEnforcementIT` integration tests in `cameleer-server-app/src/test/java/.../license/` are good templates for full-stack reproduction. + +## Pointers + +| Document | Audience | +|---|---| +| `cameleer-license-minter/README.md` | Vendor-side mint operations | +| `docs/license-enforcement.md` | End-customer operators (install, monitor, troubleshoot) | +| `docs/superpowers/specs/2026-04-25-license-enforcement-design.md` | Original design rationale | +| `docs/superpowers/plans/2026-04-25-license-enforcement.md` | Implementation plan (36 tasks) | +| `.claude/rules/core-classes.md` `# license/` section | License domain class map | +| `.claude/rules/app-classes.md` `# license/` section | Server license-app class map + endpoint surface | diff --git a/docs/license-enforcement.md b/docs/license-enforcement.md new file mode 100644 index 00000000..2224313d --- /dev/null +++ b/docs/license-enforcement.md @@ -0,0 +1,367 @@ +# License Enforcement + +Operator documentation for the cameleer-server license subsystem. Audience: operators running their own cameleer-server instance who need to install, monitor, or troubleshoot a license. + +For *issuing* licenses, see `cameleer-license-minter/README.md`. For SaaS-team operational playbooks, see `docs/handoff/2026-04-26-license-saas-handoff.md`. + +## Table of contents + +## Overview + +## What gets enforced + +## Install paths and priority + +## Public-key configuration + +## REST API + +## License state machine + +## Default tier caps + +## Cap-exceeded behavior + +## Retention semantics + +## Daily revalidation + +## Audit categories + +## Prometheus metrics + +## Troubleshooting + +--- + +## Overview + +cameleer-server can run in one of two postures: + +- **Default tier (no license installed).** A small fixed cap-set applies (1 environment, 3 apps, 5 agents, 1 day retention, etc.). Suitable for evaluation and self-host single-instance use. The default tier engages automatically when no license is configured. +- **Licensed (token installed).** Caps from the signed token override the default tier on a per-key basis. Any limit key the token does not specify falls through to the default value, so a partial license that only raises `max_environments` and `max_apps` keeps default retention. + +A signed Ed25519 license token carries the customer's `tenantId`, an `expiresAt` timestamp, an optional `gracePeriodDays`, and a `limits` map. The server's `LicenseValidator` (`cameleer-server-core/src/main/java/com/cameleer/server/core/license/LicenseValidator.java`) checks the signature against `CAMELEER_SERVER_LICENSE_PUBLICKEY`, verifies the tenant matches `CAMELEER_SERVER_TENANT_ID`, and rejects expired tokens (past `expiresAt + gracePeriodDays`). + +The license posture is summarized as a `LicenseState`: + +- `ABSENT` — no license configured. Default-tier caps apply. +- `ACTIVE` — valid token, current time is at or before `expiresAt`. License caps apply. +- `GRACE` — past `expiresAt` but within `gracePeriodDays`. License caps still apply; the operator should renew. +- `EXPIRED` — past `expiresAt + gracePeriodDays`. Default-tier caps apply. +- `INVALID` — signature, tenant, or schema validation failed. Default-tier caps apply. + +## What gets enforced + +License caps are enforced through a single component, `LicenseEnforcer.assertWithinCap(limitKey, currentUsage, requestedDelta)`, called from each creation path. + +| Limit key | Enforcement point | Effect when exceeded | +|---|---|---| +| `max_environments` | `EnvironmentService.create(...)` | HTTP 403 from `EnvironmentAdminController.create`. | +| `max_apps` | `AppService.createApp(...)` | HTTP 403 from `AppController.create`. | +| `max_agents` | `AgentRegistryService.register(...)` | HTTP 403 from `AgentRegistrationController.register`. Counted against the in-memory live agent registry. | +| `max_users` | User creation paths in `UserAdminController`, `UiAuthController`, `OidcAuthController` | HTTP 403 (REST) or rejection during OIDC first-login. | +| `max_outbound_connections` | `OutboundConnectionServiceImpl.create(...)` | HTTP 403. | +| `max_alert_rules` | `AlertRuleController.create(...)` | HTTP 403. | +| `max_total_cpu_millis` | `DeploymentExecutor` `PRE_FLIGHT` stage | Deployment fails before pulling images; row is marked FAILED with the cap message in `deployments.error_message`. | +| `max_total_memory_mb` | same | same | +| `max_total_replicas` | same | same | +| `max_jar_retention_count` | `EnvironmentAdminController` PUT `/{envSlug}/jar-retention` | HTTP 403 if requested value > cap. The daily `JarRetentionJob` is also bounded by this cap. | +| `max_execution_retention_days`, `max_log_retention_days`, `max_metric_retention_days` | Not a creation cap; clamps ClickHouse TTL to `min(cap, env.configured)` — see [Retention semantics](#retention-semantics). | + +Note that the three compute caps are checked together at deploy time, after `ConfigMerger.resolve(...)` produces the final `ResolvedContainerConfig` but before the image is pulled. The current usage figure is computed by `LicenseUsageReader.computeUsage()` over non-stopped deployments. + +## Install paths and priority + +Tokens can be installed by four mechanisms; resolution at boot is highest-priority-first: + +1. **`CAMELEER_SERVER_LICENSE_TOKEN` environment variable.** Highest priority. The raw token is read on `@PostConstruct` from `LicenseBeanConfig.LicenseBootLoader`. +2. **`cameleer.server.license.file` Spring property** (or `CAMELEER_SERVER_LICENSE_FILE`). Path to a file containing the token. Read at boot if no env-var token is present. +3. **PostgreSQL `license` table.** Set via the admin REST POST. Loaded at boot if the env var and file both miss. +4. **None of the above.** State is `ABSENT`, default-tier caps apply, the boot loader publishes a `LicenseChangedEvent(ABSENT, null)` so listeners (Prometheus gauges, retention applier) settle on default values. + +If a higher-priority source rejects (signature failure, tenant mismatch, expired) the loader logs the reason and **does not** fall through to a lower-priority source. This is deliberate: an operator who set `CAMELEER_SERVER_LICENSE_TOKEN` expects that token to be the active one, not a silently-stale DB row. + +Any token loaded at boot also flows through `LicenseService.install(...)` so audit, persistence, and `LicenseChangedEvent` publishing are uniform across paths. + +## Public-key configuration + +```bash +export CAMELEER_SERVER_LICENSE_PUBLICKEY="$(cat cameleer-license-pub.b64)" +``` + +The value is the base64 encoding of the Ed25519 public key in X.509 SubjectPublicKeyInfo form (see `cameleer-license-minter/README.md` for generation). + +When `CAMELEER_SERVER_LICENSE_PUBLICKEY` is **unset**: + +- `LicenseBeanConfig.licenseValidator()` (line 62) logs a WARN: `CAMELEER_SERVER_LICENSE_PUBLICKEY not set — all licenses will be rejected as INVALID`. +- The bean is constructed against a throwaway public key whose private counterpart no one holds. The override's `validate(...)` always throws `IllegalStateException("license public key not configured")`. +- Any token loaded from any source routes through `LicenseService.install(...)`, fails validation, marks the gate `INVALID`, and writes a `reject_license` audit row with the failure reason. +- The state will be `INVALID`, default-tier caps apply, and the operator must set the variable and restart (or hot-install via POST after restart). + +## REST API + +All endpoints require an ADMIN-role JWT. Source-of-truth controllers: `cameleer-server-app/src/main/java/com/cameleer/server/app/controller/LicenseAdminController.java`, `LicenseUsageController.java`. + +### `GET /api/v1/admin/license` + +```json +{ + "state": "ACTIVE", + "invalidReason": null, + "envelope": { + "licenseId": "fd3a8f2a-1c44-4eac-aa07-1a5d1ce9c4a4", + "tenantId": "acme-prod", + "label": "Acme Production", + "limits": { "max_apps": 25, "max_environments": 3 }, + "issuedAt": "2026-04-26T10:00:00Z", + "expiresAt": "2027-01-01T00:00:00Z", + "gracePeriodDays": 14 + }, + "lastValidatedAt": "2026-04-26T03:00:00Z" +} +``` + +The raw token string is **deliberately not** returned — only the parsed envelope. `lastValidatedAt` is omitted when no DB row exists yet (env-var or file source on first boot before the next revalidation tick). + +### `POST /api/v1/admin/license` + +```bash +curl -X POST https://server.example.com/api/v1/admin/license \ + -H "Authorization: Bearer ${ADMIN_JWT}" \ + -H "Content-Type: application/json" \ + -d '{"token": "eyJ...long.base64.string..."}' +``` + +Body shape: `{"token": ""}`. On success returns `{"state": "ACTIVE", "envelope": {...}}`. On failure returns HTTP 400 with `{"error": ""}`. + +The handler delegates to `LicenseService.install(token, userId, "api")`. Acting `userId` comes from the authenticated principal stripped of the `user:` prefix (see `app-classes.md` user-id convention). + +This endpoint installs *or replaces* — there is one row per tenant in the `license` table, so a successful POST upserts and supersedes any prior token. The previous license id is captured in the `replace_license` audit detail. + +### `GET /api/v1/admin/license/usage` + +```json +{ + "state": "ACTIVE", + "expiresAt": "2027-01-01T00:00:00Z", + "daysRemaining": 250, + "gracePeriodDays": 14, + "tenantId": "acme-prod", + "label": "Acme Production", + "lastValidatedAt": "2026-04-26T03:00:00Z", + "message": "License active. 250 days remaining.", + "limits": [ + {"key": "max_environments", "current": 2, "cap": 3, "source": "license"}, + {"key": "max_apps", "current": 12, "cap": 25, "source": "license"}, + {"key": "max_agents", "current": 38, "cap": 50, "source": "license"}, + {"key": "max_users", "current": 4, "cap": 3, "source": "default"} + ] +} +``` + +For each effective-limits key: +- `current` — current usage. `max_agents` is read from the in-memory `AgentRegistryService.liveCount()`; everything else comes from `LicenseUsageReader.snapshot()` (PostgreSQL counts, plus deployment compute aggregates from `deployed_config_snapshot`). Limits the server does not measure return `0`. +- `cap` — effective cap (license override or default-tier value). +- `source` — `"license"` if the cap came from the token's `limits` map, `"default"` if it fell through. + +## License state machine + +``` + +---------------+ + | ABSENT | (no token configured) + +-------+-------+ + | + | install via env / file / DB / POST + v + +-------+-------+ + +-------------- | ACTIVE | --------------+ + | +-------+-------+ | + | revalidate | now > expiresAt + | fails sig/tenant/ | + | parse v + | +-------+-------+ + | | GRACE | + | +-------+-------+ + | | + | | now > exp + gracePeriodDays + | v + | +-------+-------+ + | | EXPIRED | + | +-------+-------+ + v ++-------+-------+ +| INVALID | (signature mismatch, tenant mismatch, ++---------------+ missing public key, malformed payload) +``` + +Classification logic: `LicenseStateMachine.classify(license, invalidReason)` (`cameleer-server-core/src/main/java/com/cameleer/server/core/license/LicenseStateMachine.java`). + +- `INVALID` and `EXPIRED` revert to **default-tier caps**. The license envelope is dropped from the gate (`getCurrent()` returns null in `INVALID`; the gate retains the parsed info in `EXPIRED` but `getEffectiveLimits()` returns defaults-only). +- `GRACE` keeps **license caps**. This is the only state where the operator should be running but should also be actively working on renewal. + +## Default tier caps + +Source: `cameleer-server-core/src/main/java/com/cameleer/server/core/license/DefaultTierLimits.java`. + +| Key | Default | Semantics | +|---|---|---| +| `max_environments` | 1 | Total environments across the tenant. | +| `max_apps` | 3 | Total apps across all environments. | +| `max_agents` | 5 | Live agents in the in-memory registry (LIVE state). | +| `max_users` | 3 | Local + OIDC users in the `users` table. | +| `max_outbound_connections` | 1 | Rows in `outbound_connections`. | +| `max_alert_rules` | 2 | Rows in `alert_rules`. | +| `max_total_cpu_millis` | 2000 | Sum of `replicas * cpuLimit` over non-stopped deployments. cpuLimit is millicores; 1000 = one core. | +| `max_total_memory_mb` | 2048 | Sum of `replicas * memoryLimitMb` over non-stopped deployments. | +| `max_total_replicas` | 5 | Sum of `replicas` over non-stopped deployments. | +| `max_execution_retention_days` | 1 | Cap on TTL applied to `executions` and `processor_executions`. | +| `max_log_retention_days` | 1 | Cap on TTL applied to `logs`. | +| `max_metric_retention_days` | 1 | Cap on TTL applied to `agent_metrics` and `agent_events`. | +| `max_jar_retention_count` | 3 | Maximum JAR retention count per environment. | + +The default tier is intentionally restrictive — it is sized for evaluation, single-developer demos, and "I forgot to install my license" recovery, not production. New customers should install a license at first onboarding. + +## Cap-exceeded behavior + +When a creation path exceeds its cap, `LicenseEnforcer.assertWithinCap(...)` throws `LicenseCapExceededException(limitKey, current, cap)`. `LicenseExceptionAdvice` (`@ControllerAdvice`) maps it to: + +```http +HTTP/1.1 403 Forbidden +Content-Type: application/json + +{ + "error": "license cap reached", + "limit": "max_apps", + "current": 4, + "cap": 3, + "state": "ABSENT", + "message": "License absent. Default tier limits apply. Cap reached for max_apps (3 of 3 used)." +} +``` + +Concurrently: +- The Prometheus counter `cameleer_license_cap_rejections_total{limit=...}` increments. +- An audit row is written: `category=LICENSE`, `action=cap_exceeded`, `target=`, `result=FAILURE`, `detail` carries `{limit, current, requested, cap, state}`. If audit storage fails, the 403 still surfaces (audit is best-effort here). + +The `message` field is rendered by `LicenseMessageRenderer.forCap(...)` and varies per state — under `EXPIRED` it nudges the operator to renew; under `INVALID` it cites `invalidReason`. + +## Retention semantics + +The license caps `max_execution_retention_days`, `max_log_retention_days`, `max_metric_retention_days`, and `max_jar_retention_count` define **maximums**. Per-environment configuration (`environments.execution_retention_days`, `log_retention_days`, `metric_retention_days`, `jar_retention_count`) defines the **operator preference**. The effective TTL applied to ClickHouse tables is: + +``` +effective = min(licenseCap, env.configuredRetentionDays) +``` + +When `LicenseChangedEvent` fires (any install/replace/revalidate/boot transition), `RetentionPolicyApplier` (`@EventListener @Async`) recomputes TTL for every (table, env) pair using: + +```sql +ALTER TABLE + MODIFY TTL toDateTime() + INTERVAL DAY DELETE + WHERE environment = '' +``` + +Tables affected: `executions`, `processor_executions`, `logs`, `agent_metrics`, `agent_events`. Excluded: +- `route_diagrams` — content-addressed `ReplacingMergeTree`, no time-based TTL. +- `server_metrics` — server-wide, no `environment` column. Its 90-day cap is fixed in the schema. + +ClickHouse failures are logged (WARN) but do not fail the originating license install — TTL recompute is best-effort. + +## Daily revalidation + +`LicenseRevalidationJob` (`@Scheduled(cron = "0 0 3 * * *")`) re-runs `LicenseService.revalidate()` against the persisted token at 03:00 server-local time. It also fires once 60 seconds after `ApplicationReadyEvent` to catch the case where a license was installed via SQL between server starts. + +Each revalidation: +- Re-reads the token from `license` table. +- Runs `LicenseValidator.validate(...)` again — same checks as install (signature, tenant, expiry). +- On success: bumps `last_validated_at`, reloads the gate, publishes `LicenseChangedEvent`. +- On failure: marks the gate `INVALID`, writes an audit row `revalidate_license` / `FAILURE`, publishes `LicenseChangedEvent(INVALID, null)`. + +A token transitioning `ACTIVE → GRACE → EXPIRED` will surface as a state change at the next revalidation tick (or on the next license-touching admin action). + +## Audit categories + +All license lifecycle events use `AuditCategory.LICENSE`. Action codes: + +| Action | Result | Detail keys | +|---|---|---| +| `install_license` | SUCCESS | `licenseId, expiresAt, installedBy, source` | +| `replace_license` | SUCCESS | same plus `previousLicenseId` | +| `reject_license` | FAILURE | `reason, source` | +| `revalidate_license` | FAILURE | `licenseId, reason` | +| `cap_exceeded` | FAILURE | `limit, current, requested, cap, state` | + +The `source` value is one of `env`, `file`, `db`, `api` — corresponds to the install path. + +## Prometheus metrics + +Scraped at `/api/v1/prometheus`. Source: `LicenseMetrics` (`cameleer-server-app/src/main/java/com/cameleer/server/app/license/LicenseMetrics.java`). + +| Metric | Type | Labels | Semantics | +|---|---|---|---| +| `cameleer_license_state` | gauge | `state=` | One-hot per state — exactly one tag value carries `1.0` at any time, others are `0.0`. | +| `cameleer_license_days_remaining` | gauge | (none) | Whole days until `expiresAt`. `-1.0` when no license is loaded (ABSENT/INVALID). Suitable alert thresholds: warn at 30, page at 7. | +| `cameleer_license_last_validated_age_seconds` | gauge | (none) | Seconds since the persisted `last_validated_at`. `0` when there is no DB row. Alerts at >86400 (revalidation hasn't run for >24h) detect a stuck scheduler or a misconfigured server. | +| `cameleer_license_cap_rejections_total` | counter | `limit=` | Incremented every time `LicenseEnforcer` rejects a creation due to a cap. A non-zero rate indicates customers hitting their plan ceiling. | + +Gauges refresh on every `LicenseChangedEvent` and on a 60-second `@Scheduled(fixedDelay)` so values stay current even without state changes. + +## Troubleshooting + +### My license shows `INVALID` — why? + +Check `invalidReason` from `GET /api/v1/admin/license`. Common causes: + +| `invalidReason` substring | Cause | Fix | +|---|---|---| +| `License signature verification failed` | Public key on the server does not match the private key the token was signed with. | Confirm `CAMELEER_SERVER_LICENSE_PUBLICKEY` matches the keypair used to mint the token. | +| `License tenantId 'X' does not match server tenant 'Y'` | Token minted for a different `tenantId`. | Re-mint with `--tenant=` matching `CAMELEER_SERVER_TENANT_ID`. | +| `licenseId is required` / `tenantId is required` / `exp is required` | Malformed token (missing required field). | Re-mint via the supported minter — fields are mandatory. | +| `License expired at <...>` | Past `expiresAt + gracePeriodDays`. | Issue a renewal license. | +| `license public key not configured` | `CAMELEER_SERVER_LICENSE_PUBLICKEY` is unset. | Set the env var and either restart or POST the token again. | + +### I'm getting 403s on creates — which cap is biting? + +```bash +curl https://server.example.com/api/v1/admin/license/usage \ + -H "Authorization: Bearer ${ADMIN_JWT}" +``` + +The `limits[]` array shows current/cap per limit key. Any row with `current >= cap` is a candidate. The 403 response body itself names the limit: + +```json +{"error":"license cap reached","limit":"max_apps","current":3,"cap":3,"state":"ABSENT", ...} +``` + +If `state` is `ABSENT` or `EXPIRED`/`INVALID`, the fix is to install a license. If `state` is `ACTIVE` and you are at the license cap, you need a higher-tier license re-issued. + +### My new license didn't take effect + +1. Check the audit log: + ```bash + curl 'https://server.example.com/api/v1/admin/audit?category=LICENSE&limit=10' \ + -H "Authorization: Bearer ${ADMIN_JWT}" + ``` + You should see an `install_license` or `replace_license` row at `SUCCESS`. A `reject_license` `FAILURE` row carries the reason. +2. Confirm the public key matches the private key used to mint: + - Vendor side: `openssl pkey -in -pubout -outform DER | base64 -w0` + - Server side: `echo $CAMELEER_SERVER_LICENSE_PUBLICKEY` + - These must be byte-identical. +3. Confirm `CAMELEER_SERVER_TENANT_ID` matches the `tenantId` in the token envelope (`GET /api/v1/admin/license`). +4. If the env var token disagrees with what's in the DB (e.g. you POSTed but a stale env var remains): the env var wins on next boot. Either remove the env var or update it before restarting. + +### Cap rejections spiking but no licensed customer should be hitting the cap + +Inspect `cameleer_license_cap_rejections_total{limit=...}`. If a tenant is on default tier (state = `ABSENT`/`EXPIRED`/`INVALID`) the very low default caps will trip immediately on routine activity. Install a license to restore expected behavior. + +### Retention TTL didn't change after installing a license + +`RetentionPolicyApplier` runs on `LicenseChangedEvent` asynchronously (`@Async`). Look for the log line: + +``` +License changed (state=ACTIVE) — recomputing TTL across N environment(s) and 5 table(s) +Applied TTL: table=executions env=prod days=30 (cap=30, configured=90) +``` + +If the log shows `Failed to apply TTL` warnings, ClickHouse rejected the `ALTER TABLE ... MODIFY TTL` statement — most often because of a permissions issue or a ClickHouse version below 22.3. The license install itself still succeeded; the TTL change just didn't land. diff --git a/docs/superpowers/plans/2026-04-25-license-enforcement.md b/docs/superpowers/plans/2026-04-25-license-enforcement.md new file mode 100644 index 00000000..df559e67 --- /dev/null +++ b/docs/superpowers/plans/2026-04-25-license-enforcement.md @@ -0,0 +1,4083 @@ +# License Enforcement — Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Enforce arbitrary per-customer license limits (entity counts, compute, retention) on a default-tier-by-default server, with a vendor-only standalone minter and PostgreSQL-persisted licenses. + +**Architecture:** Three modules — `cameleer-server-core` carries the validator/state-machine/defaults, `cameleer-server-app` carries the enforcement, persistence, REST, and ClickHouse-TTL applier, and a new `cameleer-license-minter` top-level Maven module (vendor-only, not in the runtime tree) carries the signing primitive and CLI. License envelope is a base64(payload).base64(ed25519) token; runtime install via `POST /admin/license` writes through to PG; an event bus recomputes ClickHouse TTL on every license change; a daily revalidation job refreshes `last_validated_at` and catches public-key drift. + +**Tech Stack:** Java 17, Spring Boot 3.4.3, JdbcTemplate over PostgreSQL (Flyway), ClickHouse (jdbc), Ed25519 (`java.security.Signature`), Jackson canonical JSON, JUnit 5 + AssertJ + Mockito, Testcontainers Postgres + ClickHouse, REST-Assured-style `MockMvc` for IT. + +**Spec:** `docs/superpowers/specs/2026-04-25-license-enforcement-design.md` + +--- + +## Conventions for the executor + +- **Before editing any existing class**, run `gitnexus_impact({target: "ClassName", direction: "upstream"})` and report blast radius. Refuse HIGH/CRITICAL without confirming. +- **Before each commit**, run `gitnexus_detect_changes()` to verify scope. +- After committing, run `npx gitnexus analyze --embeddings` (PostToolUse hook may automate). +- Per-test runs: `mvn -pl cameleer-server-core -Dtest=ClassName#method test` or `mvn -pl cameleer-server-app -Dtest=ClassName#method test`. +- Full IT run (slow, Testcontainers): `mvn -pl cameleer-server-app verify`. +- Fast unit-only: `mvn -pl cameleer-server-core test && mvn -pl cameleer-server-app test -DskipITs`. +- After any controller signature change in this plan, regenerate OpenAPI types (Task 35). +- After adding/removing/renaming classes/controllers, update `.claude/rules/*.md` (Task 36). +- Commit per step that says "Commit". Use `feat(license):`, `fix(license):`, `refactor(license):`, `test(license):`, `docs(license):` prefixes. End every commit message body with `Co-Authored-By: Claude Opus 4.7 (1M context) `. + +--- + +## Files touched (summary) + +**Created:** +- `cameleer-server-core/src/main/java/com/cameleer/server/core/license/LicenseLimits.java` +- `cameleer-server-core/src/main/java/com/cameleer/server/core/license/DefaultTierLimits.java` +- `cameleer-server-core/src/main/java/com/cameleer/server/core/license/LicenseStateMachine.java` +- `cameleer-server-core/src/main/java/com/cameleer/server/core/license/LicenseState.java` (enum) +- `cameleer-server-core/src/test/java/com/cameleer/server/core/license/LicenseStateMachineTest.java` +- `cameleer-server-core/src/test/java/com/cameleer/server/core/license/DefaultTierLimitsTest.java` +- `cameleer-license-minter/pom.xml` +- `cameleer-license-minter/src/main/java/com/cameleer/license/minter/LicenseMinter.java` +- `cameleer-license-minter/src/main/java/com/cameleer/license/minter/cli/LicenseMinterCli.java` +- `cameleer-license-minter/src/test/java/com/cameleer/license/minter/LicenseMinterTest.java` +- `cameleer-license-minter/src/test/java/com/cameleer/license/minter/cli/LicenseMinterCliTest.java` +- `cameleer-server-app/src/main/resources/db/migration/V5__license_table_and_environment_retention.sql` +- `cameleer-server-app/src/main/java/com/cameleer/server/app/license/LicenseRepository.java` +- `cameleer-server-app/src/main/java/com/cameleer/server/app/license/PostgresLicenseRepository.java` +- `cameleer-server-app/src/main/java/com/cameleer/server/app/license/LicenseRecord.java` +- `cameleer-server-app/src/main/java/com/cameleer/server/app/license/LicenseChangedEvent.java` +- `cameleer-server-app/src/main/java/com/cameleer/server/app/license/LicenseService.java` +- `cameleer-server-app/src/main/java/com/cameleer/server/app/license/LicenseEnforcer.java` +- `cameleer-server-app/src/main/java/com/cameleer/server/app/license/LicenseUsageReader.java` +- `cameleer-server-app/src/main/java/com/cameleer/server/app/license/LicenseCapExceededException.java` +- `cameleer-server-app/src/main/java/com/cameleer/server/app/license/LicenseExceptionAdvice.java` +- `cameleer-server-app/src/main/java/com/cameleer/server/app/license/LicenseMessageRenderer.java` +- `cameleer-server-app/src/main/java/com/cameleer/server/app/license/LicenseRevalidationJob.java` +- `cameleer-server-app/src/main/java/com/cameleer/server/app/license/RetentionPolicyApplier.java` +- `cameleer-server-app/src/main/java/com/cameleer/server/app/license/LicenseMetrics.java` +- `cameleer-server-app/src/main/java/com/cameleer/server/app/controller/LicenseUsageController.java` + +**Modified:** +- `cameleer-server-core/src/main/java/com/cameleer/server/core/license/LicenseInfo.java` (drop `features`, add `licenseId`/`tenantId`/`gracePeriodDays`; require fields) +- `cameleer-server-core/src/main/java/com/cameleer/server/core/license/LicenseValidator.java` (require new fields, reject tenant mismatch via constructor arg, drop `features`) +- `cameleer-server-core/src/main/java/com/cameleer/server/core/license/LicenseGate.java` (drop `isEnabled(Feature)`; add `getEffectiveLimits()`/`getState()`) +- `cameleer-server-core/src/main/java/com/cameleer/server/core/runtime/Environment.java` (add 3 retention fields) +- `cameleer-server-core/src/main/java/com/cameleer/server/core/runtime/EnvironmentService.java` (cap retention values via enforcer hook) +- `cameleer-server-app/src/main/java/com/cameleer/server/app/config/LicenseBeanConfig.java` (boot order: env > file > DB; publish event) +- `cameleer-server-app/src/main/java/com/cameleer/server/app/controller/LicenseAdminController.java` (persist + audit + return state) +- `cameleer-server-app/src/main/java/com/cameleer/server/core/admin/AuditCategory.java` (add `LICENSE`) +- All "wire enforcement" call sites listed in Tasks 18–25 +- `pom.xml` (root: register `cameleer-license-minter` module) +- `.claude/rules/core-classes.md`, `.claude/rules/app-classes.md` (Task 36) +- `ui/src/api/openapi.json`, `ui/src/api/schema.d.ts` (Task 35) + +**Deleted:** +- `cameleer-server-core/src/main/java/com/cameleer/server/core/license/Feature.java` +- The `features` portions of `LicenseInfo`, `LicenseGate`, `LicenseValidator` +- `LicenseGateTest#withLicense_onlyLicensedFeaturesEnabled` + +--- + +## Task 1: Remove dead `Feature` scaffolding (first commit) + +**Why:** Spec §9 — `Feature` enum is removed before any new code lands so subsequent edits don't have to maintain a dual world. + +**Files:** +- Delete: `cameleer-server-core/src/main/java/com/cameleer/server/core/license/Feature.java` +- Modify: `cameleer-server-core/src/main/java/com/cameleer/server/core/license/LicenseInfo.java` +- Modify: `cameleer-server-core/src/main/java/com/cameleer/server/core/license/LicenseGate.java` +- Modify: `cameleer-server-core/src/main/java/com/cameleer/server/core/license/LicenseValidator.java` +- Modify: `cameleer-server-app/src/test/java/com/cameleer/server/core/license/LicenseGateTest.java` +- Modify: `cameleer-server-app/src/test/java/com/cameleer/server/core/license/LicenseValidatorTest.java` + +- [ ] **Step 1.1: Run gitnexus impact for `Feature`, `LicenseGate.isEnabled`, `LicenseInfo.hasFeature`** + +``` +gitnexus_impact({target: "Feature", direction: "upstream"}) +gitnexus_impact({target: "isEnabled", direction: "upstream"}) +gitnexus_impact({target: "hasFeature", direction: "upstream"}) +``` +Expected: only the LicenseGate/LicenseInfo classes themselves and tests reference these — no production callers. + +- [ ] **Step 1.2: Delete `Feature.java`** + +```bash +rm cameleer-server-core/src/main/java/com/cameleer/server/core/license/Feature.java +``` + +- [ ] **Step 1.3: Replace `LicenseInfo.java` with the new shape (still without `licenseId`/`tenantId` — Task 2 adds those)** + +```java +package com.cameleer.server.core.license; + +import java.time.Instant; +import java.util.Map; + +public record LicenseInfo( + String tier, + Map limits, + Instant issuedAt, + Instant expiresAt +) { + public boolean isExpired() { + return expiresAt != null && Instant.now().isAfter(expiresAt); + } + + public int getLimit(String key, int defaultValue) { + return limits.getOrDefault(key, defaultValue); + } + + public static LicenseInfo open() { + return new LicenseInfo("open", Map.of(), Instant.now(), null); + } +} +``` + +- [ ] **Step 1.4: Replace `LicenseGate.java` (drop `isEnabled`)** + +```java +package com.cameleer.server.core.license; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.util.concurrent.atomic.AtomicReference; + +public class LicenseGate { + + private static final Logger log = LoggerFactory.getLogger(LicenseGate.class); + + private final AtomicReference current = new AtomicReference<>(LicenseInfo.open()); + + public void load(LicenseInfo license) { + current.set(license); + log.info("License loaded: tier={}, limits={}, expires={}", + license.tier(), license.limits(), license.expiresAt()); + } + + public String getTier() { + return current.get().tier(); + } + + public int getLimit(String key, int defaultValue) { + return current.get().getLimit(key, defaultValue); + } + + public LicenseInfo getCurrent() { + return current.get(); + } +} +``` + +- [ ] **Step 1.5: Update `LicenseValidator.java` — drop the `features` parsing block** + +In the `validate(String token)` method, delete the entire `Set features = ...` block (lines 59–68 of the original) and remove the corresponding constructor arg from the `new LicenseInfo(...)` call so it matches Step 1.3's shape: + +```java +LicenseInfo info = new LicenseInfo(tier, limits, issuedAt, expiresAt); +``` + +Also remove the `import java.util.HashSet;` if no other usage remains. + +- [ ] **Step 1.6: Update `LicenseGateTest.java`** + +Delete `withLicense_onlyLicensedFeaturesEnabled()` and `noLicense_allFeaturesEnabled()` entirely. Replace the file with: + +```java +package com.cameleer.server.core.license; + +import org.junit.jupiter.api.Test; + +import java.time.Instant; +import java.time.temporal.ChronoUnit; +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThat; + +class LicenseGateTest { + + @Test + void noLicense_returnsOpenTier() { + LicenseGate gate = new LicenseGate(); + assertThat(gate.getTier()).isEqualTo("open"); + assertThat(gate.getLimit("max_apps", 99)).isEqualTo(99); + } + + @Test + void loaded_exposesLimits() { + LicenseGate gate = new LicenseGate(); + LicenseInfo info = new LicenseInfo("MID", + Map.of("max_agents", 10, "retention_days", 30), + Instant.now(), Instant.now().plus(365, ChronoUnit.DAYS)); + gate.load(info); + + assertThat(gate.getTier()).isEqualTo("MID"); + assertThat(gate.getLimit("max_agents", 0)).isEqualTo(10); + assertThat(gate.getLimit("missing", 7)).isEqualTo(7); + } +} +``` + +- [ ] **Step 1.7: Update `LicenseValidatorTest.java` — remove `features` from sample payloads and `Feature` assertions** + +Replace the JSON payload string in `validate_validLicense_returnsLicenseInfo` with: + +```java +String payload = """ + {"tier":"HIGH","limits":{"max_agents":50,"retention_days":90},"iat":%d,"exp":%d} + """.formatted(Instant.now().getEpochSecond(), expires.getEpochSecond()).trim(); +``` + +Delete the lines: + +```java +assertThat(info.hasFeature(Feature.debugger)).isTrue(); +assertThat(info.hasFeature(Feature.replay)).isFalse(); +``` + +In `validate_expiredLicense_throwsException`, replace its payload with: + +```java +String payload = """ + {"tier":"LOW","limits":{},"iat":%d,"exp":%d} + """.formatted(past.minus(30, ChronoUnit.DAYS).getEpochSecond(), past.getEpochSecond()).trim(); +``` + +In `validate_tamperedPayload_throwsException`, replace its payload with: + +```java +String payload = """ + {"tier":"LOW","limits":{},"iat":0,"exp":9999999999} + """.trim(); +``` + +Remove the `import com.cameleer.server.core.license.Feature;` if any. + +- [ ] **Step 1.8: Build core unit tests** + +Run: `mvn -pl cameleer-server-core test` +Expected: BUILD SUCCESS, 3 tests in `LicenseValidatorTest`, 2 in `LicenseGateTest`. + +- [ ] **Step 1.9: Build app unit tests (only the moved tests; ITs skipped)** + +Run: `mvn -pl cameleer-server-app test -DskipITs` +Expected: BUILD SUCCESS — no other code touched yet. + +- [ ] **Step 1.10: Commit** + +```bash +git add -u cameleer-server-core cameleer-server-app +git commit -m "$(cat <<'EOF' +refactor(license): remove dead Feature enum and isEnabled scaffolding + +Spec §9 — feature flags are out of scope for license enforcement. +Drops Feature.java, LicenseGate.isEnabled, LicenseInfo.hasFeature, +and the corresponding test cases. LicenseValidator now silently +ignores any features array on the wire (no error). + +Co-Authored-By: Claude Opus 4.7 (1M context) +EOF +)" +``` + +--- + +## Task 2: Expand `LicenseInfo` schema — required `licenseId`, `tenantId`, `gracePeriodDays` + +**Why:** Spec §2 — the new envelope adds three fields. `licenseId` and `tenantId` are required for audit and anti-portability respectively. + +**Files:** +- Modify: `cameleer-server-core/src/main/java/com/cameleer/server/core/license/LicenseInfo.java` +- Test: `cameleer-server-core/src/test/java/com/cameleer/server/core/license/LicenseInfoTest.java` (NEW) + +- [ ] **Step 2.1: Write failing test — `LicenseInfoTest.java`** + +Create the file with: + +```java +package com.cameleer.server.core.license; + +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 LicenseInfoTest { + + @Test + void requiresLicenseId() { + assertThatThrownBy(() -> new LicenseInfo( + null, "acme", "label", + Map.of(), Instant.now(), Instant.now().plusSeconds(60), 0)) + .isInstanceOf(NullPointerException.class) + .hasMessageContaining("licenseId"); + } + + @Test + void requiresTenantId() { + assertThatThrownBy(() -> new LicenseInfo( + UUID.randomUUID(), null, "label", + Map.of(), Instant.now(), Instant.now().plusSeconds(60), 0)) + .isInstanceOf(NullPointerException.class) + .hasMessageContaining("tenantId"); + } + + @Test + void emptyTenantIdRejected() { + assertThatThrownBy(() -> new LicenseInfo( + UUID.randomUUID(), " ", "label", + Map.of(), Instant.now(), Instant.now().plusSeconds(60), 0)) + .isInstanceOf(IllegalArgumentException.class); + } + + @Test + void getLimit_returnsDefaultWhenMissing() { + LicenseInfo info = new LicenseInfo( + UUID.randomUUID(), "acme", null, + Map.of("max_apps", 5), Instant.now(), + Instant.now().plusSeconds(60), 0); + assertThat(info.getLimit("max_apps", 99)).isEqualTo(5); + assertThat(info.getLimit("max_users", 99)).isEqualTo(99); + } + + @Test + void isExpired_honoursGracePeriod() { + Instant pastByTen = Instant.now().minusSeconds(10 * 86400); + LicenseInfo withinGrace = new LicenseInfo( + UUID.randomUUID(), "acme", null, Map.of(), + Instant.now().minusSeconds(40 * 86400), + pastByTen, 30); + assertThat(withinGrace.isExpired()).isFalse(); // 10 days into a 30-day grace + LicenseInfo pastGrace = new LicenseInfo( + UUID.randomUUID(), "acme", null, Map.of(), + Instant.now().minusSeconds(40 * 86400), + pastByTen, 5); + assertThat(pastGrace.isExpired()).isTrue(); // 10 days is past the 5-day grace + } +} +``` + +- [ ] **Step 2.2: Run — expect compile failure** + +Run: `mvn -pl cameleer-server-core test -Dtest=LicenseInfoTest` +Expected: FAIL — `LicenseInfo` constructor signature does not match. + +- [ ] **Step 2.3: Update `LicenseInfo.java` to the new shape** + +```java +package com.cameleer.server.core.license; + +import java.time.Instant; +import java.util.Map; +import java.util.Objects; +import java.util.UUID; + +public record LicenseInfo( + UUID licenseId, + String tenantId, + String label, + Map limits, + Instant issuedAt, + Instant expiresAt, + int gracePeriodDays +) { + public LicenseInfo { + Objects.requireNonNull(licenseId, "licenseId is required"); + Objects.requireNonNull(tenantId, "tenantId is required"); + Objects.requireNonNull(limits, "limits is required"); + Objects.requireNonNull(issuedAt, "issuedAt is required"); + Objects.requireNonNull(expiresAt, "expiresAt is required"); + if (tenantId.isBlank()) { + throw new IllegalArgumentException("tenantId must not be blank"); + } + if (gracePeriodDays < 0) { + throw new IllegalArgumentException("gracePeriodDays must be >= 0"); + } + } + + public boolean isExpired() { + Instant deadline = expiresAt.plusSeconds((long) gracePeriodDays * 86400); + return Instant.now().isAfter(deadline); + } + + public boolean isAfterRawExpiry() { + return Instant.now().isAfter(expiresAt); + } + + public int getLimit(String key, int defaultValue) { + return limits.getOrDefault(key, defaultValue); + } +} +``` + +Note: `LicenseInfo.open()` is removed — `LicenseGate` will switch to a sentinel `null` for ABSENT in Task 5. + +- [ ] **Step 2.4: Run the test** + +Run: `mvn -pl cameleer-server-core test -Dtest=LicenseInfoTest` +Expected: PASS, 5/5. + +- [ ] **Step 2.5: Fix the broken callers from Task 1 (transient compile failures)** + +`LicenseValidator.validate(...)` still does `new LicenseInfo(tier, limits, issuedAt, expiresAt)`. Task 3 rewrites it; for now, replace just that line with a placeholder so the module compiles: + +```java +LicenseInfo info = new LicenseInfo( + java.util.UUID.randomUUID(), "placeholder", null, + limits, issuedAt, expiresAt, 0); +``` + +Mark this with a `// TODO Task 3` comment so the executor cannot forget. + +`LicenseGate` references `LicenseInfo.open()`. Add a temporary inline stub at the top of `LicenseGate`: + +```java +private static LicenseInfo openSentinel() { + return new LicenseInfo(java.util.UUID.randomUUID(), "open", null, + Map.of(), Instant.EPOCH, Instant.MAX, 0); +} +``` + +And use `openSentinel()` instead of `LicenseInfo.open()` in the field initialiser. This is also TODO-marked for Task 5 to replace. + +`LicenseGateTest` (from Task 1.6) uses the old 4-arg constructor; update its `new LicenseInfo(...)` call to the 7-arg form using the same placeholder pattern. `LicenseValidatorTest` builds JSON, not Java objects — no change needed. + +- [ ] **Step 2.6: Build & test** + +Run: `mvn -pl cameleer-server-core test` +Expected: PASS — the placeholders compile and existing tests assert nothing about the placeholder fields. + +- [ ] **Step 2.7: Commit** + +```bash +git add cameleer-server-core/src +git commit -m "$(cat <<'EOF' +feat(license): expand LicenseInfo with licenseId, tenantId, grace period + +Required fields per spec §2.1. tenantId is non-blank; gracePeriodDays +defines the post-exp window during which limits keep applying. +isExpired() now honours the grace; isAfterRawExpiry() distinguishes +ACTIVE from GRACE for the state machine in Task 4. + +Validator and gate use placeholder values temporarily; Task 3 wires +the validator to read the new fields, Task 5 rewrites the gate. + +Co-Authored-By: Claude Opus 4.7 (1M context) +EOF +)" +``` + +--- + +## Task 3: Rewrite `LicenseValidator` for the new envelope (require `licenseId`/`tenantId`, optional tenant binding check) + +**Why:** Spec §2.1 + §6.4 — validator must reject tokens missing required fields and tokens whose `tenantId` does not match the configured server tenant. + +**Files:** +- Modify: `cameleer-server-core/src/main/java/com/cameleer/server/core/license/LicenseValidator.java` +- Modify: `cameleer-server-app/src/test/java/com/cameleer/server/core/license/LicenseValidatorTest.java` + +- [ ] **Step 3.1: Run gitnexus impact** + +``` +gitnexus_impact({target: "LicenseValidator", direction: "upstream"}) +``` +Expected: `LicenseBeanConfig`, `LicenseAdminController`, the test class. All will be touched downstream. + +- [ ] **Step 3.2: Write failing tests in `LicenseValidatorTest.java`** + +Add three new test methods (keep the existing three, but update their JSON payloads to include the new required fields): + +```java +@Test +void validate_missingTenantId_throws() throws Exception { + KeyPair kp = generateKeyPair(); + String publicKeyBase64 = Base64.getEncoder().encodeToString(kp.getPublic().getEncoded()); + LicenseValidator validator = new LicenseValidator(publicKeyBase64, "acme"); + + Instant exp = Instant.now().plus(30, ChronoUnit.DAYS); + String payload = """ + {"licenseId":"%s","tier":"X","limits":{},"iat":%d,"exp":%d} + """.formatted(UUID.randomUUID(), Instant.now().getEpochSecond(), exp.getEpochSecond()).trim(); + String token = Base64.getEncoder().encodeToString(payload.getBytes()) + "." + sign(kp.getPrivate(), payload); + + assertThatThrownBy(() -> validator.validate(token)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("tenantId"); +} + +@Test +void validate_tenantIdMismatch_throws() throws Exception { + KeyPair kp = generateKeyPair(); + String publicKeyBase64 = Base64.getEncoder().encodeToString(kp.getPublic().getEncoded()); + LicenseValidator validator = new LicenseValidator(publicKeyBase64, "beta"); + + Instant exp = Instant.now().plus(30, ChronoUnit.DAYS); + String payload = """ + {"licenseId":"%s","tenantId":"acme","tier":"X","limits":{},"iat":%d,"exp":%d} + """.formatted(UUID.randomUUID(), Instant.now().getEpochSecond(), exp.getEpochSecond()).trim(); + String token = Base64.getEncoder().encodeToString(payload.getBytes()) + "." + sign(kp.getPrivate(), payload); + + assertThatThrownBy(() -> validator.validate(token)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("tenantId"); +} + +@Test +void validate_missingLicenseId_throws() throws Exception { + KeyPair kp = generateKeyPair(); + String publicKeyBase64 = Base64.getEncoder().encodeToString(kp.getPublic().getEncoded()); + LicenseValidator validator = new LicenseValidator(publicKeyBase64, "acme"); + + Instant exp = Instant.now().plus(30, ChronoUnit.DAYS); + String payload = """ + {"tenantId":"acme","tier":"X","limits":{},"iat":%d,"exp":%d} + """.formatted(Instant.now().getEpochSecond(), exp.getEpochSecond()).trim(); + String token = Base64.getEncoder().encodeToString(payload.getBytes()) + "." + sign(kp.getPrivate(), payload); + + assertThatThrownBy(() -> validator.validate(token)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("licenseId"); +} +``` + +Update `validate_validLicense_returnsLicenseInfo` payload to include both required fields: + +```java +String payload = """ + {"licenseId":"%s","tenantId":"acme","tier":"HIGH","limits":{"max_agents":50},"iat":%d,"exp":%d,"gracePeriodDays":7} + """.formatted(UUID.randomUUID(), Instant.now().getEpochSecond(), expires.getEpochSecond()).trim(); +``` + +And construct the validator with the matching tenant: + +```java +LicenseValidator validator = new LicenseValidator(publicKeyBase64, "acme"); +``` + +Add asserts: + +```java +assertThat(info.tenantId()).isEqualTo("acme"); +assertThat(info.gracePeriodDays()).isEqualTo(7); +``` + +Apply the same `tenantId`/`licenseId` additions to `validate_expiredLicense_throwsException` and `validate_tamperedPayload_throwsException` payloads. Add `import java.util.UUID;` at the top. + +- [ ] **Step 3.3: Run — expect failures** + +Run: `mvn -pl cameleer-server-app test -Dtest=LicenseValidatorTest` +Expected: FAIL — `LicenseValidator` ctor takes only one arg. + +- [ ] **Step 3.4: Rewrite `LicenseValidator.java`** + +```java +package com.cameleer.server.core.license; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.security.*; +import java.security.spec.X509EncodedKeySpec; +import java.time.Instant; +import java.util.*; + +public class LicenseValidator { + + private static final Logger log = LoggerFactory.getLogger(LicenseValidator.class); + private static final ObjectMapper objectMapper = new ObjectMapper(); + + private final PublicKey publicKey; + private final String expectedTenantId; + + public LicenseValidator(String publicKeyBase64, String expectedTenantId) { + Objects.requireNonNull(expectedTenantId, "expectedTenantId is required"); + if (expectedTenantId.isBlank()) { + throw new IllegalArgumentException("expectedTenantId must not be blank"); + } + try { + byte[] keyBytes = Base64.getDecoder().decode(publicKeyBase64); + KeyFactory kf = KeyFactory.getInstance("Ed25519"); + this.publicKey = kf.generatePublic(new X509EncodedKeySpec(keyBytes)); + } catch (Exception e) { + throw new IllegalStateException("Failed to load license public key", e); + } + this.expectedTenantId = expectedTenantId; + } + + public LicenseInfo validate(String token) { + String[] parts = token.split("\\.", 2); + if (parts.length != 2) { + throw new IllegalArgumentException("Invalid license token format: expected payload.signature"); + } + + byte[] payloadBytes = Base64.getDecoder().decode(parts[0]); + byte[] signatureBytes = Base64.getDecoder().decode(parts[1]); + + try { + Signature verifier = Signature.getInstance("Ed25519"); + verifier.initVerify(publicKey); + verifier.update(payloadBytes); + if (!verifier.verify(signatureBytes)) { + throw new SecurityException("License signature verification failed"); + } + } catch (SecurityException e) { + throw e; + } catch (Exception e) { + throw new SecurityException("License signature verification failed", e); + } + + try { + JsonNode root = objectMapper.readTree(payloadBytes); + + String licenseIdStr = textOrThrow(root, "licenseId"); + UUID licenseId; + try { + licenseId = UUID.fromString(licenseIdStr); + } catch (IllegalArgumentException e) { + throw new IllegalArgumentException("licenseId is not a valid UUID: " + licenseIdStr); + } + + String tenantId = textOrThrow(root, "tenantId"); + if (!tenantId.equals(expectedTenantId)) { + throw new IllegalArgumentException( + "License tenantId '" + tenantId + "' does not match server tenant '" + expectedTenantId + "'"); + } + + String tier = root.has("tier") ? root.get("tier").asText() : "unspecified"; + String label = root.has("label") ? root.get("label").asText() : null; + + Map limits = new HashMap<>(); + if (root.has("limits")) { + root.get("limits").fields().forEachRemaining(entry -> + limits.put(entry.getKey(), entry.getValue().asInt())); + } + + Instant issuedAt = root.has("iat") ? Instant.ofEpochSecond(root.get("iat").asLong()) : Instant.now(); + if (!root.has("exp")) { + throw new IllegalArgumentException("exp is required"); + } + Instant expiresAt = Instant.ofEpochSecond(root.get("exp").asLong()); + int gracePeriodDays = root.has("gracePeriodDays") ? root.get("gracePeriodDays").asInt() : 0; + + LicenseInfo info = new LicenseInfo(licenseId, tenantId, label, limits, issuedAt, expiresAt, gracePeriodDays); + + if (info.isExpired()) { + throw new IllegalArgumentException("License expired at " + expiresAt + + " (grace period " + gracePeriodDays + " days)"); + } + + return info; + } catch (IllegalArgumentException e) { + throw e; + } catch (Exception e) { + throw new IllegalArgumentException("Failed to parse license payload", e); + } + } + + private static String textOrThrow(JsonNode root, String field) { + if (!root.has(field) || root.get(field).asText().isBlank()) { + throw new IllegalArgumentException(field + " is required"); + } + return root.get(field).asText(); + } +} +``` + +Note `tier` is now optional with a default — spec calls it a label-only field; we keep parsing it for backward compatibility with old hand-written tokens. + +- [ ] **Step 3.5: Run validator tests** + +Run: `mvn -pl cameleer-server-app test -Dtest=LicenseValidatorTest` +Expected: PASS, 6/6. + +- [ ] **Step 3.6: Update `LicenseBeanConfig` — pass tenant id to validator (compile fix)** + +In `cameleer-server-app/src/main/java/com/cameleer/server/app/config/LicenseBeanConfig.java`, inject the tenant id and update the two `new LicenseValidator(...)` calls: + +```java +@Value("${cameleer.server.tenant.id:default}") +private String tenantId; +``` + +And change both call sites: + +```java +LicenseValidator validator = new LicenseValidator(licensePublicKey, tenantId); +``` + +Apply the same change to `LicenseAdminController.java` line 45 (use `@Value("${cameleer.server.tenant.id:default}")` injected via the constructor). + +- [ ] **Step 3.7: Build app** + +Run: `mvn -pl cameleer-server-app compile` +Expected: BUILD SUCCESS. + +- [ ] **Step 3.8: Commit** + +```bash +git add cameleer-server-core cameleer-server-app +git commit -m "$(cat <<'EOF' +feat(license): require licenseId + tenantId in validator + +Spec §2.1 — both fields are required and the validator rejects a +token whose tenantId does not match the server's configured tenant +(CAMELEER_SERVER_TENANT_ID). Self-hosted customers cannot strip +tenantId because the field is in the signed payload. + +LicenseBeanConfig and LicenseAdminController updated to pass the +expected tenant to the validator constructor. + +Co-Authored-By: Claude Opus 4.7 (1M context) +EOF +)" +``` + +--- + +## Task 4: Add `LicenseLimits`, `DefaultTierLimits`, `LicenseState`, `LicenseStateMachine` + +**Why:** Spec §3 — pure FSM + the default-tier constants live in `core` so app and minter can both use them. + +**Files:** +- Create: `cameleer-server-core/src/main/java/com/cameleer/server/core/license/LicenseLimits.java` +- Create: `cameleer-server-core/src/main/java/com/cameleer/server/core/license/DefaultTierLimits.java` +- Create: `cameleer-server-core/src/main/java/com/cameleer/server/core/license/LicenseState.java` +- Create: `cameleer-server-core/src/main/java/com/cameleer/server/core/license/LicenseStateMachine.java` +- Test: `cameleer-server-core/src/test/java/com/cameleer/server/core/license/DefaultTierLimitsTest.java` +- Test: `cameleer-server-core/src/test/java/com/cameleer/server/core/license/LicenseStateMachineTest.java` + +- [ ] **Step 4.1: Write failing test — `DefaultTierLimitsTest.java`** + +```java +package com.cameleer.server.core.license; + +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +class DefaultTierLimitsTest { + + @Test + void allDocumentedKeysHaveDefaults() { + for (String key : new String[]{ + "max_environments", "max_apps", "max_agents", "max_users", + "max_outbound_connections", "max_alert_rules", + "max_total_cpu_millis", "max_total_memory_mb", "max_total_replicas", + "max_execution_retention_days", "max_log_retention_days", + "max_metric_retention_days", "max_jar_retention_count" + }) { + assertThat(DefaultTierLimits.DEFAULTS).containsKey(key); + } + } + + @Test + void specificValues() { + assertThat(DefaultTierLimits.DEFAULTS.get("max_environments")).isEqualTo(1); + assertThat(DefaultTierLimits.DEFAULTS.get("max_apps")).isEqualTo(3); + assertThat(DefaultTierLimits.DEFAULTS.get("max_agents")).isEqualTo(5); + assertThat(DefaultTierLimits.DEFAULTS.get("max_total_cpu_millis")).isEqualTo(2000); + assertThat(DefaultTierLimits.DEFAULTS.get("max_log_retention_days")).isEqualTo(1); + } +} +``` + +- [ ] **Step 4.2: Run — expect compile failure** + +Run: `mvn -pl cameleer-server-core test -Dtest=DefaultTierLimitsTest` +Expected: FAIL — class missing. + +- [ ] **Step 4.3: Create `DefaultTierLimits.java`** + +```java +package com.cameleer.server.core.license; + +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.Map; + +public final class DefaultTierLimits { + + public static final Map DEFAULTS; + + static { + Map m = new LinkedHashMap<>(); + m.put("max_environments", 1); + m.put("max_apps", 3); + m.put("max_agents", 5); + m.put("max_users", 3); + m.put("max_outbound_connections", 1); + m.put("max_alert_rules", 2); + m.put("max_total_cpu_millis", 2000); + m.put("max_total_memory_mb", 2048); + m.put("max_total_replicas", 5); + m.put("max_execution_retention_days", 1); + m.put("max_log_retention_days", 1); + m.put("max_metric_retention_days", 1); + m.put("max_jar_retention_count", 3); + DEFAULTS = Collections.unmodifiableMap(m); + } + + private DefaultTierLimits() {} +} +``` + +- [ ] **Step 4.4: Create `LicenseLimits.java`** + +```java +package com.cameleer.server.core.license; + +import java.util.Map; +import java.util.Objects; + +public record LicenseLimits(Map values) { + + public LicenseLimits { + Objects.requireNonNull(values, "values"); + } + + public static LicenseLimits defaultsOnly() { + return new LicenseLimits(DefaultTierLimits.DEFAULTS); + } + + public static LicenseLimits mergeOverDefaults(Map overrides) { + java.util.Map merged = new java.util.LinkedHashMap<>(DefaultTierLimits.DEFAULTS); + if (overrides != null) merged.putAll(overrides); + return new LicenseLimits(java.util.Collections.unmodifiableMap(merged)); + } + + public int get(String key) { + Integer v = values.get(key); + if (v == null) { + throw new IllegalArgumentException("Unknown license limit key: " + key); + } + return v; + } + + public boolean isDefaultSourced(String key, LicenseInfo license) { + if (license == null) return true; + return !license.limits().containsKey(key); + } +} +``` + +- [ ] **Step 4.5: Create `LicenseState.java`** + +```java +package com.cameleer.server.core.license; + +public enum LicenseState { + ABSENT, + ACTIVE, + GRACE, + EXPIRED, + INVALID +} +``` + +- [ ] **Step 4.6: Write failing test — `LicenseStateMachineTest.java`** + +```java +package com.cameleer.server.core.license; + +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 LicenseStateMachineTest { + + @Test + void noLicense_isAbsent() { + assertThat(LicenseStateMachine.classify(null, null)).isEqualTo(LicenseState.ABSENT); + } + + @Test + void invalidReason_isInvalid() { + assertThat(LicenseStateMachine.classify(null, "signature failed")).isEqualTo(LicenseState.INVALID); + } + + @Test + void activeBeforeExp() { + LicenseInfo info = info(Instant.now().plusSeconds(86400), 0); + assertThat(LicenseStateMachine.classify(info, null)).isEqualTo(LicenseState.ACTIVE); + } + + @Test + void graceWithinGracePeriod() { + LicenseInfo info = info(Instant.now().minusSeconds(86400), 7); + assertThat(LicenseStateMachine.classify(info, null)).isEqualTo(LicenseState.GRACE); + } + + @Test + void expiredAfterGrace() { + LicenseInfo info = info(Instant.now().minusSeconds(8L * 86400), 7); + assertThat(LicenseStateMachine.classify(info, null)).isEqualTo(LicenseState.EXPIRED); + } + + @Test + void expiredImmediatelyWithZeroGrace() { + LicenseInfo info = info(Instant.now().minusSeconds(60), 0); + assertThat(LicenseStateMachine.classify(info, null)).isEqualTo(LicenseState.EXPIRED); + } + + @Test + void invalidWinsOverPresentLicense() { + LicenseInfo info = info(Instant.now().plusSeconds(86400), 0); + assertThat(LicenseStateMachine.classify(info, "tenant mismatch")).isEqualTo(LicenseState.INVALID); + } + + private LicenseInfo info(Instant exp, int graceDays) { + return new LicenseInfo(UUID.randomUUID(), "acme", null, Map.of(), + Instant.now().minusSeconds(3600), exp, graceDays); + } +} +``` + +- [ ] **Step 4.7: Run — expect compile failure** + +Run: `mvn -pl cameleer-server-core test -Dtest=LicenseStateMachineTest` +Expected: FAIL — class missing. + +- [ ] **Step 4.8: Create `LicenseStateMachine.java`** + +```java +package com.cameleer.server.core.license; + +public final class LicenseStateMachine { + + private LicenseStateMachine() {} + + /** + * @param license parsed license, or null if no license is loaded + * @param invalidReason non-null if the last validation attempt failed + */ + public static LicenseState classify(LicenseInfo license, String invalidReason) { + if (invalidReason != null) { + return LicenseState.INVALID; + } + if (license == null) { + return LicenseState.ABSENT; + } + if (!license.isAfterRawExpiry()) { + return LicenseState.ACTIVE; + } + if (!license.isExpired()) { + return LicenseState.GRACE; + } + return LicenseState.EXPIRED; + } +} +``` + +- [ ] **Step 4.9: Run all core tests** + +Run: `mvn -pl cameleer-server-core test` +Expected: PASS — all four new tests + existing. + +- [ ] **Step 4.10: Commit** + +```bash +git add cameleer-server-core/src +git commit -m "$(cat <<'EOF' +feat(license): add LicenseLimits, DefaultTierLimits, LicenseStateMachine + +Pure-domain FSM (ABSENT/ACTIVE/GRACE/EXPIRED/INVALID) and the +default-tier constants per spec §3. invalidReason wins over any +loaded license so signature failures surface as INVALID rather +than masking as ABSENT. + +Co-Authored-By: Claude Opus 4.7 (1M context) +EOF +)" +``` + +--- + +## Task 5: Rewrite `LicenseGate` with state, effective limits, and invalidReason + +**Why:** Spec §3.1 — gate must expose state + effective limits (merged over defaults) for the enforcer and usage reader. + +**Files:** +- Modify: `cameleer-server-core/src/main/java/com/cameleer/server/core/license/LicenseGate.java` +- Modify: `cameleer-server-app/src/test/java/com/cameleer/server/core/license/LicenseGateTest.java` + +- [ ] **Step 5.1: Run gitnexus impact** + +``` +gitnexus_impact({target: "LicenseGate", direction: "upstream"}) +``` + +- [ ] **Step 5.2: Write failing test — replace `LicenseGateTest.java` body** + +```java +package com.cameleer.server.core.license; + +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 LicenseGateTest { + + @Test + void absent_byDefault() { + LicenseGate gate = new LicenseGate(); + assertThat(gate.getState()).isEqualTo(LicenseState.ABSENT); + assertThat(gate.getEffectiveLimits().get("max_apps")) + .isEqualTo(DefaultTierLimits.DEFAULTS.get("max_apps")); + assertThat(gate.getCurrent()).isNull(); + assertThat(gate.getInvalidReason()).isNull(); + } + + @Test + void load_setsActiveAndMergesLimits() { + LicenseGate gate = new LicenseGate(); + LicenseInfo info = new LicenseInfo(UUID.randomUUID(), "acme", "label", + Map.of("max_apps", 50), Instant.now(), + Instant.now().plusSeconds(86400), 0); + gate.load(info); + + assertThat(gate.getState()).isEqualTo(LicenseState.ACTIVE); + assertThat(gate.getEffectiveLimits().get("max_apps")).isEqualTo(50); + // unspecified key still inherits default + assertThat(gate.getEffectiveLimits().get("max_users")) + .isEqualTo(DefaultTierLimits.DEFAULTS.get("max_users")); + } + + @Test + void markInvalid_overridesActive() { + LicenseGate gate = new LicenseGate(); + LicenseInfo info = new LicenseInfo(UUID.randomUUID(), "acme", null, + Map.of("max_apps", 50), Instant.now(), + Instant.now().plusSeconds(86400), 0); + gate.load(info); + + gate.markInvalid("signature failed"); + assertThat(gate.getState()).isEqualTo(LicenseState.INVALID); + // limits revert to defaults when INVALID + assertThat(gate.getEffectiveLimits().get("max_apps")) + .isEqualTo(DefaultTierLimits.DEFAULTS.get("max_apps")); + assertThat(gate.getInvalidReason()).isEqualTo("signature failed"); + } + + @Test + void clear_returnsToAbsent() { + LicenseGate gate = new LicenseGate(); + LicenseInfo info = new LicenseInfo(UUID.randomUUID(), "acme", null, + Map.of(), Instant.now(), + Instant.now().plusSeconds(86400), 0); + gate.load(info); + gate.clear(); + + assertThat(gate.getState()).isEqualTo(LicenseState.ABSENT); + assertThat(gate.getCurrent()).isNull(); + } +} +``` + +- [ ] **Step 5.3: Run — expect failures** + +Run: `mvn -pl cameleer-server-app test -Dtest=LicenseGateTest` +Expected: FAIL — `getState`, `getEffectiveLimits`, `markInvalid`, `clear` missing. + +- [ ] **Step 5.4: Replace `LicenseGate.java`** + +```java +package com.cameleer.server.core.license; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.util.concurrent.atomic.AtomicReference; + +public class LicenseGate { + + private static final Logger log = LoggerFactory.getLogger(LicenseGate.class); + + private static final class Snapshot { + final LicenseInfo license; // null when ABSENT or INVALID + final String invalidReason; // null unless INVALID + Snapshot(LicenseInfo l, String r) { this.license = l; this.invalidReason = r; } + } + + private final AtomicReference snap = new AtomicReference<>(new Snapshot(null, null)); + + public void load(LicenseInfo license) { + snap.set(new Snapshot(license, null)); + log.info("License loaded: licenseId={}, tenantId={}, exp={}, gracePeriodDays={}", + license.licenseId(), license.tenantId(), license.expiresAt(), license.gracePeriodDays()); + } + + public void markInvalid(String reason) { + snap.set(new Snapshot(null, reason)); + log.error("License marked INVALID: {}", reason); + } + + public void clear() { + snap.set(new Snapshot(null, null)); + log.info("License cleared"); + } + + public LicenseInfo getCurrent() { + return snap.get().license; + } + + public String getInvalidReason() { + return snap.get().invalidReason; + } + + public LicenseState getState() { + Snapshot s = snap.get(); + return LicenseStateMachine.classify(s.license, s.invalidReason); + } + + /** Effective limits = defaults UNION license.limits, except in EXPIRED/ABSENT/INVALID where defaults win. */ + public LicenseLimits getEffectiveLimits() { + Snapshot s = snap.get(); + LicenseState state = LicenseStateMachine.classify(s.license, s.invalidReason); + if (state == LicenseState.ACTIVE || state == LicenseState.GRACE) { + return LicenseLimits.mergeOverDefaults(s.license.limits()); + } + return LicenseLimits.defaultsOnly(); + } + + public int getLimit(String key, int defaultValue) { + try { + return getEffectiveLimits().get(key); + } catch (IllegalArgumentException e) { + return defaultValue; + } + } +} +``` + +- [ ] **Step 5.5: Remove temporary `openSentinel()` from prior tasks (compile ripple)** + +Inside `LicenseGate.java` from Task 2.5, the temporary `openSentinel()` and field initialiser using it are now superseded by the new file in Step 5.4 — already replaced. Sanity-check by grepping: + +Run: `grep -r openSentinel cameleer-server-core/src cameleer-server-app/src || echo OK` +Expected: prints `OK`. + +- [ ] **Step 5.6: Run all core tests** + +Run: `mvn -pl cameleer-server-core test` +Expected: PASS. + +- [ ] **Step 5.7: Run app unit tests** + +Run: `mvn -pl cameleer-server-app test -DskipITs` +Expected: PASS. + +- [ ] **Step 5.8: Commit** + +```bash +git add cameleer-server-core cameleer-server-app +git commit -m "$(cat <<'EOF' +feat(license): rewrite LicenseGate around state + effective limits + +LicenseGate now exposes getState() (delegates to LicenseStateMachine), +getEffectiveLimits() (merged over DefaultTierLimits in ACTIVE/GRACE, +defaults-only in ABSENT/EXPIRED/INVALID), markInvalid(reason), and +clear(). Internal snapshot is an immutable record swapped atomically +so concurrent reads see a consistent license+reason pair. + +Co-Authored-By: Claude Opus 4.7 (1M context) +EOF +)" +``` + +--- + +## Task 6: Create `cameleer-license-minter` Maven module + +**Why:** Spec §1.1 — vendor-only signing module; not on `cameleer-server-app`'s classpath. + +**Files:** +- Create: `cameleer-license-minter/pom.xml` +- Modify: `pom.xml` (root) — add module + +- [ ] **Step 6.1: Create the module directory and pom** + +```bash +mkdir -p cameleer-license-minter/src/main/java/com/cameleer/license/minter/cli +mkdir -p cameleer-license-minter/src/test/java/com/cameleer/license/minter/cli +``` + +- [ ] **Step 6.2: Write `cameleer-license-minter/pom.xml`** + +```xml + + + 4.0.0 + + + com.cameleer + cameleer-server-parent + 1.0-SNAPSHOT + + + cameleer-license-minter + Cameleer License Minter + Vendor-only Ed25519 license signing library + CLI + + + + com.cameleer + cameleer-server-core + + + com.fasterxml.jackson.core + jackson-databind + + + org.slf4j + slf4j-api + + + + org.junit.jupiter + junit-jupiter + test + + + org.assertj + assertj-core + test + + + + + + + org.springframework.boot + spring-boot-maven-plugin + + + repackage-cli + + repackage + + + cli + com.cameleer.license.minter.cli.LicenseMinterCli + + + + + + + +``` + +- [ ] **Step 6.3: Register the module in root `pom.xml`** + +Edit `pom.xml`, locate the `` block, add the new entry: + +```xml + + cameleer-server-core + cameleer-server-app + cameleer-license-minter + +``` + +- [ ] **Step 6.4: Verify build** + +Run: `mvn -pl cameleer-license-minter -am compile` +Expected: BUILD SUCCESS — no Java sources yet, but module is recognised. + +- [ ] **Step 6.5: Commit** + +```bash +git add pom.xml cameleer-license-minter/pom.xml +git commit -m "$(cat <<'EOF' +feat(license-minter): add cameleer-license-minter Maven module + +Top-level module sibling to cameleer-server-core/-app. Depends on +cameleer-server-core for the LicenseInfo schema. Spring Boot +repackage produces a runnable -cli classifier for the vendor. + +Not added as a dependency from cameleer-server-app — runtime tree +must not carry signing primitives. + +Co-Authored-By: Claude Opus 4.7 (1M context) +EOF +)" +``` + +--- + +## Task 7: Implement `LicenseMinter` library + canonical-JSON serializer + +**Why:** Spec §7.1 — pure signer used by both the CLI and cameleer-saas. + +**Files:** +- Create: `cameleer-license-minter/src/main/java/com/cameleer/license/minter/LicenseMinter.java` +- Test: `cameleer-license-minter/src/test/java/com/cameleer/license/minter/LicenseMinterTest.java` + +- [ ] **Step 7.1: Write failing test** + +```java +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.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"); + LicenseInfo info = new LicenseInfo(id, "acme", "label", + java.util.LinkedHashMap.newLinkedHashMap(2) + // intentional non-sorted insertion order + .ordered() + ? null : null, // placeholder; rebuild below + now, exp, 0); + // Build the map in non-alpha order and ensure canonical output is sorted + java.util.LinkedHashMap limits = new java.util.LinkedHashMap<>(); + limits.put("max_apps", 5); + limits.put("max_agents", 10); + LicenseInfo info2 = new LicenseInfo(id, "acme", "label", limits, now, exp, 0); + + String t1 = LicenseMinter.mint(info2, kp.getPrivate()); + String t2 = LicenseMinter.mint(info2, kp.getPrivate()); + assertThat(t1).isEqualTo(t2); + } +} +``` + +(Strip the bogus `LinkedHashMap.newLinkedHashMap` call — it's there only to fail. The real test below is `info2`.) + +Replace the entire test method body of `canonicalJson_isStableAcrossRuns` with just the `info2` construction and assertions: + +```java +@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"); + java.util.LinkedHashMap limits = new java.util.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); +} +``` + +- [ ] **Step 7.2: Run — expect compile failure** + +Run: `mvn -pl cameleer-license-minter test -Dtest=LicenseMinterTest` +Expected: FAIL — class missing. + +- [ ] **Step 7.3: Implement `LicenseMinter.java`** + +```java +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); + } + } +} +``` + +- [ ] **Step 7.4: Run tests** + +Run: `mvn -pl cameleer-license-minter test` +Expected: PASS — both tests. + +- [ ] **Step 7.5: Commit** + +```bash +git add cameleer-license-minter +git commit -m "$(cat <<'EOF' +feat(license-minter): implement LicenseMinter library + +Pure signing primitive: serialises LicenseInfo to canonical JSON +(sorted top-level keys via ORDER_MAP_ENTRIES_BY_KEYS plus a TreeMap +for the limits sub-object) then signs with Ed25519. Round-trips +through LicenseValidator and is byte-stable across runs for +identical inputs. + +Co-Authored-By: Claude Opus 4.7 (1M context) +EOF +)" +``` + +--- + +## Task 8: Implement `LicenseMinterCli` (without `--verify` yet) + +**Why:** Spec §7.2 — vendor CLI. Split from `--verify` (Task 9) to keep diffs reviewable. + +**Files:** +- Create: `cameleer-license-minter/src/main/java/com/cameleer/license/minter/cli/LicenseMinterCli.java` +- Test: `cameleer-license-minter/src/test/java/com/cameleer/license/minter/cli/LicenseMinterCliTest.java` + +- [ ] **Step 8.1: Write failing test** + +```java +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(); + } +} +``` + +- [ ] **Step 8.2: Run — expect failure** + +Run: `mvn -pl cameleer-license-minter test -Dtest=LicenseMinterCliTest` +Expected: FAIL — class missing. + +- [ ] **Step 8.3: Implement `LicenseMinterCli.java`** + +```java +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.*; + +public final class LicenseMinterCli { + + private static final Set 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 flags = new LinkedHashMap<>(); + Set bool = new HashSet<>(); + Map 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); + } + 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(); + // Accept base64 of PKCS#8 (openssl pkey -outform DER + base64), or PEM + 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)); + } +} +``` + +- [ ] **Step 8.4: Run tests** + +Run: `mvn -pl cameleer-license-minter test -Dtest=LicenseMinterCliTest` +Expected: PASS — both tests. + +- [ ] **Step 8.5: Commit** + +```bash +git add cameleer-license-minter +git commit -m "$(cat <<'EOF' +feat(license-minter): add LicenseMinterCli (without --verify) + +Reads PEM or base64 PKCS#8 Ed25519 private key, maps --max-foo-bar +flags to max_foo_bar limit keys, parses --expires as a UTC date, +defaults --grace-days to 0. Unknown flags fail fast with exit 2. +--verify path is added in the next task. + +Co-Authored-By: Claude Opus 4.7 (1M context) +EOF +)" +``` + +--- + +## Task 9: Add `--verify` round-trip to `LicenseMinterCli` + +**Why:** Spec §7.2 — verify the freshly-minted token before shipping it to the customer. Delete the output file on failure. + +**Files:** +- Modify: `cameleer-license-minter/src/main/java/com/cameleer/license/minter/cli/LicenseMinterCli.java` +- Modify: `cameleer-license-minter/src/test/java/com/cameleer/license/minter/cli/LicenseMinterCliTest.java` + +- [ ] **Step 9.1: Add failing tests** + +Append to `LicenseMinterCliTest.java`: + +```java +@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(); +} +``` + +- [ ] **Step 9.2: Run — expect failures** + +Run: `mvn -pl cameleer-license-minter test -Dtest=LicenseMinterCliTest` +Expected: FAIL — `--verify` is silently accepted but does nothing. + +- [ ] **Step 9.3: Implement `--verify`** + +In `LicenseMinterCli.run(...)`, after computing `token` and writing the output file (if any), insert before `return 0;`: + +```java +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; + } +} +``` + +- [ ] **Step 9.4: Run tests** + +Run: `mvn -pl cameleer-license-minter test` +Expected: PASS — all five. + +- [ ] **Step 9.5: Commit** + +```bash +git add cameleer-license-minter +git commit -m "$(cat <<'EOF' +feat(license-minter): --verify round-trips before shipping + +Adds --verify (requires --public-key) to LicenseMinterCli. After +writing the output file the CLI parses the freshly-minted token +through LicenseValidator against the supplied public key. On +verify failure the output file is deleted (so the bad token is +not accidentally shipped) and the CLI exits 3. + +Co-Authored-By: Claude Opus 4.7 (1M context) +EOF +)" +``` + +--- + +## Task 10: Flyway V5 — `license` table + `environments` retention columns + +**Why:** Spec §6.1 + §4.2. + +**Files:** +- Create: `cameleer-server-app/src/main/resources/db/migration/V5__license_table_and_environment_retention.sql` + +- [ ] **Step 10.1: Write migration** + +```sql +-- 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; +``` + +- [ ] **Step 10.2: Verify migration applies cleanly via SchemaBootstrapIT (will be extended in Task 35; for now smoke via app boot)** + +Run: `mvn -pl cameleer-server-app test -Dtest=SchemaBootstrapIT` +Expected: PASS — existing assertions still hold; the new columns/table appear silently. + +- [ ] **Step 10.3: Commit** + +```bash +git add cameleer-server-app/src/main/resources/db/migration/V5__license_table_and_environment_retention.sql +git commit -m "$(cat <<'EOF' +feat(license): Flyway V5 — license table + environments retention columns + +Per-tenant license row stores the signed token, licenseId for audit, +installed/expires/last_validated timestamps. environments gains three +INTEGER NOT NULL DEFAULT 1 retention columns (execution, log, metric) +so existing rows land inside the default-tier cap. + +Co-Authored-By: Claude Opus 4.7 (1M context) +EOF +)" +``` + +--- + +## Task 11: `LicenseRecord` + `LicenseRepository` + `PostgresLicenseRepository` + +**Why:** Spec §6.1, §6.2 — typed access to the `license` row. + +**Files:** +- Create: `cameleer-server-app/src/main/java/com/cameleer/server/app/license/LicenseRecord.java` +- Create: `cameleer-server-app/src/main/java/com/cameleer/server/app/license/LicenseRepository.java` +- Create: `cameleer-server-app/src/main/java/com/cameleer/server/app/license/PostgresLicenseRepository.java` +- Test: `cameleer-server-app/src/test/java/com/cameleer/server/app/license/PostgresLicenseRepositoryIT.java` + +- [ ] **Step 11.1: Create the record + interface** + +`LicenseRecord.java`: + +```java +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 +) {} +``` + +`LicenseRepository.java`: + +```java +package com.cameleer.server.app.license; + +import java.time.Instant; +import java.util.Optional; +import java.util.UUID; + +public interface LicenseRepository { + Optional 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); +} +``` + +- [ ] **Step 11.2: Write integration test (Testcontainers Postgres)** + +```java +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); + } +} +``` + +- [ ] **Step 11.3: Run — expect compile failure (impl missing)** + +Run: `mvn -pl cameleer-server-app test -Dtest=PostgresLicenseRepositoryIT` +Expected: FAIL — `PostgresLicenseRepository` class missing. + +- [ ] **Step 11.4: Implement `PostgresLicenseRepository.java`** + +```java +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 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 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); + } +} +``` + +- [ ] **Step 11.5: Wire the bean (temporary placement; Task 14 consolidates)** + +In `cameleer-server-app/src/main/java/com/cameleer/server/app/config/StorageBeanConfig.java`, add: + +```java +@Bean +public com.cameleer.server.app.license.LicenseRepository licenseRepository( + org.springframework.jdbc.core.JdbcTemplate jdbcTemplate) { + return new com.cameleer.server.app.license.PostgresLicenseRepository(jdbcTemplate); +} +``` + +- [ ] **Step 11.6: Run IT** + +Run: `mvn -pl cameleer-server-app verify -Dit.test=PostgresLicenseRepositoryIT` +Expected: PASS, 2/2. + +- [ ] **Step 11.7: Commit** + +```bash +git add cameleer-server-app/src/{main,test}/java/com/cameleer/server/app/license cameleer-server-app/src/main/java/com/cameleer/server/app/config/StorageBeanConfig.java +git commit -m "$(cat <<'EOF' +feat(license): PostgresLicenseRepository + LicenseRecord + +JdbcTemplate-backed repo; upsert is ON CONFLICT (tenant_id), touch +updates only last_validated_at, delete is provided for future +operator-clear flow (not exposed as REST in v1). + +Co-Authored-By: Claude Opus 4.7 (1M context) +EOF +)" +``` + +--- + +## Task 12: `AuditCategory.LICENSE` + +**Why:** Spec §6.5 — every license install/replace/reject and every cap rejection writes an audit row under a dedicated category. + +**Files:** +- Modify: the file that declares `AuditCategory` (find via `grep -rn "enum AuditCategory" cameleer-server-core/src cameleer-server-app/src`) + +- [ ] **Step 12.1: Locate the enum** + +Run: `grep -rn "enum AuditCategory" cameleer-server-core/src cameleer-server-app/src` +Note the file path; expected location is core (per `.claude/rules/core-classes.md`). + +- [ ] **Step 12.2: Add `LICENSE` to the enum** + +```java +public enum AuditCategory { + INFRA, + AUTH, + USER_MGMT, + CONFIG, + RBAC, + AGENT, + OUTBOUND_CONNECTION_CHANGE, + OUTBOUND_HTTP_TRUST_CHANGE, + ALERT_RULE_CHANGE, + ALERT_SILENCE_CHANGE, + DEPLOYMENT, + LICENSE +} +``` + +(Insert `LICENSE` last to avoid renumbering existing values if the enum is persisted by ordinal — verify quickly: `grep -n "ordinal\|name()" $(grep -rl "AuditCategory" cameleer-server-app/src) | head`. The category column is stored by `name()` in `audit_log`; appending is safe.) + +- [ ] **Step 12.3: Build** + +Run: `mvn -pl cameleer-server-core test` +Expected: PASS. + +- [ ] **Step 12.4: Commit** + +```bash +git add -u +git commit -m "$(cat <<'EOF' +feat(license): add AuditCategory.LICENSE + +Tasks downstream (LicenseService, LicenseEnforcer) audit under +this category for install_license / replace_license / reject_license +/ revalidate_license / cap_exceeded actions. + +Co-Authored-By: Claude Opus 4.7 (1M context) +EOF +)" +``` + +--- + +## Task 13: `LicenseChangedEvent` + `LicenseService` + +**Why:** Spec §6.3 — single service that mediates DB ↔ gate, publishes events on every change, and audits. + +**Files:** +- Create: `cameleer-server-app/src/main/java/com/cameleer/server/app/license/LicenseChangedEvent.java` +- Create: `cameleer-server-app/src/main/java/com/cameleer/server/app/license/LicenseService.java` +- Test: `cameleer-server-app/src/test/java/com/cameleer/server/app/license/LicenseServiceTest.java` + +- [ ] **Step 13.1: Create the event** + +```java +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); + } +} +``` + +- [ ] **Step 13.2: Write failing test** + +```java +package com.cameleer.server.app.license; + +import com.cameleer.server.core.admin.AuditCategory; +import com.cameleer.server.core.admin.AuditService; +import com.cameleer.server.core.license.*; +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.Mockito.*; + +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).record(eq(AuditCategory.LICENSE), eq("install_license"), any(), any()); + } + + @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).record(eq(AuditCategory.LICENSE), eq("reject_license"), any(), any()); + } + + @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).record(eq(AuditCategory.LICENSE), eq("replace_license"), any(), any()); + } +} +``` + +- [ ] **Step 13.3: Run — expect failure** + +Run: `mvn -pl cameleer-server-app test -Dtest=LicenseServiceTest` +Expected: FAIL — class missing. + +- [ ] **Step 13.4: Implement `LicenseService.java`** + +```java +package com.cameleer.server.app.license; + +import com.cameleer.server.core.admin.AuditCategory; +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; +import java.util.UUID; + +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). 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 payload = new LinkedHashMap<>(); + payload.put("reason", reason); + payload.put("source", source); + audit.record(AuditCategory.LICENSE, "reject_license", installedBy, payload); + events.publishEvent(new LicenseChangedEvent(gate.getState(), gate.getCurrent())); + throw e instanceof RuntimeException re ? re : new IllegalArgumentException(e); + } + + Optional 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 payload = new LinkedHashMap<>(); + payload.put("licenseId", info.licenseId().toString()); + payload.put("expiresAt", info.expiresAt().toString()); + payload.put("installedBy", installedBy); + payload.put("source", source); + if (existing.isPresent()) { + payload.put("previousLicenseId", existing.get().licenseId().toString()); + audit.record(AuditCategory.LICENSE, "replace_license", installedBy, payload); + } else { + audit.record(AuditCategory.LICENSE, "install_license", installedBy, payload); + } + + 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 envToken, Optional 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 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 persisted = repo.findByTenantId(tenantId); + if (persisted.isEmpty()) return; + try { + LicenseInfo info = validator.validate(persisted.get().token()); + repo.touchValidated(tenantId, Instant.now()); + // Promote into gate in case it was marked INVALID for a transient reason + gate.load(info); + events.publishEvent(new LicenseChangedEvent(gate.getState(), info)); + } catch (Exception e) { + String reason = e.getMessage(); + gate.markInvalid(reason); + Map payload = new LinkedHashMap<>(); + payload.put("licenseId", persisted.get().licenseId().toString()); + payload.put("reason", reason); + audit.record(AuditCategory.LICENSE, "revalidate_license", "system", payload); + events.publishEvent(new LicenseChangedEvent(gate.getState(), null)); + log.error("Revalidation failed: {}", reason); + } + } + + public String getTenantId() { return tenantId; } +} +``` + +If the existing `AuditService.record(...)` signature differs, adapt the call sites in this class to match. Run `grep -n "void record" $(grep -rl "interface AuditService" cameleer-server-core/src)` to confirm. + +- [ ] **Step 13.5: Run unit test** + +Run: `mvn -pl cameleer-server-app test -Dtest=LicenseServiceTest` +Expected: PASS, 3/3. + +- [ ] **Step 13.6: Commit** + +```bash +git add cameleer-server-app/src/main/java/com/cameleer/server/app/license cameleer-server-app/src/test/java/com/cameleer/server/app/license +git commit -m "$(cat <<'EOF' +feat(license): LicenseService + LicenseChangedEvent + +Single mediation point for token install/replace/revalidate. Audits +under AuditCategory.LICENSE, persists to PG, mutates the LicenseGate, +and publishes LicenseChangedEvent so downstream listeners +(RetentionPolicyApplier, LicenseMetrics) react uniformly. + +Co-Authored-By: Claude Opus 4.7 (1M context) +EOF +)" +``` + +--- + +## Task 14: Refactor `LicenseBeanConfig` boot order; wire `LicenseService` + +**Why:** Spec §6.2 — env > file > DB; emit one `LicenseChangedEvent` on boot. + +**Files:** +- Modify: `cameleer-server-app/src/main/java/com/cameleer/server/app/config/LicenseBeanConfig.java` + +- [ ] **Step 14.1: Run gitnexus impact** + +``` +gitnexus_impact({target: "LicenseBeanConfig", direction: "upstream"}) +``` + +- [ ] **Step 14.2: Replace `LicenseBeanConfig.java`** + +```java +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.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.util.Optional; + +@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; + + @Value("${cameleer.server.license.file:}") + private String licenseFile; + + @Value("${cameleer.server.license.publickey:}") + private String licensePublicKey; + + @Bean + public LicenseGate licenseGate() { + return new LicenseGate(); + } + + @Bean + public LicenseValidator licenseValidator() { + if (licensePublicKey == null || licensePublicKey.isBlank()) { + log.warn("CAMELEER_SERVER_LICENSE_PUBLICKEY not set — all licenses will be rejected as INVALID"); + // A non-functional validator that always throws so install() routes to INVALID. + return new LicenseValidator( + "MCowBQYDK2VwAyEA" + "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=", + tenantId) { + @Override public com.cameleer.server.core.license.LicenseInfo validate(String token) { + throw new IllegalStateException("license public key not configured"); + } + }; + } + 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); + } + + 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 env = (envToken != null && !envToken.isBlank()) + ? Optional.of(envToken) : Optional.empty(); + Optional 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); + } + } +} +``` + +The anonymous-subclass trick for the absent-key case avoids surfacing a separate "no validator" bean type; `install()` will catch its exception and route to INVALID. If you prefer cleaner architecture, introduce a `LicenseValidator.alwaysFails(reason)` static factory in core — but that's a follow-up. + +- [ ] **Step 14.3: Build** + +Run: `mvn -pl cameleer-server-app test -DskipITs` +Expected: PASS — existing tests still green. + +- [ ] **Step 14.4: Commit** + +```bash +git add cameleer-server-app/src/main/java/com/cameleer/server/app/config/LicenseBeanConfig.java +git commit -m "$(cat <<'EOF' +feat(license): wire LicenseService into boot order (env > file > DB) + +LicenseBootLoader @PostConstruct calls LicenseService.loadInitial, +which delegates to install() so env-var/file/DB paths share a single +audit + event-publish code path. A missing public key now produces +an always-failing validator so loaded tokens route to INVALID +instead of being silently ignored. + +Co-Authored-By: Claude Opus 4.7 (1M context) +EOF +)" +``` + +--- + +## Task 15: `LicenseCapExceededException`, `LicenseMessageRenderer`, `LicenseExceptionAdvice` + +**Why:** Spec §4 — typed exception + per-state rendered message + global 403 mapping. + +**Files:** +- Create: `cameleer-server-app/src/main/java/com/cameleer/server/app/license/LicenseCapExceededException.java` +- Create: `cameleer-server-app/src/main/java/com/cameleer/server/app/license/LicenseMessageRenderer.java` +- Create: `cameleer-server-app/src/main/java/com/cameleer/server/app/license/LicenseExceptionAdvice.java` +- Test: `cameleer-server-app/src/test/java/com/cameleer/server/app/license/LicenseMessageRendererTest.java` + +- [ ] **Step 15.1: Create the exception** + +```java +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; } +} +``` + +- [ ] **Step 15.2: Write failing test for `LicenseMessageRenderer`** + +```java +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"); + } + + private LicenseInfo info(Instant exp, int graceDays) { + return new LicenseInfo(UUID.randomUUID(), "acme", null, Map.of(), + Instant.now().minusSeconds(86400L * 365), exp, graceDays); + } +} +``` + +- [ ] **Step 15.3: Run — expect failure** + +Run: `mvn -pl cameleer-server-app test -Dtest=LicenseMessageRendererTest` +Expected: FAIL. + +- [ ] **Step 15.4: Implement `LicenseMessageRenderer.java`** + +```java +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; + } + } + + private static long daysSince(Instant t) { + return Math.max(0, Duration.between(t, Instant.now()).toDays()); + } +} +``` + +- [ ] **Step 15.5: Run renderer test** + +Run: `mvn -pl cameleer-server-app test -Dtest=LicenseMessageRendererTest` +Expected: PASS, 5/5. + +- [ ] **Step 15.6: Implement `LicenseExceptionAdvice.java`** + +```java +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> handle(LicenseCapExceededException e) { + var state = gate.getState(); + LicenseInfo info = gate.getCurrent(); + String reason = gate.getInvalidReason(); + Map 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); + } +} +``` + +- [ ] **Step 15.7: Build** + +Run: `mvn -pl cameleer-server-app test -DskipITs` +Expected: PASS. + +- [ ] **Step 15.8: Commit** + +```bash +git add cameleer-server-app/src/main/java/com/cameleer/server/app/license cameleer-server-app/src/test/java/com/cameleer/server/app/license +git commit -m "$(cat <<'EOF' +feat(license): cap-exceeded exception + state-aware message renderer + +LicenseCapExceededException + @ControllerAdvice mapping to 403 with a +body that includes state, limit, current, cap, and a per-state human +message templated by LicenseMessageRenderer (covers ABSENT/ACTIVE/ +GRACE/EXPIRED/INVALID with day counts and reason). + +Co-Authored-By: Claude Opus 4.7 (1M context) +EOF +)" +``` + +--- + +## Task 16: `LicenseEnforcer` + +**Why:** Spec §4 — single entry point for cap checks. + +**Files:** +- Create: `cameleer-server-app/src/main/java/com/cameleer/server/app/license/LicenseEnforcer.java` +- Test: `cameleer-server-app/src/test/java/com/cameleer/server/app/license/LicenseEnforcerTest.java` + +- [ ] **Step 16.1: Write failing test** + +```java +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 limits, int grace) { + return new LicenseInfo(UUID.randomUUID(), "acme", null, + limits, Instant.now(), Instant.now().plusSeconds(86400), grace); + } +} +``` + +- [ ] **Step 16.2: Run — expect failure** + +Run: `mvn -pl cameleer-server-app test -Dtest=LicenseEnforcerTest` +Expected: FAIL. + +- [ ] **Step 16.3: Implement `LicenseEnforcer.java`** + +```java +package com.cameleer.server.app.license; + +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 org.springframework.stereotype.Component; + +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ConcurrentMap; + +@Component +public class LicenseEnforcer { + + private final LicenseGate gate; + private final MeterRegistry meters; + private final ConcurrentMap rejectionCounters = new ConcurrentHashMap<>(); + + public LicenseEnforcer(LicenseGate gate, MeterRegistry meters) { + this.gate = gate; + this.meters = meters; + } + + public LicenseEnforcer(LicenseGate gate) { + this(gate, new io.micrometer.core.instrument.simple.SimpleMeterRegistry()); + } + + public void assertWithinCap(String limitKey, long currentUsage, long requestedDelta) { + LicenseLimits effective = gate.getEffectiveLimits(); + int cap = effective.get(limitKey); // throws IAE if unknown key + if (currentUsage + requestedDelta > cap) { + rejectionCounters.computeIfAbsent(limitKey, k -> Counter.builder("cameleer_license_cap_rejections_total") + .tag("limit", k).register(meters)).increment(); + throw new LicenseCapExceededException(limitKey, currentUsage + requestedDelta, cap); + } + } +} +``` + +- [ ] **Step 16.4: Run tests** + +Run: `mvn -pl cameleer-server-app test -Dtest=LicenseEnforcerTest` +Expected: PASS, 4/4. + +- [ ] **Step 16.5: Commit** + +```bash +git add cameleer-server-app/src/main/java/com/cameleer/server/app/license cameleer-server-app/src/test/java/com/cameleer/server/app/license +git commit -m "$(cat <<'EOF' +feat(license): LicenseEnforcer single entry point + +assertWithinCap consults LicenseGate.getEffectiveLimits, throws +LicenseCapExceededException on overflow, increments +cameleer_license_cap_rejections_total{limit=...} for telemetry. +Unknown limit keys are programmer errors (IllegalArgumentException), +not 403s. + +Co-Authored-By: Claude Opus 4.7 (1M context) +EOF +)" +``` + +--- + +## Task 17: `LicenseUsageReader` + +**Why:** Spec §5 — reads counts/sums for the `/usage` endpoint. + +**Files:** +- Create: `cameleer-server-app/src/main/java/com/cameleer/server/app/license/LicenseUsageReader.java` +- Test: `cameleer-server-app/src/test/java/com/cameleer/server/app/license/LicenseUsageReaderIT.java` + +- [ ] **Step 17.1: Implement `LicenseUsageReader.java`** + +```java +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; + +@Component +public class LicenseUsageReader { + + private final JdbcTemplate jdbc; + + public LicenseUsageReader(JdbcTemplate jdbc) { + this.jdbc = jdbc; + } + + public Map snapshot() { + Map 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")); + // max_agents is in-memory; AgentRegistryService injects via callback below. + // Compute aggregates: sum across non-stopped deployments. Container config is JSONB. + Map 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->>'replicas')::int, 1) AS replicas, " + + " COALESCE((d.deployed_config_snapshot->>'cpuLimit')::int, 0) AS cpu_millis, " + + " COALESCE((d.deployed_config_snapshot->>'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; + } + + public long agentCount(int liveAgents) { + return liveAgents; + } + + private long count(String table) { + return jdbc.queryForObject("SELECT COUNT(*) FROM " + table, Long.class); + } +} +``` + +If `deployed_config_snapshot` does not contain numeric `replicas/cpuLimit/memoryLimitMb` fields in your schema (introduced in V3), confirm with `grep -n deployed_config_snapshot cameleer-server-app/src/main/java`. Adjust the JSON paths to match `DeploymentConfigSnapshot` field names. + +- [ ] **Step 17.2: Write IT (boots Spring + Postgres)** + +```java +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 static org.assertj.core.api.Assertions.assertThat; + +class LicenseUsageReaderIT extends AbstractPostgresIT { + + @Autowired LicenseUsageReader reader; + + @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); + } +} +``` + +- [ ] **Step 17.3: Run IT** + +Run: `mvn -pl cameleer-server-app verify -Dit.test=LicenseUsageReaderIT` +Expected: PASS. + +- [ ] **Step 17.4: Commit** + +```bash +git add cameleer-server-app/src/{main,test}/java/com/cameleer/server/app/license +git commit -m "$(cat <<'EOF' +feat(license): LicenseUsageReader aggregates current usage + +One COUNT per entity table; one SUM-grouped query over non-stopped +deployments for compute caps. agentCount is fed in by the controller +since it's an in-memory registry value, not a DB row. + +Co-Authored-By: Claude Opus 4.7 (1M context) +EOF +)" +``` + +--- + +## Task 18: Wire enforcement — `max_environments` in `EnvironmentService.create` + +**Why:** Spec §4.1. + +**Files:** +- Modify: `cameleer-server-core/src/main/java/com/cameleer/server/core/runtime/EnvironmentService.java` +- Modify: `cameleer-server-app/src/main/java/com/cameleer/server/app/config/RuntimeBeanConfig.java` (inject enforcer) +- Test: `cameleer-server-app/src/test/java/com/cameleer/server/app/license/EnvironmentCapEnforcementIT.java` + +- [ ] **Step 18.1: Run gitnexus impact** + +``` +gitnexus_impact({target: "EnvironmentService", direction: "upstream"}) +gitnexus_impact({target: "create", direction: "upstream"}) -- (filter to EnvironmentService.create in the report) +``` + +- [ ] **Step 18.2: Add a callback hook to `EnvironmentService`** + +`EnvironmentService` lives in `core` (no Spring) — pass the enforcer in via constructor as a functional interface to keep core decoupled: + +```java +@FunctionalInterface +public interface CreateGuard { + void check(long current); // throws if cap exceeded + CreateGuard NOOP = c -> {}; +} +``` + +Place this `CreateGuard` interface in `cameleer-server-core/src/main/java/com/cameleer/server/core/runtime/CreateGuard.java`. + +Modify `EnvironmentService`: + +```java +public class EnvironmentService { + private final EnvironmentRepository repo; + private final CreateGuard createGuard; + + public EnvironmentService(EnvironmentRepository repo) { + this(repo, CreateGuard.NOOP); + } + + public EnvironmentService(EnvironmentRepository repo, CreateGuard createGuard) { + this.repo = repo; + this.createGuard = createGuard; + } + + public UUID create(String slug, String displayName, boolean production) { + createGuard.check(repo.count()); + // ... existing body unchanged + } +} +``` + +If `EnvironmentRepository` lacks `count()`, add `long count();` to the interface and implement it in `PostgresEnvironmentRepository` as `jdbc.queryForObject("SELECT COUNT(*) FROM environments", Long.class)`. + +- [ ] **Step 18.3: Wire in `RuntimeBeanConfig` (or wherever `EnvironmentService` is constructed)** + +Locate the `@Bean public EnvironmentService environmentService(...)` method and replace with: + +```java +@Bean +public EnvironmentService environmentService(EnvironmentRepository repo, + com.cameleer.server.app.license.LicenseEnforcer enforcer) { + return new EnvironmentService(repo, current -> + enforcer.assertWithinCap("max_environments", current, 1)); +} +``` + +- [ ] **Step 18.4: Write failing IT** + +```java +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 org.springframework.http.MediaType; +import org.springframework.test.web.servlet.MockMvc; + +import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.user; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; + +class EnvironmentCapEnforcementIT extends AbstractPostgresIT { + + @Autowired MockMvc mvc; + + @Test + void createsUpToCap_thenReturns403WithStateAndMessage() throws Exception { + // Default tier: max_environments = 1; V1 seeds the default env, so the next create rejects. + mvc.perform(post("/api/v1/admin/environments") + .with(user("admin").roles("ADMIN")) + .contentType(MediaType.APPLICATION_JSON) + .content("{\"slug\":\"prod\",\"displayName\":\"Prod\",\"production\":true}")) + .andExpect(status().isForbidden()) + .andExpect(jsonPath("$.error").value("license cap reached")) + .andExpect(jsonPath("$.limit").value("max_environments")) + .andExpect(jsonPath("$.cap").value(1)) + .andExpect(jsonPath("$.state").value("ABSENT")) + .andExpect(jsonPath("$.message").exists()); + } +} +``` + +- [ ] **Step 18.5: Run — expect failure first, then PASS after implementation** + +Run: `mvn -pl cameleer-server-app verify -Dit.test=EnvironmentCapEnforcementIT` +Expected: PASS once Steps 18.2–18.3 are applied. + +- [ ] **Step 18.6: Commit** + +```bash +git add cameleer-server-core/src cameleer-server-app/src +git commit -m "$(cat <<'EOF' +feat(license): enforce max_environments at EnvironmentService.create + +Adds CreateGuard functional interface to core (preserves no-Spring +boundary), wires LicenseEnforcer into the EnvironmentService bean +in RuntimeBeanConfig. EnvironmentRepository.count() added. + +Co-Authored-By: Claude Opus 4.7 (1M context) +EOF +)" +``` + +--- + +## Task 19: Wire enforcement — `max_apps` in `AppService.createApp` + +**Files:** +- Modify: `cameleer-server-core/src/main/java/com/cameleer/server/core/runtime/AppService.java` +- Modify: `cameleer-server-app/src/main/java/com/cameleer/server/app/config/RuntimeBeanConfig.java` +- Test: `cameleer-server-app/src/test/java/com/cameleer/server/app/license/AppCapEnforcementIT.java` + +- [ ] **Step 19.1: Run gitnexus impact** on `AppService` and `createApp`. + +- [ ] **Step 19.2: Add CreateGuard to AppService** (same pattern as Task 18). Add `long count();` to `AppRepository` if missing. + +- [ ] **Step 19.3: Update `createApp`** + +```java +public UUID createApp(UUID environmentId, String slug, String displayName) { + createGuard.check(repo.count()); + // ... existing body +} +``` + +- [ ] **Step 19.4: Wire bean** + +```java +@Bean +public AppService appService(AppRepository repo, com.cameleer.server.app.license.LicenseEnforcer enforcer) { + return new AppService(repo, current -> enforcer.assertWithinCap("max_apps", current, 1)); +} +``` + +- [ ] **Step 19.5: Write IT** — analogous to 18.4, posting to `/api/v1/environments/default/apps` with body `{"slug":"a1","displayName":"A1"}`. Default cap is 3; loop create three then expect 403 on the fourth. + +- [ ] **Step 19.6: Run + Commit** + +```bash +mvn -pl cameleer-server-app verify -Dit.test=AppCapEnforcementIT +git add cameleer-server-core/src cameleer-server-app/src +git commit -m "feat(license): enforce max_apps at AppService.createApp + +Co-Authored-By: Claude Opus 4.7 (1M context) " +``` + +--- + +## Task 20: Wire enforcement — `max_agents` in `AgentRegistryService.register` + +**Files:** +- Modify: `cameleer-server-core/src/main/java/com/cameleer/server/core/agent/AgentRegistryService.java` +- Modify: bean config that constructs the registry +- Modify: `AgentRegistrationController` to translate the exception to the same 403 format +- Test: `cameleer-server-app/src/test/java/com/cameleer/server/app/license/AgentCapEnforcementIT.java` + +- [ ] **Step 20.1: Run gitnexus impact** on `AgentRegistryService.register`. + +- [ ] **Step 20.2: Inject CreateGuard into `AgentRegistryService`** + +`register(...)` currently inserts into the in-memory map. Insert at the top: + +```java +createGuard.check(liveAgentCount()); +``` + +Where `liveAgentCount()` is `(int) registry.values().stream().filter(a -> a.state() == AgentState.LIVE).count()`. + +- [ ] **Step 20.3: Wire bean** — `enforcer.assertWithinCap("max_agents", current, 1)`. + +- [ ] **Step 20.4: `AgentRegistrationController` exception handling** + +`AgentRegistrationController.register` currently returns the registered `AgentInfo`. Wrap the registry call in try/catch — if `LicenseCapExceededException` escapes, the global `LicenseExceptionAdvice` returns a 403 automatically; just verify behaviour by catching nothing here. + +- [ ] **Step 20.5: Write IT** — load a license with `max_agents=2`, register two agents, expect 403 on third with `state` and `limit=max_agents`. + +- [ ] **Step 20.6: Run + Commit** as in Task 19. + +--- + +## Task 21: Wire enforcement — `max_users` (admin + OIDC auto-signup) + +**Files:** +- Modify: `cameleer-server-app/src/main/java/com/cameleer/server/app/controller/UserAdminController.java` +- Modify: `cameleer-server-app/src/main/java/com/cameleer/server/app/security/OidcAuthController.java` +- Test: `cameleer-server-app/src/test/java/com/cameleer/server/app/license/UserCapEnforcementIT.java` + +- [ ] **Step 21.1: Run gitnexus impact** on `createUser` and `OidcAuthController.callback`. + +- [ ] **Step 21.2: Inject `LicenseEnforcer` and `UserRepository.count()`** + +In `UserAdminController.createUser` (top of method, before any insert): + +```java +enforcer.assertWithinCap("max_users", userRepository.count(), 1); +``` + +In `OidcAuthController.callback` (auto-signup branch, just before creating the new user): + +```java +if (existingUser.isEmpty() && config.autoSignup()) { + enforcer.assertWithinCap("max_users", userRepository.count(), 1); + // ... existing create +} +``` + +If `UserRepository` lacks `count()`, add `long count();` and implement. + +- [ ] **Step 21.3: Write IT** — login `admin` (V1 seeds it), create users until cap is hit, expect 403 with state + message; verify the `cap_exceeded` audit row exists by querying `/api/v1/admin/audit?category=LICENSE&action=cap_exceeded`. + +- [ ] **Step 21.4: Wire `cap_exceeded` audit emission** + +The advice returns 403 but does not audit. To meet spec §6.5, intercept in `LicenseEnforcer`: + +```java +public LicenseEnforcer(LicenseGate gate, MeterRegistry meters, + AuditService audit /* nullable in unit tests */) { + // ... store all +} +``` + +In `assertWithinCap` after computing the rejection but before throwing: + +```java +if (audit != null) { + Map payload = new LinkedHashMap<>(); + payload.put("limit", limitKey); + payload.put("current", currentUsage + requestedDelta); + payload.put("cap", cap); + payload.put("state", gate.getState().name()); + String requester = currentRequester(); // helper that reads SecurityContextHolder + audit.record(AuditCategory.LICENSE, "cap_exceeded", requester, payload); +} +``` + +`currentRequester()` strips the `user:` prefix per the existing convention in `app-classes.md`. + +Add a 3-arg constructor and update existing 1-arg/2-arg constructors and tests accordingly. The 1-arg test constructor should pass `null` for audit. + +- [ ] **Step 21.5: Run + Commit**. + +--- + +## Task 22: Wire enforcement — `max_outbound_connections` in `OutboundConnectionServiceImpl.create` + +**Files:** +- Modify: `cameleer-server-app/src/main/java/com/cameleer/server/app/outbound/OutboundConnectionServiceImpl.java` +- Test: `cameleer-server-app/src/test/java/com/cameleer/server/app/license/OutboundCapEnforcementIT.java` + +- [ ] **Step 22.1: gitnexus impact** on `OutboundConnectionServiceImpl.create`. +- [ ] **Step 22.2: Inject enforcer + repo count**. At top of `create(...)`: + +```java +enforcer.assertWithinCap("max_outbound_connections", repo.listByTenant(tenantId).size(), 1); +``` + +- [ ] **Step 22.3: IT** — default cap=1; create one then expect 403 on second. +- [ ] **Step 22.4: Run + Commit**. + +--- + +## Task 23: Wire enforcement — `max_alert_rules` in `AlertRuleController.create` + +**Files:** +- Modify: `cameleer-server-app/src/main/java/com/cameleer/server/app/alerting/controller/AlertRuleController.java` (path may vary; locate via `gitnexus_context({name: "AlertRuleController"})`) +- Test: `cameleer-server-app/src/test/java/com/cameleer/server/app/license/AlertRuleCapEnforcementIT.java` + +- [ ] **Step 23.1: gitnexus impact** on `AlertRuleController.create`. +- [ ] **Step 23.2: Inject enforcer + `AlertRuleRepository.count()`**. At top of POST handler: + +```java +enforcer.assertWithinCap("max_alert_rules", alertRuleRepository.count(), 1); +``` + +- [ ] **Step 23.3: IT** — default cap=2; create two then expect 403 on third. +- [ ] **Step 23.4: Run + Commit**. + +--- + +## Task 24: Wire enforcement — compute caps in `DeploymentExecutor.PRE_FLIGHT` + +**Why:** Spec §4.1 — sum cpu/memory/replicas across non-stopped deployments + new request must fit within caps. + +**Files:** +- Modify: `cameleer-server-app/src/main/java/com/cameleer/server/app/runtime/DeploymentExecutor.java` +- Test: `cameleer-server-app/src/test/java/com/cameleer/server/app/license/ComputeCapEnforcementIT.java` + +- [ ] **Step 24.1: gitnexus impact** on `DeploymentExecutor` and the `PRE_FLIGHT` stage method. + +- [ ] **Step 24.2: Add a compute aggregator (lives on `LicenseUsageReader` from Task 17)** + +Add to `LicenseUsageReader`: + +```java +public record ComputeUsage(long cpuMillis, long memoryMb, long replicas) {} + +public ComputeUsage computeUsage() { + var 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)); +} +``` + +- [ ] **Step 24.3: In `DeploymentExecutor.preFlight(...)`** + +After resolving config but before any container creation: + +```java +var resolved = config; // ResolvedContainerConfig already computed +int reqCpu = resolved.cpuLimit() == null ? 0 : resolved.cpuLimit(); +int reqMem = resolved.memoryLimitMb() == null ? 0 : resolved.memoryLimitMb(); +int reqReps = resolved.replicas(); + +var usage = usageReader.computeUsage(); +enforcer.assertWithinCap("max_total_cpu_millis", usage.cpuMillis(), (long) reqCpu * reqReps); +enforcer.assertWithinCap("max_total_memory_mb", usage.memoryMb(), (long) reqMem * reqReps); +enforcer.assertWithinCap("max_total_replicas", usage.replicas(), reqReps); +``` + +The exception bubbles, the executor catches, marks deployment FAILED with the validator message, audit row already covered by §6.5 cap_exceeded. + +- [ ] **Step 24.4: IT** — create a deployment with `cpuLimit=3000` while default cap is 2000; expect deploy to land in FAILED state with the failure reason containing `max_total_cpu_millis`. + +- [ ] **Step 24.5: Run + Commit**. + +--- + +## Task 25: Wire enforcement — retention caps + `max_jar_retention_count` + +**Why:** Spec §4.1. + +**Files:** +- Modify: `cameleer-server-core/src/main/java/com/cameleer/server/core/runtime/EnvironmentService.java` (add `update(...)` retention validation) +- Modify: `cameleer-server-app/src/main/java/com/cameleer/server/app/controller/EnvironmentAdminController.java` (PUT /jar-retention) +- Test: `cameleer-server-app/src/test/java/com/cameleer/server/app/license/RetentionCapEnforcementIT.java` + +- [ ] **Step 25.1: Add retention caps to update path** + +In `EnvironmentService.update(...)` (or the Postgres repo's update query), validate before save: + +```java +var enforcer = retentionEnforcer; // injected functional handle +enforcer.checkRetention("max_execution_retention_days", req.executionRetentionDays()); +enforcer.checkRetention("max_log_retention_days", req.logRetentionDays()); +enforcer.checkRetention("max_metric_retention_days", req.metricRetentionDays()); +``` + +`RetentionEnforcer` is a small wrapper: + +```java +@FunctionalInterface +public interface RetentionEnforcer { + void checkRetention(String key, int requested); +} +``` + +In the bean wiring, supply: + +```java +RetentionEnforcer re = (k, v) -> { + int cap = enforcer.gate().getEffectiveLimits().get(k); + if (v > cap) throw new ResponseStatusException(HttpStatus.UNPROCESSABLE_ENTITY, + "retention " + k + "=" + v + " exceeds license cap " + cap); +}; +``` + +(Add `LicenseGate gate()` accessor to `LicenseEnforcer` so the wrapper can read effective limits, OR pass `LicenseGate` directly.) + +- [ ] **Step 25.2: Apply to `EnvironmentAdminController.PUT /jar-retention`** + +```java +int cap = licenseGate.getEffectiveLimits().get("max_jar_retention_count"); +if (request.jarRetentionCount() > cap) { + throw new ResponseStatusException(HttpStatus.UNPROCESSABLE_ENTITY, + "jarRetentionCount " + request.jarRetentionCount() + " exceeds license cap " + cap); +} +``` + +- [ ] **Step 25.3: IT** — PUT a retention of 30 days into the default-tier server (cap=1); expect 422. + +- [ ] **Step 25.4: Run + Commit**. + +--- + +## Task 26: Add `Environment` retention fields + repository changes + +**Why:** Spec §4.2 — store + read the new columns on `environments`. + +**Files:** +- Modify: `cameleer-server-core/src/main/java/com/cameleer/server/core/runtime/Environment.java` +- Modify: `cameleer-server-app/src/main/java/com/cameleer/server/app/storage/PostgresEnvironmentRepository.java` +- Modify: any DTO/controller that returns env fields to the UI (`EnvironmentAdminController`) +- Test: `cameleer-server-app/src/test/java/com/cameleer/server/app/storage/PostgresEnvironmentRepositoryIT.java` (extend with new columns) + +- [ ] **Step 26.1: Update the record** + +Add three `int` fields after `jarRetentionCount`: + +```java +public record Environment( + UUID id, String slug, String displayName, boolean production, + boolean enabled, ContainerConfig defaultContainerConfig, + int jarRetentionCount, String color, Instant createdAt, + int executionRetentionDays, int logRetentionDays, int metricRetentionDays +) { /* validators preserved */ } +``` + +Update all `new Environment(...)` call sites — there will be several. Use `gitnexus_context({name: "Environment"})` to enumerate. + +- [ ] **Step 26.2: Update `PostgresEnvironmentRepository`** — `INSERT`, `UPDATE`, and the `RowMapper` to include the three new columns. + +- [ ] **Step 26.3: Surface on the wire** + +Add the three fields to the `EnvironmentDto` (or whatever the admin controller returns) and the `UpdateEnvironmentRequest`. + +- [ ] **Step 26.4: Run unit + ITs** + +`mvn -pl cameleer-server-app test -DskipITs && mvn -pl cameleer-server-app verify -Dit.test=PostgresEnvironmentRepositoryIT` + +- [ ] **Step 26.5: Commit**. + +--- + +## Task 27: `RetentionPolicyApplier` — runtime ClickHouse TTL recompute + +**Why:** Spec §4.3. + +**Files:** +- Create: `cameleer-server-app/src/main/java/com/cameleer/server/app/license/RetentionPolicyApplier.java` +- Test: `cameleer-server-app/src/test/java/com/cameleer/server/app/license/RetentionPolicyApplierTest.java` + +- [ ] **Step 27.1: Implement applier** + +```java +package com.cameleer.server.app.license; + +import com.cameleer.server.core.license.LicenseGate; +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.Map; + +@Component +public class RetentionPolicyApplier { + + private static final Logger log = LoggerFactory.getLogger(RetentionPolicyApplier.class); + + private static final Map TABLE_TO_LIMIT = Map.of( + "executions", "max_execution_retention_days", + "processors", "max_execution_retention_days", + "logs", "max_log_retention_days", + "metrics", "max_metric_retention_days", + "agent_events", "max_metric_retention_days", + "route_diagrams", "max_metric_retention_days" + ); + + 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 ignored) { + var limits = gate.getEffectiveLimits(); + for (Environment env : envRepo.findAll()) { + apply(env, limits.get("max_execution_retention_days"), env.executionRetentionDays(), "executions"); + apply(env, limits.get("max_execution_retention_days"), env.executionRetentionDays(), "processors"); + apply(env, limits.get("max_log_retention_days"), env.logRetentionDays(), "logs"); + apply(env, limits.get("max_metric_retention_days"), env.metricRetentionDays(), "metrics"); + apply(env, limits.get("max_metric_retention_days"), env.metricRetentionDays(), "agent_events"); + apply(env, limits.get("max_metric_retention_days"), env.metricRetentionDays(), "route_diagrams"); + } + } + + private void apply(Environment env, int cap, int configured, String table) { + int effective = Math.min(cap, configured); + // ClickHouse partition is by (tenant_id, toYYYYMM(timestamp)); per-env TTL is enforced via TTL clause that reads environment column. + // We use a single ALTER TABLE per call; ClickHouse will only re-evaluate on its next merge. + String sql = "ALTER TABLE " + table + " MODIFY TTL timestamp + INTERVAL " + effective + " DAY WHERE environment = '" + env.slug().replace("'", "''") + "'"; + try { + clickhouseJdbc.execute(sql); + log.info("Applied TTL: table={} env={} days={}", table, env.slug(), effective); + } catch (Exception e) { + log.warn("Failed to apply TTL for {}/{}: {}", table, env.slug(), e.getMessage()); + } + } +} +``` + +NB: Per-env TTL via `WHERE environment = '...'` requires ClickHouse 22.x+. If the project's CH does not support per-env TTL, fall back to a global TTL = `min(licenseCap, max(env.configured))`. Verify against `init.sql`. + +- [ ] **Step 27.2: Unit test (mock JdbcTemplate, EnvRepository)** + +```java +package com.cameleer.server.app.license; + +import com.cameleer.server.core.license.*; +import com.cameleer.server.core.runtime.Environment; +import com.cameleer.server.core.runtime.EnvironmentRepository; +import org.junit.jupiter.api.Test; +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.mockito.ArgumentMatchers.contains; +import static org.mockito.Mockito.*; + +class RetentionPolicyApplierTest { + + @Test + void onChange_emitsAlterPerTablePerEnv() { + LicenseGate gate = new LicenseGate(); + EnvironmentRepository envRepo = mock(EnvironmentRepository.class); + JdbcTemplate ch = mock(JdbcTemplate.class); + when(envRepo.findAll()).thenReturn(List.of( + new Environment(UUID.randomUUID(), "default", "Default", false, true, null, + 3, "slate", Instant.now(), 7, 7, 14) + )); + new RetentionPolicyApplier(gate, envRepo, ch) + .onLicenseChanged(new LicenseChangedEvent(LicenseState.ABSENT, null)); + + verify(ch, atLeast(1)).execute(contains("ALTER TABLE")); + } + + @Test + void chFailure_doesNotPropagate() { + LicenseGate gate = new LicenseGate(); + EnvironmentRepository envRepo = mock(EnvironmentRepository.class); + JdbcTemplate ch = mock(JdbcTemplate.class); + doThrow(new RuntimeException("ch down")).when(ch).execute(anyString()); + when(envRepo.findAll()).thenReturn(List.of( + new Environment(UUID.randomUUID(), "default", "Default", false, true, null, + 3, "slate", Instant.now(), 7, 7, 14) + )); + new RetentionPolicyApplier(gate, envRepo, ch) + .onLicenseChanged(new LicenseChangedEvent(LicenseState.ABSENT, null)); + // no exception + } +} +``` + +- [ ] **Step 27.3: Run + Commit**. + +--- + +## Task 28: `LicenseRevalidationJob` + +**Why:** Spec §6.6. + +**Files:** +- Create: `cameleer-server-app/src/main/java/com/cameleer/server/app/license/LicenseRevalidationJob.java` +- Test: `cameleer-server-app/src/test/java/com/cameleer/server/app/license/LicenseRevalidationJobTest.java` + +- [ ] **Step 28.1: Implement** + +```java +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.Scheduled; +import org.springframework.stereotype.Component; + +@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) + 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()); + } + } +} +``` + +Ensure `@EnableScheduling` is present in the Spring config (likely in `Application.java` or a common config). + +- [ ] **Step 28.2: Unit test** + +```java +package com.cameleer.server.app.license; + +import org.junit.jupiter.api.Test; + +import static org.mockito.Mockito.*; + +class LicenseRevalidationJobTest { + + @Test + void daily_callsService() { + LicenseService svc = mock(LicenseService.class); + new LicenseRevalidationJob(svc).daily(); + verify(svc).revalidate(); + } +} +``` + +- [ ] **Step 28.3: Run + Commit**. + +--- + +## Task 29: Extend `LicenseAdminController` to delegate to `LicenseService` and return state + +**Files:** +- Modify: `cameleer-server-app/src/main/java/com/cameleer/server/app/controller/LicenseAdminController.java` + +- [ ] **Step 29.1: gitnexus impact** on `LicenseAdminController`. + +- [ ] **Step 29.2: Replace controller** + +```java +@RestController +@RequestMapping("/api/v1/admin/license") +@PreAuthorize("hasRole('ADMIN')") +@Tag(name = "License Admin", description = "License management") +public class LicenseAdminController { + + private final LicenseService licenseService; + private final LicenseGate gate; + private final LicenseRepository repo; + + public LicenseAdminController(LicenseService svc, LicenseGate gate, LicenseRepository repo) { + this.licenseService = svc; + this.gate = gate; + this.repo = repo; + } + + @GetMapping + public ResponseEntity> getCurrent() { + Map 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) {} + + @PostMapping + public ResponseEntity update(@RequestBody UpdateLicenseRequest request, Authentication auth) { + String userId = auth == null ? "system" : auth.getName().replaceFirst("^user:", ""); + try { + var 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())); + } + } +} +``` + +- [ ] **Step 29.3: Run + Commit**. + +--- + +## Task 30: `LicenseUsageController` + +**Files:** +- Create: `cameleer-server-app/src/main/java/com/cameleer/server/app/controller/LicenseUsageController.java` +- Test: `cameleer-server-app/src/test/java/com/cameleer/server/app/controller/LicenseUsageControllerIT.java` + +- [ ] **Step 30.1: Implement** + +```java +@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; // for live agent count + 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> get() { + var state = gate.getState(); + var info = gate.getCurrent(); + var effective = gate.getEffectiveLimits(); + var usage = new java.util.HashMap<>(reader.snapshot()); + usage.put("max_agents", (long) agents.liveCount()); + + List> limitRows = new java.util.ArrayList<>(); + for (var key : effective.values().keySet()) { + Map 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 body = new LinkedHashMap<>(); + body.put("state", state.name()); + body.put("expiresAt", info == null ? null : info.expiresAt().toString()); + body.put("daysRemaining", info == null ? null : + java.time.Duration.between(java.time.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); + } +} +``` + +Add a sibling `LicenseMessageRenderer.forState(...)` (parallel to `forCap`) emitting the §5 templates (ABSENT/ACTIVE/GRACE/EXPIRED/INVALID). + +Add a `liveCount()` method to `AgentRegistryService` if not already exposed. + +- [ ] **Step 30.2: Write IT** — assert response shape includes `state`, `limits[]` with `key/current/cap/source`, `message`, `lastValidatedAt`. + +- [ ] **Step 30.3: Run + Commit**. + +--- + +## Task 31: `LicenseMetrics` + +**Files:** +- Create: `cameleer-server-app/src/main/java/com/cameleer/server/app/license/LicenseMetrics.java` + +- [ ] **Step 31.1: Implement** + +```java +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.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; + +@Component +public class LicenseMetrics { + + private final LicenseGate gate; + private final LicenseUsageReader usage; + private final LicenseRepository repo; + private final String tenantId; + + private final Map> stateGauges = new EnumMap<>(LicenseState.class); + private final AtomicReference daysRemaining = new AtomicReference<>(0.0); + private final AtomicReference validatedAge = new AtomicReference<>(0.0); + + public LicenseMetrics(LicenseGate gate, LicenseUsageReader usage, + LicenseRepository repo, MeterRegistry meters, + @org.springframework.beans.factory.annotation.Value("${cameleer.server.tenant.id:default}") String tenantId) { + this.gate = gate; this.usage = usage; 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); + // Per-limit utilisation registered lazily on first compute via tag-aware Gauge.builder + a ConcurrentMap. + } + + @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())); + } +} +``` + +(Per-limit `cameleer_license_limit_utilisation` is omitted from this skeleton to keep the file short — extend with a `Map>` and register one gauge per key on first refresh.) + +- [ ] **Step 31.2: Run + Commit**. + +--- + +## Task 32: Integration test — `LicenseLifecycleIT` + +**Why:** Spec §10 — install via env / replace via POST / restart restores from DB / public-key removal → INVALID / daily revalidation updates timestamp. + +**Files:** +- Create: `cameleer-server-app/src/test/java/com/cameleer/server/app/license/LicenseLifecycleIT.java` + +- [ ] **Step 32.1: Skeleton** + +```java +class LicenseLifecycleIT extends AbstractPostgresIT { + + @Autowired MockMvc mvc; + @Autowired LicenseService svc; + @Autowired LicenseGate gate; + @Autowired LicenseRepository repo; + + @Test + void install_persists_andSurvivesGateClear() throws Exception { + // Mint a token via LicenseMinter (test pulls in cameleer-license-minter dep — add as test scope) + // POST to /api/v1/admin/license; assert state=ACTIVE; clear gate; reload via svc.loadInitial; assert state=ACTIVE again from DB. + } + + @Test + void postWithBadSignature_marksInvalid() { + // POST a tampered token; expect 400; assert gate.getState() == INVALID; assert audit row exists. + } + + @Test + void revalidateAfterPublicKeyChange_marksInvalid() { + // Install valid token; rebuild validator with a different public key (or simulate by direct gate.markInvalid via svc.revalidate after corrupting DB token). + } +} +``` + +Flesh out each test fully. Add `cameleer-license-minter` as a `test` dependency on `cameleer-server-app/pom.xml` for this IT (acceptable: minter on test classpath only does not leak it into the runtime jar). + +- [ ] **Step 32.2: Run + Commit**. + +--- + +## Task 33: Integration test — `LicenseEnforcementIT` + +**Why:** Spec §10 — REST-driven, hits each cap end-to-end + verifies `cap_exceeded` audit + 403 message. + +**Files:** +- Create: `cameleer-server-app/src/test/java/com/cameleer/server/app/license/LicenseEnforcementIT.java` + +- [ ] **Step 33.1: One @Nested per limit** — install a license raising the cap, drive the REST endpoint until full, assert 403 body shape, query audit endpoint to confirm `cap_exceeded` row. + +- [ ] **Step 33.2: Run + Commit**. + +--- + +## Task 34: Integration test — `RetentionRuntimeRecomputeIT` + +**Why:** Spec §4.3. + +**Files:** +- Create: `cameleer-server-app/src/test/java/com/cameleer/server/app/license/RetentionRuntimeRecomputeIT.java` + +- [ ] **Step 34.1: Steps** + +1. Install license with `max_log_retention_days=30`. +2. Read TTL via `SELECT engine_full FROM system.tables WHERE name='logs'` on the test ClickHouse Testcontainer; assert `INTERVAL 30 DAY`. +3. Replace license with `max_log_retention_days=7`. +4. Re-read TTL; assert `INTERVAL 7 DAY` (allow up to 5s for the @Async event listener; poll). + +- [ ] **Step 34.2: Run + Commit**. + +--- + +## Task 35: Extend `SchemaBootstrapIT` + regenerate OpenAPI types + +**Files:** +- Modify: `cameleer-server-app/src/test/java/com/cameleer/server/app/alerting/storage/SchemaBootstrapIT.java` +- Regen: `ui/src/api/openapi.json`, `ui/src/api/schema.d.ts` + +- [ ] **Step 35.1: Extend `SchemaBootstrapIT`** + +Add asserts: `license` table exists with all columns including `last_validated_at`; `environments` has 3 retention columns. + +- [ ] **Step 35.2: Regenerate OpenAPI** + +Per `CLAUDE.md`: + +```bash +mvn -pl cameleer-server-app spring-boot:run & # background +sleep 30 +cd ui && npm run generate-api:live +kill %1 +``` + +(Or use the project's standard procedure if different.) + +Fix any TypeScript compile errors in the SPA call sites for the modified `/admin/license` GET response shape and the new `/admin/license/usage` endpoint. + +- [ ] **Step 35.3: Commit** — separate commit for backend, separate for SPA. + +--- + +## Task 36: Update `.claude/rules/` + +**Why:** Per `CLAUDE.md` — class/API map must stay in sync with the code. + +**Files:** +- Modify: `.claude/rules/core-classes.md` +- Modify: `.claude/rules/app-classes.md` +- Modify: `.claude/rules/gitnexus.md` (only if symbol counts change after `npx gitnexus analyze`) + +- [ ] **Step 36.1: Append to `.claude/rules/core-classes.md`** under `## license/` + +Document: `LicenseInfo` (new shape), `LicenseLimits`, `LicenseValidator(publicKey, expectedTenantId)`, `LicenseGate.{getState,getEffectiveLimits,markInvalid,clear}`, `LicenseStateMachine.classify`, `LicenseState`, `DefaultTierLimits.DEFAULTS`, `Environment` (new retention fields), `CreateGuard`. + +- [ ] **Step 36.2: Append to `.claude/rules/app-classes.md`** + +Document: `LicenseService`, `LicenseRepository`/`PostgresLicenseRepository`/`LicenseRecord`, `LicenseEnforcer.assertWithinCap`, `LicenseUsageReader.{snapshot, computeUsage, agentCount}`, `LicenseCapExceededException`, `LicenseExceptionAdvice` (403 mapping), `LicenseMessageRenderer`, `LicenseRevalidationJob`, `RetentionPolicyApplier`, `LicenseMetrics`, `LicenseUsageController` (`GET /api/v1/admin/license/usage`), revised `LicenseAdminController`, `AuditCategory.LICENSE`. + +Add `/api/v1/admin/license/usage` to the **Admin (cross-env, flat)** sub-section. + +- [ ] **Step 36.3: Run gitnexus analyze** + +```bash +npx gitnexus analyze --embeddings +``` + +Update `.claude/rules/gitnexus.md` with the new symbol/relationship counts shown by the tool. + +- [ ] **Step 36.4: Commit** + +```bash +git add .claude/rules +git commit -m "$(cat <<'EOF' +docs(rules): document license enforcement classes + endpoints + +Co-Authored-By: Claude Opus 4.7 (1M context) +EOF +)" +``` + +--- + +## Self-review checklist (run after Task 36 completes) + +- [ ] All 13 spec limit keys have an enforcement task and an IT assertion. +- [ ] `LicenseChangedEvent` is published from boot, install, and revalidation paths. +- [ ] `RetentionPolicyApplier` is invoked on every change (boot + replace + revalidation). +- [ ] `cap_exceeded` audit row carries `state`. +- [ ] 403 body contains `state` + `message`. +- [ ] OpenAPI types regenerated; no stale TS compile errors. +- [ ] `.claude/rules/*.md` updated in the same series of commits. +- [ ] Default-tier server cannot create a 2nd environment, 4th app, 6th agent, etc., per §3.2. +- [ ] `cameleer-server-app` does NOT compile-depend on `cameleer-license-minter` (verify `mvn -pl cameleer-server-app dependency:tree | grep license-minter` → empty). + + diff --git a/docs/superpowers/plans/2026-04-26-license-enforcement-handoff.md b/docs/superpowers/plans/2026-04-26-license-enforcement-handoff.md new file mode 100644 index 00000000..4f04d961 --- /dev/null +++ b/docs/superpowers/plans/2026-04-26-license-enforcement-handoff.md @@ -0,0 +1,171 @@ +# License Enforcement — Session Handoff (2026-04-26) + +> Pick up at **Task 15** in `docs/superpowers/plans/2026-04-25-license-enforcement.md`. + +## Where we are + +- **Branch:** `feature/runtime-hardening` (the three doc commits + 14 implementation commits stack on top of unrelated runtime-hardening work — extract to a clean branch later). +- **Last implementation commit:** `b95e80a2` — `feat(license): wire LicenseService into boot order (env > file > DB)`. +- **Tasks 1–14 of 36 are complete and committed.** All tests green. +- **Plan file:** `docs/superpowers/plans/2026-04-25-license-enforcement.md` — 36 tasks, ~4083 lines. +- **Spec file:** `docs/superpowers/specs/2026-04-25-license-enforcement-design.md`. + +## Resume command for the next session + +``` +Resume the License Enforcement implementation at Task 15. +Plan: docs/superpowers/plans/2026-04-25-license-enforcement.md +Handoff: docs/superpowers/plans/2026-04-26-license-enforcement-handoff.md +Last commit: b95e80a2. 14 of 36 tasks done. Branch: feature/runtime-hardening. +Use Subagent-Driven Development. Continue batching where it makes sense +(see "Batching strategy" below). +``` + +## Done, with commit SHAs + +| # | SHA | Subject | +|---|---|---| +| 1 | `551a7f12` | `refactor(license): remove dead Feature enum and isEnabled scaffolding` | +| 2 | `2ebe4989` | `feat(license): expand LicenseInfo with licenseId, tenantId, grace period` | +| 3 | `cf84d80d` | `feat(license): require licenseId + tenantId in validator` | +| 4 | `ddc0b686` | `feat(license): add LicenseLimits, DefaultTierLimits, LicenseStateMachine` | +| 5 | `0499a54e` | `feat(license): rewrite LicenseGate around state + effective limits` | +| 6 | `896b7e6e` | `feat(license-minter): add cameleer-license-minter Maven module` | +| 7 | `1ae5a1a2` | `feat(license-minter): implement LicenseMinter library` | +| 8 | `7300424a` | `feat(license-minter): add LicenseMinterCli (without --verify)` | +| 9 | `f6657f81` | `feat(license-minter): --verify round-trips before shipping` | +| 10 | `20aefd5b` | `feat(license): Flyway V5 — license table + environments retention columns` | +| 11 | `2e51deb5` | `feat(license): PostgresLicenseRepository + LicenseRecord` | +| 12 | `2f75b286` | `feat(license): add AuditCategory.LICENSE` | +| 13 | `6fbcf10e` | `feat(license): LicenseService + LicenseChangedEvent` | +| 14 | `b95e80a2` | `feat(license): wire LicenseService into boot order (env > file > DB)` | + +Plus three doc commits before T1 (already on branch): `0e512a3c` (spec), `e0be6a06` (spec revision), `ec51aef8` (plan). + +## What works end-to-end now + +- A signed license token can be installed via env var, file, admin REST POST, or auto-loaded from PG on boot. +- Validator rejects missing/blank `licenseId`/`tenantId`, tenant mismatch, missing `exp`, expired-past-grace, and signature failure. +- `LicenseService` mediates install/replace/revalidate, audits under `AuditCategory.LICENSE`, persists to PG, mutates `LicenseGate`, publishes `LicenseChangedEvent`. +- `LicenseGate.getState()` returns `ABSENT|ACTIVE|GRACE|EXPIRED|INVALID`; `getEffectiveLimits()` merges over defaults in ACTIVE/GRACE, defaults-only otherwise. +- Standalone `cameleer-license-minter` module produces tokens via `LicenseMinter.mint(info, privateKey)` or the `LicenseMinterCli` (with `--verify` round-trip). Confirmed NOT in the runtime dependency tree. +- PG schema: `license` table + 3 retention columns on `environments` migrated cleanly via Flyway V5. + +## What does NOT yet work + +- **No enforcement.** Nothing checks `LicenseGate.getEffectiveLimits()` yet. The default-tier caps exist as constants but no service calls `assertWithinCap`. +- **No usage reporting.** `/api/v1/admin/license/usage` does not exist. +- **No retention recompute.** `LicenseChangedEvent` is published but no listener acts on it. +- **No daily revalidation.** `LicenseService.revalidate()` exists but no scheduler calls it. +- **No metrics.** No Prometheus gauges yet. +- **`LicenseAdminController` still bypasses `LicenseService`** — `update(...)` constructs its own `LicenseValidator` inline. Task 29 fixes this. + +## Critical deviations from the plan to remember + +### 1. `AuditService` API (impacts Tasks 15, 21, 28, 29) + +The plan assumed `AuditService.record(category, action, actor, payload)`. The actual API is a concrete class with two `log(...)` overloads: + +```java +void log(String action, AuditCategory category, String target, + Map detail, AuditResult result, HttpServletRequest request) + +void log(String username, String action, AuditCategory category, String target, + Map detail, AuditResult result, HttpServletRequest request) +``` + +`LicenseService` (Task 13) uses the **explicit-username variant** with `request = null`. Use the same pattern in any new `AuditService` call sites in Tasks 15+. + +### 2. `LicenseInfo.label` JSON ingest (impacts any new fixture) + +The validator parses `label` from the JSON `label` field, not `tier`. Old fixtures that only set `"tier":"X"` produce `info.label() == null`. When writing new tests that need a non-null label, add `"label":"X"` to the JSON. + +### 3. Plan said "no change needed" for `LicenseValidatorTest` in Task 2 — wrong + +`info.tier()` accessor doesn't exist; it's `info.label()`. T2 implementer fixed this. If any later task asks you to construct `LicenseInfo` and assert on a "tier", use `label`. + +### 4. `LicenseValidator` always-failing fallback (Task 14) + +When `CAMELEER_SERVER_LICENSE_PUBLICKEY` is unset, `LicenseBeanConfig.licenseValidator()` constructs an anonymous subclass whose `validate()` always throws. The parent ctor is fed a freshly-generated **throwaway** Ed25519 public key so it accepts the input. Don't try to use a static all-zero base64 string — Ed25519 rejects the all-zero point at construction time. + +### 5. ClickHouse `ALTER TABLE … MODIFY TTL … WHERE` — Task 27 caveat + +The plan's `RetentionPolicyApplier` SQL (`ALTER TABLE … MODIFY TTL … WHERE environment = '…'`) requires ClickHouse 22.x+ for per-row TTL with WHERE. Verify against `cameleer-server-app/src/main/resources/clickhouse/init.sql` before implementing. If unsupported, fall back to global TTL = `min(licenseCap, max(env.configured))`. + +### 6. Per-test compile dependency between modules + +After modifying `cameleer-server-core`, the next `mvn -pl cameleer-server-app test` will fail compilation if you don't first run `mvn -pl cameleer-server-core install -DskipTests`. The implementer subagents already know this — make sure new ones do too. + +### 7. The previous code-review noted (still deferrable) + +- `LicenseValidator.label` is read via `root.get("label").asText()` which returns the literal string `"null"` for a JSON `null` value rather than Java `null`. Current fixtures don't trip this; defer. +- `LicenseValidator.validate(...)` is ~70 lines doing format-split + signature-verify + payload-parse. Splitting into helpers would help maintainability but no behavioral change required. + +## Batching strategy that worked + +The plan specifies one task = one commit. For mechanical plan-following tasks (T1, T7, T8, T9, T10, T12, T28, T31, T36) one implementer dispatch handles one or more tasks fine. Successful batches in this session: + +- T4 + T5 — both touch `cameleer-server-core/src/main/java/com/cameleer/server/core/license/`, T5 needs T4's types. +- T6 + T7 + T8 + T9 — all in the new `cameleer-license-minter` module, sequential. +- T10 + T11 + T12 — persistence layer (migration + repo + audit enum). + +Tasks NOT to batch (require judgment + per-task review): +- **T18-T25 wiring tasks** — each touches existing service code with potential downstream test fallout. Dispatch one at a time. +- **T26** (Environment record fields) — touches every `new Environment(...)` call site; high blast radius. +- **T27** (RetentionPolicyApplier) — ClickHouse SQL judgment + Async event handling. +- **T32, T33, T34** — integration tests, each large and independent. One at a time, allow full `mvn verify` runs. + +## Pre-existing working-tree dirt — leave alone + +These predate this work: +``` +M AGENTS.md +M CLAUDE.md +?? docs/superpowers/plans/2026-04-24-cmdk-attribute-filter.md +?? execution-api-response.json +?? overlay-screenshot.png +``` + +Subagents were instructed not to stage them. Continue that policy. + +## Suggested next-session task order + +1. **T15** — `LicenseCapExceededException` + `LicenseMessageRenderer` + `LicenseExceptionAdvice`. Pure new code, mechanical. Single commit. Use `LicenseMessageRenderer.forCap(...)` AND `forState(...)` (the latter is needed for the `/usage` endpoint in T30 — add it now even though the plan only mentions `forCap`). +2. **T16** — `LicenseEnforcer`. Single new class, depends on `LicenseGate.getEffectiveLimits()`. Add the 3-arg constructor with `AuditService` for `cap_exceeded` audit emission (used by Task 21). +3. **T17** — `LicenseUsageReader`. Single new class. Verify the `deployments.deployed_config_snapshot` JSONB shape against `DeploymentConfigSnapshot` before committing — the plan SQL assumes specific field names. +4. **T18-T20** — wire envs / apps / agents (one at a time, full IT each). +5. **T21** — wire users + cap_exceeded audit emission (this is when the enforcer's audit ctor matters). +6. **T22-T23** — wire outbound + alert rules (one at a time). +7. **T24** — wire compute caps (DeploymentExecutor PRE_FLIGHT). Largest single wiring task — the IT will need a real Docker flow stub. +8. **T25** — wire retention + jar caps. +9. **T26** — Environment record retention fields. High blast radius. Use gitnexus_context first. +10. **T27** — RetentionPolicyApplier (handle ClickHouse caveat). +11. **T28** — LicenseRevalidationJob. +12. **T29-T31** — REST surface + metrics. +13. **T32-T34** — integration tests. +14. **T35** — SchemaBootstrapIT extension + OpenAPI regen. +15. **T36** — `.claude/rules/*.md` updates. + +## Key files reference + +- Plan: `docs/superpowers/plans/2026-04-25-license-enforcement.md` +- Spec: `docs/superpowers/specs/2026-04-25-license-enforcement-design.md` +- Domain: `cameleer-server-core/src/main/java/com/cameleer/server/core/license/` +- App license layer: `cameleer-server-app/src/main/java/com/cameleer/server/app/license/` +- Boot config: `cameleer-server-app/src/main/java/com/cameleer/server/app/config/LicenseBeanConfig.java` +- PG migration: `cameleer-server-app/src/main/resources/db/migration/V5__license_table_and_environment_retention.sql` +- Minter: `cameleer-license-minter/` +- AuditService: `cameleer-server-core/src/main/java/com/cameleer/server/core/admin/AuditService.java` + +## Verification commands at handoff + +```bash +git log --oneline b95e80a2 -1 # confirm last commit +git log --oneline ec51aef8..b95e80a2 --no-merges # 14 commits in range +mvn -pl cameleer-server-core install -DskipTests # publish core to local repo +mvn -pl cameleer-server-core test # 122 tests, all pass +mvn -pl cameleer-license-minter test # 7 tests, all pass +mvn -pl cameleer-server-app test -DskipITs # 230 tests, all pass +mvn -pl cameleer-server-app verify -Dit.test=PostgresLicenseRepositoryIT,SchemaBootstrapIT +mvn dependency:tree -pl cameleer-server-app | grep license-minter # MUST be empty +``` diff --git a/docs/superpowers/specs/2026-04-25-license-enforcement-design.md b/docs/superpowers/specs/2026-04-25-license-enforcement-design.md new file mode 100644 index 00000000..5c8e1d4e --- /dev/null +++ b/docs/superpowers/specs/2026-04-25-license-enforcement-design.md @@ -0,0 +1,569 @@ +# License Enforcement — Design + +**Date:** 2026-04-25 +**Status:** Approved (brainstorm); pending writing-plans +**Related:** cameleer-saas#7 (Epic: License & Feature Gating), cameleer-saas#42 (vendor minting), cameleer-saas#50 (customer license view) + +## Problem + +`cameleer-server` ships a license skeleton (`LicenseValidator`, `LicenseGate`, admin endpoint) but +nothing enforces anything. Open mode (no license configured) currently grants *all* features and +*no* limits — the opposite of what we want for a self-hosted distribution that needs to gate scale +behind a paid license. + +We want: + +1. A self-hosted server with **no license** to operate within a small, hard-coded "default tier" + that is enough to evaluate the product but not enough to run it in production. +2. Licenses to express **arbitrary per-customer limits** (no fixed tiers) on a vendor-defined set + of resources: entity counts, compute footprint, retention. +3. A **standalone minter** owned by the vendor that signs licenses with an Ed25519 private key the + customer never sees. +4. Licenses to be **persisted** on the server, **installable** via env var, file, or admin POST, + and **renewable** by replacement. +5. **Revocation** handled out of band (vendor suspends the SaaS tenant, or issues short-`exp` + licenses) — no online revocation callback in v1. + +## Non-goals + +- Feature flags. The current `Feature` enum (topology/lineage/correlation/debugger/replay) is dead + scaffolding and gets removed; this design is about quantitative limits only. +- Ingestion-rate limits (executions/minute, logs/minute). Defer to a follow-up. +- Online revocation. Vendor uses shorter `exp` + reissue; SaaS suspension is independent. +- Auto-deletion of resources when caps are lowered. Existing rows stay; only new creates reject. +- Minter keypair generation tooling. Vendor uses standard `openssl genpkey -algorithm ed25519` + out of band. + +--- + +## 1. Architecture + +### 1.1 Module layout + +``` +cameleer-server-core/ (existing — pure domain, no Spring) +└── license/ + ├── LicenseInfo (record — see §2) + ├── LicenseLimits (typed wrapper over the limits map) + ├── LicenseValidator (existing, payload schema updated) + ├── LicenseGate (existing, gutted: no Feature; getLimits() only) + ├── LicenseStateMachine (NEW — pure FSM: ABSENT / ACTIVE / GRACE / EXPIRED / INVALID) + └── DefaultTierLimits (constant — §3.2 numbers) + +cameleer-server-app/ (existing — Spring, web, persistence) +├── license/ +│ ├── LicenseRepository (NEW — PostgreSQL persistence) +│ ├── LicenseService (NEW — load/save/replace; publishes LicenseChangedEvent) +│ ├── LicenseEnforcer (NEW — assertWithinCap entry point) +│ ├── LicenseUsageReader (NEW — counts current usage for /usage endpoint) +│ ├── LicenseCapExceededException (NEW — mapped to 403 by ControllerAdvice) +│ ├── LicenseRevalidationJob (NEW — @Scheduled daily; updates last_validated_at) +│ ├── RetentionPolicyApplier (NEW — @EventListener(LicenseChangedEvent); recomputes ClickHouse TTL + per-env caps) +│ └── LicenseMetrics (NEW — Prometheus gauges) +├── controller/ +│ ├── LicenseAdminController (existing — extended; persists, audited) +│ └── LicenseUsageController (NEW — GET /admin/license/usage) +└── config/ + └── LicenseBeanConfig (existing — extended for DB load order) + +cameleer-license-minter/ (NEW — top-level Maven module) +├── pom.xml (depends on cameleer-server-core) +├── LicenseMinter (signing primitive; takes private key + LicenseInfo) +└── cli/LicenseMinterCli (CLI main class, supports --verify) +``` + +### 1.2 Why a separate `cameleer-license-minter` module + +Not shipped in the runtime JAR. Vendor distributes it independently or builds it from source on a +trusted machine. Customers never receive it. + +This is module hygiene + smaller runtime attack surface, not a cryptographic protection — license +forgery requires the vendor's private key, and the public key in the server is enough to verify +forged tokens regardless of where the minter code lives. + +### 1.3 Dependency graph + +``` +cameleer-license-minter ──▶ cameleer-server-core (LicenseInfo schema only) +cameleer-server-app ──▶ cameleer-server-core (validator, gate, FSM, defaults) +cameleer-saas ──▶ cameleer-license-minter (for SaaS-mode minting) +cameleer-saas ──▶ cameleer-server-core (transitive) +``` + +`cameleer-server-app` has **no** dependency on `cameleer-license-minter`. + +--- + +## 2. License envelope + +Wire format unchanged: `base64(payload).base64(ed25519_signature)`. Payload schema: + +```json +{ + "licenseId": "550e8400-e29b-41d4-a716-446655440000", + "tenantId": "acme-corp", + "label": "ACME prod 2026 — site:hamburg", + "iat": 1745539200, + "exp": 1777075200, + "gracePeriodDays": 30, + "limits": { + "max_environments": 5, + "max_apps": 50, + "max_agents": 100, + "max_users": 25, + "max_outbound_connections": 10, + "max_alert_rules": 200, + "max_total_cpu_millis": 32000, + "max_total_memory_mb": 65536, + "max_total_replicas": 100, + "max_execution_retention_days": 90, + "max_log_retention_days": 30, + "max_metric_retention_days": 365, + "max_jar_retention_count": 10 + } +} +``` + +### 2.1 Field rules + +| Field | Required | Notes | +|---|---|---| +| `licenseId` | yes | UUID. Used in audit + future revocation. | +| `tenantId` | **yes** | Must match `CAMELEER_SERVER_TENANT_ID`. Mismatch = `INVALID` state (see §3). The field is inside the signed payload, so a self-hosted customer cannot strip it to make a license portable across tenants — any edit invalidates the signature. Air-gapped customers receive a license bound to a vendor-issued tenant id (not necessarily a UUID — any non-empty slug). | +| `label` | optional | Free-form human description. Surfaced in UI. | +| `iat` | yes | Unix seconds. | +| `exp` | yes | Unix seconds. | +| `gracePeriodDays` | optional, default `0` | Days `exp` may be in the past while limits still apply. | +| `limits.*` | each optional | Missing key inherits from `DefaultTierLimits`. A license can lift any subset. | + +### 2.2 Removed from the current envelope + +- `tier` (string) — was a non-functional label. Folded into `label`. +- `features` (array) — out of scope. `Feature` enum deleted. + +--- + +## 3. License state machine + +``` + exp + grace passes + ┌─────────┐ install valid ┌────────┐ exp ┌────────┐ ────────► ┌─────────┐ + │ ABSENT │ ───────────────▶│ ACTIVE │──────▶│ GRACE │ │ EXPIRED │ + └─────────┘ └────────┘ └────────┘ └─────────┘ + ▲ │ │ ▲ │ + │ │ replace │ │ replace valid │ replace + │ ▼ │ │ ▼ + │ ┌─────────┐ └──────────────┴─┴───────────────────┘ + └──┤ INVALID │ ──── replace valid ────────────────────────────────▶ ACTIVE + └─────────┘ + ▲ + │ install fails (signature / tenant / parse / public-key-missing) + all transitions persist + audit-log +``` + +### 3.1 State semantics + +| State | Effective limits | Trigger | Severity | +|--- |--- |--- |--- | +| `ABSENT` | `DefaultTierLimits` | No DB row. Clean install with no license configured. | INFO | +| `ACTIVE` | `merge(default, license.limits)` | License loaded, `now < exp`. | INFO | +| `GRACE` | Same as `ACTIVE` | `exp ≤ now < exp + gracePeriodDays`. UI warning banner. | WARN | +| `EXPIRED` | `DefaultTierLimits` | `now ≥ exp + gracePeriodDays`. UI label distinct from ABSENT. | ERROR | +| `INVALID` | `DefaultTierLimits` | Signature failure, tenant mismatch, parse error, or public key not configured but a token is present. | **ERROR — loud** | + +`ABSENT` and `INVALID` produce the same enforcement (default tier) but are surfaced very +differently: + +- **`ABSENT`** is a clean state — fresh install, no license yet. UI shows a calm "Install a + license to lift the default-tier caps" call to action. No audit row beyond the boot log line. +- **`INVALID`** is an active error — tampering, wrong public key, or a paste that lost + characters. UI shows a red banner with the validator's error message + (e.g. "License signature verification failed", "License tenantId 'acme-corp' does not match + server tenant 'beta-corp'"). Audit row written under + `AuditCategory.LICENSE` action `reject_license`. Prometheus + `cameleer_license_state{state="INVALID"} = 1` so an alert can fire. + +State is recomputed on every limit check (clock comparison only against parsed in-memory +`LicenseInfo`) — no scheduler needed for `ACTIVE → GRACE → EXPIRED` transitions. A separate +**daily revalidation job** (§6.6) re-runs the signature check against the DB row to catch slow +failures like public-key rotation drift. + +### 3.2 Default tier (the "no license" caps) + +| Limit | Default | +|---|---| +| `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 (2 cores) | +| `max_total_memory_mb` | 2048 (2 GB) | +| `max_total_replicas` | 5 | +| `max_execution_retention_days` | 1 | +| `max_log_retention_days` | 1 | +| `max_metric_retention_days` | 1 | +| `max_jar_retention_count` | 3 | + +Encoded as `public static final Map DEFAULTS` in `DefaultTierLimits`. Keys +match the license payload exactly. + +--- + +## 4. Enforcement map + +Every limit check goes through one method on `LicenseEnforcer`: + +```java +void assertWithinCap(String limitKey, long currentUsage, long requestedDelta); +``` + +Throws `LicenseCapExceededException(limitKey, current, cap)` when `currentUsage + requestedDelta > cap`. +A `@ControllerAdvice` maps it to `403` with a body that explains the "why" so operators can act +without grepping logs: + +```json +{ + "error": "license cap reached", + "limit": "max_apps", + "current": 3, + "cap": 3, + "state": "EXPIRED", + "message": "License expired 5 days ago: system reverted to default tier (3 apps). Current usage is 3. Install or renew the license to create more apps." +} +``` + +The `message` field is rendered server-side from a small template per state: + +| State | Message template | +|--- |---| +| `ABSENT` | "No license installed: default tier applies (cap = N for {limit}). Install a license to raise this." | +| `ACTIVE` | "License cap reached: {limit} = {cap}. Current usage is {current}. Contact your vendor to raise the cap." | +| `GRACE` | "License expired {n} day(s) ago and is in its grace period (ends in {m} days). Cap unchanged at {cap}. Renew before grace ends." | +| `EXPIRED`| "License expired {n} days ago: system reverted to default tier (cap = N for {limit}). Current usage is {current}. Renew the license to lift the cap." | +| `INVALID`| "License rejected ({reason}): default tier applies (cap = N for {limit}). Fix the license to raise this." | + +### 4.1 Per-limit call sites + +| Limit | Call site | Failure response | +|---|---|---| +| `max_environments` | `EnvironmentService.create` (start) | 403 | +| `max_apps` | `AppService.createApp` | 403 | +| `max_agents` | `AgentRegistryService.register` | 403 — agent treated as unregistered (no SSE, no commands) | +| `max_users` | `UserAdminController.createUser` and `OidcAuthController.callback` (auto-signup) | 403 / OIDC login failure | +| `max_outbound_connections` | `OutboundConnectionServiceImpl.create` | 403 | +| `max_alert_rules` | `AlertRuleController.create` | 403 | +| `max_total_cpu_millis` | `DeploymentExecutor.PRE_FLIGHT` (sum across non-stopped deploys + new) | Deploy fails fast at PRE_FLIGHT, status FAILED, audit row | +| `max_total_memory_mb` | same | same | +| `max_total_replicas` | same | same | +| `max_execution_retention_days` | `EnvironmentService.update` (per-env field, see §4.2) + `RetentionPolicyApplier` (see §4.3) | 422 on update; ClickHouse TTL recomputed on every license change | +| `max_log_retention_days` | same | same | +| `max_metric_retention_days` | same | same | +| `max_jar_retention_count` | `EnvironmentAdminController.PUT /jar-retention` | 422 | + +### 4.2 Per-environment retention fields + +Three new columns on `environments` (Flyway V2): + +```sql +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; +``` + +These are the configured per-env values. The effective ClickHouse TTL is +`min(licenseCap, configured)`. Admin UI surfaces the configured values; +`EnvironmentService.update` rejects values above the license cap with 422. + +### 4.3 Runtime retention recompute + +`RetentionPolicyApplier` is `@EventListener(LicenseChangedEvent)`: + +- Triggered on every `LicenseService.replace(...)` (boot install, env-var override, file + override, POST `/admin/license`) **and** on every state transition the revalidation job + detects (e.g. license becomes `EXPIRED`, caps drop to default). +- Recomputes the effective TTL per env (`min(licenseCap, configured)`), then issues + `ALTER TABLE … MODIFY TTL …` on the affected ClickHouse tables (executions, processors, + logs, metrics, route_diagrams, agent_events). One ALTER per table per affected env. +- Errors are logged WARN; a failed ALTER does not block the license install — the operator can + retry by reposting the license. The previous TTL keeps applying until the next successful + ALTER. +- At boot, `LicenseService.loadInitial(...)` publishes one `LicenseChangedEvent` after the + load order in §6.2 settles, so the boot path goes through the same applier as runtime + changes. + +Result: a server that stays up for months and lands in `EXPIRED` will see ClickHouse TTLs +collapse to default-tier values automatically — no restart needed. + +### 4.4 Boot-time invariant + +If a license is added that *lowers* a cap below current usage (10 apps, license now allows 5), the +server logs one WARN per limit at boot. **No deletion**. New creates reject; existing resources +keep working. + +--- + +## 5. Usage endpoint + +`GET /api/v1/admin/license/usage` (ADMIN only): + +```json +{ + "state": "ACTIVE", + "expiresAt": "2027-04-25T00:00:00Z", + "daysRemaining": 365, + "gracePeriodDays": 30, + "tenantId": "acme-corp", + "label": "ACME prod 2026", + "lastValidatedAt": "2026-04-26T03:14:07Z", + "message": "License active. 365 days remaining.", + "limits": [ + {"key": "max_apps", "current": 7, "cap": 50, "source": "license"}, + {"key": "max_agents", "current": 12, "cap": 100, "source": "license"}, + {"key": "max_total_cpu_millis", "current": 8500, "cap": 32000, "source": "license"}, + {"key": "max_outbound_connections", "current": 0, "cap": 1, "source": "default"} + ] +} +``` + +`source` is `"default"` when the cap comes from `DefaultTierLimits` (i.e. the license omits this +key, or there is no license), and `"license"` when the cap is explicit in the license. Drives the +SaaS UI's "free tier" badge. + +`message` carries the same human-readable explanation that the 403 body uses, varying by state: + +- `ABSENT` — "No license installed. Default tier applies." +- `ACTIVE` — "License active. {n} days remaining." +- `GRACE` — "License expired {n} days ago. Grace period ends in {m} days. Renew now to avoid degradation." +- `EXPIRED`— "License expired {n} days ago. System reverted to default tier." +- `INVALID`— "License rejected: {reason}. Default tier applies. Fix the license to recover." + +`LicenseUsageReader` issues one cheap aggregate per limit (`SELECT COUNT(*)` per entity table; a +single grouped `SELECT SUM(replicas * cpuMillis), SUM(replicas * memoryMb), SUM(replicas)` over +non-stopped deployments). + +`GET /api/v1/admin/license` (existing) is extended to return `{state, envelope, lastValidatedAt}` +with the raw token omitted from the response. + +--- + +## 6. Lifecycle, persistence, install paths + +### 6.1 Storage + +Flyway V2 migration: + +```sql +CREATE TABLE license ( + tenant_id TEXT PRIMARY KEY, -- one row per server (= one tenant) + token TEXT NOT NULL, -- full signed token + license_id UUID NOT NULL, + installed_at TIMESTAMPTZ NOT NULL, + installed_by TEXT NOT NULL, -- users.user_id (bare) or 'system' for env/file boot + expires_at TIMESTAMPTZ NOT NULL, + last_validated_at TIMESTAMPTZ NOT NULL -- updated by boot, install, and revalidation job +); +``` + +`last_validated_at` is the timestamp of the most recent **successful** signature/parse round-trip +against the current public key. Useful for troubleshooting "why did my license stop working" — a +stale `last_validated_at` next to a recent `now` is a strong signal that revalidation is failing +and the operator should check the public key. + +### 6.2 Boot order + +`LicenseBeanConfig`: + +1. If `CAMELEER_SERVER_LICENSE_TOKEN` env var is set → validate → write to DB (overwrite, + sets `last_validated_at = now`) → load. +2. Else if `CAMELEER_SERVER_LICENSE_FILE` is set → read file → validate → write to DB → load. +3. Else read `license` row from DB → validate → on success update `last_validated_at = now` → + load. +4. Else `ABSENT`. + +After step 1–3 the service publishes one `LicenseChangedEvent` so the retention applier and +metrics gauges initialise off the same code path as runtime changes. + +Env-var / file act as **idempotent overrides** — they always win and replace the DB row, so the +operator's last action survives reboots. + +### 6.3 Runtime install + +`POST /api/v1/admin/license { "token": "..." }` (existing): +- Validates against the configured public key. +- On success, persists to `license` table (`installed_by = user_id`, `last_validated_at = now`), + updates the in-memory `LicenseGate`, publishes `LicenseChangedEvent`, audits. +- On failure, returns 400 with the validator error message and audits the rejection. + Server transitions to `INVALID` state if a previously-loaded license was replaced; otherwise + remains in its prior state (the rejected token is *not* written to DB). + +### 6.4 Public key custody + +`CAMELEER_SERVER_LICENSE_PUBLICKEY` (existing) remains the only verification key. Build- / +deploy-time secret bound to the vendor distribution. **Not stored in DB.** If unset *and* a +license is present → reject all licenses (existing behaviour) → `INVALID` state. + +### 6.5 Audit trail + +New `AuditCategory.LICENSE`. Actions: + +| Action | When | Payload | +|---|---|---| +| `install_license` | First successful install in an empty state | `{licenseId, expiresAt, installedBy, source}` (`source` = `env`/`file`/`api`) | +| `replace_license` | Successful install over an existing license | same + `previousLicenseId` | +| `reject_license` | Validation failed (signature, tenant, parse, public key missing) | `{reason, source}` | +| `revalidate_license` | Daily job result, on **failure only** | `{licenseId, reason}` | +| `cap_exceeded` | Any `LicenseCapExceededException` | `{limit, current, cap, requestedBy, state}` | + +### 6.6 Daily revalidation job + +`LicenseRevalidationJob`: +- `@Scheduled(cron = "0 0 3 * * *")` (03:00 server local time) plus an immediate run 60s + after boot. +- Reads the DB token, re-runs `LicenseValidator.validate(token)` against the current public + key. +- On success: `UPDATE license SET last_validated_at = now WHERE tenant_id = ?`. +- On failure (e.g. operator rotated the public key without reinstalling the license, or DB + row was tampered with directly): transition state to `INVALID`, publish + `LicenseChangedEvent` (so retention recomputes too), audit `revalidate_license` with the + reason, log `ERROR`. +- Cheap (no I/O beyond one DB read + one DB write); safe to run frequently. 03:00 is chosen + to coincide with off-peak so the WARN noise lands when humans aren't deploying. + +--- + +## 7. Minter + +### 7.1 `LicenseMinter` (library) + +Pure function, packaged in `cameleer-license-minter`: + +```java +public final class LicenseMinter { + public static String mint(LicenseInfo info, PrivateKey ed25519PrivateKey); +} +``` + +Serializes `LicenseInfo` to canonical JSON (sorted keys), signs the bytes with Ed25519, returns +`base64(payload).base64(signature)`. cameleer-saas calls this directly to mint per-tenant tokens. + +### 7.2 `LicenseMinterCli` (CLI) + +```bash +java -jar cameleer-license-minter-1.0-SNAPSHOT.jar \ + --private-key=/secure/vendor.key \ + --public-key=/secure/vendor.pub \ + --tenant=acme-corp \ + --label="ACME prod 2026" \ + --expires=2027-04-25 \ + --grace-days=30 \ + --max-apps=50 \ + --max-agents=100 \ + --max-total-cpu-millis=32000 \ + --max-total-memory-mb=65536 \ + --max-execution-retention-days=90 \ + --output=acme-license.tok \ + --verify +``` + +- `--private-key` reads a PEM-encoded Ed25519 private key (output of + `openssl genpkey -algorithm ed25519`). +- `--public-key` *(used only with `--verify`)* reads the matching public key. Required when + `--verify` is set; ignored otherwise. +- Unspecified `--max-*` flags are omitted from the payload — the license inherits the default for + that key. +- Unknown flags fail fast. +- `--output` writes the token; if omitted, prints to stdout. +- `--verify` round-trips the freshly-minted token through `LicenseValidator` against + `--public-key` *after* writing the output file. This catches: + - corruption between `String → file` write, + - wrong-key pairing (vendor accidentally pointed `--public-key` at a different keypair's + public half), + - signature mismatch from a buggy build of the minter. + On verify failure the CLI exits non-zero, prints the validator error, and (if `--output` was + written) deletes the output file so the bad token does not get shipped. + +Keypair generation is **out of band** — vendor uses `openssl` and stores both halves in their +secret manager. We deliberately do not ship a `--gen-keypair` subcommand to keep the boundary +clean. + +--- + +## 8. Telemetry + +Prometheus gauges scraped via `/api/v1/prometheus`: + +| Metric | Labels | Notes | +|---|---|---| +| `cameleer_license_state` | `state="ABSENT|ACTIVE|GRACE|EXPIRED|INVALID"` | Boolean — exactly one is 1. | +| `cameleer_license_days_remaining` | (none) | Negative in GRACE/EXPIRED. | +| `cameleer_license_limit_utilisation`| `limit="max_apps"` etc. | `current / cap`, in `[0, 1+]`. | +| `cameleer_license_cap_rejections_total` | `limit="..."` | Counter. | +| `cameleer_license_last_validated_age_seconds` | (none) | `now - last_validated_at`. Spikes if the daily revalidation job is failing. | + +State-transition log lines: `INFO` on install/ACTIVE, `WARN` on GRACE, `ERROR` on EXPIRED, +`ERROR` on INVALID, `WARN` on cap reject (sampled to avoid log spam). + +Recommended alert (in cameleer-saas Grafana, not shipped with the server): page on +`cameleer_license_state{state="INVALID"} == 1` for > 5 minutes. + +--- + +## 9. Dead-code removal + +Performed in the **first commit** of the implementation. Per the project's "no backwards +compatibility shims" preference, no deprecated path or feature flag. + +- Delete `Feature.java`. +- Delete `LicenseGate.isEnabled(Feature)`. +- Delete `LicenseInfo.features` field, `LicenseInfo.hasFeature(Feature)`. +- Delete `LicenseGateTest.withLicense_onlyLicensedFeaturesEnabled` and `LicenseInfo.open()`'s + `Set.of(Feature.values())` assertion. +- Update `LicenseValidator` to ignore `features` if present in old tokens (silently dropped, + not an error). + +--- + +## 10. Testing + +| Layer | Tests | +|---|---| +| Core unit | `LicenseValidatorTest` — signature, expiry, tenant mismatch, missing required fields (`tenantId`, `licenseId`, `iat`, `exp`), unknown extra fields. | +| Core unit | `LicenseStateMachineTest` — all five transitions including grace boundary, replace from any state, invalid install routes to `INVALID`, valid install from `INVALID` recovers to `ACTIVE`. | +| Core unit | `DefaultTierLimitsTest` — every documented key has a default. | +| Minter unit | `LicenseMinterTest` — round-trip with a throwaway Ed25519 keypair. Canonical JSON is stable across runs. | +| Minter CLI | `LicenseMinterCliTest` — invokes `main` with `--private-key=tmp` and checks output token validates; `--verify` happy path; `--verify` failure path deletes the output file and exits non-zero. | +| App unit | `LicenseEnforcerTest` — for each limit: cap-reached, under-cap, default-tier with no license, missing-cap-inherits-default, message text varies per state. | +| App unit | `RetentionPolicyApplierTest` — license-changed event recomputes effective TTL per env; failed ALTER logs WARN and does not throw. | +| App integration | `LicenseLifecycleIT` — install via env, replace via POST, restart restores from DB, public-key removal at runtime transitions to `INVALID`, daily revalidation job updates `last_validated_at`. Driven through REST. | +| App integration | `LicenseEnforcementIT` — REST-driven, hit each cap end-to-end (per the project's "REST-API-driven ITs" preference). Includes `cap_exceeded` audit row check and verifies the 403 body's `message` field matches the state. | +| App integration | `RetentionRuntimeRecomputeIT` — install license with `max_log_retention_days=30`, observe `logs` TTL ALTER fires; replace with `max_log_retention_days=7`, observe TTL drops to 7 without restart. | +| Boot | `SchemaBootstrapIT` extension — `license` table exists with `last_validated_at`, `environments` retention columns exist, retention pinning honoured at boot. | + +No raw-SQL seeding of caps in ITs. All caps installed via the REST endpoint or env var. + +--- + +## 11. Open follow-ups (deliberately deferred) + +- Ingestion-rate limits (`max_executions_per_minute`, `max_logs_per_minute`). +- Online revocation callback (the `revocation_check_url` envelope field). +- Concurrent debug session limit (`max_concurrent_debug_sessions` from the SaaS epic). +- A "license usage history" report for vendors to see growth over time. +- Open a tracking issue on `cameleer/cameleer-server` (Gitea) — none exists today. + +--- + +## 12. Risk register + +| Risk | Mitigation | +|---|---| +| Default tier so tight that an honest evaluator cannot try the product. | Defaults documented; vendor can ship a longer-`exp` "trial" license at install time if needed. | +| Customer lowers `gracePeriodDays` field by editing token. | Token is signed; any edit invalidates the signature. | +| License removed from DB out of band, server lands in ABSENT and rejects new resources but old ones are above default tier. | Boot-time WARN per over-cap limit. UI banner in the admin license page. No auto-deletion. | +| Public key rotation. | Out of scope for v1; documented as "redeploy with new key" — vendors are expected to rotate via redeployment. Daily revalidation job catches a rotation that wasn't paired with a reinstall (state → `INVALID`, alertable). | +| Compute cap arithmetic relies on `cpuLimit` and `memoryLimitMb` being set on every container. | Existing `ResolvedContainerConfig` already enforces these; `DeploymentExecutor.PRE_FLIGHT` rejects deploys with unset compute fields. | +| Per-env retention column added but old ClickHouse partitions retain longer. | Documented: TTL change is honoured by ClickHouse on its next merge cycle. New rows inserted always honour the new TTL. | +| `RetentionPolicyApplier` issues blocking ALTERs from the event listener thread. | Applier runs ALTERs serialised but on a separate executor (not the publisher thread) so a slow ClickHouse does not stall the install API call. License install API returns immediately with the new state; retention recompute completes asynchronously and is observable via metrics. | diff --git a/pom.xml b/pom.xml index 9562c50a..c50c031e 100644 --- a/pom.xml +++ b/pom.xml @@ -22,6 +22,7 @@ cameleer-server-core cameleer-server-app + cameleer-license-minter diff --git a/ui/src/components/LayoutShell.tsx b/ui/src/components/LayoutShell.tsx index b0115ff1..12ba9246 100644 --- a/ui/src/components/LayoutShell.tsx +++ b/ui/src/components/LayoutShell.tsx @@ -1062,7 +1062,7 @@ function LayoutContent() { } - user={username ? { name: username } : undefined} + user={{ name: username || 'Account' }} userMenuItems={userMenuItems} onLogout={handleLogout} onNavigate={navigate}