IB-SIA-07 Intermediate Est. 3.5 to 4.5 hours
Identity Bytes // Senior IAM Architect Track

SCIM Provisioning and Lifecycle: Automating Joiner, Mover, Leaver

Build a SCIM 2.0 gateway that provisions and deprovisions identities in OpenLDAP and synchronises them into your Keycloak HA cluster, closing the gap between HR events and access reality at Northgate Financial.

01Lab Metadata

FieldValue
Lab IDIB-SIA-07
TrackIdentity Bytes, Senior IAM Architect Track
PhasePhase 1, Core Identity Operations
DifficultyIntermediate
Estimated Time3.5 to 4.5 hours
Core TechnologiesSCIM 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 OnLab 01 (OpenLDAP directory), Lab 02 (Keycloak realm and LDAP user federation), Lab 06 (Keycloak High Availability cluster)
Feeds IntoLab 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

LabWhy it is required
IB-SIA-01, OpenLDAP directoryProvides 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 federationEstablishes 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 AvailabilityThe 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

ResourceMinimum
OSUbuntu 22.04 LTS, macOS 13+, or Windows 11 with WSL2
RAM8 GB free (the Keycloak HA cluster from Lab 06 already consumes roughly 2.5 GB)
Disk5 GB free
CPU4 cores recommended
NetworkOutbound HTTPS access to PyPI and Docker Hub for package and image downloads

Required Tools

ToolExact Version
Docker Engine25.0 or later
Docker Composev2.24 or later (plugin form, invoked as docker compose)
Python3.11.x
pip24.0 or later
curl8.x
jq1.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.
INFO SCIM is defined by two IETF RFCs: RFC 7643 specifies the core resource schema (User, Group, and their attributes), and RFC 7644 specifies the HTTP protocol (endpoints, filtering, PATCH semantics, bulk operations). This lab implements a practical, RFC-aligned subset rather than every optional feature, and calls out where we simplify.

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 LearnedReal-World Enterprise Application
Implementing SCIM 2.0 Create, Read, Update, Delete and PATCH semantics against RFC 7644Building 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 retryBuilding 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 KeycloakMinimising synchronisation latency and load on federated directories in large-scale identity platforms
Modelling Joiner, Mover, Leaver events as distinct operations with different security implicationsAccess governance and recertification programme design; distinguishing "add access" from "replace access" logic in IGA policy
Bearer token authentication for machine-to-machine provisioning callsSecuring HR-to-IAM integration points, a common audit focus area in financial services penetration tests

06Architecture Overview

HR SYSTEM svc-hrportal (simulated via curl) SCIM GATEWAY ib-scim-gateway Flask 3.0, port 7643 RFC 7643 / RFC 7644 OPENLDAP ib-openldap, port 389 dc=identitybytes,dc=lab System of record ib-lb HAProxy, port 8443 ib-keycloak-1 Infinispan cluster ib-keycloak-2 Infinispan cluster ib-postgres realm store Identities asmith, finance-team jpatel, it-admins lokafor, HR new joiner: tward (Joiner/Mover/Leaver) 1 SCIM POST/PUT/DELETE 2 LDAP bind and write 3 trigger sync (Admin REST) 4 LDAP user federation read Docker network: ib-lab-net

Component Breakdown

ComponentPurposeTechnologyDeploymentPortsKey Configuration
SCIM GatewayReceives HR provisioning events, translates to LDAP operations, triggers Keycloak syncPython 3.11, Flask 3.0, ldap3 2.9Docker container ib-scim-gateway on ib-lab-net7643SCIM_BEARER_TOKEN, LDAP_URI, KEYCLOAK_ADMIN_URL environment variables
OpenLDAPSystem of record for identity attributes and group membershiposixia/openldap 1.5.0Existing container from Lab 01389Base DN dc=identitybytes,dc=lab, OUs for people and groups
Keycloak HA clusterConsumes provisioned identities via LDAP User Federation for authenticationKeycloak 24.x, Infinispan clusteringExisting ib-keycloak-1/-2 from Lab 06, behind ib-lb8443 (via ib-lb)LDAF User Federation component ID captured in Lab 02
PostgreSQLBacking store for the Keycloak realm, shared by both nodesPostgreSQL 16Existing ib-postgres from Lab 065432 (internal only)Unchanged from Lab 06

Data Flow

  1. 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 representing svc-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.
  2. 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.
  3. The gateway calls the Keycloak Admin REST API through the load balancer to trigger a changed-users sync. This uses the triggerChangedUsersSync action 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.
  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

ConcernLab Approach
EncryptionSCIM 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.
AuthenticationA 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.
AuthorisationThe gateway's LDAP bind account has write access scoped to the people and group OUs only, not the entire directory tree.
AuditEvery 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

Purpose: create the gateway's file structure and dependencies Context: independent of prior labs, runs alongside ib-openldap
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
VERIFICATION Run 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

