atlilith/@platform/docs/tenancy.md

112 lines
5.2 KiB
Markdown
Raw Permalink Normal View History

# Tenancy — Person, Org, Client
V3's defining architectural concept. V2 had only "user". V3 needs to model the actual operating reality: one human (transquinnftw) can be a standalone provider AND own an agency-shaped Org (Cocotte) AND be a member of someone else's Org.
## The three concentric layers
```
PLATFORM (Lilith Apps ehf — the tech company, sole infrastructure owner)
└─ PROVIDER (the tenant boundary — a Person, optionally overlaid by an Org)
└─ CLIENT (the provider's customers — bookings, inbound messaging)
```
Platform is operated by Lilith Apps. Providers are the customers of the Platform. Clients are the customers of the Providers. Every queryable row belongs to exactly one Provider, either via `user_id` (Person) or via `org_id` (Org).
## Person-first, Org-as-overlay
- **Person** is the primary tenant. Every Provider starts as one. A Person has a profile, inbox, bookings, analytics, and may have public surfaces (`{provider}.com`, `{provider}.my`).
- **Org** is an optional overlay. 1 owner (a Person), N admins (Persons), N members (Persons). An Org has its own dashboard, brand sites, members, and org-level analytics.
- A Person can be in zero, one, or many Orgs simultaneously. Same human, multiple tenancy contexts. The `provider-portal` nav exposes a context switcher: `Personal | Cocotte | …`.
### Worked example
- `transquinnftw` is a Person with `transquinnftw.com`, inbox, bookings, analytics.
- `cocotte` is an Org with `cocotte.maison`, member roster, org-level analytics.
- `transquinnftw` is `cocotte`'s owner. When logged in, the context switcher reads `[ Personal | Cocotte ]`. Switching emits a new JWT scoped to that context (see below).
### What this is NOT
- ❌ Slack/Notion "workspace" tenancy where each login is one context.
- ❌ Single-Person-per-Org. Orgs can grow members.
- ❌ Mandatory. Most Providers will operate as Person-only and never touch an Org.
## Schema additions
```sql
CREATE TABLE orgs (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
slug TEXT UNIQUE NOT NULL,
name TEXT NOT NULL,
owner_id UUID NOT NULL REFERENCES users(id),
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE org_members (
org_id UUID NOT NULL REFERENCES orgs(id) ON DELETE CASCADE,
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
role TEXT NOT NULL CHECK (role IN ('owner', 'admin', 'member')),
joined_at TIMESTAMPTZ NOT NULL DEFAULT now(),
PRIMARY KEY (org_id, user_id)
);
CREATE INDEX idx_org_members_user ON org_members(user_id);
```
Existing tables that can belong to either a Person OR an Org gain optional `org_id`:
```sql
ALTER TABLE bookings ADD COLUMN org_id UUID NULL REFERENCES orgs(id);
ALTER TABLE brands ADD COLUMN org_id UUID NULL REFERENCES orgs(id);
ALTER TABLE analytics_events ADD COLUMN org_id UUID NULL REFERENCES orgs(id);
-- When org_id IS NULL, the row belongs to the Person identified by user_id.
```
**Invariant:** every row that holds Provider data has either `user_id` set or `org_id` set (or both — for rows authored by a Person acting in an Org context).
## JWT context
```typescript
interface SessionToken {
user_id: string;
device_id: string;
// Optional — set only when the user switched into an Org context.
org_id?: string;
org_role?: 'owner' | 'admin' | 'member';
}
```
- No `org_id` claim → Personal context. Queries scope to `WHERE user_id = $session.user_id`.
- `org_id` claim → Org context. Queries scope to `WHERE org_id = $session.org_id`, with the API enforcing that the user is a member via `org_members`.
- Context switching emits a fresh JWT; the old one is invalidated server-side (token-rotation pattern, not a permission re-evaluation).
## Defense in depth: RLS
For V3 launch we ship Option A from `../DESIGN.md §11`**row-level tenancy in one shared `platform.db`**, with API-layer filtering as the primary guard and Postgres Row-Level Security policies as defense in depth.
```sql
ALTER TABLE bookings ENABLE ROW LEVEL SECURITY;
CREATE POLICY bookings_tenant_isolation ON bookings
USING (
user_id = current_setting('app.user_id')::uuid
OR org_id IN (
SELECT org_id FROM org_members
WHERE user_id = current_setting('app.user_id')::uuid
)
);
```
The app sets `SET LOCAL app.user_id = $1` at the start of each request (postgres.js pattern, compatible with pgBouncer transaction mode). RLS is the floor — if the API layer forgets a WHERE clause, RLS catches it.
DB-per-tenant (Option B) is deferred until scale demands it (~100+ Providers). The schema is designed to remain compatible with that migration.
## What this affects across the codebase
- `@features/sso` — JWT shape, context-switch endpoint, JWT rotation
- `@features/api` — every query takes the session token and sets `app.user_id`; org-aware filters
- `@apps/provider-portal` — context switcher in nav, org dashboard view
- `@features/org-analytics` (was `user-data`) — analytics roll up to both user and org level
- Every new table — gets `user_id` AND optional `org_id` from day one
Related: `../DESIGN.md §2, §5, §11`, [naming.md](./naming.md), [onboarding-provider.md](./onboarding-provider.md).