Repository navigation
Admin API v2 Projection #48460
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Admin API v2 Projection #48460
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,5 +1,6 @@ | ||
| package org.keycloak.admin.api.client; | ||
|
|
||
| import java.util.Set; | ||
| import java.util.stream.Stream; | ||
|
|
||
| import jakarta.validation.Valid; | ||
|
|
@@ -9,6 +10,7 @@ | |
| import jakarta.ws.rs.Path; | ||
| import jakarta.ws.rs.PathParam; | ||
| import jakarta.ws.rs.Produces; | ||
| import jakarta.ws.rs.QueryParam; | ||
| import jakarta.ws.rs.core.MediaType; | ||
| import jakarta.ws.rs.core.Response; | ||
|
|
||
|
|
@@ -20,6 +22,7 @@ | |
| import org.eclipse.microprofile.openapi.annotations.extensions.Extension; | ||
| import org.eclipse.microprofile.openapi.annotations.media.Content; | ||
| import org.eclipse.microprofile.openapi.annotations.media.Schema; | ||
| import org.eclipse.microprofile.openapi.annotations.parameters.Parameter; | ||
| import org.eclipse.microprofile.openapi.annotations.responses.APIResponse; | ||
| import org.eclipse.microprofile.openapi.annotations.responses.APIResponses; | ||
| import org.eclipse.microprofile.openapi.annotations.tags.Tag; | ||
|
|
@@ -35,7 +38,11 @@ public interface ClientsApi { | |
| @APIResponses(value = { | ||
| @APIResponse(responseCode = "200", content = @Content(schema = @Schema(type = SchemaType.ARRAY, implementation = BaseClientRepresentation.class))) | ||
| }) | ||
| Stream<BaseClientRepresentation> getClients(); | ||
| Stream<BaseClientRepresentation> getClients(@Parameter(description = "Set of fields to include in the response. Must be top-level fields on one of the client types. If omitted or empty, all fields will be populated.") @QueryParam("fields") Set<String> fields); | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. IIUC
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. See #48605
shawkins marked this conversation as resolved.
Resolved
|
||
|
|
||
| default Stream<BaseClientRepresentation> getClients() { | ||
| return getClients(Set.of()); | ||
| } | ||
|
|
||
| @POST | ||
| @Consumes(MediaType.APPLICATION_JSON) | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,81 +1,96 @@ | ||
| package org.keycloak.models.mapper; | ||
|
|
||
| import java.util.HashSet; | ||
| import java.util.LinkedHashMap; | ||
| import java.util.LinkedHashSet; | ||
| import java.util.Map; | ||
| import java.util.Set; | ||
| import java.util.function.BiConsumer; | ||
| import java.util.function.Function; | ||
| import java.util.stream.Collectors; | ||
|
|
||
| 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.BaseClientRepresentation; | ||
|
|
||
| /** | ||
| * @author Vaclav Muzikar <vmuzikar@redhat.com> | ||
| */ | ||
| public abstract class BaseClientModelMapper<T extends BaseClientRepresen 8 tation&g 10BC8 t; implements ClientModelMapper { | ||
| protected final KeycloakSession session; | ||
|
|
||
| public BaseClientModelMapper(KeycloakSession session) { | ||
| this.session = session; | ||
|
|
||
| public static class MappedField<T> { | ||
|
|
||
| Function<T, Object> repGetter; | ||
| BiConsumer<T, Object> repSetter; | ||
| Function<ClientModel, Object> modelGetter; | ||
| BiConsumer<ClientModel, Object> modelSetter; | ||
|
|
||
| void fromModel(ClientModel model, T rep) { | ||
| if (repSetter != null && modelGetter != null) { | ||
| repSetter.accept(rep, modelGetter.apply(model)); | ||
| } | ||
| } | ||
|
|
||
| void toModel(T rep, ClientModel model) { | ||
| if (hasGetter() && modelSetter != null) { | ||
| // TODO: exception handling to make things clearer when things fail | ||
| modelSetter.accept(model, getValue(rep)); | ||
| } | ||
| } | ||
|
|
||
| public boolean hasGetter() { | ||
| return repGetter != null; | ||
| } | ||
|
|
||
| public <V> V getValue(T rep) { | ||
| if (repGetter != null) { | ||
| return (V) repGetter.apply(rep); | ||
| } | ||
| return null; | ||
| } | ||
| } | ||
|
|
||
|
|
||
| final Map<String, MappedField<BaseClientRepresentation>> fields = new LinkedHashMap<String, MappedField<BaseClientRepresentation>>(); | ||
|
|
||
| protected <F> void addMapping(String name, Function<T, F> repGetter, BiConsumer<T, F> repSetter, Function<ClientModel, F> modelGetter, BiConsumer<ClientModel, F> modelSetter) { | ||
| MappedField prop = new MappedField<>(); | ||
| prop.repGetter = repGetter; | ||
| prop.repSetter = repSetter; | ||
| prop.modelGetter = modelGetter; | ||
| prop.modelSetter = modelSetter; | ||
| this.fields.put(name, prop); | ||
| } | ||
|
|
||
| public BaseClientModelMapper() { | ||
| this.addMapping("protocol", BaseClientRepresentation::getProtocol, null, ClientModel::getProtocol, ClientModel::setProtocol); | ||
| this.addMapping("uuid", BaseClientRepresentation::getUuid, BaseClientRepresentation::setUuid, ClientModel::getId, null); | ||
| this.addMapping("enabled", BaseClientRepresentation::getEnabled, BaseClientRepresentation::setEnabled, ClientModel::isEnabled, (model, enabled) -> model.setEnabled(Boolean.TRUE.equals(enabled))); | ||
| this.addMapping("clientId", BaseClientRepresentation::getClientId, BaseClientRepresentation::setClientId, ClientModel::getClientId, ClientModel::setClientId); | ||
| this.addMapping("description", BaseClientRepresentation::getDescription, BaseClientRepresentation::setDescription, ClientModel::getDescription, ClientModel::setDescription); | ||
| this.addMapping("displayName", BaseClientRepresentation::getDisplayName, BaseClientRepresentation::setDisplayName, ClientModel::getName, ClientModel::setName); | ||
| this.addMapping("appUrl", BaseClientRepresentation::getAppUrl, BaseClientRepresentation::setAppUrl, ClientModel::getBaseUrl, ClientModel::setBaseUrl); | ||
| // TODO: consider built-in logic for copying collections | ||
| this.addMapping("redirectUris", BaseClientRepresentation::getRedirectUris, BaseClientRepresentation::setRedirectUris, model -> new LinkedHashSet<>(model.getRedirectUris()), (model, uris) -> model.setRedirectUris(new LinkedHashSet<>(uris))); | ||
| this.addMapping("roles", BaseClientRepresentation::getRoles, BaseClientRepresentation::setRoles, model -> model.getRolesStream().map(RoleModel::getName).collect(Collectors.toSet()), null); | ||
| } | ||
|
|
||
| @Override | ||
| public BaseClientRepresentation fromModel(ClientModel model) { | ||
| public BaseClientRepresentation fromModel(ClientModel model, Set<String> includeFields) { | ||
| // We don't want reps to depend on any unnecessary fields deps, hence no generated builder. | ||
|
|
||
| T rep = createClientRepresentation(); | ||
|
|
||
| rep.setUuid(model.getId()); | ||
| rep.setEnabled(model.isEnabled()); | ||
| rep.setClientId(model.getClientId()); | ||
| rep.setDescription(model.getDescription()); | ||
| rep.setDisplayName(model.getName()); | ||
| rep.setAppUrl(model.getBaseUrl()); | ||
| rep.setRedirectUris(new HashSet<>(model.getRedirectUris())); | ||
| rep.setRoles(model.getRolesStream().map(RoleModel::getName).collect(Collectors.toSet())); | ||
|
|
||
| fromModelSpecific(model, rep); | ||
|
|
||
| var stream = fields.entrySet().stream(); | ||
| if (includeFields != null && !includeFields.isEmpty()) { | ||
| stream = stream.filter(e -> includeFields.contains(e.getKey())); | ||
| } | ||
| stream.forEach(e -> e.getValue().fromModel(model, rep)); | ||
|
|
||
| return rep; | ||
| } | ||
|
|
||
| @Override | ||
| @SuppressWarnings("unchecked") | ||
| public ClientModel toModel(BaseClientRepresentation rep, ClientModel existingModel) { | ||
| if (existingModel == null) { | ||
| existingModel = createClientModel(rep); | ||
| } | ||
|
|
||
| existingModel.setProtocol(rep.getProtocol()); | ||
| existingModel.setEnabled(Boolean.TRUE.equals(rep.getEnabled())); | ||
| existingModel.setClientId(rep.getClientId()); | ||
| existingModel.setDescription(rep.getDescription()); | ||
| existingModel.setName(rep.getDisplayName()); | ||
| existingModel.setBaseUrl(rep.getAppUrl()); | ||
| existingModel.setRedirectUris(new HashSet<>(rep.getRedirectUris())); | ||
| // Roles are not handled here | ||
|
|
||
| toModelSpecific((T) rep, existingModel); | ||
|
|
||
| return existingModel; | ||
| } | ||
|
|
||
| protected ClientModel createClientModel(BaseClientRepresentation rep) { | ||
| RealmModel realm = session.getContext().getRealm(); | ||
|
|
||
| // dummy add/remove to obtain a detached model | ||
| var model = realm.addClient(rep.getClientId()); | ||
| realm.removeClient(model.getId()); | ||
| return model; | ||
| public void toModel(BaseClientRepresentation rep, ClientModel existingModel) { | ||
| fields.values().forEach(m -> m.toModel(rep, existingModel)); | ||
| } | ||
|
|
||
| protected abstract T createClientRepresentation(); | ||
|
|
||
| protected abstract void fromModelSpecific(ClientModel model, T rep); | ||
|
|
||
| protected abstract void toModelSpecific(T rep, ClientModel model); | ||
|
|
||
| @Override | ||
| public void close() { | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,11 +1,10 @@ | ||
| package org.keycloak.models.mapper; | ||
|
|
||
| import org.keycloak.models.ClientModel; | ||
| import org.keycloak.provider.Provider; | ||
| import org.keycloak.representations.admin.v2.BaseClientRepresentation; | ||
|
|
||
| /** | ||
| * @author Vaclav Muzikar <vmuzikar@redhat.com> | ||
| */ | ||
| public interface ClientModelMapper extends Provider, RepModelMapper<BaseClientRepresentation, ClientModel> { | ||
| public interface ClientModelMapper extends RepModelMapper<BaseClientRepresentation, ClientModel> { | ||
| } |
This file was deleted.
This file was deleted.
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,26 @@ | ||
| package org.keycloak.models.mapper; | ||
| import java.util.Map; | ||
| import java.util.Optional; | ||
|
|
||
| import org.keycloak.representations.admin.v2.OIDCClientRepresentation; | ||
| import org.keycloak.representations.admin.v2.SAMLClientRepresentation; | ||
|
|
||
| public class ClientModelMappers { | ||
|
|
||
| private final Map<String, BaseClientModelMapper<?>> mappers; | ||
|
|
||
| public ClientModelMappers() { | ||
| // TODO: this may be done via discovery later | ||
| mappers = Map.of(OIDCClientRepresentation.PROTOCOL, new OIDCClientModelMapper(), | ||
| SAMLClientRepresentation.PROTOCOL, new SAMLClientModelMapper()); | ||
| } | ||
|
|
||
| public boolean isKnownField(String name) { | ||
| return mappers.values().stream().anyMatch(f -> f.fields.containsKey(name)); | ||
| } | ||
|
|
||
| public Optional<BaseClientModelMapper<?>> getMapper(String protocol) { | ||
| return Optional.ofNullable(mappers.get(protocol)); | ||
| } | ||
|
|
||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -2,58 +2,51 @@ | |
|
|
||
| import java.util.Collections; | ||
| import java.util.HashSet; | ||
| import java.util.LinkedHashSet; | ||
| import java.util.Set; | ||
| import java.util.stream.Collectors; | ||
|
|
||
| import org.keycloak.models.ClientModel; | ||
| import org.keycloak.models.KeycloakSession; | ||
| import org.keycloak.models.RoleModel; | ||
| import org.keycloak.representations.admin.v2.OIDCClientRepresentation; | ||
| import org.keycloak.representations.admin.v2.OIDCClientRepresentation.Auth; | ||
| import org.keycloak.utils.KeycloakSessionUtil; | ||
|
|
||
| /** | ||
| * @author Vaclav Muzikar <vmuzikar@redhat.com> | ||
| */ | ||
| public class OIDCClientModelMapper extends BaseClientModelMapper<OIDCClientRepresentation> { | ||
| public OIDCClientModelMapper(KeycloakSession session) { | ||
| super(session); | ||
| } | ||
|
|
||
| @Override | ||
| protected OIDCClientRepresentation createClientRepresentation() { | ||
| return new OIDCClientRepresentation(); | ||
| } | ||
|
|
||
| public OIDCClientModelMapper() { | ||
| addMapping("loginFlows", OIDCClientRepresentation::getLoginFlows, OIDCClientRepresentation::setLoginFlows, model -> createLoginFlows(model), (model, flows) -> setModelFromFlows(flows, model)); | ||
| addMapping("auth", OIDCClientRepresentation::getAuth, OIDCClientRepresentation::setAuth, model -> getAuth(model), (model, auth) -> setAuth(model, auth)); | ||
| addMapping("webOrigins", OIDCClientRepresentation::getWebOrigins, OIDCClientRepresentation::setWebOrigins, model -> new LinkedHashSet<>(model.getWebOrigins()), (model, webOrigins) -> model.setWebOrigins(new LinkedHashSet<>(webOrigins))); | ||
| addMapping("serviceAccountRoles", OIDCClientRepresentation::getServiceAccountRoles, OIDCClientRepresentation::setServiceAccountRoles, model -> getServiceAccountRoles(model), null); | ||
| } | ||
|
|
||
| @Override | ||
| protected void fromModelSpecific(ClientModel model, OIDCClientRepresentation rep) { | ||
| rep.setLoginFlows(createLoginFlows(model)); | ||
|
|
||
| private OIDCClientRepresentation.Auth getAuth(ClientModel model) { | ||
| OIDCClientRepresentation.Auth auth = null; | ||
| if (!model.isPublicClient()) { | ||
| OIDCClientRepresentation.Auth auth = new OIDCClientRepresentation.Auth(); | ||
| auth = new OIDCClientRepresentation.Auth(); | ||
| auth.setMethod(model.getClientAuthenticatorType()); | ||
| auth.setSecret(model.getSecret()); | ||
| rep.setAuth(auth); | ||
| // TODO: auth.certificate | ||
| } | ||
|
|
||
| rep.setWebOrigins(new HashSet<>(model.getWebOrigins())); | ||
| rep.setServiceAccountRoles(getServiceAccountRoles(model)); | ||
| return auth; | ||
| } | ||
|
|
||
| @Override | ||
| protected void toModelSpecific(OIDCClientRepresentation rep, ClientModel model) { | ||
| if (rep.getAuth() != null) { | ||
| private void setAuth(ClientModel model, Auth auth) { | ||
| if (auth != null) { | ||
| model.setPublicClient(false); | ||
| model.setClientAuthenticatorType(rep.getAuth().getMethod()); | ||
| model.setSecret(rep.getAuth().getSecret()); | ||
| model.setClientAuthenticatorType(auth.getMethod()); | ||
| model.setSecret(auth.getSecret()); | ||
| } else { | ||
| model.setPublicClient(true); | ||
| } | ||
|
|
||
| setModelFromFlows(rep.getLoginFlows(), model); | ||
|
|
||
| model.setWebOrigins(new HashSet<>(rep.getWebOrigins())); | ||
|
|
||
| // Service account roles are not handled here | ||
| } | ||
|
|
||
| private Set<OIDCClientRepresentation.Flow> createLoginFlows(ClientModel model) { | ||
|
|
@@ -82,7 +75,7 @@ private void setModelFromFlows(Set<OIDCClientRepresentation.Flow> flows, ClientM | |
|
|
||
| private Set<String> getServiceAccountRoles(ClientModel client) { | ||
| if (client.isServiceAccountsEnabled()) { | ||
| var serviceAccount = session.users().getServiceAccount(client); | ||
| var serviceAccount = KeycloakSessionUtil.getKeycloakSession().users().getServiceAccount(client); | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. When reviewing this change, it really surprised me that Keycloak trusts ThreadLocal, rather than propagation session via storage based on what is available (Arc or some test storage based on thread local). But it appears that all endpoints are blocking and as long as Keycloak never does reactive / async code (I found the health checks, but they don't need session), there isn't any functional issue.
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. We have filters that keep the request scoped session bean the same as what is in the threadlocal. Just like with our main services module we don't have a direct dependency on quarkus so that we can directly utilize Arc.
Correct, the assumption here is that we're always dealing with a blocking call. See a recent issue with our assumptions about transactions / session handling that has come up with non-blocking work https://github.com/keycloak/keycloak/pull/48471/changes#diff-a7df5c95700a743072dd7321dd8a6df9e71cb775e2490afb0e52b04ee9c8f758R38 |
||
| if (serviceAccount != null) { | ||
| return serviceAccount.getRoleMappingsStream() | ||
| .map(RoleModel::getName) | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I think the description should explain how do I select nested object fields. What if I want the
auth.methodbut notauth.secret?There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Top-level here should mean just direct children. It's not possible in this PR to specify field.nested.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
See also #48604
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I missed the
top-level. I think I concentrated on purpose of\. My bad.