Pull to refresh

Система разделения прав доступа в веб-приложении

Reading time14 min
Views69K
В этой статье мы пройдём с вами полный цикл от идеи, проектирования БД, написания PHP-Кода, и завершающей оптимизации. Постараюсь рассказать обо всем, как можно проще. Использовать для примеров буду PHP и Mysql. Заодно потренирую новичков :).

В этой статье я коснусь вопросов:
  1. Идея ACL
  2. Проектирование БД
  3. Нормализация БД
  4. Рефакторинг кода
  5. Оптимизация рабочего кода

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

Упрощенное описание идеи


Права доступа принадлежат ко всем объектам, к которым необходимо их применять.
Если рассматривать пример простой страницы новостей (которую мы с вами здесь напишем), то права доступа должны иметь:
  1) Основная страница новостей — глобальные права доступа, означающие «создание новой новости», «модерирование новостей», «просмотр самой страницы».
  2) Каждая новость — возможности «редактировать автором новости» или «не оставлять комментарии».

Система прав доступа состоит из:
{Group}+{Actions} или {Group}-{Actions}

Group — это набор имен, которые из себя представляют:
  1) Права конкретного пользователя (например 'User1', 'User2'...). Например используется для личных сообщений, к которым этот пользователь имеет доступ или для допуска редактирования только его сообщений на сайте.
  2) Группы приватных страниц (или групп пользователей), к которым необходимо дать права на определенные действия. (например, администраторами, супермодераторами и др.)
  3) Дополнительные свойства. (Например, флаг — переключатель режимов)

Action — набор действий, которые пользователи с имеющимся {Group} могут делать. В нашей системе новостей можно использовать:
  N — добавлять новую тему
  D — удалять тему
  E — редактировать тему
  V — видеть тему
  C — оставлять комментарий
  B — удалять комментарий

± означает давать пользователю с такими правами или не давать (приоритетно) доступ к действию. Например: Users+VC, Users-C = Users+V.

Теперь рассмотрим пример прав доступа, для простого сайта новостей:
Объект MainNewsPage:
  Users+VC, Moderator+NEDB, Admin+NEDB
 Объект NewsMessage:
   User1+ED (в принципе не нужно, если добавлять могут только модераторы)
   Users-C (можно использовать, если нет желания оставлять комментарии)
  Объект NewsComment:
    User2+B (а здесь необходимо, так как комментарий может оставлять любой пользователь, но не все могут удалять их)

Упростим систему, для понимания идеи компьютером


Для начала, определим Базы данных, для работы с правами объектов.

Так как у нас получается список нескольких прав, то можно начать с такой БД:
  RightsID — идентификатор списка прав.
  Group — название группы.
  Sign — знак группы.
  Action — название действия.

Пример1 (права для MainNewsPage):
ID RightsID Group Sign Action
1 100 Users + V
2 100 Users + C
3 100 Moderator + N
4 100 Moderator + E
5 100 Moderator + D
6 100 Moderator + B
7 100 Admin + N
8 100 Admin + E
9 100 Admin + D
10 100 Admin + B

Пример2 (права для NewsMessage):
ID RightsID Group Sign Action
11 101 User1 + D
12 101 User1 + E
13 101 Users - C

Теперь, если мы запросим SELECT * FROM `rights_action` WHERE `RightsID`=100, то получим все права, которые принадлежат необходимому нам объекту.

Нормализация таблиц. Добавляем права пользователя.


У пользователя, который будет просматривать нашу страницу, должны иметься права, которыми он владеет. Исходя из них, мы сможем знать, имеет ли пользователь право на действие.
Например: User2, Users, Moderator.

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

Пример:
ID RightsID Group
1 10 User1
2 10 Users
3 10 Moderator
Теперь приведем обе наши таблицы к нормальной форме. (wiki)

В результате ID ключи отсеются, и мы получим 3 таблицы:
rights_action — права объекта
  RightsID: integer (pk) — идентификатор списка прав.
  GroupID: integer (pk) — название группы.
  Sign: tinyint (1) — знак группы.
  Action: enum (pk) — название действия.