Purpose: translate SCIM User resources into LDAP operations Context: this module is imported by the Flask app in Step 1.3
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}
SECURITY WARNING The bind password is passed via environment variable for lab simplicity. In production, source it from a secrets manager such as HashiCorp Vault or AWS Secrets Manager and inject it at container start, never bake it into an image layer or commit it to source control.

Step 1.3: Implement the Flask SCIM endpoints

Purpose: expose RFC 7644 compliant HTTP endpoints Context: calls into ldap_backend.py and, after every write, into keycloak_sync.py from Step 1.4
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)
INFO RFC 7644 Section 3.5.2 defines PATCH using an Operations array with add, remove and replace verbs against SCIM path expressions. The simplified 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

Purpose: close the gap between an LDAP write and Keycloak's cached view of it Context: called after every create, patch and delete in app.py
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()
PRODUCTION CONSIDERATION 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

Purpose: give the gateway its own machine identity, distinct from any human admin Context: used by keycloak_sync.py's client credentials grant
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
VERIFICATION Run 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

Purpose: obtain the ID required by keycloak_sync.py Context: this component was created in Lab 02
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
VERIFICATION Expected output is a JSON array with one entry, name typically 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

Purpose: run the gateway on the shared ib-lab-net network Context: extends the docker-compose.yml maintained since Lab 01
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
VERIFICATION Run 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

Purpose: simulate HR confirming a new starter Context: creates uid=tward alongside existing asmith, jpatel, lokafor
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 .
VERIFICATION Expected response is HTTP 201 with "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

Purpose: simulate an internal transfer changing application access Context: uses the PATCH endpoint from Step 1.3, replacing rather than adding group membership
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 .
VERIFICATION Run 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

Purpose: simulate an immediate termination Context: uses the DELETE endpoint, which disables and strips groups rather than deleting the entry
DELETE the user
curl -i -X DELETE http://localhost:7643/scim/v2/Users/tward \
  -H "Authorization: Bearer REPLACE_WITH_STRONG_TOKEN"
VERIFICATION Expected response is HTTP 204 with no body. Confirm the entry still exists but is disabled with 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.
What just happened? You built a small but genuinely RFC-aligned SCIM gateway that sits between an HR event and two downstream systems: the directory that stores identity, and the access broker that authenticates against it. Rather than a human reading an email and typing LDAP commands, a single authenticated HTTP call now creates, moves or removes an account and immediately tells Keycloak to notice. The mover scenario in particular demonstrates the difference between adding access, which accumulates privilege over a career, and replacing access, which reflects the person's current role.

08Testing and Validation

End-to-End Test Scenarios

ScenarioStepsExpected Result
Full joiner journeyPOST create, then authenticate as tward against Keycloak's account console at https://ib-lb:8443/realms/northgate/accountLogin succeeds once the initial LDAP password is reset; user appears with finance-team realm role
Full mover journeyPATCH department change, then inspect the user's active session or force re-authenticationit-admins realm role present, finance-team role absent, on the next token issued
Full leaver journeyDELETE the user, then attempt authenticationAuthentication fails; Keycloak reports the federated account as disabled

Negative Tests

TestExpected Result
POST with no Authorization headerHTTP 401 with a SCIM error body
POST the same userName twice in successionSecond call returns HTTP 200, not 201, and does not create a duplicate LDAP entry (idempotency check)
PATCH a userName that does not existGateway 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 twiceSecond 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

SymptomLikely CauseResolution
Gateway returns 500 on every requestLDAP bind credentials incorrect or ib-openldap unreachableCheck 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 changeKeycloak sync trigger silently failedCheck 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 groupsMODIFY_DELETE targeted the wrong DN stringConfirm 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 tokenTrailing whitespace or newline in the token environment variableRe-export the token with export SCIM_BEARER_TOKEN="$(echo -n 'your-token')" to strip stray whitespace

09Security Analysis

What Makes This Implementation Secure

What Is Intentionally Simplified for the Lab

Production Hardening Recommendations

AreaRecommendation
AuthenticationReplace 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 securityTerminate TLS on the gateway itself or a sidecar proxy, and pin the internal certificate authority's root certificate rather than disabling verification
Rate limitingIntroduce a request quota per calling client, alerting on any burst of deprovisioning calls that exceeds Northgate's typical daily leaver volume
Change controlRequire a second-factor approval step for bulk operations (RFC 7644's Bulk endpoint) rather than allowing bulk deprovisioning on a single unreviewed call
MonitoringForward 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
VERIFICATION After cleanup, confirm 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

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?

Next: IB-SIA-08, Adaptive and Risk-Based Authentication
Building on the identity lifecycle you have automated, Lab 08 adds contextual risk signals (device, location, velocity) to authentication decisions in the Keycloak HA cluster.