Skip to content

Commit

Permalink
Replace env variable with realm attribute
Browse files Browse the repository at this point in the history
  • Loading branch information
anarsultanov committed Jan 20, 2024
1 parent 0de462d commit 65c3a2a
Show file tree
Hide file tree
Showing 5 changed files with 101 additions and 113 deletions.
47 changes: 29 additions & 18 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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
Expand All @@ -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

<script type="text/javascript">
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@

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;
Expand All @@ -26,7 +25,6 @@
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<TenantAdminAuth> {

Expand All @@ -41,9 +39,9 @@ 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()));
var requiredRole = realm.getAttribute("requiredRoleForTenantCreation");
if (requiredRole != null && !auth.hasAppRole(auth.getClient(), requiredRole)) {
throw new NotAuthorizedException(String.format("Missing required role for tenant creation: %s", requiredRole));
}

TenantModel model = tenantProvider.createTenant(realm, request.getName(), auth.getUser());
Expand Down

This file was deleted.

Original file line number Diff line number Diff line change
Expand Up @@ -5,79 +5,95 @@
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.HashMap;
import java.util.Map;
import java.util.UUID;
import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.keycloak.admin.client.CreatedResponseUtil;

public class TenantCreationRbacIntegrationTest extends BaseIntegrationTest {

@Nested
class WithRequiredRole {
private static final String ATTRIBUTE_NAME = "requiredRoleForTenantCreation";
private static final String REQUIRED_ROLE = "create-tenant";

private static final String TENANT_CREATION_ROLE = "create-tenant";
private KeycloakAdminCli keycloakAdminClient;

@Test
void runTests() {
withCustomKeycloak(Map.of("TENANT_CREATION_ROLE", TENANT_CREATION_ROLE), () -> {
assertThatCanCreateTenantWhenRequiredRoleIsPresent();
assertThatCannotCreateTenantWhenRequiredRoleIsMissing();
});
@BeforeEach
void setUp() {
keycloakAdminClient = KeycloakAdminCli.forMainRealm();
}

@Test
void user_shouldCreateTenant_whenRoleIsRequiredAndPresent() {
// given
addRealmAttribute(ATTRIBUTE_NAME, REQUIRED_ROLE);
var user = keycloakAdminClient.createVerifiedUser();
user.createTenant(); // complete "create-tenant" required action
keycloakAdminClient.assignClientRoleToUser(user.getClientId(), REQUIRED_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();
} finally {
removeRealmAttribute(ATTRIBUTE_NAME);
}
}

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();
}
@Test
void user_shouldFailToCreateTenant_whenRoleIsRequiredButMissing() {
// given
addRealmAttribute(ATTRIBUTE_NAME, REQUIRED_ROLE);
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);
} finally {
removeRealmAttribute(ATTRIBUTE_NAME);
}
}

void assertThatCannotCreateTenantWhenRequiredRoleIsMissing() {
// given
var keycloakAdminClient = KeycloakAdminCli.forMainRealm();
var user = keycloakAdminClient.createVerifiedUser();
user.createTenant(); // complete "create-tenant" required action
@Test
void user_shouldCreateTenant_whenRoleIsNotRequired() {
// given
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)) {
// when
var tenantRepresentation = new TenantRepresentation();
tenantRepresentation.setName("Tenant-" + UUID.randomUUID());
try (var response = user.tenantsResource().createTenant(tenantRepresentation)) {

// then
assertThat(response.getStatus()).isEqualTo(401);
}
// then
assertThat(CreatedResponseUtil.getCreatedId(response)).isNotNull();
}
}

@Nested
class WithoutRequiredRole {
private void addRealmAttribute(String key, String value) {
var realmRepresentation = keycloakAdminClient.getRealmResource().toRepresentation();
var attributes = new HashMap<>(realmRepresentation.getAttributesOrEmpty());
attributes.put(key, value);
realmRepresentation.setAttributes(attributes);

@Test
void user_shouldCreateTenant_whenRoleIsNotRequired() {
// given
var keycloakAdminClient = KeycloakAdminCli.forMainRealm();
var user = keycloakAdminClient.createVerifiedUser();
user.createTenant(); // complete "create-tenant" required action
keycloakAdminClient.getRealmResource().update(realmRepresentation);
}

// when
var tenantRepresentation = new TenantRepresentation();
tenantRepresentation.setName("Tenant-" + UUID.randomUUID());
try (var response = user.tenantsResource().createTenant(tenantRepresentation)) {
private void removeRealmAttribute(String key) {
var realmRepresentation = keycloakAdminClient.getRealmResource().toRepresentation();
Map<String, String> attributes = new HashMap<>(realmRepresentation.getAttributesOrEmpty());
attributes.remove(key);
realmRepresentation.setAttributes(attributes);

// then
assertThat(CreatedResponseUtil.getCreatedId(response)).isNotNull();
}
}
keycloakAdminClient.getRealmResource().update(realmRepresentation);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,6 @@
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;
Expand Down Expand Up @@ -57,26 +56,4 @@ static void afterAll() {
playwright.close();
IntegrationTestContextHolder.clearContext();
}

protected void withCustomKeycloak(Map<String, String> 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);
}
}
}

0 comments on commit 65c3a2a

Please sign in to comment.