воскресенье, 3 октября 2010 г.

Организация Модели. Управление доступом

Введение


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

Эта тема особенно важна для интернет/интранет приложений, где число пользователей заранее неизвестно, а количество различных операций и ресурсов велико. Как и в прошлой заметке, несмотря на то, что речь идет о Zend Framework и компоненте Zend_Acl, все размышления могут быть применены к любому фреймворку и платформе.

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

Прежде чем перейти к прочтению статьи, рекомендую ознакомиться с официальной документацией по Zend_Acl.


Немного теории


Для начала, попробуем разобраться что из себя представляет компонент Zend_Acl. Для этого окунемся в теорию. 

Существует несколько основных моделей управления доступом:
  • MAC (Mandatory access control) - мандатное или принудительное управление доступом, когда уровень доступа определяется системой, а не владельцем ресурса. Например, когда вы назначаете пользователю или группе пользователей метку (используя константу, биты и т.д.), а затем сверяете это значение с меткой ресурса. Наверняка каждый из нас использовал такую модель разграничения доступа. Недостаток этой модели в статичности, отсутствии гибкости и т.д.
  • DAC (Discretionary access control) - дискреционное или произвольное управление доступом основывается на понятии владельца ресурса. Владелец решает кто имеет доступ к ресурсу и с какими привилегиями. Реализуется на основе ACL (Access Control List) списках доступа. Недостаток такого подхода в том, что поддержание списков доступа труднозатратно при росте числа пользователей или объектов. Это связано с тем, что пользователь напрямую связывается с привилегиями, таким образом полное количество этих отношений является произведением числа пользователей и ресурсов в системе.
  • RBAC (Role-based access control) - управление доступом на основе ролей. Является развитием моделей доступа на основе MAC и DAC. В отличии от MAC, RBAC предоставляет механизм для построения политики безопасности, а не накладывает определенные ограничения. В отличии от DAC, система контролирует доступ к ресурсу поверх владельца ресурса. Так же, уклон на наличие ролей, дает больше гибкости, снижает сложность настройки системы, т.к. можно разбить систему на составные части, на каждую часть назначить права и привязать эти права к роли. А далее пользователям назначать роли, а не привилегии. RBAC не позволяет пользователям напрямую связываться с привилегиями.

Теперь вооружившись теорией, становится ясно, что несмотря на то, что в основе Zend_Acl лежат списки доступа, Zend_Acl ближе к RBAC, чем к DAC. И действительно, RBAC так же можно строить на списках доступа, отличие в том, что мы оперируем абстрактными ролями, а не конкретными пользователями. Кроме того, RBAC позволяет проверять доступ в зависимости от контекста (например в зависимости от типа соединения), что тоже учтено в Zend_Acl с помощью механизма утверждений (Assertion).

Требования и договоренности

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

На мой взгляд, следующие требования подойдут для большинства систем от простых до достаточно крупных (системы, которые разрабатывают большие компании, с десятками тысяч человеко/часов я не беру в расчет :D)
  • По умолчанию действует правило, все что явно не разрешено - то запрещено.
  • Роль - не наследуется.
  • Пользователь получает Роли через Группу, в которой он состоит.
  • Каждый Пользователь может иметь персональную Роль.
  • Персональная Роль имеет приоритет перед Ролями полученными через Группу.
  • Нет приоритета среди Ролей, полученных через Группу т.е. Групповые Роли равноправны.
  • Роль по умолчанию - guest (если у Пользователя нет явных Ролей, то считается что у него есть Роль guest)
  • Если у Пользователя есть Роль, отличная от guest, то Роль guest может быть удалена, в таком случае имеют силу только назначенные явно Роли.
  • Одну и ту же Роль могут иметь несколько Групп и Пользователей
  • Количество Ролей, Ресурсов и Привилегий не ограничено
  • Разрешение на доступ ищется среди всех ролей. Как только находится первое правило, разрешающее доступ, поиск прекращается - доступ разрешен. Если ничего не найдено - то доступ запрещен.
  • Если доступ запрещен в Персональной Роли, то проверка Групповых ролей не производится - Доступ запрещен.
  • Разрешено наследование Ресурсов (при этом наследуются все привилегии родителя)
  • Ресурс может иметь только одного родителя (дерево)
Такие договоренности, позволяют нам избежать коллизий. Так, как мне кажется, наследование ролей, которое широко освещается во всех туториалах по Zend_Acl - это потенциальный источник ошибок и не согласований. Не зря в документации приводится пример, с множественным наследованием ролей.

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

При необходимости можно заменить наследование - механизмом копирования шаблона прав в нужную роль.

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

Проблемы и вопросы


Опять же, Zend Framework не предоставляет готового решения по управлению доступом. Компонент Zend_Acl очень гибкий, настолько, что на конкретного разработчика ложится множество вопросов:
  • Что считать ролями, ресурсами и привилегиями
  • Однозначное именование ролей, ресурсов и привилегий
  • Срабатывание привилегий при определенных условиях
  • Как организовать хранение списка доступа
  • И вообще как встроить Zend_Acl в MVC-архитектуру.
С последнего вопроса я и хочу начать.

Место компонента управления доступом в MVC-архитектуре.

Если поискать кто и как использует Zend_Acl, то практически везде его используют на уровне Контроллера. В качестве ресурсов часто используют Контроллеры, в качестве Привилегий - Действия. Регулирует доступ плагин Front Controller'а, либо Action Helper'ы. В этом случае упускается из виду тот факт, что Представление может имееть прямой доступ к состоянию Модели, минуя Контроллер (например через View Helper'ы). В таком случае, о срабатывании плагина не может быть и речи. Значит, выносить контроль доступа в Контроллер грубая архитектурная ошибка. К счастью движение в правильном направлении  уже наметилось, необходимо контроль доступа выносить в Модель, а конкретнее в сервисный слой.

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

Что считать ролями, ресурсами и привилегиями

Если взглянуть в стандарт RBAC0, то эти понятия тесно связаны цепочкой


Начнем с определения этих терминов.

Пользователь (User) - это человек или внешний для системы механизм, который взаимодействует с системой. Экземпляр такого взаимодействия называется Сессией.

Роль (Role)- это совокупность привилегий. Каждый Пользователь может иметь несколько Ролей. Каждая Роль может принадлежать нескольким Пользователям.

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

Ресурсом (Resource) - может быть все что угодно, любой объект, группа объектов, отдельные поля объекта, абстрактные сущности и т.д. Другими словами, это то, к чему запрашивается или контролируется доступ.

Разбираемся с Ролью

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


Самый простой вариант №1, когда каждому Пользователю дается одна Роль, при этом одна и та же Роль может быть назначена нескольким Пользователям. На мой взгляд, этот вариант подходит только для всяких разных туториалов, гайдов ну и простых систем. Для серьезных приложений, слишком сложной станет настройка Ролей, в которых правила зачастую будут пересекаться.

Вариант №2, когда каждому Пользователю может быть назначено несколько Ролей. В отличии от первого варианта, упрощается механизм назначения привилегий, так как теперь можно оперировать заранее настроенными ролями.

Вариант №3, развитие предыдущего варианта, путем ввода посредника в виде Группы. Как мне кажется этот вариант более правильный, так как именно Группы предназначены для группировки пользователей, и проще один раз настроить группу, через которую пользователи получат роли, чем каждый раз пользователям назначать роли.

Вариант №4, является развитием варианта №3, здесь так же добавляется персональная роль. Например, с помощью персональной роли, конкретному пользователю можно назначить определенную привилегию, помимо тех, которые назначаются ему через группу. Такой привилегией может быть например, разрешение одному конкретному пользователю на просмотр фотографий другого конкретного пользователя, хотя через групповые роли, такое действие ему запрещалось. Довольно распространено в всяких соц. сетях.

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

Итак, с учетом требований нам отлично подходят варианты №3 и №4.

Разбираемся с привилегиями

Каждая Привилегия может иметь Название (как правило в виде глагола), Тип (разрешить или запретить), ограничения и ссылку на Ресурс, на который собственно эта привилегия и распространяется.

Разбираемся с Ресурсами

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

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

Каждый ресурс может наследоваться только от одного родителя (дерево). Так как у нас может быть множество независимых ресурсов (вершин), то в целом наши ресурсы образуют лес (множество деревьев). Корневой элемент описывает обобщенный ресурс, например "Новости", "Комментарии" и т.д. Дочерние элементы - описывают подресурсы (например "Неопубликованная новость", "VIP-Новость") или конкретные ресурсы (Конкретная новость, Конкретный комментарий и т.д).

Абстрактные ресурсы задаются на этапе проектирования (мандатный доступ - MAC), таким образом каждый разработчик может оперировать этими абстракциями, не имея конкретных ресурсов. Например, разработчик модуля "Новости" уже на этапе разработки знает что будут объекты "Новости", а так же каждая новость может быть "опубликована" и "не опубликована". Соответственно гости могут видеть только опубликованные новости, а модераторы и администраторы все. Жестко привязывать это в коде не правильно, вдруг изменится политика и гостям тоже будет разрешено видеть все новости. Очевидно, это надо регулировать через механизм контроля доступа. Т.о. мы получаем следующее дерево ресурсов:

Новость
    Черновик
    Опубликованная новость
    VIP Новость

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

Новость
    Черновик
        Новость c id 3
    Опубликованная новость
        Новость c id 1
        Новость c id 2
    VIP Новость
        Новость c id 4
    Новость c id 6

Так же, на эти конкретные ресурсы, мы можем назначать "особые" права уже во время работы приложения, таким образом реализуется дискреционное управление доступом (DAC).

Очевидно, что при таком подходе вложенность будет не большая, 2-3 уровня для отдельных ресурсов. Это обстоятельство важно, т.к. оно позволяет нам выбрать оптимальный способ хранения дерева в Источнике Данных.

На мой взгляд оптимальным будет вариант основанный на списках смежности (Adjacency List). Я не буду здесь описывать какие еще варианты бывают. О достоинствах и недостатках этого способа, а так же о других вариантах можно узнать тут

Модель

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


Левая часть нам уже знакома - это то, каким образом каждый пользователь получает Роли. Роль расширяет интерфейс Zend_Acl_Role_Interface. Далее, каждая роль, отношением "many-to-many" связывается с привилегиями.

Каждая привилегия, имеет название (например view, edit и т.д), тип (allow, deny), ограничение Constraint (например, только владелец может редактировать). Привилегия связывается отношением "many-to-one" с Ресурсом. Обратите внимание, что ресурс обязателен. Нет смысла от привилегии не связанной с ресурсом.

Ресурс расширяет интерфейс Zend_Acl_Resource_Interface. Ресурс может наследоваться от другого ресурса, а так же иметь ссылку на владельца ресурса.

Здесь надо сделать ремарку, что в данном случае, ресурс это не конкретный ресурс или объект, а описание ресурса. Все конкретные объекты и ресурсы должны ссылаться на это описание (например конкретная новость №2 может ссылаться на позицию "Новость", а может на "Неопубликованную Новость", а может создать свое описание и ссылаться на него "Конкретная новость №1"). Или тот же Пользователь может выступать в качестве ресурса, поэтому может иметь ссылку на свое описание.

ConcreateResource - это любой ресурс, любая сущность, ссылающийся на описание ресурса в ACL.

Однозначное именование ролей, ресурсов и привилегий

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

Организация хранения списка доступа

Имея модель, можно по разному организовать ее хранение. Т.к. я использую ORM Doctrine 2, то эту модель очень просто отобразить в реляционную БД. Необходимо лишь правильно описать связи, что довольно не сложно поэтому я не буду тратить на это время реализацию можно посмотреть на Githube). Интереснее, как из этой модели, построить список доступа и какой список строить.
  • Полный список
  • Список по ролям
  • Список по ресурсам
  • Список по ролям и ресурсу
  • Список по ролям, ресурсу и привилегии
  • Остальные сочетания комбинаций из ролей, ресурсов и привилегий.
Здесь я намеренно отрекся от пользователей, т.к. нам нужны не пользователи, а роли, которыми они обладают. Такой подход позволит облегчит работу со списками доступа, и сделать систему независимой от способа привязки ролей к пользователям (рис. со способами привязки ролей к пользователям выше). А так же например, облегчить работу с кешированием, т.к. наборы ролей, ресурсов и привилегий будут условно-постоянными, в отличии от пользователей.

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

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

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

Вариант, когда известны и роль и ресурс и привилегия самый быстрый, т.к. фактически всю работу сделает СУБД, а нашему приложению достанется легкий объект.

Последние два варианта хорошо подходят для одиночных проверок, например при ajax-запросах. При обычных запросах, велика вероятность частых обращений к сервису авторизации, а следовательно и к БД. В этом случаем лучше использовать первый или второй вариант.

Так же не стоит сбрасывать со счетов остальные сочетания ролей, ресурсов и привилегий.

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

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

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

Приведу участок кода с комментариями, отвечающего за формирование такого запроса.
/**
 * Get all the ids of parents resources without recursion.
 *
 * @param string $resourceName Name of resource (For Example: News, DraftNews, ...)
 * @return array Of resource identifiers
 */
protected function _getResourceParentsId($resourceName)
{
    $result = array();

    // Получаем объект ресурса по его названию из репозитория объектов 
    // (если ранее объект уже выбирался, то физического запроса к БД не будет!)
    $resource = $this->_em->getRepository($this->resourceClass)->findOneByName($resourceName);

    if (!\is_object($resource))
    {
        return $result;
    }

    // Получаем идентификатор объекта и его уровень вложенности
    $resourceId = $resource->getId();
    $currentLevel = $resource->getLevel();
    $result = array($resourceId);
    if (0 >= $currentLevel)
    {
        return $result;
    }

    // Динамически строим запрос, с помощью билдера с учетом вложенности
    /* @var $qb \Doctrine\ORM\QueryBuilder */
    $qb = $this->_em->createQueryBuilder();
    $qb->select(array('r0.id r0_id', 'r1.id r1_id'))
            ->from($this->resourceClass, 'r0')
            ->leftJoin('r0.parent', 'r1')
            ->where($qb->expr()->eq('r0.id', '?1'))
            ->setParameter(1, $resourceId);

    for ($i = 1; $i < $currentLevel; $i++)
    {
        $qb->addSelect('r' . ($i + 1) . '.id r' . ($i + 1) . '_id');
        $qb->leftJoin('r' . $i . '.parent', 'r' . ($i + 1));
    }

    $query = $qb->getQuery();
    $result = $query->getArrayResult();

    // возвращаем результат в виде массива идентификаторов ресурсов
    return $result[0];
}

Теперь этот массив идентификаторов ресурсов можно уже включать в фильтр запроса формирующего список доступа. Дабы не загромождать статью не нужным кодом, приведу пример DQL (Doctrine Query Language) формирующего список доступа с учетом фильтров.

Query-билдер:
$qb->select(array('res', 'p', 'r', 'res_own'))
   ->from($this->resourceClass, 'res')
   ->leftJoin('res.owner', 'res_own')
   ->leftJoin('res.permissions', 'p', 'WITH', 'p.name = ?1')
   ->leftJoin('p.roles', 'r', 'WITH', $qb->expr()->in('r.id', $role))
   ->andWhere($qb->expr()->in('res.id', $resIds))
   ->setParameter(1, $permission);

Как пример DQL, с конкретными значениями:
SELECT res, p, r, res_own FROM \Xboom\Model\Domain\Acl\Resource res 
 LEFT JOIN res.owner res_own 
 LEFT JOIN res.permissions p WITH p.name = ?1
 LEFT JOIN p.roles r WITH r.id IN(1, 3)
 WHERE res.id IN(2, 5)

И соответствующий SQL-запрос, для тех кто захочет применить эту схему если не использует ORM Doctrine.
SELECT res.*, p.*, r.*, res_own.* FROM resources res 
LEFT JOIN users res_own ON res.owner_id = res_own.id 
LEFT JOIN permissions p ON res.id = p.resource_id AND (p.name = ?) 
LEFT JOIN role_permission r1 ON p.id = r1.permission_id 
LEFT JOIN roles r ON r.id = r1.role_id AND (r.id IN (?)) 
WHERE res.id IN (?)

В результате выборки, мы без проблем сможем сформировать Zend_Acl-объект. Опять же посмотреть конкретный код можно здесь

Срабатывание правил при определенных условиях.

Условия могут быть разные, но рассмотрим на примере самого распространенного - "Ограничение по владельцу". Для этого, с ресурсом должен быть связан владелец. А при вызове isAllowed на объекте Zend_Acl должен запоминаться пользователь, чьи права проверяются. Тогда можно использовать следующий Assert.

class IsOwnerAssertion implements \Zend_Acl_Assert_Interface
{
    /**
     *
     * @param  Zend_Acl                    $acl
     * @param  Zend_Acl_Role_Interface     $role
     * @param  Zend_Acl_Resource_Interface $resource
     * @param  string                      $privilege
     * @return boolean
     */
    public function assert(\Zend_Acl $acl, \Zend_Acl_Role_Interface $role = null,
            \Zend_Acl_Resource_Interface $resource = null, $privilege = null)
    {
        $userId = null;
        $ownerId = null;
        if (null !== $acl && null !== $acl->getRememberedUser())
        {
            if (null !== $resource && null !== $resource->getOwner())
            {
                $userId = $acl->getRememberedUser()->getId();
                $ownerId = $resource->getOwner()->getId();

                if (null !== $userId && null !== $ownerId && $userId === $ownerId)
                {
                    return true;
                }
            }
        }

        return false;
    }
}

