Support aggregated policies during partial evaluation

Closes #45324

Signed-off-by: Pedro Igor <pigor.craveiro@gmail.com>
This commit is contained in:
Pedro Igor 2026-01-15 11:20:52 -03:00 committed by GitHub
parent 37ff64446b
commit ab351170b4
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 191 additions and 39 deletions

View file

@ -18,24 +18,36 @@
package org.keycloak.authorization.policy.provider.aggregated;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.stream.Stream;
import org.keycloak.authorization.AuthorizationProvider;
import org.keycloak.authorization.Decision;
import org.keycloak.authorization.fgap.evaluation.partial.PartialEvaluationPolicyProvider;
import org.keycloak.authorization.model.Policy;
import org.keycloak.authorization.model.ResourceServer;
import org.keycloak.authorization.permission.ResourcePermission;
import org.keycloak.authorization.policy.evaluation.DecisionResultCollector;
import org.keycloak.authorization.policy.evaluation.DefaultEvaluation;
import org.keycloak.authorization.policy.evaluation.Evaluation;
import org.keycloak.authorization.policy.evaluation.Result;
import org.keycloak.authorization.policy.provider.PolicyProvider;
import org.keycloak.authorization.store.StoreFactory;
import org.keycloak.models.ClientModel;
import org.keycloak.models.KeycloakSession;
import org.keycloak.models.RealmModel;
import org.keycloak.models.UserModel;
import org.keycloak.representations.idm.authorization.DecisionStrategy;
import org.keycloak.representations.idm.authorization.ResourceType;
import org.jboss.logging.Logger;
/**
* @author <a href="mailto:psilva@redhat.com">Pedro Igor</a>
*/
public class AggregatePolicyProvider implements PolicyProvider {
public class AggregatePolicyProvider implements PolicyProvider, PartialEvaluationPolicyProvider {
private static final Logger logger = Logger.getLogger(AggregatePolicyProvider.class);
@Override
@ -81,4 +93,49 @@ public class AggregatePolicyProvider implements PolicyProvider {
public void close() {
}
@Override
public boolean supports(Policy policy) {
return AggregatePolicyProviderFactory.ID.equals(policy.getType());
}
@Override
public Stream<Policy> getPermissions(KeycloakSession session, ResourceType resourceType, ResourceType groupResourceType, UserModel subject) {
AuthorizationProvider provider = session.getProvider(AuthorizationProvider.class);
RealmModel realm = session.getContext().getRealm();
ClientModel adminPermissionsClient = realm.getAdminPermissionsClient();
StoreFactory storeFactory = provider.getStoreFactory();
ResourceServer resourceServer = storeFactory.getResourceServerStore().findByClient(adminPermissionsClient);
return storeFactory.getPolicyStore().findDependentPolicies(resourceServer, resourceType.getType(), groupResourceType == null ? null : groupResourceType.getType(), AggregatePolicyProviderFactory.ID, null, List.of());
}
@Override
public boolean evaluate(KeycloakSession session, Policy policy, UserModel subject) {
DecisionStrategy decisionStrategy = policy.getDecisionStrategy();
Set<Policy> associatedPolicies = policy.getAssociatedPolicies();
int grants = 0;
for (Policy associatedPolicy : associatedPolicies) {
PolicyProvider policyProvider = session.getProvider(AuthorizationProvider.class).getProvider(associatedPolicy.getType());
if (policyProvider instanceof PartialEvaluationPolicyProvider partialPolicyProvider) {
if (partialPolicyProvider.evaluate(session, associatedPolicy, subject)) {
grants++;
}
} else {
return false;
}
}
if (grants == 0) {
return false;
}
return switch (decisionStrategy) {
case AFFIRMATIVE -> true;
case UNANIMOUS -> grants == associatedPolicies.size();
case CONSENSUS -> grants > associatedPolicies.size() - grants;
};
}
}

View file

@ -35,6 +35,8 @@ import org.keycloak.representations.idm.authorization.PolicyRepresentation;
*/
public class AggregatePolicyProviderFactory implements PolicyProviderFactory<AggregatePolicyRepresentation> {
public static final String ID = "aggregate";
private AggregatePolicyProvider provider = new AggregatePolicyProvider();
@Override
@ -120,6 +122,6 @@ public class AggregatePolicyProviderFactory implements PolicyProviderFactory<Agg
@Override
public String getId() {
return "aggregate";
return ID;
}
}

View file

@ -223,6 +223,10 @@ make them available from queries:
* `User`
* `Group`
* `Role`
* `Aggregated`
In case of using an `Aggregated` policy, all the underlying policies must be of type `User`, `Group`, or `Role`. Otherwise,
the policy will always evaluate to `DENY` when performing partial evaluation.
By using any of the policies above, {project_name} can pre-calculate the set of resources that a realm administration can view
by looking for a direct (if using a user policy) or indirect (if using a role or group policy) reference to the realm administrator.

View file

@ -68,7 +68,8 @@ import org.hibernate.annotations.Nationalized;
@NamedQuery(name="findPolicyIdByResourceType", query="select p from PolicyEntity p inner join p.config c inner join fetch p.associatedPolicies a where p.resourceServer.id = :serverId and KEY(c) = 'defaultResourceType' and c like :type"),
@NamedQuery(name="findPolicyIdByDependentPolices", query="select p.id from PolicyEntity p inner join p.associatedPolicies ap where p.resourceServer.id = :serverId and (ap.resourceServer.id = :serverId and ap.id = :policyId)"),
@NamedQuery(name="deletePolicyByResourceServer", query="delete from PolicyEntity p where p.resourceServer.id = :serverId"),
@NamedQuery(name="findDependentPolicyByResourceTypeAndConfig", query="select p.id from PolicyEntity p inner join p.scopes s inner join p.config c inner join p.associatedPolicies ap inner join ap.config ac where p.resourceServer.id = :serverId and (s.name = :scopeName) and ap.resourceServer.id = :serverId and ap.type = :associatedPolicyType and (KEY(c) = 'defaultResourceType' and c like :resourceType) and (KEY(ac) = :configKey and ac like :configValue)")
@NamedQuery(name="findDependentPolicyByResourceTypeAndConfig", query="select p.id from PolicyEntity p inner join p.scopes s inner join p.config c inner join p.associatedPolicies ap inner join ap.config ac where p.resourceServer.id = :serverId and (s.name = :scopeName) and ap.resourceServer.id = :serverId and ap.type = :associatedPolicyType and (KEY(c) = 'defaultResourceType' and c like :resourceType) and (KEY(ac) = :configKey and ac like :configValue)"),
@NamedQuery(name="findDependentPolicyByResourceType", query="select p.id from PolicyEntity p inner join p.scopes s inner join p.config c inner join p.associatedPolicies ap where p.resourceServer.id = :serverId and (s.name = :scopeName) and ap.resourceServer.id = :serverId and ap.type = :associatedPolicyType and (KEY(c) = 'defaultResourceType' and c like :resourceType)")
}
)

