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//! Version 2 is written only when a write scope is present; version 1 is kept
20//! for forward-compat honesty — a v0.2 server can load v1 safely and the
21//! `write` field (absent from v1) is ignored by serde's `#[serde(default)]`
22//! when a v2 sidecar is loaded by an older binary.
23//! Files are written atomically (temp → fsync → rename → dir-sync); a no-change
24//! re-apply leaves the file byte-identical.
25
26use serde::{Deserialize, Serialize};
27
28/// Write permissions granted to a role.
29///
30/// All fields default to empty (absent from JSON = no write permission for that
31/// operation). `write: None` on `RoleDef` is equivalent to all fields empty —
32/// the role is read-only, identical to v0.2 behavior.
33///
34/// Subset rule (enforced at `apply_schema` time):
35/// - `create_labels`, `update_labels`, and `delete_labels` must each be a
36/// subset of the role's read `labels`.
37/// - `create_edge_types` and `delete_edge_types` have no subset requirement
38/// (edge types are not read-scoped).
39#[derive(Serialize, Deserialize, Clone, PartialEq, Debug, Default)]
40pub struct WriteScope {
41 /// Labels the role may CREATE nodes under.
42 #[serde(default)]
43 pub create_labels: Vec<String>,
44 /// Labels whose nodes the role may SET properties on or MERGE.
45 /// Only nodes already in the role's read mask are reachable.
46 #[serde(default)]
47 pub update_labels: Vec<String>,
48 /// Labels whose nodes the role may DELETE (DETACH DELETE included).
49 #[serde(default)]
50 pub delete_labels: Vec<String>,
51 /// Edge types the role may insert via INSERT EDGE / Cypher CREATE
52 /// or /ingest edges field. Both endpoints must be read-visible.
53 #[serde(default)]
54 pub create_edge_types: Vec<String>,
55 /// Edge types the role may DELETE (user-owned edges only; derived
56 /// edges cannot be directly deleted by any token, including Full).
57 #[serde(default)]
58 pub delete_edge_types: Vec<String>,
59}
60
61/// A named RBAC role: resolves to a node-visibility mask at query time.
62///
63/// `keys` and `labels` both default to empty when absent from JSON, so a
64/// schema snippet that names only labels is valid.
65///
66/// The resolved mask is the union of:
67/// - all nodes whose key appears in `keys` (unknown keys silently ignored), and
68/// - all nodes carrying any label in `labels` (resolved live against the current
69/// graph — new nodes of an allowed label are immediately visible without
70/// re-applying the schema).
71///
72/// An empty union (no keys, no matching label nodes) = empty mask = sees nothing.
73///
74/// `write: None` (or absent from JSON) = read-only role, v1 behavior, backward
75/// compatible with any client that does not know about write scopes.
76#[derive(Serialize, Deserialize, Clone, PartialEq, Debug)]
77pub struct RoleDef {
78 pub name: String,
79 /// Explicit node keys always visible to the role.
80 #[serde(default)]
81 pub keys: Vec<String>,
82 /// All nodes carrying any of these labels are visible (resolved live).
83 #[serde(default)]
84 pub labels: Vec<String>,
85 /// Absent or null = read-only role (v1 behavior, backward compatible).
86 #[serde(default)]
87 pub write: Option<WriteScope>,
88}
89
90/// On-disk wrapper for `roles.json`. Version field allows future format bumps.
91///
92/// Version 1: no write scopes (all roles read-only, v0.2 compatible).
93/// Version 2: at least one role carries a `write` field.
94/// Version >2: unrecognised — roles state is poisoned on load.
95#[derive(Serialize, Deserialize)]
96pub(crate) struct RolesFile {
97 pub version: u32,
98 pub roles: Vec<RoleDef>,
99}
100
101impl RolesFile {
102 /// Build a `RolesFile` choosing the correct version automatically.
103 ///
104 /// Writes version 2 iff any role carries a write scope; otherwise writes
105 /// version 1. This preserves forward-compatibility: a v0.2 server loading
106 /// a v1 sidecar sees no behavioral change, and a v0.2 server loading a v2
107 /// sidecar silently ignores the `write` field (serde default) and treats
108 /// all roles as read-only — safe because v0.2 denies all writes from role
109 /// tokens anyway.
110 pub(crate) fn new_versioned(roles: Vec<RoleDef>) -> Self {
111 let version = if roles.iter().any(|r| r.write.is_some()) {
112 2
113 } else {
114 1
115 };
116 RolesFile { version, roles }
117 }
118
119 #[cfg(test)]
120 #[allow(dead_code)]
121 pub(crate) fn v1(roles: Vec<RoleDef>) -> Self {
122 RolesFile { version: 1, roles }
123 }
124}