# Multi-tenancy and product editions xly is a multi-tenant SaaS. The same codebase, the same database schema, the same metadata serve many customers. The framework enforces tenancy through three independent axes, applied at different layers. For the *worked example* of how this plays out for a real module, see [Slice 2](../slices/02-multi-tenancy.md). This page is the canonical two-paragraph summary you can link from anywhere. ## The three axes | Axis | Carried in | Granularity | Source of truth | |---|---|---|---| | **`sBrandsId`** (加工商ID) | almost every business row | per-row | the user's session (`UserInfo.getsBrandsId()`) | | **`sSubsidiaryId`** (子公司ID) | almost every business row | per-row | the user's session | | **`sVersionFlowId`** (版本流程ID) | `gdsmodule` only | per-module | the user's edition (against `sisversionflow`) | Per-row scoping (`sBrandsId` + `sSubsidiaryId`) appears on **1,008** of the 1,212 tables/views in this dev DB — almost every business-data table and every framework-metadata table. Per-module gating (`sVersionFlowId`) appears on **only 3** tables — and only `gdsmodule` is live; the other two are backups. So edition gating is a one-time filter at module-discovery time, not a per-row check. ## How it's enforced Every authenticated REST endpoint runs the same line right after the request arrives: ```java RequestAddParamUtil.me().addParams(params, userInfo); ``` — see `xlyPersist/.../RequestAddParamUtil.java`. This 44-line utility pulls the user's identity out of `UserInfo` and writes 13 keys into the request `params` map (`sBrandsId`, `sSubsidiaryId`, `sLoginId`, `sLanguage`, …). Every downstream MyBatis query that references `#{sBrandsId}` and `#{sSubsidiaryId}` is automatically scoped. The frontend cannot influence these — they come from the server-side session via the `@CurrentUser` argument resolver. ## Failure modes A query that *forgets* to filter by `sBrandsId` returns rows from every tenant. That's the catastrophic data-leak case. Three places to watch: 1. **Stored procedures** that compose dynamic SQL at runtime — the developer must remember to inject `sBrandsId`. The codebase does this consistently but not enforceably. 2. **Database views.** Almost all `viw_*` views select tenant columns from their primary base table, but a hand-rolled view that omits them is a per-row leak. A maintainer audit script that flags such views is a useful tool. 3. **The save endpoint** (`addUpdateDelBusinessData`), which lets the frontend supply `sTable` directly in the payload. If the runtime doesn't validate the supplied table against the form's authorised tables, this is a privilege-escalation surface. See [Slice 1](../slices/01-hello-world.md#4-user-edits-a-row-clicks-save). ## Why dev looks small `xlyweberp_saas_ai` has **one** brand (`sBrandsId = '1111111111'`), **one** subsidiary, and **one** populated edition (`8S_001 / 基础版`). The multi-tenancy machinery is wired but barely exercised here. Production tenants have dozens of brands, dozens of subsidiaries each, and several editions — the design scales by row-count, not by code.