Содержание
Есть разные способы показать пользователю только те данные, которые ему нужны. Разграничение прав доступа (Row level security) — один из самых универсальных, простых и надежных.
Еще по теме: Как проверить сайт на уязвимости
Прочитав эту статью, ты поймешь, что это несложно, и научишься организовывать разграничение доступа к записям средствами самой базы данных без особого ущерба для производительности.
Разграничение прав доступа в базе данных
Механизм row level security позволяет реализовать разграничение доступа к данным средствами базы данных прозрачно для работающих с ней приложений. Даже если злоумышленник получил прямой доступ к базе, например под учетной записью владельца схемы с данными, RLS может не дать ему увидеть защищенную информацию. Политики RLS позволяют убирать строки из выборки целиком или скрывать значения столбцов для строк, к которым пользователь не имеет доступа. В этом отличие от обычного управления правами в БД, которые можно выдать только на объект целиком.
Как это работает? При выполнении любого запроса к базе планировщик проверяет, есть ли для этих таблиц политики доступа. Если есть, он вычисляет на основе каждой политики дополнительный предикат, который добавляет к запросу. Предикаты могут быть любой сложности. Например, вот такими:
1 |
and (512 in (select id_user from v_admin_users where id_department = 255) or 512 in (select id_user from v_bypass_rls)) |
Плюс в том, что предикаты работают для любых запросов, в том числе сделанных через инструменты администрирования (SQL Developer, Toad, PgAdmin и так далее) и даже при экспорте дампов. Это единый механизм управления доступом для всех приложений на уровне ядра СУБД. Почему он не так часто используется на практике? Вот несколько причин.
- Людей, которые умеют работать с базами на достаточном уровне, много меньше, чем обычных программистов. Часто проще и дешевле реализовать механизмы контроля доступа в слое приложения.
- Прозрачность. Если RLS выключен, это может обнаружиться не сразу. Приложения продолжат работать нормально, но будут выдавать данных больше, чем нужно. Само по себе это не страшно, но при плохих процессах и в сочетании с предыдущим пунктом чревато проблемами.
- Дополнительный расход ресурсов на выполнение запроса. Обычно этот фактор не играет решающей роли. Если RLS действительно нужен, его включают и принимают чуть более медленную работу как данность. Но при неумелом применении можно тратить на RLS в разы больше, чем на полезную работу.
В общем, row level security — это инструмент для централизованного управления доступом к данным. Он реализован во многих современных СУБД — например, Oracle, PostgreSQL и MS SQL Server. В этой статье я покажу, как это работает в первых двух.
Row Level Security в Oracle
Начнем с реализации RLS в Oracle и сразу нырнем в практику.
Пример для Oracle
Мы попробуем реализовать простую политику на стандартной схеме HR. Обычный пользователь может видеть только свои данные. Руководитель департамента может видеть все данные по департаменту. Для этого нам понадобится:
- определить, какому сотруднику соответствует сессия;
- создать функцию, вычисляющую для него предикат;
- настроить политику, связывающую функцию с таблицей.
Мы будем считать, что приложение подключается к базе под учетной записью пользователя HR и в этой программе есть инструмент аутентификации, который позволяет понять, какой именно из сотрудников с ней работает. Данные о том, какой сотрудник подключен, мы будем сохранять в контексте — специальном key-value хранилище атрибутов, управляющих приложениями. Можно использовать стандартный CLIENT_IDENTIFIER, но мы создадим свой собственный. Для этого придется создать и пакет, который будет с ним работать.
Для начала создадим от имени привилегированного пользователя контекст и сразу укажем, какой пакет может его менять:
1 |
CREATE CONTEXT SECURITY_CONTEXT USING HR.P_SEC_CONTEXT; |
Создаем пакет для работы с контекстом:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 |
create or replace package P_SEC_CONTEXT is procedure set_employee(p_employee_id in number); end P_SEC_CONTEXT; / create or replace package body P_SEC_CONTEXT is procedure set_employee(p_employee_id in number) is begin dbms_session.set_context(namespace => 'SECURITY_CONTEXT', attribute => 'EMPLOYEE_ID', value => p_employee_id); end; end P_SEC_CONTEXT; / |
Теперь p_sec_context.set_employee будет использоваться приложением, чтобы задать код сотрудника в таблице EMPLOYEES, который работает в этой сессии БД.
При использовании пула соединений нужно задавать контекст каждый раз при получении нового подключения. Значение по умолчанию можно задавать в триггере на логин, но это не обязательно. Пример триггера:
1 2 3 4 5 |
create trigger tr_hr_logon after logon on HR.SCHEMA begin p_sec_context.set_employee(null); end; / |
При использовании CLIENT_IDENTIFIER вместо p_sec_context.set_employee нужно указать dbms_session.set_identifier.
Далее нам понадобится функция, формирующая предикат доступа. Текст, который она вернет, будет добавляться к общему блоку условий (WHERE) любого запроса, использующего таблицу через AND. Ты можешь думать об этом как о таком преобразовании: WHERE (все мои условия объединены в один блок скобками) AND предикат_доступа. Функция должна принимать два строковых параметра: имя схемы и имя таблицы. Она не должна писать ничего в базу (уровень чистоты WNDS — write no database state), но декларировать это через pragma RESTRICT_REFERENCES не требуется:
1 2 3 4 5 6 7 8 |
create or replace function sec_employees(p_schema_name in varchar2, p_object_name in varchar2) return varchar2 is begin return ' (employee_id = sys_context (''SECURITY_CONTEXT'', ''EMPLOYEE_ID'')' || ' or department_id in ' || '(select department_id from departments where manager_id = sys_context (''SECURITY_CONTEXT'', ''EMPLOYEE_ID'')))'; end;/ |
Максимальная длина получившегося условия не может превышать 32 Кбайт. В остальном можно себя не ограничивать — подзапросы, соединения таблиц и вся мощь SQL доступны.
Наконец, создаем политику для таблицы под привилегированным пользователем:
1 2 3 4 5 6 7 8 9 10 11 |
begin dbms_rls.add_policy(object_schema => 'HR', object_name => 'EMPLOYEES', policy_name => 'TEST_EMPLOYEES', function_schema => 'HR', policy_function => 'SEC_EMPLOYEES', statement_types => 'SELECT', enable => true, policy_type => dbms_rls.SHARED_CONTEXT_SENSITIVE); end; / |
После этого select * from EMPLOYEES начинает возвращать ноль записей. Задаем контекст и получаем полноценный доступ:
1 2 3 |
begin p_sec_context.set_employee(p_employee_id => 100); end; |
В плане запроса добавленный предикат явно виден.
Производительность
RLS влияет на производительность за счет двух факторов.
- Время на формирование дополнительного предиката. Для его сокращения нужно максимально использовать кеширование. Тип кеширования определяется типом политики. В нашем примере SHARED_CONTEXT_SENSITIVE, предикат не вычисляется повторно, если контекст сессии не менялся, и он не будет вычисляться для других объектов, использующих эту же функцию.
- Время на выполнение дополнительного предиката. Здесь все зависит от твоей фантазии. Он оптимизируется точно так же, как и обычные запросы.
Проблемы и возможные пути решения
- В этом примере при задании контекста мы доверяем вызывающему коду. Никто не мешает вызывать p_sec_context.set_employee с разными идентификаторами и проверять результаты. Можно усложнить жизнь атакующему, добавив к коду пользователя дополнительные данные, по которым проверять правильность вызова. Например, хеш от имени пользователя и текущего времени.
- Кое-что можно узнать в обход RLS. Oracle собирает статистику в таблицах: количество строк, наибольшее и наименьшее значение столбца, количество уникальных значений, гистограммы распределения. Статистика никак не фильтруется политиками, и эти данные видят все пользователи. Экспериментируя с внесением изменений в таблицы, где есть внешние ключи, тоже можно получить дополнительную информацию. Ограничения по уникальности также никак не учитывают политики.
- В этом примере код, отвечающий за безопасность, помещен в схему с данными. Это дает пользователю HR возможность просматривать его и менять. Для усложнения жизни атакующему лучше вынести объекты в отдельную схему. При желании можно и обфусцировать код, но это скорее из области замков от честных людей.
- Предикаты вычисляются динамически, во время выполнения, поэтому отлаживать их тяжело. С этим остается только смириться.
Row Level Security в PostgreSQL
В PostgreSQL RLS появился начиная с версии 9.5. В отличие от Oracle, политики в нем компилируемые. Это упрощает разработку и устраняет проблемы с кешами, но дает чуть меньше возможностей.
Пример для PostgreSQL
Мы будем реализовать ту же политику, которую делали на Oracle. Обычный пользователь может видеть только свои данные. Руководитель департамента может видеть все данные по нему. Если что-то пошло не так, нужно не выдавать ошибку, но и доступ к данным не разрешать. Схемы HR у нас нет, поэтому для начала создадим минимальный набор таблиц с данными:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 |
create table hr.employees ( employee_id serial, first_name VARCHAR(20), last_name VARCHAR(25), email VARCHAR(25), phone_number VARCHAR(20), hire_date DATE, job_id VARCHAR(10), salary float, commission_pct float, manager_id integer, department_id integer ); insert into hr.employees (employee_id, first_name, last_name, email, phone_number, hire_date, job_id, salary, commission_pct, manager_id, department_id) values (100, 'Steven', 'King', 'SKING', '515.123.4567', to_date('20-03-2058', 'dd-mm-yyyy'), 'AD_PRES', 24000.00, null, null, 90); insert into hr.employees (employee_id, first_name, last_name, email, phone_number, hire_date, job_id, salary, commission_pct, manager_id, department_id) values (101, 'Neena', 'Kochhar', 'NKOCHHAR', '515.123.4568', to_date('24-06-2060', 'dd-mm-yyyy'), 'AD_VP', 17000.00, null, 100, 90); insert into hr.employees (employee_id, first_name, last_name, email, phone_number, hire_date, job_id, salary, commission_pct, manager_id, department_id) values (102, 'Lex', 'De Haan', 'LDEHAAN', '515.123.4569', to_date('17-10-2055', 'dd-mm-yyyy'), 'AD_VP', 17000.00, null, 100, 90); insert into hr.employees (employee_id, first_name, last_name, email, phone_number, hire_date, job_id, salary, commission_pct, manager_id, department_id) values (103, 'Alexander', 'Hunold', 'AHUNOLD', '590.423.4567', to_date('06-10-2060', 'dd-mm-yyyy'), 'IT_PROG', 9000.00, null, 102, 60); insert into hr.employees (employee_id, first_name, last_name, email, phone_number, hire_date, job_id, salary, commission_pct, manager_id, department_id) values (104, 'Bruce', 'Ernst', 'BERNST', '590.423.4568', to_date('21-02-2062', 'dd-mm-yyyy'), 'IT_PROG', 6000.00, null, 103, 60); insert into hr.employees (employee_id, first_name, last_name, email, phone_number, hire_date, job_id, salary, commission_pct, manager_id, department_id) values (105, 'David', 'Austin', 'DAUSTIN', '590.423.4569', to_date('28-03-2060', 'dd-mm-yyyy'), 'IT_PROG', 4800.00, null, 103, 60); create table hr.departments ( department_id serial primary key, department_name VARCHAR(30), manager_id numeric, location_id numeric ); insert into hr.departments (department_id, department_name, manager_id, location_id) values (60, 'IT', 103, 1400); insert into hr.departments (department_id, department_name, manager_id, location_id) values (90, 'Executive', 100, 1700); alter table hr.employees add constraint fk_emp_dep foreign key (department_id) references hr.departments (department_id) on update cascade on delete cascade; create index fki_emp_dep on hr.employees(department_id); |
Теперь включим RLS на таблице:
1 |
alter table hr.employees enable row level security; |
Включим RLS для ее владельца. По умолчанию они игнорируют политики доступа.
1 |
alter table hr.employees force row level security; |
Добавим предикат доступа:
1 2 3 4 |
create policy test_security on hr.employees using ((employee_id = current_setting('SECURITY_CONTEXT.EMPLOYEE_ID', true)::integer or department_id in (select department_id from hr.departments where manager_id = current_setting('SECURITY_CONTEXT.EMPLOYEE_ID', true)::integer))); |
Using содержит точный текст, который будет добавлен к запросу при выполнении политики, при этом его синтаксис проверяется сразу. Функция current_setting возвращает значение параметра сессии, идентифицирующего пользователя. Второй параметр подавляет ошибки, если контекст не инициализирован. Приложение сможет задать его таким запросом:
1 |
select set_config('SECURITY_CONTEXT.EMPLOYEE_ID', '100', false); |
Настройка закончена. Смотрим план выполнения.
1 2 3 4 5 6 7 |
Seq Scan on employees (cost=20.36..21.49 rows=3 width=322) Filter: ((employee_id = (current_setting('SECURITY_CONTEXT.EMPLOYEE_ID'::text, true))::integer) OR (hashed SubPlan 1)) SubPlan 1 -> Seq Scan on departments (cost=0.00..20.35 rows=2 width=4) Filter: (manager_id = ((current_setting('SECURITY_CONTEXT.EMPLOYEE_ID'::text, true))::integer)::numeric) |
Можно пойти другим путем, завести для каждого пользователя сотрудника своего пользователя БД и настроить политику для учетной записи. Для начала удалим политику:
1 |
drop policy test_security on hr.employees; |
Когда операции попадают под действие нескольких политик, они объединяются операцией OR, поэтому, если бы мы не удалили test_security, запросы к employees выглядели бы как:
1 2 3 4 5 |
where ... ( ((employee_id = current_setting('SECURITY_CONTEXT.EMPLOYEE_ID', true)::integer or department_id in (select department_id from hr.departments where manager_id = current_setting('SECURITY_CONTEXT.EMPLOYEE_ID', true)::integer))) OR (email = current_user)). |
Создадим новую:
1 2 3 |
create policy test_security_role on hr.employees for select using (email = current_user); |
Эта политика позволяет пользователю увидеть только свои данные. Для этого ему придется подключаться к базе, используя учетную запись, совпадающую с его почтой, например SKING, а не общую учетку HR. В приложении ничего задавать дополнительно не требуется. Зато каждому человеку, работающему с системой, придется создать пользователя в БД со всеми правами.
Возможные проблемы
Как и в Oracle, можно частично обойти RLS, если есть доступ к статистике. Кроме этого, функции, объявленные как leakproof, могут выполняться до применения политик. Если такая функция, вопреки спецификации, раскрывает значения своих аргументов, ее можно использовать для получения информации.
Заключение
Row level security — не панацея, у него есть свои недостатки. Когда имеет смысл его применять?
- С данными работает больше одного приложения.
- Нужно реализовать разграничение доступа, не переделывая сильно программу.
- Нужно вынести управление доступом к данным в отдельный слой.
Когда стоит пойти другим путем?
- С данными гарантированно работает только одно приложение.
- База данных уже сильно нагружена и ограничивает производительность системы.
Так что выбирай RLS, если он тебе подходит, и, надеюсь, статья поможет тебе освоить эту технологию.
Еще по теме: Защита WHM / cPanel с помощью плагинов CSF и CXS