Protecting the CRM management from CRM users

We have differentiated the CRM manager and CRM user roles since the very beginning in our specifications for the application. So far, each acceptance test began with some work on the side of database management and ended with either usage of public interface or checking some assumptions right in the management UI.

Now, it's time to really prohibit the CRM user from accessing the database management UI pages. We are going to implement the following business ruleset:

  • Unauthenticated (guest) users should not be able to access anything except the home page and the login form.
  • User-level users should be able to access the Query Customer By Phone UI.
  • Manager-level users should be able to access everything except the User Management UI.
  • Administrator-level users should be able to access everything.

Have a look at the following scheme:

Protecting the CRM management from CRM users

We already have tests for the login and logout functionality without testing for access rights afterwards. However, now we are forcing users of our application to log in first. This would mean that in all our existing acceptance tests, the first step should be the logging in one as some users have enough rights to do what the test needs to do.

It is incredibly hard to do proper end-to-end tests, as it'll require not only using the corresponding admin UI to generate entities for manipulation with which we want to test, but also using the root-level administration UI to create the user and grant it with enough rights to perform such manipulations. Not even saying that we need a working UI to assign access rights (that is, roles) to users, this is too much of a preliminary work to tolerate. However, such a course of actions is preferable for extremely thorough end-to-end tests, ensuring that every part of the application works without missing anything important.

Installing predefined users

In this example, we will trade the time required for preparing the users to hold them in the database right from the start. Instead of creating a user of sufficient caliber for each individual test, we'll predefine one user for each role in the database right from the start, and our acceptance tests will use credentials of those users for logging in before doing anything. Of course, it will not relieve us from the task of actually making the logic to login before each action.

Here are the users that should be created (passwords are arbitrary high-entropy phrases that are easy to remember):

Username

Password

Role name

No username, default role

No password

guest

JoeUser

7 wonder @ American soil

user

AnnieManager

Shiny 3 things hmm, vulnerable

manager

RobAdmin

Imitate #14th syndrome of apathy

admin

Tip

You can get a nice breakdown of the password strength at http://www.passwordmeter.com/. Currently, the most important trait of a good password is the length and the types of characters used in it (see for example this note from Microsoft: http://www.microsoft.com/en-gb/security/online-privacy/passwords-create.aspx). We deliberately chose simple phrases with uppercase and lowercase letters, numbers, and special symbols in them. Note that we don't have any reason to exclude the space symbol from our passwords.

Create a migration to add them to the database:

./yii migrate/create add_predefined_users

Users listed in the preceding table can be generated using whatever style seems reasonable, but remember that a user must be created like this:

        $user = new appmodelsuserUserRecord();
        $user->attributes = compact('username', 'password'),
        $user->save();

Our beforeSave() hook handles the generation of password hashes and authorization tokens for us.

Note

You can look at the way we generated our users in the migrations/m140718_063423_add_predefined_users.php file in the example code base accompanying this book.

Then, we need to enable the RBAC manager role in our application in order to be able to assign roles to the users.

RBAC managers in Yii

Yii has two built-in RBAC managers. One is yii bacPhpManager, which reads the role assignments from the PHP script each time the application is loaded, and the other is yii bacDbManager, which stores assignments in the database. We'll use the DbManager, as it'll allow more freedom in manipulation of assignments.

Database-based RBAC manager uses the following schema to store the information about user roles:

RBAC managers in Yii

In fact, Yii's RBAC manager does not operate in terms of roles at all. It operates in terms of authorization items, which can be of two types. Here's the excerpt from the class definition:

