You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
Write guard and #562 token both skip Identity's AccountId-less tables; AspNetUserRoles is live RBAC state with no tenant column #670
Found while closing #562 (falsifying review of the seam, 2026-09-02).
The gap
Both the write-side tenant guard (TenantStampInterceptor, #546) and the #562 fix (AccountId as an EF concurrency token on every entity that carries one) match on the AccountId property name. Entity types without that property are outside both mechanisms:
DurableJob — deliberate, recorded in TenantBypassDiscoveryTests.
Identity's six own types: ApplicationRole, IdentityRoleClaim, IdentityUserClaim, IdentityUserLogin, IdentityUserRole, IdentityUserToken.
Of those, AspNetUserRoles (IdentityUserRole<Guid>) is live RBAC state: a tracked cross-tenant Remove or Add on it — a row naming another farm's user id — is refused by nothing on the write side. Reads are scoped because ApplicationUser carries AccountId and every role lookup goes through the user, but the row itself has no tenant column.
Why it is not a live bypass today
Every AspNetUserRoles write goes through UserManager.AddToRoleAsync / RemoveFromRoleAsync on a user loaded through the tenant-filtered ApplicationUser set, so the user id is always this tenant's. The gap is the same shape #562 had: the guarantee rests on call-site discipline, not on the mechanism.
A guard test that walks Identity's own DbSets and asserts every write to them is reachable only through UserManager on a filtered user (convention, mechanically checked).
Record it as accepted risk in docs/decisions/530-multi-farm-tenancy.md with the premise pinned by a test.
Verify
Whatever the fix: serving farm A, hand-build an IdentityUserRole stub naming farm B's user id, Remove/Add it through the context, and the refusal must be the assertion that goes red when the fix is reverted.
Closed by fc0552ae (PR #675, squash of five commits). What shipped, against the body above:
Option 1, by construction: a shadow AccountId on IdentityUserRole<Guid> plus a composite FK (UserId, AccountId) → AspNetUsers(Id, AccountId); the existing interceptor stamps/verifies it and the Write guard trusts OriginalValue as DB provenance; detached Update/Remove can bypass the tenant theft check #562 walk tokens it with no change to either. Migration AddAccountIdToUserRoles with a hand-inserted backfill and DROP DEFAULT, guarded by a migrate-to-a-point test.
The Verify paragraph, met: serving farm A, a hand-built row for farm B's user is refused on Add (FK 23503), on unforged detached Remove (interceptor), on forged Remove (token), and a role write under no resolved tenant is refused (FK); tracked relabel and tracked remove pinned too — UserRoleTenantWriteTests (6), each red with the captured symptom when its layer is mutated (rows C, M1–M9, driver-run).
Scope decision (owner, 2026-09-02):UserRoles only. AspNetUserClaims/UserLogins/UserTokens/RoleClaims recorded as accepted risk in docs/decisions/530-multi-farm-tenancy.md §5, with the two residuals no source walk can see named there (a future UserManager claim/login/token call; RoleManager.DeleteAsync's cascade) and the unresolved-tenant delete arm's actual control (Cross-tenant isolation hardening: enumerating IgnoreQueryFilters guard + two-farm end-to-end matrix #536's scanner) stated.
Found while closing #562 (falsifying review of the seam, 2026-09-02).
The gap
Both the write-side tenant guard (
TenantStampInterceptor, #546) and the #562 fix (AccountIdas an EF concurrency token on every entity that carries one) match on theAccountIdproperty name. Entity types without that property are outside both mechanisms:DurableJob— deliberate, recorded inTenantBypassDiscoveryTests.ApplicationRole,IdentityRoleClaim,IdentityUserClaim,IdentityUserLogin,IdentityUserRole,IdentityUserToken.Of those,
AspNetUserRoles(IdentityUserRole<Guid>) is live RBAC state: a tracked cross-tenantRemoveorAddon it — a row naming another farm's user id — is refused by nothing on the write side. Reads are scoped becauseApplicationUsercarriesAccountIdand every role lookup goes through the user, but the row itself has no tenant column.Why it is not a live bypass today
Every
AspNetUserRoleswrite goes throughUserManager.AddToRoleAsync/RemoveFromRoleAsyncon a user loaded through the tenant-filteredApplicationUserset, so the user id is always this tenant's. The gap is the same shape #562 had: the guarantee rests on call-site discipline, not on the mechanism.Options
AccountIdtoIdentityUserRole(and the other five if desired), populated from the user, so the existing name-matched guard and the Write guard trusts OriginalValue as DB provenance; detached Update/Remove can bypass the tenant theft check #562 token cover it by construction. Needs a migration and a backfill.UserManageron a filtered user (convention, mechanically checked).docs/decisions/530-multi-farm-tenancy.mdwith the premise pinned by a test.Verify
Whatever the fix: serving farm A, hand-build an
IdentityUserRolestub naming farm B's user id,Remove/Addit through the context, and the refusal must be the assertion that goes red when the fix is reverted.