- users + tenant_memberships tables (tenant-scoped)
- RLS policy on tenant_memberships using set_config('app.current_tenant_id', ...)
- withTenantContext() helper for transaction-scoped tenant context
- separate non-superuser app role (b2bcall_app): the default Docker postgres
user is SUPERUSER and always bypasses RLS even with FORCE, so the app must
never connect through the migration/owner role. Documented in
docs/TENANT_ISOLATION.md.
- automated isolation test proving tenant A never sees tenant B's data
60 lines
2.3 KiB
SQL
60 lines
2.3 KiB
SQL
-- CreateEnum
|
|
CREATE TYPE "user_status" AS ENUM ('ACTIVE', 'DISABLED');
|
|
|
|
-- CreateTable
|
|
CREATE TABLE "users" (
|
|
"id" UUID NOT NULL,
|
|
"email" TEXT NOT NULL,
|
|
"password_hash" TEXT NOT NULL,
|
|
"name" TEXT NOT NULL,
|
|
"status" "user_status" NOT NULL DEFAULT 'ACTIVE',
|
|
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
"updated_at" TIMESTAMP(3) NOT NULL,
|
|
"deleted_at" TIMESTAMP(3),
|
|
|
|
CONSTRAINT "users_pkey" PRIMARY KEY ("id")
|
|
);
|
|
|
|
-- CreateTable
|
|
CREATE TABLE "tenant_memberships" (
|
|
"id" UUID NOT NULL,
|
|
"tenant_id" UUID NOT NULL,
|
|
"user_id" UUID NOT NULL,
|
|
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
|
|
CONSTRAINT "tenant_memberships_pkey" PRIMARY KEY ("id")
|
|
);
|
|
|
|
-- CreateIndex
|
|
CREATE UNIQUE INDEX "users_email_key" ON "users"("email");
|
|
|
|
-- CreateIndex
|
|
CREATE INDEX "tenant_memberships_tenant_id_idx" ON "tenant_memberships"("tenant_id");
|
|
|
|
-- CreateIndex
|
|
CREATE UNIQUE INDEX "tenant_memberships_tenant_id_user_id_key" ON "tenant_memberships"("tenant_id", "user_id");
|
|
|
|
-- AddForeignKey
|
|
ALTER TABLE "tenant_memberships" ADD CONSTRAINT "tenant_memberships_tenant_id_fkey" FOREIGN KEY ("tenant_id") REFERENCES "tenants"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
|
|
|
-- AddForeignKey
|
|
ALTER TABLE "tenant_memberships" ADD CONSTRAINT "tenant_memberships_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
|
|
|
-- Row Level Security: tenant_memberships is the first tenant-scoped table.
|
|
-- Every future tenant-scoped table must repeat this pattern.
|
|
--
|
|
-- Convention: the application sets a session-local Postgres setting
|
|
-- `app.current_tenant_id` (via set_config(..., true) inside a transaction,
|
|
-- see packages/database withTenantContext()) before running tenant-scoped
|
|
-- queries. When unset, current_setting(..., true) returns NULL, and the
|
|
-- comparison below evaluates to NULL/false — deny-by-default, no data leaks.
|
|
--
|
|
-- FORCE ROW LEVEL SECURITY makes the policy apply even to the table owner
|
|
-- (the same role used for migrations), so app code cannot accidentally
|
|
-- bypass isolation just because it runs as that role.
|
|
ALTER TABLE "tenant_memberships" ENABLE ROW LEVEL SECURITY;
|
|
ALTER TABLE "tenant_memberships" FORCE ROW LEVEL SECURITY;
|
|
|
|
CREATE POLICY "tenant_isolation" ON "tenant_memberships"
|
|
USING (tenant_id = NULLIF(current_setting('app.current_tenant_id', true), '')::uuid);
|