navree
Documentation

Members and roles

Add members, assign roles, define your own roles and check workspace-scoped permissions with navree-access.

On this page

A member is a user in a workspace, with one role there. The same user can be the owner of one workspace and a viewer in another. Roles carry permission strings, and inside a workspace request access control evaluates them for the member's role in that workspace.

Built-in roles

Four roles exist in every app:

RoleBuilt-in permissionsMeant for
ownerall.*Everything in the workspace, including deleting and transferring it
adminworkspace.read, workspace.update, workspace.members.*, workspace.invitations.*Running the workspace and its people
memberworkspace.read, workspace.members.readRegular work, as you grant it
viewerworkspace.read, workspace.members.readRead-only access, as you grant it

member and viewer start the same: the difference is what you give each of them. In Rust a role is a WorkspaceRole (Owner, Admin, Member, Viewer or Custom(String)), stored by name in workspace_members.role.

Permissions for your data

Add permissions to a role under workspace.roles.<role>. They join the role's built-in ones:

config/app.toml

A permission is <prefix>.<action>, as everywhere in navree-access: deals.update lets the member update Deal records when deals is the permission prefix of the Deal subject, and * stands for every action. From the environment, a role's list is a comma-separated string: APP_WORKSPACE__ROLES__MEMBER__PERMISSIONS=deals.*,companies.read.

The module's own subjects use the prefixes below, which the built-in routes check:

SubjectPrefixPermissions the routes check
Workspaceworkspaceworkspace.update, workspace.delete, workspace.transfer
WorkspaceMemberworkspace.membersworkspace.members.read, .create, .update, .delete
WorkspaceInvitationworkspace.invitationsworkspace.invitations.read, .create, .delete
WorkspacePartitionworkspace.partitionsworkspace.partitions.read, .create, .update, .delete (partitions on)

With partitions on, admin also gets workspace.partitions.*, and member and viewer get workspace.partitions.read.

Your own roles

Any other name under workspace.roles defines a role:

config/app.toml

Role names are 1 to 64 lower-case letters, digits, _, - and ., starting with a letter or digit. Assigning a role that is neither built in nor configured is a 400: role must be one of the following values: admin, billing, member, owner, viewer.

How workspace roles reach access control

Inside a workspace request, navree-access builds the member's abilities from your abilities function as it does for every request, with a context that describes the member in that workspace:

AbilityCtx fieldInside a workspace
userThe signed-in user, whose roles holds one entry: their workspace role
permissionsThe role's permissions (built-in plus workspace.roles)
team_idSome(workspace id)
organization_idThe workspace's organization, if it is attached to one
permission_subjectsEvery registered subject's prefix

So ctx.grant_permissions(rules) grants the role's permissions, ctx.has_role("admin") asks about the workspace role, and ctx.team_id tells a rule which workspace it is in:

src/abilities.rs

Two things carry over from the user's global access: a member who holds the app's super-admin role (AccessOptions::super_admin_role) keeps can manage all, and a token's abilities still narrow what the request may do. Global roles and permissions are not mixed in: inside a workspace, the workspace role decides. Membership comes first in every case, so a super-admin who isn't a member of the workspace still gets a 403.

The InWorkspace guard hands these abilities to navree-access before the route's checks run. Behind it:

  • #[roles("admin")] asks for the workspace role admin;
  • #[permissions("deals.export")] asks whether the role holds deals.export;
  • #[can(Update, Deal, from = "id")] loads the deal and checks it against the member's rules;
  • the Abilities, CurrentAccess and Accessible<E, A> extractors return the workspace abilities.

navree-access's GET /api/me/abilities route has no guard, so it returns the user's global rules. To send the workspace rules to the browser, add a route behind the guard:

Rust
src/deals/controller.rs

A can(.., from = "id") check on an entity registered with m.workspace_scoped::<Deal>() first confirms that the record belongs to the current workspace, so a deal of another workspace is a 404 even for an owner (see data isolation).

Checks in the handler

CurrentWorkspace answers the same questions in code:

Rust

Checks outside a request

Code that runs outside a workspace request, such as a channel authorizer or a service deciding who may see a record, asks navree::contracts::permitted instead:

Rust

WorkspaceModule answers with the user's role in that workspace, evaluated as behind the guard (a non-member is refused), and AccessModule answers with their global roles and permissions; either one granting it is enough. With no workspace given, only the global answer counts. Operations use this to decide who sees them.

Roles through an organization

With organizations on, a user can also enter a workspace as a member of the organization it is attached to. Their organization role maps to a workspace role, and that role is what the workspace sees:

Organization roleWorkspace role (default)
ownerowner
adminadmin
membermember
viewerviewer
An app-defined roleThe one its workspace_role setting names, else the workspace role of the same name
config/app.toml

Everything on this page then applies to the mapped role: the abilities context's user.roles holds it, route checks evaluate it and ws.role() returns it. A membership of the workspace wins over the organization's role, so an organization viewer who is also an admin member of one workspace is an admin there. ws.granted_by() tells you which applied, and organization members don't appear in the workspace's member list.

Managing members

The Members service changes memberships inside a unit of work:

Rust
MethodErrors
add(&uow, workspace, user, role)409 when the user is a member already; 400 on role for an unknown role
change_role(&uow, workspace, user, role)404 when they are not a member; 409 when they are the owner and role isn't owner
remove(&uow, workspace, user)404 when they are not a member; 409 for the owner
list(workspace), find(workspace, user)

list returns each Member with the user's id, name and email when the user directory knows them.

The owner

Every workspace has exactly one owner, workspaces.owner_id, who always has the owner role. Removing or demoting them is a 409, The workspace owner can't be removed or demoted; transfer ownership first., so a workspace never loses its last owner. Other members can hold the owner role too and be removed like anyone else.

Ownership moves to another member with transfer_ownership. The new owner gets the owner role and the previous owner becomes an admin:

Rust

A user who isn't a member is a 400 on user_id.

The member routes

RoutePermissionAnswer
GET /api/workspaces/{workspace}/membersworkspace.members.read{"data": [member, ...]}
POST /api/workspaces/{workspace}/members {user_id, role?}workspace.members.create201, the member (role defaults to member)
PATCH /api/workspaces/{workspace}/members/{user} {role}workspace.members.updateThe member
DELETE /api/workspaces/{workspace}/members/{user}workspace.members.delete204
POST /api/workspaces/{workspace}/leave(member)204; 409 for the owner
POST /api/workspaces/{workspace}/transfer {user_id}workspace.transferThe workspace

Granting the owner role, or changing or removing a member who has it, also takes the owner role: an admin who tries gets a 403, Only an owner can grant or change the owner role. A member comes back as:

JSON

Names and emails of members

Member lists and invitation checks look users up through a UserDirectory. The default reads the table named by workspace.users_table (users, navree-auth's table) through its id, name and email columns, and knows no one when the table doesn't exist or the setting is empty. When users live elsewhere, register your own:

src/directory.rs

Events

EventWhen
MemberAdded (workspace.member_added)A user joins: added, by invitation, or as the creator
MemberRoleChanged (workspace.member_role_changed)A role changes (from, to)
MemberRemoved (workspace.member_removed)A user leaves or is removed
OwnershipTransferred (workspace.ownership_transferred)The owner changes (from, to)

Each is emitted once the unit of work commits. See events for listeners.

Testing

Give each user a role, then check what each role may do:

Rust