58 Commits

Author SHA1 Message Date
hsiegeln
cd92036f91 ci(minter): deploy license-minter JARs to Gitea Maven registry on push
Some checks failed
CI / cleanup-branch (push) Has been skipped
CI / build (push) Successful in 4m57s
CI / cleanup-branch (pull_request) Has been skipped
CI / build (pull_request) Successful in 4m35s
CI / docker (pull_request) Has been skipped
CI / docker (push) Successful in 3m44s
CI / deploy (pull_request) Has been skipped
CI / deploy-feature (pull_request) Has been skipped
CI / deploy (push) Has been skipped
CI / deploy-feature (push) Failing after 2m29s
Adds <distributionManagement> at the parent POM and a push-only deploy
step in the build job. Selects the parent + core + minter via -pl so
both the plain library JAR and the Spring Boot fat CLI JAR are pushed
with their full dep tree resolvable; server-app is excluded as a
fat-jar runtime, not a library.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 18:59:38 +02:00
hsiegeln
2f7c6aa005 fix(auth): @NotNull on AuthCapabilitiesResponse.Oidc.providerName 2026-04-26 18:59:20 +02:00
hsiegeln
f945d10d48 feat(auth): AuthCapabilitiesResponse DTO 2026-04-26 18:57:09 +02:00
hsiegeln
ddb18c4f17 feat(auth): OidcProviderNameDeriver — issuer URI → display label
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-26 18:53:31 +02:00
hsiegeln
f1aa1ea19f docs(auth): implementation plan for login routing harmonization
9 tasks, TDD throughout. Backend: OidcProviderNameDeriver utility,
AuthCapabilitiesResponse DTO, AuthCapabilitiesController. Frontend:
useAuthCapabilities hook, capability-driven LoginPage rewrite,
OidcCallback ?local trap removal. Plus docs and manual smoke for
the original SaaS-provisioned tenant bug.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 18:46:55 +02:00
hsiegeln
a3c0e9aa7f docs(auth): harmonization design — login routing capability model
All checks were successful
CI / cleanup-branch (push) Has been skipped
CI / build (push) Successful in 2m37s
CI / docker (push) Successful in 2m32s
CI / deploy-feature (push) Has been skipped
CI / deploy (push) Successful in 53s
Captures the decision to gate login UX on capabilities (no SaaS-mode
flag), drop prompt=none from the primary OIDC flow per RFC 9700 §4.4,
and keep ?local as the explicit admin-recovery escape hatch.

MFA enrollment / enforcement and password reset for local accounts are
explicitly deferred and tracked in issue #154.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 18:37:00 +02:00
hsiegeln
5216dab043 Merge feature/runtime-hardening: license enforcement (36 tasks)
Delivers a signed-token license tier system with 8 enforced cap
surfaces (envs/apps/agents/users/outbound/alert-rules/compute/jar-
retention), per-tenant validation, daily revalidation, ClickHouse
TTL recompute on license change, audit trail, and Prometheus
metrics. Plus runtime container-hardening pre-work and a stand-
alone cameleer-license-minter Maven module (test-scope only on
the server).

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

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

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 16:33:44 +02:00
hsiegeln
5864553fed docs(license): minter README + operator guide + SaaS handoff
cameleer-license-minter/README.md — vendor-side guide: build, public
LicenseMinter API, CLI usage with all flags, token format (standard
base64, not url-safe), LicenseInfo schema, Ed25519 key generation,
worked example, security guidance, runtime-separation verification.

docs/license-enforcement.md — operator guide: install paths and
priority (env > file > DB > none), public-key config, REST API,
state machine (ABSENT/ACTIVE/GRACE/EXPIRED/INVALID), default tier
caps, 403 envelope semantics, retention TTL recompute, daily
revalidation, audit + Prometheus surfaces, troubleshooting.

docs/handoff/2026-04-26-license-saas-handoff.md — SaaS playbook:
trust model, onboarding/renewal/revocation runbooks, key management,
cap matrix per plan tier, telemetry, failure modes, testing guidance.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 16:33:12 +02:00
hsiegeln
140ea88460 docs(rules): document license enforcement classes + endpoints
Final consolidation pass after the 36-task license-enforcement work.

core-classes.md:
- New license/ section: LicenseInfo, LicenseLimits, DefaultTierLimits,
  LicenseValidator, LicenseGate, LicenseStateMachine, LicenseState.
