Всё, что нужно знать о Row Level Security в Postgres | POSETTE 2024
Всё, что нужно знать о Row Level Security в Postgres | POSETTE 2024
Полный перевод, конспект и структурированный разбор материала
Перевод
Привет, я Пол, генеральный директор и соучредитель Supabase. Сегодня я собираюсь рассказать всё, что вам нужно знать о безопасности на уровне строк (Row Level Security, RLS) в Postgres. Я объясню, как использовать Postgres для обеспечения безопасности на уровне строк, почему вы можете захотеть её использовать, а также поделюсь некоторыми хитростями, связанными с производительностью. Давайте займемся этим.
Как я уже упомянул, в этом докладе мы рассмотрим всё, что вам нужно знать о безопасности на уровне строк в Postgres. Внизу экрана есть удобное оглавление, так что если вы смотрите это в видеоформате, вы можете просто перемотать к нужному разделу. Прежде всего, кто я такой? Меня зовут Пол Копплстоун. Я генеральный директор и соучредитель компании Supabase. Что такое Supabase? Мы — хостинг-провайдер Postgres. И если вы пользуетесь нашим сервисом или просто знакомы с нами, вы знаете, что у нас также есть множество инструментов, которые значительно упрощают работу с Postgres. В частности, существует инструмент под названием PostgREST — это очень крутой проект с открытым исходным кодом, который помогает развернуть RESTful API поверх любой базы данных Postgres, автоматически преобразуя схему базы данных в RESTful API. PostgREST очень активно использует возможности Postgres, включая систему ролей и безопасность на уровне строк. Именно по этой причине мы в Supabase детально познакомились с RLS и за последние четыре года накопили ценный опыт. Мы также являемся компанией с открытым исходным кодом, и на данный момент мы разместили и запустили более миллиона баз данных Postgres.
Прежде чем я перейду к основной теме, небольшое предупреждение: в презентации я пишу SQL-запросы в нижнем регистре. В основном это связано с профилем клиентов, на которых мы ориентировались в первые дни работы — они были скорее разработчиками приложений, а не баз данных. Поэтому мы решили использовать строчные буквы, чтобы сделать код более доступным для них. Так что если вы предпочитаете SQL в верхнем регистре, прошу прощения.
Итак, что же такое безопасность на уровне строк? Вы можете думать об этом как о механизме авторизации внутри Postgres. В безопасности существует два ключевых понятия: аутентификация и авторизация. Аутентификация пытается ответить на вопрос: разрешен ли пользователю или роли доступ к самой базе данных? Если доступ разрешен, то в дело вступает авторизация, которая отвечает на вопрос: к чему конкретно этот вошедший пользователь имеет доступ? В случае с Postgres вы можете написать SQL-функцию, которая возвращает истину (true) или ложь (false) для определенных наборов данных, к которым пользователь пытается получить доступ.
Обычно я объясняю это разработчикам (по крайней мере, мне самому это очень помогло на начальном этапе) с помощью следующей ментальной модели: безопасность на уровне строк — это неявное предложение WHERE, которое автоматически добавляется к вашим запросам. Например, предположим, что мы выбираем данные из таблицы профилей, где мне разрешено видеть только свой собственный профиль. Даже если я забыл добавить предложение WHERE в свой запрос, в идеале должна сработать политика безопасности, которая неявно укажет, что мне, пользователю с ID 1234, разрешен доступ только к моей записи. В этом и заключается суть Row Level Security в Postgres. Вы можете прикрепить политику к таблице. У меня есть очень простой пример, который я разберу через секунду, но главное, что вам нужно понимать: политика RLS по сути обеспечивает фильтрацию на основе неявного условия WHERE. Это означает, что если пользователь (в данном случае с user_id 1337) пытается получить доступ к таблице профилей, но у него нет прав на просмотр конкретных строк, политика RLS будет вычислена для этой таблицы, и поскольку условие не вернет true, база данных просто вернет пустой набор результатов.
Зачем вообще использовать безопасность на уровне строк? Для этого есть множество причин. Самая очевидная, конечно, это безопасность. В сфере информационной безопасности существует концепция «глубокоэшелонированной защиты» (defense in depth). Это означает, что вы выстраиваете безопасность на нескольких уровнях: на фронтенде, внутри приложения, на уровне API, в промежуточном ПО (middleware) и вплоть до самой базы данных. Мне очень нравится подход с защитой на уровне базы данных, потому что он повышает уровень безопасности по умолчанию. База данных Postgres находится в самом низу технологического стека, поэтому любое приложение, работающее поверх базы данных, будет по умолчанию подчиняться этой модели безопасности. Мы считаем крайне важным обеспечивать безопасность данных непосредственно на уровне СУБД. Тогда любой инструмент, обращающийся к данным, будет автоматически соблюдать эти правила.
Вторая причина заключается в том, что в некоторых случаях вы можете повысить производительность системы. Обычно в традиционном приложении клиенту приходится делать два сетевых запроса к двум разным службам. Сначала приложение отправляет запрос в службу аутентификации, чтобы проверить, разрешен ли пользователю вход и какие у него права. Затем оно делает второй запрос к базе данных, чтобы получить сами данные. Если же вы объедините эти процессы в единую парадигму внутри базы данных, вы фактически избавитесь от одного целого сетевого запроса. При таком подходе всё работает очень быстро.
Еще одна причина, которая, вероятно, найдет отклик у многих слушателей, заключается в том, что вы можете быть своего рода «Postgres-максималистом». В Supabase мы придерживаемся рабочего процесса разработки, ориентированного на базы данных (database-centric development). Мы рекомендуем использовать Postgres именно так. Честно говоря, это подходит не всем, но для тех, кто действительно любит SQL и возможности PostgreSQL, использование безопасности на уровне строк — это очень удобный и элегантный способ создания приложений.
Хорошо, давайте разберем все тонкости практического использования RLS. На самом деле всё довольно просто. Предположим, у меня есть таблица под названием profiles. Чтобы включить безопасность на уровне строк для этой таблицы, мне нужно выполнить команду: ALTER TABLE profiles ENABLE ROW LEVEL SECURITY;. Это означает, что любому, кто попытается получить доступ к таблице, будет отказано в доступе, если только у него нет политики, разрешающей доступ, или если он не является суперпользователем или владельцем таблицы. По этой причине я рекомендую включать RLS для большинства таблиц в любом случае — это повышает базовый уровень безопасности при создании новых ролей. Даже если вы не планируете сразу писать сложные политики RLS, стоит включить её для всех таблиц просто как превентивную меру.
Давайте разберем анатомию реальной политики безопасности. Прежде всего, мы вызываем команду CREATE POLICY. Затем мы даем политике имя. Мне нравится использовать короткие описательные имена в кавычках. Вы также можете использовать snake_case, как это принято для объектов Postgres. Если вы используете длинные имена, помните, что они должны быть относительно короткими, иначе Postgres их просто усечет. Далее вы привязываете политику к конкретной таблице, указывая имя таблицы и схему. Политика может быть либо разрешающей (permissive), либо ограничивающей (restrictive). Я расскажу об этом чуть позже, но по умолчанию все политики являются разрешающими. Затем вы указываете операцию, к которой применяется политика: SELECT, INSERT, UPDATE, DELETE или ALL. Я не рекомендую использовать ALL (хотя это значение применяется по умолчанию, если операция не указана). Настоятельно рекомендую создавать отдельную политику для каждой конкретной операции, и вскоре я объясню почему. Далее вы можете привязать политику к определенной роли Postgres. Я рекомендую делать это для более тонкой настройки и иногда для повышения производительности. Это необязательно, но мне нравится иметь очень детализированные политики. Наконец, вы указываете само условие проверки внутри предложений USING или WITH CHECK. Я покажу пример, где используются оба варианта, чтобы объяснить разницу подробнее. Если говорить в целом, то USING проверяет существующие значения, которые уже находятся в базе данных (то есть то, что мы пытаемся прочитать или обновить). А WITH CHECK проверяет новые входящие значения, что актуально для операций INSERT и UPDATE (то есть мы проверяем, разрешено ли пользователю записать именно такие данные).
Давайте сначала сосредоточимся на предложении USING для операции SELECT. Здесь в качестве условия я вызываю функцию auth.uid(). Она существует внутри Supabase и используется очень часто, возвращая ID текущего пользователя. Затем я сравниваю полученное значение со значением в столбце user_id таблицы profiles. Как это работает на простом примере? Условие политики всегда должно возвращать true или false. Если возвращается true, доступ разрешен, если false — нет.
Представим, что у нас есть таблица профилей с тремя строками, где значения user_id равны 1, 2 и 3. Мой текущий user_id равен 3. Когда я выполняю запрос SELECT, база данных вычисляет условие политики для каждой строки. Для первой строки: равен ли мой ID (3) значению 1? Нет (false). Для второй строки: равен ли 3 значению 2? Нет (false). Для третьей строки: равен ли 3 значению 3? Да (true). В результате база данных позволит мне выбрать и увидеть только третью строку.
Теперь, как я и обещал, давайте посмотрим на совместное использование USING и WITH CHECK. Это стандартный сценарий для операции UPDATE. Во-первых, мы используем предложение USING, чтобы проверить, имеет ли право пользователь вообще обновлять эту конкретную строку (например, принадлежит ли она ему). Во-вторых, мы используем WITH CHECK, чтобы убедиться, что новое значение, которое пользователь пытается записать, также соответствует правилам (например, что он не пытается изменить user_id в этой строке на чужой). Обычно именно так строятся надежные политики безопасности. Они получаются очень детализированными и требуют написания схожего кода для разных операций.
Важно знать, что вы можете применить несколько политик к одной таблице. Например, у вас может быть одна политика для SELECT, которая запрещает доступ, возвращая false, и другая, которая разрешает доступ, возвращая true. Как они будут объединяться — через «И» (AND) или через «ИЛИ (OR)? По умолчанию разрешающие политики объединяются через OR. То есть, если хотя бы одна политика разрешает доступ, он будет предоставлен. Однако здесь есть нюанс: если у вас настроены ограничивающие (restrictive) политики, они будут объединяться с разрешающими через оператор AND`. Это может сильно усложнить логику. Поэтому моя главная рекомендация по работе с RLS: старайтесь делать политики как можно более простыми. Создавайте одну политику на одну операцию для каждой роли и прописывайте все условия внутри этой одной политики. Для Postgres это не имеет принципиальной разницы с точки зрения выполнения, но это очень помогает вашей ментальной модели безопасности — вы всегда точно понимаете логику работы правил для каждого конкретного случая.
Теперь давайте разберем несколько важных правил («что делать» и «чего не делать») при работе с RLS в Postgres.
Во-первых, за годы работы с Supabase мы поняли: если вы вызываете функцию внутри политики RLS, её всегда следует оборачивать в подзапрос SELECT. Обратите внимание на скобки в конструкции (SELECT auth.uid()). Дело в том, что если написать просто auth.uid(), эта функция будет вычисляться заново для каждой отдельной строки таблицы. Если же обернуть её в SELECT, планировщик Postgres вычислит это значение один раз в самом начале и будет использовать его как константу для всего запроса. Это критически важно для производительности.
Во-вторых, как я уже упоминал, избегайте использования ключевого слова ALL по умолчанию. Делайте политики максимально детализированными: пишите отдельные правила для SELECT, INSERT, UPDATE и DELETE.
В-третьих, поговорим о функциях, подобных auth.uid(), которая в Supabase извлекает ID пользователя из JWT-токена. Старайтесь объявлять такие функции как STABLE (или IMMUTABLE, если это возможно), чтобы планировщик Postgres понимал их поведение. Если функция не помечена как стабильная, Postgres будет вынужден выполнять её для каждой строки таблицы, что приведет к огромным задержкам при больших объемах данных. Объявление функции как STABLE позволяет вычислить её один раз при старте запроса.
Также я рекомендую размещать подобные функции безопасности в отдельной приватной схеме базы данных и создавать их с использованием конструкции SECURITY DEFINER. Если функция создана как SECURITY DEFINER, она выполняется с правами создателя этой функции. Если же использовать SECURITY INVOKER (поведение по умолчанию), то функция будет выполняться с правами пользователя, вызвавшего запрос. Если у этого пользователя нет прав на обход RLS для таблиц, к которым обращается функция (например, таблица связей или разрешений), то правила RLS начнут применяться рекурсивно внутри самой функции, что усложнит логику и снизит скорость работы. Используя SECURITY DEFINER от имени роли, обходящей RLS (например, суперпользователя), вы можете быстро получить нужные данные внутри функции, а затем использовать результат для вычисления true или false в самой политике.
Следующий важный момент. Хотя я и сказал, что RLS можно представить как неявное предложение WHERE, в целях производительности вы всегда должны явно писать предложение WHERE в своих клиентских запросах. Поначалу кажется очень удобным просто писать SELECT * FROM table и позволять RLS автоматически фильтровать данные. Но на практике явный фильтр WHERE в запросе позволяет планировщику Postgres отсечь ненужные данные с помощью индексов еще до того, как начнется оценка политик RLS для каждой строки. Это работает в разы быстрее.
И наконец, Postgres теперь поддерживает RLS в представлениях (Views). Это работает через параметр security_invoker. Вы можете создавать представления (начиная с Postgres 15) с параметром security_invoker = true. По умолчанию (когда security_invoker равен false или используется SECURITY DEFINER) представление работает с правами его создателя. Если же установить security_invoker = true, то при обращении к представлению будут применяться политики RLS той роли, которая выполняет запрос в данный момент. То есть, если ваше представление ссылается на приватную таблицу private.profiles, к нему будут применены те же правила RLS, которые мы настроили для таблицы профилей. Я рекомендую использовать этот параметр практически всегда при создании представлений в современных версиях Postgres.
Существует еще огромное количество тонкостей и оптимизаций. Мы подробно описали их в документации Supabase, приложив результаты бенчмарков для каждого из упомянутых подходов. Каждая из этих рекомендаций способна дать существенный прирост производительности вашей базы данных.
В завершение хочу выразить благодарность нашей команде. Большая часть этого материала была подготовлена Стивом, который является мейнтейнером PostgREST и работает в Supabase, а также Гэри, который провел огромную работу по тестированию производительности RLS, и всему нашему замечательному сообществу, которое активно помогает развивать эти технологии. Если вам нужна надежная база данных Postgres, обязательно загляните к нам — вы можете мгновенно запустить новую базу данных с помощью сервиса database.new. Большое спасибо!