Multi-tenant company scoping
Genie is multi-tenant by company. Every tenant is a company, rows carry a CompanyId, and a
user only ever sees the companies they are assigned to — plus everything beneath them in the company
tree. The isolation is enforced in SQL, so a forged primary key or a spoofed parameter can never
reach a row outside the caller’s scope.
The company hierarchy
Section titled “The company hierarchy”Identity.CompanyUsersrecords membership — which users belong to which companies.Identity.Companies.ParentIdforms a hierarchy — authorising a parent cascades to all of its descendants.
So “a user’s scope” is not just their directly-assigned companies: it’s those companies plus every
descendant down the ParentId tree (soft-deleted companies excluded).
Scope is enforced in SQL, not the UI
Section titled “Scope is enforced in SQL, not the UI”The scope is materialised by a database function, Identity.fn_user_company_scope(@userId), deployed
by CompanyScopeSqlContributor as a hash-gated engine-SQL unit (see
Password encryption for how engine SQL is deployed). It returns
every company the user can manage: the ones they are assigned to via CompanyUsers, unioned with all
descendants via a recursive CTE.
-- SQL Server (an inline table-valued function; PostgreSQL ships an equivalent SQL function)CREATE OR ALTER FUNCTION [Identity].[fn_user_company_scope](@userId BIGINT)RETURNS TABLEASRETURN( WITH scope AS ( -- Anchor: companies the user is directly assigned to (skip soft-deleted). SELECT cu.CompanyId AS Id FROM [Identity].[CompanyUsers] cu INNER JOIN [Identity].[Companies] c0 ON c0.Id = cu.CompanyId AND c0.IsDeleted = 0 WHERE cu.ApplicationUserId = @userId AND cu.IsDeleted = 0 UNION ALL -- Recurse: every child company down the ParentId tree. SELECT c.Id FROM [Identity].[Companies] c INNER JOIN scope s ON c.ParentId = s.Id WHERE c.IsDeleted = 0 ) SELECT DISTINCT Id FROM scope)The hardcoded Auth management views gate every read (dataset + editor pickers) and every write (Insert / Edit / Delete) with the same predicate:
(IsSystem OR <row> IN Identity.fn_user_company_scope(@SessionUserId))Roles and permissions are either global (CompanyId IS NULL) or company-owned. This is
defence-in-depth on top of the RBAC verb check: even a caller who holds the
right verb only ever sees rows inside their company scope. A forged PK or a crafted @Parent__Id for
an out-of-scope row yields nothing, because the row simply isn’t in the function’s result set.
The three seeded roles
Section titled “The three seeded roles”Access to the built-in management surface is driven by three seeded roles. They differ in what they can see and what they can create — this is separate from the per-object RBAC verbs:
| Role | Visibility | CRUD within scope | Notable limits |
|---|---|---|---|
| System | Every company (bypasses RBAC and company scope) | Full | Can act in any company; can create global roles/permissions and top-level companies |
| Admin | Assigned companies + descendants | Full CRUD within scope | Not privileged for visibility — does not see all listings; can create companies |
| AccessManager | Assigned companies + descendants | Full CRUD within scope | Companies grant is read-only (cannot create companies) |
@SessionCompanyId is framework-owned
Section titled “@SessionCompanyId is framework-owned”For non-System writes, @SessionCompanyId is the forced owning company — the CompanyId stamped
onto a new role, permission, or user membership. It is a session parameter injected server-side by
CompanySessionParameterProvider; a client can never supply it. Like every framework-owned parameter,
it is beyond the reach of the caller — see Parameter sanitisation,
which strips caller-supplied Session* keys before SQL ever runs.
Its value is resolved by SessionCompanyService and validated against CompanyUsers membership
for non-System callers:
- The active company is read from the
SessionCompanyIdcookie — but a non-System caller may only activate a company they are actually assigned to (UserBelongsToCompany). The cookie is never trusted blindly. - If the cookie is missing or invalid, it falls back to the user’s default company, then their first assigned (non-deleted) company — each validated the same way.
- Switching the active company (
SetSessionCompanyIdAsync) re-checks membership and throwsUnauthorizedif the caller isn’t assigned to the target. Only System bypasses the check and can act in any company.
The membership check mirrors fn_user_company_scope (both exclude soft-deleted companies), so the
active session company can never be pinned to a company the scope function no longer returns.
Related
Section titled “Related”- Parameter sanitisation — why
@SessionCompanyIdcan’t be spoofed. - RBAC & permissions — the verb check company scoping layers on top of.
- Security model — the full layered picture.