Skip to content
Auth

Organizations (teams)

The B2B grouping level inside a workspace — org membership, org-scoped roles, invitations, and the $org.id permission variable.

A workspace is your account. An organization is one of your customers’ accounts: the company an end-user signs into your app on behalf of. Every B2B SaaS built on a backend eventually hand-rolls this — a company table, a membership join, an invite flow, and a filter on every query. Backlex ships it.

Organizations live entirely on the app plane (see Auth planes). Their members are app_users — the end-users of the app built on a workspace — never the control-plane operators who run the dashboard.

Both are called “roles” and they do completely different jobs. Keeping them straight is the whole model:

Membership roleOrg-scoped workspace role
Stored inapp_org_members.roleapp_org_member_rolesroles
Vocabularyowner / admin / member (fixed)any workspace role marked orgAssignable
Governswho may rename the org, invite, promote, removewhat data the member can read/write
Enforced byservices/app-orgs.tsthe permission resolver

So a person can be an org admin (may invite colleagues) while holding only a read-only workspace role inside that org — or the reverse. The membership role never widens data access, and a workspace role never grants org administration.

An org admin binding roles is a customer’s end-user handing out data access, not you. So a role has to be opened up before it can leave the workspace: Access → Roles → Organizations → “Organization admins may grant this role” (orgAssignable on the API). Off by default — a role written for your own staff, say a Support role reading every collection, is not something an org admin should be able to grant themselves.

The workspace admin role can never be bound as an org-scoped role, flag or no flag. An org owner administers their own org; they can’t mint themselves a workspace admin.

The flag applies to the app plane only. From the control plane (/api/app-orgs, GraphQL, MCP) you may bind any non-admin role to any member, and you may stage one on an invitation the org couldn’t have minted itself — you’re the author of the role, so there’s nobody to protect you from.

Upgrading. Every role starts closed, and nothing loses access: role resolution folds app_org_member_roles in without consulting the flag, so grants that already exist keep working. What changes is that an org admin re-granting one of those roles gets a VALIDATION error until you open it.

There is no backfill on purpose. “Already bound in some org” is per-org evidence for a per-workspace flag — it would open the role for every other org too, and it can’t tell your own deliberate control-plane binding from an org admin exploiting the gap this closes. So the roles in circulation are exactly the ones you should look at, rather than the ones that quietly stay open.

The permission layer needs exactly one answer per request. It’s resolved in services/app-orgs.ts::resolveOrgContext, in this order:

  1. X-Backlex-Org header (an org id or slug). It must name an org the caller belongs to — otherwise the request is rejected with 403, not quietly downgraded to a different org’s data.
  2. The session’s pinned org — set by POST /api/t/{slug}/orgs/set-active with { "orgId": "…" } (and cleared with null). Stored on app_sessions.active_org_id.
  3. The sole membership, when the caller belongs to exactly one org. A single-org end-user never has to select anything.
  4. Nothing. $org.id resolves to null, and an org-scoped rule matches no rows. Two different callers land here: one who belongs to no organization at all, and one with several who has selected none. Both fail closed on purpose — for the second, picking one would leak whichever happened to sort first; for the first, there is nothing to pick.

The membership list rides a per-isolate cache (the same TTL/LRU as the role and tenant caches), so this costs one cached lookup per app-plane request. A workspace with no organizations caches an empty list and is effectively free.

Nothing about an end-user’s account depends on an organization. app_users carries no org column; membership is a separate row in app_org_members. So an end-user who belongs to nothing isn’t a half-provisioned account — they sign in normally, hold authenticated plus whatever app_user_roles grants them, and read and write exactly what those roles allow.

What they don’t get is the org layer. $org.id is null, so an org-scoped rule matches no rows, and app_org_member_roles grants only fold in once there’s an active org — an org-scoped role is invisible outside its org. Note which direction that runs: belonging to no organization narrows what a caller reaches and never widens it, which is why the state needs no guard of its own.

It isn’t a tolerated edge case either, it’s the ordinary one. Orgs are a layer over the end-user pool, not the unit a workspace is built from — a B2C workspace never creates one and the feature stays inert. And every member of every org passed through the state on the way in: an invitation grants a membership, it doesn’t create an account, so an invitee is an org-less end-user right up until they accept.

Three variables become available (see Permissions):

  • $org.id — the active org
  • $org.role — the caller’s membership role in it
  • $user.orgs — every org they belong to

The canonical B2B rule — a member sees only their own org’s rows:

{ "collection": "tickets", "action": "read",
"condition": { "org_id": { "_eq": "$org.id" } } }

Or, without requiring an active selection — everything across all their orgs:

{ "condition": { "org_id": { "_in": "$user.orgs" } } }

$org.role is a value, not a field — every DSL key is a column name, so it belongs on the right-hand side of a comparison. It’s for rows that store which standing they require:

{ "collection": "org_documents", "action": "read",
"condition": { "$and": [
{ "org_id": { "_eq": "$org.id" } },
{ "required_role": { "_eq": "$org.role" } }
] } }

To gate an action on membership standing — “only owners and admins may delete” — use an org-scoped workspace role instead: define a role, grant it the delete permission, and bind it to those members via app_org_member_roles. That’s the layer built for it; the membership role governs org administration only.

Everything funnels through one service, so the guards below can’t be bypassed by picking a different surface.

Admin-only, scoped to the active workspace.

Method + pathPurpose
GET /api/app-orgsList orgs with member counts (?q= filters name/slug)
POST /api/app-orgsCreate; ownerAppUserId seeds the first owner
GET/PATCH/DELETE /api/app-orgs/{id}Read / rename+re-slug / delete (id or slug)
GET/POST /api/app-orgs/{id}/membersList / add a member
PATCH/DELETE /api/app-orgs/{id}/members/{appUserId}Change role or in-org roles / remove
GET/POST /api/app-orgs/{id}/invitesList (?pending=true) / mint an invitation
DELETE /api/app-orgs/{id}/invites/{inviteId}Revoke

