Roles and permissions
Authentication tells you who is making a request. Authorization decides
what they may do. howdy_auth keeps the two apart: howdy/authorization
stores roles, the permissions each role grants, and which users hold which
roles.
A session does not carry roles. Every check reads the current grants, so taking a role away works on the very next request, without waiting for anybody's session to end.
Setting up
Add authorization.schema() to your
migrations, after auth.schema(), and build one
Authorization at startup from the same Repo:
import howdy/authorization as access
let assert Ok(permissions) = access.new(db)
Defining and assigning roles
A role is a name and a list of permissions. Both are plain strings of your choosing:
import howdy/auth/user
let assert Ok(_) =
access.define_role(
permissions,
access.Global,
"editor",
["articles.read", "articles.write"],
by: user.System,
)
let assert Ok(_) =
access.assign(
permissions,
user_id,
"editor",
access.Global,
by: user.Acting(admin),
)
-
define_rolecreates a role, or replaces the permissions of an existing one. People who hold the role keep it. -
assigngives a role to a user, andrevoketakes it away. A role or a user that does not exist is aNotFound. Revoking a role somebody does not hold is not an error.
These are trusted operations. None is exposed over HTTP, and there is no
automatic administrator: decide in your own code who may call them, for
instance from a startup task, or from a handler that has already checked a
permission. by: records who acted, and is user.System for code acting
for nobody.
Checking
Inside a handler that has authenticated the request, add a second guard:
use _ <- guard.require(
ctx,
access.require_permission(permissions, "articles.write", access.Global),
)
A user without the permission gets 403. access.require_role(permissions,
"editor", access.Global) checks for the role by name.
Prefer permissions. A handler that asks "may this user write articles?"
keeps working when you later split editor into two roles; one that asks
"is this user an editor?" does not.
Outside HTTP, access.allowed(permissions, principal, "articles.write",
access.Global) and access.has_role answer the same questions as a
service.Result(Bool). A database failure is an error, never a True.
The rules
- Somebody with several roles has the permissions of all of them.
-
Names are literal. There is no
adminthat may do everything, no wildcards, no role that inherits from another, and no deny rules. - Permissions that nobody has defined are simply not granted.
Scopes
Every role and assignment lives in a scope. access.Global is the scope
for the whole app. access.Organization(id) is a scope for one
organization, and the natural id to use is a group id:
let scope = access.Organization(principal.user.group_id)
use _ <- guard.require(
ctx,
access.require_permission(permissions, "articles.write", scope),
)
A grant only counts in exactly its own scope. A global role does not pass an organization check, so define and assign roles in each scope you check.
Take the scope from something you have verified, such as the signed-in user's group or the organization of the record being changed, and not from a header the client chose. A passed check does not filter your queries either: you still select only that organization's rows.
Caching
By default every check reads the database. To allow a short cache:
let assert Ok(permissions) = access.with_cache(permissions, seconds: 5)
The lifetime is 1 to 60 seconds. Changes made through this module on the same node clear the cache at once, as do suspending and resuming a user. Changes made on another node, or straight in SQL, can go unseen until the lifetime runs out, so keep the default if a revocation must take effect everywhere immediately.
