Skip to content

FlockScopeGuard's fail-open branch grants account-wide flock access to an unresolved actor #787

Description

@mforce

FlockScopeGuard.CheckAsync (bottom of src/Cluckwork.Infrastructure/Repositories/UserRoleAssignmentRepository.cs, ~line 64) opens with:

if (!user.IsResolved) return Result.Success();

The guard's own comment has been asking for this issue since #500, and says so explicitly:

Closing the gap here means flipping an authorization default from open to closed, which is a behaviour change beyond #500's scope and deserves its own issue rather than a drive-by. Documented rather than silently narrowed.

This is that issue. Surfaced again while designing #770 (MCP server support), where an unresolved actor was a live possibility rather than a hypothetical.

The gap, precisely

The comment already draws the distinction and it is worth preserving exactly, because half of it is easy to get wrong:

So the protection on the two audited handlers is an accident of which handlers happen to audit — not a property of the guard. Any new flock-scoped handler that does not audit inherits the hole by default.

Note also FlockScope (#388, src/Cluckwork.Infrastructure/Persistence/FlockScope.cs) defaults IsUnrestricted = true when unresolved, deliberately, for the same non-HTTP callers. The two defaults compound: an unresolved caller is both unscoped by the guard and unrestricted by the query filter.

Why it is not presently a defect

No such caller exists today. Both seeders declare an actor before every feed/water-usage call (#500), every HTTP route behind the guard requires authentication, and TenantResolutionMiddleware 401s an authenticated principal that cannot resolve. The one-shot CLI verbs declare a SystemActors identity.

It is a live gap for a future non-HTTP caller, and the number of those is growing.

Why the MCP design did not fix it

#770's design makes the branch unreachable from MCP — a scoped McpCallContext whose factory throws unless tenant, actor and flock scope are all resolved, enforced by an assembly walk so a future tool inherits it. That is deliberately narrower than fixing the guard: flipping the default is an authorization behaviour change affecting both seeders, the one-shot verbs and every non-HTTP caller, which is exactly the drive-by the comment warns against.

What a fix probably looks like

Flip the branch to fail closed, and make every legitimate non-HTTP caller declare an actor explicitly — most already do, post-#500. The work is in finding the ones that do not, and in deciding what a system actor that should be account-wide declares instead, since ResolveSystemActor currently produces zero roles, which Roles.ResolveEffective reads as a plain Worker, which with zero assignment rows is the account-wide case. That interaction is the subtle part: a system actor today has more effective flock reach than a restricted worker, not less.

Worth pairing with a guard that walks flock-scoped handlers and asserts each is unreachable with an unresolved actor, rather than relying on the audit writer's incidental coverage.

Activity

  1. mforce commented on Sep 12, 2026

    @mforce
    OwnerAuthor

    Scoped as a prerequisite for #789 (EPIC: MCP server support), slice 6 — the write tool. Not a blocker for the epic as a whole.

    The reasoning, so the boundary is not lost: FlockScopeGuard is consulted by RecordDailyEntry, which is slice 6. The MCP read slices do not reach it — they are scoped by the FlockScope EF query filter instead. So this gates the write and nothing else in that epic.

    It became a prerequisite rather than a nice-to-have because an adversarial review of the MCP design found that the mechanism meant to make this branch unreachable from MCP has a hole: a tool injecting a helper that opens a secondary DI scope leaves CurrentUserContext unresolved in that scope, landing exactly on if (!user.IsResolved) return Result.Success(). The MCP design's own fix (a dependency-graph walk plus a causal test) is the primary control; flipping this branch fail-closed is the backstop that turns a silent grant into a refusal if that control is ever weakened.

    Deliberately not made an epic-wide blocker: this change touches both seeders, four one-shot CLI verbs and every non-HTTP caller, and the guard's own comment says it "deserves its own issue rather than a drive-by". Making MCP's read slices wait on it would buy no safety, since those slices never reach this code.

  2. added
    area:apiAPI/endpoint layer
    priority:tier3Real product weight, real cost
    size:MA day or two; migration or a multi-state UI
    on Sep 13, 2026
  3. mforce commented on Sep 13, 2026

    @mforce
    OwnerAuthor

    Triaged in the 2026-09-13 issue cleanup. Kept open at priority:tier3, tied to #789 slice 6 — no change. Recording the reasoning so the tier is a decision rather than an omission.

    Why it is not being promoted: no unresolved caller exists today. Both seeders declare an actor before every feed/water-usage call (#500), every HTTP route behind the guard requires authentication, and TenantResolutionMiddleware 401s an authenticated principal that cannot resolve. So this is a latent default, not a live hole, and it moves when the thing that would make it live moves.

    Why it is not being closed either. Two properties make it worth keeping on the books:

    1. The protection on the audited handlers is an accident, not a design. RecordDailyEntry and SubmitDailyEntry are safe only because they write an audit event and IAuditWriter fails closed on an unresolved actor — they throw before reaching if (!user.IsResolved) return Result.Success(). RecordFeedUsage and RecordWaterUsage audit nothing and reach it. Any new flock-scoped handler that does not audit inherits the hole by default, and nothing will tell its author.
    2. Two fail-open defaults compound. FlockScope (Worker flock assignment is enforced on writes but not on reads — a restricted worker can enumerate and read unassigned flocks #388) independently defaults IsUnrestricted = true when unresolved. An unresolved caller is therefore both unscoped by the guard and unrestricted by the query filter.

    The trigger that promotes this: MCP slice 6 (#809, the write tool), or any new non-HTTP caller of a flock-scoped handler. The MCP design's adversarial review found the concrete route — a tool injecting a helper that opens a secondary DI scope leaves CurrentUserContext unresolved in that scope, landing exactly on this branch. That design's dependency-graph walk plus causal test is the primary control; flipping this branch fail-closed is the backstop.

    A note for whoever eventually does it: this is a behaviour change from open to closed on an authorization default, which is exactly what #500 declined to do as a drive-by. It needs every non-HTTP caller audited for a declared actor first, and a causal test that goes red when the branch is restored — not just a flipped boolean.

  4. added this to the Platform hardening milestone on Sep 13, 2026
  5. added a commit that references this issue on Sep 16, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    area:apiAPI/endpoint layerenhancementNew feature or requestpriority:tier3Real product weight, real costsize:MA day or two; migration or a multi-state UI

    Projects

    No projects

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions