This is an automated email from the ASF dual-hosted git repository. lahirujayathilake pushed a commit to branch master in repository https://gitbox.apache.org/repos/asf/airavata-custos.git
commit 95f573c2c67a94bf5c02048a5dc7c7a6a418fff3 Author: lahiruj <[email protected]> AuthorDate: Mon Oct 13 16:21:36 2025 -0400 person service impl, new user modify handler, and updates to existing handlers --- .../amie/handler/DataAccountCreateHandler.java | 10 +- .../amie/handler/RequestAccountCreateHandler.java | 18 +- .../amie/handler/RequestPersonMergeHandler.java | 28 +-- ...eHandler.java => RequestUserModifyHandler.java} | 58 ++--- .../custos/amie/model/ClusterAccountEntity.java | 3 + .../custos/amie/repo/PersonDnsRepository.java | 6 + .../apache/custos/amie/service/PersonService.java | 242 +++++++++++++++++++++ 7 files changed, 311 insertions(+), 54 deletions(-) diff --git a/amie-decoder/src/main/java/org/apache/custos/amie/handler/DataAccountCreateHandler.java b/amie-decoder/src/main/java/org/apache/custos/amie/handler/DataAccountCreateHandler.java index 7138e8c78..305629bfd 100644 --- a/amie-decoder/src/main/java/org/apache/custos/amie/handler/DataAccountCreateHandler.java +++ b/amie-decoder/src/main/java/org/apache/custos/amie/handler/DataAccountCreateHandler.java @@ -37,7 +37,7 @@ import java.util.Map; @Component public class DataAccountCreateHandler implements PacketHandler { - private static final Logger log = LoggerFactory.getLogger(DataAccountCreateHandler.class); + private static final Logger LOGGER = LoggerFactory.getLogger(DataAccountCreateHandler.class); private final AmieClient amieClient; @@ -52,7 +52,7 @@ public class DataAccountCreateHandler implements PacketHandler { @Override public void handle(JsonNode packetJson, PacketEntity packetEntity) { - log.info("Starting 'data_account_create' handler for packet amie_id [{}].", packetEntity.getAmieId()); + LOGGER.info("Starting 'data_account_create' handler for packet amie_id [{}].", packetEntity.getAmieId()); JsonNode body = packetJson.path("body"); String projectId = body.path("ProjectID").asText(); @@ -61,13 +61,13 @@ public class DataAccountCreateHandler implements PacketHandler { Assert.hasText(projectId, "'ProjectID' must not be empty."); Assert.hasText(personId, "'PersonID' must not be empty."); - log.info("Packet validated. ProjectID: [{}], PersonID: [{}].", projectId, personId); + LOGGER.info("Packet validated. ProjectID: [{}], PersonID: [{}].", projectId, personId); // TODO - perform the business logic // - find the user's record by 'personId' (localID) and update the distinguished names (dnList) if (dnList.isArray() && !dnList.isEmpty()) { - log.info("Received DnList for user [{}]. In a real implementation, this would be saved to the user's profile.", personId); + LOGGER.info("Received DnList for user [{}]. In a real implementation, this would be saved to the user's profile.", personId); // TODO userService.updateUserDnList(personId, dnList); } @@ -88,6 +88,6 @@ public class DataAccountCreateHandler implements PacketHandler { amieClient.replyToPacket(packetRecId, reply); - log.info("Successfully sent 'inform_transaction_complete' for data_account_create packet_rec_id [{}].", packetRecId); + LOGGER.info("Successfully sent 'inform_transaction_complete' for data_account_create packet_rec_id [{}].", packetRecId); } } diff --git a/amie-decoder/src/main/java/org/apache/custos/amie/handler/RequestAccountCreateHandler.java b/amie-decoder/src/main/java/org/apache/custos/amie/handler/RequestAccountCreateHandler.java index ee8c6dd4e..2e49a8602 100644 --- a/amie-decoder/src/main/java/org/apache/custos/amie/handler/RequestAccountCreateHandler.java +++ b/amie-decoder/src/main/java/org/apache/custos/amie/handler/RequestAccountCreateHandler.java @@ -21,6 +21,7 @@ package org.apache.custos.amie.handler; import com.fasterxml.jackson.databind.JsonNode; import org.apache.custos.amie.client.AmieClient; import org.apache.custos.amie.model.PacketEntity; +import org.apache.custos.amie.service.PersonService; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.stereotype.Component; @@ -43,9 +44,11 @@ public class RequestAccountCreateHandler implements PacketHandler { private static final Logger LOGGER = LoggerFactory.getLogger(RequestAccountCreateHandler.class); private final AmieClient amieClient; + private final PersonService personService; - public RequestAccountCreateHandler(AmieClient amieClient) { + public RequestAccountCreateHandler(AmieClient amieClient, PersonService personService) { this.amieClient = amieClient; + this.personService = personService; } @Override @@ -62,7 +65,6 @@ public class RequestAccountCreateHandler implements PacketHandler { String grantNumber = body.path("GrantNumber").asText(); String userFirstName = body.path("UserFirstName").asText(); String userLastName = body.path("UserLastName").asText(); - String userEmail = body.path("UserEmail").asText(); String userOrgCode = body.path("UserOrgCode").asText(); Assert.hasText(projectId, "'ProjectID' (the local project ID) must not be empty."); @@ -70,11 +72,13 @@ public class RequestAccountCreateHandler implements PacketHandler { Assert.hasText(userLastName, "'UserLastName' must not be empty."); LOGGER.info("Packet validated successfully for user [{} {}] on project [{}].", userFirstName, userLastName, projectId); - // TODO invoke actual cluster's user provisioning service. For the time being generating a local user ID and a username - String localUserPersonId = UUID.randomUUID().toString(); - String localUsername = (userFirstName.trim().charAt(0) + userLastName.trim().replace(" ", "-")).toLowerCase(); - - LOGGER.info("Created local user account with PersonID [{}] and username [{}]", localUserPersonId, localUsername); + // TODO Replace with external source of truth (e.g., COmanage) lookup for PersonID and username + String proposedPersonId = UUID.randomUUID().toString(); + String proposedUsername = (userFirstName.trim().charAt(0) + userLastName.trim().replace(" ", "-")).toLowerCase(); + var provision = personService.createIfAbsentFromPacket(body, proposedPersonId, proposedUsername); + String localUserPersonId = provision.getLocalPersonId(); + String localUsername = provision.getUsername(); + LOGGER.info("Ensured local person [{}] and cluster account username [{}] exist.", localUserPersonId, localUsername); // Build and send the 'notify_account_create' reply Map<String, Object> replyBody = new HashMap<>(); diff --git a/amie-decoder/src/main/java/org/apache/custos/amie/handler/RequestPersonMergeHandler.java b/amie-decoder/src/main/java/org/apache/custos/amie/handler/RequestPersonMergeHandler.java index d94419d41..6feda885b 100644 --- a/amie-decoder/src/main/java/org/apache/custos/amie/handler/RequestPersonMergeHandler.java +++ b/amie-decoder/src/main/java/org/apache/custos/amie/handler/RequestPersonMergeHandler.java @@ -21,9 +21,11 @@ package org.apache.custos.amie.handler; import com.fasterxml.jackson.databind.JsonNode; import org.apache.custos.amie.client.AmieClient; import org.apache.custos.amie.model.PacketEntity; +import org.apache.custos.amie.service.PersonService; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; import org.springframework.util.Assert; import java.util.HashMap; @@ -44,9 +46,11 @@ public class RequestPersonMergeHandler implements PacketHandler { private static final Logger LOGGER = LoggerFactory.getLogger(RequestPersonMergeHandler.class); private final AmieClient amieClient; + private final PersonService personService; - public RequestPersonMergeHandler(AmieClient amieClient) { + public RequestPersonMergeHandler(AmieClient amieClient, PersonService personService) { this.amieClient = amieClient; + this.personService = personService; } @Override @@ -55,25 +59,23 @@ public class RequestPersonMergeHandler implements PacketHandler { } @Override + @Transactional public void handle(JsonNode packetJson, PacketEntity packetEntity) { LOGGER.info("Starting 'request_person_merge' handler for packet amie_id [{}].", packetEntity.getAmieId()); JsonNode body = packetJson.path("body"); - String survivingPersonId = body.path("PrimaryPersonID").asText(); - String retiringPersonId = body.path("PersonID").asText(); + String survivingPersonLocalId = body.path("KeepPersonID").asText(); + String survivingPersonGlobalId = body.path("KeepGlobalID").asText(); + String retiringPersonLocalId = body.path("DeletePersonID").asText(); + String retiringPersonGlobalId = body.path("DeleteGlobalID").asText(); - Assert.hasText(survivingPersonId, "'PrimaryPersonID' (the surviving user) must not be empty."); - Assert.hasText(retiringPersonId, "'PersonID' (the retiring user) must not be empty."); - LOGGER.info("Packet validated. Merging user [{}] into user [{}].", retiringPersonId, survivingPersonId); + Assert.hasText(survivingPersonLocalId, "'KeepPersonID' (the surviving user's local ID) must not be empty."); + Assert.hasText(retiringPersonLocalId, "'DeletePersonID' (the retiring user's local ID) must not be empty."); + LOGGER.info("Packet validated. Merging user with local ID [{}], global ID [{}] into user with local ID [{}], global ID [{}].", + retiringPersonLocalId, retiringPersonGlobalId, survivingPersonLocalId, survivingPersonGlobalId); - // TODO - perform the business logic - // - find the local user accounts for both the surviving and retiring IDs - // - re-associate all project memberships and allocations from the retiring user to the surviving user - // - handle the unix system changes - // - lock or disable the retiring user's local login account - LOGGER.info("Simulating business logic: Re-associating resources from [{}] to [{}] and disabling the retiring account.", retiringPersonId, survivingPersonId); + personService.mergePersons(survivingPersonLocalId, retiringPersonLocalId); - // Send the 'inform_transaction_complete' reply sendSuccessReply(packetEntity.getAmieId()); } diff --git a/amie-decoder/src/main/java/org/apache/custos/amie/handler/DataAccountCreateHandler.java b/amie-decoder/src/main/java/org/apache/custos/amie/handler/RequestUserModifyHandler.java similarity index 53% copy from amie-decoder/src/main/java/org/apache/custos/amie/handler/DataAccountCreateHandler.java copy to amie-decoder/src/main/java/org/apache/custos/amie/handler/RequestUserModifyHandler.java index 7138e8c78..62c9db6a6 100644 --- a/amie-decoder/src/main/java/org/apache/custos/amie/handler/DataAccountCreateHandler.java +++ b/amie-decoder/src/main/java/org/apache/custos/amie/handler/RequestUserModifyHandler.java @@ -7,20 +7,21 @@ * "License"); you may not use this file except in compliance * with the License. You may obtain a copy of the License at * - * http://www.apache.org/licenses/LICENSE-2.0 + * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, * software distributed under the License is distributed on an * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. + * specific language governing permissions and limitations + * under the License. */ package org.apache.custos.amie.handler; import com.fasterxml.jackson.databind.JsonNode; import org.apache.custos.amie.client.AmieClient; import org.apache.custos.amie.model.PacketEntity; +import org.apache.custos.amie.service.PersonService; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.stereotype.Component; @@ -30,48 +31,45 @@ import java.util.HashMap; import java.util.Map; /** - * Handles the 'data_account_create' packet. - * <p> - * This packet is the third step in the account creation transaction. + * Handles the 'request_user_modify' AMIE packet (ActionType replace | delete). */ @Component -public class DataAccountCreateHandler implements PacketHandler { +public class RequestUserModifyHandler implements PacketHandler { - private static final Logger log = LoggerFactory.getLogger(DataAccountCreateHandler.class); + private static final Logger LOGGER = LoggerFactory.getLogger(RequestUserModifyHandler.class); private final AmieClient amieClient; + private final PersonService personService; - public DataAccountCreateHandler(AmieClient amieClient) { + public RequestUserModifyHandler(AmieClient amieClient, PersonService personService) { this.amieClient = amieClient; + this.personService = personService; } @Override public String supportsType() { - return "data_account_create"; + return "request_user_modify"; } @Override - public void handle(JsonNode packetJson, PacketEntity packetEntity) { - log.info("Starting 'data_account_create' handler for packet amie_id [{}].", packetEntity.getAmieId()); + public void handle(JsonNode packetJson, PacketEntity packetEntity) throws Exception { + LOGGER.info("Starting 'request_user_modify' handler for packet amie_id [{}].", packetEntity.getAmieId()); JsonNode body = packetJson.path("body"); - String projectId = body.path("ProjectID").asText(); - String personId = body.path("PersonID").asText(); - JsonNode dnList = body.path("DnList"); - - Assert.hasText(projectId, "'ProjectID' must not be empty."); - Assert.hasText(personId, "'PersonID' must not be empty."); - log.info("Packet validated. ProjectID: [{}], PersonID: [{}].", projectId, personId); - - - // TODO - perform the business logic - // - find the user's record by 'personId' (localID) and update the distinguished names (dnList) - if (dnList.isArray() && !dnList.isEmpty()) { - log.info("Received DnList for user [{}]. In a real implementation, this would be saved to the user's profile.", personId); - // TODO userService.updateUserDnList(personId, dnList); + String actionType = body.path("ActionType").asText("delete"); + Assert.hasText(actionType, "'ActionType' must not be empty (replace|delete)."); + + switch (actionType.toLowerCase()) { + case "replace": + personService.replaceFromModifyPacket(body); + break; + case "delete": + personService.deleteFromModifyPacket(body); + break; + default: + throw new IllegalArgumentException("Unsupported ActionType: " + actionType); } - // Send the 'inform_transaction_complete' reply to close the transaction. sendSuccessReply(packetEntity.getAmieId()); } @@ -81,13 +79,15 @@ public class DataAccountCreateHandler implements PacketHandler { itcBody.put("StatusCode", "Success"); itcBody.put("DetailCode", 1); - itcBody.put("Message", "Transaction completed successfully by handler."); + itcBody.put("Message", "Transaction completed successfully"); reply.put("type", "inform_transaction_complete"); reply.put("body", itcBody); amieClient.replyToPacket(packetRecId, reply); - log.info("Successfully sent 'inform_transaction_complete' for data_account_create packet_rec_id [{}].", packetRecId); + LOGGER.info("Successfully sent 'inform_transaction_complete' for 'request_user_modify' packet_rec_id [{}].", packetRecId); } } + + diff --git a/amie-decoder/src/main/java/org/apache/custos/amie/model/ClusterAccountEntity.java b/amie-decoder/src/main/java/org/apache/custos/amie/model/ClusterAccountEntity.java index fc45ddf0d..064b33e27 100644 --- a/amie-decoder/src/main/java/org/apache/custos/amie/model/ClusterAccountEntity.java +++ b/amie-decoder/src/main/java/org/apache/custos/amie/model/ClusterAccountEntity.java @@ -21,6 +21,8 @@ package org.apache.custos.amie.model; import jakarta.persistence.Column; import jakarta.persistence.Entity; import jakarta.persistence.FetchType; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; import jakarta.persistence.Id; import jakarta.persistence.JoinColumn; import jakarta.persistence.ManyToOne; @@ -38,6 +40,7 @@ import java.time.Instant; public class ClusterAccountEntity { @Id + @GeneratedValue(strategy = GenerationType.UUID) @Column(name = "id") private String id; diff --git a/amie-decoder/src/main/java/org/apache/custos/amie/repo/PersonDnsRepository.java b/amie-decoder/src/main/java/org/apache/custos/amie/repo/PersonDnsRepository.java index 5773bc306..21a71194c 100644 --- a/amie-decoder/src/main/java/org/apache/custos/amie/repo/PersonDnsRepository.java +++ b/amie-decoder/src/main/java/org/apache/custos/amie/repo/PersonDnsRepository.java @@ -24,4 +24,10 @@ import org.springframework.stereotype.Repository; @Repository public interface PersonDnsRepository extends JpaRepository<PersonDnsEntity, Long> { + + boolean existsByPerson_IdAndDn(String personId, String dn); + + void deleteByPerson_Id(String personId); + + void deleteByPerson_IdAndDnNotIn(String personId, java.util.Collection<String> dnsToKeep); } diff --git a/amie-decoder/src/main/java/org/apache/custos/amie/service/PersonService.java b/amie-decoder/src/main/java/org/apache/custos/amie/service/PersonService.java new file mode 100644 index 000000000..2b5ecd47f --- /dev/null +++ b/amie-decoder/src/main/java/org/apache/custos/amie/service/PersonService.java @@ -0,0 +1,242 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.custos.amie.service; + +import com.fasterxml.jackson.databind.JsonNode; +import org.apache.custos.amie.model.ClusterAccountEntity; +import org.apache.custos.amie.model.PersonDnsEntity; +import org.apache.custos.amie.model.PersonEntity; +import org.apache.custos.amie.repo.ClusterAccountRepository; +import org.apache.custos.amie.repo.PersonDnsRepository; +import org.apache.custos.amie.repo.PersonRepository; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.ArrayList; +import java.util.HashSet; +import java.util.Optional; +import java.util.Set; +import java.util.stream.Collectors; + +@Service +public class PersonService { + + private static final Logger LOGGER = LoggerFactory.getLogger(PersonService.class); + + private final PersonRepository personRepository; + private final PersonDnsRepository personDnsRepository; + private final ClusterAccountRepository clusterAccountRepository; + + public PersonService(PersonRepository personRepository, PersonDnsRepository personDnsRepository, ClusterAccountRepository clusterAccountRepository) { + this.personRepository = personRepository; + this.personDnsRepository = personDnsRepository; + this.clusterAccountRepository = clusterAccountRepository; + } + + public static class AccountProvisionResult { + private final String localPersonId; + private final String username; + + public AccountProvisionResult(String localPersonId, String username) { + this.localPersonId = localPersonId; + this.username = username; + } + + public String getLocalPersonId() { + return localPersonId; + } + + public String getUsername() { + return username; + } + } + + @Transactional + public AccountProvisionResult createIfAbsentFromPacket(JsonNode packetBody, String localPersonId, String proposedUsername) { + String accessGlobalId = packetBody.path("UserGlobalID").asText(null); + if (accessGlobalId == null || accessGlobalId.isBlank()) { + LOGGER.warn("Missing required 'UserGlobalID' (ACCESS Global ID) in account create packet body"); + throw new IllegalArgumentException("Missing required 'UserGlobalID' (ACCESS Global ID) in account create packet body"); + } + + Optional<PersonEntity> existingByGlobal = personRepository.findByAccessGlobalId(accessGlobalId); + PersonEntity person; + if (existingByGlobal.isPresent()) { + person = existingByGlobal.get(); + LOGGER.info("Person already exists for access_global_id [{}] as local id [{}]", accessGlobalId, person.getId()); + + } else { + person = new PersonEntity(); + person.setId(localPersonId); + person.setAccessGlobalId(accessGlobalId); + person.setFirstName(packetBody.path("UserFirstName").asText("")); + person.setLastName(packetBody.path("UserLastName").asText("")); + person.setEmail(packetBody.path("UserEmail").asText("")); + person.setOrganization(packetBody.path("UserOrganization").asText(null)); + person.setOrgCode(packetBody.path("UserOrgCode").asText(null)); + person.setNsfStatusCode(packetBody.path("NsfStatusCode").asText(null)); + personRepository.save(person); + + JsonNode dnList = packetBody.path("UserDnList"); + if (dnList.isArray()) { + for (JsonNode dnNode : dnList) { + String dn = dnNode.asText(null); + if (dn == null || dn.isBlank()) continue; + if (!personDnsRepository.existsByPerson_IdAndDn(person.getId(), dn)) { + PersonDnsEntity pde = new PersonDnsEntity(); + pde.setPerson(person); + pde.setDn(dn); + personDnsRepository.save(pde); + } + } + } + } + + String uniqueUsername = ensureUniqueUsername(proposedUsername); + if (clusterAccountRepository.findByUsername(uniqueUsername).isEmpty()) { + ClusterAccountEntity clusterAccount = new ClusterAccountEntity(); + clusterAccount.setPerson(person); + clusterAccount.setUsername(uniqueUsername); + clusterAccount.setActive(true); + clusterAccountRepository.save(clusterAccount); + } else { + LOGGER.error("Username [{}] is already taken by another account", uniqueUsername); + throw new IllegalStateException("Username " + uniqueUsername + " is already taken by another account"); + } + + return new AccountProvisionResult(person.getId(), uniqueUsername); + } + + // TODO - this will not be needed when source of truth (e.g., COmanage) is integrated + private String ensureUniqueUsername(String base) { + String candidate = base; + int suffix = 0; + while (clusterAccountRepository.findByUsername(candidate).isPresent()) { + suffix++; + candidate = base + suffix; + } + return candidate; + } + + @Transactional + public void replaceFromModifyPacket(JsonNode body) { + String personId = body.path("PersonID").asText(null); + if (personId == null || personId.isBlank()) { + LOGGER.warn("Missing required 'PersonID' in request_user_modify replace body"); + throw new IllegalArgumentException("Missing required 'PersonID' in request_user_modify replace body"); + } + + PersonEntity person = personRepository.findById(personId).orElseThrow(() -> new IllegalArgumentException("Unknown local PersonID: " + personId)); + + // Update fields + if (body.has("FirstName")) person.setFirstName(body.path("FirstName").asText(person.getFirstName())); + if (body.has("LastName")) person.setLastName(body.path("LastName").asText(person.getLastName())); + if (body.has("Email")) person.setEmail(body.path("Email").asText(person.getEmail())); + person.setOrganization(body.has("Organization") ? body.path("Organization").asText(null) : null); + person.setOrgCode(body.has("OrgCode") ? body.path("OrgCode").asText(null) : null); + person.setNsfStatusCode(body.has("NsfStatusCode") ? body.path("NsfStatusCode").asText(null) : null); + personRepository.save(person); + + Set<String> newDns = new HashSet<>(); + JsonNode dnList = body.path("DnList"); + if (dnList != null && dnList.isArray()) { + for (JsonNode dnNode : dnList) { + String dn = dnNode.asText(null); + if (dn != null && !dn.isBlank()) newDns.add(dn); + } + } + + if (newDns.isEmpty()) { + // If no DnList provided in replace, clear all + personDnsRepository.deleteByPerson_Id(personId); + } else { + + // delete all not in new set + personDnsRepository.deleteByPerson_IdAndDnNotIn(personId, new ArrayList<>(newDns)); + // insert missing + for (String dn : newDns) { + if (!personDnsRepository.existsByPerson_IdAndDn(personId, dn)) { + PersonDnsEntity p = new PersonDnsEntity(); + p.setPerson(person); + p.setDn(dn); + personDnsRepository.save(p); + } + } + } + } + + @Transactional + public void deleteFromModifyPacket(JsonNode body) { + String personId = body.path("PersonID").asText(null); + if (personId == null || personId.isBlank()) { + throw new IllegalArgumentException("Missing required 'PersonID' in request_user_modify delete body"); + } + // Cascades will remove DNs and cluster accounts + personRepository.deleteById(personId); + } + + /** + * Merges two person records. All cluster accounts and unique DNs from the retiring + * person are moved to the surviving person. The retiring person record is then deleted. + * + * @param survivingPersonId The local ID of the person to keep. + * @param retiringPersonId The local ID of the person to merge and delete. + */ + @Transactional + public void mergePersons(String survivingPersonId, String retiringPersonId) { + LOGGER.info("Merging person {} into {}", retiringPersonId, survivingPersonId); + + PersonEntity survivingPerson = personRepository.findById(survivingPersonId) + .orElseThrow(() -> new IllegalStateException("Could not find surviving person with local ID: " + survivingPersonId)); + + PersonEntity retiringPerson = personRepository.findById(retiringPersonId) + .orElseThrow(() -> new IllegalStateException("Could not find retiring person with local ID: " + retiringPersonId)); + + // Re-associate all cluster accounts + for (ClusterAccountEntity account : retiringPerson.getClusterAccounts()) { + LOGGER.info("Moving cluster account '{}' from retiring person to surviving person", account.getUsername()); + account.setPerson(survivingPerson); + clusterAccountRepository.save(account); + } + + // Merge DNs, avoiding duplicates + Set<String> survivingDns = survivingPerson.getDnsEntries().stream() + .map(PersonDnsEntity::getDn) + .collect(Collectors.toSet()); + + for (PersonDnsEntity retiringDn : retiringPerson.getDnsEntries()) { + if (!survivingDns.contains(retiringDn.getDn())) { + LOGGER.info("Moving DN '{}' from retiring person to surviving person", retiringDn.getDn()); + retiringDn.setPerson(survivingPerson); + personDnsRepository.save(retiringDn); + } else { + // Remove the already existing DN + personDnsRepository.delete(retiringDn); + } + } + + // The CASCADE constraint will clean up any remaining associations + personRepository.delete(retiringPerson); + LOGGER.info("Successfully merged and deleted retiring person record {}", retiringPersonId); + } +} + +
