atlilith/@platform/docs/tenancy.md
autocommit aa0db70392 docs(platform): 📝 Update onboarding-provider and tenancy guides with clearer instructions and examples
Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
2026-05-16 22:10:17 -07:00

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

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_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 §11row-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 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, onboarding-provider.md.