Skip to content

feat: multi-tenant organizations with a project role ladder (owner/manager/editor/viewer) - #38

Open
amal66 wants to merge 4 commits into
upstream-mainfrom
upstream-pr/organizations-rbac
Open

feat: multi-tenant organizations with a project role ladder (owner/manager/editor/viewer)#38
amal66 wants to merge 4 commits into
upstream-mainfrom
upstream-pr/organizations-rbac

Conversation

@amal66

@amal66 amal66 commented Jul 17, 2026

Copy link
Copy Markdown
Owner

Design (ADR)

Context. The app has been strictly per-user since the baseline: every data table carries a user_id anchor and access is "row owner OR email in shared_with". A law firm is not one user — firms need a tenant boundary, roles inside it, and content that is visible to colleagues without emailing a share for every row. The fork (amal66/mike) built this as a full feature on its main branch; this PR ports it onto the upstream layout — and gives it the permission model the first revision only promised (see "Permissions model" below).

Decision. Introduce a tenant layer without disturbing the per-user anchor:

  • organizations — a tenant. personal = true marks the auto-provisioned one-per-user org every account gets (signup trigger + backfill), so single-user usage is completely unchanged: content simply lands in the caller's personal org.
  • org_members(org_id, user_id, role) with role in ('owner','admin','member'). This is the RBAC edge: owner/admin manage the org, its members and teams (only an owner can grant the owner role); member gets read access to org content. Last-owner protection prevents demoting/removing the sole owner.
  • teams / team_members — structural intra-org grouping (membership + naming); finer team-scoped permissions are a deliberate future extension point.
  • org_id on projects / documents / workflows / tabular_reviews as a nullable FK with ON DELETE SET NULLuser_id remains the hard CASCADE anchor, so account deletion works exactly as before and dropping an org never orphan-deletes user rows.
  • Access stays three branches evaluated in precedence — (1) row owner, (2) shared_with email, (3) org membership — but each branch now derives a project role (owner / manager / editor / viewer), and routes gate on a single capability matrix instead of ad-hoc isOwner checks. isOwner keeps meaning "row owner"; canManage is now derived from the matrix. The four overview RPCs gain the same third branch in SQL so list views and detail endpoints can never disagree.
  • New tables ship with RLS enabled + anon/authenticated revoked (default-deny for direct clients; the API runs with the service key and enforces access in code, matching the existing posture).

Consequences. Existing installs are migrated in place: the backfill gives every user a personal org and stamps their existing rows, so nothing becomes invisible when the org branch lands. Org membership grants visibility, not ownership — and unlike the first revision of this PR, that promise is now enforced, not just documented: plain org members are read-only, and destructive/structural operations require manager+ (see the matrix). Account deletion tears down the user's org footprint (personal org dropped; sole-ownership of shared orgs handed off to the earliest remaining member; empty orgs removed), and the GDPR export includes the user's orgs/teams/memberships.

Alternatives considered. (a) Overloading shared_with with group emails — no roles, no tenant boundary, O(members) writes per row. (b) Making org_id NOT NULL with CASCADE — breaks system workflows (null user_id) and turns org deletion into content deletion. (c) SSO/SCIM-first provisioning — intentionally out of scope; organizations is shaped to grow sso_config/scim_token columns and an org_invitations table, and the role CHECK can gain roles without a table rewrite. (d) Per-folder/per-document ACL overrides (full Google Drive "My Drive" semantics) — rejected for now: permissions attach at the container root and inherit, like Drive shared drives and legal-matter workspaces; per-item overrides add a lot of model complexity for little demand at this scale.

Permissions model

Every access branch resolves to one project role, and every route declares the capability it needs via can(role, capability) (backend/src/lib/permissions.ts — the whole policy is one table, exhaustively unit-tested). This generalises Open-Legal-Products#193 (owner-only folder delete) from a one-route fix into the policy itself; the same missing-gate class existed on ~ten other destructive/structural routes, all closed here.

Capability viewer (org member) editor (shared_with) manager (org owner/admin) owner (row owner)
View/download docs, read chats & reviews, watch generation streams
Upload docs, push versions, chat, accept/reject edits, run/generate reviews
Rename/move documents, create folders
Rename/move/delete folders, edit review title/columns/document set, clear cells
Edit sharing & project metadata
Delete the project/review itself

