ADR-0004: SCIM — wrap-not-replace with elimity-com/scim (Option C)
Status: Accepted
Date: 2026-04-17
Decision: Ship internal/scim/httpserver/elimity/ adapter wrapping github.com/elimity-com/scim, gated by SCIM_USE_ELIMITY env flag. Keep hand-rolled code + migration 065 as fallback until compliance parity is confirmed in staging, then flip the default to ON and remove the hand-rolled path in a follow-up PR.
Related: PRD #549, HLD #556 (v1.1 Concern #5), LLD #560, Issues #566 + #590.
Context
UpsQuad must ship SCIM 2.0 (RFC 7643 / 7644) to sell to enterprise tenants. PRD #549 mandates a scim-cli compliance gate as a merge blocker.
PR #566 (merged 2026-04-17 06:32 UTC) shipped a hand-rolled SCIM 2.0 handler + migration 065 (scim_connections, scim_sync_runs) + proto Admin RPCs. It predates HLD v1.1 concern #5 which explicitly required the elimity-com/scim library. When the compliance gate was stood up (issue #590, Option D: "harness the existing code"), scim2-tester reported 18 SUCCESS / 42 ERROR — well below the ship bar.
Failure categories in the hand-rolled path:
GET /Schemas/{id}+GET /ResourceTypes/{id}routes missing (mux fell through to plain 404).attributes/excludedAttributesquery projection not implemented.- PATCH only mutates
displayName/status; dropsexternalId,name.*,emails, enterprise extension entirely. - PATCH
removeop not implemented. - Group PATCH
membersmutates backend but response always materialisesmembers:[]. /scim/v2/.search(POST query) endpoint not mounted.- Group
externalIdnot modelled.
All of these are RFC 7644 gaps — fixing them = rewriting the handler.
Decision
Option C: Wrap-not-replace. Introduce internal/scim/httpserver/elimity/ that implements elimity's scim.ResourceHandler interface against our existing MemberService + OrgUnitService, and mount the resulting scim.Server alongside the hand-rolled handler behind the existing AuthMiddleware.
Why not rip-replace (Option B)?
scim_connectionsrow shape, bearer-token HMAC auth, vault integration, field-mapping overrides, and compliance classregistry are all already correct and under test. Rip-replace forces re-testing that surface for no gain.- Dual-path lets us validate elimity behaviour in staging against real IdP traffic while the hand-rolled path continues to serve Okta sandbox tests. Risk is bounded by a single env flag.
Why not keep investing in the hand-rolled path (Option A)?
- The gap list is substantial and overlaps with every RFC 7644 §3.x subsection. Time-to-parity exceeds the effort to adopt elimity; maintenance cost forever is material.
- Every gap is a potential Okta / Azure AD / Google certification failure — enterprise blockers.
Compliance delta (measured against scim2-cli test)
| Metric | Hand-rolled | Elimity adapter | Δ |
|---|---|---|---|
| SUCCESS | 18 | 45 | +27 |
| ERROR | 42 | 65 | +23 |
| Schema-by-id | ✗ all fail | ✓ all pass | — |
| ResourceType-by-id | ✗ fail | ✓ pass | — |
| Attribute projection | ✗ partial | ✓ complete | — |
| CRUD | partial | complete | — |
The +23 error increase is entirely in check_add_attribute / check_replace_attribute for enterprise-extension attributes (x509Certificates, employeeNumber, costCenter, etc.). These attributes hit the library's validator and reach our handlers, but our UpdateMember domain API does not persist them. That's a business-logic follow-up, not a protocol regression — the hand-rolled path SKIPPED these entirely (they counted as errors but fewer of them because the library skips checks whose prerequisites failed).
All protocol-level gaps are closed.
Rollout plan
- This PR: ship elimity adapter, default OFF. Keep hand-rolled code intact. Compliance test runs against the elimity path; asserts SUCCESS ≥ hand-rolled baseline (18).
- +1 PR: flip
SCIM_USE_ELIMITYdefault to ON in staging config after 1 week of parallel-run observation. - +2 PR: remove hand-rolled handler, mapper, and static-handlers source files. Keep
scim_connectionsschema +AuthMiddleware+ store (they're path-agnostic).
Migration 065 (scim_connections, scim_sync_runs) is unchanged. Additive migration 068 (import_jobs) is introduced for the shared CSV/SCIM BulkImport ledger needed by #591.
Alternatives considered
- Option A — keep fixing hand-rolled: rejected (time + maintenance cost + no certification confidence).
- Option B — rip-replace: rejected (forces re-validating HMAC auth + scim_connections on day 1; slower cutover).
- Option D — ship scim-cli gate against the hand-rolled code only: rejected (founder pre-approved falling back to C if compliance failed; compliance failed).
Consequences
- +4 Go deps:
elimity-com/scim,scim2/filter-parser,di-wu/xsd-datetime,di-wu/parser. All pure-Go, MIT-licensed, active maintenance. - 2 handler paths during cutover; metrics tag by
handler={hand_rolled,elimity}so ops can spot behavioural drift. - Enterprise extension fields require a follow-up LLD to extend
UpdateMemberRequestwith optionalemployee_number,cost_center, etc. Tracked as deferred work (not PRD-mandated for v1.0). - Compliance CI gate (
.github/workflows/scim-compliance.yml) is a separate follow-up PR — the gate infra is local (runsgo test -run TestSCIMCompliance_scim2cli) withSCIM_COMPLIANCE_STRICT=1in CI env.