From 58e068273d284ba18d6f313691d9d29a877326cc Mon Sep 17 00:00:00 2001 From: Amal Date: Fri, 17 Jul 2026 00:47:55 -0700 Subject: [PATCH 1/2] feat: multi-tenant organizations with owner/admin/member roles MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_01CEguyEgXa9JjCciXCcVemC --- .../20260717_01_organizations_rbac.sql | 188 ++++++++ .../20260717_02_backfill_personal_orgs.sql | 71 +++ .../20260717_03_org_overview_rpcs.sql | 390 ++++++++++++++++ backend/schema.sql | 178 +++++++- backend/src/app.ts | 2 + backend/src/lib/__tests__/access.test.ts | 118 +++++ backend/src/lib/__tests__/orgs.test.ts | 305 +++++++++++++ .../__tests__/userDataCleanup.orgs.test.ts | 151 +++++++ backend/src/lib/access.ts | 251 +++++++++-- backend/src/lib/orgs.ts | 425 ++++++++++++++++++ backend/src/lib/userDataCleanup.ts | 88 ++++ backend/src/lib/userDataExport.ts | 18 + backend/src/routes/documents.ts | 32 +- backend/src/routes/downloads.ts | 2 +- backend/src/routes/orgs.ts | 235 ++++++++++ backend/src/routes/projects.ts | 39 +- backend/src/routes/tabular.ts | 16 +- backend/src/routes/workflows.ts | 37 +- 18 files changed, 2468 insertions(+), 78 deletions(-) create mode 100644 backend/migrations/20260717_01_organizations_rbac.sql create mode 100644 backend/migrations/20260717_02_backfill_personal_orgs.sql create mode 100644 backend/migrations/20260717_03_org_overview_rpcs.sql create mode 100644 backend/src/lib/__tests__/orgs.test.ts create mode 100644 backend/src/lib/__tests__/userDataCleanup.orgs.test.ts create mode 100644 backend/src/lib/orgs.ts create mode 100644 backend/src/routes/orgs.ts diff --git a/backend/migrations/20260717_01_organizations_rbac.sql b/backend/migrations/20260717_01_organizations_rbac.sql new file mode 100644 index 000000000..f5731d7c8 --- /dev/null +++ b/backend/migrations/20260717_01_organizations_rbac.sql @@ -0,0 +1,188 @@ +-- Migration date: 2026-07-17 + +-- Multi-tenant organizations + RBAC (schema). +-- +-- The app has been per-user since the baseline: every data table carries a +-- `user_id` FK to auth.users and access is "row owner OR email in shared_with". +-- This migration introduces the tenant layer WITHOUT disturbing that anchor: +-- +-- organizations — a tenant. `personal = true` marks the one-per-user org +-- every account gets automatically (so single-user usage is +-- 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` can manage the org +-- and its members; `member` can read/create content. +-- teams / team_members — an intra-org grouping. Teams are a structural +-- grouping today (membership + naming); finer team-scoped +-- permissions are a deliberate future extension point. +-- +-- `org_id` is added to projects/documents/workflows/tabular_reviews as a +-- NULLABLE FK with ON DELETE SET NULL. Nullable because: +-- * system workflows have a null user_id and must stay valid; +-- * `user_id` remains the hard ON DELETE CASCADE anchor, so account deletion +-- still works exactly as before — dropping an org must never orphan-delete +-- a user's rows, hence SET NULL rather than CASCADE here. +-- The backfill migration (20260717_02) populates org_id for existing rows. +-- +-- RLS: every new table gets `enable row level security` + an explicit revoke of +-- anon/authenticated, so direct client roles get nothing. The API runs with the +-- service key and enforces access in code, so it is unaffected. +-- +-- SSO / SAML / SCIM: intentionally NOT implemented here. `organizations` is +-- shaped to grow future `sso_config` / `scim_token` columns and a future +-- `org_invitations` table; `org_members.role` is a text CHECK that can gain +-- roles without a table rewrite. Those are the documented extension points. +-- +-- Does NOT edit any existing migration. + +-- --------------------------------------------------------------------------- +-- organizations +-- --------------------------------------------------------------------------- + +create table if not exists public.organizations ( + id uuid primary key default gen_random_uuid(), + name text not null, + -- personal = the auto-provisioned one-per-user org. Enforced unique per user + -- by idx_organizations_personal_owner below. + personal boolean not null default false, + created_by uuid references auth.users(id) on delete set null, + -- EXTENSION POINT (SSO/SCIM): future migrations may add e.g. + -- sso_config jsonb, scim_token text, domain text + -- here without touching app-layer authz. + created_at timestamptz not null default now(), + updated_at timestamptz not null default now() +); + +-- One personal org per user. Partial unique so non-personal (shared) orgs a +-- user creates are unconstrained. +create unique index if not exists idx_organizations_personal_owner + on public.organizations(created_by) + where personal; + +alter table public.organizations enable row level security; + +-- --------------------------------------------------------------------------- +-- org_members — the RBAC edge +-- --------------------------------------------------------------------------- + +create table if not exists public.org_members ( + id uuid primary key default gen_random_uuid(), + org_id uuid not null references public.organizations(id) on delete cascade, + user_id uuid not null references auth.users(id) on delete cascade, + -- EXTENSION POINT (RBAC): additional roles (e.g. 'billing', 'viewer') can be + -- added to this CHECK; app-layer helpers in access.ts gate on the value. + role text not null default 'member' + check (role in ('owner', 'admin', 'member')), + created_at timestamptz not null default now(), + updated_at timestamptz not null default now(), + unique(org_id, user_id) +); + +create index if not exists idx_org_members_user on public.org_members(user_id); +create index if not exists idx_org_members_org on public.org_members(org_id); + +alter table public.org_members enable row level security; + +-- --------------------------------------------------------------------------- +-- teams / team_members +-- --------------------------------------------------------------------------- + +create table if not exists public.teams ( + id uuid primary key default gen_random_uuid(), + org_id uuid not null references public.organizations(id) on delete cascade, + name text not null, + created_by uuid references auth.users(id) on delete set null, + created_at timestamptz not null default now(), + updated_at timestamptz not null default now(), + unique(org_id, name) +); + +create index if not exists idx_teams_org on public.teams(org_id); + +alter table public.teams enable row level security; + +create table if not exists public.team_members ( + id uuid primary key default gen_random_uuid(), + team_id uuid not null references public.teams(id) on delete cascade, + user_id uuid not null references auth.users(id) on delete cascade, + created_at timestamptz not null default now(), + unique(team_id, user_id) +); + +create index if not exists idx_team_members_user on public.team_members(user_id); +create index if not exists idx_team_members_team on public.team_members(team_id); + +alter table public.team_members enable row level security; + +-- --------------------------------------------------------------------------- +-- org_id on the four content tables (nullable + FK + index) +-- --------------------------------------------------------------------------- + +alter table public.projects + add column if not exists org_id uuid references public.organizations(id) on delete set null; +alter table public.documents + add column if not exists org_id uuid references public.organizations(id) on delete set null; +alter table public.workflows + add column if not exists org_id uuid references public.organizations(id) on delete set null; +alter table public.tabular_reviews + add column if not exists org_id uuid references public.organizations(id) on delete set null; + +create index if not exists idx_projects_org on public.projects(org_id); +create index if not exists idx_documents_org on public.documents(org_id); +create index if not exists idx_workflows_org on public.workflows(org_id); +create index if not exists idx_tabular_reviews_org on public.tabular_reviews(org_id); + +-- --------------------------------------------------------------------------- +-- handle_new_user: also provision a personal org + owner membership +-- --------------------------------------------------------------------------- +-- Extends the email-mirroring trigger from 20260703_01. Still swallows errors +-- (returns new) so a failed org insert never blocks signup — the backfill +-- migration is idempotent and will repair any user left without a personal org. + +create or replace function public.handle_new_user() +returns trigger +language plpgsql +security definer +set search_path = public +as $$ +declare + v_org_id uuid; +begin + insert into public.user_profiles (user_id, email) + values (new.id, lower(new.email)) + on conflict (user_id) do update + set email = excluded.email, + updated_at = now(); + + -- Multi-tenant RBAC: provision a personal organization + owner membership so + -- new content has a tenant to land in (one personal org per user, enforced by + -- idx_organizations_personal_owner). + if not exists ( + select 1 from public.organizations + where created_by = new.id and personal + ) then + insert into public.organizations (name, personal, created_by) + values (coalesce(new.email, 'Personal'), true, new.id) + returning id into v_org_id; + + insert into public.org_members (org_id, user_id, role) + values (v_org_id, new.id, 'owner') + on conflict (org_id, user_id) do nothing; + end if; + + return new; +exception when others then + -- Never block signup if the profile / org insert fails. + return new; +end; +$$; + +-- --------------------------------------------------------------------------- +-- Direct client grant hardening for the new tables +-- --------------------------------------------------------------------------- + +revoke all on public.organizations from anon, authenticated; +revoke all on public.org_members from anon, authenticated; +revoke all on public.teams from anon, authenticated; +revoke all on public.team_members from anon, authenticated; diff --git a/backend/migrations/20260717_02_backfill_personal_orgs.sql b/backend/migrations/20260717_02_backfill_personal_orgs.sql new file mode 100644 index 000000000..634da32bf --- /dev/null +++ b/backend/migrations/20260717_02_backfill_personal_orgs.sql @@ -0,0 +1,71 @@ +-- Migration date: 2026-07-17 + +-- Multi-tenant organizations + RBAC (data backfill). +-- +-- Gives every pre-existing user a personal organization + owner membership, +-- then stamps org_id on their existing projects/documents/workflows/ +-- tabular_reviews so nothing becomes invisible after the RBAC branch is added +-- to the access helpers and overview RPCs. +-- +-- Idempotent: the personal-org insert is guarded by NOT EXISTS (and backed by +-- the unique partial index idx_organizations_personal_owner), the membership +-- insert by NOT EXISTS, and each org_id backfill by `where org_id is null`. +-- Re-running is a no-op. Rows with a null user_id (e.g. system workflows) are +-- deliberately left with a null org_id — they are global, not tenant-scoped. +-- +-- Note: content tables store user_id as text while organizations.created_by is +-- a uuid FK to auth.users, hence the ::text casts in the joins below. + +-- 1. One personal org per existing user that lacks one. +insert into public.organizations (name, personal, created_by) +select + coalesce(nullif(trim(up.display_name), ''), u.email, 'Personal'), + true, + u.id +from auth.users u +left join public.user_profiles up on up.user_id = u.id +where not exists ( + select 1 + from public.organizations o + where o.created_by = u.id and o.personal +); + +-- 2. Owner membership for each personal org. +insert into public.org_members (org_id, user_id, role) +select o.id, o.created_by, 'owner' +from public.organizations o +where o.personal + and o.created_by is not null + and not exists ( + select 1 + from public.org_members m + where m.org_id = o.id and m.user_id = o.created_by + ); + +-- 3. Backfill org_id on the four content tables from each row's owning user's +-- personal org. `where org_id is null` keeps this safe to re-run and never +-- clobbers a row already assigned to a (shared) org. +update public.projects p +set org_id = o.id +from public.organizations o +where o.personal and o.created_by::text = p.user_id + and p.org_id is null; + +update public.documents d +set org_id = o.id +from public.organizations o +where o.personal and o.created_by::text = d.user_id + and d.org_id is null; + +update public.workflows w +set org_id = o.id +from public.organizations o +where o.personal and o.created_by::text = w.user_id + and w.user_id is not null + and w.org_id is null; + +update public.tabular_reviews tr +set org_id = o.id +from public.organizations o +where o.personal and o.created_by::text = tr.user_id + and tr.org_id is null; diff --git a/backend/migrations/20260717_03_org_overview_rpcs.sql b/backend/migrations/20260717_03_org_overview_rpcs.sql new file mode 100644 index 000000000..6cd5481d5 --- /dev/null +++ b/backend/migrations/20260717_03_org_overview_rpcs.sql @@ -0,0 +1,390 @@ +-- Migration date: 2026-07-17 + +-- Multi-tenant organizations + RBAC (list read-models). +-- +-- The four "overview" RPCs re-implement the access logic in SQL for the list +-- views. access.ts now grants a third visibility branch — "row.org_id is in an +-- org the caller belongs to" — alongside "row owner" and "shared_with email". +-- If these RPCs are not updated in lockstep, org-shared rows would be readable +-- via the detail endpoints (which go through access.ts) but invisible in the +-- list views. This migration adds that org-membership branch to each. +-- +-- Implemented as an inline EXISTS against org_members keyed on p_user_id, so no +-- new RPC parameter is needed and existing callers stay unchanged. `is_owner` +-- keeps meaning "row owner" (user_id = p_user_id) — org membership grants +-- visibility, not ownership. +-- +-- Note: content tables store user_id as text while org_members.user_id is a +-- uuid FK to auth.users, hence the ::text casts in the org EXISTS clauses. +-- +-- create-or-replace only; safe to re-run. + +create or replace function public.get_workflows_overview( + p_user_id text, + p_user_email text default null, + p_type text default null +) +returns table ( + id uuid, + user_id text, + title text, + type text, + prompt_md text, + columns_config jsonb, + language text, + practice text, + jurisdictions text[], + is_system boolean, + created_at timestamptz, + allow_edit boolean, + is_owner boolean, + shared_by_name text +) +language sql +stable +as $$ + with owned as ( + select + w.id, + w.user_id::text as user_id, + w.title, + w.type, + w.prompt_md, + w.columns_config, + w.language, + w.practice, + w.jurisdictions, + false as is_system, + w.created_at, + true as allow_edit, + true as is_owner, + null::text as shared_by_name, + 0 as sort_bucket + from public.workflows w + where w.user_id::text = p_user_id + and (p_type is null or w.type = p_type) + ), + shared as ( + select + w.id, + w.user_id::text as user_id, + w.title, + w.type, + w.prompt_md, + w.columns_config, + w.language, + w.practice, + w.jurisdictions, + false as is_system, + w.created_at, + ws.allow_edit, + false as is_owner, + nullif(trim(up.display_name), '') as shared_by_name, + 1 as sort_bucket + from public.workflow_shares ws + join public.workflows w + on w.id = ws.workflow_id + left join public.user_profiles up + on up.user_id::text = ws.shared_by_user_id::text + where lower(ws.shared_with_email) = lower(coalesce(p_user_email, '')) + and (p_type is null or w.type = p_type) + ), + org_shared as ( + -- Workflows in an org the caller belongs to (read-only; edits stay + -- owner/share-gated). Mirrors the org branch in lib/access.ts. + select + w.id, + w.user_id::text as user_id, + w.title, + w.type, + w.prompt_md, + w.columns_config, + w.language, + w.practice, + w.jurisdictions, + false as is_system, + w.created_at, + false as allow_edit, + false as is_owner, + nullif(trim(up.display_name), '') as shared_by_name, + 2 as sort_bucket + from public.workflows w + left join public.user_profiles up + on up.user_id::text = w.user_id::text + where w.org_id is not null + and (w.user_id is null or w.user_id::text <> p_user_id) + and (p_type is null or w.type = p_type) + and exists ( + select 1 from public.org_members m + where m.org_id = w.org_id and m.user_id::text = p_user_id + ) + and not exists ( + select 1 from public.workflow_shares ws + where ws.workflow_id = w.id + and lower(ws.shared_with_email) = lower(coalesce(p_user_email, '')) + ) + ), + visible_workflows as ( + select * from owned + union all + select * from shared + union all + select * from org_shared + ) + select + vw.id, + vw.user_id, + vw.title, + vw.type, + vw.prompt_md, + vw.columns_config, + vw.language, + vw.practice, + vw.jurisdictions, + vw.is_system, + vw.created_at, + vw.allow_edit, + vw.is_owner, + vw.shared_by_name + from visible_workflows vw + order by vw.sort_bucket asc, vw.created_at desc; +$$; + +create or replace function public.get_chats_overview( + p_user_id text, + p_limit integer default null +) +returns table ( + id uuid, + project_id uuid, + user_id text, + title text, + created_at timestamptz +) +language sql +stable +as $$ + select + c.id, + c.project_id, + c.user_id, + c.title, + c.created_at + from public.chats c + where c.user_id = p_user_id + or exists ( + select 1 + from public.projects p + where p.id = c.project_id + and ( + p.user_id = p_user_id + or ( + p.org_id is not null + and exists ( + select 1 from public.org_members m + where m.org_id = p.org_id and m.user_id::text = p_user_id + ) + ) + ) + ) + order by c.created_at desc + limit case + when p_limit is null then null + else greatest(1, least(p_limit, 100)) + end; +$$; + +create or replace function public.get_projects_overview( + p_user_id text, + p_user_email text default null +) +returns table ( + id uuid, + user_id text, + name text, + cm_number text, + practice text, + shared_with jsonb, + created_at timestamptz, + updated_at timestamptz, + is_owner boolean, + owner_display_name text, + owner_email text, + document_count integer, + chat_count integer, + review_count integer +) +language sql +stable +as $$ + with visible_projects as ( + select p.* + from public.projects p + where p.user_id = p_user_id + or ( + coalesce(p_user_email, '') <> '' + and p.user_id <> p_user_id + and p.shared_with @> jsonb_build_array(p_user_email) + ) + or ( + p.org_id is not null + and p.user_id <> p_user_id + and exists ( + select 1 from public.org_members m + where m.org_id = p.org_id and m.user_id::text = p_user_id + ) + ) + ), + document_counts as ( + select d.project_id, count(*)::integer as document_count + from public.documents d + where d.project_id in (select vp.id from visible_projects vp) + group by d.project_id + ), + chat_counts as ( + select c.project_id, count(*)::integer as chat_count + from public.chats c + where c.project_id in (select vp.id from visible_projects vp) + group by c.project_id + ), + review_counts as ( + select tr.project_id, count(*)::integer as review_count + from public.tabular_reviews tr + where tr.project_id in (select vp.id from visible_projects vp) + group by tr.project_id + ) + select + vp.id, + vp.user_id, + vp.name, + vp.cm_number, + vp.practice, + vp.shared_with, + vp.created_at, + vp.updated_at, + vp.user_id = p_user_id as is_owner, + nullif(trim(up.display_name), '') as owner_display_name, + null::text as owner_email, + coalesce(dc.document_count, 0) as document_count, + coalesce(cc.chat_count, 0) as chat_count, + coalesce(rc.review_count, 0) as review_count + from visible_projects vp + left join public.user_profiles up + on up.user_id::text = vp.user_id + left join document_counts dc + on dc.project_id = vp.id + left join chat_counts cc + on cc.project_id = vp.id + left join review_counts rc + on rc.project_id = vp.id + order by vp.created_at desc; +$$; + +create or replace function public.get_tabular_reviews_overview( + p_user_id text, + p_user_email text default null, + p_project_id text default null +) +returns table ( + id uuid, + project_id uuid, + user_id text, + title text, + columns_config jsonb, + document_ids jsonb, + workflow_id uuid, + shared_with jsonb, + created_at timestamptz, + updated_at timestamptz, + is_owner boolean, + document_count integer +) +language sql +stable +as $$ + with accessible_projects as ( + select p.id + from public.projects p + where p.user_id = p_user_id + or ( + coalesce(p_user_email, '') <> '' + and p.user_id <> p_user_id + and p.shared_with @> jsonb_build_array(p_user_email) + ) + or ( + p.org_id is not null + and p.user_id <> p_user_id + and exists ( + select 1 from public.org_members m + where m.org_id = p.org_id and m.user_id::text = p_user_id + ) + ) + ), + visible_reviews as ( + select tr.* + from public.tabular_reviews tr + where (p_project_id is null or tr.project_id::text = p_project_id) + and ( + p_project_id is null + or exists ( + select 1 + from accessible_projects ap + where ap.id::text = p_project_id + ) + ) + and ( + tr.user_id = p_user_id + or ( + tr.project_id in (select ap.id from accessible_projects ap) + and tr.user_id <> p_user_id + ) + or ( + p_project_id is null + and coalesce(p_user_email, '') <> '' + and tr.user_id <> p_user_id + and tr.shared_with @> jsonb_build_array(p_user_email) + ) + or ( + p_project_id is null + and tr.org_id is not null + and tr.user_id <> p_user_id + and exists ( + select 1 from public.org_members m + where m.org_id = tr.org_id and m.user_id::text = p_user_id + ) + ) + ) + ), + cell_document_counts as ( + select + tc.review_id, + count(distinct tc.document_id)::integer as document_count + from public.tabular_cells tc + where tc.review_id in (select vr.id from visible_reviews vr) + group by tc.review_id + ) + select + vr.id, + vr.project_id, + vr.user_id, + vr.title, + vr.columns_config, + vr.document_ids, + vr.workflow_id, + vr.shared_with, + vr.created_at, + vr.updated_at, + vr.user_id = p_user_id as is_owner, + case + when jsonb_typeof(vr.document_ids) = 'array' + then ( + select count(distinct doc_id.value)::integer + from jsonb_array_elements_text(vr.document_ids) as doc_id(value) + ) + else coalesce(cdc.document_count, 0) + end as document_count + from visible_reviews vr + left join cell_document_counts cdc + on cdc.review_id = vr.id + order by vr.created_at desc; +$$; diff --git a/backend/schema.sql b/backend/schema.sql index c59647c7c..218532311 100644 --- a/backend/schema.sql +++ b/backend/schema.sql @@ -44,15 +44,34 @@ language plpgsql security definer set search_path = public as $$ +declare + v_org_id uuid; begin insert into public.user_profiles (user_id, email) values (new.id, lower(new.email)) on conflict (user_id) do update set email = excluded.email, updated_at = now(); + + -- Multi-tenant RBAC: provision a personal organization + owner membership so + -- new content has a tenant to land in (one personal org per user, enforced by + -- idx_organizations_personal_owner). + if not exists ( + select 1 from public.organizations + where created_by = new.id and personal + ) then + insert into public.organizations (name, personal, created_by) + values (coalesce(new.email, 'Personal'), true, new.id) + returning id into v_org_id; + + insert into public.org_members (org_id, user_id, role) + values (v_org_id, new.id, 'owner') + on conflict (org_id, user_id) do nothing; + end if; + return new; exception when others then - -- Never block signup if the profile insert fails. + -- Never block signup if the profile / org insert fails. return new; end; $$; @@ -62,6 +81,72 @@ create trigger on_auth_user_created after insert on auth.users for each row execute procedure public.handle_new_user(); +-- --------------------------------------------------------------------------- +-- Organizations / RBAC (multi-tenant) +-- Defined before projects/documents/workflows/tabular_reviews because those +-- carry an org_id FK to organizations(id). See lib/access.ts for the +-- owner/admin/member enforcement. SSO/SAML/SCIM are intentional extension +-- points (future organizations.sso_config / scim_token / org_invitations). +-- --------------------------------------------------------------------------- + +create table if not exists public.organizations ( + id uuid primary key default gen_random_uuid(), + name text not null, + personal boolean not null default false, + created_by uuid references auth.users(id) on delete set null, + created_at timestamptz not null default now(), + updated_at timestamptz not null default now() +); + +create unique index if not exists idx_organizations_personal_owner + on public.organizations(created_by) + where personal; + +alter table public.organizations enable row level security; + +create table if not exists public.org_members ( + id uuid primary key default gen_random_uuid(), + org_id uuid not null references public.organizations(id) on delete cascade, + user_id uuid not null references auth.users(id) on delete cascade, + role text not null default 'member' + check (role in ('owner', 'admin', 'member')), + created_at timestamptz not null default now(), + updated_at timestamptz not null default now(), + unique(org_id, user_id) +); + +create index if not exists idx_org_members_user on public.org_members(user_id); +create index if not exists idx_org_members_org on public.org_members(org_id); + +alter table public.org_members enable row level security; + +create table if not exists public.teams ( + id uuid primary key default gen_random_uuid(), + org_id uuid not null references public.organizations(id) on delete cascade, + name text not null, + created_by uuid references auth.users(id) on delete set null, + created_at timestamptz not null default now(), + updated_at timestamptz not null default now(), + unique(org_id, name) +); + +create index if not exists idx_teams_org on public.teams(org_id); + +alter table public.teams enable row level security; + +create table if not exists public.team_members ( + id uuid primary key default gen_random_uuid(), + team_id uuid not null references public.teams(id) on delete cascade, + user_id uuid not null references auth.users(id) on delete cascade, + created_at timestamptz not null default now(), + unique(team_id, user_id) +); + +create index if not exists idx_team_members_user on public.team_members(user_id); +create index if not exists idx_team_members_team on public.team_members(team_id); + +alter table public.team_members enable row level security; + create table if not exists public.user_api_keys ( id uuid primary key default gen_random_uuid(), user_id uuid not null references auth.users(id) on delete cascade, @@ -195,6 +280,9 @@ alter table public.user_mcp_tool_audit_logs enable row level security; create table if not exists public.projects ( id uuid primary key default gen_random_uuid(), user_id text not null, + -- Multi-tenant: nullable so system/global rows stay valid; user_id remains + -- the hard cascade anchor (org_id uses SET NULL, not CASCADE). + org_id uuid references public.organizations(id) on delete set null, name text not null, cm_number text, practice text, @@ -207,6 +295,9 @@ create table if not exists public.projects ( create index if not exists idx_projects_user on public.projects(user_id); +create index if not exists idx_projects_org + on public.projects(org_id); + create index if not exists projects_shared_with_idx on public.projects using gin (shared_with); @@ -244,6 +335,7 @@ create index if not exists idx_library_folders_parent create table if not exists public.documents ( id uuid primary key default gen_random_uuid(), project_id uuid references public.projects(id) on delete cascade, + org_id uuid references public.organizations(id) on delete set null, user_id text not null, status text not null default 'pending', folder_id uuid references public.project_subfolders(id) on delete set null, @@ -265,6 +357,9 @@ create index if not exists idx_documents_library_kind_folder on public.documents(user_id, library_kind, library_folder_id) where project_id is null; +create index if not exists idx_documents_org + on public.documents(org_id); + create table if not exists public.document_versions ( id uuid primary key default gen_random_uuid(), document_id uuid not null references public.documents(id) on delete cascade, @@ -364,12 +459,16 @@ create table if not exists public.workflows ( language text default 'English', practice text default 'General Transactions', jurisdictions text[] default array['General']::text[], + org_id uuid references public.organizations(id) on delete set null, created_at timestamptz not null default now() ); create index if not exists idx_workflows_user on public.workflows(user_id); +create index if not exists idx_workflows_org + on public.workflows(org_id); + create table if not exists public.hidden_workflows ( id uuid primary key default gen_random_uuid(), user_id text not null, @@ -468,10 +567,47 @@ as $$ where lower(ws.shared_with_email) = lower(coalesce(p_user_email, '')) and (p_type is null or w.type = p_type) ), + org_shared as ( + -- Workflows in an org the caller belongs to (read-only; edits stay + -- owner/share-gated). Mirrors the org branch in lib/access.ts. + select + w.id, + w.user_id::text as user_id, + w.title, + w.type, + w.prompt_md, + w.columns_config, + w.language, + w.practice, + w.jurisdictions, + false as is_system, + w.created_at, + false as allow_edit, + false as is_owner, + nullif(trim(up.display_name), '') as shared_by_name, + 2 as sort_bucket + from public.workflows w + left join public.user_profiles up + on up.user_id::text = w.user_id::text + where w.org_id is not null + and (w.user_id is null or w.user_id::text <> p_user_id) + and (p_type is null or w.type = p_type) + and exists ( + select 1 from public.org_members m + where m.org_id = w.org_id and m.user_id::text = p_user_id + ) + and not exists ( + select 1 from public.workflow_shares ws + where ws.workflow_id = w.id + and lower(ws.shared_with_email) = lower(coalesce(p_user_email, '')) + ) + ), visible_workflows as ( select * from owned union all select * from shared + union all + select * from org_shared ) select vw.id, @@ -536,7 +672,16 @@ as $$ select 1 from public.projects p where p.id = c.project_id - and p.user_id = p_user_id + and ( + p.user_id = p_user_id + or ( + p.org_id is not null + and exists ( + select 1 from public.org_members m + where m.org_id = p.org_id and m.user_id::text = p_user_id + ) + ) + ) ) order by c.created_at desc limit case @@ -590,6 +735,7 @@ create table if not exists public.tabular_reviews ( workflow_id uuid references public.workflows(id) on delete set null, practice text, shared_with jsonb not null default '[]'::jsonb, + org_id uuid references public.organizations(id) on delete set null, created_at timestamptz not null default now(), updated_at timestamptz not null default now() ); @@ -600,6 +746,9 @@ create index if not exists idx_tabular_reviews_user create index if not exists idx_tabular_reviews_project on public.tabular_reviews(project_id); +create index if not exists idx_tabular_reviews_org + on public.tabular_reviews(org_id); + create index if not exists tabular_reviews_shared_with_idx on public.tabular_reviews using gin (shared_with); @@ -638,6 +787,14 @@ as $$ and p.user_id <> p_user_id and p.shared_with @> jsonb_build_array(p_user_email) ) + or ( + p.org_id is not null + and p.user_id <> p_user_id + and exists ( + select 1 from public.org_members m + where m.org_id = p.org_id and m.user_id::text = p_user_id + ) + ) ), document_counts as ( select d.project_id, count(*)::integer as document_count @@ -739,6 +896,14 @@ as $$ and p.user_id <> p_user_id and p.shared_with @> jsonb_build_array(p_user_email) ) + or ( + p.org_id is not null + and p.user_id <> p_user_id + and exists ( + select 1 from public.org_members m + where m.org_id = p.org_id and m.user_id::text = p_user_id + ) + ) ), visible_reviews as ( select tr.* @@ -786,6 +951,15 @@ as $$ and tr.user_id <> p_user_id and tr.shared_with @> jsonb_build_array(p_user_email) ) + or ( + p_project_id is null + and tr.org_id is not null + and tr.user_id <> p_user_id + and exists ( + select 1 from public.org_members m + where m.org_id = tr.org_id and m.user_id::text = p_user_id + ) + ) ) ), cell_document_counts as ( diff --git a/backend/src/app.ts b/backend/src/app.ts index d6b980556..e6e488025 100644 --- a/backend/src/app.ts +++ b/backend/src/app.ts @@ -5,6 +5,7 @@ import helmet from "helmet"; import rateLimit from "express-rate-limit"; import { chatRouter } from "./routes/chat"; import { projectsRouter } from "./routes/projects"; +import { orgsRouter } from "./routes/orgs"; import { projectChatRouter } from "./routes/projectChat"; import { documentsRouter } from "./routes/documents"; import { libraryRouter } from "./routes/library"; @@ -161,6 +162,7 @@ app.use(express.json({ limit: JSON_BODY_LIMIT })); app.use("/chat", chatRouter); app.use("/projects", projectsRouter); +app.use("/orgs", orgsRouter); app.use("/projects/:projectId/chat", projectChatRouter); app.use("/single-documents", documentsRouter); app.use("/library", libraryRouter); diff --git a/backend/src/lib/__tests__/access.test.ts b/backend/src/lib/__tests__/access.test.ts index ddc465646..e6436a184 100644 --- a/backend/src/lib/__tests__/access.test.ts +++ b/backend/src/lib/__tests__/access.test.ts @@ -161,3 +161,121 @@ describe("access helpers", () => { ).resolves.toMatchObject({ ok: true, isOwner: false }); }); }); + +// --------------------------------------------------------------------------- +// Multi-tenant org RBAC: the third access branch (row.org_id + membership). +// --------------------------------------------------------------------------- +describe("org RBAC access", () => { + // org-a belongs to alice; carol is a member, dave an admin. org-b belongs + // to bob and is entirely separate (cross-org isolation fixture). + const db = makeDb({ + organizations: [ + { id: "org-a", created_by: "alice", personal: true }, + { id: "org-b", created_by: "bob", personal: true }, + ], + org_members: [ + { org_id: "org-a", user_id: "alice", role: "owner" }, + { org_id: "org-a", user_id: "carol", role: "member" }, + { org_id: "org-a", user_id: "dave", role: "admin" }, + { org_id: "org-b", user_id: "bob", role: "owner" }, + ], + projects: [ + { id: "proj-a", user_id: "alice", shared_with: [], org_id: "org-a" }, + { id: "proj-b", user_id: "bob", shared_with: [], org_id: "org-b" }, + ], + documents: [ + { + id: "doc-a", + user_id: "alice", + project_id: "proj-a", + org_id: "org-a", + }, + { + id: "doc-b", + user_id: "bob", + project_id: "proj-b", + org_id: "org-b", + }, + ], + }); + + it("grants an org member read access without ownership", async () => { + await expect( + checkProjectAccess("proj-a", "carol", "carol@example.com", db), + ).resolves.toMatchObject({ + ok: true, + isOwner: false, + role: "member", + canManage: false, + }); + }); + + it("marks org owners/admins as able to manage", async () => { + await expect( + checkProjectAccess("proj-a", "dave", "dave@example.com", db), + ).resolves.toMatchObject({ + ok: true, + isOwner: false, + role: "admin", + canManage: true, + }); + }); + + it("isolates users across orgs (cross-tenant denial)", async () => { + await expect( + checkProjectAccess("proj-a", "bob", "bob@example.com", db), + ).resolves.toEqual({ ok: false }); + }); + + it("extends org access to that org's documents", async () => { + await expect( + ensureDocAccess( + { user_id: "alice", project_id: "proj-a", org_id: "org-a" }, + "carol", + "carol@example.com", + db, + ), + ).resolves.toMatchObject({ ok: true, isOwner: false, role: "member" }); + + await expect( + ensureDocAccess( + { user_id: "bob", project_id: "proj-b", org_id: "org-b" }, + "carol", + "carol@example.com", + db, + ), + ).resolves.toEqual({ ok: false }); + }); + + it("extends org access to that org's reviews", async () => { + await expect( + ensureReviewAccess( + { user_id: "alice", project_id: null, org_id: "org-a" }, + "carol", + "carol@example.com", + db, + ), + ).resolves.toMatchObject({ ok: true, isOwner: false }); + }); + + it("lists org projects for members but not other tenants'", async () => { + const ids = await listAccessibleProjectIds( + "carol", + "carol@example.com", + db, + ); + expect(ids).toContain("proj-a"); + expect(ids).not.toContain("proj-b"); + }); + + it("admits org documents but rejects other tenants' documents", async () => { + await expect( + filterAccessibleDocumentIds( + ["doc-a", "doc-b"], + "carol", + "carol@example.com", + db, + ), + ).resolves.toEqual(["doc-a"]); + }); +}); diff --git a/backend/src/lib/__tests__/orgs.test.ts b/backend/src/lib/__tests__/orgs.test.ts new file mode 100644 index 000000000..4e190f738 --- /dev/null +++ b/backend/src/lib/__tests__/orgs.test.ts @@ -0,0 +1,305 @@ +import { describe, expect, it } from "vitest"; +import { + createOrg, + getOrg, + listMyOrgs, + addMember, + updateMember, + removeMember, + createTeam, + deleteTeam, + addTeamMember, +} from "../orgs"; + +type Row = Record; + +// Stateful in-memory Supabase fake: unlike the read-only makeDb in +// access.test.ts, this one actually mutates the seeded tables so +// insert/update/delete round-trips (membership changes, last-owner counts) can +// be asserted. Supports the subset of the query builder the service uses. +function makeDb(initial: Record) { + const tables: Record = {}; + for (const [k, v] of Object.entries(initial)) tables[k] = v.map((r) => ({ ...r })); + let idCounter = 1; + + function query(table: string) { + const filters: ( + | { type: "eq"; col: string; val: unknown } + | { type: "in"; col: string; vals: unknown[] } + )[] = []; + let op: "select" | "insert" | "update" | "delete" = "select"; + let payload: Row | Row[] | null = null; + let orderCol: string | null = null; + let orderAsc = true; + let limitN: number | null = null; + + const ensure = () => (tables[table] ??= []); + const matches = (rows: Row[]) => + rows.filter((r) => + filters.every((f) => + f.type === "eq" + ? r[f.col] === f.val + : f.vals.includes(r[f.col]), + ), + ); + + function resolveMany(): Promise<{ data: Row[]; error: null }> { + const arr = ensure(); + if (op === "insert") { + const rows = Array.isArray(payload) ? payload : [payload as Row]; + const inserted = rows.map((r) => ({ id: `row-${idCounter++}`, ...r })); + arr.push(...inserted); + return Promise.resolve({ data: inserted, error: null }); + } + const matched = matches(arr); + if (op === "update") { + for (const r of matched) Object.assign(r, payload as Row); + return Promise.resolve({ data: matched, error: null }); + } + if (op === "delete") { + tables[table] = arr.filter((r) => !matched.includes(r)); + return Promise.resolve({ data: matched, error: null }); + } + let out = [...matched]; + if (orderCol) { + const col = orderCol; + out.sort((a, b) => + ((a[col] as number) > (b[col] as number) ? 1 : -1) * + (orderAsc ? 1 : -1), + ); + } + if (limitN != null) out = out.slice(0, limitN); + return Promise.resolve({ data: out, error: null }); + } + + async function resolveSingle() { + const { data } = await resolveMany(); + return { data: data[0] ?? null, error: null }; + } + + const builder: Record = { + select: () => builder, + eq: (col: string, val: unknown) => { + filters.push({ type: "eq", col, val }); + return builder; + }, + in: (col: string, vals: unknown[]) => { + filters.push({ type: "in", col, vals }); + return builder; + }, + order: (col: string, opts?: { ascending?: boolean }) => { + orderCol = col; + orderAsc = opts?.ascending !== false; + return builder; + }, + limit: (n: number) => { + limitN = n; + return builder; + }, + insert: (p: Row | Row[]) => { + op = "insert"; + payload = p; + return builder; + }, + update: (p: Row) => { + op = "update"; + payload = p; + return builder; + }, + delete: () => { + op = "delete"; + return builder; + }, + single: () => resolveSingle(), + maybeSingle: () => resolveSingle(), + then: ( + resolve: (v: { data: Row[]; error: null }) => unknown, + reject?: (e: unknown) => unknown, + ) => resolveMany().then(resolve, reject), + }; + return builder; + } + + return { from: (t: string) => query(t), _tables: tables } as any; +} + +describe("orgs.service RBAC", () => { + it("createOrg makes the creator an owner", async () => { + const db = makeDb({}); + const result = await createOrg(db, { userId: "u1", name: "Acme" }); + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(result.org.role).toBe("owner"); + const members = db._tables.org_members as Row[]; + expect(members).toHaveLength(1); + expect(members[0]).toMatchObject({ user_id: "u1", role: "owner" }); + }); + + it("rejects a blank org name", async () => { + const db = makeDb({}); + const result = await createOrg(db, { userId: "u1", name: " " }); + expect(result).toMatchObject({ ok: false, kind: "validation" }); + }); + + it("hides orgs from non-members (getOrg)", async () => { + const db = makeDb({ + organizations: [{ id: "o1", name: "Acme", created_by: "u1" }], + org_members: [{ org_id: "o1", user_id: "u1", role: "owner" }], + }); + await expect(getOrg(db, { userId: "stranger", orgId: "o1" })).resolves.toEqual( + { ok: false, kind: "not_found" }, + ); + await expect( + listMyOrgs(db, "stranger"), + ).resolves.toMatchObject({ ok: true, orgs: [] }); + }); + + function seededOrg() { + return makeDb({ + organizations: [{ id: "o1", name: "Acme", created_by: "owner1" }], + org_members: [ + { org_id: "o1", user_id: "owner1", role: "owner" }, + { org_id: "o1", user_id: "admin1", role: "admin" }, + { org_id: "o1", user_id: "member1", role: "member" }, + ], + }); + } + + it("lets owner/admin add members but forbids plain members", async () => { + const db = seededOrg(); + await expect( + addMember(db, { + actorId: "owner1", + orgId: "o1", + targetUserId: "new1", + role: "member", + }), + ).resolves.toMatchObject({ ok: true }); + await expect( + addMember(db, { + actorId: "admin1", + orgId: "o1", + targetUserId: "new2", + role: "member", + }), + ).resolves.toMatchObject({ ok: true }); + await expect( + addMember(db, { + actorId: "member1", + orgId: "o1", + targetUserId: "new3", + role: "member", + }), + ).resolves.toEqual({ ok: false, kind: "forbidden" }); + }); + + it("forbids an admin from granting the owner role (no escalation)", async () => { + const db = seededOrg(); + await expect( + addMember(db, { + actorId: "admin1", + orgId: "o1", + targetUserId: "new1", + role: "owner", + }), + ).resolves.toEqual({ ok: false, kind: "forbidden" }); + }); + + it("rejects duplicate memberships", async () => { + const db = seededOrg(); + await expect( + addMember(db, { + actorId: "owner1", + orgId: "o1", + targetUserId: "member1", + role: "member", + }), + ).resolves.toMatchObject({ ok: false, kind: "conflict" }); + }); + + it("protects the last owner from demotion and removal", async () => { + const db = makeDb({ + organizations: [{ id: "o1", name: "Solo", created_by: "owner1" }], + org_members: [ + { org_id: "o1", user_id: "owner1", role: "owner" }, + { org_id: "o1", user_id: "member1", role: "member" }, + ], + }); + await expect( + updateMember(db, { + actorId: "owner1", + orgId: "o1", + targetUserId: "owner1", + role: "member", + }), + ).resolves.toEqual({ ok: false, kind: "last_owner" }); + await expect( + removeMember(db, { + actorId: "owner1", + orgId: "o1", + targetUserId: "owner1", + }), + ).resolves.toEqual({ ok: false, kind: "last_owner" }); + }); + + it("allows demoting an owner when another owner remains", async () => { + const db = makeDb({ + organizations: [{ id: "o1", name: "Duo", created_by: "owner1" }], + org_members: [ + { org_id: "o1", user_id: "owner1", role: "owner" }, + { org_id: "o1", user_id: "owner2", role: "owner" }, + ], + }); + await expect( + updateMember(db, { + actorId: "owner1", + orgId: "o1", + targetUserId: "owner2", + role: "member", + }), + ).resolves.toMatchObject({ ok: true }); + }); + + it("gates team creation on owner/admin and requires org membership to join a team", async () => { + const db = seededOrg(); + await expect( + createTeam(db, { userId: "member1", orgId: "o1", name: "Litigation" }), + ).resolves.toEqual({ ok: false, kind: "forbidden" }); + + const created = await createTeam(db, { + userId: "owner1", + orgId: "o1", + name: "Litigation", + }); + expect(created.ok).toBe(true); + if (!created.ok) return; + const teamId = created.team.id as string; + + // A user outside the org cannot be added to a team. + await expect( + addTeamMember(db, { + actorId: "owner1", + orgId: "o1", + teamId, + targetUserId: "outsider", + }), + ).resolves.toMatchObject({ ok: false, kind: "validation" }); + + // An existing org member can. + await expect( + addTeamMember(db, { + actorId: "owner1", + orgId: "o1", + teamId, + targetUserId: "member1", + }), + ).resolves.toMatchObject({ ok: true }); + + await expect( + deleteTeam(db, { userId: "member1", orgId: "o1", teamId }), + ).resolves.toEqual({ ok: false, kind: "forbidden" }); + await expect( + deleteTeam(db, { userId: "owner1", orgId: "o1", teamId }), + ).resolves.toMatchObject({ ok: true }); + }); +}); diff --git a/backend/src/lib/__tests__/userDataCleanup.orgs.test.ts b/backend/src/lib/__tests__/userDataCleanup.orgs.test.ts new file mode 100644 index 000000000..1d5b61283 --- /dev/null +++ b/backend/src/lib/__tests__/userDataCleanup.orgs.test.ts @@ -0,0 +1,151 @@ +import { describe, expect, it } from "vitest"; + +import { deleteUserOrganizations } from "../userDataCleanup"; + +type Row = Record; + +// Stateful fake with a minimal simulation of the ON DELETE CASCADE from +// org_members → organizations, so deleting a personal org also drops its +// membership rows (as Postgres would). Supports the query subset the cleanup +// uses: select/eq/order/limit/delete/update + thenable. +function makeDb(initial: Record) { + const tables: Record = {}; + for (const [k, v] of Object.entries(initial)) tables[k] = v.map((r) => ({ ...r })); + + function query(table: string) { + const filters: ( + | { type: "eq"; col: string; val: unknown } + | { type: "in"; col: string; vals: unknown[] } + )[] = []; + let op: "select" | "update" | "delete" = "select"; + let payload: Row | null = null; + let orderCol: string | null = null; + let orderAsc = true; + let limitN: number | null = null; + + const ensure = () => (tables[table] ??= []); + const matches = (rows: Row[]) => + rows.filter((r) => + filters.every((f) => + f.type === "eq" + ? r[f.col] === f.val + : f.vals.includes(r[f.col]), + ), + ); + + function resolveMany(): Promise<{ data: Row[]; error: null }> { + const arr = ensure(); + const matched = matches(arr); + if (op === "update") { + for (const r of matched) Object.assign(r, payload as Row); + return Promise.resolve({ data: matched, error: null }); + } + if (op === "delete") { + tables[table] = arr.filter((r) => !matched.includes(r)); + if (table === "organizations") { + // Simulate FK cascade to org_members/teams. + const goneOrgIds = new Set(matched.map((r) => r.id)); + tables.org_members = (tables.org_members ?? []).filter( + (m) => !goneOrgIds.has(m.org_id), + ); + tables.teams = (tables.teams ?? []).filter( + (t) => !goneOrgIds.has(t.org_id), + ); + } + return Promise.resolve({ data: matched, error: null }); + } + let out = [...matched]; + if (orderCol) { + const col = orderCol; + out.sort((a, b) => + ((a[col] as number) > (b[col] as number) ? 1 : -1) * + (orderAsc ? 1 : -1), + ); + } + if (limitN != null) out = out.slice(0, limitN); + return Promise.resolve({ data: out, error: null }); + } + + const builder: Record = { + select: () => builder, + eq: (col: string, val: unknown) => { + filters.push({ type: "eq", col, val }); + return builder; + }, + order: (col: string, opts?: { ascending?: boolean }) => { + orderCol = col; + orderAsc = opts?.ascending !== false; + return builder; + }, + limit: (n: number) => { + limitN = n; + return builder; + }, + update: (p: Row) => { + op = "update"; + payload = p; + return builder; + }, + delete: () => { + op = "delete"; + return builder; + }, + in: (col: string, vals: unknown[]) => { + filters.push({ type: "in", col, vals }); + return builder; + }, + then: ( + resolve: (v: { data: Row[]; error: null }) => unknown, + reject?: (e: unknown) => unknown, + ) => resolveMany().then(resolve, reject), + }; + return builder; + } + + return { from: (t: string) => query(t), _tables: tables } as any; +} + +describe("deleteUserOrganizations", () => { + it("drops the personal org, hands off sole ownership, preserves shared orgs", async () => { + const db = makeDb({ + organizations: [ + { id: "personal1", created_by: "u1", personal: true }, + { id: "shared1", created_by: "owner2", personal: false }, + { id: "shared2", created_by: "u1", personal: false }, + ], + org_members: [ + { id: "m1", org_id: "personal1", user_id: "u1", role: "owner", created_at: 1 }, + // shared1: u1 is one of two owners → membership just removed. + { id: "m2", org_id: "shared1", user_id: "u1", role: "owner", created_at: 2 }, + { id: "m3", org_id: "shared1", user_id: "owner2", role: "owner", created_at: 3 }, + // shared2: u1 is the SOLE owner → ownership hands off to u3. + { id: "m4", org_id: "shared2", user_id: "u1", role: "owner", created_at: 4 }, + { id: "m5", org_id: "shared2", user_id: "u3", role: "member", created_at: 5 }, + ], + teams: [{ id: "t1", org_id: "shared1" }], + team_members: [{ id: "tm1", team_id: "t1", user_id: "u1" }], + }); + + await deleteUserOrganizations(db, "u1"); + + const orgs = db._tables.organizations as Row[]; + const members = db._tables.org_members as Row[]; + + // Personal org gone (and its membership via the simulated cascade). + expect(orgs.find((o) => o.id === "personal1")).toBeUndefined(); + // Shared orgs the user merely belonged to are preserved. + expect(orgs.find((o) => o.id === "shared1")).toBeDefined(); + expect(orgs.find((o) => o.id === "shared2")).toBeDefined(); + + // No membership rows for the deleted user remain anywhere. + expect(members.filter((m) => m.user_id === "u1")).toHaveLength(0); + + // shared2 kept an owner via handoff to the earliest remaining member. + expect( + members.find((m) => m.org_id === "shared2" && m.user_id === "u3"), + ).toMatchObject({ role: "owner" }); + + // Team membership removed. + expect(db._tables.team_members as Row[]).toHaveLength(0); + }); +}); diff --git a/backend/src/lib/access.ts b/backend/src/lib/access.ts index 5964578ae..fdf59c966 100644 --- a/backend/src/lib/access.ts +++ b/backend/src/lib/access.ts @@ -4,25 +4,125 @@ * Sharing makes the previous "scope by user_id" pattern incorrect — a doc * can belong to user A's project that A has shared with B's email, and B * must still be able to read/edit it. These helpers centralize the - * "owner OR shared project member" check so every route uses the same - * logic instead of re-implementing the join. + * "owner OR shared project member OR org member" check so every route uses + * the same logic instead of re-implementing the join. * - * Returned `isOwner` lets callers gate operations that should stay - * owner-only (delete, rename, member management). + * Access is granted through three branches, evaluated in this precedence: + * 1. row owner — the row's `user_id` matches the caller. + * 2. shared_with — the caller's email is in the row's shared_with list + * (email-based sharing, unchanged by the org feature). + * 3. org member — the row's `org_id` is an org the caller belongs to + * (multi-tenant RBAC). + * + * Two orthogonal flags are returned so callers can gate correctly: + * - `isOwner` — TRUE only for branch (1), the row owner. Existing + * owner-only gates (delete, rename, member management) + * depend on this meaning, so it is NOT overloaded. + * - `canManage` — TRUE for the row owner OR an org owner/admin. Use this + * for org-level management operations that should be + * available to org admins as well as the row owner. + * - `role` — the caller's org role for branch (3), else null. */ import type { createServerSupabase } from "./supabase"; type Db = ReturnType; +// EXTENSION POINT (RBAC): new roles added to the org_members CHECK constraint +// should be reflected here and in canManage-style predicates. +export type OrgRole = "owner" | "admin" | "member"; + +/** Roles allowed to manage an org (members, teams, settings). */ +export function roleCanManage(role: OrgRole | null | undefined): boolean { + return role === "owner" || role === "admin"; +} + +/** + * The caller's role in a single org, or null if they are not a member. + */ +export async function getOrgRole( + userId: string, + orgId: string | null | undefined, + db: Db, +): Promise { + if (!orgId) return null; + const { data } = await db + .from("org_members") + .select("role") + .eq("org_id", orgId) + .eq("user_id", userId) + .single(); + const role = (data as { role?: string } | null)?.role; + if (role === "owner" || role === "admin" || role === "member") return role; + return null; +} + +/** + * Every org id the caller belongs to. Used to scope collection reads and to + * validate an org_id chosen at create time. + */ +export async function listUserOrgIds(userId: string, db: Db): Promise { + const { data } = await db + .from("org_members") + .select("org_id") + .eq("user_id", userId); + const ids = new Set(); + for (const row of (data ?? []) as { org_id?: string | null }[]) { + if (row.org_id) ids.add(row.org_id); + } + return [...ids]; +} + +/** + * The caller's auto-provisioned personal org id (the tenant new content lands + * in by default), or null if it somehow doesn't exist yet. + */ +export async function getPersonalOrgId( + userId: string, + db: Db, +): Promise { + const { data } = await db + .from("organizations") + .select("id") + .eq("created_by", userId) + .eq("personal", true) + .single(); + return (data as { id?: string } | null)?.id ?? null; +} + +/** + * Choose the org_id a newly created resource should carry. Content created + * inside a project inherits that project's org; otherwise it lands in the + * caller's personal org. This keeps every row tenant-scoped without demanding + * an explicit org context on every write. + */ +export async function resolveContentOrgId( + db: Db, + params: { userId: string; projectId?: string | null }, +): Promise { + if (params.projectId) { + const { data } = await db + .from("projects") + .select("org_id") + .eq("id", params.projectId) + .single(); + const projectOrgId = (data as { org_id?: string | null } | null)?.org_id; + if (projectOrgId) return projectOrgId; + } + return getPersonalOrgId(params.userId, db); +} + export type ProjectAccess = | { ok: true; isOwner: boolean; + role: OrgRole | null; + canManage: boolean; project: { id: string; user_id: string; shared_with: string[] | null; + org_id?: string | null; }; } | { ok: false }; @@ -35,7 +135,7 @@ export async function checkProjectAccess( ): Promise { const { data: project } = await db .from("projects") - .select("id, user_id, shared_with") + .select("id, user_id, shared_with, org_id") .eq("id", projectId) .single(); if (!project) return { ok: false }; @@ -43,34 +143,56 @@ export async function checkProjectAccess( id: string; user_id: string; shared_with: string[] | null; + org_id?: string | null; }; if (proj.user_id === userId) { - return { ok: true, isOwner: true, project: proj }; + return { ok: true, isOwner: true, role: null, canManage: true, project: proj }; } const sharedWith = Array.isArray(proj.shared_with) ? proj.shared_with : []; const email = (userEmail ?? "").toLowerCase(); - if ( - email && - sharedWith.some((e) => (e ?? "").toLowerCase() === email) - ) { - return { ok: true, isOwner: false, project: proj }; + if (email && sharedWith.some((e) => (e ?? "").toLowerCase() === email)) { + return { ok: true, isOwner: false, role: null, canManage: false, project: proj }; + } + const role = await getOrgRole(userId, proj.org_id, db); + if (role) { + return { + ok: true, + isOwner: false, + role, + canManage: roleCanManage(role), + project: proj, + }; } return { ok: false }; } +type ResourceAccess = + | { ok: true; isOwner: boolean; role: OrgRole | null; canManage: boolean } + | { ok: false }; + /** * Check whether the current user can access a document the caller has * already loaded (saves a round-trip vs. having the helper re-fetch). - * Owner-of-doc passes immediately; otherwise we fall through to a - * project-membership check via `shared_with`. + * Owner-of-doc passes immediately; then a direct org-membership check on the + * doc's own org_id; otherwise we fall through to a project-membership check. */ export async function ensureDocAccess( - doc: { user_id: string; project_id: string | null }, + doc: { user_id: string; project_id: string | null; org_id?: string | null }, userId: string, userEmail: string | null | undefined, db: Db, -): Promise<{ ok: true; isOwner: boolean } | { ok: false }> { - if (doc.user_id === userId) return { ok: true, isOwner: true }; +): Promise { + if (doc.user_id === userId) + return { ok: true, isOwner: true, role: null, canManage: true }; + const docRole = await getOrgRole(userId, doc.org_id, db); + if (docRole) { + return { + ok: true, + isOwner: false, + role: docRole, + canManage: roleCanManage(docRole), + }; + } if (!doc.project_id) return { ok: false }; const access = await checkProjectAccess( doc.project_id, @@ -78,17 +200,24 @@ export async function ensureDocAccess( userEmail, db, ); - if (access.ok) return { ok: true, isOwner: false }; + if (access.ok) + return { + ok: true, + isOwner: false, + role: access.role, + canManage: access.canManage, + }; return { ok: false }; } /** * Same shape as `ensureDocAccess`, for tabular_reviews. A review can be - * shared in two ways: + * shared in several ways: * 1. Indirectly — if `project_id` is set, everyone with project access * can read/operate on it. * 2. Directly — `tabular_reviews.shared_with` is a per-review email list * so standalone reviews (project_id null) can also be shared. + * 3. Org — the review's `org_id` is an org the caller belongs to. * The owner (review.user_id) always has access. */ export async function ensureReviewAccess( @@ -96,18 +225,29 @@ export async function ensureReviewAccess( user_id: string; project_id: string | null; shared_with?: string[] | null; + org_id?: string | null; }, userId: string, userEmail: string | null | undefined, db: Db, -): Promise<{ ok: true; isOwner: boolean } | { ok: false }> { - if (review.user_id === userId) return { ok: true, isOwner: true }; +): Promise { + if (review.user_id === userId) + return { ok: true, isOwner: true, role: null, canManage: true }; const email = (userEmail ?? "").toLowerCase(); if (email && Array.isArray(review.shared_with)) { if (review.shared_with.some((e) => (e ?? "").toLowerCase() === email)) { - return { ok: true, isOwner: false }; + return { ok: true, isOwner: false, role: null, canManage: false }; } } + const reviewRole = await getOrgRole(userId, review.org_id, db); + if (reviewRole) { + return { + ok: true, + isOwner: false, + role: reviewRole, + canManage: roleCanManage(reviewRole), + }; + } if (!review.project_id) return { ok: false }; const access = await checkProjectAccess( review.project_id, @@ -115,7 +255,13 @@ export async function ensureReviewAccess( userEmail, db, ); - if (access.ok) return { ok: true, isOwner: false }; + if (access.ok) + return { + ok: true, + isOwner: false, + role: access.role, + canManage: access.canManage, + }; return { ok: false }; } @@ -135,26 +281,29 @@ export async function filterAccessibleDocumentIds( if (documentIds.length === 0) return []; const { data: docs } = await db .from("documents") - .select("id, user_id, project_id") + .select("id, user_id, project_id, org_id") .in("id", documentIds); const rows = (docs ?? []) as { id: string; user_id: string; project_id: string | null; + org_id?: string | null; }[]; if (rows.length === 0) return []; - const accessibleProjectIds = new Set( - await listAccessibleProjectIds(userId, userEmail, db), - ); + const [accessibleProjectIds, userOrgIds] = await Promise.all([ + listAccessibleProjectIds(userId, userEmail, db).then( + (ids) => new Set(ids), + ), + listUserOrgIds(userId, db).then((ids) => new Set(ids)), + ]); const allowed: string[] = []; for (const doc of rows) { if (doc.user_id === userId) { allowed.push(doc.id); - } else if ( - doc.project_id && - accessibleProjectIds.has(doc.project_id) - ) { + } else if (doc.org_id && userOrgIds.has(doc.org_id)) { + allowed.push(doc.id); + } else if (doc.project_id && accessibleProjectIds.has(doc.project_id)) { allowed.push(doc.id); } } @@ -162,27 +311,43 @@ export async function filterAccessibleDocumentIds( } /** - * Returns the set of project IDs the user can access — own projects plus - * any project where their email is in `shared_with`. Used to scope chat - * lists and similar collection queries. + * Returns the set of project IDs the user can access — own projects, any + * project where their email is in `shared_with`, and any project in an org they + * belong to. Used to scope chat lists and similar collection queries. */ export async function listAccessibleProjectIds( userId: string, userEmail: string | null | undefined, db: Db, ): Promise { - const [{ data: own }, { data: shared }] = await Promise.all([ - db.from("projects").select("id").eq("user_id", userId), - userEmail - ? db - .from("projects") - .select("id") - .filter("shared_with", "cs", JSON.stringify([userEmail])) - .neq("user_id", userId) - : Promise.resolve({ data: [] as { id: string }[] }), - ]); + const orgIds = await listUserOrgIds(userId, db); + const [{ data: own }, { data: shared }, { data: orgProjects }] = + await Promise.all([ + db.from("projects").select("id").eq("user_id", userId), + userEmail + ? db + .from("projects") + .select("id") + // shared_with is stored lowercased, so normalise the + // caller's email before the containment check. + .filter( + "shared_with", + "cs", + JSON.stringify([userEmail.toLowerCase()]), + ) + .neq("user_id", userId) + : Promise.resolve({ data: [] as { id: string }[] }), + orgIds.length > 0 + ? db + .from("projects") + .select("id") + .in("org_id", orgIds) + .neq("user_id", userId) + : Promise.resolve({ data: [] as { id: string }[] }), + ]); const ids = new Set(); for (const p of (own ?? []) as { id: string }[]) ids.add(p.id); for (const p of (shared ?? []) as { id: string }[]) ids.add(p.id); + for (const p of (orgProjects ?? []) as { id: string }[]) ids.add(p.id); return [...ids]; } diff --git a/backend/src/lib/orgs.ts b/backend/src/lib/orgs.ts new file mode 100644 index 000000000..8ef3b5eaf --- /dev/null +++ b/backend/src/lib/orgs.ts @@ -0,0 +1,425 @@ +// Business logic + data-access for the organizations / RBAC module. +// +// These functions are the service layer behind routes/orgs.ts. They take an +// explicit Supabase client (`db`) plus request-derived primitives, enforce the +// owner/admin/member role model, and RETURN typed discriminated results the +// thin route handlers map onto HTTP status codes. They never touch req/res. +// +// Role model (see also backend/src/lib/access.ts): +// owner — full control incl. demoting/removing members and deleting the org. +// admin — manage members and teams, but the last owner is protected. +// member — read the org, its members and teams; no mutations. +// +// EXTENSION POINT (SSO/SCIM): org provisioning (SAML/SCIM) and invitations are +// intentionally out of scope. New roles can be added to the org_members CHECK +// constraint + the OrgRole union without changing this module's shape. + +import { createServerSupabase } from "./supabase"; +import { + getOrgRole, + roleCanManage, + type OrgRole, +} from "./access"; + +type Db = ReturnType; + +const VALID_ROLES: OrgRole[] = ["owner", "admin", "member"]; + +export type OrgResult = + | ({ ok: true } & T) + | { ok: false; kind: "validation"; detail: string } + | { ok: false; kind: "forbidden" } + | { ok: false; kind: "not_found" } + | { ok: false; kind: "conflict"; detail: string } + | { ok: false; kind: "last_owner" } + | { ok: false; kind: "db_error"; detail: string }; + +// --------------------------------------------------------------------------- +// Org CRUD +// --------------------------------------------------------------------------- + +export async function listMyOrgs( + db: Db, + userId: string, +): Promise> { + const { data: memberships, error } = await db + .from("org_members") + .select("org_id, role") + .eq("user_id", userId); + if (error) return { ok: false, kind: "db_error", detail: error.message }; + + const rows = (memberships ?? []) as { org_id: string; role: OrgRole }[]; + const roleByOrg = new Map(); + for (const r of rows) roleByOrg.set(r.org_id, r.role); + const orgIds = [...roleByOrg.keys()]; + if (orgIds.length === 0) return { ok: true, orgs: [] }; + + const { data: orgs, error: orgsError } = await db + .from("organizations") + .select("*") + .in("id", orgIds); + if (orgsError) + return { ok: false, kind: "db_error", detail: orgsError.message }; + + const enriched = ((orgs ?? []) as { id: string }[]).map((o) => ({ + ...o, + role: roleByOrg.get(o.id) ?? null, + })); + return { ok: true, orgs: enriched }; +} + +export async function createOrg( + db: Db, + params: { userId: string; name: unknown }, +): Promise }>> { + const name = typeof params.name === "string" ? params.name.trim() : ""; + if (!name) return { ok: false, kind: "validation", detail: "name is required" }; + + const { data: org, error } = await db + .from("organizations") + .insert({ name, personal: false, created_by: params.userId }) + .select("*") + .single(); + if (error || !org) + return { + ok: false, + kind: "db_error", + detail: error?.message ?? "Failed to create organization", + }; + + const { error: memberError } = await db + .from("org_members") + .insert({ org_id: org.id, user_id: params.userId, role: "owner" }); + if (memberError) { + // Roll back the org so we never leave an org without an owner. + await db.from("organizations").delete().eq("id", org.id); + return { ok: false, kind: "db_error", detail: memberError.message }; + } + + return { ok: true, org: { ...org, role: "owner" } }; +} + +export async function getOrg( + db: Db, + params: { userId: string; orgId: string }, +): Promise }>> { + const role = await getOrgRole(params.userId, params.orgId, db); + if (!role) return { ok: false, kind: "not_found" }; + + const { data: org, error } = await db + .from("organizations") + .select("*") + .eq("id", params.orgId) + .single(); + if (error || !org) return { ok: false, kind: "not_found" }; + return { ok: true, org: { ...org, role } }; +} + +// --------------------------------------------------------------------------- +// Membership +// --------------------------------------------------------------------------- + +export async function listMembers( + db: Db, + params: { userId: string; orgId: string }, +): Promise> { + const role = await getOrgRole(params.userId, params.orgId, db); + if (!role) return { ok: false, kind: "not_found" }; + + const { data, error } = await db + .from("org_members") + .select("id, user_id, role, created_at") + .eq("org_id", params.orgId); + if (error) return { ok: false, kind: "db_error", detail: error.message }; + return { ok: true, members: data ?? [] }; +} + +async function countOwners(db: Db, orgId: string): Promise { + const { data } = await db + .from("org_members") + .select("user_id") + .eq("org_id", orgId) + .eq("role", "owner"); + return ((data ?? []) as unknown[]).length; +} + +export async function addMember( + db: Db, + params: { + actorId: string; + orgId: string; + targetUserId: string; + role: unknown; + }, +): Promise }>> { + const actorRole = await getOrgRole(params.actorId, params.orgId, db); + if (!actorRole) return { ok: false, kind: "not_found" }; + if (!roleCanManage(actorRole)) return { ok: false, kind: "forbidden" }; + + const role = + typeof params.role === "string" && VALID_ROLES.includes(params.role as OrgRole) + ? (params.role as OrgRole) + : "member"; + // Only an owner may grant the owner role — an admin cannot escalate. + if (role === "owner" && actorRole !== "owner") + return { ok: false, kind: "forbidden" }; + + const { data: existing } = await db + .from("org_members") + .select("id") + .eq("org_id", params.orgId) + .eq("user_id", params.targetUserId) + .single(); + if (existing) + return { ok: false, kind: "conflict", detail: "User is already a member" }; + + const { data: member, error } = await db + .from("org_members") + .insert({ + org_id: params.orgId, + user_id: params.targetUserId, + role, + }) + .select("*") + .single(); + if (error || !member) + return { + ok: false, + kind: "db_error", + detail: error?.message ?? "Failed to add member", + }; + return { ok: true, member }; +} + +export async function updateMember( + db: Db, + params: { + actorId: string; + orgId: string; + targetUserId: string; + role: unknown; + }, +): Promise }>> { + const actorRole = await getOrgRole(params.actorId, params.orgId, db); + if (!actorRole) return { ok: false, kind: "not_found" }; + if (!roleCanManage(actorRole)) return { ok: false, kind: "forbidden" }; + + if ( + typeof params.role !== "string" || + !VALID_ROLES.includes(params.role as OrgRole) + ) + return { ok: false, kind: "validation", detail: "invalid role" }; + const nextRole = params.role as OrgRole; + // Only an owner may grant/keep the owner role. + if (nextRole === "owner" && actorRole !== "owner") + return { ok: false, kind: "forbidden" }; + + const targetRole = await getOrgRole(params.targetUserId, params.orgId, db); + if (!targetRole) return { ok: false, kind: "not_found" }; + + // Last-owner protection: demoting the sole owner would strand the org. + if (targetRole === "owner" && nextRole !== "owner") { + const owners = await countOwners(db, params.orgId); + if (owners <= 1) return { ok: false, kind: "last_owner" }; + } + + const { data: member, error } = await db + .from("org_members") + .update({ role: nextRole, updated_at: new Date().toISOString() }) + .eq("org_id", params.orgId) + .eq("user_id", params.targetUserId) + .select("*") + .single(); + if (error || !member) + return { + ok: false, + kind: "db_error", + detail: error?.message ?? "Failed to update member", + }; + return { ok: true, member }; +} + +export async function removeMember( + db: Db, + params: { actorId: string; orgId: string; targetUserId: string }, +): Promise>> { + const actorRole = await getOrgRole(params.actorId, params.orgId, db); + if (!actorRole) return { ok: false, kind: "not_found" }; + // A member may remove themselves (leave); managing others needs owner/admin. + const isSelf = params.actorId === params.targetUserId; + if (!isSelf && !roleCanManage(actorRole)) + return { ok: false, kind: "forbidden" }; + + const targetRole = await getOrgRole(params.targetUserId, params.orgId, db); + if (!targetRole) return { ok: false, kind: "not_found" }; + + // Last-owner protection: never remove the sole owner. + if (targetRole === "owner") { + const owners = await countOwners(db, params.orgId); + if (owners <= 1) return { ok: false, kind: "last_owner" }; + } + + const { error } = await db + .from("org_members") + .delete() + .eq("org_id", params.orgId) + .eq("user_id", params.targetUserId); + if (error) return { ok: false, kind: "db_error", detail: error.message }; + return { ok: true }; +} + +// --------------------------------------------------------------------------- +// Teams +// --------------------------------------------------------------------------- + +export async function listTeams( + db: Db, + params: { userId: string; orgId: string }, +): Promise> { + const role = await getOrgRole(params.userId, params.orgId, db); + if (!role) return { ok: false, kind: "not_found" }; + + const { data, error } = await db + .from("teams") + .select("*") + .eq("org_id", params.orgId); + if (error) return { ok: false, kind: "db_error", detail: error.message }; + return { ok: true, teams: data ?? [] }; +} + +export async function createTeam( + db: Db, + params: { userId: string; orgId: string; name: unknown }, +): Promise }>> { + const role = await getOrgRole(params.userId, params.orgId, db); + if (!role) return { ok: false, kind: "not_found" }; + if (!roleCanManage(role)) return { ok: false, kind: "forbidden" }; + + const name = typeof params.name === "string" ? params.name.trim() : ""; + if (!name) return { ok: false, kind: "validation", detail: "name is required" }; + + const { data: team, error } = await db + .from("teams") + .insert({ org_id: params.orgId, name, created_by: params.userId }) + .select("*") + .single(); + if (error || !team) + return { + ok: false, + kind: "db_error", + detail: error?.message ?? "Failed to create team", + }; + return { ok: true, team }; +} + +export async function deleteTeam( + db: Db, + params: { userId: string; orgId: string; teamId: string }, +): Promise>> { + const role = await getOrgRole(params.userId, params.orgId, db); + if (!role) return { ok: false, kind: "not_found" }; + if (!roleCanManage(role)) return { ok: false, kind: "forbidden" }; + + const { data: team } = await db + .from("teams") + .select("id") + .eq("id", params.teamId) + .eq("org_id", params.orgId) + .single(); + if (!team) return { ok: false, kind: "not_found" }; + + const { error } = await db + .from("teams") + .delete() + .eq("id", params.teamId) + .eq("org_id", params.orgId); + if (error) return { ok: false, kind: "db_error", detail: error.message }; + return { ok: true }; +} + +export async function addTeamMember( + db: Db, + params: { + actorId: string; + orgId: string; + teamId: string; + targetUserId: string; + }, +): Promise }>> { + const actorRole = await getOrgRole(params.actorId, params.orgId, db); + if (!actorRole) return { ok: false, kind: "not_found" }; + if (!roleCanManage(actorRole)) return { ok: false, kind: "forbidden" }; + + const { data: team } = await db + .from("teams") + .select("id") + .eq("id", params.teamId) + .eq("org_id", params.orgId) + .single(); + if (!team) return { ok: false, kind: "not_found" }; + + // The target must already belong to the org — teams group existing members. + const targetRole = await getOrgRole(params.targetUserId, params.orgId, db); + if (!targetRole) + return { + ok: false, + kind: "validation", + detail: "User is not a member of this organization", + }; + + const { data: existing } = await db + .from("team_members") + .select("id") + .eq("team_id", params.teamId) + .eq("user_id", params.targetUserId) + .single(); + if (existing) + return { + ok: false, + kind: "conflict", + detail: "User is already on this team", + }; + + const { data: member, error } = await db + .from("team_members") + .insert({ team_id: params.teamId, user_id: params.targetUserId }) + .select("*") + .single(); + if (error || !member) + return { + ok: false, + kind: "db_error", + detail: error?.message ?? "Failed to add team member", + }; + return { ok: true, member }; +} + +export async function removeTeamMember( + db: Db, + params: { + actorId: string; + orgId: string; + teamId: string; + targetUserId: string; + }, +): Promise>> { + const actorRole = await getOrgRole(params.actorId, params.orgId, db); + if (!actorRole) return { ok: false, kind: "not_found" }; + if (!roleCanManage(actorRole)) return { ok: false, kind: "forbidden" }; + + const { data: team } = await db + .from("teams") + .select("id") + .eq("id", params.teamId) + .eq("org_id", params.orgId) + .single(); + if (!team) return { ok: false, kind: "not_found" }; + + const { error } = await db + .from("team_members") + .delete() + .eq("team_id", params.teamId) + .eq("user_id", params.targetUserId); + if (error) return { ok: false, kind: "db_error", detail: error.message }; + return { ok: true }; +} diff --git a/backend/src/lib/userDataCleanup.ts b/backend/src/lib/userDataCleanup.ts index aa812e619..392b41de3 100644 --- a/backend/src/lib/userDataCleanup.ts +++ b/backend/src/lib/userDataCleanup.ts @@ -153,6 +153,90 @@ async function removeEmailFromSharedWith( ); } +/** + * Tear down a user's organization footprint on account deletion. + * + * - Personal orgs (one-per-user) are deleted outright; the ON DELETE CASCADE + * on org_members/teams/team_members cleans up their rows. + * - For shared orgs the user merely belonged to, their membership row is + * removed. If they were the org's sole owner, ownership is handed to the + * earliest remaining member so the org isn't stranded ownerless; if no + * members remain, the now-empty org is deleted. + * - Any team memberships are removed. + * + * Without this, deleting a user would orphan their personal organization and + * its org_members rows (org_id on content uses ON DELETE SET NULL, so the + * cascade that removes their content does not remove their org). + */ +export async function deleteUserOrganizations(db: Db, userId: string) { + const { data: personalOrgs, error: personalError } = await db + .from("organizations") + .select("id") + .eq("created_by", userId) + .eq("personal", true); + await throwIfError(personalError, "Failed to load personal organizations"); + const personalOrgIds = new Set( + uniqueStrings( + ((personalOrgs ?? []) as { id: string | null }[]).map((r) => r.id), + ), + ); + await deleteByIds(db, "organizations", [...personalOrgIds]); + + const { data: memberships, error: membershipError } = await db + .from("org_members") + .select("id, org_id, role") + .eq("user_id", userId); + await throwIfError(membershipError, "Failed to load org memberships"); + + for (const m of (memberships ?? []) as { + id: string; + org_id: string; + role: string; + }[]) { + if (personalOrgIds.has(m.org_id)) continue; // already cascade-deleted + + const { error: deleteError } = await db + .from("org_members") + .delete() + .eq("id", m.id); + await throwIfError(deleteError, "Failed to remove org membership"); + + if (m.role !== "owner") continue; + + // Sole-owner handoff: if no owners remain, promote the earliest member; + // if the org is now empty, delete it. + const { data: owners } = await db + .from("org_members") + .select("id") + .eq("org_id", m.org_id) + .eq("role", "owner"); + if (((owners ?? []) as unknown[]).length > 0) continue; + + const { data: remaining } = await db + .from("org_members") + .select("id") + .eq("org_id", m.org_id) + .order("created_at", { ascending: true }) + .limit(1); + const heir = ((remaining ?? []) as { id: string }[])[0]; + if (heir) { + const { error: promoteError } = await db + .from("org_members") + .update({ role: "owner" }) + .eq("id", heir.id); + await throwIfError(promoteError, "Failed to hand off org ownership"); + } else { + await deleteByIds(db, "organizations", [m.org_id]); + } + } + + const { error: teamError } = await db + .from("team_members") + .delete() + .eq("user_id", userId); + await throwIfError(teamError, "Failed to remove team memberships"); +} + export async function deleteAllUserChats(db: Db, userId: string) { const [assistantChats, tabularChats] = await Promise.all([ db.from("chats").delete().eq("user_id", userId), @@ -340,4 +424,8 @@ export async function deleteUserAccountData( for (const result of results) { await throwIfError(result.error, "Failed to delete account data"); } + + // Organizations use ON DELETE SET NULL on content (not CASCADE), so the + // content deletions above never remove the user's orgs — do that here. + await deleteUserOrganizations(db, userId); } diff --git a/backend/src/lib/userDataExport.ts b/backend/src/lib/userDataExport.ts index 76749f0c6..7b47b367f 100644 --- a/backend/src/lib/userDataExport.ts +++ b/backend/src/lib/userDataExport.ts @@ -240,6 +240,20 @@ export async function buildUserAccountExport( : Promise.resolve([]), ]); + // Organization membership + the orgs/teams the user belongs to, for a + // complete GDPR-style export of their multi-tenant footprint. + const orgMemberships = await selectAll(db, "org_members", (query) => + query.eq("user_id", userId).order("created_at", { ascending: true }), + ); + const orgIds = idsFrom(orgMemberships, "org_id"); + const [organizations, teamMemberships] = await Promise.all([ + selectByIds(db, "organizations", "id", orgIds), + selectAll(db, "team_members", (query) => + query.eq("user_id", userId).order("created_at", { ascending: true }), + ), + ]); + const teams = await selectByIds(db, "teams", "org_id", orgIds); + const projectIds = idsFrom(projects); const projectDocuments = await selectByIds( db, @@ -263,6 +277,10 @@ export async function buildUserAccountExport( user: { id: userId, email: userEmail ?? null }, profile, api_keys: apiKeys, + organizations, + org_members: orgMemberships, + teams, + team_members: teamMemberships, projects, project_subfolders: folders, documents, diff --git a/backend/src/routes/documents.ts b/backend/src/routes/documents.ts index 22ecd2286..0833e2325 100644 --- a/backend/src/routes/documents.ts +++ b/backend/src/routes/documents.ts @@ -21,7 +21,7 @@ import { attachLatestVersionNumbers, loadActiveVersion, } from "../lib/documentVersions"; -import { ensureDocAccess } from "../lib/access"; +import { ensureDocAccess, resolveContentOrgId } from "../lib/access"; import { singleFileUpload } from "../lib/upload"; import { ALLOWED_DOCUMENT_TYPES, @@ -123,7 +123,7 @@ documentsRouter.get("/:documentId/display", requireAuth, async (req, res) => { const { data: doc } = await db .from("documents") - .select("id, user_id, project_id") + .select("id, user_id, project_id, org_id") .eq("id", documentId) .single(); if (!doc) @@ -185,7 +185,7 @@ documentsRouter.post("/download-zip", requireAuth, async (req, res) => { const db = createServerSupabase(); const { data: rawDocs, error } = await db .from("documents") - .select("id, current_version_id, user_id, project_id") + .select("id, current_version_id, user_id, project_id, org_id") .in("id", document_ids); if (error) return void res.status(500).json({ detail: error.message }); @@ -245,7 +245,7 @@ documentsRouter.get("/:documentId/url", requireAuth, async (req, res) => { const { data: doc, error } = await db .from("documents") - .select("id, user_id, project_id") + .select("id, user_id, project_id, org_id") .eq("id", documentId) .single(); if (error || !doc) @@ -296,7 +296,7 @@ documentsRouter.get("/:documentId/docx", requireAuth, async (req, res) => { const { data: doc, error } = await db .from("documents") - .select("id, user_id, project_id") + .select("id, user_id, project_id, org_id") .eq("id", documentId) .single(); if (error || !doc) @@ -357,7 +357,7 @@ documentsRouter.get("/:documentId/versions", requireAuth, async (req, res) => { const { data: doc } = await db .from("documents") - .select("id, current_version_id, user_id, project_id") + .select("id, current_version_id, user_id, project_id, org_id") .eq("id", documentId) .single(); if (!doc) @@ -409,7 +409,7 @@ documentsRouter.post( const { data: targetDoc } = await db .from("documents") - .select("id, user_id, project_id") + .select("id, user_id, project_id, org_id") .eq("id", documentId) .single(); if (!targetDoc) @@ -420,7 +420,7 @@ documentsRouter.post( const { data: sourceDoc } = await db .from("documents") - .select("id, user_id, project_id") + .select("id, user_id, project_id, org_id") .eq("id", sourceDocumentId) .single(); if (!sourceDoc) @@ -593,7 +593,7 @@ documentsRouter.post( const { data: doc } = await db .from("documents") - .select("id, user_id, project_id, current_version_id") + .select("id, user_id, project_id, current_version_id, org_id") .eq("id", documentId) .single(); if (!doc) @@ -745,7 +745,7 @@ documentsRouter.patch( const { data: doc } = await db .from("documents") - .select("id, user_id, project_id") + .select("id, user_id, project_id, org_id") .eq("id", documentId) .single(); if (!doc) @@ -794,7 +794,7 @@ documentsRouter.put( const { data: doc } = await db .from("documents") - .select("id, user_id, project_id") + .select("id, user_id, project_id, org_id") .eq("id", documentId) .single(); if (!doc) @@ -940,7 +940,7 @@ documentsRouter.delete( const { data: doc } = await db .from("documents") - .select("id, user_id, project_id, current_version_id") + .select("id, user_id, project_id, current_version_id, org_id") .eq("id", documentId) .single(); if (!doc) @@ -1054,7 +1054,7 @@ documentsRouter.get( const { data: doc } = await db .from("documents") - .select("id, user_id, project_id") + .select("id, user_id, project_id, org_id") .eq("id", documentId) .single(); if (!doc) @@ -1117,7 +1117,7 @@ async function handleEditResolution( }); const { data: doc } = await db .from("documents") - .select("current_version_id, user_id, project_id") + .select("current_version_id, user_id, project_id, org_id") .eq("id", documentId) .single(); if (!doc) { @@ -1153,7 +1153,7 @@ async function handleEditResolution( const { data: doc, error: docErr } = await db .from("documents") - .select("id, current_version_id, user_id, project_id") + .select("id, current_version_id, user_id, project_id, org_id") .eq("id", documentId) .single(); devLog(`[edit-resolution] fetched doc`, { doc, docErr }); @@ -1316,12 +1316,14 @@ export async function handleDocumentUpload( }); const content = file.buffer; + const orgId = await resolveContentOrgId(db, { userId, projectId }); const { data: doc, error: insertErr } = await db .from("documents") .insert({ project_id: projectId, user_id: userId, status: "processing", + org_id: orgId, library_kind: options.libraryKind ?? "file", library_folder_id: options.libraryFolderId ?? null, }) diff --git a/backend/src/routes/downloads.ts b/backend/src/routes/downloads.ts index 9726f86e5..32d46dad7 100644 --- a/backend/src/routes/downloads.ts +++ b/backend/src/routes/downloads.ts @@ -46,7 +46,7 @@ downloadsRouter.get("/:token", requireAuth, async (req, res) => { const { data: doc } = await db .from("documents") - .select("id, user_id, project_id") + .select("id, user_id, project_id, org_id") .eq("id", version.document_id) .single(); if (!doc) diff --git a/backend/src/routes/orgs.ts b/backend/src/routes/orgs.ts new file mode 100644 index 000000000..cb23bb5c4 --- /dev/null +++ b/backend/src/routes/orgs.ts @@ -0,0 +1,235 @@ +// Express router for organizations + RBAC, mounted at /orgs. +// +// Thin handlers: they read res.locals (userId/userEmail set by requireAuth), +// delegate to lib/orgs.ts, and map the discriminated results onto HTTP status +// codes with {detail} bodies — mirroring routes/projects.ts. + +import { Router } from "express"; +import { requireAuth } from "../middleware/auth"; +import { createServerSupabase } from "../lib/supabase"; +import { + listMyOrgs, + createOrg, + getOrg, + listMembers, + addMember, + updateMember, + removeMember, + listTeams, + createTeam, + deleteTeam, + addTeamMember, + removeTeamMember, + type OrgResult, +} from "../lib/orgs"; + +export const orgsRouter = Router(); + +type Db = ReturnType; + +// Map the service's discriminated failure kinds onto HTTP responses. Kept in +// one place so every handler reports errors consistently. +function sendFailure( + res: { status: (n: number) => { json: (b: unknown) => void } }, + result: Extract, { ok: false }>, +) { + switch (result.kind) { + case "validation": + return void res.status(400).json({ detail: result.detail }); + case "forbidden": + return void res + .status(403) + .json({ detail: "You do not have permission to do that." }); + case "not_found": + return void res.status(404).json({ detail: "Organization not found" }); + case "conflict": + return void res.status(409).json({ detail: result.detail }); + case "last_owner": + return void res.status(409).json({ + detail: "An organization must keep at least one owner.", + }); + case "db_error": + return void res.status(500).json({ detail: result.detail }); + } +} + +// Resolve an email to a user id via the admin API (mirrors the lookup pattern +// in routes/projects.ts /people). Returns null when unknown. +async function resolveUserIdByEmail( + db: Db, + email: string, +): Promise { + const normalized = email.trim().toLowerCase(); + if (!normalized) return null; + const { data } = await db.auth.admin.listUsers({ perPage: 1000 }); + for (const u of data?.users ?? []) { + if (u.email && u.email.toLowerCase() === normalized) return u.id; + } + return null; +} + +// GET /orgs — orgs the caller belongs to (with their role). +orgsRouter.get("/", requireAuth, async (_req, res) => { + const userId = res.locals.userId as string; + const db = createServerSupabase(); + const result = await listMyOrgs(db, userId); + if (!result.ok) return sendFailure(res, result); + res.json(result.orgs); +}); + +// POST /orgs — create an org; caller becomes its owner. +orgsRouter.post("/", requireAuth, async (req, res) => { + const userId = res.locals.userId as string; + const db = createServerSupabase(); + const result = await createOrg(db, { userId, name: req.body?.name }); + if (!result.ok) return sendFailure(res, result); + res.status(201).json(result.org); +}); + +// GET /orgs/:orgId — org detail (any member). +orgsRouter.get("/:orgId", requireAuth, async (req, res) => { + const userId = res.locals.userId as string; + const db = createServerSupabase(); + const result = await getOrg(db, { userId, orgId: req.params.orgId }); + if (!result.ok) return sendFailure(res, result); + res.json(result.org); +}); + +// GET /orgs/:orgId/members — list members (any member). +orgsRouter.get("/:orgId/members", requireAuth, async (req, res) => { + const userId = res.locals.userId as string; + const db = createServerSupabase(); + const result = await listMembers(db, { userId, orgId: req.params.orgId }); + if (!result.ok) return sendFailure(res, result); + res.json(result.members); +}); + +// POST /orgs/:orgId/members — add a member by email (owner/admin only). +orgsRouter.post("/:orgId/members", requireAuth, async (req, res) => { + const userId = res.locals.userId as string; + const { email, role } = req.body as { email?: string; role?: string }; + if (typeof email !== "string" || !email.trim()) + return void res.status(400).json({ detail: "email is required" }); + + const db = createServerSupabase(); + const targetUserId = await resolveUserIdByEmail(db, email); + if (!targetUserId) + return void res.status(404).json({ detail: "No user with that email" }); + + const result = await addMember(db, { + actorId: userId, + orgId: req.params.orgId, + targetUserId, + role, + }); + if (!result.ok) return sendFailure(res, result); + res.status(201).json(result.member); +}); + +// PATCH /orgs/:orgId/members/:userId — change a member's role (owner/admin). +orgsRouter.patch("/:orgId/members/:userId", requireAuth, async (req, res) => { + const userId = res.locals.userId as string; + const db = createServerSupabase(); + const result = await updateMember(db, { + actorId: userId, + orgId: req.params.orgId, + targetUserId: req.params.userId, + role: req.body?.role, + }); + if (!result.ok) return sendFailure(res, result); + res.json(result.member); +}); + +// DELETE /orgs/:orgId/members/:userId — remove a member (owner/admin, or self). +orgsRouter.delete("/:orgId/members/:userId", requireAuth, async (req, res) => { + const userId = res.locals.userId as string; + const db = createServerSupabase(); + const result = await removeMember(db, { + actorId: userId, + orgId: req.params.orgId, + targetUserId: req.params.userId, + }); + if (!result.ok) return sendFailure(res, result); + res.status(204).send(); +}); + +// GET /orgs/:orgId/teams — list teams (any member). +orgsRouter.get("/:orgId/teams", requireAuth, async (req, res) => { + const userId = res.locals.userId as string; + const db = createServerSupabase(); + const result = await listTeams(db, { userId, orgId: req.params.orgId }); + if (!result.ok) return sendFailure(res, result); + res.json(result.teams); +}); + +// POST /orgs/:orgId/teams — create a team (owner/admin only). +orgsRouter.post("/:orgId/teams", requireAuth, async (req, res) => { + const userId = res.locals.userId as string; + const db = createServerSupabase(); + const result = await createTeam(db, { + userId, + orgId: req.params.orgId, + name: req.body?.name, + }); + if (!result.ok) return sendFailure(res, result); + res.status(201).json(result.team); +}); + +// DELETE /orgs/:orgId/teams/:teamId — delete a team (owner/admin only). +orgsRouter.delete("/:orgId/teams/:teamId", requireAuth, async (req, res) => { + const userId = res.locals.userId as string; + const db = createServerSupabase(); + const result = await deleteTeam(db, { + userId, + orgId: req.params.orgId, + teamId: req.params.teamId, + }); + if (!result.ok) return sendFailure(res, result); + res.status(204).send(); +}); + +// POST /orgs/:orgId/teams/:teamId/members — add a team member by email. +orgsRouter.post( + "/:orgId/teams/:teamId/members", + requireAuth, + async (req, res) => { + const userId = res.locals.userId as string; + const { email } = req.body as { email?: string }; + if (typeof email !== "string" || !email.trim()) + return void res.status(400).json({ detail: "email is required" }); + + const db = createServerSupabase(); + const targetUserId = await resolveUserIdByEmail(db, email); + if (!targetUserId) + return void res + .status(404) + .json({ detail: "No user with that email" }); + + const result = await addTeamMember(db, { + actorId: userId, + orgId: req.params.orgId, + teamId: req.params.teamId, + targetUserId, + }); + if (!result.ok) return sendFailure(res, result); + res.status(201).json(result.member); + }, +); + +// DELETE /orgs/:orgId/teams/:teamId/members/:userId — remove a team member. +orgsRouter.delete( + "/:orgId/teams/:teamId/members/:userId", + requireAuth, + async (req, res) => { + const userId = res.locals.userId as string; + const db = createServerSupabase(); + const result = await removeTeamMember(db, { + actorId: userId, + orgId: req.params.orgId, + teamId: req.params.teamId, + targetUserId: req.params.userId, + }); + if (!result.ok) return sendFailure(res, result); + res.status(204).send(); + }, +); diff --git a/backend/src/routes/projects.ts b/backend/src/routes/projects.ts index ea24f0711..e3fb1469c 100644 --- a/backend/src/routes/projects.ts +++ b/backend/src/routes/projects.ts @@ -13,7 +13,12 @@ import { storageKey, } from "../lib/storage"; import { docxToPdf, convertedPdfKey } from "../lib/convert"; -import { checkProjectAccess } from "../lib/access"; +import { + checkProjectAccess, + getOrgRole, + getPersonalOrgId, + resolveContentOrgId, +} from "../lib/access"; import { singleFileUpload } from "../lib/upload"; import { deleteUserProjects } from "../lib/userDataCleanup"; import { @@ -215,11 +220,12 @@ projectsRouter.get("/", requireAuth, async (req, res) => { projectsRouter.post("/", requireAuth, async (req, res) => { const userId = res.locals.userId as string; const userEmail = res.locals.userEmail as string | undefined; - const { name, cm_number, practice, shared_with } = req.body as { + const { name, cm_number, practice, shared_with, org_id } = req.body as { name: string; cm_number?: string; practice?: string; shared_with?: string[]; + org_id?: string | null; }; if (!name?.trim()) return void res.status(400).json({ detail: "name is required" }); @@ -249,6 +255,20 @@ projectsRouter.post("/", requireAuth, async (req, res) => { }); } + // Tenant assignment: an explicit org_id must be one the caller belongs to; + // otherwise the project lands in the caller's personal org. + let resolvedOrgId: string | null; + if (org_id) { + const role = await getOrgRole(userId, org_id, db); + if (!role) + return void res + .status(400) + .json({ detail: "You are not a member of that organization." }); + resolvedOrgId = org_id; + } else { + resolvedOrgId = await getPersonalOrgId(userId, db); + } + const { data, error } = await db .from("projects") .insert({ @@ -257,6 +277,7 @@ projectsRouter.post("/", requireAuth, async (req, res) => { cm_number: normalizeOptionalString(cm_number), practice: normalizeOptionalString(practice), shared_with: cleanedSharedWith, + org_id: resolvedOrgId, }) .select("*") .single(); @@ -279,11 +300,15 @@ projectsRouter.get("/:projectId", requireAuth, async (req, res) => { if (error || !project) return void res.status(404).json({ detail: "Project not found" }); - const canAccess = + let canAccess = project.user_id === userId || (userEmail && Array.isArray(project.shared_with) && project.shared_with.includes(userEmail)); + // Third access branch: org membership on the project's org (multi-tenant). + if (!canAccess && project.org_id) { + canAccess = (await getOrgRole(userId, project.org_id, db)) !== null; + } if (!canAccess) return void res.status(404).json({ detail: "Project not found" }); @@ -495,11 +520,13 @@ projectsRouter.post( if (doc.project_id === projectId) return void res.json(doc); if (doc.project_id === null) { - // Standalone → assign project_id + // Standalone → assign project_id (and inherit the project's org). + const targetOrgId = await resolveContentOrgId(db, { userId, projectId }); const { data: updated, error } = await db .from("documents") .update({ project_id: projectId, + org_id: targetOrgId, library_folder_id: null, updated_at: new Date().toISOString(), }) @@ -546,12 +573,14 @@ projectsRouter.post( .json({ detail: "Failed to read source document bytes" }); } + const copyOrgId = await resolveContentOrgId(db, { userId, projectId }); const { data: copy, error } = await db .from("documents") .insert({ project_id: projectId, user_id: userId, status: doc.status, + org_id: copyOrgId, }) .select("*") .single(); @@ -939,12 +968,14 @@ export async function handleDocumentUpload( }); const content = file.buffer; + const orgId = await resolveContentOrgId(db, { userId, projectId }); const { data: doc, error: insertErr } = await db .from("documents") .insert({ project_id: projectId, user_id: userId, status: "processing", + org_id: orgId, }) .select("*") .single(); diff --git a/backend/src/routes/tabular.ts b/backend/src/routes/tabular.ts index db0ad51d4..05a59312f 100644 --- a/backend/src/routes/tabular.ts +++ b/backend/src/routes/tabular.ts @@ -36,6 +36,7 @@ import { checkProjectAccess, ensureReviewAccess, filterAccessibleDocumentIds, + resolveContentOrgId, } from "../lib/access"; import { safeErrorLog, safeErrorMessage } from "../lib/safeError"; import { @@ -212,6 +213,12 @@ tabularRouter.post("/", requireAuth, async (req, res) => { db, ) : []; + // Tenant assignment: inherit the project's org when project-scoped, + // otherwise the caller's personal org. + const orgId = await resolveContentOrgId(db, { + userId, + projectId: project_id ?? null, + }); const { data: review, error } = await db .from("tabular_reviews") .insert({ @@ -221,6 +228,7 @@ tabularRouter.post("/", requireAuth, async (req, res) => { document_ids: allowedDocumentIds, project_id: project_id ?? null, workflow_id: workflow_id ?? null, + org_id: orgId, }) .select("*") .single(); @@ -376,7 +384,7 @@ tabularRouter.get("/:reviewId/people", requireAuth, async (req, res) => { const { data: review } = await db .from("tabular_reviews") - .select("id, user_id, project_id, shared_with") + .select("id, user_id, project_id, shared_with, org_id") .eq("id", reviewId) .single(); if (!review) @@ -676,7 +684,7 @@ tabularRouter.post("/:reviewId/clear-cells", requireAuth, async (req, res) => { const db = createServerSupabase(); const { data: review, error: reviewError } = await db .from("tabular_reviews") - .select("id, user_id, project_id") + .select("id, user_id, project_id, org_id") .eq("id", reviewId) .single(); if (reviewError || !review) @@ -1038,7 +1046,7 @@ tabularRouter.get("/:reviewId/chats", requireAuth, async (req, res) => { // Verify access (owner or shared-project member). const { data: review, error } = await db .from("tabular_reviews") - .select("id, user_id, project_id") + .select("id, user_id, project_id, org_id") .eq("id", reviewId) .single(); if (error || !review) @@ -1113,7 +1121,7 @@ tabularRouter.get( const { data: review } = await db .from("tabular_reviews") - .select("id, user_id, project_id") + .select("id, user_id, project_id, org_id") .eq("id", reviewId) .single(); if (!review) diff --git a/backend/src/routes/workflows.ts b/backend/src/routes/workflows.ts index 62b28d4c8..5aab8f86e 100644 --- a/backend/src/routes/workflows.ts +++ b/backend/src/routes/workflows.ts @@ -7,6 +7,7 @@ import { type SystemWorkflow, } from "../lib/systemWorkflows"; import { findMissingUserEmails } from "../lib/userLookup"; +import { getOrgRole, getPersonalOrgId } from "../lib/access"; export const workflowsRouter = Router(); @@ -230,17 +231,33 @@ async function resolveWorkflowAccess( } const normalizedUserEmail = (userEmail ?? "").trim().toLowerCase(); - if (!normalizedUserEmail) return null; + if (normalizedUserEmail) { + const { data: share } = await db + .from("workflow_shares") + .select("allow_edit") + .eq("workflow_id", workflowId) + .eq("shared_with_email", normalizedUserEmail) + .maybeSingle(); + if (share) + return { + workflow: workflowRecord, + allowEdit: !!share.allow_edit, + isOwner: false, + }; + } - const { data: share } = await db - .from("workflow_shares") - .select("allow_edit") - .eq("workflow_id", workflowId) - .eq("shared_with_email", normalizedUserEmail) - .maybeSingle(); - if (!share) return null; + // Org-visibility branch: a workflow living in an org the caller belongs to is + // readable (allow_edit stays false; edits remain owner/share-gated). Keeps + // the workflow_shares mechanism intact and consistent with the updated + // get_workflows_overview RPC. + const orgId = (workflowRecord as { org_id?: string | null }).org_id ?? null; + if (orgId) { + const role = await getOrgRole(userId, orgId, db); + if (role) + return { workflow: workflowRecord, allowEdit: false, isOwner: false }; + } - return { workflow: workflowRecord, allowEdit: !!share.allow_edit, isOwner: false }; + return null; } function toOpenSourceSubmissionSummary( @@ -353,6 +370,7 @@ workflowsRouter.post("/", requireAuth, asyncRoute(async (req, res) => { .json({ detail: "metadata.type must be 'assistant' or 'tabular'" }); const db = createServerSupabase(); + const orgId = await getPersonalOrgId(userId, db); devLog("[workflows/create] request", { userId, title: title.trim(), @@ -381,6 +399,7 @@ workflowsRouter.post("/", requireAuth, asyncRoute(async (req, res) => { jurisdictions: normalizeJurisdictions(metadata?.jurisdictions) ?? DEFAULT_WORKFLOW_JURISDICTIONS, + org_id: orgId, }) .select("*") .single(); From 4d587b9f90268f3e8db9a26f9831cf5a238e1073 Mon Sep 17 00:00:00 2001 From: Amalanand Muthukumaran Date: Sun, 26 Jul 2026 16:55:31 -0700 Subject: [PATCH 2/2] feat: project role ladder + capability matrix over the org access branches MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 #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 --- .../integration/projectChat.routes.test.ts | 1 + .../integration/projects.routes.test.ts | 77 ++++++++++++++- .../integration/tabular.routes.test.ts | 43 +++++++- backend/src/lib/__tests__/access.test.ts | 30 +++++- backend/src/lib/__tests__/permissions.test.ts | 57 +++++++++++ backend/src/lib/access.ts | 99 +++++++++++++++---- backend/src/lib/permissions.ts | 69 +++++++++++++ backend/src/routes/chat.ts | 4 +- backend/src/routes/documents.ts | 9 +- backend/src/routes/projectChat.ts | 5 +- backend/src/routes/projects.ts | 69 ++++++------- backend/src/routes/tabular.ts | 44 ++++++--- 12 files changed, 428 insertions(+), 79 deletions(-) create mode 100644 backend/src/lib/__tests__/permissions.test.ts create mode 100644 backend/src/lib/permissions.ts diff --git a/backend/src/__tests__/integration/projectChat.routes.test.ts b/backend/src/__tests__/integration/projectChat.routes.test.ts index e2fbd5e30..6d1c6d040 100644 --- a/backend/src/__tests__/integration/projectChat.routes.test.ts +++ b/backend/src/__tests__/integration/projectChat.routes.test.ts @@ -103,6 +103,7 @@ describe("POST /projects/:projectId/chat", () => { checkProjectAccess.mockResolvedValue({ ok: true, isOwner: true, + projectRole: "owner", project: { id: "p1", user_id: "u1", shared_with: null }, }); }); diff --git a/backend/src/__tests__/integration/projects.routes.test.ts b/backend/src/__tests__/integration/projects.routes.test.ts index d54eead23..f50274df3 100644 --- a/backend/src/__tests__/integration/projects.routes.test.ts +++ b/backend/src/__tests__/integration/projects.routes.test.ts @@ -86,12 +86,16 @@ vi.mock("../../middleware/auth", () => ({ // Every export of lib/access must be present — other routers (chat, documents, // downloads, tabular) import from it at app load. -vi.mock("../../lib/access", () => ({ +vi.mock("../../lib/access", async (importOriginal) => ({ + ...(await importOriginal()), checkProjectAccess: (...args: unknown[]) => checkProjectAccess(...args), ensureDocAccess: vi.fn(async () => ({ ok: true, isOwner: true })), ensureReviewAccess: vi.fn(async () => ({ ok: true, isOwner: true })), filterAccessibleDocumentIds: vi.fn(async (ids: string[]) => ids), listAccessibleProjectIds: vi.fn(async () => []), + getOrgRole: vi.fn(async () => null), + getPersonalOrgId: vi.fn(async () => null), + resolveContentOrgId: vi.fn(async () => null), })); // user router imports all four cleanup helpers at module load. @@ -121,6 +125,7 @@ describe("projects.routes", () => { checkProjectAccess.mockResolvedValue({ ok: true, isOwner: true, + projectRole: "owner", project: { id: "p1", user_id: "u1", shared_with: null }, }); deleteUserProjects.mockResolvedValue(1); @@ -261,6 +266,7 @@ describe("projects.routes", () => { }); it("returns 404 when the caller is neither owner nor shared", async () => { + checkProjectAccess.mockResolvedValue({ ok: false }); supabaseState.tables.projects = { data: { id: "p1", @@ -276,7 +282,17 @@ describe("projects.routes", () => { expect(res.body.detail).toBe("Project not found"); }); - it("grants access to a shared member (is_owner false)", async () => { + it("grants access to a shared member (is_owner false, editor role)", async () => { + checkProjectAccess.mockResolvedValue({ + ok: true, + isOwner: false, + projectRole: "editor", + project: { + id: "p1", + user_id: "someone-else", + shared_with: ["u1@test.local"], + }, + }); supabaseState.tables.projects = { data: { id: "p1", @@ -291,7 +307,11 @@ describe("projects.routes", () => { const res = await request(app).get("/projects/p1").set(...AUTH); expect(res.status).toBe(200); - expect(res.body).toMatchObject({ id: "p1", is_owner: false }); + expect(res.body).toMatchObject({ + id: "p1", + is_owner: false, + access_role: "editor", + }); }); it("returns 200 with documents/folders/is_owner when owned", async () => { @@ -320,6 +340,57 @@ describe("projects.routes", () => { }); }); + // ── DELETE /projects/:projectId/folders/:folderId (role ladder) ────── + // Folder deletion cascades into nested documents, so it is manager+: + // owner and org owner/admin pass; shared editors and org viewers do not. + describe("DELETE /projects/:projectId/folders/:folderId", () => { + const roleAccess = (projectRole: string) => ({ + ok: true, + isOwner: projectRole === "owner", + projectRole, + project: { id: "p1", user_id: "u1", shared_with: null }, + }); + + beforeEach(() => { + supabaseState.tables.project_subfolders = { + data: [{ id: "f1", parent_folder_id: null }], + error: null, + }; + supabaseState.tables.documents = { data: [], error: null }; + }); + + it("allows the owner (204)", async () => { + const res = await request(app) + .delete("/projects/p1/folders/f1") + .set(...AUTH); + expect(res.status).toBe(204); + }); + + it("allows an org owner/admin (manager) (204)", async () => { + checkProjectAccess.mockResolvedValue(roleAccess("manager")); + const res = await request(app) + .delete("/projects/p1/folders/f1") + .set(...AUTH); + expect(res.status).toBe(204); + }); + + it("blocks a shared editor (404)", async () => { + checkProjectAccess.mockResolvedValue(roleAccess("editor")); + const res = await request(app) + .delete("/projects/p1/folders/f1") + .set(...AUTH); + expect(res.status).toBe(404); + }); + + it("blocks a plain org member (viewer) (404)", async () => { + checkProjectAccess.mockResolvedValue(roleAccess("viewer")); + const res = await request(app) + .delete("/projects/p1/folders/f1") + .set(...AUTH); + expect(res.status).toBe(404); + }); + }); + // ── GET /projects/:projectId/documents (checkProjectAccess guard) ───── describe("GET /projects/:projectId/documents", () => { it("returns 404 when checkProjectAccess denies access", async () => { diff --git a/backend/src/__tests__/integration/tabular.routes.test.ts b/backend/src/__tests__/integration/tabular.routes.test.ts index 6ee9db10c..5b9fe12c6 100644 --- a/backend/src/__tests__/integration/tabular.routes.test.ts +++ b/backend/src/__tests__/integration/tabular.routes.test.ts @@ -97,13 +97,17 @@ vi.mock("../../middleware/auth", () => ({ next(), })); -vi.mock("../../lib/access", () => ({ +vi.mock("../../lib/access", async (importOriginal) => ({ + ...(await importOriginal()), ensureReviewAccess: (...args: unknown[]) => ensureReviewAccess(...args), checkProjectAccess: (...args: unknown[]) => checkProjectAccess(...args), filterAccessibleDocumentIds: (...args: unknown[]) => filterAccessibleDocumentIds(...args), ensureDocAccess: vi.fn(async () => ({ ok: true, isOwner: true })), listAccessibleProjectIds: vi.fn(async () => []), + getOrgRole: vi.fn(async () => null), + getPersonalOrgId: vi.fn(async () => null), + resolveContentOrgId: vi.fn(async () => null), })); vi.mock("../../lib/userSettings", () => ({ @@ -128,10 +132,15 @@ describe("tabular.routes", () => { vi.clearAllMocks(); resetSupabaseState(); // Default: caller is the owner with full access. - ensureReviewAccess.mockResolvedValue({ ok: true, isOwner: true }); + ensureReviewAccess.mockResolvedValue({ + ok: true, + isOwner: true, + projectRole: "owner", + }); checkProjectAccess.mockResolvedValue({ ok: true, isOwner: true, + projectRole: "owner", project: { id: "p1", user_id: "u1", shared_with: null }, }); // Default: every requested doc is accessible (identity passthrough). @@ -352,12 +361,16 @@ describe("tabular.routes", () => { expect(res.body.detail).toBe("Review not found"); }); - it("returns 403 when a non-owner edits columns_config", async () => { + it("returns 403 when an editor (shared member) edits columns_config", async () => { supabaseState.tables.tabular_reviews = { data: { id: "r1", user_id: "other", project_id: "p1" }, error: null, }; - ensureReviewAccess.mockResolvedValue({ ok: true, isOwner: false }); + ensureReviewAccess.mockResolvedValue({ + ok: true, + isOwner: false, + projectRole: "editor", + }); const res = await request(app) .patch("/tabular-review/r1") @@ -365,7 +378,7 @@ describe("tabular.routes", () => { .send({ columns_config: [{ index: 0, name: "X", prompt: "p" }] }); expect(res.status).toBe(403); - expect(res.body.detail).toBe("Only the review owner can change columns"); + expect(res.body.detail).toBe("Only a review manager can change columns"); }); }); @@ -424,6 +437,26 @@ describe("tabular.routes", () => { expect(res.body.detail).toBe("Review not found"); }); + it("returns 403 for an editor — clearing cells is manager+", async () => { + supabaseState.tables.tabular_reviews = { + data: { id: "r1", user_id: "other", project_id: "p1" }, + error: null, + }; + ensureReviewAccess.mockResolvedValue({ + ok: true, + isOwner: false, + projectRole: "editor", + }); + + const res = await request(app) + .post("/tabular-review/r1/clear-cells") + .set(...AUTH) + .send({ document_ids: ["d1"] }); + + expect(res.status).toBe(403); + expect(res.body.detail).toBe("Only a review manager can clear cells"); + }); + it("returns 204 on success", async () => { supabaseState.tables.tabular_reviews = { data: { id: "r1", user_id: "u1", project_id: null }, diff --git a/backend/src/lib/__tests__/access.test.ts b/backend/src/lib/__tests__/access.test.ts index e6436a184..25918c691 100644 --- a/backend/src/lib/__tests__/access.test.ts +++ b/backend/src/lib/__tests__/access.test.ts @@ -199,7 +199,7 @@ describe("org RBAC access", () => { ], }); - it("grants an org member read access without ownership", async () => { + it("grants an org member read access without ownership (viewer)", async () => { await expect( checkProjectAccess("proj-a", "carol", "carol@example.com", db), ).resolves.toMatchObject({ @@ -207,10 +207,11 @@ describe("org RBAC access", () => { isOwner: false, role: "member", canManage: false, + projectRole: "viewer", }); }); - it("marks org owners/admins as able to manage", async () => { + it("marks org owners/admins as able to manage (manager)", async () => { await expect( checkProjectAccess("proj-a", "dave", "dave@example.com", db), ).resolves.toMatchObject({ @@ -218,6 +219,31 @@ describe("org RBAC access", () => { isOwner: false, role: "admin", canManage: true, + projectRole: "manager", + }); + }); + + it("derives owner and editor roles on the non-org branches", async () => { + await expect( + checkProjectAccess("proj-a", "alice", "alice@example.com", db), + ).resolves.toMatchObject({ ok: true, projectRole: "owner" }); + + const sharedDb = makeDb({ + projects: [ + { + id: "proj-s", + user_id: "alice", + shared_with: ["eve@example.com"], + org_id: null, + }, + ], + }); + await expect( + checkProjectAccess("proj-s", "eve", "eve@example.com", sharedDb), + ).resolves.toMatchObject({ + ok: true, + isOwner: false, + projectRole: "editor", }); }); diff --git a/backend/src/lib/__tests__/permissions.test.ts b/backend/src/lib/__tests__/permissions.test.ts new file mode 100644 index 000000000..2382351a0 --- /dev/null +++ b/backend/src/lib/__tests__/permissions.test.ts @@ -0,0 +1,57 @@ +import { describe, expect, it } from "vitest"; +import { can, type Capability, type ProjectRole } from "../permissions"; + +// The full role × capability matrix, asserted cell by cell so any edit to +// the policy table is a visible diff here. +const EXPECTED: Record> = { + viewer: { + "project.view": true, + "content.edit": false, + "docs.organize": false, + "structure.manage": false, + "members.manage": false, + "container.delete": false, + }, + editor: { + "project.view": true, + "content.edit": true, + "docs.organize": true, + "structure.manage": false, + "members.manage": false, + "container.delete": false, + }, + manager: { + "project.view": true, + "content.edit": true, + "docs.organize": true, + "structure.manage": true, + "members.manage": true, + "container.delete": false, + }, + owner: { + "project.view": true, + "content.edit": true, + "docs.organize": true, + "structure.manage": true, + "members.manage": true, + "container.delete": true, + }, +}; + +describe("permissions matrix", () => { + for (const [role, caps] of Object.entries(EXPECTED)) { + for (const [capability, allowed] of Object.entries(caps)) { + it(`${role} ${allowed ? "can" : "cannot"} ${capability}`, () => { + expect( + can(role as ProjectRole, capability as Capability), + ).toBe(allowed); + }); + } + } + + it("fails closed on missing or unknown roles", () => { + expect(can(null, "project.view")).toBe(false); + expect(can(undefined, "project.view")).toBe(false); + expect(can("admin" as ProjectRole, "project.view")).toBe(false); + }); +}); diff --git a/backend/src/lib/access.ts b/backend/src/lib/access.ts index fdf59c966..53960fe45 100644 --- a/backend/src/lib/access.ts +++ b/backend/src/lib/access.ts @@ -14,22 +14,28 @@ * 3. org member — the row's `org_id` is an org the caller belongs to * (multi-tenant RBAC). * - * Two orthogonal flags are returned so callers can gate correctly: - * - `isOwner` — TRUE only for branch (1), the row owner. Existing - * owner-only gates (delete, rename, member management) - * depend on this meaning, so it is NOT overloaded. - * - `canManage` — TRUE for the row owner OR an org owner/admin. Use this - * for org-level management operations that should be - * available to org admins as well as the row owner. + * Each branch derives a ProjectRole (lib/permissions.ts) and routes gate on + * `can(projectRole, capability)` rather than on ad-hoc flags: + * - branch (1) row owner → "owner" + * - branch (2) shared email → "editor" (content collaboration) + * - branch (3) org owner/admin → "manager" (curate, not delete containers) + * - branch (3) plain member → "viewer" (visibility, not ownership) + * + * Legacy flags are kept for compatibility and derived from the role: + * - `isOwner` — TRUE only for branch (1), the row owner. + * - `canManage` — `can(projectRole, "members.manage")`. * - `role` — the caller's org role for branch (3), else null. */ import type { createServerSupabase } from "./supabase"; +import { can, type ProjectRole } from "./permissions"; + +export { can, type Capability, type ProjectRole } from "./permissions"; type Db = ReturnType; // EXTENSION POINT (RBAC): new roles added to the org_members CHECK constraint -// should be reflected here and in canManage-style predicates. +// should be reflected here and in `orgRoleToProjectRole`. export type OrgRole = "owner" | "admin" | "member"; /** Roles allowed to manage an org (members, teams, settings). */ @@ -37,6 +43,15 @@ export function roleCanManage(role: OrgRole | null | undefined): boolean { return role === "owner" || role === "admin"; } +/** + * What standing in the tenant grants on a row the caller doesn't own: + * org owners/admins curate content ("manager"), plain members see it + * ("viewer") — the ADR's "visibility, not ownership". + */ +function orgRoleToProjectRole(role: OrgRole): ProjectRole { + return roleCanManage(role) ? "manager" : "viewer"; +} + /** * The caller's role in a single org, or null if they are not a member. */ @@ -118,6 +133,7 @@ export type ProjectAccess = isOwner: boolean; role: OrgRole | null; canManage: boolean; + projectRole: ProjectRole; project: { id: string; user_id: string; @@ -146,20 +162,36 @@ export async function checkProjectAccess( org_id?: string | null; }; if (proj.user_id === userId) { - return { ok: true, isOwner: true, role: null, canManage: true, project: proj }; + return { + ok: true, + isOwner: true, + role: null, + canManage: true, + projectRole: "owner", + project: proj, + }; } const sharedWith = Array.isArray(proj.shared_with) ? proj.shared_with : []; const email = (userEmail ?? "").toLowerCase(); if (email && sharedWith.some((e) => (e ?? "").toLowerCase() === email)) { - return { ok: true, isOwner: false, role: null, canManage: false, project: proj }; + return { + ok: true, + isOwner: false, + role: null, + canManage: false, + projectRole: "editor", + project: proj, + }; } const role = await getOrgRole(userId, proj.org_id, db); if (role) { + const projectRole = orgRoleToProjectRole(role); return { ok: true, isOwner: false, role, - canManage: roleCanManage(role), + canManage: can(projectRole, "members.manage"), + projectRole, project: proj, }; } @@ -167,7 +199,13 @@ export async function checkProjectAccess( } type ResourceAccess = - | { ok: true; isOwner: boolean; role: OrgRole | null; canManage: boolean } + | { + ok: true; + isOwner: boolean; + role: OrgRole | null; + canManage: boolean; + projectRole: ProjectRole; + } | { ok: false }; /** @@ -183,14 +221,22 @@ export async function ensureDocAccess( db: Db, ): Promise { if (doc.user_id === userId) - return { ok: true, isOwner: true, role: null, canManage: true }; + return { + ok: true, + isOwner: true, + role: null, + canManage: true, + projectRole: "owner", + }; const docRole = await getOrgRole(userId, doc.org_id, db); if (docRole) { + const projectRole = orgRoleToProjectRole(docRole); return { ok: true, isOwner: false, role: docRole, - canManage: roleCanManage(docRole), + canManage: can(projectRole, "members.manage"), + projectRole, }; } if (!doc.project_id) return { ok: false }; @@ -203,9 +249,13 @@ export async function ensureDocAccess( if (access.ok) return { ok: true, + // isOwner keeps meaning "owns this row": the project owner is + // not the owner of a collaborator's document, but inherits the + // project role for capability checks. isOwner: false, role: access.role, canManage: access.canManage, + projectRole: access.projectRole, }; return { ok: false }; } @@ -232,20 +282,34 @@ export async function ensureReviewAccess( db: Db, ): Promise { if (review.user_id === userId) - return { ok: true, isOwner: true, role: null, canManage: true }; + return { + ok: true, + isOwner: true, + role: null, + canManage: true, + projectRole: "owner", + }; const email = (userEmail ?? "").toLowerCase(); if (email && Array.isArray(review.shared_with)) { if (review.shared_with.some((e) => (e ?? "").toLowerCase() === email)) { - return { ok: true, isOwner: false, role: null, canManage: false }; + return { + ok: true, + isOwner: false, + role: null, + canManage: false, + projectRole: "editor", + }; } } const reviewRole = await getOrgRole(userId, review.org_id, db); if (reviewRole) { + const projectRole = orgRoleToProjectRole(reviewRole); return { ok: true, isOwner: false, role: reviewRole, - canManage: roleCanManage(reviewRole), + canManage: can(projectRole, "members.manage"), + projectRole, }; } if (!review.project_id) return { ok: false }; @@ -261,6 +325,7 @@ export async function ensureReviewAccess( isOwner: false, role: access.role, canManage: access.canManage, + projectRole: access.projectRole, }; return { ok: false }; } diff --git a/backend/src/lib/permissions.ts b/backend/src/lib/permissions.ts new file mode 100644 index 000000000..229c169c5 --- /dev/null +++ b/backend/src/lib/permissions.ts @@ -0,0 +1,69 @@ +/** + * Project-scoped roles and the capability matrix. + * + * Every access decision reduces to: derive the caller's ProjectRole for the + * container (lib/access.ts), then ask `can(role, capability)` here. Routes + * never compare roles or re-derive rights from `isOwner` — they declare the + * capability they need, so the policy lives in exactly one table. + * + * The ladder (each tier includes everything below it): + * + * role | granted to + * ---------|------------------------------------------------------------ + * owner | the row's user_id + * manager | org owner/admin of the row's org + * editor | shared_with email collaborators + * viewer | plain org members (visibility, not ownership) + * + * capability | min role | covers + * -----------------|----------|-------------------------------------------- + * project.view | viewer | read docs/chats/reviews, download, watch + * | | generation streams + * content.edit | editor | upload documents, push versions, chat, + * | | accept/reject edits, run extractions + * docs.organize | editor | rename/move documents, create folders + * structure.manage | manager | rename/move/delete folders, clear review + * | | cells, edit review columns/document set + * members.manage | manager | edit shared_with and project metadata + * container.delete | owner | delete the project/review itself + * + * The editor/manager split is the load-bearing line (Drive's writer vs. + * fileOrganizer): content collaboration is broad, structural and destructive + * power is narrow. `container.delete` stays owner-only so tenant admins can + * curate content without being able to erase a colleague's container. + */ + +export type ProjectRole = "owner" | "manager" | "editor" | "viewer"; + +export type Capability = + | "project.view" + | "content.edit" + | "docs.organize" + | "structure.manage" + | "members.manage" + | "container.delete"; + +const ROLE_RANK: Record = { + viewer: 0, + editor: 1, + manager: 2, + owner: 3, +}; + +const REQUIRED_RANK: Record = { + "project.view": ROLE_RANK.viewer, + "content.edit": ROLE_RANK.editor, + "docs.organize": ROLE_RANK.editor, + "structure.manage": ROLE_RANK.manager, + "members.manage": ROLE_RANK.manager, + "container.delete": ROLE_RANK.owner, +}; + +/** Fail closed: an absent/unknown role can do nothing. */ +export function can( + role: ProjectRole | null | undefined, + capability: Capability, +): boolean { + if (!role || !(role in ROLE_RANK)) return false; + return ROLE_RANK[role] >= REQUIRED_RANK[capability]; +} diff --git a/backend/src/routes/chat.ts b/backend/src/routes/chat.ts index 2bb3dfda6..25f1cd6b8 100644 --- a/backend/src/routes/chat.ts +++ b/backend/src/routes/chat.ts @@ -22,6 +22,7 @@ import { getUserModelSettings, } from "../lib/userSettings"; import { checkProjectAccess } from "../lib/access"; +import { can } from "../lib/permissions"; import { safeErrorLog, safeErrorMessage } from "../lib/safeError"; export const chatRouter = Router(); @@ -115,8 +116,9 @@ async function validateAccessibleProjectId( db: Db, ): Promise<{ ok: true } | { ok: false; status: number; detail: string }> { if (!projectId) return { ok: true }; + // Creating a chat under a project contributes content to it: editor+. const access = await checkProjectAccess(projectId, userId, userEmail, db); - if (!access.ok) + if (!access.ok || !can(access.projectRole, "content.edit")) return { ok: false, status: 404, detail: "Project not found" }; return { ok: true }; } diff --git a/backend/src/routes/documents.ts b/backend/src/routes/documents.ts index 0833e2325..d69c6210d 100644 --- a/backend/src/routes/documents.ts +++ b/backend/src/routes/documents.ts @@ -22,6 +22,7 @@ import { loadActiveVersion, } from "../lib/documentVersions"; import { ensureDocAccess, resolveContentOrgId } from "../lib/access"; +import { can } from "../lib/permissions"; import { singleFileUpload } from "../lib/upload"; import { ALLOWED_DOCUMENT_TYPES, @@ -415,7 +416,7 @@ documentsRouter.post( if (!targetDoc) return void res.status(404).json({ detail: "Document not found" }); const targetAccess = await ensureDocAccess(targetDoc, userId, userEmail, db); - if (!targetAccess.ok) + if (!targetAccess.ok || !can(targetAccess.projectRole, "content.edit")) return void res.status(404).json({ detail: "Document not found" }); const { data: sourceDoc } = await db @@ -599,7 +600,7 @@ documentsRouter.post( if (!doc) return void res.status(404).json({ detail: "Document not found" }); const access = await ensureDocAccess(doc, userId, userEmail, db); - if (!access.ok) + if (!access.ok || !can(access.projectRole, "content.edit")) return void res.status(404).json({ detail: "Document not found" }); const suffix = file.originalname.includes(".") @@ -751,7 +752,7 @@ documentsRouter.patch( if (!doc) return void res.status(404).json({ detail: "Document not found" }); const access = await ensureDocAccess(doc, userId, userEmail, db); - if (!access.ok) + if (!access.ok || !can(access.projectRole, "content.edit")) return void res.status(404).json({ detail: "Document not found" }); const raw = req.body?.filename; @@ -1160,7 +1161,7 @@ async function handleEditResolution( if (!doc) return void res.status(404).json({ detail: "Document not found" }); const access = await ensureDocAccess(doc, userId, userEmail, db); - if (!access.ok) + if (!access.ok || !can(access.projectRole, "content.edit")) return void res.status(404).json({ detail: "Document not found" }); const active = await loadActiveVersion(documentId, db); diff --git a/backend/src/routes/projectChat.ts b/backend/src/routes/projectChat.ts index 56ea6efb5..3e7dd8a6e 100644 --- a/backend/src/routes/projectChat.ts +++ b/backend/src/routes/projectChat.ts @@ -22,6 +22,7 @@ import { getUserModelSettings, } from "../lib/userSettings"; import { checkProjectAccess } from "../lib/access"; +import { can } from "../lib/permissions"; import { safeErrorLog, safeErrorMessage } from "../lib/safeError"; const PROJECT_SYSTEM_PROMPT_EXTRA = `PROJECT CONTEXT: @@ -62,13 +63,15 @@ projectChatRouter.post("/", requireAuth, async (req, res) => { const db = createServerSupabase(); // Verify the user has access to the project (owner or shared member). + // Project chat writes messages and can create document versions via + // tools: editor+. const projectAccess = await checkProjectAccess( projectId, userId, userEmail, db, ); - if (!projectAccess.ok) + if (!projectAccess.ok || !can(projectAccess.projectRole, "content.edit")) return void res.status(404).json({ detail: "Project not found" }); let chatId = chat_id ?? null; diff --git a/backend/src/routes/projects.ts b/backend/src/routes/projects.ts index e3fb1469c..18d2756c8 100644 --- a/backend/src/routes/projects.ts +++ b/backend/src/routes/projects.ts @@ -19,6 +19,7 @@ import { getPersonalOrgId, resolveContentOrgId, } from "../lib/access"; +import { can } from "../lib/permissions"; import { singleFileUpload } from "../lib/upload"; import { deleteUserProjects } from "../lib/userDataCleanup"; import { @@ -292,6 +293,10 @@ projectsRouter.get("/:projectId", requireAuth, async (req, res) => { const { projectId } = req.params; const db = createServerSupabase(); + const access = await checkProjectAccess(projectId, userId, userEmail, db); + if (!access.ok) + return void res.status(404).json({ detail: "Project not found" }); + const { data: project, error } = await db .from("projects") .select("*") @@ -300,18 +305,6 @@ projectsRouter.get("/:projectId", requireAuth, async (req, res) => { if (error || !project) return void res.status(404).json({ detail: "Project not found" }); - let canAccess = - project.user_id === userId || - (userEmail && - Array.isArray(project.shared_with) && - project.shared_with.includes(userEmail)); - // Third access branch: org membership on the project's org (multi-tenant). - if (!canAccess && project.org_id) { - canAccess = (await getOrgRole(userId, project.org_id, db)) !== null; - } - if (!canAccess) - return void res.status(404).json({ detail: "Project not found" }); - const [{ data: docs }, { data: folderData }] = await Promise.all([ db.from("documents").select("*").eq("project_id", projectId).order("created_at", { ascending: true }), db.from("project_subfolders").select("*").eq("project_id", projectId).order("created_at", { ascending: true }), @@ -326,7 +319,8 @@ projectsRouter.get("/:projectId", requireAuth, async (req, res) => { await attachDocumentOwnerLabels(db, docsTyped); res.json({ ...project, - is_owner: project.user_id === userId, + is_owner: access.isOwner, + access_role: access.projectRole, documents: docsTyped, folders: folderData ?? [], }); @@ -342,23 +336,16 @@ projectsRouter.get("/:projectId/people", requireAuth, async (req, res) => { const { projectId } = req.params; const db = createServerSupabase(); - const { data: project } = await db - .from("projects") - .select("id, user_id, shared_with") - .eq("id", projectId) - .single(); - if (!project) + // Roster is visible to anyone who can see the project — including org + // members, who previously got a 404 here despite full read access. + const access = await checkProjectAccess(projectId, userId, userEmail, db); + if (!access.ok) return void res.status(404).json({ detail: "Project not found" }); - - const isOwner = project.user_id === userId; + const project = access.project; const sharedWith = (Array.isArray(project.shared_with) ? (project.shared_with as string[]) : [] ).map((e) => e.toLowerCase()); - const isShared = - !!userEmail && sharedWith.includes(userEmail.toLowerCase()); - if (!isOwner && !isShared) - return void res.status(404).json({ detail: "Project not found" }); // Use the mirrored profile email so sharing checks do not scan auth.users. const { userByEmail, userById } = await loadProfileUsersByEmail(db); @@ -410,6 +397,13 @@ projectsRouter.patch("/:projectId", requireAuth, async (req, res) => { } const db = createServerSupabase(); + // Metadata and membership edits are manager+: the owner, or an org + // owner/admin of the project's org. The user_id filter moves out of the + // UPDATE so managers can act on rows they don't own. + const access = await checkProjectAccess(projectId, userId, userEmail, db); + if (!access.ok || !can(access.projectRole, "members.manage")) + return void res.status(404).json({ detail: "Project not found" }); + if (Array.isArray(updates.shared_with)) { const missingSharedUsers = await findMissingUserEmails( db, @@ -426,7 +420,6 @@ projectsRouter.patch("/:projectId", requireAuth, async (req, res) => { .from("projects") .update({ ...updates, updated_at: new Date().toISOString() }) .eq("id", projectId) - .eq("user_id", userId) .select("*") .single(); if (error || !data) @@ -497,7 +490,7 @@ projectsRouter.post( const db = createServerSupabase(); const access = await checkProjectAccess(projectId, userId, userEmail, db); - if (!access.ok) + if (!access.ok || !can(access.projectRole, "docs.organize")) return void res.status(404).json({ detail: "Project not found" }); // Adding-by-id pulls a doc into the project — only the doc's owner @@ -682,7 +675,7 @@ projectsRouter.patch("/:projectId/documents/:documentId", requireAuth, async (re const db = createServerSupabase(); const access = await checkProjectAccess(projectId, userId, userEmail, db); - if (!access.ok) + if (!access.ok || !can(access.projectRole, "docs.organize")) return void res.status(404).json({ detail: "Project not found" }); const { data: doc } = await db @@ -747,7 +740,7 @@ projectsRouter.post( const db = createServerSupabase(); const access = await checkProjectAccess(projectId, userId, userEmail, db); - if (!access.ok) + if (!access.ok || !can(access.projectRole, "content.edit")) return void res.status(404).json({ detail: "Project not found" }); await handleDocumentUpload(req, res, userId, projectId, db); @@ -792,7 +785,8 @@ projectsRouter.post("/:projectId/folders", requireAuth, async (req, res) => { const db = createServerSupabase(); const access = await checkProjectAccess(projectId, userId, userEmail, db); - if (!access.ok) return void res.status(404).json({ detail: "Project not found" }); + if (!access.ok || !can(access.projectRole, "docs.organize")) + return void res.status(404).json({ detail: "Project not found" }); // Verify parent folder belongs to this project if (parent_folder_id) { @@ -818,8 +812,11 @@ projectsRouter.patch("/:projectId/folders/:folderId", requireAuth, async (req, r const body = req.body as { name?: string; parent_folder_id?: string | null }; const db = createServerSupabase(); + // Re-shaping the folder tree is manager+, like deleting it: a rename or + // re-parent rewrites the owner's organisation of the whole project. const access = await checkProjectAccess(projectId, userId, userEmail, db); - if (!access.ok) return void res.status(404).json({ detail: "Project not found" }); + if (!access.ok || !can(access.projectRole, "structure.manage")) + return void res.status(404).json({ detail: "Project not found" }); const updates: Record = { updated_at: new Date().toISOString() }; if (body.name != null) updates.name = body.name.trim(); @@ -855,9 +852,12 @@ projectsRouter.delete("/:projectId/folders/:folderId", requireAuth, async (req, const { projectId, folderId } = req.params; const db = createServerSupabase(); + // Folder deletion cascades into every nested document and its storage + // objects, so it is manager+ — the owner, or an org owner/admin. (This + // generalises the owner-only gate to the org tier.) const access = await checkProjectAccess(projectId, userId, userEmail, db); - if (!access.ok) return void res.status(404).json({ detail: "Project not found" }); - if (!access.isOwner) return void res.status(404).json({ detail: "Project not found" }); + if (!access.ok || !can(access.projectRole, "structure.manage")) + return void res.status(404).json({ detail: "Project not found" }); const { data: allFolders, error: foldersError } = await db .from("project_subfolders") @@ -917,7 +917,8 @@ projectsRouter.patch("/:projectId/documents/:documentId/folder", requireAuth, as const db = createServerSupabase(); const access = await checkProjectAccess(projectId, userId, userEmail, db); - if (!access.ok) return void res.status(404).json({ detail: "Project not found" }); + if (!access.ok || !can(access.projectRole, "docs.organize")) + return void res.status(404).json({ detail: "Project not found" }); if (folder_id) { const folder = await loadProjectFolder(db, projectId, folder_id); diff --git a/backend/src/routes/tabular.ts b/backend/src/routes/tabular.ts index 05a59312f..d5a773aa6 100644 --- a/backend/src/routes/tabular.ts +++ b/backend/src/routes/tabular.ts @@ -38,6 +38,7 @@ import { filterAccessibleDocumentIds, resolveContentOrgId, } from "../lib/access"; +import { can } from "../lib/permissions"; import { safeErrorLog, safeErrorMessage } from "../lib/safeError"; import { findMissingUserEmails, @@ -196,13 +197,14 @@ tabularRouter.post("/", requireAuth, async (req, res) => { const db = createServerSupabase(); if (project_id) { + // Creating a review inside a project contributes content to it. const access = await checkProjectAccess( project_id, userId, userEmail, db, ); - if (!access.ok) + if (!access.ok || !can(access.projectRole, "content.edit")) return void res.status(404).json({ detail: "Project not found" }); } const allowedDocumentIds = Array.isArray(document_ids) @@ -363,7 +365,11 @@ tabularRouter.get("/:reviewId", requireAuth, async (req, res) => { await attachActiveVersionPaths(db, docs); res.json({ - review: { ...review, is_owner: access.isOwner }, + review: { + ...review, + is_owner: access.isOwner, + access_role: access.projectRole, + }, cells: (cells ?? []).map((cell) => ({ ...cell, content: parseCellContent(cell.content), @@ -476,27 +482,34 @@ tabularRouter.patch("/:reviewId", requireAuth, async (req, res) => { ); if (!access.ok) return void res.status(404).json({ detail: "Review not found" }); + // Per-field gates, generalising #175's owner-only "settings" rule to + // the role ladder: title, document set and column set are structural + // (manager+ — renaming the container and re-shaping the grid, which + // destroys cells when narrowed); sharing is manager+; moving the + // review between projects stays owner-only. For shared_with + // collaborators this is exactly #175's behaviour; only org + // owners/admins gain these rights. if ( - (req.body.title != null || req.body.document_ids != null) && - !access.isOwner + (req.body.title != null || Array.isArray(req.body.document_ids)) && + !can(access.projectRole, "structure.manage") ) { return void res.status(403).json({ - detail: "Only the review owner can change review settings", + detail: "Only a review manager can change review settings", }); } if (req.body.columns_config != null) { - if (!access.isOwner) { + if (!can(access.projectRole, "structure.manage")) { return void res.status(403).json({ - detail: "Only the review owner can change columns", + detail: "Only a review manager can change columns", }); } updates.columns_config = req.body.columns_config; } if (sharedWithUpdate !== undefined) { - if (!access.isOwner) + if (!can(access.projectRole, "members.manage")) return void res .status(403) - .json({ detail: "Only the review owner can change sharing" }); + .json({ detail: "Only a review manager can change sharing" }); const missingSharedUsers = await findMissingUserEmails( db, sharedWithUpdate, @@ -692,6 +705,12 @@ tabularRouter.post("/:reviewId/clear-cells", requireAuth, async (req, res) => { const access = await ensureReviewAccess(review, userId, userEmail, db); if (!access.ok) return void res.status(404).json({ detail: "Review not found" }); + // Blanking extracted cells is bulk-destructive, so manager+ — the + // analogue of deleting a folder tree. + if (!can(access.projectRole, "structure.manage")) + return void res.status(403).json({ + detail: "Only a review manager can clear cells", + }); const { error } = await db .from("tabular_cells") @@ -729,7 +748,7 @@ tabularRouter.post( if (reviewError || !review) return void res.status(404).json({ detail: "Review not found" }); const access = await ensureReviewAccess(review, userId, userEmail, db); - if (!access.ok) + if (!access.ok || !can(access.projectRole, "content.edit")) return void res.status(404).json({ detail: "Review not found" }); const column = ( @@ -844,7 +863,8 @@ tabularRouter.post("/:reviewId/generate", requireAuth, async (req, res) => { if (reviewError || !review) return void res.status(404).json({ detail: "Review not found" }); const access = await ensureReviewAccess(review, userId, userEmail, db); - if (!access.ok) + // Generation overwrites cell contents and spends LLM budget: editor+. + if (!access.ok || !can(access.projectRole, "content.edit")) return void res.status(404).json({ detail: "Review not found" }); const columns: { @@ -1287,7 +1307,7 @@ tabularRouter.post("/:reviewId/chat", requireAuth, async (req, res) => { userEmail, db, ); - if (!reviewAccess.ok) + if (!reviewAccess.ok || !can(reviewAccess.projectRole, "content.edit")) return void res.status(404).json({ detail: "Review not found" }); // Fetch all cells and documents for this review