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: Allow enabling RBAC for tenants creation through API #18

Merged
merged 5 commits into from
Jan 24, 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
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.jose.jws.JWSInput;
import org.keycloak.jose.jws.JWSInputException;
Expand All @@ -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;
Expand Down Expand Up @@ -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<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
Loading