Skip to content

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.


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
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜

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

Every module defines its roles as typed constants in one place:

libs/finance/src/finance/roles.py
class FinanceRoles:
ACCOUNTS_MANAGER = "Accounts Manager"
ACCOUNTS_USER = "Accounts User"
FINANCE_MANAGER = "Finance Manager"
AUDITOR = "Auditor"
# libs/wms/src/wms/roles.py
class 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"]),
# ...
]
))

Instead of hard-coding role strings in every Meta class, DocTypes derive their permission matrix from the registry:

# ❌ Before β€” magic strings in every Meta
class JournalEntryMeta(Meta):
permissions = [{"role": "Accounts Manager", "create": True, "read": True}, ...]
# βœ… After β€” single source of truth
from 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"
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",...]}

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:

tests/test_frontend_roles_contract.py
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_roles

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 rows
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

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.

The TenantContext (resolved per request, see Β§4) carries an allowed_companies attribute. A global get_permitted_filters hook is registered at startup:

apps/business-m/src/business_m/hooks/permissions.py
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 None

Framework M calls this hook before every list query, injecting WHERE company IN (...) automatically.

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 Company

Admins assign users to companies through the standard Desk UI. No external registry is needed.


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"]

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)

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.

Resolves tenant identity from cryptographically signed CommercialFacts issued by the Tenant Registry. Facts are:

  1. Hydrated at startup from a Postgres snapshot fetched from the Registry
  2. Kept live via NATS JetStream delta subscriptions
  3. 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_URL or TENANT_REGISTRY_PUBLIC_KEY)
  • When to use: SaaS multi-tenant deployments where each tenant is isolated
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)

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.

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/bulk
call 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.


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.


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

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