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.