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
MCP slice 7: [MirrorsRoute] parity guard — tool RBAC cannot drift from its route #810
Slice 7 of #789. Blocked on slices 3-6 (needs tools to check). Can land alongside the last of them.
This guard catches RBAC drift from outside the MCP code.
The risk it closes
MCP tools call the Application layer directly, so they inherit none of a route's .RequireAuthorization(...). Tool RBAC is therefore declared twice, once on the tool and once on the route. The two can silently diverge when someone tightens a route and never opens Mcp/.
The mechanism
Each tool declares [MirrorsRoute("ListSalesOrders", "GetSalesOrder")] or [MirrorsNoRoute(reason)]. The guard walks every tool and resolves each declared name from the live EndpointDataSource. It derives each route's admitted EffectiveAccountRole set by running the real policies against one synthetic principal per role. It then asserts the tool's set is a subset of the intersection.
Set containment, not policy-name equality. The five policies are not a total order. ProductionWrite = {Owner, Manager, Worker} and SalesAccess = {Owner, Manager, Sales}. Neither contains the other, so comparing names would pass a genuine widening.
Keyed by WithName(), what the code says about itself, never by URL or file:line (#632). The guard must also fail a declared name that no longer resolves. That half keeps the registry honest.
The mutation that proves it
Tighten /api/v1/customers to AdminOnly in Program.cs and change nothing under Mcp/. The guard must go red. That is the whole point: drift detection firing from outside the code it protects.
Replaces a weaker approach
This supersedes hand-listing expected tool sets per role. That list is a remembered one, and AGENTS.md says a registry guard exists to stop anyone trusting such lists.
Re-check after milestone 10, 2026-10-10. Role parity stays as written: REST routes keep their role policies. Scope has nothing to mirror, because no REST route declares one. Add a separate scope walk:
every tool carries exactly one scope policy;
a tool with ReadOnly == false requires daily-entries:write, and every other tool requires farm:read;
every value in OAuthScopes.All is used by at least one tool.
Pair it with a causal test: a token holding only farm:read sees no write tool in tools/list and is refused if it calls one.
Amendment, 2026-10-11. #1209 (#807) shipped most of this slice:
[MirrorsRoute] (src/Cluckwork.Api/Mcp/MirrorsRouteAttribute.cs) and McpRouteParityTests. The test walks every tool, resolves the route by WithName() from the live EndpointDataSource, and runs both sides' policies through the live authorization service against one principal per role. A tool must admit no role its route denies. A missing attribute and a name that no longer resolves are both findings.
Still open, and folded into #808 by the maintainer's decision:
[MirrorsRoute] takes one route name. The issue asks for several, with the tool's roles contained in the intersection of the routes' roles.
Activity
mforce commentedon Oct 10, 2026
Re-check after milestone 10, 2026-10-10. Role parity stays as written: REST routes keep their role policies. Scope has nothing to mirror, because no REST route declares one. Add a separate scope walk:
ReadOnly == falserequiresdaily-entries:write, and every other tool requiresfarm:read;OAuthScopes.Allis used by at least one tool.Pair it with a causal test: a token holding only
farm:readsees no write tool intools/listand is refused if it calls one.mforce commentedon Oct 11, 2026
Amendment, 2026-10-11. #1209 (#807) shipped most of this slice:
[MirrorsRoute](src/Cluckwork.Api/Mcp/MirrorsRouteAttribute.cs) andMcpRouteParityTests. The test walks every tool, resolves the route byWithName()from the liveEndpointDataSource, and runs both sides' policies through the live authorization service against one principal per role. A tool must admit no role its route denies. A missing attribute and a name that no longer resolves are both findings.Still open, and folded into #808 by the maintainer's decision:
[MirrorsRoute]takes one route name. The issue asks for several, with the tool's roles contained in the intersection of the routes' roles.[MirrorsNoRoute(reason)]does not exist yet. feat(mcp): read tools for flocks, stock, egg lots and daily entries #1209 deferred it until a tool needs it./api/v1/customerstoAdminOnlyand change nothing underMcp/. The guard must go red. This needs MCP slice 5: read tools — customers, orders, balances, with money rendered at the boundary #808's customer tools.The PR for #808 will close this issue too.
feat(mcp): mirror several routes per tool and walk tool scopes