ADR 0006 — Rehash-on-login persistence#

Status: Accepted (Phase 1, 2026-08-26) Depends on: 0001 — Authentication model, 0004 — Database migration strategy

Context#

ADR 0001 (amended) requires rehash-on-login: after a successful legacy password verification, the plaintext is rehashed with BCrypt and the stored value must be updated so the next login uses the modern algorithm and eventually removes the machine-key dependency at runtime.

The obvious place to write the new hash is the same column the legacy verifier reads — aspnet_Membership.Password — flipping PasswordFormat to a Cube 2.0 sentinel value. That directly violates ADR 0004:

Migrations never claim ownership of a legacy table they didn’t create. New migrations are always forward-only deltas. The legacy site still writes to aspnet_Membership.LastLoginDate, FailedPasswordAttemptCount, IsLockedOut, etc.; introducing a competing writer for the same row invites lost-update races and breaks the legacy site if it ever encounters a $2*-prefixed value it doesn’t understand.

We considered three options:

OptionSummaryRejected because
A. Write BCrypt back into aspnet_Membership.PasswordReuse existing column, flip PasswordFormat = 99 (or similar sentinel).Violates ADR 0004 (writing to a legacy-owned table). Legacy site would fail to verify passwords for rehashed users and would potentially corrupt them on next password change. Two writers, no coordinating lock.
B. Cube-2.0-owned auth storeNew table MnxUserAuth owned by Cube 2.0. Verify checks new store first, falls back to legacy.Chosen.
C. In-memory cache onlyCache the verified plaintext hash in the Web app process for the session.Pointless — session-scoped, doesn’t outlive a restart, never removes the machine-key dependency.

Decision#

Introduce a Cube-2.0-owned table MnxUserAuth, shipped as the first EF Core migration on this codebase.

Schema#

CREATE TABLE MnxUserAuth (
    UserId          uniqueidentifier NOT NULL PRIMARY KEY,
    PasswordHash    nvarchar(255)    NOT NULL,   -- BCrypt hash ($2b$…)
    HashUpdatedAt   datetime2        NOT NULL,
    HashAlgorithm   nvarchar(20)     NOT NULL DEFAULT ('bcrypt'),
    CONSTRAINT FK_MnxUserAuth_aspnet_Users
        FOREIGN KEY (UserId) REFERENCES aspnet_Users (UserId)
);
  • UserId — 1:1 with aspnet_Users; PK doubles as the FK.
  • PasswordHash — BCrypt output, includes cost factor + salt in the string.
  • HashUpdatedAt — for audit + eventual “everyone’s migrated, flip the flag” query.
  • HashAlgorithm — future-proofs a swap from BCrypt to argon2/etc.

No index beyond the PK. MnxUserAuth is read/written 1:1 with the login flow.

Provisioning#

An idempotent SQL delta ships as deploy/sql/phase-1-add-mnxuserauth.sql. Ops applies it once against staging + prod before the app is deployed with this change. The script:

  • Guards on sys.tables so a rerun is a no-op.
  • Creates the FK to aspnet_Users(UserId) with no cascade — deliberate; a legacy user-delete should not silently sweep Cube 2.0 auth state.
  • Includes the cutover-tracking query as a comment for later reuse.

The proper EF Core migration lands once ops points us at a real staging DB to scaffold a baseline snapshot from (per ADR 0004). Until then, the SQL delta is the source of truth and dotnet ef database update is not run.

Verification flow#

UserAuthService.AuthenticateAsync, after resolving AspnetUsers:

  1. Look up MnxUserAuth for UserId. If present and PasswordHash verifies with BCrypt → success, skip legacy path.
  2. Otherwise, load aspnet_Membership and run the legacy verifier (PasswordFormat dispatch per ADR 0001).
  3. On successful legacy verification, upsert MnxUserAuth with BCrypt(plaintext) and HashUpdatedAt = UtcNow. Fire-and-forget is not enough — we complete the write before issuing the token so the next login uses the new store.
  4. Return the token. AuthResult.RehashedPassword reflects whether the upsert happened.

Failure modes#

  • Duplicate insert race (two concurrent logins for the same user, both legacy-verified): a single-row upsert using a SQL MERGE (or the EF Core Update-then-Insert fallback) leaves either row acceptable — both write the same BCrypt output pattern.
  • MnxUserAuth write fails after legacy verify succeeds: log a warning, still return the token. The next login just re-verifies against legacy and re-tries the rehash.
  • Legacy row deleted between verify and upsert: the FK insert fails; we already returned the token but the user is effectively gone. Extremely unlikely; a warning log is enough.

Cutover#

Cube 2.0 stops reading the machine key at runtime when every active user has a MnxUserAuth row. Operational check:

SELECT COUNT(*) AS legacy_only
FROM aspnet_Users u
LEFT JOIN MnxUserAuth a ON a.UserId = u.UserId
WHERE a.UserId IS NULL
  AND EXISTS (SELECT 1 FROM aspnet_Membership m WHERE m.UserId = u.UserId AND m.LastLoginDate > DATEADD(day, -90, GETUTCDATE()));

When legacy_only = 0 for a chosen window, flip FeatureFlags:UseLegacyPasswordHash = false. Users who haven’t logged in inside the window remain on the legacy path until they do; if they never do, they’re out of scope for cutover.

The legacy aspnet_Membership.Password value is never modified by Cube 2.0.

Consequences#

  • The migration folder gains its first EF Core migration (AddMnxUserAuth). Establishes the pattern for future deltas per ADR 0004.
  • Login flow now issues at most one extra SELECT (fast — PK lookup) on the hot path. When MnxUserAuth exists, the legacy verifier — and therefore the machine key — is not touched.
  • MnxUserAuth is Cube-2.0-owned. The legacy site does not read it, does not write it, and will not break if we drop or re-shape it.
  • Password changes issued through Cube 2.0’s future /api/v1/auth/change-password write only to MnxUserAuth. Password changes issued through the legacy site still update aspnet_Membership.Password; users who go that route slip back to the legacy path until their next Cube 2.0 login, which upserts MnxUserAuth fresh. This is acceptable during coexistence.
  • Machine-key dependency at runtime disappears the moment the cutover query is clean and the flag flips. The BCrypt hashes in MnxUserAuth remain valid without the machine key.

What we deliberately don’t do#

  • We don’t back-fill MnxUserAuth in a batch. Rehash happens lazily on login. Batch backfill would require knowing every plaintext — impossible by design.
  • We don’t sync deletes. If the legacy site deletes a user from aspnet_Users, the MnxUserAuth row is orphaned by the FK’s ON DELETE default (no cascade — legacy delete just fails). That’s intentional: Cube 2.0 shouldn’t be the reason legacy user delete blocks. If this ever bites, add ON DELETE CASCADE in a follow-up migration.
  • We don’t index HashUpdatedAt. The cutover query above scans, which is fine at expected user counts (<100k). Add later if needed.
  • We don’t record the previous algorithm. HashAlgorithm describes the current stored value only. Rotation history stays out of scope for Phase 1.