Departments
How a person lands in a department, what directory mode freezes, and how a department can be filled from identity-provider groups.
A departmentDepartmentPhòng banThe bucket that pays: it owns a budget, limits and which models its people may use. Everyone belongs to exactly one.Open in glossary is the cost and policy bucket every request is billed to. Its budget and limits reach everybody billed there, except people given limits of their own — see Budgets and policy.
Key rules
- Every person is in exactly one department. There is no “no department”: unassigned people sit in
GeneralGeneralGeneralThe built-in department for everyone not assigned to another one, so every request always has a department to bill.Open in glossary. - The department is read on every request, from the person’s directory record — never from a sign-in token. A move applies on the next request, with no re-login.
- A group is never a department member. Assigning from a groupGroupNhómA set of people used to give roles and to name in sharing rules. A person can be in many groups; a group never holds money.Open in glossary copies its people once; editing the group later moves nobody.
- Directory mode decides who writes membership: the portal (
PORTAL) or your identity provider (IDP). - Moving someone moves their spend and can widen or narrow which models they may run.
What happens on each request
- The portal reads the person’s directory record. No record means no access.
- It takes their department —
Generalif they were never assigned one. - If that department blocks the chosen model, the turn is refused.
- Otherwise the call runs on the department’s key, or the person’s own if they hold a personal limit.
- The budget comes from the winning policy level, and the spend is billed to the department.
Paths into a department
| Path | How it happens |
|---|---|
| Provisioned with no department | The person lands in the seeded General department |
| Single-person reassignment | An admin moves one person (PATCH /users/:id) |
| Bulk assignment from a group | An admin assigns a group’s members (POST /groups/:id/assign-department) |
Attached provider groups, IDP mode only | Department resolution, below |
Every path writes the person’s own record; nothing reads a group at request time. Only an attached group in IDP mode moves people when its membership changes upstream.
Bulk assign with conflict review
A bulk assignment expands the group once and bills everybody not already in the target department to it. No membership is stored, so later group edits move nobody. The result names each person moved and the department they came from.
General and archived departments
Generalis a real department. Anybody in it is reported asGeneral. Its Admin console page lists members and accepts new ones like any other, but offers no removal — removing someone from a department files them inGeneral.- An archived department never receives anybody. No picker offers one, and an invitation naming one is refused when accepted.
Who owns which field
Access is gated by two fields with two owners, and both must allow.
| Field | Owner | Written by |
|---|---|---|
email, displayName, status, external_id | Identity provider | SCIM |
| A group’s name, external id and membership of a synced group | Identity provider | SCIM Groups |
suspended_at | Portal | Suspend / unsuspend |
department_id, org_role, membership of a manual group | Portal | Admin actions in PORTAL mode; department resolution in IDP mode |
| A group’s role grants and visibility rules | Portal | Admin actions, both modes |
| A department’s name, description, cost centre and budget | Portal | Admin actions, both modes |
The department itself is always portal-owned: no identity provider carries a budget or cost centre, and SCIM’s core Group schema has no description.
Nothing writes back to Keycloak or Entra ID. SCIM runs one way. So an admin cannot set status; to cut access at once, suspend the person. Suspension also disables their Keycloak account, stopping new sign-ins and session renewal. The portal record is written first, so if the provider is unreachable, access stays denied. Lifting a suspension re-enables the account only if the provider still says active — it is not a way to reactivate someone the provider disabled.
Directory mode
Each organisation chooses a directory mode once, at creation.
| Mode | Who owns the directory |
|---|---|
PORTAL (default) | The portal owns who exists, their groups and their department |
IDP | An external provider owns them; the directory arrives over SCIMSCIM provisioningCấp tài khoản qua SCIMThe standard your identity provider uses to create, update and remove portal accounts automatically, so people who leave lose access.Open in glossary and the portal refuses to write it. AI feature permissions stay with the portal |
In IDP mode | Writes |
|---|---|
Refused (409 directory_frozen) | Create user, set a user’s department, create group, add or remove a member, bulk assign from a group, issue or resend an invitation |
| Open in both modes | Department lifecycle (create, rename, archive, set default), what a group grants, roles and capabilities, visibility rules, blocked models, policies, budgets, suspension, which groups a department bills and its priority, withdrawing an open invitation, the pending list |
The refusal is a conflict (409), not a permission error, because the organisation’s state decides it — even an Org Owner is refused. Withdrawing an invitation stays open because a grant that should not exist must always be revocable.
Only “set a user’s department” is frozen, so each mode has exactly one writer of a person’s department. Archiving a department with members still works, because the archive sweep moves people directly.
Departments from groups
In IDP mode you cannot move people by hand, so you attach a department to a list of provider groups instead. One Sales department is often Sales-VN + Sales-KH + a leads group. One group can bill at most one department.
Saving a selection replaces the whole list, so nobody is billed twice half-way. An empty list detaches. In PORTAL mode this is refused (409 directory_portal_owned), because the next resolution would undo an admin’s move.
A selection is blocked when it would put someone in the groups of two active departments; the refusal says how many people. Unbilled groups and archived departments bill nobody. Overlap inside one selection is fine — someone in both Sales-VN and Sales-Leads is billed to Sales either way.
Resolution
Resolution runs only in IDP mode. Among the departments billing a group the person is in:
- The lowest priority number wins; ties go to department id. Any one of a department’s groups matching is enough.
- An archived department never receives anybody.
- A person in no billed group falls back to
General.
It writes only when the result changes, and runs when a group is provisioned, updated or deleted, when a user is provisioned, and when a selection changes — not when you edit a priority. If a detached organisation has no attached departments left, members return to the default.
When the provider deletes a billed group, the attachment goes with it; the department keeps its name, budget, cost centre and audit history. Once its last group is gone, members move to General on the next resolution. Listings show which groups each department bills.
Why it works this way
Why read the department from the directory, not the token?
A token lives until it expires. Reading the record on every request means a reassignment takes effect on the very next request, without forcing anyone to sign in again.
Why doesn't the portal write back to Entra ID?
A return channel would need directory write permissions across the whole tenant, and an HR sync would overwrite it anyway. SCIM stays one way; suspension is the portal’s own lever.
Why doesn't a deleted upstream group archive its department?
The department was created by an admin and holds a budget and history. Refusing the delete would make the provider retry forever, so the portal lets the link go and keeps the department.
Under the hood
Source-group routes and refusals
| Route | Refuses with |
|---|---|
PUT /departments/:id/source-groups | 409 directory_portal_owned, source_group_taken, source_group_overlaps; 404 unknown group |
PATCH /departments/{id} | A negative priority |
Each attachment is a row in its own table, released by its foreign key when the group is deleted upstream, under a unique index so one group bills at most one department. The body carries portal group ids. Name and description are edited through PATCH /departments/{id}.
Where the directory guard lives
The directory_frozen guard sits in the use cases, so the CLI harness hits it without HTTP. SCIM writes through its own repositories and needs no exception. Resolution is checked per call, uses no cache, and its SCIM triggers log a failure without rethrowing — a 500 on an accepted write would make the provider retry forever.