navree
Documentation

Organizations

Group workspaces under an organization whose members reach all or some of them with one role, read across them, and ask to join.

On this page

Some customers are bigger than one workspace. A company runs a workspace per country, a partner manages workspaces for its clients, a group admin needs one dashboard over every team. An organization is an optional level above workspaces for them: it groups workspaces, and its members get a role there that also lets them into all or some of its workspaces, without a membership in each one.

Organizations are off by default and change nothing until you turn them on. Workspaces keep working on their own, and a workspace belongs to at most one organization.

Turning them on

Featureworkspace Organizations are part of WorkspaceModule. Turn them on in the workspace section:

config/app.toml

From the environment: APP_WORKSPACE__ORGANIZATIONS__ENABLED=true, APP_WORKSPACE__ORGANIZATIONS__RESOLVERS=subdomain,header. While enabled is false, the organization routes aren't mounted, workspace resolution ignores organizations, and the services answer a 500 that names the setting (workspace.organizations.enabled is false: turn it on to use this in the log).

The workspace migration set creates the organization tables whether the feature is on or not, so turning it on later needs no migration:

TableColumns
organizationsid, name, slug (unique), owner_id, country, timezone, created_at, updated_at
organization_membersid, organization_id (cascades on delete), user_id, role, scope (all or only), created_at, updated_at; one row per organization and user
organization_grantsid, organization_id, member_id (cascades), workspace_id (cascades), created_at: the workspaces of an only member
access_requestsid, organization_id (cascades), workspace_id, user_id, message, status, role, decided_by, decided_at, reason, created_at, updated_at

It also adds workspaces.organization_id, the organization a workspace is attached to.

Creating organizations

The Organizations service creates organizations and attaches workspaces to them. As with the workspace services, writes take the unit of work to run in, go through Repo and emit their events once it commits:

Rust

The creator becomes the owner, with the owner role and every workspace. Slugs follow the workspace rules (Acme Group becomes acme-group, then acme-group-2; or set one with .slug("acme")). country is an ISO 3166-1 alpha-2 code, stored in upper case; timezone is an IANA name such as Europe/London, stored under its current name (Asia/Calcutta is stored as Asia/Kolkata). An unknown zone or country is a 400 on that field. The organization's zone is the zone of its workspaces that have none of their own: see time zones.

MethodDoes
create(&uow, owner, NewOrganization)Creates the organization; the owner joins with owner
update(&uow, id, OrganizationChanges), rename(&uow, id, name)Changes the name, country or time zone; Some(None) clears country or timezone
attach(&uow, id, workspace)Attaches a workspace; attaching it again does nothing; 409 The workspace belongs to another organization.
detach(&uow, id, workspace)Detaches it and drops the grants that named it; the workspace and its members stay; 404 when it isn't attached
transfer_ownership(&uow, id, to)to (a member, else a 400 on user_id) becomes the owner with every workspace; the previous owner becomes an admin
delete(&uow, id)Deletes the organization with its members, grants and access requests; its workspaces are detached and kept
find(id), find_by_slug(slug), resolve(slug_or_id)Looks one up; resolve is a 404 when there is none
for_user(user)The user's organizations, each an OrgMembership { organization, role, workspaces }
workspaces(id), visible_workspaces(id, user)The attached workspaces, and the ones user reaches
membership(id, user), all()One membership; every organization
enter(&uow, id)Scopes a job's unit of work to the organization (see reading across workspaces)

Organization roles

Each member has one OrgRole. It gives permissions inside the organization and maps to the workspace role the member has in the workspaces they reach:

RoleBuilt-in permissionsWorkspace role it gives
ownerall.*owner
adminorganization.read, organization.update, organization.attach, organization.detach, organization.members.*, organization.access_requests.*admin
memberorganization.read, organization.members.readmember
viewerorganization.read, organization.members.readviewer

Add permissions to a role, change the workspace role it gives, or define your own role under workspace.organizations.roles:

config/app.toml

workspace_role defaults to the workspace role of the same name, which must exist: a role named partner without workspace_role needs a workspace.roles.partner. A name that is neither fails the boot. The permissions use the module's subjects:

SubjectPrefixPermissions the routes check
Organizationorganizationorganization.update, organization.delete, organization.transfer, organization.attach, organization.detach
OrganizationMemberorganization.membersorganization.members.read, .create, .update, .delete
AccessRequestorganization.access_requestsorganization.access_requests.read, .update

