diff --git a/CHANGELOG.md b/CHANGELOG.md index 7102cd7..c382bc6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -76,6 +76,9 @@ This file records what changes **in the product** – process and session state - AI service logs use the web/worker format: `time` (ISO 8601, `Z`) instead of `ts`, lower-case pino level labels (`warn`, not `WARNING`), `logger` only on library records. - The request list reads each page from an index in its sort order instead of sorting all of the company's requests (#61). +- The Better Auth tables live in the schema `identity` instead of `auth`, which Supabase reserves for + its own Auth service (#60): a fresh database creates `identity` directly; migration 0018 renames an + existing `auth` schema only when the app owns it. ### Fixed - AI verifier: a unit quoted together with the neighbouring table cell (e.g. `60 | Stk.`) is now diff --git a/docs/technical/architecture.md b/docs/technical/architecture.md index b021e64..bc110fa 100644 --- a/docs/technical/architecture.md +++ b/docs/technical/architecture.md @@ -48,7 +48,7 @@ Deliberately accepted risks – without an entry here a deviation counts as a de | Exception | Why accepted | Owner | Expires | |---|---|---|---| -| No RLS on the `auth` and `pgboss` schemas | Not company-owned business data; reachable only by server code (ADR-0001 D7) | Fluory | 2026-12-31 (review at M3) | +| No RLS on the `identity` (Better Auth) and `pgboss` schemas | Not company-owned business data; reachable only by server code (ADR-0001 D7) | Fluory | 2026-12-31 (review at M3) | | Showcase without unattended retries (Vercel Hobby cron once/day) | Showcase only; production runs a worker (D2) | Fluory | when a production-like demo is needed | | Better Auth admin plugin mounted without any holder of its admin role | ADR-0001 D6 names the plugin; decided in #30: kept – its `banned` field implements deactivation (sign-in blocked by the plugin). Nobody holds `platform-admin`, so `/api/auth/admin/*` rejects every caller (tested); user management runs through `identity` | Fluory | 2026-12-31 (review at M3) | | Upload endpoint without a per-user rate limit | Authenticated staff only; body bounded by `Content-Length` + `UPLOAD_MAX_REQUEST_BYTES` before reading | Fluory | before any public deployment (#19) | diff --git a/docs/technical/data-model.md b/docs/technical/data-model.md index 77d0679..d008699 100644 --- a/docs/technical/data-model.md +++ b/docs/technical/data-model.md @@ -9,7 +9,7 @@ | Schema | Owner | Runtime access (`app_rw`) | Tenant isolation | |---|---|---|---| | `app` | `app_owner` | DML via default privileges, no CREATE | every table: `company_id` + RLS **enabled and forced**, policy `_tenant_isolation` | -| `auth` | `app_owner` | DML on all tables, no CREATE | none – Better Auth data, server code only (exceptions register) | +| `identity` (until #60: `auth`) | `app_owner` | DML on all tables, no CREATE | none – Better Auth data, server code only (exceptions register) | | `pgboss` | `app_owner` (deploy step installs schema + queues) | DML only | none – job queue, IDs only (exceptions register) | | `drizzle` | `app_owner` | none | migration journal | @@ -24,7 +24,7 @@ query sees zero rows and every write fails. | Column | Type | Notes | Class | |---|---|---|---| | `id` | uuid PK | `gen_random_uuid()` | internal | -| `company_id` | uuid FK → `auth.organization.id` | tenant key, `ON DELETE RESTRICT` | internal | +| `company_id` | uuid FK → `identity.organization.id` | tenant key, `ON DELETE RESTRICT` | internal | | `status` | text | `NEW · PROCESSING · REVIEW · APPROVED · EXPORTED · REJECTED · ERROR` (check constraint) | internal | | `created_at` | timestamptz | | internal | | `source` | text | `upload` (mailbox later) | internal | @@ -60,7 +60,7 @@ All three: `company_id`, forced RLS, composite FKs to the run and request of the | `id`, `company_id`, `request_id` | uuid | composite FK `(request_id, company_id)` → `requests` | internal | | `field_key`, `item_index` | text, int | header field key (six, schema v2), or a line-item field key with its position (`item_index`, #25; null for header fields; check `item_index is null or item_index >= 0`, #47) | internal | | `old_value`, `new_value` | text | value before / after; the newest row is the current value | confidential + personal | -| `corrected_by`, `created_at` | uuid, timestamptz | who and when; no FK to `auth.user` (like `audit_events.actor_user_id`) – the history must survive a user's removal | personal (staff) | +| `corrected_by`, `created_at` | uuid, timestamptz | who and when; no FK to `identity.user` (like `audit_events.actor_user_id`) – the history must survive a user's removal | personal (staff) | Append-only: forced RLS, `app_rw` has INSERT/SELECT only (UPDATE/DELETE/TRUNCATE revoked) – the history is the correction audit. The page shows a corrected value as `korrigiert`, never as `found` @@ -103,7 +103,7 @@ The bytes (confidential + personal) live only in object storage; served via `GET `app_rw` has INSERT and SELECT only (UPDATE/DELETE/TRUNCATE revoked). Written in the same transaction as the change. -### `auth.*` – Better Auth 1.7.5 (generated with the Better Auth CLI, timestamps with time zone) +### `identity.*` – Better Auth 1.7.5 (generated with the Better Auth CLI, timestamps with time zone) | Table | Content | Class | Purpose | |---|---|---|---| @@ -122,9 +122,9 @@ company's first admin; it can never obtain a session. ## Relations ```text -auth.organization 1─n auth.member n─1 auth.user 1─n auth.session / auth.account -auth.organization 1─n auth.invitation -auth.organization 1─n app.requests (company_id) +identity.organization 1─n identity.member n─1 identity.user 1─n identity.session / identity.account +identity.organization 1─n identity.invitation +identity.organization 1─n app.requests (company_id) app.requests 1─n app.documents (request_id, company_id) app.requests 0─1 app.requests (duplicate_of_id, company_id) app.requests 1─n app.extraction_runs (request_id, company_id) 1─n segments / fields diff --git a/docs/technical/operations.md b/docs/technical/operations.md index 3e51dee..4f6c69d 100644 --- a/docs/technical/operations.md +++ b/docs/technical/operations.md @@ -115,7 +115,7 @@ the secret manager), before the first `setup` run; the first migration refuses t ## Login rate limit and client IP Better Auth limits `/api/auth/*` per client IP (5 sign-ins/sign-ups per minute, counters in -`auth.rate_limit`). The IP comes from `AUTH_IP_HEADERS`; that header is only trustworthy when a +`identity.rate_limit`). The IP comes from `AUTH_IP_HEADERS`; that header is only trustworthy when a reverse proxy sets it and clients cannot reach the web container directly. Any deployment beyond the local machine puts a proxy in front and lists it in `AUTH_TRUSTED_PROXIES`. A per-account limit is a follow-up (not in the pilot). diff --git a/drizzle.config.ts b/drizzle.config.ts index cf0bfdf..1b98a89 100644 --- a/drizzle.config.ts +++ b/drizzle.config.ts @@ -5,7 +5,7 @@ export default defineConfig({ dialect: "postgresql", schema: "./src/db/schema/index.ts", out: "./src/db/migrations", - schemaFilter: ["app", "auth"], + schemaFilter: ["app", "identity"], migrations: { schema: "drizzle" }, dbCredentials: { url: process.env.MIGRATION_DATABASE_URL ?? "" }, }); diff --git a/src/db/migrations/0001_identity_tenancy.sql b/src/db/migrations/0001_identity_tenancy.sql index e87aff5..969d98e 100644 --- a/src/db/migrations/0001_identity_tenancy.sql +++ b/src/db/migrations/0001_identity_tenancy.sql @@ -1,7 +1,7 @@ -- Generated by drizzle-kit from src/db/schema/*; edited: schema `app` already exists (0000). -CREATE SCHEMA "auth"; +CREATE SCHEMA "identity"; --> statement-breakpoint -CREATE TABLE "auth"."account" ( +CREATE TABLE "identity"."account" ( "id" uuid PRIMARY KEY DEFAULT pg_catalog.gen_random_uuid() NOT NULL, "account_id" text NOT NULL, "provider_id" text NOT NULL, @@ -17,7 +17,7 @@ CREATE TABLE "auth"."account" ( "updated_at" timestamp with time zone NOT NULL ); --> statement-breakpoint -CREATE TABLE "auth"."invitation" ( +CREATE TABLE "identity"."invitation" ( "id" uuid PRIMARY KEY DEFAULT pg_catalog.gen_random_uuid() NOT NULL, "organization_id" uuid NOT NULL, "email" text NOT NULL, @@ -28,7 +28,7 @@ CREATE TABLE "auth"."invitation" ( "inviter_id" uuid NOT NULL ); --> statement-breakpoint -CREATE TABLE "auth"."member" ( +CREATE TABLE "identity"."member" ( "id" uuid PRIMARY KEY DEFAULT pg_catalog.gen_random_uuid() NOT NULL, "organization_id" uuid NOT NULL, "user_id" uuid NOT NULL, @@ -36,7 +36,7 @@ CREATE TABLE "auth"."member" ( "created_at" timestamp with time zone NOT NULL ); --> statement-breakpoint -CREATE TABLE "auth"."organization" ( +CREATE TABLE "identity"."organization" ( "id" uuid PRIMARY KEY DEFAULT pg_catalog.gen_random_uuid() NOT NULL, "name" text NOT NULL, "slug" text NOT NULL, @@ -46,7 +46,7 @@ CREATE TABLE "auth"."organization" ( CONSTRAINT "organization_slug_unique" UNIQUE("slug") ); --> statement-breakpoint -CREATE TABLE "auth"."rate_limit" ( +CREATE TABLE "identity"."rate_limit" ( "id" uuid PRIMARY KEY DEFAULT pg_catalog.gen_random_uuid() NOT NULL, "key" text NOT NULL, "count" integer NOT NULL, @@ -54,7 +54,7 @@ CREATE TABLE "auth"."rate_limit" ( CONSTRAINT "rate_limit_key_unique" UNIQUE("key") ); --> statement-breakpoint -CREATE TABLE "auth"."session" ( +CREATE TABLE "identity"."session" ( "id" uuid PRIMARY KEY DEFAULT pg_catalog.gen_random_uuid() NOT NULL, "expires_at" timestamp with time zone NOT NULL, "token" text NOT NULL, @@ -68,7 +68,7 @@ CREATE TABLE "auth"."session" ( CONSTRAINT "session_token_unique" UNIQUE("token") ); --> statement-breakpoint -CREATE TABLE "auth"."user" ( +CREATE TABLE "identity"."user" ( "id" uuid PRIMARY KEY DEFAULT pg_catalog.gen_random_uuid() NOT NULL, "name" text NOT NULL, "email" text NOT NULL, @@ -83,7 +83,7 @@ CREATE TABLE "auth"."user" ( CONSTRAINT "user_email_unique" UNIQUE("email") ); --> statement-breakpoint -CREATE TABLE "auth"."verification" ( +CREATE TABLE "identity"."verification" ( "id" uuid PRIMARY KEY DEFAULT pg_catalog.gen_random_uuid() NOT NULL, "identifier" text NOT NULL, "value" text NOT NULL, @@ -101,19 +101,19 @@ CREATE TABLE "app"."requests" ( ); --> statement-breakpoint ALTER TABLE "app"."requests" ENABLE ROW LEVEL SECURITY;--> statement-breakpoint -ALTER TABLE "auth"."account" ADD CONSTRAINT "account_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "auth"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint -ALTER TABLE "auth"."invitation" ADD CONSTRAINT "invitation_organization_id_organization_id_fk" FOREIGN KEY ("organization_id") REFERENCES "auth"."organization"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint -ALTER TABLE "auth"."invitation" ADD CONSTRAINT "invitation_inviter_id_user_id_fk" FOREIGN KEY ("inviter_id") REFERENCES "auth"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint -ALTER TABLE "auth"."member" ADD CONSTRAINT "member_organization_id_organization_id_fk" FOREIGN KEY ("organization_id") REFERENCES "auth"."organization"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint -ALTER TABLE "auth"."member" ADD CONSTRAINT "member_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "auth"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint -ALTER TABLE "auth"."session" ADD CONSTRAINT "session_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "auth"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint -ALTER TABLE "app"."requests" ADD CONSTRAINT "requests_company_id_organization_id_fk" FOREIGN KEY ("company_id") REFERENCES "auth"."organization"("id") ON DELETE restrict ON UPDATE no action;--> statement-breakpoint -CREATE INDEX "account_userId_idx" ON "auth"."account" USING btree ("user_id");--> statement-breakpoint -CREATE INDEX "invitation_organizationId_idx" ON "auth"."invitation" USING btree ("organization_id");--> statement-breakpoint -CREATE INDEX "invitation_email_idx" ON "auth"."invitation" USING btree ("email");--> statement-breakpoint -CREATE INDEX "member_organizationId_idx" ON "auth"."member" USING btree ("organization_id");--> statement-breakpoint -CREATE INDEX "member_userId_idx" ON "auth"."member" USING btree ("user_id");--> statement-breakpoint -CREATE INDEX "session_userId_idx" ON "auth"."session" USING btree ("user_id");--> statement-breakpoint -CREATE INDEX "verification_identifier_idx" ON "auth"."verification" USING btree ("identifier");--> statement-breakpoint +ALTER TABLE "identity"."account" ADD CONSTRAINT "account_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "identity"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "identity"."invitation" ADD CONSTRAINT "invitation_organization_id_organization_id_fk" FOREIGN KEY ("organization_id") REFERENCES "identity"."organization"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "identity"."invitation" ADD CONSTRAINT "invitation_inviter_id_user_id_fk" FOREIGN KEY ("inviter_id") REFERENCES "identity"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "identity"."member" ADD CONSTRAINT "member_organization_id_organization_id_fk" FOREIGN KEY ("organization_id") REFERENCES "identity"."organization"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "identity"."member" ADD CONSTRAINT "member_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "identity"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "identity"."session" ADD CONSTRAINT "session_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "identity"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "app"."requests" ADD CONSTRAINT "requests_company_id_organization_id_fk" FOREIGN KEY ("company_id") REFERENCES "identity"."organization"("id") ON DELETE restrict ON UPDATE no action;--> statement-breakpoint +CREATE INDEX "account_userId_idx" ON "identity"."account" USING btree ("user_id");--> statement-breakpoint +CREATE INDEX "invitation_organizationId_idx" ON "identity"."invitation" USING btree ("organization_id");--> statement-breakpoint +CREATE INDEX "invitation_email_idx" ON "identity"."invitation" USING btree ("email");--> statement-breakpoint +CREATE INDEX "member_organizationId_idx" ON "identity"."member" USING btree ("organization_id");--> statement-breakpoint +CREATE INDEX "member_userId_idx" ON "identity"."member" USING btree ("user_id");--> statement-breakpoint +CREATE INDEX "session_userId_idx" ON "identity"."session" USING btree ("user_id");--> statement-breakpoint +CREATE INDEX "verification_identifier_idx" ON "identity"."verification" USING btree ("identifier");--> statement-breakpoint CREATE INDEX "requests_company_id_idx" ON "app"."requests" USING btree ("company_id");--> statement-breakpoint CREATE POLICY "requests_tenant_isolation" ON "app"."requests" AS PERMISSIVE FOR ALL TO public USING (company_id = nullif(current_setting('app.company_id', true), '')::uuid) WITH CHECK (company_id = nullif(current_setting('app.company_id', true), '')::uuid); \ No newline at end of file diff --git a/src/db/migrations/0002_auth_grants_force_rls.sql b/src/db/migrations/0002_auth_grants_force_rls.sql index e7373f5..9d91ce6 100644 --- a/src/db/migrations/0002_auth_grants_force_rls.sql +++ b/src/db/migrations/0002_auth_grants_force_rls.sql @@ -1,17 +1,17 @@ -- Hand-written (ADR-0001 D7). --- 1) The runtime role may use the Better Auth tables (schema `auth`, no RLS – exceptions register), +-- 1) The runtime role may use the Better Auth tables (schema `identity` – named `auth` until #60 –, no RLS – exceptions register), -- but never create objects there. -GRANT USAGE ON SCHEMA auth TO app_rw; +GRANT USAGE ON SCHEMA identity TO app_rw; --> statement-breakpoint -REVOKE ALL ON SCHEMA auth FROM PUBLIC; +REVOKE ALL ON SCHEMA identity FROM PUBLIC; --> statement-breakpoint -GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA auth TO app_rw; +GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA identity TO app_rw; --> statement-breakpoint -ALTER DEFAULT PRIVILEGES FOR ROLE app_owner IN SCHEMA auth GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES TO app_rw; +ALTER DEFAULT PRIVILEGES FOR ROLE app_owner IN SCHEMA identity GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES TO app_rw; --> statement-breakpoint -- 2) Forced RLS: drizzle-kit emits only ENABLE. FORCE makes the policy apply to the table owner too, -- so no role except a superuser ever reads company data without `app.company_id`. ALTER TABLE app.requests FORCE ROW LEVEL SECURITY; --> statement-breakpoint -- 3) One company per user in the pilot: the session hook and getActor resolve THE membership. -CREATE UNIQUE INDEX member_one_company_per_user ON auth.member (user_id); +CREATE UNIQUE INDEX member_one_company_per_user ON identity.member (user_id); diff --git a/src/db/migrations/0003_intake.sql b/src/db/migrations/0003_intake.sql index 46d180f..5ca9685 100644 --- a/src/db/migrations/0003_intake.sql +++ b/src/db/migrations/0003_intake.sql @@ -32,8 +32,8 @@ ALTER TABLE "app"."requests" ADD COLUMN "message_id" text;--> statement-breakpoi ALTER TABLE "app"."requests" ADD COLUMN "fingerprint" text;--> statement-breakpoint ALTER TABLE "app"."requests" ADD COLUMN "possible_duplicate" boolean DEFAULT false NOT NULL;--> statement-breakpoint ALTER TABLE "app"."requests" ADD COLUMN "duplicate_of_id" uuid;--> statement-breakpoint -ALTER TABLE "app"."audit_events" ADD CONSTRAINT "audit_events_company_id_organization_id_fk" FOREIGN KEY ("company_id") REFERENCES "auth"."organization"("id") ON DELETE restrict ON UPDATE no action;--> statement-breakpoint -ALTER TABLE "app"."documents" ADD CONSTRAINT "documents_company_id_organization_id_fk" FOREIGN KEY ("company_id") REFERENCES "auth"."organization"("id") ON DELETE restrict ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "app"."audit_events" ADD CONSTRAINT "audit_events_company_id_organization_id_fk" FOREIGN KEY ("company_id") REFERENCES "identity"."organization"("id") ON DELETE restrict ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "app"."documents" ADD CONSTRAINT "documents_company_id_organization_id_fk" FOREIGN KEY ("company_id") REFERENCES "identity"."organization"("id") ON DELETE restrict ON UPDATE no action;--> statement-breakpoint ALTER TABLE "app"."documents" ADD CONSTRAINT "documents_request_id_requests_id_fk" FOREIGN KEY ("request_id") REFERENCES "app"."requests"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint CREATE INDEX "audit_events_entity_idx" ON "app"."audit_events" USING btree ("company_id","entity_type","entity_id");--> statement-breakpoint CREATE INDEX "documents_company_id_idx" ON "app"."documents" USING btree ("company_id");--> statement-breakpoint diff --git a/src/db/migrations/0006_processing.sql b/src/db/migrations/0006_processing.sql index 94e8566..a4def8e 100644 --- a/src/db/migrations/0006_processing.sql +++ b/src/db/migrations/0006_processing.sql @@ -51,7 +51,7 @@ ALTER TABLE "app"."requests" ADD COLUMN "error_message" text;--> statement-break ALTER TABLE "app"."requests" ADD COLUMN "attempts" integer DEFAULT 0 NOT NULL;--> statement-breakpoint ALTER TABLE "app"."requests" ADD COLUMN "next_retry_at" timestamp with time zone;--> statement-breakpoint ALTER TABLE "app"."extracted_fields" ADD CONSTRAINT "extracted_fields_run_same_company_fk" FOREIGN KEY ("run_id","company_id") REFERENCES "app"."extraction_runs"("id","company_id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint -ALTER TABLE "app"."extraction_runs" ADD CONSTRAINT "extraction_runs_company_id_organization_id_fk" FOREIGN KEY ("company_id") REFERENCES "auth"."organization"("id") ON DELETE restrict ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "app"."extraction_runs" ADD CONSTRAINT "extraction_runs_company_id_organization_id_fk" FOREIGN KEY ("company_id") REFERENCES "identity"."organization"("id") ON DELETE restrict ON UPDATE no action;--> statement-breakpoint ALTER TABLE "app"."extraction_runs" ADD CONSTRAINT "extraction_runs_request_same_company_fk" FOREIGN KEY ("request_id","company_id") REFERENCES "app"."requests"("id","company_id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint ALTER TABLE "app"."extraction_segments" ADD CONSTRAINT "extraction_segments_run_same_company_fk" FOREIGN KEY ("run_id","company_id") REFERENCES "app"."extraction_runs"("id","company_id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint CREATE INDEX "extracted_fields_request_idx" ON "app"."extracted_fields" USING btree ("company_id","request_id");--> statement-breakpoint diff --git a/src/db/migrations/0011_export.sql b/src/db/migrations/0011_export.sql index 8a18fbd..ed7cc0b 100644 --- a/src/db/migrations/0011_export.sql +++ b/src/db/migrations/0011_export.sql @@ -15,6 +15,6 @@ CREATE TABLE "app"."request_exports" ( ); --> statement-breakpoint ALTER TABLE "app"."request_exports" ENABLE ROW LEVEL SECURITY;--> statement-breakpoint -ALTER TABLE "app"."request_exports" ADD CONSTRAINT "request_exports_company_id_organization_id_fk" FOREIGN KEY ("company_id") REFERENCES "auth"."organization"("id") ON DELETE restrict ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "app"."request_exports" ADD CONSTRAINT "request_exports_company_id_organization_id_fk" FOREIGN KEY ("company_id") REFERENCES "identity"."organization"("id") ON DELETE restrict ON UPDATE no action;--> statement-breakpoint ALTER TABLE "app"."request_exports" ADD CONSTRAINT "request_exports_request_same_company_fk" FOREIGN KEY ("request_id","company_id") REFERENCES "app"."requests"("id","company_id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint CREATE POLICY "request_exports_tenant_isolation" ON "app"."request_exports" AS PERMISSIVE FOR ALL TO public USING (company_id = nullif(current_setting('app.company_id', true), '')::uuid) WITH CHECK (company_id = nullif(current_setting('app.company_id', true), '')::uuid); \ No newline at end of file diff --git a/src/db/migrations/0018_identity_schema.sql b/src/db/migrations/0018_identity_schema.sql new file mode 100644 index 0000000..635e2b3 --- /dev/null +++ b/src/db/migrations/0018_identity_schema.sql @@ -0,0 +1,12 @@ +-- #60: Supabase reserves the schema `auth` for its own Auth service, so the Better Auth tables live in +-- `identity` (the module's name). Migrations 0001–0017 create `identity` directly on a fresh database, +-- so they never touch a platform-owned `auth` schema. Databases migrated before #60 still have OUR +-- schema `auth`: it is renamed here – only when it belongs to the migrating role and `identity` does not +-- exist yet. Renaming keeps tables, data, foreign keys, indexes, grants and default privileges. +DO $$ +BEGIN + IF EXISTS (SELECT 1 FROM pg_namespace WHERE nspname = 'auth' AND pg_get_userbyid(nspowner) = current_user) + AND NOT EXISTS (SELECT 1 FROM pg_namespace WHERE nspname = 'identity') THEN + ALTER SCHEMA "auth" RENAME TO "identity"; + END IF; +END $$; diff --git a/src/db/migrations/meta/0018_snapshot.json b/src/db/migrations/meta/0018_snapshot.json new file mode 100644 index 0000000..fff1f3c --- /dev/null +++ b/src/db/migrations/meta/0018_snapshot.json @@ -0,0 +1,2006 @@ +{ + "id": "8c8a14b9-dd3c-4858-bdf7-9127405111b6", + "prevId": "a3cdd9c7-27dd-40ea-8548-769ec4bd713f", + "version": "7", + "dialect": "postgresql", + "tables": { + "identity.account": { + "name": "account", + "schema": "identity", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "pg_catalog.gen_random_uuid()" + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "id_token": { + "name": "id_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "access_token_expires_at": { + "name": "access_token_expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "refresh_token_expires_at": { + "name": "refresh_token_expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "account_userId_idx": { + "name": "account_userId_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "account_user_id_user_id_fk": { + "name": "account_user_id_user_id_fk", + "tableFrom": "account", + "tableTo": "user", + "schemaTo": "identity", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "identity.invitation": { + "name": "invitation", + "schema": "identity", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "pg_catalog.gen_random_uuid()" + }, + "organization_id": { + "name": "organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "inviter_id": { + "name": "inviter_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "invitation_organizationId_idx": { + "name": "invitation_organizationId_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "invitation_email_idx": { + "name": "invitation_email_idx", + "columns": [ + { + "expression": "email", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "invitation_organization_id_organization_id_fk": { + "name": "invitation_organization_id_organization_id_fk", + "tableFrom": "invitation", + "tableTo": "organization", + "schemaTo": "identity", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "invitation_inviter_id_user_id_fk": { + "name": "invitation_inviter_id_user_id_fk", + "tableFrom": "invitation", + "tableTo": "user", + "schemaTo": "identity", + "columnsFrom": [ + "inviter_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "identity.member": { + "name": "member", + "schema": "identity", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "pg_catalog.gen_random_uuid()" + }, + "organization_id": { + "name": "organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'member'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "member_organizationId_idx": { + "name": "member_organizationId_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "member_userId_idx": { + "name": "member_userId_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "member_organization_id_organization_id_fk": { + "name": "member_organization_id_organization_id_fk", + "tableFrom": "member", + "tableTo": "organization", + "schemaTo": "identity", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "member_user_id_user_id_fk": { + "name": "member_user_id_user_id_fk", + "tableFrom": "member", + "tableTo": "user", + "schemaTo": "identity", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "identity.organization": { + "name": "organization", + "schema": "identity", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "pg_catalog.gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "logo": { + "name": "logo", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "metadata": { + "name": "metadata", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "organization_slug_unique": { + "name": "organization_slug_unique", + "nullsNotDistinct": false, + "columns": [ + "slug" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "identity.rate_limit": { + "name": "rate_limit", + "schema": "identity", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "pg_catalog.gen_random_uuid()" + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "count": { + "name": "count", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "last_request": { + "name": "last_request", + "type": "bigint", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "rate_limit_key_unique": { + "name": "rate_limit_key_unique", + "nullsNotDistinct": false, + "columns": [ + "key" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "identity.session": { + "name": "session", + "schema": "identity", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "pg_catalog.gen_random_uuid()" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "active_organization_id": { + "name": "active_organization_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "impersonated_by": { + "name": "impersonated_by", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "session_userId_idx": { + "name": "session_userId_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "session_user_id_user_id_fk": { + "name": "session_user_id_user_id_fk", + "tableFrom": "session", + "tableTo": "user", + "schemaTo": "identity", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "session_token_unique": { + "name": "session_token_unique", + "nullsNotDistinct": false, + "columns": [ + "token" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "identity.user": { + "name": "user", + "schema": "identity", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "pg_catalog.gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email_verified": { + "name": "email_verified", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "banned": { + "name": "banned", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "ban_reason": { + "name": "ban_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "ban_expires": { + "name": "ban_expires", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "user_email_unique": { + "name": "user_email_unique", + "nullsNotDistinct": false, + "columns": [ + "email" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "identity.verification": { + "name": "verification", + "schema": "identity", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "pg_catalog.gen_random_uuid()" + }, + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "verification_identifier_idx": { + "name": "verification_identifier_idx", + "columns": [ + { + "expression": "identifier", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "app.audit_events": { + "name": "audit_events", + "schema": "app", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "company_id": { + "name": "company_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "actor_user_id": { + "name": "actor_user_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "action": { + "name": "action", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "entity_type": { + "name": "entity_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "entity_id": { + "name": "entity_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "data": { + "name": "data", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "audit_events_entity_idx": { + "name": "audit_events_entity_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "entity_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "entity_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "audit_events_company_id_organization_id_fk": { + "name": "audit_events_company_id_organization_id_fk", + "tableFrom": "audit_events", + "tableTo": "organization", + "schemaTo": "identity", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": { + "audit_events_tenant_isolation": { + "name": "audit_events_tenant_isolation", + "as": "PERMISSIVE", + "for": "ALL", + "to": [ + "public" + ], + "using": "company_id = nullif(current_setting('app.company_id', true), '')::uuid", + "withCheck": "company_id = nullif(current_setting('app.company_id', true), '')::uuid" + } + }, + "checkConstraints": {}, + "isRLSEnabled": true + }, + "app.documents": { + "name": "documents", + "schema": "app", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "company_id": { + "name": "company_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "request_id": { + "name": "request_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "filename": { + "name": "filename", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content_type": { + "name": "content_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "size_bytes": { + "name": "size_bytes", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "sha256": { + "name": "sha256", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "storage_key": { + "name": "storage_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "documents_company_id_idx": { + "name": "documents_company_id_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "documents_request_id_idx": { + "name": "documents_request_id_idx", + "columns": [ + { + "expression": "request_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "documents_company_id_organization_id_fk": { + "name": "documents_company_id_organization_id_fk", + "tableFrom": "documents", + "tableTo": "organization", + "schemaTo": "identity", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + }, + "documents_request_same_company_fk": { + "name": "documents_request_same_company_fk", + "tableFrom": "documents", + "tableTo": "requests", + "schemaTo": "app", + "columnsFrom": [ + "request_id", + "company_id" + ], + "columnsTo": [ + "id", + "company_id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": { + "documents_tenant_isolation": { + "name": "documents_tenant_isolation", + "as": "PERMISSIVE", + "for": "ALL", + "to": [ + "public" + ], + "using": "company_id = nullif(current_setting('app.company_id', true), '')::uuid", + "withCheck": "company_id = nullif(current_setting('app.company_id', true), '')::uuid" + } + }, + "checkConstraints": { + "documents_kind_check": { + "name": "documents_kind_check", + "value": "kind in ('eml', 'msg', 'pdf', 'xlsx', 'docx')" + } + }, + "isRLSEnabled": true + }, + "app.extracted_fields": { + "name": "extracted_fields", + "schema": "app", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "company_id": { + "name": "company_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "run_id": { + "name": "run_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "request_id": { + "name": "request_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "field_key": { + "name": "field_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "model_status": { + "name": "model_status", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "reason": { + "name": "reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "document_id": { + "name": "document_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "segment_id": { + "name": "segment_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "quote": { + "name": "quote", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "item_index": { + "name": "item_index", + "type": "integer", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "extracted_fields_request_idx": { + "name": "extracted_fields_request_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "request_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "extracted_fields_run_same_company_fk": { + "name": "extracted_fields_run_same_company_fk", + "tableFrom": "extracted_fields", + "tableTo": "extraction_runs", + "schemaTo": "app", + "columnsFrom": [ + "run_id", + "company_id" + ], + "columnsTo": [ + "id", + "company_id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "extracted_fields_request_same_company_fk": { + "name": "extracted_fields_request_same_company_fk", + "tableFrom": "extracted_fields", + "tableTo": "requests", + "schemaTo": "app", + "columnsFrom": [ + "request_id", + "company_id" + ], + "columnsTo": [ + "id", + "company_id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "extracted_fields_evidence_segment_fk": { + "name": "extracted_fields_evidence_segment_fk", + "tableFrom": "extracted_fields", + "tableTo": "extraction_segments", + "schemaTo": "app", + "columnsFrom": [ + "run_id", + "document_id", + "segment_id" + ], + "columnsTo": [ + "run_id", + "document_id", + "segment_id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "extracted_fields_run_field_item_unique": { + "name": "extracted_fields_run_field_item_unique", + "nullsNotDistinct": true, + "columns": [ + "run_id", + "field_key", + "item_index" + ] + } + }, + "policies": { + "extracted_fields_tenant_isolation": { + "name": "extracted_fields_tenant_isolation", + "as": "PERMISSIVE", + "for": "ALL", + "to": [ + "public" + ], + "using": "company_id = nullif(current_setting('app.company_id', true), '')::uuid", + "withCheck": "company_id = nullif(current_setting('app.company_id', true), '')::uuid" + } + }, + "checkConstraints": { + "extracted_fields_item_index_check": { + "name": "extracted_fields_item_index_check", + "value": "item_index is null or item_index >= 0" + }, + "extracted_fields_status_check": { + "name": "extracted_fields_status_check", + "value": "status in ('found', 'uncertain', 'missing', 'unverified')" + }, + "extracted_fields_found_has_evidence": { + "name": "extracted_fields_found_has_evidence", + "value": "status <> 'found' or (quote is not null and segment_id is not null)" + } + }, + "isRLSEnabled": true + }, + "app.extraction_runs": { + "name": "extraction_runs", + "schema": "app", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "company_id": { + "name": "company_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "request_id": { + "name": "request_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "job_id": { + "name": "job_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "model_id": { + "name": "model_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "prompt_version": { + "name": "prompt_version", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "schema_version": { + "name": "schema_version", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "total_tokens": { + "name": "total_tokens", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "latency_ms": { + "name": "latency_ms", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "documents": { + "name": "documents", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "extraction_runs_request_idx": { + "name": "extraction_runs_request_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "request_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "extraction_runs_company_id_organization_id_fk": { + "name": "extraction_runs_company_id_organization_id_fk", + "tableFrom": "extraction_runs", + "tableTo": "organization", + "schemaTo": "identity", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + }, + "extraction_runs_request_same_company_fk": { + "name": "extraction_runs_request_same_company_fk", + "tableFrom": "extraction_runs", + "tableTo": "requests", + "schemaTo": "app", + "columnsFrom": [ + "request_id", + "company_id" + ], + "columnsTo": [ + "id", + "company_id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "extraction_runs_job_id_unique": { + "name": "extraction_runs_job_id_unique", + "nullsNotDistinct": false, + "columns": [ + "job_id" + ] + }, + "extraction_runs_id_company_unique": { + "name": "extraction_runs_id_company_unique", + "nullsNotDistinct": false, + "columns": [ + "id", + "company_id" + ] + } + }, + "policies": { + "extraction_runs_tenant_isolation": { + "name": "extraction_runs_tenant_isolation", + "as": "PERMISSIVE", + "for": "ALL", + "to": [ + "public" + ], + "using": "company_id = nullif(current_setting('app.company_id', true), '')::uuid", + "withCheck": "company_id = nullif(current_setting('app.company_id', true), '')::uuid" + } + }, + "checkConstraints": {}, + "isRLSEnabled": true + }, + "app.extraction_segments": { + "name": "extraction_segments", + "schema": "app", + "columns": { + "company_id": { + "name": "company_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "run_id": { + "name": "run_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "document_id": { + "name": "document_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "segment_id": { + "name": "segment_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "position": { + "name": "position", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "text": { + "name": "text", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "locator": { + "name": "locator", + "type": "jsonb", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "extraction_segments_run_same_company_fk": { + "name": "extraction_segments_run_same_company_fk", + "tableFrom": "extraction_segments", + "tableTo": "extraction_runs", + "schemaTo": "app", + "columnsFrom": [ + "run_id", + "company_id" + ], + "columnsTo": [ + "id", + "company_id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "extraction_segments_pk": { + "name": "extraction_segments_pk", + "columns": [ + "run_id", + "document_id", + "segment_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": { + "extraction_segments_tenant_isolation": { + "name": "extraction_segments_tenant_isolation", + "as": "PERMISSIVE", + "for": "ALL", + "to": [ + "public" + ], + "using": "company_id = nullif(current_setting('app.company_id', true), '')::uuid", + "withCheck": "company_id = nullif(current_setting('app.company_id', true), '')::uuid" + } + }, + "checkConstraints": {}, + "isRLSEnabled": true + }, + "app.field_corrections": { + "name": "field_corrections", + "schema": "app", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "company_id": { + "name": "company_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "request_id": { + "name": "request_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "field_key": { + "name": "field_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "item_index": { + "name": "item_index", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "old_value": { + "name": "old_value", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "new_value": { + "name": "new_value", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "corrected_by": { + "name": "corrected_by", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "field_corrections_request_idx": { + "name": "field_corrections_request_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "request_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "field_key", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "field_corrections_request_same_company_fk": { + "name": "field_corrections_request_same_company_fk", + "tableFrom": "field_corrections", + "tableTo": "requests", + "schemaTo": "app", + "columnsFrom": [ + "request_id", + "company_id" + ], + "columnsTo": [ + "id", + "company_id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": { + "field_corrections_tenant_isolation": { + "name": "field_corrections_tenant_isolation", + "as": "PERMISSIVE", + "for": "ALL", + "to": [ + "public" + ], + "using": "company_id = nullif(current_setting('app.company_id', true), '')::uuid", + "withCheck": "company_id = nullif(current_setting('app.company_id', true), '')::uuid" + } + }, + "checkConstraints": { + "field_corrections_item_index_check": { + "name": "field_corrections_item_index_check", + "value": "item_index is null or item_index >= 0" + } + }, + "isRLSEnabled": true + }, + "app.request_exports": { + "name": "request_exports", + "schema": "app", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "company_id": { + "name": "company_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "request_id": { + "name": "request_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "idempotency_key": { + "name": "idempotency_key", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "erp_reference": { + "name": "erp_reference", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "attempts": { + "name": "attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "last_error": { + "name": "last_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "exported_at": { + "name": "exported_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "request_exports_company_id_organization_id_fk": { + "name": "request_exports_company_id_organization_id_fk", + "tableFrom": "request_exports", + "tableTo": "organization", + "schemaTo": "identity", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + }, + "request_exports_request_same_company_fk": { + "name": "request_exports_request_same_company_fk", + "tableFrom": "request_exports", + "tableTo": "requests", + "schemaTo": "app", + "columnsFrom": [ + "request_id", + "company_id" + ], + "columnsTo": [ + "id", + "company_id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "request_exports_request_id_unique": { + "name": "request_exports_request_id_unique", + "nullsNotDistinct": false, + "columns": [ + "request_id" + ] + } + }, + "policies": { + "request_exports_tenant_isolation": { + "name": "request_exports_tenant_isolation", + "as": "PERMISSIVE", + "for": "ALL", + "to": [ + "public" + ], + "using": "company_id = nullif(current_setting('app.company_id', true), '')::uuid", + "withCheck": "company_id = nullif(current_setting('app.company_id', true), '')::uuid" + } + }, + "checkConstraints": { + "request_exports_status_check": { + "name": "request_exports_status_check", + "value": "status in ('pending', 'succeeded')" + }, + "request_exports_succeeded_has_reference": { + "name": "request_exports_succeeded_has_reference", + "value": "status <> 'succeeded' or (erp_reference is not null and exported_at is not null)" + } + }, + "isRLSEnabled": true + }, + "app.requests": { + "name": "requests", + "schema": "app", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "company_id": { + "name": "company_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'NEW'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'upload'" + }, + "created_by": { + "name": "created_by", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "subject": { + "name": "subject", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "message_id": { + "name": "message_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "fingerprint": { + "name": "fingerprint", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "possible_duplicate": { + "name": "possible_duplicate", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "duplicate_of_id": { + "name": "duplicate_of_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "error_stage": { + "name": "error_stage", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "attempts": { + "name": "attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "next_retry_at": { + "name": "next_retry_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "rejection_reason": { + "name": "rejection_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "duplicate_decision": { + "name": "duplicate_decision", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "requests_company_created_idx": { + "name": "requests_company_created_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "first" + }, + { + "expression": "id", + "isExpression": false, + "asc": false, + "nulls": "first" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "requests_company_message_id_idx": { + "name": "requests_company_message_id_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "message_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "requests_company_fingerprint_idx": { + "name": "requests_company_fingerprint_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "fingerprint", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "requests_company_id_organization_id_fk": { + "name": "requests_company_id_organization_id_fk", + "tableFrom": "requests", + "tableTo": "organization", + "schemaTo": "identity", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + }, + "requests_duplicate_same_company_fk": { + "name": "requests_duplicate_same_company_fk", + "tableFrom": "requests", + "tableTo": "requests", + "schemaTo": "app", + "columnsFrom": [ + "duplicate_of_id", + "company_id" + ], + "columnsTo": [ + "id", + "company_id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "requests_id_company_unique": { + "name": "requests_id_company_unique", + "nullsNotDistinct": false, + "columns": [ + "id", + "company_id" + ] + } + }, + "policies": { + "requests_tenant_isolation": { + "name": "requests_tenant_isolation", + "as": "PERMISSIVE", + "for": "ALL", + "to": [ + "public" + ], + "using": "company_id = nullif(current_setting('app.company_id', true), '')::uuid", + "withCheck": "company_id = nullif(current_setting('app.company_id', true), '')::uuid" + } + }, + "checkConstraints": { + "requests_status_check": { + "name": "requests_status_check", + "value": "status in ('NEW', 'PROCESSING', 'REVIEW', 'APPROVED', 'EXPORTED', 'REJECTED', 'ERROR')" + }, + "requests_duplicate_decision_check": { + "name": "requests_duplicate_decision_check", + "value": "duplicate_decision is null or duplicate_decision in ('distinct', 'duplicate')" + } + }, + "isRLSEnabled": true + } + }, + "enums": {}, + "schemas": { + "identity": "identity", + "app": "app" + }, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/src/db/migrations/meta/_journal.json b/src/db/migrations/meta/_journal.json index dd2d753..853cdfe 100644 --- a/src/db/migrations/meta/_journal.json +++ b/src/db/migrations/meta/_journal.json @@ -127,6 +127,13 @@ "when": 1790256184345, "tag": "0017_request_list_index", "breakpoints": true + }, + { + "idx": 18, + "version": "7", + "when": 1790256343255, + "tag": "0018_identity_schema", + "breakpoints": true } ] } \ No newline at end of file diff --git a/src/db/schema/auth.ts b/src/db/schema/auth.ts index 7c972e0..26c94aa 100644 --- a/src/db/schema/auth.ts +++ b/src/db/schema/auth.ts @@ -13,7 +13,7 @@ import { index, } from "drizzle-orm/pg-core"; -export const authSchema = pgSchema("auth"); +export const authSchema = pgSchema("identity"); export const user = authSchema.table("user", { id: uuid("id") diff --git a/src/db/schema/index.ts b/src/db/schema/index.ts index 378ac7f..aba176b 100644 --- a/src/db/schema/index.ts +++ b/src/db/schema/index.ts @@ -1,4 +1,4 @@ -// Drizzle schema: `auth` (Better Auth, no RLS – exceptions register) and `app` (company-owned, +// Drizzle schema: `identity` (Better Auth, no RLS – exceptions register) and `app` (company-owned, // forced RLS). Migrations in ../migrations are generated from here plus hand-written SQL. export * from "./auth"; export * from "./app"; diff --git a/tests/integration/helpers/stack.ts b/tests/integration/helpers/stack.ts index d9f829e..19a39c7 100644 --- a/tests/integration/helpers/stack.ts +++ b/tests/integration/helpers/stack.ts @@ -29,7 +29,7 @@ export const PASSWORD = "synthetic-password-123"; */ const group = () => Math.floor(Math.random() * 0x10000).toString(16).padStart(4, "0"); export const freshIp = () => `2001:db8:${group()}:${group()}::1`; -/** How Better Auth keys an IPv6 client in `auth.rate_limit` (expanded /64 prefix). */ +/** How Better Auth keys an IPv6 client in `identity.rate_limit` (expanded /64 prefix). */ export const rateLimitKeyPrefix = (ip: string) => `2001:0db8:${ip.split(":")[2]}:${ip.split(":")[3]}:`; export async function call( diff --git a/tests/integration/migrations.test.ts b/tests/integration/migrations.test.ts index 423547f..14e84fe 100644 --- a/tests/integration/migrations.test.ts +++ b/tests/integration/migrations.test.ts @@ -1,4 +1,4 @@ -import { readFileSync } from "node:fs"; +import { readdirSync, readFileSync } from "node:fs"; import pg from "pg"; import { afterAll, beforeAll, describe, expect, it } from "vitest"; @@ -59,4 +59,15 @@ describe("first migration", () => { await expect(rw.query(guard)).rejects.toThrow(/migrations must run as app_owner, not app_rw/); }); + + it("never creates or uses a schema named auth – Supabase reserves it (#60)", () => { + const folder = new URL("../../src/db/migrations/", import.meta.url); + const files = readdirSync(folder).filter((name) => name.endsWith(".sql")); + const usesAuth = /"auth"\s*\.|\bSCHEMA\s+"?auth"?\b|\bauth\.[a-z_]+/i; + const offenders = files.filter((name) => !name.startsWith("0018_") && usesAuth.test(readFileSync(new URL(name, folder), "utf8"))); + expect(offenders).toEqual([]); + // The one rename for databases migrated before #60 touches only a schema the migrating role owns. + const rename = readFileSync(new URL(files.find((name) => name.startsWith("0018_"))!, folder), "utf8"); + expect(rename).toMatch(/pg_get_userbyid\(nspowner\) = current_user/); + }); }); diff --git a/tests/integration/rls-guard.test.ts b/tests/integration/rls-guard.test.ts index a0d4612..d949355 100644 --- a/tests/integration/rls-guard.test.ts +++ b/tests/integration/rls-guard.test.ts @@ -42,7 +42,7 @@ describe("tenant isolation guard: every app table", () => { } }); - it("keeps data in known schemas only: app (guarded) plus the registered exceptions auth, pgboss, drizzle", async () => { + it("keeps data in known schemas only: app (guarded) plus the registered exceptions identity, pgboss, drizzle", async () => { const { rows } = await owner.query<{ schema: string }>( `select distinct n.nspname as schema from pg_class c join pg_namespace n on n.oid = c.relnamespace where c.relkind in ('r', 'p', 'm', 'f', 'v') and n.nspname not in ('pg_catalog', 'information_schema') and n.nspname not like 'pg\\_toast%' @@ -51,7 +51,7 @@ describe("tenant isolation guard: every app table", () => { // A new schema (or a table in public) must be added here consciously – with a register entry // in docs/technical/architecture.md if it holds company data without RLS. - expect(rows.map((row) => row.schema)).toEqual(["app", "auth", "drizzle", "pgboss"]); + expect(rows.map((row) => row.schema)).toEqual(["app", "drizzle", "identity", "pgboss"]); }); it("fails for a deliberately unprotected table (proof that the guard bites)", async () => {