1
0
mirror of https://github.com/keycloak/keycloak.git synced 2026-10-23 19:29:23 +03:00

Require manage-realm to change realm-level default client scopes

Closes #54151

Signed-off-by: vramik <vramik@redhat.com>
This commit is contained in:
vramik
2026-10-13 23:09:48 +02:00
committed by Pedro Igor
parent 1138b6d331
commit 672f05e258
7 changed files with 84 additions and 9 deletions

View File

@@ -4,6 +4,14 @@
Breaking changes are identified as those that might require changes for existing users to their configurations or applications.
In minor or patch releases, {project_name} will only introduce breaking changes to fix bugs.
=== Realm-level default client scopes require `manage-realm`
// https://github.com/keycloak/keycloak/issues/50588
Changing the realm-level default and optional client scopes through the Admin REST API endpoints `default-default-client-scopes/{clientScopeId}` and `default-optional-client-scopes/{clientScopeId}` now requires the `manage-realm` role.
Previously `manage-clients` was sufficient, even though these lists determine which client scopes every newly created client in the realm receives.
Administrators who hold only `manage-clients` can still create, update, and delete individual client scopes, and can still read the realm-level lists; they can no longer change which of them are realm defaults.
If you have automation that manages realm default client scopes with a `manage-clients` account, grant it `manage-realm` or run it with an account that already has it.
=== Deleting required client scopes
// https://github.com/keycloak/keycloak/issues/51248

View File

