5.2 KiB
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-portalnav exposes a context switcher:Personal | Cocotte | ….
Worked example
transquinnftwis a Person withtransquinnftw.com, inbox, bookings, analytics.cocotteis an Org withcocotte.maison, member roster, org-level analytics.transquinnftwiscocotte'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
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:
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
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_idclaim → Personal context. Queries scope toWHERE user_id = $session.user_id. org_idclaim → Org context. Queries scope toWHERE org_id = $session.org_id, with the API enforcing that the user is a member viaorg_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.
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 setsapp.user_id; org-aware filters@apps/provider-portal— context switcher in nav, org dashboard view@features/org-analytics(wasuser-data) — analytics roll up to both user and org level- Every new table — gets
user_idAND optionalorg_idfrom day one
Related: ../DESIGN.md §2, §5, §11, naming.md, onboarding-provider.md.