Gate AI config-push off in SaaS and cover it with tests

This commit is contained in:
Anthony Stirling
2026-07-15 18:45:52 +01:00
parent baf33b6844
commit 1143a8aba3
10 changed files with 441 additions and 6 deletions
@@ -312,6 +312,15 @@ public class ApplicationProperties {
/** Timeout (seconds) for the SSE stream held open by long-running orchestrator runs. */
private int streamTimeoutSeconds = 1800;
/**
* Whether the processor pushes admin/settings-derived AI config (models, keys, RAG, limits)
* to the engine's {@code POST /api/v1/config} on startup and after a save. True lets a
* self-hosted admin drive the engine from the UI. Environment-driven deployments pin this
* false (SaaS does in application-saas.properties) so the engine stays entirely
* env-controlled and the processor never overrides its config.
*/
private boolean pushConfigToEngine = true;
/** Model + provider selection, forwarded to the engine per-request. */
private Models models = new Models();
@@ -368,6 +368,7 @@ aiEngine:
timeoutSeconds: 120 # Timeout in seconds for AI engine requests
longRunningTimeoutSeconds: 600 # Timeout (seconds) for heavy operations like RAG ingestion of large documents
streamTimeoutSeconds: 1800 # SSE stream timeout (seconds) for long-running orchestrator runs
pushConfigToEngine: true # Push admin/settings AI config (models, keys, RAG, limits) to the engine on startup + save. Set false to leave the engine fully env-controlled
models:
provider: anthropic # Model provider: 'anthropic', 'openai', 'ollama', or 'custom' (OpenAI-compatible)
smartModel: claude-haiku-4-5 # High-quality tier model name (no provider prefix)
@@ -22,10 +22,12 @@ import tools.jackson.databind.node.ObjectNode;
* from the Stirling settings UI. Pushed on processor startup and again live whenever AI settings
* are saved (so model/RAG/limit changes apply without a restart). The engine applies it live
* (rebuilds its models) and caches it, so it self-restores on its own reboot. Empty
* key/baseUrl/model fields mean "keep the engine's own environment credential", so
* environment-driven deployments (where the engine sets {@code STIRLING_ALLOW_CONFIG_PUSH=false}
* and rejects the push) stay fully env-controlled. Best-effort and non-blocking: a slow or
* unreachable engine never delays or fails Stirling startup.
* key/baseUrl/model fields mean "keep the engine's own environment credential".
*
* <p>Gated by {@code aiEngine.pushConfigToEngine} (default true). Environment-driven deployments
* pin it false (SaaS does so in application-saas.properties) so the engine stays entirely
* env-controlled and the processor never pushes settings-derived config to it. Best-effort and
* non-blocking: a slow or unreachable engine never delays or fails Stirling startup.
*/
@Slf4j
@Service
@@ -45,6 +47,12 @@ public class AiEngineConfigSync {
if (!cfg.isEnabled()) {
return;
}
if (!cfg.isPushConfigToEngine()) {
log.debug(
"Skipping AI engine config push: aiEngine.pushConfigToEngine is disabled"
+ " (the engine is configured from its own environment)");
return;
}
// Engine may still be booting; push on a virtual thread with a few retries so we never
// block or crash Stirling startup when the engine is slow or briefly unreachable.
Thread.ofVirtual().name("ai-engine-config-sync").start(() -> pushWithRetries(cfg));
@@ -60,9 +68,11 @@ public class AiEngineConfigSync {
// Gate on the RUNNING bean: AiEngineClient refuses calls while the bean is disabled, so
// pushing on a pending-but-not-restarted enable would always fail. The post-restart
// startup push covers first-time enablement.
AiEngine cfg = applicationProperties.getAiEngine();
if (pendingAiEngine == null
|| pendingAiEngine.isEmpty()
|| !applicationProperties.getAiEngine().isEnabled()) {
|| !cfg.isPushConfigToEngine()
|| !cfg.isEnabled()) {
return;
}
boolean engineRelevant =
@@ -70,7 +80,7 @@ public class AiEngineConfigSync {
if (!engineRelevant) {
return;
}
ObjectNode node = buildConfigNode(applicationProperties.getAiEngine());
ObjectNode node = buildConfigNode(cfg);
pendingAiEngine.forEach((k, v) -> overlayIfEngineRelevant(node, k, v));
String body = node.toString();
Thread.ofVirtual().name("ai-engine-config-live-push").start(() -> pushOnce(body));
@@ -1,10 +1,13 @@
package stirling.software.proprietary.security.controller.api;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.argThat;
import static org.mockito.Mockito.mockStatic;
import static org.mockito.Mockito.verify;
import java.io.IOException;
import java.lang.reflect.Field;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
@@ -283,6 +286,51 @@ class AdminSettingsControllerTest {
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST);
}
}
@Test
@DisplayName("drops a masked ******** secret so a UI round-trip can't overwrite a real key")
void dropsMaskedSecretValue() {
UpdateSettingsRequest request = new UpdateSettingsRequest();
Map<String, Object> settings = new HashMap<>();
settings.put("aiEngine.models.apiKey", "********");
settings.put("ui.appName", "My App");
request.setSettings(settings);
try (MockedStatic<GeneralUtils> mocked = mockStatic(GeneralUtils.class)) {
ResponseEntity<Map<String, Object>> response = controller.updateSettings(request);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
// The masked secret is stripped; only the real change is persisted.
mocked.verify(
() ->
GeneralUtils.updateSettingsTransactional(
argThat(
(Map<String, Object> m) ->
!m.containsKey("aiEngine.models.apiKey")
&& m.containsKey("ui.appName"))));
}
}
@Test
@DisplayName("forwards only aiEngine.* pending keys to the engine live-push")
void forwardsOnlyAiEngineKeysToLivePush() {
UpdateSettingsRequest request = new UpdateSettingsRequest();
Map<String, Object> settings = new HashMap<>();
settings.put("aiEngine.models.provider", "ollama");
settings.put("ui.appName", "My App");
request.setSettings(settings);
try (MockedStatic<GeneralUtils> mocked = mockStatic(GeneralUtils.class)) {
controller.updateSettings(request);
verify(aiEngineConfigSync)
.pushLiveAfterSave(
argThat(
(Map<String, Object> m) ->
m.containsKey("aiEngine.models.provider")
&& !m.containsKey("ui.appName")));
}
}
}
@Nested
@@ -0,0 +1,150 @@
package stirling.software.proprietary.service;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.ArgumentMatchers.isNull;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.timeout;
import static org.mockito.Mockito.verify;
import java.util.Map;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.mockito.ArgumentCaptor;
import stirling.software.common.model.ApplicationProperties;
import stirling.software.common.model.ApplicationProperties.AiEngine;
import tools.jackson.databind.JsonNode;
import tools.jackson.databind.ObjectMapper;
import tools.jackson.databind.json.JsonMapper;
/**
* The config-push bridge is a self-hosted-only feature: it lets the admin UI drive the engine's
* model/key/RAG/limit config. Environment-driven deployments pin {@code
* aiEngine.pushConfigToEngine} false (SaaS does so in application-saas.properties) so the engine
* stays entirely env-controlled - these tests lock in that the processor stays silent then while
* still pushing when it is enabled.
*/
class AiEngineConfigSyncTest {
private ApplicationProperties applicationProperties;
private AiEngineClient aiEngineClient;
private AiEngineConfigSync sync;
@BeforeEach
void setUp() {
applicationProperties = new ApplicationProperties();
applicationProperties.getAiEngine().setEnabled(true);
applicationProperties.getAiEngine().setPushConfigToEngine(true);
aiEngineClient = mock(AiEngineClient.class);
ObjectMapper objectMapper = JsonMapper.builder().build();
sync = new AiEngineConfigSync(applicationProperties, aiEngineClient, objectMapper);
}
@Test
void startupPushSkippedWhenPushDisabled() throws Exception {
applicationProperties.getAiEngine().setPushConfigToEngine(false);
sync.pushConfigOnStartup();
// Returns synchronously before spawning the push thread, so no interaction ever happens.
verify(aiEngineClient, never()).post(anyString(), anyString(), isNull());
}
@Test
void startupPushSkippedWhenDisabled() throws Exception {
applicationProperties.getAiEngine().setEnabled(false);
sync.pushConfigOnStartup();
verify(aiEngineClient, never()).post(anyString(), anyString(), isNull());
}
@Test
void startupPushSentWhenEnabledAndPushOn() throws Exception {
sync.pushConfigOnStartup();
// Push runs on a virtual thread; wait for the single POST to /api/v1/config.
verify(aiEngineClient, timeout(3000)).post(eq("/api/v1/config"), anyString(), isNull());
}
@Test
void livePushSkippedWhenPushDisabled() throws Exception {
applicationProperties.getAiEngine().setPushConfigToEngine(false);
sync.pushLiveAfterSave(Map.of("aiEngine.models.provider", "ollama"));
verify(aiEngineClient, never()).post(anyString(), anyString(), isNull());
}
@Test
void livePushSentForEngineRelevantChangeWhenPushOn() throws Exception {
sync.pushLiveAfterSave(Map.of("aiEngine.models.provider", "ollama"));
verify(aiEngineClient, timeout(3000)).post(eq("/api/v1/config"), anyString(), isNull());
}
@Test
void startupPushSerialisesTheEngineWireContract() throws Exception {
// Distinct values so a dropped/renamed field is detectable. Mirrors
// engine/tests/fixtures/processor_config_push.json - keep the two in sync; the engine's
// test_config_contract.py validates that same shape on the receiving side.
AiEngine ai = applicationProperties.getAiEngine();
ai.getModels().setProvider("ollama");
ai.getModels().setSmartModel("smart-model-x");
ai.getModels().setFastModel("fast-model-x");
ai.getModels().setSmartMaxTokens(1111);
ai.getModels().setFastMaxTokens(2222);
ai.getModels().setApiKey("provider-key-abc");
ai.getModels().setBaseUrl("http://engine.example/v1");
ai.getRag().setEmbeddingProvider("custom");
ai.getRag().setEmbeddingModel("embed-model-x");
ai.getRag().setEmbeddingApiKey("embed-key-abc");
ai.getRag().setEmbeddingBaseUrl("http://embed.example/v1");
ai.getRag().setTopK(33);
ai.getRag().setMaxSearches(7);
ai.getLimits().setMaxPages(111);
ai.getLimits().setMaxCharacters(222222);
ai.getLimits().setModelMaxConcurrency(9);
ArgumentCaptor<String> body = ArgumentCaptor.forClass(String.class);
sync.pushConfigOnStartup();
verify(aiEngineClient, timeout(3000)).post(eq("/api/v1/config"), body.capture(), isNull());
JsonNode root = JsonMapper.builder().build().readTree(body.getValue());
JsonNode models = root.get("models");
assertEquals("ollama", models.get("provider").asText());
assertEquals("smart-model-x", models.get("smartModel").asText());
assertEquals("fast-model-x", models.get("fastModel").asText());
assertEquals(1111, models.get("smartMaxTokens").asInt());
assertEquals(2222, models.get("fastMaxTokens").asInt());
assertEquals("provider-key-abc", models.get("apiKey").asText());
assertEquals("http://engine.example/v1", models.get("baseUrl").asText());
JsonNode rag = root.get("rag");
assertEquals("custom", rag.get("embeddingProvider").asText());
assertEquals("embed-model-x", rag.get("embeddingModel").asText());
assertEquals("embed-key-abc", rag.get("embeddingApiKey").asText());
assertEquals("http://embed.example/v1", rag.get("embeddingBaseUrl").asText());
assertEquals(33, rag.get("topK").asInt());
assertEquals(7, rag.get("maxSearches").asInt());
JsonNode limits = root.get("limits");
assertEquals(111, limits.get("maxPages").asInt());
assertEquals(222222, limits.get("maxCharacters").asInt());
assertEquals(9, limits.get("modelMaxConcurrency").asInt());
}
@Test
void livePushSkippedForNonEngineRelevantChange() throws Exception {
// features.* is processor-side only; no engine push is warranted.
sync.pushLiveAfterSave(Map.of("aiEngine.features.chat", false));
verify(aiEngineClient, never()).post(anyString(), anyString(), isNull());
}
}
@@ -0,0 +1,54 @@
package stirling.software.proprietary.service;
import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.http.HttpStatus;
import org.springframework.web.server.ResponseStatusException;
import stirling.software.common.model.ApplicationProperties;
/**
* Every AI capability entry point calls the matching {@code require*} before reaching the engine.
* These lock in fail-closed behaviour: a 503 when the engine is disabled OR the individual feature
* flag is off, and a clean pass only when both are on.
*/
class AiFeatureGateTest {
private ApplicationProperties props;
private AiFeatureGate gate;
@BeforeEach
void setUp() {
props = new ApplicationProperties();
props.getAiEngine().setEnabled(true); // features default all-on
gate = new AiFeatureGate(props);
}
@Test
void passesWhenEngineEnabledAndFeatureOn() {
assertDoesNotThrow(() -> gate.requireClassify());
assertDoesNotThrow(() -> gate.requireChat());
}
@Test
void throws503WhenFeatureFlagOff() {
props.getAiEngine().getFeatures().setClassify(false);
ResponseStatusException ex =
assertThrows(ResponseStatusException.class, () -> gate.requireClassify());
assertEquals(HttpStatus.SERVICE_UNAVAILABLE, ex.getStatusCode());
}
@Test
void throws503WhenEngineDisabledEvenIfFeatureOn() {
props.getAiEngine().setEnabled(false); // feature flag still true
ResponseStatusException ex =
assertThrows(ResponseStatusException.class, () -> gate.requireChat());
assertEquals(HttpStatus.SERVICE_UNAVAILABLE, ex.getStatusCode());
}
}
@@ -1,6 +1,12 @@
# Stirling-PDF SaaS profile. Pure multi-tenant cloud.
# Activated when the :saas module is on the classpath.
# ---------- AI engine ----------
# The SaaS AI backend is env-driven and reached through the saas AiProxyController, so the
# processor must never push settings-derived config to it. Pin the config-push off; the engine
# stays entirely environment-controlled.
aiEngine.pushConfigToEngine=false
# ---------- Datasource ----------
system.datasource.enableCustomDatabase=true
system.datasource.customDatabaseUrl=${SAAS_DB_URL:}
+24
View File
@@ -0,0 +1,24 @@
{
"models": {
"provider": "ollama",
"smartModel": "smart-model-x",
"fastModel": "fast-model-x",
"smartMaxTokens": 1111,
"fastMaxTokens": 2222,
"apiKey": "provider-key-abc",
"baseUrl": "http://engine.example/v1"
},
"rag": {
"embeddingProvider": "custom",
"embeddingModel": "embed-model-x",
"embeddingApiKey": "embed-key-abc",
"embeddingBaseUrl": "http://embed.example/v1",
"topK": 33,
"maxSearches": 7
},
"limits": {
"maxPages": 111,
"maxCharacters": 222222,
"modelMaxConcurrency": 9
}
}
+80
View File
@@ -0,0 +1,80 @@
"""Wire-contract test for the processor -> engine config push.
The Java processor (AiEngineConfigSync.buildConfigNode) serialises the admin AI settings as
camelCase JSON and POSTs them to /api/v1/config. Because ConfigPushRequest uses extra="ignore"
(so version skew never 422s), a field the engine can no longer map is silently dropped to its
default instead of erroring. This test pins the exact camelCase contract: every field in the
shared fixture must round-trip onto the model, so a rename on either side fails loudly.
The same fixture (tests/fixtures/processor_config_push.json) is the contract the Java
AiEngineConfigSyncTest asserts its serialiser produces - keep the two in sync.
"""
from __future__ import annotations
import json
from pathlib import Path
from stirling.contracts import ConfigPushRequest
FIXTURE = Path(__file__).parent / "fixtures" / "processor_config_push.json"
def _load() -> dict:
return json.loads(FIXTURE.read_text(encoding="utf-8"))
def test_processor_contract_round_trips_every_field() -> None:
payload = _load()
req = ConfigPushRequest.model_validate(payload)
m = payload["models"]
assert req.models.provider == m["provider"]
assert req.models.smart_model == m["smartModel"]
assert req.models.fast_model == m["fastModel"]
assert req.models.smart_max_tokens == m["smartMaxTokens"]
assert req.models.fast_max_tokens == m["fastMaxTokens"]
assert req.models.api_key == m["apiKey"]
assert req.models.base_url == m["baseUrl"]
r = payload["rag"]
assert req.rag.embedding_provider == r["embeddingProvider"]
assert req.rag.embedding_model == r["embeddingModel"]
assert req.rag.embedding_api_key == r["embeddingApiKey"]
assert req.rag.embedding_base_url == r["embeddingBaseUrl"]
assert req.rag.top_k == r["topK"]
assert req.rag.max_searches == r["maxSearches"]
limits = payload["limits"]
assert req.limits.max_pages == limits["maxPages"]
assert req.limits.max_characters == limits["maxCharacters"]
assert req.limits.model_max_concurrency == limits["modelMaxConcurrency"]
def test_processor_contract_has_no_unmapped_keys() -> None:
"""Guard the fixture itself: every wire key must map to a model field (none silently
absorbed by extra="ignore"). If the processor adds a field, extend the contract here."""
payload = _load()
expected = {
"models": {
"provider",
"smartModel",
"fastModel",
"smartMaxTokens",
"fastMaxTokens",
"apiKey",
"baseUrl",
},
"rag": {
"embeddingProvider",
"embeddingModel",
"embeddingApiKey",
"embeddingBaseUrl",
"topK",
"maxSearches",
},
"limits": {"maxPages", "maxCharacters", "modelMaxConcurrency"},
}
assert set(payload) == set(expected)
for section, keys in expected.items():
assert set(payload[section]) == keys
@@ -0,0 +1,53 @@
import { describe, it, expect } from "vitest";
import { type TFunction } from "i18next";
import { allowConsole } from "@app/tests/failOnConsole";
import { createSaasConfigNavSections } from "@app/components/shared/config/saasConfigNavSections";
// Passthrough i18n stub: return the provided fallback (2nd arg) or the key.
const t = ((key: string, fallback?: string) =>
fallback ?? key) as unknown as TFunction<"translation", undefined>;
const Overview = () => null;
type Sections = ReturnType<typeof createSaasConfigNavSections>;
function itemKeys(sections: Sections): string[] {
return sections.flatMap((s) => s.items.map((i) => i.key));
}
// The admin AI settings pages exist only in the self-hosted proprietary flavor. SaaS builds its
// settings nav from scratch here, so this locks in that the AI group can never leak into SaaS -
// if someone later wires the AI sections into the SaaS cascade, this fails loudly.
describe("saasConfigNavSections", () => {
const AI_ITEM_KEYS = [
"adminAiGeneral",
"adminAiModels",
"adminAiDocuments",
"adminAiLimits",
];
it("never exposes the admin AI settings group or its pages", () => {
// The shared core nav helper warns it is deprecated; incidental to this test.
allowConsole.warn(/createConfigNavSections is deprecated/);
const sections = createSaasConfigNavSections(Overview, () => {}, { t });
const keys = itemKeys(sections);
for (const aiKey of AI_ITEM_KEYS) {
expect(keys).not.toContain(aiKey);
}
expect(sections.map((s) => s.title)).not.toContain("AI");
});
it("also hides the AI pages for anonymous users", () => {
allowConsole.warn(/createConfigNavSections is deprecated/);
const sections = createSaasConfigNavSections(Overview, () => {}, {
t,
isAnonymous: true,
});
const keys = itemKeys(sections);
for (const aiKey of AI_ITEM_KEYS) {
expect(keys).not.toContain(aiKey);
}
});
});