@@ -9,6 +9,7 @@ import { useTranslation } from "react-i18next";
import { useAdminClient } from "../admin-client";
import type { Row } from "../clients/scopes/ClientScopes";
import { useAlerts } from "@keycloak/keycloak-ui-shared";
import { useAccess } from "../context/access/Access";
import {
ClientScope,
allClientScopeTypes,
@@ -34,6 +35,7 @@ export const ChangeTypeDropdown = ({
const [open, setOpen] = useState(false);
const { addAlert, addError } = useAlerts();
const { hasAccess } = useAccess();
return (
<Select
@@ -43,7 +45,10 @@ export const ChangeTypeDropdown = ({
toggle={(ref) => (
<MenuToggle
id="change-type-dropdown"
isDisabled={selectedRows.length === 0}
isDisabled={
selectedRows.length === 0 ||
(!clientId && !hasAccess("manage-realm"))
}
ref={ref}
onClick={() => setOpen(!open)}
isExpanded={open}

View File

@@ -32,6 +32,7 @@ import {
import { useConfirmDialog } from "../components/confirm-dialog/ConfirmDialog";
import { Action, KeycloakDataTable } from "@keycloak/keycloak-ui-shared";
import { ViewHeader } from "../components/view-header/ViewHeader";
import { useAccess } from "../context/access/Access";
import { useRealm } from "../context/realm-context/RealmContext";
import helpUrls from "../help-urls";
import { emptyFormatter } from "../util";
@@ -58,12 +59,14 @@ const TypeSelector = (scope: TypeSelectorProps) => {
const { t } = useTranslation();
const { addAlert, addError } = useAlerts();
const { hasAccess } = useAccess();
return (
<CellDropdown
clientScope={scope}
type={scope.type}
all
isDisabled={!hasAccess("manage-realm")}
onSelect={async (value) => {
try {
await changeScope(adminClient, scope, value as AllClientScopeType);

View File

@@ -109,14 +109,21 @@ export default function EditClientScope() {
const eventsTab = useRoutableTab(toClientScope({ realm, id, tab: "events" }));
const onSubmit = async (formData: ClientScopeDefaultOptionalType) => {
const clientScope = convertFormValuesToObject({
const updatedScope = convertFormValuesToObject({
...formData,
name: formData.name?.trim().replace(/ /g, "_"),
});
try {
await adminClient.clientScopes.update({ id }, clientScope);
await changeScope(adminClient, { ...clientScope, id }, clientScope.type);
await adminClient.clientScopes.update({ id }, updatedScope);
if (updatedScope.type !== clientScope?.type) {
await changeScope(
adminClient,
{ ...updatedScope, id, type: clientScope!.type },
updatedScope.type,
);
setClientScope((prev) => (prev ? { ...prev, ...updatedScope } : prev));
}
addAlert(t("updateSuccessClientScope"), AlertVariant.success);
} catch (error) {

View File

@@ -33,6 +33,7 @@ import {
ClientScopeDefaultOptionalType,
} from "../../components/client-scope/ClientScopeTypes";
import { FormAccess } from "../../components/form/FormAccess";
import { useAccess } from "../../context/access/Access";
import { useRealm } from "../../context/realm-context/RealmContext";
import {
useLoginProviders,
@@ -198,9 +199,13 @@ export const ScopeForm = ({ clientScope, save }: ScopeFormProps) => {
const isParameterizedScopeWithFeatureDisabled =
!isParameterizedScopesEnabled &&
clientScope?.attributes?.["is.parameterized.scope"] === "true";
const scopeTypeOptions = isParameterized
? allClientScopeTypes.filter((key) => key !== "default")
: allClientScopeTypes;
const { hasAccess } = useAccess();
const canManageRealm = hasAccess("manage-realm");
const scopeTypeOptions = allClientScopeTypes.filter((key) => {
if (isParameterized && key === "default") return false;
if (!canManageRealm && !clientScope) return key === "none";
return true;
});
const selectedProtocol = useWatch({
control,
@@ -537,6 +542,7 @@ export const ScopeForm = ({ clientScope, save }: ScopeFormProps) => {
name="type"
label={t("type")}
labelIcon={t("scopeTypeHelp")}
isDisabled={!canManageRealm && !!clientScope}
controller={{ defaultValue: allClientScopeTypes[0] }}
options={scopeTypeOptions.map((key) => ({
key,

View File

@@ -287,7 +287,9 @@ public class RealmAdminResource {
}
private void addDefaultClientScope(String clientScopeId, boolean defaultScope) {
auth.clients().requireManageClientScopes();
// Realm-wide configuration affecting every newly created client, so manage-realm is required
// rather than the manage-clients level needed to manage an individual client scope.
auth.realm().requireManageRealm();
ClientScopeModel clientScope = realm.getClientScopeById(clientScopeId);
if (clientScope == null) {
@@ -318,7 +320,8 @@ public class RealmAdminResource {
@APIResponse(responseCode = "404", description = "Not Found")
})
public void removeDefaultDefaultClientScope(@PathParam("clientScopeId") String clientScopeId) {
auth.clients().requireManageClientScopes();
// See addDefaultClientScope - realm-wide configuration requires manage-realm.
auth.realm().requireManageRealm();
ClientScopeModel clientScope = realm.getClientScopeById(clientScopeId);
if (clientScope == null) {

View File

@@ -294,6 +294,49 @@ public class PermissionsTest extends AbstractPermissionsTest {
clients.get(AdminRoles.QUERY_USERS), false);
}
/**
* The realm-level default and optional client scopes determine which client scopes every newly created
* client receives, so changing them requires manage-realm. Managing an individual client scope still only
* requires manage-clients, and reading the realm-level lists still only requires view access on clients.
*/
@Test
public void realmDefaultClientScopes() {
ClientScopeRepresentation scope = new ClientScopeRepresentation();
scope.setName("realm-default-scope-permissions-test");
scope.setProtocol("openid-connect");
String scopeId = ApiUtil.getCreatedId(managedRealm1.admin().clientScopes().create(scope));
managedRealm1.cleanup().add(r -> r.clientScopes().get(scopeId).remove());
Invocation addDefault = realm -> realm.addDefaultDefaultClientScope(scopeId);
Invocation removeDefault = realm -> realm.removeDefaultDefaultClientScope(scopeId);
Invocation addOptional = realm -> realm.addDefaultOptionalClientScope(scopeId);
Invocation removeOptional = realm -> realm.removeDefaultOptionalClientScope(scopeId);
// manage-clients is enough to manage the client scope itself, but not to make it a realm default
invoke(addDefault, clients.get("none"), false);
invoke(addDefault, clients.get("master-none"), false);
invoke(addDefault, clients.get(AdminRoles.VIEW_REALM), false);
invoke(addDefault, clients.get(AdminRoles.MANAGE_CLIENTS), false);
invoke(removeDefault, clients.get(AdminRoles.MANAGE_CLIENTS), false);
invoke(addOptional, clients.get(AdminRoles.MANAGE_CLIENTS), false);
invoke(removeOptional, clients.get(AdminRoles.MANAGE_CLIENTS), false);
invoke(addDefault, clients.get(AdminRoles.MANAGE_REALM), true);
invoke(removeDefault, clients.get(AdminRoles.MANAGE_REALM), true);
invoke(addOptional, clients.get(AdminRoles.MANAGE_REALM), true);
invoke(removeOptional, clients.get(AdminRoles.MANAGE_REALM), true);
invoke(addDefault, clients.get(AdminRoles.REALM_ADMIN), true);
invoke(removeDefault, clients.get(AdminRoles.REALM_ADMIN), true);
invoke(addDefault, clients.get("master-admin"), true);
invoke(removeDefault, clients.get("master-admin"), true);
// reading the realm-level lists is unchanged
invoke(realm -> realm.getDefaultDefaultClientScopes(), clients.get(AdminRoles.VIEW_CLIENTS), true);
invoke(realm -> realm.getDefaultOptionalClientScopes(), clients.get(AdminRoles.VIEW_CLIENTS), true);
invoke(realm -> realm.getDefaultDefaultClientScopes(), clients.get(AdminRoles.MANAGE_CLIENTS), true);
}
@Test
public void clientInitialAccess() {
invoke(realm -> realm.clientInitialAccess().list(), Resource.CLIENT, false);