Skip to content

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

  1. Create <DataRoot>/IdentityMapping/Map-Identity.ps1 (see Script authoring below).
  2. Restart Cesivi Server.
  3. 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). Metadata is NOT part of the key — it is pass-through context. If your script branches on Metadata, register a custom IIdentityMappingCacheKeyBuilder via DI.
  • TTL: Absolute (no sliding). After the TTL expires the script is re-invoked on next lookup.
  • Negative caching: $null returns (= "no mapping") are cached too so unmappable identities don't thrash the runspace on repeated calls.
  • Partial-failure sentinels (Target=Unknown from 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 }
Requires the 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)