feat: multi-tenant organizations with a project role ladder (owner/manager/editor/viewer) - #267
Open
amal66 wants to merge 2 commits into
Open
feat: multi-tenant organizations with a project role ladder (owner/manager/editor/viewer)#267amal66 wants to merge 2 commits into
amal66 wants to merge 2 commits into
Conversation
amal66
force-pushed
the
olp-pr/organizations-rbac
branch
from
July 27, 2026 00:03
64c75cc to
2f0bf7d
Compare
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
force-pushed
the
olp-pr/organizations-rbac
branch
from
August 3, 2026 01:57
2f0bf7d to
4d587b9
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Design (ADR)
Context. The app has been strictly per-user since the baseline: every data table carries a
user_idanchor and access is "row owner OR email inshared_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 = truemarks 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)withrole 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_idonprojects/documents/workflows/tabular_reviewsas a nullable FK with ON DELETE SET NULL —user_idremains the hard CASCADE anchor, so account deletion works exactly as before and dropping an org never orphan-deletes user rows.shared_withemail, (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-hocisOwnerchecks.isOwnerkeeps meaning "row owner";canManageis 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.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_withwith group emails — no roles, no tenant boundary, O(members) writes per row. (b) Makingorg_idNOT NULL with CASCADE — breaks system workflows (nulluser_id) and turns org deletion into content deletion. (c) SSO/SCIM-first provisioning — intentionally out of scope;organizationsis shaped to growsso_config/scim_tokencolumns and anorg_invitationstable, 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 #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.shared_with)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:shared_with) collaborators can no longer rename/move/delete folders (deletion was already owner-gated by Restrict project folder deletion to owners #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 #175 exactly, generalised to the org tier), or clear extracted cells. They keep full content collaboration (uploads, versions, chat, generation, doc rename/move).PATCH /projects/:iddrops itsuser_idfilter in favour of the manager gate). Container deletion is not widened.GET /projects/:id/peoplepreviously 404'd for org members who could read everything else about the project; the roster now followsproject.view.GET /projects/:idalso routes throughcheckProjectAccessinstead of a hand-rolled inline check.access_rolealongsideis_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
backend/migrations/, upstream naming convention):20260717_01_organizations_rbac.sql— org/RBAC schema,org_idcolumns + indexes, signup-trigger extension, RLS + grant hardening.20260717_02_backfill_personal_orgs.sql— idempotent personal-org + membership +org_idbackfill for existing data.20260717_03_org_overview_rpcs.sql— org-membership branch added toget_workflows_overview,get_chats_overview,get_projects_overview,get_tabular_reviews_overview.backend/schema.sqlupdated to match (tables, columns, trigger, RPCs).backend/src/lib/permissions.ts—ProjectRole,Capability,can(); the role×capability policy as one data table.backend/src/lib/access.tsderivesprojectRoleon every branch ofcheckProjectAccess/ensureDocAccess/ensureReviewAccess(row owner → owner, shared email → editor, org owner/admin → manager, org member → viewer)./projects,/single-documents,/tabular-review, plus project chat and chat-in-project creation, now declares its needed capability. Read routes stay atproject.view.backend/src/routes/orgs.ts(thin handlers,{detail}error bodies) +backend/src/lib/orgs.ts(service layer enforcing the role model), mounted at/orgsinapp.ts. Endpoints: org CRUD/list, member add/update/remove (by email), team CRUD + team membership.org_idvalidated against membership, else personal org), document uploads/copies/project-assignment, tabular reviews (inherit project org), workflows (personal org). Access-check loads now selectorg_id.deleteUserOrganizations(personal-org teardown, sole-owner handoff) wired intodeleteAllUserData; orgs/teams/memberships added to the user data export.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_roleis new,is_ownerunchanged). Teaching the web UI to useaccess_roleinstead ofis_owneris 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 #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-#193/#175/#228–#238), so totals include the merged vitest harness and route suites:cd backend && npm ci && npx tsc --noEmit→ clean.cd backend && npx vitest run→ 307 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)./healthresponds.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:YYYYMMDD_NN_name.sqlconvention and dated 20260717 so they sort after existing migrations (fork names:20260701000000/1/2_*). Comment cross-references to fork-only migrations adjusted.::textcasts in the backfill and RPC org clauses: upstream stores content-tableuser_idastextwhileorganizations.created_by/org_members.user_idareuuidFKs (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_userextends upstream's current email-mirror version (20260703_01) with the fork's org-provisioning block (the fork's own merged version, verbatim).routes/*.tsinstead of the fork'smodules/*split; the fork'sfilenamecolumn on document inserts was not carried (upstream droppeddocuments.filenamein 20260602_04).lib/access.tsstarted from the fork's file (a direct descendant of the upstream file); one entangled fork hunk includes a defensiveuserEmail.toLowerCase()inlistAccessibleProjectIds(a no-op upstream —requireAuthalready lowercases).userDataCleanup.orgs.test.tsdrops the fork'svi.mock("../env")(fork-only zod env module; upstream reads process.env).permissions.ts, theprojectRolederivation, 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
🤖 Generated with Claude Code
Reference: the fork-side ADR PR is amal66#38 (same branch, kept for provenance).