ACL: в поисках идеального решения

в 23:44, , рубрики: acl, mvc, Анализ и проектирование систем, паттерны, паттерны проектирования, Программирование, Совершенный код, метки: , , ,

Новый проект. В очередной раз пришлось решать проблему с разграничением прав. В очередной раз пришлось изобретать велосипед. Вот я и подумал, а не проще ли разобраться с этой проблемой раз и навсегда. Предыдущий проект был на PHP, следующий будет на NodeJS. Поэтому хочу решить задачу «на бумаге», чтобы эти принципы можно было использовать независимо от технологии.

Эволюция системы разделения прав

Обычно разделение прав эволюционирует так:

Сначала делают флаг admin в таблице user.

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

Дальше конечные пользователи хотят сами создавать группы и раздавать им права.

Следующая ступень — некоторым пользователям нужны права, выходящие за рамки их группы. Тут есть такие варианты:

  • Доработать систему, чтобы можно было выдавать права напрямую пользователю. Если база реляционная, то получаться запросы с union.
  • Можно делать группы под каждого пользователя с «экстра» правами. При таком подходе будет проблема с массовым редактированием. Допустим, у нас есть пользователи, которые имеют права A и B и некоторое количество пользователей (из той же организационной группы), но у которых есть ещё и другие права X, Y, Z. В один момент понадобилось убрать у всей группы право A. Для этого надо будет убирать право A из каждой специально созданной «экстра» группы.

Для решения проблемы с «экстра» правами иногда делают наследование групп. При этом уходит проблема с массовым редактированием. Но появляется проблема разрешением конфликтов разрешений/запретов. Например: в группе A какое-то действие разрешено, а в группе B, которая от неё наследуется, это же действие запрещено.

С технической точки зрения есть неудобный момент — работа с «древовидной» структурой в реляционной базе. Например, для получения списка прав: сначала придётся получить все родительские группы, потом нужно будет сделать join на таблицу прав. После получения всего списка из базы к нему нужно будет применить правила разрешения конфликтов. В MySQl иногда используют для этого «хак» c GROUP BY и ORDER, но это решение не портабельное, так как такое поведение GROUP BY не соответствует спецификации SQL.

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

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

Как надо было делать

Уже видя, что из этого может получиться, можно сделать следующие выводы:

Сразу писать систему разделения прав с расчётом на более чем две группы (админы и не админы).

Следующая тонкость — в моем описании есть небольшая подмена понятий. Группа (которая отражает принадлежность к иерархической структуре в реальном мире) и группа (роль) в системе разделения прав не всегда одно и тоже. Специально оставил, так как сам наступал на эти грабли (думаю, не я один). Нужно разделить понятия группа и роль. Тогда можно будет сделать соотношение между ролями и пользователями многие ко многим.

При соотношении многие ко многим уходит проблема с «экстра» правами и наследованием групп. Экстра права выносятся в отдельные роли, которые добавляются пользователю, кроме «основной» роли.

Решение конфликтов тоже упрощается: так как структура прав «плоская», то и сами права будут простые. Например, могут быть такие правила:

  • по умолчанию все запрещено;
  • можно выдавать разрешения и запреты;
  • запрет «перевешивает» любое количество разрешений;
  • правила разрешений складываются (логическое или);
  • правила запретов складываются (логическое или);
  • потом из всех прав разрешений вычитаются все права запретов, а все, что осталось — и есть искомое множество прав, которые разрешены пользователю.

Реализация

Опишу реализацию на примере веб приложения, которое следует MVC паттерну. В таком приложении права можно разграничивать на уровне контроллера и/или на уровне модели. Так же ACL должен предоставлять хелперы для представления (view).

Базовый интерфейс (Базовый модуль)

Необходимый минимум для работы ACL.

Cамая базовая функция, которая должна быть в любом ACL, отвечающая на вопрос, есть ли у данного пользователя право на данное действие с данным ресурсом:

can(user, 'edit', resource) # => true/false

Функция для вызова в контроллере _ для проверки, есть ли доступ у текущего пользователя. При этом предполагается, что контроллер предоставляет метод для получения текущего пользователя (CoC).

authorize('edit', resource)

В случае, если доступа нет, то будет передано управление обработчику ошибок неавторизованного доступа. В зависимости от ЯП это можно сделать через исключения или через колбеки.

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

catch AccessDenied 
  redirect
end

Должна быть возможность задать проверку для всех контроллеров разом. Чтобы не надо было в каждом контроллере ставить вызов authorize. В зависимости от технологии это можно сделать или в базовом классе контроллера или с помощью программной прослойки (middleware).

Для базового интерфейса не принципиально устройство групп. Это сделано специально, чтобы его можно было легко внедрять в существующие проекты с целью переделывать по кускам. Базовый интерфейс даже сможет работать с флагом admin в таблице user. Для этого нужно будет реализовать кусок кода, который отвечает за определение прав по пользователю. Например:

detect(user)
  if user.admin
    allow('all', 'post')
  else
    allow('read', 'post')
  end
end

Модуль ролей

Предполагается, что модель пользователя предоставляет метод для получения ролей пользователя (CoC).
Если группы не нужно хранить в базе, то задание прав происходит с помощью DSL.

allow('admin', 'all', 'post')

Модуль атрибутов

Рассмотрим пример: автор может делать все CRUD операции со статьями. Но редактировать, удалять статьи, а также просматривать черновики он может только свои. Т.е. получается, что теперь не все статьи одинаковы. Такое разделение прав решается введением атрибутов для ресурсов.
Первый параметр — черновик или нет (draft=true/false); второй — принадлежит ли автору (own).
Итого:

# может создавать статьи
allow('author', 'create', 'post')
# может просматривать все статьи не черновики
allow('author', 'read', 'post[draft=false]')
# может просматривать, редактировать, удалять свои статьи
allow('author', ['read', 'update', 'delete'], 'post[own]')

Для поддержки таких атрибутов нужно будет реализовать вспомогательные функции (helpers). Для работы с инстансом модели (ресурсом):

own(user, resource)
  return user.id == resource.user_id
end

Для работы с методом получения данных (списка) из базы.

own_filter(user, resource_query)
  return resource_query.where('user_id', user.id)
end

Для моделей нужно будет реализовать метод, который будет вызывать необходимые функции. Использование этого метода может выглядеть так:

Post.find().with_permissions('read', user)

При вызове этой функции произойдет обращение к модулю разделения прав. Если задать пользователя на уровне модуля разделения прав, то не нужно будет указывать пользователя в каждом вызове этого метода.

К ресурсу можно применить более одного атрибута, при этом условия атрибутов должны складывать по правилам логического и.

Хранение в базе

Описанная структура легко сохраняется в базу.
ACL: в поисках идеального решения
Пользователь, Роль — n к n; Роль, Право — n к n; Право, Ресурс — n к 1; Право, Атрибут — n к n; Право, Действие — n к 1;

Задание начальных значений

В системе должны быть настройки по умолчанию. Чтобы не зависеть от реализации базы для задания начальных значений можно использовать все тот же DSL (функция allow).

Интерфейс редактирования прав

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

Форма создания/редактирования прав

Должны быть такие поля:

  • роль (выбор или заданная в контексте)
  • выбор ресурса
  • в зависимости от выбранного ресурса можно выбрать действие
  • в зависимости от выбранного ресурса можно выбрать атрибут
  • allow/deny

А отображаться права будут так:

<автор> <может/не может> <редактировать> <собственные> <статьи>

Синхронизация

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

Сбор данных из кода

Сначала надо собрать все данные по коду.

Сбор ресурсов

Ресурсами могут выступать модели или контроллеры (они же маршруты или route). Модели легко собрать, по идеологии MVC они должны находиться отдельно (в отдельной папке). При сборе маршрутов тоже не должно возникнуть проблем. В современных MVC фреймворках задание маршрута выглядит примерно так:

get('/posts/:id', handler)

Естественно можно собирать оба вида ресурсов или только один в зависимости от потребностей.

Сбор атрибутов

Это тоже довольно легко сделать, ведь атрибуты — это функции определённого вида, лежащие в определённом месте. Также для атрибутов надо будет хранить информацию о том, для каких моделей они подходят.

Сбор действий

Есть 4 стандартных действия для моделей (CRUD) и одно действие для маршрута (access). Все остальные действия можно искать по коду текстовым поиском (по функции allow, authorize итп).

Из кода в базу

После сбора данных по коду нужно сложить их в промежуточное хранилище (файл). Тогда в этот же файл можно будет добавлять человекопонятные описания. Так же это позволить обойти несовершенство алгоритма сбора (поиска) данных по коду. Допустим, алгоритм не находит некоторые данные в коде (скорее всего действия). Тогда их можно будет дописать в файл «руками». При следующем запуске алгоритм опять не найдёт эти же данные и удалит их из файла. Чтобы этого не происходило, их надо пометить, как добавленные вручную, чтобы скрипт синхронизации не пытался их удалить. Получившийся файл надо хранить в системе контроля версий вместе с кодом.

При доставке обновлений или при разворачивании системы на другом железе (deployment) нужно будет запустить скрипт синхронизации. Скрипт считает данные из файла. Сделает проверку, что нет конфликтов — не удалены данные, которые ещё участвуют в разделении прав. После чего запишет новые данные в базу. Дальше добавит новые права по умолчанию (если есть еще один файл с правами по умолчанию).

Формат файла

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

{
  resources: {
    "route:/posts": false,
    "model:post": "Статьи",
    "other"
  },
  attributes: {
    "own": {
      description: "Собственные",
      resources: ["model:post", "model:comment"]
    }
  },
  actions: {
    "edit": "Редактировать",
    "publish": {
      description: "Опубликовать",
      resources: "model:post",
      keep: true
    }
  }
}

Псевдо -группы, -ресурсы

Для удобства можно создать такие псведо-группы:

  • all — все пользователи
  • authenticated — все залогиненные пользователи
  • non_authenticated — все не залогиненные пользователи

И такой псевдо-ресурс:

  • all — все ресурсы

Последний штрих

Надо не забыть добавить хелперы для тестирования.

То что не попало в статью

Поля (параметры, свойства) моделей как атрибуты

Изначально я хотел собирать не только функции (хелперы) атрибутов, но и все поля моделей, чтобы можно было с ними сравнивать (draft=false, amount>100). Но понял, что это не очень хорошая идея и вот почему:

  • их будет очень много, и большинство из них не будут принимать участия в разделении прав, т.е. будет очень много мусора
  • если разрешить задавать сложные зависимости, то может получиться, что бизнес логика перекочует из кода в базу.

Поэтому я решил отказаться от идеи задания значений через поля моделей. Если нужно задать такое значение – то придется написать хелперы для атрибутов, т.е. для draft=true/false нужно делать так:

is_draft(user, resource)
  return resource.draft
end
is_not_draft(user, resource)
  return !resource.draft
end

Разделение прав на уровне полей модели

Т.е. у каких-то ролей есть доступ только к отдельным полям модели. Этот вопрос я оставил на потом (может быть для второй статьи).

P.S.

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

При написании статьи искал вдохновение здесь:

Автор: kotiara

Источник

* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js