rights_group — права пользователя
  RightsID: integer (pk) — идентификатор списка прав пользователя.
  GroupID: integer (pk) — идентификатор группы, в которой пользователь состоит.
rights_names — названия групп
  GroupID: integer (pk) — идентификатор группы.
  name — название группы.

Primary key 'ID' мы заменили на другие ключи, состоящие в некоторых случаях из нескольких полей таблицы.
Знак группы теперь 0 (+) или 1 (-), потому что так нам будет проще к ним обращаться.
Идентификатор GroupID прямиком указывает на название в rights_names.
На самом деле таблица rights_names является в нашем случае аппендиксом, который не будет использоваться для выявления прав на необходимое действие. Эта таблица теперь служит лишь для «Очеловечивания» результатов.

Пример, что у нас получилось:
rights_name
GroupID name
10 Users
11 Moderator
12 Admin
1001 User1
1002 User2
1003 User3
rights_group
RightsID GroupID
1 1001
1 10
1 11
rights_action
RightsID GroupID Sign Action
100 10  0 message_view
100 10  0 comment_create
100 11  0 message_create
100 11  0 message_edit
100 11  0 message_delete
100 11  0 comment_delete
100 12  0 message_create
100 12  0 message_edit
100 12  0 message_delete
100 12  0 comment_delete
101 1001  0 message_edit
101 1001  0 message_delete
101 10  1 comment_create
Стало менее наглядно — для человека. Компьютеру, который оперирует числами, стало намного проще обращаться с таблицами.
Теперь мы можем добавить права любому объекту в таблице на любое действие. Действия теперь записываются в таблицу в виде ENUM (поле 'action'), что упрощает понимание и разработку проектов. Само действие как string и может называться, как угодно.

`rights_group` должна быть привязана к пользователям и говорит о тех правах, которыми пользователь обладает.
`rights_action` должна быть привязана к объектам и говорит, с каким правами, какие действия пользователь может выполнять.

Например (для нашего сайта новостей):
news_page (параметры основной страница с новостями)
PageID RightsID Name
1 100 Страница новостей
news_message (сообщения на странице новостей)
MsgID PageID RightsID Header Message
1 1 101 Ура, мы на главной!!! Но это только начало, дальше, когда мы ближе подберемся к администрации хабра...
2 1 101 Новости последней недели Не смотря на наше стройное шествие по главной, похоже планы обломались...

Разработка библиотеки работы с правами доступа


А сейчас мы посмотрим, что необходимо, чтобы эти таблицы собрать воедино и произвести необходимую нам выборку результатов.

Алгоритм наших действий при проверке возможности действия:
  1) Берем из БД выборку прав по необходимому объекту (объектам). (100: Users+VC, Moderator+NEDB, Admin+NEDB)
  2) Выбираем необходимые нам действия (action). (V: Users+V)
  3) Сравниваем права доступа пользователя и нашей выборки. (Users, User1 <=> Users+)
  4) Если результатов нет, тогда возвращаем false.
  5) Если результаты есть, но состоят из минусов, возвращаем false. Иначе возвращаем true.

Ещё один момент, который стоит заметить: права доступа от parent (страницы новостей) переходят к child (в данном случае, к сообщению). То есть, если указать на странице +'message_view', то все сообщения автоматически будут с такими правами (чтения). Это обстоятельство мы будем использовать и проверять в пункте 1 нашего алгоритма.

Перейдем к реализации:
На самом деле нам сейчас не требуется PHP для того, чтобы сделать правильную выборку. Всё сделаем на mysql.
Пункт 1.
В нашем случае необходимо считать данные `rightsID` из нескольких объектов и затем выбрать их из нашей таблицы.
Несколько объектов, потому что у нас будут права на страницу и права на отдельных сообщений на странице. Права обоих будут дополнять друг друга. (child дополнять parent)
Например, права сообщения на странице:
SELECT * FROM `rights_action` WHERE `RightsID`=100 or `RightsID`=101 , где
100 — ID прав страницы
101 — ID прав сообщения на странице
Для того, чтобы было проще общаться на PHP, немного оптимизируем синтаксис:
SELECT * FROM `rights_action` WHERE `RightsID` IN (100, 101)