Добиваем требования к подсистеме управления доступом

Из нашего списка требований осталось немного правил, таких как запрет наследования ролей или проверка сразу нескольких ролей. Это все просто делается через расширение базового класса Zend_Acl. Пример можно посмотреть здесь.

Примерный порядок работы с сервисом

  • Получаем сервис AclService
  • Запрашиваем экземпляр Zend_Acl передавая опционально массив ролей, ресурс или привилегию
  • Проверяем, разрешен ли доступ, при этом передаем [Роли тек. пользователя | Ресурс | Привилегию | Идентификацию пользователя]

$aclService = $this->getServiceContainer()->getService('AclService');
$acl = $aclService->getAcl($currentUserIdentity->getRoles());
if (! $acl->isAllowed($currentUserIdentity->getRoles(), 'Users', 'register', $currentUserIdentity))
{
    throw new AccessDeniedException('Access denied');
}

Ну и в конце приведу функциональный тест:

namespace test\Xboom\Model\Service\Acl;
use \Core\Model\Domain\User,
    \Core\Model\Domain\Group,
    \Xboom\Model\Domain\Acl\Resource,
    \Xboom\Model\Domain\Acl\Permission,
    \Xboom\Model\Domain\Acl\Role;

/**
 * @group functional
 */
class AclFunctionalTest extends \FunctionalTestCase
{
   
    protected $role;
    protected $emptyRole;
    protected $newsResource;
    protected $concreateNews;
    protected $viewPermission;
    protected $user;
    protected $aclService;

    public function setUp()
    {
        parent::setUp();

        $this->aclService = $this->_sc->getService('AclService');

        $resourceOwner = new User();
        $resourceOwner->name = 'testName'. \rand(1, 100);
        $resourceOwner->email = 'test@mail.ru';
        $resourceOwner->password = \md5($resourceOwner->name);
        $this->_em->persist($resourceOwner);
        $this->user = $resourceOwner;

        $this->newsResource = new Resource();
        $this->newsResource->name = 'News';
        $this->_em->persist($this->newsResource);

        $confirmNewsResource = new Resource();
        $confirmNewsResource->name = 'Confirm';
        $confirmNewsResource->setParent($this->newsResource);
        $this->_em->persist($confirmNewsResource);

        $this->concreateNews = new Resource();
        $this->concreateNews->name = 'News 1';
        $this->concreateNews->setOwner($resourceOwner);
        $this->concreateNews->setParent($confirmNewsResource);
        $this->_em->persist($this->concreateNews);

        $this->viewPermission = new Permission();
        $this->viewPermission->name = 'view';
        $this->viewPermission->setTypeAllow();
        $this->viewPermission->setResource($this->newsResource);
        $this->_em->persist($this->viewPermission);
       
        $permission2 = new Permission();
        $permission2->name = 'edit';
        $permission2->setTypeAllow();
        $permission2->setIsOwnerRestriction(true);
        $permission2->setResource($confirmNewsResource);
        $this->_em->persist($permission2);

        $this->role = new Role();
        $this->role->name = 'Role 1';
        $this->role->assignToPermission($this->viewPermission);
        $this->role->assignToPermission($permission2);
        $this->_em->persist($this->role);

        $this->emptyRole = new Role();
        $this->emptyRole->name = 'Role 2';
        $this->_em->persist($this->emptyRole);

        $this->_em->flush();
    }

    public function testAllThatIsNotAllowedThenDenieded()
    {
        $acl = $this->aclService->getAcl();
        $testRole = 1;
        $this->assertFalse($acl->isAllowed($testRole));
    }

    public function testChekcFromFullAcl()
    {
        $acl = $this->aclService->getAcl();

        $this->assertTrue(
                $acl->isAllowed(
                        $this->role,
                        $this->newsResource,
                        $this->viewPermission->getName()
                )
        );
    }

