|
| 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