WorkOS Docs Homepage
FGA
API referenceDashboardSign In
OverviewOverviewQuick StartQuick StartCore ConceptsResource TypesResource TypesResourcesResourcesRoles and PermissionsRoles and PermissionsAssignmentsAssignmentsHigh-Cardinality EntitiesHigh-Cardinality EntitiesAccess ControlAccess ChecksAccess ChecksResource DiscoveryResource DiscoveryIntegrationsAuthKit IntegrationAuthKit IntegrationStandalone IntegrationStandalone IntegrationIdP Role AssignmentIdP Role AssignmentModel Your AppBasic AppBasic AppMulti-level InheritanceMulti-level InheritanceProtecting API EndpointsProtecting API EndpointsHigh-Cardinality DataHigh-Cardinality DataShare ButtonShare ButtonMigration GuidesMigrate from OpenFGAMigrate from OpenFGAMigrate from SpiceDBMigrate from SpiceDBMigrate from Oso CloudMigrate from Oso Cloud
API Reference
API Reference
Events
Events
Integrations
Integrations
Migrate to WorkOS
Migrate to WorkOS
SDKs
SDKs

Share Button

Build a share dialog that lists current members of a resource and grants or revokes access.

On this page

  • What you’ll build
  • 1. Model the hierarchy
  • 2. Define roles and permissions
  • 3. List who has access
  • 4. Add role labels for direct members
  • 5. Share with a new member
  • 6. Change a member’s role
  • 7. Remove a member

What you’ll build

This guide shows how to build a share dialog – the kind that appears behind a “Share” button on a project, document, or workspace. The dialog lists everyone who already has access, distinguishes direct collaborators from people who inherited access from a parent, and lets the owner add or remove members.

The example uses two resource types: workspaces contain projects. The share dialog lives on the project, and the workspace is the source of inherited access for members who weren’t added to the project directly. By the end, you’ll have:

  • Resource types, permissions, and roles configured for shareable projects
  • A member list rendered with listMembershipsForResource
  • Direct versus inherited member labels using the assignment parameter
  • Per-member role labels using listRoleAssignmentsForResource
  • Endpoints to add a member, change a member’s role, and remove a member
organization (implicit root)
└─ workspace
└─ project

1. Model the hierarchy

Create two resource types in the WorkOS Dashboard under Authorization > Resource Types. The workspace is the parent of the project – anyone with a workspace-scoped role automatically appears as an inherited member of every project in the workspace.

NameSlugParent
WorkspaceworkspaceOrganization
ProjectprojectWorkspace

For the full Dashboard walkthrough, see the Quick Start guide.

2. Define roles and permissions

The share dialog needs to answer two questions: who has access to this project, and what role do they have. Define the permissions and roles that back those operations.

PermissionResource typeDescription
project:viewprojectView a project
project:editprojectEdit the project, including membership
RoleScoped toPermissions
project-viewerprojectproject:view
project-editorprojectproject:view, project:edit

The share dialog assigns one of these two roles to each new collaborator. Gating the management endpoints on project:edit means project editors can change membership, while viewers cannot.

Workspace-scoped roles produce the inherited members shown in the dialog. A workspace-admin role that includes project:view and project:edit as child-type permissions grants project access to every project in the workspace without any direct project assignment. Those users appear in the indirect membership list with no direct role on the project. For more on how inheritance affects access, see Roles and permissions.

3. List who has access

The core of the share dialog is the list of members. listMembershipsForResource returns every organization membership that has a permission on the resource. Using permission_slug=project:view returns everyone who can see the project at all.

The assignment parameter controls whether inherited access is included:

  • direct returns only users who have a role assigned directly on this project
  • indirect returns everyone who can access the project, including via workspace or organization-scoped roles

The share dialog typically needs both. Direct assignments show who was explicitly added. Indirect assignments show everyone who can see the project, including users who inherited access through a parent.

