ADR-0056: Access asked in every workspace method¶
Status: Accepted Date: 2026-10-12 Deciders: Alex Nodeland
Context¶
ADR-0044 put authorization within a tenant behind one hook, authorize(tenant_id, workspace_id, actor), which the router and ReflexrMcp each took and passed to Workspaces.open. ADR-0053 gave stored rules a second hook, StoredRules.allow, asked after it with the RuleChange. Three gaps followed:
- The hook never heard the action. Anyone who could use a workspace could operate its runs and replay its rules; the security guide told applications to check commands in their own routes.
- Two hooks for one question. "May this actor install this rule here?" was split between
authorizeandallow, and a surface could be given one without the other. - The reads across a tenant skipped it.
GET /rules,GET /schedulesandlist_rulesnamed no workspace, so ADR-0016's amendment let every authenticated client read them, and an application that must hide them had to unmount the routes.
lattice RFC-0001 widens the hook so it hears the action, asks it for every command and read, and lets grantr answer it. Its phase 1 is this change; artifactr's ADR-0056 is its twin.
Decision¶
Access,Authorize,OnOpenandACTIONSlive inreflexr.workspace:Access(action, rule=None)is what an actor is about to do. reflexr has no threads or artifacts, so it hasrulein their place: a rule command's rule, or the rule a read names.Authorize = Callable[[TenantId, WorkspaceId | None, Actor, Access], Awaitable[bool]].OnOpen = Callable[[TenantId, WorkspaceId], Awaitable[None]].ACTIONSis the complete catalog, asreflexr:<name>. A command's action is itstype(reflexr:retry_run,reflexr:install_rule), a read's isread_and what it reads (reflexr:read_runs,reflexr:read_dead_letters,reflexr:read_rules,reflexr:read_schedules), and opening a workspace isreflexr:open. An action's progress isreflexr:checkpoint_run, and a consumer's place in the logreflexr:save_cursorandreflexr:read_cursors.
Workspacesholds the hooks:Workspaces(storage, ..., authorize=, on_open=).Workspaces.open,reflexr_routerandReflexrMcpno longer takeauthorize.- Every public
Workspacemethod asks first, as the handle's actor, throughWorkspace.check(access), which raisesForbidden("reflexr:retry_run is refused in this workspace").publishasks throughpublish_many.rule_statusesandschedule_statusesread storage themselves, so each asks once, forreflexr:read_rulesandreflexr:read_schedules; a rule status's dead-letter count and lag are read underreflexr:read_rules, notreflexr:read_dead_lettersandreflexr:read_events. - The reads across a tenant go through
Workspaces:rules_for(tenant_id, actor=)andschedules_for(tenant_id, actor=)ask with no workspace ("reflexr:read_rules is refused in this tenant").GET /rules,GET /schedulesandlist_rulescall them. This amends ADR-0016's amendment: an application can now keep rule and schedule definitions from some members. StoredRules.allowis folded in, andRuleChangeremoved. Installing, updating, archiving and replaying a rule are commands like any other, whichauthorizehears with the rule's name.StoredRuleskeepsactionsandnamespaces: what a stored rule may do, not who may store it. WithoutStoredRules, a change is stillforbidden, as is one to a code rule's name, after the hook is asked.- Opening asks
reflexr:open, then callson_open. Its callees are idempotent, so a permission check has no side effects. - The reactor's own work stays beneath the hook. Evaluating rules, claiming runs, ticking schedules and reading a workspace's rules (
Workspaces.rules_in) work on storage, not on a handle, as they always have. A run's action opens its workspace as its rule'sAgentActor, which every method then asks as. - A run that
authorizerefuses fails for good. An action's run opens its workspace as its rule'sAgentActor; a refusal there, or of anything the action then does (reflexr:publish,reflexr:checkpoint_run), fails the attempt permanently, with the reasonforbidden, since a retry would be refused too, and spoils no other run of the batch. Anon_openthat raises fails the attempt, to retry. - The MCP server follows a workspace's log as itself,
SystemActor(name="mcp"), not as the client whose tool call first opened it. - A cursor saved with a command is covered by that command's check; only
save_cursorasksreflexr:save_cursor. - A WebSocket that may not read the log is closed with 4403 before
welcome. - A permission-matrix test (
tests/test_access.py) lists every publicWorkspacemethod with theAccessit asks for, the reads across a tenant, and every REST route, WebSocket command, and MCP tool and resource with what its caller is asked for. It calls each with a hook that records what it is asked. A method or surface missing from the tables, an action outsideACTIONS, or an action inACTIONSnothing asks for fails it. What asks nothing on purpose (as_actor,caused_by,check,execute,rules_in, and listing MCP's tools and resource templates) is listed with its reason.
Options considered¶
Option A: one hook, asked in every method, with the rule in Access (chosen)¶
| Dimension | Assessment |
|---|---|
| Complexity | Low: one check call per method, and allow deleted |
| Coverage | Every surface and in-process call; the reads across a tenant too |
Pros: one question, one hook, one place per method; grantr can grant rule management to a role.
Cons: every application's authorize changes shape.
Option B: keep allow beside a widened authorize¶
Rejected by the RFC (D1): three hooks in reflexr, and a surface can forget one.
Option C: leave the reads across a tenant open¶
Pros: no workspace-less check to explain. Cons: rule definitions can't be hidden from some members, and the matrix would carry a hole by design.
Trade-off analysis¶
Folding allow into authorize loses nothing it said: the command is the action, and the rule its Access.rule. The new rule itself is no longer handed to the hook; what it may do is StoredRules' allowlists and core's limits, which the hook was never meant to judge. The cost of a check per method is one hook call, which grantr answers with one indexed query.
Consequences¶
- Easier: one hook decides every command and read, and who may manage rules is a grant like any other: a workspace admin's, in grantr's built-in roles.
- Easier: an application can hide rule and schedule definitions from some members.
- Harder: every application's
authorizetakes theAccessand moves toWorkspaces, andStoredRules(allow=...)is gone: breaking changes, released as a minor version before 1.0 (lattice ADR-0006). - Harder: a subscription is checked when it opens; events keep flowing after a revocation until it closes.
- Harder: with
RuleChange.specgone, the hook no longer sees the rule being installed, so a policy can't refuse a rule by its content; onlyStoredRules' allowlists and core's limits judge that. - Revisit: the rule a
retry_run,skip_runorcancel_runis about, which needs the run loaded first, when grants on rules need it.
Action items¶
-
Access, the widenedAuthorize,OnOpenandACTIONS, held byWorkspaces. - A check in every
Workspacemethod;rules_forandschedules_forwith no workspace. -
StoredRules.allowandRuleChangeremoved. - The permission-matrix test.
- grantr answers the hook (lattice RFC-0001, phases 2 to 4).