diff --git a/src/main/java/io/phasetwo/service/model/OrganizationMemberModel.java b/src/main/java/io/phasetwo/service/model/OrganizationMemberModel.java new file mode 100644 index 00000000..dd2bb23f --- /dev/null +++ b/src/main/java/io/phasetwo/service/model/OrganizationMemberModel.java @@ -0,0 +1,14 @@ +package io.phasetwo.service.model; + +import java.util.List; + +public interface OrganizationMemberModel extends WithAttributes { + + String getId(); + + String getUserId(); + + OrganizationModel getOrganization(); + + List getRoles(); +} diff --git a/src/main/java/io/phasetwo/service/model/OrganizationModel.java b/src/main/java/io/phasetwo/service/model/OrganizationModel.java index abf966ea..bc7eba5d 100644 --- a/src/main/java/io/phasetwo/service/model/OrganizationModel.java +++ b/src/main/java/io/phasetwo/service/model/OrganizationModel.java @@ -41,6 +41,11 @@ public interface OrganizationModel extends WithAttributes { Stream searchForMembersStream(String search, Integer firstResult, Integer maxResults); + Stream getOrganizationMembersStream(); + + Stream searchForOrganizationMembersStream( + String search, Integer firstResult, Integer maxResults); + boolean hasMembership(UserModel user); void grantMembership(UserModel user); @@ -74,6 +79,8 @@ default OrganizationRoleModel getRoleByName(String name) { .orElse(null); } + OrganizationMemberModel getMembershipDetails(UserModel user); + void removeRole(String name); OrganizationRoleModel addRole(String name); diff --git a/src/main/java/io/phasetwo/service/model/jpa/OrganizationAdapter.java b/src/main/java/io/phasetwo/service/model/jpa/OrganizationAdapter.java index 767e546b..01f6d370 100644 --- a/src/main/java/io/phasetwo/service/model/jpa/OrganizationAdapter.java +++ b/src/main/java/io/phasetwo/service/model/jpa/OrganizationAdapter.java @@ -1,10 +1,17 @@ package io.phasetwo.service.model.jpa; import static io.phasetwo.service.Orgs.*; +import static org.keycloak.models.UserModel.EMAIL; +import static org.keycloak.models.UserModel.FIRST_NAME; +import static org.keycloak.models.UserModel.LAST_NAME; +import static org.keycloak.models.UserModel.USERNAME; +import static org.keycloak.models.jpa.PaginationUtils.paginateQuery; +import static org.keycloak.utils.StreamsUtil.closing; import com.google.common.base.Strings; import io.phasetwo.service.model.DomainModel; import io.phasetwo.service.model.InvitationModel; +import io.phasetwo.service.model.OrganizationMemberModel; import io.phasetwo.service.model.OrganizationModel; import io.phasetwo.service.model.OrganizationRoleModel; import io.phasetwo.service.model.jpa.entity.DomainEntity; @@ -17,8 +24,16 @@ import io.phasetwo.service.util.IdentityProviders; import jakarta.persistence.EntityManager; import jakarta.persistence.TypedQuery; +import jakarta.persistence.criteria.CriteriaBuilder; +import jakarta.persistence.criteria.CriteriaQuery; +import jakarta.persistence.criteria.From; +import jakarta.persistence.criteria.Predicate; +import jakarta.persistence.criteria.Root; +import jakarta.persistence.criteria.Subquery; +import java.util.ArrayList; import java.util.List; import java.util.Map; +import java.util.Objects; import java.util.Set; import java.util.stream.Collectors; import java.util.stream.Stream; @@ -28,6 +43,7 @@ import org.keycloak.models.RealmModel; import org.keycloak.models.UserModel; import org.keycloak.models.jpa.JpaModel; +import org.keycloak.models.jpa.entities.UserEntity; import org.keycloak.models.utils.KeycloakModelUtils; public class OrganizationAdapter implements OrganizationModel, JpaModel { @@ -37,6 +53,8 @@ public class OrganizationAdapter implements OrganizationModel, JpaModel searchForMembersStream( .limit(maxResults); } + @Override + public Stream getOrganizationMembersStream() { + TypedQuery query = + em.createNamedQuery("getOrganizationMembers", OrganizationMemberEntity.class); + query.setParameter("organization", org); + + return query + .getResultStream() + .map( + organizationMemberEntity -> + new OrganizationMemberAdapter(session, realm, em, organizationMemberEntity)); + } + + @Override + public Stream searchForOrganizationMembersStream( + String search, Integer firstResult, Integer maxResults) { + CriteriaBuilder criteriaBuilder = em.getCriteriaBuilder(); + CriteriaQuery criteriaQuery = + criteriaBuilder.createQuery(OrganizationMemberEntity.class); + + Root root = criteriaQuery.from(OrganizationMemberEntity.class); + + List predicates = new ArrayList<>(); + // defining the organization search clause + predicates.add(criteriaBuilder.equal(root.get("organization"), org)); + if (search != null && !search.isEmpty()) { + var userIds = userIdsSubquery(criteriaQuery, search); + predicates.add(root.get("userId").in(userIds)); + } + + criteriaQuery + .where(predicates.toArray(Predicate[]::new)) + .orderBy(criteriaBuilder.asc(root.get("createdAt"))); + + TypedQuery query = em.createQuery(criteriaQuery); + + return closing(paginateQuery(query, firstResult, maxResults).getResultStream()) + .filter(Objects::nonNull) + .map( + organizationMemberEntity -> + new OrganizationMemberAdapter(session, realm, em, organizationMemberEntity)); + } + + private Subquery userIdsSubquery(CriteriaQuery query, String search) { + CriteriaBuilder cb = em.getCriteriaBuilder(); + Subquery subquery = query.subquery(String.class); + Root subRoot = subquery.from(UserEntity.class); + + subquery.select(subRoot.get("id")); + List subqueryPredicates = new ArrayList<>(); + + subqueryPredicates.add(cb.equal(subRoot.get("realmId"), realm.getId())); + + List searchTermsPredicates = new ArrayList<>(); + // define search terms + for (String stringToSearch : search.trim().split(",")) { + searchTermsPredicates.add(cb.or(getSearchOptionPredicateArray(stringToSearch, cb, subRoot))); + } + Predicate searchPredicate = cb.or(searchTermsPredicates.toArray(Predicate[]::new)); + subqueryPredicates.add(searchPredicate); + + subquery.where(subqueryPredicates.toArray(Predicate[]::new)); + + return subquery; + } + @Override public Long getMembersCount() { TypedQuery query = em.createNamedQuery("getOrganizationMembersCount", Long.class); @@ -301,6 +385,15 @@ public Stream getRolesByUserStream(UserModel user) { } } + @Override + public OrganizationMemberModel getMembershipDetails(UserModel user) { + TypedQuery query = + em.createNamedQuery("getOrganizationMemberByUserId", OrganizationMemberModel.class); + query.setParameter("organization", org); + query.setParameter("id", user.getId()); + return query.getSingleResult(); + } + @Override public void removeRole(String name) { org.getRoles().removeIf(r -> r.getName().equals(name)); @@ -319,7 +412,8 @@ public OrganizationRoleModel addRole(String name) { @Override public Stream getIdentityProvidersStream() { - return session.identityProviders() + return session + .identityProviders() .getAllStream() // Todo: do we need to apply here a role filter? I believe not since its part of the // HomeIdpDiscoverer @@ -330,4 +424,18 @@ public Stream getIdentityProvidersStream() { return orgs.contains(getId()); }); } + + private Predicate[] getSearchOptionPredicateArray( + String value, CriteriaBuilder builder, From from) { + value = value.trim().toLowerCase(); + List orPredicates = new ArrayList<>(); + if (!value.isEmpty()) { + value = "%" + value + "%"; // contains in SQL query manner + orPredicates.add(builder.like(from.get(USERNAME), value, ESCAPE_BACKSLASH)); + orPredicates.add(builder.like(from.get(EMAIL), value, ESCAPE_BACKSLASH)); + orPredicates.add(builder.like(builder.lower(from.get(FIRST_NAME)), value, ESCAPE_BACKSLASH)); + orPredicates.add(builder.like(builder.lower(from.get(LAST_NAME)), value, ESCAPE_BACKSLASH)); + } + return orPredicates.toArray(Predicate[]::new); + } } diff --git a/src/main/java/io/phasetwo/service/model/jpa/OrganizationMemberAdapter.java b/src/main/java/io/phasetwo/service/model/jpa/OrganizationMemberAdapter.java new file mode 100644 index 00000000..1a72c54c --- /dev/null +++ b/src/main/java/io/phasetwo/service/model/jpa/OrganizationMemberAdapter.java @@ -0,0 +1,105 @@ +package io.phasetwo.service.model.jpa; + +import io.phasetwo.service.model.OrganizationMemberModel; +import io.phasetwo.service.model.OrganizationModel; +import io.phasetwo.service.model.jpa.entity.OrganizationMemberAttributeEntity; +import io.phasetwo.service.model.jpa.entity.OrganizationMemberEntity; +import io.phasetwo.service.model.jpa.entity.OrganizationRoleEntity; +import io.phasetwo.service.model.jpa.entity.UserOrganizationRoleMappingEntity; +import jakarta.persistence.EntityManager; +import java.util.List; +import java.util.Map; +import org.keycloak.common.util.MultivaluedHashMap; +import org.keycloak.models.KeycloakSession; +import org.keycloak.models.RealmModel; +import org.keycloak.models.jpa.JpaModel; +import org.keycloak.models.utils.KeycloakModelUtils; + +public class OrganizationMemberAdapter + implements OrganizationMemberModel, JpaModel { + + protected final KeycloakSession session; + protected final OrganizationMemberEntity organizationMemberEntity; + protected final EntityManager em; + protected final RealmModel realm; + + public OrganizationMemberAdapter( + KeycloakSession session, + RealmModel realm, + EntityManager em, + OrganizationMemberEntity organizationMemberEntity) { + this.session = session; + this.em = em; + this.organizationMemberEntity = organizationMemberEntity; + this.realm = realm; + } + + @Override + public OrganizationMemberEntity getEntity() { + return organizationMemberEntity; + } + + @Override + public String getId() { + return organizationMemberEntity.getId(); + } + + @Override + public String getUserId() { + return organizationMemberEntity.getUserId(); + } + + @Override + public OrganizationModel getOrganization() { + return new OrganizationAdapter(session, realm, em, organizationMemberEntity.getOrganization()); + } + + @Override + public List getRoles() { + return organizationMemberEntity.getOrganization().getRoles().stream() + .flatMap(organizationRoleEntity -> organizationRoleEntity.getUserMappings().stream()) + .filter( + userOrganizationRoleMappingEntity -> + userOrganizationRoleMappingEntity + .getUserId() + .equals(organizationMemberEntity.getUserId())) + .map(UserOrganizationRoleMappingEntity::getRole) + .map(OrganizationRoleEntity::getName) + .toList(); + } + + @Override + public Map> getAttributes() { + MultivaluedHashMap result = new MultivaluedHashMap<>(); + for (OrganizationMemberAttributeEntity attr : organizationMemberEntity.getAttributes()) { + result.add(attr.getName(), attr.getValue()); + } + return result; + } + + @Override + public void removeAttribute(String name) { + organizationMemberEntity + .getAttributes() + .removeIf(attribute -> attribute.getName().equals(name)); + } + + @Override + public void removeAttributes() { + organizationMemberEntity.getAttributes().clear(); + } + + @Override + public void setAttribute(String name, List values) { + removeAttribute(name); + for (String value : values) { + OrganizationMemberAttributeEntity a = new OrganizationMemberAttributeEntity(); + a.setId(KeycloakModelUtils.generateId()); + a.setName(name); + a.setValue(value); + a.setOrganizationMember(organizationMemberEntity); + em.persist(a); + organizationMemberEntity.getAttributes().add(a); + } + } +} diff --git a/src/main/java/io/phasetwo/service/model/jpa/entity/OrganizationEntityProvider.java b/src/main/java/io/phasetwo/service/model/jpa/entity/OrganizationEntityProvider.java index 61e1863c..b112eb8b 100644 --- a/src/main/java/io/phasetwo/service/model/jpa/entity/OrganizationEntityProvider.java +++ b/src/main/java/io/phasetwo/service/model/jpa/entity/OrganizationEntityProvider.java @@ -12,6 +12,7 @@ public class OrganizationEntityProvider implements JpaEntityProvider { ExtOrganizationEntity.class, OrganizationAttributeEntity.class, OrganizationMemberEntity.class, + OrganizationMemberAttributeEntity.class, OrganizationRoleEntity.class, UserOrganizationRoleMappingEntity.class, InvitationEntity.class, diff --git a/src/main/java/io/phasetwo/service/model/jpa/entity/OrganizationMemberAttributeEntity.java b/src/main/java/io/phasetwo/service/model/jpa/entity/OrganizationMemberAttributeEntity.java new file mode 100644 index 00000000..e4d8ff58 --- /dev/null +++ b/src/main/java/io/phasetwo/service/model/jpa/entity/OrganizationMemberAttributeEntity.java @@ -0,0 +1,84 @@ +package io.phasetwo.service.model.jpa.entity; + +import jakarta.persistence.Access; +import jakarta.persistence.AccessType; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.FetchType; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.Table; +import jakarta.persistence.UniqueConstraint; +import java.util.Objects; +import org.hibernate.annotations.Nationalized; + +@Table( + name = "ORGANIZATION_MEMBER_ATTRIBUTE", + uniqueConstraints = {@UniqueConstraint(columnNames = {"ORGANIZATION_MEMBER_ID", "NAME"})}) +@Entity +public class OrganizationMemberAttributeEntity { + + @Id + @Column(name = "ID", length = 36) + @Access( + AccessType.PROPERTY) // we do this because relationships often fetch id, but not entity. This + // avoids an extra SQL + protected String id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "ORGANIZATION_MEMBER_ID") + protected OrganizationMemberEntity organizationMember; + + @Column(name = "NAME") + protected String name; + + @Nationalized + @Column(name = "VALUE") + protected String value; + + public String getId() { + return id; + } + + public void setId(String id) { + this.id = id; + } + + public String getName() { + return name; + } + + public void setName(String name) { + this.name = name; + } + + public OrganizationMemberEntity getOrganizationMember() { + return organizationMember; + } + + public void setOrganizationMember(OrganizationMemberEntity organizationMember) { + this.organizationMember = organizationMember; + } + + public String getValue() { + return value; + } + + public void setValue(String value) { + this.value = value; + } + + @Override + public boolean equals(Object o) { + if (o == null || getClass() != o.getClass()) return false; + OrganizationMemberAttributeEntity that = (OrganizationMemberAttributeEntity) o; + return Objects.equals(id, that.id) + && Objects.equals(organizationMember, that.organizationMember); + } + + @Override + public int hashCode() { + return Objects.hash(id, organizationMember); + } +} diff --git a/src/main/java/io/phasetwo/service/model/jpa/entity/OrganizationMemberEntity.java b/src/main/java/io/phasetwo/service/model/jpa/entity/OrganizationMemberEntity.java index fd2fd9ee..3e4e59e5 100644 --- a/src/main/java/io/phasetwo/service/model/jpa/entity/OrganizationMemberEntity.java +++ b/src/main/java/io/phasetwo/service/model/jpa/entity/OrganizationMemberEntity.java @@ -2,6 +2,7 @@ import jakarta.persistence.Access; import jakarta.persistence.AccessType; +import jakarta.persistence.CascadeType; import jakarta.persistence.Column; import jakarta.persistence.Entity; import jakarta.persistence.FetchType; @@ -10,11 +11,14 @@ import jakarta.persistence.ManyToOne; import jakarta.persistence.NamedQueries; import jakarta.persistence.NamedQuery; +import jakarta.persistence.OneToMany; import jakarta.persistence.PrePersist; import jakarta.persistence.Table; import jakarta.persistence.Temporal; import jakarta.persistence.TemporalType; import jakarta.persistence.UniqueConstraint; +import java.util.ArrayList; +import java.util.Collection; import java.util.Date; import java.util.Objects; @@ -60,6 +64,9 @@ public class OrganizationMemberEntity { @Column(name = "CREATED_AT") protected Date createdAt; + @OneToMany(cascade = CascadeType.ALL, orphanRemoval = true, mappedBy = "organizationMember") + protected Collection attributes = new ArrayList<>(); + @PrePersist protected void onCreate() { if (createdAt == null) createdAt = new Date(); @@ -97,6 +104,14 @@ public void setCreatedAt(Date at) { createdAt = at; } + public Collection getAttributes() { + return attributes; + } + + public void setAttributes(Collection attributes) { + this.attributes = attributes; + } + @Override public boolean equals(Object o) { if (this == o) return true; diff --git a/src/main/java/io/phasetwo/service/representation/UserOrganizationMember.java b/src/main/java/io/phasetwo/service/representation/UserOrganizationMember.java new file mode 100644 index 00000000..b34674c8 --- /dev/null +++ b/src/main/java/io/phasetwo/service/representation/UserOrganizationMember.java @@ -0,0 +1,36 @@ +package io.phasetwo.service.representation; + +import java.util.List; +import java.util.Map; +import org.keycloak.representations.idm.UserRepresentation; + +public class UserOrganizationMember extends UserRepresentation { + + Map> organizationMemberAttributes; + String organizationId; + List organizationRoles; + + public Map> getOrganizationMemberAttributes() { + return organizationMemberAttributes; + } + + public void setOrganizationMemberAttributes(Map> organizationMemberAttributes) { + this.organizationMemberAttributes = organizationMemberAttributes; + } + + public String getOrganizationId() { + return organizationId; + } + + public void setOrganizationId(String organizationId) { + this.organizationId = organizationId; + } + + public List getOrganizationRoles() { + return organizationRoles; + } + + public void setOrganizationRoles(List organizationRoles) { + this.organizationRoles = organizationRoles; + } +} diff --git a/src/main/java/io/phasetwo/service/resource/Converters.java b/src/main/java/io/phasetwo/service/resource/Converters.java index 0086ae24..7788bbd9 100644 --- a/src/main/java/io/phasetwo/service/resource/Converters.java +++ b/src/main/java/io/phasetwo/service/resource/Converters.java @@ -9,87 +9,93 @@ import io.phasetwo.service.representation.Invitation; import io.phasetwo.service.representation.Organization; import io.phasetwo.service.representation.OrganizationRole; + import java.util.List; import java.util.Map; + import org.keycloak.models.jpa.entities.UserEntity; import org.keycloak.representations.account.UserRepresentation; -/** Utilities for converting Entities to/from Representations. */ +import static org.keycloak.models.light.LightweightUserAdapter.isLightweightUser; + +/** + * Utilities for converting Entities to/from Representations. + */ public class Converters { - public static OrganizationRole convertOrganizationRole(OrganizationRoleModel m) { - OrganizationRole r = - new OrganizationRole().id(m.getId()).name(m.getName()).description(m.getDescription()); - return r; - } + public static OrganizationRole convertOrganizationRole(OrganizationRoleModel m) { + OrganizationRole r = + new OrganizationRole().id(m.getId()).name(m.getName()).description(m.getDescription()); + return r; + } - public static Organization convertOrganizationModelToOrganization(OrganizationModel e) { - Organization o = - new Organization() - .id(e.getId()) - .name(e.getName()) - .displayName(e.getDisplayName()) - .domains(e.getDomains()) - .url(e.getUrl()) - .realm(e.getRealm().getName()); - o.setAttributes(e.getAttributes()); - return o; - } + public static Organization convertOrganizationModelToOrganization(OrganizationModel e) { + Organization o = + new Organization() + .id(e.getId()) + .name(e.getName()) + .displayName(e.getDisplayName()) + .domains(e.getDomains()) + .url(e.getUrl()) + .realm(e.getRealm().getName()); + o.setAttributes(e.getAttributes()); + return o; + } - public static UserRepresentation convertUserEntityToUserRepresentation(UserEntity e) { - UserRepresentation r = new UserRepresentation(); - r.setEmail(e.getEmail()); - r.setFirstName(e.getFirstName()); - r.setLastName(e.getLastName()); - r.setUsername(e.getUsername()); - r.setEmailVerified(e.isEmailVerified()); - r.setId(e.getId()); - Map> attr = Maps.newHashMap(); - e.getAttributes() - .forEach( - a -> { - List l = attr.get(a.getName()); - if (l == null) l = Lists.newArrayList(); - if (!l.contains(a.getValue())) l.add(a.getValue()); - attr.put(a.getName(), l); - }); - r.setAttributes(attr); - return r; - } + public static UserRepresentation convertUserEntityToUserRepresentation(UserEntity e) { + UserRepresentation r = new UserRepresentation(); + r.setEmail(e.getEmail()); + r.setFirstName(e.getFirstName()); + r.setLastName(e.getLastName()); + r.setUsername(e.getUsername()); + r.setEmailVerified(e.isEmailVerified()); + r.setId(e.getId()); + Map> attr = Maps.newHashMap(); + e.getAttributes() + .forEach( + a -> { + List l = attr.get(a.getName()); + if (l == null) l = Lists.newArrayList(); + if (!l.contains(a.getValue())) l.add(a.getValue()); + attr.put(a.getName(), l); + }); + r.setAttributes(attr); + return r; + } - public static Invitation convertInvitationEntityToInvitation(InvitationEntity e) { - Invitation i = - new Invitation() - .id(e.getId()) - .email(e.getEmail()) - .createdAt(e.getCreatedAt()) - .inviterId(e.getInviterId()) - .organizationId(e.getOrganization().getId()) - .roles(Lists.newArrayList(e.getRoles())); - Map> attr = Maps.newHashMap(); - e.getAttributes() - .forEach( - a -> { - List l = attr.get(a.getName()); - if (l == null) l = Lists.newArrayList(); - if (!l.contains(a.getValue())) l.add(a.getValue()); - attr.put(a.getName(), l); - }); - i.setAttributes(attr); - return i; - } + public static Invitation convertInvitationEntityToInvitation(InvitationEntity e) { + Invitation i = + new Invitation() + .id(e.getId()) + .email(e.getEmail()) + .createdAt(e.getCreatedAt()) + .inviterId(e.getInviterId()) + .organizationId(e.getOrganization().getId()) + .roles(Lists.newArrayList(e.getRoles())); + Map> attr = Maps.newHashMap(); + e.getAttributes() + .forEach( + a -> { + List l = attr.get(a.getName()); + if (l == null) l = Lists.newArrayList(); + if (!l.contains(a.getValue())) l.add(a.getValue()); + attr.put(a.getName(), l); + }); + i.setAttributes(attr); + return i; + } - public static Invitation convertInvitationModelToInvitation(InvitationModel e) { - Invitation i = - new Invitation() - .id(e.getId()) - .email(e.getEmail()) - .createdAt(e.getCreatedAt()) - .inviterId(e.getInviter().getId()) - .invitationUrl(e.getUrl()) - .organizationId(e.getOrganization().getId()) - .roles(Lists.newArrayList(e.getRoles())); - i.setAttributes(Maps.newHashMap(e.getAttributes())); - return i; - } + public static Invitation convertInvitationModelToInvitation(InvitationModel e) { + Invitation i = + new Invitation() + .id(e.getId()) + .email(e.getEmail()) + .createdAt(e.getCreatedAt()) + .inviterId(e.getInviter().getId()) + .invitationUrl(e.getUrl()) + .organizationId(e.getOrganization().getId()) + .roles(Lists.newArrayList(e.getRoles())); + i.setAttributes(Maps.newHashMap(e.getAttributes())); + return i; + } } diff --git a/src/main/java/io/phasetwo/service/resource/MembersResource.java b/src/main/java/io/phasetwo/service/resource/MembersResource.java index 1bc6a3a2..0926ca27 100644 --- a/src/main/java/io/phasetwo/service/resource/MembersResource.java +++ b/src/main/java/io/phasetwo/service/resource/MembersResource.java @@ -3,7 +3,6 @@ import static io.phasetwo.service.Orgs.ACTIVE_ORGANIZATION; import static io.phasetwo.service.resource.OrganizationResourceType.*; import static org.keycloak.events.EventType.UPDATE_PROFILE; -import static org.keycloak.models.utils.ModelToRepresentation.*; import com.google.common.base.Strings; import io.phasetwo.service.model.OrganizationModel; @@ -41,8 +40,8 @@ public Stream getMembers( firstResult = firstResult != null ? firstResult : 0; maxResults = maxResults != null ? maxResults : Constants.DEFAULT_MAX_RESULTS; return organization - .searchForMembersStream(searchQuery, firstResult, maxResults) - .map(m -> toRepresentation(session, realm, m)); + .searchForOrganizationMembersStream(searchQuery, firstResult, maxResults) + .map(m -> UserOrganizationMemberConverter.toRepresentation(session, realm, m)); } @GET diff --git a/src/main/java/io/phasetwo/service/resource/UserOrganizationMemberConverter.java b/src/main/java/io/phasetwo/service/resource/UserOrganizationMemberConverter.java new file mode 100644 index 00000000..14e6b19f --- /dev/null +++ b/src/main/java/io/phasetwo/service/resource/UserOrganizationMemberConverter.java @@ -0,0 +1,62 @@ +package io.phasetwo.service.resource; + +import static org.keycloak.models.light.LightweightUserAdapter.isLightweightUser; + +import io.phasetwo.service.model.OrganizationMemberModel; +import io.phasetwo.service.representation.UserOrganizationMember; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; +import org.keycloak.models.KeycloakSession; +import org.keycloak.models.RealmModel; +import org.keycloak.models.UserModel; +import org.keycloak.models.credential.OTPCredentialModel; + +public final class UserOrganizationMemberConverter { + + public static UserOrganizationMember toRepresentation( + KeycloakSession session, RealmModel realm, OrganizationMemberModel organizationMemberModel) { + var rep = new UserOrganizationMember(); + + var user = session.users().getUserById(realm, organizationMemberModel.getUserId()); + rep.setId(user.getId()); + rep.setUsername(user.getUsername()); + rep.setCreatedTimestamp(user.getCreatedTimestamp()); + rep.setLastName(user.getLastName()); + rep.setFirstName(user.getFirstName()); + rep.setEmail(user.getEmail()); + rep.setEnabled(user.isEnabled()); + rep.setEmailVerified(user.isEmailVerified()); + rep.setTotp(user.credentialManager().isConfiguredFor(OTPCredentialModel.TYPE)); + rep.setDisableableCredentialTypes( + user.credentialManager().getDisableableCredentialTypesStream().collect(Collectors.toSet())); + rep.setFederationLink(user.getFederationLink()); + rep.setNotBefore( + isLightweightUser(user) + ? user.getCreatedTimestamp().intValue() + : session.users().getNotBeforeOfUser(realm, user)); + rep.setRequiredActions(user.getRequiredActionsStream().collect(Collectors.toList())); + + Map> attributes = user.getAttributes(); + Map> copy = null; + + if (attributes != null) { + copy = new HashMap<>(attributes); + copy.remove(UserModel.LAST_NAME); + copy.remove(UserModel.FIRST_NAME); + copy.remove(UserModel.EMAIL); + copy.remove(UserModel.USERNAME); + } + if (attributes != null && !copy.isEmpty()) { + Map> attrs = new HashMap<>(copy); + rep.setAttributes(attrs); + } + + rep.setOrganizationMemberAttributes(organizationMemberModel.getAttributes()); + rep.setOrganizationId(organizationMemberModel.getOrganization().getId()); + rep.setOrganizationRoles(organizationMemberModel.getRoles()); + + return rep; + } +} diff --git a/src/main/resources/META-INF/jpa-changelog-phasetwo-20241228.xml b/src/main/resources/META-INF/jpa-changelog-phasetwo-20241228.xml new file mode 100644 index 00000000..464a8c07 --- /dev/null +++ b/src/main/resources/META-INF/jpa-changelog-phasetwo-20241228.xml @@ -0,0 +1,17 @@ + + + + + + + + + + + + + + + + + diff --git a/src/main/resources/META-INF/jpa-changelog-phasetwo-master.xml b/src/main/resources/META-INF/jpa-changelog-phasetwo-master.xml index 864a6edb..a78db974 100644 --- a/src/main/resources/META-INF/jpa-changelog-phasetwo-master.xml +++ b/src/main/resources/META-INF/jpa-changelog-phasetwo-master.xml @@ -24,5 +24,6 @@ + diff --git a/src/test/java/io/phasetwo/service/AbstractCypressOrganizationTest.java b/src/test/java/io/phasetwo/service/AbstractCypressOrganizationTest.java index bf261b34..a4eee84b 100644 --- a/src/test/java/io/phasetwo/service/AbstractCypressOrganizationTest.java +++ b/src/test/java/io/phasetwo/service/AbstractCypressOrganizationTest.java @@ -41,8 +41,8 @@ public class AbstractCypressOrganizationTest { Boolean.parseBoolean(System.getProperty("include.cypress", "false")); public static final String KEYCLOAK_IMAGE = - String.format( - "quay.io/phasetwo/keycloak-crdb:%s", System.getProperty("keycloak-version", "26.0.2")); + String.format( + "quay.io/phasetwo/keycloak-crdb:%s", System.getProperty("keycloak-version", "26.0.2")); public static final String REALM = "master"; public static final String ADMIN_CLI = "admin-cli"; diff --git a/src/test/java/io/phasetwo/service/AbstractOrganizationTest.java b/src/test/java/io/phasetwo/service/AbstractOrganizationTest.java index 373bbbea..cac53378 100644 --- a/src/test/java/io/phasetwo/service/AbstractOrganizationTest.java +++ b/src/test/java/io/phasetwo/service/AbstractOrganizationTest.java @@ -54,7 +54,7 @@ public abstract class AbstractOrganizationTest { public static final String KEYCLOAK_IMAGE = - String.format( + String.format( "quay.io/phasetwo/keycloak-crdb:%s", System.getProperty("keycloak-version", "26.0.2")); public static final String REALM = "master"; public static final String ADMIN_CLI = "admin-cli"; @@ -928,6 +928,6 @@ protected ClientRepresentation createPublicClientInRealm(RealmResource realm, St client.setFullScopeAllowed(false); realm.clients().create(client).close(); - return realm.clients().findByClientId(client.getClientId()).getFirst(); + return realm.clients().findByClientId(client.getClientId()).getFirst(); } } diff --git a/src/test/java/io/phasetwo/service/importexport/OrganizationIdpExportTest.java b/src/test/java/io/phasetwo/service/importexport/OrganizationIdpExportTest.java index 40a8fb1f..f8d03edf 100644 --- a/src/test/java/io/phasetwo/service/importexport/OrganizationIdpExportTest.java +++ b/src/test/java/io/phasetwo/service/importexport/OrganizationIdpExportTest.java @@ -3,17 +3,15 @@ import static org.hamcrest.MatcherAssert.assertThat; import static org.hamcrest.Matchers.hasSize; import static org.hamcrest.Matchers.is; -import static org.junit.Assert.assertTrue; import com.fasterxml.jackson.core.JsonProcessingException; import com.google.common.collect.ImmutableMap; import io.phasetwo.client.openapi.model.OrganizationRepresentation; import io.phasetwo.service.AbstractOrganizationTest; import io.phasetwo.service.representation.LinkIdp; +import io.phasetwo.service.representation.OrganizationsConfig; import java.io.IOException; import java.util.List; - -import io.phasetwo.service.representation.OrganizationsConfig; import lombok.extern.jbosslog.JBossLog; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.Assertions; @@ -85,43 +83,43 @@ void testOrganizationLinkIdp() throws IOException { void testOrganizationChangeLink() throws IOException { // create organization var organization1 = - createOrganization( - new OrganizationRepresentation() - .name("example-org") - .domains(List.of("example.com", "test.org"))); + createOrganization( + new OrganizationRepresentation() + .name("example-org") + .domains(List.of("example.com", "test.org"))); // create organization var organization2 = - createOrganization( - new OrganizationRepresentation() - .name("example-org2") - .domains(List.of("example2.com", "test2.org"))); + createOrganization( + new OrganizationRepresentation() + .name("example-org2") + .domains(List.of("example2.com", "test2.org"))); // create identity provider String alias1 = "linking-provider-1"; org.keycloak.representations.idm.IdentityProviderRepresentation idp = - new org.keycloak.representations.idm.IdentityProviderRepresentation(); + new org.keycloak.representations.idm.IdentityProviderRepresentation(); idp.setAlias(alias1); idp.setProviderId("oidc"); idp.setEnabled(true); idp.setFirstBrokerLoginFlowAlias("first broker login"); idp.setConfig( - new ImmutableMap.Builder() - .put("useJwksUrl", "true") - .put("syncMode", "FORCE") - .put("authorizationUrl", "https://foo.com") - .put("hideOnLoginPage", "") - .put("loginHint", "") - .put("uiLocales", "") - .put("backchannelSupported", "") - .put("disableUserInfo", "") - .put("acceptsPromptNoneForwardFromClient", "") - .put("validateSignature", "") - .put("pkceEnabled", "") - .put("tokenUrl", "https://foo.com") - .put("clientAuthMethod", "client_secret_post") - .put("clientId", "aabbcc") - .put("clientSecret", "112233") - .build()); + new ImmutableMap.Builder() + .put("useJwksUrl", "true") + .put("syncMode", "FORCE") + .put("authorizationUrl", "https://foo.com") + .put("hideOnLoginPage", "") + .put("loginHint", "") + .put("uiLocales", "") + .put("backchannelSupported", "") + .put("disableUserInfo", "") + .put("acceptsPromptNoneForwardFromClient", "") + .put("validateSignature", "") + .put("pkceEnabled", "") + .put("tokenUrl", "https://foo.com") + .put("clientAuthMethod", "client_secret_post") + .put("clientId", "aabbcc") + .put("clientSecret", "112233") + .build()); keycloak.realm(REALM).identityProviders().create(idp); // link org1 @@ -130,8 +128,8 @@ void testOrganizationChangeLink() throws IOException { link1.setSyncMode("IMPORT"); var responseOrg1Link = postRequest(link1, organization1.getId(), "idps", "link"); assertThat( - responseOrg1Link.getStatusCode(), - is(jakarta.ws.rs.core.Response.Status.CREATED.getStatusCode())); + responseOrg1Link.getStatusCode(), + is(jakarta.ws.rs.core.Response.Status.CREATED.getStatusCode())); // link org2 var link2 = new LinkIdp(); @@ -139,8 +137,8 @@ void testOrganizationChangeLink() throws IOException { link2.setSyncMode("IMPORT"); var responseOrg2Link = postRequest(link2, organization2.getId(), "idps", "link"); assertThat( - responseOrg2Link.getStatusCode(), - is(jakarta.ws.rs.core.Response.Status.CREATED.getStatusCode())); + responseOrg2Link.getStatusCode(), + is(jakarta.ws.rs.core.Response.Status.CREATED.getStatusCode())); // export var export = exportOrgs(keycloak, true); @@ -148,15 +146,15 @@ void testOrganizationChangeLink() throws IOException { // validate org1 export.getOrganizations().stream() - .filter(exportOrg -> exportOrg.getOrganization().getName().equals(organization1.getName())) - .findFirst() - .ifPresent(exportOrg -> Assertions.assertNull(exportOrg.getIdpLink())); + .filter(exportOrg -> exportOrg.getOrganization().getName().equals(organization1.getName())) + .findFirst() + .ifPresent(exportOrg -> Assertions.assertNull(exportOrg.getIdpLink())); // validate org2 export.getOrganizations().stream() - .filter(exportOrg -> exportOrg.getOrganization().getName().equals(organization2.getName())) - .findFirst() - .ifPresent(exportOrg -> assertThat(exportOrg.getIdpLink(), is(alias1))); + .filter(exportOrg -> exportOrg.getOrganization().getName().equals(organization2.getName())) + .findFirst() + .ifPresent(exportOrg -> assertThat(exportOrg.getIdpLink(), is(alias1))); // delete org1 deleteOrganization(organization1.getId()); @@ -176,8 +174,8 @@ public void beforeEach() throws JsonProcessingException { orgConfig.setSharedIdps(false); var responseOrgsConfig = putRequest(orgConfig, url); assertThat( - responseOrgsConfig.getStatusCode(), - is(jakarta.ws.rs.core.Response.Status.OK.getStatusCode())); + responseOrgsConfig.getStatusCode(), + is(jakarta.ws.rs.core.Response.Status.OK.getStatusCode())); } @AfterEach @@ -188,7 +186,7 @@ public void afterEach() throws JsonProcessingException { orgConfig.setSharedIdps(false); var responseOrgsConfig = putRequest(orgConfig, url); assertThat( - responseOrgsConfig.getStatusCode(), - is(jakarta.ws.rs.core.Response.Status.OK.getStatusCode())); + responseOrgsConfig.getStatusCode(), + is(jakarta.ws.rs.core.Response.Status.OK.getStatusCode())); } } diff --git a/src/test/java/io/phasetwo/service/importexport/OrganizationSharedIdpExportTest.java b/src/test/java/io/phasetwo/service/importexport/OrganizationSharedIdpExportTest.java index db97c615..7251e2c1 100644 --- a/src/test/java/io/phasetwo/service/importexport/OrganizationSharedIdpExportTest.java +++ b/src/test/java/io/phasetwo/service/importexport/OrganizationSharedIdpExportTest.java @@ -1,23 +1,22 @@ package io.phasetwo.service.importexport; +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.hasSize; +import static org.hamcrest.Matchers.is; + import com.fasterxml.jackson.core.JsonProcessingException; import com.google.common.collect.ImmutableMap; import io.phasetwo.client.openapi.model.OrganizationRepresentation; import io.phasetwo.service.AbstractOrganizationTest; import io.phasetwo.service.representation.LinkIdp; import io.phasetwo.service.representation.OrganizationsConfig; +import java.io.IOException; +import java.util.List; import lombok.extern.jbosslog.JBossLog; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; -import java.io.IOException; -import java.util.List; - -import static org.hamcrest.MatcherAssert.assertThat; -import static org.hamcrest.Matchers.hasSize; -import static org.hamcrest.Matchers.is; - @JBossLog public class OrganizationSharedIdpExportTest extends AbstractOrganizationTest { @@ -25,43 +24,43 @@ public class OrganizationSharedIdpExportTest extends AbstractOrganizationTest { void testMultiOrganizationLink() throws IOException { // create organization var organization1 = - createOrganization( - new OrganizationRepresentation() - .name("example-org") - .domains(List.of("example.com", "test.org"))); + createOrganization( + new OrganizationRepresentation() + .name("example-org") + .domains(List.of("example.com", "test.org"))); // create organization var organization2 = - createOrganization( - new OrganizationRepresentation() - .name("example-org2") - .domains(List.of("example2.com", "test2.org"))); + createOrganization( + new OrganizationRepresentation() + .name("example-org2") + .domains(List.of("example2.com", "test2.org"))); // create identity provider String alias1 = "linking-provider-1"; org.keycloak.representations.idm.IdentityProviderRepresentation idp = - new org.keycloak.representations.idm.IdentityProviderRepresentation(); + new org.keycloak.representations.idm.IdentityProviderRepresentation(); idp.setAlias(alias1); idp.setProviderId("oidc"); idp.setEnabled(true); idp.setFirstBrokerLoginFlowAlias("first broker login"); idp.setConfig( - new ImmutableMap.Builder() - .put("useJwksUrl", "true") - .put("syncMode", "FORCE") - .put("authorizationUrl", "https://foo.com") - .put("hideOnLoginPage", "") - .put("loginHint", "") - .put("uiLocales", "") - .put("backchannelSupported", "") - .put("disableUserInfo", "") - .put("acceptsPromptNoneForwardFromClient", "") - .put("validateSignature", "") - .put("pkceEnabled", "") - .put("tokenUrl", "https://foo.com") - .put("clientAuthMethod", "client_secret_post") - .put("clientId", "aabbcc") - .put("clientSecret", "112233") - .build()); + new ImmutableMap.Builder() + .put("useJwksUrl", "true") + .put("syncMode", "FORCE") + .put("authorizationUrl", "https://foo.com") + .put("hideOnLoginPage", "") + .put("loginHint", "") + .put("uiLocales", "") + .put("backchannelSupported", "") + .put("disableUserInfo", "") + .put("acceptsPromptNoneForwardFromClient", "") + .put("validateSignature", "") + .put("pkceEnabled", "") + .put("tokenUrl", "https://foo.com") + .put("clientAuthMethod", "client_secret_post") + .put("clientId", "aabbcc") + .put("clientSecret", "112233") + .build()); keycloak.realm(REALM).identityProviders().create(idp); // link org1 @@ -70,8 +69,8 @@ void testMultiOrganizationLink() throws IOException { link1.setSyncMode("IMPORT"); var responseOrg1Link = postRequest(link1, organization1.getId(), "idps", "link"); assertThat( - responseOrg1Link.getStatusCode(), - is(jakarta.ws.rs.core.Response.Status.CREATED.getStatusCode())); + responseOrg1Link.getStatusCode(), + is(jakarta.ws.rs.core.Response.Status.CREATED.getStatusCode())); // link org2 var link2 = new LinkIdp(); @@ -79,8 +78,8 @@ void testMultiOrganizationLink() throws IOException { link2.setSyncMode("IMPORT"); var responseOrg2Link = postRequest(link2, organization2.getId(), "idps", "link"); assertThat( - responseOrg2Link.getStatusCode(), - is(jakarta.ws.rs.core.Response.Status.CREATED.getStatusCode())); + responseOrg2Link.getStatusCode(), + is(jakarta.ws.rs.core.Response.Status.CREATED.getStatusCode())); // export var export = exportOrgs(keycloak, true); @@ -88,15 +87,15 @@ void testMultiOrganizationLink() throws IOException { // validate org1 export.getOrganizations().stream() - .filter(exportOrg -> exportOrg.getOrganization().getName().equals(organization1.getName())) - .findFirst() - .ifPresent(exportOrg -> assertThat(exportOrg.getIdpLink(), is(alias1))); + .filter(exportOrg -> exportOrg.getOrganization().getName().equals(organization1.getName())) + .findFirst() + .ifPresent(exportOrg -> assertThat(exportOrg.getIdpLink(), is(alias1))); // validate org2 export.getOrganizations().stream() - .filter(exportOrg -> exportOrg.getOrganization().getName().equals(organization2.getName())) - .findFirst() - .ifPresent(exportOrg -> assertThat(exportOrg.getIdpLink(), is(alias1))); + .filter(exportOrg -> exportOrg.getOrganization().getName().equals(organization2.getName())) + .findFirst() + .ifPresent(exportOrg -> assertThat(exportOrg.getIdpLink(), is(alias1))); // delete org1 deleteOrganization(organization1.getId()); @@ -116,8 +115,8 @@ public void beforeEach() throws JsonProcessingException { orgConfig.setSharedIdps(true); var responseOrgsConfig = putRequest(orgConfig, url); assertThat( - responseOrgsConfig.getStatusCode(), - is(jakarta.ws.rs.core.Response.Status.OK.getStatusCode())); + responseOrgsConfig.getStatusCode(), + is(jakarta.ws.rs.core.Response.Status.OK.getStatusCode())); } @AfterEach @@ -128,7 +127,7 @@ public void afterEach() throws JsonProcessingException { orgConfig.setSharedIdps(true); var responseOrgsConfig = putRequest(orgConfig, url); assertThat( - responseOrgsConfig.getStatusCode(), - is(jakarta.ws.rs.core.Response.Status.OK.getStatusCode())); + responseOrgsConfig.getStatusCode(), + is(jakarta.ws.rs.core.Response.Status.OK.getStatusCode())); } }