OpenMed 结构化访问复核(Structured Access Review):本地、确定性、无值的字段权限核对机制
【免费下载链接】openmedLocal-first healthcare AI: clinical NER & HIPAA PII de-identification that runs 100% on-device. 2,200+ medical models, 21 languages, Apple MLX + Python, no cloud, no patient data leaving your network. Apache-2.0项目地址: https://gitcode.com/GitHub_Trending/ope/openmed
本文基于 docs/security/access-review.md 编写。OpenMed 是一个本地优先(local-first)的医疗 AI 与 HIPAA PII 去标识化项目,其风险模块提供了一套结构化访问复核机制:将工作流(workflow)声明的
read/export字段集合与资源 Schema、可选的拒绝(deny)策略进行本地比对,输出三类复核发现(Missing / Excessive / Denied),并渲染为确定性的 JSON 与 Markdown 报告。读完本文,你将掌握openmed.risk.review_structured_access的完整调用方式、报告结构、输入约束与安全边界,并了解如何在集成配置阶段用它做最小必要(minimum-necessary)字段的配置证据核验。
一、功能定位:配置证据,而非合规认证
openmed.risk.review_structured_access回答的是一个范围极窄的治理问题:
工作流为
read与export两种访问模式声明的字段,是否恰好落在资源 Schema 暴露的字段集合、以及调用方拒绝策略所允许的范围之内?
该函数是集成配置的本地复核辅助工具(local review aid),它:
- 不是合规认证(compliance certification);
- 不是临床决策保证(clinical decision guarantee);
- 不授予任何访问权限;
- 不证明某个集成真的强制执行了这份声明;
- 不检查任何记录(record)内容;
- 不发起任何网络调用。
复核结果应当被当作**配置证据(configuration evidence)**使用。一份完整(complete)的报告只意味着:对于所有已声明的工作流与访问模式,不存在 Missing、Excessive、Denied 三类发现中的任何一项。
二、快速上手:最小可用示例
以下示例来自文档,完整可直接运行:
from openmed.risk import render_access_review, review_structured_access report = review_structured_access( { "triage": { "read": {"patient_id", "age"}, "export": {"diagnosis"}, } }, { "properties": { "patient_id": {}, "age": {}, "diagnosis": {}, "notes": {}, } }, denied_fields={"export": {"diagnosis"}}, ) print(report.to_json()) print(render_access_review(report))其中:
- 第一个参数
workflow_requirements:工作流名 → 字段声明的映射; - 第二个参数
resource_schema:资源 Schema(支持多种形状,见下文); - 关键字参数
denied_fields:全局或按模式(mode)区分的拒绝字段集合。
三、报告内容:三类复核发现
针对每个访问模式(read/export),报告识别三类发现:
| 发现类别 | 含义 |
|---|---|
| Missing(缺失) | 工作流声明了该字段,但资源 Schema 中不存在该字段——可能是拼写错误或依赖了已删除的列 |
| Excessive(越界) | Schema 中存在该字段,但当前工作流与访问模式没有声明它——意味着潜在的超范围访问 |
| Denied(拒绝) | 声明的字段命中全局或模式级拒绝策略——配置与策略冲突,需要显式处理 |
以文档示例为例:资源 Schema 拥有patient_id、age、diagnosis、notes四个字段;triage工作流声明read = {patient_id, age}、export = {diagnosis};拒绝策略声明export模式拒绝diagnosis。复核结果:
read模式:requested = {patient_id, age},available = {patient_id, age, diagnosis, notes},无 Missing、无 Denied,但Excessive = {diagnosis, notes};export模式:requested = {diagnosis},available同上,Denied = {diagnosis},Excessive = {patient_id, age, notes};- 整体
complete == False(存在 Excessive 与 Denied 发现)。
这里有一个反直觉但重要的设计:Excessive 是常态而非异常。因为 Excessive 的定义是「Schema 中有、但本工作流本模式未声明」的字段,只要资源 Schema 比工作流声明的字段宽,就一定会出现。所以一个**完整(complete)**的结果,要求工作流声明恰好覆盖 Schema 的全部字段——这在语义上等价于「声明的字段集合必须与 Schema 完全一致」;配合 deny 策略时,还需要被拒绝的字段不在声明集合内。这一点在 源码实现 中可以看到:
denied = global_denied_set | set(mode_denied.get(mode, ())) denied_requested = requested_set & denied mode_reviews[mode] = AccessModeReview( mode=mode, requested_fields=requested, available_fields=resource_fields, allowed_fields=tuple(sorted((requested_set & resource_set) - denied)), missing_fields=tuple(sorted(requested_set - resource_set)), excessive_fields=tuple(sorted(resource_set - requested_set)), denied_fields=tuple(sorted(denied_requested)), )missing = requested - available(声明了但 Schema 没有);excessive = available - requested(Schema 有但未声明);denied = requested ∩ denied(声明了但被策略拒绝);allowed = (requested ∩ available) - denied。
四个集合互斥,恰好覆盖全部字段关系。测试用例 tests/unit/risk/test_access_review.py#L47-L60 完整验证了这三类发现的判定逻辑。
四、Schema 映射值被忽略:报告永不携带记录值
一个至关重要的隐私设计是:Schema 的映射值(mapping values)一律被忽略。Schema 中的example、default、description等元数据不会进入报告,报告只包含:
- 经过校验的结构性字段名与工作流名;
- 访问决策(decision);
- 各类计数(counts)。
在 openmed/risk/access_review.py#L103-L125 的_field_tuple中可以看到:当字段声明是Mapping时,只取value.keys(),绝不遍历值:
if isinstance(value, Mapping): # A mapping is a convenient schema/field declaration. Its values may # contain examples or defaults and are intentionally never traversed. candidates = value.keys()因此,即使 Schema 中写着{"patient_id": {"example": "PATIENT-0007"}},报告里也永远不会出现"PATIENT-0007"。测试 tests/unit/risk/test_access_review.py#L63-L87 明确断言:PATIENT-0007、PRIVATE-DIAGNOSIS、RAW-SCHEMA-VALUE等值绝不会出现在序列化后的 JSON 中,而结构性的patient_id会保留。
这也解释了「非法名称被拒绝时不会在异常中回显」的设计:调用方完全可能把敏感值误填在字段名位置,若异常消息回显输入值,就会击穿报告的价值边界。因此所有校验错误消息只描述「输入位置与类型」,从不回显具体值——参见_identifier的注释与实现 openmed/risk/access_review.py#L74-L84,以及测试 tests/unit/risk/test_access_review.py#L123-L132(SENSITIVE-RAW-VALUE不出现在异常字符串中)。
五、输入形状:灵活而受控
5.1 资源 Schema 的四种形状
resource_schema参数(类型ResourceSchema)支持多种常见形状,由 _resource_fields 统一提取字段名:
- JSON Schema 风格映射:含
properties键的映射(如{"properties": {"patient_id": {...}, ...}}); - 普通映射:以字段名为键(此时
fields键也可用); - 带属性的对象:暴露
fields、columns或names属性的对象; - 可迭代的字段名集合:如
["patient_id", "age", ...]或集合。
无论哪种形状,映射的值一律不检查,只提取键名。
5.2 工作流声明的多种写法
workflow_requirements支持:
- 映射形式:
{"triage": {"read": {...}, "export": {...}}}; - 简写形式:如果键只有
read/export(或其长写法read_fields/export_fields),会被自动包装为名为default的工作流; - 单个带名称的声明:含
name或workflow键的映射(两者不能同时出现); WorkflowRequirement对象序列:WorkflowRequirement(name, read_fields=[...], export_fields=[...]),见 openmed/risk/access_review.py#L173-L214 与测试 tests/unit/risk/test_access_review.py#L104-L120。
访问模式键支持短写法与长写法两种拼写:read/export与read_fields/export_fields。但同一模式不能同时声明两次(例如同时写read和read_fields会报错),模式对象中也不能出现未知键。
5.3 拒绝策略的全局与模式级写法
denied_fields支持:
- 普通字段集合:
{"diagnosis"}或["diagnosis"]—— 全局拒绝; - 策略映射:
{"all": [...], "read": [...], "export": [...]}——all表示全局拒绝,read/export表示模式级拒绝。策略映射中只允许这三个键,出现其他键会被拒绝(见 _normalize_denied_fields)。
六、安全边界与资源上限
复核机制对输入施加了严格的边界约束(常量定义见 openmed/risk/access_review.py#L24-L36):
| 约束 | 限制 |
|---|---|
| 字段标识符 | 最多 128 个 ASCII 结构字符,且须匹配[A-Za-z_][A-Za-z0-9_.:-]{0,127}(_SAFE_IDENTIFIER) |
| 字段声明总数 | 至多 4,096(_MAX_FIELDS) |
| 工作流总数 | 至多 128(_MAX_WORKFLOWS) |
| 非法名称处理 | 拒绝,且异常消息不含输入值 |
| 超大/异常迭代器 | 以通用、无值(value-free)的错误拒绝 |
这些边界由_bounded_tuple在物化迭代器时强制实施 openmed/risk/access_review.py#L87-L100:超过上限即抛AccessReviewValidationError,迭代过程中抛出的任意异常(RuntimeError、TypeError、MemoryError等)都会被包装成不带原始值、不带__cause__链的通用错误("structured access review declarations are invalid")——测试 tests/unit/risk/test_access_review.py#L146-L178 验证了这一行为。
其余被拒绝的输入还包括:
- 歧义的访问模式别名(
read与read_fields并存); - 模式对象或 deny 策略对象中的未知键;
- 同一工作流重复命名、
name与workflow并存; AccessReviewReport/AccessModeReview等公开类型被构造为自相矛盾的发现(例如allowed与requested/available/denied不一致),见 openmed/risk/access_review.py#L229-L261。
这些边界保证:输入无论多畸形,都不可能把原始内容注入报告或异常消息,报告的隐私边界因此成立。
七、确定性输出:等价的声明产生逐字节相同的报告
实现完全基于集合运算与排序:
- 字段名与工作流名在构造时被规范化为排序去重的元组;
- 工作流按名称排序,模式按固定的
read→export顺序输出; - JSON 序列化使用
sort_keys=True、ensure_ascii=True、allow_nan=False(见 to_json)。
因此,等价声明产生的 JSON 与 Markdown 逐字节相同。测试 tests/unit/risk/test_access_review.py#L63-L87 用两种不同书写顺序的等价声明验证了first.to_json() == second.to_json()。这对在 CI 中做配置漂移检测(diff)非常友好:配置未变,报告哈希不变。
7.1 报告对象 API
AccessReviewReport是返回值,也是公开的不可变数据类 openmed/risk/access_review.py#L347-L540,主要成员:
report.workflow(name):按名称取单个工作流复核;report.workflows_with_findings:含有任何发现的工作流;report.missing_fields/report.excessive_fields/report.denied_fields:跨工作流的去重字段;report.summary:聚合计数(workflow_count、workflows_with_findings、resource_field_count、missing_field_count、excessive_field_count、denied_field_count、complete);report.complete:所有工作流所有模式均无发现时为True;report.to_dict()/report.to_json(indent=2)/report.to_markdown():三种确定性输出;report.schema_version:ACCESS_REVIEW_SCHEMA_VERSION = 1。
render_access_review(report)是对report.to_markdown()的包装,要求传入真正的AccessReviewReport实例 openmed/risk/access_review.py#L790-L795。Markdown 输出包含摘要表(Workflows / Resource fields / Missing / Excessive / Denied / Complete)与每个工作流的发现表(Access / Missing fields / Excessive fields / Denied fields / Status,状态为pass或review)。
此外,源码为保持 API 可发现性提供了三个等价别名:build_access_review_report、access_review_report、review_access,均指向同一实现 openmed/risk/access_review.py#L798-L802,并从 openmed/risk/init.py 对外导出。
八、命名与报告即元数据:字段名必须无 PHI
由于报告会原样呈现字段名与工作流名,字段名和工作流名属于报告可见的元数据,绝不能包含受保护健康信息(PHI)。例如不要用{"workflow": "patient-john-doe", "read": {"disease-name-..."}}这类命名。每个字段标识符限定为 128 个 ASCII 结构字符(正则[A-Za-z_][A-Za-z0-9_.:-]{0,127}),这意味着字段名中不允许出现空格、非 ASCII 字符与特殊字符,天然阻断了将自由文本(如"fever 39.2C, chest pain")误作字段名注入报告的可能。
九、本地性与运行前提
复核实现不执行任何强制的网络调用,也不检查任何记录。整条计算链在 review_structured_access 中完成:提取 Schema 字段 → 规范化拒绝策略 → 规范化工作流 → 逐模式集合比对 → 组装报告。没有模型推理、没有远端服务、没有记录扫描,因此可以在离线环境、CI 流水线或集成配置评审中随时运行,且执行成本可忽略。
适用前提:本机制针对的是结构化(structured)字段访问声明的核对,并不适用于非结构化自由文本的去标识化校验;后者应配合 OpenMed 核心 PII 去标识化能力使用(参见 docs/anonymization.md 与 docs/security/minimum-necessary.md)。
十、与周边模块的协同:配置证据链
结构化访问复核并非孤岛,它与 OpenMed 风险/合规模块中的同类确定性机制构成一套「配置证据链」:
- docs/compliance/access-review-expiry.md:
openmed.compliance.access_review_expiry为结构化访问复核提供过期门禁(expiry gate)——校验复核的签发时间、排他性过期边界、策略指纹(policy fingerprint)与必需决策类别,输出稳定的pass/block与错误码(not_yet_valid、expired、policy_fingerprint_mismatch、missing_decision_categories)。两者共享相同的设计哲学:仅含白名单元数据、忽略映射值、错误不回显输入、完全离线确定。若要将访问复核接入发布自动化,建议配合该过期门禁一起使用; - openmed/risk/minimum_necessary.py:最小必要字段选择器——按「用途(purpose)声明 + 策略 profile」从源记录中挑选导出字段,与访问复核一样保持「策略声明与记录值分离」「全部调用方元数据有界」的设计。访问复核回答「声明是否与 Schema/策略一致」,最小必要选择器回答「按声明实际导出哪些字段」,二者可组成「声明核对 → 实际导出」的完整最小必要链路;
- docs/security/access-scope.md等安全文档提供了访问范围相关的策略背景。
十一、结论与使用建议
openmed.risk.review_structured_access是 OpenMed 中一类少见的「纯治理」工具:它不读取一行记录,不发起一次网络请求,仅凭三份输入(工作流声明、资源 Schema、拒绝策略)就能产出确定性的、无值的结构化访问复核报告。使用时请牢记其边界:
- 把它当作配置证据:报告完整 ≠ 实际访问已获授权,也不证明集成真的强制执行声明;
- 命名必须无 PHI:字段名与工作流名会原样进入报告与异常消息;
- 配合过期门禁与最小必要选择器使用,形成可审计、可复现的发布前核验链;
- 利用确定性输出做漂移检测:将
to_json()结果纳入 CI 基线,配置变更即产生可 diff 的报告差异。
在集成开发或对接外部系统时,将本文示例中的triage替换为你的真实工作流名、properties替换为对接资源的真实字段清单、denied_fields替换为你的策略声明,即可在代码评审与发布门禁中获得一份可引用、可复现的结构化访问证据。
【免费下载链接】openmedLocal-first healthcare AI: clinical NER & HIPAA PII de-identification that runs 100% on-device. 2,200+ medical models, 21 languages, Apple MLX + Python, no cloud, no patient data leaving your network. Apache-2.0项目地址: https://gitcode.com/GitHub_Trending/ope/openmed
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考