Manage Scope
The DaaS scope system is the standard way to implement multi-tenancy and hierarchical data partitioning. Scopes live in built-in platform tables (daas_scope_types, daas_scope_items) and are enforced at the API and permission layers automatically — no junction tables, no custom FK columns on every collection.
MCP-First: Use the
scopeMCP tool for all scope setup. SQL is only needed for custom Supabase RLS policies.
Lifecycle Events: All scope operations emit events:
daas_scope_types.items.create/update/delete,daas_scope_items.items.create/update/delete,daas_scope_collection_config.items.create/update/delete. You can attach runtime extensions to react to scope changes (e.g., provisioning resources when a new tenant is created).
Critical Rules
uri_pathis immutable once assigned — A scope item'suri_pathis set on creation and baked into all user-role and access assignments. Never update it; delete and recreate if restructuring.- Permission inheritance is upward-only — A role granted at a parent scope is effective at all descendant scopes. The reverse is NOT true: a role at a child does NOT grant access at the parent.
- Data filter direction is configurable —
inheritance_mode: "exact"returns data assigned strictly to the active scope,"down"returns data for active scope + all descendants. Set per collection viaupdate_config. - Scope context is set by the frontend — All API calls respect the
X-Resource-Uriheader ordaas_resource_uricookie. The backend never assumes a scope; if none is provided,missing_uri_modeon each collection decides the fallback. - Scope-scoped access entries — Assign
resource_uriondaas_accessrecords to restrict a policy to a specific scope node and its descendants. Anullresource_urimeans global (no scope restriction). - Admin roles bypass scope — System admins (
is_admin: true) operate globally. Scope only affects regular user permission checks.
Architecture
Scope Tree (types define levels, items are instances):
daas_scope_types daas_scope_items
───────────────── ──────────────────────────────────────────
id name level id type_id name uri_path
── ──────── ───── ── ─────── ─────────── ──────────────────────────────────
t1 Region 0 i1 t1 APAC /<t1>:<i1>
t2 Branch 1 i2 t1 EMEA /<t1>:<i2>
i3 t2 Singapore /<t1>:<i1>/<t2>:<i3>
i4 t2 London /<t1>:<i2>/<t2>:<i4>
Permission Inheritance (upward propagation):
Role assigned at /<t1>:<i1> → effective at /<t1>:<i1>/<t2>:<i3> ✓
Role assigned at /<t1>:<i1>/<t2>:<i3> → NOT effective at /<t1>:<i1> ✗
Data Filtering (per collection):
inheritance_mode: "exact" → WHERE resource_uri = '<active>'
inheritance_mode: "down" → WHERE resource_uri LIKE '<active>%'
Scope Context Flow:
ScopeSwitcher UI → daas_resource_uri cookie → API middleware
→ enforcePermission({ resourceUri })
→ getPermissionFilters(user, collection, resourceUri)
Steps
Step 1: Design the Hierarchy (Scope Types)
Define one or more scope types representing the levels of your hierarchy, in order from top to bottom.
Via MCP — create a top-level type:
// mcp_daas_scope -> action: create_type
{
"name": "Region",
"description": "Top-level geographic region",
"level": 0,
"meta": {}
}
Create a child level:
// mcp_daas_scope -> action: create_type
{
"name": "Branch",
"description": "Branch office within a region",
"level": 1,
"meta": {}
}
Read all types (to get UUIDs for next steps):
// mcp_daas_scope -> action: read_types
{}
Tip: For a flat tenant model (SaaS with no sub-levels), use a single type (e.g.,
Tenant) atlevel: 0.
Step 2: Create Scope Items
Each scope item is a concrete instance (e.g., "APAC", "Singapore HQ"). Provide parent_uri when creating child items.
Create a root item:
// mcp_daas_scope -> action: create_item
{
"type_id": "<region-type-uuid>",
"name": "APAC",
"meta": {}
}
Create a child item (scoped under APAC):
// mcp_daas_scope -> action: create_item
{
"type_id": "<branch-type-uuid>",
"name": "Singapore HQ",
"parent_uri": "/<region-type-uuid>:<apac-item-uuid>",
"meta": {}
}
Read items to obtain uri_path values:
// mcp_daas_scope -> action: read_items
{
"type_id": "<branch-type-uuid>"
}
The uri_path returned (e.g., /<t1>:<i1>/<t2>:<i3>) is used in all subsequent role assignments and access entries.
Step 3: Assign Users to Roles in Specific Scopes
Use assign_user_role to grant a user a role that is effective only within that scope node and its descendants.
Assign user to a role within Singapore HQ scope:
// mcp_daas_scope -> action: assign_user_role
{
"user_id": "<user-uuid>",
"role_id": "<branch-manager-role-uuid>",
"resource_uri": "/<region-type-uuid>:<apac-uuid>/<branch-type-uuid>:<sgp-uuid>"
}
Assign user to a global role (no scope restriction):
// mcp_daas_users -> action: add_roles
{
"user_id": "<user-uuid>",
"roles": ["<global-admin-role-uuid>"]
}
assign_user_rolewrites todaas_user_roles.resource_uri. Global roles (nullresource_uri) are managed through theuserstooladd_rolesaction.
Read scoped role assignments:
// mcp_daas_scope -> action: read_user_roles
{
"user_id": "<user-uuid>"
}
Remove a scoped role assignment:
// mcp_daas_scope -> action: remove_user_role
{
"user_id": "<user-uuid>",
"role_id": "<branch-manager-role-uuid>",
"resource_uri": "/<region-type-uuid>:<apac-uuid>/<branch-type-uuid>:<sgp-uuid>"
}
Role Scope Restrictions (scope_config)
Roles can restrict where they may be assigned using scope_config. When scope_config.allowed_scopes is set, any attempt to assign that role to a user is validated against the patterns.
scope_config value |
Meaning |
|---|---|
null / absent |
No restriction — role can be assigned at any scope |
{ "allowed_scopes": ["/<type>:<id>/*"] } |
Role can only be assigned at URIs matching any of the listed patterns (supports * wildcard segments) |
{ "allowed_scopes": [] } |
Role is locked — cannot be assigned to any user |
Validation occurs on daas_users.items.create and daas_users.items.update via a built-in platform hook (lib/hooks/role-scope.ts). It applies to:
assign_user_rolescope actionPATCH /api/users/:id(role changes)PATCH /api/items/daas_users/:id(role changes)- MCP
mcp_daas_users→updateaction (role changes)
Error codes on validation failure:
| Code | Meaning |
|---|---|
ROLE_SCOPE_MISMATCH |
The assignment resource_uri does not match any pattern in allowed_scopes |
ROLE_SCOPE_LOCKED |
The role has allowed_scopes: [] (empty array) — no assignments allowed |
Checking assignability before assigning:
When reading roles, the platform computes an assignable boolean field based on the current scope context. Use this to filter the role picker UI:
// mcp_daas_roles -> action: list
{}
// Each role in the response includes:
// "assignable": true — can be assigned at the current scope
// "assignable": false — scope_config prevents assignment here
Tip: Set
scope_configviamcp_daas_roles→createorupdateaction. See the create-rbac skill for examples.
Step 4: Configure Collection Data Filtering
Each collection can be independently configured for how it behaves under scope context.
Read current configs:
// mcp_daas_scope -> action: read_configs
{}
Scenario A — New collection (via Data Model UI or MCP)
Add the scope URI column as a Many-to-One field (type: text, related collection: daas_scope_items, related field: uri_path). Then call scope: create_config to register it.
Via Data Model UI:
In the field creation modal, select interface "Many to One", set type to text, choose related collection daas_scope_items, and select related field uri_path. Then run create_config below.
Via MCP (two steps):
// Step 1 — create collection with scope column as M2O field
// mcp_daas_collections -> action: create
// IMPORTANT: resource_uri MUST be created with interface "list-m2o" and special ["m2o"].
// This causes the system to automatically:
// • Add the FK constraint: resource_uri → daas_scope_items(uri_path)
// • Insert the daas_relations metadata row
// • Reload the PostgREST schema cache
// Creating it as a plain "input" string field skips all of this and breaks ?fields=resource_uri.*
{
"collection": "orders",
"meta": { "icon": "shopping_cart" },
"fields": [
{ "field": "id", "type": "uuid", "schema": { "is_primary_key": true } },
{
"field": "resource_uri",
"type": "string",
"schema": { "is_nullable": true, "max_length": 1024 },
"meta": {
"interface": "list-m2o",
"special": ["m2o"],
"options": {
"related_collection": "daas_scope_items",
"related_field": "uri_path",
"on_delete": "RESTRICT"
},
"note": "Scope URI for tenant filtering"
}
}
]
}
// Step 2 — register scope config row
// mcp_daas_scope -> action: create_config
// The FK + daas_relations were already wired in Step 1.
// This call adds the daas_scope_collection_config row that tells the API middleware
// to apply scope filtering on reads/writes.
{
"config_data": {
"collection": "orders",
"field_name": "resource_uri",
"inheritance_mode": "exact",
"missing_uri_mode": "strict"
}
}
The field can be named anything (e.g.
org_uri,tenant_uri).resource_uriis just the convention. There is no automatic wiring based on field name —create_configmust always be called explicitly.
Scenario B — Existing collection (add scope to it)
First add the scope URI column (if not present), then register it with create_config.
If the column doesn't exist yet:
Via Data Model UI: Add a new field with interface "Many to One", type text, related collection daas_scope_items, related field uri_path.
Via MCP fields tool:
// mcp_daas_fields -> action: create
{
"collection": "orders",
"field": "resource_uri",
"type": "text",
"meta": {
"interface": "list-m2o",
"special": ["m2o"],
"options": {
"related_collection": "daas_scope_items",
"related_field": "uri_path",
"on_delete": "RESTRICT"
}
}
}
Then register scope config:
// mcp_daas_scope -> action: create_config
// FK + daas_relations were already created by the fields tool above.
// This adds the scope config row that enables API middleware filtering.
{
"config_data": {
"collection": "orders",
"field_name": "resource_uri",
"inheritance_mode": "exact",
"missing_uri_mode": "strict"
}
}
Config Settings Reference
| Setting | Values | Effect |
|---|---|---|
field_name |
column name in collection | Name of the TEXT column that stores the scope uri_path (usually resource_uri) |
inheritance_mode |
"exact" | "down" |
exact (default): strict match only; down: includes all descendant scope items |
missing_uri_mode |
"strict" | "reject" |
strict (default): no scope = treat as root; reject: block if no scope header |
Why the FK constraint matters
- Prevents orphaned
resource_urivalues when scope items are deleted ON DELETE RESTRICTblocks deleting a scope item that is still referenced by collection rows- Enables API clients to request nested scope item data via M2O expansion:
?fields=resource_uri.name
Update config settings (after creation):
// mcp_daas_scope -> action: update_config
{
"collection": "orders",
"config_update": {
"inheritance_mode": "down"
}
}
Step 5: Assign Policies to Specific Scopes
To restrict a policy to users operating within a given scope and its descendants, set resource_uri on the daas_access record.
Assign a policy to a role only within a specific scope:
// mcp_daas_access -> action: create
{
"role": "<branch-manager-role-uuid>",
"policy": "<branch-data-read-policy-uuid>",
"resource_uri": "/<region-type-uuid>:<apac-uuid>/<branch-type-uuid>:<sgp-uuid>"
}
Assign a global policy (no scope restriction):
// mcp_daas_access -> action: create
{
"role": "<global-viewer-role-uuid>",
"policy": "<read-all-policy-uuid>"
}
When
check_permission_with_scopeis called, it finds access entries whereresource_uriis an ancestor-or-equal of the active scope URI. A nullresource_urialways matches.
Step 6: Frontend — Scope Selection & API Integration
The active scope is communicated to all API calls via the daas_resource_uri cookie or X-Resource-Uri header. See Client-Side Scope Integration for full patterns.
Using the ScopeSwitcher Component
ScopeSwitcher is a pre-built component that calls /api/scope/available to show accessible scopes for the current user and sets the cookie on selection:
import ScopeSwitcher from '@/components/ScopeSwitcher';
// Place in your header/navbar:
<ScopeSwitcher />
Bug 21 warning — NEVER add a "fallback to all tenants" path in the
/api/scope/availableproxy. If DaaS returns non-200 and you fall back to fetching all rows fromdaas_scope_items, every user will see every tenant regardless of their assignments. This is a data leakage vulnerability. Surface the real DaaS error instead:// ✅ CORRECT — proxy DaaS directly, surface real errors const resp = await fetch(`${daasUrl}/api/scope/available`, { headers, cache: 'no-store' }); return NextResponse.json(await resp.json(), { status: resp.status }); // ❌ WRONG — dangerous fallback if (!resp.ok) { const allScopes = await fetch(`${daasUrl}/api/items/daas_scope_items`); // returns ALL tenants return NextResponse.json(await allScopes.json()); }
ScopeProvider Context (app-wide scope state)
Wrap your layout with a ScopeProvider that reads the active scope from the cookie and exposes it to all components.
Bug 20: The
ScopeProvidermust validate the stored cookie URI against the user's actual available scopes. If the URI isn't in the user's list (stale from a previous user's session), clear it. This is the second defensive layer after the logout route clears the cookie.
// lib/contexts/ScopeContext.tsx
'use client';
import { apiRequest } from '@/lib/buildpad/services/api-request';
import { createContext, useContext, useEffect, useState } from 'react';
export interface ScopeItem {
id: string;
name: string;
uri_path: string;
}
interface ScopeContextValue {
resourceUri: string | null;
activeTenant: ScopeItem | null;
availableScopes: ScopeItem[];
setResourceUri: (uri: string | null) => void;
isLoading: boolean;
}
const ScopeContext = createContext<ScopeContextValue>({
resourceUri: null, activeTenant: null, availableScopes: [],
setResourceUri: () => {}, isLoading: true,
});
export function ScopeProvider({ children }: { children: React.ReactNode }) {
const [resourceUri, setResourceUriState] = useState<string | null>(null);
const [availableScopes, setAvailableScopes] = useState<ScopeItem[]>([]);
const [isLoading, setIsLoading] = useState(true);
useEffect(() => {
const match = document.cookie.match(/(?:^|;\s*)daas_resource_uri=([^;]*)/);
const cookieUri = match ? decodeURIComponent(match[1]) : null;
apiRequest<{ data: ScopeItem[] }>('/api/scope/available')
.then((data) => {
const scopes = data.data ?? [];
setAvailableScopes(scopes);
// Validate the cookie against the user's actual scopes (Bug 20)
const uriIsValid = cookieUri ? scopes.some(s => s.uri_path === cookieUri) : false;
if (uriIsValid) {
setResourceUriState(cookieUri);
} else {
if (cookieUri) document.cookie = 'daas_resource_uri=; path=/; max-age=0';
const autoUri = scopes.length === 1 ? scopes[0].uri_path : null;
if (autoUri) setResourceUri(autoUri);
}
})
.catch(() => {})
.finally(() => setIsLoading(false));
}, []);
const setResourceUri = (uri: string | null) => {
if (uri) {
document.cookie = `daas_resource_uri=${encodeURIComponent(uri)}; path=/; max-age=${30 * 24 * 3600}`;
} else {
document.cookie = 'daas_resource_uri=; path=/; max-age=0';
}
setResourceUriState(uri);
};
const activeTenant = availableScopes.find(s => s.uri_path === resourceUri) ?? null;
return <ScopeContext.Provider value={{ resourceUri, activeTenant, availableScopes, setResourceUri, isLoading }}>{children}</ScopeContext.Provider>;
}
export const useScope = () => useContext(ScopeContext);
Clearing scope on logout (server-side — Bug 20)
The logout route must clear the cookie server-side so the next user doesn't inherit it:
// app/api/auth/logout/route.ts — inside performLogout:
cookieStore.delete('daas_resource_uri'); // REQUIRED — prevents 403 for next user
Making scoped API requests
The cookie is sent automatically on all same-origin requests. For explicit override or cross-origin:
// Cookie is auto-sent — no extra code needed for standard fetch
const res = await fetch('/api/items/orders', { credentials: 'include' });
// Explicit header override (overrides cookie if both present)
const res = await fetch('/api/items/orders', {
headers: { 'X-Resource-Uri': resourceUri },
credentials: 'include',
});
Handling 403 and scope-related errors
The API returns 403 when the user lacks permission in the active scope, and 400 when the scope URI is malformed or unrecognised.
// lib/api.ts — centralised fetch wrapper with scope error handling
async function scopedFetch(url: string, options?: RequestInit) {
const res = await fetch(url, { credentials: 'include', ...options });
if (res.status === 403) {
const body = await res.json().catch(() => ({}));
const code = body?.errors?.[0]?.extensions?.code;
if (code === 'FORBIDDEN_SCOPE') {
// User's role doesn't cover the active scope — redirect to scope selector
window.location.href = '/select-scope';
return;
}
throw new Error('Permission denied');
}
if (res.status === 400) {
const body = await res.json().catch(() => ({}));
const code = body?.errors?.[0]?.extensions?.code;
if (code === 'INVALID_SCOPE') {
// Stale or invalid scope cookie — clear it and reload
document.cookie = 'daas_resource_uri=; path=/; max-age=0';
window.location.reload();
return;
}
throw new Error('Bad request');
}
if (!res.ok) throw new Error(`Request failed: ${res.status}`);
return res.json();
}
React error boundary for scope access denied
// components/ScopeErrorBoundary.tsx — catch scope permission errors in UI
'use client';
import { useEffect } from 'react';
import { useRouter } from 'next/navigation';
export function ScopeGuard({ children }: { children: React.ReactNode }) {
const router = useRouter();
useEffect(() => {
const handler = (event: PromiseRejectionEvent) => {
if (event.reason?.message === 'Permission denied') {
router.push('/select-scope?error=access_denied');
}
};
window.addEventListener('unhandledrejection', handler);
return () => window.removeEventListener('unhandledrejection', handler);
}, [router]);
return <>{children}</>;
}
Common Scenarios
Scenario 1: Simple SaaS — each user belongs to one tenant
Scope types: Tenant (level 0)
Scope items: Acme Corp, Beta LLC, Gamma Inc
Assignment: Each user gets a role at exactly one tenant URI
Config: all collections: inheritance_mode=exact, missing_uri_mode=strict
Setup:
- Create one scope type
Tenantatlevel: 0 - Create one scope item per customer
- Assign each user
assign_user_roleat their tenant'suri_path - Configure all collections with
update_config(scope_field,exact,deny) - Place
<ScopeSwitcher />in header — users with one tenant never see the switcher
Scenario 2: Regional hierarchy — managers see all branches under them
Scope types: Region (level 0), Branch (level 1)
Scope items: APAC → [Singapore, Sydney], EMEA → [London, Paris]
Assignment: Regional manager at /<region>:<apac>
Branch staff at /<region>:<apac>/<branch>:<sgp>
Config: orders, tasks: inheritance_mode=down, missing_uri_mode=strict
Result:
- Singapore staff see only Singapore data
- APAC manager sees APAC + Singapore + Sydney data
- EMEA staff have no access to APAC data at all
Scenario 3: Department isolation — HR, Finance, Engineering each see their own data
Scope types: Department (level 0)
Scope items: HR, Finance, Engineering
Assignment: Cross-department admin at root (no resource_uri = global)
Dept members assigned at their dept URI
Config: documents, tickets: inheritance_mode=exact, missing_uri_mode=strict
announcements: no scope config (visible to all, no isolation)
Scenario 4: Mixed — some collections scoped, some global
Not every collection needs scope filtering. Only configure update_config on collections that should be isolated. Collections without a config entry have no scope filtering — they return all data regardless of scope context.
Collections with scope config: orders, tasks, documents
Collections without scope config: announcements, help_articles, categories
Scenario 5: User onboarding — no scope assigned yet
When a new user logs in and has no daas_user_roles entries with resource_uri, /api/scope/available returns an empty list. Handle this in the frontend:
// app/select-scope/page.tsx
'use client';
import { useEffect, useState } from 'react';
import { useRouter } from 'next/navigation';
export default function SelectScopePage() {
const [scopes, setScopes] = useState<{ id: string; name: string; uri_path: string }[]>([]);
const router = useRouter();
useEffect(() => {
fetch('/api/scope/available', { credentials: 'include' })
.then((r) => r.json())
.then((data) => {
const selectable = (data.data ?? []).filter((s: any) => s.selectable !== false);
if (selectable.length === 1) {
// Auto-select if only one option
document.cookie = `daas_resource_uri=${encodeURIComponent(selectable[0].uri_path)}; path=/; max-age=${30 * 24 * 3600}`;
router.replace('/');
} else {
setScopes(selectable);
}
});
}, [router]);
if (scopes.length === 0) {
return <p>You have not been assigned to any scope. Contact your administrator.</p>;
}
return (
<ul>
{scopes.map((s) => (
<li key={s.id}>
<button => {
document.cookie = `daas_resource_uri=${encodeURIComponent(s.uri_path)}; path=/; max-age=${30 * 24 * 3600}`;
router.push('/');
}}>{s.name}</button>
</li>
))}
</ul>
);
}
Using mcp_daas_items with Scope
The mcp_daas_items tool accepts a resource_uri parameter that fully propagates scope through all CRUD operations, including cross-collection reads and writes via relations.
What resource_uri enforces
| Operation | Effect |
|---|---|
| read | Filters results to items whose scope field matches resource_uri (exact or down depending on inheritance_mode) |
| create | Injects resource_uri into the scope-enabled field of the new record — client value is overridden |
nested O2M create (inline { create: [...] }) |
Also injects resource_uri into each child record being created inline |
| O2M relation read | Child collection sub-queries are also filtered by resource_uri if the child is scope-enabled |
| update | Scope filtering applies when resolving which records to update |
Omit resource_uri |
No scope filtering — global/admin-level access |
resource_uri: null |
Root scope — only records with no scope assignment |
Examples
Read orders scoped to a specific branch:
// mcp_daas_items -> action: read
{
"collection": "orders",
"resource_uri": "/<region-type-uuid>:<apac-uuid>/<branch-type-uuid>:<sgp-uuid>",
"query": { "filter": { "status": { "_eq": "pending" } } }
}
Create an order with O2M order_lines — both get resource_uri injected automatically:
// mcp_daas_items -> action: create
{
"collection": "orders",
"resource_uri": "/<region-type-uuid>:<apac-uuid>/<branch-type-uuid>:<sgp-uuid>",
"data": {
"customer_name": "Acme Corp",
"order_lines": {
"create": [
{ "product": "Widget A", "qty": 3, "price": 100 }
]
}
}
}
Source of
resource_uriin agent context: The agent should read the user's active scope from thedaas_resource_uricookie value or from a priormcp_daas_scope→read_user_rolescall, then pass thaturi_pathasresource_uri.
- Scope Management — Full MCP tool reference, resource URI format, collection config settings
- Scope Permissions — Permission inheritance, enforcePermission internals, inheritance_mode / missing_uri_mode details
- Client-Side Scope Integration — ScopeProvider, REST API patterns, 400/403 handling, onboarding flow