Model authorization for an application where users navigate through nested resources and a single detail page renders many permission-gated components.
This guide extends the Basic App to a multi-level hierarchy where users navigate down through nested resources. At each level, the list shows only the resources the user can access. At the leaf, a detail page renders many UI components, each gated on a different permission.
The example is a deployment platform with three resource types: workspaces contain projects, and projects contain apps. By the end, you’ll have:
listEffectivePermissions instead of repeated check calls
organization (implicit root) └─ workspace └─ project └─ app
Create three resource types in the WorkOS Dashboard under Authorization > Resource Types. Each one points to its parent.
| Name | Slug | Parent |
|---|---|---|
Workspace | workspace | Organization |
Project | project | Workspace |
App | app | Project |
Permissions assigned higher in the hierarchy flow down. A user with a workspace-scoped role automatically has the corresponding access on every project and app inside that workspace. See Resource types for hierarchy constraints and validation rules.
Define permissions for each resource type, then create roles that bundle them. Roles can include permissions for child types, which is what enables inheritance across the hierarchy.
| Permission | Resource type | Description |
|---|---|---|
workspace:view | workspace | View a workspace |
workspace:manage | workspace | Edit workspace settings |
project:view | project | View a project |
project:edit | project | Edit a project |
project:create_app | project | Create apps inside a project |
app:view | app | View an app |
app:deploy | app | Deploy an app |
app:configure | app | Edit app settings |
app:view_logs | app | Read deployment logs |
app:delete | app | Delete an app |
Bundle these into roles scoped to each resource type. A role scoped to workspace can include permissions on project and app because those are descendant types.
| Role | Scoped to | Permissions |
|---|---|---|
workspace-admin | workspace | workspace:view, workspace:manage, project:view, project:edit, project:create_app, app:view, app:deploy, app:configure, app:view_logs, app:delete |
workspace-member | workspace | workspace:view, project:view, app:view |
project-editor | project | project:view, project:edit, project:create_app, app:view, app:deploy, app:configure, app:view_logs |
app-deployer | app | app:view, app:deploy, app:view_logs |
A workspace-admin assignment on a single workspace grants full control of every project and app in it without any per-resource assignment. A project-editor assignment grants access to one project and its apps. An app-deployer assignment grants access to one app only. See Roles and permissions for more on inheritance.
Register the corresponding FGA resource each time a workspace, project, or app is created in the database. Use the database ID as the external_id and reference the parent by its external ID and type.
import { WorkOS } from '@workos-inc/node'; const workos = new WorkOS(process.env.WORKOS_API_KEY); app.post('/workspaces', async (req, res) => { const { organizationId, organizationMembershipId } = req.user; const { name } = req.body; const workspace = await db.workspaces.create({ data: { name, organizationId }, }); await workos.authorization.createResource({ organizationId, resourceTypeSlug: 'workspace', externalId: workspace.id, name: workspace.name, }); await workos.authorization.createRoleAssignment({ organizationMembershipId, roleSlug: 'workspace-admin', resourceTypeSlug: 'workspace', resourceExternalId: workspace.id, }); return res.json(workspace); }); app.post('/workspaces/:workspaceId/projects', async (req, res) => { const { organizationId } = req.user; const { workspaceId } = req.params; const { name } = req.body; const project = await db.projects.create({ data: { name, workspaceId, organizationId }, }); await workos.authorization.createResource({ organizationId, resourceTypeSlug: 'project', externalId: project.id, name: project.name, parentResourceTypeSlug: 'workspace', parentResourceExternalId: workspaceId, }); return res.json(project); });
Apps follow the same pattern with parentResourceTypeSlug: 'project'. Note that the workspace creator gets workspace-admin, which already includes every project and app permission. There’s no need to create per-project or per-app assignments for the creator – inheritance handles it.
At each level, the UI lists the resources the user can access. Use listResourcesForMembership with the appropriate parent_resource filter to scope results to the current view.
app.get('/workspaces', async (req, res) => { const { organizationMembershipId } = req.user; const { data } = await workos.authorization.listResourcesForMembership({ organizationMembershipId, permissionSlug: 'workspace:view', resourceTypeSlug: 'workspace', }); const workspaces = await db.workspaces.findMany({ where: { id: { in: data.map((r) => r.externalId) } }, }); return res.json(workspaces); });
app.get('/workspaces/:workspaceId/projects', async (req, res) => { const { organizationMembershipId } = req.user; const { workspaceId } = req.params; const { data } = await workos.authorization.listResourcesForMembership({ organizationMembershipId, permissionSlug: 'project:view', resourceTypeSlug: 'project', parentResourceTypeSlug: 'workspace', parentResourceExternalId: workspaceId, }); const projects = await db.projects.findMany({ where: { id: { in: data.map((r) => r.externalId) } }, }); return res.json(projects); });
A user with workspace-admin on the workspace appears in this list for every project, because project:view is included in the role through inheritance. A user with project-editor on a single project appears only for that project. A user with neither role sees nothing.
The app list follows the same pattern with permission_slug=app:view and parent_resource_type_slug=project.
The app detail page renders multiple components, each gated on a different permission. Rather than calling check once per component, use listEffectivePermissions to fetch every permission the user has on the resource in a single call. Inherited permissions from workspace and project roles are included automatically.
curl "https://api.workos.com/authorization/organization_memberships/om_01HXYZ/resources/app/app_01H/permissions" \ -H "Authorization: Bearer sk_example_123456789"
app.get('/apps/:appId', async (req, res) => { const { organizationMembershipId } = req.user; const { appId } = req.params; const { data: permissions } = await workos.authorization.listEffectivePermissions({ organizationMembershipId, resourceTypeSlug: 'app', externalId: appId, }); const slugs = new Set(permissions.map((p) => p.slug)); if (!slugs.has('app:view')) { return res.status(404).json({ error: 'Not found' }); } const app = await db.apps.findUnique({ where: { id: appId } }); return res.json({ app, permissions: { canDeploy: slugs.has('app:deploy'), canConfigure: slugs.has('app:configure'), canViewLogs: slugs.has('app:view_logs'), canDelete: slugs.has('app:delete'), }, }); });
The returned permission list reflects the user’s full access on this resource, including everything inherited from roles on parent resources. A workspace admin sees every app:* permission here, even though they don’t have any direct assignment on the app itself.
The React component renders one section per permission:
function AppDetail() { const { appId } = useParams(); const { data, isLoading } = useQuery(`/apps/${appId}`); if (isLoading) return <Spinner />; if (!data) return <NotFound />; const { app, permissions } = data; return ( <article> <h1>{app.name}</h1> <Overview app={app} /> {permissions.canViewLogs && <LogsPanel appId={app.id} />} {permissions.canDeploy && <DeployButton appId={app.id} />} {permissions.canConfigure && <SettingsForm app={app} />} {permissions.canDelete && <DangerZone appId={app.id} />} </article> ); }
Every component above the fold is rendered based on the same permission set, fetched once. There are no waterfall requests as the page hydrates, and adding a new permission-gated component is a one-line change.
FGA exposes three endpoints for answering authorization questions. Pick the one that matches the shape of the question.
| Endpoint | Use when |
|---|---|
check | A single permission on a single resource. Best for action handlers and route gates. |
listEffectivePermissions | Many permissions on a single resource. Best for detail pages that render multiple permission-gated components. |
listResourcesForMembership | A single permission across many resources. Best for list views, navigation, and pickers. |
The pattern from the Basic App used check for a single permission gate. As soon as the detail page needs more than two or three permissions, prefer listEffectivePermissions. It returns the full permission set in one call instead of fanning out to multiple check requests.
The same applies in the opposite direction. When the question is “which resources can this user access,” use listResourcesForMembership rather than calling check once per candidate resource.
The complete flow for a deeply nested app:
1. Model → Three resource types: workspace → project → app 2. Privileges → Roles at each level; parent roles include child permissions 3. Resources → Register each level with the appropriate parent reference 4. Navigation → listResourcesForMembership(parent filter) at each level 5. Detail page → listEffectivePermissions(leaf resource) → render gated components 6. Endpoints → check (one + one), listEffectivePermissions (many + one), listResourcesForMembership (one + many)
Inheritance is what makes this scale. A single workspace-admin assignment grants access to every project and app under that workspace. The list endpoints surface inherited access automatically, and the detail page sees the full inherited permission set in one fetch.
Other “Model your app” guides:
check for every CRUD operation. See Protecting API Endpoints.Related reference: