navree
Documentation

Resolving the workspace

Find the current workspace from a header, a subdomain, a path prefix, a token claim or the workspace's own domain, and check that the user belongs to it.

On this page

Every workspace request names its workspace somewhere: in a header, in the host name, in the URL or inside the access token. Navree's resolvers read those places in the order you configure, load the workspace and check that the signed-in user may enter it before your handler runs: as a member or, with organizations on, through the organization the workspace belongs to. Handlers then take a CurrentWorkspace.

The resolvers

A resolver finds an identifier: the workspace's slug (acme) or its id (a UUID). An identifier that parses as a UUID is looked up by id, anything else by slug.

ResolverReadsExample
headerThe header named by workspace.header (default X-Workspace), trimmedX-Workspace: acme
pathThe path segment after workspace.path_prefix (default /w)/api/w/acme/deals
subdomainThe first label of the host under workspace.base_domainacme.app.example.com
claimThe claim workspace.claim (default workspace) of the request's bearer JWT{"sub": "...", "workspace": "acme"}
domainThe workspace's own verified domain (custom domains)crm.acme-corp.com

The header suits API clients and single-page apps that keep the current workspace in client state. An empty value counts as no value.

HTTP

Path prefix

With path_prefix = "/w", the segment after the first w segment of the path is the identifier. Mount the routes that use it under the prefix, with a path parameter in its place:

src/deals/controller.rs

A prefix can have several segments (/teams/t). The resolver takes the first match in the path.

Subdomain

Set workspace.base_domain to the domain your workspaces live under. The resolver takes the single label in front of it from the Host header (or the request URI's authority), ignoring the port and case:

base_domainHostIdentifier
app.example.comacme.app.example.comacme
app.example.comACME.app.example.com:8443acme
app.example.comapp.example.comnone
app.example.comeu.acme.app.example.comnone (two labels)
localhost:3000acme.localhost:3000acme

Without base_domain the subdomain resolver finds nothing, which is why it can sit in the default list. Behind a reverse proxy, make sure the proxy passes the original Host header through.

Token claim

The claim resolver reads a claim from the payload of the request's Authorization: Bearer JWT. It only runs for an authenticated request: the authentication layer has already verified the token's signature (a token that fails verification never produces a signed-in user), so the resolver reads the payload without verifying it again. A string claim is used as is, a number as its decimal form.

Navree's own access tokens carry a fixed set of claims (sub, iat, exp, iss, aud, jti, typ), with no workspace claim. Use this resolver with tokens issued by an identity provider or a custom authentication strategy that adds one. Whatever the resolver finds, the membership check still applies, so a claim can only select a workspace the user belongs to.

Custom domain

Featuredomains A workspace can bring its own domain: crm.acme-corp.com instead of acme.app.example.com. Custom domains (DomainsModule) verify the domain through DNS; with domain in the list, a request to a verified domain names that domain's workspace. Put it first, so a domain always names its own workspace:

config/app.toml

DomainsModule looks the request's host up (cached for domains.cache_secs) and marks the request with a DomainWorkspace { workspace_id, host }, which the resolver reads. A host that is not a verified domain, such as your app's own, falls through to the next resolver. Without DomainsModule the resolver finds nothing. Custom domains name workspaces only: domain in workspace.organizations.resolvers or workspace.partitions.resolvers fails the boot.

Order

workspace.resolvers lists the resolvers to try, in order. The first one that finds an identifier wins, and the rest are not consulted:

config/app.toml

The default is ["header", "path", "subdomain", "claim"]. From the environment, use a comma-separated string: APP_WORKSPACE__RESOLVERS=subdomain,header. A list with an unknown resolver or the same resolver twice fails the boot.

Pinned credentials

A credential can belong to one workspace, such as a token of an OAuth client created with --workspace. Its authentication strategy puts a PinnedWorkspace(id) in the request extensions. A pinned request that names no workspace resolves to the pinned one, and a request that names another one, through a resolver or a route's path parameter, gets a 403 This token is limited to another workspace. The membership check still applies. A custom strategy can pin its credentials the same way: parts.extensions.insert(PinnedWorkspace(workspace_id)).

CurrentWorkspace

Take CurrentWorkspace in a handler to get the resolved workspace and the user's membership. It dereferences to the workspace row, so ws.name and ws.slug work directly:

Rust
MethodReturns
id()The workspace id
workspace()The Workspace row (id, name, slug, owner_id, organization_id, timezone, country, created_at, updated_at)
member()The user's Member row (for an organization grant, built from the organization membership)
user_id()The signed-in user's id
role()Their WorkspaceRole
granted_by()GrantedBy::Membership, or GrantedBy::Organization { organization_id, role }
permissions()Their role's permission strings, sorted
ability(), access()Their abilities inside this workspace, as navree-access's Ability and PrincipalAccess
can(action, subject)A type-level ability check
has_permission(permission)Whether they hold deals.export (as #[permissions] checks it)
authorize(permission)Ok(()), or a 403
scope(select)select limited to this workspace's rows (row isolation)
isolation()The configured Isolation
resolver()The resolver that found the workspace (ResolverKind::Domain for a custom domain; None when a path parameter did)

The workspace is resolved once per request: the guard, CurrentWorkspace, WorkspaceUow and later extractors share the result.

The InWorkspace guard

Route checks (#[roles], #[permissions], #[can]) run before extractors. To evaluate them against the member's role in the workspace, resolve the workspace in a guard first:

Rust
  • InWorkspace::guard() uses the configured resolvers.
  • InWorkspace::from_path("workspace") reads the route's own path parameter instead, whatever the configuration says. The built-in /api/workspaces/{workspace}/... routes use it.

A route without the guard can still take CurrentWorkspace, but its route-level checks see the user's global roles and permissions, not their workspace role. Put the guard on every route that declares access checks and works inside a workspace.

Errors

The guard and the extractor answer in Navree's JSON error shape:

SituationStatusmessage
No signed-in user401Unauthorized
No resolver found a workspace400The request names no workspace.
No workspace with that slug or id404Workspace not found.
The user is not a member, and no organization grant reaches the workspace403You are not a member of this workspace.
A pinned credential names another workspace403This token is limited to another workspace.
400 Bad Request

Organizations and partitions

The levels around a workspace resolve the same way, each with its own settings:

LevelExtractor and guardDefault resolversSettings
OrganizationCurrentOrganization, InOrganization::guard()X-Organization, /o/{slug}, subdomain, claim organizationworkspace.organizations.*
PartitionCurrentPartition, PartitionScopeX-Partition, claim partition (path /p/{slug} when listed)workspace.partitions.*

With organizations on, the membership check accepts an organization grant: a member of the workspace's organization whose grant reaches the workspace enters it with the workspace role their organization role gives. A membership of the workspace wins over the grant, and granted_by() says which one applied.

Jobs and commands

Outside HTTP there is no request to resolve. Load the workspace with the Workspaces service and scope a unit of work to it with enter:

Rust

enter applies what a workspace request applies to its transaction (see data isolation). workspaces.resolve("acme") finds one workspace by slug or id.

Testing

Send the header, the host or the bearer token with the test client:

Rust