
SaaS выгоднее, когда вы храните и обрабатываете данные многих клиентов на одной платформе. Простой подход — отдельная база на клиента — быстро становится дорогим: больше инфраструктуры, бэкапов, мониторинга и ручных операций. Правильно реализованная мультиарендность (multi-tenant) даёт:
Ключ — убрать риск «перемешивания» данных между клиентами. Эту гарантию в PostgreSQL обеспечивает Row Level Security (RLS) — политики на уровне строк.
Ниже — минимальный, но полноценный каркас мультиарендности с RLS.
-- Подготовка служебной схемы и функций
CREATE SCHEMA IF NOT EXISTS app;
-- Функция, читающая текущего арендатора из параметра сессии
CREATE OR REPLACE FUNCTION app.current_tenant() RETURNS uuid
LANGUAGE sql STABLE AS $$
SELECT current_setting('app.tenant_id', true)::uuid
$$;
-- Триггер, который проставляет tenant_id при INSERT
CREATE OR REPLACE FUNCTION app.set_tenant() RETURNS trigger
LANGUAGE plpgsql AS $$
BEGIN
IF NEW.tenant_id IS NULL THEN
NEW.tenant_id := app.current_tenant();
END IF;
RETURN NEW;
END;
$$;
-- Роли приложения
CREATE ROLE app_user NOINHERIT LOGIN;
CREATE ROLE app_admin NOINHERIT LOGIN; -- для админских задач без BYPASSRLS
-- Никогда не давайте вашим ролям BYPASSRLS: это обходит RLS!
-- Проверить: SELECT rolbypassrls FROM pg_roles WHERE rolname = 'app_user';
CREATE EXTENSION IF NOT EXISTS pgcrypto; -- для gen_random_uuid()
CREATE TABLE public.customers (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id uuid NOT NULL,
name text NOT NULL,
created_at timestamptz NOT NULL DEFAULT now()
);
CREATE TABLE public.invoices (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id uuid NOT NULL,
customer_id uuid NOT NULL,
number text NOT NULL, -- номер счёта в рамках арендатора
amount_cents bigint NOT NULL CHECK (amount_cents >= 0),
currency text NOT NULL CHECK (char_length(currency) = 3),
issued_at date NOT NULL,
created_at timestamptz NOT NULL DEFAULT now(),
UNIQUE (tenant_id, number), -- уникальность в пределах арендатора
FOREIGN KEY (customer_id, tenant_id) REFERENCES public.customers(id, tenant_id)
);
-- Для внешнего ключа добавим составной индекс у customers
CREATE UNIQUE INDEX customers_pk ON public.customers(id, tenant_id);
-- Ключевые индексы по tenant_id для RLS-предикатов
CREATE INDEX customers_tenant_idx ON public.customers(tenant_id);
CREATE INDEX invoices_tenant_idx ON public.invoices(tenant_id);
CREATE INDEX invoices_tenant_number_idx ON public.invoices(tenant_id, number);
-- Триггеры для авто-проставления tenant_id
CREATE TRIGGER customers_set_tenant
BEFORE INSERT ON public.customers
FOR EACH ROW EXECUTE FUNCTION app.set_tenant();
CREATE TRIGGER invoices_set_tenant
BEFORE INSERT ON public.invoices
FOR EACH ROW EXECUTE FUNCTION app.set_tenant();
Важно: все внешние ключи и уникальные ограничения, которые «логически» зависят от арендатора, делайте составными: включайте tenant_id. Так вы избежите ложной коллизии значений у разных клиентов.
ALTER TABLE public.customers ENABLE ROW LEVEL SECURITY;
ALTER TABLE public.invoices ENABLE ROW LEVEL SECURITY;
-- Базовая политика: видеть и изменять только строки своего арендатора
CREATE POLICY customers_tenant_isolation ON public.customers
USING (tenant_id = app.current_tenant())
WITH CHECK (tenant_id = app.current_tenant());
CREATE POLICY invoices_tenant_isolation ON public.invoices
USING (tenant_id = app.current_tenant())
WITH CHECK (tenant_id = app.current_tenant());
-- Минимальные привилегии для приложения
GRANT USAGE ON SCHEMA public, app TO app_user;
GRANT SELECT, INSERT, UPDATE, DELETE ON public.customers, public.invoices TO app_user;
RLS добавляет в каждый запрос предикат tenant_id = app.current_tenant(). Планировщик сможет опираться на индексы, если предикат простой и детерминированный (current_setting — STABLE, это хорошо для планов).
Приложение обязано установить параметр сессии app.tenant_id на начало транзакции. Если используете пул в режиме «пул транзакций» (например, PgBouncer), делайте SET LOCAL в каждой транзакции.
-- В транзакции приложения
BEGIN;
SET LOCAL app.tenant_id = '8f43f1c9-5b34-4b0a-94c3-5d73a3b63f6d';
-- любой запрос теперь автоматически фильтруется политиками RLS
SELECT * FROM public.invoices ORDER BY issued_at DESC LIMIT 20;
COMMIT;
Никогда не полагайтесь на «дефолтного арендатора»: отсутствие app.tenant_id должно приводить к пустым результатам и ошибкам вставки. Добейтесь этого через NOT NULL на tenant_id и политики RLS.
CREATE TABLE public.tenants (
id uuid PRIMARY KEY,
name text NOT NULL,
created_at timestamptz NOT NULL DEFAULT now()
);
Есть два надёжных пути:
CREATE ROLE tenant_exporter NOINHERIT LOGIN;
GRANT CONNECT ON DATABASE yourdb TO tenant_exporter;
GRANT USAGE ON SCHEMA public, app TO tenant_exporter;
GRANT SELECT ON ALL TABLES IN SCHEMA public TO tenant_exporter;
ALTER ROLE tenant_exporter SET row_security = on;
ALTER ROLE tenant_exporter SET app.tenant_id = '8f43f1c9-5b34-4b0a-94c3-5d73a3b63f6d';
# Выгрузка только строк арендатора благодаря RLS
pg_dump -h db -U tenant_exporter -d yourdb -Fc -Z6 -f tenant.dump
# Восстановление в «песочницу»
pg_restore -h db -U app_admin -d sandbox -c tenant.dump
psql "$DSN" -c "COPY (SELECT * FROM public.invoices WHERE tenant_id = '8f43f1c9-5b34-4b0a-94c3-5d73a3b63f6d') TO STDOUT WITH CSV" > invoices.csv
Первый путь удобнее: меньше ручной работы и не забываете связанные таблицы — RLS сам всё отфильтрует.
ALTER TABLE public.customers ADD CONSTRAINT customers_tenant_fk
FOREIGN KEY (tenant_id) REFERENCES public.tenants(id) ON DELETE CASCADE;
ALTER TABLE public.invoices ADD CONSTRAINT invoices_tenant_fk
FOREIGN KEY (tenant_id) REFERENCES public.tenants(id) ON DELETE CASCADE;
BEGIN;
DELETE FROM public.tenants WHERE id = '8f43f1c9-5b34-4b0a-94c3-5d73a3b63f6d';
COMMIT;
ALTER SYSTEM SET log_line_prefix = '%m [%p] user=%u db=%d app=%a tenant=%v ';
-- Перед запросами: SET application_name и SET LOCAL app.tenant_id
-- Пример идеи теста (упрощённо)
SET LOCAL app.tenant_id = 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa';
SELECT COUNT(*) FROM public.invoices \gset
\echo :count -- должно быть 0 для чужих данных
Ниже — рабочий пример с node-postgres. Он показывает, как устанавливать tenant_id на транзакцию и не забыть про пул соединений.
// package.json
// {
// "type": "module",
// "dependencies": { "pg": "^8.11.3", "express": "^4.19.2" }
// }
import express from 'express';
import { Pool } from 'pg';
const pool = new Pool({ connectionString: process.env.DATABASE_URL });
const app = express();
app.use(express.json());
async function withTenant(tenantId, fn) {
const client = await pool.connect();
try {
await client.query('BEGIN');
// Важно: SET LOCAL — параметр действует только в пределах транзакции
await client.query('SET LOCAL app.tenant_id = $1', [tenantId]);
const result = await fn(client);
await client.query('COMMIT');
return result;
} catch (e) {
await client.query('ROLLBACK');
throw e;
} finally {
client.release();
}
}
// Пример ручки: создать счёт и вернуть его
app.post('/api/:tenantId/invoices', async (req, res) => {
const tenantId = req.params.tenantId;
const { customer_id, number, amount_cents, currency, issued_at } = req.body;
try {
const row = await withTenant(tenantId, async (db) => {
const ins = await db.query(
`INSERT INTO public.invoices (customer_id, number, amount_cents, currency, issued_at)
VALUES ($1, $2, $3, $4, $5)
RETURNING id, tenant_id, customer_id, number, amount_cents, currency, issued_at, created_at`,
[customer_id, number, amount_cents, currency, issued_at]
);
return ins.rows[0];
});
res.status(201).json(row);
} catch (e) {
if (e.code === '23505') { // unique_violation
return res.status(409).json({ error: 'Номер счёта уже существует' });
}
console.error(e);
res.status(500).json({ error: 'Внутренняя ошибка' });
}
});
// Пример ручки: пагинация по дате в пределах арендатора
app.get('/api/:tenantId/invoices', async (req, res) => {
const tenantId = req.params.tenantId;
const { limit = 50, before } = req.query;
const lim = Math.min(parseInt(limit, 10) || 50, 200);
try {
const rows = await withTenant(tenantId, async (db) => {
if (before) {
const { rows } = await db.query(
`SELECT id, tenant_id, customer_id, number, amount_cents, currency, issued_at, created_at
FROM public.invoices
WHERE issued_at < $1
ORDER BY issued_at DESC
LIMIT $2`,
[before, lim]
);
return rows;
} else {
const { rows } = await db.query(
`SELECT id, tenant_id, customer_id, number, amount_cents, currency, issued_at, created_at
FROM public.invoices
ORDER BY issued_at DESC
LIMIT $1`,
[lim]
);
return rows;
}
});
res.json({ items: rows });
} catch (e) {
console.error(e);
res.status(500).json({ error: 'Внутренняя ошибка' });
}
});
const port = process.env.PORT || 3000;
app.listen(port, () => console.log(`Listening on ${port}`));
Заметьте: мы никогда не передаём tenant_id в запросах к БД напрямую (кроме SET LOCAL). Политики RLS сами ограничат видимость и изменение строк.
Итого: мультиарендность на PostgreSQL с RLS — это не «хитрость ради хитрости». Это способ получить экономию и управляемость без компромиссов по безопасности. При должной дисциплине в схемах, индексах и ролях вы получите изоляцию, не размножая кластеры, и сможете легко масштабировать продукт вместе с ростом числа клиентов.