All concepts
02 — Concept

Tenancy and access

Every row belongs to an organisation, and one schema serves a single tenant or a thousand — changing modes needs no migration.

One schema for one organisation or a thousand

Every row of domain data belongs to a tenant, and the schema is identical whether the app runs for a single organisation or a thousand. TENANCY_MODE is configuration, not a fork of the codebase:

  • multi — users belong to many organisations through tenant_users; the session carries the current one, an org switcher moves between them, and global admins manage every tenant from the admin area.
  • single — one organisation is created at bootstrap and every admitted user is auto-joined to it. The switcher and the tenant-select screen disappear from the UI and answer 404 on the server. Members, roles, invitations and settings all stay.

Why one schema. The alternative — a "simple" single-tenant build you later retrofit — means a migration and an audit of every query on the day the second customer arrives. Here, flipping to multi later needs no migration at all, because the tenant column was never optional.

Sign-up modes: invite-only, approval or open

SIGNUP_MODE decides what happens when somebody who was never invited signs in. Invite-only is the default.

ModeAn uninvited login gets…
invite_onlya user row and a "pending" screen. No organisation is created, and there is nothing for an admin to review.
approvalthe same, plus one access request that a global admin approves into a new or existing organisation, or rejects with a note.
opena personal organisation of their own.

The access request is written when the email is verified, not when a magic link is requested — so the queue cannot be filled by typing a stranger's address into the login box. An optional domain allow-list turns away obvious outsiders before any of that.

Invited users behave the same in all three modes: the pending-invitation handler runs first on every login path, and every fallback gates on "has no memberships" rather than "is a new user" — which is what stops someone who just lost their last organisation from being stranded.

Roles and permissions: owner, admin, member, support

A membership carries one of owner, admin or member. A fourth role, support, can only be minted from the admin area, is excluded from member counts, and is deliberately visible to the customer. Separately, users.isGlobalAdmin is a platform flag — not a role inside any organisation.

Permissions are CASL abilities computed on the server and shipped to the browser with the session, so a nav item, a page guard and the route behind them all consult the same rules. As a rough shape: owners and admins manage their organisation; members read it and write the things they own; support behaves like an admin; global admins can do anything.

Two actions ignore the ability system on purpose and check the role directly: deleting an organisation, and changing who owns it. Those are one-way doors, and an explicit role === 'owner' test is easier to audit than a rule buried in a policy.

Groups: which rows a person can see

A role says what kind of thing you may do. A group says which rows you may see. An organisation declares group types — Department, Region, Client — each holding groups, each holding members; that membership joins the authenticated context and decides who may read a knowledge document or a dashboard.

It is part of the core rather than an optional extra, and it is inert until somebody uses it: every row ships visible to the whole organisation, so an app that never creates a group type behaves exactly as it did before one existed.

Why being restricted is a column. Restriction is an explicit value on the row, not "has rows in the grants table". Infer it from the grants instead, and deleting the last group a document was shared with publishes that document to the entire organisation, silently. Here the row stays restricted with an empty grant list, which matches nobody — so it narrows to its owner and to admins. Deleting a group that still grants something is refused with a count of what it would affect, and forcing it still only ever narrows.

The check itself is SQL, written once: a predicate added to the tenant predicate, never a replacement for it, and never a condition buried in the ability system — the same rule "is this row mine?" has always followed here. Owners, admins and support are not narrowed. A row you may not see answers the same 404 as one that does not exist, so the API is not an existence oracle in either direction.

Every read path follows it: the documents list, the hybrid search, the three tools an agent uses to read the knowledge base, the answers the chat box gives, the uploaded original behind a document, and the dashboards. An agent run resolves its requester's groups when it runs rather than when it was queued, so a run started before somebody left a group does not read on their old access. Being in a group grants read only — editing a document or a dashboard stays with its owner or an admin.

A membership change is a nudge to the people it moved, so somebody who loses a group watches the content disappear rather than clicking into a 404. There is no directory sync yet: SCIM groups and identity-provider group claims are the obvious next step. The kit speaks neither SCIM nor SAML, and its OIDC sign-in reads a groups claim without mapping it.

Tenant isolation: a predicate you cannot forget quietly

Every domain query filters by the tenant id taken from the authenticated context — never from anything the client sent. That predicate is also what keeps the composite indexes selective, so it is load-bearing twice over.

Cross-tenant SQL exists in exactly two places: the admin routes, and the pre-tenant part of the auth path (sign-in, invite acceptance). A test pins that list, so widening it is a decision somebody makes on purpose rather than a refactor that slips through.

On top of that, every tenant table also carries a Postgres row-level-security policy. The policies ship switched off, with the application role created but unable to log in, and a catalog-driven test that fails the build if a new tenant table arrives without one. Turning enforcement on is a configuration change — the scaffolding is already in place and already tested.

The admin area, and entering a customer's organisation

Everything cross-tenant lives behind one global-admin middleware, so the blast radius of that capability is one file. When an admin enters a customer's organisation to help them, the app inserts a real support membership row rather than special-casing the request. The auth middleware keeps its single invariant — you must be a member — and the customer can see who is in their organisation.

The CLI runs inside one organisation too

Logging the CLI in ends with a tenant-scoped API key, so every command already runs inside one organisation and travels the same middleware and the same abilities as the UI. There is no second permission model for machines.