Handle path-less PatchOp on Groups + return valid JSON errors#101
Conversation
0ea641f to
d946717
Compare
|
Just pushed a follow-up commit to this PR that fixes a related issue surfaced after testing this PR's path-less branch end-to-end with Okta: The path-less branch unblocked the request, but Okta's Group Push payload also includes The new commit adds an Happy to split this into a separate PR if you'd prefer. |
aa7ca32 to
0fbd604
Compare
0fbd604 to
d0c643c
Compare
d0c643c to
6ecc7bd
Compare
Three closely-related fixes that together let Okta-driven SCIM Group
Push and User provisioning round-trip through the realm and
organization-scoped endpoints.
1. Path-less PatchOp on Groups. RFC 7644 §3.5.2 says that when "path"
is omitted on a PatchOp, "value" carries a map of attribute -> value
to apply to the resource. Okta's Group Push (add / remove members)
emits this shape:
{"op":"replace","value":{"members":[{"value":"<user-id>"}]}}
The previous code called findByScimPath(null), got null, and threw
UnsupportedGroupPath, breaking every Okta Group Push. A path-less
branch in patchGroup expands the map into one logical operation per
entry.
2. Valid JSON error bodies on every patch / create / update / delete
path. Per RFC 7644 §3.12 a SCIM Service Provider error response is
a JSON object with schemas, status, and detail. Several handlers
were returning plain-text strings, which Okta surfaces in its
System Log as a malformed JSON parsing error and which other SCIM
clients reject before showing the operator anything useful. A new
ScimErrors helper centralises the envelope and is wired into the
Groups, Users, OrganizationUser, RealmScimServer, and
OrganizationScimServer entry points.
3. Silently ignore read-only / structural attrs on PATCH. Per RFC 7644
§3.5.2 and the SCIM core schema (RFC 7643 §3.1), servers MUST not
error on read-only / structural attributes appearing in PATCH
payloads. Okta and other clients echo "id", "meta", and "schemas"
inside the PatchOp value when it is constructed from a prior GET.
isReadOnlyOrStructural lives on AbstractController and is
consulted in both path-less and path-based branches before
attribute resolution. externalId is intentionally NOT in the list
per RFC 7643 §3.1 (client-settable).
Test assertions in *ListTestsIT switched from assertEquals on the
exact pre-change plain-text error string to assertTrue substring on
"Invalid filter", so they remain correct regardless of whether the
body is plain text or JSON.
6ecc7bd to
199ce36
Compare
Three changes that together let upstream SCIM clients round-trip
externalId on Group resources end-to-end:
1. Inbound (createGroup, updateGroup, patchGroup) persists the
inbound externalId as a Keycloak group attribute named
"externalId". Mirrors what the user path does via UserAttributes;
Keycloak groups have no UserProfile-style declaration so the
attribute is written directly via setSingleAttribute.
2. PATCH handler (path-less and path-based) handles externalId as a
client-settable attribute (RFC 7643 §3.1) instead of dropping it,
and narrows the read-only filter to id / meta / schemas only.
3. Outbound (translateGroup) reads the attribute back via
group.getFirstAttribute("externalId") and emits it on the Group
representation. The Group schema in scim-openapi.yaml gains
additionalProperties: true so the generated model exposes
putAdditionalProperty / getAdditionalProperty (the User schema
already declared this).
Kevra-only patch on top of v1.5.0-patch.4. Not on the upstream PR
branch, scope of Metatavu#101 stays as path-less PatchOp + JSON errors.
nicolamacoir
left a comment
There was a problem hiding this comment.
Reviewed the diff. The Okta Group Push fix and the move to RFC 7644 §3.12 error bodies are both the right calls; flagging three items I'd like to discuss before merge, plus a few smaller ones.
1. REPLACE on members wipes existing membership before resolving the incoming IDs, and silently skips unknown IDs
In applyGroupPatch the REPLACE branch clears all members via leaveGroup first, then iterates the incoming list with if (user != null) and silently no-ops on any unknown ID:
This overlaps with #108 (open), which proposes rejecting unknown member IDs in the path-based branch for the same reason — a silent 200 with a truncated/empty member list is indistinguishable from success on the client side. Worth coordinating the two PRs so the same approach applies to both branches: resolve all member IDs first, throw InvalidGroupMemberReference (or equivalent) if any is unknown, and only then mutate membership. If point 4 below (refactoring the two MEMBERS blocks into one) is taken, both paths get the fix for free.
2. ScimErrors.error JSON escaping isn't safe for arbitrary detail strings
Hand-rolled escape covers \ and " only. detail is fed from e.getMessage() for UnsupportedGroupPath at RealmScimServer.java:258, and that message embeds the attribute path from the client PATCH body — "Unsupported attribute: " + attrPath. A \n or \t in attrPath produces malformed JSON, reintroducing the same Jackson parse failure on the client side that this PR is fixing. Jackson is already on the classpath; building the body via ObjectNode (or Map<String,Object> + ObjectMapper) would be safer than extending the manual escape table.
3. applyOrgPatchValue REMOVE → ua.write(existing, null) NPEs on custom profile attributes
The writer registered for USER_PROFILE-sourced attributes is (user, value) -> user.setAttribute(name, List.of(value)) (see MetadataController.java). List.of(null) throws NPE on Java 9+, so a SCIM REMOVE targeting e.g. externalId on an org-scope user surfaces as an unhandled 500. Same defect exists in UsersController.applyPatchValue and is pre-existing there, but since the new helper duplicates the pattern it's worth fixing in both at once — user.removeAttribute(name) (or setAttribute(name, Collections.emptyList())) for USER_PROFILE attributes.
4. SonarCloud duplication
The 3.6% new-code duplication that failed the quality gate is concentrated in two pairs:
applyOrgPatchValueis nearly byte-identical toUsersController.applyPatchValue.OrganizationUserController extends UsersController— promotingapplyPatchValuefromprivatetoprotectedlets you deleteapplyOrgPatchValueentirely.applyGroupPatchand the path-based MEMBERS switch insidepatchGroupshare the REPLACE/ADD/REMOVE × DISPLAY_NAME/MEMBERS matrix. Routing both branches throughapplyGroupPatchwould cut the duplication and naturally unify the unknown-member-id handling from point 1.
Smaller stuff
RealmScimServer.scimError(lines 264-267) is a private one-line alias forScimErrors.error. 12 sites in the same file useScimErrors.*directly; only 4 use the shim. Worth removing for consistency since there are no other callers.applyGroupPatchDISPLAY_NAME REPLACE/ADD silently no-ops whenvalueisn't a String (line ~313), but the path-based branch hard-casts and throws CCE. Different behavior for the same operation — pick one (preferably reject withUnsupportedGroupPath).- The weakened test assertions (e.g.
RealmGroupListTestsIT.java:201and siblings) drop from exact match to substringcontains("Invalid filter")— necessary because the body shape changed, but they no longer assert the HTTP 400 status or the JSONdetailfield. Suggest asserting on the parsed body + status separately so a regression that swaps 400→500 or moves the message into a different field still fails. - Re: tests — yes please on
RealmGroupPatchTestsIT#testAddMemberPathLessPatchOp. The new path-less code currently has no coverage.
|
Thanks for your comments, I will have some time to review that during the next week. |
… on USER_PROFILE attrs Promote UsersController.applyPatchValue to protected and delete the near-identical OrganizationUserController.applyOrgPatchValue, routing both controllers through the single shared method. Add UserAttribute.clear(UserModel) with an optional Consumer<UserModel> remover registered at construction time. USER_PROFILE-backed attributes (externalId, displayName, etc.) register user -> user.removeAttribute(name) so REMOVE operations no longer pass null into List.of(value), which threw NPE on Java 9+ and returned HTTP 500.
# Conflicts: # src/test/java/fi/metatavu/keycloak/scim/server/test/tests/functional/RealmGroupPatchTestsIT.java
|
|
@nicolamacoir thanks for the careful review. Pushed a follow-up that addresses each item:
Heads-up on one deliberate extension: Also merged in Ready for another look when you have time. |
|
LGTM! 🚀 |



