02-multi-tenancy.md 7.46 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

Of xlyweberp_saas_ai's 1,212 tables/views, 1,008 carry both sBrandsId and sSubsidiaryId as columns. 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:

RequestAddParamUtil.me().addParams(params, userInfo);

RequestAddParamUtil lives at xlyPersist/src/main/java/com/xly/utils/RequestAddParamUtil.java (44 lines — short and worth reading in full). It pulls the authenticated user's identity out of UserInfo and writes thirteen keys into the request params map, including:

  • sBrandsId — the manufacturer/company id
  • sSubsidiaryId — the subsidiary id
  • sBrId / sSuId — short aliases used in some procs
  • sLoginId, sUserId, sUserType, sLanguage — the rest of the auth context

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:

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 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
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-SD-001, EBC-SD-003, EBC_001, EBC-COM-001, (blank) a handful each Various extensions

The 322 Essentials-tagged modules are the universally-licensed core. The others 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 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. 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 workflowsVersionFlowId 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.