diff --git a/core/src/main/java/org/keycloak/representations/idm/MembershipType.java b/core/src/main/java/org/keycloak/representations/idm/MembershipType.java index 5c89d4e81066..69ac2e3d220c 100644 --- a/core/src/main/java/org/keycloak/representations/idm/MembershipType.java +++ b/core/src/main/java/org/keycloak/representations/idm/MembershipType.java @@ -27,4 +27,6 @@ public enum MembershipType { * Indicates that member cannot exist without group/organization. */ MANAGED; + + public static final String NAME = "membershipType"; } diff --git a/integration/admin-client/src/main/java/org/keycloak/admin/client/resource/OrganizationMembersResource.java b/integration/admin-client/src/main/java/org/keycloak/admin/client/resource/OrganizationMembersResource.java index c101e100d01c..c5ae6d50720d 100644 --- a/integration/admin-client/src/main/java/org/keycloak/admin/client/resource/OrganizationMembersResource.java +++ b/integration/admin-client/src/main/java/org/keycloak/admin/client/resource/OrganizationMembersResource.java @@ -30,6 +30,7 @@ import jakarta.ws.rs.core.MediaType; import jakarta.ws.rs.core.Response; import org.keycloak.representations.idm.MemberRepresentation; +import org.keycloak.representations.idm.MembershipType; import org.keycloak.representations.idm.OrganizationRepresentation; public interface OrganizationMembersResource { @@ -83,6 +84,28 @@ List search( @QueryParam("max") Integer max ); + /** + * Return all organization members that match the specified filters. + * + * @param search a {@code String} representing either a member's username, e-mail, first name, or last name. + * @param exact if {@code true}, the members will be searched using exact match for the {@code search} param - i.e. + * at least one of the username main attributes must match exactly the {@code search} param. If false, + * the method returns all members with at least one main attribute partially matching the {@code search} param. + * @param membershipType The {@link org.keycloak.representations.idm.MembershipType}. + * @param first index of the first element (pagination offset). + * @param max the maximum number of results. + * @return a list containing the matched organization members. + */ + @GET + @Produces(MediaType.APPLICATION_JSON) + List search( + @QueryParam("search") String search, + @QueryParam("exact") Boolean exact, + @QueryParam("membershipType") MembershipType membershipType, + @QueryParam("first") Integer first, + @QueryParam("max") Integer max + ); + @Path("{id}") OrganizationMemberResource member(@PathParam("id") String id); @@ -111,4 +134,4 @@ Response inviteUser(@FormParam("email") String email, @GET @Produces(MediaType.APPLICATION_JSON) List getOrganizations(@PathParam("id") String id); -} +} \ No newline at end of file diff --git a/js/apps/admin-ui/maven-resources/theme/keycloak.v2/admin/messages/messages_en.properties b/js/apps/admin-ui/maven-resources/theme/keycloak.v2/admin/messages/messages_en.properties index 4319458dd572..e108dae114ee 100644 --- a/js/apps/admin-ui/maven-resources/theme/keycloak.v2/admin/messages/messages_en.properties +++ b/js/apps/admin-ui/maven-resources/theme/keycloak.v2/admin/messages/messages_en.properties @@ -3268,4 +3268,21 @@ groupDuplicated=Group duplicated duplicateAGroup=Duplicate group couldNotFetchClientRoleMappings=Could not fetch client role mappings\: {{error}} duplicateGroupWarning=Duplication of groups with a large number of subgroups is not supported. Please ensure that the group you are duplicating does not have a large number of subgroups. -errorSavingTranslations=Error saving translations\: '{{error}}' \ No newline at end of file +errorSavingTranslations=Error saving translations\: '{{error}}' +clearCachesTitle=Clear Caches +realmCache=Realm Cache +userCache=User Cache +keysCache=Keys Cache +clearButtonTitle=Clear +clearRealmCacheHelp=This will clear entries for all realms. +clearUserCacheHelp=This will clear entries for all realms. +clearKeysCacheHelp=Clears all entries from the cache of external public keys. These are keys of external clients or identity providers. This will clear all entries for all realms. +clearCacheSuccess=Cache cleared successfully +clearCacheError=Could not clear cache\: {{error}} +expandRow=Expand row +membershipType=Membership type +managedMembership=Managed membership +filterByMembershipType=Filter by Membership Type +organizationsMembersListError=Could not fetch organization members\: {{error}} +MANAGED=Managed +UNMANAGED=Unmanaged \ No newline at end of file diff --git a/js/apps/admin-ui/src/components/dynamic/CheckboxFilterComponent.tsx b/js/apps/admin-ui/src/components/dynamic/CheckboxFilterComponent.tsx new file mode 100644 index 000000000000..ef24c60b6747 --- /dev/null +++ b/js/apps/admin-ui/src/components/dynamic/CheckboxFilterComponent.tsx @@ -0,0 +1,84 @@ +import { + Badge, + MenuToggle, + Select, + SelectList, + SelectOption, +} from "@patternfly/react-core"; + +type CheckboxFilterOptions = { + value: string; + label: string; +}; + +type CheckboxFilterComponentProps = { + filterPlaceholderText: string; + isOpen: boolean; + options: CheckboxFilterOptions[]; + onOpenChange: (isOpen: boolean) => void; + onToggleClick: () => void; + onSelect: ( + event: React.MouseEvent, + selection: string, + ) => void; + selectedItems: string[]; + width?: string; +}; + +export const CheckboxFilterComponent = ({ + filterPlaceholderText, + isOpen, + options, + onOpenChange, + onToggleClick, + onSelect, + selectedItems, + width, +}: CheckboxFilterComponentProps) => { + const toggle = (toggleRef: React.RefObject) => ( + + {filterPlaceholderText} + {selectedItems.length > 0 && ( + + {selectedItems.length} + + )} + + ); + + return ( + + ); +}; diff --git a/js/apps/admin-ui/src/components/dynamic/SearchInputComponent.tsx b/js/apps/admin-ui/src/components/dynamic/SearchInputComponent.tsx new file mode 100644 index 000000000000..765dbf994ade --- /dev/null +++ b/js/apps/admin-ui/src/components/dynamic/SearchInputComponent.tsx @@ -0,0 +1,64 @@ +import { + Button, + TextInputGroup, + TextInputGroupMain, + TextInputGroupUtilities, +} from "@patternfly/react-core"; +import { ArrowRightIcon, SearchIcon, TimesIcon } from "@patternfly/react-icons"; +import { useTranslation } from "react-i18next"; + +type SearchInputComponentProps = { + value: string; + onChange: (value: string) => void; + onSearch: (value: string) => void; + onClear: () => void; + placeholder?: string; + "aria-label"?: string; +}; + +export const SearchInputComponent = ({ + value, + onChange, + onSearch, + onClear, + placeholder, + "aria-label": ariaLabel, +}: SearchInputComponentProps) => { + const { t } = useTranslation(); + + return ( + <> + + } + value={value} + onChange={(event: React.FormEvent) => + onChange(event.currentTarget.value) + } + placeholder={placeholder} + aria-label={ariaLabel} + data-testid="search-input" + /> + + {value && ( + + + setIsOpen(nextOpen)} + onToggleClick={onToggleClick} + onSelect={onSelect} + selectedItems={filteredMembershipTypes} + width={"260px"} + /> + } actions={[ @@ -177,6 +265,10 @@ export const Members = () => { { name: "lastName", }, + { + name: "membershipType", + cellFormatters: [translationFormatter(t)], + }, ]} emptyState={ { ]} /> } + isSearching={filteredMembershipTypes.length > 0} /> - + ); }; diff --git a/js/apps/admin-ui/src/user/EditUser.tsx b/js/apps/admin-ui/src/user/EditUser.tsx index 6bf00df777e2..0af116efad2d 100644 --- a/js/apps/admin-ui/src/user/EditUser.tsx +++ b/js/apps/admin-ui/src/user/EditUser.tsx @@ -398,7 +398,7 @@ export default function EditUser() { title={{t("organizations")}} {...organizationsTab} > - + )} { +type OrganizationProps = { + user: UserRepresentation; +}; + +type MembershipTypeRepresentation = OrganizationRepresentation & + UserRepresentation & { + membershipType?: string; + }; + +export const Organizations = ({ user }: OrganizationProps) => { const { adminClient } = useAdminClient(); const { t } = useTranslation(); const { id } = useParams(); + const navigate = useNavigate(); const { addAlert, addError } = useAlerts(); const { realm } = useRealm(); - const [key, setKey] = useState(0); const refresh = () => setKey(key + 1); - const [joinToggle, toggle, setJoinToggle] = useToggle(); const [shouldJoin, setShouldJoin] = useState(true); const [openOrganizationPicker, setOpenOrganizationPicker] = useState(false); @@ -42,13 +55,98 @@ export const Organizations = () => { const [selectedOrgs, setSelectedOrgs] = useState< OrganizationRepresentation[] >([]); + const [searchText, setSearchText] = useState(""); + const [searchTriggerText, setSearchTriggerText] = useState(""); + const [filteredMembershipTypes, setFilteredMembershipTypes] = useState< + string[] + >([]); + const [isOpen, setIsOpen] = useState(false); + + const membershipOptions = [ + { value: "Managed", label: "Managed" }, + { value: "Unmanaged", label: "Unmanaged" }, + ]; + + const onToggleClick = () => { + setIsOpen(!isOpen); + }; + + const onSelect = (_event: any, value: string) => { + if (filteredMembershipTypes.includes(value)) { + setFilteredMembershipTypes( + filteredMembershipTypes.filter((item) => item !== value), + ); + } else { + setFilteredMembershipTypes([...filteredMembershipTypes, value]); + } + setIsOpen(false); + refresh(); + }; useFetch( - () => adminClient.organizations.memberOrganizations({ userId: id! }), + async () => { + const userOrganizations = + await adminClient.organizations.memberOrganizations({ userId: id! }); + + const userOrganizationsWithMembershipTypes = await Promise.all( + userOrganizations.map(async (org) => { + const orgId = org.id; + const memberships: MembershipTypeRepresentation[] = + await adminClient.organizations.listMembers({ + orgId: orgId!, + }); + + const userMemberships = memberships.filter( + (membership) => membership.username === user.username, + ); + + const membershipType = userMemberships.map((membership) => { + const formattedMembershipType = capitalizeFirstLetterFormatter()( + membership.membershipType, + ); + return formattedMembershipType; + }); + + return { ...org, membershipType }; + }), + ); + + let filteredOrgs = userOrganizationsWithMembershipTypes; + if (filteredMembershipTypes.length > 0) { + filteredOrgs = filteredOrgs.filter((org) => + org.membershipType?.some((type) => + filteredMembershipTypes.includes(type as string), + ), + ); + } + + if (searchTriggerText) { + filteredOrgs = filteredOrgs.filter((org) => + org.name?.toLowerCase().includes(searchTriggerText.toLowerCase()), + ); + } + + return filteredOrgs; + }, setUserOrgs, - [key], + [key, filteredMembershipTypes, searchTriggerText], ); + const handleChange = (value: string) => { + setSearchText(value); + }; + + const handleSearch = () => { + setSearchTriggerText(searchText); + refresh(); + }; + + const clearInput = () => { + setSearchText(""); + setSearchTriggerText(""); + refresh(); + }; + const [toggleDeleteDialog, DeleteConfirm] = useConfirmDialog({ titleKey: "removeConfirmOrganizationTitle", messageKey: t("organizationRemoveConfirm", { count: selectedOrgs.length }), @@ -65,6 +163,10 @@ export const Organizations = () => { ), ); addAlert(t("organizationRemovedSuccess")); + const user = await adminClient.users.findOne({ id: id! }); + if (!user) { + navigate(toUsers({ realm: realm })); + } setSelectedOrgs([]); refresh(); } catch (error) { @@ -92,9 +194,7 @@ export const Organizations = () => { userId: id!, }) : adminClient.organizations.inviteExistingUser( - { - orgId: org.id!, - }, + { orgId: org.id! }, form, ); }), @@ -132,6 +232,9 @@ export const Organizations = () => { )} loader={userOrgs} + isSearching={ + searchTriggerText.length > 0 || filteredMembershipTypes.length > 0 + } onSelect={(orgs) => setSelectedOrgs(orgs)} deleteLabel="remove" onDelete={(org) => { @@ -140,6 +243,16 @@ export const Organizations = () => { }} toolbarItem={ <> + + + { {t("remove")} + + setIsOpen(nextOpen)} + onToggleClick={onToggleClick} + onSelect={onSelect} + selectedItems={filteredMembershipTypes} + width={"260px"} + /> + } > diff --git a/js/apps/admin-ui/src/util.ts b/js/apps/admin-ui/src/util.ts index 8dbf65619f3b..8dfe0c2bae5d 100644 --- a/js/apps/admin-ui/src/util.ts +++ b/js/apps/admin-ui/src/util.ts @@ -156,6 +156,17 @@ export const upperCaseFormatter = return (value ? toUpperCase(value) : undefined) as string; }; +export const capitalizeFirstLetterFormatter = + (): IFormatter => (data?: IFormatterValueType) => { + const value = data?.toString(); + + return ( + value + ? value.charAt(0).toUpperCase() + value.slice(1).toLowerCase() + : undefined + ) as string; + }; + export const alphaRegexPattern = /[^A-Za-z]/g; export const emailRegexPattern = diff --git a/js/libs/keycloak-admin-client/src/resources/organizations.ts b/js/libs/keycloak-admin-client/src/resources/organizations.ts index 3e34406663d8..6d4ab1a98937 100644 --- a/js/libs/keycloak-admin-client/src/resources/organizations.ts +++ b/js/libs/keycloak-admin-client/src/resources/organizations.ts @@ -16,6 +16,7 @@ export interface OrganizationQuery extends PaginatedQuery { interface MemberQuery extends PaginatedQuery { orgId: string; //Id of the organization to get the members of + membershipType?: string; } export class Organizations extends Resource<{ realm?: string }> { diff --git a/js/libs/ui-shared/src/controls/OrganizationTable.tsx b/js/libs/ui-shared/src/controls/OrganizationTable.tsx index 85c1780e719e..af55035b90d4 100644 --- a/js/libs/ui-shared/src/controls/OrganizationTable.tsx +++ b/js/libs/ui-shared/src/controls/OrganizationTable.tsx @@ -53,7 +53,7 @@ const Domains = (org: OrganizationRepresentation) => { ); }; -type OrganizationTableProps = PropsWithChildren & { +export type OrganizationTableProps = PropsWithChildren & { loader: | LoaderFunction | OrganizationRepresentation[]; @@ -62,6 +62,7 @@ type OrganizationTableProps = PropsWithChildren & { >; toolbarItem?: ReactNode; isPaginated?: boolean; + isSearching?: boolean; onSelect?: (orgs: OrganizationRepresentation[]) => void; onDelete?: (org: OrganizationRepresentation) => void; deleteLabel?: string; @@ -71,6 +72,7 @@ export const OrganizationTable = ({ loader, toolbarItem, isPaginated = false, + isSearching = false, onSelect, onDelete, deleteLabel = "delete", @@ -83,8 +85,8 @@ export const OrganizationTable = ({ diff --git a/model/infinispan/pom.xml b/model/infinispan/pom.xml index b48ad2bd0833..534ce9305c2d 100755 --- a/model/infinispan/pom.xml +++ b/model/infinispan/pom.xml @@ -43,6 +43,10 @@ org.keycloak keycloak-model-storage + + org.keycloak + keycloak-model-jpa + org.keycloak keycloak-model-storage-private diff --git a/model/infinispan/src/main/java/org/keycloak/models/cache/infinispan/UserCacheSession.java b/model/infinispan/src/main/java/org/keycloak/models/cache/infinispan/UserCacheSession.java index 329502066a64..92090ef52f01 100755 --- a/model/infinispan/src/main/java/org/keycloak/models/cache/infinispan/UserCacheSession.java +++ b/model/infinispan/src/main/java/org/keycloak/models/cache/infinispan/UserCacheSession.java @@ -17,9 +17,10 @@ package org.keycloak.models.cache.infinispan; +import static org.keycloak.organization.utils.Organizations.isReadOnlyOrganizationMember; + import org.jboss.logging.Logger; import org.keycloak.cluster.ClusterProvider; -import org.keycloak.common.Profile; import org.keycloak.credential.CredentialInput; import org.keycloak.models.ClientScopeModel; import org.keycloak.models.CredentialValidationOutput; @@ -56,7 +57,6 @@ import org.keycloak.models.cache.infinispan.stream.InIdentityProviderPredicate; import org.keycloak.models.utils.KeycloakModelUtils; import org.keycloak.models.utils.ReadOnlyUserModelDelegate; -import org.keycloak.organization.OrganizationProvider; import org.keycloak.storage.CacheableStorageProviderModel; import org.keycloak.storage.DatastoreProvider; import org.keycloak.storage.StoreManagers; @@ -339,7 +339,7 @@ protected UserModel validateCache(RealmModel realm, CachedUser cached) { protected UserModel cacheUser(RealmModel realm, UserModel delegate, Long revision) { int notBefore = getDelegate().getNotBeforeOfUser(realm, delegate); - if (isReadOnlyOrganizationMember(delegate)) { + if (isReadOnlyOrganizationMember(session, delegate)) { return new ReadOnlyUserModelDelegate(delegate, false); } @@ -967,27 +967,6 @@ public List decorateUserProfile(String providerId, UserProfil return List.of(); } - private boolean isReadOnlyOrganizationMember(UserModel delegate) { - if (delegate == null) { - return false; - } - - if (!Profile.isFeatureEnabled(Profile.Feature.ORGANIZATION)) { - return false; - } - - OrganizationProvider organizationProvider = session.getProvider(OrganizationProvider.class); - - if (organizationProvider.count() == 0) { - return false; - } - - // check if provider is enabled and user is managed member of a disabled organization OR provider is disabled and user is managed member - return organizationProvider.getByMember(delegate) - .anyMatch((org) -> (organizationProvider.isEnabled() && org.isManaged(delegate) && !org.isEnabled()) || - (!organizationProvider.isEnabled() && org.isManaged(delegate))); - } - public UserCacheManager getCache() { return cache; } @@ -1004,4 +983,4 @@ public void registerInvalidation(String id) { public boolean isInvalid(String key) { return invalidations.contains(key); } -} +} \ No newline at end of file diff --git a/model/infinispan/src/main/java/org/keycloak/models/cache/infinispan/organization/InfinispanOrganizationProvider.java b/model/infinispan/src/main/java/org/keycloak/models/cache/infinispan/organization/InfinispanOrganizationProvider.java index 01097309545e..cf42cee7b33c 100644 --- a/model/infinispan/src/main/java/org/keycloak/models/cache/infinispan/organization/InfinispanOrganizationProvider.java +++ b/model/infinispan/src/main/java/org/keycloak/models/cache/infinispan/organization/InfinispanOrganizationProvider.java @@ -18,6 +18,7 @@ import java.util.HashMap; import java.util.Map; +import java.util.Optional; import java.util.stream.Stream; import org.keycloak.models.IdentityProviderModel; import org.keycloak.models.KeycloakSession; @@ -176,7 +177,15 @@ public boolean removeMember(OrganizationModel organization, UserModel member) { @Override public Stream getMembersStream(OrganizationModel organization, String search, Boolean exact, Integer first, Integer max) { - return getDelegate().getMembersStream(organization, search, exact, first, max); + Map filters = Optional.ofNullable(search) + .map(value -> Map.of(UserModel.SEARCH, value)) + .orElse(Map.of()); + return getMembersStream(organization, filters, exact, first, max); + } + + @Override + public Stream getMembersStream(OrganizationModel organization, Map filters, Boolean exact, Integer first, Integer max) { + return getDelegate().getMembersStream(organization, filters, exact, first, max); } @Override @@ -402,4 +411,4 @@ private boolean isRealmCacheKeyInvalid(String cacheKey) { private boolean isUserCacheKeyInvalid(String cacheKey) { return userCache.isInvalid(cacheKey); } -} +} \ No newline at end of file diff --git a/model/jpa/src/main/java/org/keycloak/organization/jpa/JpaOrganizationProvider.java b/model/jpa/src/main/java/org/keycloak/organization/jpa/JpaOrganizationProvider.java index 5fbe98098658..317c69af810a 100644 --- a/model/jpa/src/main/java/org/keycloak/organization/jpa/JpaOrganizationProvider.java +++ b/model/jpa/src/main/java/org/keycloak/organization/jpa/JpaOrganizationProvider.java @@ -18,13 +18,20 @@ package org.keycloak.organization.jpa; import static org.keycloak.models.OrganizationModel.ORGANIZATION_DOMAIN_ATTRIBUTE; +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.organization.utils.Organizations.isReadOnlyOrganizationMember; import static org.keycloak.utils.StreamsUtil.closing; import java.util.ArrayList; import java.util.List; import java.util.Map; +import java.util.Map.Entry; import java.util.Objects; +import java.util.Optional; import java.util.Set; import java.util.stream.Stream; @@ -33,6 +40,7 @@ import jakarta.persistence.TypedQuery; import jakarta.persistence.criteria.CriteriaBuilder; import jakarta.persistence.criteria.CriteriaQuery; +import jakarta.persistence.criteria.From; import jakarta.persistence.criteria.Join; import jakarta.persistence.criteria.Predicate; import jakarta.persistence.criteria.Root; @@ -56,6 +64,7 @@ import org.keycloak.models.jpa.entities.UserEntity; import org.keycloak.models.jpa.entities.UserGroupMembershipEntity; import org.keycloak.models.utils.KeycloakModelUtils; +import org.keycloak.models.utils.ReadOnlyUserModelDelegate; import org.keycloak.organization.OrganizationProvider; import org.keycloak.representations.idm.MembershipType; import org.keycloak.organization.utils.Organizations; @@ -275,10 +284,72 @@ public Stream getAllStream(Map attributes, In @Override public Stream getMembersStream(OrganizationModel organization, String search, Boolean exact, Integer first, Integer max) { + return getMembersStream(organization, Map.of(UserModel.SEARCH, search), exact, first, max); + } + + @Override + public Stream getMembersStream(OrganizationModel organization, Map filters, Boolean exact, Integer first, Integer max) { throwExceptionIfObjectIsNull(organization, "Organization"); - GroupModel group = getOrganizationGroup(organization); + var builder = em.getCriteriaBuilder(); + var queryBuilder = builder.createQuery(String.class); + var groupMembership = queryBuilder.from(UserGroupMembershipEntity.class); + + queryBuilder.select(groupMembership.get("user").get("id")); + + var predicates = new ArrayList<>(); + var group = getOrganizationGroup(organization); + + predicates.add(builder.equal(groupMembership.get("groupId"), group.getId())); + + From userJoin = groupMembership.join("user"); + + for (Entry filter : Optional.ofNullable(filters).orElse(Map.of()).entrySet()) { + switch (filter.getKey()) { + case UserModel.SEARCH -> predicates.add(builder + .or(getSearchOptionPredicateArray(filter.getValue(), Optional.ofNullable(exact).orElse(false), builder, userJoin))); + case MembershipType.NAME -> predicates.add(builder + .equal(groupMembership.get(MembershipType.NAME), filter.getValue().toUpperCase())); + } + } + + queryBuilder.where(predicates.toArray(new Predicate[0])); + queryBuilder.orderBy(builder.asc(userJoin.get(USERNAME))); + + return closing(paginateQuery(em.createQuery(queryBuilder), first, max).getResultStream().map(id -> { + UserModel user = userProvider.getUserById(getRealm(), id); + + if (isReadOnlyOrganizationMember(session, user)) { + return new ReadOnlyUserModelDelegate(user) { + @Override + public boolean isEnabled() { + return false; + } + }; + } + + return user; + })); + } + + private Predicate[] getSearchOptionPredicateArray(String value, boolean exact, CriteriaBuilder builder, From from) { + value = value.toLowerCase(); + + List orPredicates = new ArrayList<>(); + + if (exact) { + orPredicates.add(builder.equal(from.get(USERNAME), value)); + orPredicates.add(builder.equal(from.get(EMAIL), value)); + orPredicates.add(builder.equal(builder.lower(from.get(FIRST_NAME)), value)); + orPredicates.add(builder.equal(builder.lower(from.get(LAST_NAME)), value)); + } else { + value = "%" + value + "%"; + orPredicates.add(builder.like(from.get(USERNAME), value)); + orPredicates.add(builder.like(from.get(EMAIL), value)); + orPredicates.add(builder.like(builder.lower(from.get(FIRST_NAME)), value)); + orPredicates.add(builder.like(builder.lower(from.get(LAST_NAME)), value)); + } - return userProvider.getGroupMembersStream(getRealm(), group, search, exact, first, max); + return orPredicates.toArray(Predicate[]::new); } @Override @@ -531,4 +602,4 @@ private RealmModel getRealm() { } return realm; } -} +} \ No newline at end of file diff --git a/model/storage-private/src/main/java/org/keycloak/exportimport/util/ExportUtils.java b/model/storage-private/src/main/java/org/keycloak/exportimport/util/ExportUtils.java index 161c721c57c8..2da8215a5c2a 100755 --- a/model/storage-private/src/main/java/org/keycloak/exportimport/util/ExportUtils.java +++ b/model/storage-private/src/main/java/org/keycloak/exportimport/util/ExportUtils.java @@ -257,7 +257,7 @@ public static RealmRepresentation exportRealm(KeycloakSession session, RealmMode orgProvider.getAllStream().map(model -> { OrganizationRepresentation org = ModelToRepresentation.toRepresentation(model); - orgProvider.getMembersStream(model, null, null, null, null) + orgProvider.getMembersStream(model, (Map) null, null, null, null) .forEach(user -> { MemberRepresentation member = new MemberRepresentation(); member.setUsername(user.getUsername()); diff --git a/server-spi/src/main/java/org/keycloak/organization/OrganizationProvider.java b/server-spi/src/main/java/org/keycloak/organization/OrganizationProvider.java index 3f689d61ebb5..eefee71bbfe6 100644 --- a/server-spi/src/main/java/org/keycloak/organization/OrganizationProvider.java +++ b/server-spi/src/main/java/org/keycloak/organization/OrganizationProvider.java @@ -17,13 +17,16 @@ package org.keycloak.organization; import java.util.Map; +import java.util.Optional; import java.util.stream.Stream; + import org.keycloak.models.IdentityProviderModel; import org.keycloak.models.ModelDuplicateException; import org.keycloak.models.ModelException; import org.keycloak.models.OrganizationModel; import org.keycloak.models.UserModel; import org.keycloak.provider.Provider; +import org.keycloak.representations.idm.MembershipType; /** * A {@link Provider} that manages organization and its data within the scope of a realm. @@ -139,9 +142,28 @@ default Stream getAllStream() { * * @param organization the organization * @return Stream of the members. Never returns {@code null}. + * @deprecated Use {@link #getMembersStream(OrganizationModel, Map, Boolean, Integer, Integer)} instead. */ + @Deprecated(forRemoval = true, since = "26") Stream getMembersStream(OrganizationModel organization, String search, Boolean exact, Integer first, Integer max); + /** + * Returns the members of a given {@link OrganizationModel} filtered according to the specified {@code filters}. + * + * @param organization the organization + * @return Stream of the members. Never returns {@code null}. + */ + default Stream getMembersStream(OrganizationModel organization, Map filters, Boolean exact, Integer first, Integer max) { + var result = getMembersStream(organization, Optional.ofNullable(filters).orElse(Map.of()).get(UserModel.SEARCH), exact, first, max); + var membershipType = Optional.ofNullable(filters.get(MembershipType.NAME)).map(MembershipType::valueOf).orElse(null); + + if (membershipType != null) { + return result.filter(userModel -> MembershipType.MANAGED.equals(membershipType) ? isManagedMember(organization, userModel) : !isManagedMember(organization, userModel)); + } + + return result; + } + /** * Returns number of members in the organization. * @param organization the organization @@ -254,4 +276,4 @@ default boolean isMember(OrganizationModel organization, UserModel user) { default OrganizationModel getByAlias(String alias) { return getAllStream(Map.of(OrganizationModel.ALIAS, alias), 0, 1).findAny().orElse(null); } -} +} \ No newline at end of file diff --git a/services/src/main/java/org/keycloak/organization/admin/resource/OrganizationMemberResource.java b/services/src/main/java/org/keycloak/organization/admin/resource/OrganizationMemberResource.java index f7dbe5d69a76..572df303a086 100644 --- a/services/src/main/java/org/keycloak/organization/admin/resource/OrganizationMemberResource.java +++ b/services/src/main/java/org/keycloak/organization/admin/resource/OrganizationMemberResource.java @@ -17,6 +17,8 @@ package org.keycloak.organization.admin.resource; +import java.util.HashMap; +import java.util.Map; import java.util.stream.Stream; import jakarta.ws.rs.Consumes; @@ -136,9 +138,20 @@ public Stream search( @Parameter(description = "A String representing either a member's username, e-mail, first name, or last name.") @QueryParam("search") String search, @Parameter(description = "Boolean which defines whether the param 'search' must match exactly or not") @QueryParam("exact") Boolean exact, @Parameter(description = "The position of the first result to be processed (pagination offset)") @QueryParam("first") @DefaultValue("0") Integer first, - @Parameter(description = "The maximum number of results to be returned. Defaults to 10") @QueryParam("max") @DefaultValue("10") Integer max + @Parameter(description = "The maximum number of results to be returned. Defaults to 10") @QueryParam("max") @DefaultValue("10") Integer max, + @Parameter(description = "The membership type") @QueryParam("membershipType") String membershipType ) { - return provider.getMembersStream(organization, search, exact, first, max).map(this::toRepresentation); + Map filters = new HashMap<>(); + + if (search != null) { + filters.put(UserModel.SEARCH, search); + } + + if (membershipType != null) { + filters.put(MembershipType.NAME, MembershipType.valueOf(membershipType.toUpperCase()).name()); + } + + return provider.getMembersStream(organization, filters, exact, first, max).map(this::toRepresentation); } @Path("{id}") @@ -234,4 +247,4 @@ private MemberRepresentation toRepresentation(UserModel member) { result.setMembershipType(provider.isManagedMember(organization, member) ? MembershipType.MANAGED : MembershipType.UNMANAGED); return result; } -} +} \ No newline at end of file diff --git a/services/src/main/java/org/keycloak/organization/utils/Organizations.java b/services/src/main/java/org/keycloak/organization/utils/Organizations.java index b74a6d922244..b6d21b41e80f 100644 --- a/services/src/main/java/org/keycloak/organization/utils/Organizations.java +++ b/services/src/main/java/org/keycloak/organization/utils/Organizations.java @@ -241,4 +241,25 @@ public static boolean isRegistrationAllowed(KeycloakSession session, RealmModel if (session.getContext().getOrganization() != null) return true; return realm.isRegistrationAllowed(); } -} + + public static boolean isReadOnlyOrganizationMember(KeycloakSession session, UserModel delegate) { + if (delegate == null) { + return false; + } + + if (!Profile.isFeatureEnabled(Profile.Feature.ORGANIZATION)) { + return false; + } + + var organizationProvider = getProvider(session); + + if (organizationProvider.count() == 0) { + return false; + } + + // check if provider is enabled and user is managed member of a disabled organization OR provider is disabled and user is managed member + return organizationProvider.getByMember(delegate) + .anyMatch((org) -> (organizationProvider.isEnabled() && org.isManaged(delegate) && !org.isEnabled()) || + (!organizationProvider.isEnabled() && org.isManaged(delegate))); + } +} \ No newline at end of file diff --git a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/organization/member/OrganizationMemberTest.java b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/organization/member/OrganizationMemberTest.java index fbe2f986e00d..843e2ff598fe 100755 --- a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/organization/member/OrganizationMemberTest.java +++ b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/organization/member/OrganizationMemberTest.java @@ -57,6 +57,7 @@ import org.keycloak.representations.idm.AbstractUserRepresentation; import org.keycloak.representations.idm.IdentityProviderRepresentation; import org.keycloak.representations.idm.MemberRepresentation; +import org.keycloak.representations.idm.MembershipType; import org.keycloak.representations.idm.OrganizationRepresentation; import org.keycloak.representations.idm.UserRepresentation; import org.keycloak.representations.userprofile.config.UPConfig; @@ -415,6 +416,11 @@ public void testSearchMembers() { assertThat(existing, hasSize(2)); assertThat(existing.get(0).getUsername(), is(equalTo("marthaw@neworg.org"))); assertThat(existing.get(1).getUsername(), is(equalTo("thejoker@neworg.org"))); + + existing = organization.members().search(null, null, MembershipType.MANAGED, -1, -1); + assertTrue(existing.isEmpty()); + existing = organization.members().search(null, null, MembershipType.UNMANAGED, -1, -1); + assertThat(existing, hasSize(5)); } @Test @@ -570,4 +576,4 @@ private void loginViaNonOrgIdP(String idpAlias) { private UserRepresentation getUserRepFromMemberRep(MemberRepresentation member) { return new UserRepresentation(member); } -} +} \ No newline at end of file