- runtime/: added CreateGuard (functional interface for license-cap
  hooks consulted by EnvironmentService/AppService/AgentRegistryService).
- admin/: AuditCategory.LICENSE added to the documented enum value list.

app-classes.md:
- New license/ section: LicenseService, LicenseRepository, LicenseRecord,
  PostgresLicenseRepository, LicenseChangedEvent, LicenseEnforcer,
  LicenseUsageReader, LicenseCapExceededException, LicenseExceptionAdvice,
  LicenseMessageRenderer, RetentionPolicyApplier, LicenseRevalidationJob,
  LicenseMetrics.
- LicenseAdminController entry expanded to document the GET response
  shape and the LicenseService.install delegation pattern.
- config/: RuntimeBeanConfig note about CreateGuard wiring; new
  LicenseBeanConfig entry covering the four-bean topology and the
  always-failing-validator fallback.

Note: LicenseChangedEvent, LicenseRepository, LicenseRecord, and
PostgresLicenseRepository live in cameleer-server-app, not -core; the
plan's section assignments were corrected against the actual code.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 16:17:31 +02:00
hsiegeln
581dc1ad13 test(license): SchemaBootstrapIT — assert V5 license + retention columns
Two new assertions: license table has tenant_id/license_id/token/
installed_at/installed_by/expires_at/last_validated_at columns with
expected types + NOT NULL constraints, PK on tenant_id; environments
has execution_retention_days/log_retention_days/metric_retention_days
all integer NOT NULL DEFAULT 1.

Note: V5 migration does not include an installed_via column; the
plan's spec was aspirational. Test asserts what the migration
actually creates (and what PostgresLicenseRepository reads/writes).

OpenAPI regen (Step 35.2) deferred to session end — requires running
backend + UI dev server.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 16:13:50 +02:00
hsiegeln
e198c13e8a test(license): RetentionRuntimeRecomputeIT — TTL recompute on license change
Install license with max_log_retention_days=30, env.configured=60 →
effective=30; verify ClickHouse logs table reflects toIntervalDay(30).
Replace with max=7 → effective=7; verify TTL recomputed. Polls
system.tables.create_table_query up to 5s for the @Async listener
to apply.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 16:08:28 +02:00
hsiegeln
1e78439ddd test(license): LicenseEnforcementIT — cross-cap smoke regression net
Five @Nested cap surfaces (envs, apps, outbound, alert rules, users)
share a single synthetic license with cap=1 each. Each test pushes
just past the cap and verifies the standard 403 envelope plus a
cap_exceeded audit row. Per-limit ITs cover full per-cap behavior;
this IT catches accidental wire-rip regressions across all caps.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 16:00:50 +02:00
hsiegeln
1a307da6b2 test(license): LicenseLifecycleIT — install/persist/revalidate/reject
End-to-end IT covering the full lifecycle: mint a token via
cameleer-license-minter (test-scope), POST it via /api/v1/admin/license,
verify state=ACTIVE, clear gate, revalidate from PG, verify state restored.
Plus: tampered signature -> 400 + LICENSE/FAILURE audit row, gate not
mutated to ACTIVE.

Adds cameleer-license-minter as a test-scope dep on cameleer-server-app
(verified absent from runtime/compile classpaths). Also disables the
default spring-boot:repackage execution on the minter pom so the main
artifact stays as a plain library JAR consumable as a Maven dependency
(the cli classifier still produces the executable jar).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 15:56:01 +02:00
hsiegeln
885f2be16b feat(license): Prometheus gauges for state + days remaining
cameleer_license_state{state=...} (one-hot per LicenseState),
cameleer_license_days_remaining (negative when ABSENT/INVALID),
cameleer_license_last_validated_age_seconds. Refreshed on
LicenseChangedEvent and every 60s.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 15:43:54 +02:00
hsiegeln
945ecd78cf feat(license): LicenseUsageController GET /api/v1/admin/license/usage
Returns state, expiresAt/daysRemaining, lastValidatedAt, message
(LicenseMessageRenderer.forState), and a limits[] array where each
entry carries key/current/cap/source ("license" vs "default"). Adds
public AgentRegistryService.liveCount() so max_agents can be reported
from the in-memory registry.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 15:42:39 +02:00
hsiegeln
3f69e546e4 refactor(license): LicenseAdminController delegates to LicenseService
GET returns {state, invalidReason, envelope, lastValidatedAt}. POST
delegates to licenseService.install(token, userId, "api") so install
goes through audit + persistence + event publish. Removes the inline
LicenseValidator construction from the controller.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 15:34:07 +02:00
hsiegeln
340d954fed feat(license): LicenseRevalidationJob — daily cron + 60s post-startup
@Scheduled(cron = "0 0 3 * * *") triggers svc.revalidate() daily.
@EventListener(ApplicationReadyEvent.class) @Async fires once 60s
after boot to catch ABSENT->ACTIVE transitions if the license was
written to PG between server starts. Exceptions are logged but never
propagate.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 15:32:33 +02:00
hsiegeln
484a55f4f4 feat(license): RetentionPolicyApplier listens on LicenseChangedEvent
@EventListener fires on every license install/replace/expire. For each
environment, computes effective TTL = min(licenseCap, env.configured)
and emits one ALTER TABLE ... MODIFY TTL ... per (table, env). Tables
covered: executions, processor_executions, logs, agent_metrics,
agent_events. ClickHouse failures are logged but do not propagate
(listener is async-tolerant).

