Repository navigation
refactor(users): put user administration behind IAccessModule and peer reads behind IAccessLookup - #1037
Conversation
SaleAllocationPolicyTests pins when ConfirmSaleHandler reads the actor's role and the allocation policy, but nothing pinned when it reads a plain Worker's flock assignments. This test parks a restricted worker's confirm on the account lock while another transaction removes the worker's only assignment, then requires the confirm to allocate as an unrestricted worker. It passes on main and guards the move of that read behind IAccessLookup. Refs #857
…r reads behind IAccessLookup IAccessModule is Access's surface for HTTP adapters. The users and me endpoints call it for every route; each member forwards to the existing handler or identity call, so step-up checks, locks, audit rows and errors are unchanged. It takes IIdentityProvider from DI, so a registered decorator stays in the path. IAccessLookup is the peer read port: the effective role and the flock assignments. AccessLookup depends only on the scoped AppDbContext. ConfirmSaleHandler, FlockScopeGuard and FlockScopeResolutionMiddleware read through it. The effective-role query is one routine, AccessLookup.ReadEffectiveRoleAsync, which IdentityProvider.GetEffectiveRoleAsync also runs, with both statements and Roles.ResolveEffective unchanged. SQL is unchanged except confirm's assignment read, which drops ORDER BY "Id" (the rows are used as a set), and the middleware and guard reads, whose parameter is now @userid instead of @user_UserId. Four test lines change, each passing the same unreachable context through the real lookup: FlockScopeGuardTests (1), FlockScopeMiddlewareTests (2) and TenantResolutionMiddlewareTests (1). The ledger gains the GetMe and ListUsers adapter rows and two Access -> Farm symbols; the tenant-bypass rows follow the moved query. Refs #857
…ssLookup No suite put two workers with different assignments in one farm, or gave one worker both a flock row and a farm-wide row. A lookup that returned the whole farm's rows, or dropped farm-wide rows, passed every test. The first would let a restricted worker inherit a colleague's farm-wide access. These tests seed exactly those shapes, at the port and through the flock-scope middleware. Refs #857
|
Independent security review (Claude Opus 5.5, xhigh) at head PR #1037 security review (Opus): head
|
| Question | Result | Evidence |
|---|---|---|
| A restricted worker gains another user's rows or a farm-wide row | No | AccessLookup.ListFlockAssignmentsAsync filters a.UserId == userId over the tenant-filtered UserRoleAssignments. That is the same predicate and DbSet the middleware and guard queried directly on main. Mutant B5 reproduced red (below). |
| The role resolves differently | No | ReadEffectiveRoleAsync is a verbatim move: the active-membership AnyAsync with AccountId and DisabledAt == null, then the role join, then Roles.ResolveEffective. Roles binds to Cluckwork.Domain.Accounts.Roles, the only type of that name in src/. IdentityProvider.GetEffectiveRoleAsync passes its own scoped db into the same routine, so the two cannot diverge. |
| Cross-tenant read | No change | The assignment read still relies on the tenant query filter (AppDbContext.cs:114). The role read still takes accountId explicitly. The tenant-bypass TSV rows moved with an unchanged token hash 4adcdb84. Mutant O3 (IgnoreQueryFilters() on the lookup) turns TenantBypassRealTreeTests red. |
| Confirm reads outside its transaction or before the account lock | No | Both reads remain at steps 5 and 6 inside ExecuteInTransactionAsync, after GetCurrentSharedLockedAsync and GetByIdLockedAsync and before LockForSaleAsync, and both take transactionCt. Mutants B1 and B3 reproduced red. |
| Unresolved actor treated as account-wide (#787) | No change | FlockScopeGuard still returns Auth.Unauthorized before the lookup. In the middleware, an unresolved user still skips the read and gets Unrestricted reads, by design. The FlockScopeGuardTests unreachable-DB proof still constructs the real AccessLookup. |
DI lifetime or AppDbContext instance |
Unchanged | IAccessModule and IAccessLookup are Scoped (CluckworkIdentityServiceCollectionExtensions.cs:128-129), registered unconditionally for both process roles. ConfirmSaleHandler, FlockScopeGuard, IUnitOfWork, and all 11 forwarded handlers are Scoped, so every read shares the request's single AppDbContext and its transaction. The middleware is conventional, so its InvokeAsync parameters resolve per request, which avoids a captive dependency. |
Dropped ORDER BY "Id" on confirm's assignment read |
Proven unobservable | rows is used only for Count, All(FlockId is not null) and ToHashSet(). The set reaches Reservation.Plan as IReadOnlySet<Guid> and is used only in lockedLots.Where(l => fromFlocks.Contains(l.FlockId)). Draw order comes from lockedLots, which are ordered by (ProductionDate, Id), and from SaleAllocationPlanner, never from the set. The middleware and guard never had an ORDER BY. The remaining ListByUserAsync callers (AssignFlockHandler and the sim seeder) are untouched. |
Users, me and assignment endpoints: step-up, idempotency, Version, audit, error mapping |
Unchanged | The route mapping is untouched, and so are the endpoint bodies apart from the call target. AccessModule forwards to the same handler instances. I checked every forwarded argument order against the handler signatures: all are (command, accountId, actingUserId|userId, ct), and UpdateUser is (command, accountId, ct). GetUserAsync and ListUsersAsync forward to the DI-registered IIdentityProvider, so decorators stay in the path. Mutant O4 reproduced red. |
| Ledger, adapter-reach, seam rows | Accurate | Access→Farm gains IAccessLookup and AccessLookup, and the reason text matches the code (they return and compute Domain.Accounts.EffectiveAccountRole). The Commerce→Access reason now names the port. The GetMe and ListUsers adapter rows are real new reach, since both were Platform-only through IIdentityProvider before. FlockScopeGuard (Infrastructure.Repositories) and the middleware are Platform, so they need no row. The port returns an enum and records, with no EF or entity types. CI Tests (application) is green. |
| PR body overclaims | None found | I verified "four test lines", "verbatim" and "module never bypasses the IIdentityProvider registration" by reading the code. I did not re-run the trace claim ("only the ORDER BY and the parameter names differ"), but it matches the code. |
Mutation runs (each reverted; one integration class at a time)
| # | Mutant | Origin | Result |
|---|---|---|---|
| B5 | AccessLookup: a.UserId == userId || true |
PR | Red. AccessLookupTests ×2; the restricted worker saw [A, B], expected [A] |
| B3 | Confirm reads assignments before ExecuteInTransactionAsync |
PR | Red. ConfirmSaleAssignmentReadTests: Expected: OK, Actual: UnprocessableEntity |
| B1 | Role routine drops DisabledAt == null |
PR | Red. SaleAllocationPolicyTests.ConfirmSale_ParkedOnTheAccountLock_IsForbidden_WhenTheActorIsDisabledWhileItWaits: Expected: Forbidden, Actual: OK |
| O1 | IAccessLookup registered as Singleton (captive root AppDbContext) |
mine | Red. AccessLookupTests ×2: the port returned [] and the restricted worker saw both flocks. DI validation did not catch it, because the host built fine. ConfirmSaleAssignmentReadTests stayed green because its expected outcome (unrestricted) equals the fail-open outcome. See F1. |
| O2 | Role routine drops u.AccountId == accountId |
mine | Survived. SaleAllocationPolicyTests 13/13, RoleMatrixTests 18/18, ChangeUserRoleTests 25/25. See F2. |
| O3 | Lookup adds IgnoreQueryFilters() |
mine | Red, structurally only. TenantBypassRealTreeTests.RealSourceTree_AllBypassesAreAllowListed; AccessLookupTests stayed green, as expected, because user ids are globally unique. |
| O4 | AccessModule.EnableUserAsync passes command.UserId as the actor |
mine | Red. DisableUserTests ×7: Expected: NoContent, Actual: Forbidden |
The baseline before mutation was AccessLookupTests + ConfirmSaleAssignmentReadTests, 4/4 green.
Findings
F1: P3, PLAUSIBLE (latent; pre-existing semantics, now a cross-module contract)
-
Where:
src/Cluckwork.Application/Features/Users/IAccessLookup.cs:13-14,src/Cluckwork.Infrastructure/Identity/AccessLookup.cs:16. -
Defect:
ListFlockAssignmentsAsynctakes noaccountId. It scopes by the ambient tenant query filter, and under an unresolvedTenantContextit returns zero rows. The port's own contract says zero rows means account-wide access, so a missing tenant fails open. -
Failure scenario: A future peer caller has a resolved Worker actor but has not resolved the tenant. Examples are a job, a seeder path, or the PR E callers.
FlockScopeGuardchecks onlyuser.IsResolved, so it calls the lookup, gets[], and returns Success for an unassigned flock. -
Evidence: Mutant O1 reached exactly this state, because the root-scope context has an unresolved tenant. The port returned
[]and a restricted worker became unrestricted. -
Not reachable today:
- HTTP resolves the tenant together with the actor.
DemoDataSeeder.cs:135/146andSimulationDataSeeder.cs:277resolve the tenant before the actor.- Confirm fails earlier at the account lock.
main's direct queries had the same shape, so this PR does not introduce it. It does publish the shape as a peer port. -
Suggested fix (small): Give the port an
accountIdparameter and filtera.AccountId == accountIdexplicitly, mirroringGetEffectiveRoleAsync, or throw inAccessLookupwhen the tenant is unresolved. Either can land here or in PR E, before the first peer caller.
F2: P3, CONFIRMED (test gap; pre-existing on main)
- Where:
src/Cluckwork.Infrastructure/Identity/AccessLookup.cs:37(u.AccountId == accountId). - Defect: No test pins the account predicate in the shared effective-role routine.
- Failure scenario: The predicate is dropped, and a caller passes a
userIdfrom another farm. The routine then returns that user's role instead ofnull. Today every caller passes consistent ids: confirm derives both from the JWT, andAssignFlockHandlerpre-checks withListUsersAsync(accountId). So this is defence in depth, not an exploit.IAccessLookup.GetEffectiveRoleAsyncnow exposes the routine to peer modules, though. - Evidence: Mutant O2 survived 56 tests across the three most relevant classes.
- Suggested fix: Add one case to
AccessLookupTests: a farm-B user queried with farm A's id must returnnull.
Product or security defect in this PR: none. F1 and F2 are hardening for the newly published port, and both exist on main.
security-review skill output
- The skill ran against the wrong tree. Its injected context came from the coordinator's worktree (
HEAD 05d7e7a7), not this checkout. That gave it an empty "FILES MODIFIED" and "DIFF CONTENT" and an unrelated commit list. - I ran its method by hand. Its sub-task protocol also conflicts with this brief's no-subagent rule. I applied its categories and its false-positive filter to the real diff (
git diff a71ec7af...HEAD: 19 files, +480/−74). - Result: zero findings at confidence ≥ 8.
- Authorization and privilege escalation: none; see the table above.
- Injection: the only raw SQL is in a test fixture, and it uses
ExecuteSqlInterpolatedAsync, which is parameterized. - Data exposure, crypto and secrets: none touched.
- F1 would score about 4/10 under the skill's filter (theoretical, no current attack path), so the skill would drop it. I keep it as a P3 hardening note.
Verdict
Merge once Tests (integration) is green. F1 and F2 are optional follow-ups, best done before PR E adds peer callers.
|
Review of record (Codex Astra, xhigh) at head PR #1037 security review, #857 PR B. Reviewed No CONFIRMED or PLAUSIBLE P0–P3 findings. I found no product or security defect in this diff. I compared all moved reads and all 14 endpoint forwards against the pinned baseline. The B-specific concerns from
The assignment lookup uses the existing
The lost assignment sort is safe. Confirm consumes Every endpoint keeps its original command construction, validation, step-up token, account/actor IDs, cancellation token and result mapping. I reproduced one 21-request trace on each revision using the supplied I reproduced these mutants, rebuilding each and restoring it afterward:
The reported B5 coverage gap exists on The ledger changes are accurate. Access-to-Farm gains the interface returning The fourth edited test line is mechanical. Final verification on the restored head passed 176/176 targeted integration tests and 13/13 Application checks. This includes all three edited test classes, the new tests, sale allocation, step-up, role/admin gates, me, joined projections, module ledger, adapter reach, interface persistence, generated matrix and tenant-bypass checks. See final check summary. I did not run the full integration suite. All temporary source changes and scratch tests were removed; the assigned checkout is clean at the reviewed head. No commit, push or GitHub comment was made. Nits: none. The ponytail-review pass found no material simplification within the approved extraction. net: -0 lines possible. Verdict: APPROVE PR #1037 at |
…role read's account scope Review hardening for PR #1037; both gaps predate the PR and the port now owns them. - ListFlockAssignmentsAsync relies on the tenant query filter. Under an unresolved tenant it read no rows, and no rows means account-wide access. It now throws before the query (the SQL is unchanged). Every caller resolves the tenant first: HTTP with the actor, both seeders before acting, confirm before its account lock. AppDbContext exposes its TenantContext, as it already exposes FlockScope. - Nothing pinned the account predicate in the shared effective-role query. A new test gives one email a different role in two farms and requires each farm to see only its own user's role. Refs #857
Review hardening:
|
| Finding | Change | Evidence |
|---|---|---|
| F2 (CONFIRMED). No test pinned the account predicate in the shared effective-role query; O2 survived 56 tests. | New test AccessLookupTests.GetEffectiveRole_AnswersOnlyForTheAccountItIsAskedAbout. One email is a Manager in farm A and an Owner in farm B. Each farm must see its own user's role and null for the other farm's user. |
O2 (drop u.AccountId == accountId): Assert.Null() Failure Expected: null Actual: Manager. |
F1 (PLAUSIBLE, unreachable today). ListFlockAssignmentsAsync read no rows under an unresolved tenant, and no rows means account-wide access. |
AccessLookup now throws InvalidOperationException before the query when db.Tenant is unresolved, following AuditWriter's precedent; the SQL is unchanged. AppDbContext exposes its TenantContext, as it already exposes FlockScope. New test ListFlockAssignments_RefusesAnUnresolvedTenant. |
G1 (guard removed): Assert.Throws() Failure: No exception was thrown.O1 (your Singleton mutant, guard in place): every worker request now fails closed, with the guard's exception and Expected: OK Actual: InternalServerError across 31 of 74 tests.O1+G1 (your original): restricted workers become unrestricted (16 of 74 red, ScopedWorker_FlocksList_SeesOnlyAssignedFlock: Assert.DoesNotContain() Failure). That is the fail-open case the guard now prevents. |
Caller check, done before guarding. Every caller of ListFlockAssignmentsAsync resolves the tenant first, so nothing legitimate trips the guard:
- HTTP: the tenant middleware resolves the tenant and the actor from the same token.
- Seeders:
DemoDataSeederandSimulationDataSeederresolve the tenant before resolving any actor. - Confirm: it reads the tenant's account first.
- CLI verbs and jobs: none call it.
- The two direct middleware tests: they skip the read before reaching the lookup (unresolved actor;
/errorre-execution).
Verification on 4d31a9f5:
- Application suite: 702 of 702.
- The touched integration classes plus the five
AccessLookupTestsand six seeder classes: 931 of 931. - The SQL and response trace: identical to the previous head.
- After every mutant was reverted: 74 of 74, tree clean.
The PR body's commit list, mutation table and counts are updated.
|
Review of record, round 2 (Codex Astra, xhigh) at head PR #1037 security review, round 2. Reviewed the four-file delta from No CONFIRMED or PLAUSIBLE P0–P3 findings. I found no product or security defect in this delta. The previous approval stands. The new guard at I independently traced all three assignment-lookup consumers and the four handlers that invoke
Service construction before tenant resolution remains valid: the check is inside the lookup operation, not its constructor. This matters because the seed command resolves the seeder and its dependencies before the seeder resolves its tenant. The administrative assignment-list endpoint uses the separate
No persistence type crosses an Application interface. Both types involved in the new property belong to Platform, and Access already depends on this context. No ledger allowance, adapter permission, mapped entity or migration changes. The real module-ledger, interface-persistence and generated-matrix checks pass unchanged. Both hardening tests passed on the clean head. I then rebuilt and reproduced these mutations, one class at a time:
F2 correctly tests the explicit account argument. Its same-email users have different IDs and roles in two accounts. The positive lookup for account A while the ambient scope is B also prevents a replacement implementation from silently substituting the ambient tenant for that argument. This preserves the existing effective-role contract. SQL parity is confirmed. I reran the 21-request trace and compared it with my independently captured After restoring both mutants, 27/27 targeted integration tests and 7/7 Application checks passed. These cover the five lookup cases, flock-scope middleware, tenant middleware, parked confirm, unresolved-actor refusal, interface persistence, ledger and matrix. See final summary. Seeder, CLI and job conclusions above come from source tracing; I did not rerun their suites or the full integration suite. Each test class ran alone after All mutations were restored, the clean head was verified, and the assigned round-2 worktree was removed as requested. I did not touch the checkout used by the concurrent integration run. No commit, push or GitHub comment was made. Nits: none. The ponytail-review pass found no material simplification needed. net: -0 lines possible. Verdict: APPROVE PR #1037 at |
…posing it on AppDbContext AppDbContext.Tenant made the DbContext carry request state for one caller. AccessLookup now takes the same scoped TenantContext through its own constructor, next to AppDbContext. The fail-closed guard and the SQL are unchanged; the getter is gone, so AppDbContext matches main again. The four test lines that construct AccessLookup by hand pass the tenant. Refs #857
|
Owner design fix, pushed as
The mutation table and test counts in the PR body are updated. |
|
Coverage note on the test counts above. |
|
Round-3 review (Claude Opus 5.5: the TenantContext fix, thermo-nuclear and ponytail) at head PR #1037 review, round 3 (Opus): head
|
| Question | Answer | Evidence |
|---|---|---|
| Is the fail-closed guard intact? | Yes | AccessLookup.cs:23-26 is unchanged except for the receiver: it was db.Tenant.IsResolved and is now tenant.IsResolved. Mutant M4 (guard deleted) turns ListFlockAssignments_RefusesAnUnresolvedTenant red with Assert.Throws() Failure: No exception was thrown. The other 4 tests stay green. |
| Is the SQL unchanged? | Yes | git diff 4d31a9f5 HEAD -- AccessLookup.cs shows only the comment, the constructor and the guard's receiver. The LINQ in ListFlockAssignmentsAsync and ReadEffectiveRoleAsync is byte-identical, and the tenant-bypass TSV hash is still 4adcdb84. Round 2 had already shown SQL parity at 4d31a9f5 (Astra, 42/42 files byte-identical), and this commit changes no query. |
Do the guard and the filter read the same TenantContext? |
Yes, in DI | CluckworkPersistenceServiceCollectionExtensions.cs:20 uses AddScoped<TenantContext>(). AddDbContext<AppDbContext> (line 61) is Scoped by default. AppDbContext's only constructor is (DbContextOptions, TenantContext tenant, FlockScope) (AppDbContext.cs:18), and the filters close over that tenant (:68-94). IAccessLookup is Scoped (CluckworkIdentityServiceCollectionExtensions.cs:129). So AccessLookup's db and tenant come from one scope and share one instance. Nothing in src/ uses AddDbContextPool, AddPooledDbContextFactory or IDbContextFactory<AppDbContext>. The only hand-built AppDbContext instances in src/ are the design-time factory and the ExportQueries snapshot, and neither reaches AccessLookup. No root-provider TenantContext resolve exists in src/ or tests/. |
Does a Singleton AccessLookup fail closed? |
Yes, reproduced | Mutant M1 registers it with AddSingleton. Three AccessLookupTests go red. The direct call throws the guard's own InvalidOperationException ("Flock assignments require a resolved tenant…"). Both HTTP tests return InternalServerError instead of OK, so a restricted or farm-wide Worker gets a 500, not an unrestricted 200. ConfirmSaleAssignmentReadTests also goes red: the confirm completed before it could park (WaitUntilDoneOrBlockedAsync returns false only on completion), which matches the middleware's 500 before the handler runs. Contrast this with round 1's O1 at 81ec39c0, before the guard existed: the port returned [] and the Worker became unrestricted. The DI container does not catch the captive dependency, because the test host runs in Testing, where scope validation is off. The guard catches it instead. |
| Did the four test constructions keep their proofs? | Yes | See the mutation table. Three of them pass the same tenant instance that built db, so they behave exactly as db.Tenant did. FlockScopeGuardTests passes a second, equally unresolved instance; see R3-1. |
| Any stale reference to the removed getter? | None | git grep finds no db.Tenant, AppDbContext.Tenant or .Tenant.IsResolved anywhere. The build passes with 0 errors. TenantContext is in Infrastructure.Persistence, which is Platform, so no ledger row changes. |
Mutation runs (each reverted; one test class at a time)
| # | Mutant | Result |
|---|---|---|
| base | clean 87a2eddf |
AccessLookupTests 5/5, FlockScopeMiddlewareTests 13/13, TenantResolutionMiddlewareTests 8/8, FlockScopeGuardTests 1/1 |
| M1 | IAccessLookup registered as Singleton |
Red, fail-closed. AccessLookupTests 3 of 5: the guard exception ×1, InternalServerError ×2. ConfirmSaleAssignmentReadTests 1 of 1. |
| M4 | unresolved-tenant guard deleted | Red. RefusesAnUnresolvedTenant: no exception thrown |
| G1 | FlockScopeGuard drops if (!user.IsResolved) return Unauthorized |
Red. FlockScopeGuardTests: InvalidOperationException from the lookup's guard |
| G2 | middleware drops its !user.IsResolved branch |
Red. UnresolvedLivenessRequest… and AnonymousHealthRequest…: the guard's InvalidOperationException. Four HTTP tests also fail, because anonymous login requests now reach the lookup. |
| G3 | middleware drops the IExceptionHandlerFeature bypass |
Red. ErrorReExecution…: Npgsql transient failure against the unreachable host |
Findings
R3-1: P3, CONFIRMED (introduced by e628995a)
- Where:
tests/Cluckwork.Application.Tests/FlockScoping/FlockScopeGuardTests.cs:17-18 - Defect: The test builds
AppDbContextwith onenew TenantContext()andAccessLookupwith another. Production can never produce that state, and the fix's correctness rests on exactly this instance identity: the guard and the filter must read the same object. The three integration tests share onetenant; this one does not. - Scenario: Today it is harmless, because both instances are unresolved and G1 still goes red. Suppose someone later copies this construction and resolves only the lookup's tenant. The guard then passes while the filter's tenant stays unresolved. The query reads zero rows, and that means account-wide access: the fail-open the guard exists to prevent.
- Evidence: the source lines above; the DI trace in pass 1.
- Fix:
var tenant = new TenantContext();and pass it to both constructors.
R3-2: P3, CONFIRMED (stale since round 2, 4d31a9f5, same PR)
- Where:
tests/Cluckwork.Api.IntegrationTests/FlockScopeMiddlewareTests.cs:144-145 - Defect: The comment says "Any UserRoleAssignments query attempts the unreachable connection and throws". Since the round-2 guard, an unresolved tenant throws
InvalidOperationExceptionbefore any query or connection. The test's conclusion still holds; the mechanism it names does not. - Scenario: A reader debugging a red run looks for an Npgsql error and finds the tenant-guard message instead.
- Evidence: G2 output:
System.InvalidOperationException : Flock assignments require a resolved tenant…for this exact test. - Fix: "…reaches the lookup, which throws for the unresolved tenant (or, with a resolved one, at the unreachable connection)."
T1: P3, CONFIRMED (the triplication is pre-existing on main; the port that preserves it is new)
- Where:
src/Cluckwork.Api/Middleware/FlockScopeResolutionMiddleware.cs:57-77src/Cluckwork.Infrastructure/Repositories/UserRoleAssignmentRepository.cs:81-91(FlockScopeGuard)src/Cluckwork.Application/Features/Sales/ConfirmSale/ConfirmSaleHandler.cs:202-208src/Cluckwork.Application/Features/Users/IAccessLookup.cs:13-20
- Defect: All three callers re-derive Access's rule from raw rows: no rows, or any farm-wide row, means unrestricted, and otherwise the result is the set of flock ids. The port's own comment states this rule (
IAccessLookup.cs:13), but the port does not apply it. Its record carries anIdthat no caller reads. - Scenario: The rule changes, for example a house-wide row starts to narrow access. Three files in two layers must change together, and a missed copy silently disagrees: a read scope and a write guard that answer differently for the same Worker. "Empty means everything" is also the semantic behind round 1's F1 fail-open. The rule belongs beside the tenant guard that protects it.
- Evidence: the three code sites. I checked that their outcomes are equivalent:
- middleware:
Count == 0orAny(null)gives unrestricted, otherwise the id list; - guard:
Count == 0orAny(null || == flockId); - confirm:
Count > 0 && All(not null)gives the set, otherwise null.
- middleware:
- Code-judo:
Task<IReadOnlySet<Guid>?> GetAssignedFlocksAsync(Guid userId, …), where null means unrestricted.- middleware:
scope.Resolve(assigned is null, assigned ?? []); - guard:
assigned is null || assigned.Contains(flockId); - confirm:
assignedFlockIds = role == Worker ? await … : null. - This deletes
FlockAssignmentDetails, the three copies and T2's name clash, and leaves the SQL unchanged. - Caveat:
FlockScope.Resolve's same-scope comparison usesCount, so duplicate flock rows would collapse. Check that duplicates cannot exist, or accept the change. - Doing it now or in PR C is the owner's call; it is not a blocker.
- middleware:
T2: P3, CONFIRMED (new)
-
Where:
src/Cluckwork.Application/Features/Users/IAccessModule.cs:44vsIAccessLookup.cs:16 -
Defect: Access's two new ports each declare
ListFlockAssignmentsAsync(Guid userId, …)in the same namespace, with different contracts:- the admin one returns named
UserFlockAssignmentrows and relies on the endpoint's owntenant.IsResolvedcheck; - the authorization one returns
FlockAssignmentDetailsand throws when the tenant is unresolved.
The names are also inverted:
FlockAssignmentDetails(Id, FlockId) carries fewer details thanUserFlockAssignment(Id, FlockId, FlockName). - the admin one returns named
-
Scenario: A peer module author picks the wrong port by name. If the author picks
IAccessModule's method for an authorization decision, the result skips the unresolved-tenant guard. The ledger would flag a peer reachingIAccessModule, but only after the code is written. -
Evidence: both signatures;
IUserRoleAssignmentRepository.cs:32. -
Fix: T1 removes the clash. Without T1, rename the lookup's method to say what it is for (for example
ListAssignmentsForScopeAsync).
T3: P3, CONFIRMED (the member is pre-existing; the cross-class static is new)
- Where:
src/Cluckwork.Infrastructure/Identity/IdentityProvider.cs:1455-1459src/Cluckwork.Infrastructure/Identity/AccessLookup.cs:42src/Cluckwork.Application/Common/IIdentityProvider.cs:196-203
- Defect: One routine has two public doors.
IdentityProviderreaches intoAccessLookup'sinternal staticand passes its owndb.IIdentityProvider.GetEffectiveRoleAsynchas exactly one production caller,AssignFlockHandler.cs:42, which is itself in Access (Features.Users). - Scenario: Two interfaces document the same contract in two comments (
IIdentityProvider.cs:196-201andIAccessLookup.cs:9), and they will drift. Test decorators must keep forwarding a member that only one handler uses (MustChangePasswordGateTests.cs:277,StepUpAuthTests.cs:713). - Fix:
- Point
AssignFlockHandleratIAccessLookup. - Delete the
IIdentityProvidermember, the provider's forwarder and the two decorator forwarders. - Make
ReadEffectiveRoleAsynca private instance method, and re-key the two TSV rows. - This fits [C] #514 slice 15: Access contract — security hold point, runs last #857 step 6 ("retire the exposed identity provider port"), so deferring it is reasonable.
- Point
T4: P3, CONFIRMED (stale comment, made stale by this PR; the file is not in the diff)
- Where:
tests/Cluckwork.Api.IntegrationTests/SaleAllocationPolicyTests.cs:290 - Defect: The comment says "the fresh identity.GetEffectiveRoleAsync re-check". Confirm now calls
access.GetEffectiveRoleAsync(ConfirmSaleHandler.cs:167). - Scenario: Someone grepping for the confirm role read follows the comment to
IIdentityProviderand edits the wrong door (T3). - Fix: "the fresh IAccessLookup.GetEffectiveRoleAsync re-check".
Skill 1: pstack:thermo-nuclear-code-quality-review
The skill ran over the whole PR diff. I followed the brief's single-file output in place of the skill's report directory, so its findings are T1–T4 above.
- Structural regressions: none. The PR deletes EF code from the middleware and the guard. It also drops two injected dependencies from
ConfirmSaleHandler(IUserRoleAssignmentRepositoryandIIdentityProvider) in favour of one port, and adds no branch to any flow. - Missed code-judo: T1. The port froze the raw-row shape, so the rule stayed in three places. This is the only high-conviction structural item.
- Boundary and naming: T2 and T3. Two doors to one routine, and two methods with the same name but different contracts.
- File size: no file crosses 1k lines because of this PR.
IdentityProvider.csis 1964 lines, which predates the PR, and the PR shrinks it by 16.UserEndpoints.csis 468 lines andConfirmSaleHandler.csis 340. - Considered and not raised:
AccessModuleas a 15-member 1:1 forwarder. [C] #514 slice 15: Access contract — security hold point, runs last #857 scope steps 1–2 specify "contracts around existing use cases, without rewriting Identity", so the facade is the deliverable and the ledger rows are its value. Its cost: a request to any one endpoint constructs all 11 handlers. That is unmeasured and almost certainly negligible next toUserManager, so it is not a finding.AccessModulein Infrastructure although every dependency it has today is Application. This placement is justified by PR C. Moving it later costs one file move.
Skill 2: ponytail:ponytail-review
IAccessLookup.cs:L20: delete:FlockAssignmentDetails.Id. No caller reads it; nothing replaces it. (New.)FlockScopeResolutionMiddleware.cs:L57-77,UserRoleAssignmentRepository.cs:L81-91,ConfirmSaleHandler.cs:L202-208: shrink: the assignment rule exists in three copies. ReturnIReadOnlySet<Guid>?from the port, which replaces about 38 lines with about 8 and deletes the record (T1). (The copies are pre-existing; the port is new.)IIdentityProvider.cs:L196-203,IdentityProvider.cs:L1455-1459,MustChangePasswordGateTests.cs:L277-279,StepUpAuthTests.cs:L713-715: yagni: an interface member with one caller, in the same module asIAccessLookup.AssignFlockHandlerinjectsIAccessLookupinstead, about −22 lines (T3). (The member is pre-existing.)ConfirmSaleAssignmentReadTests.cs:L23-34: reuse:SeedLotAsynccopiesSaleAllocationPolicyTests.cs:49's private helper. AddGuid? flockId = nulltoTestHarness.SeedEggLotAsync(Infrastructure/TestHarness.cs:276, which hardcodesGuid.NewGuid()for the flock) and delete both copies. (The copy is new; the original is pre-existing.)
net: about −55 lines possible.
Product or security defect
None, in the fix or anywhere in the PR. The guard fails closed under the misregistration the brief asked about. The SQL and the transaction placement are unchanged, and every authorization outcome I traced is identical to main. R3-1 through T4 concern maintainability and test hygiene only.
Verdict
APPROVE 87a2eddf. The owner's fix is correct and fails closed. The seven P3s are optional, and T1 is the only one worth doing before peer callers arrive.
Nothing changes for users. Every users, me and flock-assignment route keeps its statuses, bodies, step-up checks and audit rows. Confirming a sale still reads the actor's role and flock assignments inside its transaction, after the account and order locks.
For maintainers, Access now has one surface for HTTP adapters,
IAccessModule, and one read port for peer modules,IAccessLookup. The effective-role query exists once.This is PR B of the Access contract (#857, design v2). It moves confirm's authorization reads, so it needs a security review.
Refs #857
What changed
Commit 1,
446cbe25: one new test, green onmain.ConfirmSaleAssignmentReadTestscovers the one gap in the existing tests.SaleAllocationPolicyTestspins when confirm reads the role and the allocation policy, but nothing pinned when it reads a worker's assignments. The test parks a restricted worker's confirm on the account lock while another transaction deletes the worker's only assignment. The confirm must then allocate as an unrestricted worker. It passes onmainand on this head.Commit 2,
516d45bd: the move.IAccessLookup(Application.Features.Users):GetEffectiveRoleAsyncandListFlockAssignmentsAsync, the latter returningFlockAssignmentDetails(Id, FlockId)copies, never entities.AccessLookup(Infrastructure.Identity) depends only on the scopedAppDbContextand, since commit 5, the scopedTenantContextthat context's query filter reads. The flock-scope middleware resolves it on every request, so nothing heavier is built per request.ConfirmSaleHandler,FlockScopeGuardandFlockScopeResolutionMiddleware.ConfirmSaleHandlerno longer takesIUserRoleAssignmentRepositoryorIIdentityProvider.AccessLookup.ReadEffectiveRoleAsyncholds the query: the active-membershipAnyAsyncwithDisabledAt == null, then the role join, thenRoles.ResolveEffective, all verbatim.IdentityProvider.GetEffectiveRoleAsyncnow calls it, and its constructor is unchanged.IAccessModule(Application.Features.Users), withAccessModuleinInfrastructure.Identity. It has 14 members for users, me and assignments. Each one forwards to the existing handler, toIIdentityProvider, or to the joined assignment read (ListByNameByUserAsync, still one LEFT JOIN). It takesIIdentityProviderfrom DI, never the concrete provider, so the decorators inMustChangePasswordGateTestsandStepUpAuthTestsstay in the path.UserEndpointsandMeEndpointstake onlyIAccessModulefor Access.IAccessLookupandAccessLookup(20 → 22 symbols).IAccessLookup.UserEndpoints.ListUsersandMeEndpoints.GetMegain adapter rows. They used to reach only the Platform portIIdentityProvider; now they reach Access, so the matrix cell becomesA (45).AccessLookup.ReadEffectiveRoleAsync. The query token hash is unchanged (4adcdb84).Commit 3,
81ec39c0: three more tests, from the mutation run. No suite put two workers with different assignments in one farm, or gave one worker both a flock row and a farm-wide row. So a lookup that returned the whole farm's rows (B5) or dropped farm-wide rows (B4) passed everything. B5 would let a restricted worker inherit a colleague's farm-wide access, and that gap existed onmaintoo.AccessLookupTestsseeds exactly those shapes, at the port and through the flock-scope middleware.Commit 4,
4d31a9f5: review hardening (Opus F1, F2). Both gaps predate this PR; the port now owns them.IAccessLookup.ListFlockAssignmentsAsyncrelies on the tenant query filter. Under an unresolved tenant it read no rows, and no rows means account-wide access. It now throws before the query, so the SQL is unchanged. I checked every caller, and each resolves the tenant first:Commit 5 moved where the guard reads the tenant from.
Commit 5,
e628995a: owner design fix. Commit 4 read the tenant through a newAppDbContext.Tenantgetter, which made the DbContext carry request state for one caller.AccessLookupnow takes the same scopedTenantContextthrough its own constructor, next toAppDbContext, and the getter is gone.AppDbContextmatchesmainagain (git diff origin/main -- src/Cluckwork.Infrastructure/Persistence/AppDbContext.csis empty). The guard, its message and the SQL are unchanged. The existingFlockScopegetter stays.The branch is rebased onto
a71ec7af(#1033, the peer-module guard). #1033 changed nosrc. Its guard does not check Access yet, because Access has no contract until PR E. It reads parameter types, and B's new Access members take no Farm type as a parameter. After commit 5,mainat02e8a633(#1035 and #1036) was merged in with a merge commit. B's diff againstmainis unchanged: the same 19 files, with the same content.SQL and response trace
I adapted #854's trace harness (a scratch test, not committed). It records every SQL statement and transaction event, the status, and the normalised body for 21 requests:
Two runs on
mainwere identical. The head differs frommainin exactly these lines, and nowhere else:mainORDER BY u."Id"u."UserId" = @user_UserIdu."UserId" = @userIdFlockScopeGuard's assignment readu."UserId" = @user_UserIdu."UserId" = @userIdWhy each difference is safe:
ORDER BYwas planned in v2. All three callers use the rows as a set: unrestricted when there are no rows or any farm-wide row, otherwise the set of flock ids.userIdargument, notuser.UserId.Every status, every response body and every statement count is unchanged across the 21 requests. The trace was re-run after the rebase, after commit 4 and after commit 5, and is identical each time.
Mutation table
Each mutant was applied by exact single-match replacement, built, and run against
AccessLookupTests,SaleAllocationPolicyTests,ConfirmSaleAssignmentReadTests,FlockScopeTests,FlockScopeMiddlewareTests,NamedRowProjectionTests,MeEndpointsTests,StepUpAuthTests,AdminGatingTests,RoleMatrixTests,TenantResolutionMiddlewareTests(176 tests) and the ApplicationFlockScopeGuardTests. Each was then reverted. B1 to B3 and B7 to B11 ran before the rebase; the rebase changed nosrc. B4 to B6 and the restore check ran on this head.DisabledAt == nullSaleAllocationPolicyTests.ConfirmSale_ParkedOnTheAccountLock_IsForbidden_WhenTheActorIsDisabledWhileItWaitsExpected: ForbiddenActual: OKExpected: ForbiddenActual: OKConfirmSaleAssignmentReadTests(new; survived without it)Expected: OKActual: UnprocessableEntityAccessLookupTests×2 (new; survived without them)HashSets differExpected: [<flockA>, null]Actual: [<flockA>]AccessLookupTests×2 (new; survived without them)[<flockA>, <flockB>], expected[<flockA>]SaleAllocationPolicyTests×5,RoleMatrixTests×2,FlockScopeTests×6,AccessLookupTests×2FlockScopeTests.ScopedWorker_FlocksList_SeesOnlyAssignedFlock:Assert.DoesNotContain() FailureFlockScopeGuardreads before the actor checkFlockScopeGuardTests(Application)NpgsqlException : Failed to connect to 127.0.0.1:1, the unreachable database proving a read happenedFlockScopeTests×6Assert.DoesNotContain() FailureSetLanguageAsyncMeEndpointsTests×7Expected: NoContentActual: NotFoundGuid.Emptyas the acting user onCreateUserAsyncStepUpAuthTests×5,AdminGatingTests×3Expected: CreatedActual: ForbiddenNamedRowProjectionTests×4Expected: "<flock name>"Actual: nullu.AccountId == accountId(Opus; survived before commit 4)AccessLookupTests.GetEffectiveRole_AnswersOnlyForTheAccountItIsAskedAboutAssert.Null() FailureExpected: nullActual: ManagerCS9113: Parameter 'tenant' is unread. The constructor dependency exists only for the guard, so deleting the guard no longer compiles.!tenant.IsResolved && userId == Guid.Empty, so it still readstenant(commit 5)AccessLookupTests.ListFlockAssignments_RefusesAnUnresolvedTenantAssert.Throws() Failure: No exception was thrownExpected: typeof(System.InvalidOperationException)IAccessLookupregistered Singleton (captive root context and root tenant), guard in place; re-run after commit 5Expected: <status>Actual: InternalServerError; 2 the guard'sInvalidOperationException;FlockScopeTests.ScopedWorker_ChildRows_AreScopedreads an error body where it expected a list; the confirm inConfirmSaleAssignmentReadTestsends before it parks. None shows a widened flock set.AccessLookupTests.ARestrictedWorker_DoesNotInheritAnotherWorkersFarmWideRow:HashSets differExpected: [<flockA>]Actual: [<flockA>, <flockB>]O1+G1 failed 16 tests last round and O1+G1n fails 15. The one difference is
SaleAllocationPolicyTests.DefaultPolicy_RestrictedWorker_SufficientAssignedStock_AllocatesFromAssignedFlockOnly. Its two lots share a production date, so under a fail-open mutant FIFO breaks the tie on the lots' random ids, and the test passes whenever the assigned lot sorts first.Why a Singleton still fails closed with constructor injection. The root provider builds a Singleton
AccessLookupfrom the root scope'sAppDbContextandTenantContext. Each is a single instance, and that context's query filter reads that sameTenantContext, so the guard and the filter cannot disagree. In the serving process nothing resolves the rootTenantContext. EveryResolvecall runs on a scoped instance, either the tenant middleware'sInvokeAsyncparameter or a job's own scope, and CLI verbs run in their own one-shot process. So the captive tenant stays unresolved and the guard throws. This holds only while that stays true, and it held equally for the getter.Test counts
Measured on this machine.
maina71ec7af81ec39c0Cluckwork.Application.Tests(full, including #1033's peer guard and every ledger guard)main; 84 at the head with the two new ones)AccessLookupTests, and the six seeder classes (DemoSeedTests,DemoSeedActorTests,SeedCommandTests,SimulationSeedCommandTests,SimulationDeepSeedDrainTests,SimulationDuplicateFlockNameTests), because the guard sits on the seeders' flock-scoped writesmain02e8a633: the same 931, plus #1036'sCredentialEpochFloorTests. The Application suite is 702 of 702.The full integration suite was not run locally; CI runs it. One run on commit 5, before the merge, timed out
SeedCommandTests.SeedCommand_Demo_SeedsDataAndExitsWithoutStartingKestrelat its 60-second limit. The host's load average was 16 on 12 cores, and the seed was still writing rows when it was killed. Alone,SeedCommandTestspassed 11 of 11, with that seed taking 26 seconds, and it passed again in the 946-test run.Test edits
Four existing test lines changed. All are construction lines that now pass the same unreachable
AppDbContext, and since commit 5 aTenantContext, through the realAccessLookup, so each "no read before X" proof is unchanged. Three pass the test's own tenant.FlockScopeGuardTestspasses a fresh unresolvedTenantContext, which keeps the edit to its one line. Its guard returnsUnauthorizedbefore the lookup runs, so the instance never matters.FlockScopeGuardTests.csFlockScopeMiddlewareTests.csTenantResolutionMiddlewareTests.csCorrection to design v2: v2 promised three lines. Its census missed
TenantResolutionMiddlewareTests.cs:201, which also buildsFlockScopeResolutionMiddlewareby hand. That class is not on B's required-green list. The only way to avoid the fourth line was to constructAccessLookupby hand inside the middleware and the guard, bypassing DI, and I judged that worse. Every other existing test file is unedited (git diff --numstat origin/main...HEAD -- tests/).Deslop record
/deslop, and a by-hand/no-commentspass (subagents are off for this slice):srcdiff is forwarding only: endpoint parameter swaps, three callers onto the port, and one query moved verbatim. Nothing was removable.Change map
Review notes
FOR SHAREand the orderFOR UPDATE, and before the egg-lot lock;FlockIdstill means farm-wide access;TenantContext, and a Singleton registration fails closed;IIdentityProviderregistration.