From 69a18cc72b1b22b846264e8c2c776d096d45222c Mon Sep 17 00:00:00 2001 From: lostlevels Date: Tue, 9 Apr 2024 20:10:33 -0700 Subject: [PATCH 01/20] [BACK-2785] Add keycloak extension endpoint to clone a user making it a child with a new username and email and giving the newly created parent the child's previous username and email along w/ all other properties. --- admin/pom.xml | 6 ++ .../resource/TidepoolAdminResource.java | 88 +++++++++++++++++++ 2 files changed, 94 insertions(+) diff --git a/admin/pom.xml b/admin/pom.xml index e20604d..934ed79 100644 --- a/admin/pom.xml +++ b/admin/pom.xml @@ -48,6 +48,12 @@ ${keycloak.version} provided + + org.keycloak + keycloak-model-jpa + ${keycloak.version} + provided + com.google.auto.service auto-service diff --git a/admin/src/main/java/org/tidepool/keycloak/extensions/resource/TidepoolAdminResource.java b/admin/src/main/java/org/tidepool/keycloak/extensions/resource/TidepoolAdminResource.java index f224700..5746533 100644 --- a/admin/src/main/java/org/tidepool/keycloak/extensions/resource/TidepoolAdminResource.java +++ b/admin/src/main/java/org/tidepool/keycloak/extensions/resource/TidepoolAdminResource.java @@ -1,20 +1,27 @@ package org.tidepool.keycloak.extensions.resource; import org.keycloak.models.*; +import org.keycloak.validate.Validators; +import org.keycloak.connections.jpa.JpaConnectionProvider; import org.keycloak.models.utils.ModelToRepresentation; import org.keycloak.representations.idm.CredentialRepresentation; import org.keycloak.representations.idm.UserRepresentation; +import org.keycloak.models.utils.KeycloakModelUtils; import javax.ws.rs.GET; import javax.ws.rs.POST; import javax.ws.rs.Path; import javax.ws.rs.Produces; +import javax.ws.rs.Consumes; import javax.ws.rs.PathParam; import javax.ws.rs.QueryParam; import javax.ws.rs.BadRequestException; +import javax.ws.rs.InternalServerErrorException; import javax.ws.rs.NotFoundException; import javax.ws.rs.core.MediaType; import javax.ws.rs.core.Response; +import javax.persistence.EntityManager; +import javax.persistence.EntityTransaction; import java.util.ArrayList; import java.util.List; import java.util.stream.Collectors; @@ -67,6 +74,87 @@ public Response unlinkFederatedUser(@PathParam("userId") final String userId) { return Response.status(Response.Status.NO_CONTENT).build(); } + // This will clone the user, known as the child, with an id of userId to + // have a new parent that has the child's previous username and email, + // while the child will have the new email & username newUsername + @POST + @Consumes(MediaType.APPLICATION_JSON) + @Path("clone-user/{userId}") + public Response cloneUser(@PathParam("userId") final String userId, final CloneUserBody body) { + auth.users().canManage(); + // Todo Validators for email - the API has changed from keycloak 21 => 24 + + RealmModel realm = session.getContext().getRealm(); + UserModel user = session.users().getUserById(realm, userId); + if (user == null) { + throw new NotFoundException("User not found."); + } + String newUsername = body.newUsername; + JpaConnectionProvider connProvider = session.getProvider(JpaConnectionProvider.class); + if (connProvider == null) { + throw new InternalServerErrorException("Unable to get persistence connection provider."); + } + EntityManager em = connProvider.getEntityManager(); + EntityTransaction tx = em.getTransaction(); + String newParentUserId = KeycloakModelUtils.generateId(); + String parentUsername = user.getUsername(); + String childUserId = userId; + + // TODO: confirm this is safe or need to create a new EntityManager per call + tx.begin(); + + // Update the child to have the new username and email of newUsername + em.createNativeQuery("UPDATE user_entity SET email = ?1, email_constraint = ?1, username = ?1 WHERE id = ?2"). + setParameter(1, newUsername). + setParameter(2, childUserId). + executeUpdate(); + + // Create a new parent user with the same properties as the child + // except the parent will now assume the child's previous email / + // username. + em.createNativeQuery("INSERT INTO user_entity(id, email, email_constraint, email_verified, enabled, federation_link, first_name, last_name, realm_id, username, created_timestamp, service_account_client_link, not_before) SELECT ?1, ?2, ?2, email_verified, enabled, federation_link, first_name, last_name, realm_id, ?2, created_timestamp, service_account_client_link, not_before FROM user_entity WHERE id = ?3"). + setParameter(1, newParentUserId). + setParameter(2, parentUsername). + setParameter(3, childUserId). + executeUpdate(); + + // Copy over the credentials of the child to the parent so the parent can login with the same credentials. + em.createNativeQuery("INSERT INTO credential(id, salt, type, user_id, created_date, user_label, secret_data, credential_data, priority) SELECT ?1, salt, type, ?2, created_date, user_label, secret_data, credential_data, priority FROM credential WHERE user_id = ?3"). + setParameter(1, KeycloakModelUtils.generateId()). + setParameter(2, newParentUserId). + setParameter(3, childUserId). + executeUpdate(); + + // copy over role mappings + em.createNativeQuery("INSERT INTO user_role_mapping(role_id, user_id) SELECT role_id, ?1 FROM user_role_mapping WHERE user_id = ?2"). + setParameter(1, newParentUserId). + setParameter(2, childUserId). + executeUpdate(); + + // copy over required actions + em.createNativeQuery("INSERT INTO user_required_action(user_id, required_action) SELECT ?1, required_action FROM user_required_action WHERE user_id = ?2"). + setParameter(1, newParentUserId). + setParameter(2, childUserId). + executeUpdate(); + + // copy over attributes (profile) + em.createNativeQuery("INSERT INTO user_attribute(name, value, user_id, id) SELECT name, value, ?1, ?2 FROM user_attribute WHERE user_id = ?3"). + setParameter(1, newParentUserId). + setParameter(2, KeycloakModelUtils.generateId()). + setParameter(3, childUserId). + executeUpdate(); + + // copy over group memberships + em.createNativeQuery("INSERT INTO user_group_membership(group_id, user_id) SELECT group_id, ?1 FROM user_group_membership WHERE user_id = ?2"). + setParameter(1, newParentUserId). + setParameter(2, childUserId). + executeUpdate(); + + tx.commit(); + + return Response.status(Response.Status.NO_CONTENT).build(); + } + private UserRepresentation toRepresentation(UserModel user, RealmModel realm) { UserRepresentation representation = ModelToRepresentation.toRepresentation(session, realm, user); representation.setRealmRoles(getRoles(user)); From beb3e5139773b2a8f27bd2b6b6fd8f49c39044cb Mon Sep 17 00:00:00 2001 From: lostlevels Date: Tue, 9 Apr 2024 20:26:28 -0700 Subject: [PATCH 02/20] Clear user cache indirectly. --- .../extensions/resource/TidepoolAdminResource.java | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/admin/src/main/java/org/tidepool/keycloak/extensions/resource/TidepoolAdminResource.java b/admin/src/main/java/org/tidepool/keycloak/extensions/resource/TidepoolAdminResource.java index 5746533..0ab66f3 100644 --- a/admin/src/main/java/org/tidepool/keycloak/extensions/resource/TidepoolAdminResource.java +++ b/admin/src/main/java/org/tidepool/keycloak/extensions/resource/TidepoolAdminResource.java @@ -137,7 +137,7 @@ public Response cloneUser(@PathParam("userId") final String userId, final CloneU setParameter(2, childUserId). executeUpdate(); - // copy over attributes (profile) + // copy over attributes (profile) - this may need modification to omit child properties in parent and parent properties in child. em.createNativeQuery("INSERT INTO user_attribute(name, value, user_id, id) SELECT name, value, ?1, ?2 FROM user_attribute WHERE user_id = ?3"). setParameter(1, newParentUserId). setParameter(2, KeycloakModelUtils.generateId()). @@ -152,6 +152,11 @@ public Response cloneUser(@PathParam("userId") final String userId, final CloneU tx.commit(); + // keycloak 23+ API has removed cache eviction methods, so instead set child user's email and username "again" through the model. If we got this far, + // the previous transaction has succeeded so this is "safe" and will cause a user updated event which will clear the cache entry for the given user. + user.setEmail(newUsername); + user.setUsername(newUsername); + return Response.status(Response.Status.NO_CONTENT).build(); } From f1dbc3fa32938786633a60ed42d0539dcfcc649e Mon Sep 17 00:00:00 2001 From: lostlevels Date: Wed, 10 Apr 2024 13:07:14 -0700 Subject: [PATCH 03/20] Don't copy over certain user attributes that belong only to the child and have a separate EntityManager per transaction. --- .../resource/TidepoolAdminResource.java | 33 +++++++++++++++---- 1 file changed, 27 insertions(+), 6 deletions(-) diff --git a/admin/src/main/java/org/tidepool/keycloak/extensions/resource/TidepoolAdminResource.java b/admin/src/main/java/org/tidepool/keycloak/extensions/resource/TidepoolAdminResource.java index 0ab66f3..edb308c 100644 --- a/admin/src/main/java/org/tidepool/keycloak/extensions/resource/TidepoolAdminResource.java +++ b/admin/src/main/java/org/tidepool/keycloak/extensions/resource/TidepoolAdminResource.java @@ -1,8 +1,11 @@ package org.tidepool.keycloak.extensions.resource; +import java.util.Arrays; + import org.keycloak.models.*; import org.keycloak.validate.Validators; import org.keycloak.connections.jpa.JpaConnectionProvider; +import org.keycloak.connections.jpa.DefaultJpaConnectionProviderFactory; import org.keycloak.models.utils.ModelToRepresentation; import org.keycloak.representations.idm.CredentialRepresentation; import org.keycloak.representations.idm.UserRepresentation; @@ -76,9 +79,11 @@ public Response unlinkFederatedUser(@PathParam("userId") final String userId) { // This will clone the user, known as the child, with an id of userId to // have a new parent that has the child's previous username and email, - // while the child will have the new email & username newUsername + // while the child will have the new email and username from the newUsername + // field of the POST body @POST @Consumes(MediaType.APPLICATION_JSON) + @Produces({MediaType.APPLICATION_JSON}) @Path("clone-user/{userId}") public Response cloneUser(@PathParam("userId") final String userId, final CloneUserBody body) { auth.users().canManage(); @@ -94,13 +99,14 @@ public Response cloneUser(@PathParam("userId") final String userId, final CloneU if (connProvider == null) { throw new InternalServerErrorException("Unable to get persistence connection provider."); } - EntityManager em = connProvider.getEntityManager(); + + // EntityManager is not thread safe so create new application managed EntityManager + EntityManager em = connProvider.getEntityManager().getEntityManagerFactory().createEntityManager(); EntityTransaction tx = em.getTransaction(); String newParentUserId = KeycloakModelUtils.generateId(); String parentUsername = user.getUsername(); String childUserId = userId; - // TODO: confirm this is safe or need to create a new EntityManager per call tx.begin(); // Update the child to have the new username and email of newUsername @@ -137,11 +143,22 @@ public Response cloneUser(@PathParam("userId") final String userId, final CloneU setParameter(2, childUserId). executeUpdate(); - // copy over attributes (profile) - this may need modification to omit child properties in parent and parent properties in child. - em.createNativeQuery("INSERT INTO user_attribute(name, value, user_id, id) SELECT name, value, ?1, ?2 FROM user_attribute WHERE user_id = ?3"). + // copy over attributes (profile), ignoring certain attributes as that is part of the child. The child's custodian's fullName will be the parent's fullName. + List ignoredAttributes = Arrays.asList(new String[]{ "profile_birthday", "profile_diagnosis_date", "profile_diagnosis_type", "profile_fullname", "profile_custodian_full_name", "profile_target_devices" }); + em.createNativeQuery("INSERT INTO user_attribute(name, value, user_id, id) SELECT name, value, ?1, ?2 FROM user_attribute WHERE user_id = ?3 AND name NOT IN (?4)"). setParameter(1, newParentUserId). setParameter(2, KeycloakModelUtils.generateId()). setParameter(3, childUserId). + setParameter(4, ignoredAttributes). + executeUpdate(); + + // Set the fullName from the child's custodian's fullName + em.createNativeQuery("INSERT INTO user_attribute(name, value, user_id, id) SELECT ?1, value, ?2, ?3 FROM user_attribute WHERE user_id = ?4 AND name = ?5"). + setParameter(1, "profile_fullname"). + setParameter(2, newParentUserId). + setParameter(3, KeycloakModelUtils.generateId()). + setParameter(4, childUserId). + setParameter(5, "profile_custodian_full_name"). executeUpdate(); // copy over group memberships @@ -157,7 +174,11 @@ public Response cloneUser(@PathParam("userId") final String userId, final CloneU user.setEmail(newUsername); user.setUsername(newUsername); - return Response.status(Response.Status.NO_CONTENT).build(); + UserModel parentUser = session.users().getUserById(realm, newParentUserId); + if (parentUser == null) { + throw new InternalServerErrorException("unable to retrieve cloned user"); + } + return Response.status(Response.Status.CREATED).entity(toRepresentation(parentUser, realm)).build(); } private UserRepresentation toRepresentation(UserModel user, RealmModel realm) { From fcb42791a491b4ebeb7258b59a3d954f9a93a978 Mon Sep 17 00:00:00 2001 From: lostlevels Date: Wed, 10 Apr 2024 13:25:45 -0700 Subject: [PATCH 04/20] Add POST body. --- .../keycloak/extensions/resource/CloneUserBody.java | 13 +++++++++++++ 1 file changed, 13 insertions(+) create mode 100644 admin/src/main/java/org/tidepool/keycloak/extensions/resource/CloneUserBody.java diff --git a/admin/src/main/java/org/tidepool/keycloak/extensions/resource/CloneUserBody.java b/admin/src/main/java/org/tidepool/keycloak/extensions/resource/CloneUserBody.java new file mode 100644 index 0000000..a9681a7 --- /dev/null +++ b/admin/src/main/java/org/tidepool/keycloak/extensions/resource/CloneUserBody.java @@ -0,0 +1,13 @@ +package org.tidepool.keycloak.extensions.resource; + +public class CloneUserBody { + public String newUsername; + + public CloneUserBody() { + this.newUsername = ""; + } + + public CloneUserBody(String newUsername) { + this.newUsername = newUsername; + } +} From b1c06edd0b313a81d7a13923d87b764ea91b25dc Mon Sep 17 00:00:00 2001 From: lostlevels Date: Wed, 10 Apr 2024 14:23:24 -0700 Subject: [PATCH 05/20] Update comment to reflect keycloak 24. --- .../keycloak/extensions/resource/TidepoolAdminResource.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/admin/src/main/java/org/tidepool/keycloak/extensions/resource/TidepoolAdminResource.java b/admin/src/main/java/org/tidepool/keycloak/extensions/resource/TidepoolAdminResource.java index edb308c..b6ee965 100644 --- a/admin/src/main/java/org/tidepool/keycloak/extensions/resource/TidepoolAdminResource.java +++ b/admin/src/main/java/org/tidepool/keycloak/extensions/resource/TidepoolAdminResource.java @@ -169,7 +169,7 @@ public Response cloneUser(@PathParam("userId") final String userId, final CloneU tx.commit(); - // keycloak 23+ API has removed cache eviction methods, so instead set child user's email and username "again" through the model. If we got this far, + // The Keycloak 24+ modules have removed cache eviction methods, so instead set child user's email and username "again" through the model. If we got this far, // the previous transaction has succeeded so this is "safe" and will cause a user updated event which will clear the cache entry for the given user. user.setEmail(newUsername); user.setUsername(newUsername); From f66255958070d055503d11b4095f9df5c86244a9 Mon Sep 17 00:00:00 2001 From: lostlevels Date: Wed, 10 Apr 2024 18:46:09 -0700 Subject: [PATCH 06/20] Make sure custodiaL new email is in the right format and don't migrate it if it's already in that format. --- .../resource/TidepoolAdminResource.java | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/admin/src/main/java/org/tidepool/keycloak/extensions/resource/TidepoolAdminResource.java b/admin/src/main/java/org/tidepool/keycloak/extensions/resource/TidepoolAdminResource.java index b6ee965..64ac90b 100644 --- a/admin/src/main/java/org/tidepool/keycloak/extensions/resource/TidepoolAdminResource.java +++ b/admin/src/main/java/org/tidepool/keycloak/extensions/resource/TidepoolAdminResource.java @@ -1,6 +1,7 @@ package org.tidepool.keycloak.extensions.resource; import java.util.Arrays; +import java.util.regex.Pattern; import org.keycloak.models.*; import org.keycloak.validate.Validators; @@ -32,6 +33,7 @@ public class TidepoolAdminResource extends AdminResource { private static final String ID_SEPARATOR = ","; + private static final Pattern UNCLAIMED_CUSTODIAL = Pattern.compile("unclaimed-custodial-automation\\+\\d+@tidepool\\.org", Pattern.CASE_INSENSITIVE); private final KeycloakSession session; @@ -87,20 +89,27 @@ public Response unlinkFederatedUser(@PathParam("userId") final String userId) { @Path("clone-user/{userId}") public Response cloneUser(@PathParam("userId") final String userId, final CloneUserBody body) { auth.users().canManage(); - // Todo Validators for email - the API has changed from keycloak 21 => 24 + + String newUsername = body.newUsername; + if (!TidepoolAdminResource.UNCLAIMED_CUSTODIAL.matcher(newUsername).find()) { + throw new BadRequestException("newUsername must conform to the unclaimed custodial email format"); + } RealmModel realm = session.getContext().getRealm(); UserModel user = session.users().getUserById(realm, userId); if (user == null) { throw new NotFoundException("User not found."); } - String newUsername = body.newUsername; + boolean alreadyMigrated = user.getUsername() != null && TidepoolAdminResource.UNCLAIMED_CUSTODIAL.matcher(user.getUsername()).find(); + if (alreadyMigrated) { + throw new BadRequestException(String.format("user %s already migrated", userId)); + } JpaConnectionProvider connProvider = session.getProvider(JpaConnectionProvider.class); if (connProvider == null) { throw new InternalServerErrorException("Unable to get persistence connection provider."); } - // EntityManager is not thread safe so create new application managed EntityManager + // EntityManager is not thread safe so create a new application managed EntityManager EntityManager em = connProvider.getEntityManager().getEntityManagerFactory().createEntityManager(); EntityTransaction tx = em.getTransaction(); String newParentUserId = KeycloakModelUtils.generateId(); From 781c0e2e0b06b03656ce8a65143fd89fe4f6b6cd Mon Sep 17 00:00:00 2001 From: lostlevels Date: Thu, 11 Apr 2024 11:32:06 -0700 Subject: [PATCH 07/20] Add new custodiaN role in POST body. --- .../keycloak/extensions/resource/CloneUserBody.java | 5 ++++- .../extensions/resource/TidepoolAdminResource.java | 13 +++++++++++++ 2 files changed, 17 insertions(+), 1 deletion(-) diff --git a/admin/src/main/java/org/tidepool/keycloak/extensions/resource/CloneUserBody.java b/admin/src/main/java/org/tidepool/keycloak/extensions/resource/CloneUserBody.java index a9681a7..5d087c9 100644 --- a/admin/src/main/java/org/tidepool/keycloak/extensions/resource/CloneUserBody.java +++ b/admin/src/main/java/org/tidepool/keycloak/extensions/resource/CloneUserBody.java @@ -2,12 +2,15 @@ public class CloneUserBody { public String newUsername; + public String custodianRoleName; public CloneUserBody() { this.newUsername = ""; + this.custodianRoleName = ""; } - public CloneUserBody(String newUsername) { + public CloneUserBody(String newUsername, String custodianRoleName) { this.newUsername = newUsername; + this.custodianRoleName = custodianRoleName; } } diff --git a/admin/src/main/java/org/tidepool/keycloak/extensions/resource/TidepoolAdminResource.java b/admin/src/main/java/org/tidepool/keycloak/extensions/resource/TidepoolAdminResource.java index 64ac90b..f4bd0ac 100644 --- a/admin/src/main/java/org/tidepool/keycloak/extensions/resource/TidepoolAdminResource.java +++ b/admin/src/main/java/org/tidepool/keycloak/extensions/resource/TidepoolAdminResource.java @@ -94,6 +94,9 @@ public Response cloneUser(@PathParam("userId") final String userId, final CloneU if (!TidepoolAdminResource.UNCLAIMED_CUSTODIAL.matcher(newUsername).find()) { throw new BadRequestException("newUsername must conform to the unclaimed custodial email format"); } + if (body.custodianRoleName == null || body.custodianRoleName.isBlank()) { + throw new BadRequestException("custodianRoleName must not be empty"); + } RealmModel realm = session.getContext().getRealm(); UserModel user = session.users().getUserById(realm, userId); @@ -146,6 +149,16 @@ public Response cloneUser(@PathParam("userId") final String userId, final CloneU setParameter(2, childUserId). executeUpdate(); + // Add custodian role (this can be any valid existing role in the + // realm, carepartner, new we created, etc) - perhaps throw an error if + // it doesn't exist? + String custodianRoleName = body.custodianRoleName.trim(); + em.createNativeQuery("INSERT INTO user_role_mapping(role_id, user_id) SELECT id, ?1 FROM keycloak_role WHERE name = ?2 AND realm_id = ?3"). + setParameter(1, newParentUserId). + setParameter(2, custodianRoleName). + setParameter(3, realm.getId()). + executeUpdate(); + // copy over required actions em.createNativeQuery("INSERT INTO user_required_action(user_id, required_action) SELECT ?1, required_action FROM user_required_action WHERE user_id = ?2"). setParameter(1, newParentUserId). From b5c401844b7fe4b3278226761fb8275716246984 Mon Sep 17 00:00:00 2001 From: lostlevels Date: Mon, 15 Apr 2024 21:23:04 -0700 Subject: [PATCH 08/20] Only copy over fullName attribute. --- .../extensions/resource/TidepoolAdminResource.java | 11 +---------- 1 file changed, 1 insertion(+), 10 deletions(-) diff --git a/admin/src/main/java/org/tidepool/keycloak/extensions/resource/TidepoolAdminResource.java b/admin/src/main/java/org/tidepool/keycloak/extensions/resource/TidepoolAdminResource.java index f4bd0ac..393f085 100644 --- a/admin/src/main/java/org/tidepool/keycloak/extensions/resource/TidepoolAdminResource.java +++ b/admin/src/main/java/org/tidepool/keycloak/extensions/resource/TidepoolAdminResource.java @@ -165,16 +165,7 @@ public Response cloneUser(@PathParam("userId") final String userId, final CloneU setParameter(2, childUserId). executeUpdate(); - // copy over attributes (profile), ignoring certain attributes as that is part of the child. The child's custodian's fullName will be the parent's fullName. - List ignoredAttributes = Arrays.asList(new String[]{ "profile_birthday", "profile_diagnosis_date", "profile_diagnosis_type", "profile_fullname", "profile_custodian_full_name", "profile_target_devices" }); - em.createNativeQuery("INSERT INTO user_attribute(name, value, user_id, id) SELECT name, value, ?1, ?2 FROM user_attribute WHERE user_id = ?3 AND name NOT IN (?4)"). - setParameter(1, newParentUserId). - setParameter(2, KeycloakModelUtils.generateId()). - setParameter(3, childUserId). - setParameter(4, ignoredAttributes). - executeUpdate(); - - // Set the fullName from the child's custodian's fullName + // Only set the fullName attribute from the child's custodian's fullName em.createNativeQuery("INSERT INTO user_attribute(name, value, user_id, id) SELECT ?1, value, ?2, ?3 FROM user_attribute WHERE user_id = ?4 AND name = ?5"). setParameter(1, "profile_fullname"). setParameter(2, newParentUserId). From 02ab0864ab5a875c4936b42939b257163881e654 Mon Sep 17 00:00:00 2001 From: lostlevels Date: Tue, 16 Apr 2024 15:01:32 -0700 Subject: [PATCH 09/20] Use correct profile full name attribute. --- .../keycloak/extensions/resource/TidepoolAdminResource.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/admin/src/main/java/org/tidepool/keycloak/extensions/resource/TidepoolAdminResource.java b/admin/src/main/java/org/tidepool/keycloak/extensions/resource/TidepoolAdminResource.java index 393f085..bb555c1 100644 --- a/admin/src/main/java/org/tidepool/keycloak/extensions/resource/TidepoolAdminResource.java +++ b/admin/src/main/java/org/tidepool/keycloak/extensions/resource/TidepoolAdminResource.java @@ -167,7 +167,7 @@ public Response cloneUser(@PathParam("userId") final String userId, final CloneU // Only set the fullName attribute from the child's custodian's fullName em.createNativeQuery("INSERT INTO user_attribute(name, value, user_id, id) SELECT ?1, value, ?2, ?3 FROM user_attribute WHERE user_id = ?4 AND name = ?5"). - setParameter(1, "profile_fullname"). + setParameter(1, "profile_full_name"). setParameter(2, newParentUserId). setParameter(3, KeycloakModelUtils.generateId()). setParameter(4, childUserId). From 77575ba636aa190ec8f9d7ed07285d2fd5d66f76 Mon Sep 17 00:00:00 2001 From: lostlevels Date: Wed, 17 Apr 2024 11:01:28 -0700 Subject: [PATCH 10/20] Return bad request error if role not found. --- .../resource/TidepoolAdminResource.java | 20 +++++++++++-------- 1 file changed, 12 insertions(+), 8 deletions(-) diff --git a/admin/src/main/java/org/tidepool/keycloak/extensions/resource/TidepoolAdminResource.java b/admin/src/main/java/org/tidepool/keycloak/extensions/resource/TidepoolAdminResource.java index bb555c1..d5dfa0e 100644 --- a/admin/src/main/java/org/tidepool/keycloak/extensions/resource/TidepoolAdminResource.java +++ b/admin/src/main/java/org/tidepool/keycloak/extensions/resource/TidepoolAdminResource.java @@ -107,18 +107,24 @@ public Response cloneUser(@PathParam("userId") final String userId, final CloneU if (alreadyMigrated) { throw new BadRequestException(String.format("user %s already migrated", userId)); } + + String custodianRoleName = body.custodianRoleName.trim(); + String newParentUserId = KeycloakModelUtils.generateId(); + String parentUsername = user.getUsername(); + String childUserId = userId; + + boolean roleFound = session.roles().getRealmRolesStream(realm).anyMatch(role -> custodianRoleName.equals(role.getName())); + if (!roleFound) { + throw new BadRequestException(String.format("Realm role \"%s\" not found.", custodianRoleName)); + } + JpaConnectionProvider connProvider = session.getProvider(JpaConnectionProvider.class); if (connProvider == null) { throw new InternalServerErrorException("Unable to get persistence connection provider."); } - // EntityManager is not thread safe so create a new application managed EntityManager EntityManager em = connProvider.getEntityManager().getEntityManagerFactory().createEntityManager(); EntityTransaction tx = em.getTransaction(); - String newParentUserId = KeycloakModelUtils.generateId(); - String parentUsername = user.getUsername(); - String childUserId = userId; - tx.begin(); // Update the child to have the new username and email of newUsername @@ -150,9 +156,7 @@ public Response cloneUser(@PathParam("userId") final String userId, final CloneU executeUpdate(); // Add custodian role (this can be any valid existing role in the - // realm, carepartner, new we created, etc) - perhaps throw an error if - // it doesn't exist? - String custodianRoleName = body.custodianRoleName.trim(); + // realm, carepartner, new we created, etc) em.createNativeQuery("INSERT INTO user_role_mapping(role_id, user_id) SELECT id, ?1 FROM keycloak_role WHERE name = ?2 AND realm_id = ?3"). setParameter(1, newParentUserId). setParameter(2, custodianRoleName). From d64b19069e96cb9afc81b613e85940c6963f1152 Mon Sep 17 00:00:00 2001 From: lostlevels Date: Wed, 17 Apr 2024 11:04:09 -0700 Subject: [PATCH 11/20] Document body fields. --- .../keycloak/extensions/resource/CloneUserBody.java | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/admin/src/main/java/org/tidepool/keycloak/extensions/resource/CloneUserBody.java b/admin/src/main/java/org/tidepool/keycloak/extensions/resource/CloneUserBody.java index 5d087c9..1d7a862 100644 --- a/admin/src/main/java/org/tidepool/keycloak/extensions/resource/CloneUserBody.java +++ b/admin/src/main/java/org/tidepool/keycloak/extensions/resource/CloneUserBody.java @@ -1,7 +1,15 @@ package org.tidepool.keycloak.extensions.resource; public class CloneUserBody { + // newUsername is the username (email) the existing user, which + // contains a fake child, will become - the newly created parent + // will have the existing user's previous username / email. + // This should conform to the unclaimed user email format. public String newUsername; + + // custodianRoleName is the role the newly created parent of the + // existing user will receive - this should be some custodian + // type role. public String custodianRoleName; public CloneUserBody() { From 1c6c76a7fbc2b81f2a8545f72ab742879bfa80e8 Mon Sep 17 00:00:00 2001 From: lostlevels Date: Mon, 22 Apr 2024 20:37:39 -0700 Subject: [PATCH 12/20] Remove "profile_" prefix from keycloak user profile attribute. --- .../keycloak/extensions/resource/TidepoolAdminResource.java | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/admin/src/main/java/org/tidepool/keycloak/extensions/resource/TidepoolAdminResource.java b/admin/src/main/java/org/tidepool/keycloak/extensions/resource/TidepoolAdminResource.java index d5dfa0e..ebad5fe 100644 --- a/admin/src/main/java/org/tidepool/keycloak/extensions/resource/TidepoolAdminResource.java +++ b/admin/src/main/java/org/tidepool/keycloak/extensions/resource/TidepoolAdminResource.java @@ -171,11 +171,11 @@ public Response cloneUser(@PathParam("userId") final String userId, final CloneU // Only set the fullName attribute from the child's custodian's fullName em.createNativeQuery("INSERT INTO user_attribute(name, value, user_id, id) SELECT ?1, value, ?2, ?3 FROM user_attribute WHERE user_id = ?4 AND name = ?5"). - setParameter(1, "profile_full_name"). + setParameter(1, "full_name"). setParameter(2, newParentUserId). setParameter(3, KeycloakModelUtils.generateId()). setParameter(4, childUserId). - setParameter(5, "profile_custodian_full_name"). + setParameter(5, "custodian_full_name"). executeUpdate(); // copy over group memberships From a5f39f82eeb008de405c037dfee206670687e42b Mon Sep 17 00:00:00 2001 From: lostlevels Date: Wed, 24 Apr 2024 19:00:56 -0700 Subject: [PATCH 13/20] Updates from review. --- .../extensions/resource/CloneUserBody.java | 24 --------- .../ExtractCustodianResponseBody.java | 18 +++++++ .../resource/TidepoolAdminResource.java | 50 ++++++++++--------- 3 files changed, 45 insertions(+), 47 deletions(-) delete mode 100644 admin/src/main/java/org/tidepool/keycloak/extensions/resource/CloneUserBody.java create mode 100644 admin/src/main/java/org/tidepool/keycloak/extensions/resource/ExtractCustodianResponseBody.java diff --git a/admin/src/main/java/org/tidepool/keycloak/extensions/resource/CloneUserBody.java b/admin/src/main/java/org/tidepool/keycloak/extensions/resource/CloneUserBody.java deleted file mode 100644 index 1d7a862..0000000 --- a/admin/src/main/java/org/tidepool/keycloak/extensions/resource/CloneUserBody.java +++ /dev/null @@ -1,24 +0,0 @@ -package org.tidepool.keycloak.extensions.resource; - -public class CloneUserBody { - // newUsername is the username (email) the existing user, which - // contains a fake child, will become - the newly created parent - // will have the existing user's previous username / email. - // This should conform to the unclaimed user email format. - public String newUsername; - - // custodianRoleName is the role the newly created parent of the - // existing user will receive - this should be some custodian - // type role. - public String custodianRoleName; - - public CloneUserBody() { - this.newUsername = ""; - this.custodianRoleName = ""; - } - - public CloneUserBody(String newUsername, String custodianRoleName) { - this.newUsername = newUsername; - this.custodianRoleName = custodianRoleName; - } -} diff --git a/admin/src/main/java/org/tidepool/keycloak/extensions/resource/ExtractCustodianResponseBody.java b/admin/src/main/java/org/tidepool/keycloak/extensions/resource/ExtractCustodianResponseBody.java new file mode 100644 index 0000000..ae090a2 --- /dev/null +++ b/admin/src/main/java/org/tidepool/keycloak/extensions/resource/ExtractCustodianResponseBody.java @@ -0,0 +1,18 @@ +package org.tidepool.keycloak.extensions.resource; + +import org.keycloak.representations.idm.UserRepresentation; + +public class ExtractCustodianResponseBody { + // custodian is the new account that is created when extracting a custodian + // from a profile with a fake child. + public UserRepresentation custodian; + + // custodialEmail is the new email of the original account that had a + // custodian extracted from it. + public String custodialEmail; + + public ExtractCustodianResponseBody(UserRepresentation custodian, String custodialEmail) { + this.custodian = custodian; + this.custodialEmail = custodialEmail; + } +} diff --git a/admin/src/main/java/org/tidepool/keycloak/extensions/resource/TidepoolAdminResource.java b/admin/src/main/java/org/tidepool/keycloak/extensions/resource/TidepoolAdminResource.java index ebad5fe..404e83b 100644 --- a/admin/src/main/java/org/tidepool/keycloak/extensions/resource/TidepoolAdminResource.java +++ b/admin/src/main/java/org/tidepool/keycloak/extensions/resource/TidepoolAdminResource.java @@ -1,7 +1,5 @@ package org.tidepool.keycloak.extensions.resource; -import java.util.Arrays; -import java.util.regex.Pattern; import org.keycloak.models.*; import org.keycloak.validate.Validators; @@ -16,7 +14,6 @@ import javax.ws.rs.POST; import javax.ws.rs.Path; import javax.ws.rs.Produces; -import javax.ws.rs.Consumes; import javax.ws.rs.PathParam; import javax.ws.rs.QueryParam; import javax.ws.rs.BadRequestException; @@ -26,14 +23,22 @@ import javax.ws.rs.core.Response; import javax.persistence.EntityManager; import javax.persistence.EntityTransaction; +import java.util.Arrays; import java.util.ArrayList; import java.util.List; +import java.util.Random; import java.util.stream.Collectors; +import java.util.regex.Pattern; +import java.time.Instant; public class TidepoolAdminResource extends AdminResource { private static final String ID_SEPARATOR = ","; - private static final Pattern UNCLAIMED_CUSTODIAL = Pattern.compile("unclaimed-custodial-automation\\+\\d+@tidepool\\.org", Pattern.CASE_INSENSITIVE); + private static final Pattern UNCLAIMED_CUSTODIAL = Pattern.compile("^unclaimed-custodial-automation\\+\\d+@tidepool\\.org$", Pattern.CASE_INSENSITIVE); + // CUSTODIAN_ROLE is the role to give custodians accounts extracted from + // profiles that contain a fake child. + // TODO: decide on actual role + private static final String CUSTODIAN_ROLE = "custodian"; private final KeycloakSession session; @@ -79,25 +84,17 @@ public Response unlinkFederatedUser(@PathParam("userId") final String userId) { return Response.status(Response.Status.NO_CONTENT).build(); } - // This will clone the user, known as the child, with an id of userId to + // This route will clone the user, known as the child, with an id of userId to // have a new parent that has the child's previous username and email, // while the child will have the new email and username from the newUsername // field of the POST body @POST - @Consumes(MediaType.APPLICATION_JSON) @Produces({MediaType.APPLICATION_JSON}) - @Path("clone-user/{userId}") - public Response cloneUser(@PathParam("userId") final String userId, final CloneUserBody body) { + @Path("extract-custodian-user/{userId}") + public Response cloneUser(@PathParam("userId") final String userId) { auth.users().canManage(); - String newUsername = body.newUsername; - if (!TidepoolAdminResource.UNCLAIMED_CUSTODIAL.matcher(newUsername).find()) { - throw new BadRequestException("newUsername must conform to the unclaimed custodial email format"); - } - if (body.custodianRoleName == null || body.custodianRoleName.isBlank()) { - throw new BadRequestException("custodianRoleName must not be empty"); - } - + String newUsername = TidepoolAdminResource.newCustodialEmail(); RealmModel realm = session.getContext().getRealm(); UserModel user = session.users().getUserById(realm, userId); if (user == null) { @@ -108,14 +105,13 @@ public Response cloneUser(@PathParam("userId") final String userId, final CloneU throw new BadRequestException(String.format("user %s already migrated", userId)); } - String custodianRoleName = body.custodianRoleName.trim(); String newParentUserId = KeycloakModelUtils.generateId(); String parentUsername = user.getUsername(); String childUserId = userId; - boolean roleFound = session.roles().getRealmRolesStream(realm).anyMatch(role -> custodianRoleName.equals(role.getName())); + boolean roleFound = session.roles().getRealmRolesStream(realm).anyMatch(role -> TidepoolAdminResource.CUSTODIAN_ROLE.equals(role.getName())); if (!roleFound) { - throw new BadRequestException(String.format("Realm role \"%s\" not found.", custodianRoleName)); + throw new BadRequestException(String.format("Realm role \"%s\" not found.", TidepoolAdminResource.CUSTODIAN_ROLE)); } JpaConnectionProvider connProvider = session.getProvider(JpaConnectionProvider.class); @@ -155,11 +151,10 @@ public Response cloneUser(@PathParam("userId") final String userId, final CloneU setParameter(2, childUserId). executeUpdate(); - // Add custodian role (this can be any valid existing role in the - // realm, carepartner, new we created, etc) + // Add custodian role em.createNativeQuery("INSERT INTO user_role_mapping(role_id, user_id) SELECT id, ?1 FROM keycloak_role WHERE name = ?2 AND realm_id = ?3"). setParameter(1, newParentUserId). - setParameter(2, custodianRoleName). + setParameter(2, TidepoolAdminResource.CUSTODIAN_ROLE). setParameter(3, realm.getId()). executeUpdate(); @@ -195,7 +190,8 @@ public Response cloneUser(@PathParam("userId") final String userId, final CloneU if (parentUser == null) { throw new InternalServerErrorException("unable to retrieve cloned user"); } - return Response.status(Response.Status.CREATED).entity(toRepresentation(parentUser, realm)).build(); + ExtractCustodianResponseBody responseBody = new ExtractCustodianResponseBody(toRepresentation(parentUser, realm), newUsername); + return Response.status(Response.Status.CREATED).entity(responseBody).build(); } private UserRepresentation toRepresentation(UserModel user, RealmModel realm) { @@ -217,4 +213,12 @@ private List getCredentials(UserModel user) { models.forEach(c -> c.setSecretData(null)); return models; } + + private static String newCustodialEmail() { + // Get a pseudo random number that is a combination of the epoch and + // a pseudo random number for less chance of collisions. + long now = Instant.now().toEpochMilli() / 1000L * 1000000L; + long time = now + (long)(new Random().nextDouble() * 1000000.0); + return String.format("unclaimed-custodial-automation+%d@tidepool.org", time); + } } From ce89618867245211d5e53c4c96d5011f954267ac Mon Sep 17 00:00:00 2001 From: lostlevels Date: Tue, 30 Apr 2024 13:26:43 -0700 Subject: [PATCH 14/20] User "care_partner" role for custodiaN accounts. --- .../keycloak/extensions/resource/TidepoolAdminResource.java | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/admin/src/main/java/org/tidepool/keycloak/extensions/resource/TidepoolAdminResource.java b/admin/src/main/java/org/tidepool/keycloak/extensions/resource/TidepoolAdminResource.java index 404e83b..4e0169f 100644 --- a/admin/src/main/java/org/tidepool/keycloak/extensions/resource/TidepoolAdminResource.java +++ b/admin/src/main/java/org/tidepool/keycloak/extensions/resource/TidepoolAdminResource.java @@ -37,8 +37,7 @@ public class TidepoolAdminResource extends AdminResource { private static final Pattern UNCLAIMED_CUSTODIAL = Pattern.compile("^unclaimed-custodial-automation\\+\\d+@tidepool\\.org$", Pattern.CASE_INSENSITIVE); // CUSTODIAN_ROLE is the role to give custodians accounts extracted from // profiles that contain a fake child. - // TODO: decide on actual role - private static final String CUSTODIAN_ROLE = "custodian"; + private static final String CUSTODIAN_ROLE = "care_partner"; private final KeycloakSession session; From 9089aeec96fa15b2f3511f1fc5c6a7f9e17397d6 Mon Sep 17 00:00:00 2001 From: lostlevels Date: Mon, 24 Jun 2024 08:18:00 -0700 Subject: [PATCH 15/20] Grant fake child custodiaL role. --- .../extensions/resource/TidepoolAdminResource.java | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/admin/src/main/java/org/tidepool/keycloak/extensions/resource/TidepoolAdminResource.java b/admin/src/main/java/org/tidepool/keycloak/extensions/resource/TidepoolAdminResource.java index 4e0169f..9cbbbe1 100644 --- a/admin/src/main/java/org/tidepool/keycloak/extensions/resource/TidepoolAdminResource.java +++ b/admin/src/main/java/org/tidepool/keycloak/extensions/resource/TidepoolAdminResource.java @@ -39,6 +39,9 @@ public class TidepoolAdminResource extends AdminResource { // profiles that contain a fake child. private static final String CUSTODIAN_ROLE = "care_partner"; + // CUSTODIAL_ROLE will be added to the fake child account. + private static final String CUSTODIAL_ROLE = "custodial_account"; + private final KeycloakSession session; public TidepoolAdminResource(KeycloakSession session) { @@ -108,9 +111,13 @@ public Response cloneUser(@PathParam("userId") final String userId) { String parentUsername = user.getUsername(); String childUserId = userId; - boolean roleFound = session.roles().getRealmRolesStream(realm).anyMatch(role -> TidepoolAdminResource.CUSTODIAN_ROLE.equals(role.getName())); - if (!roleFound) { - throw new BadRequestException(String.format("Realm role \"%s\" not found.", TidepoolAdminResource.CUSTODIAN_ROLE)); + if (session.roles().getRealmRole(realm, TidepoolAdminResource.CUSTODIAN_ROLE) == null) { + throw new BadRequestException(String.format("CustodiaN realm role \"%s\" not found.", TidepoolAdminResource.CUSTODIAN_ROLE)); + } + + RoleModel custodialRole = session.roles().getRealmRole(realm, TidepoolAdminResource.CUSTODIAL_ROLE); + if (custodialRole == null) { + throw new BadRequestException(String.format("CustodiaL realm role \"%s\" not found.", TidepoolAdminResource.CUSTODIAL_ROLE)); } JpaConnectionProvider connProvider = session.getProvider(JpaConnectionProvider.class); @@ -184,6 +191,7 @@ public Response cloneUser(@PathParam("userId") final String userId) { // the previous transaction has succeeded so this is "safe" and will cause a user updated event which will clear the cache entry for the given user. user.setEmail(newUsername); user.setUsername(newUsername); + user.grantRole(custodialRole); UserModel parentUser = session.users().getUserById(realm, newParentUserId); if (parentUser == null) { From 6d3f1fcd5483fe63714075163b5eff27c22d5208 Mon Sep 17 00:00:00 2001 From: lostlevels Date: Mon, 1 Jul 2024 10:54:05 -0700 Subject: [PATCH 16/20] Add reference to created / extracted parent custodian account for a fake child. --- .../resource/TidepoolAdminResource.java | 18 ++++++++++++++++-- 1 file changed, 16 insertions(+), 2 deletions(-) diff --git a/admin/src/main/java/org/tidepool/keycloak/extensions/resource/TidepoolAdminResource.java b/admin/src/main/java/org/tidepool/keycloak/extensions/resource/TidepoolAdminResource.java index 9cbbbe1..3abe38c 100644 --- a/admin/src/main/java/org/tidepool/keycloak/extensions/resource/TidepoolAdminResource.java +++ b/admin/src/main/java/org/tidepool/keycloak/extensions/resource/TidepoolAdminResource.java @@ -42,6 +42,8 @@ public class TidepoolAdminResource extends AdminResource { // CUSTODIAL_ROLE will be added to the fake child account. private static final String CUSTODIAL_ROLE = "custodial_account"; + // Attribute name of profile value in a child profile that references created parent / extracted keycloak user's primary key id (since no foreign keys in the EAV-like user_attribute table) + private static final String ATTRIBUTE_PARENT_USER_ID= "parent_user_id"; private final KeycloakSession session; public TidepoolAdminResource(KeycloakSession session) { @@ -146,7 +148,7 @@ public Response cloneUser(@PathParam("userId") final String userId) { // Copy over the credentials of the child to the parent so the parent can login with the same credentials. em.createNativeQuery("INSERT INTO credential(id, salt, type, user_id, created_date, user_label, secret_data, credential_data, priority) SELECT ?1, salt, type, ?2, created_date, user_label, secret_data, credential_data, priority FROM credential WHERE user_id = ?3"). - setParameter(1, KeycloakModelUtils.generateId()). + setParameter(1, KeycloakModelUtils.generateId()). // random primary key setParameter(2, newParentUserId). setParameter(3, childUserId). executeUpdate(); @@ -174,11 +176,23 @@ public Response cloneUser(@PathParam("userId") final String userId) { em.createNativeQuery("INSERT INTO user_attribute(name, value, user_id, id) SELECT ?1, value, ?2, ?3 FROM user_attribute WHERE user_id = ?4 AND name = ?5"). setParameter(1, "full_name"). setParameter(2, newParentUserId). - setParameter(3, KeycloakModelUtils.generateId()). + setParameter(3, KeycloakModelUtils.generateId()). // random primary key setParameter(4, childUserId). setParameter(5, "custodian_full_name"). executeUpdate(); + // Add the parent primary key id as a profile attribute for the child + // (as there are no actual parent child foreign key references in + // keycloak) + em.createNativeQuery("INSERT INTO user_attribute(name, value, user_id, id) VALUES (?1, ?2, ?3, ?4"). + setParameter(1, TidepoolAdminResource.ATTRIBUTE_PARENT_USER_ID). + setParameter(2, newParentUserId). + setParameter(3, childUserId). + setParameter(4, KeycloakModelUtils.generateId()). // random primary key + executeUpdate(); + + + // copy over group memberships em.createNativeQuery("INSERT INTO user_group_membership(group_id, user_id) SELECT group_id, ?1 FROM user_group_membership WHERE user_id = ?2"). setParameter(1, newParentUserId). From 71f39665b291095fc14f86e3f3b2c3d2962b1cf7 Mon Sep 17 00:00:00 2001 From: lostlevels Date: Mon, 1 Jul 2024 13:22:22 -0700 Subject: [PATCH 17/20] Whitespace. --- .../keycloak/extensions/resource/TidepoolAdminResource.java | 2 -- 1 file changed, 2 deletions(-) diff --git a/admin/src/main/java/org/tidepool/keycloak/extensions/resource/TidepoolAdminResource.java b/admin/src/main/java/org/tidepool/keycloak/extensions/resource/TidepoolAdminResource.java index 3abe38c..738e052 100644 --- a/admin/src/main/java/org/tidepool/keycloak/extensions/resource/TidepoolAdminResource.java +++ b/admin/src/main/java/org/tidepool/keycloak/extensions/resource/TidepoolAdminResource.java @@ -191,8 +191,6 @@ public Response cloneUser(@PathParam("userId") final String userId) { setParameter(4, KeycloakModelUtils.generateId()). // random primary key executeUpdate(); - - // copy over group memberships em.createNativeQuery("INSERT INTO user_group_membership(group_id, user_id) SELECT group_id, ?1 FROM user_group_membership WHERE user_id = ?2"). setParameter(1, newParentUserId). From dcb62c4d45c9177fc009938b800d2962aed5ef3e Mon Sep 17 00:00:00 2001 From: lostlevels Date: Thu, 8 Aug 2024 08:58:05 -0700 Subject: [PATCH 18/20] Fix syntax in query. --- .../keycloak/extensions/resource/TidepoolAdminResource.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/admin/src/main/java/org/tidepool/keycloak/extensions/resource/TidepoolAdminResource.java b/admin/src/main/java/org/tidepool/keycloak/extensions/resource/TidepoolAdminResource.java index 738e052..b445044 100644 --- a/admin/src/main/java/org/tidepool/keycloak/extensions/resource/TidepoolAdminResource.java +++ b/admin/src/main/java/org/tidepool/keycloak/extensions/resource/TidepoolAdminResource.java @@ -184,7 +184,7 @@ public Response cloneUser(@PathParam("userId") final String userId) { // Add the parent primary key id as a profile attribute for the child // (as there are no actual parent child foreign key references in // keycloak) - em.createNativeQuery("INSERT INTO user_attribute(name, value, user_id, id) VALUES (?1, ?2, ?3, ?4"). + em.createNativeQuery("INSERT INTO user_attribute(name, value, user_id, id) VALUES (?1, ?2, ?3, ?4)"). setParameter(1, TidepoolAdminResource.ATTRIBUTE_PARENT_USER_ID). setParameter(2, newParentUserId). setParameter(3, childUserId). From 4ea2deb54b0ed47af2450ebceecf996c84c7944f Mon Sep 17 00:00:00 2001 From: lostlevels Date: Tue, 13 Aug 2024 11:21:46 -0700 Subject: [PATCH 19/20] Single Random to handle when extracting custodian generates same email. --- .../extensions/resource/TidepoolAdminResource.java | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/admin/src/main/java/org/tidepool/keycloak/extensions/resource/TidepoolAdminResource.java b/admin/src/main/java/org/tidepool/keycloak/extensions/resource/TidepoolAdminResource.java index b445044..99b92eb 100644 --- a/admin/src/main/java/org/tidepool/keycloak/extensions/resource/TidepoolAdminResource.java +++ b/admin/src/main/java/org/tidepool/keycloak/extensions/resource/TidepoolAdminResource.java @@ -32,7 +32,6 @@ import java.time.Instant; public class TidepoolAdminResource extends AdminResource { - private static final String ID_SEPARATOR = ","; private static final Pattern UNCLAIMED_CUSTODIAL = Pattern.compile("^unclaimed-custodial-automation\\+\\d+@tidepool\\.org$", Pattern.CASE_INSENSITIVE); // CUSTODIAN_ROLE is the role to give custodians accounts extracted from @@ -46,6 +45,9 @@ public class TidepoolAdminResource extends AdminResource { private static final String ATTRIBUTE_PARENT_USER_ID= "parent_user_id"; private final KeycloakSession session; + // This rand is only called during custodian extraction which is rare so not concerned about any performance issues of using just one. + private static final Random rand = new Random(); + public TidepoolAdminResource(KeycloakSession session) { this.session = session; } @@ -104,7 +106,7 @@ public Response cloneUser(@PathParam("userId") final String userId) { if (user == null) { throw new NotFoundException("User not found."); } - boolean alreadyMigrated = user.getUsername() != null && TidepoolAdminResource.UNCLAIMED_CUSTODIAL.matcher(user.getUsername()).find(); + boolean alreadyMigrated = (user.getUsername() != null && TidepoolAdminResource.UNCLAIMED_CUSTODIAL.matcher(user.getUsername()).find()) || (user.getFirstAttribute(TidepoolAdminResource.ATTRIBUTE_PARENT_USER_ID) != null); if (alreadyMigrated) { throw new BadRequestException(String.format("user %s already migrated", userId)); } @@ -237,7 +239,7 @@ private static String newCustodialEmail() { // Get a pseudo random number that is a combination of the epoch and // a pseudo random number for less chance of collisions. long now = Instant.now().toEpochMilli() / 1000L * 1000000L; - long time = now + (long)(new Random().nextDouble() * 1000000.0); + long time = now + (long)(TidepoolAdminResource.rand.nextDouble() * 1000000.0); return String.format("unclaimed-custodial-automation+%d@tidepool.org", time); } } From 2713c2cbae56403a169da34d3470ada09db9820d Mon Sep 17 00:00:00 2001 From: lostlevels Date: Tue, 20 Aug 2024 12:55:13 -0700 Subject: [PATCH 20/20] Handle specific PersistenceExceptions. --- .../resource/TidepoolAdminResource.java | 145 ++++++++++-------- 1 file changed, 77 insertions(+), 68 deletions(-) diff --git a/admin/src/main/java/org/tidepool/keycloak/extensions/resource/TidepoolAdminResource.java b/admin/src/main/java/org/tidepool/keycloak/extensions/resource/TidepoolAdminResource.java index 99b92eb..02cc0fb 100644 --- a/admin/src/main/java/org/tidepool/keycloak/extensions/resource/TidepoolAdminResource.java +++ b/admin/src/main/java/org/tidepool/keycloak/extensions/resource/TidepoolAdminResource.java @@ -9,6 +9,7 @@ import org.keycloak.representations.idm.CredentialRepresentation; import org.keycloak.representations.idm.UserRepresentation; import org.keycloak.models.utils.KeycloakModelUtils; +import org.hibernate.exception.ConstraintViolationException; import javax.ws.rs.GET; import javax.ws.rs.POST; @@ -23,6 +24,7 @@ import javax.ws.rs.core.Response; import javax.persistence.EntityManager; import javax.persistence.EntityTransaction; +import javax.persistence.PersistenceException; import java.util.Arrays; import java.util.ArrayList; import java.util.List; @@ -133,74 +135,81 @@ public Response cloneUser(@PathParam("userId") final String userId) { EntityTransaction tx = em.getTransaction(); tx.begin(); - // Update the child to have the new username and email of newUsername - em.createNativeQuery("UPDATE user_entity SET email = ?1, email_constraint = ?1, username = ?1 WHERE id = ?2"). - setParameter(1, newUsername). - setParameter(2, childUserId). - executeUpdate(); - - // Create a new parent user with the same properties as the child - // except the parent will now assume the child's previous email / - // username. - em.createNativeQuery("INSERT INTO user_entity(id, email, email_constraint, email_verified, enabled, federation_link, first_name, last_name, realm_id, username, created_timestamp, service_account_client_link, not_before) SELECT ?1, ?2, ?2, email_verified, enabled, federation_link, first_name, last_name, realm_id, ?2, created_timestamp, service_account_client_link, not_before FROM user_entity WHERE id = ?3"). - setParameter(1, newParentUserId). - setParameter(2, parentUsername). - setParameter(3, childUserId). - executeUpdate(); - - // Copy over the credentials of the child to the parent so the parent can login with the same credentials. - em.createNativeQuery("INSERT INTO credential(id, salt, type, user_id, created_date, user_label, secret_data, credential_data, priority) SELECT ?1, salt, type, ?2, created_date, user_label, secret_data, credential_data, priority FROM credential WHERE user_id = ?3"). - setParameter(1, KeycloakModelUtils.generateId()). // random primary key - setParameter(2, newParentUserId). - setParameter(3, childUserId). - executeUpdate(); - - // copy over role mappings - em.createNativeQuery("INSERT INTO user_role_mapping(role_id, user_id) SELECT role_id, ?1 FROM user_role_mapping WHERE user_id = ?2"). - setParameter(1, newParentUserId). - setParameter(2, childUserId). - executeUpdate(); - - // Add custodian role - em.createNativeQuery("INSERT INTO user_role_mapping(role_id, user_id) SELECT id, ?1 FROM keycloak_role WHERE name = ?2 AND realm_id = ?3"). - setParameter(1, newParentUserId). - setParameter(2, TidepoolAdminResource.CUSTODIAN_ROLE). - setParameter(3, realm.getId()). - executeUpdate(); - - // copy over required actions - em.createNativeQuery("INSERT INTO user_required_action(user_id, required_action) SELECT ?1, required_action FROM user_required_action WHERE user_id = ?2"). - setParameter(1, newParentUserId). - setParameter(2, childUserId). - executeUpdate(); - - // Only set the fullName attribute from the child's custodian's fullName - em.createNativeQuery("INSERT INTO user_attribute(name, value, user_id, id) SELECT ?1, value, ?2, ?3 FROM user_attribute WHERE user_id = ?4 AND name = ?5"). - setParameter(1, "full_name"). - setParameter(2, newParentUserId). - setParameter(3, KeycloakModelUtils.generateId()). // random primary key - setParameter(4, childUserId). - setParameter(5, "custodian_full_name"). - executeUpdate(); - - // Add the parent primary key id as a profile attribute for the child - // (as there are no actual parent child foreign key references in - // keycloak) - em.createNativeQuery("INSERT INTO user_attribute(name, value, user_id, id) VALUES (?1, ?2, ?3, ?4)"). - setParameter(1, TidepoolAdminResource.ATTRIBUTE_PARENT_USER_ID). - setParameter(2, newParentUserId). - setParameter(3, childUserId). - setParameter(4, KeycloakModelUtils.generateId()). // random primary key - executeUpdate(); - - // copy over group memberships - em.createNativeQuery("INSERT INTO user_group_membership(group_id, user_id) SELECT group_id, ?1 FROM user_group_membership WHERE user_id = ?2"). - setParameter(1, newParentUserId). - setParameter(2, childUserId). - executeUpdate(); - - tx.commit(); - + try { + // Update the child to have the new username and email of newUsername + em.createNativeQuery("UPDATE user_entity SET email = ?1, email_constraint = ?1, username = ?1 WHERE id = ?2"). + setParameter(1, newUsername). + setParameter(2, childUserId). + executeUpdate(); + + // Create a new parent user with the same properties as the child + // except the parent will now assume the child's previous email / + // username. + em.createNativeQuery("INSERT INTO user_entity(id, email, email_constraint, email_verified, enabled, federation_link, first_name, last_name, realm_id, username, created_timestamp, service_account_client_link, not_before) SELECT ?1, ?2, ?2, email_verified, enabled, federation_link, first_name, last_name, realm_id, ?2, created_timestamp, service_account_client_link, not_before FROM user_entity WHERE id = ?3"). + setParameter(1, newParentUserId). + setParameter(2, parentUsername). + setParameter(3, childUserId). + executeUpdate(); + + // Copy over the credentials of the child to the parent so the parent can login with the same credentials. + em.createNativeQuery("INSERT INTO credential(id, salt, type, user_id, created_date, user_label, secret_data, credential_data, priority) SELECT ?1, salt, type, ?2, created_date, user_label, secret_data, credential_data, priority FROM credential WHERE user_id = ?3"). + setParameter(1, KeycloakModelUtils.generateId()). // random primary key + setParameter(2, newParentUserId). + setParameter(3, childUserId). + executeUpdate(); + + // copy over role mappings + em.createNativeQuery("INSERT INTO user_role_mapping(role_id, user_id) SELECT role_id, ?1 FROM user_role_mapping WHERE user_id = ?2"). + setParameter(1, newParentUserId). + setParameter(2, childUserId). + executeUpdate(); + + // Add custodian role + em.createNativeQuery("INSERT INTO user_role_mapping(role_id, user_id) SELECT id, ?1 FROM keycloak_role WHERE name = ?2 AND realm_id = ?3"). + setParameter(1, newParentUserId). + setParameter(2, TidepoolAdminResource.CUSTODIAN_ROLE). + setParameter(3, realm.getId()). + executeUpdate(); + + // copy over required actions + em.createNativeQuery("INSERT INTO user_required_action(user_id, required_action) SELECT ?1, required_action FROM user_required_action WHERE user_id = ?2"). + setParameter(1, newParentUserId). + setParameter(2, childUserId). + executeUpdate(); + + // Only set the fullName attribute from the child's custodian's fullName + em.createNativeQuery("INSERT INTO user_attribute(name, value, user_id, id) SELECT ?1, value, ?2, ?3 FROM user_attribute WHERE user_id = ?4 AND name = ?5"). + setParameter(1, "full_name"). + setParameter(2, newParentUserId). + setParameter(3, KeycloakModelUtils.generateId()). // random primary key + setParameter(4, childUserId). + setParameter(5, "custodian_full_name"). + executeUpdate(); + + // Add the parent primary key id as a profile attribute for the child + // (as there are no actual parent child foreign key references in + // keycloak) + em.createNativeQuery("INSERT INTO user_attribute(name, value, user_id, id) VALUES (?1, ?2, ?3, ?4)"). + setParameter(1, TidepoolAdminResource.ATTRIBUTE_PARENT_USER_ID). + setParameter(2, newParentUserId). + setParameter(3, childUserId). + setParameter(4, KeycloakModelUtils.generateId()). // random primary key + executeUpdate(); + + // copy over group memberships + em.createNativeQuery("INSERT INTO user_group_membership(group_id, user_id) SELECT group_id, ?1 FROM user_group_membership WHERE user_id = ?2"). + setParameter(1, newParentUserId). + setParameter(2, childUserId). + executeUpdate(); + + tx.commit(); + } + catch (PersistenceException ex) { + if (ex.getCause() instanceof ConstraintViolationException) { + throw new InternalServerErrorException("constraint violation: " + ex.getMessage()); + } + throw ex; + } // The Keycloak 24+ modules have removed cache eviction methods, so instead set child user's email and username "again" through the model. If we got this far, // the previous transaction has succeeded so this is "safe" and will cause a user updated event which will clear the cache entry for the given user. user.setEmail(newUsername);