Skip to main content

SaaS Guard — Multi-Tenant Architecture

Source: engineering/anshin-orchestrate (GitLab ID: 80) — orchestrate-core/saas_guard/, Rev 2026-02-01

Anshin-Developed Application

saas_guard is designed, built, and maintained exclusively by Anshin Engineering. It is not a Frappe marketplace app or a third-party library — it is a foundational security layer in the Orchestrate platform. All future development work on this app directly impacts the security and compliance posture of every tenant on the platform.

This is the most security-critical component in the platform: a bug here can leak one tenant's data to another. Every change to saas_guard requires:

  • Full test coverage for new code paths
  • CI static analysis scripts (Layer 4) must pass
  • Senior engineering review before merging to dev

Overview

Anshin Orchestrate runs a single Frappe/ERPNext site serving multiple tenants simultaneously. Each tenant is a Company doctype in Frappe. saas_guard prevents cross-tenant data access at the framework level — not through application-layer if-checks, but through hooks that Frappe calls automatically on every query and document access.

Design Principles

  1. Deny-by-default — if tenant scope cannot be resolved, the request is blocked immediately
  2. Administrator is never auto-scoped — platform admin must use explicit access methods; no implicit cross-tenant access
  3. Immutable per-request scopeTenantScope is a frozen dataclass, set once per request, never mutated
  4. Full audit trail — every scope resolution, denial, and platform access is logged to SaaS Audit Log
  5. CI/CD enforcement — static analysis scripts catch policy violations before they reach production

The 4-Layer Defense Model

┌─────────────────────────────────────────────────────────────────┐
│ Layer 1: Request-level scope resolution (before_request hook) │
│ → Resolves TenantScope from session/OAuth user identity │
├─────────────────────────────────────────────────────────────────┤
│ Layer 2: Query-level filtering (permission_query_conditions) │
│ → Appends company = 'current_tenant' to every SQL query │
├─────────────────────────────────────────────────────────────────┤
│ Layer 3: Document-level verification (has_permission hook) │
│ → Verifies accessed document belongs to current tenant │
├─────────────────────────────────────────────────────────────────┤
│ Layer 4: CI/CD static analysis (pre-merge scripts) │
│ → Prevents policy violations from reaching production │
└─────────────────────────────────────────────────────────────────┘

Layer 1 — Request Scope Resolution

saas_guard.enforcement.hook_registry.before_request runs on every HTTP request. It calls resolve_scope() and stores the result on frappe.local:

frappe.local.tenant_company = scope.company # e.g. "TAO Travel 365" or "__platform__"
frappe.local.tenant_scope_mode = scope.mode # "customer" | "support" | "platform"
frappe.local.support_session_id = ... # set if mode is "support"

If scope resolution fails (no Company in User Permission, guest user, etc.), the before_request hook returns an HTTP 403 immediately — the request never reaches the BFF endpoint.

Layer 2 — Query Filtering

permission_query_conditions wildcard hook appends company = 'current_tenant' conditions to all SQL queries on tenant-scoped DocTypes:

permission_query_conditions = {
"*": "saas_guard.enforcement.hook_registry.get_query_conditions",
}

Both L2 and L3 use a frozenset lookup (_is_tenant_scoped()) as an early bail-out for Frappe core and platform DocTypes, keeping overhead negligible.

Layer 3 — Document Verification

has_permission wildcard hook verifies that a document being accessed belongs to the current tenant:

has_permission = {
"*": "saas_guard.enforcement.hook_registry.check_doc_permission",
}

Layer 4 — CI/CD Static Analysis

Pre-merge CI scripts scan code for policy violations before any merge to dev:

ScriptChecks For
ci/check_bare_enqueue.pyBackground jobs enqueued without tenant context
ci/check_db_commit_in_hooks.pyfrappe.db.commit() calls inside document hooks
ci/check_doctypes_company_field.pyTenant-scoped DocTypes missing the company field
ci/check_ignore_permissions.pyUnauthorized use of ignore_permissions=True
ci/check_raw_sql_guardrails.pyRaw SQL that bypasses company filters

Security Posture: SaaS Guard ↔ Frappe OAuth ↔ BFF Interchange

Understanding the security contract between the three layers is required for all work on any of them.

The Request Trust Chain

HTTP Request arrives at NGINX Ingress


Frappe gunicorn — validates session OR Bearer token:
• Session cookie (sid): Frappe session table lookup → frappe.session.user
• Bearer token: Frappe OAuth token table → frappe.session.user

