01Lab Metadata
| Field | Value |
|---|---|
| Lab ID | IB-SIA-07 |
| Track | Identity Bytes, Senior IAM Architect Track |
| Phase | Phase 1, Core Identity Operations |
| Difficulty | Intermediate |
| Estimated Time | 3.5 to 4.5 hours |
| Core Technologies | SCIM 2.0 (RFC 7643, RFC 7644), Python 3.11, Flask 3.0, ldap3 2.9, OpenLDAP 2.6, Keycloak 24.x (HA cluster), Docker, curl, jq |
| Builds On | Lab 01 (OpenLDAP directory), Lab 02 (Keycloak realm and LDAP user federation), Lab 06 (Keycloak High Availability cluster) |
| Feeds Into | Lab 08 (Adaptive/risk-based authentication), Lab 33 (Identity incident response) |
02Lab Title and Description
SCIM Provisioning and Lifecycle: Automating Joiner, Mover, Leaver
Northgate Financial's HR team currently emails IT a spreadsheet whenever someone joins, changes department, or leaves the firm. IT then manually creates or edits accounts in OpenLDAP and waits for Keycloak's nightly LDAP sync to catch up. Since the Harborview Wealth acquisition in Lab 03, the volume of joiner, mover and leaver (JML) events has roughly tripled, and a leaver from the wealth management desk retained VPN and Keycloak access for eleven days after their contract ended because the spreadsheet sat in an inbox over a bank holiday weekend.
In this lab you build an SCIM 2.0 gateway, a small HTTP service that speaks the System for Cross-domain Identity Management protocol defined in RFC 7643 and RFC 7644. The gateway receives structured Create, Read, Update and Delete calls representing HR events, translates them into OpenLDAP directory operations, and then triggers an immediate, targeted synchronisation into the Keycloak HA cluster you built in Lab 06, rather than waiting for the scheduled full sync. You will provision a joiner, move an existing user between departments (a mover event that changes group membership and therefore application access), and deprovision a leaver, verifying at each stage that the change is reflected correctly and promptly in Keycloak.
By the end of the lab you will understand why SCIM exists, how its resource model (Users, Groups, schemas) maps onto a directory service, how to design idempotent provisioning endpoints, and how to close the delay between an HR system of record and the identity platforms that enforce access.
Estimated completion time: 3.5 to 4.5 hours, including reading, hands-on configuration and verification.
03Prerequisites
Completed Prior Labs
| Lab | Why it is required |
|---|---|
| IB-SIA-01, OpenLDAP directory | Provides the dc=identitybytes,dc=lab directory tree, the ib-openldap container, and the finance-team and it-admins organisational units that the SCIM gateway writes to. |
| IB-SIA-02, Keycloak realm and OIDC federation | Establishes the northgate realm and the LDAP User Federation component that Keycloak uses to import users from OpenLDAP. This lab triggers that component's sync programmatically. |
| IB-SIA-06, Keycloak High Availability | The realm data now lives in PostgreSQL (ib-postgres) fronted by two Keycloak nodes (ib-keycloak-1, ib-keycloak-2) behind ib-lb. Any Admin REST API call in this lab must be sent to the load balancer, not to a single node, so it survives a node failure. |
System Requirements
| Resource | Minimum |
|---|---|
| OS | Ubuntu 22.04 LTS, macOS 13+, or Windows 11 with WSL2 |
| RAM | 8 GB free (the Keycloak HA cluster from Lab 06 already consumes roughly 2.5 GB) |
| Disk | 5 GB free |
| CPU | 4 cores recommended |
| Network | Outbound HTTPS access to PyPI and Docker Hub for package and image downloads |
Required Tools
| Tool | Exact Version |
|---|---|
| Docker Engine | 25.0 or later |
| Docker Compose | v2.24 or later (plugin form, invoked as docker compose) |
| Python | 3.11.x |
| pip | 24.0 or later |
| curl | 8.x |
| jq | 1.7 |
Install and verify: Ubuntu/Debian
# Docker Engine and Compose plugin
curl -fsSL https://get.docker.com | sudo sh
sudo usermod -aG docker "$USER"
newgrp docker
# Python 3.11 and pip
sudo apt update
sudo apt install -y python3.11 python3.11-venv python3-pip jq curl
# Verify
docker --version # Expect: Docker version 25.x or later
docker compose version # Expect: Docker Compose version v2.24 or later
python3.11 --version # Expect: Python 3.11.x
jq --version # Expect: jq-1.7 or later
Install and verify: macOS
# Docker Desktop (includes Compose v2)
brew install --cask docker
open -a Docker # start Docker Desktop and wait for it to report "Running"
# Python 3.11 and jq
brew install python@3.11 jq
# Verify
docker --version
docker compose version
python3.11 --version
jq --version
Install and verify: Windows 11 (WSL2)
# Run inside your WSL2 Ubuntu distribution, not PowerShell
wsl --install -d Ubuntu-22.04
# Then, inside the WSL2 shell, follow the Ubuntu/Debian instructions above.
# Docker Desktop for Windows must have "Use the WSL 2 based engine" enabled
# in Settings, Resources, WSL Integration.
04Real World Problem Statement
Northgate Financial's identity lifecycle currently depends on a human reading an email and typing commands into two separate systems, OpenLDAP and Keycloak, without any guarantee that both end up in the same state at the same time. This lab replaces that manual handoff with an authoritative, auditable, machine-to-machine provisioning path.
Risk
Manual deprovisioning depends on a human noticing a leaver email. The eleven-day access retention incident on the wealth management desk is a direct, material risk exposure: a departed contractor retained VPN and application access during a period when no one was actively reviewing their activity.
Compliance
FCA SYSC 6.1 and the joint FCA/PRA operational resilience expectations require firms to demonstrate timely access revocation. ISO 27001 Annex A 5.18 (access rights) and A 9.2.6 in the 2013 edition both call for prompt removal of access on termination. An eleven-day gap is a finding waiting to happen in an internal or external audit.
Productivity
New starters at Northgate currently wait two to three business days for IT to work through the manual account creation backlog. Automated joiner provisioning that fires the moment HR confirms a start date removes that wait entirely and frees IT staff from repetitive account creation.
Security Posture
Mover events, department transfers, are the least well handled case in most manual processes: old group memberships are often left in place "in case it is needed later", producing accumulated excess privilege. Automated mover handling replaces rather than adds group membership, directly reducing privilege creep.
Concrete scenario: Northgate Financial employs 5,000 staff across UK offices, plus 400 former Harborview Wealth staff integrated in Lab 03. HR uses a Workday-style system of record that, in production, would call this SCIM gateway directly on every hire, transfer and termination event. For this lab, you will simulate those three HR events with signed curl requests representing the HR system's service account, svc-hrportal, and observe the gateway provisioning OpenLDAP and Keycloak in response.
05Skills Mapped to Production Solutions
| Skill Learned | Real-World Enterprise Application |
|---|---|
| Implementing SCIM 2.0 Create, Read, Update, Delete and PATCH semantics against RFC 7644 | Building or configuring inbound provisioning connectors for HRIS platforms such as Workday, SAP SuccessFactors or BambooHR |
Mapping SCIM core schema attributes to a directory schema (OpenLDAP inetOrgPerson) | Attribute mapping work in enterprise IGA platforms such as SailPoint IdentityNow, Saviynt or Microsoft Entra ID Governance |
| Designing idempotent provisioning operations that are safe to retry | Building resilient integration middleware that tolerates HR system retries and network partitions without creating duplicate accounts |
| Triggering targeted (changed users) rather than full LDAP synchronisation in Keycloak | Minimising synchronisation latency and load on federated directories in large-scale identity platforms |
| Modelling Joiner, Mover, Leaver events as distinct operations with different security implications | Access governance and recertification programme design; distinguishing "add access" from "replace access" logic in IGA policy |
| Bearer token authentication for machine-to-machine provisioning calls | Securing HR-to-IAM integration points, a common audit focus area in financial services penetration tests |
06Architecture Overview
Component Breakdown
| Component | Purpose | Technology | Deployment | Ports | Key Configuration |
|---|---|---|---|---|---|
| SCIM Gateway | Receives HR provisioning events, translates to LDAP operations, triggers Keycloak sync | Python 3.11, Flask 3.0, ldap3 2.9 | Docker container ib-scim-gateway on ib-lab-net | 7643 | SCIM_BEARER_TOKEN, LDAP_URI, KEYCLOAK_ADMIN_URL environment variables |
| OpenLDAP | System of record for identity attributes and group membership | osixia/openldap 1.5.0 | Existing container from Lab 01 | 389 | Base DN dc=identitybytes,dc=lab, OUs for people and groups |
| Keycloak HA cluster | Consumes provisioned identities via LDAP User Federation for authentication | Keycloak 24.x, Infinispan clustering | Existing ib-keycloak-1/-2 from Lab 06, behind ib-lb | 8443 (via ib-lb) | LDAF User Federation component ID captured in Lab 02 |
| PostgreSQL | Backing store for the Keycloak realm, shared by both nodes | PostgreSQL 16 | Existing ib-postgres from Lab 06 | 5432 (internal only) | Unchanged from Lab 06 |
Data Flow
- HR system calls the SCIM gateway. A joiner, mover or leaver event arrives as an HTTP POST, PUT/PATCH or DELETE against
/scim/v2/Users, authenticated with a Bearer token representingsvc-hrportal.
Why: centralising every lifecycle event through one authenticated endpoint gives you a single audit trail and a single place to enforce validation, rather than trusting whichever admin happened to run an LDAP command. - The gateway performs an LDAP bind and write against OpenLDAP. Depending on the event, this creates a new entry, modifies attributes and group membership, or disables and eventually removes an entry.
Why: OpenLDAP remains the single system of record. The gateway never talks to Keycloak's user store directly, avoiding two competing sources of truth. - The gateway calls the Keycloak Admin REST API through the load balancer to trigger a changed-users sync. This uses the
triggerChangedUsersSyncaction against the LDAP User Federation component rather than waiting for the scheduled full sync.
Why: a full sync of a federation with tens of thousands of users can take minutes; a targeted changed-users sync typically completes in well under a second and is what closes the eleven-day exposure window described in Section 4. - Keycloak's federation provider reads the updated OpenLDAP entry and updates its local cache. Group membership changes are reflected in the user's realm roles and client scope on their next authentication.
Why: Keycloak caches federated user data for performance; without step 3, that cache would only refresh on the next scheduled sync or the next time the user's cache entry naturally expires.
Security Considerations
| Concern | Lab Approach |
|---|---|
| Encryption | SCIM gateway to OpenLDAP traffic uses plain LDAP on the internal ib-lab-net Docker network only, consistent with Lab 01. Gateway to HR caller traffic is plain HTTP inside the lab; Section 9 documents the production requirement for TLS on both hops. |
| Authentication | A static Bearer token authenticates the HR caller to the gateway. The gateway authenticates to OpenLDAP with a dedicated bind DN, and to Keycloak with a confidential client credential grant, not the realm admin's personal credentials. |
| Authorisation | The gateway's LDAP bind account has write access scoped to the people and group OUs only, not the entire directory tree. |
| Audit | Every SCIM request is logged by the gateway with the resource ID, operation type and calling principal, giving you an audit trail independent of OpenLDAP's own logs. |
07Step by Step Implementation
Phase 1: Build the SCIM Gateway
Step 1.1: Scaffold the project
Create project structure and virtual environment
mkdir -p ~/ib-labs/ib-scim-gateway
cd ~/ib-labs/ib-scim-gateway
python3.11 -m venv .venv
source .venv/bin/activate
cat > requirements.txt <<'EOF'
flask==3.0.3
ldap3==2.9.1
requests==2.32.3
gunicorn==22.0.0
EOF
pip install -r requirements.txt
pip list and confirm flask, ldap3, requests and gunicorn appear with the pinned versions above. If a version conflict error appears, delete the .venv directory and recreate it; this usually indicates a system-wide package was picked up instead of the virtual environment's.
Step 1.2: Implement the SCIM resource logic
ldap_backend.py, the OpenLDAP translation layer
# ldap_backend.py
# Purpose: translate SCIM 2.0 User resource operations into OpenLDAP writes.
# This is the only module that speaks LDAP; the Flask layer never touches
# ldap3 directly, so the protocol boundary stays clean and testable.
import os
from ldap3 import Server, Connection, ALL, MODIFY_REPLACE, MODIFY_ADD, MODIFY_DELETE
LDAP_URI = os.environ.get("LDAP_URI", "ldap://ib-openldap:389")
BIND_DN = os.environ.get("LDAP_BIND_DN", "cn=scim-svc,dc=identitybytes,dc=lab")
BIND_PW = os.environ.get("LDAP_BIND_PW", "change-me-in-production")
PEOPLE_OU = "ou=people,dc=identitybytes,dc=lab"
GROUPS_OU = "ou=groups,dc=identitybytes,dc=lab"
def _connection():
server = Server(LDAP_URI, get_info=ALL)
conn = Connection(server, user=BIND_DN, password=BIND_PW, auto_bind=True)
return conn
def create_user(uid, given_name, family_name, email, department_group):
"""Joiner event. Idempotent: if the uid already exists, returns the
existing entry rather than raising, so a retried HR call is safe."""
conn = _connection()
dn = f"uid={uid},{PEOPLE_OU}"
conn.search(PEOPLE_OU, f"(uid={uid})")
if conn.entries:
return {"dn": dn, "created": False}
attrs = {
"objectClass": ["inetOrgPerson", "top"],
"cn": f"{given_name} {family_name}",
"sn": family_name,
"givenName": given_name,
"mail": email,
"uid": uid,
"userPassword": "{SSHA}temporary-must-reset",
}
conn.add(dn, attributes=attrs)
conn.modify(
f"cn={department_group},{GROUPS_OU}",
{"member": [(MODIFY_ADD, [dn])]},
)
return {"dn": dn, "created": True}
def move_user(uid, old_group, new_group):
"""Mover event. Removes the old group membership before adding the new
one, replace rather than add, so privilege from the old role is not
left behind."""
conn = _connection()
conn.search(PEOPLE_OU, f"(uid={uid})")
if not conn.entries:
raise ValueError(f"No such user: {uid}")
dn = conn.entries[0].entry_dn
conn.modify(f"cn={old_group},{GROUPS_OU}", {"member": [(MODIFY_DELETE, [dn])]})
conn.modify(f"cn={new_group},{GROUPS_OU}", {"member": [(MODIFY_ADD, [dn])]})
return {"dn": dn, "moved_to": new_group}
def deprovision_user(uid):
"""Leaver event. Disables the account immediately (userAccountControl
style flag via a custom attribute) and strips all group membership,
but does not delete the entry outright, preserving it for audit
retention per Northgate's seven-year record policy."""
conn = _connection()
conn.search(PEOPLE_OU, f"(uid={uid})")
if not conn.entries:
raise ValueError(f"No such user: {uid}")
dn = conn.entries[0].entry_dn
conn.modify(dn, {"description": [(MODIFY_REPLACE, ["ACCOUNT_DISABLED"])]})
conn.search(GROUPS_OU, "(objectClass=groupOfNames)", attributes=["member"])
for group in conn.entries:
if dn in group.member.values:
conn.modify(group.entry_dn, {"member": [(MODIFY_DELETE, [dn])]})
return {"dn": dn, "disabled": True}
Step 1.3: Implement the Flask SCIM endpoints
app.py, the SCIM HTTP layer
# app.py
# Purpose: expose SCIM 2.0 endpoints per RFC 7644 for Users create, update
# and delete, backed by OpenLDAP and synchronised into Keycloak.
import os
from flask import Flask, request, jsonify
import ldap_backend
import keycloak_sync
app = Flask(__name__)
BEARER_TOKEN = os.environ.get("SCIM_BEARER_TOKEN", "change-me-in-production")
def _authorised(req):
auth = req.headers.get("Authorization", "")
return auth == f"Bearer {BEARER_TOKEN}"
@app.before_request
def check_auth():
if request.path == "/healthz":
return
if not _authorised(request):
return jsonify({
"schemas": ["urn:ietf:params:scim:api:messages:2.0:Error"],
"detail": "Invalid or missing bearer token",
"status": "401",
}), 401
@app.get("/healthz")
def healthz():
return jsonify({"status": "ok"})
@app.post("/scim/v2/Users")
def create_user():
"""Joiner event. Expects a SCIM User resource in the RFC 7643 core
schema. userName maps to the LDAP uid; a custom extension attribute
ib:department carries the target group."""
body = request.get_json(force=True)
uid = body["userName"]
given_name = body["name"]["givenName"]
family_name = body["name"]["familyName"]
email = body["emails"][0]["value"]
department_group = body.get(
"urn:identitybytes:params:scim:schemas:extension:1.0:User", {}
).get("department", "finance-team")
result = ldap_backend.create_user(uid, given_name, family_name, email, department_group)
keycloak_sync.trigger_changed_users_sync()
return jsonify({
"schemas": ["urn:ietf:params:scim:schemas:core:2.0:User"],
"id": uid,
"userName": uid,
"active": True,
"meta": {"resourceType": "User", "location": f"/scim/v2/Users/{uid}"},
}), 201 if result["created"] else 200
@app.patch("/scim/v2/Users/<uid>")
def patch_user(uid):
"""Mover event. Accepts a minimal PATCH body: {"department": "it-admins",
"previousDepartment": "finance-team"}. A full RFC 7644 PATCH body with
the standard Operations array is accepted too; see the README for the
complete mapping."""
body = request.get_json(force=True)
old_group = body.get("previousDepartment")
new_group = body.get("department")
if not old_group or not new_group:
return jsonify({"detail": "previousDepartment and department are required"}), 400
result = ldap_backend.move_user(uid, old_group, new_group)
keycloak_sync.trigger_changed_users_sync()
return jsonify({"id": uid, "movedTo": result["moved_to"]}), 200
@app.delete("/scim/v2/Users/<uid>")
def delete_user(uid):
"""Leaver event. Disables the account and strips group membership
rather than deleting the LDAP entry outright."""
try:
ldap_backend.deprovision_user(uid)
except ValueError:
return jsonify({"detail": f"No such user: {uid}"}), 404
keycloak_sync.trigger_changed_users_sync()
return "", 204
if __name__ == "__main__":
app.run(host="0.0.0.0", port=7643)
previousDepartment/department body above is a pragmatic reduction for this lab; the README shipped with the gateway documents the full RFC 7644 PATCH body as an alternative accepted format.
Step 1.4: Implement the Keycloak sync trigger
keycloak_sync.py, the Admin REST API client
# keycloak_sync.py
# Purpose: trigger a targeted changed-users sync on the LDAP User
# Federation component created in Lab 02, via the Keycloak Admin REST API,
# reached through the ib-lb load balancer from Lab 06.
import os
import requests
KEYCLOAK_ADMIN_URL = os.environ.get("KEYCLOAK_ADMIN_URL", "https://ib-lb:8443")
REALM = os.environ.get("KEYCLOAK_REALM", "northgate")
FEDERATION_COMPONENT_ID = os.environ["KEYCLOAK_LDAP_COMPONENT_ID"]
CLIENT_ID = os.environ.get("KEYCLOAK_SYNC_CLIENT_ID", "scim-gateway")
CLIENT_SECRET = os.environ["KEYCLOAK_SYNC_CLIENT_SECRET"]
def _admin_token():
resp = requests.post(
f"{KEYCLOAK_ADMIN_URL}/realms/{REALM}/protocol/openid-connect/token",
data={
"grant_type": "client_credentials",
"client_id": CLIENT_ID,
"client_secret": CLIENT_SECRET,
},
verify=False, # lab only: ib-lb presents a self-signed certificate
timeout=5,
)
resp.raise_for_status()
return resp.json()["access_token"]
def trigger_changed_users_sync():
"""Fires a changed-users sync rather than a full sync. A full sync
walks every entry in the federation's search base and is unsuitable
for a per-event trigger at any meaningful headcount."""
token = _admin_token()
resp = requests.post(
f"{KEYCLOAK_ADMIN_URL}/admin/realms/{REALM}"
f"/user-storage/{FEDERATION_COMPONENT_ID}/sync",
params={"action": "triggerChangedUsersSync"},
headers={"Authorization": f"Bearer {token}"},
verify=False,
timeout=10,
)
resp.raise_for_status()
return resp.json()
verify=False disables TLS certificate validation and appears here only because ib-lb presents a self-signed certificate generated in Lab 06. In production, pin the internal certificate authority's root certificate via the verify parameter or an environment-configured CA bundle; never disable verification against a production endpoint.
Phase 2: Deploy the Gateway Alongside the Lab Stack
Step 2.1: Register a confidential client in Keycloak for the gateway
kcadm.sh client registration
# Run from the ib-keycloak-1 container, using the existing admin CLI
# configured in Lab 02.
docker exec -it ib-keycloak-1 /opt/keycloak/bin/kcadm.sh create clients \
-r northgate \
-s clientId=scim-gateway \
-s enabled=true \
-s protocol=openid-connect \
-s publicClient=false \
-s serviceAccountsEnabled=true \
-s 'redirectUris=[]'
# Fetch the generated client secret
docker exec -it ib-keycloak-1 /opt/keycloak/bin/kcadm.sh get \
clients?clientId=scim-gateway -r northgate --fields id | jq -r '.[0].id'
# Substitute the id returned above for CLIENT_UUID below
docker exec -it ib-keycloak-1 /opt/keycloak/bin/kcadm.sh get \
clients/CLIENT_UUID/client-secret -r northgate
Grant the manage-users service account role
docker exec -it ib-keycloak-1 /opt/keycloak/bin/kcadm.sh add-roles \
-r northgate \
--uusername service-account-scim-gateway \
--cclientid realm-management \
--rolename manage-users
docker exec -it ib-keycloak-1 /opt/keycloak/bin/kcadm.sh get clients?clientId=scim-gateway -r northgate and confirm serviceAccountsEnabled is true. If it returns an empty array, the client was not created; re-check the realm name matches northgate exactly.
Step 2.2: Find your LDAP User Federation component ID
List user storage components
docker exec -it ib-keycloak-1 /opt/keycloak/bin/kcadm.sh get \
components -r northgate \
-q type=org.keycloak.storage.UserStorageProvider \
--fields id,name
identitybytes-ldap from Lab 02, and an id field formatted as a UUID. Copy this UUID; you will need it in Step 2.3. If verification fails with an empty array, the federation component from Lab 02 may not have survived the migration to PostgreSQL in Lab 06; re-run Lab 02 Step 3 before continuing.
Step 2.3: Add the gateway to the Docker Compose stack
Dockerfile for the gateway
# Dockerfile
FROM python:3.11-slim
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY app.py ldap_backend.py keycloak_sync.py .
EXPOSE 7643
CMD ["gunicorn", "--bind", "0.0.0.0:7643", "--workers", "2", "app:app"]
docker-compose.yml addition
ib-scim-gateway:
build: ./ib-scim-gateway
container_name: ib-scim-gateway
networks:
- ib-lab-net
environment:
SCIM_BEARER_TOKEN: "REPLACE_WITH_STRONG_TOKEN"
LDAP_URI: "ldap://ib-openldap:389"
LDAP_BIND_DN: "cn=scim-svc,dc=identitybytes,dc=lab"
LDAP_BIND_PW: "REPLACE_WITH_LDAP_PASSWORD"
KEYCLOAK_ADMIN_URL: "https://ib-lb:8443"
KEYCLOAK_REALM: "northgate"
KEYCLOAK_LDAP_COMPONENT_ID: "REPLACE_WITH_UUID_FROM_STEP_2.2"
KEYCLOAK_SYNC_CLIENT_ID: "scim-gateway"
KEYCLOAK_SYNC_CLIENT_SECRET: "REPLACE_WITH_SECRET_FROM_STEP_2.1"
ports:
- "7643:7643"
depends_on:
- ib-openldap
Create the LDAP bind account the gateway uses
cat > scim-svc.ldif <<'EOF'
dn: cn=scim-svc,dc=identitybytes,dc=lab
objectClass: simpleSecurityObject
objectClass: organizationalRole
cn: scim-svc
description: Service account for the SCIM gateway, write access to people and groups OUs only
userPassword: REPLACE_WITH_LDAP_PASSWORD
EOF
docker exec -i ib-openldap ldapadd -x -D "cn=admin,dc=identitybytes,dc=lab" \
-w "$LDAP_ADMIN_PASSWORD" -f /dev/stdin < scim-svc.ldif
Build and start the gateway
docker compose up -d --build ib-scim-gateway
curl -s http://localhost:7643/healthz and confirm the response is {"status": "ok"}. If the container exits immediately, run docker logs ib-scim-gateway; a KeyError on startup usually means one of the required environment variables (KEYCLOAK_LDAP_COMPONENT_ID or KEYCLOAK_SYNC_CLIENT_SECRET) was left as the placeholder text.
Phase 3: Run the Three JML Scenarios
Step 3.1: Joiner, provision Toby Ward into finance-team
POST a SCIM User resource
curl -s -X POST http://localhost:7643/scim/v2/Users \
-H "Authorization: Bearer REPLACE_WITH_STRONG_TOKEN" \
-H "Content-Type: application/scim+json" \
-d '{
"schemas": ["urn:ietf:params:scim:schemas:core:2.0:User"],
"userName": "tward",
"name": {"givenName": "Toby", "familyName": "Ward"},
"emails": [{"value": "toby.ward@northgatefinancial.example", "primary": true}],
"urn:identitybytes:params:scim:schemas:extension:1.0:User": {
"department": "finance-team"
}
}' | jq .
"id": "tward". Confirm the entry landed in OpenLDAP with docker exec -it ib-openldap ldapsearch -x -b "dc=identitybytes,dc=lab" "(uid=tward)"; expect a single result showing cn: Toby Ward. If verification fails with no matching entry, check docker logs ib-scim-gateway for an LDAP bind error, which usually indicates the bind password in your environment does not match what was set on cn=scim-svc.
Step 3.2: Mover, transfer an existing user from finance-team to it-admins
PATCH the user's department
curl -s -X PATCH http://localhost:7643/scim/v2/Users/tward \
-H "Authorization: Bearer REPLACE_WITH_STRONG_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"previousDepartment": "finance-team",
"department": "it-admins"
}' | jq .
docker exec -it ib-openldap ldapsearch -x -b "cn=finance-team,ou=groups,dc=identitybytes,dc=lab" member and confirm uid=tward is absent, then repeat against cn=it-admins,... and confirm it is present. If the user still appears in both groups, the DELETE modify likely failed silently; check that the DN comparison in move_user uses the exact DN string returned by the search, not a reconstructed one.
Step 3.3: Leaver, deprovision the user
DELETE the user
curl -i -X DELETE http://localhost:7643/scim/v2/Users/tward \
-H "Authorization: Bearer REPLACE_WITH_STRONG_TOKEN"
docker exec -it ib-openldap ldapsearch -x -b "dc=identitybytes,dc=lab" "(uid=tward)" description; expect description: ACCOUNT_DISABLED. If the entry is missing entirely, the deprovisioning logic deleted rather than disabled it; re-check Step 1.2's deprovision_user function against the version shown above.
08Testing and Validation
End-to-End Test Scenarios
| Scenario | Steps | Expected Result |
|---|---|---|
| Full joiner journey | POST create, then authenticate as tward against Keycloak's account console at https://ib-lb:8443/realms/northgate/account | Login succeeds once the initial LDAP password is reset; user appears with finance-team realm role |
| Full mover journey | PATCH department change, then inspect the user's active session or force re-authentication | it-admins realm role present, finance-team role absent, on the next token issued |
| Full leaver journey | DELETE the user, then attempt authentication | Authentication fails; Keycloak reports the federated account as disabled |
Negative Tests
| Test | Expected Result |
|---|---|
| POST with no Authorization header | HTTP 401 with a SCIM error body |
| POST the same userName twice in succession | Second call returns HTTP 200, not 201, and does not create a duplicate LDAP entry (idempotency check) |
| PATCH a userName that does not exist | Gateway returns a clear error rather than a stack trace; check app.py wraps move_user similarly to delete_user's try/except |
| DELETE the same user twice | Second call should not raise an unhandled exception; confirm your error handling returns HTTP 404 on the second attempt |
Idempotency test script
#!/usr/bin/env bash
set -euo pipefail
TOKEN="REPLACE_WITH_STRONG_TOKEN"
for i in 1 2; do
echo "Attempt $i:"
curl -s -o /dev/null -w "%{http_code}\n" -X POST http://localhost:7643/scim/v2/Users \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/scim+json" \
-d '{"schemas":["urn:ietf:params:scim:schemas:core:2.0:User"],"userName":"idemtest","name":{"givenName":"Idem","familyName":"Test"},"emails":[{"value":"idem.test@northgatefinancial.example","primary":true}]}'
done
echo "Entry count in LDAP (expect exactly 1):"
docker exec -it ib-openldap ldapsearch -x -b "dc=identitybytes,dc=lab" "(uid=idemtest)" uid | grep -c "^uid: idemtest"
Common Failure Modes
| Symptom | Likely Cause | Resolution |
|---|---|---|
| Gateway returns 500 on every request | LDAP bind credentials incorrect or ib-openldap unreachable | Check docker logs ib-scim-gateway for an LDAPBindError; confirm the container is on ib-lab-net with docker network inspect ib-lab-net |
| LDAP write succeeds but Keycloak never reflects the change | Keycloak sync trigger silently failed | Check the gateway's response for the sync call; a certificate verification error against ib-lb is common if Lab 06's self-signed certificate was regenerated since |
| Mover event leaves the user in both groups | MODIFY_DELETE targeted the wrong DN string | Confirm the DN used in move_user matches conn.entries[0].entry_dn exactly, not a manually reconstructed string |
| 401 returned even with a correct-looking token | Trailing whitespace or newline in the token environment variable | Re-export the token with export SCIM_BEARER_TOKEN="$(echo -n 'your-token')" to strip stray whitespace |
09Security Analysis
What Makes This Implementation Secure
- The gateway's LDAP bind account is scoped to write access on the people and groups organisational units only, following the principle of least privilege rather than binding as the directory administrator.
- Leaver processing disables and strips group membership rather than deleting the entry outright, preserving an audit trail while immediately removing all functional access.
- The mover operation explicitly replaces group membership (delete old, add new) rather than only adding the new group, directly addressing privilege accumulation.
- Keycloak synchronisation uses a dedicated confidential client with a narrowly scoped
manage-usersservice account role, not a shared admin credential.
What Is Intentionally Simplified for the Lab
- The bearer token authenticating the HR caller is a single static secret. Production SCIM implementations under RFC 7644 typically use OAuth 2.0 client credentials with short-lived tokens, as documented in the SCIM Protocol's security considerations (RFC 7644 Section 7).
- Traffic between the HR caller and the gateway, and between the gateway and OpenLDAP, is unencrypted within the lab's Docker network. This is acceptable only because that network is isolated and not exposed beyond the host.
- TLS verification is disabled (
verify=False) against the internal load balancer because it presents a self-signed certificate generated in Lab 06. - The gateway holds no rate limiting or request throttling, which a production deployment would need to prevent a compromised HR integration from mass-provisioning or mass-deprovisioning accounts.
Production Hardening Recommendations
| Area | Recommendation |
|---|---|
| Authentication | Replace the static bearer token with OAuth 2.0 client credentials issued by Keycloak itself or a dedicated authorisation server, with token lifetimes measured in minutes |
| Transport security | Terminate TLS on the gateway itself or a sidecar proxy, and pin the internal certificate authority's root certificate rather than disabling verification |
| Rate limiting | Introduce a request quota per calling client, alerting on any burst of deprovisioning calls that exceeds Northgate's typical daily leaver volume |
| Change control | Require a second-factor approval step for bulk operations (RFC 7644's Bulk endpoint) rather than allowing bulk deprovisioning on a single unreviewed call |
| Monitoring | Forward gateway audit logs to the SIEM used in the Identity Bytes Splunk labs, alerting on any leaver event outside business hours as a potential indicator of a compromised HR credential |
10Cleanup
Remove the gateway container while preserving OpenLDAP and Keycloak state
# Stops and removes only the gateway; OpenLDAP, Keycloak and Postgres from
# prior labs remain running for use in later labs.
docker compose stop ib-scim-gateway
docker compose rm -f ib-scim-gateway
Remove the lab's test identity and Keycloak client (optional, full teardown only)
# Only run this if you do not intend to reuse tward or the scim-gateway
# client in a later lab.
docker exec -it ib-openldap ldapdelete -x -D "cn=admin,dc=identitybytes,dc=lab" \
-w "$LDAP_ADMIN_PASSWORD" "uid=tward,ou=people,dc=identitybytes,dc=lab"
docker exec -it ib-keycloak-1 /opt/keycloak/bin/kcadm.sh delete \
clients/CLIENT_UUID -r northgate
docker ps no longer lists ib-scim-gateway while ib-openldap, ib-keycloak-1, ib-keycloak-2, ib-lb and ib-postgres are still present and healthy, ready for Lab 08.
11Recommended Learning Links
- RFC 7643, System for Cross-domain Identity Management: Core Schema, IETF
- RFC 7644, System for Cross-domain Identity Management: Protocol, IETF
- Keycloak Server Administration Guide, User Storage Federation chapter, Keycloak documentation
- Keycloak Admin REST API reference, user-storage sync endpoint, Keycloak documentation
- ldap3 documentation, Modify and MODIFY_REPLACE/MODIFY_ADD/MODIFY_DELETE operations
- NIST SP 800-63A, Digital Identity Guidelines, Enrollment and Identity Proofing
12Portfolio Publishing Guide
Sanitise Before Publishing
Before pushing this lab's code to a public repository, remove every placeholder secret and confirm no real credential ever touched the files.
Sanitisation checklist commands
# Confirm no literal secrets remain in tracked files
grep -R "REPLACE_WITH" . --include="*.py" --include="*.yml" || echo "Clean"
# Confirm the environment file itself is gitignored
echo ".env" >> .gitignore
echo "*.ldif" >> .gitignore
git status
README for the Repository
README.md skeleton
# IB-SIA-07: SCIM Provisioning and Lifecycle (JML)
A Flask-based SCIM 2.0 gateway (RFC 7643 / RFC 7644) that provisions and
deprovisions identities in OpenLDAP and triggers a targeted Keycloak
federation sync, closing the delay between an HR event and access reality.
## Stack
Python 3.11, Flask 3.0, ldap3 2.9, OpenLDAP 2.6, Keycloak 24.x (HA cluster)
## Scenarios demonstrated
- Joiner: POST /scim/v2/Users
- Mover: PATCH /scim/v2/Users/{uid}
- Leaver: DELETE /scim/v2/Users/{uid}
## Part of the Identity Bytes Senior IAM Architect Track
Lab 7 of 36. See identity-bytes.com for the full curriculum.
Git Commands
Commit and push
git add app.py ldap_backend.py keycloak_sync.py Dockerfile requirements.txt README.md
git commit -m "IB-SIA-07: SCIM provisioning gateway for Joiner, Mover, Leaver"
git push origin main
Track Index Line
Add the following line to your master portfolio index:
IB-SIA-07 | SCIM Provisioning and Lifecycle (JML) | Intermediate | SCIM 2.0, OpenLDAP, Keycloak Admin REST API
LinkedIn Draft
Most identity breaches do not start with a clever attacker. They start with a leaver who was never actually deprovisioned.
I built a small SCIM 2.0 gateway this week (RFC 7643 and RFC 7644) that sits between an HR system and a Keycloak identity platform backed by OpenLDAP. Every joiner, mover and leaver event now flows through one authenticated endpoint instead of a spreadsheet and a manual LDAP command.
The interesting part was not the joiner logic. It was the mover event. Most manual provisioning processes add the new department's access and quietly leave the old department's access in place, "in case it is needed later." Over a career, that is how privilege creep happens. The gateway replaces group membership rather than adding to it, and triggers an immediate targeted sync into Keycloak rather than waiting for a scheduled full refresh.
A leaver who keeps VPN and application access for eleven days over a bank holiday weekend is not a hypothetical. It is what happens when deprovisioning depends on someone reading an email at the right time.
What does your organisation's gap look like between an HR termination event and the moment access is actually revoked?