From a920ed4f51c8eb1886a6dc061cca1406eebbad55 Mon Sep 17 00:00:00 2001 From: Romuald Lemesle Date: Wed, 2 Sep 2026 10:45:22 +0200 Subject: [PATCH] feat(multitenancy): activate kill_chain_phases on v2 isolation (#6402) --- .../AttackPatternCoverageApi.java | 3 +- .../api/autonomous/AutonomousRunApi.java | 10 +- .../dto/AutonomousConvertToManualOutput.java | 9 + .../io/openaev/api/chaining/ChainingApi.java | 46 -- .../java/io/openaev/importer/Importer.java | 2 + .../io/openaev/importer/V1_DataImporter.java | 118 ++-- .../openaev/processor/datapack/DataPack.java | 10 +- .../datapack/V20260101_Starter_pack.java | 15 +- ...107_Tags_and_tagrules_and_assetgroups.java | 3 +- .../V20260330_Default_tenant_data.java | 3 +- .../datapack/V20260518_Service_Account.java | 3 +- .../V20260708_Dynamic_injectors_base_url.java | 3 +- .../V20260805_Observer_and_manager_users.java | 3 +- .../rest/atomic_testing/AtomicTestingApi.java | 3 +- .../rest/attack_pattern/AttackPatternApi.java | 38 +- .../response/AttackPatternOutput.java | 53 ++ .../openaev/rest/dashboard/DashboardApi.java | 2 + .../io/openaev/rest/exercise/ExerciseApi.java | 71 ++- .../rest/exercise/ExerciseImportApi.java | 2 +- .../io/openaev/rest/inject/InjectApi.java | 55 +- .../rest/inject/ScenarioInjectApi.java | 42 +- .../rest/inject/SimulationInjectApi.java | 38 +- .../rest/inject/service/InjectService.java | 10 +- .../InjectorContractApi.java | 2 +- .../kill_chain_phase/KillChainPhaseApi.java | 33 +- .../KillChainPhaseInitializer.java | 34 ++ .../service/KillChainPhaseService.java | 51 +- .../io/openaev/rest/scenario/ScenarioApi.java | 64 ++- .../rest/scenario/ScenarioImportApi.java | 2 +- .../io/openaev/service/BulkInjectService.java | 7 +- .../io/openaev/service/ImportService.java | 15 +- .../openaev/service/InjectImportService.java | 15 +- .../service/scenario/ScenarioService.java | 13 +- .../src/main/resources/application.properties | 2 +- .../api/chaining/ChainingApiUnitTest.java | 135 ----- .../TenantActiveTableAccessArchTest.java | 108 ++++ .../TenantScopedEntrypointsTxCtxArchTest.java | 64 ++- .../ImportMapperActivationConfigTest.java | 16 + .../KillChainPhaseQueryCorrelationTest.java | 207 +++++++ .../config/TenantStatementInspectorTest.java | 30 ++ .../datapack/local_fixtures/TestDataPack.java | 3 +- .../datapack/packs/StarterPackTest.java | 4 +- .../openaev/importer/V1_DataImporterTest.java | 510 ++++++++++++++++-- .../killChainPhase/KillChainPhaseApiTest.java | 99 +++- ...ackPatternKillChainPhaseIsolationTest.java | 178 ++++++ .../inject/service/InjectServiceTest.java | 13 +- .../KillChainPhaseHttpIsolationTest.java | 310 +++++++++++ .../KillChainPhaseImportAttributionTest.java | 123 +++++ .../KillChainPhaseNonAdminIsolationTest.java | 90 ++++ .../KillChainPhaseUpsertIsolationTest.java | 236 ++++++++ .../service/InjectImportServiceTest.java | 19 +- .../chaining/ChainingIntegrationTest.java | 189 ------- .../utils/fixtures/KillChainPhaseFixture.java | 4 +- .../actions/autonomous/autonomous-actions.ts | 5 +- openaev-front/src/utils/api-types.d.ts | 39 +- .../openaev/database/model/AttackPattern.java | 1 + .../database/model/KillChainPhase.java | 12 +- .../repository/KillChainPhaseRepository.java | 24 +- 58 files changed, 2490 insertions(+), 709 deletions(-) create mode 100644 openaev-api/src/main/java/io/openaev/api/autonomous/dto/AutonomousConvertToManualOutput.java create mode 100644 openaev-api/src/main/java/io/openaev/rest/attack_pattern/response/AttackPatternOutput.java create mode 100644 openaev-api/src/main/java/io/openaev/rest/kill_chain_phase/KillChainPhaseInitializer.java delete mode 100644 openaev-api/src/test/java/io/openaev/api/chaining/ChainingApiUnitTest.java create mode 100644 openaev-api/src/test/java/io/openaev/config/KillChainPhaseQueryCorrelationTest.java create mode 100644 openaev-api/src/test/java/io/openaev/rest/attack_pattern/AttackPatternKillChainPhaseIsolationTest.java create mode 100644 openaev-api/src/test/java/io/openaev/rest/kill_chain_phase/KillChainPhaseHttpIsolationTest.java create mode 100644 openaev-api/src/test/java/io/openaev/rest/kill_chain_phase/KillChainPhaseImportAttributionTest.java create mode 100644 openaev-api/src/test/java/io/openaev/rest/kill_chain_phase/KillChainPhaseNonAdminIsolationTest.java create mode 100644 openaev-api/src/test/java/io/openaev/rest/kill_chain_phase/KillChainPhaseUpsertIsolationTest.java diff --git a/openaev-api/src/main/java/io/openaev/api/attack_pattern/AttackPatternCoverageApi.java b/openaev-api/src/main/java/io/openaev/api/attack_pattern/AttackPatternCoverageApi.java index 8a98e861878..74962138f0a 100644 --- a/openaev-api/src/main/java/io/openaev/api/attack_pattern/AttackPatternCoverageApi.java +++ b/openaev-api/src/main/java/io/openaev/api/attack_pattern/AttackPatternCoverageApi.java @@ -5,6 +5,7 @@ import io.openaev.aop.AccessControl; import io.openaev.aop.LogExecutionTime; import io.openaev.api.attack_pattern.dto.AttackPatternCoverageOutput; +import io.openaev.context.TxCtx; import io.openaev.database.model.Action; import io.openaev.database.model.ResourceType; import io.openaev.rest.attack_pattern.service.AttackPatternService; @@ -39,7 +40,7 @@ public class AttackPatternCoverageApi { description = "Tenant-wide ATT&CK matrix aggregating prevention and detection results across simulations (Elasticsearch, identical to the home security-coverage matrix)") public List attackPatternsCoverage( - @RequestParam(required = false) final Integer latest) { + TxCtx ctx, @RequestParam(required = false) final Integer latest) { if (latest != null && latest < 1) { throw new ResponseStatusException( HttpStatus.BAD_REQUEST, "The 'latest' parameter must be a positive integer"); diff --git a/openaev-api/src/main/java/io/openaev/api/autonomous/AutonomousRunApi.java b/openaev-api/src/main/java/io/openaev/api/autonomous/AutonomousRunApi.java index e1c7ebdf8fb..7759895dc09 100644 --- a/openaev-api/src/main/java/io/openaev/api/autonomous/AutonomousRunApi.java +++ b/openaev-api/src/main/java/io/openaev/api/autonomous/AutonomousRunApi.java @@ -7,6 +7,7 @@ import io.openaev.api.autonomous.dto.AutonomousAttackPathStepResult; import io.openaev.api.autonomous.dto.AutonomousAttackPathStepState; import io.openaev.api.autonomous.dto.AutonomousConvertToManualInput; +import io.openaev.api.autonomous.dto.AutonomousConvertToManualOutput; import io.openaev.api.autonomous.dto.AutonomousDefaultAgentsInput; import io.openaev.api.autonomous.dto.AutonomousDefaultAgentsOutput; import io.openaev.api.autonomous.dto.AutonomousDirectiveInput; @@ -344,16 +345,17 @@ public AutonomousRun promote(TxCtx ctx, @PathVariable String runId) { + " manual for good: it halts the orchestration, drops the autonomous run and its" + " timeline, and keeps the scenario + its simulation as a normal chained" + " scenario/simulation the operator can edit and delete. IN_PLACE is irreversible." - + " Works whether the run is a built plan or has already executed. Returns the" - + " resulting manual scenario.") + + " Works whether the run is a built plan or has already executed. Returns the id of" + + " the resulting manual scenario; read it back through the scenario endpoint.") @PostMapping("/{runId}/convert-to-manual") @Transactional @AccessControl(skipRBAC = true, isEnterpriseEdition = true) - public Scenario convertToManual( + public AutonomousConvertToManualOutput convertToManual( TxCtx ctx, @PathVariable String runId, @Valid @RequestBody AutonomousConvertToManualInput input) { - return autonomousRunService.convertToManual(runId, input.getMode()); + Scenario scenario = autonomousRunService.convertToManual(runId, input.getMode()); + return new AutonomousConvertToManualOutput(scenario.getId()); } @Operation(summary = "Run decision timeline, optionally since a sequence cursor") diff --git a/openaev-api/src/main/java/io/openaev/api/autonomous/dto/AutonomousConvertToManualOutput.java b/openaev-api/src/main/java/io/openaev/api/autonomous/dto/AutonomousConvertToManualOutput.java new file mode 100644 index 00000000000..ca9b430c4a2 --- /dev/null +++ b/openaev-api/src/main/java/io/openaev/api/autonomous/dto/AutonomousConvertToManualOutput.java @@ -0,0 +1,9 @@ +package io.openaev.api.autonomous.dto; + +import com.fasterxml.jackson.annotation.JsonProperty; +import io.swagger.v3.oas.annotations.media.Schema; + +@Schema(description = "Identifier of the scenario resulting from the conversion") +public record AutonomousConvertToManualOutput( + @JsonProperty("scenario_id") @Schema(description = "Id of the resulting manual scenario.") + String scenarioId) {} diff --git a/openaev-api/src/main/java/io/openaev/api/chaining/ChainingApi.java b/openaev-api/src/main/java/io/openaev/api/chaining/ChainingApi.java index 0f996bae78d..87356990fa6 100644 --- a/openaev-api/src/main/java/io/openaev/api/chaining/ChainingApi.java +++ b/openaev-api/src/main/java/io/openaev/api/chaining/ChainingApi.java @@ -34,7 +34,6 @@ import jakarta.validation.Valid; import jakarta.validation.constraints.NotBlank; import java.util.List; -import java.util.Optional; import lombok.RequiredArgsConstructor; import org.springframework.http.HttpStatus; import org.springframework.transaction.annotation.Transactional; @@ -152,29 +151,6 @@ public void createInjectForSimulationChaining( } } - @PostMapping(SIMULATION_URI + "/{simulationId}") - @AccessControl( - resourceId = "#simulationId", - actionPerformed = Action.DUPLICATE, - resourceType = ResourceType.SIMULATION, - isEnterpriseEdition = true) - @Transactional(rollbackFor = Exception.class) - public Exercise duplicateExercise(@PathVariable @NotBlank final String simulationId) - throws ChainingException { - - Exercise simulation = exerciseService.getDuplicateExercise(simulationId); - Optional workflowOpt = - workflowService.findWorkflowTemplateBySimulationId(simulationId); - if (workflowOpt.isEmpty()) - throw new ChainingException("No workflow TEMPLATE found. Simulation ID: " + simulationId); - - Workflow workflowFrom = workflowOpt.get(); - Workflow workflowTo = workflowService.duplicateSimulation(simulationId, simulation); - stepService.copyStepTemplate(workflowFrom, workflowTo); - - return simulation; - } - // CREATE SCENARIO @PostMapping(SCENARIO_URI) @Transactional @@ -237,26 +213,4 @@ public void createInjectForScenarioChaining( // Todo return Action, Event and Link } } - - @PostMapping(SCENARIO_URI + "/{scenarioId}") - @Transactional - @AccessControl( - resourceId = "#scenarioId", - actionPerformed = Action.DUPLICATE, - resourceType = ResourceType.SCENARIO, - isEnterpriseEdition = true) - public Scenario duplicateScenarioChaining(@PathVariable @NotBlank final String scenarioId) - throws ChainingException { - - Scenario scenario = scenarioService.getDuplicateScenario(scenarioId); - Optional workflowOpt = workflowService.findWorkflowTemplateByScenarioId(scenarioId); - if (workflowOpt.isEmpty()) - throw new ChainingException("No workflow TEMPLATE found. Scenario ID: " + scenarioId); - - Workflow workflowFrom = workflowOpt.get(); - Workflow workflowTo = workflowService.duplicateScenario(scenarioId, scenario); - stepService.copyStepTemplate(workflowFrom, workflowTo); - - return scenario; - } } diff --git a/openaev-api/src/main/java/io/openaev/importer/Importer.java b/openaev-api/src/main/java/io/openaev/importer/Importer.java index 4a897b7c282..ae4ad1f9bfa 100644 --- a/openaev-api/src/main/java/io/openaev/importer/Importer.java +++ b/openaev-api/src/main/java/io/openaev/importer/Importer.java @@ -3,6 +3,7 @@ import static java.util.Spliterators.spliteratorUnknownSize; import com.fasterxml.jackson.databind.JsonNode; +import io.openaev.context.TxCtx; import io.openaev.database.model.Asset; import io.openaev.database.model.AssetGroup; import io.openaev.database.model.Exercise; @@ -18,6 +19,7 @@ public interface Importer { ImportResult importData( + TxCtx ctx, JsonNode importNode, Map docReferences, Exercise exercise, diff --git a/openaev-api/src/main/java/io/openaev/importer/V1_DataImporter.java b/openaev-api/src/main/java/io/openaev/importer/V1_DataImporter.java index cc1b5e8d544..04a73e4f268 100644 --- a/openaev-api/src/main/java/io/openaev/importer/V1_DataImporter.java +++ b/openaev-api/src/main/java/io/openaev/importer/V1_DataImporter.java @@ -17,8 +17,10 @@ import com.fasterxml.jackson.databind.node.ArrayNode; import com.fasterxml.jackson.databind.node.ObjectNode; import com.google.common.annotations.VisibleForTesting; +import io.openaev.config.TenantWriteScopeResolver; import io.openaev.config.cache.LicenseCacheManager; import io.openaev.context.TenantContext; +import io.openaev.context.TxCtx; import io.openaev.database.model.*; import io.openaev.database.model.Scenario.SEVERITY; import io.openaev.database.repository.*; @@ -30,6 +32,7 @@ import io.openaev.rest.domain.enums.PresetDomain; import io.openaev.rest.exercise.exports.VariableWithValueMixin; import io.openaev.rest.inject.form.InjectDependencyInput; +import io.openaev.rest.kill_chain_phase.service.KillChainPhaseService; import io.openaev.rest.payload.contract_output_element.ContractOutputElementInput; import io.openaev.rest.payload.form.DetectionRemediationInput; import io.openaev.rest.payload.form.PayloadCreateInput; @@ -73,7 +76,7 @@ public class V1_DataImporter implements Importer { private final DocumentRepository documentRepository; private final TagRepository tagRepository; private final AttackPatternRepository attackPatternRepository; - private final KillChainPhaseRepository killChainPhaseRepository; + private final KillChainPhaseService killChainPhaseService; private final ExerciseRepository exerciseRepository; private final ScenarioService scenarioService; private final TeamRepository teamRepository; @@ -108,6 +111,8 @@ public class V1_DataImporter implements Importer { private final InjectorService injectorService; + private final TenantWriteScopeResolver tenantWriteScopeResolver; + // endregion private String handleInjectContent( @@ -207,6 +212,7 @@ private Set computeTagsCompletion( @Override @Transactional public ImportResult importData( + TxCtx ctx, JsonNode importNode, Map docReferences, Exercise exercise, @@ -234,7 +240,7 @@ public ImportResult importData( // Should be done after tags & documents if (prefix.equals("payload_")) { - importPayloadAsMain(importNode, baseIds); + importPayloadAsMain(ctx, importNode, baseIds); } importOrganizations(importNode, prefix, baseIds); @@ -251,6 +257,7 @@ public ImportResult importData( Map resolvedContracts = new HashMap<>(); if (!hasWorkflowImport(importNode, prefix)) { importInjects( + ctx, importNode, prefix, savedExercise, @@ -263,7 +270,7 @@ public ImportResult importData( importVariables(importNode, savedExercise, savedScenario, baseIds); List skippedSteps = importWorkflow( - importNode, prefix, savedExercise, savedScenario, baseIds, resolvedContracts); + ctx, importNode, prefix, savedExercise, savedScenario, baseIds, resolvedContracts); List missingActions = skippedSteps.stream().map(V1_DataImporter::toMissingImportedAction).toList(); return new ImportResult(new ArrayList<>(missingActions)); @@ -449,7 +456,7 @@ private void resolveInjectContractDomainsFromInjectFormat( * there is no name/external id to recreate them from. */ private List importAttackPattern( - JsonNode importNode, String prefix, Map baseIds) { + TxCtx ctx, JsonNode importNode, String prefix, Map baseIds) { ArrayList attackPatterns = new ArrayList<>(); String tenantId = TenantContext.getCurrentTenant(); resolveJsonElements(importNode, prefix + "attack_patterns") @@ -496,7 +503,8 @@ private List importAttackPattern( this.attackPatternRepository.save( createAttackPattern( nodeAttackPattern, - importKillChainPhase(nodeAttackPattern, "attack_pattern_", baseIds))); + importKillChainPhase( + ctx, nodeAttackPattern, "attack_pattern_", baseIds))); baseIds.put(id, attackPatternCreated); attackPatterns.add(attackPatternCreated); } @@ -601,14 +609,16 @@ protected Set mergeDomains( * @return a deduplicated set of resolved attack patterns */ private Set mergeAttackPatterns( + TxCtx ctx, Map baseIds, JsonNode node1, String prefix1, @Nullable JsonNode node2, @Nullable String prefix2) { - Set patterns = new LinkedHashSet<>(importAttackPattern(node1, prefix1, baseIds)); + Set patterns = + new LinkedHashSet<>(importAttackPattern(ctx, node1, prefix1, baseIds)); if (node2 != null) { - patterns.addAll(importAttackPattern(node2, prefix2, baseIds)); + patterns.addAll(importAttackPattern(ctx, node2, prefix2, baseIds)); } return patterns; } @@ -625,8 +635,9 @@ private AttackPattern createAttackPattern( } private List importKillChainPhase( - JsonNode importNode, String prefix, Map baseIds) { + TxCtx ctx, JsonNode importNode, String prefix, Map baseIds) { List killChainPhases = new ArrayList<>(); + String writeTenant = tenantWriteScopeResolver.tenantForWrite(ctx, null); resolveJsonElements(importNode, prefix + "kill_chain_phases") .forEach( nodeKillChainPhase -> { @@ -640,25 +651,18 @@ private List importKillChainPhase( // Already imported return; } - String name = nodeKillChainPhase.get("phase_external_id").textValue(); - - List existingKillChainPhases = - this.killChainPhaseRepository.findAllByExternalIdInIgnoreCase(List.of(name)); - if (!existingKillChainPhases.isEmpty()) { - baseIds.put(id, existingKillChainPhases.getFirst()); - killChainPhases.add(existingKillChainPhases.getFirst()); - } else { - KillChainPhase killChainPhaseCreated = - this.killChainPhaseRepository.save(createKillChainPhase(nodeKillChainPhase)); - baseIds.put(id, killChainPhaseCreated); - killChainPhases.add(killChainPhaseCreated); - } + KillChainPhase killChainPhase = + this.killChainPhaseService.resolveOrCreateForImport( + writeTenant, createKillChainPhase(nodeKillChainPhase, writeTenant)); + baseIds.put(id, killChainPhase); + killChainPhases.add(killChainPhase); }); return killChainPhases; } - private KillChainPhase createKillChainPhase(JsonNode killChainPhaseNode) { + private KillChainPhase createKillChainPhase(JsonNode killChainPhaseNode, String tenantId) { KillChainPhase killChainPhase = new KillChainPhase(); + killChainPhase.setTenant(new Tenant(tenantId)); killChainPhase.setKillChainName(killChainPhaseNode.get("phase_kill_chain_name").textValue()); killChainPhase.setShortName(killChainPhaseNode.get("phase_shortname").textValue()); killChainPhase.setDescription(killChainPhaseNode.get("phase_description").textValue()); @@ -1310,6 +1314,7 @@ private LessonsQuestion createLessonsQuestion( } private void importInjects( + TxCtx ctx, JsonNode importNode, String prefix, Exercise savedExercise, @@ -1356,6 +1361,7 @@ private void importInjects( .filter(jsonNode -> !children.contains(jsonNode.get("inject_id").asText())); importInjects( + ctx, baseIds, savedExercise, savedScenario, @@ -1367,6 +1373,7 @@ private void importInjects( } private void importInjects( + TxCtx ctx, Map baseIds, Exercise exercise, Scenario scenario, @@ -1411,7 +1418,7 @@ private void importInjects( if (injectorContract.isPresent()) { injectorContractId = injectorContract.get().getId(); } else { - resolvedContract = resolveInjectorContract(injectContractNode, baseIds); + resolvedContract = resolveInjectorContract(ctx, injectContractNode, baseIds); injectorContractId = resolvedContract != null ? resolvedContract.getId() : null; } @@ -1437,7 +1444,8 @@ private void importInjects( Payload createdPayload = resolvedContract != null ? resolvedContract.getPayload() : null; injectorContractId = - importInjectorContractFromStarterPack(injectContractNode, createdPayload, baseIds) + importInjectorContractFromStarterPack( + ctx, injectContractNode, createdPayload, baseIds) .getId(); } else { log.warn( @@ -1593,6 +1601,7 @@ private void importInjects( .toList(); if (!childInjects.isEmpty()) { importInjects( + ctx, baseIds, exercise, scenario, @@ -1614,7 +1623,7 @@ private void importInjects( * @return */ private InjectorContract importInjectorContractFromStarterPack( - JsonNode importNode, Payload payload, Map baseIds) { + TxCtx ctx, JsonNode importNode, Payload payload, Map baseIds) { InjectorContract injectorContract = new InjectorContract(); injectorContract.setId(importNode.get("injector_contract_id").textValue()); @@ -1646,6 +1655,7 @@ private InjectorContract importInjectorContractFromStarterPack( injectorContract.setAttackPatterns( new ArrayList<>( mergeAttackPatterns( + ctx, baseIds, importNode, "injector_contract_", @@ -1730,7 +1740,10 @@ private Set buildOutputParsersFromPayloadJsonNode( } private PayloadCreateInput buildPayloadCreateInput( - Map baseIds, JsonNode payloadNode, @Nullable JsonNode injectorContractNode) { + TxCtx ctx, + Map baseIds, + JsonNode payloadNode, + @Nullable JsonNode injectorContractNode) { PayloadCreateInput payloadCreateInput = buildPayload(payloadNode); payloadCreateInput.setOutputParsers( buildOutputParsersFromPayloadJsonNode(payloadNode, baseIds)); @@ -1751,7 +1764,7 @@ private PayloadCreateInput buildPayloadCreateInput( // Attack patterns — merge from payload and injector contract nodes Set attackPatterns = mergeAttackPatterns( - baseIds, payloadNode, "payload_", injectorContractNode, "injector_contract_"); + ctx, baseIds, payloadNode, "payload_", injectorContractNode, "injector_contract_"); payloadCreateInput.setAttackPatternsIds( attackPatterns.stream().map(AttackPattern::getId).collect(Collectors.toList())); @@ -1759,7 +1772,7 @@ private PayloadCreateInput buildPayloadCreateInput( } private String importPayloadAsMain( - @NotNull final JsonNode importNode, Map baseIds) { + TxCtx ctx, @NotNull final JsonNode importNode, Map baseIds) { JsonNode payloadNode = importNode.get("payload_information"); if (payloadNode == null) { return null; @@ -1790,7 +1803,8 @@ private String importPayloadAsMain( } } } - PayloadCreateInput payloadCreateInput = buildPayloadCreateInput(baseIds, payloadNode, null); + PayloadCreateInput payloadCreateInput = + buildPayloadCreateInput(ctx, baseIds, payloadNode, null); PayloadCreationService.PayloadInjectorContractCreationResult result = this.payloadCreationService.createPayload(payloadCreateInput); @@ -1820,7 +1834,7 @@ private String importPayloadAsMain( * if resolution failed */ private InjectorContract resolveInjectorContract( - @NotNull JsonNode injectContractNode, Map baseIds) { + TxCtx ctx, @NotNull JsonNode injectContractNode, Map baseIds) { JsonNode payloadNode = injectContractNode.get("injector_contract_payload"); if (payloadNode == null || payloadNode.isNull() || payloadNode.isEmpty()) { return null; @@ -1850,7 +1864,7 @@ private InjectorContract resolveInjectorContract( } // Not found then create the payload and its contract - return importPayload(payloadNode, injectContractNode, baseIds); + return importPayload(ctx, payloadNode, injectContractNode, baseIds); } /** @@ -1892,6 +1906,7 @@ private Optional resolveInjectorContractReadOnly(JsonNode inje } private InjectorContract importPayload( + TxCtx ctx, @NotNull final JsonNode payloadNode, @NotNull final JsonNode injectContractNode, Map baseIds) { @@ -1919,7 +1934,7 @@ private InjectorContract importPayload( } PayloadCreateInput payloadCreateInput = - buildPayloadCreateInput(baseIds, payloadNode, injectContractNode); + buildPayloadCreateInput(ctx, baseIds, payloadNode, injectContractNode); PayloadCreationService.PayloadInjectorContractCreationResult result = this.payloadCreationService.createPayload(payloadCreateInput); @@ -2452,6 +2467,7 @@ private void importVariables( // -- WORKFLOW (CHAINING) -- private List importWorkflow( + TxCtx ctx, JsonNode importNode, String prefix, Exercise savedExercise, @@ -2598,7 +2614,7 @@ private List importWorkflow( if (workflowNode.has("workflow_steps")) { skippedSteps = importWorkflowSteps( - workflowNode.get("workflow_steps"), workflow, resolvedContracts, baseIds); + ctx, workflowNode.get("workflow_steps"), workflow, resolvedContracts, baseIds); } if (!skippedSteps.isEmpty()) { // Unresolvable steps are surfaced to the caller (importData -> ImportResult) so the API can @@ -2625,6 +2641,7 @@ private List importWorkflow( } private List importWorkflowSteps( + TxCtx ctx, JsonNode stepsNode, Workflow workflow, Map resolvedContracts, @@ -2666,7 +2683,7 @@ private List importWorkflowSteps( // surfaced here as a skip, so the step is treated EXACTLY like one rejected upfront by // evaluateChainingStepResolvability: no saveStep, no stepIdMap entry, added to skippedSteps. StepDataResolution stepDataResolution = - resolveStepData(stepNode, resolvedContracts, baseIds, workflow); + resolveStepData(ctx, stepNode, resolvedContracts, baseIds, workflow); if (stepDataResolution.isFailed()) { SkippedWorkflowStep skipped = stepDataResolution.skipped(); skippedSteps.add(skipped); @@ -3038,6 +3055,7 @@ private String getNodeValue(JsonNode importNode) { * injector_contract_id. */ private StepDataResolution resolveStepData( + TxCtx ctx, JsonNode stepNode, Map resolvedContracts, Map baseIds, @@ -3076,13 +3094,13 @@ private StepDataResolution resolveStepData( JsonNode injectContractNode = dataJson.get("inject_injector_contract"); if (injectContractNode == null || injectContractNode.isNull()) { return StepDataResolution.resolved( - sanitizateStepData(dataJson, stepDataRaw, workflow, baseIds)); + sanitizateStepData(ctx, dataJson, stepDataRaw, workflow, baseIds)); } String injectorContractId = extractInjectorContractId(injectContractNode); if (!hasText(injectorContractId)) { return StepDataResolution.resolved( - sanitizateStepData(dataJson, stepDataRaw, workflow, baseIds)); + sanitizateStepData(ctx, dataJson, stepDataRaw, workflow, baseIds)); } // Contract already exists in DB (tenant-scoped: the composite PK is (tenant_id, id), so the @@ -3091,7 +3109,7 @@ private StepDataResolution resolveStepData( injectorContractId, TenantContext.getCurrentTenant()) && !shouldResolveContractFromStepData(injectContractNode, injectorContractId)) { return StepDataResolution.resolved( - sanitizateStepData(dataJson, stepDataRaw, workflow, baseIds)); + sanitizateStepData(ctx, dataJson, stepDataRaw, workflow, baseIds)); } // Already resolved by a previous step or by importInjects — reuse @@ -3101,7 +3119,7 @@ private StepDataResolution resolveStepData( return StepDataResolution.resolved(stepDataRaw); } return StepDataResolution.resolved( - sanitizateStepData(dataJson, stepDataRaw, workflow, baseIds)); + sanitizateStepData(ctx, dataJson, stepDataRaw, workflow, baseIds)); } if (!(injectContractNode instanceof ObjectNode injectContractObject)) { @@ -3109,7 +3127,7 @@ private StepDataResolution resolveStepData( "Step data references missing injector contract {} in textual form with no payload to recreate", injectorContractId); return StepDataResolution.resolved( - sanitizateStepData(dataJson, stepDataRaw, workflow, baseIds)); + sanitizateStepData(ctx, dataJson, stepDataRaw, workflow, baseIds)); } // Contract is missing then resolve using the same logic as importInjects @@ -3119,7 +3137,7 @@ private StepDataResolution resolveStepData( "Step data references missing injector contract {} with no payload to recreate", injectorContractId); return StepDataResolution.resolved( - sanitizateStepData(dataJson, stepDataRaw, workflow, baseIds)); + sanitizateStepData(ctx, dataJson, stepDataRaw, workflow, baseIds)); } // A partial payload shape (e.g. only a stale payload_id, missing the fields buildPayload() @@ -3137,7 +3155,7 @@ private StepDataResolution resolveStepData( InjectorContract resolvedStepContract; try { - resolvedStepContract = resolveInjectorContract(injectContractObject, baseIds); + resolvedStepContract = resolveInjectorContract(ctx, injectContractObject, baseIds); } catch (Exception e) { // Recreation failed on unexpected embedded data BEFORE any transactional work (e.g. a // malformed field slipping past hasEmbeddedPayloadData in buildPayloadCreateInput, which @@ -3168,7 +3186,7 @@ private StepDataResolution resolveStepData( return StepDataResolution.resolved(stepDataRaw); } return StepDataResolution.resolved( - sanitizateStepData(dataJson, stepDataRaw, workflow, baseIds)); + sanitizateStepData(ctx, dataJson, stepDataRaw, workflow, baseIds)); } // Creation was attempted (embedded payload present) but failed silently: importPayload returned @@ -3189,7 +3207,7 @@ private StepDataResolution resolveStepData( } return StepDataResolution.resolved( - sanitizateStepData(dataJson, stepDataRaw, workflow, baseIds)); + sanitizateStepData(ctx, dataJson, stepDataRaw, workflow, baseIds)); } /** @@ -3305,7 +3323,7 @@ private String serializeStepData(JsonNode dataJson, String fallback) { } private String sanitizateStepData( - JsonNode dataJson, String fallback, Workflow workflow, Map baseIds) { + TxCtx ctx, JsonNode dataJson, String fallback, Workflow workflow, Map baseIds) { if (!(dataJson instanceof ObjectNode dataObject) || workflow == null) { return fallback; } @@ -3346,7 +3364,7 @@ private String sanitizateStepData( if (injectContractNode instanceof ObjectNode injectContractObject) { rewriteImportedTagIds(injectContractObject, "injector_contract_tags", baseIds); rewriteInjectorContractDomains(injectContractObject, baseIds); - rewriteInjectorContractAttackPatterns(injectContractObject, baseIds); + rewriteInjectorContractAttackPatterns(ctx, injectContractObject, baseIds); } buildStepTtpFromInjectorContract(dataObject); if (workflow.getSimulation() != null) { @@ -3550,11 +3568,11 @@ private void rewriteInjectorContractDomains(ObjectNode contractNode, Map baseIds) { - rewriteAttackPatternArray(contractNode, "injector_contract_", baseIds); + TxCtx ctx, ObjectNode contractNode, Map baseIds) { + rewriteAttackPatternArray(ctx, contractNode, "injector_contract_", baseIds); JsonNode payloadNode = contractNode.get("injector_contract_payload"); if (payloadNode instanceof ObjectNode payloadObject) { - rewriteAttackPatternArray(payloadObject, "payload_", baseIds); + rewriteAttackPatternArray(ctx, payloadObject, "payload_", baseIds); } } @@ -3596,14 +3614,14 @@ private void buildStepTtpFromInjectorContract(ObjectNode dataObject) { } private void rewriteAttackPatternArray( - ObjectNode node, String prefix, Map baseIds) { + TxCtx ctx, ObjectNode node, String prefix, Map baseIds) { JsonNode attackPatternsNode = node.get(prefix + "attack_patterns"); if (attackPatternsNode == null || !attackPatternsNode.isArray()) { return; } // importAttackPattern resolves both OBJECT entries (baseIds cache, external id, creation) and // SCALAR entries (baseIds cache, tenant-scoped existence check) — see its javadoc. - List resolvedAttackPatterns = importAttackPattern(node, prefix, baseIds); + List resolvedAttackPatterns = importAttackPattern(ctx, node, prefix, baseIds); LinkedHashSet ids = new LinkedHashSet<>(); for (AttackPattern attackPattern : resolvedAttackPatterns) { if (attackPattern != null && attackPattern.getId() != null) { diff --git a/openaev-api/src/main/java/io/openaev/processor/datapack/DataPack.java b/openaev-api/src/main/java/io/openaev/processor/datapack/DataPack.java index 2fdf4388e6c..94ffbd2dd92 100644 --- a/openaev-api/src/main/java/io/openaev/processor/datapack/DataPack.java +++ b/openaev-api/src/main/java/io/openaev/processor/datapack/DataPack.java @@ -14,7 +14,7 @@ @Slf4j /** * Base class for tenant-scoped data packs (initial/seed data). Subclasses implement {@link - * #doProcess()} which is executed exactly once per tenant (idempotency tracked via {@link + * #doProcess(Tenant)} which is executed exactly once per tenant (idempotency tracked via {@link * DataPackService}). * *

Implementations must follow the {@code V{YYYYMMDD}_Description} naming convention to ensure @@ -24,8 +24,8 @@ *

Deliberately NOT {@code @Transactional}: this class is background code, driven by {@link * io.openaev.processor.MigrationProcessor MigrationProcessor}, which opens the single tenant-scoped * transaction (via {@code TenantScopedTransaction.execute}) around the whole {@link - * #process(Tenant)} call, idempotency check included. A subclass's {@link #doProcess()} must NOT - * open its own transaction/scope (no {@code @Transactional}, no {@code TenantScopedTransaction} + * #process(Tenant)} call, idempotency check included. A subclass's {@link #doProcess(Tenant)} must + * NOT open its own transaction/scope (no {@code @Transactional}, no {@code TenantScopedTransaction} * call) — it runs inside the caller's transaction and inherits its scope automatically. */ public abstract class DataPack implements Processable { @@ -37,7 +37,7 @@ protected DataPack(DataPackService dataPackService) { this.dataPackService = dataPackService; } - protected abstract boolean doProcess(); + protected abstract boolean doProcess(Tenant tenant); /** * Enables the v1 Hibernate {@code tenantFilter} for the current tenant. Call this explicitly, @@ -67,7 +67,7 @@ public MigrationProcessingResult process(Tenant tenant) { .orElseGet( () -> { log.info("Processing datapack '{}' for tenant {}.", packId, tenant.getId()); - if (doProcess()) { + if (doProcess(tenant)) { dataPackService.registerDataPack(packId, tenant); } return MigrationProcessingResult.PROCESSED; diff --git a/openaev-api/src/main/java/io/openaev/processor/datapack/V20260101_Starter_pack.java b/openaev-api/src/main/java/io/openaev/processor/datapack/V20260101_Starter_pack.java index 498477e809b..0ebf35afd46 100644 --- a/openaev-api/src/main/java/io/openaev/processor/datapack/V20260101_Starter_pack.java +++ b/openaev-api/src/main/java/io/openaev/processor/datapack/V20260101_Starter_pack.java @@ -1,6 +1,7 @@ package io.openaev.processor.datapack; import io.openaev.context.TenantContext; +import io.openaev.context.TxCtx; import io.openaev.database.model.*; import io.openaev.database.repository.SettingRepository; import io.openaev.jsonapi.JsonApiDocument; @@ -87,7 +88,7 @@ private static final class AllEndpointsAssetGroup { private final ResourcePatternResolver resolver; @Override - protected boolean doProcess() { + protected boolean doProcess(Tenant tenant) { // early break for when the starter pack was already run if (!isStarterPackEnabled) { log.info("Starter pack is disabled by configuration"); @@ -133,7 +134,7 @@ protected boolean doProcess() { new ArrayList<>(List.of(allEndpointAssetGroup.getId())), TenantContext.getCurrentTenant()); - this.importScenariosFromResources(honeyScanMeEndpoint, allEndpointAssetGroup); + this.importScenariosFromResources(tenant.getId(), honeyScanMeEndpoint, allEndpointAssetGroup); this.importDashboardsFromResources(); return true; } catch (Exception e) { @@ -172,13 +173,19 @@ private AssetGroup createAllEndpointsAssetGroup() { return this.assetGroupService.createAssetGroup(allEndpointsAssetGroup); } - private void importScenariosFromResources(Asset asset, AssetGroup assetGroup) { + private void importScenariosFromResources(String tenantId, Asset asset, AssetGroup assetGroup) { listFilesInResourceFolder(Config.SCENARIOS_FOLDER_NAME) .forEach( resourceToAdd -> { try { this.importService.handleInputStreamFileImport( - resourceToAdd.getInputStream(), null, null, asset, assetGroup, ""); + TxCtx.forTenant(tenantId), + resourceToAdd.getInputStream(), + null, + null, + asset, + assetGroup, + ""); log.info( "Successfully imported StarterPack scenario file : {}", resourceToAdd.getFilename()); diff --git a/openaev-api/src/main/java/io/openaev/processor/datapack/V20260107_Tags_and_tagrules_and_assetgroups.java b/openaev-api/src/main/java/io/openaev/processor/datapack/V20260107_Tags_and_tagrules_and_assetgroups.java index a22f9046e2c..69a2ac54dd9 100644 --- a/openaev-api/src/main/java/io/openaev/processor/datapack/V20260107_Tags_and_tagrules_and_assetgroups.java +++ b/openaev-api/src/main/java/io/openaev/processor/datapack/V20260107_Tags_and_tagrules_and_assetgroups.java @@ -1,6 +1,7 @@ package io.openaev.processor.datapack; import io.openaev.database.model.*; +import io.openaev.database.model.Tenant; import io.openaev.rest.tag.TagService; import io.openaev.service.AssetGroupService; import io.openaev.service.DataPackService; @@ -48,7 +49,7 @@ private Optional findTagRuleForPlatform( } @Override - public boolean doProcess() { + public boolean doProcess(Tenant tenant) { try { // TODO v2: once tags get v2 activated // https://github.com/OpenAEV-Platform/openaev/issues/6424, and tag_rules get v2 activated diff --git a/openaev-api/src/main/java/io/openaev/processor/datapack/V20260330_Default_tenant_data.java b/openaev-api/src/main/java/io/openaev/processor/datapack/V20260330_Default_tenant_data.java index 586fb968d74..4e1639061ca 100644 --- a/openaev-api/src/main/java/io/openaev/processor/datapack/V20260330_Default_tenant_data.java +++ b/openaev-api/src/main/java/io/openaev/processor/datapack/V20260330_Default_tenant_data.java @@ -4,6 +4,7 @@ import io.openaev.context.TenantContext; import io.openaev.database.model.*; +import io.openaev.database.model.Tenant; import io.openaev.database.repository.CweRepository; import io.openaev.database.repository.GroupRepository; import io.openaev.database.repository.UserRepository; @@ -45,7 +46,7 @@ public V20260330_Default_tenant_data( } @Override - public boolean doProcess() { + public boolean doProcess(Tenant tenant) { try { if (!Tenant.DEFAULT_TENANT_UUID.equals(TenantContext.getCurrentTenant())) { // Init vulnerabilities diff --git a/openaev-api/src/main/java/io/openaev/processor/datapack/V20260518_Service_Account.java b/openaev-api/src/main/java/io/openaev/processor/datapack/V20260518_Service_Account.java index 2c8c9880a50..84d4a344b5b 100644 --- a/openaev-api/src/main/java/io/openaev/processor/datapack/V20260518_Service_Account.java +++ b/openaev-api/src/main/java/io/openaev/processor/datapack/V20260518_Service_Account.java @@ -1,6 +1,7 @@ package io.openaev.processor.datapack; import io.openaev.context.TenantContext; +import io.openaev.database.model.Tenant; import io.openaev.service.DataPackService; import io.openaev.service.account.ServiceAccountPrivilegeService; import lombok.extern.slf4j.Slf4j; @@ -18,7 +19,7 @@ public V20260518_Service_Account( } @Override - protected boolean doProcess() { + protected boolean doProcess(Tenant tenant) { try { privilegeService.ensurePrivilegedUserExists(TenantContext.getCurrentTenant()); } catch (Exception e) { diff --git a/openaev-api/src/main/java/io/openaev/processor/datapack/V20260708_Dynamic_injectors_base_url.java b/openaev-api/src/main/java/io/openaev/processor/datapack/V20260708_Dynamic_injectors_base_url.java index d2ac87cbe3a..4950caddb7e 100644 --- a/openaev-api/src/main/java/io/openaev/processor/datapack/V20260708_Dynamic_injectors_base_url.java +++ b/openaev-api/src/main/java/io/openaev/processor/datapack/V20260708_Dynamic_injectors_base_url.java @@ -1,6 +1,7 @@ package io.openaev.processor.datapack; import io.openaev.database.model.Injector; +import io.openaev.database.model.Tenant; import io.openaev.database.repository.InjectorRepository; import io.openaev.service.DataPackService; import java.util.HashMap; @@ -40,7 +41,7 @@ public V20260708_Dynamic_injectors_base_url( } @Override - protected boolean doProcess() { + protected boolean doProcess(Tenant tenant) { List injectors = injectorRepository.findAll(); List updatedInjectors = injectors.stream().filter(this::replaceParamsInInjectorCommands).toList(); diff --git a/openaev-api/src/main/java/io/openaev/processor/datapack/V20260805_Observer_and_manager_users.java b/openaev-api/src/main/java/io/openaev/processor/datapack/V20260805_Observer_and_manager_users.java index 672bf937ffb..45ecc75d3e6 100644 --- a/openaev-api/src/main/java/io/openaev/processor/datapack/V20260805_Observer_and_manager_users.java +++ b/openaev-api/src/main/java/io/openaev/processor/datapack/V20260805_Observer_and_manager_users.java @@ -2,6 +2,7 @@ import io.openaev.context.TenantContext; import io.openaev.database.model.*; +import io.openaev.database.model.Tenant; import io.openaev.database.repository.GroupRepository; import io.openaev.database.repository.UserRepository; import io.openaev.service.DataPackService; @@ -61,7 +62,7 @@ public V20260805_Observer_and_manager_users( } @Override - protected boolean doProcess() { + protected boolean doProcess(Tenant tenant) { String password = resolveUserPassword(); ensureRolesAndGroups(); createUserInGroup(OBSERVER_EMAIL, "Test", "Observer", "Observer", password); diff --git a/openaev-api/src/main/java/io/openaev/rest/atomic_testing/AtomicTestingApi.java b/openaev-api/src/main/java/io/openaev/rest/atomic_testing/AtomicTestingApi.java index 4c900948a27..4f56ff503da 100644 --- a/openaev-api/src/main/java/io/openaev/rest/atomic_testing/AtomicTestingApi.java +++ b/openaev-api/src/main/java/io/openaev/rest/atomic_testing/AtomicTestingApi.java @@ -342,6 +342,7 @@ public List findTargetResultMerged( resourceType = ResourceType.INJECT) @Transactional(rollbackFor = Exception.class) public InjectResultOverviewOutput updateAtomicTestingTags( + TxCtx ctx, @PathVariable @NotBlank final String injectId, @Valid @RequestBody final AtomicTestingUpdateTagsInput input) { return atomicTestingService.updateAtomicTestingTags(injectId, input); @@ -381,6 +382,6 @@ public void atomicTestingImport( throw new UnprocessableContentException("Insufficient input: file is required"); } - this.injectImportService.importInjectsForAtomicTestings(file); + this.injectImportService.importInjectsForAtomicTestings(ctx, file); } } diff --git a/openaev-api/src/main/java/io/openaev/rest/attack_pattern/AttackPatternApi.java b/openaev-api/src/main/java/io/openaev/rest/attack_pattern/AttackPatternApi.java index e22f6ce06ab..18f7be66db5 100644 --- a/openaev-api/src/main/java/io/openaev/rest/attack_pattern/AttackPatternApi.java +++ b/openaev-api/src/main/java/io/openaev/rest/attack_pattern/AttackPatternApi.java @@ -7,6 +7,7 @@ import static io.openaev.utils.pagination.PaginationUtils.buildPaginationJPA; import io.openaev.aop.AccessControl; +import io.openaev.context.TxCtx; import io.openaev.database.model.*; import io.openaev.database.raw.RawAttackPatternIndexing; import io.openaev.database.repository.AttackPatternRepository; @@ -16,9 +17,11 @@ import io.openaev.rest.attack_pattern.form.AttackPatternCreateInput; import io.openaev.rest.attack_pattern.form.AttackPatternUpdateInput; import io.openaev.rest.attack_pattern.form.AttackPatternUpsertInput; +import io.openaev.rest.attack_pattern.response.AttackPatternOutput; import io.openaev.rest.attack_pattern.service.AttackPatternService; import io.openaev.rest.exception.ElementNotFoundException; import io.openaev.rest.helper.RestBehavior; +import io.openaev.rest.kill_chain_phase.service.KillChainPhaseService; import io.openaev.utils.FilterUtilsJpa; import io.openaev.utils.pagination.SearchPaginationInput; import io.swagger.v3.oas.annotations.Operation; @@ -28,6 +31,7 @@ import java.time.Instant; import java.util.ArrayList; import java.util.List; +import java.util.Map; import lombok.RequiredArgsConstructor; import org.springframework.data.domain.Page; import org.springframework.data.domain.Sort; @@ -47,21 +51,30 @@ public class AttackPatternApi extends RestBehavior { private final AttackPatternRepository attackPatternRepository; private final InjectorContractRepository injectorContractRepository; private final KillChainPhaseRepository killChainPhaseRepository; + private final KillChainPhaseService killChainPhaseService; @GetMapping @Transactional @AccessControl(actionPerformed = Action.READ, resourceType = ResourceType.ATTACK_PATTERN) - public List attackPatterns() { + public List attackPatterns(TxCtx ctx) { return attackPatternRepository.rawAll(); } @PostMapping("/search") @Transactional @AccessControl(actionPerformed = Action.SEARCH, resourceType = ResourceType.ATTACK_PATTERN) - public Page attackPatterns( - @RequestBody @Valid final SearchPaginationInput searchPaginationInput) { - return buildPaginationJPA( - this.attackPatternRepository::findAll, searchPaginationInput, AttackPattern.class); + public Page attackPatterns( + TxCtx ctx, @RequestBody @Valid final SearchPaginationInput searchPaginationInput) { + Page page = + buildPaginationJPA( + this.attackPatternRepository::findAll, searchPaginationInput, AttackPattern.class); + Map> phaseIds = + this.killChainPhaseService.phaseIdsByAttackPatternId( + page.getContent().stream().map(AttackPattern::getId).toList()); + return page.map( + attackPattern -> + AttackPatternOutput.from( + attackPattern, phaseIds.getOrDefault(attackPattern.getId(), List.of()))); } @PostMapping("/search-with-ai") @@ -84,14 +97,20 @@ public List searchAttackPatternWithTTPAIWebservice( resourceId = "#attackPatternId", actionPerformed = Action.READ, resourceType = ResourceType.ATTACK_PATTERN) - public AttackPattern attackPattern(@PathVariable String attackPatternId) { - return attackPatternService.findById(attackPatternId); + public AttackPatternOutput attackPattern(TxCtx ctx, @PathVariable String attackPatternId) { + AttackPattern attackPattern = attackPatternService.findById(attackPatternId); + return AttackPatternOutput.from( + attackPattern, + this.killChainPhaseService + .phaseIdsByAttackPatternId(List.of(attackPatternId)) + .getOrDefault(attackPatternId, List.of())); } @PostMapping @AccessControl(actionPerformed = Action.CREATE, resourceType = ResourceType.ATTACK_PATTERN) @Transactional(rollbackFor = Exception.class) - public AttackPattern createAttackPattern(@Valid @RequestBody AttackPatternCreateInput input) { + public AttackPattern createAttackPattern( + TxCtx ctx, @Valid @RequestBody AttackPatternCreateInput input) { AttackPattern attackPattern = new AttackPattern(); attackPattern.setUpdateAttributes(input); attackPattern.setKillChainPhases( @@ -120,6 +139,7 @@ public Iterable injectorContracts(@PathVariable String attackP resourceType = ResourceType.ATTACK_PATTERN) @Transactional(rollbackFor = Exception.class) public AttackPattern updateAttackPattern( + TxCtx ctx, @NotBlank @PathVariable final String attackPatternId, @Valid @RequestBody AttackPatternUpdateInput input) { AttackPattern attackPattern = @@ -137,7 +157,7 @@ public AttackPattern updateAttackPattern( @AccessControl(actionPerformed = Action.CREATE, resourceType = ResourceType.ATTACK_PATTERN) @Transactional(rollbackFor = Exception.class) public Iterable upsertAttackPatterns( - @Valid @RequestBody AttackPatternUpsertInput input) { + TxCtx ctx, @Valid @RequestBody AttackPatternUpsertInput input) { List upserted = new ArrayList<>(); List attackPatterns = input.getAttackPatterns(); List patternsWithoutParent = diff --git a/openaev-api/src/main/java/io/openaev/rest/attack_pattern/response/AttackPatternOutput.java b/openaev-api/src/main/java/io/openaev/rest/attack_pattern/response/AttackPatternOutput.java new file mode 100644 index 00000000000..e55e59c6728 --- /dev/null +++ b/openaev-api/src/main/java/io/openaev/rest/attack_pattern/response/AttackPatternOutput.java @@ -0,0 +1,53 @@ +package io.openaev.rest.attack_pattern.response; + +import com.fasterxml.jackson.annotation.JsonProperty; +import io.openaev.database.model.AttackPattern; +import io.swagger.v3.oas.annotations.media.ArraySchema; +import io.swagger.v3.oas.annotations.media.Schema; +import java.time.Instant; +import java.util.List; + +@Schema(description = "Attack pattern as returned by the read endpoints") +public record AttackPatternOutput( + @JsonProperty("attack_pattern_id") @Schema(description = "Id of the attack pattern") String id, + @JsonProperty("attack_pattern_stix_id") @Schema(description = "STIX id") String stixId, + @JsonProperty("attack_pattern_external_id") + @Schema(description = "External id, e.g. the MITRE technique id") + String externalId, + @JsonProperty("attack_pattern_name") @Schema(description = "Name of the attack pattern") + String name, + @JsonProperty("attack_pattern_description") + @Schema(description = "Description of the attack pattern") + String description, + @JsonProperty("attack_pattern_platforms") + @ArraySchema(schema = @Schema(description = "Targeted platforms")) + String[] platforms, + @JsonProperty("attack_pattern_permissions_required") + @ArraySchema(schema = @Schema(description = "Permissions the technique requires")) + String[] permissionsRequired, + @JsonProperty("attack_pattern_parent") @Schema(description = "Id of the parent attack pattern") + String parent, + @JsonProperty("attack_pattern_created_at") @Schema(description = "Creation date") + Instant createdAt, + @JsonProperty("attack_pattern_updated_at") @Schema(description = "Last update date") + Instant updatedAt, + @JsonProperty("attack_pattern_kill_chain_phases") + @ArraySchema(schema = @Schema(description = "Ids of the linked kill chain phases")) + List killChainPhases) { + + public static AttackPatternOutput from( + AttackPattern attackPattern, List killChainPhaseIds) { + return new AttackPatternOutput( + attackPattern.getId(), + attackPattern.getStixId(), + attackPattern.getExternalId(), + attackPattern.getName(), + attackPattern.getDescription(), + attackPattern.getPlatforms(), + attackPattern.getPermissionsRequired(), + attackPattern.getParent() == null ? null : attackPattern.getParent().getId(), + attackPattern.getCreatedAt(), + attackPattern.getUpdatedAt(), + killChainPhaseIds); + } +} diff --git a/openaev-api/src/main/java/io/openaev/rest/dashboard/DashboardApi.java b/openaev-api/src/main/java/io/openaev/rest/dashboard/DashboardApi.java index cd72759cef3..494b5d83fe5 100644 --- a/openaev-api/src/main/java/io/openaev/rest/dashboard/DashboardApi.java +++ b/openaev-api/src/main/java/io/openaev/rest/dashboard/DashboardApi.java @@ -5,6 +5,7 @@ import io.openaev.aop.AccessControl; import io.openaev.api.dashboard.dto.AdHocWidgetInput; import io.openaev.api.dashboard.dto.AdHocWidgetToEntitiesInput; +import io.openaev.context.TxCtx; import io.openaev.database.model.Action; import io.openaev.database.model.ResourceType; import io.openaev.engine.model.EsSearch; @@ -101,6 +102,7 @@ public WidgetToEntitiesOutput widgetToEntitiesRuntime( actionPerformed = Action.READ, resourceType = ResourceType.DASHBOARD) public List attackPaths( + TxCtx ctx, @PathVariable final String widgetId, @RequestBody(required = false) Map parameters) throws ExecutionException, InterruptedException { diff --git a/openaev-api/src/main/java/io/openaev/rest/exercise/ExerciseApi.java b/openaev-api/src/main/java/io/openaev/rest/exercise/ExerciseApi.java index 0ab6922731c..573a6c133aa 100644 --- a/openaev-api/src/main/java/io/openaev/rest/exercise/ExerciseApi.java +++ b/openaev-api/src/main/java/io/openaev/rest/exercise/ExerciseApi.java @@ -45,6 +45,7 @@ import io.openaev.rest.helper.RestBehavior; import io.openaev.rest.inject.form.InjectExpectationResultsByAttackPattern; import io.openaev.rest.inject.service.InjectService; +import io.openaev.rest.kill_chain_phase.KillChainPhaseInitializer; import io.openaev.rest.team.output.TeamOutput; import io.openaev.service.*; import io.openaev.service.account.ReservedKeyValidator; @@ -384,6 +385,7 @@ public Iterable getPlayersByExercise(@PathVariable String exerciseId) actionPerformed = Action.WRITE, resourceType = ResourceType.SIMULATION) public Exercise enableExerciseTeamPlayers( + TxCtx ctx, @PathVariable String exerciseId, @PathVariable String teamId, @Valid @RequestBody ExerciseTeamPlayersEnableInput input) { @@ -391,7 +393,8 @@ public Exercise enableExerciseTeamPlayers( teamRepository .findByIdAndTenantId(teamId, TenantContext.getCurrentTenant()) .orElseThrow(ElementNotFoundException::new); - return exerciseService.enablePlayers(exerciseId, team, input.getPlayersIds()); + return hydrateKillChainPhases( + exerciseService.enablePlayers(exerciseId, team, input.getPlayersIds())); } @Transactional(rollbackFor = Exception.class) @@ -404,6 +407,7 @@ public Exercise enableExerciseTeamPlayers( actionPerformed = Action.WRITE, resourceType = ResourceType.SIMULATION) public Exercise disableExerciseTeamPlayers( + TxCtx ctx, @PathVariable String exerciseId, @PathVariable String teamId, @Valid @RequestBody ExerciseTeamPlayersEnableInput input) { @@ -417,7 +421,7 @@ public Exercise disableExerciseTeamPlayers( exerciseTeamUserId.setUserId(playerId); exerciseTeamUserRepository.deleteById(exerciseTeamUserId); }); - return exerciseService.exercise(exerciseId); + return hydrateKillChainPhases(exerciseService.exercise(exerciseId)); } @Transactional(rollbackFor = Exception.class) @@ -430,6 +434,7 @@ public Exercise disableExerciseTeamPlayers( actionPerformed = Action.WRITE, resourceType = ResourceType.SIMULATION) public Exercise addExerciseTeamPlayers( + TxCtx ctx, @PathVariable String exerciseId, @PathVariable String teamId, @Valid @RequestBody ExerciseTeamPlayersEnableInput input) { @@ -443,8 +448,9 @@ public Exercise addExerciseTeamPlayers( List playersToAdd = ReservedKeyValidator.excludeReservedUsers(teamUsers); team.getUsers().addAll(playersToAdd); teamRepository.save(team); - return exerciseService.enablePlayers( - exerciseId, team, playersToAdd.stream().map(User::getId).toList()); + return hydrateKillChainPhases( + exerciseService.enablePlayers( + exerciseId, team, playersToAdd.stream().map(User::getId).toList())); } @PutMapping({ @@ -457,6 +463,7 @@ public Exercise addExerciseTeamPlayers( resourceType = ResourceType.SIMULATION) @Transactional(rollbackFor = Exception.class) public Exercise removeExerciseTeamPlayers( + TxCtx ctx, @PathVariable String exerciseId, @PathVariable String teamId, @Valid @RequestBody ExerciseTeamPlayersEnableInput input) { @@ -477,7 +484,7 @@ public Exercise removeExerciseTeamPlayers( exerciseTeamUserId.setUserId(playerId); exerciseTeamUserRepository.deleteById(exerciseTeamUserId); }); - return exerciseService.exercise(exerciseId); + return hydrateKillChainPhases(exerciseService.exercise(exerciseId)); } // endregion @@ -529,8 +536,8 @@ public Exercise createExercise(@Valid @RequestBody CreateExerciseInput input) { actionPerformed = Action.DUPLICATE, resourceType = ResourceType.SIMULATION) @Transactional(rollbackFor = Exception.class) - public Exercise duplicateExercise(@PathVariable @NotBlank final String exerciseId) { - return exerciseService.getDuplicateExercise(exerciseId); + public Exercise duplicateExercise(TxCtx ctx, @PathVariable @NotBlank final String exerciseId) { + return hydrateKillChainPhases(exerciseService.getDuplicateExercise(exerciseId)); } @PutMapping({EXERCISE_URI + "/{exerciseId}", TENANT_EXERCISE_URI + "/{exerciseId}"}) @@ -540,7 +547,7 @@ public Exercise duplicateExercise(@PathVariable @NotBlank final String exerciseI resourceType = ResourceType.SIMULATION) @Transactional(rollbackFor = Exception.class) public Exercise updateExerciseInformation( - @PathVariable String exerciseId, @Valid @RequestBody UpdateExerciseInput input) { + TxCtx ctx, @PathVariable String exerciseId, @Valid @RequestBody UpdateExerciseInput input) { Exercise exercise = exerciseService.exercise(exerciseId); Set currentTagList = exercise.getTags(); exercise.setTags(iterableToSet(this.tagRepository.findAllById(input.getTagIds()))); @@ -551,7 +558,8 @@ public Exercise updateExerciseInformation( } else { exercise.setCustomDashboard(null); } - return exerciseService.updateExercice(exercise, currentTagList, input.isApplyTagRule()); + return hydrateKillChainPhases( + exerciseService.updateExercice(exercise, currentTagList, input.isApplyTagRule())); } @PutMapping({ @@ -617,11 +625,14 @@ private Exercise doUpdateExerciseStart(String exerciseId, ExerciseUpdateStartDat resourceType = ResourceType.SIMULATION) @Transactional(rollbackFor = Exception.class) public Exercise updateExerciseTags( - @PathVariable String exerciseId, @Valid @RequestBody ExerciseUpdateTagsInput input) { + TxCtx ctx, + @PathVariable String exerciseId, + @Valid @RequestBody ExerciseUpdateTagsInput input) { Exercise exercise = exerciseService.exercise(exerciseId); Set currentTagList = exercise.getTags(); exercise.setTags(iterableToSet(tagRepository.findAllById(input.getTagIds()))); - return exerciseService.updateExercice(exercise, currentTagList, input.isApplyTagRule()); + return hydrateKillChainPhases( + exerciseService.updateExercice(exercise, currentTagList, input.isApplyTagRule())); } @PutMapping({EXERCISE_URI + "/{exerciseId}/logos", TENANT_EXERCISE_URI + "/{exerciseId}/logos"}) @@ -631,11 +642,13 @@ public Exercise updateExerciseTags( resourceType = ResourceType.SIMULATION) @Transactional(rollbackFor = Exception.class) public Exercise updateExerciseLogos( - @PathVariable String exerciseId, @Valid @RequestBody ExerciseUpdateLogoInput input) { + TxCtx ctx, + @PathVariable String exerciseId, + @Valid @RequestBody ExerciseUpdateLogoInput input) { Exercise exercise = exerciseService.exercise(exerciseId); exercise.setLogoDark(documentRepository.findById(input.getLogoDark()).orElse(null)); exercise.setLogoLight(documentRepository.findById(input.getLogoLight()).orElse(null)); - return exerciseRepository.save(exercise); + return hydrateKillChainPhases(exerciseRepository.save(exercise)); } // -- OPTION -- @@ -670,7 +683,7 @@ public List optionsById(@RequestBody final List i resourceType = ResourceType.SIMULATION) @Transactional(rollbackFor = Exception.class) public Exercise updateExerciseLessons( - @PathVariable String exerciseId, @Valid @RequestBody LessonsInput input) { + TxCtx ctx, @PathVariable String exerciseId, @Valid @RequestBody LessonsInput input) { Exercise exercise = exerciseService.exercise(exerciseId); // Partial update: absent fields keep their current value (older API consumers // only send lessons_anonymized and must not reset the enabled flag). @@ -680,7 +693,7 @@ public Exercise updateExerciseLessons( if (input.getLessonsEnabled() != null) { exercise.setLessonsEnabled(input.getLessonsEnabled()); } - return exerciseRepository.save(exercise); + return hydrateKillChainPhases(exerciseRepository.save(exercise)); } @DeleteMapping({EXERCISE_URI + "/{exerciseId}", TENANT_EXERCISE_URI + "/{exerciseId}"}) @@ -714,7 +727,7 @@ public List bulkDeleteExercises( actionPerformed = Action.READ, resourceType = ResourceType.SIMULATION) @Transactional(readOnly = true) - public SimulationDetails exercise(@PathVariable String exerciseId) { + public SimulationDetails exercise(TxCtx ctx, @PathVariable String exerciseId) { // We get the raw exercise RawSimulationIndexing rawSimulation = exerciseService.rawSimulation(exerciseId); // We get aggregated inject metadata: platforms, comms count, kill chain phases @@ -839,7 +852,8 @@ public List injectResults( actionPerformed = Action.DELETE, resourceType = ResourceType.SIMULATION) @Transactional(rollbackFor = Exception.class) - public Exercise deleteDocument(@PathVariable String exerciseId, @PathVariable String documentId) { + public Exercise deleteDocument( + TxCtx ctx, @PathVariable String exerciseId, @PathVariable String documentId) { Exercise exercise = exerciseService.exercise(exerciseId); exercise.setUpdatedAt(now()); Document doc = @@ -859,7 +873,7 @@ public Exercise deleteDocument(@PathVariable String exerciseId, @PathVariable St // Delete document from all exercise injects injectService.cleanInjectsDocExercise(exerciseId, documentId); } - return exerciseRepository.save(exercise); + return hydrateKillChainPhases(exerciseRepository.save(exercise)); } @PutMapping({EXERCISE_URI + "/{exerciseId}/status", TENANT_EXERCISE_URI + "/{exerciseId}/status"}) @@ -981,6 +995,7 @@ public void downloadAttachment(@RequestParam String file, HttpServletResponse re actionPerformed = Action.READ, resourceType = ResourceType.SIMULATION) public void exerciseExport( + TxCtx ctx, @NotBlank @PathVariable final String exerciseId, @RequestParam(required = false) final boolean isWithTeams, @RequestParam(required = false) final boolean isWithPlayers, @@ -1009,13 +1024,9 @@ public void exerciseExport( @PostMapping({EXERCISE_URI + "/import", TENANT_EXERCISE_URI + "/import"}) @Transactional @AccessControl(actionPerformed = Action.CREATE, resourceType = ResourceType.SIMULATION) - public ImportResult exerciseImport( - // Unused by the handler body; TenantScopeTransactionAspect reads it to set the tenant scope - // for the transaction (the V1_DataImporter resolves InjectorContract#getFirstInjector() and - // InjectorService#injectorTypeExists(...), both v2 tenant-scoped through the injectors - // table; without a scope, imported injects silently lose their injector). - TxCtx ctx, @RequestPart("file") MultipartFile file) throws Exception { - return importService.handleFileImport(file, null, null); + public ImportResult exerciseImport(TxCtx ctx, @RequestPart("file") MultipartFile file) + throws Exception { + return importService.handleFileImport(ctx, file, null, null); } @PostMapping({ @@ -1172,9 +1183,17 @@ public List documents(@PathVariable String exerciseId) { @ApiResponse(responseCode = "404", description = "Simulation or Scenario not found") }) public Scenario scenarioFromSimulation( + TxCtx ctx, @PathVariable @NotBlank @Schema(description = "ID of the simulation") final String simulationId) { - return scenarioService.scenarioFromSimulationId(simulationId); + Scenario scenario = scenarioService.scenarioFromSimulationId(simulationId); + KillChainPhaseInitializer.initializeFromInjects(scenario.getInjects()); + return scenario; + } + + private static Exercise hydrateKillChainPhases(Exercise exercise) { + KillChainPhaseInitializer.initializeFromInjects(exercise.getInjects()); + return exercise; } // end region diff --git a/openaev-api/src/main/java/io/openaev/rest/exercise/ExerciseImportApi.java b/openaev-api/src/main/java/io/openaev/rest/exercise/ExerciseImportApi.java index aa1371881fa..d64ddb4ff40 100644 --- a/openaev-api/src/main/java/io/openaev/rest/exercise/ExerciseImportApi.java +++ b/openaev-api/src/main/java/io/openaev/rest/exercise/ExerciseImportApi.java @@ -132,6 +132,6 @@ public void injectsImport( if (file == null || file.isEmpty()) { throw new UnprocessableContentException("Insufficient input: file is required"); } - this.injectImportService.importInjectsForSimulation(file, simulationId); + this.injectImportService.importInjectsForSimulation(ctx, file, simulationId); } } diff --git a/openaev-api/src/main/java/io/openaev/rest/inject/InjectApi.java b/openaev-api/src/main/java/io/openaev/rest/inject/InjectApi.java index 1c7ed56bc4a..0b87dda6e48 100644 --- a/openaev-api/src/main/java/io/openaev/rest/inject/InjectApi.java +++ b/openaev-api/src/main/java/io/openaev/rest/inject/InjectApi.java @@ -36,6 +36,7 @@ import io.openaev.rest.inject.service.InjectExecutionService; import io.openaev.rest.inject.service.InjectExportService; import io.openaev.rest.inject.service.InjectService; +import io.openaev.rest.kill_chain_phase.KillChainPhaseInitializer; import io.openaev.rest.payload.form.DetectionRemediationOutput; import io.openaev.rest.settings.PreviewFeature; import io.openaev.service.PreviewFeatureService; @@ -137,8 +138,11 @@ public void init() throws IOException, TimeoutException { resourceId = "#injectId", actionPerformed = Action.READ, resourceType = ResourceType.INJECT) - public Inject inject(@PathVariable @NotBlank final String injectId) { - return this.injectRepository.findById(injectId).orElseThrow(ElementNotFoundException::new); + public Inject inject(TxCtx ctx, @PathVariable @NotBlank final String injectId) { + Inject inject = + this.injectRepository.findById(injectId).orElseThrow(ElementNotFoundException::new); + KillChainPhaseInitializer.initializeFromInjects(List.of(inject)); + return inject; } @LogExecutionTime @@ -380,11 +384,13 @@ public List targetOptionsById( actionPerformed = Action.WRITE, resourceType = ResourceType.INJECT) public Inject injectExecutionReception( - @PathVariable String injectId, @Valid @RequestBody InjectReceptionInput input) { + TxCtx ctx, @PathVariable String injectId, @Valid @RequestBody InjectReceptionInput input) { Inject inject = injectRepository.findById(injectId).orElseThrow(ElementNotFoundException::new); InjectStatus injectStatus = inject.getStatus().orElseThrow(ElementNotFoundException::new); injectStatus.setName(ExecutionStatus.PENDING); - return injectRepository.save(inject); + Inject saved = injectRepository.save(inject); + KillChainPhaseInitializer.initializeFromInjects(List.of(saved)); + return saved; } @PostMapping({ @@ -535,25 +541,28 @@ public InjectOutput updateInject( @GetMapping({INJECT_URI + "/next", TENANT_INJECT_URI + "/next"}) @Transactional @AccessControl(actionPerformed = Action.SEARCH, resourceType = ResourceType.INJECT) - public List nextInjectsToExecute(@RequestParam Optional size) { - return injectRepository.findAll(InjectSpecification.next()).stream() - // Keep only injects visible by the user - .filter(inject -> inject.getDate().isPresent()) - .filter( - inject -> - inject - .getExercise() - .isUserHasAccess( - userRepository - .findById(currentUser().getId()) - .orElseThrow( - () -> new ElementNotFoundException("Current user not found")))) - // Order by near execution - .sorted(Inject.executionComparator) - // Keep only the expected size - .limit(size.orElse(MAX_NEXT_INJECTS)) - // Collect the result - .toList(); + public List nextInjectsToExecute(TxCtx ctx, @RequestParam Optional size) { + List next = + injectRepository.findAll(InjectSpecification.next()).stream() + // Keep only injects visible by the user + .filter(inject -> inject.getDate().isPresent()) + .filter( + inject -> + inject + .getExercise() + .isUserHasAccess( + userRepository + .findById(currentUser().getId()) + .orElseThrow( + () -> new ElementNotFoundException("Current user not found")))) + // Order by near execution + .sorted(Inject.executionComparator) + // Keep only the expected size + .limit(size.orElse(MAX_NEXT_INJECTS)) + // Collect the result + .toList(); + KillChainPhaseInitializer.initializeFromInjects(next); + return next; } // -- OPTION -- diff --git a/openaev-api/src/main/java/io/openaev/rest/inject/ScenarioInjectApi.java b/openaev-api/src/main/java/io/openaev/rest/inject/ScenarioInjectApi.java index fb4abb1a26a..f9b7d47d6cf 100644 --- a/openaev-api/src/main/java/io/openaev/rest/inject/ScenarioInjectApi.java +++ b/openaev-api/src/main/java/io/openaev/rest/inject/ScenarioInjectApi.java @@ -18,6 +18,7 @@ import io.openaev.rest.inject.service.InjectDuplicateService; import io.openaev.rest.inject.service.InjectService; import io.openaev.rest.inject.service.ScenarioInjectService; +import io.openaev.rest.kill_chain_phase.KillChainPhaseInitializer; import io.openaev.service.*; import io.openaev.service.scenario.ScenarioService; import io.openaev.utils.mapper.InjectMapper; @@ -104,9 +105,12 @@ public Iterable scenarioInjectsSimple( resourceType = ResourceType.SCENARIO) public Iterable scenarioInjects( TxCtx ctx, @PathVariable @NotBlank final String scenarioId) { - return this.injectRepository.findByScenarioId(scenarioId).stream() - .sorted(Inject.executionComparator) - .toList(); + List injects = + this.injectRepository.findByScenarioId(scenarioId).stream() + .sorted(Inject.executionComparator) + .toList(); + KillChainPhaseInitializer.initializeFromInjects(injects); + return injects; } @GetMapping({ @@ -124,7 +128,8 @@ public Inject scenarioInject( @PathVariable @NotBlank final String injectId) { Scenario scenario = this.scenarioService.scenario(scenarioId); assert scenarioId.equals(scenario.getId()); - return injectRepository.findById(injectId).orElseThrow(ElementNotFoundException::new); + return hydrateKillChainPhases( + injectRepository.findById(injectId).orElseThrow(ElementNotFoundException::new)); } // -- CREATE -- @@ -159,14 +164,13 @@ public InjectOutput createInjectForScenario( resourceType = ResourceType.SCENARIO) @Transactional(rollbackFor = Exception.class) public List createInjectsForScenario( - // Unused by the handler body; TenantScopeTransactionAspect reads it to set the tenant scope - // for the transaction (createAndSaveInjectList resolves the injector through the v2 - // tenant-scoped injectors table) — sibling createInjectForScenario already carries this. TxCtx ctx, @PathVariable @NotBlank final String scenarioId, @Valid @RequestBody List inputs) { Scenario scenario = this.scenarioService.scenario(scenarioId); - return this.injectService.createAndSaveInjectList(null, scenario, inputs); + List created = this.injectService.createAndSaveInjectList(null, scenario, inputs); + KillChainPhaseInitializer.initializeFromInjects(created); + return created; } @PostMapping({ @@ -182,15 +186,15 @@ public List createInjectsForScenario( summary = "Assistant to generate injects for scenario", description = "Generates injects based on the provided attack pattern and targets.") public List generateInjectsForScenario( - // Unused by the handler body; TenantScopeTransactionAspect reads it to set the tenant scope - // for the transaction (buildInject resolves the injector through the v2 tenant-scoped - // injectors table). TxCtx ctx, @PathVariable @NotBlank final String scenarioId, @Valid @RequestBody InjectAssistantInput input) { Scenario scenario = this.scenarioService.scenario(scenarioId); - return injectService.saveAll( - this.injectAssistantService.generateInjectsForScenario(scenario, input)); + List generated = + injectService.saveAll( + this.injectAssistantService.generateInjectsForScenario(scenario, input)); + KillChainPhaseInitializer.initializeFromInjects(generated); + return generated; } @PostMapping({ @@ -249,7 +253,8 @@ public Inject updateInjectActivationForScenario( @PathVariable @NotBlank final String scenarioId, @PathVariable @NotBlank final String injectId, @Valid @RequestBody InjectUpdateActivationInput input) { - return scenarioInjectService.updateInjectActivationForScenario(scenarioId, injectId, input); + return hydrateKillChainPhases( + scenarioInjectService.updateInjectActivationForScenario(scenarioId, injectId, input)); } // -- BULK UPDATE -- @@ -271,10 +276,11 @@ public Inject updateInjectActivationForScenario( resourceType = ResourceType.SCENARIO) @LogExecutionTime public List bulkUpdateInjectsForScenario( + TxCtx ctx, @PathVariable @NotBlank final String scenarioId, @RequestBody @Valid final InjectBulkUpdateInputs input) { input.setSimulationOrScenarioId(scenarioId); - return bulkInjectService.bulkUpdateWithMonitoring(input); + return bulkInjectService.bulkUpdateWithMonitoring(ctx, input); } // -- BULK DELETE -- @@ -318,4 +324,10 @@ public void deleteInjectForScenario( @PathVariable @NotBlank final String injectId) { this.scenarioInjectService.deleteInject(scenarioId, injectId); } + + /** See {@link KillChainPhaseInitializer}: hydrate before open-in-view rendering. */ + private static Inject hydrateKillChainPhases(Inject inject) { + KillChainPhaseInitializer.initializeFromInjects(List.of(inject)); + return inject; + } } diff --git a/openaev-api/src/main/java/io/openaev/rest/inject/SimulationInjectApi.java b/openaev-api/src/main/java/io/openaev/rest/inject/SimulationInjectApi.java index fb8169579b6..9aae6e6095b 100644 --- a/openaev-api/src/main/java/io/openaev/rest/inject/SimulationInjectApi.java +++ b/openaev-api/src/main/java/io/openaev/rest/inject/SimulationInjectApi.java @@ -26,6 +26,7 @@ import io.openaev.rest.inject.service.InjectService; import io.openaev.rest.inject.service.InjectStatusService; import io.openaev.rest.inject.service.SimulationInjectService; +import io.openaev.rest.kill_chain_phase.KillChainPhaseInitializer; import io.openaev.service.BulkInjectService; import io.openaev.service.InjectSearchService; import io.openaev.utils.InjectUtils; @@ -142,9 +143,12 @@ public Iterable exerciseInjectsSimple( resourceType = ResourceType.SIMULATION) public Iterable exerciseInjects( TxCtx ctx, @PathVariable @NotBlank final String exerciseId) { - return injectRepository.findByExerciseId(exerciseId).stream() - .sorted(Inject.executionComparator) - .toList(); + List injects = + injectRepository.findByExerciseId(exerciseId).stream() + .sorted(Inject.executionComparator) + .toList(); + KillChainPhaseInitializer.initializeFromInjects(injects); + return injects; } @LogExecutionTime @@ -257,15 +261,14 @@ public InjectOutput createInjectForExercise( resourceType = ResourceType.SIMULATION) @Transactional(rollbackFor = Exception.class) public List createInjectsForExercise( - // Unused by the handler body; TenantScopeTransactionAspect reads it to set the tenant scope - // for the transaction (createAndSaveInjectList resolves the injector through the v2 - // tenant-scoped injectors table) — sibling createInjectForExercise already carries this. TxCtx ctx, @PathVariable String exerciseId, @Valid @RequestBody List inputs) { Exercise exercise = exerciseRepository .findByIdAndTenantId(exerciseId, TenantContext.getCurrentTenant()) .orElseThrow(ElementNotFoundException::new); - return this.injectService.createAndSaveInjectList(exercise, null, inputs); + List created = this.injectService.createAndSaveInjectList(exercise, null, inputs); + KillChainPhaseInitializer.initializeFromInjects(created); + return created; } @PostMapping({ @@ -362,7 +365,8 @@ public Inject updateInjectActivationForExercise( @PathVariable String exerciseId, @PathVariable String injectId, @Valid @RequestBody InjectUpdateActivationInput input) { - return simulationInjectService.updateInjectActivationForSimulation(exerciseId, injectId, input); + return hydrateKillChainPhases( + simulationInjectService.updateInjectActivationForSimulation(exerciseId, injectId, input)); } @PutMapping({ @@ -376,7 +380,8 @@ public Inject updateInjectActivationForExercise( resourceType = ResourceType.INJECT) public Inject updateInjectTrigger( TxCtx ctx, @PathVariable String exerciseId, @PathVariable String injectId) { - return simulationInjectService.triggerInjectForSimulation(exerciseId, injectId); + return hydrateKillChainPhases( + simulationInjectService.triggerInjectForSimulation(exerciseId, injectId)); } @Transactional(rollbackFor = Exception.class) @@ -393,7 +398,8 @@ public Inject setInjectStatus( @PathVariable String exerciseId, @PathVariable String injectId, @Valid @RequestBody InjectUpdateStatusInput input) { - return simulationInjectService.setInjectStatusForSimulation(exerciseId, injectId, input); + return hydrateKillChainPhases( + simulationInjectService.setInjectStatusForSimulation(exerciseId, injectId, input)); } @PutMapping({ @@ -410,7 +416,8 @@ public Inject updateInjectTeams( @PathVariable String exerciseId, @PathVariable String injectId, @Valid @RequestBody InjectTeamsInput input) { - return simulationInjectService.updateInjectTeamsForSimulation(exerciseId, injectId, input); + return hydrateKillChainPhases( + simulationInjectService.updateInjectTeamsForSimulation(exerciseId, injectId, input)); } // -- BULK UPDATE -- @@ -432,10 +439,11 @@ public Inject updateInjectTeams( resourceType = ResourceType.SIMULATION) @LogExecutionTime public List bulkUpdateInjectsForSimulation( + TxCtx ctx, @PathVariable @NotBlank final String exerciseId, @RequestBody @Valid final InjectBulkUpdateInputs input) { input.setSimulationOrScenarioId(exerciseId); - return bulkInjectService.bulkUpdateWithMonitoring(input); + return bulkInjectService.bulkUpdateWithMonitoring(ctx, input); } // -- BULK DELETE -- @@ -477,4 +485,10 @@ public List bulkDeleteInjectsForSimulation( public void deleteInject(@PathVariable String exerciseId, @PathVariable String injectId) { this.simulationInjectService.deleteInject(exerciseId, injectId); } + + /** See {@link KillChainPhaseInitializer}: hydrate before open-in-view rendering. */ + private static Inject hydrateKillChainPhases(Inject inject) { + KillChainPhaseInitializer.initializeFromInjects(List.of(inject)); + return inject; + } } diff --git a/openaev-api/src/main/java/io/openaev/rest/inject/service/InjectService.java b/openaev-api/src/main/java/io/openaev/rest/inject/service/InjectService.java index 5f21b5b1c46..ba29cd885f2 100644 --- a/openaev-api/src/main/java/io/openaev/rest/inject/service/InjectService.java +++ b/openaev-api/src/main/java/io/openaev/rest/inject/service/InjectService.java @@ -24,6 +24,7 @@ import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.node.ObjectNode; import io.openaev.config.cache.LicenseCacheManager; +import io.openaev.context.TxCtx; import io.openaev.database.audit.IndexEvent; import io.openaev.database.audit.ModelBaseListener; import io.openaev.database.model.*; @@ -52,6 +53,7 @@ import io.openaev.rest.injector_contract.input.InjectorContractSearchPaginationInput; import io.openaev.rest.injector_contract.output.InjectorContractBaseOutput; import io.openaev.rest.injector_contract.output.InjectorContractFullOutput; +import io.openaev.rest.kill_chain_phase.KillChainPhaseInitializer; import io.openaev.rest.security.SecurityExpression; import io.openaev.rest.security.SecurityExpressionHandler; import io.openaev.rest.tag.TagService; @@ -684,7 +686,9 @@ public Specification getInjectSpecification( */ @Transactional(rollbackFor = Exception.class) public List bulkUpdateInject( - final List injectsToUpdate, final List operations) { + final TxCtx ctx, + final List injectsToUpdate, + final List operations) { // We aggregate the different field values in distinct sets in order to avoid retrieving the // same data multiple times Set teamsIDs = new HashSet<>(); @@ -723,7 +727,9 @@ public List bulkUpdateInject( }); // Save updated injects and return them - return this.injectRepository.saveAll(injectsToUpdate); + List updated = this.injectRepository.saveAll(injectsToUpdate); + KillChainPhaseInitializer.initializeFromInjects(updated); + return updated; } /** diff --git a/openaev-api/src/main/java/io/openaev/rest/injector_contract/InjectorContractApi.java b/openaev-api/src/main/java/io/openaev/rest/injector_contract/InjectorContractApi.java index 2b5a3c6abf8..f0f03b13eb6 100644 --- a/openaev-api/src/main/java/io/openaev/rest/injector_contract/InjectorContractApi.java +++ b/openaev-api/src/main/java/io/openaev/rest/injector_contract/InjectorContractApi.java @@ -121,7 +121,7 @@ public List getDomainCounts( @Transactional @AccessControl(actionPerformed = Action.SEARCH, resourceType = ResourceType.INJECTOR_CONTRACT) public InjectorContractFacetCountsOutput getFacetCounts( - @RequestBody @Valid final InjectorContractSearchPaginationInput input) { + TxCtx ctx, @RequestBody @Valid final InjectorContractSearchPaginationInput input) { SearchPaginationInput filtered = handleArchitectureFilter(input); return new InjectorContractFacetCountsOutput( injectorContractService.getPlatformCounts(filtered), diff --git a/openaev-api/src/main/java/io/openaev/rest/kill_chain_phase/KillChainPhaseApi.java b/openaev-api/src/main/java/io/openaev/rest/kill_chain_phase/KillChainPhaseApi.java index 9eaae4f6411..ae96827dc94 100644 --- a/openaev-api/src/main/java/io/openaev/rest/kill_chain_phase/KillChainPhaseApi.java +++ b/openaev-api/src/main/java/io/openaev/rest/kill_chain_phase/KillChainPhaseApi.java @@ -6,9 +6,12 @@ import static io.openaev.utils.pagination.PaginationUtils.buildPaginationJPA; import io.openaev.aop.AccessControl; +import io.openaev.config.TenantWriteScopeResolver; +import io.openaev.context.TxCtx; import io.openaev.database.model.Action; import io.openaev.database.model.KillChainPhase; import io.openaev.database.model.ResourceType; +import io.openaev.database.model.Tenant; import io.openaev.database.repository.KillChainPhaseRepository; import io.openaev.rest.exception.ElementNotFoundException; import io.openaev.rest.helper.RestBehavior; @@ -19,7 +22,6 @@ import io.openaev.utils.FilterUtilsJpa; import io.openaev.utils.pagination.SearchPaginationInput; import jakarta.validation.Valid; -import java.time.Instant; import java.util.List; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; @@ -43,11 +45,12 @@ public class KillChainPhaseApi extends RestBehavior { private final KillChainPhaseRepository killChainPhaseRepository; private final KillChainPhaseService killChainPhaseService; + private final TenantWriteScopeResolver writeScopeResolver; @GetMapping @Transactional @AccessControl(actionPerformed = Action.SEARCH, resourceType = ResourceType.KILL_CHAIN_PHASE) - public Iterable killChainPhases() { + public Iterable killChainPhases(TxCtx ctx) { return killChainPhaseRepository.findAll(); } @@ -55,7 +58,7 @@ public Iterable killChainPhases() { @Transactional @AccessControl(actionPerformed = Action.SEARCH, resourceType = ResourceType.KILL_CHAIN_PHASE) public Page killChainPhases( - @RequestBody @Valid SearchPaginationInput searchPaginationInput) { + TxCtx ctx, @RequestBody @Valid SearchPaginationInput searchPaginationInput) { return buildPaginationJPA( (Specification specification, Pageable pageable) -> this.killChainPhaseRepository.findAll(specification, pageable), @@ -69,7 +72,7 @@ public Page killChainPhases( resourceId = "#killChainPhaseId", actionPerformed = Action.READ, resourceType = ResourceType.KILL_CHAIN_PHASE) - public KillChainPhase killChainPhase(@PathVariable String killChainPhaseId) { + public KillChainPhase killChainPhase(TxCtx ctx, @PathVariable String killChainPhaseId) { return killChainPhaseRepository .findById(killChainPhaseId) .orElseThrow(ElementNotFoundException::new); @@ -82,22 +85,26 @@ public KillChainPhase killChainPhase(@PathVariable String killChainPhaseId) { resourceType = ResourceType.KILL_CHAIN_PHASE) @Transactional(rollbackFor = Exception.class) public KillChainPhase updateKillChainPhase( - @PathVariable String killChainPhaseId, @Valid @RequestBody KillChainPhaseUpdateInput input) { + TxCtx ctx, + @PathVariable String killChainPhaseId, + @Valid @RequestBody KillChainPhaseUpdateInput input) { KillChainPhase killchainPhase = killChainPhaseRepository .findById(killChainPhaseId) .orElseThrow(ElementNotFoundException::new); killchainPhase.setUpdateAttributes(input); - killchainPhase.setUpdatedAt(Instant.now()); return killChainPhaseRepository.save(killchainPhase); } @PostMapping @AccessControl(actionPerformed = Action.CREATE, resourceType = ResourceType.KILL_CHAIN_PHASE) @Transactional(rollbackFor = Exception.class) - public KillChainPhase createKillChainPhase(@Valid @RequestBody KillChainPhaseCreateInput input) { + public KillChainPhase createKillChainPhase( + TxCtx ctx, @Valid @RequestBody KillChainPhaseCreateInput input) { + String tenantId = writeScopeResolver.tenantForWrite(ctx, null); KillChainPhase killChainPhase = new KillChainPhase(); killChainPhase.setUpdateAttributes(input); + killChainPhase.setTenant(new Tenant(tenantId)); return killChainPhaseRepository.save(killChainPhase); } @@ -114,9 +121,9 @@ public KillChainPhase createKillChainPhase(@Valid @RequestBody KillChainPhaseCre @AccessControl(actionPerformed = Action.CREATE, resourceType = ResourceType.KILL_CHAIN_PHASE) @Transactional(propagation = Propagation.NOT_SUPPORTED) public Iterable upsertKillChainPhases( - @Valid @RequestBody KillChainPhaseUpsertInput input) { + TxCtx ctx, @Valid @RequestBody KillChainPhaseUpsertInput input) { try { - return killChainPhaseService.upsertKillChainPhases(input.getKillChainPhases()); + return killChainPhaseService.upsertKillChainPhases(ctx, input.getKillChainPhases()); } catch (DataIntegrityViolationException e) { if (!isKillChainPhaseUniqueViolation(e)) { throw e; @@ -124,7 +131,7 @@ public Iterable upsertKillChainPhases( log.warn( "Kill chain phase upsert lost a concurrent-insert race, retrying once: {}", e.getMessage()); - return killChainPhaseService.upsertKillChainPhases(input.getKillChainPhases()); + return killChainPhaseService.upsertKillChainPhases(ctx, input.getKillChainPhases()); } } @@ -145,7 +152,7 @@ private static boolean isKillChainPhaseUniqueViolation(DataIntegrityViolationExc resourceId = "#killChainPhaseId", actionPerformed = Action.DELETE, resourceType = ResourceType.KILL_CHAIN_PHASE) - public void deleteKillChainPhase(@PathVariable String killChainPhaseId) { + public void deleteKillChainPhase(TxCtx ctx, @PathVariable String killChainPhaseId) { killChainPhaseRepository.deleteById(killChainPhaseId); } @@ -155,7 +162,7 @@ public void deleteKillChainPhase(@PathVariable String killChainPhaseId) { @Transactional @AccessControl(actionPerformed = Action.SEARCH, resourceType = ResourceType.KILL_CHAIN_PHASE) public List optionsByName( - @RequestParam(required = false) final String searchText) { + TxCtx ctx, @RequestParam(required = false) final String searchText) { return fromIterable( this.killChainPhaseRepository.findAll( byNameOrKillChainName(searchText), @@ -168,7 +175,7 @@ public List optionsByName( @PostMapping("/options") @Transactional @AccessControl(actionPerformed = Action.SEARCH, resourceType = ResourceType.KILL_CHAIN_PHASE) - public List optionsById(@RequestBody final List ids) { + public List optionsById(TxCtx ctx, @RequestBody final List ids) { return fromIterable(this.killChainPhaseRepository.findAllById(ids)).stream() .map(KillChainPhaseApi::toOption) .toList(); diff --git a/openaev-api/src/main/java/io/openaev/rest/kill_chain_phase/KillChainPhaseInitializer.java b/openaev-api/src/main/java/io/openaev/rest/kill_chain_phase/KillChainPhaseInitializer.java new file mode 100644 index 00000000000..ec5dbf02d2a --- /dev/null +++ b/openaev-api/src/main/java/io/openaev/rest/kill_chain_phase/KillChainPhaseInitializer.java @@ -0,0 +1,34 @@ +package io.openaev.rest.kill_chain_phase; + +import io.openaev.database.model.AttackPattern; +import io.openaev.database.model.Inject; +import io.openaev.database.model.InjectorContract; +import java.util.Collection; +import org.hibernate.Hibernate; + +/** + * Forces the LAZY {@code AttackPattern#killChainPhases} association to load while the tenant scope + * is still set. + */ +public final class KillChainPhaseInitializer { + + private KillChainPhaseInitializer() {} + + private static void initialize(AttackPattern attackPattern) { + Hibernate.initialize(attackPattern.getKillChainPhases()); + } + + public static void initializeFromContract(InjectorContract injectorContract) { + injectorContract.getAttackPatterns().forEach(KillChainPhaseInitializer::initialize); + } + + /** + * Hydrates every phase reachable from a set of injects (the {@code *_kill_chain_phases} path). + */ + public static void initializeFromInjects(Collection injects) { + injects.stream() + .map(Inject::getInjectorContract) + .flatMap(java.util.Optional::stream) + .forEach(KillChainPhaseInitializer::initializeFromContract); + } +} diff --git a/openaev-api/src/main/java/io/openaev/rest/kill_chain_phase/service/KillChainPhaseService.java b/openaev-api/src/main/java/io/openaev/rest/kill_chain_phase/service/KillChainPhaseService.java index 84a424b3dee..bcb54263723 100644 --- a/openaev-api/src/main/java/io/openaev/rest/kill_chain_phase/service/KillChainPhaseService.java +++ b/openaev-api/src/main/java/io/openaev/rest/kill_chain_phase/service/KillChainPhaseService.java @@ -1,11 +1,14 @@ package io.openaev.rest.kill_chain_phase.service; +import io.openaev.config.TenantWriteScopeResolver; +import io.openaev.context.TxCtx; import io.openaev.database.model.KillChainPhase; +import io.openaev.database.model.Tenant; import io.openaev.database.repository.KillChainPhaseRepository; import io.openaev.helper.StreamHelper; import io.openaev.rest.kill_chain_phase.KillChainPhaseUtils; import io.openaev.rest.kill_chain_phase.form.KillChainPhaseCreateInput; -import java.time.Instant; +import java.util.Collection; import java.util.Collections; import java.util.IdentityHashMap; import java.util.LinkedHashMap; @@ -13,6 +16,7 @@ import java.util.Map; import java.util.Optional; import java.util.Set; +import java.util.stream.Collectors; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -22,6 +26,7 @@ public class KillChainPhaseService { private final KillChainPhaseRepository killChainPhaseRepository; + private final TenantWriteScopeResolver writeScopeResolver; /** * Upserts a batch of kill chain phases. @@ -37,7 +42,9 @@ public class KillChainPhaseService { * rolled back and the endpoint retries once in a fresh transaction. */ @Transactional(rollbackFor = Exception.class) - public List upsertKillChainPhases(List inputs) { + public List upsertKillChainPhases( + TxCtx ctx, List inputs) { + String writeTenant = writeScopeResolver.tenantForWrite(ctx, null); // In-batch de-duplication tracks pending entities under BOTH unique keys the database // enforces (STIX id and natural key). Keying on a single one is not enough: the same phase // can appear once with a STIX id and once without, or two entries can share a STIX id under @@ -53,9 +60,9 @@ public List upsertKillChainPhases(List upsertKillChainPhases(List> phaseIdsByAttackPatternId(Collection attackPatternIds) { + if (attackPatternIds.isEmpty()) { + return Map.of(); + } + return killChainPhaseRepository.findPhaseIdsByAttackPatternIds(attackPatternIds).stream() + .collect( + Collectors.groupingBy( + row -> (String) row[0], + Collectors.mapping(row -> (String) row[1], Collectors.toList()))); + } + + public KillChainPhase resolveOrCreateForImport(String writeTenant, KillChainPhase candidate) { + return killChainPhaseRepository + .findAllByExternalIdInIgnoreCaseAndTenantId(List.of(candidate.getExternalId()), writeTenant) + .stream() + .findFirst() + .orElseGet(() -> killChainPhaseRepository.save(candidate)); + } + private String naturalKey(KillChainPhaseCreateInput input) { return input.getKillChainName() + "|" + input.getShortName(); } @@ -77,20 +103,25 @@ private String normalizedStixId(KillChainPhaseCreateInput input) { return input.getStixId() != null && !input.getStixId().isBlank() ? input.getStixId() : null; } - private Optional resolveExisting(KillChainPhaseCreateInput input) { + private Optional resolveExisting( + KillChainPhaseCreateInput input, String writeTenant) { String stixId = normalizedStixId(input); if (stixId != null) { - Optional byStixId = killChainPhaseRepository.findByStixId(stixId); + Optional byStixId = + killChainPhaseRepository.findByStixIdAndTenantId(stixId, writeTenant); if (byStixId.isPresent()) { return byStixId; } } - return killChainPhaseRepository.findByKillChainNameAndShortName( - input.getKillChainName(), input.getShortName()); + return killChainPhaseRepository.findByKillChainNameAndShortNameAndTenantId( + input.getKillChainName(), input.getShortName(), writeTenant); } - private void apply(KillChainPhase phase, KillChainPhaseCreateInput input) { + private void apply(KillChainPhase phase, KillChainPhaseCreateInput input, String writeTenant) { boolean isNew = phase.getId() == null; + if (isNew) { + phase.setTenant(new Tenant(writeTenant)); + } phase.setKillChainName(input.getKillChainName()); // Never clobber a known STIX id with null: entries without a STIX id can target the same // phase as entries with one, and the STIX id is part of the database unique key. @@ -111,8 +142,6 @@ private void apply(KillChainPhase phase, KillChainPhaseCreateInput input) { inputOrder != null && inputOrder != 0L ? inputOrder : KillChainPhaseUtils.orderFor(input.getKillChainName(), input.getShortName())); - } else { - phase.setUpdatedAt(Instant.now()); } } } diff --git a/openaev-api/src/main/java/io/openaev/rest/scenario/ScenarioApi.java b/openaev-api/src/main/java/io/openaev/rest/scenario/ScenarioApi.java index 2880dcd7a05..89c994e01ca 100644 --- a/openaev-api/src/main/java/io/openaev/rest/scenario/ScenarioApi.java +++ b/openaev-api/src/main/java/io/openaev/rest/scenario/ScenarioApi.java @@ -37,6 +37,7 @@ import io.openaev.rest.exercise.form.LessonsInput; import io.openaev.rest.exercise.form.ScenarioTeamPlayersEnableInput; import io.openaev.rest.helper.RestBehavior; +import io.openaev.rest.kill_chain_phase.KillChainPhaseInitializer; import io.openaev.rest.scenario.form.*; import io.openaev.rest.scenario.response.ScenarioOutput; import io.openaev.rest.team.output.TeamOutput; @@ -147,9 +148,6 @@ public Scenario createScenario(@Valid @RequestBody final ScenarioInput input) { @Transactional(propagation = Propagation.SUPPORTS) @AccessControl(actionPerformed = Action.CREATE, resourceType = ResourceType.SCENARIO) public ScenarioSimple createScenarioWithInjectorContracts( - // TxCtx is still declared so the resolver injects the request scope; there is no real - // transaction here to write the GUC into (SUPPORTS), so it is passed down manually into - // ScenarioService's own @Transactional method. TxCtx ctx, @Valid @RequestBody final ScenarioAndInjectorContractsInputs inputs) { return BulkOperationContext.runSuppressed( () -> @@ -171,9 +169,6 @@ public ScenarioSimple createScenarioWithInjectorContracts( @Transactional(propagation = Propagation.SUPPORTS) @AccessControl(actionPerformed = Action.WRITE, resourceType = ResourceType.SCENARIO) public List updateScenariosWithInjectorContracts( - // TxCtx is still declared so the resolver injects the request scope; there is no real - // transaction here to write the GUC into (SUPPORTS), so it is passed down manually into - // ScenarioService's own @Transactional method. TxCtx ctx, @Valid @RequestBody final ScenarioIdsAndInjectorContractsInputs inputs) { return BulkOperationContext.runSuppressed( () -> @@ -190,8 +185,8 @@ public List updateScenariosWithInjectorContracts( resourceId = "#scenarioId", actionPerformed = Action.DUPLICATE, resourceType = ResourceType.SCENARIO) - public Scenario duplicateScenario(@PathVariable @NotBlank final String scenarioId) { - return scenarioService.getDuplicateScenario(scenarioId); + public Scenario duplicateScenario(TxCtx ctx, @PathVariable @NotBlank final String scenarioId) { + return hydrateKillChainPhases(scenarioService.getDuplicateScenario(scenarioId)); } @GetMapping({SCENARIO_URI, TENANT_SCENARIO_URI}) @@ -228,7 +223,7 @@ public List scenariosById( resourceId = "#scenarioId", actionPerformed = Action.READ, resourceType = ResourceType.SCENARIO) - public ScenarioOutput scenario(@PathVariable @NotBlank final String scenarioId) { + public ScenarioOutput scenario(TxCtx ctx, @PathVariable @NotBlank final String scenarioId) { return scenarioService.getScenarioById(scenarioId); } @@ -317,6 +312,7 @@ public ExpectationsDriftOutput dismissScenarioExpectationsDrift( actionPerformed = Action.WRITE, resourceType = ResourceType.SCENARIO) public Scenario updateScenario( + TxCtx ctx, @PathVariable @NotBlank final String scenarioId, @Valid @RequestBody final UpdateScenarioInput input) { Scenario scenario = this.scenarioService.scenario(scenarioId); @@ -329,7 +325,8 @@ public Scenario updateScenario( } else { scenario.setCustomDashboard(null); } - return this.scenarioService.updateScenario(scenario, currentTagList, input.isApplyTagRule()); + return hydrateKillChainPhases( + this.scenarioService.updateScenario(scenario, currentTagList, input.isApplyTagRule())); } @DeleteMapping({SCENARIO_URI + "/{scenarioId}", TENANT_SCENARIO_URI + "/{scenarioId}"}) @@ -373,12 +370,14 @@ public List bulkDeleteScenarios( actionPerformed = Action.WRITE, resourceType = ResourceType.SCENARIO) public Scenario updateScenarioTags( + TxCtx ctx, @PathVariable @NotBlank final String scenarioId, @Valid @RequestBody final ScenarioUpdateTagsInput input) { Scenario scenario = this.scenarioService.scenario(scenarioId); Set currentTagList = scenario.getTags(); scenario.setTags(iterableToSet(this.tagRepository.findAllById(input.getTagIds()))); - return this.scenarioService.updateScenario(scenario, currentTagList, input.isApplyTagRule()); + return hydrateKillChainPhases( + this.scenarioService.updateScenario(scenario, currentTagList, input.isApplyTagRule())); } // -- EXPORT -- @@ -390,6 +389,7 @@ public Scenario updateScenarioTags( actionPerformed = Action.SEARCH, resourceType = ResourceType.SCENARIO) public void exportScenario( + TxCtx ctx, @PathVariable @NotBlank final String scenarioId, @RequestParam(required = false) final boolean isWithTeams, @RequestParam(required = false) final boolean isWithPlayers, @@ -411,13 +411,9 @@ public void exportScenario( @PostMapping({SCENARIO_URI + "/import", TENANT_SCENARIO_URI + "/import"}) @Transactional @AccessControl(actionPerformed = Action.WRITE, resourceType = ResourceType.SCENARIO) - public ImportResult importScenario( - // Unused by the handler body; TenantScopeTransactionAspect reads it to set the tenant scope - // for the transaction (the V1_DataImporter resolves InjectorContract#getFirstInjector() and - // InjectorService#injectorTypeExists(...), both v2 tenant-scoped through the injectors - // table; without a scope, imported injects silently lose their injector). - TxCtx ctx, @RequestPart("file") @NotNull MultipartFile file) throws Exception { - return this.importService.handleFileImport(file, null, null); + public ImportResult importScenario(TxCtx ctx, @RequestPart("file") @NotNull MultipartFile file) + throws Exception { + return this.importService.handleFileImport(ctx, file, null, null); } // -- TEAMS -- @@ -485,11 +481,13 @@ public Iterable getPlayersByScenario(@PathVariable String scenarioId) actionPerformed = Action.WRITE, resourceType = ResourceType.SCENARIO) public Scenario enableScenarioTeamPlayers( + TxCtx ctx, @PathVariable @NotBlank final String scenarioId, @PathVariable @NotBlank final String teamId, @Valid @RequestBody final ScenarioTeamPlayersEnableInput input) { - return this.scenarioService.enableAddScenarioTeamPlayer( - scenarioId, teamId, input.getPlayersIds()); + return hydrateKillChainPhases( + this.scenarioService.enableAddScenarioTeamPlayer( + scenarioId, teamId, input.getPlayersIds())); } @Transactional(rollbackFor = Exception.class) @@ -502,10 +500,12 @@ public Scenario enableScenarioTeamPlayers( actionPerformed = Action.WRITE, resourceType = ResourceType.SCENARIO) public Scenario disableScenarioTeamPlayers( + TxCtx ctx, @PathVariable @NotBlank final String scenarioId, @PathVariable @NotBlank final String teamId, @Valid @RequestBody final ScenarioTeamPlayersEnableInput input) { - return this.scenarioService.disablePlayers(scenarioId, teamId, input.getPlayersIds()); + return hydrateKillChainPhases( + this.scenarioService.disablePlayers(scenarioId, teamId, input.getPlayersIds())); } @Transactional(rollbackFor = Exception.class) @@ -518,10 +518,12 @@ public Scenario disableScenarioTeamPlayers( actionPerformed = Action.WRITE, resourceType = ResourceType.SCENARIO) public Scenario addScenarioTeamPlayers( + TxCtx ctx, @PathVariable @NotBlank final String scenarioId, @PathVariable @NotBlank final String teamId, @Valid @RequestBody final ScenarioTeamPlayersEnableInput input) { - return this.scenarioService.addScenarioPlayer(scenarioId, teamId, input.getPlayersIds()); + return hydrateKillChainPhases( + this.scenarioService.addScenarioPlayer(scenarioId, teamId, input.getPlayersIds())); } @Transactional(rollbackFor = Exception.class) @@ -534,6 +536,7 @@ public Scenario addScenarioTeamPlayers( actionPerformed = Action.WRITE, resourceType = ResourceType.SCENARIO) public Scenario removeScenarioTeamPlayers( + TxCtx ctx, @PathVariable @NotBlank final String scenarioId, @PathVariable @NotBlank final String teamId, @Valid @RequestBody final ScenarioTeamPlayersEnableInput input) { @@ -544,7 +547,8 @@ public Scenario removeScenarioTeamPlayers( Iterable teamUsers = userRepository.findAllById(input.getPlayersIds()); team.getUsers().removeAll(fromIterable(teamUsers)); teamRepository.save(team); - return this.scenarioService.disablePlayers(scenarioId, teamId, input.getPlayersIds()); + return hydrateKillChainPhases( + this.scenarioService.disablePlayers(scenarioId, teamId, input.getPlayersIds())); } // -- RECURRENCE -- @@ -627,7 +631,7 @@ public List categoryOptionsByName( resourceType = ResourceType.SCENARIO) @Transactional(rollbackFor = Exception.class) public Scenario updateScenarioLessons( - @PathVariable String scenarioId, @Valid @RequestBody LessonsInput input) { + TxCtx ctx, @PathVariable String scenarioId, @Valid @RequestBody LessonsInput input) { Scenario scenario = this.scenarioService.scenario(scenarioId); // Partial update: absent fields keep their current value (older API consumers // only send lessons_anonymized and must not reset the enabled flag). @@ -637,7 +641,7 @@ public Scenario updateScenarioLessons( if (input.getLessonsEnabled() != null) { scenario.setLessonsEnabled(input.getLessonsEnabled()); } - return scenarioRepository.save(scenario); + return hydrateKillChainPhases(scenarioRepository.save(scenario)); } @PostMapping({ @@ -808,4 +812,14 @@ public List documents(@PathVariable String scenarioId) { } // end region + + /** + * {@code scenario_kill_chain_phases} walks the scenario's injects down to the LAZY attack-pattern + * phases. See {@link KillChainPhaseInitializer}: hydrate them here, inside the scoped + * transaction, or open-in-view rendering serializes an empty list. + */ + private static Scenario hydrateKillChainPhases(Scenario scenario) { + KillChainPhaseInitializer.initializeFromInjects(scenario.getInjects()); + return scenario; + } } diff --git a/openaev-api/src/main/java/io/openaev/rest/scenario/ScenarioImportApi.java b/openaev-api/src/main/java/io/openaev/rest/scenario/ScenarioImportApi.java index ff0eb5ff4f9..70f499f011a 100644 --- a/openaev-api/src/main/java/io/openaev/rest/scenario/ScenarioImportApi.java +++ b/openaev-api/src/main/java/io/openaev/rest/scenario/ScenarioImportApi.java @@ -132,6 +132,6 @@ public void injectsImport( if (file == null || file.isEmpty()) { throw new UnprocessableContentException("Insufficient input: file is required"); } - this.injectImportService.importInjectsForScenario(file, scenarioId); + this.injectImportService.importInjectsForScenario(ctx, file, scenarioId); } } diff --git a/openaev-api/src/main/java/io/openaev/service/BulkInjectService.java b/openaev-api/src/main/java/io/openaev/service/BulkInjectService.java index 13ff9fc2a44..3241c484aa2 100644 --- a/openaev-api/src/main/java/io/openaev/service/BulkInjectService.java +++ b/openaev-api/src/main/java/io/openaev/service/BulkInjectService.java @@ -1,6 +1,7 @@ package io.openaev.service; import io.openaev.context.BulkOperationContext; +import io.openaev.context.TxCtx; import io.openaev.database.model.Grant; import io.openaev.database.model.Inject; import io.openaev.rest.exception.ElementNotFoundException; @@ -27,13 +28,15 @@ public class BulkInjectService { private final InjectService injectService; private final BulkOperationMonitor bulkOperationMonitor; - public List bulkUpdateWithMonitoring(InjectBulkUpdateInputs input) { + public List bulkUpdateWithMonitoring(TxCtx ctx, InjectBulkUpdateInputs input) { List injectsToUpdate = resolveTargets(input); String operationId = bulkOperationMonitor.start("update", "injects", injectsToUpdate.size()); try { List updated = BulkOperationContext.runSuppressed( - () -> injectService.bulkUpdateInject(injectsToUpdate, input.getUpdateOperations())); + () -> + injectService.bulkUpdateInject( + ctx, injectsToUpdate, input.getUpdateOperations())); bulkOperationMonitor.complete(operationId); return updated; } catch (RuntimeException e) { diff --git a/openaev-api/src/main/java/io/openaev/service/ImportService.java b/openaev-api/src/main/java/io/openaev/service/ImportService.java index 95ec7655451..604772916fb 100644 --- a/openaev-api/src/main/java/io/openaev/service/ImportService.java +++ b/openaev-api/src/main/java/io/openaev/service/ImportService.java @@ -5,6 +5,7 @@ import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; +import io.openaev.context.TxCtx; import io.openaev.database.model.Asset; import io.openaev.database.model.AssetGroup; import io.openaev.database.model.Exercise; @@ -51,6 +52,7 @@ public void setV1_dataImporter(V1_DataImporter v1_dataImporter) { } private ImportResult handleDataImport( + TxCtx ctx, InputStream inputStream, Map docReferences, Exercise exercise, @@ -64,7 +66,7 @@ private ImportResult handleDataImport( Importer importer = dataImporters.get(importVersion); if (importer != null) { return importer.importData( - importNode, docReferences, exercise, scenario, asset, assetGroup, suffix); + ctx, importNode, docReferences, exercise, scenario, asset, assetGroup, suffix); } else { throw new ImportException("Export with version " + importVersion + " is not supported"); } @@ -74,9 +76,10 @@ private ImportResult handleDataImport( } @Transactional(rollbackFor = Exception.class) - public ImportResult handleFileImport(MultipartFile file, Exercise exercise, Scenario scenario) - throws Exception { + public ImportResult handleFileImport( + TxCtx ctx, MultipartFile file, Exercise exercise, Scenario scenario) throws Exception { return handleInputStreamImport( + ctx, file.getInputStream(), exercise, scenario, @@ -87,6 +90,7 @@ public ImportResult handleFileImport(MultipartFile file, Exercise exercise, Scen @Transactional(rollbackFor = Exception.class) public ImportResult handleInputStreamFileImport( + TxCtx ctx, InputStream is, Exercise exercise, Scenario scenario, @@ -94,10 +98,11 @@ public ImportResult handleInputStreamFileImport( AssetGroup assetGroup, String suffix) throws Exception { - return handleInputStreamImport(is, exercise, scenario, asset, assetGroup, suffix); + return handleInputStreamImport(ctx, is, exercise, scenario, asset, assetGroup, suffix); } private ImportResult handleInputStreamImport( + TxCtx ctx, InputStream is, Exercise exercise, Scenario scenario, @@ -254,7 +259,7 @@ private ImportResult handleInputStreamImport( for (InputStream dataStream : dataImports) { ImportResult result = handleDataImport( - dataStream, docReferences, exercise, scenario, asset, assetGroup, suffix); + ctx, dataStream, docReferences, exercise, scenario, asset, assetGroup, suffix); aggregatedMissingActions.addAll(result.missingActions()); } return new ImportResult(aggregatedMissingActions); diff --git a/openaev-api/src/main/java/io/openaev/service/InjectImportService.java b/openaev-api/src/main/java/io/openaev/service/InjectImportService.java index 932ccbf4f49..49c94372955 100644 --- a/openaev-api/src/main/java/io/openaev/service/InjectImportService.java +++ b/openaev-api/src/main/java/io/openaev/service/InjectImportService.java @@ -13,6 +13,7 @@ import com.fasterxml.jackson.databind.node.ArrayNode; import com.fasterxml.jackson.databind.node.ObjectNode; import io.openaev.context.TenantContext; +import io.openaev.context.TxCtx; import io.openaev.database.model.*; import io.openaev.database.repository.*; import io.openaev.rest.exception.BadRequestException; @@ -1229,23 +1230,25 @@ private void processDateToAbsolute(Map mapInstantByRowIndex - earliestInstant.getEpochSecond()))); } - public void importInjectsForScenario(MultipartFile file, String scenarioId) throws Exception { + public void importInjectsForScenario(TxCtx ctx, MultipartFile file, String scenarioId) + throws Exception { Scenario targetScenario = scenarioRepository .findByIdAndTenantId(scenarioId, TenantContext.getCurrentTenant()) .orElseThrow(ElementNotFoundException::new); - this.importService.handleFileImport(file, null, targetScenario); + this.importService.handleFileImport(ctx, file, null, targetScenario); } - public void importInjectsForSimulation(MultipartFile file, String simulationId) throws Exception { + public void importInjectsForSimulation(TxCtx ctx, MultipartFile file, String simulationId) + throws Exception { Exercise targetSimulation = exerciseRepository.findById(simulationId).orElseThrow(ElementNotFoundException::new); - this.importService.handleFileImport(file, targetSimulation, null); + this.importService.handleFileImport(ctx, file, targetSimulation, null); } - public void importInjectsForAtomicTestings(MultipartFile file) throws Exception { - this.importService.handleFileImport(file, null, null); + public void importInjectsForAtomicTestings(TxCtx ctx, MultipartFile file) throws Exception { + this.importService.handleFileImport(ctx, file, null, null); } } diff --git a/openaev-api/src/main/java/io/openaev/service/scenario/ScenarioService.java b/openaev-api/src/main/java/io/openaev/service/scenario/ScenarioService.java index 7898cf6e840..bb07cb01e3b 100644 --- a/openaev-api/src/main/java/io/openaev/service/scenario/ScenarioService.java +++ b/openaev-api/src/main/java/io/openaev/service/scenario/ScenarioService.java @@ -54,6 +54,7 @@ import io.openaev.rest.inject.service.InjectDuplicateService; import io.openaev.rest.inject.service.InjectService; import io.openaev.rest.injector_contract.input.InjectorContractSearchPaginationInput; +import io.openaev.rest.kill_chain_phase.KillChainPhaseInitializer; import io.openaev.rest.kill_chain_phase.response.KillChainPhaseOutput; import io.openaev.rest.scenario.export.ScenarioFileExport; import io.openaev.rest.scenario.form.ScenarioBulkProcessingInput; @@ -186,10 +187,7 @@ public Scenario createScenarioChaining(@NotNull final Scenario scenario) @Transactional public ScenarioSimple createScenarioWithInjectorContracts( - // Unused by the method body; TenantScopeTransactionAspect reads it to set the tenant scope - // for this transaction (the arsenal selection resolves injector contracts and their linked - // injector, both v2 tenant-scoped through the injectors table). - TxCtx ctx, + final TxCtx ctx, @NotBlank final String tenantId, @NotNull final ScenarioInput scenarioInput, @NotNull final InjectorContractSearchPaginationInput injectorContractSearchPaginationInput, @@ -198,20 +196,21 @@ public ScenarioSimple createScenarioWithInjectorContracts( Scenario scenario = computeAndCreateScenario(preparedScenario); this.injectService.createInjectsFromInjectorContractInput( null, new ArrayList<>(List.of(scenario)), injectorContractSearchPaginationInput, locale); + KillChainPhaseInitializer.initializeFromInjects(scenario.getInjects()); return ScenarioSimple.fromScenario(scenario); } @Transactional public List updateScenariosWithInjectorContracts( - // Unused by the method body; TenantScopeTransactionAspect reads it to set the tenant scope - // for this transaction (same reason as createScenarioWithInjectorContracts above). - TxCtx ctx, + final TxCtx ctx, @NotNull final List scenarioIds, @NotNull final InjectorContractSearchPaginationInput injectorContractSearchPaginationInput, @NotBlank final String locale) { List scenarios = this.scenarioRepository.findAllById(scenarioIds); this.injectService.createInjectsFromInjectorContractInput( null, scenarios, injectorContractSearchPaginationInput, locale); + scenarios.forEach( + scenario -> KillChainPhaseInitializer.initializeFromInjects(scenario.getInjects())); return scenarios.stream().map(ScenarioSimple::fromScenario).toList(); } diff --git a/openaev-api/src/main/resources/application.properties b/openaev-api/src/main/resources/application.properties index 482c17f3d2a..e5ad0807fea 100644 --- a/openaev-api/src/main/resources/application.properties +++ b/openaev-api/src/main/resources/application.properties @@ -626,7 +626,7 @@ openaev.enabled-dev-features= # table, filtered through the finding it is joined to). # autonomous_runs / autonomous_events / autonomous_directives are v2-native (TenantBase, no # @Filter; TenantBaseListener removed on activation) so they MUST stay here (#7396). -openaev.tenant.active-tables=import_mappers,lessons_templates,mitigations,cwes,collectors,executors,injectors,attackpath_execution,attackpath_finding,secret_references,secrets,connector_instances,autonomous_runs,autonomous_events,autonomous_directives,security_coverages +openaev.tenant.active-tables=import_mappers,lessons_templates,mitigations,cwes,collectors,executors,injectors,attackpath_execution,attackpath_finding,secret_references,secrets,connector_instances,autonomous_runs,autonomous_events,autonomous_directives,kill_chain_phases,security_coverages ############################# # Attack path diff --git a/openaev-api/src/test/java/io/openaev/api/chaining/ChainingApiUnitTest.java b/openaev-api/src/test/java/io/openaev/api/chaining/ChainingApiUnitTest.java deleted file mode 100644 index a7007ba8696..00000000000 --- a/openaev-api/src/test/java/io/openaev/api/chaining/ChainingApiUnitTest.java +++ /dev/null @@ -1,135 +0,0 @@ -package io.openaev.api.chaining; - -import static org.junit.jupiter.api.Assertions.assertThrows; -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.ArgumentMatchers.anyString; -import static org.mockito.Mockito.*; - -import io.openaev.database.model.Exercise; -import io.openaev.database.model.Scenario; -import io.openaev.database.model.Workflow; -import io.openaev.database.repository.TagRepository; -import io.openaev.rest.custom_dashboard.CustomDashboardService; -import io.openaev.rest.exception.ChainingException; -import io.openaev.rest.exercise.service.ExerciseService; -import io.openaev.service.PlatformSettingsService; -import io.openaev.service.chaining.StepService; -import io.openaev.service.chaining.WorkflowService; -import io.openaev.service.scenario.ScenarioService; -import java.util.Optional; -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Nested; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.extension.ExtendWith; -import org.mockito.InjectMocks; -import org.mockito.Mock; -import org.mockito.junit.jupiter.MockitoExtension; - -@ExtendWith(MockitoExtension.class) -@DisplayName("ChainingApi unit tests") -class ChainingApiUnitTest { - - @Mock private ExerciseService exerciseService; - @Mock private CustomDashboardService customDashboardService; - @Mock private PlatformSettingsService platformSettingsService; - @Mock private ScenarioService scenarioService; - @Mock private WorkflowService workflowService; - @Mock private StepService stepService; - @Mock private TagRepository tagRepository; - - @InjectMocks private ChainingApi chainingApi; - - @Nested - @DisplayName("Duplicate simulation chaining") - class DuplicateExercise { - - @Test - void shouldDuplicateSimulationAndCopyStepTemplate() throws ChainingException { - String simulationId = "simulation-id"; - Exercise simulation = new Exercise(); - simulation.setId(simulationId); - Workflow sourceWorkflow = new Workflow(); - Workflow duplicatedWorkflow = new Workflow(); - - when(exerciseService.getDuplicateExercise(simulationId)).thenReturn(simulation); - when(workflowService.findWorkflowTemplateBySimulationId(simulation.getId())) - .thenReturn(Optional.of(sourceWorkflow)); - when(workflowService.duplicateSimulation(simulationId, simulation)) - .thenReturn(duplicatedWorkflow); - - chainingApi.duplicateExercise(simulationId); - - verify(stepService).copyStepTemplate(sourceWorkflow, duplicatedWorkflow); - } - - @Test - void shouldThrowWhenWorkflowTemplateMissing() throws ChainingException { - assertThrows(ChainingException.class, () -> chainingApi.duplicateExercise("simulation-id")); - - verify(exerciseService).getDuplicateExercise("simulation-id"); - verify(workflowService).findWorkflowTemplateBySimulationId("simulation-id"); - verifyNoInteractions(stepService); - } - - @Test - void shouldThrowWhenWorkflowTemplateNotFound() throws ChainingException { - String simulationId = "simulation-id"; - Exercise simulation = new Exercise(); - simulation.setId("simulation-dup-id"); - when(exerciseService.getDuplicateExercise(simulationId)).thenReturn(simulation); - - assertThrows(ChainingException.class, () -> chainingApi.duplicateExercise(simulationId)); - - verify(workflowService, never()).duplicateSimulation(anyString(), any(Exercise.class)); - verify(stepService, never()).copyStepTemplate(any(Workflow.class), any(Workflow.class)); - } - } - - @Nested - @DisplayName("duplicateScenarioChaining") - class DuplicateScenario { - - @Test - void shouldDuplicateScenarioAndCopyStepTemplate() throws ChainingException { - String scenarioId = "scenario-id"; - Scenario scenario = new Scenario(); - Workflow sourceWorkflow = new Workflow(); - Workflow duplicatedWorkflow = new Workflow(); - - when(scenarioService.getDuplicateScenario(scenarioId)).thenReturn(scenario); - when(workflowService.findWorkflowTemplateByScenarioId(scenarioId)) - .thenReturn(Optional.of(sourceWorkflow)); - when(workflowService.duplicateScenario(scenarioId, scenario)).thenReturn(duplicatedWorkflow); - - chainingApi.duplicateScenarioChaining(scenarioId); - - verify(stepService).copyStepTemplate(sourceWorkflow, duplicatedWorkflow); - } - - @Test - void shouldThrowWhenWorkflowTemplateMissing() throws ChainingException { - - assertThrows( - ChainingException.class, () -> chainingApi.duplicateScenarioChaining("scenario-id")); - - verify(scenarioService).getDuplicateScenario("scenario-id"); - verify(workflowService).findWorkflowTemplateByScenarioId("scenario-id"); - verifyNoInteractions(stepService); - } - - @Test - void shouldThrowWhenWorkflowTemplateNotFound() throws ChainingException { - String scenarioId = "scenario-id"; - Scenario scenario = new Scenario(); - when(scenarioService.getDuplicateScenario(scenarioId)).thenReturn(scenario); - when(workflowService.findWorkflowTemplateByScenarioId(scenarioId)) - .thenReturn(Optional.empty()); - - assertThrows( - ChainingException.class, () -> chainingApi.duplicateScenarioChaining(scenarioId)); - - verify(workflowService, never()).duplicateScenario(anyString(), any(Scenario.class)); - verify(stepService, never()).copyStepTemplate(any(Workflow.class), any(Workflow.class)); - } - } -} diff --git a/openaev-api/src/test/java/io/openaev/architecture/TenantActiveTableAccessArchTest.java b/openaev-api/src/test/java/io/openaev/architecture/TenantActiveTableAccessArchTest.java index c455b624b6c..9d30ac513ed 100644 --- a/openaev-api/src/test/java/io/openaev/architecture/TenantActiveTableAccessArchTest.java +++ b/openaev-api/src/test/java/io/openaev/architecture/TenantActiveTableAccessArchTest.java @@ -9,6 +9,7 @@ import com.tngtech.archunit.junit.ArchTest; import com.tngtech.archunit.lang.ArchRule; import io.openaev.api.chaining.InjectExecutionStep; +import io.openaev.database.model.AttackPattern; import io.openaev.database.model.CatalogConnector; import io.openaev.database.model.Exercise; import io.openaev.database.model.Inject; @@ -23,6 +24,7 @@ import io.openaev.database.repository.ExecutorRepository; import io.openaev.database.repository.ImportMapperRepository; import io.openaev.database.repository.InjectorRepository; +import io.openaev.database.repository.KillChainPhaseRepository; import io.openaev.database.repository.LessonsTemplateRepository; import io.openaev.database.repository.MitigationRepository; import io.openaev.database.repository.SecurityCoverageRepository; @@ -40,7 +42,10 @@ import io.openaev.executors.paloaltocortex.service.PaloAltoCortexExecutorContextService; import io.openaev.executors.sentinelone.service.SentinelOneExecutorContextService; import io.openaev.executors.tanium.service.TaniumExecutorContextService; +import io.openaev.export.WorkflowExportInitializer; import io.openaev.healthcheck.utils.HealthCheckUtils; +import io.openaev.helper.InjectHelper; +import io.openaev.importer.V1_DataImporter; import io.openaev.injectors.phishing.service.PhishingLandingPageService; import io.openaev.integration.ManagerFactory; import io.openaev.integration.migration.ConfigurationMigration; @@ -49,6 +54,8 @@ import io.openaev.processor.datapack.V20260708_Dynamic_injectors_base_url; import io.openaev.rest.asset.security_platforms.SecurityPlatformApi; import io.openaev.rest.atomic_testing.AtomicTestingApi; +import io.openaev.rest.attack_pattern.AttackPatternApi; +import io.openaev.rest.attack_pattern.service.AttackPatternService; import io.openaev.rest.collector.CollectorApi; import io.openaev.rest.collector.service.CollectorService; import io.openaev.rest.connector_instance.ConnectorInstanceApi; @@ -66,6 +73,9 @@ import io.openaev.rest.injector_contract.InjectorContractApi; import io.openaev.rest.injector_contract.InjectorContractService; import io.openaev.rest.injector_contract.output.InjectorContractFullOutput; +import io.openaev.rest.kill_chain_phase.KillChainPhaseApi; +import io.openaev.rest.kill_chain_phase.KillChainPhaseInitializer; +import io.openaev.rest.kill_chain_phase.service.KillChainPhaseService; import io.openaev.rest.lessons.ExerciseLessonsApi; import io.openaev.rest.lessons.ScenarioLessonsApi; import io.openaev.rest.lessons_template.LessonsTemplateApi; @@ -79,6 +89,7 @@ import io.openaev.rest.vulnerability.service.VulnerabilityService; import io.openaev.scheduler.jobs.ComchecksExecutionJob; import io.openaev.service.EndpointService; +import io.openaev.service.EsAttackPathService; import io.openaev.service.InjectExpectationTraceService; import io.openaev.service.InjectImportService; import io.openaev.service.InjectTestStatusService; @@ -108,13 +119,18 @@ import io.openaev.telemetry.metric_collectors.ProductInventoryMetricCollector; import io.openaev.utils.ExpectationUtils; import io.openaev.utils.InjectUtils; +import io.openaev.utils.mapper.InjectMapper; import io.openaev.utils.mapper.VulnerabilityMapper; import java.io.FileInputStream; import java.io.InputStream; +import java.lang.reflect.Method; +import java.util.ArrayList; import java.util.Arrays; +import java.util.List; import java.util.Properties; import java.util.Set; import java.util.stream.Collectors; +import org.springframework.data.jpa.repository.Query; /** * Interim guard for the activation blind spot: once a table is in {@code @@ -157,6 +173,7 @@ class TenantActiveTableAccessArchTest { "autonomous_runs", "autonomous_events", "autonomous_directives", + "kill_chain_phases", "security_coverages"); @ArchTest @@ -178,6 +195,40 @@ static void every_active_table_is_guarded(JavaClasses classes) throws Exception + " activate-tenant-table skill, go-live phase)."); } + /** + * Repositories whose joined {@code @Query} methods have been reviewed for tenant correlation. + * Other tenant-active repositories join this set as their queries are reviewed; the known backlog + * is {@code AttackPathFindingRepository} (7 joined queries, none correlated). + */ + private static final Set> REPOSITORIES_WITH_REVIEWED_JOINED_QUERIES = + Set.of(KillChainPhaseRepository.class); + + @ArchTest + static void joined_queries_on_active_tables_correlate_the_tenant(JavaClasses classes) { + // A single-table @Query needs no predicate: the inspector's can_access_tenant is the whole + // scoping story. A JOIN reaches rows the caller did not name, and the scope can be wider than + // the tenant that owns them, so the query must correlate the two itself. + List uncorrelated = new ArrayList<>(); + for (Class repository : REPOSITORIES_WITH_REVIEWED_JOINED_QUERIES) { + for (Method method : repository.getDeclaredMethods()) { + Query query = method.getAnnotation(Query.class); + if (query == null) { + continue; + } + String normalized = query.value().toLowerCase(); + if (normalized.contains(" join ") && !normalized.contains("tenant")) { + uncorrelated.add(repository.getSimpleName() + "#" + method.getName()); + } + } + } + assertTrue( + uncorrelated.isEmpty(), + "a joined @Query on a tenant-active table must correlate the tenant with the entity it is" + + " filtered by, or the request scope alone decides which tenant's rows it reaches:" + + " " + + uncorrelated); + } + @ArchTest static final ArchRule import_mappers_repository_access_is_reviewed = noClasses() @@ -708,6 +759,63 @@ static void every_active_table_is_guarded(JavaClasses classes) throws Exception "autonomous_events is tenant-active: an accessor without a tenant scope silently" + " reads zero rows. New accessors must carry a scope and be allowlisted here"); + @ArchTest + static final ArchRule kill_chain_phases_repository_access_is_reviewed = + noClasses() + .that() + .doNotBelongToAnyOf( + // TxCtx-carrying entrypoints, pinned by TenantScopedEntrypointsTxCtxArchTest: + KillChainPhaseApi.class, + AttackPatternApi.class, + ExerciseApi.class, + // Sole write path for the table (endpoints and the scenario/simulation import): + // resolves the write tenant explicitly and looks rows up by per-tenant predicates: + KillChainPhaseService.class, + // Reads phases by id for an upserted attack pattern; driven by the TxCtx-carrying + // AttackPatternApi#upsertAttackPatterns: + AttackPatternService.class) + .should() + .dependOnClassesThat() + .areAssignableTo(KillChainPhaseRepository.class) + .because( + "kill_chain_phases is tenant-active: an accessor without a tenant scope silently" + + " reads zero rows. New accessors must carry a scope and be allowlisted here"); + + @ArchTest + static final ArchRule kill_chain_phases_association_access_is_reviewed = + noClasses() + .that() + .doNotBelongToAnyOf( + // Hydrate the association inside the TxCtx-scoped transaction, before the + // open-in-view JSON rendering (the #7025 blind spot applied to this table): + KillChainPhaseInitializer.class, + AttackPatternApi.class, + // Lombok's @Data toString() on the owning entity reads every getter, including this + // one. It never renders a response, so it cannot leak or go dark: + AttackPattern.class, + // Derived *_kill_chain_phases getters on the aggregates themselves: + Exercise.class, + Inject.class, + InjectorContract.class, + Scenario.class, + // Map or hydrate phases inside the scoped transactions of wired handlers: + InjectMapper.class, + InjectHelper.class, + InjectService.class, + ScenarioService.class, + AttackPatternService.class, + WorkflowExportInitializer.class, + EsAttackPathService.class, + V1_DataImporter.class) + .should() + .callMethod(AttackPattern.class, "getKillChainPhases") + .because( + "kill_chain_phases is reached through AttackPattern's LAZY @ManyToMany WITHOUT" + + " touching the repository. The tenant scope is transaction-local and" + + " open-in-view renders after the commit, so a lazy load at rendering time" + + " silently serializes an EMPTY phase list. New callers must run inside a scoped" + + " transaction and be allowlisted here"); + @ArchTest static final ArchRule autonomous_directives_repository_access_is_reviewed = noClasses() diff --git a/openaev-api/src/test/java/io/openaev/architecture/TenantScopedEntrypointsTxCtxArchTest.java b/openaev-api/src/test/java/io/openaev/architecture/TenantScopedEntrypointsTxCtxArchTest.java index 026f6059205..34889d97701 100644 --- a/openaev-api/src/test/java/io/openaev/architecture/TenantScopedEntrypointsTxCtxArchTest.java +++ b/openaev-api/src/test/java/io/openaev/architecture/TenantScopedEntrypointsTxCtxArchTest.java @@ -375,7 +375,69 @@ class TenantScopedEntrypointsTxCtxArchTest { "io.openaev.api.autonomous.AutonomousRunApi#promoteFindingToAsset", "io.openaev.api.autonomous.AutonomousRunApi#ensureTargetTeam", "io.openaev.rest.scenario.ScenarioApi#deleteScenario", - "io.openaev.rest.scenario.ScenarioApi#bulkDeleteScenarios"); + "io.openaev.rest.scenario.ScenarioApi#bulkDeleteScenarios", + // kill_chain_phases (v2, #6402): the table's own API, plus every path that reads it + // through AttackPattern's LAZY @ManyToMany or through a native query that JOINs it. + // Losing a TxCtx here fails silently: the phase list comes back EMPTY, it is not an + // error. + "io.openaev.rest.kill_chain_phase.KillChainPhaseApi#killChainPhases", + "io.openaev.rest.kill_chain_phase.KillChainPhaseApi#killChainPhase", + "io.openaev.rest.kill_chain_phase.KillChainPhaseApi#createKillChainPhase", + "io.openaev.rest.kill_chain_phase.KillChainPhaseApi#updateKillChainPhase", + "io.openaev.rest.kill_chain_phase.KillChainPhaseApi#upsertKillChainPhases", + "io.openaev.rest.kill_chain_phase.KillChainPhaseApi#deleteKillChainPhase", + "io.openaev.rest.kill_chain_phase.KillChainPhaseApi#optionsByName", + "io.openaev.rest.kill_chain_phase.KillChainPhaseApi#optionsById", + // attack patterns: read the phases by id on write, serialize them on read + "io.openaev.rest.attack_pattern.AttackPatternApi#attackPatterns", + "io.openaev.rest.attack_pattern.AttackPatternApi#attackPattern", + "io.openaev.rest.attack_pattern.AttackPatternApi#createAttackPattern", + "io.openaev.rest.attack_pattern.AttackPatternApi#updateAttackPattern", + "io.openaev.rest.attack_pattern.AttackPatternApi#upsertAttackPatterns", + "io.openaev.api.attack_pattern.AttackPatternCoverageApi#attackPatternsCoverage", + // scenario: the raw projection JOINs kill_chain_phases; the entity-returning handlers + // serialize scenario_kill_chain_phases; import writes phases + "io.openaev.rest.scenario.ScenarioApi#scenario", + "io.openaev.rest.scenario.ScenarioApi#duplicateScenario", + "io.openaev.rest.scenario.ScenarioApi#updateScenario", + "io.openaev.rest.scenario.ScenarioApi#updateScenarioTags", + "io.openaev.rest.scenario.ScenarioApi#updateScenarioLessons", + "io.openaev.rest.scenario.ScenarioApi#enableScenarioTeamPlayers", + "io.openaev.rest.scenario.ScenarioApi#disableScenarioTeamPlayers", + "io.openaev.rest.scenario.ScenarioApi#addScenarioTeamPlayers", + "io.openaev.rest.scenario.ScenarioApi#removeScenarioTeamPlayers", + "io.openaev.rest.scenario.ScenarioApi#exportScenario", + // simulation: findDistinctByExerciseId, exercise_kill_chain_phases, import/export + "io.openaev.rest.exercise.ExerciseApi#exercise", + "io.openaev.rest.exercise.ExerciseApi#duplicateExercise", + "io.openaev.rest.exercise.ExerciseApi#updateExerciseInformation", + "io.openaev.rest.exercise.ExerciseApi#updateExerciseTags", + "io.openaev.rest.exercise.ExerciseApi#updateExerciseLogos", + "io.openaev.rest.exercise.ExerciseApi#updateExerciseLessons", + "io.openaev.rest.exercise.ExerciseApi#enableExerciseTeamPlayers", + "io.openaev.rest.exercise.ExerciseApi#disableExerciseTeamPlayers", + "io.openaev.rest.exercise.ExerciseApi#addExerciseTeamPlayers", + "io.openaev.rest.exercise.ExerciseApi#removeExerciseTeamPlayers", + "io.openaev.rest.exercise.ExerciseApi#deleteDocument", + "io.openaev.rest.exercise.ExerciseApi#exerciseExport", + "io.openaev.rest.exercise.ExerciseApi#scenarioFromSimulation", + // injects: inject_kill_chain_phases on the entity-returning handlers + "io.openaev.rest.inject.InjectApi#inject", + "io.openaev.rest.inject.InjectApi#injectExecutionReception", + "io.openaev.rest.inject.InjectApi#nextInjectsToExecute", + // atomic testing: InjectMapper maps inject_kill_chain_phases inside the transaction + "io.openaev.rest.atomic_testing.AtomicTestingApi#findAtomicTesting", + "io.openaev.rest.atomic_testing.AtomicTestingApi#updateAtomicTestingTags", + // contract picker facet: INNER JOINs kill_chain_phases to count per phase + "io.openaev.rest.injector_contract.InjectorContractApi#getFacetCounts", + // dashboards: EsAttackPathService reads each attack pattern's phases + "io.openaev.rest.dashboard.DashboardApi#attackPaths", + // chaining duplications copy injects, so they serialize the phase lists + // Propagation.SUPPORTS handlers: they hold no transaction, so the TxCtx here exists only + // to be threaded into the service method that opens one (same shape as + // ScenarioApi#bulkDeleteScenarios). Dropping it would silently empty the phase lists. + "io.openaev.rest.inject.SimulationInjectApi#bulkUpdateInjectsForSimulation", + "io.openaev.rest.inject.ScenarioInjectApi#bulkUpdateInjectsForScenario"); @ArchTest static final ArchRule tx_scoped_entrypoints_must_declare_tx_ctx = diff --git a/openaev-api/src/test/java/io/openaev/config/ImportMapperActivationConfigTest.java b/openaev-api/src/test/java/io/openaev/config/ImportMapperActivationConfigTest.java index 607d3a56830..2bcc4488e62 100644 --- a/openaev-api/src/test/java/io/openaev/config/ImportMapperActivationConfigTest.java +++ b/openaev-api/src/test/java/io/openaev/config/ImportMapperActivationConfigTest.java @@ -113,4 +113,20 @@ void prodConfigActivatesAutonomousTables() throws Exception { + active + "'"); } + + @Test + @DisplayName("openaev.tenant.active-tables in application.properties contains kill_chain_phases") + void prodConfigActivatesKillChainPhases() throws Exception { + Properties props = new Properties(); + try (InputStream in = new FileInputStream("src/main/resources/application.properties")) { + props.load(in); + } + String active = props.getProperty("openaev.tenant.active-tables", ""); + assertTrue( + active.contains("kill_chain_phases"), + "kill_chain_phases must stay in openaev.tenant.active-tables: its v1 @Filter was removed, so" + + " dropping it would leave the table with no tenant isolation. Found: '" + + active + + "'"); + } } diff --git a/openaev-api/src/test/java/io/openaev/config/KillChainPhaseQueryCorrelationTest.java b/openaev-api/src/test/java/io/openaev/config/KillChainPhaseQueryCorrelationTest.java new file mode 100644 index 00000000000..ef370d909bd --- /dev/null +++ b/openaev-api/src/test/java/io/openaev/config/KillChainPhaseQueryCorrelationTest.java @@ -0,0 +1,207 @@ +package io.openaev.config; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import io.openaev.database.model.KillChainPhase; +import io.openaev.database.repository.KillChainPhaseRepository; +import io.openaev.rest.kill_chain_phase.service.KillChainPhaseService; +import java.util.List; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; + +@SpringBootTest(properties = "openaev.tenant.active-tables=kill_chain_phases") +@DisplayName("kill chain phase reads correlate the phase tenant with its parent's") +class KillChainPhaseQueryCorrelationTest extends TenantIsolationIntegrationTest { + + private static final String OTHER_TENANT = "kcp-corr-other-tenant"; + private static final String OWN_PHASE = "kcp-corr-phase-own"; + private static final String OTHER_PHASE = "kcp-corr-phase-other"; + + @Autowired private KillChainPhaseRepository killChainPhaseRepository; + @Autowired private KillChainPhaseService killChainPhaseService; + + @Nested + @DisplayName("simulation kill chain phases stay in the simulation's tenant") + class SimulationPhases { + + private static final String EXERCISE = "kcp-corr-exercise"; + private static final String INJECT = "kcp-corr-inject"; + private static final String CONTRACT = "kcp-corr-contract"; + private static final String PATTERN = "kcp-corr-sim-pattern"; + + private String simulationTenant; + + @BeforeEach + void seedOneSimulationWhoseAttackPatternLinksTwoTenantsPhases() { + seedTenant(OTHER_TENANT); + insertExercise(EXERCISE); + simulationTenant = readTenantOf("exercises", "exercise_id", EXERCISE); + insertPhase(OWN_PHASE, simulationTenant); + insertPhase(OTHER_PHASE, OTHER_TENANT); + insertAttackPattern(PATTERN); + insertContract(CONTRACT); + insertContractPatternLink(CONTRACT, PATTERN); + insertPatternPhaseLink(PATTERN, OWN_PHASE); + insertPatternPhaseLink(PATTERN, OTHER_PHASE); + insertInject(INJECT, EXERCISE, CONTRACT); + } + + @Test + @DisplayName("a scope holding both tenants returns the simulation's phase only") + void multiTenantScopeStaysOnTheSimulationTenant() { + setScope(simulationTenant + "," + OTHER_TENANT); + assertEquals( + List.of(OWN_PHASE), + visiblePhaseIds(), + "the other tenant's phase is in scope and linked to the same attack pattern; only the" + + " correlation with the simulation's tenant keeps it out"); + } + + @Test + @DisplayName("the correlation composes with the scope: an unscoped read stays fail-closed") + void unscopedReadReturnsNothing() { + setScope(""); + assertTrue(visiblePhaseIds().isEmpty()); + } + + private List visiblePhaseIds() { + return killChainPhaseRepository.findDistinctByExerciseId(EXERCISE).stream() + .map(KillChainPhase::getId) + .sorted() + .toList(); + } + } + + @Nested + @DisplayName("attack pattern phase ids stay in the pattern's tenant") + class AttackPatternPhaseIds { + + private static final String PATTERN = "kcp-corr-ap-pattern"; + + private String patternTenant; + + @BeforeEach + void seedOnePatternLinkedToTwoTenantsPhases() { + seedTenant(OTHER_TENANT); + insertAttackPattern(PATTERN); + patternTenant = readTenantOf("attack_patterns", "attack_pattern_id", PATTERN); + insertPhase(OWN_PHASE, patternTenant); + insertPhase(OTHER_PHASE, OTHER_TENANT); + insertPatternPhaseLink(PATTERN, OWN_PHASE); + insertPatternPhaseLink(PATTERN, OTHER_PHASE); + } + + @Test + @DisplayName("a scope holding both tenants yields the pattern's own phase id only") + void multiTenantScopeStaysOnThePatternTenant() { + setScope(patternTenant + "," + OTHER_TENANT); + assertEquals( + List.of(OWN_PHASE), + visiblePhaseIds(), + "the other tenant's phase is in scope and linked to the same pattern; only the" + + " correlation with the pattern's tenant keeps it out"); + } + + @Test + @DisplayName("the correlation composes with the scope: an unscoped read stays fail-closed") + void unscopedReadReturnsNothing() { + setScope(""); + assertTrue(visiblePhaseIds().isEmpty()); + } + + private List visiblePhaseIds() { + return killChainPhaseService + .phaseIdsByAttackPatternId(List.of(PATTERN)) + .getOrDefault(PATTERN, List.of()) + .stream() + .sorted() + .toList(); + } + } + + private String readTenantOf(String table, String idColumn, String id) { + return (String) + entityManager + .createNativeQuery("SELECT tenant_id FROM " + table + " WHERE " + idColumn + " = :id") + .setParameter("id", id) + .getSingleResult(); + } + + private void insertPhase(String id, String tenantId) { + entityManager + .createNativeQuery( + "INSERT INTO kill_chain_phases" + + " (phase_id, phase_name, phase_shortname, phase_kill_chain_name," + + " phase_external_id, phase_order, tenant_id)" + + " VALUES (:id, :id, :id, 'mitre-attack', :id, 1, :tenant)") + .setParameter("id", id) + .setParameter("tenant", tenantId) + .executeUpdate(); + } + + private void insertAttackPattern(String id) { + entityManager + .createNativeQuery( + "INSERT INTO attack_patterns" + + " (attack_pattern_id, attack_pattern_name, attack_pattern_external_id)" + + " VALUES (:id, :id, :id)") + .setParameter("id", id) + .executeUpdate(); + } + + private void insertPatternPhaseLink(String patternId, String phaseId) { + entityManager + .createNativeQuery( + "INSERT INTO attack_patterns_kill_chain_phases (attack_pattern_id, phase_id)" + + " VALUES (:pattern, :phase)") + .setParameter("pattern", patternId) + .setParameter("phase", phaseId) + .executeUpdate(); + } + + private void insertContract(String id) { + entityManager + .createNativeQuery( + "INSERT INTO injectors_contracts" + + " (injector_contract_id, injector_contract_content) VALUES (:id, '{}')") + .setParameter("id", id) + .executeUpdate(); + } + + private void insertContractPatternLink(String contractId, String patternId) { + entityManager + .createNativeQuery( + "INSERT INTO injectors_contracts_attack_patterns" + + " (injector_contract_id, attack_pattern_id) VALUES (:contract, :pattern)") + .setParameter("contract", contractId) + .setParameter("pattern", patternId) + .executeUpdate(); + } + + private void insertExercise(String id) { + entityManager + .createNativeQuery( + "INSERT INTO exercises (exercise_id, exercise_name, exercise_mail_from)" + + " VALUES (:id, :id, 'test@openaev.io')") + .setParameter("id", id) + .executeUpdate(); + } + + private void insertInject(String id, String exerciseId, String contractId) { + entityManager + .createNativeQuery( + "INSERT INTO injects" + + " (inject_id, inject_title, inject_all_teams, inject_enabled," + + " inject_depends_duration, inject_exercise, inject_injector_contract)" + + " VALUES (:id, :id, false, true, 0, :exercise, :contract)") + .setParameter("id", id) + .setParameter("exercise", exerciseId) + .setParameter("contract", contractId) + .executeUpdate(); + } +} diff --git a/openaev-api/src/test/java/io/openaev/config/TenantStatementInspectorTest.java b/openaev-api/src/test/java/io/openaev/config/TenantStatementInspectorTest.java index 7646ecca01e..aeeb11f6c98 100644 --- a/openaev-api/src/test/java/io/openaev/config/TenantStatementInspectorTest.java +++ b/openaev-api/src/test/java/io/openaev/config/TenantStatementInspectorTest.java @@ -792,4 +792,34 @@ void deleteUsingFiltersEverySource() { assertTrue(out.contains("can_access_tenant(f.tenant_id)"), out); assertTrue(out.contains("can_access_tenant(g.tenant_id, true)"), out); } + + @Test + @DisplayName( + "the real scenario-detail native query keeps its CTE shape when kill_chain_phases is active") + void realScenarioDetailQueryIsRewrittenNotRefused() throws Exception { + // Activating kill_chain_phases pulls this query into the fail-closed rewrite because one of its + // CTEs JOINs the table (#7007 class of regression: the shape, not the isolation, is what + // breaks). The API test suite ships an EMPTY allowlist, so this is the only layer that + // exercises the rewriter for this table — assert on the REAL production SQL, read off the + // repository method, never a hand-simplified paraphrase. + String sql = + io.openaev.database.repository.ScenarioRepository.class + .getMethod("getScenarioByIdAndTenantId", String.class) + .getAnnotation(org.springframework.data.jpa.repository.Query.class) + .value(); + // Spring resolves the SpEL selector into a bind parameter long before Hibernate sees the SQL; + // JSqlParser only ever parses the resolved form. + String resolved = sql.replaceAll(":#\\{#[^}]*}", "?"); + TenantStatementInspector phasesActive = + new TenantStatementInspector(new TenantTables(Set.of("kill_chain_phases"), Set.of())); + + String out = phasesActive.inspect(resolved).replaceAll("\\s+", " ").trim(); + + assertTrue( + out.contains("can_access_tenant(kcp.tenant_id)"), + "the kill_chain_phases join inside the kill_chain CTE must be filtered: " + out); + // The CTE structure and the json aggregation must survive the rewrite rather than be refused. + assertTrue(out.contains("json_agg"), out); + assertTrue(out.toUpperCase().contains("WITH"), out); + } } diff --git a/openaev-api/src/test/java/io/openaev/datapack/local_fixtures/TestDataPack.java b/openaev-api/src/test/java/io/openaev/datapack/local_fixtures/TestDataPack.java index 7dc9ddc2c7d..cff4417b184 100644 --- a/openaev-api/src/test/java/io/openaev/datapack/local_fixtures/TestDataPack.java +++ b/openaev-api/src/test/java/io/openaev/datapack/local_fixtures/TestDataPack.java @@ -2,6 +2,7 @@ import static io.openaev.utils.StringUtils.generateRandomColor; +import io.openaev.database.model.Tenant; import io.openaev.processor.datapack.DataPack; import io.openaev.rest.tag.TagService; import io.openaev.rest.tag.form.TagCreateInput; @@ -20,7 +21,7 @@ public TestDataPack(DataPackService dataPackService, TagService tagService) { } @Override - protected boolean doProcess() { + protected boolean doProcess(Tenant tenant) { // insert a new tag with static name TagCreateInput input = new TagCreateInput(); input.setName(tagName); diff --git a/openaev-api/src/test/java/io/openaev/datapack/packs/StarterPackTest.java b/openaev-api/src/test/java/io/openaev/datapack/packs/StarterPackTest.java index 1dbc96428cd..1be1528499a 100644 --- a/openaev-api/src/test/java/io/openaev/datapack/packs/StarterPackTest.java +++ b/openaev-api/src/test/java/io/openaev/datapack/packs/StarterPackTest.java @@ -192,7 +192,9 @@ public void shouldNotInitStarterPackScenariosForImportFailure() throws Exception // DataPack#enableV1TenantFilter needs, which @PersistenceContext would normally provide. ReflectionTestUtils.setField(datapack, "entityManager", entityManager); ReflectionTestUtils.setField(datapack, "isStarterPackEnabled", true); - doThrow(new Exception()).when(mockImportService).handleFileImport(any(), isNull(), isNull()); + doThrow(new Exception()) + .when(mockImportService) + .handleFileImport(any(), any(), isNull(), isNull()); // EXECUTE datapack.process(new Tenant(TenantContext.getCurrentTenant())); diff --git a/openaev-api/src/test/java/io/openaev/importer/V1_DataImporterTest.java b/openaev-api/src/test/java/io/openaev/importer/V1_DataImporterTest.java index fa712bc2629..d2ee0abe0d9 100644 --- a/openaev-api/src/test/java/io/openaev/importer/V1_DataImporterTest.java +++ b/openaev-api/src/test/java/io/openaev/importer/V1_DataImporterTest.java @@ -14,6 +14,7 @@ import com.fasterxml.jackson.databind.node.ObjectNode; import io.openaev.IntegrationTest; import io.openaev.context.TenantContext; +import io.openaev.context.TxCtx; import io.openaev.database.model.*; import io.openaev.database.repository.*; import io.openaev.ee.EnterpriseEditionException; @@ -89,6 +90,11 @@ class V1_DataImporterTest extends IntegrationTest { public static final String PAYLOAD_EXTERNAL_ID = "PAYLOAD_EXTERNAL_ID"; public static final String NMAP_DUMMY_INJECTOR_TYPE = "openaev_nmap_dummy"; + /** Same scope the HTTP import endpoints resolve: the tenant the test session runs in. */ + private TxCtx txCtx() { + return TxCtx.forTenant(TenantContext.getCurrentTenant()); + } + @BeforeEach void cleanBefore() throws IOException { killChainPhaseRepository.deleteAll(); @@ -112,7 +118,14 @@ void cleanBefore() throws IOException { void testImportData() { // -- EXECUTE -- this.importer.importData( - this.importNode, Map.of(), null, null, null, null, Constants.IMPORTED_OBJECT_NAME_SUFFIX); + txCtx(), + this.importNode, + Map.of(), + null, + null, + null, + null, + Constants.IMPORTED_OBJECT_NAME_SUFFIX); // -- ASSERT -- Optional exercise = this.exerciseRepository.findOne(exerciseByName(EXERCISE_NAME)); @@ -152,7 +165,14 @@ void testScenario_with_attackpattern() throws Exception { "src/test/resources/importer-v1/import-scenario-with-attack-pattern.json"))); this.importNode = mapper.readTree(jsonContent); this.importer.importData( - this.importNode, Map.of(), null, null, null, null, Constants.IMPORTED_OBJECT_NAME_SUFFIX); + txCtx(), + this.importNode, + Map.of(), + null, + null, + null, + null, + Constants.IMPORTED_OBJECT_NAME_SUFFIX); Payload payload = payloadRepository.findAll().iterator().next(); InjectorContract injectorContract = @@ -180,7 +200,14 @@ void testScenario_with_attackpattern() throws Exception { openaevInjectorIntegrationFactory.registerConnectorForTenant(TenantContext.getCurrentTenant()); this.importer.importData( - this.importNode, Map.of(), null, null, null, null, Constants.IMPORTED_OBJECT_NAME_SUFFIX); + txCtx(), + this.importNode, + Map.of(), + null, + null, + null, + null, + Constants.IMPORTED_OBJECT_NAME_SUFFIX); payload = payloadRepository.findAll().iterator().next(); InjectorContract injectorContract2 = injectorContractRepository.findInjectorContractByPayload(payload).orElseThrow(); @@ -207,7 +234,14 @@ void testScenario_with_attackpattern() throws Exception { "src/test/resources/importer-v1/scenario_with_injects_from_injector.json"))); this.importNode = mapper.readTree(jsonContent); this.importer.importData( - this.importNode, Map.of(), null, null, null, null, Constants.IMPORTED_OBJECT_NAME_SUFFIX); + txCtx(), + this.importNode, + Map.of(), + null, + null, + null, + null, + Constants.IMPORTED_OBJECT_NAME_SUFFIX); // the contract should be created without any injector link (no placeholder injector): // the real injector adopts it by id when it registers @@ -241,7 +275,14 @@ void testScenario_with_attackpattern() throws Exception { // -- EXECUTE -- this.importer.importData( - this.importNode, Map.of(), null, null, null, null, Constants.IMPORTED_OBJECT_NAME_SUFFIX); + txCtx(), + this.importNode, + Map.of(), + null, + null, + null, + null, + Constants.IMPORTED_OBJECT_NAME_SUFFIX); // -- ASSERT -- InjectorContract importedContract = @@ -281,7 +322,14 @@ void testImportXTMHubScenarios() throws IOException { String jsonContent = Files.readString(xtmScenariosFilePath); JsonNode importNode = mapper.readTree(jsonContent); this.importer.importData( - importNode, Map.of(), null, null, null, null, Constants.IMPORTED_OBJECT_NAME_SUFFIX); + txCtx(), + importNode, + Map.of(), + null, + null, + null, + null, + Constants.IMPORTED_OBJECT_NAME_SUFFIX); } } @@ -344,7 +392,14 @@ void testImportScenario_givenPayloadWithMissingArrayFields_shouldImportWithoutEr // -- EXECUTE -- this.importer.importData( - this.importNode, Map.of(), null, null, null, null, Constants.IMPORTED_OBJECT_NAME_SUFFIX); + txCtx(), + this.importNode, + Map.of(), + null, + null, + null, + null, + Constants.IMPORTED_OBJECT_NAME_SUFFIX); // -- ASSERT -- List payloads = new ArrayList<>(); @@ -381,7 +436,14 @@ void testImportScenario_givenPayloadWithExplicitNullArrayFields_shouldImportWith // -- EXECUTE -- this.importer.importData( - this.importNode, Map.of(), null, null, null, null, Constants.IMPORTED_OBJECT_NAME_SUFFIX); + txCtx(), + this.importNode, + Map.of(), + null, + null, + null, + null, + Constants.IMPORTED_OBJECT_NAME_SUFFIX); // -- ASSERT -- List payloads = new ArrayList<>(); @@ -418,6 +480,7 @@ void given_scenarioWithWorkflow_when_enterpriseLicenseInactive_should_failImport EnterpriseEditionException.class, () -> importer.importData( + txCtx(), workflowImport, Map.of(), null, @@ -440,7 +503,14 @@ void given_scenarioWithWorkflow_should_importWorkflowStepsAndConditions() throws // -- Act -- this.importer.importData( - workflowImport, Map.of(), null, null, null, null, Constants.IMPORTED_OBJECT_NAME_SUFFIX); + txCtx(), + workflowImport, + Map.of(), + null, + null, + null, + null, + Constants.IMPORTED_OBJECT_NAME_SUFFIX); // -- Assert -- String expectedName = "test workflow import%s".formatted(Constants.IMPORTED_OBJECT_NAME_SUFFIX); @@ -524,7 +594,14 @@ void given_scenarioWithWorkflowContainingAssetScopeRules_should_importOnlyNonAss // -- Act -- this.importer.importData( - workflowImport, Map.of(), null, null, null, null, Constants.IMPORTED_OBJECT_NAME_SUFFIX); + txCtx(), + workflowImport, + Map.of(), + null, + null, + null, + null, + Constants.IMPORTED_OBJECT_NAME_SUFFIX); // -- Assert -- String expectedName = "test workflow import%s".formatted(Constants.IMPORTED_OBJECT_NAME_SUFFIX); @@ -575,7 +652,13 @@ void given_scenarioWithWorkflowContainingAssetScopeRules_should_importOnlyNonAss // -- Act -- V1_DataImporter.StepDataResolution resolution = ReflectionTestUtils.invokeMethod( - importer, "resolveStepData", stepNode, resolvedContracts, new HashMap<>(), workflow); + importer, + "resolveStepData", + txCtx(), + stepNode, + resolvedContracts, + new HashMap<>(), + workflow); String resolvedStepData = resolution.stepData(); JsonNode resolvedJson = assertDoesNotThrow(() -> objectMapper.readTree(resolvedStepData)); @@ -611,7 +694,13 @@ void given_scenarioWithWorkflowContainingAssetScopeRules_should_importOnlyNonAss // -- Act -- V1_DataImporter.StepDataResolution resolution = ReflectionTestUtils.invokeMethod( - importer, "resolveStepData", stepNode, resolvedContracts, new HashMap<>(), workflow); + importer, + "resolveStepData", + txCtx(), + stepNode, + resolvedContracts, + new HashMap<>(), + workflow); String resolvedStepData = resolution.stepData(); JsonNode resolvedJson = assertDoesNotThrow(() -> objectMapper.readTree(resolvedStepData)); @@ -655,6 +744,7 @@ void given_stepDataWithRuntimeReferences_when_resolvingStepData_should_preserveR ReflectionTestUtils.invokeMethod( importer, "resolveStepData", + txCtx(), stepNode, new HashMap(), new HashMap<>(), @@ -694,7 +784,14 @@ void given_stepDataWithSourceTeams_when_importing_should_stripInjectTeams() thro // -- Act -- this.importer.importData( - importData, Map.of(), null, null, null, null, Constants.IMPORTED_OBJECT_NAME_SUFFIX); + txCtx(), + importData, + Map.of(), + null, + null, + null, + null, + Constants.IMPORTED_OBJECT_NAME_SUFFIX); // -- Assert -- JsonNode storedData = readStoredStepData(scenarioName, om); @@ -738,7 +835,14 @@ void given_workflowStandaloneMapperConditions_should_ignoreMapperStandaloneOnImp // -- Act -- this.importer.importData( - workflowImport, Map.of(), null, null, null, null, Constants.IMPORTED_OBJECT_NAME_SUFFIX); + txCtx(), + workflowImport, + Map.of(), + null, + null, + null, + null, + Constants.IMPORTED_OBJECT_NAME_SUFFIX); // -- Assert -- String expectedName = "test workflow import%s".formatted(Constants.IMPORTED_OBJECT_NAME_SUFFIX); @@ -805,7 +909,14 @@ void given_workflowStepConditionsOutOfOrder_when_importing_should_preserveParent // -- Act -- this.importer.importData( - workflowImport, Map.of(), null, null, null, null, Constants.IMPORTED_OBJECT_NAME_SUFFIX); + txCtx(), + workflowImport, + Map.of(), + null, + null, + null, + null, + Constants.IMPORTED_OBJECT_NAME_SUFFIX); // -- Assert -- String expectedName = "test workflow import%s".formatted(Constants.IMPORTED_OBJECT_NAME_SUFFIX); @@ -879,7 +990,14 @@ void given_scenarioWithLegacyPredefinedExpectations_should_migrateToAvailableExp // Act this.importer.importData( - importNode, Map.of(), null, null, null, null, Constants.IMPORTED_OBJECT_NAME_SUFFIX); + txCtx(), + importNode, + Map.of(), + null, + null, + null, + null, + Constants.IMPORTED_OBJECT_NAME_SUFFIX); // Assert — the injector contract should have been created with migrated expectations InjectorContract importedContract = @@ -957,7 +1075,14 @@ void given_workflowStepDataWithSourceInjectUser_when_importing_should_rewriteToC // -- Act -- this.importer.importData( - workflowImport, Map.of(), null, null, null, null, Constants.IMPORTED_OBJECT_NAME_SUFFIX); + txCtx(), + workflowImport, + Map.of(), + null, + null, + null, + null, + Constants.IMPORTED_OBJECT_NAME_SUFFIX); // -- Assert -- String expectedName = @@ -1031,7 +1156,14 @@ void given_workflowStepDataWithSourceInjectUser_when_importing_should_rewriteToC // -- Act -- this.importer.importData( - importData, Map.of(), null, null, null, null, Constants.IMPORTED_OBJECT_NAME_SUFFIX); + txCtx(), + importData, + Map.of(), + null, + null, + null, + null, + Constants.IMPORTED_OBJECT_NAME_SUFFIX); // -- Assert -- // No duplicate tag created: the source id was mapped to the existing tag found by name. @@ -1076,7 +1208,14 @@ void given_stepDataTagUnknownByNameAndId_when_importing_should_createNewTagAndRe // -- Act -- this.importer.importData( - importData, Map.of(), null, null, null, null, Constants.IMPORTED_OBJECT_NAME_SUFFIX); + txCtx(), + importData, + Map.of(), + null, + null, + null, + null, + Constants.IMPORTED_OBJECT_NAME_SUFFIX); // -- Assert -- List created = tagRepository.findByNameIgnoreCase(tagName); @@ -1136,7 +1275,14 @@ void given_stepDataTagUnresolvable_when_importing_should_dropTagAndNotCrashAtRun // -- Act -- this.importer.importData( - importData, Map.of(), null, null, null, null, Constants.IMPORTED_OBJECT_NAME_SUFFIX); + txCtx(), + importData, + Map.of(), + null, + null, + null, + null, + Constants.IMPORTED_OBJECT_NAME_SUFFIX); // -- Assert -- JsonNode storedData = readStoredStepData(scenarioName, om); @@ -1223,7 +1369,14 @@ void given_stepDataWithDocumentAttachment_when_importing_should_rewriteDocumentI // -- Act -- this.importer.importData( - importData, docReferences, null, null, null, null, Constants.IMPORTED_OBJECT_NAME_SUFFIX); + txCtx(), + importData, + docReferences, + null, + null, + null, + null, + Constants.IMPORTED_OBJECT_NAME_SUFFIX); // -- Assert -- JsonNode storedData = readStoredStepData(scenarioName, om); @@ -1330,7 +1483,14 @@ void given_stepDataWithDocumentAttachment_when_importing_should_rewriteDocumentI // -- Act -- this.importer.importData( - importData, Map.of(), null, null, null, null, Constants.IMPORTED_OBJECT_NAME_SUFFIX); + txCtx(), + importData, + Map.of(), + null, + null, + null, + null, + Constants.IMPORTED_OBJECT_NAME_SUFFIX); // -- Assert -- assertEquals( @@ -1368,7 +1528,14 @@ void given_stepDataWithDocumentAttachment_when_importing_should_rewriteDocumentI // -- Act -- this.importer.importData( - importData, Map.of(), null, null, null, null, Constants.IMPORTED_OBJECT_NAME_SUFFIX); + txCtx(), + importData, + Map.of(), + null, + null, + null, + null, + Constants.IMPORTED_OBJECT_NAME_SUFFIX); // -- Assert -- assertEquals( @@ -1413,7 +1580,14 @@ void given_stepDataWithDocumentAttachment_when_importing_should_rewriteDocumentI // -- Act -- this.importer.importData( - importData, Map.of(), null, null, null, null, Constants.IMPORTED_OBJECT_NAME_SUFFIX); + txCtx(), + importData, + Map.of(), + null, + null, + null, + null, + Constants.IMPORTED_OBJECT_NAME_SUFFIX); // -- Assert -- List created = domainRepository.findByNameIn(List.of(domainName)); @@ -1455,7 +1629,14 @@ void given_stepDataWithDocumentAttachment_when_importing_should_rewriteDocumentI // -- Act -- this.importer.importData( - importData, Map.of(), null, null, null, null, Constants.IMPORTED_OBJECT_NAME_SUFFIX); + txCtx(), + importData, + Map.of(), + null, + null, + null, + null, + Constants.IMPORTED_OBJECT_NAME_SUFFIX); // -- Assert -- List created = domainRepository.findByNameIn(List.of(domainName)); @@ -1506,7 +1687,14 @@ void given_stepDataContractDomainUnresolvable_when_importing_should_dropDomainAn // -- Act -- this.importer.importData( - importData, Map.of(), null, null, null, null, Constants.IMPORTED_OBJECT_NAME_SUFFIX); + txCtx(), + importData, + Map.of(), + null, + null, + null, + null, + Constants.IMPORTED_OBJECT_NAME_SUFFIX); // -- Assert -- JsonNode storedData = readStoredStepData(scenarioName, om); @@ -1536,7 +1724,14 @@ void given_workflowConfigFieldsAbsent_when_importing_should_applyBusinessDefault // -- Act -- this.importer.importData( - importData, Map.of(), null, null, null, null, Constants.IMPORTED_OBJECT_NAME_SUFFIX); + txCtx(), + importData, + Map.of(), + null, + null, + null, + null, + Constants.IMPORTED_OBJECT_NAME_SUFFIX); // -- Assert -- Workflow workflow = findImportedWorkflow(scenarioName); @@ -1570,7 +1765,14 @@ void given_workflowConfigFieldsExplicit_when_importing_should_honourExplicitValu // -- Act -- this.importer.importData( - importData, Map.of(), null, null, null, null, Constants.IMPORTED_OBJECT_NAME_SUFFIX); + txCtx(), + importData, + Map.of(), + null, + null, + null, + null, + Constants.IMPORTED_OBJECT_NAME_SUFFIX); // -- Assert -- Workflow workflow = findImportedWorkflow(scenarioName); @@ -1600,7 +1802,14 @@ void given_workflowFullConfigFixture_when_importing_should_preserveExplicitConfi // -- Act -- this.importer.importData( - importData, Map.of(), null, null, null, null, Constants.IMPORTED_OBJECT_NAME_SUFFIX); + txCtx(), + importData, + Map.of(), + null, + null, + null, + null, + Constants.IMPORTED_OBJECT_NAME_SUFFIX); // -- Assert -- String expectedName = "test workflow import%s".formatted(Constants.IMPORTED_OBJECT_NAME_SUFFIX); @@ -1652,7 +1861,14 @@ void given_workflowFullConfigFixture_when_importing_should_preserveExplicitConfi // -- Act -- this.importer.importData( - importData, Map.of(), null, null, null, null, Constants.IMPORTED_OBJECT_NAME_SUFFIX); + txCtx(), + importData, + Map.of(), + null, + null, + null, + null, + Constants.IMPORTED_OBJECT_NAME_SUFFIX); // -- Assert -- assertEquals( @@ -1696,7 +1912,14 @@ void given_workflowFullConfigFixture_when_importing_should_preserveExplicitConfi // -- Act -- this.importer.importData( - importData, Map.of(), null, null, null, null, Constants.IMPORTED_OBJECT_NAME_SUFFIX); + txCtx(), + importData, + Map.of(), + null, + null, + null, + null, + Constants.IMPORTED_OBJECT_NAME_SUFFIX); // -- Assert -- List created = @@ -1756,7 +1979,14 @@ void given_workflowFullConfigFixture_when_importing_should_preserveExplicitConfi // -- Act -- this.importer.importData( - importData, Map.of(), null, null, null, null, Constants.IMPORTED_OBJECT_NAME_SUFFIX); + txCtx(), + importData, + Map.of(), + null, + null, + null, + null, + Constants.IMPORTED_OBJECT_NAME_SUFFIX); // -- Assert -- JsonNode storedData = readStoredStepData(scenarioName, om); @@ -1801,6 +2031,7 @@ void given_workflowFullConfigFixture_when_importing_should_preserveExplicitConfi ReflectionTestUtils.invokeMethod( importer, "rewriteInjectorContractAttackPatterns", + txCtx(), contractNode, new HashMap()); @@ -1848,6 +2079,7 @@ void given_workflowFullConfigFixture_when_importing_should_preserveExplicitConfi ReflectionTestUtils.invokeMethod( importer, "rewriteInjectorContractAttackPatterns", + txCtx(), contractNode, new HashMap()); @@ -1876,6 +2108,7 @@ void given_workflowFullConfigFixture_when_importing_should_preserveExplicitConfi ReflectionTestUtils.invokeMethod( importer, "rewriteInjectorContractAttackPatterns", + txCtx(), contractNode, new HashMap()); @@ -1912,7 +2145,14 @@ void given_stepDataTagIdExistingOnTargetButNotInExport_when_importing_should_kee // -- Act -- this.importer.importData( - importData, Map.of(), null, null, null, null, Constants.IMPORTED_OBJECT_NAME_SUFFIX); + txCtx(), + importData, + Map.of(), + null, + null, + null, + null, + Constants.IMPORTED_OBJECT_NAME_SUFFIX); // -- Assert -- JsonNode storedData = readStoredStepData(scenarioName, om); @@ -1942,7 +2182,14 @@ void given_workflowBooleanConfigFieldsExplicitNull_when_importing_should_applyDe // -- Act -- this.importer.importData( - importData, Map.of(), null, null, null, null, Constants.IMPORTED_OBJECT_NAME_SUFFIX); + txCtx(), + importData, + Map.of(), + null, + null, + null, + null, + Constants.IMPORTED_OBJECT_NAME_SUFFIX); // -- Assert -- Workflow workflow = findImportedWorkflow(scenarioName); @@ -1988,6 +2235,7 @@ void given_stepDataWithoutInjectorContract_when_resolving_should_sanitizeAndBind ReflectionTestUtils.invokeMethod( importer, "resolveStepData", + txCtx(), stepNode, new HashMap(), new HashMap(), @@ -2030,6 +2278,7 @@ void given_stepDataInjectorContractWithoutId_when_resolving_should_sanitizeWitho ReflectionTestUtils.invokeMethod( importer, "resolveStepData", + txCtx(), stepNode, new HashMap(), new HashMap(), @@ -2054,7 +2303,9 @@ void given_stepDataWithSourceInjectAttackPatterns_when_importing_should_rebuildT // so the import must recompute them from it, otherwise the imported action is displayed // TTP-less ("Other" tactic) by the chaining UI (#7577). KillChainPhase targetPhase = - killChainPhaseRepository.save(KillChainPhaseFixture.getKillChainPhase("execution", 2L)); + killChainPhaseRepository.save( + KillChainPhaseFixture.getKillChainPhase( + "execution", 2L, TenantContext.getCurrentTenant())); AttackPattern targetAttackPattern = AttackPatternFixture.createAttackPatternsWithExternalId("T1059.001"); targetAttackPattern.setKillChainPhases(new ArrayList<>(List.of(targetPhase))); @@ -2088,7 +2339,14 @@ void given_stepDataWithSourceInjectAttackPatterns_when_importing_should_rebuildT // -- Act -- this.importer.importData( - importData, Map.of(), null, null, null, null, Constants.IMPORTED_OBJECT_NAME_SUFFIX); + txCtx(), + importData, + Map.of(), + null, + null, + null, + null, + Constants.IMPORTED_OBJECT_NAME_SUFFIX); // -- Assert -- JsonNode storedData = readStoredStepData(scenarioName, om); @@ -2134,6 +2392,7 @@ void given_stepDataTextualContractMissingFromDb_when_resolving_should_logAndSani ReflectionTestUtils.invokeMethod( importer, "resolveStepData", + txCtx(), stepNode, new HashMap(), new HashMap(), @@ -2185,6 +2444,7 @@ void given_stepDataContractExistingOnTarget_when_resolving_should_sanitizeWithou ReflectionTestUtils.invokeMethod( importer, "resolveStepData", + txCtx(), stepNode, new HashMap(), new HashMap(), @@ -2237,7 +2497,14 @@ void given_stepDataContractExistingOnTarget_when_resolving_should_sanitizeWithou // -- Act -- this.importer.importData( - importData, Map.of(), null, null, null, null, Constants.IMPORTED_OBJECT_NAME_SUFFIX); + txCtx(), + importData, + Map.of(), + null, + null, + null, + null, + Constants.IMPORTED_OBJECT_NAME_SUFFIX); // -- Assert -- JsonNode storedData = readStoredStepData(scenarioName, om); @@ -2270,7 +2537,14 @@ void given_stepDataContractExistingOnTarget_when_resolving_should_sanitizeWithou // -- Act -- this.importer.importData( - importData, Map.of(), null, null, null, null, Constants.IMPORTED_OBJECT_NAME_SUFFIX); + txCtx(), + importData, + Map.of(), + null, + null, + null, + null, + Constants.IMPORTED_OBJECT_NAME_SUFFIX); // -- Assert -- JsonNode storedData = readStoredStepData(scenarioName, om); @@ -2290,6 +2564,7 @@ void given_stepDataContractExistingOnTarget_when_resolving_should_sanitizeWithou // -- Act -- this.importer.importData( + txCtx(), readMissingContractWithPayloadFixture(), Map.of(), null, @@ -2346,7 +2621,14 @@ void given_stepDataContractExistingOnTarget_when_resolving_should_sanitizeWithou // -- Act -- ImportResult result = this.importer.importData( - importData, Map.of(), null, null, null, null, Constants.IMPORTED_OBJECT_NAME_SUFFIX); + txCtx(), + importData, + Map.of(), + null, + null, + null, + null, + Constants.IMPORTED_OBJECT_NAME_SUFFIX); // -- Assert -- // The embedded payload is recreated and attached to a freshly created injector contract. @@ -2414,6 +2696,7 @@ void given_stepDataContractExistingOnTarget_when_resolving_should_sanitizeWithou assertDoesNotThrow( () -> this.importer.importData( + txCtx(), importData, Map.of(), null, @@ -2448,6 +2731,7 @@ void given_stepDataContractExistingOnTarget_when_resolving_should_sanitizeWithou // First import: creates the payload + its injector contract. this.importer.importData( + txCtx(), readMissingContractWithPayloadFixture(), Map.of(), null, @@ -2471,6 +2755,7 @@ void given_stepDataContractExistingOnTarget_when_resolving_should_sanitizeWithou // -- Act -- // Second import of the SAME simulation. this.importer.importData( + txCtx(), readMissingContractWithPayloadFixture(), Map.of(), null, @@ -2504,6 +2789,7 @@ void given_stepDataContractExistingOnTarget_when_resolving_should_sanitizeWithou // First import: creates the payload + contract, WITHOUT granting the user any read access. this.importer.importData( + txCtx(), readMissingContractWithPayloadFixture(), Map.of(), null, @@ -2517,6 +2803,7 @@ void given_stepDataContractExistingOnTarget_when_resolving_should_sanitizeWithou // -- Act -- // Second import: the existing payload's contract is unreadable (no grant) -> RBAC denied. this.importer.importData( + txCtx(), readMissingContractWithPayloadFixture(), Map.of(), null, @@ -2552,6 +2839,7 @@ void given_stepDataContractExistingOnTarget_when_resolving_should_sanitizeWithou // First import: creates the payload (Linux) + its injector contract, readable by the user so // only the semantics check can block reuse. this.importer.importData( + txCtx(), readMissingContractWithPayloadFixture(), Map.of(), null, @@ -2586,7 +2874,14 @@ void given_stepDataContractExistingOnTarget_when_resolving_should_sanitizeWithou // -- Act -- this.importer.importData( - importData, Map.of(), null, null, null, null, Constants.IMPORTED_OBJECT_NAME_SUFFIX); + txCtx(), + importData, + Map.of(), + null, + null, + null, + null, + Constants.IMPORTED_OBJECT_NAME_SUFFIX); // -- Assert -- assertEquals( @@ -2630,6 +2925,7 @@ void given_stepDataContractExistingOnTarget_when_resolving_should_sanitizeWithou assertDoesNotThrow( () -> this.importer.importData( + txCtx(), importData, Map.of(), null, @@ -2666,6 +2962,7 @@ void given_equivalentPayloadWithDifferentOutputParsers_when_reimporting_should_n // First import: creates the payload WITHOUT parsers, readable by the user. this.importer.importData( + txCtx(), readMissingContractWithPayloadFixture(), Map.of(), null, @@ -2692,7 +2989,14 @@ void given_equivalentPayloadWithDifferentOutputParsers_when_reimporting_should_n // -- Act -- this.importer.importData( - importData, Map.of(), null, null, null, null, Constants.IMPORTED_OBJECT_NAME_SUFFIX); + txCtx(), + importData, + Map.of(), + null, + null, + null, + null, + Constants.IMPORTED_OBJECT_NAME_SUFFIX); // -- Assert -- assertEquals( @@ -2718,7 +3022,14 @@ void given_equivalentPayloadWithIdenticalOutputParsers_when_reimporting_should_r firstParsers.add(fixtureOutputParserNode(om)); fixtureEmbeddedPayloadNode(firstImport).set("payload_output_parsers", firstParsers); this.importer.importData( - firstImport, Map.of(), null, null, null, null, Constants.IMPORTED_OBJECT_NAME_SUFFIX); + txCtx(), + firstImport, + Map.of(), + null, + null, + null, + null, + Constants.IMPORTED_OBJECT_NAME_SUFFIX); Payload created = findSinglePayloadByName("step missing contract payload"); InjectorContract createdContract = injectorContractRepository.findInjectorContractByPayload(created).orElseThrow(); @@ -2735,7 +3046,14 @@ void given_equivalentPayloadWithIdenticalOutputParsers_when_reimporting_should_r // -- Act -- this.importer.importData( - secondImport, Map.of(), null, null, null, null, Constants.IMPORTED_OBJECT_NAME_SUFFIX); + txCtx(), + secondImport, + Map.of(), + null, + null, + null, + null, + Constants.IMPORTED_OBJECT_NAME_SUFFIX); // -- Assert -- assertEquals( @@ -2786,6 +3104,7 @@ void given_equivalentPayloadWithIdenticalOutputParsers_when_reimporting_should_r // -- Act -- this.importer.importData( + txCtx(), readMissingContractWithPayloadFixture(), Map.of(), null, @@ -2833,7 +3152,14 @@ void given_equivalentPayloadWithIdenticalOutputParsers_when_reimporting_should_r // -- Act -- this.importer.importData( - importData, Map.of(), null, null, null, null, Constants.IMPORTED_OBJECT_NAME_SUFFIX); + txCtx(), + importData, + Map.of(), + null, + null, + null, + null, + Constants.IMPORTED_OBJECT_NAME_SUFFIX); // -- Assert -- Payload recreated = findSinglePayloadByName("step missing contract payload"); @@ -2864,6 +3190,7 @@ void given_equivalentPayloadWithIdenticalOutputParsers_when_reimporting_should_r // behaviour). openaevInjectorIntegrationFactory.registerConnectorForTenant(TenantContext.getCurrentTenant()); this.importer.importData( + txCtx(), readMissingContractWithPayloadFixture(), Map.of(), null, @@ -2891,7 +3218,14 @@ void given_equivalentPayloadWithIdenticalOutputParsers_when_reimporting_should_r // -- Act -- this.importer.importData( - importData, Map.of(), null, null, null, null, Constants.IMPORTED_OBJECT_NAME_SUFFIX); + txCtx(), + importData, + Map.of(), + null, + null, + null, + null, + Constants.IMPORTED_OBJECT_NAME_SUFFIX); // -- Assert -- assertEquals( @@ -2933,7 +3267,14 @@ void given_equivalentPayloadWithIdenticalOutputParsers_when_reimporting_should_r // -- Act -- this.importer.importData( - importData, Map.of(), null, null, null, null, Constants.IMPORTED_OBJECT_NAME_SUFFIX); + txCtx(), + importData, + Map.of(), + null, + null, + null, + null, + Constants.IMPORTED_OBJECT_NAME_SUFFIX); // -- Assert -- List imported = new ArrayList<>(); @@ -3032,6 +3373,7 @@ private ObjectNode fixtureOutputParserNode(ObjectMapper om) { ReflectionTestUtils.invokeMethod( importer, "resolveStepData", + txCtx(), stepNode, new HashMap(), new HashMap(), @@ -3206,7 +3548,14 @@ private ObjectNode fixtureOutputParserNode(ObjectMapper om) { // -- Act -- this.importer.importData( - importData, Map.of(), null, null, null, null, Constants.IMPORTED_OBJECT_NAME_SUFFIX); + txCtx(), + importData, + Map.of(), + null, + null, + null, + null, + Constants.IMPORTED_OBJECT_NAME_SUFFIX); // -- Assert -- Payload recreated = findSinglePayloadByName("step missing contract payload"); @@ -3249,7 +3598,14 @@ private ObjectNode fixtureOutputParserNode(ObjectMapper om) { // -- Act -- this.importer.importData( - importData, Map.of(), null, null, null, null, Constants.IMPORTED_OBJECT_NAME_SUFFIX); + txCtx(), + importData, + Map.of(), + null, + null, + null, + null, + Constants.IMPORTED_OBJECT_NAME_SUFFIX); // -- Assert -- Payload recreated = findSinglePayloadByName("step missing contract payload"); @@ -3299,7 +3655,14 @@ private ObjectNode fixtureOutputParserNode(ObjectMapper om) { // -- Act -- ImportResult result = this.importer.importData( - importData, Map.of(), null, null, null, null, Constants.IMPORTED_OBJECT_NAME_SUFFIX); + txCtx(), + importData, + Map.of(), + null, + null, + null, + null, + Constants.IMPORTED_OBJECT_NAME_SUFFIX); // -- Assert -- assertEquals( @@ -3373,6 +3736,7 @@ private ObjectNode fixtureOutputParserNode(ObjectMapper om) { ReflectionTestUtils.invokeMethod( importer, "resolveStepData", + txCtx(), stepNode, new HashMap(), new HashMap(), @@ -3427,7 +3791,14 @@ private ObjectNode fixtureOutputParserNode(ObjectMapper om) { // -- Act -- ImportResult result = this.importer.importData( - importData, Map.of(), null, null, null, null, Constants.IMPORTED_OBJECT_NAME_SUFFIX); + txCtx(), + importData, + Map.of(), + null, + null, + null, + null, + Constants.IMPORTED_OBJECT_NAME_SUFFIX); // -- Assert -- Workflow workflow = findImportedWorkflow(scenarioName); @@ -3458,7 +3829,14 @@ private ObjectNode fixtureOutputParserNode(ObjectMapper om) { // -- Act -- ImportResult result = this.importer.importData( - importData, Map.of(), null, null, null, null, Constants.IMPORTED_OBJECT_NAME_SUFFIX); + txCtx(), + importData, + Map.of(), + null, + null, + null, + null, + Constants.IMPORTED_OBJECT_NAME_SUFFIX); // -- Assert -- Workflow workflow = findImportedWorkflow(scenarioName); @@ -3501,6 +3879,7 @@ void given_nullWorkflowOrNonObjectData_when_sanitizing_should_returnFallbackUnch ReflectionTestUtils.invokeMethod( importer, "sanitizateStepData", + txCtx(), dataObject, fallback, null, @@ -3514,6 +3893,7 @@ void given_nullWorkflowOrNonObjectData_when_sanitizing_should_returnFallbackUnch ReflectionTestUtils.invokeMethod( importer, "sanitizateStepData", + txCtx(), om.getNodeFactory().textNode("not-an-object"), fallback, workflow, @@ -3588,7 +3968,14 @@ void given_workflowNumericConfigFieldsExplicitNull_when_importing_should_applyDe // -- Act -- this.importer.importData( - importData, Map.of(), null, null, null, null, Constants.IMPORTED_OBJECT_NAME_SUFFIX); + txCtx(), + importData, + Map.of(), + null, + null, + null, + null, + Constants.IMPORTED_OBJECT_NAME_SUFFIX); // -- Assert -- Workflow workflow = findImportedWorkflow(scenarioName); @@ -3622,6 +4009,7 @@ void given_exerciseWorkflow_when_importing_should_bindWorkflowToSimulation() { ReflectionTestUtils.invokeMethod( importer, "importWorkflow", + txCtx(), importData, "exercise_", exercise, @@ -3676,7 +4064,14 @@ void given_exerciseWorkflow_when_importing_should_bindWorkflowToSimulation() { // -- Act -- this.importer.importData( - importData, Map.of(), null, null, null, null, Constants.IMPORTED_OBJECT_NAME_SUFFIX); + txCtx(), + importData, + Map.of(), + null, + null, + null, + null, + Constants.IMPORTED_OBJECT_NAME_SUFFIX); // -- Assert -- Workflow workflow = findImportedWorkflow(scenarioName); @@ -3752,7 +4147,14 @@ void given_conditionIdDuplicatedWithinSingleStep_when_importing_should_createSin // -- Act -- this.importer.importData( - importData, Map.of(), null, null, null, null, Constants.IMPORTED_OBJECT_NAME_SUFFIX); + txCtx(), + importData, + Map.of(), + null, + null, + null, + null, + Constants.IMPORTED_OBJECT_NAME_SUFFIX); // -- Assert -- Workflow workflow = findImportedWorkflow(scenarioName); diff --git a/openaev-api/src/test/java/io/openaev/killChainPhase/KillChainPhaseApiTest.java b/openaev-api/src/test/java/io/openaev/killChainPhase/KillChainPhaseApiTest.java index 79e7423f6d5..20ad67a4e4f 100644 --- a/openaev-api/src/test/java/io/openaev/killChainPhase/KillChainPhaseApiTest.java +++ b/openaev-api/src/test/java/io/openaev/killChainPhase/KillChainPhaseApiTest.java @@ -9,7 +9,6 @@ import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertSame; import static org.junit.jupiter.api.Assertions.assertThrows; -import static org.junit.jupiter.api.Assertions.assertTrue; import static org.junit.jupiter.api.TestInstance.Lifecycle.PER_CLASS; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.times; @@ -21,7 +20,10 @@ import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; import io.openaev.IntegrationTest; +import io.openaev.config.TenantWriteScopeResolver; +import io.openaev.context.TxCtx; import io.openaev.database.model.KillChainPhase; +import io.openaev.database.model.Tenant; import io.openaev.database.repository.KillChainPhaseRepository; import io.openaev.database.specification.KillChainPhaseSpecification; import io.openaev.rest.kill_chain_phase.KillChainPhaseApi; @@ -29,6 +31,7 @@ import io.openaev.rest.kill_chain_phase.form.KillChainPhaseUpsertInput; import io.openaev.rest.kill_chain_phase.service.KillChainPhaseService; import io.openaev.utils.FilterUtilsJpa; +import io.openaev.utils.TenantIsolationTestHelper; import io.openaev.utils.fixtures.PaginationFixture; import io.openaev.utils.mockUser.WithMockUser; import io.openaev.utils.pagination.SearchPaginationInput; @@ -46,22 +49,29 @@ import org.springframework.data.domain.Sort; import org.springframework.data.jpa.domain.Specification; import org.springframework.http.MediaType; +import org.springframework.jdbc.core.JdbcTemplate; import org.springframework.test.web.servlet.MockMvc; @TestInstance(PER_CLASS) public class KillChainPhaseApiTest extends IntegrationTest { @Autowired private MockMvc mvc; + @Autowired private JdbcTemplate jdbc; @Autowired private KillChainPhaseRepository killChainPhaseRepository; + @Autowired private TenantIsolationTestHelper tenantHelper; + @Mock private KillChainPhaseRepository mockKillChainPhaseRepository; @InjectMocks private KillChainPhaseApi killChainPhaseApi; - private static final KillChainPhase KILL_CHAIN_PHASE_1 = getKillChainPhase("name1", 1L); - private static final KillChainPhase KILL_CHAIN_PHASE_2 = getKillChainPhase("name2", 2L); - private static final KillChainPhase KILL_CHAIN_PHASE_3 = getKillChainPhase("name3", 3L); + private static final KillChainPhase KILL_CHAIN_PHASE_1 = + getKillChainPhase("name1", 1L, Tenant.DEFAULT_TENANT_UUID); + private static final KillChainPhase KILL_CHAIN_PHASE_2 = + getKillChainPhase("name2", 2L, Tenant.DEFAULT_TENANT_UUID); + private static final KillChainPhase KILL_CHAIN_PHASE_3 = + getKillChainPhase("name3", 3L, Tenant.DEFAULT_TENANT_UUID); private static final String SEARCH_INPUT = "search input"; private static final Specification spec = byNameOrKillChainName(SEARCH_INPUT); @@ -276,13 +286,30 @@ class UpsertingKillChainPhases { private static final String KILL_CHAIN = "upsert-test-chain"; private static final String STIX_ID = "x-mitre-tactic--upsert-test-0001"; + private String writeTenantId; + + /** + * An upsert now needs a single-tenant scope to attribute the row: {@code + * TenantWriteScopeResolver} refuses an empty scope with 400 and a selector outside the caller's + * memberships with 403. The mock user is rebuilt for every test method and starts with no + * tenant membership at all, so the two upserts that must succeed give it exactly one: with a + * single membership the plain route resolves to an unambiguous single-tenant scope, which is + * the documented convention for tenant-unaware clients (collectors, scripts). + */ + private void grantSingleTenantMembership() throws Exception { + writeTenantId = tenantHelper.createTenantWithCurrentUser("kcp-api-upsert").getId(); + } + @AfterEach void cleanUp() { for (String shortName : List.of("old-short", "new-short", "exec")) { killChainPhaseRepository - .findByKillChainNameAndShortName(KILL_CHAIN, shortName) + .findByKillChainNameAndShortNameAndTenantId(KILL_CHAIN, shortName, writeTenantId) .ifPresent(killChainPhaseRepository::delete); } + // The membership tenant is created (and committed) by grantSingleTenantMembership. + tenantHelper.deleteCommittedTenants(writeTenantId); + writeTenantId = null; } private KillChainPhaseCreateInput createInput(String shortName, String name, String stixId) { @@ -304,10 +331,15 @@ private KillChainPhaseUpsertInput upsertInput(KillChainPhaseCreateInput... input @Test @DisplayName("Upsert matches an existing phase by STIX id even when the short name changed") void given_existing_stix_id_should_update_phase_instead_of_inserting() throws Exception { - KillChainPhase existing = getKillChainPhase("Old name", 1L); + grantSingleTenantMembership(); + KillChainPhase existing = getKillChainPhase("Old name", 1L, Tenant.DEFAULT_TENANT_UUID); existing.setKillChainName(KILL_CHAIN); existing.setShortName("old-short"); existing.setStixId(STIX_ID); + // The upsert resolves the existing row by (STIX id, write tenant): the seed must belong to + // the + // tenant the request writes to, otherwise the lookup misses it and inserts a second row. + existing.setTenant(new Tenant(writeTenantId)); String existingId = killChainPhaseRepository.save(existing).getId(); mvc.perform( @@ -321,15 +353,18 @@ void given_existing_stix_id_should_update_phase_instead_of_inserting() throws Ex .andExpect(jsonPath("$[0].phase_shortname").value("new-short")) .andExpect(jsonPath("$[0].phase_name").value("New name")); - assertTrue( - killChainPhaseRepository - .findByKillChainNameAndShortName(KILL_CHAIN, "old-short") - .isEmpty(), + // Ground truth through JdbcTemplate: a repository read carries no tenant scope here, so once + // the table is active it would return zero rows and pass this assertion for the wrong reason. + assertEquals( + 0, + countByNaturalKey("old-short"), "the old natural key must not survive as a separate row"); + assertEquals( + 1, countByNaturalKey("new-short"), "the phase must have moved to the new natural key"); assertEquals( existingId, killChainPhaseRepository - .findByKillChainNameAndShortName(KILL_CHAIN, "new-short") + .findByKillChainNameAndShortNameAndTenantId(KILL_CHAIN, "new-short", writeTenantId) .orElseThrow() .getId()); } @@ -384,6 +419,7 @@ void given_missing_external_id_should_return_bad_request() throws Exception { @Test @DisplayName("Duplicate entries in one request (with and without STIX id) persist a single row") void given_duplicate_inputs_in_batch_should_persist_single_row() throws Exception { + grantSingleTenantMembership(); mvc.perform( post("/api/kill_chain_phases/upsert") .contentType(MediaType.APPLICATION_JSON) @@ -398,12 +434,24 @@ void given_duplicate_inputs_in_batch_should_persist_single_row() throws Exceptio KillChainPhase persisted = killChainPhaseRepository - .findByKillChainNameAndShortName(KILL_CHAIN, "exec") + .findByKillChainNameAndShortNameAndTenantId(KILL_CHAIN, "exec", writeTenantId) .orElseThrow(); assertEquals("Execution updated", persisted.getName()); assertEquals(STIX_ID, persisted.getStixId(), "STIX id must survive the stix-less duplicate"); } + private int countByNaturalKey(String shortName) { + Integer count = + jdbc.queryForObject( + "SELECT count(*) FROM kill_chain_phases" + + " WHERE phase_kill_chain_name = ? AND phase_shortname = ? AND tenant_id = ?", + Integer.class, + KILL_CHAIN, + shortName, + writeTenantId); + return count == null ? 0 : count; + } + private static DataIntegrityViolationException uniqueViolation(String constraintName) { return new DataIntegrityViolationException( "duplicate key", @@ -415,35 +463,43 @@ private static DataIntegrityViolationException uniqueViolation(String constraint @DisplayName("Upsert retries once when the first attempt loses a concurrent-insert race") void given_concurrent_insert_race_should_retry_once() { KillChainPhaseService service = mock(KillChainPhaseService.class); - KillChainPhaseApi api = new KillChainPhaseApi(mock(KillChainPhaseRepository.class), service); + KillChainPhaseApi api = newApi(service); KillChainPhaseUpsertInput input = new KillChainPhaseUpsertInput(); + TxCtx ctx = TxCtx.forTenant("tenant-1"); List winner = List.of(new KillChainPhase()); - when(service.upsertKillChainPhases(input.getKillChainPhases())) + when(service.upsertKillChainPhases(ctx, input.getKillChainPhases())) .thenThrow(uniqueViolation("kill_chain_phases_stix_id_tenant_unique")) .thenReturn(winner); - Iterable result = api.upsertKillChainPhases(input); + Iterable result = api.upsertKillChainPhases(ctx, input); assertSame(winner, result); - verify(service, times(2)).upsertKillChainPhases(input.getKillChainPhases()); + verify(service, times(2)).upsertKillChainPhases(ctx, input.getKillChainPhases()); } @Test @DisplayName("Upsert does not retry integrity failures unrelated to the unique constraints") void given_unrelated_integrity_violation_should_not_retry() { KillChainPhaseService service = mock(KillChainPhaseService.class); - KillChainPhaseApi api = new KillChainPhaseApi(mock(KillChainPhaseRepository.class), service); + KillChainPhaseApi api = newApi(service); KillChainPhaseUpsertInput input = new KillChainPhaseUpsertInput(); + TxCtx ctx = TxCtx.forTenant("tenant-1"); DataIntegrityViolationException notNullViolation = new DataIntegrityViolationException("null value in column phase_external_id"); - when(service.upsertKillChainPhases(input.getKillChainPhases())).thenThrow(notNullViolation); + when(service.upsertKillChainPhases(ctx, input.getKillChainPhases())) + .thenThrow(notNullViolation); DataIntegrityViolationException thrown = assertThrows( - DataIntegrityViolationException.class, () -> api.upsertKillChainPhases(input)); + DataIntegrityViolationException.class, () -> api.upsertKillChainPhases(ctx, input)); assertSame(notNullViolation, thrown); - verify(service, times(1)).upsertKillChainPhases(input.getKillChainPhases()); + verify(service, times(1)).upsertKillChainPhases(ctx, input.getKillChainPhases()); + } + + private KillChainPhaseApi newApi(KillChainPhaseService service) { + return new KillChainPhaseApi( + mock(KillChainPhaseRepository.class), service, mock(TenantWriteScopeResolver.class)); } } @@ -454,7 +510,8 @@ void optionsByNameTest() throws Exception { try (MockedStatic mocked = Mockito.mockStatic(KillChainPhaseSpecification.class)) { when(KillChainPhaseSpecification.byNameOrKillChainName(SEARCH_INPUT)).thenReturn(spec); - List result = killChainPhaseApi.optionsByName(SEARCH_INPUT); + List result = + killChainPhaseApi.optionsByName(TxCtx.forTenant("tenant-1"), SEARCH_INPUT); // Multi kill chain platform: options are sorted by kill chain then phase order, and // labelled "[kill chain] phase" (see KillChainPhaseApi#toOption) diff --git a/openaev-api/src/test/java/io/openaev/rest/attack_pattern/AttackPatternKillChainPhaseIsolationTest.java b/openaev-api/src/test/java/io/openaev/rest/attack_pattern/AttackPatternKillChainPhaseIsolationTest.java new file mode 100644 index 00000000000..3cb36c410fb --- /dev/null +++ b/openaev-api/src/test/java/io/openaev/rest/attack_pattern/AttackPatternKillChainPhaseIsolationTest.java @@ -0,0 +1,178 @@ +package io.openaev.rest.attack_pattern; + +import static io.openaev.utils.JsonTestUtils.asJsonString; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import io.openaev.IntegrationTest; +import io.openaev.utils.TenantIsolationTestHelper; +import io.openaev.utils.fixtures.PaginationFixture; +import io.openaev.utils.mockUser.WithMockUser; +import java.util.UUID; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.http.MediaType; +import org.springframework.test.context.TestPropertySource; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.transaction.annotation.Transactional; + +/** + * {@code kill_chain_phases} is reached from {@link io.openaev.database.model.AttackPattern} through + * a LAZY {@code @ManyToMany}, which bypasses {@code KillChainPhaseRepository} entirely. With + * open-in-view the JSON rendering runs after the commit, and the tenant scope is transaction-local, + * so a lazy load at rendering time would serialize an EMPTY phase list once the table is active — + * the #7025 blind spot. This test pins the fix: the association is initialized inside the scoped + * transaction, so a tenant sees its own phases and never another tenant's. + */ +@Transactional +@TestPropertySource(properties = "openaev.tenant.active-tables=kill_chain_phases") +@WithMockUser(isAdmin = true) +@DisplayName("kill_chain_phases isolation through the attack pattern association") +class AttackPatternKillChainPhaseIsolationTest extends IntegrationTest { + + private static final String TENANT_PATTERN_BY_ID = + "/api/tenants/{tenantId}/attack_patterns/{attackPatternId}"; + private static final String TENANT_PATTERN_SEARCH = + "/api/tenants/{tenantId}/attack_patterns/search"; + + @Autowired private MockMvc mvc; + @Autowired private TenantIsolationTestHelper tenantHelper; + + private String tenantA; + private String tenantB; + private String phaseA; + private String phaseB; + private String patternA; + private String patternB; + + @BeforeEach + void seedOnePatternWithOnePhasePerTenant() throws Exception { + tenantA = tenantHelper.createTenantWithCurrentUser("kcp-ap-a").getId(); + tenantB = tenantHelper.createTenantWithCurrentUser("kcp-ap-b").getId(); + phaseA = seedPhase(tenantA, "ap-phase-a", "AP9901"); + phaseB = seedPhase(tenantB, "ap-phase-b", "AP9902"); + patternA = seedPattern(tenantA, "ap-a", "T9901"); + patternB = seedPattern(tenantB, "ap-b", "T9902"); + link(patternA, phaseA); + link(patternB, phaseB); + } + + @Test + @DisplayName("under tenant A's path: A's pattern exposes A's phase id") + void ownPatternExposesItsPhase() throws Exception { + String response = + mvc.perform(get(TENANT_PATTERN_BY_ID, tenantA, patternA)) + .andExpect(status().isOk()) + .andReturn() + .getResponse() + .getContentAsString(); + assertTrue( + response.contains(phaseA), + "the attack pattern must expose its own tenant's kill chain phase; an empty list here means" + + " the association was lazy-loaded outside the tenant scope"); + } + + @Test + @DisplayName("under tenant A's path: B's pattern exposes none of B's phases") + void crossTenantPatternExposesNoPhase() throws Exception { + // attack_patterns is NOT tenant-active yet, so B's pattern itself is still reachable here. What + // this activation guarantees is narrower and is exactly what is asserted: the phases hanging + // off + // it belong to B, so under A's scope the association resolves to nothing. + String response = + mvc.perform(get(TENANT_PATTERN_BY_ID, tenantA, patternB)) + .andExpect(status().isOk()) + .andReturn() + .getResponse() + .getContentAsString(); + assertFalse(response.contains(phaseB), "B's phase must not be readable under A's scope"); + assertFalse(response.contains(phaseA), "A's phase is not linked to B's pattern"); + } + + @Test + @DisplayName("under tenant B's path: B's pattern exposes B's phase and never A's") + void ownPatternNeverExposesAnotherTenantPhase() throws Exception { + String response = + mvc.perform(get(TENANT_PATTERN_BY_ID, tenantB, patternB)) + .andExpect(status().isOk()) + .andReturn() + .getResponse() + .getContentAsString(); + assertTrue(response.contains(phaseB), "B's pattern must expose B's phase"); + assertFalse(response.contains(phaseA), "B's pattern must never expose A's phase"); + } + + @Test + @DisplayName("search under tenant A's path: A's phase id is listed, B's is not") + void searchExposesOnlyOwnTenantPhaseIds() throws Exception { + // The search returns a DTO, so nothing hydrates the association any more: the phase ids come + // from the projection, which reads kill_chain_phases and is therefore scoped. A regression + // here means the projection lost its scope or its tenant correlation, and the page would carry + // another tenant's phase ids. + String response = + mvc.perform( + post(TENANT_PATTERN_SEARCH, tenantA) + .with(csrf()) + .contentType(MediaType.APPLICATION_JSON) + .content(asJsonString(PaginationFixture.getDefault().size(50).build()))) + .andExpect(status().isOk()) + .andReturn() + .getResponse() + .getContentAsString(); + assertTrue( + response.contains(phaseA), + "A's own phase id must be listed; an empty list means the projection lost its scope"); + assertFalse(response.contains(phaseB), "B's phase id must never appear under A's scope"); + } + + private String seedPhase(String tenantId, String name, String externalId) { + String id = UUID.randomUUID().toString(); + entityManager + .createNativeQuery( + "INSERT INTO kill_chain_phases" + + " (phase_id, phase_name, phase_shortname, phase_kill_chain_name," + + " phase_external_id, phase_stix_id, phase_order, tenant_id)" + + " VALUES (?1, ?2, ?3, 'mitre-attack', ?4, ?5, 1, ?6)") + .setParameter(1, id) + .setParameter(2, name) + .setParameter(3, name) + .setParameter(4, externalId) + .setParameter(5, "x-mitre-tactic--" + UUID.randomUUID()) + .setParameter(6, tenantId) + .executeUpdate(); + return id; + } + + private String seedPattern(String tenantId, String name, String externalId) { + String id = UUID.randomUUID().toString(); + entityManager + .createNativeQuery( + "INSERT INTO attack_patterns" + + " (attack_pattern_id, attack_pattern_name, attack_pattern_external_id," + + " attack_pattern_stix_id, tenant_id)" + + " VALUES (?1, ?2, ?3, ?4, ?5)") + .setParameter(1, id) + .setParameter(2, name) + .setParameter(3, externalId) + .setParameter(4, "attack-pattern--" + UUID.randomUUID()) + .setParameter(5, tenantId) + .executeUpdate(); + return id; + } + + private void link(String attackPatternId, String phaseId) { + entityManager + .createNativeQuery( + "INSERT INTO attack_patterns_kill_chain_phases (attack_pattern_id, phase_id)" + + " VALUES (?1, ?2)") + .setParameter(1, attackPatternId) + .setParameter(2, phaseId) + .executeUpdate(); + } +} diff --git a/openaev-api/src/test/java/io/openaev/rest/inject/service/InjectServiceTest.java b/openaev-api/src/test/java/io/openaev/rest/inject/service/InjectServiceTest.java index e6a6a64addc..5619f8ef21f 100644 --- a/openaev-api/src/test/java/io/openaev/rest/inject/service/InjectServiceTest.java +++ b/openaev-api/src/test/java/io/openaev/rest/inject/service/InjectServiceTest.java @@ -11,6 +11,7 @@ import com.fasterxml.jackson.databind.node.ArrayNode; import com.fasterxml.jackson.databind.node.ObjectNode; import io.openaev.config.cache.LicenseCacheManager; +import io.openaev.context.TxCtx; import io.openaev.database.model.*; import io.openaev.database.repository.*; import io.openaev.ee.EnterpriseEditionService; @@ -363,7 +364,8 @@ void bulkUpdateInjectsWithValidOperations() { when(injectRepository.saveAll(expectedUpdatedInjects)).thenReturn(expectedUpdatedInjects); // Act - List updatedInjects = injectService.bulkUpdateInject(injectsToUpdate, operations); + List updatedInjects = + injectService.bulkUpdateInject(TxCtx.forTenant("tenant-1"), injectsToUpdate, operations); // Assert assertNotNull(updatedInjects); @@ -440,7 +442,8 @@ void given_injects_with_mixed_contract_types_should_apply_asset_operations_per_c when(injectRepository.saveAll(any())).thenAnswer(invocation -> invocation.getArgument(0)); // Act - List updatedInjects = injectService.bulkUpdateInject(injectsToUpdate, operations); + List updatedInjects = + injectService.bulkUpdateInject(TxCtx.forTenant("tenant-1"), injectsToUpdate, operations); // Assert assertNotNull(updatedInjects); @@ -505,7 +508,8 @@ void bulkUpdateInjectsWithEmptyOperations() { when(injectRepository.saveAll(injectsToUpdate)).thenReturn(injectsToUpdate); // Act - List updatedInjects = injectService.bulkUpdateInject(injectsToUpdate, operations); + List updatedInjects = + injectService.bulkUpdateInject(TxCtx.forTenant("tenant-1"), injectsToUpdate, operations); // Assert assertNotNull(updatedInjects); @@ -545,7 +549,8 @@ void bulkUpdateInjectsWithNonExistingEntity() { when(injectRepository.saveAll(expectedUpdatedInjects)).thenReturn(expectedUpdatedInjects); // Act - List updatedInjects = injectService.bulkUpdateInject(injectsToUpdate, operations); + List updatedInjects = + injectService.bulkUpdateInject(TxCtx.forTenant("tenant-1"), injectsToUpdate, operations); // Assert assertNotNull(updatedInjects); diff --git a/openaev-api/src/test/java/io/openaev/rest/kill_chain_phase/KillChainPhaseHttpIsolationTest.java b/openaev-api/src/test/java/io/openaev/rest/kill_chain_phase/KillChainPhaseHttpIsolationTest.java new file mode 100644 index 00000000000..afe15aaa914 --- /dev/null +++ b/openaev-api/src/test/java/io/openaev/rest/kill_chain_phase/KillChainPhaseHttpIsolationTest.java @@ -0,0 +1,310 @@ +package io.openaev.rest.kill_chain_phase; + +import static io.openaev.utils.JsonTestUtils.asJsonString; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.put; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import com.jayway.jsonpath.JsonPath; +import io.openaev.IntegrationTest; +import io.openaev.utils.TenantIsolationTestHelper; +import io.openaev.utils.fixtures.PaginationFixture; +import io.openaev.utils.mockUser.WithMockUser; +import java.sql.PreparedStatement; +import java.sql.ResultSet; +import java.util.UUID; +import org.hibernate.Session; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.http.MediaType; +import org.springframework.test.context.TestPropertySource; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.transaction.annotation.Transactional; + +/** + * End-to-end proof that, with {@code kill_chain_phases} activated, the tenant scope set from the + * URL path isolates the table through the real {@link KillChainPhaseApi} endpoints. A user who + * belongs to two tenants sees a phase only under its own tenant's path, never another tenant's. + * + *

Each test stays on a single tenant path so the per-request scope is set once: re-applying the + * same scope inside the test transaction is tolerated, changing it would hit the nesting guard. + */ +@Transactional +@TestPropertySource(properties = "openaev.tenant.active-tables=kill_chain_phases") +@WithMockUser(isAdmin = true) +@DisplayName("kill_chain_phases read and write isolation through the real HTTP endpoint") +class KillChainPhaseHttpIsolationTest extends IntegrationTest { + + private static final String PHASES = "/api/kill_chain_phases"; + private static final String TENANT_PHASES = "/api/tenants/{tenantId}/kill_chain_phases"; + private static final String TENANT_PHASE_BY_ID = TENANT_PHASES + "/{phaseId}"; + + @Autowired private MockMvc mvc; + @Autowired private TenantIsolationTestHelper tenantHelper; + + private String tenantA; + private String tenantB; + private String phaseA; + private String phaseB; + + @BeforeEach + void seedTwoTenantsWithOnePhaseEach() throws Exception { + tenantA = tenantHelper.createTenantWithCurrentUser("kcp-iso-a").getId(); + tenantB = tenantHelper.createTenantWithCurrentUser("kcp-iso-b").getId(); + phaseA = seedPhase(tenantA, "phase-a", "shortname-a", "TA9901"); + phaseB = seedPhase(tenantB, "phase-b", "shortname-b", "TA9902"); + } + + @Test + @DisplayName("under tenant A's path: A's phase is visible, B's is hidden") + void underTenantAPath() throws Exception { + mvc.perform(get(TENANT_PHASE_BY_ID, tenantA, phaseA)).andExpect(status().isOk()); + mvc.perform(get(TENANT_PHASE_BY_ID, tenantA, phaseB)).andExpect(status().isNotFound()); + } + + @Test + @DisplayName("under tenant B's path: B's phase is visible, A's is hidden") + void underTenantBPath() throws Exception { + mvc.perform(get(TENANT_PHASE_BY_ID, tenantB, phaseB)).andExpect(status().isOk()); + mvc.perform(get(TENANT_PHASE_BY_ID, tenantB, phaseA)).andExpect(status().isNotFound()); + } + + @Test + @DisplayName("under tenant A's path: the list returns A's phase and not B's") + void listUnderTenantAReturnsOnlyA() throws Exception { + String response = + mvc.perform(get(TENANT_PHASES, tenantA)) + .andExpect(status().isOk()) + .andReturn() + .getResponse() + .getContentAsString(); + assertTrue(response.contains(phaseA), "A's phase must appear in A's list"); + assertFalse(response.contains(phaseB), "B's phase must not appear in A's list"); + } + + @Test + @DisplayName( + "via the X-Tenant-Ids header (no path tenant): the list returns A's phase and not B's") + void listViaHeaderReturnsOnlyA() throws Exception { + String response = + mvc.perform(get(PHASES).header("X-Tenant-Ids", tenantA)) + .andExpect(status().isOk()) + .andReturn() + .getResponse() + .getContentAsString(); + assertTrue(response.contains(phaseA), "A's phase must appear when A is selected via header"); + assertFalse(response.contains(phaseB), "B's phase must not appear"); + } + + @Test + @DisplayName("under tenant A's path: search returns A's phase and not B's") + void searchUnderTenantAReturnsOnlyA() throws Exception { + String body = asJsonString(PaginationFixture.getDefault().textSearch("").build()); + String response = + mvc.perform( + post(TENANT_PHASES + "/search", tenantA) + .contentType(MediaType.APPLICATION_JSON) + .content(body) + .with(csrf())) + .andExpect(status().isOk()) + .andReturn() + .getResponse() + .getContentAsString(); + assertTrue(response.contains(phaseA), "A's phase must appear in A's search results"); + assertFalse(response.contains(phaseB), "B's phase must not appear in A's search results"); + } + + @Test + @DisplayName("under tenant A's path: the name options only expose A's phase") + void optionsUnderTenantAReturnsOnlyA() throws Exception { + String response = + mvc.perform(get(TENANT_PHASES + "/options", tenantA)) + .andExpect(status().isOk()) + .andReturn() + .getResponse() + .getContentAsString(); + assertTrue(response.contains(phaseA), "A's phase must appear in A's options"); + assertFalse(response.contains(phaseB), "B's phase must not appear in A's options"); + } + + @Test + @DisplayName("under tenant A's path: resolving B's id by options returns nothing") + void optionsByIdUnderTenantACannotResolveB() throws Exception { + String response = + mvc.perform( + post(TENANT_PHASES + "/options", tenantA) + .contentType(MediaType.APPLICATION_JSON) + .content("[\"" + phaseB + "\"]") + .with(csrf())) + .andExpect(status().isOk()) + .andReturn() + .getResponse() + .getContentAsString(); + assertFalse(response.contains(phaseB), "B's phase must not be resolvable under A's path"); + } + + @Test + @DisplayName("a create under tenant A's path is attributed to tenant A") + void createUnderTenantAIsAttributedToA() throws Exception { + String response = + mvc.perform( + post(TENANT_PHASES, tenantA) + .contentType(MediaType.APPLICATION_JSON) + .content(createInput("created-under-a", "created-under-a", "TA9910")) + .with(csrf())) + .andExpect(status().isOk()) + .andReturn() + .getResponse() + .getContentAsString(); + String createdId = JsonPath.read(response, "$.phase_id"); + String storedTenant = + (String) + entityManager + .createNativeQuery("SELECT tenant_id FROM kill_chain_phases WHERE phase_id = ?1") + .setParameter(1, createdId) + .getSingleResult(); + assertEquals(tenantA, storedTenant, "the created phase must belong to tenant A"); + } + + @Test + @DisplayName("a create with no tenant selector is refused (a single-tenant scope is required)") + void createWithoutSelectorIsRejected() throws Exception { + mvc.perform( + post(PHASES) + .contentType(MediaType.APPLICATION_JSON) + .content(createInput("no-selector", "no-selector", "TA9911")) + .with(csrf())) + .andExpect(status().isBadRequest()); + } + + @Test + @DisplayName("under tenant A's path: A can update its own phase") + void updateUnderTenantAUpdatesOwnPhase() throws Exception { + mvc.perform( + put(TENANT_PHASE_BY_ID, tenantA, phaseA) + .contentType(MediaType.APPLICATION_JSON) + .content(updateInput("renamed-a")) + .with(csrf())) + .andExpect(status().isOk()); + assertEquals("renamed-a", rawName(phaseA), "A's own phase must be updated"); + } + + @Test + @DisplayName("under tenant A's path: updating B's phase is not found and leaves it untouched") + void updateUnderTenantAOfBPhaseIsBlocked() throws Exception { + mvc.perform( + put(TENANT_PHASE_BY_ID, tenantA, phaseB) + .contentType(MediaType.APPLICATION_JSON) + .content(updateInput("hijacked")) + .with(csrf())) + .andExpect(status().isNotFound()); + assertEquals("phase-b", rawName(phaseB), "B's phase must be untouched"); + } + + @Test + @DisplayName("under tenant A's path: A can delete its own phase") + void deleteUnderTenantADeletesOwnPhase() throws Exception { + mvc.perform(delete(TENANT_PHASE_BY_ID, tenantA, phaseA).with(csrf())) + .andExpect(status().is2xxSuccessful()); + assertEquals(0L, rawCount(phaseA), "A's own phase must be deleted"); + } + + @Test + @DisplayName("under tenant A's path: deleting B's phase is a no-op and leaves it in place") + void deleteUnderTenantAOfBPhaseIsBlocked() throws Exception { + mvc.perform(delete(TENANT_PHASE_BY_ID, tenantA, phaseB).with(csrf())) + .andExpect(status().is2xxSuccessful()); + assertEquals(1L, rawCount(phaseB), "B's phase must survive tenant A's delete attempt"); + } + + private static String createInput(String name, String shortName, String externalId) { + return "{\"phase_kill_chain_name\":\"mitre-attack\"," + + "\"phase_name\":\"" + + name + + "\"," + + "\"phase_shortname\":\"" + + shortName + + "\"," + + "\"phase_external_id\":\"" + + externalId + + "\"," + + "\"phase_order\":1}"; + } + + private static String updateInput(String name) { + return "{\"phase_kill_chain_name\":\"mitre-attack\",\"phase_name\":\"" + + name + + "\",\"phase_order\":1}"; + } + + // Ground-truth reads, bypassing the scope: raw JDBC on the test's own connection sees the + // uncommitted seed and the rewriter does not touch a statement it never generated. A flush first + // forces any pending scoped UPDATE/DELETE to reach the database. + private String rawName(String phaseId) { + return rawQuery( + "SELECT phase_name FROM kill_chain_phases WHERE phase_id = ?", + statement -> statement.setString(1, phaseId), + rows -> rows.next() ? rows.getString(1) : null); + } + + private long rawCount(String phaseId) { + return rawQuery( + "SELECT count(*) FROM kill_chain_phases WHERE phase_id = ?", + statement -> statement.setString(1, phaseId), + rows -> { + rows.next(); + return rows.getLong(1); + }); + } + + private T rawQuery(String sql, StatementBinder binder, ResultReader reader) { + entityManager.flush(); + return entityManager + .unwrap(Session.class) + .doReturningWork( + connection -> { + try (PreparedStatement statement = connection.prepareStatement(sql)) { + binder.bind(statement); + try (ResultSet rows = statement.executeQuery()) { + return reader.read(rows); + } + } + }); + } + + private interface StatementBinder { + void bind(PreparedStatement statement) throws java.sql.SQLException; + } + + private interface ResultReader { + T read(ResultSet rows) throws java.sql.SQLException; + } + + private String seedPhase(String tenantId, String name, String shortName, String externalId) { + String id = UUID.randomUUID().toString(); + entityManager + .createNativeQuery( + "INSERT INTO kill_chain_phases" + + " (phase_id, phase_name, phase_shortname, phase_kill_chain_name," + + " phase_external_id, phase_stix_id, phase_order, tenant_id)" + + " VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)") + .setParameter(1, id) + .setParameter(2, name) + .setParameter(3, shortName) + .setParameter(4, "mitre-attack") + .setParameter(5, externalId) + .setParameter(6, "x-mitre-tactic--" + UUID.randomUUID()) + .setParameter(7, 1L) + .setParameter(8, tenantId) + .executeUpdate(); + return id; + } +} diff --git a/openaev-api/src/test/java/io/openaev/rest/kill_chain_phase/KillChainPhaseImportAttributionTest.java b/openaev-api/src/test/java/io/openaev/rest/kill_chain_phase/KillChainPhaseImportAttributionTest.java new file mode 100644 index 00000000000..d9a90d279e0 --- /dev/null +++ b/openaev-api/src/test/java/io/openaev/rest/kill_chain_phase/KillChainPhaseImportAttributionTest.java @@ -0,0 +1,123 @@ +package io.openaev.rest.kill_chain_phase; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import io.openaev.IntegrationTest; +import io.openaev.context.TenantContext; +import io.openaev.context.TxCtx; +import io.openaev.importer.V1_DataImporter; +import io.openaev.integration.impl.injectors.openaev.OpenaevInjectorIntegrationFactory; +import io.openaev.rest.exception.TenantWriteScopeException; +import io.openaev.utils.TenantIsolationTestHelper; +import io.openaev.utils.constants.Constants; +import io.openaev.utils.mockUser.WithMockUser; +import jakarta.persistence.EntityManager; +import java.nio.file.Files; +import java.nio.file.Paths; +import java.util.List; +import java.util.Map; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.test.context.TestPropertySource; +import org.springframework.transaction.annotation.Transactional; + +/** + * The importer creates kill chain phases on the scenario/simulation import paths. It must attribute + * them from the request scope: {@code TenantContext} is unset off the {@code /api/tenants} route + * and falls back to the default tenant, which would write the phase outside the scope its own reads + * are filtered by. + */ +@Transactional +@TestPropertySource(properties = "openaev.tenant.active-tables=kill_chain_phases") +@WithMockUser(isAdmin = true) +@DisplayName("kill_chain_phases write attribution on the import path") +class KillChainPhaseImportAttributionTest extends IntegrationTest { + + private static final String IMPORT_FILE = + "src/test/resources/importer-v1/import-scenario-with-attack-pattern.json"; + private static final String PHASE_EXTERNAL_ID = "KILLCHAIN_EXTERNAL_ID"; + + @Autowired private V1_DataImporter importer; + @Autowired private TenantIsolationTestHelper tenantHelper; + @Autowired private JdbcTemplate jdbc; + @Autowired private EntityManager entityManager; + @Autowired private OpenaevInjectorIntegrationFactory openaevInjectorIntegrationFactory; + + private String contextTenant; + private String tenantA; + private String tenantB; + + @BeforeEach + void seedTwoTenants() throws Exception { + contextTenant = TenantContext.getCurrentTenant(); + tenantA = tenantHelper.createTenantWithCurrentUser("kcp-import-a").getId(); + tenantB = tenantHelper.createTenantWithCurrentUser("kcp-import-b").getId(); + openaevInjectorIntegrationFactory.registerConnectorForTenant(contextTenant); + } + + @Test + @DisplayName("the phase is attributed to the scoped tenant, not to the TenantContext one") + void importAttributesThePhaseToTheScopedTenant() throws Exception { + importer.importData( + TxCtx.forTenant(tenantB), + importNode(), + Map.of(), + null, + null, + null, + null, + Constants.IMPORTED_OBJECT_NAME_SUFFIX); + entityManager.flush(); + + assertEquals( + 1, + countByExternalIdAndTenant(PHASE_EXTERNAL_ID, tenantB), + "the phase must belong to the tenant the request is scoped to"); + assertEquals( + 0, + countByExternalIdAndTenant(PHASE_EXTERNAL_ID, contextTenant), + "the phase must not follow TenantContext into the default tenant"); + } + + @Test + @DisplayName("a multi-tenant scope cannot attribute the phase: refused, never guessed") + void multiTenantScopeIsRefused() throws Exception { + JsonNode importNode = importNode(); + TxCtx ambiguous = TxCtx.forTenants(List.of(tenantA, tenantB)); + + assertThrows( + TenantWriteScopeException.class, + () -> + importer.importData( + ambiguous, + importNode, + Map.of(), + null, + null, + null, + null, + Constants.IMPORTED_OBJECT_NAME_SUFFIX)); + } + + private JsonNode importNode() throws Exception { + return new ObjectMapper().readTree(new String(Files.readAllBytes(Paths.get(IMPORT_FILE)))); + } + + // Ground truth through JdbcTemplate: raw JDBC never reaches the statement inspector, so the count + // sees every tenant's rows regardless of the scope in effect. + private int countByExternalIdAndTenant(String externalId, String tenantId) { + Integer count = + jdbc.queryForObject( + "SELECT count(*) FROM kill_chain_phases WHERE phase_external_id = ? AND tenant_id = ?", + Integer.class, + externalId, + tenantId); + return count == null ? 0 : count; + } +} diff --git a/openaev-api/src/test/java/io/openaev/rest/kill_chain_phase/KillChainPhaseNonAdminIsolationTest.java b/openaev-api/src/test/java/io/openaev/rest/kill_chain_phase/KillChainPhaseNonAdminIsolationTest.java new file mode 100644 index 00000000000..3304911af6a --- /dev/null +++ b/openaev-api/src/test/java/io/openaev/rest/kill_chain_phase/KillChainPhaseNonAdminIsolationTest.java @@ -0,0 +1,90 @@ +package io.openaev.rest.kill_chain_phase; + +import static io.openaev.utils.JsonTestUtils.asJsonString; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import io.openaev.IntegrationTest; +import io.openaev.database.model.Capability; +import io.openaev.utils.TenantIsolationTestHelper; +import io.openaev.utils.fixtures.PaginationFixture; +import io.openaev.utils.mockUser.WithMockUser; +import java.util.Set; +import java.util.UUID; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.http.MediaType; +import org.springframework.test.context.TestPropertySource; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.transaction.annotation.Transactional; + +/** + * The isolation must not depend on the caller being an administrator. The other isolation tests run + * as admin (RBAC bypassed); this one runs as a non-admin that is a member of two tenants and holds + * only the capability that grants reading kill chain phases, so isolation — not RBAC — is what the + * assertions exercise. + */ +@Transactional +@TestPropertySource(properties = "openaev.tenant.active-tables=kill_chain_phases") +@WithMockUser(isAdmin = false) +@DisplayName("kill_chain_phases isolation holds for a non-admin spanning two tenants") +class KillChainPhaseNonAdminIsolationTest extends IntegrationTest { + + @Autowired private MockMvc mvc; + @Autowired private TenantIsolationTestHelper tenantHelper; + + private String tenantA; + private String phaseA; + private String phaseB; + + @BeforeEach + void seedTwoTenantsTheNonAdminBelongsToWithOnePhaseEach() throws Exception { + Set readPhases = Set.of(Capability.ACCESS_TENANT_SETTINGS); + tenantA = tenantHelper.createTenantWithCapabilities("nonadmin-kcp-a", readPhases).getId(); + String tenantB = + tenantHelper.createTenantWithCapabilities("nonadmin-kcp-b", readPhases).getId(); + phaseA = seedPhase(tenantA, "nonadmin-kcp-a", "TA9801"); + phaseB = seedPhase(tenantB, "nonadmin-kcp-b", "TA9802"); + } + + @Test + @DisplayName("a non-admin searching under tenant A's path sees only A's phase") + void searchUnderTenantAReturnsOnlyAForNonAdmin() throws Exception { + String body = asJsonString(PaginationFixture.getDefault().textSearch("").build()); + String response = + mvc.perform( + post("/api/tenants/{tenantId}/kill_chain_phases/search", tenantA) + .contentType(MediaType.APPLICATION_JSON) + .content(body) + .with(csrf())) + .andExpect(status().isOk()) + .andReturn() + .getResponse() + .getContentAsString(); + assertTrue(response.contains(phaseA), "A's phase must appear for the non-admin member of A"); + assertFalse(response.contains(phaseB), "B's phase must not leak into A's scope"); + } + + private String seedPhase(String tenantId, String name, String externalId) { + String id = UUID.randomUUID().toString(); + entityManager + .createNativeQuery( + "INSERT INTO kill_chain_phases" + + " (phase_id, phase_name, phase_shortname, phase_kill_chain_name," + + " phase_external_id, phase_stix_id, phase_order, tenant_id)" + + " VALUES (?1, ?2, ?3, 'mitre-attack', ?4, ?5, 1, ?6)") + .setParameter(1, id) + .setParameter(2, name) + .setParameter(3, name) + .setParameter(4, externalId) + .setParameter(5, "x-mitre-tactic--" + UUID.randomUUID()) + .setParameter(6, tenantId) + .executeUpdate(); + return id; + } +} diff --git a/openaev-api/src/test/java/io/openaev/rest/kill_chain_phase/KillChainPhaseUpsertIsolationTest.java b/openaev-api/src/test/java/io/openaev/rest/kill_chain_phase/KillChainPhaseUpsertIsolationTest.java new file mode 100644 index 00000000000..26c640c1d80 --- /dev/null +++ b/openaev-api/src/test/java/io/openaev/rest/kill_chain_phase/KillChainPhaseUpsertIsolationTest.java @@ -0,0 +1,236 @@ +package io.openaev.rest.kill_chain_phase; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import io.openaev.IntegrationTest; +import io.openaev.context.TenantContext; +import io.openaev.database.model.KillChainPhase; +import io.openaev.database.repository.KillChainPhaseRepository; +import io.openaev.utils.TenantIsolationTestHelper; +import io.openaev.utils.mockUser.WithMockUser; +import java.util.UUID; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.TestConfiguration; +import org.springframework.context.annotation.Bean; +import org.springframework.http.MediaType; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.test.context.TestPropertySource; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.transaction.annotation.Transactional; + +/** + * Upsert-side isolation for {@code kill_chain_phases}, split from {@link + * KillChainPhaseHttpIsolationTest} because the endpoint is {@code Propagation.NOT_SUPPORTED}: it + * suspends any ambient transaction so each service call can retry in a fresh one. Inside a + * {@code @Transactional} test that suspension also hides the test's uncommitted tenant and user + * rows, which makes the request fail authentication. + * + *

So this class is NOT {@code @Transactional}: everything it creates is committed, and both the + * phases and the tenants are removed in {@link #cleanUp()}. + */ +@TestPropertySource(properties = "openaev.tenant.active-tables=kill_chain_phases") +@WithMockUser(isAdmin = true) +@DisplayName("kill_chain_phases upsert attribution and per-tenant duplication") +class KillChainPhaseUpsertIsolationTest extends IntegrationTest { + + private static final String PHASES = "/api/kill_chain_phases"; + private static final String TENANT_PHASES = "/api/tenants/{tenantId}/kill_chain_phases"; + private static final String KILL_CHAIN = "kcp-upsert-iso"; + + @Autowired private MockMvc mvc; + @Autowired private TenantIsolationTestHelper tenantHelper; + @Autowired private JdbcTemplate jdbc; + @Autowired private ProvisioningStyleWriter provisioningStyleWriter; + + private String tenantA; + private String tenantB; + + @BeforeEach + void seedTwoTenants() throws Exception { + tenantA = tenantHelper.createTenantWithCurrentUser("kcp-ups-a").getId(); + tenantB = tenantHelper.createTenantWithCurrentUser("kcp-ups-b").getId(); + } + + @AfterEach + void cleanUp() { + jdbc.update("DELETE FROM kill_chain_phases WHERE phase_kill_chain_name = ?", KILL_CHAIN); + tenantHelper.deleteCommittedTenants(tenantA, tenantB); + } + + @Test + @DisplayName("an upsert under tenant A's path is attributed to tenant A") + void upsertUnderTenantAIsAttributedToA() throws Exception { + mvc.perform( + post(TENANT_PHASES + "/upsert", tenantA) + .contentType(MediaType.APPLICATION_JSON) + .content(upsertBody("up-a", "up-a", "TA9915")) + .with(csrf())) + .andExpect(status().isOk()); + assertEquals( + 1, countByExternalIdAndTenant("TA9915", tenantA), "the upserted phase must belong to A"); + assertEquals(0, countByExternalIdAndTenant("TA9915", tenantB), "tenant B must own no such row"); + } + + @Test + @DisplayName("an upsert with no tenant selector is refused (a single-tenant scope is required)") + void upsertWithoutSelectorIsRejected() throws Exception { + mvc.perform( + post(PHASES + "/upsert") + .contentType(MediaType.APPLICATION_JSON) + .content(upsertBody("up-none", "up-none", "TA9916")) + .with(csrf())) + .andExpect(status().isBadRequest()); + assertEquals(0, countByExternalId("TA9916"), "no row must be written without a selector"); + } + + @Test + @DisplayName("upserting the same natural key under A then B yields two distinct rows") + void upsertSameNaturalKeyUnderTwoTenantsYieldsTwoRows() throws Exception { + String body = upsertBody("shared-phase", "shared-short", "TA9920"); + mvc.perform( + post(TENANT_PHASES + "/upsert", tenantA) + .contentType(MediaType.APPLICATION_JSON) + .content(body) + .with(csrf())) + .andExpect(status().isOk()); + mvc.perform( + post(TENANT_PHASES + "/upsert", tenantB) + .contentType(MediaType.APPLICATION_JSON) + .content(body) + .with(csrf())) + .andExpect(status().isOk()); + + assertEquals( + 1, countByExternalIdAndTenant("TA9920", tenantA), "tenant A owns exactly one such row"); + assertEquals( + 1, countByExternalIdAndTenant("TA9920", tenantB), "tenant B owns exactly one such row"); + assertEquals( + 2, + countByNaturalKey("shared-short"), + "each tenant must own an independent row for the same natural key"); + } + + @Test + @DisplayName("under tenant A's path: a second upsert of the same key reuses A's row") + void upsertTwiceUnderTenantAReusesTheSameRow() throws Exception { + String body = upsertBody("reused", "reused-short", "TA9925"); + for (int i = 0; i < 2; i++) { + mvc.perform( + post(TENANT_PHASES + "/upsert", tenantA) + .contentType(MediaType.APPLICATION_JSON) + .content(body) + .with(csrf())) + .andExpect(status().isOk()); + } + assertEquals( + 1, + countByNaturalKey("reused-short"), + "the second upsert must reuse tenant A's row, not duplicate it"); + } + + @Test + @DisplayName("a write with no explicit attribution is refused, never attributed from the context") + void writeWithoutExplicitAttributionIsRefused() { + // The entity carries no TenantBaseListener since activation: attribution is explicit, through + // TenantWriteScopeResolver on the endpoints and the resolved write tenant in the importer. A + // save that forgets it must fail loudly rather than silently land on TenantContext's tenant, + // which would mask the missing attribution and put the row on whatever tenant happened to be + // set. + TenantContext.setCurrentTenant(tenantA); + try { + assertThrows( + RuntimeException.class, + () -> { + provisioningStyleWriter.saveFreshPhase("TA-PROV"); + entityManager.flush(); + }, + "an unattributed write must be refused by the non-nullable tenant_id, not rescued by a" + + " listener reading TenantContext"); + } finally { + TenantContext.clearCurrentTenant(); + } + } + + private static String upsertBody(String name, String shortName, String externalId) { + return "{\"kill_chain_phases\":[{" + + "\"phase_kill_chain_name\":\"" + + KILL_CHAIN + + "\"," + + "\"phase_name\":\"" + + name + + "\"," + + "\"phase_shortname\":\"" + + shortName + + "\"," + + "\"phase_external_id\":\"" + + externalId + + "\"," + + "\"phase_stix_id\":\"x-mitre-tactic--" + + UUID.nameUUIDFromBytes((KILL_CHAIN + shortName).getBytes()) + + "\"," + + "\"phase_order\":1}]}"; + } + + // Ground truth through JdbcTemplate: raw JDBC never reaches the statement inspector, so these + // counts see every tenant's rows regardless of scope. + private int countByExternalIdAndTenant(String externalId, String tenantId) { + return count( + "SELECT count(*) FROM kill_chain_phases WHERE phase_external_id = ? AND tenant_id = ?", + externalId, + tenantId); + } + + private int countByExternalId(String externalId) { + return count("SELECT count(*) FROM kill_chain_phases WHERE phase_external_id = ?", externalId); + } + + private int countByNaturalKey(String shortName) { + return count( + "SELECT count(*) FROM kill_chain_phases" + + " WHERE phase_kill_chain_name = ? AND phase_shortname = ?", + KILL_CHAIN, + shortName); + } + + private int count(String sql, Object... args) { + Integer count = jdbc.queryForObject(sql, Integer.class, args); + return count == null ? 0 : count; + } + + /** + * Mirrors the provisioning write shape: @Transactional, TenantContext only, no TxCtx anywhere. + */ + public static class ProvisioningStyleWriter { + private final KillChainPhaseRepository killChainPhaseRepository; + + public ProvisioningStyleWriter(KillChainPhaseRepository killChainPhaseRepository) { + this.killChainPhaseRepository = killChainPhaseRepository; + } + + @Transactional + public void saveFreshPhase(String externalId) { + KillChainPhase phase = new KillChainPhase(); + phase.setKillChainName(KILL_CHAIN); + phase.setName(externalId); + phase.setShortName(externalId); + phase.setExternalId(externalId); + killChainPhaseRepository.save(phase); + } + } + + @TestConfiguration + static class ProvisioningStyleWriterFixture { + @Bean + ProvisioningStyleWriter provisioningStyleWriter(KillChainPhaseRepository repository) { + return new ProvisioningStyleWriter(repository); + } + } +} diff --git a/openaev-api/src/test/java/io/openaev/service/InjectImportServiceTest.java b/openaev-api/src/test/java/io/openaev/service/InjectImportServiceTest.java index 9183c7b3f30..e2fe798e1d0 100644 --- a/openaev-api/src/test/java/io/openaev/service/InjectImportServiceTest.java +++ b/openaev-api/src/test/java/io/openaev/service/InjectImportServiceTest.java @@ -6,6 +6,7 @@ import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.node.ObjectNode; import io.openaev.context.TenantContext; +import io.openaev.context.TxCtx; import io.openaev.database.model.Exercise; import io.openaev.database.model.RuleAttribute; import io.openaev.database.model.Scenario; @@ -324,6 +325,8 @@ void testGetInjectTimeUndetectedWithTimePattern() { @Nested class ImportConvenience { + private final TxCtx ctx = TxCtx.forTenant("tenant-1"); + @Mock private ScenarioRepository scenarioRepository; @Mock private ExerciseRepository exerciseRepository; @Mock private ImportService importService; @@ -360,10 +363,10 @@ void shouldDelegateToImportService_forScenario() throws Exception { .thenReturn(Optional.of(scenario)); // -------- Act -------- - injectImportService.importInjectsForScenario(file, "sc-1"); + injectImportService.importInjectsForScenario(ctx, file, "sc-1"); // -------- Assert -------- - verify(importService).handleFileImport(file, null, scenario); + verify(importService).handleFileImport(ctx, file, null, scenario); } } @@ -377,10 +380,10 @@ void shouldDelegateToImportService_forSimulation() throws Exception { MultipartFile file = mock(MultipartFile.class); // -------- Act -------- - injectImportService.importInjectsForSimulation(file, "ex-1"); + injectImportService.importInjectsForSimulation(ctx, file, "ex-1"); // -------- Assert -------- - verify(importService).handleFileImport(file, exercise, null); + verify(importService).handleFileImport(ctx, file, exercise, null); } @Test @@ -389,10 +392,10 @@ void shouldDelegateToImportService_forAtomicTestings() throws Exception { MultipartFile file = mock(MultipartFile.class); // -------- Act -------- - injectImportService.importInjectsForAtomicTestings(file); + injectImportService.importInjectsForAtomicTestings(ctx, file); // -------- Assert -------- - verify(importService).handleFileImport(file, null, null); + verify(importService).handleFileImport(ctx, file, null, null); } @Test @@ -408,7 +411,7 @@ void shouldThrowElementNotFound_whenScenarioMissing() { // -------- Act / Assert -------- assertThrows( ElementNotFoundException.class, - () -> injectImportService.importInjectsForScenario(file, "missing")); + () -> injectImportService.importInjectsForScenario(ctx, file, "missing")); } } @@ -421,7 +424,7 @@ void shouldThrowElementNotFound_whenExerciseMissing() { // -------- Act / Assert -------- assertThrows( ElementNotFoundException.class, - () -> injectImportService.importInjectsForSimulation(file, "missing")); + () -> injectImportService.importInjectsForSimulation(ctx, file, "missing")); } } diff --git a/openaev-api/src/test/java/io/openaev/service/chaining/ChainingIntegrationTest.java b/openaev-api/src/test/java/io/openaev/service/chaining/ChainingIntegrationTest.java index 0c4694c87e1..b93163b3bd8 100644 --- a/openaev-api/src/test/java/io/openaev/service/chaining/ChainingIntegrationTest.java +++ b/openaev-api/src/test/java/io/openaev/service/chaining/ChainingIntegrationTest.java @@ -536,90 +536,6 @@ void should_not_expose_workflow_chaining_inject_as_atomic_testing() throws Excep result.contains(injectId), "Workflow chaining inject must not be exposed as atomic testing"); } - - // ------------------------------------------------------------------------- - // 6. DUPLICATE SCENARIO CHAINING → Scenario + Workflow TEMPLATE + Step TEMPLATE duplicated - // ------------------------------------------------------------------------- - - @Test - @WithMockUser(isAdmin = true) - void should_duplicate_scenario_workflow_template_and_step_template_when_chaining_enabled() - throws Exception { - // Create scenario with chaining enabled - String scenarioResponse = - mvc.perform( - post(tenantUri(TENANT_CHAINING_URI + "/scenarios")) - .with(csrf()) - .contentType(MediaType.APPLICATION_JSON) - .content(mapper.writeValueAsString(buildScenarioInput()))) - .andExpect(status().is2xxSuccessful()) - .andReturn() - .getResponse() - .getContentAsString(); - Scenario createdScenario = mapper.readValue(scenarioResponse, Scenario.class); - - // Get the workflow template created - Workflow workflowTemplate = - workflowRepository.findAll().stream() - .filter(w -> WorkflowStatus.TEMPLATE.equals(w.getStatus())) - .filter( - w -> - w.getScenario() != null - && createdScenario.getId().equals(w.getScenario().getId())) - .findFirst() - .orElseThrow(); - - // Add a step with an inject to the workflow template - InjectInput injectInput = mapper.readValue(injectInputJson, InjectInput.class); - StepsCreateInput.StepInput step = buildValidStepInput(); - step.setDataStep(injectInput); - stepService.createStepTemplates(workflowTemplate, List.of(step)); - - String result = - mvc.perform( - post(tenantUri(TENANT_CHAINING_URI + "/scenarios/" + createdScenario.getId())) - .with(csrf()) - .contentType(MediaType.APPLICATION_JSON)) - .andExpect(status().is2xxSuccessful()) - .andReturn() - .getResponse() - .getContentAsString(); - Scenario scenarioDuplicated = mapper.readValue(result, Scenario.class); - - Workflow workflowTemplateDuplicated = - workflowRepository.findAll().stream() - .filter(w -> WorkflowStatus.TEMPLATE.equals(w.getStatus())) - .filter( - w -> - w.getScenario() != null - && scenarioDuplicated.getId().equals(w.getScenario().getId())) - .findFirst() - .orElseThrow(); - - assertWorkflowEqualsExceptId(workflowTemplate, workflowTemplateDuplicated); - - List originalSteps = - stepRepository.findAll().stream() - .filter( - s -> - s.getWorkflow() != null - && workflowTemplate.getId().equals(s.getWorkflow().getId())) - .toList(); - List duplicatedSteps = - stepRepository.findAll().stream() - .filter( - s -> - s.getWorkflow() != null - && workflowTemplateDuplicated.getId().equals(s.getWorkflow().getId())) - .toList(); - - assertEquals(originalSteps.size(), duplicatedSteps.size(), "Step TEMPLATE count must match"); - assertFalse(duplicatedSteps.isEmpty(), "Duplicated workflow must contain step templates"); - - Step originalStep = originalSteps.getFirst(); - Step duplicatedStep = duplicatedSteps.getFirst(); - assertStepEqualsExceptId(originalStep, duplicatedStep); - } } @Nested @@ -708,77 +624,6 @@ void should_create_step_template_when_add_inject_to_simulation_chaining() throws assertEquals(1, stepsCreated.size()); assertEquals(StepStatus.TEMPLATE, stepsCreated.getFirst().getStatus()); } - - @Test - @WithMockUser(isAdmin = true) - void should_duplicate_simulation_workflow_template_and_step_template_when_chaining_enabled() - throws Exception { - String response = - mvc.perform( - post(tenantUri(TENANT_CHAINING_URI + "/simulations")) - .with(csrf()) - .contentType(MediaType.APPLICATION_JSON) - .content(mapper.writeValueAsString(buildSimulationInput()))) - .andExpect(status().is2xxSuccessful()) - .andReturn() - .getResponse() - .getContentAsString(); - - String simulationId = JsonPath.read(response, "$.exercise_id"); - Exercise createdSimulation = exerciseRepository.findById(simulationId).orElseThrow(); - - Workflow workflowTemplate = findTemplateWorkflowBySimulationId(createdSimulation.getId()); - - mvc.perform( - post(tenantUri( - TENANT_CHAINING_URI - + "/simulations/" - + createdSimulation.getId() - + "/injects")) - .with(csrf()) - .contentType(MediaType.APPLICATION_JSON) - .content(injectInputJson)) - .andExpect(status().is2xxSuccessful()); - - String duplicatedResponse = - mvc.perform( - post(tenantUri(TENANT_CHAINING_URI + "/simulations/" + createdSimulation.getId())) - .with(csrf()) - .contentType(MediaType.APPLICATION_JSON)) - .andExpect(status().is2xxSuccessful()) - .andReturn() - .getResponse() - .getContentAsString(); - - String duplicatedSimulationId = JsonPath.read(duplicatedResponse, "$.exercise_id"); - Exercise duplicatedSimulation = - exerciseRepository.findById(duplicatedSimulationId).orElseThrow(); - Workflow duplicatedWorkflowTemplate = - findTemplateWorkflowBySimulationId(duplicatedSimulation.getId()); - - assertWorkflowEqualsExceptId(workflowTemplate, duplicatedWorkflowTemplate); - - List originalSteps = - stepRepository.findAll().stream() - .filter( - s -> - s.getWorkflow() != null - && workflowTemplate.getId().equals(s.getWorkflow().getId())) - .toList(); - List duplicatedSteps = - stepRepository.findAll().stream() - .filter( - s -> - s.getWorkflow() != null - && duplicatedWorkflowTemplate.getId().equals(s.getWorkflow().getId())) - .toList(); - - assertEquals(originalSteps.size(), duplicatedSteps.size(), "Step TEMPLATE count must match"); - assertFalse( - duplicatedSteps.isEmpty(), "Duplicated simulation workflow must contain step templates"); - - assertStepEqualsExceptId(originalSteps.getFirst(), duplicatedSteps.getFirst()); - } } private Workflow findTemplateWorkflowBySimulationId(String simulationId) { @@ -797,40 +642,6 @@ private CreateExerciseInput buildSimulationInput() { return input; } - private void assertWorkflowEqualsExceptId(Workflow expected, Workflow actual) { - assertNotNull(expected.getId()); - assertNotNull(actual.getId()); - assertNotEquals(expected.getId(), actual.getId(), "Workflow ids must differ"); - - assertEquals(expected.getStatus(), actual.getStatus()); - assertEquals(expected.getVersion(), actual.getVersion()); - assertEquals(expected.isEdited(), actual.isEdited()); - assertEquals(expected.isRateLimitEnabled(), actual.isRateLimitEnabled()); - assertEquals(expected.getMaxAttempts(), actual.getMaxAttempts()); - assertEquals(expected.getMaxTemporalRateSeconds(), actual.getMaxTemporalRateSeconds()); - assertEquals(expected.isTimeoutEnabled(), actual.isTimeoutEnabled()); - assertEquals(expected.getTimeoutSeconds(), actual.getTimeoutSeconds()); - assertEquals(expected.isSafeModeEnabled(), actual.isSafeModeEnabled()); - } - - private void assertStepEqualsExceptId(Step expected, Step actual) { - assertNotNull(expected.getId()); - assertNotNull(actual.getId()); - assertNotEquals(expected.getId(), actual.getId(), "Step ids must differ"); - - assertEquals(expected.getStatus(), actual.getStatus()); - assertEquals(expected.getStepAction(), actual.getStepAction()); - assertEquals(expected.getInput(), actual.getInput()); - assertEquals( - StepService.setField(expected.getData(), "inject_exercise", ""), - StepService.setField(actual.getData(), "inject_exercise", ""), - "Step data must be the same expected for simulation id (inject_exercise)"); - assertEquals(expected.getOutput(), actual.getOutput()); - assertEquals(expected.getOutputParser(), actual.getOutputParser()); - assertEquals(expected.getConditionExecuted(), actual.getConditionExecuted()); - assertEquals(expected.getLimitExecution(), actual.getLimitExecution()); - } - private ScenarioInput buildScenarioInput() { ScenarioInput input = new ScenarioInput(); input.setName("Test Scenario Chaining"); diff --git a/openaev-api/src/test/java/io/openaev/utils/fixtures/KillChainPhaseFixture.java b/openaev-api/src/test/java/io/openaev/utils/fixtures/KillChainPhaseFixture.java index e5c6d4784d2..36b632b9988 100644 --- a/openaev-api/src/test/java/io/openaev/utils/fixtures/KillChainPhaseFixture.java +++ b/openaev-api/src/test/java/io/openaev/utils/fixtures/KillChainPhaseFixture.java @@ -3,6 +3,7 @@ import static java.lang.String.valueOf; import io.openaev.database.model.KillChainPhase; +import io.openaev.database.model.Tenant; import jakarta.validation.constraints.NotBlank; import java.util.Random; @@ -11,13 +12,14 @@ public class KillChainPhaseFixture { private static final Random RANDOM = new Random(); public static KillChainPhase getKillChainPhase( - @NotBlank final String name, @NotBlank final Long order) { + @NotBlank final String name, @NotBlank final Long order, @NotBlank final String tenantId) { KillChainPhase killChainPhase = new KillChainPhase(); killChainPhase.setName(name); killChainPhase.setShortName(name); killChainPhase.setKillChainName("mitre-attack"); killChainPhase.setOrder(order); killChainPhase.setExternalId(valueOf(RANDOM.nextInt())); + killChainPhase.setTenant(new Tenant(tenantId)); return killChainPhase; } } diff --git a/openaev-front/src/actions/autonomous/autonomous-actions.ts b/openaev-front/src/actions/autonomous/autonomous-actions.ts index 530aca1de31..19198bd94a1 100644 --- a/openaev-front/src/actions/autonomous/autonomous-actions.ts +++ b/openaev-front/src/actions/autonomous/autonomous-actions.ts @@ -129,11 +129,12 @@ export const cancelAutonomousRun = (runId: string): Promise<{ data: AutonomousRu // Turn an autonomous scenario into a manual chained scenario. DUPLICATE clones it into a brand-new // manual scenario and leaves the AI run untouched; IN_PLACE flips this scenario to manual for good // (halts the run, drops the autonomous_runs row + timeline, unlocks the simulation for edit/delete) -// and is irreversible. Returns the resulting manual chained scenario to navigate to. +// and is irreversible. Returns only the resulting scenario's id: read it back through the scenario +// endpoint rather than from this response. export const convertAutonomousRunToManual = ( runId: string, mode: 'DUPLICATE' | 'IN_PLACE', -): Promise<{ data: Scenario }> => +): Promise<{ data: { scenario_id: Scenario['scenario_id'] } }> => simplePostCall(`${AUTONOMOUS_URI}/${runId}/convert-to-manual`, { mode }); // -- live view: decision timeline + steering -- diff --git a/openaev-front/src/utils/api-types.d.ts b/openaev-front/src/utils/api-types.d.ts index 5c98bddb376..5258c67d203 100644 --- a/openaev-front/src/utils/api-types.d.ts +++ b/openaev-front/src/utils/api-types.d.ts @@ -1253,6 +1253,35 @@ export interface AttackPatternCreateInput { attack_pattern_stix_id?: string; } +/** Attack pattern as returned by the read endpoints */ +export interface AttackPatternOutput { + /** + * Creation date + * @format date-time + */ + attack_pattern_created_at?: string; + /** Description of the attack pattern */ + attack_pattern_description?: string; + /** External id, e.g. the MITRE technique id */ + attack_pattern_external_id?: string; + /** Id of the attack pattern */ + attack_pattern_id?: string; + attack_pattern_kill_chain_phases?: string[]; + /** Name of the attack pattern */ + attack_pattern_name?: string; + /** Id of the parent attack pattern */ + attack_pattern_parent?: string; + attack_pattern_permissions_required?: string[]; + attack_pattern_platforms?: string[]; + /** STIX id */ + attack_pattern_stix_id?: string; + /** + * Last update date + * @format date-time + */ + attack_pattern_updated_at?: string; +} + export interface AttackPatternSimple { /** @minLength 1 */ attack_pattern_external_id: string; @@ -1327,6 +1356,12 @@ export interface AutonomousConvertToManualInput { mode: "DUPLICATE" | "IN_PLACE"; } +/** Identifier of the scenario resulting from the conversion */ +export interface AutonomousConvertToManualOutput { + /** Id of the resulting manual scenario. */ + scenario_id?: string; +} + /** Tenant default additional agents for autonomous runs */ export interface AutonomousDefaultAgentsInput { /** XTM One agent ids to consult by default. Empty clears the default. */ @@ -8220,8 +8255,8 @@ export interface PageAssetOutput { totalPages?: number; } -export interface PageAttackPattern { - content?: AttackPattern[]; +export interface PageAttackPatternOutput { + content?: AttackPatternOutput[]; empty?: boolean; first?: boolean; last?: boolean; diff --git a/openaev-model/src/main/java/io/openaev/database/model/AttackPattern.java b/openaev-model/src/main/java/io/openaev/database/model/AttackPattern.java index f123e876a25..05b7731c017 100644 --- a/openaev-model/src/main/java/io/openaev/database/model/AttackPattern.java +++ b/openaev-model/src/main/java/io/openaev/database/model/AttackPattern.java @@ -96,6 +96,7 @@ public class AttackPattern implements TenantBase { @Schema(implementation = String[].class) @ManyToMany(fetch = FetchType.LAZY) + @BatchSize(size = 1000) @JoinTable( name = "attack_patterns_kill_chain_phases", joinColumns = @JoinColumn(name = "attack_pattern_id"), diff --git a/openaev-model/src/main/java/io/openaev/database/model/KillChainPhase.java b/openaev-model/src/main/java/io/openaev/database/model/KillChainPhase.java index d31f7e33b47..cb58a998085 100644 --- a/openaev-model/src/main/java/io/openaev/database/model/KillChainPhase.java +++ b/openaev-model/src/main/java/io/openaev/database/model/KillChainPhase.java @@ -5,8 +5,9 @@ import com.fasterxml.jackson.annotation.JsonIgnore; import com.fasterxml.jackson.annotation.JsonProperty; import io.openaev.annotation.Queryable; +import io.openaev.database.audit.Auditable; +import io.openaev.database.audit.AuditableListener; import io.openaev.database.audit.ModelBaseListener; -import io.openaev.database.audit.TenantBaseListener; import io.openaev.jsonapi.BusinessId; import jakarta.persistence.*; import jakarta.validation.constraints.NotBlank; @@ -15,15 +16,16 @@ import java.util.Objects; import lombok.Data; import lombok.Getter; -import org.hibernate.annotations.Filter; import org.hibernate.annotations.UuidGenerator; @Data @Entity @Table(name = "kill_chain_phases") -@EntityListeners({ModelBaseListener.class, TenantBaseListener.class}) -@Filter(name = "tenantFilter", condition = "tenant_id = :tenantId") -public class KillChainPhase implements TenantBase { +// Fully on v2: no v1 @Filter and no TenantBaseListener. Neither must come back — the filter's +// thread-local predicate ANDs with the v2 scope and empties header-route reads, and the listener is +// a TenantContext fallback that would silently mask a write path missing its explicit attribution. +@EntityListeners({ModelBaseListener.class, AuditableListener.class}) +public class KillChainPhase implements TenantBase, Auditable { @Id @Column(name = "phase_id") diff --git a/openaev-model/src/main/java/io/openaev/database/repository/KillChainPhaseRepository.java b/openaev-model/src/main/java/io/openaev/database/repository/KillChainPhaseRepository.java index 8d02ef25d93..cedff529805 100644 --- a/openaev-model/src/main/java/io/openaev/database/repository/KillChainPhaseRepository.java +++ b/openaev-model/src/main/java/io/openaev/database/repository/KillChainPhaseRepository.java @@ -2,6 +2,7 @@ import io.openaev.database.model.KillChainPhase; import jakarta.validation.constraints.NotNull; +import java.util.Collection; import java.util.List; import java.util.Optional; import org.springframework.data.jpa.repository.JpaSpecificationExecutor; @@ -14,22 +15,27 @@ public interface KillChainPhaseRepository extends CrudRepository, JpaSpecificationExecutor { - List findAllByExternalIdInIgnoreCase(List externalIds); - @NotNull Optional findById(@NotNull String id); - Optional findByKillChainNameAndShortName( - @NotNull String killChainName, @NotNull String shortName); + List findAllByExternalIdInIgnoreCaseAndTenantId( + List externalIds, @NotNull String tenantId); + + Optional findByKillChainNameAndShortNameAndTenantId( + @NotNull String killChainName, @NotNull String shortName, @NotNull String tenantId); - // The database unique key is (phase_stix_id, tenant_id): upserts must match on the STIX id - // first, otherwise a renamed phase (same STIX id, new short name) is treated as a new row and - // violates the constraint. The Hibernate tenant filter scopes this to the current tenant. - Optional findByStixId(@NotNull String stixId); + Optional findByStixIdAndTenantId( + @NotNull String stixId, @NotNull String tenantId); @Query( "SELECT DISTINCT kcp FROM Inject i JOIN i.injectorContract ic" + " JOIN ic.attackPatterns ap JOIN ap.killChainPhases kcp" - + " WHERE i.exercise.id = :exerciseId") + + " WHERE i.exercise.id = :exerciseId AND kcp.tenant.id = i.exercise.tenant.id") List findDistinctByExerciseId(@Param("exerciseId") String exerciseId); + + @Query( + "SELECT ap.id, kcp.id FROM AttackPattern ap JOIN ap.killChainPhases kcp" + + " WHERE ap.id IN :attackPatternIds AND kcp.tenant.id = ap.tenant.id") + List findPhaseIdsByAttackPatternIds( + @Param("attackPatternIds") Collection attackPatternIds); }