(motir-core) An org-scoped READ arm on `workspace` — the org's TRUE workspace count, readable inside a user-bound org transaction
The prerequisite MOTIR-3501 needs and cannot supply itself: make the organization's own workspace rows readable to a user who is bound to that organization, so a count-1 predicate inside organizationsService.addMember reads the ORG's shape rather than the ACTOR's.
The gap, measured
addMember runs inside withOrgContext, which binds app.user_id + app.organization_id and no app.workspace_id. Under that binding, a probe on origin/main @ d32892bd (org with two workspaces, actor a member of one) returned:
TRUE org workspace count = 2
SEEN under withOrgContext(founder) = 1
workspace carries five SELECT policies and not one of them admits "the bound org's rows, to a member of that org":
| policy | why it cannot fire here |
|---|---|
workspace_active | id = app.workspace_id — unbound in an org context |
workspace_membership_visible | the caller's OWN memberships — this is the arm that fires, and it is the actor's view |
workspace_public_project_read | requires coalesce(app.workspace_id,'') = '' and a public project |
workspace_system_read | app.system_admin = 'true' |
workspace_org_service_read (20260818010000) | org-keyed, but requires coalesce(app.user_id,'') = '' — the userless service context |
githubRepoRepository.ts:285-305 records the same finding independently and routes around it via github_repo, noting that widening workspace's RLS "would be a cross-tenant access change, which is not this card's to make." This card is that change, made deliberately and on its own.
The change
One migration adding a sixth permissive SELECT policy on workspace. Permissive policies OR-combine, so nothing admitted today is admitted less:
CREATE POLICY "workspace_org_member_read" ON "workspace"
FOR SELECT
USING (
"organizationId" = current_setting('app.organization_id', true)
AND EXISTS (
SELECT 1 FROM "organization_membership" m
WHERE m."organizationId" = "workspace"."organizationId"
AND m."userId" = current_setting('app.user_id', true)
)
);
- The EXISTS is reachable.
organization_membershipis itself RLS'd, andorg_membership_visible_active_or_own(20260613120000) admits"organizationId" = current_setting('app.organization_id')— bound here. The shippedworkspace_membership_visiblepolicy already proves a subquery against an RLS'd table works in this position. - Fails closed on every unbound axis, the house pattern: with no org bound,
current_settingreturns NULL, the comparison is NULL, the row is refused. - SELECT only. UPDATE/DELETE stay on
workspace_mutate_active/workspace_delete_active(the active-workspace GUC) — org membership must not become a licence to rename or delete a workspace you are not in. - Write the migration header in the house style — the two neighbouring RLS migrations (20260818010000, 20260815200000) both open with the defect, the inventory of existing policies, why the obvious fix is wrong, and the fail-closed argument. Match that.
What this DELIBERATELY widens, and the argument for it
Any org member — not only an owner/admin — can now enumerate the workspace rows of the org they belong to (id, name, slug, timestamps). It does not grant reach into any workspace's contents: project, work_item, workspace_membership and every scoped table keep their own policies, all of which still require the workspace GUC or an actual membership.
This is already the product's stated model — docs/decisions/organization-tier.md §4 makes the org the root tenancy tier and org membership the gate beneath which workspaces sit — and it is already what the org surfaces show: summarizeOrgFootprint and the org roster both intend the org's workspaces and merely under-deliver today. Restricting the arm to owner/admin was considered and rejected: addMember's caller is already assertOrgAdmin-gated, so the narrower policy would buy nothing at this call site while leaving the same wrong answer on the two roster surfaces below.
Acceptance criteria
- A migration under
prisma/migrations/adds exactly one policy,workspace_org_member_read,FOR SELECTonworkspace. No existing policy is altered or dropped. - Under
withOrgContext({ userId, organizationId }),workspaceRepository.listByOrganization(orgId, tx)returns every workspace of that org — asserted against a fixture where the org has two workspaces and the acting user is a member of exactly one (the shape that currently returns 1). The same call returns 2 for a member of neither. workspaceRepository.countByOrganization(orgId, tx)under the same binding returns the org's true count.- A user who is not an org member reads zero of that org's workspaces under a context binding that org — the policy's EXISTS is asserted, not assumed.
- With no org bound (
app.organization_idunset), the new arm admits nothing: a workspace-context read returns exactly the rowsworkspace_membership_visiblealone admits — the caller's own memberships — so the policy set behaves as it did before this migration. (Amended 2026-08-26, on the record: this criterion read "returns exactly what it returns onmain", whichvalidate_work_itemflagged aslikely-ordering-violationon the phrase "on main". It was a BASELINE reference rather than a post-merge read, so the check is kept and the ambiguous phrasing removed — see this card's advisory-disposition comment.) - The arm is SELECT-only: an UPDATE and a DELETE of a workspace the actor reaches only through this policy are both still refused.
- The assertions run under the non-bypass runtime role, so a policy that is never consulted cannot pass them. Since MOTIR-2734 retired
TEST_DB_APP_ROLE,currentWorkerUrl()returns the app-role credentials unconditionally — there is no mode in which these assertions silently run as the owner, and fixtures useadminDb. summarizeOrgFootprint's comment — "the actor's workspaces in the org" — is corrected, since after this card it is the org's.
Context refs
prisma/migrations/20260527134009_add_workspace_rls/migration.sql—workspace_active,workspace_membership_visible, and the mutate/delete pair this must NOT touchprisma/migrations/20260818010000_attachment_org_service_read_arm/migration.sql— the userless org arm, and the header style to matchprisma/migrations/20260613120000_add_organization_tier/migration.sql:217—org_membership_visible_active_or_own, what makes the EXISTS reachablelib/organizations/context.ts—withOrgContext(the binding this arm is written for)lib/repositories/workspaceRepository.ts:44(listByOrganization),:97(countByOrganization)lib/services/organizationsService.ts—summarizeOrgFootprintand the cross-workspace roster, the two shipped readers this silently correctslib/repositories/githubRepoRepository.ts:285-305— the independent record of the same gapdocs/decisions/organization-tier.md§4 (org as root tenancy tier)tests/rls/policyArms.ts,tests/rls/org-context-arm-guard.test.ts— the arm inventory, its documented blind spot, and the negative control this arm makes two-named
Repo: every criterion above is discharged in motir-core (a migration, a repository read, a vitest suite). No criterion names a path in another repository.