mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 21:21:10 +02:00
feat(spaces): add spaces pg schema — credentials + module_permissions
Groundwork for server-side Space extensions that must NOT live in Dexie:
- spaces.credentials — per-space OAuth tokens, API keys, SMTP
configs. Access tokens are stored
encrypted at rest with the service KEK.
- spaces.module_permissions — role × module read/write/admin overrides
on top of the SPACE_MODULE_ALLOWLIST
defaults.
Both tables FK to auth.organizations with ON DELETE CASCADE so deleting
a space drops its credentials and permission overrides automatically.
RLS is intentionally deferred — enabling it now would lock out services
that don't yet pass space context. A follow-up migration turns it on
after mana-api speaks the Spaces protocol end-to-end.
To apply locally: bun run db:push in services/mana-auth, or psql -f
sql/004_spaces.sql against the mana_platform DB.
No runtime code reads these tables yet — they're the scaffolding that
Task-8 (mana-sync) and the eventual social-relay/clubs modules will
consume.
Plan: docs/plans/spaces-foundation.md
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
da1bb2d6e9
commit
698ffe797c
3 changed files with 167 additions and 0 deletions
70
services/mana-auth/sql/004_spaces.sql
Normal file
70
services/mana-auth/sql/004_spaces.sql
Normal file
|
|
@ -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;
|
||||
|
|
@ -3,3 +3,4 @@ export * from './organizations';
|
|||
export * from './api-keys';
|
||||
export * from './login-attempts';
|
||||
export * from './encryption-vaults';
|
||||
export * from './spaces';
|
||||
|
|
|
|||
96
services/mana-auth/src/db/schema/spaces.ts
Normal file
96
services/mana-auth/src/db/schema/spaces.ts
Normal file
|
|
@ -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
|
||||
),
|
||||
})
|
||||
);
|
||||
Loading…
Add table
Add a link
Reference in a new issue