Summary
Continues #97 (path-less PatchOp on Users, Okta Deactivate User flow) by closing two related gaps that block Okta's Group Push:
GroupsController.patchGroupdid not handle path-less PatchOp shapes (RFC 7644 §3.5.2). Okta's Group membership push emits{\"op\":\"replace\",\"value\":{\"members\":[...]}}without a path field, which previously hitfindByScimPath(null)and threwUnsupportedGroupPath.Multiple error sites returned plain-text bodies (e.g.
Unsupported group path,Missing userName,Invalid filter). Strict SCIM clients (Okta, Entra ID) parse error responses as JSON and fail. Concretely Okta's Jackson parser surfaces:masking the actual SCIM error from the IdP.
Fixes #100, refs #95, #97.
Changes
ScimErrorshelper builds RFC 7644 §3.12-compliant Error JSON withapplication/scim+jsonmedia type, plus convenience methodsbadRequest,notFound,conflict,forbidden.RealmScimServer,OrganizationScimServer, and the filter handlers inScimResourcesroute through it.GroupsController.patchGroup: whenpathis null, iterate the map-valuedvalueand apply each entry as a sub-operation via a new private helperapplyGroupPatch(handlesDISPLAY_NAMEandMEMBERSfor ADD/REPLACE/REMOVE).OrganizationUserController.patchOrganizationUser: same path-less treatment as Handle path-less PatchOp shape (Okta Deactivate User) #97 brought fromUsersController.No new public API. No version bump in this PR (you manage releases).
Verification
Reproduced the customer-facing failure against a realm running v1.5.0:
Before:
HTTP 400with bodyUnsupported group path(plain text). Okta's parser fails.After this PR:
HTTP 200with the updated Group SCIM JSON, members list reflects the change. Negative inputs returnHTTP 4xxwith a parseable SCIM Error JSON body andapplication/scim+jsonContent-Type.Verified end-to-end with a real Okta tenant pushing user-to-group memberships.
Tests
I haven't added new tests in this PR; happy to add an integration test along the lines of
RealmUserPatchTestsIT#testDeactivateUserPathLessPatchOpif you'd like one specifically forRealmGroupPatchTestsIT#testAddMemberPathLessPatchOp. Let me know.