8000 Service account user ID available on client representation instead of exposing separate endpoint returning whole user representation by vramik · Pull Request #54056 · keycloak/keycloak · GitHub
[go: up one dir, main page]

Skip to content
Merged
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 @@ -52,6 +52,7 @@ public class ClientRepresentation {
protected Boolean implicitFlowEnabled;
protected Boolean directAccessGrantsEnabled;
protected Boolean serviceAccountsEnabled;
protected String serviceAccountUserId;
protected Boolean authorizationServicesEnabled;
@Deprecated
protected Boolean directGrantsOnly;
Expand Down Expand Up @@ -276,6 +277,14 @@ public void setServiceAccountsEnabled(Boolean serviceAccountsEnabled) {
this.serviceAccountsEnabled = serviceAccountsEnabled;
}

public String getServiceAccountUserId() {
return serviceAccountUserId;
}

public void setServiceAccountUserId(String serviceAccountUserId) {
this.serviceAccountUserId = serviceAccountUserId;
}

public Boolean getAuthorizationServicesEnabled() {
if (authorizationSettings != null) {
return true;
Expand Down
9 changes: 9 additions & 0 deletions docs/documentation/release_notes/topics/27_0_0.adoc
Original file line number Diff line number Diff line change
@@ -1,3 +1,12 @@
= Administration

== Service account user ID available on client representation

The service account user ID is now included directly in the client representation as the `serviceAccountUserId` field.
This removes the need for a separate API call to discover the service account user, simplifying client administration and role mapping workflows.
The dedicated `GET /admin/realms/\{realm}/clients/\{id}/service-account-user` endpoint has been removed.
See the upgrading guide for migration details.

= Security and Standards

== Credential ID in credential update events
Expand Down
22 changes: 22 additions & 0 deletions docs/documentation/upgrading/topics/changes/changes-27_0_0.adoc
Original file line number Diff line number Diff line change
Expand Up @@ -141,6 +141,28 @@ Providers extending `OAuth2IdentityProviderConfig` can instead override `getClie
The Client Initiated Backchannel Authentication (CIBA) grant is now rejected for public clients, as required by the https://openid.net/specs/openid-client-initiated-backchannel-authentication-core-1_0.html#rfc.section.7.1[CIBA specification], which mandates client authentication on both the backchannel authentication endpoint and the token endpoint.
Previously, a public client with the CIBA grant enabled could complete the flow without authenticating and obtain tokens for the approving user.

=== Service account user endpoint removed from Admin REST API
// https://github.com/keycloak/keycloak/issues/54047

The `GET /admin/realms/\{realm}/clients/\{id}/service-account-user` endpoint has been removed.
The service account user ID is now available directly on the client representation as the `serviceAccountUserId` field, which is populated when `serviceAccountsEnabled` is `true`.
This field is read-only and managed by the server.

If you previously called this endpoint to obtain the service account user ID for role mapping or other operations, retrieve the client representation instead:

[source,java]
----
// Before:
UserRepresentation sa = clientResource.getServiceAccountUser();
String saUserId = sa.getId();

// After:
String saUserId = clientResource.toRepresentation().getServiceAccountUserId();
----

The `getServiceAccountUser()` method has also been removed from the Java and JavaScript admin client libraries.
If you need the full user representation for the service account, use the user ID from the client representation to fetch it via the Users API.

// ------------------------ Notable changes ------------------------ //
== Notable changes

Expand Down
10BC8
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,6 @@
import org.keycloak.representations.idm.CredentialRepresentation;
import org.keycloak.representations.idm.ManagementPermissionReference;
import org.keycloak.representations.idm.ManagementPermissionRepresentation;
import org.keycloak.representations.idm.UserRepresentation;
import org.keycloak.representations.idm.UserSessionRepresentation;

/**
Expand Down Expand Up @@ -204,11 +203,6 @@ public interface ClientResource {
@Path("optional-client-scopes/{clientScopeId}")
void removeOptionalClientScope(@PathParam("clientScopeId") String clientScopeId);

@Path("/service-account-user")
@GET
@Produces(MediaType.APPLICATION_JSON)
UserRepresentation getServiceAccountUser();

@Path("nodes")
@POST
@Consumes(MediaType.APPLICATION_JSON)
Expand Down
27 changes: 15 additions & 12 deletions js/apps/admin-ui/src/clients/service-account/ServiceAccount.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
import type ClientRepresentation from "@keycloak/keycloak-admin-client/lib/defs/clientRepresentation";
import type { RoleMappingPayload } from "@keycloak/keycloak-admin-client/lib/defs/roleRepresentation";
import type UserRepresentation from "@keycloak/keycloak-admin-client/lib/defs/userRepresentation";
import { useAlerts, useFetch } from "@keycloak/keycloak-ui-shared";
import { AlertVariant, PageSection } from "@patternfly/react-core";
import { InfoCircleIcon } from "@patternfly/react-icons";
Expand All @@ -27,36 +26,40 @@ export const ServiceAccount = ({ client }: ServiceAccountProps) => {
const { addAlert, addError } = useAlerts();
const { realm } = useRealm();

const [serviceAccount, setServiceAccount] = useState<UserRepresentation>();
const [serviceAccountUserId, setServiceAccountUserId] = useState<string>();

const { hasAccess } = useAccess();
const hasManageClients = hasAccess("manage-clients");

useFetch(
() =>
adminClient.clients.getServiceAccountUser({
async () => {
const freshClient = await adminClient.clients.findOne({
id: client.id!,
}),
(serviceAccount) => setServiceAccount(serviceAccount),
});
return freshClient?.serviceAccountUserId;
},
(id) => setServiceAccountUserId(id),
[],
);

const serviceAccountUsername = `service-account-${client.clientId}`;

const assignRoles = async (rows: Row[]) => {
try {
const realmRoles = rows
.filter((row) => row.client === undefined)
.map((row) => row.role as RoleMappingPayload)
.flat();
await adminClient.users.addRealmRoleMappings({
id: serviceAccount?.id!,
id: serviceAccountUserId!,
roles: realmRoles,
});
await Promise.all(
rows
.filter((row) => row.client !== undefined)
.map((row) =>
adminClient.users.addClientRoleMappings({
id: serviceAccount?.id!,
id: serviceAccountUserId!,
clientUniqueId: row.client!.id!,
roles: [row.role as RoleMappingPayload],
}),
Expand All @@ -67,24 +70,24 @@ export const ServiceAccount = ({ client }: ServiceAccountProps) => {
addError("roleMappingUpdatedError", error);
}
};
return serviceAccount ? (
return serviceAccountUserId ? (
<>
<PageSection className="pf-v5-u-pb-0">
<InfoCircleIcon className="pf-v5-c-alert__icon keycloak--service-account--info-text" />
<span className="pf-v5-u-pl-sm">
<Trans i18nKey="manageServiceAccountUser">
{""}
<Link
to={toUser({ realm, id: serviceAccount.id!, tab: "settings" })}
to={toUser({ realm, id: serviceAccountUserId, tab: "settings" })}
>
{{ link: serviceAccount.username }}
{{ link: serviceAccountUsername }}
</Link>
</Trans>
</span>
</PageSection>
<RoleMapping
name={client.clientId!}
id={serviceAccount.id!}
id={serviceAccountUserId}
type="users"
isManager={hasManageClients || client.access?.configure}
save={assignRoles}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ export default interface ClientRepresentation {
rootUrl?: string;
secret?: string;
serviceAccountsEnabled?: boolean;
serviceAccountUserId?: string;
standardFlowEnabled?: boolean;
surrogateAuthRequired?: boolean;
webOrigins?: string[];
Expand Down
13 changes: 0 additions & 13 deletions js/libs/keycloak-admin-client/src/resources/clients.ts
Original file line number Diff line number Diff line change
Expand Up @@ -161,19 +161,6 @@ export class Clients extends Resource<{ realm?: string }> {
urlParamKeys: ["id", "roleName"],
});

/**
* Service account user
*/

public getServiceAccountUser = this.makeRequest<
{ id: string },
UserRepresentation
>({
method: "GET",
path: "/{id}/service-account-user",
urlParamKeys: ["id"],
});

/**
* Client secret
*/
Expand Down
11 changes: 5 additions & 6 deletions js/libs/keycloak-admin-client/test/clients.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -257,13 +257,12 @@ describe("Clients", () => {
expect(typeof installationProvider).to.be.equal("string");
});

it("get service account user", async () => {
const serviceAccountUser =
await kcAdminClient.clients.getServiceAccountUser({
id: currentClient.id!,
});
it("get service account user id", async () => {
const client = await kcAdminClient.clients.findOne({
id: currentClient.id!,
});

expect(serviceAccountUser).to.be.ok;
expect(client?.serviceAccountUserId).to.be.ok;
});
});

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -147,10 +147,10 @@ private void setupJwtClient() {
.substring(response.getLocation().getPath().lastIndexOf('/') + 1);
response.close();

var serviceAccountUser = adminClient.realm("master").clients()
.get(clientUuid).getServiceAccountUser();
var serviceAccountUserId = adminClient.realm("master").clients()
.get(clientUuid).toRepresentation().getServiceAccountUserId();
var adminRole = adminClient.realm("master").roles().get("admin").toRepresentation();
adminClient.realm("master").users().get(serviceAccountUser.getId())
adminClient.realm("master").users().get(serviceAccountUserId)
.roles().realmLevel().add(java.util.List.of(adminRole));

try {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -379,13 +379,13 @@ public void testServiceAccountNotExposedInGroupMembers() {
realm.cleanup().add(r -> r.clients().get(clientDbId).remove());

String regularGroupId = getGroupId(REGULAR_GROUP);
UserRepresentation serviceAccount = realm.admin().clients().get(clientDbId).getServiceAccountUser();
realm.admin().users().get(serviceAccount.getId()).joinGroup(regularGroupId);
String serviceAccountUserId = realm.admin().clients().get(clientDbId).toRepresentation().getServiceAccountUserId();
realm.admin().users().get(serviceAccountUserId).joinGroup(regularGroupId);

Group group = client.groups().get(regularGroupId, List.of("members"));
List<org.keycloak.scim.resource.group.Member> members = group.getMembers();
if (members != null) {
boolean exposed = members.stream().anyMatch(m -> serviceAccount.getId().equals(m.getValue()));
boolean exposed = members.stream().anyMatch(m -> serviceAccountUserId.equals(m.getValue()));
assertFalse(exposed, "Service account must not appear in SCIM group members");
}
}
Expand All @@ -405,11 +405,11 @@ public void testCannotAddServiceAccountToGroupViaGroupPatch() {
realm.cleanup().add(r -> r.clients().get(clientDbId).remove());

String regularGroupId = getGroupId(REGULAR_GROUP);
UserRepresentation serviceAccount = realm.admin().clients().get(clientDbId).getServiceAccountUser();
String serviceAccountUserId = realm.admin().clients().get(clientDbId).toRepresentation().getServiceAccountUserId();

try {
client.groups().patch(regularGroupId, PatchRequest.create()
.add("members", serviceAccount.getId())
.add("members", serviceAccountUserId)
.build());
fail("Should not be able to add a service account to a group via SCIM PATCH");
} catch (ScimClientException sce) {
Expand All @@ -432,22 +432,22 @@ public void testCannotRemoveServiceAccountFromGroupViaGroupPatch() {
realm.cleanup().add(r -> r.clients().get(clientDbId).remove());

String regularGroupId = getGroupId(REGULAR_GROUP);
UserRepresentation serviceAccount = realm.admin().clients().get(clientDbId).getServiceAccountUser();
String serviceAccountUserId = realm.admin().clients().get(clientDbId).toRepresentation().getServiceAccountUserId();

// Add service account to group via Admin API (bypassing SCIM protection)
realm.admin().users().get(serviceAccount.getId()).joinGroup(regularGroupId);
realm.admin().users().get(serviceAccountUserId).joinGroup(regularGroupId);

try {
client.groups().patch(regularGroupId, PatchRequest.create()
.remove("members[value eq \"" + serviceAccount.getId() + "\"]")
.remove("members[value eq \"" + serviceAccountUserId + "\"]")
.build());
fail("Should not be able to remove a service account from a group via SCIM PATCH");
} catch (ScimClientException sce) {
assertEquals(Status.BAD_REQUEST.getStatusCode(), sce.getError().getStatusInt());
}

// Verify service account is still in the group (operation was rejected)
UserResource userResource = realm.admin().users().get(serviceAccount.getId());
UserResource userResource = realm.admin().users().get(serviceAccountUserId);
List<String> groups = Optional.ofNullable(userResource.groups()).orElse(List.of()).stream().map(GroupRepresentation::getId).toList();
assertTrue(groups.contains(regularGroupId), "Service account should still be in group after failed PATCH");
}
Expand All @@ -474,12 +474,12 @@ public void testServiceAccountNotDiscoverableViaGroupMembersFilter() {
String groupId = group.getId();
realm.cleanup().add(r -> r.groups().group(groupId).remove());

UserRepresentation serviceAccount = realm.admin().clients().get(clientDbId).getServiceAccountUser();
String serviceAccountUserId = realm.admin().clients().get(clientDbId).toRepresentation().getServiceAccountUserId();
// Add only the service account to the group via Admin API (bypassing SCIM protection)
realm.admin().users().get(serviceAccount.getId()).joinGroup(groupId);
realm.admin().users().get(serviceAccountUserId).joinGroup(groupId);

// A service account must not be discoverable by its id (eq)
assertGroupNotMatched(groupId, ResourceFilter.filter().eq("members.value", serviceAccount.getId()).build(),
assertGroupNotMatched(groupId, ResourceFilter.filter().eq("members.value", serviceAccountUserId).build(),
"Service account must not be discoverable via members.value eq");
// A group whose only member is a service account must appear empty (pr)
assertGroupNotMatched(groupId, ResourceFilter.filter().pr("members").build(),
Expand Down
Loading
Loading
0