Members and the workspaces they reach

OrgMembers adds members with a role and a WorkspaceSelector: All of the organization's workspaces, including ones attached later, or Only the listed ones:

Rust
MethodErrors
add(&uow, org, user, role, workspaces)409 when the user is a member already; 400 on role for owner or an unknown role; 400 on workspaces for a workspace outside the organization
update(&uow, org, user, role, workspaces)None keeps a value; 404 when they are not a member; 409 when it would demote or limit the owner
grant(&uow, org, user, role, workspaces)add for a new member, update for an existing one
remove(&uow, org, user)404 when they are not a member; 409 for the owner
list(org), find(org, user), selector(org, user)

list returns each member with their workspaces and, when the user directory knows them, their name and email. The owner (organizations.owner_id) always has the owner role and every workspace: removing, demoting or limiting them is a 409, The organization owner can't be removed, demoted or limited; transfer ownership first.

In JSON a selector is "all" or an array of workspace ids, in the routes and in events.

Entering workspaces through the organization

With organizations on, workspace resolution admits a user who is a member of the workspace, or a member of its organization whose selector includes it. An organization member enters with the workspace role their organization role gives, so behind InWorkspace the route's #[roles], #[permissions] and #[can] checks see that role, and nothing in your workspace routes changes.

A membership of the workspace wins over the organization grant. CurrentWorkspace::granted_by() tells the two apart:

Rust

Workspaces::for_user and GET /api/workspaces list the workspaces a user reaches through organizations next to their own, and Workspaces::role_of(workspace, user) returns the role and how it was granted. A user who enters through an organization has no workspace_members row: ws.member() carries their organization membership's id and dates with the mapped role, and the workspace's member list doesn't show them.

The current organization

