FForm Platform
enzh-CN

部门多租户

在表单、引用关系和业务数据中一致执行租户根节点与部门子树边界。

部门多租户设计

FormPlatform 将部门边界作为外层安全边界,将表单 ACL 作为内层权限。ACL 授权不能扩大已认证用户的部门子树。内置 `Administrator` 是唯一默认绕过部门行过滤的角色。

数据结构和归属

  • `app_departments` 通过 `parent_id` 保存层级。
  • `app_users.department_id` 把系统用户分配到一个部门。Administrator 可以不分配;普通用户没有有效部门时,其业务数据范围为空。
  • `form_definitions.department_id` 表示表单定义归属。部门为空表示发布一份共享定义:任意部门中经过有效表单 ACL 授权的系统用户(包括无部门用户)都可获得对应的单项操作,但表单不会因此变成匿名表单。
  • `form_submissions` 从 `form_id` 继承边界,不重复保存部门列。因此,共享表单的通用提交集合也会对取得 `ManageSubmissions` 的主体形成一个共享集合;若提交记录必须按公司隔离,应使用有部门归属的表单,或使用带部门范围的 Data Model。

只有 Administrator 可以发布空部门表单;普通建立者始终由服务器写入当前部门。这样业务系统表单无需加入不断增长的共享白名单。明确划分的平台启动、登录、外壳、许可/schema 和安全管理集合只用于保留正常 ACL 计算前后确有必要的硬编码保护。匿名 Viewer 仍是由 `metadata.isAnonymous` 控制的另一种发布机制:空部门只向经 ACL 授权的系统用户共享定义,`isAnonymous` 才允许未认证读取。

迁移 `FormPlatform.Core/011_department_multitenancy` 建立部门层级和用户/表单归属列。`FormStorage` 可以使用独立数据库,因此 `form_definitions.department_id` 由领域服务验证,不依赖跨库外键。

表单有效权限

非 Administrator 系统用户按以下顺序计算权限:

1. 读取表单定义部门;空值表示共享定义,只跳过表单归属检查。

2. 部门非空时,解析用户的有效部门及全部有效下级部门 ID,并要求表单部门位于该集合中;明确划分的平台基础设施除外。

3. 应用匹配的 ACL deny 规则;deny 优先。

4. 应用 Read、Edit、Delete、ManageSubmissions 授权和支持的内置角色默认值。

`Read` 表示使用表单:渲染、提交以及执行可信表单结构声明的业务数据操作。`Edit` 表示在 Designer 中修改定义。`Delete` 删除定义。`ManageSubmissions` 管理通用 JSON 提交历史,并可授权 Survey 代填。设计权不隐含提交管理权。

Survey 管理、通知、Submission Center 和 Module Starter 等业务 System/Functional 表单不再统一检查 Administrator。有部门归属的定义同时要求部门范围与有效 ACL;空部门定义可跨部门共享,但仍必须通过有效 ACL。Form Center 的数据库快速路径与逐表单检查使用相同规则。

建立和修改表单

  • 普通建立者不能选择归属;服务器写入其当前有效部门。
  • Administrator 可选择任一有效部门,也可留空。留空表示显式发布共享表单定义;它不会绕过 ACL,也不会共享带部门范围的业务记录。
  • 只有 Administrator 可改变现有表单部门;普通用户更新时保留数据库中的值。
  • UI 是否显示按钮不是安全边界;直接 API 请求执行同样的检查。

客户端通过 `GET /api/forms/create-access` 取得建立状态;Administrator 的部门选项来自 `/api/admin/system-access/departments/options`。

Designer 将 `displayName`、`description`、`isActive`、`hidden` 和表单部门归属统一放在 **表单设置 / Action Code** 中编辑;技术名称 `name` 仍留在顶栏。即使普通用户伪造请求,服务器仍会保留原部门归属。`form_definitions.hidden` 只是发现/展示标志,不是授权边界:默认值为 false,非 Administrator 的 Form Center 查询会在分页前排除隐藏表单,Administrator 仍可看到。它不会授予或撤销 ACL 操作,不改变部门范围,不等同于停用表单,也不阻止已获授权主体通过直达地址访问。关系 FormStorage 会在自己实际配置的数据库中幂等建立或补齐该列;它有意不使用绑定到另一个 Management 数据源的 Core migration。

ORM 行范围自动约定

部门范围是服务器约定,不在表单或控件上配置:

  • 物理表为 `app_departments` 的 Data Model 按物理列 `id` 过滤。
  • 其他 Data Model 只要暴露物理列 `department_id`,就通过映射到该列的逻辑属性(通常为 `departmentId`)自动过滤。
  • 没有物理列 `department_id` 的 Data Model 是全局模型,不自动加部门条件。

