A SCIM 2.0 intermediary that translates Okta provisioning operations into Brivo Access API calls. Manages the full identity lifecycle (create, update, delete users and groups) with reliability guarantees under rate limits and partial failures.
| Actor | Role | ID field |
|---|---|---|
| Okta | Initiates provisioning via SCIM 2.0 | externalId |
| Bridge | This app — translates SCIM → Brivo | scim_id (UUID v4) |
| Brivo | Target access control system | target_id (integer) |
sequenceDiagram
participant Okta
participant Auth as BearerTokenMiddleware
participant Log as RequestLoggingMiddleware
participant Router
participant Service
participant Redis
participant Client as BrivoClient
participant Brivo as Brivo API
Okta->>Auth: SCIM request + Bearer token
Auth->>Auth: validate token, bind correlation_id
Auth->>Log: forward request
Log->>Router: forward request
Router->>Redis: resolve scim_id → target_id
Redis-->>Router: target_id
Router->>Service: call service fn
Service->>Redis: check idmap / lock / cache
Redis-->>Service: hit or miss
Service->>Client: Brivo API call
Client->>Client: acquire rate-limit token
Client->>Brivo: HTTP request
Brivo-->>Client: response (or 429)
Client-->>Service: result (or BrivoError)
Service-->>Router: SCIM resource
Router-->>Log: response
Log->>Log: log method/path/status
Log-->>Auth: response
Auth->>Auth: set Content-Type: application/scim+json
Auth-->>Okta: SCIM response
- Auth middleware validates the bearer token (constant-time compare), generates a
correlation_id, and binds it tostructlogcontext so every log line in that request carries it. - Logging middleware logs method, path, and response status on every request. Body is not logged (PII).
- Router parses SCIM parameters, resolves
scim_id → target_idvia Redis, calls the appropriate service function. - Service runs the business logic, often as a saga (see below).
- BrivoClient enforces rate limiting and retries 429s before surfacing errors.
| Layer | Choice |
|---|---|
| Language | Python 3.14 |
| Framework | FastAPI |
| Server | Uvicorn |
| Validation | Pydantic v2 |
| Settings | pydantic-settings |
| ID store + Cache | Redis (redis-py asyncio) |
| HTTP client | httpx (AsyncClient) |
| Rate limiting | aiolimiter (leaky bucket) |
| Retries | tenacity |
| Logging | structlog |
| Testing | pytest + pytest-asyncio + httpx |
| Test mocks | fakeredis + respx |
| Infra | Docker + Compose |
scim-bridge/
├── main.py ← FastAPI app, lifespan, middleware, exception handlers
├── app/
│ ├── routers/ ← SCIM endpoints: users, groups, discovery
│ ├── models/ ← Pydantic schemas (SCIM + Brivo)
│ ├── services/ ← Business logic, saga orchestrator, field mapper
│ ├── brivo/ ← BrivoClient, rate limiter, cached fetch helpers, DI
│ ├── redis/ ← ID mapping store + response cache
│ └── core/ ← Config, auth middleware, error types, logging
├── tests/
│ ├── unit/
│ └── integration/
└── docs/ ← Architecture, specs, per-component design docs
BearerTokenMiddleware (Starlette BaseHTTPMiddleware):
- Skips auth for discovery endpoints (
/ServiceProviderConfig,/Schemas,/ResourceTypes) — Okta probes these unauthenticated during setup. - Compares tokens with
hmac.compare_digestto prevent timing attacks. - Forces
Content-Type: application/scim+json; charset=UTF-8on every response — FastAPI defaults toapplication/json, which Okta rejects. - Generates a
correlation_idUUID and binds it tostructlogcontextvars before the request; all log calls in that request automatically include it.
Multi-step operations against Brivo run as sagas. If any step fails, completed steps are rolled back in reverse order.
flowchart TD
S([run_saga]) --> F1[step_A.forward]
F1 -->|ok| F2[step_B.forward]
F2 -->|ok| F3[step_C.forward]
F3 -->|ok| Done([saga.completed])
F3 -->|error| R3[step_C.rollback]
R3 --> R2[step_B.rollback]
R2 --> R1[step_A.rollback]
F2 -->|error| R2B[step_B.rollback]
R2B --> R1B[step_A.rollback]
R1 --> Fail([SagaError raised])
R1B --> Fail
run_saga(steps) executes forward steps in sequence. On failure:
completed = [step_A, step_B]
failed = step_C
rollbacks executed (reverse): step_C.rollback → step_B.rollback → step_A.rollback
The failed step's own rollback runs first (in case it partially mutated state), then completed steps unwind in reverse order. Rollback errors are swallowed and logged — the saga still raises SagaError to signal the operation failed.
| Operation | Steps |
|---|---|
| Create User | create at Brivo → write idmap + release lock |
| Delete User | fetch groups → remove from groups → delete user → del idmap + cache |
| Create Group | create at Brivo → write idmap + release lock → add members |
| Update Group | update name → fetch members → add new members → remove stale members |
| Delete Group | delete at Brivo → del idmap + cache |
Update User and Patch Group are simple read-modify-write operations — no saga needed.
Saga state is in-memory only. A crash loses the running saga. Recovery relies on the idempotency lock expiring (5 min TTL), after which Okta retries and a fresh saga starts.
A single AsyncLimiter(max_rate=N, time_period=1) instance is created at startup and stored on app.state. All Brivo calls share it. Before every HTTP call:
async with self._limiter:
response = await self._http.request(...)This is a leaky-bucket: at most N requests per second are allowed through. Excess requests wait in the async queue rather than being rejected.
Retries are applied only at the BrivoClient._call level, and only for BrivoRateLimitError (HTTP 429):
brivo_retry = retry(
retry=retry_if_exception_type(BrivoRateLimitError),
wait=wait_fixed(1),
stop=stop_after_attempt(4),
reraise=True,
)- 4 total attempts, 1 second between each.
- Exhausted →
BrivoRateLimitErrorpropagates to the router → 429 to Okta. - Other
BrivoErrortypes (404, 5xx) surface immediately without retrying. brivo_retryis defined at module level (not inside the class) to avoid circular import issues and enable reuse.
Decision: Retries live at the client layer, not the saga layer. A 429 is transient and worth retrying transparently. A 500 from Brivo indicates something is wrong with the request or Brivo state — the saga should roll back, not blindly retry.
Redis serves two distinct roles: permanent ID mapping store and short-lived Brivo response cache.
Every provisioned resource has three keys written atomically in a pipeline:
| Key | Value |
|---|---|
idmap:brivo:scim:{type}:{scim_id} |
{target_id, external_id, created_at} |
idmap:brivo:ext:{type}:{external_id} |
{scim_id, target_id} |
idmap:brivo:tid:{type}:{target_id} |
{scim_id, external_id} |
Three keys for O(1) lookup in any direction. The tid reverse key is used during member hydration (Brivo integer ID → scim_id for SCIM list responses).
created_at is stored here because Brivo doesn't expose creation timestamps. It's used to populate meta.created in SCIM responses.
lock:brivo:create:{type}:{external_id} is set with SET NX EX 300.
NXmakes it atomic — only the first caller wins.- Returns the
saga_idof the in-flight operation. - Guards against concurrent duplicate creates (two Okta retries racing each other).
- Separate from the idmap check: idmap guards completed creates, lock guards in-progress creates.
Cache-aside for GET /users/{id}, GET /groups/{id}, and group member lists. Populated on miss, invalidated on any write to that resource.
- Email: picks the first
primary=Trueemail; falls back toemails[0]. - Phone: same logic; field is optional.
- Active/suspended:
active: true→suspended: false(inverted). - Group name: enforces Brivo's 35-character limit; raises
ScimBadRequestif exceeded. - Group defaults:
keypadUnlock=False,immuneToAntipassback=False,antipassbackResetTime=0(Brivo requires these fields but SCIM has no equivalent).
userName=emails[0].address(Brivo has no separate username field).meta.version= SHA-256 of stable JSON serialization of the Brivo object (deterministic ETag without storing version counters).meta.created= pulled from idmap (see Redis section).- Members = resolved from
idmap:brivo:tid:user:{target_id}for each integer ID returned by Brivo.
All SCIM input models use extra="ignore". Okta sends fields that Brivo has no concept of (password, displayName, locale, groups) — these are silently dropped at deserialization rather than causing validation errors.
externalId is optional per the SCIM spec. The bridge falls back to userName (email) as the deduplication key when absent.
GET /scim/v2/ServiceProviderConfig— declares supported features: PATCH, filter, ETags. If this returns an invalid response, Okta disables PATCH and falls back to PUT-only.GET /scim/v2/ResourceTypesGET /scim/v2/Schemas
| Method | Path | Notes |
|---|---|---|
| POST | / |
Idempotent: returns existing user if externalId already mapped |
| GET | / |
Supports filter=userName eq "..." (full scan + client-side match) |
| GET | /{scim_id} |
Cache-aside fetch |
| PUT | /{scim_id} |
Full replace |
| PATCH | /{scim_id} |
Partial update; supports active, name.*, userName ops |
| DELETE | /{scim_id} |
Removes from groups first, then deletes user, then cleans idmap |
| Method | Path | Notes |
|---|---|---|
| POST | / |
Pre-resolves all member scim_id → target_id before lock (bad member = 400, no state mutation) |
| GET | / |
Supports filter=displayName eq "..." |
| GET | /{scim_id} |
Fetches group + member list + hydrates member scim_ids |
| PUT | /{scim_id} |
Diffs current vs desired members; adds/removes in saga |
| PATCH | /{scim_id} |
Handles replace, add, remove ops |
| DELETE | /{scim_id} |
Deletes group, then cleans idmap + cache |
List filtering is an in-memory full scan (paginate all from Brivo, filter client-side). Acceptable at this scale; would need server-side filtering or an index at production volume.
List responses exclude Brivo resources with no idmap entry (seed data, manually created Brivo users). These are silently filtered out — the bridge only manages what it provisioned.
Structured JSON via structlog, stdout, ISO 8601 UTC timestamps, INFO minimum.
correlation_id is bound at request entry in BearerTokenMiddleware via structlog.contextvars. All log calls within that request context automatically include it — no explicit passing required.
Key log events:
| Event | Level | Fields |
|---|---|---|
http.request |
INFO | method, path, status |
http.error |
ERROR | method, path, error |
brivo.error |
ERROR | method, path, brivo_status, error |
saga.error |
ERROR | method, path, error |
saga.start/completed/failed |
INFO/ERROR | saga_id |
step.start/done/failed |
INFO/ERROR | saga_id, step |
rollback.error |
WARNING | saga_id, step, error |
Health check (GET /health) access logs are suppressed at the Uvicorn level (--no-access-log) to reduce noise.
- Docker + Docker Compose
- A
.envfile (see.env.example)
cp .env.example .env
# set SCIM_BEARER_TOKEN=<any-secret>
docker compose up --buildServices:
| Service | Port | Description |
|---|---|---|
app |
8000 | SCIM bridge |
mock-brivo |
8001 | In-memory Brivo API simulator |
redis |
6379 | ID store + cache |
app waits for both redis and mock-brivo to pass healthchecks before starting.
Standalone FastAPI app with in-memory state. Configured via environment:
| Variable | Effect |
|---|---|
BRIVO_ERROR_RATE |
0–1 probability of returning 500/503 |
BRIVO_LATENCY_MS |
Max added latency per request (ms) |
BRIVO_RATE_LIMIT |
Requests before simulated 429 |
Seed user (id=1, firstName="Seed") is created on startup and is always present in list responses. It has no idmap entry and is filtered from SCIM list responses.
pytest tests/ # all tests
pytest tests/unit/ # unit only (fakeredis + respx, no Docker)
pytest tests/integration/ # integration (requires running services)Unit tests use fakeredis for Redis and respx to mock Brivo HTTP calls. No real network calls, no Docker required.