initial version of an endpoint (#3095)

* initial version of an endpoint

* added open-api yaml

* changed to list available added mapping to own domain model

* added search and roles endpoint

* use new endpoints

* removed `Representation` suffix and made id role and client mandatory fields

* fixed test
This commit is contained in:
Erik Jan de Wit 2022-08-20 10:10:36 +02:00 committed by GitHub
parent 7729a54ad6
commit 84da777693
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
11 changed files with 514 additions and 36 deletions

View file

@ -555,7 +555,10 @@ describe("Clients test", () => {
.checkNotificationMessage("Associated roles have been added", true);
// Add associated client role
associatedRolesPage.addAssociatedRoleFromSearchBar("create-client", true);
associatedRolesPage.addAssociatedRoleFromSearchBar(
"manage-account",
true
);
commonPage
.masthead()
.checkNotificationMessage("Associated roles have been added", true);
@ -609,7 +612,7 @@ describe("Clients test", () => {
rolesTab.goToAssociatedRolesTab();
cy.get('td[data-label="Role name"]')
.contains("create-client")
.contains("manage-account")
.parent()
.within(() => {
cy.get("input").click();

View file

@ -122,7 +122,10 @@ describe("Realm roles test", () => {
rolesTab.goToAssociatedRolesTab();
// Add associated client role
associatedRolesPage.addAssociatedRoleFromSearchBar("manage-clients", true);
associatedRolesPage.addAssociatedRoleFromSearchBar(
"manage-account-links",
true
);
masthead.checkNotificationMessage("Associated roles have been added", true);
});

View file

@ -15,7 +15,8 @@
See the License for the specific language governing permissions and
limitations under the License.
-->
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/maven-v4_0_0.xsd">
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/maven-v4_0_0.xsd">
<modelVersion>4.0.0</modelVersion>
<groupId>org.keycloak</groupId>
@ -34,6 +35,10 @@
<nexus.staging.plugin.version>1.6.13</nexus.staging.plugin.version>
<frontend.maven.plugin.version>1.12.1</frontend.maven.plugin.version>
<frontend.maven.plugin.nodeVersion>v18.7.0</frontend.maven.plugin.nodeVersion>
<keycloak.version>19.0.1</keycloak.version>
<kotlin.version>1.7.10</kotlin.version>
<mapstruct.version>1.5.2.Final</mapstruct.version>
</properties>
<licenses>
@ -72,6 +77,51 @@
</snapshotRepository>
</distributionManagement>
<dependencies>
<dependency>
<groupId>org.keycloak</groupId>
<artifactId>keycloak-server-spi</artifactId>
<version>${keycloak.version}</version>
</dependency>
<dependency>
<groupId>org.keycloak</groupId>
<artifactId>keycloak-server-spi-private</artifactId>
<version>${keycloak.version}</version>
</dependency>
<dependency>
<groupId>org.keycloak</groupId>
<artifactId>keycloak-services</artifactId>
<version>${keycloak.version}</version>
</dependency>
<dependency>
<groupId>org.jetbrains.kotlin</groupId>
<artifactId>kotlin-stdlib-jdk8</artifactId>
<version>${kotlin.version}</version>
</dependency>
<dependency>
<groupId>org.mapstruct</groupId>
<artifactId>mapstruct</artifactId>
<version>${mapstruct.version}</version>
</dependency>
<dependency>
<groupId>org.eclipse.microprofile.openapi</groupId>
<artifactId>microprofile-openapi-api</artifactId>
<version>3.0</version>
</dependency>
<dependency>
<groupId>org.jetbrains.kotlin</groupId>
<artifactId>kotlin-test</artifactId>
<version>${kotlin.version}</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.testng</groupId>
<artifactId>testng</artifactId>
<version>6.14.2</version>
<scope>test</scope>
</dependency>
</dependencies>
<profiles>
<profile>
<id>gpg</id>
@ -105,7 +155,7 @@
<id>install-tarball</id>
<activation>
<property>
<name>installTarball</name>
<name>installTarball</name>
</property>
</activation>
<build>
@ -122,7 +172,9 @@
<goal>npm</goal>
</goals>
<configuration>
<arguments>install ${project.basedir}/keycloak-nodejs-admin-client.tgz ${project.basedir}/keycloak-js.tgz</arguments>
<arguments>install ${project.basedir}/keycloak-nodejs-admin-client.tgz
${project.basedir}/keycloak-js.tgz
</arguments>
</configuration>
</execution>
</executions>
@ -139,6 +191,98 @@
<build>
<plugins>
<plugin>
<groupId>org.jetbrains.kotlin</groupId>
<artifactId>kotlin-maven-plugin</artifactId>
<version>${kotlin.version}</version>
<executions>
<execution>
<id>compile</id>
<phase>compile</phase>
<goals>
<goal>compile</goal>
</goals>
</execution>
<execution>
<id>test-compile</id>
<phase>test-compile</phase>
<goals>
<goal>test-compile</goal>
</goals>
</execution>
<execution>
<id>kapt</id>
<goals>
<goal>kapt</goal>
</goals>
<configuration>
<sourceDirs>
<sourceDir>src/main/java</sourceDir>
</sourceDirs>
<annotationProcessorPaths>
<annotationProcessorPath>
<groupId>org.mapstruct</groupId>
<artifactId>mapstruct-processor</artifactId>
<version>${mapstruct.version}</version>
</annotationProcessorPath>
</annotationProcessorPaths>
</configuration>
</execution>
</executions>
<configuration>
<jvmTarget>1.8</jvmTarget>
</configuration>
</plugin>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-compiler-plugin</artifactId>
<version>3.8.1</version>
<executions>
<!-- Replacing default-compile as it is treated specially by maven -->
<execution>
<id>default-compile</id>
<phase>none</phase>
</execution>
<!-- Replacing default-testCompile as it is treated specially by maven -->
<execution>
<id>default-testCompile</id>
<phase>none</phase>
</execution>
<execution>
<id>java-compile</id>
<phase>compile</phase>
<goals>
<goal>compile</goal>
</goals>
</execution>
<execution>
<id>java-test-compile</id>
<phase>test-compile</phase>
<goals>
<goal>testCompile</goal>
</goals>
</execution>
</executions>
<configuration>
<source>8</source>
<target>8</target>
</configuration>
</plugin>
<plugin>
<artifactId>smallrye-open-api-maven-plugin</artifactId>
<groupId>io.smallrye</groupId>
<version>2.1.22</version>
<configuration>
<scanPackages>org.keycloak.admin.ui.rest</scanPackages>
</configuration>
<executions>
<execution>
<goals>
<goal>generate-schema</goal>
</goals>
</execution>
</executions>
</plugin>
<plugin>
<groupId>org.sonatype.plugins</groupId>
<artifactId>nexus-staging-maven-plugin</artifactId>
@ -149,7 +293,6 @@
<nexusUrl>${jboss.repo.nexusUrl}</nexusUrl>
</configuration>
</plugin>
<plugin>
<groupId>com.github.eirslett</groupId>
<artifactId>frontend-maven-plugin</artifactId>

View file

@ -0,0 +1,205 @@
package org.keycloak.admin.ui.rest
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.ClientRole
import org.keycloak.admin.ui.rest.model.RoleMapper
import org.keycloak.models.KeycloakSession
import org.keycloak.models.RealmModel
import org.keycloak.models.RoleModel
import org.keycloak.services.resources.admin.permissions.AdminPermissionEvaluator
import org.mapstruct.factory.Mappers
import java.util.*
import java.util.function.Predicate
import java.util.stream.Collectors
import javax.ws.rs.*
import javax.ws.rs.core.Context
import javax.ws.rs.core.MediaType
@Path("/")
open class AdminUIExtendedResource(
private var realm: RealmModel,
private var auth: AdminPermissionEvaluator,
) {
@Context
var session: KeycloakSession? = null
@GET
@Path("/clientScopes/{id}")
@Consumes(MediaType.APPLICATION_JSON)
@Produces(MediaType.APPLICATION_JSON)
@Operation(
summary = "List all composite client roles for this client scope",
description = "This endpoint returns all the client role mapping for a specific client scope"
)
@APIResponse(
responseCode = "200",
description = "",
content = [Content(
schema = Schema(
type = SchemaType.ARRAY,
implementation = ClientRole::class
)
)]
)
fun listCompositeClientScopeRoleMappings(
@PathParam("id") id: String,
@QueryParam("first") @DefaultValue("0") first: Long,
@QueryParam("max") @DefaultValue("10") max: Long,
@QueryParam("search") @DefaultValue("") search: String
): List<ClientRole> {
val scopeContainer = realm.getClientScopeById(id) ?: throw NotFoundException("Could not find client scope")
auth.clients().requireView(scopeContainer)
return availableMapping(Predicate<RoleModel?> { r -> scopeContainer.hasDirectScope(r) }.negate(), first, max, search)
}
@GET
@Path("/clients/{id}")
@Consumes(MediaType.APPLICATION_JSON)
@Produces(MediaType.APPLICATION_JSON)
@Operation(
summary = "List all composite client roles for this client",
description = "This endpoint returns all the client role mapping for a specific client"
)
@APIResponse(
responseCode = "200",
description = "",
content = [Content(
schema = Schema(
type = SchemaType.ARRAY,
implementation = ClientRole::class
)
)]
)
fun listCompositeClientRoleMappings(
@PathParam("id") id: String,
@QueryParam("first") @DefaultValue("0") first: Long,
@QueryParam("max") @DefaultValue("10") max: Long,
@QueryParam("search") @DefaultValue("") search: String
): List<ClientRole> {
val scopeContainer = realm.getClientById(id) ?: throw NotFoundException("Could not find client")
auth.clients().requireView(scopeContainer)
return availableMapping(Predicate<RoleModel?> { r -> scopeContainer.hasDirectScope(r) }.negate(), first, max, search)
}
@GET
@Path("/groups/{id}")
@Consumes(MediaType.APPLICATION_JSON)
@Produces(MediaType.APPLICATION_JSON)
@Operation(
summary = "List all composite client roles for this group",
description = "This endpoint returns all the client role mapping for a specific group"
)
@APIResponse(
responseCode = "200",
description = "",
content = [Content(
schema = Schema(
type = SchemaType.ARRAY,
implementation = ClientRole::class
)
)]
)
fun listCompositeGroupRoleMappings(
@PathParam("id") id: String,
@QueryParam("first") @DefaultValue("0") first: Long,
@QueryParam("max") @DefaultValue("10") max: Long,
@QueryParam("search") @DefaultValue("") search: String
): List<ClientRole> {
val scopeContainer = realm.getGroupById(id) ?: throw NotFoundException("Could not find group")
auth.groups().requireView(scopeContainer)
return availableMapping(Predicate<RoleModel?> { r -> scopeContainer.hasDirectRole(r) }.negate(), first, max, search)
}
@GET
@Path("/users/{id}")
@Consumes(MediaType.APPLICATION_JSON)
@Produces(MediaType.APPLICATION_JSON)
@Operation(
summary = "List all composite client roles for this user",
description = "This endpoint returns all the client role mapping for a specific user"
)
@APIResponse(
responseCode = "200",
description = "",
content = [Content(
schema = Schema(
type = SchemaType.ARRAY,
implementation = ClientRole::class
)
)]
)
fun listCompositeUserRoleMappings(
@PathParam("id") id: String,
@QueryParam("first") @DefaultValue("0") first: Long,
@QueryParam("max") @DefaultValue("10") max: Long,
@QueryParam("search") @DefaultValue("") search: String
): List<ClientRole> {
val user = session?.users()?.getUserById(realm, id)
?: if (auth.users().canQuery()) throw NotFoundException("User not found") else throw ForbiddenException()
auth.users().requireView(user)
return availableMapping(Predicate<RoleModel?> { r -> user.hasDirectRole(r) }.negate(), first, max, search)
}
@GET
@Path("/roles/{id}")
@Consumes(MediaType.APPLICATION_JSON)
@Produces(MediaType.APPLICATION_JSON)
@Operation(
summary = "List all composite client roles",
description = "This endpoint returns all the client role"
)
@APIResponse(
responseCode = "200",
description = "",
content = [Content(
schema = Schema(
type = SchemaType.ARRAY,
implementation = ClientRole::class
)
)]
)
fun listCompositeRoleMappings(
@QueryParam("first") @DefaultValue("0") first: Long,
@QueryParam("max") @DefaultValue("10") max: Long,
@QueryParam("search") @DefaultValue("") search: String
): List<ClientRole> {
val clients = realm.clientsStream
val mapper = Mappers.getMapper(RoleMapper::class.java)
return clients
.flatMap { c -> c.rolesStream }
.map { r -> mapper.convertToRepresentation(r, realm.clientsStream) }
.skip(first)
.limit(max)
.collect(Collectors.toList()) ?: Collections.emptyList()
}
private fun availableMapping(
predicate: Predicate<RoleModel?>,
first: Long,
max: Long,
search: String
): List<ClientRole> {
val clients = realm.clientsStream
val mapper = Mappers.getMapper(RoleMapper::class.java)
return clients
.flatMap { c -> c.rolesStream }
.filter(predicate)
.filter(auth.roles()::canMapClientScope)
.map { r -> mapper.convertToRepresentation(r, realm.clientsStream) }
.filter { r -> r.client?.indexOf(search) != -1 || r.role.indexOf(search) != -1 }
.skip(first)
.limit(max)
.collect(Collectors.toList()) ?: Collections.emptyList()
}
}

View file

@ -0,0 +1,32 @@
package org.keycloak.admin.ui.rest
import org.keycloak.Config
import org.keycloak.models.KeycloakSession
import org.keycloak.models.KeycloakSessionFactory
import org.keycloak.models.RealmModel
import org.keycloak.services.resources.admin.AdminEventBuilder
import org.keycloak.services.resources.admin.ext.AdminRealmResourceProvider
import org.keycloak.services.resources.admin.ext.AdminRealmResourceProviderFactory
import org.keycloak.services.resources.admin.permissions.AdminPermissionEvaluator
class AdminUIRestEndpointProvider : AdminRealmResourceProviderFactory, AdminRealmResourceProvider {
override fun create(session: KeycloakSession): AdminRealmResourceProvider {
return this
}
override fun init(config: Config.Scope) {}
override fun postInit(factory: KeycloakSessionFactory) {}
override fun close() {}
override fun getId(): String {
return "admin-ui"
}
override fun getResource(
session: KeycloakSession,
realm: RealmModel,
auth: AdminPermissionEvaluator,
adminEvent: AdminEventBuilder
): Any {
return AdminUIExtendedResource(realm, auth)
}
}

View file

@ -0,0 +1,11 @@
package org.keycloak.admin.ui.rest.model
import org.eclipse.microprofile.openapi.annotations.media.Schema
data class ClientRole(
@field:Schema(required = true) var id: String,
@field:Schema(required = true) var role: String,
@field:Schema(required = true) var client: String?,
var description: String?
) {
}

View file

@ -0,0 +1,19 @@
package org.keycloak.admin.ui.rest.model
import org.keycloak.models.ClientModel
import org.keycloak.models.RoleModel
import org.mapstruct.*
import java.util.stream.Stream
@Mapper
abstract class RoleMapper {
@Mapping(source = "name", target = "role")
@Mapping(target = "client", ignore = true)
abstract fun convertToRepresentation(role: RoleModel, @Context clientModel: Stream<ClientModel>): ClientRole
@AfterMapping
fun convert(role: RoleModel, @MappingTarget clientRole: ClientRole, @Context list: Stream<ClientModel>) {
clientRole.client = list.filter { c -> role.containerId == c.id }.findFirst().get().clientId
}
}

View file

@ -0,0 +1,18 @@
#
# Copyright 2022 Red Hat, Inc. and/or its affiliates
# and other contributors as indicated by the @author tags.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
#
org.keycloak.admin.ui.rest.AdminUIRestEndpointProvider

View file

@ -15,7 +15,9 @@ import { KeycloakDataTable } from "../table-toolbar/KeycloakDataTable";
import { useAdminClient } from "../../context/auth/AdminClient";
import useLocaleSort from "../../utils/useLocaleSort";
import { ResourcesKey, Row, ServiceRole } from "./RoleMapping";
import { getAvailableClientRoles, getAvailableRoles } from "./queries";
import { getAvailableRoles } from "./queries";
import { getAvailableClientRoles } from "./resource";
import { useRealm } from "../../context/realm-context/RealmContext";
type AddRoleMappingModalProps = {
id: string;
@ -40,6 +42,7 @@ export const AddRoleMappingModal = ({
}: AddRoleMappingModalProps) => {
const { t } = useTranslation("common");
const { adminClient } = useAdminClient();
const { realm } = useRealm();
const [searchToggle, setSearchToggle] = useState(false);
@ -69,19 +72,28 @@ export const AddRoleMappingModal = ({
return localeSort(roles, compareRow);
};
/* this is still pretty expensive querying all client and then all roles */
const clientRolesLoader = async (): Promise<Row[]> => {
const allClients = await adminClient.clients.find();
const clientRolesLoader = async (
first?: number,
max?: number,
search?: string
): Promise<Row[]> => {
const roles = await getAvailableClientRoles({
adminClient,
id,
realm,
type,
first: first || 0,
max: max || 10,
search,
});
const roles = (
await Promise.all(
allClients.map((client) =>
getAvailableClientRoles(adminClient, type, id, client)
)
)
).flat();
return localeSort(roles, compareRow);
return localeSort(
roles.map((e) => ({
client: { clientId: e.client },
role: { id: e.id, name: e.role, description: e.description },
})),
compareRow
);
};
return (
@ -119,6 +131,7 @@ export const AddRoleMappingModal = ({
key={key}
onSelect={(rows) => setSelectedRows([...rows])}
searchPlaceholderKey="clients:searchByRoleName"
isPaginated={filterType === "clients"}
searchTypeComponent={
<ToolbarItem>
<Dropdown

View file

@ -181,19 +181,3 @@ export const getAvailableRoles = async (
role,
}));
};
export const getAvailableClientRoles = async (
adminClient: KeycloakAdminClient,
type: ResourcesKey,
id: string,
client: ClientRepresentation
) => {
const query = mapping[type]!.listAvailable[0];
return (
await applyQuery(adminClient, type === "roles" ? "clients" : type, query, {
id: type === "roles" ? client.id : id,
client: client.id,
clientUniqueId: client.id,
})
).map((role) => ({ role, client: { clientId: client.id, ...client } }));
};

View file

@ -0,0 +1,47 @@
import KeycloakAdminClient from "@keycloak/keycloak-admin-client";
import { addTrailingSlash } from "../../util";
import { getAuthorizationHeaders } from "../../utils/getAuthorizationHeaders";
type AvailableClientRolesQuery = {
adminClient: KeycloakAdminClient;
id: string;
realm: string;
type: string;
first: number;
max: number;
search?: string;
};
type ClientRole = {
id: string;
role: string;
description?: string;
client?: string;
};
export const getAvailableClientRoles = async ({
adminClient,
id,
realm,
type,
first,
max,
search,
}: AvailableClientRolesQuery): Promise<ClientRole[]> => {
const accessToken = await adminClient.getAccessToken();
const baseUrl = adminClient.baseUrl;
const response = await fetch(
`${addTrailingSlash(
baseUrl
)}admin/realms/${realm}/admin-ui/${type}/${id}?first=${first}&max=${max}${
search ? "&search=" + search : ""
}`,
{
method: "GET",
headers: getAuthorizationHeaders(accessToken),
}
);
return await response.json();
};