diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/policy/store/JpaPolicyStore.java b/app/proprietary/src/main/java/stirling/software/proprietary/policy/store/JpaPolicyStore.java index 409555312f..0fa679c79d 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/policy/store/JpaPolicyStore.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/policy/store/JpaPolicyStore.java @@ -3,6 +3,7 @@ package stirling.software.proprietary.policy.store; import java.util.List; import java.util.Objects; import java.util.Optional; +import java.util.Set; import java.util.UUID; import org.springframework.stereotype.Service; @@ -11,8 +12,10 @@ import org.springframework.transaction.annotation.Transactional; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; +import stirling.software.proprietary.policy.model.EditorConfig; import stirling.software.proprietary.policy.model.Policy; import stirling.software.proprietary.policy.model.PolicyBinding; +import stirling.software.proprietary.policy.source.EditorSource; import tools.jackson.databind.JsonNode; import tools.jackson.databind.ObjectMapper; @@ -149,7 +152,9 @@ public class JpaPolicyStore implements PolicyStore { // One unreadable row must never abort a bulk read or crash startup. private Optional toPolicy(PolicyEntity entity) { try { - JsonNode node = upgradeLegacyShape(objectMapper.readTree(entity.getPolicyJson())); + JsonNode node = + liftEditorConfig( + upgradeLegacyShape(objectMapper.readTree(entity.getPolicyJson()))); return Optional.of(objectMapper.treeToValue(node, Policy.class)); } catch (Exception e) { log.error( @@ -192,4 +197,60 @@ public class JpaPolicyStore implements PolicyStore { obj.remove("sourceIds"); return obj; } + + /** Categories whose editor moment defaulted to export before it was stored (see runOn.ts). */ + private static final Set EXPORT_BY_DEFAULT = Set.of("security"); + + /** + * Derive {@code editor} for a blob written before editor participation had its own field. It + * used to be inferred from {@code output.options}: {@code "editor"} in {@code sources}, or - + * for a catalogue policy - no sources at all, which meant "nobody narrowed it". + * + *

Runs on every read, deliberately outside {@link #upgradeLegacyShape}'s early return: a + * blob written after triggers moved onto {@code inputs} but before this field existed still + * needs lifting, and that early return would skip exactly those rows. + */ + private JsonNode liftEditorConfig(JsonNode root) { + if (!(root instanceof ObjectNode obj) || obj.hasNonNull("editor")) { + return root; + } + JsonNode options = obj.path("output").path("options"); + String categoryId = text(options, "categoryId"); + JsonNode sources = options.get("sources"); + boolean listed = sources != null && sources.isArray() && !sources.isEmpty(); + boolean allowed; + if (listed) { + // An explicit scope list decides: only the editor's own id puts it on the editor. + allowed = false; + for (JsonNode source : sources) { + if (source.isValueNode() && EditorSource.ID.equals(source.asString())) { + allowed = true; + break; + } + } + } else { + // No list: a catalogue policy ran in the editor by default, but a builder pipeline + // (no category) could not reach the editor at all, so silence is not consent there. + allowed = !categoryId.isBlank(); + } + ObjectNode editor = objectMapper.createObjectNode(); + editor.put("allowed", allowed); + editor.put("runOn", legacyRunOn(options, categoryId)); + obj.set("editor", editor); + return obj; + } + + /** The stored moment, or the category default the client applied when none was stored. */ + private static String legacyRunOn(JsonNode options, String categoryId) { + String stored = text(options, "runOn"); + if (EditorConfig.EXPORT.equals(stored) || EditorConfig.UPLOAD.equals(stored)) { + return stored; + } + return EXPORT_BY_DEFAULT.contains(categoryId) ? EditorConfig.EXPORT : EditorConfig.UPLOAD; + } + + private static String text(JsonNode parent, String field) { + JsonNode node = parent.path(field); + return node.isValueNode() ? node.asString() : ""; + } } diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/policy/store/JpaPolicyStoreTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/policy/store/JpaPolicyStoreTest.java index 2a1d2b4f11..ae95b3a3ce 100644 --- a/app/proprietary/src/test/java/stirling/software/proprietary/policy/store/JpaPolicyStoreTest.java +++ b/app/proprietary/src/test/java/stirling/software/proprietary/policy/store/JpaPolicyStoreTest.java @@ -18,6 +18,7 @@ import org.mockito.ArgumentCaptor; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; +import stirling.software.proprietary.policy.model.EditorConfig; import stirling.software.proprietary.policy.model.OutputSpec; import stirling.software.proprietary.policy.model.PipelineInput; import stirling.software.proprietary.policy.model.PipelineStep; @@ -113,6 +114,129 @@ class JpaPolicyStoreTest { upgraded.inputs()); } + /** + * The regression this guards: before the editor lift, a blob written by the pre-{@code editor} + * seeder deserialized straight onto {@link EditorConfig#disabled()}, silently taking every + * upgraded install's Classification policy off the editor. + * + *

The {@code inputs} variant is the important one - {@link + * JpaPolicyStore#upgradeLegacyShape} returns early on it, so a lift living inside that method + * would miss exactly the rows written between the trigger migration and this field. + */ + @Test + void getLiftsALegacyEditorSourceOntoEditorConfigWhenInputsArePresent() { + Policy lifted = readLegacy(legacyJson("\"inputs\":[],", "\"sources\":[\"editor\"],")); + + assertEquals(EditorConfig.onUpload(), lifted.editor()); + assertEquals(Optional.of("upload"), lifted.editorRunOn()); + } + + @Test + void getLiftsALegacyEditorSourceOnThePreInputsShapeToo() { + // Oldest shape: policy-level trigger + sourceIds, so both migrations have to compose. + Policy lifted = + readLegacy( + legacyJson( + "\"trigger\":{\"type\":\"schedule\",\"options\":{}}," + + "\"sourceIds\":[\"s1\"],", + "\"sources\":[\"editor\"],")); + + assertEquals(EditorConfig.onUpload(), lifted.editor()); + assertEquals( + List.of(new PipelineInput("s1", new TriggerConfig("schedule", Map.of()))), + lifted.inputs()); + } + + @Test + void getTreatsAnUnnarrowedCataloguePolicyAsEditorRun() { + // Empty and absent both meant "nobody narrowed it", which the editor read as its own. + assertTrue(readLegacy(legacyJson("\"inputs\":[],", "\"sources\":[],")).editor().allowed()); + assertTrue(readLegacy(legacyJson("\"inputs\":[],", "")).editor().allowed()); + } + + @Test + void getLeavesACataloguePolicyScopedElsewhereOffTheEditor() { + Policy lifted = readLegacy(legacyJson("\"inputs\":[],", "\"sources\":[\"sharepoint\"],")); + + assertFalse(lifted.editor().allowed()); + assertEquals(Optional.empty(), lifted.editorRunOn()); + } + + @Test + void getLeavesASourcelessBuilderPipelineOffTheEditor() { + // No categoryId: a pipeline built on the Pipelines page, which never reached the editor. + String json = + "{\"id\":\"p1\",\"name\":\"legacy\",\"enabled\":true,\"inputs\":[]," + + "\"steps\":[],\"output\":{\"type\":\"inline\",\"options\":{}}}"; + + assertFalse(readLegacy(json).editor().allowed()); + } + + @Test + void getKeepsTheCategoryDefaultMomentWhenNoRunOnWasStored() { + // Security enforced on export before runOn was persisted (frontend runOn.ts + // DEFAULT_RUN_ON). + String json = + "{\"id\":\"p1\",\"name\":\"legacy\",\"enabled\":true,\"inputs\":[]," + + "\"steps\":[],\"output\":{\"type\":\"inline\",\"options\":{" + + "\"categoryId\":\"security\",\"sources\":[\"editor\"]}}}"; + + assertEquals(EditorConfig.onExport(), readLegacy(json).editor()); + } + + @Test + void getNeverOverridesAnExplicitlyStoredEditorBlock() { + // A deliberate opt-out survives, so the lift stays safe to leave in permanently. + String json = + "{\"id\":\"p1\",\"name\":\"legacy\",\"enabled\":true,\"inputs\":[]," + + "\"steps\":[],\"editor\":{\"allowed\":false,\"runOn\":\"upload\"}," + + "\"output\":{\"type\":\"inline\",\"options\":{" + + "\"categoryId\":\"classification\",\"sources\":[\"editor\"]}}}"; + + assertFalse(readLegacy(json).editor().allowed()); + } + + /** + * Pins the wire shape the stubbed Playwright spec hardcodes: the derived block is additive, so + * a real response carries it alongside the untouched legacy options bag. + */ + @Test + void getLeavesTheLegacyOptionsBagIntactSoTheResponseCarriesBoth() { + Policy lifted = readLegacy(legacyJson("\"inputs\":[],", "\"sources\":[\"editor\"],")); + + assertEquals(List.of("editor"), lifted.output().options().get("sources")); + String wire = objectMapper.writeValueAsString(lifted); + assertTrue( + wire.contains("\"editor\":{\"allowed\":true,\"runOn\":\"upload\"}"), + "expected the derived editor block on the wire, got: " + wire); + } + + /** + * The blob main's DefaultClassificationPolicySeeder wrote, with the shape bits parameterised. + */ + private static String legacyJson(String shapeFields, String sourcesField) { + return "{\"id\":\"p1\",\"name\":\"Classification Policy\",\"owner\":\"system\"," + + "\"enabled\":true," + + shapeFields + + "\"steps\":[{\"operation\":\"/api/v1/ai/tools/classify-and-label\"," + + "\"parameters\":{}}]," + + "\"output\":{\"type\":\"inline\",\"options\":{" + + "\"categoryId\":\"classification\",\"runOn\":\"upload\"," + + "\"mode\":\"new_version\"," + + sourcesField + + "\"scopeTypes\":[],\"reviewerEmail\":\"\"}},\"teamId\":1}"; + } + + private Policy readLegacy(String policyJson) { + PolicyEntity entity = new PolicyEntity(); + entity.setId("p1"); + entity.setName("legacy"); + entity.setEnabled(true); + entity.setPolicyJson(policyJson); + when(repository.findById("p1")).thenReturn(Optional.of(entity)); + return store.get("p1").orElseThrow(); + } + @Test void saveDenormalizesTeamIdForScopedQueries() { store.save( diff --git a/frontend/editor/src/core/tests/stubbed/classification-heuristic-upload.spec.ts b/frontend/editor/src/core/tests/stubbed/classification-heuristic-upload.spec.ts index 856ddd58f5..7d42434385 100644 --- a/frontend/editor/src/core/tests/stubbed/classification-heuristic-upload.spec.ts +++ b/frontend/editor/src/core/tests/stubbed/classification-heuristic-upload.spec.ts @@ -12,14 +12,20 @@ const FIXTURES = path.join( "../test-fixtures/classification/unlabelled", ); -/** The stored policy DefaultClassificationPolicySeeder writes for a new team. */ +/** + * What GET /api/v1/policies returns for the row an older + * DefaultClassificationPolicySeeder wrote - i.e. one stored before editor + * participation had its own field. `JpaPolicyStore.liftEditorConfig` derives the + * `editor` block from the legacy `output.options` on read; the lift is additive, + * so a real response carries both. Migration of the stored shape itself is + * covered by JpaPolicyStoreTest, which exercises the Java the stub stands in for. + */ const SEEDED_POLICY = { id: "seeded-classification", name: "Classification Policy", owner: "system", enabled: true, - trigger: null, - sourceIds: [], + inputs: [], steps: [{ operation: "/api/v1/ai/tools/classify-and-label", parameters: {} }], output: { type: "inline", @@ -32,7 +38,9 @@ const SEEDED_POLICY = { reviewerEmail: "", }, }, + outputIds: [], teamId: 1, + editor: { allowed: true, runOn: "upload" }, }; test("a 10-file upload wave classifies every file into its group", async ({