route_diagrams is intentionally excluded -- it has no TTL clause in
init.sql (ReplacingMergeTree keyed on content_hash, not time-series).
server_metrics is also excluded -- it has no environment column
(server straddles environments).

Per-environment TTL via WHERE requires ClickHouse 22.3+; the project's
current image (clickhouse/clickhouse-server:24.12) is well above that
floor.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 15:28:42 +02:00
hsiegeln
cc5d88d708 feat(license): surface execution/log/metric retention days on Environment
Adds three int fields to the Environment record + repository row mapper,
matching the columns added in V5. Default value is 1 per the V5 NOT NULL
DEFAULT 1. Read DTO surfaces the fields via Jackson record serialization;
setter endpoint deferred to a follow-up that wires the corresponding
license cap checks.

The canonical constructor enforces >= 1 for each retention field — V5
guarantees this at the DB level, but the runtime guard catches in-memory
construction errors (e.g., test sites that pass 0).

Test sites updated to the 12-arg signature with retention defaults of 1.
EnvironmentAdminControllerIT gains a regression test asserting the wire
shape exposes all three fields.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 15:22:40 +02:00
hsiegeln
046f08fe87 feat(license): enforce max_jar_retention_count at PUT jar-retention
Returns 422 UNPROCESSABLE_ENTITY when jarRetentionCount exceeds
license cap. Default tier cap = 3. The other three retention caps
(execution/log/metric retention days) are deferred to T26+ where
the corresponding fields are added to Environment.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 15:16:04 +02:00
hsiegeln
56bddcc747 feat(license): enforce compute caps at DeploymentExecutor PRE_FLIGHT
Adds ComputeUsage record + computeUsage() helper to LicenseUsageReader
that aggregates from PG. DeploymentExecutor.executeAsync runs three
assertWithinCap checks (max_total_cpu_millis, max_total_memory_mb,
max_total_replicas) right after config resolution. The existing
executor try/catch turns a LicenseCapExceededException into a FAILED
deployment with the cap message in the failure reason.

Adds ComputeCapEnforcementIT (HTTP-driven; @MockBean RuntimeOrchestrator,
since cap rejection short-circuits before any orchestrator call) plus
defensive license lifts in BlueGreenStrategyIT, RollingStrategyIT,
DeploymentSnapshotIT, and DeploymentControllerAuditIT so sequential
deploys under testcontainer reuse don't trip the new caps.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 15:09:39 +02:00
hsiegeln
71f3b70b86 feat(license): enforce max_alert_rules at AlertRuleController.create
Adds AlertRuleRepository.count() and a LicenseEnforcer.assertWithinCap
call at the top of the POST handler. Default cap = 2; the 3rd rule
gets the standard 403 envelope. Sibling alert ITs that legitimately
need more than 2 rules get the cap lifted via the test-license helper.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 14:50:59 +02:00
hsiegeln
5a579415a1 feat(license): enforce max_outbound_connections at OutboundConnectionServiceImpl.create
Adds LicenseEnforcer.assertWithinCap call at the top of create() using
repo.listByTenant(tenantId).size() as the current count. Lifts the cap
in OutboundConnectionAdminControllerIT (duplicateNameReturns409 needs
2 creates in one test). LicenseExceptionAdvice maps the rejection to
the standard 403 envelope; cap_exceeded audit row emitted via the
LicenseEnforcer 3-arg ctor.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 14:40:12 +02:00
hsiegeln
1ff30905f7 feat(license): enforce max_users at user creation paths
Wires LicenseEnforcer into UserAdminController.createUser and
OidcAuthController auto-signup. Cap fires before any validation so
over-cap creates short-circuit cheaply. Audit emission already
present (LicenseEnforcer 3-arg ctor from T16 emits cap_exceeded
under AuditCategory.LICENSE).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 14:29:54 +02:00
hsiegeln
afdaee628b feat(license): enforce max_agents at AgentRegistryService.register
Adds a CreateGuard to AgentRegistryService that fires only on NEW
registrations: re-registers of an existing agent bypass the cap (they
don't grow the registry, and rejecting them would orphan an agent that
already counts against the cap). Live-only count for cap enforcement —
STALE/DEAD/SHUTDOWN agents are excluded so the cap reflects the working
fleet, not historical residue.

