Let Organization Admins Manage Teams
Give each organization's admin a Team page inside your application, where they add people, organize them into groups, and hand out roles for their own organization and nothing beyond it. In Wayfinder Enterprise, the Northstar Labs admin onboards a new hire, adds them to a group, and makes a teammate a Wayfinder Member. Initech's people never appear on that page, and no call the page makes can reach them.
This page also builds the backend client that the later journeys use to act for one organization, such as starting an invitation or setting a company's identity provider.
Prerequisites
- The platform organization, the shared
Wayfinder AdminandWayfinder Memberroles, and the shared application from B2B Quick Start with React. - An application with a server side, such as a Next.js application, registered as its own confidential application: one that holds a client secret, because it signs people in from your server instead of the browser. Configure its tokens and its Organization Access as the quick start configures
Wayfinder Enterprise Web. See Next.js Configuration.
Where the Boundary Sits
Each organization is an organization unit (OU), a node in the hierarchy that owns its own users, groups, and roles. See Manage Organization Units. A company's admin must never act outside their own organization, and two layers hold that line:
- Your backend pins the organization. Every management call goes through a route in your backend. The route reads the organization from the admin's verified token and forces it onto the call, whatever the browser sent. In this design, this route is the boundary.
- ThunderID checks the data. Whatever token a call carries, a group accepts only members who live in its OU or are shared into it. A role can be assigned only where it is shared with assignments on, and the backend client gets a token only for an organization it is shared with. See What the Server Still Checks.
The route is the boundary because the backend client holds the root system permission. system is the parent of every system:* management permission, and unlike them it is not confined to one OU: a token that holds it acts in every OU of the deployment. Starting an invitation needs it, which is why the backend client holds it. See System Permissions.
Without root system, ThunderID adds a boundary of its own on the Data Plane:
- Permissions stay in one organization. A management permission such as
system:user:vieworsystem:role:updateacts only on the resources of the OU the caller's token names, and a list returns only that OU's resources. - Lists filter by organization.
filter=ouId eq "<ou-id>"lists one organization's resources. Users come back owned first, then shared into the organization. Roles come back the same way. Groups come back owned only, because groups are never shared. - You grant only what you hold. Assigning a role or adding a group member that would grant more permissions than the caller holds fails with
403andSAZ-4030. See Permission Requirements.
If your backend never starts invitations, give its role system:user, system:group, and system:role instead of root system. ThunderID then confines every call to the organization in the backend's token, a second boundary behind your route.
Keep Management Calls on Your Server
The SDK's management components call the ThunderID management API. Route those calls through your own backend instead of sending the admin's token straight to ThunderID:
- The admin's token stays small. It carries only your application's own permissions, such as
user:manage, and never a ThunderID management permission. - Audience checks keep working. When a deployment sets
server.security.rest.audience, the management API accepts only tokens issued for that audience, and a token issued for your application's API is refused. - The root permission never reaches an admin. A token that carries
systemacts in every organization, so it must never be issued to an organization admin.
The backend checks the admin's own permission, pins the call to the admin's organization, and forwards it with a token of its own:
Build the Backend Proxy
Create the Backend Client
Create a confidential client for your backend that signs no one in. Share it with every child organization, so it can get a token for any of them, and give it a role that holds the management permission it calls with. The role is assigned in the client's own OU, Wayfinder Enterprise, and only roles assigned there count when the client gets a token for an organization.
resource_type: role
id: 019f0a10-0000-7000-8000-000000000013
name: Wayfinder Enterprise Backend
ouHandle: wayfinder-enterprise
permissions:
- resourceServerId: 01900000-0000-7000-8000-000000000020 # The System resource server
permissions:
- system
assignments:
- id: 019f0a10-0000-7000-8000-000000000032
type: app
---
resource_type: application
id: 019f0a10-0000-7000-8000-000000000032
ouHandle: wayfinder-enterprise
name: Wayfinder Enterprise Backend
type: m2m
sharingPolicies:
- id: wayfinder-backend-all-companies
childUnits:
mode: all
inboundAuthConfig:
- type: oauth2
config:
clientId: WAYFINDER_ENTERPRISE_BACKEND
clientSecret: <client-secret>
grantTypes:
- client_credentials
tokenEndpointAuthMethod: client_secret_basic
publicClient: false
token:
accessToken:
clientConfig:
attributes:
- ouId
- ouHandle
- ouName
01900000-0000-7000-8000-000000000020 is the ID of the System resource server, the resource server that holds the system:* permissions, in a default deployment. Import the file where you manage configuration: in the Control Plane console, click Import / Export at the bottom of the sidebar, or send it to POST /import. See Import Resources, and Machine-to-Machine (M2M) Access for Organization Units for sharing policies on clients.
Give Admins Your Application's Own Permissions
The admin's token carries your application's permissions, not ThunderID's. Create a resource server for your application's API, with the identifier urn:wayfinder-enterprise:api, and declare these permissions on it. See Manage Resource Servers.
Then add the permissions to the Wayfinder Admin role you already created: in the Console, open Roles, select Wayfinder Admin, and add them on the Permissions tab. Do not create a second role. If you manage the role as a declarative resource, these are the permissions to add to it:
permissions:
- resourceServerId: <wayfinder-enterprise-api-id>
permissions:
- user:view
- user:manage
- group:view
- group:manage
- role:view
- role:manage
A token carries only the permissions the application asks for at sign-in, so your application requests them as scopes. See Point the SDK Components at the Proxy.
Request a Token for the Admin's Organization
For each call, request a token at the admin's organization's own token endpoint. Name the System resource server in resource, the resource indicator that says which API the token is for, so the token carries its permissions. https://localhost:8090/mcp is the System resource server's identifier in a default deployment:
curl -X POST https://localhost:8090/ou/<admin-ou-id>/oauth2/token \
-u 'WAYFINDER_ENTERPRISE_BACKEND:<client-secret>' \
-d 'grant_type=client_credentials' \
-d 'scope=system' \
-d 'resource=https://localhost:8090/mcp'
The token's ouId, ouHandle, and ouName claims name the admin's organization. An organization the client is not shared with gets 400 unauthorized_client. Cache one token per organization until it nears expiry. The journeys that follow call this token $NORTHSTAR_TOKEN when it is issued for Northstar Labs, and $INITECH_TOKEN when it is issued for Initech.
Pin Every Call to the Caller's Organization
Expose one backend route that the management components call. The following sketch shows its shape for reading users. Extend the same pattern to groups, roles, and user types:
export async function GET(request: Request, {params}: {params: Promise<{path: string[]}>}) {
const admin = await verifyAccessToken(request); // Signature, issuer, audience, expiry, ouId
if (!admin) return Response.json({error: 'Sign in again.'}, {status: 401});
const [collection, id] = (await params).path;
if (collection !== 'users') return new Response(null, {status: 404});
if (!admin.scopes.includes('user:view')) return new Response(null, {status: 403});
const token = await getBackendToken(admin.ouId); // /ou/{ouId}/oauth2/token, cached
const url =
id === undefined
? `https://localhost:8090/users?${new URLSearchParams({filter: `ouId eq "${admin.ouId}"`})}`
: `https://localhost:8090/users/${encodeURIComponent(id)}?${new URLSearchParams({ouId: admin.ouId})}`;
return fetch(url, {headers: {Authorization: `Bearer ${token}`}});
}
The route pins the organization on every kind of call:
- Lists. It replaces any filter the browser sends with
ouId eq "<admin's ouId>". The SDK'sGroupListandRoleListtake no organization filter, so this rewrite is what limits them to one organization. - One record. It reads a user as the admin's organization sees it, with
GET /users/{id}?ouId=<admin's ouId>. ThunderID answers404for a user that organization cannot see. - Bodies. It overwrites
ouIdin every body it forwards.POST /usersandPOST /groupscreate in that organization, andPOST /roles/{id}/assignments/addandPOST /roles/{id}/assignments/removechange assignments in it.
Allow only the calls your Team page makes, and return 404 for everything else. A write follows the same shape. This sketch forwards a role assignment, and applies the role allow-list from What the Server Still Checks:
const ASSIGNABLE_ROLE_IDS = [process.env.WAYFINDER_ADMIN_ROLE_ID, process.env.WAYFINDER_MEMBER_ROLE_ID];
export async function POST(request: Request, {params}: {params: Promise<{path: string[]}>}) {
const admin = await verifyAccessToken(request);
if (!admin) return Response.json({error: 'Sign in again.'}, {status: 401});
const [collection, roleId, part, action] = (await params).path;
if (collection !== 'roles' || part !== 'assignments' || !['add', 'remove'].includes(action)) {
return new Response(null, {status: 404});
}
if (!admin.scopes.includes('role:manage')) return new Response(null, {status: 403});
if (!ASSIGNABLE_ROLE_IDS.includes(roleId)) return new Response(null, {status: 403});
const body = await request.json();
const token = await getBackendToken(admin.ouId);
return fetch(`https://localhost:8090/roles/${roleId}/assignments/${action}`, {
method: 'POST',
headers: {Authorization: `Bearer ${token}`, 'Content-Type': 'application/json'},
body: JSON.stringify({...body, ouId: admin.ouId}),
});
}
Point the SDK Components at the Proxy
Override the management endpoints in ThunderIDProvider, so the components call your backend route instead of ThunderID. UserCreate also lists user types, so route userTypes too. Request your API's permissions as scopes, and name its resource server in resource, so the admin's token carries what the route checks:
import {ThunderIDProvider} from '@thunderid/nextjs/server';
const appUrl = process.env.WAYFINDER_APP_URL;
export default function RootLayout({children}: {children: React.ReactNode}) {
return (
<html lang="en">
<body>
<ThunderIDProvider
scopes={['openid', 'profile', 'ou', 'user:view', 'user:manage', 'group:view', 'group:manage', 'role:view', 'role:manage']}
signInOptions={{resource: 'urn:wayfinder-enterprise:api'}}
preferences={{user: {fetchUserProfile: false}}}
endpoints={{
users: `${appUrl}/api/admin/users`,
groups: `${appUrl}/api/admin/groups`,
roles: `${appUrl}/api/admin/roles`,
userTypes: `${appUrl}/api/admin/user-types`,
}}
>
{children}
</ThunderIDProvider>
</body>
</html>
);
}
fetchUserProfile: false makes the SDK read the signed-in user's profile from the ID token, so the admin's token is never sent to /users/me either.
Build the Team Page
Pass the admin's organization to each component. The list, create, and assignment components then show and change only that organization:
'use client';
import {GroupCreate, GroupList, GroupMembers, RoleAssignments, UserCreate, UserList} from '@thunderid/nextjs';
import {useState} from 'react';
export default function TeamManagement({ouId, adminRoleId, memberRoleId}) {
const [groupId, setGroupId] = useState();
return (
<>
<UserList filter={`ouId eq "${ouId}"`} title="People in your company" />
<UserCreate ouId={ouId} title="Add a user" />
<GroupList title="Groups" onSelect={(group) => setGroupId(group.id)} selectedGroupId={groupId} />
{groupId && <GroupMembers groupId={groupId} ouId={ouId} editable />}
<GroupCreate ouId={ouId} title="Create a group" />
<RoleAssignments roleId={adminRoleId} ouId={ouId} editable title="Wayfinder Admin" />
<RoleAssignments roleId={memberRoleId} ouId={ouId} editable title="Wayfinder Member" />
</>
);
}
Read ouId from the admin's session, for example currentOrganizationUnit?.id from useThunderID(). When ouId is omitted, UserCreate, GroupCreate, and RoleCreate fall back to the ouId in the signed-in user's profile. With fetchUserProfile: false, as here, the profile comes from the ID token, so that is the organization the admin signed in to.
What the Server Still Checks
The proxy decides which organization a call acts in, and ThunderID still checks the data itself:
- A group member, or a role assignee, must live in the organization or be shared into it.
- A role can be assigned only in an organization the role is owned by or shared with, with assignments left on. See Share a Role With Other Organization Units.
- An organization the backend client is not shared with gets no token.
The backend client holds the root system permission, so the rule that you grant only what you hold never stops it. Let the proxy assign only the roles your Team page offers, such as Wayfinder Admin and Wayfinder Member, and refuse every other role ID.