身份、角色与表单访问
管理系统身份、受访者身份和明确表单访问规则,不把授权当作客户端功能。
表单访问控制开发指南
本文说明系统用户访问普通表单、System Form、Functional Form 和 Survey Form 时的服务器契约。Respondent 访问已部署问卷仍属于独立身份与部署链路。
English edition: FORM_ACCESS_CONTROL_DEVELOPER_GUIDE.md.
安全不变量
1. 服务器是最终权限来源。菜单条件、隐藏按钮、前端路由守卫或客户端 Filter 都不是授权。
2. 对有部门归属的定义和带部门范围的记录,部门范围是外层边界,表单 ACL 是内层权限边界。表单定义部门为空表示 Administrator 发布的共享定义,不表示业务空部门记录被共享。
3. 显式 `deny` 高于普通 grant 和内置默认值。`isAnonymous` 提供的公开读取按设计保持公开,但不会跨部门附带编辑、删除或提交管理能力。`Administrator` 是不可锁死的恢复主体。
4. Designer、删除表单、管理提交记录和使用业务数据是不同操作。
5. 只有启动/布局、登录、许可和安全管理基础设施可以保留硬编码 Administrator 限制。
ACL 操作
规则保存在 `app_form_access_rules`,主体是系统用户 ID 或角色 ID。
| 权限 | 服务器语义 |
|---|---|
| `deny` | 对该主体拒绝全部普通能力。 |
| `read` | 使用表单:呈现、提交,以及执行表单可信结构声明的业务数据命令。 |
| `edit` | 读取和修改表单定义、metadata 与 Designer 状态;不授予提交记录管理。 |
| `delete` | 通过部门和领域完整性检查后删除表单定义。 |
| `submissions` | 查看、更新、删除通用 JSON 提交;对 Survey Form 还允许授权代填。 |
| `create` | 在全局建立作用域中建立表单,不属于某一张表单。 |
全局建立作用域使用 `form_id = 00000000-0000-0000-0000-000000000000`。兼容初始化默认向 FormDesigner 授予 `create`,直到 Administrator 明确修改配置。
内置默认值仍参与有效 ACL:
- Administrator 可读取、设计、建立、管理提交和代填,并可删除受保护基础设施之外的表单。
- FormDesigner 可设计非基础设施表单并默认读取 Survey Form,但不自动获得提交管理或删除定义权限。
- SurveyAssistant 默认可进入 Survey Responses 工作区并代填。也可只给其它角色或用户显式授予目标 Survey Form 的 `submissions`,无需加入 SurveyAssistant。
部门与表单分类
有部门归属的表单先要求当前用户的有效部门子树包含 `form_definitions.department_id`,再计算 grant。`form_definitions.department_id` 为空则表示 Administrator 发布的共享定义:任意系统用户部门都通过归属检查,但 deny/grant、内置默认值、受保护表单规则、认证要求和 `isAnonymous` 语义均不改变。该例外只属于表单定义;带范围业务表中的空 `department_id` 对普通用户仍不可访问。
`form_definitions.hidden` 明确不参与权限计算。隐藏表单会在游标分页前从所有非 Administrator 的 Form Center 页面中排除,Administrator 仍可发现它;表单直达路由和 API 继续执行相同的 Read/Edit/Delete/ManageSubmissions ACL 与部门范围。`hidden` 用于减少目录内容,`is_active` 用于控制运行可用性,ACL/部门归属用于实施安全,三者不能互相替代。
窄化后的基础设施例外只包含平台/系统用户/Respondent 外壳(含 Form Center 和 Respondent Dashboard)、系统用户或 Respondent 登录、许可/schema,以及用户、角色、部门和 ACL 管理所必需的表单。非管理员不能读取安全管理表单,也不能设计或删除受保护基础设施。Form Center 通过独立的建立表单能力进入。
问卷管理、问卷通知、Submission Center 和 Module Starter 等业务系统表单不再统一写死 Administrator,而是按 ACL 加匹配部门归属或共享定义规则计算,因此不需要维护业务共享表单白名单。
有效权限计算顺序
`FormAccessControlService` 是单请求与批量能力投影的统一权限来源:
1. 保留 Administrator 恢复规则,但受保护基础设施仍不可删除。
2. 非管理员访问 Administrator-only 安全表单时立即拒绝。
3. 验证认证身份与对应部门规则。
4. 应用显式 deny。
5. 应用显式 grant 和内置默认值。
6. 应用领域规则,例如受保护基础设施或已有部署的问卷不能删除。
`CanReadAsync`、`CanEditAsync`、`CanDeleteAsync`、`CanManageSubmissionsAsync`、`CanAssistSurveyAsync` 和 `EvaluateAsync` 必须保持等价语义。列表或菜单不能显示一种能力,而目标端点又因为额外角色名单拒绝同一用户。
Host 与模块端点鉴权
内置端点使用 `FormAccessControlService`。可信模块应依赖 SDK 接口,不能复制角色判断:
using FormPlatform.DataAccess.Forms;
group.MapPost("/command", async (
HttpContext http,
IFormAccessAuthorizer authorization,
CancellationToken ct) =>
{
var allowed = await authorization.AuthorizeAsync(
OwningSystemFormId,
http.User,
FormAccessOperation.Read,
ct);
return allowed ? Results.Ok() : Results.Forbid();
}).RequireAuthorization();
按操作选择能力,不能按 URL 名称猜测:
- 呈现、普通提交、表单结构声明的业务数据命令使用 `Read`;
- Designer / schema 变更使用 `Edit`;
- 删除定义使用 `Delete`;
- 历史通用提交管理和问卷授权代填使用 `ManageSubmissions`。
复制完整表单定义同时要求全局 `create` 与源表单 `Edit`,因为复制内容包含 metadata、Action Code 和后端 Mapping,而不只是 Viewer 可见 Schema。
`/api/admin/...` 只是命名约定,并不代表必须套用 Administrator policy。只有 Data Model / schema migration、系统身份与角色、ACL 配置、许可状态、全局媒体管理等真正的全局基础设施才保留 `RequireAuthorization("Administration")`。
业务数据部门范围
表单授权不能代替行级授权。API 模式控件和模块端点必须注入 `IDepartmentScopeResolver`,按认证系统用户 ID 解析范围,并把 `scope.DepartmentIds` 编译为服务器控制的 `IN` 条件;绝不能接收浏览器上传的允许部门集合。
平台 ORM 范围由服务器自动判定,表单结构不能重定向。`app_departments` Data Model 按物理列 `id` 限制;其他模型只要暴露物理列 `department_id`,就按映射到它的逻辑属性限制。没有 `department_id` 的模型为全局模型。不要在控件或 `metadata.mapping` 中增加 `departmentScopeProperty`;已保存的旧值会被忽略。
该约定保护 DataGrid、ItemRenderer、AsyncSelect、Tree、实体列表、完整主键/路由 ID 加载、新增、更新、复制和删除,并对已选 join 和写入引用执行同样边界。对普通用户,`department_id IS NULL` 不可访问而不是共享。新增时会用操作者当前部门填充缺失/空值;无有效部门时失败关闭,更新也不能清空或转移到子树外。
启动时,`DepartmentIdFormFieldInitializer` 会在模块 migration 和表单 initializer 完成后检查每个 Data Model 表单的实际数据源。物理表与可信模型都暴露 `department_id` 时,它会幂等补充缺失的 `Department ID` 文本框;使用显式 Mapping 的表单同时补直接属性映射。它只校验自己负责的新增绑定,因此无关的陈旧 Mapping 不会阻止修复,也不会被自动改写。已有数据控件不会被替换,也不能把这个可见字段当作授权机制。物理 Schema 与模型不一致时只记录日志并保持表单不变,应刷新 Data Model,而不能伪造仅客户端可见的映射。
`IFormEntityDataService` 为二进制兼容保留原签名,并新增接收可信部门 ID 的范围重载。内置实现解析到有部门边界的 Data Model 时会拒绝旧无范围调用;替换实现必须实现范围重载。
业务行复制/删除和问卷管理命令属于“使用表单”,因此要求 `Read`,而不是 `Edit`。保存的 grid/filter/mapping 与部门条件共同决定已授权表单用户能操作哪些记录;删除表单定义仍要求 `Delete`。
问卷管理
问卷管理包含两层相关权限:
- deployments、lists、respondents、responses、push、notifications 等管理表单要求所属系统表单的有效 `Read`。
- 建立或修改 deployment 时还要求对所选目标 Survey Form 具有 `Read`;伪造 Form ID 不能把 deployment 管理权限扩大为其它表单的访问权。共享 Survey 定义可由任一允许部门的 deployment 引用;有部门归属的 Survey 定义必须与 deployment 部门一致。
- 代填还要求 Survey Responses 工作区权限,以及目标 Survey Form 的 `submissions` grant 或 SurveyAssistant 内置默认值;即使目标定义共享,deployment/list/respondent 行级范围仍独立生效。
只恢复 `survey_deployment` 行而没有恢复它引用的目标 Survey Form,不会形成可用部署。deployment-only 代填路由会先解析目标表单;定义缺失时返回 `409 survey.deploymentFormMissing` 及 deployment/form ID,而不是无信息的 404。该情况保持失败关闭:受访者集合、问卷内容和代填操作都不会因为表单丢失而绕过目标表单 ACL。恢复程序必须先安装原始稳定表单定义,不能自动改绑到另一个问卷。
问卷管理查询和变更会对 `survey_deployment`、`survey_list`、`survey_respondent`、通知、选择项、成员关系和 push 收件人路径加入服务器解析的部门子树。伪造主键不能选择范围外记录;Administrator 省略该条件。
受访者页面的通知投递有意区别于管理操作。`system_notification.department_id` 为空时,符合启用状态、位置和时间窗口的通知全局发布;非空时,发布给该部门及其有效下级部门中的活跃本地受访者。匿名/登录前请求、外部身份,以及没有完整有效部门链的本地受访者只接收全局通知。服务器从已保存的受访者部门解析祖先链,绝不信任浏览器提交的部门。
通知端点还要供登录前页面使用,因此保持 `AllowAnonymous`。应用默认认证方案是管理 Cookie;这种可匿名端点在解析 Dashboard 受访者之前必须显式调用 `AuthenticateAsync(SurveyAuthentication.Scheme)`,不能把默认 `HttpContext.User` 当作受访者身份。Respondent Cookie 缺失或无效时只能进入全局通知路径。
Respondent 仍使用独立认证方案,其 deployment、清单成员、时间窗口、响应归属和重复提交规则不能用系统用户 ACL 替换。
代填时,Action Code 通过 `context.systemUser` 获取经授权的操作主体,通过 `context.respondent` / `context.actingRespondent` 获取目标身份。`context.administrator` 仅作为旧表单兼容别名保留;它存在不再证明操作人具有 Administrator 角色。服务器鉴权结果始终是最终依据。
离线代填沿用同一授权判断,但使用独立的授权模式。签发包要求 Survey Responses 工作区 Read、目标 Survey Read 和目标代填权限;授权行绑定系统操作员及一个已分配受访者。文件上传和响应同步要求同一操作员重新登录,并重新检查 ACL、成员关系和部门范围。浏览器不能提交或替换任一身份。因此,即使加密包仍可在本地打开,只要同步前撤销 ACL,排队写入就会被拒绝。
功能表单转发
URL 映射的 Functional Form 必须先鉴权表单,再调用功能处理器。内置 `/api/features` 要求有效 `formId`,验证该 Functional Form 确实映射到此路由,对匿名访问应用平台的启用状态规则,计算 `Read` 与部门上下文,应用可信系统值/选项,并丢弃调用方伪造的 form/user 身份字段。
表单提交分发器在外层 `/api/forms/{id}/submissions` 完成授权后,直接在进程内调用功能服务,避免匿名用户直接请求 URL 而绕过表单契约。
客户端能力投影
登录和 `/api/auth/me` 返回面向客户端的有效状态:
{
"canCreateForms": true,
"formAccess": {
"form-guid": {
"canRead": true,
"canEdit": false,
"canDelete": false,
"canManageSubmissions": true
}
}
}
SystemLeftPane 使用这些字段控制业务菜单显示,不再为问卷管理、Submission Center、Module Starter 或任意功能表单重复写 Administrator 判断。服务器仍会重新鉴权每个请求;该投影只用于保持界面一致。ACL 修改后会在下次登录或强制刷新 `/me` 时更新。
兼容与扩展检查清单
增加系统表单或功能表单时:
1. 使用稳定 Form ID,并明确它是真正的安全/启动基础设施、有部门归属的业务定义,还是由 Administrator 发布的共享定义。不要把共享业务表单加入代码白名单。
2. 所有 HTTP 操作按正确能力调用 `IFormAccessAuthorizer`。
3. 所有查询、新增、更新、删除、导入、选择、导出和批处理端点都加入行级部门过滤。
4. ORM 记录在 Data Model 中暴露可信物理列 `department_id`;API 记录显式使用 `IDepartmentScopeResolver`。
5. 建立或复制表单时授予 creator ACL,并由服务器保留或解析部门归属。
6. 导航使用 `formAccess[formId].canRead` 或 `canCreateForms`,Vue 不按角色名推断权限。
7. 单表单与批量 ACL 判定必须等价。
8. 验证 deny 优先;无部门用户只能获得共享表单的 ACL 能力,带部门范围的数据仍失败关闭;父部门可见有效下级;兄弟部门隔离;伪造主键不能跨行级边界。
缺失的官方业务系统表单可能在后续应用启动时被 initializer 重新建立。有效 `Delete` 能力控制本次删除请求,但确定性的 seed 生命周期仍可能在重启后恢复平台所需定义。Initializer 仍必须保留之后由授权 Designer 完成的修改,并且只做窄化、幂等升级。
主要代码
| 领域 | 文件 |
|---|---|
| ACL 规则和有效权限 | `src/FormPlatform.Host/Data/FormAccessControl.cs` |
| 基础设施豁免分类 | `src/FormPlatform.Host/Data/SystemManagementGridService.cs` |
| SDK 模块鉴权 | `src/FormPlatform.Sdk/Forms/FormAuthorization.cs` |
| ORM 自动部门范围 | `src/FormPlatform.Sdk/DataAccess/Forms/DepartmentScopeConvention.cs`、`FormEntityDataService.cs` |
| Viewer、Designer、数据和提交路由 | `src/FormPlatform.Host/Hosting/Endpoints/FormEndpoints.cs` |
| 问卷管理与行级范围 | `src/FormPlatform.Host/Hosting/Endpoints/SurveyAdministrationEndpoints.cs`、`src/FormPlatform.Host/Data/SurveyApplicationService.cs` |
| Functional URL 转发 | `src/FormPlatform.Host/Hosting/Endpoints/SystemDataEndpoints.cs`、`src/FormPlatform.Host/Data/FormSubmissionDispatcher.cs` |
| 客户端有效能力 | `src/FormPlatform.Host/Hosting/Endpoints/AuthenticationEndpoints.cs` |
| 导航升级 | `src/FormPlatform.Host/Data/SystemLayoutForms.cs`、`SurveySystemForms.cs`、`ModuleStarterSystemForm.cs` |
另见部门多租户设计。