SCIM Provisioning Local Test Mode
Context
Prism supports SCIM-based user and group provisioning from external identity providers (RFC-065). The central design rule is that SCIM providers provision provider-scoped directory objects first; namespace authorization bindings are managed as explicit Prism relationships on top of that directory state.
During local development and testing, developers need to validate the complete SCIM provisioning pipeline without requiring actual SCIM push from an IdP (Okta, Azure AD).
Current Problems:
- Production SCIM requires an IdP configured to push to Prism's SCIM endpoints
- Testing provider-scoped isolation requires multiple SCIM providers pushing simultaneously
- Namespace binding approval workflows need realistic directory state
- Deprovisioning reconciliation (inactive user removal from groups) requires lifecycle state changes
- Edge cases (conflicting external IDs, cross-provider group name collisions, idempotency) are difficult to reproduce against real IdPs
Requirements:
- Local SCIM user and group provisioning without a running IdP
- Provider-scoped storage isolation (same external ID across providers must not collide)
- Namespace binding management separate from provisioning
- Deprovisioning lifecycle (active to inactive, group reconciliation)
- Bearer token authentication for SCIM endpoints
- SCIM Patch operations for group membership management
- Full idempotency semantics
Decision
We implement an in-memory SCIM store with programmatic API for local testing. The ScimStore provides provider-scoped storage, and the ScimServer exposes the same operations that would be served by HTTP endpoints in production.
Why not a mock HTTP SCIM server?
- The SCIM server is a thin layer over the store -- the interesting logic is in provider scoping, idempotency, and namespace bindings
- An in-memory store with programmatic API is faster and more deterministic than HTTP roundtrips
- The same
ScimStoreandScimServercode will back the production HTTP endpoints - Test code can directly assert on store state without HTTP parsing
Why not mock the store?
- Mocking bypasses provider-scoped isolation logic
- Production bugs in idempotency or namespace binding would not be caught
- The
ScimStoreis pure in-memory logic with no I/O -- fast and deterministic
Architecture
Production Flow:
IdP SCIM Push → HTTP Endpoint → ScimServer → ScimStore → Provider-Scoped Directory
→ Namespace Bindings
Local Test Flow:
Test Code → ScimServer → ScimStore → Provider-Scoped Directory
→ Namespace Bindings
┌─────────────────────────────────────────────────┐
│ ScimStore │
│ │
│ ┌──────────────┐ ┌──────────────┐ │
│ │ Provider: │ │ Provider: │ │
│ │ okta-enterp. │ │ azuread-corp │ │
│ │ │ │ │ │
│ │ users: {} │ │ users: {} │ │
│ │ groups: {} │ │ groups: {} │ │
│ └──────────────┘ └──────────────┘ │
│ │
│ ┌──────────────────────────────────────────────┐│
│ │ Namespace Bindings (provider-agnostic) ││
│ │ ││
│ │ group:scim:okta-enterprise:eng → write → ns1 ││
│ │ group:scim:azuread-corp:ops → read → ns1 ││
│ └──────────────────────────────────────────────┘│
└─────────────────────────────────────────────────┘
Provider-Scoped Identity Model
Per RFC-065, every SCIM object is scoped to its provider:
User ID: user:scim:<provider-id>:<external-id>
Group ID: group:scim:<provider-id>:<external-id>
Examples:
user:scim:okta-enterprise:2819c223-7f76-453a
user:scim:azuread-corp:2819c223-7f76-453a (different user, same external ID)
group:scim:okta-enterprise:platform-engineering
group:scim:azuread-corp:platform-engineering (different group, same display name)
This prevents identity collision when two providers use the same external IDs or group names.
Namespace Binding Model
Namespace bindings are separate objects that link provider-scoped groups to Prism namespaces:
namespace: digital-twin-prod
bindings:
- source_group: group:scim:okta-enterprise:twin-operators
relation: write
source: manual
approved_by: admin@example.com
SCIM provisioning does NOT automatically create namespace grants. A namespace admin must explicitly approve the binding.
Deprovisioning Model
Deprovisioning follows RFC-065's two-phase approach:
- User is marked
active: falsein the provider-scoped directory - Reconciliation engine identifies affected group memberships
- Namespace bindings for the deactivated user remain (manual cleanup)
store.deactivate_user("okta-enterprise", "user-1").await?;
let affected = store.reconcile_deprovisioned("okta-enterprise").await;
// affected = ["Engineering:user-1", "Admins:user-1"]
Test Patterns
Pattern 1: Full User Lifecycle
let server = ScimServer::new("okta-enterprise", store);
let user = ScimUser::new("okta-enterprise", "2819c223", "alice@example.com");
server.create_user(user).await?;
let retrieved = server.get_user("2819c223").await?;
server.delete_user("2819c223").await?;
Pattern 2: Group Membership via Patch
let patch = ScimPatchRequest {
schemas: vec![SCIM_SCHEMA_PATCH],
operations: vec![ScimPatchOperation {
op: PatchOpType::Add,
path: Some("members".to_string()),
value: Some(json!([{"value": "user:scim:okta-enterprise:u1"}])),
}],
};
server.patch_group("eng", patch).await?;
Pattern 3: Cross-Provider Isolation
let okta_server = ScimServer::new("okta-enterprise", store.clone());
let azure_server = ScimServer::new("azuread-corp", store.clone());
// Same external ID, different providers
okta_server.create_user(ScimUser::new("okta-enterprise", "same-id", "a@okta.com")).await?;
azure_server.create_user(ScimUser::new("azuread-corp", "same-id", "a@azure.com")).await?;
// Users are distinct: user:scim:okta-enterprise:same-id != user:scim:azuread-corp:same-id
Pattern 4: Namespace Binding
let binding = NamespaceBinding {
bound_subject: "group:scim:okta-enterprise:eng".to_string(),
namespace: "digital-twin-prod".to_string(),
relation: BindingRelation::Write,
source: BindingSource::Manual,
..Default::default()
};
store.add_binding(binding).await?;
let bindings = store.list_bindings(Some("digital-twin-prod")).await;
Implementation
Module Location
prism-proxy/src/scim/
| File | Responsibility |
|---|---|
mod.rs | Module exports |
model.rs | SCIM resource types (User, Group, ListResponse, Error, Patch, NamespaceBinding) |
store.rs | In-memory provider-scoped store with binding management |
server.rs | SCIM server with CRUD operations, auth validation, patch handling |
Test Location
prism-proxy/tests/scim_provisioning_test.rs
21 integration tests covering:
- Full user lifecycle (create, get, list, delete)
- Full group lifecycle (create, get, delete)
- User deprovisioning lifecycle (create, add to group, deactivate, reconcile)
- Provider-scoped isolation (same external ID, different providers)
- Cross-provider group membership isolation
- Namespace bindings separate from provisioning
- Namespace binding idempotency
- Binding removal
- User idempotent creation
- Group idempotent creation
- Bearer token authentication
- SCIM Patch add/remove members
- Provider-scoped ID collision prevention
- Deprovisioning reconciliation (multi-group, multi-user)
- Error response types
- User API serialization
- List response format
- Multi-provider namespace bindings
- User update preservation
- Update nonexistent user failure
SCIM Schema Compliance
The model implements standard SCIM 2.0 schema URNs:
| Schema | URN |
|---|---|
| User | urn:ietf:params:scim:schemas:core:2.0:User |
| Group | urn:ietf:params:scim:schemas:core:2.0:Group |
| ListResponse | urn:ietf:params:scim:api:messages:2.0:ListResponse |
| Error | urn:ietf:params:scim:api:messages:2.0:Error |
| PatchOp | urn:ietf:params:scim:api:messages:2.0:PatchOp |
Environment Variables
PRISM_SCIM_ENABLED=true # Enable SCIM endpoints
PRISM_SCIM_LISTEN=0.0.0.0:9091 # SCIM HTTP listener address
PRISM_SCIM_BEARER_TOKEN=local-scim-secret # Bearer token for SCIM auth
Future: HTTP SCIM Endpoints
The ScimServer methods map directly to HTTP handlers:
| Method | HTTP Endpoint |
|---|---|
create_user | POST /scim/v2/Users |
get_user | GET /scim/v2/Users/{id} |
list_users | GET /scim/v2/Users |
update_user | PUT /scim/v2/Users/{id} |
delete_user | DELETE /scim/v2/Users/{id} |
create_group | POST /scim/v2/Groups |
get_group | GET /scim/v2/Groups/{id} |
list_groups | GET /scim/v2/Groups |
delete_group | DELETE /scim/v2/Groups/{id} |
patch_group | PATCH /scim/v2/Groups/{id} |
HTTP endpoint wiring is deferred to RFC-065 Phase 1 implementation.
Future: Persistent Store
The current ScimStore is in-memory. Production deployment requires a persistent store backed by the Prism KeyValue pattern (SQLite for single-node, Redis/Postgres for distributed). The ScimStore trait abstraction allows swapping the backend without changing server logic.
Consequences
Positive
- Instant test execution: no HTTP, no database, no IdP connection
- Full provider-scoped isolation: same external IDs across providers tested naturally
- Deterministic: no flaky IdP push delays or ordering issues
- Production parity: same
ScimServerandScimStorecode as production - Idempotency validation: repeated operations produce identical results
- Namespace binding separation verified: provisioning never auto-grants namespace access
- Deprovisioning lifecycle fully testable: activate, deactivate, reconcile
Negative
- HTTP transport is not tested (request parsing, content-type negotiation, filtering)
- SCIM protocol edge cases (pagination, sorting, complex filters) deferred to HTTP endpoint phase
- No real IdP push simulation (webhook delivery, retry semantics)
Neutral
- This mode validates Prism's SCIM data model and provider-scoping logic, not the SCIM HTTP protocol
- Production SCIM deployments will add HTTP endpoint handlers that delegate to
ScimServer - The
ScimStorein-memory backend will be replaced with a persistent backend for production
References
- RFC-065: SCIM Provisioning and Namespace Directory Bindings
- RFC-064: Federation Profile for Namespace-Aware Identity
- RFC-063: Normative Proxy Auth Contract
- ADR-046: Dex IDP for Local Identity Testing
- ADR-063: SAML Federation Local Test Mode
- RFC 7644: SCIM Protocol
- RFC 7643: SCIM Core Schema
Revision History
- 2026-04-15: Initial ADR for SCIM local test mode