Payload Multi-Tenant 插件指南:在管理后台内实现按租户的数据隔离与管理
【免费下载链接】payloadPayload is the open-source, fullstack Next.js framework, giving you instant backend superpowers. Get a full TypeScript backend and admin panel instantly. Use Payload as a headless CMS or for building powerful applications.项目地址: https://gitcode.com/GitHub_Trending/pa/payload
多租户(Multi-Tenancy)是 SaaS 类应用最常见的需求之一。本指南以当前仓库中@payloadcms/plugin-multi-tenant(源码位于 packages/plugin-multi-tenant)为讲解对象,说明如何在不写大量重复代码的前提下,通过插件向 Payload 的集合注入租户字段、租户选择器、列表过滤与访问控制,实现"一套 Payload 实例、多租户数据天然隔离"。读完本文你将掌握:插件全部配置项与默认值、如何把集合当 Global 使用、如何自定义访问控制以支持跨租户共享文档、如何手动接管 users 集合上的租户数组字段,以及插件底层(集合改造、base access 包装、Cookie 上下文)是如何工作的。
一、插件要解决什么问题
该插件解决的核心问题是在 Payload 中快速搭建多租户能力。其底层实现(入口文件 src/index.ts)是一个标准的definePlugin,在 Payload 构建配置时做"集合改造",主要完成:
- 向所有开启多租户的集合注入一个指向
tenants集合的tenant关联字段(默认字段名为tenant); - 在 Admin UI 中注入一个租户选择器(Tenant Selector),让管理员可以"切换当前上下文租户";
- 依据当前选中的租户自动过滤列表视图结果,并对关系字段的候选值(
filterOptions)做同样约束; - 在 users 集合上注入一个
tenants数组字段,记录用户可访问的租户,并把约束写入访问控制; - 支持把某些集合"当作 Global 使用"(每租户一份文档,隐藏列表视图);
- 新文档自动带上当前选中租户作为默认值;
- 在删除租户时,默认清理关联文档并移除用户对该租户的引用。
这些能力都可以从 源码结构 中一一得到印证,例如filters/filterDocumentsByTenants.ts、utilities/addCollectionAccess.ts、components/TenantSelector/、hooks/afterTenantDelete.ts等模块各司其职。
二、安装与运行前提
安装插件(package.json 中定义包名为@payloadcms/plugin-multi-tenant):
pnpm add @payloadcms/plugin-multi-tenant安装完成后还需满足两个硬性前提:
- 你必须自己创建
tenants集合,并自行决定它包含哪些字段(例如name、slug、domain)。插件不会替你创建它。若在你的配置里找不到对应 slug 的集合,插件会直接抛出Tenants collection not found with slug: ...错误(见 src/index.ts)。 - 配置中必须存在一个开启 auth 的集合(通常是
users)。插件会用它作为管理员用户集合,并注入租户数组字段;如果找不到,会抛出An auth enabled collection was not found(见 src/index.ts)。该插件把payload与@payloadcms/ui声明为 peer 依赖,需要与你的 Payload 版本配套使用。
三、插件配置项全解
multiTenantPlugin的配置结构定义在 src/types.ts,其中每个字段的默认值集中在 src/defaults.ts。下面按类别逐一说明。
3.1 顶层开关类
| 配置项 | 类型 | 默认值 | 作用 |
|---|---|---|---|
enabled | boolean | true | 设为false直接禁用插件(src/index.ts)。 |
debug | boolean | false | 开启调试模式,让tenant字段在 Admin UI 中可见(正常模式下它会被隐藏,改由独立的 Assign Tenant 操作维护)。 |
tenantsSlug | string | 'tenants' | 指定 tenants 集合的 slug,用于插件定位它。 |
cleanupAfterTenantDelete | boolean | true | 删除租户后是否清理其关联文档、并把该租户从所有用户的tenants数组中移除。 |
3.2 collections:按集合粒度开启多租户
collections是必传的核心选项,键为集合 slug:
collections: { pages: {}, navigation: { isGlobal: true }, media: { useTenantAccess: false }, }每个集合可用的子选项如下:
| 子选项 | 默认值 | 作用 |
|---|---|---|
isGlobal | false | 设为true后集合按 Global 方式工作,隐藏列表视图,并且每个租户只允许一条文档(插件会为其 tenant 字段加unique约束并禁止复制,见 src/index.ts)。 |
useBaseFilter | true | 设为false表示你不希望插件自动叠加"按当前选中租户过滤列表"的 baseFilter,改为手动实现。 |
useBaseListFilter | true(已废弃) | 旧版选项,功能被useBaseFilter取代;源码中当两者同时出现时useBaseFilter优先(src/types.ts)。 |
useTenantAccess | true | 设为false表示该集合访问控制完全由你手动接管,不再叠加插件生成的租户约束。适用于需要"部分文档跨租户共享"的场景(见第六节)。 |
customTenantField | false | 设为true后插件不为该集合注入tenant字段,你需要用插件导出的tenantField自行放置。 |
tenantFieldOverrides | - | 该集合独有的 tenant 字段覆盖配置,优先于顶层tenantField(见 src/index.ts)。 |
accessResultOverride | - | 覆写该集合访问控制结果。回调接收accessResult(原始访问结果)、accessKey(create/read/update/delete/readVersions/unlock)以及其余 Access 参数,返回新的AccessResult(src/types.ts)。 |
3.3 tenantField:注入业务集合的租户字段
控制添加到所有开启多租户集合上的那个关联字段(默认名tenant)。可用配置:
| 子选项 | 默认值 | 作用 |
|---|---|---|
name | 'tenant' | 字段名称。 |
access | {} | 字段级访问控制,类型为RelationshipField['access']。 |
从字段工厂 src/fields/tenantField/index.ts 可以看到该字段的真实形态:一个指向tenantsSlug的relationship字段(hasMany默认false),放置于 Admin 侧边栏(position: 'sidebar'),关闭了创建/编辑与列/筛选/分组显示(allowCreate/allowEdit为 false,column/filter/groupBy禁用),并默认做了两件事:
- 默认值自动填充:优先读取当前请求携带的
payload-tenantCookie 对应的租户(校验其有效性后作为默认值);若集合开启了 autosave,则回退到用户tenants数组中的第一个租户(src/fields/tenantField/index.ts)。 - filterOptions 约束:该字段在 UI 中只能从"当前用户已分配的租户"里选择(src/fields/tenantField/index.ts)。
3.4 tenantsArrayField:注入 users 集合的租户数组字段
控制添加到用户集合上的tenants数组字段(记录"这个用户能访问哪些租户")。配置为联合类型:
tenantsArrayField?: { arrayFieldAccess?: ArrayField['access'] // 数组字段本身的访问控制 arrayFieldName?: string // 数组字段名,默认 'tenants' arrayTenantFieldName?: string // 数组内租户关系字段名,默认 'tenant' includeDefaultField?: true // 默认行为:自动注入到 users 集合 rowFields?: Field[] // 在每行追加的自定义字段 tenantFieldAccess?: RelationshipField['access'] // 行内租户关系字段的访问控制 } // 或 includeDefaultField: false 时,其余选项不可用(见第五节的联合类型定义)字段工厂 src/fields/tenantsArrayField/index.ts 会生成一个名为tenants的array,每行包含一个指向 tenants 集合的必填 relationship(required: true、index: true),并且数组与行内字段都设置了saveToJWT: true,保证租户 ID 会进入 JWT,便于服务端在各处校验。注意联合类型约束:当includeDefaultField为false时,arrayFieldAccess、rowFields、tenantFieldAccess均不允许再配置(类型上为never),因为你将手动接管整个字段。
3.5 全局类选项
| 配置项 | 类型 | 默认值 | 作用 |
|---|---|---|---|
userHasAccessToAllTenants | (user) => boolean | 恒返回false | 判定某用户是否为可访问所有租户的超级管理员。若返回true,插件会跳过大部分租户约束。 |
usersAccessResultOverride | CollectionAccessResultOverride | - | 覆写 users 集合访问控制结果,签名同集合级accessResultOverride。 |
useUsersTenantFilter | boolean | true | 是否在 users 集合上叠加"按当前选中租户过滤列表"的 baseFilter。 |
useTenantsCollectionAccess | boolean | true | 是否给 tenants 集合本身加上"只能访问被分配租户"的访问约束。 |
useTenantsListFilter | boolean | true | 是否给 tenants 集合叠加"按当前选中租户过滤列表"的 baseFilter。 |
tenantSelectorLabel | string或Record<语言代码, string> | 'Tenant' | 自定义租户选择器文案,支持 i18n 多语言对象;在 types.ts 中被标记为已废弃,推荐改用i18n。 |
i18n | { translations: { [locale]: {...} } } | - | 覆盖插件界面文案,可用 key 包括assign-tenant-button-label(默认'Assign Tenant')、assign-tenant-modal-title(默认'Assign "{{title}}"')、field-assignedTenant-label(默认'Assigned Tenant')、nav-tenantSelector-label(默认'Filter by Tenant')。 |
需要指出的是,collections的键不能包含 users 集合——插件会打印警告并跳过对该集合的 tenant 字段注入,以避免双重访问控制(src/index.ts)。
四、基础用法:配置 payload.config.ts
在 配置文档 docs/plugins/multi-tenant.mdx 中给出了完整示例。核心步骤是:先定义好tenants集合(字段完全归你所有),再在plugins数组中调用multiTenantPlugin并指定哪些集合开启多租户:
import { buildConfig } from 'payload' import { multiTenantPlugin } from '@payloadcms/plugin-multi-tenant' import type { Config } from './payload-types' const config = buildConfig({ collections: [ { slug: 'tenants', admin: { useAsTitle: 'name', // 租户选择器依赖该字段展示名称 }, fields: [ // tenants 集合的字段由你定义,以下只是建议示例 { name: 'name', type: 'text', required: true }, { name: 'slug', type: 'text', required: true }, { name: 'domain', type: 'text', required: true }, ], }, ], plugins: [ multiTenantPlugin<Config>({ collections: { pages: {}, navigation: { isGlobal: true, }, }, }), ], }) export default config插件允许传入 Payload 生成的类型(<Config>),这样userHasAccessToAllTenants等回调可以获得精确的user类型。插件对集合的改造包括:在pages中注入tenant字段;把navigation处理成"每租户一份"的 Global 式集合;向users注入租户数组字段;在 Admin 的providers、actions、beforeNav中分别挂入TenantSelectionProvider、Global 重定向组件与租户选择器(见 src/index.ts)。
五、插件默认的数据模型与上下文机制
为理解插件的各种开关,先掌握它默认在数据层与运行时做了什么:
users 上的关联结构。插件默认向 users 注入:
tenants: Array<{ tenant: Relationship -> tenants 集合(required, saveToJWT) // + 你通过 rowFields 追加的字段 }>业务集合上的 tenant 字段。每个开启多租户的集合拥有一个tenantrelationship(指向 tenants),全局/Global 式集合该字段是unique。
运行时"当前租户"上下文。租户选择器选中的值会写入名为payload-tenant的 Cookie(Cookie 名可在 src/hooks/afterTenantDelete.ts 的清理逻辑中看到,那里会用generateCookie以相同名称清除过期 Cookie)。列表过滤(src/filters/filterDocumentsByTenants.ts)的执行顺序是:优先使用"当前文档的租户/显式传入的docTenantID",其次读取payload-tenantCookie,再次回退到用户被分配的租户集合,最后返回null交由访问控制兜底。
关系字段候选值也被过滤。插件会递归扫描开启多租户集合上的 relationship 字段(含 blocks 内引用)并注入filterOptions,避免出现"可以选择其他租户的文档"这种越权场景,相关逻辑见 src/utilities/addFilterOptionsToFields.ts。
六、把集合配置成"Global"(每租户一份内容)
多租户场景下,很多"全站性"内容(导航、站点设置、页头页脚)也需要按租户区分,但 Global 本身无法按租户分数据。官方给出的方案是:Global 需要用集合实现,再在插件里把它标记为isGlobal。这样插件会隐藏它的列表视图、为 tenant 字段加unique约束(同一租户只能存在一份),并自动处理文档切换/重定向逻辑:
multiTenantPlugin({ collections: { navigation: { isGlobal: true, }, }, })对应实现中:isGlobal的集合会进入globalCollectionSlugs分组,被设置disableDuplicate = true,并在存在这类集合时为 Admin 注入GlobalViewRedirectaction 组件(见 src/index.ts)。
七、访问控制原理与自定义
7.1 默认行为:AND 合并租户约束
插件默认把你自己写的访问控制结果,与"文档 tenant 必须 ∈ 当前用户可访问租户"的约束做AND合并,施加在baseAccess层(对 read/update/delete 等均生效,见 src/utilities/addCollectionAccess.ts)。也就是说,用户自己声明的 access 条件与租户约束必须同时满足;若用户未分配任何租户,则只能看到自己的用户记录或什么都看不到(src/utilities/addCollectionAccess.ts)。约束查询体由 getTenantAccess 生成,形如:
{ [fieldName]: { in: userAssignedTenantIDs } }7.2 自定义:手动接入租户约束以支持共享文档
AND 语义并不适合"某些文档在所有租户间共享"。此时官方推荐:为该集合关闭useTenantAccess,在自定义read访问控制中,把"共享条件"与租户约束用OR组合起来。README(packages/plugin-multi-tenant/README.md)给出了完整可运行示例,关键思路是复用插件导出的getTenantAccess来构造租户约束,而不是自己手写:
// File: payload.config.ts import { buildConfig } from 'payload' import { multiTenantPlugin } from '@payloadcms/plugin-multi-tenant' import { getTenantAccess } from '@payloadcms/plugin-multi-tenant/utilities' import { Config as ConfigTypes } from './payload-types' export default buildConfig({ plugins: [ multiTenantPlugin<ConfigTypes>({ collections: { media: { useTenantAccess: false, // 关闭默认租户访问约束,改为手动控制 }, }, }), ], collections: [ { slug: 'media', fields: [ { name: 'isShared', type: 'checkbox', defaultValue: false, // 建议对这个"是否共享"字段设置访问控制,防止普通用户随意修改 }, ], access: { read: ({ req, doc }) => { if (!req.user) return false const whereConstraint = { or: [ { isShared: { equals: true } }, // 共享文档对所有登录用户可见 ], } const tenantAccessResult = getTenantAccess({ user: req.user }) if (tenantAccessResult) { whereConstraint.or.push(tenantAccessResult) } return whereConstraint }, }, }, ], })这里getTenantAccess返回{ tenant: { in: [...] } }形式的 Where 约束,把它 push 进or数组,即得到"共享文档或归属于我所在租户的文档"这一符合业务直觉的可见性规则。若使用自定义 access 后仍希望租户约束生效,可用集合级accessResultOverride/usersAccessResultOverride在插件结果之上再做一层覆写。
八、手动放置 users 集合上的租户数组字段
默认情况下插件会把tenants数组字段追加到 users 集合字段的末尾。若你想把它放进 Tab、侧边栏或行/折叠区,或修改其部分属性,可设置tenantsArrayField.includeDefaultField: false,然后手动把插件导出的数组字段合并进自己的字段定义:
import type { CollectionConfig } from 'payload' import { tenantsArrayField } from '@payloadcms/plugin-multi-tenant/fields' const customTenantsArrayField = tenantsArrayField({ arrayFieldAccess: {}, // 数组字段的访问控制 tenantFieldAccess: {}, // 行内租户关系字段的访问控制 rowFields: [], // 每行追加的自定义字段 }) export const UsersCollection: CollectionConfig = { slug: 'users', fields: [ { ...customTenantsArrayField, label: 'Associated Tenants', }, ], }README 特别强调了这个字段的摆放限制:该字段不能嵌套在具名字段(group、命名 Tab、array)内部,但可以放进 row、未命名 Tab 或 collapsible 中(packages/plugin-multi-tenant/README.md)。导出的tenantsArrayField工厂位于 src/fields/tenantsArrayField/index.ts,同目录下还导出了用于业务集合的tenantField(模块导出清单见 src/exports/fields.ts)。同样地,若你希望某个业务集合自行摆放 tenant 字段,可设置customTenantField: true后用导出的tenantField放置。
九、租户删除时的清理行为与安全提示
插件会在 tenants 集合上注册afterDelete钩子(src/hooks/afterTenantDelete.ts),当租户被删除时默认执行两件事:
- 删除关联文档:对每个开启多租户的集合执行批量
delete(where: { tenant: { in: [deletedId] } }); - 清理用户引用:查找
tenants.tenant包含该租户的用户,从其数组中移除对应行;若被删租户恰好是当前 Cookie 中选中的租户,还会下发一条过期的payload-tenantSet-Cookie,把上下文清除。
正因删除副作用较大,配置文档 特别提醒:必须为 tenants 集合配置足够严格的访问控制,防止未授权用户删除租户。若你希望完全自己掌控删除流程,可将顶层cleanupAfterTenantDelete设为false关闭该行为(src/index.ts)。
十、前端如何按租户消费数据
插件只负责管理与隔离数据,前端查询仍需你自己完成。由于tenant就是普通关系字段,可以直接在查询条件里按 tenants 集合上的业务字段过滤(例如按slug或domain匹配当前请求的租户):
const pagesBySlug = await payload.find({ collection: 'pages', depth: 1, draft: false, limit: 1000, overrideAccess: false, where: { 'tenant.slug': { equals: 'gold' }, // 具体约束取决于你在 tenants 集合上定义的字段 }, })若采用/[tenantDomain]/[slug]的路由结构 + Next.js rewrites,可按域名改写请求路径,把域名解析为租户标识后用于上面的查询:
async rewrites() { return [ { source: '/((?!admin|api)):path*', destination: '/:tenantDomain/:path*', has: [{ type: 'host', value: '(?<tenantDomain>.*)' }], }, ] }编写自定义 Admin 组件时使用 useTenantSelection
插件从@payloadcms/plugin-multi-tenant/client导出了若干客户端模块(见 src/exports/client.ts):TenantField、AssignTenantFieldTrigger、WatchTenantCollection与useTenantSelection。在 官方配置文档 中记录了useTenantSelection的用法与返回上下文:
import { useTenantSelection } from '@payloadcms/plugin-multi-tenant/client' const tenantContext = useTenantSelection() // { // options, // 可选的租户选项数组 // selectedTenantID, // 当前选中的租户 ID // setPreventRefreshOnChange, // 切换租户时是否阻止页面刷新(浏览 Global 时建议 true) // setTenant: ({ id, refresh }) => void, // 切换租户,可选刷新 // }同目录的providers/TenantSelectionProvider/提供实现,它由插件在 Admin 的providers中自动注入;TenantSelector、AssignTenantFieldModal等界面组件也依赖同一套上下文。
十一、插件内部工作流与常见问题排查
把 src/index.ts 从头读一遍,可看到插件执行的关键流水线,这对排查问题很有帮助:
- 处理
enabled === false的提前退出,合并所有默认值(defaults.ts:tenantsslug、tenant字段名、tenants数组字段名、选择器默认文案); - 定位 auth 集合(
admin.user优先,其次遍历找带auth的集合),注入tenants数组字段; - 为 users 集合叠加按选中租户过滤的 baseFilter(
useUsersTenantFilter !== false时); - 遍历所有集合:找到 tenants 集合后叠加
useTenantsCollectionAccess访问约束、useTenantsListFilter列表过滤、租户删除清理钩子、用于监听租户变更的_watchTenantUI 字段与getTenantOptions端点;对其他声明过的集合注入tenant字段、为关系字段注入 filterOptions、叠加 baseFilter 与租户访问约束; - 校验"声明的集合都存在于配置中"——若有集合在
pluginConfig.collections里声明却未找到,会打印黄色警告missing collections ... try placing the multi-tenant plugin after other plugins(src/index.ts)。这意味着插件顺序很重要:如果其他插件也会新增/重命名集合,请把本插件放在它们之后; - 把收集到的所有租户访问约束统一写入
config.baseAccess(addCollectionAccess.ts); - 挂载 Admin 组件与 45 种语言的翻译文案(见 src/translations 目录)。
模块导出速查(定义在 package.json 的exports中):
| 导入路径 | 内容 |
|---|---|
@payloadcms/plugin-multi-tenant | multiTenantPlugin主入口 |
@payloadcms/plugin-multi-tenant/fields | tenantField、tenantsArrayField字段工厂 |
@payloadcms/plugin-multi-tenant/utilities | defaults、getTenantAccess、getTenantListFilter(即filterDocumentsByTenants)、getTenantFromCookie、getUserTenantIDs |
@payloadcms/plugin-multi-tenant/client | TenantField、AssignTenantFieldTrigger、WatchTenantCollection、useTenantSelection |
@payloadcms/plugin-multi-tenant/rsc | TenantSelectionProvider、TenantSelector、GlobalViewRedirect等服务端组件 |
@payloadcms/plugin-multi-tenant/types | MultiTenantPluginConfig等类型 |
@payloadcms/plugin-multi-tenant/translations/languages/* | 单语言翻译文件 |
综上,@payloadcms/plugin-multi-tenant的核心价值是把"多租户"这一横切关注点(字段、上下文、过滤、访问控制、清理)收敛为一份声明式配置:collections声明哪些集合受管、isGlobal声明哪些集合按每租户单文档工作,其余注入与约束由插件自动完成。当你需要突破默认行为时,includeDefaultField: false、customTenantField: true、useTenantAccess: false配合./fields与./utilities导出的构件,则提供了从"插件默认"平滑过渡到"完全自定义"的完整梯度。建议进一步阅读 插件主 README、配置文档 docs/plugins/multi-tenant.mdx 以及 测试目录 test/plugin-multi-tenant 中的用例,以获得更多可运行的集成参考。
【免费下载链接】payloadPayload is the open-source, fullstack Next.js framework, giving you instant backend superpowers. Get a full TypeScript backend and admin panel instantly. Use Payload as a headless CMS or for building powerful applications.项目地址: https://gitcode.com/GitHub_Trending/pa/payload
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考