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