Security & Authorization Model
Business M implements a layered, zero-trust-ready authorization stack that scales from a simple self-hosted single-tenant deployment all the way to a fully verified multi-tenant SaaS β using the same business code throughout.
Overview: Three Layers of Access Control
Section titled βOverview: Three Layers of Access ControlβAuthorization in Business M is enforced at three distinct levels, applied in order on every request:
ββββββββββββββββββββββββββββββββββββββββββββββββββ 1. RBAC β "Can this role access this DocType?" ββ βββΊ Meta.permissions derived from RoleRegistryβββββββββββββββββββββββββββββββββββββββββββββββββ€β 2. RLS β "Does this row belong to this user?" ββ βββΊ Meta.rls_field β WHERE owner/company = ?βββββββββββββββββββββββββββββββββββββββββββββββββ€β 3. ABAC β "Is this company allowed for this tenant?" ββ βββΊ TenantContext.attributes.allowed_companiesβββββββββββββββββββββββββββββββββββββββββββββββββ1. RBAC β Canonical Role Registry
Section titled β1. RBAC β Canonical Role RegistryβThe Problem with Magic Strings
Section titled βThe Problem with Magic StringsβPreviously, role names were scattered as string literals across Meta.permissions arrays, frontend visibility_policy configs, and controller guards with no single place to audit what a role can do. This created:
- Silent drift when role names were renamed or typoβd
- No tooling discoverability (what does βAccounts Managerβ have access to?)
- Duplicate definitions across Finance and WMS modules
Solution: Structured RoleRegistry
Section titled βSolution: Structured RoleRegistryβEvery module defines its roles as typed constants in one place:
class FinanceRoles: ACCOUNTS_MANAGER = "Accounts Manager" ACCOUNTS_USER = "Accounts User" FINANCE_MANAGER = "Finance Manager" AUDITOR = "Auditor"
# libs/wms/src/wms/roles.pyclass WMSRoles: WAREHOUSE_MANAGER = "Warehouse Manager" WAREHOUSE_OPERATOR = "Warehouse Operator" INVENTORY_AUDITOR = "Inventory Auditor"
# apps/business-m/src/business_m/roles.py (core cross-module)class CoreRoles: ADMIN = "Admin" SYSTEM = "System"These constants are registered in a central RoleRegistry during module bootstrap, mapping each role to its allowed DocTypes and actions:
from business_m.roles.registry import RoleRegistry, RoleDefinition, DoctypeGrant
RoleRegistry.register(RoleDefinition( name=FinanceRoles.ACCOUNTS_MANAGER, description="Full control over all finance doctypes.", module="finance", grants=[ DoctypeGrant("JournalEntry", ["create","read","write","delete","submit","cancel","amend"]), DoctypeGrant("PaymentVoucher", ["create","read","write","delete","submit","cancel"]), # ... ]))DocType Permissions from Registry
Section titled βDocType Permissions from RegistryβInstead of hard-coding role strings in every Meta class, DocTypes derive their permission matrix from the registry:
# β Before β magic strings in every Metaclass JournalEntryMeta(Meta): permissions = [{"role": "Accounts Manager", "create": True, "read": True}, ...]
# β
After β single source of truthfrom m_protocols.roles import permissions_from_registry
class JournalEntry(BaseDocType): class Meta: permissions = permissions_from_registry("JournalEntry") requires_auth = True apply_rls = True rls_field = "company"Discovering Role Access (Tooling & Tests)
Section titled βDiscovering Role Access (Tooling & Tests)βfrom business_m.roles.registry import RoleRegistry
# All grants for a role:grants = RoleRegistry.doctypes_for_role("Accounts Manager")# β [DoctypeGrant("PaymentVoucher", [...]), DoctypeGrant("JournalEntry", [...]), ...]
# All roles that have access to a specific DocType:matrix = RoleRegistry.roles_for_doctype("JournalEntry")# β {"Accounts Manager": ["create","read",...], "Finance Manager": ["read","write",...]}Contract Test: Frontend Visibility Roles
Section titled βContract Test: Frontend Visibility RolesβThe canonical RoleRegistry is also the source of truth for frontend visibility_policy.roles[] configurations. A CI contract test enforces that every role name referenced in plugin.config.ts files resolves to a registered role:
def test_frontend_visibility_roles_are_canonical() -> None: """All roles in frontend visibility policies must be in the RoleRegistry.""" canonical_roles = {v for cls in (FinanceRoles, WMSRoles, CoreRoles) for k, v in cls.__dict__.items() if isinstance(v, str)} # ...parse plugin.config.ts, assert each role is in canonical_roles2. RLS β Row-Level Security
Section titled β2. RLS β Row-Level SecurityβRow-level security automatically scopes list queries to rows the current user is allowed to see. It is configured per DocType in Meta:
class Meta: apply_rls = True # Enable RLS for this doctype rls_field = "company" # Field used to filter rowsRLS Fields by DocType
Section titled βRLS Fields by DocTypeβ| DocType | rls_field |
Description |
|---|---|---|
ExpenseClaim |
owner |
Users see only their own claims |
PaymentVoucher |
company |
Filtered to userβs allowed companies |
PaymentReceipt |
company |
Filtered to userβs allowed companies |
JournalEntry |
company |
Filtered to userβs allowed companies |
StockEntry |
(disabled) | Multi-warehouse entries use company ABAC instead |
3. ABAC β Company-Level Authorization
Section titled β3. ABAC β Company-Level AuthorizationβBeyond RBAC and RLS, Business M enforces a company-level Attribute-Based Access Control (ABAC) filter. This ensures users only see data belonging to companies they have been authorized for, regardless of which DocType or route they access.
How It Works
Section titled βHow It WorksβThe TenantContext (resolved per request, see Β§4) carries an allowed_companies attribute. A global get_permitted_filters hook is registered at startup:
COMPANY_SCOPED = { "PaymentVoucher", "PaymentReceipt", "JournalEntry", "StockEntry", "ExpenseClaim", "FinanceCustomer", "FinanceSupplier"}
def get_company_filter(doctype: str, request: Request) -> dict | None: if doctype not in COMPANY_SCOPED: return None allowed = request.state.tenant.attributes.get("allowed_companies", []) return {"company": {"$in": allowed}} if allowed else NoneFramework M calls this hook before every list query, injecting WHERE company IN (...) automatically.
Implicit Mode: CompanyMember DocType
Section titled βImplicit Mode: CompanyMember DocTypeβIn M_TENANT_MODE=implicit (self-hosted, default), the allowed_companies list is fetched from a local CompanyMember DocType:
CompanyMember user_id β links to User company β links to CompanyAdmins assign users to companies through the standard Desk UI. No external registry is needed.
4. Multi-Tenancy: The TenantAdapter Pattern
Section titled β4. Multi-Tenancy: The TenantAdapter PatternβBusiness M supports three progressive tiers of tenant identity, selected via the M_TENANT_MODE environment variable. All tiers produce the same TenantContext struct β the rest of the application is adapter-agnostic.
graph TD
ENV["M_TENANT_MODE env var"] --> T1["implicit (default)\nImplicitTenantAdapter"]
ENV --> T2["header\nHeaderTenantAdapter"]
ENV --> T3["registry\nRegistryTenantAdapter"]
T1 -->|"Single tenant 'default',\nCompanyMember from local DB"| CTX[TenantContext]
T2 -->|"X-Tenant-ID from gateway header"| CTX
T3 -->|"Signed CommercialFact\nfrom Tenant Registry"| CTX
CTX --> MW["TenantMiddleware\ninjects into request.state.tenant"]
Tier 1: implicit (Default, Self-Host)
Section titled βTier 1: implicit (Default, Self-Host)βThe simplest mode β no registry, no headers. A single hardcoded "default" tenant is used for all requests. Company membership is stored in the local CompanyMember DocType.
- Configuration:
M_TENANT_MODE=implicit(the default) - When to use: All self-hosted deployments (single company or multi-company on the same server)
Tier 2: header (Trusted Perimeter)
Section titled βTier 2: header (Trusted Perimeter)βReads the tenant ID from the X-Tenant-ID HTTP header set by a trusted gateway (Traefik, Caddy, Nginx).
- Configuration:
M_TENANT_MODE=header - When to use: Deployments behind a dedicated internal API gateway in a trusted network. Do not expose this mode directly to the internet.
Tier 3: registry (Full Zero-Trust)
Section titled βTier 3: registry (Full Zero-Trust)βResolves tenant identity from cryptographically signed CommercialFacts issued by the Tenant Registry. Facts are:
- Hydrated at startup from a Postgres snapshot fetched from the Registry
- Kept live via NATS JetStream delta subscriptions
- Verified on every request using Ed25519 public-key signature checks
class RegistryTenantAdapter: async def resolve(self, user_id: str, request: Any) -> TenantContext: raw = await self._cache.get(f"tenant_fact:user:{user_id}") fact = CommercialFact.model_validate_json(raw) self._verify_signature(fact) # raises 403 if tampered return TenantContext( tenant_id=fact.tenant_id, roles=fact.payload["roles"], attributes=fact.payload.get("attributes", {}), )- Configuration:
M_TENANT_MODE=registry+ (TENANT_REGISTRY_URLorTENANT_REGISTRY_PUBLIC_KEY) - When to use: SaaS multi-tenant deployments where each tenant is isolated
Environment Variables
Section titled βEnvironment Variablesβ| Variable | Default | Description |
|---|---|---|
M_TENANT_MODE |
implicit |
Tenant strategy: implicit | header | registry |
M_IMPLICIT_TENANT_ID |
default |
Tenant ID used in implicit mode |
TENANT_REGISTRY_URL |
(none) | Base URL of the Tenant Registry (for registry mode) |
TENANT_REGISTRY_PUBLIC_KEY |
(none) | Base64-encoded Ed25519 public key (overrides URL fetch) |
5. Bulk Authorization Evaluation (Performance)
Section titled β5. Bulk Authorization Evaluation (Performance)βThe N+1 Problem
Section titled βThe N+1 ProblemβThe frontend sidebar uses visibility_policy.roles[] to decide which menu items to show. Before this optimization, each menu item triggered a separate POST /authz/evaluate API call. On a dashboard with 10 menu items, this produced 10 sequential HTTP roundtrips on every page load.
Solution: DataLoader + Batch Endpoint
Section titled βSolution: DataLoader + Batch EndpointβBackend: A new POST /authz/evaluate/bulk endpoint accepts an array of authorization checks and returns results in one round trip:
POST /authz/evaluate/bulk[ {"doctype": "JournalEntry", "action": "read"}, {"doctype": "PaymentVoucher", "action": "read"}, {"doctype": "StockEntry", "action": "read"}]
β [{"allowed": true}, {"allowed": true}, {"allowed": false}]Frontend: The checkUserRole() function in @framework-m/desk uses a DataLoader pattern with a 20ms coalesce window:
call checkUserRole("JournalEntry") ββcall checkUserRole("PaymentVoucher") ββ€β batched into ONE /authz/evaluate/bulkcall checkUserRole("StockEntry") ββResults are also cached in a module-level Map<string, boolean>. Subsequent navigation to the same page makes zero API calls since the results are already in the session cache. The cache is cleared on logout.
6. Database Routing by Tenant (M_DB_TIER)
Section titled β6. Database Routing by Tenant (M_DB_TIER)βIn addition to logical authorization, the database layer can route to different physical databases per tenant:
M_DB_TIER |
Resolver | Description |
|---|---|---|
local (default) |
LocalDatabaseResolver |
Single DATABASE_URL, no routing |
shared |
SharedDatabaseResolver |
Shared cluster, tenant_id column isolation |
dedicated |
DedicatedDatabaseResolver |
Per-tenant DB URL from control plane |
In shared and dedicated tiers, the framework automatically injects a tenant_id column into all scoped DocTypes at startup.
7. Monolith vs Macroservices Compatibility
Section titled β7. Monolith vs Macroservices CompatibilityβAll security code is mode-agnostic. BusinessMBootstrap wires the correct adapter via environment variables; no if mode == "monolith" logic exists in business code:
| Concern | Monolith | Macroservices |
|---|---|---|
| Tenant adapter | ImplicitTenantAdapter (default) |
RegistryTenantAdapter via NATS |
| DB resolver | LocalDatabaseResolver |
SharedDatabaseResolver or Dedicated |
| Fact cache | Redis-backed CacheProtocol |
Redis-backed CacheProtocol |
| Role registry | Registered at startup from module bootstraps | Same β registered at startup |
| Company ABAC | CompanyMember DocType in local DB |
allowed_companies from CommercialFact.payload |
8. Security Test Suite
Section titled β8. Security Test Suiteβ| Test File | What It Validates |
|---|---|
tests/unit/test_doctype_permissions.py |
All DocTypes have requires_auth=True, no "All" role, correct rls_field on finance models |
tests/test_frontend_roles_contract.py |
All role strings in plugin.config.ts files are registered canonical roles |
tests/integration/test_registry_tenant_adapter.py |
Tampered CommercialFact signatures return HTTP 403 |
tests/integration/test_tenant_isolation.py |
User A cannot see User Bβs tenant data |
tests/integration/test_company_filter.py |
Company ABAC filter correctly restricts list results |