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