Authorization
The RBAC and UBAC permission model.
Once a caller is authenticated, ErtisAuth decides whether it may perform the request. The decision combines two models:
- RBAC (role based access control): the permissions of the caller's role.
- UBAC (user based access control): the permissions of the caller itself, stored on the user or the application.
The same model protects ErtisAuth's own API and, through the SDK, your APIs too.
Permission expressions#
A permission is a dot-separated expression of up to four segments:
subject.resource.action.object| Segment | Meaning | Examples |
|---|---|---|
subject | Who acts: a user or application id | *, 66f1c0d2a4b5c6d7e8f90124 |
resource | What kind of thing | users, roles, orders |
action | What is done | create, read, update, delete, or any custom action such as approve |
object | Which single thing: a resource id | *, 66f1c0d2a4b5c6d7e8f90127 |
* matches any value of its segment. Shorter forms leave the missing segments as *:
| Written | Means | Allows |
|---|---|---|
users | *.users.*.* | everything on users |
users.read | *.users.read.* | reading any user |
users.update.66f1…27 | *.users.update.66f1…27 | updating that one user |
66f1…24.orders.approve.* | (as written) | that subject approving any order |
Rules for the segments:
resourceandactionare names, compared case-insensitively (Users.Readisusers.read).subjectandobjectare ids, compared exactly.- A segment can't be empty, can't start or end with
.or*, and*can't be used as part of a value (user*is invalid). Invalid expressions are rejected with400 InvalidRbac.
UBAC expressions#
The permissions and forbidden lists of a user or an application use the same format without the subject (the subject is the user or application itself): resource.action.object, with the same shorter forms (users, users.read).
How a request is checked#
For every request to a protected endpoint, ErtisAuth builds the expression of the request from the endpoint and the caller:
subject: the id of the caller,resourceandaction: declared by the endpoint (for exampleusersandread),object: the id in the route for single-resource endpoints (GET /users/{id}), otherwise*.
For example GET /memberships/{m}/users/66f1…27 by user 66f1…24 is checked as 66f1…24.users.read.66f1…27.
Then the decision is made in this order:
- UBAC first. If any entry of the caller's own
permissionsorforbiddenmatches, the caller's own entries decide alone: allowed if a permission matches and no forbidden entry matches, denied otherwise. - Then the role. Otherwise the role decides: allowed if one of its
permissionsmatches and none of itsforbiddenentries matches. A forbidden entry always wins over a permission of the same role. - Then the own-record rules. If neither allows the request, two exceptions still apply, unless the role explicitly forbids the action:
- a user may update their own user record, and an application its own application record;
- an application may read its own application record.
- Finally the scopes. If the token is a scoped token, its scopes must also cover the request, whatever granted it above.
A denied request answers 403 AccessDenied.
Every user and application must have a role. One whose role is empty or no longer exists is denied on every protected endpoint, whatever its own permissions are (403 AccessDenied, "The user has no role" or "The user role is not found by the given slug"). The API always requires a role, so this only happens when the data was changed outside of it, or when a role that is still in use is deleted.
Examples#
A role:
{
"name": "Support Agent",
"slug": "support",
"permissions": [ "users.read", "users.update", "roles.read" ],
"forbidden": [ "users.update.66f1c0d2a4b5c6d7e8f90124" ]
}| Request | Result | Why |
|---|---|---|
| Read any user | allowed | users.read |
Update user …27 | allowed | users.update |
Update user …24 | denied | the role forbids it |
| Delete any user | denied | no matching permission |
| Read roles | allowed | roles.read |
A user with that role and their own entries:
{
"username": "agent-007",
"role": "support",
"permissions": [ "users.delete" ],
"forbidden": [ "roles.read" ]
}| Request | Result | Why |
|---|---|---|
| Delete any user | allowed | the user's own permission decides (UBAC first) |
| Read roles | denied | the user's own forbidden entry decides |
| Read any user | allowed | no UBAC entry matches, the role allows it |
The same expression can't be in both permissions and forbidden: such a role or application is rejected with 400 ModelValidationError, such a user with 409 UbacsConflicted.
Changing privileged fields#
The own-record rule lets users edit their own profile, but some fields grant power. Changing any of these on a user requires a real users.update permission on that user, from the role or from UBAC; the own-record rule does not cover them:
rolepermissionsforbiddenis_activeuser_type
Otherwise the update is rejected with 403 AccessDenied and the list of the fields.
source_provider and connected_accounts are managed by ErtisAuth itself: values sent by clients are ignored.
Resources of the ErtisAuth API#
| Resource | Endpoints | Actions |
|---|---|---|
memberships | /memberships | create, read, update, delete |
users | /memberships/{m}/users | create, read, update, delete |
otp | /memberships/{m}/users/{id}/generate-otp | create |
user-types | /memberships/{m}/user-types | create, read, update, delete |
roles | /memberships/{m}/roles | create, read, update, delete |
applications | /memberships/{m}/applications | create, read, update, delete |
providers | /memberships/{m}/providers | create, read, update, delete |
tokens | /memberships/{m}/active-tokens, /revoked-tokens, /codes | read, create |
events | /memberships/{m}/events | read |
webhooks | /memberships/{m}/webhooks | create, read, update, delete |
mailhooks | /memberships/{m}/mailhooks | create, read, update, delete |
code-policies | /memberships/{m}/code-policies | create, read, update, delete |
Each reference page lists the permission of every endpoint. A few endpoints need a permission you might not expect:
| Endpoint | Permission |
|---|---|
GET /users/activation, POST /users/reset-password, POST /users/set-password | users.update |
POST /users/resend-activation-mail | users.create |
GET /users/verify-reset-token, GET /users/check-password | users.read |
GET /users/{id}/generate-otp | otp.create |
POST /codes, GET /codes/{user_code}, POST /codes/{user_code}/approve, POST /codes/{user_code}/deny | tokens.create |
Public pages such as a reset password page call these endpoints from your backend, usually with an application's Basic token.
memberships resource is installation-wide. Its read permission discloses the secret keys of the memberships, so grant it only to the operators of the installation.The admin role#
The setup creates the reserved admin role with create, read, update and delete on every resource above (for example *.users.read.*). Another role with the admin slug can't be created (409 ReservedRole), and the admin role can't be deleted (409 SystemRolesCannotBeDeleted).
Checking a permission#
For the caller#
Ask whether the caller's token may do something, with its role, its UBAC entries and its scopes:
GET /memberships/{membershipId}/roles/check-permission?permission=orders.approve
Authorization: Bearer <access_token>Any valid token of the membership may call it. Answers 200 OK when allowed and 401 when denied.
This is what the SDK calls on every request to your APIs.
For a role#
GET /memberships/{membershipId}/roles/{roleId}/check-permission?permission=users.delete
Authorization: Bearer <access_token>Requires roles.read. Answers 200 OK when the role has the permission and 401 when not.
401, not 403.| Error | When |
|---|---|
400 PermissionParameterRequired | permission is missing |
400 InvalidRbac | permission is not a valid expression |
404 RoleNotFound | The role does not exist |
Designing permissions for your own APIs#
Resources and actions are free-form names, so you can model your own domain:
{
"name": "Warehouse Manager",
"slug": "warehouse-manager",
"permissions": [
"orders.read",
"orders.update",
"orders.ship",
"products"
],
"forbidden": [ "products.delete" ]
}Then protect your endpoints with the same names; see .NET SDK.
Found a mistake in the docs? Open an issue