[admin-api-v2] Incorrect DTO/DAO mapping (#44587)

* [admin-api-v2] Incorrect DTO/DAO mapping

Closes #44586

Signed-off-by: Martin Bartoš <mabartos@redhat.com>

* Handle roles and service account operations, cleanup service contract

Signed-off-by: Martin Bartoš <mabartos@redhat.com>

---------

Signed-off-by: Martin Bartoš <mabartos@redhat.com>
This commit is contained in:
Martin Bartoš
2025-12-03 09:41:18 +01:00
committed by GitHub
parent ae7e7ba084
commit 5828fab258
10 changed files with 491 additions and 67 deletions

View File

@@ -1,12 +1,16 @@
package org.keycloak.models.mapper;
import org.keycloak.models.ClientModel;
import org.keycloak.models.KeycloakSession;
import org.keycloak.models.RealmModel;
import org.keycloak.representations.admin.v2.ClientRepresentation;
import org.keycloak.services.ServiceException;
public interface ClientModelMapper {
ClientRepresentation fromModel(ClientModel model);
ClientRepresentation fromModel(KeycloakSession session, ClientModel model);
void toModel(ClientModel model, ClientRepresentation rep, RealmModel realm);
ClientModel toModel(KeycloakSession session, RealmModel realm, ClientModel existingModel, ClientRepresentation rep) throws ServiceException;
ClientModel toModel(KeycloakSession session, RealmModel realm, ClientRepresentation rep) throws ServiceException;
}

View File

@@ -1,36 +1,89 @@
package org.keycloak.models.mapper;
import java.util.Collections;
import java.util.Set;
import java.util.stream.Collectors;
import java.util.stream.Stream;
import org.keycloak.models.ClientModel;
import org.keycloak.models.KeycloakSession;
import org.keycloak.models.RealmModel;
import org.keycloak.models.RoleModel;
import org.keycloak.representations.admin.v2.ClientRepresentation;
import org.keycloak.services.ServiceException;
import org.mapstruct.Context;
import org.mapstruct.Mapper;
import org.mapstruct.Mapping;
import org.mapstruct.MappingTarget;
import org.mapstruct.Named;
import org.mapstruct.ObjectFactory;
@Mapper
public interface MapStructClientModelMapper extends ClientModelMapper {
@Override
@ModelToRep
ClientRepresentation fromModel(@Context KeycloakSession session, ClientModel model);
// we don't want to ignore nulls so that we completely overwrite the state
@Override
@RepToModel
ClientModel toModel(@Context KeycloakSession session, @Context RealmModel realm, @MappingTarget ClientModel existingModel, ClientRepresentation rep) throws ServiceException;
@Override
@RepToModel
ClientModel toModel(@Context KeycloakSession session, @Context RealmModel realm, ClientRepresentation rep) throws ServiceException;
/*-------------------------------------*
* MAPPERS *
*-------------------------------------*/
@Mapping(target = "name", source = "displayName")
@Mapping(target = "baseUrl", source = "appUrl")
@Mapping(target = "redirectUris", source = "appRedirectUrls")
@Mapping(target = "authenticationFlowBindingOverrides", source = "loginFlows", ignore = true) // TODO
@Mapping(target = "publicClient", source = "auth.enabled", qualifiedByName = "isPublicClientPrimitive")
@Mapping(target = "clientAuthenticatorType", source = "auth.method")
@Mapping(target = "secret", source = "auth.secret")
@Mapping(target = "serviceAccountsEnabled", source = "serviceAccount.enabled")
@interface RepToModel {
}
@Mapping(target = "displayName", source = "name")
@Mapping(target = "appUrl", source = "baseUrl")
@Mapping(target = "appRedirectUrls", source = "redirectUris")
@Mapping(target = "loginFlows", source = "authenticationFlowBindingOverrides", ignore = true)
@Mapping(target = "auth", ignore = true) // TODO
@Mapping(target = "auth.enabled", source = "publicClient", qualifiedByName = "isPublicClient")
@Mapping(target = "auth.method", source = "clientAuthenticatorType")
@Mapping(target = "auth.secret", source = "secret")
@Mapping(target = "auth.certificate", ignore = true) // no cert in the representation
@Mapping(target = "roles", source = "rolesStream", qualifiedByName = "getRoleStrings")
@Mapping(target = "serviceAccount.enabled", source = "serviceAccountsEnabled")
@Mapping(target = "serviceAccount.roles", source = "rolesStream", qualifiedByName = "getServiceAccountRoles")
@Override
ClientRepresentation fromModel(ClientModel model);
@Mapping(target = "serviceAccount.roles", source = ".", qualifiedByName = "getServiceAccountRoles")
@interface ModelToRep {
}
// we don't want to ignore nulls so that we completely overwrite the state
@Override
void toModel(@MappingTarget ClientModel model, ClientRepresentation rep, @Context RealmModel realm);
/*-------------------------------------*
* HELPER METHODS *
*-------------------------------------*/
@ObjectFactory
default ClientModel createClientModel(@Context RealmModel realm, ClientRepresentation rep) {
// dummy add/remove to obtain a detached model
var model = realm.addClient(rep.getClientId());
realm.removeClient(model.getId());
return model;
}
@Named("isPublicClientPrimitive")
default boolean isPublicClientPrimitive(Boolean authEnabled) {
var result = isPublicClient(authEnabled);
return result != null ? result : false;
}
@Named("isPublicClient")
default Boolean isPublicClient(Boolean authEnabled) {
return authEnabled != null ? !authEnabled : null;
}
@Named("getRoleStrings")
default Set<String> getRoleStrings(Stream<RoleModel> stream) {
@@ -38,9 +91,13 @@ public interface MapStructClientModelMapper extends ClientModelMapper {
}
@Named("getServiceAccountRoles")
default Set<String> getServiceAccountRoles(Stream<RoleModel> stream) {
return stream.filter(f -> true) //TODO check roles for SA
.map(RoleModel::getName)
.collect(Collectors.toSet());
default Set<String> getServiceAccountRoles(@Context KeycloakSession session, ClientModel client) {
if (client.isServiceAccountsEnabled()) {
return session.users().getServiceAccount(client)
.getRoleMappingsStream()
.map(RoleModel::getName)
.collect(Collectors.toSet());
}
return Collections.emptySet();
}
}

View File

@@ -1,6 +1,7 @@
package org.keycloak.representations.admin.v2;
import java.util.LinkedHashSet;
import java.util.Objects;
import java.util.Set;
import jakarta.validation.Valid;
@@ -211,6 +212,21 @@ public class ClientRepresentation extends BaseRepresentation {
public void setCertificate(String certificate) {
this.certificate = certificate;
}
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (!(o instanceof Auth auth)) return false;
return Objects.equals(enabled, auth.enabled)
&& Objects.equals(method, auth.method)
&& Objects.equals(secret, auth.secret)
&& Objects.equals(certificate, auth.certificate);
}
@Override
public int hashCode() {
return Objects.hash(enabled, method, secret, certificate);
}
}
@JsonInclude(JsonInclude.Include.NON_ABSENT)
@@ -238,5 +254,44 @@ public class ClientRepresentation extends BaseRepresentation {
public void setRoles(Set<String> roles) {
this.roles = roles;
}
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (!(o instanceof ServiceAccount that)) return false;
return Objects.equals(enabled, that.enabled)
&& Objects.equals(roles, that.roles);
}
@Override
public int hashCode() {
return Objects.hash(enabled, roles);
}
}
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (o == null || getClass() != o.getClass()) return false;
ClientRepresentation that = (ClientRepresentation) o;
return Objects.equals(clientId, that.clientId)
&& Objects.equals(displayName, that.displayName)
&& Objects.equals(description, that.description)
&& Objects.equals(protocol, that.protocol)
&& Objects.equals(enabled, that.enabled)
&& Objects.equals(appUrl, that.appUrl)
&& Objects.equals(appRedirectUrls, that.appRedirectUrls)
&& Objects.equals(loginFlows, that.loginFlows)
&& Objects.equals(auth, that.auth)
&& Objects.equals(webOrigins, that.webOrigins)
&& Objects.equals(roles, that.roles)
&& Objects.equals(serviceAccount, that.serviceAccount);
}
@Override
public int hashCode() {
return Objects.hash(clientId, displayName, description, protocol, enabled, appUrl, appRedirectUrls,
loginFlows, auth, webOrigins, roles, serviceAccount
);
}
}

