Skip to content

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.

CallerSees and managesFilters (headers on every @Entity route)
Platform adminevery organizationorganization, then unit, then member
ORG_ADMINtheir organizationunit (sub-units included), then member
UNIT_ADMINtheir units and sub-units; never the parent unit, a sibling or the organization aboveunit (within their subtree), then member
USERtheir 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

TermMeaning
OrganizationA 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).
MembershipA 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.
RoleA 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 assignmentA 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.
UnitA 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 organizationThe 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 adminThe 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):

RoleHoldsAssigned
PLATFORM_ADMINeverything (locked: nothing to edit)from Users, platform-wide (the flag)
ORG_ADMINMembers.*, 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_ADMINORG_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
USERnothing 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 (409 ROLE_HIERARCHY, details.permissions): an admin assigns the roles below them, and nobody grants what they do not hold.
  • ORG_ADMIN and UNIT_ADMIN keep Members.*, Invitations.* and Roles.Read (ORG_ADMIN all of Roles.*) (409 ROLE_ADMIN_CORE).
  • Organization-wide-only permissions (Billing.*, Organization.*, Roles.Create/Update/Delete) never go in UNIT_ADMIN nor in a role held in a unit - by a member or a pending invitation, for system and organization roles alike (400 PERMISSION_ORG_WIDE_ONLY).
  • PLATFORM_ADMIN cannot change, and a built-in keeps its name and cannot be deleted (409 ROLE_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 default one (slug default) 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. /organizations shows them the one organization (its screen, as in multi mode); creating and suspending organizations are not offered.
    • POST /api/organizations answers 409 SINGLE_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 maxPerUser per user, counted from organizations.createdById (soft-deleted ones count; a per-user advisory lock serialises parallel requests): 403 ORGANIZATION_LIMIT_REACHED { max }.
  • A platform admin uses the platform screen: 403 ORGANIZATION_SIGNUP_CLOSED; so does everyone when the key is absent. Single mode: 409 SINGLE_ORGANIZATION_MODE.
  • The slug comes from the name (Greek transliterated, org when nothing is left) with a 4-character suffix when taken; an explicit taken slug is 409 SLUG_TAKEN.
  • Billing: only a user’s first organization gets the trial; later ones start on the default plan.

4. The platform admin

  • The PLATFORM_ADMIN role, stored as a flag on the user (Users shows it as the role), set by PATCH /api/users/:id/platform-admin { isPlatformAdmin } (returns the user row, as GET /api/users lists it; platform admin only; not on yourself; never removes the last platform admin, 409 LAST_PLATFORM_ADMIN). The first one is a manual UPDATE 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 between JwtAuthGuard and PermissionGuard) decides each request’s scope from X-Organization-Id:
    • No header, on an @Entity route: 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: 404 ORGANIZATION_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 the organization relation) is refused.
    • A malformed header (not uuids, more than 50): 400 VALIDATION_FAILED. Anyone else sending it: 403 ORGANIZATION_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/checkout and /portal), @RequirePlatformAdmin() and @Public() routes. There the header is refused (400 ORGANIZATION_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 the Vary: Origin of CORS), and one scoped beyond the caller’s own organization - the header sent, every organization, or a non-empty filter header - carries Cache-Control: no-store.
  • 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/organizations with page/pageSize and unitCount so 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 (409 SLUG_TAKEN), then opens the new organization. Picking an organization opens it at /organizations/:id - its members, settings, units and every action, sent with X-Organization-Id. Other organizations’ units load on expand (GET /api/organizations/:id/chart). GET /api/organizations also takes organization[] (those ids; the filter bar resolves its labels with it). /organizations opens the first organization; /organization sends 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’s roles tab lists the assignable system roles read-only.
  • 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 with X-Organization-Id) and on product pages across every organization. They have full rights (permissions and the reach of // @owned / // @unit records 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 the LAST_ORG_ADMIN rule 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.

CapabilityOrg admin (org-wide)Unit admin (scoped to unit U)
List members (GET /members)every membermembers 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 scopeonly 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 membershipremoves 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 scopeinto U’s subtree only; sees, resends and revokes only invitations wholly inside it
Units (/units)every unit, incl. top levelsees 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 adminsU’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.Readevents 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/Updateno (org-wide grant required)
Product records of a // @owned modelown; with the *Any permission (e.g. Todos.UpdateAny), every member’sown; 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 modeldirect units’ rows; with the *Any, all, org-level (no unit) rows includeddirect 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 organizationthe 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_ADMIN role org-wide: a change or removal that would break it is 409 LAST_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, needs Members.Read org-wide or in some unit): the unit tree on the left (search, collapse, or the box chart), the selection on the right. /organization/units/:unitId is 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 with Roles.* organization-wide; a unit admin, holding Roles.Read in 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, and GET /api/organization/roles/permissions for what the caller may put in a role) billing (Billing.Read, when the product has billing: plan, usage, Upgrade and Manage billing; Billing) and settings (Organization.Read) - org-wide grants only. The audit log is its own page (/audit). /organization opens the Members tab, or for a unit admin the top of their subtree. The old /members (?unit= selects that unit), /organization/chart and /organization/units redirect here.
  • An id of another organization’s member, unit or invitation is a 404.
  • Filters on GET /api/members (and GET /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), and directUnit (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 uses directUnit for 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, needs Members.Read org-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 (as directUnit; sub-units count their own) and its admins: members holding Members.Update through 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, system flagged, only those the caller could grant somewhere; needs Members.Read org-wide or in some unit) and GET /api/members/unit-options (the unit tree as far as the caller’s Members.Read reaches, no Units.Read needed).

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:

CallerReach
Platform adminevery 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.

PageShown to (sidebar and route guard)Platform viewOrganization view
/usersa platform admin, or Members.Read org-wide or in a unitGET /api/users: every account, its organizationsGET /api/members: the members table (roles, units, remove) with the same permissions as on the organization screen
/audita platform admin, or Audit.Read org-wide or in a unitGET /api/audit-events (Audit log)GET /api/organization/audit-events
/helpdeska platform admin, or Helpdesk.Read org-wide or in a unitGET /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 lists GET /api/users, /api/audit-events and /api/helpdesk/tickets take them too, with no reach to narrow: they combine with organization[] 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 @Entity route 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/invitation tells the token holder accountExists; that user logs in and accepts with POST /api/auth/accept-invitation { token } and their session (membership created or restored). A session for another email is 403 INVITATION_EMAIL_MISMATCH.
  • Nobody joins a suspended organization. Accepting (new account, existing account, or Google sign-up) while the organization is SUSPENDED is 400 ORGANIZATION_SUSPENDED and 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_MEMBER for 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. Permissions Units.Read/Create/Update/Move/Delete. Audited as UNIT_CREATED/RENAMED/TYPE_CHANGED/MOVED/DELETED.
  • Type: a unit may have a type (units.type, nullable): one of the product config’s unitTypes (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 without type keeps it; type: null clears 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 403 UNIT_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 // @unit model 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 unitGrants claim 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, nested select, 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 (read prisma.todo directly, not user.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 // @owned and // @unit models 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 isPlatformAdmin is a platform-wide breach. Guard PATCH /users/:id/platform-admin accordingly.

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.