Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,15 @@ If you enable import during sync then you can choose between to following import
- Nothing
- Delete Remote - deletes users from the remote application

### Custom user attributes

Outbound user provisioning also propagates non built-in Keycloak user attributes as SCIM additional properties.

Notes:
- only single-valued string custom attributes are propagated
- built-in fields (`username`, `email`, `firstName`, `lastName`, `enabled`) are still handled through the standard SCIM user schema
- the receiving SCIM server must expose matching user attributes, for example through Keycloak User Profile attribute definitions




Expand Down
56 changes: 51 additions & 5 deletions src/main/java/sh/libre/scim/core/UserAdapter.java
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,10 @@
import java.net.URISyntaxException;
import java.util.ArrayList;
import java.util.HashSet;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.stream.Stream;

import de.captaingoldfish.scim.sdk.client.ScimRequestBuilder;
Expand All @@ -25,13 +27,23 @@

public class UserAdapter extends Adapter<UserModel, User> {

private static final Set<String> BUILT_IN_ATTRIBUTE_NAMES = Set.of(
UserModel.USERNAME,
UserModel.EMAIL,
UserModel.FIRST_NAME,
UserModel.LAST_NAME,
UserModel.ENABLED,
"scim-skip"
);

private String username;
private String displayName;
private String givenName;
private String familyName;
private String email;
private Boolean active;
private String[] roles;
private Map<String, String> customAttributes = new LinkedHashMap<>();

public UserAdapter(KeycloakSession session, String componentId) {
super(session, componentId, "User", Logger.getLogger(UserAdapter.class));
Expand Down Expand Up @@ -101,6 +113,17 @@ public void setRoles(String[] roles) {
this.roles = roles;
}

public Map<String, String> getCustomAttributes() {
return customAttributes;
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The getCustomAttributes method returns a direct reference to the internal customAttributes map. This allows external code to modify the adapter's internal state, which can lead to unexpected behavior. It's better to return an unmodifiable view of the map to maintain encapsulation and prevent accidental modifications.

Suggested change
return customAttributes;
return java.util.Collections.unmodifiableMap(customAttributes);

}

public void setCustomAttributes(Map<String, String> customAttributes) {
this.customAttributes = new LinkedHashMap<>();
if (customAttributes != null) {
this.customAttributes.putAll(customAttributes);
}
}

@Override
public Class<User> getResourceClass() {
return User.class;
Expand Down Expand Up @@ -136,6 +159,7 @@ public void apply(UserModel user) {
var roles = new String[rolesSet.size()];
rolesSet.toArray(roles);
setRoles(roles);
setCustomAttributes(extractCustomAttributes(user));
this.skip = StringUtils.equals(user.getFirstAttribute("scim-skip"), "true");
}

Expand Down Expand Up @@ -184,6 +208,7 @@ public User toSCIM(Boolean addMeta) {
roles.add(role);
}
user.setRoles(roles);
customAttributes.forEach(user::put);
return user;
}

Expand Down Expand Up @@ -247,11 +272,6 @@ public Boolean skipRefresh() {
}
@Override
public PatchBuilder<User> toPatchBuilder(ScimRequestBuilder scimRequestBuilder, String url) {
var emails = new ArrayList<Email>();
if (email != null) {
emails.add(
Email.builder().value(getEmail()).build());
}
PatchBuilder<User> patchBuilder;
patchBuilder = scimRequestBuilder.patch(url, User.class);
patchBuilder.addOperation()
Expand All @@ -268,6 +288,32 @@ public PatchBuilder<User> toPatchBuilder(ScimRequestBuilder scimRequestBuilder,
.value(displayName)
.build();

customAttributes.forEach((attributeName, attributeValue) ->
patchBuilder.addOperation()
.path(attributeName)
.op(PatchOp.REPLACE)
.value(attributeValue)
.build()
);

return patchBuilder;
}

private Map<String, String> extractCustomAttributes(UserModel user) {
var attributes = new LinkedHashMap<String, String>();
user.getAttributes().forEach((attributeName, values) -> {
if (BUILT_IN_ATTRIBUTE_NAMES.contains(attributeName) || values == null || values.isEmpty()) {
return;
}

String value = values.stream()
.filter(StringUtils::isNotBlank)
.findFirst()
.orElse(values.get(0));
if (StringUtils.isNotBlank(value)) {
attributes.put(attributeName, value);
}
Comment on lines +309 to +315
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The logic to extract the first non-blank attribute value can be simplified. The current implementation is a bit convoluted with .orElse() and a separate if check. Using .ifPresent() on the Optional returned by findFirst() would make the code more concise, readable, and idiomatic for stream processing.

Suggested change
String value = values.stream()
.filter(StringUtils::isNotBlank)
.findFirst()
.orElse(values.get(0));
if (StringUtils.isNotBlank(value)) {
attributes.put(attributeName, value);
}
values.stream()
.filter(StringUtils::isNotBlank)
.findFirst()
.ifPresent(value -> attributes.put(attributeName, value));

});
return attributes;
}
}