▼ (frappe.session.user is now set)
before_request hook chain:
1. BFF middleware (rate limit, request ID, audit record creation)
2. SaaS Guard (tenant scope resolution → frappe.local.tenant_company)

▼ (frappe.local.tenant_company is now set)
BFF @frappe.whitelist endpoint is called


L4 SaaS Guard decorator (@with_company_scope / @platform_only / etc.)
→ Re-validates scope is set, logs denial if missing


Endpoint logic runs — calls get_current_company() safely

▼ (on every frappe.get_all / frappe.get_doc)
L2: permission_query_conditions → SQL WHERE company = 'X' is appended
L3: has_permission → document ownership verified

What Frappe OAuth Provides

Frappe's built-in OAuth2 server (frappe.integrations.oauth2) issues access tokens that Frappe validates on every request. saas_guard does not implement its own token validation — it relies entirely on Frappe's token table lookup.

What SaaS Guard adds on top of Frappe OAuth:

  • After Frappe resolves frappe.session.user from the token, SaaS Guard resolves which tenant that user belongs to
  • OAuth alone does not enforce data isolation — it only establishes identity
  • SaaS Guard maps identity → tenant scope → data isolation

Multi-Company Users

A user with User Permission records for multiple companies must set active_company in their session data before calling tenant-scoped BFF endpoints. Without this, resolve_scope() raises TenantScopeError:

# User with companies [A, B, C] — must set active_company first
POST /api/method/frappe.client.set_value
{
"doctype": "Session",
"name": "<session_id>",
"fieldname": "active_company",
"value": "TAO Travel 365"
}

Support Sessions and Audit Trail

Platform staff (with SaaS Support Agent role) can access tenant data by opening a SaaS Support Session. This:

  • Time-boxes access (max 4 hours, tracked by expires_at)
  • Scopes the staff user to a specific tenant company for the session duration
  • Generates a complete audit trail in SaaS Audit Log (every BFF call, every data access)

This is the only mechanism for platform staff to see tenant data. There is no "admin override" mode that bypasses the audit trail.


Tenant Scope Resolution

The core logic lives in saas_guard.enforcement.tenant_context.resolve_scope().

Resolution Order

  1. Support Session — if the user has an active SaaS Support Session, scope to the session's company with mode="support"
  2. Platform admin — if user holds a platform admin role (or is Administrator):
    • If allow_platform=True, return mode="platform" with company="__platform__"
    • If user's Company (via User Permission) matches the configured platform_company, auto-scope to platform mode (logged to audit trail)
    • In developer_mode (dev/stage only), auto-scope to the user's assigned Company — avoids ceremony during development
    • Otherwise: raise SupportSessionRequiredError
  3. Standard tenant user — resolve company from User Permission record

Scope Modes

ModeMeaningcompany Value
customerStandard tenant userCompany name (e.g. "TAO Travel 365")
supportPlatform staff with active Support SessionTenant company being supported
platformPlatform admin with explicit cross-tenant access"__platform__"

The TenantScope Object

TenantScope is an immutable frozen dataclass — it cannot be modified once set:

@dataclass(frozen=True)
class TenantScope:
company: str
mode: str # "customer" | "support" | "platform"
support_session_id: str | None = None

Getting the Current Company in App Code

from saas_guard.enforcement.tenant_context import get_current_company, get_scope_mode, is_platform_mode

# In tenant-scoped operations:
company = get_current_company() # raises TenantScopeError if mode is "platform"
mode = get_scope_mode() # "customer", "support", or "platform"

# In platform-mode-aware operations:
if is_platform_mode():
# cross-tenant operation allowed
else:
company = get_current_company()

get_current_company() raises TenantScopeError if called in platform mode — it is intentionally unusable for cross-tenant operations. Callers must explicitly check is_platform_mode() first.


L4 Decorators — Endpoint Access Control

All SaaS Guard decorators live in saas_guard.enforcement.decorators. These are the explicit per-endpoint access control contract that BFF endpoints must declare.

danger
Correct decorator: @with_company_scope — not @tenant_scoped

The decorator for standard tenant endpoints is @with_company_scope. There is no @tenant_scoped decorator. Using the wrong name results in a silent AttributeError at call time.

@with_company_scope — Standard Tenant

from saas_guard.enforcement.decorators import with_company_scope
from saas_guard.enforcement.tenant_context import get_current_company

@frappe.whitelist(methods=["GET"])
@with_company_scope
def get_my_bookings():
company = get_current_company()
return frappe.get_all("Booking", filters={"company": company}, ...)

@platform_only — Cross-Tenant / Admin Operations

