ADR 0001 – Read access: deny list on the actor, “not found”, enforced in the services
Status: accepted – introduced in #52 (Read access control: restricted project groups), review fixes in #55. Research and the full reasoning: Read permissions for project groups.
Context
Until #52 every signed-in user could read every item, file and change; project groups only limited editing. Some groups have to be invisible to non-members, without making every object carry its own access list. The same data reaches users through the web UI, the REST API, the MCP server, the assistant, notifications, e-mail, Mattermost and webhooks.
Decision
- The project group is the boundary. A group is
internal(everybody reads, members edit) orrestricted(only members – of any role – and administrators read). Columnprojects.visibility, migration0024_restricted_projects.sql. - A deny list on the actor. The signed-in
Actor(src/auth.rs) carrieshidden_projects: the restricted groups it may not read, empty for administrators. It is never serialized. - Hidden looks like missing. A hidden item, revision, file, change or group answers exactly like one that does not exist (404 / “not found”), like a private GitHub repository.
- Links across the boundary stay visible as placeholders when they go out of a visible object (a BOM line, a reference: “restricted item”, quantity kept) and are dropped and only counted when they come in (where-used, referrers: “+N in groups you cannot see”).
- One place writes the SQL guard.
src/services/access.rsbuilds every guard (item_visible,project_visible,change_visible, …) that bindshidden_projects; read services take&Actorand use them. The*_uncheckedgetters (no read check) are for background and system code. - Side channels – notifications, e-mail, Mattermost, webhooks, the assistant, the MCP server – apply the same rule per recipient.
Consequences
- Every read service needs the actor; a forgotten guard is a leak, so the rule is held by tests rather than by review alone (below).
- Lists that leave entries out say how many (
X-Hidden-Countin the REST API, “+N …” in the UI). - Copying out of a restricted group declassifies content, so Save As across
the boundary is limited to managers of the source group (
items::ensure_may_take_out, used byservices::save_as). - A need-to-know policy (
project_policies.admin_read, #151 step 1) fills the hidden groups of administrators too (access::hidden_of_user): the guards stay the only place that decides. Administrators keep maintaining such a group (projects::get_for_management,require_manager) without reading it; elevations are rows inproject_elevations, never deleted. - Interface switches (
project_policies.api_tokens,mcp,assistant,webhooks,packages, #151 step 3) also feed the hidden groups, per channel:project_policy::narrowhides the denied groups from the actor of that channel (Actor::without_groups), webhook outbox inserts check the switch in SQL. The read log (access_log, step 4) is a separate table written byservices::access_log;tests/interface_switches.rsreads the sources and fails when an interface lacks its check. - Conditions on membership (#151 step 2) feed the hidden groups as well, for
everybody including administrators: the claims of the last single sign-on
and the last sign-in’s age are part of
access::hidden_of_user(membership_conditions::unmet_sql), so notification recipients and tokens are covered; the client network and the web session’s age depend on the request and are applied once by theWebUser/ApiCallerextractors (membership_conditions::apply,Actor::without_groups) and again at each assistant step. A member hidden this way is no member for that request (Actor::projectsdrops the group). - There are no per-object ACLs and no per-membership access levels; a read-only member is a member with the global viewer role.
Alternatives turned down
From the research note:
- PostgreSQL row-level security – RLS can only hide rows, while TeamCraft needs placeholders and counts; and per-request session state on a shared connection pool is risky.
- Per-object ACLs and field-level rules (as in large PDM systems) – hard to audit, not needed for “not everybody may see every repository”.
- 403 for hidden objects – would confirm that the object exists.
- Onshape-style view / copy / export levels and token scopes are listed as possible follow-ups, not adopted.
Where it is held
| What | Where |
|---|---|
| Deny list on the actor | Actor::hidden_projects, Actor::can_see_project in src/auth.rs |
| The only SQL guard writer | src/services/access.rs |
Handlers never call *_unchecked; nobody but access writes a guard | tests/no_unchecked_in_handlers.rs |
| Every web route, REST route and MCP tool leaks nothing to an outsider | tests/read_permissions.rs (routes read from the router sources) |
| Handlers never touch SQL themselves | proposed in #96 (tests/architecture_guards.rs) |