Данный текст - мой набросок после филтрации огромного количества текстов.
По работе стоит задача реализации системы распределения прав. Дабы не строить велосипед, решил посмотреть в CI, Yii, Zend, и так далее. В итоге выяснилось, что даже именитые библиотеки вроде Zend_Acl не решают даже части проблем.
Для того, чтобы не держать в закладках уйму текста, было решено составить этот конспект. Если Вы случайно наткнулись на этот текст, и у Вас есть вопросы, мысли или предложения - пишите их в комментариях. Если нет регистрации тут, пишите на мой ящик [email protected].
Код вида d()->User->permissions
относится к разрабатываемому мною фреймворку, и написан на PHP (не руби).
Крайне важно понимать, что роли и права - разные вещи. В системе, которую мы создаём, должны быть реализованы либо первые, либо вторые, но не одновременно. Рассмотрим два примера:
В нашей системе администратор форума может дать права разделу четырём пользователям - модераторам.
Вот так:
Чтение Запись Удаление постов Загрузка картинок
Пользователь 1 да да да да
Пользователь 2 да да нет нет
Пользователь 3 да да да да
Пользователь 4 да да нет нет
Таким образом пользователи 1 и 3 получили по 4 роли, а 2 и 4 по 3 роли (роль читателя постов и писателя постов).
Другими словами, мы можем сделать так:
Роль
Пользователь 1 супермодератор
Пользователь 2 младший модератор
Пользователь 3 супермодератор
Пользователь 4 младший модератор
В таком случае, роль пользователя (группы) по отношениюк объекту будет одна и только одна - квинтэссенция прав пользователя.
В последнем случае система более логична, потому что в первом случае нельзя дать пользователю права на запись и не дать на чтение. В Yii и Zend это решили в лоб - роль Запись наследуется от роли Чтение и получает все её привелегии.
В последнем случае есть ещё одно преимущество - дав права редактора, мы автоматически даём права на чтение. Проблема в том, что это заставит писать немного лишнего кода.
Есть способ обойти это:
function can_write()
{
if(!can_read()){
return false;
}
if(user_moderator()){
return true;
}
}
Подход позволяет инкапсулировать внутрь себя другие проверки. Проблема возникает оттого, что проходят две проверки, соотвественно, проодят лишние запросы на чтение в базу данных.
Суть
// проверка доступа к ресурсу 'dummy' залогиненым пользователем
if ($this->zacl->check_acl('dummy')){
...
}
Это означает, что второй параметр функции check_acl необязательный (он принимает роль, например, admin). по умолчанию, используется залогиненый пользователь. и это хорошо.
Второй плюс статьи: структура таблиц:
CREATE TABLE IF NOT EXISTS `tbl_acl` (
`id` int(11) NOT NULL AUTO_INCREMENT,
`type` enum('role','user') NOT NULL,
`type_id` int(11) NOT NULL,
`resource_id` int(11) NOT NULL,
`action` enum('allow','deny') NOT NULL,
PRIMARY KEY (`id`)
) ENGINE=MyISAM DEFAULT;
CREATE TABLE IF NOT EXISTS `tbl_aclresources` (
`id` int(11) NOT NULL AUTO_INCREMENT,
`resource` varchar(255) NOT NULL,
`description` longtext NOT NULL,
`aclgroup` varchar(255) NOT NULL,
`aclgrouporder` int(11) NOT NULL,
`default_value` enum('true','false') NOT NULL,
PRIMARY KEY (`id`)
) ENGINE=MyISAM DEFAULT;
CREATE TABLE IF NOT EXISTS `tbl_aclroles` (
`id` int(11) NOT NULL AUTO_INCREMENT,
`name` varchar(255) NOT NULL,
`roleorder` int(11) NOT NULL,
PRIMARY KEY (`id`)
) ENGINE=MyISAM DEFAULT;
INSERT INTO `tbl_aclroles` (`id`, `name`, `roleorder`) VALUES
(1, 'Admin', 1),
(2, 'User', 2),
(3, 'Guest', 3);
CREATE TABLE IF NOT EXISTS `tbl_users` (
`userid` int(11) NOT NULL AUTO_INCREMENT,
`user` longtext NOT NULL,
`pass` longtext NOT NULL,
`firstname` longtext NOT NULL,
`prefix` longtext NOT NULL,
`lastname` longtext NOT NULL,
`gender` enum('m','f') NOT NULL,
`roleid` int(11) NOT NULL,
`mail` longtext NOT NULL,
PRIMARY KEY (`userid`)
) ENGINE=MyISAM DEFAULT;
Но сама структура ужасна,т.к. это группы а не роли. групп должно быть безлимитно, роли захардкожены.
Интересный проект для ROR, добавляет в контроллер возможность написать role_requirement "admin", если для данного контроллера нужна роль админа.
Настраивается; можно написать require_role "admin", :for_all_except => :index
Можно написать несколько ролей.
require_role "admin", :for_all_except => :index require_role "registered_user"
Добавляет в модель User метод has_role?, простейший метод, проверяющий есть ли роль в массиве ролей User.roles.
Подходит для простейших админок, схож с методом if(iam('admin'))
Не является полноценной системой ролей.
Глубокий и сложный проект, однако один из самых полезных. Полезные фрагменты:
user.has_no_role 'moderator', group
Model.accepts_role 'class moderator', user
user.is_eligible_for_what --> returns array of authorizable objects for which user has role "eligible"
user.is_moderator_of? group --> returns true/false
user.is_moderator_of group --> sets user to have role "moderator" for object group.
user.is_administrator --> sets user to have role "administrator" not really tied to any object.
Есть связи с объектами. Это хорошо.
Есть мысль раздавать роли по аналогии с Zend (если параметр опущен, то он для всего, чем уже и точнее правило, тем выше его приоритет).
Например,
User('заказчик')->add_role('administrator');
User($object->creator)->add_role('administrator', $object);
В первом сучае объект не указан, поэтому пользователь получит права применительно ко всем объектам. Пробмема в том, что $object и 'заказчик' - захардкоженные объекты, а пользователей - админов и объектов вообще в системе могут быть тысячи.
Много ответов. Один из них базируется на 4 китах
Resources <- require -> (one or many) Permissions.
Roles <- are collections of -> (one or many) Permissions.
Users <- can have -> (one or many) Roles.
Таблицы:
permission
role
user
role_permission
user_role
То же самое что и ранее. Ресурс поста может требовать разрешение на запись. Оно есть у ролей: Админ, Модератор, Хозяин_поста. Вопрос: в базе данных так и писать - хозяин поста? Таким образом, роль Хозяин_поста динамическая. Однако, тогда зачем нужна таблица role?
Вторая проблема - отсуствие групп. Они замещены Ролями. Я не могу создать группу Редакторы Нижний Новгород и дать им право на запись в раздел с ID=381 (Новости в Нижнем Новгороде).
Вывод - очень хорошо для админок. Гибко. Но не решает всех проблем, в т.ч. самых банальных.
А вот это то, что надо. Академично, гибко, полно.
Основная идея - объект Permission - право, связан с пользователем и ресурсом (полиморфически).
Сам объект тоже является полиморфным, то есть объект права на создание CreatePermission наследуется от Permission.
Таким образом:
class Membership has_many :create_permissions, :as => :resource
Группа имеет несколько прав на запись (ресурсов), несколько прав на чтение (ресурсов). Т.е. user->read_permissins[3]->title заголовок третьего поста, который разрешено править поьзователю.
Стоит заметить, что по-правильному, необходимо делать следующее: user->write_post_permissions - массив постов с разрешением на запись.
Обращаю внимание на то, что результат - не функция вроде user->can_write?($post_12)
, а полноценный массив.
В комментариях приведён интересный фрагмент:
class User
def creatable_by?(creator)
!creator.guest? && creator == user
end
def updatable_by?(updater, new)
!updater.guest? && updater == user and same_fields?(new, :user)
end
def deletable_by?(deleter)
!deleter.guest? && deleter == user
end
def viewable_by?(viewer, field)
viewer == user || conference_sessions.count > 0
end
end
Таким образом правила записываются в моделях, а вот это уже плохо. С другой стороны, добустим есть post, и на странице редактирвоания
function post_edit($id)
{
d()->post = d()->Post($id);
if(!d()->post->editable(d()->Auth->id)) {
return 'Запрещено';
}
}
Если следовать идее из первого столбца, а также из моего черновика ACL, результат может быть таким:
function post_edit($id)
{
d()->post = d()->Post($id);
if(!d()->Acl->post_editable(d()->post)) {
return 'Запрещено';
}
}
Другим вариантом может быть:
if(!d()->Can->edit_post(d()->post)) {
В свою очередь, функция edit_post выглядит так (самый дикий вариант):
function edit_post($object) {
//Самый крутой случай
if($object->permissions->where('user_id = ?',d()->Auth->id)->is_creator_role){
return true;
}
if(d()->Auth->user->is_root){
return true;
}
//В данном случае моддератор отвественнен только за один раздел. Частный случай
if(!$object->category->moderators->where('user_id = ?',d()->Auth->id)->is_empty){
return true;
}
}
Данная проверка всегда привязывается к объекту. Поэтому можно сделать так:
d()->Comment(482)->can_edit
d()->Comment->can_edit_list - вот это уже на порядок-два сложнее.
Тут можно вставлять объект Acl через Dependency Injection
Второй хорошей мыслью была запись в комментариях
def creatable_by?(user)
# Style one. Permissions are attributes attached to model.
user.director? or user.manager?
# Style two. Permissions are abstracted away from model.
user.permissions.find_by_name('Can create stores')
end
Нам интересна вторая часть - хранение ролей в виде разрешений. Это ещё одна альтернативная точка зрения, не универсальная, но удобочитаемая.
Знаменитый CanCan от ryanb.
Альтернативный подход, но, наряду с предыдущим решением, справляется со своими задачами.
Сложно портировать ввиду того, что активно использует фишки ruby
can :read, Project, :category => { :visible => true }
can :read, Project, :priority => 1..3
Судя по всему, используется нечто булево, т.е. просмотр только видимых проектов. Попытка реализовать это на PHP будет следующей:
can('read','project',array('user_id' => d()->Auth->id));
Однако реализовать редактирование проекта в случае, если у группы пользователя есть права на запись, затруднительно. В этом случае помогает мой подхд:
class Can
{
function read_project($object)
{
if($object->user_id == d()->Auth->id){
return true;
}
if($object->premissions->where('group_id in ?', d()->Auth->user->groups)->role == 'write'){
return true;
}
}
}
С другой стороны, d()->Project(21)->can_read
class Project
{
$this->can = new CanProject();
function can_read()
{
if($this->user_id == d()->Auth->id){
return true;
}
if($this->premissions->where('group_id in ?', d()->Auth->user->groups)->role == 'write'){
return true;
}
}
}
Таким образом, используя такой подход, можно использовать следующие команды:
d()->Can->read_project($project);
//или:
$project->can_read;
и при этом легко управлять правами.
Любопытная статья, но не решает всех проблем. Предполагает, что пользователи бывают N-го количества типов с захардкоженными правами. Соответственно каждый пользователь в БД записан жёстко - пользователь, админ, рут. И стальные права харжкдятся в стветствии с разрешениями.
Подход достаточно простой и удобный, но не позволяет дать пользователю админские права на определённый раздел. Если пользователь - админ, то он и останется админом.
В качестве фишки подразуемевается наследование ролей. Например, админ наследуется от пользователя и получает все его привилегии.
Также используется дикая штука bizRule. Содержит строку с PHP кодом. Ужасно.
Также возникает проблема, если надо например, топику назначить 5 модераторов.
Ничего нового, дополнение к предыдущей статье. Также не учитывает, что пользователь может иметь роль по отношению к ресурсу.
Тоже самое. Говорится по большей мере об авторизации и регистрации. Банально трудно запретить пользователю редактировать чужие посты.
Пример:
if(Yii::app()->user->checkAccess('createPost'))
{
// создаём запись
}
Те же яйца тольо в профиль. Хорошее объяснение что в себя что включает (Роли, Таски, Действия, Объекты). Та же самая проблема - невозможность задать модераторов или банально разрешить править собственные объекты. Зато хорошо для админок.
Вывод: RBAC в Yii не универсален. Можно использовать внедрением дополнительных полей, как в linux - 777 - права для всех, есть owner, есть рут, есть гость.
Назначить владельцами объекта 10 пользователей нельзя.
Тоже самое, что Zend_Acl. Ничего особенного. К тому же имеет собственную админку и кучу других вещей, что плохо в плане того, что устройство системы должно быть крайне прозрачным.