Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,7 @@
import org.keycloak.storage.user.UserQueryMethodsProvider;
import org.keycloak.storage.user.UserQueryProvider;
import org.keycloak.storage.user.UserRegistrationProvider;
import org.keycloak.storage.user.UserServiceAccountProvider;
import org.keycloak.tracing.TracingProvider;
import org.keycloak.userprofile.AttributeMetadata;
import org.keycloak.userprofile.UserProfileDecorator;
Expand Down Expand Up @@ -498,8 +499,11 @@ private static Stream<UserModel> removeDuplicates(Stream<UserModel> withDuplicat
@Override
public UserModel addUser(RealmModel realm, String username) {
if (username.startsWith(ServiceAccountConstants.SERVICE_ACCOUNT_USER_PREFIX)) {
// Don't use federation for service account user
return localStorage().addUser(realm, username);
return getEnabledStorageProviders(realm, UserServiceAccountProvider.class)
.map(provider -> provider.addServiceAccountUser(realm, username))
.filter(Objects::nonNull)
.findFirst()
.orElseGet(() -> localStorage().addUser(realm, username));
}

return getEnabledStorageProviders(realm, UserRegistrationProvider.class)
Expand All @@ -511,7 +515,11 @@ public UserModel addUser(RealmModel realm, String username) {

@Override
public boolean removeUser(RealmModel realm, UserModel user) {
if (getFederatedStorage() != null && user.getServiceAccountClientLink() == null) {
// Skip federated-storage cleanup for locally stored service accounts to avoid issuing
// unnecessary FED_USER_* deletes on client removal. Externally stored service accounts
// may have sidecar rows and must still be cleaned up.
if (getFederatedStorage() != null
&& (user.getServiceAccountClientLink() == null || !StorageId.isLocalStorage(user.getId()))) {
getFederatedStorage().preRemove(realm, user);
}

Expand Down Expand Up @@ -1054,7 +1062,13 @@ public UserModel getUserByFederatedIdentity(RealmModel realm, FederatedIdentityM

@Override
public UserModel getServiceAccount(ClientModel client) {
return localStorage().getServiceAccount(client);
UserModel user = localStorage().getServiceAccount(client);
if (user != null) return user;
return getEnabledStorageProviders(client.getRealm(), UserServiceAccountProvider.class)
.map(provider -> provider.getServiceAccount(client))
.filter(Objects::nonNull)
.findFirst()
.orElse(null);
}

@Override
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -254,21 +254,11 @@ public void setFederationLink(String link) {

}

/**
* This method should not be overridden
*
* @return
*/
@Override
public String getServiceAccountClientLink() {
return null;
}

/**
* This method should not be overridden
*
* @return
*/
@Override
public void setServiceAccountClientLink(String clientInternalId) {
throw new ReadOnlyException("user is read only for this update");
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -259,21 +259,11 @@ public void setFederationLink(String link) {

}

/**
* This method should not be overridden
*
* @return
*/
@Override
public String getServiceAccountClientLink() {
return null;
}

/**
* This method should not be overridden
*
* @return
*/
@Override
public void setServiceAccountClientLink(String clientInternalId) {

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
/*
* Copyright 2026 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.
*/
package org.keycloak.storage.user;

import org.keycloak.models.ClientModel;
import org.keycloak.models.RealmModel;
import org.keycloak.models.UserModel;

/**
* This is an optional capability interface that is intended to be implemented by any
* {@code UserStorageProvider} that supports storing service account users.
*
* <p>By default, Keycloak stores service account users in local (JPA) storage. Providers
* that implement this interface can intercept service account creation and lookup,
* allowing service account users to be stored in an external store.
*
* <p>For service account creation, all enabled storage providers implementing this interface are tried in priority order.
* If a provider returns null, the next provider is tried; if none handle creation, local storage is used as a fallback.
*
* <p>For service account lookup, local storage is consulted first for backward compatibility; if not found, providers are queried in the same order.
*
* <p>This capability extends {@link UserRegistrationProvider} because service account users must
* be removable when the owning client is deleted or has service accounts disabled.
*
* <p>This interface currently resides in the private SPI ({@code server-spi-private}).
* Promoting it to the public {@code server-spi} is planned once the contract is proven.
*/
public interface UserServiceAccountProvider extends UserRegistrationProvider {

/**
* Creates a service account user in this storage provider.
*
* <p>If this method returns null, then the next storage provider's method will be called.
* If no storage providers handle the creation, the user will be created in local storage.
*
* <p>The returned {@link UserModel} must support {@link UserModel#setEnabled(boolean)},
* {@link UserModel#setServiceAccountClientLink(String)}, and {@link UserModel#setUsername(String)}.
* The username must be mutable because {@code ClientManager.clientIdChanged} renames
* the service account user whenever the owning client's {@code clientId} is updated.
* The client link must be durably persisted so that it survives across sessions and is
* returned by {@link UserModel#getServiceAccountClientLink()} on subsequent lookups.
*
* @param realm a reference to the realm
* @param username the username for the service account (prefixed with "service-account-")
* @return a model of the created user, or null if this provider does not handle the request
*/
UserModel addServiceAccountUser(RealmModel realm, String username);

/**
* Returns a UserModel representing the service account of the given client.
*
* <p>If this method returns null, then the next storage provider will be queried.
*
* @param client the client model whose service account user is being looked up
* @return the service account user model, or null if not managed by this provider
*/
UserModel getServiceAccount(ClientModel client);
}
Loading
Loading