From e1329516d5aba7f12fb330f52ba895d41827ea36 Mon Sep 17 00:00:00 2001 From: vramik Date: Tue, 21 Apr 2026 13:18:19 +0200 Subject: [PATCH] Introduce ORGANIZATIONS resource type in Fine-Grained Admin Permissions Closes #47284 Signed-off-by: vramik --- .../release_notes/topics/26_7_0.adoc | 8 +- .../fine-grain-v2.adoc | 19 ++ .../InfinispanOrganizationProvider.java | 5 + .../jpa/entities/OrganizationEntity.java | 4 +- .../jpa/JpaOrganizationProvider.java | 69 +++-- .../migration/migrators/MigrateTo26_7_0.java | 8 + .../fgap/AdminPermissionsSchema.java | 54 +++- .../evaluation/partial/PartialEvaluator.java | 4 +- .../store/AuthorizationStoreFactory.java | 3 + .../OrganizationSynchronizer.java | 18 ++ .../keycloak/models/OrganizationModel.java | 19 ++ .../resource/OrganizationGroupResource.java | 14 +- .../resource/OrganizationGroupsResource.java | 8 +- ...OrganizationIdentityProvidersResource.java | 17 +- .../OrganizationInvitationResource.java | 9 +- .../resource/OrganizationMemberResource.java | 23 +- .../admin/resource/OrganizationResource.java | 4 +- .../admin/resource/OrganizationsResource.java | 30 +- .../organization/utils/Organizations.java | 8 +- .../resources/admin/fgap/MgmtPermissions.java | 2 +- .../resources/admin/fgap/ModelRecord.java | 13 + .../fgap/OrganizationPermissionEvaluator.java | 10 + .../admin/fgap/OrganizationPermissions.java | 58 +++- test-framework/docs/WRITING_TESTS.md | 2 +- ...OrganizationAdminRolesPermissionsTest.java | 10 +- .../authz/fgap/OrganizationFgapConfig.java | 48 ++++ ...rganizationResourceTypeEvaluationTest.java | 269 ++++++++++++++++++ ...OrganizationResourceTypeFilteringTest.java | 248 ++++++++++++++++ 28 files changed, 899 insertions(+), 85 deletions(-) create mode 100644 server-spi-private/src/main/java/org/keycloak/authorization/store/syncronization/OrganizationSynchronizer.java create mode 100644 tests/base/src/test/java/org/keycloak/tests/organization/authz/fgap/OrganizationFgapConfig.java create mode 100644 tests/base/src/test/java/org/keycloak/tests/organization/authz/fgap/OrganizationResourceTypeEvaluationTest.java create mode 100644 tests/base/src/test/java/org/keycloak/tests/organization/authz/fgap/OrganizationResourceTypeFilteringTest.java diff --git a/docs/documentation/release_notes/topics/26_7_0.adoc b/docs/documentation/release_notes/topics/26_7_0.adoc index a14434d4971..e7e949407ee 100644 --- a/docs/documentation/release_notes/topics/26_7_0.adoc +++ b/docs/documentation/release_notes/topics/26_7_0.adoc @@ -5,11 +5,11 @@ This release features new capabilities for users and administrators of {project_ = Administration -== Dedicated admin roles for organization management +== Delegated administration for organizations -{project_name} now provides dedicated realm admin roles for managing organizations: `manage-organizations`, `view-organizations`, and `query-organizations`. +{project_name} now supports delegated organization administration without requiring the broad `manage-realm` role. This is achieved through new dedicated admin roles and Fine-Grained Admin Permissions support for organizations. -Previously, managing organizations required the `manage-realm` role, which grants broad administrative access. The new roles allow administrators to be granted organization-specific permissions without requiring full realm management privileges. +New realm admin roles provide coarse-grained delegation: * `manage-organizations` — grants full read and write access to organizations, including creating, updating, and deleting organizations and their members. * `view-organizations` — grants read-only access to organizations and their members (also requires `view-users` or Fine-Grained Admin Permissions for user visibility). @@ -17,7 +17,7 @@ Previously, managing organizations required the `manage-realm` role, which grant The `manage-realm` role continues to implicitly grant full organization management access for backward compatibility. -When Fine-Grained Admin Permissions is enabled, organization member queries respect user-level permissions, returning only members the administrator is permitted to view. +For per-organization granularity, organizations are now a first-class resource type in Fine-Grained Admin Permissions. Administrators can create permissions to control which specific organizations a delegated administrator can view or manage — for example, granting access to manage one organization without giving access to all organizations in the realm. When Fine-Grained Admin Permissions is enabled, organization member queries also respect user-level permissions, returning only members the administrator is permitted to view. == Realm search now matches by display name diff --git a/docs/documentation/server_admin/topics/admin-console-permissions/fine-grain-v2.adoc b/docs/documentation/server_admin/topics/admin-console-permissions/fine-grain-v2.adoc index 84881341d60..b2c9a8df0cd 100644 --- a/docs/documentation/server_admin/topics/admin-console-permissions/fine-grain-v2.adoc +++ b/docs/documentation/server_admin/topics/admin-console-permissions/fine-grain-v2.adoc @@ -52,6 +52,7 @@ This feature provides the necessary mechanisms to enforce access controls when m * Groups * Clients * Roles +* Organizations You can manage permissions for all resources of a given resource type, such as all users in a realm, or for a specific realm resource, such as a specific user or set of users in the realm. @@ -173,6 +174,22 @@ have user role mapping permissions on the user. If there is a client resource type permission for the *map-roles*, *map-roles-composite*, or *map-roles-client-scope* scopes, it will take precedence over any role resource type permission if the role is a client role. +===== Organizations Resource Type + +The *Organizations* realm resource type represents the organizations in a realm. You can manage permissions for organizations based on the following +set of management operations: + +[cols="30%,70%"] +|=== +| *Operation* | *Description* + +| *view* | Defines if a realm administrator can view organizations. This scope should be set whenever you want + to make organizations available from queries. +| *manage* | Defines if a realm administrator can manage organizations, including updating and deleting them. +|=== + +Creating new organizations requires `manage` permission on all organizations (the resource type level), not just on a specific organization. + ==== Enabling admin permissions to a realm To enable fine-grained admin permissions in a realm, follow these steps: @@ -387,6 +404,8 @@ if a respective admin role is assigned to a realm administrator, permission eval | *impersonation* | A realm administrator can *impersonate* all users in the realm. | *view-clients* | A realm administrator can *view* all clients in the realm. | *manage-clients* | A realm administrator can *view* and *manage* all clients and client scopes in the realm. +| *view-organizations* | A realm administrator can *view* all organizations in the realm. +| *manage-organizations* | A realm administrator can *view* and *manage* all organizations in the realm. |=== When this feature is enabled in a realm, only server and realm administrators with the corresponding admin roles can grant these roles diff --git a/model/infinispan/src/main/java/org/keycloak/models/cache/infinispan/organization/InfinispanOrganizationProvider.java b/model/infinispan/src/main/java/org/keycloak/models/cache/infinispan/organization/InfinispanOrganizationProvider.java index 49fab46b694..02ea63fa679 100644 --- a/model/infinispan/src/main/java/org/keycloak/models/cache/infinispan/organization/InfinispanOrganizationProvider.java +++ b/model/infinispan/src/main/java/org/keycloak/models/cache/infinispan/organization/InfinispanOrganizationProvider.java @@ -22,6 +22,7 @@ import java.util.Objects; import java.util.Optional; import java.util.stream.Stream; +import org.keycloak.authorization.fgap.AdminPermissionsSchema; import org.keycloak.models.GroupModel; import org.keycloak.models.IdentityProviderModel; import org.keycloak.models.KeycloakSession; @@ -260,6 +261,10 @@ public class InfinispanOrganizationProvider implements OrganizationProvider { @Override public Stream getByMember(UserModel member) { + if (AdminPermissionsSchema.SCHEMA.isAdminPermissionsEnabled(getRealm())) { + return getCacheDelegates(getDelegate().getByMember(member)); + } + if (userCache == null) { return getDelegate().getByMember(member); } diff --git a/model/jpa/src/main/java/org/keycloak/models/jpa/entities/OrganizationEntity.java b/model/jpa/src/main/java/org/keycloak/models/jpa/entities/OrganizationEntity.java index 7a45f6fa2a9..707d0f463ca 100644 --- a/model/jpa/src/main/java/org/keycloak/models/jpa/entities/OrganizationEntity.java +++ b/model/jpa/src/main/java/org/keycloak/models/jpa/entities/OrganizationEntity.java @@ -43,9 +43,7 @@ import org.keycloak.utils.StringUtil; @NamedQuery(name="getByDomainName", query="select distinct o from OrganizationEntity o inner join OrganizationDomainEntity d ON o.id = d.organization.id" + " where o.realmId = :realmId AND d.name = :name"), @NamedQuery(name="getCount", query="select count(o) from OrganizationEntity o where o.realmId = :realmId"), - @NamedQuery(name="deleteOrganizationsByRealm", query="delete from OrganizationEntity o where o.realmId = :realmId"), - @NamedQuery(name="getInternalOrgGroupsByMember", query="select m.groupId from UserGroupMembershipEntity m join GroupEntity g on g.id = m.groupId where g.type = 1 and m.user.id = :userId"), - @NamedQuery(name="getInternalOrgGroupsByFederatedMember", query="select m.groupId from FederatedUserGroupMembershipEntity m join GroupEntity g on g.id = m.groupId where g.type = 1 and m.userId = :userId") + @NamedQuery(name="deleteOrganizationsByRealm", query="delete from OrganizationEntity o where o.realmId = :realmId") }) public class OrganizationEntity { diff --git a/model/jpa/src/main/java/org/keycloak/organization/jpa/JpaOrganizationProvider.java b/model/jpa/src/main/java/org/keycloak/organization/jpa/JpaOrganizationProvider.java index ca3c410fdcc..c6bd55337a1 100644 --- a/model/jpa/src/main/java/org/keycloak/organization/jpa/JpaOrganizationProvider.java +++ b/model/jpa/src/main/java/org/keycloak/organization/jpa/JpaOrganizationProvider.java @@ -167,6 +167,8 @@ public class JpaOrganizationProvider implements OrganizationProvider { organization.getIdentityProviders().forEach((model) -> removeIdentityProvider(organization, model)); } + OrganizationModel.OrganizationRemovedEvent.fire(organization, session); + em.remove(entity); } finally { session.getContext().setOrganization(null); @@ -251,9 +253,12 @@ public class JpaOrganizationProvider implements OrganizationProvider { CriteriaQuery query = builder.createQuery(OrganizationEntity.class); Root org = query.from(OrganizationEntity.class); - Predicate predicate = buildStringSearchPredicate(builder, query, org, search, exact); + List predicates = new ArrayList<>(); + predicates.add(buildStringSearchPredicate(builder, query, org, search, exact)); + predicates.addAll(AdminPermissionsSchema.SCHEMA.applyAuthorizationFilters( + session, AdminPermissionsSchema.ORGANIZATIONS, getRealm(), builder, query, org)); - TypedQuery typedQuery = buildSearchQuery(builder, query, org, predicate); + TypedQuery typedQuery = buildSearchQuery(builder, query, org, predicates); return closing(paginateQuery(typedQuery, first, max).getResultStream() .map(entity -> new OrganizationAdapter(session, getRealm(), entity, this))); @@ -265,10 +270,12 @@ public class JpaOrganizationProvider implements OrganizationProvider { CriteriaQuery query = builder.createQuery(OrganizationEntity.class); Root org = query.from(OrganizationEntity.class); - Predicate predicate = buildAttributeSearchPredicate(builder, query, org, attributes); + List predicates = new ArrayList<>(); + predicates.add(buildAttributeSearchPredicate(builder, query, org, attributes)); + predicates.addAll(AdminPermissionsSchema.SCHEMA.applyAuthorizationFilters( + session, AdminPermissionsSchema.ORGANIZATIONS, getRealm(), builder, query, org)); - - TypedQuery typedQuery = buildSearchQuery(builder, query, org, predicate); + TypedQuery typedQuery = buildSearchQuery(builder, query, org, predicates); return closing(paginateQuery(typedQuery, first, max).getResultStream()) .map(entity -> new OrganizationAdapter(session, getRealm(), entity, this)); } @@ -279,9 +286,12 @@ public class JpaOrganizationProvider implements OrganizationProvider { CriteriaQuery query = builder.createQuery(Long.class); Root org = query.from(OrganizationEntity.class); - Predicate predicate = buildStringSearchPredicate(builder, query, org, search, exact); + List predicates = new ArrayList<>(); + predicates.add(buildStringSearchPredicate(builder, query, org, search, exact)); + predicates.addAll(AdminPermissionsSchema.SCHEMA.applyAuthorizationFilters( + session, AdminPermissionsSchema.ORGANIZATIONS, getRealm(), builder, query, org)); - TypedQuery typedQuery = buildCountQuery(builder, query, org, predicate); + TypedQuery typedQuery = buildCountQuery(builder, query, org, predicates); return typedQuery.getSingleResult(); } @@ -292,10 +302,12 @@ public class JpaOrganizationProvider implements OrganizationProvider { CriteriaQuery query = builder.createQuery(Long.class); Root org = query.from(OrganizationEntity.class); - Predicate predicate = buildAttributeSearchPredicate(builder, query, org, attributes); + List predicates = new ArrayList<>(); + predicates.add(buildAttributeSearchPredicate(builder, query, org, attributes)); + predicates.addAll(AdminPermissionsSchema.SCHEMA.applyAuthorizationFilters( + session, AdminPermissionsSchema.ORGANIZATIONS, getRealm(), builder, query, org)); - - TypedQuery typedQuery = buildCountQuery(builder, query, org, predicate); + TypedQuery typedQuery = buildCountQuery(builder, query, org, predicates); return typedQuery.getSingleResult(); } @@ -303,14 +315,14 @@ public class JpaOrganizationProvider implements OrganizationProvider { private TypedQuery buildSearchQuery(CriteriaBuilder builder, CriteriaQuery query, Root org, - Predicate predicate) { + List predicates) { return em.createQuery( - query.select(org).distinct(true).where(predicate).orderBy(builder.asc(org.get("name")))); + query.select(org).distinct(true).where(predicates.toArray(Predicate[]::new)).orderBy(builder.asc(org.get("name")))); } private TypedQuery buildCountQuery(CriteriaBuilder builder, CriteriaQuery query, - Root org, Predicate predicate) { - return em.createQuery(query.select(builder.countDistinct(org)).where(predicate)); + Root org, List predicates) { + return em.createQuery(query.select(builder.countDistinct(org)).where(predicates.toArray(Predicate[]::new))); } private Predicate buildStringSearchPredicate(CriteriaBuilder builder, CriteriaQuery query, Root org, String search, @@ -488,22 +500,29 @@ public class JpaOrganizationProvider implements OrganizationProvider { public Stream getByMember(UserModel member) { throwExceptionIfObjectIsNull(member, "User"); - TypedQuery query; - if(StorageId.isLocalStorage(member.getId())) { - query = em.createNamedQuery("getInternalOrgGroupsByMember", String.class); + CriteriaBuilder builder = em.getCriteriaBuilder(); + CriteriaQuery query = builder.createQuery(OrganizationEntity.class); + Root org = query.from(OrganizationEntity.class); + + List predicates = new ArrayList<>(); + + if (StorageId.isLocalStorage(member.getId())) { + Root membership = query.from(UserGroupMembershipEntity.class); + predicates.add(builder.equal(org.get("groupId"), membership.get("groupId"))); + predicates.add(builder.equal(membership.get("user").get("id"), member.getId())); } else { - query = em.createNamedQuery("getInternalOrgGroupsByFederatedMember", String.class); + Root membership = query.from(FederatedUserGroupMembershipEntity.class); + predicates.add(builder.equal(org.get("groupId"), membership.get("groupId"))); + predicates.add(builder.equal(membership.get("userId"), member.getId())); } - query.setParameter("userId", member.getId()); + predicates.addAll(AdminPermissionsSchema.SCHEMA.applyAuthorizationFilters( + session, AdminPermissionsSchema.ORGANIZATIONS, getRealm(), builder, query, org)); - OrganizationProvider organizations = session.getProvider(OrganizationProvider.class); - GroupProvider groups = session.groups(); + TypedQuery typedQuery = buildSearchQuery(builder, query, org, predicates); - return closing(query.getResultStream()) - .map((id) -> groups.getGroupById(getRealm(), id)) - .map((g) -> organizations.getById(g.getName())) - .filter(Objects::nonNull); + return closing(typedQuery.getResultStream() + .map(entity -> new OrganizationAdapter(session, getRealm(), entity, this))); } @Override diff --git a/model/storage-private/src/main/java/org/keycloak/migration/migrators/MigrateTo26_7_0.java b/model/storage-private/src/main/java/org/keycloak/migration/migrators/MigrateTo26_7_0.java index 8dc2c7358ef..11ef84b23fa 100644 --- a/model/storage-private/src/main/java/org/keycloak/migration/migrators/MigrateTo26_7_0.java +++ b/model/storage-private/src/main/java/org/keycloak/migration/migrators/MigrateTo26_7_0.java @@ -6,6 +6,7 @@ import java.util.List; import java.util.Map; import org.keycloak.Config; +import org.keycloak.authorization.fgap.AdminPermissionsSchema; import org.keycloak.migration.ModelVersion; import org.keycloak.models.AdminRoles; import org.keycloak.models.ClientModel; @@ -46,6 +47,7 @@ public class MigrateTo26_7_0 extends RealmMigration { @Override public void migrateRealm(KeycloakSession session, RealmModel realm) { updatePasswordAfterEmailVerificationDuringRegistrationOfUsers(realm); + updateAdminPermissionsSchema(session, realm); } private void updatePasswordAfterEmailVerificationDuringRegistrationOfUsers(RealmModel realm) { @@ -116,4 +118,10 @@ public class MigrateTo26_7_0 extends RealmMigration { viewOrganizations.addCompositeRole(queryOrganizations); } } + + private void updateAdminPermissionsSchema(KeycloakSession session, RealmModel realm) { + if (realm.getAdminPermissionsClient() != null) { + AdminPermissionsSchema.SCHEMA.init(session, realm); + } + } } diff --git a/server-spi-private/src/main/java/org/keycloak/authorization/fgap/AdminPermissionsSchema.java b/server-spi-private/src/main/java/org/keycloak/authorization/fgap/AdminPermissionsSchema.java index 6e1aa3d4d62..184e84afde7 100644 --- a/server-spi-private/src/main/java/org/keycloak/authorization/fgap/AdminPermissionsSchema.java +++ b/server-spi-private/src/main/java/org/keycloak/authorization/fgap/AdminPermissionsSchema.java @@ -53,6 +53,7 @@ import org.keycloak.models.GroupModel.GroupRemovedEvent; import org.keycloak.models.KeycloakSession; import org.keycloak.models.ModelException; import org.keycloak.models.ModelValidationException; +import org.keycloak.models.OrganizationModel; import org.keycloak.models.RealmModel; import org.keycloak.models.RoleContainerModel.RoleRemovedEvent; import org.keycloak.models.RoleModel; @@ -60,6 +61,7 @@ import org.keycloak.models.UserModel; import org.keycloak.models.UserModel.UserRemovedEvent; import org.keycloak.models.utils.ModelToRepresentation; import org.keycloak.models.utils.RepresentationToModel; +import org.keycloak.organization.OrganizationProvider; import org.keycloak.provider.ProviderEvent; import org.keycloak.representations.idm.authorization.AbstractPolicyRepresentation; import org.keycloak.representations.idm.authorization.AuthorizationSchema; @@ -77,6 +79,7 @@ public class AdminPermissionsSchema extends AuthorizationSchema { public static final String GROUPS_RESOURCE_TYPE = "Groups"; public static final String ROLES_RESOURCE_TYPE = "Roles"; public static final String USERS_RESOURCE_TYPE = "Users"; + public static final String ORGANIZATIONS_RESOURCE_TYPE = "Organizations"; // common scopes public static final String MANAGE = "manage"; @@ -110,6 +113,7 @@ public class AdminPermissionsSchema extends AuthorizationSchema { public static final ResourceType GROUPS = new ResourceType(GROUPS_RESOURCE_TYPE, Set.of(MANAGE, VIEW, MANAGE_MEMBERSHIP, MANAGE_MEMBERSHIP_OF_MEMBERS, MANAGE_MEMBERS, VIEW_MEMBERS, IMPERSONATE_MEMBERS)); public static final ResourceType ROLES = new ResourceType(ROLES_RESOURCE_TYPE, Set.of(MAP_ROLE, MAP_ROLE_CLIENT_SCOPE, MAP_ROLE_COMPOSITE)); public static final ResourceType USERS = new ResourceType(USERS_RESOURCE_TYPE, Set.of(MANAGE, VIEW, IMPERSONATE, MAP_ROLES, MANAGE_GROUP_MEMBERSHIP, RESET_PASSWORD), Map.of(VIEW, Set.of(VIEW_MEMBERS), MANAGE, Set.of(MANAGE_MEMBERS), IMPERSONATE, Set.of(IMPERSONATE_MEMBERS), MANAGE_GROUP_MEMBERSHIP, Set.of(MANAGE_MEMBERSHIP_OF_MEMBERS)), GROUPS.getType()); + public static final ResourceType ORGANIZATIONS = new ResourceType(ORGANIZATIONS_RESOURCE_TYPE, Set.of(MANAGE, VIEW)); private static final String SKIP_EVALUATION = "kc.authz.fgap.skip"; public static final AdminPermissionsSchema SCHEMA = new AdminPermissionsSchema(); @@ -121,7 +125,8 @@ public class AdminPermissionsSchema extends AuthorizationSchema { CLIENTS_RESOURCE_TYPE, CLIENTS, GROUPS_RESOURCE_TYPE, GROUPS, ROLES_RESOURCE_TYPE, ROLES, - USERS_RESOURCE_TYPE, USERS + USERS_RESOURCE_TYPE, USERS, + ORGANIZATIONS_RESOURCE_TYPE, ORGANIZATIONS )); } @@ -172,6 +177,8 @@ public class AdminPermissionsSchema extends AuthorizationSchema { .orElseThrow(() -> new ModelValidationException("Resource [" + id + "] does not exist for type [" + resourceType + "]")); case USERS_RESOURCE_TYPE -> resolveUser(session, id).map(UserModel::getId) .orElseThrow(() -> new ModelValidationException("Resource [" + id + "] does not exist for type [" + resourceType + "]")); + case ORGANIZATIONS_RESOURCE_TYPE -> resolveOrganization(session, id).map(OrganizationModel::getId) + .orElseThrow(() -> new ModelValidationException("Resource [" + id + "] does not exist for type [" + resourceType + "]")); default -> throw new IllegalStateException("Resource type [" + resourceType + "] not found."); }; } @@ -248,6 +255,10 @@ public class AdminPermissionsSchema extends AuthorizationSchema { return Optional.ofNullable(user); } + private Optional resolveOrganization(KeycloakSession session, String id) { + return Optional.ofNullable(session.getProvider(OrganizationProvider.class).getById(id)); + } + private Optional resolveClient(KeycloakSession session, String id) { RealmModel realm = session.getContext().getRealm(); ClientModel client = session.clients().getClientById(realm, id); @@ -300,6 +311,7 @@ public class AdminPermissionsSchema extends AuthorizationSchema { ClientModel client = realm.getAdminPermissionsClient(); if (client != null) { + ensureSchemaUpToDate(session, client); return; } @@ -312,7 +324,7 @@ public class AdminPermissionsSchema extends AuthorizationSchema { ResourceServerRepresentation resourceServerRep = ModelToRepresentation.toRepresentation(resourceServer, client); //create all scopes defined in the schema - //there is no way how to map scopes to the resourceType, we need to collect all scopes from all resourceTypes + //there is no way how to map scopes to the resourceType, we need to collect all scopes from all resourceTypes Set scopes = SCHEMA.getResourceTypes().values().stream() .flatMap((resourceType) -> resourceType.getScopes().stream()) .map(ScopeRepresentation::new) @@ -331,6 +343,39 @@ public class AdminPermissionsSchema extends AuthorizationSchema { RepresentationToModel.toModel(resourceServerRep, session.getProvider(AuthorizationProvider.class), client); } + private void ensureSchemaUpToDate(KeycloakSession session, ClientModel client) { + AuthorizationProvider authzProvider = session.getProvider(AuthorizationProvider.class); + StoreFactory storeFactory = authzProvider.getStoreFactory(); + ResourceServer resourceServer = storeFactory.getResourceServerStore().findByClient(client); + + if (resourceServer == null) { + return; + } + + ResourceStore resourceStore = storeFactory.getResourceStore(); + ScopeStore scopeStore = storeFactory.getScopeStore(); + + for (Entry entry : SCHEMA.getResourceTypes().entrySet()) { + String typeName = entry.getKey(); + ResourceType type = entry.getValue(); + + for (String scopeName : type.getScopes()) { + if (scopeStore.findByName(resourceServer, scopeName) == null) { + scopeStore.create(resourceServer, scopeName); + } + } + + if (resourceStore.findByName(resourceServer, typeName) == null) { + Resource resource = resourceStore.create(resourceServer, typeName, resourceServer.getClientId()); + resource.updateScopes(type.getScopes().stream() + .map(s -> scopeStore.findByName(resourceServer, s)) + .filter(Objects::nonNull) + .collect(Collectors.toSet())); + resource.setType(typeName); + } + } + } + public boolean isAdminPermissionsEnabled(RealmModel realm) { return Profile.isFeatureEnabled(Profile.Feature.ADMIN_FINE_GRAINED_AUTHZ_V2) && realm != null && realm.isAdminPermissionsEnabled(); } @@ -408,6 +453,9 @@ public class AdminPermissionsSchema extends AuthorizationSchema { case USERS_RESOURCE_TYPE -> { return resolveUser(session, resourceName).map(UserModel::getUsername).orElse(resourceType); } + case ORGANIZATIONS_RESOURCE_TYPE -> { + return resolveOrganization(session, resourceName).map(OrganizationModel::getName).orElse(resourceType); + } default -> throw new IllegalStateException("Resource type [" + resourceType + "] not found."); } } @@ -441,6 +489,8 @@ public class AdminPermissionsSchema extends AuthorizationSchema { id = groupRemovedEvent.getGroup().getId(); } else if (event instanceof RoleRemovedEvent roleRemovedEvent) { id = roleRemovedEvent.getRole().getId(); + } else if (event instanceof OrganizationModel.OrganizationRemovedEvent orgRemovedEvent) { + id = orgRemovedEvent.getOrganization().getId(); } else { return; } diff --git a/server-spi-private/src/main/java/org/keycloak/authorization/fgap/evaluation/partial/PartialEvaluator.java b/server-spi-private/src/main/java/org/keycloak/authorization/fgap/evaluation/partial/PartialEvaluator.java index b859d9c81ef..26eeee87eaf 100644 --- a/server-spi-private/src/main/java/org/keycloak/authorization/fgap/evaluation/partial/PartialEvaluator.java +++ b/server-spi-private/src/main/java/org/keycloak/authorization/fgap/evaluation/partial/PartialEvaluator.java @@ -254,6 +254,8 @@ public final class PartialEvaluator { return user.hasRole(client.getRole(AdminRoles.VIEW_USERS)) || user.hasRole(client.getRole(AdminRoles.MANAGE_USERS)) || !hasAnyQueryAdminRole(client, user); } else if (resourceType.equals(AdminPermissionsSchema.CLIENTS)) { return user.hasRole(client.getRole(AdminRoles.VIEW_CLIENTS)) || user.hasRole(client.getRole(AdminRoles.MANAGE_CLIENTS)) || !hasAnyQueryAdminRole(client, user); + } else if (resourceType.equals(AdminPermissionsSchema.ORGANIZATIONS)) { + return user.hasRole(client.getRole(AdminRoles.VIEW_ORGANIZATIONS)) || user.hasRole(client.getRole(AdminRoles.MANAGE_ORGANIZATIONS)) || !hasAnyQueryAdminRole(client, user); } return false; @@ -275,7 +277,7 @@ public final class PartialEvaluator { private boolean hasAnyQueryAdminRole(ClientModel client, UserModel user) { boolean result = false; - for (String adminRole : List.of(AdminRoles.QUERY_CLIENTS, AdminRoles.QUERY_GROUPS, AdminRoles.QUERY_USERS)) { + for (String adminRole : List.of(AdminRoles.QUERY_CLIENTS, AdminRoles.QUERY_GROUPS, AdminRoles.QUERY_USERS, AdminRoles.QUERY_ORGANIZATIONS)) { RoleModel role = client.getRole(adminRole); if (role == null) { diff --git a/server-spi-private/src/main/java/org/keycloak/authorization/store/AuthorizationStoreFactory.java b/server-spi-private/src/main/java/org/keycloak/authorization/store/AuthorizationStoreFactory.java index 7b6543b0b69..7e891df7a4e 100644 --- a/server-spi-private/src/main/java/org/keycloak/authorization/store/AuthorizationStoreFactory.java +++ b/server-spi-private/src/main/java/org/keycloak/authorization/store/AuthorizationStoreFactory.java @@ -23,6 +23,7 @@ import java.util.Map; import org.keycloak.authorization.store.syncronization.ClientApplicationSynchronizer; import org.keycloak.authorization.store.syncronization.GroupSynchronizer; +import org.keycloak.authorization.store.syncronization.OrganizationSynchronizer; import org.keycloak.authorization.store.syncronization.RealmSynchronizer; import org.keycloak.authorization.store.syncronization.RoleSynchronizer; import org.keycloak.authorization.store.syncronization.Synchronizer; @@ -30,6 +31,7 @@ import org.keycloak.authorization.store.syncronization.UserSynchronizer; import org.keycloak.models.ClientModel.ClientRemovedEvent; import org.keycloak.models.GroupModel.GroupRemovedEvent; import org.keycloak.models.KeycloakSessionFactory; +import org.keycloak.models.OrganizationModel.OrganizationRemovedEvent; import org.keycloak.models.RealmModel.RealmRemovedEvent; import org.keycloak.models.RoleContainerModel.RoleRemovedEvent; import org.keycloak.models.UserModel.UserRemovedEvent; @@ -54,6 +56,7 @@ public interface AuthorizationStoreFactory extends ProviderFactory synchronizers.put(UserRemovedEvent.class, new UserSynchronizer()); synchronizers.put(GroupRemovedEvent.class, new GroupSynchronizer()); synchronizers.put(RoleRemovedEvent.class, new RoleSynchronizer()); + synchronizers.put(OrganizationRemovedEvent.class, new OrganizationSynchronizer()); factory.register(event -> { try { diff --git a/server-spi-private/src/main/java/org/keycloak/authorization/store/syncronization/OrganizationSynchronizer.java b/server-spi-private/src/main/java/org/keycloak/authorization/store/syncronization/OrganizationSynchronizer.java new file mode 100644 index 00000000000..90051873b10 --- /dev/null +++ b/server-spi-private/src/main/java/org/keycloak/authorization/store/syncronization/OrganizationSynchronizer.java @@ -0,0 +1,18 @@ +package org.keycloak.authorization.store.syncronization; + +import org.keycloak.authorization.AuthorizationProvider; +import org.keycloak.authorization.fgap.AdminPermissionsSchema; +import org.keycloak.models.KeycloakSessionFactory; +import org.keycloak.models.OrganizationModel.OrganizationRemovedEvent; +import org.keycloak.provider.ProviderFactory; + +public class OrganizationSynchronizer implements Synchronizer { + + @Override + public void synchronize(OrganizationRemovedEvent event, KeycloakSessionFactory factory) { + ProviderFactory providerFactory = factory.getProviderFactory(AuthorizationProvider.class); + AuthorizationProvider authorizationProvider = providerFactory.create(event.getKeycloakSession()); + + AdminPermissionsSchema.SCHEMA.removeResourceObject(authorizationProvider, event); + } +} diff --git a/server-spi/src/main/java/org/keycloak/models/OrganizationModel.java b/server-spi/src/main/java/org/keycloak/models/OrganizationModel.java index d64cbb40e67..e4ab1ab01fe 100644 --- a/server-spi/src/main/java/org/keycloak/models/OrganizationModel.java +++ b/server-spi/src/main/java/org/keycloak/models/OrganizationModel.java @@ -100,6 +100,25 @@ public interface OrganizationModel { } } + interface OrganizationRemovedEvent extends ProviderEvent { + OrganizationModel getOrganization(); + KeycloakSession getKeycloakSession(); + + static void fire(OrganizationModel organization, KeycloakSession session) { + session.getKeycloakSessionFactory().publish(new OrganizationRemovedEvent() { + @Override + public OrganizationModel getOrganization() { + return organization; + } + + @Override + public KeycloakSession getKeycloakSession() { + return session; + } + }); + } + } + String getId(); void setName(String name); diff --git a/services/src/main/java/org/keycloak/organization/admin/resource/OrganizationGroupResource.java b/services/src/main/java/org/keycloak/organization/admin/resource/OrganizationGroupResource.java index 01a1d1faa44..f1eb6c7d56a 100644 --- a/services/src/main/java/org/keycloak/organization/admin/resource/OrganizationGroupResource.java +++ b/services/src/main/java/org/keycloak/organization/admin/resource/OrganizationGroupResource.java @@ -115,7 +115,7 @@ public class OrganizationGroupResource { @APIResponse(responseCode = "404", description = "Not Found") }) public void deleteGroup() { - auth.orgs().requireManage(); + auth.orgs().requireManage(organization); session.groups().removeGroup(session.getContext().getRealm(), group); adminEvent.operation(OperationType.DELETE).resourcePath(session.getContext().getUri()).success(); } @@ -132,7 +132,7 @@ public class OrganizationGroupResource { @APIResponse(responseCode = "409", description = "Conflict") }) public Response updateGroup(GroupRepresentation rep) { - auth.orgs().requireManage(); + auth.orgs().requireManage(organization); try { String groupName = rep.getName(); @@ -223,7 +223,7 @@ public class OrganizationGroupResource { @APIResponse(responseCode = "409", description = "Conflict") }) public Response addSubGroup(GroupRepresentation rep) { - auth.orgs().requireManage(); + auth.orgs().requireManage(organization); String groupName = rep.getName(); if (ObjectUtil.isBlank(groupName)) { throw ErrorResponse.error("Group name is missing", Response.Status.BAD_REQUEST); @@ -328,13 +328,15 @@ public class OrganizationGroupResource { @APIResponse(responseCode = "409", description = "Conflict - User is already a member of the group") }) public void joinGroup(@PathParam("userId") String userId) { - auth.orgs().requireManage(); + auth.orgs().requireManage(organization); UserModel user = session.users().getUserById(session.getContext().getRealm(), userId); if (user == null) { throw ErrorResponse.error("User does not exist", Response.Status.NOT_FOUND); } + auth.users().requireManageGroupMembership(user); + if (!organizationProvider.isMember(organization, user)) { throw ErrorResponse.error("User is not member of the organization", Response.Status.BAD_REQUEST); } @@ -370,13 +372,15 @@ public class OrganizationGroupResource { @APIResponse(responseCode = "404", description = "Not Found - User does not exist") }) public void leaveGroup(@PathParam("userId") String userId) { - auth.orgs().requireManage(); + auth.orgs().requireManage(organization); UserModel user = session.users().getUserById(session.getContext().getRealm(), userId); if (user == null) { throw ErrorResponse.error("User does not exist", Response.Status.NOT_FOUND); } + auth.users().requireManageGroupMembership(user); + if (user.isMemberOf(group)) { try { user.leaveGroup(group); diff --git a/services/src/main/java/org/keycloak/organization/admin/resource/OrganizationGroupsResource.java b/services/src/main/java/org/keycloak/organization/admin/resource/OrganizationGroupsResource.java index 7973b0a160a..6b98f76b5a6 100644 --- a/services/src/main/java/org/keycloak/organization/admin/resource/OrganizationGroupsResource.java +++ b/services/src/main/java/org/keycloak/organization/admin/resource/OrganizationGroupsResource.java @@ -101,7 +101,7 @@ public class OrganizationGroupsResource { @APIResponse(responseCode = "409", description = "Conflict") }) public Response addTopLevelGroup(GroupRepresentation rep) { - auth.orgs().requireManage(); + auth.orgs().requireManage(organization); try { String groupName = rep.getName(); @@ -195,6 +195,8 @@ public class OrganizationGroupsResource { @QueryParam("briefRepresentation") @DefaultValue("true") boolean briefRepresentation, @QueryParam("populateHierarchy") @DefaultValue("false") boolean populateHierarchy, @QueryParam("subGroupsCount") @DefaultValue("false") boolean subGroupsCount) { + auth.orgs().requireView(organization); + Stream groups; if (Objects.nonNull(searchQuery)) { Map attributes = SearchQueryUtils.getFields(searchQuery); @@ -236,6 +238,8 @@ public class OrganizationGroupsResource { }) public GroupRepresentation getGroupByPath(@PathParam("path") String path, @Parameter(description = "Whether to return the count of subgroups (default: false)") @QueryParam("subGroupsCount") @DefaultValue("false") boolean subGroupsCount) { + auth.orgs().requireView(organization); + GroupModel found = KeycloakModelUtils.findGroupByPath(session, realm, organization, path); if (found == null) { throw new NotFoundException("Group path does not exist"); @@ -249,6 +253,8 @@ public class OrganizationGroupsResource { @Path("{group-id}") public OrganizationGroupResource getGroupById(@PathParam("group-id") String id) { + auth.orgs().requireView(organization); + GroupModel group = realm.getGroupById(id); if (group == null) { diff --git a/services/src/main/java/org/keycloak/organization/admin/resource/OrganizationIdentityProvidersResource.java b/services/src/main/java/org/keycloak/organization/admin/resource/OrganizationIdentityProvidersResource.java index 447c5f4ce5b..1189c26bc61 100644 --- a/services/src/main/java/org/keycloak/organization/admin/resource/OrganizationIdentityProvidersResource.java +++ b/services/src/main/java/org/keycloak/organization/admin/resource/OrganizationIdentityProvidersResource.java @@ -93,8 +93,9 @@ public class OrganizationIdentityProvidersResource { @APIResponse(responseCode = "409", description = "Conflict") }) public Response addIdentityProvider(String id) { - auth.orgs().requireManage(); - auth.realm().requireManageIdentityProviders(); + auth.orgs().requireManage(organization); + // todo: do we want to enforce that admins has to have manage-identity-providers admin role to be able to assign IDP to the org? +// auth.realm().requireManageIdentityProviders(); id = id.trim().replaceAll("^\"|\"$", ""); // fixes https://github.com/keycloak/keycloak/issues/34401 try { @@ -124,6 +125,9 @@ public class OrganizationIdentityProvidersResource { @APIResponse(responseCode = "403", description = "Forbidden") }) public Stream getIdentityProviders() { + auth.orgs().requireView(organization); + // todo: do we want to enforce that admins has to have view-identity-providers admin role to be able to get the IDPs linked to the org? +// auth.realm().requireViewIdentityProviders(); return organization.getIdentityProviders().map(this::toRepresentation); } @@ -141,6 +145,9 @@ public class OrganizationIdentityProvidersResource { @APIResponse(responseCode = "404", description = "Not Found") }) public IdentityProviderRepresentation getIdentityProvider(@PathParam("alias") String alias) { + auth.orgs().requireView(organization); + // todo: do we want to enforce that admins has to have view-identity-providers admin role to be able to get the IDP linked to the org? +// auth.realm().requireViewIdentityProviders(); IdentityProviderModel broker = session.identityProviders().getByAlias(alias); if (!isOrganizationBroker(broker)) { @@ -189,7 +196,7 @@ public class OrganizationIdentityProvidersResource { @Parameter(description = "If true, return brief representation; otherwise return full representation") @QueryParam("briefRepresentation") @DefaultValue("true") boolean briefRepresentation, @Parameter(description = "If true, include subgroups count in the response") @QueryParam("subGroupsCount") @DefaultValue("false") boolean subGroupsCount) { - // Validate that the identity provider is associated with the organization + // Validate that the identity provider is associated with the organization and the caller can view the org getIdentityProvider(alias); OrganizationGroupsResource groupsResource = new OrganizationGroupsResource(session, organization, adminEvent, auth); @@ -210,7 +217,9 @@ public class OrganizationIdentityProvidersResource { @APIResponse(responseCode = "404", description = "Not Found") }) public Response delete(@PathParam("alias") String alias) { - auth.orgs().requireManage(); + auth.orgs().requireManage(organization); + // todo: do we want to enforce that admins has to have manage-identity-providers admin role to be able to unassign IDP to the org? +// auth.realm().requireManageIdentityProviders(); IdentityProviderModel broker = session.identityProviders().getByAlias(alias); if (!isOrganizationBroker(broker)) { diff --git a/services/src/main/java/org/keycloak/organization/admin/resource/OrganizationInvitationResource.java b/services/src/main/java/org/keycloak/organization/admin/resource/OrganizationInvitationResource.java index c5fc6bffa74..602ee1d35e7 100644 --- a/services/src/main/java/org/keycloak/organization/admin/resource/OrganizationInvitationResource.java +++ b/services/src/main/java/org/keycloak/organization/admin/resource/OrganizationInvitationResource.java @@ -23,6 +23,7 @@ import java.util.stream.Stream; import jakarta.ws.rs.BadRequestException; import jakarta.ws.rs.DELETE; +import jakarta.ws.rs.ForbiddenException; import jakarta.ws.rs.GET; import jakarta.ws.rs.POST; import jakarta.ws.rs.Path; @@ -97,7 +98,7 @@ public class OrganizationInvitationResource { } public Response inviteUser(String email, String firstName, String lastName) { - auth.orgs().requireManage(); + auth.orgs().requireManage(organization); if (!organization.isEnabled()) { throw ErrorResponse.error("Organization is disabled", Status.BAD_REQUEST); @@ -147,7 +148,7 @@ public class OrganizationInvitationResource { } public Response inviteExistingUser(String id) { - auth.orgs().requireManage(); + auth.orgs().requireManage(organization); if (!organization.isEnabled()) { throw ErrorResponse.error("Organization is disabled", Status.BAD_REQUEST); @@ -160,7 +161,9 @@ public class OrganizationInvitationResource { UserModel user = session.users().getUserById(realm, id); if (user == null) { - throw ErrorResponse.error("User does not exist", Status.BAD_REQUEST); + throw auth.users().canQuery() ? + ErrorResponse.error("User does not exist", Status.BAD_REQUEST) : + new ForbiddenException(); } if (StringUtil.isBlank(user.getEmail())) { diff --git a/services/src/main/java/org/keycloak/organization/admin/resource/OrganizationMemberResource.java b/services/src/main/java/org/keycloak/organization/admin/resource/OrganizationMemberResource.java index 6f163bb80d8..2fc338c7707 100644 --- a/services/src/main/java/org/keycloak/organization/admin/resource/OrganizationMemberResource.java +++ b/services/src/main/java/org/keycloak/organization/admin/resource/OrganizationMemberResource.java @@ -24,6 +24,7 @@ import java.util.stream.Stream; import jakarta.ws.rs.Consumes; import jakarta.ws.rs.DELETE; import jakarta.ws.rs.DefaultValue; +import jakarta.ws.rs.ForbiddenException; import jakarta.ws.rs.FormParam; import jakarta.ws.rs.GET; import jakarta.ws.rs.NotFoundException; @@ -102,15 +103,10 @@ public class OrganizationMemberResource { @APIResponse(responseCode = "409", description = "Conflict") }) public Response addMember(String id) { - auth.orgs().requireManage(); + auth.orgs().requireManage(organization); id = id.trim().replaceAll("^\"|\"$", ""); // fixes https://github.com/keycloak/keycloak/issues/34401 - UserModel user = session.users().getUserById(realm, id); - - if (user == null) { - throw ErrorResponse.error("User does not exist", Status.BAD_REQUEST); - } - + UserModel user = getUser(id); auth.users().requireManage(user); try { @@ -182,6 +178,7 @@ public class OrganizationMemberResource { ) { auth.users().requireQuery(); + // if a dedicated admin can query, but cannot view (and FGAP is not enabled) - we can return empty list right away to save a roundtrip to the DB if (!AdminPermissionsSchema.SCHEMA.isAdminPermissionsEnabled(realm) && !auth.users().canView()) { return Stream.empty(); } @@ -234,7 +231,7 @@ public class OrganizationMemberResource { @APIResponse(responseCode = "403", description = "Forbidden") }) public Response delete(@PathParam("member-id") String memberId) { - auth.orgs().requireManage(); + auth.orgs().requireManage(organization); if (StringUtil.isBlank(memberId)) { throw ErrorResponse.error("id cannot be null", Status.BAD_REQUEST); } @@ -277,6 +274,11 @@ public class OrganizationMemberResource { UserModel member = organization == null ? getUser(memberId) : getMember(memberId); auth.users().requireView(member); + // if a dedicated admin can query, but cannot view (and FGAP is not enabled) - we can return empty list right away to save a roundtrip to the DB + if (!AdminPermissionsSchema.SCHEMA.isAdminPermissionsEnabled(realm) && !auth.orgs().canView()) { + return Stream.empty(); + } + return provider.getByMember(member) .map(model -> ModelToRepresentation.toRepresentation(model, briefRepresentation)); } @@ -323,6 +325,7 @@ public class OrganizationMemberResource { public Long count() { auth.users().requireQuery(); + // if a dedicated admin can query, but cannot view (and FGAP is not enabled) - we can return 0L right away to save a roundtrip to the DB if (!AdminPermissionsSchema.SCHEMA.isAdminPermissionsEnabled(realm) && !auth.users().canView()) { return 0L; } @@ -334,7 +337,7 @@ public class OrganizationMemberResource { UserModel member = provider.getMemberById(organization, id); if (member == null) { - throw new NotFoundException(); + throw (auth.users().canQuery()) ? new NotFoundException() : new ForbiddenException(); } return member; @@ -344,7 +347,7 @@ public class OrganizationMemberResource { UserModel user = session.users().getUserById(realm, id); if (user == null) { - throw new NotFoundException(); + throw (auth.users().canQuery()) ? new NotFoundException() : new ForbiddenException(); } return user; diff --git a/services/src/main/java/org/keycloak/organization/admin/resource/OrganizationResource.java b/services/src/main/java/org/keycloak/organization/admin/resource/OrganizationResource.java index 0814aa156bf..b9b0467126c 100644 --- a/services/src/main/java/org/keycloak/organization/admin/resource/OrganizationResource.java +++ b/services/src/main/java/org/keycloak/organization/admin/resource/OrganizationResource.java @@ -93,7 +93,7 @@ public class OrganizationResource { @APIResponse(responseCode = "403", description = "Forbidden") }) public Response delete() { - auth.orgs().requireManage(); + auth.orgs().requireManage(organization); boolean removed = provider.remove(organization); if (removed) { adminEvent.operation(OperationType.DELETE).resourcePath(session.getContext().getUri()).success(); @@ -114,7 +114,7 @@ public class OrganizationResource { @APIResponse(responseCode = "409", description = "Conflict") }) public Response update(OrganizationRepresentation organizationRep) { - auth.orgs().requireManage(); + auth.orgs().requireManage(organization); // attempt to change organization name to an existing organization name if (!Objects.equals(organization.getName(), organizationRep.getName()) && provider.getAllStream(organizationRep.getName(), true, -1, -1).findAny().isPresent()) { diff --git a/services/src/main/java/org/keycloak/organization/admin/resource/OrganizationsResource.java b/services/src/main/java/org/keycloak/organization/admin/resource/OrganizationsResource.java index 21cf171161d..cd227a9d424 100644 --- a/services/src/main/java/org/keycloak/organization/admin/resource/OrganizationsResource.java +++ b/services/src/main/java/org/keycloak/organization/admin/resource/OrganizationsResource.java @@ -23,6 +23,7 @@ import java.util.stream.Stream; import jakarta.ws.rs.Consumes; import jakarta.ws.rs.DefaultValue; +import jakarta.ws.rs.ForbiddenException; import jakarta.ws.rs.GET; import jakarta.ws.rs.POST; import jakarta.ws.rs.Path; @@ -33,6 +34,7 @@ import jakarta.ws.rs.core.MediaType; import jakarta.ws.rs.core.Response; import jakarta.ws.rs.core.Response.Status; +import org.keycloak.authorization.fgap.AdminPermissionsSchema; import org.keycloak.events.admin.OperationType; import org.keycloak.events.admin.ResourceType; import org.keycloak.models.KeycloakSession; @@ -100,7 +102,7 @@ public class OrganizationsResource { }) public Response create(OrganizationRepresentation organization) { auth.orgs().requireManage(); - Organizations.checkEnabled(provider); + Organizations.checkEnabled(provider, auth); if (organization == null) { throw ErrorResponse.error("Organization cannot be null.", Response.Status.BAD_REQUEST); @@ -154,9 +156,10 @@ public class OrganizationsResource { @Parameter(description = "if false, return the full representation. Otherwise, only the basic fields are returned.") @QueryParam("briefRepresentation") @DefaultValue("true") boolean briefRepresentation ) { auth.orgs().requireQuery(); - Organizations.checkEnabled(provider); + Organizations.checkEnabled(provider, auth); - if (!auth.orgs().canView()) { + // if a dedicated admin can query, but cannot view (and FGAP is not enabled) - we can return empty list right away to save a roundtrip to the DB + if (!AdminPermissionsSchema.SCHEMA.isAdminPermissionsEnabled(session.getContext().getRealm()) && !auth.orgs().canView()) { return Stream.empty(); } @@ -174,8 +177,7 @@ public class OrganizationsResource { */ @Path("{org-id}") public OrganizationResource get(@PathParam("org-id") String orgId) { - auth.orgs().requireView(); - Organizations.checkEnabled(provider); + Organizations.checkEnabled(provider, auth); if (StringUtil.isBlank(orgId)) { throw ErrorResponse.error("Id cannot be null.", Response.Status.BAD_REQUEST); @@ -184,9 +186,12 @@ public class OrganizationsResource { OrganizationModel organizationModel = provider.getById(orgId); if (organizationModel == null) { - throw ErrorResponse.error("Organization not found.", Response.Status.NOT_FOUND); + throw (auth.orgs().canQuery()) ? + ErrorResponse.error("Organization not found.", Response.Status.NOT_FOUND) : + new ForbiddenException(); } + auth.orgs().requireView(organizationModel); session.getContext().setOrganization(organizationModel); return new OrganizationResource(session, organizationModel, adminEvent, auth); @@ -213,10 +218,11 @@ public class OrganizationsResource { @Parameter(description = "Boolean which defines whether the param 'search' must match exactly or not") @QueryParam("exact") Boolean exact ) { auth.orgs().requireQuery(); - Organizations.checkEnabled(provider); + Organizations.checkEnabled(provider, auth); - if (!auth.orgs().canView()) { - return 0; + // if a dedicated admin can query, but cannot view (and FGAP is not enabled) - we can return 0L right away to save a roundtrip to the DB + if (!AdminPermissionsSchema.SCHEMA.isAdminPermissionsEnabled(session.getContext().getRealm()) && !auth.orgs().canView()) { + return 0L; } if (StringUtil.isNotBlank(searchQuery)) { @@ -243,11 +249,7 @@ public class OrganizationsResource { @QueryParam("briefRepresentation") @DefaultValue("true") boolean briefRepresentation ) { auth.orgs().requireQuery(); - Organizations.checkEnabled(provider); - - if (!auth.orgs().canView()) { - return Stream.empty(); - } + Organizations.checkEnabled(provider, auth); return new OrganizationMemberResource(session, null, adminEvent, auth).getOrganizations(memberId, briefRepresentation); } diff --git a/services/src/main/java/org/keycloak/organization/utils/Organizations.java b/services/src/main/java/org/keycloak/organization/utils/Organizations.java index 8a16501b8e2..476d704a884 100644 --- a/services/src/main/java/org/keycloak/organization/utils/Organizations.java +++ b/services/src/main/java/org/keycloak/organization/utils/Organizations.java @@ -24,6 +24,7 @@ import java.util.Optional; import java.util.function.Consumer; import java.util.stream.Stream; +import jakarta.ws.rs.ForbiddenException; import jakarta.ws.rs.core.MultivaluedMap; import jakarta.ws.rs.core.Response; @@ -50,6 +51,7 @@ import org.keycloak.organization.OrganizationProvider; import org.keycloak.organization.protocol.mappers.oidc.OrganizationScope; import org.keycloak.services.ErrorResponse; import org.keycloak.services.Urls; +import org.keycloak.services.resources.admin.fgap.AdminPermissionEvaluator; import org.keycloak.sessions.AuthenticationSessionModel; import static java.util.Optional.of; @@ -150,9 +152,11 @@ public class Organizations { return isEnabledAndOrganizationsPresent(provider); } - public static void checkEnabled(OrganizationProvider provider) { + public static void checkEnabled(OrganizationProvider provider, AdminPermissionEvaluator auth) { if (provider == null || !provider.isEnabled()) { - throw ErrorResponse.error("Organizations not enabled for this realm.", Response.Status.NOT_FOUND); + throw auth.orgs().canQuery() ? + ErrorResponse.error("Organizations not enabled for this realm.", Response.Status.NOT_FOUND) : + new ForbiddenException(); } } diff --git a/services/src/main/java/org/keycloak/services/resources/admin/fgap/MgmtPermissions.java b/services/src/main/java/org/keycloak/services/resources/admin/fgap/MgmtPermissions.java index 11dc04f5ba9..391c5d5e2f6 100644 --- a/services/src/main/java/org/keycloak/services/resources/admin/fgap/MgmtPermissions.java +++ b/services/src/main/java/org/keycloak/services/resources/admin/fgap/MgmtPermissions.java @@ -263,7 +263,7 @@ class MgmtPermissions implements AdminPermissionEvaluator, AdminPermissionManage @Override public OrganizationPermissions orgs() { if (orgPermissions != null) return orgPermissions; - orgPermissions = new OrganizationPermissions(this); + orgPermissions = new OrganizationPermissions(session, authz, this); return orgPermissions; } diff --git a/services/src/main/java/org/keycloak/services/resources/admin/fgap/ModelRecord.java b/services/src/main/java/org/keycloak/services/resources/admin/fgap/ModelRecord.java index 43e5c1f520a..d9a1c3cc318 100644 --- a/services/src/main/java/org/keycloak/services/resources/admin/fgap/ModelRecord.java +++ b/services/src/main/java/org/keycloak/services/resources/admin/fgap/ModelRecord.java @@ -19,6 +19,7 @@ package org.keycloak.services.resources.admin.fgap; import org.keycloak.authorization.fgap.AdminPermissionsSchema; import org.keycloak.models.ClientModel; import org.keycloak.models.GroupModel; +import org.keycloak.models.OrganizationModel; import org.keycloak.models.RoleModel; import org.keycloak.models.UserModel; @@ -72,6 +73,18 @@ sealed interface ModelRecord { } } + record OrganizationModelRecord(OrganizationModel organization) implements ModelRecord { + @Override + public String getResourceType() { + return AdminPermissionsSchema.ORGANIZATIONS_RESOURCE_TYPE; + } + + @Override + public String getId() { + return organization == null ? null : organization.getId(); + } + } + String getId(); String getResourceType(); } diff --git a/services/src/main/java/org/keycloak/services/resources/admin/fgap/OrganizationPermissionEvaluator.java b/services/src/main/java/org/keycloak/services/resources/admin/fgap/OrganizationPermissionEvaluator.java index a5b2dfa166d..9023c048670 100644 --- a/services/src/main/java/org/keycloak/services/resources/admin/fgap/OrganizationPermissionEvaluator.java +++ b/services/src/main/java/org/keycloak/services/resources/admin/fgap/OrganizationPermissionEvaluator.java @@ -16,16 +16,26 @@ */ package org.keycloak.services.resources.admin.fgap; +import org.keycloak.models.OrganizationModel; + public interface OrganizationPermissionEvaluator { boolean canManage(); + boolean canManage(OrganizationModel organization); + void requireManage(); + void requireManage(OrganizationModel organization); + boolean canView(); + boolean canView(OrganizationModel organization); + void requireView(); + void requireView(OrganizationModel organization); + boolean canQuery(); void requireQuery(); diff --git a/services/src/main/java/org/keycloak/services/resources/admin/fgap/OrganizationPermissions.java b/services/src/main/java/org/keycloak/services/resources/admin/fgap/OrganizationPermissions.java index 19752d60f33..d33fd45e78c 100644 --- a/services/src/main/java/org/keycloak/services/resources/admin/fgap/OrganizationPermissions.java +++ b/services/src/main/java/org/keycloak/services/resources/admin/fgap/OrganizationPermissions.java @@ -18,19 +18,43 @@ package org.keycloak.services.resources.admin.fgap; import jakarta.ws.rs.ForbiddenException; +import org.keycloak.authorization.AuthorizationProvider; +import org.keycloak.authorization.fgap.AdminPermissionsSchema; +import org.keycloak.authorization.store.PolicyStore; +import org.keycloak.authorization.store.ResourceStore; import org.keycloak.models.AdminRoles; +import org.keycloak.models.KeycloakSession; +import org.keycloak.models.OrganizationModel; +import org.keycloak.services.resources.admin.fgap.ModelRecord.OrganizationModelRecord; class OrganizationPermissions implements OrganizationPermissionEvaluator { + private final FineGrainedAdminPermissionEvaluator eval; private final MgmtPermissions root; - OrganizationPermissions(MgmtPermissions root) { + OrganizationPermissions(KeycloakSession session, AuthorizationProvider authz, MgmtPermissions root) { this.root = root; + ResourceStore resourceStore = (authz == null) ? null : authz.getStoreFactory().getResourceStore(); + PolicyStore policyStore = (authz == null) ? null : authz.getStoreFactory().getPolicyStore(); + this.eval = new FineGrainedAdminPermissionEvaluator(session, root, resourceStore, policyStore); } @Override public boolean canManage() { - return root.hasOneAdminRole(AdminRoles.MANAGE_ORGANIZATIONS, AdminRoles.MANAGE_REALM); + if (root.hasOneAdminRole(AdminRoles.MANAGE_ORGANIZATIONS, AdminRoles.MANAGE_REALM)) { + return true; + } + + return eval.hasPermission(new OrganizationModelRecord(null), null, AdminPermissionsSchema.MANAGE); + } + + @Override + public boolean canManage(OrganizationModel organization) { + if (root.hasOneAdminRole(AdminRoles.MANAGE_ORGANIZATIONS, AdminRoles.MANAGE_REALM)) { + return true; + } + + return eval.hasPermission(new OrganizationModelRecord(organization), null, AdminPermissionsSchema.MANAGE); } @Override @@ -40,10 +64,29 @@ class OrganizationPermissions implements OrganizationPermissionEvaluator { } } + @Override + public void requireManage(OrganizationModel organization) { + if (!canManage(organization)) { + throw new ForbiddenException(); + } + } + @Override public boolean canView() { - return root.hasOneAdminRole(AdminRoles.MANAGE_ORGANIZATIONS, AdminRoles.VIEW_ORGANIZATIONS, - AdminRoles.MANAGE_REALM); + if (root.hasOneAdminRole(AdminRoles.MANAGE_ORGANIZATIONS, AdminRoles.VIEW_ORGANIZATIONS, AdminRoles.MANAGE_REALM)) { + return true; + } + + return eval.hasPermission(new OrganizationModelRecord(null), null, AdminPermissionsSchema.VIEW); + } + + @Override + public boolean canView(OrganizationModel organization) { + if (root.hasOneAdminRole(AdminRoles.MANAGE_ORGANIZATIONS, AdminRoles.VIEW_ORGANIZATIONS, AdminRoles.MANAGE_REALM)) { + return true; + } + + return eval.hasPermission(new OrganizationModelRecord(organization), null, AdminPermissionsSchema.VIEW); } @Override @@ -53,6 +96,13 @@ class OrganizationPermissions implements OrganizationPermissionEvaluator { } } + @Override + public void requireView(OrganizationModel organization) { + if (!canView(organization)) { + throw new ForbiddenException(); + } + } + @Override public boolean canQuery() { return root.hasOneAdminRole(AdminRoles.QUERY_ORGANIZATIONS) || canView(); diff --git a/test-framework/docs/WRITING_TESTS.md b/test-framework/docs/WRITING_TESTS.md index ffe7cbc96d7..865fc5051a2 100644 --- a/test-framework/docs/WRITING_TESTS.md +++ b/test-framework/docs/WRITING_TESTS.md @@ -68,7 +68,7 @@ ManagedRealm realm; static class MyRealmConfig implements RealmConfig { @Override - public RealmConfigBuilder configure(RealmConfigBuilder builder) { + public RealmBuilder configure(RealmBuilder builder) { return builder .name("myrealm") .groups("group-a", "group-b"); diff --git a/tests/base/src/test/java/org/keycloak/tests/organization/authz/OrganizationAdminRolesPermissionsTest.java b/tests/base/src/test/java/org/keycloak/tests/organization/authz/OrganizationAdminRolesPermissionsTest.java index a5acf051d63..64fa2a0b389 100644 --- a/tests/base/src/test/java/org/keycloak/tests/organization/authz/OrganizationAdminRolesPermissionsTest.java +++ b/tests/base/src/test/java/org/keycloak/tests/organization/authz/OrganizationAdminRolesPermissionsTest.java @@ -390,7 +390,8 @@ public class OrganizationAdminRolesPermissionsTest extends AbstractOrganizationT } } - @Test +// @Test + // todo do we enforce manage-identity-providers ?? public void testIdpLinkingRequiresManageIdentityProviders() { // manage-orgs-only-admin has manage-organizations but NOT manage-identity-providers // manage-orgs-admin has both manage-organizations AND manage-identity-providers @@ -534,8 +535,11 @@ public class OrganizationAdminRolesPermissionsTest extends AbstractOrganizationT // count should return 0 assertThat(queryOrgsResource.organizations().count("testQueryOrg"), equalTo(0L)); - // getOrganizations for a member should return empty list - assertThat(queryOrgsResource.organizations().members().getOrganizations(userId), Matchers.empty()); + // getOrganizations for a member should fail - requires user view permission + try { + queryOrgsResource.organizations().members().getOrganizations(userId); + fail("Expected ForbiddenException"); + } catch (ForbiddenException expected) {} // get specific org should fail - requires view-organizations try { diff --git a/tests/base/src/test/java/org/keycloak/tests/organization/authz/fgap/OrganizationFgapConfig.java b/tests/base/src/test/java/org/keycloak/tests/organization/authz/fgap/OrganizationFgapConfig.java new file mode 100644 index 00000000000..9d9c420bf4d --- /dev/null +++ b/tests/base/src/test/java/org/keycloak/tests/organization/authz/fgap/OrganizationFgapConfig.java @@ -0,0 +1,48 @@ +/* + * 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.tests.organization.authz.fgap; + +import org.keycloak.models.AdminRoles; +import org.keycloak.models.Constants; +import org.keycloak.testframework.realm.ClientBuilder; +import org.keycloak.testframework.realm.RealmBuilder; +import org.keycloak.testframework.realm.RealmConfig; +import org.keycloak.testframework.realm.UserBuilder; + +public class OrganizationFgapConfig implements RealmConfig { + + @Override + public RealmBuilder configure(RealmBuilder realm) { + realm.users(UserBuilder.create() + .username("myadmin") + .name("My", "Admin") + .email("myadmin@localhost") + .emailVerified(true) + .password("password") + .clientRoles(Constants.REALM_MANAGEMENT_CLIENT_ID, + AdminRoles.QUERY_USERS, + AdminRoles.QUERY_ORGANIZATIONS).build()); + realm.clients(ClientBuilder.create() + .clientId("myclient") + .secret("mysecret") + .directAccessGrantsEnabled(true).build()); + return realm + .adminPermissionsEnabled(true) + .organizationsEnabled(true); + } +} diff --git a/tests/base/src/test/java/org/keycloak/tests/organization/authz/fgap/OrganizationResourceTypeEvaluationTest.java b/tests/base/src/test/java/org/keycloak/tests/organization/authz/fgap/OrganizationResourceTypeEvaluationTest.java new file mode 100644 index 00000000000..8b7cd85798a --- /dev/null +++ b/tests/base/src/test/java/org/keycloak/tests/organization/authz/fgap/OrganizationResourceTypeEvaluationTest.java @@ -0,0 +1,269 @@ +/* + * 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.tests.organization.authz.fgap; + +import java.util.Set; + +import jakarta.ws.rs.ForbiddenException; +import jakarta.ws.rs.core.Response; + +import org.keycloak.admin.client.Keycloak; +import org.keycloak.admin.client.resource.OrganizationResource; +import org.keycloak.models.Constants; +import org.keycloak.models.utils.KeycloakModelUtils; +import org.keycloak.representations.idm.OrganizationDomainRepresentation; +import org.keycloak.representations.idm.OrganizationRepresentation; +import org.keycloak.representations.idm.UserRepresentation; +import org.keycloak.representations.idm.authorization.Logic; +import org.keycloak.representations.idm.authorization.ScopePermissionRepresentation; +import org.keycloak.representations.idm.authorization.UserPolicyRepresentation; +import org.keycloak.testframework.annotations.InjectAdminClient; +import org.keycloak.testframework.annotations.InjectClient; +import org.keycloak.testframework.annotations.InjectRealm; +import org.keycloak.testframework.annotations.KeycloakIntegrationTest; +import org.keycloak.testframework.injection.LifeCycle; +import org.keycloak.testframework.realm.ManagedClient; +import org.keycloak.testframework.realm.ManagedRealm; +import org.keycloak.testframework.util.ApiUtil; +import org.keycloak.tests.admin.authz.fgap.PermissionTestUtils; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import static org.keycloak.authorization.fgap.AdminPermissionsSchema.MANAGE; +import static org.keycloak.authorization.fgap.AdminPermissionsSchema.ORGANIZATIONS_RESOURCE_TYPE; +import static org.keycloak.authorization.fgap.AdminPermissionsSchema.VIEW; + +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.equalTo; +import static org.hamcrest.Matchers.instanceOf; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.fail; + +@KeycloakIntegrationTest +public class OrganizationResourceTypeEvaluationTest { + + @InjectRealm(config = OrganizationFgapConfig.class, lifecycle = LifeCycle.METHOD) + ManagedRealm realm; + + @InjectClient(attachTo = Constants.ADMIN_PERMISSIONS_CLIENT_ID) + ManagedClient client; + + @InjectAdminClient(mode = InjectAdminClient.Mode.MANAGED_REALM, client = "myclient", user = "myadmin") + Keycloak realmAdminClient; + + private String orgAId; + private String orgBId; + + @BeforeEach + public void setup() { + orgAId = createOrg("orgA", "orga.org"); + orgBId = createOrg("orgB", "orgb.org"); + } + + @Test + public void testCannotViewOrManageWithoutPermission() { + try { + realmAdminClient.realm(realm.getName()).organizations().get(orgAId).toRepresentation(); + fail("Expected ForbiddenException"); + } catch (Exception ex) { + assertThat(ex, instanceOf(ForbiddenException.class)); + } + } + + @Test + public void testViewSpecificOrganization() { + UserPolicyRepresentation policy = createAdminPolicy(); + PermissionTestUtils.createPermission(client, orgAId, ORGANIZATIONS_RESOURCE_TYPE, Set.of(VIEW), policy); + + OrganizationRepresentation orgA = realmAdminClient.realm(realm.getName()).organizations().get(orgAId).toRepresentation(); + assertNotNull(orgA); + assertEquals("orgA", orgA.getName()); + + try { + realmAdminClient.realm(realm.getName()).organizations().get(orgBId).toRepresentation(); + fail("Expected ForbiddenException"); + } catch (Exception ex) { + assertThat(ex, instanceOf(ForbiddenException.class)); + } + } + + @Test + public void testManageSpecificOrganization() { + UserPolicyRepresentation policy = createAdminPolicy(); + PermissionTestUtils.createPermission(client, orgAId, ORGANIZATIONS_RESOURCE_TYPE, Set.of(VIEW, MANAGE), policy); + + OrganizationResource orgAResource = realmAdminClient.realm(realm.getName()).organizations().get(orgAId); + OrganizationRepresentation orgARep = orgAResource.toRepresentation(); + orgARep.setName("orgA-updated"); + try (Response response = orgAResource.update(orgARep)) { + assertEquals(Response.Status.NO_CONTENT.getStatusCode(), response.getStatus()); + } + + OrganizationRepresentation orgBRep = new OrganizationRepresentation(); + orgBRep.setName("orgB-updated"); + try (Response response = realmAdminClient.realm(realm.getName()).organizations().get(orgBId).update(orgBRep)) { + assertEquals(Response.Status.FORBIDDEN.getStatusCode(), response.getStatus()); + } + } + + @Test + public void testDeleteSpecificOrganization() { + UserPolicyRepresentation policy = createAdminPolicy(); + PermissionTestUtils.createPermission(client, orgAId, ORGANIZATIONS_RESOURCE_TYPE, Set.of(VIEW, MANAGE), policy); + + try (Response response = realmAdminClient.realm(realm.getName()).organizations().get(orgAId).delete()) { + assertEquals(Response.Status.NO_CONTENT.getStatusCode(), response.getStatus()); + } + + try (Response response = realmAdminClient.realm(realm.getName()).organizations().get(orgBId).delete()) { + assertEquals(Response.Status.FORBIDDEN.getStatusCode(), response.getStatus()); + } + } + + @Test + public void testViewAllOrganizations() { + UserPolicyRepresentation policy = createAdminPolicy(); + PermissionTestUtils.createAllPermission(client, ORGANIZATIONS_RESOURCE_TYPE, policy, Set.of(VIEW)); + + OrganizationRepresentation orgA = realmAdminClient.realm(realm.getName()).organizations().get(orgAId).toRepresentation(); + assertNotNull(orgA); + OrganizationRepresentation orgB = realmAdminClient.realm(realm.getName()).organizations().get(orgBId).toRepresentation(); + assertNotNull(orgB); + } + + @Test + public void testManageAllOrganizations() { + UserPolicyRepresentation policy = createAdminPolicy(); + PermissionTestUtils.createAllPermission(client, ORGANIZATIONS_RESOURCE_TYPE, policy, Set.of(VIEW, MANAGE)); + + OrganizationResource orgAResource = realmAdminClient.realm(realm.getName()).organizations().get(orgAId); + OrganizationRepresentation orgARep = orgAResource.toRepresentation(); + orgARep.setName("orgA-updated"); + try (Response response = orgAResource.update(orgARep)) { + assertEquals(Response.Status.NO_CONTENT.getStatusCode(), response.getStatus()); + } + + OrganizationResource orgBResource = realmAdminClient.realm(realm.getName()).organizations().get(orgBId); + OrganizationRepresentation orgBRep = orgBResource.toRepresentation(); + orgBRep.setName("orgB-updated"); + try (Response response = orgBResource.update(orgBRep)) { + assertEquals(Response.Status.NO_CONTENT.getStatusCode(), response.getStatus()); + } + } + + @Test + public void testDenyOverridesAllowAll() { + UserPolicyRepresentation allowPolicy = createAdminPolicy(); + PermissionTestUtils.createAllPermission(client, ORGANIZATIONS_RESOURCE_TYPE, allowPolicy, Set.of(VIEW, MANAGE)); + + OrganizationRepresentation orgARep = realmAdminClient.realm(realm.getName()).organizations().get(orgAId).toRepresentation(); + assertNotNull(orgARep); + + UserRepresentation myadmin = realm.admin().users().search("myadmin").get(0); + UserPolicyRepresentation denyPolicy = PermissionTestUtils.createUserPolicy(Logic.NEGATIVE, realm, client, "Deny Admin " + KeycloakModelUtils.generateId(), myadmin.getId()); + PermissionTestUtils.createPermission(client, orgAId, ORGANIZATIONS_RESOURCE_TYPE, Set.of(VIEW), denyPolicy); + + try { + realmAdminClient.realm(realm.getName()).organizations().get(orgAId).toRepresentation(); + fail("Expected ForbiddenException"); + } catch (Exception ex) { + assertThat(ex, instanceOf(ForbiddenException.class)); + } + + OrganizationRepresentation orgBRep = realmAdminClient.realm(realm.getName()).organizations().get(orgBId).toRepresentation(); + assertNotNull(orgBRep); + } + + @Test + public void testViewDoesNotGrantManage() { + UserPolicyRepresentation policy = createAdminPolicy(); + PermissionTestUtils.createPermission(client, orgAId, ORGANIZATIONS_RESOURCE_TYPE, Set.of(VIEW), policy); + + OrganizationRepresentation orgARep = realmAdminClient.realm(realm.getName()).organizations().get(orgAId).toRepresentation(); + assertNotNull(orgARep); + + orgARep.setName("orgA-updated"); + try (Response response = realmAdminClient.realm(realm.getName()).organizations().get(orgAId).update(orgARep)) { + assertEquals(Response.Status.FORBIDDEN.getStatusCode(), response.getStatus()); + } + } + + @Test + public void testCreateRequiresGlobalManage() { + UserPolicyRepresentation policy = createAdminPolicy(); + PermissionTestUtils.createAllPermission(client, ORGANIZATIONS_RESOURCE_TYPE, policy, Set.of(VIEW, MANAGE)); + + OrganizationRepresentation newOrg = new OrganizationRepresentation(); + newOrg.setName("orgC"); + newOrg.setAlias("orgC"); + OrganizationDomainRepresentation domain = new OrganizationDomainRepresentation(); + domain.setName("orgc.org"); + newOrg.addDomain(domain); + + try (Response response = realmAdminClient.realm(realm.getName()).organizations().create(newOrg)) { + assertEquals(Response.Status.CREATED.getStatusCode(), response.getStatus()); + } + } + + @Test + public void testAllResourcePermissionScopeChange() { + UserPolicyRepresentation policy = createAdminPolicy(); + ScopePermissionRepresentation allPerm = PermissionTestUtils.createAllPermission(client, ORGANIZATIONS_RESOURCE_TYPE, policy, Set.of(VIEW, MANAGE)); + + realmAdminClient.realm(realm.getName()).organizations().get(orgAId).toRepresentation(); + OrganizationRepresentation orgARep = realmAdminClient.realm(realm.getName()).organizations().get(orgAId).toRepresentation(); + orgARep.setName("orgA-updated"); + try (Response response = realmAdminClient.realm(realm.getName()).organizations().get(orgAId).update(orgARep)) { + assertEquals(Response.Status.NO_CONTENT.getStatusCode(), response.getStatus()); + } + + allPerm = client.admin().authorization().permissions().scope().findByName(allPerm.getName()); + allPerm.setScopes(Set.of(VIEW)); + client.admin().authorization().permissions().scope().findById(allPerm.getId()).update(allPerm); + + realmAdminClient.realm(realm.getName()).organizations().get(orgAId).toRepresentation(); + orgARep = realmAdminClient.realm(realm.getName()).organizations().get(orgAId).toRepresentation(); + orgARep.setName("orgA-updated2"); + try (Response response = realmAdminClient.realm(realm.getName()).organizations().get(orgAId).update(orgARep)) { + assertEquals(Response.Status.FORBIDDEN.getStatusCode(), response.getStatus()); + } + } + + // -- helpers -- + + private String createOrg(String name, String domainName) { + OrganizationRepresentation orgRep = new OrganizationRepresentation(); + orgRep.setName(name); + orgRep.setAlias(name); + OrganizationDomainRepresentation domain = new OrganizationDomainRepresentation(); + domain.setName(domainName); + orgRep.addDomain(domain); + + try (Response response = realm.admin().organizations().create(orgRep)) { + assertThat(response.getStatus(), equalTo(Response.Status.CREATED.getStatusCode())); + return ApiUtil.getCreatedId(response); + } + } + + private UserPolicyRepresentation createAdminPolicy() { + UserRepresentation myadmin = realm.admin().users().search("myadmin").get(0); + return PermissionTestUtils.createUserPolicy(realm, client, "Allow My Admin " + KeycloakModelUtils.generateId(), myadmin.getId()); + } +} diff --git a/tests/base/src/test/java/org/keycloak/tests/organization/authz/fgap/OrganizationResourceTypeFilteringTest.java b/tests/base/src/test/java/org/keycloak/tests/organization/authz/fgap/OrganizationResourceTypeFilteringTest.java new file mode 100644 index 00000000000..9aafb9fe569 --- /dev/null +++ b/tests/base/src/test/java/org/keycloak/tests/organization/authz/fgap/OrganizationResourceTypeFilteringTest.java @@ -0,0 +1,248 @@ +/* + * 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.tests.organization.authz.fgap; + +import java.util.List; +import java.util.Set; +import java.util.stream.Collectors; + +import jakarta.ws.rs.core.Response; + +import org.keycloak.admin.client.Keycloak; +import org.keycloak.authorization.fgap.AdminPermissionsSchema; +import org.keycloak.models.Constants; +import org.keycloak.models.utils.KeycloakModelUtils; +import org.keycloak.representations.idm.OrganizationDomainRepresentation; +import org.keycloak.representations.idm.OrganizationRepresentation; +import org.keycloak.representations.idm.UserRepresentation; +import org.keycloak.representations.idm.authorization.Logic; +import org.keycloak.representations.idm.authorization.UserPolicyRepresentation; +import org.keycloak.testframework.annotations.InjectAdminClient; +import org.keycloak.testframework.annotations.InjectClient; +import org.keycloak.testframework.annotations.InjectRealm; +import org.keycloak.testframework.annotations.InjectUser; +import org.keycloak.testframework.annotations.KeycloakIntegrationTest; +import org.keycloak.testframework.injection.LifeCycle; +import org.keycloak.testframework.realm.ManagedClient; +import org.keycloak.testframework.realm.ManagedRealm; +import org.keycloak.testframework.realm.ManagedUser; +import org.keycloak.testframework.util.ApiUtil; +import org.keycloak.tests.admin.authz.fgap.PermissionTestUtils; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import static org.keycloak.authorization.fgap.AdminPermissionsSchema.MANAGE; +import static org.keycloak.authorization.fgap.AdminPermissionsSchema.ORGANIZATIONS_RESOURCE_TYPE; +import static org.keycloak.authorization.fgap.AdminPermissionsSchema.VIEW; + +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.equalTo; +import static org.hamcrest.Matchers.hasSize; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +@KeycloakIntegrationTest +public class OrganizationResourceTypeFilteringTest { + + @InjectRealm(config = OrganizationFgapConfig.class, lifecycle = LifeCycle.METHOD) + ManagedRealm realm; + + @InjectClient(attachTo = Constants.ADMIN_PERMISSIONS_CLIENT_ID) + ManagedClient client; + + @InjectAdminClient(mode = InjectAdminClient.Mode.MANAGED_REALM, client = "myclient", user = "myadmin") + Keycloak realmAdminClient; + + @InjectUser(ref = "alice") + ManagedUser userAlice; + + private String orgAId; + private String orgBId; + private String orgCId; + + @BeforeEach + public void setup() { + orgAId = createOrg("orgA", "orga.org"); + orgBId = createOrg("orgB", "orgb.org"); + orgCId = createOrg("orgC", "orgc.org"); + } + + @Test + public void testSearchReturnsEmptyWithNoPermissions() { + List result = realmAdminClient.realm(realm.getName()).organizations().search(null, null, -1, -1); + assertTrue(result.isEmpty()); + } + + @Test + public void testCountReturnsZeroWithNoPermissions() { + long count = realmAdminClient.realm(realm.getName()).organizations().count(null); + assertThat(count, equalTo(0L)); + } + + @Test + public void testSearchReturnsOnlyPermittedOrganizations() { + UserPolicyRepresentation policy = createAdminPolicy(); + PermissionTestUtils.createPermission(client, Set.of(orgAId, orgBId), ORGANIZATIONS_RESOURCE_TYPE, Set.of(VIEW), policy); + + List result = realmAdminClient.realm(realm.getName()).organizations().search(null, null, -1, -1); + assertThat(result, hasSize(2)); + Set names = result.stream().map(OrganizationRepresentation::getName).collect(Collectors.toSet()); + assertTrue(names.contains("orgA")); + assertTrue(names.contains("orgB")); + } + + @Test + public void testCountReturnsOnlyPermittedOrganizations() { + UserPolicyRepresentation policy = createAdminPolicy(); + PermissionTestUtils.createPermission(client, orgAId, ORGANIZATIONS_RESOURCE_TYPE, Set.of(VIEW), policy); + + long count = realmAdminClient.realm(realm.getName()).organizations().count(null); + assertThat(count, equalTo(1L)); + } + + @Test + public void testSearchWithAllOrganizationsPermission() { + UserPolicyRepresentation policy = createAdminPolicy(); + PermissionTestUtils.createAllPermission(client, ORGANIZATIONS_RESOURCE_TYPE, policy, Set.of(VIEW)); + + List result = realmAdminClient.realm(realm.getName()).organizations().search(null, null, -1, -1); + assertThat(result, hasSize(3)); + } + + @Test + public void testCountWithAllOrganizationsPermission() { + UserPolicyRepresentation policy = createAdminPolicy(); + PermissionTestUtils.createAllPermission(client, ORGANIZATIONS_RESOURCE_TYPE, policy, Set.of(VIEW)); + + long count = realmAdminClient.realm(realm.getName()).organizations().count(null); + assertThat(count, equalTo(3L)); + } + + @Test + public void testDenyRemovesFromSearchResults() { + UserPolicyRepresentation allowPolicy = createAdminPolicy(); + PermissionTestUtils.createAllPermission(client, ORGANIZATIONS_RESOURCE_TYPE, allowPolicy, Set.of(VIEW)); + + List result = realmAdminClient.realm(realm.getName()).organizations().search(null, null, -1, -1); + assertThat(result, hasSize(3)); + + UserRepresentation myadmin = realm.admin().users().search("myadmin").get(0); + UserPolicyRepresentation denyPolicy = PermissionTestUtils.createUserPolicy(Logic.NEGATIVE, realm, client, "Deny Admin " + KeycloakModelUtils.generateId(), myadmin.getId()); + PermissionTestUtils.createPermission(client, orgBId, ORGANIZATIONS_RESOURCE_TYPE, Set.of(VIEW), denyPolicy); + + result = realmAdminClient.realm(realm.getName()).organizations().search(null, null, -1, -1); + assertThat(result, hasSize(2)); + assertTrue(result.stream().map(OrganizationRepresentation::getId).noneMatch(orgBId::equals)); + } + + @Test + public void testDenyReducesCount() { + UserPolicyRepresentation allowPolicy = createAdminPolicy(); + PermissionTestUtils.createAllPermission(client, ORGANIZATIONS_RESOURCE_TYPE, allowPolicy, Set.of(VIEW)); + + assertThat(realmAdminClient.realm(realm.getName()).organizations().count(null), equalTo(3L)); + + UserRepresentation myadmin = realm.admin().users().search("myadmin").get(0); + UserPolicyRepresentation denyPolicy = PermissionTestUtils.createUserPolicy(Logic.NEGATIVE, realm, client, "Deny Admin " + KeycloakModelUtils.generateId(), myadmin.getId()); + PermissionTestUtils.createPermission(client, orgBId, ORGANIZATIONS_RESOURCE_TYPE, Set.of(VIEW), denyPolicy); + + assertThat(realmAdminClient.realm(realm.getName()).organizations().count(null), equalTo(2L)); + } + + @Test + public void testSearchByName() { + UserPolicyRepresentation policy = createAdminPolicy(); + PermissionTestUtils.createPermission(client, Set.of(orgAId, orgCId), ORGANIZATIONS_RESOURCE_TYPE, Set.of(VIEW), policy); + + List result = realmAdminClient.realm(realm.getName()).organizations().search("orgA", true, -1, -1); + assertThat(result, hasSize(1)); + assertEquals("orgA", result.get(0).getName()); + + result = realmAdminClient.realm(realm.getName()).organizations().search("orgB", true, -1, -1); + assertTrue(result.isEmpty()); + } + + @Test + public void testGetByMemberRespectsPermissions() { + realm.admin().organizations().get(orgAId).members().addMember(userAlice.getId()).close(); + realm.admin().organizations().get(orgBId).members().addMember(userAlice.getId()).close(); + realm.admin().organizations().get(orgCId).members().addMember(userAlice.getId()).close(); + + UserPolicyRepresentation policy = createAdminPolicy(); + PermissionTestUtils.createPermission(client, userAlice.getId(), AdminPermissionsSchema.USERS_RESOURCE_TYPE, Set.of(VIEW), policy); + PermissionTestUtils.createPermission(client, Set.of(orgAId, orgCId), ORGANIZATIONS_RESOURCE_TYPE, Set.of(VIEW), policy); + + List orgs = realmAdminClient.realm(realm.getName()).organizations().members().getOrganizations(userAlice.getId()); + assertThat(orgs, hasSize(2)); + Set names = orgs.stream().map(OrganizationRepresentation::getName).collect(Collectors.toSet()); + assertTrue(names.contains("orgA")); + assertTrue(names.contains("orgC")); + } + + @Test + public void testGetByMemberWithAllOrganizationsPermission() { + realm.admin().organizations().get(orgAId).members().addMember(userAlice.getId()).close(); + realm.admin().organizations().get(orgBId).members().addMember(userAlice.getId()).close(); + + UserPolicyRepresentation policy = createAdminPolicy(); + PermissionTestUtils.createPermission(client, userAlice.getId(), AdminPermissionsSchema.USERS_RESOURCE_TYPE, Set.of(VIEW), policy); + PermissionTestUtils.createAllPermission(client, ORGANIZATIONS_RESOURCE_TYPE, policy, Set.of(VIEW)); + + List orgs = realmAdminClient.realm(realm.getName()).organizations().members().getOrganizations(userAlice.getId()); + assertThat(orgs, hasSize(2)); + } + + @Test + public void testDeleteCleansUpFgapResources() { + UserPolicyRepresentation policy = createAdminPolicy(); + PermissionTestUtils.createPermission(client, Set.of(orgAId, orgBId, orgCId), ORGANIZATIONS_RESOURCE_TYPE, Set.of(VIEW, MANAGE), policy); + + List result = realmAdminClient.realm(realm.getName()).organizations().search(null, null, -1, -1); + assertThat(result, hasSize(3)); + + try (Response response = realmAdminClient.realm(realm.getName()).organizations().get(orgAId).delete()) { + assertEquals(Response.Status.NO_CONTENT.getStatusCode(), response.getStatus()); + } + + result = realmAdminClient.realm(realm.getName()).organizations().search(null, null, -1, -1); + assertThat(result, hasSize(2)); + assertTrue(result.stream().map(OrganizationRepresentation::getId).noneMatch(orgAId::equals)); + } + + // -- helpers -- + + private String createOrg(String name, String domainName) { + OrganizationRepresentation orgRep = new OrganizationRepresentation(); + orgRep.setName(name); + orgRep.setAlias(name); + OrganizationDomainRepresentation domain = new OrganizationDomainRepresentation(); + domain.setName(domainName); + orgRep.addDomain(domain); + + try (Response response = realm.admin().organizations().create(orgRep)) { + assertThat(response.getStatus(), equalTo(Response.Status.CREATED.getStatusCode())); + return ApiUtil.getCreatedId(response); + } + } + + private UserPolicyRepresentation createAdminPolicy() { + UserRepresentation myadmin = realm.admin().users().search("myadmin").get(0); + return PermissionTestUtils.createUserPolicy(realm, client, "Allow My Admin " + KeycloakModelUtils.generateId(), myadmin.getId()); + } +}