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/__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/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..25918c691 100644 --- a/backend/src/lib/__tests__/access.test.ts +++ b/backend/src/lib/__tests__/access.test.ts @@ -161,3 +161,147 @@ 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 (viewer)", async () => { + await expect( + checkProjectAccess("proj-a", "carol", "carol@example.com", db), + ).resolves.toMatchObject({ + ok: true, + isOwner: false, + role: "member", + canManage: false, + projectRole: "viewer", + }); + }); + + it("marks org owners/admins as able to manage (manager)", async () => { + await expect( + checkProjectAccess("proj-a", "dave", "dave@example.com", db), + ).resolves.toMatchObject({ + ok: true, + 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", + }); + }); + + 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__/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/__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..53960fe45 100644 --- a/backend/src/lib/access.ts +++ b/backend/src/lib/access.ts @@ -4,25 +4,141 @@ * 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). + * + * 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 `orgRoleToProjectRole`. +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"; +} + +/** + * 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. + */ +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; + projectRole: ProjectRole; project: { id: string; user_id: string; shared_with: string[] | null; + org_id?: string | null; }; } | { ok: false }; @@ -35,7 +151,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 +159,86 @@ 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, + 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, project: proj }; + if (email && sharedWith.some((e) => (e ?? "").toLowerCase() === email)) { + 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: can(projectRole, "members.manage"), + projectRole, + project: proj, + }; } return { ok: false }; } +type ResourceAccess = + | { + ok: true; + isOwner: boolean; + role: OrgRole | null; + canManage: boolean; + projectRole: ProjectRole; + } + | { 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, + 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: can(projectRole, "members.manage"), + projectRole, + }; + } if (!doc.project_id) return { ok: false }; const access = await checkProjectAccess( doc.project_id, @@ -78,17 +246,28 @@ export async function ensureDocAccess( userEmail, db, ); - if (access.ok) return { ok: true, isOwner: false }; + 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 }; } /** * 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 +275,43 @@ 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, + 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 }; + 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: can(projectRole, "members.manage"), + projectRole, + }; + } if (!review.project_id) return { ok: false }; const access = await checkProjectAccess( review.project_id, @@ -115,7 +319,14 @@ 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, + projectRole: access.projectRole, + }; return { ok: false }; } @@ -135,26 +346,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 +376,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/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/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/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 22ecd2286..d69c6210d 100644 --- a/backend/src/routes/documents.ts +++ b/backend/src/routes/documents.ts @@ -21,7 +21,8 @@ import { attachLatestVersionNumbers, loadActiveVersion, } from "../lib/documentVersions"; -import { ensureDocAccess } from "../lib/access"; +import { ensureDocAccess, resolveContentOrgId } from "../lib/access"; +import { can } from "../lib/permissions"; import { singleFileUpload } from "../lib/upload"; import { ALLOWED_DOCUMENT_TYPES, @@ -123,7 +124,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 +186,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 +246,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 +297,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 +358,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,18 +410,18 @@ 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) 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 .from("documents") - .select("id, user_id, project_id") + .select("id, user_id, project_id, org_id") .eq("id", sourceDocumentId) .single(); if (!sourceDoc) @@ -593,13 +594,13 @@ 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) 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(".") @@ -745,13 +746,13 @@ 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) 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; @@ -794,7 +795,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 +941,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 +1055,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 +1118,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,14 +1154,14 @@ 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 }); 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); @@ -1316,12 +1317,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..3bf32e473 --- /dev/null +++ b/backend/src/routes/orgs.ts @@ -0,0 +1,278 @@ +// 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"; +import { loadProfileUsersByEmail } from "../lib/userLookup"; + +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). Rows are enriched +// with the mirrored profile email/display_name (same source as the projects +// /people endpoint) so the client never has to render a bare user id. +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); + const { userById } = await loadProfileUsersByEmail(db); + const members = (result.members as { user_id: string }[]).map((m) => { + const info = userById.get(m.user_id); + return { + ...m, + email: info?.email ?? null, + display_name: info?.display_name ?? null, + }; + }); + res.json(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), each carrying its +// members enriched with profile email/display_name so the team panel can +// render people, not ids. +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); + const teams = result.teams as { id: string }[]; + if (teams.length === 0) return void res.json([]); + + const [{ data: memberRows }, { userById }] = await Promise.all([ + db + .from("team_members") + .select("team_id, user_id") + .in("team_id", teams.map((t) => t.id)), + loadProfileUsersByEmail(db), + ]); + const membersByTeam = new Map< + string, + { user_id: string; email: string | null; display_name: string | null }[] + >(); + for (const row of (memberRows ?? []) as { + team_id: string; + user_id: string; + }[]) { + const info = userById.get(row.user_id); + const list = membersByTeam.get(row.team_id) ?? []; + list.push({ + user_id: row.user_id, + email: info?.email ?? null, + display_name: info?.display_name ?? null, + }); + membersByTeam.set(row.team_id, list); + } + res.json( + teams.map((t) => ({ ...t, members: membersByTeam.get(t.id) ?? [] })), + ); +}); + +// 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/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 ea24f0711..18d2756c8 100644 --- a/backend/src/routes/projects.ts +++ b/backend/src/routes/projects.ts @@ -13,7 +13,13 @@ 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 { can } from "../lib/permissions"; import { singleFileUpload } from "../lib/upload"; import { deleteUserProjects } from "../lib/userDataCleanup"; import { @@ -215,11 +221,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 +256,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 +278,7 @@ projectsRouter.post("/", requireAuth, async (req, res) => { cm_number: normalizeOptionalString(cm_number), practice: normalizeOptionalString(practice), shared_with: cleanedSharedWith, + org_id: resolvedOrgId, }) .select("*") .single(); @@ -271,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("*") @@ -279,14 +305,6 @@ projectsRouter.get("/:projectId", requireAuth, async (req, res) => { if (error || !project) return void res.status(404).json({ detail: "Project not found" }); - const canAccess = - project.user_id === userId || - (userEmail && - Array.isArray(project.shared_with) && - project.shared_with.includes(userEmail)); - 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 }), @@ -301,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 ?? [], }); @@ -317,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); @@ -385,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, @@ -401,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) @@ -472,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 @@ -495,11 +513,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 +566,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(); @@ -653,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 @@ -718,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); @@ -763,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) { @@ -789,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(); @@ -826,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") @@ -888,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); @@ -939,12 +969,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..d5a773aa6 100644 --- a/backend/src/routes/tabular.ts +++ b/backend/src/routes/tabular.ts @@ -36,7 +36,9 @@ import { checkProjectAccess, ensureReviewAccess, filterAccessibleDocumentIds, + resolveContentOrgId, } from "../lib/access"; +import { can } from "../lib/permissions"; import { safeErrorLog, safeErrorMessage } from "../lib/safeError"; import { findMissingUserEmails, @@ -195,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) @@ -212,6 +215,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 +230,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(); @@ -355,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), @@ -376,7 +390,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) @@ -468,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, @@ -676,7 +697,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) @@ -684,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") @@ -721,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 = ( @@ -836,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: { @@ -1038,7 +1066,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 +1141,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) @@ -1279,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 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(); diff --git a/frontend/src/app/(pages)/account/layout.tsx b/frontend/src/app/(pages)/account/layout.tsx index 8c4a187a6..681a66c33 100644 --- a/frontend/src/app/(pages)/account/layout.tsx +++ b/frontend/src/app/(pages)/account/layout.tsx @@ -21,6 +21,11 @@ const TABS: TabDef[] = [ href: "/account/privacy-data", }, { id: "security", label: "Security", href: "/account/security" }, + { + id: "organizations", + label: "Organizations", + href: "/account/organizations", + }, { id: "models", label: "Model Preferences", href: "/account/models" }, { id: "api-keys", label: "API Keys", href: "/account/api-keys" }, { id: "connectors", label: "Connectors", href: "/account/connectors" }, diff --git a/frontend/src/app/(pages)/account/organizations/page.test.tsx b/frontend/src/app/(pages)/account/organizations/page.test.tsx new file mode 100644 index 000000000..9aa79cca2 --- /dev/null +++ b/frontend/src/app/(pages)/account/organizations/page.test.tsx @@ -0,0 +1,158 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { render, screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import OrganizationsPage from "./page"; + +const api = vi.hoisted(() => ({ + listOrgs: vi.fn(), + createOrg: vi.fn(), + listOrgMembers: vi.fn(), + listOrgTeams: vi.fn(), + addOrgMember: vi.fn(), + updateOrgMember: vi.fn(), + removeOrgMember: vi.fn(), + createOrgTeam: vi.fn(), + deleteOrgTeam: vi.fn(), + addOrgTeamMember: vi.fn(), + removeOrgTeamMember: vi.fn(), + lookupUserByEmail: vi.fn(), +})); + +vi.mock("@/app/lib/mikeApi", () => api); +vi.mock("@/app/contexts/AuthContext", () => ({ + useAuth: () => ({ + user: { id: "me", email: "me@firm.com" }, + isAuthenticated: true, + authLoading: false, + }), +})); + +const FIRM = { + id: "org-1", + name: "Acme Legal", + personal: false, + created_by: "me", + role: "owner" as const, +}; + +beforeEach(() => { + vi.clearAllMocks(); + api.listOrgs.mockResolvedValue([ + FIRM, + { + id: "org-personal", + name: "personal", + personal: true, + created_by: "me", + role: "owner", + }, + ]); + api.listOrgMembers.mockResolvedValue([ + { + id: "m1", + user_id: "me", + role: "owner", + email: "me@firm.com", + display_name: "Me", + }, + { + id: "m2", + user_id: "u2", + role: "member", + email: "colleague@firm.com", + display_name: "Colleague", + }, + ]); + api.listOrgTeams.mockResolvedValue([]); +}); + +describe("OrganizationsPage", () => { + it("lists non-personal orgs and hides the personal workspace", async () => { + render(); + expect(await screen.findByText("Acme Legal")).toBeInTheDocument(); + expect(screen.queryByText("personal")).not.toBeInTheDocument(); + }); + + it("creates an organization", async () => { + const user = userEvent.setup(); + api.createOrg.mockResolvedValue({ + id: "org-2", + name: "New Firm", + personal: false, + created_by: "me", + role: "owner", + }); + render(); + await screen.findByText("Acme Legal"); + + await user.type( + screen.getByPlaceholderText("New organization name…"), + "New Firm", + ); + await user.click( + screen.getByRole("button", { name: /create organization/i }), + ); + + expect(api.createOrg).toHaveBeenCalledWith("New Firm"); + expect(await screen.findByText("New Firm")).toBeInTheDocument(); + }); + + it("expands an org, shows the enriched roster and changes a role", async () => { + const user = userEvent.setup(); + api.updateOrgMember.mockResolvedValue({}); + render(); + + await user.click(await screen.findByText("Acme Legal")); + expect(await screen.findByText(/Colleague/)).toBeInTheDocument(); + expect(screen.getByText("(You)")).toBeInTheDocument(); + + await user.selectOptions( + screen.getByLabelText("Role for Colleague"), + "admin", + ); + await waitFor(() => + expect(api.updateOrgMember).toHaveBeenCalledWith( + "org-1", + "u2", + "admin", + ), + ); + }); + + it("surfaces server errors like last-owner protection inline", async () => { + const user = userEvent.setup(); + api.updateOrgMember.mockRejectedValue( + new Error("An organization must keep at least one owner."), + ); + render(); + + await user.click(await screen.findByText("Acme Legal")); + await user.selectOptions( + await screen.findByLabelText("Role for Colleague"), + "admin", + ); + + expect( + await screen.findByText( + "An organization must keep at least one owner.", + ), + ).toBeInTheDocument(); + }); + + it("hides management affordances for plain members", async () => { + const user = userEvent.setup(); + api.listOrgs.mockResolvedValue([ + { ...FIRM, role: "member" as const }, + ]); + render(); + + await user.click(await screen.findByText("Acme Legal")); + await screen.findByText(/Colleague/); + expect( + screen.queryByPlaceholderText("Add a colleague by email…"), + ).not.toBeInTheDocument(); + expect( + screen.queryByLabelText("Role for Colleague"), + ).not.toBeInTheDocument(); + }); +}); diff --git a/frontend/src/app/(pages)/account/organizations/page.tsx b/frontend/src/app/(pages)/account/organizations/page.tsx new file mode 100644 index 000000000..67019b92a --- /dev/null +++ b/frontend/src/app/(pages)/account/organizations/page.tsx @@ -0,0 +1,600 @@ +"use client"; + +import { useCallback, useEffect, useState } from "react"; +import { + Building2, + ChevronDown, + Loader2, + Plus, + Trash2, + Users, + X, +} from "lucide-react"; +import { useAuth } from "@/app/contexts/AuthContext"; +import { AddUserInput } from "@/app/components/shared/AddUserInput"; +import { PillButton } from "@/app/components/ui/pill-button"; +import { ConfirmPopup } from "@/app/components/popups/ConfirmPopup"; +import { + type Org, + type OrgMember, + type OrgRole, + type OrgTeam, + addOrgMember, + addOrgTeamMember, + createOrg, + createOrgTeam, + deleteOrgTeam, + listOrgMembers, + listOrgTeams, + listOrgs, + removeOrgMember, + removeOrgTeamMember, + updateOrgMember, +} from "@/app/lib/mikeApi"; +import { cn } from "@/app/lib/utils"; +import { + accountGlassInputClassName, +} from "../accountStyles"; +import { AccountSection } from "../AccountSection"; + +const ROLE_LABELS: Record = { + owner: "Owner", + admin: "Admin", + member: "Member", +}; + +const ROLE_DESCRIPTIONS: Record = { + owner: "Full control, including granting the owner role.", + admin: "Manages members, teams and the firm's shared content.", + member: "Sees the firm's shared content (read-only).", +}; + +function roleCanManage(role: OrgRole | null | undefined): boolean { + return role === "owner" || role === "admin"; +} + +function memberLabel(m: { + display_name: string | null; + email: string | null; + user_id: string; +}): string { + return m.display_name || m.email || m.user_id; +} + +function errorMessage(err: unknown): string { + return err instanceof Error ? err.message : "Something went wrong."; +} + +export default function OrganizationsPage() { + const { user } = useAuth(); + const [orgs, setOrgs] = useState(null); + const [loadError, setLoadError] = useState(null); + const [newOrgName, setNewOrgName] = useState(""); + const [creatingOrg, setCreatingOrg] = useState(false); + const [createError, setCreateError] = useState(null); + const [openOrgId, setOpenOrgId] = useState(null); + + const loadOrgs = useCallback(async () => { + try { + const rows = await listOrgs(); + // The auto-provisioned personal org is private plumbing — every + // account has one and it should not read as a manageable firm. + setOrgs(rows.filter((o) => !o.personal)); + setLoadError(null); + } catch (err) { + console.error("Failed to load organizations", err); + setLoadError("Could not load organizations."); + } + }, []); + + useEffect(() => { + void loadOrgs(); + }, [loadOrgs]); + + async function handleCreateOrg() { + const name = newOrgName.trim(); + if (!name || creatingOrg) return; + setCreatingOrg(true); + setCreateError(null); + try { + const org = await createOrg(name); + setNewOrgName(""); + setOrgs((prev) => [...(prev ?? []), org]); + setOpenOrgId(org.id); + } catch (err) { + setCreateError(errorMessage(err)); + } finally { + setCreatingOrg(false); + } + } + + return ( +
+
+
+

