Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
d5f9a21
Make root network applicabilities explicit
flomillot Oct 10, 2026
cadab4a
Fix the mono root network context tag test
flomillot Oct 10, 2026
4bf3444
Refuse root network tags the shared modifications the user cannot wri…
flomillot Oct 13, 2026
a6bcede
Merge remote-tracking branch 'origin/main' into refactor/explicit-roo…
flomillot Oct 15, 2026
9af215b
Merge remote-tracking branch 'origin/main' into feat/guard-root-netwo…
flomillot Oct 15, 2026
179f087
Merge branch 'refactor/explicit-root-network-applicabilities' into fe…
flomillot Oct 15, 2026
705b2f2
Refuse pasting or importing shared modifications missing the study ro…
flomillot Oct 15, 2026
e54e8b5
Check root network tags only on pastes from another study
flomillot Oct 16, 2026
32143dd
Tell whether the user can write on all, some or none of the shared mo…
flomillot Oct 16, 2026
1f43de9
Merge remote-tracking branch 'origin/main' into HEAD
flomillot Oct 21, 2026
109f4d2
Merge remote-tracking branch 'origin/refactor/explicit-root-network-a…
flomillot Oct 21, 2026
76bbe29
Merge remote-tracking branch 'origin/feat/guard-root-network-tags-on-…
flomillot Oct 22, 2026
4cea6c9
Extract the check of the inserted composites
flomillot Oct 22, 2026
507dbbb
Send the root network tags check in the request body
flomillot Oct 22, 2026
c08ee29
Send the root network tags check in the request body
flomillot Oct 22, 2026
2c88476
Merge remote-tracking branch 'origin/feat/guard-root-network-tags-on-…
flomillot Oct 22, 2026
672a42c
Cover the checks of subtree copies and composite splits
flomillot Oct 22, 2026
1dcac44
Shorten the root network tags check comment
flomillot Oct 22, 2026
bbb3932
Merge remote-tracking branch 'origin/feat/guard-root-network-tags-on-…
flomillot Oct 22, 2026
9d690b5
Remove or shorten comments restating the code
flomillot Oct 22, 2026
02775d6
Merge remote-tracking branch 'origin/main' into feat/guard-root-netwo…
flomillot Oct 23, 2026
51bbf7e
Merge remote-tracking branch 'origin/main' into feat/guard-root-netwo…
flomillot Oct 23, 2026
b1b6524
Merge remote-tracking branch 'origin/main' into HEAD
flomillot Oct 26, 2026
2b1a3bd
Merge remote-tracking branch 'origin/main' into HEAD
flomillot Oct 26, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -12,5 +12,6 @@
/**
* Modifications to check against root network tags.
*/
public record RootNetworkTagsCheckInfos(List<UUID> containerUuids, List<String> tags) {
public record RootNetworkTagsCheckInfos(List<UUID> containerUuids, List<UUID> modificationUuids, List<UUID> sharedUuids,
List<String> tags) {
}
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@
import java.util.concurrent.atomic.AtomicReference;
import java.util.function.Function;
import java.util.stream.Collectors;
import java.util.stream.Stream;

import static org.gridsuite.study.server.error.StudyBusinessErrorCode.*;

Expand Down Expand Up @@ -871,6 +872,14 @@ public UUID getModificationGroupUuid(UUID nodeUuid) {
return getNetworkModificationNodeInfoEntity(nodeUuid).getModificationGroupUuid();
}

@Transactional(readOnly = true)
public List<UUID> getSubtreeModificationGroupUuids(UUID parentNodeUuid) {
List<UUID> nodeUuids = Stream.concat(Stream.of(parentNodeUuid), getAllChildrenUuids(parentNodeUuid).stream()).toList();
return networkModificationNodeInfoRepository.findAllByIdIn(nodeUuids).stream()
.map(NetworkModificationNodeInfoEntity::getModificationGroupUuid)
.toList();
}

@Transactional(readOnly = true)
public String getNetworkModifications(@NonNull UUID nodeUuid, boolean onlyStashed, boolean onlyMetadata, String userId) {
return networkModificationService.getModifications(self.getModificationGroupUuid(nodeUuid), onlyStashed, onlyMetadata, userId);
Expand Down
46 changes: 44 additions & 2 deletions src/main/java/org/gridsuite/study/server/service/StudyService.java
Original file line number Diff line number Diff line change
Expand Up @@ -415,7 +415,25 @@ private void renameRootNetworkTagInApplicabilities(UUID studyUuid, String previo
* write on, which they cannot associate with a new tag, must already be.
*/
private void assertCanAddRootNetworkTag(UUID studyUuid, String newTag, String userId) {
networkModificationRestService.assertRootNetworkTagsAllowed(getStudyModificationGroupUuids(studyUuid), List.of(newTag), userId);
networkModificationRestService.assertRootNetworkTagsAllowed(getStudyModificationGroupUuids(studyUuid), List.of(), List.of(), List.of(newTag), userId);
}

/**
* Modifications written into a study must be associated with all its root network tags, including the shared
* modifications they point to: the ones the user cannot write on, which they cannot associate with a new tag, must
* already be.
*
* @param containerUuids groups or composites whose content is written into the study: only the shared modifications
* their content refers to are checked, not the containers themselves
* @param modificationUuids modifications written into the study: the shared modifications they and their content
* refer to are checked
* @param sharedUuids shared modifications the study is given a reference to: they are checked themselves, along
* with the shared modifications their content refers to
*/
private void assertCanWriteModificationsInStudy(UUID studyUuid, List<UUID> containerUuids, List<UUID> modificationUuids,
List<UUID> sharedUuids, String userId) {
networkModificationRestService.assertRootNetworkTagsAllowed(containerUuids, modificationUuids, sharedUuids,
rootNetworkService.getStudyRootNetworkTags(studyUuid), userId);
}

public SharedModificationsWriteAccess getSharedModificationsWriteAccess(UUID studyUuid, String userId) {
Expand Down Expand Up @@ -1373,6 +1391,10 @@ private void assertMoveStudyNode(UUID studyUuid, UUID nodeToMoveUuid, UUID refer
@Transactional
public void duplicateStudyNode(UUID sourceStudyUuid, UUID targetStudyUuid, UUID nodeToCopyUuid, UUID referenceNodeUuid, InsertMode insertMode, String userId) {
assertDuplicateStudyNode(sourceStudyUuid, targetStudyUuid, nodeToCopyUuid, referenceNodeUuid, insertMode);
if (!sourceStudyUuid.equals(targetStudyUuid)) {
assertCanWriteModificationsInStudy(targetStudyUuid, List.of(networkModificationTreeService.getModificationGroupUuid(nodeToCopyUuid)),
List.of(), List.of(), userId);
}

UUID duplicatedNodeUuid = networkModificationTreeService.duplicateStudyNode(nodeToCopyUuid, referenceNodeUuid, insertMode, userId);
boolean invalidateBuild = networkModificationTreeService.hasModifications(nodeToCopyUuid, false);
Expand Down Expand Up @@ -1415,6 +1437,10 @@ private void assertDuplicateStudySubtree(UUID sourceStudyUuid, UUID targetStudyU
@Transactional
public void duplicateStudySubtree(UUID sourceStudyUuid, UUID targetStudyUuid, UUID parentNodeToCopyUuid, UUID referenceNodeUuid, String userId) {
assertDuplicateStudySubtree(sourceStudyUuid, targetStudyUuid, parentNodeToCopyUuid, referenceNodeUuid);
if (!sourceStudyUuid.equals(targetStudyUuid)) {
assertCanWriteModificationsInStudy(targetStudyUuid, networkModificationTreeService.getSubtreeModificationGroupUuids(parentNodeToCopyUuid),
List.of(), List.of(), userId);
}
AbstractNode studySubTree = networkModificationTreeService.getStudySubtree(sourceStudyUuid, parentNodeToCopyUuid, null);
StudyEntity studyEntity = getStudy(targetStudyUuid);
UUID duplicatedNodeUuid = networkModificationTreeService.cloneStudyTree(studySubTree, referenceNodeUuid, studyEntity, userId);
Expand Down Expand Up @@ -1638,6 +1664,7 @@ public void duplicateNetworkModifications(
UUID targetNodeUuid,
List<UUID> modificationsUuids,
String userId) {
assertCanWriteModificationsInStudy(targetStudyUuid, List.of(), modificationsUuids, List.of(), userId);
duplicateModificationsOrInsertComposites(targetStudyUuid, targetNodeUuid,
(groupUuid, modificationApplicationContexts) -> {
NetworkModificationsResult result = networkModificationRestService.duplicateModifications(groupUuid, Pair.of(modificationsUuids, modificationApplicationContexts), userId);
Expand Down Expand Up @@ -1690,12 +1717,14 @@ public void insertCompositeNetworkModifications(
List<CompositeInfos> compositesInfos,
String userId,
StudyConstants.CompositeModificationsActionType action) {
boolean isInsert = action == StudyConstants.CompositeModificationsActionType.INSERT;
assertCanWriteCompositesInStudy(targetStudyUuid, compositesInfos, isInsert, userId);
duplicateModificationsOrInsertComposites(
targetStudyUuid,
targetNodeUuid,
(groupUuid, modificationApplicationContexts) -> {
NetworkModificationsResult result = networkModificationRestService.insertCompositeModifications(groupUuid, action, Pair.of(compositesInfos, modificationApplicationContexts));
if (action == StudyConstants.CompositeModificationsActionType.INSERT) {
if (isInsert) {
createCompositesReferences(compositesInfos, result.modificationUuids(), targetStudyUuid, targetNodeUuid, userId);
}
return result;
Expand All @@ -1704,6 +1733,19 @@ public void insertCompositeNetworkModifications(
);
}

/**
* Only an INSERT of a composite marked as shared gives the study a reference to it: the shared composite is checked
* itself. Any other composite is copied: only its content is checked, the composite being passed as its container.
*/
private void assertCanWriteCompositesInStudy(UUID studyUuid, List<CompositeInfos> compositesInfos, boolean isInsert, String userId) {
Map<Boolean, List<UUID>> uuidsByShared = compositesInfos.stream()
.collect(Collectors.partitioningBy(composite -> isInsert && composite.isShared(),
Collectors.mapping(CompositeInfos::id, Collectors.toList())));
List<UUID> sharedCompositeUuids = uuidsByShared.get(true);
List<UUID> copiedCompositeUuids = uuidsByShared.get(false);
assertCanWriteModificationsInStudy(studyUuid, copiedCompositeUuids, List.of(), sharedCompositeUuids, userId);
}

private void createCompositesReferences(List<CompositeInfos> compositesInfos, List<UUID> insertedModificationUuids,
UUID studyUuid, UUID nodeUuid, String userId) {
for (int i = 0; i < compositesInfos.size(); i++) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -788,12 +788,15 @@ public SharedModificationsWriteAccess getReferencedModificationsWriteAccess(List
/**
* Throws forbidden when the unwritable shared modifications involved miss some of the tags.
*/
public void assertRootNetworkTagsAllowed(List<UUID> containerUuids, List<String> rootNetworkTags, String userId) {
if (rootNetworkTags.isEmpty() || containerUuids.isEmpty()) {
public void assertRootNetworkTagsAllowed(List<UUID> containerUuids, List<UUID> modificationUuids, List<UUID> sharedUuids,
List<String> rootNetworkTags, String userId) {
if (rootNetworkTags.isEmpty() || containerUuids.isEmpty() && modificationUuids.isEmpty() && sharedUuids.isEmpty()) {
return;
}
restTemplate.exchange(getNetworkModificationServerURI(false) + CONTAINERS + DELIMITER + "root-network-tags/authorized",
HttpMethod.POST, userIdEntity(userId, new RootNetworkTagsCheckInfos(containerUuids, rootNetworkTags)), Void.class);
HttpMethod.POST,
userIdEntity(userId, new RootNetworkTagsCheckInfos(containerUuids, modificationUuids, sharedUuids, rootNetworkTags)),
Void.class);
}

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,7 @@
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.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;

/**
Expand Down Expand Up @@ -341,6 +342,7 @@ void setup() {
networkModificationRestService.setNetworkModificationServerBaseUri(baseUrl);
directoryService.setDirectoryServerServerBaseUri(baseUrl);
userAdminService.setUserAdminServerBaseUri(baseUrl);
wireMockStubs.stubRootNetworkTagsAllowed();

buildOkStubId = wireMockServer.stubFor(WireMock.post(WireMock.urlPathEqualTo("/v1/networks/" + NETWORK_UUID_STRING + "/build"))
.withPostServeAction(POST_ACTION_SEND_INPUT, Map.of("payload", DEFAULT_BUILD_RESULT, "destination", "build.result"))
Expand Down Expand Up @@ -2180,6 +2182,29 @@ void testInsertComposite() throws Exception {
"/v1/elements/" + sharedNetModId + "/references",
Map.of(),
mapper.writeValueAsString(ReferenceAttributes.createReferenceAttributes(insertedReferenceUuid, studyUuid, nodeUuid1, STUDY_NODE)));
// the shared composite is checked itself, the other one only through its content
List<String> studyTags = List.of(studyEntity.getFirstRootNetwork().getTag());
WireMockUtilsCriteria.verifyPostRequest(wireMockServer, WireMockStubs.ROOT_NETWORK_TAGS_AUTHORIZED_URL, false, Map.of(),
mapper.writeValueAsString(new RootNetworkTagsCheckInfos(List.of(modification1.id()), List.of(), List.of(sharedNetModId), studyTags)));

// split composites are all checked only through their content, and the study gets no reference to them
wireMockServer.stubFor(WireMock.any(urlPathMatching("/v1/network-composite-modifications/groups/.*"))
.withQueryParam("action", equalTo("SPLIT"))
.willReturn(WireMock.ok()
.withBody(mapper.writeValueAsString(new NetworkModificationsResult(List.of(UUID.randomUUID(), UUID.randomUUID()), List.of(Optional.empty()))))
.withHeader(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE)));
mockMvc.perform(put("/v1/studies/{studyUuid}/nodes/{nodeUuid}/composite-modifications?action=SPLIT",
studyUuid, nodeUuid1)
.contentType(MediaType.APPLICATION_JSON)
.content(compositesData)
.header(USER_ID_HEADER, "userId"))
.andExpect(status().isOk());
checkUpdateStatusMessagesReceived(studyUuid, nodeUuid1, output);
checkEquipmentUpdatingFinishedMessagesReceived(studyUuid, nodeUuid1);
checkElementUpdatedMessageSent(studyUuid, userId);
WireMockUtilsCriteria.verifyPutRequest(wireMockServer, url, false, Map.of("action", equalTo("SPLIT")), expectedBody);
WireMockUtilsCriteria.verifyPostRequest(wireMockServer, WireMockStubs.ROOT_NETWORK_TAGS_AUTHORIZED_URL, false, Map.of(),
mapper.writeValueAsString(new RootNetworkTagsCheckInfos(List.of(modification1.id(), sharedNetModId), List.of(), List.of(), studyTags)));
}

@Test
Expand Down Expand Up @@ -2360,6 +2385,37 @@ void testAssembleModificationsIntoNewComposite() throws Exception {
);
}

@Test
void testPasteForbiddenByASharedModificationNotAssociatedWithTheRootNetworkTags() throws Exception {
StudyEntity studyEntity = insertDummyStudy(UUID.fromString(NETWORK_UUID_STRING), CASE_UUID, "UCTE");
UUID studyUuid = studyEntity.getId();
UUID nodeUuid = createNetworkModificationNode(studyUuid, getRootNode(studyUuid).getId(), UUID.randomUUID(), VARIANT_ID, "New node 1", "userId").getId();
UUID modificationUuid = UUID.randomUUID();
String forbiddenBody = mapper.writeValueAsString(PowsyblWsProblemDetail.builder(HttpStatus.FORBIDDEN)
.server("network-modification-server")
.detail("forbidden")
.businessErrorCode("modification.rootNetworkTagsForbiddenByShared")
.businessErrorValues(Map.of("allowedTags", "A, B", "allowedTagsCount", 2))
.build());
UUID forbiddenStubId = wireMockServer.stubFor(WireMock.post(WireMock.urlPathEqualTo(WireMockStubs.ROOT_NETWORK_TAGS_AUTHORIZED_URL))
.atPriority(1)
.willReturn(WireMock.forbidden().withHeader(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_PROBLEM_JSON_VALUE).withBody(forbiddenBody)))
.getId();

// the error tells the user the tags they may use, and nothing is copied
mockMvc.perform(put("/v1/studies/{studyUuid}/nodes/{nodeUuid}", studyUuid, nodeUuid)
.contentType(MediaType.APPLICATION_JSON)
.content(mapper.writeValueAsString(List.of(new ModificationCopyInfos(modificationUuid, null))))
.header(USER_ID_HEADER, "userId"))
.andExpect(status().isForbidden())
.andExpect(jsonPath("$.businessErrorCode").value("modification.rootNetworkTagsForbiddenByShared"))
.andExpect(jsonPath("$.businessErrorValues.allowedTags").value("A, B"));

WireMockUtils.verifyPostRequest(wireMockServer, forbiddenStubId, WireMockStubs.ROOT_NETWORK_TAGS_AUTHORIZED_URL, false, Map.of(),
mapper.writeValueAsString(new RootNetworkTagsCheckInfos(List.of(), List.of(modificationUuid), List.of(),
List.of(studyEntity.getFirstRootNetwork().getTag()))));
}

@Test
void testDuplicateModification() throws Exception {
String userId = "userId";
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -305,7 +305,7 @@ void testRenamingARootNetworkTagNeedsTheSharedModificationsTheUserCannotWriteOnT

// the study points to a shared modification the user is not allowed to write on, and not associated with the new tag
doThrow(HttpClientErrorException.create(HttpStatus.FORBIDDEN, "Forbidden", null, null, null))
.when(networkModificationRestService).assertRootNetworkTagsAllowed(List.of(firstNode.getModificationGroupUuid()), List.of(ROOT_NETWORK_TAG_2), USER_ID);
.when(networkModificationRestService).assertRootNetworkTagsAllowed(List.of(firstNode.getModificationGroupUuid()), List.of(), List.of(), List.of(ROOT_NETWORK_TAG_2), USER_ID);

UUID studyUuid = studyEntity.getId();
RootNetworkInfos renamingTagInfos = RootNetworkInfos.builder().id(rootNetworkUuid).tag(ROOT_NETWORK_TAG_2).build();
Expand All @@ -317,7 +317,7 @@ void testRenamingARootNetworkTagNeedsTheSharedModificationsTheUserCannotWriteOnT

// an update keeping the tag is no rename, so it needs no permission
updateRootNetwork(studyUuid, rootNetworkUuid, ROOT_NETWORK_TAG_1);
verify(networkModificationRestService, times(1)).assertRootNetworkTagsAllowed(any(), any(), any());
verify(networkModificationRestService, times(1)).assertRootNetworkTagsAllowed(any(), any(), any(), any(), any());
}

@Test
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -265,17 +265,28 @@ void testDuplicateModificationsGroupAssociatesTheRootNetworkTags() {
@Test
void testAssertRootNetworkTagsAllowed() {
UUID groupUuid = UUID.randomUUID();
UUID modificationUuid = UUID.randomUUID();
UUID sharedUuid = UUID.randomUUID();
String url = NETWORK_MODIFICATION_SERVER_URI + "/v1/containers/root-network-tags/authorized";
HttpHeaders headers = new HttpHeaders();
headers.set(HEADER_USER_ID, USER_ID);
headers.setContentType(MediaType.APPLICATION_JSON);

networkModificationService.assertRootNetworkTagsAllowed(List.of(groupUuid), List.of("PH1", "PH2"), USER_ID);
verify(restTemplate).exchange(NETWORK_MODIFICATION_SERVER_URI + "/v1/containers/root-network-tags/authorized", HttpMethod.POST,
new HttpEntity<>(new RootNetworkTagsCheckInfos(List.of(groupUuid), List.of("PH1", "PH2")), headers), Void.class);

// there is nothing to check without a tag, or without a container to check it on
networkModificationService.assertRootNetworkTagsAllowed(List.of(groupUuid), List.of(), USER_ID);
networkModificationService.assertRootNetworkTagsAllowed(List.of(), List.of("PH1"), USER_ID);
networkModificationService.assertRootNetworkTagsAllowed(List.of(groupUuid), List.of(), List.of(), List.of("PH1", "PH2"), USER_ID);
verify(restTemplate).exchange(url, HttpMethod.POST, new HttpEntity<>(
new RootNetworkTagsCheckInfos(List.of(groupUuid), List.of(), List.of(), List.of("PH1", "PH2")), headers), Void.class);

// the modifications and the shared modifications are checked too, even alone
networkModificationService.assertRootNetworkTagsAllowed(List.of(), List.of(modificationUuid), List.of(), List.of("PH1"), USER_ID);
verify(restTemplate).exchange(url, HttpMethod.POST, new HttpEntity<>(
new RootNetworkTagsCheckInfos(List.of(), List.of(modificationUuid), List.of(), List.of("PH1")), headers), Void.class);
networkModificationService.assertRootNetworkTagsAllowed(List.of(), List.of(), List.of(sharedUuid), List.of("PH1"), USER_ID);
verify(restTemplate).exchange(url, HttpMethod.POST, new HttpEntity<>(
new RootNetworkTagsCheckInfos(List.of(), List.of(), List.of(sharedUuid), List.of("PH1")), headers), Void.class);

// there is nothing to check without a tag, or without anything to check it on
networkModificationService.assertRootNetworkTagsAllowed(List.of(groupUuid), List.of(), List.of(), List.of(), USER_ID);
networkModificationService.assertRootNetworkTagsAllowed(List.of(), List.of(), List.of(), List.of("PH1"), USER_ID);

verifyNoMoreInteractions(restTemplate);
}
Expand Down
Loading
Loading