Skip to content

Commit 8bc0072

Browse files
committed
Normalize attribute references and add projection helpers
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) <noreply@anthropic.com>
1 parent 389deae commit 8bc0072

4 files changed

Lines changed: 247 additions & 1 deletion

File tree

scim-core/src/main/java/org/apache/directory/scim/core/repository/ScimRequestContext.java

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@
2727
import java.util.Objects;
2828
import java.util.Optional;
2929
import java.util.Set;
30+
import java.util.stream.Collectors;
3031

3132
public class ScimRequestContext {
3233

@@ -163,4 +164,55 @@ public String toString() {
163164
public static ScimRequestContext empty() {
164165
return new ScimRequestContext();
165166
}
167+
168+
/**
169+
* Returns the fully qualified SCIM attribute names the client explicitly requested
170+
* via the {@code attributes} query parameter, or an empty set if no specific
171+
* attributes were requested (meaning all attributes should be returned).
172+
*
173+
* <p>All attribute names are returned in their fully qualified form
174+
* (e.g., {@code urn:ietf:params:scim:schemas:core:2.0:User:userName}).
175+
* The server layer normalizes unqualified attribute references before they
176+
* reach the repository, so all references in this context carry their schema URN.</p>
177+
*
178+
* <p>Repository implementations MAY use this to optimize backend queries
179+
* (e.g., selecting specific LDAP attributes or database columns). The SCIM server
180+
* layer always applies attribute filtering post-retrieval as a safety net, so
181+
* repositories that ignore this hint will still produce correct results.</p>
182+
*
183+
* @return set of fully qualified SCIM attribute names, or empty for "all"
184+
* @see #getIncludedAttributes()
185+
*/
186+
public Set<String> getIncludedAttributeNames() {
187+
if (includedAttributes == null) {
188+
return Set.of();
189+
}
190+
return includedAttributes.stream()
191+
.map(AttributeReference::getFullyQualifiedAttributeName)
192+
.collect(Collectors.toUnmodifiableSet());
193+
}
194+
195+
/**
196+
* Returns the fully qualified SCIM attribute names the client explicitly excluded
197+
* via the {@code excludedAttributes} query parameter, or an empty set if none
198+
* were excluded.
199+
*
200+
* <p>Attribute names follow the same fully qualified rules as
201+
* {@link #getIncludedAttributeNames()}.</p>
202+
*
203+
* <p>Repository implementations MAY use this to optimize backend queries by
204+
* omitting excluded attributes from the fetch. The SCIM server layer always
205+
* applies attribute filtering post-retrieval as a safety net.</p>
206+
*
207+
* @return set of fully qualified excluded SCIM attribute names, or empty for "none excluded"
208+
* @see #getExcludedAttributes()
209+
*/
210+
public Set<String> getExcludedAttributeNames() {
211+
if (excludedAttributes == null) {
212+
return Set.of();
213+
}
214+
return excludedAttributes.stream()
215+
.map(AttributeReference::getFullyQualifiedAttributeName)
216+
.collect(Collectors.toUnmodifiableSet());
217+
}
166218
}
Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
1+
/*
2+
* Licensed to the Apache Software Foundation (ASF) under one
3+
* or more contributor license agreements. See the NOTICE file
4+
* distributed with this work for additional information
5+
* regarding copyright ownership. The ASF licenses this file
6+
* to you under the Apache License, Version 2.0 (the
7+
* "License"); you may not use this file except in compliance
8+
* with the License. You may obtain a copy of the License at
9+
10+
* http://www.apache.org/licenses/LICENSE-2.0
11+
12+
* Unless required by applicable law or agreed to in writing,
13+
* software distributed under the License is distributed on an
14+
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
15+
* KIND, either express or implied. See the License for the
16+
* specific language governing permissions and limitations
17+
* under the License.
18+
*/
19+
20+
package org.apache.directory.scim.core.repository;
21+
22+
import org.apache.directory.scim.spec.filter.attribute.AttributeReference;
23+
import org.junit.jupiter.api.Test;
24+
25+
import java.util.Set;
26+
27+
import static org.assertj.core.api.Assertions.assertThat;
28+
29+
class ScimRequestContextTest {
30+
31+
static final String USER_SCHEMA = "urn:ietf:params:scim:schemas:core:2.0:User";
32+
static final String ENTERPRISE_SCHEMA = "urn:ietf:params:scim:schemas:extension:enterprise:2.0:User";
33+
34+
// --- getIncludedAttributeNames ---
35+
36+
@Test
37+
void getIncludedAttributeNames_returnsFullyQualifiedNames() {
38+
// Server layer normalizes references before they reach the repository,
39+
// so all references should have URNs by this point
40+
AttributeReference ref1 = new AttributeReference(USER_SCHEMA + ":userName");
41+
AttributeReference ref2 = new AttributeReference(USER_SCHEMA + ":emails");
42+
ScimRequestContext ctx = new ScimRequestContext(Set.of(ref1, ref2), Set.of());
43+
44+
Set<String> names = ctx.getIncludedAttributeNames();
45+
46+
assertThat(names).containsExactlyInAnyOrder(
47+
USER_SCHEMA + ":userName",
48+
USER_SCHEMA + ":emails"
49+
);
50+
}
51+
52+
@Test
53+
void getIncludedAttributeNames_extensionAttributesPreserveTheirUrn() {
54+
AttributeReference coreAttr = new AttributeReference(USER_SCHEMA + ":userName");
55+
AttributeReference extensionAttr = new AttributeReference(ENTERPRISE_SCHEMA + ":department");
56+
ScimRequestContext ctx = new ScimRequestContext(Set.of(coreAttr, extensionAttr), Set.of());
57+
58+
Set<String> names = ctx.getIncludedAttributeNames();
59+
60+
assertThat(names).containsExactlyInAnyOrder(
61+
USER_SCHEMA + ":userName",
62+
ENTERPRISE_SCHEMA + ":department"
63+
);
64+
}
65+
66+
@Test
67+
void getIncludedAttributeNames_subAttributeIncluded() {
68+
AttributeReference ref = new AttributeReference(USER_SCHEMA + ":name.givenName");
69+
ScimRequestContext ctx = new ScimRequestContext(Set.of(ref), Set.of());
70+
71+
assertThat(ctx.getIncludedAttributeNames())
72+
.containsExactly(USER_SCHEMA + ":name.givenName");
73+
}
74+
75+
@Test
76+
void getIncludedAttributeNames_emptyReturnsEmptySet() {
77+
ScimRequestContext ctx = new ScimRequestContext(Set.of(), Set.of());
78+
assertThat(ctx.getIncludedAttributeNames()).isEmpty();
79+
}
80+
81+
@Test
82+
void getIncludedAttributeNames_nullReturnsEmptySet() {
83+
ScimRequestContext ctx = new ScimRequestContext();
84+
ctx.setIncludedAttributes(null);
85+
assertThat(ctx.getIncludedAttributeNames()).isEmpty();
86+
}
87+
88+
// --- getExcludedAttributeNames ---
89+
90+
@Test
91+
void getExcludedAttributeNames_returnsFullyQualifiedNames() {
92+
AttributeReference ref = new AttributeReference(USER_SCHEMA + ":password");
93+
ScimRequestContext ctx = new ScimRequestContext(Set.of(), Set.of(ref));
94+
95+
assertThat(ctx.getExcludedAttributeNames())
96+
.containsExactly(USER_SCHEMA + ":password");
97+
}
98+
99+
@Test
100+
void getExcludedAttributeNames_emptyReturnsEmptySet() {
101+
ScimRequestContext ctx = new ScimRequestContext(Set.of(), Set.of());
102+
assertThat(ctx.getExcludedAttributeNames()).isEmpty();
103+
}
104+
105+
// --- empty() ---
106+
107+
@Test
108+
void empty_returnsEmptySetsForBothIncludedAndExcluded() {
109+
ScimRequestContext ctx = ScimRequestContext.empty();
110+
assertThat(ctx.getIncludedAttributeNames()).isEmpty();
111+
assertThat(ctx.getExcludedAttributeNames()).isEmpty();
112+
}
113+
}

scim-server/src/main/java/org/apache/directory/scim/server/rest/BaseResourceTypeResourceImpl.java

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@
2626
import java.util.Optional;
2727
import java.util.Objects;
2828
import java.util.Set;
29+
import java.util.stream.Collectors;
2930

3031
import jakarta.enterprise.inject.spi.CDI;
3132
import jakarta.ws.rs.core.*;
@@ -57,6 +58,8 @@
5758
import org.apache.directory.scim.protocol.data.SearchRequest;
5859
import org.apache.directory.scim.spec.filter.FilterResponse;
5960
import org.apache.directory.scim.spec.filter.Filter;
61+
import org.apache.directory.scim.spec.schema.ResourceType;
62+
import org.apache.directory.scim.spec.schema.Schema;
6063
import org.apache.directory.scim.spec.filter.SortOrder;
6164
import org.apache.directory.scim.core.repository.ScimRequestContext;
6265
import org.apache.directory.scim.spec.resources.ScimResource;
@@ -67,6 +70,8 @@ public abstract class BaseResourceTypeResourceImpl<T extends ScimResource> imple
6770

6871
private final RepositoryRegistry repositoryRegistry;
6972

73+
private final SchemaRegistry schemaRegistry;
74+
7075
private final AttributeUtil attributeUtil;
7176

7277
private final Class<T> resourceClass;
@@ -83,6 +88,7 @@ public abstract class BaseResourceTypeResourceImpl<T extends ScimResource> imple
8388
HttpHeaders headers;
8489

8590
public BaseResourceTypeResourceImpl(SchemaRegistry schemaRegistry, RepositoryRegistry repositoryRegistry, Class<T> resourceClass) {
91+
this.schemaRegistry = schemaRegistry;
8692
this.repositoryRegistry = repositoryRegistry;
8793
this.resourceClass = resourceClass;
8894
this.attributeUtil = new AttributeUtil(schemaRegistry);
@@ -109,6 +115,8 @@ public Response getById(String id, AttributeReferenceListWrapper attributes, Att
109115
Set<AttributeReference> attributeReferences = AttributeReferenceListWrapper.getAttributeReferences(attributes);
110116
Set<AttributeReference> excludedAttributeReferences = AttributeReferenceListWrapper.getAttributeReferences(excludedAttributes);
111117
validateAttributes(attributeReferences, excludedAttributeReferences);
118+
attributeReferences = qualifyReferences(attributeReferences);
119+
excludedAttributeReferences = qualifyReferences(excludedAttributeReferences);
112120

113121
Repository<T> repository = getRepositoryInternal();
114122

@@ -172,6 +180,8 @@ public Response create(T resource, AttributeReferenceListWrapper attributes, Att
172180
Set<AttributeReference> attributeReferences = AttributeReferenceListWrapper.getAttributeReferences(attributes);
173181
Set<AttributeReference> excludedAttributeReferences = AttributeReferenceListWrapper.getAttributeReferences(excludedAttributes);
174182
validateAttributes(attributeReferences, excludedAttributeReferences);
183+
attributeReferences = qualifyReferences(attributeReferences);
184+
excludedAttributeReferences = qualifyReferences(excludedAttributeReferences);
175185

176186
T created = repository.create(resource, new ScimRequestContext(attributeReferences, excludedAttributeReferences, null, null, null));
177187

@@ -207,6 +217,8 @@ public Response find(SearchRequest request) throws ScimException, ResourceExcept
207217
Set<AttributeReference> excludedAttributeReferences = Optional.ofNullable(request.getExcludedAttributes())
208218
.orElse(Collections.emptySet());
209219
validateAttributes(attributeReferences, excludedAttributeReferences);
220+
attributeReferences = qualifyReferences(attributeReferences);
221+
excludedAttributeReferences = qualifyReferences(excludedAttributeReferences);
210222

211223
Filter filter = request.getFilter();
212224
ScimRequestContext requestContext = new ScimRequestContext(attributeReferences, excludedAttributeReferences, request.getPageRequest(), request.getSortRequest(), null);
@@ -273,6 +285,8 @@ private Response update(AttributeReferenceListWrapper attributes, AttributeRefer
273285
Set<AttributeReference> attributeReferences = AttributeReferenceListWrapper.getAttributeReferences(attributes);
274286
Set<AttributeReference> excludedAttributeReferences = AttributeReferenceListWrapper.getAttributeReferences(excludedAttributes);
275287
validateAttributes(attributeReferences, excludedAttributeReferences);
288+
attributeReferences = qualifyReferences(attributeReferences);
289+
excludedAttributeReferences = qualifyReferences(excludedAttributeReferences);
276290

277291
String requestEtag = headers.getHeaderString("If-Match");
278292
Set<ETag> etags = EtagParser.parseETag(requestEtag);
@@ -368,6 +382,55 @@ private EntityTag fromVersion(ScimResource resource) {
368382
return null;
369383
}
370384

385+
/**
386+
* Normalizes {@link AttributeReference} objects to their fully qualified form by
387+
* resolving unqualified attribute names against the base schema and extension schemas
388+
* for this resource type. References that already have a URN are returned as-is.
389+
*/
390+
private Set<AttributeReference> qualifyReferences(Set<AttributeReference> refs) {
391+
if (refs == null || refs.isEmpty()) {
392+
return refs;
393+
}
394+
395+
org.apache.directory.scim.spec.annotation.ScimResourceType annotation =
396+
resourceClass.getAnnotation(org.apache.directory.scim.spec.annotation.ScimResourceType.class);
397+
if (annotation == null) {
398+
return refs;
399+
}
400+
401+
ResourceType resourceType = schemaRegistry.getResourceType(annotation.name());
402+
if (resourceType == null) {
403+
return refs;
404+
}
405+
406+
Schema baseSchema = schemaRegistry.getSchema(resourceType.getSchemaUrn());
407+
408+
return refs.stream()
409+
.map(ref -> {
410+
if (ref.isFullyQualified()) {
411+
return ref;
412+
}
413+
414+
// Try base schema first
415+
if (baseSchema != null && baseSchema.getAttribute(ref.getAttributeName()) != null) {
416+
return new AttributeReference(baseSchema.getId(), ref.getFullAttributeName());
417+
}
418+
419+
// Try extension schemas
420+
if (resourceType.getSchemaExtensions() != null) {
421+
for (ResourceType.SchemaExtensionConfiguration ext : resourceType.getSchemaExtensions()) {
422+
Schema extSchema = schemaRegistry.getSchema(ext.getSchemaUrn());
423+
if (extSchema != null && extSchema.getAttribute(ref.getAttributeName()) != null) {
424+
return new AttributeReference(extSchema.getId(), ref.getFullAttributeName());
425+
}
426+
}
427+
}
428+
429+
return ref; // best-effort: leave unresolved
430+
})
431+
.collect(Collectors.toUnmodifiableSet());
432+
}
433+
371434
@FunctionalInterface
372435
private interface UpdateFunction<T extends ScimResource> {
373436
T update(ScimRequestContext requestContext, Repository<T> repository) throws ResourceException;

scim-spec/scim-spec-schema/src/main/java/org/apache/directory/scim/spec/filter/attribute/AttributeReference.java

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -118,10 +118,28 @@ public boolean hasSubAttribute() {
118118
return subAttributeName != null;
119119
}
120120

121-
public boolean hasUrn() {
121+
/**
122+
* Returns {@code true} if this reference includes a schema URN prefix,
123+
* making it unambiguous across schemas.
124+
*
125+
* <p>Extension attributes are always fully qualified since their names
126+
* are only meaningful with the schema URN. Core attributes may or may
127+
* not be, depending on how the client sent them.</p>
128+
*
129+
* @return {@code true} if the URN is present
130+
*/
131+
public boolean isFullyQualified() {
122132
return urn != null;
123133
}
124134

135+
/**
136+
* @deprecated Use {@link #isFullyQualified()} instead.
137+
*/
138+
@Deprecated
139+
public boolean hasUrn() {
140+
return isFullyQualified();
141+
}
142+
125143
public String toString() {
126144
return (this.urn != null ? this.urn + ":" : "") + this.attributeName + (this.subAttributeName != null ? "." + this.subAttributeName : "");
127145
}

0 commit comments

Comments
 (0)