multi-tenancy.md 3.41 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 (版本流程ID) gdsmodule only per-module the user's edition (against sisversionflow)

Per-row scoping is widespread in the live DB: sBrandsId appears on 1,009 tables/views and sSubsidiaryId appears on 1,008 tables/views. That is almost every business-data table and every framework-metadata table.

Per-module gating (sVersionFlowId) appears on only 3 tables — and only gdsmodule is live; the other two are backups. So edition gating is a one-time filter at module-discovery time, 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.

Why this live DB looks small

xlyweberp_saas_ai has one brand (sBrandsId = '1111111111'), one subsidiary, and one populated edition (8S_001 / 基础版). The multi-tenancy machinery is wired but only lightly exercised here. Production tenants have dozens of brands, dozens of subsidiaries each, and several editions — the design scales by row-count, not by code.