Предисловие

Как-то давно у моего коллеги всплыл вопрос про организацию авторизации. Вступили мы с ним в диалог, в рамках которого я предложил некоторую теоретическую модель. Относительно недавно мне и самому пришлось делать похожую систему авторизации на проекте, и я взглянул на это всё с практической точки зрения. Посему хочу поделиться с миром читателями самим собой размышлениями на эту тему.

Перед прочтением рекомендую ознакомиться с понятием авторизации.

Умных книжек на тему авторизации я не читал. Если у вас есть возможность, лучше прочитайте умные книжки, а не этот пост.

Подбиваем базу

Определимся, какие сущности у нас участвуют в авторизации (т.е. в процессе определения доступа к совершению действия), и как определяется сама авторизация.

Система

В широком смысле — вся система, с которой производится взаимодействие.

В узком смысле — тот модуль, который осуществляет авторизацию.

Стоит добавить, что чаще всего реакцией на отрицательный результат авторизации занимается тот же модуль. Но это скорее из-за удобства, потому что в идеале этим должен заниматься модуль ввода-вывода.

Модуль ввода-вывода

В случае MVC приложений этим модулем будет связка view и controller. К чему нас это приведёт, увидим позже.

Субъект

Тот, кто совершает операцию.

При этом есть внешний субъект — тот самый человек (или не человек), который взаимодействует с системой.

Есть внутренний субъект — объект внутри системы, который отвечает за отображение внешнего субъекта. Чаще всего это запись о пользователе.

Так как за отображение внешнего субъекта во внутренний отвечает аутентификация, рассматривать этот вопрос в статье не будем, а предположим, что отображение уже совершено (и это всегда так и есть) и под субъектом будем подразумевать внутреннего субъекта.

Далее субъект может обозначаться как s ().

Действие

То, что хочет совершить субъект в системе. Типичное действие в мире веба (и не только) — одна из CRUD-операций. Но, конечно, действия могут быть любыми. Например распечатка документа на принтере. Или запуск ядерной ракеты.

Далее действие может обозначаться как a ().

Как и субъект, действие внешнее отображается в действие внутрненнее.

Объект

То, над чем субъект хочет совершить действие. Например, пользователь хочет удалить свой комментарий. В данном случае комментарий — и есть объект.

Под объектом () в рамках данной статейки будем понимать объект авторизации. Но тут важно понимать, что объектом авторизации может в разных условиях быть как один объект системы (), так и несколько. Пример с ядерной ракетой: если пользователь хочет запустить ракету с точкой назначения “Вашингтон”, то объектом авторизации может быть как конкретная “ядерная ракета”, так и “Вашингтон”, и даже оба одновременно.

При этом объектом авторизации может являться как один объект системы, так и несколько. Но для устранения неоднозначности каждый такой объект авторизации должен содержать уникальный набор объектов системы. То есть объект авторизации является уникальным подмножеством всех объектов системы:

Также как и субъекты, объекты рассматриваем только внутренние.

Политика (правила) авторизации

Политикой или набором правил авторизации будем называть некоторый предикат , на основании которого можно определить, имеет ли возможность субъект совершить действие над объектом.

Из указанного определения следует, что областью определения предиката является декартово произведение множеств субъектов, действий и объектов ().

Чёрные и белые списки

Для большинства этот пункт очевиден, но всё-таки я напишу.

Правила могут быть двух видов: разрешающие и запрещающие. Набор разрешающих правил называется «белый список», а набор запрещающих — «чёрный список».

Предположение о замкнутости мира в отношении правил

Предположение о замкнутости мира для правил политики авторизации можно сформулировать так: «Если для субъекта , действия и объекта не определено значение предиката p, то считаем, что высказывание будет ложным». Этот подход позволяет не определять правила для всех возможных случаев, а только специфицировать истиные.

Можно сказать (неформально), что исходным высказыванием политики белого списка является «никому ничего нельзя», а правила указывают на исключения, в то время как исходным высказыванием политики чёрного списка является «всем всё можно», а правила указывают на исключения.

Во-первых, для устранения неоднозначности следует всегда использовать только один список. В противном случае нарушается принцип замкнутости и нам надо будет писать правила для всех случаев. Такой себе вариант.

