Skip to content

Commit 78a207a

Browse files
committed
fix(groups): accept path-less PATCH Operations per RFC 7644 §3.5.2
RFC 7644 §3.5.2.1 / §3.5.2.3 allow Operations with no "path" — in that case "value" is a partial resource that should be merged into the target. UsersController.patchUser already handles this branch; the Group variant did not, returning "Unsupported group path" (400) for any path-less Operation. Okta's SCIM client uses this shape for group metadata reconciliation immediately after each create: {"Operations":[{"op":"replace", "value":{"id":"...","displayName":"..."}}]} The 400 caused Okta to mark the entire group push as Error and stop pushing group memberships, breaking provisioning end-to-end against the OAuth Bearer Token variant of Okta's SCIM 2.0 Test App. Mirror the path-less branch from UsersController: - if path is null and value is a Map, iterate entries and apply each - read-only / unknown attributes (id, schemas, meta, externalId, …) are skipped with a debug log rather than failing, matching the RFC's "extend gracefully" tone and matching Okta's real payloads - the existing path-based switch is refactored into a private applyGroupPatchValue helper so both branches share it Adds two regression tests in RealmGroupPatchTestsIT exercising the happy path (displayName update) and the read-only-only edge case (only id/schemas in value). Validated end-to-end against a live Okta SCIM 2.0 Test App (OAuth Bearer Token); after this fix, Okta proceeds to push group memberships via PATCH op=add path=members value=[...] which the existing handler accepts.
1 parent 5c89498 commit 78a207a

2 files changed

Lines changed: 167 additions & 53 deletions

File tree

src/main/java/fi/metatavu/keycloak/scim/server/groups/GroupsController.java

Lines changed: 88 additions & 53 deletions
Original file line numberDiff line numberDiff line change
@@ -171,8 +171,31 @@ public fi.metatavu.keycloak.scim.server.model.Group patchGroup(
171171
throw new UnsupportedPatchOperation("Unsupported patch operation: " + operation.getOp());
172172
}
173173

174+
// RFC 7644 §3.5.2: when "path" is omitted, "value" carries a
175+
// partial resource (map of attribute -> value) to apply to the
176+
// target. Okta emits this for group metadata reconciliation:
177+
// {"op":"replace","value":{"id":"...","displayName":"..."}}.
178+
// Iterate the map and apply each known attribute; ignore
179+
// read-only / unknown keys so spurious metadata fields like "id"
180+
// don't fail the request.
181+
if (path == null) {
182+
if (!(value instanceof Map<?, ?> valueMap)) {
183+
throw new UnsupportedGroupPath("PatchOp without 'path' requires a map-valued 'value'");
184+
}
185+
for (Map.Entry<?, ?> entry : valueMap.entrySet()) {
186+
String attrName = String.valueOf(entry.getKey());
187+
GroupAttribute attr = GroupAttribute.findByScimPath(attrName);
188+
if (attr == null) {
189+
logger.debugf("Ignoring unsupported group attribute in path-less PATCH: %s", attrName);
190+
continue;
191+
}
192+
applyGroupPatchValue(scimContext, op, attr, null, existing, entry.getValue());
193+
}
194+
continue;
195+
}
196+
174197
// Extract base attribute path (e.g., "members" from "members[value eq \"id\"]")
175-
String attributePath = path != null && path.contains("[")
198+
String attributePath = path.contains("[")
176199
? path.substring(0, path.indexOf("["))
177200
: path;
178201

@@ -187,64 +210,78 @@ public fi.metatavu.keycloak.scim.server.model.Group patchGroup(
187210
break;
188211
}
189212