Reuses the CreateGuard pattern from T18-T19. The global
LicenseExceptionAdvice maps the resulting LicenseCapExceededException to
403 with the structured envelope — no AgentRegistrationController
changes needed.

AgentCapEnforcementIT exercises the HTTP path end-to-end: two registers
succeed at cap=2, a third returns 403 with the expected envelope, and a
re-register of an already-registered agent succeeds at-cap.

Sibling agent-registering ITs (Agent*ControllerIT, Diagram*IT,
Execution*IT, Search*IT, Protocol*IT, Backpressure*IT, JwtRefresh*IT,
Registration*IT, Security*IT, SseSigning*IT, IngestionSchemaIT) lift
max_agents in @BeforeEach and clear the synthetic license in @AfterEach
— the in-memory registry is shared across @SpringBootTest reuse
boundaries, so without the lift the default-tier max_agents=5 would be
exhausted by accumulated test residue.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 14:19:08 +02:00
hsiegeln
80dafe685b feat(license): enforce max_apps at AppService.createApp
Adds CreateGuard hook to AppService.createApp using the same pattern
as T18 (EnvironmentService). AppRepository.count() added; the bean
wires LicenseEnforcer.assertWithinCap("max_apps", current, 1).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 13:36:34 +02:00
hsiegeln
198811b752 refactor(license-test): rename installTestLicenseWithCaps -> installSyntheticUnsignedLicense
Makes the signature-bypass loud at every call site since T19-T25 will
copy this pattern 5+ more times. The helper still loads via
LicenseGate.load() directly (no signature check) — the new name
ensures any future caller has to acknowledge that.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 13:24:58 +02:00
hsiegeln
8a64a9e04c feat(license): enforce max_environments at EnvironmentService.create
Adds CreateGuard functional interface to core (preserves the no-Spring
boundary between core and app) and wires LicenseEnforcer into the
EnvironmentService bean in RuntimeBeanConfig so POST
/api/v1/admin/environments rejects with the structured 403 envelope
(error/limit/cap/state/message) once the cap is reached. Default tier
max_environments=1; the V1 baseline seeds the default env, so the very
next create through the API is rejected unless a license lifts the cap.

Also adds EnvironmentRepository.count() (with PostgresEnvironmentRepository
impl), TestSecurityHelper.installTestLicenseWithCaps(...) so existing ITs
that POST envs keep working, and a defensive cleanup in
LicenseUsageReaderIT/EnvironmentAdminControllerIT to stay
order-independent under Testcontainer reuse (deletes deployments+apps
before envs to avoid FK violations).

Test: EnvironmentCapEnforcementIT (new) drives the rejection path
end-to-end and asserts the 403 body shape produced by
LicenseExceptionAdvice.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 13:16:41 +02:00
hsiegeln
f291d7c24d feat(license): LicenseUsageReader aggregates current usage
One COUNT per entity table; one SUM-grouped query over non-stopped
deployments for compute caps. SQL traverses
deployed_config_snapshot->'containerConfig' (corrected from the
plan's top-level path; the snapshot record nests containerConfig
under that key). 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) <noreply@anthropic.com>
2026-04-26 12:47:59 +02:00
hsiegeln
9b9b56043c fix(license): explicit @Autowired ctor + tolerate audit failures
Two follow-ups to LicenseEnforcer review:
- Add @Autowired to the 3-arg ctor so Spring picks it unambiguously
  (the 2-arg test ctor is otherwise an equally-greedy candidate).
