Skip to main content

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 / excludedAttributes query projection not implemented.
  • PATCH only mutates displayName / status; drops externalId, name.*, emails, enterprise extension entirely.
  • PATCH remove op not implemented.
  • Group PATCH members mutates backend but response always materialises members:[].
  • /scim/v2/.search (POST query) endpoint not mounted.
  • Group externalId not 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_connections row 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)

MetricHand-rolledElimity adapterΔ
SUCCESS1845+27
ERROR4265+23
Schema-by-id✗ all fail✓ all pass
ResourceType-by-id✗ fail✓ pass
Attribute projection✗ partial✓ complete
CRUDpartialcomplete

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

  1. 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).
  2. +1 PR: flip SCIM_USE_ELIMITY default to ON in staging config after 1 week of parallel-run observation.
  3. +2 PR: remove hand-rolled handler, mapper, and static-handlers source files. Keep scim_connections schema + 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 UpdateMemberRequest with optional employee_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 (runs go test -run TestSCIMCompliance_scim2cli) with SCIM_COMPLIANCE_STRICT=1 in CI env.