IDENTITY GOVERNANCE · LAB
Send a joiner, a mover and a leaver over raw SCIM, and read every answer
Create, find, page, patch, move between groups, deactivate and delete a user against your own tenant, and produce each SCIM error from the lesson's catalogue, including a stale write stopped by If-Match.
ReadyUses your lab tenant
The lesson
Builds on: What a directory holds.
New to the labs? Start with the lab toolkit and the shared cast and names every lab uses.
Your progress
Press Start before you begin. Only events your tenant records after that count, in the order below. Checking reads your tenant's Audit, so you need Audit read access in it.
Sign in to start this lab and check your progress. Log in or create an account.
Create a disabled joiner
Recorded as
scim.user.createsucceeded forlab-provisioning.A second user with the same userName is refused
Recorded as
scim.user.createrejected (uniqueness) forlab-provisioning.A PATCH with one bad operation changes nothing
Recorded as
scim.user.patchrejected (invalid_path) forlab-provisioning.Add the mover to their new group
Recorded as
scim.group.patchsucceeded forlab-provisioning.A write based on an old version is refused
Recorded as
scim.user.patchrejected (version_mismatch) forlab-provisioning.Delete the leaver's account
Recorded as
scim.user.deletesucceeded forlab-provisioning.
Request console
Requests in this lab can be sent from this page to your tenant: open one and choose Send. Fill in the values below first. They stay in this page's memory and are gone when you leave; secrets are never stored or sent anywhere except the request you send.
Setup
The lesson follows Sam Reyes from joiner to leaver. Here a temporary user, lab-tmp-sam, makes the same journey through your tenant.
Open a bash shell and set the variables and helpers from the directory lab, plus
READER_IDandREADER_SECRETforlab-scim-reader.Look up the people and group the steps use.
export CORA=$(scim -G "$SCIM/Users" --data-urlencode 'filter=userName eq "[email protected]"' | jq -r '.Resources[0].id')
export MOD=$(scim -G "$SCIM/Groups" --data-urlencode 'filter=displayName eq "Photo moderation"' | jq -r '.Resources[0].id')
Press Start on the lab page.
Walkthrough
Create a disabled user. Send the joiner with
activefalse, so the account exists before its start date.
POST$ISSUER/scim/v2/Users
Open in console
POST $ISSUER/scim/v2/Users HTTP/1.1
Authorization: Bearer $TOKEN
Content-Type: application/scim+json
{"schemas":["urn:ietf:params:scim:schemas:core:2.0:User","urn:ietf:params:scim:schemas:extension:enterprise:2.0:User"],
"externalId":"E07731","userName":"lab-tmp-sam","name":{"givenName":"Sam","familyName":"Reyes"},"displayName":"Sam Reyes",
"emails":[{"value":"[email protected]","type":"work","primary":true}],"active":false,
"urn:ietf:params:scim:schemas:extension:enterprise:2.0:User":{"employeeNumber":"E07731","department":"Print support"}}The answer is 201 Created with a Location ending in the new id, an ETag, and the stored user. Run export SAM=<id>.
Why it matters: this is "Creating a user". The client sends its own key in externalId, the service assigns id, and the client stores that id for every later request.
Uniqueness. Send the same body again with a different
externalId. The answer is409withscimType: uniqueness. Then find what holds the name:scim -G "$SCIM/Users" --data-urlencode 'filter=userName eq "lab-tmp-sam"' --data-urlencode 'attributes=userName,externalId,active' | jq .Resources.
Why it matters: a conflict means an account you did not create may exist. Search for it and let a person decide; never add a digit to the name.
Filter operators. Predict each count, then run it with
scim -G "$SCIM/Users" --data-urlencode "filter=<filter>" | jq .totalResults:externalId eq "E07731"displayName co "Reyes"userName sw "lab-tmp-"emails.value ew "@example.com"externalId prmeta.lastModified gt "<today>T00:00:00Z"active eq true and not (externalId pr)emails[type eq "work" and value ew "@example.com"]
Why it matters: this is "Finding users". A search that matches nothing is not an error, and active eq true and not (externalId pr) finds the accounts no client has claimed.
Paging, and searching in the body.
scim -G "$SCIM/Users" --data-urlencode count=5 --data-urlencode startIndex=1 --data-urlencode sortBy=meta.created | jq '{totalResults, startIndex, itemsPerPage}'
scim -G "$SCIM/Users" --data-urlencode count=5 --data-urlencode startIndex=6 --data-urlencode sortBy=meta.created | jq '{totalResults, startIndex, itemsPerPage}'
jq -n '{schemas:["urn:ietf:params:scim:api:messages:2.0:SearchRequest"],filter:"userName eq \"lab-tmp-sam\"",count:5}' \
| scim -X POST "$SCIM/Users/.search" --data-binary @- | jq .totalResults
Keep raising startIndex until you have seen totalResults.
Why it matters: pages are not a snapshot, so a client keeps going until it has seen every match. /Users/.search keeps filter values out of URLs, which tend to end up in logs.
Move the user with PATCH. Enable Sam and move him to moderation in one atomic request, then replace only his work email.
jq -n --arg ent "$ENT" --arg cora "$CORA" '{schemas:["urn:ietf:params:scim:api:messages:2.0:PatchOp"],Operations:[
{op:"replace",path:"active",value:true},{op:"replace",path:($ent+":department"),value:"Photo moderation"},
{op:"replace",path:($ent+":manager"),value:{value:$cora}}]}' | scim -i -X PATCH "$SCIM/Users/$SAM" --data-binary @- | grep -i -E '^(HTTP|etag)'
jq -n '{schemas:["urn:ietf:params:scim:api:messages:2.0:PatchOp"],Operations:[{op:"replace",path:"emails[type eq \"work\"].value",value:"[email protected]"}]}' \
| scim -X PATCH "$SCIM/Users/$SAM" --data-binary @- | jq '.emails'
The first answer is 200 with a new ETag. The second changes only the work address.
Why it matters: this is "Changing a user". PATCH changes only what it names, so attributes another team added survive. The enterprise path starts with the extension's URN, and a filter in square brackets picks one email out of a list.
Atomic means all or nothing. Send two operations where the second has the misspelled path
departmnet.
jq -n '{schemas:["urn:ietf:params:scim:api:messages:2.0:PatchOp"],Operations:[{op:"replace",path:"title",value:"Moderator"},{op:"replace",path:"departmnet",value:"x"}]}' \
| scim -X PATCH "$SCIM/Users/$SAM" --data-binary @- | jq .
scim "$SCIM/Users/$SAM" | jq .title
The answer is 400 with scimType: invalidPath, and Sam's title is unchanged: the first operation was not applied either.
Group membership. Add Sam to
Photo moderation, send the same add again, then remove him with a filter.
add() { jq -n --arg u "$SAM" '{schemas:["urn:ietf:params:scim:api:messages:2.0:PatchOp"],Operations:[{op:"add",path:"members",value:[{value:$u}]}]}'; }
add | scim -X PATCH "$SCIM/Groups/$MOD" --data-binary @- -o /dev/null -w '%{http_code}\n'
add | scim -X PATCH "$SCIM/Groups/$MOD" --data-binary @- -o /dev/null -w '%{http_code}\n'
scim "$SCIM/Groups/$MOD" | jq --arg u "$SAM" '[.members[] | select(.value == $u)] | length'
Both adds succeed and Sam is listed once. Remove him with {"op":"remove","path":"members[value eq \"<SAM>\"]"}.
Why it matters: this is "Changing group membership". Adding is safe to repeat, and a filtered remove changes only the named member, never anyone the client does not know about.
Optimistic concurrency. Read Sam and keep his version, let someone else change him, then send a write based on the old version.
export E1=$(scim -i "$SCIM/Users/$SAM" | awk 'tolower($1)=="etag:" {print $2}' | tr -d '\r')
In the portal, change Sam's first name to Samuel. Now replace his title with If-Match: $E1:
jq -n '{schemas:["urn:ietf:params:scim:api:messages:2.0:PatchOp"],Operations:[{op:"replace",path:"title",value:"Moderator"}]}' \
| scim -X PATCH "$SCIM/Users/$SAM" -H "If-Match: $E1" --data-binary @- | jq .
The answer is 412 with "The resource changed since the version in If-Match. Read it again before retrying." and no scimType. Read Sam again, take the new ETag, and retry: 200.
Why it matters: this is "Conflicts and errors". The right response to 412 is to read again and reapply the intended change, never to drop the header.
The error catalogue. Produce each error and note its status and
scimType:invalidFilter: a filter with an unclosed quoteinvalidPath: a PATCH path with a misspelled attributenoTarget:{"op":"replace","path":"emails[type eq \"home\"].value","value":"[email protected]"}on Sam, who has no home emailinvalidValue:{"op":"replace","path":"active","value":"no"}mutability:{"op":"replace","path":"id","value":"x"}401: any request with no token403: a create with alab-scim-readertoken
Deactivate, then delete. Replace Sam's
activewithfalse(200, and Audit recordsscim.user.lock). Then delete him and read him back.
scim -X DELETE "$SCIM/Users/$SAM" -o /dev/null -w '%{http_code}\n'
scim "$SCIM/Users/$SAM" | jq .
The delete answers 204, and the read answers 404 with "User not found."
Why it matters: this is "Deactivating and deleting". A leaver process sends both, in that order: active false at departure, the delete after the retention period.
Break it
See what
If-Matchprevents. Createlab-tmp-kim(Kim Sato) the same way as step 1 with a newexternalId, read it with-i, and save the body withoutmetaaskim.json. In the portal, change Kim's first name. Now send a PUT built from the stale copy with noIf-Match:scim -X PUT "$SCIM/Users/<kim id>" --data-binary @kim.json. It succeeds, and the portal's change is gone without anyone deciding to discard it.
Restore: delete lab-tmp-kim and kim.json.
Check your work
Press Check my progress. The checks look for, in order:
scim.user.createsucceeded bylab-provisioning(step 1)scim.user.createrejected withuniqueness(step 2)scim.user.patchrejected withinvalid_path(step 6)scim.group.patchsucceeded (step 7)scim.user.patchrejected withversion_mismatch(step 8)scim.user.deletesucceeded (step 10)
In Audit, also find scim.user.unlock and scim.user.lock for Sam, and the rejected entries from step 9.
Cleanup
Confirm
lab-tmp-samandlab-tmp-kimare deleted.