Backport to expose membership type

Signed-off-by: Agnieszka Gancarczyk <agagancarczyk@gmail.com>
This commit is contained in:
Agnieszka Gancarczyk
2024-11-14 17:25:25 +00:00
committed by Pedro Igor
parent 3400602ee6
commit f0243a8c0b
20 changed files with 611 additions and 60 deletions

View File

@@ -27,4 +27,6 @@ public enum MembershipType {
* Indicates that member cannot exist without group/organization.
*/
MANAGED;
public static final String NAME = "membershipType";
}

View File

@@ -30,6 +30,7 @@ import jakarta.ws.rs.QueryParam;
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 @@ public interface OrganizationMembersResource {
@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<MemberRepresentation> 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 @@ public interface OrganizationMembersResource {
@GET
@Produces(MediaType.APPLICATION_JSON)
List<OrganizationRepresentation> getOrganizations(@PathParam("id") String id);
}
}

View File

@@ -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}}'
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

View File

@@ -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<HTMLButtonElement>,
selection: string,
) => void;
selectedItems: string[];
width?: string;
};
export const CheckboxFilterComponent = ({
filterPlaceholderText,
isOpen,
options,
onOpenChange,
onToggleClick,
onSelect,
selectedItems,
width,
}: CheckboxFilterComponentProps) => {
const toggle = (toggleRef: React.RefObject<HTMLButtonElement>) => (
<MenuToggle
ref={toggleRef}
onClick={onToggleClick}
isExpanded={isOpen}
style={{
width,
}}
>
{filterPlaceholderText}
{selectedItems.length > 0 && (
<Badge isRead className="pf-v5-u-m-xs">
{selectedItems.length}
</Badge>
)}
</MenuToggle>
);
return (
<Select
role="menu"
id="checkbox-select"
isOpen={isOpen}
selected={selectedItems}
onSelect={(event, value) => {
onSelect(event as React.MouseEvent<HTMLButtonElement>, value as string);
}}
onOpenChange={onOpenChange}
toggle={toggle}
data-testid="checkbox-filter-select"
>
<SelectList>
{options.map((option) => (
<SelectOption
key={option.value}
hasCheckbox
value={option.value}
isSelected={selectedItems.includes(option.value)}
data-testid={`checkbox-filter-option-${option.value}`}
>
{option.label}
</SelectOption>
))}
</SelectList>
</Select>
);
};

View File

@@ -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 (
<>
<TextInputGroup>
<TextInputGroupMain
icon={<SearchIcon />}
value={value}
onChange={(event: React.FormEvent<HTMLInputElement>) =>
onChange(event.currentTarget.value)
}
placeholder={placeholder}
aria-label={ariaLabel}
data-testid="search-input"
/>
<TextInputGroupUtilities style={{ marginInline: "0px" }}>
{value && (
<Button
variant="plain"
onClick={onClear}
aria-label={t("clear")}
data-testid="clear-search"
icon={<TimesIcon />}
/>
)}
</TextInputGroupUtilities>
</TextInputGroup>
<Button
icon={<ArrowRightIcon />}
variant="control"
style={{ marginLeft: "0.1rem" }}
onClick={() => onSearch(value)}
aria-label={t("search")}
data-testid="search"
/>
</>
);
};

View File

@@ -5,7 +5,6 @@ import {
DropdownItem,
DropdownList,
MenuToggle,
PageSection,
ToolbarItem,
} from "@patternfly/react-core";
import { useState } from "react";
@@ -22,6 +21,13 @@ import { useParams } from "../utils/useParams";
import useToggle from "../utils/useToggle";
import { InviteMemberModal } from "./InviteMemberModal";
import { EditOrganizationParams } from "./routes/EditOrganization";
import { CheckboxFilterComponent } from "../components/dynamic/CheckboxFilterComponent";
import { SearchInputComponent } from "../components/dynamic/SearchInputComponent";
import { translationFormatter } from "../utils/translationFormatter";
type MembershipTypeRepresentation = UserRepresentation & {
membershipType?: string;
};
const UserDetailLink = (user: any) => {
const { realm } = useRealm();
@@ -37,19 +43,79 @@ export const Members = () => {
const { adminClient } = useAdminClient();
const { id: orgId } = useParams<EditOrganizationParams>();
const { addAlert, addError } = useAlerts();
const [key, setKey] = useState(0);
const refresh = () => setKey(key + 1);
const [open, toggle] = useToggle();
const [openAddMembers, toggleAddMembers] = useToggle();
const [openInviteMembers, toggleInviteMembers] = useToggle();
const [selectedMembers, setSelectedMembers] = useState<UserRepresentation[]>(
[],
);
const [searchText, setSearchText] = useState<string>("");
const [searchTriggerText, setSearchTriggerText] = useState<string>("");
const [filteredMembershipTypes, setFilteredMembershipTypes] = useState<
string[]
>([]);
const [isOpen, setIsOpen] = useState(false);
const loader = (first?: number, max?: number, search?: string) =>
adminClient.organizations.listMembers({ orgId, first, max, search });
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();
};
const loader = async (first?: number, max?: number) => {
try {
const membershipType =
filteredMembershipTypes.length === 1
? filteredMembershipTypes[0]
: undefined;
const memberships: MembershipTypeRepresentation[] =
await adminClient.organizations.listMembers({
orgId,
first,
max,
search: searchTriggerText,
membershipType,
});
return memberships;
} catch (error) {
addError("organizationsMembersListError", error);
return [];
}
};
const handleChange = (value: string) => {
setSearchText(value);
};
const handleSearch = () => {
setSearchTriggerText(searchText);
refresh();
};
const clearInput = () => {
setSearchText("");
setSearchTriggerText("");
refresh();
};
const removeMember = async (selectedMembers: UserRepresentation[]) => {
try {
@@ -68,8 +134,9 @@ export const Members = () => {
refresh();
};
return (
<PageSection variant="light">
<>
{openAddMembers && (
<MemberModal
membersQuery={() => adminClient.organizations.listMembers({ orgId })}
@@ -104,11 +171,20 @@ export const Members = () => {
loader={loader}
isPaginated
ariaLabelKey="membersList"
searchPlaceholderKey="searchMember"
onSelect={(members) => setSelectedMembers([...members])}
canSelectAll
toolbarItem={
<>
<ToolbarItem>
<SearchInputComponent
value={searchText}
onChange={handleChange}
onSearch={handleSearch}
onClear={clearInput}
placeholder={t("searchMembers")}
aria-label={t("searchMembers")}
/>
</ToolbarItem>
<ToolbarItem>
<Dropdown
onOpenChange={toggle}
@@ -153,6 +229,18 @@ export const Members = () => {
{t("removeMember")}
</Button>
</ToolbarItem>
<ToolbarItem>
<CheckboxFilterComponent
filterPlaceholderText={t("filterByMembershipType")}
isOpen={isOpen}
options={membershipOptions}
onOpenChange={(nextOpen) => setIsOpen(nextOpen)}
onToggleClick={onToggleClick}
onSelect={onSelect}
selectedItems={filteredMembershipTypes}
width={"260px"}
/>
</ToolbarItem>
</>
}
actions={[
@@ -177,6 +265,10 @@ export const Members = () => {
{
name: "lastName",
},
{
name: "membershipType",
cellFormatters: [translationFormatter(t)],
},
]}
emptyState={
<ListEmptyState
@@ -194,7 +286,8 @@ export const Members = () => {
]}
/>
}
isSearching={filteredMembershipTypes.length > 0}
/>
</PageSection>
</>
);
};

View File

@@ -398,7 +398,7 @@ export default function EditUser() {
title={<TabTitleText>{t("organizations")}</TabTitleText>}
{...organizationsTab}
>
<Organizations />
<Organizations user={user} />
</Tab>
)}
<Tab

View File

@@ -1,4 +1,5 @@
import OrganizationRepresentation from "@keycloak/keycloak-admin-client/lib/defs/organizationRepresentation";
import UserRepresentation from "@keycloak/keycloak-admin-client/lib/defs/userRepresentation";
import {
ListEmptyState,
OrganizationTable,
@@ -16,7 +17,7 @@ import {
} from "@patternfly/react-core";
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { Link, useParams } from "react-router-dom";
import { Link, useNavigate, useParams } from "react-router-dom";
import { useAdminClient } from "../admin-client";
import { useConfirmDialog } from "../components/confirm-dialog/ConfirmDialog";
import { useRealm } from "../context/realm-context/RealmContext";
@@ -24,17 +25,29 @@ import { OrganizationModal } from "../organizations/OrganizationModal";
import { toEditOrganization } from "../organizations/routes/EditOrganization";
import useToggle from "../utils/useToggle";
import { UserParams } from "./routes/User";
import { toUsers } from "./routes/Users";
import { CheckboxFilterComponent } from "../components/dynamic/CheckboxFilterComponent";
import { capitalizeFirstLetterFormatter } from "../util";
import { SearchInputComponent } from "../components/dynamic/SearchInputComponent";
export const Organizations = () => {
type OrganizationProps = {
user: UserRepresentation;
};
type MembershipTypeRepresentation = OrganizationRepresentation &
UserRepresentation & {
membershipType?: string;
};
export const Organizations = ({ user }: OrganizationProps) => {
const { adminClient } = useAdminClient();
const { t } = useTranslation();
const { id } = useParams<UserParams>();
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<string>("");
const [searchTriggerText, setSearchTriggerText] = useState<string>("");
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 = () => {
</Link>
)}
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={
<>
<ToolbarItem>
<SearchInputComponent
value={searchText}
placeholder={t("searchMembers")}
onChange={handleChange}
onSearch={handleSearch}
onClear={clearInput}
aria-label={t("searchMembers")}
/>
</ToolbarItem>
<ToolbarItem>
<Dropdown
onOpenChange={setJoinToggle}
@@ -187,6 +300,18 @@ export const Organizations = () => {
{t("remove")}
</Button>
</ToolbarItem>
<ToolbarItem>
<CheckboxFilterComponent
filterPlaceholderText={t("filterByMembershipType")}
isOpen={isOpen}
options={membershipOptions}
onOpenChange={(nextOpen) => setIsOpen(nextOpen)}
onToggleClick={onToggleClick}
onSelect={onSelect}
selectedItems={filteredMembershipTypes}
width={"260px"}
/>
</ToolbarItem>
</>
}
>

View File

@@ -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 =

View File

@@ -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 }> {

View File

@@ -53,7 +53,7 @@ const Domains = (org: OrganizationRepresentation) => {
);
};
type OrganizationTableProps = PropsWithChildren & {
export type OrganizationTableProps = PropsWithChildren & {
loader:
| LoaderFunction<OrganizationRepresentation>
| 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 = ({
<KeycloakDataTable
loader={loader}
isPaginated={isPaginated}
isSearching={isSearching}
ariaLabelKey="organizationList"
searchPlaceholderKey="searchOrganization"
toolbarItem={toolbarItem}
onSelect={onSelect}
canSelectAll={onSelect !== undefined}
@@ -115,6 +117,10 @@ export const OrganizationTable = ({
name: "description",
displayKey: "description",
},
{
name: "membershipType",
displayKey: "membershipType",
},
]}
emptyState={children}
/>

View File

@@ -43,6 +43,10 @@
<groupId>org.keycloak</groupId>
<artifactId>keycloak-model-storage</artifactId>
</dependency>
<dependency>
<groupId>org.keycloak</groupId>
<artifactId>keycloak-model-jpa</artifactId>
</dependency>
<dependency>
<groupId>org.keycloak</groupId>
<artifactId>keycloak-model-storage-private</artifactId>

View File

@@ -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.events.UserUpdatedEvent;
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 @@ public class UserCacheSession implements UserCache, OnCreateComponent, OnUpdateC
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 class UserCacheSession implements UserCache, OnCreateComponent, OnUpdateC
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 class UserCacheSession implements UserCache, OnCreateComponent, OnUpdateC
public boolean isInvalid(String key) {
return invalidations.contains(key);
}
}
}

View File

@@ -18,6 +18,7 @@ package org.keycloak.models.cache.infinispan.organization;
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 class InfinispanOrganizationProvider implements OrganizationProvider {
@Override
public Stream<UserModel> getMembersStream(OrganizationModel organization, String search, Boolean exact, Integer first, Integer max) {
return getDelegate().getMembersStream(organization, search, exact, first, max);
Map<String, String> filters = Optional.ofNullable(search)
.map(value -> Map.of(UserModel.SEARCH, value))
.orElse(Map.of());
return getMembersStream(organization, filters, exact, first, max);
}
@Override
public Stream<UserModel> getMembersStream(OrganizationModel organization, Map<String, String> filters, Boolean exact, Integer first, Integer max) {
return getDelegate().getMembersStream(organization, filters, exact, first, max);
}
@Override
@@ -402,4 +411,4 @@ public class InfinispanOrganizationProvider implements OrganizationProvider {
private boolean isUserCacheKeyInvalid(String cacheKey) {
return userCache.isInvalid(cacheKey);
}
}
}

View File

@@ -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.NoResultException;
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.OrganizationEntity;
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 class JpaOrganizationProvider implements OrganizationProvider {
@Override
public Stream<UserModel> getMembersStream(OrganizationModel organization, String search, Boolean exact, Integer first, Integer max) {
throwExceptionIfObjectIsNull(organization, "Organization");
GroupModel group = getOrganizationGroup(organization);
return getMembersStream(organization, Map.of(UserModel.SEARCH, search), exact, first, max);
}
return userProvider.getGroupMembersStream(getRealm(), group, search, exact, first, max);
@Override
public Stream<UserModel> getMembersStream(OrganizationModel organization, Map<String, String> filters, Boolean exact, Integer first, Integer max) {
throwExceptionIfObjectIsNull(organization, "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<UserGroupMembershipEntity, UserEntity> userJoin = groupMembership.join("user");
for (Entry<String, String> 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<?, UserEntity> from) {
value = value.toLowerCase();
List<Predicate> 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 orPredicates.toArray(Predicate[]::new);
}
@Override
@@ -531,4 +602,4 @@ public class JpaOrganizationProvider implements OrganizationProvider {
}
return realm;
}
}
}

View File

@@ -257,7 +257,7 @@ public class ExportUtils {
orgProvider.getAllStream().map(model -> {
OrganizationRepresentation org = ModelToRepresentation.toRepresentation(model);
orgProvider.getMembersStream(model, null, null, null, null)
orgProvider.getMembersStream(model, (Map<String, String>) null, null, null, null)
.forEach(user -> {
MemberRepresentation member = new MemberRepresentation();
member.setUsername(user.getUsername());

View File

@@ -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 @@ public interface OrganizationProvider extends Provider {
*
* @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<UserModel> 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<UserModel> getMembersStream(OrganizationModel organization, Map<String, String> 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 @@ public interface OrganizationProvider extends Provider {
default OrganizationModel getByAlias(String alias) {
return getAllStream(Map.of(OrganizationModel.ALIAS, alias), 0, 1).findAny().orElse(null);
}
}
}

View File

@@ -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 class OrganizationMemberResource {
@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<String, String> 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 @@ public class OrganizationMemberResource {
result.setMembershipType(provider.isManagedMember(organization, member) ? MembershipType.MANAGED : MembershipType.UNMANAGED);
return result;
}
}
}

View File

@@ -241,4 +241,25 @@ public class Organizations {
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)));
}
}

View File

@@ -57,6 +57,7 @@ import org.keycloak.organization.OrganizationProvider;
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 class OrganizationMemberTest extends AbstractOrganizationTest {
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 @@ public class OrganizationMemberTest extends AbstractOrganizationTest {
private UserRepresentation getUserRepFromMemberRep(MemberRepresentation member) {
return new UserRepresentation(member);
}
}
}