multi-tenancy.md 5.64 KB

Multi-tenancy and product editions

xly is a multi-tenant SaaS. The same codebase, the same database schema, the same metadata serve many customers. The framework enforces tenancy through three independent axes, applied at different layers.

For the worked example of how this plays out for a real module, see Slice 2. This page is the canonical two-paragraph summary you can link from anywhere.

The three axes

Axis Carried in Granularity Source of truth
sBrandsId (加工商ID) almost every business row per-row the user's session (UserInfo.getsBrandsId())
sSubsidiaryId (子公司ID) almost every business row per-row the user's session
sVersionFlowId / sVersionFlowCode (版本流程ID / code) gdsmodule only per-module tag edition catalogue metadata; the runtime menu gate uses the licence-derived sVerifyLicense module list

Per-row scoping is universal across business-data tables: both sBrandsId and sSubsidiaryId appear on essentially every one. 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. Convention: "if a row represents tenant-owned state, both columns are present and populated from the session."

Per-module edition metadata is the opposite — it lives on gdsmodule only. The live runtime does not filter directly on sVersionFlowId; module discovery is gated by the licence-derived sVerifyLicense list of permitted gdsmodule.sId values. So edition gating is a one-time module-discovery filter, not a per-row check.

How it's enforced

Every authenticated REST endpoint runs the same line right after the request arrives:

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

— see xlyPersist/.../RequestAddParamUtil.java. This 56-line utility pulls the user's identity out of UserInfo and writes 16 keys into the request params map (sBrandsId, sSubsidiaryId, sBrId, sSuId, sLoginId, sIpAddress, sComputeName, sUserId, userId, sLanguage, sUserType, sUserName, sMakePerson, sTeamId, sMachineId, plus CURRENT_USER_LOGIN_TYPE). xlyApi ships its own near-identical 57-line copy at xlyApi/.../api/util/RequestAddParamUtil.java. Every downstream MyBatis query that references #{sBrandsId} and #{sSubsidiaryId} is automatically scoped. The frontend cannot influence these — they come from the server-side session via the @CurrentUser argument resolver.

Failure modes

A query that forgets to filter by sBrandsId returns rows from every tenant. That's the catastrophic data-leak case. Three places to watch:

  1. Stored procedures that compose dynamic SQL at runtime — the developer must remember to inject sBrandsId. The codebase does this consistently but not enforceably.
  2. Database views. Almost all viw_* views select tenant columns from their primary base table, but a hand-rolled view that omits them is a per-row leak. A maintainer audit script that flags such views is a useful tool.
  3. The save endpoint (addUpdateDelBusinessData), which lets the frontend supply sTable directly in the payload. If the runtime doesn't validate the supplied table against the form's authorised tables, this is a privilege-escalation surface. See Slice 1.

How the design scales — and where it doesn't

The framework's multi-tenancy design scales by row count, not by code. A small SaaS deployment with one brand and one subsidiary uses exactly the same Java, MyBatis mappers, and stored procedures as a deployment with dozens of brands × dozens of subsidiaries × several editions; only the row distributions in gdsmodule, sisversionflow, and the business-data tables differ.

Scaling by row count is operationally simple but has limits the wiki should not paper over:

  • Shared physical schema means shared resource contention. Every tenant's queries hit the same MySQL instance, same tables, same indexes. A heavy report on tenant A's data competes for buffer-pool space and CPU with tenant B's order entry. There is no per-tenant resource isolation.
  • Tenant filters in every WHERE clause. Every read query carries sBrandsId = ? AND sSubsidiaryId = ?. Indexes have to lead with these columns to be useful — and almost all xly tables do, by convention, but a maintainer adding a new index has to remember. Forgetting produces a query plan that scans across all tenants' rows and silently slows down once the table gets large.
  • No physical hard-delete boundary. A tenant offboarding does not drop a database; it leaves the rows where they are (sometimes marked bInvalid, sometimes deleted, sometimes untouched). Permanent removal requires a custom cleanup script per tenant. From a GDPR / data-residency angle, "this tenant is gone" is hard to prove.
  • sBrandsId / sSubsidiaryId everywhere is an inflexible tenancy unit. "Tenant" means exactly the (sBrandsId, sSubsidiaryId) tuple. Alternate cuts (e.g., per-region access, per-department access without sub-tenanting) don't fit the model and would require parallel scoping columns. The model assumed this shape would always be right for every customer; in practice, it has been, but it's a hard commitment.