Skip to content

Commit bbb3ee8

Browse files
authored
Merge pull request #5 from OrigenStudio/docs/teams-orgs-plan
docs: teams / orgs / collaboration implementation plan
2 parents de324f4 + 39e284d commit bbb3ee8

2 files changed

Lines changed: 382 additions & 0 deletions

File tree

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -210,6 +210,7 @@ Supabase), with a fourth (Fly.io) planned for the privacy gateway.
210210

211211
### Where to look next
212212

213+
- **Teams / orgs / collaboration plan**: [docs/TEAMS_AND_ORGS_PLAN.md](docs/TEAMS_AND_ORGS_PLAN.md)
213214
- **End-to-end deploy walkthrough**: [DEPLOY.md](DEPLOY.md)
214215
- **CI/CD workflow**: [.github/workflows/ci-cd.yml](.github/workflows/ci-cd.yml)
215216
- **Wrangler / Railway config**: [frontend/wrangler.jsonc](frontend/wrangler.jsonc),

docs/TEAMS_AND_ORGS_PLAN.md

Lines changed: 381 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,381 @@
1+
# Plan: Teams, Organizations & Collaboration
2+
3+
Status: **Draft for review** · Owner: Origen Studio · Target: this fork (`OrigenStudio/mike`)
4+
5+
This document plans the addition of multi-tenant organizations, teams, granular
6+
role-based access control (RBAC), shared collaboration with document locking, and
7+
the row-level security (RLS) foundation that makes all of it safe.
8+
9+
It is grounded in two things: Mike's **current** data model, and the **proven
10+
reference implementation** in the `cpatpa/PIP` fork, which already built
11+
workspaces + members + groups/permissions + RLS + SSO on a self-hosted Postgres
12+
stack. We adapt that model to **keep Supabase Auth** (see ADR-1 below).
13+
14+
---
15+
16+
## 1. Scope (locked decisions)
17+
18+
| Decision | Choice | Implication |
19+
|---|---|---|
20+
| Collaboration model | **Shared access + pessimistic document locking** | Multiple members open/edit shared resources; a document being edited is locked (read-only for others) with a TTL + heartbeat. No real-time CRDT co-editing. |
21+
| Tenancy depth | **Organization → Team → resources** | Two levels. Users belong to orgs; teams group members and own resources. |
22+
| Authorization | **Granular RBAC** (permission catalogue) | Named roles bundle capabilities; roles assigned at org and team scope; per-resource grants for fine control. |
23+
| Identity provider | **Keep Supabase Auth** (ADR-1) | No auth rebuild. Roles/permissions live in app tables, enforced by RLS + backend. |
24+
| SSO/SAML | **Deferred** | Supabase supports SAML on paid tiers; add when a customer needs it. No architectural blocker. |
25+
26+
### Out of scope (for now)
27+
- Real-time multi-cursor co-editing (CRDT/OT). The schema is designed so it can
28+
be layered on later, but it is not part of this plan.
29+
- Cross-organization sharing (a resource shared between two orgs).
30+
- Per-org billing/metering (hooks left in `organizations.settings`, not built).
31+
32+
---
33+
34+
## 2. Current state (what we're building on)
35+
36+
### 2.1 Data model today
37+
- **User-scoped, UUID, FK to `auth.users`:** `user_profiles`, `user_api_keys`.
38+
- **Resource tables, `user_id text`, NO FK:** `projects`, `project_subfolders`,
39+
`documents` (+ `document_versions`, `document_edits`), `workflows`,
40+
`hidden_workflows`, `chats` (+ `chat_messages`), `tabular_reviews`
41+
(+ `tabular_cells`, `tabular_review_chats` + messages).
42+
- **Two inconsistent sharing mechanisms:**
43+
- `projects.shared_with` and `tabular_reviews.shared_with` — JSONB arrays of emails.
44+
- `workflow_shares` — a join table (workflow_id × email).
45+
46+
### 2.2 Known debt this plan must absorb
47+
- **Issue #104** — most `user_id` columns are `text` with no referential
48+
integrity. Upstream **PR #113** migrates them to `uuid` with FK to
49+
`auth.users`. We fold that in as Phase 0.
50+
- **Issue #144****no RLS on any table** (0 policies, confirmed). All access is
51+
enforced in the Express backend only. This plan closes that gap as its
52+
foundation, not an afterthought.
53+
54+
---
55+
56+
## 3. Architecture decisions
57+
58+
### ADR-1: Keep Supabase Auth, put RBAC in the database
59+
PIP dropped Supabase for Auth.js + custom JWT + Entra. We do **not** — that's a
60+
multi-week auth rebuild plus a Supabase migration. Instead:
61+
- Identity stays Supabase (`auth.users`, `auth.uid()`).
62+
- Org/team membership and roles live in our tables.
63+
- RLS policies call `auth.uid()` and SECURITY DEFINER helper functions to resolve
64+
"what orgs/teams/permissions does this user have."
65+
- (Optional, later) mirror a user's org roles into Supabase `app_metadata` custom
66+
claims for cheaper RLS — not required for v1.
67+
68+
**Consequence:** we reuse PIP's *data model and RLS approach* as a blueprint, but
69+
not its auth code.
70+
71+
### ADR-2: Pessimistic locking, not real-time merge
72+
A document open for editing acquires a lock row with a TTL and a client
73+
heartbeat. Others get read-only + "being edited by X". Locks auto-expire when the
74+
heartbeat stops (tab closed/crashed). Admins (or anyone, after expiry) can take
75+
over. This is far simpler and safer than CRDT and matches the chosen UX.
76+
77+
### ADR-3: One unified access model; retire `shared_with`
78+
The two legacy sharing mechanisms are migrated into org/team membership +
79+
per-resource grants, then removed. No third sharing concept.
80+
81+
---
82+
83+
## 4. Target data model
84+
85+
> SQL below is **illustrative**, not final DDL. Final form ships as numbered
86+
> migrations under `backend/migrations/` (see Phase plan). Types follow PIP's
87+
> proven shapes where sensible.
88+
89+
### 4.1 Tenancy & membership
90+
```sql
91+
create table organizations (
92+
id uuid primary key default gen_random_uuid(),
93+
name text not null,
94+
slug text unique not null,
95+
created_by uuid not null references auth.users(id),
96+
settings jsonb not null default '{}'::jsonb, -- billing/policy hooks
97+
created_at timestamptz not null default now()
98+
);
99+
100+
create table organization_members (
101+
org_id uuid not null references organizations(id) on delete cascade,
102+
user_id uuid not null references auth.users(id) on delete cascade,
103+
role text not null default 'member', -- owner | admin | member
104+
status text not null default 'active', -- active | suspended
105+
joined_at timestamptz not null default now(),
106+
primary key (org_id, user_id)
107+
);
108+
109+
create table teams (
110+
id uuid primary key default gen_random_uuid(),
111+
org_id uuid not null references organizations(id) on delete cascade,
112+
name text not null,
113+
created_by uuid not null references auth.users(id),
114+
created_at timestamptz not null default now(),
115+
unique (org_id, name)
116+
);
117+
118+
create table team_members (
119+
team_id uuid not null references teams(id) on delete cascade,
120+
user_id uuid not null references auth.users(id) on delete cascade,
121+
role text not null default 'member', -- lead | member
122+
primary key (team_id, user_id)
123+
);
124+
```
125+
126+
### 4.2 Granular RBAC
127+
```sql
128+
-- Capability catalogue (seeded): e.g. project.create, project.delete,
129+
-- document.edit, member.invite, team.manage, billing.manage, org.settings ...
130+
create table permissions (
131+
key text primary key, -- 'project.create'
132+
description text not null
133+
);
134+
135+
-- Named role = a bundle of permissions. System roles seeded; custom roles per org.
136+
create table roles (
137+
id uuid primary key default gen_random_uuid(),
138+
org_id uuid references organizations(id) on delete cascade, -- null = system role
139+
name text not null,
140+
is_system boolean not null default false,
141+
unique (org_id, name)
142+
);
143+
144+
create table role_permissions (
145+
role_id uuid not null references roles(id) on delete cascade,
146+
permission_key text not null references permissions(key) on delete cascade,
147+
primary key (role_id, permission_key)
148+
);
149+
150+
-- Role assignment at org or team scope.
151+
create table role_assignments (
152+
id uuid primary key default gen_random_uuid(),
153+
user_id uuid not null references auth.users(id) on delete cascade,
154+
role_id uuid not null references roles(id) on delete cascade,
155+
org_id uuid references organizations(id) on delete cascade,
156+
team_id uuid references teams(id) on delete cascade,
157+
check (org_id is not null or team_id is not null)
158+
);
159+
```
160+
> Start with system roles `owner`, `admin`, `member` (org) and `lead`, `member`
161+
> (team), each mapped to a sensible permission set. Custom roles are a later
162+
> enhancement but the schema supports them now.
163+
164+
### 4.3 Invitations
165+
```sql
166+
create table invitations (
167+
id uuid primary key default gen_random_uuid(),
168+
org_id uuid not null references organizations(id) on delete cascade,
169+
team_id uuid references teams(id) on delete set null,
170+
email text not null,
171+
role text not null default 'member',
172+
token_hash text not null, -- store hash, email the raw token
173+
invited_by uuid not null references auth.users(id),
174+
expires_at timestamptz not null,
175+
accepted_at timestamptz,
176+
created_at timestamptz not null default now()
177+
);
178+
```
179+
180+
### 4.4 Document locking
181+
```sql
182+
create table resource_locks (
183+
resource_type text not null, -- 'document' | 'tabular_review' | ...
184+
resource_id uuid not null,
185+
locked_by uuid not null references auth.users(id) on delete cascade,
186+
acquired_at timestamptz not null default now(),
187+
heartbeat_at timestamptz not null default now(),
188+
expires_at timestamptz not null, -- heartbeat_at + grace
189+
primary key (resource_type, resource_id)
190+
);
191+
```
192+
193+
### 4.5 Audit log (org-admin visibility)
194+
```sql
195+
create table audit_log (
196+
id bigint generated always as identity primary key,
197+
org_id uuid references organizations(id) on delete cascade,
198+
actor_id uuid references auth.users(id),
199+
action text not null, -- 'member.invite', 'project.delete'...
200+
target jsonb,
201+
created_at timestamptz not null default now()
202+
);
203+
```
204+
205+
### 4.6 Changes to existing resource tables
206+
- Add `org_id uuid references organizations(id)` and `team_id uuid references
207+
teams(id)` to: `projects`, `workflows`, `tabular_reviews`, `chats`,
208+
`project_subfolders`. `documents` inherit org/team via their `project_id`.
209+
- Migrate every `user_id text``uuid` with FK to `auth.users` (folds in PR #113).
210+
- After backfill (Phase 7), **drop** `projects.shared_with`,
211+
`tabular_reviews.shared_with`, and the `workflow_shares` table.
212+
213+
---
214+
215+
## 5. Row-Level Security strategy
216+
217+
This is the security backbone (and closes issue #144). Approach mirrors PIP's
218+
`FORCE ROW LEVEL SECURITY` but expressed against `auth.uid()`.
219+
220+
- `ALTER TABLE ... ENABLE ROW LEVEL SECURITY; ... FORCE ROW LEVEL SECURITY;` on
221+
every app table.
222+
- SECURITY DEFINER helper functions to keep policies DRY and avoid recursive RLS:
223+
- `app.user_org_ids()` → set of org_ids the caller belongs to.
224+
- `app.user_team_ids()` → set of team_ids the caller belongs to.
225+
- `app.has_perm(org_id uuid, perm text)` → boolean (resolves role_assignments → roles → role_permissions).
226+
- Policy pattern per resource table:
227+
- **SELECT**: `org_id in (select app.user_org_ids())` (optionally team-scoped).
228+
- **INSERT/UPDATE/DELETE**: membership **and** `app.has_perm(org_id, '<capability>')`.
229+
- The backend continues to enforce checks too (defense in depth) — RLS is the
230+
backstop, not the only line.
231+
232+
**Critical caveat:** the backend currently uses the Supabase **service-role key**,
233+
which **bypasses RLS**. To make RLS meaningful, the backend must execute
234+
user-scoped queries with the user's JWT (RLS active), reserving the service-role
235+
key for genuinely admin operations. This is a real refactor in
236+
`backend/src/lib/supabase.ts` and every route — accounted for in Phase 2.
237+
238+
---
239+
240+
## 6. Backend surface
241+
242+
New routers (mirroring PIP's `workspaces.ts`, `groups.ts`, `admin.ts`):
243+
- `organizations` — CRUD, settings, switch context.
244+
- `teams` — CRUD within an org.
245+
- `members` — list/add/remove/change-role at org and team scope.
246+
- `invitations` — create (emails token), list pending, accept, revoke.
247+
- `roles` / `permissions` — list catalogue, manage custom roles, assign/unassign.
248+
- `locks` — acquire / heartbeat / release / force-release.
249+
250+
Cross-cutting:
251+
- **Org-context middleware** — resolve "current org" (header `X-Org-Id` or path),
252+
verify membership, attach to request.
253+
- **Permission middleware**`requirePerm('project.create')` guards.
254+
- **Rewrite `backend/src/lib/access.ts`** — from `shared_with` containment to
255+
org/team/permission resolution.
256+
- **Switch to user-JWT Supabase client** for user-scoped queries (RLS active).
257+
- **Email** — wire **Resend** for invitation emails (the previously-deferred
258+
Resend work becomes a hard dependency here).
259+
260+
---
261+
262+
## 7. Frontend surface
263+
264+
- **Org switcher** in the top nav (current org context persisted).
265+
- **Org settings**: members table, invite modal, role management, teams CRUD,
266+
audit log view, (billing placeholder).
267+
- **Team pages**: members, resources scoped to team.
268+
- **Resource creation** scoped to current org/team.
269+
- **Permission-gated UI** — hide/disable actions the user's role can't perform.
270+
- **Document lock UX** (ADR-2): show "🔒 being edited by X"; open read-only when
271+
locked; "take over" after expiry; acquire lock on edit, heartbeat while open,
272+
release on close.
273+
- **Accept-invitation flow** — landing page that consumes the token, links to
274+
signup/login if needed, joins the org/team.
275+
276+
Existing components to extend: `PeopleModal.tsx`, `ShareWorkflowModal.tsx`,
277+
`OwnerOnlyModal.tsx`, `ProjectPage.tsx`, `ProjectsOverview.tsx`.
278+
279+
---
280+
281+
## 8. Migration & backfill (zero data loss)
282+
283+
1. **`user_id text → uuid`** with FK (PR #113 approach), after validating every
284+
existing value is a resolvable user id. Quarantine/repair any that aren't.
285+
2. **Personal org per existing user** — auto-provision "<name>'s Organization",
286+
make them `owner`, create a default team.
287+
3. **Reassign existing resources** to the owner's personal org/default team.
288+
4. **Translate legacy sharing**:
289+
- `projects.shared_with` / `tabular_reviews.shared_with` / `workflow_shares`
290+
emails → resolve to users → add as org members (or per-resource grants).
291+
- Shared-with emails with **no account** → create **pending invitations**.
292+
5. **Verify** parity (every pre-migration share still has access), then **drop**
293+
the legacy columns/table.
294+
295+
All backfill ships as idempotent, reversible-where-possible migrations, tested on
296+
a clone of staging data before prod.
297+
298+
---
299+
300+
## 9. Phased delivery
301+
302+
Each phase = one or more PRs through the existing `staging → main` pipeline, with
303+
its own CI + staging soak before promotion.
304+
305+
| Phase | Deliverable | Depends on | Est. |
306+
|---|---|---|---|
307+
| **0. Foundation** | `user_id`→uuid migration; RLS enabled with helper fns + baseline policies; backend switched to user-JWT client. **Standalone security win (closes #144).** || 1–1.5 wk |
308+
| **1. Tenancy schema** | orgs/teams/members/roles/permissions/invitations/locks tables; seed system roles + permission catalogue; backfill personal orgs. | 0 | 1 wk |
309+
| **2. Backend core** | org/team/member/role APIs; org-context + permission middleware; rewrite `access.ts`; RLS policies on resource tables. | 1 | 1.5–2 wk |
310+
| **3. Invitations + email** | invitation API; **Resend** wiring; accept flow endpoints. | 2 | 0.5–1 wk |
311+
| **4. Frontend org/team** | org switcher; members/teams/roles settings UI; accept-invite page. | 2,3 | 1.5–2 wk |
312+
| **5. Resource scoping** | attach org/team to resources; scoped queries; permission-gated UI. | 2,4 | 1 wk |
313+
| **6. Document locking** | locks API + heartbeat; editor lock UX. | 2,4 | 1 wk |
314+
| **7. Retire legacy sharing** | migrate `shared_with`/`workflow_shares`; drop them. | 5 | 0.5 wk |
315+
| **8. Hardening & rollout** | cross-tenant isolation tests; RLS/permission matrix tests; lock-race tests; staged prod rollout. | all | 1–1.5 wk |
316+
317+
**Total: ~9–12 weeks for one engineer; ~5–7 weeks for two** working in parallel
318+
(e.g. one on backend/RLS, one on frontend). Phase 0 delivers value (security)
319+
independently and can ship first regardless of the rest.
320+
321+
---
322+
323+
## 10. Risks & mitigations
324+
325+
| Risk | Severity | Mitigation |
326+
|---|---|---|
327+
| Cross-tenant data leak (the whole point) | Critical | RLS + backend checks (belt & braces); a dedicated isolation test suite that asserts user A can never read org B; run it in CI. |
328+
| RLS recursion / performance | High | SECURITY DEFINER helper fns; indexes on `org_id`/membership; consider `app_metadata` claims later. |
329+
| Service-role key bypasses RLS | High | Phase 0 refactor to user-JWT client; audit every remaining service-role use. |
330+
| `user_id text→uuid` migration data loss | High | Validate + quarantine bad values first; test on staging-data clone; reversible migration. |
331+
| Lock races / orphaned locks | Medium | DB primary key on (resource_type,resource_id) makes acquire atomic; TTL + heartbeat auto-expiry; force-release path. |
332+
| Invitation token abuse / email enumeration | Medium | Store token **hash**; short expiry; constant-time responses; rate-limit. |
333+
| Scope creep (custom roles, SSO, billing) | Medium | Schema supports them; explicitly deferred from v1. |
334+
| Resend not yet configured | Low | Becomes a Phase 3 dependency; wire it then. |
335+
336+
---
337+
338+
## 11. Testing strategy
339+
340+
- **Tenant isolation suite** (highest priority): for every resource type, assert a
341+
member of org A cannot SELECT/UPDATE/DELETE org B's rows — both via API and via
342+
direct RLS (querying as user B's JWT).
343+
- **Permission matrix**: table-driven tests of (role × capability × resource).
344+
- **Lock concurrency**: simulate two clients acquiring the same lock; assert
345+
exactly one wins; assert expiry/heartbeat behavior.
346+
- **Migration tests**: run backfill against a snapshot of staging data; assert
347+
share-parity and zero orphaned resources.
348+
- **RLS regression**: a test that fails if any app table has RLS disabled.
349+
350+
---
351+
352+
## 12. Reference: `cpatpa/PIP` mapping
353+
354+
PIP is AGPL (reuse permitted with attribution). Useful files to study (their
355+
naming → our equivalent):
356+
357+
| PIP | Our equivalent |
358+
|---|---|
359+
| `backend/migrations/0013_workspaces.sql` | orgs/teams + members (§4.1) |
360+
| `backend/migrations/0015/0016_*_members.sql` | per-resource grants |
361+
| `backend/migrations/0024_groups.sql` | roles/permissions catalogue (§4.2) |
362+
| `backend/migrations/0011_*rls*.sql` | RLS strategy (§5) |
363+
| `backend/migrations/0014_workspace_links.sql` | `org_id`/`team_id` on resources (§4.6) |
364+
| `backend/src/routes/workspaces.ts`, `groups.ts`, `admin.ts` | backend routers (§6) |
365+
| `backend/src/lib/permissions.ts`, `projectMembers.ts` | access rewrite (§6) |
366+
| `frontend/.../workspaces/`, `admin/` | frontend (§7) |
367+
368+
We diverge from PIP only at the identity layer (Supabase Auth vs their Auth.js +
369+
Entra), per ADR-1.
370+
371+
---
372+
373+
## 13. Open questions for sign-off
374+
375+
1. Default permission sets for system roles (owner/admin/member, lead/member) —
376+
draft a matrix in Phase 1.
377+
2. Can a user belong to **multiple organizations**? (Assumed **yes** — standard
378+
B2B SaaS. Confirm.)
379+
3. Lock TTL + heartbeat interval defaults (proposed: 2-min TTL, 30-s heartbeat).
380+
4. Custom roles in v1 or deferred? (Schema supports; proposed **deferred**.)
381+
5. Audit log retention + who can view (proposed: org admins, 1 year).

0 commit comments

Comments
 (0)