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 : "");
}