8000 New client service - PUT/POST - update or create client by mabartos · Pull Request #47029 · keycloak/keycloak · GitHub
[go: up one dir, main page]

Skip to content

New client service - PUT/POST - update or create client - #47029

Merged
vmuzikar merged 4 commits into
keycloak:mainfrom
mabartos:new-service-layer-v2-create
Apr 13, 2026
Merged

vmuzikar merged 4 commits into
keycloak:mainfrom
mabartos:new-service-layer-v2-create

Conversation

@mabartos
@mabartos mabartos commented Mar 10, 2026 •
Copy link
Copy Markdown
Member

@mabartos mabartos self-assigned this Mar 10, 2026
@mabartos
mabartos force-pushed the new-service-layer-v2-create branch from 2c4bc7f to fb97143 Compare March 10, 2026 14:40
@mabartos
mabartos force-pushed the new-service-layer-v2-create branch from fb97143 to 9c62205 Compare March 19, 2026 09:25
@mabartos
mabartos requested a review from Copilot March 19, 2026 09:25
Copilot AI left a comment
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR implements a new client service (NewClientService) that handles PUT/POST operations to create or update clients without relying on the Admin API v1. The implementation includes:

  1. NewClientService: A new service class that extends DefaultClientService and provides dedicated create/update logic with proper validation and client policy trigger handling
  2. RolesService: A new utility service to manage client roles through RoleContainerResource instances
  3. ClientManager enhancements: Added methods to safely handle service account management with Optional-based return types
  4. Bug fixes: Fixed a typo in AdminClientUpdatedContext and refactored service account logic in ClientResource

Changes:

  • Implemented createClient() and createOrUpdateClient() methods in NewClientService
  • Added RolesService for declarative role management
  • Refactored service account handling to use ClientManager static methods
  • Fixed typo in constructor parameter name
  • Added protocol setting to BaseClientModelMapper for consistency

Reviewed changes

Copilot reviewed 9 out of 9 changed files in this pull request and generated 2 comments.

Show a summary per file
File Description
rest/admin-v2/api/src/main/java/org/keycloak/services/client/NewClientService.java Main implementation of new client service with create/update logic
rest/admin-v2/api/src/main/java/org/keycloak/services/RolesService.java New service for declarative role management
rest/admin-v2/api/src/main/java/org/keycloak/services/client/DefaultClientService.java Refactored handleServiceAccount and handleRoles methods to use RoleContainerResource
services/src/main/java/org/keycloak/services/managers/ClientManager.java Added getServiceAccountUser() and updateClientServiceAccount() methods
services/src/main/java/org/keycloak/services/resources/admin/RoleContainerResource.java Added new constructor and setter for flexible initialization
rest/admin-v2/api/src/main/java/org/keycloak/models/mapper/BaseClientModelMapper.java Added protocol setting in toModel method
services/src/main/java/org/keycloak/services/resources/admin/ClientResource.java Removed unused import, refactored service account logic
services/src/main/java/org/keycloak/services/clientpolicy/context/AdminClientUpdatedContext.java Fixed typo in parameter name
rest/admin-v2/tests/src/test/java/org/keycloak/tests/admin/client/v2/ClientPoliciesV2Test.java Added conditional assertions for legacy vs new service

You can also share your feedback on Copilot code review. Take the survey.

@mabartos
mabartos force-pushed the new-service-layer-v2-create branch from 9c62205 to d7ab2a2 Compare March 19, 2026 10:38
@mabartos
mabartos requested a review from Copilot March 19, 2026 10:39
Copilot AI left a comment
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This pull request implements the new client service's PUT/POST logic for creating and updating clients without relying on Admin API v1. The implementation extracts and unifies common roles and service account handling logic, creates a new RolesService for better separation of concerns, and fixes a bug where protocol wasn't being mapped correctly during updates.

Changes:

  • Implements createOrUpdate method in NewClientService with full validation and policy event triggers
  • Extracts service account logic to ClientManager with static methods for broader reusability
  • Creates new RolesService for decoupled role resource management
  • Enhances RoleContainerResource with flexible initialization via new constructor and setter
  • Fixes protocol mapping bug in BaseClientModelMapper by ensuring protocol is set during updates
  • Updates test assertions to accommodate differences between legacy and new client service implementations

