SCIM 2.0 PATCH (RFC 7644 §3.5.2) is the most expressive part of the protocol — and the one that varies the most between IdPs. AshScim parses PATCH bodies into a structured shape the router applies atomically.
{
"schemas": ["urn:ietf:params:scim:api:messages:2.0:PatchOp"],
"Operations": [
{"op": "replace", "path": "active", "value": false},
{"op": "replace", "path": "name.givenName", "value": "Alicia"},
{"op": "add", "path": "members",
"value": [{"value": "user-c"}]},
{"op": "remove", "path": "members[value eq \"user-a\"]"}
]
}Each operation has:
op—add,replace, orremovepath— the target (optional foraddandreplace)value— the new value (required foraddandreplace)
PATCH path values come in four shapes:
| Shape | Example |
|---|---|
| Simple | userName |
| Sub-attribute | name.givenName |
| Bracket filter | emails[type eq "work"] |
| Bracket filter + sub | emails[type eq "work"].value |
The bracket filter is a sub-set of the SCIM filter grammar and is resolved against the sub-attributes of the multi-valued attribute being targeted. See Filter syntax for the operators.
Sets the underlying Ash attribute. name.givenName writes to the
attribute mapped by the :givenName sub-map of the :name complex
attribute. Works for any simple or complex sub-attribute.
Bare-path replace on a complex attribute decodes the value through the
complex's sub-maps and merges all the underlying attributes in one
update.
The decoder runs over the value as if it were a partial SCIM resource; each declared mapping that appears writes to its underlying attribute. Useful as "merge this object into the resource."
Path-less ops only carry attribute changes — relationship arrays in the value are ignored, since the semantics of "add a partial resource that also includes a member list" aren't well-defined in the spec.
Sets the underlying attribute to nil. Whether your resource accepts
that depends on its allow_nil? declaration; if not, you get a
400 invalidValue.
Removes a complex attribute by setting all its sub-map attributes to
nil in one update.
For relationship-backed multivalueds, append new related rows. Uses
Ash.Changeset.manage_relationship/4 with
on_no_match: :create, on_match: :ignore. Existing related rows are
left in place.
For relationship-backed multivalueds, full-replace the related rows.
manage_relationship with
on_no_match: :create, on_match: :ignore, on_missing: :destroy.
Clears all related rows. Equivalent to replace with an empty array.
Destroys the related rows matching the bracket filter.
When the relationship is a plain has_many (no relationship-level
filter, not many_to_many), the router builds a single query:
Membership
|> Ash.Query.filter_input(%{
and: [
%{user_id: %{eq: "user-id"}},
%{group_id: %{eq: ^parent_group_id}}
]
})
|> Ash.bulk_destroy(:destroy, %{}, ...)For more complex relationships (with their own filter expr(...) clause,
or many_to_many), it falls back to loading through the relationship
and destroying the loaded records — that path correctly applies the
relationship's own scoping rules.
The whole PATCH — the Ash.update for attribute changes,
manage_relationship for append/replace ops, and the post-update
bulk_destroy for :remove_where ops — runs inside a single
Ash.transact/2 call. If any step fails, the entire transaction rolls
back. There is no partial-PATCH state.
On data layers that support row-level locking (Postgres), the parent
record is fetched with Ash.Query.lock(:for_update), so concurrent SCIM
PATCHes against the same record serialize cleanly.
-
Per-element
replacevia bracket filter on relationship-backed multivalueds.replace path: "members[value eq \"u1\"].value"with intent "change u1 to u2" is not currently wired up; most IdPs achieve the same effect withremovefollowed byadd, both of which work. -
PATCH ops that target the schemas array (e.g. adding/removing schema URNs from the
schemasclaim). Since AshScim derivesschemasfrom the resource's DSL, mutating it from the IdP side isn't meaningful.