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