Reviewed changes

Copilot reviewed 9 out of 9 changed files in this pull request and generated no comments.

Show a summary per file
File Description
AdminClientUpdatedContext.java Fixes typo in parameter name from "roposed" to "proposed"
BaseClientModelMapper.java Adds protocol setting for existing models to fix update bug
ClientManager.java Adds public methods for service account management and Optional-returning helper
ClientResource.java Refactors to use new ClientManager methods, removing duplicate code
RoleContainerResource.java Adds new constructor and setter for flexible role container initialization
RolesService.java New service class for managing role resources with proper dependency injection
NewClientService.java Implements complete createOrUpdate logic with validation, policy events, and role/service account handling
DefaultClientService.java Refactors handleRoles and handleServiceAccount to use RoleContainerResource directly
ClientPoliciesV2Test.java Adds transaction rollback verification and conditional assertions for service implementation differences

You can also share your feedback on Copilot code review. Take the survey.

@mabartos
mabartos marked this pull request as ready for review March 19, 2026 11:16
@mabartos
mabartos requested review from a team as code owners March 19, 2026 11:16
@mabartos
mabartos force-pushed the new-service-layer-v2-create branch from d7ab2a2 to 6175e6b Compare March 19, 2026 15:17
@vmuzikar
vmuzikar force-pushed the new-service-layer-v2-create branch from 6175e6b to d653280 Compare March 25, 2026 17:19
@vmuzikar
vmuzikar requested a review from Pepo48 March 25, 2026 17:20
@vmuzikar
Copy link
Copy Markdown
Contributor

@shawkins @Pepo48 Could you please review as well?

vmuzikar
vmuzikar previously approved these changes Mar 25, 2026
@vmuzikar vmuzikar left a comment
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@mabartos Thank you for the changes. In general LGTM, but not merging yet as I'd like to get some additional reviews.

