From 29e77ba2846a5cef17903542fc43eab211629680 Mon Sep 17 00:00:00 2001 From: Brian Demers Date: Sun, 22 Mar 2026 22:25:20 -0400 Subject: [PATCH] Normalize attribute references and add projection helpers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add AttributeReference.isFullyQualified() and deprecate hasUrn(). Normalize unqualified AttributeReference objects to fully qualified form in BaseResourceTypeResourceImpl before creating ScimRequestContext, searching base and extension schemas for the resource type. Add getIncludedAttributeNames() and getExcludedAttributeNames() on ScimRequestContext — returns fully qualified strings since the server layer pre-qualifies all references. Generated-by: Claude Opus 4.6 (1M context) --- .../core/repository/ScimRequestContext.java | 52 ++++++++ .../repository/ScimRequestContextTest.java | 113 ++++++++++++++++++ .../rest/BaseResourceTypeResourceImpl.java | 63 ++++++++++ .../filter/attribute/AttributeReference.java | 20 +++- 4 files changed, 247 insertions(+), 1 deletion(-) create mode 100644 scim-core/src/test/java/org/apache/directory/scim/core/repository/ScimRequestContextTest.java diff --git a/scim-core/src/main/java/org/apache/directory/scim/core/repository/ScimRequestContext.java b/scim-core/src/main/java/org/apache/directory/scim/core/repository/ScimRequestContext.java index a6a85c57..d12fe268 100644 --- a/scim-core/src/main/java/org/apache/directory/scim/core/repository/ScimRequestContext.java +++ b/scim-core/src/main/java/org/apache/directory/scim/core/repository/ScimRequestContext.java @@ -27,6 +27,7 @@ import java.util.Objects; import java.util.Optional; import java.util.Set; +import java.util.stream.Collectors; public class ScimRequestContext { @@ -163,4 +164,55 @@ public String toString() { public static ScimRequestContext empty() { return new ScimRequestContext(); } + + /** + * Returns the fully qualified SCIM attribute names the client explicitly requested + * via the {@code attributes} query parameter, or an empty set if no specific + * attributes were requested (meaning all attributes should be returned). + * + *

All attribute names are returned in their fully qualified form + * (e.g., {@code urn:ietf:params:scim:schemas:core:2.0:User:userName}). + * The server layer normalizes unqualified attribute references before they + * reach the repository, so all references in this context carry their schema URN.

+ * + *

Repository implementations MAY use this to optimize backend queries + * (e.g., selecting specific LDAP attributes or database columns). The SCIM server + * layer always applies attribute filtering post-retrieval as a safety net, so + * repositories that ignore this hint will still produce correct results.

+ * + * @return set of fully qualified SCIM attribute names, or empty for "all" + * @see #getIncludedAttributes() + */ + public Set getIncludedAttributeNames() { + if (includedAttributes == null) { + return Set.of(); + } + return includedAttributes.stream() + .map(AttributeReference::getFullyQualifiedAttributeName) + .collect(Collectors.toUnmodifiableSet()); + } + + /** + * Returns the fully qualified SCIM attribute names the client explicitly excluded + * via the {@code excludedAttributes} query parameter, or an empty set if none + * were excluded. + * + *

Attribute names follow the same fully qualified rules as + * {@link #getIncludedAttributeNames()}.

+ * + *

Repository implementations MAY use this to optimize backend queries by + * omitting excluded attributes from the fetch. The SCIM server layer always + * applies attribute filtering post-retrieval as a safety net.

+ * + * @return set of fully qualified excluded SCIM attribute names, or empty for "none excluded" + * @see #getExcludedAttributes() + */ + public Set getExcludedAttributeNames() { + if (excludedAttributes == null) { + return Set.of(); + } + return excludedAttributes.stream() + .map(AttributeReference::getFullyQualifiedAttributeName) + .collect(Collectors.toUnmodifiableSet()); + } } diff --git a/scim-core/src/test/java/org/apache/directory/scim/core/repository/ScimRequestContextTest.java b/scim-core/src/test/java/org/apache/directory/scim/core/repository/ScimRequestContextTest.java new file mode 100644 index 00000000..241f95d9 --- /dev/null +++ b/scim-core/src/test/java/org/apache/directory/scim/core/repository/ScimRequestContextTest.java @@ -0,0 +1,113 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + + * http://www.apache.org/licenses/LICENSE-2.0 + + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package org.apache.directory.scim.core.repository; + +import org.apache.directory.scim.spec.filter.attribute.AttributeReference; +import org.junit.jupiter.api.Test; + +import java.util.Set; + +import static org.assertj.core.api.Assertions.assertThat; + +class ScimRequestContextTest { + + static final String USER_SCHEMA = "urn:ietf:params:scim:schemas:core:2.0:User"; + static final String ENTERPRISE_SCHEMA = "urn:ietf:params:scim:schemas:extension:enterprise:2.0:User"; + + // --- getIncludedAttributeNames --- + + @Test + void getIncludedAttributeNames_returnsFullyQualifiedNames() { + // Server layer normalizes references before they reach the repository, + // so all references should have URNs by this point + AttributeReference ref1 = new AttributeReference(USER_SCHEMA + ":userName"); + AttributeReference ref2 = new AttributeReference(USER_SCHEMA + ":emails"); + ScimRequestContext ctx = new ScimRequestContext(Set.of(ref1, ref2), Set.of()); + + Set names = ctx.getIncludedAttributeNames(); + + assertThat(names).containsExactlyInAnyOrder( + USER_SCHEMA + ":userName", + USER_SCHEMA + ":emails" + ); + } + + @Test + void getIncludedAttributeNames_extensionAttributesPreserveTheirUrn() { + AttributeReference coreAttr = new AttributeReference(USER_SCHEMA + ":userName"); + AttributeReference extensionAttr = new AttributeReference(ENTERPRISE_SCHEMA + ":department"); + ScimRequestContext ctx = new ScimRequestContext(Set.of(coreAttr, extensionAttr), Set.of()); + + Set names = ctx.getIncludedAttributeNames(); + + assertThat(names).containsExactlyInAnyOrder( + USER_SCHEMA + ":userName", + ENTERPRISE_SCHEMA + ":department" + ); + } + + @Test + void getIncludedAttributeNames_subAttributeIncluded() { + AttributeReference ref = new AttributeReference(USER_SCHEMA + ":name.givenName"); + ScimRequestContext ctx = new ScimRequestContext(Set.of(ref), Set.of()); + + assertThat(ctx.getIncludedAttributeNames()) + .containsExactly(USER_SCHEMA + ":name.givenName"); + } + + @Test + void getIncludedAttributeNames_emptyReturnsEmptySet() { + ScimRequestContext ctx = new ScimRequestContext(Set.of(), Set.of()); + assertThat(ctx.getIncludedAttributeNames()).isEmpty(); + } + + @Test + void getIncludedAttributeNames_nullReturnsEmptySet() { + ScimRequestContext ctx = new ScimRequestContext(); + ctx.setIncludedAttributes(null); + assertThat(ctx.getIncludedAttributeNames()).isEmpty(); + } + + // --- getExcludedAttributeNames --- + + @Test + void getExcludedAttributeNames_returnsFullyQualifiedNames() { + AttributeReference ref = new AttributeReference(USER_SCHEMA + ":password"); + ScimRequestContext ctx = new ScimRequestContext(Set.of(), Set.of(ref)); + + assertThat(ctx.getExcludedAttributeNames()) + .containsExactly(USER_SCHEMA + ":password"); + } + + @Test + void getExcludedAttributeNames_emptyReturnsEmptySet() { + ScimRequestContext ctx = new ScimRequestContext(Set.of(), Set.of()); + assertThat(ctx.getExcludedAttributeNames()).isEmpty(); + } + + // --- empty() --- + + @Test + void empty_returnsEmptySetsForBothIncludedAndExcluded() { + ScimRequestContext ctx = ScimRequestContext.empty(); + assertThat(ctx.getIncludedAttributeNames()).isEmpty(); + assertThat(ctx.getExcludedAttributeNames()).isEmpty(); + } +} diff --git a/scim-server/src/main/java/org/apache/directory/scim/server/rest/BaseResourceTypeResourceImpl.java b/scim-server/src/main/java/org/apache/directory/scim/server/rest/BaseResourceTypeResourceImpl.java index d46a332e..3b4d9796 100644 --- a/scim-server/src/main/java/org/apache/directory/scim/server/rest/BaseResourceTypeResourceImpl.java +++ b/scim-server/src/main/java/org/apache/directory/scim/server/rest/BaseResourceTypeResourceImpl.java @@ -26,6 +26,7 @@ import java.util.Optional; import java.util.Objects; import java.util.Set; +import java.util.stream.Collectors; import jakarta.enterprise.inject.spi.CDI; import jakarta.ws.rs.core.*; @@ -57,6 +58,8 @@ import org.apache.directory.scim.protocol.data.SearchRequest; import org.apache.directory.scim.spec.filter.FilterResponse; import org.apache.directory.scim.spec.filter.Filter; +import org.apache.directory.scim.spec.schema.ResourceType; +import org.apache.directory.scim.spec.schema.Schema; import org.apache.directory.scim.spec.filter.SortOrder; import org.apache.directory.scim.core.repository.ScimRequestContext; import org.apache.directory.scim.spec.resources.ScimResource; @@ -67,6 +70,8 @@ public abstract class BaseResourceTypeResourceImpl imple private final RepositoryRegistry repositoryRegistry; + private final SchemaRegistry schemaRegistry; + private final AttributeUtil attributeUtil; private final Class resourceClass; @@ -83,6 +88,7 @@ public abstract class BaseResourceTypeResourceImpl imple HttpHeaders headers; public BaseResourceTypeResourceImpl(SchemaRegistry schemaRegistry, RepositoryRegistry repositoryRegistry, Class resourceClass) { + this.schemaRegistry = schemaRegistry; this.repositoryRegistry = repositoryRegistry; this.resourceClass = resourceClass; this.attributeUtil = new AttributeUtil(schemaRegistry); @@ -109,6 +115,8 @@ public Response getById(String id, AttributeReferenceListWrapper attributes, Att Set attributeReferences = AttributeReferenceListWrapper.getAttributeReferences(attributes); Set excludedAttributeReferences = AttributeReferenceListWrapper.getAttributeReferences(excludedAttributes); validateAttributes(attributeReferences, excludedAttributeReferences); + attributeReferences = qualifyReferences(attributeReferences); + excludedAttributeReferences = qualifyReferences(excludedAttributeReferences); Repository repository = getRepositoryInternal(); @@ -172,6 +180,8 @@ public Response create(T resource, AttributeReferenceListWrapper attributes, Att Set attributeReferences = AttributeReferenceListWrapper.getAttributeReferences(attributes); Set excludedAttributeReferences = AttributeReferenceListWrapper.getAttributeReferences(excludedAttributes); validateAttributes(attributeReferences, excludedAttributeReferences); + attributeReferences = qualifyReferences(attributeReferences); + excludedAttributeReferences = qualifyReferences(excludedAttributeReferences); T created = repository.create(resource, new ScimRequestContext(attributeReferences, excludedAttributeReferences, null, null, null)); @@ -207,6 +217,8 @@ public Response find(SearchRequest request) throws ScimException, ResourceExcept Set excludedAttributeReferences = Optional.ofNullable(request.getExcludedAttributes()) .orElse(Collections.emptySet()); validateAttributes(attributeReferences, excludedAttributeReferences); + attributeReferences = qualifyReferences(attributeReferences); + excludedAttributeReferences = qualifyReferences(excludedAttributeReferences); Filter filter = request.getFilter(); ScimRequestContext requestContext = new ScimRequestContext(attributeReferences, excludedAttributeReferences, request.getPageRequest(), request.getSortRequest(), null); @@ -273,6 +285,8 @@ private Response update(AttributeReferenceListWrapper attributes, AttributeRefer Set attributeReferences = AttributeReferenceListWrapper.getAttributeReferences(attributes); Set excludedAttributeReferences = AttributeReferenceListWrapper.getAttributeReferences(excludedAttributes); validateAttributes(attributeReferences, excludedAttributeReferences); + attributeReferences = qualifyReferences(attributeReferences); + excludedAttributeReferences = qualifyReferences(excludedAttributeReferences); String requestEtag = headers.getHeaderString("If-Match"); Set etags = EtagParser.parseETag(requestEtag); @@ -368,6 +382,55 @@ private EntityTag fromVersion(ScimResource resource) { return null; } + /** + * Normalizes {@link AttributeReference} objects to their fully qualified form by + * resolving unqualified attribute names against the base schema and extension schemas + * for this resource type. References that already have a URN are returned as-is. + */ + private Set qualifyReferences(Set refs) { + if (refs == null || refs.isEmpty()) { + return refs; + } + + org.apache.directory.scim.spec.annotation.ScimResourceType annotation = + resourceClass.getAnnotation(org.apache.directory.scim.spec.annotation.ScimResourceType.class); + if (annotation == null) { + return refs; + } + + ResourceType resourceType = schemaRegistry.getResourceType(annotation.name()); + if (resourceType == null) { + return refs; + } + + Schema baseSchema = schemaRegistry.getSchema(resourceType.getSchemaUrn()); + + return refs.stream() + .map(ref -> { + if (ref.isFullyQualified()) { + return ref; + } + + // Try base schema first + if (baseSchema != null && baseSchema.getAttribute(ref.getAttributeName()) != null) { + return new AttributeReference(baseSchema.getId(), ref.getFullAttributeName()); + } + + // Try extension schemas + if (resourceType.getSchemaExtensions() != null) { + for (ResourceType.SchemaExtensionConfiguration ext : resourceType.getSchemaExtensions()) { + Schema extSchema = schemaRegistry.getSchema(ext.getSchemaUrn()); + if (extSchema != null && extSchema.getAttribute(ref.getAttributeName()) != null) { + return new AttributeReference(extSchema.getId(), ref.getFullAttributeName()); + } + } + } + + return ref; // best-effort: leave unresolved + }) + .collect(Collectors.toUnmodifiableSet()); + } + @FunctionalInterface private interface UpdateFunction { T update(ScimRequestContext requestContext, Repository repository) throws ResourceException; diff --git a/scim-spec/scim-spec-schema/src/main/java/org/apache/directory/scim/spec/filter/attribute/AttributeReference.java b/scim-spec/scim-spec-schema/src/main/java/org/apache/directory/scim/spec/filter/attribute/AttributeReference.java index 1091478e..2049dbbe 100644 --- a/scim-spec/scim-spec-schema/src/main/java/org/apache/directory/scim/spec/filter/attribute/AttributeReference.java +++ b/scim-spec/scim-spec-schema/src/main/java/org/apache/directory/scim/spec/filter/attribute/AttributeReference.java @@ -118,10 +118,28 @@ public boolean hasSubAttribute() { return subAttributeName != null; } - public boolean hasUrn() { + /** + * Returns {@code true} if this reference includes a schema URN prefix, + * making it unambiguous across schemas. + * + *

Extension attributes are always fully qualified since their names + * are only meaningful with the schema URN. Core attributes may or may + * not be, depending on how the client sent them.

+ * + * @return {@code true} if the URN is present + */ + public boolean isFullyQualified() { return urn != null; } + /** + * @deprecated Use {@link #isFullyQualified()} instead. + */ + @Deprecated + public boolean hasUrn() { + return isFullyQualified(); + } + public String toString() { return (this.urn != null ? this.urn + ":" : "") + this.attributeName + (this.subAttributeName != null ? "." + this.subAttributeName : ""); }