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:
sisversionflowcurrently defines only one row —8S_001 / 基础版. The other edition codes shown ingdsmodule.sVersionFlowCode(EBC-SD-002,EBC-RD-007,EBC-MDM-002, etc.) are referenced as tags on module rows but have no matching row in thesisversionflowlookup 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
sVersionFlowId lives on gdsmodule and on a couple of historical
backup snapshots of that table — nowhere else. 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.
Within gdsmodule (1358 rows in the dev DB), three tagging patterns coexist:
-
Untagged rows (
sVersionFlowCodeempty) — 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/sSubsidiaryIdas the per-row tenant boundary; the framework's universal injector (RequestAddParamUtil). -
Product editions (new concept page) —
sVersionFlowIdagainstsisversionflowas 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 —
RequestAddParamUtilbelongs 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
-
Module-discovery filtering by edition. The mechanism is reasonable
(filter
gdsmodulebysVersionFlowIdagainst the user's edition), but we haven't located the exact code path. Likely candidate:GdsmoduleControllerorGdsmoduleServiceImpl. Confirm. -
Activiti workflow —
sVersionFlowIdis not a workflow id (despite the name "flow"). The actual workflow tables (act_*,biz_flow,gdsmoduleflow,sysflowsendtointerface) are populated only in deployments that actually run an approval flow. A future Slice 7 will document workflow once a deployment with active flows is available. -
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 belowRequestAddParamUtil. Worth tracing in the maintainer chapter.