The editor/manager line is the load-bearing one (Drive's writer vs. fileOrganizer): content collaboration stays broad, structural/destructive power is narrow. Deleting containers stays owner-only so org admins can curate without being able to erase a colleague's project.

Behaviour changes vs. upstream main, all disclosed:

  • Tightened: shared (shared_with) collaborators can no longer rename/move/delete folders (deletion was already owner-gated by Restrict project folder deletion to owners Open-Legal-Products/mike#193; rename/move had no gate), edit a review's title/column set/document set (removing a document deletes its cells; for title/document set this matches Require review owner for tabular settings edits Open-Legal-Products/mike#175 exactly, generalised to the org tier), or clear extracted cells. They keep full content collaboration (uploads, versions, chat, generation, doc rename/move).
  • Tightened: plain org members are read-only on org content, per the ADR. (In the first revision of this PR they would have inherited every shared-member power, including the destructive ones above.)
  • Widened: org owners/admins can now manage projects in their org — metadata, sharing, folder structure, review structure — without owning the row (PATCH /projects/:id drops its user_id filter in favour of the manager gate). Container deletion is not widened.
  • Fixed: GET /projects/:id/people previously 404'd for org members who could read everything else about the project; the roster now follows project.view. GET /projects/:id also routes through checkProjectAccess instead of a hand-rolled inline check.
  • Surfaced: project and review detail responses now include access_role alongside is_owner, so a client can render per-role affordances instead of re-deriving policy from one boolean.

Summary

A firm is not one user. This PR adds multi-tenant organizations with owner/admin/member roles: every account gets a personal org automatically (so nothing changes for individuals), firms can create shared orgs, add colleagues by email, group them into teams, and everyone in the org can see the org's projects, documents, workflows and tabular reviews — with a four-tier project role ladder (owner/manager/editor/viewer) and a single capability matrix deciding who can change what.

Changes

  • Migrations (backend/migrations/, upstream naming convention):
    • 20260717_01_organizations_rbac.sql — org/RBAC schema, org_id columns + indexes, signup-trigger extension, RLS + grant hardening.
    • 20260717_02_backfill_personal_orgs.sql — idempotent personal-org + membership + org_id backfill for existing data.
    • 20260717_03_org_overview_rpcs.sql — org-membership branch added to get_workflows_overview, get_chats_overview, get_projects_overview, get_tabular_reviews_overview.
    • backend/schema.sql updated to match (tables, columns, trigger, RPCs).
  • Permissions layer: backend/src/lib/permissions.tsProjectRole, Capability, can(); the role×capability policy as one data table. backend/src/lib/access.ts derives projectRole on every branch of checkProjectAccess / ensureDocAccess / ensureReviewAccess (row owner → owner, shared email → editor, org owner/admin → manager, org member → viewer).
  • Route sweep: every write route under /projects, /single-documents, /tabular-review, plus project chat and chat-in-project creation, now declares its needed capability. Read routes stay at project.view.
  • Org REST module: backend/src/routes/orgs.ts (thin handlers, {detail} error bodies) + backend/src/lib/orgs.ts (service layer enforcing the role model), mounted at /orgs in app.ts. Endpoints: org CRUD/list, member add/update/remove (by email), team CRUD + team membership.
  • Tenant stamping on create: projects (explicit org_id validated against membership, else personal org), document uploads/copies/project-assignment, tabular reviews (inherit project org), workflows (personal org). Access-check loads now select org_id.
  • Account deletion / export: deleteUserOrganizations (personal-org teardown, sole-owner handoff) wired into deleteAllUserData; orgs/teams/memberships added to the user data export.
  • Tests: permissions.test.ts (the full role×capability matrix, cell by cell, plus fail-closed on unknown roles), access.test.ts (role derivation on all four branches, cross-tenant denial), orgs.test.ts (service RBAC), userDataCleanup.orgs.test.ts (org teardown/handoff), and route-level gate coverage in the existing integration suites (folder-delete tier walk, review clear-cells/columns gates).

No frontend changes are required (everything is additive; access_role is new, is_owner unchanged). Teaching the web UI to use access_role instead of is_owner is a natural follow-up PR.

Why

Multi-tenant RBAC is the difference between "a tool a lawyer uses" and "a tool a firm can adopt": tenant isolation is enforced in one shared code path (access.ts + RPCs in lockstep), roles come with escalation guards (admins cannot mint owners), and the personal-org design means zero migration burden for existing single users. The capability matrix keeps it honest: without it, adding a colleague to your org would silently grant them destructive power over every project in it — the exact bug class Open-Legal-Products#193 just fixed for shared_with, at tenant scale. No new runtime dependencies. No new always-on cost: the org branch only adds queries on the access paths that already hit the database.

Testing

Rebased on current main (post-Open-Legal-Products#193/Open-Legal-Products#175/Open-Legal-Products#228Open-Legal-Products#238), so totals include the merged vitest harness and route suites:

  • cd backend && npm ci && npx tsc --noEmit → clean.
  • cd backend && npx vitest run307 passed | 5 skipped (23 files), including 30 permission-matrix cells, role-derivation tests on all four branches, and the new route-gate cases (folder-delete allowed for owner/manager, blocked for editor/viewer; clear-cells manager gate; columns manager gate).
  • Runtime smoke: server boots and /health responds.

Provenance

The schema, migrations, org module, cleanup/export wiring and tenant stamping are mechanical ports of amal66/mike@origin/main (b3166dd) — path moves (apps/api/src/modules/orgs/*backend/src/{routes,lib}/orgs.ts), import rewrites, and re-application of the fork's org hunks onto upstream's route files. Exceptions, all mechanical adaptations to upstream's conventions:

  • Migrations renamed to upstream's YYYYMMDD_NN_name.sql convention and dated 20260717 so they sort after existing migrations (fork names: 20260701000000/1/2_*). Comment cross-references to fork-only migrations adjusted.
  • ::text casts in the backfill and RPC org clauses: upstream stores content-table user_id as text while organizations.created_by/org_members.user_id are uuid FKs (the fork migrated its ids to uuid; upstream has not). RPC bodies otherwise reproduce upstream's current definitions plus the fork's org branches verbatim; the fork's unrelated drift (result caps / lower() email normalization from other fork migrations) was NOT carried in.
  • handle_new_user extends upstream's current email-mirror version (20260703_01) with the fork's org-provisioning block (the fork's own merged version, verbatim).
  • Route-layer hunks applied to upstream's routes/*.ts instead of the fork's modules/* split; the fork's filename column on document inserts was not carried (upstream dropped documents.filename in 20260602_04).
  • lib/access.ts started from the fork's file (a direct descendant of the upstream file); one entangled fork hunk includes a defensive userEmail.toLowerCase() in listAccessibleProjectIds (a no-op upstream — requireAuth already lowercases).
  • userDataCleanup.orgs.test.ts drops the fork's vi.mock("../env") (fork-only zod env module; upstream reads process.env).
  • The permissions layer is NOT a portpermissions.ts, the projectRole derivation, the route capability sweep and their tests are new code written for this PR after review discussion, closing the gap between the ADR's stated policy ("visibility, not ownership") and what the first revision actually enforced. The fork will adopt the same model.

Credits & prior art

  • @Chris-o-O (Chris-o-O/mike-explor) — independently parallels this work: their fork built organizations and team management on top of Mike. Different implementation (this PR's personal-org anchor, RBAC roles and RPC lockstep are the fork's own design), same conviction that a firm is not one user.
  • The role ladder follows Google Drive's separation of content editing from structure management (writer vs. fileOrganizer) and matter-workspace conventions from legal AI tools: private by default, container-rooted inheritance, admin oversight without container deletion.

🤖 Generated with Claude Code

Reference: the fork-side ADR PR is #38 (same branch, kept for provenance).

…-review-owner-settings

Require review owner for tabular settings edits
@amal66
amal66 force-pushed the upstream-pr/organizations-rbac branch from 0a9434f to 64c75cc Compare July 26, 2026 23:56
@amal66 amal66 changed the title feat: multi-tenant organizations with owner/admin/member roles (ADR) feat: multi-tenant organizations with a project role ladder (owner/manager/editor/viewer) Jul 26, 2026
@amal66

amal66 commented Jul 27, 2026

Copy link
Copy Markdown
Owner Author

Raised upstream as Open-Legal-Products#267 (same branch content, rebased on current upstream main). Keeping this PR open as the fork-side ADR/provenance reference per the Open-Legal-Products#205 pattern.

amal66 and others added 2 commits July 26, 2026 17:02
Introduce an organizations tenant layer on top of the existing per-user
model: every account gets an auto-provisioned personal org, orgs carry
owner/admin/member RBAC via org_members, and teams group members inside
an org. projects/documents/workflows/tabular_reviews gain a nullable
org_id (ON DELETE SET NULL) so org membership becomes a third access
branch alongside row ownership and shared_with emails — in the access
helpers, the overview RPCs, and the org-aware /orgs REST module.

Mechanical port of the organizations/RBAC feature from amal66/mike@main
(b3166dd) onto the upstream layout.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CEguyEgXa9JjCciXCcVemC
…nches

Give the three access branches a Drive-style role ladder instead of raw
ok/isOwner flags: row owner → owner, shared_with email → editor, org
owner/admin → manager, plain org member → viewer. A single capability
matrix (lib/permissions.ts) maps roles to what routes may do — view,
content.edit, docs.organize, structure.manage, members.manage,
container.delete — and every project/document/review write route now
declares the capability it needs instead of hand-rolling an owner check.

This makes the ADR's 'org membership grants visibility, not ownership'
promise real: plain org members are read-only (previously the org branch
returned ok:true and most write routes gated on nothing beyond ok), and
org owner/admins can curate content (manage folders, sharing, review
structure) without being able to delete containers they don't own.

Notable tightenings, all fail-closed:
- folder rename/move/delete, doc-set/column edits on reviews, and
  clear-cells are manager+ (generalising the owner-only folder-delete
  gate that landed upstream in Open-Legal-Products#193)
- version pushes, edit resolution, chat, and review generation are
  editor+ (org viewers excluded)
- project PATCH (metadata + sharing) is manager+, so org admins can
  manage without owning; project/review DELETE stays owner-only
- GET /projects/:id and /people now go through checkProjectAccess (the
  roster previously 404'd for org members who could read the project)

Detail responses expose access_role alongside is_owner so the client
can render per-role affordances. can() is exhaustively unit-tested
(role × capability), and route suites cover the new gates.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@amal66
amal66 force-pushed the upstream-pr/organizations-rbac branch from 64c75cc to 2f0bf7d Compare July 27, 2026 00:03
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants