Computer scienceBackendFlaskUser-specified interactions

User roles

8 minutes read

Introduction

Imagine you're using an online shopping platform like Amazon. You log in, browse products, add items to your cart, and make a purchase. Everything seems straightforward, right? But have you ever wondered how the platform knows what actions you can perform? How does it know you're a customer, not a seller or an admin?
Further, consider what happens when you decide to become a seller on the same platform. Now, you can list your products for sale, set prices, manage inventory, and more. How does the platform know to give you these additional options?
These capabilities are managed through the concept of user roles. Each user on the platform is assigned a role (like 'Buyer,' 'Seller,' or 'Admin'), and each role has a specific set of actions that it can perform. This system of user roles is what keeps the platform secure, efficient, and user-friendly.

This topic walks you through the concept of user roles in Flask, showing you how to define roles, assign them to users, and restrict access based on those roles. So, keep reading to learn how to implement this crucial feature in your Flask app!

Roles vs. permissions

Permissions Vs Roles

Roles and Permissions are two fundamental concepts in access control and authorization mechanisms. They form the basis for determining what actions a user can perform in an application. While they are related, they serve different purposes, so understanding their differences is important.

  • Permissions: Permissions are the most granular level of access control. They represent specific actions that a user can perform on a resource. For example, permissions might include "read," "write," "delete," and so on.

  • Roles: A role, on the other hand, is a named set of permissions that can be assigned to users. Roles represent a functional position within your application's domain. For example, in a blogging platform, you might define roles such as "Administrator," "Editor," "Author," and "Reader." Each of these roles would have different levels of access to the system:

    • An "Administrator" might have full access to create, read, update, and delete (CRUD) any content.

    • An "Editor" might have permission to read, update, and delete content but not create new content.

    • An "Author" might have permission to create and read the content but not update or delete it.

    • A "Reader" might only have permission to read content.

    Roles are a way to group permissions into logical units that make sense for your application. Assigning a user a role gives them all the permissions associated with that role.


Using roles can greatly simplify your access control system. Instead of assigning individual permissions to each user, you can simply assign them a role, and they will automatically get all the permissions associated with that role. This makes managing access control much easier, especially for larger applications with many users and permissions.
For example, imagine an application with 50 different permissions and 1000 users. If you were to manage permissions individually for each user, you would potentially need to track 50,000 (50 permissions x 1000 users) different permission assignments. But if you group the permissions into 5 roles, you only need to track 5,000 (5 roles x 1000 users) role assignments, and the permissions for each role can be managed separately.

User roles in flask

Now, the question is: how do you create and assign these roles in our flask application? The following steps provide a general approach for implementing roles in Flask applications across various scenarios. These steps serve as a foundation for building authorization systems and can be adapted according to the specific needs of each project.

  1. Define the Roles and Permissions: Define the various roles based on the responsibilities and access levels needed in your application. This could include roles like 'Admin,' 'User,' 'Guest,' and so on.

  2. Create Roles Model: Create a model for the roles in your application. This will typically be a database table with fields for the role's ID and name.

  3. Associate Users with Roles: Create a way to associate users with roles. This could be a many-to-many relationship table if a user can have multiple roles or a foreign key on the user table if each user can only have one role.

  4. Define Access Control: Define the access control rules for each role. This involves specifying what actions a user in a particular role can perform. This could be implemented as a function or method that checks the user's role and the action they're trying to perform.

  5. Check Access Control: Before performing an action, check the user's role and whether they have permission to perform that action. This could be done using a decorator on your view functions or in the function itself.

  6. Handle Unauthorized Access: If a user tries to perform an action they don't have permission for, handle this appropriately. This could involve showing an error message, redirecting them to a different page, or asking them to log in.
    Remember that the specific implementation details will depend on the libraries and tools you're using. But in general, you can use these steps as a guide whenever you need to use role-based authorization.

Implementation

Despite the availability of several Flask extensions designed to manage role-based authorizations, we will develop our own custom solution. The primary motivation here is that if you already use other extensions to handle another aspect of your web application, adding another one might need more work to avoid conflict. Adopting a custom approach enables us to learn fundamental authorization principles, ensuring a solid grasp of the subject matter. Although alternative solutions exist, the procedures outlined below apply equally to third-party extensions, providing flexibility and adaptability in managing authorization processes within Flask frameworks.

