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: Add Tenant Attributes with Token Mapper Support #56

Open
wants to merge 2 commits into
base: main
Choose a base branch
from
Open
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
3 changes: 3 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ This project is licensed under the [Apache License, Version 2.0](http://www.apac
## Features

- Creation of a tenant during registration (Required action)
- Customizable tenant attributes with support for multiple values and search (API)
- Invitations for users to join the tenant (API)
- Review of pending invitations (Required action)
- Selection of active tenant on login (Required action)
Expand Down Expand Up @@ -99,6 +100,8 @@ Now information about the selected tenant will be added to token in the followin

In the same way, you can set up `All tenants` mapper that will add to the token claims all tenants that the user is a member of.

Additionally, the `Tenant attribute` mapper allows you to map specific tenant attributes to token claims. This is useful when you need certain tenant configuration or metadata to be available in your application. The mapper supports both single and multi-valued attributes.

### IDP and SSO Integration

In a multi-tenant application, it's often necessary for tenants to use their own Identity Provider (IDP).
Expand Down
27 changes: 27 additions & 0 deletions docs/openapi.json
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,16 @@
"realm" : {
"type" : "string",
"readOnly" : true
},
"attributes" : {
"type" : "object",
"additionalProperties" : {
"type" : "array",
"items" : {
"type" : "string"
}
},
"description" : "Attributes of the tenant"
}
},
"required" : [ "name" ]
Expand Down Expand Up @@ -123,6 +133,13 @@
"type" : "integer",
"format" : "int32"
}
}, {
"description" : "Tenant attribute query",
"name" : "q",
"in" : "query",
"schema" : {
"type" : "string"
}
}, {
"description" : "Tenant name",
"name" : "search",
Expand Down Expand Up @@ -234,6 +251,16 @@
"realm" : {
"type" : "string",
"readOnly" : true
},
"attributes" : {
"type" : "object",
"additionalProperties" : {
"type" : "array",
"items" : {
"type" : "string"
}
},
"description" : "Attributes of the tenant"
}
},
"required" : [ "name" ]
Expand Down
19 changes: 19 additions & 0 deletions docs/openapi.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,13 @@ components:
realm:
type: string
readOnly: true
attributes:
type: object
additionalProperties:
type: array
items:
type: string
description: Attributes of the tenant
required:
- name
paths:
Expand All @@ -92,6 +99,11 @@ paths:
schema:
type: integer
format: int32
- description: Tenant attribute query
name: q
in: query
schema:
type: string
- description: Tenant name
name: search
in: query
Expand Down Expand Up @@ -168,6 +180,13 @@ paths:
realm:
type: string
readOnly: true
attributes:
type: object
additionalProperties:
type: array
items:
type: string
description: Attributes of the tenant
required:
- name
"401":
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@
import java.util.Optional;
import java.util.Set;
import java.util.stream.Stream;
import java.util.Map;
import java.util.List;
import org.keycloak.models.KeycloakSession;
import org.keycloak.models.RealmModel;
import org.keycloak.models.UserModel;
Expand All @@ -18,6 +20,34 @@ public interface TenantModel {

RealmModel getRealm();

/* Attribute */
/**
* Set single value of specified attribute. Remove all other existing values
*
* @param name
* @param value
*/
void setSingleAttribute(String name, String value);

void setAttribute(String name, List<String> values);

void removeAttribute(String name);

/**
* @param name
* @return null if there is not any value of specified attribute or first value otherwise. Don't throw exception if there are more values of the attribute
*/
String getFirstAttribute(String name);

/**
* Returns tenant attributes that match the given name as a stream.
* @param name {@code String} Name of the attribute to be used as a filter.
* @return Stream of all attribute values or empty stream if there are not any values. Never return {@code null}.
*/
Stream<String> getAttributeStream(String name);

Map<String, List<String>> getAttributes();

/* Membership */

TenantMembershipModel grantMembership(UserModel user, Set<String> roles);
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
package dev.sultanov.keycloak.multitenancy.model;

import java.util.Map;
import java.util.Optional;
import java.util.stream.Stream;
import org.keycloak.models.RealmModel;
Expand All @@ -14,6 +15,10 @@ public interface TenantProvider extends Provider {

Stream<TenantModel> getTenantsStream(RealmModel realm);

Stream<TenantModel> getTenantsStream(RealmModel realm, String name, Map<String, String> attributes, Integer firstResult, Integer maxResults);

Stream<TenantModel> getTenantsByAttributeStream(RealmModel realm, String attrName, String attrValue);

boolean deleteTenant(RealmModel realm, String id);

Stream<TenantInvitationModel> getTenantInvitationsStream(RealmModel realm, UserModel user);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,8 @@ public List<Class<?>> getEntities() {
return List.of(
TenantEntity.class,
TenantMembershipEntity.class,
TenantInvitationEntity.class
TenantInvitationEntity.class,
TenantAttributeEntity.class
);
}

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
package dev.sultanov.keycloak.multitenancy.model.entity;

import jakarta.persistence.Access;
import jakarta.persistence.AccessType;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.FetchType;
import jakarta.persistence.Id;
import jakarta.persistence.JoinColumn;
import jakarta.persistence.ManyToOne;
import jakarta.persistence.NamedQueries;
import jakarta.persistence.NamedQuery;
import jakarta.persistence.Table;
import org.hibernate.annotations.Nationalized;
import org.keycloak.storage.jpa.JpaHashUtils;

@NamedQueries({
@NamedQuery(name="deleteTenantAttributesByRealm",
query="delete from TenantAttributeEntity attr where attr.tenant IN (select t from TenantEntity t where t.realmId=:realmId)"),
@NamedQuery(name="deleteTenantAttributesByNameAndTenant",
query="delete from TenantAttributeEntity attr where attr.tenant.id = :tenantId and attr.name = :name"),
@NamedQuery(name="deleteTenantAttributesByNameAndTenantOtherThan",
query="delete from TenantAttributeEntity attr where attr.tenant.id = :tenantId and attr.name = :name and attr.id <> :attrId")
})
@Table(name="TENANT_ATTRIBUTE")
@Entity
public class TenantAttributeEntity {

@Id
@Column(name="ID", length = 36)
@Access(AccessType.PROPERTY)
protected String id;

@ManyToOne(fetch= FetchType.LAZY)
@JoinColumn(name = "TENANT_ID")
protected TenantEntity tenant;

@Column(name = "NAME")
protected String name;

@Nationalized
@Column(name = "VALUE")
protected String value;

@Column(name = "LONG_VALUE_HASH")
private byte[] longValueHash;

@Column(name = "LONG_VALUE_HASH_LOWER_CASE")
private byte[] longValueHashLowerCase;

@Nationalized
@Column(name = "LONG_VALUE")
private String longValue;

public String getId() {
return id;
}

public void setId(String id) {
this.id = id;
}

public String getName() {
return name;
}

public void setName(String name) {
this.name = name;
}

public String getValue() {
if (value != null && longValue != null) {
throw new IllegalStateException(String.format("Tenant with id %s should not have set both `value` and `longValue` for attribute %s.", tenant.getId(), name));
}
return value != null ? value : longValue;
}

public void setValue(String value) {
if (value == null) {
this.value = null;
this.longValue = null;
this.longValueHash = null;
this.longValueHashLowerCase = null;
} else if (value.length() > 255) {
this.value = null;
this.longValue = value;
this.longValueHash = JpaHashUtils.hashForAttributeValue(value);
this.longValueHashLowerCase = JpaHashUtils.hashForAttributeValueLowerCase(value);
} else {
this.value = value;
this.longValue = null;
this.longValueHash = null;
this.longValueHashLowerCase = null;
}
}

public TenantEntity getTenant() {
return tenant;
}

public void setTenant(TenantEntity tenant) {
this.tenant = tenant;
}

@Override
public boolean equals(Object o) {
if (this == o) return true;
if (o == null || getClass() != o.getClass()) return false;
if (!(o instanceof TenantAttributeEntity)) return false;

TenantAttributeEntity that = (TenantAttributeEntity) o;

if (!id.equals(that.getId())) return false;

return true;
}

@Override
public int hashCode() {
return id.hashCode();
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -13,9 +13,15 @@
import java.util.Collection;
import java.util.Objects;

import org.hibernate.annotations.BatchSize;
import org.hibernate.annotations.Fetch;
import org.hibernate.annotations.FetchMode;

@Entity
@Table(name = "TENANT", uniqueConstraints = {@UniqueConstraint(columnNames = {"NAME", "REALM_ID"})})
@NamedQuery(name = "getTenantsByRealmId", query = "SELECT t FROM TenantEntity t WHERE t.realmId = :realmId")
@NamedQuery(name="getTenantsByAttributeNameAndValue", query="select u from TenantEntity u join u.attributes attr where u.realmId = :realmId and attr.name = :name and attr.value = :value")
@NamedQuery(name="getTenantsByAttributeNameAndLongValue", query="select u from TenantEntity u join u.attributes attr where u.realmId = :realmId and attr.name = :name and attr.longValueHash = :longValueHash")
public class TenantEntity {

@Id
Expand All @@ -34,6 +40,11 @@ public class TenantEntity {
@OneToMany(fetch = FetchType.LAZY, cascade = CascadeType.ALL, orphanRemoval = true, mappedBy = "tenant")
private Collection<TenantInvitationEntity> invitations = new ArrayList<>();

@OneToMany(cascade = CascadeType.REMOVE, orphanRemoval = false, mappedBy = "tenant")
@Fetch(FetchMode.SELECT)
@BatchSize(size = 20)
protected Collection<TenantAttributeEntity> attributes = new ArrayList<>();

public String getId() {
return id;
}
Expand Down Expand Up @@ -74,6 +85,17 @@ public void setInvitations(Collection<TenantInvitationEntity> invitations) {
this.invitations = invitations;
}

public Collection<TenantAttributeEntity> getAttributes() {
if (attributes == null) {
attributes = new ArrayList<>();
}
return attributes;
}

public void setAttributes(Collection<TenantAttributeEntity> attributes) {
this.attributes = attributes;
}

@Override
public boolean equals(Object o) {
if (this == o) {
Expand Down
Loading