# Slice 2 — multi-tenancy and product editions xly is a multi-tenant SaaS. The same codebase, the same database schema, the same metadata tables serve many customers — and within one customer, many subsidiaries — and across customers, several product *editions* (基础版, EBC-MDM, EBC-SD, EBC-RD, …). This slice traces how that scoping is enforced. Multi-tenancy is fundamental: get it wrong and one customer reads another customer's orders. So this slice is one of the most security-relevant chapters in the wiki. ## Three scoping axes xly's tenancy has **three** dimensions, applied at different layers: | Axis | Carried in | Granularity | Used for | |---|---|---|---| | **`sBrandsId`** (加工商ID) | Almost every business row | Per-row | "Which manufacturer/company owns this row?" | | **`sSubsidiaryId`** (子公司ID) | Almost every business row | Per-row | "Which subsidiary within the company?" | | **`sVersionFlowId`** (版本流程ID) | `gdsmodule` only | Per-module | "Which product edition is this module part of?" | The first two are **per-row** scoping. The third is **per-module** filtering applied at module-list load time. Different mechanisms, different layers. ## Per-row scoping (`sBrandsId` + `sSubsidiaryId`) ### How wide Of `xlyweberp_saas_ai`'s 1,212 tables/views (901 base tables + 311 views), **1,008 carry both `sBrandsId` and `sSubsidiaryId`** as columns. A further 1 carries `sBrandsId` only (1,009 total) and 0 carry only `sSubsidiaryId`. That's nearly every business-data table and every framework-metadata table — almost the whole schema. ### How they're injected Every authenticated REST endpoint runs the same call right after the request arrives: ```java RequestAddParamUtil.me().addParams(params, userInfo); ``` `RequestAddParamUtil` lives at `xlyPersist/src/main/java/com/xly/utils/RequestAddParamUtil.java` (56 lines — short and worth reading in full). It pulls the authenticated user's identity out of `UserInfo` and writes sixteen keys into the request `params` map: - `sBrandsId` — the manufacturer/company id - `sSubsidiaryId` — the subsidiary id - `sBrId` / `sSuId` — short aliases used in some procs (duplicates of the two above) - `sLoginId` (= `userInfo.getsUserName()`), `sUserId`, `userId`, `sUserType`, `sUserName`, `sMakePerson`, `sLanguage` — auth context - `sIpAddress`, `sComputeName` — request origin - `sTeamId`, `sMachineId` — shop-floor scope - `CURRENT_USER_LOGIN_TYPE` — login channel xlyApi ships a near-identical 57-line copy at `xlyApi/src/main/java/com/xly/api/util/RequestAddParamUtil.java`; same key set, same logic. Treat them as one utility duplicated across two services. Every downstream MyBatis query and stored-procedure call that references `#{sBrandsId}` / `#{sSubsidiaryId}` is then automatically scoped. The frontend cannot influence these values — they come from the server-side session via the `@CurrentUser` argument resolver. ### How it shows up in queries The Slice-1 `getModelBysId` call ended up reading metadata via: ```sql WHERE sBrandsId = #{sBrandsId} AND sSubsidiaryId = #{sSubsidiaryId} ``` …on every metadata table (`gdsmodule`, `gdsconfigformmaster`, `gdsconfigformslave`, `gdsformconst`, `gdsjurisdiction`, `sysbillnosettings`). The same predicate appears in essentially every business-data query in the codebase. This is the multi-tenancy boundary at runtime. ### Failure mode A query that *forgets* to filter by `sBrandsId` would return rows from every tenant. That's the catastrophic data-leak case. Slice 1 already raised this as a security concern in the save-endpoint context (the frontend supplies `sTable` directly); the same concern recurs whenever a stored procedure or MyBatis mapper omits the tenant predicate. The wiki's [Maintainer Reference on permissions](../reference/maintainer/runtime.md) should call this out. ## Per-edition filtering (`sVersionFlowId`) ### What "edition" means xly is sold in several editions: **基础版** (Essentials), **EBC-MDM** (Master Data Management), **EBC-SD** (Sales/Delivery), **EBC-RD** (R&D), and others. Each edition exposes a different set of modules. A 基础版 customer doesn't see Premium modules; a Premium customer sees everything Essentials sees plus extras. ### Where editions are defined The `sisversionflow` table (1 row in this dev DB): | Column | Value | Meaning | |---|---|---| | `sId` | `17551378250008601172639655149000` | Edition primary key | | `sCode` | `8S_001` | Short code referenced by `gdsmodule.sVersionFlowCode` | | `sFlowName` | `基础版` | Display name | | `bEbcErpPremium`, `bEbcMes`, `bEbcMesStandard`, `bSass` | flags | Which product variants this edition belongs to | In the live SaaS, expect more rows here — one per distinct edition. The `EBC-MDM-002`, `EBC-SD-002`, `EBC-RD-007` flow codes seen in `gdsmodule` correspond to rows we'd find in a multi-edition production DB. ### How modules are filtered per edition `sVersionFlowId` is **only** on three tables: - `gdsmodule` (the live module catalog) - `gdsmodule_0923bak` (a backup snapshot) - `gdsmodule_copy1` (another snapshot) So per-edition filtering applies **only at module-discovery time**, not on every business-data query. When a user logs in, the framework resolves which edition their tenant is on, then filters the visible module list to those matching `gdsmodule.sVersionFlowId`. From there, every loaded module reads its data with `sBrandsId`/`sSubsidiaryId` scoping as normal. In `xlyweberp_saas_ai` the picture (`SELECT … COUNT(*) FROM gdsmodule GROUP BY sVersionFlowId, sVersionFlowCode`): | Flow code | Modules tagged | What it covers | |---|---|---| | *(blank)* | 1,002 | Untagged — overwhelmingly framework-internal modules and items the edition gate does not apply to | | `8S_001` (基础版) | 322 | Essentials baseline | | `EBC-SD-002` | 15 | Sales/Delivery | | `EBC-RD-007` | 6 | R&D | | `EBC-MDM-002` | 5 | Master-data management | | `EBC_001` | 4 | Baseline EBC bundle | | `EBC-SD-003` | 2 | SD variant | | `EBC-SD-001` | 1 | SD variant | | `EBC-COM-001` | 1 | Common component | The 322 Essentials-tagged modules are the universally-licensed core; the 1,002 untagged rows are largely framework-internal modules that no edition gate applies to (verify by sampling: `SELECT sChinese FROM gdsmodule WHERE sVersionFlowCode='' LIMIT 10` returns mostly system-management screens). The remaining named flows are edition-specific add-ons. ## Why dev looks small The `xlyweberp_saas_ai` schema this wiki is written from has **one** brand (`sBrandsId = '1111111111'`), **one** subsidiary (same value), and **one** populated edition (`8S_001`). The multi-tenancy machinery is wired but barely exercised. In production, expect dozens of brands × dozens of subsidiaries × multiple editions, all isolated through the same per-row filter pattern. ## Concepts this slice introduces - *Multi-tenant scoping* (new concept page) — `sBrandsId`/`sSubsidiaryId` as the per-row tenant boundary; the framework's universal injector (`RequestAddParamUtil`). - *Product editions* (new concept page) — `sVersionFlowId` against `sisversionflow` as the per-module visibility filter; the difference between scoping (per-row) and gating (per-module). These will be added to Concepts as part of the next backfill pass. ## Reference entries this slice exercises **Maintainer track:** - [The runtime](../reference/maintainer/runtime.md) — `RequestAddParamUtil` belongs in the runtime chapter as the universal tenant-context injector. - New page: *Multi-tenant query patterns* — the conventions every MyBatis mapper and every stored procedure must follow to stay tenant-safe. ## Open verification items 1. **Module-discovery filtering by edition.** The mechanism is reasonable (filter `gdsmodule` by `sVersionFlowId` against the user's edition), but we haven't located the exact code path. Likely candidate: `GdsmoduleController` or `GdsmoduleServiceImpl`. Confirm. 2. **Activiti workflow** — `sVersionFlowId` is *not* a workflow id (despite the name "flow"). The actual workflow tables (`act_*`, `biz_flow`, `gdsmoduleflow`, `sysflowsendtointerface`) are all empty in this dev DB. A future Slice 7 will document workflow once a DB with active flows is available. 3. **Session-level tenant resolution.** How the JWT/session lookup actually maps a logged-in user to `sBrandsId`/`sSubsidiaryId` (and which middleware enforces it) is one layer below `RequestAddParamUtil`. Worth tracing in the maintainer chapter.