# 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` / `sVersionFlowCode`** (版本流程ID / code) | `gdsmodule` only | Per-module tag | "Which product edition is this module tagged for?" Runtime gating uses the licence-derived module list. |

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

Essentially every business-data table and view in the schema carries
both `sBrandsId` and `sSubsidiaryId`. **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. The other tables that lack one or both columns are
single-tenant shared dictionaries or third-party schemas
(`act_*`, `qrtz_*`).

### 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

In the Slice-1 `getModelBysId` call, the per-tenant predicate

```sql
WHERE sBrandsId = #{sBrandsId} AND sSubsidiaryId = #{sSubsidiaryId}
```

is added on **per-tenant overlay reads** (`gdsconfigformpersonalize`,
`gdsconfigformcustomslave`) and on the **business-state** reads
(`sysbillnosettings`, `sysreport`, plus `sysjurisdiction` joined to
`sftlogininfojurisdictiongroup` for per-user grants — note the
returned map key is `gdsjurisdiction` even though the actual table
read is `sysjurisdiction`). It is *not* added on the framework-metadata
reads (`gdsmodule`, `gdsconfigformmaster`, `gdsconfigformslave`,
`gdsformconst`) — those are global and filtered by form-id only. The
same per-tenant predicate appears in essentially every business-data
query in the codebase. This is the multi-tenancy boundary at runtime
for tenant-owned state; framework metadata is intentionally global.

### 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

Editions are defined in the `sisversionflow` lookup table — one row
per edition. The key columns:

| Column | Meaning |
|---|---|
| `sId` | Edition primary key, referenced by `gdsmodule.sVersionFlowId` |
| `sCode` | Short code (e.g., `8S_001`, `EBC-SD-002`), referenced by `gdsmodule.sVersionFlowCode` |
| `sFlowName` | Display name (e.g., `基础版`) |
| `bEbcErpPremium`, `bEbcMes`, `bEbcMesStandard`, `bSass` | Flags marking which product variants this edition belongs to |

> **State of this dev DB:** `sisversionflow` currently defines only
> one row — `8S_001 / 基础版`. The other edition codes shown in
> `gdsmodule.sVersionFlowCode` (`EBC-SD-002`, `EBC-RD-007`,
> `EBC-MDM-002`, etc.) are referenced as tags on module rows but have
> **no matching row in the `sisversionflow` lookup table** here. A
> production tenant of the SaaS likely populates the lookup table with
> the full edition catalog; the dev DB doesn't.

### How modules are filtered per edition (the actual mechanism)

`sVersionFlowId` / `sVersionFlowCode` are TAGS on `gdsmodule` rows
labelling which edition each module belongs to — **but neither column
appears in any Java source or MyBatis mapper** (verified: `grep -r
sVersionFlowId xly-src --include='*.java' --include='*.xml'` returns
zero hits in mapper SQL). The runtime does **not** filter on those
columns directly.

The actual gate is licence-based: `xly-src/xlyBusinessService/.../license/`
(TrueLicense + xly's `VerifyLicense.getModelAllList()`) returns the
list of module `sId`s the tenant's licence permits. That list is
comma-substituted into the menu SQL as `sVerifyLicense`:

```java
// MenuChildServiceImpl.java:38-65 — getBuMenuSql
sql.append(" AND m.sId in ("+sVerifyLicense+")");
```

`sVerifyLicense` is populated either by `RequestAddParamUtil` in
`xlyApi` (lines 50-52: `params.put("sVerifyLicense","'"+String.join("','",listModel)+"'")`)
or by hand-built params in xlyEntry (e.g., `MobliePhoneController.java:57`).
So per-edition filtering really applies **at module-discovery time
through the licence layer**, not via `sVersionFlowId`. The
`sVersionFlowId`/`sVersionFlowCode` tags are catalogue metadata for
operations and BACK-side reporting; the runtime gate is `sVerifyLicense`
→ `IN (...)` against the licence-derived module list.

Within `gdsmodule` (1358 rows in the dev DB), three tagging patterns coexist:

- **Untagged rows** (`sVersionFlowCode` empty) — 1002 rows. Framework-internal modules
  and screens the edition gate does not apply to. The bulk of the catalog.
- **Essentials-tagged rows** (`sVersionFlowCode = '8S_001'`) — 322 rows. The
  universally-licensed core every edition gets.
- **Edition-specific rows** — `EBC-SD-002` (15), `EBC-RD-007` (6),
  `EBC-MDM-002` (5), `EBC_001` (4), `EBC-SD-003` (2), `EBC-SD-001` (1),
  `EBC-COM-001` (1) — add-ons gated by the customer's licence.

## 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* (concept page) — `sVersionFlowId` /
  `sVersionFlowCode` as catalogue tags, with actual visibility gated
  by the licence-derived module list; 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.
- [Multi-tenancy and product editions](../concepts/multi-tenancy.md) —
  the conventions every MyBatis mapper and stored procedure must follow
  to stay tenant-safe.

## Open verification items

1. ~~**Module-discovery filtering by edition — locate the code path.**~~
   **CLOSED.** The licence-driven filter is in
   `xlyBusinessService/.../service/impl/MenuChildServiceImpl.java:38-65`
   (`getBuMenuSql`) — the SQL ends with `AND m.sId in (#{sVerifyLicense})`.
   `sVerifyLicense` itself is sourced from `VerifyLicense.getModelAllList()`
   (TrueLicense-bound) and injected via `RequestAddParamUtil` (xlyApi)
   or controller-level param assembly (xlyEntry). See the corrected
   "How modules are filtered per edition" section above — the wiki's
   prior `sVersionFlowId` claim was wrong.
2. ~~**Activiti workflow / `sVersionFlowId` not a workflow id.**~~
   **CLOSED.** Documented in
   [Activiti integration](../reference/maintainer/activiti.md):
   Activiti is wired but idle; no BPMN deployed; the framework's
   actual workflow uses three non-Activiti paths
   (single-step proc + bCheck flag, document chaining, the gated-and-
   currently-disabled Activiti dispatch).
3. ~~**Session-level tenant resolution — JWT / session lookup chain.**~~
   **CLOSED.** Chain (all under `xlyBusinessService/.../web/token/`):
   `AuthorizationInterceptor.preHandle` checks the `Authorization`
   header against `RedisTokenManager.getToken` (AES-decrypts the
   bearer to recover `(userId, sBrandsId, sSubsidiaryId, …)`,
   then `checkToken` validates the cached token at Redis key
   `<sLoginType><userId>` and refreshes its TTL). The resolved
   `UserInfo` is then made available through `@CurrentUser`
   (resolved by `CurrentUserMethodArgumentResolver`), and
   `RequestAddParamUtil.me().addParams(params, userInfo)` injects
   the 16 keys (sBrandsId, sSubsidiaryId, sBrId, sSuId, sLoginId,
   sIpAddress, sComputeName, sUserId, userId, sLanguage, sUserType,
   sUserName, sMakePerson, sTeamId, sMachineId, CURRENT_USER_LOGIN_TYPE)
   on every authenticated method call.
