Allow fetching roles when evaluating role licies

Closes #20736

Signed-off-by: Pedro Igor <pigor.craveiro@gmail.com>
This commit is contained in:
Pedro Igor 2024-03-01 11:34:42 -03:00 committed by Marek Posolda
parent 4fa940a31e
commit d12711e858
9 changed files with 200 additions and 105 deletions

View file

@ -27,8 +27,10 @@ import org.keycloak.authorization.model.Policy;
import org.keycloak.authorization.policy.evaluation.Evaluation;
import org.keycloak.authorization.policy.provider.PolicyProvider;
import org.keycloak.models.ClientModel;
import org.keycloak.models.KeycloakSession;
import org.keycloak.models.RealmModel;
import org.keycloak.models.RoleModel;
import org.keycloak.models.UserModel;
import org.keycloak.representations.idm.authorization.RolePolicyRepresentation;
/**
@ -47,7 +49,8 @@ public class RolePolicyProvider implements PolicyProvider {
@Override
public void evaluate(Evaluation evaluation) {
Policy policy = evaluation.getPolicy();
Set<RolePolicyRepresentation.RoleDefinition> roleIds = representationFunction.apply(policy, evaluation.getAuthorizationProvider()).getRoles();
RolePolicyRepresentation policyRep = representationFunction.apply(policy, evaluation.getAuthorizationProvider());
Set<RolePolicyRepresentation.RoleDefinition> roleIds = policyRep.getRoles();
AuthorizationProvider authorizationProvider = evaluation.getAuthorizationProvider();
RealmModel realm = authorizationProvider.getKeycloakSession().getContext().getRealm();
Identity identity = evaluation.getContext().getIdentity();
@ -56,7 +59,7 @@ public class RolePolicyProvider implements PolicyProvider {
RoleModel role = realm.getRoleById(roleDefinition.getId());
if (role != null) {
boolean hasRole = hasRole(identity, role, realm);
boolean hasRole = hasRole(identity, role, realm, authorizationProvider, policyRep.isFetchRoles());
if (!hasRole && roleDefinition.isRequired()) {
evaluation.deny();
@ -69,7 +72,12 @@ public class RolePolicyProvider implements PolicyProvider {
logger.debugv("policy {} evaluated with status {} on identity {}", policy.getName(), evaluation.getEffect(), identity.getId());
}
private boolean hasRole(Identity identity, RoleModel role, RealmModel realm) {
private boolean hasRole(Identity identity, RoleModel role, RealmModel realm, AuthorizationProvider authorizationProvider, boolean fetchRoles) {
if (fetchRoles) {
KeycloakSession session = authorizationProvider.getKeycloakSession();
UserModel user = session.users().getUserById(realm, identity.getId());
return user.hasRole(role);
}
String roleName = role.getName();
if (role.isClientRole()) {
ClientModel clientModel = realm.getClientById(role.getContainerId());

View file

@ -36,6 +36,7 @@ import org.keycloak.models.RoleModel;
import org.keycloak.representations.idm.authorization.PolicyRepresentation;
import org.keycloak.representations.idm.authorization.RolePolicyRepresentation;
import org.keycloak.util.JsonSerialization;
import org.keycloak.utils.StringUtil;
import java.io.IOException;
import java.util.ArrayList;
@ -87,6 +88,12 @@ public class RolePolicyProviderFactory implements PolicyProviderFactory<RolePoli
representation.setRoles(new HashSet<>(
Arrays.asList(JsonSerialization.readValue(roles, RolePolicyRepresentation.RoleDefinition[].class))));
}
String fetchRoles = policy.getConfig().get("fetchRoles");
if (StringUtil.isNotBlank(fetchRoles)) {
representation.setFetchRoles(Boolean.parseBoolean(fetchRoles));
}
} catch (IOException cause) {
throw new RuntimeException("Failed to deserialize roles", cause);
}
@ -116,6 +123,11 @@ public class RolePolicyProviderFactory implements PolicyProviderFactory<RolePoli
} catch (IOException cause) {
throw new RuntimeException("Failed to deserialize roles during import", cause);
}
String fetchRoles = representation.getConfig().get("fetchRoles");
if (StringUtil.isNotBlank(fetchRoles)) {
policy.putConfig("fetchRoles", fetchRoles);
}
}
@Override
@ -139,10 +151,17 @@ public class RolePolicyProviderFactory implements PolicyProviderFactory<RolePoli
throw new RuntimeException("Failed to export role policy [" + policy.getName() + "]", cause);
}
String fetchRoles = policy.getConfig().get("fetchRoles");
if (StringUtil.isNotBlank(fetchRoles)) {
config.put("fetchRoles", fetchRoles);
}
representation.setConfig(config);
}
private void updateRoles(Policy policy, RolePolicyRepresentation representation, AuthorizationProvider authorization) {
policy.putConfig("fetchRoles", String.valueOf(representation.isFetchRoles()));
updateRoles(policy, authorization, representation.getRoles());
}

View file

@ -25,6 +25,7 @@ import java.util.Set;
public class RolePolicyRepresentation extends AbstractPolicyRepresentation {
private Set<RoleDefinition> roles;
private boolean fetchRoles;
@Override
public String getType() {
@ -58,6 +59,14 @@ public class RolePolicyRepresentation extends AbstractPolicyRepresentation {
addRole(clientId + "/" + name, required);
}
public boolean isFetchRoles() {
return fetchRoles;
}
public void setFetchRoles(boolean fetchRoles) {
this.fetchRoles = fetchRoles;
}
public static class RoleDefinition {
private String id;

Binary file not shown.

Before

Width:  |  Height:  |  Size: 105 KiB

After

Width:  |  Height:  |  Size: 42 KiB

View file

@ -31,6 +31,10 @@ Specifies which *realm* roles are permitted by this policy.
+
Specifies which *client* roles are permitted by this policy. To enable this field must first select a `Client`.
+
* *Fetch Roles*
+
By default, only the roles available from the token sent with the authorization requests are used to check if the user is granted with a role. If this setting is enabled, the policy will ignore roles from the token and check any role associated with the user instead.
+
* *Logic*
+
The logic of this policy to apply after the other conditions have been evaluated.

View file

@ -3097,3 +3097,5 @@ addTranslationDialogHelperText=The translation based on the default language is
noLanguagesSearchResultsInstructions=Click on the search bar above to search for languages
addTranslationDialogOkBtn=Ok
translationError=Please add translations before saving
fetchRoles=Fetch Roles
fetchRolesHelp=By default, only the roles available from the token sent with the authorization requests are used to check if the user is granted with a role. If this setting is enabled, the policy will ignore roles from the token and check any role associated with the user instead.

View file

@ -18,6 +18,7 @@ import { AddRoleMappingModal } from "../../../components/role-mapping/AddRoleMap
import { Row, ServiceRole } from "../../../components/role-mapping/RoleMapping";
import { useFetch } from "../../../utils/useFetch";
import type { RequiredIdValue } from "./ClientScope";
import { DefaultSwitchControl } from "../../../components/SwitchControl";
export const Role = () => {
const { t } = useTranslation();
@ -28,6 +29,7 @@ export const Role = () => {
formState: { errors },
} = useFormContext<{
roles?: RequiredIdValue[];
fetchRoles?: boolean;
}>();
const values = getValues("roles");
@ -58,109 +60,116 @@ export const Role = () => {
);
return (
<FormGroup
label={t("roles")}
labelIcon={
<HelpItem helpText={t("policyRolesHelp")} fieldLabelId="roles" />
}
fieldId="roles"
helperTextInvalid={t("requiredRoles")}
validated={errors.roles ? "error" : "default"}
isRequired
>
<Controller
name="roles"
control={control}
defaultValue={[]}
rules={{
validate: (value?: RequiredIdValue[]) =>
value && value.filter((c) => c.id).length > 0,
}}
render={({ field }) => (
<>
{open && (
<AddRoleMappingModal
id="role"
type="roles"
onAssign={(rows) => {
field.onChange([
...(field.value || []),
...rows.map((row) => ({ id: row.role.id })),
]);
setSelectedRoles([...selectedRoles, ...rows]);
setOpen(false);
<>
<FormGroup
label={t("roles")}
labelIcon={
<HelpItem helpText={t("policyRolesHelp")} fieldLabelId="roles" />
}
fieldId="roles"
helperTextInvalid={t("requiredRoles")}
validated={errors.roles ? "error" : "default"}
isRequired
>
<Controller
name="roles"
control={control}
defaultValue={[]}
rules={{
validate: (value?: RequiredIdValue[]) =>
value && value.filter((c) => c.id).length > 0,
}}
render={({ field }) => (
<>
{open && (
<AddRoleMappingModal
id="role"
type="roles"
onAssign={(rows) => {
field.onChange([
...(field.value || []),
...rows.map((row) => ({ id: row.role.id })),
]);
setSelectedRoles([...selectedRoles, ...rows]);
setOpen(false);
}}
onClose={() => {
setOpen(false);
}}
isLDAPmapper
/>
)}
<Button
data-testid="select-role-button"
variant="secondary"
onClick={() => {
setOpen(true);
}}
onClose={() => {
setOpen(false);
}}
isLDAPmapper
/>
)}
<Button
data-testid="select-role-button"
variant="secondary"
onClick={() => {
setOpen(true);
}}
>
{t("addRoles")}
</Button>
</>
)}
/>
{selectedRoles.length > 0 && (
<TableComposable variant="compact">
<Thead>
<Tr>
<Th>{t("roles")}</Th>
<Th>{t("required")}</Th>
<Th aria-hidden="true" />
</Tr>
</Thead>
<Tbody>
{selectedRoles.map((row, index) => (
<Tr key={row.role.id}>
<Td>
<ServiceRole role={row.role} client={row.client} />
</Td>
<Td>
<Controller
name={`roles.${index}.required`}
defaultValue={false}
control={control}
render={({ field }) => (
<Checkbox
id="required"
data-testid="standard"
name="required"
isChecked={field.value}
onChange={field.onChange}
/>
)}
/>
</Td>
<Td>
<Button
variant="link"
className="keycloak__client-authorization__policy-row-remove"
icon={<MinusCircleIcon />}
onClick={() => {
setValue("roles", [
...(values || []).filter((s) => s.id !== row.role.id),
]);
setSelectedRoles([
...selectedRoles.filter(
(s) => s.role.id !== row.role.id,
),
]);
}}
/>
</Td>
>
{t("addRoles")}
</Button>
</>
)}
/>
{selectedRoles.length > 0 && (
<TableComposable variant="compact">
<Thead>
<Tr>
<Th>{t("roles")}</Th>
<Th>{t("required")}</Th>
<Th aria-hidden="true" />
</Tr>
))}
</Tbody>
</TableComposable>
)}
</FormGroup>
</Thead>
<Tbody>
{selectedRoles.map((row, index) => (
<Tr key={row.role.id}>
<Td>
<ServiceRole role={row.role} client={row.client} />
</Td>
<Td>
<Controller
name={`roles.${index}.required`}
defaultValue={false}
control={control}
render={({ field }) => (
<Checkbox
id="required"
data-testid="standard"
name="required"
isChecked={field.value}
onChange={field.onChange}
/>
)}
/>
</Td>
<Td>
<Button
variant="link"
className="keycloak__client-authorization__policy-row-remove"
icon={<MinusCircleIcon />}
onClick={() => {
setValue("roles", [
...(values || []).filter((s) => s.id !== row.role.id),
]);
setSelectedRoles([
...selectedRoles.filter(
(s) => s.role.id !== row.role.id,
),
]);
}}
/>
</Td>
</Tr>
))}
</Tbody>
</TableComposable>
)}
</FormGroup>
<DefaultSwitchControl
name="fetchRoles"
label={t("fetchRoles")}
labelIcon={t("fetchRolesHelp")}
/>
</>
);
};

View file

@ -30,6 +30,9 @@ import org.keycloak.admin.client.resource.ClientsResource;
import org.keycloak.admin.client.resource.RealmResource;
import org.keycloak.authorization.client.AuthorizationDeniedException;
import org.keycloak.authorization.client.AuthzClient;
import org.keycloak.protocol.oidc.OIDCLoginProtocolFactory;
import org.keycloak.representations.idm.ClientRepresentation;
import org.keycloak.representations.idm.ClientScopeRepresentation;
import org.keycloak.representations.idm.GroupRepresentation;
import org.keycloak.representations.idm.RealmRepresentation;
import org.keycloak.representations.idm.RoleRepresentation;
@ -40,6 +43,7 @@ import org.keycloak.representations.idm.authorization.PermissionRequest;
import org.keycloak.representations.idm.authorization.ResourcePermissionRepresentation;
import org.keycloak.representations.idm.authorization.ResourceRepresentation;
import org.keycloak.representations.idm.authorization.RolePolicyRepresentation;
import org.keycloak.testsuite.admin.ApiUtil;
import org.keycloak.testsuite.util.ClientBuilder;
import org.keycloak.testsuite.util.GroupBuilder;
import org.keycloak.testsuite.util.RealmBuilder;
@ -164,6 +168,30 @@ public class RolePolicyTest extends AbstractAuthzTest {
assertNotNull(authzClient.authorization("alice", "password").authorize(new AuthorizationRequest(ticket)));
}
@Test
public void testFetchRoles() {
AuthzClient authzClient = getAuthzClient();
RealmResource realm = getRealm();
ClientsResource clients = realm.clients();
ClientRepresentation client = clients.findByClientId(authzClient.getConfiguration().getResource()).get(0);
ClientScopeRepresentation rolesScope = ApiUtil.findClientScopeByName(realm, OIDCLoginProtocolFactory.ROLES_SCOPE).toRepresentation();
ClientResource clientResource = clients.get(client.getId());
clientResource.removeDefaultClientScope(rolesScope.getId());
getCleanup().addCleanup(() -> clientResource.addDefaultClientScope(rolesScope.getId()));
PermissionRequest request = new PermissionRequest("Resource B");
String ticket = authzClient.protection().permission().create(request).getTicket();
try {
authzClient.authorization("kolo", "password").authorize(new AuthorizationRequest(ticket));
fail("Should fail because no role is available from the token");
} catch (AuthorizationDeniedException ignore) {
}
RolePolicyRepresentation roleRep = clientResource.authorization().policies().role().findByName("Role B Policy");
roleRep.setFetchRoles(true);
clientResource.authorization().policies().role().findById(roleRep.getId()).update(roleRep);
assertNotNull(authzClient.authorization("kolo", "password").authorize(new AuthorizationRequest(ticket)));
}
private void createRealmRolePolicy(String name, String... roles) {
RolePolicyRepresentation policy = new RolePolicyRepresentation();

View file

@ -34,6 +34,7 @@ import org.keycloak.admin.client.resource.PolicyResource;
import org.keycloak.admin.client.resource.RolePoliciesResource;
import org.keycloak.admin.client.resource.RolePolicyResource;
import org.keycloak.admin.client.resource.RolesResource;
import org.keycloak.models.utils.KeycloakModelUtils;
import org.keycloak.representations.idm.ClientRepresentation;
import org.keycloak.representations.idm.RoleRepresentation;
import org.keycloak.representations.idm.authorization.DecisionStrategy;
@ -73,6 +74,19 @@ public class RolePolicyManagementTest extends AbstractPolicyManagementTest {
assertCreated(authorization, representation);
}
@Test
public void testCreateFetchRoles() {
AuthorizationResource authorization = getClient().authorization();
RolePolicyRepresentation representation = new RolePolicyRepresentation();
representation.setName(KeycloakModelUtils.generateId());
representation.setFetchRoles(true);
representation.addRole("Role A", false);
representation.addRole("Role B", true);
assertCreated(authorization, representation);
}
@Test
public void testCreateClientRolePolicy() {
ClientResource client = getClient();
@ -115,6 +129,7 @@ public class RolePolicyManagementTest extends AbstractPolicyManagementTest {
representation.setName("changed");
representation.setDescription("changed");
representation.setFetchRoles(true);
representation.setDecisionStrategy(DecisionStrategy.AFFIRMATIVE);
representation.setLogic(Logic.POSITIVE);
representation.setRoles(representation.getRoles().stream().filter(roleDefinition -> !roleDefinition.getId().equals("Resource A")).collect(Collectors.toSet()));
@ -208,6 +223,7 @@ public class RolePolicyManagementTest extends AbstractPolicyManagementTest {
.filter(roleDefinition -> (getRoleName(actualDefinition.getId()).equals(roleDefinition.getId()) || (clientRep.getClientId() + "/" + getRoleName(actualDefinition.getId())).equals(roleDefinition.getId())) && actualDefinition.isRequired() == roleDefinition.isRequired())
.findFirst().isPresent())
.count());
assertEquals(representation.isFetchRoles(), actual.isFetchRoles());
}
private String getRoleName(String id) {