MotirBuilding in public
MOTIR · moooon
onMotir
You’re viewing a public project. Anyone can view it — no account needed. Sign in to submit, upvote, or comment on requests.View-only — you can’t edit work items
MOTIR-3512

(motir-core) An org-scoped READ arm on `workspace` — the org's TRUE workspace count, readable inside a user-bound org transaction

Done
Description

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":

policywhy it cannot fire here
workspace_activeid = app.workspace_id — unbound in an org context
workspace_membership_visiblethe caller's OWN memberships — this is the arm that fires, and it is the actor's view
workspace_public_project_readrequires coalesce(app.workspace_id,'') = '' and a public project
workspace_system_readapp.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_membership is itself RLS'd, and org_membership_visible_active_or_own (20260613120000) admits "organizationId" = current_setting('app.organization_id') — bound here. The shipped workspace_membership_visible policy 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_setting returns 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

  1. A migration under prisma/migrations/ adds exactly one policy, workspace_org_member_read, FOR SELECT on workspace. No existing policy is altered or dropped.
  2. 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.
  3. workspaceRepository.countByOrganization(orgId, tx) under the same binding returns the org's true count.
  4. 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.
  5. With no org bound (app.organization_id unset), the new arm admits nothing: a workspace-context read returns exactly the rows workspace_membership_visible alone 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 on main", which validate_work_item flagged as likely-ordering-violation on 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.)
  6. The arm is SELECT-only: an UPDATE and a DELETE of a workspace the actor reaches only through this policy are both still refused.
  7. 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 use adminDb.
  8. 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.sqlworkspace_active, workspace_membership_visible, and the mutate/delete pair this must NOT touch
  • prisma/migrations/20260818010000_attachment_org_service_read_arm/migration.sql — the userless org arm, and the header style to match
  • prisma/migrations/20260613120000_add_organization_tier/migration.sql:217org_membership_visible_active_or_own, what makes the EXISTS reachable
  • lib/organizations/context.tswithOrgContext (the binding this arm is written for)
  • lib/repositories/workspaceRepository.ts:44 (listByOrganization), :97 (countByOrganization)
  • lib/services/organizationsService.tssummarizeOrgFootprint and the cross-workspace roster, the two shipped readers this silently corrects
  • lib/repositories/githubRepoRepository.ts:285-305 — the independent record of the same gap
  • docs/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.