Skip to content

Authentication and authorisation

Authentication refers to the task of identifying who a user is. Authorization refers to the task of checking if a user is permitted to do some thing.

Overview

rframe follows PostgreSQL in having users and roles be the same thing. In practice, the concept of a user in rframe corresponds closest to an account. An account is a login to the system, and is linked to a role.

In database tables, fields that would usually refer to a user id instead refer to a role id (e.g, created_by and updated_by fields are linked to the role table). So, a role is some kind of actor in the system (possibly an individual or possibly a group), while an account is a specific login to a system.

Once logged in as an account, the end user is now acting as the role id associated with that account.

Authentication

To log into the website, a user needs an account. To log in via a specific account, a user needs an account provider. Think of an account as a user, while an account provider is one or more ways of logging in as that specific user.

Consider, for example, a user which has an account with email a@b.com, and that account has two account providers: - Github (via a@b.com) - Facebook (also via a@b.com)

When the user logs in via github using a@b.com, we check if there's an account provider for that github account, and if authentication is successful then we log the user in to the account associated with it.

However, if the user logs in with Facebook, we also log them in to the same account.

The above describes how the database is designed. In practice, it will rarely or never happen that one account has more than one account provider. Instead, the default behaviour is that the first time that a user logs in via Github using a@b.com email address, that creates a new account. Then, the first time a user logs in via Facebook using the same a@b.com email address, that creates a separate new account.

The database design supports us manually linking these two login methods (account providers) to the same account, but the default is to create separate accounts. To understand why, consider a user that has created an account via Facebook using a@b.com. Then, a malicious actor creates a new github account using the same a@b.com email addres, and then uses that to log into the site. If we automatically linked the Github account to the matching Facebook account that has the same email, this malicious user has now just logged in with an account that isn't theirs. And so, to ensure a secure default, we never link new logins to an existinig account, even when the email addresses match. However, the database has been designed to allow manually linking when desired.

Tip

Think of a user as being the combination of account+role. In the database, the account is linked to a specific role. An account is a login identity for a particular role, and an account provider is a method to login as that account. In practice, accounts will have just one account provider, but in theory could have more than one.

Roles and permissions

Permissions are the things you are allowed to do. Roles are the entities that have permissions to do things.

Roles in rframe are analogous to both users and groups, much like how they work in PostgreSQL. Just like in postgres, you can't log in to the system as a role unless that is configured separately. In rframe, that requires an account to exist that is linked to the role.

Once logged in, all actions are done as that role. It is the role id that is used throughout most of rframe.

Why role and user are the same

In many applications, users are separate from roles. Users are individuals, usually third parties, who can log in. Roles, on the other hand, are collections of permissions that can be assigned to users, removing the need to assign permissions individually. Applications will differ in these details, but that is an example of one setup. Since there's a difference between a user who can log in, and the permissions that user has, these are recorded differently. However, a database structure challenge arises when we decide we want to apply permissions to users directly as well as to roles that are then attached to users.

By treating users and roles as the same record type, a permission can happily be applied to either, and it's a foreign key reference to the same external type of user/role record. Suppose you have a document, and you want to allow the assignment of edit permissions. To assign the edit permission, you add the appropriate edit permission to the table, linked to either a role that can log in (a 'user'), or to a role that cannot (a 'group'). Either way, it's the same record type.

A user's permissions are simply the collection of permissions for their role along with any other roles that have been assigned to them (recursively). Roles are discovered like so:


This returns a list of all roles that the role has, going as deep as needed. This is then used to fetch a list of permissions:


Finally, determining whether a role has a permission involves checking if they either have the super permission, or if that permission appears in their list of permissions:


Checking permissions in the database

With our thick database philosophy, often we'll be doing permission checks in the database itself. This requires us to know which role is making a particular request. Any request to the database that requires knowing which role is making a request will create a transaction using the begin_as method:

    pub async fn begin_insecure(
        &self,
        isolation: Isolation,
        role_id: Option<Uuid>,
    ) -> Result<Transaction<'static, Postgres>, Error> {
        let mut tx = self.pool.begin().await?;
        let mut query = format!(
            "SET TRANSACTION ISOLATION LEVEL {}; SET LOCAL search_path={};",
            get_isolation_command(isolation).to_string(),
            self.tenant.schema(),
        );

        if let Some(role_id) = role_id {
            let set_role = format!("set local \"request.web.sub\" = '{}'", role_id.to_string());

            query = format!("{} {};", query, set_role)
        }

        tx.execute(query.as_str()).await?;

        Ok(tx)
    }

This creates a new transaction, and sets some information in the context of that transaction, so that we can tell who the actor is for this request. First, we set the transaction level and use the postgres webuser role. Then, we store the role id of the requester into the local value request.web.sub:

set local "request.web.sub" = '{}'

Here we are following a convention similar to that used by PostgREST, where a user's id is stored in the context of the request so that further authorisation checks can be done.

In practice, what this means is that when a user tries to do an action such as linking a permission to a role, we can check inside that SQL function that they have the permission, and throw an error if not:

    if auth.fn_has_permission(auth.fn_requesting_role(), 'edit-role-permissions') = false then
      select error.fn_app_error('PROHIBITED', 'permission denied for auth.fn_link_role_permissions', 'Permission denied');
      return;
    end if;

This uses the fn_app_error function to return the failure, so that it's in a standardised format that can be consistently interpreted and acted on by our Rust code. In src/model/error.rs there is a method called extrat_app_error, which handles this error appropriately so that it should be as easy as passing the error up to get handled.

Using requester in model

In rframe, the model packages are the interface between the website and the database. In most cases, we want to do queries to the database on behalf of the user who made the web request. To make this easier, the model struct looks something like this:

#[derive(Clone, Debug)]
pub struct Model {
    pub db: DB,
    requester_id: Option<Uuid>,
}

Every request has its own copy of this struct, which includes the shared db connection, as well as the role id (requester_id) on whose behalf this request is on. It is optional, because sometimes there may be actions that are not performed on behalf of any user (e.g., regularly scheduled maintenance tasks). Inside handlers, you can extract the model and provide the requesting ID something like this:

pub async fn some_handler(
    payload: TemplatePayload,
    Extension(model_provider): Extension<ModelProvider>,
    Extension(account): Extension<Option<account::Account>>,
) -> site::Result<SomePayload> {
    let results = model_provider
        // Note that here we set the role id for the account that made this
        // request, which ultimately makes its way into any db requests that
        // need it.
        .model(Account::get_role_id(account))
        .new_site_model()
        .some_action()
        .await?;

    let tmpl = SomePayload {
        results,
        payload,
    };

    Ok(tmpl)
}

Note that it's up to some_action to make use of this id. For example, it may look something like this:

impl SiteModel {
    pub async fn some_action(&self) -> anyhow::Result<Vec<super::bot::Bot>> {
        let mut tx = self
            .model
            .db
            // Note that here we are using the role id that was provided by
            // the handler above, and is stored in the model:
            .begin_as(Isolation::ReadCommitted, self.model.requester_id())
            .await?;

        ...
    }

Note

rframe provides a model that provides a bunch of the base framework functionality. But any specific site using rframe will want to provide functionality specific to that site. In this case, the SiteModel has a Model struct inside it, giving us a way to implement site specific behaviour. E.g.:

#[derive(Clone, Debug)]
pub struct SiteModel {
    pub model: Model,
}

: double check my readme with authentication info