Пункт 2.
С выбором необходимого нам действия, тоже всё просто:
SELECT * FROM `rights_action` WHERE `action`= 'message_view'

Пункт 3.
А вот здесь придется объединить несколько SELECTов. Сначала выбираем права доступа пользователя, а затем сравниваем их с необходимыми нам. Если объединить эти действия в одно, получится:
SELECT * FROM `rights_action` WHERE `GroupID` IN (SELECT `GroupID` FROM `rights_group` WHERE `RightsID` = 1)

Пункт 1-3.
Теперь всё вместе одним сложным запросом:
SELECT * FROM `rights_action` WHERE `RightsID` IN (100, 101) AND `action`= 'message_view' AND `GroupID` IN (SELECT `GroupID` FROM `rights_group` WHERE `RightsID` = 1)
этот пример, берет права пользователя №1, права объектов №100 и №101, действие 'просмотр сообщений' (message_view) и выдаёт результат (знаки + и -).

Пункт 1-5.
Вставим это в PHP реализацию и заодно добавим проверку:
  function check(/*array(int,int,...)*/ $obj_rights, /*integer*/ $user_rightsID, /*string*/ $action){
    $result = mysql_query("SELECT * FROM `rights_action` WHERE `RightsID` IN (". implode(",",$obj_rights) .") AND `action`= '$action' AND `GroupID` IN (SELECT `GroupID` FROM `rights_group` WHERE `RightsID` = $user_rightsID)");

    if (!$result)
      return false;

    $tmp=array();
    while ($t = mysql_fetch_assoc($result)){
      //В каждую из найденных групп (Users, User1, Moderator) пользователей
      //заполняем + (0) или - (1) (с приоритетом).
      if (!isset($tmp[$t['groupID']]))
        $tmp[$t['groupID']] = $t['sign'];
      else
        $tmp[$t['groupID']] |= $t['sign'];
    }
    mysql_free_result($result);
    
    if ($tmp)
      //Если нашли + в любой из групп, возвращаем true. Иначе false.
      return (array_search(0, $tmp) !== FALSE);

    //Не нашли ни одного результата $tmp == false
    return false;
  }

* This source code was highlighted with Source Code Highlighter.
Это лишь начало, первый шаг.

Создаём класс работы с правами доступа


Что нам необходимо?
Разработаем пример работы с нашим классом.
  1) Во-первых, необходимо указать права пользователя, с правами которого нам надо работать. А сам класс необходимо привязать к конкретному пользователю.
  2) Добавление в класс child-прав объектов с дополнением свойств.
  3) Проверка доступа по различным действиям (action).

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

Давайте попробуем это всё реализовать:
class Rights{
  private $usrID;//User rights ID

  function __construct($user_rightsID){
    $this->usrID=$user_rightsID;
  }  
}
Теперь можно использовать конструкцию:
$UserRights = new Rights($CurrentUser->rightsID);
и использовать $UserRights в дальнейшей программе.

Рассмотрим добавление права объекта для проверки:
class Rights{
  private $group=array();//Сначала нет никаких объектов сравнения
  
  function include_right($grp){
    $clone=clone $this;//Чтобы не запортить объект, клонируем его
    $clone->group[]=$grp;//Добавляем права клону
    return $clone;
  }

  //... constructor
}
Напомню, что портить объект нам не нужно, потому что мы будем к нему (parent) добавлять права от разных сообщений (child).

Теперь перепишем нашу функцию check, введя её в класс, и посмотрим что получилось:
class Rights{
  private $usrID;//User rights ID
  private $group=array();//Сначала нет никаких объектов сравнения

