111 lines
5.2 KiB
Markdown
111 lines
5.2 KiB
Markdown
# 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).
|