View file

@ -339,29 +339,41 @@ public class JPAPolicyStore implements PolicyStore {
String dbProductName = entityManager.unwrap(Session.class).doReturningWork(connection -> connection.getMetaData().getDatabaseProductName());
if (dbProductName.equals("Oracle")) {
Stream<Policy> result = Stream.empty();
TypedQuery<String> query;
for (String value : configValues) {
TypedQuery<String> query = entityManager.createNamedQuery("findDependentPolicyByResourceTypeAndConfig", String.class);
query.setParameter("serverId", resourceServer.getId());
query.setParameter("resourceType", resourceType);
query.setParameter("associatedPolicyType", associatedPolicyType);
query.setParameter("configKey", configKey);
query.setParameter("configValue", "%" + value + "%");
if (AdminPermissionsSchema.GROUPS.getType().equals(groupResourceType)) {
query.setParameter("scopeName", AdminPermissionsSchema.VIEW_MEMBERS);
} else {
query.setParameter("scopeName", AdminPermissionsSchema.VIEW);
}
PolicyStore policyStore = provider.getStoreFactory().getPolicyStore();
result = Stream.concat(result, query.getResultStream().map((id) -> policyStore.findById(resourceServer, id)).filter(Objects::nonNull));
if (configKey == null) {
query = entityManager.createNamedQuery("findDependentPolicyByResourceType", String.class);
} else {
query = entityManager.createNamedQuery("findDependentPolicyByResourceTypeAndConfig", String.class);
}
return result;
query.setParameter("serverId", resourceServer.getId());
query.setParameter("resourceType", resourceType);
query.setParameter("associatedPolicyType", associatedPolicyType);
if (AdminPermissionsSchema.GROUPS.getType().equals(groupResourceType)) {
query.setParameter("scopeName", AdminPermissionsSchema.VIEW_MEMBERS);
} else {
query.setParameter("scopeName", AdminPermissionsSchema.VIEW);
}
if (configKey == null) {
PolicyStore policyStore = provider.getStoreFactory().getPolicyStore();
return query.getResultStream().map((id) -> policyStore.findById(resourceServer, id)).filter(Objects::nonNull);
} else {
Stream<Policy> result = Stream.empty();
for (String value : configValues) {
query.setParameter("configKey", configKey);
query.setParameter("configValue", "%" + value + "%");
PolicyStore policyStore = provider.getStoreFactory().getPolicyStore();
result = Stream.concat(result, query.getResultStream().map((id) -> policyStore.findById(resourceServer, id)).filter(Objects::nonNull));
}
return result;
}
}
CriteriaBuilder cb = entityManager.getCriteriaBuilder();
@ -373,7 +385,6 @@ public class JPAPolicyStore implements PolicyStore {
Join<Object, Object> scope = from.join("scopes");
MapJoin<Object, Object, Object> config = from.joinMap("config");
Join<Object, Object> associatedPolicy = from.join("associatedPolicies");
MapJoin<Object, Object, Object> associatedPolicyConfig = associatedPolicy.joinMap("config");
List<Predicate> predicates = new LinkedList<>();
@ -389,16 +400,19 @@ public class JPAPolicyStore implements PolicyStore {
predicates.add(cb.equal(config.key(), "defaultResourceType"));
predicates.add(cb.equal(config.value(), resourceType));
List<Predicate> configValuePredicates = new LinkedList<>();
if (configKey != null) {
MapJoin<Object, Object, Object> associatedPolicyConfig = associatedPolicy.joinMap("config");
List<Predicate> configValuePredicates = new LinkedList<>();
predicates.add(cb.equal(associatedPolicyConfig.key(), configKey));
predicates.add(cb.equal(associatedPolicyConfig.key(), configKey));
for (String value : configValues) {
configValuePredicates.add(cb.like(associatedPolicyConfig.value().as(String.class), "%" + value + "%"));
for (String value : configValues) {
configValuePredicates.add(cb.like(associatedPolicyConfig.value().as(String.class), "%" + value + "%"));
}
predicates.add(cb.or(configValuePredicates.toArray(new Predicate[0])));
}
predicates.add(cb.or(configValuePredicates.toArray(new Predicate[0])));
query.where(predicates.toArray(new Predicate[0]));
PolicyStore policyStore = provider.getStoreFactory().getPolicyStore();

View file

@ -31,8 +31,11 @@ import org.keycloak.authorization.fgap.AdminPermissionsSchema;
import org.keycloak.models.Constants;
import org.keycloak.models.utils.KeycloakModelUtils;
import org.keycloak.representations.idm.GroupRepresentation;
import org.keycloak.representations.idm.UserRepresentation;
import org.keycloak.representations.idm.authorization.AbstractPolicyRepresentation;
import org.keycloak.representations.idm.authorization.AggregatePolicyRepresentation;
import org.keycloak.representations.idm.authorization.ClientPolicyRepresentation;
import org.keycloak.representations.idm.authorization.DecisionStrategy;
import org.keycloak.representations.idm.authorization.GroupPolicyRepresentation;
import org.keycloak.representations.idm.authorization.Logic;
import org.keycloak.representations.idm.authorization.RolePolicyRepresentation;
@ -43,6 +46,8 @@ import org.keycloak.testframework.annotations.InjectRealm;
import org.keycloak.testframework.injection.LifeCycle;
import org.keycloak.testframework.realm.ManagedClient;
import org.keycloak.testframework.realm.ManagedRealm;
import org.keycloak.testframework.realm.UserConfigBuilder;
import org.keycloak.testframework.util.ApiUtil;
import static org.hamcrest.MatcherAssert.assertThat;
import static org.hamcrest.Matchers.equalTo;
@ -193,6 +198,17 @@ public abstract class AbstractPermissionTest {
return policy;
}
protected AggregatePolicyRepresentation createAggregatedPolicy(ManagedClient client, String name, Logic logic, DecisionStrategy decisionStrategy, String... policies) {
AggregatePolicyRepresentation aggregatedPolicy = new AggregatePolicyRepresentation();
aggregatedPolicy.setName(name);
aggregatedPolicy.setLogic(logic);
aggregatedPolicy.setDecisionStrategy(decisionStrategy);
aggregatedPolicy.setPolicies(Set.of(policies));
try (Response response = client.admin().authorization().policies().aggregate().create(aggregatedPolicy)) {
return response.readEntity(AggregatePolicyRepresentation.class);
}
}
protected ScopePermissionRepresentation createAllPermission(ManagedClient client, String resourceType, AbstractPolicyRepresentation policy, Set<String> scopes) {
ScopePermissionRepresentation permission = PermissionBuilder.create()
.resourceType(resourceType)
@ -228,4 +244,26 @@ public abstract class AbstractPermissionTest {
protected ScopePermissionRepresentation createGroupPermission(Set<GroupRepresentation> groups, Set<String> scopes, AbstractPolicyRepresentation... policies) {
return createPermission(client, groups.stream().map(GroupRepresentation::getId).collect(Collectors.toSet()), AdminPermissionsSchema.GROUPS_RESOURCE_TYPE, scopes, policies);
}
protected UserRepresentation createUser(String username) {
UserRepresentation user = UserConfigBuilder.create()
.username(username)
.build();
try (Response response = realm.admin().users().create(user)) {
user.setId(ApiUtil.getCreatedId(response));
return user;
}
}
protected GroupRepresentation createGroup(String name) {
GroupRepresentation group = new GroupRepresentation();
group.setName(name);
try (Response response = realm.admin().groups().add(group)) {
group.setId(ApiUtil.getCreatedId(response));
return group;
}
}
}

View file

@ -261,13 +261,4 @@ public class FineGrainedPermissionsUsersTest extends AbstractPermissionTest {
return groups;
}
private GroupRepresentation createGroup(String name) {
GroupRepresentation grp = new GroupRepresentation();
grp.setName(name);
String groupId = ApiUtil.getCreatedId(realm.admin().groups().add(grp));
grp.setId(groupId);
realm.cleanup().add(r -> r.groups().group(groupId).remove());
return grp;
}
}

View file

@ -42,6 +42,8 @@ import org.keycloak.models.utils.KeycloakModelUtils;
import org.keycloak.representations.idm.GroupRepresentation;
import org.keycloak.representations.idm.RoleRepresentation;
import org.keycloak.representations.idm.UserRepresentation;
import org.keycloak.representations.idm.authorization.AggregatePolicyRepresentation;
import org.keycloak.representations.idm.authorization.DecisionStrategy;
import org.keycloak.representations.idm.authorization.GroupPolicyRepresentation;
import org.keycloak.representations.idm.authorization.Logic;
import org.keycloak.representations.idm.authorization.RolePolicyRepresentation;
@ -61,6 +63,8 @@ import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import static org.keycloak.authorization.fgap.AdminPermissionsSchema.GROUPS_RESOURCE_TYPE;
import static org.keycloak.authorization.fgap.AdminPermissionsSchema.MANAGE_MEMBERS;
import static org.keycloak.authorization.fgap.AdminPermissionsSchema.MANAGE_MEMBERSHIP;
import static org.keycloak.authorization.fgap.AdminPermissionsSchema.USERS_RESOURCE_TYPE;
import static org.keycloak.authorization.fgap.AdminPermissionsSchema.VIEW;
import static org.keycloak.authorization.fgap.AdminPermissionsSchema.VIEW_MEMBERS;
@ -527,4 +531,45 @@ public class UserResourceTypeFilteringTest extends AbstractPermissionTest {
assertThat(roleMembers, hasSize(allowedUsers.size()));
assertThat(roleMembers, hasItems(allowedUsers.toArray(new String[0])));
}
@Test
public void testViewGroupMembersPolicyUsingAggregatedPolicy() {
List<UserRepresentation> search = realmAdminClient.realm(realm.getName()).users().search(null, 0, 10);
assertTrue(search.isEmpty());
GroupRepresentation fooGroup = createGroup(KeycloakModelUtils.generateId());
UserRepresentation fooUser = createUser(KeycloakModelUtils.generateId());
realm.admin().users().get(fooUser.getId()).joinGroup(fooGroup.getId());
GroupRepresentation fooGroupManager = createGroup(KeycloakModelUtils.generateId());
UserRepresentation barUser = createUser(KeycloakModelUtils.generateId());
GroupRepresentation barGroup = createGroup(KeycloakModelUtils.generateId());
realm.admin().users().get(barUser.getId()).joinGroup(barGroup.getId());
GroupRepresentation barGroupManager = createGroup(KeycloakModelUtils.generateId());
GroupPolicyRepresentation fooGroupManagerPolicy = createGroupPolicy(realm, client, "Foo Group Policy", Logic.POSITIVE, fooGroupManager.getId());
GroupPolicyRepresentation barGroupManagerPolicy = createGroupPolicy(realm, client, "Bar Group Policy", Logic.POSITIVE, barGroupManager.getId());
AggregatePolicyRepresentation aggregatedPolicy = createAggregatedPolicy(client, "Foo and Bar Group Policy", Logic.POSITIVE, DecisionStrategy.AFFIRMATIVE, fooGroupManagerPolicy.getName(), barGroupManagerPolicy.getName());
search = realmAdminClient.realm(realm.getName()).users().search(null, 0, 10);
assertTrue(search.isEmpty());
UserRepresentation myadmin = realm.admin().users().search("myadmin").get(0);
createAllPermission(client, GROUPS_RESOURCE_TYPE, aggregatedPolicy, Set.of(VIEW_MEMBERS, MANAGE_MEMBERSHIP, MANAGE_MEMBERS));
realm.admin().users().get(myadmin.getId()).joinGroup(fooGroupManager.getId());
search = realmAdminClient.realm(realm.getName()).users().search(null, 0, 10);
assertEquals(3, search.size());
assertTrue(search.stream().map(UserRepresentation::getUsername).anyMatch(fooUser.getUsername()::equals));
assertTrue(search.stream().map(UserRepresentation::getUsername).anyMatch(barUser.getUsername()::equals));
aggregatedPolicy.setDecisionStrategy(DecisionStrategy.UNANIMOUS);
client.admin().authorization().policies().aggregate().findById(aggregatedPolicy.getId()).update(aggregatedPolicy);
search = realmAdminClient.realm(realm.getName()).users().search(null, 0, 10);
assertTrue(search.isEmpty());
realm.admin().users().get(myadmin.getId()).joinGroup(barGroupManager.getId());
search = realmAdminClient.realm(realm.getName()).users().search(null, 0, 10);
assertEquals(3, search.size());
}
}