From 7f0a5b27afdf4dccc8bf842b1c8ec3244a0bb96e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ole-Johannes=20Aasb=C3=B8?= Date: Mon, 16 Dec 2024 16:09:52 +0000 Subject: [PATCH 1/2] feat: Add tenant attributes with token mapper support - 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. --- README.md | 3 + docs/openapi.json | 27 +++ docs/openapi.yaml | 19 ++ .../multitenancy/model/TenantModel.java | 30 +++ .../multitenancy/model/TenantProvider.java | 5 + .../model/entity/EntitiesProvider.java | 3 +- .../model/entity/TenantAttributeEntity.java | 122 ++++++++++ .../model/entity/TenantEntity.java | 22 ++ .../model/jpa/JpaTenantProvider.java | 66 +++++ .../multitenancy/model/jpa/TenantAdapter.java | 102 ++++++++ .../oidc/mappers/TenantAttributeMapper.java | 112 +++++++++ .../multitenancy/resource/ModelMapper.java | 1 + .../multitenancy/resource/TenantResource.java | 21 +- .../resource/TenantsResource.java | 54 ++++- .../representation/TenantRepresentation.java | 5 + ...cloak-multi-tenancy-changelog-20241025.xml | 47 ++++ ...eycloak-multi-tenancy-changelog-master.xml | 1 + .../org.keycloak.protocol.ProtocolMapper | 3 +- .../multitenancy/TenantAttributesTest.java | 226 ++++++++++++++++++ .../support/actor/KeycloakUser.java | 2 +- .../support/api/TenantsResource.java | 1 + 21 files changed, 860 insertions(+), 12 deletions(-) create mode 100644 src/main/java/dev/sultanov/keycloak/multitenancy/model/entity/TenantAttributeEntity.java create mode 100644 src/main/java/dev/sultanov/keycloak/multitenancy/protocol/oidc/mappers/TenantAttributeMapper.java create mode 100644 src/main/resources/META-INF/keycloak-multi-tenancy-changelog-20241025.xml create mode 100644 src/test/java/dev/sultanov/keycloak/multitenancy/TenantAttributesTest.java diff --git a/README.md b/README.md index b6b0240..976c9a7 100644 --- a/README.md +++ b/README.md @@ -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) @@ -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). diff --git a/docs/openapi.json b/docs/openapi.json index 73f0aa9..474fdb3 100644 --- a/docs/openapi.json +++ b/docs/openapi.json @@ -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" ] @@ -123,6 +133,13 @@ "type" : "integer", "format" : "int32" } + }, { + "description" : "Tenant attribute query", + "name" : "q", + "in" : "query", + "schema" : { + "type" : "string" + } }, { "description" : "Tenant name", "name" : "search", @@ -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" ] diff --git a/docs/openapi.yaml b/docs/openapi.yaml index 9bbb2ae..6c25a8e 100644 --- a/docs/openapi.yaml +++ b/docs/openapi.yaml @@ -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: @@ -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 @@ -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": diff --git a/src/main/java/dev/sultanov/keycloak/multitenancy/model/TenantModel.java b/src/main/java/dev/sultanov/keycloak/multitenancy/model/TenantModel.java index 79ee154..8f7efce 100644 --- a/src/main/java/dev/sultanov/keycloak/multitenancy/model/TenantModel.java +++ b/src/main/java/dev/sultanov/keycloak/multitenancy/model/TenantModel.java @@ -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; @@ -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 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 getAttributeStream(String name); + + Map> getAttributes(); + /* Membership */ TenantMembershipModel grantMembership(UserModel user, Set roles); diff --git a/src/main/java/dev/sultanov/keycloak/multitenancy/model/TenantProvider.java b/src/main/java/dev/sultanov/keycloak/multitenancy/model/TenantProvider.java index ac933a5..b445272 100644 --- a/src/main/java/dev/sultanov/keycloak/multitenancy/model/TenantProvider.java +++ b/src/main/java/dev/sultanov/keycloak/multitenancy/model/TenantProvider.java @@ -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; @@ -14,6 +15,10 @@ public interface TenantProvider extends Provider { Stream getTenantsStream(RealmModel realm); + Stream getTenantsStream(RealmModel realm, String name, Map attributes, Integer firstResult, Integer maxResults); + + Stream getTenantsByAttributeStream(RealmModel realm, String attrName, String attrValue); + boolean deleteTenant(RealmModel realm, String id); Stream getTenantInvitationsStream(RealmModel realm, UserModel user); diff --git a/src/main/java/dev/sultanov/keycloak/multitenancy/model/entity/EntitiesProvider.java b/src/main/java/dev/sultanov/keycloak/multitenancy/model/entity/EntitiesProvider.java index fe7732c..16677ab 100644 --- a/src/main/java/dev/sultanov/keycloak/multitenancy/model/entity/EntitiesProvider.java +++ b/src/main/java/dev/sultanov/keycloak/multitenancy/model/entity/EntitiesProvider.java @@ -10,7 +10,8 @@ public List> getEntities() { return List.of( TenantEntity.class, TenantMembershipEntity.class, - TenantInvitationEntity.class + TenantInvitationEntity.class, + TenantAttributeEntity.class ); } diff --git a/src/main/java/dev/sultanov/keycloak/multitenancy/model/entity/TenantAttributeEntity.java b/src/main/java/dev/sultanov/keycloak/multitenancy/model/entity/TenantAttributeEntity.java new file mode 100644 index 0000000..523a88a --- /dev/null +++ b/src/main/java/dev/sultanov/keycloak/multitenancy/model/entity/TenantAttributeEntity.java @@ -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(); + } +} \ No newline at end of file diff --git a/src/main/java/dev/sultanov/keycloak/multitenancy/model/entity/TenantEntity.java b/src/main/java/dev/sultanov/keycloak/multitenancy/model/entity/TenantEntity.java index d0c47ca..8736d4d 100644 --- a/src/main/java/dev/sultanov/keycloak/multitenancy/model/entity/TenantEntity.java +++ b/src/main/java/dev/sultanov/keycloak/multitenancy/model/entity/TenantEntity.java @@ -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 @@ -34,6 +40,11 @@ public class TenantEntity { @OneToMany(fetch = FetchType.LAZY, cascade = CascadeType.ALL, orphanRemoval = true, mappedBy = "tenant") private Collection invitations = new ArrayList<>(); + @OneToMany(cascade = CascadeType.REMOVE, orphanRemoval = false, mappedBy = "tenant") + @Fetch(FetchMode.SELECT) + @BatchSize(size = 20) + protected Collection attributes = new ArrayList<>(); + public String getId() { return id; } @@ -74,6 +85,17 @@ public void setInvitations(Collection invitations) { this.invitations = invitations; } + public Collection getAttributes() { + if (attributes == null) { + attributes = new ArrayList<>(); + } + return attributes; + } + + public void setAttributes(Collection attributes) { + this.attributes = attributes; + } + @Override public boolean equals(Object o) { if (this == o) { diff --git a/src/main/java/dev/sultanov/keycloak/multitenancy/model/jpa/JpaTenantProvider.java b/src/main/java/dev/sultanov/keycloak/multitenancy/model/jpa/JpaTenantProvider.java index 8942f60..1f953b5 100644 --- a/src/main/java/dev/sultanov/keycloak/multitenancy/model/jpa/JpaTenantProvider.java +++ b/src/main/java/dev/sultanov/keycloak/multitenancy/model/jpa/JpaTenantProvider.java @@ -5,12 +5,22 @@ import dev.sultanov.keycloak.multitenancy.model.TenantModel; import dev.sultanov.keycloak.multitenancy.model.TenantModel.TenantRemovedEvent; import dev.sultanov.keycloak.multitenancy.model.TenantProvider; +import dev.sultanov.keycloak.multitenancy.model.entity.TenantAttributeEntity; import dev.sultanov.keycloak.multitenancy.model.entity.TenantEntity; import dev.sultanov.keycloak.multitenancy.model.entity.TenantInvitationEntity; import dev.sultanov.keycloak.multitenancy.model.entity.TenantMembershipEntity; import dev.sultanov.keycloak.multitenancy.util.Constants; import jakarta.persistence.EntityManager; import jakarta.persistence.TypedQuery; +import jakarta.persistence.criteria.CriteriaBuilder; +import jakarta.persistence.criteria.CriteriaQuery; +import jakarta.persistence.criteria.Join; +import jakarta.persistence.criteria.Predicate; +import jakarta.persistence.criteria.Root; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; import java.util.Optional; import java.util.Set; import java.util.stream.Stream; @@ -18,6 +28,10 @@ import org.keycloak.models.RealmModel; import org.keycloak.models.UserModel; import org.keycloak.models.utils.KeycloakModelUtils; +import org.keycloak.storage.jpa.JpaHashUtils; + +import static org.keycloak.models.jpa.PaginationUtils.paginateQuery; +import static org.keycloak.utils.StreamsUtil.closing; public class JpaTenantProvider implements TenantProvider { @@ -62,6 +76,58 @@ public Stream getTenantsStream(RealmModel realm) { return query.getResultStream().map(t -> new TenantAdapter(session, realm, em, t)); } + @Override + public Stream getTenantsStream(RealmModel realm, String name, Map attributes, Integer firstResult, Integer maxResults) { + CriteriaBuilder builder = em.getCriteriaBuilder(); + CriteriaQuery queryBuilder = builder.createQuery(TenantEntity.class); + Root root = queryBuilder.from(TenantEntity.class); + + List predicates = new ArrayList<>(); + + predicates.add(builder.equal(root.get("realmId"), realm.getId())); + if (name != null && !name.isEmpty()) { + predicates.add(builder.like(root.get("name"), "%" + name + "%")); + } + + for (Map.Entry entry : attributes.entrySet()) { + String key = entry.getKey(); + if (key == null || key.isEmpty()) { + continue; + } + String value = entry.getValue(); + + Join attributeJoin = root.join("attributes"); + + Predicate attrNamePredicate = builder.equal(attributeJoin.get("name"), key); + Predicate attrValuePredicate = builder.equal(attributeJoin.get("value"), value); + predicates.add(builder.and(attrNamePredicate, attrValuePredicate)); + } + + Predicate finalPredicate = builder.and(predicates.toArray(new Predicate[0])); + queryBuilder.where(finalPredicate).orderBy(builder.asc(root.get("name"))); + + TypedQuery query = em.createQuery(queryBuilder); + return closing(paginateQuery(query, firstResult, maxResults).getResultStream()) + .map(tenantEntity -> new TenantAdapter(session, realm, em, tenantEntity)); + } + + @Override + public Stream getTenantsByAttributeStream(RealmModel realm, String attrName, String attrValue) { + boolean longAttribute = attrValue != null && attrValue.length() > 255; + TypedQuery query = longAttribute ? + em.createNamedQuery("getTenantsByAttributeNameAndLongValue", TenantEntity.class) + .setParameter("realmId", realm.getId()) + .setParameter("name", attrName) + .setParameter("longValueHash", JpaHashUtils.hashForAttributeValue(attrValue)): + em.createNamedQuery("getTenantsByAttributeNameAndValue", TenantEntity.class) + .setParameter("realmId", realm.getId()) + .setParameter("name", attrName) + .setParameter("value", attrValue); + + return closing(query.getResultStream() + .map(tenantEntity -> new TenantAdapter(session, realm, em, tenantEntity))); + } + @Override public boolean deleteTenant(RealmModel realm, String id) { getTenantById(realm, id).ifPresent(tenant -> { diff --git a/src/main/java/dev/sultanov/keycloak/multitenancy/model/jpa/TenantAdapter.java b/src/main/java/dev/sultanov/keycloak/multitenancy/model/jpa/TenantAdapter.java index d91bc64..a6aeacc 100644 --- a/src/main/java/dev/sultanov/keycloak/multitenancy/model/jpa/TenantAdapter.java +++ b/src/main/java/dev/sultanov/keycloak/multitenancy/model/jpa/TenantAdapter.java @@ -6,12 +6,20 @@ import dev.sultanov.keycloak.multitenancy.model.entity.TenantEntity; import dev.sultanov.keycloak.multitenancy.model.entity.TenantInvitationEntity; import dev.sultanov.keycloak.multitenancy.model.entity.TenantMembershipEntity; +import dev.sultanov.keycloak.multitenancy.model.entity.TenantAttributeEntity; import jakarta.persistence.EntityManager; import jakarta.persistence.TypedQuery; import java.util.HashSet; +import java.util.Iterator; import java.util.Optional; import java.util.Set; import java.util.stream.Stream; +import java.util.Map; +import java.util.Objects; +import java.util.List; +import java.util.ArrayList; + +import org.keycloak.common.util.MultivaluedHashMap; import org.keycloak.models.KeycloakSession; import org.keycloak.models.RealmModel; import org.keycloak.models.UserModel; @@ -20,6 +28,9 @@ import org.keycloak.models.jpa.entities.UserEntity; import org.keycloak.models.utils.KeycloakModelUtils; +import static java.util.Optional.ofNullable; +import static org.keycloak.common.util.CollectionUtil.collectionEquals; + public class TenantAdapter implements TenantModel, JpaModel { private final KeycloakSession session; @@ -54,6 +65,97 @@ public RealmModel getRealm() { return session.realms().getRealm(tenant.getRealmId()); } + @Override + public void setSingleAttribute(String name, String value) { + boolean found = false; + List toRemove = new ArrayList<>(); + for (TenantAttributeEntity attr : tenant.getAttributes()) { + if (attr.getName().equals(name)) { + if (!found) { + attr.setValue(value); + found = true; + } else { + toRemove.add(attr); + } + } + } + + for (TenantAttributeEntity attr : toRemove) { + em.remove(attr); + tenant.getAttributes().remove(attr); + } + + if (found) { + return; + } + + persistAttributeValue(name, value); + } + + @Override + public void setAttribute(String name, List values) { + List current = getAttributes().getOrDefault(name, List.of()); + + if (collectionEquals(current, ofNullable(values).orElse(List.of()))) { + return; + } + + // Remove all existing + removeAttribute(name); + + // Put all new + for (String value : values) { + persistAttributeValue(name, value); + } + } + + private void persistAttributeValue(String name, String value) { + TenantAttributeEntity attr = new TenantAttributeEntity(); + attr.setId(KeycloakModelUtils.generateId()); + attr.setName(name); + attr.setValue(value); + attr.setTenant(tenant); + em.persist(attr); + tenant.getAttributes().add(attr); + } + + @Override + public void removeAttribute(String name) { + Iterator it = tenant.getAttributes().iterator(); + while (it.hasNext()) { + TenantAttributeEntity attr = it.next(); + if (attr.getName().equals(name)) { + it.remove(); + em.remove(attr); + } + } + } + + @Override + public String getFirstAttribute(String name) { + for (TenantAttributeEntity attr : tenant.getAttributes()) { + if (attr.getName().equals(name)) { + return attr.getValue(); + } + } + return null; + } + + @Override + public Stream getAttributeStream(String name) { + return tenant.getAttributes().stream().filter(attribute -> Objects.equals(attribute.getName(), name)) + .map(TenantAttributeEntity::getValue); + } + + @Override + public Map> getAttributes() { + MultivaluedHashMap result = new MultivaluedHashMap<>(); + for (TenantAttributeEntity attr : tenant.getAttributes()) { + result.add(attr.getName(), attr.getValue()); + } + return result; + } + @Override public TenantMembershipModel grantMembership(UserModel user, Set roles) { TenantMembershipEntity entity = new TenantMembershipEntity(); diff --git a/src/main/java/dev/sultanov/keycloak/multitenancy/protocol/oidc/mappers/TenantAttributeMapper.java b/src/main/java/dev/sultanov/keycloak/multitenancy/protocol/oidc/mappers/TenantAttributeMapper.java new file mode 100644 index 0000000..1a3bc6b --- /dev/null +++ b/src/main/java/dev/sultanov/keycloak/multitenancy/protocol/oidc/mappers/TenantAttributeMapper.java @@ -0,0 +1,112 @@ +package dev.sultanov.keycloak.multitenancy.protocol.oidc.mappers; + +import dev.sultanov.keycloak.multitenancy.model.TenantProvider; +import java.util.ArrayList; +import java.util.List; +import java.util.stream.Collectors; +import org.keycloak.models.ClientSessionContext; +import org.keycloak.models.KeycloakSession; +import org.keycloak.models.ProtocolMapperModel; +import org.keycloak.models.UserSessionModel; +import org.keycloak.protocol.oidc.mappers.AbstractOIDCProtocolMapper; +import org.keycloak.protocol.oidc.mappers.OIDCAccessTokenMapper; +import org.keycloak.protocol.oidc.mappers.OIDCAttributeMapperHelper; +import org.keycloak.protocol.oidc.mappers.OIDCIDTokenMapper; +import org.keycloak.protocol.oidc.mappers.UserInfoTokenMapper; +import org.keycloak.provider.ProviderConfigProperty; +import org.keycloak.representations.IDToken; +import dev.sultanov.keycloak.multitenancy.util.Constants; + +public class TenantAttributeMapper extends AbstractOIDCProtocolMapper + implements OIDCAccessTokenMapper, OIDCIDTokenMapper, UserInfoTokenMapper { + + private static final String PROVIDER_ID = "oidc-tenant-attribute-mapper"; + private static final String TENANT_ATTRIBUTE_NAME = "tenant.attribute.name"; + private static final String MULTIVALUED = "multivalued"; + + private static final List configProperties = new ArrayList<>(); + + static { + OIDCAttributeMapperHelper.addTokenClaimNameConfig(configProperties); + + OIDCAttributeMapperHelper.addIncludeInTokensConfig(configProperties, TenantAttributeMapper.class); + + ProviderConfigProperty attributeNameProperty = new ProviderConfigProperty(); + attributeNameProperty.setName(TENANT_ATTRIBUTE_NAME); + attributeNameProperty.setLabel("Tenant Attribute Name"); + attributeNameProperty.setType(ProviderConfigProperty.STRING_TYPE); + attributeNameProperty.setHelpText("Name of the tenant attribute to map"); + configProperties.add(attributeNameProperty); + + ProviderConfigProperty multivaluedProperty = new ProviderConfigProperty(); + multivaluedProperty.setName(MULTIVALUED); + multivaluedProperty.setLabel("Multivalued"); + multivaluedProperty.setType(ProviderConfigProperty.BOOLEAN_TYPE); + multivaluedProperty.setDefaultValue("true"); + multivaluedProperty.setHelpText("Indicates if the attribute contains multiple values"); + configProperties.add(multivaluedProperty); + } + + @Override + public String getDisplayCategory() { + return TOKEN_MAPPER_CATEGORY; + } + + @Override + public String getDisplayType() { + return "Tenant Attribute"; + } + + @Override + public String getHelpText() { + return "Maps a tenant attribute to a token claim."; + } + + @Override + public List getConfigProperties() { + return configProperties; + } + + @Override + public String getId() { + return PROVIDER_ID; + } + + @Override + protected void setClaim(IDToken token, ProtocolMapperModel mappingModel, UserSessionModel userSession, + KeycloakSession keycloakSession, ClientSessionContext clientSessionCtx) { + + String activeTenantId = userSession.getNote(Constants.ACTIVE_TENANT_ID_SESSION_NOTE); + if (activeTenantId == null) { + return; + } + + String attributeName = mappingModel.getConfig().get(TENANT_ATTRIBUTE_NAME); + if (attributeName == null) { + return; + } + + boolean isMultivalued = Boolean.parseBoolean(mappingModel.getConfig().getOrDefault(MULTIVALUED, "true")); + String claimName = mappingModel.getConfig().get(OIDCAttributeMapperHelper.TOKEN_CLAIM_NAME); + + TenantProvider provider = keycloakSession.getProvider(TenantProvider.class); + var tenant = provider.getTenantById(userSession.getRealm(), activeTenantId); + + if (tenant.isPresent()) { + if (isMultivalued) { + List values = tenant.get() + .getAttributeStream(attributeName) + .collect(Collectors.toList()); + + if (!values.isEmpty()) { + token.getOtherClaims().put(claimName, values); + } + } else { + tenant.get() + .getAttributeStream(attributeName) + .findFirst() + .ifPresent(value -> token.getOtherClaims().put(claimName, value)); + } + } + } +} \ No newline at end of file diff --git a/src/main/java/dev/sultanov/keycloak/multitenancy/resource/ModelMapper.java b/src/main/java/dev/sultanov/keycloak/multitenancy/resource/ModelMapper.java index 1c7beea..79ac572 100644 --- a/src/main/java/dev/sultanov/keycloak/multitenancy/resource/ModelMapper.java +++ b/src/main/java/dev/sultanov/keycloak/multitenancy/resource/ModelMapper.java @@ -21,6 +21,7 @@ static TenantRepresentation toRepresentation(TenantModel tenant) { representation.setId(tenant.getId()); representation.setName(tenant.getName()); representation.setRealm(tenant.getRealm().getName()); + representation.setAttributes(tenant.getAttributes()); return representation; } diff --git a/src/main/java/dev/sultanov/keycloak/multitenancy/resource/TenantResource.java b/src/main/java/dev/sultanov/keycloak/multitenancy/resource/TenantResource.java index ea155c0..a1fbd11 100644 --- a/src/main/java/dev/sultanov/keycloak/multitenancy/resource/TenantResource.java +++ b/src/main/java/dev/sultanov/keycloak/multitenancy/resource/TenantResource.java @@ -10,6 +10,12 @@ import jakarta.ws.rs.Produces; import jakarta.ws.rs.core.MediaType; import jakarta.ws.rs.core.Response; + +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; + import org.eclipse.microprofile.openapi.annotations.Operation; import org.eclipse.microprofile.openapi.annotations.enums.SchemaType; import org.eclipse.microprofile.openapi.annotations.media.Content; @@ -51,8 +57,21 @@ public TenantRepresentation getTenant() { @APIResponse(responseCode = "404", description = "Not Found") }) public Response updateTenant(TenantRepresentation request) { + if (request.getName() != null && !request.getName().isEmpty()) { + tenant.setName(request.getName()); + } - tenant.setName(request.getName()); + if (request.getAttributes() != null) { + Set attrsToRemove = new HashSet<>(tenant.getAttributes().keySet()); + attrsToRemove.removeAll(request.getAttributes().keySet()); + for (Map.Entry> attr : request.getAttributes().entrySet()) { + tenant.setAttribute(attr.getKey(), attr.getValue()); + } + + for (String attr : attrsToRemove) { + tenant.removeAttribute(attr); + } + } adminEvent.operation(OperationType.UPDATE) .resourcePath(session.getContext().getUri()) diff --git a/src/main/java/dev/sultanov/keycloak/multitenancy/resource/TenantsResource.java b/src/main/java/dev/sultanov/keycloak/multitenancy/resource/TenantsResource.java index 012bc89..1d99b78 100644 --- a/src/main/java/dev/sultanov/keycloak/multitenancy/resource/TenantsResource.java +++ b/src/main/java/dev/sultanov/keycloak/multitenancy/resource/TenantsResource.java @@ -2,6 +2,7 @@ import dev.sultanov.keycloak.multitenancy.model.TenantModel; import dev.sultanov.keycloak.multitenancy.resource.representation.TenantRepresentation; +import jakarta.ws.rs.BadRequestException; import jakarta.ws.rs.Consumes; import jakarta.ws.rs.ForbiddenException; import jakarta.ws.rs.GET; @@ -13,7 +14,10 @@ import jakarta.ws.rs.QueryParam; import jakarta.ws.rs.core.MediaType; import jakarta.ws.rs.core.Response; -import java.util.Optional; + +import java.util.Collections; +import java.util.List; +import java.util.Map; import java.util.stream.Stream; import org.eclipse.microprofile.openapi.annotations.Operation; import org.eclipse.microprofile.openapi.annotations.enums.SchemaType; @@ -26,7 +30,7 @@ import org.keycloak.events.admin.OperationType; import org.keycloak.models.Constants; import org.keycloak.models.KeycloakSession; - +import org.keycloak.utils.SearchQueryUtils; public class TenantsResource extends AbstractAdminResource { @@ -50,7 +54,18 @@ public Response createTenant(@RequestBody(required = true) TenantRepresentation throw new ForbiddenException(String.format("Missing required role for tenant creation: %s", requiredRole)); } + validateAttributes(request.getAttributes()); + TenantModel model = tenantProvider.createTenant(realm, request.getName(), auth.getUser()); + + if (request.getAttributes() != null) { + request.getAttributes().forEach((key, values) -> { + if (values != null && !values.isEmpty()) { + model.setAttribute(key, values); + } + }); + } + TenantRepresentation representation = ModelMapper.toRepresentation(model); adminEvent.operation(OperationType.CREATE) @@ -70,16 +85,21 @@ public Response createTenant(@RequestBody(required = true) TenantRepresentation }) public Stream listTenants( @Parameter(description = "Tenant name") @QueryParam("search") String searchQuery, + @Parameter(description = "Tenant attribute query") @QueryParam("q") String attributeQuery, @Parameter(description = "Pagination offset") @QueryParam("first") Integer firstResult, @Parameter(description = "Maximum results size (defaults to 100)") @QueryParam("max") Integer maxResults) { - Optional search = Optional.ofNullable(searchQuery); firstResult = firstResult != null ? firstResult : 0; maxResults = maxResults != null ? maxResults : Constants.DEFAULT_MAX_RESULTS; - return tenantProvider.getTenantsStream(realm) - .filter( tenant -> auth.isTenantsManager() || auth.isTenantMember(tenant)) - .filter(tenant -> search.isEmpty() || tenant.getName().contains(search.get())) - .skip(firstResult) - .limit(maxResults) + + Map searchAttributes = attributeQuery == null + ? Collections.emptyMap() + : SearchQueryUtils.getFields(attributeQuery); + + Stream tenantStream = tenantProvider.getTenantsStream(realm, searchQuery, searchAttributes, + firstResult, maxResults); + + return tenantStream + .filter(tenant -> auth.isTenantsManager() || auth.isTenantMember(tenant)) .map(ModelMapper::toRepresentation); } @@ -93,4 +113,22 @@ public TenantResource getTenantResource(@PathParam("tenantId") String tenantId) throw new ForbiddenException(String.format("Insufficient permission to access %s", tenantId)); } } + + private void validateAttributes(Map> attributes) { + if (attributes != null) { + attributes.forEach((key, values) -> { + if (key == null || key.trim().isEmpty()) { + throw new BadRequestException("Attribute name cannot be null or empty"); + } + if (values == null || values.isEmpty()) { + throw new BadRequestException("Attribute values cannot be null or empty for key: " + key); + } + values.forEach(value -> { + if (value == null || value.trim().isEmpty()) { + throw new BadRequestException("Attribute value cannot be null or empty for key: " + key); + } + }); + }); + } + } } diff --git a/src/main/java/dev/sultanov/keycloak/multitenancy/resource/representation/TenantRepresentation.java b/src/main/java/dev/sultanov/keycloak/multitenancy/resource/representation/TenantRepresentation.java index 63bad76..40cde6a 100644 --- a/src/main/java/dev/sultanov/keycloak/multitenancy/resource/representation/TenantRepresentation.java +++ b/src/main/java/dev/sultanov/keycloak/multitenancy/resource/representation/TenantRepresentation.java @@ -2,6 +2,9 @@ import lombok.Data; import org.eclipse.microprofile.openapi.annotations.media.Schema; +import java.util.Map; +import java.util.HashMap; +import java.util.List; @Schema @Data @@ -16,4 +19,6 @@ public class TenantRepresentation { @Schema(readOnly = true) private String realm; + @Schema(description = "Attributes of the tenant") + private Map> attributes = new HashMap<>(); } diff --git a/src/main/resources/META-INF/keycloak-multi-tenancy-changelog-20241025.xml b/src/main/resources/META-INF/keycloak-multi-tenancy-changelog-20241025.xml new file mode 100644 index 0000000..f9e7527 --- /dev/null +++ b/src/main/resources/META-INF/keycloak-multi-tenancy-changelog-20241025.xml @@ -0,0 +1,47 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/src/main/resources/META-INF/keycloak-multi-tenancy-changelog-master.xml b/src/main/resources/META-INF/keycloak-multi-tenancy-changelog-master.xml index 2ec2b41..ca4ae5a 100644 --- a/src/main/resources/META-INF/keycloak-multi-tenancy-changelog-master.xml +++ b/src/main/resources/META-INF/keycloak-multi-tenancy-changelog-master.xml @@ -3,5 +3,6 @@ + diff --git a/src/main/resources/META-INF/services/org.keycloak.protocol.ProtocolMapper b/src/main/resources/META-INF/services/org.keycloak.protocol.ProtocolMapper index a5fedc7..95604ee 100644 --- a/src/main/resources/META-INF/services/org.keycloak.protocol.ProtocolMapper +++ b/src/main/resources/META-INF/services/org.keycloak.protocol.ProtocolMapper @@ -1,3 +1,4 @@ dev.sultanov.keycloak.multitenancy.protocol.oidc.mappers.ActiveTenantMapper dev.sultanov.keycloak.multitenancy.protocol.oidc.mappers.AllTenantsMapper -dev.sultanov.keycloak.multitenancy.protocol.oidc.mappers.HardcodedTenantMapper \ No newline at end of file +dev.sultanov.keycloak.multitenancy.protocol.oidc.mappers.HardcodedTenantMapper +dev.sultanov.keycloak.multitenancy.protocol.oidc.mappers.TenantAttributeMapper \ No newline at end of file diff --git a/src/test/java/dev/sultanov/keycloak/multitenancy/TenantAttributesTest.java b/src/test/java/dev/sultanov/keycloak/multitenancy/TenantAttributesTest.java new file mode 100644 index 0000000..b8fb17b --- /dev/null +++ b/src/test/java/dev/sultanov/keycloak/multitenancy/TenantAttributesTest.java @@ -0,0 +1,226 @@ +package dev.sultanov.keycloak.multitenancy; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +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.List; +import java.util.HashMap; +import java.util.Map; +import java.util.UUID; + +import org.apache.http.HttpStatus; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import jakarta.ws.rs.ProcessingException; +import org.keycloak.admin.client.CreatedResponseUtil; + +public class TenantAttributesTest extends BaseIntegrationTest { + + private KeycloakAdminCli keycloakAdminClient; + + @BeforeEach + void setUp() { + keycloakAdminClient = KeycloakAdminCli.forMainRealm(); + } + + @Test + void shouldCreateTenantWithAttributes() { + // given + var user = keycloakAdminClient.createVerifiedUser(); + user.createTenant(); // complete "create-tenant" required action + var tenantRequest = new TenantRepresentation(); + tenantRequest.setName("Tenant-" + UUID.randomUUID()); + Map> attributes = new HashMap<>(); + attributes.put("department", List.of("IT")); + attributes.put("location", List.of("New York")); + tenantRequest.setAttributes(attributes); + + // when + var tenantsResource = user.tenantsResource(); + var response = tenantsResource.createTenant(tenantRequest); + var tenantId = CreatedResponseUtil.getCreatedId(response); + var tenantResource = tenantsResource.getTenantResource(tenantId); + + // then + var createdTenant = tenantResource.toRepresentation(); + assertThat(createdTenant.getAttributes()) + .containsEntry("department", List.of("IT")) + .containsEntry("location", List.of("New York")); + } + + @Test + void shouldUpdateTenantAttributes() { + // given + var user = keycloakAdminClient.createVerifiedUser(); + var tenantResource = user.createTenant(); + + var updateRequest = new TenantRepresentation(); + Map> attributes = new HashMap<>(); + attributes.put("department", List.of("Sales")); + attributes.put("location", List.of("London")); + updateRequest.setAttributes(attributes); + + // when + try (var response = tenantResource.updateTenant(updateRequest)) { + assertThat(response.getStatus()).isEqualTo(HttpStatus.SC_NO_CONTENT); + } + + // then + var updatedTenant = tenantResource.toRepresentation(); + assertThat(updatedTenant.getAttributes()) + .containsEntry("department", List.of("Sales")) + .containsEntry("location", List.of("London")); + } + + @Test + void shouldRemoveAttributesWhenUpdatingWithEmptyMap() { + // given + var user = keycloakAdminClient.createVerifiedUser(); + var tenantResource = user.createTenant(); + + // First add some attributes + var addAttributesRequest = new TenantRepresentation(); + Map> initialAttributes = new HashMap<>(); + initialAttributes.put("department", List.of("IT")); + addAttributesRequest.setAttributes(initialAttributes); + tenantResource.updateTenant(addAttributesRequest); + + // Then update with empty map + var updateRequest = new TenantRepresentation(); + updateRequest.setAttributes(new HashMap<>()); + + // when + try (var response = tenantResource.updateTenant(updateRequest)) { + assertThat(response.getStatus()).isEqualTo(HttpStatus.SC_NO_CONTENT); + } + + // then + var updatedTenant = tenantResource.toRepresentation(); + assertThat(updatedTenant.getAttributes()).isEmpty(); + } + + @Test + void shouldSearchTenantsByAttribute() { + // given + var user = keycloakAdminClient.createVerifiedUser(); + user.createTenant(); // complete "create-tenant" required action + + var tenantRequest = new TenantRepresentation(); + tenantRequest.setName("IT Tenant-" + UUID.randomUUID()); + Map> attrs = new HashMap<>(); + attrs.put("department", List.of("IT")); + tenantRequest.setAttributes(attrs); + var tenantsResource = user.tenantsResource(); + tenantsResource.createTenant(tenantRequest); + + // when + var tenants = tenantsResource.listTenants(null, "department:IT", null, null); + + // then + assertThat(tenants) + .hasSize(1) + .extracting(TenantRepresentation::getName) + .allMatch(name -> name.startsWith("IT Tenant")); + } + + @Test + void shouldFailToCreateTenantWithEmptyAttributeKey() { + // given + var user = keycloakAdminClient.createVerifiedUser(); + var tenantRequest = new TenantRepresentation(); + tenantRequest.setName("Test Tenant" + UUID.randomUUID()); + Map> attributes = new HashMap<>(); + attributes.put("", List.of("value")); + tenantRequest.setAttributes(attributes); + + // when/then + assertThatThrownBy(() -> user.tenantsResource().createTenant(tenantRequest)) + .isInstanceOf(ProcessingException.class) + .hasMessageContaining("HTTP 400 Bad Request"); + } + + @Test + void shouldFailToCreateTenantWithEmptyAttributeValue() { + // given + var user = keycloakAdminClient.createVerifiedUser(); + var tenantRequest = new TenantRepresentation(); + tenantRequest.setName("Test Tenant" + UUID.randomUUID()); + Map> attributes = new HashMap<>(); + attributes.put("key", List.of()); + tenantRequest.setAttributes(attributes); + + // when/then + assertThatThrownBy(() -> user.tenantsResource().createTenant(tenantRequest)) + .isInstanceOf(ProcessingException.class) + .hasMessageContaining("HTTP 400 Bad Request"); + } + + @Test + void shouldFailToCreateTenantWithEmptyAttributeListValue() { + // given + var user = keycloakAdminClient.createVerifiedUser(); + var tenantRequest = new TenantRepresentation(); + tenantRequest.setName("Test Tenant" + UUID.randomUUID()); + Map> attributes = new HashMap<>(); + attributes.put("key", List.of("")); + tenantRequest.setAttributes(attributes); + + // when/then + assertThatThrownBy(() -> user.tenantsResource().createTenant(tenantRequest)) + .isInstanceOf(ProcessingException.class) + .hasMessageContaining("HTTP 400 Bad Request"); + } + + @Test + void shouldStoreShortAttributeValueInValueField() { + // given + var user = keycloakAdminClient.createVerifiedUser(); + user.createTenant(); // complete "create-tenant" required action + var tenantRequest = new TenantRepresentation(); + tenantRequest.setName("Tenant-" + UUID.randomUUID()); + + String shortValue = "x".repeat(250); + Map> attributes = new HashMap<>(); + attributes.put("test", List.of(shortValue)); + tenantRequest.setAttributes(attributes); + + // when + var tenantsResource = user.tenantsResource(); + var response = tenantsResource.createTenant(tenantRequest); + var tenantId = CreatedResponseUtil.getCreatedId(response); + var tenantResource = tenantsResource.getTenantResource(tenantId); + + // then + var createdTenant = tenantResource.toRepresentation(); + assertThat(createdTenant.getAttributes()) + .containsEntry("test", List.of(shortValue)); + } + + @Test + void shouldStoreLongAttributeValueInLongValueField() { + // given + var user = keycloakAdminClient.createVerifiedUser(); + user.createTenant(); // complete "create-tenant" required action + var tenantRequest = new TenantRepresentation(); + tenantRequest.setName("Tenant-" + UUID.randomUUID()); + + String longValue = "x".repeat(260); // Exceeds max length for value field + Map> attributes = new HashMap<>(); + attributes.put("test", List.of(longValue)); + tenantRequest.setAttributes(attributes); + + // when + var tenantsResource = user.tenantsResource(); + var response = tenantsResource.createTenant(tenantRequest); + var tenantId = CreatedResponseUtil.getCreatedId(response); + var tenantResource = tenantsResource.getTenantResource(tenantId); + + // then + var createdTenant = tenantResource.toRepresentation(); + assertThat(createdTenant.getAttributes()) + .containsEntry("test", List.of(longValue)); + } +} \ No newline at end of file diff --git a/src/test/java/dev/sultanov/keycloak/multitenancy/support/actor/KeycloakUser.java b/src/test/java/dev/sultanov/keycloak/multitenancy/support/actor/KeycloakUser.java index 3acdf66..44b399a 100644 --- a/src/test/java/dev/sultanov/keycloak/multitenancy/support/actor/KeycloakUser.java +++ b/src/test/java/dev/sultanov/keycloak/multitenancy/support/actor/KeycloakUser.java @@ -61,7 +61,7 @@ public TenantResource createTenant() { .fillTenantData(tenantData) .submit(); var tenantsResource = tenantsResource(); - return tenantsResource.listTenants(null, null, null) + return tenantsResource.listTenants(null, null, null, null) .stream() .findFirst() .map(TenantRepresentation::getId) diff --git a/src/test/java/dev/sultanov/keycloak/multitenancy/support/api/TenantsResource.java b/src/test/java/dev/sultanov/keycloak/multitenancy/support/api/TenantsResource.java index 87ae5e7..fe70f14 100644 --- a/src/test/java/dev/sultanov/keycloak/multitenancy/support/api/TenantsResource.java +++ b/src/test/java/dev/sultanov/keycloak/multitenancy/support/api/TenantsResource.java @@ -24,6 +24,7 @@ public interface TenantsResource { @Produces(MediaType.APPLICATION_JSON) List listTenants( @QueryParam("search") String searchQuery, + @QueryParam("q") String attributeQuery, @QueryParam("first") Integer firstResult, @QueryParam("max") Integer maxResults); From 8f0664930775d0947d36a98d6c9a12cd77d6b960 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ole-Johannes=20Aasb=C3=B8?= Date: Tue, 17 Dec 2024 09:37:42 +0000 Subject: [PATCH 2/2] fix: update ApiIntegrationTest to align with listTenants changes - Update test cases to include the new argument in the listTenants function - Ensure tests remain consistent with recent changes made to the function signature These changes were missed in the original commit introducing the new argument. --- .../sultanov/keycloak/multitenancy/ApiIntegrationTest.java | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/test/java/dev/sultanov/keycloak/multitenancy/ApiIntegrationTest.java b/src/test/java/dev/sultanov/keycloak/multitenancy/ApiIntegrationTest.java index 0d037a3..71d8391 100644 --- a/src/test/java/dev/sultanov/keycloak/multitenancy/ApiIntegrationTest.java +++ b/src/test/java/dev/sultanov/keycloak/multitenancy/ApiIntegrationTest.java @@ -41,7 +41,7 @@ static void setUpRealm() { assignTenantsManagementRole(tenantsManager); // remove tenants created by other tests - tenantsManager.tenantsResource().listTenants(null, null, null).stream() + tenantsManager.tenantsResource().listTenants(null, null, null, null).stream() .map(TenantRepresentation::getId) .filter(id -> !id.equals(tenantsManagerTenant.getId())) .map(tenantsManager.tenantsResource()::getTenantResource) @@ -182,7 +182,7 @@ void userRemoval_shouldRemoveTheirMembership() { @Test void tenantsManager_shouldListAllTenants() { // when - var tenants = tenantsManager.tenantsResource().listTenants(null, null, null); + var tenants = tenantsManager.tenantsResource().listTenants(null, null, null, null); // then assertThat(tenants).extracting(TenantRepresentation::getId).containsExactlyInAnyOrder(