What can hold a role
A user, within an organization membership
Triggered byorganization.member.role.assigned / organization.member.role.deleted. This is the most common case, and the one B2B via Organizations walks through end to end.
organization.member.deleted’s payload only ever carries organization.id and user.user_id, never which roles that user held. So a role assignment is wired through an intermediate organization_membership object instead of directly from user to role: that’s what lets removing a user from an organization revoke every role they held in it with a pair of filters keyed on the membership, without knowing what those roles were.
user --member--> organization_membership:{organization_id}_{user_id} and organization_membership:{organization_id}_{user_id}#member --assignee--> role:{role_id}. “Remove user from organization” removes the user’s organization membership tuple, and “Revoke roles held through the membership” cleans up the rest with two filters, both computable from organization_id and user_id, which organization.member.deleted carries: one deletes every role assignment granted to that membership’s #member userset, the other deletes the membership’s own member tuples. Without the first filter, the user would lose access (their member tuple is gone) but the assignee tuples on each role would remain as orphans. A simpler model, role with define assignee: [user] and the role’s assignees granted directly on the resource (for example define billing_manager: [role#assignee] on organization), works when you don’t need roles revoked automatically on membership removal: a delete would have to name each role, and the event doesn’t say which ones the user held. If the same user holds the same role via a second organization they’re still a member of, each organization gets its own organization_membership object, so removing one doesn’t affect the other.
A group, at the tenant level
Triggered bygroup.role.assigned / group.role.deleted. Covers assigning a role directly to a SCIM group with no organization involved, as in Workforce via SCIM Groups.
role#assignee accepting group#member (alongside user) is what lets a role reach every current and future member of a group with one tuple, instead of one tuple per member.
group.role.deleted only carries the role’s id, never its name. If a rule needs to branch on which role fired, branch on role.id.
A group, within an organization
Triggered byorganization.group.role.assigned / organization.group.role.deleted, distinct from the bare, tenant-level events above. The payload carries organization.id, group.id, and role.id together.
Whether this should write to the same role:{id}#assignee tuple as the tenant-level case, or a separate one, depends on whether a group in your tenant can hold the same role at both scopes and you need to tell the two grants apart. If a group only ever gets a given role one way, write to the same tuple as the tenant-level case:
organization_membership above: an object scoped to (group, organization), with its own member tuple, so each grant can be told apart by which object it’s attached to and cleaned up independently of the other.
Static vs. dynamic roles
Roles fall into two categories, and each needs different modeling. This applies to any of the three attachment points above.- Static roles: your application defines these itself, once, as a fixed set of named permissions hardcoded into your model as relations (for example, an “Approver” role your app checks for explicitly). Your mapping can branch on these by identity.
- Dynamic roles: each customer or organization defines their own roles, with arbitrary names, IDs, and permissions your application can’t know ahead of time. Since they’re managed by the customer rather than fixed in your model, these have to be modeled generically and managed as tuples.
Static roles
Since your application already knows about these roles, model each as its own named relation directly onorganization, for example:
Map it by matching the role’s ID, hardcoded to the specific role you created in your own tenant:
rol_YOUR_APPROVER_ROLE_ID with the real role ID from your tenant. Because this writes a direct relation on organization, no separate cleanup rule is needed: Cleaning up roles on membership removal’s filter-by-user+organization technique already reaches it when the membership is removed.
Dynamic roles
Since these roles’ names, IDs, and count aren’t known ahead of time, model them generically asrole:{id}#assignee. Use whichever attachment point above matches how the role is assigned: through organization_membership for a user in an organization, or directly on group#member for a group.
Combining both
If your application has both kinds, add two separate rule pairs per event, each guarded by whether the role ID matches your known, hardcoded set. The first pair is the static branch shown above. The second pair reuses the same rules as A user, within an organization membership, withinput.data.object.role.id != "rol_YOUR_APPROVER_ROLE_ID" (and any other static role IDs) added to their when guards, so the two branches don’t overlap.
Rules are independent, and all matching ones fire for a given event, so ordering between the two pairs doesn’t matter as long as their when conditions are mutually exclusive.