- Wrap audit.log() in try/catch + log.warn so a degraded audit DB
  cannot mask a cap rejection: callers still see HTTP 403 even when
  audit storage is unhealthy.
- Extract counter name to private static final.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 12:43:27 +02:00
hsiegeln
4985348827 feat(license): LicenseEnforcer single entry point
assertWithinCap consults LicenseGate.getEffectiveLimits, throws
LicenseCapExceededException on overflow, increments
cameleer_license_cap_rejections_total{limit=...} for telemetry, and
emits an AuditCategory.LICENSE cap_exceeded audit row when an
AuditService is wired (3-arg ctor; the test-only 2-arg ctor passes
null and the audit call short-circuits). Unknown limit keys are
programmer errors (IllegalArgumentException), not 403s.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 12:36:58 +02:00
hsiegeln
e98d790874 fix: always show user badge for logout access
When username is empty (e.g. email-registered OIDC users with no display
name), the badge was hidden entirely, making logout inaccessible. Always
render the badge with fallback text "Account".

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-26 12:31:25 +02:00
hsiegeln
2bad9c3e48 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). Adds the forState()
overload now (used by the /usage endpoint in Task 30) so both surfaces
share identical phrasing.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 12:26:39 +02:00
hsiegeln
6f658b6648 docs(license): session handoff at task 14/36
Resume point for the next session executing the License Enforcement
plan. Captures: 14 done commit SHAs, what works/doesn't end-to-end,
critical plan deviations (AuditService.log API; LicenseInfo.label
not tier; throwaway-keypair fallback validator; ClickHouse TTL WHERE
caveat for T27), batching strategy, and suggested next-task order.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 12:07:35 +02:00
hsiegeln
b95e80a24a 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 (constructed with a throwaway keypair so
the parent ctor accepts it) so loaded tokens route to INVALID
instead of being silently ignored.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 11:16:49 +02:00
hsiegeln
6fbcf10ee4 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) <noreply@anthropic.com>
2026-04-26 11:11:48 +02:00
hsiegeln
2f75b2865b 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) <noreply@anthropic.com>
2026-04-26 11:06:07 +02:00
hsiegeln
2e51deb511 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) <noreply@anthropic.com>
2026-04-26 11:05:35 +02:00
hsiegeln
20aefd5bf6 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) <noreply@anthropic.com>
2026-04-26 11:02:44 +02:00
hsiegeln
f6657f811b 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) <noreply@anthropic.com>
2026-04-26 10:57:05 +02:00
hsiegeln
7300424a49 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) <noreply@anthropic.com>
2026-04-26 10:56:02 +02:00
hsiegeln
1ae5a1a27e 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) <noreply@anthropic.com>
2026-04-26 10:55:11 +02:00
hsiegeln
896b7e6e91 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) <noreply@anthropic.com>
2026-04-26 10:54:19 +02:00
hsiegeln
0499a54ebc 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-like class swapped
atomically so concurrent reads see a consistent license+reason pair.

Removes the transient openSentinel() and getTier() introduced by
earlier tasks (no production consumers).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 10:48:56 +02:00
hsiegeln
ddc0b686c3 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) <noreply@anthropic.com>
2026-04-26 10:47:10 +02:00
hsiegeln
cf84d80de7 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. The transient
placeholder/TODO from Task 2 is removed.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 10:40:04 +02:00
hsiegeln
2ebe4989bb 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) <noreply@anthropic.com>
2026-04-26 10:33:16 +02:00
hsiegeln
551a7f12b5 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) <noreply@anthropic.com>
2026-04-26 10:21:51 +02:00
hsiegeln
ec51aef802 docs(license): implementation plan for license enforcement
36 tasks covering: dead-Feature removal; LicenseInfo/Limits/State
machine; standalone cameleer-license-minter Maven module + CLI with
--verify; Flyway V5 license table + environments retention columns;
LicenseRepository/Service/Enforcer/UsageReader; per-state cap-rejection
ControllerAdvice with rendered messages; wiring across Environment/
App/Agent/User/Outbound/AlertRule/Deployment compute caps; runtime
ClickHouse TTL applier on every LicenseChangedEvent; daily
revalidation job; usage endpoint; Prometheus gauges; ITs; OpenAPI
regen; .claude/rules updates.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 10:09:28 +02:00
hsiegeln
e0be6a069f docs(license): apply review feedback to enforcement design
- Add INVALID state to FSM (signature/tenant/parse failure ≠ ABSENT)
  with loud UI/audit/metric severity; ABSENT stays a calm state.
