From 5da3dd38ffdedd1885779bf42fdd076a54118fdb Mon Sep 17 00:00:00 2001 From: Jesse_Chen Date: Tue, 21 Jul 2026 17:27:36 +0800 Subject: [PATCH] feat(identity): add local postgres identity schema --- .../20260721000100_self_hosted_identity.sql | 99 ++++++++++ .../database-self-hosted-identity.test.ts | 185 ++++++++++++++++++ 2 files changed, 284 insertions(+) create mode 100644 frontend/db/migrations/20260721000100_self_hosted_identity.sql create mode 100644 frontend/tests/database-self-hosted-identity.test.ts diff --git a/frontend/db/migrations/20260721000100_self_hosted_identity.sql b/frontend/db/migrations/20260721000100_self_hosted_identity.sql new file mode 100644 index 00000000..6ce78ddc --- /dev/null +++ b/frontend/db/migrations/20260721000100_self_hosted_identity.sql @@ -0,0 +1,99 @@ +create table if not exists identity.users ( + id uuid primary key default gen_random_uuid(), + name text not null, + email text not null, + email_verified boolean not null default false, + email_verified_at timestamptz, + image text, + role text not null default 'user', + banned boolean not null default false, + ban_reason text, + ban_expires timestamptz, + created_at timestamptz not null default now(), + updated_at timestamptz not null default now() +); + +create unique index if not exists identity_users_email_canonical_key + on identity.users (lower(btrim(email))); + +create table if not exists identity.sessions ( + id uuid primary key default gen_random_uuid(), + token text not null unique, + user_id uuid not null references identity.users(id) on delete cascade, + expires_at timestamptz not null, + ip_address text, + user_agent text, + impersonated_by uuid references identity.users(id) on delete set null, + created_at timestamptz not null default now(), + updated_at timestamptz not null default now() +); + +create index if not exists identity_sessions_user_id_idx + on identity.sessions (user_id); +create index if not exists identity_sessions_expires_at_idx + on identity.sessions (expires_at); + +create table if not exists identity.accounts ( + id uuid primary key default gen_random_uuid(), + account_id text not null, + provider_id text not null, + user_id uuid not null references identity.users(id) on delete cascade, + access_token text, + refresh_token text, + id_token text, + access_token_expires_at timestamptz, + refresh_token_expires_at timestamptz, + scope text, + password text, + created_at timestamptz not null default now(), + updated_at timestamptz not null default now(), + unique (provider_id, account_id) +); + +create index if not exists identity_accounts_user_id_idx + on identity.accounts (user_id); + +create table if not exists identity.verifications ( + id uuid primary key default gen_random_uuid(), + identifier text not null, + value text not null, + expires_at timestamptz not null, + created_at timestamptz not null default now(), + updated_at timestamptz not null default now() +); + +create index if not exists identity_verifications_identifier_idx + on identity.verifications (identifier); +create index if not exists identity_verifications_expires_at_idx + on identity.verifications (expires_at); + +create table if not exists identity.otp_rate_limits ( + id uuid primary key default gen_random_uuid(), + key text not null unique, + count integer not null default 0 check (count >= 0), + last_request bigint not null +); + +revoke all on table + identity.users, + identity.sessions, + identity.accounts, + identity.verifications, + identity.otp_rate_limits +from public, app_runtime, backup_reader, migration_runner; + +grant select, insert, update, delete on table + identity.users, + identity.sessions, + identity.accounts, + identity.verifications, + identity.otp_rate_limits +to identity_runtime; + +grant select on table + identity.users, + identity.sessions, + identity.accounts, + identity.verifications, + identity.otp_rate_limits +to admin_runtime; diff --git a/frontend/tests/database-self-hosted-identity.test.ts b/frontend/tests/database-self-hosted-identity.test.ts new file mode 100644 index 00000000..3266315b --- /dev/null +++ b/frontend/tests/database-self-hosted-identity.test.ts @@ -0,0 +1,185 @@ +import assert from "node:assert/strict"; +import { spawnSync } from "node:child_process"; +import { readFileSync } from "node:fs"; +import { fileURLToPath } from "node:url"; +import test from "node:test"; + +import { startPostgresFixture } from "./helpers/postgres-fixture.ts"; + +const runnerPath = fileURLToPath( + new URL("../scripts/db-migrate.mjs", import.meta.url), +); +const migrationsDirectory = fileURLToPath( + new URL("../db/migrations", import.meta.url), +); +const identityMigration = fileURLToPath( + new URL( + "../db/migrations/20260721000100_self_hosted_identity.sql", + import.meta.url, + ), +); + +test("self-hosted identity migration creates Better Auth tables with least privilege", () => { + const migrationSource = readFileSync(identityMigration, "utf8"); + assert.doesNotMatch(migrationSource, /grant all/i); + + const fixture = startPostgresFixture(); + const schemaUrl = fixture.connectionUrl( + "schema_owner", + "schema-owner-test-password", + ); + const migrate = () => + spawnSync(process.execPath, [runnerPath], { + encoding: "utf8", + env: { + ...process.env, + MIGRATIONS_DIRECTORY: migrationsDirectory, + SCHEMA_DATABASE_URL: schemaUrl, + }, + }); + + try { + const firstRun = migrate(); + assert.equal(firstRun.status, 0, firstRun.stderr); + assert.match( + firstRun.stdout, + /applied 20260721000100_self_hosted_identity\.sql/, + ); + + const secondRun = migrate(); + assert.equal(secondRun.status, 0, secondRun.stderr); + assert.match( + secondRun.stdout, + /already applied 20260721000100_self_hosted_identity\.sql/, + ); + + assert.equal( + fixture.psql(` + select string_agg(tablename, ',' order by tablename) + from pg_tables + where schemaname = 'identity' + `), + "accounts,otp_rate_limits,sessions,users,verifications", + ); + assert.equal( + fixture.psql(` + select string_agg(tablename || ':' || tableowner, ',' order by tablename) + from pg_tables + where schemaname = 'identity' + `), + [ + "accounts:schema_owner", + "otp_rate_limits:schema_owner", + "sessions:schema_owner", + "users:schema_owner", + "verifications:schema_owner", + ].join(","), + ); + + assert.equal( + fixture.psql(` + select data_type || ':' || coalesce(column_default, '') + from information_schema.columns + where table_schema = 'identity' + and table_name = 'users' + and column_name = 'id' + `), + "uuid:gen_random_uuid()", + ); + assert.equal( + fixture.psql(` + select is_nullable || ':' || data_type + from information_schema.columns + where table_schema = 'identity' + and table_name = 'users' + and column_name = 'email_verified' + `), + "NO:boolean", + ); + + for (const table of [ + "users", + "sessions", + "accounts", + "verifications", + "otp_rate_limits", + ]) { + assert.equal( + fixture.psql( + `select has_table_privilege('identity_runtime', 'identity.${table}', 'select,insert,update,delete')`, + ), + "t", + ); + assert.equal( + fixture.psql( + `select has_table_privilege('app_runtime', 'identity.${table}', 'select')`, + ), + "f", + ); + assert.equal( + fixture.psql( + `select has_table_privilege('admin_runtime', 'identity.${table}', 'select')`, + ), + "t", + ); + } + + fixture.psqlAs( + "identity_runtime", + "identity-runtime-test-password", + ` + insert into identity.users (name, email) + values ('Migration User', 'migration@example.com') + `, + ); + const userId = fixture.psql( + "select id from identity.users where email = 'migration@example.com'", + ); + assert.match(userId, /^[0-9a-f-]{36}$/); + + fixture.psqlAs( + "identity_runtime", + "identity-runtime-test-password", + ` + insert into identity.sessions (token, user_id, expires_at) + values ('opaque-session-token', '${userId}', now() + interval '1 hour') + `, + ); + fixture.psqlAs( + "identity_runtime", + "identity-runtime-test-password", + `delete from identity.users where id = '${userId}'`, + ); + assert.equal(fixture.psql("select count(*) from identity.sessions"), "0"); + + fixture.psqlAs( + "identity_runtime", + "identity-runtime-test-password", + "insert into identity.users (name, email) values ('One', 'Case@Example.com')", + ); + assert.throws(() => + fixture.psqlAs( + "identity_runtime", + "identity-runtime-test-password", + "insert into identity.users (name, email) values ('Two', 'case@example.com')", + ), + ); + assert.throws(() => + fixture.psqlAs( + "app_runtime", + "app-runtime-test-password", + "select count(*) from identity.users", + ), + ); + assert.equal( + fixture.psqlAs( + "admin_runtime", + "admin-runtime-test-password", + "select count(*) from identity.users", + ), + "1", + ); + } finally { + fixture.stop(); + } +});