Skip to content

Commit

Permalink
feature: Allow enabling RBAC for tenants creation through API (#18)
Browse files Browse the repository at this point in the history
  • Loading branch information
anarsultanov authored Jan 24, 2024
1 parent 0aa2a38 commit dddef01
Show file tree
Hide file tree
Showing 6 changed files with 148 additions and 19 deletions.
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `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 Down
Original file line number Diff line number Diff line change
Expand Up @@ -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.http.HttpRequest;
import org.keycloak.http.HttpResponse;
Expand All @@ -22,6 +20,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.Cors;
import org.keycloak.services.resources.admin.AdminAuth;
Expand Down Expand Up @@ -98,31 +97,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<T> clazz = findSupportedType();
Constructor<T> 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);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,11 @@ public TenantsResource(KeycloakSession session) {
@APIResponse(responseCode = "201", description = "Created")
public Response createTenant(@RequestBody(required = true) TenantRepresentation request) {

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());
TenantRepresentation representation = ModelMapper.toRepresentation(model);

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

public class TenantCreationRbacIntegrationTest extends BaseIntegrationTest {

private static final String ATTRIBUTE_NAME = "requiredRoleForTenantCreation";
private static final String REQUIRED_ROLE = "create-tenant";

private KeycloakAdminCli keycloakAdminClient;

@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);
}
}

@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);
}
}

@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)) {

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

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);

keycloakAdminClient.getRealmResource().update(realmRepresentation);
}

private void removeRealmAttribute(String key) {
var realmRepresentation = keycloakAdminClient.getRealmResource().toRepresentation();
Map<String, String> attributes = new HashMap<>(realmRepresentation.getAttributesOrEmpty());
attributes.remove(key);
realmRepresentation.setAttributes(attributes);

keycloakAdminClient.getRealmResource().update(realmRepresentation);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -85,6 +88,32 @@ public KeycloakUser createVerifiedUser(UserData userData, Map<String, List<Strin
}
}

public void assignClientRoleToUser(String clientId, String role, String userId) {
var userResource = keycloak.realm(realm).users().get(userId);

var clientRepresentation = keycloak.realm(realm).clients().findAll()
.stream()
.filter(client -> 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);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down Expand Up @@ -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));
}

Expand Down

0 comments on commit dddef01

Please sign in to comment.