import { WorkOS } from '@workos-inc/node';
const workos = new WorkOS(process.env.WORKOS_API_KEY);
app.get('/projects/:projectId/members', async (req, res) => {
const { organizationMembershipId } = req.user;
const { projectId } = req.params;
const { authorized } = await workos.authorization.check({
organizationMembershipId,
permissionSlug: 'project:view',
resourceTypeSlug: 'project',
resourceExternalId: projectId,
});
if (!authorized) {
return res.status(404).json({ error: 'Not found' });
}
const project = await db.projects.findUnique({ where: { id: projectId } });
if (!project) {
return res.status(404).json({ error: 'Not found' });
}
const [{ data: allMembers }, { data: directMembers }] = await Promise.all([
workos.authorization.listMembershipsForResource({
resourceId: project.authzResourceId,
permissionSlug: 'project:view',
assignment: 'indirect',
}),
workos.authorization.listMembershipsForResource({
resourceId: project.authzResourceId,
permissionSlug: 'project:view',
assignment: 'direct',
}),
]);
const directIds = new Set(directMembers.map((m) => m.id));
const members = allMembers.map((member) => ({
id: member.id,
user: member.user,
accessSource: directIds.has(member.id) ? 'direct' : 'inherited',
}));
return res.json(members);
});

The handler runs two calls in parallel and uses the direct list as a lookup to label each indirect entry. The response includes the user object for each membership, so the dialog can render names and avatars without a second round trip.

Store the WorkOS authz_resource_id on the project row when it’s created. The listMembershipsForResource endpoint takes the internal resource ID rather than an external ID.

4. Add role labels for direct members

The membership endpoint returns who has access but not what role they were assigned. To show “Alice – Editor” in the dialog, fetch role assignments for the resource and merge them with the member list.

async function loadProjectMembers(projectId, authzResourceId) {
const [allMembers, directMembers, roleAssignments] = await Promise.all([
workos.authorization.listMembershipsForResource({
resourceId: authzResourceId,
permissionSlug: 'project:view',
assignment: 'indirect',
}),
workos.authorization.listMembershipsForResource({
resourceId: authzResourceId,
permissionSlug: 'project:view',
assignment: 'direct',
}),
workos.authorization.listRoleAssignmentsForResource({
resourceId: authzResourceId,
}),
]);
const directIds = new Set(directMembers.data.map((m) => m.id));
const roleByMembershipId = new Map(
roleAssignments.data.map((a) => [a.organizationMembershipId, a]),
);
return allMembers.data.map((member) => {
const organizationMembershipId = member.id;
const isDirect = directIds.has(organizationMembershipId);
const assignment = roleByMembershipId.get(organizationMembershipId);
return {
id: organizationMembershipId,
user: member.user,
accessSource: isDirect ? 'direct' : 'inherited',
role: isDirect ? assignment?.role.slug : null,
roleAssignmentId: isDirect ? assignment?.id : null,
};
});
}

listRoleAssignmentsForResource only returns assignments granted on this resource, which matches the set of direct members. Inherited members don’t have a role on the project itself – their access comes from a role on a parent – so the role label is omitted for them.

The React component renders each row with the user’s name, role, and an “Inherited” pill for members who picked up access from a parent.

function MemberList({ members, currentUserId, onRoleChange, onRemove }) {
return (
<ul>
{members.map((member) => (
<li key={member.id}>
<Avatar src={member.user.profilePictureUrl} />
<span>{member.user.email}</span>
{member.accessSource === 'direct' ? (
<RoleSelect
value={member.role}
onChange={(role) =>
onRoleChange(member.roleAssignmentId, member.id, role)
}
/>
) : (
<Pill>Inherited from workspace</Pill>
)}
{member.accessSource === 'direct' &&
member.user.id !== currentUserId && (
<Button onClick={() => onRemove(member.roleAssignmentId, member.id)}>
Remove
</Button>
)}
</li>
))}
</ul>
);
}

Inherited members can’t be removed from the project directly – their access is controlled by the parent role. The dialog should reflect that by hiding the Remove and Role controls for inherited rows.

