# 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 — and where it doesn't 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. Scaling by row count is operationally simple but has limits the wiki should not paper over: - **Shared physical schema means shared resource contention.** Every tenant's queries hit the same MySQL instance, same tables, same indexes. A heavy report on tenant A's data competes for buffer-pool space and CPU with tenant B's order entry. There is no per-tenant resource isolation. - **Tenant filters in every WHERE clause.** Every read query carries `sBrandsId = ? AND sSubsidiaryId = ?`. Indexes have to lead with these columns to be useful — and almost all xly tables do, by convention, but a maintainer adding a new index has to remember. Forgetting produces a query plan that scans across all tenants' rows and silently slows down once the table gets large. - **No physical hard-delete boundary.** A tenant offboarding does not drop a database; it leaves the rows where they are (sometimes marked `bInvalid`, sometimes deleted, sometimes untouched). Permanent removal requires a custom cleanup script per tenant. From a GDPR / data-residency angle, "this tenant is gone" is hard to prove. - **`sBrandsId` / `sSubsidiaryId` everywhere is an inflexible tenancy unit.** "Tenant" means exactly the `(sBrandsId, sSubsidiaryId)` tuple. Alternate cuts (e.g., per-region access, per-department access without sub-tenanting) don't fit the model and would require parallel scoping columns. The model assumed this shape would always be right for every customer; in practice, it has been, but it's a hard commitment.