+ Organizations +

+

+ A firm is not one user. Create an organization to share + projects, documents, workflows and reviews with + colleagues — owners and admins manage, members can + view. Your private workspace stays separate. +

+
+ + +
+ setNewOrgName(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter") void handleCreateOrg(); + }} + placeholder="New organization name…" + className={cn(accountGlassInputClassName, "flex-1")} + /> + void handleCreateOrg()} + disabled={!newOrgName.trim() || creatingOrg} + > + {creatingOrg ? ( + + ) : ( + + )} + Create organization + +
+ {createError ? ( +

+ {createError} +

+ ) : null} +
+ + {loadError ? ( +

{loadError}

+ ) : orgs === null ? ( +
+ + Loading organizations… +
+ ) : orgs.length === 0 ? ( +

+ You are not part of any organization yet. +

+ ) : ( +
+ {orgs.map((org) => ( + + setOpenOrgId((prev) => + prev === org.id ? null : org.id, + ) + } + onLeftOrg={() => { + setOpenOrgId(null); + void loadOrgs(); + }} + /> + ))} +
+ )} +
+
+ ); +} + +function OrgCard({ + org, + currentUserId, + open, + onToggle, + onLeftOrg, +}: { + org: Org; + currentUserId: string | null; + open: boolean; + onToggle: () => void; + onLeftOrg: () => void; +}) { + const canManage = roleCanManage(org.role); + const [members, setMembers] = useState(null); + const [teams, setTeams] = useState(null); + const [error, setError] = useState(null); + const [busyKey, setBusyKey] = useState(null); + const [newTeamName, setNewTeamName] = useState(""); + const [pendingRemove, setPendingRemove] = useState(null); + + const refresh = useCallback(async () => { + try { + const [memberRows, teamRows] = await Promise.all([ + listOrgMembers(org.id), + listOrgTeams(org.id), + ]); + setMembers(memberRows); + setTeams(teamRows); + } catch (err) { + console.error("Failed to load organization detail", err); + setError("Could not load this organization."); + } + }, [org.id]); + + useEffect(() => { + if (open && members === null) void refresh(); + }, [open, members, refresh]); + + async function run(key: string, fn: () => Promise) { + if (busyKey) return; + setBusyKey(key); + setError(null); + try { + await fn(); + } catch (err) { + setError(errorMessage(err)); + } finally { + setBusyKey(null); + } + } + + return ( + + + + {open ? ( +
+ {error ? ( +

{error}

+ ) : null} + +
+

+ Members +

+ {canManage ? ( +
+ + run("add-member", async () => { + await addOrgMember( + org.id, + u.email, + "member", + ); + await refresh(); + }) + } + /> +
+ ) : null} + {members === null ? ( +
+ + Loading members… +
+ ) : ( +
    + {members.map((m) => { + const isSelf = + m.user_id === currentUserId; + return ( +
  • + + {memberLabel(m)} + {isSelf ? ( + + (You) + + ) : null} + + {canManage && !isSelf ? ( + + ) : ( + + )} + {canManage || isSelf ? ( + + ) : null} +
  • + ); + })} +
+ )} +
+ +
+

+ Teams +

+ {canManage ? ( +
+ + setNewTeamName(e.target.value) + } + placeholder="New team name…" + className={cn( + accountGlassInputClassName, + "flex-1", + )} + /> + + run("create-team", async () => { + await createOrgTeam( + org.id, + newTeamName.trim(), + ); + setNewTeamName(""); + await refresh(); + }) + } + > + Team + +
+ ) : null} + {teams === null ? null : teams.length === 0 ? ( +

+ No teams yet. +

+ ) : ( +
    + {teams.map((team) => ( +
  • +
    + + {team.name} + + {canManage ? ( + + ) : null} +
    +
    + {team.members.map((tm) => ( + + {memberLabel(tm)} + {canManage ? ( + + ) : null} + + ))} + {canManage ? ( +
    + + run( + `team-add-${team.id}`, + async () => { + await addOrgTeamMember( + org.id, + team.id, + u.email, + ); + await refresh(); + }, + ) + } + /> +
    + ) : null} +
    +
  • + ))} +
+ )} +
+
+ ) : null} + + setPendingRemove(null)} + onConfirm={() => { + const target = pendingRemove; + if (!target) return; + void run(`remove-${target.user_id}`, async () => { + await removeOrgMember(org.id, target.user_id); + setPendingRemove(null); + if (target.user_id === currentUserId) onLeftOrg(); + else await refresh(); + }); + }} + /> +
+ ); +} + +function RoleBadge({ role }: { role: OrgRole }) { + return ( + + {ROLE_LABELS[role]} + + ); +} diff --git a/frontend/src/app/components/documents/DocTable.tsx b/frontend/src/app/components/documents/DocTable.tsx index 050bdb90b..44441ecdf 100644 --- a/frontend/src/app/components/documents/DocTable.tsx +++ b/frontend/src/app/components/documents/DocTable.tsx @@ -45,6 +45,8 @@ import { SubfolderSvgIcon, } from "@/app/components/shared/FolderSvgIcon"; import { useAuth } from "@/app/contexts/AuthContext"; +import type { Capability } from "@/app/lib/permissions"; +import type { OwnerGate } from "@/app/components/projects/ProjectWorkspace"; import { WarningPopup } from "@/app/components/popups/WarningPopup"; import { UploadOverlay } from "@/app/components/assistant/UploadOverlay"; import { ConfirmPopup } from "@/app/components/popups/ConfirmPopup"; @@ -137,7 +139,13 @@ interface DocTableProps { onAddDocumentsActionChange?: (action: (() => void) | null) => void; onCreateFolderActionChange?: (action: (() => void) | null) => void; onSelectionActionsChange?: (actions: DocTableSelectionActions | null) => void; - onOwnerOnlyAction?: Dispatch>; + onOwnerOnlyAction?: Dispatch>; + /** + * Role-based capability check for the containing collection. When + * omitted (library, standalone docs) every capability is allowed and + * only the per-document creator checks apply. + */ + canDo?: (capability: Capability) => boolean; enableHeaderFilters?: boolean; } @@ -271,6 +279,7 @@ export function DocTable({ onCreateFolderActionChange, onSelectionActionsChange, onOwnerOnlyAction, + canDo, enableHeaderFilters = false, }: DocTableProps) { const [addDocsOpen, setAddDocsOpen] = useState(false); @@ -294,6 +303,21 @@ export function DocTable({ () => onOwnerOnlyAction ?? (() => {}), [onOwnerOnlyAction], ); + // Absent canDo (library/standalone contexts) means no role model applies. + const allowed = useMemo(() => canDo ?? (() => true), [canDo]); + /** Guard: false + popup when the caller's role lacks the capability. */ + const requireCapability = useCallback( + ( + capability: Capability, + action: string, + requiredRole: "manager" | "editor", + ) => { + if (allowed(capability)) return true; + setOwnerOnlyAction({ action, requiredRole }); + return false; + }, + [allowed, setOwnerOnlyAction], + ); useEffect(() => { loadingRef.current = loading; @@ -683,6 +707,10 @@ export function DocTable({ setCreatingFolderIn(undefined); return; } + if (!requireCapability("docs.organize", "create folders", "editor")) { + setCreatingFolderIn(undefined); + return; + } // Immediately hide the input and show an optimistic folder row setCreatingFolderIn(undefined); @@ -715,6 +743,14 @@ export function DocTable({ const name = renameFolderValue.trim(); setRenamingFolderId(null); if (!name) return; + if ( + !requireCapability( + "structure.manage", + "rename folders", + "manager", + ) + ) + return; setFolders((prev) => prev.map((f) => (f.id === folderId ? { ...f, name } : f)), ); @@ -748,6 +784,14 @@ export function DocTable({ } function requestDeleteFolder(folderId: string) { + if ( + !requireCapability( + "structure.manage", + "delete folders and their documents", + "manager", + ) + ) + return; const folder = folders.find((f) => f.id === folderId); if (!folder) return; const impact = folderDeleteImpact(folderId); @@ -907,6 +951,10 @@ export function DocTable({ } async function handleRemoveDocFromFolder(docId: string) { + if ( + !requireCapability("docs.organize", "move documents", "editor") + ) + return; setDocuments((prev) => prev.map((d) => d.id === docId ? { ...d, folder_id: null } : d, @@ -926,6 +974,12 @@ export function DocTable({ setRenamingDocumentId(null); return; } + if ( + !requireCapability("docs.organize", "rename documents", "editor") + ) { + setRenamingDocumentId(null); + return; + } if (hasFilenameExtensionChange(previous.filename, trimmed)) { setDocumentRenameWarning(extensionChangeWarning(previous.filename)); return; @@ -1268,6 +1322,10 @@ export function DocTable({ if (docId) { const doc = documents.find((d) => d.id === docId); if (!doc || (doc.folder_id ?? null) === targetFolderId) return; + if ( + !requireCapability("docs.organize", "move documents", "editor") + ) + return; setDocuments((prev) => prev.map((d) => d.id === docId ? { ...d, folder_id: targetFolderId } : d, @@ -1275,6 +1333,14 @@ export function DocTable({ ); await operations.moveDocument(docId, targetFolderId); } else if (subFolderId && subFolderId !== targetFolderId) { + if ( + !requireCapability( + "structure.manage", + "move folders", + "manager", + ) + ) + return; if ( targetFolderId !== null && wouldCreateCycle(subFolderId, targetFolderId) @@ -1944,6 +2010,10 @@ export function DocTable({ }, [downloadDoc, selectedDocIds]); const handleRemoveSelectedFromFolder = useCallback(async () => { + if ( + !requireCapability("docs.organize", "move documents", "editor") + ) + return; const ids = selectedDocIds.filter( (id) => docs.find((d) => d.id === id)?.folder_id != null, ); @@ -1956,7 +2026,7 @@ export function DocTable({ await Promise.all( ids.map((id) => operations.moveDocument(id, null).catch(() => {})), ); - }, [docs, operations, selectedDocIds, setDocuments]); + }, [docs, operations, requireCapability, selectedDocIds, setDocuments]); const handleDeleteSelectedDocs = useCallback(async () => { const ids = [...selectedDocIds]; diff --git a/frontend/src/app/components/popups/OwnerOnlyPopup.test.tsx b/frontend/src/app/components/popups/OwnerOnlyPopup.test.tsx new file mode 100644 index 000000000..95f3c85c3 --- /dev/null +++ b/frontend/src/app/components/popups/OwnerOnlyPopup.test.tsx @@ -0,0 +1,51 @@ +import { describe, expect, it, vi } from "vitest"; +import { render, screen } from "@testing-library/react"; +import { OwnerOnlyPopup } from "./OwnerOnlyPopup"; + +describe("OwnerOnlyPopup", () => { + it("keeps the historic owner copy by default", () => { + render( + , + ); + expect(screen.getByText("Owner-only action")).toBeInTheDocument(); + expect( + screen.getByText( + "Only the project owner can delete this project.", + ), + ).toBeInTheDocument(); + }); + + it("renders manager-tier copy for structural actions", () => { + render( + , + ); + expect(screen.getByText("Manager-only action")).toBeInTheDocument(); + expect( + screen.getByText( + "Only the owner or a manager can rename folders.", + ), + ).toBeInTheDocument(); + }); + + it("shows who to ask when the owner email is known", () => { + render( + , + ); + expect(screen.getByText(/owner@firm.com/)).toBeInTheDocument(); + }); +}); diff --git a/frontend/src/app/components/popups/OwnerOnlyPopup.tsx b/frontend/src/app/components/popups/OwnerOnlyPopup.tsx index fa41bed94..a08118756 100644 --- a/frontend/src/app/components/popups/OwnerOnlyPopup.tsx +++ b/frontend/src/app/components/popups/OwnerOnlyPopup.tsx @@ -10,39 +10,63 @@ interface Props { title?: string; /** Sentence describing what the user tried to do. */ action?: string; + /** + * Who the action is reserved for. "owner" (default) keeps the historic + * copy; "manager" covers the structural/sharing tier (the owner or an + * org owner/admin); "editor" covers content actions denied to viewers. + */ + requiredRole?: "owner" | "manager" | "editor"; /** Email of the project/resource owner, shown so the user knows who to ask. */ ownerEmail?: string | null; /** Override the default message entirely. */ message?: string; } +const ROLE_SUBJECT: Record< + NonNullable, + { title: string; subject: string } +> = { + owner: { title: "Owner-only action", subject: "the project owner" }, + manager: { + title: "Manager-only action", + subject: "the owner or a manager", + }, + editor: { + title: "Editors only", + subject: "someone with edit access", + }, +}; + /** - * Lightweight "you don't have permission" popup shown when a non-owner - * attempts an owner-only action (manage people, rename, delete, …) on a - * shared project. Replaces the silent 404 the backend would otherwise - * return so the user understands why the action didn't go through. + * Lightweight "you don't have permission" popup shown when the caller's + * project role does not allow an action (manage people, rename, delete, …). + * Replaces the silent 404/403 the backend would otherwise return so the + * user understands why the action didn't go through. */ export function OwnerOnlyPopup({ open, onClose, - title = "Owner-only action", + title, action, + requiredRole = "owner", ownerEmail, message, }: Props) { if (!open) return null; + const subject = ROLE_SUBJECT[requiredRole]; + const heading = title ?? subject.title; const body = message ?? (action - ? `Only the project owner can ${action}.` - : "Only the project owner can perform this action."); + ? `Only ${subject.subject} can ${action}.` + : `Only ${subject.subject} can perform this action.`); return ( } > diff --git a/frontend/src/app/components/projects/NewProjectModal.tsx b/frontend/src/app/components/projects/NewProjectModal.tsx index dda4df46d..7875f1c28 100644 --- a/frontend/src/app/components/projects/NewProjectModal.tsx +++ b/frontend/src/app/components/projects/NewProjectModal.tsx @@ -1,10 +1,12 @@ "use client"; -import { useRef, useState } from "react"; +import { useEffect, useRef, useState } from "react"; import { Upload, User, X } from "lucide-react"; import { + type Org, addDocumentToProject, createProject, + listOrgs, uploadProjectDocument, } from "@/app/lib/mikeApi"; import { FileDirectory } from "../shared/FileDirectory"; @@ -15,8 +17,11 @@ import { useAuth } from "@/app/contexts/AuthContext"; import { Modal } from "../modals/Modal"; import { ModalFieldLabel } from "../modals/ModalFieldLabel"; import { ModalTextInput } from "../modals/ModalTextInput"; +import { ModalSelect } from "../modals/ModalSelect"; import { ProjectPracticeField } from "./ProjectPracticeField"; +const PERSONAL_WORKSPACE = "__personal__"; + interface Props { open: boolean; onClose: () => void; @@ -29,6 +34,8 @@ export function NewProjectModal({ open, onClose, onCreated }: Props) { const [cmNumber, setCmNumber] = useState(""); const [practice, setPractice] = useState(""); const [sharedUsers, setSharedUsers] = useState([]); + const [orgs, setOrgs] = useState([]); + const [orgId, setOrgId] = useState(PERSONAL_WORKSPACE); const [selectedDocuments, setSelectedDocuments] = useState([]); const [pendingFiles, setPendingFiles] = useState([]); const [loading, setLoading] = useState(false); @@ -38,6 +45,22 @@ export function NewProjectModal({ open, onClose, onCreated }: Props) { const ownEmail = user?.email?.trim().toLowerCase() ?? null; const formId = "new-project-modal-form"; + // Load the caller's organizations so a project can be created inside a + // firm instead of the private personal workspace. Best-effort: without + // orgs the field simply doesn't render. + useEffect(() => { + if (!open) return; + let cancelled = false; + listOrgs() + .then((rows) => { + if (!cancelled) setOrgs(rows.filter((o) => !o.personal)); + }) + .catch(() => {}); + return () => { + cancelled = true; + }; + }, [open]); + if (!open) return null; function submitterValue(e: React.FormEvent) { @@ -76,6 +99,7 @@ export function NewProjectModal({ open, onClose, onCreated }: Props) { .map((user) => user.email) .filter((email) => email !== ownEmail) : sharedUsers.map((user) => user.email), + orgId !== PERSONAL_WORKSPACE ? orgId : undefined, ); await Promise.all([ ...selectedDocuments.map((document) => @@ -104,6 +128,7 @@ export function NewProjectModal({ open, onClose, onCreated }: Props) { setSharedUsers([]); setSelectedDocuments([]); setPendingFiles([]); + setOrgId(PERSONAL_WORKSPACE); setError(""); } @@ -250,6 +275,29 @@ export function NewProjectModal({ open, onClose, onCreated }: Props) { /> + {orgs.length > 0 && ( +
+ + Organization + + ({ + value: org.id, + label: org.name, + })), + ]} + /> +
+ )} +
Share with diff --git a/frontend/src/app/components/projects/ProjectDocumentsView.tsx b/frontend/src/app/components/projects/ProjectDocumentsView.tsx index 0e53bf063..84de55ae0 100644 --- a/frontend/src/app/components/projects/ProjectDocumentsView.tsx +++ b/frontend/src/app/components/projects/ProjectDocumentsView.tsx @@ -46,6 +46,7 @@ export function ProjectDocumentsView({ projectId }: Props) { prefetchProjectSections, search, setOwnerOnlyAction, + canDo, } = workspace; const [createFolderAction, setCreateFolderAction] = useState< (() => void) | null @@ -168,13 +169,15 @@ export function ProjectDocumentsView({ projectId }: Props) { )}
)} - - - Folder - + {canDo("docs.organize") && ( + + + Folder + + )} ); @@ -201,7 +204,9 @@ export function ProjectDocumentsView({ projectId }: Props) { search={search} operations={operations} onAddDocumentsActionChange={ - workspace.setAddDocumentsHeaderAction + canDo("content.edit") + ? workspace.setAddDocumentsHeaderAction + : undefined } onCreateFolderActionChange={handleCreateFolderActionChange} onSelectionActionsChange={handleSelectionActionsChange} @@ -224,6 +229,7 @@ export function ProjectDocumentsView({ projectId }: Props) { ) : null } onOwnerOnlyAction={setOwnerOnlyAction} + canDo={canDo} /> ); diff --git a/frontend/src/app/components/projects/ProjectWorkspace.tsx b/frontend/src/app/components/projects/ProjectWorkspace.tsx index 094a7aeac..550a08a07 100644 --- a/frontend/src/app/components/projects/ProjectWorkspace.tsx +++ b/frontend/src/app/components/projects/ProjectWorkspace.tsx @@ -34,12 +34,26 @@ import { PeopleModal } from "@/app/components/modals/PeopleModal"; import { useChatHistoryContext } from "@/app/contexts/ChatHistoryContext"; import { useAuth } from "@/app/contexts/AuthContext"; import { useUserProfile } from "@/app/contexts/UserProfileContext"; +import { + type Capability, + type ProjectRole, + can, + roleFrom, +} from "@/app/lib/permissions"; import { ProjectDetailsModal } from "./ProjectDetailsModal"; import { ProjectPageHeader, type ProjectWorkspaceSection, } from "./ProjectPageParts"; +/** + * A denied action: the sentence for the popup plus which role the action is + * reserved for. Plain strings keep the historic owner-only phrasing. + */ +export type OwnerGate = + | string + | { action: string; requiredRole: "owner" | "manager" | "editor" }; + type ProjectWorkspaceValue = { projectId: string; project: Project | null; @@ -60,7 +74,11 @@ type ProjectWorkspaceValue = { createChat: () => Promise; openNewReview: () => void; setAddDocumentsHeaderAction: (action: (() => void) | null) => void; - setOwnerOnlyAction: React.Dispatch>; + setOwnerOnlyAction: React.Dispatch>; + /** The caller's role on this project ("owner" until the project loads). */ + accessRole: ProjectRole; + /** Capability check against the caller's role — mirror of the server. */ + canDo: (capability: Capability) => boolean; }; const ProjectWorkspaceContext = @@ -111,7 +129,9 @@ export function ProjectWorkspaceProvider({ const [projectChatsLoading, setProjectChatsLoading] = useState(false); const [peopleModalOpen, setPeopleModalOpen] = useState(false); const [projectDetailsOpen, setProjectDetailsOpen] = useState(false); - const [ownerOnlyAction, setOwnerOnlyAction] = useState(null); + const [ownerOnlyAction, setOwnerOnlyAction] = useState( + null, + ); const [deleteProjectConfirmOpen, setDeleteProjectConfirmOpen] = useState(false); const [deleteProjectStatus, setDeleteProjectStatus] = useState< @@ -268,13 +288,24 @@ export function ProjectWorkspaceProvider({ } } + // Role derived from the loaded project; "owner" until it loads, matching + // the historic `is_owner !== false` optimism. The server enforces. + const accessRole: ProjectRole = project ? roleFrom(project) : "owner"; + const canDo = useCallback( + (capability: Capability) => can(accessRole, capability), + [accessRole], + ); + async function handleProjectDetailsSave(values: { name: string; cmNumber: string; practice: string; }) { - if (project && project.is_owner === false) { - setOwnerOnlyAction("edit project details"); + if (project && !canDo("members.manage")) { + setOwnerOnlyAction({ + action: "edit project details", + requiredRole: "manager", + }); return; } const name = values.name.trim(); @@ -299,7 +330,7 @@ export function ProjectWorkspaceProvider({ } function requestProjectDelete() { - if (project && project.is_owner === false) { + if (project && !canDo("container.delete")) { setOwnerOnlyAction("delete this project"); return; } @@ -342,6 +373,8 @@ export function ProjectWorkspaceProvider({ openNewReview, setAddDocumentsHeaderAction, setOwnerOnlyAction, + accessRole, + canDo, }), [ projectId, @@ -360,6 +393,8 @@ export function ProjectWorkspaceProvider({ createChat, openNewReview, setAddDocumentsHeaderAction, + accessRole, + canDo, ], ); @@ -381,7 +416,7 @@ export function ProjectWorkspaceProvider({ creatingChat={creatingChat} creatingReview={creatingReview} docsCount={project?.documents?.length ?? 0} - isOwner={project?.is_owner !== false} + isOwner={canDo("members.manage")} onBackToProjects={() => router.push("/projects")} onOpenDetails={() => setProjectDetailsOpen(true)} onDeleteProject={requestProjectDelete} @@ -407,14 +442,24 @@ export function ProjectWorkspaceProvider({ setOwnerOnlyAction(null)} /> setProjectDetailsOpen(false)} onSave={handleProjectDetailsSave} onShareProject={() => { @@ -460,7 +505,7 @@ export function ProjectWorkspaceProvider({ "People", ]} onSharedWithChange={ - project.is_owner === false + !canDo("members.manage") ? undefined : async (next) => { const updated = await updateProject( diff --git a/frontend/src/app/components/shared/AppSidebar.tsx b/frontend/src/app/components/shared/AppSidebar.tsx index ec46d29a2..e0993cb06 100644 --- a/frontend/src/app/components/shared/AppSidebar.tsx +++ b/frontend/src/app/components/shared/AppSidebar.tsx @@ -2,6 +2,7 @@ import { useState, useEffect, useMemo } from "react"; import { + Building2, PanelLeft, User, ChevronsUpDown, @@ -491,6 +492,21 @@ export function AppSidebar({ isOpen, onToggle }: AppSidebarProps) { Account Settings + )} diff --git a/frontend/src/app/components/shared/types.ts b/frontend/src/app/components/shared/types.ts index 5ef38011d..c830acbbe 100644 --- a/frontend/src/app/components/shared/types.ts +++ b/frontend/src/app/components/shared/types.ts @@ -24,6 +24,8 @@ export interface Project { id: string; user_id: string; is_owner?: boolean; + /** Server-computed project role for the caller (detail endpoints only). */ + access_role?: "owner" | "manager" | "editor" | "viewer"; owner_display_name?: string | null; owner_email?: string | null; name: string; @@ -561,6 +563,8 @@ export interface TabularReview { shared_with?: string[]; /** Server-set: true when the requesting user is the review's creator. */ is_owner?: boolean; + /** Server-computed role for the caller (detail endpoint only). */ + access_role?: "owner" | "manager" | "editor" | "viewer"; created_at: string; updated_at: string; document_count?: number; diff --git a/frontend/src/app/components/tabular/TabularReviewView.tsx b/frontend/src/app/components/tabular/TabularReviewView.tsx index 760fd1576..2c7b70ff8 100644 --- a/frontend/src/app/components/tabular/TabularReviewView.tsx +++ b/frontend/src/app/components/tabular/TabularReviewView.tsx @@ -48,6 +48,8 @@ import { ApiKeyMissingPopup } from "../popups/ApiKeyMissingPopup"; import { ConfirmPopup } from "../popups/ConfirmPopup"; import { HeaderActionsMenu } from "../shared/HeaderActionsMenu"; import { useAuth } from "@/app/contexts/AuthContext"; +import { can, roleFrom } from "@/app/lib/permissions"; +import type { OwnerGate } from "@/app/components/projects/ProjectWorkspace"; import { useUserProfile } from "@/app/contexts/UserProfileContext"; import { getModelProvider, @@ -93,7 +95,9 @@ export function TRView({ reviewId, projectId }: Props) { const [deleteReviewStatus, setDeleteReviewStatus] = useState< "idle" | "deleting" | "deleted" >("idle"); - const [ownerOnlyAction, setOwnerOnlyAction] = useState(null); + const [ownerOnlyAction, setOwnerOnlyAction] = useState( + null, + ); const { user } = useAuth(); const [expandedCell, setExpandedCell] = useState(null); const [expandedCellCitation, setExpandedCellCitation] = useState< @@ -190,6 +194,18 @@ export function TRView({ reviewId, projectId }: Props) { ); } + // Role ladder for this review; "owner" until it loads. Column/document + // mutations and clearing cells are manager+ server-side; generation and + // chat are editor+; delete stays owner-only. + const reviewRole = review ? roleFrom(review) : "owner"; + const canManageStructure = can(reviewRole, "structure.manage"); + + function requireStructure(action: string): boolean { + if (canManageStructure) return true; + setOwnerOnlyAction({ action, requiredRole: "manager" }); + return false; + } + async function saveColumnsConfig(nextColumns: ColumnConfig[]) { setSavingColumnsConfig(true); try { @@ -419,6 +435,7 @@ export function TRView({ reviewId, projectId }: Props) { } async function handleAddColumn(newColumns: ColumnConfig[]) { + if (!requireStructure("add columns")) return; const startIndex = getNextColumnIndex(); const normalizedColumns = newColumns.map((column, index) => ({ ...column, @@ -480,6 +497,7 @@ export function TRView({ reviewId, projectId }: Props) { } async function handleUpdateColumn(nextColumn: ColumnConfig) { + if (!requireStructure("edit columns")) return; const nextColumns = columns.map((column) => column.index === nextColumn.index ? nextColumn : column, ); @@ -494,6 +512,7 @@ export function TRView({ reviewId, projectId }: Props) { } async function handleDeleteColumn(columnIndex: number) { + if (!requireStructure("delete columns")) return; const previousColumns = columns; const nextColumns = columns.filter( (column) => column.index !== columnIndex, @@ -556,18 +575,23 @@ export function TRView({ reviewId, projectId }: Props) { } async function handleClearResults() { + if (!requireStructure("clear results")) return; await clearResultsForDocuments([...selectedDocIds]); } async function handleClearAllResults() { + if (!requireStructure("clear results")) return; await clearResultsForDocuments( documents.map((document) => document.id), ); } function requestReviewDetails() { - if (review?.is_owner === false) { - setOwnerOnlyAction("edit tabular review details"); + if (review && !canManageStructure) { + setOwnerOnlyAction({ + action: "edit tabular review details", + requiredRole: "manager", + }); return; } setDetailsOpen(true); @@ -577,13 +601,16 @@ export function TRView({ reviewId, projectId }: Props) { title: string; projectId?: string | null; }) { - if (!review || review.is_owner === false) { - setOwnerOnlyAction("edit tabular review details"); + if (!review || !requireStructure("edit tabular review details")) return; - } + // Only send project_id when it actually changes: moving a review + // between projects is owner-only server-side, and sending an + // unchanged value would 403 a manager editing just the title. + const nextProjectId = values.projectId ?? null; + const projectChanged = nextProjectId !== (review.project_id ?? null); const updated = await updateTabularReview(reviewId, { title: values.title, - project_id: values.projectId ?? null, + ...(projectChanged ? { project_id: nextProjectId } : {}), }); setReview((prev) => prev @@ -602,7 +629,7 @@ export function TRView({ reviewId, projectId }: Props) { } function requestReviewDelete() { - if (review?.is_owner === false) { + if (review && !can(reviewRole, "container.delete")) { setOwnerOnlyAction("delete this tabular review"); return; } @@ -630,10 +657,7 @@ export function TRView({ reviewId, projectId }: Props) { } function requestWorkflow() { - if (review?.is_owner === false) { - setOwnerOnlyAction("apply a workflow"); - return; - } + if (!requireStructure("apply a workflow")) return; setWorkflowModalOpen(true); } @@ -1121,7 +1145,7 @@ export function TRView({ reviewId, projectId }: Props) { open={detailsOpen} review={review} projects={project ? [project] : availableProjects} - canEdit={review?.is_owner !== false} + canEdit={canManageStructure} lockProject={Boolean(projectId)} onClose={() => setDetailsOpen(false)} onSave={handleDetailsSave} @@ -1138,10 +1162,11 @@ export function TRView({ reviewId, projectId }: Props) { review?.title || "Untitled Review", "People", ]} - // Only the review owner may modify the member list. PeopleModal - // hides the add/remove controls when this prop is undefined. + // Managers and the owner may modify the member list. + // PeopleModal hides the add/remove controls when this prop + // is undefined. onSharedWithChange={ - review?.is_owner === false + !can(reviewRole, "members.manage") ? undefined : async (next) => { const updated = await updateTabularReview( @@ -1209,7 +1234,16 @@ export function TRView({ reviewId, projectId }: Props) { setOwnerOnlyAction(null)} /> diff --git a/frontend/src/app/lib/mikeApi.ts b/frontend/src/app/lib/mikeApi.ts index 95d894c0b..ab61b6d3b 100644 --- a/frontend/src/app/lib/mikeApi.ts +++ b/frontend/src/app/lib/mikeApi.ts @@ -175,11 +175,12 @@ export async function createProject( cm_number?: string, practice?: string, shared_with?: string[], + org_id?: string, ): Promise { return apiRequest("/projects", { method: "POST", headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ name, cm_number, practice, shared_with }), + body: JSON.stringify({ name, cm_number, practice, shared_with, org_id }), }); } @@ -463,6 +464,138 @@ export async function getProjectPeople( return apiRequest(`/projects/${projectId}/people`); } +// --------------------------------------------------------------------------- +// Organizations +// --------------------------------------------------------------------------- + +export type OrgRole = "owner" | "admin" | "member"; + +export interface Org { + id: string; + name: string; + personal: boolean; + created_by: string; + created_at?: string; + /** The caller's role in this org. */ + role: OrgRole; +} + +export interface OrgMember { + id: string; + user_id: string; + role: OrgRole; + created_at?: string; + email: string | null; + display_name: string | null; +} + +export interface OrgTeamMember { + user_id: string; + email: string | null; + display_name: string | null; +} + +export interface OrgTeam { + id: string; + org_id: string; + name: string; + created_at?: string; + members: OrgTeamMember[]; +} + +export async function listOrgs(): Promise { + return apiRequest("/orgs"); +} + +export async function createOrg(name: string): Promise { + return apiRequest("/orgs", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ name }), + }); +} + +export async function listOrgMembers(orgId: string): Promise { + return apiRequest(`/orgs/${orgId}/members`); +} + +export async function addOrgMember( + orgId: string, + email: string, + role: OrgRole = "member", +): Promise { + return apiRequest(`/orgs/${orgId}/members`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ email, role }), + }); +} + +export async function updateOrgMember( + orgId: string, + userId: string, + role: OrgRole, +): Promise { + return apiRequest(`/orgs/${orgId}/members/${userId}`, { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ role }), + }); +} + +export async function removeOrgMember( + orgId: string, + userId: string, +): Promise { + await apiRequest(`/orgs/${orgId}/members/${userId}`, { + method: "DELETE", + }); +} + +export async function listOrgTeams(orgId: string): Promise { + return apiRequest(`/orgs/${orgId}/teams`); +} + +export async function createOrgTeam( + orgId: string, + name: string, +): Promise { + return apiRequest(`/orgs/${orgId}/teams`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ name }), + }); +} + +export async function deleteOrgTeam( + orgId: string, + teamId: string, +): Promise { + await apiRequest(`/orgs/${orgId}/teams/${teamId}`, { method: "DELETE" }); +} + +export async function addOrgTeamMember( + orgId: string, + teamId: string, + email: string, +): Promise { + return apiRequest(`/orgs/${orgId}/teams/${teamId}/members`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ email }), + }); +} + +export async function removeOrgTeamMember( + orgId: string, + teamId: string, + userId: string, +): Promise { + await apiRequest(`/orgs/${orgId}/teams/${teamId}/members/${userId}`, { + method: "DELETE", + }); +} + // --------------------------------------------------------------------------- // Documents // --------------------------------------------------------------------------- diff --git a/frontend/src/app/lib/permissions.test.ts b/frontend/src/app/lib/permissions.test.ts new file mode 100644 index 000000000..b05ffe234 --- /dev/null +++ b/frontend/src/app/lib/permissions.test.ts @@ -0,0 +1,87 @@ +import { describe, expect, it } from "vitest"; +import { + can, + roleFrom, + type Capability, + type ProjectRole, +} from "./permissions"; + +// Mirror of the backend matrix (backend/src/lib/permissions.ts) — cell by +// cell so any drift between client and server policy is a visible diff. +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 (client mirror)", () => { + 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 roles", () => { + expect(can(null, "project.view")).toBe(false); + expect(can(undefined, "container.delete")).toBe(false); + }); +}); + +describe("roleFrom", () => { + it("prefers access_role from detail responses", () => { + expect(roleFrom({ access_role: "viewer", is_owner: false })).toBe( + "viewer", + ); + expect(roleFrom({ access_role: "manager", is_owner: false })).toBe( + "manager", + ); + }); + + it("falls back to the is_owner list-row contract", () => { + expect(roleFrom({ is_owner: true })).toBe("owner"); + expect(roleFrom({})).toBe("owner"); + expect(roleFrom({ is_owner: false })).toBe("editor"); + }); + + it("ignores unknown access_role values", () => { + expect( + roleFrom({ + access_role: "superuser" as never, + is_owner: false, + }), + ).toBe("editor"); + }); +}); diff --git a/frontend/src/app/lib/permissions.ts b/frontend/src/app/lib/permissions.ts new file mode 100644 index 000000000..642a011eb --- /dev/null +++ b/frontend/src/app/lib/permissions.ts @@ -0,0 +1,54 @@ +// Client-side mirror of backend/src/lib/permissions.ts — the project role +// ladder and capability matrix. The server is the enforcement point; this +// exists so the UI can hide or disable affordances the server would reject, +// instead of offering actions that fail. + +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]; +} + +/** + * Resolve a role from an API row. Detail endpoints return `access_role`; + * list endpoints only return `is_owner`, where a non-owner row means "shared + * with me" — historically full edit access, so editor is the faithful + * fallback. + */ +export function roleFrom(row: { + access_role?: ProjectRole | null; + is_owner?: boolean | null; +}): ProjectRole { + if (row.access_role && row.access_role in ROLE_RANK) + return row.access_role; + return row.is_owner === false ? "editor" : "owner"; +}