SaaS Guard — Multi-Tenant Architecture
Source:
engineering/anshin-orchestrate(GitLab ID: 80) —orchestrate-core/saas_guard/, Rev 2026-02-01
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
- Deny-by-default — if tenant scope cannot be resolved, the request is blocked immediately
- Administrator is never auto-scoped — platform admin must use explicit access methods; no implicit cross-tenant access
- Immutable per-request scope —
TenantScopeis a frozen dataclass, set once per request, never mutated - Full audit trail — every scope resolution, denial, and platform access is logged to
SaaS Audit Log - 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:
| Script | Checks For |
|---|---|
ci/check_bare_enqueue.py | Background jobs enqueued without tenant context |
ci/check_db_commit_in_hooks.py | frappe.db.commit() calls inside document hooks |
ci/check_doctypes_company_field.py | Tenant-scoped DocTypes missing the company field |
ci/check_ignore_permissions.py | Unauthorized use of ignore_permissions=True |
ci/check_raw_sql_guardrails.py | Raw 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.userfrom 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
- Support Session — if the user has an active
SaaS Support Session, scope to the session's company withmode="support" - Platform admin — if user holds a platform admin role (or is Administrator):
- If
allow_platform=True, returnmode="platform"withcompany="__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
- If
- Standard tenant user — resolve company from
User Permissionrecord
Scope Modes
| Mode | Meaning | company Value |
|---|---|---|
customer | Standard tenant user | Company name (e.g. "TAO Travel 365") |
support | Platform staff with active Support Session | Tenant company being supported |
platform | Platform 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.
@with_company_scope — not @tenant_scopedThe 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()
...
@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
| Key | Default | Description |
|---|---|---|
platform_company | None | The 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
| DocType | Purpose |
|---|---|
SaaS Audit Log | Immutable log of all scope resolutions, platform accesses, support sessions, and access denials |
SaaS Entitlement | Per-tenant plan entitlements (feature gates, usage limits) |
SaaS Provisioning Run | Tenant lifecycle operations (create, suspend, terminate) |
SaaS Support Session | Active support sessions for platform staff accessing tenant data |
SaaS Announcement | Platform-level announcements shown to tenant users |
SaaS Notification Target | Notification delivery targets per tenant |
SaaS Notification Read | Per-user read receipt tracking for notifications |
Scheduled Tasks
| Schedule | Task |
|---|---|
| Daily | Clean up expired SaaS Audit Log entries |
| Daily | Auto-archive expired SaaS Announcement records |
| Hourly | Check suspended tenants (enforces billing-based suspension) |
| Hourly | Auto-publish scheduled announcements |
Bundled Roles
SaaS Guard ships two Frappe roles as fixtures (installed automatically via bench migrate):
| Role | Purpose |
|---|---|
SaaS Platform Admin | Full cross-tenant access in platform mode |
SaaS Support Agent | Requires Support Session to access tenant data; generates audit trail |
Tenant Lifecycle
Creating a Tenant
- Create a
Companyrecord in Frappe for the new tenant - Create
User Permissionrecords assigning the tenant's users to that Company - Run the provisioning script (creates seed data, configures entitlements)
- Record the provisioning run in
SaaS Provisioning Run
Suspending a Tenant
The hourly check_suspended_tenants job:
- Checks billing status for each tenant
- If suspended: adds all tenant users to a deny-list in
frappe.cache before_requesthook rejects requests from suspended-tenant users with HTTP 402
Tenant Data Isolation Rules
- Every tenant-scoped DocType must have a
companyfield - The
ci/check_doctypes_company_field.pyCI 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:
- All changes must have tests — new enforcement paths must be covered by unit tests
- CI Layer 4 scripts must pass — run
python ci/check_*.pylocally before committing - Never weaken enforcement in a hotfix — if a security check blocks a legitimate operation, fix the calling code, not the check
- Audit logging is non-negotiable —
log_denial()must be called wherever access is denied; never silently suppress it ignore_permissions=Trueis banned in all non-platform-only code paths — the L4 CI script catches this
Document Control
| Rev | Date | Author | Description |
|---|---|---|---|
| 1.0 | 2026-02-01 | Anshin Engineering | Initial release |
| 1.1 | 2026-03-22 | Anshin Engineering | Added ownership declaration, full BFF↔OAuth↔SaaS Guard security posture, correct decorator names, check_entitlement gating, development rules |