diff --git a/services/mana-auth/sql/004_spaces.sql b/services/mana-auth/sql/004_spaces.sql new file mode 100644 index 000000000..114a9a044 --- /dev/null +++ b/services/mana-auth/sql/004_spaces.sql @@ -0,0 +1,70 @@ +-- Migration 004: Spaces schema +-- +-- Adds the `spaces` schema with two server-side tables that extend Better +-- Auth organizations for our multi-tenancy model. See +-- docs/plans/spaces-foundation.md for the full RFC, and the Drizzle +-- definitions at src/db/schema/spaces.ts. +-- +-- Why a separate schema: +-- - Keeps auth tables focused on identity, not domain extensions +-- - Lets us grant narrower RLS policies per schema +-- - Mirrors the pgSchema-per-concern pattern used across mana_platform +-- +-- Idempotent: re-running on a partially-migrated DB is safe. + +-- ─── Schema ────────────────────────────────────────────────────── +CREATE SCHEMA IF NOT EXISTS spaces; + +-- ─── credentials ──────────────────────────────────────────────── +-- Per-space external credentials: OAuth tokens, API keys, SMTP configs. +-- NEVER stored client-side — these are server-held secrets, wrapped with +-- the service-wide KEK (same mechanism as auth.encryption_vaults). +CREATE TABLE IF NOT EXISTS spaces.credentials ( + space_id TEXT NOT NULL, + provider TEXT NOT NULL, + access_token_encrypted TEXT NOT NULL, + refresh_token_encrypted TEXT, + expires_at TIMESTAMPTZ, + scopes TEXT[], + provider_account_id TEXT, + metadata_json TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + PRIMARY KEY (space_id, provider), + CONSTRAINT space_credentials_space_fk + FOREIGN KEY (space_id) REFERENCES auth.organizations (id) ON DELETE CASCADE +); + +CREATE INDEX IF NOT EXISTS space_credentials_space_idx + ON spaces.credentials (space_id); + +-- ─── module_permissions ───────────────────────────────────────── +-- Role × module permission matrix. If no row exists for a given +-- (space, role, module) tuple, the default is derived from SPACE_MODULE_ALLOWLIST +-- plus role-tier fallback (owner > admin > member). Rows here are +-- explicit overrides — typically written when a space owner customises +-- the default permissions for a custom role. +CREATE TABLE IF NOT EXISTS spaces.module_permissions ( + space_id TEXT NOT NULL, + role TEXT NOT NULL, + module_id TEXT NOT NULL, + can_read BOOLEAN NOT NULL DEFAULT TRUE, + can_write BOOLEAN NOT NULL DEFAULT FALSE, + can_admin BOOLEAN NOT NULL DEFAULT FALSE, + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + PRIMARY KEY (space_id, role, module_id), + CONSTRAINT space_module_permissions_space_fk + FOREIGN KEY (space_id) REFERENCES auth.organizations (id) ON DELETE CASCADE +); + +CREATE INDEX IF NOT EXISTS space_module_permissions_space_module_idx + ON spaces.module_permissions (space_id, module_id); + +-- ─── RLS ───────────────────────────────────────────────────────── +-- Defer enabling RLS until the rest of the app is scope-aware. Turning +-- it on now would lock out services that don't yet pass the space +-- context. Re-enable in a follow-up migration once mana-api speaks the +-- Spaces protocol end-to-end. +-- +-- ALTER TABLE spaces.credentials ENABLE ROW LEVEL SECURITY; +-- ALTER TABLE spaces.module_permissions ENABLE ROW LEVEL SECURITY; diff --git a/services/mana-auth/src/db/schema/index.ts b/services/mana-auth/src/db/schema/index.ts index b2d3ad7b7..5eaf984e5 100644 --- a/services/mana-auth/src/db/schema/index.ts +++ b/services/mana-auth/src/db/schema/index.ts @@ -3,3 +3,4 @@ export * from './organizations'; export * from './api-keys'; export * from './login-attempts'; export * from './encryption-vaults'; +export * from './spaces'; diff --git a/services/mana-auth/src/db/schema/spaces.ts b/services/mana-auth/src/db/schema/spaces.ts new file mode 100644 index 000000000..6ef1d0294 --- /dev/null +++ b/services/mana-auth/src/db/schema/spaces.ts @@ -0,0 +1,96 @@ +/** + * Spaces — Postgres schema extensions for Better Auth organizations. + * + * The canonical SpaceType + SpaceMetadata contract lives in + * `@mana/shared-types`; the organization itself lives in the `auth` schema + * (see organizations.ts). This file adds the *server-side* extensions that + * don't belong in the client-synced world: + * + * - `spaces.credentials` — per-space OAuth tokens + API keys + * (LinkedIn, Stripe, Twilio, SMTP, …). + * Must live server-side because we never + * want them in IndexedDB / Dexie. + * - `spaces.module_permissions` — role × module × action matrix. + * Lets a club's trainer read `calendar` + * but not `club-finance`, for example. + * + * See docs/plans/spaces-foundation.md. + */ + +import { pgSchema, text, timestamp, boolean, index, primaryKey } from 'drizzle-orm/pg-core'; +import { organizations } from './organizations'; + +export const spacesSchema = pgSchema('spaces'); + +/** + * Per-space external credentials. + * + * Tokens are encrypted at rest with the service-wide KEK (same mechanism + * as auth.encryption_vaults). The `(space_id, provider)` composite key + * means one token per provider per space — a second LinkedIn OAuth flow + * for the same Edisconet space overwrites the first. + * + * No FK on `provider` — it's a free-form string (`linkedin`, `stripe`, + * `twilio_sms`, `twitter`, …) so we can add integrations without schema + * migrations. Service code handles the provider-specific payload. + */ +export const spaceCredentials = spacesSchema.table( + 'credentials', + { + spaceId: text('space_id') + .references(() => organizations.id, { onDelete: 'cascade' }) + .notNull(), + provider: text('provider').notNull(), + accessTokenEncrypted: text('access_token_encrypted').notNull(), + refreshTokenEncrypted: text('refresh_token_encrypted'), + expiresAt: timestamp('expires_at', { withTimezone: true }), + scopes: text('scopes').array(), + providerAccountId: text('provider_account_id'), + // Free-form per-provider metadata (org name, page id, webhook secret). + // Kept as text JSON to avoid pulling the jsonb type in — callers + // parse/serialize explicitly. + metadataJson: text('metadata_json'), + createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(), + updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(), + }, + (table) => ({ + pk: primaryKey({ columns: [table.spaceId, table.provider] }), + spaceIdx: index('space_credentials_space_idx').on(table.spaceId), + }) +); + +/** + * Role × module permission matrix for a space. + * + * Example rows for a club: + * (org_123, 'owner', 'club-finance', true, true, true) + * (org_123, 'admin', 'club-finance', true, true, false) + * (org_123, 'trainer', 'club-finance', false, false, false) -- explicit deny + * (org_123, 'trainer', 'calendar', true, true, false) + * + * If no row exists for `(space, role, module)`, the fallback is the + * default derived from the space type (see SPACE_MODULE_ALLOWLIST in + * shared-types) + role tier (owner > admin > member). These rows only + * exist when the space owner has customized the default. + */ +export const spaceModulePermissions = spacesSchema.table( + 'module_permissions', + { + spaceId: text('space_id') + .references(() => organizations.id, { onDelete: 'cascade' }) + .notNull(), + role: text('role').notNull(), + moduleId: text('module_id').notNull(), + canRead: boolean('can_read').notNull().default(true), + canWrite: boolean('can_write').notNull().default(false), + canAdmin: boolean('can_admin').notNull().default(false), + updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(), + }, + (table) => ({ + pk: primaryKey({ columns: [table.spaceId, table.role, table.moduleId] }), + spaceModuleIdx: index('space_module_permissions_space_module_idx').on( + table.spaceId, + table.moduleId + ), + }) +);