diff --git a/Cargo.lock b/Cargo.lock index fd594cf166..a331328a72 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -6595,9 +6595,12 @@ dependencies = [ "parking_lot", "poem", "pretty_assertions", + "proptest", "pyo3", + "rand 0.9.4", "raphtory", "raphtory-api", + "raphtory-auth-noop", "raphtory-storage", "rayon", "reqwest", diff --git a/python/tests/test_permissions.py b/python/tests/test_permissions.py index 43488acc9d..85d76efd2e 100644 --- a/python/tests/test_permissions.py +++ b/python/tests/test_permissions.py @@ -1288,6 +1288,53 @@ def test_child_namespace_restriction_overrides_parent(): assert "team/restricted/secret" in paths +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) + 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_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() diff --git a/raphtory-graphql/Cargo.toml b/raphtory-graphql/Cargo.toml index 081ea82f76..d880cbd11a 100644 --- a/raphtory-graphql/Cargo.toml +++ b/raphtory-graphql/Cargo.toml @@ -64,13 +64,19 @@ 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 } +auth = { workspace = true } +rand = { workspace = true } +proptest = { workspace = true } +jsonwebtoken = { workspace = true } +serde = { workspace = true } +serde_json = { workspace = true } + [features] python = ["dep:pyo3", "raphtory/python"] diff --git a/raphtory-graphql/src/client/raphtory_client.rs b/raphtory-graphql/src/client/raphtory_client.rs index c8af4c36ae..b919652bb6 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..9e13ef4624 100644 --- a/raphtory-graphql/src/paths.rs +++ b/raphtory-graphql/src/paths.rs @@ -220,12 +220,14 @@ pub(crate) fn create_valid_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() { @@ -237,6 +239,7 @@ pub(crate) fn create_valid_path( path: full_path.clone(), dirty_marker: mark_dirty(&full_path)?, }); + fs::create_dir(&full_path)?; } } @@ -329,11 +332,13 @@ impl ValidWriteableGraphFolder { error, } })?; + if !path.cleanup.is_some() { return Err(PathValidationError::GraphExistsError( relative_path.to_string(), )); } + Self::new(path, relative_path) } @@ -567,15 +572,21 @@ pub(crate) fn mark_dirty(path: &Path) -> Result, + 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 { + let children: Vec = node + .out_neighbours() + .into_iter() + .map(|n| n.name().to_string()) + .collect(); + + validate_namespace_grant(path, permission, client, &children); + } +} + +#[test] +fn permissions_proptest() { + 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), + |(case in permissions_strategy(NAMESPACE_SIZE, NUM_GRANTS, NUM_USERS))| { + 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()).unwrap(); + + let tree = case.namespace_tree; + + // Create graphs on the server. + for path in &case.graph_paths { + create_graph(path, &admin_client).unwrap(); + } + + 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(&role, &admin_client).unwrap(); + + 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(grant, &admin_client).unwrap(); + track_grant(grant, &user_trees); + } + + // Validate grants across graph paths for each user. + for path in &case.graph_paths { + 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(); + + let role = format!("user_{user_id}"); + let user_client = get_client(url.clone(), user_jwt(&role)).unwrap(); + validate_grant(path.as_str(), &node, &user_client); + } + } + + // Validate grants across namespace paths for each user. + for path in &case.namespace_paths { + 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(); + + // Ignore namespaces with no children since permissions are irrelevant. + if node.out_neighbours().is_empty() { + continue; + } + + let role = format!("user_{user_id}"); + 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 new file mode 100644 index 0000000000..65c942d8d9 --- /dev/null +++ b/raphtory-graphql/tests/utils/graphql.rs @@ -0,0 +1,171 @@ +use std::{ + collections::HashMap, + sync::{LazyLock, Once}, + time::Duration, +}; + +use raphtory::db::api::storage::storage::Config; +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; +use url::Url; + +use crate::utils::strategy::{GrantType, Permission, PermissionGrant}; + +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) -> Result { + RUNTIME.block_on(RaphtoryGraphQLClient::connect(url, Some(token))) +} + +pub fn start_server( + port: u16, + pub_key: &str, + cache_capacity: u64, +) -> (RunningGraphServer, TempDir) { + init_raphtory_auth(); + + 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(); + 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(); + + 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) + }) +} + +/// Create a graph on the graphql server using the given path. +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) -> Result<(), ClientError> { + let query = + format!(r#"mutation {{ permissions {{ createRole(name: "{name}") {{ success }} }} }}"#); + + let data = gql(&query, client)?; + + let success = data + .get("permissions") + .and_then(|p| p.get("createRole")) + .and_then(|c| c.get("success")) + .and_then(JsonValue::as_bool); + + if success != Some(true) { + return Err(ClientError::InvalidResponse(format!( + "createRole {name} data: {data:?}" + ))); + } + Ok(()) +} + +pub fn create_grant( + grant: &PermissionGrant, + client: &RaphtoryGraphQLClient, +) -> Result<(), ClientError> { + let role = format!("user_{}", grant.user_id); + + match grant.grant_type { + GrantType::Graph => grant_graph(&grant.path, &role, grant.permission, client), + GrantType::Namespace => grant_namespace(&grant.path, &role, grant.permission, client), + } +} + +pub fn grant_graph( + path: &str, + 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)?; + + let success = data + .get("permissions") + .and_then(|p| p.get("grantGraph")) + .and_then(|c| c.get("success")) + .and_then(JsonValue::as_bool); + + if success != Some(true) { + return Err(ClientError::InvalidResponse(format!( + "grantGraph role={role} path={path} permission={permission} data: {data:?}" + ))); + } + Ok(()) +} + +pub fn grant_namespace( + path: &str, + 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)?; + + let success = data + .get("permissions") + .and_then(|p| p.get("grantNamespace")) + .and_then(|c| c.get("success")) + .and_then(JsonValue::as_bool); + + if success != Some(true) { + return Err(ClientError::InvalidResponse(format!( + "grantNamespace role={role} path={path} permission={permission} data: {data:?}" + ))); + } + Ok(()) +} + +pub(crate) 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 new file mode 100644 index 0000000000..1d5341a08f --- /dev/null +++ b/raphtory-graphql/tests/utils/jwt.rs @@ -0,0 +1,34 @@ +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") +}); + +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 new file mode 100644 index 0000000000..45da12614d --- /dev/null +++ b/raphtory-graphql/tests/utils/mod.rs @@ -0,0 +1,5 @@ +pub mod graphql; +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 new file mode 100644 index 0000000000..dc89820721 --- /dev/null +++ b/raphtory-graphql/tests/utils/strategy.rs @@ -0,0 +1,177 @@ +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 { + pub num_users: usize, + pub grants: Vec, + pub graph_paths: Vec, + pub namespace_paths: Vec, + pub namespace_tree: Graph, +} + +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) + .field("namespace_paths", &self.namespace_paths) + .finish() + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum GrantType { + Graph, + Namespace, +} + +#[derive(Debug, Clone)] +pub struct PermissionGrant { + pub grant_type: GrantType, + pub user_id: usize, + pub path: String, + pub permission: Permission, +} + +#[derive(Debug, Clone, Copy)] +pub enum Permission { + Discover, + Introspect, + Read, + Write, +} + +impl fmt::Display for Permission { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + 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"), + } + } +} + +pub fn permissions_strategy( + namespace_size: RangeInclusive, + num_grants: RangeInclusive, + num_users: RangeInclusive, +) -> BoxedStrategy { + (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_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() +} + +/// 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_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(); + + 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),] + .prop_map(move |permission| PermissionGrant { + grant_type: GrantType::Graph, + user_id, + path: path.clone(), + permission, + }) + .boxed() + } + }), + num_grants, + ) +} diff --git a/raphtory-graphql/tests/utils/tree.rs b/raphtory-graphql/tests/utils/tree.rs new file mode 100644 index 0000000000..01c98559bf --- /dev/null +++ b/raphtory-graphql/tests/utils/tree.rs @@ -0,0 +1,102 @@ +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. +/// "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(); + + // 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() { + let node_path = if parent_path.is_empty() { + // Prevent leading slash in paths. + 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. +/// "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(); + + // 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() { + let node_path = if parent_path.is_empty() { + // Prevent leading slash in paths. + 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/validate.rs b/raphtory-graphql/tests/utils/validate.rs new file mode 100644 index 0000000000..f47346d801 --- /dev/null +++ b/raphtory-graphql/tests/utils/validate.rs @@ -0,0 +1,317 @@ +use std::collections::HashSet; + +use raphtory_graphql::client::{raphtory_client::RaphtoryGraphQLClient, ClientError}; +use serde_json::Value as JsonValue; + +use crate::utils::{ + graphql::{create_graph, delete_graph, gql}, + strategy::Permission, +}; + +// --- Graph validators --- + +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).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!( + !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, +) -> Result { + let query = format!(r#"query {{ graphMetadata(path: "{path}") {{ path nodeCount }} }}"#); + let data = match gql(&query, client) { + Ok(data) => data, + Err(e) if access_denied(&e) => return Ok(false), + Err(e) => return Err(e), + }; + + Ok(data + .get("graphMetadata") + .is_some_and(|metadata| !metadata.is_null())) +} + +pub fn can_read_graph(path: &str, client: &RaphtoryGraphQLClient) -> Result { + let query = format!(r#"query {{ graph(path: "{path}") {{ path }} }}"#); + let data = match gql(&query, client) { + Ok(data) => data, + Err(e) if access_denied(&e) => return Ok(false), + Err(e) => return Err(e), + }; + + Ok(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) -> Result { + let query = format!( + r#"query {{ updateGraph(path: "{path}") {{ addNode(time: 1, name: "test_node") {{ success }} }} }}"# + ); + let data = match gql(&query, client) { + Ok(data) => data, + Err(e) if access_denied(&e) => return Ok(false), + Err(e) => return Err(e), + }; + + 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)) +} + +// --- Namespace validators --- + +pub fn validate_namespace_grant( + path: &str, + permission: Option, + client: &RaphtoryGraphQLClient, + 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" + ); + } + }, + None => { + // Namespace has no permission attributed to it, no access should be allowed. + assert!( + !can_discover_namespace(path, client).unwrap(), + "namespace {path} should not be discoverable" + ); + assert!( + !can_introspect_namespace(path, 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, +) -> 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 }} }} }} }}"#) + }; + + 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") + } else { + data.get("namespace") + }; + + // Verify that the parent's children list is non-empty and contains this namespace. + Ok(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 and child namespaces match `children`. +pub fn can_introspect_namespace( + path: &str, + children: &[String], + client: &RaphtoryGraphQLClient, +) -> 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 access_denied(&e) => return Ok(false), + Err(e) => return Err(e), + }; + + let Some(namespace) = data.get("namespace").filter(|v| !v.is_null()) else { + return Ok(false); + }; + + let mut listed = HashSet::new(); + + if let Some(entries) = namespace + .get("graphs") + .and_then(|graphs| graphs.get("list")) + .and_then(JsonValue::as_array) + { + for entry in entries { + if let Some(p) = entry.get("path").and_then(JsonValue::as_str) { + listed.insert(path_last_segment(p).to_string()); + } + } + } + + if let Some(entries) = namespace + .get("children") + .and_then(|ch| ch.get("list")) + .and_then(JsonValue::as_array) + { + 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 expected: HashSet = children.iter().cloned().collect(); + + Ok(listed == expected) +} + +/// Can create and delete graphs at `path`. +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), + } +} + +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) +} diff --git a/raphtory/src/db/api/view/graph.rs b/raphtory/src/db/api/view/graph.rs index c85cf76d2d..6e6d9984a3 100644 --- a/raphtory/src/db/api/view/graph.rs +++ b/raphtory/src/db/api/view/graph.rs @@ -95,7 +95,6 @@ pub trait GraphViewOps<'graph>: BoxableGraphView + Sized + Clone + 'graph { /// If a path is provided, it will be used to store the new graph /// (assuming the storage feature is enabled). Sets a new config. #[cfg(feature = "io")] - #[cfg(feature = "io")] fn materialize_at_with_config( &self, path: &(impl GraphPaths + ?Sized), @@ -350,6 +349,7 @@ pub fn materialize_impl( let base_layer_meta = graph.edge_meta().layer_meta(); let layer_meta = edge_meta.layer_meta(); + // NOTE: layers must be set in layer_meta before the TemporalGraph is initialized to // make sure empty layers are created. match graph.layer_ids() { @@ -369,8 +369,10 @@ pub fn materialize_impl( } } } + node_meta.set_layer_mapper(layer_meta.deep_clone()); + // Create a new Extension instance for the new materialized graph. let ext = Extension::new(config, path)?; let temporal_graph = TemporalGraph::new_with_meta( path.map(|p| p.into()),