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 AppDeep InheritanceDeep 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
  • Putting it all together
  • Next steps

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 Quick Start: Configure resource types.

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 } });
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 roleByMembership = new Map(
roleAssignments.data.map((a) => [a.organizationMembershipId, a]),
);
return allMembers.data.map((member) => {
const isDirect = directIds.has(member.id);
const assignment = roleByMembership.get(member.id);
return {
id: member.id,
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, role)}
/>
) : (
<Pill>Inherited from workspace</Pill>
)}
{member.accessSource === 'direct' &&
member.user.id !== currentUserId && (
<Button onClick={() => onRemove(member.roleAssignmentId)}>
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 createRoleAssignment 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.createRoleAssignment({
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 } = 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 existing = await workos.authorization.getRoleAssignment({
id: roleAssignmentId,
});
await workos.authorization.deleteRoleAssignment({ id: roleAssignmentId });
const updated = await workos.authorization.createRoleAssignment({
organizationMembershipId: existing.organizationMembershipId,
roleSlug,
resourceTypeSlug: 'project',
resourceExternalId: projectId,
});
return res.json(updated);
},
);

The role assignment ID comes from the member list returned in step 4. There is a brief window between the delete and create 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 deleteRoleAssignment call. Pass the assignment ID from the member list.

app.delete(
'/projects/:projectId/members/:roleAssignmentId',
async (req, res) => {
const { organizationMembershipId } = req.user;
const { projectId, 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.deleteRoleAssignment({ id: 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.

Putting it all together

The complete shape of a share dialog:

1. Load → listMembershipsForResource(indirect)
+ listMembershipsForResource(direct)
+ listRoleAssignmentsForResource
2. Render → Direct members with role + Remove; inherited members with pill
3. Add → createRoleAssignment(membershipId, roleSlug)
4. Change → deleteRoleAssignment(id) + createRoleAssignment(new role)
5. Remove → deleteRoleAssignment(id)
6. Gate → Every mutation runs check(project:edit) first

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.

Next steps

Other “Model your app” guides:

  • Basic App – Single resource type with list and detail views. See Basic App.
  • Deep Inheritance – Multi-level hierarchies and listEffectivePermissions. See Deep Inheritance.
  • Protecting API Endpoints – Using check for every CRUD operation. See Protecting API Endpoints.
  • High-Cardinality Data – Files and documents that inherit access from an FGA-managed parent. See High-Cardinality Data.

Related reference:

  • Resource discovery – listMembershipsForResource and listResourcesForMembership
  • Role assignments – Creating, listing, and deleting assignments
  • Roles and permissions – Direct versus inherited access
Migrate from OpenFGAMap 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.