Skip to main content

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?

  1. The SCIM server is a thin layer over the store -- the interesting logic is in provider scoping, idempotency, and namespace bindings
  2. An in-memory store with programmatic API is faster and more deterministic than HTTP roundtrips
  3. The same ScimStore and ScimServer code will back the production HTTP endpoints
  4. Test code can directly assert on store state without HTTP parsing

Why not mock the store?

  1. Mocking bypasses provider-scoped isolation logic
  2. Production bugs in idempotency or namespace binding would not be caught
  3. The ScimStore is 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:

  1. User is marked active: false in the provider-scoped directory
  2. Reconciliation engine identifies affected group memberships
  3. 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/

FileResponsibility
mod.rsModule exports
model.rsSCIM resource types (User, Group, ListResponse, Error, Patch, NamespaceBinding)
store.rsIn-memory provider-scoped store with binding management
server.rsSCIM 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:

SchemaURN
Userurn:ietf:params:scim:schemas:core:2.0:User
Groupurn:ietf:params:scim:schemas:core:2.0:Group
ListResponseurn:ietf:params:scim:api:messages:2.0:ListResponse
Errorurn:ietf:params:scim:api:messages:2.0:Error
PatchOpurn: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:

MethodHTTP Endpoint
create_userPOST /scim/v2/Users
get_userGET /scim/v2/Users/{id}
list_usersGET /scim/v2/Users
update_userPUT /scim/v2/Users/{id}
delete_userDELETE /scim/v2/Users/{id}
create_groupPOST /scim/v2/Groups
get_groupGET /scim/v2/Groups/{id}
list_groupsGET /scim/v2/Groups
delete_groupDELETE /scim/v2/Groups/{id}
patch_groupPATCH /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

  1. Instant test execution: no HTTP, no database, no IdP connection
  2. Full provider-scoped isolation: same external IDs across providers tested naturally
  3. Deterministic: no flaky IdP push delays or ordering issues
  4. Production parity: same ScimServer and ScimStore code as production
  5. Idempotency validation: repeated operations produce identical results
  6. Namespace binding separation verified: provisioning never auto-grants namespace access
  7. Deprovisioning lifecycle fully testable: activate, deactivate, reconcile

Negative

  1. HTTP transport is not tested (request parsing, content-type negotiation, filtering)
  2. SCIM protocol edge cases (pagination, sorting, complex filters) deferred to HTTP endpoint phase
  3. 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 ScimStore in-memory backend will be replaced with a persistent backend for production

References

Revision History

  • 2026-04-15: Initial ADR for SCIM local test mode