Skip to content

Commit

Permalink
feature: Tenants management role (#53)
Browse files Browse the repository at this point in the history
  • Loading branch information
anarsultanov authored Dec 12, 2024
1 parent e5b54c3 commit 41d3496
Show file tree
Hide file tree
Showing 6 changed files with 132 additions and 17 deletions.
13 changes: 13 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -76,7 +76,7 @@ public Stream<TenantRepresentation> 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)
Expand All @@ -87,10 +87,10 @@ public Stream<TenantRepresentation> 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));
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down
117 changes: 104 additions & 13 deletions src/test/java/dev/sultanov/keycloak/multitenancy/ApiIntegrationTest.java
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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();
Expand All @@ -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()
);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down

0 comments on commit 41d3496

Please sign in to comment.