View File

@@ -7,8 +7,6 @@ import org.keycloak.models.RealmModel;
import org.keycloak.representations.admin.v2.ClientRepresentation;
import org.keycloak.services.Service;
import org.keycloak.services.ServiceException;
import org.keycloak.services.resources.admin.ClientResource;
import org.keycloak.services.resources.admin.ClientsResource;
public interface ClientService extends Service {
@@ -29,12 +27,12 @@ public interface ClientService extends Service {
record CreateOrUpdateResult(ClientRepresentation representation, boolean created) {}
Optional<ClientRepresentation> getClient(ClientResource clientResource, RealmModel realm, String clientId, ClientProjectionOptions projectionOptions);
Optional<ClientRepresentation> getClient(RealmModel realm, String clientId, ClientProjectionOptions projectionOptions);
Stream<ClientRepresentation> getClients(ClientsResource clientsResource, RealmModel realm, ClientProjectionOptions projectionOptions, ClientSearchOptions searchOptions, ClientSortAndSliceOptions sortAndSliceOptions);
Stream<ClientRepresentation> getClients(RealmModel realm, ClientProjectionOptions projectionOptions, ClientSearchOptions searchOptions, ClientSortAndSliceOptions sortAndSliceOptions);
Stream<ClientRepresentation> deleteClients(RealmModel realm, ClientSearchOptions searchOptions);
CreateOrUpdateResult createOrUpdate(ClientsResource clientsResource, ClientResource clientResource, RealmModel realm, ClientRepresentation client, boolean allowUpdate) throws ServiceException;
CreateOrUpdateResult createOrUpdate(RealmModel realm, ClientRepresentation client, boolean allowUpdate) throws ServiceException;
}

View File

@@ -1,21 +1,29 @@
package org.keycloak.services.client;
import java.util.Collections;
import java.util.List;
import java.util.Optional;
import java.util.Set;
import java.util.stream.Collectors;
import java.util.stream.Stream;
import jakarta.ws.rs.NotFoundException;
import jakarta.ws.rs.core.Response;
import org.keycloak.models.ClientModel;
import org.keycloak.models.KeycloakSession;
import org.keycloak.models.RealmModel;
import org.keycloak.models.RoleModel;
import org.keycloak.models.mapper.ClientModelMapper;
import org.keycloak.models.mapper.MapStructModelMapper;
import org.keycloak.models.utils.ModelToRepresentation;
import org.keycloak.representations.admin.v2.ClientRepresentation;
import org.keycloak.representations.admin.v2.validation.CreateClientDefault;
import org.keycloak.representations.idm.RoleRepresentation;
import org.keycloak.services.ServiceException;
import org.keycloak.services.resources.admin.ClientResource;
import org.keycloak.services.resources.admin.ClientsResource;
import org.keycloak.services.resources.admin.RealmAdminResource;
import org.keycloak.validation.jakarta.HibernateValidatorProvider;
import org.keycloak.validation.jakarta.JakartaValidatorProvider;
@@ -24,55 +32,61 @@ public class DefaultClientService implements ClientService {
private final KeycloakSession session;
private final ClientModelMapper mapper;
private final JakartaValidatorProvider validator;
private final RealmAdminResource realmAdminResource;
private final ClientsResource clientsResource;
private ClientResource clientResource;
public DefaultClientService(KeycloakSession session) {
public DefaultClientService(KeycloakSession session, RealmAdminResource realmAdminResource, ClientResource clientResource) {
this.session = session;
this.realmAdminResource = realmAdminResource;
this.clientResource = clientResource;
this.clientsResource = realmAdminResource.getClients();
this.mapper = new MapStructModelMapper().clients();
this.validator = new HibernateValidatorProvider();
}
@Override
public Optional<ClientRepresentation> getClient(ClientResource clientResource, RealmModel realm, String clientId,
ClientProjectionOptions projectionOptions) {
// TODO: is the access map on the representation needed
return Optional.ofNullable(clientResource).map(ClientResource::viewClientModel).map(mapper::fromModel);
public DefaultClientService(KeycloakSession session, RealmAdminResource realmAdminResource) {
this(session, realmAdminResource, null);
}
@Override
public Stream<ClientRepresentation> getClients(ClientsResource clientsResource, RealmModel realm,
ClientProjectionOptions projectionOptions, ClientSearchOptions searchOptions,
ClientSortAndSliceOptions sortAndSliceOptions) {
public Optional<ClientRepresentation> getClient(RealmModel realm, String clientId, ClientProjectionOptions projectionOptions) {
// TODO: is the access map on the representation needed
return clientsResource.getClientModels(null, true, false, null, null, null).map(mapper::fromModel);
return Optional.ofNullable(clientResource).map(ClientResource::viewClientModel).map(model -> mapper.fromModel(session, model));
}
@Override
public CreateOrUpdateResult createOrUpdate(ClientsResource clientsResource, ClientResource clientResource,
RealmModel realm, ClientRepresentation client, boolean allowUpdate) throws ServiceException {
public Stream<ClientRepresentation> getClients(RealmModel realm, ClientProjectionOptions projectionOptions,
ClientSearchOptions searchOptions, ClientSortAndSliceOptions sortAndSliceOptions) {
// TODO: is the access map on the representation needed
return clientsResource.getClientModels(null, true, false, null, null, null).map(model -> mapper.fromModel(session, model));
}
@Override
public CreateOrUpdateResult createOrUpdate(RealmModel realm, ClientRepresentation client, boolean allowUpdate) throws ServiceException {
boolean created = false;
ClientModel model = null;
ClientModel model;
if (clientResource != null) {
if (!allowUpdate) {
throw new ServiceException("Client already exists", Response.Status.CONFLICT);
}
model = clientResource.viewClientModel();
mapper.toModel(model, client, realm);
model = mapper.toModel(session, realm, clientResource.viewClientModel(), client);
var rep = ModelToRepresentation.toRepresentation(model, session);
clientResource.update(rep);
} else {
created = true;
validator.validate(client, CreateClientDefault.class); // TODO improve it to avoid second validation when we know it is create and not update
// dummy add/remove to obtain a detached model
model = realm.addClient(client.getClientId());
realm.removeClient(model.getId());
mapper.toModel(model, client, realm);
model = mapper.toModel(session, realm, client);
var rep = ModelToRepresentation.toRepresentation(model, session);
model = clientsResource.createClientModel(rep);
clientResource = clientsResource.getClient(model.getId());
}
var updated = mapper.fromModel(model);
handleRoles(client.getRoles());
handleServiceAccount(model, client.getServiceAccount());
var updated = mapper.fromModel(session, model);
return new CreateOrUpdateResult(updated, created);
}
@@ -83,4 +97,89 @@ public class DefaultClientService implements ClientService {
return null;
}
/**
* Declaratively manage client roles - ensures the client has exactly the roles specified in 'rolesFromRep'
* <p>
* Reuses API v1 logic
*/
protected void handleRoles(Set<String> rolesFromRep) {
var roleResource = clientResource.getRoleContainerResource();
Set<String> desiredRoleNames = Optional.ofNullable(rolesFromRep)
.orElse(Collections.emptySet());
Set<String> currentRoleNames = roleResource.getRoles(null, null, null, false)
.map(RoleRepresentation::getName)
.collect(Collectors.toSet());
// Add missing roles (in desiredRoleNames but not in currentRoleNames)
desiredRoleNames.stream()
.filter(roleName -> !currentRoleNames.contains(roleName))
.forEach(roleName -> roleResource.createRole(new RoleRepresentation(roleName, "", false)));
// Remove extra roles (in currentRoleNames but not in desiredRoleNames)
currentRoleNames.stream()
.filter(role -> !desiredRoleNames.contains(role))
.forEach(roleResource::deleteRole);
}
/**
* Declaratively manage service account - enables/disables it and ensures it has exactly the roles specified (realm and client roles)
* <p>
* Reuses API v1 logic
*/
protected void handleServiceAccount(ClientModel model, ClientRepresentation.ServiceAccount serviceAccount) {
if (serviceAccount != null && serviceAccount.getEnabled() != null) {
ClientResource.updateClientServiceAccount(session, model, serviceAccount.getEnabled());
if (serviceAccount.getEnabled()) {
var clientRoleResource = clientResource.getRoleContainerResource();
var realmRoleResource = realmAdminResource.getRoleContainerResource();
var serviceAccountUser = session.users().getServiceAccount(model);
var serviceAccountRoleResource = realmAdminResource.users().user(clientResource.getServiceAccountUser().getId()).getRoleMappings();
Set<String> desiredRoleNames = Optional.ofNullable(serviceAccount.getRoles()).orElse(Collections.emptySet());
Set<RoleModel> currentRoles = serviceAccountUser.getRoleMappingsStream().collect(Collectors.toSet());
Set<String> currentRoleNames = currentRoles.stream().map(RoleModel::getName).collect(Collectors.toSet());
// Get missing roles (in desired but not in current)
List<RoleRepresentation> missingRoles = desiredRoleNames.stream()
.filter(roleName -> !currentRoleNames.contains(roleName))
.map(roleName -> {
try {
return clientRoleResource.getRole(roleName); // client role
} catch (NotFoundException e) {
try {
return realmRoleResource.getRole(roleName); // realm role
} catch (NotFoundException e2) {
throw new ServiceException("Cannot assign role to the service account (field 'serviceAccount.roles') as it does not exist", Response.Status.BAD_REQUEST);
}
}
})
.toList();
// Add missing roles (in desired but not in current)
if (!missingRoles.isEmpty()) {
serviceAccountRoleResource.addRealmRoleMappings(missingRoles);
}
// Get extra roles (in current but not in desired)
List<RoleRepresentation> extraRoles = currentRoles.stream()
.filter(role -> !desiredRoleNames.contains(role.getName()))
.map(ModelToRepresentation::toRepresentation)
.toList();
// Remove extra roles (in current but not in desired)
if (!extraRoles.isEmpty()) {
try {
serviceAccountRoleResource.deleteRealmRoleMappings(extraRoles);
} catch (NotFoundException e) {
throw new ServiceException("Cannot unassign role from the service account (field 'serviceAccount.roles') as it does not exist", Response.Status.BAD_REQUEST);
}
}
}
}
}
}

View File

@@ -18,7 +18,7 @@ import org.keycloak.services.ServiceException;
import org.keycloak.services.client.ClientService;
import org.keycloak.services.client.DefaultClientService;
import org.keycloak.services.resources.admin.ClientResource;
import org.keycloak.services.resources.admin.ClientsResource;
import org.keycloak.services.resources.admin.RealmAdminResource;
import org.keycloak.services.util.ObjectMapperResolver;
import com.fasterxml.jackson.core.JsonProcessingException;
@@ -34,26 +34,26 @@ public class DefaultClientApi implements ClientApi {
private final ClientService clientService;
private final ClientResource clientResource;
private final ClientsResource clientsResource;
private final String clientId;
private final ObjectMapper objectMapper;
private static final ObjectMapper MAPPER = new ObjectMapperResolver().getContext(null);
public DefaultClientApi(KeycloakSession session, ClientsResource clientsResource, ClientResource clientResource, String clientId) {
public DefaultClientApi(KeycloakSession session, RealmAdminResource realmAdminResource, ClientResource clientResource, String clientId) {
this.session = session;
this.realm = Objects.requireNonNull(session.getContext().getRealm());
this.client = Objects.requireNonNull(session.getContext().getClient());
this.clientService = new DefaultClientService(session);
this.clientsResource = clientsResource;
this.clientResource = clientResource;
this.clientId = clientId;
this.realm = Objects.requireNonNull(session.getContext().getRealm());
this.client = Objects.requireNonNull(session.getContext().getClient());
this.clientService = new DefaultClientService(session, realmAdminResource, clientResource);
this.objectMapper = MAPPER;
}
@Override
public ClientRepresentation getClient() {
return clientService.getClient(clientResource, realm, client.getClientId(), null)
return clientService.getClient(realm, client.getClientId(), null)
.orElseThrow(() -> new NotFoundException("Cannot find the specified client"));
}
@@ -64,7 +64,7 @@ public class DefaultClientApi implements ClientApi {
throw new WebApplicationException("cliendId in payload does not match the clientId in the path", Response.Status.BAD_REQUEST);
}
validateUnknownFields(client);
var result = clientService.createOrUpdate(clientsResource, clientResource, realm, client, true);
var result = clientService.createOrUpdate(realm, client, true);
return Response.status(result.created() ? Response.Status.CREATED : Response.Status.OK).entity(result.representation()).build();
} catch (ServiceException e) {
throw new WebApplicationException(e.getMessage(), e.getSuggestedResponseStatus().orElse(Response.Status.BAD_REQUEST));
@@ -86,7 +86,7 @@ public class DefaultClientApi implements ClientApi {
ClientRepresentation updated = objectReader.readValue(patch);
validateUnknownFields(updated);
return clientService.createOrUpdate(clientsResource, clientResource, realm, updated, true).representation();
return clientService.createOrUpdate(realm, updated, true).representation();
} catch (IllegalArgumentException e) {
throw new WebApplicationException("Unsupported media type", Response.Status.UNSUPPORTED_MEDIA_TYPE);
} catch (JsonProcessingException e) {

View File

@@ -17,6 +17,7 @@ import org.keycloak.services.ServiceException;
import org.keycloak.services.client.ClientService;
import org.keycloak.services.client.DefaultClientService;
import org.keycloak.services.resources.admin.ClientsResource;
import org.keycloak.services.resources.admin.RealmAdminResource;
import org.keycloak.validation.jakarta.HibernateValidatorProvider;
import org.keycloak.validation.jakarta.JakartaValidatorProvider;
@@ -25,19 +26,22 @@ public class DefaultClientsApi implements ClientsApi {
private final RealmModel realm;
private final ClientService clientService;
private final JakartaValidatorProvider validator;
private final RealmAdminResource realmAdminResource;
private final ClientsResource clientsResource;
public DefaultClientsApi(KeycloakSession session, ClientsResource clientsResource) {
public DefaultClientsApi(KeycloakSession session, RealmAdminResource realmAdminResource) {
this.session = session;
this.realmAdminResource = realmAdminResource;
this.realm = Objects.requireNonNull(session.getContext().getRealm());
this.clientService = new DefaultClientService(session);
this.clientService = new DefaultClientService(session, realmAdminResource);
this.validator = new HibernateValidatorProvider();
this.clientsResource = clientsResource;
this.clientsResource = realmAdminResource.getClients();
}
@Override
public Stream<ClientRepresentation> getClients() {
return clientService.getClients(clientsResource, realm, null, null, null);
return clientService.getClients(realm, null, null, null);
}
@Override
@@ -46,7 +50,7 @@ public class DefaultClientsApi implements ClientsApi {
DefaultClientApi.validateUnknownFields(client);
validator.validate(client, CreateClientDefault.class);
return Response.status(Response.Status.CREATED)
.entity(clientService.createOrUpdate(clientsResource, null, realm, client, false).representation())
.entity(clientService.createOrUpdate(realm, client, false).representation())
.build();
} catch (ServiceException e) {
throw new WebApplicationException(e.getMessage(), e.getSuggestedResponseStatus().orElse(Response.Status.BAD_REQUEST));
@@ -56,7 +60,7 @@ public class DefaultClientsApi implements ClientsApi {
@Override
public ClientApi client(@PathParam("id") String clientId) {
var client = Optional.ofNullable(session.clients().getClientByClientId(realm, clientId));
return new DefaultClientApi(session, clientsResource, client.map(c -> clientsResource.getClient(c.getId())).orElse(null), clientId);
return new DefaultClientApi(session, realmAdminResource, client.map(c -> clientsResource.getClient(c.getId())).orElse(null), clientId);
}
}

View File

@@ -19,7 +19,7 @@ public class DefaultRealmApi implements RealmApi {
@Path("clients")
@Override
public ClientsApi clients() {
return new DefaultClientsApi(session, realmAdminResource.getClients());
return new DefaultClientsApi(session, realmAdminResource);
}
}

View File

@@ -17,6 +17,8 @@
package org.keycloak.tests.admin.client.v2;
import java.util.Set;
import jakarta.ws.rs.core.HttpHeaders;
import jakarta.ws.rs.core.MediaType;
@@ -48,6 +50,7 @@ import org.apache.http.util.EntityUtils;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Test;
import static org.hamcrest.CoreMatchers.containsString;
import static org.hamcrest.CoreMatchers.is;
import static org.hamcrest.MatcherAssert.assertThat;
import static org.hamcrest.Matchers.notNullValue;
@@ -276,6 +279,206 @@ public class ClientApiV2Test {
}
}
@Test
public void createFullClient() throws Exception {
HttpPost request = new HttpPost(HOSTNAME_LOCAL_ADMIN + "/realms/master/clients");
setAuthHeader(request);
request.setHeader(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON);
ClientRepresentation rep = getTestingFullClientRep();
request.setEntity(new StringEntity(mapper.writeValueAsString(rep)));
try (var response = client.execute(request)) {
assertEquals(201, response.getStatusLine().getStatusCode());
ClientRepresentation client = mapper.createParser(response.getEntity().getContent()).readValueAs(ClientRepresentation.class);
assertThat(client, is(rep));
}
}
@Test
public void createFullClientWrongServiceAccountRoles() throws Exception {
HttpPost request = new HttpPost(HOSTNAME_LOCAL_ADMIN + "/realms/master/clients");
setAuthHeader(request);
request.setHeader(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON);
ClientRepresentation rep = getTestingFullClientRep();
rep.getServiceAccount().setRoles(Set.of("non-existing", "bad-role"));
request.setEntity(new StringEntity(mapper.writeValueAsString(rep)));
try (var response = client.execute(request)) {
assertEquals(400, response.getStatusLine().getStatusCode());
assertThat(EntityUtils.toString(response.getEntity()), containsString("Cannot assign role to the service account (field 'serviceAccount.roles') as it does not exist"));
}
}
@Test
public void declarativeRoleManagement() throws Exception {
// 1. Create a client with initial roles
HttpPut createRequest = new HttpPut(HOSTNAME_LOCAL_ADMIN + "/realms/master/clients/declarative-role-test");
setAuthHeader(createRequest);
createRequest.setHeader(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON);
ClientRepresentation rep = new ClientRepresentation();
rep.setClientId("declarative-role-test");
rep.setEnabled(true);
rep.setRoles(Set.of("role1", "role2", "role3"));
createRequest.setEntity(new StringEntity(mapper.writeValueAsString(rep)));
try (var response = client.execute(createRequest)) {
assertEquals(201, response.getStatusLine().getStatusCode());
ClientRepresentation created = mapper.createParser(response.getEntity().getContent()).readValueAs(ClientRepresentation.class);
assertThat(created.getRoles(), is(Set.of("role1", "role2", "role3")));
}
// 2. Update with completely new roles - should remove old ones and add new ones
HttpPut updateRequest = new HttpPut(HOSTNAME_LOCAL_ADMIN + "/realms/master/clients/declarative-role-test");
setAuthHeader(updateRequest);
updateRequest.setHeader(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON);
rep.setRoles(Set.of("new-role1", "new-role2"));
updateRequest.setEntity(new StringEntity(mapper.writeValueAsString(rep)));
try (var response = client.execute(updateRequest)) {
assertEquals(200, response.getStatusLine().getStatusCode());
ClientRepresentation updated = mapper.createParser(response.getEntity().getContent()).readValueAs(ClientRepresentation.class);
assertThat(updated.getRoles(), is(Set.of("new-role1", "new-role2")));
}
// 3. Update with partial overlap - keep some, add some, remove some
rep.setRoles(Set.of("new-role1", "add-role3", "add-role4"));
updateRequest.setEntity(new StringEntity(mapper.writeValueAsString(rep)));
try (var response = client.execute(updateRequest)) {
assertEquals(200, response.getStatusLine().getStatusCode());
ClientRepresentation updated = mapper.createParser(response.getEntity().getContent()).readValueAs(ClientRepresentation.class);
assertThat(updated.getRoles(), is(Set.of("new-role1", "add-role3", "add-role4")));
}
// 4. Update with same roles - should be idempotent
rep.setRoles(Set.of("new-role1", "add-role3", "add-role4"));
updateRequest.setEntity(new StringEntity(mapper.writeValueAsString(rep)));
try (var response = client.execute(updateRequest)) {
assertEquals(200, response.getStatusLine().getStatusCode());
ClientRepresentation updated = mapper.createParser(response.getEntity().getContent()).readValueAs(ClientRepresentation.class);
assertThat(updated.getRoles(), is(Set.of("new-role1", "add-role3", "add-role4")));
}
// 5. Update with empty set - should remove all roles
rep.setRoles(Set.of());
updateRequest.setEntity(new StringEntity(mapper.writeValueAsString(rep)));
try (var response = client.execute(updateRequest)) {
assertEquals(200, response.getStatusLine().getStatusCode());
ClientRepresentation updated = mapper.createParser(response.getEntity().getContent()).readValueAs(ClientRepresentation.class);
assertThat(updated.getRoles(), is(Set.of()));
}
}
@Test
public void declarativeServiceAccountRoleManagement() throws Exception {
// 1. Create a client with service account and initial realm roles
HttpPut createRequest = new HttpPut(HOSTNAME_LOCAL_ADMIN + "/realms/master/clients/sa-declarative-test");
setAuthHeader(createRequest);
createRequest.setHeader(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON);
ClientRepresentation rep = new ClientRepresentation();
rep.setClientId("sa-declarative-test");
rep.setEnabled(true);
var serviceAccount = new ClientRepresentation.ServiceAccount();
serviceAccount.setEnabled(true);
serviceAccount.setRoles(Set.of("default-roles-master", "offline_access"));
rep.setServiceAccount(serviceAccount);
createRequest.setEntity(new StringEntity(mapper.writeValueAsString(rep)));
try (var response = client.execute(createRequest)) {
assertEquals(201, response.getStatusLine().getStatusCode());
ClientRepresentation created = mapper.createParser(response.getEntity().getContent()).readValueAs(ClientRepresentation.class);
assertThat(created.getServiceAccount().getRoles(), is(Set.of("default-roles-master", "offline_access")));
}
// 2. Update with completely new roles - should remove old ones and add new ones
HttpPut updateRequest = new HttpPut(HOSTNAME_LOCAL_ADMIN + "/realms/master/clients/sa-declarative-test");
setAuthHeader(updateRequest);
updateRequest.setHeader(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON);
serviceAccount.setRoles(Set.of("uma_authorization", "offline_access"));
rep.setServiceAccount(serviceAccount);
updateRequest.setEntity(new StringEntity(mapper.writeValueAsString(rep)));
try (var response = client.execute(updateRequest)) {
assertEquals(200, response.getStatusLine().getStatusCode());
ClientRepresentation updated = mapper.createParser(response.getEntity().getContent()).readValueAs(ClientRepresentation.class);
assertThat(updated.getServiceAccount().getRoles(), is(Set.of("uma_authorization", "offline_access")));
}
// 3. Update with partial overlap - keep some, add some, remove some
serviceAccount.setRoles(Set.of("offline_access", "default-roles-master"));
rep.setServiceAccount(serviceAccount);
updateRequest.setEntity(new StringEntity(mapper.writeValueAsString(rep)));
try (var response = client.execute(updateRequest)) {
assertEquals(200, response.getStatusLine().getStatusCode());
ClientRepresentation updated = mapper.createParser(response.getEntity().getContent()).readValueAs(ClientRepresentation.class);
assertThat(updated.getServiceAccount().getRoles(), is(Set.of("offline_access", "default-roles-master")));
}
// 4. Update with same roles - should be idempotent
serviceAccount.setRoles(Set.of("offline_access", "default-roles-master"));
rep.setServiceAccount(serviceAccount);
updateRequest.setEntity(new StringEntity(mapper.writeValueAsString(rep)));
try (var response = client.execute(updateRequest)) {
assertEquals(200, response.getStatusLine().getStatusCode());
ClientRepresentation updated = mapper.createParser(response.getEntity().getContent()).readValueAs(ClientRepresentation.class);
assertThat(updated.getServiceAccount().getRoles(), is(Set.of("offline_access", "default-roles-master")));
}
// 5. Update with empty set - should remove all roles
serviceAccount.setRoles(Set.of());
rep.setServiceAccount(serviceAccount);
updateRequest.setEntity(new StringEntity(mapper.writeValueAsString(rep)));
try (var response = client.execute(updateRequest)) {
assertEquals(200, response.getStatusLine().getStatusCode());
ClientRepresentation updated = mapper.createParser(response.getEntity().getContent()).readValueAs(ClientRepresentation.class);
assertThat(updated.getServiceAccount().getRoles(), is(Set.of()));
}
}
private ClientRepresentation getTestingFullClientRep() {
var rep = new ClientRepresentation();
rep.setClientId("my-client");
rep.setDisplayName("My Client");
rep.setDescription("This is My Client");
rep.setProtocol(ClientRepresentation.OIDC);
rep.setEnabled(true);
rep.setAppUrl("http://localhost:3000");
rep.setAppRedirectUrls(Set.of("http://localhost:3000", "http://localhost:3001"));
// no login flows -> only flow overrides map
// rep.setLoginFlows(Set.of("browser"));
var auth = new ClientRepresentation.Auth();
auth.setEnabled(true);
auth.setMethod("client-jwt");
auth.setSecret("secret-1234");
// no certificate inside the old rep
// auth.setCertificate("certificate-5678");
rep.setAuth(auth);
rep.setWebOrigins(Set.of("http://localhost:4000", "http://localhost:4001"));
rep.setRoles(Set.of("view-consent", "manage-account"));
var serviceAccount = new ClientRepresentation.ServiceAccount();
serviceAccount.setEnabled(true);
// TODO when roles are not set and SA is enabled, the default role 'default-roles-master' for the SA is used for the master realm
serviceAccount.setRoles(Set.of("default-roles-master"));
rep.setServiceAccount(serviceAccount);
// not implemented yet
// rep.setAdditionalFields(Map.of("key1", "val1", "key2", "val2"));
return rep;
}
// TODO Rewrite the tests to not need explicit auth. They should use the admin client directly.
private void setAuthHeader(HttpMessage request, Keycloak adminClient) {
String token = adminClient.tokenManager().getAccessTokenString();

View File

@@ -822,17 +822,7 @@ public class ClientResource {
}
private void updateClientFromRep(ClientRepresentation rep, ClientModel client, KeycloakSession session) throws ModelDuplicateException {
UserModel serviceAccount = this.session.users().getServiceAccount(client);
boolean serviceAccountScopeAssigned = client.getClientScopes(true).containsKey(ServiceAccountConstants.SERVICE_ACCOUNT_SCOPE);
if (Boolean.TRUE.equals(rep.isServiceAccountsEnabled())) {
if (serviceAccount == null || !serviceAccountScopeAssigned) {
new ClientManager(new RealmManager(session)).enableServiceAccount(client);
}
} else if (Boolean.FALSE.equals(rep.isServiceAccountsEnabled()) || !client.isServiceAccountsEnabled()) {
if (serviceAccount != null || serviceAccountScopeAssigned) {
new ClientManager(new RealmManager(session)).disableServiceAccount(client);
}
}
updateClientServiceAccount(session, client, rep.isServiceAccountsEnabled());
if (rep.getClientId() != null && !rep.getClientId().equals(client.getClientId())) {
new ClientManager(new RealmManager(session)).clientIdChanged(client, rep);
@@ -851,6 +841,20 @@ public class ClientResource {
updateAuthorizationSettings(rep);
}
public static void updateClientServiceAccount(KeycloakSession session, ClientModel client, Boolean isServiceAccountEnabled) {
UserModel serviceAccount = session.users().getServiceAccount(client);
boolean serviceAccountScopeAssigned = client.getClientScopes(true).containsKey(ServiceAccountConstants.SERVICE_ACCOUNT_SCOPE);
if (Boolean.TRUE.equals(isServiceAccountEnabled)) {
if (serviceAccount == null || !serviceAccountScopeAssigned) {
new ClientManager(new RealmManager(session)).enableServiceAccount(client);
}
} else if (Boolean.FALSE.equals(isServiceAccountEnabled) || !client.isServiceAccountsEnabled()) {
if (serviceAccount != null || serviceAccountScopeAssigned) {
new ClientManager(new RealmManager(session)).disableServiceAccount(client);
}
}
}
private void updateAuthorizationSettings(ClientRepresentation rep) {
if (Profile.isFeatureEnabled(Profile.Feature.AUTHORIZATION)) {
if (Boolean.TRUE.equals(rep.getAuthorizationServicesEnabled())) {