-
Notifications
You must be signed in to change notification settings - Fork 4
Role Based Access Control
Access control is managed by checking a User's Permissions. A User has Permissions through their associated Role(s).
Authorization is done at the endpoints. Wrap existing logic in provided functions and define which permission(s) are needed. Appropriate error responses are handled automatically:
+ authorize(conn, "users:create", fn () ->
existing logic
+ end)
Name | GitHub |
---|---|
Chris Laskey | @chrislaskey |
Hailey Willis | @yourFriendWes |
The role-based access control pattern uses three core concepts:
- Users
- Roles
- Permissions
Where the following is true:
- A User may have many associated Roles
- A Role may have many associated Permissions
- A User may have many associated Permissions through their associated Roles
The key to the pattern is:
- Access control is done by checking the collective Permissions of the User
- Roles are only a semantic way to track logical groupings of Permissions, but do not have meaning themselves
In other words, when authorizing an action the users's permissions are checked, never the user's roles.
Let's visualize the pattern with the following example:
1. The `Staff` Role has the permissions:
- clouds:list
- customers:list
2. The `Customer Success Manager` Role has the permissions:
- customers:list
- customers:show
- customers:create
- customers:update
- customers:delete
The Permissions of a User associated with both Roles would be a combination of both:
- clouds:list
- customers:list
- customers:show
- customers:create
- customers:update
- customers:delete
From an access control standpoint, when the User tries to access the Customer List page it verifies if the user has a customers:list
Permission. It does not matter which of the User's Role(s) granted the Permission, only that the Permission exists.
The value of this access control pattern is:
-
Access levels constantly evolve over the lifespan of an application. By tying access control to permissions, it allows administrators to define semantically meaningful Roles and keep them up-to-date as the use-cases change
-
It keeps permissions flat. No more having to remember what the difference is between an "admin" vs. "site admin" vs. "super admin". Users can be assigned many Roles, and each Role has a clear set of permissions.
-
Permissions can be simple to start. As the application's complexity grows, permissions can scale with it to become increasingly granular. It's important to be able to use the same access control pattern as the application grows without becoming limited by it or having to rewrite it from the ground up.
The downsides of this pattern are:
- There is more to manage. The overhead of managing users, roles, and permissions is higher than in other access control patterns. However, this can be mitigated in part by Web and API patterns to make managing changes faster and more intuitive.
App: artemis
The core application logic lies in apps/artemis
.
The database migrations and schema files are defined for:
- Users
- Roles
- Permissions
It also defines Contexts for other applications can use to list / show / create / update / delete records without exposing any implementation details about the database or how the data is stored.
- User Contexts
- Role Contexts
- Permission Contexts
In addition, the Artemis.UserAccess
module defines the core code for checking if a user has the required permission(s).
- User Access Module
Each application endpoint builds on top of these core resources to define their own authorization checks. These are outlined below.
App: artemis_web
In the web application, authorization is done in the controller for each action. Given a standard controller index action:
def index(conn, params) do
render(conn, "index.html")
end
Authorization is added by wrapping the existing logic in an authorize
function. It takes three arguments:
-
conn
which is used to pull out the current user and its permissions - required permission(s)
- a function to execute if the permission(s) check passes
An implementation of the pattern looks like:
def index(conn, params) do
+ authorize(conn, "users:list", fn () ->
render(conn, "index.html")
+ end)
end
If the permission check succeeds, the function passed in as the third argument is executed and the page is rendered.
If the permission check fails, the render_forbidden
function is executed, which sets a 403
status code header and renders the 403.html
error page.
Note: In addition to authorize
which checks for one permissions, there is also authorize_any
and authorize_all
which check for multiple permissions.
App: artemis_api
In the API application, authorization is done in the GraphQL resolver. Given a standard response:
def list(params, context) do
{:ok, ListUsers.call(params)}
end
Authorization is added by wrapping the existing logic in an authorize
function. It takes three arguments:
-
context
which is used to pull out the current user and its permissions - required permission(s)
- a function to execute if the permission(s) check passes
An implementation of the pattern looks like:
def list(params, context) do
+ authorize context, "users:list", fn () ->
{:ok, ListUsers.call(params)}
+ end
end
If the permission check succeeds, the function passed in as the third argument is executed and the page is rendered.
If the permission check fails, a GraphQL error message is returned with the message Unauthorized User
.
Note: In addition to authorize
which checks for one permissions, there is also authorize_any
and authorize_all
which check for multiple permissions.
Thoughts? Feedback? View the discussion thread for this pattern.