To understand the concept better, we will start by creating a concrete application with different types of users. We'll create a simple blog application where there are two types of users: 'Author' and 'Reader.'

Let's get started:

  • The first step is defining the roles and permissions clearly. This will be easy to do as you create a blog application with two roles.

Role

Permissions

Reader

  • 'view_post': Allows reading existing posts.

Author

  • 'create_post': Allows creating a new post.

  • 'edit_own_post': Allows editing their own posts.

  • delete_own_post': Allows deleting their own posts.

  • the second step from our guide above is to build basic models for users and roles

#blog.py

from flask import Flask

from flask_sqlalchemy import SQLAlchemy

app = Flask(__name__)
app.config["SQLALCHEMY_DATABASE_URI"] = "sqlite:///site.sqlite"

db = SQLAlchemy(app)

#...

#permissions
class Permission:                                                    #1
    view_post = 1  #2^0
    edit_own_post = 2  #2^1
    create_post = 4  #2^2
    delete_own_post = 8  #2^3

class Role(db.Model):    
    id = db.Column(db.Integer, primary_key = True)
    name = db.Column(db.String(20), unique = True)
    users = db.relationship("User", backref = "role")                #2
    def __repr__(self):                                              #3
        return f"Role({self.name})"


class User(db.Model):
    id = db.Column(db.Integer, primary_key = True) 
    username = db.Column(db.String(20), unique = True)
    role_id = db.Column(db.Integer, db.ForeignKey("role.id"))        #4       
    def __repr__(self):
        return f"User({self.username})"



@app.route("/")
#...

Let's unpack the above code.

#1 Here, we created a permission class containing all the individual permissions we defined in the previous step. As it is usually done, we defined them using powers of two. The reasons will be explained shortly.

#2 is where we establish a relationship between the Role and User models. As you can see, it's a one-to-many relationship — one role can be assigned to many users. Our Role class does not yet have a way to store permissions. This will be added in the next step.

#3 This is just a dunder method that will help us to control how any instance of this class will be printed. It is helpful for checking our data and for debugging.

#4 We created a foreign key that will link our Role and User models.

  • Next, we need to define methods inside our roles model to define the set of permissions

#...
class Role(db.Model):
    def __init__(self, **kwargs) -> None:                      #1
        super(Role,self).__init__(**kwargs)
        if self.permissions is None:
            self.permissions = 0
    id = db.Column(db.Integer, primary_key = True)
    name = db.Column(db.String(20), unique = True)
    users = db.relationship("User", backref = "role")
    permissions = db.Column(db.Integer)                        #2

    def add_permission(self,permission):                       #3
        if not self.has_permission(permission):
            self.permissions |= permission

    def remove_permission(self, permission):
        if self.has_permission(permission):
            self.permissions &= ~permission

    def reset_permissions(self):
        self.permissions = 0

    def has_permission(self, permission):
        return (self.permissions & permission) == permission

    def __repr__(self):
        return f"Role({self.name})"
    
    
#...

#2 This line defines a permissions column in our Role table. It consists of Integers as pointed out in the previous step.

#1 We simply defined the constructor method for the Role class. This method will be called every time a new instance of the class is created. Initially, Flask-SQLAlchemy will set the permissions variable to None if it's empty. But as discussed next, we want to represent the permissions as Integers, assigning 0 for no permission.

#3 The methods starting from here are what we will use to manipulate a group of permissions. We use bitwise operations to combine permissions, remove a permission from a group of permissions, as well as to check if permission is part of a group. The use of numbers that are power of two is also explained by this operator.

Let's briefly discuss the bitwise operators used and the logic behind using powers of two as values for the permissions.

Bitwise operations are used to manipulate data at the bit level. They convert the numbers into binary digits and perform some sort of operations.

  • Bitwise AND (&): Takes two numbers and returns a number that has '1' bits only where both input numbers have '1' bits (0 & 0 = 0, 1 & 0 = 0, 1 & 1 = 1).

  • Bitwise OR (|): Takes two numbers and returns a number that has '1' bits where either input number has '1' bits (0 | 0 = 0, 1 | 0 = 1, 1 | 1 = 1).

  • Bitwise NOT (~): Takes a number and inverts all its bits(~ 0 = 1 , ~ 1 = 0).