    public function testCheckByRole()
    {
        $acl = $this->aclService->getAcl($this->role->getId());

        $this->assertTrue(
                $acl->isAllowed(
                        $this->role->getId(),
                        $this->newsResource,
                        $this->viewPermission->getName()
                )
        );

        $acl = $this->aclService->getAcl($this->role);

        $this->assertTrue(
                $acl->isAllowed(
                        $this->role,
                        $this->newsResource,
                        $this->viewPermission->getName()
                )
        );
    }

    public function testCheckByRoles()
    {
        $roles = array(
            $this->role,
            $this->emptyRole,
        );

        $acl = $this->aclService->getAcl($roles);

        $this->assertTrue(
                $acl->isAllowed(
                        $roles,
                        $this->newsResource,
                        $this->viewPermission->getName()
                )
        );
    }

    public function testCheckByResource()
    {
        $acl = $this->aclService->getAcl(null, 'News');

        $this->assertTrue(
                $acl->isAllowed(
                        $this->role,
                        'News',
                        $this->viewPermission->getName()
                )
        );

        $acl = $this->aclService->getAcl(null, $this->newsResource);

        $this->assertTrue(
                $acl->isAllowed(
                        $this->role,
                        $this->newsResource,
                        $this->viewPermission->getName()
                )
        );
    }

    public function testCheckByPermission()
    {
        $acl = $this->aclService->getAcl(null, null, 'view');

        $this->assertTrue(
                $acl->isAllowed(
                        $this->role,
                        $this->newsResource,
                        'view'
                )
        );

        $acl = $this->aclService->getAcl(null, null, $this->viewPermission);

        $this->assertTrue(
                $acl->isAllowed(
                        $this->role,
                        $this->newsResource,
                        $this->viewPermission->getName()
                )
        );
    }

    public function testCheckByRoleAndResource()
    {
        $acl = $this->aclService->getAcl($this->role->getId(), 'News');

        $this->assertTrue(
                $acl->isAllowed(
                        $this->role->getId(),
                        'News',
                        'view'
                )
        );

        $acl = $this->aclService->getAcl($this->role, $this->newsResource);

        $this->assertTrue(
                $acl->isAllowed(
                        $this->role,
                        $this->newsResource,
                        $this->viewPermission->getName()
                )
        );
    }

    public function testCheckByRoleResourceAndPermission()
    {
        $acl = $this->aclService->getAcl($this->role->getId(), 'News', 'view');

        $this->assertTrue(
                $acl->isAllowed(
                        $this->role->getId(),
                        'News',
                        'view'
                )
        );

        $acl = $this->aclService->getAcl($this->role, $this->newsResource, $this->viewPermission);

        $this->assertTrue(
                $acl->isAllowed(
                        $this->role,
                        $this->newsResource,
                        $this->viewPermission->getName()
                )
        );
    }

    public function testInheritResource()
    {
        $acl = $this->aclService->getAcl($this->role, $this->concreateNews);

        $this->assertTrue(
                $acl->isAllowed(
                        $this->role,
                        $this->concreateNews,
                        'view'
                )
        );
    }

    public function testAssertionByOwner()
    {
        $acl = $this->aclService->getAcl($this->role);

        $this->assertTrue(
                $acl->isAllowed(
                        $this->role,
                        $this->concreateNews,
                        'edit',
                        $this->user
                )
        );

        $this->assertFalse(
                $acl->isAllowed(
                        $this->role,
                        $this->concreateNews,
                        'edit'
                )
        );
    }

}

Заключение

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

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

Рассмотрены основные понятия и термины модели RBAC. Разобрались с ролями, ресурсами и привилегиями.

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

Построена доменная модель на основе RBAC. Приведены рекомендации по формированию списков доступа на основе этой модели. Особое внимание уделено наследованию ресурсов.

В заключении приведен пример вызова сервиса ACL, получения списка доступа и проверки прав.

Весь код покрыт тестами и можно найти на Github'е

update:

На недавно прошедшей конференции PHPNW10 была презентация от Rowan Merewood по поводу практического применения Zend_Acl. Его выводы во многом совпадают с моими.

Ссылки по теме

Управление_доступом_на_основе_ролей
RBAC стандарт
Applying ACLs to Models
Extending Zend_Acl to support custom roles and resources
Zend_Acl часть 3: создание и хранение динамических ACL
Zend Framework: Zend_Acl & MVC Integration Component Proposal
ACL Плагин фронт контроллера
Создание объекта Acl по данным из MySQL
Zend_Acl + Zend_Auth + Zend_Controller_Plugin = HAPPY!
Zend_Db + Zend_Acl = ограничение доступа к записям || вопросы извращенца
CodeUtopia Article Zend_Acl

