The system of separation of access rights in a web application

    In this article we will go through a full cycle with you from the idea, designing a database, writing a PHP code, and final optimization. I will try to talk about everything as simple as possible. I will use PHP and Mysql for examples. At the same time I will train newcomers :).

    In this article I will touch upon the following questions:
      1. The idea of ​​ACL
      2. Database design
      3. Database normalization
      4. Code refactoring
      5. Optimization of the working code The

    article is a response to the Binary distribution of access rights in CMS . While the author is writing the practical part, I want to provide my version, which I have been using for quite some time.
    What I will tell you now looks like an ACL .

    Simplified description of the idea


    Access rights belong to all objects to which they must be applied.
    If we consider an example of a simple news page (which we will write here), then the access rights should have:
      1) The main news page - global access rights, meaning "create new news", "moderate news", "view the page itself."
      2) Each news - the ability to "edit the author of the news" or "do not leave comments."

    The access rights system consists of:
    {Group} + {Actions} or {Group} - {Actions}

    Group is a set of names that are:
      1) The rights of a specific user (for example, 'User1', 'User2' ...). For example, it is used for private messages to which this user has access or to allow editing only his messages on the site.
      2) Groups of private pages (or user groups) to which you need to give rights to certain actions. (for example, administrators, super moderators, etc.)
      3) Additional properties. (For example, a flag is a mode switch)

    Action is a set of actions that users with an existing {Group} can do. In our news system you can use:
      N - add a new topic
      D - delete a topic
      E - edit a topic
      V - see a topic
      C - leave a comment
      B - delete comment

    ± means to give the user with such rights or not to give (priority) access to the action. For example: Users + VC, Users-C = Users + V.

    Now let's look at an example of access rights for a simple news site:
    MainNewsPage object:
      Users + VC, Moderator + NEDB, Admin + NEDB NewsMessage
     object:
       User1 + ED (in principle, it’s not necessary if only moderators can add it)
       Users-C (you can use if you don’t want to leave comments)
      NewsComment:
        User2 + B object (and here it is necessary, since any user can leave a comment, but not everyone can delete them)

    Simplify the system for understanding the idea of ​​a computer


    To begin with, we define Databases, for work with the rights of objects.

    Since we get a list of several rights, we can start with such a database:
      RightsID - identifier of the list of rights.
      Group - the name of the group.
      Sign - a group sign.
      Action - the name of the action.

    Example1 (permissions for MainNewsPage):
    IDRightsidGroupSignAction
    1100Users+V
    2100Users+C
    3100Moderator+N
    4100Moderator+E
    5100Moderator+D
    6100Moderator+B
    7100Admin+N
    8100Admin+E
    9100Admin+D
    10100Admin+B

    Example2 (rights to NewsMessage):
    IDRightsidGroupSignAction
    eleven101User1+D
    12101User1+E
    thirteen101Users-C

    Now, if we ask for SELECT * FROM `rights_action` WHERE` RightsID` = 100, we get all the rights that belong to the object we need.

    Normalization of tables. Add user rights.


    The user who will view our page must have the rights that he owns. Based on them, we can know whether the user has the right to action.
    For example: User2, Users, Moderator.

    To do this, define the rights table:
      RightsID - identifier of the list of user rights.
      Group - the name of the group the user is a member of.

    Example:
    IDRightsidGroup
    110User1
    210Users
    310Moderator
    Now we bring both of our tables to normal form . ( wiki )

    As a result, the ID keys are eliminated, and we get 3 tables:
    rights_action - rights of the
      RightsID object : integer (pk) - identifier of the list of rights.
      GroupID: integer (pk) - the name of the group.
      Sign: tinyint (1) - the sign of the group.
      Action: enum (pk) - the name of the action.
    rights_group - user rights
      RightsID: integer (pk) - identifier of the list of user rights.
      GroupID: integer (pk) - identifier of the group the user is a member of.
    rights_names - GroupID names of groups
      : integer (pk)- group identifier.
      name - the name of the group.

    We replaced the Primary key 'ID' with other keys, which in some cases consist of several fields in the table.
    The sign of the group is now 0 (+) or 1 (-), because this way it will be easier for us to access them.
    The GroupID identifier directly points to the name in rights_names.
    In fact, the rights_names table is in our case an appendix that will not be used to identify rights to the necessary action. This table now serves only to “humanize” the results.

    An example of what we got:
    rights_name
    Groupidname
    10Users
    elevenModerator
    12Admin
    1001User1
    1002User2
    1003User3
    rights_group
    RightsidGroupid
    11001
    110
    1eleven
    rights_action
    RightsidGroupidSignAction
    10010 0message_view
    10010 0comment_create
    100eleven 0message_create
    100eleven 0message_edit
    100eleven 0message_delete
    100eleven 0comment_delete
    10012 0message_create
    10012 0message_edit
    10012 0message_delete
    10012 0comment_delete
    1011001 0message_edit
    1011001 0message_delete
    10110 1comment_create
    It has become less obvious - for a person. A computer that operates with numbers has become much easier to handle tables.
    Now we can add rights to any object in the table for any action. Actions are now written to the table in the form of ENUM (field 'action'), which simplifies the understanding and development of projects. The action itself is like a string and can be called as you like.

    `rights_group` should be tied to users and speaks about those rights which the user possesses.
    `rights_action` should be bound to objects and says with what rights, what actions the user can perform.

    For example (for our news site):
    news_page (parameters of the main news page)
    PageIDRightsidName
    1100News Page
    news_message (posts on the news page)
    MsgidPageIDRightsidHeaderMessage
    11101Hooray, we are on the main !!!But this is only the beginning, then, when we get closer to the administration of the Habr ...
    21101Last week newsDespite our orderly march along the main, it seems that plans have broken off ...

    Development of access rights library


    And now we will see what is necessary for these tables to be put together and we need to select the results we need.

    The algorithm of our actions when checking the possibility of action:
      1) We take from the database a selection of rights for the necessary object (s). (100: Users + VC, Moderator + NEDB, Admin + NEDB)
      2) Select the actions we need (action). (V: Users + V)
      3) Compare the access rights of the user and our selection. (Users, User1 <=> Users +)
      4) If there are no results, then return false.
      5) If the results are, but consist of minuses, return false. Otherwise, return true.

    Another point worth noting: permissions from parent (news page) go to child (in this case, to the message). That is, if you specify on the page + 'message_view', then all messages will automatically be with such rights (read). We will use and verify this circumstance in paragraph 1 of our algorithm.

    Let's move on to the implementation:
    Actually, we don’t need PHP in order to make the right selection. We will do everything on mysql.
    Paragraph 1.
    In our case, it is necessary to read the `rightsID` data from several objects and then select them from our table.
    Several objects, because we will have rights to the page and rights to individual posts on the page. The rights of both will complement each other. (child padding parent)
    For example, the rights of the message on the page:
    SELECT * FROM `rights_action` WHERE` RightsID` = 100 or `RightsID` = 101, where
    100 is the ID of the rights of the page
    101 - the ID of the rights of the message on the page
    In order to make it easier to communicate in PHP, a little optimizing the syntax:
    SELECT * FROM `rights_action` WHERE` RightsID` IN (100, 101)

    Item 2. Choosing the
    desired action is also simple:
    SELECT * FROM `rights_action` WHERE` action` = 'message_view'

    Item 3.
    A here it is necessary to combine several SELECTs. First, select the user's access rights, and then compare them with the ones we need. If you combine these actions into one, you get:
    SELECT * FROM `rights_action` WHERE` GroupID` IN (SELECT `GroupID` FROM` rights_group` WHERE `RightsID` = 1)

    Clause 1-3.
    Now all together in one complex query:
    SELECT * FROM `rights_action` WHERE` RightsID` IN (100, 101) AND `action` = 'message_view' AND` GroupID` IN (SELECT `GroupID` FROM` rights_group` WHERE `RightsID` = 1)
    This example takes user rights # 1, rights of objects # 100 and # 101, the action 'view messages' (message_view) and produces the result (signs + and -).

    Item 1-5.
    Paste this into the PHP implementation and at the same time add a check:
      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.
    This is only the beginning, the first step.

    Create an access rights class


    What do we need?
    We will develop an example of working with our class.
      1) First, you must specify the rights of the user whose rights we need to work with. And the class itself needs to be bound to a specific user.
      2) Adding objects to the class of child rights with the addition of properties.
      3) Check access for various actions (action).

    Remarks and thoughts:
    User rights can be specified when creating a class in __construct.
    When adding new properties so that properties in the class are not lost, it will be necessary to make a new class (clone the old one with the addition of properties).

    Let's try to implement all this:
    class Rights{
      private $usrID;//User rights ID

      function __construct($user_rightsID){
        $this->usrID=$user_rightsID;
      }  
    }
    Now you can use the construct:
    $UserRights = new Rights($CurrentUser->rightsID);
    and use $ UserRights in a further program.

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

      //... constructor
    }
    Let me remind you that we do not need to spoil the object, because we will add rights from different messages (child) to it (parent).

    Now we rewrite our check function , introducing it into the class, and see what happened:
    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.
    What we have done now (redid the function into a class, making it more convenient to use and more universal) is called Refactoring . ( wiki )

    Now this class can be used like this:
    //Создаём права пользователя
    $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);
        }
      }
    }
    where $ CurrentUser is the structure of the user who is viewing the page.
    $ MainPage - The page structure the user is viewing.
    $ MainPage-> Messages - an array of messages that are displayed on the page.
    Structures, previously, were read from the database.

    Optimization


    We are pleased with the quality and functionality of the library, but the question of productivity arises.

    The first thing that catches your eye is that with each check with a new 'action', a non-feeble SQL query occurs. Let's try to fix it.

    First, let's see what does not change from request to request, and we optimize this.
    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.)

    Firstly, each time a SELECT query `GroupID` FROM` rights_group` WHERE `RightsID` = = .... Let's get started with it.
    When declaring UserRights, we will run this query once, and the result will already be inserted into our SQL query.
      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);    
      }  
    Now in $ this-> usrID there is a ready-made string that can be directly inserted into the query instead of the whole SELECTa.

    It has already become easier, but still there is a search throughout the database for each request. How can we get rid of this? Most likely, create a preliminary result that depends only on action - because the selection of `RightsID` and` GroupID` remains unchanged.

    When a group of objects is added, we read all the results from the database into an array, which will depend only on the values ​​of 'action'.
    SELECT * FROM `rights_action` WHERE `RightsID` IN (...) AND `GroupID` IN (...)

    Next, already sorting through each 'action' in the array, we are looking for the necessary element. At the same time, there are no more queries in the database - until the next object with new rights.

    As a result of optimization, our class will look like this:
    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.

    Is it even faster?


    Yes you can.
    1) If you consider, for example, empty groups of rights for messages (child), which will not change our already used temporary table. In this case, we can use it without creating anew. And for verification, we need to add only one more SELECT count (*) FROM `action_rights` WHERE` GroupID` = ..., which will go through the index and return the result.

    2) Correctly arrange the indexes in the tables `action_rights` and` group_rights`.
    I'm not sure here. Experts hope they correct me. Personally made PK - 'rightsID', 'action', 'groupID', INDEX - 'groupID', 'rightsID'

    3) After creating the Temporary Table, add an index to it by 'action': ALTER TABLE `{$ this-> temptable } `ADD INDEX (` action`). True i'm not sure that this method is also effective. Experts, please devote.
    :)

    4) Use the cache. But that's another story :)

    Working example


    I think that is enough code for today. Here's how it works: a
    working example - I apologize for the lack of clarity.
    test.php (working example) - my libraries that work with SQL databases are used here, do not be surprised. I am sure that you will understand.
    rights.php is our library.

    Extensibility


    Any new actions that you will use in your project are added to the 'action' ENUM.

    If you do not want to be attached to specific actions and add them in real time, then you should replace the 'action' ENUM with an integer and create another actionID correspondence table with action_name. (as we did with the names of the Groups)

    Update: There is a continuation: Optimization of the system of separation of access rights in a web application

    Also popular now: