""" 高考志愿填报系统 - 分享权限策略 (T7.3) 职责: 把 ShortLinkService.permission 字段 (read/comment/edit) 翻译成 "前端可不可以点编辑/可不可以提交评论/可见哪些字段" 的策略对象。 设计目标: - 单测友好: 纯函数 + dataclass, 不依赖 DB / Web 框架。 - 关注点分离: 短链接服务只管"短码 → 报告元数据";策略层只管"权限 → UI 能力"。 - 默认安全: 未知 permission 一律按最严格的"只读 + 全脱敏"处理, 不允许越权。 与 masking 的关系: - data.orders.masking.mask_name 提供基础姓名脱敏能力。 - 本模块负责 "在哪个 permission 等级下要不要遮" 的策略决策。 - 分享页是公开场景,因此在基础脱敏之上再做更保守的收敛,保证输出符合 T7.3/T7.5 的 "张**" 风格约束。 层级 (T7.3 范围, 与 docs/plans/T7-sharing-mvp.md 对齐): 级别 字段值 可查看 可评论 可编辑 姓名展示 -------- --------- ---------- ------------ ----------- ------------ 只读 read ✔ ✘ ✘ 张** (全遮) 评论 comment ✔ ✔ ✘ 张** (全遮) 编辑 edit ✔ ✔ ✔ 张明 (全显) # 兼容 T7.1 留下的 PERM_ADMIN 等级: 视为 edit, 仍走策略表。 """ from __future__ import annotations from dataclasses import dataclass from typing import Any, Optional # 兼容 PERM_ADMIN: 把它当 edit 处理, 防止历史数据遗留 admin 等级时崩溃。 # 改 POLICY_ADMIN_ALIAS 可调整。 POLICY_ADMIN_ALIAS = "edit" # --------------------------------------------------------------------------- # 字段可见性策略表 # --------------------------------------------------------------------------- # key: permission 等级 # value: dict(可见字段集合, 允许能力集合, 是否脱敏姓名) # # 字段集合 iterable = "只允许 iterable 列出的字段被前端渲染"。 # P1-6 起不再允许 edit/admin 走 None 透传,避免未来新增敏感字段自动外泄。 _EDIT_VISIBLE_FIELDS = { "report_id", "permission", "owner_id", "share_url", "created_at_iso", "expires_at_iso", "candidate_name", "customer_name", "student_name", "name", "title", "summary", "recommendations", "volunteers", "score", "rank", "year", "province", "candidate_subjects", "candidate_interests", "candidate_strong_subjects", "candidate_weak_subjects", "candidate_family", "assigned_consultant", "service_version", "notes", "tags", } _POLICY_TABLE: dict[str, dict[str, Any]] = { "read": { "can_view": True, "can_comment": False, "can_edit": False, "mask_name": True, # read 默认只暴露"通用展示"字段 + 脱敏姓名; # 报告正文 / 推荐表 / 联系方式 全部隐藏 "visible_fields": { "report_id", "permission", "owner_id", "share_url", "created_at_iso", "expires_at_iso", "candidate_name", "customer_name", "student_name", "name", }, }, "comment": { "can_view": True, "can_comment": True, "can_edit": False, "mask_name": True, # comment 可看报告正文 + 脱敏姓名, 但联系方式 / 身份证 / 电话仍不暴露 "visible_fields": { "report_id", "permission", "owner_id", "share_url", "created_at_iso", "expires_at_iso", "candidate_name", "customer_name", "student_name", "name", "title", "summary", "recommendations", "volunteers", "score", "rank", "year", "province", }, }, "edit": { "can_view": True, "can_comment": True, "can_edit": True, "mask_name": False, # P1-6: edit 必须显式 allowlist,不能再走 None 透传。 "visible_fields": _EDIT_VISIBLE_FIELDS, }, "admin": { # alias -> edit "can_view": True, "can_comment": True, "can_edit": True, "mask_name": False, "visible_fields": _EDIT_VISIBLE_FIELDS, }, } # 默认拒止策略: 任何未知 permission 一律当 read 处理 (最严) _RESTRICTIVE_FALLBACK = _POLICY_TABLE["read"].copy() _RESTRICTIVE_FALLBACK["visible_fields"] = set(_POLICY_TABLE["read"]["visible_fields"]) # 显式 PII 字段: 永远走 mask_name 决策, 与 visible_fields 正交 _PII_NAME_FIELDS = ("candidate_name", "customer_name", "student_name", "name") # 无论 permission 等级如何都不应进入公开分享 payload 的内部字段。 # 这些字段要么属于安全敏感信息,要么只服务存储/运营侧,不应由 T7.3 透传。 _ALWAYS_HIDDEN_FIELDS = frozenset( { "password_hash", "api_token", "access_token", "refresh_token", "internal_note", "note", "debug_info", "raw_payload", "candidate_id_card", "candidate_phone", "customer_phone", "customer_email", "customer_wechat", "plan_file", "audit_report", "pdf_path", } ) # --------------------------------------------------------------------------- # Policy 数据类 # --------------------------------------------------------------------------- @dataclass(frozen=True) class PermissionPolicy: """分享页的权限策略对象。""" permission: str can_view: bool can_comment: bool can_edit: bool mask_name: bool # None = "默认通过 (除显式内部字段外全部可见)" visible_fields: Optional[frozenset[str]] # 内部使用: True 表示输入 permission 不在策略表内, 已落 fallback。 _is_fallback: bool = False @property def is_restrictive_fallback(self) -> bool: """True 表示 permission 未知/为空, 已落到 read 默认拒止策略上。""" return self._is_fallback def allows_field(self, field_name: str) -> bool: """判断某个字段对当前策略是否应该被前端渲染。""" if not self.can_view: return False if self.visible_fields is None: return True return field_name in self.visible_fields def can(self, action: str) -> bool: """通用能力判断: can("view") / can("comment") / can("edit")""" return bool(getattr(self, f"can_{action}", False)) @classmethod def for_permission(cls, permission: str) -> "PermissionPolicy": """根据 permission 字段值返回策略对象;未知值走严格 fallback。""" perm_raw = (permission or "").strip().lower() perm = POLICY_ADMIN_ALIAS if perm_raw == "admin" else perm_raw if perm not in _POLICY_TABLE: cfg = _RESTRICTIVE_FALLBACK stored_perm = "read" is_fallback = True else: cfg = _POLICY_TABLE[perm] stored_perm = perm is_fallback = False vf = cfg.get("visible_fields") vf_fs = frozenset(vf) if vf is not None else None return cls( permission=stored_perm, can_view=bool(cfg.get("can_view", False)), can_comment=bool(cfg.get("can_comment", False)), can_edit=bool(cfg.get("can_edit", False)), mask_name=bool(cfg.get("mask_name", True)), visible_fields=vf_fs, _is_fallback=is_fallback, ) # --------------------------------------------------------------------------- # 报告 payload 渲染 # --------------------------------------------------------------------------- def _mask_name_safe(value: Any) -> Any: """分享页姓名脱敏包装。 复用 data.orders.masking.mask_name 的基础能力,但分享页采用更保守的公开展示策略: - 中文 3 字及以上统一收敛为“姓 + **”(如 张三丰 -> 张**) - 非中文姓名统一收敛为 "**",不泄露原始长度 - 2 字中文沿用基础规则(张三 -> 张*) """ from data.orders.masking import mask_name if not isinstance(value, str): return mask_name(value) s = value.strip() if not s: return "" base_masked = mask_name(s) is_cjk = all("\u4e00" <= ch <= "\u9fff" for ch in s) if not is_cjk: return "**" if len(s) >= 3: return s[0] + "**" return base_masked def render_report_payload( permission: str, report: Optional[dict[str, Any]], *, share_url: Optional[str] = None, ) -> dict[str, Any]: """根据权限等级, 渲染前端可见的报告 payload。""" policy = PermissionPolicy.for_permission(permission) raw = report if isinstance(report, dict) else {} payload: dict[str, Any] = {} masked: list[str] = [] if share_url: payload["share_url"] = share_url for key, value in raw.items(): if key in _ALWAYS_HIDDEN_FIELDS: continue if not policy.allows_field(key): continue if key in _PII_NAME_FIELDS and policy.mask_name: payload[key] = _mask_name_safe(value) masked.append(key) else: payload[key] = value return { "permission": policy.permission, "policy": { "can_view": policy.can_view, "can_comment": policy.can_comment, "can_edit": policy.can_edit, "mask_name": policy.mask_name, }, "visible_fields": sorted(policy.visible_fields) if policy.visible_fields is not None else None, "payload": payload, "masked_fields": masked, } # --------------------------------------------------------------------------- # 工具函数 # --------------------------------------------------------------------------- def supported_permissions() -> list[str]: """返回策略表支持的全部 permission 等级 (含 admin alias)。""" return list(_POLICY_TABLE.keys()) def is_known_permission(permission: str) -> bool: """判断 permission 是否在策略表中 (admin 视为合法)。""" perm = (permission or "").strip().lower() return perm in _POLICY_TABLE __all__ = [ "POLICY_ADMIN_ALIAS", "PermissionPolicy", "is_known_permission", "render_report_payload", "supported_permissions", ]