Skip to main content

rust_store_core/
permission.rs

1//! 权限引擎(对应 JS `src/permission.js`)
2//!
3//! 与 JS 版的差异:`AsyncLocalStorage` 隐式上下文改为**显式 `Context` 入参**。
4//!
5//! ## 信任模型(fail-open 契约)
6//!
7//! `ctx: None` = 「系统内部调用,跳过权限检查」——与 JS 原版 `if (ctx)` 语义对齐,
8//! **默认 fail-open**(调用方忘传 ctx 不会报错而是放行)。对安全敏感的宿主应:
9//!
10//! 1. 开启 [`crate::schema::Registry::set_require_context(true)`]:此后 `ctx` 缺失
11//!    在所有 plan 入口显式报错(fail-secure opt-in,默认关闭以保持三端 parity);
12//! 2. 内部调用(索引创建、归档回填等)显式传 [`Context::system`],
13//!    与「忘传 ctx」在语义上彻底分离。
14
15use std::collections::HashSet;
16
17use serde_json::{json, Map, Value};
18
19use crate::schema::Schema;
20use crate::types::{is_truthy, str_list};
21
22#[derive(Debug, Clone, Default)]
23pub struct Context {
24    pub user_id: Option<String>,
25    pub roles: Option<Vec<String>>,
26    pub role: Option<String>,
27    pub internal: bool,
28}
29
30impl Context {
31    /// 系统内部调用上下文:`internal: true` → 权限引擎全放行、不注入 owner 条件。
32    ///
33    /// 供 Host 的内部路径(索引创建、归档回填、后台任务等)显式表达「这是系统调用」,
34    /// 与 `None`(调用方未传上下文,`require_context` 开启时会报错)区分开。
35    pub fn system() -> Self {
36        Self {
37            user_id: None,
38            roles: None,
39            role: None,
40            internal: true,
41        }
42    }
43}
44
45/// 从 fixture/请求的 JSON 上下文构建 `Context`;null/非对象 → None(等价 JS 的 undefined)
46pub fn context_from_value(v: &Value) -> Option<Context> {
47    if v.is_null() {
48        return None;
49    }
50    let o = v.as_object()?;
51    Some(Context {
52        user_id: o.get("userId").and_then(|x| x.as_str()).map(String::from),
53        roles: str_list(o.get("roles")),
54        role: o.get("role").and_then(|x| x.as_str()).map(String::from),
55        internal: o.get("internal").map(is_truthy).unwrap_or(false),
56    })
57}
58
59/// doc 语义(用于 creator 伪角色):
60/// `Missing` → 新插入(无已有文档,creator 自动通过)
61/// `NoResult` → 查询无结果(creator 不通过)
62/// `Doc` → 已有文档
63#[derive(Debug, Clone, Copy)]
64pub enum Doc<'a> {
65    Missing,
66    NoResult,
67    Doc(&'a Value),
68}
69
70/// schema 白名单缺失/为空时的默认姿态(设计 §11.2)。
71/// Open = 现状放行语义(guest 读拒已随清单化移除);Closed = fail-secure(未配置模型读写全拒)。
72#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
73pub enum UnconfiguredPolicy {
74    #[default]
75    Open,
76    Closed,
77}
78
79impl UnconfiguredPolicy {
80    /// 字符串 → 姿态;未知值 **Err**(禁静默回落 Open,对齐 `Profile::from_str_or_err`)
81    pub fn from_str_or_err(s: &str) -> Result<Self, String> {
82        match s {
83            "open" => Ok(UnconfiguredPolicy::Open),
84            "closed" => Ok(UnconfiguredPolicy::Closed),
85            other => Err(format!("未知 unconfigured_policy: {other}(仅 open / closed)")),
86        }
87    }
88    pub fn as_str(&self) -> &'static str {
89        match self {
90            UnconfiguredPolicy::Open => "open",
91            UnconfiguredPolicy::Closed => "closed",
92        }
93    }
94}
95
96/// 角色清单与未配置姿态(判决的全部用户配置输入,无任何隐藏项——设计 §11.3)。
97/// 默认值即「无豁免 / 无拒写 / Open」:清单化后判决 = 白名单 ∧ RBAC 策略 ∧ 本规则。
98#[derive(Debug, Clone, Default)]
99pub struct RoleRules {
100    /// 豁免角色清单:命中者在一切判决环节(静态 + RBAC)直接放行。默认空。
101    pub exempt_roles: Vec<String>,
102    /// 拒写角色清单:命中者一切写路径拒绝(读不受影响)。默认空。
103    pub deny_write_roles: Vec<String>,
104    /// 未配置姿态。默认 Open。
105    pub unconfigured: UnconfiguredPolicy,
106}
107
108/// 评估当前用户是否满足指定角色白名单。
109///
110/// 判决输入全部来自 `rules`(用户配置,默认 []/[]/Open),无任何内置角色硬编码:
111/// - 豁免:有效角色 ∩ `rules.exempt_roles` ≠ ∅ → true(默认空 = 无豁免)。豁免检查
112///   **先于**未配置姿态——设计 §11.3「一切判决环节直通」:`set_exempt_roles` 与
113///   `Closed` 并存时豁免仍生效(豁免是显式信任声明,姿态管不住它);
114/// - 未配置(role_list 缺失/空):`Open` 放行(guest 读拒已随清单化移除——迁移差异,
115///   设计 §11.4);`Closed` 对非内部用户全拒(fail-secure);ctx=None 与 internal
116///   的直通不受姿态影响(无 ctx 维度归 `require_context` 管,设计 §11.6)。
117pub fn evaluate(
118    rules: &RoleRules,
119    ctx: Option<&Context>,
120    role_list: Option<&[String]>,
121    doc: Doc,
122) -> bool {
123    let Some(c) = ctx else { return true };
124    if c.internal {
125        return true;
126    }
127    // 豁免清单(默认空——无隐形豁免;清单化语义见设计 §11.3)
128    if has_any(c, &rules.exempt_roles) {
129        return true;
130    }
131    let empty = role_list.map(|r| r.is_empty()).unwrap_or(true);
132    if empty {
133        // 未配置姿态显式化(原 empty 分支的 guest 读拒随清单化移除)
134        return rules.unconfigured == UnconfiguredPolicy::Open;
135    }
136
137    // ① 角色匹配
138    let effective = effective(c);
139    // `empty` 已早退,此处 role_list 必为 Some 且非空
140    let Some(list) = role_list else { return true };
141    if list.iter().any(|r| effective.iter().any(|x| x == r)) {
142        return true;
143    }
144
145    // ② 创作者匹配
146    match_creator(c, doc, list)
147}
148
149fn match_creator(ctx: &Context, doc: Doc, role_list: &[String]) -> bool {
150    if !role_list.iter().any(|r| r == "creator") {
151        return false;
152    }
153    match doc {
154        Doc::Missing => true,
155        Doc::NoResult => false,
156        Doc::Doc(d) => {
157            let uid = ctx.user_id.clone().unwrap_or_default();
158            if uid.is_empty() {
159                return false;
160            }
161            d.get("createdBy")
162                .and_then(|v| v.as_str())
163                .map(|cb| cb == uid)
164                .unwrap_or(false)
165        }
166    }
167}
168
169// ─── 字段级过滤(读) ─────────────────────────────────────────
170
171/// ctx 为 None 时返回 None(= 不做裁剪)
172pub fn get_readable_fields(
173    rules: &RoleRules,
174    schema: &Schema,
175    ctx: Option<&Context>,
176) -> Option<HashSet<String>> {
177    let c = ctx?;
178    let mut allowed = HashSet::new();
179    for (key, field) in &schema.fields {
180        match &field.read {
181            Some(rl) => {
182                if evaluate(rules, Some(c), Some(rl), Doc::Missing) {
183                    allowed.insert(key.clone());
184                }
185            }
186            None => {
187                allowed.insert(key.clone());
188            }
189        }
190    }
191    Some(allowed)
192}
193
194pub fn get_readable_computes(
195    rules: &RoleRules,
196    schema: &Schema,
197    ctx: Option<&Context>,
198) -> Option<HashSet<String>> {
199    let c = ctx?;
200    let mut allowed = HashSet::new();
201    for (key, comp) in &schema.computes {
202        match &comp.read {
203            Some(rl) => {
204                if evaluate(rules, Some(c), Some(rl), Doc::Missing) {
205                    allowed.insert(key.clone());
206                }
207            }
208            None => {
209                allowed.insert(key.clone());
210            }
211        }
212    }
213    Some(allowed)
214}
215
216pub fn get_readable_relations(
217    rules: &RoleRules,
218    schema: &Schema,
219    ctx: Option<&Context>,
220) -> Option<HashSet<String>> {
221    let c = ctx?;
222    let mut allowed = HashSet::new();
223    for (key, rel) in &schema.relations {
224        match &rel.read {
225            Some(rl) => {
226                if evaluate(rules, Some(c), Some(rl), Doc::Missing) {
227                    allowed.insert(key.clone());
228                }
229            }
230            None => {
231                allowed.insert(key.clone());
232            }
233        }
234    }
235    Some(allowed)
236}
237
238/// 单个字段读权限判定(点号路径按 root 字段判定)。
239///
240/// - `ctx = None` → 放行(fail-open,与默认姿态一致);
241/// - 字段未在 schema `fields` 中声明 → 放行(无 `field.read` 配置可依);
242/// - 字段在 schema 中且配了 `read` 白名单 → 按 [`get_readable_fields`] 判定。
243pub fn is_field_readable(
244    rules: &RoleRules,
245    schema: &Schema,
246    ctx: Option<&Context>,
247    field: &str,
248) -> bool {
249    let root = field.split('.').next().unwrap_or(field);
250    if !schema.fields.contains_key(root) {
251        return true;
252    }
253    match get_readable_fields(rules, schema, ctx) {
254        None => true,
255        Some(allowed) => allowed.contains(root),
256    }
257}
258
259/// 单个关系读权限判定(R6/L1)。`ctx = None` → 放行(fail-open)。
260pub fn is_relation_readable(
261    rules: &RoleRules,
262    schema: &Schema,
263    ctx: Option<&Context>,
264    rel: &str,
265) -> bool {
266    match get_readable_relations(rules, schema, ctx) {
267        None => true,
268        Some(allowed) => allowed.contains(rel),
269    }
270}
271
272// ─── Schema 级检查 ────────────────────────────────────────────
273
274pub fn can_read_schema(rules: &RoleRules, schema: &Schema, ctx: Option<&Context>) -> bool {
275    evaluate(rules, ctx, schema.read.as_deref(), Doc::Missing)
276}
277
278/// 拒写清单命中者一切写路径拒绝(读不受影响,默认空——无拒写);
279/// R2 语义保留:write 显式配置为空白名单(`[]`)= 未声明任何可写角色 → 拒绝一切写,
280/// 豁免清单命中者与 internal 例外。
281pub fn can_write_schema(rules: &RoleRules, schema: &Schema, ctx: Option<&Context>) -> bool {
282    if let Some(c) = ctx {
283        if has_any(c, &rules.deny_write_roles) {
284            return false;
285        }
286        if !c.internal && matches!(schema.write, Some(ref w) if w.is_empty()) {
287            if !has_any(c, &rules.exempt_roles) {
288                return false;
289            }
290        }
291    }
292    evaluate(rules, ctx, schema.write.as_deref(), Doc::Missing)
293}
294
295fn has_role(ctx: &Context, role: &str) -> bool {
296    ctx.roles
297        .as_ref()
298        .map(|r| r.iter().any(|x| x == role))
299        .unwrap_or(false)
300}
301
302/// 有效角色集:`roles` 非空用之,否则回落单 `role`——对齐 `rbac::effective_roles`
303/// (rbac.rs)与白名单匹配语义。清单命中一律用本函数(不再存在
304/// 「roles 为空则清单失明」的隐形差异)。
305fn effective(c: &Context) -> Vec<String> {
306    match &c.roles {
307        Some(r) if !r.is_empty() => r.clone(),
308        _ => vec![c.role.clone().unwrap_or_default()],
309    }
310}
311
312/// 有效角色集与清单是否有交集
313fn has_any(c: &Context, list: &[String]) -> bool {
314    effective(c).iter().any(|r| list.iter().any(|x| x == r))
315}
316
317/// [`has_any`] 的 crate 内公开包装:command 层写路径复用,避免各调用点手写 effective 归一
318pub(crate) fn has_any_role(c: &Context, list: &[String]) -> bool {
319    has_any(c, list)
320}
321
322// ─── 所有者条件注入 ──────────────────────────────────────────
323
324pub fn should_inject_owner_condition(
325    rules: &RoleRules,
326    schema: &Schema,
327    ctx: Option<&Context>,
328) -> bool {
329    let Some(c) = ctx else { return false };
330    // JS `!ctx.userId`:空串同样视为缺席
331    if c.user_id.as_deref().map(|s| s.is_empty()).unwrap_or(true) {
332        return false;
333    }
334    if c.internal {
335        return false;
336    }
337    if has_any(c, &rules.exempt_roles) {
338        return false;
339    }
340    let effective = effective(c);
341    let read = schema.read.clone().unwrap_or_default();
342    let real_roles: Vec<&String> = read.iter().filter(|r| *r != "creator").collect();
343    if real_roles.iter().any(|r| effective.iter().any(|x| x == *r)) {
344        return false;
345    }
346    read.iter().any(|r| r == "creator")
347}
348
349/// 非 admin 用户只看自己的数据:`creator` 专属读权限时注入 `createdBy` 条件
350pub fn merge_owner_condition(
351    rules: &RoleRules,
352    schema: &Schema,
353    ctx: Option<&Context>,
354    condition: Option<Value>,
355) -> Option<Value> {
356    if !should_inject_owner_condition(rules, schema, ctx) {
357        return condition;
358    }
359    let owner = json!({ "createdBy": ctx?.user_id });
360    match condition.filter(is_truthy) {
361        None => Some(owner),
362        Some(c) => Some(json!({ "$and": [c, owner] })),
363    }
364}
365
366// ─── 字段级过滤(写) ─────────────────────────────────────────
367
368pub fn get_writable_fields(
369    rules: &RoleRules,
370    schema: &Schema,
371    ctx: Option<&Context>,
372) -> Option<HashSet<String>> {
373    let c = ctx?;
374    let schema_writable = can_write_schema(rules, schema, ctx);
375    let mut allowed = HashSet::new();
376    for (key, field) in &schema.fields {
377        match &field.write {
378            Some(wl) => {
379                if evaluate(rules, Some(c), Some(wl), Doc::Missing) {
380                    allowed.insert(key.clone());
381                }
382            }
383            None => {
384                if schema_writable {
385                    allowed.insert(key.clone());
386                }
387            }
388        }
389    }
390    Some(allowed)
391}
392
393/// 过滤写入数据:只保留当前用户可写的字段(点号路径按 root 字段检查)
394pub fn filter_writable_data(
395    rules: &RoleRules,
396    schema: &Schema,
397    ctx: Option<&Context>,
398    data: &Value,
399) -> Value {
400    let Some(writable) = get_writable_fields(rules, schema, ctx) else {
401        return data.clone();
402    };
403    let Some(obj) = data.as_object() else {
404        return data.clone();
405    };
406    let mut result = Map::new();
407    for (key, val) in obj {
408        let root = key.split('.').next().unwrap_or(key);
409        // `_id` 是文档标识而非普通数据字段,豁免写权限过滤(D-03:否则
410        // 「显式提供 _id」路径在有权限过滤的上下文中永远不可达)
411        if key == "_id" || writable.contains(root) {
412            result.insert(key.clone(), val.clone());
413        }
414    }
415    Value::Object(result)
416}