Repository navigation
feat(tenancy): write-side tenant guard + single-assignment TenantContext (#546) #561
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
mforce
merged 3 commits into
main
from
mforce/write-side-tenant-guard-single-assignment-tenant
Aug 19, 2026
Merged
Changes from all commits
Commits
Show all changes
3 commits
Select commit
Hold shift + click to select a range
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
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
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
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
21 changes: 21 additions & 0 deletions
21
src/Cluckwork.Infrastructure/Persistence/TenantReassignmentException.cs
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
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,21 @@ | ||
| namespace Cluckwork.Infrastructure.Persistence; | ||
|
|
||
| // Distinct type so the failure is greppable in logs rather than read as a | ||
| // generic 500 — the same rationale FarmTimeZoneException carries | ||
| // (Infrastructure/Time/FarmClock.cs). | ||
| // | ||
| // Thrown when one DI scope is asked to serve two different accounts. See | ||
| // TenantContext for why that is never legitimate. | ||
| public sealed class TenantReassignmentException : InvalidOperationException | ||
| { | ||
| public Guid ResolvedAccountId { get; } | ||
| public Guid AttemptedAccountId { get; } | ||
|
|
||
| public TenantReassignmentException(Guid resolvedAccountId, Guid attemptedAccountId) | ||
| : base($"TenantContext is already resolved to account {resolvedAccountId} and cannot be " + | ||
| $"re-resolved to {attemptedAccountId}. One scope serves exactly one account.") | ||
| { | ||
| ResolvedAccountId = resolvedAccountId; | ||
| AttemptedAccountId = attemptedAccountId; | ||
| } | ||
| } |
25 changes: 25 additions & 0 deletions
25
src/Cluckwork.Infrastructure/Persistence/TenantWriteMismatchException.cs
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
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,25 @@ | ||
| namespace Cluckwork.Infrastructure.Persistence; | ||
|
|
||
| // Distinct type so a write-side tenant breach is greppable in logs rather than | ||
| // read as a generic 500 — the same rationale FarmTimeZoneException carries | ||
| // (Infrastructure/Time/FarmClock.cs). | ||
| // | ||
| // Thrown by TenantStampInterceptor when a tracked write carries an AccountId | ||
| // that is not the resolved tenant's. That is a bug or an attack, never user | ||
| // input, so it is an exception rather than a Result failure. | ||
| public sealed class TenantWriteMismatchException : InvalidOperationException | ||
| { | ||
| public string EntityType { get; } | ||
| public Guid ExpectedAccountId { get; } | ||
| public Guid ActualAccountId { get; } | ||
|
|
||
| public TenantWriteMismatchException( | ||
| string entityType, string state, Guid expectedAccountId, Guid actualAccountId) | ||
| : base($"Refusing to save {state} {entityType}: AccountId {actualAccountId} does not match the " + | ||
| $"resolved tenant {expectedAccountId}.") | ||
| { | ||
| EntityType = entityType; | ||
| ExpectedAccountId = expectedAccountId; | ||
| ActualAccountId = actualAccountId; | ||
| } | ||
| } |
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
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
45 changes: 45 additions & 0 deletions
45
tests/Cluckwork.Api.IntegrationTests/TenantContextSingleAssignmentTests.cs
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
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,45 @@ | ||
| namespace Cluckwork.Api.IntegrationTests; | ||
|
|
||
| using Cluckwork.Infrastructure.Persistence; | ||
|
|
||
| // #546 — TenantContext is single-assignment: one DI scope serves exactly one | ||
| // account. See TenantContext for why re-pointing a live scope is never | ||
| // legitimate. | ||
| public sealed class TenantContextSingleAssignmentTests | ||
| { | ||
| [Fact] | ||
| public void Resolve_DifferentAccount_Throws() | ||
| { | ||
| var first = Guid.NewGuid(); | ||
| var second = Guid.NewGuid(); | ||
| var tenant = new TenantContext(); | ||
| tenant.Resolve(first); | ||
|
|
||
| var ex = Assert.Throws<TenantReassignmentException>(() => tenant.Resolve(second)); | ||
|
|
||
| Assert.Equal(first, ex.ResolvedAccountId); | ||
| Assert.Equal(second, ex.AttemptedAccountId); | ||
| // The original resolution survives the refusal — a rejected re-resolve | ||
| // must not leave the scope half-repointed. | ||
| Assert.Equal(first, tenant.AccountId); | ||
| } | ||
|
|
||
| // NOT a proof of single-assignment, and deliberately named so nobody reads | ||
| // it as one: this passes identically against the old plain-setter | ||
| // TenantContext and would survive deleting the feature outright. Its job is | ||
| // the opposite — pinning that we did NOT over-tighten into throwing on a | ||
| // harmless same-account re-resolve, which would make every defensive caller | ||
| // order-dependent. | ||
| [Fact] | ||
| public void Resolve_SameAccountTwice_DoesNotThrow() | ||
| { | ||
| var accountId = Guid.NewGuid(); | ||
| var tenant = new TenantContext(); | ||
|
|
||
| tenant.Resolve(accountId); | ||
| tenant.Resolve(accountId); | ||
|
|
||
| Assert.Equal(accountId, tenant.AccountId); | ||
| Assert.True(tenant.IsResolved); | ||
| } | ||
| } |
Oops, something went wrong.
Oops, something went wrong.
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.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
When a detached entity is passed to
DbSet.UpdateorDbSet.Remove, EF attaches it and initializesOriginalValuefrom the caller-supplied current values; therefore a foreign-row stub whose primary key names another tenant's row but whoseAccountIdis set to the current tenant passes this check and can update or delete that row. The absence of explicit.Attach(calls does not make this unreachable because repositories such asFlockRepository.UpdateandRemoveinvoke these attaching APIs directly; either reject detached-origin writes or enforce the tenant in the generated SQL rather than treating this snapshot as database provenance.AGENTS.md reference: AGENTS.md:L72-L72
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Confirmed on the mechanism, and thank you — this is a real defect in what I claimed, not just in what I wrote.
Verified.
DbSet.Update/DbSet.Removedo attach a detached instance and seed its original values from the caller's own current values, soOriginalValueis not database provenance in general. My comment asserted that it was, without qualification. You are also right that grepping for.Attach(was the wrong check — the attach here is implicit, andFlockRepository.Update/Removedo call the attaching APIs directly.One correction to the severity, with the evidence. I could not find it reachable today. Every
Update/Removecall site is preceded by a tracked read behind the tenant query filter, so the snapshot really is the database's:DepleteFlock/ArchiveFlock/ReactivateFlock/UpdateFlockGetByIdAsync(tracked, filtered)RemoveFarmLogo/RemoveFarmBannerGetTrackedAsyncUpdateInventoryItemGetByIdLockedAsyncUpdateProduct/UpdateEggGrade/UpdateEggUnitConversion/UpdateWaterUsageGetByIdAsyncNo handler constructs an entity, or reads one
AsNoTracking, and then hands it toUpdate/Remove. So I am treating this as a latent soundness gap plus an overstated guarantee rather than a live bypass — P2 as you labelled it. If you can point at a call site that does reachUpdate/Removewith a detached instance, that changes the severity and I would want to know before this merges.What is changing here:
OriginalValueis provenance only for a tracked load,Update/Removeattach, and closing it by construction is Write guard trusts OriginalValue as DB provenance; detached Update/Remove can bypass the tenant theft check #562.TrackedMutationReadTestspins the precondition rather than the mechanism: it asserts the repository mutation read returns a tracked entity with a real database snapshot. MutatingFlockRepository.GetByIdAsynctoAsNoTracking— precisely the change that would void the theft check — turns it red withExpected: Unchanged / Actual: Detached. Driver-verified before dispatch.What is deliberately not changing: I did not add a test asserting that a detached cross-tenant write succeeds. That would pin a known loss as specified behaviour. And I did not take the structural fix (tenant in the generated SQL, or forbidding
DbSet.Updatein repositories) into this slice — it reaches ~12 repositories and the write path other Phase 1.6 slices are building on right now. That is #562, suggested to land alongside T8 (#536), whose negative isolation matrix is the natural place to prove it closed.Your suggested fix — "enforce the tenant in the generated SQL rather than treating this snapshot as database provenance" — is recorded verbatim as option 1 in #562.
Commit SHA and full verification to follow once the change is applied and I have re-run it myself.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Addressed in
1ef818f1846cd33c0043f66207fa52c49d6c233e.What changed (no executable line of the interceptor — verified: the diff has zero non-comment additions):
OriginalValueis the database's only for an entity loaded while tracked;DbSet.Update/Removeattach and seed originals from the caller's values; a stub with another tenant's PK and this tenant'sAccountIdwould satisfy both halves; closing it by construction is Write guard trusts OriginalValue as DB provenance; detached Update/Remove can bypass the tenant theft check #562.TrackedMutationReadTestspins the precondition the guard depends on.Verification, re-run by the driver rather than quoted from the implementer:
0 Warning(s), 0 Error(s)FlockRepository.GetByIdAsync→AsNoTracking()makesFlockRepository_GetByIdAsync_ReturnsATrackedEntityfail on its own assertion:git diffonFlockRepository.csempty.That mutation is the point of the test:
AsNoTrackingon a mutation read is exactly the change that would silently void the theft check, and it now fails loudly instead.Still open, deliberately, and tracked in #562: the underlying mechanism. Your suggested fix — enforce the tenant in the generated SQL rather than trusting the change-tracker snapshot — is recorded there verbatim as option 1, alongside forbidding
DbSet.Update/Removein repositories and widening this test to every mutation read. Suggested to land with T8 (#536), whose negative isolation matrix is the natural place to prove it closed.I did not add a test asserting a detached cross-tenant write succeeds, on purpose: pinning a known loss turns "not yet fixed" into "specified".
If you can point at a call site that reaches
Update/Removewith a detached instance, that makes this a live bypass rather than a latent gap and I would want it before this merges — I checked all current ones and found none.