Skip to main content

grafeo_engine/
auth.rs

1//! Role-based access control for Grafeo sessions.
2//!
3//! This module provides [`Identity`], [`Role`], and [`StatementKind`] types
4//! that let callers scope sessions to specific permission levels. The caller
5//! is trusted to assign the correct role: there are no credentials or
6//! cryptographic verification at this layer.
7//!
8//! # Roles
9//!
10//! Roles follow a hierarchy: [`Role::Admin`] implies [`Role::ReadWrite`]
11//! implies [`Role::ReadOnly`]. Permission checks use the convenience methods
12//! on [`Identity`] (`can_read`, `can_write`, `can_admin`) which respect this
13//! hierarchy.
14//!
15//! # Usage
16//!
17//! ```
18//! use grafeo_engine::auth::{Identity, Role};
19//! use grafeo_engine::GrafeoDB;
20//!
21//! let db = GrafeoDB::new_in_memory();
22//!
23//! // Anonymous session (full access, backward compatible)
24//! let admin_session = db.session();
25//!
26//! // Scoped session by role
27//! let reader = db.session_with_role(Role::ReadOnly);
28//!
29//! // Scoped session with full identity
30//! let identity = Identity::new("app-service", [Role::ReadWrite]);
31//! let writer = db.session_with_identity(identity);
32//! ```
33
34use std::collections::HashSet;
35use std::fmt;
36
37/// A per-graph access grant.
38///
39/// When an identity has grants, it can only access the listed graphs at the
40/// specified role level. An identity with no grants has unrestricted access
41/// (governed only by its top-level roles).
42#[derive(Debug, Clone, PartialEq, Eq, Hash)]
43pub struct Grant {
44    /// The graph name this grant applies to.
45    pub graph: String,
46    /// The maximum role level for this graph.
47    pub role: Role,
48}
49
50impl Grant {
51    /// Creates a new grant for the given graph and role.
52    #[must_use]
53    pub fn new(graph: impl Into<String>, role: Role) -> Self {
54        Self {
55            graph: graph.into(),
56            role,
57        }
58    }
59}
60
61/// A verified identity bound to a session.
62///
63/// Created by the caller (typically a server or application layer) and
64/// passed to [`GrafeoDB::session_with_identity`](crate::GrafeoDB::session_with_identity).
65/// The engine trusts the caller to construct the identity correctly.
66#[derive(Debug, Clone)]
67pub struct Identity {
68    /// Unique user identifier (e.g. "admin", "app-service-1", "anonymous").
69    user_id: String,
70    /// Roles assigned to this identity.
71    roles: HashSet<Role>,
72    /// Per-graph access grants. Empty means unrestricted (all graphs accessible
73    /// at the identity's role level).
74    grants: Vec<Grant>,
75}
76
77impl Identity {
78    /// Creates a new identity with the given user ID and roles.
79    #[must_use]
80    pub fn new(user_id: impl Into<String>, roles: impl IntoIterator<Item = Role>) -> Self {
81        Self {
82            user_id: user_id.into(),
83            roles: roles.into_iter().collect(),
84            grants: Vec::new(),
85        }
86    }
87
88    /// Creates an anonymous identity with full access.
89    ///
90    /// Used internally when no identity is provided (backward-compatible
91    /// default). Anonymous sessions have the [`Role::Admin`] role.
92    #[must_use]
93    pub fn anonymous() -> Self {
94        Self {
95            user_id: "anonymous".to_owned(),
96            roles: [Role::Admin].into_iter().collect(),
97            grants: Vec::new(),
98        }
99    }
100
101    /// Adds per-graph access grants to this identity.
102    ///
103    /// When grants are set, the identity can only access the listed graphs
104    /// at the specified role level. Graphs not in the grant list are
105    /// inaccessible regardless of the identity's top-level roles.
106    #[must_use]
107    pub fn with_grants(mut self, grants: impl IntoIterator<Item = Grant>) -> Self {
108        self.grants = grants.into_iter().collect();
109        self
110    }
111
112    /// Returns the user ID.
113    #[must_use]
114    pub fn user_id(&self) -> &str {
115        &self.user_id
116    }
117
118    /// Returns the roles assigned to this identity.
119    #[must_use]
120    pub fn roles(&self) -> &HashSet<Role> {
121        &self.roles
122    }
123
124    /// Returns true if this identity has the given role.
125    #[must_use]
126    pub fn has_role(&self, role: Role) -> bool {
127        self.roles.contains(&role)
128    }
129
130    /// Returns true if this identity can perform read operations.
131    ///
132    /// Any assigned role grants read access.
133    #[must_use]
134    pub fn can_read(&self) -> bool {
135        !self.roles.is_empty()
136    }
137
138    /// Returns true if this identity can perform write operations
139    /// (create/update/delete nodes and edges, graph management).
140    #[must_use]
141    pub fn can_write(&self) -> bool {
142        self.has_role(Role::Admin) || self.has_role(Role::ReadWrite)
143    }
144
145    /// Returns true if this identity can perform admin operations
146    /// (schema DDL, index management, GC, configuration changes).
147    #[must_use]
148    pub fn can_admin(&self) -> bool {
149        self.has_role(Role::Admin)
150    }
151
152    /// Returns the per-graph grants, if any.
153    #[must_use]
154    pub fn grants(&self) -> &[Grant] {
155        &self.grants
156    }
157
158    /// Returns true if this identity has per-graph restrictions.
159    #[must_use]
160    pub fn has_grants(&self) -> bool {
161        !self.grants.is_empty()
162    }
163
164    /// Checks whether this identity can access the given graph at the
165    /// required role level.
166    ///
167    /// If no grants are configured, access is governed only by the
168    /// identity's top-level roles. If grants are configured, the graph
169    /// must appear in the grant list with a sufficient role.
170    #[must_use]
171    pub fn can_access_graph(&self, graph: &str, required: Role) -> bool {
172        if self.grants.is_empty() {
173            // No per-graph restrictions: use top-level role check
174            return match required {
175                Role::ReadOnly => self.can_read(),
176                Role::ReadWrite => self.can_write(),
177                Role::Admin => self.can_admin(),
178            };
179        }
180        // Check if any grant covers this graph at the required level
181        self.grants.iter().any(|g| {
182            g.graph.eq_ignore_ascii_case(graph)
183                && match required {
184                    Role::ReadOnly => true, // Any grant implies read access
185                    Role::ReadWrite => g.role == Role::ReadWrite || g.role == Role::Admin,
186                    Role::Admin => g.role == Role::Admin,
187                }
188        })
189    }
190}
191
192impl fmt::Display for Identity {
193    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
194        write!(f, "{}", self.user_id)
195    }
196}
197
198/// Database-level roles.
199///
200/// Roles follow a hierarchy: `Admin` implies `ReadWrite` implies `ReadOnly`.
201/// Permission checks use the hierarchy via [`Identity::can_write`] and
202/// [`Identity::can_admin`], but roles are stored explicitly (not inherited)
203/// to keep the model simple and auditable.
204#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
205pub enum Role {
206    /// Full administrative access: schema DDL, index management, GC,
207    /// plus all read-write operations.
208    Admin,
209    /// Read and write data: create/update/delete nodes, edges, and
210    /// properties. Cannot modify schema or indexes.
211    ReadWrite,
212    /// Read-only access: MATCH queries, graph traversals, read-only
213    /// introspection (database stats, schema info).
214    ReadOnly,
215}
216
217impl fmt::Display for Role {
218    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
219        match self {
220            Self::Admin => write!(f, "Admin"),
221            Self::ReadWrite => write!(f, "ReadWrite"),
222            Self::ReadOnly => write!(f, "ReadOnly"),
223        }
224    }
225}
226
227/// Classification of a parsed statement for permission checking.
228///
229/// Determined after parsing but before execution. The session checks the
230/// caller's [`Identity`] against the statement kind and rejects operations
231/// that exceed the caller's role.
232#[derive(Debug, Clone, Copy, PartialEq, Eq)]
233pub enum StatementKind {
234    /// Read-only: MATCH, RETURN, WITH, UNWIND, CALL (read-only procedures).
235    Read,
236    /// Data mutation: CREATE, SET, DELETE, REMOVE, MERGE.
237    Write,
238    /// Schema/admin: CREATE TYPE, DROP TYPE, CREATE INDEX, DROP INDEX,
239    /// CREATE CONSTRAINT, DROP CONSTRAINT.
240    Admin,
241    /// Transaction control: START TRANSACTION, COMMIT, ROLLBACK, SAVEPOINT.
242    /// Always allowed regardless of role.
243    Transaction,
244}
245
246impl StatementKind {
247    /// Returns the minimum [`Role`] required for this statement kind.
248    ///
249    /// Returns `None` for [`StatementKind::Transaction`] (always allowed).
250    #[must_use]
251    pub fn required_role(self) -> Option<Role> {
252        match self {
253            Self::Read => Some(Role::ReadOnly),
254            Self::Write => Some(Role::ReadWrite),
255            Self::Admin => Some(Role::Admin),
256            Self::Transaction => None,
257        }
258    }
259}
260
261impl fmt::Display for StatementKind {
262    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
263        match self {
264            Self::Read => write!(f, "read"),
265            Self::Write => write!(f, "write"),
266            Self::Admin => write!(f, "admin"),
267            Self::Transaction => write!(f, "transaction control"),
268        }
269    }
270}
271
272/// Checks whether an identity is permitted to execute a statement of the
273/// given kind. Returns `Ok(())` on success, or an error describing the
274/// denial.
275///
276/// Transaction control statements are always permitted.
277pub(crate) fn check_permission(
278    identity: &Identity,
279    kind: StatementKind,
280) -> std::result::Result<(), PermissionDenied> {
281    match kind {
282        StatementKind::Transaction => Ok(()),
283        StatementKind::Read => {
284            if identity.can_read() {
285                Ok(())
286            } else {
287                Err(PermissionDenied {
288                    operation: kind,
289                    required: Role::ReadOnly,
290                    user_id: identity.user_id.clone(),
291                })
292            }
293        }
294        StatementKind::Write => {
295            if identity.can_write() {
296                Ok(())
297            } else {
298                Err(PermissionDenied {
299                    operation: kind,
300                    required: Role::ReadWrite,
301                    user_id: identity.user_id.clone(),
302                })
303            }
304        }
305        StatementKind::Admin => {
306            if identity.can_admin() {
307                Ok(())
308            } else {
309                Err(PermissionDenied {
310                    operation: kind,
311                    required: Role::Admin,
312                    user_id: identity.user_id.clone(),
313                })
314            }
315        }
316    }
317}
318
319/// Permission denied error with context about what was attempted.
320#[derive(Debug, Clone)]
321pub struct PermissionDenied {
322    /// What kind of statement was attempted.
323    pub operation: StatementKind,
324    /// The minimum role that would have been required.
325    pub required: Role,
326    /// The user who was denied.
327    pub user_id: String,
328}
329
330impl fmt::Display for PermissionDenied {
331    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
332        write!(
333            f,
334            "permission denied: {} operations require {} role (user: {})",
335            self.operation, self.required, self.user_id
336        )
337    }
338}
339
340impl std::error::Error for PermissionDenied {}
341
342#[cfg(test)]
343mod tests {
344    use super::*;
345
346    #[test]
347    fn anonymous_has_admin_role() {
348        let id = Identity::anonymous();
349        assert_eq!(id.user_id(), "anonymous");
350        assert!(id.has_role(Role::Admin));
351        assert!(id.can_read());
352        assert!(id.can_write());
353        assert!(id.can_admin());
354    }
355
356    #[test]
357    fn read_only_identity() {
358        let id = Identity::new("reader", [Role::ReadOnly]);
359        assert!(id.can_read());
360        assert!(!id.can_write());
361        assert!(!id.can_admin());
362    }
363
364    #[test]
365    fn read_write_identity() {
366        let id = Identity::new("writer", [Role::ReadWrite]);
367        assert!(id.can_read());
368        assert!(id.can_write());
369        assert!(!id.can_admin());
370    }
371
372    #[test]
373    fn admin_identity() {
374        let id = Identity::new("admin", [Role::Admin]);
375        assert!(id.can_read());
376        assert!(id.can_write());
377        assert!(id.can_admin());
378    }
379
380    #[test]
381    fn empty_roles_cannot_read() {
382        let id = Identity::new("nobody", std::iter::empty::<Role>());
383        assert!(!id.can_read());
384        assert!(!id.can_write());
385        assert!(!id.can_admin());
386    }
387
388    #[test]
389    fn role_display() {
390        assert_eq!(Role::Admin.to_string(), "Admin");
391        assert_eq!(Role::ReadWrite.to_string(), "ReadWrite");
392        assert_eq!(Role::ReadOnly.to_string(), "ReadOnly");
393    }
394
395    #[test]
396    fn statement_kind_required_role() {
397        assert_eq!(StatementKind::Read.required_role(), Some(Role::ReadOnly));
398        assert_eq!(StatementKind::Write.required_role(), Some(Role::ReadWrite));
399        assert_eq!(StatementKind::Admin.required_role(), Some(Role::Admin));
400        assert_eq!(StatementKind::Transaction.required_role(), None);
401    }
402
403    #[test]
404    fn check_permission_allows_transaction_for_all() {
405        let readonly = Identity::new("r", [Role::ReadOnly]);
406        assert!(check_permission(&readonly, StatementKind::Transaction).is_ok());
407
408        let nobody = Identity::new("n", std::iter::empty::<Role>());
409        assert!(check_permission(&nobody, StatementKind::Transaction).is_ok());
410    }
411
412    #[test]
413    fn check_permission_denies_write_for_readonly() {
414        let id = Identity::new("reader", [Role::ReadOnly]);
415        let err = check_permission(&id, StatementKind::Write).unwrap_err();
416        assert_eq!(err.required, Role::ReadWrite);
417        assert_eq!(err.operation, StatementKind::Write);
418        assert!(err.to_string().contains("permission denied"));
419    }
420
421    #[test]
422    fn check_permission_denies_admin_for_readwrite() {
423        let id = Identity::new("writer", [Role::ReadWrite]);
424        let err = check_permission(&id, StatementKind::Admin).unwrap_err();
425        assert_eq!(err.required, Role::Admin);
426    }
427
428    #[test]
429    fn identity_display() {
430        let id = Identity::new("app-service", [Role::ReadWrite]);
431        assert_eq!(id.to_string(), "app-service");
432    }
433
434    #[test]
435    fn identity_with_multiple_roles() {
436        let id = Identity::new("alix", [Role::ReadOnly, Role::ReadWrite]);
437        assert!(id.can_read());
438        assert!(id.can_write());
439        assert!(!id.can_admin());
440        assert!(id.has_role(Role::ReadOnly));
441        assert!(id.has_role(Role::ReadWrite));
442        assert!(!id.has_role(Role::Admin));
443        assert_eq!(id.roles().len(), 2);
444    }
445
446    #[test]
447    fn identity_with_all_roles() {
448        let id = Identity::new("gus", [Role::ReadOnly, Role::ReadWrite, Role::Admin]);
449        assert!(id.can_read());
450        assert!(id.can_write());
451        assert!(id.can_admin());
452        assert_eq!(id.roles().len(), 3);
453    }
454
455    #[test]
456    fn permission_denied_error_message_contains_user_and_role() {
457        let id = Identity::new("alix", [Role::ReadOnly]);
458        let err = check_permission(&id, StatementKind::Write).unwrap_err();
459        let msg = err.to_string();
460        assert!(msg.contains("alix"), "error should contain user id");
461        assert!(
462            msg.contains("ReadWrite"),
463            "error should contain required role"
464        );
465        assert!(msg.contains("write"), "error should contain operation kind");
466    }
467
468    #[test]
469    fn permission_denied_admin_error_message() {
470        let id = Identity::new("gus", [Role::ReadOnly]);
471        let err = check_permission(&id, StatementKind::Admin).unwrap_err();
472        let msg = err.to_string();
473        assert!(msg.contains("gus"));
474        assert!(msg.contains("Admin"));
475        assert!(msg.contains("admin"));
476    }
477
478    #[test]
479    fn check_permission_denies_read_for_no_roles() {
480        let id = Identity::new("nobody", std::iter::empty::<Role>());
481        let err = check_permission(&id, StatementKind::Read).unwrap_err();
482        assert_eq!(err.required, Role::ReadOnly);
483        assert_eq!(err.operation, StatementKind::Read);
484        assert!(err.to_string().contains("nobody"));
485    }
486
487    #[test]
488    fn check_permission_allows_read_for_readonly() {
489        let id = Identity::new("alix", [Role::ReadOnly]);
490        assert!(check_permission(&id, StatementKind::Read).is_ok());
491    }
492
493    #[test]
494    fn check_permission_allows_admin_for_admin() {
495        let id = Identity::new("gus", [Role::Admin]);
496        assert!(check_permission(&id, StatementKind::Admin).is_ok());
497    }
498
499    #[test]
500    fn check_permission_allows_write_for_readwrite() {
501        let id = Identity::new("alix", [Role::ReadWrite]);
502        assert!(check_permission(&id, StatementKind::Write).is_ok());
503    }
504
505    #[test]
506    fn statement_kind_display() {
507        assert_eq!(StatementKind::Read.to_string(), "read");
508        assert_eq!(StatementKind::Write.to_string(), "write");
509        assert_eq!(StatementKind::Admin.to_string(), "admin");
510        assert_eq!(
511            StatementKind::Transaction.to_string(),
512            "transaction control"
513        );
514    }
515
516    #[test]
517    fn permission_denied_is_std_error() {
518        let id = Identity::new("alix", [Role::ReadOnly]);
519        let err = check_permission(&id, StatementKind::Write).unwrap_err();
520        // Verify PermissionDenied implements std::error::Error
521        let _: &dyn std::error::Error = &err;
522    }
523
524    // --- Grant tests ---
525
526    #[test]
527    fn no_grants_means_unrestricted() {
528        let id = Identity::new("alix", [Role::ReadWrite]);
529        assert!(id.can_access_graph("any_graph", Role::ReadWrite));
530        assert!(id.can_access_graph("other", Role::ReadOnly));
531        assert!(!id.has_grants());
532    }
533
534    #[test]
535    fn grant_restricts_to_listed_graphs() {
536        let id = Identity::new("gus", [Role::ReadWrite]).with_grants([
537            Grant::new("social", Role::ReadWrite),
538            Grant::new("analytics", Role::ReadOnly),
539        ]);
540        assert!(id.has_grants());
541        assert!(id.can_access_graph("social", Role::ReadWrite));
542        assert!(id.can_access_graph("social", Role::ReadOnly));
543        assert!(id.can_access_graph("analytics", Role::ReadOnly));
544        assert!(!id.can_access_graph("analytics", Role::ReadWrite));
545        assert!(!id.can_access_graph("secret", Role::ReadOnly));
546    }
547
548    #[test]
549    fn grant_admin_implies_all() {
550        let id =
551            Identity::new("admin", [Role::Admin]).with_grants([Grant::new("prod", Role::Admin)]);
552        assert!(id.can_access_graph("prod", Role::Admin));
553        assert!(id.can_access_graph("prod", Role::ReadWrite));
554        assert!(id.can_access_graph("prod", Role::ReadOnly));
555    }
556
557    #[test]
558    fn grant_case_insensitive() {
559        let id = Identity::new("alix", [Role::ReadWrite])
560            .with_grants([Grant::new("Social", Role::ReadWrite)]);
561        assert!(id.can_access_graph("social", Role::ReadOnly));
562        assert!(id.can_access_graph("SOCIAL", Role::ReadWrite));
563    }
564
565    #[test]
566    fn grant_display() {
567        let g = Grant::new("social", Role::ReadWrite);
568        assert_eq!(g.graph, "social");
569        assert_eq!(g.role, Role::ReadWrite);
570    }
571}