Skip to content

Commit

Permalink
feat: Add tenant attributes with token mapper support
Browse files Browse the repository at this point in the history
- Add support for storing tenant attributes with multi-value capability
- Implement token mapper for including tenant attributes in tokens
- Support both short (<= 255 chars) and long values with efficient storage
- Add attribute search functionality to tenant listing API
- Include validation for attribute keys and values
- Update documentation in README and OpenAPI spec

The tenant attributes feature allows storing and retrieving configuration and metadata at the tenant level, with token mapper support making these attributes available in tokens for applications.
  • Loading branch information
oleaasbo committed Dec 16, 2024
1 parent e22f4b3 commit 7f0a5b2
Show file tree
Hide file tree
Showing 21 changed files with 860 additions and 12 deletions.
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

0 comments on commit 7f0a5b2

Please sign in to comment.