Repository navigation
Conversation
…tor columns AspNetUserTokens gets #670's seam: a shadow AccountId, stamped and checked by TenantStampInterceptor and tokened by the #562 walk, and a composite foreign key to AspNetUsers(Id, AccountId) with cascade. One migration backfills it and drops the add-column default. The same migration adds the five AspNetUsers two-factor columns and the nullable refresh_tokens.SignedInAt, so later slices ship no migration (#1274 decisions 8 and 15). Refs #1276
…as digests TwoFactorCredentials is the one protect site: the live key and the pending payload are protected with one purpose naming the account and the user, in Cluckwork token rows. Each recovery code is one row named by the SHA-256 digest of the user id and the code, with no value. Rows carry the caller's account, so the composite foreign key checks it before the tenant resolves. Both db.UserTokens sites are classified rows that filter by UserId and AccountId (#1274 decisions 9 and 13). Refs #1276
…or storage rules 530 section 5 and its enforcement list now name three Identity tables as the accepted risk, not four, and describe the AspNetUserTokens seam. src/AGENTS.md gains the two-factor storage rule. The 794 runbook says how many recovery codes a user needs after a certificate change or a ring deletion. Refs #1276
…comments The Rows query's FilterFreeSetSites signature is re-pinned because its tokens changed. The copy test's comment no longer claims the cross-farm case isolates the account: user ids are globally unique, so the account in the purpose has no observable effect. Refs #1276
…stored names AddRow no longer sets AccountId, so the interceptor stamps every add from the resolved tenant and the foreign key refuses an add under no tenant (#1274 decision 15). The row names and the protector purpose are constants with literal tests, and the purpose chain is pinned directly. RecoveryCodeName normalizes the code itself and is pinned by a fixed vector. The flaky four-character fragment check is gone. Refs #1276
The carrier floor rises from 29 to 31, the measured count, and the walk must find IdentityUserToken<Guid> by name, as it finds IdentityUserRole<Guid>. Refs #1276
The stage methods take the account from TenantContext and throw when none is resolved, so a value can no longer be protected under one account and stamped with another. ReadProtectedAsync keeps its account argument, because login reads before the tenant resolves. The two protected rows become a closed class that states each name once, the stored constants are private, and the digest's length check has its own test. Refs #1276
…ctor ban its own test The two-factor list adds ReplaceCodesAsync, RedeemCodeAsync and CountCodesAsync and runs as its own test with its own failure message and no allowed file. 530 and src/AGENTS.md say exactly what the list covers, that EF-generated migration files are skipped, and that an update of a stored value goes through the same account filter. Refs #1276
…proof The walk must read TwoFactorCredentials.cs and at least 639 files, the source count measured without obj/, so an empty walk fails. A fixture theory proves each banned token is reported in a source file and skipped in a migration-shaped one. The failure message names the migration exemption. Refs #1276
StageRecoveryCodes checks the tenant and computes every row name before it tracks a row. The tests say why the row names stay literals, the read-back test's comment claims only what it proves, and the foreign-key helper is private again. Refs #1276
… the banned list Both walks now enumerate through one SourceFiles helper that skips bin/ and obj/, requires TwoFactorCredentials.cs, and holds a floor of 550 against the 694 source files measured on 2026-10-22. The real walk and the fixtures share one two-factor scan call, the theory's names are literals with an equality check against the list, and a model-snapshot probe pins the second migration shape. The docs say migration-shaped files are skipped. Refs #1276
…ly read ScanFiles reports the files it read after its allowed-file and migration skips, and both walks assert on that set: it must include TwoFactorCredentials.cs and hold at least 550 files (639 and 637 measured). A scanner skip of that file or of a subtree now fails. The skip wording names build output as well as migration-shaped files, the sentinel's message says what a move means, and TwoFactorApi says what pins it. Refs #1276
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
This is the first two-factor slice of #1273. It builds the storage that the later slices use, and it changes nothing a user sees: no route, no screen, and no enrolment path yet. The design is the #1274 record ("The state it owns", decisions 9, 13, 15 and 22).
AspNetUserTokensgets the same tenant guard that #670 gaveAspNetUserRoles. The interceptor stamps and checks the shadowAccountId, the #562 walk makes it a concurrency token, and the composite foreign key refuses a row for another farm's user. One migration,20261021205522_AddTwoFactorStorage, adds every two-factor column, so #1278 to #1281 ship no migration.The live authenticator key and the pending payload are protected with the #794 key ring. The protector's purpose names the account and the user, so a value copied to another user's row does not unprotect. Recovery codes are stored only as SHA-256 digests of the user id and the code. Identity's own
[AspNetUserStore]rows are never written, andProtectPersonalDatastays off.Where to read:
TwoFactorCredentials.cs, theAspNetUserTokensmapping inAppDbContext.cs, the hand-written backfill andDROP DEFAULTin20261021205522_AddTwoFactorStorage.cs, and the new properties onApplicationUserandRefreshToken.UserTokenTenantWriteTests,UserTokenAccountIdModelTests,UserTokenAccountIdMigrationTests,TwoFactorCredentialsStorageTests, and the two-factor ban inGlobalUserLookupGuardTests..Designer.cs,AppDbContextModelSnapshot.csanddocs/schema/, regenerated withtools/schema-docs/generate.sh.src/AGENTS.mdlines, and the record's "How it is enforced" sentence.Acceptance criteria
From the issue, with the amendment comment's three items in place of the two "If #1274 chooses…" items:
UserTokenTenantWriteTestscopies the sixUserRoleTenantWriteTestscases. A detached add for another farm's user is refused by the foreign key, and a detached remove by the interceptor. A forged remove is refused by the database. Tracked relabel and tracked remove are refused by the interceptor.TwoFactorCredentialsStorageTestscovers both protected rows. The purpose chain is pinned directly: another account, the order swapped, or no account all fail.UserNameandEmailstay plaintext. A raw-SQL read of the user row checks them.TableOwnerRealModelTests,TenantBypassDiscoveryTestsand the schema-docs check pass. The first two keep their expected sets.FilterFreeSetSitesgains two classified rows, one for the query root and one for the insert. The query root filters byUserIdandAccountId.AccountIdConcurrencyTokenModelTestscarrier floor rises from 29 to 31, the measured count, and the test now also requiresIdentityUserToken<Guid>."Cluckwork"rows, the five user columns with their defaults, andrefresh_tokens.SignedInAtall ship here.GlobalUserLookupGuardTestsbans, by name,UserManager's authenticator, recovery-code and authentication-token methods and the recovery-code store'sReplaceCodesAsync,RedeemCodeAsyncandCountCodesAsync, anywhere insrc/except build output and migration-shaped files. It does not ban the token store'sGetTokenAsync,SetTokenAsyncorRemoveTokenAsync, becauseHttpContext.GetTokenAsyncshares the name. Coverage by interface is 2FA slice 5: TOTP code as a second step at login #1278's G3.20.recover-admin.Not in this slice, as decision 22 says:
ITwoFactorCredentials, its DI registration, code generation, verification and every route (#1278 onward). That nothing butTwoFactorCredentialswrites the token rows or theSecondFactor*columns stays review-only until #1278's guard G3.20.Evidence
Migration on a real Postgres. I ran
20261021205522_AddTwoFactorStorageon a throwaway container of the repo's pinnedpostgres:18.4-trixieimage. Before the migration ran, the database held threeAspNetUserTokensrows from two farms. This is from the full log:DownkeepsAK_AspNetUsers_Id_AccountId, because the #670 migration owns it.Mutations. Before I claimed any guard, I broke the code it protects and watched it go red. Every valid mutation went red on the test it targets, except the two greens explained below the table.
The per-round result files are in my local working directory, not in this repository, so the table lists what each round mutated.
SecondFactorFailuresmade nullable with no default (one mutation changing both); no protector; user dropped from the purpose; plaintext code in the row name; user dropped from the digest; account left to the interceptor (superseded: round 1 removed the hand-setAccountIdit guarded);ProtectPersonalDataon with a working protectorAccountId; normalization broken; digest separator changedStageRecoveryCodes; names computed while tracking; a banned call inAdminRecoveryService.csTwoFactorCredentials.csallowed, skipped by name, or inside a skipped subtree;Cluckwork.ApiskippedTwo greens, and the invalid attempts:
obj/again stayed green, and I accepted it without a guard. Today the scans read 639 and 637 files after their skips, against a floor of 550, so reading the generated files underobj/again cannot change a result. No test guards the build-output skip.Tests at
8bd45974, the merge withmain.dotnet build Cluckwork.slnxgives 0 warnings and 0 errors.GlobalUserLookupGuardTests, the raw-SQL user fixtures, migration security and the concurrency-token model.tools/schema-docs/generate.sh --checkat8bd45974on 2026-10-22 at 03:04 UTC. It exited 0 and printeddocs/schema/ is up to date.Review. The automated per-commit security review ran on each of this branch's commits that changed source files and reported no security issues, except one review at 21:19 UTC on 2026-10-21 that got no verdict, because the reviewer's credentials were rejected (HTTP 401). The sweep over the branch at push time, at 02:52 UTC on 2026-10-22, reported no new findings. Separately, a five-model local review (Opus, Fable, Sonnet, Codex and GLM) covered security over six rounds. No round found a critical issue, and round 6 found nothing above a nit.
I deferred round 6's nits to #1278, which edits the same file. They are test cleanup in
GlobalUserLookupGuardTests.cs. The main one: no test pins that the scanned-file list excludes skipped files, or that a file is recorded only after it is read. The others fix a "same code path" comment, narrow two comments and the sentinel's message, return the scanned list fromScanFilesinstead of an out-list, and make the sentinel's failure print the list.Merge Danger
Door: two-way
Downreverts the migration cleanly, and I ran it above. Rolling back by image alone is also safe. The old code writes noAspNetUserTokensrow, the newAspNetUserscolumns have defaults, andSignedInAtis nullable, so inserts from the old image keep working.Blast Radius: Identity storage
The migration takes locks on
AspNetUsers,AspNetUserTokensandrefresh_tokenswhile it adds columns, and onAspNetUserTokenswhile it backfills. Nothing insrc/wroteAspNetUserTokensbefore this slice, so the backfill should touch few or no rows. Nothing reads the new columns until #1278.Closes #1276