Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feature: Tenants management role #53

Merged
merged 2 commits into from
Dec 12, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
Loading