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 (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 sVerifyLicense
→ IN (...) against the licence-derived module list.
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 — locate the code path.CLOSED. The licence-driven filter is inxlyBusinessService/.../service/impl/MenuChildServiceImpl.java:38-65(getBuMenuSql) — the SQL ends withAND m.sId in (#{sVerifyLicense}).sVerifyLicenseitself is sourced fromVerifyLicense.getModelAllList()(TrueLicense-bound) and injected viaRequestAddParamUtil(xlyApi) or controller-level param assembly (xlyEntry). See the corrected "How modules are filtered per edition" section above — the wiki's priorsVersionFlowIdclaim was wrong. -
Activiti workflow /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).sVersionFlowIdnot a workflow id. -
Session-level tenant resolution — JWT / session lookup chain.CLOSED. Chain (all underxlyBusinessService/.../web/token/):AuthorizationInterceptor.preHandlechecks theAuthorizationheader againstRedisTokenManager.getToken(AES-decrypts the bearer to recover(userId, sBrandsId, sSubsidiaryId, …), thencheckTokenvalidates the cached token at Redis key<sLoginType><userId>and refreshes its TTL). The resolvedUserInfois then made available through@CurrentUser(resolved byCurrentUserMethodArgumentResolver), andRequestAddParamUtil.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.