Organization-level routes (a dashboard over every workspace, the organization's settings) take a CurrentOrganization. It resolves the organization from the request, loads it and checks that the signed-in user is a member:

ResolverReadsExample
headerworkspace.organizations.header (default X-Organization)X-Organization: acme-group
pathThe segment after workspace.organizations.path_prefix (default /o)/api/o/acme-group/reports
subdomainThe label in front of workspace.organizations.base_domainacme-group.partners.example.com
claimThe claim workspace.organizations.claim (default organization) of the bearer JWT{"organization": "acme-group"}

They work as the workspace resolvers do, with their own settings, so a request can name a workspace and an organization at once. Put the InOrganization guard on the controller so the route's checks run against the organization role:

src/reports/controller.rs

InOrganization::from_path("organization") reads a route's path parameter instead; the built-in /api/organizations/{organization}/... routes use it.

MethodReturns
id(), organization()The organization (it also dereferences to the row: org.slug)
member(), role()The user's OrgMember row and OrgRole
workspaces()Their WorkspaceSelector
workspace_ids()The ids of the attached workspaces they reach, by name, read once per request
reaches(workspace)Whether they reach that workspace
permissions(), has_permission(p), authorize(p)The role's permissions; authorize is Ok(()) or a 403
ability(), access(), can(action, subject)Their abilities in the organization
scope(select)select limited to the rows of workspace_ids()
resolver()The resolver that found it (None for a path parameter)
SituationStatusmessage
No signed-in user401Unauthorized
No resolver found an organization400The request names no organization.
No organization with that slug or id404Organization not found.
The user is not a member403You are not a member of this organization.
A credential pinned to a workspace403This token is limited to a workspace.

Inside an organization request, navree-access builds the abilities from your abilities function with the organization role: ctx.user.roles holds the organization role, ctx.permissions its permissions, ctx.team_id is None and ctx.organization_id is Some(organization id). Inside a workspace request, ctx.organization_id is the workspace's organization, if it has one.

Reading across workspaces

OrgRepo<E> reads a workspace-scoped entity across several workspaces: every query filters the workspace column by the ids you pass, usually org.workspace_ids(). An empty list matches nothing.

OrgRepo::<E>::Does
select(workspaces)E::find() limited to the rows of those workspaces
scope(workspaces, select)Adds the filter to any Select<E>
find(&uow, workspaces, id), find_or_failOne record, if it belongs to one of them; else not found (a 404 from a handler)

OrgRepo only reads. Writes go to one workspace at a time, through WorkspaceRepo inside a workspace request. It reads row isolation's shared tables; schema and database isolation keep each workspace's rows apart physically.

On PostgreSQL, an organization request sets navree.organization for its transaction. A row-level security policy built with RlsPolicy::new("deals").organizations() lets it read the rows of every workspace attached to the organization, and still writes only with navree.workspace set (see data isolation). The policy sees the whole organization; OrgRepo with workspace_ids() narrows a member limited to Only some workspaces. Jobs scope a unit of work with organizations.enter(&uow, id).

Access requests

A user who isn't a member can ask to join, for the whole organization or one of its workspaces. Admins (organization.access_requests.*) approve, which makes them a member, or decline:

Rust
  • A user has at most one pending request per organization: a second one is a 409, An access request is already pending., and so is a request from a member.
  • Approval { role, workspaces } defaults to member, reaching the workspace the request named, or every workspace when it named none.
  • decline(&uow, org, id, decided_by, reason) records an optional reason (up to 1,000 characters, as the message).
  • Deciding a decided request is a 409; an unknown one a 404.
  • pending(org) lists the pending requests with the users' names and emails; for_user(user) lists a user's requests.

The module tells no one by itself. Listen to AccessRequested to notify the admins.

The routes

With workspace.routes and workspace.organizations.enabled on, the module mounts these routes under the API prefix. {organization} is a slug or an id, and every route needs a signed-in user:

RoutePermissionAnswer
GET /api/organizationsThe caller's organizations, each with their role and workspaces
POST /api/organizations {name, slug?, country?, timezone?}201; the caller becomes the owner
GET /api/organizations/{organization}(member)The organization
PATCH /api/organizations/{organization} {name?, country?, timezone?}organization.updateThe organization; null clears country or timezone
DELETE /api/organizations/{organization}organization.delete204
POST /api/organizations/{organization}/transfer {user_id}organization.transferThe organization
POST /api/organizations/{organization}/leave(member)204; 409 for the owner
GET, POST /api/organizations/{organization}/membersorganization.members.read, .createPOST takes {user_id, role?, workspaces?}
PATCH, DELETE /api/organizations/{organization}/members/{user}organization.members.update, .deletePATCH takes {role?, workspaces?}
GET /api/organizations/{organization}/workspacesorganization.readThe workspaces the caller reaches, with their role in each
POST /api/organizations/{organization}/workspaces {workspace}organization.attach, and owning the workspaceThe workspace
DELETE /api/organizations/{organization}/workspaces/{workspace}organization.detach204
GET /api/organizations/{organization}/access-requestsorganization.access_requests.readThe pending requests
POST /api/organizations/{organization}/access-requests {workspace_id?, message?}(signed in)201, the request
POST .../access-requests/{request}/approve {role?, workspaces?}organization.access_requests.update{request, member}
POST .../access-requests/{request}/decline {reason?}organization.access_requests.updateThe request

An admin limited to some workspaces grants only workspaces they reach and changes only members whose workspaces they reach: anything else is a 403, You can only grant workspaces you reach. (code workspace_grant_forbidden). Attaching a workspace takes its owner: an organization admin can't pull in a workspace that isn't theirs (a 403, not_the_workspace_owner). An organization comes back as:

JSON

Workspaces in every response carry organization_id (null for a standalone workspace). The routes are in the OpenAPI document under the Organizations tag.

Events

Each event is emitted once its unit of work commits. They live in navree::workspace::events:

EventNamePayload
OrganizationCreatedorganization.createdorganization_id, name, slug, owner_id
OrganizationDeletedorganization.deletedorganization_id, slug, detached (workspace ids)
OrganizationOwnershipTransferredorganization.ownership_transferredorganization_id, from, to
WorkspaceAttachedorganization.workspace_attachedorganization_id, workspace_id
WorkspaceDetachedorganization.workspace_detachedorganization_id, workspace_id
OrgMemberAddedorganization.member_addedorganization_id, user_id, role, workspaces
OrgMemberChangedorganization.member_changedorganization_id, user_id, from, to, workspaces
OrgMemberRemovedorganization.member_removedorganization_id, user_id, role
AccessRequestedorganization.access_requestedorganization_id, request_id, user_id, workspace_id
AccessRequestApprovedorganization.access_request_approvedorganization_id, request_id, user_id, role, decided_by
AccessRequestDeclinedorganization.access_request_declinedorganization_id, request_id, user_id, decided_by

Testing

Turn the feature on in the test app's configuration, then check that each member sees what their grant allows:

Rust