diff --git a/README.md b/README.md index b56657b..490ae82 100644 --- a/README.md +++ b/README.md @@ -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 + diff --git a/src/main/java/sh/libre/scim/core/UserAdapter.java b/src/main/java/sh/libre/scim/core/UserAdapter.java index f13d6ee..3572260 100644 --- a/src/main/java/sh/libre/scim/core/UserAdapter.java +++ b/src/main/java/sh/libre/scim/core/UserAdapter.java @@ -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; @@ -25,6 +27,15 @@ public class UserAdapter extends Adapter { + private static final Set 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; @@ -32,6 +43,7 @@ public class UserAdapter extends Adapter { private String email; private Boolean active; private String[] roles; + private Map customAttributes = new LinkedHashMap<>(); public UserAdapter(KeycloakSession session, String componentId) { super(session, componentId, "User", Logger.getLogger(UserAdapter.class)); @@ -101,6 +113,17 @@ public void setRoles(String[] roles) { this.roles = roles; } + public Map getCustomAttributes() { + return customAttributes; + } + + public void setCustomAttributes(Map customAttributes) { + this.customAttributes = new LinkedHashMap<>(); + if (customAttributes != null) { + this.customAttributes.putAll(customAttributes); + } + } + @Override public Class getResourceClass() { return User.class; @@ -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"); } @@ -184,6 +208,7 @@ public User toSCIM(Boolean addMeta) { roles.add(role); } user.setRoles(roles); + customAttributes.forEach(user::put); return user; } @@ -247,11 +272,6 @@ public Boolean skipRefresh() { } @Override public PatchBuilder toPatchBuilder(ScimRequestBuilder scimRequestBuilder, String url) { - var emails = new ArrayList(); - if (email != null) { - emails.add( - Email.builder().value(getEmail()).build()); - } PatchBuilder patchBuilder; patchBuilder = scimRequestBuilder.patch(url, User.class); patchBuilder.addOperation() @@ -268,6 +288,32 @@ public PatchBuilder toPatchBuilder(ScimRequestBuilder scimRequestBuilder, .value(displayName) .build(); + customAttributes.forEach((attributeName, attributeValue) -> + patchBuilder.addOperation() + .path(attributeName) + .op(PatchOp.REPLACE) + .value(attributeValue) + .build() + ); + return patchBuilder; } + + private Map extractCustomAttributes(UserModel user) { + var attributes = new LinkedHashMap(); + 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); + } + }); + return attributes; + } }