需求原文(prompts.md L456):
上传文件时,对话框高级设置里可选设置密码,多选针对不同操作进行设置;即使是拥有权限的用户,进行对应操作时也需再次输入密码确认。此操作也是为了防止超级管理员越权查看。对超级管理员而言,若方案被设置密码,各类操作均需输入密码(包括查看,不局限于设置的操作),但删除不需要,删除时需二次确认。
已确认的设计决策:
| 决策点 | 结论 |
|---|---|
| 密码粒度 | 一个密码 + 多选操作(7 个操作勾选位) |
| 保护范围 | 仅主案文档本身(子方案、附件不受主案密码约束) |
| 密码管理 | 修改/清除密码时任何人(含超管)都必须提供原密码 |
| 操作集合 | 7 操作全集,与权限矩阵对齐:VIEW / DOWNLOAD / EDIT / DELETE / CONFIG_PERMISSION / BIND_ATTACHMENT / UNBIND_ATTACHMENT |
设文档 password_hash 非空(已设密),当前用户 role_code:
密码传递方式:请求头 X-DMS-Document-Password(避免出现在 URL/访问日志)。
响应语义(均 403):
DOCUMENT_PASSWORD_REQUIRED:该操作需要密码但请求未携带;DOCUMENT_PASSWORD_INVALID:携带的密码错误(修改/清除密码时原密码错误同样返回此码)。查看保护的对象是文件内容,不改变列表可见性(主案列表本就全员可见):
GET /documents/{id}/preview、下载(勾选 DOWNLOAD 时)→ 拦截;GET /documents/{id} 不阻断,但当"查看需密码且未通过验证"时屏蔽正文类字段(contentText、列表 contentSnippet),并置 viewLocked: true;携带正确密码头后返回完整正文;description(管理员维护的补充说明)属元数据,不屏蔽。DTO 新增 hasPassword: boolean 与 passwordProtectedActions: string[](勾选的操作码)。保护位类似"此门需钥匙"的告示,向有权看到列表的用户公开(前端需显示锁标志),不泄露文件内容。
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。
降级保护:存在设密文档时拒绝降级(防丢失密码配置)。
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 = "方案密码错误"
复用 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 不含密码)。
| 操作 | 服务入口 | 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。PUT /documents/<id>/password(admin_required)→ save_document_password;create_document 的 metadata 支持可选 password + protectedActions(仅 MAIN;SUB_PLAN/ATTACHMENT 携带即 INVALID_ARGUMENT),同事务写入并追加一笔 SET 审计;request.headers 读取密码(服务层内部完成,路由无需改动签名)。/documents/{id}/password PUT 定义;hasPassword/passwordProtectedActions,DocumentDetail 加 viewLocked;定义 X-DMS-Document-Password header 参数组件并在操作端点引用;错误码补充。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 已存在,无需改造。
Map<documentId, password>:验证一次后本会话内免重复输入;requestPassword({documentId, documentName, actionLabel, invalid}) → Promise:发布弹窗请求,等待提交/取消(取消 resolve null);withDocumentPassword(documentId, documentName, actionLabel, invoke):
DOCUMENT_PASSWORD_REQUIRED/DOCUMENT_PASSWORD_INVALID → 弹窗(invalid 标记显示"密码错误")→ 带新密码重试;用户取消则抛出原错误;PasswordPromptModal 挂 App 根部,订阅 store 渲染;clearPasswordCache(documentId?):登出、修改/清除密码成功后调用。| 位置 | 改动 |
|---|---|
| SingleUploadModal | kind=document 且 MAIN 时显示"高级设置"折叠区:密码输入 + 7 操作勾选(默认全选);密码非空但未勾选任何操作 → formError |
| EditMetadataModal | MAIN 文档显示"密码锁"区块:未设密可设置;已设密显示保护操作并可修改/清除(均需原密码) |
| DeleteDocumentModal | 设密主案:警示标识 + 二阶段确认(先确认知晓风险,再出现删除按钮) |
| FilePreviewModal | 预览请求包 withDocumentPassword;正文加载遇 viewLocked 弹密码重取 |
| App.tsx 下载入口 | downloadFile 包 withDocumentPassword |
| PermissionModal / AttachmentBindingModal / UnbindAttachmentModal | 提交调用包 withDocumentPassword(EDIT 除外:授权/挂载/解挂) |
| 列表 | hasPassword 文档显示锁图标(Lock) |
(本机无 MySQL 时整体 skip,与既有 DB 测试一致;用户环境执行 pytest tests/dms/test_document_password_lock.py -q。)
verify_password 的 dummy-hash 分支防时序探测;