User Roles
Chapter 9. User Roles
Not all users of web applications are created equal. In most applications, a small percentage of users are trusted with extra powers to help keep the application running smoothly. Administrators are the best example, but in many cases middle-level power users such as content moderators exist as well. To implement this, all users are assigned a role.
There are several ways to implement roles in an application. The appropriate method largely depends on how many roles need to be supported and how elaborate they are. For example, a simple application may need just two roles, one for regular users and one for administrators. In this case, having an is_administrator Boolean field in the User model may be all that is necessary. A more complex application may need additional roles with varying levels of power in between regular users and administrators. In some applications it may not even make sense to talk about discrete roles, and instead giving users a set of individual permissions may be the right approach.
The user role implementation presented in this chapter is a hybrid between discrete roles and permissions. Users are assigned a discrete role, but each role defines what actions it allows its users to perform through a list of permissions.
Database Representation of Roles
A simple roles table was created in Chapter 5 as a vehicle to demonstrate one-to-many relationships. Example 9-1 shows an improved Role model with some additions.
Example 9-1. app/models.py: role database model
The default field is one of the additions to this model. This field should be set to True for only one role and False for all the others. The role marked as default will be the one assigned to new users upon registration. Since the application is going to search the roles table to find the default one, this column is configured to have an index, as that will make searches much faster.
Another addition to the model is the permissions field, which is an integer value that defines the list of permissions for the role in a compact way. Since SQLAlchemy will set this field to None by default, a class constructor is added that sets it to 0 if an initial value isn’t provided in the constructor arguments.
The list of tasks for which permissions are needed is obviously application specific. For Flasky, the list is shown in Table 9-1.
| Task name | Permission name | Permission value |
|---|---|---|
| Follow users | FOLLOW | 1 |
| Comment on posts made by others | COMMENT | 2 |
| Write articles | WRITE | 4 |
| Moderate comments made by others | MODERATE | 8 |
| Administration access | ADMIN | 16 |
The benefit of using powers of two for permission values is that it allows permissions to be combined, giving each possible combination of permissions a unique value to store in the role’s permissions field. For example, for a user role that gives users permission to follow other users and comment on posts, the permission value is FOLLOW + COMMENT = 3. This is a very efficient way to store the list of permissions assigned to each role.
The code representation of Table 9-1 is shown in Example 9-2.
Example 9-2. app/models.py: permission constants
With the permission constants in place, a few new methods can be added to the Role model to manage permissions. These are shown in Example 9-3.
Example 9-3. app/models.py: permission management in the Role model
The add_permission(), remove_permission(), and reset_permission() methods all use basic arithmetic operations to update the permission list. The has_permission() method is the most complex of the set, as it relies on the bitwise and operator & to check if a combined permission value includes the given basic permission. You can play with these methods in a Python shell:
Table 9-2 shows the list of user roles that will be supported in this application, along with the permission combinations that define each of them.
| User role | Permissions | Description |
|---|---|---|
| None | None | Read-only access to the application. This applies to unknown users who are not logged in. |
| User | FOLLOW, COMMENT, WRITE | Basic permissions to write articles and comments and to follow other users. This is the default for new users. |
| Moderator | FOLLOW, COMMENT, WRITE, MODERATE | Adds permission to moderate comments made by other users. |
| Administrator | FOLLOW, COMMENT, WRITE, MODERATE, ADMIN | Full access, which includes permission to change the roles of other users. |
Adding the roles to the database manually is time consuming and error prone, so instead a class method can be added to the Role class for this purpose, as shown in Example 9-4. This will make it easy to re-create the correct roles and permissions during unit testing and, more importantly, on the production server once the application is deployed.
Example 9-4. app/models.py: creating roles in the database
The insert_roles() function does not directly create new role objects. Instead, it tries to find existing roles by name and update those. A new role object is created only for roles that aren’t in the database already. This is done so that the role list can be updated in the future when changes need to be made. To add a new role or change the permission assignments for a role, change the roles dictionary at the top of the function and then run the function again. Note that the "Anonymous" role does not need to be represented in the database, as it is the role that represents users who are not known and therefore are not in the database.
Note also that insert_roles() is a static method, a special type of method that does not require an object to be created as it can be invoked directly on the class, for example, as Role.insert_roles(). Static methods do not take a self argument like instance methods.
Role Assignment
When users register an account with the application, the correct role should be assigned to them. For most users, the role assigned at registration time will be the "User" role, as that is the role that is marked as a default. The only exception is made for the administrator, who needs to be assigned the "Administrator" role from the start. This user is identified by an email address stored in the FLASKY_ADMIN configuration variable, so as soon as that email address appears in a registration request it can be given the correct role. Example 9-5 shows how this is done in the User model constructor.
Example 9-5. app/models.py: defining a default role for users
The User constructor first invokes the constructors of the base classes, and if after that the object does not have a role defined, it sets the administrator or default role depending on the email address.
Role Verification
To simplify the implementation of roles and permissions, a helper method can be added to the User model that checks whether users have a given permission in the role they have been assigned. The implementation simply defers to the role methods added previously. This is shown in Example 9-6.
The can() method added to the User model returns True if the requested permission is present in the role, which means that the user should be allowed to perform the requested task. The check for administration permissions is so common that it is also implemented as a standalone is_administrator() method.
For added convenience, a custom AnonymousUser class that implements the can() and is_administrator() methods is created as well. This will enable the application to freely call current_user.can() and current_user.is_administrator() without having to check whether the user is logged in first. Flask-Login is told to use the application’s custom anonymous user by setting its class in the login_manager.anonymous_user attribute.
For cases in which an entire view function needs to be made available only to users with certain permissions, a custom decorator can be used.Example 9-7 shows the implementation of two decorators, one for generic permission checks and one that checks specifically for the administrator permission.
These decorators are built with the help of the functools package from the Python standard library and return a 403 response, the “Forbidden” HTTP status code, when the current user does not have the requested permission. In Chapter 3, custom error pages were created for errors 404 and 500, so now a page for the 403 error is added in a similar way.
The following are two examples that demonstrate the usage of these decorators:
As a rule of thumb, the route decorator from Flask should be given first when using multiple decorators in a view function. The remaining decorators should be given in the order in which they need to evaluate when the view function is invoked. In these two cases, the user authenticated state needs to be checked first, since the user needs to be redirected to the login prompt if found to not be authenticated.
Permissions may also need to be checked from templates, so the Permission class with all its constants needs to be accessible to them. To avoid having to add a template argument in every render_template() call, a context processor can be used. Context processors make variables available to all templates during rendering. This change is shown in Example 9-8.
Example 9-8. app/main/__init__.py: adding the Permission class to the template context
The new roles and permissions can be exercised in unit tests.Example 9-9 shows two of the tests. The source code on GitHub includes one for each role.
Example 9-9. tests/test_user_model.py: unit tests for roles and permissions
Tip
If you have cloned the application’s Git repository on GitHub, you can run git checkout 9a to check out this version of the application. This update contains a database migration, so remember to run flask db upgrade after you check out the code.
Before you move on to the next chapter, add the new roles to your development database in a shell session:
It is also a good idea to update the user list so that all the user accounts that were created before roles and permissions existed have a role assigned. You can run the following code in a Python shell to perform this update:
The user system is now fairly complete. The next chapter will make use of it to create user profile pages.
Table of contents collapsed