9 комментариев:

  1. Снова круто. Хоть личный опыт и предпочтения отразились на статье, но все равно в целом - очень круто.

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

    Насчет хранения списка правил доступа вообщем все что вы сказали, годиться в чистом виде для скорее простых случаев. Или, нужно будет городить всякие Асёршны на вроде того что вы привели для "private data" (IsOwnerAssertion), но это совсем не удобно, так как, цитирую "Жестко привязывать это в коде не правильно, вдруг изменится политика и гостям тоже будет разрешено видеть все новости."
    Нельзя так просто сунуть acl в отдельный сервис забыв о его связи непосредственно с данными.
    Нельзя просто так "сформировать объект Zend_Acl". Вернее можно, все те варианты что вы привели для выборки и формирования являются скорее хаками, или уловками, которые обязательно вас подведут с ростом сущностей/конкретных ресурсов/конкретных ролей в системе.

    А все потому что сам Репозиторий данных должен "знать" и о таком понятии как "Пользовательская сессия" и о том что можно возвращать этому пользователю а что нет. Наиболее полно интерфейс такого репозитория, по настоящему уникального изложен, в спецификации Java Content Repository. Использование этой спецификации было темой для дискуссии в группе разработчиков новой Cms на основе Symfony 2 и Doctrine 2. (http://groups.google.com/group/symfony-cms-devs/browse_thread/thread/4643fcf1acdb5f64). Хотя думаю можно и попроще наэкспериментировать и реализовать, просто что Zend_Acl для этого явно не подходит.

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

    Однако, повторю, статья эта - самое лучшее и подробное исследование возможностей Zend_Acl и с теоретической и с практической точки зрения и покрывающая тем или иным образом множество задач.

    ОтветитьУдалить
  2. Статья супер! Да Zend_Acl далек от совершенства, но для решения многих задач его конечно хватает.
    Кстати речь идет о http://cmf.symfony-project.org/ ?

    ОтветитьУдалить
  3. @lcf Спасибо за коммент, для меня услышать такой коммент именно от тебя очень ценно.

    Спасибо про JCR, когда искал как это сделано на яве, то лучшее что нашел это spring security, видимо потому, что JCR это не только разграничение прав, а иной подход к представлению информации, меняющий многое. Вещь слишком крутая для тех типов приложений которые под силу простым фрилансерам и скорее подходит для промышленных приложений, т.к. требует серьезной инфраструктуры.

    За проектом Symfony2 CMF буду следить, концепция интересная, жаль что пока что только в стадии обсуждения.

    ОтветитьУдалить
  4. Статья очень интересная, разбираюсь с реализацией, так как старая система управления доступа себя исчерпала и нужно реализовать что-то новое, свежее. Не совсем понятно из статьи, что же такое роли и как организовать доступ к данным. Если у меня, допустим, есть список филиалов конкретного предприятия, внутри которого существуют определенные взаимоотношения, типа, если пользователи в одном филиале, они могут изменять объекты друг друга, если нет, то только просматривать. А если мы создадим команду внутри предприятия, в которую могут входить пользователи из разных филиалов и назначить членам команды полный доступ к объектам друг друга? Они, с одной стороны, члены предприятия, с другой - члены команды. А группа (в терминах статьи) может быть установлена только одна. Как быть? Или описанный подход не позволяет распределять права в такой ситуации?

    ОтветитьУдалить
  5. 1. обозначения: 0..* - 0..1, это тоже самое, что many-to-one ?

    2. "Далее, каждая роль, отношением "many-to-many" связывается с привилегиями."
    Судя по схеме и тексту, под привилегиями имеется в виду "Permissions"? Просто permission, переводится как: позволение, разрешение.

    ОтветитьУдалить
  6. А выносить acl, не в сервисный слой, а в domain model, это тоже архитектурная ошибка? Спрашиваю, так как видел другой подход, когда acl помешали в domain model, в книге Zend Framework 1.8 by Keith Pope.

    ОтветитьУдалить
  7. Спасибо, классная статья. Не смог понять момент "Конкретные же ресурсы появятся уже во время работы приложения и связываются с конкретными объектами". Что такое конкретный ресурс и конкретный объект и как они связываются?

    ОтветитьУдалить
  8. А работали ли вы с СМИС - с системами мониторинга инженерных сооружений? Какое мнение о них?

    ОтветитьУдалить