方案密码锁方案.md 12 KB

方案密码锁 技术实施方案

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)

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"
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。

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/<id>/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<documentId, password>:验证一次后本会话内免重复输入;
  • 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 被免密规则覆盖,保留为声明性配置与矩阵对齐。