- Make tenantId required in the license envelope (it's already inside
  the signed payload, so a self-hosted customer cannot strip it).
- Move ClickHouse TTL recompute from boot-only to a
  RetentionPolicyApplier @EventListener(LicenseChangedEvent), so a
  long-running server that lands in EXPIRED tightens TTL automatically.
- Add LicenseRevalidationJob (daily) that re-runs signature check
  against the DB row and updates last_validated_at; transitions to
  INVALID on failure (catches public-key rotation drift).
- Add last_validated_at column to the license table, surfaced on the
  /usage endpoint and as cameleer_license_last_validated_age_seconds.
- Enrich enforcement-failure responses and the /usage endpoint with a
  per-state human-readable message so 403s and the UI both explain
  WHY caps changed.
- Add --verify (with --public-key) to the minter CLI to round-trip a
  freshly-minted token through LicenseValidator before shipping it,
  deleting the output file on verify failure.
- Add corresponding tests, telemetry gauge, and a runtime-recompute IT.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 09:42:16 +02:00
hsiegeln
0e512a3c0c docs(license): brainstorm spec for license enforcement design
Captures the agreed design for enforcing licensing on cameleer-server:
- Default tier with hard caps when no license is configured
- Arbitrary per-customer limits in signed Ed25519 license tokens
- Standalone cameleer-license-minter module (vendor-only)
- DB-persisted license with env/file override paths
- ABSENT/ACTIVE/GRACE/EXPIRED state machine; offline expiry only
- Removes the dead Feature enum scaffolding

