Commit 42b9e8da by luoqi

fix(auth): expandOrgScope fail-closed — 树外 ref 不再退化成"不限品牌"(跨品牌泄漏)

agent 审查(3 个独立都挖到)的真·安全洞:expandOrgScope 对树里找不到的 ref 兜底成
"当裸 clinicId、sourceUnits 留空"。而瑞尔/瑞泰共用 tenantId,sourceUnits=[] 被下游当
"不限品牌" → 跨品牌看全部患者(含真号 reveal)。触发:退化树(无数据)、新诊所未进树、
10min 缓存陈旧。

- org-scope:探测 branded(树有非空 source_unit);品牌制租户 + 非空 scope 却没解析出
  任何品牌 → 塞 DENY_SOURCE_UNIT 哨兵(患者查询返回空),不退化成不限。命中集团根仍合法不限。
  单命名空间/退化树(无品牌)→ 维持 [] 不限(正确)。回传 unresolved 供刷新重试。
- org-tree:展开有 unresolved → 强制重派生树一次再展开(避免误拒刚摄入的新诊所合法用户);
  forceRefresh 30s 防抖,挡 bogus ref 刷库。

单测:品牌ref/诊所ref正常、集团根→不限、未知ref→DENY、混合(含已解析品牌)→限该品牌不DENY、
单命名空间未知ref→不限。活体三级 + REST 无回归。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
parent 2a858612
...@@ -71,6 +71,18 @@ export interface ExpandedScope { ...@@ -71,6 +71,18 @@ export interface ExpandedScope {
clinicIds: string[]; clinicIds: string[];
} }
/** expandOrgScope 完整结果:多带 unresolved(树里找不到的 ref),供调用方决定是否刷新树重试。 */
export interface ExpandResult extends ExpandedScope {
unresolved: string[];
}
/**
* 哨兵 source_unit —— 匹配不到任何真实 source_unit。
* fail-closed 用:展开失败(品牌制租户却没解析出品牌)时塞它,让患者查询返回空(看不到),
* 而不是 sourceUnits=[] 被下游当成"不限品牌"→ 跨品牌泄漏。
*/
export const DENY_SOURCE_UNIT = '__pac_deny_no_brand__';
/** /**
* 收集一棵子树下所有的 sourceUnit / clinicId(到累加器)。 * 收集一棵子树下所有的 sourceUnit / clinicId(到累加器)。
* sourceUnit(命名空间)**向下继承**:诊所叶子自身不带 sourceUnit,但归属于其上的品牌 * sourceUnit(命名空间)**向下继承**:诊所叶子自身不带 sourceUnit,但归属于其上的品牌
...@@ -93,26 +105,35 @@ function collectSubtree( ...@@ -93,26 +105,35 @@ function collectSubtree(
* - 根(集团 id)→ 不限(sourceUnits=[], clinicIds=[]) * - 根(集团 id)→ 不限(sourceUnits=[], clinicIds=[])
* - 任意中间层(如区域 / 多级区域 / 品牌)→ 该节点整棵子树的 sourceUnit + clinic * - 任意中间层(如区域 / 多级区域 / 品牌)→ 该节点整棵子树的 sourceUnit + clinic
* - 诊所叶子 → 该诊所 + 其所属命名空间 * - 诊所叶子 → 该诊所 + 其所属命名空间
* 多个 ref 取并集。命中根即整体不限。未知 ref 当裸 clinicId 透传。 * 多个 ref 取并集。命中根即整体不限。
*
* **fail-closed**:品牌制租户(树里存在非空 source_unit)下,非空 scope 却没解析出任何品牌
* (ref 全是树外未知 id —— 退化树 / 新诊所未进树 / 缓存陈旧),**不退化成"不限品牌"**,
* 而是塞 DENY 哨兵 → 患者查询返回空。否则因瑞尔/瑞泰共用 tenantId,sourceUnits=[] 会跨品牌泄漏。
* unresolved 回传未知 ref,调用方可据此刷新树重试(避免误拒刚摄入的新诊所合法用户)。
*/ */
export function expandOrgScope(tree: OrgTree, refs: string[]): ExpandedScope { export function expandOrgScope(tree: OrgTree, refs: string[]): ExpandResult {
if (refs.length === 0) return { sourceUnits: [], clinicIds: [] }; // 空范围 = 不限 if (refs.length === 0) return { sourceUnits: [], clinicIds: [], unresolved: [] }; // 空 = 不限
// 扁平化:id → [{ 节点, 继承的命名空间 }]。同 id 可出现多处(如一个品牌跨多个区域), // 扁平化:id → [{ 节点, 继承的命名空间 }]。同 id 可出现多处(如一个品牌跨多个区域),
// 按数组**聚合**全收 —— 用 Map 直接覆盖会静默丢掉同名节点的其余出现。 // 按数组**聚合**全收 —— 用 Map 直接覆盖会静默丢掉同名节点的其余出现。
// 同时探测 branded:树里是否存在非空 source_unit(= 多命名空间 / 品牌制租户)。
const index = new Map<string, Array<{ node: OrgNode; su: string | undefined }>>(); const index = new Map<string, Array<{ node: OrgNode; su: string | undefined }>>();
let branded = false;
(function walk(n: OrgNode, inheritedSu: string | undefined) { (function walk(n: OrgNode, inheritedSu: string | undefined) {
const su = n.sourceUnit ?? inheritedSu; const su = n.sourceUnit ?? inheritedSu;
if (n.sourceUnit) branded = true;
(index.get(n.id) ?? index.set(n.id, []).get(n.id)!).push({ node: n, su }); (index.get(n.id) ?? index.set(n.id, []).get(n.id)!).push({ node: n, su });
for (const c of n.children ?? []) walk(c, su); for (const c of n.children ?? []) walk(c, su);
})(tree.root, undefined); })(tree.root, undefined);
const sourceUnits = new Set<string>(); const sourceUnits = new Set<string>();
const clinicIds = new Set<string>(); const clinicIds = new Set<string>();
const unresolved: string[] = [];
for (const ref of refs) { for (const ref of refs) {
if (ref === tree.groupId) { if (ref === tree.groupId) {
return { sourceUnits: [], clinicIds: [] }; // 命中根 → 整体不限 return { sourceUnits: [], clinicIds: [], unresolved: [] }; // 命中根 → 整体不限(唯一合法的不限来路)
} }
const hits = index.get(ref); const hits = index.get(ref);
if (hits) { if (hits) {
...@@ -120,8 +141,13 @@ export function expandOrgScope(tree: OrgTree, refs: string[]): ExpandedScope { ...@@ -120,8 +141,13 @@ export function expandOrgScope(tree: OrgTree, refs: string[]): ExpandedScope {
for (const h of hits) collectSubtree(h.node, h.su, sourceUnits, clinicIds); for (const h of hits) collectSubtree(h.node, h.su, sourceUnits, clinicIds);
continue; continue;
} }
clinicIds.add(ref); // 未知 ref:当裸诊所 id 透传 clinicIds.add(ref); // 未知 ref:当裸诊所 id 透传(plan 维度);记 unresolved
unresolved.push(ref);
} }
return { sourceUnits: [...sourceUnits], clinicIds: [...clinicIds] }; // fail-closed:品牌制租户却没解析出任何品牌 → 绝不当"不限品牌",塞哨兵 → 患者查询空。
if (branded && sourceUnits.size === 0) {
return { sourceUnits: [DENY_SOURCE_UNIT], clinicIds: [...clinicIds], unresolved };
}
return { sourceUnits: [...sourceUnits], clinicIds: [...clinicIds], unresolved };
} }
...@@ -24,12 +24,15 @@ export class OrgTreeService { ...@@ -24,12 +24,15 @@ export class OrgTreeService {
private readonly cache = new Map<string, { tree: OrgTree; at: number }>(); private readonly cache = new Map<string, { tree: OrgTree; at: number }>();
private readonly ttlMs = 10 * 60 * 1000; // 10min private readonly ttlMs = 10 * 60 * 1000; // 10min
private readonly refreshMinMs = 30 * 1000; // 强制重建防抖:30s 内不重复查(挡 bogus ref 刷库)
/** 取 host org 树(缓存;miss 时从摄入数据派生)。 */ /** 取 host org 树(缓存;miss/过期或 forceRefresh 时从摄入数据派生)。 */
async getTree(hostId: string, tenantId: string): Promise<OrgTree> { async getTree(hostId: string, tenantId: string, forceRefresh = false): Promise<OrgTree> {
const key = `${hostId}:${tenantId}`; const key = `${hostId}:${tenantId}`;
const hit = this.cache.get(key); const hit = this.cache.get(key);
if (hit && Date.now() - hit.at < this.ttlMs) return hit.tree; const age = hit ? Date.now() - hit.at : Infinity;
if (hit && !forceRefresh && age < this.ttlMs) return hit.tree;
if (hit && forceRefresh && age < this.refreshMinMs) return hit.tree; // 防抖
// 从摄入数据派生「品牌(source_unit)→ 诊所(clinic_id)」: // 从摄入数据派生「品牌(source_unit)→ 诊所(clinic_id)」:
// clinic_id 立柱在 patient_transactions,source_unit 在 patients → join patient_id。 // clinic_id 立柱在 patient_transactions,source_unit 在 patients → join patient_id。
...@@ -59,6 +62,12 @@ export class OrgTreeService { ...@@ -59,6 +62,12 @@ export class OrgTreeService {
tenantId: string, tenantId: string,
orgScope: string[], orgScope: string[],
): Promise<ExpandedScope> { ): Promise<ExpandedScope> {
return expandOrgScope(await this.getTree(hostId, tenantId), orgScope); let r = expandOrgScope(await this.getTree(hostId, tenantId), orgScope);
// 有 ref 树里找不到 → 树缓存可能陈旧(新诊所/品牌刚摄入),强制重派生一次再展开,
// 既避免误拒合法新诊所用户,也避免陈旧退化树把品牌制用户 fail-open(见 org-scope 的 DENY)。
if (r.unresolved.length > 0) {
r = expandOrgScope(await this.getTree(hostId, tenantId, true), orgScope);
}
return { sourceUnits: r.sourceUnits, clinicIds: r.clinicIds };
} }
} }
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment