From 7b05207d400eb07fe9528be96a345c9d17cf87cb Mon Sep 17 00:00:00 2001 From: Fadhil Abubaker Date: Thu, 23 Apr 2026 10:55:07 -0400 Subject: [PATCH 01/31] Add basic permissions proptest --- Cargo.lock | 1 + raphtory-graphql/Cargo.toml | 3 +- raphtory-graphql/tests/permissions.rs | 47 +++++++++++++++++++++++++++ 3 files changed, 50 insertions(+), 1 deletion(-) create mode 100644 raphtory-graphql/tests/permissions.rs diff --git a/Cargo.lock b/Cargo.lock index 9aa8ceb0f2..42896c763c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -6595,6 +6595,7 @@ dependencies = [ "poem", "pretty_assertions", "pyo3", + "rand 0.9.2", "raphtory", "raphtory-api", "raphtory-storage", diff --git a/raphtory-graphql/Cargo.toml b/raphtory-graphql/Cargo.toml index 081ea82f76..9310300f7a 100644 --- a/raphtory-graphql/Cargo.toml +++ b/raphtory-graphql/Cargo.toml @@ -64,13 +64,14 @@ clap = { workspace = true } rust-embed = { workspace = true } - [dev-dependencies] parking_lot = { workspace = true } tempfile = { workspace = true } pretty_assertions = { workspace = true } raphtory = { workspace = true, features = ["test-utils"] } arrow-array = { workspace = true } +rand = { workspace = true } + [features] python = ["dep:pyo3", "raphtory/python"] diff --git a/raphtory-graphql/tests/permissions.rs b/raphtory-graphql/tests/permissions.rs new file mode 100644 index 0000000000..64404b41eb --- /dev/null +++ b/raphtory-graphql/tests/permissions.rs @@ -0,0 +1,47 @@ +use rand::prelude::IndexedRandom; +use raphtory::prelude::*; +use raphtory_graphql::client::{raphtory_client::RaphtoryGraphQLClient, ClientError}; +use url::Url; + +/// Create namespaces in graphql using the given tree. +/// Leaf nodes are graphs, internal nodes are namespaces. +fn create_namespaces(tree: &Graph, url: Url) { + let client = RaphtoryGraphQLClient::new(url, None); +} + +/// Create a random tree with the given number of nodes. +fn create_tree(nodes: u64) -> Graph { + let graph = Graph::new(); + + if nodes == 0 { + return graph; + } + + for node_id in 0..nodes { + graph + .add_node(0, node_id, NO_PROPS, None) + .unwrap(); + } + + let mut rng = rand::rng(); + let mut available_parents = vec![0]; // start with root + + for node_id in 1..nodes { + let parent_id = available_parents.choose(&mut rng).unwrap(); + + graph + .add_edge(0, *parent_id, node_id, NO_PROPS, None) + .unwrap(); + + available_parents.push(node_id); + } + + graph +} + +#[test] +fn smoke_create_namespace_tree() { + let g = create_tree(10); + assert_eq!(g.count_nodes(), 10); + assert_eq!(g.count_edges(), 9); +} From 1498a324c678f6a08281d5378a0b2cd01f48dc5e Mon Sep 17 00:00:00 2001 From: Fadhil Abubaker Date: Fri, 24 Apr 2026 09:21:56 -0400 Subject: [PATCH 02/31] Commit before merge --- Cargo.lock | 2 +- .../src/client/raphtory_client.rs | 4 +- raphtory-graphql/src/paths.rs | 11 +++ raphtory-graphql/tests/permissions.rs | 82 +++++++++++++++---- 4 files changed, 80 insertions(+), 19 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 42896c763c..ea97c7188f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -6595,7 +6595,7 @@ dependencies = [ "poem", "pretty_assertions", "pyo3", - "rand 0.9.2", + "rand 0.9.4", "raphtory", "raphtory-api", "raphtory-storage", diff --git a/raphtory-graphql/src/client/raphtory_client.rs b/raphtory-graphql/src/client/raphtory_client.rs index 18c58bfa86..f2092af32b 100644 --- a/raphtory-graphql/src/client/raphtory_client.rs +++ b/raphtory-graphql/src/client/raphtory_client.rs @@ -116,9 +116,9 @@ impl RaphtoryGraphQLClient { .join("\n\t"), _ => format!("{}", errors), }; + return Err(ClientError::GraphQLErrors(format!( - "After sending query to the server:\n\t{}\nGot the following errors:\n\t{}", - query, message + "Error while executing query: {message}" ))); } diff --git a/raphtory-graphql/src/paths.rs b/raphtory-graphql/src/paths.rs index b3f8093663..046d72d9bc 100644 --- a/raphtory-graphql/src/paths.rs +++ b/raphtory-graphql/src/paths.rs @@ -214,6 +214,9 @@ pub(crate) fn create_valid_path( base_path: PathBuf, relative_path: &str, ) -> Result { + println!("Base path: {base_path:?}"); + println!("Relative path: {relative_path}"); + ensure_clean_folder(&base_path)?; let user_facing_path = PathBuf::from(relative_path); @@ -233,10 +236,12 @@ pub(crate) fn create_valid_path( Ok(_) => { if !full_path.exists() { if cleanup_marker.is_none() { + println!("Creating cleanup marker for: {full_path:?}"); cleanup_marker = Some(CleanupPath { path: full_path.clone(), dirty_marker: mark_dirty(&full_path)?, }); + println!("Created cleanup marker for: {full_path:?}"); fs::create_dir(&full_path)?; } } @@ -567,15 +572,21 @@ pub(crate) fn mark_dirty(path: &Path) -> Result>(); + + // Include the node itself in the path. + in_components.push(node.name()); + + let path = in_components.join("/"); + graph_paths.push(path); + } + } + + graph_paths.sort(); + + println!("Graph paths: {graph_paths:?}"); + + for path in graph_paths { + println!("Creating graph: {path}"); + client.new_graph(path.as_str(), "EVENT").await.unwrap(); + } } /// Create a random tree with the given number of nodes. @@ -17,31 +50,48 @@ fn create_tree(nodes: u64) -> Graph { return graph; } - for node_id in 0..nodes { + for node in 0..nodes { + let name = format!("node_{node}"); + graph - .add_node(0, node_id, NO_PROPS, None) + .add_node(0, name, NO_PROPS, None, None) .unwrap(); } let mut rng = rand::rng(); let mut available_parents = vec![0]; // start with root - for node_id in 1..nodes { - let parent_id = available_parents.choose(&mut rng).unwrap(); + // For each node, add an edge to a random parent. + for node in 1..nodes { + let parent = available_parents.choose(&mut rng).unwrap(); + let parent_name = format!("node_{parent}"); + let node_name = format!("node_{node}"); graph - .add_edge(0, *parent_id, node_id, NO_PROPS, None) + .add_edge(0, parent_name, node_name, NO_PROPS, None) .unwrap(); - available_parents.push(node_id); + available_parents.push(node); } graph } -#[test] -fn smoke_create_namespace_tree() { - let g = create_tree(10); - assert_eq!(g.count_nodes(), 10); - assert_eq!(g.count_edges(), 9); +async fn start_server() -> RunningGraphServer { + let tempdir = tempdir().unwrap(); + let server = GraphServer::new(tempdir.path().to_path_buf(), None, None, Config::default()).await.unwrap(); + let server = server.start_with_port(PORT).await.unwrap(); + + tokio::time::sleep(Duration::from_secs(1)).await; + + server +} + +#[tokio::test] +async fn permissions_proptest() { + let url = Url::parse(&format!("http://127.0.0.1:{PORT}")).unwrap(); + let namespace_tree = create_tree(10); + + let server = start_server().await; + create_namespaces(&namespace_tree, url).await; } From 7b8d05ec12e57bc543703afaa5346021e088c53d Mon Sep 17 00:00:00 2001 From: Fadhil Abubaker Date: Fri, 24 Apr 2026 10:08:19 -0400 Subject: [PATCH 03/31] Prevent cleanup on Drop --- raphtory-graphql/src/paths.rs | 10 +++++----- raphtory-graphql/tests/permissions.rs | 14 ++++++-------- 2 files changed, 11 insertions(+), 13 deletions(-) diff --git a/raphtory-graphql/src/paths.rs b/raphtory-graphql/src/paths.rs index 046d72d9bc..9e13ef4624 100644 --- a/raphtory-graphql/src/paths.rs +++ b/raphtory-graphql/src/paths.rs @@ -214,21 +214,20 @@ pub(crate) fn create_valid_path( base_path: PathBuf, relative_path: &str, ) -> Result { - println!("Base path: {base_path:?}"); - println!("Relative path: {relative_path}"); - ensure_clean_folder(&base_path)?; let user_facing_path = PathBuf::from(relative_path); if relative_path.contains(r"//") { return Err(InvalidPathReason::DoubleForwardSlash.into()); } + if relative_path.contains(r"\") { return Err(InvalidPathReason::BackslashError.into()); } let mut full_path = base_path.clone(); let mut cleanup_marker = None; + // fail if any component is a Prefix (C://), tries to access root, // tries to access a parent dir or is a symlink which could break out of the working dir for component in user_facing_path.components() { @@ -236,12 +235,11 @@ pub(crate) fn create_valid_path( Ok(_) => { if !full_path.exists() { if cleanup_marker.is_none() { - println!("Creating cleanup marker for: {full_path:?}"); cleanup_marker = Some(CleanupPath { path: full_path.clone(), dirty_marker: mark_dirty(&full_path)?, }); - println!("Created cleanup marker for: {full_path:?}"); + fs::create_dir(&full_path)?; } } @@ -334,11 +332,13 @@ impl ValidWriteableGraphFolder { error, } })?; + if !path.cleanup.is_some() { return Err(PathValidationError::GraphExistsError( relative_path.to_string(), )); } + Self::new(path, relative_path) } diff --git a/raphtory-graphql/tests/permissions.rs b/raphtory-graphql/tests/permissions.rs index fd6e470af1..41aca1f81c 100644 --- a/raphtory-graphql/tests/permissions.rs +++ b/raphtory-graphql/tests/permissions.rs @@ -3,7 +3,7 @@ use std::time::Duration; use rand::prelude::IndexedRandom; use raphtory::{algorithms::components::in_component, prelude::*}; use raphtory_graphql::{client::raphtory_client::RaphtoryGraphQLClient, server::RunningGraphServer, GraphServer}; -use tempfile::tempdir; +use tempfile::TempDir; use url::Url; const PORT: u16 = 43871; @@ -34,10 +34,7 @@ async fn create_namespaces(tree: &Graph, url: Url) { graph_paths.sort(); - println!("Graph paths: {graph_paths:?}"); - for path in graph_paths { - println!("Creating graph: {path}"); client.new_graph(path.as_str(), "EVENT").await.unwrap(); } } @@ -77,21 +74,22 @@ fn create_tree(nodes: u64) -> Graph { graph } -async fn start_server() -> RunningGraphServer { - let tempdir = tempdir().unwrap(); +async fn start_server() -> (RunningGraphServer, TempDir) { + let tempdir = TempDir::new().unwrap(); let server = GraphServer::new(tempdir.path().to_path_buf(), None, None, Config::default()).await.unwrap(); let server = server.start_with_port(PORT).await.unwrap(); tokio::time::sleep(Duration::from_secs(1)).await; - server + // Return server and tempdir to prevent cleanup on drop. + (server, tempdir) } #[tokio::test] async fn permissions_proptest() { let url = Url::parse(&format!("http://127.0.0.1:{PORT}")).unwrap(); + let (_server, _tempdir) = start_server().await; let namespace_tree = create_tree(10); - let server = start_server().await; create_namespaces(&namespace_tree, url).await; } From 83bb4437010a936fccd285b3bca81eabc59376db Mon Sep 17 00:00:00 2001 From: Fadhil Abubaker Date: Fri, 24 Apr 2026 11:35:26 -0400 Subject: [PATCH 04/31] Return created graph paths --- raphtory-graphql/tests/permissions.rs | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/raphtory-graphql/tests/permissions.rs b/raphtory-graphql/tests/permissions.rs index 41aca1f81c..075c29da06 100644 --- a/raphtory-graphql/tests/permissions.rs +++ b/raphtory-graphql/tests/permissions.rs @@ -1,4 +1,4 @@ -use std::time::Duration; +use std::{time::Duration}; use rand::prelude::IndexedRandom; use raphtory::{algorithms::components::in_component, prelude::*}; @@ -8,9 +8,9 @@ use url::Url; const PORT: u16 = 43871; -/// Create namespaces in graphql using the given tree. +/// Create namespaces and graphs in graphql using the given tree. /// Every leaf node is turned into a graph using the path from root as the namespace. -async fn create_namespaces(tree: &Graph, url: Url) { +async fn create_graphs(tree: &Graph, url: Url) -> Vec { let client = RaphtoryGraphQLClient::connect(url, None).await.unwrap(); let mut graph_paths = Vec::new(); @@ -32,11 +32,11 @@ async fn create_namespaces(tree: &Graph, url: Url) { } } - graph_paths.sort(); - - for path in graph_paths { - client.new_graph(path.as_str(), "EVENT").await.unwrap(); + for path in graph_paths.iter() { + client.new_graph(path, "EVENT").await.unwrap(); } + + graph_paths } /// Create a random tree with the given number of nodes. @@ -91,5 +91,5 @@ async fn permissions_proptest() { let (_server, _tempdir) = start_server().await; let namespace_tree = create_tree(10); - create_namespaces(&namespace_tree, url).await; + let graph_paths = create_graphs(&namespace_tree, url).await; } From b6cac8dfad4ede5518876bd8836c2ffef88bb752 Mon Sep 17 00:00:00 2001 From: Fadhil Abubaker Date: Fri, 24 Apr 2026 13:11:39 -0400 Subject: [PATCH 05/31] Add proptest --- Cargo.lock | 16 ++++++ raphtory-graphql/Cargo.toml | 3 + raphtory-graphql/tests/permissions.rs | 79 +++++++++++++++++++-------- 3 files changed, 74 insertions(+), 24 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index ea97c7188f..5ab081a75d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -6511,6 +6511,20 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "raphtory-auth" +version = "0.18.0" +dependencies = [ + "async-graphql", + "dynamic-graphql", + "futures-util", + "raphtory-graphql", + "serde", + "serde_json", + "thiserror 2.0.18", + "tracing", +] + [[package]] name = "raphtory-auth-noop" version = "0.18.0" @@ -6594,10 +6608,12 @@ dependencies = [ "parking_lot", "poem", "pretty_assertions", + "proptest", "pyo3", "rand 0.9.4", "raphtory", "raphtory-api", + "raphtory-auth", "raphtory-storage", "rayon", "reqwest", diff --git a/raphtory-graphql/Cargo.toml b/raphtory-graphql/Cargo.toml index 9310300f7a..7b926d0e05 100644 --- a/raphtory-graphql/Cargo.toml +++ b/raphtory-graphql/Cargo.toml @@ -71,6 +71,9 @@ pretty_assertions = { workspace = true } raphtory = { workspace = true, features = ["test-utils"] } arrow-array = { workspace = true } rand = { workspace = true } +proptest = { workspace = true } +jsonwebtoken = { workspace = true } +serde = { workspace = true } [features] diff --git a/raphtory-graphql/tests/permissions.rs b/raphtory-graphql/tests/permissions.rs index 075c29da06..c9a4d92727 100644 --- a/raphtory-graphql/tests/permissions.rs +++ b/raphtory-graphql/tests/permissions.rs @@ -1,17 +1,26 @@ -use std::{time::Duration}; +use std::ops::RangeInclusive; +use std::{hint::black_box, sync::LazyLock, time::Duration}; +use proptest::prelude::*; use rand::prelude::IndexedRandom; use raphtory::{algorithms::components::in_component, prelude::*}; -use raphtory_graphql::{client::raphtory_client::RaphtoryGraphQLClient, server::RunningGraphServer, GraphServer}; +use raphtory::db::api::storage::storage::Config; +use raphtory_graphql::{ + client::raphtory_client::RaphtoryGraphQLClient, + server::{RunningGraphServer, GraphServer}, +}; use tempfile::TempDir; +use tokio::runtime::Runtime; use url::Url; const PORT: u16 = 43871; +static RUNTIME: LazyLock = + LazyLock::new(|| Runtime::new().expect("Failed to create Tokio runtime")); + /// Create namespaces and graphs in graphql using the given tree. /// Every leaf node is turned into a graph using the path from root as the namespace. -async fn create_graphs(tree: &Graph, url: Url) -> Vec { - let client = RaphtoryGraphQLClient::connect(url, None).await.unwrap(); +fn create_graphs(tree: &Graph, url: Url) -> Vec { let mut graph_paths = Vec::new(); for node in tree.nodes() { @@ -19,9 +28,7 @@ async fn create_graphs(tree: &Graph, url: Url) -> Vec { // In-component order yields path from root to node. let mut in_components = in_component(node.clone()) .iter() - .map(|(node_view, _)| { - node_view.name() - }) + .map(|(node_view, _)| node_view.name()) .collect::>(); // Include the node itself in the path. @@ -32,8 +39,14 @@ async fn create_graphs(tree: &Graph, url: Url) -> Vec { } } + let client = RUNTIME + .block_on(RaphtoryGraphQLClient::connect(url, None)) + .unwrap(); + for path in graph_paths.iter() { - client.new_graph(path, "EVENT").await.unwrap(); + RUNTIME + .block_on(client.new_graph(path, "EVENT")) + .unwrap(); } graph_paths @@ -74,22 +87,40 @@ fn create_tree(nodes: u64) -> Graph { graph } -async fn start_server() -> (RunningGraphServer, TempDir) { - let tempdir = TempDir::new().unwrap(); - let server = GraphServer::new(tempdir.path().to_path_buf(), None, None, Config::default()).await.unwrap(); - let server = server.start_with_port(PORT).await.unwrap(); - - tokio::time::sleep(Duration::from_secs(1)).await; - - // Return server and tempdir to prevent cleanup on drop. - (server, tempdir) +fn start_server() -> (RunningGraphServer, TempDir) { + RUNTIME.block_on(async { + let tempdir = TempDir::new().unwrap(); + let server = GraphServer::new( + tempdir.path().to_path_buf(), + None, + None, + Config::default(), + ) + .await + .unwrap(); + + let server = server.start_with_port(PORT).await.unwrap(); + tokio::time::sleep(Duration::from_secs(1)).await; + + (server, tempdir) + }) } -#[tokio::test] -async fn permissions_proptest() { - let url = Url::parse(&format!("http://127.0.0.1:{PORT}")).unwrap(); - let (_server, _tempdir) = start_server().await; - let namespace_tree = create_tree(10); - - let graph_paths = create_graphs(&namespace_tree, url).await; +#[test] +fn permissions_proptest() { + const PROPTEST_CASES: u32 = 10; + const TREE_SIZE_RANGE: RangeInclusive = 1..=20; + const NUM_USERS_RANGE: RangeInclusive = 2..=10; + + proptest!( + ProptestConfig::with_cases(PROPTEST_CASES), + |(tree_size in TREE_SIZE_RANGE, num_users in NUM_USERS_RANGE)| { + let url = Url::parse(&format!("http://127.0.0.1:{PORT}")).unwrap(); + let (_server, _tempdir) = start_server(); + let namespace_tree = create_tree(tree_size); + let graph_paths = create_graphs(&namespace_tree, url); + + // For each + } + ); } From 835263c30e2d05d9e318692b1b340dc6d2e620cb Mon Sep 17 00:00:00 2001 From: Fadhil Abubaker Date: Sat, 25 Apr 2026 14:39:54 -0400 Subject: [PATCH 06/31] Prevent server drop from failing --- raphtory-graphql/tests/permissions.rs | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/raphtory-graphql/tests/permissions.rs b/raphtory-graphql/tests/permissions.rs index c9a4d92727..2567e7022f 100644 --- a/raphtory-graphql/tests/permissions.rs +++ b/raphtory-graphql/tests/permissions.rs @@ -89,7 +89,11 @@ fn create_tree(nodes: u64) -> Graph { fn start_server() -> (RunningGraphServer, TempDir) { RUNTIME.block_on(async { - let tempdir = TempDir::new().unwrap(); + let mut tempdir = TempDir::new().unwrap(); + + // Prevent server drop from failing due to tempdir cleanup. + tempdir.disable_cleanup(true); + let server = GraphServer::new( tempdir.path().to_path_buf(), None, @@ -110,7 +114,7 @@ fn start_server() -> (RunningGraphServer, TempDir) { fn permissions_proptest() { const PROPTEST_CASES: u32 = 10; const TREE_SIZE_RANGE: RangeInclusive = 1..=20; - const NUM_USERS_RANGE: RangeInclusive = 2..=10; + const NUM_USERS_RANGE: RangeInclusive = 1..=10; proptest!( ProptestConfig::with_cases(PROPTEST_CASES), @@ -121,6 +125,8 @@ fn permissions_proptest() { let graph_paths = create_graphs(&namespace_tree, url); // For each + black_box(_server); + black_box(_tempdir); } ); } From 3e28146b6fc6b41074505e309f5d81af52e74672 Mon Sep 17 00:00:00 2001 From: Fadhil Abubaker Date: Sun, 26 Apr 2026 12:11:29 -0400 Subject: [PATCH 07/31] Create roles --- raphtory-graphql/Cargo.toml | 2 + raphtory-graphql/tests/permissions.rs | 87 ++++++++++++++++++++++++--- 2 files changed, 79 insertions(+), 10 deletions(-) diff --git a/raphtory-graphql/Cargo.toml b/raphtory-graphql/Cargo.toml index 7b926d0e05..d880cbd11a 100644 --- a/raphtory-graphql/Cargo.toml +++ b/raphtory-graphql/Cargo.toml @@ -70,10 +70,12 @@ tempfile = { workspace = true } pretty_assertions = { workspace = true } raphtory = { workspace = true, features = ["test-utils"] } arrow-array = { workspace = true } +auth = { workspace = true } rand = { workspace = true } proptest = { workspace = true } jsonwebtoken = { workspace = true } serde = { workspace = true } +serde_json = { workspace = true } [features] diff --git a/raphtory-graphql/tests/permissions.rs b/raphtory-graphql/tests/permissions.rs index 2567e7022f..e7301e1d74 100644 --- a/raphtory-graphql/tests/permissions.rs +++ b/raphtory-graphql/tests/permissions.rs @@ -1,26 +1,66 @@ +use std::collections::HashMap; use std::ops::RangeInclusive; +use std::sync::Once; use std::{hint::black_box, sync::LazyLock, time::Duration}; +use jsonwebtoken::{encode, Algorithm, EncodingKey, Header}; use proptest::prelude::*; use rand::prelude::IndexedRandom; use raphtory::{algorithms::components::in_component, prelude::*}; use raphtory::db::api::storage::storage::Config; -use raphtory_graphql::{ - client::raphtory_client::RaphtoryGraphQLClient, - server::{RunningGraphServer, GraphServer}, -}; +use raphtory_graphql::client::raphtory_client::RaphtoryGraphQLClient; +use raphtory_graphql::config::app_config::AppConfigBuilder; +use raphtory_graphql::server::{apply_server_extension, GraphServer, RunningGraphServer}; +use serde_json::{json, Value as JsonValue}; use tempfile::TempDir; use tokio::runtime::Runtime; use url::Url; const PORT: u16 = 43871; +/// Same key pair as `Raphtory/python/tests/test_permissions.py` and `test_auth.py`. +const AUTH_PUB_KEY: &str = "MCowBQYDK2VwAyEADdrWr1kTLj+wSHlr45eneXmOjlHo3N1DjLIvDa2ozno="; +const AUTH_PRIVATE_KEY_PEM: &str = "-----BEGIN PRIVATE KEY----- +MC4CAQAwBQYDK2VwBCIEIFzEcSO/duEjjX4qKxDVy4uLqfmiEIA6bEw1qiPyzTQg +-----END PRIVATE KEY-----"; + +static AUTH_INIT: Once = Once::new(); static RUNTIME: LazyLock = LazyLock::new(|| Runtime::new().expect("Failed to create Tokio runtime")); +static ADMIN_JWT: LazyLock = LazyLock::new(|| { + let key = EncodingKey::from_ed_pem(AUTH_PRIVATE_KEY_PEM.as_bytes()) + .expect("decode Ed25519 private key for test JWTs"); + encode( + &Header::new(Algorithm::EdDSA), + &json!({ "access": "rw", "role": "admin" }), + &key, + ) + .expect("encode admin JWT") +}); + +fn init_raphtory_auth() { + AUTH_INIT.call_once(auth::init); +} + +/// `createRole` with admin credentials — mirrors Python `create_role` + default `gql(..., ADMIN_HEADERS)`. +fn create_role(client: &RaphtoryGraphQLClient, name: &str) { + let q = format!( + r#"mutation {{ permissions {{ createRole(name: "{name}") {{ success }} }} }}"# + ); + let data = RUNTIME + .block_on(client.query(&q, HashMap::new())) + .unwrap_or_else(|e| panic!("createRole {name}: {e}")); + let success = data + .get("permissions") + .and_then(|p| p.get("createRole")) + .and_then(|c| c.get("success")) + .and_then(JsonValue::as_bool); + assert_eq!(success, Some(true), "createRole {name} data: {data:?}"); +} /// Create namespaces and graphs in graphql using the given tree. /// Every leaf node is turned into a graph using the path from root as the namespace. -fn create_graphs(tree: &Graph, url: Url) -> Vec { +fn create_graphs(tree: &Graph, url: Url, admin_token: &str) -> Vec { let mut graph_paths = Vec::new(); for node in tree.nodes() { @@ -40,7 +80,10 @@ fn create_graphs(tree: &Graph, url: Url) -> Vec { } let client = RUNTIME - .block_on(RaphtoryGraphQLClient::connect(url, None)) + .block_on(RaphtoryGraphQLClient::connect( + url, + Some(admin_token.to_string()), + )) .unwrap(); for path in graph_paths.iter() { @@ -88,20 +131,31 @@ fn create_tree(nodes: u64) -> Graph { } fn start_server() -> (RunningGraphServer, TempDir) { + init_raphtory_auth(); + RUNTIME.block_on(async { let mut tempdir = TempDir::new().unwrap(); // Prevent server drop from failing due to tempdir cleanup. tempdir.disable_cleanup(true); + let work_dir = tempdir.path().to_path_buf(); + let permissions_path = work_dir.join("permissions.json"); + // Same public key and default `require_auth_for_reads` as Python `test_permissions` server. + let app_config = AppConfigBuilder::new() + .with_auth_public_key(Some(AUTH_PUB_KEY.to_string())) + .expect("test auth public key") + .build(); + let server = GraphServer::new( - tempdir.path().to_path_buf(), - None, + work_dir, + Some(app_config), None, Config::default(), ) .await .unwrap(); + let server = apply_server_extension(server, Some(permissions_path.as_path())); let server = server.start_with_port(PORT).await.unwrap(); tokio::time::sleep(Duration::from_secs(1)).await; @@ -122,11 +176,24 @@ fn permissions_proptest() { let url = Url::parse(&format!("http://127.0.0.1:{PORT}")).unwrap(); let (_server, _tempdir) = start_server(); let namespace_tree = create_tree(tree_size); - let graph_paths = create_graphs(&namespace_tree, url); + let admin_token = ADMIN_JWT.as_str(); + let graph_paths = create_graphs(&namespace_tree, url.clone(), admin_token); + + let client = RUNTIME + .block_on(RaphtoryGraphQLClient::connect( + url, + Some(admin_token.to_string()), + )) + .unwrap(); + + for i in 0..num_users { + let role = format!("user_{i}"); + create_role(&client, &role); + } - // For each black_box(_server); black_box(_tempdir); + black_box(graph_paths); } ); } From af9672360dde5ff60c00b0a0c01f436753158ec9 Mon Sep 17 00:00:00 2001 From: Fadhil Abubaker Date: Mon, 27 Apr 2026 10:14:35 -0400 Subject: [PATCH 08/31] Reuse client across requests --- raphtory-graphql/tests/permissions.rs | 52 ++++++++++++++------------- 1 file changed, 28 insertions(+), 24 deletions(-) diff --git a/raphtory-graphql/tests/permissions.rs b/raphtory-graphql/tests/permissions.rs index e7301e1d74..b4c9205c4b 100644 --- a/raphtory-graphql/tests/permissions.rs +++ b/raphtory-graphql/tests/permissions.rs @@ -18,18 +18,19 @@ use url::Url; const PORT: u16 = 43871; -/// Same key pair as `Raphtory/python/tests/test_permissions.py` and `test_auth.py`. -const AUTH_PUB_KEY: &str = "MCowBQYDK2VwAyEADdrWr1kTLj+wSHlr45eneXmOjlHo3N1DjLIvDa2ozno="; -const AUTH_PRIVATE_KEY_PEM: &str = "-----BEGIN PRIVATE KEY----- +const PUB_KEY: &str = "MCowBQYDK2VwAyEADdrWr1kTLj+wSHlr45eneXmOjlHo3N1DjLIvDa2ozno="; +const PRIVATE_KEY: &str = "-----BEGIN PRIVATE KEY----- MC4CAQAwBQYDK2VwBCIEIFzEcSO/duEjjX4qKxDVy4uLqfmiEIA6bEw1qiPyzTQg -----END PRIVATE KEY-----"; static AUTH_INIT: Once = Once::new(); static RUNTIME: LazyLock = LazyLock::new(|| Runtime::new().expect("Failed to create Tokio runtime")); + static ADMIN_JWT: LazyLock = LazyLock::new(|| { - let key = EncodingKey::from_ed_pem(AUTH_PRIVATE_KEY_PEM.as_bytes()) + let key = EncodingKey::from_ed_pem(PRIVATE_KEY.as_bytes()) .expect("decode Ed25519 private key for test JWTs"); + encode( &Header::new(Algorithm::EdDSA), &json!({ "access": "rw", "role": "admin" }), @@ -42,25 +43,31 @@ fn init_raphtory_auth() { AUTH_INIT.call_once(auth::init); } -/// `createRole` with admin credentials — mirrors Python `create_role` + default `gql(..., ADMIN_HEADERS)`. +fn gql(client: &RaphtoryGraphQLClient, query: &str) -> HashMap { + RUNTIME + .block_on(client.query(query, HashMap::new())) + .unwrap_or_else(|e| panic!("Error executing query {query}: {e}")) +} + fn create_role(client: &RaphtoryGraphQLClient, name: &str) { - let q = format!( + let query = format!( r#"mutation {{ permissions {{ createRole(name: "{name}") {{ success }} }} }}"# ); - let data = RUNTIME - .block_on(client.query(&q, HashMap::new())) - .unwrap_or_else(|e| panic!("createRole {name}: {e}")); + + let data = gql(client, &query); + let success = data .get("permissions") .and_then(|p| p.get("createRole")) .and_then(|c| c.get("success")) .and_then(JsonValue::as_bool); + assert_eq!(success, Some(true), "createRole {name} data: {data:?}"); } /// Create namespaces and graphs in graphql using the given tree. /// Every leaf node is turned into a graph using the path from root as the namespace. -fn create_graphs(tree: &Graph, url: Url, admin_token: &str) -> Vec { +fn create_graphs(client: &RaphtoryGraphQLClient, tree: &Graph) -> Vec { let mut graph_paths = Vec::new(); for node in tree.nodes() { @@ -79,13 +86,6 @@ fn create_graphs(tree: &Graph, url: Url, admin_token: &str) -> Vec { } } - let client = RUNTIME - .block_on(RaphtoryGraphQLClient::connect( - url, - Some(admin_token.to_string()), - )) - .unwrap(); - for path in graph_paths.iter() { RUNTIME .block_on(client.new_graph(path, "EVENT")) @@ -141,9 +141,9 @@ fn start_server() -> (RunningGraphServer, TempDir) { let work_dir = tempdir.path().to_path_buf(); let permissions_path = work_dir.join("permissions.json"); - // Same public key and default `require_auth_for_reads` as Python `test_permissions` server. + let app_config = AppConfigBuilder::new() - .with_auth_public_key(Some(AUTH_PUB_KEY.to_string())) + .with_auth_public_key(Some(PUB_KEY.to_string())) .expect("test auth public key") .build(); @@ -155,9 +155,11 @@ fn start_server() -> (RunningGraphServer, TempDir) { ) .await .unwrap(); - let server = apply_server_extension(server, Some(permissions_path.as_path())); + let server = apply_server_extension(server, Some(permissions_path.as_path())); let server = server.start_with_port(PORT).await.unwrap(); + + // Wait for server to start. tokio::time::sleep(Duration::from_secs(1)).await; (server, tempdir) @@ -175,17 +177,19 @@ fn permissions_proptest() { |(tree_size in TREE_SIZE_RANGE, num_users in NUM_USERS_RANGE)| { let url = Url::parse(&format!("http://127.0.0.1:{PORT}")).unwrap(); let (_server, _tempdir) = start_server(); - let namespace_tree = create_tree(tree_size); - let admin_token = ADMIN_JWT.as_str(); - let graph_paths = create_graphs(&namespace_tree, url.clone(), admin_token); let client = RUNTIME .block_on(RaphtoryGraphQLClient::connect( url, - Some(admin_token.to_string()), + Some(ADMIN_JWT.to_string()), )) .unwrap(); + // Create nested namespaces and graphs on the server. + let namespace_tree = create_tree(tree_size); + let graph_paths = create_graphs(&client, &namespace_tree); + + // Create roles for each user. for i in 0..num_users { let role = format!("user_{i}"); create_role(&client, &role); From b954d57fd61a5b0f68dc2b573f471773849b5cc3 Mon Sep 17 00:00:00 2001 From: Fadhil Abubaker Date: Mon, 27 Apr 2026 14:25:35 -0400 Subject: [PATCH 09/31] Generate strategy --- Cargo.lock | 16 +-- raphtory-graphql/tests/permissions.rs | 168 ++++++++--------------- raphtory-graphql/tests/utils/graphql.rs | 162 ++++++++++++++++++++++ raphtory-graphql/tests/utils/mod.rs | 2 + raphtory-graphql/tests/utils/strategy.rs | 108 +++++++++++++++ 5 files changed, 331 insertions(+), 125 deletions(-) create mode 100644 raphtory-graphql/tests/utils/graphql.rs create mode 100644 raphtory-graphql/tests/utils/mod.rs create mode 100644 raphtory-graphql/tests/utils/strategy.rs diff --git a/Cargo.lock b/Cargo.lock index 5ab081a75d..bb030240a6 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -6511,20 +6511,6 @@ dependencies = [ "syn 2.0.117", ] -[[package]] -name = "raphtory-auth" -version = "0.18.0" -dependencies = [ - "async-graphql", - "dynamic-graphql", - "futures-util", - "raphtory-graphql", - "serde", - "serde_json", - "thiserror 2.0.18", - "tracing", -] - [[package]] name = "raphtory-auth-noop" version = "0.18.0" @@ -6613,7 +6599,7 @@ dependencies = [ "rand 0.9.4", "raphtory", "raphtory-api", - "raphtory-auth", + "raphtory-auth-noop", "raphtory-storage", "rayon", "reqwest", diff --git a/raphtory-graphql/tests/permissions.rs b/raphtory-graphql/tests/permissions.rs index b4c9205c4b..6598cf0d4d 100644 --- a/raphtory-graphql/tests/permissions.rs +++ b/raphtory-graphql/tests/permissions.rs @@ -1,33 +1,33 @@ -use std::collections::HashMap; +mod utils; + +use std::collections::BTreeSet; +use std::hint::black_box; use std::ops::RangeInclusive; -use std::sync::Once; -use std::{hint::black_box, sync::LazyLock, time::Duration}; +use std::sync::LazyLock; use jsonwebtoken::{encode, Algorithm, EncodingKey, Header}; use proptest::prelude::*; +use proptest::strategy::ValueTree; use rand::prelude::IndexedRandom; -use raphtory::{algorithms::components::in_component, prelude::*}; -use raphtory::db::api::storage::storage::Config; -use raphtory_graphql::client::raphtory_client::RaphtoryGraphQLClient; -use raphtory_graphql::config::app_config::AppConfigBuilder; -use raphtory_graphql::server::{apply_server_extension, GraphServer, RunningGraphServer}; -use serde_json::{json, Value as JsonValue}; -use tempfile::TempDir; -use tokio::runtime::Runtime; +use raphtory::prelude::*; +use serde_json::json; use url::Url; +use utils::graphql::{ + create_graphs, create_role, get_client, + grant_graph, grant_namespace, start_server, +}; +use utils::strategy::{permissions_strategy, PermissionTarget}; + const PORT: u16 = 43871; +// Borrowed from test_permissions.py. const PUB_KEY: &str = "MCowBQYDK2VwAyEADdrWr1kTLj+wSHlr45eneXmOjlHo3N1DjLIvDa2ozno="; const PRIVATE_KEY: &str = "-----BEGIN PRIVATE KEY----- MC4CAQAwBQYDK2VwBCIEIFzEcSO/duEjjX4qKxDVy4uLqfmiEIA6bEw1qiPyzTQg -----END PRIVATE KEY-----"; -static AUTH_INIT: Once = Once::new(); -static RUNTIME: LazyLock = - LazyLock::new(|| Runtime::new().expect("Failed to create Tokio runtime")); - -static ADMIN_JWT: LazyLock = LazyLock::new(|| { +pub static ADMIN_JWT: LazyLock = LazyLock::new(|| { let key = EncodingKey::from_ed_pem(PRIVATE_KEY.as_bytes()) .expect("decode Ed25519 private key for test JWTs"); @@ -39,60 +39,19 @@ static ADMIN_JWT: LazyLock = LazyLock::new(|| { .expect("encode admin JWT") }); -fn init_raphtory_auth() { - AUTH_INIT.call_once(auth::init); -} - -fn gql(client: &RaphtoryGraphQLClient, query: &str) -> HashMap { - RUNTIME - .block_on(client.query(query, HashMap::new())) - .unwrap_or_else(|e| panic!("Error executing query {query}: {e}")) -} - -fn create_role(client: &RaphtoryGraphQLClient, name: &str) { - let query = format!( - r#"mutation {{ permissions {{ createRole(name: "{name}") {{ success }} }} }}"# - ); - - let data = gql(client, &query); - - let success = data - .get("permissions") - .and_then(|p| p.get("createRole")) - .and_then(|c| c.get("success")) - .and_then(JsonValue::as_bool); - - assert_eq!(success, Some(true), "createRole {name} data: {data:?}"); -} - -/// Create namespaces and graphs in graphql using the given tree. -/// Every leaf node is turned into a graph using the path from root as the namespace. -fn create_graphs(client: &RaphtoryGraphQLClient, tree: &Graph) -> Vec { - let mut graph_paths = Vec::new(); - - for node in tree.nodes() { - if node.out_neighbours().len() == 0 { // Leaf node - // In-component order yields path from root to node. - let mut in_components = in_component(node.clone()) - .iter() - .map(|(node_view, _)| node_view.name()) - .collect::>(); +pub fn namespace_paths(graph_paths: &[String]) -> Vec { + let mut namespaces = BTreeSet::new(); - // Include the node itself in the path. - in_components.push(node.name()); - - let path = in_components.join("/"); - graph_paths.push(path); + for path in graph_paths { + let mut end = 0usize; + while let Some(rel) = path[end..].find('/') { + end += rel; + namespaces.insert(path[..end].to_string()); + end += 1; } } - for path in graph_paths.iter() { - RUNTIME - .block_on(client.new_graph(path, "EVENT")) - .unwrap(); - } - - graph_paths + namespaces.into_iter().collect() } /// Create a random tree with the given number of nodes. @@ -130,42 +89,6 @@ fn create_tree(nodes: u64) -> Graph { graph } -fn start_server() -> (RunningGraphServer, TempDir) { - init_raphtory_auth(); - - RUNTIME.block_on(async { - let mut tempdir = TempDir::new().unwrap(); - - // Prevent server drop from failing due to tempdir cleanup. - tempdir.disable_cleanup(true); - - let work_dir = tempdir.path().to_path_buf(); - let permissions_path = work_dir.join("permissions.json"); - - let app_config = AppConfigBuilder::new() - .with_auth_public_key(Some(PUB_KEY.to_string())) - .expect("test auth public key") - .build(); - - let server = GraphServer::new( - work_dir, - Some(app_config), - None, - Config::default(), - ) - .await - .unwrap(); - - let server = apply_server_extension(server, Some(permissions_path.as_path())); - let server = server.start_with_port(PORT).await.unwrap(); - - // Wait for server to start. - tokio::time::sleep(Duration::from_secs(1)).await; - - (server, tempdir) - }) -} - #[test] fn permissions_proptest() { const PROPTEST_CASES: u32 = 10; @@ -176,18 +99,13 @@ fn permissions_proptest() { ProptestConfig::with_cases(PROPTEST_CASES), |(tree_size in TREE_SIZE_RANGE, num_users in NUM_USERS_RANGE)| { let url = Url::parse(&format!("http://127.0.0.1:{PORT}")).unwrap(); - let (_server, _tempdir) = start_server(); - - let client = RUNTIME - .block_on(RaphtoryGraphQLClient::connect( - url, - Some(ADMIN_JWT.to_string()), - )) - .unwrap(); + let (_server, _tempdir) = start_server(PORT, PUB_KEY); + let client = get_client(url, ADMIN_JWT.to_string()); // Create nested namespaces and graphs on the server. let namespace_tree = create_tree(tree_size); let graph_paths = create_graphs(&client, &namespace_tree); + let namespace_paths = namespace_paths(&graph_paths); // Create roles for each user. for i in 0..num_users { @@ -195,9 +113,39 @@ fn permissions_proptest() { create_role(&client, &role); } + let mut runner = proptest::test_runner::TestRunner::default(); + let grants = permissions_strategy(num_users, graph_paths.len(), namespace_paths.len()) + .new_tree(&mut runner) + .unwrap() + .current(); + + for grant in &grants { + let role = format!("user_{}", grant.user_idx); + let (path, target) = match grant.target { + PermissionTarget::Graph => ( + &graph_paths[grant.path_idx], + PermissionTarget::Graph, + ), + PermissionTarget::Namespace => ( + &namespace_paths[grant.path_idx], + PermissionTarget::Namespace, + ), + }; + match target { + PermissionTarget::Graph => { + grant_graph(&client, &role, path, grant.graph_permission) + } + PermissionTarget::Namespace => { + grant_namespace(&client, &role, path, grant.namespace_permission) + } + } + } + black_box(_server); black_box(_tempdir); black_box(graph_paths); + black_box(namespace_paths); + black_box(grants); } ); } diff --git a/raphtory-graphql/tests/utils/graphql.rs b/raphtory-graphql/tests/utils/graphql.rs new file mode 100644 index 0000000000..8e148a5b9c --- /dev/null +++ b/raphtory-graphql/tests/utils/graphql.rs @@ -0,0 +1,162 @@ +use std::collections::HashMap; +use std::sync::{LazyLock, Once}; +use std::time::Duration; + +use raphtory::algorithms::components::in_component; +use raphtory::db::api::storage::storage::Config; +use raphtory::prelude::{Graph, GraphViewOps, NodeStateOps, NodeViewOps}; +use raphtory_graphql::client::raphtory_client::RaphtoryGraphQLClient; +use raphtory_graphql::config::app_config::AppConfigBuilder; +use raphtory_graphql::server::{apply_server_extension, GraphServer, RunningGraphServer}; +use serde_json::Value as JsonValue; +use tempfile::TempDir; +use tokio::runtime::Runtime; +use url::Url; + +use crate::utils::strategy::{GraphPermission, NamespacePermission}; + +static AUTH_INIT: Once = Once::new(); + +fn init_raphtory_auth() { + AUTH_INIT.call_once(::auth::init); +} + +pub static RUNTIME: LazyLock = + LazyLock::new(|| Runtime::new().expect("Failed to create Tokio runtime")); + +pub fn get_client(url: Url, token: String) -> RaphtoryGraphQLClient { + RUNTIME + .block_on(RaphtoryGraphQLClient::connect(url, Some(token))) + .expect("connect GraphQL client") +} + +pub fn start_server(port: u16, pub_key: &str) -> (RunningGraphServer, TempDir) { + init_raphtory_auth(); + + RUNTIME.block_on(async { + let mut tempdir = TempDir::new().unwrap(); + tempdir.disable_cleanup(true); + + let work_dir = tempdir.path().to_path_buf(); + let permissions_path = work_dir.join("permissions.json"); + + let app_config = AppConfigBuilder::new() + .with_auth_public_key(Some(pub_key.to_string())) + .expect("test auth public key") + .build(); + + let server = GraphServer::new(work_dir, Some(app_config), None, Config::default()) + .await + .unwrap(); + + // Configure permissions store. + let server = apply_server_extension(server, Some(permissions_path.as_path())); + let server = server.start_with_port(port).await.unwrap(); + + // Wait for server to start. + tokio::time::sleep(Duration::from_secs(1)).await; + + (server, tempdir) + }) +} + +fn gql(client: &RaphtoryGraphQLClient, query: &str) -> HashMap { + RUNTIME + .block_on(client.query(query, HashMap::new())) + .unwrap_or_else(|e| panic!("Error executing query {query}: {e}")) +} + +pub fn create_role(client: &RaphtoryGraphQLClient, name: &str) { + let query = + format!(r#"mutation {{ permissions {{ createRole(name: "{name}") {{ success }} }} }}"#); + + let data = gql(client, &query); + + let success = data + .get("permissions") + .and_then(|p| p.get("createRole")) + .and_then(|c| c.get("success")) + .and_then(JsonValue::as_bool); + + assert_eq!(success, Some(true), "createRole {name} data: {data:?}"); +} + +pub fn grant_graph( + client: &RaphtoryGraphQLClient, + role: &str, + path: &str, + permission: GraphPermission, +) { + let query = format!( + r#"mutation {{ permissions {{ grantGraph(role: "{role}", path: "{path}", permission: {}) {{ success }} }} }}"#, + permission.as_gql() + ); + + let data = gql(client, &query); + + let success = data + .get("permissions") + .and_then(|p| p.get("grantGraph")) + .and_then(|c| c.get("success")) + .and_then(JsonValue::as_bool); + + assert_eq!( + success, + Some(true), + "grantGraph role={role} path={path} permission={} data: {data:?}", + permission.as_gql() + ); +} + +pub fn grant_namespace( + client: &RaphtoryGraphQLClient, + role: &str, + path: &str, + permission: NamespacePermission, +) { + let query = format!( + r#"mutation {{ permissions {{ grantNamespace(role: "{role}", path: "{path}", permission: {}) {{ success }} }} }}"#, + permission.as_gql() + ); + + let data = gql(client, &query); + + let success = data + .get("permissions") + .and_then(|p| p.get("grantNamespace")) + .and_then(|c| c.get("success")) + .and_then(JsonValue::as_bool); + + assert_eq!( + success, + Some(true), + "grantNamespace role={role} path={path} permission={} data: {data:?}", + permission.as_gql() + ); +} + +/// Create namespaces and graphs in graphql using the given tree. +/// Each leaf node is turned into a graph, all other nodes are turned into namespaces. +pub fn create_graphs(client: &RaphtoryGraphQLClient, tree: &Graph) -> Vec { + let mut graph_paths = Vec::new(); + + for node in tree.nodes() { + if node.out_neighbours().is_empty() { // Leaf node + // In-components are sorted by distance from the leaf node. + let mut in_components = in_component(node.clone()) + .iter() + .map(|(node_view, _)| node_view.name()) + .collect::>(); + + // Include the leaf node itself in the path. + in_components.push(node.name()); + graph_paths.push(in_components.join("/")); + } + } + + for path in &graph_paths { + RUNTIME.block_on(client.new_graph(path, "EVENT")).unwrap(); + } + + graph_paths +} diff --git a/raphtory-graphql/tests/utils/mod.rs b/raphtory-graphql/tests/utils/mod.rs new file mode 100644 index 0000000000..777c2f02ad --- /dev/null +++ b/raphtory-graphql/tests/utils/mod.rs @@ -0,0 +1,2 @@ +pub mod graphql; +pub mod strategy; diff --git a/raphtory-graphql/tests/utils/strategy.rs b/raphtory-graphql/tests/utils/strategy.rs new file mode 100644 index 0000000000..12634056d4 --- /dev/null +++ b/raphtory-graphql/tests/utils/strategy.rs @@ -0,0 +1,108 @@ +use proptest::prelude::*; + +const MAX_GRANTS_PER_CASE: usize = 50; + +#[derive(Debug, Clone, Copy)] +pub enum PermissionTarget { + Graph, + Namespace, +} + +#[derive(Debug, Clone, Copy)] +pub enum GraphPermission { + Read, + Write, +} + +impl GraphPermission { + pub(crate) fn as_gql(self) -> &'static str { + match self { + GraphPermission::Read => "READ", + GraphPermission::Write => "WRITE", + } + } +} + +#[derive(Debug, Clone, Copy)] +pub enum NamespacePermission { + Discover, + Introspect, + Read, + Write, +} + +impl NamespacePermission { + pub(crate) fn as_gql(self) -> &'static str { + match self { + NamespacePermission::Discover => "DISCOVER", + NamespacePermission::Introspect => "INTROSPECT", + NamespacePermission::Read => "READ", + NamespacePermission::Write => "WRITE", + } + } +} + +#[derive(Debug, Clone)] +pub struct PermissionGrant { + pub user_idx: u64, + pub target: PermissionTarget, + pub path_idx: usize, + pub graph_permission: GraphPermission, + pub namespace_permission: NamespacePermission, +} + +pub fn permissions_strategy( + num_users: u64, + num_graph_paths: usize, + num_namespace_paths: usize, +) -> impl Strategy> { + let max_user = num_users.saturating_sub(1); + let max_graph = num_graph_paths.saturating_sub(1); + let max_namespace = num_namespace_paths.saturating_sub(1); + let namespace_available = num_namespace_paths > 0; + + prop::collection::vec( + ( + 0..=max_user, + any::(), + prop_oneof![Just(GraphPermission::Read), Just(GraphPermission::Write)], + prop_oneof![ + Just(NamespacePermission::Discover), + Just(NamespacePermission::Introspect), + Just(NamespacePermission::Read), + Just(NamespacePermission::Write), + ], + 0usize..=max_graph, + 0usize..=max_namespace, + ) + .prop_map( + move |( + user_idx, + choose_namespace, + graph_permission, + namespace_permission, + graph_idx, + namespace_idx, + )| { + let target = if choose_namespace && namespace_available { + PermissionTarget::Namespace + } else { + PermissionTarget::Graph + }; + let path_idx = match target { + PermissionTarget::Graph => graph_idx, + PermissionTarget::Namespace => namespace_idx, + }; + + PermissionGrant { + user_idx, + target, + path_idx, + graph_permission, + namespace_permission, + } + }, + ), + 1..=MAX_GRANTS_PER_CASE, + ) +} From 79026a45e848544f4f1f736080bde8dc42f50669 Mon Sep 17 00:00:00 2001 From: Fadhil Abubaker Date: Mon, 27 Apr 2026 18:08:47 -0400 Subject: [PATCH 10/31] Remove DISCOVER from namespace permissions --- raphtory-graphql/tests/permissions.rs | 8 ++++++-- raphtory-graphql/tests/utils/strategy.rs | 9 +++------ 2 files changed, 9 insertions(+), 8 deletions(-) diff --git a/raphtory-graphql/tests/permissions.rs b/raphtory-graphql/tests/permissions.rs index 6598cf0d4d..e43a2b533b 100644 --- a/raphtory-graphql/tests/permissions.rs +++ b/raphtory-graphql/tests/permissions.rs @@ -98,8 +98,9 @@ fn permissions_proptest() { proptest!( ProptestConfig::with_cases(PROPTEST_CASES), |(tree_size in TREE_SIZE_RANGE, num_users in NUM_USERS_RANGE)| { - let url = Url::parse(&format!("http://127.0.0.1:{PORT}")).unwrap(); let (_server, _tempdir) = start_server(PORT, PUB_KEY); + + let url = Url::parse(&format!("http://127.0.0.1:{PORT}")).unwrap(); let client = get_client(url, ADMIN_JWT.to_string()); // Create nested namespaces and graphs on the server. @@ -114,13 +115,15 @@ fn permissions_proptest() { } let mut runner = proptest::test_runner::TestRunner::default(); + let grants = permissions_strategy(num_users, graph_paths.len(), namespace_paths.len()) .new_tree(&mut runner) .unwrap() .current(); for grant in &grants { - let role = format!("user_{}", grant.user_idx); + let role = format!("user_{}", grant.user_id); + let (path, target) = match grant.target { PermissionTarget::Graph => ( &graph_paths[grant.path_idx], @@ -131,6 +134,7 @@ fn permissions_proptest() { PermissionTarget::Namespace, ), }; + match target { PermissionTarget::Graph => { grant_graph(&client, &role, path, grant.graph_permission) diff --git a/raphtory-graphql/tests/utils/strategy.rs b/raphtory-graphql/tests/utils/strategy.rs index 12634056d4..fcd939b164 100644 --- a/raphtory-graphql/tests/utils/strategy.rs +++ b/raphtory-graphql/tests/utils/strategy.rs @@ -25,7 +25,6 @@ impl GraphPermission { #[derive(Debug, Clone, Copy)] pub enum NamespacePermission { - Discover, Introspect, Read, Write, @@ -34,7 +33,6 @@ pub enum NamespacePermission { impl NamespacePermission { pub(crate) fn as_gql(self) -> &'static str { match self { - NamespacePermission::Discover => "DISCOVER", NamespacePermission::Introspect => "INTROSPECT", NamespacePermission::Read => "READ", NamespacePermission::Write => "WRITE", @@ -44,7 +42,7 @@ impl NamespacePermission { #[derive(Debug, Clone)] pub struct PermissionGrant { - pub user_idx: u64, + pub user_id: u64, pub target: PermissionTarget, pub path_idx: usize, pub graph_permission: GraphPermission, @@ -67,7 +65,6 @@ pub fn permissions_strategy( any::(), prop_oneof![Just(GraphPermission::Read), Just(GraphPermission::Write)], prop_oneof![ - Just(NamespacePermission::Discover), Just(NamespacePermission::Introspect), Just(NamespacePermission::Read), Just(NamespacePermission::Write), @@ -77,7 +74,7 @@ pub fn permissions_strategy( ) .prop_map( move |( - user_idx, + user_id, choose_namespace, graph_permission, namespace_permission, @@ -95,7 +92,7 @@ pub fn permissions_strategy( }; PermissionGrant { - user_idx, + user_id, target, path_idx, graph_permission, From b9b3b202345f922bd88caa64f8b5a9b32b40beb2 Mon Sep 17 00:00:00 2001 From: Fadhil Abubaker Date: Tue, 28 Apr 2026 11:58:43 -0400 Subject: [PATCH 11/31] Remove in_components and use dfs --- raphtory-graphql/tests/permissions.rs | 116 +++--------- raphtory-graphql/tests/utils/graphql.rs | 23 +-- raphtory-graphql/tests/utils/strategy.rs | 214 +++++++++++++++++++---- raphtory/src/db/api/view/graph.rs | 2 +- 4 files changed, 210 insertions(+), 145 deletions(-) diff --git a/raphtory-graphql/tests/permissions.rs b/raphtory-graphql/tests/permissions.rs index e43a2b533b..ea84e4d299 100644 --- a/raphtory-graphql/tests/permissions.rs +++ b/raphtory-graphql/tests/permissions.rs @@ -1,15 +1,10 @@ mod utils; -use std::collections::BTreeSet; -use std::hint::black_box; use std::ops::RangeInclusive; use std::sync::LazyLock; use jsonwebtoken::{encode, Algorithm, EncodingKey, Header}; use proptest::prelude::*; -use proptest::strategy::ValueTree; -use rand::prelude::IndexedRandom; -use raphtory::prelude::*; use serde_json::json; use url::Url; @@ -17,7 +12,7 @@ use utils::graphql::{ create_graphs, create_role, get_client, grant_graph, grant_namespace, start_server, }; -use utils::strategy::{permissions_strategy, PermissionTarget}; +use utils::strategy::{permissions_strategy, PermissionGrant}; const PORT: u16 = 43871; @@ -39,117 +34,50 @@ pub static ADMIN_JWT: LazyLock = LazyLock::new(|| { .expect("encode admin JWT") }); -pub fn namespace_paths(graph_paths: &[String]) -> Vec { - let mut namespaces = BTreeSet::new(); - - for path in graph_paths { - let mut end = 0usize; - while let Some(rel) = path[end..].find('/') { - end += rel; - namespaces.insert(path[..end].to_string()); - end += 1; - } - } - - namespaces.into_iter().collect() -} - -/// Create a random tree with the given number of nodes. -fn create_tree(nodes: u64) -> Graph { - let graph = Graph::new(); - - if nodes == 0 { - return graph; - } - - for node in 0..nodes { - let name = format!("node_{node}"); - - graph - .add_node(0, name, NO_PROPS, None, None) - .unwrap(); - } - - let mut rng = rand::rng(); - let mut available_parents = vec![0]; // start with root - - // For each node, add an edge to a random parent. - for node in 1..nodes { - let parent = available_parents.choose(&mut rng).unwrap(); - let parent_name = format!("node_{parent}"); - let node_name = format!("node_{node}"); - - graph - .add_edge(0, parent_name, node_name, NO_PROPS, None) - .unwrap(); - - available_parents.push(node); - } - - graph -} #[test] fn permissions_proptest() { const PROPTEST_CASES: u32 = 10; - const TREE_SIZE_RANGE: RangeInclusive = 1..=20; - const NUM_USERS_RANGE: RangeInclusive = 1..=10; + const NAMESPACE_SIZE: RangeInclusive = 1..=20; + const NUM_USERS: RangeInclusive = 1..=10; proptest!( ProptestConfig::with_cases(PROPTEST_CASES), - |(tree_size in TREE_SIZE_RANGE, num_users in NUM_USERS_RANGE)| { + |(case in permissions_strategy(NAMESPACE_SIZE, NUM_USERS))| { let (_server, _tempdir) = start_server(PORT, PUB_KEY); let url = Url::parse(&format!("http://127.0.0.1:{PORT}")).unwrap(); let client = get_client(url, ADMIN_JWT.to_string()); // Create nested namespaces and graphs on the server. - let namespace_tree = create_tree(tree_size); - let graph_paths = create_graphs(&client, &namespace_tree); - let namespace_paths = namespace_paths(&graph_paths); + create_graphs(&client, &case.namespace_tree); // Create roles for each user. - for i in 0..num_users { + for i in 0..case.num_users { let role = format!("user_{i}"); create_role(&client, &role); } - let mut runner = proptest::test_runner::TestRunner::default(); - - let grants = permissions_strategy(num_users, graph_paths.len(), namespace_paths.len()) - .new_tree(&mut runner) - .unwrap() - .current(); - - for grant in &grants { - let role = format!("user_{}", grant.user_id); - - let (path, target) = match grant.target { - PermissionTarget::Graph => ( - &graph_paths[grant.path_idx], - PermissionTarget::Graph, - ), - PermissionTarget::Namespace => ( - &namespace_paths[grant.path_idx], - PermissionTarget::Namespace, - ), - }; - - match target { - PermissionTarget::Graph => { - grant_graph(&client, &role, path, grant.graph_permission) + for grant in &case.grants { + match grant { + PermissionGrant::Graph { + user_id, + path, + permission, + } => { + let role = format!("user_{user_id}"); + grant_graph(&client, &role, path, *permission) } - PermissionTarget::Namespace => { - grant_namespace(&client, &role, path, grant.namespace_permission) + PermissionGrant::Namespace { + user_id, + path, + permission, + } => { + let role = format!("user_{user_id}"); + grant_namespace(&client, &role, path, *permission) } } } - - black_box(_server); - black_box(_tempdir); - black_box(graph_paths); - black_box(namespace_paths); - black_box(grants); } ); } diff --git a/raphtory-graphql/tests/utils/graphql.rs b/raphtory-graphql/tests/utils/graphql.rs index 8e148a5b9c..9a6752a4a6 100644 --- a/raphtory-graphql/tests/utils/graphql.rs +++ b/raphtory-graphql/tests/utils/graphql.rs @@ -2,9 +2,8 @@ use std::collections::HashMap; use std::sync::{LazyLock, Once}; use std::time::Duration; -use raphtory::algorithms::components::in_component; use raphtory::db::api::storage::storage::Config; -use raphtory::prelude::{Graph, GraphViewOps, NodeStateOps, NodeViewOps}; +use raphtory::prelude::Graph; use raphtory_graphql::client::raphtory_client::RaphtoryGraphQLClient; use raphtory_graphql::config::app_config::AppConfigBuilder; use raphtory_graphql::server::{apply_server_extension, GraphServer, RunningGraphServer}; @@ -13,7 +12,7 @@ use tempfile::TempDir; use tokio::runtime::Runtime; use url::Url; -use crate::utils::strategy::{GraphPermission, NamespacePermission}; +use crate::utils::strategy::{leaf_paths, GraphPermission, NamespacePermission}; static AUTH_INIT: Once = Once::new(); @@ -35,6 +34,8 @@ pub fn start_server(port: u16, pub_key: &str) -> (RunningGraphServer, TempDir) { RUNTIME.block_on(async { let mut tempdir = TempDir::new().unwrap(); + + // Prevent cleanup so server can flush to workdir after drop. tempdir.disable_cleanup(true); let work_dir = tempdir.path().to_path_buf(); @@ -138,21 +139,7 @@ pub fn grant_namespace( /// Create namespaces and graphs in graphql using the given tree. /// Each leaf node is turned into a graph, all other nodes are turned into namespaces. pub fn create_graphs(client: &RaphtoryGraphQLClient, tree: &Graph) -> Vec { - let mut graph_paths = Vec::new(); - - for node in tree.nodes() { - if node.out_neighbours().is_empty() { // Leaf node - // In-components are sorted by distance from the leaf node. - let mut in_components = in_component(node.clone()) - .iter() - .map(|(node_view, _)| node_view.name()) - .collect::>(); - - // Include the leaf node itself in the path. - in_components.push(node.name()); - graph_paths.push(in_components.join("/")); - } - } + let graph_paths = leaf_paths(tree); for path in &graph_paths { RUNTIME.block_on(client.new_graph(path, "EVENT")).unwrap(); diff --git a/raphtory-graphql/tests/utils/strategy.rs b/raphtory-graphql/tests/utils/strategy.rs index fcd939b164..80b4c71b07 100644 --- a/raphtory-graphql/tests/utils/strategy.rs +++ b/raphtory-graphql/tests/utils/strategy.rs @@ -1,11 +1,138 @@ +use std::ops::RangeInclusive; use proptest::prelude::*; +use proptest::strategy::BoxedStrategy; +use raphtory::prelude::*; const MAX_GRANTS_PER_CASE: usize = 50; -#[derive(Debug, Clone, Copy)] -pub enum PermissionTarget { - Graph, - Namespace, +/// Build a namespace tree with the given number of nodes and parent relationships. +/// `parents` is a slice of indices that represent the parent of each node, excluding the root. +pub fn build_namespace_tree(nodes: usize, parents: &[usize]) -> Graph { + let graph = Graph::new(); + + if parents.len() != nodes - 1 { + panic!("parents must have length nodes - 1"); + } + + if nodes == 0 { + return graph; + } + + for node in 0..nodes { + let name = format!("node_{node}"); + + graph + .add_node(0, name, NO_PROPS, None, None) + .unwrap(); + } + + for node in 1..nodes { + let parent = parents[node - 1]; + let parent_name = format!("node_{parent}"); + let node_name = format!("node_{node}"); + + graph + .add_edge(0, parent_name, node_name, NO_PROPS, None) + .unwrap(); + } + + graph +} + +/// Build paths for all leaf nodes in the tree. +/// Example: a -> b -> c returns ["a/b/c"] +pub fn leaf_paths(tree: &Graph) -> Vec { + let mut stack = Vec::new(); + let root = tree.node("node_0").unwrap(); + let parent_path = "".to_string(); + let mut leaves = Vec::new(); + + stack.push((root, parent_path)); + + while let Some((node, parent_path)) = stack.pop() { + // Prevent leading slash in paths. + let node_path = if parent_path.is_empty() { + node.name() + } else { + [parent_path, node.name()].join("/") + }; + + let neighbours = node.out_neighbours(); + + if neighbours.is_empty() { + leaves.push(node_path); + } else { + for neighbour in node.out_neighbours() { + stack.push((neighbour, node_path.clone())); + } + } + } + + leaves +} + +/// Build paths for all branch nodes in the tree. +/// Example: a -> b -> c returns ["a", "a/b"] +pub fn branch_paths(tree: &Graph) -> Vec { + let mut stack = Vec::new(); + let root = tree.node("node_0").unwrap(); + let parent_path = "".to_string(); + let mut branches = Vec::new(); + + stack.push((root, parent_path)); + + while let Some((node, parent_path)) = stack.pop() { + // Prevent leading slash in paths. + let node_path = if parent_path.is_empty() { + node.name() + } else { + [parent_path, node.name()].join("/") + }; + + let neighbours = node.out_neighbours(); + + if !neighbours.is_empty() { + branches.push(node_path.clone()); + + for neighbour in neighbours { + stack.push((neighbour, node_path.clone())); + } + } + } + + branches +} + +/// Create a strategy to pick a parent for each node in a tree. +fn parents_strategy(tree_size: usize) -> BoxedStrategy> { + if tree_size <= 1 { + return Just(vec![]).boxed(); + } + + let mut strategy: BoxedStrategy> = Just(vec![]).boxed(); + + for node in 1..tree_size { + strategy = strategy + .prop_flat_map(move |parents| { + // Pick a random parent for the current node from existing nodes seen so far. + (0usize..node).prop_map(move |parent| { + let mut parents = parents.clone(); + + parents.push(parent); + parents + }) + }) + .boxed(); + } + + strategy +} + +#[derive(Debug, Clone)] +pub struct PermissionsCase { + pub num_users: usize, + pub namespace_tree: Graph, + pub grants: Vec, } #[derive(Debug, Clone, Copy)] @@ -41,23 +168,28 @@ impl NamespacePermission { } #[derive(Debug, Clone)] -pub struct PermissionGrant { - pub user_id: u64, - pub target: PermissionTarget, - pub path_idx: usize, - pub graph_permission: GraphPermission, - pub namespace_permission: NamespacePermission, +pub enum PermissionGrant { + Graph { + user_id: usize, + path: String, + permission: GraphPermission, + }, + Namespace { + user_id: usize, + path: String, + permission: NamespacePermission, + }, } -pub fn permissions_strategy( - num_users: u64, - num_graph_paths: usize, - num_namespace_paths: usize, +fn grants_strategy( + num_users: usize, + graph_paths: Vec, + namespace_paths: Vec, ) -> impl Strategy> { let max_user = num_users.saturating_sub(1); - let max_graph = num_graph_paths.saturating_sub(1); - let max_namespace = num_namespace_paths.saturating_sub(1); - let namespace_available = num_namespace_paths > 0; + let max_graph = graph_paths.len().saturating_sub(1); + let max_namespace = namespace_paths.len().saturating_sub(1); + let namespace_available = !namespace_paths.is_empty(); prop::collection::vec( ( @@ -81,25 +213,43 @@ pub fn permissions_strategy( graph_idx, namespace_idx, )| { - let target = if choose_namespace && namespace_available { - PermissionTarget::Namespace + if choose_namespace && namespace_available { + PermissionGrant::Namespace { + user_id, + path: namespace_paths[namespace_idx].clone(), + permission: namespace_permission, + } } else { - PermissionTarget::Graph - }; - let path_idx = match target { - PermissionTarget::Graph => graph_idx, - PermissionTarget::Namespace => namespace_idx, - }; - - PermissionGrant { - user_id, - target, - path_idx, - graph_permission, - namespace_permission, + PermissionGrant::Graph { + user_id, + path: graph_paths[graph_idx].clone(), + permission: graph_permission, + } } }, ), 1..=MAX_GRANTS_PER_CASE, ) } + +pub fn permissions_strategy( + namespace_size: RangeInclusive, + num_users: RangeInclusive, +) -> BoxedStrategy { + (namespace_size, num_users) + .prop_flat_map(|(namespace_size, num_users)| { + parents_strategy(namespace_size).prop_flat_map(move |parents| { + let namespace_tree = build_namespace_tree(namespace_size, &parents); + let leaf_paths = leaf_paths(&namespace_tree); + let branch_paths = branch_paths(&namespace_tree); + + grants_strategy(num_users, leaf_paths.clone(), branch_paths.clone()) + .prop_map(move |grants| PermissionsCase { + num_users, + namespace_tree: namespace_tree.clone(), + grants, + }) + }) + }) + .boxed() +} diff --git a/raphtory/src/db/api/view/graph.rs b/raphtory/src/db/api/view/graph.rs index 73c935c7c4..9a03756686 100644 --- a/raphtory/src/db/api/view/graph.rs +++ b/raphtory/src/db/api/view/graph.rs @@ -305,7 +305,7 @@ fn materialize_impl( node_meta.set_layer_mapper(layer_meta.clone()); - // Create new WAL file for the new materialized graph. + // Create a new Extension instance for the new materialized graph. let ext = Extension::new(config, path)?; let temporal_graph = TemporalGraph::new_with_meta( From 64e07a9bd3e469c4c4f8da4b8c81e0f6497bd010 Mon Sep 17 00:00:00 2001 From: Fadhil Abubaker Date: Tue, 28 Apr 2026 12:38:17 -0400 Subject: [PATCH 12/31] Simplify building parent edges --- raphtory-graphql/tests/permissions.rs | 11 +- raphtory-graphql/tests/utils/graphql.rs | 16 +- raphtory-graphql/tests/utils/strategy.rs | 336 ++++++++++++----------- 3 files changed, 183 insertions(+), 180 deletions(-) diff --git a/raphtory-graphql/tests/permissions.rs b/raphtory-graphql/tests/permissions.rs index ea84e4d299..a19bcf322b 100644 --- a/raphtory-graphql/tests/permissions.rs +++ b/raphtory-graphql/tests/permissions.rs @@ -8,11 +8,12 @@ use proptest::prelude::*; use serde_json::json; use url::Url; +use utils::strategy::{permissions_strategy, PermissionGrant}; + use utils::graphql::{ - create_graphs, create_role, get_client, + create_graph, create_role, get_client, grant_graph, grant_namespace, start_server, }; -use utils::strategy::{permissions_strategy, PermissionGrant}; const PORT: u16 = 43871; @@ -49,8 +50,10 @@ fn permissions_proptest() { let url = Url::parse(&format!("http://127.0.0.1:{PORT}")).unwrap(); let client = get_client(url, ADMIN_JWT.to_string()); - // Create nested namespaces and graphs on the server. - create_graphs(&client, &case.namespace_tree); + // Create graphs on the server. + for path in &case.graph_paths { + create_graph(&client, path); + } // Create roles for each user. for i in 0..case.num_users { diff --git a/raphtory-graphql/tests/utils/graphql.rs b/raphtory-graphql/tests/utils/graphql.rs index 9a6752a4a6..ce30f74efc 100644 --- a/raphtory-graphql/tests/utils/graphql.rs +++ b/raphtory-graphql/tests/utils/graphql.rs @@ -3,7 +3,6 @@ use std::sync::{LazyLock, Once}; use std::time::Duration; use raphtory::db::api::storage::storage::Config; -use raphtory::prelude::Graph; use raphtory_graphql::client::raphtory_client::RaphtoryGraphQLClient; use raphtory_graphql::config::app_config::AppConfigBuilder; use raphtory_graphql::server::{apply_server_extension, GraphServer, RunningGraphServer}; @@ -12,7 +11,7 @@ use tempfile::TempDir; use tokio::runtime::Runtime; use url::Url; -use crate::utils::strategy::{leaf_paths, GraphPermission, NamespacePermission}; +use crate::utils::strategy::{GraphPermission, NamespacePermission, PermissionGrant}; static AUTH_INIT: Once = Once::new(); @@ -136,14 +135,7 @@ pub fn grant_namespace( ); } -/// Create namespaces and graphs in graphql using the given tree. -/// Each leaf node is turned into a graph, all other nodes are turned into namespaces. -pub fn create_graphs(client: &RaphtoryGraphQLClient, tree: &Graph) -> Vec { - let graph_paths = leaf_paths(tree); - - for path in &graph_paths { - RUNTIME.block_on(client.new_graph(path, "EVENT")).unwrap(); - } - - graph_paths +/// Create a graph on the graphql server using the given path. +pub fn create_graph(client: &RaphtoryGraphQLClient, path: &str) { + RUNTIME.block_on(client.new_graph(path, "EVENT")).unwrap(); } diff --git a/raphtory-graphql/tests/utils/strategy.rs b/raphtory-graphql/tests/utils/strategy.rs index 80b4c71b07..491df6f8c8 100644 --- a/raphtory-graphql/tests/utils/strategy.rs +++ b/raphtory-graphql/tests/utils/strategy.rs @@ -1,33 +1,192 @@ -use std::ops::RangeInclusive; +use std::{fmt, ops::RangeInclusive}; use proptest::prelude::*; use proptest::strategy::BoxedStrategy; use raphtory::prelude::*; -const MAX_GRANTS_PER_CASE: usize = 50; +#[derive(Clone)] +pub struct PermissionsCase { + pub num_users: usize, + pub grants: Vec, + pub graph_paths: Vec, + pub namespace_tree: Graph, +} -/// Build a namespace tree with the given number of nodes and parent relationships. -/// `parents` is a slice of indices that represent the parent of each node, excluding the root. -pub fn build_namespace_tree(nodes: usize, parents: &[usize]) -> Graph { - let graph = Graph::new(); +impl fmt::Debug for PermissionsCase { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + // Exclude the namespace tree to avoid printing large graphs. + f.debug_struct("PermissionsCase") + .field("num_users", &self.num_users) + .field("grants", &self.grants) + .field("graph_paths", &self.graph_paths) + .finish() + } +} + +#[derive(Debug, Clone)] +pub enum PermissionGrant { + Graph { + user_id: usize, + path: String, + permission: GraphPermission, + }, + Namespace { + user_id: usize, + path: String, + permission: NamespacePermission, + }, +} - if parents.len() != nodes - 1 { - panic!("parents must have length nodes - 1"); +#[derive(Debug, Clone, Copy)] +pub enum GraphPermission { + Read, + Write, +} + +impl GraphPermission { + pub(crate) fn as_gql(self) -> &'static str { + match self { + GraphPermission::Read => "READ", + GraphPermission::Write => "WRITE", + } } +} + +#[derive(Debug, Clone, Copy)] +pub enum NamespacePermission { + Introspect, + Read, + Write, +} + +impl NamespacePermission { + pub(crate) fn as_gql(self) -> &'static str { + match self { + NamespacePermission::Introspect => "INTROSPECT", + NamespacePermission::Read => "READ", + NamespacePermission::Write => "WRITE", + } + } +} + +pub fn permissions_strategy( + namespace_size: RangeInclusive, + num_users: RangeInclusive, +) -> BoxedStrategy { + (namespace_size, num_users) + .prop_flat_map(|(namespace_size, num_users)| { + parents_strategy(namespace_size).prop_flat_map(move |parents| { + let namespace_tree = build_namespace_tree(&parents); + let graph_paths = leaf_paths(&namespace_tree); + let namespace_paths = branch_paths(&namespace_tree); + + grants_strategy(num_users, graph_paths.clone(), namespace_paths).prop_map(move |grants| { + PermissionsCase { + num_users, + namespace_tree: namespace_tree.clone(), + graph_paths: graph_paths.clone(), + grants, + } + }) + }) + }) + .boxed() +} + +/// Create a strategy to pick a parent for each node in a tree. +fn parents_strategy(tree_size: usize) -> BoxedStrategy> { + if tree_size <= 1 { + return Just(vec![]).boxed(); + } + + // Root node has no parent. + let mut strategy: BoxedStrategy> = Just(vec![0]).boxed(); + + for node in 1..tree_size { + strategy = strategy + .prop_flat_map(move |parents| { + // Pick a random parent for the current node from existing nodes seen so far. + (0usize..node).prop_map(move |parent| { + let mut parents = parents.clone(); + + parents.push(parent); + parents + }) + }) + .boxed(); + } + + strategy +} + +fn grants_strategy( + num_users: usize, + graph_paths: Vec, + namespace_paths: Vec, +) -> impl Strategy> { + let max_user = num_users - 1; + let total_paths = namespace_paths.len() + graph_paths.len(); + let num_grants = 1..=total_paths; // FIXME: Increase this to 3x later. + + prop::collection::vec( + (0..=max_user, 0..total_paths) + .prop_flat_map(move |(user_id, path_idx)| { + // Choose either a namespace or a graph path based on path_idx. + if path_idx < namespace_paths.len() { + let path = namespace_paths[path_idx].clone(); + + prop_oneof![ + Just(NamespacePermission::Introspect), + Just(NamespacePermission::Read), + Just(NamespacePermission::Write), + ] + .prop_map(move |permission| PermissionGrant::Namespace { + user_id, + path: path.clone(), + permission, + }) + .boxed() + } else { + let graph_idx = path_idx - namespace_paths.len(); + let path = graph_paths[graph_idx].clone(); + + prop_oneof![ + Just(GraphPermission::Read), + Just(GraphPermission::Write), + ] + .prop_map(move |permission| PermissionGrant::Graph { + user_id, + path: path.clone(), + permission, + }) + .boxed() + } + }), + num_grants, + ) +} + +/// Build a namespace tree with the given number of nodes and parent relationships. +/// `parents` is a slice of indices that represent the parent of each node. +pub fn build_namespace_tree(parents: &[usize]) -> Graph { + let graph = Graph::new(); - if nodes == 0 { + if parents.len() == 0 { return graph; } - for node in 0..nodes { + for node in 0..parents.len() { let name = format!("node_{node}"); graph .add_node(0, name, NO_PROPS, None, None) .unwrap(); - } - for node in 1..nodes { - let parent = parents[node - 1]; + // Root node has no parent. + if node == 0 { + continue; + } + + let parent = parents[node]; let parent_name = format!("node_{parent}"); let node_name = format!("node_{node}"); @@ -102,154 +261,3 @@ pub fn branch_paths(tree: &Graph) -> Vec { branches } - -/// Create a strategy to pick a parent for each node in a tree. -fn parents_strategy(tree_size: usize) -> BoxedStrategy> { - if tree_size <= 1 { - return Just(vec![]).boxed(); - } - - let mut strategy: BoxedStrategy> = Just(vec![]).boxed(); - - for node in 1..tree_size { - strategy = strategy - .prop_flat_map(move |parents| { - // Pick a random parent for the current node from existing nodes seen so far. - (0usize..node).prop_map(move |parent| { - let mut parents = parents.clone(); - - parents.push(parent); - parents - }) - }) - .boxed(); - } - - strategy -} - -#[derive(Debug, Clone)] -pub struct PermissionsCase { - pub num_users: usize, - pub namespace_tree: Graph, - pub grants: Vec, -} - -#[derive(Debug, Clone, Copy)] -pub enum GraphPermission { - Read, - Write, -} - -impl GraphPermission { - pub(crate) fn as_gql(self) -> &'static str { - match self { - GraphPermission::Read => "READ", - GraphPermission::Write => "WRITE", - } - } -} - -#[derive(Debug, Clone, Copy)] -pub enum NamespacePermission { - Introspect, - Read, - Write, -} - -impl NamespacePermission { - pub(crate) fn as_gql(self) -> &'static str { - match self { - NamespacePermission::Introspect => "INTROSPECT", - NamespacePermission::Read => "READ", - NamespacePermission::Write => "WRITE", - } - } -} - -#[derive(Debug, Clone)] -pub enum PermissionGrant { - Graph { - user_id: usize, - path: String, - permission: GraphPermission, - }, - Namespace { - user_id: usize, - path: String, - permission: NamespacePermission, - }, -} - -fn grants_strategy( - num_users: usize, - graph_paths: Vec, - namespace_paths: Vec, -) -> impl Strategy> { - let max_user = num_users.saturating_sub(1); - let max_graph = graph_paths.len().saturating_sub(1); - let max_namespace = namespace_paths.len().saturating_sub(1); - let namespace_available = !namespace_paths.is_empty(); - - prop::collection::vec( - ( - 0..=max_user, - any::(), - prop_oneof![Just(GraphPermission::Read), Just(GraphPermission::Write)], - prop_oneof![ - Just(NamespacePermission::Introspect), - Just(NamespacePermission::Read), - Just(NamespacePermission::Write), - ], - 0usize..=max_graph, - 0usize..=max_namespace, - ) - .prop_map( - move |( - user_id, - choose_namespace, - graph_permission, - namespace_permission, - graph_idx, - namespace_idx, - )| { - if choose_namespace && namespace_available { - PermissionGrant::Namespace { - user_id, - path: namespace_paths[namespace_idx].clone(), - permission: namespace_permission, - } - } else { - PermissionGrant::Graph { - user_id, - path: graph_paths[graph_idx].clone(), - permission: graph_permission, - } - } - }, - ), - 1..=MAX_GRANTS_PER_CASE, - ) -} - -pub fn permissions_strategy( - namespace_size: RangeInclusive, - num_users: RangeInclusive, -) -> BoxedStrategy { - (namespace_size, num_users) - .prop_flat_map(|(namespace_size, num_users)| { - parents_strategy(namespace_size).prop_flat_map(move |parents| { - let namespace_tree = build_namespace_tree(namespace_size, &parents); - let leaf_paths = leaf_paths(&namespace_tree); - let branch_paths = branch_paths(&namespace_tree); - - grants_strategy(num_users, leaf_paths.clone(), branch_paths.clone()) - .prop_map(move |grants| PermissionsCase { - num_users, - namespace_tree: namespace_tree.clone(), - grants, - }) - }) - }) - .boxed() -} From 3bde749eff1f7c7ca81c3192bea5981a4f646912 Mon Sep 17 00:00:00 2001 From: Fadhil Abubaker Date: Wed, 29 Apr 2026 14:30:34 -0400 Subject: [PATCH 13/31] Add test for checking parent grant override --- python/tests/test_permissions.py | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/python/tests/test_permissions.py b/python/tests/test_permissions.py index 43488acc9d..ffd242730c 100644 --- a/python/tests/test_permissions.py +++ b/python/tests/test_permissions.py @@ -1288,6 +1288,28 @@ def test_child_namespace_restriction_overrides_parent(): assert "team/restricted/secret" in paths +def test_parent_grant_does_not_override_child_grant(): + """A direct child grant should remain effective after a broader parent READ grant.""" + work_dir = tempfile.mkdtemp() + with make_server(work_dir).start(): + gql(CREATE_DEEP) + create_role("analyst") + + update_deep = """query { updateGraph(path: "a/b/c") { addNode(time: 1, name: "child_write_node") { success } } }""" + + # Grant child graph WRITE first and verify write access works. + grant_graph("analyst", "a/b/c", "WRITE") + response = gql(update_deep, headers=ANALYST_HEADERS) + assert "errors" not in response, response + assert response["data"]["updateGraph"]["addNode"]["success"] is True + + # Then grant parent namespace READ; child graph WRITE should still win. + grant_namespace("analyst", "a", "READ") + response = gql(update_deep, headers=ANALYST_HEADERS) + assert "errors" not in response, response + assert response["data"]["updateGraph"]["addNode"]["success"] is True + + def test_discover_derivation(): """grantGraph READ on a namespaced graph → ancestor namespace gets DISCOVER (visible in children).""" work_dir = tempfile.mkdtemp() From 9e88638afaeb2d3839c52ea40a082c21f16f633e Mon Sep 17 00:00:00 2001 From: Fadhil Abubaker Date: Wed, 29 Apr 2026 18:33:44 -0400 Subject: [PATCH 14/31] Add test for most specific namespace grant --- python/tests/test_permissions.py | 29 +++++++++++++++++++++++++++-- 1 file changed, 27 insertions(+), 2 deletions(-) diff --git a/python/tests/test_permissions.py b/python/tests/test_permissions.py index ffd242730c..85d76efd2e 100644 --- a/python/tests/test_permissions.py +++ b/python/tests/test_permissions.py @@ -1288,8 +1288,8 @@ def test_child_namespace_restriction_overrides_parent(): assert "team/restricted/secret" in paths -def test_parent_grant_does_not_override_child_grant(): - """A direct child grant should remain effective after a broader parent READ grant.""" +def test_parent_does_not_override_child_namespace_restriction(): + """A direct child grant should remain effective after a broader parent grant.""" work_dir = tempfile.mkdtemp() with make_server(work_dir).start(): gql(CREATE_DEEP) @@ -1310,6 +1310,31 @@ def test_parent_grant_does_not_override_child_grant(): assert response["data"]["updateGraph"]["addNode"]["success"] is True +def test_most_specific_namespace_grant_applies(): + """Grant READ at a/b/c/d/e then WRITE at a/b/c. + The descendant graph a/b/c/d/e/f/g should retain READ-level access under specificity rules. + """ + CREATE_DEEP_LEAF = """mutation { newGraph(path:"a/b/c/d/e/f/g", graphType:EVENT) }""" + QUERY_DEEP_LEAF = """query { graph(path: "a/b/c/d/e/f/g") { path } }""" + UPDATE_DEEP_LEAF = """query { updateGraph(path: "a/b/c/d/e/f/g") { addNode(time: 1, name: "from_test") { success } } }""" + work_dir = tempfile.mkdtemp() + with make_server(work_dir).start(): + gql(CREATE_DEEP_LEAF) + create_role("analyst") + + grant_namespace("analyst", "a/b/c/d/e", "READ") + grant_namespace("analyst", "a/b/c", "WRITE") + + response = gql(QUERY_DEEP_LEAF, headers=ANALYST_HEADERS) + assert "errors" not in response, response + assert response["data"]["graph"]["path"] == "a/b/c/d/e/f/g" + + response = gql(UPDATE_DEEP_LEAF, headers=ANALYST_HEADERS) + assert response["data"] is None or response["data"].get("updateGraph") is None + assert "errors" in response + assert "Access denied" in response["errors"][0]["message"] + + def test_discover_derivation(): """grantGraph READ on a namespaced graph → ancestor namespace gets DISCOVER (visible in children).""" work_dir = tempfile.mkdtemp() From ab448f87a6eea2c9cc67039e3e6d5c909db3dc7d Mon Sep 17 00:00:00 2001 From: Fadhil Abubaker Date: Wed, 29 Apr 2026 19:48:37 -0400 Subject: [PATCH 15/31] Track permission grants in individual namespace trees --- raphtory-graphql/tests/permissions.rs | 155 +++++++++++++++++++---- raphtory-graphql/tests/utils/graphql.rs | 37 ++++-- raphtory-graphql/tests/utils/strategy.rs | 49 +++---- 3 files changed, 177 insertions(+), 64 deletions(-) diff --git a/raphtory-graphql/tests/permissions.rs b/raphtory-graphql/tests/permissions.rs index a19bcf322b..500f03d909 100644 --- a/raphtory-graphql/tests/permissions.rs +++ b/raphtory-graphql/tests/permissions.rs @@ -5,14 +5,16 @@ use std::sync::LazyLock; use jsonwebtoken::{encode, Algorithm, EncodingKey, Header}; use proptest::prelude::*; +use raphtory::db::api::view::MaterializedGraph; +use raphtory::prelude::{GraphViewOps, NodeViewOps, Prop, PropUnwrap, PropertiesOps}; use serde_json::json; use url::Url; -use utils::strategy::{permissions_strategy, PermissionGrant}; +use utils::strategy::{permissions_strategy, Permission, PermissionGrant}; use utils::graphql::{ - create_graph, create_role, get_client, - grant_graph, grant_namespace, start_server, + create_graph, create_role, create_grant, get_client, + start_server, }; const PORT: u16 = 43871; @@ -23,7 +25,7 @@ const PRIVATE_KEY: &str = "-----BEGIN PRIVATE KEY----- MC4CAQAwBQYDK2VwBCIEIFzEcSO/duEjjX4qKxDVy4uLqfmiEIA6bEw1qiPyzTQg -----END PRIVATE KEY-----"; -pub static ADMIN_JWT: LazyLock = LazyLock::new(|| { +static ADMIN_JWT: LazyLock = LazyLock::new(|| { let key = EncodingKey::from_ed_pem(PRIVATE_KEY.as_bytes()) .expect("decode Ed25519 private key for test JWTs"); @@ -35,6 +37,122 @@ pub static ADMIN_JWT: LazyLock = LazyLock::new(|| { .expect("encode admin JWT") }); +// Track a permission grant across the given namespace tree. +fn track_grant(grant: &PermissionGrant, user_trees: &[MaterializedGraph]) { + match grant { + PermissionGrant::Graph { user_id, path, permission } => { + assert!(matches!(permission, Permission::Read | Permission::Write)); + + let user_tree = &user_trees[*user_id]; + + // Find the node in the tree corresponding to the given path. + let node_name = path.split('/').last().unwrap(); + let node = user_tree.node(node_name).unwrap(); + + // Update the node's direct permission. + node + .update_metadata(vec![ + ("permission", Prop::Str(permission.as_str().into())), + ("direct", Prop::Bool(true)), + ]) + .unwrap(); + + // Propagate discover to ancestor namespaces. + propagate_up(path, user_tree); + } + PermissionGrant::Namespace { user_id, path, permission } => { + assert!( + matches!(permission, Permission::Introspect | Permission::Read | Permission::Write) + ); + + let user_tree = &user_trees[*user_id]; + + // Find the node in the tree corresponding to the given path. + let node_name = path.split('/').last().unwrap(); + let node = user_tree.node(node_name).unwrap(); + + // Update the node's direct permission. + node + .update_metadata(vec![ + ("permission", Prop::Str(permission.as_str().into())), + ("direct", Prop::Bool(true)), + ]) + .unwrap(); + + // Propagate discover to ancestor namespaces. + propagate_up(path, user_tree); + + // Propagate the permission to descendant namespaces and graphs. + propagate_down(path, user_tree, *permission); + } + } +} + +// Propagate discover permissions to ancestor namespaces. +fn propagate_up(path: &str, user_tree: &MaterializedGraph) { + // Apply discover to each node in the path. + for node_name in path.split('/').filter(|segment| !segment.is_empty()) { + let node = user_tree.node(node_name).unwrap(); + + // Discover permissions have least precedence, set them if no other permission is set. + if node.metadata().get("permission").is_none() { + node + .update_metadata(vec![ + ("permission", Prop::Str(Permission::Discover.as_str().into())), + ("direct", Prop::Bool(false)), + ]) + .unwrap(); + } + } +} + +// Propagates a permission to descendant namespaces and graphs. +fn propagate_down(path: &str, user_tree: &MaterializedGraph, permission: Permission) { + let node_name = path.split('/').last().unwrap(); + let node = user_tree.node(node_name).unwrap(); + let mut stack = Vec::new(); + + // Start by propagating to the immediate children. + for neighbour in node.out_neighbours() { + stack.push(neighbour); + } + + while let Some(node) = stack.pop() { + let node_permission = node.metadata().get("permission"); + let direct = node.metadata().get("direct").unwrap().into_bool().unwrap(); + + if let Some(node_permission) = node_permission { + // Direct permissions override inherited permissions, skip this node + // and stop propagating. + if direct { + continue; + } + + node + .update_metadata(vec![ + ("permission", Prop::Str(node_permission.into_str().unwrap())), + ("direct", Prop::Bool(false)), + ]) + .unwrap(); + + for neighbour in node.out_neighbours() { + stack.push(neighbour); + } + } else { + // This node has no permission, set it to the given permission. + node + .update_metadata(vec![ + ("permission", Prop::Str(permission.as_str().into())), + ("direct", Prop::Bool(false)), + ]) + .unwrap(); + + for neighbour in node.out_neighbours() { + stack.push(neighbour); + } + } + } +} #[test] fn permissions_proptest() { @@ -50,36 +168,27 @@ fn permissions_proptest() { let url = Url::parse(&format!("http://127.0.0.1:{PORT}")).unwrap(); let client = get_client(url, ADMIN_JWT.to_string()); + let tree = case.namespace_tree.clone(); + // Create graphs on the server. for path in &case.graph_paths { create_graph(&client, path); } - // Create roles for each user. + let mut user_trees = Vec::with_capacity(case.num_users); + + // Create roles and separate namespace trees for each user. for i in 0..case.num_users { let role = format!("user_{i}"); create_role(&client, &role); + + let user_tree = tree.materialize().unwrap(); + user_trees[i] = user_tree; } for grant in &case.grants { - match grant { - PermissionGrant::Graph { - user_id, - path, - permission, - } => { - let role = format!("user_{user_id}"); - grant_graph(&client, &role, path, *permission) - } - PermissionGrant::Namespace { - user_id, - path, - permission, - } => { - let role = format!("user_{user_id}"); - grant_namespace(&client, &role, path, *permission) - } - } + create_grant(&client, grant); + track_grant(grant, &user_trees); } } ); diff --git a/raphtory-graphql/tests/utils/graphql.rs b/raphtory-graphql/tests/utils/graphql.rs index ce30f74efc..e849e6d7b2 100644 --- a/raphtory-graphql/tests/utils/graphql.rs +++ b/raphtory-graphql/tests/utils/graphql.rs @@ -11,7 +11,7 @@ use tempfile::TempDir; use tokio::runtime::Runtime; use url::Url; -use crate::utils::strategy::{GraphPermission, NamespacePermission, PermissionGrant}; +use crate::utils::strategy::{Permission, PermissionGrant}; static AUTH_INIT: Once = Once::new(); @@ -81,15 +81,33 @@ pub fn create_role(client: &RaphtoryGraphQLClient, name: &str) { assert_eq!(success, Some(true), "createRole {name} data: {data:?}"); } +/// Create a graph on the graphql server using the given path. +pub fn create_graph(client: &RaphtoryGraphQLClient, path: &str) { + RUNTIME.block_on(client.new_graph(path, "EVENT")).unwrap(); +} + +pub fn create_grant(client: &RaphtoryGraphQLClient, grant: &PermissionGrant) { + match grant { + PermissionGrant::Graph { user_id, path, permission } => { + let role = format!("user_{user_id}"); + grant_graph(client, &role, path, *permission); + } + PermissionGrant::Namespace { user_id, path, permission } => { + let role = format!("user_{user_id}"); + grant_namespace(client, &role, path, *permission); + } + } +} + pub fn grant_graph( client: &RaphtoryGraphQLClient, role: &str, path: &str, - permission: GraphPermission, + permission: Permission, ) { let query = format!( r#"mutation {{ permissions {{ grantGraph(role: "{role}", path: "{path}", permission: {}) {{ success }} }} }}"#, - permission.as_gql() + permission.as_str() ); let data = gql(client, &query); @@ -104,7 +122,7 @@ pub fn grant_graph( success, Some(true), "grantGraph role={role} path={path} permission={} data: {data:?}", - permission.as_gql() + permission.as_str() ); } @@ -112,11 +130,11 @@ pub fn grant_namespace( client: &RaphtoryGraphQLClient, role: &str, path: &str, - permission: NamespacePermission, + permission: Permission, ) { let query = format!( r#"mutation {{ permissions {{ grantNamespace(role: "{role}", path: "{path}", permission: {}) {{ success }} }} }}"#, - permission.as_gql() + permission.as_str() ); let data = gql(client, &query); @@ -131,11 +149,6 @@ pub fn grant_namespace( success, Some(true), "grantNamespace role={role} path={path} permission={} data: {data:?}", - permission.as_gql() + permission.as_str() ); } - -/// Create a graph on the graphql server using the given path. -pub fn create_graph(client: &RaphtoryGraphQLClient, path: &str) { - RUNTIME.block_on(client.new_graph(path, "EVENT")).unwrap(); -} diff --git a/raphtory-graphql/tests/utils/strategy.rs b/raphtory-graphql/tests/utils/strategy.rs index 491df6f8c8..49600bd8dc 100644 --- a/raphtory-graphql/tests/utils/strategy.rs +++ b/raphtory-graphql/tests/utils/strategy.rs @@ -27,43 +27,30 @@ pub enum PermissionGrant { Graph { user_id: usize, path: String, - permission: GraphPermission, + permission: Permission, }, Namespace { user_id: usize, path: String, - permission: NamespacePermission, + permission: Permission, }, } #[derive(Debug, Clone, Copy)] -pub enum GraphPermission { - Read, - Write, -} - -impl GraphPermission { - pub(crate) fn as_gql(self) -> &'static str { - match self { - GraphPermission::Read => "READ", - GraphPermission::Write => "WRITE", - } - } -} - -#[derive(Debug, Clone, Copy)] -pub enum NamespacePermission { +pub enum Permission { + Discover, Introspect, Read, Write, } -impl NamespacePermission { - pub(crate) fn as_gql(self) -> &'static str { +impl Permission { + pub(crate) fn as_str(self) -> &'static str { match self { - NamespacePermission::Introspect => "INTROSPECT", - NamespacePermission::Read => "READ", - NamespacePermission::Write => "WRITE", + Permission::Discover => "DISCOVER", + Permission::Introspect => "INTROSPECT", + Permission::Read => "READ", + Permission::Write => "WRITE", } } } @@ -125,7 +112,7 @@ fn grants_strategy( ) -> impl Strategy> { let max_user = num_users - 1; let total_paths = namespace_paths.len() + graph_paths.len(); - let num_grants = 1..=total_paths; // FIXME: Increase this to 3x later. + let num_grants = 1..=total_paths; // FIXME: Increase this to 3x. prop::collection::vec( (0..=max_user, 0..total_paths) @@ -134,10 +121,12 @@ fn grants_strategy( if path_idx < namespace_paths.len() { let path = namespace_paths[path_idx].clone(); + // FIXME: Include revoke permissions. + // Exclude Discover since it cannot be applied directly to namespaces. prop_oneof![ - Just(NamespacePermission::Introspect), - Just(NamespacePermission::Read), - Just(NamespacePermission::Write), + Just(Permission::Introspect), + Just(Permission::Read), + Just(Permission::Write), ] .prop_map(move |permission| PermissionGrant::Namespace { user_id, @@ -149,9 +138,11 @@ fn grants_strategy( let graph_idx = path_idx - namespace_paths.len(); let path = graph_paths[graph_idx].clone(); + // FIXME: Include revoke permissions. + // Exclude Introspect since it cannot be applied directly to graphs. prop_oneof![ - Just(GraphPermission::Read), - Just(GraphPermission::Write), + Just(Permission::Read), + Just(Permission::Write), ] .prop_map(move |permission| PermissionGrant::Graph { user_id, From 31c87d1c7f7e82cb47174bf213639c13f1c017cb Mon Sep 17 00:00:00 2001 From: Fadhil Abubaker Date: Thu, 30 Apr 2026 11:28:00 -0400 Subject: [PATCH 16/31] Rework display for Permission --- raphtory-graphql/tests/permissions.rs | 61 ++++++++++++++---------- raphtory-graphql/tests/utils/graphql.rs | 38 +++++++++------ raphtory-graphql/tests/utils/strategy.rs | 27 ++++++++--- 3 files changed, 79 insertions(+), 47 deletions(-) diff --git a/raphtory-graphql/tests/permissions.rs b/raphtory-graphql/tests/permissions.rs index 500f03d909..a084a8c34a 100644 --- a/raphtory-graphql/tests/permissions.rs +++ b/raphtory-graphql/tests/permissions.rs @@ -1,6 +1,7 @@ mod utils; use std::ops::RangeInclusive; +use std::str::FromStr; use std::sync::LazyLock; use jsonwebtoken::{encode, Algorithm, EncodingKey, Header}; @@ -17,6 +18,8 @@ use utils::graphql::{ start_server, }; +use crate::utils::graphql::{validate_graph_grant, validate_namespace_grant}; + const PORT: u16 = 43871; // Borrowed from test_permissions.py. @@ -52,7 +55,7 @@ fn track_grant(grant: &PermissionGrant, user_trees: &[MaterializedGraph]) { // Update the node's direct permission. node .update_metadata(vec![ - ("permission", Prop::Str(permission.as_str().into())), + ("permission", Prop::Str(permission.to_string().into())), ("direct", Prop::Bool(true)), ]) .unwrap(); @@ -74,7 +77,7 @@ fn track_grant(grant: &PermissionGrant, user_trees: &[MaterializedGraph]) { // Update the node's direct permission. node .update_metadata(vec![ - ("permission", Prop::Str(permission.as_str().into())), + ("permission", Prop::Str(permission.to_string().into())), ("direct", Prop::Bool(true)), ]) .unwrap(); @@ -98,7 +101,7 @@ fn propagate_up(path: &str, user_tree: &MaterializedGraph) { if node.metadata().get("permission").is_none() { node .update_metadata(vec![ - ("permission", Prop::Str(Permission::Discover.as_str().into())), + ("permission", Prop::Str(Permission::Discover.to_string().into())), ("direct", Prop::Bool(false)), ]) .unwrap(); @@ -119,37 +122,39 @@ fn propagate_down(path: &str, user_tree: &MaterializedGraph, permission: Permiss while let Some(node) = stack.pop() { let node_permission = node.metadata().get("permission"); - let direct = node.metadata().get("direct").unwrap().into_bool().unwrap(); - if let Some(node_permission) = node_permission { + if let Some(_) = node_permission { + let direct = node.metadata().get("direct").unwrap().into_bool().unwrap(); + // Direct permissions override inherited permissions, skip this node // and stop propagating. if direct { continue; } + } - node - .update_metadata(vec![ - ("permission", Prop::Str(node_permission.into_str().unwrap())), - ("direct", Prop::Bool(false)), - ]) - .unwrap(); + node + .update_metadata(vec![ + ("permission", Prop::Str(permission.to_string().into())), + ("direct", Prop::Bool(false)), + ]) + .unwrap(); - for neighbour in node.out_neighbours() { - stack.push(neighbour); - } - } else { - // This node has no permission, set it to the given permission. - node - .update_metadata(vec![ - ("permission", Prop::Str(permission.as_str().into())), - ("direct", Prop::Bool(false)), - ]) - .unwrap(); + for neighbour in node.out_neighbours() { + stack.push(neighbour); + } + } +} - for neighbour in node.out_neighbours() { - stack.push(neighbour); - } +fn validate_grants(user_tree: &MaterializedGraph) { + for node in user_tree.nodes() { + let permission_prop = node.metadata().get("permission").and_then(|p| p.into_str()); + let permission = permission_prop.and_then(|p| Permission::from_str(p.as_ref()).ok()); + + if node.out_neighbours().is_empty() { + validate_graph_grant(&node, permission); + } else { + validate_namespace_grant(&node, permission); } } } @@ -183,13 +188,17 @@ fn permissions_proptest() { create_role(&client, &role); let user_tree = tree.materialize().unwrap(); - user_trees[i] = user_tree; + user_trees.push(user_tree); } for grant in &case.grants { create_grant(&client, grant); track_grant(grant, &user_trees); } + + for user_tree in &user_trees { + validate_grants(user_tree); + } } ); } diff --git a/raphtory-graphql/tests/utils/graphql.rs b/raphtory-graphql/tests/utils/graphql.rs index e849e6d7b2..7b2efbcbc4 100644 --- a/raphtory-graphql/tests/utils/graphql.rs +++ b/raphtory-graphql/tests/utils/graphql.rs @@ -3,6 +3,8 @@ use std::sync::{LazyLock, Once}; use std::time::Duration; use raphtory::db::api::storage::storage::Config; +use raphtory::db::api::view::MaterializedGraph; +use raphtory::db::graph::node::NodeView; use raphtory_graphql::client::raphtory_client::RaphtoryGraphQLClient; use raphtory_graphql::config::app_config::AppConfigBuilder; use raphtory_graphql::server::{apply_server_extension, GraphServer, RunningGraphServer}; @@ -60,10 +62,9 @@ pub fn start_server(port: u16, pub_key: &str) -> (RunningGraphServer, TempDir) { }) } -fn gql(client: &RaphtoryGraphQLClient, query: &str) -> HashMap { - RUNTIME - .block_on(client.query(query, HashMap::new())) - .unwrap_or_else(|e| panic!("Error executing query {query}: {e}")) +/// Create a graph on the graphql server using the given path. +pub fn create_graph(client: &RaphtoryGraphQLClient, path: &str) { + RUNTIME.block_on(client.new_graph(path, "EVENT")).unwrap(); } pub fn create_role(client: &RaphtoryGraphQLClient, name: &str) { @@ -81,11 +82,6 @@ pub fn create_role(client: &RaphtoryGraphQLClient, name: &str) { assert_eq!(success, Some(true), "createRole {name} data: {data:?}"); } -/// Create a graph on the graphql server using the given path. -pub fn create_graph(client: &RaphtoryGraphQLClient, path: &str) { - RUNTIME.block_on(client.new_graph(path, "EVENT")).unwrap(); -} - pub fn create_grant(client: &RaphtoryGraphQLClient, grant: &PermissionGrant) { match grant { PermissionGrant::Graph { user_id, path, permission } => { @@ -107,7 +103,7 @@ pub fn grant_graph( ) { let query = format!( r#"mutation {{ permissions {{ grantGraph(role: "{role}", path: "{path}", permission: {}) {{ success }} }} }}"#, - permission.as_str() + permission ); let data = gql(client, &query); @@ -121,8 +117,7 @@ pub fn grant_graph( assert_eq!( success, Some(true), - "grantGraph role={role} path={path} permission={} data: {data:?}", - permission.as_str() + "grantGraph role={role} path={path} permission={permission} data: {data:?}" ); } @@ -134,7 +129,7 @@ pub fn grant_namespace( ) { let query = format!( r#"mutation {{ permissions {{ grantNamespace(role: "{role}", path: "{path}", permission: {}) {{ success }} }} }}"#, - permission.as_str() + permission ); let data = gql(client, &query); @@ -148,7 +143,20 @@ pub fn grant_namespace( assert_eq!( success, Some(true), - "grantNamespace role={role} path={path} permission={} data: {data:?}", - permission.as_str() + "grantNamespace role={role} path={path} permission={permission} data: {data:?}" ); } + +pub fn validate_graph_grant(node: &NodeView<'_, MaterializedGraph>, permission: Option) { + todo!() +} + +pub fn validate_namespace_grant(node: &NodeView<'_, MaterializedGraph>, permission: Option) { + todo!() +} + +fn gql(client: &RaphtoryGraphQLClient, query: &str) -> HashMap { + RUNTIME + .block_on(client.query(query, HashMap::new())) + .unwrap_or_else(|e| panic!("Error executing query {query}: {e}")) +} diff --git a/raphtory-graphql/tests/utils/strategy.rs b/raphtory-graphql/tests/utils/strategy.rs index 49600bd8dc..e46b5f8dac 100644 --- a/raphtory-graphql/tests/utils/strategy.rs +++ b/raphtory-graphql/tests/utils/strategy.rs @@ -1,4 +1,5 @@ use std::{fmt, ops::RangeInclusive}; +use std::str::FromStr; use proptest::prelude::*; use proptest::strategy::BoxedStrategy; use raphtory::prelude::*; @@ -44,13 +45,27 @@ pub enum Permission { Write, } -impl Permission { - pub(crate) fn as_str(self) -> &'static str { +impl fmt::Display for Permission { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { match self { - Permission::Discover => "DISCOVER", - Permission::Introspect => "INTROSPECT", - Permission::Read => "READ", - Permission::Write => "WRITE", + Permission::Discover => f.write_str("DISCOVER"), + Permission::Introspect => f.write_str("INTROSPECT"), + Permission::Read => f.write_str("READ"), + Permission::Write => f.write_str("WRITE"), + } + } +} + +impl FromStr for Permission { + type Err = &'static str; + + fn from_str(s: &str) -> Result { + match s { + "DISCOVER" => Ok(Permission::Discover), + "INTROSPECT" => Ok(Permission::Introspect), + "READ" => Ok(Permission::Read), + "WRITE" => Ok(Permission::Write), + _ => Err("invalid permission"), } } } From 7bc52c2f01f19553249754b7c5301de3d8dfc55c Mon Sep 17 00:00:00 2001 From: Fadhil Abubaker Date: Thu, 30 Apr 2026 11:58:47 -0400 Subject: [PATCH 17/31] Simplify PermissionGrant --- raphtory-graphql/tests/permissions.rs | 62 ++++------- raphtory-graphql/tests/utils/graphql.rs | 16 ++- raphtory-graphql/tests/utils/mod.rs | 1 + raphtory-graphql/tests/utils/strategy.rs | 131 ++++------------------- raphtory-graphql/tests/utils/tree.rs | 96 +++++++++++++++++ 5 files changed, 141 insertions(+), 165 deletions(-) create mode 100644 raphtory-graphql/tests/utils/tree.rs diff --git a/raphtory-graphql/tests/permissions.rs b/raphtory-graphql/tests/permissions.rs index a084a8c34a..3a1d3a9cb8 100644 --- a/raphtory-graphql/tests/permissions.rs +++ b/raphtory-graphql/tests/permissions.rs @@ -11,7 +11,7 @@ use raphtory::prelude::{GraphViewOps, NodeViewOps, Prop, PropUnwrap, PropertiesO use serde_json::json; use url::Url; -use utils::strategy::{permissions_strategy, Permission, PermissionGrant}; +use utils::strategy::{permissions_strategy, GrantType, Permission, PermissionGrant}; use utils::graphql::{ create_graph, create_role, create_grant, get_client, @@ -42,52 +42,30 @@ static ADMIN_JWT: LazyLock = LazyLock::new(|| { // Track a permission grant across the given namespace tree. fn track_grant(grant: &PermissionGrant, user_trees: &[MaterializedGraph]) { - match grant { - PermissionGrant::Graph { user_id, path, permission } => { - assert!(matches!(permission, Permission::Read | Permission::Write)); + let user_id = grant.user_id; + let path = grant.path.as_str(); + let permission = grant.permission; - let user_tree = &user_trees[*user_id]; + let user_tree = &user_trees[user_id]; - // Find the node in the tree corresponding to the given path. - let node_name = path.split('/').last().unwrap(); - let node = user_tree.node(node_name).unwrap(); - - // Update the node's direct permission. - node - .update_metadata(vec![ - ("permission", Prop::Str(permission.to_string().into())), - ("direct", Prop::Bool(true)), - ]) - .unwrap(); - - // Propagate discover to ancestor namespaces. - propagate_up(path, user_tree); - } - PermissionGrant::Namespace { user_id, path, permission } => { - assert!( - matches!(permission, Permission::Introspect | Permission::Read | Permission::Write) - ); - - let user_tree = &user_trees[*user_id]; - - // Find the node in the tree corresponding to the given path. - let node_name = path.split('/').last().unwrap(); - let node = user_tree.node(node_name).unwrap(); + // Find the node in the tree corresponding to the given path. + let node_name = path.split('/').last().unwrap(); + let node = user_tree.node(node_name).unwrap(); - // Update the node's direct permission. - node - .update_metadata(vec![ - ("permission", Prop::Str(permission.to_string().into())), - ("direct", Prop::Bool(true)), - ]) - .unwrap(); + // Update the node's direct permission. + node + .update_metadata(vec![ + ("permission", Prop::Str(permission.to_string().into())), + ("direct", Prop::Bool(true)), + ]) + .unwrap(); - // Propagate discover to ancestor namespaces. - propagate_up(path, user_tree); + // Propagate discover to ancestor namespaces. + propagate_up(path, user_tree); - // Propagate the permission to descendant namespaces and graphs. - propagate_down(path, user_tree, *permission); - } + // Namespace grants also propagate to descendants. + if grant.grant_type == GrantType::Namespace { + propagate_down(path, user_tree, permission); } } diff --git a/raphtory-graphql/tests/utils/graphql.rs b/raphtory-graphql/tests/utils/graphql.rs index 7b2efbcbc4..967c6a63dd 100644 --- a/raphtory-graphql/tests/utils/graphql.rs +++ b/raphtory-graphql/tests/utils/graphql.rs @@ -13,7 +13,7 @@ use tempfile::TempDir; use tokio::runtime::Runtime; use url::Url; -use crate::utils::strategy::{Permission, PermissionGrant}; +use crate::utils::strategy::{GrantType, Permission, PermissionGrant}; static AUTH_INIT: Once = Once::new(); @@ -83,15 +83,11 @@ pub fn create_role(client: &RaphtoryGraphQLClient, name: &str) { } pub fn create_grant(client: &RaphtoryGraphQLClient, grant: &PermissionGrant) { - match grant { - PermissionGrant::Graph { user_id, path, permission } => { - let role = format!("user_{user_id}"); - grant_graph(client, &role, path, *permission); - } - PermissionGrant::Namespace { user_id, path, permission } => { - let role = format!("user_{user_id}"); - grant_namespace(client, &role, path, *permission); - } + let role = format!("user_{}", grant.user_id); + + match grant.grant_type { + GrantType::Graph => grant_graph(client, &role, &grant.path, grant.permission), + GrantType::Namespace => grant_namespace(client, &role, &grant.path, grant.permission), } } diff --git a/raphtory-graphql/tests/utils/mod.rs b/raphtory-graphql/tests/utils/mod.rs index 777c2f02ad..bf45cf6796 100644 --- a/raphtory-graphql/tests/utils/mod.rs +++ b/raphtory-graphql/tests/utils/mod.rs @@ -1,2 +1,3 @@ pub mod graphql; pub mod strategy; +pub mod tree; diff --git a/raphtory-graphql/tests/utils/strategy.rs b/raphtory-graphql/tests/utils/strategy.rs index e46b5f8dac..b68977ba8e 100644 --- a/raphtory-graphql/tests/utils/strategy.rs +++ b/raphtory-graphql/tests/utils/strategy.rs @@ -3,6 +3,7 @@ use std::str::FromStr; use proptest::prelude::*; use proptest::strategy::BoxedStrategy; use raphtory::prelude::*; +use crate::utils::tree::{branch_paths, build_namespace_tree, leaf_paths}; #[derive(Clone)] pub struct PermissionsCase { @@ -23,18 +24,18 @@ impl fmt::Debug for PermissionsCase { } } +#[derive(Debug, Clone, Copy)] +pub enum GrantType { + Graph, + Namespace, +} + #[derive(Debug, Clone)] -pub enum PermissionGrant { - Graph { - user_id: usize, - path: String, - permission: Permission, - }, - Namespace { - user_id: usize, - path: String, - permission: Permission, - }, +pub struct PermissionGrant { + pub grant_type: GrantType, + pub user_id: usize, + pub path: String, + pub permission: Permission, } #[derive(Debug, Clone, Copy)] @@ -136,14 +137,14 @@ fn grants_strategy( if path_idx < namespace_paths.len() { let path = namespace_paths[path_idx].clone(); - // FIXME: Include revoke permissions. - // Exclude Discover since it cannot be applied directly to namespaces. + // Exclude discover since it cannot be applied directly to namespaces. prop_oneof![ Just(Permission::Introspect), Just(Permission::Read), Just(Permission::Write), ] - .prop_map(move |permission| PermissionGrant::Namespace { + .prop_map(move |permission| PermissionGrant { + grant_type: GrantType::Namespace, user_id, path: path.clone(), permission, @@ -153,13 +154,13 @@ fn grants_strategy( let graph_idx = path_idx - namespace_paths.len(); let path = graph_paths[graph_idx].clone(); - // FIXME: Include revoke permissions. - // Exclude Introspect since it cannot be applied directly to graphs. + // Exclude introspect since it cannot be applied directly to graphs. prop_oneof![ Just(Permission::Read), Just(Permission::Write), ] - .prop_map(move |permission| PermissionGrant::Graph { + .prop_map(move |permission| PermissionGrant { + grant_type: GrantType::Graph, user_id, path: path.clone(), permission, @@ -171,99 +172,3 @@ fn grants_strategy( ) } -/// Build a namespace tree with the given number of nodes and parent relationships. -/// `parents` is a slice of indices that represent the parent of each node. -pub fn build_namespace_tree(parents: &[usize]) -> Graph { - let graph = Graph::new(); - - if parents.len() == 0 { - return graph; - } - - for node in 0..parents.len() { - let name = format!("node_{node}"); - - graph - .add_node(0, name, NO_PROPS, None, None) - .unwrap(); - - // Root node has no parent. - if node == 0 { - continue; - } - - let parent = parents[node]; - let parent_name = format!("node_{parent}"); - let node_name = format!("node_{node}"); - - graph - .add_edge(0, parent_name, node_name, NO_PROPS, None) - .unwrap(); - } - - graph -} - -/// Build paths for all leaf nodes in the tree. -/// Example: a -> b -> c returns ["a/b/c"] -pub fn leaf_paths(tree: &Graph) -> Vec { - let mut stack = Vec::new(); - let root = tree.node("node_0").unwrap(); - let parent_path = "".to_string(); - let mut leaves = Vec::new(); - - stack.push((root, parent_path)); - - while let Some((node, parent_path)) = stack.pop() { - // Prevent leading slash in paths. - let node_path = if parent_path.is_empty() { - node.name() - } else { - [parent_path, node.name()].join("/") - }; - - let neighbours = node.out_neighbours(); - - if neighbours.is_empty() { - leaves.push(node_path); - } else { - for neighbour in node.out_neighbours() { - stack.push((neighbour, node_path.clone())); - } - } - } - - leaves -} - -/// Build paths for all branch nodes in the tree. -/// Example: a -> b -> c returns ["a", "a/b"] -pub fn branch_paths(tree: &Graph) -> Vec { - let mut stack = Vec::new(); - let root = tree.node("node_0").unwrap(); - let parent_path = "".to_string(); - let mut branches = Vec::new(); - - stack.push((root, parent_path)); - - while let Some((node, parent_path)) = stack.pop() { - // Prevent leading slash in paths. - let node_path = if parent_path.is_empty() { - node.name() - } else { - [parent_path, node.name()].join("/") - }; - - let neighbours = node.out_neighbours(); - - if !neighbours.is_empty() { - branches.push(node_path.clone()); - - for neighbour in neighbours { - stack.push((neighbour, node_path.clone())); - } - } - } - - branches -} diff --git a/raphtory-graphql/tests/utils/tree.rs b/raphtory-graphql/tests/utils/tree.rs new file mode 100644 index 0000000000..08906d53cf --- /dev/null +++ b/raphtory-graphql/tests/utils/tree.rs @@ -0,0 +1,96 @@ +use raphtory::prelude::{AdditionOps, Graph, GraphViewOps, NodeViewOps, NO_PROPS}; + +/// Build a namespace tree with the given number of nodes and parent relationships. +/// `parents` is a slice of indices that represent the parent of each node. +pub fn build_namespace_tree(parents: &[usize]) -> Graph { + let graph = Graph::new(); + + if parents.is_empty() { + return graph; + } + + for node in 0..parents.len() { + let name = format!("node_{node}"); + + graph + .add_node(0, name, NO_PROPS, None, None) + .unwrap(); + + // Root node has no parent. + if node == 0 { + continue; + } + + let parent = parents[node]; + let parent_name = format!("node_{parent}"); + let node_name = format!("node_{node}"); + + graph + .add_edge(0, parent_name, node_name, NO_PROPS, None) + .unwrap(); + } + + graph +} + +/// Build paths for all leaf nodes in the tree. +/// Example: a -> b -> c returns ["a/b/c"] +pub fn leaf_paths(tree: &Graph) -> Vec { + let mut stack = Vec::new(); + let root = tree.node("node_0").unwrap(); + let mut leaves = Vec::new(); + + stack.push((root, String::new())); + + while let Some((node, parent_path)) = stack.pop() { + // Prevent leading slash in paths. + let node_path = if parent_path.is_empty() { + node.name() + } else { + [parent_path, node.name()].join("/") + }; + + let neighbours = node.out_neighbours(); + + if neighbours.is_empty() { + leaves.push(node_path); + } else { + for neighbour in node.out_neighbours() { + stack.push((neighbour, node_path.clone())); + } + } + } + + leaves +} + +/// Build paths for all branch nodes in the tree. +/// Example: a -> b -> c returns ["a", "a/b"] +pub fn branch_paths(tree: &Graph) -> Vec { + let mut stack = Vec::new(); + let root = tree.node("node_0").unwrap(); + let mut branches = Vec::new(); + + stack.push((root, String::new())); + + while let Some((node, parent_path)) = stack.pop() { + // Prevent leading slash in paths. + let node_path = if parent_path.is_empty() { + node.name() + } else { + [parent_path, node.name()].join("/") + }; + + let neighbours = node.out_neighbours(); + + if !neighbours.is_empty() { + branches.push(node_path.clone()); + + for neighbour in neighbours { + stack.push((neighbour, node_path.clone())); + } + } + } + + branches +} From 7f7bc75568678fcca749d0beea50388fad194f6d Mon Sep 17 00:00:00 2001 From: Fadhil Abubaker Date: Thu, 30 Apr 2026 13:25:28 -0400 Subject: [PATCH 18/31] Tee up validate_grant --- raphtory-graphql/tests/permissions.rs | 54 +++++++++++++++------- raphtory-graphql/tests/utils/graphql.rs | 57 ++++++++++++++++-------- raphtory-graphql/tests/utils/strategy.rs | 23 ++++++---- 3 files changed, 91 insertions(+), 43 deletions(-) diff --git a/raphtory-graphql/tests/permissions.rs b/raphtory-graphql/tests/permissions.rs index 3a1d3a9cb8..7bd8ba98f6 100644 --- a/raphtory-graphql/tests/permissions.rs +++ b/raphtory-graphql/tests/permissions.rs @@ -7,6 +7,8 @@ use std::sync::LazyLock; use jsonwebtoken::{encode, Algorithm, EncodingKey, Header}; use proptest::prelude::*; use raphtory::db::api::view::MaterializedGraph; +use raphtory::db::graph::node::NodeView; +use raphtory_graphql::client::raphtory_client::RaphtoryGraphQLClient; use raphtory::prelude::{GraphViewOps, NodeViewOps, Prop, PropUnwrap, PropertiesOps}; use serde_json::json; use url::Url; @@ -124,16 +126,23 @@ fn propagate_down(path: &str, user_tree: &MaterializedGraph, permission: Permiss } } -fn validate_grants(user_tree: &MaterializedGraph) { - for node in user_tree.nodes() { - let permission_prop = node.metadata().get("permission").and_then(|p| p.into_str()); - let permission = permission_prop.and_then(|p| Permission::from_str(p.as_ref()).ok()); - - if node.out_neighbours().is_empty() { - validate_graph_grant(&node, permission); - } else { - validate_namespace_grant(&node, permission); - } +fn validate_grant( + path: &str, + node: &NodeView<'_, MaterializedGraph>, + client: &RaphtoryGraphQLClient, +) { + let permission = node + .metadata() + .get("permission") + .and_then(|p| p.into_str()) + .and_then(|s| Permission::from_str(s.as_ref()).ok()); + + let is_graph_path = node.out_neighbours().is_empty(); + + if is_graph_path { + validate_graph_grant(path, permission, client); + } else { + validate_namespace_grant(path, permission, client); } } @@ -155,7 +164,7 @@ fn permissions_proptest() { // Create graphs on the server. for path in &case.graph_paths { - create_graph(&client, path); + create_graph(path, &client); } let mut user_trees = Vec::with_capacity(case.num_users); @@ -163,19 +172,34 @@ fn permissions_proptest() { // Create roles and separate namespace trees for each user. for i in 0..case.num_users { let role = format!("user_{i}"); - create_role(&client, &role); + create_role(&role, &client); let user_tree = tree.materialize().unwrap(); user_trees.push(user_tree); } + // Create grants on the server and track them locally. for grant in &case.grants { - create_grant(&client, grant); + create_grant(grant, &client); track_grant(grant, &user_trees); } - for user_tree in &user_trees { - validate_grants(user_tree); + // Validate grants across graph paths for each user. + for path in &case.graph_paths { + for user_tree in &user_trees { + let node_name = path.split('/').last().unwrap(); + let node = user_tree.node(node_name).unwrap(); + validate_grant(path.as_str(), &node, &client); + } + } + + // Validate grants across namespace paths for each user. + for path in &case.namespace_paths { + for user_tree in &user_trees { + let node_name = path.split('/').last().unwrap(); + let node = user_tree.node(node_name).unwrap(); + validate_grant(path.as_str(), &node, &client); + } } } ); diff --git a/raphtory-graphql/tests/utils/graphql.rs b/raphtory-graphql/tests/utils/graphql.rs index 967c6a63dd..64881dcf3d 100644 --- a/raphtory-graphql/tests/utils/graphql.rs +++ b/raphtory-graphql/tests/utils/graphql.rs @@ -3,8 +3,6 @@ use std::sync::{LazyLock, Once}; use std::time::Duration; use raphtory::db::api::storage::storage::Config; -use raphtory::db::api::view::MaterializedGraph; -use raphtory::db::graph::node::NodeView; use raphtory_graphql::client::raphtory_client::RaphtoryGraphQLClient; use raphtory_graphql::config::app_config::AppConfigBuilder; use raphtory_graphql::server::{apply_server_extension, GraphServer, RunningGraphServer}; @@ -63,15 +61,15 @@ pub fn start_server(port: u16, pub_key: &str) -> (RunningGraphServer, TempDir) { } /// Create a graph on the graphql server using the given path. -pub fn create_graph(client: &RaphtoryGraphQLClient, path: &str) { +pub fn create_graph(path: &str, client: &RaphtoryGraphQLClient) { RUNTIME.block_on(client.new_graph(path, "EVENT")).unwrap(); } -pub fn create_role(client: &RaphtoryGraphQLClient, name: &str) { +pub fn create_role(name: &str, client: &RaphtoryGraphQLClient) { let query = format!(r#"mutation {{ permissions {{ createRole(name: "{name}") {{ success }} }} }}"#); - let data = gql(client, &query); + let data = gql(&query, client); let success = data .get("permissions") @@ -82,27 +80,27 @@ pub fn create_role(client: &RaphtoryGraphQLClient, name: &str) { assert_eq!(success, Some(true), "createRole {name} data: {data:?}"); } -pub fn create_grant(client: &RaphtoryGraphQLClient, grant: &PermissionGrant) { +pub fn create_grant(grant: &PermissionGrant, client: &RaphtoryGraphQLClient) { let role = format!("user_{}", grant.user_id); match grant.grant_type { - GrantType::Graph => grant_graph(client, &role, &grant.path, grant.permission), - GrantType::Namespace => grant_namespace(client, &role, &grant.path, grant.permission), + GrantType::Graph => grant_graph(&grant.path, &role, grant.permission, client), + GrantType::Namespace => grant_namespace(&grant.path, &role, grant.permission, client), } } pub fn grant_graph( - client: &RaphtoryGraphQLClient, - role: &str, path: &str, + role: &str, permission: Permission, + client: &RaphtoryGraphQLClient, ) { let query = format!( r#"mutation {{ permissions {{ grantGraph(role: "{role}", path: "{path}", permission: {}) {{ success }} }} }}"#, permission ); - let data = gql(client, &query); + let data = gql(&query, client); let success = data .get("permissions") @@ -118,17 +116,17 @@ pub fn grant_graph( } pub fn grant_namespace( - client: &RaphtoryGraphQLClient, - role: &str, path: &str, + role: &str, permission: Permission, + client: &RaphtoryGraphQLClient, ) { let query = format!( r#"mutation {{ permissions {{ grantNamespace(role: "{role}", path: "{path}", permission: {}) {{ success }} }} }}"#, permission ); - let data = gql(client, &query); + let data = gql(&query, client); let success = data .get("permissions") @@ -143,15 +141,36 @@ pub fn grant_namespace( ); } -pub fn validate_graph_grant(node: &NodeView<'_, MaterializedGraph>, permission: Option) { - todo!() +pub fn validate_graph_grant( + path: &str, + permission: Option, + client: &RaphtoryGraphQLClient, +) { + let _ = (path, client); + match permission { + Some(_) => { + todo!() + } + None => { + todo!() + } + } +} + +pub fn validate_namespace_grant( + path: &str, + permission: Option, + client: &RaphtoryGraphQLClient, +) { + let _ = (path, permission, client); } -pub fn validate_namespace_grant(node: &NodeView<'_, MaterializedGraph>, permission: Option) { - todo!() +pub fn query_graph(path: &str, client: &RaphtoryGraphQLClient) -> HashMap { + let query = format!(r#"query {{ graph(path: "{path}") {{ path }} }}"#); + gql(&query, client) } -fn gql(client: &RaphtoryGraphQLClient, query: &str) -> HashMap { +fn gql(query: &str, client: &RaphtoryGraphQLClient) -> HashMap { RUNTIME .block_on(client.query(query, HashMap::new())) .unwrap_or_else(|e| panic!("Error executing query {query}: {e}")) diff --git a/raphtory-graphql/tests/utils/strategy.rs b/raphtory-graphql/tests/utils/strategy.rs index b68977ba8e..ada46db2ab 100644 --- a/raphtory-graphql/tests/utils/strategy.rs +++ b/raphtory-graphql/tests/utils/strategy.rs @@ -10,6 +10,7 @@ pub struct PermissionsCase { pub num_users: usize, pub grants: Vec, pub graph_paths: Vec, + pub namespace_paths: Vec, pub namespace_tree: Graph, } @@ -20,11 +21,12 @@ impl fmt::Debug for PermissionsCase { .field("num_users", &self.num_users) .field("grants", &self.grants) .field("graph_paths", &self.graph_paths) + .field("namespace_paths", &self.namespace_paths) .finish() } } -#[derive(Debug, Clone, Copy)] +#[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum GrantType { Graph, Namespace, @@ -82,14 +84,17 @@ pub fn permissions_strategy( let graph_paths = leaf_paths(&namespace_tree); let namespace_paths = branch_paths(&namespace_tree); - grants_strategy(num_users, graph_paths.clone(), namespace_paths).prop_map(move |grants| { - PermissionsCase { - num_users, - namespace_tree: namespace_tree.clone(), - graph_paths: graph_paths.clone(), - grants, - } - }) + grants_strategy(num_users, graph_paths.clone(), namespace_paths.clone()).prop_map( + move |grants| { + PermissionsCase { + num_users, + namespace_tree: namespace_tree.clone(), + graph_paths: graph_paths.clone(), + namespace_paths: namespace_paths.clone(), + grants, + } + }, + ) }) }) .boxed() From 1a15a0cb872f7ee71e4451d828d081aeac7fa553 Mon Sep 17 00:00:00 2001 From: Fadhil Abubaker Date: Thu, 30 Apr 2026 19:05:55 -0400 Subject: [PATCH 19/31] Add permission checkers --- raphtory-graphql/tests/permissions.rs | 6 ++ raphtory-graphql/tests/utils/graphql.rs | 90 +++++++++++++++++++++++-- 2 files changed, 92 insertions(+), 4 deletions(-) diff --git a/raphtory-graphql/tests/permissions.rs b/raphtory-graphql/tests/permissions.rs index 7bd8ba98f6..524dedaa82 100644 --- a/raphtory-graphql/tests/permissions.rs +++ b/raphtory-graphql/tests/permissions.rs @@ -198,6 +198,12 @@ fn permissions_proptest() { for user_tree in &user_trees { let node_name = path.split('/').last().unwrap(); let node = user_tree.node(node_name).unwrap(); + + // Ignore namespaces with no children since permissions are irrelevant. + if node.out_neighbours().is_empty() { + continue; + } + validate_grant(path.as_str(), &node, &client); } } diff --git a/raphtory-graphql/tests/utils/graphql.rs b/raphtory-graphql/tests/utils/graphql.rs index 64881dcf3d..943b107926 100644 --- a/raphtory-graphql/tests/utils/graphql.rs +++ b/raphtory-graphql/tests/utils/graphql.rs @@ -146,10 +146,9 @@ pub fn validate_graph_grant( permission: Option, client: &RaphtoryGraphQLClient, ) { - let _ = (path, client); match permission { Some(_) => { - todo!() + return; } None => { todo!() @@ -165,9 +164,92 @@ pub fn validate_namespace_grant( let _ = (path, permission, client); } -pub fn query_graph(path: &str, client: &RaphtoryGraphQLClient) -> HashMap { +pub fn can_read_graph(path: &str, client: &RaphtoryGraphQLClient) -> bool { let query = format!(r#"query {{ graph(path: "{path}") {{ path }} }}"#); - gql(&query, client) + let data = gql(&query, client); + + data.get("graph") + .and_then(|graph| graph.get("path")) + .and_then(JsonValue::as_str) + .is_some_and(|graph_path| graph_path == path) +} + +pub fn can_introspect_graph(path: &str, client: &RaphtoryGraphQLClient) -> bool { + let query = format!(r#"query {{ graphMetadata(path: "{path}") {{ path nodeCount }} }}"#); + let data = gql(&query, client); + + data.get("graphMetadata") + .is_some_and(|metadata| !metadata.is_null()) +} + +pub fn can_write_graph(path: &str, client: &RaphtoryGraphQLClient) -> bool { + let query = format!( + r#"query {{ updateGraph(path: "{path}") {{ addNode(time: 1, name: "test_node") {{ success }} }} }}"# + ); + let data = gql(&query, client); + + data.get("updateGraph") + .and_then(|update| update.get("addNode")) + .and_then(|add_node| add_node.get("success")) + .and_then(JsonValue::as_bool) + .unwrap_or(false) +} + +/// Verify that the namespace at `path` appears in its parent listing. +pub fn can_discover_namespace(path: &str, client: &RaphtoryGraphQLClient) -> bool { + let parent = path.rsplit_once('/').map_or("root", |(parent, _)| parent); + + let query = if parent == "root" { + r#"query { root { children { list { path } } } }"#.to_string() + } else { + format!(r#"query {{ namespace(path: "{parent}") {{ children {{ list {{ path }} }} }} }}"#) + }; + + let data = gql(&query, client); + + let children = if parent == "root" { + data.get("root") + } else { + data.get("namespace") + }; + + // Verify that the namespace is in the children list. + children + .and_then(|namespace| namespace.get("children")) + .and_then(|children| children.get("list")) + .and_then(JsonValue::as_array) + .is_some_and(|entries| { + entries.iter().any(|entry| { + entry + .get("path") + .and_then(JsonValue::as_str) + .is_some_and(|entry_path| entry_path == path) + }) + }) +} + +// Verify that the namespace at `path` can have its listings browsed. +pub fn can_introspect_namespace(path: &str, client: &RaphtoryGraphQLClient) -> bool { + let query = format!( + r#"query {{ namespace(path: "{path}") {{ graphs {{ list {{ path }} }} children {{ list {{ path }} }} }} }}"# + ); + + let data = gql(&query, client); + let namespace = data.get("namespace"); + + let has_graphs_list = namespace + .and_then(|ns| ns.get("graphs")) + .and_then(|graphs| graphs.get("list")) + .and_then(JsonValue::as_array) + .is_some(); + + let has_children_list = namespace + .and_then(|ns| ns.get("children")) + .and_then(|children| children.get("list")) + .and_then(JsonValue::as_array) + .is_some(); + + has_graphs_list || has_children_list } fn gql(query: &str, client: &RaphtoryGraphQLClient) -> HashMap { From 269d4d183f05f648aad8316439859d0ada9e3cd2 Mon Sep 17 00:00:00 2001 From: Fadhil Abubaker Date: Thu, 30 Apr 2026 19:26:38 -0400 Subject: [PATCH 20/31] Add assertions --- raphtory-graphql/tests/permissions.rs | 3 +- raphtory-graphql/tests/utils/graphql.rs | 67 +++++++++++++++++++----- raphtory-graphql/tests/utils/strategy.rs | 33 +++++++----- 3 files changed, 77 insertions(+), 26 deletions(-) diff --git a/raphtory-graphql/tests/permissions.rs b/raphtory-graphql/tests/permissions.rs index 524dedaa82..851a4f9c96 100644 --- a/raphtory-graphql/tests/permissions.rs +++ b/raphtory-graphql/tests/permissions.rs @@ -150,11 +150,12 @@ fn validate_grant( fn permissions_proptest() { const PROPTEST_CASES: u32 = 10; const NAMESPACE_SIZE: RangeInclusive = 1..=20; + const NUM_GRANTS: RangeInclusive = 1..=20; const NUM_USERS: RangeInclusive = 1..=10; proptest!( ProptestConfig::with_cases(PROPTEST_CASES), - |(case in permissions_strategy(NAMESPACE_SIZE, NUM_USERS))| { + |(case in permissions_strategy(NAMESPACE_SIZE, NUM_GRANTS, NUM_USERS))| { let (_server, _tempdir) = start_server(PORT, PUB_KEY); let url = Url::parse(&format!("http://127.0.0.1:{PORT}")).unwrap(); diff --git a/raphtory-graphql/tests/utils/graphql.rs b/raphtory-graphql/tests/utils/graphql.rs index 943b107926..cd5bea41c1 100644 --- a/raphtory-graphql/tests/utils/graphql.rs +++ b/raphtory-graphql/tests/utils/graphql.rs @@ -147,11 +147,33 @@ pub fn validate_graph_grant( client: &RaphtoryGraphQLClient, ) { match permission { - Some(_) => { - return; + Some(permission_enum) => { + match permission_enum { + Permission::Discover => { + panic!("Discover permission is not supported for graphs"); + } + Permission::Introspect => { + assert!(can_introspect_graph(path, client), "graph {path} should be introspectable"); + assert!(!can_read_graph(path, client), "graph {path} should not be readable"); + assert!(!can_write_graph(path, client), "graph {path} should not be writable"); + } + Permission::Read => { + assert!(can_introspect_graph(path, client), "graph {path} should be introspectable"); + assert!(can_read_graph(path, client), "graph {path} should be readable"); + assert!(!can_write_graph(path, client), "graph {path} should not be writable"); + } + Permission::Write => { + assert!(can_introspect_graph(path, client), "graph {path} should be introspectable"); + assert!(can_read_graph(path, client), "graph {path} should be readable"); + assert!(can_write_graph(path, client), "graph {path} should be writable"); + } + } } None => { - todo!() + // Graph has no permission attributed to it, no access should be allowed. + assert!(!can_read_graph(path, client), "graph {path} should not be readable"); + assert!(!can_introspect_graph(path, client), "graph {path} should not be introspectable"); + assert!(!can_write_graph(path, client), "graph {path} should not be writable"); } } } @@ -161,7 +183,36 @@ pub fn validate_namespace_grant( permission: Option, client: &RaphtoryGraphQLClient, ) { - let _ = (path, permission, client); + match permission { + Some(permission_enum) => { + match permission_enum { + Permission::Discover => { + assert!(can_discover_namespace(path, client), "namespace {path} should be discoverable"); + assert!(!can_introspect_namespace(path, client), "namespace {path} should not be introspectable"); + } + Permission::Introspect => { + assert!(can_introspect_namespace(path, client), "namespace {path} should be introspectable"); + } + _ => { + // Ignore READ and WRITE permissions since they are propagated down to graphs + // and are tested separately. + } + } + } + None => { + // Namespace has no permission attributed to it, no access should be allowed. + assert!(!can_discover_namespace(path, client), "namespace {path} should not be discoverable"); + assert!(!can_introspect_namespace(path, client), "namespace {path} should not be introspectable"); + } + } +} + +pub fn can_introspect_graph(path: &str, client: &RaphtoryGraphQLClient) -> bool { + let query = format!(r#"query {{ graphMetadata(path: "{path}") {{ path nodeCount }} }}"#); + let data = gql(&query, client); + + data.get("graphMetadata") + .is_some_and(|metadata| !metadata.is_null()) } pub fn can_read_graph(path: &str, client: &RaphtoryGraphQLClient) -> bool { @@ -174,14 +225,6 @@ pub fn can_read_graph(path: &str, client: &RaphtoryGraphQLClient) -> bool { .is_some_and(|graph_path| graph_path == path) } -pub fn can_introspect_graph(path: &str, client: &RaphtoryGraphQLClient) -> bool { - let query = format!(r#"query {{ graphMetadata(path: "{path}") {{ path nodeCount }} }}"#); - let data = gql(&query, client); - - data.get("graphMetadata") - .is_some_and(|metadata| !metadata.is_null()) -} - pub fn can_write_graph(path: &str, client: &RaphtoryGraphQLClient) -> bool { let query = format!( r#"query {{ updateGraph(path: "{path}") {{ addNode(time: 1, name: "test_node") {{ success }} }} }}"# diff --git a/raphtory-graphql/tests/utils/strategy.rs b/raphtory-graphql/tests/utils/strategy.rs index ada46db2ab..b61792f9ef 100644 --- a/raphtory-graphql/tests/utils/strategy.rs +++ b/raphtory-graphql/tests/utils/strategy.rs @@ -75,26 +75,33 @@ impl FromStr for Permission { pub fn permissions_strategy( namespace_size: RangeInclusive, + num_grants: RangeInclusive, num_users: RangeInclusive, ) -> BoxedStrategy { (namespace_size, num_users) - .prop_flat_map(|(namespace_size, num_users)| { + .prop_flat_map(move |(namespace_size, num_users)| { + let num_grants = num_grants.clone(); + parents_strategy(namespace_size).prop_flat_map(move |parents| { let namespace_tree = build_namespace_tree(&parents); let graph_paths = leaf_paths(&namespace_tree); let namespace_paths = branch_paths(&namespace_tree); - grants_strategy(num_users, graph_paths.clone(), namespace_paths.clone()).prop_map( - move |grants| { - PermissionsCase { - num_users, - namespace_tree: namespace_tree.clone(), - graph_paths: graph_paths.clone(), - namespace_paths: namespace_paths.clone(), - grants, - } - }, + grants_strategy( + num_grants.clone(), + graph_paths.clone(), + namespace_paths.clone(), + num_users, ) + .prop_map(move |grants| { + PermissionsCase { + num_users, + namespace_tree: namespace_tree.clone(), + graph_paths: graph_paths.clone(), + namespace_paths: namespace_paths.clone(), + grants, + } + }) }) }) .boxed() @@ -127,13 +134,13 @@ fn parents_strategy(tree_size: usize) -> BoxedStrategy> { } fn grants_strategy( - num_users: usize, + num_grants: RangeInclusive, graph_paths: Vec, namespace_paths: Vec, + num_users: usize, ) -> impl Strategy> { let max_user = num_users - 1; let total_paths = namespace_paths.len() + graph_paths.len(); - let num_grants = 1..=total_paths; // FIXME: Increase this to 3x. prop::collection::vec( (0..=max_user, 0..total_paths) From 966972a84b91f752545327e7c80e61cb26137171 Mon Sep 17 00:00:00 2001 From: Fadhil Abubaker Date: Thu, 30 Apr 2026 21:24:26 -0400 Subject: [PATCH 21/31] Move jwt code to jwt.rs --- raphtory-graphql/tests/permissions.rs | 41 +++++++-------------------- raphtory-graphql/tests/utils/jwt.rs | 35 +++++++++++++++++++++++ raphtory-graphql/tests/utils/mod.rs | 1 + 3 files changed, 47 insertions(+), 30 deletions(-) create mode 100644 raphtory-graphql/tests/utils/jwt.rs diff --git a/raphtory-graphql/tests/permissions.rs b/raphtory-graphql/tests/permissions.rs index 851a4f9c96..42f8ffddf2 100644 --- a/raphtory-graphql/tests/permissions.rs +++ b/raphtory-graphql/tests/permissions.rs @@ -2,17 +2,15 @@ mod utils; use std::ops::RangeInclusive; use std::str::FromStr; -use std::sync::LazyLock; -use jsonwebtoken::{encode, Algorithm, EncodingKey, Header}; use proptest::prelude::*; use raphtory::db::api::view::MaterializedGraph; use raphtory::db::graph::node::NodeView; use raphtory_graphql::client::raphtory_client::RaphtoryGraphQLClient; use raphtory::prelude::{GraphViewOps, NodeViewOps, Prop, PropUnwrap, PropertiesOps}; -use serde_json::json; use url::Url; +use utils::jwt::{ADMIN_JWT, PUB_KEY}; use utils::strategy::{permissions_strategy, GrantType, Permission, PermissionGrant}; use utils::graphql::{ @@ -24,24 +22,6 @@ use crate::utils::graphql::{validate_graph_grant, validate_namespace_grant}; const PORT: u16 = 43871; -// Borrowed from test_permissions.py. -const PUB_KEY: &str = "MCowBQYDK2VwAyEADdrWr1kTLj+wSHlr45eneXmOjlHo3N1DjLIvDa2ozno="; -const PRIVATE_KEY: &str = "-----BEGIN PRIVATE KEY----- -MC4CAQAwBQYDK2VwBCIEIFzEcSO/duEjjX4qKxDVy4uLqfmiEIA6bEw1qiPyzTQg ------END PRIVATE KEY-----"; - -static ADMIN_JWT: LazyLock = LazyLock::new(|| { - let key = EncodingKey::from_ed_pem(PRIVATE_KEY.as_bytes()) - .expect("decode Ed25519 private key for test JWTs"); - - encode( - &Header::new(Algorithm::EdDSA), - &json!({ "access": "rw", "role": "admin" }), - &key, - ) - .expect("encode admin JWT") -}); - // Track a permission grant across the given namespace tree. fn track_grant(grant: &PermissionGrant, user_trees: &[MaterializedGraph]) { let user_id = grant.user_id; @@ -148,9 +128,9 @@ fn validate_grant( #[test] fn permissions_proptest() { - const PROPTEST_CASES: u32 = 10; + const PROPTEST_CASES: u32 = 1; const NAMESPACE_SIZE: RangeInclusive = 1..=20; - const NUM_GRANTS: RangeInclusive = 1..=20; + const NUM_GRANTS: RangeInclusive = 1..=1; const NUM_USERS: RangeInclusive = 1..=10; proptest!( @@ -159,13 +139,13 @@ fn permissions_proptest() { let (_server, _tempdir) = start_server(PORT, PUB_KEY); let url = Url::parse(&format!("http://127.0.0.1:{PORT}")).unwrap(); - let client = get_client(url, ADMIN_JWT.to_string()); + let admin_client = get_client(url.clone(), ADMIN_JWT.to_string()); - let tree = case.namespace_tree.clone(); + let tree = case.namespace_tree; // Create graphs on the server. for path in &case.graph_paths { - create_graph(path, &client); + create_graph(path, &admin_client); } let mut user_trees = Vec::with_capacity(case.num_users); @@ -173,7 +153,7 @@ fn permissions_proptest() { // Create roles and separate namespace trees for each user. for i in 0..case.num_users { let role = format!("user_{i}"); - create_role(&role, &client); + create_role(&role, &admin_client); let user_tree = tree.materialize().unwrap(); user_trees.push(user_tree); @@ -181,7 +161,7 @@ fn permissions_proptest() { // Create grants on the server and track them locally. for grant in &case.grants { - create_grant(grant, &client); + create_grant(grant, &admin_client); track_grant(grant, &user_trees); } @@ -190,7 +170,8 @@ fn permissions_proptest() { for user_tree in &user_trees { let node_name = path.split('/').last().unwrap(); let node = user_tree.node(node_name).unwrap(); - validate_grant(path.as_str(), &node, &client); + + validate_grant(path.as_str(), &node, &admin_client); } } @@ -205,7 +186,7 @@ fn permissions_proptest() { continue; } - validate_grant(path.as_str(), &node, &client); + validate_grant(path.as_str(), &node, &admin_client); } } } diff --git a/raphtory-graphql/tests/utils/jwt.rs b/raphtory-graphql/tests/utils/jwt.rs new file mode 100644 index 0000000000..1860ccdc44 --- /dev/null +++ b/raphtory-graphql/tests/utils/jwt.rs @@ -0,0 +1,35 @@ +use std::sync::LazyLock; + +use jsonwebtoken::{encode, Algorithm, EncodingKey, Header}; +use serde_json::json; + +// Borrowed from test_permissions.py. +pub const PUB_KEY: &str = "MCowBQYDK2VwAyEADdrWr1kTLj+wSHlr45eneXmOjlHo3N1DjLIvDa2ozno="; +const PRIVATE_KEY: &str = "-----BEGIN PRIVATE KEY----- +MC4CAQAwBQYDK2VwBCIEIFzEcSO/duEjjX4qKxDVy4uLqfmiEIA6bEw1qiPyzTQg +-----END PRIVATE KEY-----"; + +pub static ADMIN_JWT: LazyLock = LazyLock::new(|| { + let key = EncodingKey::from_ed_pem(PRIVATE_KEY.as_bytes()) + .expect("decode Ed25519 private key for test JWTs"); + + encode( + &Header::new(Algorithm::EdDSA), + &json!({ "access": "rw", "role": "admin" }), + &key, + ) + .expect("encode admin JWT") +}); + +#[allow(dead_code)] +pub fn user_jwt(role: &str) -> String { + let key = EncodingKey::from_ed_pem(PRIVATE_KEY.as_bytes()) + .expect("decode Ed25519 private key for test JWTs"); + + encode( + &Header::new(Algorithm::EdDSA), + &json!({ "access": "ro", "role": role }), + &key, + ) + .expect("encode user JWT") +} diff --git a/raphtory-graphql/tests/utils/mod.rs b/raphtory-graphql/tests/utils/mod.rs index bf45cf6796..26b91f3775 100644 --- a/raphtory-graphql/tests/utils/mod.rs +++ b/raphtory-graphql/tests/utils/mod.rs @@ -1,3 +1,4 @@ pub mod graphql; +pub mod jwt; pub mod strategy; pub mod tree; From 3ca116c67ebecac3e477d111f93ddcd10ed16287 Mon Sep 17 00:00:00 2001 From: Fadhil Abubaker Date: Fri, 1 May 2026 11:56:32 -0400 Subject: [PATCH 22/31] Fix namespace introspect check --- raphtory-graphql/tests/permissions.rs | 22 +++-- raphtory-graphql/tests/utils/graphql.rs | 119 ++++++++++++++---------- raphtory-graphql/tests/utils/jwt.rs | 1 - 3 files changed, 81 insertions(+), 61 deletions(-) diff --git a/raphtory-graphql/tests/permissions.rs b/raphtory-graphql/tests/permissions.rs index 42f8ffddf2..da7a54bef9 100644 --- a/raphtory-graphql/tests/permissions.rs +++ b/raphtory-graphql/tests/permissions.rs @@ -10,7 +10,7 @@ use raphtory_graphql::client::raphtory_client::RaphtoryGraphQLClient; use raphtory::prelude::{GraphViewOps, NodeViewOps, Prop, PropUnwrap, PropertiesOps}; use url::Url; -use utils::jwt::{ADMIN_JWT, PUB_KEY}; +use utils::jwt::{user_jwt, ADMIN_JWT, PUB_KEY}; use utils::strategy::{permissions_strategy, GrantType, Permission, PermissionGrant}; use utils::graphql::{ @@ -128,10 +128,10 @@ fn validate_grant( #[test] fn permissions_proptest() { - const PROPTEST_CASES: u32 = 1; - const NAMESPACE_SIZE: RangeInclusive = 1..=20; - const NUM_GRANTS: RangeInclusive = 1..=1; - const NUM_USERS: RangeInclusive = 1..=10; + const PROPTEST_CASES: u32 = 10; + const NAMESPACE_SIZE: RangeInclusive = 1..=100; + const NUM_GRANTS: RangeInclusive = 1..=100; + const NUM_USERS: RangeInclusive = 1..=20; proptest!( ProptestConfig::with_cases(PROPTEST_CASES), @@ -167,17 +167,19 @@ fn permissions_proptest() { // Validate grants across graph paths for each user. for path in &case.graph_paths { - for user_tree in &user_trees { + for (user_id, user_tree) in user_trees.iter().enumerate() { let node_name = path.split('/').last().unwrap(); let node = user_tree.node(node_name).unwrap(); - validate_grant(path.as_str(), &node, &admin_client); + let role = format!("user_{user_id}"); + let user_client = get_client(url.clone(), user_jwt(&role)); + validate_grant(path.as_str(), &node, &user_client); } } // Validate grants across namespace paths for each user. for path in &case.namespace_paths { - for user_tree in &user_trees { + for (user_id, user_tree) in user_trees.iter().enumerate() { let node_name = path.split('/').last().unwrap(); let node = user_tree.node(node_name).unwrap(); @@ -186,7 +188,9 @@ fn permissions_proptest() { continue; } - validate_grant(path.as_str(), &node, &admin_client); + let role = format!("user_{user_id}"); + let user_client = get_client(url.clone(), user_jwt(&role)); + validate_grant(path.as_str(), &node, &user_client); } } } diff --git a/raphtory-graphql/tests/utils/graphql.rs b/raphtory-graphql/tests/utils/graphql.rs index cd5bea41c1..14e7ec5e1c 100644 --- a/raphtory-graphql/tests/utils/graphql.rs +++ b/raphtory-graphql/tests/utils/graphql.rs @@ -3,7 +3,7 @@ use std::sync::{LazyLock, Once}; use std::time::Duration; use raphtory::db::api::storage::storage::Config; -use raphtory_graphql::client::raphtory_client::RaphtoryGraphQLClient; +use raphtory_graphql::client::{raphtory_client::RaphtoryGraphQLClient, ClientError}; use raphtory_graphql::config::app_config::AppConfigBuilder; use raphtory_graphql::server::{apply_server_extension, GraphServer, RunningGraphServer}; use serde_json::Value as JsonValue; @@ -69,7 +69,8 @@ pub fn create_role(name: &str, client: &RaphtoryGraphQLClient) { let query = format!(r#"mutation {{ permissions {{ createRole(name: "{name}") {{ success }} }} }}"#); - let data = gql(&query, client); + let data = gql(&query, client) + .unwrap_or_else(|e| panic!("Error executing query: {query}: {e}")); let success = data .get("permissions") @@ -100,7 +101,8 @@ pub fn grant_graph( permission ); - let data = gql(&query, client); + let data = gql(&query, client) + .unwrap_or_else(|e| panic!("Error executing query: {query}: {e}")); let success = data .get("permissions") @@ -126,7 +128,8 @@ pub fn grant_namespace( permission ); - let data = gql(&query, client); + let data = gql(&query, client) + .unwrap_or_else(|e| panic!("Error executing query: {query}: {e}")); let success = data .get("permissions") @@ -178,38 +181,10 @@ pub fn validate_graph_grant( } } -pub fn validate_namespace_grant( - path: &str, - permission: Option, - client: &RaphtoryGraphQLClient, -) { - match permission { - Some(permission_enum) => { - match permission_enum { - Permission::Discover => { - assert!(can_discover_namespace(path, client), "namespace {path} should be discoverable"); - assert!(!can_introspect_namespace(path, client), "namespace {path} should not be introspectable"); - } - Permission::Introspect => { - assert!(can_introspect_namespace(path, client), "namespace {path} should be introspectable"); - } - _ => { - // Ignore READ and WRITE permissions since they are propagated down to graphs - // and are tested separately. - } - } - } - None => { - // Namespace has no permission attributed to it, no access should be allowed. - assert!(!can_discover_namespace(path, client), "namespace {path} should not be discoverable"); - assert!(!can_introspect_namespace(path, client), "namespace {path} should not be introspectable"); - } - } -} - pub fn can_introspect_graph(path: &str, client: &RaphtoryGraphQLClient) -> bool { let query = format!(r#"query {{ graphMetadata(path: "{path}") {{ path nodeCount }} }}"#); - let data = gql(&query, client); + let data = gql(&query, client) + .unwrap_or_else(|e| panic!("Error executing query: {query}: {e}")); data.get("graphMetadata") .is_some_and(|metadata| !metadata.is_null()) @@ -217,7 +192,8 @@ pub fn can_introspect_graph(path: &str, client: &RaphtoryGraphQLClient) -> bool pub fn can_read_graph(path: &str, client: &RaphtoryGraphQLClient) -> bool { let query = format!(r#"query {{ graph(path: "{path}") {{ path }} }}"#); - let data = gql(&query, client); + let data = gql(&query, client) + .unwrap_or_else(|e| panic!("Error executing query: {query}: {e}")); data.get("graph") .and_then(|graph| graph.get("path")) @@ -229,7 +205,15 @@ pub fn can_write_graph(path: &str, client: &RaphtoryGraphQLClient) -> bool { let query = format!( r#"query {{ updateGraph(path: "{path}") {{ addNode(time: 1, name: "test_node") {{ success }} }} }}"# ); - let data = gql(&query, client); + + // Write requests are denied if the user does not have write access to the graph. + // This is unlike read requests which return a successful but empty response if + // the graph does not exist. + let data = match gql(&query, client) { + Ok(data) => data, + Err(e) if e.to_string().contains("Access denied") => return false, + Err(e) => panic!("Error executing query: {query}: {e}"), + }; data.get("updateGraph") .and_then(|update| update.get("addNode")) @@ -238,6 +222,34 @@ pub fn can_write_graph(path: &str, client: &RaphtoryGraphQLClient) -> bool { .unwrap_or(false) } +pub fn validate_namespace_grant( + path: &str, + permission: Option, + client: &RaphtoryGraphQLClient, +) { + match permission { + Some(permission_enum) => { + match permission_enum { + Permission::Discover => { + assert!(can_discover_namespace(path, client), "namespace {path} should be discoverable"); + } + Permission::Introspect => { + assert!(can_introspect_namespace(path, client), "namespace {path} should be introspectable"); + } + _ => { + // Ignore READ and WRITE permissions since they are propagated down to graphs + // and are tested separately. + } + } + } + None => { + // Namespace has no permission attributed to it, no access should be allowed. + assert!(!can_discover_namespace(path, client), "namespace {path} should not be discoverable"); + assert!(!can_introspect_namespace(path, client), "namespace {path} should not be introspectable"); + } + } +} + /// Verify that the namespace at `path` appears in its parent listing. pub fn can_discover_namespace(path: &str, client: &RaphtoryGraphQLClient) -> bool { let parent = path.rsplit_once('/').map_or("root", |(parent, _)| parent); @@ -248,7 +260,8 @@ pub fn can_discover_namespace(path: &str, client: &RaphtoryGraphQLClient) -> boo format!(r#"query {{ namespace(path: "{parent}") {{ children {{ list {{ path }} }} }} }}"#) }; - let data = gql(&query, client); + let data = gql(&query, client) + .unwrap_or_else(|e| panic!("Error executing query: {query}: {e}")); let children = if parent == "root" { data.get("root") @@ -256,18 +269,19 @@ pub fn can_discover_namespace(path: &str, client: &RaphtoryGraphQLClient) -> boo data.get("namespace") }; - // Verify that the namespace is in the children list. + // Verify that the parent's children list is non-empty and contains this namespace. children .and_then(|namespace| namespace.get("children")) .and_then(|children| children.get("list")) .and_then(JsonValue::as_array) .is_some_and(|entries| { - entries.iter().any(|entry| { - entry - .get("path") - .and_then(JsonValue::as_str) - .is_some_and(|entry_path| entry_path == path) - }) + !entries.is_empty() + && entries.iter().any(|entry| { + entry + .get("path") + .and_then(JsonValue::as_str) + .is_some_and(|entry_path| entry_path == path) + }) }) } @@ -277,26 +291,29 @@ pub fn can_introspect_namespace(path: &str, client: &RaphtoryGraphQLClient) -> b r#"query {{ namespace(path: "{path}") {{ graphs {{ list {{ path }} }} children {{ list {{ path }} }} }} }}"# ); - let data = gql(&query, client); + let data = gql(&query, client) + .unwrap_or_else(|e| panic!("Error executing query: {query}: {e}")); + let namespace = data.get("namespace"); let has_graphs_list = namespace .and_then(|ns| ns.get("graphs")) .and_then(|graphs| graphs.get("list")) .and_then(JsonValue::as_array) - .is_some(); + .is_some_and(|entries| !entries.is_empty()); let has_children_list = namespace .and_then(|ns| ns.get("children")) .and_then(|children| children.get("list")) .and_then(JsonValue::as_array) - .is_some(); + .is_some_and(|entries| !entries.is_empty()); has_graphs_list || has_children_list } -fn gql(query: &str, client: &RaphtoryGraphQLClient) -> HashMap { - RUNTIME - .block_on(client.query(query, HashMap::new())) - .unwrap_or_else(|e| panic!("Error executing query {query}: {e}")) +fn gql( + query: &str, + client: &RaphtoryGraphQLClient, +) -> Result, ClientError> { + RUNTIME.block_on(client.query(query, HashMap::new())) } diff --git a/raphtory-graphql/tests/utils/jwt.rs b/raphtory-graphql/tests/utils/jwt.rs index 1860ccdc44..1d5341a08f 100644 --- a/raphtory-graphql/tests/utils/jwt.rs +++ b/raphtory-graphql/tests/utils/jwt.rs @@ -21,7 +21,6 @@ pub static ADMIN_JWT: LazyLock = LazyLock::new(|| { .expect("encode admin JWT") }); -#[allow(dead_code)] pub fn user_jwt(role: &str) -> String { let key = EncodingKey::from_ed_pem(PRIVATE_KEY.as_bytes()) .expect("decode Ed25519 private key for test JWTs"); From 447aef329ffec7a437dc6f4e2e22634d561d85c3 Mon Sep 17 00:00:00 2001 From: Fadhil Abubaker Date: Fri, 1 May 2026 16:18:52 -0400 Subject: [PATCH 23/31] Set cache capacity to prevent eviction --- raphtory-graphql/tests/permissions.rs | 3 ++- raphtory-graphql/tests/utils/graphql.rs | 8 +++++++- 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/raphtory-graphql/tests/permissions.rs b/raphtory-graphql/tests/permissions.rs index da7a54bef9..9d899567cc 100644 --- a/raphtory-graphql/tests/permissions.rs +++ b/raphtory-graphql/tests/permissions.rs @@ -136,7 +136,8 @@ fn permissions_proptest() { proptest!( ProptestConfig::with_cases(PROPTEST_CASES), |(case in permissions_strategy(NAMESPACE_SIZE, NUM_GRANTS, NUM_USERS))| { - let (_server, _tempdir) = start_server(PORT, PUB_KEY); + let total_graphs = case.graph_paths.len() as u64; + let (_server, _tempdir) = start_server(PORT, PUB_KEY, total_graphs); let url = Url::parse(&format!("http://127.0.0.1:{PORT}")).unwrap(); let admin_client = get_client(url.clone(), ADMIN_JWT.to_string()); diff --git a/raphtory-graphql/tests/utils/graphql.rs b/raphtory-graphql/tests/utils/graphql.rs index 14e7ec5e1c..c04f9f77ba 100644 --- a/raphtory-graphql/tests/utils/graphql.rs +++ b/raphtory-graphql/tests/utils/graphql.rs @@ -28,7 +28,11 @@ pub fn get_client(url: Url, token: String) -> RaphtoryGraphQLClient { .expect("connect GraphQL client") } -pub fn start_server(port: u16, pub_key: &str) -> (RunningGraphServer, TempDir) { +pub fn start_server( + port: u16, + pub_key: &str, + cache_capacity: u64, +) -> (RunningGraphServer, TempDir) { init_raphtory_auth(); RUNTIME.block_on(async { @@ -40,7 +44,9 @@ pub fn start_server(port: u16, pub_key: &str) -> (RunningGraphServer, TempDir) { let work_dir = tempdir.path().to_path_buf(); let permissions_path = work_dir.join("permissions.json"); + // Set cache capacity to prevent eviction during testing. let app_config = AppConfigBuilder::new() + .with_cache_capacity(cache_capacity) .with_auth_public_key(Some(pub_key.to_string())) .expect("test auth public key") .build(); From 21fea7b099c9f3904930dc043ff45286070b281c Mon Sep 17 00:00:00 2001 From: Fadhil Abubaker Date: Fri, 1 May 2026 17:47:39 -0400 Subject: [PATCH 24/31] Modify can_introspect_namespace to account for children --- raphtory-graphql/tests/permissions.rs | 3 +- raphtory-graphql/tests/utils/graphql.rs | 50 +++++++++++++++++-------- 2 files changed, 37 insertions(+), 16 deletions(-) diff --git a/raphtory-graphql/tests/permissions.rs b/raphtory-graphql/tests/permissions.rs index 9d899567cc..f7ee6c7e5b 100644 --- a/raphtory-graphql/tests/permissions.rs +++ b/raphtory-graphql/tests/permissions.rs @@ -122,7 +122,8 @@ fn validate_grant( if is_graph_path { validate_graph_grant(path, permission, client); } else { - validate_namespace_grant(path, permission, client); + let num_children = node.out_neighbours().len(); + validate_namespace_grant(path, permission, client, num_children); } } diff --git a/raphtory-graphql/tests/utils/graphql.rs b/raphtory-graphql/tests/utils/graphql.rs index c04f9f77ba..b11a5b48de 100644 --- a/raphtory-graphql/tests/utils/graphql.rs +++ b/raphtory-graphql/tests/utils/graphql.rs @@ -214,7 +214,7 @@ pub fn can_write_graph(path: &str, client: &RaphtoryGraphQLClient) -> bool { // Write requests are denied if the user does not have write access to the graph. // This is unlike read requests which return a successful but empty response if - // the graph does not exist. + // the user does not have read access. let data = match gql(&query, client) { Ok(data) => data, Err(e) if e.to_string().contains("Access denied") => return false, @@ -232,6 +232,7 @@ pub fn validate_namespace_grant( path: &str, permission: Option, client: &RaphtoryGraphQLClient, + num_children: usize, ) { match permission { Some(permission_enum) => { @@ -240,7 +241,10 @@ pub fn validate_namespace_grant( assert!(can_discover_namespace(path, client), "namespace {path} should be discoverable"); } Permission::Introspect => { - assert!(can_introspect_namespace(path, client), "namespace {path} should be introspectable"); + assert!( + can_introspect_namespace(path, num_children, client), + "namespace {path} should be introspectable" + ); } _ => { // Ignore READ and WRITE permissions since they are propagated down to graphs @@ -251,7 +255,10 @@ pub fn validate_namespace_grant( None => { // Namespace has no permission attributed to it, no access should be allowed. assert!(!can_discover_namespace(path, client), "namespace {path} should not be discoverable"); - assert!(!can_introspect_namespace(path, client), "namespace {path} should not be introspectable"); + assert!( + !can_introspect_namespace(path, num_children, client), + "namespace {path} should not be introspectable" + ); } } } @@ -291,30 +298,43 @@ pub fn can_discover_namespace(path: &str, client: &RaphtoryGraphQLClient) -> boo }) } -// Verify that the namespace at `path` can have its listings browsed. -pub fn can_introspect_namespace(path: &str, client: &RaphtoryGraphQLClient) -> bool { +// Verify that the namespace at `path` can have its listings browsed and that +// immediate graphs plus child namespaces match the expected `num_children`. +pub fn can_introspect_namespace( + path: &str, + num_children: usize, + client: &RaphtoryGraphQLClient, +) -> bool { let query = format!( r#"query {{ namespace(path: "{path}") {{ graphs {{ list {{ path }} }} children {{ list {{ path }} }} }} }}"# ); - let data = gql(&query, client) - .unwrap_or_else(|e| panic!("Error executing query: {query}: {e}")); + let data = match gql(&query, client) { + Ok(data) => data, + Err(e) if e.to_string().contains("Access denied") => return false, + Err(e) => panic!("Error executing query: {query}: {e}"), + }; - let namespace = data.get("namespace"); + let Some(namespace) = data.get("namespace").filter(|v| !v.is_null()) else { + return false; + }; - let has_graphs_list = namespace - .and_then(|ns| ns.get("graphs")) + let num_graphs = namespace + .get("graphs") .and_then(|graphs| graphs.get("list")) .and_then(JsonValue::as_array) - .is_some_and(|entries| !entries.is_empty()); + .map(|entries| entries.len()) + .unwrap_or(0); - let has_children_list = namespace - .and_then(|ns| ns.get("children")) + let num_namespaces = namespace + .get("children") .and_then(|children| children.get("list")) .and_then(JsonValue::as_array) - .is_some_and(|entries| !entries.is_empty()); + .map(|entries| entries.len()) + .unwrap_or(0); - has_graphs_list || has_children_list + // INSTROSPECT implies all listings of `path` are visible. + num_graphs + num_namespaces == num_children } fn gql( From 35c828ac2a1c43349ba512aa97a4e74c89a88ce9 Mon Sep 17 00:00:00 2001 From: Fadhil Abubaker Date: Mon, 4 May 2026 12:33:16 -0400 Subject: [PATCH 25/31] Add can_write_namespace --- raphtory-graphql/tests/permissions.rs | 2 +- raphtory-graphql/tests/utils/graphql.rs | 29 ++++++++++++++++++++----- 2 files changed, 25 insertions(+), 6 deletions(-) diff --git a/raphtory-graphql/tests/permissions.rs b/raphtory-graphql/tests/permissions.rs index f7ee6c7e5b..7e65d1bddc 100644 --- a/raphtory-graphql/tests/permissions.rs +++ b/raphtory-graphql/tests/permissions.rs @@ -147,7 +147,7 @@ fn permissions_proptest() { // Create graphs on the server. for path in &case.graph_paths { - create_graph(path, &admin_client); + create_graph(path, &admin_client).unwrap(); } let mut user_trees = Vec::with_capacity(case.num_users); diff --git a/raphtory-graphql/tests/utils/graphql.rs b/raphtory-graphql/tests/utils/graphql.rs index b11a5b48de..e08f0b039e 100644 --- a/raphtory-graphql/tests/utils/graphql.rs +++ b/raphtory-graphql/tests/utils/graphql.rs @@ -67,8 +67,12 @@ pub fn start_server( } /// Create a graph on the graphql server using the given path. -pub fn create_graph(path: &str, client: &RaphtoryGraphQLClient) { - RUNTIME.block_on(client.new_graph(path, "EVENT")).unwrap(); +pub fn create_graph(path: &str, client: &RaphtoryGraphQLClient) -> Result<(), ClientError> { + RUNTIME.block_on(client.new_graph(path, "EVENT")) +} + +pub fn delete_graph(path: &str, client: &RaphtoryGraphQLClient) -> Result<(), ClientError> { + RUNTIME.block_on(client.delete_graph(path)) } pub fn create_role(name: &str, client: &RaphtoryGraphQLClient) { @@ -245,10 +249,15 @@ pub fn validate_namespace_grant( can_introspect_namespace(path, num_children, client), "namespace {path} should be introspectable" ); + assert!(!can_write_namespace(path, client), "namespace {path} should not be writable"); } - _ => { - // Ignore READ and WRITE permissions since they are propagated down to graphs - // and are tested separately. + Permission::Read => { + assert!(can_introspect_namespace(path, num_children, client), "namespace {path} should be introspectable"); + assert!(!can_write_namespace(path, client), "namespace {path} should not be writable"); + } + Permission::Write => { + assert!(can_introspect_namespace(path, num_children, client), "namespace {path} should be introspectable"); + assert!(can_write_namespace(path, client), "namespace {path} should be writable"); } } } @@ -337,6 +346,16 @@ pub fn can_introspect_namespace( num_graphs + num_namespaces == num_children } +/// Can create and delete graphs at `path`. +pub fn can_write_namespace(path: &str, client: &RaphtoryGraphQLClient) -> bool { + if create_graph(path, client).is_err() { + // Do not attempt delete if create fails. + return false; + } + + delete_graph(path, client).is_ok() +} + fn gql( query: &str, client: &RaphtoryGraphQLClient, From 2aaa287b725801e8b500cf2ab104c3de22a739f4 Mon Sep 17 00:00:00 2001 From: Fadhil Abubaker Date: Mon, 4 May 2026 12:39:02 -0400 Subject: [PATCH 26/31] Create validate.rs --- raphtory-graphql/tests/permissions.rs | 2 +- raphtory-graphql/tests/utils/graphql.rs | 204 +--------------------- raphtory-graphql/tests/utils/mod.rs | 1 + raphtory-graphql/tests/utils/validate.rs | 207 +++++++++++++++++++++++ 4 files changed, 210 insertions(+), 204 deletions(-) create mode 100644 raphtory-graphql/tests/utils/validate.rs diff --git a/raphtory-graphql/tests/permissions.rs b/raphtory-graphql/tests/permissions.rs index 7e65d1bddc..232a1fecbe 100644 --- a/raphtory-graphql/tests/permissions.rs +++ b/raphtory-graphql/tests/permissions.rs @@ -18,7 +18,7 @@ use utils::graphql::{ start_server, }; -use crate::utils::graphql::{validate_graph_grant, validate_namespace_grant}; +use crate::utils::validate::{validate_graph_grant, validate_namespace_grant}; const PORT: u16 = 43871; diff --git a/raphtory-graphql/tests/utils/graphql.rs b/raphtory-graphql/tests/utils/graphql.rs index e08f0b039e..5c0096baf5 100644 --- a/raphtory-graphql/tests/utils/graphql.rs +++ b/raphtory-graphql/tests/utils/graphql.rs @@ -154,209 +154,7 @@ pub fn grant_namespace( ); } -pub fn validate_graph_grant( - path: &str, - permission: Option, - client: &RaphtoryGraphQLClient, -) { - match permission { - Some(permission_enum) => { - match permission_enum { - Permission::Discover => { - panic!("Discover permission is not supported for graphs"); - } - Permission::Introspect => { - assert!(can_introspect_graph(path, client), "graph {path} should be introspectable"); - assert!(!can_read_graph(path, client), "graph {path} should not be readable"); - assert!(!can_write_graph(path, client), "graph {path} should not be writable"); - } - Permission::Read => { - assert!(can_introspect_graph(path, client), "graph {path} should be introspectable"); - assert!(can_read_graph(path, client), "graph {path} should be readable"); - assert!(!can_write_graph(path, client), "graph {path} should not be writable"); - } - Permission::Write => { - assert!(can_introspect_graph(path, client), "graph {path} should be introspectable"); - assert!(can_read_graph(path, client), "graph {path} should be readable"); - assert!(can_write_graph(path, client), "graph {path} should be writable"); - } - } - } - None => { - // Graph has no permission attributed to it, no access should be allowed. - assert!(!can_read_graph(path, client), "graph {path} should not be readable"); - assert!(!can_introspect_graph(path, client), "graph {path} should not be introspectable"); - assert!(!can_write_graph(path, client), "graph {path} should not be writable"); - } - } -} - -pub fn can_introspect_graph(path: &str, client: &RaphtoryGraphQLClient) -> bool { - let query = format!(r#"query {{ graphMetadata(path: "{path}") {{ path nodeCount }} }}"#); - let data = gql(&query, client) - .unwrap_or_else(|e| panic!("Error executing query: {query}: {e}")); - - data.get("graphMetadata") - .is_some_and(|metadata| !metadata.is_null()) -} - -pub fn can_read_graph(path: &str, client: &RaphtoryGraphQLClient) -> bool { - let query = format!(r#"query {{ graph(path: "{path}") {{ path }} }}"#); - let data = gql(&query, client) - .unwrap_or_else(|e| panic!("Error executing query: {query}: {e}")); - - data.get("graph") - .and_then(|graph| graph.get("path")) - .and_then(JsonValue::as_str) - .is_some_and(|graph_path| graph_path == path) -} - -pub fn can_write_graph(path: &str, client: &RaphtoryGraphQLClient) -> bool { - let query = format!( - r#"query {{ updateGraph(path: "{path}") {{ addNode(time: 1, name: "test_node") {{ success }} }} }}"# - ); - - // Write requests are denied if the user does not have write access to the graph. - // This is unlike read requests which return a successful but empty response if - // the user does not have read access. - let data = match gql(&query, client) { - Ok(data) => data, - Err(e) if e.to_string().contains("Access denied") => return false, - Err(e) => panic!("Error executing query: {query}: {e}"), - }; - - data.get("updateGraph") - .and_then(|update| update.get("addNode")) - .and_then(|add_node| add_node.get("success")) - .and_then(JsonValue::as_bool) - .unwrap_or(false) -} - -pub fn validate_namespace_grant( - path: &str, - permission: Option, - client: &RaphtoryGraphQLClient, - num_children: usize, -) { - match permission { - Some(permission_enum) => { - match permission_enum { - Permission::Discover => { - assert!(can_discover_namespace(path, client), "namespace {path} should be discoverable"); - } - Permission::Introspect => { - assert!( - can_introspect_namespace(path, num_children, client), - "namespace {path} should be introspectable" - ); - assert!(!can_write_namespace(path, client), "namespace {path} should not be writable"); - } - Permission::Read => { - assert!(can_introspect_namespace(path, num_children, client), "namespace {path} should be introspectable"); - assert!(!can_write_namespace(path, client), "namespace {path} should not be writable"); - } - Permission::Write => { - assert!(can_introspect_namespace(path, num_children, client), "namespace {path} should be introspectable"); - assert!(can_write_namespace(path, client), "namespace {path} should be writable"); - } - } - } - None => { - // Namespace has no permission attributed to it, no access should be allowed. - assert!(!can_discover_namespace(path, client), "namespace {path} should not be discoverable"); - assert!( - !can_introspect_namespace(path, num_children, client), - "namespace {path} should not be introspectable" - ); - } - } -} - -/// Verify that the namespace at `path` appears in its parent listing. -pub fn can_discover_namespace(path: &str, client: &RaphtoryGraphQLClient) -> bool { - let parent = path.rsplit_once('/').map_or("root", |(parent, _)| parent); - - let query = if parent == "root" { - r#"query { root { children { list { path } } } }"#.to_string() - } else { - format!(r#"query {{ namespace(path: "{parent}") {{ children {{ list {{ path }} }} }} }}"#) - }; - - let data = gql(&query, client) - .unwrap_or_else(|e| panic!("Error executing query: {query}: {e}")); - - let children = if parent == "root" { - data.get("root") - } else { - data.get("namespace") - }; - - // Verify that the parent's children list is non-empty and contains this namespace. - children - .and_then(|namespace| namespace.get("children")) - .and_then(|children| children.get("list")) - .and_then(JsonValue::as_array) - .is_some_and(|entries| { - !entries.is_empty() - && entries.iter().any(|entry| { - entry - .get("path") - .and_then(JsonValue::as_str) - .is_some_and(|entry_path| entry_path == path) - }) - }) -} - -// Verify that the namespace at `path` can have its listings browsed and that -// immediate graphs plus child namespaces match the expected `num_children`. -pub fn can_introspect_namespace( - path: &str, - num_children: usize, - client: &RaphtoryGraphQLClient, -) -> bool { - let query = format!( - r#"query {{ namespace(path: "{path}") {{ graphs {{ list {{ path }} }} children {{ list {{ path }} }} }} }}"# - ); - - let data = match gql(&query, client) { - Ok(data) => data, - Err(e) if e.to_string().contains("Access denied") => return false, - Err(e) => panic!("Error executing query: {query}: {e}"), - }; - - let Some(namespace) = data.get("namespace").filter(|v| !v.is_null()) else { - return false; - }; - - let num_graphs = namespace - .get("graphs") - .and_then(|graphs| graphs.get("list")) - .and_then(JsonValue::as_array) - .map(|entries| entries.len()) - .unwrap_or(0); - - let num_namespaces = namespace - .get("children") - .and_then(|children| children.get("list")) - .and_then(JsonValue::as_array) - .map(|entries| entries.len()) - .unwrap_or(0); - - // INSTROSPECT implies all listings of `path` are visible. - num_graphs + num_namespaces == num_children -} - -/// Can create and delete graphs at `path`. -pub fn can_write_namespace(path: &str, client: &RaphtoryGraphQLClient) -> bool { - if create_graph(path, client).is_err() { - // Do not attempt delete if create fails. - return false; - } - - delete_graph(path, client).is_ok() -} - -fn gql( +pub(crate) fn gql( query: &str, client: &RaphtoryGraphQLClient, ) -> Result, ClientError> { diff --git a/raphtory-graphql/tests/utils/mod.rs b/raphtory-graphql/tests/utils/mod.rs index 26b91f3775..9e32a11695 100644 --- a/raphtory-graphql/tests/utils/mod.rs +++ b/raphtory-graphql/tests/utils/mod.rs @@ -1,4 +1,5 @@ pub mod graphql; +pub mod validate; pub mod jwt; pub mod strategy; pub mod tree; diff --git a/raphtory-graphql/tests/utils/validate.rs b/raphtory-graphql/tests/utils/validate.rs new file mode 100644 index 0000000000..7e653fe143 --- /dev/null +++ b/raphtory-graphql/tests/utils/validate.rs @@ -0,0 +1,207 @@ +use raphtory_graphql::client::raphtory_client::RaphtoryGraphQLClient; +use serde_json::Value as JsonValue; + +use crate::utils::graphql::{create_graph, delete_graph, gql}; +use crate::utils::strategy::Permission; + +pub fn validate_graph_grant( + path: &str, + permission: Option, + client: &RaphtoryGraphQLClient, +) { + match permission { + Some(permission_enum) => { + match permission_enum { + Permission::Discover => { + panic!("Discover permission is not supported for graphs"); + } + Permission::Introspect => { + assert!(can_introspect_graph(path, client), "graph {path} should be introspectable"); + assert!(!can_read_graph(path, client), "graph {path} should not be readable"); + assert!(!can_write_graph(path, client), "graph {path} should not be writable"); + } + Permission::Read => { + assert!(can_introspect_graph(path, client), "graph {path} should be introspectable"); + assert!(can_read_graph(path, client), "graph {path} should be readable"); + assert!(!can_write_graph(path, client), "graph {path} should not be writable"); + } + Permission::Write => { + assert!(can_introspect_graph(path, client), "graph {path} should be introspectable"); + assert!(can_read_graph(path, client), "graph {path} should be readable"); + assert!(can_write_graph(path, client), "graph {path} should be writable"); + } + } + } + None => { + // Graph has no permission attributed to it, no access should be allowed. + assert!(!can_read_graph(path, client), "graph {path} should not be readable"); + assert!(!can_introspect_graph(path, client), "graph {path} should not be introspectable"); + assert!(!can_write_graph(path, client), "graph {path} should not be writable"); + } + } +} + +pub fn can_introspect_graph(path: &str, client: &RaphtoryGraphQLClient) -> bool { + let query = format!(r#"query {{ graphMetadata(path: "{path}") {{ path nodeCount }} }}"#); + let data = gql(&query, client) + .unwrap_or_else(|e| panic!("Error executing query: {query}: {e}")); + + data.get("graphMetadata") + .is_some_and(|metadata| !metadata.is_null()) +} + +pub fn can_read_graph(path: &str, client: &RaphtoryGraphQLClient) -> bool { + let query = format!(r#"query {{ graph(path: "{path}") {{ path }} }}"#); + let data = gql(&query, client) + .unwrap_or_else(|e| panic!("Error executing query: {query}: {e}")); + + data.get("graph") + .and_then(|graph| graph.get("path")) + .and_then(JsonValue::as_str) + .is_some_and(|graph_path| graph_path == path) +} + +pub fn can_write_graph(path: &str, client: &RaphtoryGraphQLClient) -> bool { + let query = format!( + r#"query {{ updateGraph(path: "{path}") {{ addNode(time: 1, name: "test_node") {{ success }} }} }}"# + ); + + // Write requests are denied if the user does not have write access to the graph. + // This is unlike read requests which return a successful but empty response if + // the user does not have read access. + let data = match gql(&query, client) { + Ok(data) => data, + Err(e) if e.to_string().contains("Access denied") => return false, + Err(e) => panic!("Error executing query: {query}: {e}"), + }; + + data.get("updateGraph") + .and_then(|update| update.get("addNode")) + .and_then(|add_node| add_node.get("success")) + .and_then(JsonValue::as_bool) + .unwrap_or(false) +} + +pub fn validate_namespace_grant( + path: &str, + permission: Option, + client: &RaphtoryGraphQLClient, + num_children: usize, +) { + match permission { + Some(permission_enum) => { + match permission_enum { + Permission::Discover => { + assert!(can_discover_namespace(path, client), "namespace {path} should be discoverable"); + } + Permission::Introspect => { + assert!( + can_introspect_namespace(path, num_children, client), + "namespace {path} should be introspectable" + ); + assert!(!can_write_namespace(path, client), "namespace {path} should not be writable"); + } + Permission::Read => { + assert!(can_introspect_namespace(path, num_children, client), "namespace {path} should be introspectable"); + assert!(!can_write_namespace(path, client), "namespace {path} should not be writable"); + } + Permission::Write => { + assert!(can_introspect_namespace(path, num_children, client), "namespace {path} should be introspectable"); + assert!(can_write_namespace(path, client), "namespace {path} should be writable"); + } + } + } + None => { + // Namespace has no permission attributed to it, no access should be allowed. + assert!(!can_discover_namespace(path, client), "namespace {path} should not be discoverable"); + assert!( + !can_introspect_namespace(path, num_children, client), + "namespace {path} should not be introspectable" + ); + } + } +} + +/// Verify that the namespace at `path` appears in its parent listing. +pub fn can_discover_namespace(path: &str, client: &RaphtoryGraphQLClient) -> bool { + let parent = path.rsplit_once('/').map_or("root", |(parent, _)| parent); + + let query = if parent == "root" { + r#"query { root { children { list { path } } } }"#.to_string() + } else { + format!(r#"query {{ namespace(path: "{parent}") {{ children {{ list {{ path }} }} }} }}"#) + }; + + let data = gql(&query, client) + .unwrap_or_else(|e| panic!("Error executing query: {query}: {e}")); + + let children = if parent == "root" { + data.get("root") + } else { + data.get("namespace") + }; + + // Verify that the parent's children list is non-empty and contains this namespace. + children + .and_then(|namespace| namespace.get("children")) + .and_then(|children| children.get("list")) + .and_then(JsonValue::as_array) + .is_some_and(|entries| { + !entries.is_empty() + && entries.iter().any(|entry| { + entry + .get("path") + .and_then(JsonValue::as_str) + .is_some_and(|entry_path| entry_path == path) + }) + }) +} + +// Verify that the namespace at `path` can have its listings browsed and that +// immediate graphs plus child namespaces match the expected `num_children`. +pub fn can_introspect_namespace( + path: &str, + num_children: usize, + client: &RaphtoryGraphQLClient, +) -> bool { + let query = format!( + r#"query {{ namespace(path: "{path}") {{ graphs {{ list {{ path }} }} children {{ list {{ path }} }} }} }}"# + ); + + let data = match gql(&query, client) { + Ok(data) => data, + Err(e) if e.to_string().contains("Access denied") => return false, + Err(e) => panic!("Error executing query: {query}: {e}"), + }; + + let Some(namespace) = data.get("namespace").filter(|v| !v.is_null()) else { + return false; + }; + + let num_graphs = namespace + .get("graphs") + .and_then(|graphs| graphs.get("list")) + .and_then(JsonValue::as_array) + .map(|entries| entries.len()) + .unwrap_or(0); + + let num_namespaces = namespace + .get("children") + .and_then(|children| children.get("list")) + .and_then(JsonValue::as_array) + .map(|entries| entries.len()) + .unwrap_or(0); + + // INSTROSPECT implies all listings of `path` are visible. + num_graphs + num_namespaces == num_children +} + +/// Can create and delete graphs at `path`. +pub fn can_write_namespace(path: &str, client: &RaphtoryGraphQLClient) -> bool { + if create_graph(path, client).is_err() { + // Do not attempt delete if create fails. + return false; + } + + delete_graph(path, client).is_ok() +} From 4d2d8c74cfa13f0ff0d650055421767f317e08aa Mon Sep 17 00:00:00 2001 From: Fadhil Abubaker Date: Mon, 4 May 2026 12:46:49 -0400 Subject: [PATCH 27/31] Return Result --- raphtory-graphql/tests/permissions.rs | 10 ++--- raphtory-graphql/tests/utils/graphql.rs | 58 ++++++++++++++----------- 2 files changed, 38 insertions(+), 30 deletions(-) diff --git a/raphtory-graphql/tests/permissions.rs b/raphtory-graphql/tests/permissions.rs index 232a1fecbe..774880431e 100644 --- a/raphtory-graphql/tests/permissions.rs +++ b/raphtory-graphql/tests/permissions.rs @@ -141,7 +141,7 @@ fn permissions_proptest() { let (_server, _tempdir) = start_server(PORT, PUB_KEY, total_graphs); let url = Url::parse(&format!("http://127.0.0.1:{PORT}")).unwrap(); - let admin_client = get_client(url.clone(), ADMIN_JWT.to_string()); + let admin_client = get_client(url.clone(), ADMIN_JWT.to_string()).unwrap(); let tree = case.namespace_tree; @@ -155,7 +155,7 @@ fn permissions_proptest() { // Create roles and separate namespace trees for each user. for i in 0..case.num_users { let role = format!("user_{i}"); - create_role(&role, &admin_client); + create_role(&role, &admin_client).unwrap(); let user_tree = tree.materialize().unwrap(); user_trees.push(user_tree); @@ -163,7 +163,7 @@ fn permissions_proptest() { // Create grants on the server and track them locally. for grant in &case.grants { - create_grant(grant, &admin_client); + create_grant(grant, &admin_client).unwrap(); track_grant(grant, &user_trees); } @@ -174,7 +174,7 @@ fn permissions_proptest() { let node = user_tree.node(node_name).unwrap(); let role = format!("user_{user_id}"); - let user_client = get_client(url.clone(), user_jwt(&role)); + let user_client = get_client(url.clone(), user_jwt(&role)).unwrap(); validate_grant(path.as_str(), &node, &user_client); } } @@ -191,7 +191,7 @@ fn permissions_proptest() { } let role = format!("user_{user_id}"); - let user_client = get_client(url.clone(), user_jwt(&role)); + let user_client = get_client(url.clone(), user_jwt(&role)).unwrap(); validate_grant(path.as_str(), &node, &user_client); } } diff --git a/raphtory-graphql/tests/utils/graphql.rs b/raphtory-graphql/tests/utils/graphql.rs index 5c0096baf5..3c873551c9 100644 --- a/raphtory-graphql/tests/utils/graphql.rs +++ b/raphtory-graphql/tests/utils/graphql.rs @@ -22,10 +22,11 @@ fn init_raphtory_auth() { pub static RUNTIME: LazyLock = LazyLock::new(|| Runtime::new().expect("Failed to create Tokio runtime")); -pub fn get_client(url: Url, token: String) -> RaphtoryGraphQLClient { - RUNTIME - .block_on(RaphtoryGraphQLClient::connect(url, Some(token))) - .expect("connect GraphQL client") +pub fn get_client( + url: Url, + token: String, +) -> Result { + RUNTIME.block_on(RaphtoryGraphQLClient::connect(url, Some(token))) } pub fn start_server( @@ -75,12 +76,11 @@ pub fn delete_graph(path: &str, client: &RaphtoryGraphQLClient) -> Result<(), Cl RUNTIME.block_on(client.delete_graph(path)) } -pub fn create_role(name: &str, client: &RaphtoryGraphQLClient) { +pub fn create_role(name: &str, client: &RaphtoryGraphQLClient) -> Result<(), ClientError> { let query = format!(r#"mutation {{ permissions {{ createRole(name: "{name}") {{ success }} }} }}"#); - let data = gql(&query, client) - .unwrap_or_else(|e| panic!("Error executing query: {query}: {e}")); + let data = gql(&query, client)?; let success = data .get("permissions") @@ -88,10 +88,18 @@ pub fn create_role(name: &str, client: &RaphtoryGraphQLClient) { .and_then(|c| c.get("success")) .and_then(JsonValue::as_bool); - assert_eq!(success, Some(true), "createRole {name} data: {data:?}"); + if success != Some(true) { + return Err(ClientError::InvalidResponse(format!( + "createRole {name} data: {data:?}" + ))); + } + Ok(()) } -pub fn create_grant(grant: &PermissionGrant, client: &RaphtoryGraphQLClient) { +pub fn create_grant( + grant: &PermissionGrant, + client: &RaphtoryGraphQLClient, +) -> Result<(), ClientError> { let role = format!("user_{}", grant.user_id); match grant.grant_type { @@ -105,14 +113,13 @@ pub fn grant_graph( role: &str, permission: Permission, client: &RaphtoryGraphQLClient, -) { +) -> Result<(), ClientError> { let query = format!( r#"mutation {{ permissions {{ grantGraph(role: "{role}", path: "{path}", permission: {}) {{ success }} }} }}"#, permission ); - let data = gql(&query, client) - .unwrap_or_else(|e| panic!("Error executing query: {query}: {e}")); + let data = gql(&query, client)?; let success = data .get("permissions") @@ -120,11 +127,12 @@ pub fn grant_graph( .and_then(|c| c.get("success")) .and_then(JsonValue::as_bool); - assert_eq!( - success, - Some(true), - "grantGraph role={role} path={path} permission={permission} data: {data:?}" - ); + if success != Some(true) { + return Err(ClientError::InvalidResponse(format!( + "grantGraph role={role} path={path} permission={permission} data: {data:?}" + ))); + } + Ok(()) } pub fn grant_namespace( @@ -132,14 +140,13 @@ pub fn grant_namespace( role: &str, permission: Permission, client: &RaphtoryGraphQLClient, -) { +) -> Result<(), ClientError> { let query = format!( r#"mutation {{ permissions {{ grantNamespace(role: "{role}", path: "{path}", permission: {}) {{ success }} }} }}"#, permission ); - let data = gql(&query, client) - .unwrap_or_else(|e| panic!("Error executing query: {query}: {e}")); + let data = gql(&query, client)?; let success = data .get("permissions") @@ -147,11 +154,12 @@ pub fn grant_namespace( .and_then(|c| c.get("success")) .and_then(JsonValue::as_bool); - assert_eq!( - success, - Some(true), - "grantNamespace role={role} path={path} permission={permission} data: {data:?}" - ); + if success != Some(true) { + return Err(ClientError::InvalidResponse(format!( + "grantNamespace role={role} path={path} permission={permission} data: {data:?}" + ))); + } + Ok(()) } pub(crate) fn gql( From dc2d5bf55f187104303d9b9a18d6fddd00dcc6bd Mon Sep 17 00:00:00 2001 From: Fadhil Abubaker Date: Mon, 4 May 2026 14:36:55 -0400 Subject: [PATCH 28/31] Cover more permissions --- raphtory-graphql/tests/utils/validate.rs | 210 +++++++++++++++++------ 1 file changed, 154 insertions(+), 56 deletions(-) diff --git a/raphtory-graphql/tests/utils/validate.rs b/raphtory-graphql/tests/utils/validate.rs index 7e653fe143..f31d072ead 100644 --- a/raphtory-graphql/tests/utils/validate.rs +++ b/raphtory-graphql/tests/utils/validate.rs @@ -1,9 +1,12 @@ use raphtory_graphql::client::raphtory_client::RaphtoryGraphQLClient; +use raphtory_graphql::client::ClientError; use serde_json::Value as JsonValue; use crate::utils::graphql::{create_graph, delete_graph, gql}; use crate::utils::strategy::Permission; +// --- Graph validators --- + pub fn validate_graph_grant( path: &str, permission: Option, @@ -16,72 +19,119 @@ pub fn validate_graph_grant( panic!("Discover permission is not supported for graphs"); } Permission::Introspect => { - assert!(can_introspect_graph(path, client), "graph {path} should be introspectable"); - assert!(!can_read_graph(path, client), "graph {path} should not be readable"); - assert!(!can_write_graph(path, client), "graph {path} should not be writable"); + assert!( + can_introspect_graph(path, client).unwrap(), + "graph {path} should be introspectable" + ); + assert!( + !can_read_graph(path, client).unwrap(), + "graph {path} should not be readable" + ); + assert!( + !can_write_graph(path, client).unwrap(), + "graph {path} should not be writable" + ); } Permission::Read => { - assert!(can_introspect_graph(path, client), "graph {path} should be introspectable"); - assert!(can_read_graph(path, client), "graph {path} should be readable"); - assert!(!can_write_graph(path, client), "graph {path} should not be writable"); + assert!( + can_introspect_graph(path, client).unwrap(), + "graph {path} should be introspectable" + ); + assert!( + can_read_graph(path, client).unwrap(), + "graph {path} should be readable" + ); + assert!( + !can_write_graph(path, client).unwrap(), + "graph {path} should not be writable" + ); } Permission::Write => { - assert!(can_introspect_graph(path, client), "graph {path} should be introspectable"); - assert!(can_read_graph(path, client), "graph {path} should be readable"); - assert!(can_write_graph(path, client), "graph {path} should be writable"); + assert!( + can_introspect_graph(path, client).unwrap(), + "graph {path} should be introspectable" + ); + assert!( + can_read_graph(path, client).unwrap(), + "graph {path} should be readable" + ); + assert!( + can_write_graph(path, client).unwrap(), + "graph {path} should be writable" + ); } } } None => { // Graph has no permission attributed to it, no access should be allowed. - assert!(!can_read_graph(path, client), "graph {path} should not be readable"); - assert!(!can_introspect_graph(path, client), "graph {path} should not be introspectable"); - assert!(!can_write_graph(path, client), "graph {path} should not be writable"); + assert!( + !can_read_graph(path, client).unwrap(), + "graph {path} should not be readable" + ); + assert!( + !can_introspect_graph(path, client).unwrap(), + "graph {path} should not be introspectable" + ); + assert!( + !can_write_graph(path, client).unwrap(), + "graph {path} should not be writable" + ); } } } -pub fn can_introspect_graph(path: &str, client: &RaphtoryGraphQLClient) -> bool { +pub fn can_introspect_graph( + path: &str, + client: &RaphtoryGraphQLClient, +) -> Result { let query = format!(r#"query {{ graphMetadata(path: "{path}") {{ path nodeCount }} }}"#); - let data = gql(&query, client) - .unwrap_or_else(|e| panic!("Error executing query: {query}: {e}")); + let data = match gql(&query, client) { + Ok(data) => data, + Err(e) if access_denied(&e) => return Ok(false), + Err(e) => return Err(e), + }; - data.get("graphMetadata") - .is_some_and(|metadata| !metadata.is_null()) + Ok(data + .get("graphMetadata") + .is_some_and(|metadata| !metadata.is_null())) } -pub fn can_read_graph(path: &str, client: &RaphtoryGraphQLClient) -> bool { +pub fn can_read_graph(path: &str, client: &RaphtoryGraphQLClient) -> Result { let query = format!(r#"query {{ graph(path: "{path}") {{ path }} }}"#); - let data = gql(&query, client) - .unwrap_or_else(|e| panic!("Error executing query: {query}: {e}")); + let data = match gql(&query, client) { + Ok(data) => data, + Err(e) if access_denied(&e) => return Ok(false), + Err(e) => return Err(e), + }; - data.get("graph") + Ok(data + .get("graph") .and_then(|graph| graph.get("path")) .and_then(JsonValue::as_str) - .is_some_and(|graph_path| graph_path == path) + .is_some_and(|graph_path| graph_path == path)) } -pub fn can_write_graph(path: &str, client: &RaphtoryGraphQLClient) -> bool { +pub fn can_write_graph(path: &str, client: &RaphtoryGraphQLClient) -> Result { let query = format!( r#"query {{ updateGraph(path: "{path}") {{ addNode(time: 1, name: "test_node") {{ success }} }} }}"# ); - - // Write requests are denied if the user does not have write access to the graph. - // This is unlike read requests which return a successful but empty response if - // the user does not have read access. let data = match gql(&query, client) { + Ok(data) => data, - Err(e) if e.to_string().contains("Access denied") => return false, - Err(e) => panic!("Error executing query: {query}: {e}"), + Err(e) if access_denied(&e) => return Ok(false), + Err(e) => return Err(e), }; - data.get("updateGraph") + Ok(data + .get("updateGraph") .and_then(|update| update.get("addNode")) .and_then(|add_node| add_node.get("success")) .and_then(JsonValue::as_bool) - .unwrap_or(false) + .unwrap_or(false)) } +// --- Namespace validators --- + pub fn validate_namespace_grant( path: &str, permission: Option, @@ -92,48 +142,81 @@ pub fn validate_namespace_grant( Some(permission_enum) => { match permission_enum { Permission::Discover => { - assert!(can_discover_namespace(path, client), "namespace {path} should be discoverable"); + assert!( + can_discover_namespace(path, client).unwrap(), + "namespace {path} should be discoverable" + ); } Permission::Introspect => { assert!( - can_introspect_namespace(path, num_children, client), + can_introspect_namespace(path, num_children, client).unwrap(), "namespace {path} should be introspectable" ); - assert!(!can_write_namespace(path, client), "namespace {path} should not be writable"); + assert!( + !can_write_namespace(path, client).unwrap(), + "namespace {path} should not be writable" + ); } Permission::Read => { - assert!(can_introspect_namespace(path, num_children, client), "namespace {path} should be introspectable"); - assert!(!can_write_namespace(path, client), "namespace {path} should not be writable"); + assert!( + can_introspect_namespace(path, num_children, client).unwrap(), + "namespace {path} should be introspectable" + ); + assert!( + !can_write_namespace(path, client).unwrap(), + "namespace {path} should not be writable" + ); } Permission::Write => { - assert!(can_introspect_namespace(path, num_children, client), "namespace {path} should be introspectable"); - assert!(can_write_namespace(path, client), "namespace {path} should be writable"); + assert!( + can_introspect_namespace(path, num_children, client).unwrap(), + "namespace {path} should be introspectable" + ); + assert!( + can_write_namespace(path, client).unwrap(), + "namespace {path} should be writable" + ); } } } None => { // Namespace has no permission attributed to it, no access should be allowed. - assert!(!can_discover_namespace(path, client), "namespace {path} should not be discoverable"); assert!( - !can_introspect_namespace(path, num_children, client), + !can_discover_namespace(path, client).unwrap(), + "namespace {path} should not be discoverable" + ); + assert!( + !can_introspect_namespace(path, num_children, client).unwrap(), "namespace {path} should not be introspectable" ); + assert!( + !can_write_namespace(path, client).unwrap(), + "namespace {path} should not be writable" + ); } } } /// Verify that the namespace at `path` appears in its parent listing. -pub fn can_discover_namespace(path: &str, client: &RaphtoryGraphQLClient) -> bool { +pub fn can_discover_namespace( + path: &str, + client: &RaphtoryGraphQLClient, +) -> Result { let parent = path.rsplit_once('/').map_or("root", |(parent, _)| parent); let query = if parent == "root" { r#"query { root { children { list { path } } } }"#.to_string() } else { - format!(r#"query {{ namespace(path: "{parent}") {{ children {{ list {{ path }} }} }} }}"#) + format!( + r#"query {{ namespace(path: "{parent}") {{ children {{ list {{ path }} }} }} }}"# + ) }; - let data = gql(&query, client) - .unwrap_or_else(|e| panic!("Error executing query: {query}: {e}")); + let data = match gql(&query, client) { + Ok(data) => data, + Err(e) if access_denied(&e) => return Ok(false), + Err(e) => return Err(e), + }; let children = if parent == "root" { data.get("root") @@ -142,7 +225,7 @@ pub fn can_discover_namespace(path: &str, client: &RaphtoryGraphQLClient) -> boo }; // Verify that the parent's children list is non-empty and contains this namespace. - children + Ok(children .and_then(|namespace| namespace.get("children")) .and_then(|children| children.get("list")) .and_then(JsonValue::as_array) @@ -154,7 +237,7 @@ pub fn can_discover_namespace(path: &str, client: &RaphtoryGraphQLClient) -> boo .and_then(JsonValue::as_str) .is_some_and(|entry_path| entry_path == path) }) - }) + })) } // Verify that the namespace at `path` can have its listings browsed and that @@ -163,19 +246,19 @@ pub fn can_introspect_namespace( path: &str, num_children: usize, client: &RaphtoryGraphQLClient, -) -> bool { +) -> Result { let query = format!( r#"query {{ namespace(path: "{path}") {{ graphs {{ list {{ path }} }} children {{ list {{ path }} }} }} }}"# ); let data = match gql(&query, client) { Ok(data) => data, - Err(e) if e.to_string().contains("Access denied") => return false, - Err(e) => panic!("Error executing query: {query}: {e}"), + Err(e) if access_denied(&e) => return Ok(false), + Err(e) => return Err(e), }; let Some(namespace) = data.get("namespace").filter(|v| !v.is_null()) else { - return false; + return Ok(false); }; let num_graphs = namespace @@ -193,15 +276,30 @@ pub fn can_introspect_namespace( .unwrap_or(0); // INSTROSPECT implies all listings of `path` are visible. - num_graphs + num_namespaces == num_children + Ok(num_graphs + num_namespaces == num_children) } /// Can create and delete graphs at `path`. -pub fn can_write_namespace(path: &str, client: &RaphtoryGraphQLClient) -> bool { - if create_graph(path, client).is_err() { - // Do not attempt delete if create fails. - return false; +pub fn can_write_namespace( + path: &str, + client: &RaphtoryGraphQLClient, +) -> Result { + let graph_path = format!("{path}/test_new_graph"); + + match create_graph(&graph_path, client) { + Ok(()) => {} + Err(e) if access_denied(&e) => return Ok(false), + Err(e) => return Err(e), + } + + match delete_graph(&graph_path, client) { + Ok(()) => Ok(true), + Err(e) if access_denied(&e) => Ok(false), + Err(e) => Err(e), } +} - delete_graph(path, client).is_ok() +#[inline] +fn access_denied(err: &ClientError) -> bool { + err.to_string().contains("Access denied") } From 7202a598b1c3f727cdc31c6ac559b7111b6c7639 Mon Sep 17 00:00:00 2001 From: Fadhil Abubaker Date: Mon, 4 May 2026 18:00:24 -0400 Subject: [PATCH 29/31] Modify validate_namespace to use child names --- raphtory-graphql/tests/permissions.rs | 9 +++- raphtory-graphql/tests/utils/validate.rs | 52 ++++++++++++++++-------- 2 files changed, 42 insertions(+), 19 deletions(-) diff --git a/raphtory-graphql/tests/permissions.rs b/raphtory-graphql/tests/permissions.rs index 774880431e..4105f59c35 100644 --- a/raphtory-graphql/tests/permissions.rs +++ b/raphtory-graphql/tests/permissions.rs @@ -122,8 +122,13 @@ fn validate_grant( if is_graph_path { validate_graph_grant(path, permission, client); } else { - let num_children = node.out_neighbours().len(); - validate_namespace_grant(path, permission, client, num_children); + let children: Vec = node + .out_neighbours() + .into_iter() + .map(|n| n.name().to_string()) + .collect(); + + validate_namespace_grant(path, permission, client, &children); } } diff --git a/raphtory-graphql/tests/utils/validate.rs b/raphtory-graphql/tests/utils/validate.rs index f31d072ead..c26df6a341 100644 --- a/raphtory-graphql/tests/utils/validate.rs +++ b/raphtory-graphql/tests/utils/validate.rs @@ -1,3 +1,5 @@ +use std::collections::HashSet; + use raphtory_graphql::client::raphtory_client::RaphtoryGraphQLClient; use raphtory_graphql::client::ClientError; use serde_json::Value as JsonValue; @@ -136,7 +138,7 @@ pub fn validate_namespace_grant( path: &str, permission: Option, client: &RaphtoryGraphQLClient, - num_children: usize, + children: &[String], ) { match permission { Some(permission_enum) => { @@ -149,7 +151,7 @@ pub fn validate_namespace_grant( } Permission::Introspect => { assert!( - can_introspect_namespace(path, num_children, client).unwrap(), + can_introspect_namespace(path, children, client).unwrap(), "namespace {path} should be introspectable" ); assert!( @@ -159,7 +161,7 @@ pub fn validate_namespace_grant( } Permission::Read => { assert!( - can_introspect_namespace(path, num_children, client).unwrap(), + can_introspect_namespace(path, children, client).unwrap(), "namespace {path} should be introspectable" ); assert!( @@ -169,7 +171,7 @@ pub fn validate_namespace_grant( } Permission::Write => { assert!( - can_introspect_namespace(path, num_children, client).unwrap(), + can_introspect_namespace(path, children, client).unwrap(), "namespace {path} should be introspectable" ); assert!( @@ -186,7 +188,7 @@ pub fn validate_namespace_grant( "namespace {path} should not be discoverable" ); assert!( - !can_introspect_namespace(path, num_children, client).unwrap(), + !can_introspect_namespace(path, children, client).unwrap(), "namespace {path} should not be introspectable" ); assert!( @@ -241,10 +243,10 @@ pub fn can_discover_namespace( } // Verify that the namespace at `path` can have its listings browsed and that -// immediate graphs plus child namespaces match the expected `num_children`. +// immediate graphs and child namespaces match `children`. pub fn can_introspect_namespace( path: &str, - num_children: usize, + children: &[String], client: &RaphtoryGraphQLClient, ) -> Result { let query = format!( @@ -261,22 +263,35 @@ pub fn can_introspect_namespace( return Ok(false); }; - let num_graphs = namespace + let mut listed = HashSet::new(); + + if let Some(entries) = namespace .get("graphs") .and_then(|graphs| graphs.get("list")) .and_then(JsonValue::as_array) - .map(|entries| entries.len()) - .unwrap_or(0); + { + for entry in entries { + if let Some(p) = entry.get("path").and_then(JsonValue::as_str) { + listed.insert(path_last_segment(p).to_string()); + } + } + } - let num_namespaces = namespace + if let Some(entries) = namespace .get("children") - .and_then(|children| children.get("list")) + .and_then(|ch| ch.get("list")) .and_then(JsonValue::as_array) - .map(|entries| entries.len()) - .unwrap_or(0); + { + for entry in entries { + if let Some(p) = entry.get("path").and_then(JsonValue::as_str) { + listed.insert(path_last_segment(p).to_string()); + } + } + } - // INSTROSPECT implies all listings of `path` are visible. - Ok(num_graphs + num_namespaces == num_children) + let expected: HashSet = children.iter().cloned().collect(); + + Ok(listed == expected) } /// Can create and delete graphs at `path`. @@ -299,7 +314,10 @@ pub fn can_write_namespace( } } -#[inline] fn access_denied(err: &ClientError) -> bool { err.to_string().contains("Access denied") } + +fn path_last_segment(path: &str) -> &str { + path.rsplit('/').next().unwrap_or(path) +} From a7bb414569714fe90c813ed1ccdc678d8efff6d0 Mon Sep 17 00:00:00 2001 From: Fadhil Abubaker Date: Mon, 4 May 2026 21:47:54 -0400 Subject: [PATCH 30/31] Fix root node generation --- raphtory-graphql/tests/utils/tree.rs | 20 ++++++++++++++------ 1 file changed, 14 insertions(+), 6 deletions(-) diff --git a/raphtory-graphql/tests/utils/tree.rs b/raphtory-graphql/tests/utils/tree.rs index 08906d53cf..c8f906f86f 100644 --- a/raphtory-graphql/tests/utils/tree.rs +++ b/raphtory-graphql/tests/utils/tree.rs @@ -34,17 +34,21 @@ pub fn build_namespace_tree(parents: &[usize]) -> Graph { } /// Build paths for all leaf nodes in the tree. -/// Example: a -> b -> c returns ["a/b/c"] +/// "node_0" is treated as the root namespace. +/// Example: node_0 -> node_1 -> node_2 returns ["node_1/node_2"] pub fn leaf_paths(tree: &Graph) -> Vec { let mut stack = Vec::new(); let root = tree.node("node_0").unwrap(); let mut leaves = Vec::new(); - stack.push((root, String::new())); + // Leave out node_0 since it is the root namespace. + for neighbour in root.out_neighbours() { + stack.push((neighbour, String::new())); + } while let Some((node, parent_path)) = stack.pop() { - // Prevent leading slash in paths. let node_path = if parent_path.is_empty() { + // Prevent leading slash in paths. node.name() } else { [parent_path, node.name()].join("/") @@ -65,17 +69,21 @@ pub fn leaf_paths(tree: &Graph) -> Vec { } /// Build paths for all branch nodes in the tree. -/// Example: a -> b -> c returns ["a", "a/b"] +/// "node_0" is treated as the root namespace. +/// Example: node_0 -> node_1 -> node_2 returns ["node_1/"] pub fn branch_paths(tree: &Graph) -> Vec { let mut stack = Vec::new(); let root = tree.node("node_0").unwrap(); let mut branches = Vec::new(); - stack.push((root, String::new())); + // Leave out node_0 since it is the root namespace. + for neighbour in root.out_neighbours() { + stack.push((neighbour, String::new())); + } while let Some((node, parent_path)) = stack.pop() { - // Prevent leading slash in paths. let node_path = if parent_path.is_empty() { + // Prevent leading slash in paths. node.name() } else { [parent_path, node.name()].join("/") From d0eac20af90cfd31e26863b423ad48b93cba4dc6 Mon Sep 17 00:00:00 2001 From: Fadhil Abubaker Date: Tue, 5 May 2026 10:13:12 -0400 Subject: [PATCH 31/31] Run fmt --- raphtory-graphql/tests/permissions.rs | 57 ++++--- raphtory-graphql/tests/utils/graphql.rs | 21 +-- raphtory-graphql/tests/utils/mod.rs | 2 +- raphtory-graphql/tests/utils/strategy.rs | 79 +++++----- raphtory-graphql/tests/utils/tree.rs | 4 +- raphtory-graphql/tests/utils/validate.rs | 184 +++++++++++------------ 6 files changed, 165 insertions(+), 182 deletions(-) diff --git a/raphtory-graphql/tests/permissions.rs b/raphtory-graphql/tests/permissions.rs index 4105f59c35..c4fb983fc3 100644 --- a/raphtory-graphql/tests/permissions.rs +++ b/raphtory-graphql/tests/permissions.rs @@ -1,23 +1,22 @@ mod utils; -use std::ops::RangeInclusive; -use std::str::FromStr; +use std::{ops::RangeInclusive, str::FromStr}; use proptest::prelude::*; -use raphtory::db::api::view::MaterializedGraph; -use raphtory::db::graph::node::NodeView; +use raphtory::{ + db::{api::view::MaterializedGraph, graph::node::NodeView}, + prelude::{GraphViewOps, NodeViewOps, Prop, PropUnwrap, PropertiesOps}, +}; use raphtory_graphql::client::raphtory_client::RaphtoryGraphQLClient; -use raphtory::prelude::{GraphViewOps, NodeViewOps, Prop, PropUnwrap, PropertiesOps}; use url::Url; -use utils::jwt::{user_jwt, ADMIN_JWT, PUB_KEY}; -use utils::strategy::{permissions_strategy, GrantType, Permission, PermissionGrant}; - -use utils::graphql::{ - create_graph, create_role, create_grant, get_client, - start_server, +use utils::{ + jwt::{user_jwt, ADMIN_JWT, PUB_KEY}, + strategy::{permissions_strategy, GrantType, Permission, PermissionGrant}, }; +use utils::graphql::{create_grant, create_graph, create_role, get_client, start_server}; + use crate::utils::validate::{validate_graph_grant, validate_namespace_grant}; const PORT: u16 = 43871; @@ -35,12 +34,11 @@ fn track_grant(grant: &PermissionGrant, user_trees: &[MaterializedGraph]) { let node = user_tree.node(node_name).unwrap(); // Update the node's direct permission. - node - .update_metadata(vec![ - ("permission", Prop::Str(permission.to_string().into())), - ("direct", Prop::Bool(true)), - ]) - .unwrap(); + node.update_metadata(vec![ + ("permission", Prop::Str(permission.to_string().into())), + ("direct", Prop::Bool(true)), + ]) + .unwrap(); // Propagate discover to ancestor namespaces. propagate_up(path, user_tree); @@ -59,12 +57,14 @@ fn propagate_up(path: &str, user_tree: &MaterializedGraph) { // Discover permissions have least precedence, set them if no other permission is set. if node.metadata().get("permission").is_none() { - node - .update_metadata(vec![ - ("permission", Prop::Str(Permission::Discover.to_string().into())), - ("direct", Prop::Bool(false)), - ]) - .unwrap(); + node.update_metadata(vec![ + ( + "permission", + Prop::Str(Permission::Discover.to_string().into()), + ), + ("direct", Prop::Bool(false)), + ]) + .unwrap(); } } } @@ -93,12 +93,11 @@ fn propagate_down(path: &str, user_tree: &MaterializedGraph, permission: Permiss } } - node - .update_metadata(vec![ - ("permission", Prop::Str(permission.to_string().into())), - ("direct", Prop::Bool(false)), - ]) - .unwrap(); + node.update_metadata(vec![ + ("permission", Prop::Str(permission.to_string().into())), + ("direct", Prop::Bool(false)), + ]) + .unwrap(); for neighbour in node.out_neighbours() { stack.push(neighbour); diff --git a/raphtory-graphql/tests/utils/graphql.rs b/raphtory-graphql/tests/utils/graphql.rs index 3c873551c9..65c942d8d9 100644 --- a/raphtory-graphql/tests/utils/graphql.rs +++ b/raphtory-graphql/tests/utils/graphql.rs @@ -1,11 +1,15 @@ -use std::collections::HashMap; -use std::sync::{LazyLock, Once}; -use std::time::Duration; +use std::{ + collections::HashMap, + sync::{LazyLock, Once}, + time::Duration, +}; use raphtory::db::api::storage::storage::Config; -use raphtory_graphql::client::{raphtory_client::RaphtoryGraphQLClient, ClientError}; -use raphtory_graphql::config::app_config::AppConfigBuilder; -use raphtory_graphql::server::{apply_server_extension, GraphServer, RunningGraphServer}; +use raphtory_graphql::{ + client::{raphtory_client::RaphtoryGraphQLClient, ClientError}, + config::app_config::AppConfigBuilder, + server::{apply_server_extension, GraphServer, RunningGraphServer}, +}; use serde_json::Value as JsonValue; use tempfile::TempDir; use tokio::runtime::Runtime; @@ -22,10 +26,7 @@ fn init_raphtory_auth() { pub static RUNTIME: LazyLock = LazyLock::new(|| Runtime::new().expect("Failed to create Tokio runtime")); -pub fn get_client( - url: Url, - token: String, -) -> Result { +pub fn get_client(url: Url, token: String) -> Result { RUNTIME.block_on(RaphtoryGraphQLClient::connect(url, Some(token))) } diff --git a/raphtory-graphql/tests/utils/mod.rs b/raphtory-graphql/tests/utils/mod.rs index 9e32a11695..45da12614d 100644 --- a/raphtory-graphql/tests/utils/mod.rs +++ b/raphtory-graphql/tests/utils/mod.rs @@ -1,5 +1,5 @@ pub mod graphql; -pub mod validate; pub mod jwt; pub mod strategy; pub mod tree; +pub mod validate; diff --git a/raphtory-graphql/tests/utils/strategy.rs b/raphtory-graphql/tests/utils/strategy.rs index b61792f9ef..dc89820721 100644 --- a/raphtory-graphql/tests/utils/strategy.rs +++ b/raphtory-graphql/tests/utils/strategy.rs @@ -1,9 +1,7 @@ -use std::{fmt, ops::RangeInclusive}; -use std::str::FromStr; -use proptest::prelude::*; -use proptest::strategy::BoxedStrategy; -use raphtory::prelude::*; use crate::utils::tree::{branch_paths, build_namespace_tree, leaf_paths}; +use proptest::{prelude::*, strategy::BoxedStrategy}; +use raphtory::prelude::*; +use std::{fmt, ops::RangeInclusive, str::FromStr}; #[derive(Clone)] pub struct PermissionsCase { @@ -93,14 +91,12 @@ pub fn permissions_strategy( namespace_paths.clone(), num_users, ) - .prop_map(move |grants| { - PermissionsCase { - num_users, - namespace_tree: namespace_tree.clone(), - graph_paths: graph_paths.clone(), - namespace_paths: namespace_paths.clone(), - grants, - } + .prop_map(move |grants| PermissionsCase { + num_users, + namespace_tree: namespace_tree.clone(), + graph_paths: graph_paths.clone(), + namespace_paths: namespace_paths.clone(), + grants, }) }) }) @@ -143,34 +139,30 @@ fn grants_strategy( let total_paths = namespace_paths.len() + graph_paths.len(); prop::collection::vec( - (0..=max_user, 0..total_paths) - .prop_flat_map(move |(user_id, path_idx)| { - // Choose either a namespace or a graph path based on path_idx. - if path_idx < namespace_paths.len() { - let path = namespace_paths[path_idx].clone(); - - // Exclude discover since it cannot be applied directly to namespaces. - prop_oneof![ - Just(Permission::Introspect), - Just(Permission::Read), - Just(Permission::Write), - ] - .prop_map(move |permission| PermissionGrant { - grant_type: GrantType::Namespace, - user_id, - path: path.clone(), - permission, - }) - .boxed() - } else { - let graph_idx = path_idx - namespace_paths.len(); - let path = graph_paths[graph_idx].clone(); - - // Exclude introspect since it cannot be applied directly to graphs. - prop_oneof![ - Just(Permission::Read), - Just(Permission::Write), - ] + (0..=max_user, 0..total_paths).prop_flat_map(move |(user_id, path_idx)| { + // Choose either a namespace or a graph path based on path_idx. + if path_idx < namespace_paths.len() { + let path = namespace_paths[path_idx].clone(); + + // Exclude discover since it cannot be applied directly to namespaces. + prop_oneof![ + Just(Permission::Introspect), + Just(Permission::Read), + Just(Permission::Write), + ] + .prop_map(move |permission| PermissionGrant { + grant_type: GrantType::Namespace, + user_id, + path: path.clone(), + permission, + }) + .boxed() + } else { + let graph_idx = path_idx - namespace_paths.len(); + let path = graph_paths[graph_idx].clone(); + + // Exclude introspect since it cannot be applied directly to graphs. + prop_oneof![Just(Permission::Read), Just(Permission::Write),] .prop_map(move |permission| PermissionGrant { grant_type: GrantType::Graph, user_id, @@ -178,9 +170,8 @@ fn grants_strategy( permission, }) .boxed() - } - }), + } + }), num_grants, ) } - diff --git a/raphtory-graphql/tests/utils/tree.rs b/raphtory-graphql/tests/utils/tree.rs index c8f906f86f..01c98559bf 100644 --- a/raphtory-graphql/tests/utils/tree.rs +++ b/raphtory-graphql/tests/utils/tree.rs @@ -12,9 +12,7 @@ pub fn build_namespace_tree(parents: &[usize]) -> Graph { for node in 0..parents.len() { let name = format!("node_{node}"); - graph - .add_node(0, name, NO_PROPS, None, None) - .unwrap(); + graph.add_node(0, name, NO_PROPS, None, None).unwrap(); // Root node has no parent. if node == 0 { diff --git a/raphtory-graphql/tests/utils/validate.rs b/raphtory-graphql/tests/utils/validate.rs index c26df6a341..f47346d801 100644 --- a/raphtory-graphql/tests/utils/validate.rs +++ b/raphtory-graphql/tests/utils/validate.rs @@ -1,11 +1,12 @@ use std::collections::HashSet; -use raphtory_graphql::client::raphtory_client::RaphtoryGraphQLClient; -use raphtory_graphql::client::ClientError; +use raphtory_graphql::client::{raphtory_client::RaphtoryGraphQLClient, ClientError}; use serde_json::Value as JsonValue; -use crate::utils::graphql::{create_graph, delete_graph, gql}; -use crate::utils::strategy::Permission; +use crate::utils::{ + graphql::{create_graph, delete_graph, gql}, + strategy::Permission, +}; // --- Graph validators --- @@ -15,55 +16,53 @@ pub fn validate_graph_grant( client: &RaphtoryGraphQLClient, ) { match permission { - Some(permission_enum) => { - match permission_enum { - Permission::Discover => { - panic!("Discover permission is not supported for graphs"); - } - Permission::Introspect => { - assert!( - can_introspect_graph(path, client).unwrap(), - "graph {path} should be introspectable" - ); - assert!( - !can_read_graph(path, client).unwrap(), - "graph {path} should not be readable" - ); - assert!( - !can_write_graph(path, client).unwrap(), - "graph {path} should not be writable" - ); - } - Permission::Read => { - assert!( - can_introspect_graph(path, client).unwrap(), - "graph {path} should be introspectable" - ); - assert!( - can_read_graph(path, client).unwrap(), - "graph {path} should be readable" - ); - assert!( - !can_write_graph(path, client).unwrap(), - "graph {path} should not be writable" - ); - } - Permission::Write => { - assert!( - can_introspect_graph(path, client).unwrap(), - "graph {path} should be introspectable" - ); - assert!( - can_read_graph(path, client).unwrap(), - "graph {path} should be readable" - ); - assert!( - can_write_graph(path, client).unwrap(), - "graph {path} should be writable" - ); - } + Some(permission_enum) => match permission_enum { + Permission::Discover => { + panic!("Discover permission is not supported for graphs"); } - } + Permission::Introspect => { + assert!( + can_introspect_graph(path, client).unwrap(), + "graph {path} should be introspectable" + ); + assert!( + !can_read_graph(path, client).unwrap(), + "graph {path} should not be readable" + ); + assert!( + !can_write_graph(path, client).unwrap(), + "graph {path} should not be writable" + ); + } + Permission::Read => { + assert!( + can_introspect_graph(path, client).unwrap(), + "graph {path} should be introspectable" + ); + assert!( + can_read_graph(path, client).unwrap(), + "graph {path} should be readable" + ); + assert!( + !can_write_graph(path, client).unwrap(), + "graph {path} should not be writable" + ); + } + Permission::Write => { + assert!( + can_introspect_graph(path, client).unwrap(), + "graph {path} should be introspectable" + ); + assert!( + can_read_graph(path, client).unwrap(), + "graph {path} should be readable" + ); + assert!( + can_write_graph(path, client).unwrap(), + "graph {path} should be writable" + ); + } + }, None => { // Graph has no permission attributed to it, no access should be allowed. assert!( @@ -118,7 +117,6 @@ pub fn can_write_graph(path: &str, client: &RaphtoryGraphQLClient) -> Result data, Err(e) if access_denied(&e) => return Ok(false), Err(e) => return Err(e), @@ -141,46 +139,44 @@ pub fn validate_namespace_grant( children: &[String], ) { match permission { - Some(permission_enum) => { - match permission_enum { - Permission::Discover => { - assert!( - can_discover_namespace(path, client).unwrap(), - "namespace {path} should be discoverable" - ); - } - Permission::Introspect => { - assert!( - can_introspect_namespace(path, children, client).unwrap(), - "namespace {path} should be introspectable" - ); - assert!( - !can_write_namespace(path, client).unwrap(), - "namespace {path} should not be writable" - ); - } - Permission::Read => { - assert!( - can_introspect_namespace(path, children, client).unwrap(), - "namespace {path} should be introspectable" - ); - assert!( - !can_write_namespace(path, client).unwrap(), - "namespace {path} should not be writable" - ); - } - Permission::Write => { - assert!( - can_introspect_namespace(path, children, client).unwrap(), - "namespace {path} should be introspectable" - ); - assert!( - can_write_namespace(path, client).unwrap(), - "namespace {path} should be writable" - ); - } + Some(permission_enum) => match permission_enum { + Permission::Discover => { + assert!( + can_discover_namespace(path, client).unwrap(), + "namespace {path} should be discoverable" + ); } - } + Permission::Introspect => { + assert!( + can_introspect_namespace(path, children, client).unwrap(), + "namespace {path} should be introspectable" + ); + assert!( + !can_write_namespace(path, client).unwrap(), + "namespace {path} should not be writable" + ); + } + Permission::Read => { + assert!( + can_introspect_namespace(path, children, client).unwrap(), + "namespace {path} should be introspectable" + ); + assert!( + !can_write_namespace(path, client).unwrap(), + "namespace {path} should not be writable" + ); + } + Permission::Write => { + assert!( + can_introspect_namespace(path, children, client).unwrap(), + "namespace {path} should be introspectable" + ); + assert!( + can_write_namespace(path, client).unwrap(), + "namespace {path} should be writable" + ); + } + }, None => { // Namespace has no permission attributed to it, no access should be allowed. assert!( @@ -209,9 +205,7 @@ pub fn can_discover_namespace( let query = if parent == "root" { r#"query { root { children { list { path } } } }"#.to_string() } else { - format!( - r#"query {{ namespace(path: "{parent}") {{ children {{ list {{ path }} }} }} }}"# - ) + format!(r#"query {{ namespace(path: "{parent}") {{ children {{ list {{ path }} }} }} }}"#) }; let data = match gql(&query, client) {