from saas_guard.enforcement.decorators import platform_only

@frappe.whitelist(methods=["GET"])
@platform_only
def list_all_tenants():
"""Only callable by platform admins. No company filter."""
return frappe.get_all("Company", filters={"is_tenant": 1}, ...)

@support_session_required — Explicit Audit Access

from saas_guard.enforcement.decorators import support_session_required

@frappe.whitelist(methods=["POST"])
@support_session_required
def impersonate_tenant_action():
"""Requires active SaaS Support Session. Generates full audit trail."""
company = get_current_company() # returns the session's target company
...

@check_entitlement — Feature Gating

from saas_guard.enforcement.decorators import check_entitlement, with_company_scope

@frappe.whitelist(methods=["GET"])
@check_entitlement("advanced_analytics") # outer — runs second (after scope is set)
@with_company_scope # inner — runs first (sets scope)
def get_advanced_report():
"""Only tenants with 'advanced_analytics' SaaS Entitlement can call this."""
company = get_current_company()
...
Decorator order matters

@check_entitlement must wrap (outer) @with_company_scope (inner). Python decorators apply bottom-up, so @with_company_scope runs first, setting frappe.local.tenant_company, before @check_entitlement calls get_current_company() to look up the entitlement.


Configuration

SaaS Guard is configured via the saas_guard_config Frappe hook. Any installed app can contribute to this config:

# In orchestrate/hooks.py
saas_guard_config = {
"platform_company": "Anshin Health Solutions",
"platform_roles": ["SaaS Platform Admin", "System Manager"],
}

Config Keys

KeyDefaultDescription
platform_companyNoneThe company that owns the platform. Users from this company get auto-scoped to platform mode.
platform_roles["System Manager"]Roles that grant platform admin access. System Manager is always included.

DocTypes

DocTypePurpose
SaaS Audit LogImmutable log of all scope resolutions, platform accesses, support sessions, and access denials
SaaS EntitlementPer-tenant plan entitlements (feature gates, usage limits)
SaaS Provisioning RunTenant lifecycle operations (create, suspend, terminate)
SaaS Support SessionActive support sessions for platform staff accessing tenant data
SaaS AnnouncementPlatform-level announcements shown to tenant users
SaaS Notification TargetNotification delivery targets per tenant
SaaS Notification ReadPer-user read receipt tracking for notifications

Scheduled Tasks

ScheduleTask
DailyClean up expired SaaS Audit Log entries
DailyAuto-archive expired SaaS Announcement records
HourlyCheck suspended tenants (enforces billing-based suspension)
HourlyAuto-publish scheduled announcements

Bundled Roles

SaaS Guard ships two Frappe roles as fixtures (installed automatically via bench migrate):

RolePurpose
SaaS Platform AdminFull cross-tenant access in platform mode
SaaS Support AgentRequires Support Session to access tenant data; generates audit trail

Tenant Lifecycle

Creating a Tenant

  1. Create a Company record in Frappe for the new tenant
  2. Create User Permission records assigning the tenant's users to that Company
  3. Run the provisioning script (creates seed data, configures entitlements)
  4. Record the provisioning run in SaaS Provisioning Run

Suspending a Tenant

The hourly check_suspended_tenants job:

  1. Checks billing status for each tenant
  2. If suspended: adds all tenant users to a deny-list in frappe.cache
  3. before_request hook rejects requests from suspended-tenant users with HTTP 402

Tenant Data Isolation Rules

  • Every tenant-scoped DocType must have a company field
  • The ci/check_doctypes_company_field.py CI script verifies this automatically
  • No new DocType containing tenant data should be created without registering it in the tenant-scoped DocTypes list

Development Rules for saas_guard Changes

Because saas_guard is a critical security layer, additional rules apply beyond the standard git workflow:

  1. All changes must have tests — new enforcement paths must be covered by unit tests
  2. CI Layer 4 scripts must pass — run python ci/check_*.py locally before committing
  3. Never weaken enforcement in a hotfix — if a security check blocks a legitimate operation, fix the calling code, not the check
  4. Audit logging is non-negotiablelog_denial() must be called wherever access is denied; never silently suppress it
  5. ignore_permissions=True is banned in all non-platform-only code paths — the L4 CI script catches this

Document Control

RevDateAuthorDescription
1.02026-02-01Anshin EngineeringInitial release
1.12026-03-22Anshin EngineeringAdded ownership declaration, full BFF↔OAuth↔SaaS Guard security posture, correct decorator names, check_entitlement gating, development rules