Pending writing-plans.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 21:55:18 +02:00
hsiegeln
f6b76b2d5e docs(runtime): document hardening contract and runtime override (#152)
Surfaces the multi-tenant container hardening contract introduced in the
prior commit so operators and integrators know what is enforced and why.

- application.yml: declare `cameleer.server.runtime.dockerruntime`
  alongside the other runtime properties (empty = auto-detect runsc).
- HOWTO.md: add the override row to the Runtime config table.
- SERVER-CAPABILITIES.md: new "Multi-Tenant Runtime Sandboxing" section
  describing the cap_drop, no-new-privileges, AppArmor, read-only rootfs,
  pids_limit, /tmp tmpfs, and runsc auto-detect contract — plus the
  on-disk state caveat that motivates issue #153.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 21:06:10 +02:00
hsiegeln
8e9ad47077 feat(runtime): harden tenant containers + auto-detect gVisor (#152)
Tenant JARs are arbitrary user code: Camel ships components (camel-exec,
camel-bean, MVEL/Groovy templating) that turn a header into shell, and
Java 17 has no SecurityManager — the JVM is not a security boundary.
This applies an unconditional hardening contract to every tenant
container so a single runc CVE no longer equals host takeover.

DockerRuntimeOrchestrator.startContainer now sets:
- cap_drop ALL (Capability.values() — docker-java has no ALL constant)
- security_opt: no-new-privileges, apparmor=docker-default
  (default seccomp profile applies implicitly)
- read_only rootfs, pids_limit=512
- /tmp tmpfs rw,nosuid,size=256m — no noexec, since Netty/Snappy/LZ4/Zstd
  dlopen native libs from /tmp via mmap(PROT_EXEC) which noexec blocks

The orchestrator also probes `docker info` at construction and uses runsc
(gVisor) automatically when the daemon has it registered. Override via
cameleer.server.runtime.dockerruntime (e.g. "kata"); empty = auto.

Outbound TCP, DNS, and TLS are unaffected — caps/seccomp don't gate
those — so vanilla Camel-Kafka producers/consumers and REST integrations
keep working unchanged. Stateful tenants (Kafka Streams with on-disk
state stores, apps writing to /var/log/...) need explicit writeable
volumes; that's tracked in #153 as the natural follow-up.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 20:58:26 +02:00
hsiegeln
c5b6f2bbad fix(dirty-state): exclude live-pushed fields from deploy diff
All checks were successful
CI / cleanup-branch (push) Has been skipped
CI / build (push) Successful in 1m13s
CI / docker (push) Successful in 1m2s
CI / deploy-feature (push) Has been skipped
CI / deploy (push) Successful in 41s
SonarQube / sonarqube (push) Successful in 5m49s
Live-pushed config fields (taps, tapVersion, tracedProcessors,
routeRecording) apply via SSE CONFIG_UPDATE — they take effect on
running agents without a redeploy and are fetched on agent restart
from application_config. They must not contribute to the
"pending deploy" diff against the last-successful-deployment snapshot.

Before this fix, applying a tap from the process diagram correctly
rolled out in real time but then marked the app "Pending Deploy (1)"
because DirtyStateCalculator compared every agentConfig field. This
also contradicted the UI rule (ui.md) that the live tabs "never mark
dirty".

Adds taps, tapVersion, tracedProcessors, routeRecording to
AGENT_CONFIG_IGNORED_KEYS. Updates the nested-path test to use a
staged field (sensitiveKeys) and adds a new test asserting that
divergent live-push fields keep dirty=false.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 14:42:07 +02:00
83c3ac3ef3 Merge pull request 'feat(ui): show deployment status + rich pending-deploy tooltip on app header' (#151) from feature/deployment-status-badge into main
All checks were successful
CI / cleanup-branch (push) Has been skipped
CI / build (push) Successful in 2m20s
CI / docker (push) Successful in 23s
CI / deploy (push) Successful in 43s
CI / deploy-feature (push) Has been skipped
Reviewed-on: #151
2026-04-24 13:50:00 +02:00
7dd7317cb8 Merge branch 'main' into feature/deployment-status-badge
Some checks failed
CI / cleanup-branch (pull_request) Has been skipped
CI / build (pull_request) Successful in 2m7s
CI / cleanup-branch (push) Has been skipped
CI / build (push) Successful in 2m6s
CI / docker (pull_request) Has been skipped
CI / deploy (pull_request) Has been skipped
CI / deploy-feature (pull_request) Has been skipped
CI / docker (push) Successful in 1m48s
CI / deploy (push) Has been skipped
CI / deploy-feature (push) Failing after 2m19s
2026-04-24 13:49:51 +02:00
2654271494 Merge pull request 'feature/cmdk-attribute-filter' (#150) from feature/cmdk-attribute-filter into main
Some checks failed
CI / docker (push) Has been cancelled
CI / deploy (push) Has been cancelled
CI / deploy-feature (push) Has been cancelled
CI / cleanup-branch (push) Has been cancelled
CI / build (push) Has been cancelled
Reviewed-on: #150
2026-04-24 13:49:24 +02:00
hsiegeln
888f589934 feat(ui): show deployment status + rich pending-deploy tooltip on app header
Some checks failed
CI / cleanup-branch (push) Has been skipped
CI / build (push) Successful in 1m24s
CI / docker (push) Successful in 1m12s
CI / deploy (push) Has been cancelled
CI / deploy-feature (push) Has been cancelled
CI / cleanup-branch (pull_request) Has been skipped
CI / build (pull_request) Successful in 2m6s
CI / docker (pull_request) Has been skipped
CI / deploy (pull_request) Has been skipped
CI / deploy-feature (pull_request) Has been skipped
Add a StatusDot + colored Badge next to the app name in the deployment
page header, showing the latest deployment's status (RUNNING / STARTING
/ FAILED / STOPPED / DEGRADED / STOPPING). The existing "Pending
deploy" badge now carries a tooltip explaining *why*: either a list of
local unsaved edits, or a per-field diff against the last successful
deploy's snapshot (field, staged vs deployed values). When server-side
differences exist, the badge shows the count.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 13:47:04 +02:00
140 changed files with 12731 additions and 183 deletions

View File

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

View File

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

View File

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

View File

@@ -84,6 +84,12 @@ jobs:
- name: Build and Test
run: mvn clean verify -DskipITs -U --batch-mode
- name: Deploy minter to Maven registry
if: github.event_name == 'push'
run: mvn deploy -DskipTests -DskipITs --batch-mode -pl .,cameleer-server-core,cameleer-license-minter
env:
REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
docker:
needs: build
runs-on: ubuntu-latest

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,24 @@
package com.cameleer.server.app.dto;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.NotNull;
@Schema(description = "Authentication capabilities reported to the SPA so it can render the login page deterministically")
public record AuthCapabilitiesResponse(
@Schema(description = "OIDC interactive login capability") Oidc oidc,
@Schema(description = "Local username/password account capability") LocalAccounts localAccounts
) {
@Schema(description = "OIDC interactive login")
public record Oidc(
@Schema(description = "Whether OIDC is configured AND enabled") boolean enabled,
@Schema(description = "Best-effort display label, e.g. \"Logto\", \"Keycloak\", \"Single Sign-On\"") @NotNull String providerName,
@Schema(description = "When true, OIDC is the canonical entry point and the SPA hides the local form unless ?local is set") boolean primary
) {}
@Schema(description = "Local username/password accounts")
public record LocalAccounts(
@Schema(description = "Whether the local form is reachable at all") boolean enabled,
@Schema(description = "When true, the SPA gates the local form behind ?local with an admin-recovery banner") boolean adminRecoveryOnly
) {}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,41 @@
package com.cameleer.server.app.security;
import java.net.URI;
/**
* Pure utility — derives a display label for an OIDC provider from its issuer URI.
* Used by {@link AuthCapabilitiesController} so the SPA can render
* "Sign in with {providerName}" on the login page.
*
* <p>Pattern-match only — never network-discover. If the issuer doesn't match a
* known vendor pattern, we return the generic "Single Sign-On" label rather than
* leaking hostnames into the UI.
*/
public final class OidcProviderNameDeriver {
private static final String GENERIC = "Single Sign-On";
private OidcProviderNameDeriver() {}
public static String deriveName(String issuerUri) {
if (issuerUri == null || issuerUri.isBlank()) {
return GENERIC;
}
String host;
try {
URI uri = URI.create(issuerUri.trim());
host = uri.getHost();
} catch (IllegalArgumentException e) {
return GENERIC;
}
if (host == null || host.isBlank()) {
return GENERIC;
}
String h = host.toLowerCase();
if (h.contains("logto")) return "Logto";
if (h.contains("keycloak")) return "Keycloak";
if (h.endsWith("auth0.com")) return "Auth0";
if (h.endsWith("okta.com") || h.endsWith("oktapreview.com")) return "Okta";
return GENERIC;
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,53 @@
package com.cameleer.server.app.security;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
/** Unit tests for {@link OidcProviderNameDeriver}. No Spring context. */
class OidcProviderNameDeriverTest {
@Test
void logtoIssuer_returnsLogto() {
assertThat(OidcProviderNameDeriver.deriveName("https://auth.logto.example/")).isEqualTo("Logto");
assertThat(OidcProviderNameDeriver.deriveName("https://logto.cameleer.local")).isEqualTo("Logto");
}
@Test
void keycloakIssuer_returnsKeycloak() {
assertThat(OidcProviderNameDeriver.deriveName("https://keycloak.example/realms/cameleer")).isEqualTo("Keycloak");
}
@Test
void auth0Issuer_returnsAuth0() {
assertThat(OidcProviderNameDeriver.deriveName("https://example.auth0.com/")).isEqualTo("Auth0");
}
@Test
void oktaIssuer_returnsOkta() {
assertThat(OidcProviderNameDeriver.deriveName("https://dev-123.okta.com/")).isEqualTo("Okta");
assertThat(OidcProviderNameDeriver.deriveName("https://login.oktapreview.com/")).isEqualTo("Okta");
}
@Test
void unknownIssuer_returnsGenericLabel() {
assertThat(OidcProviderNameDeriver.deriveName("https://idp.example.com/")).isEqualTo("Single Sign-On");
}
@Test
void blankOrNullIssuer_returnsGenericLabel() {
assertThat(OidcProviderNameDeriver.deriveName("")).isEqualTo("Single Sign-On");
assertThat(OidcProviderNameDeriver.deriveName(null)).isEqualTo("Single Sign-On");
assertThat(OidcProviderNameDeriver.deriveName(" ")).isEqualTo("Single Sign-On");
}
@Test
void malformedUri_returnsGenericLabel() {
assertThat(OidcProviderNameDeriver.deriveName("not a url")).isEqualTo("Single Sign-On");
}
@Test
void caseInsensitiveMatching() {
assertThat(OidcProviderNameDeriver.deriveName("https://AUTH.LOGTO.EXAMPLE/")).isEqualTo("Logto");
}
}

View File

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

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