namespace yii
bac;
class Item extends Object
{
    const TYPE_ROLE = 1;
    const TYPE_PERMISSION = 2;

The database manager stores the authorization items in the table named auth_item by default. As usual in Yii 2, this is configurable.

The problem is that these types are neither enforced nor checked anywhere. Yii::$app->user->can($itemName) fits them all. So, for simplicity, it's easier to talk in terms of roles instead of authorization items. However, in case of really elaborated RBAC, this distinction can be useful from the design standpoint.

Authorization items, be it role or permissions stored by the authorization manager, form a directed acyclic graph so they can have children, as was mentioned before. Let's elaborate on that, for simplicity, supposing that we are dealing only with roles.

If a user $user has a role $role and this role has a child role $child, then the call to $user->can($child) will return true, that is, parent has access to everything that child has. However, if a user has a role $child and this role is a child of $role, then the call to $user->can($role) will return false, that is, the child role has no access to what the parent role has access to.

Parent-child relationships between roles are stored by DbManager in the table named auth_item_child by default.

The actual assignments of roles to users are stored inside the table named auth_assignment. Note that in the user_id column there is not a foreign key and it is not even of INT type in default schema shown before. This is, of course, because the default schema has no knowledge about how you store your user records (they can even be in different databases, after all). Also, the user_id column should be filled with IDs that are returned by the IdentityInterface.getId() method, which our UserRecord class happily implements This is not necessarily the actual primary keys of the records in the user table. However, this is indeed exactly as it is in our case.

Yii 2 developers provided us with the migration already prepared to initialize the database schema for yii bacDbManager. You can set up the appropriate tables using the following console command:

./yii migrate --migrationPath='@yii/rbac/migrations'

Note

You can also use the following hack to fill our database with the tables expected by the database manager. The schema is stored in the set of files with names schema-*.sql inside the rbac directory under the root folder of Yii 2 installation. Each file corresponds to some specific RDBMS. Under the assumptions of using MySQL and Yii 2 being installed by composer, the required file should be exactly vendor/yiisoft/yii2/rbac/schema-mysql.sql. Create the migration as follows:

./yii migrate/
create create_rbac_tables

Then, in the up() method inside the generated migration script, write the following hack which loads the raw SQL from the file just discussed:

        $this->execute(
            file_get_contents(
                Yii::getAlias('@yii/rbac/schema-mysql.sql')));

This trick is really useful in cases when you have an already battle-tested, very old database schema, large enough that rewriting it in Yii idiomatic migrations code will be too cumbersome.

The failing test for our role hierarchy

Before we start to really fill our database with roles, how can we ensure that our access control is actually in place? Let's construct a functional test for our role hierarchy. Create the test:

./cept generate:test functional RoleHierarchy

Note

Things became a little tricky with the addition of predefined roles. To have a clean state in our functional tests, we are using the dump from clean database, which is not so easy to get now with the migrations that generate actual records in the table. Even without them, there's still need to regenerate the dump after each migration added. Also, with this approach we can completely wipe out production database if we launch the functional tests on the production environment. All of these issues we'll postpone until the final chapter, Chapter 13, Collaborative Work. Let's pretend until then that we somehow have the clean state before running any of the functional and acceptance tests provided.

Look at the files tests/functional.suite.yml, tests/functional/_bootstrap.php, and config/test.php in the code base bundled with this book to see how we solved this problem.

Inside this test, we'll write the brute-force implementation of checking the default role, which is a guest, as shown in the following code:

    /** @test */
    public function DefaultRoleIsGuest()
    {
        // no login at all

        $this->assertFalse($this->user->can('admin'));
        $this->assertFalse($this->user->can('manager'));
        $this->assertFalse($this->user->can('user'));
        $this->assertTrue($this->user->can('guest'));
    }

Yes, we need to use the can() method for checking the assigned roles, which reads really strange.

The class property $this->user is set up with the yiiwebUser component at the setup stage of the test to act both as shorthand and as a single place for changes:

    /** @var yiiwebUser */
    private $user;

