Bugfix: "User Profile" attributes not available for Users Attribute search, when admin user does not have view- or manage-realm realm-management role (#31771)

- UIRealmResource: add "info" sub-resource to get realm-related information, which is visible for ALL admins (users having any realm-management role); for now, only provide the information whether any user profile provider is enabled
- UIRealmResourceTest: test the new endpoint, including permissions check
- UserDataTable.tsx: use this resource to get the info whether user profile providers are enabled, instead of using the realm components resource (which requires "view-realm" permissions)
- .../cypress/e2e/users_attribute_search_test.spec.ts: add cypress test to test the attribute search with minimum access rights
- further small changes for reuse of components, test-code etc

Closes #27536

Signed-off-by: Daniel Fesenmeyer <daniel.fesenmeyer@bosch.com>
This commit is contained in:
Daniel Fesenmeyer 2024-09-20 20:06:08 +02:00 committed by GitHub
parent c532751ff4
commit 87da4011f7
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
18 changed files with 538 additions and 87 deletions

View file

@ -86,7 +86,7 @@ describe("Client Scopes test", () => {
it("should filter items by Assigned type All types", () => {
listingPage
.selectFilter(Filter.AssignedType)
.selectClientScopeFilter(Filter.AssignedType)
.selectSecondaryFilterAssignedType(FilterAssignedType.AllTypes)
.itemExist(FilterAssignedType.Default, true)
.itemExist(FilterAssignedType.Optional, true)
@ -95,7 +95,7 @@ describe("Client Scopes test", () => {
it("should filter items by Assigned type Default", () => {
listingPage
.selectFilter(Filter.AssignedType)
.selectClientScopeFilter(Filter.AssignedType)
.selectSecondaryFilterAssignedType(FilterAssignedType.Default)
.itemExist(FilterAssignedType.Default, true)
.itemExist(FilterAssignedType.Optional, false)
@ -104,7 +104,7 @@ describe("Client Scopes test", () => {
it("should filter items by Assigned type Optional", () => {
listingPage
.selectFilter(Filter.AssignedType)
.selectClientScopeFilter(Filter.AssignedType)
.selectSecondaryFilterAssignedType(FilterAssignedType.Optional)
.itemExist(FilterAssignedType.Default, false)
.itemExist(FilterAssignedType.Optional, true)
@ -113,7 +113,7 @@ describe("Client Scopes test", () => {
it("should filter items by Protocol All", () => {
listingPage
.selectFilter(Filter.Protocol)
.selectClientScopeFilter(Filter.Protocol)
.selectSecondaryFilterProtocol(FilterProtocol.All);
sidebarPage.waitForPageLoad();
listingPage
@ -124,7 +124,7 @@ describe("Client Scopes test", () => {
it("should filter items by Protocol SAML", () => {
listingPage
.selectFilter(Filter.Protocol)
.selectClientScopeFilter(Filter.Protocol)
.selectSecondaryFilterProtocol(FilterProtocol.SAML)
.itemExist(FilterProtocol.SAML, true)
.itemExist(openIDConnectItemText, false); //using FilterProtocol.OpenID will fail, text does not match.
@ -132,7 +132,7 @@ describe("Client Scopes test", () => {
it("should filter items by Protocol OpenID", () => {
listingPage
.selectFilter(Filter.Protocol)
.selectClientScopeFilter(Filter.Protocol)
.selectSecondaryFilterProtocol(FilterProtocol.OpenID)
.itemExist(FilterProtocol.SAML, false)
.itemExist(openIDConnectItemText, true); //using FilterProtocol.OpenID will fail, text does not match.

View file

@ -0,0 +1,100 @@
import SidebarPage from "../support/pages/admin-ui/SidebarPage";
import LoginPage from "../support/pages/LoginPage";
import { keycloakBefore } from "../support/util/keycloak_hooks";
import adminClient from "../support/util/AdminClient";
import {
DefaultUserAttribute,
UserFilterType,
} from "../support/pages/admin-ui/manage/users/UsersListingPage";
import UsersPage from "../support/pages/admin-ui/manage/users/UsersPage";
describe("Query by user attributes", () => {
const loginPage = new LoginPage();
const sidebarPage = new SidebarPage();
const usersPage = new UsersPage();
const listingPage = usersPage.listing();
const emailSuffix = "@example.org";
const user1Username = "user-attrs-1";
const user1FirstName = "John";
const user1LastName = "Doe";
const user1Pwd = "pwd";
const user2Username = "user-attrs-2";
const user2FirstName = "Jane";
const user2LastName = user1LastName;
before(async () => {
await cleanupTestData();
const user1 = await adminClient.createUser({
username: user1Username,
credentials: [
{
type: "password",
value: user1Pwd,
},
],
email: user1Username + emailSuffix,
firstName: user1FirstName,
lastName: user1LastName,
enabled: true,
});
const user1Id = user1.id!;
await adminClient.addClientRoleToUser(user1Id, "master-realm", [
"view-users",
]);
await adminClient.createUser({
username: user2Username,
email: user2Username + emailSuffix,
firstName: user2FirstName,
lastName: user2LastName,
enabled: true,
});
});
beforeEach(() => {
loginPage.logIn(user1Username, user1Pwd);
keycloakBefore();
sidebarPage.goToUsers();
});
after(async () => {
await cleanupTestData();
});
async function cleanupTestData() {
await adminClient.deleteUser(user1Username, true);
await adminClient.deleteUser(user2Username, true);
}
it("Query with one attribute condition", () => {
listingPage
.selectUserSearchFilter(UserFilterType.AttributeSearch)
.openUserAttributesSearchForm()
.addUserAttributeSearchCriteria(
DefaultUserAttribute.lastName,
user1LastName,
)
.triggerAttributesSearch()
.itemExist(user1Username, true)
.itemExist(user2Username, true);
});
it("Query with two attribute conditions", () => {
listingPage
.selectUserSearchFilter(UserFilterType.AttributeSearch)
.openUserAttributesSearchForm()
.addUserAttributeSearchCriteria(
DefaultUserAttribute.lastName,
user1LastName,
)
.addUserAttributeSearchCriteria(
DefaultUserAttribute.firstName,
user1FirstName,
)
.triggerAttributesSearch()
.itemExist(user1Username, true)
.itemExist(user2Username, false);
});
});

View file

@ -52,7 +52,6 @@ export default class ListingPage extends CommonElements {
public tableRowItem = "tbody tr[data-ouia-component-type]:visible";
#table = "table[aria-label]";
#filterSessionDropdownButton = ".pf-v5-c-select button:nth-child(1)";
#searchTypeButton = "[data-testid='clientScopeSearch']";
#filterDropdownButton = "[data-testid='clientScopeSearchType']";
#protocolFilterDropdownButton = "[data-testid='clientScopeSearchProtocol']";
#kebabMenu = "[data-testid='kebab']";
@ -320,9 +319,13 @@ export default class ListingPage extends CommonElements {
return this;
}
selectFilter(filter: Filter) {
cy.get(this.#searchTypeButton).click();
cy.get(this.#dropdownItem).contains(filter).click();
selectClientScopeFilter(filter: Filter) {
return this.selectFilter("clientScopeSearch", filter);
}
protected selectFilter(searchTypeButtonTestId: string, filterStr: string) {
cy.get(`[data-testid='${searchTypeButtonTestId}']`).click();
cy.get(this.#dropdownItem).contains(filterStr).click();
return this;
}

View file

@ -8,13 +8,14 @@ export default class PageObject {
#selectMenuToggleBtn = ".pf-v5-c-menu-toggle";
#switchInput = ".pf-v5-c-switch__input";
#formLabel = ".pf-v5-c-form__label";
#chipGroup = ".pf-v5-c-chip-group";
#chipGroupCloseBtn = ".pf-v5-c-chip-group__close";
#chipItem = ".pf-v5-c-chip-group__list-item";
#emptyStateDiv = ".pf-v5-c-empty-state:visible";
#toolbarActionsButton = ".pf-v5-c-toolbar button[aria-label='Actions']";
#breadcrumbItem = ".pf-v5-c-breadcrumb .pf-v5-c-breadcrumb__item";
genericChipGroupSelector = ".pf-v5-c-chip-group";
protected assertExist(element: Cypress.Chainable<JQuery>, exist: boolean) {
element.should((!exist ? "not." : "") + "exist");
return this;
@ -281,41 +282,55 @@ export default class PageObject {
return this;
}
#getChipGroup(groupName: string) {
return cy.get(this.#chipGroup).contains(groupName).parent().parent();
#getChipGroup(groupSelector: string, groupName: string) {
return cy.get(groupSelector).contains(groupName).parent().parent();
}
#getChipItem(itemName: string) {
return cy.get(this.#chipItem).contains(itemName).parent();
#getChipGroupWithLabel(groupSelector: string, label: string) {
cy.get(groupSelector)
.parent()
.find(".pf-v5-c-chip-group__label")
.contains(label);
return cy.get(groupSelector);
}
#getChipGroupItem(groupName: string, itemName: string) {
return this.#getChipGroup(groupName)
#getChipGroupItem(
groupSelector: string,
groupName: string,
itemName: string,
) {
return this.#getChipGroup(groupSelector, groupName)
.find(this.#chipItem)
.contains(itemName)
.parent();
}
protected removeChipGroup(groupName: string) {
this.#getChipGroup(groupName)
protected removeChipGroup(groupSelector: string, groupName: string) {
this.#getChipGroup(groupSelector, groupName)
.find(this.#chipGroupCloseBtn)
.find("button")
.click();
return this;
}
protected removeChipItem(itemName: string) {
this.#getChipItem(itemName).find("button").click();
protected removeChipGroupItem(
groupSelector: string,
groupName: string,
itemName: string,
) {
this.#getChipGroupItem(groupSelector, groupName, itemName)
.find("button")
.click();
return this;
}
protected removeChipGroupItem(groupName: string, itemName: string) {
this.#getChipGroupItem(groupName, itemName).find("button").click();
return this;
}
protected assertChipGroupExist(groupName: string, exist: boolean) {
this.assertExist(cy.contains(this.#chipGroup, groupName), exist);
protected assertChipGroupExist(
groupSelector: string,
groupName: string,
exist: boolean,
) {
this.assertExist(cy.contains(groupSelector, groupName), exist);
return this;
}
@ -325,20 +340,33 @@ export default class PageObject {
return this;
}
protected assertChipItemExist(itemName: string, exist: boolean) {
cy.get(this.#chipItem).within(() => {
cy.contains(itemName).should((exist ? "" : "not.") + "exist");
});
return this;
}
protected assertChipGroupItemExist(
groupSelector: string,
groupName: string,
itemName: string,
exist: boolean,
) {
this.assertExist(
this.#getChipGroup(groupName).contains(this.#chipItem, itemName),
this.#getChipGroup(groupSelector, groupName).contains(
this.#chipItem,
itemName,
),
exist,
);
return this;
}
protected assertLabeledChipGroupItemExist(
groupSelector: string,
labelName: string,
itemName: string,
exist: boolean,
) {
this.assertExist(
this.#getChipGroupWithLabel(groupSelector, labelName).contains(
this.#chipItem,
itemName,
),
exist,
);
return this;

View file

@ -200,12 +200,16 @@ export default class AdminEventsTab extends PageObject {
}
public removeResourcePathChipGroup() {
super.removeChipGroup(AdminEventsTabSearchFormFieldsLabel.ResourcePath);
super.removeChipGroup(
this.genericChipGroupSelector,
AdminEventsTabSearchFormFieldsLabel.ResourcePath,
);
return this;
}
public assertResourceTypesChipGroupExist(exist: boolean) {
super.assertChipGroupExist(
this.genericChipGroupSelector,
AdminEventsTabSearchFormFieldsLabel.ResourceTypes,
exist,
);
@ -214,6 +218,7 @@ export default class AdminEventsTab extends PageObject {
public assertOperationTypesChipGroupExist(exist: boolean) {
super.assertChipGroupExist(
this.genericChipGroupSelector,
AdminEventsTabSearchFormFieldsLabel.OperationTypes,
exist,
);
@ -222,6 +227,7 @@ export default class AdminEventsTab extends PageObject {
public assertResourcePathChipGroupExist(exist: boolean) {
super.assertChipGroupExist(
this.genericChipGroupSelector,
AdminEventsTabSearchFormFieldsLabel.ResourcePath,
exist,
);
@ -233,6 +239,7 @@ export default class AdminEventsTab extends PageObject {
exist: boolean,
) {
super.assertChipGroupItemExist(
this.genericChipGroupSelector,
AdminEventsTabSearchFormFieldsLabel.ResourcePath,
itemName,
exist,
@ -242,6 +249,7 @@ export default class AdminEventsTab extends PageObject {
public assertRealmChipGroupExist(exist: boolean) {
super.assertChipGroupExist(
this.genericChipGroupSelector,
AdminEventsTabSearchFormFieldsLabel.Realm,
exist,
);
@ -250,6 +258,7 @@ export default class AdminEventsTab extends PageObject {
public assertClientChipGroupExist(exist: boolean) {
super.assertChipGroupExist(
this.genericChipGroupSelector,
AdminEventsTabSearchFormFieldsLabel.Client,
exist,
);
@ -257,12 +266,17 @@ export default class AdminEventsTab extends PageObject {
}
public assertUserChipGroupExist(exist: boolean) {
super.assertChipGroupExist(AdminEventsTabSearchFormFieldsLabel.User, exist);
super.assertChipGroupExist(
this.genericChipGroupSelector,
AdminEventsTabSearchFormFieldsLabel.User,
exist,
);
return this;
}
public assertIpAddressChipGroupExist(exist: boolean) {
super.assertChipGroupExist(
this.genericChipGroupSelector,
AdminEventsTabSearchFormFieldsLabel.IpAddress,
exist,
);
@ -271,6 +285,7 @@ export default class AdminEventsTab extends PageObject {
public assertDateFromChipGroupExist(exist: boolean) {
super.assertChipGroupExist(
this.genericChipGroupSelector,
AdminEventsTabSearchFormFieldsLabel.DateFrom,
exist,
);
@ -279,6 +294,7 @@ export default class AdminEventsTab extends PageObject {
public assertDateToChipGroupExist(exist: boolean) {
super.assertChipGroupExist(
this.genericChipGroupSelector,
AdminEventsTabSearchFormFieldsLabel.DateTo,
exist,
);

View file

@ -163,6 +163,7 @@ export default class UserEventsTab extends PageObject {
public removeEventTypeChipGroupItem(itemName: string) {
super.removeChipGroupItem(
this.genericChipGroupSelector,
UserEventsTabSearchFormFieldsLabel.EventType,
itemName,
);
@ -171,6 +172,7 @@ export default class UserEventsTab extends PageObject {
public assertEventTypeChipGroupItemExist(itemName: string, exist: boolean) {
super.assertChipGroupItemExist(
this.genericChipGroupSelector,
UserEventsTabSearchFormFieldsLabel.EventType,
itemName,
exist,
@ -180,6 +182,7 @@ export default class UserEventsTab extends PageObject {
public assertUserIdChipGroupExist(exist: boolean) {
super.assertChipGroupExist(
this.genericChipGroupSelector,
UserEventsTabSearchFormFieldsLabel.UserId,
exist,
);
@ -188,6 +191,7 @@ export default class UserEventsTab extends PageObject {
public assertEventTypeChipGroupExist(exist: boolean) {
super.assertChipGroupExist(
this.genericChipGroupSelector,
UserEventsTabSearchFormFieldsLabel.EventType,
exist,
);
@ -196,6 +200,7 @@ export default class UserEventsTab extends PageObject {
public assertClientChipGroupExist(exist: boolean) {
super.assertChipGroupExist(
this.genericChipGroupSelector,
UserEventsTabSearchFormFieldsLabel.Client,
exist,
);
@ -204,6 +209,7 @@ export default class UserEventsTab extends PageObject {
public assertDateFromChipGroupExist(exist: boolean) {
super.assertChipGroupExist(
this.genericChipGroupSelector,
UserEventsTabSearchFormFieldsLabel.DateFrom,
exist,
);
@ -212,6 +218,7 @@ export default class UserEventsTab extends PageObject {
public assertDateToChipGroupExist(exist: boolean) {
super.assertChipGroupExist(
this.genericChipGroupSelector,
UserEventsTabSearchFormFieldsLabel.DateTo,
exist,
);
@ -220,6 +227,7 @@ export default class UserEventsTab extends PageObject {
public assertIpAddressChipGroupExist(exist: boolean) {
super.assertChipGroupExist(
this.genericChipGroupSelector,
UserEventsTabSearchFormFieldsLabel.IpAddress,
exist,
);

View file

@ -0,0 +1,88 @@
import ListingPage from "../../ListingPage";
import UsersPage from "./UsersPage";
export enum DefaultUserAttribute {
username = "Username",
email = "Email",
firstName = "First name",
lastName = "Last name",
}
export enum UserFilterType {
DefaultSearch = "Default search",
AttributeSearch = "Attribute search",
}
export default class UsersListingPage extends ListingPage {
#dropdownPanelBtn = "[data-testid='dropdown-panel-btn']";
#userAttributeSearchForm = "[data-testid='user-attribute-search-form']";
#userAttributeSearchAddFilterBtn =
"[data-testid='user-attribute-search-add-filter-button']";
#userAttributeSearchBtn = "[data-testid='search-user-attribute-btn']";
#usersPage: UsersPage;
constructor(usersPage: UsersPage) {
super();
this.#usersPage = usersPage;
console.log("this.u1", usersPage);
console.log("this.u2", this.#usersPage);
}
selectUserSearchFilter(filter: UserFilterType) {
super.selectFilter("user-search-toggle", filter);
return this;
}
openUserAttributesSearchForm() {
cy.get(this.#dropdownPanelBtn).click();
cy.get(this.#userAttributeSearchForm).should("be.visible");
return this;
}
addUserAttributeSearchCriteria(
defaultUserAttribute: DefaultUserAttribute,
attributeValue: string,
) {
return this.addUserAttributeSearchCriteriaCustom(
defaultUserAttribute,
attributeValue,
);
}
addUserAttributeSearchCriteriaCustom(
attributeLabel: string,
attributeValue: string,
) {
cy.get(this.#userAttributeSearchForm)
.find(".pf-m-typeahead")
.click()
.get(".pf-v5-c-menu__list-item")
.contains(attributeLabel)
.click({ force: true });
cy.get(this.#userAttributeSearchForm)
.find("#value")
.clear()
.type(attributeValue);
cy.get(this.#userAttributeSearchForm)
.find(this.#userAttributeSearchAddFilterBtn)
.click();
this.#usersPage.assertAttributeSearchChipExists(
attributeLabel,
attributeValue,
true,
);
return this;
}
triggerAttributesSearch() {
cy.get(this.#userAttributeSearchBtn).click();
return this;
}
}

View file

@ -1,11 +1,12 @@
import PageObject from "../../components/PageObject";
import ListingPage from "../../ListingPage";
const listingPage = new ListingPage();
import UsersListingPage from "./UsersListingPage";
export default class UsersPage extends PageObject {
#userListTabLink = "listTab";
#permissionsTabLink = "permissionsTab";
#userAttributeSearchChipsGroup =
"[data-testid='user-attribute-search-chips-group']";
#usersListingPage = new UsersListingPage(this);
public goToUserListTab() {
cy.findByTestId(this.#userListTabLink).click();
@ -13,6 +14,10 @@ export default class UsersPage extends PageObject {
return this;
}
public listing() {
return this.#usersListingPage;
}
public goToPermissionsTab() {
cy.findByTestId(this.#permissionsTabLink).click();
@ -20,8 +25,23 @@ export default class UsersPage extends PageObject {
}
public goToUserDetailsPage(username: string) {
listingPage.searchItem(username);
listingPage.goToItemDetails(username);
this.#usersListingPage.searchItem(username);
this.#usersListingPage.goToItemDetails(username);
return this;
}
public assertAttributeSearchChipExists(
attributeLabel: string,
attributeValue: string,
exists: boolean,
) {
super.assertLabeledChipGroupItemExist(
this.#userAttributeSearchChipsGroup,
attributeLabel,
attributeValue,
exists,
);
return this;
}

View file

@ -191,10 +191,18 @@ class AdminClient {
});
}
async deleteUser(username: string) {
async deleteUser(username: string, ignoreNonExisting: boolean = false) {
await this.#login();
const user = await this.#client.users.find({ username });
await this.#client.users.del({ id: user[0].id! });
const foundUsers = await this.#client.users.find({ username });
if (foundUsers.length == 0) {
if (ignoreNonExisting) {
return;
} else {
throw new Error(`User not found: ${username}`);
}
}
await this.#client.users.del({ id: foundUsers[0].id! });
}
async createClientScope(scope: ClientScopeRepresentation) {

View file

@ -1,8 +1,8 @@
import type ComponentRepresentation from "@keycloak/keycloak-admin-client/lib/defs/componentRepresentation";
import type { UserProfileConfig } from "@keycloak/keycloak-admin-client/lib/defs/userProfileMetadata";
import type UserRepresentation from "@keycloak/keycloak-admin-client/lib/defs/userRepresentation";
import {
KeycloakDataTable,
KeycloakSpinner,
ListEmptyState,
useAlerts,
useFetch,
@ -39,9 +39,10 @@ import { toAddUser } from "../../user/routes/AddUser";
import { toUser } from "../../user/routes/User";
import { emptyFormatter } from "../../util";
import { useConfirmDialog } from "../confirm-dialog/ConfirmDialog";
import { KeycloakSpinner } from "@keycloak/keycloak-ui-shared";
import { BruteUser, findUsers } from "../role-mapping/resource";
import { UserDataTableToolbarItems } from "./UserDataTableToolbarItems";
import { UiRealmInfo } from "../../context/auth/uiRealmInfo";
import { fetchRealmInfo } from "../../context/auth/admin-ui-endpoint";
export type UserAttribute = {
name: string;
@ -113,7 +114,7 @@ export function UserDataTable() {
const { addAlert, addError } = useAlerts();
const { realm: realmName, realmRepresentation: realm } = useRealm();
const navigate = useNavigate();
const [userStorage, setUserStorage] = useState<ComponentRepresentation[]>();
const [uiRealmInfo, setUiRealmInfo] = useState<UiRealmInfo>({});
const [searchUser, setSearchUser] = useState("");
const [selectedRows, setSelectedRows] = useState<UserRepresentation[]>([]);
const [searchType, setSearchType] = useState<SearchType>("default");
@ -127,23 +128,17 @@ export function UserDataTable() {
useFetch(
async () => {
const testParams = {
type: "org.keycloak.storage.UserStorageProvider",
};
try {
return await Promise.all([
adminClient.components.find(testParams),
fetchRealmInfo(adminClient),
adminClient.users.getProfile(),
]);
} catch {
return [[], {}] as [ComponentRepresentation[], UserProfileConfig];
return [{}, {}] as [UiRealmInfo, UserProfileConfig];
}
},
([storageProviders, profile]) => {
setUserStorage(
storageProviders.filter((p) => p.config?.enabled?.[0] === "true"),
);
([uiRealmInfo, profile]) => {
setUiRealmInfo(uiRealmInfo);
setProfile(profile);
},
[],
@ -171,7 +166,7 @@ export function UserDataTable() {
...params,
});
} catch (error) {
if (userStorage?.length) {
if (uiRealmInfo.userProfileProvidersEnabled) {
addError("noUsersFoundErrorStorage", error);
} else {
addError("noUsersFoundError", error);
@ -216,12 +211,12 @@ export function UserDataTable() {
const goToCreate = () => navigate(toAddUser({ realm: realmName }));
if (!userStorage || !realm) {
if (!uiRealmInfo || !realm) {
return <KeycloakSpinner />;
}
//should *only* list users when no user federation is configured
const listUsers = !(userStorage.length > 0);
const listUsers = !uiRealmInfo.userProfileProvidersEnabled;
const clearAllFilters = () => {
const filtered = [...activeFilters].filter(
@ -252,6 +247,7 @@ export function UserDataTable() {
return (
<ChipGroup
className="pf-v5-u-mt-md pf-v5-u-mr-md"
data-testid="user-attribute-search-chips-group"
key={entry.name}
categoryName={
entry.displayName.length ? entry.displayName : entry.name

View file

@ -135,7 +135,7 @@ export function UserDataTableAttributeSearchForm({
if (profile) {
return (
<KeycloakSelect
data-testid="search-attribute-name"
data-testid="search-attribute-name-select"
variant={SelectVariant.typeahead}
onToggle={(isOpen) => setSelectAttributeKeyOpen(isOpen)}
selections={getValues().displayName}
@ -188,7 +188,10 @@ export function UserDataTableAttributeSearchForm({
};
return (
<Form className="user-attribute-search-form">
<Form
className="user-attribute-search-form"
data-testid="user-attribute-search-form"
>
<TextContent className="user-attribute-search-form-headline">
<Text component={TextVariants.h2}>{t("selectAttributes")}</Text>
</TextContent>
@ -231,6 +234,7 @@ export function UserDataTableAttributeSearchForm({
</InputGroupItem>
<InputGroupItem>
<Button
data-testid="user-attribute-search-add-filter-button"
variant="control"
icon={<CheckIcon />}
onClick={addToFilter}

View file

@ -126,6 +126,7 @@ export function UserDataTableToolbarItems({
return (
<>
<DropdownPanel
data-testid="select-attributes-dropdown"
buttonText={t("selectAttributes")}
setSearchDropdownOpen={setSearchDropdownOpen}
searchDropdownOpen={searchDropdownOpen}

View file

@ -3,6 +3,7 @@ import KeycloakAdminClient, {
} from "@keycloak/keycloak-admin-client";
import { getAuthorizationHeaders } from "../../utils/getAuthorizationHeaders";
import { joinPath } from "../../utils/joinPath";
import { UiRealmInfo } from "./uiRealmInfo";
export async function fetchAdminUI<T>(
adminClient: KeycloakAdminClient,
@ -27,3 +28,9 @@ export async function fetchAdminUI<T>(
return await response.json();
}
export async function fetchRealmInfo(
adminClient: KeycloakAdminClient,
): Promise<UiRealmInfo> {
return fetchAdminUI(adminClient, `ui-ext/info`);
}

View file

@ -0,0 +1,5 @@
/** Information about a realm, which is available for all admins of the realm */
export interface UiRealmInfo {
/** Whether at least one user storage provider is enabled */
userProfileProvidersEnabled?: boolean;
}

View file

@ -46,6 +46,7 @@ export const SearchDropdown = ({
onOpenChange={(isOpen) => setSearchToggle(isOpen)}
toggle={(ref) => (
<MenuToggle
data-testid="user-search-toggle"
ref={ref}
id="toggle-id"
onClick={() => setSearchToggle(!searchToggle)}

View file

@ -19,20 +19,30 @@
package org.keycloak.admin.ui.rest;
import jakarta.ws.rs.Consumes;
import jakarta.ws.rs.InternalServerErrorException;
import jakarta.ws.rs.PUT;
import jakarta.ws.rs.core.MediaType;
import jakarta.ws.rs.core.Response;
import jakarta.ws.rs.core.Response.Status.Family;
import org.eclipse.microprofile.openapi.annotations.Operation;
import org.eclipse.microprofile.openapi.annotations.enums.SchemaType;
import org.eclipse.microprofile.openapi.annotations.media.Content;
import org.eclipse.microprofile.openapi.annotations.media.Schema;
import org.eclipse.microprofile.openapi.annotations.responses.APIResponse;
import org.keycloak.admin.ui.rest.model.UIRealmRepresentation;
import org.keycloak.admin.ui.rest.model.UIRealmInfo;
import org.keycloak.models.KeycloakSession;
import org.keycloak.models.StorageProviderRealmModel;
import org.keycloak.representations.userprofile.config.UPConfig;
import org.keycloak.services.resources.admin.AdminEventBuilder;
import org.keycloak.services.resources.admin.RealmAdminResource;
import org.keycloak.services.resources.admin.UserProfileResource;
import org.keycloak.services.resources.admin.permissions.AdminPermissionEvaluator;
import org.keycloak.storage.UserStorageProviderModel;
import jakarta.ws.rs.Consumes;
import jakarta.ws.rs.GET;
import jakarta.ws.rs.InternalServerErrorException;
import jakarta.ws.rs.PUT;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.core.MediaType;
import jakarta.ws.rs.core.Response;
import jakarta.ws.rs.core.Response.Status.Family;
/**
* This JAX-RS resource is decorating the Admin Realm API in order to support specific behaviors from the
@ -67,6 +77,24 @@ public class UIRealmResource {
return response;
}
@GET
@Path("info")
@Operation(summary = "Gets information about the realm, viewable by all realm admins")
@APIResponse(responseCode = "200", description = "", content = {
@Content(schema = @Schema(implementation = UIRealmInfo.class, type = SchemaType.OBJECT))})
public UIRealmInfo getInfo() {
auth.requireAnyAdminRole();
final var info = new UIRealmInfo();
info.setUserProfileProvidersEnabled(isAtLeastOneUserStorageProviderEnabled());
return info;
}
private boolean isAtLeastOneUserStorageProviderEnabled() {
return ((StorageProviderRealmModel) session.getContext().getRealm()).getUserStorageProvidersStream()
.anyMatch(UserStorageProviderModel::isEnabled);
}
private void updateUserProfileConfiguration(UIRealmRepresentation rep) {
UPConfig upConfig = rep.getUpConfig();

View file

@ -0,0 +1,16 @@
package org.keycloak.admin.ui.rest.model;
/**
* Information about a realm, which is available for each admin of a realm, not only for admins allowed to view the realm.
*/
public class UIRealmInfo {
private boolean userProfileProvidersEnabled;
public boolean isUserProfileProvidersEnabled() {
return userProfileProvidersEnabled;
}
public void setUserProfileProvidersEnabled(final boolean userProfileProvidersEnabled) {
this.userProfileProvidersEnabled = userProfileProvidersEnabled;
}
}

View file

@ -20,6 +20,7 @@ package org.keycloak.testsuite.user.profile;
import jakarta.ws.rs.client.Client;
import jakarta.ws.rs.client.Entity;
import jakarta.ws.rs.client.WebTarget;
import jakarta.ws.rs.core.MediaType;
import jakarta.ws.rs.core.Response;
import jakarta.ws.rs.core.Response.Status;
@ -27,36 +28,100 @@ import java.io.IOException;
import java.util.Map;
import java.util.Set;
import org.hamcrest.Matchers;
import org.junit.AfterClass;
import org.junit.Assert;
import org.junit.BeforeClass;
import org.junit.Rule;
import org.junit.Test;
import org.keycloak.admin.client.Keycloak;
import org.keycloak.admin.client.KeycloakBuilder;
import org.keycloak.admin.client.resource.BearerAuthFilter;
import org.keycloak.admin.client.token.TokenManager;
import org.keycloak.admin.ui.rest.model.UIRealmRepresentation;
import org.keycloak.admin.ui.rest.model.UIRealmInfo;
import org.keycloak.events.admin.OperationType;
import org.keycloak.events.admin.ResourceType;
import org.keycloak.models.AccountRoles;
import org.keycloak.models.AdminRoles;
import org.keycloak.models.Constants;
import org.keycloak.representations.idm.AdminEventRepresentation;
import org.keycloak.representations.idm.RealmRepresentation;
import org.keycloak.representations.idm.UserRepresentation;
import org.keycloak.representations.userprofile.config.UPAttribute;
import org.keycloak.representations.userprofile.config.UPAttributePermissions;
import org.keycloak.representations.userprofile.config.UPAttributeRequired;
import org.keycloak.representations.userprofile.config.UPConfig;
import org.keycloak.testsuite.AbstractTestRealmKeycloakTest;
import org.keycloak.testsuite.util.AssertAdminEvents;
import org.keycloak.testsuite.util.UserBuilder;
import org.keycloak.userprofile.config.UPConfigUtils;
import org.keycloak.util.JsonSerialization;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertNotNull;
import static org.keycloak.testsuite.util.ServerURLs.getAuthServerContextRoot;
/**
*
* @author rmartinc
*/
public class UIRealmResourceTest extends AbstractTestRealmKeycloakTest {
private static final String TEST_PWD = "password";
private static final String USER_WITH_VIEW_USERS_ROLE = "user-with-view-users-role";
private static final String USER_WITHOUT_ADMIN_ROLE = "user-without-admin-role";
private static Client httpClient;
private static Keycloak keycloakAdminClientViewUsers;
private static Keycloak keycloakAdminClientWithoutAdminRoles;
@Rule
public AssertAdminEvents assertAdminEvents = new AssertAdminEvents(this);
@BeforeClass
public static void initHttpClients() {
httpClient = Keycloak.getClientProvider().newRestEasyClient(null, null, true);
keycloakAdminClientViewUsers = KeycloakBuilder.builder().serverUrl(getKeycloakServerUrl())
.realm(TEST_REALM_NAME)
.username(USER_WITH_VIEW_USERS_ROLE)
.password(TEST_PWD)
.clientId(Constants.ADMIN_CLI_CLIENT_ID)
.resteasyClient(httpClient)
.build();
keycloakAdminClientWithoutAdminRoles = KeycloakBuilder.builder().serverUrl(getKeycloakServerUrl())
.realm(TEST_REALM_NAME)
.username(USER_WITHOUT_ADMIN_ROLE)
.password(TEST_PWD)
.clientId(Constants.ADMIN_CLI_CLIENT_ID)
.resteasyClient(httpClient)
.build();
}
@AfterClass
public static void closeHttpClients() {
if (keycloakAdminClientViewUsers != null) {
keycloakAdminClientViewUsers.close();
}
if (keycloakAdminClientWithoutAdminRoles != null) {
keycloakAdminClientWithoutAdminRoles.close();
}
if (httpClient != null) {
httpClient.close();
}
}
@Override
public void configureTestRealm(RealmRepresentation testRealm) {
public void configureTestRealm(final RealmRepresentation testRealm) {
final var userWithViewUsersRole = createTestUserRep(USER_WITH_VIEW_USERS_ROLE,
Constants.REALM_MANAGEMENT_CLIENT_ID, AdminRoles.VIEW_USERS);
testRealm.getUsers().add(userWithViewUsersRole);
final var userWithoutAdminRole = createTestUserRep(USER_WITHOUT_ADMIN_ROLE,
Constants.ACCOUNT_MANAGEMENT_CLIENT_ID, AccountRoles.VIEW_GROUPS);
testRealm.getUsers().add(userWithoutAdminRole);
}
@Test
@ -89,55 +154,112 @@ public class UIRealmResourceTest extends AbstractTestRealmKeycloakTest {
AdminEventRepresentation adminEvent = assertAdminEvents.assertEvent(TEST_REALM_NAME, OperationType.UPDATE, Matchers.nullValue(String.class), ResourceType.REALM);
Assert.assertNotNull(adminEvent.getRepresentation());
adminEvent = assertAdminEvents.assertEvent(TEST_REALM_NAME, OperationType.UPDATE, "ui-ext", ResourceType.USER_PROFILE);
Assert.assertEquals(upConfig, toUpConfig(adminEvent.getRepresentation()));
assertEquals(upConfig, toUpConfig(adminEvent.getRepresentation()));
upConfig.getAttribute("foo").setDisplayName("Foo");
updateRealmExt(toUIRealmRepresentation(rep, upConfig));
assertAdminEvents.assertEvent(TEST_REALM_NAME, OperationType.UPDATE, Matchers.nullValue(String.class), ResourceType.REALM);
adminEvent = assertAdminEvents.assertEvent(TEST_REALM_NAME, OperationType.UPDATE, "ui-ext", ResourceType.USER_PROFILE);
Assert.assertEquals(upConfig, toUpConfig(adminEvent.getRepresentation()));
assertEquals(upConfig, toUpConfig(adminEvent.getRepresentation()));
upConfig.getAttribute("foo").setPermissions(new UPAttributePermissions(Set.of(), Set.of(UPConfigUtils.ROLE_USER)));
updateRealmExt(toUIRealmRepresentation(rep, upConfig));
assertAdminEvents.assertEvent(TEST_REALM_NAME, OperationType.UPDATE, Matchers.nullValue(String.class), ResourceType.REALM);
adminEvent = assertAdminEvents.assertEvent(TEST_REALM_NAME, OperationType.UPDATE, "ui-ext", ResourceType.USER_PROFILE);
Assert.assertEquals(upConfig, toUpConfig(adminEvent.getRepresentation()));
assertEquals(upConfig, toUpConfig(adminEvent.getRepresentation()));
upConfig.getAttribute("foo").setRequired(new UPAttributeRequired(Set.of(UPConfigUtils.ROLE_ADMIN, UPConfigUtils.ROLE_USER), Set.of()));
updateRealmExt(toUIRealmRepresentation(rep, upConfig));
assertAdminEvents.assertEvent(TEST_REALM_NAME, OperationType.UPDATE, Matchers.nullValue(String.class), ResourceType.REALM);
adminEvent = assertAdminEvents.assertEvent(TEST_REALM_NAME, OperationType.UPDATE, "ui-ext", ResourceType.USER_PROFILE);
Assert.assertEquals(upConfig, toUpConfig(adminEvent.getRepresentation()));
assertEquals(upConfig, toUpConfig(adminEvent.getRepresentation()));
upConfig.getAttribute("foo").setValidations(Map.of("length", Map.of("min", "3", "max", "128")));
updateRealmExt(toUIRealmRepresentation(rep, upConfig));
assertAdminEvents.assertEvent(TEST_REALM_NAME, OperationType.UPDATE, Matchers.nullValue(String.class), ResourceType.REALM);
adminEvent = assertAdminEvents.assertEvent(TEST_REALM_NAME, OperationType.UPDATE, "ui-ext", ResourceType.USER_PROFILE);
Assert.assertEquals(upConfig, toUpConfig(adminEvent.getRepresentation()));
assertEquals(upConfig, toUpConfig(adminEvent.getRepresentation()));
updateRealmExt(toUIRealmRepresentation(rep, upConfig));
assertAdminEvents.assertEvent(TEST_REALM_NAME, OperationType.UPDATE, Matchers.nullValue(String.class), ResourceType.REALM);
assertAdminEvents.assertEmpty();
}
@Test
public void uiRealmInfoSucceedsWithAnyAdminRole() {
final var response = getUiRealmInfo(keycloakAdminClientViewUsers.tokenManager());
assertEquals(Status.OK.getStatusCode(), response.getStatus());
final var responseStr = response.readEntity(String.class);
final var uiRealmInfo = toUiRealmInfo(responseStr);
assertNotNull(uiRealmInfo);
assertFalse(uiRealmInfo.isUserProfileProvidersEnabled());
}
@Test
public void uiRealmInfoFailsWhenNoAdminRoleIsAssigned() {
final var response = getUiRealmInfo(keycloakAdminClientWithoutAdminRoles.tokenManager());
assertEquals(Status.FORBIDDEN.getStatusCode(), response.getStatus());
}
private static String getKeycloakServerUrl() {
return getAuthServerContextRoot() + "/auth";
}
private static UserRepresentation createTestUserRep(final String username, final String clientId, final String roleName) {
return UserBuilder.create().enabled(true)
.username(username)
.email(username + "@localhost")
.firstName(username + "-first")
.lastName(username + "-last")
.password(TEST_PWD)
.role(clientId, roleName)
.build();
}
private Response getUiRealmInfo(final TokenManager tokenManager) {
return prepareHttpRequest(TEST_REALM_NAME, "ui-ext/info", tokenManager)
.request(MediaType.APPLICATION_JSON)
.get();
}
private void updateRealmExt(UIRealmRepresentation rep) {
try (Client client = Keycloak.getClientProvider().newRestEasyClient(null, null, true)) {
Response response = client.target(suiteContext.getAuthServerInfo().getContextRoot().toString() + "/auth")
.path("/admin/realms/" + rep.getRealm() + "/ui-ext")
.register(new BearerAuthFilter(adminClient.tokenManager()))
.request(MediaType.APPLICATION_JSON)
.put(Entity.entity(rep, MediaType.APPLICATION_JSON));
Assert.assertEquals(Status.NO_CONTENT.getStatusCode(), response.getStatus());
}
final var realmName = rep.getRealm();
final var request = prepareHttpRequest(realmName, "ui-ext", adminClient.tokenManager());
final var response = request
.request(MediaType.APPLICATION_JSON)
.put(Entity.entity(rep, MediaType.APPLICATION_JSON));
assertEquals(Status.NO_CONTENT.getStatusCode(), response.getStatus());
}
private WebTarget prepareHttpRequest(final String realmName, final String subPath, final TokenManager tokenManager) {
final var realmAdminPath = "/admin/realms/" + realmName;
return httpClient.target(getKeycloakServerUrl())
.path(realmAdminPath + "/" + subPath)
.register(new BearerAuthFilter(tokenManager));
}
private UIRealmRepresentation toUIRealmRepresentation(RealmRepresentation realm, UPConfig upConfig) throws IOException {
UIRealmRepresentation uiRealm = JsonSerialization.readValue(JsonSerialization.writeValueAsString(realm), UIRealmRepresentation.class);
UIRealmRepresentation uiRealm = deserialize(JsonSerialization.writeValueAsString(realm), UIRealmRepresentation.class);
uiRealm.setUpConfig(upConfig);
return uiRealm;
}
private UPConfig toUpConfig(String representation) throws IOException {
return JsonSerialization.readValue(representation, UPConfig.class);
private UPConfig toUpConfig(final String representation) {
return deserialize(representation, UPConfig.class);
}
private UIRealmInfo toUiRealmInfo(final String representation) {
return deserialize(representation, UIRealmInfo.class);
}
private static <T> T deserialize(final String representation, final Class<T> type) {
try {
return JsonSerialization.readValue(representation, type);
} catch (final IOException e) {
throw new IllegalStateException(e);
}
}
}