# 方案密码锁 技术实施方案 ## 1. 需求与设计决策 需求原文(prompts.md L456): > 上传文件时,对话框高级设置里可选设置密码,多选针对不同操作进行设置;即使是拥有权限的用户,进行对应操作时也需再次输入密码确认。此操作也是为了防止超级管理员越权查看。对超级管理员而言,若方案被设置密码,各类操作均需输入密码(包括查看,不局限于设置的操作),但删除不需要,删除时需二次确认。 已确认的设计决策: | 决策点 | 结论 | | --- | --- | | 密码粒度 | 一个密码 + 多选操作(7 个操作勾选位) | | 保护范围 | 仅主案文档本身(子方案、附件不受主案密码约束) | | 密码管理 | 修改/清除密码时任何人(含超管)都必须提供原密码 | | 操作集合 | 7 操作全集,与权限矩阵对齐:VIEW / DOWNLOAD / EDIT / DELETE / CONFIG_PERMISSION / BIND_ATTACHMENT / UNBIND_ATTACHMENT | ### 1.1 判定规则(核心语义) 设文档 `password_hash` 非空(已设密),当前用户 `role_code`: - **ADMIN(超管/管理员)**:除 DELETE 外的所有操作均需密码(无论是否勾选);DELETE 免密,但前端删除弹窗需二次确认。 - **普通用户**:仅当对应勾选位为真时需密码。 - 未设密文档:一切照旧。 密码传递方式:请求头 `X-DMS-Document-Password`(避免出现在 URL/访问日志)。 响应语义(均 403): - `DOCUMENT_PASSWORD_REQUIRED`:该操作需要密码但请求未携带; - `DOCUMENT_PASSWORD_INVALID`:携带的密码错误(修改/清除密码时原密码错误同样返回此码)。 ### 1.2 "查看"保护的范围 查看保护的对象是**文件内容**,不改变列表可见性(主案列表本就全员可见): - 预览 `GET /documents/{id}/preview`、下载(勾选 DOWNLOAD 时)→ 拦截; - 详情 `GET /documents/{id}` **不阻断**,但当"查看需密码且未通过验证"时屏蔽正文类字段(`contentText`、列表 `contentSnippet`),并置 `viewLocked: true`;携带正确密码头后返回完整正文; - `description`(管理员维护的补充说明)属元数据,不屏蔽。 ### 1.3 敏感字段与标志公开性 DTO 新增 `hasPassword: boolean` 与 `passwordProtectedActions: string[]`(勾选的操作码)。保护位类似"此门需钥匙"的告示,向有权看到列表的用户公开(前端需显示锁标志),不泄露文件内容。 ## 2. 数据库(迁移 0013_document_password_lock) `doc_document` 新增 8 列: | 列 | 类型 | 说明 | | --- | --- | --- | | `password_hash` | VARCHAR(255) NULL | Argon2id 哈希,NULL=未设密 | | `protect_view` | TINYINT(1) NOT NULL DEFAULT 0 | 普通用户查看需密码 | | `protect_download` | 同上 | 下载 | | `protect_edit` | 同上 | 编辑(元数据+补充说明) | | `protect_delete` | 同上 | 删除(声明位;ADMIN 免密规则优先) | | `protect_config_permission` | 同上 | 权限配置 | | `protect_bind_attachment` | 同上 | 挂载附件 | | `protect_unbind_attachment` | 同上 | 解除挂载 | 约束:`CHECK ((password_hash IS NULL) OR (document_type = 'MAIN'))` —— 仅主案可设密。 `sys_audit_log.action_type` CHECK 扩展:`DOCUMENT_PASSWORD_SET`、`DOCUMENT_PASSWORD_REMOVE`。 降级保护:存在设密文档时拒绝降级(防丢失密码配置)。 ## 3. 后端 ### 3.1 枚举与错误(enums.py / errors.py) ```python class DocumentPasswordAction(StrEnum): VIEW = "VIEW" DOWNLOAD = "DOWNLOAD" EDIT = "EDIT" DELETE = "DELETE" CONFIG_PERMISSION = "CONFIG_PERMISSION" BIND_ATTACHMENT = "BIND_ATTACHMENT" UNBIND_ATTACHMENT = "UNBIND_ATTACHMENT" # AuditAction 追加 DOCUMENT_PASSWORD_SET = "DOCUMENT_PASSWORD_SET" DOCUMENT_PASSWORD_REMOVE = "DOCUMENT_PASSWORD_REMOVE" ``` ```python class DocumentPasswordRequiredError(ForbiddenError): code = "DOCUMENT_PASSWORD_REQUIRED" default_message = "该方案已设置密码保护,请输入密码后重试" class DocumentPasswordInvalidError(ForbiddenError): code = "DOCUMENT_PASSWORD_INVALID" default_message = "方案密码错误" ``` ### 3.2 新服务 document_password_service.py 复用 `dms/security/passwords.py` 的 Argon2id `hash_password/verify_password`。 ```python PASSWORD_HEADER = "X-DMS-Document-Password" def password_required_for(document, action: str, context) -> bool # 未设密→False;ADMIN→action != "DELETE";否则读 protect_* 勾选位 def require_document_password(document, action, password: str | None) -> None # 需要时:无密码→Required;verify 失败→Invalid def get_request_password() -> str | None # request.headers.get(PASSWORD_HEADER) def is_view_locked(document, context) -> bool # 列表序列化用 def view_password_verified(document, context) -> bool # 详情:需密且 header 密码正确 def save_document_password(document_id, payload) -> dict # payload: oldPassword | password | protectedActions | rowVersion # 规则:仅 MAIN;已设密必须验 oldPassword; # password 非空→protectedActions 必填合法子集,写入 hash+7位; # password 空→清除(hash=NULL、7位归零);乐观锁校验;bump row_version; # 审计 DOCUMENT_PASSWORD_SET / DOCUMENT_PASSWORD_REMOVE(detail 不含密码)。 ``` ### 3.3 判定链融合点(各服务在已持有 document 对象处调用) | 操作 | 服务入口 | action | | --- | --- | --- | | 预览 | file_read_service.preview_document | VIEW | | 下载 | file_read_service.download_document | DOWNLOAD | | 编辑元数据 | document_mutation_service.update_document | EDIT | | 补充说明 | document_mutation_service.update_document_description | EDIT | | 删除 | document_mutation_service.delete_document | DELETE | | 权限读/存 | permission_service.get_permissions / save_permissions | CONFIG_PERMISSION | | 挂载 | attachment_binding_service.bind_attachments | BIND_ATTACHMENT | | 解挂 | attachment_binding_service.unbind_attachment | UNBIND_ATTACHMENT | 序列化(document_query_service): - `document_summary`:追加 `hasPassword`、`passwordProtectedActions`;`is_view_locked` 时不生成 `contentSnippet`; - `document_detail`:追加 `viewLocked`;`viewLocked` 时 `contentText` 置 None。 ### 3.4 API 路由(documents.py) - 新增 `PUT /documents//password`(admin_required)→ `save_document_password`; - `create_document` 的 metadata 支持可选 `password` + `protectedActions`(仅 MAIN;SUB_PLAN/ATTACHMENT 携带即 INVALID_ARGUMENT),同事务写入并追加一笔 SET 审计; - 各受保护端点从 `request.headers` 读取密码(服务层内部完成,路由无需改动签名)。 ### 3.5 OpenAPI - paths/documents.yaml:`/documents/{id}/password` PUT 定义; - schemas.yaml:DocumentSummary 加 `hasPassword`/`passwordProtectedActions`,DocumentDetail 加 `viewLocked`;定义 `X-DMS-Document-Password` header 参数组件并在操作端点引用;错误码补充。 ## 4. 前端 ### 4.1 类型与 API - `shared/api/enums.ts`:`ProtectedAction` 枚举(7 值,与矩阵 MATRIX_ACTION_COLUMNS 的 key 对齐); - `documents/documentTypes.ts`:`DocumentSummary += hasPassword?: boolean; passwordProtectedActions?: ProtectedActionCode[]`;`DocumentDetail += viewLocked?: boolean`;守卫对可选字段宽松校验(兼容既有 fixture); - `mutations/mutationTypes.ts`:`MainDocumentCreateMetadata += password?: string; protectedActions?: ProtectedActionCode[]`(仅 MAIN 分支允许);新增 `UpdateDocumentPasswordRequest` 及守卫; - `mutations/documentMutationApi.ts`:`updatePassword(id, request)`;`update/remove/updateDescription` 增加可选 `password` 参数(headers 透传); - `files/fileApi.ts`:`preview/download` 增加可选 `password`(`FileClient.getBlob` options 扩展 headers); - `files/fileState.ts`:`loadPreviewOnce/acquirePreview/downloadFile` 透传 password; - `permissions/permissionApi.ts`、`bindings/bindingApi.ts`:`get/save/bind/unbind` 增加可选 password。 httpClient 的 `RequestOptions.headers` 已存在,无需改造。 ### 4.2 密码弹窗基础设施(shared/state/documentPassword.ts + PasswordPromptModal) - 模块级会话缓存 `Map`:验证一次后本会话内免重复输入; - `requestPassword({documentId, documentName, actionLabel, invalid})` → Promise:发布弹窗请求,等待提交/取消(取消 resolve null); - `withDocumentPassword(documentId, documentName, actionLabel, invoke)`: 1. 缓存有密码直接带上调用; 2. 捕获 `DOCUMENT_PASSWORD_REQUIRED`/`DOCUMENT_PASSWORD_INVALID` → 弹窗(invalid 标记显示"密码错误")→ 带新密码重试;用户取消则抛出原错误; - `PasswordPromptModal` 挂 App 根部,订阅 store 渲染; - `clearPasswordCache(documentId?)`:登出、修改/清除密码成功后调用。 ### 4.3 UI 集成 | 位置 | 改动 | | --- | --- | | SingleUploadModal | kind=document 且 MAIN 时显示"高级设置"折叠区:密码输入 + 7 操作勾选(默认全选);密码非空但未勾选任何操作 → formError | | EditMetadataModal | MAIN 文档显示"密码锁"区块:未设密可设置;已设密显示保护操作并可修改/清除(均需原密码) | | DeleteDocumentModal | 设密主案:警示标识 + 二阶段确认(先确认知晓风险,再出现删除按钮) | | FilePreviewModal | 预览请求包 withDocumentPassword;正文加载遇 `viewLocked` 弹密码重取 | | App.tsx 下载入口 | downloadFile 包 withDocumentPassword | | PermissionModal / AttachmentBindingModal / UnbindAttachmentModal | 提交调用包 withDocumentPassword(EDIT 除外:授权/挂载/解挂) | | 列表 | hasPassword 文档显示锁图标(Lock) | ## 5. 测试 ### 5.1 后端 test_document_password_lock.py 1. 上传 MAIN 设密成功(hash 落库、勾选位正确、SET 审计); 2. SUB_PLAN 携带密码字段被拒; 3. USER:勾选操作无密码 → 403 REQUIRED;带密码通过;错误密码 → 403 INVALID;未勾选操作直接通过; 4. ADMIN:VIEW 等全部操作 REQUIRED(未勾选亦然);DELETE 免密直接成功; 5. CONFIG_PERMISSION / BIND / UNBIND 拦截验证; 6. 修改/清除密码:无 oldPassword / 错误 oldPassword → INVALID;正确 → 成功、rowVersion+1、审计正确; 7. 列表:viewLocked 文档无 contentSnippet;详情 contentText 屏蔽,带正确密码头返回; 8. 编辑/删除被拦截(缺密码)。 (本机无 MySQL 时整体 skip,与既有 DB 测试一致;用户环境执行 `pytest tests/dms/test_document_password_lock.py -q`。) ### 5.2 前端 vitest - documentTypes 守卫:hasPassword/passwordProtectedActions/viewLocked 可选校验; - documentPassword:缓存命中不弹窗、REQUIRED 弹窗后重试、INVALID 重弹、取消抛原错误; - fileApi/documentMutationApi:password → header 透传、updatePassword 请求体校验; - mutationTypes:MAIN 允许密码字段、SUB_PLAN 拒绝。 ## 6. 实施顺序 1. 迁移 0013 + 模型字段; 2. 枚举/错误/密码服务/判定融合/路由/OpenAPI; 3. 后端测试; 4. 前端类型/API/密码弹窗基础设施; 5. 前端 UI 集成(上传/编辑/删除/预览/下载/授权/挂载); 6. 前端测试; 7. 全量回归(tsc + vitest + pytest)。 ## 7. 风险与边界 - 密码哈希使用登录同款 Argon2id 参数;`verify_password` 的 dummy-hash 分支防时序探测; - 密码修改后前端缓存清理(clearPasswordCache),避免旧密码反复 INVALID; - 忘记密码无自助通道(用户决策:任何人含超管都需原密码),只能数据库运维层重置——文档明示; - DELETE 勾选位对普通用户不可达(删除为管理员端点)、对 ADMIN 被免密规则覆盖,保留为声明性配置与矩阵对齐。