* Required because client policy contexts expect ClientRepresentation (v1), but there's no
* direct converter from BaseClientRepresentation (v2 API). The temp client is immediately removed.
*/
private ClientRepresentation getProposedOldRepresentation(RealmModel realm, BaseClientRepresentation client, ClientModelMapper mapper) {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

As discussed offline, this is not ideal. @mabartos Can you please create a follow-up issue to look into how we could resolve that so we don't need to create fake clients?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could we directly maps v2 fields to a v1 ClientRepresentation in memory? (see suggestion below)

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@Pepo48 Commented on the other comment.

@mabartos
mabartos commented Mar 27, 2026 •
Copy link
Copy Markdown
Member Author

We should wait for the:

Or we can make some additional changes in follow-ups - I don't have strong preferences.

@mabartos mabartos added the status/hold PR should not be merged. On hold for later. label Mar 27, 2026
@mabartos
mabartos force-pushed the new-service-layer-v2-create branch 2 times, most recently from 04877d4 to 4297d09 Compare March 27, 2026 16:29
@keycloak-github-bot keycloak-github-bot Bot left a comment
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Unreported flaky test detected, please review

@keycloak-github-bot
Copy link
Copy Markdown

Unreported flaky test detected

If the flaky tests below are affected by the changes, please review and update the changes accordingly. Otherwise, a maintainer should report the flaky tests prior to merging the PR.

org.keycloak.testsuite.broker.KcOidcBrokerTest#loginWithExistingUserWithBruteForceEnabled

Keycloak CI - Java Distribution IT (windows-latest - temurin - 17)

org.openqa.selenium.TimeoutException: 
Expected condition failed: waiting for value to contain (ignoring case) "sign in to". Current value: "AUTH_RESPONSE" (tried for 5 second(s) with 500 milliseconds interval)
Build info: version: '4.28.1', revision: '73f5ad48a2'
System info: os.name: 'Windows Server 2025', os.arch: 'amd64', os.version: '10.0', java.version: '17.0.18'
Driver info: org.jboss.arquillian.drone.webdriver.htmlunit.DroneHtmlUnitDriver_ByGraphene
...

Report flaky test

@mabartos
mabartos force-pushed the new-service-layer-v2-create branch from 4297d09 to 2bfabc7 Compare March 30, 2026 10:09
@mabartos
Copy link
Copy Markdown
Member Author

@vmuzikar Your comments should be addressed now.

@mabartos
mabartos requested review from a team and vmuzikar March 31, 2026 10:20
@Pepo48 Pepo48 left a comment
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@mabartos thanks for the PR! I added a couple of suggestions below.

* Required because client policy contexts expect ClientRepresentation (v1), but there's no
* direct converter from BaseClientRepresentation (v2 API). The temp client is immediately removed.
*/
private ClientRepresentation getProposedOldRepresentation(RealmModel realm, BaseClientRepresentation client, ClientModelMapper mapper) {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could we directly maps v2 fields to a v1 ClientRepresentation in memory? (see suggestion below)

Comment on lines +233 to 251
/**
* Creates a temporary client to convert BaseClientRepresentation to ClientRepresentation.
* Required because client policy contexts expect ClientRepresentation (v1), but there's no
* direct converter from BaseClientRepresentation (v2 API). The temp client is immediately removed.
* <p>
* For more details, see the <a href="https://github.com/keycloak/keycloak/issues/47576">keycloak#47576</a>.
*/
private ClientRepresentation getProposedOldRepresentation(RealmModel realm, BaseClientRepresentation client, ClientModelMapper mapper) {
String tempId = "__temp__" + client.getClientId() + "__" + System.nanoTime();
ClientModel tempModel = mapper.toModel(client, realm.addClient(tempId));
try {
var proposedRepresentation = ModelToRepresentation.toRepresentation(tempModel, session);
proposedRepresentation.setClientId(client.getClientId());
return proposedRepresentation;
} finally {
realm.removeClient(tempModel.getId());
}
}
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
/**
* Creates a temporary client to convert BaseClientRepresentation to ClientRepresentation.
* Required because client policy contexts expect ClientRepresentation (v1), but there's no
* direct converter from BaseClientRepresentation (v2 API). The temp client is immediately removed.
* <p>
* For more details, see the <a href="https://github.com/keycloak/keycloak/issues/47576">keycloak#47576</a>.
*/
private ClientRepresentation getProposedOldRepresentation(RealmModel realm, BaseClientRepresentation client, ClientModelMapper mapper) {
String tempId = "__temp__" + client.getClientId() + "__" + System.nanoTime();
ClientModel tempModel = mapper.toModel(client, realm.addClient(tempId));
try {
var proposedRepresentation = ModelToRepresentation.toRepresentation(tempModel, session);
proposedRepresentation.setClientId(client.getClientId());
return proposedRepresentation;
} finally {
realm.removeClient(tempModel.getId());
}
}
}
/**
* Converts a v2 BaseClientRepresentation to a v1 ClientRepresentation without creating
* any database entities. This is needed because client policy contexts expect ClientRepresentation (v1).
* <p>
* For more details, see the <a href="https://github.com/keycloak/keycloak/issues/47576">keycloak#47576</a>.
*/
private ClientRepresentation toProposedV1Representation(BaseClientRepresentation client) {
ClientRepresentation rep = new ClientRepresentation();
rep.setClientId(client.getClientId());
rep.setName(client.getDisplayName());
rep.setDescription(client.getDescription());
rep.setEnabled(Boolean.TRUE.equals(client.getEnabled()));
rep.setBaseUrl(client.getAppUrl());
rep.setRootUrl(client.getAppUrl());
rep.setProtocol(client.getProtocol());
rep.setRedirectUris(client.getRedirectUris() != null ? new ArrayList<>(client.getRedirectUris()) : null);
if (client instanceof OIDCClientRepresentation oidc) {
boolean isPublic = oidc.getAuth() == null;
rep.setPublicClient(isPublic);
if (!isPublic) {
rep.setClientAuthenticatorType(oidc.getAuth().getMethod());
rep.setSecret(oidc.getAuth().getSecret());
}
Set<OIDCClientRepresentation.Flow> flows = Optional.ofNullable(oidc.getLoginFlows()).orElse(Collections.emptySet());
rep.setStandardFlowEnabled(flows.contains(OIDCClientRepresentation.Flow.STANDARD));
rep.setImplicitFlowEnabled(flows.contains(OIDCClientRepresentation.Flow.IMPLICIT));
rep.setDirectAccessGrantsEnabled(flows.contains(OIDCClientRepresentation.Flow.DIRECT_GRANT));
rep.setServiceAccountsEnabled(flows.contains(OIDCClientRepresentation.Flow.SERVICE_ACCOUNT));
if (oidc.getWebOrigins() != null) {
rep.setWebOrigins(new ArrayList<>(oidc.getWebOrigins()));
}
}
return rep;
}
}

