87 Commits

Author SHA1 Message Date
hsiegeln
1809574fe6 ci: include cameleer-license-api in maven deploy project list
All checks were successful
CI / cleanup-branch (push) Has been skipped
CI / build (push) Successful in 3m31s
CI / docker (push) Successful in 2m44s
CI / deploy-feature (push) Has been skipped
CI / deploy (push) Successful in 48s
The license-api module was added in 858975f0 but the CI deploy step's
`-pl` list still only built parent + server-core + minter. server-core
now depends on cameleer-license-api, which wasn't in the registry yet,
so the deploy job failed with:

    Could not find artifact com.cameleer:cameleer-license-api:jar:1.0-SNAPSHOT
    in gitea (https://gitea.siegeln.net/api/packages/cameleer/maven)

Add cameleer-license-api to the project list so it builds and publishes
before its consumers in the same reactor invocation.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 20:41:26 +02:00
hsiegeln
858975f03f refactor(license): extract cameleer-license-api module from server-core
Some checks failed
CI / cleanup-branch (push) Has been skipped
CI / build (push) Failing after 2m57s
CI / docker (push) Has been skipped
CI / deploy (push) Has been skipped
CI / deploy-feature (push) Has been skipped
Splits the pure license contract types (LicenseInfo, LicenseValidator,
LicenseState, LicenseStateMachine, LicenseLimits, DefaultTierLimits) into a
new cameleer-license-api module under package com.cameleer.license.

Why: cameleer-license-minter previously depended on cameleer-server-core for
these types, dragging cameleer-server-core + cameleer-common onto the
classpath of every minter consumer (notably cameleer-saas). The SaaS
management plane has no business carrying server-runtime types — it only
needs the license contract to mint and verify tokens.

After:
  cameleer-license-minter -> cameleer-license-api  (no server internals)
  cameleer-server-core    -> cameleer-license-api
  cameleer-saas           -> cameleer-license-minter -> cameleer-license-api

Verified: mvn -pl cameleer-license-minter dependency:tree shows the minter
no longer pulls cameleer-server-core or cameleer-common. Full reactor
verify (-DskipITs) green: 371 tests pass.

LicenseGate stays in server-core (server-runtime state holder, not contract).

Closes cameleer/cameleer-server#156

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 20:06:52 +02:00
hsiegeln
30db609aff Merge feature/auth-harmonization: capability-driven login UX
All checks were successful
CI / cleanup-branch (push) Has been skipped
CI / build (push) Successful in 3m6s
CI / docker (push) Successful in 2m49s
CI / deploy-feature (push) Has been skipped
CI / deploy (push) Successful in 53s
Replaces the prompt=none → /login?local trap with a deterministic
capability endpoint (GET /api/v1/auth/capabilities). LoginPage renders
SSO-primary or local form based on caps; ?local is the explicit
admin-recovery escape hatch. Drops prompt=none from the SSO authorize
URL per RFC 9700 §4.4. Adds Vitest + IT coverage and docs.

MFA enrollment / enforcement deferred to issue #154.
2026-04-26 19:52:31 +02:00
hsiegeln
45b5f473c9 refactor(auth): post-review tidy — drop @NotNull, refresh e2e comment, use oidc.primary
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-26 19:48:20 +02:00
hsiegeln
71688dea16 docs(auth): document AuthCapabilitiesController + login routing 2026-04-26 19:41:20 +02:00
hsiegeln
b63b9aa4bb fix(ui): drop OidcCallback ?local trap on login_required 2026-04-26 19:38:15 +02:00
hsiegeln
7565cdcf2f fix(ui): try/finally in handleOidcLogin; logout redirects to /login (not ?local)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-26 19:36:23 +02:00
hsiegeln
b7d390adf4 feat(ui): capability-driven LoginPage; drop prompt=none silent SSO
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-26 19:29:59 +02:00
hsiegeln
29769480be feat(ui): useAuthCapabilities hook 2026-04-26 19:23:39 +02:00
hsiegeln
657281461d chore(api): regenerate OpenAPI types for /auth/capabilities
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 19:22:14 +02:00
hsiegeln
af53eca7f6 test(auth): tighten AuthCapabilitiesControllerIT — drop redundant stub, add coverage gaps 2026-04-26 19:17:05 +02:00
hsiegeln
4f6e7ea4dc feat(auth): AuthCapabilitiesController — GET /api/v1/auth/capabilities
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 19:10:17 +02:00
96fc55b932 Merge pull request 'feature/auth-harmonization' (#155) from feature/auth-harmonization into main
All checks were successful
CI / cleanup-branch (push) Has been skipped
CI / build (push) Successful in 3m58s
CI / docker (push) Successful in 37s
CI / deploy (push) Successful in 38s
CI / deploy-feature (push) Has been skipped
Reviewed-on: #155
2026-04-26 19:01:00 +02:00
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
hsiegeln
9aad2f3871 docs(rules): document AttributeFilter + SearchController attr param
All checks were successful
CI / cleanup-branch (pull_request) Has been skipped
CI / build (pull_request) Successful in 1m50s
CI / docker (pull_request) Has been skipped
CI / deploy (pull_request) Has been skipped
CI / deploy-feature (pull_request) Has been skipped
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 11:22:27 +02:00
hsiegeln
cbaac2bfa5 feat(cmdk): Enter on 'key: value' query submits as attribute facet 2026-04-24 11:21:12 +02:00
hsiegeln
7529a9ce99 feat(cmdk): synthetic facet result when query matches key: value 2026-04-24 11:18:13 +02:00
hsiegeln
09309de982 fix(cmdk): attribute clicks filter the exchange list via ?attr= instead of opening one exchange 2026-04-24 11:13:28 +02:00
hsiegeln
56c41814fc fix(ui): gate AUTO badge on attributeFilters too 2026-04-24 11:11:26 +02:00
hsiegeln
68704e15b4 feat(ui): exchange list reads ?attr= URL params and renders filter chips
(carries forward pre-existing attribute-badge color-by-key tweak)
2026-04-24 11:05:50 +02:00
hsiegeln
510206c752 feat(ui): add attribute-filter URL and facet parsing helpers 2026-04-24 10:58:35 +02:00
hsiegeln
58e9695b4c chore(ui): regenerate openapi types with AttributeFilter 2026-04-24 10:39:45 +02:00
hsiegeln
f27a0044f1 refactor(search): align ResponseStatusException imports + add wildcard HTTP test 2026-04-24 10:30:42 +02:00
hsiegeln
5c9323cfed feat(search): accept attr= multi-value query param on /executions GET
Add a repeatable attr query parameter to the GET /executions endpoint that
parses key-only (exists check) and key:value (exact or wildcard-via-*)
filters. Invalid keys are mapped to HTTP 400 via ResponseStatusException.
The POST /executions/search path already honoured attributeFilters from
the request body via the Jackson canonical ctor; an IT now proves it.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 10:23:52 +02:00
hsiegeln
2dcbd5a772 feat(search): push AttributeFilter list into ClickHouse WHERE clause
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 10:13:30 +02:00
hsiegeln
f9b5f235cc feat(search): extend SearchRequest with attributeFilters (legacy ctor preserved) 2026-04-24 09:59:05 +02:00
hsiegeln
0b419db9f1 feat(search): add AttributeFilter record with key regex + wildcard pattern translation 2026-04-24 09:51:28 +02:00
hsiegeln
5f6f9e523d chore(gitnexus): sync indexed symbol count
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 09:20:25 +02:00
hsiegeln
35319dc666 refactor(ui): server metrics page uses global time range
All checks were successful
CI / cleanup-branch (push) Has been skipped
CI / build (push) Successful in 1m31s
CI / docker (push) Successful in 1m10s
CI / deploy-feature (push) Has been skipped
CI / deploy (push) Successful in 44s
Drop the page-local DS Select window picker. Drive from() / to() off
useGlobalFilters().timeRange so the dashboard tracks the same TopBar range
as Exchanges / Dashboard / Runtime. Bucket size auto-scales via
stepSecondsFor(windowSeconds) (10 s for ≤30 min → 1 h for >48 h). Query
hooks now take ServerMetricsRange = { from: Date; to: Date } instead of a
windowSeconds number, so they support arbitrary absolute or rolling ranges
the TopBar may supply (not just "now − N"). Toolbar collapses to just the
server-instance badges.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 09:19:20 +02:00
hsiegeln
3c2409ed6e docs(server-metrics): document the built-in admin dashboard
SERVER-CAPABILITIES.md now lists the two consumption paths (UI + REST API)
side-by-side with visibility rules; the dashboard-builder doc leads with a
"Built-in admin dashboard" section and a 2026-04-24 changelog entry so
first-time readers know they don't have to build anything before seeing
server health.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 09:05:22 +02:00
173 changed files with 14507 additions and 544 deletions

View File

@@ -57,7 +57,7 @@ Env-scoped read-path controllers (`AlertController`, `AlertRuleController`, `Ale
- `DeploymentController``/api/v1/environments/{envSlug}/apps/{appSlug}/deployments`. GET list / POST create (body `{ appVersionId }`) / POST `{id}/stop` / POST `{id}/promote` (body `{ targetEnvironment: slug }` — target app slug must exist in target env) / GET `{id}/logs`. All lifecycle ops (`POST /` deploy, `POST /{id}/stop`, `POST /{id}/promote`) audited under `AuditCategory.DEPLOYMENT`. Action codes: `deploy_app`, `stop_deployment`, `promote_deployment`. Acting user resolved via the `user:` prefix-strip convention; both SUCCESS and FAILURE branches write audit rows. `created_by` (TEXT, nullable) populated from `SecurityContextHolder` and surfaced on the `Deployment` DTO. - `DeploymentController``/api/v1/environments/{envSlug}/apps/{appSlug}/deployments`. GET list / POST create (body `{ appVersionId }`) / POST `{id}/stop` / POST `{id}/promote` (body `{ targetEnvironment: slug }` — target app slug must exist in target env) / GET `{id}/logs`. All lifecycle ops (`POST /` deploy, `POST /{id}/stop`, `POST /{id}/promote`) audited under `AuditCategory.DEPLOYMENT`. Action codes: `deploy_app`, `stop_deployment`, `promote_deployment`. Acting user resolved via the `user:` prefix-strip convention; both SUCCESS and FAILURE branches write audit rows. `created_by` (TEXT, nullable) populated from `SecurityContextHolder` and surfaced on the `Deployment` DTO.
- `ApplicationConfigController``/api/v1/environments/{envSlug}`. GET `/config` (list), GET/PUT `/apps/{appSlug}/config`, GET `/apps/{appSlug}/processor-routes`, POST `/apps/{appSlug}/config/test-expression`. PUT accepts `?apply=staged|live` (default `live`). `live` saves to DB and pushes `CONFIG_UPDATE` SSE to live agents in this env (existing behavior); `staged` saves to DB only, skipping the SSE push — used by the unified app deployment page. Audit action is `stage_app_config` for staged writes, `update_app_config` for live. Invalid `apply` values return 400. - `ApplicationConfigController``/api/v1/environments/{envSlug}`. GET `/config` (list), GET/PUT `/apps/{appSlug}/config`, GET `/apps/{appSlug}/processor-routes`, POST `/apps/{appSlug}/config/test-expression`. PUT accepts `?apply=staged|live` (default `live`). `live` saves to DB and pushes `CONFIG_UPDATE` SSE to live agents in this env (existing behavior); `staged` saves to DB only, skipping the SSE push — used by the unified app deployment page. Audit action is `stage_app_config` for staged writes, `update_app_config` for live. Invalid `apply` values return 400.
- `AppSettingsController``/api/v1/environments/{envSlug}`. GET `/app-settings` (list), GET/PUT/DELETE `/apps/{appSlug}/settings`. ADMIN/OPERATOR only. - `AppSettingsController``/api/v1/environments/{envSlug}`. GET `/app-settings` (list), GET/PUT/DELETE `/apps/{appSlug}/settings`. ADMIN/OPERATOR only.
- `SearchController``/api/v1/environments/{envSlug}`. GET `/executions`, POST `/executions/search`, GET `/stats`, `/stats/timeseries`, `/stats/timeseries/by-app`, `/stats/timeseries/by-route`, `/stats/punchcard`, `/attributes/keys`, `/errors/top`. - `SearchController``/api/v1/environments/{envSlug}`. GET `/executions`, POST `/executions/search`, GET `/stats`, `/stats/timeseries`, `/stats/timeseries/by-app`, `/stats/timeseries/by-route`, `/stats/punchcard`, `/attributes/keys`, `/errors/top`. GET `/executions` accepts repeat `attr` query params: `attr=order` (key-exists), `attr=order:47` (exact), `attr=order:4*` (wildcard — `*` maps to SQL LIKE `%`). First `:` splits key/value; later colons stay in the value. Invalid keys → 400. POST `/executions/search` accepts the same filters via `SearchRequest.attributeFilters` in the body.
- `LogQueryController` — GET `/api/v1/environments/{envSlug}/logs` (filters: source (multi, comma-split, OR-joined), level (multi, comma-split, OR-joined), application, agentId, exchangeId, logger, q, time range, instanceIds (multi, comma-split, AND-joined as WHERE instance_id IN (...) — used by the Checkpoint detail drawer to scope logs to a deployment's replicas); sort asc/desc). Cursor-paginated, returns `{ data, nextCursor, hasMore, levelCounts }`; cursor is base64url of `"{timestampIso}|{insert_id_uuid}"` — same-millisecond tiebreak via the `insert_id` UUID column on `logs`. - `LogQueryController` — GET `/api/v1/environments/{envSlug}/logs` (filters: source (multi, comma-split, OR-joined), level (multi, comma-split, OR-joined), application, agentId, exchangeId, logger, q, time range, instanceIds (multi, comma-split, AND-joined as WHERE instance_id IN (...) — used by the Checkpoint detail drawer to scope logs to a deployment's replicas); sort asc/desc). Cursor-paginated, returns `{ data, nextCursor, hasMore, levelCounts }`; cursor is base64url of `"{timestampIso}|{insert_id_uuid}"` — same-millisecond tiebreak via the `insert_id` UUID column on `logs`.
- `RouteCatalogController` — GET `/api/v1/environments/{envSlug}/routes` (merged route catalog from registry + ClickHouse; env filter unconditional). - `RouteCatalogController` — GET `/api/v1/environments/{envSlug}/routes` (merged route catalog from registry + ClickHouse; env filter unconditional).
- `RouteMetricsController` — GET `/api/v1/environments/{envSlug}/routes/metrics`, GET `/api/v1/environments/{envSlug}/routes/metrics/processors`. - `RouteMetricsController` — GET `/api/v1/environments/{envSlug}/routes/metrics`, GET `/api/v1/environments/{envSlug}/routes/metrics/processors`.
@@ -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. - `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. - `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`. - `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`. - `ThresholdAdminController` — CRUD `/api/v1/admin/thresholds`.
- `AuditLogController` — GET `/api/v1/admin/audit`. - `AuditLogController` — GET `/api/v1/admin/audit`.
- `RbacStatsController` — GET `/api/v1/admin/rbac/stats`. - `RbacStatsController` — GET `/api/v1/admin/rbac/stats`.
@@ -111,6 +112,12 @@ Env-scoped read-path controllers (`AlertController`, `AlertRuleController`, `Ale
- `DatabaseAdminController` — GET `/api/v1/admin/database/**` (conditional on `infrastructureendpoints` flag). - `DatabaseAdminController` — GET `/api/v1/admin/database/**` (conditional on `infrastructureendpoints` flag).
- `ServerMetricsAdminController``/api/v1/admin/server-metrics/**`. GET `/catalog`, GET `/instances`, POST `/query`. Generic read API over the `server_metrics` ClickHouse table so SaaS dashboards don't need direct CH access. Delegates to `ServerMetricsQueryStore` (impl `ClickHouseServerMetricsQueryStore`). Visibility matches ClickHouse/Database admin: `@ConditionalOnProperty(infrastructureendpoints, matchIfMissing=true)` + class-level `@PreAuthorize("hasRole('ADMIN')")`. Validation: metric/tag regex `^[a-zA-Z0-9._]+$`, statistic regex `^[a-z_]+$`, `to - from ≤ 31 days`, stepSeconds ∈ [10, 3600], response capped at 500 series. `IllegalArgumentException` → 400. `/query` supports `raw` + `delta` modes (delta does per-`server_instance_id` positive-clipped differences, then aggregates across instances). Derived `statistic=mean` for timers computes `sum(total|total_time)/sum(count)` per bucket. - `ServerMetricsAdminController``/api/v1/admin/server-metrics/**`. GET `/catalog`, GET `/instances`, POST `/query`. Generic read API over the `server_metrics` ClickHouse table so SaaS dashboards don't need direct CH access. Delegates to `ServerMetricsQueryStore` (impl `ClickHouseServerMetricsQueryStore`). Visibility matches ClickHouse/Database admin: `@ConditionalOnProperty(infrastructureendpoints, matchIfMissing=true)` + class-level `@PreAuthorize("hasRole('ADMIN')")`. Validation: metric/tag regex `^[a-zA-Z0-9._]+$`, statistic regex `^[a-z_]+$`, `to - from ≤ 31 days`, stepSeconds ∈ [10, 3600], response capped at 500 series. `IllegalArgumentException` → 400. `/query` supports `raw` + `delta` modes (delta does per-`server_instance_id` positive-clipped differences, then aggregates across instances). Derived `statistic=mean` for timers computes `sum(total|total_time)/sum(count)` per bucket.
### Auth (flat)
- `UiAuthController``/api/v1/auth` (login, refresh, me). Local username/password against env-var admin or DB BCrypt hash. Lockout after 5 failed attempts.
- `OidcAuthController``/api/v1/auth/oidc` (config, callback). Code → token exchange. Roles via custom JWT claim, claim mapping rules, or default roles.
- `AuthCapabilitiesController``GET /api/v1/auth/capabilities` (unauthenticated). Reports `{oidc:{enabled, providerName, primary}, localAccounts:{enabled, adminRecoveryOnly}}` so the SPA renders the login page deterministically. `oidc.primary == oidc.enabled`; `localAccounts.adminRecoveryOnly == oidc.primary`. `providerName` is best-effort label via `OidcProviderNameDeriver` (Logto / Keycloak / Auth0 / Okta / Single Sign-On). The SPA hides the local form behind `?local` when `adminRecoveryOnly` is true.
### Other (flat) ### Other (flat)
- `DetailController` — GET `/api/v1/executions/{executionId}` + processor snapshot endpoints. - `DetailController` — GET `/api/v1/executions/{executionId}` + processor snapshot endpoints.
@@ -119,7 +126,7 @@ Env-scoped read-path controllers (`AlertController`, `AlertRuleController`, `Ale
## runtime/ — Docker orchestration ## runtime/ — Docker orchestration
- `DockerRuntimeOrchestrator` — implements RuntimeOrchestrator; Docker Java client (zerodep transport), container lifecycle - `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 - `DockerNetworkManager` — ensures bridge networks (cameleer-traefik, cameleer-env-{slug}), connects containers
- `DockerEventMonitor` — persistent Docker event stream listener (die, oom, start, stop), updates deployment status - `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. - `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 +208,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). - `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. - `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 ## config/ — Spring beans
- `RuntimeOrchestratorAutoConfig` — conditional Docker/Disabled orchestrator + NetworkManager + EventMonitor - `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 - `SecurityBeanConfig` — JwtService, Ed25519, BootstrapTokenValidator
- `StorageBeanConfig` — all repositories - `StorageBeanConfig` — all repositories
- `ClickHouseConfig` — ClickHouse JdbcTemplate, schema initializer - `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) - `App` — record: id, environmentId, slug, displayName, containerConfig (JSONB)
- `AppVersion` — record: id, appId, version, jarPath, detectedRuntimeType, detectedMainClass - `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)`. - `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) - `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. - `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,11 +43,28 @@ paths:
- `RuntimeOrchestrator` — interface: startContainer, stopContainer, getContainerStatus, getLogs, startLogCapture, stopLogCapture - `RuntimeOrchestrator` — interface: startContainer, stopContainer, getContainerStatus, getLogs, startLogCapture, stopLogCapture
- `AppRepository`, `AppVersionRepository`, `EnvironmentRepository`, `DeploymentRepository` — repository interfaces - `AppRepository`, `AppVersionRepository`, `EnvironmentRepository`, `DeploymentRepository` — repository interfaces
- `AppService`, `EnvironmentService` — domain services - `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)
The pure license **contract types** live in the separate `cameleer-license-api` module under package `com.cameleer.license` (no Spring, no server-runtime deps) so consumers like `cameleer-license-minter` and `cameleer-saas` can use them without inheriting server internals. Server-core only contains the runtime state holder (`LicenseGate`).
Contract types in `cameleer-license-api` (package `com.cameleer.license`):
- `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.
- `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`.
Runtime state holder in server-core (package `com.cameleer.server.core.license`):
- `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.
## search/ — Execution search and stats ## search/ — Execution search and stats
- `SearchService` — search, count, stats, statsForApp, statsForRoute, timeseries, timeseriesForApp, timeseriesForRoute, timeseriesGroupedByApp, timeseriesGroupedByRoute, slaCompliance, slaCountsByApp, slaCountsByRoute, topErrors, activeErrorTypes, punchcard, distinctAttributeKeys. `statsForRoute`/`timeseriesForRoute` take `(routeId, applicationId)` — app filter is applied to `stats_1m_route`. - `SearchService` — search, count, stats, statsForApp, statsForRoute, timeseries, timeseriesForApp, timeseriesForRoute, timeseriesGroupedByApp, timeseriesGroupedByRoute, slaCompliance, slaCountsByApp, slaCountsByRoute, topErrors, activeErrorTypes, punchcard, distinctAttributeKeys. `statsForRoute`/`timeseriesForRoute` take `(routeId, applicationId)` — app filter is applied to `stats_1m_route`.
- `SearchRequest` / `SearchResult` — search DTOs - `SearchRequest` / `SearchResult` — search DTOs. `SearchRequest.attributeFilters: List<AttributeFilter>` carries structured facet filters for execution attributes — key-only (exists), exact (key=value), or wildcard (`*` in value). The 21-arg legacy ctor is preserved for call-site churn; the compact ctor normalises null → `List.of()`.
- `AttributeFilter(key, value)` — record with key regex `^[a-zA-Z0-9._-]+$` (inlined into SQL, same constraint as alerting), `value == null` means key-exists, `value` containing `*` becomes a SQL LIKE pattern via `toLikePattern()`.
- `ExecutionStats`, `ExecutionSummary` — stats aggregation records - `ExecutionStats`, `ExecutionSummary` — stats aggregation records
- `StatsTimeseries`, `TopError` — timeseries and error DTOs - `StatsTimeseries`, `TopError` — timeseries and error DTOs
- `LogSearchRequest` / `LogSearchResponse` — log search DTOs. `LogSearchRequest.sources` / `levels` are `List<String>` (null-normalized, multi-value OR); `cursor` + `limit` + `sort` drive keyset pagination. Response carries `nextCursor` + `hasMore` + per-level `levelCounts`. - `LogSearchRequest` / `LogSearchResponse` — log search DTOs. `LogSearchRequest.sources` / `levels` are `List<String>` (null-normalized, multi-value OR); `cursor` + `limit` + `sort` drive keyset pagination. Response carries `nextCursor` + `hasMore` + per-level `levelCounts`.
@@ -80,7 +97,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. - `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 - `ThresholdConfig`, `ThresholdRepository` — alerting threshold config and persistence
- `AuditService` — audit logging facade - `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) ## 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`). - **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). - **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 ## 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. 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

@@ -21,7 +21,7 @@ The UI has 4 main tabs: **Exchanges**, **Dashboard**, **Runtime**, **Deployments
**Admin pages** (ADMIN-only, under `/admin/`): **Admin pages** (ADMIN-only, under `/admin/`):
- **Sensitive Keys** (`ui/src/pages/Admin/SensitiveKeysPage.tsx`) — global sensitive key masking config. Shows agent built-in defaults as outlined Badge reference, editable Tag pills for custom keys, amber-highlighted push-to-agents toggle. Keys add to (not replace) agent defaults. Per-app sensitive key additions managed via `ApplicationConfigController` API. Note: `AppConfigDetailPage.tsx` exists but is not routed in `router.tsx`. - **Sensitive Keys** (`ui/src/pages/Admin/SensitiveKeysPage.tsx`) — global sensitive key masking config. Shows agent built-in defaults as outlined Badge reference, editable Tag pills for custom keys, amber-highlighted push-to-agents toggle. Keys add to (not replace) agent defaults. Per-app sensitive key additions managed via `ApplicationConfigController` API. Note: `AppConfigDetailPage.tsx` exists but is not routed in `router.tsx`.
- **Server Metrics** (`ui/src/pages/Admin/ServerMetricsAdminPage.tsx`) — dashboard over the `server_metrics` ClickHouse table. Visibility matches Database/ClickHouse pages: gated on `capabilities.infrastructureEndpoints` in `buildAdminTreeNodes`; backend is `@ConditionalOnProperty(infrastructureendpoints) + @PreAuthorize('hasRole(ADMIN)')`. Uses the generic `/api/v1/admin/server-metrics/{catalog,instances,query}` API via `ui/src/api/queries/admin/serverMetrics.ts` hooks (`useServerMetricsCatalog`, `useServerMetricsInstances`, `useServerMetricsSeries`). Toolbar: server-instance badges + DS `Select` window picker (15 min / 1 h / 6 h / 24 h / 7 d). Sections: Server health (agents/ingestion/auth), JVM (memory/CPU/GC/threads), HTTP & DB pools, Alerting (conditional on catalog), Deployments (conditional on catalog). Each panel is a `ThemedChart` with `Line`/`Area` children from the design system; multi-series responses are flattened into overlap rows by bucket timestamp. Alerting and Deployments rows are hidden when their metrics aren't in the catalog (zero-deploy / alerting-disabled installs). - **Server Metrics** (`ui/src/pages/Admin/ServerMetricsAdminPage.tsx`) — dashboard over the `server_metrics` ClickHouse table. Visibility matches Database/ClickHouse pages: gated on `capabilities.infrastructureEndpoints` in `buildAdminTreeNodes`; backend is `@ConditionalOnProperty(infrastructureendpoints) + @PreAuthorize('hasRole(ADMIN)')`. Uses the generic `/api/v1/admin/server-metrics/{catalog,instances,query}` API via `ui/src/api/queries/admin/serverMetrics.ts` hooks (`useServerMetricsCatalog`, `useServerMetricsInstances`, `useServerMetricsSeries`), all three of which take a `ServerMetricsRange = { from: Date; to: Date }`. Time range is driven by the global TopBar picker via `useGlobalFilters()` — no page-local selector; bucket size auto-scales through `stepSecondsFor(windowSeconds)` (10 s up to 1 h buckets). Toolbar is just server-instance badges. Sections: Server health (agents/ingestion/auth), JVM (memory/CPU/GC/threads), HTTP & DB pools, Alerting (conditional on catalog), Deployments (conditional on catalog). Each panel is a `ThemedChart` with `Line`/`Area` children from the design system; multi-series responses are flattened into overlap rows by bucket timestamp. Alerting and Deployments rows are hidden when their metrics aren't in the catalog (zero-deploy / alerting-disabled installs).
## Key UI Files ## Key UI Files

View File

@@ -84,6 +84,12 @@ jobs:
- name: Build and Test - name: Build and Test
run: mvn clean verify -DskipITs -U --batch-mode 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-license-api,cameleer-server-core,cameleer-license-minter
env:
REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
docker: docker:
needs: build needs: build
runs-on: ubuntu-latest runs-on: ubuntu-latest

View File

@@ -1,7 +1,7 @@
<!-- gitnexus:start --> <!-- gitnexus:start -->
# GitNexus — Code Intelligence # GitNexus — Code Intelligence
This project is indexed by GitNexus as **cameleer-server** (9725 symbols, 24977 relationships, 300 execution flows). Use the GitNexus MCP tools to understand code, assess impact, and navigate safely. This project is indexed by GitNexus as **cameleer-server** (9731 symbols, 24987 relationships, 300 execution flows). Use the GitNexus MCP tools to understand code, assess impact, and navigate safely.
> If any GitNexus tool warns the index is stale, run `npx gitnexus analyze` in terminal first. > If any GitNexus tool warns the index is stale, run `npx gitnexus analyze` in terminal first.

View File

@@ -14,8 +14,10 @@ Cameleer Server — observability server that receives, stores, and serves Camel
## Modules ## Modules
- `cameleer-license-api` — pure license contract types (`LicenseInfo`, `LicenseValidator`, `LicenseState`, `LicenseStateMachine`, `LicenseLimits`, `DefaultTierLimits`) under package `com.cameleer.license`. No Spring or server-runtime deps; consumed by `cameleer-server-core` (validation/runtime gate) and `cameleer-license-minter` (vendor signing) — and transitively by `cameleer-saas` via the minter — without inheriting server internals.
- `cameleer-server-core` — domain logic, storage interfaces, services (no Spring dependencies) - `cameleer-server-core` — domain logic, storage interfaces, services (no Spring dependencies)
- `cameleer-server-app` — Spring Boot web app, REST controllers, SSE, persistence, Docker orchestration - `cameleer-server-app` — Spring Boot web app, REST controllers, SSE, persistence, Docker orchestration
- `cameleer-license-minter` — vendor-only Ed25519 license signing library + CLI. Depends only on `cameleer-license-api` so consumers don't pull in `cameleer-server-core`.
## Build Commands ## Build Commands
@@ -59,6 +61,7 @@ java -jar cameleer-server-app/target/cameleer-server-app-1.0-SNAPSHOT.jar
- Log processor correlation: The agent sets `cameleer.processorId` in MDC, identifying which processor node emitted a log line. - Log processor correlation: The agent sets `cameleer.processorId` in MDC, identifying which processor node emitted a log line.
- Logging: ClickHouse JDBC set to INFO (`com.clickhouse`), HTTP client to WARN (`org.apache.hc.client5`) in application.yml - Logging: ClickHouse JDBC set to INFO (`com.clickhouse`), HTTP client to WARN (`org.apache.hc.client5`) in application.yml
- Security: JWT auth with RBAC (AGENT/VIEWER/OPERATOR/ADMIN roles), Ed25519 config signing (key derived deterministically from JWT secret via HMAC-SHA256), bootstrap token for registration. CORS: `CAMELEER_SERVER_SECURITY_CORSALLOWEDORIGINS` (comma-separated) overrides `CAMELEER_SERVER_SECURITY_UIORIGIN` for multi-origin setups. Infrastructure access: `CAMELEER_SERVER_SECURITY_INFRASTRUCTUREENDPOINTS=false` disables Database and ClickHouse admin endpoints. Last-ADMIN guard: system prevents removal of the last ADMIN role (409 Conflict). Password policy: min 12 chars, 3-of-4 character classes, no username match. Brute-force protection: 5 failed attempts -> 15 min lockout. Token revocation: `token_revoked_before` column on users, checked in `JwtAuthenticationFilter`, set on password change. - Security: JWT auth with RBAC (AGENT/VIEWER/OPERATOR/ADMIN roles), Ed25519 config signing (key derived deterministically from JWT secret via HMAC-SHA256), bootstrap token for registration. CORS: `CAMELEER_SERVER_SECURITY_CORSALLOWEDORIGINS` (comma-separated) overrides `CAMELEER_SERVER_SECURITY_UIORIGIN` for multi-origin setups. Infrastructure access: `CAMELEER_SERVER_SECURITY_INFRASTRUCTUREENDPOINTS=false` disables Database and ClickHouse admin endpoints. Last-ADMIN guard: system prevents removal of the last ADMIN role (409 Conflict). Password policy: min 12 chars, 3-of-4 character classes, no username match. Brute-force protection: 5 failed attempts -> 15 min lockout. Token revocation: `token_revoked_before` column on users, checked in `JwtAuthenticationFilter`, set on password change.
- Login routing: `GET /api/v1/auth/capabilities` (unauthenticated) tells the SPA whether OIDC is the primary entry point. When OIDC is configured, the SSO button is the primary CTA and the local form is hidden behind `?local` (admin-recovery escape hatch). Per RFC 9700 §4.4 we do **not** use `prompt=none` for primary login — that returns `login_required` for first-time users and traps them on a local form.
- OIDC: Optional external identity provider support (token exchange pattern). Configured via admin API/UI, stored in database (`server_config` table). Resource server mode: accepts external access tokens (Logto M2M) via JWKS validation when `CAMELEER_SERVER_SECURITY_OIDCISSUERURI` is set. Scope-based role mapping via `SystemRole.normalizeScope()`. System roles synced on every OIDC login via `applyClaimMappings()` in `OidcAuthController` (calls `clearManagedAssignments` + `assignManagedRole` on `RbacService`) — always overwrites managed role assignments; uses managed assignment origin to avoid touching group-inherited or directly-assigned roles. Supports ES384, ES256, RS256. - OIDC: Optional external identity provider support (token exchange pattern). Configured via admin API/UI, stored in database (`server_config` table). Resource server mode: accepts external access tokens (Logto M2M) via JWKS validation when `CAMELEER_SERVER_SECURITY_OIDCISSUERURI` is set. Scope-based role mapping via `SystemRole.normalizeScope()`. System roles synced on every OIDC login via `applyClaimMappings()` in `OidcAuthController` (calls `clearManagedAssignments` + `assignManagedRole` on `RbacService`) — always overwrites managed role assignments; uses managed assignment origin to avoid touching group-inherited or directly-assigned roles. Supports ES384, ES256, RS256.
- OIDC role extraction: `OidcTokenExchanger` reads roles from the **access_token** first (JWT with `at+jwt` type), then falls back to id_token. `OidcConfig` includes `audience` (RFC 8707 resource indicator) and `additionalScopes`. All provider-specific configuration is external — no provider-specific code in the server. - OIDC role extraction: `OidcTokenExchanger` reads roles from the **access_token** first (JWT with `at+jwt` type), then falls back to id_token. `OidcConfig` includes `audience` (RFC 8707 resource indicator) and `additionalScopes`. All provider-specific configuration is external — no provider-specific code in the server.
- Sensitive keys: Global enforced baseline for masking sensitive data in agent payloads. Merge rule: `final = global UNION per-app` (case-insensitive dedup, per-app can only add, never remove global keys). - Sensitive keys: Global enforced baseline for masking sensitive data in agent payloads. Merge rule: `final = global UNION per-app` (case-insensitive dedup, per-app can only add, never remove global keys).
@@ -96,7 +99,7 @@ When adding, removing, or renaming classes, controllers, endpoints, UI component
<!-- gitnexus:start --> <!-- gitnexus:start -->
# GitNexus — Code Intelligence # GitNexus — Code Intelligence
This project is indexed by GitNexus as **cameleer-server** (9725 symbols, 24977 relationships, 300 execution flows). Use the GitNexus MCP tools to understand code, assess impact, and navigate safely. This project is indexed by GitNexus as **cameleer-server** (10530 symbols, 27383 relationships, 300 execution flows). Use the GitNexus MCP tools to understand code, assess impact, and navigate safely.
> If any GitNexus tool warns the index is stale, run `npx gitnexus analyze` in terminal first. > If any GitNexus tool warns the index is stale, run `npx gitnexus analyze` in terminal first.

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.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.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.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.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.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 | | `cameleer.server.runtime.routingmode` | `path` | `CAMELEER_SERVER_RUNTIME_ROUTINGMODE` | `path` or `subdomain` Traefik routing |

View File

@@ -0,0 +1,54 @@
<?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-api</artifactId>
<name>Cameleer License API</name>
<description>Pure license contract types — LicenseInfo, LicenseValidator, LicenseState, LicenseStateMachine, LicenseLimits, DefaultTierLimits. Shared by server-core (validation/runtime gate) and cameleer-license-minter (vendor-side signing). Has no Spring or server-runtime dependencies so consumers like cameleer-saas can depend on the minter without inheriting server internals.</description>
<dependencies>
<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>
<!-- Plain library JAR — no repackage. -->
<execution>
<id>repackage</id>
<phase>none</phase>
</execution>
</executions>
</plugin>
</plugins>
</build>
</project>

View File

@@ -0,0 +1,30 @@
package com.cameleer.license;
import java.util.Collections;
import java.util.LinkedHashMap;
import java.util.Map;
public final class DefaultTierLimits {
public static final Map<String, Integer> DEFAULTS;
static {
Map<String, Integer> m = new LinkedHashMap<>();
m.put("max_environments", 1);
m.put("max_apps", 3);
m.put("max_agents", 5);
m.put("max_users", 3);
m.put("max_outbound_connections", 1);
m.put("max_alert_rules", 2);
m.put("max_total_cpu_millis", 2000);
m.put("max_total_memory_mb", 2048);
m.put("max_total_replicas", 5);
m.put("max_execution_retention_days", 1);
m.put("max_log_retention_days", 1);
m.put("max_metric_retention_days", 1);
m.put("max_jar_retention_count", 3);
DEFAULTS = Collections.unmodifiableMap(m);
}
private DefaultTierLimits() {}
}

View File

@@ -0,0 +1,46 @@
package com.cameleer.license;
import java.time.Instant;
import java.util.Map;
import java.util.Objects;
import java.util.UUID;
/** A parsed and signature-verified license. Construct via {@link LicenseValidator}. */
public record LicenseInfo(
UUID licenseId,
String tenantId,
String label,
Map<String, Integer> limits,
Instant issuedAt,
Instant expiresAt,
int gracePeriodDays
) {
public LicenseInfo {
Objects.requireNonNull(licenseId, "licenseId is required");
Objects.requireNonNull(tenantId, "tenantId is required");
Objects.requireNonNull(limits, "limits is required");
Objects.requireNonNull(issuedAt, "issuedAt is required");
Objects.requireNonNull(expiresAt, "expiresAt is required");
if (tenantId.isBlank()) {
throw new IllegalArgumentException("tenantId must not be blank");
}
if (gracePeriodDays < 0) {
throw new IllegalArgumentException("gracePeriodDays must be >= 0");
}
}
/** True iff now > expiresAt + gracePeriodDays. */
public boolean isExpired() {
Instant deadline = expiresAt.plusSeconds((long) gracePeriodDays * 86400);
return Instant.now().isAfter(deadline);
}
/** True iff now > expiresAt (regardless of grace). Used by the state machine to distinguish ACTIVE from GRACE. */
public boolean isAfterRawExpiry() {
return Instant.now().isAfter(expiresAt);
}
public int getLimit(String key, int defaultValue) {
return limits.getOrDefault(key, defaultValue);
}
}

View File

@@ -0,0 +1,36 @@
package com.cameleer.license;
import java.util.Collections;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.Objects;
public record LicenseLimits(Map<String, Integer> values) {
public LicenseLimits {
Objects.requireNonNull(values, "values");
}
public static LicenseLimits defaultsOnly() {
return new LicenseLimits(DefaultTierLimits.DEFAULTS);
}
public static LicenseLimits mergeOverDefaults(Map<String, Integer> overrides) {
Map<String, Integer> merged = new LinkedHashMap<>(DefaultTierLimits.DEFAULTS);
if (overrides != null) merged.putAll(overrides);
return new LicenseLimits(Collections.unmodifiableMap(merged));
}
public int get(String key) {
Integer v = values.get(key);
if (v == null) {
throw new IllegalArgumentException("Unknown license limit key: " + key);
}
return v;
}
public boolean isDefaultSourced(String key, LicenseInfo license) {
if (license == null) return true;
return !license.limits().containsKey(key);
}
}

View File

@@ -0,0 +1,9 @@
package com.cameleer.license;
public enum LicenseState {
ABSENT,
ACTIVE,
GRACE,
EXPIRED,
INVALID
}

View File

@@ -0,0 +1,26 @@
package com.cameleer.license;
public final class LicenseStateMachine {
private LicenseStateMachine() {}
/**
* @param license parsed license, or null if no license is loaded
* @param invalidReason non-null if the last validation attempt failed
*/
public static LicenseState classify(LicenseInfo license, String invalidReason) {
if (invalidReason != null) {
return LicenseState.INVALID;
}
if (license == null) {
return LicenseState.ABSENT;
}
if (!license.isAfterRawExpiry()) {
return LicenseState.ACTIVE;
}
if (!license.isExpired()) {
return LicenseState.GRACE;
}
return LicenseState.EXPIRED;
}
}

View File

@@ -1,14 +1,20 @@
package com.cameleer.server.core.license; package com.cameleer.license;
import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.ObjectMapper;
import org.slf4j.Logger; import org.slf4j.Logger;
import org.slf4j.LoggerFactory; import org.slf4j.LoggerFactory;
import java.security.*; import java.security.KeyFactory;
import java.security.PublicKey;
import java.security.Signature;
import java.security.spec.X509EncodedKeySpec; import java.security.spec.X509EncodedKeySpec;
import java.time.Instant; import java.time.Instant;
import java.util.*; import java.util.Base64;
import java.util.HashMap;
import java.util.Map;
import java.util.Objects;
import java.util.UUID;
public class LicenseValidator { public class LicenseValidator {
@@ -16,8 +22,13 @@ public class LicenseValidator {
private static final ObjectMapper objectMapper = new ObjectMapper(); private static final ObjectMapper objectMapper = new ObjectMapper();
private final PublicKey publicKey; private final PublicKey publicKey;
private final String expectedTenantId;
public LicenseValidator(String publicKeyBase64) { public LicenseValidator(String publicKeyBase64, String expectedTenantId) {
Objects.requireNonNull(expectedTenantId, "expectedTenantId is required");
if (expectedTenantId.isBlank()) {
throw new IllegalArgumentException("expectedTenantId must not be blank");
}
try { try {
byte[] keyBytes = Base64.getDecoder().decode(publicKeyBase64); byte[] keyBytes = Base64.getDecoder().decode(publicKeyBase64);
KeyFactory kf = KeyFactory.getInstance("Ed25519"); KeyFactory kf = KeyFactory.getInstance("Ed25519");
@@ -25,6 +36,7 @@ public class LicenseValidator {
} catch (Exception e) { } catch (Exception e) {
throw new IllegalStateException("Failed to load license public key", e); throw new IllegalStateException("Failed to load license public key", e);
} }
this.expectedTenantId = expectedTenantId;
} }
public LicenseInfo validate(String token) { public LicenseInfo validate(String token) {
@@ -36,7 +48,6 @@ public class LicenseValidator {
byte[] payloadBytes = Base64.getDecoder().decode(parts[0]); byte[] payloadBytes = Base64.getDecoder().decode(parts[0]);
byte[] signatureBytes = Base64.getDecoder().decode(parts[1]); byte[] signatureBytes = Base64.getDecoder().decode(parts[1]);
// Verify signature
try { try {
Signature verifier = Signature.getInstance("Ed25519"); Signature verifier = Signature.getInstance("Ed25519");
verifier.initVerify(publicKey); verifier.initVerify(publicKey);
@@ -50,23 +61,25 @@ public class LicenseValidator {
throw new SecurityException("License signature verification failed", e); throw new SecurityException("License signature verification failed", e);
} }
// Parse payload
try { try {
JsonNode root = objectMapper.readTree(payloadBytes); JsonNode root = objectMapper.readTree(payloadBytes);
String tier = root.get("tier").asText(); String licenseIdStr = textOrThrow(root, "licenseId");
UUID licenseId;
Set<Feature> features = new HashSet<>();
if (root.has("features")) {
for (JsonNode f : root.get("features")) {
try { try {
features.add(Feature.valueOf(f.asText())); licenseId = UUID.fromString(licenseIdStr);
} catch (IllegalArgumentException e) { } catch (IllegalArgumentException e) {
log.warn("Unknown feature in license: {}", f.asText()); throw new IllegalArgumentException("licenseId is not a valid UUID: " + licenseIdStr);
}
} }
String tenantId = textOrThrow(root, "tenantId");
if (!tenantId.equals(expectedTenantId)) {
throw new IllegalArgumentException(
"License tenantId '" + tenantId + "' does not match server tenant '" + expectedTenantId + "'");
} }
String label = root.has("label") ? root.get("label").asText() : null;
Map<String, Integer> limits = new HashMap<>(); Map<String, Integer> limits = new HashMap<>();
if (root.has("limits")) { if (root.has("limits")) {
root.get("limits").fields().forEachRemaining(entry -> root.get("limits").fields().forEachRemaining(entry ->
@@ -74,12 +87,17 @@ public class LicenseValidator {
} }
Instant issuedAt = root.has("iat") ? Instant.ofEpochSecond(root.get("iat").asLong()) : Instant.now(); Instant issuedAt = root.has("iat") ? Instant.ofEpochSecond(root.get("iat").asLong()) : Instant.now();
Instant expiresAt = root.has("exp") ? Instant.ofEpochSecond(root.get("exp").asLong()) : null; if (!root.has("exp")) {
throw new IllegalArgumentException("exp is required");
}
Instant expiresAt = Instant.ofEpochSecond(root.get("exp").asLong());
int gracePeriodDays = root.has("gracePeriodDays") ? root.get("gracePeriodDays").asInt() : 0;
LicenseInfo info = new LicenseInfo(tier, features, limits, issuedAt, expiresAt); LicenseInfo info = new LicenseInfo(licenseId, tenantId, label, limits, issuedAt, expiresAt, gracePeriodDays);
if (info.isExpired()) { if (info.isExpired()) {
throw new IllegalArgumentException("License expired at " + expiresAt); throw new IllegalArgumentException("License expired at " + expiresAt
+ " (grace period " + gracePeriodDays + " days)");
} }
return info; return info;
@@ -89,4 +107,11 @@ public class LicenseValidator {
throw new IllegalArgumentException("Failed to parse license payload", e); throw new IllegalArgumentException("Failed to parse license payload", e);
} }
} }
private static String textOrThrow(JsonNode root, String field) {
if (!root.has(field) || root.get(field).asText().isBlank()) {
throw new IllegalArgumentException(field + " is required");
}
return root.get(field).asText();
}
} }

View File

@@ -0,0 +1,30 @@
package com.cameleer.license;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
class DefaultTierLimitsTest {
@Test
void allDocumentedKeysHaveDefaults() {
for (String key : new String[]{
"max_environments", "max_apps", "max_agents", "max_users",
"max_outbound_connections", "max_alert_rules",
"max_total_cpu_millis", "max_total_memory_mb", "max_total_replicas",
"max_execution_retention_days", "max_log_retention_days",
"max_metric_retention_days", "max_jar_retention_count"
}) {
assertThat(DefaultTierLimits.DEFAULTS).containsKey(key);
}
}
@Test
void specificValues() {
assertThat(DefaultTierLimits.DEFAULTS.get("max_environments")).isEqualTo(1);
assertThat(DefaultTierLimits.DEFAULTS.get("max_apps")).isEqualTo(3);
assertThat(DefaultTierLimits.DEFAULTS.get("max_agents")).isEqualTo(5);
assertThat(DefaultTierLimits.DEFAULTS.get("max_total_cpu_millis")).isEqualTo(2000);
assertThat(DefaultTierLimits.DEFAULTS.get("max_log_retention_days")).isEqualTo(1);
}
}

View File

@@ -0,0 +1,64 @@
package com.cameleer.license;
import org.junit.jupiter.api.Test;
import java.time.Instant;
import java.util.Map;
import java.util.UUID;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
class LicenseInfoTest {
@Test
void requiresLicenseId() {
assertThatThrownBy(() -> new LicenseInfo(
null, "acme", "label",
Map.of(), Instant.now(), Instant.now().plusSeconds(60), 0))
.isInstanceOf(NullPointerException.class)
.hasMessageContaining("licenseId");
}
@Test
void requiresTenantId() {
assertThatThrownBy(() -> new LicenseInfo(
UUID.randomUUID(), null, "label",
Map.of(), Instant.now(), Instant.now().plusSeconds(60), 0))
.isInstanceOf(NullPointerException.class)
.hasMessageContaining("tenantId");
}
@Test
void emptyTenantIdRejected() {
assertThatThrownBy(() -> new LicenseInfo(
UUID.randomUUID(), " ", "label",
Map.of(), Instant.now(), Instant.now().plusSeconds(60), 0))
.isInstanceOf(IllegalArgumentException.class);
}
@Test
void getLimit_returnsDefaultWhenMissing() {
LicenseInfo info = new LicenseInfo(
UUID.randomUUID(), "acme", null,
Map.of("max_apps", 5), Instant.now(),
Instant.now().plusSeconds(60), 0);
assertThat(info.getLimit("max_apps", 99)).isEqualTo(5);
assertThat(info.getLimit("max_users", 99)).isEqualTo(99);
}
@Test
void isExpired_honoursGracePeriod() {
Instant pastByTen = Instant.now().minusSeconds(10 * 86400);
LicenseInfo withinGrace = new LicenseInfo(
UUID.randomUUID(), "acme", null, Map.of(),
Instant.now().minusSeconds(40 * 86400),
pastByTen, 30);
assertThat(withinGrace.isExpired()).isFalse(); // 10 days into a 30-day grace
LicenseInfo pastGrace = new LicenseInfo(
UUID.randomUUID(), "acme", null, Map.of(),
Instant.now().minusSeconds(40 * 86400),
pastByTen, 5);
assertThat(pastGrace.isExpired()).isTrue(); // 10 days is past the 5-day grace
}
}

View File

@@ -0,0 +1,57 @@
package com.cameleer.license;
import org.junit.jupiter.api.Test;
import java.time.Instant;
import java.util.Map;
import java.util.UUID;
import static org.assertj.core.api.Assertions.assertThat;
class LicenseStateMachineTest {
@Test
void noLicense_isAbsent() {
assertThat(LicenseStateMachine.classify(null, null)).isEqualTo(LicenseState.ABSENT);
}
@Test
void invalidReason_isInvalid() {
assertThat(LicenseStateMachine.classify(null, "signature failed")).isEqualTo(LicenseState.INVALID);
}
@Test
void activeBeforeExp() {
LicenseInfo info = info(Instant.now().plusSeconds(86400), 0);
assertThat(LicenseStateMachine.classify(info, null)).isEqualTo(LicenseState.ACTIVE);
}
@Test
void graceWithinGracePeriod() {
LicenseInfo info = info(Instant.now().minusSeconds(86400), 7);
assertThat(LicenseStateMachine.classify(info, null)).isEqualTo(LicenseState.GRACE);
}
@Test
void expiredAfterGrace() {
LicenseInfo info = info(Instant.now().minusSeconds(8L * 86400), 7);
assertThat(LicenseStateMachine.classify(info, null)).isEqualTo(LicenseState.EXPIRED);
}
@Test
void expiredImmediatelyWithZeroGrace() {
LicenseInfo info = info(Instant.now().minusSeconds(60), 0);
assertThat(LicenseStateMachine.classify(info, null)).isEqualTo(LicenseState.EXPIRED);
}
@Test
void invalidWinsOverPresentLicense() {
LicenseInfo info = info(Instant.now().plusSeconds(86400), 0);
assertThat(LicenseStateMachine.classify(info, "tenant mismatch")).isEqualTo(LicenseState.INVALID);
}
private LicenseInfo info(Instant exp, int graceDays) {
return new LicenseInfo(UUID.randomUUID(), "acme", null, Map.of(),
Instant.now().minusSeconds(3600), exp, graceDays);
}
}

View File

@@ -0,0 +1,141 @@
package com.cameleer.license;
import org.junit.jupiter.api.Test;
import java.security.KeyPair;
import java.security.KeyPairGenerator;
import java.security.PrivateKey;
import java.security.Signature;
import java.time.Instant;
import java.time.temporal.ChronoUnit;
import java.util.Base64;
import java.util.UUID;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
class LicenseValidatorTest {
private KeyPair generateKeyPair() throws Exception {
KeyPairGenerator kpg = KeyPairGenerator.getInstance("Ed25519");
return kpg.generateKeyPair();
}
private String sign(PrivateKey key, String payload) throws Exception {
Signature signer = Signature.getInstance("Ed25519");
signer.initSign(key);
signer.update(payload.getBytes());
return Base64.getEncoder().encodeToString(signer.sign());
}
@Test
void validate_validLicense_returnsLicenseInfo() throws Exception {
KeyPair kp = generateKeyPair();
String publicKeyBase64 = Base64.getEncoder().encodeToString(kp.getPublic().getEncoded());
LicenseValidator validator = new LicenseValidator(publicKeyBase64, "acme");
Instant expires = Instant.now().plus(365, ChronoUnit.DAYS);
String payload = """
{"licenseId":"%s","tenantId":"acme","label":"HIGH","tier":"HIGH","limits":{"max_agents":50,"retention_days":90},"iat":%d,"exp":%d,"gracePeriodDays":7}
""".formatted(UUID.randomUUID(), Instant.now().getEpochSecond(), expires.getEpochSecond()).trim();
String signature = sign(kp.getPrivate(), payload);
String token = Base64.getEncoder().encodeToString(payload.getBytes()) + "." + signature;
LicenseInfo info = validator.validate(token);
assertThat(info.label()).isEqualTo("HIGH");
assertThat(info.getLimit("max_agents", 0)).isEqualTo(50);
assertThat(info.isExpired()).isFalse();
assertThat(info.tenantId()).isEqualTo("acme");
assertThat(info.gracePeriodDays()).isEqualTo(7);
}
@Test
void validate_expiredLicense_throwsException() throws Exception {
KeyPair kp = generateKeyPair();
String publicKeyBase64 = Base64.getEncoder().encodeToString(kp.getPublic().getEncoded());
LicenseValidator validator = new LicenseValidator(publicKeyBase64, "acme");
Instant past = Instant.now().minus(1, ChronoUnit.DAYS);
String payload = """
{"licenseId":"%s","tenantId":"acme","tier":"LOW","limits":{},"iat":%d,"exp":%d}
""".formatted(UUID.randomUUID(), past.minus(30, ChronoUnit.DAYS).getEpochSecond(), past.getEpochSecond()).trim();
String signature = sign(kp.getPrivate(), payload);
String token = Base64.getEncoder().encodeToString(payload.getBytes()) + "." + signature;
assertThatThrownBy(() -> validator.validate(token))
.isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("expired");
}
@Test
void validate_tamperedPayload_throwsException() throws Exception {
KeyPair kp = generateKeyPair();
String publicKeyBase64 = Base64.getEncoder().encodeToString(kp.getPublic().getEncoded());
LicenseValidator validator = new LicenseValidator(publicKeyBase64, "acme");
String payload = """
{"licenseId":"%s","tenantId":"acme","tier":"LOW","limits":{},"iat":0,"exp":9999999999}
""".formatted(UUID.randomUUID()).trim();
String signature = sign(kp.getPrivate(), payload);
// Tamper with payload
String tampered = payload.replace("LOW", "BUSINESS");
String token = Base64.getEncoder().encodeToString(tampered.getBytes()) + "." + signature;
assertThatThrownBy(() -> validator.validate(token))
.isInstanceOf(SecurityException.class)
.hasMessageContaining("signature");
}
@Test
void validate_missingTenantId_throws() throws Exception {
KeyPair kp = generateKeyPair();
String publicKeyBase64 = Base64.getEncoder().encodeToString(kp.getPublic().getEncoded());
LicenseValidator validator = new LicenseValidator(publicKeyBase64, "acme");
Instant exp = Instant.now().plus(30, ChronoUnit.DAYS);
String payload = """
{"licenseId":"%s","tier":"X","limits":{},"iat":%d,"exp":%d}
""".formatted(UUID.randomUUID(), Instant.now().getEpochSecond(), exp.getEpochSecond()).trim();
String token = Base64.getEncoder().encodeToString(payload.getBytes()) + "." + sign(kp.getPrivate(), payload);
assertThatThrownBy(() -> validator.validate(token))
.isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("tenantId");
}
@Test
void validate_tenantIdMismatch_throws() throws Exception {
KeyPair kp = generateKeyPair();
String publicKeyBase64 = Base64.getEncoder().encodeToString(kp.getPublic().getEncoded());
LicenseValidator validator = new LicenseValidator(publicKeyBase64, "beta");
Instant exp = Instant.now().plus(30, ChronoUnit.DAYS);
String payload = """
{"licenseId":"%s","tenantId":"acme","tier":"X","limits":{},"iat":%d,"exp":%d}
""".formatted(UUID.randomUUID(), Instant.now().getEpochSecond(), exp.getEpochSecond()).trim();
String token = Base64.getEncoder().encodeToString(payload.getBytes()) + "." + sign(kp.getPrivate(), payload);
assertThatThrownBy(() -> validator.validate(token))
.isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("tenantId");
}
@Test
void validate_missingLicenseId_throws() throws Exception {
KeyPair kp = generateKeyPair();
String publicKeyBase64 = Base64.getEncoder().encodeToString(kp.getPublic().getEncoded());
LicenseValidator validator = new LicenseValidator(publicKeyBase64, "acme");
Instant exp = Instant.now().plus(30, ChronoUnit.DAYS);
String payload = """
{"tenantId":"acme","tier":"X","limits":{},"iat":%d,"exp":%d}
""".formatted(Instant.now().getEpochSecond(), exp.getEpochSecond()).trim();
String token = Base64.getEncoder().encodeToString(payload.getBytes()) + "." + sign(kp.getPrivate(), payload);
assertThatThrownBy(() -> validator.validate(token))
.isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("licenseId");
}
}

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.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-license-api/src/main/java/com/cameleer/license/LicenseValidator.java:42`) splits on the first `.`, decodes both halves, verifies the signature, then deserializes the payload.
## LicenseInfo schema
Source: `cameleer-license-api/src/main/java/com/cameleer/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-license-api/src/main/java/com/cameleer/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-license-api` for the pure license contract types (`LicenseInfo`, `LicenseValidator`) used by mint + `--verify`. It deliberately does **not** depend on `cameleer-server-core`, so consumers of the minter (e.g. `cameleer-saas`) do not inherit server-runtime types onto their classpath. 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-license-api</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.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.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.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.license.LicenseInfo;
import com.cameleer.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.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> <groupId>com.cameleer</groupId>
<artifactId>cameleer-server-core</artifactId> <artifactId>cameleer-server-core</artifactId>
</dependency> </dependency>
<dependency>
<groupId>com.cameleer</groupId>
<artifactId>cameleer-license-minter</artifactId>
<version>${project.version}</version>
<scope>test</scope>
</dependency>
<dependency> <dependency>
<groupId>org.springframework.boot</groupId> <groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId> <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.EvalResult;
import com.cameleer.server.app.alerting.eval.TickCache; import com.cameleer.server.app.alerting.eval.TickCache;
import com.cameleer.server.app.alerting.notify.MustacheRenderer; 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.app.web.EnvPath;
import com.cameleer.server.core.admin.AuditCategory; import com.cameleer.server.core.admin.AuditCategory;
import com.cameleer.server.core.admin.AuditResult; import com.cameleer.server.core.admin.AuditResult;
@@ -78,6 +79,7 @@ public class AlertRuleController {
private final Map<ConditionKind, ConditionEvaluator<?>> evaluators; private final Map<ConditionKind, ConditionEvaluator<?>> evaluators;
private final Clock clock; private final Clock clock;
private final String tenantId; private final String tenantId;
private final LicenseEnforcer licenseEnforcer;
@SuppressWarnings("SpringJavaInjectionPointsAutowiringInspection") @SuppressWarnings("SpringJavaInjectionPointsAutowiringInspection")
public AlertRuleController(AlertRuleRepository ruleRepo, public AlertRuleController(AlertRuleRepository ruleRepo,
@@ -86,7 +88,8 @@ public class AlertRuleController {
MustacheRenderer renderer, MustacheRenderer renderer,
List<ConditionEvaluator<?>> evaluatorList, List<ConditionEvaluator<?>> evaluatorList,
Clock alertingClock, Clock alertingClock,
@Value("${cameleer.server.tenant.id:default}") String tenantId) { @Value("${cameleer.server.tenant.id:default}") String tenantId,
LicenseEnforcer licenseEnforcer) {
this.ruleRepo = ruleRepo; this.ruleRepo = ruleRepo;
this.connectionService = connectionService; this.connectionService = connectionService;
this.auditService = auditService; this.auditService = auditService;
@@ -97,6 +100,7 @@ public class AlertRuleController {
} }
this.clock = alertingClock; this.clock = alertingClock;
this.tenantId = tenantId; this.tenantId = tenantId;
this.licenseEnforcer = licenseEnforcer;
} }
// ------------------------------------------------------------------------- // -------------------------------------------------------------------------
@@ -126,6 +130,8 @@ public class AlertRuleController {
@Valid @RequestBody AlertRuleRequest req, @Valid @RequestBody AlertRuleRequest req,
HttpServletRequest httpRequest) { HttpServletRequest httpRequest) {
licenseEnforcer.assertWithinCap("max_alert_rules", ruleRepo.count(), 1);
validateAttributeKeys(req.condition()); validateAttributeKeys(req.condition());
validateBusinessRules(req); validateBusinessRules(req);
validateWebhooks(req.webhooks(), env.id()); 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); 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 @Override
public List<AlertRule> claimDueRules(String instanceId, int batchSize, int claimTtlSeconds) { public List<AlertRule> claimDueRules(String instanceId, int batchSize, int claimTtlSeconds) {
String sql = """ String sql = """

View File

@@ -17,11 +17,13 @@ import org.springframework.context.annotation.Configuration;
public class AgentRegistryBeanConfig { public class AgentRegistryBeanConfig {
@Bean @Bean
public AgentRegistryService agentRegistryService(AgentRegistryConfig config) { public AgentRegistryService agentRegistryService(AgentRegistryConfig config,
com.cameleer.server.app.license.LicenseEnforcer enforcer) {
return new AgentRegistryService( return new AgentRegistryService(
config.getStaleThresholdMs(), config.getStaleThresholdMs(),
config.getDeadThresholdMs(), 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; 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.LicenseGate;
import com.cameleer.server.core.license.LicenseInfo; import com.cameleer.license.LicenseInfo;
import com.cameleer.server.core.license.LicenseValidator; import com.cameleer.license.LicenseValidator;
import jakarta.annotation.PostConstruct;
import org.slf4j.Logger; import org.slf4j.Logger;
import org.slf4j.LoggerFactory; import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Value; import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.ApplicationEventPublisher;
import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration; import org.springframework.context.annotation.Configuration;
import java.nio.file.Files; import java.nio.file.Files;
import java.nio.file.Path; 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 @Configuration
public class LicenseBeanConfig { public class LicenseBeanConfig {
private static final Logger log = LoggerFactory.getLogger(LicenseBeanConfig.class); private static final Logger log = LoggerFactory.getLogger(LicenseBeanConfig.class);
@Value("${cameleer.server.tenant.id:default}")
private String tenantId;
@Value("${cameleer.server.license.token:}") @Value("${cameleer.server.license.token:}")
private String licenseToken; private String licenseToken;
@@ -28,41 +54,77 @@ public class LicenseBeanConfig {
@Bean @Bean
public LicenseGate licenseGate() { public LicenseGate licenseGate() {
LicenseGate gate = new LicenseGate(); return new LicenseGate();
String token = resolveLicenseToken();
if (token == null || token.isBlank()) {
log.info("No license configured — running in open mode (all features enabled)");
return gate;
} }
@Bean
public LicenseValidator licenseValidator() {
if (licensePublicKey == null || licensePublicKey.isBlank()) { if (licensePublicKey == null || licensePublicKey.isBlank()) {
log.warn("License token provided but no public key configured (CAMELEER_SERVER_LICENSE_PUBLICKEY). Running in open mode."); log.warn("CAMELEER_SERVER_LICENSE_PUBLICKEY not set — all licenses will be rejected as INVALID");
return gate; // 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 { try {
LicenseValidator validator = new LicenseValidator(licensePublicKey); KeyPair throwaway = KeyPairGenerator.getInstance("Ed25519").generateKeyPair();
LicenseInfo info = validator.validate(token); String dummyPub = Base64.getEncoder().encodeToString(throwaway.getPublic().getEncoded());
gate.load(info); return new LicenseValidator(dummyPub, tenantId) {
@Override
public LicenseInfo validate(String token) {
throw new IllegalStateException("license public key not configured");
}
};
} catch (Exception e) { } catch (Exception e) {
log.error("Failed to validate license: {}. Running in open mode.", e.getMessage()); throw new IllegalStateException("Failed to construct fallback license validator", e);
}
}
return new LicenseValidator(licensePublicKey, tenantId);
} }
return gate; @Bean
public LicenseService licenseService(LicenseRepository repo,
LicenseGate gate,
LicenseValidator validator,
AuditService audit,
ApplicationEventPublisher events) {
return new LicenseService(tenantId, repo, gate, validator, audit, events);
} }
private String resolveLicenseToken() { @Bean
if (licenseToken != null && !licenseToken.isBlank()) { public LicenseBootLoader licenseBootLoader(LicenseService svc) {
return licenseToken; return new LicenseBootLoader(svc, licenseToken, licenseFile);
} }
if (licenseFile != null && !licenseFile.isBlank()) {
/**
* {@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 { try {
return Files.readString(Path.of(licenseFile)).trim(); file = Optional.of(Files.readString(Path.of(filePath)).trim());
} catch (Exception e) { } catch (Exception e) {
log.warn("Failed to read license file {}: {}", licenseFile, e.getMessage()); log.warn("Failed to read license file {}: {}", filePath, e.getMessage());
} }
} }
return null; svc.loadInitial(env, file);
}
} }
} }

View File

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

View File

@@ -203,4 +203,12 @@ public class StorageBeanConfig {
ClickHouseUsageTracker usageTracker) { ClickHouseUsageTracker usageTracker) {
return new com.cameleer.server.app.analytics.UsageFlushScheduler(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; 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.Environment;
import com.cameleer.server.core.runtime.EnvironmentColor; import com.cameleer.server.core.runtime.EnvironmentColor;
import com.cameleer.server.core.runtime.EnvironmentService; 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.Operation;
import io.swagger.v3.oas.annotations.responses.ApiResponse; import io.swagger.v3.oas.annotations.responses.ApiResponse;
import io.swagger.v3.oas.annotations.tags.Tag; import io.swagger.v3.oas.annotations.tags.Tag;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity; import org.springframework.http.ResponseEntity;
import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.*; import org.springframework.web.bind.annotation.*;
import org.springframework.web.server.ResponseStatusException;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
@@ -21,9 +24,11 @@ import java.util.Map;
public class EnvironmentAdminController { public class EnvironmentAdminController {
private final EnvironmentService environmentService; private final EnvironmentService environmentService;
private final LicenseGate licenseGate;
public EnvironmentAdminController(EnvironmentService environmentService) { public EnvironmentAdminController(EnvironmentService environmentService, LicenseGate licenseGate) {
this.environmentService = environmentService; this.environmentService = environmentService;
this.licenseGate = licenseGate;
} }
@GetMapping @GetMapping
@@ -141,11 +146,24 @@ public class EnvironmentAdminController {
@Operation(summary = "Update JAR retention policy for an environment") @Operation(summary = "Update JAR retention policy for an environment")
@ApiResponse(responseCode = "200", description = "Retention policy updated") @ApiResponse(responseCode = "200", description = "Retention policy updated")
@ApiResponse(responseCode = "404", description = "Environment not found") @ApiResponse(responseCode = "404", description = "Environment not found")
@ApiResponse(responseCode = "422", description = "jarRetentionCount exceeds license cap")
public ResponseEntity<?> updateJarRetention(@PathVariable String envSlug, public ResponseEntity<?> updateJarRetention(@PathVariable String envSlug,
@RequestBody JarRetentionRequest request) { @RequestBody JarRetentionRequest request) {
try { try {
Environment current = environmentService.getBySlug(envSlug); 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)); return ResponseEntity.ok(environmentService.getBySlug(envSlug));
} catch (IllegalArgumentException e) { } catch (IllegalArgumentException e) {
if (e.getMessage().contains("not found")) { if (e.getMessage().contains("not found")) {

View File

@@ -1,51 +1,71 @@
package com.cameleer.server.app.controller; 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.LicenseGate;
import com.cameleer.server.core.license.LicenseInfo; import com.cameleer.license.LicenseInfo;
import com.cameleer.server.core.license.LicenseValidator;
import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag; import io.swagger.v3.oas.annotations.tags.Tag;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.ResponseEntity; import org.springframework.http.ResponseEntity;
import org.springframework.security.access.prepost.PreAuthorize; 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; 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 @RestController
@RequestMapping("/api/v1/admin/license") @RequestMapping("/api/v1/admin/license")
@PreAuthorize("hasRole('ADMIN')") @PreAuthorize("hasRole('ADMIN')")
@Tag(name = "License Admin", description = "License management") @Tag(name = "License Admin", description = "License management")
public class LicenseAdminController { public class LicenseAdminController {
private final LicenseGate licenseGate; private final LicenseService licenseService;
private final String licensePublicKey; private final LicenseGate gate;
private final LicenseRepository repo;
public LicenseAdminController(LicenseGate licenseGate, public LicenseAdminController(LicenseService svc, LicenseGate gate, LicenseRepository repo) {
@Value("${cameleer.server.license.publickey:}") String licensePublicKey) { this.licenseService = svc;
this.licenseGate = licenseGate; this.gate = gate;
this.licensePublicKey = licensePublicKey; this.repo = repo;
} }
@GetMapping @GetMapping
@Operation(summary = "Get current license info") @Operation(summary = "Get current license state, invalid reason, and parsed envelope")
public ResponseEntity<LicenseInfo> getCurrent() { public ResponseEntity<Map<String, Object>> getCurrent() {
return ResponseEntity.ok(licenseGate.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 @PostMapping
@Operation(summary = "Update license token at runtime") @Operation(summary = "Install or replace the license token at runtime")
public ResponseEntity<?> update(@RequestBody UpdateLicenseRequest request) { public ResponseEntity<?> update(@RequestBody UpdateLicenseRequest request, Authentication auth) {
if (licensePublicKey == null || licensePublicKey.isBlank()) { String userId = auth == null ? "system" : auth.getName().replaceFirst("^user:", "");
return ResponseEntity.badRequest().body(Map.of("error", "No license public key configured"));
}
try { try {
LicenseValidator validator = new LicenseValidator(licensePublicKey); LicenseInfo info = licenseService.install(request.token(), userId, "api");
LicenseInfo info = validator.validate(request.token()); return ResponseEntity.ok(Map.of(
licenseGate.load(info); "state", gate.getState().name(),
return ResponseEntity.ok(info); "envelope", info));
} catch (Exception e) { } catch (Exception e) {
return ResponseEntity.badRequest().body(Map.of("error", e.getMessage())); 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

@@ -4,6 +4,7 @@ import com.cameleer.server.app.web.EnvPath;
import com.cameleer.server.core.admin.AppSettings; import com.cameleer.server.core.admin.AppSettings;
import com.cameleer.server.core.admin.AppSettingsRepository; import com.cameleer.server.core.admin.AppSettingsRepository;
import com.cameleer.server.core.runtime.Environment; import com.cameleer.server.core.runtime.Environment;
import com.cameleer.server.core.search.AttributeFilter;
import com.cameleer.server.core.search.ExecutionStats; import com.cameleer.server.core.search.ExecutionStats;
import com.cameleer.server.core.search.ExecutionSummary; import com.cameleer.server.core.search.ExecutionSummary;
import com.cameleer.server.core.search.SearchRequest; import com.cameleer.server.core.search.SearchRequest;
@@ -14,6 +15,7 @@ import com.cameleer.server.core.search.TopError;
import com.cameleer.server.core.storage.StatsStore; import com.cameleer.server.core.storage.StatsStore;
import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag; import io.swagger.v3.oas.annotations.tags.Tag;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity; import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.PostMapping;
@@ -21,8 +23,10 @@ import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController; import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.server.ResponseStatusException;
import java.time.Instant; import java.time.Instant;
import java.util.ArrayList;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
@@ -57,11 +61,19 @@ public class SearchController {
@RequestParam(name = "agentId", required = false) String instanceId, @RequestParam(name = "agentId", required = false) String instanceId,
@RequestParam(required = false) String processorType, @RequestParam(required = false) String processorType,
@RequestParam(required = false) String application, @RequestParam(required = false) String application,
@RequestParam(name = "attr", required = false) List<String> attr,
@RequestParam(defaultValue = "0") int offset, @RequestParam(defaultValue = "0") int offset,
@RequestParam(defaultValue = "50") int limit, @RequestParam(defaultValue = "50") int limit,
@RequestParam(required = false) String sortField, @RequestParam(required = false) String sortField,
@RequestParam(required = false) String sortDir) { @RequestParam(required = false) String sortDir) {
List<AttributeFilter> attributeFilters;
try {
attributeFilters = parseAttrParams(attr);
} catch (IllegalArgumentException e) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, e.getMessage(), e);
}
SearchRequest request = new SearchRequest( SearchRequest request = new SearchRequest(
status, timeFrom, timeTo, status, timeFrom, timeTo,
null, null, null, null,
@@ -72,12 +84,36 @@ public class SearchController {
offset, limit, offset, limit,
sortField, sortDir, sortField, sortDir,
null, null,
env.slug() env.slug(),
attributeFilters
); );
return ResponseEntity.ok(searchService.search(request)); return ResponseEntity.ok(searchService.search(request));
} }
/**
* Parses {@code attr} query params of the form {@code key} (key-only) or {@code key:value}
* (exact or wildcard via {@code *}). Splits on the first {@code :}; later colons are part of
* the value. Blank / null list → empty result. Key validation is delegated to
* {@link AttributeFilter}'s compact constructor, which throws {@link IllegalArgumentException}
* on invalid keys (mapped to 400 by the caller).
*/
static List<AttributeFilter> parseAttrParams(List<String> raw) {
if (raw == null || raw.isEmpty()) return List.of();
List<AttributeFilter> out = new ArrayList<>(raw.size());
for (String entry : raw) {
if (entry == null || entry.isBlank()) continue;
int colon = entry.indexOf(':');
if (colon < 0) {
out.add(new AttributeFilter(entry.trim(), null));
} else {
out.add(new AttributeFilter(entry.substring(0, colon).trim(),
entry.substring(colon + 1)));
}
}
return out;
}
@PostMapping("/executions/search") @PostMapping("/executions/search")
@Operation(summary = "Advanced search with all filters", @Operation(summary = "Advanced search with all filters",
description = "Env from the path overrides any environment field in the body.") description = "Env from the path overrides any environment field in the body.")

View File

@@ -1,6 +1,7 @@
package com.cameleer.server.app.controller; package com.cameleer.server.app.controller;
import com.cameleer.server.app.dto.SetPasswordRequest; 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.AuditCategory;
import com.cameleer.server.core.admin.AuditResult; import com.cameleer.server.core.admin.AuditResult;
import com.cameleer.server.core.admin.AuditService; import com.cameleer.server.core.admin.AuditService;
@@ -52,13 +53,16 @@ public class UserAdminController {
private final RbacService rbacService; private final RbacService rbacService;
private final UserRepository userRepository; private final UserRepository userRepository;
private final AuditService auditService; private final AuditService auditService;
private final LicenseEnforcer licenseEnforcer;
private final boolean oidcEnabled; private final boolean oidcEnabled;
public UserAdminController(RbacService rbacService, UserRepository userRepository, public UserAdminController(RbacService rbacService, UserRepository userRepository,
AuditService auditService, SecurityProperties securityProperties) { AuditService auditService, SecurityProperties securityProperties,
LicenseEnforcer licenseEnforcer) {
this.rbacService = rbacService; this.rbacService = rbacService;
this.userRepository = userRepository; this.userRepository = userRepository;
this.auditService = auditService; this.auditService = auditService;
this.licenseEnforcer = licenseEnforcer;
String issuer = securityProperties.getOidc().getIssuerUri(); String issuer = securityProperties.getOidc().getIssuerUri();
this.oidcEnabled = issuer != null && !issuer.isBlank(); this.oidcEnabled = issuer != null && !issuer.isBlank();
} }
@@ -89,6 +93,9 @@ public class UserAdminController {
@ApiResponse(responseCode = "400", description = "Disabled in OIDC mode") @ApiResponse(responseCode = "400", description = "Disabled in OIDC mode")
public ResponseEntity<?> createUser(@RequestBody CreateUserRequest request, public ResponseEntity<?> createUser(@RequestBody CreateUserRequest request,
HttpServletRequest httpRequest) { 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) { if (oidcEnabled) {
return ResponseEntity.badRequest() return ResponseEntity.badRequest()
.body(Map.of("error", "Local user creation is disabled when OIDC is enabled. Users are provisioned automatically via SSO.")); .body(Map.of("error", "Local user creation is disabled when OIDC is enabled. Users are provisioned automatically via SSO."));

View File

@@ -0,0 +1,23 @@
package com.cameleer.server.app.dto;
import io.swagger.v3.oas.annotations.media.Schema;
@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\"") 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.license.LicenseInfo;
import com.cameleer.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.license.LicenseLimits;
import com.cameleer.server.core.license.LicenseGate;
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.license.LicenseInfo;
import com.cameleer.server.core.license.LicenseGate;
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.license.LicenseInfo;
import com.cameleer.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.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.license.LicenseInfo;
import com.cameleer.license.LicenseValidator;
import com.cameleer.server.core.license.LicenseGate;
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.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; 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.alerting.AlertRuleRepository;
import com.cameleer.server.core.outbound.OutboundConnection; import com.cameleer.server.core.outbound.OutboundConnection;
import com.cameleer.server.core.outbound.OutboundConnectionRepository; import com.cameleer.server.core.outbound.OutboundConnectionRepository;
@@ -18,21 +19,25 @@ public class OutboundConnectionServiceImpl implements OutboundConnectionService
private final OutboundConnectionRepository repo; private final OutboundConnectionRepository repo;
private final AlertRuleRepository ruleRepo; private final AlertRuleRepository ruleRepo;
private final SsrfGuard ssrfGuard; private final SsrfGuard ssrfGuard;
private final LicenseEnforcer licenseEnforcer;
private final String tenantId; private final String tenantId;
public OutboundConnectionServiceImpl( public OutboundConnectionServiceImpl(
OutboundConnectionRepository repo, OutboundConnectionRepository repo,
AlertRuleRepository ruleRepo, AlertRuleRepository ruleRepo,
SsrfGuard ssrfGuard, SsrfGuard ssrfGuard,
LicenseEnforcer licenseEnforcer,
String tenantId) { String tenantId) {
this.repo = repo; this.repo = repo;
this.ruleRepo = ruleRepo; this.ruleRepo = ruleRepo;
this.ssrfGuard = ssrfGuard; this.ssrfGuard = ssrfGuard;
this.licenseEnforcer = licenseEnforcer;
this.tenantId = tenantId; this.tenantId = tenantId;
} }
@Override @Override
public OutboundConnection create(OutboundConnection draft, String actingUserId) { public OutboundConnection create(OutboundConnection draft, String actingUserId) {
licenseEnforcer.assertWithinCap("max_outbound_connections", repo.listByTenant(tenantId).size(), 1);
assertNameUnique(draft.name(), null); assertNameUnique(draft.name(), null);
validateUrl(draft.url()); validateUrl(draft.url());
OutboundConnection c = new OutboundConnection( OutboundConnection c = new OutboundConnection(

View File

@@ -1,5 +1,6 @@
package com.cameleer.server.app.outbound.config; 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.OutboundConnectionServiceImpl;
import com.cameleer.server.app.outbound.SsrfGuard; import com.cameleer.server.app.outbound.SsrfGuard;
import com.cameleer.server.app.outbound.crypto.SecretCipher; import com.cameleer.server.app.outbound.crypto.SecretCipher;
@@ -33,7 +34,8 @@ public class OutboundBeanConfig {
OutboundConnectionRepository repo, OutboundConnectionRepository repo,
AlertRuleRepository ruleRepo, AlertRuleRepository ruleRepo,
SsrfGuard ssrfGuard, SsrfGuard ssrfGuard,
LicenseEnforcer licenseEnforcer,
@Value("${cameleer.server.tenant.id:default}") String tenantId) { @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; package com.cameleer.server.app.runtime;
import com.cameleer.common.model.ApplicationConfig; 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.metrics.ServerMetrics;
import com.cameleer.server.app.storage.PostgresApplicationConfigRepository; import com.cameleer.server.app.storage.PostgresApplicationConfigRepository;
import com.cameleer.server.app.storage.PostgresDeploymentRepository; import com.cameleer.server.app.storage.PostgresDeploymentRepository;
@@ -28,6 +30,8 @@ public class DeploymentExecutor {
private final DeploymentRepository deploymentRepository; private final DeploymentRepository deploymentRepository;
private final PostgresDeploymentRepository pgDeployRepo; private final PostgresDeploymentRepository pgDeployRepo;
private final PostgresApplicationConfigRepository applicationConfigRepository; private final PostgresApplicationConfigRepository applicationConfigRepository;
private final LicenseEnforcer licenseEnforcer;
private final LicenseUsageReader licenseUsageReader;
@Autowired(required = false) @Autowired(required = false)
private DockerNetworkManager networkManager; private DockerNetworkManager networkManager;
@@ -82,7 +86,9 @@ public class DeploymentExecutor {
AppService appService, AppService appService,
EnvironmentService envService, EnvironmentService envService,
DeploymentRepository deploymentRepository, DeploymentRepository deploymentRepository,
PostgresApplicationConfigRepository applicationConfigRepository) { PostgresApplicationConfigRepository applicationConfigRepository,
LicenseEnforcer licenseEnforcer,
LicenseUsageReader licenseUsageReader) {
this.orchestrator = orchestrator; this.orchestrator = orchestrator;
this.deploymentService = deploymentService; this.deploymentService = deploymentService;
this.appService = appService; this.appService = appService;
@@ -90,6 +96,8 @@ public class DeploymentExecutor {
this.deploymentRepository = deploymentRepository; this.deploymentRepository = deploymentRepository;
this.pgDeployRepo = (PostgresDeploymentRepository) deploymentRepository; this.pgDeployRepo = (PostgresDeploymentRepository) deploymentRepository;
this.applicationConfigRepository = applicationConfigRepository; this.applicationConfigRepository = applicationConfigRepository;
this.licenseEnforcer = licenseEnforcer;
this.licenseUsageReader = licenseUsageReader;
} }
/** Deployment-scoped id suffix — distinguishes container names and /** Deployment-scoped id suffix — distinguishes container names and
@@ -147,6 +155,19 @@ public class DeploymentExecutor {
updateStage(deployment.id(), DeployStage.PRE_FLIGHT); updateStage(deployment.id(), DeployStage.PRE_FLIGHT);
preFlightChecks(jarPath, config); 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 // Resolve runtime type
String resolvedRuntimeType = config.runtimeType(); String resolvedRuntimeType = config.runtimeType();
String mainClass = null; 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.async.ResultCallback;
import com.github.dockerjava.api.model.AccessMode; import com.github.dockerjava.api.model.AccessMode;
import com.github.dockerjava.api.model.Bind; 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.Frame;
import com.github.dockerjava.api.model.HealthCheck; import com.github.dockerjava.api.model.HealthCheck;
import com.github.dockerjava.api.model.HostConfig; import com.github.dockerjava.api.model.HostConfig;
@@ -25,12 +26,58 @@ import java.util.stream.Stream;
public class DockerRuntimeOrchestrator implements RuntimeOrchestrator { public class DockerRuntimeOrchestrator implements RuntimeOrchestrator {
private static final Logger log = LoggerFactory.getLogger(DockerRuntimeOrchestrator.class); 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 DockerClient dockerClient;
private final String dockerRuntime;
private ContainerLogForwarder logForwarder; private ContainerLogForwarder logForwarder;
public DockerRuntimeOrchestrator(DockerClient dockerClient) { public DockerRuntimeOrchestrator(DockerClient dockerClient) {
this(dockerClient, "");
}
public DockerRuntimeOrchestrator(DockerClient dockerClient, String runtimeOverride) {
this.dockerClient = dockerClient; 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) { public void setLogForwarder(ContainerLogForwarder logForwarder) {
@@ -68,12 +115,36 @@ public class DockerRuntimeOrchestrator implements RuntimeOrchestrator {
List<String> envList = request.envVars().entrySet().stream() List<String> envList = request.envVars().entrySet().stream()
.map(e -> e.getKey() + "=" + e.getValue()).toList(); .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() HostConfig hostConfig = HostConfig.newHostConfig()
.withMemory(request.memoryLimitBytes()) .withMemory(request.memoryLimitBytes())
.withMemorySwap(request.memoryLimitBytes()) .withMemorySwap(request.memoryLimitBytes())
.withCpuShares(request.cpuShares()) .withCpuShares(request.cpuShares())
.withNetworkMode(request.network()) .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) // JAR mounting: volume mount (Docker-in-Docker) or bind mount (host path)
if (request.jarVolumeName() != null && !request.jarVolumeName().isBlank()) { 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.Logger;
import org.slf4j.LoggerFactory; import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired; 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.Bean;
import org.springframework.context.annotation.Configuration; import org.springframework.context.annotation.Configuration;
@@ -41,10 +42,12 @@ public class RuntimeOrchestratorAutoConfig {
@Bean @Bean
public RuntimeOrchestrator runtimeOrchestrator( public RuntimeOrchestrator runtimeOrchestrator(
@Autowired(required = false) DockerClient dockerClient, @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) { if (dockerClient != null) {
log.info("Docker socket detected - enabling Docker runtime orchestrator"); log.info("Docker socket detected - enabling Docker runtime orchestrator");
DockerRuntimeOrchestrator orchestrator = new DockerRuntimeOrchestrator(dockerClient); DockerRuntimeOrchestrator orchestrator =
new DockerRuntimeOrchestrator(dockerClient, dockerRuntimeOverride);
if (logForwarder != null) { if (logForwarder != null) {
orchestrator.setLogForwarder(logForwarder); orchestrator.setLogForwarder(logForwarder);
} }

View File

@@ -1,6 +1,7 @@
package com.cameleer.server.app.search; package com.cameleer.server.app.search;
import com.cameleer.server.core.alerting.AlertMatchSpec; import com.cameleer.server.core.alerting.AlertMatchSpec;
import com.cameleer.server.core.search.AttributeFilter;
import com.cameleer.server.core.search.ExecutionSummary; import com.cameleer.server.core.search.ExecutionSummary;
import com.cameleer.server.core.search.SearchRequest; import com.cameleer.server.core.search.SearchRequest;
import com.cameleer.server.core.search.SearchResult; import com.cameleer.server.core.search.SearchResult;
@@ -256,6 +257,23 @@ public class ClickHouseSearchIndex implements SearchIndex {
params.add(likeTerm); params.add(likeTerm);
} }
// Structured attribute filters. Keys were validated at AttributeFilter construction
// time against ^[a-zA-Z0-9._-]+$ so they are safe to single-quote-inline; the JSON path
// argument of JSONExtractString does not accept a ? placeholder in ClickHouse JDBC
// (same constraint as countExecutionsForAlerting below). Values are parameter-bound.
for (AttributeFilter filter : request.attributeFilters()) {
String escapedKey = filter.key().replace("'", "\\'");
if (filter.isKeyOnly()) {
conditions.add("JSONHas(attributes, '" + escapedKey + "')");
} else if (filter.isWildcard()) {
conditions.add("JSONExtractString(attributes, '" + escapedKey + "') LIKE ?");
params.add(filter.toLikePattern());
} else {
conditions.add("JSONExtractString(attributes, '" + escapedKey + "') = ?");
params.add(filter.value());
}
}
return String.join(" AND ", conditions); return String.join(" AND ", conditions);
} }

View File

@@ -0,0 +1,52 @@
package com.cameleer.server.app.security;
import com.cameleer.server.app.dto.AuthCapabilitiesResponse;
import com.cameleer.server.core.security.OidcConfig;
import com.cameleer.server.core.security.OidcConfigRepository;
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.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import java.util.Optional;
/**
* Reports auth capabilities so the SPA renders the login page deterministically
* instead of inferring from {@code GET /api/v1/auth/oidc/config} 200/404.
*
* <p>Unauthenticated by design — the SPA calls this before any sign-in attempt.
* Inherits permit-all from the {@code /api/v1/auth/**} matcher in
* {@link SecurityConfig}.
*
* <p>Future deferred work (issue #154) extends this same payload with MFA
* enrollment URL and password-reset URL fields.
*/
@RestController
@RequestMapping("/api/v1/auth")
@Tag(name = "Authentication", description = "Login and token refresh endpoints")
public class AuthCapabilitiesController {
private final OidcConfigRepository oidcConfigRepository;
public AuthCapabilitiesController(OidcConfigRepository oidcConfigRepository) {
this.oidcConfigRepository = oidcConfigRepository;
}
@GetMapping("/capabilities")
@Operation(summary = "Auth capabilities for the SPA login page")
@ApiResponse(responseCode = "200", description = "Capabilities resolved")
public ResponseEntity<AuthCapabilitiesResponse> getCapabilities() {
Optional<OidcConfig> config = oidcConfigRepository.find();
boolean oidcEnabled = config.isPresent() && config.get().enabled();
String providerName = oidcEnabled
? OidcProviderNameDeriver.deriveName(config.get().issuerUri())
: "";
var oidc = new AuthCapabilitiesResponse.Oidc(oidcEnabled, providerName, oidcEnabled);
var local = new AuthCapabilitiesResponse.LocalAccounts(true, oidcEnabled);
return ResponseEntity.ok(new AuthCapabilitiesResponse(oidc, local));
}
}

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.AuthTokenResponse;
import com.cameleer.server.app.dto.ErrorResponse; import com.cameleer.server.app.dto.ErrorResponse;
import com.cameleer.server.app.dto.OidcPublicConfigResponse; 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.AuditCategory;
import com.cameleer.server.core.admin.AuditResult; import com.cameleer.server.core.admin.AuditResult;
import com.cameleer.server.core.admin.AuditService; import com.cameleer.server.core.admin.AuditService;
@@ -63,6 +64,7 @@ public class OidcAuthController {
private final ClaimMappingService claimMappingService; private final ClaimMappingService claimMappingService;
private final ClaimMappingRepository claimMappingRepository; private final ClaimMappingRepository claimMappingRepository;
private final GroupRepository groupRepository; private final GroupRepository groupRepository;
private final LicenseEnforcer licenseEnforcer;
public OidcAuthController(OidcTokenExchanger tokenExchanger, public OidcAuthController(OidcTokenExchanger tokenExchanger,
OidcConfigRepository configRepository, OidcConfigRepository configRepository,
@@ -72,7 +74,8 @@ public class OidcAuthController {
RbacService rbacService, RbacService rbacService,
ClaimMappingService claimMappingService, ClaimMappingService claimMappingService,
ClaimMappingRepository claimMappingRepository, ClaimMappingRepository claimMappingRepository,
GroupRepository groupRepository) { GroupRepository groupRepository,
LicenseEnforcer licenseEnforcer) {
this.tokenExchanger = tokenExchanger; this.tokenExchanger = tokenExchanger;
this.configRepository = configRepository; this.configRepository = configRepository;
this.jwtService = jwtService; this.jwtService = jwtService;
@@ -82,6 +85,7 @@ public class OidcAuthController {
this.claimMappingService = claimMappingService; this.claimMappingService = claimMappingService;
this.claimMappingRepository = claimMappingRepository; this.claimMappingRepository = claimMappingRepository;
this.groupRepository = groupRepository; this.groupRepository = groupRepository;
this.licenseEnforcer = licenseEnforcer;
} }
/** /**
@@ -154,6 +158,13 @@ public class OidcAuthController {
"Account not provisioned. Contact your administrator."); "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( userRepository.upsert(new UserInfo(
userId, provider, oidcUser.email(), oidcUser.name(), Instant.now())); 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)); (rs, rowNum) -> mapRow(rs));
} }
@Override
public long count() {
Long n = jdbc.queryForObject("SELECT COUNT(*) FROM apps", Long.class);
return n == null ? 0L : n;
}
@Override @Override
public void updateContainerConfig(UUID id, Map<String, Object> containerConfig) { public void updateContainerConfig(UUID id, Map<String, Object> containerConfig) {
try { try {

View File

@@ -26,7 +26,8 @@ public class PostgresEnvironmentRepository implements EnvironmentRepository {
} }
private static final String SELECT_COLS = 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 @Override
public List<Environment> findAll() { public List<Environment> findAll() {
@@ -35,6 +36,11 @@ public class PostgresEnvironmentRepository implements EnvironmentRepository {
(rs, rowNum) -> mapRow(rs)); (rs, rowNum) -> mapRow(rs));
} }
@Override
public long count() {
return jdbc.queryForObject("SELECT COUNT(*) FROM environments", Long.class);
}
@Override @Override
public Optional<Environment> findById(UUID id) { public Optional<Environment> findById(UUID id) {
var results = jdbc.query( var results = jdbc.query(
@@ -108,7 +114,10 @@ public class PostgresEnvironmentRepository implements EnvironmentRepository {
config, config,
jarRetentionCount, jarRetentionCount,
color, 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); 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 { private UserInfo mapUser(java.sql.ResultSet rs) throws java.sql.SQLException {
java.sql.Timestamp ts = rs.getTimestamp("created_at"); java.sql.Timestamp ts = rs.getTimestamp("created_at");
java.time.Instant createdAt = ts != null ? ts.toInstant() : null; java.time.Instant createdAt = ts != null ? ts.toInstant() : null;

View File

@@ -47,6 +47,11 @@ cameleer:
jarstoragepath: ${CAMELEER_SERVER_RUNTIME_JARSTORAGEPATH:/data/jars} jarstoragepath: ${CAMELEER_SERVER_RUNTIME_JARSTORAGEPATH:/data/jars}
baseimage: ${CAMELEER_SERVER_RUNTIME_BASEIMAGE:gitea.siegeln.net/cameleer/cameleer-runtime-base:latest} baseimage: ${CAMELEER_SERVER_RUNTIME_BASEIMAGE:gitea.siegeln.net/cameleer/cameleer-runtime-base:latest}
dockernetwork: ${CAMELEER_SERVER_RUNTIME_DOCKERNETWORK:cameleer} 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 agenthealthport: 9464
healthchecktimeout: 60 healthchecktimeout: 60
container: 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; package com.cameleer.server.app;
import com.cameleer.server.core.agent.AgentRegistryService; import com.cameleer.server.core.agent.AgentRegistryService;
import com.cameleer.server.core.license.LicenseGate;
import com.cameleer.license.LicenseInfo;
import com.cameleer.server.core.security.JwtService; import com.cameleer.server.core.security.JwtService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.HttpHeaders; import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType; import org.springframework.http.MediaType;
import org.springframework.stereotype.Component; import org.springframework.stereotype.Component;
import java.time.Instant;
import java.time.temporal.ChronoUnit;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
import java.util.UUID;
/** /**
* Test utility for creating JWT-authenticated requests in integration tests. * Test utility for creating JWT-authenticated requests in integration tests.
@@ -20,10 +26,39 @@ public class TestSecurityHelper {
private final JwtService jwtService; private final JwtService jwtService;
private final AgentRegistryService agentRegistryService; 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.jwtService = jwtService;
this.agentRegistryService = agentRegistryService; 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()); .dynamicHttpsPort());
wm.start(); 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 // Default clock behaviour: delegate to simulatedNow
stubClock(); stubClock();
@@ -145,6 +150,7 @@ class AlertingFullLifecycleIT extends AbstractPostgresIT {
@AfterAll @AfterAll
void cleanupFixtures() { void cleanupFixtures() {
securityHelper.clearTestLicense();
if (wm != null) wm.stop(); if (wm != null) wm.stop();
jdbcTemplate.update("DELETE FROM alert_silences WHERE environment_id = ?", envId); 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); 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 { void setUp() throws Exception {
when(agentRegistryService.findAll()).thenReturn(List.of()); 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(); adminJwt = securityHelper.adminToken();
operatorJwt = securityHelper.operatorToken(); operatorJwt = securityHelper.operatorToken();
@@ -93,6 +100,7 @@ class OutboundConnectionAllowedEnvIT extends AbstractPostgresIT {
@AfterEach @AfterEach
void cleanUp() { void cleanUp() {
securityHelper.clearTestLicense();
jdbcTemplate.update("DELETE FROM alert_rules WHERE environment_id IN (?, ?, ?)", envIdA, envIdB, envIdC); 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 outbound_connections WHERE id = ?", connId);
jdbcTemplate.update("DELETE FROM environments WHERE id IN (?, ?, ?)", envIdA, envIdB, envIdC); 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-operator");
seedUser("test-viewer"); 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 // Create a test environment
envSlug = "test-env-" + UUID.randomUUID().toString().substring(0, 8); envSlug = "test-env-" + UUID.randomUUID().toString().substring(0, 8);
envId = UUID.randomUUID(); envId = UUID.randomUUID();
@@ -54,6 +59,7 @@ class AlertRuleControllerIT extends AbstractPostgresIT {
@AfterEach @AfterEach
void cleanUp() { void cleanUp() {
securityHelper.clearTestLicense();
jdbcTemplate.update("DELETE FROM alert_rules WHERE environment_id = ?", envId); jdbcTemplate.update("DELETE FROM alert_rules WHERE environment_id = ?", envId);
jdbcTemplate.update("DELETE FROM environments WHERE id = ?", envId); jdbcTemplate.update("DELETE FROM environments WHERE id = ?", envId);
jdbcTemplate.update("DELETE FROM users WHERE user_id IN ('test-operator','test-viewer')"); 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); events = mock(AgentEventRepository.class);
envRepo = mock(EnvironmentRepository.class); envRepo = mock(EnvironmentRepository.class);
when(envRepo.findById(ENV_ID)).thenReturn(Optional.of( 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); 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); null, null, null, null, null, null, null, null, null, null, null, null, null, null);
eval = new ExchangeMatchEvaluator(searchIndex, envRepo, props); 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)); when(envRepo.findById(ENV_ID)).thenReturn(Optional.of(env));
} }

View File

@@ -35,7 +35,7 @@ class LogPatternEvaluatorTest {
envRepo = mock(EnvironmentRepository.class); envRepo = mock(EnvironmentRepository.class);
eval = new LogPatternEvaluator(logStore, envRepo); 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)); when(envRepo.findById(ENV_ID)).thenReturn(Optional.of(env));
} }

View File

@@ -36,7 +36,7 @@ class RouteMetricEvaluatorTest {
envRepo = mock(EnvironmentRepository.class); envRepo = mock(EnvironmentRepository.class);
eval = new RouteMetricEvaluator(statsStore, envRepo); 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)); when(envRepo.findById(ENV_ID)).thenReturn(Optional.of(env));
} }

View File

@@ -28,7 +28,7 @@ class NotificationContextBuilderTest {
// ---- helpers ---- // ---- helpers ----
private Environment env() { 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) { private AlertRule rule(ConditionKind kind) {

View File

@@ -115,6 +115,91 @@ class SchemaBootstrapIT extends AbstractPostgresIT {
assertThat(isUnique).isTrue(); 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 @Test
void deleting_environment_cascades_alerting_rows() { void deleting_environment_cascades_alerting_rows() {
testEnvId = UUID.randomUUID(); testEnvId = UUID.randomUUID();

View File

@@ -4,6 +4,7 @@ import com.cameleer.server.app.AbstractPostgresIT;
import com.cameleer.server.app.TestSecurityHelper; import com.cameleer.server.app.TestSecurityHelper;
import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired; 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.HttpStatus;
import org.springframework.http.ResponseEntity; import org.springframework.http.ResponseEntity;
import java.util.Map;
import java.util.UUID; import java.util.UUID;
import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThat;
@@ -33,10 +35,18 @@ class AgentCommandControllerIT extends AbstractPostgresIT {
@BeforeEach @BeforeEach
void setUp() { 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"); agentJwt = securityHelper.registerTestAgent("test-agent-command-it");
operatorJwt = securityHelper.operatorToken(); operatorJwt = securityHelper.operatorToken();
} }
@AfterEach
void tearDown() {
securityHelper.clearTestLicense();
}
private ResponseEntity<String> registerAgent(String agentId, String name, String application) { private ResponseEntity<String> registerAgent(String agentId, String name, String application) {
String json = """ String json = """
{ {

View File

@@ -4,6 +4,7 @@ import com.cameleer.server.app.AbstractPostgresIT;
import com.cameleer.server.app.TestSecurityHelper; import com.cameleer.server.app.TestSecurityHelper;
import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired; 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.HttpStatus;
import org.springframework.http.ResponseEntity; import org.springframework.http.ResponseEntity;
import java.util.Map;
import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThat;
class AgentRegistrationControllerIT extends AbstractPostgresIT { class AgentRegistrationControllerIT extends AbstractPostgresIT {
@@ -31,10 +34,18 @@ class AgentRegistrationControllerIT extends AbstractPostgresIT {
@BeforeEach @BeforeEach
void setUp() { 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"); jwt = securityHelper.registerTestAgent("test-agent-registration-it");
viewerJwt = securityHelper.viewerToken(); viewerJwt = securityHelper.viewerToken();
} }
@AfterEach
void tearDown() {
securityHelper.clearTestLicense();
}
private ResponseEntity<String> registerAgent(String agentId, String name) { private ResponseEntity<String> registerAgent(String agentId, String name) {
String json = """ 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.AbstractPostgresIT;
import com.cameleer.server.app.TestSecurityHelper; import com.cameleer.server.app.TestSecurityHelper;
import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Autowired;
@@ -20,6 +21,7 @@ import java.net.http.HttpResponse;
import java.time.Duration; import java.time.Duration;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.List; import java.util.List;
import java.util.Map;
import java.util.UUID; import java.util.UUID;
import java.util.concurrent.CompletableFuture; import java.util.concurrent.CompletableFuture;
import java.util.concurrent.CountDownLatch; import java.util.concurrent.CountDownLatch;
@@ -48,10 +50,18 @@ class AgentSseControllerIT extends AbstractPostgresIT {
@BeforeEach @BeforeEach
void setUp() { 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"); jwt = securityHelper.registerTestAgent("test-agent-sse-it");
operatorJwt = securityHelper.operatorToken(); operatorJwt = securityHelper.operatorToken();
} }
@AfterEach
void tearDown() {
securityHelper.clearTestLicense();
}
private ResponseEntity<String> registerAgent(String agentId, String name, String application) { private ResponseEntity<String> registerAgent(String agentId, String name, String application) {
String json = """ String json = """
{ {

View File

@@ -0,0 +1,96 @@
package com.cameleer.server.app.controller;
import com.cameleer.server.app.AbstractPostgresIT;
import com.cameleer.server.app.dto.AuthCapabilitiesResponse;
import com.cameleer.server.core.security.OidcConfig;
import com.cameleer.server.core.security.OidcConfigRepository;
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 java.util.List;
import java.util.Optional;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.Mockito.when;
/**
* Integration tests for {@link com.cameleer.server.app.security.AuthCapabilitiesController}.
* Mocks {@link OidcConfigRepository} so each test controls the OIDC state it observes.
*/
class AuthCapabilitiesControllerIT extends AbstractPostgresIT {
@Autowired private TestRestTemplate restTemplate;
@MockBean private OidcConfigRepository oidcConfigRepository;
@BeforeEach
void resetMock() {
when(oidcConfigRepository.find()).thenReturn(Optional.empty());
}
@Test
void noOidcConfig_returnsLocalOnlyCaps() {
var resp = restTemplate.getForEntity("/api/v1/auth/capabilities", AuthCapabilitiesResponse.class);
assertThat(resp.getStatusCode().value()).isEqualTo(200);
assertThat(resp.getBody()).isNotNull();
assertThat(resp.getBody().oidc().enabled()).isFalse();
assertThat(resp.getBody().oidc().providerName()).isEqualTo("");
assertThat(resp.getBody().oidc().primary()).isFalse();
assertThat(resp.getBody().localAccounts().enabled()).isTrue();
assertThat(resp.getBody().localAccounts().adminRecoveryOnly()).isFalse();
}
@Test
void oidcDisabledRow_behavesLikeAbsent() {
OidcConfig disabled = new OidcConfig(false, "https://auth.logto.example/", "client-id", "secret",
"roles", List.of("VIEWER"), true, "name", "sub", "", List.of());
when(oidcConfigRepository.find()).thenReturn(Optional.of(disabled));
var resp = restTemplate.getForEntity("/api/v1/auth/capabilities", AuthCapabilitiesResponse.class);
assertThat(resp.getStatusCode().value()).isEqualTo(200);
assertThat(resp.getBody().oidc().enabled()).isFalse();
assertThat(resp.getBody().oidc().providerName()).isEqualTo("");
assertThat(resp.getBody().oidc().primary()).isFalse();
assertThat(resp.getBody().localAccounts().adminRecoveryOnly()).isFalse();
}
@Test
void oidcEnabledLogto_returnsOidcPrimaryWithProviderName() {
OidcConfig enabled = new OidcConfig(true, "https://auth.logto.example/", "client-id", "secret",
"roles", List.of("VIEWER"), true, "name", "sub", "", List.of());
when(oidcConfigRepository.find()).thenReturn(Optional.of(enabled));
var resp = restTemplate.getForEntity("/api/v1/auth/capabilities", AuthCapabilitiesResponse.class);
assertThat(resp.getStatusCode().value()).isEqualTo(200);
assertThat(resp.getBody().oidc().enabled()).isTrue();
assertThat(resp.getBody().oidc().providerName()).isEqualTo("Logto");
assertThat(resp.getBody().oidc().primary()).isTrue();
assertThat(resp.getBody().localAccounts().enabled()).isTrue();
assertThat(resp.getBody().localAccounts().adminRecoveryOnly()).isTrue();
}
@Test
void oidcEnabledUnknownProvider_returnsGenericProviderName() {
OidcConfig enabled = new OidcConfig(true, "https://idp.example.com/", "client-id", "secret",
"roles", List.of("VIEWER"), true, "name", "sub", "", List.of());
when(oidcConfigRepository.find()).thenReturn(Optional.of(enabled));
var resp = restTemplate.getForEntity("/api/v1/auth/capabilities", AuthCapabilitiesResponse.class);
assertThat(resp.getStatusCode().value()).isEqualTo(200);
assertThat(resp.getBody().oidc().providerName()).isEqualTo("Single Sign-On");
assertThat(resp.getBody().oidc().primary()).isTrue();
assertThat(resp.getBody().localAccounts().adminRecoveryOnly()).isTrue();
}
@Test
void endpointIsUnauthenticated() {
var resp = restTemplate.getForEntity("/api/v1/auth/capabilities", String.class);
assertThat(resp.getStatusCode().value()).isEqualTo(200);
}
}

View File

@@ -3,6 +3,7 @@ package com.cameleer.server.app.controller;
import com.cameleer.server.app.AbstractPostgresIT; import com.cameleer.server.app.AbstractPostgresIT;
import com.cameleer.server.app.TestSecurityHelper; import com.cameleer.server.app.TestSecurityHelper;
import com.cameleer.server.core.ingestion.IngestionService; import com.cameleer.server.core.ingestion.IngestionService;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Autowired;
@@ -13,6 +14,8 @@ import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity; import org.springframework.http.ResponseEntity;
import org.springframework.test.context.TestPropertySource; import org.springframework.test.context.TestPropertySource;
import java.util.Map;
import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThat;
/** /**
@@ -45,10 +48,18 @@ class BackpressureIT extends AbstractPostgresIT {
@BeforeEach @BeforeEach
void setUp() { 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"); String jwt = securityHelper.registerTestAgent("test-agent-backpressure-it");
authHeaders = securityHelper.authHeaders(jwt); authHeaders = securityHelper.authHeaders(jwt);
} }
@AfterEach
void tearDown() {
securityHelper.clearTestLicense();
}
@Test @Test
void whenMetricsBufferFull_returns503WithRetryAfter() { void whenMetricsBufferFull_returns503WithRetryAfter() {
// Fill the metrics buffer completely with a batch of 5 // 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")); aliceJwt = securityHelper.createToken("user:alice", "user", List.of("OPERATOR"));
adminJwt = securityHelper.adminToken(); 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 // Clean up deployment-related tables and test-created environments
jdbcTemplate.update("DELETE FROM deployments"); jdbcTemplate.update("DELETE FROM deployments");
jdbcTemplate.update("DELETE FROM app_versions"); jdbcTemplate.update("DELETE FROM app_versions");
@@ -90,6 +100,11 @@ class DeploymentControllerAuditIT extends AbstractPostgresIT {
versionId = objectMapper.readTree(versionResponse.getBody()).path("id").asText(); versionId = objectMapper.readTree(versionResponse.getBody()).path("id").asText();
} }
@org.junit.jupiter.api.AfterEach
void tearDown() {
securityHelper.clearTestLicense();
}
@Test @Test
void deploy_writes_audit_row_with_DEPLOYMENT_category_and_alice_actor() throws Exception { void deploy_writes_audit_row_with_DEPLOYMENT_category_and_alice_actor() throws Exception {
String json = String.format(""" String json = String.format("""

View File

@@ -4,6 +4,7 @@ import com.cameleer.server.app.AbstractPostgresIT;
import com.cameleer.server.app.TestSecurityHelper; import com.cameleer.server.app.TestSecurityHelper;
import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.AfterAll;
import org.junit.jupiter.api.BeforeAll; import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.TestInstance; import org.junit.jupiter.api.TestInstance;
@@ -15,6 +16,8 @@ import org.springframework.http.HttpMethod;
import org.springframework.http.HttpStatus; import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity; import org.springframework.http.ResponseEntity;
import java.util.Map;
import static java.util.concurrent.TimeUnit.SECONDS; import static java.util.concurrent.TimeUnit.SECONDS;
import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThat;
import static org.awaitility.Awaitility.await; import static org.awaitility.Awaitility.await;
@@ -49,6 +52,9 @@ class DetailControllerIT extends AbstractPostgresIT {
*/ */
@BeforeAll @BeforeAll
void seedTestData() { 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"); jwt = securityHelper.registerTestAgent("test-agent-detail-it");
viewerJwt = securityHelper.viewerToken(); viewerJwt = securityHelper.viewerToken();
@@ -231,4 +237,9 @@ class DetailControllerIT extends AbstractPostgresIT {
new HttpEntity<>(headers), new HttpEntity<>(headers),
String.class); 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.AbstractPostgresIT;
import com.cameleer.server.app.TestSecurityHelper; import com.cameleer.server.app.TestSecurityHelper;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired; 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.HttpStatus;
import org.springframework.http.ResponseEntity; import org.springframework.http.ResponseEntity;
import java.util.Map;
import static java.util.concurrent.TimeUnit.SECONDS; import static java.util.concurrent.TimeUnit.SECONDS;
import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThat;
import static org.awaitility.Awaitility.await; import static org.awaitility.Awaitility.await;
@@ -29,11 +32,19 @@ class DiagramControllerIT extends AbstractPostgresIT {
@BeforeEach @BeforeEach
void setUp() { 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"); String jwt = securityHelper.registerTestAgent("test-agent-diagram-it");
authHeaders = securityHelper.authHeaders(jwt); authHeaders = securityHelper.authHeaders(jwt);
viewerHeaders = securityHelper.authHeadersNoBody(securityHelper.viewerToken()); viewerHeaders = securityHelper.authHeadersNoBody(securityHelper.viewerToken());
} }
@AfterEach
void tearDown() {
securityHelper.clearTestLicense();
}
@Test @Test
void postSingleDiagram_returns202() { void postSingleDiagram_returns202() {
String json = """ String json = """

View File

@@ -4,6 +4,7 @@ import com.cameleer.server.app.AbstractPostgresIT;
import com.cameleer.server.app.TestSecurityHelper; import com.cameleer.server.app.TestSecurityHelper;
import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired; 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.HttpStatus;
import org.springframework.http.ResponseEntity; import org.springframework.http.ResponseEntity;
import java.util.Map;
import static java.util.concurrent.TimeUnit.SECONDS; import static java.util.concurrent.TimeUnit.SECONDS;
import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThat;
import static org.awaitility.Awaitility.await; import static org.awaitility.Awaitility.await;
@@ -41,6 +44,9 @@ class DiagramRenderControllerIT extends AbstractPostgresIT {
@BeforeEach @BeforeEach
void seedDiagram() { 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"); jwt = securityHelper.registerTestAgent("test-agent-diagram-render-it");
viewerJwt = securityHelper.viewerToken(); viewerJwt = securityHelper.viewerToken();
@@ -115,6 +121,11 @@ class DiagramRenderControllerIT extends AbstractPostgresIT {
}); });
} }
@AfterEach
void tearDown() {
securityHelper.clearTestLicense();
}
@Test @Test
void getSvg_withAcceptHeader_returnsSvg() { void getSvg_withAcceptHeader_returnsSvg() {
HttpHeaders headers = securityHelper.authHeadersNoBody(viewerJwt); HttpHeaders headers = securityHelper.authHeadersNoBody(viewerJwt);

View File

@@ -35,8 +35,21 @@ class EnvironmentAdminControllerIT extends AbstractPostgresIT {
adminJwt = securityHelper.adminToken(); adminJwt = securityHelper.adminToken();
viewerJwt = securityHelper.viewerToken(); viewerJwt = securityHelper.viewerToken();
operatorJwt = securityHelper.operatorToken(); 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'"); 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 @Test
@@ -92,6 +105,25 @@ class EnvironmentAdminControllerIT extends AbstractPostgresIT {
assertThat(body.has("id")).isTrue(); 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 @Test
void updateEnvironment_withValidColor_persists() throws Exception { void updateEnvironment_withValidColor_persists() throws Exception {
restTemplate.exchange( restTemplate.exchange(

View File

@@ -4,6 +4,7 @@ import com.cameleer.server.app.AbstractPostgresIT;
import com.cameleer.server.app.TestSecurityHelper; import com.cameleer.server.app.TestSecurityHelper;
import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired; 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.HttpStatus;
import org.springframework.http.ResponseEntity; import org.springframework.http.ResponseEntity;
import java.util.Map;
import static java.util.concurrent.TimeUnit.SECONDS; import static java.util.concurrent.TimeUnit.SECONDS;
import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThat;
import static org.awaitility.Awaitility.await; import static org.awaitility.Awaitility.await;
@@ -38,11 +41,19 @@ class ExecutionControllerIT extends AbstractPostgresIT {
@BeforeEach @BeforeEach
void setUp() { 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"); String jwt = securityHelper.registerTestAgent("test-agent-execution-it");
authHeaders = securityHelper.authHeaders(jwt); authHeaders = securityHelper.authHeaders(jwt);
viewerHeaders = securityHelper.authHeadersNoBody(securityHelper.viewerToken()); viewerHeaders = securityHelper.authHeadersNoBody(securityHelper.viewerToken());
} }
@AfterEach
void tearDown() {
securityHelper.clearTestLicense();
}
@Test @Test
void postSingleExecution_returns202() { void postSingleExecution_returns202() {
String json = """ 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.AbstractPostgresIT;
import com.cameleer.server.app.TestSecurityHelper; import com.cameleer.server.app.TestSecurityHelper;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired; 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.HttpHeaders;
import org.springframework.http.HttpMethod; import org.springframework.http.HttpMethod;
import java.util.Map;
import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThat;
/** /**
@@ -28,9 +31,17 @@ class ForwardCompatIT extends AbstractPostgresIT {
@BeforeEach @BeforeEach
void setUp() { 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"); jwt = securityHelper.registerTestAgent("test-agent-forward-compat-it");
} }
@AfterEach
void tearDown() {
securityHelper.clearTestLicense();
}
@Test @Test
void unknownFieldsInRequestBodyDoNotCauseError() { void unknownFieldsInRequestBodyDoNotCauseError() {
// Valid ExecutionChunk plus extra fields a future agent version // 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.cameleer.server.app.TestSecurityHelper;
import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired; 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.HttpStatus;
import org.springframework.http.ResponseEntity; import org.springframework.http.ResponseEntity;
import java.util.Map;
import static java.util.concurrent.TimeUnit.SECONDS; import static java.util.concurrent.TimeUnit.SECONDS;
import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThat;
import static org.awaitility.Awaitility.await; import static org.awaitility.Awaitility.await;
@@ -34,12 +37,20 @@ class MetricsControllerIT extends AbstractPostgresIT {
@BeforeEach @BeforeEach
void setUp() { 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"; agentId = "test-agent-metrics-it";
String jwt = securityHelper.registerTestAgent(agentId); String jwt = securityHelper.registerTestAgent(agentId);
authHeaders = securityHelper.authHeaders(jwt); authHeaders = securityHelper.authHeaders(jwt);
viewerHeaders = securityHelper.authHeadersNoBody(securityHelper.viewerToken()); viewerHeaders = securityHelper.authHeadersNoBody(securityHelper.viewerToken());
} }
@AfterEach
void tearDown() {
securityHelper.clearTestLicense();
}
@Test @Test
void postMetrics_returns202() { void postMetrics_returns202() {
String json = """ String json = """

View File

@@ -14,6 +14,8 @@ import org.springframework.http.HttpMethod;
import org.springframework.http.HttpStatus; import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity; import org.springframework.http.ResponseEntity;
import java.util.Map;
import static java.util.concurrent.TimeUnit.SECONDS; import static java.util.concurrent.TimeUnit.SECONDS;
import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThat;
import static org.awaitility.Awaitility.await; import static org.awaitility.Awaitility.await;
@@ -42,6 +44,10 @@ class SearchControllerIT extends AbstractPostgresIT {
*/ */
@BeforeEach @BeforeEach
void seedTestData() { 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; if (seeded) return;
seeded = true; seeded = true;
jwt = securityHelper.registerTestAgent("test-agent-search-it"); jwt = securityHelper.registerTestAgent("test-agent-search-it");
@@ -166,6 +172,42 @@ class SearchControllerIT extends AbstractPostgresIT {
""", i, i, i, i, i)); """, i, i, i, i, i));
} }
// Executions 11-12: carry structured attributes used by the attribute-filter tests.
ingest("""
{
"exchangeId": "ex-search-attr-1",
"applicationId": "test-group",
"instanceId": "test-agent-search-it",
"routeId": "search-route-attr-1",
"correlationId": "corr-attr-alpha",
"status": "COMPLETED",
"startTime": "2026-03-12T10:00:00Z",
"endTime": "2026-03-12T10:00:00.050Z",
"durationMs": 50,
"attributes": {"order": "12345", "tenant": "acme"},
"chunkSeq": 0,
"final": true,
"processors": []
}
""");
ingest("""
{
"exchangeId": "ex-search-attr-2",
"applicationId": "test-group",
"instanceId": "test-agent-search-it",
"routeId": "search-route-attr-2",
"correlationId": "corr-attr-beta",
"status": "COMPLETED",
"startTime": "2026-03-12T10:01:00Z",
"endTime": "2026-03-12T10:01:00.050Z",
"durationMs": 50,
"attributes": {"order": "99999"},
"chunkSeq": 0,
"final": true,
"processors": []
}
""");
// Wait for async ingestion + search indexing via REST (no raw SQL). // Wait for async ingestion + search indexing via REST (no raw SQL).
// Probe the last seeded execution to avoid false positives from // Probe the last seeded execution to avoid false positives from
// other test classes that may have written into the shared CH tables. // other test classes that may have written into the shared CH tables.
@@ -174,6 +216,11 @@ class SearchControllerIT extends AbstractPostgresIT {
JsonNode body = objectMapper.readTree(r.getBody()); JsonNode body = objectMapper.readTree(r.getBody());
assertThat(body.get("total").asLong()).isGreaterThanOrEqualTo(1); assertThat(body.get("total").asLong()).isGreaterThanOrEqualTo(1);
}); });
await().atMost(30, SECONDS).untilAsserted(() -> {
ResponseEntity<String> r = searchGet("?correlationId=corr-attr-beta");
JsonNode body = objectMapper.readTree(r.getBody());
assertThat(body.get("total").asLong()).isGreaterThanOrEqualTo(1);
});
} }
@Test @Test
@@ -371,6 +418,69 @@ class SearchControllerIT extends AbstractPostgresIT {
assertThat(body.get("limit").asInt()).isEqualTo(50); assertThat(body.get("limit").asInt()).isEqualTo(50);
} }
@Test
void attrParam_exactMatch_filtersToMatchingExecution() throws Exception {
ResponseEntity<String> response = searchGet("?attr=order:12345&correlationId=corr-attr-alpha");
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
JsonNode body = objectMapper.readTree(response.getBody());
assertThat(body.get("total").asLong()).isEqualTo(1);
assertThat(body.get("data").get(0).get("correlationId").asText()).isEqualTo("corr-attr-alpha");
}
@Test
void attrParam_keyOnly_matchesAnyExecutionCarryingTheKey() throws Exception {
ResponseEntity<String> response = searchGet("?attr=tenant&correlationId=corr-attr-alpha");
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
JsonNode body = objectMapper.readTree(response.getBody());
assertThat(body.get("total").asLong()).isEqualTo(1);
assertThat(body.get("data").get(0).get("correlationId").asText()).isEqualTo("corr-attr-alpha");
}
@Test
void attrParam_multipleValues_produceIntersection() throws Exception {
// order:99999 AND tenant=* should yield zero — exec-attr-2 has order=99999 but no tenant.
ResponseEntity<String> response = searchGet("?attr=order:99999&attr=tenant");
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
JsonNode body = objectMapper.readTree(response.getBody());
assertThat(body.get("total").asLong()).isZero();
}
@Test
void attrParam_invalidKey_returns400() throws Exception {
ResponseEntity<String> response = searchGet("?attr=bad%20key:x");
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST);
}
@Test
void attributeFilters_inPostBody_filtersCorrectly() throws Exception {
ResponseEntity<String> response = searchPost("""
{
"attributeFilters": [
{"key": "order", "value": "12345"}
],
"correlationId": "corr-attr-alpha"
}
""");
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
JsonNode body = objectMapper.readTree(response.getBody());
assertThat(body.get("total").asLong()).isEqualTo(1);
assertThat(body.get("data").get(0).get("correlationId").asText()).isEqualTo("corr-attr-alpha");
}
@Test
void attrParam_wildcardValue_matchesOnPrefix() throws Exception {
ResponseEntity<String> response = searchGet("?attr=order:1*&correlationId=corr-attr-alpha");
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
JsonNode body = objectMapper.readTree(response.getBody());
assertThat(body.get("total").asLong()).isEqualTo(1);
assertThat(body.get("data").get(0).get("correlationId").asText()).isEqualTo("corr-attr-alpha");
}
// --- Helper methods --- // --- Helper methods ---
private void ingest(String json) { private void ingest(String json) {

View File

@@ -2,6 +2,7 @@ package com.cameleer.server.app.interceptor;
import com.cameleer.server.app.AbstractPostgresIT; import com.cameleer.server.app.AbstractPostgresIT;
import com.cameleer.server.app.TestSecurityHelper; import com.cameleer.server.app.TestSecurityHelper;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired; 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.HttpMethod;
import org.springframework.http.MediaType; import org.springframework.http.MediaType;
import java.util.Map;
import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThat;
/** /**
@@ -30,9 +33,17 @@ class ProtocolVersionIT extends AbstractPostgresIT {
@BeforeEach @BeforeEach
void setUp() { 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"); jwt = securityHelper.registerTestAgent("test-agent-protocol-it");
} }
@AfterEach
void tearDown() {
securityHelper.clearTestLicense();
}
@Test @Test
void requestWithoutProtocolHeaderReturns400() { void requestWithoutProtocolHeaderReturns400() {
HttpHeaders headers = new HttpHeaders(); 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.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.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.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.license.LicenseInfo;
import com.cameleer.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.license.LicenseInfo;
import com.cameleer.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);
}
}

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