Repository: dhis2/dhis2-core
Pull request #20637: fix: Move param validations into transfer service [DHIS2-19472]
Author: login: muilpp
Created: 2025-04-14T13:43:03Z | Merged: 2025-04-23T13:23:58Z

I'm doing a bit of a refactor in the `TrackerOwnershipController`, where I:

- Move all validations into the service layer. So far, we had some validations in the controller and some in the service. The goal is to have all of them in the service layer. This way, we ensure that service API users won't experience unexpected behavior due to missing validations. There's a temporary exception when transferring a tracked entity, since we can't use the `trackedEntityService`, as explained [here](https://github.com/dhis2/dhis2-core/pull/20637/files#diff-e770e251f69cf4452ad2bdb19338b709c016ceffdc470b72b8b9a7ddf4ba4432R118).
- Use the `TrackerProgramService` to automatically perform ACL validations.
- Return an exception if no owner is found for the given tracked entity/program. Currently, when this happens, no changes are made but no exception is thrown, so the controller returns `ok("Ownership transferred")`, which is misleading.
- Introduce a new method in `TrackerProgramService` to get a tracker program by UID.
- Remove the deprecated `ou` param in the transfer endpoint.


Diff:
diff --git a/dhis-2/dhis-services/dhis-service-tracker/pom.xml b/dhis-2/dhis-services/dhis-service-tracker/pom.xml
index f095313a7ecc..3c383e70d40d 100644
--- a/dhis-2/dhis-services/dhis-service-tracker/pom.xml
+++ b/dhis-2/dhis-services/dhis-service-tracker/pom.xml
@@ -196,6 +196,11 @@
       <artifactId>joda-time</artifactId>
       <scope>test</scope>
     </dependency>
+    <dependency>
+      <groupId>org.mockito</groupId>
+      <artifactId>mockito-inline</artifactId>
+      <scope>test</scope>
+    </dependency>
   </dependencies>
 
   <build>
diff --git a/dhis-2/dhis-services/dhis-service-tracker/src/main/java/org/hisp/dhis/tracker/acl/DefaultTrackedEntityProgramOwnerService.java b/dhis-2/dhis-services/dhis-service-tracker/src/main/java/org/hisp/dhis/tracker/acl/DefaultTrackedEntityProgramOwnerService.java
index 2015e43f475c..016e2b889d1d 100644
--- a/dhis-2/dhis-services/dhis-service-tracker/src/main/java/org/hisp/dhis/tracker/acl/DefaultTrackedEntityProgramOwnerService.java
+++ b/dhis-2/dhis-services/dhis-service-tracker/src/main/java/org/hisp/dhis/tracker/acl/DefaultTrackedEntityProgramOwnerService.java
@@ -29,7 +29,9 @@
  */
 package org.hisp.dhis.tracker.acl;
 
+import javax.annotation.Nonnull;
 import lombok.RequiredArgsConstructor;
+import org.hisp.dhis.feedback.BadRequestException;
 import org.hisp.dhis.organisationunit.OrganisationUnit;
 import org.hisp.dhis.program.Program;
 import org.hisp.dhis.trackedentity.TrackedEntity;
