-
-
Notifications
You must be signed in to change notification settings - Fork 1
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
[BACK-2785] Add keycloak extension endpoint to extract and create a new custodian user from a fake child account. #28
Open
lostlevels
wants to merge
20
commits into
master
Choose a base branch
from
jimmy/back-2785
base: master
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Changes from all commits
Commits
Show all changes
20 commits
Select commit
Hold shift + click to select a range
69a18cc
[BACK-2785] Add keycloak extension endpoint to clone a user making it a
lostlevels beb3e51
Clear user cache indirectly.
lostlevels f1dbc3f
Don't copy over certain user attributes that belong only to the child
lostlevels fcb4279
Add POST body.
lostlevels b1c06ed
Update comment to reflect keycloak 24.
lostlevels f662559
Make sure custodiaL new email is in the right format and don't migrat…
lostlevels 781c0e2
Add new custodiaN role in POST body.
lostlevels b5c4018
Only copy over fullName attribute.
lostlevels 02ab086
Use correct profile full name attribute.
lostlevels 77575ba
Return bad request error if role not found.
lostlevels d64b190
Document body fields.
lostlevels 1c6c76a
Remove "profile_" prefix from keycloak user profile attribute.
lostlevels a5f39f8
Updates from review.
lostlevels ce89618
User "care_partner" role for custodiaN accounts.
lostlevels 9089aee
Grant fake child custodiaL role.
lostlevels 6d3f1fc
Add reference to created / extracted parent custodian account for a fake
lostlevels 71f3966
Whitespace.
lostlevels dcb62c4
Fix syntax in query.
lostlevels 4ea2deb
Single Random to handle when extracting custodian generates same email.
lostlevels 2713c2c
Handle specific PersistenceExceptions.
lostlevels File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
18 changes: 18 additions & 0 deletions
18
...src/main/java/org/tidepool/keycloak/extensions/resource/ExtractCustodianResponseBody.java
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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; | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1,9 +1,15 @@ | ||
package org.tidepool.keycloak.extensions.resource; | ||
|
||
|
||
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; | ||
import org.keycloak.models.utils.KeycloakModelUtils; | ||
import org.hibernate.exception.ConstraintViolationException; | ||
|
||
import javax.ws.rs.GET; | ||
import javax.ws.rs.POST; | ||
|
@@ -12,19 +18,38 @@ | |
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 javax.persistence.PersistenceException; | ||
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); | ||
// CUSTODIAN_ROLE is the role to give custodians accounts extracted from | ||
// 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"; | ||
|
||
// 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; | ||
|
||
// 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; | ||
} | ||
|
@@ -67,6 +92,138 @@ public Response unlinkFederatedUser(@PathParam("userId") final String userId) { | |
return Response.status(Response.Status.NO_CONTENT).build(); | ||
} | ||
|
||
// 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 | ||
@Produces({MediaType.APPLICATION_JSON}) | ||
@Path("extract-custodian-user/{userId}") | ||
public Response cloneUser(@PathParam("userId") final String userId) { | ||
auth.users().canManage(); | ||
|
||
String newUsername = TidepoolAdminResource.newCustodialEmail(); | ||
RealmModel realm = session.getContext().getRealm(); | ||
UserModel user = session.users().getUserById(realm, userId); | ||
if (user == null) { | ||
throw new NotFoundException("User not found."); | ||
} | ||
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)); | ||
} | ||
|
||
String newParentUserId = KeycloakModelUtils.generateId(); | ||
String parentUsername = user.getUsername(); | ||
String childUserId = userId; | ||
|
||
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); | ||
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(); | ||
tx.begin(); | ||
|
||
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); | ||
user.setUsername(newUsername); | ||
user.grantRole(custodialRole); | ||
|
||
UserModel parentUser = session.users().getUserById(realm, newParentUserId); | ||
if (parentUser == null) { | ||
throw new InternalServerErrorException("unable to retrieve cloned user"); | ||
} | ||
ExtractCustodianResponseBody responseBody = new ExtractCustodianResponseBody(toRepresentation(parentUser, realm), newUsername); | ||
return Response.status(Response.Status.CREATED).entity(responseBody).build(); | ||
} | ||
|
||
private UserRepresentation toRepresentation(UserModel user, RealmModel realm) { | ||
UserRepresentation representation = ModelToRepresentation.toRepresentation(session, realm, user); | ||
representation.setRealmRoles(getRoles(user)); | ||
|
@@ -86,4 +243,12 @@ private List<CredentialRepresentation> 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)(TidepoolAdminResource.rand.nextDouble() * 1000000.0); | ||
return String.format("unclaimed-custodial-automation+%[email protected]", time); | ||
} | ||
} |
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
You should add custodial_account role