From f559d13c8c217b335d6246ad9dc786f41d997c7f Mon Sep 17 00:00:00 2001 From: Anar Sultanov Date: Sat, 20 Jan 2024 17:04:30 +0100 Subject: [PATCH 1/5] feature: Allow enabling RBAC for tenants creation using API --- .../resource/AbstractAdminResource.java | 27 ++---- .../resource/TenantsResource.java | 7 ++ .../multitenancy/util/Environment.java | 14 ++++ .../TenantCreationRbacIntegrationTest.java | 83 +++++++++++++++++++ .../support/BaseIntegrationTest.java | 25 +++++- .../support/actor/KeycloakAdminCli.java | 29 +++++++ .../support/actor/KeycloakUser.java | 6 +- 7 files changed, 171 insertions(+), 20 deletions(-) create mode 100644 src/main/java/dev/sultanov/keycloak/multitenancy/util/Environment.java create mode 100644 src/test/java/dev/sultanov/keycloak/multitenancy/TenantCreationRbacIntegrationTest.java diff --git a/src/main/java/dev/sultanov/keycloak/multitenancy/resource/AbstractAdminResource.java b/src/main/java/dev/sultanov/keycloak/multitenancy/resource/AbstractAdminResource.java index b057e1f..7e864a4 100644 --- a/src/main/java/dev/sultanov/keycloak/multitenancy/resource/AbstractAdminResource.java +++ b/src/main/java/dev/sultanov/keycloak/multitenancy/resource/AbstractAdminResource.java @@ -3,12 +3,10 @@ import dev.sultanov.keycloak.multitenancy.model.TenantProvider; import jakarta.persistence.EntityManager; import jakarta.ws.rs.NotAuthorizedException; -import jakarta.ws.rs.NotFoundException; import java.lang.reflect.Constructor; import java.lang.reflect.InvocationTargetException; import java.lang.reflect.ParameterizedType; import java.lang.reflect.Type; -import org.keycloak.Config; import org.keycloak.connections.jpa.JpaConnectionProvider; import org.keycloak.jose.jws.JWSInput; import org.keycloak.jose.jws.JWSInputException; @@ -20,6 +18,7 @@ import org.keycloak.services.managers.AppAuthManager; import org.keycloak.services.managers.AppAuthManager.BearerTokenAuthenticator; import org.keycloak.services.managers.AuthenticationManager; +import org.keycloak.services.managers.AuthenticationManager.AuthResult; import org.keycloak.services.managers.RealmManager; import org.keycloak.services.resources.admin.AdminAuth; import org.keycloak.services.resources.admin.AdminEventBuilder; @@ -73,31 +72,23 @@ private void setupAuth() { throw new NotAuthorizedException("Unknown realm in token"); } session.getContext().setRealm(realm); - var bearerTokenAuthenticator = new BearerTokenAuthenticator(session); - bearerTokenAuthenticator.setRealm(realm); - bearerTokenAuthenticator.setUriInfo(session.getContext().getUri()); - bearerTokenAuthenticator.setConnection(session.getContext().getConnection()); - bearerTokenAuthenticator.setHeaders(session.getContext().getRequestHeaders()); - AuthenticationManager.AuthResult authResult = bearerTokenAuthenticator.authenticate(); + + var authResult = new BearerTokenAuthenticator(session) + .setRealm(realm) + .setUriInfo(session.getContext().getUri()) + .setConnection(session.getContext().getConnection()) + .setHeaders(session.getContext().getRequestHeaders()) + .authenticate(); if (authResult == null) { throw new NotAuthorizedException("Bearer"); } - ClientModel client - = realm.getName().equals(Config.getAdminRealm()) - ? this.realm.getMasterAdminClient() - : this.realm.getClientByClientId(realmManager.getRealmAdminClientId(this.realm)); - - if (client == null) { - throw new NotFoundException("Could not find client for authorization"); - } - user = authResult.getUser(); try { Class clazz = findSupportedType(); Constructor constructor = clazz.getConstructor(RealmModel.class, AccessToken.class, UserModel.class, ClientModel.class); - auth = constructor.newInstance(realm, token, user, client); + auth = constructor.newInstance(realm, token, user, authResult.getClient()); } catch (NoSuchMethodException | InvocationTargetException | InstantiationException | IllegalAccessException e) { throw new RuntimeException(e); } 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 8226360..27ebf61 100644 --- a/src/main/java/dev/sultanov/keycloak/multitenancy/resource/TenantsResource.java +++ b/src/main/java/dev/sultanov/keycloak/multitenancy/resource/TenantsResource.java @@ -2,6 +2,7 @@ import dev.sultanov.keycloak.multitenancy.model.TenantModel; import dev.sultanov.keycloak.multitenancy.resource.representation.TenantRepresentation; +import dev.sultanov.keycloak.multitenancy.util.Environment; import jakarta.ws.rs.Consumes; import jakarta.ws.rs.GET; import jakarta.ws.rs.NotAuthorizedException; @@ -25,6 +26,7 @@ import org.keycloak.events.admin.OperationType; import org.keycloak.models.Constants; import org.keycloak.models.KeycloakSession; +import org.keycloak.models.RoleModel; public class TenantsResource extends AbstractAdminResource { @@ -39,6 +41,11 @@ public TenantsResource(KeycloakSession session) { @APIResponse(responseCode = "201", description = "Created") public Response createTenant(@RequestBody(required = true) TenantRepresentation request) { + var requiredRole = Environment.getVariable("TENANT_CREATION_ROLE"); + if (requiredRole.isPresent() && !auth.hasAppRole(auth.getClient(), requiredRole.get())) { + throw new NotAuthorizedException(String.format("Missing required role for tenant creation: %s", requiredRole.get())); + } + TenantModel model = tenantProvider.createTenant(realm, request.getName(), auth.getUser()); TenantRepresentation representation = ModelMapper.toRepresentation(model); diff --git a/src/main/java/dev/sultanov/keycloak/multitenancy/util/Environment.java b/src/main/java/dev/sultanov/keycloak/multitenancy/util/Environment.java new file mode 100644 index 0000000..ee1540d --- /dev/null +++ b/src/main/java/dev/sultanov/keycloak/multitenancy/util/Environment.java @@ -0,0 +1,14 @@ +package dev.sultanov.keycloak.multitenancy.util; + +import java.util.Optional; + +public class Environment { + + private Environment() { + throw new AssertionError("Not for instantiation"); + } + + public static Optional getVariable(String name) { + return Optional.ofNullable(System.getenv(name)); + } +} diff --git a/src/test/java/dev/sultanov/keycloak/multitenancy/TenantCreationRbacIntegrationTest.java b/src/test/java/dev/sultanov/keycloak/multitenancy/TenantCreationRbacIntegrationTest.java new file mode 100644 index 0000000..52ac3e1 --- /dev/null +++ b/src/test/java/dev/sultanov/keycloak/multitenancy/TenantCreationRbacIntegrationTest.java @@ -0,0 +1,83 @@ +package dev.sultanov.keycloak.multitenancy; + +import static org.assertj.core.api.Assertions.assertThat; + +import dev.sultanov.keycloak.multitenancy.resource.representation.TenantRepresentation; +import dev.sultanov.keycloak.multitenancy.support.BaseIntegrationTest; +import dev.sultanov.keycloak.multitenancy.support.actor.KeycloakAdminCli; +import java.util.Map; +import java.util.UUID; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.keycloak.admin.client.CreatedResponseUtil; + +public class TenantCreationRbacIntegrationTest extends BaseIntegrationTest { + + @Nested + class WithRequiredRole { + + private static final String TENANT_CREATION_ROLE = "create-tenant"; + + @Test + void runTests() { + withCustomKeycloak(Map.of("TENANT_CREATION_ROLE", TENANT_CREATION_ROLE), () -> { + assertThatCanCreateTenantWhenRequiredRoleIsPresent(); + assertThatCannotCreateTenantWhenRequiredRoleIsMissing(); + }); + } + + void assertThatCanCreateTenantWhenRequiredRoleIsPresent() { + // given + var keycloakAdminClient = KeycloakAdminCli.forMainRealm(); + var user = keycloakAdminClient.createVerifiedUser(); + user.createTenant(); // complete "create-tenant" required action + keycloakAdminClient.assignClientRoleToUser(user.getClientId(), TENANT_CREATION_ROLE, user.getUserId()); + + // when + var tenantRepresentation = new TenantRepresentation(); + tenantRepresentation.setName("Tenant-" + UUID.randomUUID()); + try (var response = user.tenantsResource().createTenant(tenantRepresentation)) { + + // then + assertThat(CreatedResponseUtil.getCreatedId(response)).isNotNull(); + } + } + + void assertThatCannotCreateTenantWhenRequiredRoleIsMissing() { + // given + var keycloakAdminClient = KeycloakAdminCli.forMainRealm(); + var user = keycloakAdminClient.createVerifiedUser(); + user.createTenant(); // complete "create-tenant" required action + + // when + var tenantRepresentation = new TenantRepresentation(); + tenantRepresentation.setName("Tenant-" + UUID.randomUUID()); + try (var response = user.tenantsResource().createTenant(tenantRepresentation)) { + + // then + assertThat(response.getStatus()).isEqualTo(401); + } + } + } + + @Nested + class WithoutRequiredRole { + + @Test + void user_shouldCreateTenant_whenRoleIsNotRequired() { + // given + var keycloakAdminClient = KeycloakAdminCli.forMainRealm(); + var user = keycloakAdminClient.createVerifiedUser(); + user.createTenant(); // complete "create-tenant" required action + + // when + var tenantRepresentation = new TenantRepresentation(); + tenantRepresentation.setName("Tenant-" + UUID.randomUUID()); + try (var response = user.tenantsResource().createTenant(tenantRepresentation)) { + + // then + assertThat(CreatedResponseUtil.getCreatedId(response)).isNotNull(); + } + } + } +} 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 4f51ee8..3bc45b0 100644 --- a/src/test/java/dev/sultanov/keycloak/multitenancy/support/BaseIntegrationTest.java +++ b/src/test/java/dev/sultanov/keycloak/multitenancy/support/BaseIntegrationTest.java @@ -5,6 +5,7 @@ import dasniko.testcontainers.keycloak.KeycloakContainer; import jakarta.ws.rs.client.Client; import jakarta.ws.rs.client.ClientBuilder; +import java.util.Map; import org.junit.jupiter.api.AfterAll; import org.junit.jupiter.api.BeforeAll; import org.testcontainers.containers.GenericContainer; @@ -18,7 +19,7 @@ public class BaseIntegrationTest { private static final Integer MAILHOG_HTTP_PORT = 8025; private static final Network network = Network.newNetwork(); - private static final KeycloakContainer keycloak = new KeycloakContainer("quay.io/keycloak/keycloak:23.0.1") + protected static final KeycloakContainer keycloak = new KeycloakContainer("quay.io/keycloak/keycloak:23.0.1") .withRealmImportFiles("/realm-export.json", "/idp-realm-export.json") .withProviderClassesFrom("target/classes") .withNetwork(network) @@ -56,4 +57,26 @@ static void afterAll() { playwright.close(); IntegrationTestContextHolder.clearContext(); } + + protected void withCustomKeycloak(Map envVars, Runnable runnable) { + var currentContext = IntegrationTestContextHolder.getContext(); + var customKeycloak = new KeycloakContainer(keycloak.getDockerImageName()) + .withNetwork(keycloak.getNetwork()) + .withAccessToHost(true) + .withRealmImportFiles("/realm-export.json", "/idp-realm-export.json") + .withProviderClassesFrom("target/classes") + .withNetworkAliases("keycloak-custom") + .withEnv(envVars); + + try { + customKeycloak.start(); + var updatedContext = new IntegrationTestContext(currentContext.httpClient(), currentContext.browser(), customKeycloak.getAuthServerUrl(), + currentContext.mailhogUrl()); + IntegrationTestContextHolder.setContext(updatedContext); + runnable.run(); + } finally { + keycloak.stop(); + IntegrationTestContextHolder.setContext(currentContext); + } + } } diff --git a/src/test/java/dev/sultanov/keycloak/multitenancy/support/actor/KeycloakAdminCli.java b/src/test/java/dev/sultanov/keycloak/multitenancy/support/actor/KeycloakAdminCli.java index 318999f..cdb2741 100644 --- a/src/test/java/dev/sultanov/keycloak/multitenancy/support/actor/KeycloakAdminCli.java +++ b/src/test/java/dev/sultanov/keycloak/multitenancy/support/actor/KeycloakAdminCli.java @@ -2,14 +2,17 @@ import dev.sultanov.keycloak.multitenancy.support.IntegrationTestContextHolder; import dev.sultanov.keycloak.multitenancy.support.data.UserData; +import java.util.Collections; import java.util.List; import java.util.Map; import org.keycloak.OAuth2Constants; import org.keycloak.admin.client.CreatedResponseUtil; import org.keycloak.admin.client.Keycloak; import org.keycloak.admin.client.KeycloakBuilder; +import org.keycloak.admin.client.resource.ClientResource; import org.keycloak.admin.client.resource.RealmResource; import org.keycloak.representations.idm.CredentialRepresentation; +import org.keycloak.representations.idm.RoleRepresentation; import org.keycloak.representations.idm.UserRepresentation; public class KeycloakAdminCli { @@ -85,6 +88,32 @@ public KeycloakUser createVerifiedUser(UserData userData, Map client.getClientId().equals(clientId)) + .findFirst() + .orElseThrow(); + var clientResource = keycloak.realm(realm).clients().get(clientRepresentation.getId()); + + var roleRepresentation = clientResource.roles().list() + .stream() + .filter(element -> element.getName().equals(role)) + .findFirst() + .orElseGet(() -> createRole(clientResource, role)); + + userResource.roles().clientLevel(clientRepresentation.getId()).add(Collections.singletonList(roleRepresentation)); + } + + private RoleRepresentation createRole(ClientResource clientResource, String roleName) { + var roleRepresentation = new RoleRepresentation(); + roleRepresentation.setName(roleName); + clientResource.roles().create(roleRepresentation); + return clientResource.roles().get(roleName).toRepresentation(); + } + public RealmResource getRealmResource() { return keycloak.realm(realm); } diff --git a/src/test/java/dev/sultanov/keycloak/multitenancy/support/actor/KeycloakUser.java b/src/test/java/dev/sultanov/keycloak/multitenancy/support/actor/KeycloakUser.java index 1b0eba3..3acdf66 100644 --- a/src/test/java/dev/sultanov/keycloak/multitenancy/support/actor/KeycloakUser.java +++ b/src/test/java/dev/sultanov/keycloak/multitenancy/support/actor/KeycloakUser.java @@ -32,6 +32,10 @@ static KeycloakUser from(String userId, UserData userData) { return new KeycloakUser(userId, userData); } + public String getClientId() { + return CLIENT_ID; + } + public String getUserId() { return userId; } @@ -65,7 +69,7 @@ public TenantResource createTenant() { .orElseThrow(); } - private TenantsResource tenantsResource() { + public TenantsResource tenantsResource() { return createClient().proxy(TenantsResource.class, URI.create(IntegrationTestContextHolder.getContext().keycloakUrl() + "/realms/" + REALM_NAME)); } From 0de462df5b52566ced73cd217e9b3d7f3a184543 Mon Sep 17 00:00:00 2001 From: Anar Sultanov Date: Sat, 20 Jan 2024 17:32:00 +0100 Subject: [PATCH 2/5] Update documentation --- README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/README.md b/README.md index 852f870..af754e8 100644 --- a/README.md +++ b/README.md @@ -64,6 +64,7 @@ Now go to `Realm Settings` > `Login` and turn on `Verify email` as only users wi Each new user will be prompted to review pending invitations, if any, and/or create a new tenant if there is no invitation, or they do not accept any of them. If they accept the invitation, they can still create another tenant later using the [API](#api). +Note that tenant creation through the API can be restricted to users with a specified client role by setting the `TENANT_CREATION_ROLE` environment variable. Users who is a member of more than one tenant will be prompted to select an active tenant when they log in. ### Token Claims From 65c3a2aa48598f34a582768626acced57f3d7253 Mon Sep 17 00:00:00 2001 From: Anar Sultanov Date: Sat, 20 Jan 2024 19:37:25 +0100 Subject: [PATCH 3/5] Replace env variable with realm attribute --- README.md | 47 ++++--- .../resource/TenantsResource.java | 8 +- .../multitenancy/util/Environment.java | 14 -- .../TenantCreationRbacIntegrationTest.java | 122 ++++++++++-------- .../support/BaseIntegrationTest.java | 23 ---- 5 files changed, 101 insertions(+), 113 deletions(-) delete mode 100644 src/main/java/dev/sultanov/keycloak/multitenancy/util/Environment.java diff --git a/README.md b/README.md index af754e8..1e9272a 100644 --- a/README.md +++ b/README.md @@ -17,7 +17,8 @@ Keycloak extension for creating multi-tenant IAM for B2B SaaS applications. ## License -This project is licensed under the [Apache License, Version 2.0](http://www.apache.org/licenses/LICENSE-2.0). You can find the full text of the license in the [LICENSE](LICENSE) file. +This project is licensed under the [Apache License, Version 2.0](http://www.apache.org/licenses/LICENSE-2.0). You can find the full text of the license in +the [LICENSE](LICENSE) file. ## Features @@ -64,7 +65,7 @@ Now go to `Realm Settings` > `Login` and turn on `Verify email` as only users wi Each new user will be prompted to review pending invitations, if any, and/or create a new tenant if there is no invitation, or they do not accept any of them. If they accept the invitation, they can still create another tenant later using the [API](#api). -Note that tenant creation through the API can be restricted to users with a specified client role by setting the `TENANT_CREATION_ROLE` environment variable. +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. ### Token Claims @@ -88,33 +89,43 @@ In the same way, you can set up `All tenants` mapper that will add to the token ### IDP and SSO Integration -In a multi-tenant application, it's often necessary for tenants to use their own Identity Provider (IDP). +In a multi-tenant application, it's often necessary for tenants to use their own Identity Provider (IDP). While Keycloak supports identity brokering, it may not be fully compatible with the multi-tenant model introduced by this extension. -One of the issues arises from the lack of a connection between tenants and their respective IDPs. -This prevents the onboarding of users during their initial login using the IDP, since if users haven't been pre-created and added to the required tenant, -the created users will be tenantless and asked to create a new tenant. -To address this issue, this extension introduces the concept of `tenant-specific IDPs` and an additional authenticator that facilitates the creation of required memberships. +One of the issues arises from the lack of a connection between tenants and their respective IDPs. +This prevents the onboarding of users during their initial login using the IDP, since if users haven't been pre-created and added to the required tenant, +the created users will be tenantless and asked to create a new tenant. +To address this issue, this extension introduces the concept of `tenant-specific IDPs` and an additional authenticator that facilitates the creation of required +memberships. -To configure an IDP as tenant-specific, tenants' IDs should be added to the `multi-tenancy.tenants` configuration attribute of the IDP as a **comma-separated list**. +To configure an IDP as tenant-specific, tenants' IDs should be added to the `multi-tenancy.tenants` configuration attribute of the IDP as a **comma-separated +list**. This can be achieved using the standard [Keycloak REST API](https://www.keycloak.org/docs-api/23.0.1/rest-api/index.html#_identity_providers). > **_Note_** -> - _With tenant-specific IDP configuration, the IDP limits access to only the tenants listed in the configuration. -> If a user logs in with the IDP but isn't a member of any of these specified tenants, and automatic membership creation isn't configured, an error will occur._ -> - _IDPs that lack the `multi-tenancy.tenants` configuration attribute are considered public. -> These public IDPs grant access to any tenants for users who are members of those tenants. This ensures compatibility with existing setups and doesn't disrupt previous configurations._ - -To automatically add users as members of all the configured tenants during their initial login, the `Create tenant membership` authenticator should be added to the IDP's `first login flow`. -Alternatively, this authenticator can be added to the `post-login flow`, allowing memberships to be created even for tenants added to the IDP after the user has already been onboarded. +> - _With tenant-specific IDP configuration, the IDP limits access to only the tenants listed in the configuration. + > If a user logs in with the IDP but isn't a member of any of these specified tenants, and automatic membership creation isn't configured, an error will + occur._ +> - _IDPs that lack the `multi-tenancy.tenants` configuration attribute are considered public. + > These public IDPs grant access to any tenants for users who are members of those tenants. This ensures compatibility with existing setups and doesn't + disrupt previous configurations._ + +To automatically add users as members of all the configured tenants during their initial login, the `Create tenant membership` authenticator should be added to +the IDP's `first login flow`. +Alternatively, this authenticator can be added to the `post-login flow`, allowing memberships to be created even for tenants added to the IDP after the user has +already been onboarded. Any memberships created by this authenticator will automatically have the default `tenant-user` role assigned to them. In order to enhance privacy and avoid listing all tenant-specific IDPs on the login page, a custom `Login with SSO` authenticator has been introduced. It can be added as an alternative to the password and other authentication methods in the `browser flow`. -This will either include a `Try Another Way` link on the login page or, if it already exists, add an additional `Single Sign-on (SSO)` option to the available login methods. -If the user selects `Single Sign-on (SSO)`, they will be prompted to enter an IDP alias and then redirected to the corresponding IDP login page if a match is found. +This will either include a `Try Another Way` link on the login page or, if it already exists, add an additional `Single Sign-on (SSO)` option to the available +login methods. +If the user selects `Single Sign-on (SSO)`, they will be prompted to enter an IDP alias and then redirected to the corresponding IDP login page if a match is +found. + +If you'd like to include a direct link to `Single Sign-On (SSO)` on the login page, you can achieve this by modifying the login page template and adding the +following code: -If you'd like to include a direct link to `Single Sign-On (SSO)` on the login page, you can achieve this by modifying the login page template and adding the following code: ```html