    protected function _before()
    {
        $this->user = Yii::$app->user;
    }

We will test the roles of the predefined users using the DataProvider feature of the PHPUnit framework (visit http://phpunit.de/manual/current/en/writing-tests-for-phpunit.html#writing-tests-for-phpunit.data-providers, do not mistake it for the DataProvider concept from Yii):

    public function PredefinedUserRoles()
    {
        return [
            ['RobAdmin',     ['admin' => true,  'manager' => true,  'user' => true, 'guest' => true]],
            ['AnnieManager', ['admin' => false, 'manager' => true,  'user' => true, 'guest' => true]],
            ['JoeUser',      ['admin' => false, 'manager' => false, 'user' => true, 'guest' => true]],
        ];
    }

    /**
     * @test
     * @dataProvider PredefinedUserRoles
     * @param string $username
     * @param array $rbac
     */
    public function PredefinedUsersHasProperRoles($username, $rbac)
    {
        $identity = appmodelsuserUserRecord::findOne(compact('username'));

        $this->user->login($identity);

        foreach ($rbac as $role => $allowed)
            $this->assertEquals($allowed, $this->user->can($role));
    }

The foreach instruction will partially hinder us in case an error occurs in checking the access. However, increase in readability is an important tradeoff.

For each dataset provided by our data provider, we find the user record in database with the provided username, and then check whether Yii::$app->user->can() is behaving according to our role hierarchy for each of these users.

As we're logging into the system on each test, let's log out at the teardown stage:

    protected function _after()
    {
        $this->user->logout();
    }

This test should obviously fail, because we did not even attach the RBAC Manager component to our application.

Setting up the role hierarchy

We'll fill the database with our roles and role assignments using the migration. This means that we need to attach the RBAC manager both to the console application that will be used when we'll launch the ./yii migrate script in the root of code base and by the web application that will actually use the RBAC. In addition to being able to write this one migration, we'll be able to write custom console commands which use our RBAC manager, if necessary.

To attach the database-based RBAC manager to our application, we only need to reference it in the components section of the configuration:

'authManager' => [
    'class' => 'yii
bacDbManager',
    'defaultRoles' => ['guest'],
],

As explained, this should be either added to both @app/config/web.php and @app/config/console.php or separated out to an additional config snippet and included in both of these configurations anyway. The paths provided are valid for the example CRM application so far.

So, from now on, we have Yii::$app->authManager available, pointing to the instance of yii bacDbManager. The default role, as specified in the highlighted part of the preceding code, will be the guest, that is, everyone who is not authenticated or does not have role assigned will be treated as having a guest role.

We are now able to make migrations which install the needed roles. We can use the following methods in the yii bacDbManager class:

Method name

Reason to use

createRole($name)

This method is the main method to create roles. This returns a configured instance of yii bacRole.

createPermission($name)

This method is same as createRole(), but creates instances of yii bacPermission. We will not use this method as, for simplicity, we are using only roles in our security schema.

assign($role, $userId)

This method binds the instance of yii bacRole to the user with $userId. The $userId must be the ID that IdentityInterface.getId() returns!

add($item)

This method registers the given authorization item, be it an instance of yii bacPermission or yii bacRole.

addChild($parent, $child);

This method registers one authorization item to be the child to the other.

There are many more methods, but we listed only the most fundamental ones. Others can be found in the reference documentation for yii bacManagerInterface at http://www.yiiframework.com/doc-2.0/yii-rbac-managerinterface.html.

Finally, here's the migration to set up our role hierarchy:

    public function up()
    {
        $rbac = Yii::$app->authManager;
        
        $guest = $rbac->createRole('guest'),
        $guest->description = 'Nobody';
        $rbac->add($guest);

        $user = $rbac->createRole('user'),
        $user->description = 'Can use the query UI and nothing else';
        $rbac->add($user);

        $manager = $rbac->createRole('manager'),
        $manager->description = 'Can manage entities in database, but not users';
        $rbac->add($manager);

        $admin = $rbac->createRole('admin'),
        $admin->description = 'Can do anything including managing users';
        $rbac->add($admin);

        $rbac->addChild($admin, $manager);
        $rbac->addChild($manager, $user);
        $rbac->addChild($user, $guest);

        $rbac->assign(
            $user,
            appmodelsuserUserRecord::findOne(['username' => 'JoeUser'])->id
        );
        $rbac->assign(
            $manager,
            appmodelsuserUserRecord::findOne(['username' => 'AnnieManager'])->id
        );
        $rbac->assign(
            $admin,
            appmodelsuserUserRecord::findOne(['username' => 'RobAdmin'])->id
        );    }
    public function down()
    {
        $manager = Yii::$app->authManager;
        $manager->removeAll();
    }

We really can recover from this migration by using the yii bacDbManager.removeAll() method, which is not really useful otherwise.

Now our functional test for roles hierarchy passes. We're ready to implement the real protection for our controllers.

The failing test for access control in controllers

In this section, we return from the implementation details of the RBAC management to the feature we actually want to implement.

How are we going to test the access restrictions described in the Protecting the CRM management from CRM users section? It's a really tough question, and it's amazingly hard to do it in the right way. For ease of explanation, let's make a very, very simple implementation that will just work for us right now.

Each of the subclasses of AcceptanceTester we created for our acceptance tests so far are implied to have some roles from our hierarchy. We will treat each of them as users from the predefined ones in the following manner:

  • AcceptanceTesterCRMOperatorSteps corresponds to 'AnnieManager', who is a manager
  • AcceptanceTesterCRMServiceManagementSteps corresponds to 'AnnieManager', who is a manager
  • AcceptanceTesterCRMUserSteps corresponds to 'JoeUser', who is a user
  • AcceptanceTesterCRMUsersManagementSteps corresponds to 'RobAdmin', who is the admin and the only role capable of managing users

We'll write the set of absolutely straightforward acceptance tests to check our assumptions in access rights:

./cept generate:cept acceptance AdminAccessRi
ghts
./cept generate:cept acceptance ManagerAccessRights
./cept generate:cept acceptance UserAccessRights
./cept generate:cept acceptance GuestAccessRights

Here's the content of tests/acceptance/ManagerAccessRightsCept.php (other tests are similar):

$I = new AcceptanceTesterCRMOperatorSteps($scenario);
$I->wantTo('Check Manager-level access rights'),

$I->amOnPage('/customers/query'),
$I->dontSee('Forbidden'),

$I->amOnPage('/customers/index'),
$I->dontSee('Forbidden'),

// ... and so on...

$I->amOnPage('/users/create'),
$I->see('Forbidden'),

$I->amOnPage('/users/index'),
$I->see('Forbidden'),

Note

The preceding test code is neither maintainable nor thorough. Do not write your tests like this. This style was chosen because it expresses the idea well and is concise enough to present in the book format.

We just go to each of the routes we have on the system and check whether we will get the 403 Forbidden error page, which has the word Forbidden written on it in huge letters. The routes /services/delete and /users/delete were omitted, because (as explained earlier in this chapter) they're protected by the VerbFilter and can be accessed only by POST requests. The routes /site/index, /site/login, and /site/logout were omitted because they will have their own special tests for access rights.

The preceding list of see and don't see should be repeated for each of the CRMOperatorSteps, CRMServiceManagementSteps, CRMUserSteps, and CRMUsersManagementSteps AcceptanceTester classes. The test scenario for manager access rights, thus, should have two classes tested.

For guest users, there should be a slightly different expected behavior. While it's possible to change it, by default Yii 2 will redirect guest users to the login form instead of showing them as 403 Forbidden. This is really a nice addition. So, GuestAccessRightsCept should have checks not in the form:

$I->see('Forbidden'),

Checks should be in the following form:

$I->seeElement('#login-form'),

Codeception's seeElement() method from the WebDriver module (visit http://codeception.com/docs/modules/WebDriver#seeElement) can search DOM elements by CSS selectors, and login-form is what we specified as ID for this form when configuring it.

We should have the additional AcceptanceTester subclass that represents the guest users. A guest user is what LoginAndLogoutCept from the previous chapter really requires. Let's create it:

./cept generate:stepobject acceptance CRMGuest

This subclass will just be empty for now, because it's only used in GuestAccessRightsCept.

In the end, using this test strategy, we will end up with the following set of acceptance test scenarios:

  • tests/acceptance/GuestAccessRightsCept.php: This file checks the access rights for the guest role, which is represented by the newly-created AcceptanceTesterCRMGuestSteps class
  • tests/acceptance/UserAccessRightsCept.php: This file checks the access rights for the user role, which is represented by the AcceptanceTesterCRMUserSteps class
  • tests/acceptance/ManagerAccessRightsCept.php: This file checks the access rights for the manager role, which is represented by two classes in our test suite, AcceptanceTesterCRMOperatorSteps and AcceptanceTesterCRMServicesManagementSteps
  • tests/acceptance/AdminAccessRightsCept.php: This file checks the access rights for the admin role, which is represented by the AcceptanceTesterCRMUsersManagementSteps class

Access rights will be checked by combinations of calls to the see('Forbidden') and dontSee('Forbidden') methods, according to the access rights scheme we decided to use at the beginning of this section.

After that, our tests will run without exceptions, but without success too. How do we make them pass, that is, how do we protect the controllers from access by unauthorized users?

We'll use the access control filter.

FEATURE – access control filter

An access control filter is a special action filter that allows us to specify access rights to actions inside some controller.

It should be attached to the controller in the following way:

    public function behaviors()
    {
        return [
            'access' => [
                'class' => AccessControl::className(),
                'rules' => [
                    // rules in the format described above
                ]
            ]
        ];
    }

So, the predefined behaviors() method should return an array that should have the configuration for the AccessControl class initialization.

Each element in the rules setting is an array with the following fields:

 [
    'allow' => true, // or false
    'actions' => ['ids', 'of', 'actions', 'to', 'apply', 'rule', 'to'],
    'controllers' => ['ids', 'of', 'controllers', 'to', 'apply', 'rule', 'to'],
    'roles' => ['roles', 'including', 'symbols', '?', 'and', '@', 'explained', 'before'],
    'ips' => ['IP', 'addresses', 'possibly', 'including', 'wildcards', '*'],
    'verbs' => ['HTTP', 'Request', 'Methods', 'to', 'apply', 'rule', 'to'],
    'matchCallback' => $callable1, // to determine whether we should apply rule in arbitrary way
    'denyCallback' => $callable2 // defining what to do when the access is rejected by this rule
]

The highlighted fields correspond to the public attributes of the yiiwebAccessRule class. To save space, we will not discuss the full meaning of every field, especially when the class definition for yiiwebAccessRule holds a complete description for them.

Rules are checked in order of appearance, so they can have overlapping prerequisites. If no rule was fired, the request is blocked. So if the rules section of AccessControl filter configuration is left empty, everything is banned in this controller for everyone.

The denyCallback field mentioned in the rule configuration, as all other options, is optional. If omitted, the similar denyCallback property of the AccessControl class will be used. By default, it shows the 403 Forbidden page for authenticated users and the login form page for guests, which is exactly the behavior we anticipate in our acceptance tests. Yeah, we cheated again.

Applying access control to the site

First, we need to protect the UsersController, so it'll be only accessible by users with the admin role:

                'rules' => [
                    [
                        'roles' => ['admin'],
                        'allow' => true
                    ]

We presented only the rules, as the AccessControl config surrounding them is the same all of the time. We need to add our AccessControl config to the behaviors() method. Note that the UserController class already has this method defined by Gii upon generation.

For CustomersController we need the following:

                'rules' => [
                    [
                        'actions' => ['add'],
                        'roles' => ['manager'],
                        'allow' => true
                    ],
                    [
                        'actions' => ['index', 'query'],
                        'roles' => ['user'],
                        'allow' => true
                    ],
                ]

According to our specifications for access rights, any user has read access to the customers management UI, but manager-level access rights requires one to register as a new customer.

For ServicesController, we need the following rule, which is similar to the UsersController but has a different role:

                'rules' => [
                    [
                        'roles' => ['manager'],
                        'allow' => true
                    ]
                ]

Well, that's all that we needed to do. The most interesting part is the restructuring of our AcceptanceTester variants so that they survive in this new world filled with restrictions. All of our tests now basically require a log in first. Allow CRMGuest to log in and log out, and then have all other AcceptanceTester subclasses extend CRMGuest so they'll also be able to do so:

    function login($username, $password) // 1
    {
        $I = $this;
        $I->amOnPage('/site/login'),
        $I->fillField('LoginForm[username]', $username);
        $I->fillField('LoginForm[password]', $password);
        $I->click('Login'),
        $I->wait(1); // 2
        $I->seeCurrentUrlEquals('/'), // 3
    }

The highlighted parts show the important pieces of this code. First of all, this method is versatile and accepts the username and password as parameters. Secondly, it's using our login form, which still has JavaScript validation, so we are forced to wait a second after submitting it. Thirdly, to protect us early in case of problems, as the acceptance tests are notoriously slow, we immediately check whether we were redirected to the homepage after logging in. This means the login was successful. The code is as follows:

    function logout()
    {
        $I = $this;
        $I->amOnPage('/'),
        // Expecting that this button is presented on the homepage.
        $I->click('logout'),
    }

The logout option is a lot simpler here. We just need to move to the homepage, because we can be on some page like 403 Forbidden (and in fact, we'll end up there a lot) where there's no logout link at all. However, we should be careful where we use this test step, as the logout link exists only when the user is authenticated.

As mentioned before, we need to make all kinds of AcceptanceTester subclasses into descendants of CRMGuestSteps now: CRMOperatorSteps, CRMUsersManagementSteps, CRMUserSteps, and CRMServicesManagementSteps.

Having all of that in place, we now have the ability to make all of our AcceptanceTester instances login right after birth. We just put the following two properties and a constructor in CRMGuestSteps:

    public $username;
    public $password;

    public function __construct($scenario)
    {
        parent::__construct($scenario);

        if ($this->username and $this->password)
            $this->login($this->username, $this->password);
    }

So, if the descendant has the username and password defined, it'll login as the very first step in any test. We don't need to change any of our existing tests using this technique.

In CRMOperatorSteps, therefore, the first lines should be as follows:

class CRMOperatorSteps extends CRMGuestSteps
{
    public $username = 'AnnieManager';
    public $password = 'managerpass';

These steps can be the performed in a similar manner for the other CRM...Steps classes.

We need to make five changes in our existing tests nevertheless.

The first change is in LoginAndLogoutCept. After AcceptanceTesterCRMUsersManagementSteps finishes creating a new user, this user is obviously a guest, as we don't have any RBAC management UI. So, the user who will be doing real testing of the login and logout functionality should not be associated with AcceptanceTesterCRMUserSteps, but AcceptanceTesterCRMGuestSteps. Also, AcceptanceTesterCRMUsersManagementSteps should log out after it finishes work.

Note

Now, it's clear that it's very hard to relate the notion of step objects enforced by Codeception with the intuitive understanding of how we should treat the subclass of AcceptanceTester. Why did they not name the inheritors of Tester classes a TesterKinds or something like that?

We'll not show the full final code, as it's quite easy to implement in tests/acceptance/LoginAndLogoutCept.php:

$I = new AcceptanceTesterCRMUsersManagementSteps($scenario);
// … steps to create a user ...
$I->logout();
$I = new AcceptanceTesterCRMGuestSteps($scenario);
// … steps to check the login/logout features.

The second change is that we have the following lines in LoginAndLogoutCept.php:

$I->amGoingTo('logout from arbitrary page'),
$I->amInQueryCustomerUi();
$I->click('logout'),

Given our new access control rules, guest users cannot go to a arbitrary page, so this test is meaningless for now. Just remove the preceding code lines.

The third change is in the same LoginAndLogoutCept.php file. Here is the line:

$I->seeLink('logout'),

This line is there so we will be able to check whether we logged in successfully. After that, there are another four test steps that are there to check that logout actually works. But for that, we need to actually log out! Put the following line after that seeLink() invocation:

$I->logout();

The fourth change is that you must move definitions for seeIAmInLoginFormUi(), fillLoginForm(), submitLoginForm(), seeIAmAtHomepage(), seeUsername($user), and dontSeeUsername($user) to the AcceptanceTesterCRMGuestSteps class from AcceptanceTesterCRMUserSteps.

The last change we should make is to log out from QueryCustomerByPhoneNumberCept after we're done creating a customer:

$I = new AcceptanceTesterCRMOperatorSteps($scenario);
// … steps to create a customer ...
$I->logout();
$I = new AcceptanceTesterCRMUserSteps($scenario);
// … steps to try the "query by phone number" feature.

This is the end of our implementation. Run the tests as shown in the following screenshot:

Applying access control to the site

Success! More than that, we now have a sufficiently reliable automatic test suite which will relieve us from the job of clicking through these login forms manually.

..................Content has been hidden....................

You can't read the all page of ebook, please click here login for view all page.
Reset
18.218.97.75