The admin UI for this is Settings → Organizations.

End-user (app plane) — /api/t/{slug}/orgs

Section titled “End-user (app plane) — /api/t/{slug}/orgs”

Authenticated as an app-plane identity (bearer token or the workspace’s session cookie). A control-plane admin session is deliberately not accepted here.

Method + pathWhoPurpose
GET /api/t/{slug}/orgsmemberOrgs I belong to, plus the currently active one
POST /api/t/{slug}/orgsany end-userStart an org; the creator becomes its owner
GET /api/t/{slug}/orgs/{id}memberOne org
PATCH/DELETE …/orgs/{id}ownerRename / delete
POST …/orgs/set-activememberPin this session to an org ({ "orgId": null } clears)
POST …/orgs/{id}/leavememberLeave
GET …/orgs/{id}/membersmemberWho else is here
PATCH/DELETE …/orgs/{id}/members/{appUserId}org adminChange role + in-org roles / remove
GET/POST …/orgs/{id}/invitesorg adminList / send an invitation
DELETE …/orgs/{id}/invites/{inviteId}org adminRevoke
POST …/orgs/invites/acceptany end-userRedeem { token }

Two rules bound what a member may do to another:

  • Only an owner can grant ownership — an org admin can’t promote themselves past their own ceiling.
  • Nobody can act on a member who outranks them. An admin manages members and fellow admins; owners are above them and stay there. Acting on yourself is always allowed, which is how stepping down and leaving work.

Both live in the service, so no surface can route around them. A control-plane admin holds no membership row and sits outside the order entirely — that’s the recovery path when an org has painted itself into a corner.

One namespace, routed by client mode:

// End-user app (app mode)
const app = createClient({ url, workspace: "acme", token });
const { data: orgs } = await app.orgs.list();
app.orgs.use("northwind"); // X-Backlex-Org on every request
await app.orgs.invite("northwind", { email: "sam@customer.com", role: "admin" });
await app.orgs.acceptInvite(token);
// Dashboard / server-side (admin mode)
const admin = createClient({ url, apiKey });
await admin.orgs.create({ name: "Northwind", ownerAppUserId });
await admin.orgs.addMember(orgId, { appUserId, roleIds: [analystRoleId] });

orgs.use() is the stateless option — it just sets the header, works with access-JWT clients that have no session row, and can be passed up front as createClient({ org: "northwind" }). orgs.setActive() is the stateful one; it persists on the session so later requests need no header.

  • GraphQL: appOrgs, appOrg, appOrgMembers, appOrgInvites + createAppOrg, updateAppOrg, deleteAppOrg, addAppOrgMember, updateAppOrgMember, removeAppOrgMember, inviteToAppOrg, revokeAppOrgInvite. Admin-gated, same as REST.
  • MCP: app_orgs.list / .get / .create / .update / .delete / .members / .add_member / .update_member / .remove_member / .invites / .invite / .revoke_invite.
  • CLI: bun backlex orgs <list|get|create|update|delete|members|add-member|update-member|remove-member|invites|invite|revoke-invite>.

An org invitation is addressed to a person, not to whoever holds the link: the accepting account’s email must match the invited address, so a forwarded token is useless. It also doesn’t provision an account — the invitee needs an app_users row first (self-signup, or the workspace-level end-user invite). Keeping the two flows separate is deliberate: one creates an identity, the other grants a membership.

Invitations live 7 days. Accepted rows are kept with accepted_at set so an org has an audit trail of how each member got in. The raw token is returned exactly once — from the create call, and in the email. Listing invitations never includes it, so an org admin can’t replay somebody else’s link.

  • An org always has at least one owner. Demoting, removing or leaving as the last owner is a VALIDATION error; promote someone else first.
  • Slugs are unique per workspace. A derived slug (from the name) is auto-suffixed on collision; an explicit one that’s taken is a CONFLICT — silently renaming what the caller asked for would be worse than saying so.
  • Deleting an org drops its memberships, in-org role bindings and invitations, and clears active_org_id on any session pinned to it. Done with explicit deletes rather than FK cascade so it behaves identically on SQLite/D1, which don’t enforce foreign keys by default.
  • Deleting an end-user takes their seats with them. Both account-removal paths — DELETE /api/app-users/{id} and erasure — call removeAppUserFromAllOrgs first. A membership row orphaned by an account delete would be invisible and counted: every listing inner-joins app_users, so it vanishes from the UI while the owner count still sees it — a ghost owner satisfying the last-owner guard, letting the org’s only real owner be removed. Pending invitations are left alone on purpose: they’re addressed to an email, so someone who signs up again can still accept.
  • Cross-workspace isolation. Every lookup is tenant-scoped, and an app_users row already belongs to exactly one workspace, so a slug or id from another workspace resolves to nothing.
PieceFile
Service (all guards)apps/web/src/server/services/app-orgs.ts
Admin routesapps/web/src/server/routes/app-orgs.ts
End-user routesapps/web/src/server/routes/app-orgs-public.ts
Active-org resolutionapps/web/src/server/middleware/tenant.ts
Org-scoped role mergeapps/web/src/server/services/permissions.ts
DSL variablespackages/db/src/permission.ts
Schema (both dialects)packages/db/src/{pg,sqlite}/schema.ts
Admin UIapps/web/src/client/admin/pages/access/app-orgs.tsx
Testsapps/web/tests/app-orgs.test.ts, app-orgs-surfaces.test.ts