Skip to content

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.

  • Identity.CompanyUsers records membership — which users belong to which companies.
  • Identity.Companies.ParentId forms 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).

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 TABLE
AS
RETURN
(
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.

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)

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 SessionCompanyId cookie — 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 throws Unauthorized if 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.