diff --git a/raphtory-graphql/src/auth_policy.rs b/raphtory-graphql/src/auth_policy.rs index e6b78327fe..293fb2298b 100644 --- a/raphtory-graphql/src/auth_policy.rs +++ b/raphtory-graphql/src/auth_policy.rs @@ -100,13 +100,7 @@ impl Ord for GraphPermission { /// Variants are ordered lowest to highest so that `PartialOrd`/`Ord` reflect the hierarchy. #[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord)] pub enum NamespacePermission { - /// No access — namespace is invisible. - Denied, - /// Namespace is visible in parent `children()` listings but cannot be browsed. - Discover, - /// Namespace is browseable; graphs inside are visible as MetaGraph in `graphs()`. - Introspect, - /// All descendant graphs are fully readable. + /// Namespace is listable; graphs and child namespaces are visible. Read, /// All descendants are writable; `newGraph` is allowed. Write, @@ -126,13 +120,22 @@ pub trait AuthorizationPolicy: Send + Sync + 'static { ) -> Result; /// Resolves the effective namespace permission for a principal. - /// Admin principals always yield `Write`. - /// Empty store yields `Read` (fail open, consistent with graph_permissions). - /// Missing role yields `Denied`. + /// Returns `None` if the principal has no access to this namespace (it is invisible). + /// Admin principals always yield `Some(Write)`. + /// Empty store yields `Some(Read)` (fail open, consistent with graph_permissions). + /// Missing role or no explicit grant yields `None`. /// The implementation is responsible for extracting principal identity from `ctx`. fn namespace_permissions( &self, ctx: &async_graphql::Context<'_>, path: &str, - ) -> NamespacePermission; + ) -> Option; + + /// Called after a graph is successfully created to auto-grant `Write` for the creator's role. + /// Returns an error if the grant cannot be persisted; the caller is responsible for rolling + /// back the graph creation so the store and filesystem stay consistent. + /// Default no-op — only meaningful when a policy and a role claim are present. + fn on_graph_created(&self, _role: &str, _path: &str) -> Result<(), String> { + Ok(()) + } } diff --git a/raphtory-graphql/src/data.rs b/raphtory-graphql/src/data.rs index 989a706eaf..bc7f99a931 100644 --- a/raphtory-graphql/src/data.rs +++ b/raphtory-graphql/src/data.rs @@ -1,12 +1,14 @@ use crate::{ auth::ContextValidation, - auth_policy::{AuthorizationPolicy, GraphPermission, NamespacePermission}, + auth_policy::{AuthorizationPolicy, GraphPermission}, config::app_config::AppConfig, graph::GraphWithVectors, model::{ blocking_io, graph::{ filtering::{GraphAccessFilter, GraphRowFilter, HiddenKeys}, + namespace::Namespace, + namespaced_item::NamespacedItem, vectorised_graph::GqlVectorisedGraph, }, }, @@ -229,6 +231,24 @@ impl Data { } } + /// Enumerates all descendants of a namespace as `(path, is_graph)` pairs. + /// `is_graph = true` means the path is a graph; `false` means a sub-namespace. + /// Returns an error if `ns_path` does not exist or is not a namespace. + pub fn enumerate_namespace_descendants( + &self, + ns_path: &str, + ) -> Result, PathValidationError> { + let ns = Namespace::try_new(self.work_dir.clone(), ns_path.to_string())?; + let entries = ns + .get_all_children() + .map(|item| match item { + NamespacedItem::Namespace(n) => (n.local_path().to_string(), false), + NamespacedItem::MetaGraph(g) => (g.local_path().to_string(), true), + }) + .collect(); + Ok(entries) + } + /// # ⚠ Bypasses all permission checks — do not call from resolvers directly. /// Use `get_graph_with_read_permission`, `get_raw_graph_with_read_permission`, or /// `get_graph_with_write_permission` instead. @@ -456,7 +476,7 @@ fn require_at_least_read( "Access denied by auth policy" ); let ns = parent_namespace(path); - if policy.namespace_permissions(ctx, ns) >= NamespacePermission::Introspect { + if policy.namespace_permissions(ctx, ns).is_some() { Err(msg.into()) } else { Err(PermissionError::GraphNotFound.into()) diff --git a/raphtory-graphql/src/model/graph/namespace.rs b/raphtory-graphql/src/model/graph/namespace.rs index cdf13fb9bf..32f59d017c 100644 --- a/raphtory-graphql/src/model/graph/namespace.rs +++ b/raphtory-graphql/src/model/graph/namespace.rs @@ -1,5 +1,5 @@ use crate::{ - auth_policy::{AuthorizationPolicy, NamespacePermission}, + auth_policy::AuthorizationPolicy, data::{get_relative_path, Data}, model::graph::{ collection::GqlCollection, meta_graph::MetaGraph, namespaced_item::NamespacedItem, @@ -75,6 +75,10 @@ impl Namespace { } } + pub(crate) fn local_path(&self) -> &str { + &self.relative_path + } + pub fn try_new(root: PathBuf, relative_path: String) -> Result { let current_dir = ValidPath::try_new(root, relative_path.as_str())?; Self::try_from_valid(current_dir, &relative_path) @@ -157,7 +161,7 @@ fn is_namespace_visible( n: &Namespace, ) -> bool { policy.as_ref().map_or(true, |p| { - p.namespace_permissions(ctx, &n.relative_path) >= NamespacePermission::Discover + p.namespace_permissions(ctx, &n.relative_path).is_some() }) } diff --git a/raphtory-graphql/src/model/mod.rs b/raphtory-graphql/src/model/mod.rs index 41b846552a..b97d4c173b 100644 --- a/raphtory-graphql/src/model/mod.rs +++ b/raphtory-graphql/src/model/mod.rs @@ -1,5 +1,5 @@ use crate::{ - auth::ContextValidation, + auth::{Access, ContextValidation}, auth_policy::{AuthorizationPolicy, NamespacePermission}, data::{parent_namespace, require_graph_write, Data, GqlGraphType, PermissionError}, graph::GraphWithVectors, @@ -114,6 +114,29 @@ pub enum GqlGraphError { FailedToCreateDir(String), } +/// Auto-grants Write on `path` for the creator's role after a graph is created. +/// Returns an error if the grant fails so the caller can roll back the graph. +/// No-op for admin users (they have blanket write access via JWT and need no store entry), +/// when there is no active auth policy, or when the JWT carries no role claim. +fn auto_grant_on_create( + ctx: &Context<'_>, + policy: &Option>, + path: &str, +) -> async_graphql::Result<()> { + let is_admin = ctx.data::().is_ok_and(|a| a == &Access::Rw); + if is_admin { + return Ok(()); + } + if let Some(policy) = policy { + if let Some(role) = ctx.data::>().ok().and_then(|r| r.as_deref()) { + policy + .on_graph_created(role, path) + .map_err(async_graphql::Error::new)?; + } + } + Ok(()) +} + fn require_namespace_write( ctx: &Context<'_>, policy: &Option>, @@ -124,7 +147,7 @@ fn require_namespace_write( match policy { None => ctx.require_jwt_write_access().map_err(Into::into), Some(p) => { - if p.namespace_permissions(ctx, ns_path) < NamespacePermission::Write { + if p.namespace_permissions(ctx, ns_path) < Some(NamespacePermission::Write) { return Err(PermissionError::NamespaceWriteRequired { namespace: ns_path.to_string(), graph: new_path.to_string(), @@ -391,6 +414,10 @@ impl Mut { }; data.insert_graph(folder, graph).await?; + if let Err(e) = auto_grant_on_create(ctx, &data.auth_policy, &path) { + let _ = data.delete_graph(&path).await; + return Err(e); + } Ok(true) } @@ -439,6 +466,10 @@ impl Mut { let src = data.get_raw_graph_with_read_permission(ctx, path).await?; let folder = data.validate_path_for_insert(new_path, overwrite)?; data.insert_graph(folder, src.graph).await?; + if let Err(e) = auto_grant_on_create(ctx, &data.auth_policy, new_path) { + let _ = data.delete_graph(new_path).await; + return Err(e); + } Ok(true) } @@ -460,6 +491,10 @@ impl Mut { let in_file = graph.value(ctx)?.content; let folder = data.validate_path_for_insert(&path, overwrite)?; data.insert_graph_as_bytes(folder, in_file).await?; + if let Err(e) = auto_grant_on_create(ctx, &data.auth_policy, &path) { + let _ = data.delete_graph(&path).await; + return Err(e); + } Ok(path) } @@ -490,6 +525,10 @@ impl Mut { }) .await?; data.insert_graph(folder, g).await?; + if let Err(e) = auto_grant_on_create(ctx, &data.auth_policy, path) { + let _ = data.delete_graph(path).await; + return Err(e); + } Ok(path.to_owned()) } @@ -524,6 +563,10 @@ impl Mut { .await?; data.insert_graph(folder, new_subgraph).await?; + if let Err(e) = auto_grant_on_create(ctx, &data.auth_policy, &new_path) { + let _ = data.delete_graph(&new_path).await; + return Err(e); + } Ok(new_path) }