Всем привет, хочу представить свой рассказ о том, как мы решали проблему авторизации с учетом использования Hasura в нашей архитектуре.
Сразу хочу сделать ремарку, что не буду вдаваться в подробности того, что такое Hasura, как она работает и зачем нужна, на это есть замечательная статья, а также восхитительная на мой субъективный взгляд подробнейшая документация.
Как все началось
Каким-то неизвестным мне способом в наш стек затесалась такая штука как Hasura. Сначала пользовались только механизмом cron trigger для регулярных ночных выгрузок пользователей из внешней системы в нашу и почему-то пользовались именно Хасурой, что выглядело немного странно и непонятно в тот момент, но не об этом речь сейчас.
Так случилось, что захотелось поизучать инструмент глубже, потому что, кроме вкладки Events было еще аж 4 штуки, куда лично я никогда не заходил ибо все, что мне рассказали про Хасуру звучало примерно так: "ну это шедулер ночной, она дергает сервис импорта, чтобы тот пользователей загрузил" и "там вкладка events, в ней все настройки, остальное нам не надо, так что забей"
Плюсом к моему личному интересу стали речи нового СТО компании о том, что это очень полезный и классный инструмент, надо его использовать и тд. Что ж, инструмент действительно оказался отличным и теперь это центральное звено нашей архитектуры.
Авторизация Hasura из коробки
Прежде чем перейти к основной части, думаю стоит чуть подбронее рассказать про то, как работает авторизация в Hasura. Основной идеей является то, что Hasura умеет подхватывать контекст пользователя, забирать из него переменные, подставлять в правила авторизации и выстраивать на их основе запросы к базе данных. То есть данные будут считаны из БД только те, что удовлетворяют разрешениям пользователя, который пытается их считать. Те самые переменные контекста выглядят как X-Hasura-*. Восхитительно то, что никто не ограничивает вас в нейминге и количестве этих переменных (кроме их начала, разумеется), хотя некоторые переменные сама Hasura предлагает как стандартные системные, так что использовать их по своему разумению надо осторожно. Например X-Hasura-role используется для определения роли пользователя внутри самой Hasura.
Что же скрывается под ролью? Для каждой роли есть 4 операции CRUD: insert, select, update, delete (названия даны также как оперции в SQL). Для каждой из операций настраивается правило доступа. Эти настройки могут быть различны для каждой сущности (таблицы, вьюхи или функции). То есть, например, для таблиц со справочными данными можно настроить правило для SELECT без каких-либо ограничений, а для супер секьюрной таблицы с данными пользователей настроить жесткую фильтрацию только по user_id = X-Hasura-user-id, тогда пользователи смогут видеть только свои данные.
Как видно, в правиле доступа фигурирует переменная X-Hasura-User-Id. Это означает, что когда будет вызван запрос на чтение данных из этой таблицы пользователем с ролью User, то Hasura будет искать в контексте переменную X-Hasura-User-Id для того, чтобы подставить в правило.
Как же сработает это правило? Если переводить на человеческий язык, то правило говорит следующее: "выдать доступ только к тем строкам таблицы, где поле id совпадает со значением переменной X-Hasura-User-Id". На основе него будет сгенерирован SELECT, в котором это условие попадет в условие выборки WHERE. Если придет пользователь с X-Hasura-User-Id = 123, то условие будет:
WHERE <table_name>.id = 123
Откуда же берутся X-Hasura-* переменные? Есть два варианта:
http хэдеры ответа аутентификационного хука. Про то, как это работает, подробнее тут.
Или еще вариант с X-Hasura-* переменными в JWT токене. Об этом тут.
Наш варинат первый. Архитектура решения выглядит так:
Номера на стрелках расставлены в порядке прохождения запросов (и ответов)
Запрос на аутентификацию
Ответ с session_id
Запрос данных у Hasura. В cookies лежит session_id
Запрос на проверку аутентифкации. Coookies пересылаются в payload
Запрос авторизационных данных на основе session_id.
Ответ с авторизационными данными в виде X-Hasura-headers
Ответ с авторизационными данными в виде X-Hasura-headers
Запрос в БД на основе правил авторизации по X-Hasura-headers
Ответ с данными разрешенными этому пользователю
Ответ с данными разрешенными этому пользователю
Подход, предлагаемый Hasura направлен на предоставление доступа по модели Row Level Security, что отлично отвечает нашим требованиям к системе. Однако, как всегда, есть нюансы, которые всплывают при дальнейшем более детальном проектировании отдельных модулей.
Во-первых, правила не всегда почти никогда не являются такими простыми, как указано на картинке. Во-вторых, в разных модулях могут быть разные необходимые заголовки для авторизации и если пустить разработку на самотек в этом плане, то их количество будет бесконтрольно расти и через какое-то время уже будет невозможно разобраться зачем нужен тот или иной хэдер, а совместное использование одних и тех же заголовков вообще может родить проблему ломания одного куска из-за изменения в другом, по сути, это неконтролируемое ничем порождение зависимостей. В-третьих, хотелось иметь одно центральное место управления правами доступа для того, чтобы впоследствии организовать админку, а также красиво встраивать в процессы шаг выдачи доступа
Первый подход к снаряду
Итак, для того, чтобы как-то «причесать» систему контроля доступа была рассмотрена майкрософтовская версия организации доступов на уровне Row Level Security. Первоисточник можно почитать тут. Не совсем явно она называется Security Labels (в дальнейшем я ее и буду так называть, и мы тоже называем ее так). Суть работы такой модели звучит очень круто на мой взгляд: «У пользователей и объектов есть лейблы. Доступ к объекту разрешен пользователю тогда, когда его лейбл доминирует над лейблом объекта». Это очень кратко и передает самую основную суть. Лично мне очень понравился такой подход. Теперь чуть подробнее про терминологию:
Метка (марка) - уровень доступа.
Иерархическая метка. Нужна для того, чтобы автоматически, имея определённый уровень доступа, получать доступ к уровням ниже по иерархии.
Не иерархическая метка. Нужна для того, чтобы организовать проверки доступа по правилам ALL (все метки категории) и ANY (хотя бы одна метка категории).
Категория меток - абстракция, объединяющая несколько меток в группу. Необходима для организации проверок "доминирования" лейблов.
Иерархическая категория. Для иерархических меток
Не иерархическая категория. Для не иерархических меток
Категория с правилом ALL. Нужна для того, чтобы организовывать проверку наличия всех меток этой категории в лейбле пользователя
Категория с правилом ANY. Нужна для того, чтобы организовывать проверку наличия хотя бы одной метки категории в лейбле пользователя
Лейбл - набор меток различных категорий, по которому определяется наличие у пользователя доступа к объекту. Лейбл имеет бизнес название. Лейбл есть у пользователя (много) и у объекта (один). Для получения доступа лейбл пользователя должен "доминировать" над лейблом объекта.
Доминирование лейблов проверяется следующим образом:
Вычисляются все категории меток, которые содержатся в лейбле объекта.
Для каждой категории меток, вычисленной на прошлом шаге, проводится проверка меток из лейбла пользователя по правилу, заданному для категории:
Для иерархических - уровень иерархии метки пользователя этой категории выше или равен уровню иерархии метки объекта этой категории
Для не иерархических
ALL - в лейбле пользователя должны быть все метки лейбла объекта этой категории.
ANY - в лейбле пользователя должна быть хотя бы одна метка лейбла объекта этой категории.
Пример для большей понятности:
Метки | ||
Категория | Правило | Метка |
Доступ по уровню | HIERARCHICAL | DISTRICT.REGION.CITY.COUNTRY |
Доступ по купленным подпискам | ALL | BASE, ULTIMATE, PRO, MAX |
Доступ к проведению мероприятия | ANY | CREATOR, EDITOR, SPEAKER |
Доступ по территориям | ALL | MSK, SPB, CAO, VAO, TAGANSKIY, RUS, KZH |
Лейблы | ||
Лейбл | Бизнес-название | Комментарий |
[DISTRICT, TAGANSKIY, MSK, RUS, BASE] | Районный доступ по подписке BASE | Позволит дать доступ всем пользователям района Таганский (живущим в Москве, в России) с подпиской BASE, при этом пользователи другого района с той же подпиской доступ иметь не будут |
[COUNTRY, RUS, MAX] | Государственный доступ по подписке MAX | Позволит дать доступ всем пользователям России с подпиской MAX, при этом пользователи другой страны с той же подпиской доступ иметь не будут |
В первом примере в лейбле указаны и страна, и город, и район. Выглядит как будто избыточно, но отсутствие подобных ограничений по всем параметрам может вызывать коллизии, например, Таганский район может быть и в каком-то другом городе или стране, но при этом нельзя жить в Таганском районе Москвы и при этом не жить в самой Москве и в России, соответсвенно, так что весь этот набор меток будет автоматически выдан пользователю при указании места жительства в личной карточке пользователя.
Все описанное выше легло в следующую модель данных
Приземление на Hasura
Модель данных модуля доступа легла в отдельную схему БД и автоматически получила CRUD операции над собой. Но как же теперь с помощью нее и правил в Hasura организовать проверку доступов? Ответ находится довольно просто. Вспоминаем первые вводные про security labels: «У пользователей и объектов есть лейблы…». Так-с пользовательские лейблы мы знаем из модели данных модуля авторизации, а где же взять лейблы объектов? Правильно, сделать одним из атрибутов объектов лейблы, хотя, лучше их id для реляционной целостности.
Таким образом для подключения к платформенному модулю авторизации необходимы были следующие шаги:
Аналитикам продумать набор категорий, меток, лейблов для обеспечения полноценного контроля доступа ко всем объектам модуля
Аналитикам продумать принципы вычисления лейблов для объектов при их создании.
Разработчикам модифицировать модель данных: добавить ко всем таблицам, к которым необходимо контролировать доступ поле label_id
Разработчикам написать скрипты DML на добавление новых категорий, меток и лейблов в БД
Хорошо. Категории, метки, лейблы есть. У объектов появилось поле label_id. Как же проверять доступ? По сути нужно проверить следующее: есть ли у текущего пользователя такой лейбл, который доминирует над лейблом объекта, к которому запрашивает доступ пользователь. Набор тех лейблов, к которым имеет доступ пользователь, было решено вытащить на свет с помощью вьюхи, которая с помощью нескольких JOIN между таблицами модели данных модуля доступа и нескольких функций, в итоге имела 3 поля: user_id, label_id, has_access. По сути, это декартово произведение всех лейблов и пользователей для определения наличия или отсутствия доступа пользователя к тому или иному лейблу.
Окей, остался последний шаг, как-то нужно связать эту вьюху с таблицами из других модулей для контроля доступа. У нас есть label_id, по нему и сделаем связку, но городить физическую связь в БД – плохая идея, потому что это порождение связанности модулей, и тут на сцену выходит механизм Hasura Relationships, который позволяет использовать механизм реляционного связывания сущностей Hasura между собой по аналогии со связями в БД. Причем связи в БД он подхватывает автоматически, но восхитительно то, что можно создать дополнительные связи без необходимости создания таких связей в самой БД. Итого: для проверки доступа в любом модуле необходимо было добавить к таблицам, к которым необходимо контролировать доступ, поле label_id, настроить Hasura Relation между этой таблицей и вьюхой и настроить правило проверки доступа следующим образом:
Представленное правило будет работать так: для всех строк в таблице будет произведена проверка наличия записи в связанной вьюхе accessControl (связь по label_id) где поле user_id = X-Hasura-user-id, то есть id текущего пользователя, а поле has_access = true, то есть имеется доступ. Если вдруг таких записей не найдется, то ответом будет пустое множество. Таким образом, если у текущего пользователя есть доступ хоть к чему-то, то ему вернутся доступные данные, если у него нет доступа, то ему не выдаст ничего. Получается, что нет ограничений на вызовы API, а просто под капотом ограничивается набор данных возвращаемых пользователю.
При таком подходе мы практически совсем отказались от ролей самой Hasura. Все проверки можно теперь уложить в лейблы. А ролей остается 3: admin – системная роль Hasura, под которой происходит доступ в административную консоль и ей доступно все (правила нельзя никак настроить и что-то ограничить, удалить роль нельзя, да и зачем...), user – абсолютно любой пользователь системы, а все ограничения доступов организованы с помощью лейблов, anonymous – роль для анонимного доступа без авторизации, содержащий свои правила проверки (кстати, в итоге, правила для анонима тоже были уложены в лейблы).
Возникшие проблемы
Все выглядит достаточно просто, изящно и лаконично, однако, есть некоторые нюансы, которые конечно же вылезли наружу. Что-то вылезло сразу, что-то вылезло потом. Например, сразу стало непонятно, а как нам разграничивать операции CRUD между друг-другом. То есть при использовании представленной модели лейбл может либо дать доступ сразу на все операции, либо запретить его совсем, а очень часто необходимо одним пользователям дать доступ только на чтение, другим дать возможность редактирования, а третьи должны уметь еще и создавать что-то, причем вторые этого не должны мочь делать.
Также было не до конца понятно, как красиво применить эту модель для Actions, с одной стороны это по сути одна операция и проблемы разделения доступа по операциям не возникает, но с другой стороны, надо каким-то образом организовать фильтрацию данных, возвращаемых Action.
Для решения первой проблемы было использован следующий подход: каждый лейбл пользователя сопровождается маркой привилегий, таким образом для каждой из операций CRUD в правиле доступа должно проверяться не поле has_access, а поле, содержащее информацию о том, что операция, которую сейчас вызывают, разрешена для пользователя с этим лейблом. То есть вьюха проверки доступа теперь стала содержать поля: user_id, label_id, privilege.
При более подробном разборе второй проблемы оказалось, что все не так страшно. Во-первых, большинство Actions – это операции на запись с дополнительной бизнес валидацией, то есть аналог операции INSERT, таким образом возможность обращения к ним можно было также контролировать с помощью привилегий доступа. Во-вторых, в тех случаях, когда Action должен был возвращать какие-то данные, можно было воспользоваться тем же контекстом пользователя, который Hasura прокидывает в Action. Вот так выглядит payload, который Hasura отправляет в обработчик Action
При таком подходе обработчик Action мог в запросе к Hasura подставлять контекст пользователя и получать только те данные, которые ему доступны. Или в случае, когда надо из данных недоступных пользователю напрямую что-то вычислить, а потом вернуть что-то пользователю, то на уровне обработчика можно было организовать фильтрацию. Да, это выглядит как частичное повторение логики работы механизма авторизации Hasura, но других идей пока что нет, может, что-то придумаем в дальнейшем.
Заключение
Хочу отметить, что предложенный в первом подходе вариант уже реально работает в проде и достаточно успешно. Те проблемы, которые возникли, пока не критичны, но главное, что они известны, а также понятны способы их решения и в ближайшее время будет произведено внедрение этих решений. Уже видится и следующая проблема на горизонте: как разграничить доступы к различным полям у различных пользователей, не прибегая к разным ролям? А если внедрять большее количество ролей, то как управлять назначением этих ролей пользователям? Есть несколько мыслей на счет того, как это можно сделать, но пока в стадии проектирования и проверки гипотез, так что не вижу смысла говорить об этом сейчас. Возможно, было не учтено что-то еще, но если это так, то проблемы вылезут и решения обязательно найдутся.