Skip to main content

core_api/
roles.rs

1//! RBAC role definitions and sidecar I/O.
2//!
3//! [`RoleDef`] is the public unit of role configuration. Roles are declared in
4//! [`Schema::roles`](crate::schema::Schema) and persisted as `roles.json` in
5//! the database directory via [`GraphDb::apply_schema`].
6//!
7//! # Never-widen rule
8//!
9//! - Empty role (no keys, no labels) = empty mask = sees nothing.
10//! - Unknown role on a request = `Err` (never silently grant full access).
11//! - Corrupt `roles.json` at open = roles poisoned; [`GraphDb::mask_for_role`]
12//!   returns `Err` for any role name until the file is fixed and the DB
13//!   re-opened.
14//!
15//! # Persistence
16//!
17//! `roles.json` format: `{ "version": 1, "roles": [...] }` (no write scopes)
18//! or `{ "version": 2, "roles": [...] }` (at least one role has a write scope)
19//! or `{ "version": 3, "roles": [...] }` (at least one role has a
20//! [`visible_where`](RoleDef::visible_where) predicate).
21//! The highest applicable version is written and no higher: version 2 is
22//! written only when a write scope is present, version 3 only when a predicate
23//! is. Version 1 is kept for forward-compat honesty — a v0.2 server can load v1
24//! safely and the `write` field (absent from v1) is ignored by serde's
25//! `#[serde(default)]` when a v2 sidecar is loaded by an older binary.
26//! Version 3 is deliberately *not* loadable by an older binary: a binary that
27//! does not know `visible_where` would resolve a narrowed role to its full
28//! label set, so an unrecognised version poisons instead, which denies rather
29//! than over-grants.
30//! Files are written atomically (temp → fsync → rename → dir-sync); a no-change
31//! re-apply leaves the file byte-identical.
32
33use core_storage::Value;
34use serde::{Deserialize, Serialize};
35
36/// Write permissions granted to a role.
37///
38/// All fields default to empty (absent from JSON = no write permission for that
39/// operation). `write: None` on `RoleDef` is equivalent to all fields empty —
40/// the role is read-only, identical to v0.2 behavior.
41///
42/// Subset rule (enforced at `apply_schema` time):
43/// - `create_labels`, `update_labels`, and `delete_labels` must each be a
44///   subset of the role's read `labels`.
45/// - `create_edge_types` and `delete_edge_types` have no subset requirement
46///   (edge types are not read-scoped).
47#[derive(Serialize, Deserialize, Clone, PartialEq, Debug, Default)]
48pub struct WriteScope {
49    /// Labels the role may CREATE nodes under.
50    #[serde(default)]
51    pub create_labels: Vec<String>,
52    /// Labels whose nodes the role may SET properties on or MERGE.
53    /// Only nodes already in the role's read mask are reachable.
54    #[serde(default)]
55    pub update_labels: Vec<String>,
56    /// Labels whose nodes the role may DELETE (DETACH DELETE included).
57    #[serde(default)]
58    pub delete_labels: Vec<String>,
59    /// Edge types the role may insert via INSERT EDGE / Cypher CREATE
60    /// or /ingest edges field.  Both endpoints must be read-visible.
61    #[serde(default)]
62    pub create_edge_types: Vec<String>,
63    /// Edge types the role may DELETE (user-owned edges only; derived
64    /// edges cannot be directly deleted by any token, including Full).
65    #[serde(default)]
66    pub delete_edge_types: Vec<String>,
67}
68
69/// One property test a role's visibility may carry, beside `labels`.
70///
71/// Equality and membership only: no ranges, no negation, no nesting. A mask
72/// that can express arbitrary predicates is a query language with a security
73/// boundary attached — every operator added is another shape the resolver has
74/// to be right about, on the deny side, forever. Two operators are enough for
75/// the case that motivates them (`status in ["published"]`) and small enough to
76/// be obviously correct.
77///
78/// Exactly one of `eq` and `in` is set; `validate` enforces it.
79///
80/// # Value shapes accepted on the way in
81///
82/// A predicate is usually hand-written, so both spellings of a value parse:
83/// the plain JSON scalar (`"published"`, `3`, `true`) and the tagged form the
84/// graph's own [`Value`] serializes as (`{"Str": "published"}`, `{"Int": 3}`).
85/// They mean the same thing. Serialization always writes the tagged form, so a
86/// sidecar this binary rewrote is unambiguous no matter which one was typed.
87#[derive(Serialize, Deserialize, Clone, PartialEq, Debug)]
88pub struct PropPredicate {
89    /// The property to test. Never empty.
90    pub field: String,
91    /// Single-value form: the property must equal this value.
92    #[serde(
93        default,
94        deserialize_with = "de_value_opt",
95        skip_serializing_if = "Option::is_none"
96    )]
97    pub eq: Option<Value>,
98    /// Membership form: the property must equal one of these values. An empty
99    /// list matches nothing — it is a valid, fully-closed predicate.
100    #[serde(
101        default,
102        rename = "in",
103        deserialize_with = "de_value_vec_opt",
104        skip_serializing_if = "Option::is_none"
105    )]
106    pub in_: Option<Vec<Value>>,
107}
108
109/// Read one predicate value, accepting the tagged form or a plain JSON scalar.
110///
111/// The tagged form is tried first, so `{"Str": "x"}` never falls through to the
112/// scalar branch and is never mistaken for a map-valued property.
113fn value_from_json(j: serde_json::Value) -> std::result::Result<Value, String> {
114    if let Ok(v) = serde_json::from_value::<Value>(j.clone()) {
115        return Ok(v);
116    }
117    match j {
118        serde_json::Value::String(s) => Ok(Value::Str(s)),
119        serde_json::Value::Bool(b) => Ok(Value::Bool(b)),
120        serde_json::Value::Number(n) => n
121            .as_i64()
122            .map(Value::Int)
123            .or_else(|| n.as_f64().map(Value::Float))
124            .ok_or_else(|| format!("visible_where: {n} is not a representable number")),
125        other => Err(format!(
126            "visible_where: {other} is not a value — use a string, number or boolean, \
127             or the tagged form such as {{\"Str\": \"published\"}}"
128        )),
129    }
130}
131
132fn de_value_opt<'de, D>(d: D) -> std::result::Result<Option<Value>, D::Error>
133where
134    D: serde::Deserializer<'de>,
135{
136    match Option::<serde_json::Value>::deserialize(d)? {
137        None => Ok(None),
138        Some(j) => value_from_json(j)
139            .map(Some)
140            .map_err(serde::de::Error::custom),
141    }
142}
143
144fn de_value_vec_opt<'de, D>(d: D) -> std::result::Result<Option<Vec<Value>>, D::Error>
145where
146    D: serde::Deserializer<'de>,
147{
148    match Option::<Vec<serde_json::Value>>::deserialize(d)? {
149        None => Ok(None),
150        // Element-wise, so one list may mix the two spellings.
151        Some(items) => items
152            .into_iter()
153            .map(value_from_json)
154            .collect::<std::result::Result<Vec<_>, _>>()
155            .map(Some)
156            .map_err(serde::de::Error::custom),
157    }
158}
159
160impl PropPredicate {
161    /// Test one node's property value against the predicate.
162    ///
163    /// `visible = keys ∪ { n : label(n) ∈ labels ∧ holds(n) }`.
164    ///
165    /// A missing property does **not** hold: absent is not a match. A node that
166    /// never carried the field is outside a narrowed role, which is the
167    /// deny-side answer — a role narrowed to `status in ["published"]` must not
168    /// see a document that has no status at all.
169    pub fn holds(&self, value: Option<&Value>) -> bool {
170        let Some(value) = value else {
171            return false;
172        };
173        match (&self.eq, &self.in_) {
174            (Some(expected), None) => value == expected,
175            (None, Some(allowed)) => allowed.iter().any(|a| a == value),
176            // Neither or both is refused by `validate`; hold nothing if a
177            // hand-edited sidecar slips one through.
178            _ => false,
179        }
180    }
181
182    /// Reject a predicate that does not name exactly one test of one field.
183    pub fn validate(&self) -> std::result::Result<(), String> {
184        if self.field.is_empty() {
185            return Err("visible_where.field must not be empty".into());
186        }
187        match (&self.eq, &self.in_) {
188            (Some(_), None) | (None, Some(_)) => Ok(()),
189            (None, None) => Err(format!(
190                "visible_where on field '{}' sets neither 'eq' nor 'in'",
191                self.field
192            )),
193            (Some(_), Some(_)) => Err(format!(
194                "visible_where on field '{}' sets both 'eq' and 'in'; use one",
195                self.field
196            )),
197        }
198    }
199}
200
201/// A named RBAC role: resolves to a node-visibility mask at query time.
202///
203/// `keys` and `labels` both default to empty when absent from JSON, so a
204/// schema snippet that names only labels is valid.
205///
206/// The resolved mask is the union of:
207/// - all nodes whose key appears in `keys` (unknown keys silently ignored), and
208/// - all nodes carrying any label in `labels` (resolved live against the current
209///   graph — new nodes of an allowed label are immediately visible without
210///   re-applying the schema).
211///
212/// An empty union (no keys, no matching label nodes) = empty mask = sees nothing.
213///
214/// `write: None` (or absent from JSON) = read-only role, v1 behavior, backward
215/// compatible with any client that does not know about write scopes.
216#[derive(Serialize, Deserialize, Clone, PartialEq, Debug)]
217pub struct RoleDef {
218    pub name: String,
219    /// Explicit node keys always visible to the role.
220    #[serde(default)]
221    pub keys: Vec<String>,
222    /// All nodes carrying any of these labels are visible (resolved live).
223    #[serde(default)]
224    pub labels: Vec<String>,
225    /// Optional property test that narrows the **label leg only**.
226    ///
227    /// Absent = the role is exactly what it was before version 3: every node of
228    /// an allowed label. Present = a node of an allowed label is visible only
229    /// when it also passes the predicate. `keys` is an administrative grant and
230    /// is never narrowed by it.
231    ///
232    /// A predicate with no labels is refused at `apply_schema`: it would narrow
233    /// nothing, and silently granting the key leg under a name that reads like
234    /// a restriction is the wrong way to be wrong.
235    #[serde(default, skip_serializing_if = "Option::is_none")]
236    pub visible_where: Option<PropPredicate>,
237    /// Absent or null = read-only role (v1 behavior, backward compatible).
238    #[serde(default)]
239    pub write: Option<WriteScope>,
240}
241
242/// On-disk wrapper for `roles.json`.  Version field allows future format bumps.
243///
244/// Version 1: no write scopes (all roles read-only, v0.2 compatible).
245/// Version 2: at least one role carries a `write` field.
246/// Version 3: at least one role carries a `visible_where` predicate.
247/// Version >3: unrecognised — roles state is poisoned on load.
248#[derive(Serialize, Deserialize)]
249pub(crate) struct RolesFile {
250    pub version: u32,
251    pub roles: Vec<RoleDef>,
252}
253
254impl RolesFile {
255    /// Build a `RolesFile` choosing the correct version automatically.
256    ///
257    /// Picks 3 > 2 > 1, the highest the content actually needs: version 3 iff
258    /// any role carries a `visible_where` predicate, else version 2 iff any
259    /// role carries a write scope, else version 1. This preserves
260    /// forward-compatibility where it is safe to: a v0.2 server loading a v1
261    /// sidecar sees no behavioral change, and a v0.2 server loading a v2
262    /// sidecar silently ignores the `write` field (serde default) and treats
263    /// all roles as read-only — safe because v0.2 denies all writes from role
264    /// tokens anyway.
265    ///
266    /// A predicate is different: an older binary ignoring `visible_where` would
267    /// resolve a narrowed role to its whole label set, which widens. Version 3
268    /// is therefore unrecognised by every binary that predates it, and an
269    /// unrecognised version poisons the roles state rather than loading it.
270    pub(crate) fn new_versioned(roles: Vec<RoleDef>) -> Self {
271        let version = if roles.iter().any(|r| r.visible_where.is_some()) {
272            3
273        } else if roles.iter().any(|r| r.write.is_some()) {
274            2
275        } else {
276            1
277        };
278        RolesFile { version, roles }
279    }
280
281    #[cfg(test)]
282    #[allow(dead_code)]
283    pub(crate) fn v1(roles: Vec<RoleDef>) -> Self {
284        RolesFile { version: 1, roles }
285    }
286}