In binary, each power of two (1, 2, 4, 8, 16, 32, and so on) has a unique representation where only one bit is set to '1' and all other bits are '0'. This unique representation allows each permission to be associated with a unique bit in the binary representation.

For example:

  • 1 in binary is '01'

  • 2 in binary is '10'

  • 4 in binary is '100'

  • 8 in binary is '1000'

If you use a number that is not a power of two, like 3 ('11' in binary), it will overlap with the permissions represented by 1 ('01') and 2 ('10').

You aren't done with this step yet. Now, you want to populate our database with the initially defined roles. You can do this outside the class but create a static method to handle this functionality.

class Role(db.Model):
    #...
    
    @staticmethod
    def populate_roles():
        roles = {                                                                 #4
            'Reader': [Permission.view_post],
            'Author': [Permission.edit_own_post, Permission.create_post, Permission.delete_own_post]
        }
        for rol in roles:
            role = Role.query.filter_by(name=rol).first()                         #5
            if role is None:
                role = Role(name=rol)
            role.reset_permissions()                                              #6
            for perm in roles[r]:
                role.add_permission(perm)                                         #7
            db.session.add(role)
        db.session.commit()

At #4, simply format the roles you defined in step 1 into a dictionary. After that, check if the role already exists at #5 and only add it to the Role table if it is new. This will make it easier to update our list of roles; it's as easy as adding another 'role':[permissions] element in our dictionary and calling the populate_roles method on the class.

#6 Here, simply reset the permission to 0, ensuring the correct initial value before populating our table at #7

creating and testing the database

  • The final steps are adding role verification to our blog app. You can do this by checking which permission the user has and allowing/denying resources based on that information. This can be done easily by adding a method to our User class:

class User(db.Model):
    #...
    def is_allowed(self, permission):                                             #1
       return self.role is not None and self.role.has_permission(permission)
    #...

the method at #1 will ensure the user is assigned a role and check the allowed permissions from that role. While you can use this method at any point in your view functions to allow/deny resources, you might want to block the whole route for users without the right role. You can do this easily by creating a decorator from the above method(using flask_login for getting the current user):

#....
from flask import abort
from flask_login import current_user
from functools import wraps

#decorator
def permission_for(permission):
    def decorator(view_func):
        @wraps(view_func)
        def decorated_function(*args, **kwargs):
            if not current_user.is_allowed(permission):
                abort(403)                                  #handling unauthorized request
            return view_func(*args, **kwargs)
        return decorated_function
    return decorator

#test
@app.route('/create_post')                                  #1
@login_required
@permission_for(Permission.create_post)
def create_post():
    return "Insert your post"

Notice at #1 flask app's route decorator comes first. After that, you can order the others based on their requirement. For example, in the above case, the user must be in the database to check the permissions and roles. Therefore, the @login_required must come first.

That's it! You have successfully built a role-based authorization system. Remember, when in doubt, use the 6 steps guide above!

Additional notes and best practices

  • While you can extend the above implementation to your need, you might want a third-party package that comes with such capabilities already built in. Here are some frequently used packages for this purpose: Flask-Principal, Flask-Admin, Flask-User, Flask-Security.

  • Before using the above Flask extensions, ensure they have been updated within the past 2,3 years. Don't use abandoned packages in your code!

  • It's customary to define a default role in your web application. This role will be given to anyone who just logged in. The default role will depend on your application's target users and other specifics.

  • Use descriptive role names. This will make it easier to understand the permissions that are associated with each role.

Conclusion

  1. Roles and permissions are critical in securing web applications by controlling access to sensitive resources.

  2. We have constructed a generic 6-step guide that can be used to incorporate role-based authorization into our flask app. These steps can help us when building custom systems or even when using third-party packages.

  3. Permissions can be checked at the view/route level or inside the view function, allowing fine-grained control over who can perform certain actions.

How did you like the theory?
Report a typo