Identity Mapping Bridge — Operator Reference¶
The Identity Mapping Bridge lets Cesivi translate source identities from a migrating SharePoint farm into the identity system your deployment actually uses (Keycloak, Active Directory, Entra ID, or a custom store). You supply a PowerShell script; Cesivi calls it once per unique identity during migration.
Quick Start¶
- Create
<DataRoot>/IdentityMapping/Map-Identity.ps1(see Script authoring below). - Restart Cesivi Server.
- Run a migration — the MigrationTool automatically calls the bridge for identity-bearing fields.
How It Works¶
Cesivi loads your script into a pool of PowerShell runspaces at startup. For each source identity encountered during migration, it calls:
function Map-Identity {
param([Parameter(Mandatory)] [Cesivi.Sdk.Identity.SourceRef] $Source)
# Return [Cesivi.Sdk.Identity.MappedRef] or $null
}
The function receives a SourceRef describing the identity kind and raw value, and must
return either a MappedRef (= found a target identity) or $null (= no mapping).
Script Authoring¶
SourceRef properties¶
| Property | Type | Description |
|---|---|---|
Kind |
SourceKind enum |
Identity type: EntraObjectId, EntraUserPrincipalName, ActiveDirectorySid, SharePointClaim, GroupSid, Custom |
Value |
string |
Raw identity value (GUID, SID, UPN, claim string, etc.) |
CustomKindTag |
string? |
Set when Kind = Custom; caller-defined discriminator |
Metadata |
IDictionary<string,string>? |
Optional caller-supplied context (migration run ID, farm URL, etc.) |
MappedRef properties¶
| Property | Type | Description |
|---|---|---|
Target |
TargetType enum |
Target system: KeycloakUser, KeycloakGroup, ActiveDirectoryUser, ActiveDirectoryGroup, EntraUser, EntraGroup, Custom |
TargetId |
string |
Primary identifier in the target system (UUID, SID, sAMAccountName, UPN) |
DisplayName |
string? |
Human-readable name (for logs and UI) |
CustomTargetTag |
string? |
Set when Target = Custom |
Annotations |
IDictionary<string,string>? |
Optional extra metadata (realm, group path, license tier, etc.) |
Minimal example¶
function Map-Identity {
param([Parameter(Mandatory)] [Cesivi.Sdk.Identity.SourceRef] $Source)
if ($Source.Kind -ne [Cesivi.Sdk.Identity.SourceKind]::EntraObjectId) { return $null }
# Look up in your identity store...
$userId = Get-MyKeycloakUser -OID $Source.Value
if ($null -eq $userId) { return $null }
return [Cesivi.Sdk.Identity.MappedRef] @{
Target = [Cesivi.Sdk.Identity.TargetType]::KeycloakUser
TargetId = $userId
DisplayName = 'Alice Smith'
}
}
Returning from the function¶
| Return | Meaning |
|---|---|
[MappedRef] @{ Target=...; TargetId=... } |
Identity mapped successfully |
$null |
No mapping found — Cesivi logs a warning and skips the identity |
throw 'message' |
Hard error — Cesivi fails the migration item and records the exception |
Sandbox Restrictions¶
The script runs with these restrictions enforced by the host (cannot be bypassed):
| Capability | Allowed |
|---|---|
Network calls (Invoke-RestMethod, Invoke-WebRequest) |
✅ Yes |
| File reads (e.g. load a local cache file) | ✅ Yes |
File writes (Set-Content, Out-File, etc.) |
❌ No |
Starting processes (Start-Process) |
❌ No |
Evaluating arbitrary code (Invoke-Expression) |
❌ No |
Loading assemblies (Add-Type) |
❌ No |
| P/Invoke / unsafe code | ❌ No |
The trust model is the same as appsettings.json — operators are trusted, attackers who can
modify the script file can already modify the server configuration.
Script Path¶
Default: <DataRoot>/IdentityMapping/Map-Identity.ps1
Override via appsettings.json:
{
"Cesivi": {
"IdentityMapping": {
"ScriptPath": "/absolute/path/to/my/Map-Identity.ps1"
}
}
}
Hot-Reload¶
The script is loaded once per runspace at pool startup. Changes to the script file require a server restart to take effect. Phase γ will add a REST endpoint and CLI verb for flushing the pool without a full restart.
Configuration¶
All settings live under Cesivi:IdentityMapping in appsettings.json:
| Setting | Default | Description |
|---|---|---|
ScriptPath |
"" |
Absolute script path. Empty = <DataRoot>/IdentityMapping/Map-Identity.ps1 |
RunspacePoolSize |
4 |
Number of runspaces kept alive. Must be ≥ DefaultMaxConcurrent. |
DefaultTimeoutSeconds |
5 |
Max seconds per Map-Identity call before timeout exception |
DefaultMaxConcurrent |
4 |
Max parallel batch invocations |
Error Handling¶
| Scenario | Bridge behaviour |
|---|---|
Script returns $null |
MapIdentityAsync returns null. |
| Script throws (terminating error) | IdentityMappingException with the PS exception as InnerException |
| Script exceeds timeout | IdentityMappingTimeoutException with elapsed and configured timeout |
| Caller cancels | OperationCanceledException |
| Batch partial failure | Failed slot filled with sentinel MappedRef { Target=Unknown, Annotations["error"]=... } |
| Script file not found | IdentityMappingException on first call; check DataRoot path |
Reference Implementation¶
A fully working reference for EntraID → Keycloak translation lives in:
Cesivi.Examples.IdentityMapping.Keycloak/Map-Identity.ps1
Cesivi.Examples.IdentityMapping.Keycloak/README.md
Copy it to <DataRoot>/IdentityMapping/Map-Identity.ps1 and set the environment variables
described in the README.
Cache Layer (Phase γ)¶
Phase γ adds an in-memory cache in front of the runspace host so the same source identity is only translated once per TTL window.
How the cache works¶
- Key:
(Kind, Value, CustomKindTag).Metadatais NOT part of the key — it is pass-through context. If your script branches onMetadata, register a customIIdentityMappingCacheKeyBuildervia DI. - TTL: Absolute (no sliding). After the TTL expires the script is re-invoked on next lookup.
- Negative caching:
$nullreturns (= "no mapping") are cached too so unmappable identities don't thrash the runspace on repeated calls. - Partial-failure sentinels (
Target=Unknownfrom batch failures) are NOT cached — the next lookup re-tries the runspace. - Size cap: LRU eviction kicks in when the entry count exceeds
CacheSizeLimit(default 50 000).
Configuration (appsettings.json)¶
{
"Cesivi": {
"IdentityMapping": {
"CacheEnabled": true,
"CacheTtlSeconds": 3600,
"CacheSizeLimit": 50000
}
}
}
Set CacheEnabled: false in dev/test to disable caching entirely (every call reaches the script).
Operator: flushing the cache¶
After editing Map-Identity.ps1 you can flush the cache without a full server restart:
Via REST (recommended):
curl -X POST http://localhost:5000/_admin/identity-mapping/flush-cache \
-H "Authorization: Bearer <admin-token>"
# Response: { "flushed": 1234, "cacheSize": 0 }
FarmAdmin role. Returns 401/403 if unauthenticated or unauthorized.
Via CLI:
# Set the server URL in appsettings.json under Cesivi:IdentityMapping:CliServerUrl first
./Cesivi identity-mapping flush-cache
# Prints: Flushed 1234 entries.
When to flush:
- After editing Map-Identity.ps1 to pick up new mapping logic
- After a bulk identity rename in your identity provider
- After a test run that seeded stale test-identity entries
Trade-off: Flushing causes re-warm latency (one runspace call per unique identity on next access). For large migration runs consider scheduling the flush before an off-hours run starts.
Monitoring cache performance¶
curl http://localhost:5000/_admin/identity-mapping/cache-stats \
-H "Authorization: Bearer <admin-token>"
# Response: { "hits": 8420, "misses": 311, "evictions": 0, "size": 311 }
A high hit-rate (hits / (hits + misses) > 95%) means the TTL is well-matched to your workload.
If you see many evictions at a large size, increase CacheSizeLimit.
See Also¶
_project/areas/identity-mapping/CONTRACT.md— full technical contract (type shapes, exception table)Cesivi.Sdk/Identity/IIdentityMappingBridge.cs— the C# interface implemented by the bridge_docs/MIGRATION.md— MigrationTool overview (Phase δ will document the wire-in)