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