Во-вторых, исходное высказывание чёрного списка является чрезвычайно опасным, потому что отсутствие а не наличие правила предоставляет доступ к действию. А отсутствие правила контролировать обычно сложнее.

Разбиение предиката

Так как писать одно большое правило для всего и вся — такая себе затея (ну просто представьте себе этот безумный огромный if), обычно предикат разбивают на несколько меньших объединённых дизъюнкцией: .

Выделение групп объектов

Так как зачастую объекты системы являются изменяемыми данными, прописывать правило для каждой тройки не представляется возможным. В таком случае предикат политики авторизации описывается с помощью правил, выделяющих подмножество из .

Логичнее всего выделять наибольшие подмножества, которые имеют значения с точки зрения бизнеса и для каждого определять меньший предикат:

Можно ввести требование, при котором подмножества не должны пересекаться, в таком случае каждый отдельный меньший предикат будет однозначно определять, будет ли конкретный случай разрешён или запрещён: .

Общий процесс

Общий процесс совершения действия с авторизацией

Определение субъекта

Как я уже писал выше, определением (отображением внешнего во внутренний) объекта занимается процесс аутентификации или идентификации. На обе темы написаны не только статьи, а целые книги, разработаны мегатонны технологий (kerberos?, tls?), поэтому эта тема не стала предметом рассмотрения данной статьи.

Определение объекта

А вот тут даже в общем случае есть что написать.

Идентификация

Для большинства действий отображение объекта производится по данным, приходящим из системы ввода-вывода. Например, получение поста по его идентификатору: SELECT * FROM posts WHERE id=&post_id.

Важно заметить, что отображение происходит не на объект авторизации (потому что внешнего объекта авторизации в явном виде нет), а на объект системы. И уже из объекта системы (или нескольких) определяется объект авторизации.

Замена объекта авторизации

В качестве сценария для рассмотрения замены объекта авторизации можно рассмотреть добавление объекта, хотя, очевидно, данный подход можно применить для любого класса действия.
С добавлением объекта сложность в том, что новый объект ещё не является объектом системы (), поэтому он не входит в область определения предиката авторизации.

В таком случае можно рассматривать в качестве объекта авторизации множество или подмножество, которое пользователь хочет изменить.

Рассмотрим три случая.

  1. Замена на пустое множество.
    В ряде случаев нет возможности однозначно идентифицировать объект авторизации. Для таких сценариев можно рассматривать множество всех объектов системы () в качестве объекта авторизации, так как действием является расширение этого множества. Но ввиду того, что такие действия де-факто не зависят от , то его () можно заменить на пустое множество.
    Я бы предложил для такой замены название «нуль-замена».
    Признаком возможности такой замены является отсутствие идентификационных данных для определения конкретного объекта авторизации.
    Примером такого сценария является добавление поста. Пользователь либо может добавить пост, либо не может, и это никак не зависит ни от добавляемого поста, ни от других объектов в системе.
  2. Подмножество объектов как объект авторизации
    Для предыдущего случая можно рассмотреть в качестве объекта авторизации множество постов, если явно обозначен дискриминатор этого подмножества. Тогда для данного действия объектом авторизации будет (). Очевидно, что для данного сценария нуль-замена не требуется.
  3. Замена на владельца.
    Рассмотрим добавление комментария к постам. В этом случае в качестве объекта авторизации выступит множество комментариев к конкретному посту. Но в таком случае, опять же, удобно делать де-факто замену множества комментариев к посту на сам пост как объект авторизации. Подобно предыдущему пункту данная замена противоречит строгой теории, но позволяет сделать правила авторизации () проще и понятнее, а также лучше соответствовать внешним (бизнесовым) правилам.
    Я бы предложил для такой замены название «замена объектом-владельцем», потому что в данном случае множество определяется через пренадлежность какому-то другому объекту (владельцу).
    Про каскадную замену объектом-владельцем мне лень писать, честно.

Про выбор «нуль-замены» или классов объектов напишу в пункте про определение действия.

Групповые действия

