02-multi-tenancy.md 10.5 KB

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

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:

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

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 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 sIds the tenant's licence permits. That list is comma-substituted into the menu SQL as sVerifyLicense:

// 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 sVerifyLicenseIN (...) 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 rowsEBC-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 (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 runtimeRequestAddParamUtil 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 — 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: 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.