190-
switch (op) {
191-
case REPLACE, ADD -> {
192-
switch (groupAttribute) {
193-
case DISPLAY_NAME -> existing.setName((String) value);
194-
case MEMBERS -> {
195-
// Clear current members if REPLACE, just add if ADD
196-
if (op == PatchOperation.REPLACE) {
197-
session.users().getGroupMembersStream(realm, existing)
198-
.forEach(user -> user.leaveGroup(existing));
199-
}
213+
applyGroupPatchValue(scimContext, op, groupAttribute, path, existing, value);
214+
}
200215

201-
for (Object obj : (List<?>) value) {
202-
if (!(obj instanceof Map<?, ?> memberMap)) {
203-
logger.warn("Invalid member object: " + obj);
204-
continue;
205-
}
216+
return translateGroup(scimContext, existing);
217+
}
206218

207-
String memberId = (String) memberMap.get("value");
208-
if (memberId == null) {
209-
logger.warn("Member value missing: " + obj);
210-
continue;
211-
}
219+
/**
220+
* Applies a single PATCH operation to a group attribute. Shared by the
221+
* path-based and path-less PATCH branches so the membership and
222+
* displayName mutation logic stays in one place.
223+
*/
224+
private void applyGroupPatchValue(
225+
ScimContext scimContext,
226+
PatchOperation op,
227+
GroupAttribute groupAttribute,
228+
String path,
229+
GroupModel existing,
230+
Object value
231+
) {
232+
KeycloakSession session = scimContext.getSession();
233+
RealmModel realm = scimContext.getRealm();
212234

213-
UserModel user = scimContext.getSession().users().getUserById(scimContext.getRealm(), memberId);
214-
if (user != null) {
215-
user.joinGroup(existing);
216-
dispatchGroupMembershipJoinEvent(scimContext, existing, user);
217-
}
235+
switch (op) {
236+
case REPLACE, ADD -> {
237+
switch (groupAttribute) {
238+
case DISPLAY_NAME -> existing.setName((String) value);
239+
case MEMBERS -> {
240+
if (op == PatchOperation.REPLACE) {
241+
session.users().getGroupMembersStream(realm, existing)
242+
.forEach(user -> user.leaveGroup(existing));
243+
}
244+
for (Object obj : (List<?>) value) {
245+
if (!(obj instanceof Map<?, ?> memberMap)) {
246+
logger.warn("Invalid member object: " + obj);
247+
continue;
248+
}
249+
String memberId = (String) memberMap.get("value");
250+
if (memberId == null) {
251+
logger.warn("Member value missing: " + obj);
252+
continue;
253+
}
254+
UserModel user = session.users().getUserById(realm, memberId);
255+
if (user != null) {
256+
user.joinGroup(existing);
257+
dispatchGroupMembershipJoinEvent(scimContext, existing, user);
218258
}
219259
}
220260
}
221261
}
222-
223-
case REMOVE -> {
224-
switch (groupAttribute) {
225-
case DISPLAY_NAME -> existing.setName(null);
226-
case MEMBERS -> {
227-
// Handle path filter (e.g., "members[value eq \"user-id\"]")
228-
if (path != null && path.contains("[")) {
229-
String memberId = extractValueFromFilter(path);
230-
if (memberId != null) {
231-
UserModel user = session.users().getUserById(realm, memberId);
232-
if (user != null) {
233-
user.leaveGroup(existing);
234-
dispatchGroupMembershipLeaveEvent(scimContext, existing, user);
235-
}
262+
}
263+
case REMOVE -> {
264+
switch (groupAttribute) {
265+
case DISPLAY_NAME -> existing.setName(null);
266+
case MEMBERS -> {
267+
if (path != null && path.contains("[")) {
268+
String memberId = extractValueFromFilter(path);
269+
if (memberId != null) {
270+
UserModel user = session.users().getUserById(realm, memberId);
271+
if (user != null) {
272+
user.leaveGroup(existing);
273+
dispatchGroupMembershipLeaveEvent(scimContext, existing, user);
236274
}
237-
} else if (value instanceof List<?> list) {
238-
// Handle direct value list
239-
for (Object obj : list) {
240-
if (obj instanceof Map<?, ?> memberMap) {
241-
String memberId = (String) memberMap.get("value");
242-
if (memberId != null) {
243-
UserModel user = session.users().getUserById(realm, memberId);
244-
if (user != null) {
245-
user.leaveGroup(existing);
246-
dispatchGroupMembershipLeaveEvent(scimContext, existing, user);
247-
}
275+
}
276+
} else if (value instanceof List<?> list) {
277+
for (Object obj : list) {
278+
if (obj instanceof Map<?, ?> memberMap) {
279+
String memberId = (String) memberMap.get("value");
280+
if (memberId != null) {
281+
UserModel user = session.users().getUserById(realm, memberId);
282+
if (user != null) {
283+
user.leaveGroup(existing);
284+
dispatchGroupMembershipLeaveEvent(scimContext, existing, user);
248285
}
249286
}
250287
}
@@ -254,8 +291,6 @@ public fi.metatavu.keycloak.scim.server.model.Group patchGroup(
254291
}
255292
}
256293
}
257-
258-
return translateGroup(scimContext, existing);
259294
}
260295

261296
/**

src/test/java/fi/metatavu/keycloak/scim/server/test/tests/functional/RealmGroupPatchTestsIT.java

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,9 @@
1414

1515
import java.io.IOException;
1616
import java.util.Collections;
17+
import java.util.HashMap;
1718
import java.util.List;
19+
import java.util.Map;
1820

1921
import static org.junit.jupiter.api.Assertions.*;
2022

@@ -256,4 +258,81 @@ void testRemoveGroupMemberWithoutEmail() throws ApiException {
256258
deleteRealmUser(TestConsts.TEST_REALM, user.getId());
257259
deleteRealmGroup(TestConsts.TEST_REALM, group.getId());
258260
}
261+
262+
/**
263+
* Regression test for path-less PATCH on Groups per RFC 7644 §3.5.2.
264+
*
265+
* Okta's SCIM client refreshes group metadata after each push by sending
266+
* a PATCH where the Operation omits "path" and supplies a partial
267+
* resource as "value":
268+
*
269+
* {"op":"replace","value":{"id":"...","displayName":"..."}}
270+
*
271+
* Before this fix, GroupsController#patchGroup rejected such Operations
272+
* with `UnsupportedGroupPath`, which Okta interpreted as a group-push
273+
* failure and stopped pushing memberships. The fix mirrors the
274+
* path-less branch already present in UsersController#patchUser:
275+
* iterate the value map, apply each known attribute, and ignore
276+
* unknown / read-only keys (id, schemas, meta, externalId) without
277+
* failing.
278+
*/
279+
@Test
280+
void testPatchGroupWithoutPathReplacesDisplayName() throws ApiException {
281+
ScimClient scimClient = getAuthenticatedScimClient();
282+
Group group = createGroup(scimClient, "pathless-original-name");
283+
284+
PatchRequest patchRequest = new PatchRequest();
285+
patchRequest.setSchemas(List.of("urn:ietf:params:scim:api:messages:2.0:PatchOp"));
286+
287+
PatchRequestOperationsInner operation = new PatchRequestOperationsInner();
288+
operation.setOp("replace");
289+
// path intentionally omitted to exercise the path-less branch.
290+
291+
Map<String, Object> partialResource = new HashMap<>();
292+
partialResource.put("id", group.getId()); // read-only, must be ignored
293+
partialResource.put("displayName", "pathless-updated-name"); // must be applied
294+
operation.setValue(partialResource);
295+
296+
patchRequest.setOperations(List.of(operation));
297+
298+
Group patched = scimClient.patchGroup(group.getId(), patchRequest);
299+
300+
assertEquals("pathless-updated-name", patched.getDisplayName());
301+
302+
deleteRealmGroup(TestConsts.TEST_REALM, group.getId());
303+
}
304+
305+
/**
306+
* Path-less PATCH where the value map contains only attributes the
307+
* server treats as read-only / unknown (id, schemas) must succeed
308+
* silently — applying nothing — rather than throwing
309+
* UnsupportedGroupPath. This mirrors Okta's idempotent metadata
310+
* reconciliation calls where the server returns the same data Okta
311+
* just pushed.
312+
*/
313+
@Test
314+
void testPatchGroupWithoutPathIgnoresReadOnlyAttributes() throws ApiException {
315+
ScimClient scimClient = getAuthenticatedScimClient();
316+
Group group = createGroup(scimClient, "pathless-readonly-name");
317+
String originalDisplayName = group.getDisplayName();
318+
319+
PatchRequest patchRequest = new PatchRequest();
320+
patchRequest.setSchemas(List.of("urn:ietf:params:scim:api:messages:2.0:PatchOp"));
321+
322+
PatchRequestOperationsInner operation = new PatchRequestOperationsInner();
323+
operation.setOp("replace");
324+
325+
Map<String, Object> partialResource = new HashMap<>();
326+
partialResource.put("id", group.getId());
327+
partialResource.put("schemas", List.of("urn:ietf:params:scim:schemas:core:2.0:Group"));
328+
operation.setValue(partialResource);
329+
330+
patchRequest.setOperations(List.of(operation));
331+
332+
Group patched = scimClient.patchGroup(group.getId(), patchRequest);
333+
334+
assertEquals(originalDisplayName, patched.getDisplayName());
335+
336+
deleteRealmGroup(TestConsts.TEST_REALM, group.getId());
337+
}
259338
}

0 commit comments

Comments
 (0)