📜 ⬆️ ⬇️

The system of separation of access rights in the web application

In this article, we will go with you a full cycle of ideas, database design, writing PHP-code, and the final optimization. I will try to tell everything as easy as possible. I will use PHP and Mysql for examples. At the same time I will train beginners :).

In this article I will touch on the questions:
1. The idea of ​​ACL
2. Designing the database
3. Normalization of DB
4. Code refactoring
5. Optimization of working code

The article is a response to the binary distribution of access rights in the CMS . While the author is writing the practical part, I want to provide my version, which I have been using for a long time.
What I am about to tell is similar to an ACL .
')

Simplified description of the idea


Access rights belong to all objects to which you want to apply them.
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 “creating new news”, “moderating news”, “viewing the page itself”.
2) Each news - the ability to "edit news by the author" 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 particular user (for example, 'User1', 'User2' ...). For example, it is used for private messages to which this user has access or to allow editing of only his messages on the site.
2) Groups of private pages (or groups of users) to which it is necessary to give rights to certain actions. (for example, administrators, supermoderators, etc.)
3) Additional properties. (For example, the flag - mode switch)

Action - a set of actions that users with the existing {Group} can do. In our news system you can use:
N - add new topic
D - delete topic
E - edit topic
V - see the 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 consider an example of permissions for a simple news site:
MainNewsPage object:
Users + VC, Moderator + NEDB, Admin + NEDB
NewsMessage object:
User1 + ED (in principle, it is not necessary if only moderators can add)
Users-C (can be used if you do not want to leave comments)
NewsComment object:
User2 + B (and here it is necessary, since any user can leave a comment, but not everyone can delete them)

Simplify the system to understand the idea of ​​a computer


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

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

Example1 (rights for MainNewsPage):
IDRightsidGroupSignAction
one100Users+V
2100Users+C
3100Moderator+N
four100Moderator+E
five100Moderator+D
6100Moderator+B
7100Admin+N
eight100Admin+E
9100Admin+D
ten100Admin+B

Example2 (rights for NewsMessage):
IDRightsidGroupSignAction
eleven101User1+D
12101User1+E
13101Users-C

Now, if we request SELECT * FROM `rights_action` WHERE` RightsID` = 100 , then 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, we define the table of rights:
RightsID - user rights list identifier.
Group - the name of the group in which the user is a member.

Example:
IDRightsidGroup
onetenUser1
2tenUsers
3tenModerator
Now we will bring both our tables to normal form . ( wiki )

As a result, ID keys will be eliminated, and we will get 3 tables:
rights_action - object rights
RightsID: integer (pk) - identifier of the list of rights.
GroupID: integer (pk) - group name.
Sign: tinyint (1) is a group sign.
Action: enum (pk) - the name of the action.
rights_group - user rights
RightsID: integer (pk) - user rights list identifier.
GroupID: integer (pk) - identifier of the group in which the user is a member.
rights_names - group names
GroupID: 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 table fields.
The group sign is now 0 (+) or 1 (-), because it will be easier for us to access them.
The GroupID identifier directly points to the name in rights_names.
In fact, the table of rights_names is in our case an appendix that will not be used to identify the rights to the necessary action. This table now serves only for the “Humanization” of the results.

An example of what we got:
rights_name
GroupIDname
tenUsers
elevenModerator
12Admin
1001User1
1002User2
1003User3
rights_group
RightsidGroupID
one1001
oneten
oneeleven
rights_action
RightsidGroupIDSignAction
100ten0message_view
100ten0comment_create
100eleven0message_create
100eleven0message_edit
100eleven0message_delete
100eleven0comment_delete
100120message_create
100120message_edit
100120message_delete
100120comment_delete
10110010message_edit
10110010message_delete
101tenonecomment_create
It became less obvious - for a man. A computer that operates on numbers has made it much easier to handle tables.
Now we can add rights to any object in the table for any action. Actions are now recorded in a table in the form of ENUM ('action' field), which simplifies the understanding and development of projects. The action itself is as a string and can be called anything.

`rights_group` should be tied to users and speaks about the rights that the user has.
`rights_action` should be tied to objects and say with what rights, what actions the user can perform.

For example (for our news site):
news_page (main news page options)
PageIDRightsidName
one100News page
news_message (messages on the news page)
MsgIDPageIDRightsidHeaderMessage
oneone101Hooray, we are on the main !!!But this is only the beginning, then when we get closer to the administration of Habr ...
2one101Last week's newsDespite our slender procession on the main, it seems the plans have broken off ...

Development of the library of work with access rights


And now we will look at what is needed in order to put these tables together and produce a sample of the results we need.

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

Another point worth noting: access rights from the parent (news pages) go to the 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). This circumstance we will use and check in paragraph 1 of our algorithm.

Let's proceed to the implementation:
In fact, we do not need PHP right now to make the right sample. 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 it from our table.
Several objects, because we will have the rights to the page and the rights to individual messages on the page. The rights of both will complement each other. (child add parent)
For example, the rights of the message on the page:
SELECT * FROM `rights_action` WHERE` RightsID` = 100 or `RightsID` = 101 , where
100 - page ID
101 - ID of the message rights on the page
In order to make it easier to communicate in PHP, we slightly optimize the syntax:
SELECT * FROM `rights_action` WHERE` RightsID` IN (100, 101)

Point 2.
With the choice of the action we need, everything is also simple:
SELECT * FROM `rights_action` WHERE` action` = 'message_view'

Point 3.
And here it is necessary to combine several SELECTs. First, we 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)

Item 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 gives the result (signs + and -).

Item 1-5.
Insert this into the PHP implementation and at the same time add the 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 a class working with access rights


What do we need?
Develop an example of working with our class.
1) First, you must specify the rights of the user, with the rights of which we need to work. And the class itself must be tied 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).

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

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

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

Consider adding the right object to check:
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 to it (parent) rights from different messages (child).

Now we will rewrite our check function by entering it into the class, and see what happens:
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 with you now (redone the function into a class, making it easier to use and more versatile) 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 views the page.
$ MainPage - the structure of the page that the user is watching.
$ 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 performance arises.

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

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

First, every time a SELECT `GroupID` FROM` rights_group` WHERE `RightsID` = ... query occurs. Let's start 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 all the same, a search is performed on the entire database every request. How can we get rid of it? Most likely, create a preliminary result that depends only on the 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 the array, which will depend only on the values ​​of the 'action'.
SELECT * FROM `rights_action` WHERE `RightsID` IN (...) AND `GroupID` IN (...)

Further, already search for each 'action' in the array, we are looking for the necessary element. At the same time, there are no more queries to 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 we take into account, for example, empty rights groups for messages (child) that do not change our already used temporary table. In this case, we can use it without creating it again. And for verification, we need to add just 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 `action_rights` and` group_rights` tables.
I'm not sure here. I hope the experts will 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, dedicate please. :)

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 not visualizing.
test.php (working example) - my libraries that work with SQL database are used here, don't be surprised. I'm sure you figure it out.
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 tied to specific actions and add them in real time, then you should replace the 'action' ENUM with an integer and create another corresponding actionID table with action_name. (as we did with the names of the Groups)

Update: Continued: Optimization of the system of separation of access rights in the web application

Source: https://habr.com/ru/post/51327/


All Articles