  function __construct($user_rightsID){
    $this->usrID=$user_rightsID;
  } 
  
  function include_right($grp){
    $clone=clone $this;//Чтобы не запортить объект, клонируем его
    $clone->group[]=$grp;//Добавляем права клону
    return $clone;
  }

  function check($action){
    $result = mysql_query("SELECT * FROM `rights_action` WHERE `RightsID` IN (". implode(",",$this->group) .") AND `action`= '$action' AND `GroupID` IN (SELECT `GroupID` FROM `rights_group` WHERE `RightsID` = ". $this->usrID .")");

    if (!$result)
      return false;

    $tmp=array();
    while ($t = mysql_fetch_assoc($result)){
      //В каждую из найденных групп (Users, User1, Moderator) пользователей
      //заполняем + (0) или - (1) (с приоритетом).
      if (!isset($tmp[$t['groupID']]))
        $tmp[$t['groupID']] = $t['sign'];
      else
        $tmp[$t['groupID']] |= $t['sign'];
    }
    mysql_free_result($result);
  
    if ($tmp)
      //Если нашли + в любой из групп, возвращаем true. Иначе false.
      return (array_search(0, $tmp) !== FALSE);

    //Не нашли ни одного результата $tmp == false
    return false;
  }
}


* This source code was highlighted with Source Code Highlighter.
То, что мы сейчас с вами проделали (переделали функцию в класс, сделав её удобнее в использовании и универсальнее) называется Рефакторинг. (wiki)

Сейчас этот класс можно использовать вот так:
//Создаём права пользователя
$UserRights = new Rights($CurrentUser->rightsID);

//Добавляем права объекта, с которыми должен иметь дело пользователь.
$PageRights = $UserRights->include_right($MainPage->rightsID);

//Проверяем, может ли пользователь просматривать страницу?
if ($PageRights->check('messages_view')){
  //Да, может. Но что делать с сообщениями?

  //Пройдемся по каждому из них
  foreach($MainPage->Messages as $msg){
    //Добавляем к правам страницы (parent), личные права сообщения (child)
    $MsgRights = $PageRights->include_right($msg->rightsID);

    //И проверяем на читаемость
    if ($MsgRights->check('messages_view')){
      //И если оно читается, проверяем можем ли мы редактировать сообщения?
      if ($MsgRights->check('messages_edit'))
        $msg->editable_flag = 1;
      //А удалять сообщения?
      if ($MsgRights->check('messages_delete'))
        $msg->delete_flag = 1;
    
      DrawMessage($msg);
    }
  }
}
где $CurrentUser — структура пользователя, который смотрит страницу.
$MainPage — структура страницы, который смотрит пользователь.
$MainPage->Messages — массив сообщений, которые выводятся на странице.
Структуры, предварительно, были считаны из БД.

Оптимизация


Качеством и функциональностью библиотеки мы с вами довольны, но возникает вопрос производительности.

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

Для начала посмотрим, что от запроса к запросу не меняется и оптимизируем это.
SELECT * FROM `rights_action` WHERE `RightsID` IN ( .implode(",",$this->group). ) AND `action`= '$action' AND `GroupID` IN (SELECT `GroupID` FROM `rights_group` WHERE `RightsID` = .$this->usrID. )

Во-первых, каждый раз происходит запрос SELECT `GroupID` FROM `rights_group` WHERE `RightsID` = .... Давайте с него и начнем.
При объявлении UserRights проведем этот запрос один раз, а результат уже будем вставлять в наш SQL-запрос.
  function __construct($grp){
    $result=mysql_query("SELECT `group_rights`.groupID FROM `group_rights` WHERE `group_rights`.rightsID=$grp");

    $this->usrID=array();
    while ($t=mysql_fetch_assoc($result)){
      $this->usrID[]=$t['groupID'];
    }
    mysql_free_result($result);
    
    $this->usrID=implode(",",$this->usrID);    
  }  
Теперь в $this->usrID лежит готовая строка, которую можно пряма вставлять в запрос вместо целого SELECTa.

Уже стало легче, но все равно происходит поиск по всей БД каждый запрос. Как нам от этого можно избавиться? Вероятнее всего, создать предварительный результат, зависящий только от action — потому что выборка `RightsID` и `GroupID` остается неизменной.

Когда добавляется группа объектов, считываем все результаты из БД в массив, который будет зависеть лишь от значений 'action'.
SELECT * FROM `rights_action` WHERE `RightsID` IN (...) AND `GroupID` IN (...)

Далее, уже перебором по каждому 'action' в массиве, ищем необходимый элемент. При этом запросов в БД больше нет — до следующего объекта с новыми правами.

В результате оптимизации, наш класс будет выглядеть вот так:
class Rights{
  private $group="";
  private $usrID=array();
  private $temptable="";
  
  function include_right($grp){
    $clone=clone $this;
    $clone->group[]=$grp;

    $result=mysql_query("SELECT * FROM `action_rights` WHERE `action_rights`.groupID IN ({$this->usrID}) AND `action_rights`.rightsID IN (".implode(",",$clone->group).")");
    $tmp=array();
    while ($t=mysql_fetch_assoc($result)){
      $tmp[]=$t;
    }
    mysql_free_result($result);
    
    $clone->temptable=$tmp;
    
    return $clone;
  }
  
  function check($action){
    $tmp=array();
    foreach ($this->temptable as $t){
      if ($t['action']==$action){
        if (!isset($tmp[$t['groupID']]))
          $tmp[$t['groupID']]=$t['sign'];
        else
          $tmp[$t['groupID']]|=$t['sign'];
      }
    }

    if ($tmp){
      return (array_search(0,$tmp)!==FALSE);
    }
    return false;
  }
  
  function __construct($grp){
    $result=mysql_query("SELECT `group_rights`.groupID FROM `group_rights` WHERE `group_rights`.rightsID=$grp");

    $this->usrID=array();
    while ($t=mysql_fetch_assoc($result)){
      $this->usrID[]=$t['groupID'];
    }
    mysql_free_result($result);
    
    $this->usrID=implode(",",$this->usrID);    
  }  
}


* This source code was highlighted with Source Code Highlighter.

Можно ли ещё быстрее?


Да, можно.
1) Если учитывать, например пустые группы прав у сообщений (child), которые не поменяют нашу уже используемую временную таблицу. В этом случае мы можем использовать её, не создавая заново. А для проверки, нам необходимо добавить лишь ещё один SELECT count(*) FROM `action_rights` WHERE `GroupID` =…, который пройдётся по индексу и вернет результат.

2) Правильно расставить индексы в таблицах `action_rights` и `group_rights`.
Тут я не уверен. Эксперты меня надеюсь, поправят. Лично сделал PK — 'rightsID', 'action', 'groupID', INDEX — 'groupID', 'rightsID'

3) После создания Temporary Table, добавлять в неё индекс по 'action': ALTER TABLE `{$this->temptable}` ADD INDEX ( `action` ).
Правда я не уверен, что этот способ тоже действенен. Эксперты, посвятите пожалуйста. :)

4) Использовать кеш. Но это уже другая история :)

Работающий пример


Я думаю, что уже достаточно на сегодня кода. Вот как это работает:
работающий пример — извиняюсь за не наглядность.
test.php (рабочий пример) — здесь используются мои библиотеки, которые работают с SQL БД, не удивляйтесь. Уверен, что разберетесь.
rights.php — наша библиотека.

Расширяемость


Любые новые действия, которые вы будете использовать в вашем проекте добавляются в 'action' ENUM.

Если вы не хотите быть привязанным к конкретным действиям и добавлять их в реальном времени, то стоит заменить 'action' ENUM на integer и создать ещё одну таблицу соответствий actionID с action_name. (как мы сделали это с названиями Групп)

Update: Появилось продолжение: Оптимизации системы разделения прав доступа в веб-приложении
Tags:
Hubs:
+48
Comments103

Articles