diff --git a/packages/server/src/admin/session.ts b/packages/server/src/admin/session.ts index 36cb5bc..787e096 100644 --- a/packages/server/src/admin/session.ts +++ b/packages/server/src/admin/session.ts @@ -13,6 +13,8 @@ export interface AdminUser { email: string; role: AdminRole; totpEnabled: boolean; + // The tenant this admin belongs to. Owners are superusers WITHIN this org only. + organizationId: string; } function hashToken(token: string): string { @@ -33,7 +35,8 @@ export async function createSession(userId: string): Promise { // Resolve a raw token to its admin user, or null if missing/expired. export async function validateSession(token: string): Promise { const rows = await db` - SELECT u.id, u.email, u.role, u.totp_enabled AS "totpEnabled" + SELECT u.id, u.email, u.role, u.totp_enabled AS "totpEnabled", + u.organization_id AS "organizationId" FROM admin_sessions s JOIN admin_users u ON u.id = s.user_id WHERE s.token_hash = ${hashToken(token)} AND s.expires_at > now() AND u.is_active diff --git a/packages/server/src/db/migrations/013_organizations.sql b/packages/server/src/db/migrations/013_organizations.sql new file mode 100644 index 0000000..324882d --- /dev/null +++ b/packages/server/src/db/migrations/013_organizations.sql @@ -0,0 +1,56 @@ +-- Organizations: the tenant layer above projects. Until now the install was a single +-- flat namespace — every 'owner' was a global superuser and all projects/admins lived +-- side by side. That is fine for self-hosting (one operator) but unsafe for a hosted +-- multi-customer box, where one company's owner must never see another's data. An +-- organization is now the unit of isolation (and the future anchor for metering/billing). +-- +-- Self-host is unaffected: this backfills a single 'Default' org and assigns every +-- existing admin and project to it (mirroring how migration 009 backfilled role), so a +-- single-org install behaves exactly as before. + +CREATE TABLE IF NOT EXISTS organizations ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + name TEXT NOT NULL, + slug TEXT UNIQUE, + -- Per-org 2FA policy (supersedes the install-wide admin_settings.require_2fa). One + -- tenant tightening 2FA must not change it for others on the same box. + require_2fa BOOLEAN NOT NULL DEFAULT false, + created_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +-- Tenant FKs. Added nullable so the backfill below can populate them before they are +-- constrained NOT NULL. admin_invites carries the org so an accepted invite lands the +-- new admin in the inviting company. +ALTER TABLE admin_users ADD COLUMN IF NOT EXISTS organization_id UUID REFERENCES organizations(id); +ALTER TABLE projects ADD COLUMN IF NOT EXISTS organization_id UUID REFERENCES organizations(id); +ALTER TABLE admin_invites ADD COLUMN IF NOT EXISTS organization_id UUID REFERENCES organizations(id); + +-- Backfill: if the install already has admins or projects, fold them all into one +-- 'Default' org, seeding its 2FA policy from the existing install-wide setting. A truly +-- empty fresh DB gets no org here — the bootstrap script (create-admin) creates the +-- first org and owner together. +DO $$ +DECLARE + default_org UUID; + needs_org BOOLEAN; + seed_2fa BOOLEAN; +BEGIN + SELECT EXISTS (SELECT 1 FROM admin_users) OR EXISTS (SELECT 1 FROM projects) INTO needs_org; + IF needs_org THEN + SELECT COALESCE((SELECT require_2fa FROM admin_settings WHERE id = true LIMIT 1), false) INTO seed_2fa; + INSERT INTO organizations (name, slug, require_2fa) + VALUES ('Default', 'default', seed_2fa) + RETURNING id INTO default_org; + UPDATE admin_users SET organization_id = default_org WHERE organization_id IS NULL; + UPDATE projects SET organization_id = default_org WHERE organization_id IS NULL; + END IF; +END $$; + +-- NOTE: organization_id is left NULLABLE here on purpose. The NOT NULL backstop is +-- applied in a later migration, once every writer (the admin routes, create-project, +-- create-admin, and the test helpers) has been made org-aware. Backfilled rows and all +-- app-created rows always carry an org; the constraint just closes the gap last. + +CREATE INDEX IF NOT EXISTS idx_admin_users_org ON admin_users(organization_id); +CREATE INDEX IF NOT EXISTS idx_projects_org ON projects(organization_id); +CREATE INDEX IF NOT EXISTS idx_admin_invites_org ON admin_invites(organization_id);