Support multivalued user attributes in SCIM - #50865
Conversation
There was a problem hiding this comment.
Pull request overview
Adds SCIM exposure of all values from multivalued user-profile attributes.
Changes:
- Returns collections for multivalued attributes.
- Serializes
.valueattributes as SCIM complex arrays. - Adds integration coverage for simple and complex custom attributes.
Reviewed changes
Copilot reviewed 3 out of 3 changed files in this pull request and generated 3 comments.
| File | Description |
|---|---|
AbstractUserModelSchema.java |
Reads all values for multivalued attributes. |
UserExtensionModelSchema.java |
Maps multivalued extension values to SCIM arrays. |
UserTest.java |
Tests both supported multivalued representations. |
| AttributeMetadata metadata = attributes.getMetadata(name); | ||
| //support multivalued user attribute to scim as array | ||
| if (metadata != null && metadata.isMultivalued()) { | ||
| return attributes.get(name); |
| if ("value".equals(attributeName) && value instanceof Collection<?> values) { | ||
| subAttributes.put(parentAttributeName, values.stream() | ||
| .filter(Objects::nonNull) | ||
| .map(v -> Map.<String, Object>of("value", v)) | ||
| .toList()); |
| // Handle multivalued attributes annotated as "<parent>.value" | ||
| // so that SCIM gets: "<parent>": [ { "value": "..." }, ... ] | ||
| if ("value".equals(attributeName) && value instanceof Collection<?> values) { | ||
| subAttributes.put(parentAttributeName, values.stream() | ||
| .filter(Objects::nonNull) | ||
| .map(v -> Map.<String, Object>of("value", v)) | ||
| .toList()); |
| if (isMultivalued) { | ||
| builder = builder.multivalued(); |
| Attribute parent = topLevelAttributes.computeIfAbsent(topName, k -> { | ||
| Attribute p = new Attribute(); | ||
| p.setName(k); | ||
| p.setType("complex"); | ||
| p.setMultiValued(false); | ||
| p.setMultiValued(attribute.isMultivalued()); |
| if (existingParent instanceof Map<?, ?> existingMap && !existingMap.isEmpty()) { | ||
| return; |
48f10e5 to
160abdf
Compare
| import org.keycloak.scim.resource.Scim; | ||
| import org.keycloak.scim.resource.schema.Schema; | ||
| import org.keycloak.scim.resource.user.Email; |
| private Attribute<UserModel, User> createCustomAttribute(Object scimName, boolean isMultivalued) { | ||
| Attribute.Builder<UserModel, User> builder = Attribute.<UserModel, User>simple(scimName.toString()); | ||
| if (isMultivalued) { | ||
| builder = builder.multivalued() |
| if (values == null || values.isEmpty()) { | ||
| model.removeAttribute(name); | ||
| return; |
| if (values == null || values.isEmpty()) { | ||
| model.removeAttribute(name); | ||
| return; |
| // Handle multivalued attributes annotated as "<parent>.value" | ||
| // so that SCIM gets: "<parent>": [ { "value": "..." }, ... ] | ||
| if ("value".equals(attributeName) && value instanceof Collection<?> values) { |
Closes keycloak#50856 Signed-off-by: cgeorgilakis-grnet <cgeorgilakis@admin.grnet.gr>
72e3b60 to
8d864bc
Compare
| if (value.isArray()) { | ||
| values = value.valueStream().map(JsonNode::asText).collect(Collectors.toSet()); | ||
| values = value.valueStream().map(AttributeMapper::asMultivaluedString).collect(Collectors.toSet()); | ||
| } else { |
| * return a single value. | ||
| */ | ||
| protected boolean shouldReturnMultivaluedValues(AttributeMetadata metadata, Attribute<UserModel, User> scimAttribute) { | ||
| return metadata != null && scimAttribute != null && metadata.isMultivalued() && (scimAttribute.isMultivalued() || ( isInternal() && scimAttribute.isExtension())); |
There was a problem hiding this comment.
The isInternal() && scimAttribute.isExtension() fallback here appears unreachable today: for the extension schema (where isInternal() is true), createCustomAttribute always mirrors metadata.isMultivalued() onto the SCIM attribute, so scimAttribute.isMultivalued() already covers it. For core/enterprise schemas, isInternal() is false so the right side never fires. So the entire right side of the or seems to be dead code, no?
More importantly, if we later publish custom schemas via /Schemas (flipping isInternal() to false), this fallback would stop working as it guards on isInternal() == true, which is the opposite of what a published schema would have.
| } | ||
|
|
||
| @Test | ||
| public void testGetMultivaluedCustomAttributesWithAndWithoutValueSubAttribute() { |
There was a problem hiding this comment.
Wouldn't it be good to have tests covering PUT, PATCH, and maybe scenarios exercising the validateExtensionAttributeMappings path?
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 5 out of 5 changed files in this pull request and generated no new comments.
Comments suppressed due to low confidence (2)
scim/model/src/main/java/org/keycloak/scim/model/user/UserExtensionModelSchema.java:225
- A filtered PATCH remove on these new multivalued mappings deletes the entire attribute. For example,
remove assurance[value eq "x"]resolves the mapper through its.valuealias, butScimFilterToJsonNodeConverterreturnsnullwhencomplexType == null; this branch then interprets that as remove-all instead of removing onlyx. The filter conversion needs to support these simple-backed multivalued mappings (and this case should be covered by a PATCH test).
if (values == null || values.isEmpty()) {
model.removeAttribute(name);
return;
scim/model/src/main/java/org/keycloak/scim/model/user/UserExtensionModelSchema.java:322
- Multivalued nested mappings are only shaped correctly when the sub-attribute is named
value. A mapping such asroles.typefalls through and emits"roles":{"type":[...]}, while the schema change declaresrolesitself multivalued (which requires an array of complex objects). Either serialize every multivalued nested mapping as an array of objects or reject non-valuemappings during validation.
// Handle multivalued attributes annotated as "<parent>.value"
// so that SCIM gets: "<parent>": [ { "value": "..." }, ... ]
if ("value".equals(attributeName) && value instanceof Collection<?> values) {
Closes #50856