这个判断只来自可信 Data Model metadata。`departmentScopeProperty` 不再是 DataGrid、ItemRenderer、AsyncSelect、Tree 或 `metadata.mapping` 的安全配置;已保存 JSON 中的旧字段会被忽略。

对有部门列的模型,服务器用 `AND` 合并条件:

已配置的业务 Filter
AND department_id IN(当前部门及有效下级部门)
AND 搜索/游标条件

同一约定覆盖 DataGrid、ItemRenderer、AsyncSelect、Tree、详情表单加载/列表、路由主键加载、新增、更新、复制和删除。已选择的 join 引用也会被限制;写入时还会验证已声明的 Data Model 引用,伪造 ID 不能建立跨公司引用。

对普通用户,有部门列但 `department_id IS NULL` 的记录不可见,不能按主键加载、更新、删除、复制或引用。系统不会加入 `OR department_id IS NULL`。Administrator 可查看并修复这些记录。普通用户通过 Data Model 表单或平台 API 新增时,客户端缺失、`null`、空字符串或仅含空白的部门值都会先规范化为未分配,再于范围校验前自动写入操作者当前部门;如果没有有效部门,写入失败。普通更新不能清空部门,也不能把它改到范围外。

`IFormEntityDataService` 原无范围签名为二进制兼容保留。内置实现发现目标模型有部门边界时,会拒绝这些旧调用。替换实现在服务普通用户前,必须实现接收可信 `departmentIds` 的重载。

不要把 `{user.departmentId}` 或客户端 Filter 当作安全机制。请求可被修改;允许的 ID 集合必须由服务器从认证主体解析。

系统通知投递

`system_notification` 在受访者页面的投递读取上采用一个明确而狭窄的发布规则;它不会改变上面的通用 ORM 规则:

  • `department_id IS NULL` 的记录是全局通知。位置、启用状态和时间窗口均匹配时,可显示给相应受访者页面上的所有访客或受访者。
  • 分配给部门 D 的记录只显示给有效部门为 D 或 D 的有效下级部门的活跃本地受访者。服务器从受访者数据库中的部门向上解析有效祖先链,不接收浏览器指定的通知范围。
  • 本地受访者登录前,登录页还没有可信部门身份。外部身份,以及部门缺失、停用、父链断裂、成环或深度异常的本地受访者,也没有可信内部范围;这些情况只接收全局通知。

这个例外只用于只读的受访者通知接口。通知管理仍执行普通 Data Model 部门边界:普通系统用户只能查询和变更其部门子树内的通知,不能管理空部门记录;新增时留空会写入该用户当前部门。只有 Administrator 可以有意建立或管理全局通知。

`FormPlatform.Core/013_core_business_tenant_audit` 是已经发布并受 checksum 锁定的历史迁移,必须保留它原来根据 `created_by` 一次性回填已有空通知部门的行为。不能为了新的发布语义修改该迁移或数据库中记录的 checksum。空数据库执行 013 时还没有通知记录,所以之后建立的全局通知仍会保持空部门。升级库中,被 013 推断出的部门值无法与有意定向到该部门的通知可靠区分,因此后续 migration 不会自动清空;只能由 Administrator 明确判断并清空确实应全局发布的记录。

部门字段自动补齐

数据库迁移及全部内置/扩展模块的表单初始化完成后,Host 会对 Data Model 表单执行幂等补齐。每个表单只有同时满足以下事实才会被修改:

1. 表单实际数据源中的物理表确实包含 `department_id`。

2. 可信 Data Model 通过逻辑属性暴露了该物理列,通常是 `departmentId`。

如果表单尚无绑定该逻辑属性的数据控件(也识别物理别名 `department_id`),补齐器会在根级提交按钮之前插入标签为 `Department ID` 的文本框;没有根级提交按钮时追加到根组件列表末尾。已有控件及 Designer 布局不会被替换。表单使用显式 `metadata.mapping.attributes` 时,同一轮还会补上直接属性映射,使字段参与加载和保存。该过程只校验自己负责的部门绑定;无关的旧 Mapping 不会阻止补齐,也不会被静默改写。以后从 Data Model 新生成的表单原本就会通过正常生成器包含该可写属性。

这个文本框只是管理/录入辅助,不是安全边界。普通用户新增时留空,服务器会写入其当前部门;填写或伪造子树外 ID 会被拒绝。如果物理 Schema 和 Data Model 不一致,表单保持不变,并在启动日志中提示 Administrator 先刷新 Data Model。没有物理 `department_id` 的模型、API 表单以及使用 `app_departments.id` 的部门层级特例都不会增加该文本框。

