Organizations and units
One deployment of a product serves many customer organizations (accounting firms, for example). Each organization’s data is invisible to every other organization, an organization’s admins manage only their own, and the platform admin runs the whole thing. This is core: every product has it, and a product model opts in to tenant scoping with one schema marker.
The rule behind every page: everyone sees and manages their own part of the tree and everything below it, never above.
| Caller | Sees and manages | Filters (headers on every @Entity route) |
|---|---|---|
| Platform admin | every organization | organization, then unit, then member |
ORG_ADMIN | their organization | unit (sub-units included), then member |
UNIT_ADMIN | their units and sub-units; never the parent unit, a sibling or the organization above | unit (within their subtree), then member |
USER | their own records | - |
Each level does what the level above does, over a smaller range. A filter value outside the caller’s range shrinks the result, never 403 (The platform admin, Tenant data).
1. Concepts
| Term | Meaning |
|---|---|
| Organization | A tenant: name, immutable URL-safe slug, status ACTIVE or SUSPENDED. Created by the platform admin, or by a signed-in user when the product turns on organizationSignup (see Self-service signup). |
| Membership | A user’s place in one organization (one row per user and organization; removing a member soft-deletes it, re-adding restores it). One account can hold memberships in many organizations with one login. |
| Role | A named set of permissions. System roles have no organization and are the same in every one: the four built-ins (PLATFORM_ADMIN, ORG_ADMIN, UNIT_ADMIN, USER) and any the platform admin creates; the platform admin manages them on /admin/roles. Organization roles belong to one organization: its admins create, edit and delete them on its Roles tab, and only it sees and assigns them. |
| Role assignment | A role given to a member with a scope: org-wide (the whole organization) or a unit (that unit and all its descendants). A member holds one or more assignments and never zero. |
| Unit | A branch or department inside one organization, in a tree (parentId, null = top level). A member can belong to any number of units. A unit-scoped assignment also links the member to the unit. |
| Active organization | The one organization a member’s session acts in. It is in the access token; the switcher changes it (section 3). A platform admin never has one: they work across every organization (section 4). |
| Platform admin | The PLATFORM_ADMIN role: stored as a flag on the user (users.isPlatformAdmin), never an assignment. Passes every permission check, in every organization (section 4). |
The built-in system roles (roles.key):
| Role | Holds | Assigned |
|---|---|---|
PLATFORM_ADMIN | everything (locked: nothing to edit) | from Users, platform-wide (the flag) |
ORG_ADMIN | Members.*, Invitations.*, Audit.Read, Organization.Read/Update, Units.*, Roles.*, Billing.*, Helpdesk.Read/ReadOne/Reply/UpdateStatus; keelmint adds Todos.* (incl. ReadAny/UpdateAny/DeleteAny: every member’s todos) | organization-wide only |
UNIT_ADMIN | ORG_ADMIN’s minus the organization-wide-only permissions (so Audit.Read and Helpdesk.* stay); keelmint adds Todos.* (the *Any ones reach the unit’s members’ todos) | in a unit only: that unit and its descendants |
USER | nothing in core; keelmint adds its own todos (Todos.Read/ReadOne/Create/Update/Delete) | organization-wide or in a unit |
The code gives only their initial permissions (builtInRoleDefinitions in @core/shared: core’s plus the product config’s systemRolePermissions). SystemRolesSyncService creates a missing built-in at boot (a permission no route checks fails the boot; audited SYSTEM_ROLE_SYNCED) and never changes an existing one: from then on the database is the truth and the platform admin edits them on /admin/roles. A permission a new version adds is granted to nobody until the platform admin gives it to a role (the page names permissions no role holds). A new product’s init migration has no roles: its first boot (or its seed) creates the four with the product config’s initial permissions.
The platform admin also creates, renames and deletes additional system roles (POST/PATCH/DELETE /api/roles; no key; delete only while nobody holds them, 409 ROLE_IN_USE). Rules the editor keeps (spec “Role hierarchy”):
ORG_ADMIN⊇UNIT_ADMIN⊇USER(409ROLE_HIERARCHY,details.permissions): an admin assigns the roles below them, and nobody grants what they do not hold.ORG_ADMINandUNIT_ADMINkeepMembers.*,Invitations.*andRoles.Read(ORG_ADMINall ofRoles.*) (409ROLE_ADMIN_CORE).- Organization-wide-only permissions (
Billing.*,Organization.*,Roles.Create/Update/Delete) never go inUNIT_ADMINnor in a role held in a unit - by a member or a pending invitation, for system and organization roles alike (400PERMISSION_ORG_WIDE_ONLY). PLATFORM_ADMINcannot change, and a built-in keeps its name and cannot be deleted (409ROLE_LOCKED).
Default role: what a new member gets when nobody chose a role - single-mode registration and Google sign-up, an invitation whose roles were all deleted since, and the invite dialog’s preselection. The platform default is the system role flagged roles.isDefault (initially USER; PUT /api/roles/default, platform admin, on /admin/roles); an organization may override it for itself (organizations.defaultRoleId, PUT /api/organization/roles/default { roleId }, Roles.Update organization-wide, on its Roles tab; null follows the platform). A default is a system role or the organization’s own, never an administrative one: not the three admin built-ins, nor a role that manages members, invitations or roles (Members.Update/Delete, Invitations.*, Roles.*) or holds an organization-wide-only permission (400 ROLE_NOT_DEFAULTABLE, also for an edit that would make a default role so). Deleting a default role falls back (the organization to the platform default, the platform to USER). Changes are audited DEFAULT_ROLE_CHANGED.
Role is not a tenant model (system roles have no organization): every read in an organization filters by hand, organizationId IS NULL (minus PLATFORM_ADMIN where roles are assigned) or organizationId = <active>. Names are unique per organization and against the system roles, case-insensitively (409 ROLE_NAME_TAKEN).
Nobody grants more than they hold, nor takes it away (ungrantable in @core/shared): a role’s permissions are added or removed only within the editor’s org-wide grants (403 PERMISSION_NOT_HELD, details.permissions; a bare generic verb such as Update only by a caller who holds that verb); every assignment given (PATCH /members/:userId/roles, invitations) or taken away (PATCH /members/:userId/roles, DELETE /members/:userId) to what the caller holds at its scope - org-wide for an org-wide one, org-wide or in the unit (or an ancestor) for a unit-scoped one. So a members manager without Billing.* cannot demote or remove an ORG_ADMIN; a unit admin may still change a fellow UNIT_ADMIN of the same unit (they hold the same). A role of another organization (or PLATFORM_ADMIN) is 400 UNKNOWN_ROLE; UNIT_ADMIN organization-wide is 400 ROLE_UNIT_ONLY, and a role holding an organization-wide-only permission in a unit (so ORG_ADMIN in a unit) is 400 PERMISSION_ORG_WIDE_ONLY. The platform admin has no limit; an organization’s owner (created on the platform) gets system roles only.
An organization role held by a member or a pending invitation cannot be deleted (409 ROLE_IN_USE); an organization must keep a member holding ORG_ADMIN organization-wide: a change that would leave none is refused (409 LAST_ORG_ADMIN, platform admin included). An accepted invitation whose roles were all deleted since falls back to the default role.
2. Modes: multi and single
organizations: 'multi' | 'single' in the product config (libs/<name>/shared/src/product.config.ts; the generator asks, keelmint is multi).
- multi: everything on this page. A new account from open registration has no membership and lands on
/no-organization, where it creates its own organization (with self-service signup on) or waits for an invitation. Without self-service signup,registration: 'invite-only'is usually the better setting. - single (an app for everyone, or for one company): exactly one organization exists, the built-in
defaultone (slugdefault) that the migration creates. Same code path, with these differences and nothing else:- Registration, invitation accept and Google sign-up add the new account to the built-in organization (registration with the default role; an invitation with the roles it carries). Every member always has an active organization.
- A platform admin has no active organization here either (section 3): they reach the built-in organization like any other, without switching.
/organizationsshows them the one organization (its screen, as in multi mode); creating and suspending organizations are not offered. POST /api/organizationsanswers 409SINGLE_ORGANIZATION_MODE. The switcher is hidden.- The Administration nav section (Members & structure, Users, Helpdesk and the Audit log, each by permission) shows as in multi mode: it is how an org admin, or the platform admin in the built-in organization, invites members, assigns roles and manages units. Roles is there too for the platform admin.
- Tenant scoping, token claims and tests are identical: a member’s active organization is just always the built-in one.
- single to multi later needs no data migration: every tenant row already carries the built-in organization’s id. Change the flag; the built-in organization becomes the first customer. multi to single is not supported.
3. Active organization and switching
The access token carries the platform admin flag, the active organization ({ id, name, slug } or null), the member’s roles there, permissions (the union of the org-wide assignments’ permissions) and unitGrants (section 7). AuthenticatedUser.organizationId is the organization’s id or null.
Choosing it at session start (login, 2FA verify, Google, invitation accept then login): the user’s lastOrganizationId if they still have a live membership there and the organization is ACTIVE; else their oldest live membership in an ACTIVE organization; else none. A platform admin never has an active organization: every token issued to them (login, refresh, Google) carries organization: null and no organization roles or permissions, memberships notwithstanding, and their lastOrganizationId is neither read nor written.
Refresh re-reads the session’s organization and re-validates it the same way; if it no longer holds, the session falls back to the second rule. Removing a member, or suspending an organization, also deletes the affected refresh tokens bound to that organization, so they cannot refresh back in.
Switching: POST /api/auth/organization { organizationId } (needs the access token and the refresh cookie). The session’s refresh token is rotated into one bound to the new organization, lastOrganizationId is saved, and a new access token comes back. Allowed for a live member of an ACTIVE organization. Refusals: 403 NOT_A_MEMBER (also for an organization that does not exist: nobody learns whether one they are not in exists), 403 ORGANIZATION_SUSPENDED, 403 PLATFORM_ADMIN_NO_SWITCH (a platform admin never switches; they name an organization per request with X-Organization-Id). Audited as ORGANIZATION_SWITCHED.
GET /api/auth/organizations lists what the switcher shows: the caller’s own memberships (suspended ones included, marked), platform admins included; search matches name or slug. A platform admin cannot switch into any of them; they reach every organization from the platform screens (see The platform admin).
On the client the switcher sits in the header, next to the page title, for members only (a platform admin has none). A switch navigates to the home page, because the current page may show another organization’s record.
Without an active organization, every @Entity route answers 403 ORGANIZATION_REQUIRED - except for a platform admin, who never has one: their requests reach every organization, or the one named per request with X-Organization-Id (see The platform admin). @RequirePlatformAdmin() platform routes and @Authenticated() routes need none. A member without one is sent to /no-organization by the product pages. A platform admin is not: product pages show their rows across organizations, /no-organization sends them to /users (where they also land after login), and /organization to /organizations.
Self-service signup
With organizationSignup: { maxPerUser } in the product config (multi mode
only), a signed-in user creates an organization from the no-organization
page or the header switcher’s “New organization”
(POST /api/organization-signup { name, slug? }). They hold ORG_ADMIN in
it at once and the client switches into it.
- At most
maxPerUserper user, counted fromorganizations.createdById(soft-deleted ones count; a per-user advisory lock serialises parallel requests): 403ORGANIZATION_LIMIT_REACHED { max }. - A platform admin uses the platform screen: 403
ORGANIZATION_SIGNUP_CLOSED; so does everyone when the key is absent. Single mode: 409SINGLE_ORGANIZATION_MODE. - The slug comes from the name (Greek transliterated,
orgwhen nothing is left) with a 4-character suffix when taken; an explicit taken slug is 409SLUG_TAKEN. - Billing: only a user’s first organization gets the trial; later ones start on the default plan.
4. The platform admin
- The
PLATFORM_ADMINrole, stored as a flag on the user (Users shows it as the role), set byPATCH /api/users/:id/platform-admin { isPlatformAdmin }(returns the user row, asGET /api/userslists it; platform admin only; not on yourself; never removes the last platform admin, 409LAST_PLATFORM_ADMIN). The first one is a manualUPDATE users SET "isPlatformAdmin" = true ...(README, “Roles and permissions”). Keep at least two. - Across every organization, without switching. A platform admin has no active organization.
OrganizationOverrideGuard(runs betweenJwtAuthGuardandPermissionGuard) decides each request’s scope fromX-Organization-Id:- No header, on an
@Entityroute: every organization. Reads, updates and deletes of tenant data reach the rows of any live organization (a soft-deleted organization’s rows stay out). - One id (
X-Organization-Id: <organization id>): the request runs in that organization - no member roles or grants (a platform admin carries none) - without touching the session; suspended organizations included. Unknown or deleted organization: 404ORGANIZATION_NOT_FOUND. - A comma-separated list (
X-Organization-Id: <a>,<b>, at most 50): reads, updates and deletes reach those organizations only. Unknown or deleted ids drop out; none left is an empty scope (no rows, never 404). - Creates need exactly one organization: a create (an upsert included) without the header or with a list answers 400
ORGANIZATION_REQUIRED, as does every route that works on one organization - units, members, invitations, the organization’s settings, billing and roles, its audit log and helpdesk, the role and unit pickers, the chart. The organization screen (/organizations/:id) sends the header for all of them; a product page names the target with the organization picker (Your product). - No write moves a row: under every organization or a list, an update or upsert that names
organizationId(or theorganizationrelation) is refused. - A malformed header (not uuids, more than 50): 400
VALIDATION_FAILED. Anyone else sending it: 403ORGANIZATION_OVERRIDE_FORBIDDEN. - The unit and member filter headers (
X-Filter-Unit,X-Filter-Member) narrow a platform admin’s request too, under any of these scopes (Tenant data). - Not on routes where it would change what the request means:
@NoOrganizationOverride()routes (the/auth*controllers,POST /api/organization/leave,POST /api/support/tickets,POST /api/organization/billing/checkoutand/portal),@RequirePlatformAdmin()and@Public()routes. There the header is refused (400ORGANIZATION_OVERRIDE_NOT_ALLOWED) and its absence does not mean every organization. - When the header is sent, audit events carry
metadata.viaOverride: true. - Caching: every response that passes the guard carries
Vary: X-Organization-Id, X-Filter-Unit, X-Filter-Member(added to theVary: Originof CORS), and one scoped beyond the caller’s own organization - the header sent, every organization, or a non-empty filter header - carriesCache-Control: no-store.
- No header, on an
- Platform pages (all
@RequirePlatformAdmin(), never@Entity(), so no permission grant reaches them):- The platform is part of the organization screen (below): a platform admin’s tree has a Platform heading with a New organization button and every organization under it (by name, 20 at a time: Load more (20 of 57) appends the next 20 and the search box queries the API, both
GET /api/organizationswithpage/pageSizeandunitCountso only organizations with units expand; a new search starts again at the first 20, and the organization being viewed stays listed even before its page is loaded). Each organization row has a menu: rename (PATCH /api/organizations/:id, the slug is immutable) and suspend / reactivate (POST /api/organizations/:id/suspend|reactivate); no delete. Creating takes{ name, slug, ownerEmail, ownerRoleIds }and invites the owner in the same transaction (409SLUG_TAKEN), then opens the new organization. Picking an organization opens it at/organizations/:id- its members, settings, units and every action, sent withX-Organization-Id. Other organizations’ units load on expand (GET /api/organizations/:id/chart).GET /api/organizationsalso takesorganization[](those ids; the filter bar resolves its labels with it)./organizationsopens the first organization;/organizationsends a platform admin there. Old links redirect:/admin/organizations→/organizations;/admin/organizations/:id(/chart, /units/:unitId)→/organizations/:id(…). /admin/roles: the system roles (section 1) - edit the built-ins’ permissions, create and delete additional ones, pick the platform default role, see the permissions no role holds.GET/POST /api/roles,PATCH/DELETE /api/roles/:id,PUT /api/roles/default,GET /api/roles/options(the assignable ones),GET /api/roles/:id/permissions. Every organization’srolestab lists the assignable system roles read-only.
- The platform is part of the organization screen (below): a platform admin’s tree has a Platform heading with a New organization button and every organization under it (by name, 20 at a time: Load more (20 of 57) appends the next 20 and the search box queries the API, both
- Users, Helpdesk and Audit log are one page each for every level (Admin pages per level): a platform admin always gets the platform view, across organizations.
- No entering: a platform admin has no active organization and cannot switch (403
PLATFORM_ADMIN_NO_SWITCH); the client shows them no switcher and no Enter button. They act in an organization from its screen (/organizations/:id, every request sent withX-Organization-Id) and on product pages across every organization. They have full rights (permissions and the reach of// @owned/// @unitrecords are bypassed; only the filter headers they send narrow a list; in one organization the write checks still hold: a record they create is theirs, and an update never changes its owner) and what they do in an organization is audited as theirs, with that organization on the event. A platform admin is exempt from theLAST_ORG_ADMINrule so they can fix any organization. - A platform admin in an organization they do not belong to carries no role claims; the bypass covers them.
5. Org admin vs unit admin
An org admin holds the ORG_ADMIN role, organization-wide. A unit admin holds the UNIT_ADMIN role in a unit (ORG_ADMIN’s permissions minus the organization-wide-only ones, so no billing, settings or role writes). A unit can have no admin, one or several. Routes of Members, Invitations and Units are unit-aware (@Entity({ name, units: true })): the guard passes a permission held org-wide or in any unit, and the service keeps a unit-scoped caller inside their reach.
| Capability | Org admin (org-wide) | Unit admin (scoped to unit U) |
|---|---|---|
List members (GET /members) | every member | members linked to U or below, with only the assignments and unit links inside U’s subtree (no organization-wide assignments) |
Change roles (PATCH /members/:userId/roles) | any role, any scope | only unit-scoped roles inside U’s subtree; org-wide and other units’ assignments are kept and cannot be named (403 UNIT_FORBIDDEN) |
Remove a member (DELETE /members/:userId) | ends the membership | removes only the member’s assignments and unit links inside the subtree; the membership ends only when no live role is left |
Invitations (/members/invitations) | into any scope | into U’s subtree only; sees, resends and revokes only invitations wholly inside it |
Units (/units) | every unit, incl. top level | sees U’s subtree, its root shown top-level (the parent is not exposed); creates inside it; renames, moves and deletes only units below U, never U, its parent or its siblings |
Chart (GET /members/chart) | every unit, every member, org-wide admins | U’s subtree; root counts the members linked to it; no org-wide admins |
Audit log (/organization/audit-events) | every event of the organization, with Audit.Read | events whose actor is a member (live or past) of U or below, and their own; never about what is above U (Audit.Read in U; Audit log); no events without an actor |
Helpdesk (/organization/helpdesk/tickets) | tickets of every live member, with Helpdesk.* | tickets whose reporter is a live member of U or below (Helpdesk.* in U) |
Organization settings (/organization) | yes, with Organization.Read/Update | no (org-wide grant required) |
Product records of a // @owned model | own; with the *Any permission (e.g. Todos.UpdateAny), every member’s | own; with the *Any held in U, also those of the members of U and its descendants (a member who left: org admin only) |
Product records of a // @unit model | direct units’ rows; with the *Any, all, org-level (no unit) rows included | direct units’ rows; with the *Any held in U, also the rows of U and its descendants; org-level rows are not visible |
Roles (GET /organization/roles) | the roles, each with its member count over the whole organization | the same roles, each member count only over U’s subtree |
Rules that hold for both:
- A unit admin sees their subtree and nothing above it: no organization-wide assignments, and role counts within the subtree. New members come in by invitation; an existing member outside the subtree is not visible to them. On the pending invitations they see, “invited by” is shown only for themselves and people in their reach (live members of their units); an invitation from an organization admin above them shows no inviter.
- Nobody changes their own roles or removes themselves (400). Leaving is
POST /api/organization/leave(any member). - An organization always keeps at least one member holding the
ORG_ADMINrole org-wide: a change or removal that would break it is 409LAST_ORG_ADMIN, and leaving is refused for the last one, as is deleting one’s own account (DELETE /api/profile, checked in every organization the user belongs to, under each one’s units and members locks). Unit-scoped holders do not count. The platform admin is exempt (as the one acting on others, and on the platform user delete). - The organization screen (
/organization, needsMembers.Readorg-wide or in some unit): the unit tree on the left (search, collapse, or the box chart), the selection on the right./organization/units/:unitIdis one unit: its path, type, admins, the actions the caller’s reach allows (add sub-unit, rename, move, delete) and its own members (invite into the unit, edit roles, remove). The organization itself has tabs:members(every member and the pending invitations),roles(Roles.Read: the system roles, view only, and the organization’s own - create, edit, delete withRoles.*organization-wide; a unit admin, holdingRoles.Readin a unit, reads them read-only from a header link, since a role applies to the whole organization;GET/POST /api/organization/roles,PATCH/DELETE /api/organization/roles/:id, andGET /api/organization/roles/permissionsfor what the caller may put in a role)billing(Billing.Read, when the product has billing: plan, usage, Upgrade and Manage billing; Billing) andsettings(Organization.Read) - org-wide grants only. The audit log is its own page (/audit)./organizationopens the Members tab, or for a unit admin the top of their subtree. The old/members(?unit=selects that unit),/organization/chartand/organization/unitsredirect here. - An id of another organization’s member, unit or invitation is a 404.
- Filters on
GET /api/members(andGET /api/organizations/:id/members, the same query):unit[](members of those units or their sub-units),member[](those users),role[](members holding any of the roles; a unit admin matches only the assignments inside their subtree), each repeatable (Admin pages per level), anddirectUnit(members linked to that unit itself, not its sub-units; for a unit admin, a unit outside their subtree lists nobody). The organization screen’s unit pane usesdirectUnitfor the selected unit and offers the role in the Roles column. - Organization chart (
GET /api/members/chart, the organization screen’s tree and its Chart view, needsMembers.Readorg-wide or in some unit, like the pickers): the organization at the top and the unit tree below it. Each unit shows its type, the count of members linked to it directly (asdirectUnit; sub-units count their own) and its admins: members holdingMembers.Updatethrough a role assigned at that very unit. The root shows every member the caller sees, members with only org-wide roles included, so the unit counts need not add up to it, and the org-wide admins. - Pickers:
GET /api/members/role-options(the system roles and the organization’s own,systemflagged, only those the caller could grant somewhere; needsMembers.Readorg-wide or in some unit) andGET /api/members/unit-options(the unit tree as far as the caller’sMembers.Readreaches, noUnits.Readneeded).
Admin pages per level
Users (/users), Helpdesk (/helpdesk) and the Audit log (/audit) are one page each for every level. What a caller sees follows the role hierarchy:
| Caller | Reach |
|---|---|
| Platform admin | every organization (the platform view) |
Org-wide grant (ORG_ADMIN) | the active organization |
Unit grant (UNIT_ADMIN) | the people who are live members of the unit or its sub-units, in the active organization |
Reach is by person, like // @owned records: a user (Users), an event’s actor (Audit log) or a ticket’s reporter (Helpdesk) is in reach when they are a live member of the organization (org-wide grant) or of a reached unit (unit grant). A member who left is out of reach, except in the Audit log, which keeps a unit’s history: their past events there still show to the unit’s admins.
| Page | Shown to (sidebar and route guard) | Platform view | Organization view |
|---|---|---|---|
/users | a platform admin, or Members.Read org-wide or in a unit | GET /api/users: every account, its organizations | GET /api/members: the members table (roles, units, remove) with the same permissions as on the organization screen |
/audit | a platform admin, or Audit.Read org-wide or in a unit | GET /api/audit-events (Audit log) | GET /api/organization/audit-events |
/helpdesk | a platform admin, or Helpdesk.Read org-wide or in a unit | GET /api/helpdesk/tickets (Helpdesk) | GET /api/organization/helpdesk/tickets |
/admin/users and /admin redirect to /users (query kept). An organization view needs an active organization.
Filters. A filter bar above each table offers multi-select filters, each a searchable checkbox list: Organizations (platform view; on the platform Users, Audit and Helpdesk pages Units (“Organization / Unit”) and Members follow once an organization is chosen, and taking the organization out clears both), and Units and Members (organization view, only for a caller holding Members.Read, since their options come from GET /api/members; the Users page adds Roles). Units list the caller’s reach in tree order; members search from 2 characters. A filter with nothing to choose is hidden. The selection is in the URL as repeated parameters (?unit=a&unit=b), so a link keeps it. On the API:
organization[]: rows of those organizations (platform routes:GET /api/users,/api/audit-events,/api/helpdesk/tickets,/api/organizations).unit[]: people who are live members of those units or their sub-units;member[]: those users (organization routes:GET /api/members,/api/organization/audit-events,/api/organization/helpdesk/tickets; both intersected with the caller’s reach). The platform listsGET /api/users,/api/audit-eventsand/api/helpdesk/ticketstake them too, with no reach to narrow: they combine withorganization[]by AND,unit[]matches events and tickets per (organization, person) and users by account, and a deleted or unknown unit matches nothing (an empty page).- Each value is a uuid, at most 50 per filter, one value accepted as a one-element list; otherwise 400
VALIDATION_FAILED. A value outside the caller’s reach, or of another organization, contributes nothing: the result shrinks, never 403. - These pages keep their query filters. Product pages filter the same way through headers instead (
X-Organization-Id,X-Filter-Unit,X-Filter-Member, which every@Entityroute takes): see Tenant data and, for the client, Your product.
GET /api/users lists each account’s organizations (live memberships, the first five by name) and organizationCount (all of them).
6. Invitations
An invitation is to one organization: { email, assignments: [{ roleId, unitId|null }], language? }; the platform side creates one for the owner of a new organization. Routes: POST/GET /api/members/invitations, POST /api/members/invitations/:id/resend, DELETE /api/members/invitations/:id (Invitations.Create/Read/Resend/Delete). Details and the accept flow: Invitations.
- A pending invitation is unique per (organization, email): re-inviting supersedes it. An invitation to another organization is left alone.
- Existing accounts can be invited.
GET /api/auth/invitationtells the token holderaccountExists; that user logs in and accepts withPOST /api/auth/accept-invitation { token }and their session (membership created or restored). A session for another email is 403INVITATION_EMAIL_MISMATCH. - Nobody joins a suspended organization. Accepting (new account, existing account, or Google sign-up) while the organization is
SUSPENDEDis 400ORGANIZATION_SUSPENDEDand writes nothing: the invitation stays pending and works again once the organization is reactivated (until it expires). Google sign-up passes such an invitation over for the newest one to an active organization. - No account enumeration for inviters. The inviter’s response never says whether the email has a platform account: only 409
ALREADY_MEMBERfor a live member of their own organization. The one exception is the platform side (refuseDeletedAccount), which refuses an email held by a deleted account, since that account could never accept.
7. Units
- Managed on the organization screen (a unit’s pane) and
/api/units:GET(tree),POST { name, parentId, type? },PATCH /:id { name, type? },POST /:id/move { parentId },DELETE /:id. PermissionsUnits.Read/Create/Update/Move/Delete. Audited asUNIT_CREATED/RENAMED/TYPE_CHANGED/MOVED/DELETED. - Type: a unit may have a type (
units.type, nullable): one of the product config’sunitTypes(e.g.branch,department; Your product), else 400. Null = no type. A label only: types change no permission, scope or rule (a department may sit under anything, even another department). PATCH withouttypekeeps it;type: nullclears it. The tree, the member unit options, the scope pickers and the assignment chips show it. - Sibling names are unique ignoring case (409
UNIT_NAME_TAKEN). A unit cannot move under itself or a descendant (400). An unknown or foreign unit is 404, and so is a unit outside a unit admin’s reach - as a unit to act on or as the new parent of a create or move - so the answer never tells them it exists; a unit the caller sees but may not change (their own unit, the top level) is 403UNIT_FORBIDDEN. - Delete is soft and refused while the unit is in use (409
UNIT_NOT_EMPTY): live sub-units, members, role assignments, pending invitations, or rows of any// @unitmodel pointing at it. - Reach and the token. A unit-scoped assignment is expanded at issue time over the organization’s unit tree: the access token’s
unitGrantsclaim lists, for every covered unit (the assigned one and its descendants), the grants that apply. It is encoded compactly (each distinct grant set once, plus an index per unit). A guard that needs “may this caller touch unit X for permission P” reads it with no query. - Ceiling: the token must fit a typical ~8 KB header limit, which leaves room for roughly 150 covered units per caller. Nothing enforces it: a caller whose scoped assignments cover more gets a token that a proxy may reject as too large (431 or 400 on every request). Keep unit admins’ subtrees small, or give them an org-wide role.
- Staleness: moving a unit, or changing assignments, reaches an already-issued token only at the next refresh (up to the access-token lifetime, 15 minutes by default), like role changes.
8. Tenant data in your product
How to make your own models organization-scoped, and the rules behind it, is in Tenant data.
9. Limits
- Relation reads and writes are not scoped. Like the soft-delete filter, the extension only sees top-level
wheres.include, nestedselect, relation filters (some,every) and nested writes bypass it, and raw SQL is not scoped at all. So: product code never reaches a tenant model through a relation of a non-tenant model (readprisma.tododirectly, notuser.todos), and a tenant model’s foreign keys to other tenant models are plain scalar ids, checked in the service, not relations traversed across tenants. The ownership filter of// @ownedand// @unitmodels has the same limit. - Token staleness. Roles, memberships, units and suspensions reach an issued access token only at the next refresh (about 15 minutes,
JWT_ACCESS_TTL). Removal and suspension also kill the affected refresh tokens, so the window is one access-token lifetime. - Token unit ceiling: roughly 150 covered units per caller (section 7).
- The platform admin is not tenant-limited: a bug that grants
isPlatformAdminis a platform-wide breach. GuardPATCH /users/:id/platform-adminaccordingly.
10. Testing
The isolation suites, database integration specs and what to add for a new organization-scoped route are in Testing.
11. Demo data
The development seed’s organizations, units and logins are in Demo data.