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
| Concept | Description |
|---|---|
| Scoped role assignment | A user holds a role at a specific scope rather than globally |
| Role inheritance | A role granted at a scope also applies to every descendant scope |
scope_config | Per-role rule controlling where the role may be assigned |
reject missing-URI mode | A collection that blocks any request arriving without a scope |
/api/scope/available | Endpoint 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 value | Where the role may be assigned |
|---|---|
null | Anywhere (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 /scopes → Collection 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 picks | Reads of orders / invoices return |
|---|---|
| Sales (Department) | all orders across North + South Region (down inheritance) |
| North Region (Team) | only North Region records |
(no scope) on invoices | MISSING_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_scopesregex (e.g.^/<tenantTypeId>:) to restrict a role to one level of the hierarchy. - Set
rejecton every collection holding tenant-private data so a forgotten scope header fails loudly instead of leaking root data.