# Company groups and roles

Let a customer company decide who in it does what - company roles, its own groups that give workspace access, and its IT admin assigning both from WorkOS.

A company that signs in through [enterprise SSO](/authentication/enterprise-sso) wants two things your workspace roles do not give it. Its security team wants to say who may read the trail, who may change the policy and who may only look, without making everyone an admin. And its IT team wants to give a whole team the workspaces it needs in one place, rather than inviting people one by one.

This builds on Enterprise SSO for the company: [set that up](/sign-in-setup/enterprise-sso) first, if you have not.

**Company roles** answer the first, and **company groups** the second. Both belong to the company: its admins manage them on the Single sign-on screen in your app, or with a [company API key](/authentication/enterprise-company-api). Your team can manage them in the console.

> **Note:**
  [Enterprise SSO](/authentication/enterprise-sso) set up for the company, with
  at least one workspace [bound to
  it](/authentication/enterprise-sso#6-bind-workspaces) for a group to give.


## Company roles

A company role says what its holder may do in the company's own settings, area by area, at **No access**, **Can view** or **Can manage**. It is not a workspace role: what a role in a workspace means stays your app's to decide.

| Area           | Can view                                                                                                         | Can manage                                                                                       |
| -------------- | ---------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------ |
| Activity       | The trail, its viewer and its exports                                                                            | (view only)                                                                                      |
| People         | The company's people and each person's sessions, requests to join, accounts on its domains and people outside it | Signing people out or ending one session, deciding requests to join, bringing accounts in        |
| Access policy  | The access policy                                                                                                | Changing it                                                                                      |
| Insights       | The insights                                                                                                     | (view only)                                                                                      |
| Log streams    | Where the trail is sent                                                                                          | Adding, changing, testing and removing streams                                                   |
| Connected apps | The apps and agents its people connected                                                                         | Disconnecting them                                                                               |
| API keys       | API keys and service accounts                                                                                    | Making and revoking keys, service accounts                                                       |
| Groups         | Groups, their people and what each gives                                                                         | Making, changing and deleting groups                                                             |
| Company roles  | Who holds which company role, and the IT and security contacts                                                   | Giving and taking roles, making custom roles                                                     |
| Setup          | (manage only)                                                                                                    | The SSO, directory and domain setup links, reading domains back from WorkOS, certificate renewal |

Every company has three roles, which cannot be changed:

| Role               | Allows                                                                                                |
| ------------------ | ----------------------------------------------------------------------------------------------------- |
| **Company admin**  | Everything. The [company admins](/authentication/enterprise-sso#company-admins) from before roles     |
| **Security admin** | Manages the access policy, sign-outs, connected apps, API keys, log streams and setup; views the rest |
| **Auditor**        | Views everything, changes nothing                                                                     |

The [IT and security contacts](/authentication/enterprise-sso#it-and-security-contacts) are the addresses WorkOS emails about the company's connection. They are listed, read only, at **Company roles**, Can view. They are changed in WorkOS's Admin Portal, and **Change in WorkOS**, which opens it, needs **Setup** at Can manage, as every setup link does.

A company can make up to 20 roles of its own, for example a help desk that may sign people out and read the trail and nothing else. A role a company makes can never manage the **Company roles** area: only a Company admin gives roles out, so no custom role can be used to climb to a broader one.

### Who holds a role

- **Named one by one**, up to 20 people, on the Single sign-on screen or in the console, as company admins were before. Changing someone's role is recorded as a role given, with the role they held before.
- **Through a group** that carries the role (below), for any role but Company admin. Each Company admin is named on their own.
- **Through WorkOS**, for a role [published to WorkOS](#let-the-it-admin-assign-roles-in-workos): anyone WorkOS assigns its slug to, from the groups the company's IT admin assigned to it. Company admin comes this way only when your team publishes it.

What someone may do is the most permissive of every role they hold, area by area.

**Before anyone is named.** Until the company names a Company admin, anyone who can edit a bound workspace's settings manages everything, as before roles. Naming an Auditor first does not change that, so the company can never lock itself out of managing its own settings.

In your app, each section of the Single sign-on screen shows only to the people whose role reaches it, and its buttons only to those who can manage it. A request beyond someone's role is refused with 403 `company_role_required`, naming the area and level. The status the SDK reads carries `access`, area by area, and the `role` the member was named with.

```bash
# Name a person with a role (console route; the workspace and company API routes take the same body)
curl -X POST -H "Authorization: Bearer $API_KEY" -H "Content-Type: application/json" \
  -d '{"userId":"<user id>","role":"auditor"}' \
  "https://<your server>/api/auth/enterprise/enterprises/<enterprise id>/admins"

# Change a named person's role
curl -X PATCH -H "Authorization: Bearer $API_KEY" -H "Content-Type: application/json" \
  -d '{"role":"security-admin"}' \
  "https://<your server>/api/auth/enterprise/enterprises/<enterprise id>/admins/<user id>"

# Make a role of the company's own
curl -X POST -H "Authorization: Bearer $API_KEY" -H "Content-Type: application/json" \
  -d '{"name":"Help desk","permissions":{"people":"manage","activity":"view"}}' \
  "https://<your server>/api/auth/enterprise/enterprises/<enterprise id>/roles"
```

`GET /roles` answers every role the company can give, the three presets first, each with what it allows, how many people and groups hold it and how many hold it through WorkOS (`holders.workos`), and, once published, its `workos` record: `{ slug, publishedAt, publishedBy, state }`. `PATCH /roles/<role id>` changes one, and `DELETE /roles/<role id>` takes it from everyone who holds it: each named person loses it, and each group that carried it carries none.

### API keys and roles

A [company API key](/authentication/enterprise-company-api) someone makes for themselves can do no more than they can. Making one needs **API keys** at Can manage, and each scope must be one their roles reach. On every call, the key uses only the scopes its owner's roles still reach, so a Security admin moved to Auditor keeps a key that reads and no longer writes. When the owner holds no role at all, the key is revoked. A service account's key is not tied to anyone's role.

## Company groups

A group is the company's own: a name, its people, and what being in it gives.

**Its people** are the ones named in it, up to 1,000, and everyone in up to five linked identity provider groups: a WorkOS role, a directory group, an SSO group or an SSO role, the same four your [role mappings](/authentication/enterprise-sso#roles-from-single-sign-on) read. A linked group is how a company with an identity provider keeps a group in step with it, without asking your team for a mapping. A group [published to WorkOS](#let-the-it-admin-assign-roles-in-workos) also carries a link to its own WorkOS role, which does not count toward the five. Someone blocked or deactivated is in no group.

**What it gives:**

- **Workspaces.** Up to 20 of the company's bound workspaces, each with a role (never owner). Someone in the group who is not in that workspace is joined to it with that role, through the same checks as any join: the member limits and the subscription's seats. Someone already in it gets that role. When they leave the group, the role goes back to the binding's, and the membership goes too when the group was what joined them and the binding does not join everyone. Membership they had before stays.
- **A company role**, for everyone in it. Never Company admin.

When several things give someone a role in one workspace, your mappings and their groups alike, the highest role wins, as it does for mappings. A custom workspace role ranks below viewer.

**Giving a role through a group is the Company roles area's.** Setting a group's role, changing who is in a group that gives one (itself, or through a group that [includes it](#groups-inside-groups)), or deleting such a group needs **Company roles** at Can manage, even for someone who manages groups. Otherwise someone who manages groups could put themselves in a group that carries Security admin.

A group is applied as soon as it is saved, to everyone in it before and after, and again at each sign-in and directory sync. Up to 200 people are brought in line before the save answers, and the rest straight after (`pending` in the answer says how many).

```bash
curl -X POST -H "Authorization: Bearer $API_KEY" -H "Content-Type: application/json" \
  -d '{
        "name": "Finance",
        "members": ["<user id>"],
        "linked": [{ "source": "sso_group", "groupId": "Finance team" }],
        "workspaces": [{ "workspaceId": "<workspace id>", "role": "editor" }],
        "companyRole": "auditor"
      }' \
  "https://<your server>/api/auth/enterprise/enterprises/<enterprise id>/company-groups"
```

`GET` answers the groups, each with its people, and the bound workspaces a group may give with the roles in each. `PATCH /<group id>` changes any of its fields and `DELETE /<group id>` takes away everything it gave. A company has up to 50 groups. In the console the route is `/company-groups`, because `/groups` there lists the linked directory's groups for [role mappings](/authentication/enterprise-sso#roles-from-single-sign-on); in your app and the company API it is `/groups`.

## Groups inside groups

A group can include other groups of the company. Everyone in an included group is in the outer one too, as an indirect member: **Engineering** that includes **Backend** and **Frontend** holds everyone in either, with nobody named twice.

**What reaches whom:**

- **What the outer group gives reaches the people of every group inside it**, at any depth: its workspaces, each with its role, and its company role. Backend's people get what Engineering gives.
- **What an inner group gives stays with its own people.** Frontend's workspaces do not reach Backend's people, and nothing Backend gives reaches the people named in Engineering itself.
- **A group can sit inside several groups.** Backend can be in Engineering and in On-call; its people get what both give, and the highest workspace role wins, as everywhere else.
- **Linked identity provider groups stay links.** A group includes only the company's own groups. To nest a directory group, an SSO group or role, or a WorkOS role, link it to a company group and include that one.

| Rule                                                                                    | Refused with                                              |
| --------------------------------------------------------------------------------------- | --------------------------------------------------------- |
| A group includes at most 10 groups; any number of groups can include it                 | 400 `invalid`, `field: "includes"`                        |
| Only the company's own groups, and never the group itself                               | 400 `invalid`, `field: "includes"`                        |
| No cycles: a group cannot include a group that already includes it, at any depth        | 409 `group_cycle`, with `path` (outer to inner)           |
| At most 3 deep, counting both ends: Everyone includes Engineering includes Backend      | 422 `group_too_deep`, with `chain`                        |
| One change to a company's groups at a time; a second waits two seconds, then is refused | 409 `group_change_in_progress` ("Try again in a moment.") |

`includes` is the whole list, as `members` and `linked` are: send it on `POST` or `PATCH`, and leave it out to keep the nesting as it is. Saving it is applied like any change to a group: everyone in the groups added or taken out is brought in line, up to 200 before the save answers and the rest straight after. When more than 5,000 people would need it, the answer says `capped: true` and the rest are brought in line at their next sign-in or directory sync. Deleting a group takes it out of every group that includes it, in the same save.

**Including a group can give a company role.** When the outer group gives a company role, or is inside one that does, including another group gives that role to its people. So including a group in one that reaches a company role, and changing who is in a group that reaches one through nesting, needs **Company roles** at Can manage, and in your app the member [confirms it's them](/authentication/enterprise-sso#confirming-it-s-you) first (403 `reauth_required`). A group that gives only workspace access needs neither.

**Directory groups.** BuildBase takes each person's directory groups as WorkOS reports them; it does not rebuild the identity provider's own nesting. Microsoft Entra ID's provisioning sends a group's direct members only, so someone in a group inside an assigned Entra group is not in it as far as your app is told. WorkOS's advice is to assign each inner group to the app in Entra as well. Then link each one to a company group, and include those groups in the one that gives the access: the nesting lives in your app, where the company can see it.

**A worked example.** Globex makes **Engineering**, which gives editor in its **Design** workspace, and includes **Backend** and **Frontend**. Backend is linked to the directory group `backend-eng` and gives viewer in **Ops**. Frontend gives editor in **Web**. Its **Security reviewers** group gives the Auditor role and includes nothing.

| When                                                  | Mia holds                                                                                                   |
| ----------------------------------------------------- | ----------------------------------------------------------------------------------------------------------- |
| Mia joins `backend-eng` in the directory              | Viewer in Ops (Backend), editor in Design (Engineering, through Backend). Not Web: that is Frontend's       |
| Globex's admin includes Backend in Security reviewers | The same, and Auditor, through Security reviewers. The admin needed Company roles and confirmed it was them |
| Mia leaves `backend-eng`                              | Nothing from any of the three groups. Membership they had before stays                                      |

Each change is in the trail as `enterprise_member_added` or `enterprise_member_role_changed` with `via: "group"`, `group: "Engineering"` and `throughGroup: "Backend"`, which the activity reads as "group Engineering, through Backend".

## Group managers

A group can have up to 10 **managers**: people of the company who add and remove the people named in it by hand, and nothing else. A team lead keeps their own team's group current without holding a company role, and without asking the company's admins for every new starter.

- **Who names them:** whoever manages **Groups** (Can manage), your team in the console, or a company API key with `groups:write`. Naming a manager gives them no company role, so it never asks them to confirm it's them.
- **What a manager may do:** read the group (what it gives, its own and through the groups that include it, everyone in it, how each is in it, and its managers), add any of the company's active people to it, and remove anyone named in it.
- **What a manager may not do:** change the group's name, workspaces, role, links or the groups inside it, delete it, name managers, add or remove themselves ("You can't add or remove yourself. Ask someone who manages the company's groups."), or add or remove another of its managers. People in it through a linked group or a group inside it are listed, with where they come from, and cannot be removed: that is the identity provider's or the inner group's to change.
- **Never a group that gives a company role.** While the group reaches a company role, itself or through a group that includes it, its managers keep their place but every change of theirs is refused with 403 `group_manager_limit` and `reason: "company_role"`: "This group gives a company role (Auditor, through Security reviewers), so only a Company admin changes who is in it." Taking the group out of the role-giving group, or the role off it, lets them back in.
- **A manager holds no company access.** Their status's `access` stays `none` in every area: managing a group opens no other screen, key or setup link, and they never count as one of the company's admins.
- **It ends** when someone who manages Groups removes them, the group is deleted, or they are deprovisioned. A manager who is blocked or no longer active is refused on every call, and the group's managers list marks them "no longer one of the company's people" until someone removes them.

**Groups you manage.** The manager's status carries `groupsManaged` (`groupId`, `name`, `people`, `mayChange` and, when they cannot, `reason`). In your app, `` shows **Groups you manage** (``) to a manager even when they hold no company role: each group with its people, **Add people**, which searches the company's people (at least three characters, ten at a time), and **Remove** beside each person named by hand. `ui.settings.enterprise.managedGroups` hides it. `useEnterpriseManagedGroups(workspaceId)` (from SDK 1.0.0) reads the same list from the status for your own screen, with `unsupported` on a server before group managers. The hooks are `useEnterpriseGroup`, `useEnterpriseGroupPeople`, `useEnterpriseGroupMembers` (`{ add, remove, saving, error }`), `useEnterpriseGroupManagers` and `useEnterpriseGroupCandidates`; from your backend, `bb.enterprise.group(workspaceId, groupId)`, `groupPeople`, `changeGroupMembers`, `groupCandidates`, `addGroupManager` and `removeGroupManager` do the same with the member's session.

```bash
# A manager adds two people and removes one (the app, with the member's session)
curl -X POST -H "x-session-id: $SESSION_ID" -H "Content-Type: application/json" \
  -d '{ "add": ["<user id>", "<user id>"], "remove": ["<user id>"] }' \
  "https://<your server>/api/v1/public/workspaces/<workspace id>/enterprise/groups/<group id>/members"

# Someone who manages Groups names a manager, or removes one
curl -X POST -H "x-session-id: $SESSION_ID" -H "Content-Type: application/json" \
  -d '{ "userId": "<user id>" }' \
  "https://<your server>/api/v1/public/workspaces/<workspace id>/enterprise/groups/<group id>/managers"
curl -X DELETE -H "x-session-id: $SESSION_ID" \
  "https://<your server>/api/v1/public/workspaces/<workspace id>/enterprise/groups/<group id>/managers/<user id>"
```

`POST /members` takes `add` and `remove`, up to 100 each, at least one of them, and answers the group with how many were really `added` and `removed`, `applied` and `pending` (and `capped`, as a save does). Adding someone already named, or removing someone who is not, changes nothing. Anyone not one of the company's active people is refused with 400 `invalid`, naming the field (`add[2]`). `GET /groups/<group id>/candidates?search=` answers up to ten of the company's active people whose name or email contains the search, each with `inGroup`, 30 searches a minute. A group with 10 managers refuses another with 422 `too_many_managers`. Your team in the console and a company key change who is in any group, with none of a manager's limits; a key needs `admins:write` beside `groups:write` for a group that reaches a company role.

## Let the IT admin assign roles in WorkOS

A linked group works when someone knows the identity provider's group names or role slugs and types them in. Most companies would rather their IT admin decided, in the tool they already use for it. WorkOS has that place: the role step of its Admin Portal, where the IT admin assigns their own groups to roles. Publishing puts the company's roles and groups there, under names they recognise, and BuildBase still decides what each one gives.

**Turning it on.** Your team turns on **Let the IT admin assign roles in WorkOS** for one company at a time, in the enterprise's detail in the console. It is off for every company until you do. The console says what happens first, and offers to publish Security admin and Auditor straight away. One thing to know before you turn it on: the first role published gives the company's WorkOS organization its own default role and priority order, so later changes you make to those for your whole WorkOS environment stop reaching it. That is WorkOS's rule, and it touches only the companies you turn on.

**What can be published, and by whom:**

| What                                             | Published by                                                                                                                                                          |
| ------------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Security admin, Auditor, the company's own roles | Your team, or whoever manages **Company roles** at the company                                                                                                        |
| A company group                                  | Your team, or whoever manages **Groups** at the company. A group that carries a company role also needs **Company roles** at Can manage (403 `company_role_required`) |
| Company admin                                    | Your team only, from the console. The company is refused with 403 `company_admin_console_only`                                                                        |

In the console and on the Single sign-on screen, publishing is the **Show in the Admin Portal** switch on each role and group. Nothing can be published before your team turns it on (409 `workos_roles_off`). The company publishes only while **Let the company's admins change this in the app** is on, and is refused with 403 `managed_by_app` otherwise, as with its other changes.

**The slug.** Publishing creates a WorkOS role on the company's WorkOS organization, under a slug that starts with `org-bb-`:

- the presets: `org-bb-company-admin`, `org-bb-security-admin` and `org-bb-auditor`;
- a role of the company's own: `org-bb-` and its name, such as `org-bb-help-desk`;
- a group: `org-bb-group-` and its name, such as `org-bb-group-design-editors`.

A slug is lowercase letters, digits and hyphens, at most 48 characters, with `-2`, `-3` and so on when the company already has it. **It never changes while the role stays published.** Rename the role in BuildBase and WorkOS gets the new name under the old slug, so the IT admin's assignments keep working. Unpublish it and publish it again, though, and it gets a fresh slug from its current name, which the IT admin assigns their groups to again. The slugs of Company admin, Security admin and Auditor are kept for those roles, so a company role named like one of them gets `-2`. If WorkOS already holds a role under the slug, publishing answers 409 `role_slug_taken`; when another of the company's own roles or groups holds it, the answer also names that one in `heldBy`, and it cannot be taken over. Otherwise your team can take that role over from the console with **Use the role WorkOS has** (`adopt: true`), and the IT admin's groups assigned to it stay; the app and the company API say your team can confirm it.

**The name and the description** are BuildBase's. The name is the role's or group's, cut to 48 characters. The description starts with your app's name and says what it gives there, cut to 150: `Company role in <your app>: views activity, manages people` for a role, `Group in <your app>: editor in Design, viewer in Docs` for a group. Changing what a role allows, or a group's workspaces, rewrites it. If WorkOS cannot be reached, the change is saved in BuildBase anyway and written to WorkOS later (`publish_pending`, [below](#workos-roles-in-sync)).

**What it gives:**

- Anyone WorkOS assigns a published company role holds that role, beside any they were named with or hold through a group. A role the company makes still cannot manage **Company roles**, however it is held.
- Anyone WorkOS assigns a published group's role is in that group, and gets its workspaces and its company role. The group lists the link as `{ "source": "workos_role", "groupId": "org-bb-group-design-editors" }`, and it stays while the group is published.
- Publishing Company admin also adds a role mapping from `org-bb-company-admin` to **Company admin**, so its holders become company admins with `source: "mapping"`, and count as a named Company admin only while a real person holds it. Unpublishing it removes the mapping.

WorkOS decides who holds a published role; BuildBase decides what it gives. Nothing WorkOS does takes away a role someone was named with by hand, and nothing about people is ever written to WorkOS: no assignments, no permissions, no roles for your whole WorkOS environment.

**Unpublishing** deletes the WorkOS role, and WorkOS refuses while any of the IT admin's groups is assigned to it. So does BuildBase, with 409 `role_in_use`, naming the role: "Groups in the company's identity provider are assigned Auditor in WorkOS. Remove them in the Admin Portal (Assign roles in WorkOS), then try again." The body also carries `roles`, the name of the role WorkOS kept. Nothing changes in BuildBase. WorkOS does not say which groups, so the IT admin looks in the portal. A role already gone from WorkOS is just cleared.

Deleting a published role or group is refused the same way. From the console, your team can choose **Delete anyway** (`?force=1`): it is deleted in BuildBase, taken from everyone who held it as before, and the WorkOS role is left behind as orphaned until the IT admin removes its groups and you choose **Clean up**. Turning the switch off unpublishes everything WorkOS lets go of. If the IT admin's groups are still assigned to any of them, those stay published, the switch stays on, and the answer is 409 `role_in_use` naming them all: "Groups in the company's identity provider are still assigned these roles in WorkOS: Auditor, Design editors. Remove them in the Admin Portal (Assign roles in WorkOS), then try again." `roles` lists the same names.

**The IT admin's side.** **Assign roles in WorkOS**, in the console and on the Single sign-on screen for whoever manages **Setup**, opens the Admin Portal for the IT admin. WorkOS has no link straight to its role step, which is part of setting up the directory or single sign-on, so the button opens the directory's setup when the company has a linked directory, and single sign-on's otherwise. Coming back reads the company's roles in WorkOS again at once, and with a directory linked, queues a read of the whole directory, so people's roles follow shortly after. The console comes back to the enterprise (`?setup=done&action=roleAssignment&enterprise=<enterprise id>`) and its card reads the roles again; the Single sign-on screen does the same in your app. Send the IT admin [their page](/authentication/enterprise-sso-it-admin#assigning-roles-in-the-admin-portal).

**Settings only your WorkOS dashboard has.** WorkOS's API can neither read nor change these, so the console lists them as a checklist, beside what BuildBase has seen at each company:

- **The role step itself.** Show it on the **Admin Portal** page of your WorkOS dashboard, for directory groups and for single sign-on groups, or turn it on for one company on its organization's **Roles** tab. Without it the IT admin never sees the step, and publishing does nothing for them.
- **Multiple roles**, in the dashboard's authorization settings, for your whole WorkOS environment. It is off by default, and then someone in two assigned groups gets only the higher-priority role: a company may need a group that combines both.
- **The default role**, `member` unless you changed it. Everyone holds it, so mapping or publishing onto it reaches every person at the company. The status warns when that happens.

Each company's **WorkOS roles** card shows whether anyone at the company carries a role other than the default, which means assignment is in use, and whether anyone carries more than one, which means multiple roles is on.

```bash
# Turn it on for a company and publish Security admin and Auditor at once (console)
curl -X PUT -H "Authorization: Bearer $API_KEY" -H "Content-Type: application/json" \
  -d '{"enabled":true,"presets":["security-admin","auditor"]}' \
  "https://<your server>/api/auth/enterprise/enterprises/<enterprise id>/workos-roles"

# Publish one of the company's own roles (console); a group is /groups/<group id>/workos
curl -X PUT -H "Authorization: Bearer $API_KEY" -H "Content-Type: application/json" \
  -d '{"published":true}' \
  "https://<your server>/api/auth/enterprise/enterprises/<enterprise id>/roles/<role id>/workos"

# The company's own tools publish a group with the same field the app uses
curl -X PATCH -H "Authorization: Bearer $COMPANY_KEY" -H "Content-Type: application/json" \
  -d '{"assignableInWorkOS":true}' \
  "https://<your server>/api/v1/company/groups/<group id>"
```

Turning it on answers the view, plus `refused` when a preset you asked for could not be published: `[{ "id": "auditor", "code": "role_slug_taken", "slug": "org-bb-auditor" }]`, each with the refusal's `code`, and the `slug` when WorkOS already holds it. The field is there only when something was refused. The switch is on either way, so publish the refused preset again from its role. `PUT /workos-roles` also takes `workosRolesFrom` (`auto`, `directory` or `sso`, see [roles from WorkOS](/authentication/enterprise-sso#roles-from-workos)). In the console, `PUT /roles/<role id>/workos` takes a preset's id (`company-admin`, `security-admin`, `auditor`) as well as a custom role's, and `{ "published": false }` unpublishes. In your app and the company API, `assignableInWorkOS: true` or `false` on a role's or group's create or change does the same.

## WorkOS roles in sync

BuildBase reads the company's roles back from WorkOS after every change it makes, when WorkOS reports a role changed, when the IT admin comes back from the portal, on **Refresh from WorkOS**, and in the daily sweep at 04:17 UTC, which first retries anything waiting to be written. It compares them with what it published and says where things stand, on the console's **WorkOS roles** card (the enterprises list shows a dot beside a company that needs attention), on the Single sign-on screen, and from `GET /workos-roles`.

| State              | What it means                                                                                                                                                                                                                                         |
| ------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Off                | Role assignment in WorkOS is not turned on for the company. WorkOS roles are still read and can still be mapped                                                                                                                                       |
| In sync            | "WorkOS roles in sync": every published role is in WorkOS under its slug, with BuildBase's name and description, nothing is waiting to be written, and the last read was less than 24 hours ago                                                       |
| Needs attention    | One or more of the issues below                                                                                                                                                                                                                       |
| Can't reach WorkOS | The last read failed. It says when the last good one was. Nobody loses anything meanwhile: people keep the roles last read. Your webhooks hear of it only once the last good read is more than 24 hours old ([below](#when-the-roles-need-attention)) |

Each issue comes with a sentence and a fix:

| Issue                       | What it says                                                                      | Why, and what to do                                                                                                                                                        |
| --------------------------- | --------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `role_missing_in_workos`    | A published role is gone from WorkOS.                                             | Someone deleted it in the WorkOS dashboard. **Publish again** makes it again under the same slug; the IT admin may have to assign its groups again                         |
| `role_renamed_in_workos`    | A published role's name or description in WorkOS differs from the one in the app. | Someone edited it in the WorkOS dashboard. BuildBase never takes that edit as its own: **Publish again** writes BuildBase's back                                           |
| `publish_pending`           | A change could not be written to WorkOS yet and will be retried.                  | WorkOS was unreachable or refused a write. The change stands in BuildBase and the daily sweep retries it; **Retry now** tries at once                                      |
| `orphaned_in_workos`        | A role deleted here is still in WorkOS because groups are mapped to it.           | Your team deleted a role WorkOS still had groups assigned to. The IT admin removes them in the portal, then **Clean up** deletes the role in WorkOS                        |
| `role_gives_nothing`        | People carry a WorkOS role that no mapping, link or published role uses.          | **Map it** opens the mapping form on that slug. Leave it if the role is meant for something else                                                                           |
| `default_role_gives_access` | The role every person holds is mapped or published, so it reaches everyone.       | A warning, with who it reaches. Remove the mapping, the link or the publish unless everyone at the company should have it                                                  |
| `sources_disagree`          | Some people's directory and single sign-on roles differ.                          | Under `auto`, directory people whose two sets differ. Set `workosRolesFrom` to the one the IT admin assigns roles in                                                       |
| `people_stale`              | Some people's roles were last read from WorkOS more than 48 hours ago.            | Directory people not read for two days. **Refresh from WorkOS** queues a read of the whole directory, which reads everyone's roles again; the issue clears once it has run |

A rename or delete made in the WorkOS dashboard is shown, never applied: BuildBase's roles are the source of truth, and **Publish again** puts them back in one click.

```bash
# The company's WorkOS roles (console); the app and the company API answer the same
curl -H "Authorization: Bearer $API_KEY" \
  "https://<your server>/api/auth/enterprise/enterprises/<enterprise id>/workos-roles"

# Read WorkOS now, retry what is waiting, or delete an orphaned role in WorkOS
curl -X POST -H "Authorization: Bearer $API_KEY" \
  "https://<your server>/api/auth/enterprise/enterprises/<enterprise id>/workos-roles/refresh"
curl -X POST -H "Authorization: Bearer $API_KEY" \
  "https://<your server>/api/auth/enterprise/enterprises/<enterprise id>/workos-roles/retry"
curl -X POST -H "Authorization: Bearer $API_KEY" -H "Content-Type: application/json" \
  -d '{"slug":"org-bb-help-desk"}' \
  "https://<your server>/api/auth/enterprise/enterprises/<enterprise id>/workos-roles/cleanup"
```

The answer is `{ state, enabled, workosRolesFrom, readAt, lastGoodReadAt, published, seen, issues, checks }`. `published` lists each published item with its `kind` (`preset`, `role` or `group`), `name`, `slug` and `state` (`published`, `publish_pending` or `orphaned`). `seen` lists every slug the company's people carry, how many from the directory and how many from sign-in, and what it gives in BuildBase (`gives`, `null` for nothing). `checks` says whether role assignment and multiple roles look in use. The status the SDK reads carries `workosRoles: { enabled, state, readAt }`.

The sentences are the ones the company reads too, in your app and from `GET /workos-roles` with its own key, so none of them names BuildBase.

### When the roles need attention

Nobody has to watch the card. When a company's roles in WorkOS start to need attention, and again when they are back in sync, your server sends a [webhook event](/webhooks/overview) and starts any [workflow](/workflows/triggers-and-actions) on its trigger, once per change:

| Event                               | When                                                                                                                                                        | Payload                                                                                                                                                                                       |
| ----------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `enterprise.workos_roles_attention` | The state moves from in sync to **Needs attention**, or to **Can't reach WorkOS** once the last good read is more than 24 hours old, or there never was one | `enterpriseId`, `name`, `state` (`needs_attention` or `unreachable`), `issues` (each `code`, `item`, `since` and `sentence`; empty when WorkOS cannot be reached), `readAt`, `lastGoodReadAt` |
| `enterprise.workos_roles_recovered` | Back in sync after one of those                                                                                                                             | `enterpriseId`, `name`, `state: "in_sync"`, `attentionSince` (when it started to need attention), `readAt`                                                                                    |

- **Once per change, whoever reads.** Every read of the roles (after a write, on Refresh, on the portal return, on WorkOS's role events, in the daily sweep) works out the state, and many can run at once. The newest read of WorkOS stands: a read that answers after a newer one is not kept and sends nothing. Only the one that stores the change sends it, so two Refreshes at once send one event.
- **Never for the first state.** After role assignment is turned on, the first state is kept without an event, as it was for every company that had it on before these events existed; only a change after that is sent. When that first state needs attention, its return to in sync is kept without an event too, so `enterprise.workos_roles_recovered` always follows an `enterprise.workos_roles_attention`. Turning it off sends nothing either.
- **Nothing more while it lasts.** A new issue, or a move between **Needs attention** and **Can't reach WorkOS**, while the company already needs attention sends nothing: the card and `GET /workos-roles` show what is wrong now.
- **A blip pages nobody.** **Can't reach WorkOS** counts only once the last good read is more than 24 hours old, the age at which the status reads WorkOS again on its own. An issue that comes and goes, such as `role_gives_nothing` while the IT admin is still assigning groups, sends a pair each time.

Each change is also in the company's trail, with no actor: `enterprise_workos_roles_attention` with the `state` and the issue codes, and `enterprise_workos_roles_recovered` with `attentionSince`. The company sees them as **Roles in WorkOS need attention** and **Roles in WorkOS back in sync**, under its Company roles filter, and with company audit logs on they reach WorkOS as `company.workos_roles_attention` and `company.workos_roles_recovered`.

## How roles add up

A person can hold roles four ways at once: named by hand, through a group, through a role mapping, and through WorkOS. They add up, and none takes another away:

- **Company roles:** they hold every one, and may do the most permissive of them, area by area.
- **Workspace roles:** the highest any mapping or group gives wins, workspace by workspace, whatever the source. A custom workspace role ranks only by the rank its mapping carries.
- **A role changed by hand** stands until the next time the mappings and groups disagree with it.
- **When WorkOS stops giving a role**, what it gave goes back, as when someone leaves a directory group. Whatever was named by hand stays.
- **Through a group inside a group**, a person gets what every group that includes theirs gives, at any depth, and it adds up with the rest the same way. What a group inside theirs gives does not reach them.

Take Globex. Its **Design** workspace is bound with the viewer role. It publishes its group **Design editors**, which gives editor in Design, as `org-bb-group-design-editors`, and its IT admin assigns the identity provider's **Engineering** group to that role in the portal. Sam is in Engineering, and was named Auditor by hand last month.

| When                                            | Sam holds                                                          |
| ----------------------------------------------- | ------------------------------------------------------------------ |
| Before the IT admin assigns Engineering         | Viewer in Design, from the binding. Auditor, named by hand         |
| Engineering is assigned to Design editors       | Editor in Design, through the group. Auditor, named by hand        |
| Sam leaves Engineering in the identity provider | Viewer in Design again, the binding's role. Auditor, named by hand |

Sam's role in Design changes twice in the trail (`enterprise_member_role_changed`, `via: "group"`), and Auditor never moves: WorkOS did not give it, so WorkOS cannot take it.

## Where

| Who                       | Routes                                                                                                                                                                                                                                                                                         |
| ------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Your team, in the console | `/api/auth/enterprise/enterprises/<enterprise id>/roles`, `/company-groups`, `/admins`; `/workos-roles` (with `/refresh`, `/retry` and `/cleanup`), `/roles/<role id>/workos` and `/groups/<group id>/workos`; `/it-contacts`, read only                                                       |
| The company, in your app  | `/api/v1/public/workspaces/<workspace id>/enterprise/roles`, `/groups`, `/admins`, with the member's session; `/workos-roles` (Company roles at Can view) and `/workos-roles/refresh` (Setup at Can manage); the IT and security contacts in the enterprise status (Company roles at Can view) |
| The company's tools       | `/api/v1/company/roles` (`admins:read`, `admins:write`), `/groups` (`groups:read`, `groups:write`), `/admins` (`admins:write`), `/workos-roles` and `/it-contacts` (`admins:read`)                                                                                                             |

On each surface a group's own routes sit under its id: `/<group id>` and `/<group id>/people` read it, `/<group id>/members` and `/<group id>/managers` change it, and in your app `/<group id>/candidates` searches the company's people for it (the console's are under `/company-groups/<group id>`). A [group's managers](#group-managers) reach the reads, `/members` and `/candidates` for their own groups, and nothing else.

While **Let the company's admins change this in the app** is off, the company's own changes to groups and roles are refused with 403 `managed_by_app`, and your team keeps them in the console.

| Refusal | `code`                              | Why                                                                                                                                                                                                                                                                       |
| ------- | ----------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| 400     | `invalid`                           | A field is wrong; `field` names it (`permissions.admins`, `workspaces[0].role`, `companyRole`, `members`, `linked[0].groupId`)                                                                                                                                            |
| 403     | `company_role_required`             | The member's role does not reach the area and level the route needs; `area` and `level` name them                                                                                                                                                                         |
| 404     | `not_found`                         | No such role, group or named person                                                                                                                                                                                                                                       |
| 409     | `name_taken`                        | The company already has a role or group by that name                                                                                                                                                                                                                      |
| 409     | `preset_role`                       | Company admin, Security admin and Auditor cannot be changed or deleted                                                                                                                                                                                                    |
| 422     | `unknown_role`                      | Naming someone with a role the company does not have                                                                                                                                                                                                                      |
| 422     | `too_many_roles`, `too_many_groups` | More than 20 custom roles, or 50 groups                                                                                                                                                                                                                                   |
| 409     | `group_cycle`                       | Including a group that already includes this one, at any depth; `path` names the groups, outer to inner ([groups inside groups](#groups-inside-groups))                                                                                                                   |
| 422     | `group_too_deep`                    | The save would make a chain of more than 3 groups; `chain` names it                                                                                                                                                                                                       |
| 409     | `group_change_in_progress`          | Another change to the company's groups is being saved. Try again in a moment                                                                                                                                                                                              |
| 403     | `group_manager_limit`               | A group's manager tried what managers may not: `reason` is `company_role`, `self` or `manager` ([group managers](#group-managers))                                                                                                                                        |
| 422     | `too_many_managers`                 | A group has 10 managers already                                                                                                                                                                                                                                           |
| 403     | `company_admin_console_only`        | Publishing or unpublishing Company admin from your app or the company API: only your team does that, from the console                                                                                                                                                     |
| 409     | `workos_roles_off`                  | Publishing while role assignment in WorkOS is off for the company                                                                                                                                                                                                         |
| 409     | `role_slug_taken`                   | WorkOS already holds a role under the slug (`slug`), or another of the company's roles or groups does (`heldBy`, never adoptable). In the console, **Use the role WorkOS has** sends `adopt: true` to take it over; in the app and the company API, your team confirms it |
| 409     | `role_in_use`                       | Unpublishing, deleting or turning off while the IT admin's groups are still assigned to the role in WorkOS. `roles` names what WorkOS kept, and the message says what to do; nothing else changed                                                                         |

## In the trail

Every change is in the company's [audit trail](/authentication/enterprise-sso#the-enterprise-s-audit-trail):

| Entry                                                                                    | When                                                                                                                                                                                |
| ---------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `enterprise_group_created`, `enterprise_group_updated`, `enterprise_group_deleted`       | A group was made, changed (with what changed, and how many people it gained and lost) or deleted                                                                                    |
| `enterprise_role_created`, `enterprise_role_updated`, `enterprise_role_deleted`          | A custom role was made, changed (with the areas that changed) or deleted (with how many people and groups held it)                                                                  |
| `enterprise_admin_added`, `enterprise_admin_removed`                                     | Someone was given, or lost, a company role, with `role`, `roleName` and, on a change, `previousRole`                                                                                |
| `enterprise_member_added`, `enterprise_member_role_changed`, `enterprise_member_removed` | A group joined someone to a workspace, set their role or took them out, with `via: "group"`, the `group`, and `throughGroup` for a group inside it                                  |
| `enterprise_group_members_changed`                                                       | The people named in a group by hand changed, by any route: who was `added` and `removed` (100 listed each way, counts exact) and `actingAs`                                         |
| `enterprise_group_manager_added`, `enterprise_group_manager_removed`                     | Someone was named a group's manager or stopped being one, with the `reason` (`removed`, `deprovisioned` or `group_deleted`)                                                         |
| `enterprise_workos_roles_toggled`                                                        | Your team turned role assignment in WorkOS on or off for the company                                                                                                                |
| `enterprise_workos_roles_attention`, `enterprise_workos_roles_recovered`                 | The company's roles in WorkOS started to need attention, with the `state` and the issue codes, or are back in sync, with `attentionSince` ([above](#when-the-roles-need-attention)) |
| `enterprise_role_published`, `enterprise_role_unpublished`                               | A role or group was published to WorkOS or taken out of it, with the `kind`, the `slug` and `via` (`console`, `app` or `api`)                                                       |
| `enterprise_directory_roles_applied`                                                     | The roles WorkOS assigned a directory person changed their roles, with the slugs and what changed                                                                                   |

The same changes are [webhook events](/webhooks/overview) and [workflow triggers](/workflows/triggers-and-actions), and `enterprise.admin_added` and `enterprise.admin_removed` now carry the role:

| Event                                                                              | Payload                                                                                                         |
| ---------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------- |
| `enterprise.group_created`, `enterprise.group_updated`, `enterprise.group_deleted` | `enterpriseId`, `name`, `groupId`, `group`, and on a change `changed`, `added`, `removed`; on a delete `people` |
| `enterprise.group_members_changed`                                                 | As the trail entry: `groupId`, `group`, `added`, `removed` (user ids), their counts, `actingAs`                 |
| `enterprise.role_created`, `enterprise.role_updated`, `enterprise.role_deleted`    | `enterpriseId`, `name`, `roleId`, `roleName`, and on a change `changed`; on a delete `people` and `groups`      |
| `enterprise.admin_added`, `enterprise.admin_removed`                               | As before, with `role`, `roleName`, and on a change `previousRole` and `previousRoleName`                       |
| `enterprise.workos_roles_attention`, `enterprise.workos_roles_recovered`           | As in [when the roles need attention](#when-the-roles-need-attention)                                           |

On `enterprise.role_updated` and `enterprise.group_updated`, `changed` includes `workos` when the role or group was published to WorkOS, renamed while published, or unpublished. Publishing and unpublishing have no events of their own.

On `enterprise.group_updated` (and its trail entry), `changed` also says `includes` when the groups inside it changed, with `included` and `excluded` (each `{ groupId, name }`) and `reached` (how many people gained or lost what it gives), and `managers` when its managers changed, with `managersAdded` and `managersRemoved` (user ids). A save that changes the people named by hand writes `enterprise_group_updated` and `enterprise_group_members_changed` both. Older consumers read `changed` as a list of strings and can ignore the new values.

A change a group makes to someone's workspace emails nobody, as a directory sync does.