Зачастую нужно произвести действие сразу с несколькими объектами. В данном случае ответом на групповое действие, очевидно, будет конъюнкция из атомарных действий: .

Определение действия

В общем случае отображение действия полностью зависит от системы ввода-вывода. Но есть повод рассмотреть определение базового класса объектов для действий.

Для действий «добавить комментарий к посту №2», «изменить пост №3», «распечатать отчёт о всех моих постах», «запустить ядерную ракету в направлении Вашингтона» рассмотрим два подхода.

«Широкие» действия

В таком случае действия могут описываться как «добавить», «изменить», «распечатать», «запустить». А классы объектов системы будут либо выводиться на уровне идентификации, либо поступать в качестве данных.

Для широких действий возможно два вида идентификации объектов: идентификация одного объекта системы и идентификация подмножества объектов системы (которые можно называть «классами объектов системы»). Примером такого класса может быть класс постов.

«Узкие» действия

«Узкие» действия описываются как «добавить комментарий к посту», «изменить пост», «распечатать отчёт о всех моих постах», «запустить ракету в направлении города». Очевидно, что в отличии от «широких» действий класс объектов уже включён в само действие, а потому повторное его использование для специфицирования области не требуется.

Выбор между узкими и широкими действиями

Не смотря на то, что подходы рассмотрены отдельно, в реальных программах предикат может быть одновременно и «узким» и «широким», специфицируя условие и в самом действии и в идентификаторах объектов системы.

Применительно к реальному миру

Теперь можно поговорить про типичные MVC-web приложения.

Тут я буду исходить из модульной rack-/plug-style системы.

Чаще всего (хотя можно и по-другому) используется такая схема:

Совершение действия с авторизацией в MVC

А где объект авторизации?

Это была первая проблема, с которой я столкнулся, когда понадобилась полноценная система авторизации.

Большая часть предлагаемых решений работают на основе предиката вместо . Компенсируется это обычно за счёт использования «узких» действий и исключения объектов авторизации из области определения предиката.
Очевидно, что в таком случае нет никакого способа запретить действия, которые действительно зависят от объекта авторизации. Пример: «Пользователи, которые не являются администраторами не имеют права редактировать и удалять не свои посты».

Как можно обойтись без объекта авторизации

На самом деле можно обойтись без объекта авторизации, перенося политику авторизации на уровень логики приложения.

Для указанного выше примера удаление логически делается только среди собственных постов: DELETE FROM posts WHERE author_id=&current_user_id AND id=&post_id.
А для администраторов добавляется отдельный доступный только им список постов, где можно делать всё что угодно.

Проблемы, как видно из примера, две:

  1. Действия приходится делать очень «узкими», чтобы с помощью субъекта и действия можно было как-то ограничивать доступ.
  2. Там, где такой способ не позволяет полностью контролировать действие, ограничение переносится на уровень логики работы.

И если в первом случае ещё нет явных проблем (хотя архитектура интерфейса страдает), вторая проблема куда хуже.

Перенося разграничение прав на уровень логики мы разделяем одно бизнес правило (нельзя/можно что-то делать) на несколько высказываний в коде.
Таким образом программист, который откроет оригинальную задачу в трекере и начнёт искать соответствующий код в политиках авторизации может потратить на поиск ответственного участка кода больше времени.
Ну и бонусом: пользователь вместо ошибки 403 получит ошибку 404. Хотя чаще всего это не сильно портит проектирование интерфейса.

Всё-таки получаем объект авторизации

Казалось бы, на схеме всё исправляется просто:

Совершение действия с авторизацией и объектом в MVC

Но в действительности теперь надо делать select в не в контроллере, а до него.

В модульной системе всё достаточно просто: добавляем перед контроллером и модулем авторизации блок, который предзагружает данные, а затем в контроллере делаем на пару селектов меньше:

defmodule App.PostController do
  use App, :controller

  plug(
    App.Plugs.PreloadObject,
    [function: &__MODULE__.preload_post/2, as: :post]
    when action in [:show, :update, :delete]
  )

  plug(App.Plugs.Authorization, for: :post)

  # ...

  def preload_post(_conn, %{"id" => id}), do: Posts.get!(id)

В данном случае App.Plugs.PreloadObject использует указанную функцию загрузки и складывает объект авторизации в conn, а затем App.Plugs.Authorization на основе этого объекта и правил авторизации определяет, можно ли выполнять действие.

Преимущества такого подхода очевидны:

  1. Получаем возможность полноценно определить предикат .
  2. Ошибки 404 и 403 теперь чётко разграничены.

Теперь о недостатках:

  1. Теперь контроллер — не единственная точка работы с данными на пути обработки запроса! Если поменяется код работы (в частности select) с постами — надо будет идти потенциально в два места.
  2. Теперь при определении полноценного предиката нет возможности проверить доступность до получения объекта. И хотя это — то, к чему мы осознанно шли, в высоконагруженных системах такое поведение может значительно добавить нагрузки в тех сценариях, когда большая часть запросов может быть отвергнута с использованием .

Использование полноценного предиката до отображения объекта авторизации

Теоретическая возможность сделать двухэтапную авторизацию для высоконагруженных систем есть: достаточно свести предикат в области к предикату в области , но

  1. Это приведёт к поддержке в коде двух предикатов вместо одного. А в действительности это будет поддержка меньших предикатов вместо
    вместо ).
  2. На практике я такого не делал, поэтому про подводные камни рассказать не могу.

Определение действия

Тут тоже есть свои особенности в реальных системах.

Проблема в том, что отображение действия происходит не один раз: сначала внешнее действие отображается на внутреннее на уровне контроллера, потом контроллер отображает это на одно (или несколько) действий модуля бизнес-логики, которые уже отображают свои действия на действия уровня данных или низкоуровневые процедуры (например, печать).

И тут встаёт проблема выбора уровня отображаемых действий для авторизации.

Типичные библиотеки для описания авторизации (как минимум рельсовые) переносят это на уровень бизнес-логики (а с active record этот слой ещё и смешан со слоем хранения).

Я же считаю, что для авторизации нужно использовать отображение как можно более близкое к вводу, потому что

  1. Отображение уровня контроллера позволяет делать действия бизнес-логики и работы с данными максимально широкими, что уменьшает количество дублируемого кода и позволяет выстроить достаточно высокоуровневые абстракции.
  2. В то же время действия уровня контроллера можно делать максимально узкими для построения минимально-необходимого интерфейса (что уменьшает уровень энтропии интерфейса).
  3. Авторизация не будет «ломаться», если одно действие уровня контроллера будет отображатся на несколько действий более низкого уровня.

Но в общем и целом, помня про текучие абстрации, можно руководствоваться подходом, при котором предикат будет иметь наименее объёмное определение. В частности иногда есть смысл авторизовать действие на уровне бизнес-логики, а не на уровне контроллера.

Обратная авторизация и логически недоступные действия

Вот сейчас я буду рассказывать про откровенный костыль, но мне пришлось на это пойти.

Кроме авторизации зачастую встаёт вопрос «обратной авторизации» — когда нужно не ответить отказом/успехом на попытку действия, а предоставить список доступных действий.

Я использовал такой подход: брал список вообще всех действий и применял к ним предикат с текущими субъектом и объектом.

Способ полностью работал. Но проблема была в том, что кнопку нужно было задизейблить не только когда пользователю нельзя чего-то сделать, но и когда действие было логически невозможным.

Тут есть два варианта:

  1. Составить предикат логически недоступных действий и конънктивно применять его вместе с предикатом авторизации.
  2. Учесть логическую невозможность действия в авторизации.

Позор мне, я выбрал второй способ. Чреват он двумя выхлопами:

  1. Разделить правила логические и политические в определении предиката невозможно. Это очень плохо.
  2. Авторизационный предикат разрастается в объёме на k логических условий для каждого из авторизационных (условий): .

Заключение

Авторизация в простых приложениях — достаточно простой предмет. Основная проблема почти всегда заключается в подходе, а не в теоретической ёмкости проблемы.

Но при этом многие (в т.ч. и я зачастую) делают авторизацию в MVC-приложениях исходя исключительно из практики, «как получится», что приводит к проблемам гибкости и корректности.