This should preserve the exact semantics for client policy executors.
I also did some checks with this fix in place, all fields seems to be mapped and ClientApiV2Test, ClientPoliciesV2Test, PatchClientValidationTest, PostClientValidationTest and PutClientValidationTest passed.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hmm, not sure what's better. I'm still inclining to reuse the model for this to prevent any maintenance burden - as providing a new field to the representation, you need to keep track of places that touch that.

@vmuzikar @Pepo48 WDYT? Or would you like to complete it in a follow-up?

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Creating a model is far beyond ideal here. However, so is maintaining another mapping logic (v2 --> v1 rep). Let's keep as is and reassess in a follow-up.

Comment on lines 45 to 70
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
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;
}

I think this is just a leftover the single-arg toModel(rep) was used in the original mapper commit (421835c1e6) but was abandoned by the time DefaultClientService introduction (see the comment on the line 165 that says that we can't use mapper.toModel(client) directly for creation).

Thus, I believe createClientModel() here and the default toModel(rep) in RepModelMapper can be cleaned up.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I guess it's related to your comment here: #47029 (comment)

@Pepo48 @vmuzikar Are you ok with cleaning this up in the follow-up? Could you please create an issue?

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Let's tackle this under #47576.

Comment on lines +216 to +218
if (model == null) {
throw new ServiceException("Cannot create/update client");
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

model is always non-null at this point, no?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed

assertSameClientIds(clientId, client.getClientId());
}

ClientModel model = avoidClientIdPhishing(realm.getClientByClientId(clientId)).orElse(null);
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I know it's probably an edge case (since even in the tests we always grant both VIEW and MANAGE together), but about scenario, when a user has MANAGE permission but no realm roles (canManage() = true, canList() = false). I suspect that for MANAGE-only grant, the phishing check would block a legitimate creation with 403.

Regardless of it, isn't the phishing check is semantically wrong for create/update? Like there is no risk of an information leak during these operations, these are expected conditions and requireManage() and/or requireConfigure() should cover it in terms of authorization.

Suggested change
ClientModel model = avoidClientIdPhishing(realm.getClientByClientId(clientId)).orElse(null);
ClientModel model = realm.getClientByClientId(clientId);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@Pepo48 Were you able to reproduce? At least in FGAP v1 it seems view permissions also consider manage:

return canManageClientsDefault() || canViewClientDefault();

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, I tested it during the review. As I understand it, in V1, canView() checks for the MANAGE_CLIENTS role - not the FGAP manage scope. And since canManage() in V1 is purely role-based (MANAGE_CLIENTS role), any user who passes requireManage() automatically passes canList() too. The phishing check never blocks.

V2 introduced a new path: a user can pass requireManage() via FGAP manage scope without the MANAGE_CLIENTS role. But canView() in V2 only checks for the FGAP view scope - manage doesn't imply view. So canList() returns false, and avoidClientIdPhishing blocks before requireManage() ever runs.

tl;dr: avoidClientIdPhishing at line 157 was safe in V1 (role-based manage always implied list) but breaks in V2 (FGAP manage doesn't imply list).

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

As discussed offline, for the scope of this PR, let's just skip the phishing check on POST. There's a follow-up for a broader fix: #47804

@mabartos Can you please update the PR?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Updated.

Comment on lines +229 to +230
fireAdminEvent(alreadyExists ? OperationType.UPDATE : OperationType.CREATE, mapper.fromModel(model));
return new CreateOrUpdateResult(mapper.fromModel(model), !alreadyExists);
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

mapper.fromModel(model) called twice:

Suggested change
fireAdminEvent(alreadyExists ? OperationType.UPDATE : OperationType.CREATE, mapper.fromModel(model));
return new CreateOrUpdateResult(mapper.fromModel(model), !alreadyExists);
var result = mapper.fromModel(model);
fireAdminEvent(alreadyExists ? OperationType.UPDATE : OperationType.CREATE, result);
return new CreateOrUpdateResult(result, !alreadyExists);

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@Pepo48
Pepo48 commented Apr 2, 2026
Copy link
Copy Markdown
Contributor

During the review I noticed one more thing. In DefaultClientService class, handleServiceAccount method gathers both client and realm roles and then passes all of them to addRealmRoleMappings(), which internally calls realm.getRole(name). That returns null (NotFoundException) for client roles.

The behaviour was introduced in #44587, this PR just inherits it.

Same issue exists for deleteRealmRoleMappings(). The mapper (OIDCClientModelMapper.getServiceAccountRoles()) also returns all roles including client roles, so client role names would appear in GET but fail on PUT.

I think that for now it should be scoped to realm roles only until the roles v2 API #47631 lands.

To reproduce it:

/.kc.sh start-dev --features=client-admin-api
./kcadm.sh --v2 config credentials --server http://localhost:8080 --realm master --user admin --password admin
./kcadm.sh --v2 client create oidc --client-id client-bug --roles my-client-role --login-flows SERVICE_ACCOUNT --service-account-roles default-roles-master --auth-method client_secret
./kcadm.sh --v2 client update oidc client-bug --client-id client-bug --roles my-client-role --login-flows SERVICE_ACCOUNT --service-account-roles my-client-role --auth-method client_secret

10BC8 @mabartos
mabartos force-pushed the new-service-layer-v2-create branch from 2bfabc7 to ea40932 Compare April 8, 2026 16:07
@mabartos
mabartos commented Apr 8, 2026 •
Copy link
Copy Markdown
Member Author

During the review I noticed one more thing.

@Pepo48 Thanks for the detailed analysis! It'd be good if you could create follow-ups for your findings. WDYT?

Closes keycloak#46785

Signed-off-by: Martin Bartoš <mabartos@redhat.com>
Signed-off-by: Martin Bartoš <mabartos@redhat.com>
Signed-off-by: Martin Bartoš <mabartos@redhat.com>
Signed-off-by: Martin Bartoš <mabartos@redhat.com>
@mabartos
mabartos force-pushed the new-service-layer-v2-create branch from ea40932 to c5fb47f Compare April 10, 2026 12:08
@mabartos
Copy link
Copy Markdown
Member Author

Rebased

@Pepo48
Pepo48 commented Apr 10, 2026
Copy link
Copy Markdown
Contributor

It'd be good if you could create follow-ups for your findings. WDYT?

#47966

@vmuzikar vmuzikar left a comment
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM, thanks.

@Pepo48 Can you please re-review too?

@vmuzikar
vmuzikar enabled auto-merge (squash) April 13, 2026 13:44
@mabartos
mabartos requested a review from Pepo48 April 13, 2026 14:56
@Pepo48 Pepo48 left a comment
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks Martin!

@vmuzikar
vmuzikar merged commit 5cd043f into keycloak:main Apr 13, 2026
93 of 95 checks passed
@stianst stianst mentioned this pull request Jun 1, 2026
sashyo pushed a commit to tide-foundation/keycloak-IGA that referenced this pull request Jul 20, 2026
* New client service - PUT/POST - update or create client

Closes keycloak#46785

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

* Assert same payload and path clientId for PUT/PATCH as in legacy service

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

* Provide link to a spike around client policices rep

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

* Remove phishing check for CREATE

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

---------

Signed-off-by: Martin Bartoš <mabartos@redhat.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

flaky-test status/hold PR should not be merged. On hold for later. team/cloud-native

Projects

None yet

Development

Successfully merging this pull request may close these issues.

New client service - PUT/POST - update or create client

4 participants

0