ErtisAuth

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:

text
subject.resource.action.object
SegmentMeaningExamples
subjectWho acts: a user or application id*, 66f1c0d2a4b5c6d7e8f90124
resourceWhat kind of thingusers, roles, orders
actionWhat is donecreate, read, update, delete, or any custom action such as approve
objectWhich single thing: a resource id*, 66f1c0d2a4b5c6d7e8f90127

* matches any value of its segment. Shorter forms leave the missing segments as *:

WrittenMeansAllows
users*.users.*.*everything on users
users.read*.users.read.*reading any user
users.update.66f1…27*.users.update.66f1…27updating that one user
66f1…24.orders.approve.*(as written)that subject approving any order

Rules for the segments:

  • resource and action are names, compared case-insensitively (Users.Read is users.read).
  • subject and object are 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 with 400 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,
  • resource and action: declared by the endpoint (for example users and read),
  • 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:

  1. UBAC first. If any entry of the caller's own permissions or forbidden matches, the caller's own entries decide alone: allowed if a permission matches and no forbidden entry matches, denied otherwise.
  2. Then the role. Otherwise the role decides: allowed if one of its permissions matches and none of its forbidden entries matches. A forbidden entry always wins over a permission of the same role.
  3. 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.
  4. 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:

json
{
	"name": "Support Agent",
	"slug": "support",
	"permissions": [ "users.read", "users.update", "roles.read" ],
	"forbidden": [ "users.update.66f1c0d2a4b5c6d7e8f90124" ]
}
RequestResultWhy
Read any userallowedusers.read
Update user …27allowedusers.update
Update user …24deniedthe role forbids it
Delete any userdeniedno matching permission
Read rolesallowedroles.read

A user with that role and their own entries:

json
{
	"username": "agent-007",
	"role": "support",
	"permissions": [ "users.delete" ],
	"forbidden": [ "roles.read" ]
}
RequestResultWhy
Delete any userallowedthe user's own permission decides (UBAC first)
Read rolesdeniedthe user's own forbidden entry decides
Read any userallowedno UBAC entry matches, the role allows it
Note: because UBAC entries decide before the role, a user's own permission can grant something that the role forbids. Use UBAC for deliberate per-user exceptions.

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:

  • role
  • permissions
  • forbidden
  • is_active
  • user_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#

ResourceEndpointsActions
memberships/membershipscreate, read, update, delete
users/memberships/{m}/userscreate, read, update, delete
otp/memberships/{m}/users/{id}/generate-otpcreate
user-types/memberships/{m}/user-typescreate, read, update, delete
roles/memberships/{m}/rolescreate, read, update, delete
applications/memberships/{m}/applicationscreate, read, update, delete
providers/memberships/{m}/providerscreate, read, update, delete
tokens/memberships/{m}/active-tokens, /revoked-tokens, /codesread, create
events/memberships/{m}/eventsread
webhooks/memberships/{m}/webhookscreate, read, update, delete
mailhooks/memberships/{m}/mailhookscreate, read, update, delete
code-policies/memberships/{m}/code-policiescreate, read, update, delete

Each reference page lists the permission of every endpoint. A few endpoints need a permission you might not expect:

EndpointPermission
GET /users/activation, POST /users/reset-password, POST /users/set-passwordusers.update
POST /users/resend-activation-mailusers.create
GET /users/verify-reset-token, GET /users/check-passwordusers.read
GET /users/{id}/generate-otpotp.create
POST /codes, GET /codes/{user_code}, POST /codes/{user_code}/approve, POST /codes/{user_code}/denytokens.create

Public pages such as a reset password page call these endpoints from your backend, usually with an application's Basic token.

Note: the 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:

http
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#

http
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.

Note: both check endpoints answer a denied permission with 401, not 403.
ErrorWhen
400 PermissionParameterRequiredpermission is missing
400 InvalidRbacpermission is not a valid expression
404 RoleNotFoundThe role does not exist

Designing permissions for your own APIs#

Resources and actions are free-form names, so you can model your own domain:

json
{
	"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