diff --git a/README.md b/README.md index 1b6da9e..b6b0240 100644 --- a/README.md +++ b/README.md @@ -67,6 +67,19 @@ If they accept the invitation, they can still create another tenant later using Note that tenant creation through the API can be restricted to users with a specified client role by setting the `requiredRoleForTenantCreation` realm attribute. Users who is a member of more than one tenant will be prompted to select an active tenant when they log in. +### Tenant Management Role + +To enable realm-wide tenant management, you can configure a new role, `manage-tenants`, which allows administrators to list and manage all tenants in the realm. + +#### Setup Instructions +1. **Create the Role** + - Navigate to **Clients > realm-management > Roles** in the Keycloak Admin Console. + - Add a new role: `manage-tenants`. + - Optionally provide a description for clarity. + +2. **Assign the Role** + - Assign the `manage-tenants` role to appropriate realm administrators. + ### Token Claims In order to use information about tenants in your application, you need to add it to the token claims. diff --git a/src/main/java/dev/sultanov/keycloak/multitenancy/resource/TenantAdminAuth.java b/src/main/java/dev/sultanov/keycloak/multitenancy/resource/TenantAdminAuth.java index dd695e1..8d569ab 100644 --- a/src/main/java/dev/sultanov/keycloak/multitenancy/resource/TenantAdminAuth.java +++ b/src/main/java/dev/sultanov/keycloak/multitenancy/resource/TenantAdminAuth.java @@ -21,4 +21,12 @@ boolean isTenantAdmin(TenantModel tenantModel) { boolean isTenantMember(TenantModel tenantModel) { return tenantModel.getMembershipByUser(getUser()).isPresent(); } + + boolean isTenantsManager() { + return hasAppRole(getRealmManagementClient(), Constants.TENANTS_MANAGEMENT_ROLE); + } + + private ClientModel getRealmManagementClient() { + return getRealm().getClientByClientId(org.keycloak.models.Constants.REALM_MANAGEMENT_CLIENT_ID); + } } diff --git a/src/main/java/dev/sultanov/keycloak/multitenancy/resource/TenantsResource.java b/src/main/java/dev/sultanov/keycloak/multitenancy/resource/TenantsResource.java index ffb2e41..012bc89 100644 --- a/src/main/java/dev/sultanov/keycloak/multitenancy/resource/TenantsResource.java +++ b/src/main/java/dev/sultanov/keycloak/multitenancy/resource/TenantsResource.java @@ -76,7 +76,7 @@ public Stream listTenants( firstResult = firstResult != null ? firstResult : 0; maxResults = maxResults != null ? maxResults : Constants.DEFAULT_MAX_RESULTS; return tenantProvider.getTenantsStream(realm) - .filter(tenant -> auth.isTenantMember(tenant)) + .filter( tenant -> auth.isTenantsManager() || auth.isTenantMember(tenant)) .filter(tenant -> search.isEmpty() || tenant.getName().contains(search.get())) .skip(firstResult) .limit(maxResults) @@ -87,10 +87,10 @@ public Stream listTenants( public TenantResource getTenantResource(@PathParam("tenantId") String tenantId) { TenantModel model = tenantProvider.getTenantById(realm, tenantId) .orElseThrow(() -> new NotFoundException(String.format("%s not found", tenantId))); - if (!auth.isTenantAdmin(model)) { - throw new ForbiddenException(String.format("Insufficient permission to access %s", tenantId)); - } else { + if (auth.isTenantsManager() || auth.isTenantAdmin(model)) { return new TenantResource(this, model); + } else { + throw new ForbiddenException(String.format("Insufficient permission to access %s", tenantId)); } } } diff --git a/src/main/java/dev/sultanov/keycloak/multitenancy/util/Constants.java b/src/main/java/dev/sultanov/keycloak/multitenancy/util/Constants.java index 5160188..7ebef4f 100644 --- a/src/main/java/dev/sultanov/keycloak/multitenancy/util/Constants.java +++ b/src/main/java/dev/sultanov/keycloak/multitenancy/util/Constants.java @@ -2,6 +2,8 @@ public class Constants { + public static final String TENANTS_MANAGEMENT_ROLE = "manage-tenants"; + public static final String TENANT_ADMIN_ROLE = "tenant-admin"; public static final String TENANT_USER_ROLE = "tenant-user"; diff --git a/src/test/java/dev/sultanov/keycloak/multitenancy/ApiIntegrationTest.java b/src/test/java/dev/sultanov/keycloak/multitenancy/ApiIntegrationTest.java index ccfb26b..11b5f7e 100644 --- a/src/test/java/dev/sultanov/keycloak/multitenancy/ApiIntegrationTest.java +++ b/src/test/java/dev/sultanov/keycloak/multitenancy/ApiIntegrationTest.java @@ -7,29 +7,75 @@ import dev.sultanov.keycloak.multitenancy.resource.representation.TenantRepresentation; import dev.sultanov.keycloak.multitenancy.support.BaseIntegrationTest; import dev.sultanov.keycloak.multitenancy.support.actor.KeycloakAdminCli; +import dev.sultanov.keycloak.multitenancy.support.actor.KeycloakUser; +import dev.sultanov.keycloak.multitenancy.support.api.TenantResource; import dev.sultanov.keycloak.multitenancy.support.browser.AccountPage; import dev.sultanov.keycloak.multitenancy.support.browser.ReviewInvitationsPage; +import dev.sultanov.keycloak.multitenancy.util.Constants; import org.apache.http.HttpStatus; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeAll; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.keycloak.admin.client.CreatedResponseUtil; +import org.keycloak.representations.idm.RoleRepresentation; import org.keycloak.representations.idm.UserRepresentation; public class ApiIntegrationTest extends BaseIntegrationTest { - private KeycloakAdminCli keycloakAdminClient; + private static KeycloakAdminCli keycloakAdminClient; + + @BeforeAll + static void setUpRealm() { + keycloakAdminClient = KeycloakAdminCli.forMainRealm(); + createTenantsManagementRole(); + + } + + private static void createTenantsManagementRole() { + keycloakAdminClient.getRealmResource().clients() + .findByClientId(org.keycloak.models.Constants.REALM_MANAGEMENT_CLIENT_ID) + .stream() + .map(client -> keycloakAdminClient.getRealmResource().clients().get(client.getId())) + .findFirst() + .orElseThrow() + .roles() + .create(new RoleRepresentation(Constants.TENANTS_MANAGEMENT_ROLE, null, false)); + } + + private KeycloakUser tenantAdmin; + private TenantResource tenantResource; + private TenantRepresentation tenant; + + private KeycloakUser tenantsManager; + private TenantResource tenantsManagerTenantResource; + private TenantRepresentation tenantsManagerTenant; @BeforeEach void setUp() { - keycloakAdminClient = KeycloakAdminCli.forMainRealm(); + tenantAdmin = keycloakAdminClient.createVerifiedUser(); + tenantResource = tenantAdmin.createTenant(); + tenant = tenantResource.toRepresentation(); + + tenantsManager = keycloakAdminClient.createVerifiedUser(); + tenantsManagerTenantResource = tenantsManager.createTenant(); + tenantsManagerTenant = tenantsManagerTenantResource.toRepresentation(); + assignTenantsManagementRole(tenantsManager); + } + + @SuppressWarnings("resource") + @AfterEach + void tearDown() { + tenantResource.deleteTenant(); + keycloakAdminClient.getRealmResource().users().delete(tenantAdmin.getUserId()); + + tenantsManagerTenantResource.deleteTenant(); + keycloakAdminClient.getRealmResource().users().delete(tenantsManager.getUserId()); } @Test void adminRevokesMembership_shouldSucceed_whenUserHasAcceptedInvitation() { // given - var adminUser = keycloakAdminClient.createVerifiedUser(); - var tenantResource = adminUser.createTenant(); - var user = keycloakAdminClient.createVerifiedUser(); var invitation = new TenantInvitationRepresentation(); @@ -59,15 +105,13 @@ void adminRevokesMembership_shouldSucceed_whenUserHasAcceptedInvitation() { .extracting(TenantMembershipRepresentation::getUser) .extracting(UserRepresentation::getEmail) .extracting(String::toLowerCase) - .containsExactly(adminUser.getUserData().getEmail().toLowerCase()); + .containsExactly(tenantAdmin.getUserData().getEmail().toLowerCase()); } } @Test void adminUpdatesTenant_shouldReturnNoContent_whenTenantIsSuccessfullyUpdated() { // given - var adminUser = keycloakAdminClient.createVerifiedUser(); - var tenantResource = adminUser.createTenant(); var newName = "new-name"; // when @@ -84,8 +128,6 @@ void adminUpdatesTenant_shouldReturnNoContent_whenTenantIsSuccessfullyUpdated() @Test void adminUpdatesTenant_shouldReturnConflict_whenUpdatedTenantNameAlreadyExists() { // given - var adminUser = keycloakAdminClient.createVerifiedUser(); - var tenantResource = adminUser.createTenant(); var existingTenantName = keycloakAdminClient.createVerifiedUser().createTenant().toRepresentation().getName(); // when @@ -101,9 +143,6 @@ void adminUpdatesTenant_shouldReturnConflict_whenUpdatedTenantNameAlreadyExists( @Test void userRemoval_shouldRemoveTheirMembership() { // given - var adminUser = keycloakAdminClient.createVerifiedUser(); - var tenantResource = adminUser.createTenant(); - var user = keycloakAdminClient.createVerifiedUser(); var invitation = new TenantInvitationRepresentation(); @@ -128,4 +167,56 @@ void userRemoval_shouldRemoveTheirMembership() { .noneMatch(membership -> membership.getUser().getEmail().equalsIgnoreCase(user.getUserData().getEmail())); } } + + @Test + void tenantsManager_shouldListAllTenants() { + // when + var tenants = tenantsManager.tenantsResource().listTenants(null, null, null); + + // then + assertThat(tenants).extracting(TenantRepresentation::getId).containsExactlyInAnyOrder( + tenant.getId(), + tenantsManagerTenant.getId() + ); + } + + @Test + void tenantsManager_shouldListMembers_whenTheyAreNotMemberOfTenant() { + // when + var memberships = tenantsManager.tenantsResource() + .getTenantResource(tenant.getId()) + .memberships() + .listMemberships(null, null, null); + + // then + assertThat(memberships).extracting(TenantMembershipRepresentation::getUser) + .extracting(UserRepresentation::getEmail) + .containsExactly(tenantAdmin.getUserData().getEmail()); + } + + @Test + void tenantsManager_shouldUpdateTenant_whenTheyAreNotMemberOfTenant() { + // given + var newName = "new-name"; + + // when + var request = new TenantRepresentation(); + request.setName(newName); + try (var response = tenantsManager.tenantsResource().getTenantResource(tenant.getId()).updateTenant(request)) { + + // then + assertThat(response.getStatus()).isEqualTo(HttpStatus.SC_NO_CONTENT); + } + + // and user1 should see the updated tenant name + assertThat(tenantResource.toRepresentation().getName()).isEqualTo(newName); + } + + private void assignTenantsManagementRole(KeycloakUser user) { + keycloakAdminClient.assignClientRoleToUser( + org.keycloak.models.Constants.REALM_MANAGEMENT_CLIENT_ID, + Constants.TENANTS_MANAGEMENT_ROLE, + user.getUserId() + ); + } } diff --git a/src/test/java/dev/sultanov/keycloak/multitenancy/support/BaseIntegrationTest.java b/src/test/java/dev/sultanov/keycloak/multitenancy/support/BaseIntegrationTest.java index 906dcb9..c01306e 100644 --- a/src/test/java/dev/sultanov/keycloak/multitenancy/support/BaseIntegrationTest.java +++ b/src/test/java/dev/sultanov/keycloak/multitenancy/support/BaseIntegrationTest.java @@ -23,6 +23,7 @@ public class BaseIntegrationTest { .withProviderClassesFrom("target/classes") .withNetwork(network) .withNetworkAliases("keycloak") + .withEnv("KC_LOGLEVEL", "DEBUG") .withAccessToHost(true); private static final GenericContainer mailhog = new GenericContainer<>("mailhog/mailhog")