Skip to Content
TutorialsBuild a Multi-Tenant App with ScopesScope-Aware Roles & Access Control

Scope-Aware Roles & Access Control

Build a Multi-Tenant App with Scopes set up a tenant hierarchy and scoped the orders collection. This follow-on gives users access per tenant: a manager assigned at one department sees only that department’s branch of the tree, sensitive collections refuse scope-less requests, and your app reads the user’s reachable scopes to build a switcher. See Scopes and Roles & Policies for reference.

Concepts

ConceptDescription
Scoped role assignmentA user holds a role at a specific scope rather than globally
Role inheritanceA role granted at a scope also applies to every descendant scope
scope_configPer-role rule controlling where the role may be assigned
reject missing-URI modeA collection that blocks any request arriving without a scope
/api/scope/availableEndpoint listing the scopes the current user can reach

Create a scope-restricted role

A role’s scope_config controls where it can be assigned. For a per-tenant role, require that an assignment always has a scope (regex .+) so it can never be granted globally.

On /roles, create a Tenant Manager role. Its scope_config:

{ "allowed_scopes": [".+"], "validation_message": "{role_name} must be assigned at a specific scope, not globally." }
allowed_scopes valueWhere the role may be assigned
nullAnywhere (default, no restriction)
[]Nowhere — the role is locked
[".+"]Only when a scope is present (never global)
["^$"]Only globally (never scoped)
["^/<tenantTypeId>:"]Only within scopes matching the pattern

Assign the role at a scope

Assign Tenant Manager to a user at the Acme → Sales department — not globally. In the Studio this is the scoped role assignment UI; over the API it is a daas_user_roles row created with the scope on the X-Resource-Uri header:

curl -X POST https://your-domain.com/api/items/daas_user_roles \ -H "Authorization: Bearer <admin-token>" \ -H "Content-Type: application/json" \ -H "X-Resource-Uri: /<tenantTypeId>:<acmeId>/<deptTypeId>:<salesId>" \ -d '{"user_id":"<user-id>","role_id":"<tenant-manager-id>"}'

The role applies at Sales and inherits down to every team beneath it (North Region, South Region) — but not up to Acme Corp or across to Globex.

Confirm the guardrail works

Try to assign the same role globally (no scope). The scope_config rejects it:

curl -X POST https://your-domain.com/api/items/daas_user_roles \ -H "Authorization: Bearer <admin-token>" \ -H "Content-Type: application/json" \ -d '{"user_id":"<user-id>","role_id":"<tenant-manager-id>"}'
{ "error": "Tenant Manager must be assigned at a specific scope, not globally.", "code": "ROLE_SCOPE_MISMATCH" }

Lock down a sensitive collection

For data that must never be read without a scope, set the collection’s Missing URI Mode to reject (instead of strict). On /scopesCollection Config, add a config for an invoices collection:

  • Field Name: resource_uri
  • Missing URI Mode: reject
  • Inheritance Mode: down

Now a request with no X-Resource-Uri header is refused outright:

curl https://your-domain.com/api/items/invoices \ -H "Authorization: Bearer <token>"
{ "error": "Resource URI required for collection 'invoices'", "code": "MISSING_SCOPE" }

strict treats a missing scope as the root scope — and root combined with down inheritance rolls up every record beneath root. Use reject whenever a collection must always be accessed within an explicit scope.

Power a scope switcher

When a user logs in, list the scopes they can reach with GET /api/scope/available. It returns the user’s authorized items, their ancestors (for breadcrumbs), and the roles that apply at each — including inherited ones.

curl https://your-domain.com/api/scope/available \ -H "Authorization: Bearer <user-token>"

For the Tenant Manager assigned at Sales, the response shows the role applying directly at Sales and inherited at the teams below it:

[ { "name": "Acme Corp", "scope_type": { "name": "Tenant" }, "selectable": false }, { "name": "Sales", "scope_type": { "name": "Department" }, "selectable": true, "roles": [{ "name": "Tenant Manager", "inherited": false }] }, { "name": "North Region", "scope_type": { "name": "Team" }, "selectable": true, "roles": [{ "name": "Tenant Manager", "inherited": true }] }, { "name": "South Region", "scope_type": { "name": "Team" }, "selectable": true, "roles": [{ "name": "Tenant Manager", "inherited": true }] } ]
  • selectable: false — an ancestor shown only for breadcrumb context; the user has no role there.
  • inherited: false — the role is assigned directly at this scope.
  • inherited: true — the role flows down from an ancestor scope.

Render the selectable items as the switcher; when the user picks one, send its uri_path as X-Resource-Uri on every subsequent request.

How a scoped user sees data

Once the user selects a scope, every scope-enabled collection is filtered automatically:

User picksReads of orders / invoices return
Sales (Department)all orders across North + South Region (down inheritance)
North Region (Team)only North Region records
(no scope) on invoicesMISSING_SCOPE error (reject mode)

No tenant-filtering logic lives in your queries — the scope header drives it all.

Next Steps

  • Attach policies to the role to control what actions it can perform; scopes control which rows it can touch. The two compose.
  • Use a narrower allowed_scopes regex (e.g. ^/<tenantTypeId>:) to restrict a role to one level of the hierarchy.
  • Set reject on every collection holding tenant-private data so a forgotten scope header fails loudly instead of leaking root data.
Last updated on