5. Share with a new member

Adding a member is a single assignRole call. The dialog typically takes an email or organization membership ID, then assigns the chosen role on the project.

app.post('/projects/:projectId/members', async (req, res) => {
const { organizationMembershipId } = req.user;
const { projectId } = req.params;
const { membershipId, roleSlug } = req.body;
const { authorized } = await workos.authorization.check({
organizationMembershipId,
permissionSlug: 'project:edit',
resourceTypeSlug: 'project',
resourceExternalId: projectId,
});
if (!authorized) {
return res.status(404).json({ error: 'Not found' });
}
const assignment = await workos.authorization.assignRole({
organizationMembershipId: membershipId,
roleSlug,
resourceTypeSlug: 'project',
resourceExternalId: projectId,
});
return res.json(assignment);
});

Gate the endpoint on project:edit so only users who can edit the project can manage its members. The new assignment takes effect immediately – the next check for that user will return the new permissions.

If the user being added isn’t already an organization member, create the membership through the standard user flow first. FGA only assigns roles to existing organization memberships.

6. Change a member’s role

Role assignments are immutable. To change a member’s role, delete the existing assignment and create a new one with the updated role.

app.patch(
'/projects/:projectId/members/:roleAssignmentId',
async (req, res) => {
const { organizationMembershipId } = req.user;
const { projectId, roleAssignmentId } = req.params;
const { roleSlug, membershipId } = req.body;
const { authorized } = await workos.authorization.check({
organizationMembershipId,
permissionSlug: 'project:edit',
resourceTypeSlug: 'project',
resourceExternalId: projectId,
});
if (!authorized) {
return res.status(404).json({ error: 'Not found' });
}
await workos.authorization.removeRoleAssignment({
organizationMembershipId: membershipId,
roleAssignmentId,
});
const updated = await workos.authorization.assignRole({
organizationMembershipId: membershipId,
roleSlug,
resourceTypeSlug: 'project',
resourceExternalId: projectId,
});
return res.json(updated);
},
);

The role assignment ID and the member’s organization membership ID both come from the member list returned in step 4. There is a brief window between removing and reassigning where the user has no role on the project – if that matters, perform the operation inside a transaction or queue, and treat any failure as a rollback.

7. Remove a member

Removing a direct member is a single removeRoleAssignment call. Pass the assignment ID and the member’s organization membership ID from the member list.

app.delete(
'/projects/:projectId/members/:membershipId/:roleAssignmentId',
async (req, res) => {
const { organizationMembershipId } = req.user;
const { projectId, membershipId, roleAssignmentId } = req.params;
const { authorized } = await workos.authorization.check({
organizationMembershipId,
permissionSlug: 'project:edit',
resourceTypeSlug: 'project',
resourceExternalId: projectId,
});
if (!authorized) {
return res.status(404).json({ error: 'Not found' });
}
await workos.authorization.removeRoleAssignment({
organizationMembershipId: membershipId,
roleAssignmentId,
});
return res.status(204).end();
},
);

Access is revoked immediately. The member may still appear in the indirect list if they inherit access from a parent role – the dialog should reflect that by relabeling the row from “Direct” to “Inherited” after the delete.

To revoke inherited access, the user’s role on the parent resource (workspace or organization) needs to change. The share dialog should not attempt this from a project-scoped view; surface a hint to manage workspace access elsewhere.

The same pattern works for any resource type. To share a workspace, swap project for workspace and use workspace-scoped roles. The membership and assignment endpoints work identically regardless of resource type.

Migrate from OpenFGA Map your OpenFGA authorization model to WorkOS FGA resource types, roles, and permissions
Up next
© WorkOS, Inc.
FeaturesAuthKitSingle Sign-OnDirectory SyncAdmin PortalFine-Grained Authorization
DevelopersDocumentationChangelogAPI Status
ResourcesBlogPodcastPricingSecuritySupport
CompanyAboutCustomersCareersLegalPrivacy
© WorkOS, Inc.