From ed5a2e0f004fd6fecdf52c9cb24e0e6cdb35f8f6 Mon Sep 17 00:00:00 2001 From: Pedro Igor Date: Fri, 27 Mar 2026 16:01:41 -0300 Subject: [PATCH] Request and exclude attributes based on the parent or schema extension Signed-off-by: Pedro Igor --- .../resource/schema/AbstractModelSchema.java | 41 +---------- .../resource/schema/attribute/Attribute.java | 72 +++++++++++++++---- .../model/group/GroupCoreModelSchema.java | 4 +- .../scim/model/user/UserCoreModelSchema.java | 4 +- .../org/keycloak/tests/scim/tck/UserTest.java | 57 ++++++++++----- 5 files changed, 107 insertions(+), 71 deletions(-) diff --git a/scim/core/src/main/java/org/keycloak/scim/resource/schema/AbstractModelSchema.java b/scim/core/src/main/java/org/keycloak/scim/resource/schema/AbstractModelSchema.java index 3675b46f5e9..7637a70ac69 100644 --- a/scim/core/src/main/java/org/keycloak/scim/resource/schema/AbstractModelSchema.java +++ b/scim/core/src/main/java/org/keycloak/scim/resource/schema/AbstractModelSchema.java @@ -73,8 +73,8 @@ public abstract class AbstractModelSchema attributes, List excludedAttributes) { - populateResourceType(resource, model, attributes, excludedAttributes); + public void populate(R resource, M model, List requestedAttributes, List excludedAttributes) { + populateResourceType(resource, model, requestedAttributes, excludedAttributes); resource.setId(model.getId()); } @@ -228,7 +228,7 @@ public abstract class AbstractModelSchema attribute = getAttributeMapperByModelAttribute(name); - if (attribute != null && !shouldSkipAttribute(attribute, requestedAttributes, excludedAttributes)) { + if (attribute != null && !attribute.isExcluded(this, requestedAttributes, excludedAttributes)) { Object value = getAttributeValue(model, name); attribute.set(resource, value); resource.addSchema(this.id); @@ -236,41 +236,6 @@ public abstract class AbstractModelSchema attribute, List requestedAttributes, List excludedAttributes) { - String returned = attribute.getReturned(); - - // returned: always - never skip - if (Attribute.RETURNED_ALWAYS.equals(returned)) { - return false; - } - - // returned: never - always skip - if (Attribute.RETURNED_NEVER.equals(returned)) { - return true; - } - - // If attributes parameter is specified (inclusion filter) - if (requestedAttributes != null && !requestedAttributes.isEmpty()) { - if (requestedAttributes.stream().map(this::getAttributeByPath).noneMatch(attribute::equals)) { - return true; - } - } else if (Attribute.RETURNED_REQUEST.equals(returned)) { - // No attributes parameter specified - returned: request attributes are not returned by default - return true; - } - - // If excludedAttributes parameter is specified (exclusion filter) - if (excludedAttributes != null && !excludedAttributes.isEmpty()) { - return excludedAttributes.stream().map(this::getAttributeByPath).anyMatch(attribute::equals); - } - - return false; - } - private Attribute getAttributeMapperByModelAttribute(String name) { String scimName = getAttributeSchemaName(name); diff --git a/scim/core/src/main/java/org/keycloak/scim/resource/schema/attribute/Attribute.java b/scim/core/src/main/java/org/keycloak/scim/resource/schema/attribute/Attribute.java index 23156299ec4..c5c88b5064f 100644 --- a/scim/core/src/main/java/org/keycloak/scim/resource/schema/attribute/Attribute.java +++ b/scim/core/src/main/java/org/keycloak/scim/resource/schema/attribute/Attribute.java @@ -14,6 +14,8 @@ import org.keycloak.scim.resource.schema.ModelSchema; import com.fasterxml.jackson.databind.JsonNode; +import static java.util.Optional.ofNullable; + /** * Represents an attribute from a {@link ModelSchema}, its metadata and the mapper * that is used to map the attribute from a {@link ResourceTypeRepresentation} to a {@link Model} and vice versa. @@ -27,14 +29,6 @@ public class Attribute { public static final String RETURNED_REQUEST = "request"; public static final String RETURNED_NEVER = "never"; - private final String alias; - private Function, String> modelAttributeResolver; - private String type; - private String mutability; - private String returned = RETURNED_DEFAULT; - private boolean multivalued; - private Class complexType; - /** * Creates a simple attribute with the given {@code name}. * @@ -63,10 +57,13 @@ public class Attribute { private final String name; private final AttributeMapper mapper; private final String parentName; - - private Attribute(String name, AttributeMapper mapper, String parentName) { - this(name, mapper, parentName, null); - } + private final String alias; + private Function, String> modelAttributeResolver; + private String type; + private String mutability; + private String returned = RETURNED_DEFAULT; + private boolean multivalued; + private Class complexType; private Attribute(String name, AttributeMapper mapper, String parentName, String alias) { this.name = name; @@ -189,6 +186,57 @@ public class Attribute { mapper.removeValue(model, value); } + /** + * Determines whether the given attribute should be skipped during population based on + * the {@code returned} characteristic and the requested attribute filters. + */ + public boolean isExcluded(ModelSchema schema, List requestedAttributes, List excludedAttributes) { + String returned = getReturned(); + + // returned: always - never skip + if (Attribute.RETURNED_ALWAYS.equals(returned)) { + return false; + } + + // returned: never - always skip + if (Attribute.RETURNED_NEVER.equals(returned)) { + return true; + } + + // If attributes parameter is specified (inclusion filter) + if (requestedAttributes != null && !requestedAttributes.isEmpty()) { + if (!isPresent(schema, requestedAttributes)) { + return true; + } + } + + if (Attribute.RETURNED_REQUEST.equals(returned)) { + // No attributes parameter specified - returned: request attributes are not returned by default + return true; + } + + return isPresent(schema, excludedAttributes); + } + + private boolean isPresent(ModelSchema schema, List names) { + return ofNullable(names).orElse(List.of()).stream() + .map(path -> { + String parentName = getParentName(); + + // fallback to check if the attribute is a child of a requested attribute + if (path.equalsIgnoreCase(parentName)) { + return this; + } + + // fallback to check if the path is the scheme itself + if (path.equalsIgnoreCase(schema.getId())) { + return this; + } + + return schema.getAttributeByPath(path); + }).anyMatch(this::equals); + } + public static class Builder { private final Class complexType; diff --git a/scim/model/src/main/java/org/keycloak/scim/model/group/GroupCoreModelSchema.java b/scim/model/src/main/java/org/keycloak/scim/model/group/GroupCoreModelSchema.java index adfcdaad5ff..7e475eaa700 100644 --- a/scim/model/src/main/java/org/keycloak/scim/model/group/GroupCoreModelSchema.java +++ b/scim/model/src/main/java/org/keycloak/scim/model/group/GroupCoreModelSchema.java @@ -84,8 +84,8 @@ public final class GroupCoreModelSchema extends AbstractModelSchema attributes, List excludedAttributes) { - super.populate(resource, model, attributes, excludedAttributes); + public void populate(Group resource, GroupModel model, List requestedAttributes, List excludedAttributes) { + super.populate(resource, model, requestedAttributes, excludedAttributes); setTimestamps(resource, model); } diff --git a/scim/model/src/main/java/org/keycloak/scim/model/user/UserCoreModelSchema.java b/scim/model/src/main/java/org/keycloak/scim/model/user/UserCoreModelSchema.java index 60cfe408ccf..191cb676d5d 100644 --- a/scim/model/src/main/java/org/keycloak/scim/model/user/UserCoreModelSchema.java +++ b/scim/model/src/main/java/org/keycloak/scim/model/user/UserCoreModelSchema.java @@ -215,8 +215,8 @@ public final class UserCoreModelSchema extends AbstractUserModelSchema { } @Override - public void populate(User resource, UserModel model, List attributes, List excludedAttributes) { - super.populate(resource, model, attributes, excludedAttributes); + public void populate(User resource, UserModel model, List requestedAttributes, List excludedAttributes) { + super.populate(resource, model, requestedAttributes, excludedAttributes); setTimestamps(resource, model); } diff --git a/scim/tests/base/src/test/java/org/keycloak/tests/scim/tck/UserTest.java b/scim/tests/base/src/test/java/org/keycloak/tests/scim/tck/UserTest.java index 6b136e96644..33a6e9c8fef 100644 --- a/scim/tests/base/src/test/java/org/keycloak/tests/scim/tck/UserTest.java +++ b/scim/tests/base/src/test/java/org/keycloak/tests/scim/tck/UserTest.java @@ -198,23 +198,7 @@ public class UserTest extends AbstractScimTest { @Test public void testCreateEnterpriseUser() { - UPConfig configuration = realm.admin().users().userProfile().getConfiguration(); - - configuration.addOrReplaceAttribute(new UPAttribute("department", Map.of( - ANNOTATION_SCIM_SCHEMA_ATTRIBUTE, ENTERPRISE_USER_SCHEMA + ".department"))); - configuration.addOrReplaceAttribute(new UPAttribute("division", Map.of( - ANNOTATION_SCIM_SCHEMA_ATTRIBUTE, ENTERPRISE_USER_SCHEMA + ".division"))); - configuration.addOrReplaceAttribute(new UPAttribute("costCenter", Map.of( - ANNOTATION_SCIM_SCHEMA_ATTRIBUTE, ENTERPRISE_USER_SCHEMA + ".costCenter"))); - configuration.addOrReplaceAttribute(new UPAttribute("employeeNumber", Map.of( - ANNOTATION_SCIM_SCHEMA_ATTRIBUTE, ENTERPRISE_USER_SCHEMA + ".employeeNumber"))); - configuration.addOrReplaceAttribute(new UPAttribute("organization", Map.of( - ANNOTATION_SCIM_SCHEMA_ATTRIBUTE, ENTERPRISE_USER_SCHEMA + ".organization"))); - configuration.addOrReplaceAttribute(new UPAttribute("manager", Map.of( - ANNOTATION_SCIM_SCHEMA_ATTRIBUTE, ENTERPRISE_USER_SCHEMA + ".manager.value"))); - configuration.addOrReplaceAttribute(new UPAttribute("managerName", Map.of( - ANNOTATION_SCIM_SCHEMA_ATTRIBUTE, ENTERPRISE_USER_SCHEMA + ".manager.displayName"))); - realm.admin().users().userProfile().update(configuration); + addEnterpriseUserUserProfileAttributes(); User expected = createUser(); EnterpriseUser enterpriseUser = new EnterpriseUser(); @@ -1048,6 +1032,7 @@ public class UserTest extends AbstractScimTest { @Test public void testGetWithExtensionUrnAttribute() { + addEnterpriseUserUserProfileAttributes(); User expected = createUser(); EnterpriseUser enterpriseUser = new EnterpriseUser(); enterpriseUser.setEmployeeNumber("12345"); @@ -1067,6 +1052,24 @@ public class UserTest extends AbstractScimTest { assertNull(actual.getName()); } + @Test + public void testGetWithExcludedExtensionUrnAttribute() { + addEnterpriseUserUserProfileAttributes(); + User expected = createUser(); + EnterpriseUser enterpriseUser = new EnterpriseUser(); + enterpriseUser.setEmployeeNumber("12345"); + enterpriseUser.setDepartment("Engineering"); + expected.setEnterpriseUser(enterpriseUser); + expected = client.users().create(expected); + + // Requesting the extension URN should return all extension attributes + User actual = client.users().get(expected.getId(), + null, List.of("urn:ietf:params:scim:schemas:extension:enterprise:2.0:User")); + assertNotNull(actual); + assertNotNull(actual.getId()); + assertNull(actual.getEnterpriseUser()); + } + private static void assertGroup(List groups, GroupRepresentation group, String type) { assertTrue(groups.stream().anyMatch(membership -> { boolean found = group.getId().equals(membership.getValue()) && group.getName().equals(membership.getDisplay()); @@ -1184,4 +1187,24 @@ public class UserTest extends AbstractScimTest { return user; } + + private void addEnterpriseUserUserProfileAttributes() { + UPConfig configuration = realm.admin().users().userProfile().getConfiguration(); + + configuration.addOrReplaceAttribute(new UPAttribute("department", Map.of( + ANNOTATION_SCIM_SCHEMA_ATTRIBUTE, ENTERPRISE_USER_SCHEMA + ".department"))); + configuration.addOrReplaceAttribute(new UPAttribute("division", Map.of( + ANNOTATION_SCIM_SCHEMA_ATTRIBUTE, ENTERPRISE_USER_SCHEMA + ".division"))); + configuration.addOrReplaceAttribute(new UPAttribute("costCenter", Map.of( + ANNOTATION_SCIM_SCHEMA_ATTRIBUTE, ENTERPRISE_USER_SCHEMA + ".costCenter"))); + configuration.addOrReplaceAttribute(new UPAttribute("employeeNumber", Map.of( + ANNOTATION_SCIM_SCHEMA_ATTRIBUTE, ENTERPRISE_USER_SCHEMA + ".employeeNumber"))); + configuration.addOrReplaceAttribute(new UPAttribute("organization", Map.of( + ANNOTATION_SCIM_SCHEMA_ATTRIBUTE, ENTERPRISE_USER_SCHEMA + ".organization"))); + configuration.addOrReplaceAttribute(new UPAttribute("manager", Map.of( + ANNOTATION_SCIM_SCHEMA_ATTRIBUTE, ENTERPRISE_USER_SCHEMA + ".manager.value"))); + configuration.addOrReplaceAttribute(new UPAttribute("managerName", Map.of( + ANNOTATION_SCIM_SCHEMA_ATTRIBUTE, ENTERPRISE_USER_SCHEMA + ".manager.displayName"))); + realm.admin().users().userProfile().update(configuration); + } }