Продвинутое управление доступом на основе ролей в PostgreSQL: Row-Level Security и управление политиками
Введение
Безопасность на уровне строк в PostgreSQL (RLS) обеспечивает гибкое управление доступом на уровне каждой записи, позволяя применять сложные правила авторизации непосредственно в базе данных. Это избавляет от необходимости фильтрации на уровне приложения и гарантирует защиту данных независимо от того, как приложения обращаются к базе данных.
Почему Row-Level Security?
Проблемы традиционного подхода
Фильтрация на уровне приложения:
# В каждом запросе нужно не забыть добавить фильтр
query = "SELECT * FROM documents WHERE tenant_id = %s"
results = db.execute(query, [current_user_tenant_id])
Проблемы:
- Легко забыть о фильтрах (уязвимости безопасности)
- Дублирование кода по всему приложению
- Отсутствие защиты при прямом доступе к базе данных
- Сложные требования к аудиту
Преимущества Row-Level Security
- Автоматическое применение: Политики применяются прозрачно
- Централизованные правила: Логика безопасности в одном месте
- Изоляция мультитенантности: Идеально для SaaS-приложений
- Соответствие аудиту: Встроенные гарантии безопасности
- Производительность: Эффективное планирование запросов
Базовая настройка RLS
Шаг 1: Включение RLS для таблицы
CREATE TABLE documents (
id SERIAL PRIMARY KEY,
title VARCHAR(200),
content TEXT,
owner_id INTEGER NOT NULL,
department VARCHAR(50),
is_public BOOLEAN DEFAULT false,
created_at TIMESTAMP DEFAULT NOW()
);
-- Включение безопасности на уровне строк
ALTER TABLE documents ENABLE ROW LEVEL SECURITY;
Шаг 2: Создание ролей
-- Создание различных ролей пользователей
CREATE ROLE regular_user;
CREATE ROLE department_manager;
CREATE ROLE admin;
-- Предоставление доступа к таблице
GRANT SELECT, INSERT, UPDATE, DELETE ON documents TO regular_user;
GRANT SELECT, INSERT, UPDATE, DELETE ON documents TO department_manager;
GRANT ALL ON documents TO admin;
Шаг 3: Создание политик
-- Политика: Пользователи могут видеть свои собственные документы
CREATE POLICY user_own_documents ON documents
FOR SELECT
TO regular_user
USING (owner_id = current_setting('app.user_id')::INTEGER);
-- Политика: Пользователи могут вставлять документы, принадлежащие им самим
CREATE POLICY user_insert_own ON documents
FOR INSERT
TO regular_user
WITH CHECK (owner_id = current_setting('app.user_id')::INTEGER);
-- Политика: Пользователи могут обновлять свои собственные документы
CREATE POLICY user_update_own ON documents
FOR UPDATE
TO regular_user
USING (owner_id = current_setting('app.user_id')::INTEGER)
WITH CHECK (owner_id = current_setting('app.user_id')::INTEGER);
Шаг 4: Установка переменных сессии
В коде приложения:
import psycopg2
# Подключение под ролью
conn = psycopg2.connect(
host="localhost",
database="mydb",
user="regular_user",
password="password"
)
# Установка контекста пользователя
cursor = conn.cursor()
cursor.execute("SET app.user_id = %s", [current_user_id])
# Теперь запросы фильтруются автоматически
cursor.execute("SELECT * FROM documents")
# Возвращаются только документы, где owner_id = current_user_id
Мультитенантное SaaS-приложение
Полная настройка мультитенантности
-- Создание таблицы арендаторов (tenants)
CREATE TABLE tenants (
id SERIAL PRIMARY KEY,
name VARCHAR(100) NOT NULL,
created_at TIMESTAMP DEFAULT NOW()
);
-- Создание таблицы пользователей
CREATE TABLE users (
id SERIAL PRIMARY KEY,
username VARCHAR(50) NOT NULL,
tenant_id INTEGER REFERENCES tenants(id),
role VARCHAR(20) NOT NULL,
created_at TIMESTAMP DEFAULT NOW()
);
-- Создание таблицы данных приложения
CREATE TABLE orders (
id SERIAL PRIMARY KEY,
tenant_id INTEGER NOT NULL REFERENCES tenants(id),
customer_name VARCHAR(100),
amount DECIMAL(10,2),
status VARCHAR(20),
created_by INTEGER REFERENCES users(id),
created_at TIMESTAMP DEFAULT NOW()
);
-- Включение RLS
ALTER TABLE orders ENABLE ROW LEVEL SECURITY;
-- Политика: Пользователи видят только заказы своего тенанта
CREATE POLICY tenant_isolation ON orders
FOR ALL
TO public
USING (tenant_id = current_setting('app.tenant_id')::INTEGER)
WITH CHECK (tenant_id = current_setting('app.tenant_id')::INTEGER);
Интеграция с приложением
Пример на Django:
from django.db import connection
class TenantMiddleware:
def __init__(self, get_response):
self.get_response = get_response
def __call__(self, request):
if request.user.is_authenticated:
with connection.cursor() as cursor:
cursor.execute(
"SET app.tenant_id = %s",
[request.user.tenant_id]
)
response = self.get_response(request)
return response
Пример на Node.js:
const { Pool } = require('pg');
const pool = new Pool();
async function setTenantContext(tenantId) {
const client = await pool.connect();
try {
await client.query('SET app.tenant_id = $1', [tenantId]);
return client;
} catch (err) {
client.release();
throw err;
}
}
// Использование
app.use(async (req, res, next) => {
if (req.user) {
req.dbClient = await setTenantContext(req.user.tenantId);
}
next();
});
Продвинутые шаблоны политик
1. Иерархический контроль доступа
-- Пользователи могут видеть документы своего отдела и публичные документы
CREATE POLICY department_access ON documents
FOR SELECT
TO regular_user
USING (
department = current_setting('app.user_department', true)
OR is_public = true
OR owner_id = current_setting('app.user_id')::INTEGER
);
2. Доступ на основе времени
CREATE TABLE subscriptions (
id SERIAL PRIMARY KEY,
user_id INTEGER NOT NULL,
content TEXT,
valid_from TIMESTAMP NOT NULL,
valid_until TIMESTAMP NOT NULL
);
ALTER TABLE subscriptions ENABLE ROW LEVEL SECURITY;
-- Политика: Пользователи могут видеть только активные подписки
CREATE POLICY active_subscriptions ON subscriptions
FOR SELECT
TO public
USING (
user_id = current_setting('app.user_id')::INTEGER
AND NOW() BETWEEN valid_from AND valid_until
);
3. Доступ на основе ролей со сложной логикой
CREATE TABLE projects (
id SERIAL PRIMARY KEY,
name VARCHAR(100),
owner_id INTEGER,
team_id INTEGER,
visibility VARCHAR(20) -- 'private', 'team', 'public'
);
ALTER TABLE projects ENABLE ROW LEVEL SECURITY;
-- Сложная политика видимости
CREATE POLICY project_visibility ON projects
FOR SELECT
TO public
USING (
CASE current_setting('app.user_role', true)
WHEN 'admin' THEN true
WHEN 'manager' THEN (
team_id = current_setting('app.user_team_id')::INTEGER
OR visibility = 'public'
)
ELSE (
owner_id = current_setting('app.user_id')::INTEGER
OR visibility = 'public'
)
END
);
4. Списки общего доступа (Shared Access Lists)
CREATE TABLE documents (
id SERIAL PRIMARY KEY,
title VARCHAR(200),
owner_id INTEGER NOT NULL
);
CREATE TABLE document_shares (
document_id INTEGER REFERENCES documents(id),
shared_with_user_id INTEGER,
permission VARCHAR(20), -- 'read', 'write'
PRIMARY KEY (document_id, shared_with_user_id)
);
ALTER TABLE documents ENABLE ROW LEVEL SECURITY;
-- Политика: Пользователи видят собственные документы или те, к которым им дали доступ
CREATE POLICY document_access ON documents
FOR SELECT
TO public
USING (
owner_id = current_setting('app.user_id')::INTEGER
OR id IN (
SELECT document_id
FROM document_shares
WHERE shared_with_user_id = current_setting('app.user_id')::INTEGER
)
);
-- Политика: Пользователи могут изменять свои документы или те, для которых дано право на запись
CREATE POLICY document_modify ON documents
FOR UPDATE
TO public
USING (
owner_id = current_setting('app.user_id')::INTEGER
OR id IN (
SELECT document_id
FROM document_shares
WHERE shared_with_user_id = current_setting('app.user_id')::INTEGER
AND permission = 'write'
)
);
Обход RLS для административных задач
Вариант 1: Атрибут роли BYPASSRLS
-- Предоставление доступа, аналогичного суперпользователю
CREATE ROLE admin_user WITH LOGIN PASSWORD 'secure_password';
ALTER ROLE admin_user BYPASSRLS;
-- Админ видит все строки независимо от политик
Вариант 2: Разрешающие политики для админов
-- Создание разрешающей политики для роли admin
CREATE POLICY admin_all_access ON documents
FOR ALL
TO admin
USING (true)
WITH CHECK (true);
Вариант 3: Временное отключение RLS
-- Для скриптов обслуживания
BEGIN;
ALTER TABLE documents DISABLE ROW LEVEL SECURITY;
-- Выполнение административных операций
UPDATE documents SET archived = true WHERE created_at < NOW() - INTERVAL '1 year';
ALTER TABLE documents ENABLE ROW LEVEL SECURITY;
COMMIT;
Типы политик: USING vs WITH CHECK
Предложение USING
Управляет тем, какие существующие строки видимы/доступны для изменения:
CREATE POLICY select_own ON documents
FOR SELECT
USING (owner_id = current_user_id());
-- Пользователь может выбирать (SELECT) только те строки, где owner_id совпадает
Предложение WITH CHECK
Управляет тем, какие новые или обновленные строки разрешены:
CREATE POLICY insert_own ON documents
FOR INSERT
WITH CHECK (owner_id = current_user_id());
-- Пользователь может вставлять (INSERT) только строки, где owner_id совпадает
Комбинированные политики
CREATE POLICY update_own ON documents
FOR UPDATE
USING (owner_id = current_user_id()) -- Можно обновлять только свои строки
WITH CHECK (owner_id = current_user_id()); -- Нельзя сменить владельца
Рекомендации по производительности
1. Индексация столбцов, используемых в политиках
-- Создание индексов для столбцов, используемых в политиках
CREATE INDEX idx_documents_owner ON documents(owner_id);
CREATE INDEX idx_documents_tenant ON documents(tenant_id);
CREATE INDEX idx_documents_department ON documents(department);
2. Анализ планов запросов
EXPLAIN ANALYZE
SELECT * FROM documents
WHERE title LIKE '%%report%%';
-- Проверьте, эффективно ли применяется фильтр политики
3. Избегайте подзапросов в политиках
Плохо — подзапрос выполняется для каждой строки:
CREATE POLICY slow_policy ON documents
USING (
id IN (SELECT document_id FROM shares WHERE user_id = current_user_id())
);
Лучше — используйте EXISTS:
CREATE POLICY faster_policy ON documents
USING (
EXISTS (
SELECT 1 FROM shares
WHERE document_id = documents.id
AND user_id = current_user_id()
)
);
Лучший вариант — использование JOIN в приложении или материализованного представления:
CREATE MATERIALIZED VIEW user_accessible_documents AS
SELECT d.*, s.user_id AS shared_with
FROM documents d
LEFT JOIN shares s ON d.id = s.document_id;
CREATE INDEX ON user_accessible_documents(shared_with);
Мониторинг и аудит
Просмотр активных политик
-- Список всех политик RLS
SELECT
schemaname,
tablename,
policyname,
permissive,
roles,
cmd,
qual,
with_check
FROM pg_policies
ORDER BY tablename, policyname;
Аудит изменений политик
-- Создание лога аудита для изменений политик
CREATE TABLE policy_audit_log (
id SERIAL PRIMARY KEY,
table_name VARCHAR(100),
policy_name VARCHAR(100),
action VARCHAR(20),
changed_by VARCHAR(50),
changed_at TIMESTAMP DEFAULT NOW()
);
-- Функция триггера для логирования изменений
CREATE OR REPLACE FUNCTION log_policy_changes()
RETURNS event_trigger AS $$
BEGIN
INSERT INTO policy_audit_log (table_name, policy_name, action, changed_by)
SELECT
objid::regclass::text,
object_identity,
'CREATED',
current_user
FROM pg_event_trigger_ddl_commands()
WHERE command_tag = 'CREATE POLICY';
END;
$$ LANGUAGE plpgsql;
CREATE EVENT TRIGGER log_policy_create
ON ddl_command_end
WHEN TAG IN ('CREATE POLICY')
EXECUTE FUNCTION log_policy_changes();
Тестирование эффективности политики
-- Создание тестовой функции
CREATE OR REPLACE FUNCTION test_user_access(
test_user_id INTEGER,
test_tenant_id INTEGER
)
RETURNS TABLE(document_id INTEGER, title VARCHAR) AS $$
BEGIN
-- Установка переменных сессии
PERFORM set_config('app.user_id', test_user_id::text, true);
PERFORM set_config('app.tenant_id', test_tenant_id::text, true);
-- Возврат доступных документов
RETURN QUERY SELECT id, documents.title FROM documents;
END;
$$ LANGUAGE plpgsql SECURITY DEFINER;
-- Тестирование доступа
SELECT * FROM test_user_access(123, 5);
Устранение неполадок
Проблема 1: Политики не применяются
Проверьте, включен ли RLS:
SELECT tablename, rowsecurity
FROM pg_tables
WHERE schemaname = 'public' AND tablename = 'documents';
Включите, если отключен:
ALTER TABLE documents ENABLE ROW LEVEL SECURITY;
Проблема 2: Переменные сессии не установлены
Отладка текущих настроек:
SHOW app.user_id;
SHOW app.tenant_id;
-- Или используйте current_setting
SELECT current_setting('app.user_id', true);
Проблема 3: Падение производительности
Выявление медленных политик:
EXPLAIN (ANALYZE, BUFFERS)
SELECT * FROM documents WHERE title = 'test';
-- Ищите операции "Filter" с условиями политики
Решение: Упростите политики или добавьте индексы
Проблема 4: Неожиданный отказ в доступе
Тестирование от имени конкретного пользователя:
SET ROLE regular_user;
SET app.user_id = '123';
SELECT * FROM documents;
-- Показывает, что пользователь может видеть на самом деле
RESET ROLE;
Лучшие практики
- Всегда устанавливайте переменные сессии: Убедитесь, что значения
current_setting()заполнены. - Индексируйте столбцы политик: Создавайте индексы для всех столбцов, используемых в USING/WITH CHECK.
- Тщательно тестируйте: Проверяйте политики с разными ролями и сценариями.
- Делайте политики простыми: Сложная логика влияет на производительность.
- Используйте EXPLAIN: Анализируйте планы запросов с включенным RLS.
- Документируйте политики: Комментируйте логику политики для удобства сопровождения.
- Разделяйте обязанности: Используйте разные политики для SELECT, INSERT, UPDATE, DELETE.
- Мониторьте производительность: Настройте оповещения о медленных запросах.
Заключение
Row-Level Security в PostgreSQL обеспечивает надежный контроль доступа на уровне базы данных:
- Автоматическая фильтрация: Безопасность применяется прозрачно.
- Изоляция мультитенантности: Идеально подходит для SaaS-приложений.
- Гибкие политики: Поддержка сложных правил авторизации.
- Централизованная безопасность: Логика в базе данных, а не в разрозненном коде.
Начните с простой изоляции тенантов, протестируйте влияние на производительность, а затем расширяйте систему до более сложных шаблонов авторизации по мере необходимости.