Decision deadline: irrelevant since implementation pending
Author: Philipp (@pylipp)
Discussion participants: Vahid (@vahidbazzaz), Roanna (@aerinsol), Hans (@HaGuesto)
Implementation on-going.
A first proposal for authorization design can be found here.
The boxtribute
application is used by various organisations to manage distribution of aid goods. Some data stored contains personal, confidential information of individuals in vulnerable situations. These individuals have a right to privacy, hence their data must not be exposed to unintended parties. Implementation of authorization measures is required to define and enforce data excess depending on the current application user.
The current document serves as a summary of decisions about user authorization in the boxtribute application.
- Security: exposure of confidential data must be prohibited for legal and ethical reasons
- Clarity: make the governing structures easy to understand for stakeholders (users, product management, developers)
- Single source of trust: data for controlling authorization must not be distributed
- Simplicity during development: enforcing authorization should be straightforward for developers while guaranteeing security
- Maintainability: management of roles and permissions should have little overhead and be extensible
- Integration in both dropapp and boxtribute 2.0
The boxtribute
partner organisations operate in one or more sites each, called bases
. Any registered user belongs to exactly one organisation, and one or more bases subordinated to this organisation. Hence they must not be granted access to any resource outside of the organisation or bases they're assigned to (there are exceptions to this rule depending on the context, e.g. for box transfers).
A role reflects the user's responsibilities in the partner organisations. Currently, these roles exist:
- administrator (head of operations)
- coordinator
- warehouse volunteer
- free shop volunteer
- library volunteer
- label creator
- external free-shop checkout
- God user (note: this is an application administration role and does not belong to a specific partner organisation)
Depending on the user group, a user is able to perform certain actions. In our authorization concept, these actions are mirrored by action-based permissions (ABP).
ABPs represent functions in the application that the user who was granted the ABPs can execute. Examples: viewing the inventory, or managing products. The name of an ABP consists of a verb indicating the action, and a plural noun (if applicable), separated by underscore: manage_products
, view_inventory
, etc.
In the application back-end however it needs to be distinguished which data a user is allowed to access in what way. This is achieved through resource-based permissions (RBP). An RBP refers to a resource in the database, and the methods that the user who was granted the RBP can execute. These methods correspond to database operations:
- read:
SELECT
- create:
INSERT
- edit:
UPDATE
- write:
INSERT
,UPDATE
- delete:
DELETE
- assign:
INSERT
into cross-reference table
The naming convention for RBP is a singular noun (the resource; multi-word nouns concatenated by underscore), and a method name, separated by colon: user:edit
, beneficiary:create
, tag_relation:read
, etc.
Every ABP comprises one or more RBP, e.g. the ABP manage_tags
stands for tag:write
, stock:read
, tag_relation:read
, and beneficiary:read
.
The ground truth for permissions management are the Auth0 Action scripts. Any updates to the scripts are downloaded to the System Management repository. The links to the Auth0 scripts are found in this document, along with other information about RBPs, ABPs, and dropapp menues.
There are two scenarios that require an additional guarding mechanism in boxtribute v2. We introduce a series of levels, each associated with certain available app functionality. The lowest level provides the least functionality, while each of the larger levels additively builds up on the previous one. We can now assign beta-level values to individual users to control their access to certain functionality because a user can only access functionality of a beta-level smaller or equal to the user's beta-level value.
The default beta-level value is 3 (Nov 2024). On the back-end side, this is controlled in the authz
module. On the user-management service side, an Auth0 Action script adds the beta-level value to the user's JWT.
God users are not affected by beta-level checks. The beta-level mechanism is not used in dropapp.
Details on the two scenarios:
- Prevent use of functionality not yet accessible via the FE. During the development cycle for v2, the back-end is usual extended with new functionality (e.g. a new mutation) before the UI implementation follows. This creates a gap during which the new mutation is already present in the GraphQL schema (and technically can be executed by users who pretend to be the front-end). However we don't want the mutation to be used, and hence associate it with a beta-level higher than the default beta-level value. Example: the
createTag
mutation is part of the GraphQL schema, hence any user withtag:write
RBP (corresponding tomanage_tags
ABP, or at least coordinator role) could execute it. However the mutation is assigned to beta-level 6 which is larger than the default beta-level. - Restrict use of functionality to certain users. Some new functionality might be made available to a subset of users for a certain testing period. Once it's decided who these users are (e.g. only certain bases or organisations), these users are assigned a beta-level value to enable them accessing the functionality to be tested. Example: box transfer feature which was enabled first for a sending and a receiving organisation.
Auth0 is a service for managing user authentication and authorization. It serves as single source of truth. An authenticated user gets issued a JSON Web Token (JWT) in one of two variants holding their information: the ID token with authentication information, and the access token with authorization information. For signing the JWT we use the RS256 algorithm: the token will be signed with our private signing key and can be verified using our public signing key.
Cf. related ADR
Any user registered for boxtribute has their authorization data (app_metadata
) stored in the Auth0 database. The user attributes are:
usergroup_id
: the ID of the usergroup the user belongs tobase_ids
: a list of base IDs that the user has access toorganisation_id
: the ID of the organisation the user belongs tobeta_user
(optional): the largest beta-level the user is permitted to access
During registration, the user manually gets assigned a role, indicating their usergroup and the bases they belong to. The role is named like base_1_coordinator
.
When the user has successfully logged in, two custom Auth0 post-login action scripts run (create-dynamic-permissions
and add-beta-user-field
). The first script creates a JWT with the content derived from user authorization data and their role.
Most importantly the script derives ABPs and base-specific RBPs for the current user (see below about their format). The second script assigns the user's beta-level value.
Auth0 also offers the feature to assign permissions to user roles. Although these permissions were once seeded when creating the roles, they might become outdated, and should not be relied upon. Using Auth0 permissions is also less flexible than the permissions management in the Action scripts.
The JWT (access and ID token, unless specified otherwise) contains standard and customs fields.
Field name | Kind | Description | Usage |
---|---|---|---|
iss |
standard | Name of token issuer | JWT decoding |
aud |
standard | Name of token audience | JWT decoding |
iat |
standard | Unix timestamp of issuing datetime | - |
exp |
standard | Unix timestamp of expiration datetime | JWT decoding |
azp |
standard | ID of client through which the JWT was requested | traceability of application used for authentication |
gty |
standard | Grant type | - |
sub |
standard | User ID | see below |
https://www.boxtribute.com/email |
custom | User email (only in access token) | - |
https://www.boxtribute.com/roles |
custom | List of user's roles | - |
https://www.boxtribute.com/base_ids |
custom | List of IDs of bases that the user has access to | see below |
https://www.boxtribute.com/organisation_id |
custom | ID of the organisation the user belongs to | see below |
https://www.boxtribute.com/permissions |
custom | List of RBPs that the user holds (only in access token) | see below |
https://www.boxtribute.com/actions |
custom | List of ABPs that the user holds (only in ID token) | - |
https://www.boxtribute.com/beta_user |
custom | the largest beta-level the user is permitted to access | - |
When a new base is created, the following is automatically created:
- in dropapp the usergroups: administrator (only created when an organisation is created), coordinator, volunteer (combination of warehouse/free shop volunteer), warehouse volunteer, free shop volunteer, label creator.
- in Auth0 all roles (see above which roles are created).
- in dropapp database table
usergroups_roles
a mapping between the user groups and roles.
The mapping between user groups in dropapp and roles in Auth0 is needed because only one user group can be assigned to a user in dropapp, but multiple roles can be assigned to a user in Auth0.
When a user is created/edited a user group must be assigned. Through the mapping the corresponding roles are then assigned in Auth0 to the user.
Dropapp does not use the JWT since it's a server-side application.
The information of the JWT ID token is used.
When a user issues a request to the back-end, their authorization information needs to be pulled out and converted into a representation that can be programmatically used. Before data is accessed according to the request, the respective permissions are enforced on the current user.
Any valid request (i.e. by an authenticated user) to the back-end contains a JWT access token as bearer
string in the HTTP authorization header. When a URL endpoint is hit, the token is extracted and decoded (in auth.requires_auth()
)
The decoding routine (auth.decode_jwt()
) has to be provided with the public key of the Auth0 domain. The decoding fails with a 401 response if one of the following cases happen:
- the token has expired
- the token audience and issuer do not match the values stored in the back-end
- the token decoding library fails
Any other unexpected error results in a 500 response.
Upon successful decoding, the JWT payload is returned as Python dictionary.
The current user is programmatically represented by the auth.CurrentUser
class. It has the read-only attributes
id
(integer): the user IDorganisation_id
(integer): ID of the organisation that the user belongs to. If the user is a god user, it isNone
is_god
(boolean): whether the user is god user or not (default: false)_base_ids
(list of integers): a data structure indicating the bases in which the user is allowed to access specific resources. This structure has to be queried via theCurrentUser.authorized_base_ids()
method, passing in an RBP name.timezone
(string): timezone identifier determined by Auth0, e.g. "Europe/Berlin"max_beta_level
(integer): the largest beta-level the user is permitted to access
The decoded JWT payload is converted into a CurrentUser
instance with the following procedure:
- if the list of the
roles
custom claim contains"boxtribute_god"
, the attributeis_god
is set to true - the
organisation_id
custom claim is copied to the eponymous attribute - the user ID is extracted from the
sub
claim by stripping theauth0|
prefix, and assigned toid
- if
is_god
is false, the permissions custom claim is parsed:- an element of form
base_X/permission
(permission
is an RBP of formresource:method
) results in the entry{permission: [X]
forbase_ids
- if multiple base IDs are given, they are grouped:
base_X-Y/permission
results in{permission: [X, Y]
(in order to reduce payload size) - a
write
,edit
,create
,delete
permission method impliesread
permission on the same resource - if the element has no
base_X
prefix, the custom claimbase_ids
is used to form an entry{permission: base_ids}
- for examples please see
CurrentUser.from_jwt()
- an element of form
GraphQL resolvers are functions which are called by the GraphQL server to resolve the requested fields on the data level. Resolvers allow fine-grained access over data resources and hence are suited to enforce RBP. Every resolver must enforce RBP in one of the ways described below.
Resolvers can either (A) directly return a single resource entry, (B) directly return a list of resource entries, or (C) load one or more resource entries through a data loader. Enforcement of RBP works differently in these cases.
(A) Enforcement of RBP in the single-resource resolver has to be explicitly called by developers using the authz.authorize()
function. If the current user is authorized to access a resource acc. to the given arguments, the function returns, otherwise it raises an exceptions.Forbidden
exception, resulting in an error for the particular GraphQL field being resolved. If the current user is a god user, the function instantly returns. authz.authorize()
accepts the following combination of arguments (the developer must select the one suitable for the enforcement context):
Arguments | Types | Description | Condition for successful authorization |
---|---|---|---|
permission |
string | base-agnostic RBP name, e.g. category:read |
the current user was granted the given permission in at least one base |
permission and base_id |
string and integer | base-related RBP name and base ID | the current user was granted the given permission in the specified base |
permission and base_ids |
string and list of integers | base-related RBP name and base IDs | the current user was granted the given permission in at least one of the specified bases |
organisation_id |
integer | organisation ID | the current user is member of the organisation with given ID |
organisation_ids |
list of integers | organisation IDs | the current user is member of one of the organisations with given IDs |
user_id |
integer | user ID | the current user's ID matches the given user ID |
Any other combination of arguments can be handled by the function but is considered a development error. If no arguments are given, the function raises an exception.
Note that it is distinguished between base-agnostic (e.g. box state, product category, size range) and base-related resources (e.g. box, beneficiary, product, tag). The former are listed in BASE_AGNOSTIC_RESOURCES
in the authz
module. Any resources missing from the list are assumed to be base-related. When enforcing base-related RBP via authorize()
, either base_id
or base_ids
argument must be provided.
(B) A filter needs to be applied to select only those resource entries in bases that the user is authorized for. This is achieved via the authorized_bases_filter(model)
function which enforces permission for the resource corresponding to the specified model under the hood.
(C) When loading a resource through a data loader, one can omit enforcement of RBP in the loader. However in the loader's batch_load_fn()
method, one of authorize()
or authorized_base_ids()
must be called. This reduces permission enforcement overhead.