API 数据源

自动识别只适用于平台 ORM/Data Model 路径。`apiUrl`、模块端点或自定义应用服务必须使用 `IDepartmentScopeResolver`,并自行在每个查询和写入上执行边界:

var userId = http.User.FindFirstValue(ClaimTypes.NameIdentifier);
var scope = await departments.ResolveAsync(userId, ct);
// 把 scope.DepartmentIds 编译到 API 自己的查询;不要接收客户端上传的范围 ID。

业务表单 API 还必须使用 `IFormAccessAuthorizer` 检查拥有该操作的表单:

var allowed = await formAuthorization.AuthorizeAsync(
    owningFormId,
    http.User,
    FormAccessOperation.Read,
    ct);
if (!allowed) return Results.Forbid();

表单 ACL 和行范围缺一不可。ACL 不能代替数据过滤,数据过滤也不能授予表单使用权。

Chat、Commerce 与 ResourceBooking 模块

这些第一方/扩展模块的 `apiUrl` 路由自行执行与通用 ORM 相同的约定:

  • Chat 聊天室必须具有 `department_id`;管理功能要求隐藏

`System_Chat_Management` 表单的 Read,并受管理者有效部门子树限制。成员引用只能

位于聊天室部门子树,参与者成员记录的部门必须与用户当前部门一致。改变聊天室部门

会清空全部成员。

  • Commerce 只对商品管理查询及商品新增、修改、删除应用部门范围,要求

`Shop_Products_Admin` 的 Read 和操作者部门子树。匿名 Catalog 是明确设计的聚合商城:

它列出所有租户中已启用且已有部门归属的商品,Catalog 表单自身部门不参与过滤。

购物车与结账使用相同的公共商品规则;订单与支付保持原有平台级边界。

  • ResourceBooking 同时限制资源和预约。自定义 API 要求相关 Portal、Request 或

Administration 表单 Read。预约从所选资源继承部门;资源改部门时在同一事务同步

其预约部门。

三个模块都不会共享部门为空的旧业务行。普通用户不能列出、按 ID 操作或修复这些行,

Administrator 可明确分配有效部门。模块只追加新的不可变 migration,不修改已发布 SQL。

离线问卷 API

`FormPlatform.Offline` 不接收浏览器指定的部门或响应身份。受访者包会把准确的 deployment/版本、form、受访者身份类型、部门、指纹、响应 ID/版本和设备保存在服务器授权中。代填包还会保存 `authorization_mode = assisted`、当前认证系统用户的 `operator_id` 及明确选择且已分配的 `respondent_id`。同步只从授权行解析这些事实,并分别要求同一受访者身份或同一系统操作员;Host 再次要求 deployment、list、respondent、成员关系与操作员当前范围一致。授权记录的 `department_id` 是审计边界,不是客户端可选权限,也不会形成 `OR department_id IS NULL` 的共享路径。

离线 Data Model AsyncSelect/Tree 快照调用与对应在线模式相同的部门感知查询重载,因此不能扩大数据行范围。受访者模式沿用受访者约定;代填模式提供操作员当前允许部门 ID。包中只保存该完整授权结果;服务器清单绑定条数、SHA-256 版本、上限和过期时间。同步时在当前范围内重新执行查询,选项集变化或提交键不属于集合都会被拒绝。没有物理 `department_id` 的模型仍与在线时一样属于全局模型;有部门边界的模型不会因为下载而变成全局共享。

`/offline` 与 `/offline/assisted` 应用外壳必须避免在线登录检查,才能在断网后打开加密包。这不表示问卷数据公开:受访者 API 要求隔离的 Respondent 策略;代填 API 要求系统登录以及当前工作区/表单 ACL 和部门范围;IndexedDB payload 在锁定时保持加密;Service Worker 不缓存 Survey API 响应。

部门树规则

建议层级为:平台根节点、其直接下级公司根节点、各公司下的部门。代码不硬编码层数,但服务器管理会阻止:

  • 把平台根节点挂到其他部门下;
  • 把公司根节点挂到另一公司下或移到另一平台根;
  • 把现有部门移出所属公司或平台根边界;
  • 循环和自己作为父节点。

停用部门不会进入普通用户的范围。有下级、已分配用户或已归属表单的部门不能删除。导入和身份同步应保持部门代码稳定,但授权始终使用 ID。

UI 不提供范围外引用入口只是交互优化。服务器写入验证仍是必需的,因为直接 HTTP 请求、导入、触发器和未来客户端都可以不经过该 UI 提交 ID。