# 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 is universal across business-data tables: both `sBrandsId` and `sSubsidiaryId` appear on essentially every one. Most framework-metadata tables also carry the columns, but four of them (`gdsformconst`, `gdsmodule`, `gdsconfigformmaster`, `gdsconfigformslave`) are an explicit exception — `BusinessBaseServiceImpl.sTableNameList` (lines 162-169) lists them as "不需要公司子公司的表" and lines 1078-1084 strip `sBrandsId`/`sSubsidiaryId` from the write payload for those tables. In practice they hold a single sentinel tenant value shared across all customers. Convention: "if a row represents tenant-owned state, both columns are present *and populated from the session*." Per-module gating (`sVersionFlowId`) is the opposite — it lives on `gdsmodule` only. 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 56-line utility pulls the user's identity out of `UserInfo` and writes 16 keys into the request `params` map (`sBrandsId`, `sSubsidiaryId`, `sBrId`, `sSuId`, `sLoginId`, `sIpAddress`, `sComputeName`, `sUserId`, `userId`, `sLanguage`, `sUserType`, `sUserName`, `sMakePerson`, `sTeamId`, `sMachineId`, plus `CURRENT_USER_LOGIN_TYPE`). xlyApi ships its own near-identical 57-line copy at `xlyApi/.../api/util/RequestAddParamUtil.java`. 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). ## How the design scales The framework's multi-tenancy design scales by **row count**, not by code. A small SaaS deployment with one brand and one subsidiary uses exactly the same Java, MyBatis mappers, and stored procedures as a deployment with dozens of brands × dozens of subsidiaries × several editions; only the row distributions in `gdsmodule`, `sisversionflow`, and the business-data tables differ.