Articles · Compliance & Migration
Shadow-mode migration
Try out the running example
The dangerous day in an identity migration isn’t when you copy the users — it’s when you start believing the new engine’s answers. Shadow mode removes the belief: Sentinel evaluates every authorization decision beside your legacy stack, silently, and you cut over on a counter that reads zero, not on a gut feeling.
Step 1 — Import, twice
Run your importer (AspNetIdentityImporter, KeycloakRealmImporter, Auth0Importer or
DuendeConfigImporter — see the migration reference) in dry-run
first:
var report = await importer.ImportAsync(exportJson, new ImportOptions
{
TargetRealmId = realm,
DryRun = true,
});
Console.WriteLine($"{report.Users} users, {report.Credentials} credentials, " +
$"{report.Issues.Count} issues");
foreach (var issue in report.Issues.Where(i => i.Severity == ImportIssueSeverity.Warning))
Console.WriteLine($" {issue}");
Nothing is written; the report is the triage list. Fix the mappings, then run for real —
and don’t fear running it again: re-import is idempotent (Reimport_is_idempotent),
updating existing rows instead of duplicating, so a delta export the night before
cutover is routine, not scary.
Imported bcrypt/PBKDF2/ASP.NET-Identity hashes verify natively from day one — each
credential is tagged with its algorithm, and successful logins transparently rehash to
argon2id. No password-reset email blast. Non-portable client secrets (Duende’s SHA-256
variants) come back in GeneratedClientSecrets as a rotation checklist
(Sha256_secret_triggers_rotation_on_migration).
Step 2 — Model, then verify with the inspector
Translate your legacy roles into grants
(imported roles arrive prefixed imported: so they can’t collide with your declared
catalog). Spot-check the model before shadow mode ever runs, with the inspector:
POST /sentinel-admin/authz/inspect
{ "userId": "…", "permission": "records:org:read", "organizationId": "…" }
Every grant reports its outcome with provenance — five minutes with the inspector catches the mapping errors that would otherwise be five hundred divergence events.
Step 3 — Shadow
ShadowAuthzRecorder (in Nuvora.Nexus.Sentinel.Importers) wraps the comparison. The
contract is strict: legacy remains authoritative; Sentinel only watches.
public sealed class RecordAccessPolicy(
LegacyAuthz legacy, ShadowAuthzRecorder shadow, ISubjectSnapshotProvider snapshots)
{
public async ValueTask<bool> CanReadAsync(User user, Record record)
{
var legacyDecision = legacy.Can(user, "records.read", record);
var snapshot = await snapshots.GetAsync(user.Id, record.OrgId);
return shadow.Compare(legacyDecision, snapshot, new AccessCheck(
PermissionId.Parse("records:org:read"),
resourceOrganizationId: record.OrgId,
resourceOwnerId: record.OwnerId));
// Returns legacyDecision unchanged — always.
}
}
Compare runs AuthorizationEvaluator.Evaluate, bumps an agreement or divergence
counter (thread-safe — Counters_are_thread_safe pins it under parallel load), and on
mismatch emits authz.shadow_divergence with both verdicts, fire-and-forget so the
event sink can never slow a request. Then it returns exactly what legacy said.
Step 4 — Work the divergence queue
Route authz.shadow_divergence to a webhook and treat each event
as a finding with two possible resolutions:
- Sentinel is wrong → a grant you haven’t modeled. Add it; the divergence class disappears on the next occurrences.
- Legacy is wrong → you just found a live authorization bug with production evidence attached. (Every migration finds at least one. Fix legacy, or accept that cutover fixes it.)
Divergences per day is your burn-down chart. Run until it flatlines — through a month-end close or whatever your traffic’s slow-path cycle is, because the rarely-hit permissions are where models diverge.
Step 5 — The gate
var report = shadow.Report(); // ShadowAuthzReport(Agreements, Divergences)
if (report.ReadyForCutover) // Divergences == 0 && Total > 0
featureFlags.Enable("sentinel-authoritative");
ReadyForCutover encodes the policy: zero divergences over a non-empty sample. Zero
observations is not zero divergences — the gate refuses to open on silence
(Cutover_gate_opens_on_zero_divergences_over_a_nonempty_sample). Flip the flag, swap
which decision gets returned — and if you’re prudent, keep Compare running with the
roles reversed as a regression tripwire while legacy winds down.
Step 6 — Decommission
Rotate the flagged client secrets, retire the legacy tables, and let rehash-on-login
finish converting the hash population. The imported: role prefix makes the borrowed
taxonomy easy to find and progressively replace with your
declared, boot-verified catalog.
The whole arc — import, model, shadow, gate, flip — turns the scariest sentence in identity engineering, “we switched authorization systems,” into a sequence of reversible, evidence-producing steps. That’s the point.