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}