Skip to content
Open
Show file tree
Hide file tree
Changes from 7 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
40 changes: 19 additions & 21 deletions .github/workflows/release-keycloak-scim.yaml
Original file line number Diff line number Diff line change
@@ -1,34 +1,32 @@
name: Build keycloak-scim and release

on:
push:
branches:
- 'main'
- "main"
tags:
- 'v*'

- "v*"
jobs:
build:
runs-on: ubuntu-22.04

steps:
- uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4
- uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4

- name: Set up JDK 17
uses: actions/setup-java@99b8673ff64fbf99d8d325f52d9a5bdedb8483e9 # v4
with:
java-version: '17'
distribution: 'temurin'
- name: Set up JDK 17
uses: actions/setup-java@99b8673ff64fbf99d8d325f52d9a5bdedb8483e9 # v4
with:
java-version: "17"
distribution: "temurin"

- name: Build with Gradle
run: gradle shadowJar
- name: Build with Gradle
run: gradle shadowJar

- name: Create Release with jar file
uses: "marvinpinto/action-automatic-releases@latest"
with:
repo_token: "${{ secrets.GITHUB_TOKEN }}"
automatic_release_tag: "latest"
prerelease: false
title: "keycloak-scim release"
files: |
./build/libs/*.jar
- name: Create Release with jar file
uses: "marvinpinto/action-automatic-releases@latest"
with:
repo_token: "${{ secrets.GITHUB_TOKEN }}"
automatic_release_tag: "latest"
prerelease: false
title: "keycloak-scim release"
files: |
./build/libs/*.jar
234 changes: 181 additions & 53 deletions src/main/java/sh/libre/scim/core/GroupAdapter.java
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,8 @@ public void apply(GroupModel group) {
.getGroupMembersStream(session.getContext().getRealm(), group)
.map(x -> x.getId())
.collect(Collectors.toSet());
LOGGER.info(String.format("Collected %d members for group %s (id=%s)",
this.members.size(), group.getName(), group.getId()));
this.skip = StringUtils.equals(group.getFirstAttribute("scim-skip"), "true");
}

Expand All @@ -61,14 +63,30 @@ public void apply(Group group) {
setExternalId(group.getId().get());
setDisplayName(group.getDisplayName().get());
var groupMembers = group.getMembers();
this.members = new HashSet<String>();
if (groupMembers != null && groupMembers.size() > 0) {
this.members = new HashSet<String>();
LOGGER.info(String.format("Processing %d incoming members for group %s",
groupMembers.size(), getDisplayName()));
for (var groupMember : groupMembers) {
var userMapping = this.query("findByExternalId", groupMember.getValue().get(), "User")
.getSingleResult();
this.members.add(userMapping.getId());
try {
String memberValue = groupMember.getValue().get();
LOGGER.info(String.format("Looking up user with externalId: %s", memberValue));
var userMapping = this.query("findByExternalId", memberValue, "User")
.getSingleResult();
LOGGER.info(String.format("Found user mapping: id=%s, externalId=%s",
userMapping.getId(), userMapping.getExternalId()));
this.members.add(userMapping.getId());
} catch (NoResultException e) {
LOGGER.error(String.format("No user mapping found for externalId: %s",
groupMember.getValue().get()));
Comment on lines +79 to +81

Choose a reason for hiding this comment

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

medium

The NoResultException is caught, but only an error is logged. Consider adding more context to the log message, or potentially retrying the operation, or even skipping the user and logging a warning if the user is not found. Also, consider using a more specific exception type if possible.

} catch (Exception e) {
LOGGER.error(String.format("Failed to process incoming group member: %s - Error: %s",
groupMember.getValue().get(), e.getMessage()), e);
}
}
}
LOGGER.info(String.format("Processed incoming SCIM group %s with %d mapped members",
getDisplayName(), this.members.size()));
}

@Override
Expand All @@ -77,28 +95,42 @@ public Group toSCIM(Boolean addMeta) {
group.setId(externalId);
group.setExternalId(id);
group.setDisplayName(displayName);
if (members.size() > 0) {
var groupMembers = new ArrayList<Member>();
for (var member : members) {
var groupMember = new Member();
try {
var userMapping = this.query("findById", member, "User").getSingleResult();
groupMember.setValue(userMapping.getExternalId());
var ref = new URI(String.format("Users/%s", userMapping.getExternalId()));
groupMember.setRef(ref.toString());
groupMembers.add(groupMember);
} catch (Exception e) {
LOGGER.error(e);

List<Member> groupMembers = new ArrayList<>();
LOGGER.info(String.format("Processing %d members for SCIM group %s", members.size(), displayName));

for (String memberId : members) {
try {
// Try to ensure the user has a mapping
String externalId = ensureUserMapping(memberId);

if (externalId == null) {
LOGGER.error(String.format("Could not get or create mapping for user %s, skipping", memberId));
continue;
Comment on lines +107 to +109

Choose a reason for hiding this comment

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

high

If externalId is null, the code logs an error and continues. It might be better to throw an exception or return an error to indicate that the mapping failed. This would allow the calling method to handle the error appropriately.

}

LOGGER.info(String.format("Adding member with externalId %s to group %s", externalId, displayName));
var groupMember = new Member();
groupMember.setValue(externalId);
groupMember.setType("User");

Choose a reason for hiding this comment

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

medium

Why is the type hardcoded to User? Should this be configurable, or is it always the case that the members are users?

var ref = new URI(String.format("Users/%s", externalId));
groupMember.setRef(ref.toString());
groupMembers.add(groupMember);
} catch (Exception e) {
LOGGER.error("Failed to process group member: " + memberId, e);
}
group.setMembers(groupMembers);
}

group.setMembers(groupMembers);
LOGGER.info(String.format("Final SCIM group %s has %d members", displayName, groupMembers.size()));

if (addMeta) {
var meta = new Meta();
try {
var uri = new URI("Groups/" + externalId);
meta.setLocation(uri.toString());
} catch (URISyntaxException e) {
LOGGER.error("Failed to create meta URI", e);
}
group.setMeta(meta);
}
Expand Down Expand Up @@ -131,15 +163,20 @@ public Boolean tryToMap() {
public void createEntity() {
var group = session.groups().createGroup(realm, displayName);
this.id = group.getId();
LOGGER.info(String.format("Created new group: %s (id=%s)", displayName, this.id));

for (String mId : members) {
try {
LOGGER.info(String.format("Attempting to add user with id=%s to group %s", mId, displayName));
var user = session.users().getUserById(realm, mId);
if (user == null) {
throw new NoResultException();
LOGGER.warn(String.format("User with id=%s not found in Keycloak", mId));
continue;
}
LOGGER.info(String.format("Adding user %s to group %s", user.getUsername(), displayName));
user.joinGroup(group);
} catch (Exception e) {
LOGGER.warn(e);
LOGGER.warn(String.format("Failed to add user with id=%s to group: %s", mId, e.getMessage()), e);
}
}
}
Expand All @@ -158,42 +195,133 @@ public Boolean skipRefresh() {
public PatchBuilder<Group> toPatchBuilder(ScimRequestBuilder scimRequestBuilder, String url) {
List<Member> groupMembers = new ArrayList<>();
PatchBuilder<Group> patchBuilder;
patchBuilder = scimRequestBuilder.patch(url, Group.class);
if (members.size() > 0) {
for (String member : members) {
var userMapping = this.query("findById", member, "User").getSingleResult();
groupMembers.add(Member.builder().value(userMapping.getExternalId()).build());
try {
LOGGER.info(String.format("Creating PATCH request to URL: %s", url));
patchBuilder = scimRequestBuilder.patch(url, Group.class);

if (members.size() > 0) {
for (String member : members) {
try {
LOGGER.info(String.format("Looking for member with id: %s", member));

// Try to ensure the user has a mapping
String externalId = ensureUserMapping(member);

if (externalId == null) {
LOGGER.error(
String.format("Could not get or create mapping for user %s, skipping", member));
continue;
Comment on lines +210 to +213

Choose a reason for hiding this comment

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

high

If externalId is null, the code logs an error and continues. It might be better to throw an exception or return an error to indicate that the mapping failed. This would allow the calling method to handle the error appropriately.

}

LOGGER.info(String.format("Using externalId %s for user %s", externalId, member));

Member memberNode = Member.builder()
.value(externalId)
.type("User")
.build();
groupMembers.add(memberNode);
LOGGER.info(String.format("Added member %s to PATCH request", externalId));
} catch (Exception e) {
LOGGER.error(String.format("Failed to process member %s: %s - Stack trace: ",
member, e.getMessage()), e);
}
}

// Debug the members being sent
LOGGER.info(String.format("Adding %d members to PATCH request", groupMembers.size()));

patchBuilder.addOperation()
.path("members")
.op(PatchOp.REPLACE)
.valueNodes(groupMembers)
.next()
.op(PatchOp.REPLACE)
.path("displayName")
.value(displayName)
.next()
.op(PatchOp.REPLACE)
.path("externalId")
.value(id)
.build();
} else {
LOGGER.info("No members to add, using REMOVE operation");
patchBuilder.addOperation()
.path("members")
.op(PatchOp.REMOVE)
.value(null)
.next()
.op(PatchOp.REPLACE)
.path("displayName")
.value(displayName)
.next()
.op(PatchOp.REPLACE)
.path("externalId")
.value(id)
.build();
}
patchBuilder.addOperation()
.path("members")
.op(PatchOp.REPLACE)
.valueNodes(groupMembers)
.next()
.op(PatchOp.REPLACE)
.path("displayName")
.value(displayName)
.next()
.op(PatchOp.REPLACE)
.path("externalId")
.value(id)
.build();
} else {
patchBuilder.addOperation()
.path("members")
.op(PatchOp.REMOVE)
.value(null)
.next()
.op(PatchOp.REPLACE)
.path("displayName")
.value(displayName)
.next()
.op(PatchOp.REPLACE)
.path("externalId")
.value(id)
.build();

// Log the entire request for debugging
LOGGER.info("Final PATCH request payload: " + patchBuilder.getResource());
return patchBuilder;

} catch (Exception e) {
LOGGER.error(String.format("Failed to create patch request to %s: %s", url, e.getMessage()), e);
throw e;
}
}

/**
* Ensures a user has a SCIM mapping, creating one if needed
*
* @param userId The Keycloak user ID
* @return The external SCIM ID for the user, or null if mapping failed
*/
private String ensureUserMapping(String userId) {
try {
// First try to get the existing mapping
LOGGER.info(String.format("Checking if user %s already has a SCIM mapping", userId));
var userMappingQuery = this.query("findById", userId, "User");
try {
var existingMapping = userMappingQuery.getSingleResult();
LOGGER.info(String.format("Found existing user mapping: id=%s, externalId=%s",
existingMapping.getId(), existingMapping.getExternalId()));
return existingMapping.getExternalId();
} catch (NoResultException e) {
// No mapping found, need to create one
LOGGER.info(String.format("No SCIM mapping found for user %s, creating one", userId));

// Get the user from Keycloak
var user = session.users().getUserById(realm, userId);
if (user == null) {
LOGGER.error(String.format("Cannot create mapping: User %s not found in Keycloak", userId));
return null;
}

// Create a new UserAdapter to handle the mapping
LOGGER.info(String.format("Creating UserAdapter for user %s (%s)", userId, user.getUsername()));
try {
// Create a new User adapter and generate a mapping
var userAdapter = new UserAdapter(session, this.componentId);
userAdapter.setId(userId);

// Generate a new externalId (UUID) for this user
String externalId = java.util.UUID.randomUUID().toString();
userAdapter.setExternalId(externalId);

// Persist the mapping
LOGGER.info(String.format("Persisting new user mapping: id=%s, externalId=%s", userId, externalId));
userAdapter.saveMapping();

return externalId;
} catch (Exception ex) {
LOGGER.error(String.format("Failed to create user mapping for %s: %s", userId, ex.getMessage()),
ex);
return null;
}
}
LOGGER.info(patchBuilder.getResource());
return patchBuilder;
} catch (Exception e) {
LOGGER.error(String.format("Error ensuring user mapping for %s: %s", userId, e.getMessage()), e);
return null;
}
}
}
5 changes: 4 additions & 1 deletion src/main/java/sh/libre/scim/core/ScimClient.java
Original file line number Diff line number Diff line change
Expand Up @@ -256,7 +256,9 @@ public <M extends RoleMapperModel, S extends ResourceNode, A extends Adapter<M,
LOGGER.info("Import");
try {
var adapter = getAdapter(aClass);
ServerResponse<ListResponse<S>> response = scimRequestBuilder.list("url", adapter.getResourceClass()).get().sendRequest();
String endpointPath = "/" + adapter.getSCIMEndpoint();
LOGGER.infof("Importing resources from %s", endpointPath);
ServerResponse<ListResponse<S>> response = scimRequestBuilder.list(endpointPath, adapter.getResourceClass()).get().sendRequest();
ListResponse<S> resourceTypeListResponse = response.getResource();

for (var resource : resourceTypeListResponse.getListedResources()) {
Expand Down Expand Up @@ -311,6 +313,7 @@ public <M extends RoleMapperModel, S extends ResourceNode, A extends Adapter<M,
}
}
} catch (ResponseException e) {
LOGGER.error("Error during import: " + e.getMessage(), e);
throw new RuntimeException(e);
}
}
Expand Down
Loading