@@ -87,14 +89,17 @@ public void createOrUpdateTrackedEntityProgramOwner(
   @Override
   @Transactional
   public void updateTrackedEntityProgramOwner(
-      TrackedEntity trackedEntity, Program program, OrganisationUnit orgUnit) {
-    if (trackedEntity == null || program == null || orgUnit == null) {
-      return;
-    }
+      @Nonnull TrackedEntity trackedEntity,
+      @Nonnull Program program,
+      @Nonnull OrganisationUnit orgUnit)
+      throws BadRequestException {
     TrackedEntityProgramOwner teProgramOwner =
         trackedEntityProgramOwnerStore.getTrackedEntityProgramOwner(trackedEntity, program);
     if (teProgramOwner == null) {
-      return;
+      throw new BadRequestException(
+          String.format(
+              "Tracked entity not transferred. No owner found for the tracked entity %s and program %s combination",
+              trackedEntity.getUid(), program.getUid()));
     }
     updateTrackedEntityProgramOwner(teProgramOwner, orgUnit);
     trackedEntityProgramOwnerStore.update(teProgramOwner);
diff --git a/dhis-2/dhis-services/dhis-service-tracker/src/main/java/org/hisp/dhis/tracker/acl/DefaultTrackerOwnershipManager.java b/dhis-2/dhis-services/dhis-service-tracker/src/main/java/org/hisp/dhis/tracker/acl/DefaultTrackerOwnershipManager.java
index 7bdaa139e31e..4df90d0bf9a9 100644
--- a/dhis-2/dhis-services/dhis-service-tracker/src/main/java/org/hisp/dhis/tracker/acl/DefaultTrackerOwnershipManager.java
+++ b/dhis-2/dhis-services/dhis-service-tracker/src/main/java/org/hisp/dhis/tracker/acl/DefaultTrackerOwnershipManager.java
@@ -29,6 +29,8 @@
  */
 package org.hisp.dhis.tracker.acl;
 
+import static org.hisp.dhis.user.CurrentUserUtil.getCurrentUserDetails;
+
 import java.util.Objects;
 import java.util.Optional;
 import java.util.function.Supplier;
@@ -38,8 +40,12 @@
 import org.hisp.dhis.cache.Cache;
 import org.hisp.dhis.cache.CacheProvider;
 import org.hisp.dhis.common.IdentifiableObjectManager;
+import org.hisp.dhis.common.UID;
+import org.hisp.dhis.feedback.BadRequestException;
 import org.hisp.dhis.feedback.ForbiddenException;
+import org.hisp.dhis.feedback.NotFoundException;
 import org.hisp.dhis.organisationunit.OrganisationUnit;
+import org.hisp.dhis.organisationunit.OrganisationUnitService;
 import org.hisp.dhis.program.Program;
 import org.hisp.dhis.program.ProgramOwnershipHistory;
 import org.hisp.dhis.program.ProgramOwnershipHistoryService;
@@ -52,7 +58,6 @@
 import org.hisp.dhis.security.acl.AclService;
 import org.hisp.dhis.trackedentity.TrackedEntity;
 import org.hisp.dhis.trackedentity.TrackedEntityProgramOwner;
-import org.hisp.dhis.user.CurrentUserUtil;
 import org.hisp.dhis.user.UserDetails;
 import org.hisp.dhis.user.UserService;
 import org.springframework.stereotype.Service;
@@ -62,13 +67,15 @@
  * @author Ameen Mohamed
  */
 @Slf4j
-@Service("org.hisp.dhis.trackedentity.TrackerOwnershipManager")
+@Service("org.hisp.dhis.tracker.acl.TrackerOwnershipManager")
 public class DefaultTrackerOwnershipManager implements TrackerOwnershipManager {
   private static final int TEMPORARY_OWNERSHIP_VALIDITY_IN_HOURS = 3;
 
   private final TrackedEntityProgramOwnerService trackedEntityProgramOwnerService;
   private final ProgramTempOwnershipAuditService programTempOwnershipAuditService;
   private final ProgramTempOwnerService programTempOwnerService;
+  private final TrackerProgramService trackerProgramService;
+  private final OrganisationUnitService organisationUnitService;
   private final ProgramOwnershipHistoryService programOwnershipHistoryService;
   private final UserService userService;
   private final ProgramService programService;
@@ -83,6 +90,8 @@ public DefaultTrackerOwnershipManager(
       ProgramTempOwnerService programTempOwnerService,
       ProgramOwnershipHistoryService programOwnershipHistoryService,
       ProgramService programService,
+      TrackerProgramService trackerProgramService,
+      OrganisationUnitService organisationUnitService,
       IdentifiableObjectManager manager,
       AclService aclService) {
 
@@ -92,6 +101,8 @@ public DefaultTrackerOwnershipManager(
     this.programOwnershipHistoryService = programOwnershipHistoryService;
     this.programTempOwnerService = programTempOwnerService;
     this.programService = programService;
+    this.trackerProgramService = trackerProgramService;
+    this.organisationUnitService = organisationUnitService;
     this.manager = manager;
     this.ownerCache = cacheProvider.createProgramOwnerCache();
     this.tempOwnerCache = cacheProvider.createProgramTempOwnerCache();
@@ -104,62 +115,84 @@ public DefaultTrackerOwnershipManager(
   /** Cache for storing recent temporary ownership checks */
   private final Cache<Boolean> tempOwnerCache;
 
-  // -------------------------------------------------------------------------
-  // Implementation
-  // -------------------------------------------------------------------------
-
   @Override
   @Transactional
+  // TODO(tracker) This method should accept a tracked entity UID instead. The problem is, we can't
+  // use the TrackedEntityService as it introduces a cyclic dependency. That's because the ownership
+  // manager is used to filter TEs after hitting the database. As soon as we move those filters into
+  // the store to fix the pagination issue, we should be able to use the TrackedEntityService here,
+  // so we can run all validations in this service instead of the controller.
   public void transferOwnership(
-      TrackedEntity trackedEntity, Program program, OrganisationUnit orgUnit)
-      throws ForbiddenException {
-    if (trackedEntity == null || program == null || orgUnit == null) {
-      return;
+      @Nonnull TrackedEntity trackedEntity, @Nonnull UID programUid, @Nonnull UID orgUnitUid)
+      throws ForbiddenException, BadRequestException, NotFoundException {
+    Program program = trackerProgramService.getTrackerProgram(programUid);
+    OrganisationUnit orgUnit = organisationUnitService.getOrganisationUnit(orgUnitUid.getValue());
+
+    if (orgUnit == null) {
+      throw new NotFoundException(
+          "Tracked entity not transferred. Org unit supplied does not exist.");
     }
 
-    UserDetails currentUser = CurrentUserUtil.getCurrentUserDetails();
-
-    if (hasAccess(currentUser, trackedEntity, program)) {
-      if (!programService.hasOrgUnit(program, orgUnit)) {
-        throw new ForbiddenException(
-            String.format(
-                "The program %s is not associated to the org unit %s",
-                program.getUid(), orgUnit.getUid()));
-      }
-
-      TrackedEntityProgramOwner teProgramOwner =
-          trackedEntityProgramOwnerService.getTrackedEntityProgramOwner(trackedEntity, program);
-
-      // TODO(tracker) jdbc-hibernate: check the impact on performance
-      TrackedEntity hibernateTrackedEntity =
-          manager.get(TrackedEntity.class, trackedEntity.getUid());
-      if (teProgramOwner != null && !teProgramOwner.getOrganisationUnit().equals(orgUnit)) {
-        ProgramOwnershipHistory programOwnershipHistory =
-            new ProgramOwnershipHistory(
-                program,
-                hibernateTrackedEntity,
-                teProgramOwner.getOrganisationUnit(),
-                teProgramOwner.getLastUpdated(),
-                teProgramOwner.getCreatedBy());
-        programOwnershipHistoryService.addProgramOwnershipHistory(programOwnershipHistory);
-        trackedEntityProgramOwnerService.updateTrackedEntityProgramOwner(
-            hibernateTrackedEntity, program, orgUnit);
-      }
-
-      ownerCache.invalidate(getOwnershipCacheKey(trackedEntity, program));
-    } else {
+    if (!hasAccess(getCurrentUserDetails(), trackedEntity, program)) {
       log.error("Unauthorized attempt to change ownership");
       throw new ForbiddenException(
-          "User does not have access to change ownership for the entity-program combination");
+          "Tracked entity not transferred. User does not have access to change ownership for the entity-program combination");
+    }
+
+    if (!programService.hasOrgUnit(program, orgUnit)) {
+      throw new ForbiddenException(
+          String.format(
+              "Tracked entity not transferred. The program %s is not associated to the org unit %s",
+              program.getUid(), orgUnit.getUid()));
+    }
+
+    // TODO(tracker) jdbc-hibernate: check the impact on performance
+    TrackedEntity hibernateTrackedEntity = manager.get(TrackedEntity.class, trackedEntity.getUid());
+    TrackedEntityProgramOwner teProgramOwner =
+        trackedEntityProgramOwnerService.getTrackedEntityProgramOwner(trackedEntity, program);
+    // TODO(tracker) As soon as we use the trackedEntityService in this method, remove this
+    // validation, as that's already validated in the query when fetching a trackedEntity with a
+    // program
+    if (teProgramOwner == null) {
+      throw new BadRequestException(
+          String.format(
+              "Tracked entity not transferred. No owner found for the combination of tracked entity %s and program %s",
+              trackedEntity.getUid(), programUid));
+    }
+
+    if (teProgramOwner.getOrganisationUnit().equals(orgUnit)) {
+      throw new BadRequestException(
+          String.format(
+              "Tracked entity not transferred. The owner of the tracked entity %s and program %s is already %s",
+              trackedEntity.getUid(), programUid, orgUnitUid));
     }
+
+    ProgramOwnershipHistory programOwnershipHistory =
+        new ProgramOwnershipHistory(
+            program,
+            hibernateTrackedEntity,
+            teProgramOwner.getOrganisationUnit(),
+            teProgramOwner.getLastUpdated(),
+            teProgramOwner.getCreatedBy());
+    programOwnershipHistoryService.addProgramOwnershipHistory(programOwnershipHistory);
+    trackedEntityProgramOwnerService.updateTrackedEntityProgramOwner(
+        hibernateTrackedEntity, program, orgUnit);
+    ownerCache.invalidate(getOwnershipCacheKey(trackedEntity, program));
   }
 
   @Override
   @Transactional
   public void grantTemporaryOwnership(
-      @Nonnull TrackedEntity trackedEntity, Program program, UserDetails user, String reason)
-      throws ForbiddenException {
-    validateGrantTemporaryOwnershipInputs(trackedEntity, program, user);
+      @Nonnull UID trackedEntityUid, @Nonnull UID programUid, String reason)
+      throws ForbiddenException, BadRequestException, NotFoundException {
+    UserDetails user = getCurrentUserDetails();
+    // TODO(tracker) jdbc-hibernate: check the impact on performance
+    TrackedEntity trackedEntity = manager.get(TrackedEntity.class, trackedEntityUid.getValue());
+    Program program = trackerProgramService.getTrackerProgram(programUid);
+
+    validateTrackedEntity(trackedEntity, user);
+    validateProgram(program, trackedEntity);
+    validateUser(trackedEntity, program, user);
 
     if (trackedEntity.getTrackedEntityType().isAllowAuditLog()) {
       programTempOwnershipAuditService.addProgramTempOwnershipAudit(
@@ -178,27 +211,22 @@ public void grantTemporaryOwnership(
         getTempOwnershipCacheKey(trackedEntity.getUid(), program.getUid(), user.getUid()));
   }
 
-  private void validateGrantTemporaryOwnershipInputs(
-      TrackedEntity trackedEntity, Program program, UserDetails user) throws ForbiddenException {
-    if (program == null) {
-      throw new ForbiddenException(
-          "Temporary ownership not created. Program supplied does not exist.");
-    }
-
+  private void validateTrackedEntity(TrackedEntity trackedEntity, UserDetails user)
+      throws ForbiddenException, NotFoundException {
     if (trackedEntity == null) {
-      throw new ForbiddenException(
+      throw new NotFoundException(
           "Temporary ownership not created. Tracked entity supplied does not exist.");
     }
 
-    if (user.isSuper()) {
-      throw new ForbiddenException("Temporary ownership not created. Current user is a superuser.");
-    }
-
-    if (ProgramType.WITHOUT_REGISTRATION == program.getProgramType()) {
+    if (!aclService.canDataRead(user, trackedEntity.getTrackedEntityType())) {
       throw new ForbiddenException(
-          "Temporary ownership not created. Program supplied is not a tracker program.");
+          "Temporary ownership not created. User has no data read access to tracked entity type: "
+              + trackedEntity.getTrackedEntityType().getUid());
     }
+  }
 
+  private void validateProgram(Program program, TrackedEntity trackedEntity)
+      throws ForbiddenException {
     if (!program.isProtected()) {
       throw new ForbiddenException(
           String.format(
@@ -206,23 +234,6 @@ private void validateGrantTemporaryOwnershipInputs(
               program.getUid(), program.getAccessLevel().name()));
     }
 
-    if (!isOwnerInUserSearchScope(user, trackedEntity, program)) {
-      throw new ForbiddenException(
-          "Temporary ownership not created. The owner of the entity-program combination is not in the user's search scope.");
-    }
-
-    if (!aclService.canDataRead(user, program)) {
-      throw new ForbiddenException(
-          "Temporary ownership not created. User has no data read access to program: "
-              + program.getUid());
-    }
-
-    if (!aclService.canDataRead(user, trackedEntity.getTrackedEntityType())) {
-      throw new ForbiddenException(
-          "Temporary ownership not created. User has no data read access to tracked entity type: "
-              + trackedEntity.getTrackedEntityType().getUid());
-    }
-
     if (!Objects.equals(
         program.getTrackedEntityType().getUid(), trackedEntity.getTrackedEntityType().getUid())) {
       throw new ForbiddenException(
@@ -233,6 +244,21 @@ private void validateGrantTemporaryOwnershipInputs(
     }
   }
 
+  private void validateUser(TrackedEntity trackedEntity, Program program, UserDetails user)
+      throws ForbiddenException {
+    if (user.isSuper()) {
+      throw new ForbiddenException("Temporary ownership not created. Current user is a superuser.");
+    }
+
+    if (!isOwnerInUserSearchScope(user, trackedEntity, program)) {
+      throw new ForbiddenException(
+          "Temporary ownership not created. The owner of the entity-program combination is not in the user's search scope.");
+    }
+  }
+
+  // -------------------------------------------------------------------------
+  // Implementation
+  // -------------------------------------------------------------------------
   @Override
   @Transactional(readOnly = true)
   public boolean hasAccess(UserDetails user, TrackedEntity trackedEntity, Program program) {
diff --git a/dhis-2/dhis-services/dhis-service-tracker/src/main/java/org/hisp/dhis/tracker/acl/TrackedEntityProgramOwnerService.java b/dhis-2/dhis-services/dhis-service-tracker/src/main/java/org/hisp/dhis/tracker/acl/TrackedEntityProgramOwnerService.java
index b3c5628aa433..2b592c3b1498 100644
--- a/dhis-2/dhis-services/dhis-service-tracker/src/main/java/org/hisp/dhis/tracker/acl/TrackedEntityProgramOwnerService.java
+++ b/dhis-2/dhis-services/dhis-service-tracker/src/main/java/org/hisp/dhis/tracker/acl/TrackedEntityProgramOwnerService.java
@@ -29,6 +29,8 @@
  */
 package org.hisp.dhis.tracker.acl;
 
+import javax.annotation.Nonnull;
+import org.hisp.dhis.feedback.BadRequestException;
 import org.hisp.dhis.organisationunit.OrganisationUnit;
 import org.hisp.dhis.program.Program;
 import org.hisp.dhis.trackedentity.TrackedEntity;
@@ -59,7 +61,10 @@ void createOrUpdateTrackedEntityProgramOwner(
    * then this method will fail.
    */
   void updateTrackedEntityProgramOwner(
-      TrackedEntity trackedEntity, Program program, OrganisationUnit orgUnit);
+      @Nonnull TrackedEntity trackedEntity,
+      @Nonnull Program program,
+      @Nonnull OrganisationUnit orgUnit)
+      throws BadRequestException;
 
   /**
    * Create a new program owner ou for a tracked entity. If an owner previously exist, then this
diff --git a/dhis-2/dhis-services/dhis-service-tracker/src/main/java/org/hisp/dhis/tracker/acl/TrackerOwnershipManager.java b/dhis-2/dhis-services/dhis-service-tracker/src/main/java/org/hisp/dhis/tracker/acl/TrackerOwnershipManager.java
index 5b874d1320ee..99eebed4a3a6 100644
--- a/dhis-2/dhis-services/dhis-service-tracker/src/main/java/org/hisp/dhis/tracker/acl/TrackerOwnershipManager.java
+++ b/dhis-2/dhis-services/dhis-service-tracker/src/main/java/org/hisp/dhis/tracker/acl/TrackerOwnershipManager.java
@@ -29,7 +29,11 @@
  */
 package org.hisp.dhis.tracker.acl;
 
+import javax.annotation.Nonnull;
+import org.hisp.dhis.common.UID;
+import org.hisp.dhis.feedback.BadRequestException;
 import org.hisp.dhis.feedback.ForbiddenException;
+import org.hisp.dhis.feedback.NotFoundException;
 import org.hisp.dhis.organisationunit.OrganisationUnit;
 import org.hisp.dhis.program.Program;
 import org.hisp.dhis.program.ProgramType;
@@ -41,18 +45,23 @@
  */
 public interface TrackerOwnershipManager {
   String OWNERSHIP_ACCESS_DENIED = "OWNERSHIP_ACCESS_DENIED";
-
-  String PROGRAM_ACCESS_CLOSED = "PROGRAM_ACCESS_CLOSED";
-
   String NO_READ_ACCESS_TO_ORG_UNIT = "User has no read access to organisation unit";
 
+  /** Transfers the ownership of the given TE - program pair, to the specified org unit. */
+  void transferOwnership(
+      @Nonnull TrackedEntity trackedEntity, @Nonnull UID programUid, @Nonnull UID orgUnitUid)
+      throws ForbiddenException, BadRequestException, NotFoundException;
+
   /**
-   * @param trackedEntity The tracked entity object
-   * @param program The program object
-   * @param orgUnit The org unit that has to become the owner
+   * Grant temporary ownership for a user for a specific tracked entity - program combination
+   *
+   * @param trackedEntityUid The UID of the tracked entity object
+   * @param programUid The UID of the program object
+   * @param reason The reason for requesting temporary ownership
    */
-  void transferOwnership(TrackedEntity trackedEntity, Program program, OrganisationUnit orgUnit)
-      throws ForbiddenException;
+  void grantTemporaryOwnership(
+      @Nonnull UID trackedEntityUid, @Nonnull UID programUid, String reason)
+      throws ForbiddenException, BadRequestException, NotFoundException;
 
   /**
    * Check whether the user has access (as owner or has temporarily broken the glass) to the tracked
@@ -68,18 +77,6 @@ void transferOwnership(TrackedEntity trackedEntity, Program program, Organisatio
   boolean hasAccess(
       UserDetails user, String trackedEntity, OrganisationUnit organisationUnit, Program program);
 
-  /**
-   * Grant temporary ownership for a user for a specific tracked entity - program combination
-   *
-   * @param trackedEntity The tracked entity object
-   * @param program The program object
-   * @param user The user for which temporary access is granted.
-   * @param reason The reason for requesting temporary ownership
-   */
-  void grantTemporaryOwnership(
-      TrackedEntity trackedEntity, Program program, UserDetails user, String reason)
-      throws ForbiddenException;
-
   /**
    * Ownership check can be skipped if the user is superuser or if the program type is without
    * registration.
diff --git a/dhis-2/dhis-services/dhis-service-tracker/src/main/java/org/hisp/dhis/tracker/acl/TrackerProgramService.java b/dhis-2/dhis-services/dhis-service-tracker/src/main/java/org/hisp/dhis/tracker/acl/TrackerProgramService.java
index 7c28d30dedda..6feaa2360a1b 100644
--- a/dhis-2/dhis-services/dhis-service-tracker/src/main/java/org/hisp/dhis/tracker/acl/TrackerProgramService.java
+++ b/dhis-2/dhis-services/dhis-service-tracker/src/main/java/org/hisp/dhis/tracker/acl/TrackerProgramService.java
@@ -29,17 +29,22 @@
  */
 package org.hisp.dhis.tracker.acl;
 
+import static org.hisp.dhis.user.CurrentUserUtil.getCurrentUserDetails;
+
 import java.util.List;
 import java.util.Objects;
 import javax.annotation.Nonnull;
 import lombok.RequiredArgsConstructor;
+import org.hisp.dhis.common.UID;
+import org.hisp.dhis.feedback.BadRequestException;
+import org.hisp.dhis.feedback.ForbiddenException;
 import org.hisp.dhis.program.Program;
 import org.hisp.dhis.program.ProgramService;
 import org.hisp.dhis.security.acl.AclService;
 import org.hisp.dhis.trackedentity.TrackedEntityType;
-import org.hisp.dhis.user.CurrentUserUtil;
 import org.hisp.dhis.user.UserDetails;
 import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
 
 /**
  * Service for fetching tracker programs (i.e., programs that require registration). ACL validations
@@ -52,9 +57,35 @@ public class TrackerProgramService {
   @Nonnull private final ProgramService programService;
   @Nonnull private final AclService aclService;
 
+  /**
+   * Returns the tracker program associated with the provided UID if it exists and is accessible to
+   * the current user.
+   */
+  @Transactional(readOnly = true)
+  public @Nonnull Program getTrackerProgram(@Nonnull UID programUid)
+      throws BadRequestException, ForbiddenException {
+    Program program = programService.getProgram(programUid.getValue());
+    if (program == null) {
+      throw new BadRequestException(
+          String.format("Provided program, %s, does not exist.", programUid));
+    }
+    if (program.isWithoutRegistration()) {
+      throw new BadRequestException(
+          String.format("Provided program, %s, is not a tracker program.", programUid));
+    }
+    if (!aclService.canDataRead(getCurrentUserDetails(), program)) {
+      throw new ForbiddenException(
+          String.format(
+              "Current user doesn't have access to the provided program %s.", programUid));
+    }
+
+    return program;
+  }
+
   /** Retrieves the list of tracker programs accessible to the current user. */
+  @Transactional(readOnly = true)
   public @Nonnull List<Program> getAccessibleTrackerPrograms() {
-    UserDetails user = CurrentUserUtil.getCurrentUserDetails();
+    UserDetails user = getCurrentUserDetails();
 
     return programService.getAllPrograms().stream()
         .filter(p -> p.isRegistration() && aclService.canDataRead(user, p))
@@ -65,9 +96,10 @@ public class TrackerProgramService {
    * Retrieves the list of tracker programs accessible to the current user that match the given
    * tracked entity type. It is assumed that the user has access to the supplied trackedEntityType.
    */
+  @Transactional(readOnly = true)
   public @Nonnull List<Program> getAccessibleTrackerPrograms(
       @Nonnull TrackedEntityType trackedEntityType) {
-    UserDetails user = CurrentUserUtil.getCurrentUserDetails();
+    UserDetails user = getCurrentUserDetails();
 
     return programService.getAllPrograms().stream()
         .filter(
diff --git a/dhis-2/dhis-services/dhis-service-tracker/src/main/java/org/hisp/dhis/tracker/export/enrollment/DefaultEnrollmentService.java b/dhis-2/dhis-services/dhis-service-tracker/src/main/java/org/hisp/dhis/tracker/export/enrollment/DefaultEnrollmentService.java
index a4d28117fb4e..6ca3353229d4 100644
--- a/dhis-2/dhis-services/dhis-service-tracker/src/main/java/org/hisp/dhis/tracker/export/enrollment/DefaultEnrollmentService.java
+++ b/dhis-2/dhis-services/dhis-service-tracker/src/main/java/org/hisp/dhis/tracker/export/enrollment/DefaultEnrollmentService.java
@@ -74,7 +74,7 @@ class DefaultEnrollmentService implements EnrollmentService {
 
   private final RelationshipService relationshipService;
 
-  private final TrackerOwnershipManager trackerOwnershipAccessManager;
+  private final TrackerOwnershipManager trackerOwnershipManager;
 
   private final TrackedEntityAttributeService trackedEntityAttributeService;
 
@@ -265,7 +265,7 @@ private List<Enrollment> findEnrollments(
     for (Enrollment enrollment : enrollments) {
       if (enrollment != null
           && (orgUnitMode == ALL
-              || trackerOwnershipAccessManager.hasAccess(
+              || trackerOwnershipManager.hasAccess(
                   currentUser, enrollment.getTrackedEntity(), enrollment.getProgram()))
           && trackerAccessManager.canRead(currentUser, enrollment, orgUnitMode == ALL).isEmpty()) {
         enrollmentList.add(getEnrollment(enrollment, params, includeDeleted));
diff --git a/dhis-2/dhis-services/dhis-service-tracker/src/test/java/org/hisp/dhis/tracker/acl/DefaultTrackerAccessManagerTest.java b/dhis-2/dhis-services/dhis-service-tracker/src/test/java/org/hisp/dhis/tracker/acl/DefaultTrackerAccessManagerTest.java
index 384528ed19b7..f7c367d3a94c 100644
--- a/dhis-2/dhis-services/dhis-service-tracker/src/test/java/org/hisp/dhis/tracker/acl/DefaultTrackerAccessManagerTest.java
+++ b/dhis-2/dhis-services/dhis-service-tracker/src/test/java/org/hisp/dhis/tracker/acl/DefaultTrackerAccessManagerTest.java
@@ -60,7 +60,7 @@ class DefaultTrackerAccessManagerTest {
   private User user;
 
   @BeforeEach
-  public void before() {
+  void before() {
     program = createProgram('A');
     orgUnit = createOrganisationUnit('A');
     user = new User();
diff --git a/dhis-2/dhis-services/dhis-service-tracker/src/test/java/org/hisp/dhis/tracker/acl/DefaultTrackerOwnershipManagerTest.java b/dhis-2/dhis-services/dhis-service-tracker/src/test/java/org/hisp/dhis/tracker/acl/DefaultTrackerOwnershipManagerTest.java
index 0521c004d5f3..4e9a140cfb26 100644
--- a/dhis-2/dhis-services/dhis-service-tracker/src/test/java/org/hisp/dhis/tracker/acl/DefaultTrackerOwnershipManagerTest.java
+++ b/dhis-2/dhis-services/dhis-service-tracker/src/test/java/org/hisp/dhis/tracker/acl/DefaultTrackerOwnershipManagerTest.java
@@ -34,6 +34,8 @@
 import static org.hisp.dhis.test.TestBase.createTrackedEntity;
 import static org.hisp.dhis.test.TestBase.createTrackedEntityType;
 import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyString;
+import static org.mockito.Mockito.mockStatic;
 import static org.mockito.Mockito.never;
 import static org.mockito.Mockito.times;
 import static org.mockito.Mockito.verify;
@@ -44,8 +46,12 @@
 import org.hisp.dhis.cache.CacheProvider;
 import org.hisp.dhis.common.AccessLevel;
 import org.hisp.dhis.common.IdentifiableObjectManager;
+import org.hisp.dhis.common.UID;
+import org.hisp.dhis.feedback.BadRequestException;
 import org.hisp.dhis.feedback.ForbiddenException;
+import org.hisp.dhis.feedback.NotFoundException;
 import org.hisp.dhis.organisationunit.OrganisationUnit;
+import org.hisp.dhis.organisationunit.OrganisationUnitService;
 import org.hisp.dhis.program.Program;
 import org.hisp.dhis.program.ProgramOwnershipHistoryService;
 import org.hisp.dhis.program.ProgramService;
@@ -55,14 +61,17 @@
 import org.hisp.dhis.security.acl.AclService;
 import org.hisp.dhis.trackedentity.TrackedEntity;
 import org.hisp.dhis.trackedentity.TrackedEntityType;
+import org.hisp.dhis.user.CurrentUserUtil;
 import org.hisp.dhis.user.User;
 import org.hisp.dhis.user.UserDetails;
 import org.hisp.dhis.user.UserService;
+import org.junit.jupiter.api.AfterEach;
 import org.junit.jupiter.api.BeforeEach;
 import org.junit.jupiter.api.Test;
 import org.junit.jupiter.api.extension.ExtendWith;
 import org.mockito.InjectMocks;
 import org.mockito.Mock;
+import org.mockito.MockedStatic;
 import org.mockito.junit.jupiter.MockitoExtension;
 
 @ExtendWith(MockitoExtension.class)
@@ -80,6 +89,10 @@ class DefaultTrackerOwnershipManagerTest {
 
   @Mock private ProgramService programService;
 
+  @Mock private TrackerProgramService trackerProgramService;
+
+  @Mock private OrganisationUnitService organisationUnitService;
+
   @Mock private IdentifiableObjectManager manager;
 
   @Mock private CacheProvider cacheProvider;
@@ -97,9 +110,10 @@ class DefaultTrackerOwnershipManagerTest {
   private String reason;
   private OrganisationUnit orgUnit;
   private TrackedEntityType trackedEntityType;
+  private MockedStatic<CurrentUserUtil> mockedStatic;
 
   @BeforeEach
-  void setUp() {
+  void setUp() throws BadRequestException, ForbiddenException {
     when(cacheProvider.createProgramOwnerCache()).thenReturn(ownerCache);
     when(cacheProvider.createProgramTempOwnerCache()).thenReturn(tempOwnerCache);
 
@@ -112,6 +126,8 @@ void setUp() {
             programTempOwnerService,
             programOwnershipHistoryService,
             programService,
+            trackerProgramService,
+            organisationUnitService,
             manager,
             aclService);
 
@@ -121,23 +137,32 @@ void setUp() {
     program.setAccessLevel(AccessLevel.PROTECTED);
     trackedEntityType = createTrackedEntityType('A');
     program.setTrackedEntityType(trackedEntityType);
+    when(trackerProgramService.getTrackerProgram(UID.of(program))).thenReturn(program);
+
     User user = new User();
     user.setTeiSearchOrganisationUnits(Set.of(orgUnit));
     userDetails = UserDetails.fromUser(user);
     reason = "breaking the glass";
 
-    when(ownerCache.get(any(), any())).thenReturn(orgUnit);
-    when(aclService.canDataRead(userDetails, program)).thenReturn(true);
+    mockedStatic = mockStatic(CurrentUserUtil.class);
+    mockedStatic.when(CurrentUserUtil::getCurrentUserDetails).thenReturn(userDetails);
+  }
+
+  @AfterEach
+  void tearDown() {
+    mockedStatic.close();
   }
 
   @Test
   void shouldLogProgramOwnershipChangeWhenTrackedEntityTypeAuditEnabled()
-      throws ForbiddenException {
+      throws ForbiddenException, BadRequestException, NotFoundException {
     TrackedEntity trackedEntity = createTrackedEntityWithAuditLog(true);
+    when(manager.get(TrackedEntity.class, trackedEntity.getUid())).thenReturn(trackedEntity);
+    when(ownerCache.get(anyString(), any())).thenReturn(orgUnit);
     when(aclService.canDataRead(userDetails, trackedEntity.getTrackedEntityType()))
         .thenReturn(true);
 
-    trackerOwnershipManager.grantTemporaryOwnership(trackedEntity, program, userDetails, reason);
+    trackerOwnershipManager.grantTemporaryOwnership(UID.of(trackedEntity), UID.of(program), reason);
 
     verify(programTempOwnershipAuditService, times(1))
         .addProgramTempOwnershipAudit(any(ProgramTempOwnershipAudit.class));
@@ -145,12 +170,14 @@ void shouldLogProgramOwnershipChangeWhenTrackedEntityTypeAuditEnabled()
 
   @Test
   void shouldNotLogProgramOwnershipChangeWhenTrackedEntityTypeAuditDisabled()
-      throws ForbiddenException {
+      throws ForbiddenException, BadRequestException, NotFoundException {
     TrackedEntity trackedEntity = createTrackedEntityWithAuditLog(false);
+    when(manager.get(TrackedEntity.class, trackedEntity.getUid())).thenReturn(trackedEntity);
+    when(ownerCache.get(anyString(), any())).thenReturn(orgUnit);
     when(aclService.canDataRead(userDetails, trackedEntity.getTrackedEntityType()))
         .thenReturn(true);
 
-    trackerOwnershipManager.grantTemporaryOwnership(trackedEntity, program, userDetails, reason);
+    trackerOwnershipManager.grantTemporaryOwnership(UID.of(trackedEntity), UID.of(program), reason);
 
     verify(programTempOwnershipAuditService, never())
         .addProgramTempOwnershipAudit(any(ProgramTempOwnershipAudit.class));
diff --git a/dhis-2/dhis-test-integration/src/test/java/org/hisp/dhis/tracker/acl/TrackedEntityProgramOwnerServiceTest.java b/dhis-2/dhis-test-integration/src/test/java/org/hisp/dhis/tracker/acl/TrackedEntityProgramOwnerServiceTest.java
index c7e16a1c78ea..93cf769165b3 100644
--- a/dhis-2/dhis-test-integration/src/test/java/org/hisp/dhis/tracker/acl/TrackedEntityProgramOwnerServiceTest.java
+++ b/dhis-2/dhis-test-integration/src/test/java/org/hisp/dhis/tracker/acl/TrackedEntityProgramOwnerServiceTest.java
@@ -29,11 +29,14 @@
  */
 package org.hisp.dhis.tracker.acl;
 
+import static org.hisp.dhis.test.utils.Assertions.assertStartsWith;
 import static org.junit.jupiter.api.Assertions.assertEquals;
 import static org.junit.jupiter.api.Assertions.assertNotNull;
 import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertThrows;
 
 import org.hisp.dhis.common.IdentifiableObjectManager;
+import org.hisp.dhis.feedback.BadRequestException;
 import org.hisp.dhis.organisationunit.OrganisationUnit;
 import org.hisp.dhis.organisationunit.OrganisationUnitService;
 import org.hisp.dhis.program.Program;
@@ -121,4 +124,30 @@ void testCreateOrUpdateTrackedEntityProgramOwner() {
     assertNotNull(programOwner);
     assertEquals(organisationUnitB.getUid(), programOwner.getOrganisationUnit().getUid());
   }
+
+  @Test
+  void shouldUpdateTrackedEntityOwnerIfExists() throws BadRequestException {
+    programOwnerService.createTrackedEntityProgramOwner(
+        trackedEntityA1, programA, organisationUnitA);
+    programOwnerService.updateTrackedEntityProgramOwner(
+        trackedEntityA1, programA, organisationUnitA);
+
+    assertEquals(
+        organisationUnitA.getUid(),
+        programOwnerService
+            .getTrackedEntityProgramOwner(trackedEntityA1, programA)
+            .getOrganisationUnit()
+            .getUid());
+  }
+
+  @Test
+  void shouldFailToUpdateTrackedEntityOwnerIfNotExists() {
+    Exception exception =
+        assertThrows(
+            BadRequestException.class,
+            () ->
+                programOwnerService.updateTrackedEntityProgramOwner(
+                    trackedEntityA1, programA, organisationUnitA));
+    assertStartsWith("Tracked entity not transferred. No owner found", exception.getMessage());
+  }
 }
diff --git a/dhis-2/dhis-test-integration/src/test/java/org/hisp/dhis/tracker/acl/TrackerAccessManagerTest.java b/dhis-2/dhis-test-integration/src/test/java/org/hisp/dhis/tracker/acl/TrackerAccessManagerTest.java
index e05d6cce1d71..346d19eaf2ee 100644
--- a/dhis-2/dhis-test-integration/src/test/java/org/hisp/dhis/tracker/acl/TrackerAccessManagerTest.java
+++ b/dhis-2/dhis-test-integration/src/test/java/org/hisp/dhis/tracker/acl/TrackerAccessManagerTest.java
@@ -51,6 +51,7 @@
 import org.hisp.dhis.common.ValueType;
 import org.hisp.dhis.dataelement.DataElement;
 import org.hisp.dhis.event.EventStatus;
+import org.hisp.dhis.feedback.BadRequestException;
 import org.hisp.dhis.feedback.ForbiddenException;
 import org.hisp.dhis.feedback.NotFoundException;
 import org.hisp.dhis.organisationunit.OrganisationUnit;
@@ -172,6 +173,9 @@ void setUp() {
     trackedEntityA.getEnrollments().add(enrollmentA);
     manager.update(trackedEntityA);
 
+    trackedEntityProgramOwnerService.createTrackedEntityProgramOwner(
+        trackedEntityA, programA, orgUnitA);
+
     eventA = new Event();
     eventA.setEnrollment(enrollmentA);
     eventA.setProgramStage(programStageA);
@@ -192,6 +196,7 @@ void setUp() {
 
     User adminUser = getAdminUser();
     adminUser.setTeiSearchOrganisationUnits(Set.of(orgUnitA));
+    adminUser.setOrganisationUnits(Set.of(orgUnitA));
     injectSecurityContextUser(adminUser);
   }
 
@@ -247,7 +252,8 @@ void checkAccessPermissionForTeWhenTeOuOutsideSearchScope()
   }
 
   @Test
-  void checkAccessPermissionForEnrollmentInClosedProgram() throws ForbiddenException {
+  void checkAccessPermissionForEnrollmentInClosedProgram()
+      throws ForbiddenException, BadRequestException, NotFoundException {
     programA.setPublicAccess(AccessStringHelper.FULL);
     manager.update(programA);
     trackedEntityType.setPublicAccess(AccessStringHelper.FULL);
@@ -274,9 +280,7 @@ void checkAccessPermissionForEnrollmentInClosedProgram() throws ForbiddenExcepti
         "User has no create access to organisation unit:");
     enrollment.setOrganisationUnit(orgUnitA);
     // Transferring ownership to orgUnitB. user is no longer owner
-    trackedEntityProgramOwnerService.createTrackedEntityProgramOwner(
-        trackedEntity, programA, orgUnitA);
-    trackerOwnershipManager.transferOwnership(trackedEntity, programA, orgUnitB);
+    trackerOwnershipManager.transferOwnership(trackedEntity, UID.of(programA), UID.of(orgUnitB));
     // Cannot create enrollment if not owner
     assertHasError(
         trackerAccessManager.canCreate(userDetails, enrollment, false), "OWNERSHIP_ACCESS_DENIED");
@@ -316,7 +320,8 @@ void checkAccessPermissionForEnrollmentWhenOrgUnitIsNull() {
   }
 
   @Test
-  void checkAccessPermissionForEnrollmentInOpenProgram() throws ForbiddenException {
+  void checkAccessPermissionForEnrollmentInOpenProgram()
+      throws ForbiddenException, BadRequestException, NotFoundException {
     programA.setPublicAccess(AccessStringHelper.FULL);
     programA.setAccessLevel(AccessLevel.OPEN);
     manager.update(programA);
@@ -337,7 +342,7 @@ void checkAccessPermissionForEnrollmentInOpenProgram() throws ForbiddenException
     // Can read enrollment if ownerOU falls inside search scope
     assertNoErrors(trackerAccessManager.canRead(userDetails, enrollment, false));
     // Transferring ownership to orgUnitB. user is now owner
-    trackerOwnershipManager.transferOwnership(trackedEntity, programA, orgUnitB);
+    trackerOwnershipManager.transferOwnership(trackedEntity, UID.of(programA), UID.of(orgUnitB));
     // Cannot create enrollment if enrollmentOU falls outside capture scope,
     // even if user is owner
     assertHasError(
@@ -350,7 +355,10 @@ void checkAccessPermissionForEnrollmentInOpenProgram() throws ForbiddenException
     // Can read enrollment
     assertNoErrors(trackerAccessManager.canRead(userDetails, enrollment, false));
     // Transferring ownership to orgUnitB. user is now owner
-    trackerOwnershipManager.transferOwnership(trackedEntity, programA, orgUnitA);
+    User adminUser = getAdminUser();
+    adminUser.setOrganisationUnits(Set.of(orgUnitB));
+    injectSecurityContextUser(adminUser);
+    trackerOwnershipManager.transferOwnership(trackedEntity, UID.of(programA), UID.of(orgUnitA));
     user.setTeiSearchOrganisationUnits(Sets.newHashSet(orgUnitA, orgUnitB));
     // Cannot create enrollment if enrollment OU is outside capture scope
     assertHasError(
@@ -365,7 +373,8 @@ void checkAccessPermissionForEnrollmentInOpenProgram() throws ForbiddenException
   }
 
   @Test
-  void checkAccessPermissionsForEventInClosedProgram() throws ForbiddenException {
+  void checkAccessPermissionsForEventInClosedProgram()
+      throws ForbiddenException, BadRequestException, NotFoundException {
     programA.setPublicAccess(AccessStringHelper.FULL);
     programStageA.setPublicAccess(AccessStringHelper.FULL);
     programStageB.setPublicAccess(AccessStringHelper.FULL);
@@ -392,9 +401,7 @@ void checkAccessPermissionsForEventInClosedProgram() throws ForbiddenException {
     assertNoErrors(trackerAccessManager.canUpdate(userDetails, eventB, false));
     // Can delete events if user is owner irrespective of eventOU
     assertNoErrors(trackerAccessManager.canDelete(userDetails, eventB, false));
-    trackedEntityProgramOwnerService.createTrackedEntityProgramOwner(
-        trackedEntityA, programA, orgUnitA);
-    trackerOwnershipManager.transferOwnership(trackedEntityA, programA, orgUnitB);
+    trackerOwnershipManager.transferOwnership(trackedEntityA, UID.of(programA), UID.of(orgUnitB));
     // Cannot create events anywhere if user is not owner
     assertHasErrors(2, trackerAccessManager.canCreate(userDetails, eventB, false));
     // Cannot read events if user is not owner (OwnerOU falls into capture
@@ -412,7 +419,8 @@ void checkAccessPermissionsForEventInClosedProgram() throws ForbiddenException {
   }
 
   @Test
-  void checkAccessPermissionsForEventInOpenProgram() throws ForbiddenException {
+  void checkAccessPermissionsForEventInOpenProgram()
+      throws ForbiddenException, BadRequestException, NotFoundException {
     programA.setPublicAccess(AccessStringHelper.FULL);
     programA.setAccessLevel(AccessLevel.OPEN);
     programStageA.setPublicAccess(AccessStringHelper.FULL);
@@ -436,7 +444,7 @@ void checkAccessPermissionsForEventInOpenProgram() throws ForbiddenException {
     assertNoErrors(trackerAccessManager.canUpdate(userDetails, eventA, false));
     // Can delete events if ownerOu falls into users search scope
     assertNoErrors(trackerAccessManager.canDelete(userDetails, eventA, false));
-    trackerOwnershipManager.transferOwnership(trackedEntityA, programA, orgUnitB);
+    trackerOwnershipManager.transferOwnership(trackedEntityA, UID.of(programA), UID.of(orgUnitB));
     // Cannot create events with eventOu outside capture scope, even if
     // ownerOu is
     // also in capture scope
diff --git a/dhis-2/dhis-test-integration/src/test/java/org/hisp/dhis/tracker/acl/TrackerProgramServiceTest.java b/dhis-2/dhis-test-integration/src/test/java/org/hisp/dhis/tracker/acl/TrackerProgramServiceTest.java
index 0a325674972c..9df40e2c0da8 100644
--- a/dhis-2/dhis-test-integration/src/test/java/org/hisp/dhis/tracker/acl/TrackerProgramServiceTest.java
+++ b/dhis-2/dhis-test-integration/src/test/java/org/hisp/dhis/tracker/acl/TrackerProgramServiceTest.java
@@ -32,12 +32,17 @@
 import static org.hisp.dhis.common.IdentifiableObjectUtils.getUids;
 import static org.hisp.dhis.test.utils.Assertions.assertContainsOnly;
 import static org.hisp.dhis.test.utils.Assertions.assertIsEmpty;
+import static org.hisp.dhis.test.utils.Assertions.assertStartsWith;
 import static org.junit.jupiter.api.Assertions.assertEquals;
 import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertThrows;
 
 import java.io.IOException;
 import java.util.List;
 import org.hisp.dhis.common.IdentifiableObjectManager;
+import org.hisp.dhis.common.UID;
+import org.hisp.dhis.feedback.BadRequestException;
+import org.hisp.dhis.feedback.ForbiddenException;
 import org.hisp.dhis.program.Program;
 import org.hisp.dhis.security.acl.AccessStringHelper;
 import org.hisp.dhis.test.integration.PostgresIntegrationTestBase;
@@ -103,6 +108,48 @@ void shouldReturnEmptyListWhenNoProgramAccessible() {
     assertIsEmpty(trackerProgramService.getAccessibleTrackerPrograms(trackedEntityType));
   }
 
+  @Test
+  void shouldReturnSingleProgramWhenTrackerProgramAccessible()
+      throws ForbiddenException, BadRequestException {
+    assertEquals(
+        "TsngICFQjvP", trackerProgramService.getTrackerProgram(UID.of("TsngICFQjvP")).getUid());
+  }
+
+  @Test
+  void shouldFailWhenRequestingSingleProgramThatDoesNotExist() {
+    UID madeUpUid = UID.generate();
+
+    Exception exception =
+        assertThrows(
+            BadRequestException.class, () -> trackerProgramService.getTrackerProgram(madeUpUid));
+    assertEquals("Provided program, " + madeUpUid + ", does not exist.", exception.getMessage());
+  }
+
+  @Test
+  void shouldFailWhenRequestingSingleProgramThatIsNotATrackerProgram() {
+    UID eventProgramUid = UID.of("BFcipDERJne");
+
+    Exception exception =
+        assertThrows(
+            BadRequestException.class,
+            () -> trackerProgramService.getTrackerProgram(eventProgramUid));
+    assertEquals(
+        "Provided program, " + eventProgramUid.getValue() + ", is not a tracker program.",
+        exception.getMessage());
+  }
+
+  @Test
+  void shouldFailWhenRequestingSingleProgramThatIsNotAccessible() {
+    makeProgramInaccessible("TsngICFQjvP");
+
+    Exception exception =
+        assertThrows(
+            ForbiddenException.class,
+            () -> trackerProgramService.getTrackerProgram(UID.of("TsngICFQjvP")));
+    assertStartsWith(
+        "Current user doesn't have access to the provided program", exception.getMessage());
+  }
+
   private void makeProgramInaccessible(String uid) {
     User admin = getAdminUser();
     injectSecurityContextUser(admin);
diff --git a/dhis-2/dhis-test-integration/src/test/java/org/hisp/dhis/tracker/export/relationship/RelationshipServiceTest.java b/dhis-2/dhis-test-integration/src/test/java/org/hisp/dhis/tracker/export/relationship/RelationshipServiceTest.java
index 06b584fccb1c..226cbd2b7e04 100644
--- a/dhis-2/dhis-test-integration/src/test/java/org/hisp/dhis/tracker/export/relationship/RelationshipServiceTest.java
+++ b/dhis-2/dhis-test-integration/src/test/java/org/hisp/dhis/tracker/export/relationship/RelationshipServiceTest.java
@@ -58,7 +58,6 @@
 import org.hisp.dhis.trackedentity.TrackedEntity;
 import org.hisp.dhis.trackedentity.TrackedEntityType;
 import org.hisp.dhis.tracker.acl.TrackedEntityProgramOwnerService;
-import org.hisp.dhis.tracker.acl.TrackerOwnershipManager;
 import org.hisp.dhis.user.User;
 import org.hisp.dhis.user.UserService;
 import org.junit.jupiter.api.BeforeAll;
@@ -77,12 +76,8 @@ class RelationshipServiceTest extends PostgresIntegrationTestBase {
 
   @Autowired private IdentifiableObjectManager manager;
 
-  @Autowired private TrackerOwnershipManager trackerOwnershipAccessManager;
-
   @Autowired private TrackedEntityProgramOwnerService trackedEntityProgramOwnerService;
 
-  private Date enrollmentDate;
-
   private TrackedEntity teA;
 
   private TrackedEntity teB;
@@ -113,15 +108,13 @@ class RelationshipServiceTest extends PostgresIntegrationTestBase {
 
   private User user;
 
-  private Program program;
-
   private ProgramStage programStage;
 
   private TrackedEntityType trackedEntityType;
 
   @BeforeAll
   void setUp() {
-    enrollmentDate = new Date();
+    Date enrollmentDate = new Date();
 
     orgUnitA = createOrganisationUnit('A');
     manager.save(orgUnitA, false);
@@ -147,7 +140,7 @@ void setUp() {
     inaccessibleTe = createTrackedEntity(orgUnitA, inaccessibleTrackedEntityType);
     manager.save(inaccessibleTe, false);
 
-    program = createProgram('A', new HashSet<>(), orgUnitA);
+    Program program = createProgram('A', new HashSet<>(), orgUnitA);
     program.setProgramType(ProgramType.WITH_REGISTRATION);
     program.setTrackedEntityType(trackedEntityType);
     manager.save(program, false);
@@ -281,7 +274,7 @@ void shouldNotReturnRelationshipByEventIfUserHasNoAccessToProgramStage()
 
   @Test
   void shouldNotReturnRelationshipWhenTeIsTransferredAndUserHasNoAccessToAtLeastOneProgram()
-      throws ForbiddenException {
+      throws BadRequestException {
     injectAdminIntoSecurityContext();
 
     TrackedEntityType trackedEntityType = createTrackedEntityType('X');
@@ -301,7 +294,8 @@ void shouldNotReturnRelationshipWhenTeIsTransferredAndUserHasNoAccessToAtLeastOn
     trackedEntityProgramOwnerService.createTrackedEntityProgramOwner(
         trackedEntityFrom, program, orgUnitA);
 
-    trackerOwnershipAccessManager.transferOwnership(trackedEntityFrom, program, orgUnitB);
+    trackedEntityProgramOwnerService.updateTrackedEntityProgramOwner(
+        trackedEntityFrom, program, orgUnitB);
 
     TrackedEntity trackedEntityTo = createTrackedEntity(orgUnitA, trackedEntityType);
     manager.save(trackedEntityTo);
diff --git a/dhis-2/dhis-test-integration/src/test/java/org/hisp/dhis/tracker/imports/bundle/OwnershipTest.java b/dhis-2/dhis-test-integration/src/test/java/org/hisp/dhis/tracker/imports/bundle/OwnershipTest.java
index 62cfad9826e1..c23e8e63d805 100644
--- a/dhis-2/dhis-test-integration/src/test/java/org/hisp/dhis/tracker/imports/bundle/OwnershipTest.java
+++ b/dhis-2/dhis-test-integration/src/test/java/org/hisp/dhis/tracker/imports/bundle/OwnershipTest.java
@@ -44,12 +44,12 @@
 import java.util.Set;
 import org.hisp.dhis.common.IdentifiableObjectManager;
 import org.hisp.dhis.common.UID;
+import org.hisp.dhis.feedback.BadRequestException;
 import org.hisp.dhis.feedback.ForbiddenException;
-import org.hisp.dhis.organisationunit.OrganisationUnit;
+import org.hisp.dhis.feedback.NotFoundException;
 import org.hisp.dhis.program.Enrollment;
 import org.hisp.dhis.program.EnrollmentStatus;
 import org.hisp.dhis.program.Event;
-import org.hisp.dhis.program.Program;
 import org.hisp.dhis.test.integration.PostgresIntegrationTestBase;
 import org.hisp.dhis.trackedentity.TrackedEntity;
 import org.hisp.dhis.trackedentity.TrackedEntityProgramOwner;
@@ -237,7 +237,8 @@ void testCreateEnrollmentAfterDeleteEnrollment() throws IOException {
   }
 
   @Test
-  void testCreateEnrollmentWithoutOwnership() throws IOException, ForbiddenException {
+  void testCreateEnrollmentWithoutOwnership()
+      throws IOException, ForbiddenException, BadRequestException, NotFoundException {
     injectSecurityContextUser(userService.getUser(nonSuperUser.getUid()));
     TrackerImportParams params = TrackerImportParams.builder().build();
     TrackerObjects trackerObjects = testSetup.fromJson("tracker/ownership_enrollment.json");
@@ -247,10 +248,10 @@ void testCreateEnrollmentWithoutOwnership() throws IOException, ForbiddenExcepti
     ImportReport updatedReport = trackerImportService.importTracker(params, trackerObjects);
     assertNoErrors(updatedReport);
     assertEquals(1, updatedReport.getStats().getDeleted());
-    TrackedEntity trackedEntity = manager.get(TrackedEntity.class, "IOR1AXXl24H");
-    OrganisationUnit ou = manager.get(OrganisationUnit.class, "B1nCbRV3qtP");
-    Program pgm = manager.get(Program.class, "BFcipDERJnf");
-    trackerOwnershipManager.transferOwnership(trackedEntity, pgm, ou);
+    trackerOwnershipManager.transferOwnership(
+        manager.get(TrackedEntity.class, "IOR1AXXl24H"),
+        UID.of("BFcipDERJnf"),
+        UID.of("B1nCbRV3qtP"));
     params.setImportStrategy(TrackerImportStrategy.CREATE);
     trackerObjects.getEnrollments().get(0).setEnrollment(UID.generate());
     updatedReport = trackerImportService.importTracker(params, trackerObjects);
@@ -274,14 +275,15 @@ void shouldFailWhenCreatingTEAndEnrollmentAndUserHasNoAccessToEnrollmentOU() thr
   }
 
   @Test
-  void testDeleteEnrollmentWithoutOwnership() throws IOException, ForbiddenException {
+  void testDeleteEnrollmentWithoutOwnership()
+      throws IOException, ForbiddenException, BadRequestException, NotFoundException {
     // Change ownership
-    TrackedEntity trackedEntity = manager.get(TrackedEntity.class, "IOR1AXXl24H");
-    OrganisationUnit ou = manager.get(OrganisationUnit.class, "B1nCbRV3qtP");
-    Program pgm = manager.get(Program.class, "BFcipDERJnf");
     TrackerImportParams params = TrackerImportParams.builder().build();
     TrackerObjects trackerObjects = testSetup.fromJson("tracker/ownership_enrollment.json");
-    trackerOwnershipManager.transferOwnership(trackedEntity, pgm, ou);
+    trackerOwnershipManager.transferOwnership(
+        manager.get(TrackedEntity.class, "IOR1AXXl24H"),
+        UID.of("BFcipDERJnf"),
+        UID.of("B1nCbRV3qtP"));
     params.setImportStrategy(TrackerImportStrategy.DELETE);
     ImportReport updatedReport = trackerImportService.importTracker(params, trackerObjects);
     assertEquals(1, updatedReport.getStats().getIgnored());
@@ -289,12 +291,13 @@ void testDeleteEnrollmentWithoutOwnership() throws IOException, ForbiddenExcepti
   }
 
   @Test
-  void testUpdateEnrollmentWithoutOwnership() throws IOException, ForbiddenException {
+  void testUpdateEnrollmentWithoutOwnership()
+      throws IOException, ForbiddenException, BadRequestException, NotFoundException {
     // Change ownership
-    TrackedEntity trackedEntity = manager.get(TrackedEntity.class, "IOR1AXXl24H");
-    OrganisationUnit ou = manager.get(OrganisationUnit.class, "B1nCbRV3qtP");
-    Program pgm = manager.get(Program.class, "BFcipDERJnf");
-    trackerOwnershipManager.transferOwnership(trackedEntity, pgm, ou);
+    trackerOwnershipManager.transferOwnership(
+        manager.get(TrackedEntity.class, "IOR1AXXl24H"),
+        UID.of("BFcipDERJnf"),
+        UID.of("B1nCbRV3qtP"));
     TrackerImportParams params = TrackerImportParams.builder().build();
     TrackerObjects trackerObjects = testSetup.fromJson("tracker/ownership_enrollment.json");
     params.setImportStrategy(TrackerImportStrategy.CREATE_AND_UPDATE);
diff --git a/dhis-2/dhis-test-integration/src/test/java/org/hisp/dhis/tracker/imports/validation/EventSecurityImportValidationTest.java b/dhis-2/dhis-test-integration/src/test/java/org/hisp/dhis/tracker/imports/validation/EventSecurityImportValidationTest.java
index b2e0430005e6..710c304e3a84 100644
--- a/dhis-2/dhis-test-integration/src/test/java/org/hisp/dhis/tracker/imports/validation/EventSecurityImportValidationTest.java
+++ b/dhis-2/dhis-test-integration/src/test/java/org/hisp/dhis/tracker/imports/validation/EventSecurityImportValidationTest.java
@@ -207,10 +207,7 @@ void setUp() throws IOException {
     manager.save(enrollmentA);
     maleA.getEnrollments().add(enrollmentA);
     manager.update(maleA);
-    trackedEntityProgramOwnerService.updateTrackedEntityProgramOwner(
-        maleA, programA, organisationUnitA);
-
-    trackedEntityProgramOwnerService.updateTrackedEntityProgramOwner(
+    trackedEntityProgramOwnerService.createTrackedEntityProgramOwner(
         maleA, programA, organisationUnitA);
     manager.update(programA);
     OrganisationUnit qfUVllTs6cS = organisationUnitService.getOrganisationUnit("QfUVllTs6cS");
diff --git a/dhis-2/dhis-test-integration/src/test/java/org/hisp/dhis/tracker/imports/validation/TrackedEntityImportValidationTest.java b/dhis-2/dhis-test-integration/src/test/java/org/hisp/dhis/tracker/imports/validation/TrackedEntityImportValidationTest.java
index 66cd788df2ed..0c9253328ddd 100644
--- a/dhis-2/dhis-test-integration/src/test/java/org/hisp/dhis/tracker/imports/validation/TrackedEntityImportValidationTest.java
+++ b/dhis-2/dhis-test-integration/src/test/java/org/hisp/dhis/tracker/imports/validation/TrackedEntityImportValidationTest.java
@@ -45,13 +45,13 @@
 import java.io.IOException;
 import org.hisp.dhis.common.IdentifiableObjectManager;
 import org.hisp.dhis.common.UID;
-import org.hisp.dhis.feedback.ForbiddenException;
+import org.hisp.dhis.feedback.BadRequestException;
 import org.hisp.dhis.organisationunit.OrganisationUnit;
 import org.hisp.dhis.program.Program;
 import org.hisp.dhis.test.integration.PostgresIntegrationTestBase;
 import org.hisp.dhis.trackedentity.TrackedEntity;
 import org.hisp.dhis.tracker.TestSetup;
-import org.hisp.dhis.tracker.acl.TrackerOwnershipManager;
+import org.hisp.dhis.tracker.acl.TrackedEntityProgramOwnerService;
 import org.hisp.dhis.tracker.imports.AtomicMode;
 import org.hisp.dhis.tracker.imports.TrackerImportParams;
 import org.hisp.dhis.tracker.imports.TrackerImportService;
@@ -76,10 +76,10 @@ class TrackedEntityImportValidationTest extends PostgresIntegrationTestBase {
 
   @Autowired private TrackerImportService trackerImportService;
 
-  @Autowired private TrackerOwnershipManager trackerOwnershipManager;
-
   @Autowired private IdentifiableObjectManager manager;
 
+  @Autowired private TrackedEntityProgramOwnerService trackedEntityProgramOwnerService;
+
   private User importUser;
 
   @BeforeAll
@@ -259,7 +259,7 @@ void testDeleteCascadeEnrollments() throws IOException {
 
   @Test
   void shouldFailToDeleteWhenUserHasAccessToRegistrationUnitAndTEWasTransferred()
-      throws IOException, ForbiddenException {
+      throws IOException, BadRequestException {
     TrackerImportParams params = TrackerImportParams.builder().build();
     TrackerObjects trackerObjects =
         testSetup.fromJson("tracker/validations/enrollments_te_te-data.json");
@@ -269,11 +269,13 @@ void shouldFailToDeleteWhenUserHasAccessToRegistrationUnitAndTEWasTransferred()
     manager.flush();
     manager.clear();
     TrackedEntity trackedEntity = manager.get(TrackedEntity.class, "Kj6vYde4LHh");
-    OrganisationUnit orgUnit = manager.get(OrganisationUnit.class, "QfUVllTs6cW");
     Program program = manager.get(Program.class, "E8o1E9tAppy");
-    trackerOwnershipManager.transferOwnership(trackedEntity, program, orgUnit);
+    OrganisationUnit orgUnit = manager.get(OrganisationUnit.class, "QfUVllTs6cS");
+    trackedEntityProgramOwnerService.updateTrackedEntityProgramOwner(
+        trackedEntity, program, orgUnit);
     manager.flush();
     manager.clear();
+
     ImportReport importReport = deleteTransferredTrackedEntity(userService.getUser(USER_10));
     assertHasErrors(importReport, 1, ValidationCode.E1003);
   }
@@ -281,7 +283,7 @@ void shouldFailToDeleteWhenUserHasAccessToRegistrationUnitAndTEWasTransferred()
   @Test
   void
       shouldFailToDeleteWhenTEWasTransferredAndUserHasAccessToTransferredOrgUnitAndTEOUIsNotInCaptureScope()
-          throws IOException, ForbiddenException {
+          throws IOException, BadRequestException {
     TrackerImportParams params = TrackerImportParams.builder().build();
     TrackerObjects trackerObjects =
         testSetup.fromJson("tracker/validations/enrollments_te_te-data.json");
@@ -291,18 +293,20 @@ void shouldFailToDeleteWhenUserHasAccessToRegistrationUnitAndTEWasTransferred()
     manager.flush();
     manager.clear();
     TrackedEntity trackedEntity = manager.get(TrackedEntity.class, "Kj6vYde4LHh");
-    OrganisationUnit orgUnit = manager.get(OrganisationUnit.class, "QfUVllTs6cW");
     Program program = manager.get(Program.class, "E8o1E9tAppy");
-    trackerOwnershipManager.transferOwnership(trackedEntity, program, orgUnit);
+    OrganisationUnit orgUnit = manager.get(OrganisationUnit.class, "QfUVllTs6cW");
+    trackedEntityProgramOwnerService.updateTrackedEntityProgramOwner(
+        trackedEntity, program, orgUnit);
     manager.flush();
     manager.clear();
+
     ImportReport importReport = deleteTransferredTrackedEntity(userService.getUser(USER_9));
     assertHasErrors(importReport, 1, ValidationCode.E1000);
   }
 
   @Test
   void shouldDeleteWhenTEWasTransferredAndUserHasAccessToTransferredOrgUnitAndTEOUIsInCaptureScope()
-      throws IOException, ForbiddenException {
+      throws IOException, BadRequestException {
     TrackerImportParams params = TrackerImportParams.builder().build();
     TrackerObjects trackerObjects =
         testSetup.fromJson("tracker/validations/enrollments_te_te-data.json");
@@ -312,9 +316,10 @@ void shouldDeleteWhenTEWasTransferredAndUserHasAccessToTransferredOrgUnitAndTEOU
     manager.flush();
     manager.clear();
     TrackedEntity trackedEntity = manager.get(TrackedEntity.class, "Kj6vYde4LHh");
-    OrganisationUnit orgUnit = manager.get(OrganisationUnit.class, "QfUVllTs6cW");
     Program program = manager.get(Program.class, "E8o1E9tAppy");
-    trackerOwnershipManager.transferOwnership(trackedEntity, program, orgUnit);
+    OrganisationUnit orgUnit = manager.get(OrganisationUnit.class, "QfUVllTs6cW");
+    trackedEntityProgramOwnerService.updateTrackedEntityProgramOwner(
+        trackedEntity, program, orgUnit);
     manager.flush();
     manager.clear();
     ImportReport importReport = deleteTransferredTrackedEntity(userService.getUser(USER_7));
@@ -323,7 +328,7 @@ void shouldDeleteWhenTEWasTransferredAndUserHasAccessToTransferredOrgUnitAndTEOU
 
   @Test
   void shouldFailToUpdateWhenUserHasAccessToRegistrationUnitAndTEWasTransferred()
-      throws IOException, ForbiddenException {
+      throws IOException, BadRequestException {
     TrackerImportParams params = TrackerImportParams.builder().build();
     TrackerObjects trackerObjects =
         testSetup.fromJson("tracker/validations/enrollments_te_te-data.json");
@@ -339,9 +344,10 @@ void shouldFailToUpdateWhenUserHasAccessToRegistrationUnitAndTEWasTransferred()
     manager.flush();
     manager.clear();
     TrackedEntity trackedEntity = manager.get(TrackedEntity.class, "KKKKj6vYdes");
-    OrganisationUnit orgUnit = manager.get(OrganisationUnit.class, "QfUVllTs6cW");
     Program program = manager.get(Program.class, "E8o1E9tAppy");
-    trackerOwnershipManager.transferOwnership(trackedEntity, program, orgUnit);
+    OrganisationUnit orgUnit = manager.get(OrganisationUnit.class, "QfUVllTs6cW");
+    trackedEntityProgramOwnerService.updateTrackedEntityProgramOwner(
+        trackedEntity, program, orgUnit);
     manager.flush();
     manager.clear();
     importReport = updateTransferredTrackedEntity(USER_10, UID.of("KKKKj6vYdes"));
@@ -350,7 +356,7 @@ void shouldFailToUpdateWhenUserHasAccessToRegistrationUnitAndTEWasTransferred()
 
   @Test
   void shouldUpdateWhenTEWasTransferredAndUserHasAccessToTransferredOrgUnit()
-      throws IOException, ForbiddenException {
+      throws IOException, BadRequestException {
     TrackerImportParams params = TrackerImportParams.builder().build();
     TrackerObjects trackerObjects =
         testSetup.fromJson("tracker/validations/enrollments_te_te-data.json");
@@ -361,9 +367,10 @@ void shouldUpdateWhenTEWasTransferredAndUserHasAccessToTransferredOrgUnit()
     manager.flush();
     manager.clear();
     TrackedEntity trackedEntity = manager.get(TrackedEntity.class, "Kj6vYde4LHh");
-    OrganisationUnit orgUnit = manager.get(OrganisationUnit.class, "QfUVllTs6cW");
     Program program = manager.get(Program.class, "E8o1E9tAppy");
-    trackerOwnershipManager.transferOwnership(trackedEntity, program, orgUnit);
+    OrganisationUnit orgUnit = manager.get(OrganisationUnit.class, "QfUVllTs6cW");
+    trackedEntityProgramOwnerService.updateTrackedEntityProgramOwner(
+        trackedEntity, program, orgUnit);
     manager.flush();
     manager.clear();
     ImportReport importReport = updateTransferredTrackedEntity(USER_9, UID.of("Kj6vYde4LHh"));
diff --git a/dhis-2/dhis-test-integration/src/test/java/org/hisp/dhis/tracker/ownership/TrackerOwnershipManagerTest.java b/dhis-2/dhis-test-integration/src/test/java/org/hisp/dhis/tracker/ownership/TrackerOwnershipManagerTest.java
index 34715e1c9aa0..242bccd81e2f 100644
--- a/dhis-2/dhis-test-integration/src/test/java/org/hisp/dhis/tracker/ownership/TrackerOwnershipManagerTest.java
+++ b/dhis-2/dhis-test-integration/src/test/java/org/hisp/dhis/tracker/ownership/TrackerOwnershipManagerTest.java
@@ -29,15 +29,11 @@
  */
 package org.hisp.dhis.tracker.ownership;
 
-import static org.hisp.dhis.common.AccessLevel.AUDITED;
-import static org.hisp.dhis.common.AccessLevel.CLOSED;
 import static org.hisp.dhis.common.AccessLevel.OPEN;
 import static org.hisp.dhis.common.AccessLevel.PROTECTED;
 import static org.hisp.dhis.common.OrganisationUnitSelectionMode.ACCESSIBLE;
-import static org.hisp.dhis.test.utils.Assertions.assertContains;
 import static org.hisp.dhis.test.utils.Assertions.assertContainsOnly;
 import static org.hisp.dhis.test.utils.Assertions.assertIsEmpty;
-import static org.hisp.dhis.test.utils.Assertions.assertStartsWith;
 import static org.hisp.dhis.tracker.TrackerTestUtils.uids;
 import static org.junit.jupiter.api.Assertions.assertEquals;
 import static org.junit.jupiter.api.Assertions.assertFalse;
@@ -47,7 +43,6 @@
 import java.util.List;
 import java.util.Map;
 import java.util.Set;
-import java.util.stream.Stream;
 import org.hisp.dhis.common.AccessLevel;
 import org.hisp.dhis.common.IdentifiableObjectManager;
 import org.hisp.dhis.common.UID;
@@ -59,8 +54,6 @@
 import org.hisp.dhis.program.Enrollment;
 import org.hisp.dhis.program.Program;
 import org.hisp.dhis.program.ProgramService;
-import org.hisp.dhis.program.ProgramType;
-import org.hisp.dhis.security.Authorities;
 import org.hisp.dhis.security.acl.AccessStringHelper;
 import org.hisp.dhis.test.integration.PostgresIntegrationTestBase;
 import org.hisp.dhis.trackedentity.TrackedEntity;
@@ -75,11 +68,8 @@
 import org.hisp.dhis.user.UserDetails;
 import org.hisp.dhis.user.sharing.Sharing;
 import org.hisp.dhis.user.sharing.UserAccess;
-import org.junit.jupiter.api.Assertions;
 import org.junit.jupiter.api.BeforeEach;
 import org.junit.jupiter.api.Test;
-import org.junit.jupiter.params.ParameterizedTest;
-import org.junit.jupiter.params.provider.MethodSource;
 import org.springframework.beans.factory.annotation.Autowired;
 
 /**
@@ -87,7 +77,7 @@
  */
 class TrackerOwnershipManagerTest extends PostgresIntegrationTestBase {
 
-  @Autowired private TrackerOwnershipManager trackerOwnershipAccessManager;
+  @Autowired private TrackerOwnershipManager trackerOwnershipManager;
 
   @Autowired
   private org.hisp.dhis.tracker.export.trackedentity.TrackedEntityService trackedEntityService;
@@ -102,8 +92,6 @@ class TrackerOwnershipManagerTest extends PostgresIntegrationTestBase {
 
   @Autowired private TrackedEntityProgramOwnerService trackedEntityProgramOwnerService;
 
-  private TrackedEntityType trackedEntityType;
-
   private TrackedEntity trackedEntityA1;
 
   private TrackedEntity trackedEntityB1;
@@ -118,7 +106,6 @@ class TrackerOwnershipManagerTest extends PostgresIntegrationTestBase {
 
   private User userA;
   private User userB;
-  private User superUser;
 
   private UserDetails userDetailsA;
   private UserDetails userDetailsB;
@@ -132,7 +119,7 @@ void setUp() {
     organisationUnitB = createOrganisationUnit('B');
     organisationUnitService.addOrganisationUnit(organisationUnitB);
 
-    trackedEntityType = createTrackedEntityType('A');
+    TrackedEntityType trackedEntityType = createTrackedEntityType('A');
     trackedEntityTypeService.addTrackedEntityType(trackedEntityType);
     trackedEntityType.setSharing(Sharing.builder().publicAccess(AccessStringHelper.FULL).build());
     trackedEntityTypeService.updateTrackedEntityType(trackedEntityType);
@@ -148,8 +135,6 @@ void setUp() {
     userB = createUserWithAuth("userB");
     userB.addOrganisationUnit(organisationUnitB);
     userService.updateUser(userB);
-    superUser =
-        createAndAddUserWithAuth("trackertestownership", organisationUnitA, Authorities.ALL);
 
     programA = createProgram('A');
     programA.setAccessLevel(PROTECTED);
@@ -180,68 +165,11 @@ void setUp() {
 
     defaultParams =
         new TrackedEntityParams(false, TrackedEntityEnrollmentParams.FALSE, false, false);
-  }
-
-  @Test
-  void shouldFailWhenGrantingTemporaryOwnershipAndUserNotInSearchScope() {
-    assertTrue(trackerOwnershipAccessManager.hasAccess(userDetailsA, trackedEntityA1, programA));
-    assertFalse(trackerOwnershipAccessManager.hasAccess(userDetailsB, trackedEntityA1, programA));
-    Exception exception =
-        Assertions.assertThrows(
-            ForbiddenException.class,
-            () ->
-                trackerOwnershipAccessManager.grantTemporaryOwnership(
-                    trackedEntityA1, programA, userDetailsB, "testing reason"));
-
-    assertEquals(
-        "Temporary ownership not created. The owner of the entity-program combination is not in the user's search scope.",
-        exception.getMessage());
-  }
-
-  @Test
-  void shouldNotHaveAccessToEnrollmentWithUserAWhenTransferredToAnotherOrgUnit()
-      throws ForbiddenException {
-    userA.setTeiSearchOrganisationUnits(Set.of(organisationUnitB));
-    userService.updateUser(userA);
 
-    trackerOwnershipAccessManager.transferOwnership(trackedEntityA1, programA, organisationUnitB);
-
-    injectSecurityContextUser(userA);
-    NotFoundException exception =
-        assertThrows(
-            NotFoundException.class,
-            () ->
-                trackedEntityService.getTrackedEntity(
-                    UID.of(trackedEntityA1), UID.of(programA), defaultParams));
-    assertEquals(
-        String.format("TrackedEntity with id %s could not be found.", trackedEntityA1.getUid()),
-        exception.getMessage());
-  }
-
-  @Test
-  void shouldHaveAccessToEnrollmentWithUserBWhenTransferredToOwnOrgUnit()
-      throws ForbiddenException, NotFoundException {
-    trackerOwnershipAccessManager.transferOwnership(trackedEntityA1, programA, organisationUnitB);
-
-    injectSecurityContextUser(userB);
-    assertEquals(
-        trackedEntityA1,
-        trackedEntityService.getTrackedEntity(
-            UID.of(trackedEntityA1), UID.of(programA), defaultParams));
-  }
-
-  @Test
-  void shouldHaveAccessToEnrollmentWithSuperUserWhenTransferredToOwnOrgUnit()
-      throws ForbiddenException, NotFoundException {
-    trackerOwnershipAccessManager.transferOwnership(trackedEntityA1, programA, organisationUnitB);
-    superUser.setOrganisationUnits(Set.of(organisationUnitB));
-    userService.updateUser(superUser);
-
-    injectSecurityContextUser(superUser);
-    assertEquals(
-        trackedEntityA1,
-        trackedEntityService.getTrackedEntity(
-            UID.of(trackedEntityA1), UID.of(programA), defaultParams));
+    User admin = getAdminUser();
+    admin.setOrganisationUnits(Set.of(organisationUnitA, organisationUnitB));
+    manager.update(admin);
+    injectSecurityContextUser(admin);
   }
 
   @Test
@@ -274,38 +202,20 @@ void shouldNotHaveAccessToTEWhenProgramNotProvidedAndUserHasNoAccessToAnyProgram
 
   @Test
   void shouldHaveAccessWhenProgramProtectedAndUserInCaptureScope() {
-    assertTrue(trackerOwnershipAccessManager.hasAccess(userDetailsA, trackedEntityA1, programA));
+    assertTrue(trackerOwnershipManager.hasAccess(userDetailsA, trackedEntityA1, programA));
     assertTrue(
-        trackerOwnershipAccessManager.hasAccess(
+        trackerOwnershipManager.hasAccess(
             userDetailsA,
             trackedEntityA1.getUid(),
             trackedEntityA1.getOrganisationUnit(),
             programA));
   }
 
-  @Test
-  void shouldHaveAccessWhenProgramProtectedAndHasTemporaryAccess() throws ForbiddenException {
-    userB.setTeiSearchOrganisationUnits(Set.of(organisationUnitA));
-    userService.updateUser(userB);
-    userDetailsB = UserDetails.fromUser(userB);
-
-    trackerOwnershipAccessManager.grantTemporaryOwnership(
-        trackedEntityA1, programA, userDetailsB, "test protected program");
-
-    assertTrue(trackerOwnershipAccessManager.hasAccess(userDetailsB, trackedEntityA1, programA));
-    assertTrue(
-        trackerOwnershipAccessManager.hasAccess(
-            userDetailsB,
-            trackedEntityA1.getUid(),
-            trackedEntityA1.getOrganisationUnit(),
-            programA));
-  }
-
   @Test
   void shouldNotHaveAccessWhenProgramProtectedAndUserNotInSearchScopeNorHasTemporaryAccess() {
-    assertFalse(trackerOwnershipAccessManager.hasAccess(userDetailsB, trackedEntityA1, programA));
+    assertFalse(trackerOwnershipManager.hasAccess(userDetailsB, trackedEntityA1, programA));
     assertFalse(
-        trackerOwnershipAccessManager.hasAccess(
+        trackerOwnershipManager.hasAccess(
             UserDetails.fromUser(userB),
             trackedEntityA1.getUid(),
             trackedEntityA1.getOrganisationUnit(),
@@ -328,10 +238,9 @@ void shouldNotHaveAccessWhenProgramProtectedAndUserNotInCaptureScopeNorHasTempor
     userB.setTeiSearchOrganisationUnits(Set.of(organisationUnitA));
     userService.updateUser(userB);
     assertFalse(
-        trackerOwnershipAccessManager.hasAccess(
-            UserDetails.fromUser(userB), trackedEntityA1, programA));
+        trackerOwnershipManager.hasAccess(UserDetails.fromUser(userB), trackedEntityA1, programA));
     assertFalse(
-        trackerOwnershipAccessManager.hasAccess(
+        trackerOwnershipManager.hasAccess(
             UserDetails.fromUser(userB),
             trackedEntityA1.getUid(),
             trackedEntityA1.getOrganisationUnit(),
@@ -351,153 +260,15 @@ void shouldNotHaveAccessWhenProgramProtectedAndUserNotInCaptureScopeNorHasTempor
 
   @Test
   void shouldHaveAccessWhenProgramClosedAndUserInCaptureScope() {
-    assertTrue(trackerOwnershipAccessManager.hasAccess(userDetailsB, trackedEntityB1, programB));
+    assertTrue(trackerOwnershipManager.hasAccess(userDetailsB, trackedEntityB1, programB));
     assertTrue(
-        trackerOwnershipAccessManager.hasAccess(
+        trackerOwnershipManager.hasAccess(
             userDetailsB,
             trackedEntityB1.getUid(),
             trackedEntityB1.getOrganisationUnit(),
             programB));
   }
 
-  private static Stream<Program> providePrograms() {
-    return Stream.of(createProgram(OPEN), createProgram(AUDITED), createProgram(CLOSED));
-  }
-
-  @ParameterizedTest
-  @MethodSource("providePrograms")
-  void shouldFailWhenGrantingTemporaryOwnershipToProgramWithAccessLevelOtherThanProtected(
-      Program program) {
-    Exception exception =
-        Assertions.assertThrows(
-            ForbiddenException.class,
-            () ->
-                trackerOwnershipAccessManager.grantTemporaryOwnership(
-                    trackedEntityA1, program, userDetailsB, "test temporary ownership"));
-
-    assertContains(
-        "Temporary ownership can only be granted to protected programs.", exception.getMessage());
-  }
-
-  @Test
-  void shouldFailWhenGrantingTemporaryAccessIfUserIsSuperuser() {
-    Exception exception =
-        Assertions.assertThrows(
-            ForbiddenException.class,
-            () ->
-                trackerOwnershipAccessManager.grantTemporaryOwnership(
-                    trackedEntityA1,
-                    programA,
-                    UserDetails.fromUser(superUser),
-                    "test temporary ownership"));
-
-    assertEquals(
-        "Temporary ownership not created. Current user is a superuser.", exception.getMessage());
-  }
-
-  @Test
-  void shouldFailWhenGrantingTemporaryAccessIfProgramIsNull() {
-    Exception exception =
-        Assertions.assertThrows(
-            ForbiddenException.class,
-            () ->
-                trackerOwnershipAccessManager.grantTemporaryOwnership(
-                    trackedEntityA1, null, userDetailsB, "test temporary ownership"));
-
-    assertEquals(
-        "Temporary ownership not created. Program supplied does not exist.",
-        exception.getMessage());
-  }
-
-  @Test
-  void shouldFailWhenGrantingTemporaryAccessIfProgramIsNotTrackerProgram() {
-    Program eventProgram = createProgram(PROTECTED);
-    eventProgram.setProgramType(ProgramType.WITHOUT_REGISTRATION);
-
-    Exception exception =
-        Assertions.assertThrows(
-            ForbiddenException.class,
-            () ->
-                trackerOwnershipAccessManager.grantTemporaryOwnership(
-                    trackedEntityA1, eventProgram, userDetailsB, "test temporary ownership"));
-
-    assertEquals(
-        "Temporary ownership not created. Program supplied is not a tracker program.",
-        exception.getMessage());
-  }
-
-  @Test
-  void shouldFailWhenGrantingTemporaryAccessIfTrackedEntitySuppliedIsNull() {
-    Exception exception =
-        Assertions.assertThrows(
-            ForbiddenException.class,
-            () ->
-                trackerOwnershipAccessManager.grantTemporaryOwnership(
-                    null, programA, userDetailsA, "test temporary ownership"));
-
-    assertEquals(
-        "Temporary ownership not created. Tracked entity supplied does not exist.",
-        exception.getMessage());
-  }
-
-  @Test
-  void shouldFailWhenGrantingTemporaryAccessIfUserHasNoAccessToProgram() {
-    programA.setSharing(Sharing.builder().publicAccess(AccessStringHelper.DEFAULT).build());
-    programService.updateProgram(programA);
-
-    Exception exception =
-        Assertions.assertThrows(
-            ForbiddenException.class,
-            () ->
-                trackerOwnershipAccessManager.grantTemporaryOwnership(
-                    trackedEntityA1, programA, userDetailsA, "test temporary ownership"));
-
-    assertStartsWith(
-        "Temporary ownership not created. User has no data read access to program",
-        exception.getMessage());
-  }
-
-  @Test
-  void shouldFailWhenGrantingTemporaryAccessIfUserHasNoAccessToTET() {
-    trackedEntityType.setSharing(
-        Sharing.builder().publicAccess(AccessStringHelper.DEFAULT).build());
-    trackedEntityTypeService.updateTrackedEntityType(trackedEntityType);
-    trackedEntityA1.setTrackedEntityType(trackedEntityType);
-    manager.update(trackedEntityA1);
-
-    Exception exception =
-        Assertions.assertThrows(
-            ForbiddenException.class,
-            () ->
-                trackerOwnershipAccessManager.grantTemporaryOwnership(
-                    trackedEntityA1, programA, userDetailsA, "test temporary ownership"));
-
-    assertStartsWith(
-        "Temporary ownership not created. User has no data read access to tracked entity type",
-        exception.getMessage());
-  }
-
-  @Test
-  void shouldFailWhenGrantingTemporaryAccessIfProgramTETDifferentThanTEs() {
-    TrackedEntityType differentTET = createTrackedEntityType('B');
-    trackedEntityTypeService.addTrackedEntityType(differentTET);
-    differentTET.setSharing(Sharing.builder().publicAccess(AccessStringHelper.FULL).build());
-    trackedEntityTypeService.updateTrackedEntityType(differentTET);
-    trackedEntityA1.setTrackedEntityType(differentTET);
-    manager.update(trackedEntityA1);
-
-    Exception exception =
-        Assertions.assertThrows(
-            ForbiddenException.class,
-            () ->
-                trackerOwnershipAccessManager.grantTemporaryOwnership(
-                    trackedEntityA1, programA, userDetailsA, "test temporary ownership"));
-
-    assertStartsWith(
-        "Temporary ownership not created. The tracked entity type of the program",
-        exception.getMessage());
-  }
-
   @Test
   void shouldHaveAccessWhenProgramOpenAndUserInScope()
       throws ForbiddenException, NotFoundException {
@@ -511,36 +282,6 @@ void shouldHaveAccessWhenProgramOpenAndUserInScope()
             UID.of(trackedEntityA1), UID.of(programA), defaultParams));
   }
 
-  @Test
-  void shouldNotHaveAccessWhenProgramOpenAndUserNotInSearchScope() throws ForbiddenException {
-    programA.setAccessLevel(OPEN);
-    programService.updateProgram(programA);
-
-    trackerOwnershipAccessManager.transferOwnership(trackedEntityA1, programA, organisationUnitB);
-
-    injectSecurityContextUser(userA);
-    NotFoundException exception =
-        assertThrows(
-            NotFoundException.class,
-            () ->
-                trackedEntityService.getTrackedEntity(
-                    UID.of(trackedEntityA1), UID.of(programA), defaultParams));
-    assertEquals(
-        String.format("TrackedEntity with id %s could not be found.", trackedEntityA1.getUid()),
-        exception.getMessage());
-  }
-
-  @Test
-  void shouldHaveAccessWhenProgramNotProvidedAndTEEnrolledButHaveAccessToTEOwner()
-      throws ForbiddenException, NotFoundException {
-    trackerOwnershipAccessManager.transferOwnership(trackedEntityA1, programA, organisationUnitB);
-
-    injectSecurityContextUser(userB);
-    assertEquals(
-        trackedEntityA1,
-        trackedEntityService.getTrackedEntity(UID.of(trackedEntityA1), null, defaultParams));
-  }
-
   @Test
   void shouldNotHaveAccessWhenProgramNotProvidedAndTEEnrolledAndNoAccessToTEOwner() {
     injectSecurityContextUser(userB);
@@ -581,84 +322,6 @@ void shouldNotHaveAccessWhenProgramNotProvidedAndTENotEnrolledAndNoAccessToTeReg
         exception.getMessage());
   }
 
-  @Test
-  void shouldFindTrackedEntityWhenTransferredToAccessibleOrgUnit()
-      throws ForbiddenException, BadRequestException, NotFoundException {
-    transferOwnership(trackedEntityA1, programA, organisationUnitB);
-    TrackedEntityOperationParams operationParams = createOperationParams(null);
-    injectSecurityContext(userDetailsB);
-    List<String> trackedEntities = getTrackedEntities(operationParams);
-
-    assertContainsOnly(
-        List.of(trackedEntityA1.getUid(), trackedEntityB1.getUid()), trackedEntities);
-  }
-
-  @Test
-  void shouldFindTrackedEntityWhenTransferredToAccessibleOrgUnitAndSuperUser()
-      throws ForbiddenException, BadRequestException, NotFoundException {
-    transferOwnership(trackedEntityA1, programA, organisationUnitB);
-    superUser.setOrganisationUnits(Set.of(organisationUnitB));
-    userService.updateUser(superUser);
-    TrackedEntityOperationParams operationParams = createOperationParams(null);
-    injectSecurityContextUser(superUser);
-    List<String> trackedEntities = getTrackedEntities(operationParams);
-
-    assertContainsOnly(
-        List.of(trackedEntityA1.getUid(), trackedEntityB1.getUid()), trackedEntities);
-  }
-
-  @Test
-  void shouldNotFindTrackedEntityWhenTransferredToInaccessibleOrgUnit()
-      throws ForbiddenException, BadRequestException, NotFoundException {
-    transferOwnership(trackedEntityA1, programA, organisationUnitB);
-
-    TrackedEntityOperationParams operationParams = createOperationParams(null);
-    injectSecurityContext(userDetailsA);
-    assertIsEmpty(getTrackedEntities(operationParams));
-  }
-
-  @Test
-  void shouldFindTrackedEntityWhenTransferredToInaccessibleOrgUnitIfHasReadAccessToOtherProgram()
-      throws ForbiddenException, BadRequestException, NotFoundException {
-    transferOwnership(trackedEntityA1, programA, organisationUnitB);
-    programB.setSharing(Sharing.builder().publicAccess("rwrw----").build());
-    programService.updateProgram(programB);
-    injectSecurityContext(userDetailsA);
-
-    TrackedEntityOperationParams operationParams = createOperationParams(null);
-
-    List<String> trackedEntities = getTrackedEntities(operationParams);
-
-    assertContainsOnly(List.of(trackedEntityA1.getUid()), trackedEntities);
-  }
-
-  @Test
-  void shouldFindTrackedEntityWhenTransferredToInaccessibleOrgUnitIfSuperUser()
-      throws ForbiddenException, BadRequestException, NotFoundException {
-    transferOwnership(trackedEntityA1, programA, organisationUnitB);
-    injectSecurityContextUser(superUser);
-
-    TrackedEntityOperationParams operationParams = createOperationParams(null);
-    List<String> trackedEntities = getTrackedEntities(operationParams);
-
-    assertContainsOnly(List.of(trackedEntityA1.getUid()), trackedEntities);
-  }
-
-  @Test
-  void shouldNotTransferOwnershipWhenOrgUnitNotAssociatedToProgram() {
-    OrganisationUnit notAssociatedOrgUnit = createOrganisationUnit('C');
-    organisationUnitService.addOrganisationUnit(notAssociatedOrgUnit);
-    Exception exception =
-        assertThrows(
-            ForbiddenException.class,
-            () -> transferOwnership(trackedEntityA1, programA, notAssociatedOrgUnit));
-    assertEquals(
-        String.format(
-            "The program %s is not associated to the org unit %s",
-            programA.getUid(), notAssociatedOrgUnit.getUid()),
-        exception.getMessage());
-  }
-
   @Test
   void shouldFindTrackedEntityWhenProgramSuppliedAndUserIsOwner()
       throws ForbiddenException, BadRequestException, NotFoundException {
@@ -679,12 +342,6 @@ void shouldNotFindTrackedEntityWhenProgramSuppliedAndUserIsNotOwner()
     assertIsEmpty(getTrackedEntities(operationParams));
   }
 
-  private void transferOwnership(
-      TrackedEntity trackedEntity, Program program, OrganisationUnit orgUnit)
-      throws ForbiddenException {
-    trackerOwnershipAccessManager.transferOwnership(trackedEntity, program, orgUnit);
-  }
-
   private TrackedEntityOperationParams createOperationParams(UID programUid) {
     return TrackedEntityOperationParams.builder()
         .trackedEntityType(trackedEntityA1.getTrackedEntityType())
@@ -697,11 +354,4 @@ private List<String> getTrackedEntities(TrackedEntityOperationParams params)
       throws ForbiddenException, BadRequestException, NotFoundException {
     return uids(trackedEntityService.findTrackedEntities(params));
   }
-
-  private static Program createProgram(AccessLevel accessLevel) {
-    Program program = new Program();
-    program.setAccessLevel(accessLevel);
-
-    return program;
-  }
 }
diff --git a/dhis-2/dhis-test-integration/src/test/java/org/hisp/dhis/tracker/ownership/TrackerOwnershipTransferManagerTest.java b/dhis-2/dhis-test-integration/src/test/java/org/hisp/dhis/tracker/ownership/TrackerOwnershipTransferManagerTest.java
new file mode 100644
index 000000000000..bb117ef6ae9d
--- /dev/null
+++ b/dhis-2/dhis-test-integration/src/test/java/org/hisp/dhis/tracker/ownership/TrackerOwnershipTransferManagerTest.java
@@ -0,0 +1,597 @@
+/*
+ * Copyright (c) 2004-2022, University of Oslo
+ * All rights reserved.
+ *
+ * Redistribution and use in source and binary forms, with or without
+ * modification, are permitted provided that the following conditions are met:
+ *
+ * 1. Redistributions of source code must retain the above copyright notice, this
+ * list of conditions and the following disclaimer.
+ *
+ * 2. Redistributions in binary form must reproduce the above copyright notice,
+ * this list of conditions and the following disclaimer in the documentation
+ * and/or other materials provided with the distribution.
+ *
+ * 3. Neither the name of the copyright holder nor the names of its contributors 
+ * may be used to endorse or promote products derived from this software without
+ * specific prior written permission.
+ *
+ * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
+ * ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
+ * WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
+ * DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR
+ * ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
+ * (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
+ * LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON
+ * ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
+ * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
+ * SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+ */
+package org.hisp.dhis.tracker.ownership;
+
+import static org.hisp.dhis.common.AccessLevel.AUDITED;
+import static org.hisp.dhis.common.AccessLevel.CLOSED;
+import static org.hisp.dhis.common.AccessLevel.OPEN;
+import static org.hisp.dhis.common.AccessLevel.PROTECTED;
+import static org.hisp.dhis.common.OrganisationUnitSelectionMode.ACCESSIBLE;
+import static org.hisp.dhis.program.ProgramType.WITHOUT_REGISTRATION;
+import static org.hisp.dhis.test.utils.Assertions.assertContains;
+import static org.hisp.dhis.test.utils.Assertions.assertContainsOnly;
+import static org.hisp.dhis.test.utils.Assertions.assertIsEmpty;
+import static org.hisp.dhis.test.utils.Assertions.assertStartsWith;
+import static org.hisp.dhis.tracker.TrackerTestUtils.uids;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import java.util.stream.Stream;
+import org.hisp.dhis.common.AccessLevel;
+import org.hisp.dhis.common.IdentifiableObjectManager;
+import org.hisp.dhis.common.UID;
+import org.hisp.dhis.feedback.BadRequestException;
+import org.hisp.dhis.feedback.ForbiddenException;
+import org.hisp.dhis.feedback.NotFoundException;
+import org.hisp.dhis.organisationunit.OrganisationUnit;
+import org.hisp.dhis.organisationunit.OrganisationUnitService;
+import org.hisp.dhis.program.Enrollment;
+import org.hisp.dhis.program.Program;
+import org.hisp.dhis.program.ProgramService;
+import org.hisp.dhis.relationship.RelationshipEntity;
+import org.hisp.dhis.relationship.RelationshipType;
+import org.hisp.dhis.security.Authorities;
+import org.hisp.dhis.security.acl.AccessStringHelper;
+import org.hisp.dhis.test.integration.PostgresIntegrationTestBase;
+import org.hisp.dhis.trackedentity.TrackedEntity;
+import org.hisp.dhis.trackedentity.TrackedEntityType;
+import org.hisp.dhis.trackedentity.TrackedEntityTypeService;
+import org.hisp.dhis.tracker.acl.TrackedEntityProgramOwnerService;
+import org.hisp.dhis.tracker.acl.TrackerOwnershipManager;
+import org.hisp.dhis.tracker.export.trackedentity.TrackedEntityEnrollmentParams;
+import org.hisp.dhis.tracker.export.trackedentity.TrackedEntityOperationParams;
+import org.hisp.dhis.tracker.export.trackedentity.TrackedEntityParams;
+import org.hisp.dhis.user.User;
+import org.hisp.dhis.user.UserDetails;
+import org.hisp.dhis.user.sharing.Sharing;
+import org.hisp.dhis.user.sharing.UserAccess;
+import org.junit.jupiter.api.Assertions;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.Arguments;
+import org.junit.jupiter.params.provider.MethodSource;
+import org.springframework.beans.factory.annotation.Autowired;
+
+class TrackerOwnershipTransferManagerTest extends PostgresIntegrationTestBase {
+
+  @Autowired private TrackerOwnershipManager trackerOwnershipManager;
+
+  @Autowired
+  private org.hisp.dhis.tracker.export.trackedentity.TrackedEntityService trackedEntityService;
+
+  @Autowired private OrganisationUnitService organisationUnitService;
+
+  @Autowired private ProgramService programService;
+
+  @Autowired private IdentifiableObjectManager manager;
+
+  @Autowired private TrackedEntityTypeService trackedEntityTypeService;
+
+  @Autowired private TrackedEntityProgramOwnerService trackedEntityProgramOwnerService;
+
+  private TrackedEntityType trackedEntityType;
+
+  private TrackedEntity trackedEntityA1;
+
+  private TrackedEntity trackedEntityB1;
+
+  private OrganisationUnit organisationUnitA;
+
+  private OrganisationUnit organisationUnitB;
+
+  private Program programA;
+
+  private Program programB;
+
+  private User userA;
+  private User userB;
+  private User superUser;
+
+  private UserDetails userDetailsA;
+  private UserDetails userDetailsB;
+
+  private TrackedEntityParams defaultParams;
+
+  private final RelationshipType relationshipType = createRelationshipType('A');
+
+  @BeforeEach
+  void setUp() {
+    organisationUnitA = createOrganisationUnit('A');
+    organisationUnitService.addOrganisationUnit(organisationUnitA);
+    organisationUnitB = createOrganisationUnit('B');
+    organisationUnitService.addOrganisationUnit(organisationUnitB);
+
+    trackedEntityType = createTrackedEntityType('A');
+    trackedEntityTypeService.addTrackedEntityType(trackedEntityType);
+    trackedEntityType.setSharing(Sharing.builder().publicAccess(AccessStringHelper.FULL).build());
+    trackedEntityTypeService.updateTrackedEntityType(trackedEntityType);
+
+    trackedEntityA1 = createTrackedEntity(organisationUnitA, trackedEntityType);
+    trackedEntityB1 = createTrackedEntity(organisationUnitB, trackedEntityType);
+    manager.save(trackedEntityA1);
+    manager.save(trackedEntityB1);
+
+    userA = createUserWithAuth("userA");
+    userA.addOrganisationUnit(organisationUnitA);
+    userService.updateUser(userA);
+    userB = createUserWithAuth("userB");
+    userB.addOrganisationUnit(organisationUnitB);
+    userService.updateUser(userB);
+    superUser =
+        createAndAddUserWithAuth("trackertestownership", organisationUnitA, Authorities.ALL);
+
+    programA = createProgram('A');
+    programA.setAccessLevel(PROTECTED);
+    programA.setTrackedEntityType(trackedEntityType);
+    programA.setOrganisationUnits(Set.of(organisationUnitA, organisationUnitB));
+    programService.addProgram(programA);
+    programA.setSharing(Sharing.builder().publicAccess(AccessStringHelper.FULL).build());
+    programService.updateProgram(programA);
+    programB = createProgram('B');
+    programB.setAccessLevel(AccessLevel.CLOSED);
+    programB.setTrackedEntityType(trackedEntityType);
+    programService.addProgram(programB);
+    programB.setSharing(
+        Sharing.builder()
+            .publicAccess(AccessStringHelper.DEFAULT)
+            .users(Map.of(userB.getUid(), new UserAccess(userB, "r-r-----")))
+            .build());
+    programService.updateProgram(programB);
+
+    userDetailsA = UserDetails.fromUser(userA);
+    userDetailsB = UserDetails.fromUser(userB);
+
+    Enrollment trackedEntityA1Enrollment =
+        createEnrollment(programA, trackedEntityA1, organisationUnitA);
+    manager.save(trackedEntityA1Enrollment);
+    trackedEntityProgramOwnerService.createOrUpdateTrackedEntityProgramOwner(
+        trackedEntityA1, programA, organisationUnitA);
+
+    defaultParams =
+        new TrackedEntityParams(false, TrackedEntityEnrollmentParams.FALSE, false, false);
+
+    relationshipType
+        .getFromConstraint()
+        .setRelationshipEntity(RelationshipEntity.TRACKED_ENTITY_INSTANCE);
+    relationshipType.getFromConstraint().setTrackedEntityType(trackedEntityType);
+    relationshipType
+        .getToConstraint()
+        .setRelationshipEntity(RelationshipEntity.TRACKED_ENTITY_INSTANCE);
+    relationshipType.getToConstraint().setTrackedEntityType(trackedEntityType);
+    manager.save(relationshipType, false);
+
+    User admin = getAdminUser();
+    admin.setOrganisationUnits(Set.of(organisationUnitA, organisationUnitB));
+    manager.update(admin);
+    injectSecurityContextUser(admin);
+  }
+
+  @Test
+  void shouldFailWhenGrantingTemporaryOwnershipIfTrackedEntityDoesNotExist() {
+    injectSecurityContext(userDetailsB);
+    Exception exception =
+        assertThrows(
+            NotFoundException.class,
+            () ->
+                trackerOwnershipManager.grantTemporaryOwnership(
+                    UID.generate(), UID.of(programA), "testing reason"));
+
+    assertEquals(
+        "Temporary ownership not created. Tracked entity supplied does not exist.",
+        exception.getMessage());
+  }
+
+  @Test
+  void shouldFailWhenGrantingTemporaryOwnershipAndUserNotInSearchScope() {
+    injectSecurityContext(userDetailsB);
+    assertTrue(trackerOwnershipManager.hasAccess(userDetailsA, trackedEntityA1, programA));
+    assertFalse(trackerOwnershipManager.hasAccess(userDetailsB, trackedEntityA1, programA));
+    Exception exception =
+        assertThrows(
+            ForbiddenException.class,
+            () ->
+                trackerOwnershipManager.grantTemporaryOwnership(
+                    UID.of(trackedEntityA1), UID.of(programA), "testing reason"));
+
+    assertEquals(
+        "Temporary ownership not created. The owner of the entity-program combination is not in the user's search scope.",
+        exception.getMessage());
+  }
+
+  @Test
+  void shouldNotHaveAccessToEnrollmentWithUserAWhenTransferredToAnotherOrgUnit()
+      throws ForbiddenException, BadRequestException, NotFoundException {
+    userA.setTeiSearchOrganisationUnits(Set.of(organisationUnitB));
+    userService.updateUser(userA);
+
+    transferOwnership(trackedEntityA1, programA, organisationUnitB);
+
+    injectSecurityContextUser(userA);
+    NotFoundException exception =
+        assertThrows(
+            NotFoundException.class,
+            () ->
+                trackedEntityService.getTrackedEntity(
+                    UID.of(trackedEntityA1), UID.of(programA), defaultParams));
+    assertEquals(
+        String.format("TrackedEntity with id %s could not be found.", trackedEntityA1.getUid()),
+        exception.getMessage());
+  }
+
+  @Test
+  void shouldHaveAccessToEnrollmentWithUserBWhenTransferredToOwnOrgUnit()
+      throws ForbiddenException, NotFoundException, BadRequestException {
+    transferOwnership(trackedEntityA1, programA, organisationUnitB);
+
+    injectSecurityContextUser(userB);
+    assertEquals(
+        trackedEntityA1,
+        trackedEntityService.getTrackedEntity(
+            UID.of(trackedEntityA1), UID.of(programA), defaultParams));
+  }
+
+  @Test
+  void shouldHaveAccessToEnrollmentWithSuperUserWhenTransferredToOwnOrgUnit()
+      throws ForbiddenException, NotFoundException, BadRequestException {
+    transferOwnership(trackedEntityA1, programA, organisationUnitB);
+    superUser.setOrganisationUnits(Set.of(organisationUnitB));
+    userService.updateUser(superUser);
+    injectSecurityContextUser(superUser);
+
+    assertEquals(
+        trackedEntityA1,
+        trackedEntityService.getTrackedEntity(
+            UID.of(trackedEntityA1), UID.of(programA), defaultParams));
+  }
+
+  @Test
+  void shouldHaveAccessWhenProgramProtectedAndHasTemporaryAccess()
+      throws ForbiddenException, BadRequestException, NotFoundException {
+    userB.setTeiSearchOrganisationUnits(Set.of(organisationUnitA));
+    userService.updateUser(userB);
+    userDetailsB = UserDetails.fromUser(userB);
+    injectSecurityContext(userDetailsB);
+
+    trackerOwnershipManager.grantTemporaryOwnership(
+        UID.of(trackedEntityA1), UID.of(programA), "test protected program");
+
+    assertTrue(trackerOwnershipManager.hasAccess(userDetailsB, trackedEntityA1, programA));
+    assertTrue(
+        trackerOwnershipManager.hasAccess(
+            userDetailsB,
+            trackedEntityA1.getUid(),
+            trackedEntityA1.getOrganisationUnit(),
+            programA));
+  }
+
+  private static Stream<Arguments> providePrograms() {
+    return Stream.of(
+        Arguments.of(OPEN, 'C'), Arguments.of(AUDITED, 'D'), Arguments.of(CLOSED, 'E'));
+  }
+
+  @ParameterizedTest
+  @MethodSource("providePrograms")
+  void shouldFailWhenGrantingTemporaryOwnershipToProgramWithAccessLevelOtherThanProtected(
+      AccessLevel accessLevel, char uniqueChar) {
+    Program program = createProgram(accessLevel, uniqueChar);
+
+    Exception exception =
+        assertThrows(
+            ForbiddenException.class,
+            () ->
+                trackerOwnershipManager.grantTemporaryOwnership(
+                    UID.of(trackedEntityA1), UID.of(program), "test temporary ownership"));
+
+    assertContains(
+        "Temporary ownership can only be granted to protected programs.", exception.getMessage());
+  }
+
+  @Test
+  void shouldFailWhenGrantingTemporaryAccessIfUserIsSuperuser() {
+    Exception exception =
+        assertThrows(
+            ForbiddenException.class,
+            () ->
+                trackerOwnershipManager.grantTemporaryOwnership(
+                    UID.of(trackedEntityA1), UID.of(programA), "test temporary ownership"));
+
+    assertEquals(
+        "Temporary ownership not created. Current user is a superuser.", exception.getMessage());
+  }
+
+  @Test
+  void shouldFailWhenGrantingTemporaryAccessIfProgramIsNotTrackerProgram() {
+    Program eventProgram = createProgram(PROTECTED, 'F');
+    eventProgram.setProgramType(WITHOUT_REGISTRATION);
+    injectSecurityContextUser(superUser);
+    manager.update(eventProgram);
+    injectSecurityContext(userDetailsB);
+
+    Exception exception =
+        Assertions.assertThrows(
+            BadRequestException.class,
+            () ->
+                trackerOwnershipManager.grantTemporaryOwnership(
+                    UID.of(trackedEntityA1), UID.of(eventProgram), "test temporary ownership"));
+
+    assertEquals(
+        "Provided program, " + eventProgram.getUid() + ", is not a tracker program.",
+        exception.getMessage());
+  }
+
+  @Test
+  void shouldFailWhenGrantingTemporaryAccessIfUserHasNoAccessToProgram() {
+    programA.setSharing(Sharing.builder().publicAccess(AccessStringHelper.DEFAULT).build());
+    programService.updateProgram(programA);
+    injectSecurityContext(userDetailsA);
+
+    Exception exception =
+        assertThrows(
+            BadRequestException.class,
+            () ->
+                trackerOwnershipManager.grantTemporaryOwnership(
+                    UID.of(trackedEntityA1), UID.of(programA), "test temporary ownership"));
+
+    assertStartsWith(
+        "Provided program, " + programA.getUid() + ", does not exist.", exception.getMessage());
+  }
+
+  @Test
+  void shouldFailWhenGrantingTemporaryAccessIfUserHasNoAccessToTET() {
+    trackedEntityType.setSharing(
+        Sharing.builder().publicAccess(AccessStringHelper.DEFAULT).build());
+    trackedEntityTypeService.updateTrackedEntityType(trackedEntityType);
+    trackedEntityA1.setTrackedEntityType(trackedEntityType);
+    manager.update(trackedEntityA1);
+    injectSecurityContext(userDetailsA);
+
+    Exception exception =
+        assertThrows(
+            ForbiddenException.class,
+            () ->
+                trackerOwnershipManager.grantTemporaryOwnership(
+                    UID.of(trackedEntityA1), UID.of(programA), "test temporary ownership"));
+
+    assertStartsWith(
+        "Temporary ownership not created. User has no data read access to tracked entity type",
+        exception.getMessage());
+  }
+
+  @Test
+  void shouldFailWhenGrantingTemporaryAccessIfProgramTETDifferentThanTEs() {
+    TrackedEntityType differentTET = createTrackedEntityType('B');
+    trackedEntityTypeService.addTrackedEntityType(differentTET);
+    differentTET.setSharing(Sharing.builder().publicAccess(AccessStringHelper.FULL).build());
+    trackedEntityTypeService.updateTrackedEntityType(differentTET);
+    trackedEntityA1.setTrackedEntityType(differentTET);
+    manager.update(trackedEntityA1);
+    injectSecurityContext(userDetailsA);
+
+    Exception exception =
+        assertThrows(
+            ForbiddenException.class,
+            () ->
+                trackerOwnershipManager.grantTemporaryOwnership(
+                    UID.of(trackedEntityA1), UID.of(programA), "test temporary ownership"));
+
+    assertStartsWith(
+        "Temporary ownership not created. The tracked entity type of the program",
+        exception.getMessage());
+  }
+
+  @Test
+  void shouldNotHaveAccessWhenProgramOpenAndUserNotInSearchScope()
+      throws ForbiddenException, BadRequestException, NotFoundException {
+    programA.setAccessLevel(OPEN);
+    programService.updateProgram(programA);
+
+    transferOwnership(trackedEntityA1, programA, organisationUnitB);
+
+    injectSecurityContextUser(userA);
+    NotFoundException exception =
+        assertThrows(
+            NotFoundException.class,
+            () ->
+                trackedEntityService.getTrackedEntity(
+                    UID.of(trackedEntityA1), UID.of(programA), defaultParams));
+    assertEquals(
+        String.format("TrackedEntity with id %s could not be found.", trackedEntityA1.getUid()),
+        exception.getMessage());
+  }
+
+  @Test
+  void shouldHaveAccessWhenProgramNotProvidedAndTEEnrolledButHaveAccessToTEOwner()
+      throws ForbiddenException, NotFoundException, BadRequestException {
+    transferOwnership(trackedEntityA1, programA, organisationUnitB);
+
+    injectSecurityContextUser(userB);
+    assertEquals(
+        trackedEntityA1,
+        trackedEntityService.getTrackedEntity(UID.of(trackedEntityA1), null, defaultParams));
+  }
+
+  @Test
+  void shouldFindTrackedEntityWhenTransferredToAccessibleOrgUnit()
+      throws ForbiddenException, BadRequestException, NotFoundException {
+    transferOwnership(trackedEntityA1, programA, organisationUnitB);
+    TrackedEntityOperationParams operationParams = createOperationParams();
+    injectSecurityContext(userDetailsB);
+    List<String> trackedEntities = getTrackedEntities(operationParams);
+
+    assertContainsOnly(
+        List.of(trackedEntityA1.getUid(), trackedEntityB1.getUid()), trackedEntities);
+  }
+
+  @Test
+  void shouldFindTrackedEntityWhenTransferredToAccessibleOrgUnitAndSuperUser()
+      throws ForbiddenException, BadRequestException, NotFoundException {
+    transferOwnership(trackedEntityA1, programA, organisationUnitB);
+    superUser.setOrganisationUnits(Set.of(organisationUnitB));
+    userService.updateUser(superUser);
+    TrackedEntityOperationParams operationParams = createOperationParams();
+    injectSecurityContextUser(superUser);
+    List<String> trackedEntities = getTrackedEntities(operationParams);
+
+    assertContainsOnly(
+        List.of(trackedEntityA1.getUid(), trackedEntityB1.getUid()), trackedEntities);
+  }
+
+  @Test
+  void shouldNotFindTrackedEntityWhenTransferredToInaccessibleOrgUnit()
+      throws ForbiddenException, BadRequestException, NotFoundException {
+    transferOwnership(trackedEntityA1, programA, organisationUnitB);
+
+    TrackedEntityOperationParams operationParams = createOperationParams();
+    injectSecurityContext(userDetailsA);
+    assertIsEmpty(getTrackedEntities(operationParams));
+  }
+
+  @Test
+  void shouldFindTrackedEntityWhenTransferredToInaccessibleOrgUnitIfHasReadAccessToOtherProgram()
+      throws ForbiddenException, BadRequestException, NotFoundException {
+    transferOwnership(trackedEntityA1, programA, organisationUnitB);
+    programB.setSharing(Sharing.builder().publicAccess("rwrw----").build());
+    programService.updateProgram(programB);
+    injectSecurityContext(userDetailsA);
+    TrackedEntityOperationParams operationParams = createOperationParams();
+
+    List<String> trackedEntities = getTrackedEntities(operationParams);
+
+    assertContainsOnly(List.of(trackedEntityA1.getUid()), trackedEntities);
+  }
+
+  @Test
+  void shouldFindTrackedEntityWhenTransferredToInaccessibleOrgUnitIfSuperUser()
+      throws ForbiddenException, BadRequestException, NotFoundException {
+    transferOwnership(trackedEntityA1, programA, organisationUnitB);
+    injectSecurityContextUser(superUser);
+
+    TrackedEntityOperationParams operationParams = createOperationParams();
+    List<String> trackedEntities = getTrackedEntities(operationParams);
+
+    assertContainsOnly(List.of(trackedEntityA1.getUid()), trackedEntities);
+  }
+
+  @Test
+  void shouldNotTransferOwnershipWhenOrgUnitNotAssociatedToProgram() {
+    OrganisationUnit notAssociatedOrgUnit = createOrganisationUnit('C');
+    organisationUnitService.addOrganisationUnit(notAssociatedOrgUnit);
+    Exception exception =
+        assertThrows(
+            ForbiddenException.class,
+            () -> transferOwnership(trackedEntityA1, programA, notAssociatedOrgUnit));
+    assertEquals(
+        String.format(
+            "Tracked entity not transferred. The program %s is not associated to the org unit %s",
+            programA.getUid(), notAssociatedOrgUnit.getUid()),
+        exception.getMessage());
+  }
+
+  @Test
+  void shouldNotTransferOwnershipWhenOrgUnitDoesNotExist() {
+    UID madeUpOrgUnit = UID.generate();
+
+    Exception exception =
+        assertThrows(
+            NotFoundException.class,
+            () ->
+                trackerOwnershipManager.transferOwnership(
+                    trackedEntityA1, UID.of(programA), madeUpOrgUnit));
+    assertEquals(
+        "Tracked entity not transferred. Org unit supplied does not exist.",
+        exception.getMessage());
+  }
+
+  @Test
+  void shouldNotTransferOwnershipIfNoOwnerFound() {
+    Exception exception =
+        assertThrows(
+            BadRequestException.class,
+            () -> transferOwnership(trackedEntityB1, programA, organisationUnitA));
+    assertEquals(
+        "Tracked entity not transferred. No owner found for the combination of tracked entity "
+            + trackedEntityB1.getUid()
+            + " and program "
+            + programA.getUid(),
+        exception.getMessage());
+  }
+
+  @Test
+  void shouldNotTransferOwnershipIfSuppliedOrgUnitIsAlreadyOwner() {
+    Exception exception =
+        assertThrows(
+            BadRequestException.class,
+            () -> transferOwnership(trackedEntityA1, programA, organisationUnitA));
+    assertEquals(
+        "Tracked entity not transferred. The owner of the tracked entity "
+            + trackedEntityA1.getUid()
+            + " and program "
+            + programA.getUid()
+            + " is already "
+            + organisationUnitA.getUid(),
+        exception.getMessage());
+  }
+
+  private void transferOwnership(
+      TrackedEntity trackedEntity, Program program, OrganisationUnit orgUnit)
+      throws ForbiddenException, BadRequestException, NotFoundException {
+    trackerOwnershipManager.transferOwnership(trackedEntity, UID.of(program), UID.of(orgUnit));
+  }
+
+  private TrackedEntityOperationParams createOperationParams() {
+    return TrackedEntityOperationParams.builder()
+        .trackedEntityType(trackedEntityA1.getTrackedEntityType())
+        .orgUnitMode(ACCESSIBLE)
+        .build();
+  }
+
+  private List<String> getTrackedEntities(TrackedEntityOperationParams params)
+      throws ForbiddenException, BadRequestException, NotFoundException {
+    return uids(trackedEntityService.findTrackedEntities(params));
+  }
+
+  private Program createProgram(AccessLevel accessLevel, char uniqueChar) {
+    injectSecurityContextUser(superUser);
+    Program program = createProgram(uniqueChar);
+    program.setAccessLevel(accessLevel);
+    manager.save(program);
+    program.getSharing().setPublicAccess("rwrw----");
+    manager.update(program);
+    injectSecurityContext(userDetailsB);
+
+    return program;
+  }
+}
diff --git a/dhis-2/dhis-test-web-api/src/test/java/org/hisp/dhis/webapi/controller/tracker/ownership/TrackerOwnershipControllerTest.java b/dhis-2/dhis-test-web-api/src/test/java/org/hisp/dhis/webapi/controller/tracker/ownership/TrackerOwnershipControllerTest.java
index 406d19de1cfe..14e96fe32034 100644
--- a/dhis-2/dhis-test-web-api/src/test/java/org/hisp/dhis/webapi/controller/tracker/ownership/TrackerOwnershipControllerTest.java
+++ b/dhis-2/dhis-test-web-api/src/test/java/org/hisp/dhis/webapi/controller/tracker/ownership/TrackerOwnershipControllerTest.java
@@ -153,36 +153,6 @@ void shouldUpdateTrackerProgramOwnerAndBeAccessibleFromTransferredOrgUnit() {
             .content(HttpStatus.OK));
   }
 
-  @Test
-  void
-      shouldUpdateTrackerProgramOwnerAndBeAccessibleFromTransferredOrgUnitUsingDeprecatedParameter() {
-    assertWebMessage(
-        "OK",
-        200,
-        "OK",
-        "Ownership transferred",
-        PUT(
-                "/tracker/ownership/transfer?trackedEntity={te}&program={prog}&ou={ou}",
-                teUid,
-                pId,
-                orgUnitBUid)
-            .content(HttpStatus.OK));
-  }
-
-  @Test
-  void shouldFailToTransferIfGivenDeprecatedAndNewOrgUnitParameter() {
-    assertStartsWith(
-        "Only one parameter of 'ou'",
-        PUT(
-                "/tracker/ownership/transfer?trackedEntity={te}&program={prog}&ou={ou}&orgUnit={orgUnit}",
-                teUid,
-                pId,
-                orgUnitBUid,
-                orgUnitBUid)
-            .error(HttpStatus.BAD_REQUEST)
-            .getMessage());
-  }
-
   @Test
   void shouldFailToUpdateWhenNoTrackedEntityIsPresent() {
     assertStartsWith(
diff --git a/dhis-2/dhis-web-api/src/main/java/org/hisp/dhis/webapi/controller/tracker/ownership/TrackerOwnershipController.java b/dhis-2/dhis-web-api/src/main/java/org/hisp/dhis/webapi/controller/tracker/ownership/TrackerOwnershipController.java
index 70eb5273f705..9230f73236a9 100644
--- a/dhis-2/dhis-web-api/src/main/java/org/hisp/dhis/webapi/controller/tracker/ownership/TrackerOwnershipController.java
+++ b/dhis-2/dhis-web-api/src/main/java/org/hisp/dhis/webapi/controller/tracker/ownership/TrackerOwnershipController.java
@@ -30,11 +30,10 @@
 package org.hisp.dhis.webapi.controller.tracker.ownership;
 
 import static org.hisp.dhis.dxf2.webmessage.WebMessageUtils.ok;
-import static org.hisp.dhis.webapi.controller.tracker.RequestParamsValidator.validateMandatoryDeprecatedUidParameter;
+import static org.hisp.dhis.tracker.export.trackedentity.TrackedEntityParams.FALSE;
 import static org.springframework.http.MediaType.APPLICATION_JSON_VALUE;
 
 import org.hisp.dhis.common.DhisApiVersion;
-import org.hisp.dhis.common.IdentifiableObjectManager;
 import org.hisp.dhis.common.OpenApi;
 import org.hisp.dhis.common.UID;
 import org.hisp.dhis.dxf2.webmessage.WebMessage;
@@ -42,15 +41,9 @@
 import org.hisp.dhis.feedback.ForbiddenException;
 import org.hisp.dhis.feedback.NotFoundException;
 import org.hisp.dhis.fieldfilter.FieldFilterService;
-import org.hisp.dhis.organisationunit.OrganisationUnitService;
 import org.hisp.dhis.program.Program;
-import org.hisp.dhis.program.ProgramService;
-import org.hisp.dhis.trackedentity.TrackedEntity;
 import org.hisp.dhis.tracker.acl.TrackerOwnershipManager;
-import org.hisp.dhis.tracker.export.trackedentity.TrackedEntityParams;
 import org.hisp.dhis.tracker.export.trackedentity.TrackedEntityService;
-import org.hisp.dhis.user.CurrentUserUtil;
-import org.hisp.dhis.user.UserDetails;
 import org.hisp.dhis.webapi.mvc.annotation.ApiVersion;
 import org.hisp.dhis.webapi.service.ContextService;
 import org.springframework.beans.factory.annotation.Autowired;
@@ -72,7 +65,7 @@
 @ApiVersion({DhisApiVersion.DEFAULT, DhisApiVersion.ALL})
 public class TrackerOwnershipController {
 
-  @Autowired private TrackerOwnershipManager trackerOwnershipAccessManager;
+  @Autowired private TrackerOwnershipManager trackerOwnershipManager;
 
   @Autowired protected FieldFilterService fieldFilterService;
 
@@ -80,12 +73,6 @@ public class TrackerOwnershipController {
 
   @Autowired private TrackedEntityService trackedEntityService;
 
-  @Autowired private ProgramService programService;
-
-  @Autowired private OrganisationUnitService organisationUnitService;
-
-  @Autowired private IdentifiableObjectManager manager;
-
   // -------------------------------------------------------------------------
   // 1. Transfer ownership if the logged in user is part of the owner ou.
   // 2. Break the glass and override ownership.
@@ -94,21 +81,11 @@ public class TrackerOwnershipController {
   @PutMapping(value = "/transfer", produces = APPLICATION_JSON_VALUE)
   @ResponseBody
   public WebMessage updateTrackerProgramOwner(
-      @RequestParam UID trackedEntity,
-      @RequestParam UID program,
-      @Deprecated(
-              since = "2.42",
-              forRemoval = true) // TODO(tracker) remove `ou` parameter in favor of `orgUnit` in v43
-          @RequestParam(required = false)
-          UID ou,
-      @RequestParam(required = false) UID orgUnit)
-      throws BadRequestException, ForbiddenException, NotFoundException {
-    UID orgUnitUid = validateMandatoryDeprecatedUidParameter("ou", ou, "orgUnit", orgUnit);
+      @RequestParam UID trackedEntity, @RequestParam UID program, @RequestParam UID orgUnit)
+      throws ForbiddenException, NotFoundException, BadRequestException {
+    trackerOwnershipManager.transferOwnership(
+        trackedEntityService.getTrackedEntity(trackedEntity, program, FALSE), program, orgUnit);
 
-    trackerOwnershipAccessManager.transferOwnership(
-        trackedEntityService.getTrackedEntity(trackedEntity, program, TrackedEntityParams.FALSE),
-        programService.getProgram(program.getValue()),
-        organisationUnitService.getOrganisationUnit(orgUnitUid.getValue()));
     return ok("Ownership transferred");
   }
 
@@ -116,14 +93,8 @@ public WebMessage updateTrackerProgramOwner(
   @ResponseBody
   public WebMessage grantTemporaryAccess(
       @RequestParam UID trackedEntity, @RequestParam String reason, @RequestParam UID program)
-      throws ForbiddenException {
-    UserDetails currentUser = CurrentUserUtil.getCurrentUserDetails();
-    trackerOwnershipAccessManager.grantTemporaryOwnership(
-        // TODO(tracker) jdbc-hibernate: check the impact on performance
-        manager.get(TrackedEntity.class, trackedEntity.getValue()),
-        programService.getProgram(program.getValue()),
-        currentUser,
-        reason);
+      throws ForbiddenException, BadRequestException, NotFoundException {
+    trackerOwnershipManager.grantTemporaryOwnership(trackedEntity, program, reason);
 
     return ok("Temporary Ownership granted");
   }


Reviews:
user: login: github-advanced-security[bot]
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-14T14:42:56Z
user: login: github-advanced-security[bot]
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-15T07:44:46Z
user: login: enricocolasante
body: We need to use the same strategy in `transferOwnership` and `grantTemporaryOwnership` methods.
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-16T08:09:20Z
user: login: ameenhere
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-16T09:09:31Z
user: login: muilpp
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-16T09:29:44Z
user: login: enricocolasante
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-16T09:38:14Z
user: login: enricocolasante
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-04-16T09:38:23Z
user: login: muilpp
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-16T09:49:04Z
user: login: larshelge
state: APPROVED
author_association: MEMBER
submitted_at: 2025-04-23T13:23:48Z

Review comments:
diff_hunk: @@ -372,21 +213,11 @@
                 > 0);
   }
 
-  /**
-   * Returns key used to store and retrieve cached records for ownership
-   *
-   * @return a String representing a record of ownership
-   */
-  private String getOwnershipCacheKey(TrackedEntity trackedEntity, Program program) {
+  public String getOwnershipCacheKey(TrackedEntity trackedEntity, Program program) {
path: dhis-2/dhis-services/dhis-service-tracker/src/main/java/org/hisp/dhis/tracker/acl/DefaultTrackerOwnershipAccessManager.java
user: login: github-advanced-security[bot]
body: ## Missing Override annotation

This method overrides [TrackerOwnershipAccessManager.getOwnershipCacheKey](1); it is advisable to add an Override annotation.

[Show more details](https://github.com/dhis2/dhis2-core/security/code-scanning/9625)
created_at: 2025-04-14T14:42:56Z
author_association: CONTRIBUTOR
diff_hunk: @@ -372,21 +213,11 @@
                 > 0);
   }
 
-  /**
-   * Returns key used to store and retrieve cached records for ownership
-   *
-   * @return a String representing a record of ownership
-   */
-  private String getOwnershipCacheKey(TrackedEntity trackedEntity, Program program) {
+  public String getOwnershipCacheKey(TrackedEntity trackedEntity, Program program) {
     return trackedEntity.getUid() + "_" + program.getUid();
   }
 
-  /**
-   * Returns key used to store and retrieve cached records for ownership
-   *
-   * @return a String representing a record of ownership
-   */
-  private String getTempOwnershipCacheKey(String teUid, String programUid, String userUid) {
+  public String getTempOwnershipCacheKey(String teUid, String programUid, String userUid) {
path: dhis-2/dhis-services/dhis-service-tracker/src/main/java/org/hisp/dhis/tracker/acl/DefaultTrackerOwnershipAccessManager.java
user: login: github-advanced-security[bot]
body: ## Missing Override annotation

This method overrides [TrackerOwnershipAccessManager.getTempOwnershipCacheKey](1); it is advisable to add an Override annotation.

[Show more details](https://github.com/dhis2/dhis2-core/security/code-scanning/9624)
created_at: 2025-04-14T14:42:56Z
author_association: CONTRIBUTOR
diff_hunk: @@ -298,6 +298,6 @@
               ownershipAccessManager.hasAccess(user, te, queryParams.getEnrolledInTrackerProgram());
     }
 
-    return te -> trackerAccessManager.canRead(user, te, skipOwnershipCheck).isEmpty();
+    return te -> trackerDataAccessManager.canRead(user, te, skipOwnershipCheck).isEmpty();
path: dhis-2/dhis-services/dhis-service-tracker/src/main/java/org/hisp/dhis/tracker/export/trackedentity/DefaultTrackedEntityService.java
user: login: github-advanced-security[bot]
body: ## Deprecated method or constructor invocation

Invoking [TrackerDataAccessManager.canRead](1) should be avoided because it has been deprecated.

[Show more details](https://github.com/dhis2/dhis2-core/security/code-scanning/9623)
created_at: 2025-04-14T14:42:56Z
author_association: CONTRIBUTOR
diff_hunk: @@ -154,12 +173,27 @@
     }
   }
 
-  @Override
+  /**
+   * Grant temporary ownership for a user for a specific tracked entity - program combination
+   *
+   * @param trackedEntityUid The UID of the tracked entity object
+   * @param programUid The UID of the program object
+   * @param reason The reason for requesting temporary ownership
+   */
   @Transactional
-  public void grantTemporaryOwnership(
-      @Nonnull TrackedEntity trackedEntity, Program program, UserDetails user, String reason)
-      throws ForbiddenException {
-    validateGrantTemporaryOwnershipInputs(trackedEntity, program, user);
+  public void grantTemporaryOwnership(UID trackedEntityUid, UID programUid, String reason)
path: dhis-2/dhis-services/dhis-service-tracker/src/main/java/org/hisp/dhis/tracker/acl/DefaultTrackerOwnershipManager.java
user: login: github-advanced-security[bot]
body: ## Missing Override annotation

This method overrides [TrackerOwnershipManager.grantTemporaryOwnership](1); it is advisable to add an Override annotation.

[Show more details](https://github.com/dhis2/dhis2-core/security/code-scanning/9634)
created_at: 2025-04-15T07:44:46Z
author_association: CONTRIBUTOR
diff_hunk: @@ -150,16 +161,26 @@ public void transferOwnership(
     } else {
       log.error("Unauthorized attempt to change ownership");
       throw new ForbiddenException(
-          "User does not have access to change ownership for the entity-program combination");
+          "Tracked entity not transferred. User does not have access to change ownership for the entity-program combination");
     }
   }
 
   @Override
   @Transactional
-  public void grantTemporaryOwnership(
-      @Nonnull TrackedEntity trackedEntity, Program program, UserDetails user, String reason)
-      throws ForbiddenException {
-    validateGrantTemporaryOwnershipInputs(trackedEntity, program, user);
+  public void grantTemporaryOwnership(UID trackedEntityUid, UID programUid, String reason)
path: dhis-2/dhis-services/dhis-service-tracker/src/main/java/org/hisp/dhis/tracker/acl/DefaultTrackerOwnershipManager.java
user: login: enricocolasante
body: Add `@Nonnull` and `@CheckForNull` annotations
created_at: 2025-04-16T07:45:29Z
author_association: CONTRIBUTOR
diff_hunk: @@ -150,16 +161,26 @@ public void transferOwnership(
     } else {
       log.error("Unauthorized attempt to change ownership");
       throw new ForbiddenException(
-          "User does not have access to change ownership for the entity-program combination");
+          "Tracked entity not transferred. User does not have access to change ownership for the entity-program combination");
     }
   }
 
   @Override
   @Transactional
-  public void grantTemporaryOwnership(
-      @Nonnull TrackedEntity trackedEntity, Program program, UserDetails user, String reason)
-      throws ForbiddenException {
-    validateGrantTemporaryOwnershipInputs(trackedEntity, program, user);
+  public void grantTemporaryOwnership(UID trackedEntityUid, UID programUid, String reason)
path: dhis-2/dhis-services/dhis-service-tracker/src/main/java/org/hisp/dhis/tracker/acl/DefaultTrackerOwnershipManager.java
user: login: enricocolasante
body: Can `trackedEntityUid` ever be null?
created_at: 2025-04-16T07:50:38Z
author_association: CONTRIBUTOR
diff_hunk: @@ -104,26 +113,28 @@ public DefaultTrackerOwnershipManager(
   /** Cache for storing recent temporary ownership checks */
   private final Cache<Boolean> tempOwnerCache;
 
-  // -------------------------------------------------------------------------
-  // Implementation
-  // -------------------------------------------------------------------------
-
   @Override
   @Transactional
-  public void transferOwnership(
-      TrackedEntity trackedEntity, Program program, OrganisationUnit orgUnit)
-      throws ForbiddenException {
-    if (trackedEntity == null || program == null || orgUnit == null) {
-      return;
+  // TODO(tracker) This method should accept a tracked entity UID instead. The problem is, we can't
path: dhis-2/dhis-services/dhis-service-tracker/src/main/java/org/hisp/dhis/tracker/acl/DefaultTrackerOwnershipManager.java
user: login: enricocolasante
body: We are solving the same problem in 2 quite different ways.
In this method we are passing the trackedEntity from the service and we know that it is already completely validated, then we need to get the managed entity to be able to save a `ProgramOwnershipHistory`. I agree that we should get only the UID and do everything in the method.

In `grantTemporaryOwnership` we are passing the UID, and we are manually validating the tracked entity (tracked entity type, existence, etc), it would be better to use `TrackedEntityService` here as well in the controller and then just retrieve the managed entity from the `manager`, so we know all the proper validations are run.
created_at: 2025-04-16T08:05:20Z
author_association: CONTRIBUTOR
diff_hunk: @@ -178,51 +199,29 @@ public void grantTemporaryOwnership(
         getTempOwnershipCacheKey(trackedEntity.getUid(), program.getUid(), user.getUid()));
   }
 
-  private void validateGrantTemporaryOwnershipInputs(
-      TrackedEntity trackedEntity, Program program, UserDetails user) throws ForbiddenException {
-    if (program == null) {
-      throw new ForbiddenException(
-          "Temporary ownership not created. Program supplied does not exist.");
-    }
-
+  private void validateTrackedEntity(TrackedEntity trackedEntity, UserDetails user)
+      throws ForbiddenException {
     if (trackedEntity == null) {
       throw new ForbiddenException(
path: dhis-2/dhis-services/dhis-service-tracker/src/main/java/org/hisp/dhis/tracker/acl/DefaultTrackerOwnershipManager.java
user: login: enricocolasante
body: If we use the `TrackedEntityService` we do not need to worry about this but this one should be more a `NOT_FOUND` exception
created_at: 2025-04-16T08:06:15Z
author_association: CONTRIBUTOR
diff_hunk: @@ -52,9 +58,39 @@ public class TrackerProgramService {
   @Nonnull private final ProgramService programService;
   @Nonnull private final AclService aclService;
 
+  /**
+   * Returns the tracker program associated with the provided UID if it exists and is accessible to
+   * the current user.
+   */
+  @Transactional(readOnly = true)
+  public @Nonnull Program getTrackerProgram(@CheckForNull UID programUid)
+      throws BadRequestException, ForbiddenException {
+    if (programUid == null) {
path: dhis-2/dhis-services/dhis-service-tracker/src/main/java/org/hisp/dhis/tracker/acl/TrackerProgramService.java
user: login: enricocolasante
body: I would personally give this responsibility to the caller and annotate `programUid` as `@Nonnull`
created_at: 2025-04-16T08:08:03Z
author_association: CONTRIBUTOR
diff_hunk: @@ -104,26 +113,28 @@ public DefaultTrackerOwnershipManager(
   /** Cache for storing recent temporary ownership checks */
   private final Cache<Boolean> tempOwnerCache;
 
-  // -------------------------------------------------------------------------
-  // Implementation
-  // -------------------------------------------------------------------------
-
   @Override
   @Transactional
-  public void transferOwnership(
-      TrackedEntity trackedEntity, Program program, OrganisationUnit orgUnit)
-      throws ForbiddenException {
-    if (trackedEntity == null || program == null || orgUnit == null) {
-      return;
+  // TODO(tracker) This method should accept a tracked entity UID instead. The problem is, we can't
path: dhis-2/dhis-services/dhis-service-tracker/src/main/java/org/hisp/dhis/tracker/acl/DefaultTrackerOwnershipManager.java
user: login: muilpp
body: In `grantTemporaryOwnership`, the user wants to break the glass, so we can't run the usual validations.
The point of breaking the glass is to temporarily access an enrollment that the user is not the owner of and that is not in their capture scope. I think this is the only exception where we can't use the `TrackedEntityService`, and I need to manually validate the ACL.
created_at: 2025-04-16T08:52:00Z
author_association: CONTRIBUTOR
diff_hunk: @@ -150,16 +161,26 @@ public void transferOwnership(
     } else {
       log.error("Unauthorized attempt to change ownership");
       throw new ForbiddenException(
-          "User does not have access to change ownership for the entity-program combination");
+          "Tracked entity not transferred. User does not have access to change ownership for the entity-program combination");
     }
   }
 
   @Override
   @Transactional
-  public void grantTemporaryOwnership(
-      @Nonnull TrackedEntity trackedEntity, Program program, UserDetails user, String reason)
-      throws ForbiddenException {
-    validateGrantTemporaryOwnershipInputs(trackedEntity, program, user);
+  public void grantTemporaryOwnership(UID trackedEntityUid, UID programUid, String reason)
path: dhis-2/dhis-services/dhis-service-tracker/src/main/java/org/hisp/dhis/tracker/acl/DefaultTrackerOwnershipManager.java
user: login: muilpp
body: This is the service API, so it could be null if supplied by the API user.
But we can do the same as in `getTrackerProgram` and give this responsibility to whoever calls the method.
created_at: 2025-04-16T09:00:43Z
author_association: CONTRIBUTOR
diff_hunk: @@ -104,26 +113,28 @@ public DefaultTrackerOwnershipManager(
   /** Cache for storing recent temporary ownership checks */
   private final Cache<Boolean> tempOwnerCache;
 
-  // -------------------------------------------------------------------------
-  // Implementation
-  // -------------------------------------------------------------------------
-
   @Override
   @Transactional
-  public void transferOwnership(
-      TrackedEntity trackedEntity, Program program, OrganisationUnit orgUnit)
-      throws ForbiddenException {
-    if (trackedEntity == null || program == null || orgUnit == null) {
-      return;
+  // TODO(tracker) This method should accept a tracked entity UID instead. The problem is, we can't
+  // use the TrackedEntityService as it introduces a cyclic dependency. That's because the ownership
+  // manager is used to filter TEs after hitting the database. As soon as we move those filters into
+  // the store to fix the pagination issue, we should be able to use the TrackedEntityService here,
+  // so we can run all validations in this service instead of the controller.
+  public void transferOwnership(TrackedEntity trackedEntity, UID programUid, UID orgUnitUid)
+      throws ForbiddenException, BadRequestException {
+    Program program = trackerProgramService.getTrackerProgram(programUid);
+    OrganisationUnit orgUnit = organisationUnitService.getOrganisationUnit(orgUnitUid.getValue());
+
+    if (orgUnit == null) {
+      throw new ForbiddenException(
+          "Tracked entity not transferred. Org unit supplied does not exist.");
     }
 
-    UserDetails currentUser = CurrentUserUtil.getCurrentUserDetails();
-
-    if (hasAccess(currentUser, trackedEntity, program)) {
+    if (hasAccess(getCurrentUserDetails(), trackedEntity, program)) {
path: dhis-2/dhis-services/dhis-service-tracker/src/main/java/org/hisp/dhis/tracker/acl/DefaultTrackerOwnershipManager.java
user: login: ameenhere
body: Perhaps we can flip some of these if conditions to have easy to read flow (avoids chained or inner ifs)

Like

if (orgUnit == null)
throw

if(!hasAccess....)
throw

if (!programService.hasOrgUnit(program, orgUnit))
throw

if (any other validation failure)
throw

....normal success flow...

created_at: 2025-04-16T09:09:31Z
author_association: CONTRIBUTOR
diff_hunk: @@ -178,51 +199,29 @@ public void grantTemporaryOwnership(
         getTempOwnershipCacheKey(trackedEntity.getUid(), program.getUid(), user.getUid()));
   }
 
-  private void validateGrantTemporaryOwnershipInputs(
-      TrackedEntity trackedEntity, Program program, UserDetails user) throws ForbiddenException {
-    if (program == null) {
-      throw new ForbiddenException(
-          "Temporary ownership not created. Program supplied does not exist.");
-    }
-
+  private void validateTrackedEntity(TrackedEntity trackedEntity, UserDetails user)
+      throws ForbiddenException {
     if (trackedEntity == null) {
       throw new ForbiddenException(
path: dhis-2/dhis-services/dhis-service-tracker/src/main/java/org/hisp/dhis/tracker/acl/DefaultTrackerOwnershipManager.java
user: login: muilpp
body: as explained [here](https://github.com/dhis2/dhis2-core/pull/20637/files#r2046343406), I can't use the `TrackedEntityService`, but I agree with throwing a `NOT_FOUND`
created_at: 2025-04-16T09:19:06Z
author_association: CONTRIBUTOR
diff_hunk: @@ -150,16 +161,26 @@ public void transferOwnership(
     } else {
       log.error("Unauthorized attempt to change ownership");
       throw new ForbiddenException(
-          "User does not have access to change ownership for the entity-program combination");
+          "Tracked entity not transferred. User does not have access to change ownership for the entity-program combination");
     }
   }
 
   @Override
   @Transactional
-  public void grantTemporaryOwnership(
-      @Nonnull TrackedEntity trackedEntity, Program program, UserDetails user, String reason)
-      throws ForbiddenException {
-    validateGrantTemporaryOwnershipInputs(trackedEntity, program, user);
+  public void grantTemporaryOwnership(UID trackedEntityUid, UID programUid, String reason)
path: dhis-2/dhis-services/dhis-service-tracker/src/main/java/org/hisp/dhis/tracker/acl/DefaultTrackerOwnershipManager.java
user: login: enricocolasante
body: API user can never supplies a null value, the request parameter is required.
created_at: 2025-04-16T09:38:13Z
author_association: CONTRIBUTOR
diff_hunk: @@ -150,16 +161,26 @@ public void transferOwnership(
     } else {
       log.error("Unauthorized attempt to change ownership");
       throw new ForbiddenException(
-          "User does not have access to change ownership for the entity-program combination");
+          "Tracked entity not transferred. User does not have access to change ownership for the entity-program combination");
     }
   }
 
   @Override
   @Transactional
-  public void grantTemporaryOwnership(
-      @Nonnull TrackedEntity trackedEntity, Program program, UserDetails user, String reason)
-      throws ForbiddenException {
-    validateGrantTemporaryOwnershipInputs(trackedEntity, program, user);
+  public void grantTemporaryOwnership(UID trackedEntityUid, UID programUid, String reason)
path: dhis-2/dhis-services/dhis-service-tracker/src/main/java/org/hisp/dhis/tracker/acl/DefaultTrackerOwnershipManager.java
user: login: muilpp
body: I meant if the service API is used directly, in that case it could be null, but that's now annotated with `@Nonnull` anyway
created_at: 2025-04-16T09:49:04Z
author_association: CONTRIBUTOR

PR comments:
user: login: sonarqubecloud[bot]
created_at: 2025-04-16T10:55:23Z
body: ## [![Quality Gate Passed](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/checks/QualityGateBadge/qg-passed-20px.png 'Quality Gate Passed')](https://sonarcloud.io/dashboard?id=dhis2_dhis2-core&pullRequest=20637) **Quality Gate passed**  
Issues  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/passed.svg '') [0 New issues](https://sonarcloud.io/project/issues?id=dhis2_dhis2-core&pullRequest=20637&issueStatuses=OPEN,CONFIRMED&sinceLeakPeriod=true)  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/accepted.svg '') [3 Accepted issues](https://sonarcloud.io/project/issues?id=dhis2_dhis2-core&pullRequest=20637&issueStatuses=ACCEPTED)

Measures  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/passed.svg '') [0 Security Hotspots](https://sonarcloud.io/project/security_hotspots?id=dhis2_dhis2-core&pullRequest=20637&issueStatuses=OPEN,CONFIRMED&sinceLeakPeriod=true)  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/passed.svg '') [0.0% Coverage on New Code](https://sonarcloud.io/component_measures?id=dhis2_dhis2-core&pullRequest=20637&metric=new_coverage&view=list)  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/passed.svg '') [0.0% Duplication on New Code](https://sonarcloud.io/component_measures?id=dhis2_dhis2-core&pullRequest=20637&metric=new_duplicated_lines_density&view=list)  
  
[See analysis details on SonarQube Cloud](https://sonarcloud.io/dashboard?id=dhis2_dhis2-core&pullRequest=20637)


author_association: NONE