From 4fd03db136af2711e56676bce9f64a5ced3c793f Mon Sep 17 00:00:00 2001 From: shivamka1 <4599890+shivamka1@users.noreply.github.com> Date: Wed, 4 Mar 2026 16:17:56 +0000 Subject: [PATCH 01/64] init rbac --- python/python/raphtory/graphql/__init__.pyi | 1 + python/tests/test_permissions.py | 336 ++++++++++++++++++ .../src/core/entities/properties/prop/mod.rs | 2 +- raphtory-graphql/schema.graphql | 1 - raphtory-graphql/src/auth.rs | 37 +- raphtory-graphql/src/cli.rs | 4 + raphtory-graphql/src/config/app_config.rs | 13 + raphtory-graphql/src/data.rs | 14 + raphtory-graphql/src/lib.rs | 1 + raphtory-graphql/src/model/graph/graph.rs | 81 ++++- raphtory-graphql/src/model/mod.rs | 46 ++- raphtory-graphql/src/permissions.rs | 63 ++++ raphtory-graphql/src/python/server/server.rs | 7 +- raphtory-graphql/src/server.rs | 33 +- 14 files changed, 606 insertions(+), 33 deletions(-) create mode 100644 python/tests/test_permissions.py create mode 100644 raphtory-graphql/src/permissions.rs diff --git a/python/python/raphtory/graphql/__init__.pyi b/python/python/raphtory/graphql/__init__.pyi index 22047a51d6..e0d831f27b 100644 --- a/python/python/raphtory/graphql/__init__.pyi +++ b/python/python/raphtory/graphql/__init__.pyi @@ -80,6 +80,7 @@ class GraphServer(object): auth_enabled_for_reads: Any = None, config_path: Optional[str | PathLike] = None, create_index: Any = None, + permissions_store_path=None, ) -> GraphServer: """Create and return a new object. See help(type) for accurate signature.""" diff --git a/python/tests/test_permissions.py b/python/tests/test_permissions.py new file mode 100644 index 0000000000..969908edd3 --- /dev/null +++ b/python/tests/test_permissions.py @@ -0,0 +1,336 @@ +import json +import tempfile +import os +import time +import requests +import jwt +import pytest +from raphtory.graphql import GraphServer + +# Reuse the same key pair as test_auth.py +PUB_KEY = "MCowBQYDK2VwAyEADdrWr1kTLj+wSHlr45eneXmOjlHo3N1DjLIvDa2ozno=" +PRIVATE_KEY = """-----BEGIN PRIVATE KEY----- +MC4CAQAwBQYDK2VwBCIEIFzEcSO/duEjjX4qKxDVy4uLqfmiEIA6bEw1qiPyzTQg +-----END PRIVATE KEY-----""" + +RAPHTORY = "http://localhost:1736" + +# JWTs with roles +ANALYST_JWT = jwt.encode({"a": "ro", "role": "analyst"}, PRIVATE_KEY, algorithm="EdDSA") +ANALYST_HEADERS = {"Authorization": f"Bearer {ANALYST_JWT}"} + +ADMIN_JWT = jwt.encode({"a": "rw", "role": "admin"}, PRIVATE_KEY, algorithm="EdDSA") +ADMIN_HEADERS = {"Authorization": f"Bearer {ADMIN_JWT}"} + +# JWT with no role — valid token but no role field +NO_ROLE_JWT = jwt.encode({"a": "ro"}, PRIVATE_KEY, algorithm="EdDSA") +NO_ROLE_HEADERS = {"Authorization": f"Bearer {NO_ROLE_JWT}"} + +QUERY_JIRA = """query { graph(path: "jira") { path } }""" +QUERY_ADMIN = """query { graph(path: "admin") { path } }""" +CREATE_JIRA = """mutation { newGraph(path:"jira", graphType:EVENT) }""" +CREATE_ADMIN = """mutation { newGraph(path:"admin", graphType:EVENT) }""" + + +def make_permissions_store(path: str) -> str: + """Write a permissions store JSON file and return its path.""" + store = { + "roles": { + "analyst": {"graphs": [{"name": "jira"}]}, + "admin": {"graphs": [{"name": "*", "nodes": "ro", "edges": "ro"}]}, + } + } + store_path = os.path.join(path, "permissions.json") + with open(store_path, "w") as f: + json.dump(store, f) + return store_path + + +def make_permissions_store_with_node_access(path: str) -> str: + """Permissions store where analyst has node access to jira but not edges.""" + store = { + "roles": { + "analyst": {"graphs": [{"name": "jira", "nodes": "ro"}]}, + "admin": {"graphs": [{"name": "*", "nodes": "rw", "edges": "rw"}]}, + } + } + store_path = os.path.join(path, "permissions.json") + with open(store_path, "w") as f: + json.dump(store, f) + return store_path + + +def test_analyst_can_access_permitted_graph(): + work_dir = tempfile.mkdtemp() + store_path = make_permissions_store(work_dir) + + with GraphServer( + work_dir, + auth_public_key=PUB_KEY, + permissions_store_path=store_path, + ).start(): + # Create the graphs using admin role + requests.post( + RAPHTORY, headers=ADMIN_HEADERS, data=json.dumps({"query": CREATE_JIRA}) + ) + requests.post( + RAPHTORY, headers=ADMIN_HEADERS, data=json.dumps({"query": CREATE_ADMIN}) + ) + + response = requests.post( + RAPHTORY, headers=ANALYST_HEADERS, data=json.dumps({"query": QUERY_JIRA}) + ) + assert "errors" not in response.json(), response.json() + assert response.json()["data"]["graph"]["path"] == "jira" + + +def test_analyst_cannot_access_denied_graph(): + work_dir = tempfile.mkdtemp() + store_path = make_permissions_store(work_dir) + + with GraphServer( + work_dir, + auth_public_key=PUB_KEY, + permissions_store_path=store_path, + ).start(): + requests.post( + RAPHTORY, headers=ADMIN_HEADERS, data=json.dumps({"query": CREATE_ADMIN}) + ) + + response = requests.post( + RAPHTORY, headers=ANALYST_HEADERS, data=json.dumps({"query": QUERY_ADMIN}) + ) + assert response.json()["data"] is None + assert "Access denied" in response.json()["errors"][0]["message"] + + +def test_admin_can_access_all_graphs(): + work_dir = tempfile.mkdtemp() + store_path = make_permissions_store(work_dir) + + with GraphServer( + work_dir, + auth_public_key=PUB_KEY, + permissions_store_path=store_path, + ).start(): + requests.post( + RAPHTORY, headers=ADMIN_HEADERS, data=json.dumps({"query": CREATE_JIRA}) + ) + requests.post( + RAPHTORY, headers=ADMIN_HEADERS, data=json.dumps({"query": CREATE_ADMIN}) + ) + + for query in [QUERY_JIRA, QUERY_ADMIN]: + response = requests.post( + RAPHTORY, headers=ADMIN_HEADERS, data=json.dumps({"query": query}) + ) + assert "errors" not in response.json(), response.json() + + +def test_no_role_is_denied_when_store_is_active(): + work_dir = tempfile.mkdtemp() + store_path = make_permissions_store(work_dir) + + with GraphServer( + work_dir, + auth_public_key=PUB_KEY, + permissions_store_path=store_path, + ).start(): + requests.post( + RAPHTORY, headers=ADMIN_HEADERS, data=json.dumps({"query": CREATE_JIRA}) + ) + + response = requests.post( + RAPHTORY, headers=NO_ROLE_HEADERS, data=json.dumps({"query": QUERY_JIRA}) + ) + assert response.json()["data"] is None + assert "Access denied" in response.json()["errors"][0]["message"] + + +def test_no_permissions_store_gives_full_access(): + """Without a permissions store configured, all authenticated users see everything.""" + work_dir = tempfile.mkdtemp() + + with GraphServer(work_dir, auth_public_key=PUB_KEY).start(): + requests.post( + RAPHTORY, headers=ADMIN_HEADERS, data=json.dumps({"query": CREATE_JIRA}) + ) + + # analyst role but no store → full access + response = requests.post( + RAPHTORY, headers=ANALYST_HEADERS, data=json.dumps({"query": QUERY_JIRA}) + ) + assert "errors" not in response.json(), response.json() + + +QUERY_JIRA_NODES = """query { graph(path: "jira") { nodes { list { name } } } }""" +QUERY_JIRA_EDGES = ( + """query { graph(path: "jira") { edges { list { src { name } } } } }""" +) +ADD_NODE_JIRA = """mutation { newGraph(path:"jira", graphType:EVENT) }""" + + +def test_graph_metadata_accessible_without_node_grant(): + """path/name are shallow — accessible even without nodes/edges grant.""" + work_dir = tempfile.mkdtemp() + store_path = make_permissions_store(work_dir) + + with GraphServer( + work_dir, + auth_public_key=PUB_KEY, + permissions_store_path=store_path, + ).start(): + requests.post( + RAPHTORY, headers=ADMIN_HEADERS, data=json.dumps({"query": CREATE_JIRA}) + ) + + # analyst has graph access but no nodes/edges grant + response = requests.post( + RAPHTORY, headers=ANALYST_HEADERS, data=json.dumps({"query": QUERY_JIRA}) + ) + assert "errors" not in response.json(), response.json() + assert response.json()["data"]["graph"]["path"] == "jira" + + +def test_nodes_denied_without_grant(): + """Querying nodes without a nodes grant returns an error.""" + work_dir = tempfile.mkdtemp() + store_path = make_permissions_store(work_dir) + + with GraphServer( + work_dir, + auth_public_key=PUB_KEY, + permissions_store_path=store_path, + ).start(): + requests.post( + RAPHTORY, headers=ADMIN_HEADERS, data=json.dumps({"query": CREATE_JIRA}) + ) + + response = requests.post( + RAPHTORY, + headers=ANALYST_HEADERS, + data=json.dumps({"query": QUERY_JIRA_NODES}), + ) + assert ( + response.json()["data"] is None or response.json()["data"]["graph"] is None + ) + assert "Access denied" in response.json()["errors"][0]["message"] + + +def test_edges_denied_without_grant(): + """Querying edges without an edges grant returns an error.""" + work_dir = tempfile.mkdtemp() + store_path = make_permissions_store(work_dir) + + with GraphServer( + work_dir, + auth_public_key=PUB_KEY, + permissions_store_path=store_path, + ).start(): + requests.post( + RAPHTORY, headers=ADMIN_HEADERS, data=json.dumps({"query": CREATE_JIRA}) + ) + + response = requests.post( + RAPHTORY, + headers=ANALYST_HEADERS, + data=json.dumps({"query": QUERY_JIRA_EDGES}), + ) + assert ( + response.json()["data"] is None or response.json()["data"]["graph"] is None + ) + assert "Access denied" in response.json()["errors"][0]["message"] + + +def test_nodes_accessible_with_grant(): + """Querying nodes succeeds when nodes grant is present.""" + work_dir = tempfile.mkdtemp() + store_path = make_permissions_store_with_node_access(work_dir) + + with GraphServer( + work_dir, + auth_public_key=PUB_KEY, + permissions_store_path=store_path, + ).start(): + requests.post( + RAPHTORY, headers=ADMIN_HEADERS, data=json.dumps({"query": CREATE_JIRA}) + ) + + response = requests.post( + RAPHTORY, + headers=ANALYST_HEADERS, + data=json.dumps({"query": QUERY_JIRA_NODES}), + ) + assert "errors" not in response.json(), response.json() + assert isinstance(response.json()["data"]["graph"]["nodes"]["list"], list) + + +def test_edges_still_denied_when_only_nodes_granted(): + """Having nodes access does not grant edges access.""" + work_dir = tempfile.mkdtemp() + store_path = make_permissions_store_with_node_access(work_dir) + + with GraphServer( + work_dir, + auth_public_key=PUB_KEY, + permissions_store_path=store_path, + ).start(): + requests.post( + RAPHTORY, headers=ADMIN_HEADERS, data=json.dumps({"query": CREATE_JIRA}) + ) + + response = requests.post( + RAPHTORY, + headers=ANALYST_HEADERS, + data=json.dumps({"query": QUERY_JIRA_EDGES}), + ) + assert ( + response.json()["data"] is None or response.json()["data"]["graph"] is None + ) + assert "Access denied" in response.json()["errors"][0]["message"] + + +def test_permissions_hot_reload(): + """Updating the permissions file on disk is picked up without restarting the server.""" + work_dir = tempfile.mkdtemp() + + # Start with analyst denied access to jira + store = { + "roles": { + "analyst": {"graphs": []}, # no graphs granted + "admin": {"graphs": [{"name": "*", "nodes": "rw", "edges": "rw"}]}, + } + } + store_path = os.path.join(work_dir, "permissions.json") + with open(store_path, "w") as f: + json.dump(store, f) + + with GraphServer( + work_dir, + auth_public_key=PUB_KEY, + permissions_store_path=store_path, + ).start(): + requests.post( + RAPHTORY, headers=ADMIN_HEADERS, data=json.dumps({"query": CREATE_JIRA}) + ) + + # Confirm analyst is denied before reload + response = requests.post( + RAPHTORY, headers=ANALYST_HEADERS, data=json.dumps({"query": QUERY_JIRA}) + ) + assert "Access denied" in response.json()["errors"][0]["message"] + + # Update permissions file on disk — grant analyst access to jira + store["roles"]["analyst"]["graphs"] = [{"name": "jira"}] + with open(store_path, "w") as f: + json.dump(store, f) + + # Wait for the polling task to pick up the change (polls every 5s) + time.sleep(7) + + # Analyst should now be able to access jira without a server restart + response = requests.post( + RAPHTORY, headers=ANALYST_HEADERS, data=json.dumps({"query": QUERY_JIRA}) + ) + assert "errors" not in response.json(), response.json() + assert response.json()["data"]["graph"]["path"] == "jira" diff --git a/raphtory-api/src/core/entities/properties/prop/mod.rs b/raphtory-api/src/core/entities/properties/prop/mod.rs index b0bab3edac..b0c9637e71 100644 --- a/raphtory-api/src/core/entities/properties/prop/mod.rs +++ b/raphtory-api/src/core/entities/properties/prop/mod.rs @@ -1,6 +1,6 @@ pub mod arrow; -mod prop_array; +pub mod prop_array; mod prop_enum; mod prop_ref_enum; mod prop_type; diff --git a/raphtory-graphql/schema.graphql b/raphtory-graphql/schema.graphql index bf1bbe56d1..2266426c97 100644 --- a/raphtory-graphql/schema.graphql +++ b/raphtory-graphql/schema.graphql @@ -3451,4 +3451,3 @@ schema { query: QueryRoot mutation: MutRoot } - diff --git a/raphtory-graphql/src/auth.rs b/raphtory-graphql/src/auth.rs index 1626bf38a3..43ed40f5d1 100644 --- a/raphtory-graphql/src/auth.rs +++ b/raphtory-graphql/src/auth.rs @@ -17,6 +17,7 @@ use reqwest::header::AUTHORIZATION; use serde::Deserialize; use std::{sync::Arc, time::Duration}; use tokio::sync::{RwLock, Semaphore}; +use tracing::{debug, warn}; #[derive(Clone, Debug, Deserialize, PartialEq)] #[serde(rename_all = "lowercase")] @@ -28,6 +29,8 @@ pub(crate) enum Access { #[derive(Deserialize, Debug, Clone)] pub(crate) struct TokenClaims { pub(crate) a: Access, + #[serde(default)] + pub(crate) role: Option, } // TODO: maybe this should be renamed as it doens't only take care of auth anymore @@ -124,23 +127,28 @@ where async fn call(&self, req: Request) -> Result { // here ANY error when trying to validate the Authorization header is equivalent to it not being present at all - let access = match &self.config.public_key { + let (access, role) = match &self.config.public_key { Some(public_key) => { - let presented_access = req + let claims = req .header(AUTHORIZATION) - .and_then(|header| extract_access_from_header(header, public_key)); - match presented_access { - Some(access) => access, + .and_then(|header| extract_claims_from_header(header, public_key)); + match claims { + Some(claims) => { + debug!(role = ?claims.role, "JWT validated successfully"); + (claims.a, claims.role) + } None => { if self.config.enabled_for_reads { + warn!("Request missing valid JWT — rejecting (auth_enabled_for_reads=true)"); return Err(Unauthorized(AuthError::RequireRead)); } else { - Access::Ro // if read access is not required, we give read access to all requests + debug!("No valid JWT but auth_enabled_for_reads=false — granting read access"); + (Access::Ro, None) } } } } - None => Access::Rw, // if auth is not setup, we give write access to all requests + None => (Access::Rw, None), // if auth is not setup, we give write access to all requests }; let is_accept_multipart_mixed = req @@ -151,7 +159,7 @@ where if is_accept_multipart_mixed { let (req, mut body) = req.split(); let req = GraphQLRequest::from_request(&req, &mut body).await?; - let req = req.0.data(access); + let req = req.0.data(access).data(role); let stream = self.executor.execute_stream(req, None); Ok(Response::builder() .header("content-type", "multipart/mixed; boundary=graphql") @@ -162,7 +170,7 @@ where } else { let (req, mut body) = req.split(); let req = GraphQLBatchRequest::from_request(&req, &mut body).await?; - let req = req.0.data(access); + let req = req.0.data(access).data(role); let contains_update = match &req { BatchRequest::Single(request) => request.query.contains("updateGraph"), @@ -200,14 +208,21 @@ fn is_query_heavy(query: &str) -> bool { || query.contains("inNeighbours") } -fn extract_access_from_header(header: &str, public_key: &PublicKey) -> Option { +fn extract_claims_from_header(header: &str, public_key: &PublicKey) -> Option { if header.starts_with("Bearer ") { let jwt = header.replace("Bearer ", ""); let mut validation = Validation::new(Algorithm::EdDSA); validation.set_required_spec_claims::(&[]); // we don't require 'exp' to be present let decoded = decode::(&jwt, &public_key.decoding_key, &validation); - Some(decoded.ok()?.claims.a) + match decoded { + Ok(token_data) => Some(token_data.claims), + Err(e) => { + warn!(error = %e, "JWT signature validation failed"); + None + } + } } else { + warn!("Authorization header is missing or does not start with 'Bearer '"); None } } diff --git a/raphtory-graphql/src/cli.rs b/raphtory-graphql/src/cli.rs index 4cc5190322..de6b952590 100644 --- a/raphtory-graphql/src/cli.rs +++ b/raphtory-graphql/src/cli.rs @@ -81,6 +81,9 @@ struct ServerArgs { #[arg(long, env = "RAPHTORY_PUBLIC_DIR", default_value = None, help = "Public directory path")] public_dir: Option, + #[arg(long, env = "RAPHTORY_PERMISSIONS_STORE_PATH", default_value = None, help = "Path to the permissions store JSON file")] + permissions_store_path: Option, + #[cfg(feature = "search")] #[arg(long, env = "RAPHTORY_CREATE_INDEX", default_value_t = DEFAULT_CREATE_INDEX, help = "Enable index creation")] create_index: bool, @@ -114,6 +117,7 @@ where .with_auth_public_key(server_args.auth_public_key) .expect(PUBLIC_KEY_DECODING_ERR_MSG) .with_public_dir(server_args.public_dir) + .with_permissions_store_path(server_args.permissions_store_path) .with_auth_enabled_for_reads(server_args.auth_enabled_for_reads); #[cfg(feature = "search")] diff --git a/raphtory-graphql/src/config/app_config.rs b/raphtory-graphql/src/config/app_config.rs index 9404d678e6..5147d6dfbc 100644 --- a/raphtory-graphql/src/config/app_config.rs +++ b/raphtory-graphql/src/config/app_config.rs @@ -17,6 +17,7 @@ pub struct AppConfig { pub tracing: TracingConfig, pub auth: AuthConfig, pub public_dir: Option, + pub permissions_store_path: Option, #[cfg(feature = "search")] pub index: IndexConfig, } @@ -27,6 +28,7 @@ pub struct AppConfigBuilder { tracing: TracingConfig, auth: AuthConfig, public_dir: Option, + permissions_store_path: Option, #[cfg(feature = "search")] index: IndexConfig, } @@ -39,6 +41,7 @@ impl From for AppConfigBuilder { tracing: config.tracing, auth: config.auth, public_dir: config.public_dir, + permissions_store_path: config.permissions_store_path, #[cfg(feature = "search")] index: config.index, } @@ -116,6 +119,11 @@ impl AppConfigBuilder { self } + pub fn with_permissions_store_path(mut self, path: Option) -> Self { + self.permissions_store_path = path; + self + } + #[cfg(feature = "search")] pub fn with_create_index(mut self, create_index: bool) -> Self { self.index.create_index = create_index; @@ -129,6 +137,7 @@ impl AppConfigBuilder { tracing: self.tracing, auth: self.auth, public_dir: self.public_dir, + permissions_store_path: self.permissions_store_path, #[cfg(feature = "search")] index: self.index, } @@ -203,6 +212,10 @@ pub fn load_config( app_config_builder = app_config_builder.with_public_dir(public_dir); } + if let Ok(permissions_store_path) = settings.get::>("permissions_store_path") { + app_config_builder = app_config_builder.with_permissions_store_path(permissions_store_path); + } + #[cfg(feature = "search")] if let Ok(create_index) = settings.get::("index.create_index") { app_config_builder = app_config_builder.with_create_index(create_index); diff --git a/raphtory-graphql/src/data.rs b/raphtory-graphql/src/data.rs index 46462837de..9e082359ea 100644 --- a/raphtory-graphql/src/data.rs +++ b/raphtory-graphql/src/data.rs @@ -6,6 +6,7 @@ use crate::{ mark_dirty, ExistingGraphFolder, InternalPathValidationError, PathValidationError, ValidGraphPaths, ValidWriteableGraphFolder, }, + permissions::PermissionsStore, rayon::blocking_compute, GQLError, }; @@ -27,6 +28,7 @@ use std::{ path::{Path, PathBuf}, sync::Arc, }; +use tokio::sync::RwLock; use tracing::{error, warn}; use walkdir::WalkDir; @@ -136,6 +138,7 @@ pub struct Data { pub(crate) create_index: bool, pub(crate) embedding_conf: Option, pub(crate) graph_conf: Config, + pub(crate) permissions: Option>>, } impl Data { @@ -167,12 +170,23 @@ impl Data { #[cfg(not(feature = "search"))] let create_index = false; + let permissions = configs.permissions_store_path.as_ref().and_then(|path| { + match PermissionsStore::load(path) { + Ok(store) => Some(Arc::new(RwLock::new(store))), + Err(e) => { + eprintln!("Warning: failed to load permissions store from {path:?}: {e}"); + None + } + } + }); + Self { work_dir: work_dir.to_path_buf(), cache, create_index, embedding_conf: Default::default(), graph_conf, + permissions, } } diff --git a/raphtory-graphql/src/lib.rs b/raphtory-graphql/src/lib.rs index 93c609ff1b..4733180e67 100644 --- a/raphtory-graphql/src/lib.rs +++ b/raphtory-graphql/src/lib.rs @@ -11,6 +11,7 @@ mod graph; pub mod model; pub mod observability; mod paths; +pub mod permissions; mod routes; pub mod server; pub mod url_encode; diff --git a/raphtory-graphql/src/model/graph/graph.rs b/raphtory-graphql/src/model/graph/graph.rs index d235d6a68a..d396c4a0b6 100644 --- a/raphtory-graphql/src/model/graph/graph.rs +++ b/raphtory-graphql/src/model/graph/graph.rs @@ -18,6 +18,7 @@ use crate::{ schema::graph_schema::GraphSchema, }, paths::{ExistingGraphFolder, PathValidationError, ValidGraphPaths}, + permissions::GraphPermissions, rayon::blocking_compute, GQLError, }; @@ -62,6 +63,9 @@ use std::{ pub(crate) struct GqlGraph { path: ExistingGraphFolder, graph: DynamicGraph, + /// When `Some`, permissions from the store are active for this request. + /// `None` means the server has no permissions store configured (full access). + graph_permissions: Option>, } impl From for GqlGraph { @@ -75,6 +79,19 @@ impl GqlGraph { Self { path, graph: graph.into_dynamic(), + graph_permissions: None, + } + } + + pub fn new_with_permissions( + path: ExistingGraphFolder, + graph: G, + permissions: Option, + ) -> Self { + Self { + path, + graph: graph.into_dynamic(), + graph_permissions: permissions.map(Arc::new), } } @@ -86,7 +103,30 @@ impl GqlGraph { Self { path: self.path.clone(), graph: graph_operation(&self.graph).into_dynamic(), + graph_permissions: self.graph_permissions.clone(), + } + } + + fn require_node_access(&self) -> Result<()> { + if let Some(perms) = &self.graph_permissions { + if perms.nodes.is_none() { + return Err(async_graphql::Error::new( + "Access denied: node access is not permitted for this role", + )); + } + } + Ok(()) + } + + fn require_edge_access(&self) -> Result<()> { + if let Some(perms) = &self.graph_permissions { + if perms.edges.is_none() { + return Err(async_graphql::Error::new( + "Access denied: edge access is not permitted for this role", + )); + } } + Ok(()) } } @@ -374,20 +414,22 @@ impl GqlGraph { //////////////////////// /// Returns true if the graph contains the specified node. - async fn has_node(&self, name: String) -> bool { - self.graph.has_node(name) + async fn has_node(&self, name: String) -> Result { + self.require_node_access()?; + Ok(self.graph.has_node(name)) } /// Returns true if the graph contains the specified edge. Edges are specified by providing a source and destination node id. You can restrict the search to a specified layer. - async fn has_edge(&self, src: String, dst: String, layer: Option) -> bool { - match layer { + async fn has_edge(&self, src: String, dst: String, layer: Option) -> Result { + self.require_edge_access()?; + Ok(match layer { Some(name) => self .graph .layers(name) .map(|l| l.has_edge(src, dst)) .unwrap_or(false), None => self.graph.has_edge(src, dst), - } + }) } //////////////////////// @@ -395,12 +437,14 @@ impl GqlGraph { //////////////////////// /// Gets the node with the specified id. - async fn node(&self, name: String) -> Option { - self.graph.node(name).map(|node| node.into()) + async fn node(&self, name: String) -> Result> { + self.require_node_access()?; + Ok(self.graph.node(name).map(|node| node.into())) } /// Gets (optionally a subset of) the nodes in the graph. - async fn nodes(&self, select: Option) -> Result { + async fn nodes(&self, select: Option) -> Result { + self.require_node_access()?; let nn = self.graph.nodes(); if let Some(sel) = select { @@ -417,12 +461,14 @@ impl GqlGraph { } /// Gets the edge with the specified source and destination nodes. - async fn edge(&self, src: String, dst: String) -> Option { - self.graph.edge(src, dst).map(|e| e.into()) + async fn edge(&self, src: String, dst: String) -> Result> { + self.require_edge_access()?; + Ok(self.graph.edge(src, dst).map(|e| e.into())) } /// Gets the edges in the graph. - async fn edges<'a>(&self, select: Option) -> Result { + async fn edges<'a>(&self, select: Option) -> Result { + self.require_edge_access()?; let base = self.graph.edges_unlocked(); if let Some(sel) = select { @@ -484,9 +530,10 @@ impl GqlGraph { self.graph.clone().into() } - async fn shared_neighbours(&self, selected_nodes: Vec) -> Vec { + async fn shared_neighbours(&self, selected_nodes: Vec) -> Result> { + self.require_node_access()?; let self_clone = self.clone(); - blocking_compute(move || { + Ok(blocking_compute(move || { if selected_nodes.is_empty() { return vec![]; } @@ -512,7 +559,7 @@ impl GqlGraph { None => vec![], } }) - .await + .await) } /// Export all nodes and edges from this graph view to another existing graph @@ -602,7 +649,8 @@ impl GqlGraph { filter: GqlNodeFilter, limit: usize, offset: usize, - ) -> Result, GraphError> { + ) -> Result> { + self.require_node_access()?; #[cfg(feature = "search")] { let self_clone = self.clone(); @@ -628,7 +676,8 @@ impl GqlGraph { filter: GqlEdgeFilter, limit: usize, offset: usize, - ) -> Result, GraphError> { + ) -> Result> { + self.require_edge_access()?; #[cfg(feature = "search")] { let self_clone = self.clone(); diff --git a/raphtory-graphql/src/model/mod.rs b/raphtory-graphql/src/model/mod.rs index 3044b7b64d..f61e892f5d 100644 --- a/raphtory-graphql/src/model/mod.rs +++ b/raphtory-graphql/src/model/mod.rs @@ -10,6 +10,7 @@ use crate::{ plugins::{mutation_plugin::MutationPlugin, query_plugin::QueryPlugin}, }, paths::{ValidGraphPaths, ValidWriteableGraphFolder}, + permissions::GraphPermissions, rayon::blocking_compute, url_encode::{url_decode_graph_at, url_encode_graph}, }; @@ -25,7 +26,7 @@ use raphtory::{ storage::storage::{Extension, PersistenceStrategy}, view::MaterializedGraph, }, - graph::views::deletion_graph::PersistentGraph, + graph::views::{deletion_graph::PersistentGraph, filter::model::NodeViewFilterOps}, }, errors::GraphError, prelude::*, @@ -35,6 +36,7 @@ use std::{ error::Error, fmt::{Display, Formatter}, }; +use tracing::{debug, warn}; pub(crate) mod graph; pub mod plugins; @@ -98,7 +100,47 @@ impl QueryRoot { /// Returns a graph async fn graph<'a>(ctx: &Context<'a>, path: &str) -> Result { let data = ctx.data_unchecked::(); - Ok(data.get_graph(path).await?.into()) + + let graph_perms: Option = if let Some(store) = &data.permissions { + let store = store.read().await; + let role = ctx + .data::>() + .ok() + .and_then(|r| r.as_deref()) + .unwrap_or(""); + debug!(role = role, graph = path, "Checking permissions store"); + match store.get_graph_permissions(role, path) { + None => { + warn!( + role = role, + graph = path, + "Access denied: no matching permission entry" + ); + return Err(async_graphql::Error::new(format!( + "Access denied: role '{role}' is not permitted to access graph '{path}'" + ))); + } + Some(perms) => { + debug!( + role = role, + graph = path, + nodes = ?perms.nodes, + edges = ?perms.edges, + "Permission granted" + ); + Some(perms.clone()) + } + } + } else { + None // no store -> unrestricted + }; + + let graph_with_vecs = data.get_graph(path).await?; + Ok(GqlGraph::new_with_permissions( + graph_with_vecs.folder, + graph_with_vecs.graph, + graph_perms, + )) } /// Update graph query, has side effects to update graph state diff --git a/raphtory-graphql/src/permissions.rs b/raphtory-graphql/src/permissions.rs new file mode 100644 index 0000000000..525425bde7 --- /dev/null +++ b/raphtory-graphql/src/permissions.rs @@ -0,0 +1,63 @@ +use serde::Deserialize; +use std::{collections::HashMap, fs, path::Path}; +use thiserror::Error; + +#[derive(Debug, Error)] +pub enum PermissionsError { + #[error("Failed to read permissions store file: {0}")] + Io(#[from] std::io::Error), + #[error("Failed to parse permissions store file: {0}")] + Parse(#[from] serde_json::Error), +} + +/// Read-only (`ro`) or read-write (`rw`) access level. +#[derive(Debug, Deserialize, Clone, PartialEq)] +#[serde(rename_all = "lowercase")] +pub enum ContentAccess { + Ro, + Rw, +} + +#[derive(Debug, Deserialize, Clone)] +pub struct GraphPermissions { + /// Graph name this entry applies to. Use "*" to allow all graphs. + pub name: String, + /// Access level for nodes. Absent means denied. + pub nodes: Option, + /// Access level for edges. Absent means denied. + pub edges: Option, +} + +#[derive(Debug, Deserialize)] +pub struct RolePermissions { + pub graphs: Vec, +} + +#[derive(Debug, Deserialize)] +pub struct PermissionsStore { + pub roles: HashMap, +} + +impl PermissionsStore { + pub fn load(path: &Path) -> Result { + let content = fs::read_to_string(path)?; + let store = serde_json::from_str(&content)?; + Ok(store) + } + + /// Returns the matching `GraphPermissions` entry for the given role and graph name, + /// or `None` if the role has no entry covering that graph. + /// Wildcard entry (`name: "*"`) matches any graph but a specific entry takes precedence. + pub fn get_graph_permissions<'a>( + &'a self, + role: &str, + graph: &str, + ) -> Option<&'a GraphPermissions> { + let role_perms = self.roles.get(role)?; + let specific = role_perms.graphs.iter().find(|g| g.name == graph); + if specific.is_some() { + return specific; + } + role_perms.graphs.iter().find(|g| g.name == "*") + } +} diff --git a/raphtory-graphql/src/python/server/server.rs b/raphtory-graphql/src/python/server/server.rs index 96e3108cf6..ef8387d96c 100644 --- a/raphtory-graphql/src/python/server/server.rs +++ b/raphtory-graphql/src/python/server/server.rs @@ -86,7 +86,7 @@ impl PyGraphServer { impl PyGraphServer { #[new] #[pyo3( - signature = (work_dir, cache_capacity = None, cache_tti_seconds = None, log_level = None, tracing=None, tracing_level=None, otlp_agent_host=None, otlp_agent_port=None, otlp_tracing_service_name=None, auth_public_key=None, auth_enabled_for_reads=None, config_path = None, create_index = None) + signature = (work_dir, cache_capacity = None, cache_tti_seconds = None, log_level = None, tracing=None, tracing_level=None, otlp_agent_host=None, otlp_agent_port=None, otlp_tracing_service_name=None, auth_public_key=None, auth_enabled_for_reads=None, config_path = None, create_index = None, permissions_store_path = None) )] fn py_new( work_dir: PathBuf, @@ -102,6 +102,7 @@ impl PyGraphServer { auth_enabled_for_reads: Option, config_path: Option, create_index: Option, + permissions_store_path: Option, ) -> PyResult { let mut app_config_builder = AppConfigBuilder::new(); if let Some(log_level) = log_level { @@ -147,6 +148,10 @@ impl PyGraphServer { if let Some(create_index) = create_index { app_config_builder = app_config_builder.with_create_index(create_index); } + if let Some(permissions_store_path) = permissions_store_path { + app_config_builder = + app_config_builder.with_permissions_store_path(Some(permissions_store_path)); + } let app_config = Some(app_config_builder.build()); let server = GraphServer::new(work_dir, app_config, config_path, Config::default())?; diff --git a/raphtory-graphql/src/server.rs b/raphtory-graphql/src/server.rs index 7d8342e1ab..569a65fc37 100644 --- a/raphtory-graphql/src/server.rs +++ b/raphtory-graphql/src/server.rs @@ -7,6 +7,7 @@ use crate::{ App, }, observability::open_telemetry::OpenTelemetry, + permissions::PermissionsStore, routes::{health, version, PublicFilesEndpoint}, server::ServerError::SchemaError, }; @@ -42,7 +43,7 @@ use tokio::{ task, task::JoinHandle, }; -use tracing::{debug, info}; +use tracing::{debug, info, warn}; use tracing_subscriber::{ fmt, fmt::format::FmtSpan, layer::SubscriberExt, util::SubscriberInitExt, Registry, }; @@ -212,6 +213,36 @@ impl GraphServer { } }); + // Hot-reload permissions store when the file changes (polls every 5s) + if let (Some(path), Some(permissions)) = ( + self.config.permissions_store_path.clone(), + self.data.permissions.clone(), + ) { + tokio::spawn(async move { + let mut last_modified = std::fs::metadata(&path).and_then(|m| m.modified()).ok(); + let mut interval = tokio::time::interval(std::time::Duration::from_secs(5)); + loop { + interval.tick().await; + let current_modified = std::fs::metadata(&path).and_then(|m| m.modified()).ok(); + if current_modified != last_modified { + match PermissionsStore::load(&path) { + Ok(new_store) => { + *permissions.write().await = new_store; + info!("Permissions store reloaded from {:?}", path); + last_modified = current_modified; + } + Err(e) => { + warn!( + error = %e, + "Failed to reload permissions store — keeping old. Restart server to load new permissions." + ); + } + } + } + } + }); + } + // it is important that this runs after algorithms have been pushed to PLUGIN_ALGOS static variable let app = self .generate_endpoint(tp.clone().map(|tp| tp.tracer(tracer_name))) From 16d21c09da9962860f9246a8bc3894ae3e4a2064 Mon Sep 17 00:00:00 2001 From: shivamka1 <4599890+shivamka1@users.noreply.github.com> Date: Wed, 4 Mar 2026 18:37:44 +0000 Subject: [PATCH 02/64] impl introspection --- python/tests/test_permissions.py | 192 ++++++++++++++++++++++ raphtory-graphql/src/auth.rs | 38 ++++- raphtory-graphql/src/model/graph/graph.rs | 36 +++- raphtory-graphql/src/model/mod.rs | 68 ++++---- raphtory-graphql/src/permissions.rs | 37 ++++- raphtory-graphql/src/server.rs | 4 +- 6 files changed, 329 insertions(+), 46 deletions(-) diff --git a/python/tests/test_permissions.py b/python/tests/test_permissions.py index 969908edd3..5edf4d8a5e 100644 --- a/python/tests/test_permissions.py +++ b/python/tests/test_permissions.py @@ -334,3 +334,195 @@ def test_permissions_hot_reload(): ) assert "errors" not in response.json(), response.json() assert response.json()["data"]["graph"]["path"] == "jira" + + +######## Introspection tests ################################################## + +QUERY_SCHEMA = """query { __schema { queryType { name } } }""" +QUERY_COUNT_NODES = """query { graph(path: "jira") { countNodes } }""" +QUERY_UNIQUE_LAYERS = """query { graph(path: "jira") { uniqueLayers } }""" +QUERY_COUNT_EDGES = """query { graph(path: "jira") { countEdges } }""" + + +def test_schema_introspection_denied_when_role_flag_false(): + """Role with introspection:false cannot query __schema.""" + work_dir = tempfile.mkdtemp() + store = { + "roles": { + "analyst": { + "introspection": False, + "graphs": [{"name": "jira", "nodes": "ro"}], + }, + "admin": { + "introspection": True, + "graphs": [{"name": "*", "nodes": "rw", "edges": "rw"}], + }, + } + } + store_path = os.path.join(work_dir, "permissions.json") + with open(store_path, "w") as f: + json.dump(store, f) + + with GraphServer( + work_dir, + auth_public_key=PUB_KEY, + permissions_store_path=store_path, + ).start(): + requests.post( + RAPHTORY, headers=ADMIN_HEADERS, data=json.dumps({"query": CREATE_JIRA}) + ) + response = requests.post( + RAPHTORY, headers=ANALYST_HEADERS, data=json.dumps({"query": QUERY_SCHEMA}) + ) + # async-graphql returns {"data": null} with no errors array when introspection + # is disabled via disable_introspection() — the field is silently nullified. + assert response.json()["data"] is None + assert "errors" not in response.json() or response.json()["errors"] == [] + + +def test_schema_introspection_allowed_when_role_flag_true(): + """Role with introspection:true can query __schema.""" + work_dir = tempfile.mkdtemp() + store = { + "roles": { + "admin": { + "introspection": True, + "graphs": [{"name": "*", "nodes": "rw", "edges": "rw"}], + } + } + } + store_path = os.path.join(work_dir, "permissions.json") + with open(store_path, "w") as f: + json.dump(store, f) + + with GraphServer( + work_dir, + auth_public_key=PUB_KEY, + permissions_store_path=store_path, + ).start(): + response = requests.post( + RAPHTORY, headers=ADMIN_HEADERS, data=json.dumps({"query": QUERY_SCHEMA}) + ) + assert "errors" not in response.json(), response.json() + assert response.json()["data"]["__schema"]["queryType"]["name"] == "QueryRoot" + + +def test_count_nodes_denied_when_graph_introspection_false(): + """countNodes is blocked when graph-level introspection is false.""" + work_dir = tempfile.mkdtemp() + store = { + "roles": { + "analyst": { + "introspection": True, # role-level allows schema + "graphs": [{"name": "jira", "nodes": "ro", "introspection": False}], + }, + "admin": {"graphs": [{"name": "*", "nodes": "rw", "edges": "rw"}]}, + } + } + store_path = os.path.join(work_dir, "permissions.json") + with open(store_path, "w") as f: + json.dump(store, f) + + with GraphServer( + work_dir, + auth_public_key=PUB_KEY, + permissions_store_path=store_path, + ).start(): + requests.post( + RAPHTORY, headers=ADMIN_HEADERS, data=json.dumps({"query": CREATE_JIRA}) + ) + response = requests.post( + RAPHTORY, + headers=ANALYST_HEADERS, + data=json.dumps({"query": QUERY_COUNT_NODES}), + ) + assert ( + response.json()["data"] is None or response.json()["data"]["graph"] is None + ) + assert "Access denied" in response.json()["errors"][0]["message"] + + +def test_count_nodes_allowed_when_graph_introspection_true(): + """countNodes succeeds when graph-level introspection is true.""" + work_dir = tempfile.mkdtemp() + store = { + "roles": { + "analyst": { + "introspection": False, # role-level denies schema + "graphs": [{"name": "jira", "nodes": "ro", "introspection": True}], + }, + "admin": {"graphs": [{"name": "*", "nodes": "rw", "edges": "rw"}]}, + } + } + store_path = os.path.join(work_dir, "permissions.json") + with open(store_path, "w") as f: + json.dump(store, f) + + with GraphServer( + work_dir, + auth_public_key=PUB_KEY, + permissions_store_path=store_path, + ).start(): + requests.post( + RAPHTORY, headers=ADMIN_HEADERS, data=json.dumps({"query": CREATE_JIRA}) + ) + response = requests.post( + RAPHTORY, + headers=ANALYST_HEADERS, + data=json.dumps({"query": QUERY_COUNT_NODES}), + ) + assert "errors" not in response.json(), response.json() + assert isinstance(response.json()["data"]["graph"]["countNodes"], int) + + +def test_per_graph_introspection_overrides_role_level(): + """Per-graph introspection:false blocks counts even when role-level is true.""" + work_dir = tempfile.mkdtemp() + store = { + "roles": { + "analyst": { + "introspection": True, + "graphs": [ + {"name": "jira", "nodes": "ro", "introspection": False}, + {"name": "admin", "nodes": "ro", "introspection": True}, + ], + }, + "admin": {"graphs": [{"name": "*", "nodes": "rw", "edges": "rw"}]}, + } + } + store_path = os.path.join(work_dir, "permissions.json") + with open(store_path, "w") as f: + json.dump(store, f) + + with GraphServer( + work_dir, + auth_public_key=PUB_KEY, + permissions_store_path=store_path, + ).start(): + requests.post( + RAPHTORY, headers=ADMIN_HEADERS, data=json.dumps({"query": CREATE_JIRA}) + ) + requests.post( + RAPHTORY, headers=ADMIN_HEADERS, data=json.dumps({"query": CREATE_ADMIN}) + ) + + # jira: per-graph false overrides role-level true → denied + response = requests.post( + RAPHTORY, + headers=ANALYST_HEADERS, + data=json.dumps({"query": QUERY_COUNT_NODES}), + ) + assert ( + response.json()["data"] is None or response.json()["data"]["graph"] is None + ) + assert "Access denied" in response.json()["errors"][0]["message"] + + # admin graph: per-graph true → allowed + query_admin_count = """query { graph(path: "admin") { countNodes } }""" + response = requests.post( + RAPHTORY, + headers=ANALYST_HEADERS, + data=json.dumps({"query": query_admin_count}), + ) + assert "errors" not in response.json(), response.json() + assert isinstance(response.json()["data"]["graph"]["countNodes"], int) diff --git a/raphtory-graphql/src/auth.rs b/raphtory-graphql/src/auth.rs index 43ed40f5d1..1976c1c0fa 100644 --- a/raphtory-graphql/src/auth.rs +++ b/raphtory-graphql/src/auth.rs @@ -1,4 +1,7 @@ -use crate::config::auth_config::{AuthConfig, PublicKey}; +use crate::{ + config::auth_config::{AuthConfig, PublicKey}, + permissions::PermissionsStore, +}; use async_graphql::{ async_trait, extensions::{Extension, ExtensionContext, ExtensionFactory, NextParseQuery}, @@ -39,14 +42,20 @@ pub struct AuthenticatedGraphQL { config: AuthConfig, semaphore: Option, lock: Option>, + permissions: Option>>, } impl AuthenticatedGraphQL { /// Create a GraphQL endpoint. - pub fn new(executor: E, config: AuthConfig) -> Self { + pub fn new( + executor: E, + config: AuthConfig, + permissions: Option>>, + ) -> Self { Self { executor, config, + permissions, semaphore: std::env::var("RAPHTORY_CONCURRENCY_LIMIT") .ok() .and_then(|limit| { @@ -151,6 +160,15 @@ where None => (Access::Rw, None), // if auth is not setup, we give write access to all requests }; + // Determine whether this role may perform schema introspection. + // When no permissions store is configured, introspection is always allowed. + let allow_introspection = match (&self.permissions, &role) { + (Some(perms), Some(role_name)) => { + perms.read().await.is_introspection_allowed(role_name) + } + _ => true, + }; + let is_accept_multipart_mixed = req .header("accept") .map(is_accept_multipart_mixed) @@ -159,7 +177,10 @@ where if is_accept_multipart_mixed { let (req, mut body) = req.split(); let req = GraphQLRequest::from_request(&req, &mut body).await?; - let req = req.0.data(access).data(role); + let mut req = req.0.data(access).data(role); + if !allow_introspection { + req = req.disable_introspection(); + } let stream = self.executor.execute_stream(req, None); Ok(Response::builder() .header("content-type", "multipart/mixed; boundary=graphql") @@ -172,6 +193,17 @@ where let req = GraphQLBatchRequest::from_request(&req, &mut body).await?; let req = req.0.data(access).data(role); + let req = if !allow_introspection { + match req { + BatchRequest::Single(r) => BatchRequest::Single(r.disable_introspection()), + BatchRequest::Batch(rs) => BatchRequest::Batch( + rs.into_iter().map(|r| r.disable_introspection()).collect(), + ), + } + } else { + req + }; + let contains_update = match &req { BatchRequest::Single(request) => request.query.contains("updateGraph"), BatchRequest::Batch(requests) => requests diff --git a/raphtory-graphql/src/model/graph/graph.rs b/raphtory-graphql/src/model/graph/graph.rs index d396c4a0b6..b6cb8a4c3f 100644 --- a/raphtory-graphql/src/model/graph/graph.rs +++ b/raphtory-graphql/src/model/graph/graph.rs @@ -66,6 +66,9 @@ pub(crate) struct GqlGraph { /// When `Some`, permissions from the store are active for this request. /// `None` means the server has no permissions store configured (full access). graph_permissions: Option>, + /// Whether this role may perform introspection on this graph + /// (countNodes, countEdges, uniqueLayers). Derived from permissions store at request time. + introspection_allowed: bool, } impl From for GqlGraph { @@ -80,6 +83,7 @@ impl GqlGraph { path, graph: graph.into_dynamic(), graph_permissions: None, + introspection_allowed: true, } } @@ -87,11 +91,13 @@ impl GqlGraph { path: ExistingGraphFolder, graph: G, permissions: Option, + introspection_allowed: bool, ) -> Self { Self { path, graph: graph.into_dynamic(), graph_permissions: permissions.map(Arc::new), + introspection_allowed, } } @@ -104,6 +110,7 @@ impl GqlGraph { path: self.path.clone(), graph: graph_operation(&self.graph).into_dynamic(), graph_permissions: self.graph_permissions.clone(), + introspection_allowed: self.introspection_allowed, } } @@ -128,6 +135,15 @@ impl GqlGraph { } Ok(()) } + + fn require_introspection(&self) -> Result<()> { + if !self.introspection_allowed { + return Err(async_graphql::Error::new( + "Access denied: introspection is not permitted for this role on this graph", + )); + } + Ok(()) + } } #[ResolvedObjectFields] @@ -137,9 +153,10 @@ impl GqlGraph { //////////////////////// /// Returns the names of all layers in the graphview. - async fn unique_layers(&self) -> Vec { + async fn unique_layers(&self) -> Result> { + self.require_introspection()?; let self_clone = self.clone(); - blocking_compute(move || self_clone.graph.unique_layers().map_into().collect()).await + Ok(blocking_compute(move || self_clone.graph.unique_layers().map_into().collect()).await) } /// Returns a view containing only the default layer. @@ -390,23 +407,26 @@ impl GqlGraph { /// /// Returns: /// int: - async fn count_edges(&self) -> usize { + async fn count_edges(&self) -> Result { + self.require_introspection()?; let self_clone = self.clone(); - blocking_compute(move || self_clone.graph.count_edges()).await + Ok(blocking_compute(move || self_clone.graph.count_edges()).await) } /// Returns the number of temporal edges in the graph. - async fn count_temporal_edges(&self) -> usize { + async fn count_temporal_edges(&self) -> Result { + self.require_introspection()?; let self_clone = self.clone(); - blocking_compute(move || self_clone.graph.count_temporal_edges()).await + Ok(blocking_compute(move || self_clone.graph.count_temporal_edges()).await) } /// Returns the number of nodes in the graph. /// /// Optionally takes a list of node ids to return a subset. - async fn count_nodes(&self) -> usize { + async fn count_nodes(&self) -> Result { + self.require_introspection()?; let self_clone = self.clone(); - blocking_compute(move || self_clone.graph.count_nodes()).await + Ok(blocking_compute(move || self_clone.graph.count_nodes()).await) } //////////////////////// diff --git a/raphtory-graphql/src/model/mod.rs b/raphtory-graphql/src/model/mod.rs index f61e892f5d..b3b2c75416 100644 --- a/raphtory-graphql/src/model/mod.rs +++ b/raphtory-graphql/src/model/mod.rs @@ -101,45 +101,49 @@ impl QueryRoot { async fn graph<'a>(ctx: &Context<'a>, path: &str) -> Result { let data = ctx.data_unchecked::(); - let graph_perms: Option = if let Some(store) = &data.permissions { - let store = store.read().await; - let role = ctx - .data::>() - .ok() - .and_then(|r| r.as_deref()) - .unwrap_or(""); - debug!(role = role, graph = path, "Checking permissions store"); - match store.get_graph_permissions(role, path) { - None => { - warn!( - role = role, - graph = path, - "Access denied: no matching permission entry" - ); - return Err(async_graphql::Error::new(format!( - "Access denied: role '{role}' is not permitted to access graph '{path}'" - ))); - } - Some(perms) => { - debug!( - role = role, - graph = path, - nodes = ?perms.nodes, - edges = ?perms.edges, - "Permission granted" - ); - Some(perms.clone()) + let (graph_perms, introspection_allowed): (Option, bool) = + if let Some(store) = &data.permissions { + let store = store.read().await; + let role = ctx + .data::>() + .ok() + .and_then(|r| r.as_deref()) + .unwrap_or(""); + debug!(role = role, graph = path, "Checking permissions store"); + match store.get_graph_permissions(role, path) { + None => { + warn!( + role = role, + graph = path, + "Access denied: no matching permission entry" + ); + return Err(async_graphql::Error::new(format!( + "Access denied: role '{role}' is not permitted to access graph '{path}'" + ))); + } + Some(perms) => { + let introspection = store.is_graph_introspection_allowed(role, path); + debug!( + role = role, + graph = path, + nodes = ?perms.nodes, + edges = ?perms.edges, + introspection = introspection, + "Permission granted" + ); + (Some(perms.clone()), introspection) + } } - } - } else { - None // no store -> unrestricted - }; + } else { + (None, true) // no store -> unrestricted + }; let graph_with_vecs = data.get_graph(path).await?; Ok(GqlGraph::new_with_permissions( graph_with_vecs.folder, graph_with_vecs.graph, graph_perms, + introspection_allowed, )) } diff --git a/raphtory-graphql/src/permissions.rs b/raphtory-graphql/src/permissions.rs index 525425bde7..bb87130ec2 100644 --- a/raphtory-graphql/src/permissions.rs +++ b/raphtory-graphql/src/permissions.rs @@ -20,17 +20,24 @@ pub enum ContentAccess { #[derive(Debug, Deserialize, Clone)] pub struct GraphPermissions { - /// Graph name this entry applies to. Use "*" to allow all graphs. + /// Graph name this entry applies to. Use `"*"` to match all graphs. pub name: String, /// Access level for nodes. Absent means denied. pub nodes: Option, /// Access level for edges. Absent means denied. pub edges: Option, + /// Per-graph introspection override (counts, uniqueLayers, schema stats). + /// When absent, falls back to the role-level `introspection` setting. + pub introspection: Option, } -#[derive(Debug, Deserialize)] +#[derive(Debug, Deserialize, Clone)] pub struct RolePermissions { pub graphs: Vec, + /// Whether this role may perform GraphQL schema introspection (`__schema` / `__type`). + /// Defaults to `false` — deny introspection unless explicitly granted. + #[serde(default)] + pub introspection: bool, } #[derive(Debug, Deserialize)] @@ -60,4 +67,30 @@ impl PermissionsStore { } role_perms.graphs.iter().find(|g| g.name == "*") } + + /// Returns whether the given role is allowed to perform GraphQL schema introspection + /// (`__schema` / `__type`). Uses the role-level setting only (schema is not graph-specific). + /// Defaults to `false` when the role is not found. + pub fn is_introspection_allowed(&self, role: &str) -> bool { + self.roles + .get(role) + .map(|r| r.introspection) + .unwrap_or(false) + } + + /// Returns whether the given role is allowed to perform introspection on a specific graph + /// (e.g. `countNodes`, `countEdges`, `uniqueLayers`). + /// The per-graph `introspection` field takes precedence; falls back to the role-level setting. + pub fn is_graph_introspection_allowed(&self, role: &str, graph: &str) -> bool { + let role_perms = match self.roles.get(role) { + Some(r) => r, + None => return false, + }; + if let Some(graph_perms) = self.get_graph_permissions(role, graph) { + if let Some(override_val) = graph_perms.introspection { + return override_val; + } + } + role_perms.introspection + } } diff --git a/raphtory-graphql/src/server.rs b/raphtory-graphql/src/server.rs index 569a65fc37..0a37aaaca2 100644 --- a/raphtory-graphql/src/server.rs +++ b/raphtory-graphql/src/server.rs @@ -273,6 +273,8 @@ impl GraphServer { self, tracer: Option, ) -> Result>, ServerError> { + let permissions = self.data.permissions.clone(); + let schema_builder = App::create_schema(); let schema_builder = schema_builder.data(self.data); let schema_builder = schema_builder.extension(MutationAuth); @@ -291,7 +293,7 @@ impl GraphServer { "/", PublicFilesEndpoint::new( self.config.public_dir, - AuthenticatedGraphQL::new(schema, self.config.auth), + AuthenticatedGraphQL::new(schema, self.config.auth, permissions), ), ) .at("/health", get(health)) From fc54da65b2b289fd1220fc99472f4d29d884af89 Mon Sep 17 00:00:00 2001 From: shivamka1 <4599890+shivamka1@users.noreply.github.com> Date: Wed, 4 Mar 2026 18:44:00 +0000 Subject: [PATCH 03/64] add introspection for schema as well, add test --- python/tests/test_permissions.py | 36 +++++++++++++++++++++++ raphtory-graphql/src/model/graph/graph.rs | 5 ++-- 2 files changed, 39 insertions(+), 2 deletions(-) diff --git a/python/tests/test_permissions.py b/python/tests/test_permissions.py index 5edf4d8a5e..0e776c7607 100644 --- a/python/tests/test_permissions.py +++ b/python/tests/test_permissions.py @@ -342,6 +342,7 @@ def test_permissions_hot_reload(): QUERY_COUNT_NODES = """query { graph(path: "jira") { countNodes } }""" QUERY_UNIQUE_LAYERS = """query { graph(path: "jira") { uniqueLayers } }""" QUERY_COUNT_EDGES = """query { graph(path: "jira") { countEdges } }""" +QUERY_GRAPH_SCHEMA = """query { graph(path: "jira") { schema { layers { name } } } }""" def test_schema_introspection_denied_when_role_flag_false(): @@ -526,3 +527,38 @@ def test_per_graph_introspection_overrides_role_level(): ) assert "errors" not in response.json(), response.json() assert isinstance(response.json()["data"]["graph"]["countNodes"], int) + + +def test_graph_schema_denied_when_introspection_false(): + """Raphtory's graph schema resolver is blocked when introspection is false.""" + work_dir = tempfile.mkdtemp() + store = { + "roles": { + "analyst": { + "introspection": False, + "graphs": [{"name": "jira", "nodes": "ro"}], + }, + "admin": {"graphs": [{"name": "*", "nodes": "rw", "edges": "rw"}]}, + } + } + store_path = os.path.join(work_dir, "permissions.json") + with open(store_path, "w") as f: + json.dump(store, f) + + with GraphServer( + work_dir, + auth_public_key=PUB_KEY, + permissions_store_path=store_path, + ).start(): + requests.post( + RAPHTORY, headers=ADMIN_HEADERS, data=json.dumps({"query": CREATE_JIRA}) + ) + response = requests.post( + RAPHTORY, + headers=ANALYST_HEADERS, + data=json.dumps({"query": QUERY_GRAPH_SCHEMA}), + ) + assert ( + response.json()["data"] is None or response.json()["data"]["graph"] is None + ) + assert "Access denied" in response.json()["errors"][0]["message"] diff --git a/raphtory-graphql/src/model/graph/graph.rs b/raphtory-graphql/src/model/graph/graph.rs index b6cb8a4c3f..20cfd8d75b 100644 --- a/raphtory-graphql/src/model/graph/graph.rs +++ b/raphtory-graphql/src/model/graph/graph.rs @@ -541,9 +541,10 @@ impl GqlGraph { } /// Returns the graph schema. - async fn schema(&self) -> GraphSchema { + async fn schema(&self) -> Result { + self.require_introspection()?; let self_clone = self.clone(); - blocking_compute(move || GraphSchema::new(&self_clone.graph)).await + Ok(blocking_compute(move || GraphSchema::new(&self_clone.graph)).await) } async fn algorithms(&self) -> GraphAlgorithmPlugin { From 4e91a803acff50042cbd0e1f493fe0733ce1c6fd Mon Sep 17 00:00:00 2001 From: shivamka1 <4599890+shivamka1@users.noreply.github.com> Date: Tue, 10 Mar 2026 12:28:07 +0000 Subject: [PATCH 04/64] impl raphtory-auth member mod, impl permissions as gql apis, fix tests --- Cargo.lock | 15 + Cargo.toml | 6 +- python/Cargo.toml | 1 + python/src/lib.rs | 4 + python/tests/test_permissions.py | 539 +++--------------- raphtory-graphql/src/auth.rs | 44 +- raphtory-graphql/src/auth_policy.rs | 23 + raphtory-graphql/src/cli.rs | 4 - raphtory-graphql/src/config/app_config.rs | 13 - raphtory-graphql/src/data.rs | 17 +- raphtory-graphql/src/lib.rs | 2 +- raphtory-graphql/src/model/graph/graph.rs | 43 +- raphtory-graphql/src/model/mod.rs | 65 +-- raphtory-graphql/src/model/plugins/mod.rs | 13 + .../src/model/plugins/operation.rs | 21 + .../src/model/plugins/permissions_plugin.rs | 51 ++ raphtory-graphql/src/permissions.rs | 96 ---- raphtory-graphql/src/python/server/server.rs | 7 +- raphtory-graphql/src/server.rs | 67 +-- 19 files changed, 290 insertions(+), 741 deletions(-) create mode 100644 raphtory-graphql/src/auth_policy.rs create mode 100644 raphtory-graphql/src/model/plugins/permissions_plugin.rs delete mode 100644 raphtory-graphql/src/permissions.rs diff --git a/Cargo.lock b/Cargo.lock index 977b366521..53d5b3b401 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4676,6 +4676,20 @@ dependencies = [ "syn 2.0.114", ] +[[package]] +name = "raphtory-auth" +version = "0.17.0" +dependencies = [ + "async-graphql", + "dynamic-graphql", + "futures-util", + "pyo3", + "raphtory-graphql", + "serde", + "serde_json", + "thiserror 2.0.17", +] + [[package]] name = "raphtory-benchmark" version = "0.17.0" @@ -4794,6 +4808,7 @@ dependencies = [ "pyo3", "pyo3-build-config", "raphtory", + "raphtory-auth", "raphtory-graphql", ] diff --git a/Cargo.toml b/Cargo.toml index a8863d4d10..c831acae15 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -13,8 +13,9 @@ members = [ "raphtory-api-macros", "raphtory-itertools", "clam-core", - "clam-core/snb" - , "raphtory-itertools"] + "clam-core/snb", + "raphtory-itertools" +] default-members = ["raphtory"] exclude = ["optd"] resolver = "2" @@ -60,6 +61,7 @@ raphtory-api = { version = "0.17.0", path = "raphtory-api", default-features = f raphtory-api-macros = { version = "0.17.0", path = "raphtory-api-macros", default-features = false } raphtory-core = { version = "0.17.0", path = "raphtory-core", default-features = false } raphtory-graphql = { version = "0.17.0", path = "raphtory-graphql", default-features = false } +raphtory-auth = { version = "0.17.0", path = "../members/raphtory-auth" } raphtory-storage = { version = "0.17.0", path = "raphtory-storage", default-features = false } raphtory-itertools = { version = "0.17.0", path = "raphtory-itertools" } clam-core = { path = "clam-core" } diff --git a/python/Cargo.toml b/python/Cargo.toml index 1767936c68..87003db67c 100644 --- a/python/Cargo.toml +++ b/python/Cargo.toml @@ -26,6 +26,7 @@ raphtory = { workspace = true, features = [ raphtory-graphql = { workspace = true, features = [ "python", ] } +raphtory-auth = { workspace = true, features = ["python"] } clam-core = { path = "../clam-core", version = "0.17.0", features = ["python"] } diff --git a/python/src/lib.rs b/python/src/lib.rs index 2b2d92569d..696b024c7a 100644 --- a/python/src/lib.rs +++ b/python/src/lib.rs @@ -16,6 +16,10 @@ fn _raphtory(py: Python<'_>, m: &Bound) -> PyResult<()> { let _ = add_raphtory_classes(m); let graphql_module = base_graphql_module(py)?; + graphql_module.add_function(wrap_pyfunction!( + raphtory_auth::python::with_permissions_store, + &graphql_module + )?)?; let algorithm_module = base_algorithm_module(py)?; let graph_loader_module = base_graph_loader_module(py)?; let graph_gen_module = base_graph_gen_module(py)?; diff --git a/python/tests/test_permissions.py b/python/tests/test_permissions.py index 0e776c7607..575e7ac514 100644 --- a/python/tests/test_permissions.py +++ b/python/tests/test_permissions.py @@ -1,11 +1,9 @@ import json -import tempfile import os -import time +import tempfile import requests import jwt -import pytest -from raphtory.graphql import GraphServer +from raphtory.graphql import GraphServer, with_permissions_store # Reuse the same key pair as test_auth.py PUB_KEY = "MCowBQYDK2VwAyEADdrWr1kTLj+wSHlr45eneXmOjlHo3N1DjLIvDa2ozno=" @@ -15,67 +13,62 @@ RAPHTORY = "http://localhost:1736" -# JWTs with roles ANALYST_JWT = jwt.encode({"a": "ro", "role": "analyst"}, PRIVATE_KEY, algorithm="EdDSA") ANALYST_HEADERS = {"Authorization": f"Bearer {ANALYST_JWT}"} ADMIN_JWT = jwt.encode({"a": "rw", "role": "admin"}, PRIVATE_KEY, algorithm="EdDSA") ADMIN_HEADERS = {"Authorization": f"Bearer {ADMIN_JWT}"} -# JWT with no role — valid token but no role field NO_ROLE_JWT = jwt.encode({"a": "ro"}, PRIVATE_KEY, algorithm="EdDSA") NO_ROLE_HEADERS = {"Authorization": f"Bearer {NO_ROLE_JWT}"} QUERY_JIRA = """query { graph(path: "jira") { path } }""" QUERY_ADMIN = """query { graph(path: "admin") { path } }""" +QUERY_COUNT_NODES = """query { graph(path: "jira") { countNodes } }""" CREATE_JIRA = """mutation { newGraph(path:"jira", graphType:EVENT) }""" CREATE_ADMIN = """mutation { newGraph(path:"admin", graphType:EVENT) }""" -def make_permissions_store(path: str) -> str: - """Write a permissions store JSON file and return its path.""" - store = { - "roles": { - "analyst": {"graphs": [{"name": "jira"}]}, - "admin": {"graphs": [{"name": "*", "nodes": "ro", "edges": "ro"}]}, - } - } - store_path = os.path.join(path, "permissions.json") - with open(store_path, "w") as f: - json.dump(store, f) - return store_path - - -def make_permissions_store_with_node_access(path: str) -> str: - """Permissions store where analyst has node access to jira but not edges.""" - store = { - "roles": { - "analyst": {"graphs": [{"name": "jira", "nodes": "ro"}]}, - "admin": {"graphs": [{"name": "*", "nodes": "rw", "edges": "rw"}]}, - } - } - store_path = os.path.join(path, "permissions.json") - with open(store_path, "w") as f: - json.dump(store, f) - return store_path +def gql(query: str, headers=None) -> dict: + h = headers if headers is not None else ADMIN_HEADERS + return requests.post(RAPHTORY, headers=h, data=json.dumps({"query": query})).json() + + +def create_role(role: str) -> None: + gql(f'mutation {{ permissions {{ createRole(name: "{role}") {{ success }} }} }}') + + +def grant_graph(role: str, path: str, permissions: list) -> None: + perms = "[" + ", ".join(permissions) + "]" + gql( + f'mutation {{ permissions {{ grantGraph(role: "{role}", path: "{path}", permissions: {perms}) {{ success }} }} }}' + ) + + +def grant_namespace(role: str, path: str, permissions: list) -> None: + perms = "[" + ", ".join(permissions) + "]" + gql( + f'mutation {{ permissions {{ grantNamespace(role: "{role}", path: "{path}", permissions: {perms}) {{ success }} }} }}' + ) + + +def make_server(work_dir: str): + """Create a GraphServer wired with a permissions store at {work_dir}/permissions.json.""" + return with_permissions_store( + GraphServer(work_dir, auth_public_key=PUB_KEY), + os.path.join(work_dir, "permissions.json"), + ) def test_analyst_can_access_permitted_graph(): work_dir = tempfile.mkdtemp() - store_path = make_permissions_store(work_dir) - - with GraphServer( - work_dir, - auth_public_key=PUB_KEY, - permissions_store_path=store_path, - ).start(): - # Create the graphs using admin role - requests.post( - RAPHTORY, headers=ADMIN_HEADERS, data=json.dumps({"query": CREATE_JIRA}) - ) - requests.post( - RAPHTORY, headers=ADMIN_HEADERS, data=json.dumps({"query": CREATE_ADMIN}) - ) + with make_server(work_dir).start(): + gql(CREATE_JIRA) + gql(CREATE_ADMIN) + create_role("analyst") + create_role("admin") + grant_graph("analyst", "jira", ["READ"]) + grant_namespace("admin", "*", ["READ"]) response = requests.post( RAPHTORY, headers=ANALYST_HEADERS, data=json.dumps({"query": QUERY_JIRA}) @@ -86,16 +79,10 @@ def test_analyst_can_access_permitted_graph(): def test_analyst_cannot_access_denied_graph(): work_dir = tempfile.mkdtemp() - store_path = make_permissions_store(work_dir) - - with GraphServer( - work_dir, - auth_public_key=PUB_KEY, - permissions_store_path=store_path, - ).start(): - requests.post( - RAPHTORY, headers=ADMIN_HEADERS, data=json.dumps({"query": CREATE_ADMIN}) - ) + with make_server(work_dir).start(): + gql(CREATE_ADMIN) + create_role("analyst") + grant_graph("analyst", "jira", ["READ"]) # only jira, not admin response = requests.post( RAPHTORY, headers=ANALYST_HEADERS, data=json.dumps({"query": QUERY_ADMIN}) @@ -106,19 +93,11 @@ def test_analyst_cannot_access_denied_graph(): def test_admin_can_access_all_graphs(): work_dir = tempfile.mkdtemp() - store_path = make_permissions_store(work_dir) - - with GraphServer( - work_dir, - auth_public_key=PUB_KEY, - permissions_store_path=store_path, - ).start(): - requests.post( - RAPHTORY, headers=ADMIN_HEADERS, data=json.dumps({"query": CREATE_JIRA}) - ) - requests.post( - RAPHTORY, headers=ADMIN_HEADERS, data=json.dumps({"query": CREATE_ADMIN}) - ) + with make_server(work_dir).start(): + gql(CREATE_JIRA) + gql(CREATE_ADMIN) + create_role("admin") + grant_namespace("admin", "*", ["READ"]) for query in [QUERY_JIRA, QUERY_ADMIN]: response = requests.post( @@ -127,18 +106,12 @@ def test_admin_can_access_all_graphs(): assert "errors" not in response.json(), response.json() -def test_no_role_is_denied_when_store_is_active(): +def test_no_role_is_denied_when_policy_is_active(): work_dir = tempfile.mkdtemp() - store_path = make_permissions_store(work_dir) - - with GraphServer( - work_dir, - auth_public_key=PUB_KEY, - permissions_store_path=store_path, - ).start(): - requests.post( - RAPHTORY, headers=ADMIN_HEADERS, data=json.dumps({"query": CREATE_JIRA}) - ) + with make_server(work_dir).start(): + gql(CREATE_JIRA) + create_role("analyst") + grant_graph("analyst", "jira", ["READ"]) response = requests.post( RAPHTORY, headers=NO_ROLE_HEADERS, data=json.dumps({"query": QUERY_JIRA}) @@ -147,188 +120,69 @@ def test_no_role_is_denied_when_store_is_active(): assert "Access denied" in response.json()["errors"][0]["message"] -def test_no_permissions_store_gives_full_access(): - """Without a permissions store configured, all authenticated users see everything.""" +def test_no_policy_gives_full_access(): + """Without with_permissions_store, all authenticated users see everything.""" work_dir = tempfile.mkdtemp() - with GraphServer(work_dir, auth_public_key=PUB_KEY).start(): - requests.post( - RAPHTORY, headers=ADMIN_HEADERS, data=json.dumps({"query": CREATE_JIRA}) - ) + gql(CREATE_JIRA) - # analyst role but no store → full access response = requests.post( RAPHTORY, headers=ANALYST_HEADERS, data=json.dumps({"query": QUERY_JIRA}) ) assert "errors" not in response.json(), response.json() -QUERY_JIRA_NODES = """query { graph(path: "jira") { nodes { list { name } } } }""" -QUERY_JIRA_EDGES = ( - """query { graph(path: "jira") { edges { list { src { name } } } } }""" -) -ADD_NODE_JIRA = """mutation { newGraph(path:"jira", graphType:EVENT) }""" - - -def test_graph_metadata_accessible_without_node_grant(): - """path/name are shallow — accessible even without nodes/edges grant.""" - work_dir = tempfile.mkdtemp() - store_path = make_permissions_store(work_dir) - - with GraphServer( - work_dir, - auth_public_key=PUB_KEY, - permissions_store_path=store_path, - ).start(): - requests.post( - RAPHTORY, headers=ADMIN_HEADERS, data=json.dumps({"query": CREATE_JIRA}) - ) - - # analyst has graph access but no nodes/edges grant - response = requests.post( - RAPHTORY, headers=ANALYST_HEADERS, data=json.dumps({"query": QUERY_JIRA}) - ) - assert "errors" not in response.json(), response.json() - assert response.json()["data"]["graph"]["path"] == "jira" - - -def test_nodes_denied_without_grant(): - """Querying nodes without a nodes grant returns an error.""" - work_dir = tempfile.mkdtemp() - store_path = make_permissions_store(work_dir) - - with GraphServer( - work_dir, - auth_public_key=PUB_KEY, - permissions_store_path=store_path, - ).start(): - requests.post( - RAPHTORY, headers=ADMIN_HEADERS, data=json.dumps({"query": CREATE_JIRA}) - ) - - response = requests.post( - RAPHTORY, - headers=ANALYST_HEADERS, - data=json.dumps({"query": QUERY_JIRA_NODES}), - ) - assert ( - response.json()["data"] is None or response.json()["data"]["graph"] is None - ) - assert "Access denied" in response.json()["errors"][0]["message"] - - -def test_edges_denied_without_grant(): - """Querying edges without an edges grant returns an error.""" - work_dir = tempfile.mkdtemp() - store_path = make_permissions_store(work_dir) - - with GraphServer( - work_dir, - auth_public_key=PUB_KEY, - permissions_store_path=store_path, - ).start(): - requests.post( - RAPHTORY, headers=ADMIN_HEADERS, data=json.dumps({"query": CREATE_JIRA}) - ) - - response = requests.post( - RAPHTORY, - headers=ANALYST_HEADERS, - data=json.dumps({"query": QUERY_JIRA_EDGES}), - ) - assert ( - response.json()["data"] is None or response.json()["data"]["graph"] is None - ) - assert "Access denied" in response.json()["errors"][0]["message"] - - -def test_nodes_accessible_with_grant(): - """Querying nodes succeeds when nodes grant is present.""" +def test_introspection_allowed_with_introspect_permission(): work_dir = tempfile.mkdtemp() - store_path = make_permissions_store_with_node_access(work_dir) - - with GraphServer( - work_dir, - auth_public_key=PUB_KEY, - permissions_store_path=store_path, - ).start(): - requests.post( - RAPHTORY, headers=ADMIN_HEADERS, data=json.dumps({"query": CREATE_JIRA}) - ) + with make_server(work_dir).start(): + gql(CREATE_JIRA) + create_role("analyst") + grant_graph("analyst", "jira", ["READ", "INTROSPECT"]) response = requests.post( RAPHTORY, headers=ANALYST_HEADERS, - data=json.dumps({"query": QUERY_JIRA_NODES}), + data=json.dumps({"query": QUERY_COUNT_NODES}), ) assert "errors" not in response.json(), response.json() - assert isinstance(response.json()["data"]["graph"]["nodes"]["list"], list) + assert isinstance(response.json()["data"]["graph"]["countNodes"], int) -def test_edges_still_denied_when_only_nodes_granted(): - """Having nodes access does not grant edges access.""" +def test_introspection_denied_without_introspect_permission(): work_dir = tempfile.mkdtemp() - store_path = make_permissions_store_with_node_access(work_dir) - - with GraphServer( - work_dir, - auth_public_key=PUB_KEY, - permissions_store_path=store_path, - ).start(): - requests.post( - RAPHTORY, headers=ADMIN_HEADERS, data=json.dumps({"query": CREATE_JIRA}) - ) + with make_server(work_dir).start(): + gql(CREATE_JIRA) + create_role("analyst") + grant_graph("analyst", "jira", ["READ"]) # READ only, no INTROSPECT response = requests.post( RAPHTORY, headers=ANALYST_HEADERS, - data=json.dumps({"query": QUERY_JIRA_EDGES}), + data=json.dumps({"query": QUERY_COUNT_NODES}), ) assert ( - response.json()["data"] is None or response.json()["data"]["graph"] is None + response.json()["data"] is None + or response.json()["data"]["graph"] is None ) assert "Access denied" in response.json()["errors"][0]["message"] -def test_permissions_hot_reload(): - """Updating the permissions file on disk is picked up without restarting the server.""" +def test_permissions_update_via_mutation(): + """Granting access via mutation takes effect immediately.""" work_dir = tempfile.mkdtemp() + with make_server(work_dir).start(): + gql(CREATE_JIRA) + create_role("analyst") - # Start with analyst denied access to jira - store = { - "roles": { - "analyst": {"graphs": []}, # no graphs granted - "admin": {"graphs": [{"name": "*", "nodes": "rw", "edges": "rw"}]}, - } - } - store_path = os.path.join(work_dir, "permissions.json") - with open(store_path, "w") as f: - json.dump(store, f) - - with GraphServer( - work_dir, - auth_public_key=PUB_KEY, - permissions_store_path=store_path, - ).start(): - requests.post( - RAPHTORY, headers=ADMIN_HEADERS, data=json.dumps({"query": CREATE_JIRA}) - ) - - # Confirm analyst is denied before reload + # No grants yet — denied response = requests.post( RAPHTORY, headers=ANALYST_HEADERS, data=json.dumps({"query": QUERY_JIRA}) ) assert "Access denied" in response.json()["errors"][0]["message"] - # Update permissions file on disk — grant analyst access to jira - store["roles"]["analyst"]["graphs"] = [{"name": "jira"}] - with open(store_path, "w") as f: - json.dump(store, f) + # Grant via mutation + grant_graph("analyst", "jira", ["READ"]) - # Wait for the polling task to pick up the change (polls every 5s) - time.sleep(7) - - # Analyst should now be able to access jira without a server restart response = requests.post( RAPHTORY, headers=ANALYST_HEADERS, data=json.dumps({"query": QUERY_JIRA}) ) @@ -336,229 +190,16 @@ def test_permissions_hot_reload(): assert response.json()["data"]["graph"]["path"] == "jira" -######## Introspection tests ################################################## - -QUERY_SCHEMA = """query { __schema { queryType { name } } }""" -QUERY_COUNT_NODES = """query { graph(path: "jira") { countNodes } }""" -QUERY_UNIQUE_LAYERS = """query { graph(path: "jira") { uniqueLayers } }""" -QUERY_COUNT_EDGES = """query { graph(path: "jira") { countEdges } }""" -QUERY_GRAPH_SCHEMA = """query { graph(path: "jira") { schema { layers { name } } } }""" - - -def test_schema_introspection_denied_when_role_flag_false(): - """Role with introspection:false cannot query __schema.""" +def test_namespace_wildcard_grants_access_to_all_graphs(): work_dir = tempfile.mkdtemp() - store = { - "roles": { - "analyst": { - "introspection": False, - "graphs": [{"name": "jira", "nodes": "ro"}], - }, - "admin": { - "introspection": True, - "graphs": [{"name": "*", "nodes": "rw", "edges": "rw"}], - }, - } - } - store_path = os.path.join(work_dir, "permissions.json") - with open(store_path, "w") as f: - json.dump(store, f) - - with GraphServer( - work_dir, - auth_public_key=PUB_KEY, - permissions_store_path=store_path, - ).start(): - requests.post( - RAPHTORY, headers=ADMIN_HEADERS, data=json.dumps({"query": CREATE_JIRA}) - ) - response = requests.post( - RAPHTORY, headers=ANALYST_HEADERS, data=json.dumps({"query": QUERY_SCHEMA}) - ) - # async-graphql returns {"data": null} with no errors array when introspection - # is disabled via disable_introspection() — the field is silently nullified. - assert response.json()["data"] is None - assert "errors" not in response.json() or response.json()["errors"] == [] - - -def test_schema_introspection_allowed_when_role_flag_true(): - """Role with introspection:true can query __schema.""" - work_dir = tempfile.mkdtemp() - store = { - "roles": { - "admin": { - "introspection": True, - "graphs": [{"name": "*", "nodes": "rw", "edges": "rw"}], - } - } - } - store_path = os.path.join(work_dir, "permissions.json") - with open(store_path, "w") as f: - json.dump(store, f) - - with GraphServer( - work_dir, - auth_public_key=PUB_KEY, - permissions_store_path=store_path, - ).start(): - response = requests.post( - RAPHTORY, headers=ADMIN_HEADERS, data=json.dumps({"query": QUERY_SCHEMA}) - ) - assert "errors" not in response.json(), response.json() - assert response.json()["data"]["__schema"]["queryType"]["name"] == "QueryRoot" - - -def test_count_nodes_denied_when_graph_introspection_false(): - """countNodes is blocked when graph-level introspection is false.""" - work_dir = tempfile.mkdtemp() - store = { - "roles": { - "analyst": { - "introspection": True, # role-level allows schema - "graphs": [{"name": "jira", "nodes": "ro", "introspection": False}], - }, - "admin": {"graphs": [{"name": "*", "nodes": "rw", "edges": "rw"}]}, - } - } - store_path = os.path.join(work_dir, "permissions.json") - with open(store_path, "w") as f: - json.dump(store, f) - - with GraphServer( - work_dir, - auth_public_key=PUB_KEY, - permissions_store_path=store_path, - ).start(): - requests.post( - RAPHTORY, headers=ADMIN_HEADERS, data=json.dumps({"query": CREATE_JIRA}) - ) - response = requests.post( - RAPHTORY, - headers=ANALYST_HEADERS, - data=json.dumps({"query": QUERY_COUNT_NODES}), - ) - assert ( - response.json()["data"] is None or response.json()["data"]["graph"] is None - ) - assert "Access denied" in response.json()["errors"][0]["message"] - + with make_server(work_dir).start(): + gql(CREATE_JIRA) + gql(CREATE_ADMIN) + create_role("analyst") + grant_namespace("analyst", "*", ["READ"]) -def test_count_nodes_allowed_when_graph_introspection_true(): - """countNodes succeeds when graph-level introspection is true.""" - work_dir = tempfile.mkdtemp() - store = { - "roles": { - "analyst": { - "introspection": False, # role-level denies schema - "graphs": [{"name": "jira", "nodes": "ro", "introspection": True}], - }, - "admin": {"graphs": [{"name": "*", "nodes": "rw", "edges": "rw"}]}, - } - } - store_path = os.path.join(work_dir, "permissions.json") - with open(store_path, "w") as f: - json.dump(store, f) - - with GraphServer( - work_dir, - auth_public_key=PUB_KEY, - permissions_store_path=store_path, - ).start(): - requests.post( - RAPHTORY, headers=ADMIN_HEADERS, data=json.dumps({"query": CREATE_JIRA}) - ) - response = requests.post( - RAPHTORY, - headers=ANALYST_HEADERS, - data=json.dumps({"query": QUERY_COUNT_NODES}), - ) - assert "errors" not in response.json(), response.json() - assert isinstance(response.json()["data"]["graph"]["countNodes"], int) - - -def test_per_graph_introspection_overrides_role_level(): - """Per-graph introspection:false blocks counts even when role-level is true.""" - work_dir = tempfile.mkdtemp() - store = { - "roles": { - "analyst": { - "introspection": True, - "graphs": [ - {"name": "jira", "nodes": "ro", "introspection": False}, - {"name": "admin", "nodes": "ro", "introspection": True}, - ], - }, - "admin": {"graphs": [{"name": "*", "nodes": "rw", "edges": "rw"}]}, - } - } - store_path = os.path.join(work_dir, "permissions.json") - with open(store_path, "w") as f: - json.dump(store, f) - - with GraphServer( - work_dir, - auth_public_key=PUB_KEY, - permissions_store_path=store_path, - ).start(): - requests.post( - RAPHTORY, headers=ADMIN_HEADERS, data=json.dumps({"query": CREATE_JIRA}) - ) - requests.post( - RAPHTORY, headers=ADMIN_HEADERS, data=json.dumps({"query": CREATE_ADMIN}) - ) - - # jira: per-graph false overrides role-level true → denied - response = requests.post( - RAPHTORY, - headers=ANALYST_HEADERS, - data=json.dumps({"query": QUERY_COUNT_NODES}), - ) - assert ( - response.json()["data"] is None or response.json()["data"]["graph"] is None - ) - assert "Access denied" in response.json()["errors"][0]["message"] - - # admin graph: per-graph true → allowed - query_admin_count = """query { graph(path: "admin") { countNodes } }""" - response = requests.post( - RAPHTORY, - headers=ANALYST_HEADERS, - data=json.dumps({"query": query_admin_count}), - ) - assert "errors" not in response.json(), response.json() - assert isinstance(response.json()["data"]["graph"]["countNodes"], int) - - -def test_graph_schema_denied_when_introspection_false(): - """Raphtory's graph schema resolver is blocked when introspection is false.""" - work_dir = tempfile.mkdtemp() - store = { - "roles": { - "analyst": { - "introspection": False, - "graphs": [{"name": "jira", "nodes": "ro"}], - }, - "admin": {"graphs": [{"name": "*", "nodes": "rw", "edges": "rw"}]}, - } - } - store_path = os.path.join(work_dir, "permissions.json") - with open(store_path, "w") as f: - json.dump(store, f) - - with GraphServer( - work_dir, - auth_public_key=PUB_KEY, - permissions_store_path=store_path, - ).start(): - requests.post( - RAPHTORY, headers=ADMIN_HEADERS, data=json.dumps({"query": CREATE_JIRA}) - ) - response = requests.post( - RAPHTORY, - headers=ANALYST_HEADERS, - data=json.dumps({"query": QUERY_GRAPH_SCHEMA}), - ) - assert ( - response.json()["data"] is None or response.json()["data"]["graph"] is None - ) - assert "Access denied" in response.json()["errors"][0]["message"] + for query in [QUERY_JIRA, QUERY_ADMIN]: + response = requests.post( + RAPHTORY, headers=ANALYST_HEADERS, data=json.dumps({"query": query}) + ) + assert "errors" not in response.json(), response.json() diff --git a/raphtory-graphql/src/auth.rs b/raphtory-graphql/src/auth.rs index 1976c1c0fa..14df49ddce 100644 --- a/raphtory-graphql/src/auth.rs +++ b/raphtory-graphql/src/auth.rs @@ -1,7 +1,4 @@ -use crate::{ - config::auth_config::{AuthConfig, PublicKey}, - permissions::PermissionsStore, -}; +use crate::config::auth_config::{AuthConfig, PublicKey}; use async_graphql::{ async_trait, extensions::{Extension, ExtensionContext, ExtensionFactory, NextParseQuery}, @@ -19,7 +16,7 @@ use poem::{ use reqwest::header::AUTHORIZATION; use serde::Deserialize; use std::{sync::Arc, time::Duration}; -use tokio::sync::{RwLock, Semaphore}; +use tokio::sync::Semaphore; use tracing::{debug, warn}; #[derive(Clone, Debug, Deserialize, PartialEq)] @@ -41,21 +38,15 @@ pub struct AuthenticatedGraphQL { executor: E, config: AuthConfig, semaphore: Option, - lock: Option>, - permissions: Option>>, + lock: Option>, } impl AuthenticatedGraphQL { /// Create a GraphQL endpoint. - pub fn new( - executor: E, - config: AuthConfig, - permissions: Option>>, - ) -> Self { + pub fn new(executor: E, config: AuthConfig) -> Self { Self { executor, config, - permissions, semaphore: std::env::var("RAPHTORY_CONCURRENCY_LIMIT") .ok() .and_then(|limit| { @@ -70,7 +61,7 @@ impl AuthenticatedGraphQL { .and_then(|thread_safe| { if thread_safe == "1" { println!("Server running in threadsafe mode"); - Some(RwLock::new(())) + Some(tokio::sync::RwLock::new(())) } else { None } @@ -160,15 +151,6 @@ where None => (Access::Rw, None), // if auth is not setup, we give write access to all requests }; - // Determine whether this role may perform schema introspection. - // When no permissions store is configured, introspection is always allowed. - let allow_introspection = match (&self.permissions, &role) { - (Some(perms), Some(role_name)) => { - perms.read().await.is_introspection_allowed(role_name) - } - _ => true, - }; - let is_accept_multipart_mixed = req .header("accept") .map(is_accept_multipart_mixed) @@ -177,10 +159,7 @@ where if is_accept_multipart_mixed { let (req, mut body) = req.split(); let req = GraphQLRequest::from_request(&req, &mut body).await?; - let mut req = req.0.data(access).data(role); - if !allow_introspection { - req = req.disable_introspection(); - } + let req = req.0.data(access).data(role); let stream = self.executor.execute_stream(req, None); Ok(Response::builder() .header("content-type", "multipart/mixed; boundary=graphql") @@ -193,17 +172,6 @@ where let req = GraphQLBatchRequest::from_request(&req, &mut body).await?; let req = req.0.data(access).data(role); - let req = if !allow_introspection { - match req { - BatchRequest::Single(r) => BatchRequest::Single(r.disable_introspection()), - BatchRequest::Batch(rs) => BatchRequest::Batch( - rs.into_iter().map(|r| r.disable_introspection()).collect(), - ), - } - } else { - req - }; - let contains_update = match &req { BatchRequest::Single(request) => request.query.contains("updateGraph"), BatchRequest::Batch(requests) => requests diff --git a/raphtory-graphql/src/auth_policy.rs b/raphtory-graphql/src/auth_policy.rs new file mode 100644 index 0000000000..4324184cac --- /dev/null +++ b/raphtory-graphql/src/auth_policy.rs @@ -0,0 +1,23 @@ +pub trait AuthorizationPolicy: Send + Sync + 'static { + /// Returns `Some(true)` to allow access, `Some(false)` to deny, `None` if the role has no + /// entry covering this graph (treated as denied when a policy is active). + fn check_graph_access(&self, role: Option<&str>, path: &str) -> Option; + + /// Returns `true` if the role may perform graph-level introspection + /// (countNodes, countEdges, uniqueLayers, schema). + fn check_graph_introspection(&self, role: Option<&str>, path: &str) -> bool; +} + +/// A no-op policy that permits all reads and leaves writes to the `PermissionsPlugin`. +/// Used when no auth policy has been configured on the server. +pub struct NoopPolicy; + +impl AuthorizationPolicy for NoopPolicy { + fn check_graph_access(&self, _: Option<&str>, _: &str) -> Option { + Some(true) + } + + fn check_graph_introspection(&self, _: Option<&str>, _: &str) -> bool { + true + } +} diff --git a/raphtory-graphql/src/cli.rs b/raphtory-graphql/src/cli.rs index de6b952590..4cc5190322 100644 --- a/raphtory-graphql/src/cli.rs +++ b/raphtory-graphql/src/cli.rs @@ -81,9 +81,6 @@ struct ServerArgs { #[arg(long, env = "RAPHTORY_PUBLIC_DIR", default_value = None, help = "Public directory path")] public_dir: Option, - #[arg(long, env = "RAPHTORY_PERMISSIONS_STORE_PATH", default_value = None, help = "Path to the permissions store JSON file")] - permissions_store_path: Option, - #[cfg(feature = "search")] #[arg(long, env = "RAPHTORY_CREATE_INDEX", default_value_t = DEFAULT_CREATE_INDEX, help = "Enable index creation")] create_index: bool, @@ -117,7 +114,6 @@ where .with_auth_public_key(server_args.auth_public_key) .expect(PUBLIC_KEY_DECODING_ERR_MSG) .with_public_dir(server_args.public_dir) - .with_permissions_store_path(server_args.permissions_store_path) .with_auth_enabled_for_reads(server_args.auth_enabled_for_reads); #[cfg(feature = "search")] diff --git a/raphtory-graphql/src/config/app_config.rs b/raphtory-graphql/src/config/app_config.rs index 5147d6dfbc..9404d678e6 100644 --- a/raphtory-graphql/src/config/app_config.rs +++ b/raphtory-graphql/src/config/app_config.rs @@ -17,7 +17,6 @@ pub struct AppConfig { pub tracing: TracingConfig, pub auth: AuthConfig, pub public_dir: Option, - pub permissions_store_path: Option, #[cfg(feature = "search")] pub index: IndexConfig, } @@ -28,7 +27,6 @@ pub struct AppConfigBuilder { tracing: TracingConfig, auth: AuthConfig, public_dir: Option, - permissions_store_path: Option, #[cfg(feature = "search")] index: IndexConfig, } @@ -41,7 +39,6 @@ impl From for AppConfigBuilder { tracing: config.tracing, auth: config.auth, public_dir: config.public_dir, - permissions_store_path: config.permissions_store_path, #[cfg(feature = "search")] index: config.index, } @@ -119,11 +116,6 @@ impl AppConfigBuilder { self } - pub fn with_permissions_store_path(mut self, path: Option) -> Self { - self.permissions_store_path = path; - self - } - #[cfg(feature = "search")] pub fn with_create_index(mut self, create_index: bool) -> Self { self.index.create_index = create_index; @@ -137,7 +129,6 @@ impl AppConfigBuilder { tracing: self.tracing, auth: self.auth, public_dir: self.public_dir, - permissions_store_path: self.permissions_store_path, #[cfg(feature = "search")] index: self.index, } @@ -212,10 +203,6 @@ pub fn load_config( app_config_builder = app_config_builder.with_public_dir(public_dir); } - if let Ok(permissions_store_path) = settings.get::>("permissions_store_path") { - app_config_builder = app_config_builder.with_permissions_store_path(permissions_store_path); - } - #[cfg(feature = "search")] if let Ok(create_index) = settings.get::("index.create_index") { app_config_builder = app_config_builder.with_create_index(create_index); diff --git a/raphtory-graphql/src/data.rs b/raphtory-graphql/src/data.rs index 9e082359ea..94955f9665 100644 --- a/raphtory-graphql/src/data.rs +++ b/raphtory-graphql/src/data.rs @@ -1,4 +1,5 @@ use crate::{ + auth_policy::AuthorizationPolicy, config::app_config::AppConfig, graph::GraphWithVectors, model::blocking_io, @@ -6,7 +7,6 @@ use crate::{ mark_dirty, ExistingGraphFolder, InternalPathValidationError, PathValidationError, ValidGraphPaths, ValidWriteableGraphFolder, }, - permissions::PermissionsStore, rayon::blocking_compute, GQLError, }; @@ -28,7 +28,6 @@ use std::{ path::{Path, PathBuf}, sync::Arc, }; -use tokio::sync::RwLock; use tracing::{error, warn}; use walkdir::WalkDir; @@ -138,7 +137,7 @@ pub struct Data { pub(crate) create_index: bool, pub(crate) embedding_conf: Option, pub(crate) graph_conf: Config, - pub(crate) permissions: Option>>, + pub(crate) auth_policy: Option>, } impl Data { @@ -170,23 +169,13 @@ impl Data { #[cfg(not(feature = "search"))] let create_index = false; - let permissions = configs.permissions_store_path.as_ref().and_then(|path| { - match PermissionsStore::load(path) { - Ok(store) => Some(Arc::new(RwLock::new(store))), - Err(e) => { - eprintln!("Warning: failed to load permissions store from {path:?}: {e}"); - None - } - } - }); - Self { work_dir: work_dir.to_path_buf(), cache, create_index, embedding_conf: Default::default(), graph_conf, - permissions, + auth_policy: None, } } diff --git a/raphtory-graphql/src/lib.rs b/raphtory-graphql/src/lib.rs index 4733180e67..9a93ebf38a 100644 --- a/raphtory-graphql/src/lib.rs +++ b/raphtory-graphql/src/lib.rs @@ -4,6 +4,7 @@ use raphtory::errors::GraphError; use std::sync::Arc; mod auth; +pub mod auth_policy; pub mod client; pub mod data; mod embeddings; @@ -11,7 +12,6 @@ mod graph; pub mod model; pub mod observability; mod paths; -pub mod permissions; mod routes; pub mod server; pub mod url_encode; diff --git a/raphtory-graphql/src/model/graph/graph.rs b/raphtory-graphql/src/model/graph/graph.rs index 20cfd8d75b..dc9d50cd8d 100644 --- a/raphtory-graphql/src/model/graph/graph.rs +++ b/raphtory-graphql/src/model/graph/graph.rs @@ -18,7 +18,6 @@ use crate::{ schema::graph_schema::GraphSchema, }, paths::{ExistingGraphFolder, PathValidationError, ValidGraphPaths}, - permissions::GraphPermissions, rayon::blocking_compute, GQLError, }; @@ -63,11 +62,8 @@ use std::{ pub(crate) struct GqlGraph { path: ExistingGraphFolder, graph: DynamicGraph, - /// When `Some`, permissions from the store are active for this request. - /// `None` means the server has no permissions store configured (full access). - graph_permissions: Option>, /// Whether this role may perform introspection on this graph - /// (countNodes, countEdges, uniqueLayers). Derived from permissions store at request time. + /// (countNodes, countEdges, uniqueLayers, schema). Derived from auth policy at request time. introspection_allowed: bool, } @@ -82,7 +78,6 @@ impl GqlGraph { Self { path, graph: graph.into_dynamic(), - graph_permissions: None, introspection_allowed: true, } } @@ -90,13 +85,11 @@ impl GqlGraph { pub fn new_with_permissions( path: ExistingGraphFolder, graph: G, - permissions: Option, introspection_allowed: bool, ) -> Self { Self { path, graph: graph.into_dynamic(), - graph_permissions: permissions.map(Arc::new), introspection_allowed, } } @@ -109,33 +102,10 @@ impl GqlGraph { Self { path: self.path.clone(), graph: graph_operation(&self.graph).into_dynamic(), - graph_permissions: self.graph_permissions.clone(), introspection_allowed: self.introspection_allowed, } } - fn require_node_access(&self) -> Result<()> { - if let Some(perms) = &self.graph_permissions { - if perms.nodes.is_none() { - return Err(async_graphql::Error::new( - "Access denied: node access is not permitted for this role", - )); - } - } - Ok(()) - } - - fn require_edge_access(&self) -> Result<()> { - if let Some(perms) = &self.graph_permissions { - if perms.edges.is_none() { - return Err(async_graphql::Error::new( - "Access denied: edge access is not permitted for this role", - )); - } - } - Ok(()) - } - fn require_introspection(&self) -> Result<()> { if !self.introspection_allowed { return Err(async_graphql::Error::new( @@ -435,13 +405,11 @@ impl GqlGraph { /// Returns true if the graph contains the specified node. async fn has_node(&self, name: String) -> Result { - self.require_node_access()?; Ok(self.graph.has_node(name)) } /// Returns true if the graph contains the specified edge. Edges are specified by providing a source and destination node id. You can restrict the search to a specified layer. async fn has_edge(&self, src: String, dst: String, layer: Option) -> Result { - self.require_edge_access()?; Ok(match layer { Some(name) => self .graph @@ -458,13 +426,11 @@ impl GqlGraph { /// Gets the node with the specified id. async fn node(&self, name: String) -> Result> { - self.require_node_access()?; Ok(self.graph.node(name).map(|node| node.into())) } /// Gets (optionally a subset of) the nodes in the graph. async fn nodes(&self, select: Option) -> Result { - self.require_node_access()?; let nn = self.graph.nodes(); if let Some(sel) = select { @@ -482,13 +448,11 @@ impl GqlGraph { /// Gets the edge with the specified source and destination nodes. async fn edge(&self, src: String, dst: String) -> Result> { - self.require_edge_access()?; Ok(self.graph.edge(src, dst).map(|e| e.into())) } /// Gets the edges in the graph. async fn edges<'a>(&self, select: Option) -> Result { - self.require_edge_access()?; let base = self.graph.edges_unlocked(); if let Some(sel) = select { @@ -552,7 +516,6 @@ impl GqlGraph { } async fn shared_neighbours(&self, selected_nodes: Vec) -> Result> { - self.require_node_access()?; let self_clone = self.clone(); Ok(blocking_compute(move || { if selected_nodes.is_empty() { @@ -671,7 +634,7 @@ impl GqlGraph { limit: usize, offset: usize, ) -> Result> { - self.require_node_access()?; + #[cfg(feature = "search")] { let self_clone = self.clone(); @@ -698,7 +661,7 @@ impl GqlGraph { limit: usize, offset: usize, ) -> Result> { - self.require_edge_access()?; + #[cfg(feature = "search")] { let self_clone = self.clone(); diff --git a/raphtory-graphql/src/model/mod.rs b/raphtory-graphql/src/model/mod.rs index b3b2c75416..dbfdfb4833 100644 --- a/raphtory-graphql/src/model/mod.rs +++ b/raphtory-graphql/src/model/mod.rs @@ -7,10 +7,12 @@ use crate::{ mutable_graph::GqlMutableGraph, namespace::Namespace, namespaced_item::NamespacedItem, vectorised_graph::GqlVectorisedGraph, }, - plugins::{mutation_plugin::MutationPlugin, query_plugin::QueryPlugin}, + plugins::{ + mutation_plugin::MutationPlugin, permissions_plugin::PermissionsPlugin, + query_plugin::QueryPlugin, + }, }, paths::{ValidGraphPaths, ValidWriteableGraphFolder}, - permissions::GraphPermissions, rayon::blocking_compute, url_encode::{url_decode_graph_at, url_encode_graph}, }; @@ -36,7 +38,7 @@ use std::{ error::Error, fmt::{Display, Formatter}, }; -use tracing::{debug, warn}; +use tracing::warn; pub(crate) mod graph; pub mod plugins; @@ -100,49 +102,27 @@ impl QueryRoot { /// Returns a graph async fn graph<'a>(ctx: &Context<'a>, path: &str) -> Result { let data = ctx.data_unchecked::(); - - let (graph_perms, introspection_allowed): (Option, bool) = - if let Some(store) = &data.permissions { - let store = store.read().await; - let role = ctx - .data::>() - .ok() - .and_then(|r| r.as_deref()) - .unwrap_or(""); - debug!(role = role, graph = path, "Checking permissions store"); - match store.get_graph_permissions(role, path) { - None => { - warn!( - role = role, - graph = path, - "Access denied: no matching permission entry" - ); - return Err(async_graphql::Error::new(format!( - "Access denied: role '{role}' is not permitted to access graph '{path}'" - ))); - } - Some(perms) => { - let introspection = store.is_graph_introspection_allowed(role, path); - debug!( - role = role, - graph = path, - nodes = ?perms.nodes, - edges = ?perms.edges, - introspection = introspection, - "Permission granted" - ); - (Some(perms.clone()), introspection) - } + let role = ctx.data::>().ok().and_then(|r| r.as_deref()); + + let introspection_allowed = if let Some(policy) = &data.auth_policy { + match policy.check_graph_access(role, path) { + Some(false) | None => { + let role_str = role.unwrap_or(""); + warn!(role = role_str, graph = path, "Access denied by auth policy"); + return Err(async_graphql::Error::new(format!( + "Access denied: role '{role_str}' is not permitted to access graph '{path}'" + ))); } - } else { - (None, true) // no store -> unrestricted - }; + Some(true) => policy.check_graph_introspection(role, path), + } + } else { + true // no policy -> unrestricted + }; let graph_with_vecs = data.get_graph(path).await?; Ok(GqlGraph::new_with_permissions( graph_with_vecs.folder, graph_with_vecs.graph, - graph_perms, introspection_allowed, )) } @@ -237,6 +217,11 @@ impl Mut { MutationPlugin::default() } + /// Returns the permissions namespace for managing roles and access policies. + async fn permissions<'a>(_ctx: &Context<'a>) -> PermissionsPlugin { + PermissionsPlugin::default() + } + /// Delete graph from a path on the server. // If namespace is not provided, it will be set to the current working directory. async fn delete_graph<'a>(ctx: &Context<'a>, path: String) -> Result { diff --git a/raphtory-graphql/src/model/plugins/mod.rs b/raphtory-graphql/src/model/plugins/mod.rs index d499e1d39c..d2c0ebb76e 100644 --- a/raphtory-graphql/src/model/plugins/mod.rs +++ b/raphtory-graphql/src/model/plugins/mod.rs @@ -7,7 +7,20 @@ pub mod graph_algorithm_plugin; pub mod mutation_entry_point; pub mod mutation_plugin; pub mod operation; +pub mod permissions_plugin; pub mod query_entry_point; pub mod query_plugin; pub type RegisterFunction = Box (Registry, Object) + Send>; + +/// Register an operation into the `PermissionsPlugin` entry point. +/// Call this before `GraphServer::run()` / `create_schema()`. +pub fn register_permissions_mutation(name: &'static str) +where + O: for<'a> operation::Operation<'a, permissions_plugin::PermissionsPlugin> + 'static, +{ + permissions_plugin::PERMISSIONS_MUTATIONS + .lock() + .unwrap() + .insert(name.to_string(), Box::new(O::register_operation)); +} diff --git a/raphtory-graphql/src/model/plugins/operation.rs b/raphtory-graphql/src/model/plugins/operation.rs index 43e7ae51f7..b22819868c 100644 --- a/raphtory-graphql/src/model/plugins/operation.rs +++ b/raphtory-graphql/src/model/plugins/operation.rs @@ -37,6 +37,27 @@ pub trait Operation<'a, A: Send + Sync + 'static> { } } +pub(crate) struct NoOpPermissions; + +impl<'a> Operation<'a, super::permissions_plugin::PermissionsPlugin> for NoOpPermissions { + type OutputType = String; + + fn output_type() -> TypeRef { + TypeRef::named_nn(TypeRef::STRING) + } + + fn args<'b>() -> Vec<(&'b str, TypeRef)> { + vec![] + } + + fn apply<'b>( + _entry_point: &super::permissions_plugin::PermissionsPlugin, + _ctx: ResolverContext<'b>, + ) -> BoxFuture<'b, FieldResult>>> { + Box::pin(async move { Ok(Some(FieldValue::value("no-op".to_owned()))) }) + } +} + pub(crate) struct NoOpMutation; impl<'a> Operation<'a, MutationPlugin> for NoOpMutation { diff --git a/raphtory-graphql/src/model/plugins/permissions_plugin.rs b/raphtory-graphql/src/model/plugins/permissions_plugin.rs new file mode 100644 index 0000000000..39561146b2 --- /dev/null +++ b/raphtory-graphql/src/model/plugins/permissions_plugin.rs @@ -0,0 +1,51 @@ +use super::{ + operation::{NoOpPermissions, Operation}, + RegisterFunction, +}; +use crate::model::plugins::entry_point::EntryPoint; +use async_graphql::{dynamic::FieldValue, indexmap::IndexMap, Context}; +use dynamic_graphql::internal::{OutputTypeName, Register, Registry, ResolveOwned, TypeName}; +use once_cell::sync::Lazy; +use std::{ + borrow::Cow, + sync::{Mutex, MutexGuard}, +}; + +pub static PERMISSIONS_MUTATIONS: Lazy>> = + Lazy::new(|| Mutex::new(IndexMap::new())); + +#[derive(Clone, Default)] +pub struct PermissionsPlugin; + +impl<'a> EntryPoint<'a> for PermissionsPlugin { + fn predefined_operations() -> IndexMap<&'static str, RegisterFunction> { + IndexMap::from([( + "NoOps", + Box::new(NoOpPermissions::register_operation) as RegisterFunction, + )]) + } + + fn lock_plugins() -> MutexGuard<'static, IndexMap> { + PERMISSIONS_MUTATIONS.lock().unwrap() + } +} + +impl Register for PermissionsPlugin { + fn register(registry: Registry) -> Registry { + Self::register_operations(registry) + } +} + +impl TypeName for PermissionsPlugin { + fn get_type_name() -> Cow<'static, str> { + "PermissionsPlugin".into() + } +} + +impl OutputTypeName for PermissionsPlugin {} + +impl<'a> ResolveOwned<'a> for PermissionsPlugin { + fn resolve_owned(self, _ctx: &Context) -> dynamic_graphql::Result>> { + Ok(Some(FieldValue::owned_any(self))) + } +} diff --git a/raphtory-graphql/src/permissions.rs b/raphtory-graphql/src/permissions.rs deleted file mode 100644 index bb87130ec2..0000000000 --- a/raphtory-graphql/src/permissions.rs +++ /dev/null @@ -1,96 +0,0 @@ -use serde::Deserialize; -use std::{collections::HashMap, fs, path::Path}; -use thiserror::Error; - -#[derive(Debug, Error)] -pub enum PermissionsError { - #[error("Failed to read permissions store file: {0}")] - Io(#[from] std::io::Error), - #[error("Failed to parse permissions store file: {0}")] - Parse(#[from] serde_json::Error), -} - -/// Read-only (`ro`) or read-write (`rw`) access level. -#[derive(Debug, Deserialize, Clone, PartialEq)] -#[serde(rename_all = "lowercase")] -pub enum ContentAccess { - Ro, - Rw, -} - -#[derive(Debug, Deserialize, Clone)] -pub struct GraphPermissions { - /// Graph name this entry applies to. Use `"*"` to match all graphs. - pub name: String, - /// Access level for nodes. Absent means denied. - pub nodes: Option, - /// Access level for edges. Absent means denied. - pub edges: Option, - /// Per-graph introspection override (counts, uniqueLayers, schema stats). - /// When absent, falls back to the role-level `introspection` setting. - pub introspection: Option, -} - -#[derive(Debug, Deserialize, Clone)] -pub struct RolePermissions { - pub graphs: Vec, - /// Whether this role may perform GraphQL schema introspection (`__schema` / `__type`). - /// Defaults to `false` — deny introspection unless explicitly granted. - #[serde(default)] - pub introspection: bool, -} - -#[derive(Debug, Deserialize)] -pub struct PermissionsStore { - pub roles: HashMap, -} - -impl PermissionsStore { - pub fn load(path: &Path) -> Result { - let content = fs::read_to_string(path)?; - let store = serde_json::from_str(&content)?; - Ok(store) - } - - /// Returns the matching `GraphPermissions` entry for the given role and graph name, - /// or `None` if the role has no entry covering that graph. - /// Wildcard entry (`name: "*"`) matches any graph but a specific entry takes precedence. - pub fn get_graph_permissions<'a>( - &'a self, - role: &str, - graph: &str, - ) -> Option<&'a GraphPermissions> { - let role_perms = self.roles.get(role)?; - let specific = role_perms.graphs.iter().find(|g| g.name == graph); - if specific.is_some() { - return specific; - } - role_perms.graphs.iter().find(|g| g.name == "*") - } - - /// Returns whether the given role is allowed to perform GraphQL schema introspection - /// (`__schema` / `__type`). Uses the role-level setting only (schema is not graph-specific). - /// Defaults to `false` when the role is not found. - pub fn is_introspection_allowed(&self, role: &str) -> bool { - self.roles - .get(role) - .map(|r| r.introspection) - .unwrap_or(false) - } - - /// Returns whether the given role is allowed to perform introspection on a specific graph - /// (e.g. `countNodes`, `countEdges`, `uniqueLayers`). - /// The per-graph `introspection` field takes precedence; falls back to the role-level setting. - pub fn is_graph_introspection_allowed(&self, role: &str, graph: &str) -> bool { - let role_perms = match self.roles.get(role) { - Some(r) => r, - None => return false, - }; - if let Some(graph_perms) = self.get_graph_permissions(role, graph) { - if let Some(override_val) = graph_perms.introspection { - return override_val; - } - } - role_perms.introspection - } -} diff --git a/raphtory-graphql/src/python/server/server.rs b/raphtory-graphql/src/python/server/server.rs index ef8387d96c..96e3108cf6 100644 --- a/raphtory-graphql/src/python/server/server.rs +++ b/raphtory-graphql/src/python/server/server.rs @@ -86,7 +86,7 @@ impl PyGraphServer { impl PyGraphServer { #[new] #[pyo3( - signature = (work_dir, cache_capacity = None, cache_tti_seconds = None, log_level = None, tracing=None, tracing_level=None, otlp_agent_host=None, otlp_agent_port=None, otlp_tracing_service_name=None, auth_public_key=None, auth_enabled_for_reads=None, config_path = None, create_index = None, permissions_store_path = None) + signature = (work_dir, cache_capacity = None, cache_tti_seconds = None, log_level = None, tracing=None, tracing_level=None, otlp_agent_host=None, otlp_agent_port=None, otlp_tracing_service_name=None, auth_public_key=None, auth_enabled_for_reads=None, config_path = None, create_index = None) )] fn py_new( work_dir: PathBuf, @@ -102,7 +102,6 @@ impl PyGraphServer { auth_enabled_for_reads: Option, config_path: Option, create_index: Option, - permissions_store_path: Option, ) -> PyResult { let mut app_config_builder = AppConfigBuilder::new(); if let Some(log_level) = log_level { @@ -148,10 +147,6 @@ impl PyGraphServer { if let Some(create_index) = create_index { app_config_builder = app_config_builder.with_create_index(create_index); } - if let Some(permissions_store_path) = permissions_store_path { - app_config_builder = - app_config_builder.with_permissions_store_path(Some(permissions_store_path)); - } let app_config = Some(app_config_builder.build()); let server = GraphServer::new(work_dir, app_config, config_path, Config::default())?; diff --git a/raphtory-graphql/src/server.rs b/raphtory-graphql/src/server.rs index 0a37aaaca2..3bb7e141e3 100644 --- a/raphtory-graphql/src/server.rs +++ b/raphtory-graphql/src/server.rs @@ -1,5 +1,6 @@ use crate::{ auth::{AuthenticatedGraphQL, MutationAuth}, + auth_policy::AuthorizationPolicy, config::app_config::{load_config, AppConfig}, data::{Data, EmbeddingConf}, model::{ @@ -7,7 +8,6 @@ use crate::{ App, }, observability::open_telemetry::OpenTelemetry, - permissions::PermissionsStore, routes::{health, version, PublicFilesEndpoint}, server::ServerError::SchemaError, }; @@ -79,10 +79,14 @@ impl From for io::Error { } } +type SchemaDataInjector = + Box async_graphql::dynamic::SchemaBuilder + Send + Sync>; + /// A struct for defining and running a Raphtory GraphQL server pub struct GraphServer { data: Data, config: AppConfig, + schema_data: Vec, } pub fn register_query_plugin< @@ -121,7 +125,11 @@ impl GraphServer { } let config = load_config(app_config, config_path).map_err(ServerError::ConfigError)?; let data = Data::new(work_dir.as_path(), &config, graph_config); - Ok(Self { data, config }) + Ok(Self { + data, + config, + schema_data: Vec::new(), + }) } /// Turn off index for all graphs @@ -130,6 +138,18 @@ impl GraphServer { self } + /// Set the authorization policy used for graph access checks. + pub fn with_auth_policy(mut self, policy: std::sync::Arc) -> Self { + self.data.auth_policy = Some(policy); + self + } + + /// Inject arbitrary typed data into the GQL schema (accessible via `ctx.data::()`). + pub fn with_schema_data(mut self, data: T) -> Self { + self.schema_data.push(Box::new(move |sb| sb.data(data))); + self + } + pub async fn set_embeddings( mut self, embedding: F, @@ -213,36 +233,6 @@ impl GraphServer { } }); - // Hot-reload permissions store when the file changes (polls every 5s) - if let (Some(path), Some(permissions)) = ( - self.config.permissions_store_path.clone(), - self.data.permissions.clone(), - ) { - tokio::spawn(async move { - let mut last_modified = std::fs::metadata(&path).and_then(|m| m.modified()).ok(); - let mut interval = tokio::time::interval(std::time::Duration::from_secs(5)); - loop { - interval.tick().await; - let current_modified = std::fs::metadata(&path).and_then(|m| m.modified()).ok(); - if current_modified != last_modified { - match PermissionsStore::load(&path) { - Ok(new_store) => { - *permissions.write().await = new_store; - info!("Permissions store reloaded from {:?}", path); - last_modified = current_modified; - } - Err(e) => { - warn!( - error = %e, - "Failed to reload permissions store — keeping old. Restart server to load new permissions." - ); - } - } - } - } - }); - } - // it is important that this runs after algorithms have been pushed to PLUGIN_ALGOS static variable let app = self .generate_endpoint(tp.clone().map(|tp| tp.tracer(tracer_name))) @@ -273,11 +263,12 @@ impl GraphServer { self, tracer: Option, ) -> Result>, ServerError> { - let permissions = self.data.permissions.clone(); - - let schema_builder = App::create_schema(); - let schema_builder = schema_builder.data(self.data); - let schema_builder = schema_builder.extension(MutationAuth); + let mut schema_builder = App::create_schema(); + schema_builder = schema_builder.data(self.data); + for inject in self.schema_data { + schema_builder = inject(schema_builder); + } + schema_builder = schema_builder.extension(MutationAuth); let trace_level = self.config.tracing.tracing_level.clone(); let schema = if let Some(t) = tracer { schema_builder @@ -293,7 +284,7 @@ impl GraphServer { "/", PublicFilesEndpoint::new( self.config.public_dir, - AuthenticatedGraphQL::new(schema, self.config.auth, permissions), + AuthenticatedGraphQL::new(schema, self.config.auth), ), ) .at("/health", get(health)) From c0251a7fa2a853195862d521edde2da4f44357d1 Mon Sep 17 00:00:00 2001 From: shivamka1 <4599890+shivamka1@users.noreply.github.com> Date: Tue, 10 Mar 2026 14:23:08 +0000 Subject: [PATCH 05/64] fix circular dep --- Cargo.lock | 60 +++++++------------- Cargo.toml | 7 ++- python/Cargo.toml | 3 +- python/src/lib.rs | 5 +- python/tests/test_permissions.py | 17 ++++-- python/tox.ini | 3 + raphtory-auth-noop/Cargo.toml | 6 ++ raphtory-auth-noop/src/lib.rs | 1 + raphtory-graphql/src/cli.rs | 16 ++++-- raphtory-graphql/src/python/pymodule.rs | 7 +++ raphtory-graphql/src/python/server/server.rs | 5 +- raphtory-graphql/src/server.rs | 23 ++++++++ raphtory-server/Cargo.toml | 13 +++++ raphtory-server/src/main.rs | 7 +++ 14 files changed, 116 insertions(+), 57 deletions(-) create mode 100644 raphtory-auth-noop/Cargo.toml create mode 100644 raphtory-auth-noop/src/lib.rs create mode 100644 raphtory-server/Cargo.toml create mode 100644 raphtory-server/src/main.rs diff --git a/Cargo.lock b/Cargo.lock index 53d5b3b401..c8953c9187 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1059,23 +1059,27 @@ dependencies = [ [[package]] name = "clam-core" -version = "0.17.0" +version = "0.1.0" dependencies = [ "ahash", "arrow", "async-trait", + "bincode 1.3.3", "chrono", - "chrono-tz 0.10.4", + "chrono-tz 0.8.6", "comfy-table", + "crc32fast", "criterion", + "dashmap", "db4-storage", "env_logger 0.10.2", "fastrand", "flate2", "insta", - "itertools 0.13.0", + "itertools 0.14.0", "log", "nom", + "once_cell", "optd-core", "parking_lot", "proptest", @@ -1093,8 +1097,7 @@ dependencies = [ "slotmap", "snb", "tempfile", - "thiserror 2.0.17", - "tikv-jemallocator", + "thiserror 1.0.69", "tokio", "tracing", "tracing-test", @@ -3640,7 +3643,7 @@ dependencies = [ [[package]] name = "optd-core" -version = "0.17.0" +version = "0.1.0" dependencies = [ "anyhow", "bitvec", @@ -4677,18 +4680,8 @@ dependencies = [ ] [[package]] -name = "raphtory-auth" +name = "raphtory-auth-noop" version = "0.17.0" -dependencies = [ - "async-graphql", - "dynamic-graphql", - "futures-util", - "pyo3", - "raphtory-graphql", - "serde", - "serde_json", - "thiserror 2.0.17", -] [[package]] name = "raphtory-benchmark" @@ -4808,10 +4801,19 @@ dependencies = [ "pyo3", "pyo3-build-config", "raphtory", - "raphtory-auth", + "raphtory-auth-noop", "raphtory-graphql", ] +[[package]] +name = "raphtory-server" +version = "0.17.0" +dependencies = [ + "raphtory-auth-noop", + "raphtory-graphql", + "tokio", +] + [[package]] name = "raphtory-storage" version = "0.17.0" @@ -5584,7 +5586,7 @@ checksum = "1b6b67fb9a61334225b5b790716f609cd58395f895b3fe8b328786812a40bc3b" [[package]] name = "snb" -version = "0.17.0" +version = "0.1.0" dependencies = [ "chrono", "flate2", @@ -6039,26 +6041,6 @@ dependencies = [ "ordered-float 2.10.1", ] -[[package]] -name = "tikv-jemalloc-sys" -version = "0.6.1+5.3.0-1-ge13ca993e8ccb9ba9847cc330696e02839f328f7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cd8aa5b2ab86a2cefa406d889139c162cbb230092f7d1d7cbc1716405d852a3b" -dependencies = [ - "cc", - "libc", -] - -[[package]] -name = "tikv-jemallocator" -version = "0.6.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0359b4327f954e0567e69fb191cf1436617748813819c94b8cd4a431422d053a" -dependencies = [ - "libc", - "tikv-jemalloc-sys", -] - [[package]] name = "time" version = "0.3.45" diff --git a/Cargo.toml b/Cargo.toml index c831acae15..f79eef5551 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -7,6 +7,8 @@ members = [ "examples/custom-gql-apis", "python", "raphtory-graphql", + "raphtory-auth-noop", + "raphtory-server", "raphtory-api", "raphtory-core", "raphtory-storage", @@ -61,7 +63,6 @@ raphtory-api = { version = "0.17.0", path = "raphtory-api", default-features = f raphtory-api-macros = { version = "0.17.0", path = "raphtory-api-macros", default-features = false } raphtory-core = { version = "0.17.0", path = "raphtory-core", default-features = false } raphtory-graphql = { version = "0.17.0", path = "raphtory-graphql", default-features = false } -raphtory-auth = { version = "0.17.0", path = "../members/raphtory-auth" } raphtory-storage = { version = "0.17.0", path = "raphtory-storage", default-features = false } raphtory-itertools = { version = "0.17.0", path = "raphtory-itertools" } clam-core = { path = "clam-core" } @@ -193,3 +194,7 @@ test-log = "0.2.19" [workspace.dependencies.storage] package = "db4-storage" path = "db4-storage" + +[workspace.dependencies.auth] +package = "raphtory-auth-noop" +path = "raphtory-auth-noop" diff --git a/python/Cargo.toml b/python/Cargo.toml index 87003db67c..f4ce6a9c5b 100644 --- a/python/Cargo.toml +++ b/python/Cargo.toml @@ -26,10 +26,9 @@ raphtory = { workspace = true, features = [ raphtory-graphql = { workspace = true, features = [ "python", ] } -raphtory-auth = { workspace = true, features = ["python"] } +auth = { workspace = true } clam-core = { path = "../clam-core", version = "0.17.0", features = ["python"] } - [features] extension-module = ["pyo3/extension-module"] search = ["raphtory/search", "raphtory-graphql/search"] diff --git a/python/src/lib.rs b/python/src/lib.rs index 696b024c7a..22d2dba082 100644 --- a/python/src/lib.rs +++ b/python/src/lib.rs @@ -13,13 +13,10 @@ use raphtory_graphql::python::pymodule::base_graphql_module; /// Raphtory graph analytics library #[pymodule] fn _raphtory(py: Python<'_>, m: &Bound) -> PyResult<()> { + auth::init(); let _ = add_raphtory_classes(m); let graphql_module = base_graphql_module(py)?; - graphql_module.add_function(wrap_pyfunction!( - raphtory_auth::python::with_permissions_store, - &graphql_module - )?)?; let algorithm_module = base_algorithm_module(py)?; let graph_loader_module = base_graph_loader_module(py)?; let graph_gen_module = base_graph_gen_module(py)?; diff --git a/python/tests/test_permissions.py b/python/tests/test_permissions.py index 575e7ac514..debbf63260 100644 --- a/python/tests/test_permissions.py +++ b/python/tests/test_permissions.py @@ -3,7 +3,13 @@ import tempfile import requests import jwt -from raphtory.graphql import GraphServer, with_permissions_store +import pytest +from raphtory.graphql import GraphServer, has_permissions_extension + +pytestmark = pytest.mark.skipif( + not has_permissions_extension(), + reason="raphtory-auth not compiled in (open-source build)", +) # Reuse the same key pair as test_auth.py PUB_KEY = "MCowBQYDK2VwAyEADdrWr1kTLj+wSHlr45eneXmOjlHo3N1DjLIvDa2ozno=" @@ -54,9 +60,10 @@ def grant_namespace(role: str, path: str, permissions: list) -> None: def make_server(work_dir: str): """Create a GraphServer wired with a permissions store at {work_dir}/permissions.json.""" - return with_permissions_store( - GraphServer(work_dir, auth_public_key=PUB_KEY), - os.path.join(work_dir, "permissions.json"), + return GraphServer( + work_dir, + auth_public_key=PUB_KEY, + permissions_store_path=os.path.join(work_dir, "permissions.json"), ) @@ -121,7 +128,7 @@ def test_no_role_is_denied_when_policy_is_active(): def test_no_policy_gives_full_access(): - """Without with_permissions_store, all authenticated users see everything.""" + """Without permissions_store_path, all authenticated users see everything.""" work_dir = tempfile.mkdtemp() with GraphServer(work_dir, auth_public_key=PUB_KEY).start(): gql(CREATE_JIRA) diff --git a/python/tox.ini b/python/tox.ini index b716ef94ef..6c18de443b 100644 --- a/python/tox.ini +++ b/python/tox.ini @@ -40,6 +40,9 @@ commands = pytest --nbmake --nbmake-timeout=1200 {tty:--color=yes} tests/test_ba [testenv:auth] commands = pytest tests/test_auth.py +[testenv:permissions] +commands = pytest tests/test_permissions.py + [testenv:vectors] commands = pytest tests/test_vectors diff --git a/raphtory-auth-noop/Cargo.toml b/raphtory-auth-noop/Cargo.toml new file mode 100644 index 0000000000..7b20f3a52c --- /dev/null +++ b/raphtory-auth-noop/Cargo.toml @@ -0,0 +1,6 @@ +[package] +name = "raphtory-auth-noop" +version.workspace = true +edition.workspace = true + +[dependencies] diff --git a/raphtory-auth-noop/src/lib.rs b/raphtory-auth-noop/src/lib.rs new file mode 100644 index 0000000000..12cd021c75 --- /dev/null +++ b/raphtory-auth-noop/src/lib.rs @@ -0,0 +1 @@ +pub fn init() {} diff --git a/raphtory-graphql/src/cli.rs b/raphtory-graphql/src/cli.rs index 4cc5190322..618f70518c 100644 --- a/raphtory-graphql/src/cli.rs +++ b/raphtory-graphql/src/cli.rs @@ -12,7 +12,7 @@ use crate::{ }, }, model::App, - server::DEFAULT_PORT, + server::{apply_server_extension, DEFAULT_PORT}, GraphServer, }; use clap::{Parser, Subcommand}; @@ -81,6 +81,9 @@ struct ServerArgs { #[arg(long, env = "RAPHTORY_PUBLIC_DIR", default_value = None, help = "Public directory path")] public_dir: Option, + #[arg(long, env = "RAPHTORY_PERMISSIONS_STORE_PATH", default_value = None, help = "Path to the JSON permissions store file")] + permissions_store_path: Option, + #[cfg(feature = "search")] #[arg(long, env = "RAPHTORY_CREATE_INDEX", default_value_t = DEFAULT_CREATE_INDEX, help = "Enable index creation")] create_index: bool, @@ -123,14 +126,17 @@ where let app_config = Some(builder.build()); - GraphServer::new( + let server = GraphServer::new( server_args.work_dir, app_config, None, server_args.graph_config, - )? - .run_with_port(server_args.port) - .await?; + )?; + let server = apply_server_extension( + server, + server_args.permissions_store_path.as_deref(), + ); + server.run_with_port(server_args.port).await?; } } Ok(()) diff --git a/raphtory-graphql/src/python/pymodule.rs b/raphtory-graphql/src/python/pymodule.rs index 53c00759b6..4117b35eb1 100644 --- a/raphtory-graphql/src/python/pymodule.rs +++ b/raphtory-graphql/src/python/pymodule.rs @@ -13,6 +13,12 @@ use crate::{ }; use pyo3::prelude::*; +/// Returns True if the permissions extension (raphtory-auth) is compiled in. +#[pyfunction] +pub fn has_permissions_extension() -> bool { + crate::server::has_server_extension() +} + pub fn base_graphql_module(py: Python<'_>) -> Result, PyErr> { let graphql_module = PyModule::new(py, "graphql")?; graphql_module.add_class::()?; @@ -33,6 +39,7 @@ pub fn base_graphql_module(py: Python<'_>) -> Result, PyErr> graphql_module.add_function(wrap_pyfunction!(decode_graph, &graphql_module)?)?; graphql_module.add_function(wrap_pyfunction!(schema, &graphql_module)?)?; graphql_module.add_function(wrap_pyfunction!(python_cli, &graphql_module)?)?; + graphql_module.add_function(wrap_pyfunction!(has_permissions_extension, &graphql_module)?)?; Ok(graphql_module) } diff --git a/raphtory-graphql/src/python/server/server.rs b/raphtory-graphql/src/python/server/server.rs index 96e3108cf6..389b91d42a 100644 --- a/raphtory-graphql/src/python/server/server.rs +++ b/raphtory-graphql/src/python/server/server.rs @@ -6,6 +6,7 @@ use crate::{ python::server::{ running_server::PyRunningGraphServer, take_server_ownership, wait_server, BridgeCommand, }, + server::apply_server_extension, GraphServer, }; use pyo3::{ @@ -86,7 +87,7 @@ impl PyGraphServer { impl PyGraphServer { #[new] #[pyo3( - signature = (work_dir, cache_capacity = None, cache_tti_seconds = None, log_level = None, tracing=None, tracing_level=None, otlp_agent_host=None, otlp_agent_port=None, otlp_tracing_service_name=None, auth_public_key=None, auth_enabled_for_reads=None, config_path = None, create_index = None) + signature = (work_dir, cache_capacity = None, cache_tti_seconds = None, log_level = None, tracing=None, tracing_level=None, otlp_agent_host=None, otlp_agent_port=None, otlp_tracing_service_name=None, auth_public_key=None, auth_enabled_for_reads=None, config_path = None, create_index = None, permissions_store_path = None) )] fn py_new( work_dir: PathBuf, @@ -102,6 +103,7 @@ impl PyGraphServer { auth_enabled_for_reads: Option, config_path: Option, create_index: Option, + permissions_store_path: Option, ) -> PyResult { let mut app_config_builder = AppConfigBuilder::new(); if let Some(log_level) = log_level { @@ -150,6 +152,7 @@ impl PyGraphServer { let app_config = Some(app_config_builder.build()); let server = GraphServer::new(work_dir, app_config, config_path, Config::default())?; + let server = apply_server_extension(server, permissions_store_path.as_deref()); Ok(PyGraphServer::new(server)) } diff --git a/raphtory-graphql/src/server.rs b/raphtory-graphql/src/server.rs index 3bb7e141e3..ebe4325cf0 100644 --- a/raphtory-graphql/src/server.rs +++ b/raphtory-graphql/src/server.rs @@ -27,9 +27,11 @@ use raphtory::{ vectors::{cache::VectorCache, embeddings::EmbeddingFunction, template::DocumentTemplate}, }; use serde_json::json; +use once_cell::sync::Lazy; use std::{ fs::create_dir_all, path::{Path, PathBuf}, + sync::RwLock, }; use thiserror::Error; use tokio::{ @@ -51,6 +53,27 @@ use url::ParseError; pub const DEFAULT_PORT: u16 = 1736; +type ServerExtensionFn = + Box) -> GraphServer + Send + Sync>; + +static SERVER_EXTENSION: Lazy>> = + Lazy::new(|| RwLock::new(None)); + +pub fn register_server_extension(f: ServerExtensionFn) { + *SERVER_EXTENSION.write().unwrap() = Some(f); +} + +pub fn apply_server_extension(server: GraphServer, path: Option<&Path>) -> GraphServer { + match SERVER_EXTENSION.read().unwrap().as_ref() { + Some(ext) => ext(server, path), + None => server, + } +} + +pub fn has_server_extension() -> bool { + SERVER_EXTENSION.read().unwrap().is_some() +} + #[derive(Error, Debug)] pub enum ServerError { #[error("Config error: {0}")] diff --git a/raphtory-server/Cargo.toml b/raphtory-server/Cargo.toml new file mode 100644 index 0000000000..8015995507 --- /dev/null +++ b/raphtory-server/Cargo.toml @@ -0,0 +1,13 @@ +[package] +name = "raphtory-server" +version.workspace = true +edition.workspace = true + +[[bin]] +name = "raphtory-server" +path = "src/main.rs" + +[dependencies] +raphtory-graphql = { workspace = true } +auth = { workspace = true } +tokio = { workspace = true } diff --git a/raphtory-server/src/main.rs b/raphtory-server/src/main.rs new file mode 100644 index 0000000000..733c411eb4 --- /dev/null +++ b/raphtory-server/src/main.rs @@ -0,0 +1,7 @@ +use std::io::Result as IoResult; + +#[tokio::main] +async fn main() -> IoResult<()> { + auth::init(); + raphtory_graphql::cli::cli().await +} From 11eee0caf1a1aedb5c8d14d565941c6258276d47 Mon Sep 17 00:00:00 2001 From: shivamka1 <4599890+shivamka1@users.noreply.github.com> Date: Tue, 10 Mar 2026 14:24:42 +0000 Subject: [PATCH 06/64] fmt --- raphtory-graphql/src/cli.rs | 6 ++---- raphtory-graphql/src/model/graph/graph.rs | 2 -- raphtory-graphql/src/model/mod.rs | 6 +++++- raphtory-graphql/src/python/pymodule.rs | 5 ++++- raphtory-graphql/src/server.rs | 15 ++++++++------- 5 files changed, 19 insertions(+), 15 deletions(-) diff --git a/raphtory-graphql/src/cli.rs b/raphtory-graphql/src/cli.rs index 618f70518c..6b65b2c204 100644 --- a/raphtory-graphql/src/cli.rs +++ b/raphtory-graphql/src/cli.rs @@ -132,10 +132,8 @@ where None, server_args.graph_config, )?; - let server = apply_server_extension( - server, - server_args.permissions_store_path.as_deref(), - ); + let server = + apply_server_extension(server, server_args.permissions_store_path.as_deref()); server.run_with_port(server_args.port).await?; } } diff --git a/raphtory-graphql/src/model/graph/graph.rs b/raphtory-graphql/src/model/graph/graph.rs index dc9d50cd8d..f70e68a143 100644 --- a/raphtory-graphql/src/model/graph/graph.rs +++ b/raphtory-graphql/src/model/graph/graph.rs @@ -634,7 +634,6 @@ impl GqlGraph { limit: usize, offset: usize, ) -> Result> { - #[cfg(feature = "search")] { let self_clone = self.clone(); @@ -661,7 +660,6 @@ impl GqlGraph { limit: usize, offset: usize, ) -> Result> { - #[cfg(feature = "search")] { let self_clone = self.clone(); diff --git a/raphtory-graphql/src/model/mod.rs b/raphtory-graphql/src/model/mod.rs index dbfdfb4833..2033761e09 100644 --- a/raphtory-graphql/src/model/mod.rs +++ b/raphtory-graphql/src/model/mod.rs @@ -108,7 +108,11 @@ impl QueryRoot { match policy.check_graph_access(role, path) { Some(false) | None => { let role_str = role.unwrap_or(""); - warn!(role = role_str, graph = path, "Access denied by auth policy"); + warn!( + role = role_str, + graph = path, + "Access denied by auth policy" + ); return Err(async_graphql::Error::new(format!( "Access denied: role '{role_str}' is not permitted to access graph '{path}'" ))); diff --git a/raphtory-graphql/src/python/pymodule.rs b/raphtory-graphql/src/python/pymodule.rs index 4117b35eb1..fa19c92795 100644 --- a/raphtory-graphql/src/python/pymodule.rs +++ b/raphtory-graphql/src/python/pymodule.rs @@ -39,7 +39,10 @@ pub fn base_graphql_module(py: Python<'_>) -> Result, PyErr> graphql_module.add_function(wrap_pyfunction!(decode_graph, &graphql_module)?)?; graphql_module.add_function(wrap_pyfunction!(schema, &graphql_module)?)?; graphql_module.add_function(wrap_pyfunction!(python_cli, &graphql_module)?)?; - graphql_module.add_function(wrap_pyfunction!(has_permissions_extension, &graphql_module)?)?; + graphql_module.add_function(wrap_pyfunction!( + has_permissions_extension, + &graphql_module + )?)?; Ok(graphql_module) } diff --git a/raphtory-graphql/src/server.rs b/raphtory-graphql/src/server.rs index ebe4325cf0..6db8eb4931 100644 --- a/raphtory-graphql/src/server.rs +++ b/raphtory-graphql/src/server.rs @@ -12,6 +12,7 @@ use crate::{ server::ServerError::SchemaError, }; use config::ConfigError; +use once_cell::sync::Lazy; use opentelemetry::trace::TracerProvider; use opentelemetry_sdk::trace::{Tracer, TracerProvider as TP}; use poem::{ @@ -27,7 +28,6 @@ use raphtory::{ vectors::{cache::VectorCache, embeddings::EmbeddingFunction, template::DocumentTemplate}, }; use serde_json::json; -use once_cell::sync::Lazy; use std::{ fs::create_dir_all, path::{Path, PathBuf}, @@ -53,11 +53,9 @@ use url::ParseError; pub const DEFAULT_PORT: u16 = 1736; -type ServerExtensionFn = - Box) -> GraphServer + Send + Sync>; +type ServerExtensionFn = Box) -> GraphServer + Send + Sync>; -static SERVER_EXTENSION: Lazy>> = - Lazy::new(|| RwLock::new(None)); +static SERVER_EXTENSION: Lazy>> = Lazy::new(|| RwLock::new(None)); pub fn register_server_extension(f: ServerExtensionFn) { *SERVER_EXTENSION.write().unwrap() = Some(f); @@ -102,8 +100,11 @@ impl From for io::Error { } } -type SchemaDataInjector = - Box async_graphql::dynamic::SchemaBuilder + Send + Sync>; +type SchemaDataInjector = Box< + dyn FnOnce(async_graphql::dynamic::SchemaBuilder) -> async_graphql::dynamic::SchemaBuilder + + Send + + Sync, +>; /// A struct for defining and running a Raphtory GraphQL server pub struct GraphServer { From 506234a29baf4603b1664de1b9ee088b47b8badb Mon Sep 17 00:00:00 2001 From: shivamka1 <4599890+shivamka1@users.noreply.github.com> Date: Tue, 10 Mar 2026 15:13:42 +0000 Subject: [PATCH 07/64] fix build --- _Cargo.toml | 200 ++++++++++++++++++++++++++++++++++++++++++++++ python/Cargo.toml | 2 +- 2 files changed, 201 insertions(+), 1 deletion(-) create mode 100644 _Cargo.toml diff --git a/_Cargo.toml b/_Cargo.toml new file mode 100644 index 0000000000..f79eef5551 --- /dev/null +++ b/_Cargo.toml @@ -0,0 +1,200 @@ +[workspace] +members = [ + "raphtory", + "raphtory-benchmark", + "examples/rust", + "examples/netflow", + "examples/custom-gql-apis", + "python", + "raphtory-graphql", + "raphtory-auth-noop", + "raphtory-server", + "raphtory-api", + "raphtory-core", + "raphtory-storage", + "raphtory-api-macros", + "raphtory-itertools", + "clam-core", + "clam-core/snb", + "raphtory-itertools" +] +default-members = ["raphtory"] +exclude = ["optd"] +resolver = "2" + +[workspace.package] +version = "0.17.0" +documentation = "https://raphtory.readthedocs.io/en/latest/" +repository = "https://github.com/Raphtory/raphtory/" +license = "GPL-3.0" +readme = "README.md" +homepage = "https://github.com/Raphtory/raphtory/" +keywords = ["graph", "temporal-graph", "temporal"] +authors = ["Pometry"] +rust-version = "1.89.0" +edition = "2021" + +# debug symbols are using a lot of resources +[profile.dev] +split-debuginfo = "unpacked" +debug = 1 + +[profile.release-with-debug] +inherits = "release" +debug = true + +# use this if you really need debug symbols +[profile.with-debug] +inherits = "dev" +debug = true + +# for fast one-time builds (e.g., docs/CI) +[profile.build-fast] +inherits = "dev" +debug = false +incremental = false + + +[workspace.dependencies] +db4-graph = { version = "0.17.0", path = "db4-graph", default-features = false } +db4-storage = { version = "0.17.0", path = "db4-storage" } +raphtory = { version = "0.17.0", path = "raphtory", default-features = false } +raphtory-api = { version = "0.17.0", path = "raphtory-api", default-features = false } +raphtory-api-macros = { version = "0.17.0", path = "raphtory-api-macros", default-features = false } +raphtory-core = { version = "0.17.0", path = "raphtory-core", default-features = false } +raphtory-graphql = { version = "0.17.0", path = "raphtory-graphql", default-features = false } +raphtory-storage = { version = "0.17.0", path = "raphtory-storage", default-features = false } +raphtory-itertools = { version = "0.17.0", path = "raphtory-itertools" } +clam-core = { path = "clam-core" } +async-graphql = { version = "7.0.16", features = ["dynamic-schema"] } +bincode = { version = "2", features = ["serde"] } +async-graphql-poem = "7.0.16" +dynamic-graphql = "0.10.1" +derive_more = "2.0.1" +tikv-jemallocator = "0.6.1" +reqwest = { version = "0.12.8", default-features = false, features = [ + "rustls-tls", + "multipart", + "json", +] } +boxcar = "0.2.14" +iter-enum = { version = "1.2.0", features = ["rayon"] } +serde = { version = "1.0.197", features = ["derive", "rc"] } +serde_json = { version = "1.0.114", features = ["float_roundtrip"] } +pyo3 = { version = "0.27.2", features = ["multiple-pymethods", "chrono"] } +pyo3-build-config = "0.27.2" +pyo3-arrow = "0.15.0" +numpy = "0.27.1" +itertools = "0.13.0" +rand = "0.9.2" +rayon = "1.8.1" +roaring = "0.10.6" +sorted_vector_map = "0.2.0" +tokio = { version = "1.43.1", features = ["full"] } +once_cell = "1.19.0" +parking_lot = { version = "0.12.1", features = [ + "serde", + "arc_lock", + "send_guard", +] } +ordered-float = "4.2.0" +chrono = { version = "0.4.42", features = ["serde"] } +chrono-tz = "0.10.4" +tempfile = "3.10.0" +futures-util = "0.3.30" +thiserror = "2.0.0" +dotenv = "0.15.0" +csv = "1.3.0" +flate2 = "1.0.28" +regex = "1.10.3" +num-traits = "0.2.18" +num-integer = "0.1" +rand_distr = "0.5.1" +rustc-hash = "2.0.0" +twox-hash = "2.1.0" +tinyvec = { version = "1.10", features = ["serde", "alloc"] } +lock_api = { version = "0.4.11", features = ["arc_lock", "serde"] } +dashmap = { version = "6.0.1", features = ["serde", "rayon"] } +glam = "0.29.0" +quad-rand = "0.2.1" +zip = "2.3.0" +neo4rs = "0.8.0" +bzip2 = "0.4.4" +tantivy = "0.22.0" +async-trait = "0.1.77" +async-openai = "0.26.0" +num = "0.4.1" +display-error-chain = "0.2.0" +bigdecimal = { version = "0.4.7", features = ["serde"] } +kdam = "0.6.3" +hashbrown = { version = "0.14.5", features = ["raw"] } +pretty_assertions = "1.4.0" +streaming-stats = "0.2.3" +proptest = "1.8.0" +proptest-derive = "0.6.0" +criterion = "0.5.1" +crossbeam-channel = "0.5.15" +base64 = "0.22.1" +jsonwebtoken = "9.3.1" +spki = "0.7.3" +poem = { version = "3.0.1", features = ["compression", "embed", "static-files"] } +rust-embed = { version = "8.7.2", features = ["interpolate-folder-path"] } +opentelemetry = "0.27.1" +opentelemetry_sdk = { version = "0.27.1", features = ["rt-tokio"] } +opentelemetry-otlp = { version = "0.27.0" } +tracing = { version = "0.1.37", features = ["log"] } +tracing-opentelemetry = "0.28.0" +tracing-subscriber = { version = "0.3.20", features = ["std", "env-filter"] } +indoc = "2.0.5" +walkdir = "2" +config = "0.14.0" +either = "=1.15.0" +clap = { version = "4.5.21", features = ["derive", "env"] } +memmap2 = { version = "0.9.4" } +ahash = { version = "0.8.3", features = ["serde"] } +bytemuck = { version = "1.18.0", features = ["derive"] } +ouroboros = "0.18.3" +url = "2.2" +base64-compat = { package = "base64-compat", version = "1.0.0" } +prost = "0.13.1" +prost-types = "0.13.1" +prost-build = "0.13.1" +lazy_static = "1.4.0" +pest = "2.7.8" +pest_derive = "2.7.8" +minijinja = "2.2.0" +minijinja-contrib = { version = "2.2.0", features = ["datetime"] } +datafusion = { version = "50.0.0" } +arroy = "0.6.1" +heed = "0.22.0" +sqlparser = "0.58.0" +futures = "0.3" +arrow = { version = "57.2.0" } +parquet = { version = "57.2.0" } +arrow-json = { version = "57.2.0" } +arrow-buffer = { version = "57.2.0" } +arrow-schema = { version = "57.2.0" } +arrow-csv = { version = "57.2.0" } +arrow-array = { version = "57.2.0", features = ["chrono-tz"] } +arrow-cast = { version = "57.2.0" } +arrow-ipc = { version = "57.2.0" } +serde_arrow = { version = "0.13.6", features = ["arrow-57"] } +moka = { version = "0.12.7", features = ["future"] } +indexmap = { version = "2.7.0", features = ["rayon"] } +fake = { version = "3.1.0", features = ["chrono"] } +strsim = { version = "0.11.1" } +uuid = { version = "1.16.0", features = ["v4"] } +bitvec = "1.0.1" +sysinfo = "0.37.0" +strum = "0.27.2" +strum_macros = "0.27.2" +pythonize = { version = "0.27.0" } +test-log = "0.2.19" + +[workspace.dependencies.storage] +package = "db4-storage" +path = "db4-storage" + +[workspace.dependencies.auth] +package = "raphtory-auth-noop" +path = "raphtory-auth-noop" diff --git a/python/Cargo.toml b/python/Cargo.toml index f4ce6a9c5b..3862bd9411 100644 --- a/python/Cargo.toml +++ b/python/Cargo.toml @@ -27,7 +27,7 @@ raphtory-graphql = { workspace = true, features = [ "python", ] } auth = { workspace = true } -clam-core = { path = "../clam-core", version = "0.17.0", features = ["python"] } +clam-core = { path = "../clam-core", features = ["python"] } [features] extension-module = ["pyo3/extension-module"] From f463a79196c6773700b864ac30c58738ba56a8e1 Mon Sep 17 00:00:00 2001 From: shivamka1 <4599890+shivamka1@users.noreply.github.com> Date: Wed, 11 Mar 2026 13:19:33 +0000 Subject: [PATCH 08/64] fix tests --- python/tests/test_permissions.py | 6 +++--- python/tox.ini | 2 +- raphtory-graphql/src/model/mod.rs | 3 ++- raphtory-graphql/src/server.rs | 5 +++++ 4 files changed, 11 insertions(+), 5 deletions(-) diff --git a/python/tests/test_permissions.py b/python/tests/test_permissions.py index debbf63260..468e71ef80 100644 --- a/python/tests/test_permissions.py +++ b/python/tests/test_permissions.py @@ -127,10 +127,10 @@ def test_no_role_is_denied_when_policy_is_active(): assert "Access denied" in response.json()["errors"][0]["message"] -def test_no_policy_gives_full_access(): - """Without permissions_store_path, all authenticated users see everything.""" +def test_empty_store_gives_full_access(): + """With an empty permissions store (no roles configured), authenticated users see everything.""" work_dir = tempfile.mkdtemp() - with GraphServer(work_dir, auth_public_key=PUB_KEY).start(): + with make_server(work_dir).start(): gql(CREATE_JIRA) response = requests.post( diff --git a/python/tox.ini b/python/tox.ini index 6c18de443b..a0b42869dd 100644 --- a/python/tox.ini +++ b/python/tox.ini @@ -10,7 +10,7 @@ package = wheel wheel_build_env = .pkg extras = tox - all, storage, auth, timezone: test + all, storage, auth, timezone, permissions: test export: export all: all pass_env = diff --git a/raphtory-graphql/src/model/mod.rs b/raphtory-graphql/src/model/mod.rs index 2033761e09..25c4cf01e2 100644 --- a/raphtory-graphql/src/model/mod.rs +++ b/raphtory-graphql/src/model/mod.rs @@ -106,7 +106,7 @@ impl QueryRoot { let introspection_allowed = if let Some(policy) = &data.auth_policy { match policy.check_graph_access(role, path) { - Some(false) | None => { + Some(false) => { let role_str = role.unwrap_or(""); warn!( role = role_str, @@ -118,6 +118,7 @@ impl QueryRoot { ))); } Some(true) => policy.check_graph_introspection(role, path), + None => true, // role not in store = no rules apply = full access } } else { true // no policy -> unrestricted diff --git a/raphtory-graphql/src/server.rs b/raphtory-graphql/src/server.rs index 6db8eb4931..5e79bc7d0b 100644 --- a/raphtory-graphql/src/server.rs +++ b/raphtory-graphql/src/server.rs @@ -156,6 +156,11 @@ impl GraphServer { }) } + /// Returns the working directory for this server. + pub fn work_dir(&self) -> &Path { + &self.data.work_dir + } + /// Turn off index for all graphs pub fn turn_off_index(mut self) -> Self { self.data.create_index = false; From 9499302c29697ffc8c7707c32d71bbbd85a6ad0b Mon Sep 17 00:00:00 2001 From: shivamka1 <4599890+shivamka1@users.noreply.github.com> Date: Wed, 11 Mar 2026 15:19:25 +0000 Subject: [PATCH 09/64] impl permissions read gql, add tests --- python/tests/test_permissions.py | 173 ++++++++++++++++++ raphtory-graphql/src/auth.rs | 27 ++- raphtory-graphql/src/auth_policy.rs | 8 + raphtory-graphql/src/lib.rs | 1 + raphtory-graphql/src/model/mod.rs | 55 +++++- raphtory-graphql/src/model/plugins/mod.rs | 15 +- .../src/model/plugins/operation.rs | 21 +++ .../src/model/plugins/permissions_plugin.rs | 42 ++++- 8 files changed, 331 insertions(+), 11 deletions(-) diff --git a/python/tests/test_permissions.py b/python/tests/test_permissions.py index 468e71ef80..81ad81f2c9 100644 --- a/python/tests/test_permissions.py +++ b/python/tests/test_permissions.py @@ -210,3 +210,176 @@ def test_namespace_wildcard_grants_access_to_all_graphs(): RAPHTORY, headers=ANALYST_HEADERS, data=json.dumps({"query": query}) ) assert "errors" not in response.json(), response.json() + + +# --- WRITE permission enforcement --- + +UPDATE_JIRA = """mutation { updateGraph(path: "jira") { addNode(time: 1, name: "test_node") } }""" +CREATE_JIRA_NS = """mutation { newGraph(path:"team/jira", graphType:EVENT) }""" + + +def test_admin_bypasses_policy_for_reads(): + """'a':'rw' admin can read any graph even without a role entry in the store.""" + work_dir = tempfile.mkdtemp() + with make_server(work_dir).start(): + gql(CREATE_JIRA) + # Policy is active (analyst role exists) but admin has no role entry + create_role("analyst") + grant_graph("analyst", "jira", ["READ"]) + + response = requests.post( + RAPHTORY, headers=ADMIN_HEADERS, data=json.dumps({"query": QUERY_JIRA}) + ) + assert "errors" not in response.json(), response.json() + assert response.json()["data"]["graph"]["path"] == "jira" + + +def test_analyst_can_write_with_write_grant(): + """'a':'ro' user with WRITE grant on a specific graph can call updateGraph.""" + work_dir = tempfile.mkdtemp() + with make_server(work_dir).start(): + gql(CREATE_JIRA) + create_role("analyst") + grant_graph("analyst", "jira", ["READ", "WRITE"]) + + response = requests.post( + RAPHTORY, headers=ANALYST_HEADERS, data=json.dumps({"query": UPDATE_JIRA}) + ) + assert "errors" not in response.json(), response.json() + + +def test_analyst_cannot_write_without_write_grant(): + """'a':'ro' user with READ-only grant cannot call updateGraph.""" + work_dir = tempfile.mkdtemp() + with make_server(work_dir).start(): + gql(CREATE_JIRA) + create_role("analyst") + grant_graph("analyst", "jira", ["READ"]) # READ only, no WRITE + + response = requests.post( + RAPHTORY, headers=ANALYST_HEADERS, data=json.dumps({"query": UPDATE_JIRA}) + ) + assert ( + response.json()["data"] is None + or response.json()["data"].get("updateGraph") is None + ) + assert "errors" in response.json() + assert "Access denied" in response.json()["errors"][0]["message"] + + +def test_analyst_can_create_graph_in_namespace(): + """'a':'ro' user with namespace WRITE grant can create a new graph in that namespace.""" + work_dir = tempfile.mkdtemp() + with make_server(work_dir).start(): + create_role("analyst") + grant_namespace("analyst", "team/", ["READ", "WRITE"]) + + response = requests.post( + RAPHTORY, + headers=ANALYST_HEADERS, + data=json.dumps({"query": CREATE_JIRA_NS}), + ) + assert "errors" not in response.json(), response.json() + + +def test_analyst_cannot_create_graph_outside_namespace(): + """'a':'ro' user with namespace WRITE grant cannot create a graph outside that namespace.""" + work_dir = tempfile.mkdtemp() + with make_server(work_dir).start(): + create_role("analyst") + grant_namespace("analyst", "team/", ["READ", "WRITE"]) + + response = requests.post( + RAPHTORY, + headers=ANALYST_HEADERS, + data=json.dumps({"query": CREATE_JIRA}), # "jira" not under "team/" + ) + assert "errors" in response.json() + assert "Access denied" in response.json()["errors"][0]["message"] + + +def test_analyst_cannot_call_permissions_mutations(): + """'a':'ro' user with WRITE grant on a graph cannot manage roles/permissions.""" + work_dir = tempfile.mkdtemp() + with make_server(work_dir).start(): + create_role("analyst") + grant_namespace("analyst", "*", ["READ", "WRITE"]) + + response = requests.post( + RAPHTORY, + headers=ANALYST_HEADERS, + data=json.dumps( + {"query": 'mutation { permissions { createRole(name: "hacker") { success } } }'} + ), + ) + assert "errors" in response.json() + assert "Access denied" in response.json()["errors"][0]["message"] + + +def test_admin_can_list_roles(): + """'a':'rw' admin can query permissions { listRoles }.""" + work_dir = tempfile.mkdtemp() + with make_server(work_dir).start(): + create_role("analyst") + + response = requests.post( + RAPHTORY, + headers=ADMIN_HEADERS, + data=json.dumps({"query": "query { permissions { listRoles } }"}), + ) + assert "errors" not in response.json(), response.json() + assert "analyst" in response.json()["data"]["permissions"]["listRoles"] + + +def test_analyst_cannot_list_roles(): + """'a':'ro' user cannot query permissions { listRoles }.""" + work_dir = tempfile.mkdtemp() + with make_server(work_dir).start(): + create_role("analyst") + + response = requests.post( + RAPHTORY, + headers=ANALYST_HEADERS, + data=json.dumps({"query": "query { permissions { listRoles } }"}), + ) + assert "errors" in response.json() + assert "Access denied" in response.json()["errors"][0]["message"] + + +def test_admin_can_get_role(): + """'a':'rw' admin can query permissions { getRole(...) }.""" + work_dir = tempfile.mkdtemp() + with make_server(work_dir).start(): + create_role("analyst") + grant_graph("analyst", "jira", ["READ"]) + + response = requests.post( + RAPHTORY, + headers=ADMIN_HEADERS, + data=json.dumps( + { + "query": 'query { permissions { getRole(name: "analyst") { name graphs { path permissions } } } }' + } + ), + ) + assert "errors" not in response.json(), response.json() + role_data = response.json()["data"]["permissions"]["getRole"] + assert role_data["name"] == "analyst" + assert role_data["graphs"][0]["path"] == "jira" + + +def test_analyst_cannot_get_role(): + """'a':'ro' user cannot query permissions { getRole(...) }.""" + work_dir = tempfile.mkdtemp() + with make_server(work_dir).start(): + create_role("analyst") + + response = requests.post( + RAPHTORY, + headers=ANALYST_HEADERS, + data=json.dumps( + {"query": 'query { permissions { getRole(name: "analyst") { name } } }'} + ), + ) + assert "errors" in response.json() + assert "Access denied" in response.json()["errors"][0]["message"] diff --git a/raphtory-graphql/src/auth.rs b/raphtory-graphql/src/auth.rs index 14df49ddce..7c7f590c5d 100644 --- a/raphtory-graphql/src/auth.rs +++ b/raphtory-graphql/src/auth.rs @@ -231,6 +231,19 @@ pub(crate) trait ContextValidation { fn require_write_access(&self) -> Result<(), AuthError>; } +/// Check that the request carries a write-access JWT (`"a": "rw"`). +/// For use in dynamic resolver ops that run under `query { ... }` and are +/// therefore not covered by the `MutationAuth` extension. +pub fn require_write_access_dynamic( + ctx: &async_graphql::dynamic::ResolverContext, +) -> Result<(), async_graphql::Error> { + if ctx.data::().is_ok_and(|a| a == &Access::Rw) { + Ok(()) + } else { + Err(async_graphql::Error::new("Access denied: write access required")) + } +} + impl<'a> ContextValidation for &Context<'a> { fn require_write_access(&self) -> Result<(), AuthError> { if self.data::().is_ok_and(|role| role == &Access::Rw) { @@ -264,10 +277,18 @@ impl Extension for MutationAuth { .iter() .any(|op| op.1.node.ty == OperationType::Mutation); if mutation && ctx.data::() != Ok(&Access::Rw) { - Err(AuthError::RequireWrite.into()) - } else { - Ok(doc) + // If a policy is active, allow "ro" users through to resolvers — + // each resolver enforces its own per-graph or admin-only check. + // Without a policy (OSS), preserve the original blanket deny. + let policy_active = ctx + .data::() + .map(|d| d.auth_policy.is_some()) + .unwrap_or(false); + if !policy_active { + return Err(AuthError::RequireWrite.into()); + } } + Ok(doc) }) } } diff --git a/raphtory-graphql/src/auth_policy.rs b/raphtory-graphql/src/auth_policy.rs index 4324184cac..f4f7101487 100644 --- a/raphtory-graphql/src/auth_policy.rs +++ b/raphtory-graphql/src/auth_policy.rs @@ -6,6 +6,10 @@ pub trait AuthorizationPolicy: Send + Sync + 'static { /// Returns `true` if the role may perform graph-level introspection /// (countNodes, countEdges, uniqueLayers, schema). fn check_graph_introspection(&self, role: Option<&str>, path: &str) -> bool; + + /// Returns `true` if the role may write to this graph (addNode, addEdge, updateGraph, newGraph). + /// `"a": "rw"` users bypass this check entirely — it is only called for `"a": "ro"` users. + fn check_graph_write_access(&self, role: Option<&str>, path: &str) -> bool; } /// A no-op policy that permits all reads and leaves writes to the `PermissionsPlugin`. @@ -20,4 +24,8 @@ impl AuthorizationPolicy for NoopPolicy { fn check_graph_introspection(&self, _: Option<&str>, _: &str) -> bool { true } + + fn check_graph_write_access(&self, _: Option<&str>, _: &str) -> bool { + true + } } diff --git a/raphtory-graphql/src/lib.rs b/raphtory-graphql/src/lib.rs index 9a93ebf38a..a563249802 100644 --- a/raphtory-graphql/src/lib.rs +++ b/raphtory-graphql/src/lib.rs @@ -1,3 +1,4 @@ +pub use crate::auth::require_write_access_dynamic; pub use crate::server::GraphServer; use crate::{data::InsertionError, paths::PathValidationError}; use raphtory::errors::GraphError; diff --git a/raphtory-graphql/src/model/mod.rs b/raphtory-graphql/src/model/mod.rs index 25c4cf01e2..fa1df9772c 100644 --- a/raphtory-graphql/src/model/mod.rs +++ b/raphtory-graphql/src/model/mod.rs @@ -1,6 +1,6 @@ use crate::{ - auth::ContextValidation, - data::{Data, DeletionError}, + auth::{Access, ContextValidation}, + data::Data, model::{ graph::{ collection::GqlCollection, graph::GqlGraph, index::IndexSpecInput, @@ -8,7 +8,8 @@ use crate::{ vectorised_graph::GqlVectorisedGraph, }, plugins::{ - mutation_plugin::MutationPlugin, permissions_plugin::PermissionsPlugin, + mutation_plugin::MutationPlugin, + permissions_plugin::{PermissionsPlugin, PermissionsQueryPlugin}, query_plugin::QueryPlugin, }, }, @@ -104,7 +105,9 @@ impl QueryRoot { let data = ctx.data_unchecked::(); let role = ctx.data::>().ok().and_then(|r| r.as_deref()); - let introspection_allowed = if let Some(policy) = &data.auth_policy { + let introspection_allowed = if ctx.data::().is_ok_and(|a| a == &Access::Rw) { + true // "a": "rw" = admin, bypass all per-graph policy checks + } else if let Some(policy) = &data.auth_policy { match policy.check_graph_access(role, path) { Some(false) => { let role_str = role.unwrap_or(""); @@ -136,8 +139,22 @@ impl QueryRoot { /// /// Returns:: GqlMutableGraph async fn update_graph<'a>(ctx: &Context<'a>, path: String) -> Result { - ctx.require_write_access()?; let data = ctx.data_unchecked::(); + if !ctx.data::().is_ok_and(|a| a == &Access::Rw) { + // Not admin — check per-graph write permission from the policy + match &data.auth_policy { + Some(policy) => { + let role = ctx.data::>().ok().and_then(|r| r.as_deref()); + if !policy.check_graph_write_access(role, &path) { + let role_str = role.unwrap_or(""); + return Err(async_graphql::Error::new(format!( + "Access denied: role '{role_str}' does not have write permission for graph '{path}'" + ))); + } + } + None => ctx.require_write_access()?, + } + } let graph = data.get_graph(path.as_ref()).await?.into(); @@ -207,6 +224,11 @@ impl QueryRoot { async fn version<'a>(_ctx: &Context<'a>) -> String { String::from(version()) } + + /// Returns the permissions namespace for inspecting roles and access policies (admin only). + async fn permissions<'a>(_ctx: &Context<'a>) -> PermissionsQueryPlugin { + PermissionsQueryPlugin::default() + } } #[derive(MutationRoot)] @@ -229,7 +251,8 @@ impl Mut { /// Delete graph from a path on the server. // If namespace is not provided, it will be set to the current working directory. - async fn delete_graph<'a>(ctx: &Context<'a>, path: String) -> Result { + async fn delete_graph<'a>(ctx: &Context<'a>, path: String) -> Result { + ctx.require_write_access()?; let data = ctx.data_unchecked::(); data.delete_graph(&path).await?; Ok(true) @@ -242,6 +265,20 @@ impl Mut { graph_type: GqlGraphType, ) -> Result { let data = ctx.data_unchecked::(); + if !ctx.data::().is_ok_and(|a| a == &Access::Rw) { + match &data.auth_policy { + Some(policy) => { + let role = ctx.data::>().ok().and_then(|r| r.as_deref()); + if !policy.check_graph_write_access(role, &path) { + let role_str = role.unwrap_or(""); + return Err(async_graphql::Error::new(format!( + "Access denied: role '{role_str}' does not have write permission for namespace '{path}'" + ))); + } + } + None => ctx.require_write_access()?, + } + } let overwrite = false; let folder = data.validate_path_for_insert(&path, overwrite)?; let graph_path = folder.graph_folder(); @@ -269,6 +306,7 @@ impl Mut { new_path: &str, overwrite: Option, ) -> Result { + ctx.require_write_access()?; Self::copy_graph(ctx, path, new_path, overwrite).await?; let data = ctx.data_unchecked::(); data.delete_graph(path).await?; @@ -282,6 +320,7 @@ impl Mut { new_path: &str, overwrite: Option, ) -> Result { + ctx.require_write_access()?; // doing this in a more efficient way is not trivial, this at least is correct // there are questions like, maybe the new vectorised graph have different rules // for the templates or if it needs to be vectorised at all @@ -304,6 +343,7 @@ impl Mut { graph: Upload, overwrite: bool, ) -> Result { + ctx.require_write_access()?; let data = ctx.data_unchecked::(); let in_file = graph.value(ctx)?.content; let folder = data.validate_path_for_insert(&path, overwrite)?; @@ -322,6 +362,7 @@ impl Mut { graph: String, overwrite: bool, ) -> Result { + ctx.require_write_access()?; let data = ctx.data_unchecked::(); let folder = if overwrite { ValidWriteableGraphFolder::try_existing_or_new(data.work_dir.clone(), path)? @@ -349,6 +390,7 @@ impl Mut { new_path: String, overwrite: bool, ) -> Result { + ctx.require_write_access()?; let data = ctx.data_unchecked::(); let folder = data.validate_path_for_insert(&new_path, overwrite)?; let parent_graph = data.get_graph(parent_path).await?.graph; @@ -374,6 +416,7 @@ impl Mut { index_spec: Option, in_ram: bool, ) -> Result { + ctx.require_write_access()?; #[cfg(feature = "search")] { let data = ctx.data_unchecked::(); diff --git a/raphtory-graphql/src/model/plugins/mod.rs b/raphtory-graphql/src/model/plugins/mod.rs index d2c0ebb76e..9f97f8467a 100644 --- a/raphtory-graphql/src/model/plugins/mod.rs +++ b/raphtory-graphql/src/model/plugins/mod.rs @@ -13,7 +13,7 @@ pub mod query_plugin; pub type RegisterFunction = Box (Registry, Object) + Send>; -/// Register an operation into the `PermissionsPlugin` entry point. +/// Register an operation into the `PermissionsPlugin` entry point (mutation root). /// Call this before `GraphServer::run()` / `create_schema()`. pub fn register_permissions_mutation(name: &'static str) where @@ -24,3 +24,16 @@ where .unwrap() .insert(name.to_string(), Box::new(O::register_operation)); } + +/// Register an operation into the `PermissionsQueryPlugin` entry point (query root). +/// Ops registered here must call `require_write_access_dynamic` themselves since they +/// are not covered by the `MutationAuth` extension. +pub fn register_permissions_query(name: &'static str) +where + O: for<'a> operation::Operation<'a, permissions_plugin::PermissionsQueryPlugin> + 'static, +{ + permissions_plugin::PERMISSIONS_QUERIES + .lock() + .unwrap() + .insert(name.to_string(), Box::new(O::register_operation)); +} diff --git a/raphtory-graphql/src/model/plugins/operation.rs b/raphtory-graphql/src/model/plugins/operation.rs index b22819868c..26aefa8cb4 100644 --- a/raphtory-graphql/src/model/plugins/operation.rs +++ b/raphtory-graphql/src/model/plugins/operation.rs @@ -58,6 +58,27 @@ impl<'a> Operation<'a, super::permissions_plugin::PermissionsPlugin> for NoOpPer } } +pub(crate) struct NoOpPermissionsQuery; + +impl<'a> Operation<'a, super::permissions_plugin::PermissionsQueryPlugin> for NoOpPermissionsQuery { + type OutputType = String; + + fn output_type() -> TypeRef { + TypeRef::named_nn(TypeRef::STRING) + } + + fn args<'b>() -> Vec<(&'b str, TypeRef)> { + vec![] + } + + fn apply<'b>( + _entry_point: &super::permissions_plugin::PermissionsQueryPlugin, + _ctx: ResolverContext<'b>, + ) -> BoxFuture<'b, FieldResult>>> { + Box::pin(async move { Ok(Some(FieldValue::value("no-op".to_owned()))) }) + } +} + pub(crate) struct NoOpMutation; impl<'a> Operation<'a, MutationPlugin> for NoOpMutation { diff --git a/raphtory-graphql/src/model/plugins/permissions_plugin.rs b/raphtory-graphql/src/model/plugins/permissions_plugin.rs index 39561146b2..bcf8cfcb3b 100644 --- a/raphtory-graphql/src/model/plugins/permissions_plugin.rs +++ b/raphtory-graphql/src/model/plugins/permissions_plugin.rs @@ -1,5 +1,5 @@ use super::{ - operation::{NoOpPermissions, Operation}, + operation::{NoOpPermissions, NoOpPermissionsQuery, Operation}, RegisterFunction, }; use crate::model::plugins::entry_point::EntryPoint; @@ -14,6 +14,9 @@ use std::{ pub static PERMISSIONS_MUTATIONS: Lazy>> = Lazy::new(|| Mutex::new(IndexMap::new())); +pub static PERMISSIONS_QUERIES: Lazy>> = + Lazy::new(|| Mutex::new(IndexMap::new())); + #[derive(Clone, Default)] pub struct PermissionsPlugin; @@ -49,3 +52,40 @@ impl<'a> ResolveOwned<'a> for PermissionsPlugin { Ok(Some(FieldValue::owned_any(self))) } } + +/// Read-only entry point for permissions queries (admin-gated via require_write_access_dynamic). +#[derive(Clone, Default)] +pub struct PermissionsQueryPlugin; + +impl<'a> EntryPoint<'a> for PermissionsQueryPlugin { + fn predefined_operations() -> IndexMap<&'static str, RegisterFunction> { + IndexMap::from([( + "NoOps", + Box::new(NoOpPermissionsQuery::register_operation) as RegisterFunction, + )]) + } + + fn lock_plugins() -> MutexGuard<'static, IndexMap> { + PERMISSIONS_QUERIES.lock().unwrap() + } +} + +impl Register for PermissionsQueryPlugin { + fn register(registry: Registry) -> Registry { + Self::register_operations(registry) + } +} + +impl TypeName for PermissionsQueryPlugin { + fn get_type_name() -> Cow<'static, str> { + "PermissionsQueryPlugin".into() + } +} + +impl OutputTypeName for PermissionsQueryPlugin {} + +impl<'a> ResolveOwned<'a> for PermissionsQueryPlugin { + fn resolve_owned(self, _ctx: &Context) -> dynamic_graphql::Result>> { + Ok(Some(FieldValue::owned_any(self))) + } +} From af9d9430d52f6caddf5ca4a22d0799c19dc8a8d3 Mon Sep 17 00:00:00 2001 From: shivamka1 <4599890+shivamka1@users.noreply.github.com> Date: Fri, 13 Mar 2026 14:03:40 +0000 Subject: [PATCH 10/64] filters --- raphtory-graphql/schema.graphql | 16 ++++ raphtory-graphql/src/auth_policy.rs | 10 +++ raphtory-graphql/src/lib.rs | 1 + raphtory-graphql/src/model/graph/filtering.rs | 64 +++++++++----- raphtory-graphql/src/model/graph/property.rs | 6 +- raphtory-graphql/src/model/graph/timeindex.rs | 62 ++++++++++++++ raphtory-graphql/src/model/mod.rs | 85 ++++++++++++++++++- 7 files changed, 216 insertions(+), 28 deletions(-) diff --git a/raphtory-graphql/schema.graphql b/raphtory-graphql/schema.graphql index 2266426c97..28040f74df 100644 --- a/raphtory-graphql/schema.graphql +++ b/raphtory-graphql/schema.graphql @@ -1754,6 +1754,10 @@ type MutRoot { """ plugins: MutationPlugin! """ + Returns the permissions namespace for managing roles and access policies. + """ + permissions: PermissionsPlugin! + """ Delete graph from a path on the server. """ deleteGraph(path: String!): Boolean! @@ -2904,6 +2908,14 @@ type PathFromNodeWindowSet { list: [PathFromNode!]! } +type PermissionsPlugin { + NoOps: String! +} + +type PermissionsQueryPlugin { + NoOps: String! +} + """ Boolean expression over a property value. @@ -3178,6 +3190,10 @@ type QueryRoot { """ receiveGraph(path: String!): String! version: String! + """ + Returns the permissions namespace for inspecting roles and access policies (admin only). + """ + permissions: PermissionsQueryPlugin! } type ShortestPathOutput { diff --git a/raphtory-graphql/src/auth_policy.rs b/raphtory-graphql/src/auth_policy.rs index f4f7101487..2ce4d511b6 100644 --- a/raphtory-graphql/src/auth_policy.rs +++ b/raphtory-graphql/src/auth_policy.rs @@ -10,6 +10,12 @@ pub trait AuthorizationPolicy: Send + Sync + 'static { /// Returns `true` if the role may write to this graph (addNode, addEdge, updateGraph, newGraph). /// `"a": "rw"` users bypass this check entirely — it is only called for `"a": "ro"` users. fn check_graph_write_access(&self, role: Option<&str>, path: &str) -> bool; + + /// Returns a JSON value with optional `node`, `edge`, `graph` keys representing a data filter + /// to apply transparently when this role queries the graph. + /// Returns `None` if no filter is configured (full access to graph data). + /// `"a": "rw"` admin users bypass this — it is never called for them. + fn get_graph_data_filter(&self, role: Option<&str>, path: &str) -> Option; } /// A no-op policy that permits all reads and leaves writes to the `PermissionsPlugin`. @@ -28,4 +34,8 @@ impl AuthorizationPolicy for NoopPolicy { fn check_graph_write_access(&self, _: Option<&str>, _: &str) -> bool { true } + + fn get_graph_data_filter(&self, _: Option<&str>, _: &str) -> Option { + None + } } diff --git a/raphtory-graphql/src/lib.rs b/raphtory-graphql/src/lib.rs index a563249802..82568f1e30 100644 --- a/raphtory-graphql/src/lib.rs +++ b/raphtory-graphql/src/lib.rs @@ -1,4 +1,5 @@ pub use crate::auth::require_write_access_dynamic; +pub use crate::model::graph::filtering::GraphAccessFilter; pub use crate::server::GraphServer; use crate::{data::InsertionError, paths::PathValidationError}; use raphtory::errors::GraphError; diff --git a/raphtory-graphql/src/model/graph/filtering.rs b/raphtory-graphql/src/model/graph/filtering.rs index fdbfd8187e..a6fb174827 100644 --- a/raphtory-graphql/src/model/graph/filtering.rs +++ b/raphtory-graphql/src/model/graph/filtering.rs @@ -31,6 +31,7 @@ use raphtory_api::core::{ entities::{properties::prop::Prop, Layer, GID}, storage::timeindex::{AsTime, EventTime}, }; +use serde::{Deserialize, Serialize}; use std::{ borrow::Cow, collections::HashSet, @@ -40,7 +41,7 @@ use std::{ sync::Arc, }; -#[derive(InputObject, Clone, Debug)] +#[derive(InputObject, Clone, Debug, Serialize, Deserialize)] pub struct Window { /// Window start time. pub start: GqlTimeInput, @@ -260,7 +261,8 @@ pub enum PathFromNodeViewCollection { ShrinkEnd(GqlTimeInput), } -#[derive(Enum, Copy, Clone, Debug)] +#[derive(Enum, Copy, Clone, Debug, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] pub enum NodeField { /// Node ID field. /// @@ -303,7 +305,7 @@ impl Display for NodeField { /// ```graphql /// { Property: { name: "weight", where: { Gt: 0.5 } } } /// ``` -#[derive(InputObject, Clone, Debug)] +#[derive(InputObject, Clone, Debug, Serialize, Deserialize)] pub struct PropertyFilterNew { /// Property (or metadata) key. pub name: String, @@ -311,6 +313,7 @@ pub struct PropertyFilterNew { /// /// Exposed as `where` in GraphQL. #[graphql(name = "where")] + #[serde(rename = "where")] pub where_: PropCondition, } @@ -331,7 +334,8 @@ pub struct PropertyFilterNew { /// - `Value` is interpreted according to the property’s type. /// - Aggregators/qualifiers like `Sum` and `Len` apply when the underlying /// property is list-like or aggregatable (depending on your engine rules). -#[derive(OneOfInput, Clone, Debug)] +#[derive(OneOfInput, Clone, Debug, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] pub enum PropCondition { /// Equality: property value equals the given value. Eq(Value), @@ -448,7 +452,7 @@ impl PropCondition { /// ```graphql /// { Window: { start: 0, end: 10, expr: { Layers: { names: ["A"] } } } } /// ``` -#[derive(InputObject, Clone, Debug)] +#[derive(InputObject, Clone, Debug, Serialize, Deserialize)] pub struct GraphWindowExpr { /// Window start time (inclusive). pub start: GqlTimeInput, @@ -464,7 +468,7 @@ pub struct GraphWindowExpr { /// /// Example: /// `{ At: { time: 5, expr: { Layers: { names: ["L1"] } } } }` -#[derive(InputObject, Clone, Debug)] +#[derive(InputObject, Clone, Debug, Serialize, Deserialize)] pub struct GraphTimeExpr { /// Reference time for the operation. pub time: GqlTimeInput, @@ -475,7 +479,7 @@ pub struct GraphTimeExpr { /// Graph view restriction that takes only a nested expression. /// /// Used for unary view operations like `Latest` and `SnapshotLatest`. -#[derive(InputObject, Clone, Debug)] +#[derive(InputObject, Clone, Debug, Serialize, Deserialize)] pub struct GraphUnaryExpr { /// Optional nested filter applied after the unary operation. pub expr: Option>, @@ -484,7 +488,7 @@ pub struct GraphUnaryExpr { /// Graph view restriction by layer membership, optionally chaining another `GraphFilter`. /// /// Used by `GqlGraphFilter::Layers`. -#[derive(InputObject, Clone, Debug)] +#[derive(InputObject, Clone, Debug, Serialize, Deserialize)] pub struct GraphLayersExpr { /// Layer names to include. pub names: Vec, @@ -504,8 +508,9 @@ pub struct GraphLayersExpr { /// /// These filters can be nested via the `expr` field on the corresponding /// `*Expr` input objects to form pipelines. -#[derive(OneOfInput, Clone, Debug)] +#[derive(OneOfInput, Clone, Debug, Serialize, Deserialize)] #[graphql(name = "GraphFilter")] +#[serde(rename_all = "camelCase")] pub enum GqlGraphFilter { /// Restrict evaluation to a time window (inclusive start, exclusive end). Window(GraphWindowExpr), @@ -534,7 +539,8 @@ pub enum GqlGraphFilter { /// /// Supports comparisons, string predicates, and set membership. /// (Presence checks and aggregations are handled via property filters instead.) -#[derive(OneOfInput, Clone, Debug)] +#[derive(OneOfInput, Clone, Debug, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] pub enum NodeFieldCondition { /// Equality. Eq(Value), @@ -590,7 +596,7 @@ impl NodeFieldCondition { /// ```graphql /// { Node: { field: NodeName, where: { Contains: "ali" } } } /// ``` -#[derive(InputObject, Clone, Debug)] +#[derive(InputObject, Clone, Debug, Serialize, Deserialize)] pub struct NodeFieldFilterNew { /// Which built-in field to filter. pub field: NodeField, @@ -598,6 +604,7 @@ pub struct NodeFieldFilterNew { /// /// Exposed as `where` in GraphQL. #[graphql(name = "where")] + #[serde(rename = "where")] pub where_: NodeFieldCondition, } @@ -606,7 +613,7 @@ pub struct NodeFieldFilterNew { /// Used by `GqlNodeFilter::Window`. /// /// The window is inclusive of `start` and exclusive of `end`. -#[derive(InputObject, Clone, Debug)] +#[derive(InputObject, Clone, Debug, Serialize, Deserialize)] pub struct NodeWindowExpr { /// Window start time (inclusive). pub start: GqlTimeInput, @@ -619,7 +626,7 @@ pub struct NodeWindowExpr { /// Restricts node evaluation to a single time bound and applies a nested `NodeFilter`. /// /// Used by `At`, `Before`, and `After` node filters. -#[derive(InputObject, Clone, Debug)] +#[derive(InputObject, Clone, Debug, Serialize, Deserialize)] pub struct NodeTimeExpr { /// Reference time for the operation. pub time: GqlTimeInput, @@ -630,7 +637,7 @@ pub struct NodeTimeExpr { /// Applies a unary node-view operation and then evaluates a nested `NodeFilter`. /// /// Used by `Latest` and `SnapshotLatest` node filters. -#[derive(InputObject, Clone, Debug)] +#[derive(InputObject, Clone, Debug, Serialize, Deserialize)] pub struct NodeUnaryExpr { /// Filter evaluated after applying the unary operation. pub expr: Wrapped, @@ -639,7 +646,7 @@ pub struct NodeUnaryExpr { /// Restricts node evaluation to one or more layers and applies a nested `NodeFilter`. /// /// Used by `GqlNodeFilter::Layers`. -#[derive(InputObject, Clone, Debug)] +#[derive(InputObject, Clone, Debug, Serialize, Deserialize)] pub struct NodeLayersExpr { /// Layer names to include. pub names: Vec, @@ -661,8 +668,9 @@ pub struct NodeLayersExpr { /// /// Filters can be combined recursively using logical operators /// (`And`, `Or`, `Not`). -#[derive(OneOfInput, Clone, Debug)] +#[derive(OneOfInput, Clone, Debug, Serialize, Deserialize)] #[graphql(name = "NodeFilter")] +#[serde(rename_all = "camelCase")] pub enum GqlNodeFilter { /// Filters a built-in node field (ID, name, or type). Node(NodeFieldFilterNew), @@ -718,7 +726,7 @@ pub enum GqlNodeFilter { /// Used by `GqlEdgeFilter::Window`. /// /// The window is inclusive of `start` and exclusive of `end`. -#[derive(InputObject, Clone, Debug)] +#[derive(InputObject, Clone, Debug, Serialize, Deserialize)] pub struct EdgeWindowExpr { /// Window start time (inclusive). pub start: GqlTimeInput, @@ -731,7 +739,7 @@ pub struct EdgeWindowExpr { /// Restricts edge evaluation to a single time bound and applies a nested `EdgeFilter`. /// /// Used by `At`, `Before`, and `After` edge filters. -#[derive(InputObject, Clone, Debug)] +#[derive(InputObject, Clone, Debug, Serialize, Deserialize)] pub struct EdgeTimeExpr { /// Reference time for the operation. pub time: GqlTimeInput, @@ -742,7 +750,7 @@ pub struct EdgeTimeExpr { /// Applies a unary edge-view operation and then evaluates a nested `EdgeFilter`. /// /// Used by `Latest` and `SnapshotLatest` edge filters. -#[derive(InputObject, Clone, Debug)] +#[derive(InputObject, Clone, Debug, Serialize, Deserialize)] pub struct EdgeUnaryExpr { /// Filter evaluated after applying the unary operation. pub expr: Wrapped, @@ -751,7 +759,7 @@ pub struct EdgeUnaryExpr { /// Restricts edge evaluation to one or more layers and applies a nested `EdgeFilter`. /// /// Used by `GqlEdgeFilter::Layers`. -#[derive(InputObject, Clone, Debug)] +#[derive(InputObject, Clone, Debug, Serialize, Deserialize)] pub struct EdgeLayersExpr { /// Layer names to include. pub names: Vec, @@ -787,8 +795,9 @@ pub struct EdgeLayersExpr { /// } /// } /// ``` -#[derive(OneOfInput, Clone, Debug)] +#[derive(OneOfInput, Clone, Debug, Serialize, Deserialize)] #[graphql(name = "EdgeFilter")] +#[serde(rename_all = "camelCase")] pub enum GqlEdgeFilter { /// Applies a filter to the **source node** of the edge. /// @@ -903,7 +912,8 @@ pub enum GqlEdgeFilter { IsSelfLoop(bool), } -#[derive(Clone, Debug)] +#[derive(Clone, Debug, Serialize, Deserialize)] +#[serde(transparent)] pub struct Wrapped(Box); impl Deref for Wrapped { type Target = T; @@ -1734,3 +1744,13 @@ impl TryFrom for DynView { }) } } + +/// Combined filter input covering all three filter levels (node, edge, graph-level). +/// Used by `grantGraphFilteredReadOnly` to express a data-access restriction +/// that is transparently applied whenever the role queries the graph. +#[derive(InputObject, Clone, Debug, Serialize, Deserialize)] +pub struct GraphAccessFilter { + pub node: Option, + pub edge: Option, + pub graph: Option, +} diff --git a/raphtory-graphql/src/model/graph/property.rs b/raphtory-graphql/src/model/graph/property.rs index 8321302ab3..d1836248a4 100644 --- a/raphtory-graphql/src/model/graph/property.rs +++ b/raphtory-graphql/src/model/graph/property.rs @@ -11,6 +11,7 @@ use dynamic_graphql::{ InputObject, OneOfInput, ResolvedObject, ResolvedObjectFields, Scalar, ScalarValue, }; use itertools::Itertools; +use serde::{Deserialize, Serialize}; use raphtory::{ db::api::properties::{ dyn_props::{DynMetadata, DynProperties, DynProps, DynTemporalProperties}, @@ -38,7 +39,7 @@ use std::{ sync::Arc, }; -#[derive(InputObject, Clone, Debug)] +#[derive(InputObject, Clone, Debug, Serialize, Deserialize)] pub struct ObjectEntry { /// Key. pub key: String, @@ -46,7 +47,8 @@ pub struct ObjectEntry { pub value: Value, } -#[derive(OneOfInput, Clone, Debug)] +#[derive(OneOfInput, Clone, Debug, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] pub enum Value { /// 8 bit unsigned integer. U8(u8), diff --git a/raphtory-graphql/src/model/graph/timeindex.rs b/raphtory-graphql/src/model/graph/timeindex.rs index 42cc7e713a..efb3f49e1d 100644 --- a/raphtory-graphql/src/model/graph/timeindex.rs +++ b/raphtory-graphql/src/model/graph/timeindex.rs @@ -5,6 +5,7 @@ use raphtory_api::core::{ storage::timeindex::{AsTime, EventTime}, utils::time::{IntoTime, TryIntoTime}, }; +use serde::{Deserialize, Deserializer, Serialize, Serializer}; /// Input for primary time component. Expects Int, DateTime formatted String, or Object { timestamp, eventId } /// where the timestamp is either an Int or a DateTime formatted String, and eventId is a non-negative Int. @@ -86,6 +87,67 @@ impl IntoTime for GqlTimeInput { } } +impl Serialize for GqlTimeInput { + fn serialize(&self, serializer: S) -> Result { + // Stored as raw millisecond timestamp (matches how GQL sends an Int time) + self.0.t().serialize(serializer) + } +} + +impl<'de> Deserialize<'de> for GqlTimeInput { + fn deserialize>(deserializer: D) -> Result { + use serde::de::Error; + let v = serde_json::Value::deserialize(deserializer)?; + match v { + serde_json::Value::Number(n) => { + let millis = n + .as_i64() + .ok_or_else(|| D::Error::custom("time must be an i64 millisecond timestamp"))?; + Ok(GqlTimeInput(EventTime::start(millis))) + } + serde_json::Value::String(s) => s + .try_into_time() + .map(|t| GqlTimeInput(t.set_event_id(0))) + .map_err(|e| D::Error::custom(e.to_string())), + serde_json::Value::Object(obj) => { + let ts_val = obj + .get("timestamp") + .or_else(|| obj.get("time")) + .ok_or_else(|| D::Error::custom("time object must contain 'timestamp'"))?; + let ts = match ts_val { + serde_json::Value::Number(n) => n + .as_i64() + .ok_or_else(|| D::Error::custom("timestamp must be i64"))?, + serde_json::Value::String(s) => s + .try_into_time() + .map(|t| t.t()) + .map_err(|e| D::Error::custom(e.to_string()))?, + _ => return Err(D::Error::custom("timestamp must be number or string")), + }; + let event_id = if let Some(id_val) = + obj.get("eventId").or_else(|| obj.get("id")) + { + match id_val { + serde_json::Value::Number(n) => n + .as_u64() + .and_then(|u| usize::try_from(u).ok()) + .ok_or_else(|| { + D::Error::custom("eventId must be a non-negative integer") + })?, + _ => return Err(D::Error::custom("eventId must be a number")), + } + } else { + 0 + }; + Ok(GqlTimeInput(EventTime::new(ts, event_id))) + } + _ => Err(D::Error::custom( + "time must be a number (millis), string (datetime), or object {timestamp, eventId}", + )), + } + } +} + pub fn dt_format_str_is_valid(fmt_str: &str) -> bool { if StrftimeItems::new(fmt_str).any(|it| matches!(it, Item::Error)) { false diff --git a/raphtory-graphql/src/model/mod.rs b/raphtory-graphql/src/model/mod.rs index fa1df9772c..41049c0f4e 100644 --- a/raphtory-graphql/src/model/mod.rs +++ b/raphtory-graphql/src/model/mod.rs @@ -3,8 +3,13 @@ use crate::{ data::Data, model::{ graph::{ - collection::GqlCollection, graph::GqlGraph, index::IndexSpecInput, - mutable_graph::GqlMutableGraph, namespace::Namespace, namespaced_item::NamespacedItem, + collection::GqlCollection, + filtering::{GqlEdgeFilter, GqlGraphFilter, GqlNodeFilter}, + graph::GqlGraph, + index::IndexSpecInput, + mutable_graph::GqlMutableGraph, + namespace::Namespace, + namespaced_item::NamespacedItem, vectorised_graph::GqlVectorisedGraph, }, plugins::{ @@ -27,7 +32,7 @@ use raphtory::{ db::{ api::{ storage::storage::{Extension, PersistenceStrategy}, - view::MaterializedGraph, + view::{DynamicGraph, Filter, IntoDynamic, MaterializedGraph}, }, graph::views::{deletion_graph::PersistentGraph, filter::model::NodeViewFilterOps}, }, @@ -89,6 +94,61 @@ pub enum GqlGraphType { Event, } +/// Applies a stored data filter (serialised as `serde_json::Value` with optional `node`, `edge`, +/// `graph` keys) to a `DynamicGraph`, returning a new filtered view. +async fn apply_graph_filter( + mut graph: DynamicGraph, + filter: serde_json::Value, +) -> async_graphql::Result { + use raphtory::db::graph::views::filter::model::{ + edge_filter::CompositeEdgeFilter, node_filter::CompositeNodeFilter, DynView, + }; + + if let Some(node_val) = filter.get("node") { + let gql_filter: GqlNodeFilter = serde_json::from_value(node_val.clone()) + .map_err(|e| async_graphql::Error::new(format!("node filter invalid: {e}")))?; + let raphtory_filter = CompositeNodeFilter::try_from(gql_filter) + .map_err(|e| async_graphql::Error::new(format!("node filter conversion: {e}")))?; + graph = blocking_compute({ + let g = graph.clone(); + move || g.filter(raphtory_filter) + }) + .await + .map_err(|e| async_graphql::Error::new(format!("node filter apply: {e}")))? + .into_dynamic(); + } + + if let Some(edge_val) = filter.get("edge") { + let gql_filter: GqlEdgeFilter = serde_json::from_value(edge_val.clone()) + .map_err(|e| async_graphql::Error::new(format!("edge filter invalid: {e}")))?; + let raphtory_filter = CompositeEdgeFilter::try_from(gql_filter) + .map_err(|e| async_graphql::Error::new(format!("edge filter conversion: {e}")))?; + graph = blocking_compute({ + let g = graph.clone(); + move || g.filter(raphtory_filter) + }) + .await + .map_err(|e| async_graphql::Error::new(format!("edge filter apply: {e}")))? + .into_dynamic(); + } + + if let Some(graph_val) = filter.get("graph") { + let gql_filter: GqlGraphFilter = serde_json::from_value(graph_val.clone()) + .map_err(|e| async_graphql::Error::new(format!("graph filter invalid: {e}")))?; + let dyn_view = DynView::try_from(gql_filter) + .map_err(|e| async_graphql::Error::new(format!("graph filter conversion: {e}")))?; + graph = blocking_compute({ + let g = graph.clone(); + move || g.filter(dyn_view) + }) + .await + .map_err(|e| async_graphql::Error::new(format!("graph filter apply: {e}")))? + .into_dynamic(); + } + + Ok(graph) +} + #[derive(ResolvedObject)] #[graphql(root)] pub(crate) struct QueryRoot; @@ -128,9 +188,26 @@ impl QueryRoot { }; let graph_with_vecs = data.get_graph(path).await?; + let graph: DynamicGraph = graph_with_vecs.graph.into_dynamic(); + + // Apply per-role data filter if one is configured (admin "a":"rw" bypasses this) + let graph = if !ctx.data::().is_ok_and(|a| a == &Access::Rw) { + if let Some(policy) = &data.auth_policy { + if let Some(filter_json) = policy.get_graph_data_filter(role, path) { + apply_graph_filter(graph, filter_json).await? + } else { + graph + } + } else { + graph + } + } else { + graph + }; + Ok(GqlGraph::new_with_permissions( graph_with_vecs.folder, - graph_with_vecs.graph, + graph, introspection_allowed, )) } From 676b6395f23b1d0ced229e02133f307f8ad011f4 Mon Sep 17 00:00:00 2001 From: shivamka1 <4599890+shivamka1@users.noreply.github.com> Date: Fri, 13 Mar 2026 15:10:32 +0000 Subject: [PATCH 11/64] fix test, fmt --- python/tests/test_permissions.py | 214 +++++++++++++++++- raphtory-graphql/src/auth.rs | 4 +- raphtory-graphql/src/lib.rs | 7 +- raphtory-graphql/src/model/graph/property.rs | 2 +- raphtory-graphql/src/model/graph/timeindex.rs | 4 +- 5 files changed, 222 insertions(+), 9 deletions(-) diff --git a/python/tests/test_permissions.py b/python/tests/test_permissions.py index 81ad81f2c9..62ec18186c 100644 --- a/python/tests/test_permissions.py +++ b/python/tests/test_permissions.py @@ -58,6 +58,14 @@ def grant_namespace(role: str, path: str, permissions: list) -> None: ) +def grant_graph_filtered_read_only(role: str, path: str, filter_gql: str) -> None: + """Call grantGraphFilteredReadOnly with a raw GQL filter fragment.""" + resp = gql( + f'mutation {{ permissions {{ grantGraphFilteredReadOnly(role: "{role}", path: "{path}", filter: {filter_gql}) {{ success }} }} }}' + ) + assert "errors" not in resp, f"grantGraphFilteredReadOnly failed: {resp}" + + def make_server(work_dir: str): """Create a GraphServer wired with a permissions store at {work_dir}/permissions.json.""" return GraphServer( @@ -214,7 +222,7 @@ def test_namespace_wildcard_grants_access_to_all_graphs(): # --- WRITE permission enforcement --- -UPDATE_JIRA = """mutation { updateGraph(path: "jira") { addNode(time: 1, name: "test_node") } }""" +UPDATE_JIRA = """query { updateGraph(path: "jira") { addNode(time: 1, name: "test_node") { success } } }""" CREATE_JIRA_NS = """mutation { newGraph(path:"team/jira", graphType:EVENT) }""" @@ -383,3 +391,207 @@ def test_analyst_cannot_get_role(): ) assert "errors" in response.json() assert "Access denied" in response.json()["errors"][0]["message"] + + +def test_analyst_sees_only_filtered_nodes(): + """grantGraphFilteredReadOnly applies a node filter transparently for the role. + + Admin sees all nodes; analyst only sees nodes matching the stored filter. + Calling grantGraph([READ]) clears the filter and restores full access. + """ + work_dir = tempfile.mkdtemp() + with make_server(work_dir).start(): + # Create graph and add nodes with a "region" property + gql(CREATE_JIRA) + for name, region in [("alice", "us-west"), ("bob", "us-east"), ("carol", "us-west")]: + resp = gql( + f"""query {{ + updateGraph(path: "jira") {{ + addNode( + time: 1, + name: "{name}", + properties: [{{ key: "region", value: {{ str: "{region}" }} }}] + ) {{ + success + node {{ + name + }} + }} + }} + }}""" + ) + assert resp["data"]["updateGraph"]["addNode"]["success"] is True, resp + + create_role("analyst") + # Grant filtered read-only: analyst only sees nodes where region = "us-west" + grant_graph_filtered_read_only( + "analyst", + "jira", + '{ node: { property: { name: "region", where: { eq: { str: "us-west" } } } } }', + ) + + QUERY_NODES = 'query { graph(path: "jira") { nodes { list { name } } } }' + + # Analyst should only see alice and carol (region=us-west) + analyst_response = requests.post( + RAPHTORY, headers=ANALYST_HEADERS, data=json.dumps({"query": QUERY_NODES}) + ) + assert "errors" not in analyst_response.json(), analyst_response.json() + analyst_names = { + n["name"] + for n in analyst_response.json()["data"]["graph"]["nodes"]["list"] + } + assert analyst_names == {"alice", "carol"}, f"expected {{alice, carol}}, got {analyst_names}" + + # Admin should see all three nodes (filter is bypassed for "a":"rw") + admin_response = requests.post( + RAPHTORY, headers=ADMIN_HEADERS, data=json.dumps({"query": QUERY_NODES}) + ) + assert "errors" not in admin_response.json(), admin_response.json() + admin_names = { + n["name"] + for n in admin_response.json()["data"]["graph"]["nodes"]["list"] + } + assert admin_names == {"alice", "bob", "carol"}, f"expected all 3 nodes, got {admin_names}" + + # Clear the filter by calling grantGraph([READ]) — analyst should now see all nodes + grant_graph("analyst", "jira", ["READ"]) + analyst_response_after = requests.post( + RAPHTORY, headers=ANALYST_HEADERS, data=json.dumps({"query": QUERY_NODES}) + ) + assert "errors" not in analyst_response_after.json(), analyst_response_after.json() + names_after = { + n["name"] + for n in analyst_response_after.json()["data"]["graph"]["nodes"]["list"] + } + assert names_after == {"alice", "bob", "carol"}, ( + f"after plain grant, expected all 3 nodes, got {names_after}" + ) + + +def test_analyst_sees_only_filtered_edges(): + """grantGraphFilteredReadOnly with an edge filter hides edges that don't match. + + Edges with weight >= 5 are visible; edges with weight < 5 are hidden. + Admin bypasses the filter and sees all edges. + """ + work_dir = tempfile.mkdtemp() + with make_server(work_dir).start(): + gql(CREATE_JIRA) + # Add three edges: (a->b weight=3), (b->c weight=7), (a->c weight=9) + for src, dst, weight in [("a", "b", 3), ("b", "c", 7), ("a", "c", 9)]: + resp = gql( + f"""query {{ + updateGraph(path: "jira") {{ + addEdge( + time: 1, + src: "{src}", + dst: "{dst}", + properties: [{{ key: "weight", value: {{ i64: {weight} }} }}] + ) {{ + success + edge {{ + src {{ name }} + dst {{ name }} + }} + }} + }} + }}""" + ) + assert resp["data"]["updateGraph"]["addEdge"]["success"] is True, resp + + create_role("analyst") + # Only show edges where weight >= 5 + grant_graph_filtered_read_only( + "analyst", + "jira", + '{ edge: { property: { name: "weight", where: { ge: { i64: 5 } } } } }', + ) + + QUERY_EDGES = 'query { graph(path: "jira") { edges { list { src { name } dst { name } } } } }' + + analyst_response = requests.post( + RAPHTORY, headers=ANALYST_HEADERS, data=json.dumps({"query": QUERY_EDGES}) + ) + assert "errors" not in analyst_response.json(), analyst_response.json() + analyst_edges = { + (e["src"]["name"], e["dst"]["name"]) + for e in analyst_response.json()["data"]["graph"]["edges"]["list"] + } + assert analyst_edges == {("b", "c"), ("a", "c")}, ( + f"expected only heavy edges, got {analyst_edges}" + ) + + # Admin sees all three edges + admin_response = requests.post( + RAPHTORY, headers=ADMIN_HEADERS, data=json.dumps({"query": QUERY_EDGES}) + ) + assert "errors" not in admin_response.json(), admin_response.json() + admin_edges = { + (e["src"]["name"], e["dst"]["name"]) + for e in admin_response.json()["data"]["graph"]["edges"]["list"] + } + assert admin_edges == {("a", "b"), ("b", "c"), ("a", "c")}, ( + f"expected all edges for admin, got {admin_edges}" + ) + + +def test_analyst_sees_only_graph_filter_window(): + """grantGraphFilteredReadOnly with a graph-level window filter restricts the temporal view. + + Nodes added inside the window [5, 15) are visible; those outside are not. + Admin bypasses the filter and sees all nodes. + """ + work_dir = tempfile.mkdtemp() + with make_server(work_dir).start(): + gql(CREATE_JIRA) + # Add nodes at different timestamps: t=1 (outside), t=10 (inside), t=20 (outside) + for name, t in [("early", 1), ("middle", 10), ("late", 20)]: + resp = gql( + f"""query {{ + updateGraph(path: "jira") {{ + addNode(time: {t}, name: "{name}") {{ + success + node {{ + name + }} + }} + }} + }}""" + ) + assert resp["data"]["updateGraph"]["addNode"]["success"] is True, resp + + create_role("analyst") + # Window [5, 15) — only "middle" (t=10) falls inside + grant_graph_filtered_read_only( + "analyst", + "jira", + "{ graph: { window: { start: 5, end: 15 } } }", + ) + + QUERY_NODES = 'query { graph(path: "jira") { nodes { list { name } } } }' + + analyst_response = requests.post( + RAPHTORY, headers=ANALYST_HEADERS, data=json.dumps({"query": QUERY_NODES}) + ) + assert "errors" not in analyst_response.json(), analyst_response.json() + analyst_names = { + n["name"] + for n in analyst_response.json()["data"]["graph"]["nodes"]["list"] + } + assert analyst_names == {"middle"}, ( + f"expected only 'middle' in window, got {analyst_names}" + ) + + # Admin sees all three nodes + admin_response = requests.post( + RAPHTORY, headers=ADMIN_HEADERS, data=json.dumps({"query": QUERY_NODES}) + ) + assert "errors" not in admin_response.json(), admin_response.json() + admin_names = { + n["name"] + for n in admin_response.json()["data"]["graph"]["nodes"]["list"] + } + assert admin_names == {"early", "middle", "late"}, ( + f"expected all nodes for admin, got {admin_names}" + ) diff --git a/raphtory-graphql/src/auth.rs b/raphtory-graphql/src/auth.rs index 7c7f590c5d..3781edd733 100644 --- a/raphtory-graphql/src/auth.rs +++ b/raphtory-graphql/src/auth.rs @@ -240,7 +240,9 @@ pub fn require_write_access_dynamic( if ctx.data::().is_ok_and(|a| a == &Access::Rw) { Ok(()) } else { - Err(async_graphql::Error::new("Access denied: write access required")) + Err(async_graphql::Error::new( + "Access denied: write access required", + )) } } diff --git a/raphtory-graphql/src/lib.rs b/raphtory-graphql/src/lib.rs index 82568f1e30..09a059f387 100644 --- a/raphtory-graphql/src/lib.rs +++ b/raphtory-graphql/src/lib.rs @@ -1,6 +1,7 @@ -pub use crate::auth::require_write_access_dynamic; -pub use crate::model::graph::filtering::GraphAccessFilter; -pub use crate::server::GraphServer; +pub use crate::{ + auth::require_write_access_dynamic, model::graph::filtering::GraphAccessFilter, + server::GraphServer, +}; use crate::{data::InsertionError, paths::PathValidationError}; use raphtory::errors::GraphError; use std::sync::Arc; diff --git a/raphtory-graphql/src/model/graph/property.rs b/raphtory-graphql/src/model/graph/property.rs index d1836248a4..d08b3f377c 100644 --- a/raphtory-graphql/src/model/graph/property.rs +++ b/raphtory-graphql/src/model/graph/property.rs @@ -11,7 +11,6 @@ use dynamic_graphql::{ InputObject, OneOfInput, ResolvedObject, ResolvedObjectFields, Scalar, ScalarValue, }; use itertools::Itertools; -use serde::{Deserialize, Serialize}; use raphtory::{ db::api::properties::{ dyn_props::{DynMetadata, DynProperties, DynProps, DynTemporalProperties}, @@ -29,6 +28,7 @@ use raphtory_api::core::{ utils::time::{IntoTime, TryIntoTime}, }; use rustc_hash::FxHashMap; +use serde::{Deserialize, Serialize}; use serde_json::Number; use std::{ collections::HashMap, diff --git a/raphtory-graphql/src/model/graph/timeindex.rs b/raphtory-graphql/src/model/graph/timeindex.rs index efb3f49e1d..bdb3630718 100644 --- a/raphtory-graphql/src/model/graph/timeindex.rs +++ b/raphtory-graphql/src/model/graph/timeindex.rs @@ -124,9 +124,7 @@ impl<'de> Deserialize<'de> for GqlTimeInput { .map_err(|e| D::Error::custom(e.to_string()))?, _ => return Err(D::Error::custom("timestamp must be number or string")), }; - let event_id = if let Some(id_val) = - obj.get("eventId").or_else(|| obj.get("id")) - { + let event_id = if let Some(id_val) = obj.get("eventId").or_else(|| obj.get("id")) { match id_val { serde_json::Value::Number(n) => n .as_u64() From 2a1101769534d9e6d82ae3f31197de99a7533fb2 Mon Sep 17 00:00:00 2001 From: shivamka1 <4599890+shivamka1@users.noreply.github.com> Date: Mon, 16 Mar 2026 09:17:04 +0000 Subject: [PATCH 12/64] fix test --- _Cargo.toml | 200 ----------------------------------- raphtory-graphql/src/auth.rs | 8 +- 2 files changed, 4 insertions(+), 204 deletions(-) delete mode 100644 _Cargo.toml diff --git a/_Cargo.toml b/_Cargo.toml deleted file mode 100644 index f79eef5551..0000000000 --- a/_Cargo.toml +++ /dev/null @@ -1,200 +0,0 @@ -[workspace] -members = [ - "raphtory", - "raphtory-benchmark", - "examples/rust", - "examples/netflow", - "examples/custom-gql-apis", - "python", - "raphtory-graphql", - "raphtory-auth-noop", - "raphtory-server", - "raphtory-api", - "raphtory-core", - "raphtory-storage", - "raphtory-api-macros", - "raphtory-itertools", - "clam-core", - "clam-core/snb", - "raphtory-itertools" -] -default-members = ["raphtory"] -exclude = ["optd"] -resolver = "2" - -[workspace.package] -version = "0.17.0" -documentation = "https://raphtory.readthedocs.io/en/latest/" -repository = "https://github.com/Raphtory/raphtory/" -license = "GPL-3.0" -readme = "README.md" -homepage = "https://github.com/Raphtory/raphtory/" -keywords = ["graph", "temporal-graph", "temporal"] -authors = ["Pometry"] -rust-version = "1.89.0" -edition = "2021" - -# debug symbols are using a lot of resources -[profile.dev] -split-debuginfo = "unpacked" -debug = 1 - -[profile.release-with-debug] -inherits = "release" -debug = true - -# use this if you really need debug symbols -[profile.with-debug] -inherits = "dev" -debug = true - -# for fast one-time builds (e.g., docs/CI) -[profile.build-fast] -inherits = "dev" -debug = false -incremental = false - - -[workspace.dependencies] -db4-graph = { version = "0.17.0", path = "db4-graph", default-features = false } -db4-storage = { version = "0.17.0", path = "db4-storage" } -raphtory = { version = "0.17.0", path = "raphtory", default-features = false } -raphtory-api = { version = "0.17.0", path = "raphtory-api", default-features = false } -raphtory-api-macros = { version = "0.17.0", path = "raphtory-api-macros", default-features = false } -raphtory-core = { version = "0.17.0", path = "raphtory-core", default-features = false } -raphtory-graphql = { version = "0.17.0", path = "raphtory-graphql", default-features = false } -raphtory-storage = { version = "0.17.0", path = "raphtory-storage", default-features = false } -raphtory-itertools = { version = "0.17.0", path = "raphtory-itertools" } -clam-core = { path = "clam-core" } -async-graphql = { version = "7.0.16", features = ["dynamic-schema"] } -bincode = { version = "2", features = ["serde"] } -async-graphql-poem = "7.0.16" -dynamic-graphql = "0.10.1" -derive_more = "2.0.1" -tikv-jemallocator = "0.6.1" -reqwest = { version = "0.12.8", default-features = false, features = [ - "rustls-tls", - "multipart", - "json", -] } -boxcar = "0.2.14" -iter-enum = { version = "1.2.0", features = ["rayon"] } -serde = { version = "1.0.197", features = ["derive", "rc"] } -serde_json = { version = "1.0.114", features = ["float_roundtrip"] } -pyo3 = { version = "0.27.2", features = ["multiple-pymethods", "chrono"] } -pyo3-build-config = "0.27.2" -pyo3-arrow = "0.15.0" -numpy = "0.27.1" -itertools = "0.13.0" -rand = "0.9.2" -rayon = "1.8.1" -roaring = "0.10.6" -sorted_vector_map = "0.2.0" -tokio = { version = "1.43.1", features = ["full"] } -once_cell = "1.19.0" -parking_lot = { version = "0.12.1", features = [ - "serde", - "arc_lock", - "send_guard", -] } -ordered-float = "4.2.0" -chrono = { version = "0.4.42", features = ["serde"] } -chrono-tz = "0.10.4" -tempfile = "3.10.0" -futures-util = "0.3.30" -thiserror = "2.0.0" -dotenv = "0.15.0" -csv = "1.3.0" -flate2 = "1.0.28" -regex = "1.10.3" -num-traits = "0.2.18" -num-integer = "0.1" -rand_distr = "0.5.1" -rustc-hash = "2.0.0" -twox-hash = "2.1.0" -tinyvec = { version = "1.10", features = ["serde", "alloc"] } -lock_api = { version = "0.4.11", features = ["arc_lock", "serde"] } -dashmap = { version = "6.0.1", features = ["serde", "rayon"] } -glam = "0.29.0" -quad-rand = "0.2.1" -zip = "2.3.0" -neo4rs = "0.8.0" -bzip2 = "0.4.4" -tantivy = "0.22.0" -async-trait = "0.1.77" -async-openai = "0.26.0" -num = "0.4.1" -display-error-chain = "0.2.0" -bigdecimal = { version = "0.4.7", features = ["serde"] } -kdam = "0.6.3" -hashbrown = { version = "0.14.5", features = ["raw"] } -pretty_assertions = "1.4.0" -streaming-stats = "0.2.3" -proptest = "1.8.0" -proptest-derive = "0.6.0" -criterion = "0.5.1" -crossbeam-channel = "0.5.15" -base64 = "0.22.1" -jsonwebtoken = "9.3.1" -spki = "0.7.3" -poem = { version = "3.0.1", features = ["compression", "embed", "static-files"] } -rust-embed = { version = "8.7.2", features = ["interpolate-folder-path"] } -opentelemetry = "0.27.1" -opentelemetry_sdk = { version = "0.27.1", features = ["rt-tokio"] } -opentelemetry-otlp = { version = "0.27.0" } -tracing = { version = "0.1.37", features = ["log"] } -tracing-opentelemetry = "0.28.0" -tracing-subscriber = { version = "0.3.20", features = ["std", "env-filter"] } -indoc = "2.0.5" -walkdir = "2" -config = "0.14.0" -either = "=1.15.0" -clap = { version = "4.5.21", features = ["derive", "env"] } -memmap2 = { version = "0.9.4" } -ahash = { version = "0.8.3", features = ["serde"] } -bytemuck = { version = "1.18.0", features = ["derive"] } -ouroboros = "0.18.3" -url = "2.2" -base64-compat = { package = "base64-compat", version = "1.0.0" } -prost = "0.13.1" -prost-types = "0.13.1" -prost-build = "0.13.1" -lazy_static = "1.4.0" -pest = "2.7.8" -pest_derive = "2.7.8" -minijinja = "2.2.0" -minijinja-contrib = { version = "2.2.0", features = ["datetime"] } -datafusion = { version = "50.0.0" } -arroy = "0.6.1" -heed = "0.22.0" -sqlparser = "0.58.0" -futures = "0.3" -arrow = { version = "57.2.0" } -parquet = { version = "57.2.0" } -arrow-json = { version = "57.2.0" } -arrow-buffer = { version = "57.2.0" } -arrow-schema = { version = "57.2.0" } -arrow-csv = { version = "57.2.0" } -arrow-array = { version = "57.2.0", features = ["chrono-tz"] } -arrow-cast = { version = "57.2.0" } -arrow-ipc = { version = "57.2.0" } -serde_arrow = { version = "0.13.6", features = ["arrow-57"] } -moka = { version = "0.12.7", features = ["future"] } -indexmap = { version = "2.7.0", features = ["rayon"] } -fake = { version = "3.1.0", features = ["chrono"] } -strsim = { version = "0.11.1" } -uuid = { version = "1.16.0", features = ["v4"] } -bitvec = "1.0.1" -sysinfo = "0.37.0" -strum = "0.27.2" -strum_macros = "0.27.2" -pythonize = { version = "0.27.0" } -test-log = "0.2.19" - -[workspace.dependencies.storage] -package = "db4-storage" -path = "db4-storage" - -[workspace.dependencies.auth] -package = "raphtory-auth-noop" -path = "raphtory-auth-noop" diff --git a/raphtory-graphql/src/auth.rs b/raphtory-graphql/src/auth.rs index 3781edd733..e7b231cf31 100644 --- a/raphtory-graphql/src/auth.rs +++ b/raphtory-graphql/src/auth.rs @@ -248,10 +248,10 @@ pub fn require_write_access_dynamic( impl<'a> ContextValidation for &Context<'a> { fn require_write_access(&self) -> Result<(), AuthError> { - if self.data::().is_ok_and(|role| role == &Access::Rw) { - Ok(()) - } else { - Err(AuthError::RequireWrite) + match self.data::() { + Ok(access) if access == &Access::Rw => Ok(()), + Err(_) => Ok(()), // no auth context (e.g. tests) — unrestricted + _ => Err(AuthError::RequireWrite), } } } From 599b14d6a75de0c1c2eea792a997891fb02ea795 Mon Sep 17 00:00:00 2001 From: shivamka1 <4599890+shivamka1@users.noreply.github.com> Date: Mon, 16 Mar 2026 09:21:37 +0000 Subject: [PATCH 13/64] fix workflow --- .github/workflows/benchmark.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/benchmark.yml b/.github/workflows/benchmark.yml index 5c9be5fef7..3f82d33ec3 100644 --- a/.github/workflows/benchmark.yml +++ b/.github/workflows/benchmark.yml @@ -49,8 +49,8 @@ jobs: run: | set -o pipefail cargo bench --bench base --bench algobench -p raphtory-benchmark -- --output-format=bencher | tee benchmark-result.txt - - name: Delete cargo.lock if it exists - run: rm -f Cargo.lock + - name: Restore Cargo.lock to avoid dirty working tree + run: git checkout -- Cargo.lock - name: Store benchmark results from master branch if: github.ref == 'refs/heads/master' uses: benchmark-action/github-action-benchmark@v1 From fae63b2f1175dcc5d8480cde7ad18fde0c3e45f6 Mon Sep 17 00:00:00 2001 From: shivamka1 <4599890+shivamka1@users.noreply.github.com> Date: Mon, 16 Mar 2026 14:15:56 +0000 Subject: [PATCH 14/64] fix tests --- .github/workflows/bench-graphql.yml | 6 +- python/tests/test_permissions.py | 100 ++++++++++++++++++++-- raphtory-graphql/src/model/graph/graph.rs | 100 ++++++++++++++-------- raphtory-graphql/src/model/mod.rs | 81 ++++++++++++------ 4 files changed, 221 insertions(+), 66 deletions(-) diff --git a/.github/workflows/bench-graphql.yml b/.github/workflows/bench-graphql.yml index 476831f509..0dd7fded4f 100644 --- a/.github/workflows/bench-graphql.yml +++ b/.github/workflows/bench-graphql.yml @@ -40,8 +40,10 @@ jobs: k6-version: '1.0.0' - name: Run GraphQL benchmarks run: cd graphql-bench && make bench-local - - name: Restore metadata file - run: git restore graphql-bench/data/apache/master # otherwise github-action-benchmark fails to create the commit + - name: Restore modified files + run: | + git restore Cargo.lock # modified by build; github-action-benchmark can't switch to gh-pages with dirty working tree + git restore graphql-bench/data/apache/master # otherwise github-action-benchmark fails to create the commit - name: Print bench results run: cat graphql-bench/output.json - name: Store benchmark results from master branch diff --git a/python/tests/test_permissions.py b/python/tests/test_permissions.py index 62ec18186c..5f2d9e5bc5 100644 --- a/python/tests/test_permissions.py +++ b/python/tests/test_permissions.py @@ -175,11 +175,11 @@ def test_introspection_denied_without_introspect_permission(): headers=ANALYST_HEADERS, data=json.dumps({"query": QUERY_COUNT_NODES}), ) - assert ( - response.json()["data"] is None - or response.json()["data"]["graph"] is None - ) - assert "Access denied" in response.json()["errors"][0]["message"] + errors = response.json().get("errors", []) + assert errors, response.json() + assert "Access denied" in errors[0]["message"] + assert "countNodes" in errors[0]["message"] + assert "introspect" in errors[0]["message"] def test_permissions_update_via_mutation(): @@ -393,6 +393,96 @@ def test_analyst_cannot_get_role(): assert "Access denied" in response.json()["errors"][0]["message"] +def test_introspect_only_can_call_introspection_fields(): + """A role with only INTROSPECT (no READ) can call countNodes/countEdges/schema/uniqueLayers.""" + work_dir = tempfile.mkdtemp() + with make_server(work_dir).start(): + gql(CREATE_JIRA) + create_role("analyst") + grant_graph("analyst", "jira", ["INTROSPECT"]) # no READ + + for query in [ + 'query { graph(path: "jira") { countNodes } }', + 'query { graph(path: "jira") { countEdges } }', + 'query { graph(path: "jira") { uniqueLayers } }', + ]: + response = requests.post( + RAPHTORY, headers=ANALYST_HEADERS, data=json.dumps({"query": query}) + ) + assert "errors" not in response.json(), f"query={query} response={response.json()}" + + +def test_introspect_only_cannot_read_nodes_or_edges(): + """A role with only INTROSPECT (no READ) is denied access to node/edge data.""" + work_dir = tempfile.mkdtemp() + with make_server(work_dir).start(): + gql(CREATE_JIRA) + # Add a node and edge so the graph has data to query + gql('query { updateGraph(path: "jira") { addNode(time: 1, name: "a") { success } } }') + gql('query { updateGraph(path: "jira") { addEdge(time: 1, src: "a", dst: "b") { success } } }') + create_role("analyst") + grant_graph("analyst", "jira", ["INTROSPECT"]) # no READ + + for query, expected_field in [ + ('query { graph(path: "jira") { nodes { list { name } } } }', "nodes"), + ('query { graph(path: "jira") { edges { list { src { name } } } } }', "edges"), + ('query { graph(path: "jira") { node(name: "a") { name } } }', "node"), + ('query { graph(path: "jira") { properties { values { key } } } }', "properties"), + ('query { graph(path: "jira") { earliestTime { timestamp } } }', "earliestTime"), + ]: + response = requests.post( + RAPHTORY, headers=ANALYST_HEADERS, data=json.dumps({"query": query}) + ) + errors = response.json().get("errors", []) + assert errors, f"expected denial for query={query!r}, got: {response.json()}" + msg = errors[0]["message"] + assert "Access denied" in msg and expected_field in msg and "read" in msg, ( + f"unexpected error for {expected_field!r}: {msg!r}" + ) + + +def test_read_only_cannot_call_introspection_fields(): + """A role with only READ (no INTROSPECT) is denied countNodes/countEdges/schema/uniqueLayers.""" + work_dir = tempfile.mkdtemp() + with make_server(work_dir).start(): + gql(CREATE_JIRA) + create_role("analyst") + grant_graph("analyst", "jira", ["READ"]) # no INTROSPECT + + for query, expected_field in [ + ('query { graph(path: "jira") { countNodes } }', "countNodes"), + ('query { graph(path: "jira") { countEdges } }', "countEdges"), + ('query { graph(path: "jira") { uniqueLayers } }', "uniqueLayers"), + ('query { graph(path: "jira") { schema { nodes { typeName } } } }', "schema"), + ]: + response = requests.post( + RAPHTORY, headers=ANALYST_HEADERS, data=json.dumps({"query": query}) + ) + errors = response.json().get("errors", []) + assert errors, f"expected denial for query={query!r}, got: {response.json()}" + msg = errors[0]["message"] + assert "Access denied" in msg and expected_field in msg and "introspect" in msg, ( + f"unexpected error for {expected_field!r}: {msg!r}" + ) + + +def test_introspect_only_is_denied_without_introspect_or_read(): + """A role with no permissions on a graph is denied even when trying introspection fields.""" + work_dir = tempfile.mkdtemp() + with make_server(work_dir).start(): + gql(CREATE_JIRA) + create_role("analyst") + # analyst has no grant on jira at all + + response = requests.post( + RAPHTORY, + headers=ANALYST_HEADERS, + data=json.dumps({"query": 'query { graph(path: "jira") { countNodes } }'}), + ) + assert response.json()["data"] is None + assert "Access denied" in response.json()["errors"][0]["message"] + + def test_analyst_sees_only_filtered_nodes(): """grantGraphFilteredReadOnly applies a node filter transparently for the role. diff --git a/raphtory-graphql/src/model/graph/graph.rs b/raphtory-graphql/src/model/graph/graph.rs index f70e68a143..47b578f64f 100644 --- a/raphtory-graphql/src/model/graph/graph.rs +++ b/raphtory-graphql/src/model/graph/graph.rs @@ -62,6 +62,8 @@ use std::{ pub(crate) struct GqlGraph { path: ExistingGraphFolder, graph: DynamicGraph, + /// Whether this role may read graph data (nodes, edges, properties). + read_allowed: bool, /// Whether this role may perform introspection on this graph /// (countNodes, countEdges, uniqueLayers, schema). Derived from auth policy at request time. introspection_allowed: bool, @@ -78,6 +80,7 @@ impl GqlGraph { Self { path, graph: graph.into_dynamic(), + read_allowed: true, introspection_allowed: true, } } @@ -85,11 +88,13 @@ impl GqlGraph { pub fn new_with_permissions( path: ExistingGraphFolder, graph: G, + read_allowed: bool, introspection_allowed: bool, ) -> Self { Self { path, graph: graph.into_dynamic(), + read_allowed, introspection_allowed, } } @@ -102,15 +107,25 @@ impl GqlGraph { Self { path: self.path.clone(), graph: graph_operation(&self.graph).into_dynamic(), + read_allowed: self.read_allowed, introspection_allowed: self.introspection_allowed, } } - fn require_introspection(&self) -> Result<()> { + fn require_read_access(&self, field: &str) -> Result<()> { + if !self.read_allowed { + return Err(async_graphql::Error::new(format!( + "Access denied: '{field}' requires read permission on this graph" + ))); + } + Ok(()) + } + + fn require_introspection(&self, field: &str) -> Result<()> { if !self.introspection_allowed { - return Err(async_graphql::Error::new( - "Access denied: introspection is not permitted for this role on this graph", - )); + return Err(async_graphql::Error::new(format!( + "Access denied: '{field}' requires introspect permission on this graph" + ))); } Ok(()) } @@ -124,7 +139,7 @@ impl GqlGraph { /// Returns the names of all layers in the graphview. async fn unique_layers(&self) -> Result> { - self.require_introspection()?; + self.require_introspection("uniqueLayers")?; let self_clone = self.clone(); Ok(blocking_compute(move || self_clone.graph.unique_layers().map_into().collect()).await) } @@ -311,62 +326,65 @@ impl GqlGraph { } /// Returns the time entry of the earliest activity in the graph. - async fn earliest_time(&self) -> GqlEventTime { + async fn earliest_time(&self) -> Result { + self.require_read_access("earliestTime")?; let self_clone = self.clone(); - blocking_compute(move || self_clone.graph.earliest_time().into()).await + Ok(blocking_compute(move || self_clone.graph.earliest_time().into()).await) } /// Returns the time entry of the latest activity in the graph. - async fn latest_time(&self) -> GqlEventTime { + async fn latest_time(&self) -> Result { + self.require_read_access("latestTime")?; let self_clone = self.clone(); - blocking_compute(move || self_clone.graph.latest_time().into()).await + Ok(blocking_compute(move || self_clone.graph.latest_time().into()).await) } /// Returns the start time of the window. Errors if there is no window. - async fn start(&self) -> GqlEventTime { - self.graph.start().into() + async fn start(&self) -> Result { + self.require_read_access("start")?; + Ok(self.graph.start().into()) } /// Returns the end time of the window. Errors if there is no window. - async fn end(&self) -> GqlEventTime { - self.graph.end().into() + async fn end(&self) -> Result { + self.require_read_access("end")?; + Ok(self.graph.end().into()) } /// Returns the earliest time that any edge in this graph is valid. - async fn earliest_edge_time(&self, include_negative: Option) -> GqlEventTime { + async fn earliest_edge_time(&self, include_negative: Option) -> Result { + self.require_read_access("earliestEdgeTime")?; let self_clone = self.clone(); - blocking_compute(move || { + Ok(blocking_compute(move || { let include_negative = include_negative.unwrap_or(true); - let all_edges = self_clone + self_clone .graph .edges() .earliest_time() .into_iter() .filter_map(|edge_time| edge_time.filter(|&time| include_negative || time.t() >= 0)) .min() - .into(); - all_edges + .into() }) - .await + .await) } /// Returns the latest time that any edge in this graph is valid. - async fn latest_edge_time(&self, include_negative: Option) -> GqlEventTime { + async fn latest_edge_time(&self, include_negative: Option) -> Result { + self.require_read_access("latestEdgeTime")?; let self_clone = self.clone(); - blocking_compute(move || { + Ok(blocking_compute(move || { let include_negative = include_negative.unwrap_or(true); - let all_edges = self_clone + self_clone .graph .edges() .latest_time() .into_iter() .filter_map(|edge_time| edge_time.filter(|&time| include_negative || time.t() >= 0)) .max() - .into(); - - all_edges + .into() }) - .await + .await) } //////////////////////// @@ -378,14 +396,14 @@ impl GqlGraph { /// Returns: /// int: async fn count_edges(&self) -> Result { - self.require_introspection()?; + self.require_introspection("countEdges")?; let self_clone = self.clone(); Ok(blocking_compute(move || self_clone.graph.count_edges()).await) } /// Returns the number of temporal edges in the graph. async fn count_temporal_edges(&self) -> Result { - self.require_introspection()?; + self.require_introspection("countTemporalEdges")?; let self_clone = self.clone(); Ok(blocking_compute(move || self_clone.graph.count_temporal_edges()).await) } @@ -394,7 +412,7 @@ impl GqlGraph { /// /// Optionally takes a list of node ids to return a subset. async fn count_nodes(&self) -> Result { - self.require_introspection()?; + self.require_introspection("countNodes")?; let self_clone = self.clone(); Ok(blocking_compute(move || self_clone.graph.count_nodes()).await) } @@ -405,11 +423,13 @@ impl GqlGraph { /// Returns true if the graph contains the specified node. async fn has_node(&self, name: String) -> Result { + self.require_read_access("hasNode")?; Ok(self.graph.has_node(name)) } /// Returns true if the graph contains the specified edge. Edges are specified by providing a source and destination node id. You can restrict the search to a specified layer. async fn has_edge(&self, src: String, dst: String, layer: Option) -> Result { + self.require_read_access("hasEdge")?; Ok(match layer { Some(name) => self .graph @@ -426,11 +446,13 @@ impl GqlGraph { /// Gets the node with the specified id. async fn node(&self, name: String) -> Result> { + self.require_read_access("node")?; Ok(self.graph.node(name).map(|node| node.into())) } /// Gets (optionally a subset of) the nodes in the graph. async fn nodes(&self, select: Option) -> Result { + self.require_read_access("nodes")?; let nn = self.graph.nodes(); if let Some(sel) = select { @@ -448,11 +470,13 @@ impl GqlGraph { /// Gets the edge with the specified source and destination nodes. async fn edge(&self, src: String, dst: String) -> Result> { + self.require_read_access("edge")?; Ok(self.graph.edge(src, dst).map(|e| e.into())) } /// Gets the edges in the graph. async fn edges<'a>(&self, select: Option) -> Result { + self.require_read_access("edges")?; let base = self.graph.edges_unlocked(); if let Some(sel) = select { @@ -469,13 +493,15 @@ impl GqlGraph { //////////////////////// /// Returns the properties of the graph. - async fn properties(&self) -> GqlProperties { - Into::::into(self.graph.properties()).into() + async fn properties(&self) -> Result { + self.require_read_access("properties")?; + Ok(Into::::into(self.graph.properties()).into()) } /// Returns the metadata of the graph. - async fn metadata(&self) -> GqlMetadata { - self.graph.metadata().into() + async fn metadata(&self) -> Result { + self.require_read_access("metadata")?; + Ok(self.graph.metadata().into()) } //////////////////////// @@ -506,7 +532,7 @@ impl GqlGraph { /// Returns the graph schema. async fn schema(&self) -> Result { - self.require_introspection()?; + self.require_introspection("schema")?; let self_clone = self.clone(); Ok(blocking_compute(move || GraphSchema::new(&self_clone.graph)).await) } @@ -516,6 +542,7 @@ impl GqlGraph { } async fn shared_neighbours(&self, selected_nodes: Vec) -> Result> { + self.require_read_access("sharedNeighbours")?; let self_clone = self.clone(); Ok(blocking_compute(move || { if selected_nodes.is_empty() { @@ -547,7 +574,8 @@ impl GqlGraph { } /// Export all nodes and edges from this graph view to another existing graph - async fn export_to<'a>(&self, ctx: &Context<'a>, path: String) -> Result { + async fn export_to<'a>(&self, ctx: &Context<'a>, path: String) -> Result { + self.require_read_access("exportTo")?; let data = ctx.data_unchecked::(); let other_g = data.get_graph(path.as_ref()).await?.graph; let g = self.graph.clone(); @@ -634,6 +662,7 @@ impl GqlGraph { limit: usize, offset: usize, ) -> Result> { + self.require_read_access("searchNodes")?; #[cfg(feature = "search")] { let self_clone = self.clone(); @@ -660,6 +689,7 @@ impl GqlGraph { limit: usize, offset: usize, ) -> Result> { + self.require_read_access("searchEdges")?; #[cfg(feature = "search")] { let self_clone = self.clone(); diff --git a/raphtory-graphql/src/model/mod.rs b/raphtory-graphql/src/model/mod.rs index 41049c0f4e..537d8c7fa0 100644 --- a/raphtory-graphql/src/model/mod.rs +++ b/raphtory-graphql/src/model/mod.rs @@ -165,26 +165,34 @@ impl QueryRoot { let data = ctx.data_unchecked::(); let role = ctx.data::>().ok().and_then(|r| r.as_deref()); - let introspection_allowed = if ctx.data::().is_ok_and(|a| a == &Access::Rw) { - true // "a": "rw" = admin, bypass all per-graph policy checks + let (read_allowed, introspection_allowed) = if ctx + .data::() + .is_ok_and(|a| a == &Access::Rw) + { + (true, true) // "a": "rw" = admin, bypass all per-graph policy checks } else if let Some(policy) = &data.auth_policy { match policy.check_graph_access(role, path) { Some(false) => { - let role_str = role.unwrap_or(""); - warn!( - role = role_str, - graph = path, - "Access denied by auth policy" - ); - return Err(async_graphql::Error::new(format!( - "Access denied: role '{role_str}' is not permitted to access graph '{path}'" - ))); + // No READ — introspect-only access is still allowed + if policy.check_graph_introspection(role, path) { + (false, true) + } else { + let role_str = role.unwrap_or(""); + warn!( + role = role_str, + graph = path, + "Access denied by auth policy" + ); + return Err(async_graphql::Error::new(format!( + "Access denied: role '{role_str}' is not permitted to access graph '{path}'" + ))); + } } - Some(true) => policy.check_graph_introspection(role, path), - None => true, // role not in store = no rules apply = full access + Some(true) => (true, policy.check_graph_introspection(role, path)), + None => (true, true), // role not in store = no rules apply = full access } } else { - true // no policy -> unrestricted + (true, true) // no policy -> unrestricted }; let graph_with_vecs = data.get_graph(path).await?; @@ -208,6 +216,7 @@ impl QueryRoot { Ok(GqlGraph::new_with_permissions( graph_with_vecs.folder, graph, + read_allowed, introspection_allowed, )) } @@ -222,11 +231,23 @@ impl QueryRoot { match &data.auth_policy { Some(policy) => { let role = ctx.data::>().ok().and_then(|r| r.as_deref()); - if !policy.check_graph_write_access(role, &path) { - let role_str = role.unwrap_or(""); - return Err(async_graphql::Error::new(format!( - "Access denied: role '{role_str}' does not have write permission for graph '{path}'" - ))); + match policy.check_graph_access(role, &path) { + // No active policy entries (empty store) — fall back to JWT-level check + None => ctx.require_write_access()?, + Some(false) => { + let role_str = role.unwrap_or(""); + return Err(async_graphql::Error::new(format!( + "Access denied: role '{role_str}' does not have write permission for graph '{path}'" + ))); + } + Some(true) => { + if !policy.check_graph_write_access(role, &path) { + let role_str = role.unwrap_or(""); + return Err(async_graphql::Error::new(format!( + "Access denied: role '{role_str}' does not have write permission for graph '{path}'" + ))); + } + } } } None => ctx.require_write_access()?, @@ -346,11 +367,23 @@ impl Mut { match &data.auth_policy { Some(policy) => { let role = ctx.data::>().ok().and_then(|r| r.as_deref()); - if !policy.check_graph_write_access(role, &path) { - let role_str = role.unwrap_or(""); - return Err(async_graphql::Error::new(format!( - "Access denied: role '{role_str}' does not have write permission for namespace '{path}'" - ))); + match policy.check_graph_access(role, &path) { + // No active policy entries (empty store) — fall back to JWT-level check + None => ctx.require_write_access()?, + Some(false) => { + let role_str = role.unwrap_or(""); + return Err(async_graphql::Error::new(format!( + "Access denied: role '{role_str}' does not have write permission for namespace '{path}'" + ))); + } + Some(true) => { + if !policy.check_graph_write_access(role, &path) { + let role_str = role.unwrap_or(""); + return Err(async_graphql::Error::new(format!( + "Access denied: role '{role_str}' does not have write permission for namespace '{path}'" + ))); + } + } } } None => ctx.require_write_access()?, From b20242c5d76057435932eb6536f2047bce0df868 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Mon, 16 Mar 2026 15:32:41 +0000 Subject: [PATCH 15/64] chore: apply tidy-public auto-fixes --- Cargo.lock | 37 +++-- docs/reference/graphql/graphql_API.md | 58 ++++++++ python/python/raphtory/graphql/__init__.pyi | 3 + python/tests/test_permissions.py | 144 ++++++++++++-------- raphtory-graphql/schema.graphql | 1 + 5 files changed, 178 insertions(+), 65 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index c8953c9187..6f1e9c9694 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1059,27 +1059,23 @@ dependencies = [ [[package]] name = "clam-core" -version = "0.1.0" +version = "0.17.0" dependencies = [ "ahash", "arrow", "async-trait", - "bincode 1.3.3", "chrono", - "chrono-tz 0.8.6", + "chrono-tz 0.10.4", "comfy-table", - "crc32fast", "criterion", - "dashmap", "db4-storage", "env_logger 0.10.2", "fastrand", "flate2", "insta", - "itertools 0.14.0", + "itertools 0.13.0", "log", "nom", - "once_cell", "optd-core", "parking_lot", "proptest", @@ -1097,7 +1093,8 @@ dependencies = [ "slotmap", "snb", "tempfile", - "thiserror 1.0.69", + "thiserror 2.0.17", + "tikv-jemallocator", "tokio", "tracing", "tracing-test", @@ -3643,7 +3640,7 @@ dependencies = [ [[package]] name = "optd-core" -version = "0.1.0" +version = "0.17.0" dependencies = [ "anyhow", "bitvec", @@ -5586,7 +5583,7 @@ checksum = "1b6b67fb9a61334225b5b790716f609cd58395f895b3fe8b328786812a40bc3b" [[package]] name = "snb" -version = "0.1.0" +version = "0.17.0" dependencies = [ "chrono", "flate2", @@ -6041,6 +6038,26 @@ dependencies = [ "ordered-float 2.10.1", ] +[[package]] +name = "tikv-jemalloc-sys" +version = "0.6.1+5.3.0-1-ge13ca993e8ccb9ba9847cc330696e02839f328f7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd8aa5b2ab86a2cefa406d889139c162cbb230092f7d1d7cbc1716405d852a3b" +dependencies = [ + "cc", + "libc", +] + +[[package]] +name = "tikv-jemallocator" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0359b4327f954e0567e69fb191cf1436617748813819c94b8cd4a431422d053a" +dependencies = [ + "libc", + "tikv-jemalloc-sys", +] + [[package]] name = "time" version = "0.3.45" diff --git a/docs/reference/graphql/graphql_API.md b/docs/reference/graphql/graphql_API.md index fb1f57bdce..9b6647fbb7 100644 --- a/docs/reference/graphql/graphql_API.md +++ b/docs/reference/graphql/graphql_API.md @@ -142,6 +142,15 @@ Returns:: Base64 url safe encoded string String! + +permissions +PermissionsQueryPlugin! + + +Returns the permissions namespace for inspecting roles and access policies (admin only). + + + @@ -163,6 +172,15 @@ Returns:: Base64 url safe encoded string Returns a collection of mutation plugins. + + + +permissions +PermissionsPlugin! + + +Returns the permissions namespace for managing roles and access policies. + @@ -5609,6 +5627,46 @@ will be returned. +### PermissionsPlugin + + + + + + + + + + + + + + + + + +
FieldArgumentTypeDescription
NoOpsString!
+ +### PermissionsQueryPlugin + + + + + + + + + + + + + + + + + +
FieldArgumentTypeDescription
NoOpsString!
+ ### Properties diff --git a/python/python/raphtory/graphql/__init__.pyi b/python/python/raphtory/graphql/__init__.pyi index e0d831f27b..9c3220c063 100644 --- a/python/python/raphtory/graphql/__init__.pyi +++ b/python/python/raphtory/graphql/__init__.pyi @@ -44,6 +44,7 @@ __all__ = [ "decode_graph", "schema", "cli", + "has_permissions_extension", ] class GraphServer(object): @@ -781,3 +782,5 @@ def schema(): """ def cli(): ... +def has_permissions_extension(): + """Returns True if the permissions extension (raphtory-auth) is compiled in.""" diff --git a/python/tests/test_permissions.py b/python/tests/test_permissions.py index 5f2d9e5bc5..7e1e00591d 100644 --- a/python/tests/test_permissions.py +++ b/python/tests/test_permissions.py @@ -317,7 +317,9 @@ def test_analyst_cannot_call_permissions_mutations(): RAPHTORY, headers=ANALYST_HEADERS, data=json.dumps( - {"query": 'mutation { permissions { createRole(name: "hacker") { success } } }'} + { + "query": 'mutation { permissions { createRole(name: "hacker") { success } } }' + } ), ) assert "errors" in response.json() @@ -409,7 +411,9 @@ def test_introspect_only_can_call_introspection_fields(): response = requests.post( RAPHTORY, headers=ANALYST_HEADERS, data=json.dumps({"query": query}) ) - assert "errors" not in response.json(), f"query={query} response={response.json()}" + assert ( + "errors" not in response.json() + ), f"query={query} response={response.json()}" def test_introspect_only_cannot_read_nodes_or_edges(): @@ -418,27 +422,42 @@ def test_introspect_only_cannot_read_nodes_or_edges(): with make_server(work_dir).start(): gql(CREATE_JIRA) # Add a node and edge so the graph has data to query - gql('query { updateGraph(path: "jira") { addNode(time: 1, name: "a") { success } } }') - gql('query { updateGraph(path: "jira") { addEdge(time: 1, src: "a", dst: "b") { success } } }') + gql( + 'query { updateGraph(path: "jira") { addNode(time: 1, name: "a") { success } } }' + ) + gql( + 'query { updateGraph(path: "jira") { addEdge(time: 1, src: "a", dst: "b") { success } } }' + ) create_role("analyst") grant_graph("analyst", "jira", ["INTROSPECT"]) # no READ for query, expected_field in [ ('query { graph(path: "jira") { nodes { list { name } } } }', "nodes"), - ('query { graph(path: "jira") { edges { list { src { name } } } } }', "edges"), + ( + 'query { graph(path: "jira") { edges { list { src { name } } } } }', + "edges", + ), ('query { graph(path: "jira") { node(name: "a") { name } } }', "node"), - ('query { graph(path: "jira") { properties { values { key } } } }', "properties"), - ('query { graph(path: "jira") { earliestTime { timestamp } } }', "earliestTime"), + ( + 'query { graph(path: "jira") { properties { values { key } } } }', + "properties", + ), + ( + 'query { graph(path: "jira") { earliestTime { timestamp } } }', + "earliestTime", + ), ]: response = requests.post( RAPHTORY, headers=ANALYST_HEADERS, data=json.dumps({"query": query}) ) errors = response.json().get("errors", []) - assert errors, f"expected denial for query={query!r}, got: {response.json()}" + assert ( + errors + ), f"expected denial for query={query!r}, got: {response.json()}" msg = errors[0]["message"] - assert "Access denied" in msg and expected_field in msg and "read" in msg, ( - f"unexpected error for {expected_field!r}: {msg!r}" - ) + assert ( + "Access denied" in msg and expected_field in msg and "read" in msg + ), f"unexpected error for {expected_field!r}: {msg!r}" def test_read_only_cannot_call_introspection_fields(): @@ -453,17 +472,22 @@ def test_read_only_cannot_call_introspection_fields(): ('query { graph(path: "jira") { countNodes } }', "countNodes"), ('query { graph(path: "jira") { countEdges } }', "countEdges"), ('query { graph(path: "jira") { uniqueLayers } }', "uniqueLayers"), - ('query { graph(path: "jira") { schema { nodes { typeName } } } }', "schema"), + ( + 'query { graph(path: "jira") { schema { nodes { typeName } } } }', + "schema", + ), ]: response = requests.post( RAPHTORY, headers=ANALYST_HEADERS, data=json.dumps({"query": query}) ) errors = response.json().get("errors", []) - assert errors, f"expected denial for query={query!r}, got: {response.json()}" + assert ( + errors + ), f"expected denial for query={query!r}, got: {response.json()}" msg = errors[0]["message"] - assert "Access denied" in msg and expected_field in msg and "introspect" in msg, ( - f"unexpected error for {expected_field!r}: {msg!r}" - ) + assert ( + "Access denied" in msg and expected_field in msg and "introspect" in msg + ), f"unexpected error for {expected_field!r}: {msg!r}" def test_introspect_only_is_denied_without_introspect_or_read(): @@ -493,9 +517,12 @@ def test_analyst_sees_only_filtered_nodes(): with make_server(work_dir).start(): # Create graph and add nodes with a "region" property gql(CREATE_JIRA) - for name, region in [("alice", "us-west"), ("bob", "us-east"), ("carol", "us-west")]: - resp = gql( - f"""query {{ + for name, region in [ + ("alice", "us-west"), + ("bob", "us-east"), + ("carol", "us-west"), + ]: + resp = gql(f"""query {{ updateGraph(path: "jira") {{ addNode( time: 1, @@ -508,8 +535,7 @@ def test_analyst_sees_only_filtered_nodes(): }} }} }} - }}""" - ) + }}""") assert resp["data"]["updateGraph"]["addNode"]["success"] is True, resp create_role("analyst") @@ -528,10 +554,12 @@ def test_analyst_sees_only_filtered_nodes(): ) assert "errors" not in analyst_response.json(), analyst_response.json() analyst_names = { - n["name"] - for n in analyst_response.json()["data"]["graph"]["nodes"]["list"] + n["name"] for n in analyst_response.json()["data"]["graph"]["nodes"]["list"] } - assert analyst_names == {"alice", "carol"}, f"expected {{alice, carol}}, got {analyst_names}" + assert analyst_names == { + "alice", + "carol", + }, f"expected {{alice, carol}}, got {analyst_names}" # Admin should see all three nodes (filter is bypassed for "a":"rw") admin_response = requests.post( @@ -539,24 +567,31 @@ def test_analyst_sees_only_filtered_nodes(): ) assert "errors" not in admin_response.json(), admin_response.json() admin_names = { - n["name"] - for n in admin_response.json()["data"]["graph"]["nodes"]["list"] + n["name"] for n in admin_response.json()["data"]["graph"]["nodes"]["list"] } - assert admin_names == {"alice", "bob", "carol"}, f"expected all 3 nodes, got {admin_names}" + assert admin_names == { + "alice", + "bob", + "carol", + }, f"expected all 3 nodes, got {admin_names}" # Clear the filter by calling grantGraph([READ]) — analyst should now see all nodes grant_graph("analyst", "jira", ["READ"]) analyst_response_after = requests.post( RAPHTORY, headers=ANALYST_HEADERS, data=json.dumps({"query": QUERY_NODES}) ) - assert "errors" not in analyst_response_after.json(), analyst_response_after.json() + assert ( + "errors" not in analyst_response_after.json() + ), analyst_response_after.json() names_after = { n["name"] for n in analyst_response_after.json()["data"]["graph"]["nodes"]["list"] } - assert names_after == {"alice", "bob", "carol"}, ( - f"after plain grant, expected all 3 nodes, got {names_after}" - ) + assert names_after == { + "alice", + "bob", + "carol", + }, f"after plain grant, expected all 3 nodes, got {names_after}" def test_analyst_sees_only_filtered_edges(): @@ -570,8 +605,7 @@ def test_analyst_sees_only_filtered_edges(): gql(CREATE_JIRA) # Add three edges: (a->b weight=3), (b->c weight=7), (a->c weight=9) for src, dst, weight in [("a", "b", 3), ("b", "c", 7), ("a", "c", 9)]: - resp = gql( - f"""query {{ + resp = gql(f"""query {{ updateGraph(path: "jira") {{ addEdge( time: 1, @@ -586,8 +620,7 @@ def test_analyst_sees_only_filtered_edges(): }} }} }} - }}""" - ) + }}""") assert resp["data"]["updateGraph"]["addEdge"]["success"] is True, resp create_role("analyst") @@ -608,9 +641,10 @@ def test_analyst_sees_only_filtered_edges(): (e["src"]["name"], e["dst"]["name"]) for e in analyst_response.json()["data"]["graph"]["edges"]["list"] } - assert analyst_edges == {("b", "c"), ("a", "c")}, ( - f"expected only heavy edges, got {analyst_edges}" - ) + assert analyst_edges == { + ("b", "c"), + ("a", "c"), + }, f"expected only heavy edges, got {analyst_edges}" # Admin sees all three edges admin_response = requests.post( @@ -621,9 +655,11 @@ def test_analyst_sees_only_filtered_edges(): (e["src"]["name"], e["dst"]["name"]) for e in admin_response.json()["data"]["graph"]["edges"]["list"] } - assert admin_edges == {("a", "b"), ("b", "c"), ("a", "c")}, ( - f"expected all edges for admin, got {admin_edges}" - ) + assert admin_edges == { + ("a", "b"), + ("b", "c"), + ("a", "c"), + }, f"expected all edges for admin, got {admin_edges}" def test_analyst_sees_only_graph_filter_window(): @@ -637,8 +673,7 @@ def test_analyst_sees_only_graph_filter_window(): gql(CREATE_JIRA) # Add nodes at different timestamps: t=1 (outside), t=10 (inside), t=20 (outside) for name, t in [("early", 1), ("middle", 10), ("late", 20)]: - resp = gql( - f"""query {{ + resp = gql(f"""query {{ updateGraph(path: "jira") {{ addNode(time: {t}, name: "{name}") {{ success @@ -647,8 +682,7 @@ def test_analyst_sees_only_graph_filter_window(): }} }} }} - }}""" - ) + }}""") assert resp["data"]["updateGraph"]["addNode"]["success"] is True, resp create_role("analyst") @@ -666,12 +700,11 @@ def test_analyst_sees_only_graph_filter_window(): ) assert "errors" not in analyst_response.json(), analyst_response.json() analyst_names = { - n["name"] - for n in analyst_response.json()["data"]["graph"]["nodes"]["list"] + n["name"] for n in analyst_response.json()["data"]["graph"]["nodes"]["list"] } - assert analyst_names == {"middle"}, ( - f"expected only 'middle' in window, got {analyst_names}" - ) + assert analyst_names == { + "middle" + }, f"expected only 'middle' in window, got {analyst_names}" # Admin sees all three nodes admin_response = requests.post( @@ -679,9 +712,10 @@ def test_analyst_sees_only_graph_filter_window(): ) assert "errors" not in admin_response.json(), admin_response.json() admin_names = { - n["name"] - for n in admin_response.json()["data"]["graph"]["nodes"]["list"] + n["name"] for n in admin_response.json()["data"]["graph"]["nodes"]["list"] } - assert admin_names == {"early", "middle", "late"}, ( - f"expected all nodes for admin, got {admin_names}" - ) + assert admin_names == { + "early", + "middle", + "late", + }, f"expected all nodes for admin, got {admin_names}" diff --git a/raphtory-graphql/schema.graphql b/raphtory-graphql/schema.graphql index 28040f74df..68275758b9 100644 --- a/raphtory-graphql/schema.graphql +++ b/raphtory-graphql/schema.graphql @@ -3467,3 +3467,4 @@ schema { query: QueryRoot mutation: MutRoot } + From b332f7c8882cfff94273979bfe07289fa2e498b3 Mon Sep 17 00:00:00 2001 From: shivamka1 <4599890+shivamka1@users.noreply.github.com> Date: Tue, 17 Mar 2026 13:46:00 +0000 Subject: [PATCH 16/64] impl heirarchy based permissions, fix tests --- python/tests/test_permissions.py | 339 +++++++++++-------------------- 1 file changed, 118 insertions(+), 221 deletions(-) diff --git a/python/tests/test_permissions.py b/python/tests/test_permissions.py index 5f2d9e5bc5..e7b8d64063 100644 --- a/python/tests/test_permissions.py +++ b/python/tests/test_permissions.py @@ -44,17 +44,15 @@ def create_role(role: str) -> None: gql(f'mutation {{ permissions {{ createRole(name: "{role}") {{ success }} }} }}') -def grant_graph(role: str, path: str, permissions: list) -> None: - perms = "[" + ", ".join(permissions) + "]" +def grant_graph(role: str, path: str, permission: str) -> None: gql( - f'mutation {{ permissions {{ grantGraph(role: "{role}", path: "{path}", permissions: {perms}) {{ success }} }} }}' + f'mutation {{ permissions {{ grantGraph(role: "{role}", path: "{path}", permission: {permission}) {{ success }} }} }}' ) -def grant_namespace(role: str, path: str, permissions: list) -> None: - perms = "[" + ", ".join(permissions) + "]" +def grant_namespace(role: str, path: str, permission: str) -> None: gql( - f'mutation {{ permissions {{ grantNamespace(role: "{role}", path: "{path}", permissions: {perms}) {{ success }} }} }}' + f'mutation {{ permissions {{ grantNamespace(role: "{role}", path: "{path}", permission: {permission}) {{ success }} }} }}' ) @@ -82,14 +80,12 @@ def test_analyst_can_access_permitted_graph(): gql(CREATE_ADMIN) create_role("analyst") create_role("admin") - grant_graph("analyst", "jira", ["READ"]) - grant_namespace("admin", "*", ["READ"]) + grant_graph("analyst", "jira", "READ") + grant_namespace("admin", "*", "READ") - response = requests.post( - RAPHTORY, headers=ANALYST_HEADERS, data=json.dumps({"query": QUERY_JIRA}) - ) - assert "errors" not in response.json(), response.json() - assert response.json()["data"]["graph"]["path"] == "jira" + response = gql(QUERY_JIRA, headers=ANALYST_HEADERS) + assert "errors" not in response, response + assert response["data"]["graph"]["path"] == "jira" def test_analyst_cannot_access_denied_graph(): @@ -97,13 +93,11 @@ def test_analyst_cannot_access_denied_graph(): with make_server(work_dir).start(): gql(CREATE_ADMIN) create_role("analyst") - grant_graph("analyst", "jira", ["READ"]) # only jira, not admin + grant_graph("analyst", "jira", "READ") # only jira, not admin - response = requests.post( - RAPHTORY, headers=ANALYST_HEADERS, data=json.dumps({"query": QUERY_ADMIN}) - ) - assert response.json()["data"] is None - assert "Access denied" in response.json()["errors"][0]["message"] + response = gql(QUERY_ADMIN, headers=ANALYST_HEADERS) + assert response["data"] is None + assert "Access denied" in response["errors"][0]["message"] def test_admin_can_access_all_graphs(): @@ -112,13 +106,11 @@ def test_admin_can_access_all_graphs(): gql(CREATE_JIRA) gql(CREATE_ADMIN) create_role("admin") - grant_namespace("admin", "*", ["READ"]) + grant_namespace("admin", "*", "READ") for query in [QUERY_JIRA, QUERY_ADMIN]: - response = requests.post( - RAPHTORY, headers=ADMIN_HEADERS, data=json.dumps({"query": query}) - ) - assert "errors" not in response.json(), response.json() + response = gql(query, headers=ADMIN_HEADERS) + assert "errors" not in response, response def test_no_role_is_denied_when_policy_is_active(): @@ -126,13 +118,11 @@ def test_no_role_is_denied_when_policy_is_active(): with make_server(work_dir).start(): gql(CREATE_JIRA) create_role("analyst") - grant_graph("analyst", "jira", ["READ"]) + grant_graph("analyst", "jira", "READ") - response = requests.post( - RAPHTORY, headers=NO_ROLE_HEADERS, data=json.dumps({"query": QUERY_JIRA}) - ) - assert response.json()["data"] is None - assert "Access denied" in response.json()["errors"][0]["message"] + response = gql(QUERY_JIRA, headers=NO_ROLE_HEADERS) + assert response["data"] is None + assert "Access denied" in response["errors"][0]["message"] def test_empty_store_gives_full_access(): @@ -141,45 +131,34 @@ def test_empty_store_gives_full_access(): with make_server(work_dir).start(): gql(CREATE_JIRA) - response = requests.post( - RAPHTORY, headers=ANALYST_HEADERS, data=json.dumps({"query": QUERY_JIRA}) - ) - assert "errors" not in response.json(), response.json() + response = gql(QUERY_JIRA, headers=ANALYST_HEADERS) + assert "errors" not in response, response def test_introspection_allowed_with_introspect_permission(): + """INTROSPECT-only role can call countNodes.""" work_dir = tempfile.mkdtemp() with make_server(work_dir).start(): gql(CREATE_JIRA) create_role("analyst") - grant_graph("analyst", "jira", ["READ", "INTROSPECT"]) + grant_graph("analyst", "jira", "INTROSPECT") - response = requests.post( - RAPHTORY, - headers=ANALYST_HEADERS, - data=json.dumps({"query": QUERY_COUNT_NODES}), - ) - assert "errors" not in response.json(), response.json() - assert isinstance(response.json()["data"]["graph"]["countNodes"], int) + response = gql(QUERY_COUNT_NODES, headers=ANALYST_HEADERS) + assert "errors" not in response, response + assert isinstance(response["data"]["graph"]["countNodes"], int) -def test_introspection_denied_without_introspect_permission(): +def test_read_implies_introspect(): + """READ implies INTROSPECT: a role with READ can call countNodes without an explicit INTROSPECT grant.""" work_dir = tempfile.mkdtemp() with make_server(work_dir).start(): gql(CREATE_JIRA) create_role("analyst") - grant_graph("analyst", "jira", ["READ"]) # READ only, no INTROSPECT + grant_graph("analyst", "jira", "READ") # READ implies INTROSPECT - response = requests.post( - RAPHTORY, - headers=ANALYST_HEADERS, - data=json.dumps({"query": QUERY_COUNT_NODES}), - ) - errors = response.json().get("errors", []) - assert errors, response.json() - assert "Access denied" in errors[0]["message"] - assert "countNodes" in errors[0]["message"] - assert "introspect" in errors[0]["message"] + response = gql(QUERY_COUNT_NODES, headers=ANALYST_HEADERS) + assert "errors" not in response, response + assert isinstance(response["data"]["graph"]["countNodes"], int) def test_permissions_update_via_mutation(): @@ -190,19 +169,15 @@ def test_permissions_update_via_mutation(): create_role("analyst") # No grants yet — denied - response = requests.post( - RAPHTORY, headers=ANALYST_HEADERS, data=json.dumps({"query": QUERY_JIRA}) - ) - assert "Access denied" in response.json()["errors"][0]["message"] + response = gql(QUERY_JIRA, headers=ANALYST_HEADERS) + assert "Access denied" in response["errors"][0]["message"] # Grant via mutation - grant_graph("analyst", "jira", ["READ"]) + grant_graph("analyst", "jira", "READ") - response = requests.post( - RAPHTORY, headers=ANALYST_HEADERS, data=json.dumps({"query": QUERY_JIRA}) - ) - assert "errors" not in response.json(), response.json() - assert response.json()["data"]["graph"]["path"] == "jira" + response = gql(QUERY_JIRA, headers=ANALYST_HEADERS) + assert "errors" not in response, response + assert response["data"]["graph"]["path"] == "jira" def test_namespace_wildcard_grants_access_to_all_graphs(): @@ -211,13 +186,11 @@ def test_namespace_wildcard_grants_access_to_all_graphs(): gql(CREATE_JIRA) gql(CREATE_ADMIN) create_role("analyst") - grant_namespace("analyst", "*", ["READ"]) + grant_namespace("analyst", "*", "READ") for query in [QUERY_JIRA, QUERY_ADMIN]: - response = requests.post( - RAPHTORY, headers=ANALYST_HEADERS, data=json.dumps({"query": query}) - ) - assert "errors" not in response.json(), response.json() + response = gql(query, headers=ANALYST_HEADERS) + assert "errors" not in response, response # --- WRITE permission enforcement --- @@ -233,13 +206,11 @@ def test_admin_bypasses_policy_for_reads(): gql(CREATE_JIRA) # Policy is active (analyst role exists) but admin has no role entry create_role("analyst") - grant_graph("analyst", "jira", ["READ"]) + grant_graph("analyst", "jira", "READ") - response = requests.post( - RAPHTORY, headers=ADMIN_HEADERS, data=json.dumps({"query": QUERY_JIRA}) - ) - assert "errors" not in response.json(), response.json() - assert response.json()["data"]["graph"]["path"] == "jira" + response = gql(QUERY_JIRA, headers=ADMIN_HEADERS) + assert "errors" not in response, response + assert response["data"]["graph"]["path"] == "jira" def test_analyst_can_write_with_write_grant(): @@ -248,12 +219,10 @@ def test_analyst_can_write_with_write_grant(): with make_server(work_dir).start(): gql(CREATE_JIRA) create_role("analyst") - grant_graph("analyst", "jira", ["READ", "WRITE"]) + grant_graph("analyst", "jira", "WRITE") - response = requests.post( - RAPHTORY, headers=ANALYST_HEADERS, data=json.dumps({"query": UPDATE_JIRA}) - ) - assert "errors" not in response.json(), response.json() + response = gql(UPDATE_JIRA, headers=ANALYST_HEADERS) + assert "errors" not in response, response def test_analyst_cannot_write_without_write_grant(): @@ -262,17 +231,15 @@ def test_analyst_cannot_write_without_write_grant(): with make_server(work_dir).start(): gql(CREATE_JIRA) create_role("analyst") - grant_graph("analyst", "jira", ["READ"]) # READ only, no WRITE + grant_graph("analyst", "jira", "READ") # READ only, no WRITE - response = requests.post( - RAPHTORY, headers=ANALYST_HEADERS, data=json.dumps({"query": UPDATE_JIRA}) - ) + response = gql(UPDATE_JIRA, headers=ANALYST_HEADERS) assert ( - response.json()["data"] is None - or response.json()["data"].get("updateGraph") is None + response["data"] is None + or response["data"].get("updateGraph") is None ) - assert "errors" in response.json() - assert "Access denied" in response.json()["errors"][0]["message"] + assert "errors" in response + assert "Access denied" in response["errors"][0]["message"] def test_analyst_can_create_graph_in_namespace(): @@ -280,14 +247,10 @@ def test_analyst_can_create_graph_in_namespace(): work_dir = tempfile.mkdtemp() with make_server(work_dir).start(): create_role("analyst") - grant_namespace("analyst", "team/", ["READ", "WRITE"]) + grant_namespace("analyst", "team/", "WRITE") - response = requests.post( - RAPHTORY, - headers=ANALYST_HEADERS, - data=json.dumps({"query": CREATE_JIRA_NS}), - ) - assert "errors" not in response.json(), response.json() + response = gql(CREATE_JIRA_NS, headers=ANALYST_HEADERS) + assert "errors" not in response, response def test_analyst_cannot_create_graph_outside_namespace(): @@ -295,15 +258,11 @@ def test_analyst_cannot_create_graph_outside_namespace(): work_dir = tempfile.mkdtemp() with make_server(work_dir).start(): create_role("analyst") - grant_namespace("analyst", "team/", ["READ", "WRITE"]) + grant_namespace("analyst", "team/", "WRITE") - response = requests.post( - RAPHTORY, - headers=ANALYST_HEADERS, - data=json.dumps({"query": CREATE_JIRA}), # "jira" not under "team/" - ) - assert "errors" in response.json() - assert "Access denied" in response.json()["errors"][0]["message"] + response = gql(CREATE_JIRA, headers=ANALYST_HEADERS) # "jira" not under "team/" + assert "errors" in response + assert "Access denied" in response["errors"][0]["message"] def test_analyst_cannot_call_permissions_mutations(): @@ -311,17 +270,14 @@ def test_analyst_cannot_call_permissions_mutations(): work_dir = tempfile.mkdtemp() with make_server(work_dir).start(): create_role("analyst") - grant_namespace("analyst", "*", ["READ", "WRITE"]) + grant_namespace("analyst", "*", "WRITE") - response = requests.post( - RAPHTORY, + response = gql( + 'mutation { permissions { createRole(name: "hacker") { success } } }', headers=ANALYST_HEADERS, - data=json.dumps( - {"query": 'mutation { permissions { createRole(name: "hacker") { success } } }'} - ), ) - assert "errors" in response.json() - assert "Access denied" in response.json()["errors"][0]["message"] + assert "errors" in response + assert "Access denied" in response["errors"][0]["message"] def test_admin_can_list_roles(): @@ -330,13 +286,9 @@ def test_admin_can_list_roles(): with make_server(work_dir).start(): create_role("analyst") - response = requests.post( - RAPHTORY, - headers=ADMIN_HEADERS, - data=json.dumps({"query": "query { permissions { listRoles } }"}), - ) - assert "errors" not in response.json(), response.json() - assert "analyst" in response.json()["data"]["permissions"]["listRoles"] + response = gql("query { permissions { listRoles } }", headers=ADMIN_HEADERS) + assert "errors" not in response, response + assert "analyst" in response["data"]["permissions"]["listRoles"] def test_analyst_cannot_list_roles(): @@ -345,13 +297,9 @@ def test_analyst_cannot_list_roles(): with make_server(work_dir).start(): create_role("analyst") - response = requests.post( - RAPHTORY, - headers=ANALYST_HEADERS, - data=json.dumps({"query": "query { permissions { listRoles } }"}), - ) - assert "errors" in response.json() - assert "Access denied" in response.json()["errors"][0]["message"] + response = gql("query { permissions { listRoles } }", headers=ANALYST_HEADERS) + assert "errors" in response + assert "Access denied" in response["errors"][0]["message"] def test_admin_can_get_role(): @@ -359,21 +307,17 @@ def test_admin_can_get_role(): work_dir = tempfile.mkdtemp() with make_server(work_dir).start(): create_role("analyst") - grant_graph("analyst", "jira", ["READ"]) + grant_graph("analyst", "jira", "READ") - response = requests.post( - RAPHTORY, + response = gql( + 'query { permissions { getRole(name: "analyst") { name graphs { path permission } } } }', headers=ADMIN_HEADERS, - data=json.dumps( - { - "query": 'query { permissions { getRole(name: "analyst") { name graphs { path permissions } } } }' - } - ), ) - assert "errors" not in response.json(), response.json() - role_data = response.json()["data"]["permissions"]["getRole"] + assert "errors" not in response, response + role_data = response["data"]["permissions"]["getRole"] assert role_data["name"] == "analyst" assert role_data["graphs"][0]["path"] == "jira" + assert role_data["graphs"][0]["permission"] == "READ" def test_analyst_cannot_get_role(): @@ -382,15 +326,12 @@ def test_analyst_cannot_get_role(): with make_server(work_dir).start(): create_role("analyst") - response = requests.post( - RAPHTORY, + response = gql( + 'query { permissions { getRole(name: "analyst") { name } } }', headers=ANALYST_HEADERS, - data=json.dumps( - {"query": 'query { permissions { getRole(name: "analyst") { name } } }'} - ), ) - assert "errors" in response.json() - assert "Access denied" in response.json()["errors"][0]["message"] + assert "errors" in response + assert "Access denied" in response["errors"][0]["message"] def test_introspect_only_can_call_introspection_fields(): @@ -399,17 +340,15 @@ def test_introspect_only_can_call_introspection_fields(): with make_server(work_dir).start(): gql(CREATE_JIRA) create_role("analyst") - grant_graph("analyst", "jira", ["INTROSPECT"]) # no READ + grant_graph("analyst", "jira", "INTROSPECT") # no READ for query in [ 'query { graph(path: "jira") { countNodes } }', 'query { graph(path: "jira") { countEdges } }', 'query { graph(path: "jira") { uniqueLayers } }', ]: - response = requests.post( - RAPHTORY, headers=ANALYST_HEADERS, data=json.dumps({"query": query}) - ) - assert "errors" not in response.json(), f"query={query} response={response.json()}" + response = gql(query, headers=ANALYST_HEADERS) + assert "errors" not in response, f"query={query} response={response}" def test_introspect_only_cannot_read_nodes_or_edges(): @@ -421,7 +360,7 @@ def test_introspect_only_cannot_read_nodes_or_edges(): gql('query { updateGraph(path: "jira") { addNode(time: 1, name: "a") { success } } }') gql('query { updateGraph(path: "jira") { addEdge(time: 1, src: "a", dst: "b") { success } } }') create_role("analyst") - grant_graph("analyst", "jira", ["INTROSPECT"]) # no READ + grant_graph("analyst", "jira", "INTROSPECT") # no READ for query, expected_field in [ ('query { graph(path: "jira") { nodes { list { name } } } }', "nodes"), @@ -430,42 +369,15 @@ def test_introspect_only_cannot_read_nodes_or_edges(): ('query { graph(path: "jira") { properties { values { key } } } }', "properties"), ('query { graph(path: "jira") { earliestTime { timestamp } } }', "earliestTime"), ]: - response = requests.post( - RAPHTORY, headers=ANALYST_HEADERS, data=json.dumps({"query": query}) - ) - errors = response.json().get("errors", []) - assert errors, f"expected denial for query={query!r}, got: {response.json()}" + response = gql(query, headers=ANALYST_HEADERS) + errors = response.get("errors", []) + assert errors, f"expected denial for query={query!r}, got: {response}" msg = errors[0]["message"] assert "Access denied" in msg and expected_field in msg and "read" in msg, ( f"unexpected error for {expected_field!r}: {msg!r}" ) -def test_read_only_cannot_call_introspection_fields(): - """A role with only READ (no INTROSPECT) is denied countNodes/countEdges/schema/uniqueLayers.""" - work_dir = tempfile.mkdtemp() - with make_server(work_dir).start(): - gql(CREATE_JIRA) - create_role("analyst") - grant_graph("analyst", "jira", ["READ"]) # no INTROSPECT - - for query, expected_field in [ - ('query { graph(path: "jira") { countNodes } }', "countNodes"), - ('query { graph(path: "jira") { countEdges } }', "countEdges"), - ('query { graph(path: "jira") { uniqueLayers } }', "uniqueLayers"), - ('query { graph(path: "jira") { schema { nodes { typeName } } } }', "schema"), - ]: - response = requests.post( - RAPHTORY, headers=ANALYST_HEADERS, data=json.dumps({"query": query}) - ) - errors = response.json().get("errors", []) - assert errors, f"expected denial for query={query!r}, got: {response.json()}" - msg = errors[0]["message"] - assert "Access denied" in msg and expected_field in msg and "introspect" in msg, ( - f"unexpected error for {expected_field!r}: {msg!r}" - ) - - def test_introspect_only_is_denied_without_introspect_or_read(): """A role with no permissions on a graph is denied even when trying introspection fields.""" work_dir = tempfile.mkdtemp() @@ -474,20 +386,19 @@ def test_introspect_only_is_denied_without_introspect_or_read(): create_role("analyst") # analyst has no grant on jira at all - response = requests.post( - RAPHTORY, + response = gql( + 'query { graph(path: "jira") { countNodes } }', headers=ANALYST_HEADERS, - data=json.dumps({"query": 'query { graph(path: "jira") { countNodes } }'}), ) - assert response.json()["data"] is None - assert "Access denied" in response.json()["errors"][0]["message"] + assert response["data"] is None + assert "Access denied" in response["errors"][0]["message"] def test_analyst_sees_only_filtered_nodes(): """grantGraphFilteredReadOnly applies a node filter transparently for the role. Admin sees all nodes; analyst only sees nodes matching the stored filter. - Calling grantGraph([READ]) clears the filter and restores full access. + Calling grantGraph(READ) clears the filter and restores full access. """ work_dir = tempfile.mkdtemp() with make_server(work_dir).start(): @@ -523,36 +434,30 @@ def test_analyst_sees_only_filtered_nodes(): QUERY_NODES = 'query { graph(path: "jira") { nodes { list { name } } } }' # Analyst should only see alice and carol (region=us-west) - analyst_response = requests.post( - RAPHTORY, headers=ANALYST_HEADERS, data=json.dumps({"query": QUERY_NODES}) - ) - assert "errors" not in analyst_response.json(), analyst_response.json() + analyst_response = gql(QUERY_NODES, headers=ANALYST_HEADERS) + assert "errors" not in analyst_response, analyst_response analyst_names = { n["name"] - for n in analyst_response.json()["data"]["graph"]["nodes"]["list"] + for n in analyst_response["data"]["graph"]["nodes"]["list"] } assert analyst_names == {"alice", "carol"}, f"expected {{alice, carol}}, got {analyst_names}" # Admin should see all three nodes (filter is bypassed for "a":"rw") - admin_response = requests.post( - RAPHTORY, headers=ADMIN_HEADERS, data=json.dumps({"query": QUERY_NODES}) - ) - assert "errors" not in admin_response.json(), admin_response.json() + admin_response = gql(QUERY_NODES, headers=ADMIN_HEADERS) + assert "errors" not in admin_response, admin_response admin_names = { n["name"] - for n in admin_response.json()["data"]["graph"]["nodes"]["list"] + for n in admin_response["data"]["graph"]["nodes"]["list"] } assert admin_names == {"alice", "bob", "carol"}, f"expected all 3 nodes, got {admin_names}" - # Clear the filter by calling grantGraph([READ]) — analyst should now see all nodes - grant_graph("analyst", "jira", ["READ"]) - analyst_response_after = requests.post( - RAPHTORY, headers=ANALYST_HEADERS, data=json.dumps({"query": QUERY_NODES}) - ) - assert "errors" not in analyst_response_after.json(), analyst_response_after.json() + # Clear the filter by calling grantGraph(READ) — analyst should now see all nodes + grant_graph("analyst", "jira", "READ") + analyst_response_after = gql(QUERY_NODES, headers=ANALYST_HEADERS) + assert "errors" not in analyst_response_after, analyst_response_after names_after = { n["name"] - for n in analyst_response_after.json()["data"]["graph"]["nodes"]["list"] + for n in analyst_response_after["data"]["graph"]["nodes"]["list"] } assert names_after == {"alice", "bob", "carol"}, ( f"after plain grant, expected all 3 nodes, got {names_after}" @@ -600,26 +505,22 @@ def test_analyst_sees_only_filtered_edges(): QUERY_EDGES = 'query { graph(path: "jira") { edges { list { src { name } dst { name } } } } }' - analyst_response = requests.post( - RAPHTORY, headers=ANALYST_HEADERS, data=json.dumps({"query": QUERY_EDGES}) - ) - assert "errors" not in analyst_response.json(), analyst_response.json() + analyst_response = gql(QUERY_EDGES, headers=ANALYST_HEADERS) + assert "errors" not in analyst_response, analyst_response analyst_edges = { (e["src"]["name"], e["dst"]["name"]) - for e in analyst_response.json()["data"]["graph"]["edges"]["list"] + for e in analyst_response["data"]["graph"]["edges"]["list"] } assert analyst_edges == {("b", "c"), ("a", "c")}, ( f"expected only heavy edges, got {analyst_edges}" ) # Admin sees all three edges - admin_response = requests.post( - RAPHTORY, headers=ADMIN_HEADERS, data=json.dumps({"query": QUERY_EDGES}) - ) - assert "errors" not in admin_response.json(), admin_response.json() + admin_response = gql(QUERY_EDGES, headers=ADMIN_HEADERS) + assert "errors" not in admin_response, admin_response admin_edges = { (e["src"]["name"], e["dst"]["name"]) - for e in admin_response.json()["data"]["graph"]["edges"]["list"] + for e in admin_response["data"]["graph"]["edges"]["list"] } assert admin_edges == {("a", "b"), ("b", "c"), ("a", "c")}, ( f"expected all edges for admin, got {admin_edges}" @@ -661,26 +562,22 @@ def test_analyst_sees_only_graph_filter_window(): QUERY_NODES = 'query { graph(path: "jira") { nodes { list { name } } } }' - analyst_response = requests.post( - RAPHTORY, headers=ANALYST_HEADERS, data=json.dumps({"query": QUERY_NODES}) - ) - assert "errors" not in analyst_response.json(), analyst_response.json() + analyst_response = gql(QUERY_NODES, headers=ANALYST_HEADERS) + assert "errors" not in analyst_response, analyst_response analyst_names = { n["name"] - for n in analyst_response.json()["data"]["graph"]["nodes"]["list"] + for n in analyst_response["data"]["graph"]["nodes"]["list"] } assert analyst_names == {"middle"}, ( f"expected only 'middle' in window, got {analyst_names}" ) # Admin sees all three nodes - admin_response = requests.post( - RAPHTORY, headers=ADMIN_HEADERS, data=json.dumps({"query": QUERY_NODES}) - ) - assert "errors" not in admin_response.json(), admin_response.json() + admin_response = gql(QUERY_NODES, headers=ADMIN_HEADERS) + assert "errors" not in admin_response, admin_response admin_names = { n["name"] - for n in admin_response.json()["data"]["graph"]["nodes"]["list"] + for n in admin_response["data"]["graph"]["nodes"]["list"] } assert admin_names == {"early", "middle", "late"}, ( f"expected all nodes for admin, got {admin_names}" From ddce715339969b339f36008db74691250db9e516 Mon Sep 17 00:00:00 2001 From: shivamka1 <4599890+shivamka1@users.noreply.github.com> Date: Tue, 17 Mar 2026 13:57:59 +0000 Subject: [PATCH 17/64] ren "a" to "access" --- python/tests/test_auth.py | 8 ++++---- python/tests/test_permissions.py | 8 ++++---- raphtory-graphql/src/auth.rs | 6 +++--- 3 files changed, 11 insertions(+), 11 deletions(-) diff --git a/python/tests/test_auth.py b/python/tests/test_auth.py index a9c733c0b8..101518774c 100644 --- a/python/tests/test_auth.py +++ b/python/tests/test_auth.py @@ -19,12 +19,12 @@ RAPHTORY = "http://localhost:1736" -READ_JWT = jwt.encode({"a": "ro"}, PRIVATE_KEY, algorithm="EdDSA") +READ_JWT = jwt.encode({"access": "ro"}, PRIVATE_KEY, algorithm="EdDSA") READ_HEADERS = { "Authorization": f"Bearer {READ_JWT}", } -WRITE_JWT = jwt.encode({"a": "rw"}, PRIVATE_KEY, algorithm="EdDSA") +WRITE_JWT = jwt.encode({"access": "rw"}, PRIVATE_KEY, algorithm="EdDSA") WRITE_HEADERS = { "Authorization": f"Bearer {WRITE_JWT}", } @@ -54,7 +54,7 @@ def test_expired_token(): work_dir = tempfile.mkdtemp() with GraphServer(work_dir, auth_public_key=PUB_KEY).start(): exp = time() - 100 - token = jwt.encode({"a": "ro", "exp": exp}, PRIVATE_KEY, algorithm="EdDSA") + token = jwt.encode({"access": "ro", "exp": exp}, PRIVATE_KEY, algorithm="EdDSA") headers = { "Authorization": f"Bearer {token}", } @@ -63,7 +63,7 @@ def test_expired_token(): ) assert response.status_code == 401 - token = jwt.encode({"a": "rw", "exp": exp}, PRIVATE_KEY, algorithm="EdDSA") + token = jwt.encode({"access": "rw", "exp": exp}, PRIVATE_KEY, algorithm="EdDSA") headers = { "Authorization": f"Bearer {token}", } diff --git a/python/tests/test_permissions.py b/python/tests/test_permissions.py index 6950c37798..53a2b8fa2b 100644 --- a/python/tests/test_permissions.py +++ b/python/tests/test_permissions.py @@ -19,13 +19,13 @@ RAPHTORY = "http://localhost:1736" -ANALYST_JWT = jwt.encode({"a": "ro", "role": "analyst"}, PRIVATE_KEY, algorithm="EdDSA") +ANALYST_JWT = jwt.encode({"access": "ro", "role": "analyst"}, PRIVATE_KEY, algorithm="EdDSA") ANALYST_HEADERS = {"Authorization": f"Bearer {ANALYST_JWT}"} -ADMIN_JWT = jwt.encode({"a": "rw", "role": "admin"}, PRIVATE_KEY, algorithm="EdDSA") +ADMIN_JWT = jwt.encode({"access": "rw", "role": "admin"}, PRIVATE_KEY, algorithm="EdDSA") ADMIN_HEADERS = {"Authorization": f"Bearer {ADMIN_JWT}"} -NO_ROLE_JWT = jwt.encode({"a": "ro"}, PRIVATE_KEY, algorithm="EdDSA") +NO_ROLE_JWT = jwt.encode({"access": "ro"}, PRIVATE_KEY, algorithm="EdDSA") NO_ROLE_HEADERS = {"Authorization": f"Bearer {NO_ROLE_JWT}"} QUERY_JIRA = """query { graph(path: "jira") { path } }""" @@ -459,7 +459,7 @@ def test_analyst_sees_only_filtered_nodes(): } assert analyst_names == {"alice", "carol"}, f"expected {{alice, carol}}, got {analyst_names}" - # Admin should see all three nodes (filter is bypassed for "a":"rw") + # Admin should see all three nodes (filter is bypassed for "access":"rw") admin_response = gql(QUERY_NODES, headers=ADMIN_HEADERS) assert "errors" not in admin_response, admin_response admin_names = { diff --git a/raphtory-graphql/src/auth.rs b/raphtory-graphql/src/auth.rs index e7b231cf31..c19a498238 100644 --- a/raphtory-graphql/src/auth.rs +++ b/raphtory-graphql/src/auth.rs @@ -28,7 +28,7 @@ pub(crate) enum Access { #[derive(Deserialize, Debug, Clone)] pub(crate) struct TokenClaims { - pub(crate) a: Access, + pub(crate) access: Access, #[serde(default)] pub(crate) role: Option, } @@ -135,7 +135,7 @@ where match claims { Some(claims) => { debug!(role = ?claims.role, "JWT validated successfully"); - (claims.a, claims.role) + (claims.access, claims.role) } None => { if self.config.enabled_for_reads { @@ -231,7 +231,7 @@ pub(crate) trait ContextValidation { fn require_write_access(&self) -> Result<(), AuthError>; } -/// Check that the request carries a write-access JWT (`"a": "rw"`). +/// Check that the request carries a write-access JWT (`"access": "rw"`). /// For use in dynamic resolver ops that run under `query { ... }` and are /// therefore not covered by the `MutationAuth` extension. pub fn require_write_access_dynamic( From a74accea2156cdecebb0a321895e48a343f7bf0e Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Tue, 17 Mar 2026 15:17:16 +0000 Subject: [PATCH 18/64] chore: apply tidy-public auto-fixes --- Cargo.lock | 37 +++++++++---- python/tests/test_permissions.py | 94 +++++++++++++++++--------------- 2 files changed, 76 insertions(+), 55 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index c8953c9187..6f1e9c9694 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1059,27 +1059,23 @@ dependencies = [ [[package]] name = "clam-core" -version = "0.1.0" +version = "0.17.0" dependencies = [ "ahash", "arrow", "async-trait", - "bincode 1.3.3", "chrono", - "chrono-tz 0.8.6", + "chrono-tz 0.10.4", "comfy-table", - "crc32fast", "criterion", - "dashmap", "db4-storage", "env_logger 0.10.2", "fastrand", "flate2", "insta", - "itertools 0.14.0", + "itertools 0.13.0", "log", "nom", - "once_cell", "optd-core", "parking_lot", "proptest", @@ -1097,7 +1093,8 @@ dependencies = [ "slotmap", "snb", "tempfile", - "thiserror 1.0.69", + "thiserror 2.0.17", + "tikv-jemallocator", "tokio", "tracing", "tracing-test", @@ -3643,7 +3640,7 @@ dependencies = [ [[package]] name = "optd-core" -version = "0.1.0" +version = "0.17.0" dependencies = [ "anyhow", "bitvec", @@ -5586,7 +5583,7 @@ checksum = "1b6b67fb9a61334225b5b790716f609cd58395f895b3fe8b328786812a40bc3b" [[package]] name = "snb" -version = "0.1.0" +version = "0.17.0" dependencies = [ "chrono", "flate2", @@ -6041,6 +6038,26 @@ dependencies = [ "ordered-float 2.10.1", ] +[[package]] +name = "tikv-jemalloc-sys" +version = "0.6.1+5.3.0-1-ge13ca993e8ccb9ba9847cc330696e02839f328f7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd8aa5b2ab86a2cefa406d889139c162cbb230092f7d1d7cbc1716405d852a3b" +dependencies = [ + "cc", + "libc", +] + +[[package]] +name = "tikv-jemallocator" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0359b4327f954e0567e69fb191cf1436617748813819c94b8cd4a431422d053a" +dependencies = [ + "libc", + "tikv-jemalloc-sys", +] + [[package]] name = "time" version = "0.3.45" diff --git a/python/tests/test_permissions.py b/python/tests/test_permissions.py index 53a2b8fa2b..18e9d9d521 100644 --- a/python/tests/test_permissions.py +++ b/python/tests/test_permissions.py @@ -19,10 +19,14 @@ RAPHTORY = "http://localhost:1736" -ANALYST_JWT = jwt.encode({"access": "ro", "role": "analyst"}, PRIVATE_KEY, algorithm="EdDSA") +ANALYST_JWT = jwt.encode( + {"access": "ro", "role": "analyst"}, PRIVATE_KEY, algorithm="EdDSA" +) ANALYST_HEADERS = {"Authorization": f"Bearer {ANALYST_JWT}"} -ADMIN_JWT = jwt.encode({"access": "rw", "role": "admin"}, PRIVATE_KEY, algorithm="EdDSA") +ADMIN_JWT = jwt.encode( + {"access": "rw", "role": "admin"}, PRIVATE_KEY, algorithm="EdDSA" +) ADMIN_HEADERS = {"Authorization": f"Bearer {ADMIN_JWT}"} NO_ROLE_JWT = jwt.encode({"access": "ro"}, PRIVATE_KEY, algorithm="EdDSA") @@ -234,10 +238,7 @@ def test_analyst_cannot_write_without_write_grant(): grant_graph("analyst", "jira", "READ") # READ only, no WRITE response = gql(UPDATE_JIRA, headers=ANALYST_HEADERS) - assert ( - response["data"] is None - or response["data"].get("updateGraph") is None - ) + assert response["data"] is None or response["data"].get("updateGraph") is None assert "errors" in response assert "Access denied" in response["errors"][0]["message"] @@ -422,8 +423,7 @@ def test_analyst_sees_only_filtered_nodes(): ("bob", "us-east"), ("carol", "us-west"), ]: - resp = gql( - f"""query {{ + resp = gql(f"""query {{ updateGraph(path: "jira") {{ addNode( time: 1, @@ -436,8 +436,7 @@ def test_analyst_sees_only_filtered_nodes(): }} }} }} - }}""" - ) + }}""") assert resp["data"]["updateGraph"]["addNode"]["success"] is True, resp create_role("analyst") @@ -454,31 +453,37 @@ def test_analyst_sees_only_filtered_nodes(): analyst_response = gql(QUERY_NODES, headers=ANALYST_HEADERS) assert "errors" not in analyst_response, analyst_response analyst_names = { - n["name"] - for n in analyst_response["data"]["graph"]["nodes"]["list"] + n["name"] for n in analyst_response["data"]["graph"]["nodes"]["list"] } - assert analyst_names == {"alice", "carol"}, f"expected {{alice, carol}}, got {analyst_names}" + assert analyst_names == { + "alice", + "carol", + }, f"expected {{alice, carol}}, got {analyst_names}" # Admin should see all three nodes (filter is bypassed for "access":"rw") admin_response = gql(QUERY_NODES, headers=ADMIN_HEADERS) assert "errors" not in admin_response, admin_response admin_names = { - n["name"] - for n in admin_response["data"]["graph"]["nodes"]["list"] + n["name"] for n in admin_response["data"]["graph"]["nodes"]["list"] } - assert admin_names == {"alice", "bob", "carol"}, f"expected all 3 nodes, got {admin_names}" + assert admin_names == { + "alice", + "bob", + "carol", + }, f"expected all 3 nodes, got {admin_names}" # Clear the filter by calling grantGraph(READ) — analyst should now see all nodes grant_graph("analyst", "jira", "READ") analyst_response_after = gql(QUERY_NODES, headers=ANALYST_HEADERS) assert "errors" not in analyst_response_after, analyst_response_after names_after = { - n["name"] - for n in analyst_response_after["data"]["graph"]["nodes"]["list"] + n["name"] for n in analyst_response_after["data"]["graph"]["nodes"]["list"] } - assert names_after == {"alice", "bob", "carol"}, ( - f"after plain grant, expected all 3 nodes, got {names_after}" - ) + assert names_after == { + "alice", + "bob", + "carol", + }, f"after plain grant, expected all 3 nodes, got {names_after}" def test_analyst_sees_only_filtered_edges(): @@ -492,8 +497,7 @@ def test_analyst_sees_only_filtered_edges(): gql(CREATE_JIRA) # Add three edges: (a->b weight=3), (b->c weight=7), (a->c weight=9) for src, dst, weight in [("a", "b", 3), ("b", "c", 7), ("a", "c", 9)]: - resp = gql( - f"""query {{ + resp = gql(f"""query {{ updateGraph(path: "jira") {{ addEdge( time: 1, @@ -508,8 +512,7 @@ def test_analyst_sees_only_filtered_edges(): }} }} }} - }}""" - ) + }}""") assert resp["data"]["updateGraph"]["addEdge"]["success"] is True, resp create_role("analyst") @@ -528,9 +531,10 @@ def test_analyst_sees_only_filtered_edges(): (e["src"]["name"], e["dst"]["name"]) for e in analyst_response["data"]["graph"]["edges"]["list"] } - assert analyst_edges == {("b", "c"), ("a", "c")}, ( - f"expected only heavy edges, got {analyst_edges}" - ) + assert analyst_edges == { + ("b", "c"), + ("a", "c"), + }, f"expected only heavy edges, got {analyst_edges}" # Admin sees all three edges admin_response = gql(QUERY_EDGES, headers=ADMIN_HEADERS) @@ -539,9 +543,11 @@ def test_analyst_sees_only_filtered_edges(): (e["src"]["name"], e["dst"]["name"]) for e in admin_response["data"]["graph"]["edges"]["list"] } - assert admin_edges == {("a", "b"), ("b", "c"), ("a", "c")}, ( - f"expected all edges for admin, got {admin_edges}" - ) + assert admin_edges == { + ("a", "b"), + ("b", "c"), + ("a", "c"), + }, f"expected all edges for admin, got {admin_edges}" def test_analyst_sees_only_graph_filter_window(): @@ -555,8 +561,7 @@ def test_analyst_sees_only_graph_filter_window(): gql(CREATE_JIRA) # Add nodes at different timestamps: t=1 (outside), t=10 (inside), t=20 (outside) for name, t in [("early", 1), ("middle", 10), ("late", 20)]: - resp = gql( - f"""query {{ + resp = gql(f"""query {{ updateGraph(path: "jira") {{ addNode(time: {t}, name: "{name}") {{ success @@ -565,8 +570,7 @@ def test_analyst_sees_only_graph_filter_window(): }} }} }} - }}""" - ) + }}""") assert resp["data"]["updateGraph"]["addNode"]["success"] is True, resp create_role("analyst") @@ -582,20 +586,20 @@ def test_analyst_sees_only_graph_filter_window(): analyst_response = gql(QUERY_NODES, headers=ANALYST_HEADERS) assert "errors" not in analyst_response, analyst_response analyst_names = { - n["name"] - for n in analyst_response["data"]["graph"]["nodes"]["list"] + n["name"] for n in analyst_response["data"]["graph"]["nodes"]["list"] } - assert analyst_names == {"middle"}, ( - f"expected only 'middle' in window, got {analyst_names}" - ) + assert analyst_names == { + "middle" + }, f"expected only 'middle' in window, got {analyst_names}" # Admin sees all three nodes admin_response = gql(QUERY_NODES, headers=ADMIN_HEADERS) assert "errors" not in admin_response, admin_response admin_names = { - n["name"] - for n in admin_response["data"]["graph"]["nodes"]["list"] + n["name"] for n in admin_response["data"]["graph"]["nodes"]["list"] } - assert admin_names == {"early", "middle", "late"}, ( - f"expected all nodes for admin, got {admin_names}" - ) + assert admin_names == { + "early", + "middle", + "late", + }, f"expected all nodes for admin, got {admin_names}" From 1e7ef4e271dbdb759d6359be3444a381c711f155 Mon Sep 17 00:00:00 2001 From: shivamka1 <4599890+shivamka1@users.noreply.github.com> Date: Tue, 17 Mar 2026 18:28:31 +0000 Subject: [PATCH 19/64] ref --- raphtory-graphql/src/auth_policy.rs | 60 +++++++++- raphtory-graphql/src/model/graph/graph.rs | 28 ++--- raphtory-graphql/src/model/graph/mod.rs | 2 +- raphtory-graphql/src/model/mod.rs | 140 +++++++--------------- 4 files changed, 107 insertions(+), 123 deletions(-) diff --git a/raphtory-graphql/src/auth_policy.rs b/raphtory-graphql/src/auth_policy.rs index 2ce4d511b6..6f42053fe9 100644 --- a/raphtory-graphql/src/auth_policy.rs +++ b/raphtory-graphql/src/auth_policy.rs @@ -1,3 +1,17 @@ +use crate::model::graph::filtering::GraphAccessFilter; + +/// The effective permission level a principal has on a specific graph. +/// Variants are ordered by the hierarchy: `Write` > `Read` > `Introspect`. +#[derive(Clone)] +pub enum GraphPermission { + /// May query graph metadata (counts, schema) but not read data. + Introspect, + /// May read graph data; optionally restricted by a data filter. + Read { filter: Option }, + /// May read and mutate the graph (implies `Read` and `Introspect`, never filtered). + Write, +} + pub trait AuthorizationPolicy: Send + Sync + 'static { /// Returns `Some(true)` to allow access, `Some(false)` to deny, `None` if the role has no /// entry covering this graph (treated as denied when a policy is active). @@ -11,11 +25,47 @@ pub trait AuthorizationPolicy: Send + Sync + 'static { /// `"a": "rw"` users bypass this check entirely — it is only called for `"a": "ro"` users. fn check_graph_write_access(&self, role: Option<&str>, path: &str) -> bool; - /// Returns a JSON value with optional `node`, `edge`, `graph` keys representing a data filter - /// to apply transparently when this role queries the graph. + /// Returns a filter to apply transparently when this role queries the graph. /// Returns `None` if no filter is configured (full access to graph data). - /// `"a": "rw"` admin users bypass this — it is never called for them. - fn get_graph_data_filter(&self, role: Option<&str>, path: &str) -> Option; + /// `"access": "rw"` admin users bypass this — it is never called for them. + fn get_graph_data_filter(&self, role: Option<&str>, path: &str) -> Option; + + /// Resolves the effective permission level for a principal on a graph. + /// Returns `Err(denial message)` only when access is entirely denied (not even introspect). + /// Admin bypass (`is_admin = true`) always yields `Write`. + /// Empty store (no roles configured) yields `Read` for non-admins — fail open for reads, + /// but write still requires an explicit `Write` grant. + fn graph_permissions( + &self, + is_admin: bool, + role: Option<&str>, + path: &str, + ) -> Result { + if is_admin { + return Ok(GraphPermission::Write); + } + match self.check_graph_access(role, path) { + // No roles configured: fail open for reads, but not writes + None => Ok(GraphPermission::Read { filter: None }), + Some(true) => { + if self.check_graph_write_access(role, path) { + Ok(GraphPermission::Write) + } else { + Ok(GraphPermission::Read { filter: self.get_graph_data_filter(role, path) }) + } + } + Some(false) => { + if self.check_graph_introspection(role, path) { + Ok(GraphPermission::Introspect) + } else { + Err(format!( + "Access denied: role '{}' is not permitted to access graph '{path}'", + role.unwrap_or("") + )) + } + } + } + } } /// A no-op policy that permits all reads and leaves writes to the `PermissionsPlugin`. @@ -35,7 +85,7 @@ impl AuthorizationPolicy for NoopPolicy { true } - fn get_graph_data_filter(&self, _: Option<&str>, _: &str) -> Option { + fn get_graph_data_filter(&self, _: Option<&str>, _: &str) -> Option { None } } diff --git a/raphtory-graphql/src/model/graph/graph.rs b/raphtory-graphql/src/model/graph/graph.rs index 47b578f64f..08454e769c 100644 --- a/raphtory-graphql/src/model/graph/graph.rs +++ b/raphtory-graphql/src/model/graph/graph.rs @@ -1,4 +1,5 @@ use crate::{ + auth_policy::GraphPermission, data::Data, graph::GraphWithVectors, model::{ @@ -62,11 +63,7 @@ use std::{ pub(crate) struct GqlGraph { path: ExistingGraphFolder, graph: DynamicGraph, - /// Whether this role may read graph data (nodes, edges, properties). - read_allowed: bool, - /// Whether this role may perform introspection on this graph - /// (countNodes, countEdges, uniqueLayers, schema). Derived from auth policy at request time. - introspection_allowed: bool, + permission: GraphPermission, } impl From for GqlGraph { @@ -80,22 +77,19 @@ impl GqlGraph { Self { path, graph: graph.into_dynamic(), - read_allowed: true, - introspection_allowed: true, + permission: GraphPermission::Write, } } pub fn new_with_permissions( path: ExistingGraphFolder, graph: G, - read_allowed: bool, - introspection_allowed: bool, + permission: GraphPermission, ) -> Self { Self { path, graph: graph.into_dynamic(), - read_allowed, - introspection_allowed, + permission, } } @@ -107,13 +101,12 @@ impl GqlGraph { Self { path: self.path.clone(), graph: graph_operation(&self.graph).into_dynamic(), - read_allowed: self.read_allowed, - introspection_allowed: self.introspection_allowed, + permission: self.permission.clone(), } } fn require_read_access(&self, field: &str) -> Result<()> { - if !self.read_allowed { + if matches!(self.permission, GraphPermission::Introspect) { return Err(async_graphql::Error::new(format!( "Access denied: '{field}' requires read permission on this graph" ))); @@ -122,11 +115,8 @@ impl GqlGraph { } fn require_introspection(&self, field: &str) -> Result<()> { - if !self.introspection_allowed { - return Err(async_graphql::Error::new(format!( - "Access denied: '{field}' requires introspect permission on this graph" - ))); - } + // All GraphPermission variants include introspection; this check always passes. + let _ = field; Ok(()) } } diff --git a/raphtory-graphql/src/model/graph/mod.rs b/raphtory-graphql/src/model/graph/mod.rs index 726fac2b67..3464a89bcc 100644 --- a/raphtory-graphql/src/model/graph/mod.rs +++ b/raphtory-graphql/src/model/graph/mod.rs @@ -6,7 +6,7 @@ pub(crate) mod collection; mod document; pub(crate) mod edge; mod edges; -pub(crate) mod filtering; +pub mod filtering; pub(crate) mod graph; pub(crate) mod history; pub(crate) mod index; diff --git a/raphtory-graphql/src/model/mod.rs b/raphtory-graphql/src/model/mod.rs index 537d8c7fa0..654940e670 100644 --- a/raphtory-graphql/src/model/mod.rs +++ b/raphtory-graphql/src/model/mod.rs @@ -1,10 +1,11 @@ use crate::{ auth::{Access, ContextValidation}, + auth_policy::GraphPermission, data::Data, model::{ graph::{ collection::GqlCollection, - filtering::{GqlEdgeFilter, GqlGraphFilter, GqlNodeFilter}, + filtering::{GqlEdgeFilter, GqlGraphFilter, GqlNodeFilter, GraphAccessFilter}, graph::GqlGraph, index::IndexSpecInput, mutable_graph::GqlMutableGraph, @@ -46,7 +47,7 @@ use std::{ }; use tracing::warn; -pub(crate) mod graph; +pub mod graph; pub mod plugins; pub(crate) mod schema; pub(crate) mod sorting; @@ -98,15 +99,13 @@ pub enum GqlGraphType { /// `graph` keys) to a `DynamicGraph`, returning a new filtered view. async fn apply_graph_filter( mut graph: DynamicGraph, - filter: serde_json::Value, + filter: GraphAccessFilter, ) -> async_graphql::Result { use raphtory::db::graph::views::filter::model::{ edge_filter::CompositeEdgeFilter, node_filter::CompositeNodeFilter, DynView, }; - if let Some(node_val) = filter.get("node") { - let gql_filter: GqlNodeFilter = serde_json::from_value(node_val.clone()) - .map_err(|e| async_graphql::Error::new(format!("node filter invalid: {e}")))?; + if let Some(gql_filter) = filter.node { let raphtory_filter = CompositeNodeFilter::try_from(gql_filter) .map_err(|e| async_graphql::Error::new(format!("node filter conversion: {e}")))?; graph = blocking_compute({ @@ -118,9 +117,7 @@ async fn apply_graph_filter( .into_dynamic(); } - if let Some(edge_val) = filter.get("edge") { - let gql_filter: GqlEdgeFilter = serde_json::from_value(edge_val.clone()) - .map_err(|e| async_graphql::Error::new(format!("edge filter invalid: {e}")))?; + if let Some(gql_filter) = filter.edge { let raphtory_filter = CompositeEdgeFilter::try_from(gql_filter) .map_err(|e| async_graphql::Error::new(format!("edge filter conversion: {e}")))?; graph = blocking_compute({ @@ -132,9 +129,7 @@ async fn apply_graph_filter( .into_dynamic(); } - if let Some(graph_val) = filter.get("graph") { - let gql_filter: GqlGraphFilter = serde_json::from_value(graph_val.clone()) - .map_err(|e| async_graphql::Error::new(format!("graph filter invalid: {e}")))?; + if let Some(gql_filter) = filter.graph { let dyn_view = DynView::try_from(gql_filter) .map_err(|e| async_graphql::Error::new(format!("graph filter conversion: {e}")))?; graph = blocking_compute({ @@ -164,61 +159,29 @@ impl QueryRoot { async fn graph<'a>(ctx: &Context<'a>, path: &str) -> Result { let data = ctx.data_unchecked::(); let role = ctx.data::>().ok().and_then(|r| r.as_deref()); - - let (read_allowed, introspection_allowed) = if ctx - .data::() - .is_ok_and(|a| a == &Access::Rw) - { - (true, true) // "a": "rw" = admin, bypass all per-graph policy checks - } else if let Some(policy) = &data.auth_policy { - match policy.check_graph_access(role, path) { - Some(false) => { - // No READ — introspect-only access is still allowed - if policy.check_graph_introspection(role, path) { - (false, true) - } else { - let role_str = role.unwrap_or(""); - warn!( - role = role_str, - graph = path, - "Access denied by auth policy" - ); - return Err(async_graphql::Error::new(format!( - "Access denied: role '{role_str}' is not permitted to access graph '{path}'" - ))); - } - } - Some(true) => (true, policy.check_graph_introspection(role, path)), - None => (true, true), // role not in store = no rules apply = full access - } + let is_admin = ctx.data::().is_ok_and(|a| a == &Access::Rw); + + let perms = if let Some(policy) = &data.auth_policy { + policy + .graph_permissions(is_admin, role, path) + .map_err(|msg| { + warn!(role = role.unwrap_or(""), graph = path, "Access denied by auth policy"); + async_graphql::Error::new(msg) + })? } else { - (true, true) // no policy -> unrestricted + GraphPermission::Write // no policy: unrestricted }; let graph_with_vecs = data.get_graph(path).await?; let graph: DynamicGraph = graph_with_vecs.graph.into_dynamic(); - // Apply per-role data filter if one is configured (admin "a":"rw" bypasses this) - let graph = if !ctx.data::().is_ok_and(|a| a == &Access::Rw) { - if let Some(policy) = &data.auth_policy { - if let Some(filter_json) = policy.get_graph_data_filter(role, path) { - apply_graph_filter(graph, filter_json).await? - } else { - graph - } - } else { - graph - } + let graph = if let GraphPermission::Read { filter: Some(ref f) } = perms { + apply_graph_filter(graph, f.clone()).await? } else { graph }; - Ok(GqlGraph::new_with_permissions( - graph_with_vecs.folder, - graph, - read_allowed, - introspection_allowed, - )) + Ok(GqlGraph::new_with_permissions(graph_with_vecs.folder, graph, perms)) } /// Update graph query, has side effects to update graph state @@ -226,31 +189,21 @@ impl QueryRoot { /// Returns:: GqlMutableGraph async fn update_graph<'a>(ctx: &Context<'a>, path: String) -> Result { let data = ctx.data_unchecked::(); - if !ctx.data::().is_ok_and(|a| a == &Access::Rw) { - // Not admin — check per-graph write permission from the policy + let is_admin = ctx.data::().is_ok_and(|a| a == &Access::Rw); + let role = ctx.data::>().ok().and_then(|r| r.as_deref()); + if !is_admin { match &data.auth_policy { + None => ctx.require_write_access()?, Some(policy) => { - let role = ctx.data::>().ok().and_then(|r| r.as_deref()); - match policy.check_graph_access(role, &path) { - // No active policy entries (empty store) — fall back to JWT-level check - None => ctx.require_write_access()?, - Some(false) => { - let role_str = role.unwrap_or(""); - return Err(async_graphql::Error::new(format!( - "Access denied: role '{role_str}' does not have write permission for graph '{path}'" - ))); - } - Some(true) => { - if !policy.check_graph_write_access(role, &path) { - let role_str = role.unwrap_or(""); - return Err(async_graphql::Error::new(format!( - "Access denied: role '{role_str}' does not have write permission for graph '{path}'" - ))); - } - } + let perms = policy.graph_permissions(false, role, &path) + .map_err(|msg| async_graphql::Error::new(msg))?; + if !matches!(perms, GraphPermission::Write) { + return Err(async_graphql::Error::new(format!( + "Access denied: role '{}' does not have write permission for graph '{path}'", + role.unwrap_or("") + ))); } } - None => ctx.require_write_access()?, } } @@ -363,30 +316,21 @@ impl Mut { graph_type: GqlGraphType, ) -> Result { let data = ctx.data_unchecked::(); - if !ctx.data::().is_ok_and(|a| a == &Access::Rw) { + let is_admin = ctx.data::().is_ok_and(|a| a == &Access::Rw); + let role = ctx.data::>().ok().and_then(|r| r.as_deref()); + if !is_admin { match &data.auth_policy { + None => ctx.require_write_access()?, Some(policy) => { - let role = ctx.data::>().ok().and_then(|r| r.as_deref()); - match policy.check_graph_access(role, &path) { - // No active policy entries (empty store) — fall back to JWT-level check - None => ctx.require_write_access()?, - Some(false) => { - let role_str = role.unwrap_or(""); - return Err(async_graphql::Error::new(format!( - "Access denied: role '{role_str}' does not have write permission for namespace '{path}'" - ))); - } - Some(true) => { - if !policy.check_graph_write_access(role, &path) { - let role_str = role.unwrap_or(""); - return Err(async_graphql::Error::new(format!( - "Access denied: role '{role_str}' does not have write permission for namespace '{path}'" - ))); - } - } + let perms = policy.graph_permissions(false, role, &path) + .map_err(|msg| async_graphql::Error::new(msg))?; + if !matches!(perms, GraphPermission::Write) { + return Err(async_graphql::Error::new(format!( + "Access denied: role '{}' does not have write permission for graph '{path}'", + role.unwrap_or("") + ))); } } - None => ctx.require_write_access()?, } } let overwrite = false; From 3952759005eaa46bc854929938dff4a971e21263 Mon Sep 17 00:00:00 2001 From: shivamka1 <4599890+shivamka1@users.noreply.github.com> Date: Tue, 17 Mar 2026 18:42:13 +0000 Subject: [PATCH 20/64] ref --- raphtory-graphql/src/auth_policy.rs | 62 ++--------------------------- 1 file changed, 4 insertions(+), 58 deletions(-) diff --git a/raphtory-graphql/src/auth_policy.rs b/raphtory-graphql/src/auth_policy.rs index 6f42053fe9..2f68e9a62a 100644 --- a/raphtory-graphql/src/auth_policy.rs +++ b/raphtory-graphql/src/auth_policy.rs @@ -13,23 +13,6 @@ pub enum GraphPermission { } pub trait AuthorizationPolicy: Send + Sync + 'static { - /// Returns `Some(true)` to allow access, `Some(false)` to deny, `None` if the role has no - /// entry covering this graph (treated as denied when a policy is active). - fn check_graph_access(&self, role: Option<&str>, path: &str) -> Option; - - /// Returns `true` if the role may perform graph-level introspection - /// (countNodes, countEdges, uniqueLayers, schema). - fn check_graph_introspection(&self, role: Option<&str>, path: &str) -> bool; - - /// Returns `true` if the role may write to this graph (addNode, addEdge, updateGraph, newGraph). - /// `"a": "rw"` users bypass this check entirely — it is only called for `"a": "ro"` users. - fn check_graph_write_access(&self, role: Option<&str>, path: &str) -> bool; - - /// Returns a filter to apply transparently when this role queries the graph. - /// Returns `None` if no filter is configured (full access to graph data). - /// `"access": "rw"` admin users bypass this — it is never called for them. - fn get_graph_data_filter(&self, role: Option<&str>, path: &str) -> Option; - /// Resolves the effective permission level for a principal on a graph. /// Returns `Err(denial message)` only when access is entirely denied (not even introspect). /// Admin bypass (`is_admin = true`) always yields `Write`. @@ -40,52 +23,15 @@ pub trait AuthorizationPolicy: Send + Sync + 'static { is_admin: bool, role: Option<&str>, path: &str, - ) -> Result { - if is_admin { - return Ok(GraphPermission::Write); - } - match self.check_graph_access(role, path) { - // No roles configured: fail open for reads, but not writes - None => Ok(GraphPermission::Read { filter: None }), - Some(true) => { - if self.check_graph_write_access(role, path) { - Ok(GraphPermission::Write) - } else { - Ok(GraphPermission::Read { filter: self.get_graph_data_filter(role, path) }) - } - } - Some(false) => { - if self.check_graph_introspection(role, path) { - Ok(GraphPermission::Introspect) - } else { - Err(format!( - "Access denied: role '{}' is not permitted to access graph '{path}'", - role.unwrap_or("") - )) - } - } - } - } + ) -> Result; } -/// A no-op policy that permits all reads and leaves writes to the `PermissionsPlugin`. +/// A no-op policy that grants full access to everyone. /// Used when no auth policy has been configured on the server. pub struct NoopPolicy; impl AuthorizationPolicy for NoopPolicy { - fn check_graph_access(&self, _: Option<&str>, _: &str) -> Option { - Some(true) - } - - fn check_graph_introspection(&self, _: Option<&str>, _: &str) -> bool { - true - } - - fn check_graph_write_access(&self, _: Option<&str>, _: &str) -> bool { - true - } - - fn get_graph_data_filter(&self, _: Option<&str>, _: &str) -> Option { - None + fn graph_permissions(&self, _: bool, _: Option<&str>, _: &str) -> Result { + Ok(GraphPermission::Write) } } From 9494b262dba962695a5134e50275861a2e1c56e8 Mon Sep 17 00:00:00 2001 From: shivamka1 <4599890+shivamka1@users.noreply.github.com> Date: Tue, 17 Mar 2026 19:09:33 +0000 Subject: [PATCH 21/64] ref --- raphtory-graphql/src/model/mod.rs | 28 +++++++++++++++++++--------- 1 file changed, 19 insertions(+), 9 deletions(-) diff --git a/raphtory-graphql/src/model/mod.rs b/raphtory-graphql/src/model/mod.rs index 654940e670..1ca5cd004e 100644 --- a/raphtory-graphql/src/model/mod.rs +++ b/raphtory-graphql/src/model/mod.rs @@ -1,5 +1,5 @@ use crate::{ - auth::{Access, ContextValidation}, + auth::{Access, AuthError, ContextValidation}, auth_policy::GraphPermission, data::Data, model::{ @@ -198,10 +198,15 @@ impl QueryRoot { let perms = policy.graph_permissions(false, role, &path) .map_err(|msg| async_graphql::Error::new(msg))?; if !matches!(perms, GraphPermission::Write) { - return Err(async_graphql::Error::new(format!( - "Access denied: role '{}' does not have write permission for graph '{path}'", - role.unwrap_or("") - ))); + // role=Some: policy is active and grants less than Write. + // role=None: store is empty (no policy yet), fall back to JWT check. + return if let Some(role) = role { + Err(async_graphql::Error::new(format!( + "Access denied: role '{role}' does not have write permission for graph '{path}'" + ))) + } else { + Err(AuthError::RequireWrite.into()) + }; } } } @@ -325,10 +330,15 @@ impl Mut { let perms = policy.graph_permissions(false, role, &path) .map_err(|msg| async_graphql::Error::new(msg))?; if !matches!(perms, GraphPermission::Write) { - return Err(async_graphql::Error::new(format!( - "Access denied: role '{}' does not have write permission for graph '{path}'", - role.unwrap_or("") - ))); + // role=Some: policy is active and grants less than Write. + // role=None: store is empty (no policy yet), fall back to JWT check. + return if let Some(role) = role { + Err(async_graphql::Error::new(format!( + "Access denied: role '{role}' does not have write permission for graph '{path}'" + ))) + } else { + Err(AuthError::RequireWrite.into()) + }; } } } From 3ca3f99c3c130062626c3d8fb765cd675c3bfdae Mon Sep 17 00:00:00 2001 From: shivamka1 <4599890+shivamka1@users.noreply.github.com> Date: Tue, 17 Mar 2026 19:10:37 +0000 Subject: [PATCH 22/64] fmt --- raphtory-graphql/src/auth_policy.rs | 7 ++++++- raphtory-graphql/src/model/mod.rs | 23 ++++++++++++++++++----- 2 files changed, 24 insertions(+), 6 deletions(-) diff --git a/raphtory-graphql/src/auth_policy.rs b/raphtory-graphql/src/auth_policy.rs index 2f68e9a62a..37a6169c1c 100644 --- a/raphtory-graphql/src/auth_policy.rs +++ b/raphtory-graphql/src/auth_policy.rs @@ -31,7 +31,12 @@ pub trait AuthorizationPolicy: Send + Sync + 'static { pub struct NoopPolicy; impl AuthorizationPolicy for NoopPolicy { - fn graph_permissions(&self, _: bool, _: Option<&str>, _: &str) -> Result { + fn graph_permissions( + &self, + _: bool, + _: Option<&str>, + _: &str, + ) -> Result { Ok(GraphPermission::Write) } } diff --git a/raphtory-graphql/src/model/mod.rs b/raphtory-graphql/src/model/mod.rs index 1ca5cd004e..47074d9998 100644 --- a/raphtory-graphql/src/model/mod.rs +++ b/raphtory-graphql/src/model/mod.rs @@ -165,7 +165,11 @@ impl QueryRoot { policy .graph_permissions(is_admin, role, path) .map_err(|msg| { - warn!(role = role.unwrap_or(""), graph = path, "Access denied by auth policy"); + warn!( + role = role.unwrap_or(""), + graph = path, + "Access denied by auth policy" + ); async_graphql::Error::new(msg) })? } else { @@ -175,13 +179,20 @@ impl QueryRoot { let graph_with_vecs = data.get_graph(path).await?; let graph: DynamicGraph = graph_with_vecs.graph.into_dynamic(); - let graph = if let GraphPermission::Read { filter: Some(ref f) } = perms { + let graph = if let GraphPermission::Read { + filter: Some(ref f), + } = perms + { apply_graph_filter(graph, f.clone()).await? } else { graph }; - Ok(GqlGraph::new_with_permissions(graph_with_vecs.folder, graph, perms)) + Ok(GqlGraph::new_with_permissions( + graph_with_vecs.folder, + graph, + perms, + )) } /// Update graph query, has side effects to update graph state @@ -195,7 +206,8 @@ impl QueryRoot { match &data.auth_policy { None => ctx.require_write_access()?, Some(policy) => { - let perms = policy.graph_permissions(false, role, &path) + let perms = policy + .graph_permissions(false, role, &path) .map_err(|msg| async_graphql::Error::new(msg))?; if !matches!(perms, GraphPermission::Write) { // role=Some: policy is active and grants less than Write. @@ -327,7 +339,8 @@ impl Mut { match &data.auth_policy { None => ctx.require_write_access()?, Some(policy) => { - let perms = policy.graph_permissions(false, role, &path) + let perms = policy + .graph_permissions(false, role, &path) .map_err(|msg| async_graphql::Error::new(msg))?; if !matches!(perms, GraphPermission::Write) { // role=Some: policy is active and grants less than Write. From 659b7d2ea477f7fd3b9fd446aef4c8b93a49e054 Mon Sep 17 00:00:00 2001 From: shivamka1 <4599890+shivamka1@users.noreply.github.com> Date: Tue, 17 Mar 2026 19:28:00 +0000 Subject: [PATCH 23/64] add client tests --- Cargo.lock | 37 ++++++--------------- python/tests/test_auth.py | 22 ++++++++++++ python/tests/test_permissions.py | 57 +++++++++++++++++++++++++++++++- 3 files changed, 88 insertions(+), 28 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 6f1e9c9694..c8953c9187 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1059,23 +1059,27 @@ dependencies = [ [[package]] name = "clam-core" -version = "0.17.0" +version = "0.1.0" dependencies = [ "ahash", "arrow", "async-trait", + "bincode 1.3.3", "chrono", - "chrono-tz 0.10.4", + "chrono-tz 0.8.6", "comfy-table", + "crc32fast", "criterion", + "dashmap", "db4-storage", "env_logger 0.10.2", "fastrand", "flate2", "insta", - "itertools 0.13.0", + "itertools 0.14.0", "log", "nom", + "once_cell", "optd-core", "parking_lot", "proptest", @@ -1093,8 +1097,7 @@ dependencies = [ "slotmap", "snb", "tempfile", - "thiserror 2.0.17", - "tikv-jemallocator", + "thiserror 1.0.69", "tokio", "tracing", "tracing-test", @@ -3640,7 +3643,7 @@ dependencies = [ [[package]] name = "optd-core" -version = "0.17.0" +version = "0.1.0" dependencies = [ "anyhow", "bitvec", @@ -5583,7 +5586,7 @@ checksum = "1b6b67fb9a61334225b5b790716f609cd58395f895b3fe8b328786812a40bc3b" [[package]] name = "snb" -version = "0.17.0" +version = "0.1.0" dependencies = [ "chrono", "flate2", @@ -6038,26 +6041,6 @@ dependencies = [ "ordered-float 2.10.1", ] -[[package]] -name = "tikv-jemalloc-sys" -version = "0.6.1+5.3.0-1-ge13ca993e8ccb9ba9847cc330696e02839f328f7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cd8aa5b2ab86a2cefa406d889139c162cbb230092f7d1d7cbc1716405d852a3b" -dependencies = [ - "cc", - "libc", -] - -[[package]] -name = "tikv-jemallocator" -version = "0.6.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0359b4327f954e0567e69fb191cf1436617748813819c94b8cd4a431422d053a" -dependencies = [ - "libc", - "tikv-jemalloc-sys", -] - [[package]] name = "time" version = "0.3.45" diff --git a/python/tests/test_auth.py b/python/tests/test_auth.py index 101518774c..3bccd53cf9 100644 --- a/python/tests/test_auth.py +++ b/python/tests/test_auth.py @@ -206,6 +206,28 @@ def test_raphtory_client(): assert g.node("test") is not None +def test_raphtory_client_write_denied_for_read_jwt(): + """RaphtoryClient initialized with a read JWT is denied write operations.""" + work_dir = tempfile.mkdtemp() + with GraphServer(work_dir, auth_public_key=PUB_KEY).start(): + client = RaphtoryClient(url=RAPHTORY, token=READ_JWT) + with pytest.raises(Exception, match="requires write access"): + client.new_graph("test", "EVENT") + + +def test_raphtory_client_read_jwt_can_receive_graph(): + """RaphtoryClient initialized with a read JWT can download graphs.""" + work_dir = tempfile.mkdtemp() + with GraphServer(work_dir, auth_public_key=PUB_KEY).start(): + client = RaphtoryClient(url=RAPHTORY, token=WRITE_JWT) + client.new_graph("test", "EVENT") + client.remote_graph("test").add_node(0, "mynode") + + client2 = RaphtoryClient(url=RAPHTORY, token=READ_JWT) + g = client2.receive_graph("test") + assert g.node("mynode") is not None + + def test_upload_graph(): work_dir = tempfile.mkdtemp() with GraphServer(work_dir, auth_public_key=PUB_KEY).start(): diff --git a/python/tests/test_permissions.py b/python/tests/test_permissions.py index 18e9d9d521..116bc65a8d 100644 --- a/python/tests/test_permissions.py +++ b/python/tests/test_permissions.py @@ -4,7 +4,7 @@ import requests import jwt import pytest -from raphtory.graphql import GraphServer, has_permissions_extension +from raphtory.graphql import GraphServer, RaphtoryClient, has_permissions_extension pytestmark = pytest.mark.skipif( not has_permissions_extension(), @@ -550,6 +550,61 @@ def test_analyst_sees_only_filtered_edges(): }, f"expected all edges for admin, got {admin_edges}" +def test_raphtory_client_analyst_can_query_permitted_graph(): + """RaphtoryClient with analyst role can query a graph it has READ access to.""" + work_dir = tempfile.mkdtemp() + with make_server(work_dir).start(): + gql(CREATE_JIRA) + create_role("analyst") + grant_graph("analyst", "jira", "READ") + + client = RaphtoryClient(url=RAPHTORY, token=ANALYST_JWT) + result = client.query(QUERY_JIRA) + assert result["graph"]["path"] == "jira" + + +def test_raphtory_client_analyst_denied_unpermitted_graph(): + """RaphtoryClient with analyst role is denied access to a graph it has no grant for.""" + work_dir = tempfile.mkdtemp() + with make_server(work_dir).start(): + gql(CREATE_JIRA) + create_role("analyst") + # No grant on jira + + client = RaphtoryClient(url=RAPHTORY, token=ANALYST_JWT) + with pytest.raises(Exception, match="Access denied"): + client.query(QUERY_JIRA) + + +def test_raphtory_client_analyst_write_with_write_grant(): + """RaphtoryClient with analyst role and WRITE grant can add nodes via remote_graph.""" + work_dir = tempfile.mkdtemp() + with make_server(work_dir).start(): + gql(CREATE_JIRA) + create_role("analyst") + grant_graph("analyst", "jira", "WRITE") + + client = RaphtoryClient(url=RAPHTORY, token=ANALYST_JWT) + client.remote_graph("jira").add_node(1, "client_node") + + client2 = RaphtoryClient(url=RAPHTORY, token=ADMIN_JWT) + received = client2.receive_graph("jira") + assert received.node("client_node") is not None + + +def test_raphtory_client_analyst_write_denied_without_write_grant(): + """RaphtoryClient with analyst role and READ-only grant cannot add nodes via remote_graph.""" + work_dir = tempfile.mkdtemp() + with make_server(work_dir).start(): + gql(CREATE_JIRA) + create_role("analyst") + grant_graph("analyst", "jira", "READ") + + client = RaphtoryClient(url=RAPHTORY, token=ANALYST_JWT) + with pytest.raises(Exception, match="Access denied"): + client.remote_graph("jira").add_node(1, "client_node") + + def test_analyst_sees_only_graph_filter_window(): """grantGraphFilteredReadOnly with a graph-level window filter restricts the temporal view. From bc800f547bd73309942c16bc3c65c5e08aebdb8f Mon Sep 17 00:00:00 2001 From: shivamka1 <4599890+shivamka1@users.noreply.github.com> Date: Tue, 17 Mar 2026 19:31:15 +0000 Subject: [PATCH 24/64] skip none --- raphtory-graphql/src/model/graph/filtering.rs | 3 +++ 1 file changed, 3 insertions(+) diff --git a/raphtory-graphql/src/model/graph/filtering.rs b/raphtory-graphql/src/model/graph/filtering.rs index a6fb174827..ce5efd743c 100644 --- a/raphtory-graphql/src/model/graph/filtering.rs +++ b/raphtory-graphql/src/model/graph/filtering.rs @@ -1750,7 +1750,10 @@ impl TryFrom for DynView { /// that is transparently applied whenever the role queries the graph. #[derive(InputObject, Clone, Debug, Serialize, Deserialize)] pub struct GraphAccessFilter { + #[serde(skip_serializing_if = "Option::is_none")] pub node: Option, + #[serde(skip_serializing_if = "Option::is_none")] pub edge: Option, + #[serde(skip_serializing_if = "Option::is_none")] pub graph: Option, } From 00d831ba110e3a67cdf5331ced659871c070ac28 Mon Sep 17 00:00:00 2001 From: shivamka1 <4599890+shivamka1@users.noreply.github.com> Date: Tue, 17 Mar 2026 20:04:17 +0000 Subject: [PATCH 25/64] introspect should allow access to metagraph only to avoid loading graphs only for introspect --- python/tests/test_permissions.py | 94 ++++++------------- raphtory-graphql/src/model/graph/graph.rs | 38 -------- .../src/model/graph/meta_graph.rs | 4 + raphtory-graphql/src/model/graph/namespace.rs | 54 +++++++++-- raphtory-graphql/src/model/mod.rs | 8 ++ 5 files changed, 90 insertions(+), 108 deletions(-) diff --git a/python/tests/test_permissions.py b/python/tests/test_permissions.py index 116bc65a8d..696d4b3236 100644 --- a/python/tests/test_permissions.py +++ b/python/tests/test_permissions.py @@ -34,7 +34,7 @@ QUERY_JIRA = """query { graph(path: "jira") { path } }""" QUERY_ADMIN = """query { graph(path: "admin") { path } }""" -QUERY_COUNT_NODES = """query { graph(path: "jira") { countNodes } }""" +QUERY_NS_GRAPHS = """query { root { graphs { list { path } } } }""" CREATE_JIRA = """mutation { newGraph(path:"jira", graphType:EVENT) }""" CREATE_ADMIN = """mutation { newGraph(path:"admin", graphType:EVENT) }""" @@ -140,29 +140,37 @@ def test_empty_store_gives_full_access(): def test_introspection_allowed_with_introspect_permission(): - """INTROSPECT-only role can call countNodes.""" + """INTROSPECT-only role sees graph in namespace listing but is denied by graph().""" work_dir = tempfile.mkdtemp() with make_server(work_dir).start(): gql(CREATE_JIRA) create_role("analyst") grant_graph("analyst", "jira", "INTROSPECT") - response = gql(QUERY_COUNT_NODES, headers=ANALYST_HEADERS) + # Namespace listing is allowed + response = gql(QUERY_NS_GRAPHS, headers=ANALYST_HEADERS) assert "errors" not in response, response - assert isinstance(response["data"]["graph"]["countNodes"], int) + paths = [g["path"] for g in response["data"]["root"]["graphs"]["list"]] + assert "jira" in paths + + # graph() resolver is denied + response = gql(QUERY_JIRA, headers=ANALYST_HEADERS) + assert response["data"] is None + assert "Access denied" in response["errors"][0]["message"] def test_read_implies_introspect(): - """READ implies INTROSPECT: a role with READ can call countNodes without an explicit INTROSPECT grant.""" + """READ also shows the graph in namespace listings (implies INTROSPECT).""" work_dir = tempfile.mkdtemp() with make_server(work_dir).start(): gql(CREATE_JIRA) create_role("analyst") - grant_graph("analyst", "jira", "READ") # READ implies INTROSPECT + grant_graph("analyst", "jira", "READ") - response = gql(QUERY_COUNT_NODES, headers=ANALYST_HEADERS) + response = gql(QUERY_NS_GRAPHS, headers=ANALYST_HEADERS) assert "errors" not in response, response - assert isinstance(response["data"]["graph"]["countNodes"], int) + paths = [g["path"] for g in response["data"]["root"]["graphs"]["list"]] + assert "jira" in paths def test_permissions_update_via_mutation(): @@ -335,78 +343,38 @@ def test_analyst_cannot_get_role(): assert "Access denied" in response["errors"][0]["message"] -def test_introspect_only_can_call_introspection_fields(): - """A role with only INTROSPECT (no READ) can call countNodes/countEdges/schema/uniqueLayers.""" - work_dir = tempfile.mkdtemp() - with make_server(work_dir).start(): - gql(CREATE_JIRA) - create_role("analyst") - grant_graph("analyst", "jira", "INTROSPECT") # no READ - - for query in [ - 'query { graph(path: "jira") { countNodes } }', - 'query { graph(path: "jira") { countEdges } }', - 'query { graph(path: "jira") { uniqueLayers } }', - ]: - response = gql(query, headers=ANALYST_HEADERS) - assert "errors" not in response, f"query={query} response={response}" - - -def test_introspect_only_cannot_read_nodes_or_edges(): - """A role with only INTROSPECT (no READ) is denied access to node/edge data.""" +def test_introspect_only_cannot_access_graph_data(): + """INTROSPECT-only role is denied by graph() — graph data is not accessible at all.""" work_dir = tempfile.mkdtemp() with make_server(work_dir).start(): gql(CREATE_JIRA) - # Add a node and edge so the graph has data to query - gql( - 'query { updateGraph(path: "jira") { addNode(time: 1, name: "a") { success } } }' - ) - gql( - 'query { updateGraph(path: "jira") { addEdge(time: 1, src: "a", dst: "b") { success } } }' - ) create_role("analyst") grant_graph("analyst", "jira", "INTROSPECT") # no READ - for query, expected_field in [ - ('query { graph(path: "jira") { nodes { list { name } } } }', "nodes"), - ( - 'query { graph(path: "jira") { edges { list { src { name } } } } }', - "edges", - ), - ('query { graph(path: "jira") { node(name: "a") { name } } }', "node"), - ( - 'query { graph(path: "jira") { properties { values { key } } } }', - "properties", - ), - ( - 'query { graph(path: "jira") { earliestTime { timestamp } } }', - "earliestTime", - ), - ]: - response = gql(query, headers=ANALYST_HEADERS) - errors = response.get("errors", []) - assert errors, f"expected denial for query={query!r}, got: {response}" - msg = errors[0]["message"] - assert ( - "Access denied" in msg and expected_field in msg and "read" in msg - ), f"unexpected error for {expected_field!r}: {msg!r}" + response = gql(QUERY_JIRA, headers=ANALYST_HEADERS) + assert response["data"] is None + assert "Access denied" in response["errors"][0]["message"] -def test_introspect_only_is_denied_without_introspect_or_read(): - """A role with no permissions on a graph is denied even when trying introspection fields.""" +def test_no_grant_hidden_from_namespace_and_graph(): + """A role with no permissions on a graph sees it neither in listings nor via graph().""" work_dir = tempfile.mkdtemp() with make_server(work_dir).start(): gql(CREATE_JIRA) create_role("analyst") # analyst has no grant on jira at all - response = gql( - 'query { graph(path: "jira") { countNodes } }', - headers=ANALYST_HEADERS, - ) + # graph() denied + response = gql(QUERY_JIRA, headers=ANALYST_HEADERS) assert response["data"] is None assert "Access denied" in response["errors"][0]["message"] + # namespace listing hides it + response = gql(QUERY_NS_GRAPHS, headers=ANALYST_HEADERS) + assert "errors" not in response, response + paths = [g["path"] for g in response["data"]["root"]["graphs"]["list"]] + assert "jira" not in paths + def test_analyst_sees_only_filtered_nodes(): """grantGraphFilteredReadOnly applies a node filter transparently for the role. diff --git a/raphtory-graphql/src/model/graph/graph.rs b/raphtory-graphql/src/model/graph/graph.rs index 08454e769c..2c1bf5a292 100644 --- a/raphtory-graphql/src/model/graph/graph.rs +++ b/raphtory-graphql/src/model/graph/graph.rs @@ -104,21 +104,6 @@ impl GqlGraph { permission: self.permission.clone(), } } - - fn require_read_access(&self, field: &str) -> Result<()> { - if matches!(self.permission, GraphPermission::Introspect) { - return Err(async_graphql::Error::new(format!( - "Access denied: '{field}' requires read permission on this graph" - ))); - } - Ok(()) - } - - fn require_introspection(&self, field: &str) -> Result<()> { - // All GraphPermission variants include introspection; this check always passes. - let _ = field; - Ok(()) - } } #[ResolvedObjectFields] @@ -129,7 +114,6 @@ impl GqlGraph { /// Returns the names of all layers in the graphview. async fn unique_layers(&self) -> Result> { - self.require_introspection("uniqueLayers")?; let self_clone = self.clone(); Ok(blocking_compute(move || self_clone.graph.unique_layers().map_into().collect()).await) } @@ -317,33 +301,28 @@ impl GqlGraph { /// Returns the time entry of the earliest activity in the graph. async fn earliest_time(&self) -> Result { - self.require_read_access("earliestTime")?; let self_clone = self.clone(); Ok(blocking_compute(move || self_clone.graph.earliest_time().into()).await) } /// Returns the time entry of the latest activity in the graph. async fn latest_time(&self) -> Result { - self.require_read_access("latestTime")?; let self_clone = self.clone(); Ok(blocking_compute(move || self_clone.graph.latest_time().into()).await) } /// Returns the start time of the window. Errors if there is no window. async fn start(&self) -> Result { - self.require_read_access("start")?; Ok(self.graph.start().into()) } /// Returns the end time of the window. Errors if there is no window. async fn end(&self) -> Result { - self.require_read_access("end")?; Ok(self.graph.end().into()) } /// Returns the earliest time that any edge in this graph is valid. async fn earliest_edge_time(&self, include_negative: Option) -> Result { - self.require_read_access("earliestEdgeTime")?; let self_clone = self.clone(); Ok(blocking_compute(move || { let include_negative = include_negative.unwrap_or(true); @@ -361,7 +340,6 @@ impl GqlGraph { /// Returns the latest time that any edge in this graph is valid. async fn latest_edge_time(&self, include_negative: Option) -> Result { - self.require_read_access("latestEdgeTime")?; let self_clone = self.clone(); Ok(blocking_compute(move || { let include_negative = include_negative.unwrap_or(true); @@ -386,14 +364,12 @@ impl GqlGraph { /// Returns: /// int: async fn count_edges(&self) -> Result { - self.require_introspection("countEdges")?; let self_clone = self.clone(); Ok(blocking_compute(move || self_clone.graph.count_edges()).await) } /// Returns the number of temporal edges in the graph. async fn count_temporal_edges(&self) -> Result { - self.require_introspection("countTemporalEdges")?; let self_clone = self.clone(); Ok(blocking_compute(move || self_clone.graph.count_temporal_edges()).await) } @@ -402,7 +378,6 @@ impl GqlGraph { /// /// Optionally takes a list of node ids to return a subset. async fn count_nodes(&self) -> Result { - self.require_introspection("countNodes")?; let self_clone = self.clone(); Ok(blocking_compute(move || self_clone.graph.count_nodes()).await) } @@ -413,13 +388,11 @@ impl GqlGraph { /// Returns true if the graph contains the specified node. async fn has_node(&self, name: String) -> Result { - self.require_read_access("hasNode")?; Ok(self.graph.has_node(name)) } /// Returns true if the graph contains the specified edge. Edges are specified by providing a source and destination node id. You can restrict the search to a specified layer. async fn has_edge(&self, src: String, dst: String, layer: Option) -> Result { - self.require_read_access("hasEdge")?; Ok(match layer { Some(name) => self .graph @@ -436,13 +409,11 @@ impl GqlGraph { /// Gets the node with the specified id. async fn node(&self, name: String) -> Result> { - self.require_read_access("node")?; Ok(self.graph.node(name).map(|node| node.into())) } /// Gets (optionally a subset of) the nodes in the graph. async fn nodes(&self, select: Option) -> Result { - self.require_read_access("nodes")?; let nn = self.graph.nodes(); if let Some(sel) = select { @@ -460,13 +431,11 @@ impl GqlGraph { /// Gets the edge with the specified source and destination nodes. async fn edge(&self, src: String, dst: String) -> Result> { - self.require_read_access("edge")?; Ok(self.graph.edge(src, dst).map(|e| e.into())) } /// Gets the edges in the graph. async fn edges<'a>(&self, select: Option) -> Result { - self.require_read_access("edges")?; let base = self.graph.edges_unlocked(); if let Some(sel) = select { @@ -484,13 +453,11 @@ impl GqlGraph { /// Returns the properties of the graph. async fn properties(&self) -> Result { - self.require_read_access("properties")?; Ok(Into::::into(self.graph.properties()).into()) } /// Returns the metadata of the graph. async fn metadata(&self) -> Result { - self.require_read_access("metadata")?; Ok(self.graph.metadata().into()) } @@ -522,7 +489,6 @@ impl GqlGraph { /// Returns the graph schema. async fn schema(&self) -> Result { - self.require_introspection("schema")?; let self_clone = self.clone(); Ok(blocking_compute(move || GraphSchema::new(&self_clone.graph)).await) } @@ -532,7 +498,6 @@ impl GqlGraph { } async fn shared_neighbours(&self, selected_nodes: Vec) -> Result> { - self.require_read_access("sharedNeighbours")?; let self_clone = self.clone(); Ok(blocking_compute(move || { if selected_nodes.is_empty() { @@ -565,7 +530,6 @@ impl GqlGraph { /// Export all nodes and edges from this graph view to another existing graph async fn export_to<'a>(&self, ctx: &Context<'a>, path: String) -> Result { - self.require_read_access("exportTo")?; let data = ctx.data_unchecked::(); let other_g = data.get_graph(path.as_ref()).await?.graph; let g = self.graph.clone(); @@ -652,7 +616,6 @@ impl GqlGraph { limit: usize, offset: usize, ) -> Result> { - self.require_read_access("searchNodes")?; #[cfg(feature = "search")] { let self_clone = self.clone(); @@ -679,7 +642,6 @@ impl GqlGraph { limit: usize, offset: usize, ) -> Result> { - self.require_read_access("searchEdges")?; #[cfg(feature = "search")] { let self_clone = self.clone(); diff --git a/raphtory-graphql/src/model/graph/meta_graph.rs b/raphtory-graphql/src/model/graph/meta_graph.rs index 3e34abbccf..d98a489dcf 100644 --- a/raphtory-graphql/src/model/graph/meta_graph.rs +++ b/raphtory-graphql/src/model/graph/meta_graph.rs @@ -48,6 +48,10 @@ impl MetaGraph { } } + pub(crate) fn local_path(&self) -> String { + self.folder.local_path().into() + } + async fn meta(&self) -> Result<&GraphMetadata> { Ok(self .meta diff --git a/raphtory-graphql/src/model/graph/namespace.rs b/raphtory-graphql/src/model/graph/namespace.rs index 89f259d011..498177053c 100644 --- a/raphtory-graphql/src/model/graph/namespace.rs +++ b/raphtory-graphql/src/model/graph/namespace.rs @@ -1,11 +1,13 @@ use crate::{ - data::get_relative_path, + auth::Access, + data::{get_relative_path, Data}, model::graph::{ collection::GqlCollection, meta_graph::MetaGraph, namespaced_item::NamespacedItem, }, paths::{ExistingGraphFolder, PathValidationError, ValidPath}, rayon::blocking_compute, }; +use async_graphql::Context; use dynamic_graphql::{ResolvedObject, ResolvedObjectFields}; use itertools::Itertools; use std::path::PathBuf; @@ -137,15 +139,28 @@ impl Namespace { #[ResolvedObjectFields] impl Namespace { - async fn graphs(&self) -> GqlCollection { + async fn graphs(&self, ctx: &Context<'_>) -> GqlCollection { + let data = ctx.data_unchecked::(); + let is_admin = ctx.data::().is_ok_and(|a| a == &Access::Rw); + let role: Option = ctx.data::>().ok().and_then(|r| r.clone()); + let policy = data.auth_policy.clone(); let self_clone = self.clone(); blocking_compute(move || { GqlCollection::new( self_clone .get_children() - .into_iter() .filter_map(|g| match g { - NamespacedItem::MetaGraph(g) => Some(g), + NamespacedItem::MetaGraph(g) => { + if let Some(ref policy) = policy { + let path = g.local_path(); + match policy.graph_permissions(is_admin, role.as_deref(), &path) { + Ok(_) => Some(g), + Err(_) => None, + } + } else { + Some(g) + } + } NamespacedItem::Namespace(_) => None, }) .sorted() @@ -193,9 +208,34 @@ impl Namespace { // Fetch the collection of namespaces/graphs in this namespace. // Namespaces will be listed before graphs. - async fn items(&self) -> GqlCollection { + async fn items(&self, ctx: &Context<'_>) -> GqlCollection { + let data = ctx.data_unchecked::(); + let is_admin = ctx.data::().is_ok_and(|a| a == &Access::Rw); + let role: Option = ctx.data::>().ok().and_then(|r| r.clone()); + let policy = data.auth_policy.clone(); let self_clone = self.clone(); - blocking_compute(move || GqlCollection::new(self_clone.get_children().sorted().collect())) - .await + blocking_compute(move || { + GqlCollection::new( + self_clone + .get_children() + .filter_map(|item| match item { + NamespacedItem::MetaGraph(ref g) => { + if let Some(ref policy) = policy { + let path = g.local_path(); + match policy.graph_permissions(is_admin, role.as_deref(), &path) { + Ok(_) => Some(item), + Err(_) => None, + } + } else { + Some(item) + } + } + NamespacedItem::Namespace(_) => Some(item), + }) + .sorted() + .collect(), + ) + }) + .await } } diff --git a/raphtory-graphql/src/model/mod.rs b/raphtory-graphql/src/model/mod.rs index 47074d9998..1de82f4704 100644 --- a/raphtory-graphql/src/model/mod.rs +++ b/raphtory-graphql/src/model/mod.rs @@ -176,6 +176,14 @@ impl QueryRoot { GraphPermission::Write // no policy: unrestricted }; + if matches!(perms, GraphPermission::Introspect) { + return Err(async_graphql::Error::new(format!( + "Access denied: role '{}' has introspect-only access to graph '{path}' — \ + use namespace listings for graph metadata", + role.unwrap_or("") + ))); + } + let graph_with_vecs = data.get_graph(path).await?; let graph: DynamicGraph = graph_with_vecs.graph.into_dynamic(); From f85f5a968628fabf422569083fc9c3b95fc9081d Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Tue, 17 Mar 2026 21:08:41 +0000 Subject: [PATCH 26/64] chore: apply tidy-public auto-fixes --- Cargo.lock | 37 +++++++++++++++++++++++++++---------- 1 file changed, 27 insertions(+), 10 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index c8953c9187..6f1e9c9694 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1059,27 +1059,23 @@ dependencies = [ [[package]] name = "clam-core" -version = "0.1.0" +version = "0.17.0" dependencies = [ "ahash", "arrow", "async-trait", - "bincode 1.3.3", "chrono", - "chrono-tz 0.8.6", + "chrono-tz 0.10.4", "comfy-table", - "crc32fast", "criterion", - "dashmap", "db4-storage", "env_logger 0.10.2", "fastrand", "flate2", "insta", - "itertools 0.14.0", + "itertools 0.13.0", "log", "nom", - "once_cell", "optd-core", "parking_lot", "proptest", @@ -1097,7 +1093,8 @@ dependencies = [ "slotmap", "snb", "tempfile", - "thiserror 1.0.69", + "thiserror 2.0.17", + "tikv-jemallocator", "tokio", "tracing", "tracing-test", @@ -3643,7 +3640,7 @@ dependencies = [ [[package]] name = "optd-core" -version = "0.1.0" +version = "0.17.0" dependencies = [ "anyhow", "bitvec", @@ -5586,7 +5583,7 @@ checksum = "1b6b67fb9a61334225b5b790716f609cd58395f895b3fe8b328786812a40bc3b" [[package]] name = "snb" -version = "0.1.0" +version = "0.17.0" dependencies = [ "chrono", "flate2", @@ -6041,6 +6038,26 @@ dependencies = [ "ordered-float 2.10.1", ] +[[package]] +name = "tikv-jemalloc-sys" +version = "0.6.1+5.3.0-1-ge13ca993e8ccb9ba9847cc330696e02839f328f7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd8aa5b2ab86a2cefa406d889139c162cbb230092f7d1d7cbc1716405d852a3b" +dependencies = [ + "cc", + "libc", +] + +[[package]] +name = "tikv-jemallocator" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0359b4327f954e0567e69fb191cf1436617748813819c94b8cd4a431422d053a" +dependencies = [ + "libc", + "tikv-jemalloc-sys", +] + [[package]] name = "time" version = "0.3.45" From 4b43a7807e5abb7d6de2ca25cec718dcd5588f31 Mon Sep 17 00:00:00 2001 From: shivamka1 <4599890+shivamka1@users.noreply.github.com> Date: Thu, 19 Mar 2026 13:36:28 +0000 Subject: [PATCH 27/64] impl graph metadata gql api, add tests --- python/tests/test_permissions.py | 42 +++++++++++++++++++++++++++++++ raphtory-graphql/src/model/mod.rs | 28 ++++++++++++++++++++- 2 files changed, 69 insertions(+), 1 deletion(-) diff --git a/python/tests/test_permissions.py b/python/tests/test_permissions.py index 696d4b3236..a5d3c6ccbf 100644 --- a/python/tests/test_permissions.py +++ b/python/tests/test_permissions.py @@ -35,6 +35,7 @@ QUERY_JIRA = """query { graph(path: "jira") { path } }""" QUERY_ADMIN = """query { graph(path: "admin") { path } }""" QUERY_NS_GRAPHS = """query { root { graphs { list { path } } } }""" +QUERY_META_JIRA = """query { graphMetadata(path: "jira") { path nodeCount } }""" CREATE_JIRA = """mutation { newGraph(path:"jira", graphType:EVENT) }""" CREATE_ADMIN = """mutation { newGraph(path:"admin", graphType:EVENT) }""" @@ -376,6 +377,47 @@ def test_no_grant_hidden_from_namespace_and_graph(): assert "jira" not in paths +def test_graph_metadata_allowed_with_introspect(): + """graphMetadata is accessible with INTROSPECT (no full graph load required).""" + work_dir = tempfile.mkdtemp() + with make_server(work_dir).start(): + gql(CREATE_JIRA) + create_role("analyst") + grant_graph("analyst", "jira", "INTROSPECT") + + response = gql(QUERY_META_JIRA, headers=ANALYST_HEADERS) + assert "errors" not in response, response + meta = response["data"]["graphMetadata"] + assert meta["path"] == "jira" + assert isinstance(meta["nodeCount"], int) + + +def test_graph_metadata_allowed_with_read(): + """graphMetadata is also accessible with READ.""" + work_dir = tempfile.mkdtemp() + with make_server(work_dir).start(): + gql(CREATE_JIRA) + create_role("analyst") + grant_graph("analyst", "jira", "READ") + + response = gql(QUERY_META_JIRA, headers=ANALYST_HEADERS) + assert "errors" not in response, response + assert response["data"]["graphMetadata"]["path"] == "jira" + + +def test_graph_metadata_denied_without_grant(): + """graphMetadata is denied when the role has no grant on the graph.""" + work_dir = tempfile.mkdtemp() + with make_server(work_dir).start(): + gql(CREATE_JIRA) + create_role("analyst") + # no grant on jira + + response = gql(QUERY_META_JIRA, headers=ANALYST_HEADERS) + assert response["data"] is None + assert "Access denied" in response["errors"][0]["message"] + + def test_analyst_sees_only_filtered_nodes(): """grantGraphFilteredReadOnly applies a node filter transparently for the role. diff --git a/raphtory-graphql/src/model/mod.rs b/raphtory-graphql/src/model/mod.rs index 1de82f4704..9a329bd47f 100644 --- a/raphtory-graphql/src/model/mod.rs +++ b/raphtory-graphql/src/model/mod.rs @@ -8,6 +8,7 @@ use crate::{ filtering::{GqlEdgeFilter, GqlGraphFilter, GqlNodeFilter, GraphAccessFilter}, graph::GqlGraph, index::IndexSpecInput, + meta_graph::MetaGraph, mutable_graph::GqlMutableGraph, namespace::Namespace, namespaced_item::NamespacedItem, @@ -19,7 +20,7 @@ use crate::{ query_plugin::QueryPlugin, }, }, - paths::{ValidGraphPaths, ValidWriteableGraphFolder}, + paths::{ExistingGraphFolder, ValidGraphPaths, ValidWriteableGraphFolder}, rayon::blocking_compute, url_encode::{url_decode_graph_at, url_encode_graph}, }; @@ -203,6 +204,31 @@ impl QueryRoot { )) } + /// Returns lightweight metadata for a graph (node/edge counts, timestamps) without loading it. + /// Requires at least INTROSPECT permission. + async fn graph_metadata<'a>(ctx: &Context<'a>, path: String) -> Result { + let data = ctx.data_unchecked::(); + let role = ctx.data::>().ok().and_then(|r| r.as_deref()); + let is_admin = ctx.data::().is_ok_and(|a| a == &Access::Rw); + + if let Some(policy) = &data.auth_policy { + policy + .graph_permissions(is_admin, role, &path) + .map_err(|msg| { + warn!( + role = role.unwrap_or(""), + graph = path.as_str(), + "Access denied by auth policy" + ); + async_graphql::Error::new(msg) + })?; + } + + let folder = ExistingGraphFolder::try_from(data.work_dir.clone(), &path) + .map_err(|e| async_graphql::Error::new(e.to_string()))?; + Ok(MetaGraph::new(folder)) + } + /// Update graph query, has side effects to update graph state /// /// Returns:: GqlMutableGraph From 210d40ba923aee2efad0f77efb31c1e1eb535322 Mon Sep 17 00:00:00 2001 From: shivamka1 <4599890+shivamka1@users.noreply.github.com> Date: Thu, 19 Mar 2026 14:06:26 +0000 Subject: [PATCH 28/64] gate vectorised graph and receive graph behind read gate --- Cargo.lock | 37 +++++----------- python/tests/test_permissions.py | 23 ++++++++++ raphtory-graphql/src/model/mod.rs | 72 ++++++++++++++++++++++++------- 3 files changed, 90 insertions(+), 42 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 6f1e9c9694..c8953c9187 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1059,23 +1059,27 @@ dependencies = [ [[package]] name = "clam-core" -version = "0.17.0" +version = "0.1.0" dependencies = [ "ahash", "arrow", "async-trait", + "bincode 1.3.3", "chrono", - "chrono-tz 0.10.4", + "chrono-tz 0.8.6", "comfy-table", + "crc32fast", "criterion", + "dashmap", "db4-storage", "env_logger 0.10.2", "fastrand", "flate2", "insta", - "itertools 0.13.0", + "itertools 0.14.0", "log", "nom", + "once_cell", "optd-core", "parking_lot", "proptest", @@ -1093,8 +1097,7 @@ dependencies = [ "slotmap", "snb", "tempfile", - "thiserror 2.0.17", - "tikv-jemallocator", + "thiserror 1.0.69", "tokio", "tracing", "tracing-test", @@ -3640,7 +3643,7 @@ dependencies = [ [[package]] name = "optd-core" -version = "0.17.0" +version = "0.1.0" dependencies = [ "anyhow", "bitvec", @@ -5583,7 +5586,7 @@ checksum = "1b6b67fb9a61334225b5b790716f609cd58395f895b3fe8b328786812a40bc3b" [[package]] name = "snb" -version = "0.17.0" +version = "0.1.0" dependencies = [ "chrono", "flate2", @@ -6038,26 +6041,6 @@ dependencies = [ "ordered-float 2.10.1", ] -[[package]] -name = "tikv-jemalloc-sys" -version = "0.6.1+5.3.0-1-ge13ca993e8ccb9ba9847cc330696e02839f328f7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cd8aa5b2ab86a2cefa406d889139c162cbb230092f7d1d7cbc1716405d852a3b" -dependencies = [ - "cc", - "libc", -] - -[[package]] -name = "tikv-jemallocator" -version = "0.6.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0359b4327f954e0567e69fb191cf1436617748813819c94b8cd4a431422d053a" -dependencies = [ - "libc", - "tikv-jemalloc-sys", -] - [[package]] name = "time" version = "0.3.45" diff --git a/python/tests/test_permissions.py b/python/tests/test_permissions.py index a5d3c6ccbf..3e31de38a1 100644 --- a/python/tests/test_permissions.py +++ b/python/tests/test_permissions.py @@ -615,6 +615,29 @@ def test_raphtory_client_analyst_write_denied_without_write_grant(): client.remote_graph("jira").add_node(1, "client_node") +def test_receive_graph_requires_read(): + """receive_graph (graph download) requires at least READ; INTROSPECT is not enough.""" + work_dir = tempfile.mkdtemp() + with make_server(work_dir).start(): + gql(CREATE_JIRA) + create_role("analyst") + + # No grant — denied + client = RaphtoryClient(url=RAPHTORY, token=ANALYST_JWT) + with pytest.raises(Exception, match="Access denied"): + client.receive_graph("jira") + + # INTROSPECT only — also denied + grant_graph("analyst", "jira", "INTROSPECT") + with pytest.raises(Exception, match="Access denied"): + client.receive_graph("jira") + + # READ — allowed + grant_graph("analyst", "jira", "READ") + g = client.receive_graph("jira") + assert g is not None + + def test_analyst_sees_only_graph_filter_window(): """grantGraphFilteredReadOnly with a graph-level window filter restricts the temporal view. diff --git a/raphtory-graphql/src/model/mod.rs b/raphtory-graphql/src/model/mod.rs index 9a329bd47f..c248897d0c 100644 --- a/raphtory-graphql/src/model/mod.rs +++ b/raphtory-graphql/src/model/mod.rs @@ -96,6 +96,36 @@ pub enum GqlGraphType { Event, } +/// Checks that the caller has at least READ permission for the graph at `path`. +/// Returns `Err` if denied or if only INTROSPECT was granted. +fn require_at_least_read( + policy: &Option>, + is_admin: bool, + role: Option<&str>, + path: &str, +) -> async_graphql::Result<()> { + if let Some(policy) = policy { + let perms = policy + .graph_permissions(is_admin, role, path) + .map_err(|msg| { + warn!( + role = role.unwrap_or(""), + graph = path, + "Access denied by auth policy" + ); + async_graphql::Error::new(msg) + })?; + if matches!(perms, GraphPermission::Introspect) { + return Err(async_graphql::Error::new(format!( + "Access denied: role '{}' has introspect-only access to graph '{path}' — \ + use namespace listings for graph metadata", + role.unwrap_or("") + ))); + } + } + Ok(()) +} + /// Applies a stored data filter (serialised as `serde_json::Value` with optional `node`, `edge`, /// `graph` keys) to a `DynamicGraph`, returning a new filtered view. async fn apply_graph_filter( @@ -163,7 +193,7 @@ impl QueryRoot { let is_admin = ctx.data::().is_ok_and(|a| a == &Access::Rw); let perms = if let Some(policy) = &data.auth_policy { - policy + let perms = policy .graph_permissions(is_admin, role, path) .map_err(|msg| { warn!( @@ -172,19 +202,19 @@ impl QueryRoot { "Access denied by auth policy" ); async_graphql::Error::new(msg) - })? + })?; + if matches!(perms, GraphPermission::Introspect) { + return Err(async_graphql::Error::new(format!( + "Access denied: role '{}' has introspect-only access to graph '{path}' — \ + use namespace listings for graph metadata", + role.unwrap_or("") + ))); + } + perms } else { GraphPermission::Write // no policy: unrestricted }; - if matches!(perms, GraphPermission::Introspect) { - return Err(async_graphql::Error::new(format!( - "Access denied: role '{}' has introspect-only access to graph '{path}' — \ - use namespace listings for graph metadata", - role.unwrap_or("") - ))); - } - let graph_with_vecs = data.get_graph(path).await?; let graph: DynamicGraph = graph_with_vecs.graph.into_dynamic(); @@ -266,10 +296,20 @@ impl QueryRoot { /// Create vectorised graph in the format used for queries /// /// Returns:: GqlVectorisedGraph - async fn vectorised_graph<'a>(ctx: &Context<'a>, path: &str) -> Option { + async fn vectorised_graph<'a>( + ctx: &Context<'a>, + path: &str, + ) -> Result> { let data = ctx.data_unchecked::(); - let g = data.get_graph(path).await.ok()?.vectors?; - Some(g.into()) + let role = ctx.data::>().ok().and_then(|r| r.as_deref()); + let is_admin = ctx.data::().is_ok_and(|a| a == &Access::Rw); + require_at_least_read(&data.auth_policy, is_admin, role, path)?; + Ok(data + .get_graph(path) + .await + .ok() + .and_then(|g| g.vectors) + .map(|v| v.into())) } /// Returns all namespaces using recursive search @@ -316,9 +356,11 @@ impl QueryRoot { /// /// Returns:: Base64 url safe encoded string async fn receive_graph<'a>(ctx: &Context<'a>, path: String) -> Result { - let path = path.as_ref(); let data = ctx.data_unchecked::(); - let g = data.get_graph(path).await?.graph.clone(); + let role = ctx.data::>().ok().and_then(|r| r.as_deref()); + let is_admin = ctx.data::().is_ok_and(|a| a == &Access::Rw); + require_at_least_read(&data.auth_policy, is_admin, role, &path)?; + let g = data.get_graph(&path).await?.graph.clone(); let res = url_encode_graph(g)?; Ok(res) } From 2471998c71090a6fb81aefd15c532078720fa737 Mon Sep 17 00:00:00 2001 From: shivamka1 <4599890+shivamka1@users.noreply.github.com> Date: Thu, 26 Mar 2026 13:25:10 +0000 Subject: [PATCH 29/64] impl namespace_permissions, add/fix tests, update postman collection --- python/python/raphtory/graphql/__init__.pyi | 4 +- python/tests/test_auth.py | 2 +- python/tests/test_permissions.py | 243 ++++++++++++++++-- raphtory-graphql/src/auth.rs | 12 +- raphtory-graphql/src/auth_policy.rs | 31 +++ raphtory-graphql/src/cli.rs | 8 +- raphtory-graphql/src/config/app_config.rs | 8 +- raphtory-graphql/src/config/auth_config.rs | 6 +- raphtory-graphql/src/lib.rs | 2 +- raphtory-graphql/src/model/graph/namespace.rs | 41 ++- raphtory-graphql/src/model/mod.rs | 177 ++++++++++++- raphtory-graphql/src/model/plugins/mod.rs | 2 +- .../src/model/plugins/permissions_plugin.rs | 2 +- raphtory-graphql/src/python/server/server.rs | 10 +- 14 files changed, 475 insertions(+), 73 deletions(-) diff --git a/python/python/raphtory/graphql/__init__.pyi b/python/python/raphtory/graphql/__init__.pyi index 9c3220c063..b2dd837a1a 100644 --- a/python/python/raphtory/graphql/__init__.pyi +++ b/python/python/raphtory/graphql/__init__.pyi @@ -62,7 +62,7 @@ class GraphServer(object): otlp_tracing_service_name (str, optional): The OTLP tracing service name config_path (str | PathLike, optional): Path to the config file auth_public_key: - auth_enabled_for_reads: + require_auth_for_reads: create_index: """ @@ -78,7 +78,7 @@ class GraphServer(object): otlp_agent_port: Optional[str] = None, otlp_tracing_service_name: Optional[str] = None, auth_public_key: Any = None, - auth_enabled_for_reads: Any = None, + require_auth_for_reads: Any = None, config_path: Optional[str | PathLike] = None, create_index: Any = None, permissions_store_path=None, diff --git a/python/tests/test_auth.py b/python/tests/test_auth.py index 3bccd53cf9..39c97d8adc 100644 --- a/python/tests/test_auth.py +++ b/python/tests/test_auth.py @@ -94,7 +94,7 @@ def test_default_read_access(query): def test_disabled_read_access(query): work_dir = tempfile.mkdtemp() with GraphServer( - work_dir, auth_public_key=PUB_KEY, auth_enabled_for_reads=False + work_dir, auth_public_key=PUB_KEY, require_auth_for_reads=False ).start(): add_test_graph() data = json.dumps({"query": query}) diff --git a/python/tests/test_permissions.py b/python/tests/test_permissions.py index 3e31de38a1..c928ef4f4c 100644 --- a/python/tests/test_permissions.py +++ b/python/tests/test_permissions.py @@ -35,9 +35,13 @@ QUERY_JIRA = """query { graph(path: "jira") { path } }""" QUERY_ADMIN = """query { graph(path: "admin") { path } }""" QUERY_NS_GRAPHS = """query { root { graphs { list { path } } } }""" +QUERY_NS_CHILDREN = """query { root { children { list { path } } } }""" QUERY_META_JIRA = """query { graphMetadata(path: "jira") { path nodeCount } }""" CREATE_JIRA = """mutation { newGraph(path:"jira", graphType:EVENT) }""" CREATE_ADMIN = """mutation { newGraph(path:"admin", graphType:EVENT) }""" +CREATE_TEAM_JIRA = """mutation { newGraph(path:"team/jira", graphType:EVENT) }""" +QUERY_TEAM_JIRA = """query { graph(path: "team/jira") { path } }""" +QUERY_TEAM_GRAPHS = """query { namespace(path: "team") { graphs { list { path } } } }""" def gql(query: str, headers=None) -> dict: @@ -130,6 +134,24 @@ def test_no_role_is_denied_when_policy_is_active(): assert "Access denied" in response["errors"][0]["message"] +def test_unknown_role_is_denied_when_policy_is_active(): + """JWT has a role claim but that role does not exist in the store → Denied. + + Distinct from test_no_role_is_denied_when_policy_is_active: here the JWT + does carry a role claim ('analyst'), but 'analyst' was never created in the + store. Both paths deny, but via different branches of the policy flowchart. + """ + work_dir = tempfile.mkdtemp() + with make_server(work_dir).start(): + gql(CREATE_JIRA) + # Make the store non-empty with a different role — but never create "analyst" + create_role("other_team") + + response = gql(QUERY_JIRA, headers=ANALYST_HEADERS) # JWT says role="analyst" + assert response["data"] is None + assert "Access denied" in response["errors"][0]["message"] + + def test_empty_store_gives_full_access(): """With an empty permissions store (no roles configured), authenticated users see everything.""" work_dir = tempfile.mkdtemp() @@ -141,21 +163,21 @@ def test_empty_store_gives_full_access(): def test_introspection_allowed_with_introspect_permission(): - """INTROSPECT-only role sees graph in namespace listing but is denied by graph().""" + """Namespace INTROSPECT makes graphs visible in listings but graph() is denied.""" work_dir = tempfile.mkdtemp() with make_server(work_dir).start(): - gql(CREATE_JIRA) + gql(CREATE_TEAM_JIRA) create_role("analyst") - grant_graph("analyst", "jira", "INTROSPECT") + grant_namespace("analyst", "team", "INTROSPECT") - # Namespace listing is allowed - response = gql(QUERY_NS_GRAPHS, headers=ANALYST_HEADERS) + # Namespace listing shows the graph as MetaGraph + response = gql(QUERY_TEAM_GRAPHS, headers=ANALYST_HEADERS) assert "errors" not in response, response - paths = [g["path"] for g in response["data"]["root"]["graphs"]["list"]] - assert "jira" in paths + paths = [g["path"] for g in response["data"]["namespace"]["graphs"]["list"]] + assert "team/jira" in paths - # graph() resolver is denied - response = gql(QUERY_JIRA, headers=ANALYST_HEADERS) + # graph() resolver is denied (only INTROSPECT, not READ) + response = gql(QUERY_TEAM_JIRA, headers=ANALYST_HEADERS) assert response["data"] is None assert "Access denied" in response["errors"][0]["message"] @@ -345,14 +367,14 @@ def test_analyst_cannot_get_role(): def test_introspect_only_cannot_access_graph_data(): - """INTROSPECT-only role is denied by graph() — graph data is not accessible at all.""" + """Namespace INTROSPECT is denied by graph() — READ is required to access graph data.""" work_dir = tempfile.mkdtemp() with make_server(work_dir).start(): - gql(CREATE_JIRA) + gql(CREATE_TEAM_JIRA) create_role("analyst") - grant_graph("analyst", "jira", "INTROSPECT") # no READ + grant_namespace("analyst", "team", "INTROSPECT") # no READ - response = gql(QUERY_JIRA, headers=ANALYST_HEADERS) + response = gql(QUERY_TEAM_JIRA, headers=ANALYST_HEADERS) assert response["data"] is None assert "Access denied" in response["errors"][0]["message"] @@ -377,19 +399,39 @@ def test_no_grant_hidden_from_namespace_and_graph(): assert "jira" not in paths -def test_graph_metadata_allowed_with_introspect(): - """graphMetadata is accessible with INTROSPECT (no full graph load required).""" +def test_grantgraph_introspect_rejected(): + """grantGraph with INTROSPECT permission is rejected — INTROSPECT is namespace-only.""" work_dir = tempfile.mkdtemp() with make_server(work_dir).start(): gql(CREATE_JIRA) create_role("analyst") - grant_graph("analyst", "jira", "INTROSPECT") - response = gql(QUERY_META_JIRA, headers=ANALYST_HEADERS) + response = gql( + 'mutation { permissions { grantGraph(role: "analyst", path: "jira", permission: INTROSPECT) { success } } }' + ) + assert "errors" in response + assert "INTROSPECT cannot be granted on a graph" in response["errors"][0]["message"] + + +def test_graph_metadata_allowed_with_introspect(): + """graphMetadata is accessible with INTROSPECT permission (namespace grant).""" + work_dir = tempfile.mkdtemp() + with make_server(work_dir).start(): + gql(CREATE_TEAM_JIRA) + create_role("analyst") + grant_namespace("analyst", "team", "INTROSPECT") + + response = gql( + 'query { graphMetadata(path: "team/jira") { path nodeCount } }', + headers=ANALYST_HEADERS, + ) assert "errors" not in response, response - meta = response["data"]["graphMetadata"] - assert meta["path"] == "jira" - assert isinstance(meta["nodeCount"], int) + assert response["data"]["graphMetadata"]["path"] == "team/jira" + + # graph() is still denied — INTROSPECT does not grant data access + response = gql(QUERY_TEAM_JIRA, headers=ANALYST_HEADERS) + assert response["data"] is None + assert "Access denied" in response["errors"][0]["message"] def test_graph_metadata_allowed_with_read(): @@ -616,25 +658,25 @@ def test_raphtory_client_analyst_write_denied_without_write_grant(): def test_receive_graph_requires_read(): - """receive_graph (graph download) requires at least READ; INTROSPECT is not enough.""" + """receive_graph (graph download) requires at least READ; namespace INTROSPECT is not enough.""" work_dir = tempfile.mkdtemp() with make_server(work_dir).start(): - gql(CREATE_JIRA) + gql(CREATE_TEAM_JIRA) create_role("analyst") # No grant — denied client = RaphtoryClient(url=RAPHTORY, token=ANALYST_JWT) with pytest.raises(Exception, match="Access denied"): - client.receive_graph("jira") + client.receive_graph("team/jira") - # INTROSPECT only — also denied - grant_graph("analyst", "jira", "INTROSPECT") + # Namespace INTROSPECT only — also denied for receive_graph + grant_namespace("analyst", "team", "INTROSPECT") with pytest.raises(Exception, match="Access denied"): - client.receive_graph("jira") + client.receive_graph("team/jira") # READ — allowed - grant_graph("analyst", "jira", "READ") - g = client.receive_graph("jira") + grant_namespace("analyst", "team", "READ") + g = client.receive_graph("team/jira") assert g is not None @@ -691,3 +733,148 @@ def test_analyst_sees_only_graph_filter_window(): "middle", "late", }, f"expected all nodes for admin, got {admin_names}" + + +# --- Namespace permission tests --- + + +def test_namespace_introspect_shows_graphs_in_listing(): + """grantNamespace INTROSPECT: graphs appear in namespace listing but graph() is denied.""" + work_dir = tempfile.mkdtemp() + with make_server(work_dir).start(): + gql(CREATE_TEAM_JIRA) + create_role("analyst") + grant_namespace("analyst", "team", "INTROSPECT") + + # Graphs visible as MetaGraph in namespace listing + response = gql(QUERY_TEAM_GRAPHS, headers=ANALYST_HEADERS) + assert "errors" not in response, response + paths = [g["path"] for g in response["data"]["namespace"]["graphs"]["list"]] + assert "team/jira" in paths + + # Direct graph access is denied + response = gql(QUERY_TEAM_JIRA, headers=ANALYST_HEADERS) + assert response["data"] is None + assert "Access denied" in response["errors"][0]["message"] + + +def test_namespace_read_exposes_graphs(): + """grantNamespace READ: graphs in the namespace are fully accessible via graph().""" + work_dir = tempfile.mkdtemp() + with make_server(work_dir).start(): + gql(CREATE_TEAM_JIRA) + create_role("analyst") + grant_namespace("analyst", "team", "READ") + + response = gql(QUERY_TEAM_JIRA, headers=ANALYST_HEADERS) + assert "errors" not in response, response + assert response["data"]["graph"]["path"] == "team/jira" + + +def test_discover_derivation(): + """grantGraph READ on a namespaced graph → ancestor namespace gets DISCOVER (visible in children).""" + work_dir = tempfile.mkdtemp() + with make_server(work_dir).start(): + gql(CREATE_TEAM_JIRA) + create_role("analyst") + grant_graph("analyst", "team/jira", "READ") # no explicit namespace grant + + # "team" namespace appears in root children due to DISCOVER derivation + response = gql(QUERY_NS_CHILDREN, headers=ANALYST_HEADERS) + assert "errors" not in response, response + paths = [n["path"] for n in response["data"]["root"]["children"]["list"]] + assert "team" in paths + + +def test_no_namespace_grant_hidden_from_children(): + """No grants at all → namespace is hidden from root children listing.""" + work_dir = tempfile.mkdtemp() + with make_server(work_dir).start(): + gql(CREATE_TEAM_JIRA) + create_role("analyst") + # analyst has no grants at all + + response = gql(QUERY_NS_CHILDREN, headers=ANALYST_HEADERS) + assert "errors" not in response, response + paths = [n["path"] for n in response["data"]["root"]["children"]["list"]] + assert "team" not in paths + + +# --- deleteGraph / sendGraph policy delegation --- + +DELETE_JIRA = """mutation { deleteGraph(path: "jira") }""" +DELETE_TEAM_JIRA = """mutation { deleteGraph(path: "team/jira") }""" + + +def test_analyst_can_delete_with_write_grant(): + """'a':'ro' user with WRITE grant on a graph can delete it.""" + work_dir = tempfile.mkdtemp() + with make_server(work_dir).start(): + gql(CREATE_JIRA) + create_role("analyst") + grant_graph("analyst", "jira", "WRITE") + + response = gql(DELETE_JIRA, headers=ANALYST_HEADERS) + assert "errors" not in response, response + assert response["data"]["deleteGraph"] is True + + +def test_analyst_cannot_delete_with_read_grant(): + """'a':'ro' user with READ-only grant is denied by deleteGraph.""" + work_dir = tempfile.mkdtemp() + with make_server(work_dir).start(): + gql(CREATE_JIRA) + create_role("analyst") + grant_graph("analyst", "jira", "READ") + + response = gql(DELETE_JIRA, headers=ANALYST_HEADERS) + assert "errors" in response + assert "Access denied" in response["errors"][0]["message"] + + +def test_analyst_can_delete_with_namespace_write(): + """'a':'ro' user with namespace WRITE (cascades to graph WRITE) can delete a graph.""" + work_dir = tempfile.mkdtemp() + with make_server(work_dir).start(): + gql(CREATE_TEAM_JIRA) + create_role("analyst") + grant_namespace("analyst", "team", "WRITE") + + response = gql(DELETE_TEAM_JIRA, headers=ANALYST_HEADERS) + assert "errors" not in response, response + assert response["data"]["deleteGraph"] is True + + +def test_analyst_cannot_send_graph_without_namespace_write(): + """'a':'ro' user without namespace WRITE is denied by sendGraph.""" + work_dir = tempfile.mkdtemp() + with make_server(work_dir).start(): + create_role("analyst") + grant_namespace("analyst", "team", "READ") # READ, not WRITE + + response = gql( + 'mutation { sendGraph(path: "team/new", graph: "dummydata", overwrite: false) }', + headers=ANALYST_HEADERS, + ) + assert "errors" in response + assert "Access denied" in response["errors"][0]["message"] + + +def test_analyst_send_graph_passes_auth_with_namespace_write(): + """'a':'ro' user with namespace WRITE passes the auth gate in sendGraph. + + The request fails on graph decoding (invalid data), not on access control — + proving the namespace WRITE check is honoured. + """ + work_dir = tempfile.mkdtemp() + with make_server(work_dir).start(): + create_role("analyst") + grant_namespace("analyst", "team", "WRITE") + + response = gql( + 'mutation { sendGraph(path: "team/new", graph: "not_valid_base64", overwrite: false) }', + headers=ANALYST_HEADERS, + ) + # Auth passed — error is about graph decoding, not access + assert "errors" in response + assert "Access denied" not in response["errors"][0]["message"] diff --git a/raphtory-graphql/src/auth.rs b/raphtory-graphql/src/auth.rs index c19a498238..12f3031648 100644 --- a/raphtory-graphql/src/auth.rs +++ b/raphtory-graphql/src/auth.rs @@ -138,11 +138,11 @@ where (claims.access, claims.role) } None => { - if self.config.enabled_for_reads { - warn!("Request missing valid JWT — rejecting (auth_enabled_for_reads=true)"); + if self.config.require_auth_for_reads { + warn!("Request missing valid JWT — rejecting (require_auth_for_reads=true)"); return Err(Unauthorized(AuthError::RequireRead)); } else { - debug!("No valid JWT but auth_enabled_for_reads=false — granting read access"); + debug!("No valid JWT but require_auth_for_reads=false — granting read access"); (Access::Ro, None) } } @@ -228,13 +228,13 @@ fn extract_claims_from_header(header: &str, public_key: &PublicKey) -> Option Result<(), AuthError>; + fn require_jwt_write_access(&self) -> Result<(), AuthError>; } /// Check that the request carries a write-access JWT (`"access": "rw"`). /// For use in dynamic resolver ops that run under `query { ... }` and are /// therefore not covered by the `MutationAuth` extension. -pub fn require_write_access_dynamic( +pub fn require_jwt_write_access_dynamic( ctx: &async_graphql::dynamic::ResolverContext, ) -> Result<(), async_graphql::Error> { if ctx.data::().is_ok_and(|a| a == &Access::Rw) { @@ -247,7 +247,7 @@ pub fn require_write_access_dynamic( } impl<'a> ContextValidation for &Context<'a> { - fn require_write_access(&self) -> Result<(), AuthError> { + fn require_jwt_write_access(&self) -> Result<(), AuthError> { match self.data::() { Ok(access) if access == &Access::Rw => Ok(()), Err(_) => Ok(()), // no auth context (e.g. tests) — unrestricted diff --git a/raphtory-graphql/src/auth_policy.rs b/raphtory-graphql/src/auth_policy.rs index 37a6169c1c..4b46e390ff 100644 --- a/raphtory-graphql/src/auth_policy.rs +++ b/raphtory-graphql/src/auth_policy.rs @@ -12,6 +12,22 @@ pub enum GraphPermission { Write, } +/// The effective permission level a principal has on a namespace. +/// Variants are ordered lowest to highest so that `PartialOrd`/`Ord` reflect the hierarchy. +#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord)] +pub enum NamespacePermission { + /// No access — namespace is invisible. + Denied, + /// Namespace is visible in parent `children()` listings but cannot be browsed. + Discover, + /// Namespace is browseable; graphs inside are visible as MetaGraph in `graphs()`. + Introspect, + /// All descendant graphs are fully readable. + Read, + /// All descendants are writable; `newGraph` is allowed. + Write, +} + pub trait AuthorizationPolicy: Send + Sync + 'static { /// Resolves the effective permission level for a principal on a graph. /// Returns `Err(denial message)` only when access is entirely denied (not even introspect). @@ -24,6 +40,17 @@ pub trait AuthorizationPolicy: Send + Sync + 'static { role: Option<&str>, path: &str, ) -> Result; + + /// Resolves the effective namespace permission for a principal. + /// Admin bypass always yields `Write`. + /// Empty store yields `Read` (fail open, consistent with graph_permissions). + /// Missing role yields `Denied`. + fn namespace_permissions( + &self, + is_admin: bool, + role: Option<&str>, + path: &str, + ) -> NamespacePermission; } /// A no-op policy that grants full access to everyone. @@ -39,4 +66,8 @@ impl AuthorizationPolicy for NoopPolicy { ) -> Result { Ok(GraphPermission::Write) } + + fn namespace_permissions(&self, _: bool, _: Option<&str>, _: &str) -> NamespacePermission { + NamespacePermission::Write + } } diff --git a/raphtory-graphql/src/cli.rs b/raphtory-graphql/src/cli.rs index 6b65b2c204..49da454e84 100644 --- a/raphtory-graphql/src/cli.rs +++ b/raphtory-graphql/src/cli.rs @@ -3,7 +3,7 @@ use crate::config::index_config::DEFAULT_CREATE_INDEX; use crate::{ config::{ app_config::AppConfigBuilder, - auth_config::{DEFAULT_AUTH_ENABLED_FOR_READS, PUBLIC_KEY_DECODING_ERR_MSG}, + auth_config::{DEFAULT_REQUIRE_AUTH_FOR_READS, PUBLIC_KEY_DECODING_ERR_MSG}, cache_config::{DEFAULT_CAPACITY, DEFAULT_TTI_SECONDS}, log_config::DEFAULT_LOG_LEVEL, otlp_config::{ @@ -75,8 +75,8 @@ struct ServerArgs { #[arg(long, env = "RAPHTORY_AUTH_PUBLIC_KEY", default_value = None, help = "Public key for auth")] auth_public_key: Option, - #[arg(long, env = "RAPHTORY_AUTH_ENABLED_FOR_READS", default_value_t = DEFAULT_AUTH_ENABLED_FOR_READS, help = "Enable auth for reads")] - auth_enabled_for_reads: bool, + #[arg(long, env = "RAPHTORY_REQUIRE_AUTH_FOR_READS", default_value_t = DEFAULT_REQUIRE_AUTH_FOR_READS, help = "Require JWT authentication for read requests (default: true)")] + require_auth_for_reads: bool, #[arg(long, env = "RAPHTORY_PUBLIC_DIR", default_value = None, help = "Public directory path")] public_dir: Option, @@ -117,7 +117,7 @@ where .with_auth_public_key(server_args.auth_public_key) .expect(PUBLIC_KEY_DECODING_ERR_MSG) .with_public_dir(server_args.public_dir) - .with_auth_enabled_for_reads(server_args.auth_enabled_for_reads); + .with_require_auth_for_reads(server_args.require_auth_for_reads); #[cfg(feature = "search")] { diff --git a/raphtory-graphql/src/config/app_config.rs b/raphtory-graphql/src/config/app_config.rs index 9404d678e6..56c6ba29a1 100644 --- a/raphtory-graphql/src/config/app_config.rs +++ b/raphtory-graphql/src/config/app_config.rs @@ -106,8 +106,8 @@ impl AppConfigBuilder { Ok(self) } - pub fn with_auth_enabled_for_reads(mut self, enabled_for_reads: bool) -> Self { - self.auth.enabled_for_reads = enabled_for_reads; + pub fn with_require_auth_for_reads(mut self, require_auth_for_reads: bool) -> Self { + self.auth.require_auth_for_reads = require_auth_for_reads; self } @@ -195,8 +195,8 @@ pub fn load_config( .with_auth_public_key(public_key) .map_err(|_| ConfigError::Message(PUBLIC_KEY_DECODING_ERR_MSG.to_owned()))?; } - if let Ok(enabled_for_reads) = settings.get::("auth.enabled_for_reads") { - app_config_builder = app_config_builder.with_auth_enabled_for_reads(enabled_for_reads); + if let Ok(require_auth_for_reads) = settings.get::("auth.require_auth_for_reads") { + app_config_builder = app_config_builder.with_require_auth_for_reads(require_auth_for_reads); } if let Ok(public_dir) = settings.get::>("public_dir") { diff --git a/raphtory-graphql/src/config/auth_config.rs b/raphtory-graphql/src/config/auth_config.rs index 166429ad1f..91940f5fa9 100644 --- a/raphtory-graphql/src/config/auth_config.rs +++ b/raphtory-graphql/src/config/auth_config.rs @@ -4,7 +4,7 @@ use serde::{de, Deserialize, Deserializer, Serialize}; use spki::SubjectPublicKeyInfoRef; use std::fmt::Debug; -pub const DEFAULT_AUTH_ENABLED_FOR_READS: bool = true; +pub const DEFAULT_REQUIRE_AUTH_FOR_READS: bool = true; pub const PUBLIC_KEY_DECODING_ERR_MSG: &str = "Could not successfully decode the public key. Make sure you use the standard alphabet with padding"; #[derive(Clone)] @@ -69,14 +69,14 @@ impl Debug for PublicKey { #[derive(Debug, Deserialize, Clone, Serialize, PartialEq)] pub struct AuthConfig { pub public_key: Option, - pub enabled_for_reads: bool, + pub require_auth_for_reads: bool, } impl Default for AuthConfig { fn default() -> Self { Self { public_key: None, - enabled_for_reads: DEFAULT_AUTH_ENABLED_FOR_READS, + require_auth_for_reads: DEFAULT_REQUIRE_AUTH_FOR_READS, } } } diff --git a/raphtory-graphql/src/lib.rs b/raphtory-graphql/src/lib.rs index 09a059f387..82779cb902 100644 --- a/raphtory-graphql/src/lib.rs +++ b/raphtory-graphql/src/lib.rs @@ -1,5 +1,5 @@ pub use crate::{ - auth::require_write_access_dynamic, model::graph::filtering::GraphAccessFilter, + auth::require_jwt_write_access_dynamic, model::graph::filtering::GraphAccessFilter, server::GraphServer, }; use crate::{data::InsertionError, paths::PathValidationError}; diff --git a/raphtory-graphql/src/model/graph/namespace.rs b/raphtory-graphql/src/model/graph/namespace.rs index 498177053c..d3933a05f9 100644 --- a/raphtory-graphql/src/model/graph/namespace.rs +++ b/raphtory-graphql/src/model/graph/namespace.rs @@ -1,5 +1,6 @@ use crate::{ auth::Access, + auth_policy::NamespacePermission, data::{get_relative_path, Data}, model::graph::{ collection::GqlCollection, meta_graph::MetaGraph, namespaced_item::NamespacedItem, @@ -189,7 +190,11 @@ impl Namespace { } } - async fn children(&self) -> GqlCollection { + async fn children(&self, ctx: &Context<'_>) -> GqlCollection { + let data = ctx.data_unchecked::(); + let is_admin = ctx.data::().is_ok_and(|a| a == &Access::Rw); + let role: Option = ctx.data::>().ok().and_then(|r| r.clone()); + let policy = data.auth_policy.clone(); let self_clone = self.clone(); blocking_compute(move || { GqlCollection::new( @@ -197,7 +202,22 @@ impl Namespace { .get_children() .filter_map(|item| match item { NamespacedItem::MetaGraph(_) => None, - NamespacedItem::Namespace(n) => Some(n), + NamespacedItem::Namespace(n) => { + if let Some(ref policy) = policy { + let perm = policy.namespace_permissions( + is_admin, + role.as_deref(), + &n.relative_path, + ); + if perm >= NamespacePermission::Discover { + Some(n) + } else { + None + } + } else { + Some(n) + } + } }) .sorted() .collect(), @@ -230,7 +250,22 @@ impl Namespace { Some(item) } } - NamespacedItem::Namespace(_) => Some(item), + NamespacedItem::Namespace(ref n) => { + if let Some(ref policy) = policy { + let perm = policy.namespace_permissions( + is_admin, + role.as_deref(), + &n.relative_path, + ); + if perm >= NamespacePermission::Discover { + Some(item) + } else { + None + } + } else { + Some(item) + } + } }) .sorted() .collect(), diff --git a/raphtory-graphql/src/model/mod.rs b/raphtory-graphql/src/model/mod.rs index c248897d0c..3fad41f510 100644 --- a/raphtory-graphql/src/model/mod.rs +++ b/raphtory-graphql/src/model/mod.rs @@ -1,6 +1,6 @@ use crate::{ auth::{Access, AuthError, ContextValidation}, - auth_policy::GraphPermission, + auth_policy::{GraphPermission, NamespacePermission}, data::Data, model::{ graph::{ @@ -118,7 +118,7 @@ fn require_at_least_read( if matches!(perms, GraphPermission::Introspect) { return Err(async_graphql::Error::new(format!( "Access denied: role '{}' has introspect-only access to graph '{path}' — \ - use namespace listings for graph metadata", + use graphMetadata(path:) for counts and timestamps, or namespace listings to browse graphs", role.unwrap_or("") ))); } @@ -175,6 +175,12 @@ async fn apply_graph_filter( Ok(graph) } +/// Returns the namespace portion of a graph path: everything before the last `/`. +/// For top-level graphs (no `/`), returns `""` (the root namespace). +fn parent_namespace(path: &str) -> &str { + path.rfind('/').map(|i| &path[..i]).unwrap_or("") +} + #[derive(ResolvedObject)] #[graphql(root)] pub(crate) struct QueryRoot; @@ -206,7 +212,7 @@ impl QueryRoot { if matches!(perms, GraphPermission::Introspect) { return Err(async_graphql::Error::new(format!( "Access denied: role '{}' has introspect-only access to graph '{path}' — \ - use namespace listings for graph metadata", + READ is required to access graph data; use graphMetadata(path:) for counts and timestamps, or namespace listings to browse graphs", role.unwrap_or("") ))); } @@ -268,7 +274,7 @@ impl QueryRoot { let role = ctx.data::>().ok().and_then(|r| r.as_deref()); if !is_admin { match &data.auth_policy { - None => ctx.require_write_access()?, + None => ctx.require_jwt_write_access()?, Some(policy) => { let perms = policy .graph_permissions(false, role, &path) @@ -396,8 +402,28 @@ impl Mut { /// Delete graph from a path on the server. // If namespace is not provided, it will be set to the current working directory. async fn delete_graph<'a>(ctx: &Context<'a>, path: String) -> Result { - ctx.require_write_access()?; let data = ctx.data_unchecked::(); + let is_admin = ctx.data::().is_ok_and(|a| a == &Access::Rw); + let role = ctx.data::>().ok().and_then(|r| r.as_deref()); + if !is_admin { + match &data.auth_policy { + None => ctx.require_jwt_write_access()?, + Some(policy) => { + let perm = policy + .graph_permissions(false, role, &path) + .map_err(|msg| async_graphql::Error::new(msg))?; + if !matches!(perm, GraphPermission::Write) { + return if let Some(role) = role { + Err(async_graphql::Error::new(format!( + "Access denied: WRITE required on graph '{path}' to delete it" + ))) + } else { + Err(AuthError::RequireWrite.into()) + }; + } + } + } + } data.delete_graph(&path).await?; Ok(true) } @@ -413,7 +439,7 @@ impl Mut { let role = ctx.data::>().ok().and_then(|r| r.as_deref()); if !is_admin { match &data.auth_policy { - None => ctx.require_write_access()?, + None => ctx.require_jwt_write_access()?, Some(policy) => { let perms = policy .graph_permissions(false, role, &path) @@ -459,9 +485,28 @@ impl Mut { new_path: &str, overwrite: Option, ) -> Result { - ctx.require_write_access()?; - Self::copy_graph(ctx, path, new_path, overwrite).await?; let data = ctx.data_unchecked::(); + let is_admin = ctx.data::().is_ok_and(|a| a == &Access::Rw); + let role = ctx.data::>().ok().and_then(|r| r.as_deref()); + if !is_admin { + if let Some(policy) = &data.auth_policy { + // src: require WRITE (moving = deleting source) + let src_perm = policy + .graph_permissions(false, role, path) + .map_err(|msg| async_graphql::Error::new(msg))?; + if !matches!(src_perm, GraphPermission::Write) { + return if let Some(role) = role { + Err(async_graphql::Error::new(format!( + "Access denied: WRITE required on source graph '{path}' to move it" + ))) + } else { + Err(AuthError::RequireWrite.into()) + }; + } + } + } + // copy_graph handles dst namespace WRITE check (and src READ, which WRITE implies) + Self::copy_graph(ctx, path, new_path, overwrite).await?; data.delete_graph(path).await?; Ok(true) } @@ -473,12 +518,45 @@ impl Mut { new_path: &str, overwrite: Option, ) -> Result { - ctx.require_write_access()?; + let data = ctx.data_unchecked::(); + let is_admin = ctx.data::().is_ok_and(|a| a == &Access::Rw); + let role = ctx.data::>().ok().and_then(|r| r.as_deref()); + if !is_admin { + match &data.auth_policy { + None => ctx.require_jwt_write_access()?, + Some(policy) => { + // src: require at least READ + let src_perm = policy + .graph_permissions(false, role, path) + .map_err(|msg| async_graphql::Error::new(msg))?; + if matches!(src_perm, GraphPermission::Introspect) { + return if let Some(role) = role { + Err(async_graphql::Error::new(format!( + "Access denied: READ required on source graph '{path}' to copy it" + ))) + } else { + Err(AuthError::RequireWrite.into()) + }; + } + // dst namespace: require WRITE + let dst_ns = parent_namespace(new_path); + let ns_perm = policy.namespace_permissions(false, role, dst_ns); + if ns_perm < NamespacePermission::Write { + return if let Some(role) = role { + Err(async_graphql::Error::new(format!( + "Access denied: WRITE required on namespace '{dst_ns}' to create graph '{new_path}'" + ))) + } else { + Err(AuthError::RequireWrite.into()) + }; + } + } + } + } // doing this in a more efficient way is not trivial, this at least is correct // there are questions like, maybe the new vectorised graph have different rules // for the templates or if it needs to be vectorised at all let overwrite = overwrite.unwrap_or(false); - let data = ctx.data_unchecked::(); let graph = data.get_graph(path).await?.graph; let folder = data.validate_path_for_insert(new_path, overwrite)?; data.insert_graph(folder, graph).await?; @@ -496,8 +574,27 @@ impl Mut { graph: Upload, overwrite: bool, ) -> Result { - ctx.require_write_access()?; let data = ctx.data_unchecked::(); + let is_admin = ctx.data::().is_ok_and(|a| a == &Access::Rw); + let role = ctx.data::>().ok().and_then(|r| r.as_deref()); + if !is_admin { + match &data.auth_policy { + None => ctx.require_jwt_write_access()?, + Some(policy) => { + let dst_ns = parent_namespace(&path); + let ns_perm = policy.namespace_permissions(false, role, dst_ns); + if ns_perm < NamespacePermission::Write { + return if let Some(role) = role { + Err(async_graphql::Error::new(format!( + "Access denied: WRITE required on namespace '{dst_ns}' to upload graph '{path}'" + ))) + } else { + Err(AuthError::RequireWrite.into()) + }; + } + } + } + } let in_file = graph.value(ctx)?.content; let folder = data.validate_path_for_insert(&path, overwrite)?; data.insert_graph_as_bytes(folder, in_file).await?; @@ -515,8 +612,27 @@ impl Mut { graph: String, overwrite: bool, ) -> Result { - ctx.require_write_access()?; let data = ctx.data_unchecked::(); + let is_admin = ctx.data::().is_ok_and(|a| a == &Access::Rw); + let role = ctx.data::>().ok().and_then(|r| r.as_deref()); + if !is_admin { + match &data.auth_policy { + None => ctx.require_jwt_write_access()?, + Some(policy) => { + let dst_ns = parent_namespace(path); + let ns_perm = policy.namespace_permissions(false, role, dst_ns); + if ns_perm < NamespacePermission::Write { + return if let Some(role) = role { + Err(async_graphql::Error::new(format!( + "Access denied: WRITE required on namespace '{dst_ns}' to send graph '{path}'" + ))) + } else { + Err(AuthError::RequireWrite.into()) + }; + } + } + } + } let folder = if overwrite { ValidWriteableGraphFolder::try_existing_or_new(data.work_dir.clone(), path)? } else { @@ -543,8 +659,41 @@ impl Mut { new_path: String, overwrite: bool, ) -> Result { - ctx.require_write_access()?; let data = ctx.data_unchecked::(); + let is_admin = ctx.data::().is_ok_and(|a| a == &Access::Rw); + let role = ctx.data::>().ok().and_then(|r| r.as_deref()); + if !is_admin { + match &data.auth_policy { + None => ctx.require_jwt_write_access()?, + Some(policy) => { + // parent: require at least READ + let parent_perm = policy + .graph_permissions(false, role, parent_path) + .map_err(|msg| async_graphql::Error::new(msg))?; + if matches!(parent_perm, GraphPermission::Introspect) { + return if let Some(role) = role { + Err(async_graphql::Error::new(format!( + "Access denied: READ required on source graph '{parent_path}' to create a subgraph" + ))) + } else { + Err(AuthError::RequireWrite.into()) + }; + } + // dst namespace: require WRITE + let dst_ns = parent_namespace(&new_path); + let ns_perm = policy.namespace_permissions(false, role, dst_ns); + if ns_perm < NamespacePermission::Write { + return if let Some(role) = role { + Err(async_graphql::Error::new(format!( + "Access denied: WRITE required on namespace '{dst_ns}' to create graph '{new_path}'" + ))) + } else { + Err(AuthError::RequireWrite.into()) + }; + } + } + } + } let folder = data.validate_path_for_insert(&new_path, overwrite)?; let parent_graph = data.get_graph(parent_path).await?.graph; let folder_clone = folder.clone(); @@ -569,7 +718,7 @@ impl Mut { index_spec: Option, in_ram: bool, ) -> Result { - ctx.require_write_access()?; + ctx.require_jwt_write_access()?; #[cfg(feature = "search")] { let data = ctx.data_unchecked::(); diff --git a/raphtory-graphql/src/model/plugins/mod.rs b/raphtory-graphql/src/model/plugins/mod.rs index 9f97f8467a..cc7d3f6704 100644 --- a/raphtory-graphql/src/model/plugins/mod.rs +++ b/raphtory-graphql/src/model/plugins/mod.rs @@ -26,7 +26,7 @@ where } /// Register an operation into the `PermissionsQueryPlugin` entry point (query root). -/// Ops registered here must call `require_write_access_dynamic` themselves since they +/// Ops registered here must call `require_jwt_write_access_dynamic` themselves since they /// are not covered by the `MutationAuth` extension. pub fn register_permissions_query(name: &'static str) where diff --git a/raphtory-graphql/src/model/plugins/permissions_plugin.rs b/raphtory-graphql/src/model/plugins/permissions_plugin.rs index bcf8cfcb3b..2a6e084363 100644 --- a/raphtory-graphql/src/model/plugins/permissions_plugin.rs +++ b/raphtory-graphql/src/model/plugins/permissions_plugin.rs @@ -53,7 +53,7 @@ impl<'a> ResolveOwned<'a> for PermissionsPlugin { } } -/// Read-only entry point for permissions queries (admin-gated via require_write_access_dynamic). +/// Read-only entry point for permissions queries (admin-gated via require_jwt_write_access_dynamic). #[derive(Clone, Default)] pub struct PermissionsQueryPlugin; diff --git a/raphtory-graphql/src/python/server/server.rs b/raphtory-graphql/src/python/server/server.rs index 389b91d42a..3a7febe928 100644 --- a/raphtory-graphql/src/python/server/server.rs +++ b/raphtory-graphql/src/python/server/server.rs @@ -37,7 +37,7 @@ use std::{path::PathBuf, sync::Arc, thread}; /// otlp_tracing_service_name (str, optional): The OTLP tracing service name /// config_path (str | PathLike, optional): Path to the config file /// auth_public_key: -/// auth_enabled_for_reads: +/// require_auth_for_reads: /// create_index: #[pyclass(name = "GraphServer", module = "raphtory.graphql")] pub struct PyGraphServer(pub Option); @@ -87,7 +87,7 @@ impl PyGraphServer { impl PyGraphServer { #[new] #[pyo3( - signature = (work_dir, cache_capacity = None, cache_tti_seconds = None, log_level = None, tracing=None, tracing_level=None, otlp_agent_host=None, otlp_agent_port=None, otlp_tracing_service_name=None, auth_public_key=None, auth_enabled_for_reads=None, config_path = None, create_index = None, permissions_store_path = None) + signature = (work_dir, cache_capacity = None, cache_tti_seconds = None, log_level = None, tracing=None, tracing_level=None, otlp_agent_host=None, otlp_agent_port=None, otlp_tracing_service_name=None, auth_public_key=None, require_auth_for_reads=None, config_path = None, create_index = None, permissions_store_path = None) )] fn py_new( work_dir: PathBuf, @@ -100,7 +100,7 @@ impl PyGraphServer { otlp_agent_port: Option, otlp_tracing_service_name: Option, auth_public_key: Option, - auth_enabled_for_reads: Option, + require_auth_for_reads: Option, config_path: Option, create_index: Option, permissions_store_path: Option, @@ -141,9 +141,9 @@ impl PyGraphServer { app_config_builder = app_config_builder .with_auth_public_key(auth_public_key) .map_err(|_| PyValueError::new_err(PUBLIC_KEY_DECODING_ERR_MSG))?; - if let Some(auth_enabled_for_reads) = auth_enabled_for_reads { + if let Some(require_auth_for_reads) = require_auth_for_reads { app_config_builder = - app_config_builder.with_auth_enabled_for_reads(auth_enabled_for_reads); + app_config_builder.with_require_auth_for_reads(require_auth_for_reads); } #[cfg(feature = "search")] if let Some(create_index) = create_index { From cca37e1747aa0ad80d616ca2facbb1e38b86e798 Mon Sep 17 00:00:00 2001 From: shivamka1 <4599890+shivamka1@users.noreply.github.com> Date: Thu, 26 Mar 2026 13:43:30 +0000 Subject: [PATCH 30/64] ref --- raphtory-graphql/src/model/graph/namespace.rs | 94 ++++++++----------- 1 file changed, 39 insertions(+), 55 deletions(-) diff --git a/raphtory-graphql/src/model/graph/namespace.rs b/raphtory-graphql/src/model/graph/namespace.rs index d3933a05f9..cf3e85b750 100644 --- a/raphtory-graphql/src/model/graph/namespace.rs +++ b/raphtory-graphql/src/model/graph/namespace.rs @@ -1,6 +1,6 @@ use crate::{ auth::Access, - auth_policy::NamespacePermission, + auth_policy::{AuthorizationPolicy, NamespacePermission}, data::{get_relative_path, Data}, model::graph::{ collection::GqlCollection, meta_graph::MetaGraph, namespaced_item::NamespacedItem, @@ -11,7 +11,7 @@ use crate::{ use async_graphql::Context; use dynamic_graphql::{ResolvedObject, ResolvedObjectFields}; use itertools::Itertools; -use std::path::PathBuf; +use std::{path::PathBuf, sync::Arc}; use walkdir::WalkDir; #[derive(ResolvedObject, Clone, Ord, Eq, PartialEq, PartialOrd)] @@ -138,6 +138,28 @@ impl Namespace { } } +fn is_graph_visible( + policy: &Option>, + is_admin: bool, + role: Option<&str>, + g: &MetaGraph, +) -> bool { + policy.as_ref().map_or(true, |p| { + p.graph_permissions(is_admin, role, &g.local_path()).is_ok() + }) +} + +fn is_namespace_visible( + policy: &Option>, + is_admin: bool, + role: Option<&str>, + n: &Namespace, +) -> bool { + policy.as_ref().map_or(true, |p| { + p.namespace_permissions(is_admin, role, &n.relative_path) >= NamespacePermission::Discover + }) +} + #[ResolvedObjectFields] impl Namespace { async fn graphs(&self, ctx: &Context<'_>) -> GqlCollection { @@ -151,18 +173,12 @@ impl Namespace { self_clone .get_children() .filter_map(|g| match g { - NamespacedItem::MetaGraph(g) => { - if let Some(ref policy) = policy { - let path = g.local_path(); - match policy.graph_permissions(is_admin, role.as_deref(), &path) { - Ok(_) => Some(g), - Err(_) => None, - } - } else { - Some(g) - } + NamespacedItem::MetaGraph(g) + if is_graph_visible(&policy, is_admin, role.as_deref(), &g) => + { + Some(g) } - NamespacedItem::Namespace(_) => None, + _ => None, }) .sorted() .collect(), @@ -201,23 +217,12 @@ impl Namespace { self_clone .get_children() .filter_map(|item| match item { - NamespacedItem::MetaGraph(_) => None, - NamespacedItem::Namespace(n) => { - if let Some(ref policy) = policy { - let perm = policy.namespace_permissions( - is_admin, - role.as_deref(), - &n.relative_path, - ); - if perm >= NamespacePermission::Discover { - Some(n) - } else { - None - } - } else { - Some(n) - } + NamespacedItem::Namespace(n) + if is_namespace_visible(&policy, is_admin, role.as_deref(), &n) => + { + Some(n) } + _ => None, }) .sorted() .collect(), @@ -238,33 +243,12 @@ impl Namespace { GqlCollection::new( self_clone .get_children() - .filter_map(|item| match item { - NamespacedItem::MetaGraph(ref g) => { - if let Some(ref policy) = policy { - let path = g.local_path(); - match policy.graph_permissions(is_admin, role.as_deref(), &path) { - Ok(_) => Some(item), - Err(_) => None, - } - } else { - Some(item) - } + .filter(|item| match item { + NamespacedItem::MetaGraph(g) => { + is_graph_visible(&policy, is_admin, role.as_deref(), g) } - NamespacedItem::Namespace(ref n) => { - if let Some(ref policy) = policy { - let perm = policy.namespace_permissions( - is_admin, - role.as_deref(), - &n.relative_path, - ); - if perm >= NamespacePermission::Discover { - Some(item) - } else { - None - } - } else { - Some(item) - } + NamespacedItem::Namespace(n) => { + is_namespace_visible(&policy, is_admin, role.as_deref(), n) } }) .sorted() From 115510a080da571157d1e82bf7f897011a8a4005 Mon Sep 17 00:00:00 2001 From: shivamka1 <4599890+shivamka1@users.noreply.github.com> Date: Thu, 26 Mar 2026 14:12:18 +0000 Subject: [PATCH 31/64] ref --- raphtory-graphql/src/auth_policy.rs | 19 - raphtory-graphql/src/model/graph/timeindex.rs | 6 +- raphtory-graphql/src/model/mod.rs | 355 ++++++++---------- 3 files changed, 161 insertions(+), 219 deletions(-) diff --git a/raphtory-graphql/src/auth_policy.rs b/raphtory-graphql/src/auth_policy.rs index 4b46e390ff..5f5a3a3d16 100644 --- a/raphtory-graphql/src/auth_policy.rs +++ b/raphtory-graphql/src/auth_policy.rs @@ -52,22 +52,3 @@ pub trait AuthorizationPolicy: Send + Sync + 'static { path: &str, ) -> NamespacePermission; } - -/// A no-op policy that grants full access to everyone. -/// Used when no auth policy has been configured on the server. -pub struct NoopPolicy; - -impl AuthorizationPolicy for NoopPolicy { - fn graph_permissions( - &self, - _: bool, - _: Option<&str>, - _: &str, - ) -> Result { - Ok(GraphPermission::Write) - } - - fn namespace_permissions(&self, _: bool, _: Option<&str>, _: &str) -> NamespacePermission { - NamespacePermission::Write - } -} diff --git a/raphtory-graphql/src/model/graph/timeindex.rs b/raphtory-graphql/src/model/graph/timeindex.rs index bdb3630718..7cc979dafc 100644 --- a/raphtory-graphql/src/model/graph/timeindex.rs +++ b/raphtory-graphql/src/model/graph/timeindex.rs @@ -147,11 +147,7 @@ impl<'de> Deserialize<'de> for GqlTimeInput { } pub fn dt_format_str_is_valid(fmt_str: &str) -> bool { - if StrftimeItems::new(fmt_str).any(|it| matches!(it, Item::Error)) { - false - } else { - true - } + !StrftimeItems::new(fmt_str).any(|it| matches!(it, Item::Error)) } /// Raphtory’s EventTime. diff --git a/raphtory-graphql/src/model/mod.rs b/raphtory-graphql/src/model/mod.rs index 3fad41f510..f32bda7326 100644 --- a/raphtory-graphql/src/model/mod.rs +++ b/raphtory-graphql/src/model/mod.rs @@ -1,6 +1,6 @@ use crate::{ auth::{Access, AuthError, ContextValidation}, - auth_policy::{GraphPermission, NamespacePermission}, + auth_policy::{AuthorizationPolicy, GraphPermission, NamespacePermission}, data::Data, model::{ graph::{ @@ -45,6 +45,7 @@ use raphtory::{ use std::{ error::Error, fmt::{Display, Formatter}, + sync::Arc, }; use tracing::warn; @@ -99,7 +100,7 @@ pub enum GqlGraphType { /// Checks that the caller has at least READ permission for the graph at `path`. /// Returns `Err` if denied or if only INTROSPECT was granted. fn require_at_least_read( - policy: &Option>, + policy: &Option>, is_admin: bool, role: Option<&str>, path: &str, @@ -181,6 +182,100 @@ fn parent_namespace(path: &str) -> &str { path.rfind('/').map(|i| &path[..i]).unwrap_or("") } +fn auth_context<'a>(ctx: &'a Context<'_>) -> (bool, Option<&'a str>) { + let is_admin = ctx.data::().is_ok_and(|a| a == &Access::Rw); + let role = ctx.data::>().ok().and_then(|r| r.as_deref()); + (is_admin, role) +} + +fn write_denied(role: Option<&str>, msg: impl std::fmt::Display) -> async_graphql::Error { + match role { + Some(_) => async_graphql::Error::new(msg.to_string()), + None => AuthError::RequireWrite.into(), + } +} + +fn require_graph_write( + ctx: &Context<'_>, + policy: &Option>, + is_admin: bool, + role: Option<&str>, + path: &str, +) -> async_graphql::Result<()> { + if is_admin { + return Ok(()); + } + match policy { + None => ctx.require_jwt_write_access().map_err(Into::into), + Some(p) => { + let perms = p + .graph_permissions(false, role, path) + .map_err(|msg| async_graphql::Error::new(msg))?; + if !matches!(perms, GraphPermission::Write) { + return Err(write_denied( + role, + format!("Access denied: WRITE permission required for graph '{path}'"), + )); + } + Ok(()) + } + } +} + +fn require_namespace_write( + ctx: &Context<'_>, + policy: &Option>, + is_admin: bool, + role: Option<&str>, + ns_path: &str, + new_path: &str, + operation: &str, +) -> async_graphql::Result<()> { + if is_admin { + return Ok(()); + } + match policy { + None => ctx.require_jwt_write_access().map_err(Into::into), + Some(p) => { + if p.namespace_permissions(false, role, ns_path) < NamespacePermission::Write { + return Err(write_denied( + role, + format!("Access denied: WRITE required on namespace '{ns_path}' to {operation} graph '{new_path}'"), + )); + } + Ok(()) + } + } +} + +fn require_graph_read_src( + ctx: &Context<'_>, + policy: &Option>, + is_admin: bool, + role: Option<&str>, + path: &str, + operation: &str, +) -> async_graphql::Result<()> { + if is_admin { + return Ok(()); + } + match policy { + None => ctx.require_jwt_write_access().map_err(Into::into), + Some(p) => { + let perms = p + .graph_permissions(false, role, path) + .map_err(|msg| async_graphql::Error::new(msg))?; + if matches!(perms, GraphPermission::Introspect) { + return Err(write_denied( + role, + format!("Access denied: READ required on source graph '{path}' to {operation}"), + )); + } + Ok(()) + } + } +} + #[derive(ResolvedObject)] #[graphql(root)] pub(crate) struct QueryRoot; @@ -195,8 +290,7 @@ impl QueryRoot { /// Returns a graph async fn graph<'a>(ctx: &Context<'a>, path: &str) -> Result { let data = ctx.data_unchecked::(); - let role = ctx.data::>().ok().and_then(|r| r.as_deref()); - let is_admin = ctx.data::().is_ok_and(|a| a == &Access::Rw); + let (is_admin, role) = auth_context(ctx); let perms = if let Some(policy) = &data.auth_policy { let perms = policy @@ -244,8 +338,7 @@ impl QueryRoot { /// Requires at least INTROSPECT permission. async fn graph_metadata<'a>(ctx: &Context<'a>, path: String) -> Result { let data = ctx.data_unchecked::(); - let role = ctx.data::>().ok().and_then(|r| r.as_deref()); - let is_admin = ctx.data::().is_ok_and(|a| a == &Access::Rw); + let (is_admin, role) = auth_context(ctx); if let Some(policy) = &data.auth_policy { policy @@ -270,29 +363,8 @@ impl QueryRoot { /// Returns:: GqlMutableGraph async fn update_graph<'a>(ctx: &Context<'a>, path: String) -> Result { let data = ctx.data_unchecked::(); - let is_admin = ctx.data::().is_ok_and(|a| a == &Access::Rw); - let role = ctx.data::>().ok().and_then(|r| r.as_deref()); - if !is_admin { - match &data.auth_policy { - None => ctx.require_jwt_write_access()?, - Some(policy) => { - let perms = policy - .graph_permissions(false, role, &path) - .map_err(|msg| async_graphql::Error::new(msg))?; - if !matches!(perms, GraphPermission::Write) { - // role=Some: policy is active and grants less than Write. - // role=None: store is empty (no policy yet), fall back to JWT check. - return if let Some(role) = role { - Err(async_graphql::Error::new(format!( - "Access denied: role '{role}' does not have write permission for graph '{path}'" - ))) - } else { - Err(AuthError::RequireWrite.into()) - }; - } - } - } - } + let (is_admin, role) = auth_context(ctx); + require_graph_write(ctx, &data.auth_policy, is_admin, role, &path)?; let graph = data.get_graph(path.as_ref()).await?.into(); @@ -307,8 +379,7 @@ impl QueryRoot { path: &str, ) -> Result> { let data = ctx.data_unchecked::(); - let role = ctx.data::>().ok().and_then(|r| r.as_deref()); - let is_admin = ctx.data::().is_ok_and(|a| a == &Access::Rw); + let (is_admin, role) = auth_context(ctx); require_at_least_read(&data.auth_policy, is_admin, role, path)?; Ok(data .get_graph(path) @@ -363,8 +434,7 @@ impl QueryRoot { /// Returns:: Base64 url safe encoded string async fn receive_graph<'a>(ctx: &Context<'a>, path: String) -> Result { let data = ctx.data_unchecked::(); - let role = ctx.data::>().ok().and_then(|r| r.as_deref()); - let is_admin = ctx.data::().is_ok_and(|a| a == &Access::Rw); + let (is_admin, role) = auth_context(ctx); require_at_least_read(&data.auth_policy, is_admin, role, &path)?; let g = data.get_graph(&path).await?.graph.clone(); let res = url_encode_graph(g)?; @@ -403,27 +473,8 @@ impl Mut { // If namespace is not provided, it will be set to the current working directory. async fn delete_graph<'a>(ctx: &Context<'a>, path: String) -> Result { let data = ctx.data_unchecked::(); - let is_admin = ctx.data::().is_ok_and(|a| a == &Access::Rw); - let role = ctx.data::>().ok().and_then(|r| r.as_deref()); - if !is_admin { - match &data.auth_policy { - None => ctx.require_jwt_write_access()?, - Some(policy) => { - let perm = policy - .graph_permissions(false, role, &path) - .map_err(|msg| async_graphql::Error::new(msg))?; - if !matches!(perm, GraphPermission::Write) { - return if let Some(role) = role { - Err(async_graphql::Error::new(format!( - "Access denied: WRITE required on graph '{path}' to delete it" - ))) - } else { - Err(AuthError::RequireWrite.into()) - }; - } - } - } - } + let (is_admin, role) = auth_context(ctx); + require_graph_write(ctx, &data.auth_policy, is_admin, role, &path)?; data.delete_graph(&path).await?; Ok(true) } @@ -435,29 +486,8 @@ impl Mut { graph_type: GqlGraphType, ) -> Result { let data = ctx.data_unchecked::(); - let is_admin = ctx.data::().is_ok_and(|a| a == &Access::Rw); - let role = ctx.data::>().ok().and_then(|r| r.as_deref()); - if !is_admin { - match &data.auth_policy { - None => ctx.require_jwt_write_access()?, - Some(policy) => { - let perms = policy - .graph_permissions(false, role, &path) - .map_err(|msg| async_graphql::Error::new(msg))?; - if !matches!(perms, GraphPermission::Write) { - // role=Some: policy is active and grants less than Write. - // role=None: store is empty (no policy yet), fall back to JWT check. - return if let Some(role) = role { - Err(async_graphql::Error::new(format!( - "Access denied: role '{role}' does not have write permission for graph '{path}'" - ))) - } else { - Err(AuthError::RequireWrite.into()) - }; - } - } - } - } + let (is_admin, role) = auth_context(ctx); + require_graph_write(ctx, &data.auth_policy, is_admin, role, &path)?; let overwrite = false; let folder = data.validate_path_for_insert(&path, overwrite)?; let graph_path = folder.graph_folder(); @@ -486,22 +516,20 @@ impl Mut { overwrite: Option, ) -> Result { let data = ctx.data_unchecked::(); - let is_admin = ctx.data::().is_ok_and(|a| a == &Access::Rw); - let role = ctx.data::>().ok().and_then(|r| r.as_deref()); + let (is_admin, role) = auth_context(ctx); if !is_admin { - if let Some(policy) = &data.auth_policy { + if let Some(p) = &data.auth_policy { // src: require WRITE (moving = deleting source) - let src_perm = policy + let src_perm = p .graph_permissions(false, role, path) .map_err(|msg| async_graphql::Error::new(msg))?; if !matches!(src_perm, GraphPermission::Write) { - return if let Some(role) = role { - Err(async_graphql::Error::new(format!( + return Err(write_denied( + role, + format!( "Access denied: WRITE required on source graph '{path}' to move it" - ))) - } else { - Err(AuthError::RequireWrite.into()) - }; + ), + )); } } } @@ -519,40 +547,18 @@ impl Mut { overwrite: Option, ) -> Result { let data = ctx.data_unchecked::(); - let is_admin = ctx.data::().is_ok_and(|a| a == &Access::Rw); - let role = ctx.data::>().ok().and_then(|r| r.as_deref()); - if !is_admin { - match &data.auth_policy { - None => ctx.require_jwt_write_access()?, - Some(policy) => { - // src: require at least READ - let src_perm = policy - .graph_permissions(false, role, path) - .map_err(|msg| async_graphql::Error::new(msg))?; - if matches!(src_perm, GraphPermission::Introspect) { - return if let Some(role) = role { - Err(async_graphql::Error::new(format!( - "Access denied: READ required on source graph '{path}' to copy it" - ))) - } else { - Err(AuthError::RequireWrite.into()) - }; - } - // dst namespace: require WRITE - let dst_ns = parent_namespace(new_path); - let ns_perm = policy.namespace_permissions(false, role, dst_ns); - if ns_perm < NamespacePermission::Write { - return if let Some(role) = role { - Err(async_graphql::Error::new(format!( - "Access denied: WRITE required on namespace '{dst_ns}' to create graph '{new_path}'" - ))) - } else { - Err(AuthError::RequireWrite.into()) - }; - } - } - } - } + let (is_admin, role) = auth_context(ctx); + require_graph_read_src(ctx, &data.auth_policy, is_admin, role, path, "copy it")?; + let dst_ns = parent_namespace(new_path); + require_namespace_write( + ctx, + &data.auth_policy, + is_admin, + role, + dst_ns, + new_path, + "create", + )?; // doing this in a more efficient way is not trivial, this at least is correct // there are questions like, maybe the new vectorised graph have different rules // for the templates or if it needs to be vectorised at all @@ -575,26 +581,17 @@ impl Mut { overwrite: bool, ) -> Result { let data = ctx.data_unchecked::(); - let is_admin = ctx.data::().is_ok_and(|a| a == &Access::Rw); - let role = ctx.data::>().ok().and_then(|r| r.as_deref()); - if !is_admin { - match &data.auth_policy { - None => ctx.require_jwt_write_access()?, - Some(policy) => { - let dst_ns = parent_namespace(&path); - let ns_perm = policy.namespace_permissions(false, role, dst_ns); - if ns_perm < NamespacePermission::Write { - return if let Some(role) = role { - Err(async_graphql::Error::new(format!( - "Access denied: WRITE required on namespace '{dst_ns}' to upload graph '{path}'" - ))) - } else { - Err(AuthError::RequireWrite.into()) - }; - } - } - } - } + let (is_admin, role) = auth_context(ctx); + let dst_ns = parent_namespace(&path); + require_namespace_write( + ctx, + &data.auth_policy, + is_admin, + role, + dst_ns, + &path, + "upload", + )?; let in_file = graph.value(ctx)?.content; let folder = data.validate_path_for_insert(&path, overwrite)?; data.insert_graph_as_bytes(folder, in_file).await?; @@ -613,26 +610,9 @@ impl Mut { overwrite: bool, ) -> Result { let data = ctx.data_unchecked::(); - let is_admin = ctx.data::().is_ok_and(|a| a == &Access::Rw); - let role = ctx.data::>().ok().and_then(|r| r.as_deref()); - if !is_admin { - match &data.auth_policy { - None => ctx.require_jwt_write_access()?, - Some(policy) => { - let dst_ns = parent_namespace(path); - let ns_perm = policy.namespace_permissions(false, role, dst_ns); - if ns_perm < NamespacePermission::Write { - return if let Some(role) = role { - Err(async_graphql::Error::new(format!( - "Access denied: WRITE required on namespace '{dst_ns}' to send graph '{path}'" - ))) - } else { - Err(AuthError::RequireWrite.into()) - }; - } - } - } - } + let (is_admin, role) = auth_context(ctx); + let dst_ns = parent_namespace(path); + require_namespace_write(ctx, &data.auth_policy, is_admin, role, dst_ns, path, "send")?; let folder = if overwrite { ValidWriteableGraphFolder::try_existing_or_new(data.work_dir.clone(), path)? } else { @@ -660,40 +640,25 @@ impl Mut { overwrite: bool, ) -> Result { let data = ctx.data_unchecked::(); - let is_admin = ctx.data::().is_ok_and(|a| a == &Access::Rw); - let role = ctx.data::>().ok().and_then(|r| r.as_deref()); - if !is_admin { - match &data.auth_policy { - None => ctx.require_jwt_write_access()?, - Some(policy) => { - // parent: require at least READ - let parent_perm = policy - .graph_permissions(false, role, parent_path) - .map_err(|msg| async_graphql::Error::new(msg))?; - if matches!(parent_perm, GraphPermission::Introspect) { - return if let Some(role) = role { - Err(async_graphql::Error::new(format!( - "Access denied: READ required on source graph '{parent_path}' to create a subgraph" - ))) - } else { - Err(AuthError::RequireWrite.into()) - }; - } - // dst namespace: require WRITE - let dst_ns = parent_namespace(&new_path); - let ns_perm = policy.namespace_permissions(false, role, dst_ns); - if ns_perm < NamespacePermission::Write { - return if let Some(role) = role { - Err(async_graphql::Error::new(format!( - "Access denied: WRITE required on namespace '{dst_ns}' to create graph '{new_path}'" - ))) - } else { - Err(AuthError::RequireWrite.into()) - }; - } - } - } - } + let (is_admin, role) = auth_context(ctx); + require_graph_read_src( + ctx, + &data.auth_policy, + is_admin, + role, + parent_path, + "create a subgraph", + )?; + let dst_ns = parent_namespace(&new_path); + require_namespace_write( + ctx, + &data.auth_policy, + is_admin, + role, + dst_ns, + &new_path, + "create", + )?; let folder = data.validate_path_for_insert(&new_path, overwrite)?; let parent_graph = data.get_graph(parent_path).await?.graph; let folder_clone = folder.clone(); From 85767355d7f30030541f4bbd8895a9f448c36831 Mon Sep 17 00:00:00 2001 From: shivamka1 <4599890+shivamka1@users.noreply.github.com> Date: Sat, 28 Mar 2026 12:30:12 +0000 Subject: [PATCH 32/64] fix permissions --- python/tests/test_permissions.py | 101 +++++++++++++++++++++++++++++- raphtory-graphql/src/model/mod.rs | 28 +++------ 2 files changed, 109 insertions(+), 20 deletions(-) diff --git a/python/tests/test_permissions.py b/python/tests/test_permissions.py index c928ef4f4c..ee9beaa815 100644 --- a/python/tests/test_permissions.py +++ b/python/tests/test_permissions.py @@ -806,19 +806,33 @@ def test_no_namespace_grant_hidden_from_children(): DELETE_TEAM_JIRA = """mutation { deleteGraph(path: "team/jira") }""" -def test_analyst_can_delete_with_write_grant(): - """'a':'ro' user with WRITE grant on a graph can delete it.""" +def test_analyst_can_delete_with_graph_and_namespace_write(): + """deleteGraph requires WRITE on both the graph and its parent namespace.""" work_dir = tempfile.mkdtemp() with make_server(work_dir).start(): gql(CREATE_JIRA) create_role("analyst") grant_graph("analyst", "jira", "WRITE") + grant_namespace("analyst", "*", "WRITE") response = gql(DELETE_JIRA, headers=ANALYST_HEADERS) assert "errors" not in response, response assert response["data"]["deleteGraph"] is True +def test_analyst_cannot_delete_with_graph_write_only(): + """Graph WRITE alone is insufficient for deleteGraph — namespace WRITE is also required.""" + work_dir = tempfile.mkdtemp() + with make_server(work_dir).start(): + gql(CREATE_JIRA) + create_role("analyst") + grant_graph("analyst", "jira", "WRITE") + + response = gql(DELETE_JIRA, headers=ANALYST_HEADERS) + assert "errors" in response + assert "Access denied" in response["errors"][0]["message"] + + def test_analyst_cannot_delete_with_read_grant(): """'a':'ro' user with READ-only grant is denied by deleteGraph.""" work_dir = tempfile.mkdtemp() @@ -878,3 +892,86 @@ def test_analyst_send_graph_passes_auth_with_namespace_write(): # Auth passed — error is about graph decoding, not access assert "errors" in response assert "Access denied" not in response["errors"][0]["message"] + + +# --- moveGraph policy --- + +MOVE_TEAM_JIRA = """mutation { moveGraph(path: "team/jira", newPath: "team/jira-moved", overwrite: false) }""" + + +def test_analyst_can_move_with_graph_write_and_namespace_write(): + """moveGraph requires WRITE on the source graph and its parent namespace, plus WRITE on the destination namespace.""" + work_dir = tempfile.mkdtemp() + with make_server(work_dir).start(): + gql(CREATE_TEAM_JIRA) + create_role("analyst") + grant_graph("analyst", "team/jira", "WRITE") + grant_namespace("analyst", "team", "WRITE") + + response = gql(MOVE_TEAM_JIRA, headers=ANALYST_HEADERS) + assert "errors" not in response, response + assert response["data"]["moveGraph"] is True + + +def test_analyst_cannot_move_with_graph_write_only(): + """Graph WRITE alone is insufficient for moveGraph — namespace WRITE on source is also required.""" + work_dir = tempfile.mkdtemp() + with make_server(work_dir).start(): + gql(CREATE_TEAM_JIRA) + create_role("analyst") + grant_graph("analyst", "team/jira", "WRITE") + # no namespace grant → namespace WRITE check fails + + response = gql(MOVE_TEAM_JIRA, headers=ANALYST_HEADERS) + assert "errors" in response + assert "Access denied" in response["errors"][0]["message"] + + +def test_analyst_cannot_move_with_read_grant(): + """READ on source graph is insufficient for moveGraph — WRITE is required.""" + work_dir = tempfile.mkdtemp() + with make_server(work_dir).start(): + gql(CREATE_TEAM_JIRA) + create_role("analyst") + grant_graph("analyst", "team/jira", "READ") + grant_namespace("analyst", "team", "WRITE") + + response = gql(MOVE_TEAM_JIRA, headers=ANALYST_HEADERS) + assert "errors" in response + assert "Access denied" in response["errors"][0]["message"] + + +# --- createIndex policy --- + + +def test_analyst_can_create_index_with_graph_write(): + """A user with WRITE on a graph can call createIndex (not admin-only).""" + work_dir = tempfile.mkdtemp() + with make_server(work_dir).start(): + gql(CREATE_JIRA) + create_role("analyst") + grant_graph("analyst", "jira", "WRITE") + + response = gql( + 'mutation { createIndex(path: "jira", inRam: true) }', + headers=ANALYST_HEADERS, + ) + # Auth passed — success or a feature-not-compiled error, not an access denial + if "errors" in response: + assert "Access denied" not in response["errors"][0]["message"] + + +def test_analyst_cannot_create_index_with_read_grant(): + """READ on a graph is insufficient for createIndex — WRITE is required.""" + work_dir = tempfile.mkdtemp() + with make_server(work_dir).start(): + gql(CREATE_JIRA) + create_role("analyst") + grant_graph("analyst", "jira", "READ") + + response = gql( + 'mutation { createIndex(path: "jira", inRam: true) }', + headers=ANALYST_HEADERS, + ) + assert "errors" in response + assert "Access denied" in response["errors"][0]["message"] diff --git a/raphtory-graphql/src/model/mod.rs b/raphtory-graphql/src/model/mod.rs index f32bda7326..6e931a3664 100644 --- a/raphtory-graphql/src/model/mod.rs +++ b/raphtory-graphql/src/model/mod.rs @@ -475,6 +475,8 @@ impl Mut { let data = ctx.data_unchecked::(); let (is_admin, role) = auth_context(ctx); require_graph_write(ctx, &data.auth_policy, is_admin, role, &path)?; + let src_ns = parent_namespace(&path); + require_namespace_write(ctx, &data.auth_policy, is_admin, role, src_ns, &path, "delete")?; data.delete_graph(&path).await?; Ok(true) } @@ -517,22 +519,11 @@ impl Mut { ) -> Result { let data = ctx.data_unchecked::(); let (is_admin, role) = auth_context(ctx); - if !is_admin { - if let Some(p) = &data.auth_policy { - // src: require WRITE (moving = deleting source) - let src_perm = p - .graph_permissions(false, role, path) - .map_err(|msg| async_graphql::Error::new(msg))?; - if !matches!(src_perm, GraphPermission::Write) { - return Err(write_denied( - role, - format!( - "Access denied: WRITE required on source graph '{path}' to move it" - ), - )); - } - } - } + // src: require WRITE on graph (moving = deleting source) + require_graph_write(ctx, &data.auth_policy, is_admin, role, path)?; + // src: require WRITE on parent namespace (removing graph from namespace) + let src_ns = parent_namespace(path); + require_namespace_write(ctx, &data.auth_policy, is_admin, role, src_ns, path, "move")?; // copy_graph handles dst namespace WRITE check (and src READ, which WRITE implies) Self::copy_graph(ctx, path, new_path, overwrite).await?; data.delete_graph(path).await?; @@ -683,10 +674,11 @@ impl Mut { index_spec: Option, in_ram: bool, ) -> Result { - ctx.require_jwt_write_access()?; + let data = ctx.data_unchecked::(); + let (is_admin, role) = auth_context(ctx); + require_graph_write(ctx, &data.auth_policy, is_admin, role, path)?; #[cfg(feature = "search")] { - let data = ctx.data_unchecked::(); let graph = data.get_graph(path).await?.graph; match index_spec { Some(index_spec) => { From 24d52d8fdb16ba6555a8311ff70898a789006d16 Mon Sep 17 00:00:00 2001 From: shivamka1 <4599890+shivamka1@users.noreply.github.com> Date: Sat, 28 Mar 2026 19:21:48 +0000 Subject: [PATCH 33/64] ref --- raphtory-graphql/src/auth.rs | 2 +- raphtory-graphql/src/auth_policy.rs | 14 +-- raphtory-graphql/src/lib.rs | 3 +- raphtory-graphql/src/model/graph/namespace.rs | 112 +++++++---------- raphtory-graphql/src/model/mod.rs | 118 +++++------------- 5 files changed, 84 insertions(+), 165 deletions(-) diff --git a/raphtory-graphql/src/auth.rs b/raphtory-graphql/src/auth.rs index 12f3031648..0203fc3c51 100644 --- a/raphtory-graphql/src/auth.rs +++ b/raphtory-graphql/src/auth.rs @@ -21,7 +21,7 @@ use tracing::{debug, warn}; #[derive(Clone, Debug, Deserialize, PartialEq)] #[serde(rename_all = "lowercase")] -pub(crate) enum Access { +pub enum Access { Ro, Rw, } diff --git a/raphtory-graphql/src/auth_policy.rs b/raphtory-graphql/src/auth_policy.rs index 5f5a3a3d16..49b092a566 100644 --- a/raphtory-graphql/src/auth_policy.rs +++ b/raphtory-graphql/src/auth_policy.rs @@ -31,24 +31,24 @@ pub enum NamespacePermission { pub trait AuthorizationPolicy: Send + Sync + 'static { /// Resolves the effective permission level for a principal on a graph. /// Returns `Err(denial message)` only when access is entirely denied (not even introspect). - /// Admin bypass (`is_admin = true`) always yields `Write`. - /// Empty store (no roles configured) yields `Read` for non-admins — fail open for reads, + /// Admin principals (`"access": "rw"` JWT) always yield `Write`. + /// Empty store (no roles configured) yields `Read` — fail open for reads, /// but write still requires an explicit `Write` grant. + /// The implementation is responsible for extracting principal identity from `ctx`. fn graph_permissions( &self, - is_admin: bool, - role: Option<&str>, + ctx: &async_graphql::Context<'_>, path: &str, ) -> Result; /// Resolves the effective namespace permission for a principal. - /// Admin bypass always yields `Write`. + /// Admin principals always yield `Write`. /// Empty store yields `Read` (fail open, consistent with graph_permissions). /// Missing role yields `Denied`. + /// The implementation is responsible for extracting principal identity from `ctx`. fn namespace_permissions( &self, - is_admin: bool, - role: Option<&str>, + ctx: &async_graphql::Context<'_>, path: &str, ) -> NamespacePermission; } diff --git a/raphtory-graphql/src/lib.rs b/raphtory-graphql/src/lib.rs index 82779cb902..db228a98fc 100644 --- a/raphtory-graphql/src/lib.rs +++ b/raphtory-graphql/src/lib.rs @@ -1,5 +1,6 @@ pub use crate::{ - auth::require_jwt_write_access_dynamic, model::graph::filtering::GraphAccessFilter, + auth::{Access, require_jwt_write_access_dynamic}, + model::graph::filtering::GraphAccessFilter, server::GraphServer, }; use crate::{data::InsertionError, paths::PathValidationError}; diff --git a/raphtory-graphql/src/model/graph/namespace.rs b/raphtory-graphql/src/model/graph/namespace.rs index cf3e85b750..71b799795b 100644 --- a/raphtory-graphql/src/model/graph/namespace.rs +++ b/raphtory-graphql/src/model/graph/namespace.rs @@ -1,5 +1,4 @@ use crate::{ - auth::Access, auth_policy::{AuthorizationPolicy, NamespacePermission}, data::{get_relative_path, Data}, model::graph::{ @@ -139,24 +138,22 @@ impl Namespace { } fn is_graph_visible( + ctx: &Context<'_>, policy: &Option>, - is_admin: bool, - role: Option<&str>, g: &MetaGraph, ) -> bool { policy.as_ref().map_or(true, |p| { - p.graph_permissions(is_admin, role, &g.local_path()).is_ok() + p.graph_permissions(ctx, &g.local_path()).is_ok() }) } fn is_namespace_visible( + ctx: &Context<'_>, policy: &Option>, - is_admin: bool, - role: Option<&str>, n: &Namespace, ) -> bool { policy.as_ref().map_or(true, |p| { - p.namespace_permissions(is_admin, role, &n.relative_path) >= NamespacePermission::Discover + p.namespace_permissions(ctx, &n.relative_path) >= NamespacePermission::Discover }) } @@ -164,27 +161,22 @@ fn is_namespace_visible( impl Namespace { async fn graphs(&self, ctx: &Context<'_>) -> GqlCollection { let data = ctx.data_unchecked::(); - let is_admin = ctx.data::().is_ok_and(|a| a == &Access::Rw); - let role: Option = ctx.data::>().ok().and_then(|r| r.clone()); - let policy = data.auth_policy.clone(); let self_clone = self.clone(); - blocking_compute(move || { - GqlCollection::new( - self_clone - .get_children() - .filter_map(|g| match g { - NamespacedItem::MetaGraph(g) - if is_graph_visible(&policy, is_admin, role.as_deref(), &g) => - { - Some(g) - } - _ => None, - }) - .sorted() - .collect(), - ) - }) - .await + let items = blocking_compute(move || self_clone.get_children().collect::>()).await; + GqlCollection::new( + items + .into_iter() + .filter_map(|item| match item { + NamespacedItem::MetaGraph(g) + if is_graph_visible(ctx, &data.auth_policy, &g) => + { + Some(g) + } + _ => None, + }) + .sorted() + .collect(), + ) } async fn path(&self) -> String { self.relative_path.clone() @@ -208,53 +200,39 @@ impl Namespace { async fn children(&self, ctx: &Context<'_>) -> GqlCollection { let data = ctx.data_unchecked::(); - let is_admin = ctx.data::().is_ok_and(|a| a == &Access::Rw); - let role: Option = ctx.data::>().ok().and_then(|r| r.clone()); - let policy = data.auth_policy.clone(); let self_clone = self.clone(); - blocking_compute(move || { - GqlCollection::new( - self_clone - .get_children() - .filter_map(|item| match item { - NamespacedItem::Namespace(n) - if is_namespace_visible(&policy, is_admin, role.as_deref(), &n) => - { - Some(n) - } - _ => None, - }) - .sorted() - .collect(), - ) - }) - .await + let items = blocking_compute(move || self_clone.get_children().collect::>()).await; + GqlCollection::new( + items + .into_iter() + .filter_map(|item| match item { + NamespacedItem::Namespace(n) + if is_namespace_visible(ctx, &data.auth_policy, &n) => + { + Some(n) + } + _ => None, + }) + .sorted() + .collect(), + ) } // Fetch the collection of namespaces/graphs in this namespace. // Namespaces will be listed before graphs. async fn items(&self, ctx: &Context<'_>) -> GqlCollection { let data = ctx.data_unchecked::(); - let is_admin = ctx.data::().is_ok_and(|a| a == &Access::Rw); - let role: Option = ctx.data::>().ok().and_then(|r| r.clone()); - let policy = data.auth_policy.clone(); let self_clone = self.clone(); - blocking_compute(move || { - GqlCollection::new( - self_clone - .get_children() - .filter(|item| match item { - NamespacedItem::MetaGraph(g) => { - is_graph_visible(&policy, is_admin, role.as_deref(), g) - } - NamespacedItem::Namespace(n) => { - is_namespace_visible(&policy, is_admin, role.as_deref(), n) - } - }) - .sorted() - .collect(), - ) - }) - .await + let all_items = blocking_compute(move || self_clone.get_children().collect::>()).await; + GqlCollection::new( + all_items + .into_iter() + .filter(|item| match item { + NamespacedItem::MetaGraph(g) => is_graph_visible(ctx, &data.auth_policy, g), + NamespacedItem::Namespace(n) => is_namespace_visible(ctx, &data.auth_policy, n), + }) + .sorted() + .collect(), + ) } } diff --git a/raphtory-graphql/src/model/mod.rs b/raphtory-graphql/src/model/mod.rs index 6e931a3664..5c6e9fe886 100644 --- a/raphtory-graphql/src/model/mod.rs +++ b/raphtory-graphql/src/model/mod.rs @@ -1,5 +1,5 @@ use crate::{ - auth::{Access, AuthError, ContextValidation}, + auth::{AuthError, ContextValidation}, auth_policy::{AuthorizationPolicy, GraphPermission, NamespacePermission}, data::Data, model::{ @@ -100,14 +100,14 @@ pub enum GqlGraphType { /// Checks that the caller has at least READ permission for the graph at `path`. /// Returns `Err` if denied or if only INTROSPECT was granted. fn require_at_least_read( + ctx: &Context<'_>, policy: &Option>, - is_admin: bool, - role: Option<&str>, path: &str, ) -> async_graphql::Result<()> { if let Some(policy) = policy { + let role = ctx.data::>().ok().and_then(|r| r.as_deref()); let perms = policy - .graph_permissions(is_admin, role, path) + .graph_permissions(ctx, path) .map_err(|msg| { warn!( role = role.unwrap_or(""), @@ -182,12 +182,6 @@ fn parent_namespace(path: &str) -> &str { path.rfind('/').map(|i| &path[..i]).unwrap_or("") } -fn auth_context<'a>(ctx: &'a Context<'_>) -> (bool, Option<&'a str>) { - let is_admin = ctx.data::().is_ok_and(|a| a == &Access::Rw); - let role = ctx.data::>().ok().and_then(|r| r.as_deref()); - (is_admin, role) -} - fn write_denied(role: Option<&str>, msg: impl std::fmt::Display) -> async_graphql::Error { match role { Some(_) => async_graphql::Error::new(msg.to_string()), @@ -198,18 +192,14 @@ fn write_denied(role: Option<&str>, msg: impl std::fmt::Display) -> async_graphq fn require_graph_write( ctx: &Context<'_>, policy: &Option>, - is_admin: bool, - role: Option<&str>, path: &str, ) -> async_graphql::Result<()> { - if is_admin { - return Ok(()); - } match policy { None => ctx.require_jwt_write_access().map_err(Into::into), Some(p) => { + let role = ctx.data::>().ok().and_then(|r| r.as_deref()); let perms = p - .graph_permissions(false, role, path) + .graph_permissions(ctx, path) .map_err(|msg| async_graphql::Error::new(msg))?; if !matches!(perms, GraphPermission::Write) { return Err(write_denied( @@ -225,19 +215,15 @@ fn require_graph_write( fn require_namespace_write( ctx: &Context<'_>, policy: &Option>, - is_admin: bool, - role: Option<&str>, ns_path: &str, new_path: &str, operation: &str, ) -> async_graphql::Result<()> { - if is_admin { - return Ok(()); - } match policy { None => ctx.require_jwt_write_access().map_err(Into::into), Some(p) => { - if p.namespace_permissions(false, role, ns_path) < NamespacePermission::Write { + let role = ctx.data::>().ok().and_then(|r| r.as_deref()); + if p.namespace_permissions(ctx, ns_path) < NamespacePermission::Write { return Err(write_denied( role, format!("Access denied: WRITE required on namespace '{ns_path}' to {operation} graph '{new_path}'"), @@ -251,19 +237,15 @@ fn require_namespace_write( fn require_graph_read_src( ctx: &Context<'_>, policy: &Option>, - is_admin: bool, - role: Option<&str>, path: &str, operation: &str, ) -> async_graphql::Result<()> { - if is_admin { - return Ok(()); - } match policy { None => ctx.require_jwt_write_access().map_err(Into::into), Some(p) => { + let role = ctx.data::>().ok().and_then(|r| r.as_deref()); let perms = p - .graph_permissions(false, role, path) + .graph_permissions(ctx, path) .map_err(|msg| async_graphql::Error::new(msg))?; if matches!(perms, GraphPermission::Introspect) { return Err(write_denied( @@ -290,11 +272,11 @@ impl QueryRoot { /// Returns a graph async fn graph<'a>(ctx: &Context<'a>, path: &str) -> Result { let data = ctx.data_unchecked::(); - let (is_admin, role) = auth_context(ctx); let perms = if let Some(policy) = &data.auth_policy { + let role = ctx.data::>().ok().and_then(|r| r.as_deref()); let perms = policy - .graph_permissions(is_admin, role, path) + .graph_permissions(ctx, path) .map_err(|msg| { warn!( role = role.unwrap_or(""), @@ -338,11 +320,11 @@ impl QueryRoot { /// Requires at least INTROSPECT permission. async fn graph_metadata<'a>(ctx: &Context<'a>, path: String) -> Result { let data = ctx.data_unchecked::(); - let (is_admin, role) = auth_context(ctx); if let Some(policy) = &data.auth_policy { + let role = ctx.data::>().ok().and_then(|r| r.as_deref()); policy - .graph_permissions(is_admin, role, &path) + .graph_permissions(ctx, &path) .map_err(|msg| { warn!( role = role.unwrap_or(""), @@ -363,8 +345,7 @@ impl QueryRoot { /// Returns:: GqlMutableGraph async fn update_graph<'a>(ctx: &Context<'a>, path: String) -> Result { let data = ctx.data_unchecked::(); - let (is_admin, role) = auth_context(ctx); - require_graph_write(ctx, &data.auth_policy, is_admin, role, &path)?; + require_graph_write(ctx, &data.auth_policy, &path)?; let graph = data.get_graph(path.as_ref()).await?.into(); @@ -379,8 +360,7 @@ impl QueryRoot { path: &str, ) -> Result> { let data = ctx.data_unchecked::(); - let (is_admin, role) = auth_context(ctx); - require_at_least_read(&data.auth_policy, is_admin, role, path)?; + require_at_least_read(ctx, &data.auth_policy, path)?; Ok(data .get_graph(path) .await @@ -434,8 +414,7 @@ impl QueryRoot { /// Returns:: Base64 url safe encoded string async fn receive_graph<'a>(ctx: &Context<'a>, path: String) -> Result { let data = ctx.data_unchecked::(); - let (is_admin, role) = auth_context(ctx); - require_at_least_read(&data.auth_policy, is_admin, role, &path)?; + require_at_least_read(ctx, &data.auth_policy, &path)?; let g = data.get_graph(&path).await?.graph.clone(); let res = url_encode_graph(g)?; Ok(res) @@ -473,10 +452,9 @@ impl Mut { // If namespace is not provided, it will be set to the current working directory. async fn delete_graph<'a>(ctx: &Context<'a>, path: String) -> Result { let data = ctx.data_unchecked::(); - let (is_admin, role) = auth_context(ctx); - require_graph_write(ctx, &data.auth_policy, is_admin, role, &path)?; + require_graph_write(ctx, &data.auth_policy, &path)?; let src_ns = parent_namespace(&path); - require_namespace_write(ctx, &data.auth_policy, is_admin, role, src_ns, &path, "delete")?; + require_namespace_write(ctx, &data.auth_policy, src_ns, &path, "delete")?; data.delete_graph(&path).await?; Ok(true) } @@ -488,8 +466,7 @@ impl Mut { graph_type: GqlGraphType, ) -> Result { let data = ctx.data_unchecked::(); - let (is_admin, role) = auth_context(ctx); - require_graph_write(ctx, &data.auth_policy, is_admin, role, &path)?; + require_graph_write(ctx, &data.auth_policy, &path)?; let overwrite = false; let folder = data.validate_path_for_insert(&path, overwrite)?; let graph_path = folder.graph_folder(); @@ -518,12 +495,11 @@ impl Mut { overwrite: Option, ) -> Result { let data = ctx.data_unchecked::(); - let (is_admin, role) = auth_context(ctx); // src: require WRITE on graph (moving = deleting source) - require_graph_write(ctx, &data.auth_policy, is_admin, role, path)?; + require_graph_write(ctx, &data.auth_policy, path)?; // src: require WRITE on parent namespace (removing graph from namespace) let src_ns = parent_namespace(path); - require_namespace_write(ctx, &data.auth_policy, is_admin, role, src_ns, path, "move")?; + require_namespace_write(ctx, &data.auth_policy, src_ns, path, "move")?; // copy_graph handles dst namespace WRITE check (and src READ, which WRITE implies) Self::copy_graph(ctx, path, new_path, overwrite).await?; data.delete_graph(path).await?; @@ -538,18 +514,9 @@ impl Mut { overwrite: Option, ) -> Result { let data = ctx.data_unchecked::(); - let (is_admin, role) = auth_context(ctx); - require_graph_read_src(ctx, &data.auth_policy, is_admin, role, path, "copy it")?; + require_graph_read_src(ctx, &data.auth_policy, path, "copy it")?; let dst_ns = parent_namespace(new_path); - require_namespace_write( - ctx, - &data.auth_policy, - is_admin, - role, - dst_ns, - new_path, - "create", - )?; + require_namespace_write(ctx, &data.auth_policy, dst_ns, new_path, "create")?; // doing this in a more efficient way is not trivial, this at least is correct // there are questions like, maybe the new vectorised graph have different rules // for the templates or if it needs to be vectorised at all @@ -572,17 +539,8 @@ impl Mut { overwrite: bool, ) -> Result { let data = ctx.data_unchecked::(); - let (is_admin, role) = auth_context(ctx); let dst_ns = parent_namespace(&path); - require_namespace_write( - ctx, - &data.auth_policy, - is_admin, - role, - dst_ns, - &path, - "upload", - )?; + require_namespace_write(ctx, &data.auth_policy, dst_ns, &path, "upload")?; let in_file = graph.value(ctx)?.content; let folder = data.validate_path_for_insert(&path, overwrite)?; data.insert_graph_as_bytes(folder, in_file).await?; @@ -601,9 +559,8 @@ impl Mut { overwrite: bool, ) -> Result { let data = ctx.data_unchecked::(); - let (is_admin, role) = auth_context(ctx); let dst_ns = parent_namespace(path); - require_namespace_write(ctx, &data.auth_policy, is_admin, role, dst_ns, path, "send")?; + require_namespace_write(ctx, &data.auth_policy, dst_ns, path, "send")?; let folder = if overwrite { ValidWriteableGraphFolder::try_existing_or_new(data.work_dir.clone(), path)? } else { @@ -631,25 +588,9 @@ impl Mut { overwrite: bool, ) -> Result { let data = ctx.data_unchecked::(); - let (is_admin, role) = auth_context(ctx); - require_graph_read_src( - ctx, - &data.auth_policy, - is_admin, - role, - parent_path, - "create a subgraph", - )?; + require_graph_read_src(ctx, &data.auth_policy, parent_path, "create a subgraph")?; let dst_ns = parent_namespace(&new_path); - require_namespace_write( - ctx, - &data.auth_policy, - is_admin, - role, - dst_ns, - &new_path, - "create", - )?; + require_namespace_write(ctx, &data.auth_policy, dst_ns, &new_path, "create")?; let folder = data.validate_path_for_insert(&new_path, overwrite)?; let parent_graph = data.get_graph(parent_path).await?.graph; let folder_clone = folder.clone(); @@ -675,8 +616,7 @@ impl Mut { in_ram: bool, ) -> Result { let data = ctx.data_unchecked::(); - let (is_admin, role) = auth_context(ctx); - require_graph_write(ctx, &data.auth_policy, is_admin, role, path)?; + require_graph_write(ctx, &data.auth_policy, path)?; #[cfg(feature = "search")] { let graph = data.get_graph(path).await?.graph; From 58a1da03d56fcdaff50c614cc5b9779afa8c61bf Mon Sep 17 00:00:00 2001 From: shivamka1 <4599890+shivamka1@users.noreply.github.com> Date: Sat, 28 Mar 2026 19:41:07 +0000 Subject: [PATCH 34/64] best match namespace permission resolution --- python/tests/test_permissions.py | 41 ++++++++++++++++++++++++++++++++ 1 file changed, 41 insertions(+) diff --git a/python/tests/test_permissions.py b/python/tests/test_permissions.py index ee9beaa815..5a6d1908bd 100644 --- a/python/tests/test_permissions.py +++ b/python/tests/test_permissions.py @@ -771,6 +771,47 @@ def test_namespace_read_exposes_graphs(): assert response["data"]["graph"]["path"] == "team/jira" +def test_child_namespace_restriction_overrides_parent(): + """More-specific child namespace grant overrides a broader parent grant. + + team → READ (parent) + team/restricted → INTROSPECT (child — more specific, should win) + + Graphs under team/jira are reachable via READ (only parent matches). + Graphs under team/restricted/ are only introspectable — the child INTROSPECT + entry overrides the parent READ, so graph() is denied there. + """ + work_dir = tempfile.mkdtemp() + with make_server(work_dir).start(): + gql(CREATE_TEAM_JIRA) + gql("""mutation { newGraph(path:"team/restricted/secret", graphType:EVENT) }""") + create_role("analyst") + grant_namespace("analyst", "team", "READ") + grant_namespace("analyst", "team/restricted", "INTROSPECT") + + # team/jira: only matched by "team" → READ — direct access allowed + response = gql(QUERY_TEAM_JIRA, headers=ANALYST_HEADERS) + assert "errors" not in response, response + assert response["data"]["graph"]["path"] == "team/jira" + + # team/restricted/secret: "team/restricted" is the most specific match → INTROSPECT only + response = gql( + """query { graph(path: "team/restricted/secret") { path } }""", + headers=ANALYST_HEADERS, + ) + assert response["data"] is None + assert "Access denied" in response["errors"][0]["message"] + + # But team/restricted/secret should still appear in the namespace listing + response = gql( + """query { namespace(path: "team/restricted") { graphs { list { path } } } }""", + headers=ANALYST_HEADERS, + ) + assert "errors" not in response, response + paths = [g["path"] for g in response["data"]["namespace"]["graphs"]["list"]] + assert "team/restricted/secret" in paths + + def test_discover_derivation(): """grantGraph READ on a namespaced graph → ancestor namespace gets DISCOVER (visible in children).""" work_dir = tempfile.mkdtemp() From 3522dca9c018fd66c86d48603649c1226af4ad1c Mon Sep 17 00:00:00 2001 From: shivamka1 <4599890+shivamka1@users.noreply.github.com> Date: Sat, 28 Mar 2026 19:46:09 +0000 Subject: [PATCH 35/64] ref --- python/src/lib.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/python/src/lib.rs b/python/src/lib.rs index 22d2dba082..b1ca6c95d9 100644 --- a/python/src/lib.rs +++ b/python/src/lib.rs @@ -14,7 +14,7 @@ use raphtory_graphql::python::pymodule::base_graphql_module; #[pymodule] fn _raphtory(py: Python<'_>, m: &Bound) -> PyResult<()> { auth::init(); - let _ = add_raphtory_classes(m); + add_raphtory_classes(m)?; let graphql_module = base_graphql_module(py)?; let algorithm_module = base_algorithm_module(py)?; From 1f0f43b11c850cca1aa14ecacbcd6976104dbb9c Mon Sep 17 00:00:00 2001 From: shivamka1 <4599890+shivamka1@users.noreply.github.com> Date: Sat, 28 Mar 2026 19:57:48 +0000 Subject: [PATCH 36/64] fix at least read/write --- raphtory-graphql/src/auth_policy.rs | 12 ++++++++++++ raphtory-graphql/src/model/mod.rs | 8 ++++---- 2 files changed, 16 insertions(+), 4 deletions(-) diff --git a/raphtory-graphql/src/auth_policy.rs b/raphtory-graphql/src/auth_policy.rs index 49b092a566..5761682f2c 100644 --- a/raphtory-graphql/src/auth_policy.rs +++ b/raphtory-graphql/src/auth_policy.rs @@ -12,6 +12,18 @@ pub enum GraphPermission { Write, } +impl GraphPermission { + /// Returns `true` if the permission level is `Read` or higher (`Write`). + pub fn is_at_least_read(&self) -> bool { + matches!(self, GraphPermission::Read { .. } | GraphPermission::Write) + } + + /// Returns `true` only for `Write` permission. + pub fn is_write(&self) -> bool { + matches!(self, GraphPermission::Write) + } +} + /// The effective permission level a principal has on a namespace. /// Variants are ordered lowest to highest so that `PartialOrd`/`Ord` reflect the hierarchy. #[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord)] diff --git a/raphtory-graphql/src/model/mod.rs b/raphtory-graphql/src/model/mod.rs index 5c6e9fe886..bf3250d12c 100644 --- a/raphtory-graphql/src/model/mod.rs +++ b/raphtory-graphql/src/model/mod.rs @@ -116,7 +116,7 @@ fn require_at_least_read( ); async_graphql::Error::new(msg) })?; - if matches!(perms, GraphPermission::Introspect) { + if !perms.is_at_least_read() { return Err(async_graphql::Error::new(format!( "Access denied: role '{}' has introspect-only access to graph '{path}' — \ use graphMetadata(path:) for counts and timestamps, or namespace listings to browse graphs", @@ -201,7 +201,7 @@ fn require_graph_write( let perms = p .graph_permissions(ctx, path) .map_err(|msg| async_graphql::Error::new(msg))?; - if !matches!(perms, GraphPermission::Write) { + if !perms.is_write() { return Err(write_denied( role, format!("Access denied: WRITE permission required for graph '{path}'"), @@ -247,7 +247,7 @@ fn require_graph_read_src( let perms = p .graph_permissions(ctx, path) .map_err(|msg| async_graphql::Error::new(msg))?; - if matches!(perms, GraphPermission::Introspect) { + if !perms.is_at_least_read() { return Err(write_denied( role, format!("Access denied: READ required on source graph '{path}' to {operation}"), @@ -285,7 +285,7 @@ impl QueryRoot { ); async_graphql::Error::new(msg) })?; - if matches!(perms, GraphPermission::Introspect) { + if !perms.is_at_least_read() { return Err(async_graphql::Error::new(format!( "Access denied: role '{}' has introspect-only access to graph '{path}' — \ READ is required to access graph data; use graphMetadata(path:) for counts and timestamps, or namespace listings to browse graphs", From 49302bafa3a4948a9a2101081f117104c5a4e914 Mon Sep 17 00:00:00 2001 From: shivamka1 <4599890+shivamka1@users.noreply.github.com> Date: Sat, 28 Mar 2026 20:22:26 +0000 Subject: [PATCH 37/64] intro levels and ordering of permissions --- raphtory-graphql/src/auth_policy.rs | 50 ++++++++++++++++++++++++++--- raphtory-graphql/src/model/mod.rs | 47 ++++++++++++--------------- 2 files changed, 66 insertions(+), 31 deletions(-) diff --git a/raphtory-graphql/src/auth_policy.rs b/raphtory-graphql/src/auth_policy.rs index 5761682f2c..8c0ab385fc 100644 --- a/raphtory-graphql/src/auth_policy.rs +++ b/raphtory-graphql/src/auth_policy.rs @@ -1,7 +1,8 @@ use crate::model::graph::filtering::GraphAccessFilter; /// The effective permission level a principal has on a specific graph. -/// Variants are ordered by the hierarchy: `Write` > `Read` > `Introspect`. +/// Variants are ordered by the hierarchy: `Write` > `Read{filter:None}` > `Read{filter:Some}` > `Introspect`. +/// A filtered `Read` is less powerful than an unfiltered `Read` because it sees a restricted view. #[derive(Clone)] pub enum GraphPermission { /// May query graph metadata (counts, schema) but not read data. @@ -13,14 +14,55 @@ pub enum GraphPermission { } impl GraphPermission { - /// Returns `true` if the permission level is `Read` or higher (`Write`). + /// Numeric level used for ordering: `Introspect`=0, `Read{Some}`=1, `Read{None}`=2, `Write`=3. + fn level(&self) -> u8 { + match self { + GraphPermission::Introspect => 0, + GraphPermission::Read { filter: Some(_) } => 1, + GraphPermission::Read { filter: None } => 2, + GraphPermission::Write => 3, + } + } + + /// Returns `true` if the permission level is `Read` or higher. pub fn is_at_least_read(&self) -> bool { - matches!(self, GraphPermission::Read { .. } | GraphPermission::Write) + self.level() >= 1 } /// Returns `true` only for `Write` permission. pub fn is_write(&self) -> bool { - matches!(self, GraphPermission::Write) + self.level() >= 3 + } + + /// Returns `Some(self)` if at least `Read` (filtered or not), `None` otherwise. + /// Use with `?` to gate access and preserve the permission value for filter extraction. + pub fn at_least_read(self) -> Option { + self.is_at_least_read().then_some(self) + } + + /// Returns `Some(self)` if `Write`, `None` otherwise. + pub fn at_least_write(self) -> Option { + self.is_write().then_some(self) + } +} + +impl PartialEq for GraphPermission { + fn eq(&self, other: &Self) -> bool { + self.level() == other.level() + } +} + +impl Eq for GraphPermission {} + +impl PartialOrd for GraphPermission { + fn partial_cmp(&self, other: &Self) -> Option { + Some(self.cmp(other)) + } +} + +impl Ord for GraphPermission { + fn cmp(&self, other: &Self) -> std::cmp::Ordering { + self.level().cmp(&other.level()) } } diff --git a/raphtory-graphql/src/model/mod.rs b/raphtory-graphql/src/model/mod.rs index bf3250d12c..006ec8c492 100644 --- a/raphtory-graphql/src/model/mod.rs +++ b/raphtory-graphql/src/model/mod.rs @@ -106,7 +106,7 @@ fn require_at_least_read( ) -> async_graphql::Result<()> { if let Some(policy) = policy { let role = ctx.data::>().ok().and_then(|r| r.as_deref()); - let perms = policy + policy .graph_permissions(ctx, path) .map_err(|msg| { warn!( @@ -115,14 +115,13 @@ fn require_at_least_read( "Access denied by auth policy" ); async_graphql::Error::new(msg) - })?; - if !perms.is_at_least_read() { - return Err(async_graphql::Error::new(format!( + })? + .at_least_read() + .ok_or_else(|| async_graphql::Error::new(format!( "Access denied: role '{}' has introspect-only access to graph '{path}' — \ use graphMetadata(path:) for counts and timestamps, or namespace listings to browse graphs", role.unwrap_or("") - ))); - } + )))?; } Ok(()) } @@ -198,15 +197,13 @@ fn require_graph_write( None => ctx.require_jwt_write_access().map_err(Into::into), Some(p) => { let role = ctx.data::>().ok().and_then(|r| r.as_deref()); - let perms = p - .graph_permissions(ctx, path) - .map_err(|msg| async_graphql::Error::new(msg))?; - if !perms.is_write() { - return Err(write_denied( + p.graph_permissions(ctx, path) + .map_err(|msg| async_graphql::Error::new(msg))? + .at_least_write() + .ok_or_else(|| write_denied( role, format!("Access denied: WRITE permission required for graph '{path}'"), - )); - } + ))?; Ok(()) } } @@ -244,15 +241,13 @@ fn require_graph_read_src( None => ctx.require_jwt_write_access().map_err(Into::into), Some(p) => { let role = ctx.data::>().ok().and_then(|r| r.as_deref()); - let perms = p - .graph_permissions(ctx, path) - .map_err(|msg| async_graphql::Error::new(msg))?; - if !perms.is_at_least_read() { - return Err(write_denied( + p.graph_permissions(ctx, path) + .map_err(|msg| async_graphql::Error::new(msg))? + .at_least_read() + .ok_or_else(|| write_denied( role, format!("Access denied: READ required on source graph '{path}' to {operation}"), - )); - } + ))?; Ok(()) } } @@ -275,7 +270,7 @@ impl QueryRoot { let perms = if let Some(policy) = &data.auth_policy { let role = ctx.data::>().ok().and_then(|r| r.as_deref()); - let perms = policy + policy .graph_permissions(ctx, path) .map_err(|msg| { warn!( @@ -284,15 +279,13 @@ impl QueryRoot { "Access denied by auth policy" ); async_graphql::Error::new(msg) - })?; - if !perms.is_at_least_read() { - return Err(async_graphql::Error::new(format!( + })? + .at_least_read() + .ok_or_else(|| async_graphql::Error::new(format!( "Access denied: role '{}' has introspect-only access to graph '{path}' — \ READ is required to access graph data; use graphMetadata(path:) for counts and timestamps, or namespace listings to browse graphs", role.unwrap_or("") - ))); - } - perms + )))? } else { GraphPermission::Write // no policy: unrestricted }; From b6f3e0a1e5764bca1da59d8c4e426382b1603368 Mon Sep 17 00:00:00 2001 From: shivamka1 <4599890+shivamka1@users.noreply.github.com> Date: Sat, 28 Mar 2026 20:38:02 +0000 Subject: [PATCH 38/64] remove fail-open in require_jwt_write_access --- raphtory-graphql/src/auth.rs | 1 - raphtory-graphql/src/lib.rs | 3 ++- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/raphtory-graphql/src/auth.rs b/raphtory-graphql/src/auth.rs index 0203fc3c51..3b9178af4d 100644 --- a/raphtory-graphql/src/auth.rs +++ b/raphtory-graphql/src/auth.rs @@ -250,7 +250,6 @@ impl<'a> ContextValidation for &Context<'a> { fn require_jwt_write_access(&self) -> Result<(), AuthError> { match self.data::() { Ok(access) if access == &Access::Rw => Ok(()), - Err(_) => Ok(()), // no auth context (e.g. tests) — unrestricted _ => Err(AuthError::RequireWrite), } } diff --git a/raphtory-graphql/src/lib.rs b/raphtory-graphql/src/lib.rs index db228a98fc..4bc11e7694 100644 --- a/raphtory-graphql/src/lib.rs +++ b/raphtory-graphql/src/lib.rs @@ -46,6 +46,7 @@ mod graphql_test { #[cfg(feature = "search")] use crate::config::app_config::AppConfigBuilder; use crate::{ + auth::Access, config::app_config::AppConfig, data::{data_tests::save_graphs_to_work_dir, Data}, model::App, @@ -89,7 +90,7 @@ mod graphql_test { ) }"#; - let req = Request::new(query); + let req = Request::new(query).data(Access::Rw); let res = schema.execute(req).await; assert_eq!(res.errors, []); } From ed5148be483faf556531d218721dde5a84e0e2af Mon Sep 17 00:00:00 2001 From: shivamka1 <4599890+shivamka1@users.noreply.github.com> Date: Sat, 28 Mar 2026 20:51:32 +0000 Subject: [PATCH 39/64] req ns write perm --- raphtory-graphql/src/model/mod.rs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/raphtory-graphql/src/model/mod.rs b/raphtory-graphql/src/model/mod.rs index 006ec8c492..a700c281e2 100644 --- a/raphtory-graphql/src/model/mod.rs +++ b/raphtory-graphql/src/model/mod.rs @@ -459,7 +459,8 @@ impl Mut { graph_type: GqlGraphType, ) -> Result { let data = ctx.data_unchecked::(); - require_graph_write(ctx, &data.auth_policy, &path)?; + let ns = parent_namespace(&path); + require_namespace_write(ctx, &data.auth_policy, ns, &path, "create")?; let overwrite = false; let folder = data.validate_path_for_insert(&path, overwrite)?; let graph_path = folder.graph_folder(); From b9b9dabff12982561bec02e9a54b4c863915821d Mon Sep 17 00:00:00 2001 From: shivamka1 <4599890+shivamka1@users.noreply.github.com> Date: Sat, 28 Mar 2026 21:04:05 +0000 Subject: [PATCH 40/64] gate permissions api --- python/tests/test_permissions.py | 67 +++++++++++++++++++++++++++++++ raphtory-graphql/src/model/mod.rs | 12 ++++-- 2 files changed, 75 insertions(+), 4 deletions(-) diff --git a/python/tests/test_permissions.py b/python/tests/test_permissions.py index 5a6d1908bd..b25d0c3261 100644 --- a/python/tests/test_permissions.py +++ b/python/tests/test_permissions.py @@ -982,6 +982,73 @@ def test_analyst_cannot_move_with_read_grant(): assert "Access denied" in response["errors"][0]["message"] +# --- newGraph namespace write enforcement --- + + +def test_analyst_can_create_root_graph_with_wildcard_namespace_write(): + """'a':'ro' user with namespace WRITE on '*' can create a graph at the root level.""" + work_dir = tempfile.mkdtemp() + with make_server(work_dir).start(): + create_role("analyst") + grant_namespace("analyst", "*", "WRITE") + + response = gql(CREATE_JIRA, headers=ANALYST_HEADERS) + assert "errors" not in response, response + assert response["data"]["newGraph"] is True + + +def test_analyst_cannot_create_root_graph_with_read_only(): + """'a':'ro' user with namespace READ (not WRITE) is denied by newGraph.""" + work_dir = tempfile.mkdtemp() + with make_server(work_dir).start(): + create_role("analyst") + grant_namespace("analyst", "*", "READ") + + response = gql(CREATE_JIRA, headers=ANALYST_HEADERS) + assert "errors" in response + assert "Access denied" in response["errors"][0]["message"] + + +# --- permissions entry point admin gate --- + + +def test_analyst_cannot_access_permissions_query_entry_point(): + """'a':'ro' user is denied at the permissions query entry point, not just the individual ops. + + This verifies the entry-point-level admin check added to query { permissions { ... } }. + Even with full namespace WRITE, a non-admin JWT cannot reach the permissions resolver. + """ + work_dir = tempfile.mkdtemp() + with make_server(work_dir).start(): + create_role("analyst") + grant_namespace("analyst", "*", "WRITE") # full write, still not admin + + response = gql( + "query { permissions { listRoles } }", + headers=ANALYST_HEADERS, + ) + assert "errors" in response + assert "Access denied" in response["errors"][0]["message"] + + +def test_analyst_cannot_access_permissions_mutation_entry_point(): + """'a':'ro' user is denied at the mutation { permissions { ... } } entry point. + + Even with full namespace WRITE, a non-admin JWT is blocked before reaching any op. + """ + work_dir = tempfile.mkdtemp() + with make_server(work_dir).start(): + create_role("analyst") + grant_namespace("analyst", "*", "WRITE") # full write, still not admin + + response = gql( + 'mutation { permissions { createRole(name: "hacker") { success } } }', + headers=ANALYST_HEADERS, + ) + assert "errors" in response + assert "Access denied" in response["errors"][0]["message"] + + # --- createIndex policy --- diff --git a/raphtory-graphql/src/model/mod.rs b/raphtory-graphql/src/model/mod.rs index a700c281e2..2effad9c3d 100644 --- a/raphtory-graphql/src/model/mod.rs +++ b/raphtory-graphql/src/model/mod.rs @@ -418,8 +418,10 @@ impl QueryRoot { } /// Returns the permissions namespace for inspecting roles and access policies (admin only). - async fn permissions<'a>(_ctx: &Context<'a>) -> PermissionsQueryPlugin { - PermissionsQueryPlugin::default() + async fn permissions<'a>(ctx: &Context<'a>) -> Result { + ctx.require_jwt_write_access() + .map_err(|_| async_graphql::Error::new("Access denied: write access required"))?; + Ok(PermissionsQueryPlugin::default()) } } @@ -437,8 +439,10 @@ impl Mut { } /// Returns the permissions namespace for managing roles and access policies. - async fn permissions<'a>(_ctx: &Context<'a>) -> PermissionsPlugin { - PermissionsPlugin::default() + async fn permissions<'a>(ctx: &Context<'a>) -> Result { + ctx.require_jwt_write_access() + .map_err(|_| async_graphql::Error::new("Access denied: write access required"))?; + Ok(PermissionsPlugin::default()) } /// Delete graph from a path on the server. From 579839da02dc878cb7dd35ddc783099612b7bad3 Mon Sep 17 00:00:00 2001 From: shivamka1 <4599890+shivamka1@users.noreply.github.com> Date: Sat, 28 Mar 2026 21:06:24 +0000 Subject: [PATCH 41/64] ref --- raphtory-graphql/src/model/graph/meta_graph.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/raphtory-graphql/src/model/graph/meta_graph.rs b/raphtory-graphql/src/model/graph/meta_graph.rs index d98a489dcf..534183fde3 100644 --- a/raphtory-graphql/src/model/graph/meta_graph.rs +++ b/raphtory-graphql/src/model/graph/meta_graph.rs @@ -48,8 +48,8 @@ impl MetaGraph { } } - pub(crate) fn local_path(&self) -> String { - self.folder.local_path().into() + pub(crate) fn local_path(&self) -> &str { + self.folder.local_path() } async fn meta(&self) -> Result<&GraphMetadata> { From e463daeb9cf1d7c9c199c2f2fa657ed18b8aa851 Mon Sep 17 00:00:00 2001 From: shivamka1 <4599890+shivamka1@users.noreply.github.com> Date: Sat, 28 Mar 2026 21:15:19 +0000 Subject: [PATCH 42/64] rid dead results --- raphtory-graphql/src/model/graph/graph.rs | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/raphtory-graphql/src/model/graph/graph.rs b/raphtory-graphql/src/model/graph/graph.rs index 2c1bf5a292..5965f49242 100644 --- a/raphtory-graphql/src/model/graph/graph.rs +++ b/raphtory-graphql/src/model/graph/graph.rs @@ -113,9 +113,9 @@ impl GqlGraph { //////////////////////// /// Returns the names of all layers in the graphview. - async fn unique_layers(&self) -> Result> { + async fn unique_layers(&self) -> Vec { let self_clone = self.clone(); - Ok(blocking_compute(move || self_clone.graph.unique_layers().map_into().collect()).await) + blocking_compute(move || self_clone.graph.unique_layers().map_into().collect()).await } /// Returns a view containing only the default layer. @@ -363,23 +363,23 @@ impl GqlGraph { /// /// Returns: /// int: - async fn count_edges(&self) -> Result { + async fn count_edges(&self) -> usize { let self_clone = self.clone(); - Ok(blocking_compute(move || self_clone.graph.count_edges()).await) + blocking_compute(move || self_clone.graph.count_edges()).await } /// Returns the number of temporal edges in the graph. - async fn count_temporal_edges(&self) -> Result { + async fn count_temporal_edges(&self) -> usize { let self_clone = self.clone(); - Ok(blocking_compute(move || self_clone.graph.count_temporal_edges()).await) + blocking_compute(move || self_clone.graph.count_temporal_edges()).await } /// Returns the number of nodes in the graph. /// /// Optionally takes a list of node ids to return a subset. - async fn count_nodes(&self) -> Result { + async fn count_nodes(&self) -> usize { let self_clone = self.clone(); - Ok(blocking_compute(move || self_clone.graph.count_nodes()).await) + blocking_compute(move || self_clone.graph.count_nodes()).await } //////////////////////// From e58ac6fefe2b79cfec8e9d598b6f2df81649f564 Mon Sep 17 00:00:00 2001 From: shivamka1 <4599890+shivamka1@users.noreply.github.com> Date: Sat, 28 Mar 2026 21:27:22 +0000 Subject: [PATCH 43/64] add discover tests --- python/tests/test_permissions.py | 83 ++++++++++++++++++++++++++++++++ 1 file changed, 83 insertions(+) diff --git a/python/tests/test_permissions.py b/python/tests/test_permissions.py index b25d0c3261..c5b1e7ae54 100644 --- a/python/tests/test_permissions.py +++ b/python/tests/test_permissions.py @@ -40,8 +40,11 @@ CREATE_JIRA = """mutation { newGraph(path:"jira", graphType:EVENT) }""" CREATE_ADMIN = """mutation { newGraph(path:"admin", graphType:EVENT) }""" CREATE_TEAM_JIRA = """mutation { newGraph(path:"team/jira", graphType:EVENT) }""" +CREATE_TEAM_CONFLUENCE = """mutation { newGraph(path:"team/confluence", graphType:EVENT) }""" +CREATE_DEEP = """mutation { newGraph(path:"a/b/c", graphType:EVENT) }""" QUERY_TEAM_JIRA = """query { graph(path: "team/jira") { path } }""" QUERY_TEAM_GRAPHS = """query { namespace(path: "team") { graphs { list { path } } } }""" +QUERY_A_CHILDREN = """query { namespace(path: "a") { children { list { path } } } }""" def gql(query: str, headers=None) -> dict: @@ -65,6 +68,12 @@ def grant_namespace(role: str, path: str, permission: str) -> None: ) +def revoke_graph(role: str, path: str) -> None: + gql( + f'mutation {{ permissions {{ revokeGraph(role: "{role}", path: "{path}") {{ success }} }} }}' + ) + + def grant_graph_filtered_read_only(role: str, path: str, filter_gql: str) -> None: """Call grantGraphFilteredReadOnly with a raw GQL filter fragment.""" resp = gql( @@ -827,6 +836,80 @@ def test_discover_derivation(): assert "team" in paths +def test_discover_revoked_when_only_child_revoked(): + """Revoking the only child READ grant removes DISCOVER from the parent namespace.""" + work_dir = tempfile.mkdtemp() + with make_server(work_dir).start(): + gql(CREATE_TEAM_JIRA) + create_role("analyst") + grant_graph("analyst", "team/jira", "READ") + + paths = [n["path"] for n in gql(QUERY_NS_CHILDREN, headers=ANALYST_HEADERS)["data"]["root"]["children"]["list"]] + assert "team" in paths # baseline: DISCOVER present + + revoke_graph("analyst", "team/jira") + + paths = [n["path"] for n in gql(QUERY_NS_CHILDREN, headers=ANALYST_HEADERS)["data"]["root"]["children"]["list"]] + assert "team" not in paths # DISCOVER gone + + +def test_discover_stays_when_one_of_two_children_revoked(): + """DISCOVER persists while at least one child grant remains; clears only when all are revoked.""" + work_dir = tempfile.mkdtemp() + with make_server(work_dir).start(): + gql(CREATE_TEAM_JIRA) + gql(CREATE_TEAM_CONFLUENCE) + create_role("analyst") + grant_graph("analyst", "team/jira", "READ") + grant_graph("analyst", "team/confluence", "READ") + + revoke_graph("analyst", "team/jira") + paths = [n["path"] for n in gql(QUERY_NS_CHILDREN, headers=ANALYST_HEADERS)["data"]["root"]["children"]["list"]] + assert "team" in paths # still visible via team/confluence + + revoke_graph("analyst", "team/confluence") + paths = [n["path"] for n in gql(QUERY_NS_CHILDREN, headers=ANALYST_HEADERS)["data"]["root"]["children"]["list"]] + assert "team" not in paths # now gone + + +def test_discover_stays_when_parent_has_explicit_namespace_read(): + """Revoking a child graph READ does not remove an explicit namespace READ on the parent.""" + work_dir = tempfile.mkdtemp() + with make_server(work_dir).start(): + gql(CREATE_TEAM_JIRA) + create_role("analyst") + grant_graph("analyst", "team/jira", "READ") + grant_namespace("analyst", "team", "READ") # explicit, higher than DISCOVER + + revoke_graph("analyst", "team/jira") + + paths = [n["path"] for n in gql(QUERY_NS_CHILDREN, headers=ANALYST_HEADERS)["data"]["root"]["children"]["list"]] + assert "team" in paths # still visible via explicit namespace READ + + +def test_discover_revoked_for_nested_namespaces(): + """Revoking the only deep grant removes DISCOVER from all ancestor namespaces.""" + work_dir = tempfile.mkdtemp() + with make_server(work_dir).start(): + gql(CREATE_DEEP) + create_role("analyst") + grant_graph("analyst", "a/b/c", "READ") # "a" and "a/b" both get DISCOVER + + root_paths = [n["path"] for n in gql(QUERY_NS_CHILDREN, headers=ANALYST_HEADERS)["data"]["root"]["children"]["list"]] + assert "a" in root_paths + + a_paths = [n["path"] for n in gql(QUERY_A_CHILDREN, headers=ANALYST_HEADERS)["data"]["namespace"]["children"]["list"]] + assert "a/b" in a_paths + + revoke_graph("analyst", "a/b/c") + + root_paths = [n["path"] for n in gql(QUERY_NS_CHILDREN, headers=ANALYST_HEADERS)["data"]["root"]["children"]["list"]] + assert "a" not in root_paths + + a_paths = [n["path"] for n in gql(QUERY_A_CHILDREN, headers=ANALYST_HEADERS)["data"]["namespace"]["children"]["list"]] + assert "a/b" not in a_paths + + def test_no_namespace_grant_hidden_from_children(): """No grants at all → namespace is hidden from root children listing.""" work_dir = tempfile.mkdtemp() From 81912f5586841e818f7025a30ccd715c11fe496d Mon Sep 17 00:00:00 2001 From: shivamka1 <4599890+shivamka1@users.noreply.github.com> Date: Mon, 30 Mar 2026 10:37:59 +0100 Subject: [PATCH 44/64] change fail open to fail close, add test --- python/tests/test_permissions.py | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/python/tests/test_permissions.py b/python/tests/test_permissions.py index c5b1e7ae54..de98d67a57 100644 --- a/python/tests/test_permissions.py +++ b/python/tests/test_permissions.py @@ -161,14 +161,26 @@ def test_unknown_role_is_denied_when_policy_is_active(): assert "Access denied" in response["errors"][0]["message"] -def test_empty_store_gives_full_access(): - """With an empty permissions store (no roles configured), authenticated users see everything.""" +def test_empty_store_denies_non_admin(): + """With an empty permissions store (no roles configured), non-admin users are denied.""" work_dir = tempfile.mkdtemp() with make_server(work_dir).start(): gql(CREATE_JIRA) response = gql(QUERY_JIRA, headers=ANALYST_HEADERS) + assert response["data"] is None + assert "errors" in response + + +def test_empty_store_allows_admin(): + """With an empty permissions store, admin (rw JWT) still gets full access.""" + work_dir = tempfile.mkdtemp() + with make_server(work_dir).start(): + gql(CREATE_JIRA) + + response = gql(QUERY_JIRA, headers=ADMIN_HEADERS) assert "errors" not in response, response + assert response["data"]["graph"]["path"] == "jira" def test_introspection_allowed_with_introspect_permission(): From c427eac38304ac99de5d14be9b62a8c24c480093 Mon Sep 17 00:00:00 2001 From: shivamka1 <4599890+shivamka1@users.noreply.github.com> Date: Mon, 30 Mar 2026 11:05:37 +0100 Subject: [PATCH 45/64] rid wild card, add test --- python/tests/test_permissions.py | 47 ++++++++++++++++---------------- 1 file changed, 23 insertions(+), 24 deletions(-) diff --git a/python/tests/test_permissions.py b/python/tests/test_permissions.py index de98d67a57..2dfc2fd2f1 100644 --- a/python/tests/test_permissions.py +++ b/python/tests/test_permissions.py @@ -97,9 +97,7 @@ def test_analyst_can_access_permitted_graph(): gql(CREATE_JIRA) gql(CREATE_ADMIN) create_role("analyst") - create_role("admin") grant_graph("analyst", "jira", "READ") - grant_namespace("admin", "*", "READ") response = gql(QUERY_JIRA, headers=ANALYST_HEADERS) assert "errors" not in response, response @@ -123,8 +121,6 @@ def test_admin_can_access_all_graphs(): with make_server(work_dir).start(): gql(CREATE_JIRA) gql(CREATE_ADMIN) - create_role("admin") - grant_namespace("admin", "*", "READ") for query in [QUERY_JIRA, QUERY_ADMIN]: response = gql(query, headers=ADMIN_HEADERS) @@ -236,17 +232,20 @@ def test_permissions_update_via_mutation(): assert response["data"]["graph"]["path"] == "jira" -def test_namespace_wildcard_grants_access_to_all_graphs(): +def test_namespace_grant_does_not_cover_root_level_graphs(): + """Namespace grants only apply to graphs within that namespace; root-level graphs require explicit graph grants.""" work_dir = tempfile.mkdtemp() with make_server(work_dir).start(): gql(CREATE_JIRA) - gql(CREATE_ADMIN) + gql(CREATE_TEAM_JIRA) create_role("analyst") - grant_namespace("analyst", "*", "READ") + grant_namespace("analyst", "team", "READ") # covers team/jira but not root-level jira - for query in [QUERY_JIRA, QUERY_ADMIN]: - response = gql(query, headers=ANALYST_HEADERS) - assert "errors" not in response, response + response = gql(QUERY_TEAM_JIRA, headers=ANALYST_HEADERS) + assert "errors" not in response, response + + response = gql(QUERY_JIRA, headers=ANALYST_HEADERS) + assert response["data"] is None # root-level graph not covered by namespace grant # --- WRITE permission enforcement --- @@ -323,7 +322,7 @@ def test_analyst_cannot_call_permissions_mutations(): work_dir = tempfile.mkdtemp() with make_server(work_dir).start(): create_role("analyst") - grant_namespace("analyst", "*", "WRITE") + grant_namespace("analyst", "team", "WRITE") response = gql( 'mutation { permissions { createRole(name: "hacker") { success } } }', @@ -946,12 +945,12 @@ def test_analyst_can_delete_with_graph_and_namespace_write(): """deleteGraph requires WRITE on both the graph and its parent namespace.""" work_dir = tempfile.mkdtemp() with make_server(work_dir).start(): - gql(CREATE_JIRA) + gql(CREATE_TEAM_JIRA) create_role("analyst") - grant_graph("analyst", "jira", "WRITE") - grant_namespace("analyst", "*", "WRITE") + grant_graph("analyst", "team/jira", "WRITE") + grant_namespace("analyst", "team", "WRITE") - response = gql(DELETE_JIRA, headers=ANALYST_HEADERS) + response = gql(DELETE_TEAM_JIRA, headers=ANALYST_HEADERS) assert "errors" not in response, response assert response["data"]["deleteGraph"] is True @@ -1080,26 +1079,26 @@ def test_analyst_cannot_move_with_read_grant(): # --- newGraph namespace write enforcement --- -def test_analyst_can_create_root_graph_with_wildcard_namespace_write(): - """'a':'ro' user with namespace WRITE on '*' can create a graph at the root level.""" +def test_analyst_can_create_namespaced_graph_with_namespace_write(): + """'a':'ro' user with namespace WRITE can create a graph inside that namespace.""" work_dir = tempfile.mkdtemp() with make_server(work_dir).start(): create_role("analyst") - grant_namespace("analyst", "*", "WRITE") + grant_namespace("analyst", "team", "WRITE") - response = gql(CREATE_JIRA, headers=ANALYST_HEADERS) + response = gql(CREATE_TEAM_JIRA, headers=ANALYST_HEADERS) assert "errors" not in response, response assert response["data"]["newGraph"] is True -def test_analyst_cannot_create_root_graph_with_read_only(): +def test_analyst_cannot_create_graph_with_namespace_read_only(): """'a':'ro' user with namespace READ (not WRITE) is denied by newGraph.""" work_dir = tempfile.mkdtemp() with make_server(work_dir).start(): create_role("analyst") - grant_namespace("analyst", "*", "READ") + grant_namespace("analyst", "team", "READ") - response = gql(CREATE_JIRA, headers=ANALYST_HEADERS) + response = gql(CREATE_TEAM_JIRA, headers=ANALYST_HEADERS) assert "errors" in response assert "Access denied" in response["errors"][0]["message"] @@ -1116,7 +1115,7 @@ def test_analyst_cannot_access_permissions_query_entry_point(): work_dir = tempfile.mkdtemp() with make_server(work_dir).start(): create_role("analyst") - grant_namespace("analyst", "*", "WRITE") # full write, still not admin + grant_namespace("analyst", "team", "WRITE") # full write, still not admin response = gql( "query { permissions { listRoles } }", @@ -1134,7 +1133,7 @@ def test_analyst_cannot_access_permissions_mutation_entry_point(): work_dir = tempfile.mkdtemp() with make_server(work_dir).start(): create_role("analyst") - grant_namespace("analyst", "*", "WRITE") # full write, still not admin + grant_namespace("analyst", "team", "WRITE") # full write, still not admin response = gql( 'mutation { permissions { createRole(name: "hacker") { success } } }', From 9b86914fc17a5c218cb0e5e3706ef89d5ca81911 Mon Sep 17 00:00:00 2001 From: shivamka1 <4599890+shivamka1@users.noreply.github.com> Date: Mon, 30 Mar 2026 11:15:57 +0100 Subject: [PATCH 46/64] fix inference issues --- python/tests/test_permissions.py | 51 ++++++++++++++---------- raphtory-graphql/src/model/mod.rs | 64 ++++++++++++++++++------------- 2 files changed, 68 insertions(+), 47 deletions(-) diff --git a/python/tests/test_permissions.py b/python/tests/test_permissions.py index 2dfc2fd2f1..299b8f6006 100644 --- a/python/tests/test_permissions.py +++ b/python/tests/test_permissions.py @@ -111,9 +111,11 @@ def test_analyst_cannot_access_denied_graph(): create_role("analyst") grant_graph("analyst", "jira", "READ") # only jira, not admin + # "admin" graph is silently null — analyst has no namespace INTROSPECT, so + # existence of "admin" is not revealed. response = gql(QUERY_ADMIN, headers=ANALYST_HEADERS) - assert response["data"] is None - assert "Access denied" in response["errors"][0]["message"] + assert "errors" not in response, response + assert response["data"]["graph"] is None def test_admin_can_access_all_graphs(): @@ -135,8 +137,8 @@ def test_no_role_is_denied_when_policy_is_active(): grant_graph("analyst", "jira", "READ") response = gql(QUERY_JIRA, headers=NO_ROLE_HEADERS) - assert response["data"] is None - assert "Access denied" in response["errors"][0]["message"] + assert "errors" not in response, response + assert response["data"]["graph"] is None def test_unknown_role_is_denied_when_policy_is_active(): @@ -153,8 +155,8 @@ def test_unknown_role_is_denied_when_policy_is_active(): create_role("other_team") response = gql(QUERY_JIRA, headers=ANALYST_HEADERS) # JWT says role="analyst" - assert response["data"] is None - assert "Access denied" in response["errors"][0]["message"] + assert "errors" not in response, response + assert response["data"]["graph"] is None def test_empty_store_denies_non_admin(): @@ -164,8 +166,8 @@ def test_empty_store_denies_non_admin(): gql(CREATE_JIRA) response = gql(QUERY_JIRA, headers=ANALYST_HEADERS) - assert response["data"] is None - assert "errors" in response + assert "errors" not in response, response + assert response["data"]["graph"] is None def test_empty_store_allows_admin(): @@ -195,7 +197,7 @@ def test_introspection_allowed_with_introspect_permission(): # graph() resolver is denied (only INTROSPECT, not READ) response = gql(QUERY_TEAM_JIRA, headers=ANALYST_HEADERS) - assert response["data"] is None + assert response["data"]["graph"] is None assert "Access denied" in response["errors"][0]["message"] @@ -245,7 +247,8 @@ def test_namespace_grant_does_not_cover_root_level_graphs(): assert "errors" not in response, response response = gql(QUERY_JIRA, headers=ANALYST_HEADERS) - assert response["data"] is None # root-level graph not covered by namespace grant + assert "errors" not in response, response + assert response["data"]["graph"] is None # root-level graph not covered by namespace grant # --- WRITE permission enforcement --- @@ -400,17 +403,22 @@ def test_introspect_only_cannot_access_graph_data(): def test_no_grant_hidden_from_namespace_and_graph(): - """A role with no permissions on a graph sees it neither in listings nor via graph().""" + """A role with no namespace INTROSPECT sees graph() as null, not an 'Access denied' error. + + Returning an error would leak that the graph exists. Null is indistinguishable from + 'graph not found'. An error is only appropriate when the role already has INTROSPECT + on the namespace (and therefore can list the graph name anyway). + """ work_dir = tempfile.mkdtemp() with make_server(work_dir).start(): gql(CREATE_JIRA) create_role("analyst") - # analyst has no grant on jira at all + # analyst has no grant at all - # graph() denied + # graph() returns null silently — does not reveal the graph exists response = gql(QUERY_JIRA, headers=ANALYST_HEADERS) - assert response["data"] is None - assert "Access denied" in response["errors"][0]["message"] + assert "errors" not in response, response + assert response["data"]["graph"] is None # namespace listing hides it response = gql(QUERY_NS_GRAPHS, headers=ANALYST_HEADERS) @@ -450,7 +458,7 @@ def test_graph_metadata_allowed_with_introspect(): # graph() is still denied — INTROSPECT does not grant data access response = gql(QUERY_TEAM_JIRA, headers=ANALYST_HEADERS) - assert response["data"] is None + assert response["data"]["graph"] is None assert "Access denied" in response["errors"][0]["message"] @@ -476,8 +484,8 @@ def test_graph_metadata_denied_without_grant(): # no grant on jira response = gql(QUERY_META_JIRA, headers=ANALYST_HEADERS) - assert response["data"] is None - assert "Access denied" in response["errors"][0]["message"] + assert "errors" not in response, response + assert response["data"]["graphMetadata"] is None def test_analyst_sees_only_filtered_nodes(): @@ -772,9 +780,10 @@ def test_namespace_introspect_shows_graphs_in_listing(): paths = [g["path"] for g in response["data"]["namespace"]["graphs"]["list"]] assert "team/jira" in paths - # Direct graph access is denied + # Direct graph access is denied — role has INTROSPECT so the graph name is + # already visible in listings; "Access denied" doesn't leak new information. response = gql(QUERY_TEAM_JIRA, headers=ANALYST_HEADERS) - assert response["data"] is None + assert response["data"]["graph"] is None assert "Access denied" in response["errors"][0]["message"] @@ -819,7 +828,7 @@ def test_child_namespace_restriction_overrides_parent(): """query { graph(path: "team/restricted/secret") { path } }""", headers=ANALYST_HEADERS, ) - assert response["data"] is None + assert response["data"]["graph"] is None assert "Access denied" in response["errors"][0]["message"] # But team/restricted/secret should still appear in the namespace listing diff --git a/raphtory-graphql/src/model/mod.rs b/raphtory-graphql/src/model/mod.rs index 2effad9c3d..758606e778 100644 --- a/raphtory-graphql/src/model/mod.rs +++ b/raphtory-graphql/src/model/mod.rs @@ -265,27 +265,36 @@ impl QueryRoot { } /// Returns a graph - async fn graph<'a>(ctx: &Context<'a>, path: &str) -> Result { + async fn graph<'a>(ctx: &Context<'a>, path: &str) -> Result> { let data = ctx.data_unchecked::(); let perms = if let Some(policy) = &data.auth_policy { let role = ctx.data::>().ok().and_then(|r| r.as_deref()); - policy - .graph_permissions(ctx, path) - .map_err(|msg| { - warn!( - role = role.unwrap_or(""), - graph = path, - "Access denied by auth policy" - ); - async_graphql::Error::new(msg) - })? - .at_least_read() - .ok_or_else(|| async_graphql::Error::new(format!( - "Access denied: role '{}' has introspect-only access to graph '{path}' — \ - READ is required to access graph data; use graphMetadata(path:) for counts and timestamps, or namespace listings to browse graphs", - role.unwrap_or("") - )))? + match policy.graph_permissions(ctx, path) { + Err(msg) => { + // Only surface the denial if the role already has INTROSPECT (or higher) + // on the parent namespace — they already know graphs exist there. + // Otherwise return null, indistinguishable from "graph not found". + let ns = parent_namespace(path); + if policy.namespace_permissions(ctx, ns) >= NamespacePermission::Introspect { + warn!( + role = role.unwrap_or(""), + graph = path, + "Access denied by auth policy" + ); + return Err(async_graphql::Error::new(msg)); + } else { + return Ok(None); + } + } + Ok(perm) => perm + .at_least_read() + .ok_or_else(|| async_graphql::Error::new(format!( + "Access denied: role '{}' has introspect-only access to graph '{path}' — \ + READ is required to access graph data; use graphMetadata(path:) for counts and timestamps, or namespace listings to browse graphs", + role.unwrap_or("") + )))?, + } } else { GraphPermission::Write // no policy: unrestricted }; @@ -302,35 +311,38 @@ impl QueryRoot { graph }; - Ok(GqlGraph::new_with_permissions( + Ok(Some(GqlGraph::new_with_permissions( graph_with_vecs.folder, graph, perms, - )) + ))) } /// Returns lightweight metadata for a graph (node/edge counts, timestamps) without loading it. /// Requires at least INTROSPECT permission. - async fn graph_metadata<'a>(ctx: &Context<'a>, path: String) -> Result { + async fn graph_metadata<'a>(ctx: &Context<'a>, path: String) -> Result> { let data = ctx.data_unchecked::(); if let Some(policy) = &data.auth_policy { let role = ctx.data::>().ok().and_then(|r| r.as_deref()); - policy - .graph_permissions(ctx, &path) - .map_err(|msg| { + if let Err(msg) = policy.graph_permissions(ctx, &path) { + let ns = parent_namespace(&path); + if policy.namespace_permissions(ctx, ns) >= NamespacePermission::Introspect { warn!( role = role.unwrap_or(""), graph = path.as_str(), "Access denied by auth policy" ); - async_graphql::Error::new(msg) - })?; + return Err(async_graphql::Error::new(msg)); + } else { + return Ok(None); + } + } } let folder = ExistingGraphFolder::try_from(data.work_dir.clone(), &path) .map_err(|e| async_graphql::Error::new(e.to_string()))?; - Ok(MetaGraph::new(folder)) + Ok(Some(MetaGraph::new(folder))) } /// Update graph query, has side effects to update graph state From ada4320ec380227655db45945eb81d4a98171dfb Mon Sep 17 00:00:00 2001 From: shivamka1 <4599890+shivamka1@users.noreply.github.com> Date: Mon, 30 Mar 2026 11:37:19 +0100 Subject: [PATCH 47/64] fix inference, add test --- python/tests/test_permissions.py | 31 +++++++++++++++++++--- raphtory-graphql/src/model/mod.rs | 43 +++++++++++++++++++------------ 2 files changed, 55 insertions(+), 19 deletions(-) diff --git a/python/tests/test_permissions.py b/python/tests/test_permissions.py index 299b8f6006..4c40705e57 100644 --- a/python/tests/test_permissions.py +++ b/python/tests/test_permissions.py @@ -692,12 +692,12 @@ def test_receive_graph_requires_read(): gql(CREATE_TEAM_JIRA) create_role("analyst") - # No grant — denied + # No grant — looks like the graph doesn't exist (no information leakage) client = RaphtoryClient(url=RAPHTORY, token=ANALYST_JWT) - with pytest.raises(Exception, match="Access denied"): + with pytest.raises(Exception, match="does not exist"): client.receive_graph("team/jira") - # Namespace INTROSPECT only — also denied for receive_graph + # Namespace INTROSPECT only — also denied for receive_graph, but now reveals access denied grant_namespace("analyst", "team", "INTROSPECT") with pytest.raises(Exception, match="Access denied"): client.receive_graph("team/jira") @@ -708,6 +708,31 @@ def test_receive_graph_requires_read(): assert g is not None +def test_receive_graph_without_introspect_hides_existence(): + """Without namespace INTROSPECT, receive_graph acts as if the graph does not exist. + + This prevents information leakage: a role without any grants cannot distinguish + between 'graph does not exist' and 'graph exists but you are denied'. + """ + work_dir = tempfile.mkdtemp() + with make_server(work_dir).start(): + gql(CREATE_TEAM_JIRA) + create_role("analyst") + + client = RaphtoryClient(url=RAPHTORY, token=ANALYST_JWT) + + # No grants at all — error must be indistinguishable from a missing graph + with pytest.raises(Exception, match="does not exist") as exc_no_grant: + client.receive_graph("team/jira") + + # Compare with a truly non-existent graph — error should look the same + with pytest.raises(Exception, match="does not exist") as exc_missing: + client.receive_graph("team/nonexistent") + + assert "Access denied" not in str(exc_no_grant.value) + assert "Access denied" not in str(exc_missing.value) + + def test_analyst_sees_only_graph_filter_window(): """grantGraphFilteredReadOnly with a graph-level window filter restricts the temporal view. diff --git a/raphtory-graphql/src/model/mod.rs b/raphtory-graphql/src/model/mod.rs index 758606e778..fcae5e86e8 100644 --- a/raphtory-graphql/src/model/mod.rs +++ b/raphtory-graphql/src/model/mod.rs @@ -99,6 +99,8 @@ pub enum GqlGraphType { /// Checks that the caller has at least READ permission for the graph at `path`. /// Returns `Err` if denied or if only INTROSPECT was granted. +/// When denied and the caller has no INTROSPECT on the parent namespace, returns a +/// "Graph does not exist" error to avoid leaking that the graph is present. fn require_at_least_read( ctx: &Context<'_>, policy: &Option>, @@ -106,22 +108,31 @@ fn require_at_least_read( ) -> async_graphql::Result<()> { if let Some(policy) = policy { let role = ctx.data::>().ok().and_then(|r| r.as_deref()); - policy - .graph_permissions(ctx, path) - .map_err(|msg| { - warn!( - role = role.unwrap_or(""), - graph = path, - "Access denied by auth policy" - ); - async_graphql::Error::new(msg) - })? - .at_least_read() - .ok_or_else(|| async_graphql::Error::new(format!( - "Access denied: role '{}' has introspect-only access to graph '{path}' — \ - use graphMetadata(path:) for counts and timestamps, or namespace listings to browse graphs", - role.unwrap_or("") - )))?; + match policy.graph_permissions(ctx, path) { + Err(msg) => { + let ns = parent_namespace(path); + if policy.namespace_permissions(ctx, ns) >= NamespacePermission::Introspect { + warn!( + role = role.unwrap_or(""), + graph = path, + "Access denied by auth policy" + ); + return Err(async_graphql::Error::new(msg)); + } else { + // Don't leak graph existence — act as if it doesn't exist. + return Err(async_graphql::Error::new(MissingGraph.to_string())); + } + } + Ok(perm) => { + perm.at_least_read().ok_or_else(|| { + async_graphql::Error::new(format!( + "Access denied: role '{}' has introspect-only access to graph '{path}' — \ + use graphMetadata(path:) for counts and timestamps, or namespace listings to browse graphs", + role.unwrap_or("") + )) + })?; + } + } } Ok(()) } From 47c04ab5a74e17a79a4a21b6fd8b63abc4389961 Mon Sep 17 00:00:00 2001 From: shivamka1 <4599890+shivamka1@users.noreply.github.com> Date: Mon, 30 Mar 2026 12:03:40 +0100 Subject: [PATCH 48/64] impl filtered receiveGraph --- python/tests/test_permissions.py | 42 +++++++++++++++++++++++++++++++ raphtory-graphql/src/model/mod.rs | 30 +++++++++++++++------- 2 files changed, 63 insertions(+), 9 deletions(-) diff --git a/python/tests/test_permissions.py b/python/tests/test_permissions.py index 4c40705e57..68c18acb55 100644 --- a/python/tests/test_permissions.py +++ b/python/tests/test_permissions.py @@ -733,6 +733,48 @@ def test_receive_graph_without_introspect_hides_existence(): assert "Access denied" not in str(exc_missing.value) +def test_receive_graph_with_filtered_access(): + """receive_graph with grantGraphFilteredReadOnly returns a materialized view of the filtered graph. + + The downloaded graph should only contain nodes/edges that pass the stored filter, + not the full unfiltered graph. + """ + work_dir = tempfile.mkdtemp() + with make_server(work_dir).start(): + gql(CREATE_JIRA) + for name, region in [ + ("alice", "us-west"), + ("bob", "us-east"), + ("carol", "us-west"), + ]: + resp = gql( + f"""query {{ + updateGraph(path: "jira") {{ + addNode( + time: 1, + name: "{name}", + properties: [{{ key: "region", value: {{ str: "{region}" }} }}] + ) {{ success }} + }} + }}""" + ) + assert resp["data"]["updateGraph"]["addNode"]["success"] is True, resp + + create_role("analyst") + grant_graph_filtered_read_only( + "analyst", + "jira", + '{ node: { property: { name: "region", where: { eq: { str: "us-west" } } } } }', + ) + + client = RaphtoryClient(url=RAPHTORY, token=ANALYST_JWT) + received = client.receive_graph("jira") + + names = {n.name for n in received.nodes} + assert names == {"alice", "carol"}, f"Expected only us-west nodes, got: {names}" + assert "bob" not in names + + def test_analyst_sees_only_graph_filter_window(): """grantGraphFilteredReadOnly with a graph-level window filter restricts the temporal view. diff --git a/raphtory-graphql/src/model/mod.rs b/raphtory-graphql/src/model/mod.rs index fcae5e86e8..a5f42d9920 100644 --- a/raphtory-graphql/src/model/mod.rs +++ b/raphtory-graphql/src/model/mod.rs @@ -98,14 +98,14 @@ pub enum GqlGraphType { } /// Checks that the caller has at least READ permission for the graph at `path`. -/// Returns `Err` if denied or if only INTROSPECT was granted. +/// Returns the effective `GraphPermission` (including any stored filter) on success. /// When denied and the caller has no INTROSPECT on the parent namespace, returns a /// "Graph does not exist" error to avoid leaking that the graph is present. fn require_at_least_read( ctx: &Context<'_>, policy: &Option>, path: &str, -) -> async_graphql::Result<()> { +) -> async_graphql::Result { if let Some(policy) = policy { let role = ctx.data::>().ok().and_then(|r| r.as_deref()); match policy.graph_permissions(ctx, path) { @@ -124,17 +124,17 @@ fn require_at_least_read( } } Ok(perm) => { - perm.at_least_read().ok_or_else(|| { + return Ok(perm.at_least_read().ok_or_else(|| { async_graphql::Error::new(format!( "Access denied: role '{}' has introspect-only access to graph '{path}' — \ use graphMetadata(path:) for counts and timestamps, or namespace listings to browse graphs", role.unwrap_or("") )) - })?; + })?); } } } - Ok(()) + Ok(GraphPermission::Write) } /// Applies a stored data filter (serialised as `serde_json::Value` with optional `node`, `edge`, @@ -425,14 +425,26 @@ impl QueryRoot { QueryPlugin::default() } - /// Encodes graph and returns as string + /// Encodes graph and returns as string. + /// If the caller has filtered access, the returned graph is a materialized view of the filter. /// /// Returns:: Base64 url safe encoded string async fn receive_graph<'a>(ctx: &Context<'a>, path: String) -> Result { let data = ctx.data_unchecked::(); - require_at_least_read(ctx, &data.auth_policy, &path)?; - let g = data.get_graph(&path).await?.graph.clone(); - let res = url_encode_graph(g)?; + let perm = require_at_least_read(ctx, &data.auth_policy, &path)?; + let raw = data.get_graph(&path).await?.graph; + let res = if let GraphPermission::Read { + filter: Some(ref f), + } = perm + { + let filtered = apply_graph_filter(raw.into_dynamic(), f.clone()).await?; + let materialized = blocking_compute(move || filtered.materialize()) + .await + .map_err(|e| async_graphql::Error::new(e.to_string()))?; + url_encode_graph(materialized)? + } else { + url_encode_graph(raw)? + }; Ok(res) } From cdad1dd723ad926cfc84454646952422e3b2e14f Mon Sep 17 00:00:00 2001 From: shivamka1 <4599890+shivamka1@users.noreply.github.com> Date: Mon, 30 Mar 2026 12:48:47 +0100 Subject: [PATCH 49/64] fix clam-core version --- Cargo.lock | 2 +- python/Cargo.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index c8953c9187..c1eb3ec3d6 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1059,7 +1059,7 @@ dependencies = [ [[package]] name = "clam-core" -version = "0.1.0" +version = "0.17.0" dependencies = [ "ahash", "arrow", diff --git a/python/Cargo.toml b/python/Cargo.toml index 3862bd9411..bbdb6b79ea 100644 --- a/python/Cargo.toml +++ b/python/Cargo.toml @@ -27,7 +27,7 @@ raphtory-graphql = { workspace = true, features = [ "python", ] } auth = { workspace = true } -clam-core = { path = "../clam-core", features = ["python"] } +clam-core = { workspace = true, features = ["python"] } [features] extension-module = ["pyo3/extension-module"] From 54c5b6898340a4f4d4dbcce08e811b5e7afdb32f Mon Sep 17 00:00:00 2001 From: shivamka1 <4599890+shivamka1@users.noreply.github.com> Date: Mon, 30 Mar 2026 12:50:02 +0100 Subject: [PATCH 50/64] make rbac explicit by passing permissions store, fix tests --- python/tests/test_permissions.py | 106 ++++++++++++++++++++---------- raphtory-graphql/src/model/mod.rs | 37 +++++------ 2 files changed, 87 insertions(+), 56 deletions(-) diff --git a/python/tests/test_permissions.py b/python/tests/test_permissions.py index 68c18acb55..13399d8a1a 100644 --- a/python/tests/test_permissions.py +++ b/python/tests/test_permissions.py @@ -195,10 +195,10 @@ def test_introspection_allowed_with_introspect_permission(): paths = [g["path"] for g in response["data"]["namespace"]["graphs"]["list"]] assert "team/jira" in paths - # graph() resolver is denied (only INTROSPECT, not READ) + # graph() resolver returns null — INTROSPECT does not grant data access response = gql(QUERY_TEAM_JIRA, headers=ANALYST_HEADERS) + assert "errors" not in response, response assert response["data"]["graph"] is None - assert "Access denied" in response["errors"][0]["message"] def test_read_implies_introspect(): @@ -222,9 +222,10 @@ def test_permissions_update_via_mutation(): gql(CREATE_JIRA) create_role("analyst") - # No grants yet — denied + # No grants yet — graph returns null (indistinguishable from "graph not found") response = gql(QUERY_JIRA, headers=ANALYST_HEADERS) - assert "Access denied" in response["errors"][0]["message"] + assert "errors" not in response, response + assert response["data"]["graph"] is None # Grant via mutation grant_graph("analyst", "jira", "READ") @@ -258,7 +259,7 @@ def test_namespace_grant_does_not_cover_root_level_graphs(): def test_admin_bypasses_policy_for_reads(): - """'a':'rw' admin can read any graph even without a role entry in the store.""" + """'access':'rw' admin can read any graph even without a role entry in the store.""" work_dir = tempfile.mkdtemp() with make_server(work_dir).start(): gql(CREATE_JIRA) @@ -272,7 +273,7 @@ def test_admin_bypasses_policy_for_reads(): def test_analyst_can_write_with_write_grant(): - """'a':'ro' user with WRITE grant on a specific graph can call updateGraph.""" + """'access':'ro' user with WRITE grant on a specific graph can call updateGraph.""" work_dir = tempfile.mkdtemp() with make_server(work_dir).start(): gql(CREATE_JIRA) @@ -284,7 +285,7 @@ def test_analyst_can_write_with_write_grant(): def test_analyst_cannot_write_without_write_grant(): - """'a':'ro' user with READ-only grant cannot call updateGraph.""" + """'access':'ro' user with READ-only grant cannot call updateGraph.""" work_dir = tempfile.mkdtemp() with make_server(work_dir).start(): gql(CREATE_JIRA) @@ -298,7 +299,7 @@ def test_analyst_cannot_write_without_write_grant(): def test_analyst_can_create_graph_in_namespace(): - """'a':'ro' user with namespace WRITE grant can create a new graph in that namespace.""" + """'access':'ro' user with namespace WRITE grant can create a new graph in that namespace.""" work_dir = tempfile.mkdtemp() with make_server(work_dir).start(): create_role("analyst") @@ -309,7 +310,7 @@ def test_analyst_can_create_graph_in_namespace(): def test_analyst_cannot_create_graph_outside_namespace(): - """'a':'ro' user with namespace WRITE grant cannot create a graph outside that namespace.""" + """'access':'ro' user with namespace WRITE grant cannot create a graph outside that namespace.""" work_dir = tempfile.mkdtemp() with make_server(work_dir).start(): create_role("analyst") @@ -321,7 +322,7 @@ def test_analyst_cannot_create_graph_outside_namespace(): def test_analyst_cannot_call_permissions_mutations(): - """'a':'ro' user with WRITE grant on a graph cannot manage roles/permissions.""" + """'access':'ro' user with WRITE grant on a graph cannot manage roles/permissions.""" work_dir = tempfile.mkdtemp() with make_server(work_dir).start(): create_role("analyst") @@ -336,7 +337,7 @@ def test_analyst_cannot_call_permissions_mutations(): def test_admin_can_list_roles(): - """'a':'rw' admin can query permissions { listRoles }.""" + """'access':'rw' admin can query permissions { listRoles }.""" work_dir = tempfile.mkdtemp() with make_server(work_dir).start(): create_role("analyst") @@ -347,7 +348,7 @@ def test_admin_can_list_roles(): def test_analyst_cannot_list_roles(): - """'a':'ro' user cannot query permissions { listRoles }.""" + """'access':'ro' user cannot query permissions { listRoles }.""" work_dir = tempfile.mkdtemp() with make_server(work_dir).start(): create_role("analyst") @@ -358,7 +359,7 @@ def test_analyst_cannot_list_roles(): def test_admin_can_get_role(): - """'a':'rw' admin can query permissions { getRole(...) }.""" + """'access':'rw' admin can query permissions { getRole(...) }.""" work_dir = tempfile.mkdtemp() with make_server(work_dir).start(): create_role("analyst") @@ -376,7 +377,7 @@ def test_admin_can_get_role(): def test_analyst_cannot_get_role(): - """'a':'ro' user cannot query permissions { getRole(...) }.""" + """'access':'ro' user cannot query permissions { getRole(...) }.""" work_dir = tempfile.mkdtemp() with make_server(work_dir).start(): create_role("analyst") @@ -398,8 +399,8 @@ def test_introspect_only_cannot_access_graph_data(): grant_namespace("analyst", "team", "INTROSPECT") # no READ response = gql(QUERY_TEAM_JIRA, headers=ANALYST_HEADERS) - assert response["data"] is None - assert "Access denied" in response["errors"][0]["message"] + assert "errors" not in response, response + assert response["data"]["graph"] is None def test_no_grant_hidden_from_namespace_and_graph(): @@ -456,10 +457,10 @@ def test_graph_metadata_allowed_with_introspect(): assert "errors" not in response, response assert response["data"]["graphMetadata"]["path"] == "team/jira" - # graph() is still denied — INTROSPECT does not grant data access + # graph() returns null — INTROSPECT does not grant data access response = gql(QUERY_TEAM_JIRA, headers=ANALYST_HEADERS) + assert "errors" not in response, response assert response["data"]["graph"] is None - assert "Access denied" in response["errors"][0]["message"] def test_graph_metadata_allowed_with_read(): @@ -644,16 +645,16 @@ def test_raphtory_client_analyst_can_query_permitted_graph(): def test_raphtory_client_analyst_denied_unpermitted_graph(): - """RaphtoryClient with analyst role is denied access to a graph it has no grant for.""" + """RaphtoryClient with analyst role gets null for a graph it has no grant for.""" work_dir = tempfile.mkdtemp() with make_server(work_dir).start(): gql(CREATE_JIRA) create_role("analyst") - # No grant on jira + # No grant on jira — graph returns null (indistinguishable from "graph not found") client = RaphtoryClient(url=RAPHTORY, token=ANALYST_JWT) - with pytest.raises(Exception, match="Access denied"): - client.query(QUERY_JIRA) + response = client.query(QUERY_JIRA) + assert response["graph"] is None def test_raphtory_client_analyst_write_with_write_grant(): @@ -847,11 +848,10 @@ def test_namespace_introspect_shows_graphs_in_listing(): paths = [g["path"] for g in response["data"]["namespace"]["graphs"]["list"]] assert "team/jira" in paths - # Direct graph access is denied — role has INTROSPECT so the graph name is - # already visible in listings; "Access denied" doesn't leak new information. + # Direct graph access returns null — INTROSPECT does not grant data access. response = gql(QUERY_TEAM_JIRA, headers=ANALYST_HEADERS) + assert "errors" not in response, response assert response["data"]["graph"] is None - assert "Access denied" in response["errors"][0]["message"] def test_namespace_read_exposes_graphs(): @@ -895,8 +895,8 @@ def test_child_namespace_restriction_overrides_parent(): """query { graph(path: "team/restricted/secret") { path } }""", headers=ANALYST_HEADERS, ) + assert "errors" not in response, response assert response["data"]["graph"] is None - assert "Access denied" in response["errors"][0]["message"] # But team/restricted/secret should still appear in the namespace listing response = gql( @@ -1045,7 +1045,7 @@ def test_analyst_cannot_delete_with_graph_write_only(): def test_analyst_cannot_delete_with_read_grant(): - """'a':'ro' user with READ-only grant is denied by deleteGraph.""" + """'access':'ro' user with READ-only grant is denied by deleteGraph.""" work_dir = tempfile.mkdtemp() with make_server(work_dir).start(): gql(CREATE_JIRA) @@ -1058,7 +1058,7 @@ def test_analyst_cannot_delete_with_read_grant(): def test_analyst_can_delete_with_namespace_write(): - """'a':'ro' user with namespace WRITE (cascades to graph WRITE) can delete a graph.""" + """'access':'ro' user with namespace WRITE (cascades to graph WRITE) can delete a graph.""" work_dir = tempfile.mkdtemp() with make_server(work_dir).start(): gql(CREATE_TEAM_JIRA) @@ -1071,7 +1071,7 @@ def test_analyst_can_delete_with_namespace_write(): def test_analyst_cannot_send_graph_without_namespace_write(): - """'a':'ro' user without namespace WRITE is denied by sendGraph.""" + """'access':'ro' user without namespace WRITE is denied by sendGraph.""" work_dir = tempfile.mkdtemp() with make_server(work_dir).start(): create_role("analyst") @@ -1086,7 +1086,7 @@ def test_analyst_cannot_send_graph_without_namespace_write(): def test_analyst_send_graph_passes_auth_with_namespace_write(): - """'a':'ro' user with namespace WRITE passes the auth gate in sendGraph. + """'access':'ro' user with namespace WRITE passes the auth gate in sendGraph. The request fails on graph decoding (invalid data), not on access control — proving the namespace WRITE check is honoured. @@ -1105,6 +1105,44 @@ def test_analyst_send_graph_passes_auth_with_namespace_write(): assert "Access denied" not in response["errors"][0]["message"] +def test_analyst_send_graph_valid_data_with_namespace_write(): + """'access':'ro' user with namespace WRITE can successfully send a valid graph via sendGraph. + + Admin creates a graph and downloads it; analyst with WRITE sends it to a new path. + The graph appears at the new path and its data matches the original. + """ + work_dir = tempfile.mkdtemp() + with make_server(work_dir).start(): + gql(CREATE_JIRA) + # Add a node so the graph has content to verify after the roundtrip + gql( + """query { + updateGraph(path: "jira") { + addNode(time: 1, name: "alice", properties: []) { success } + } + }""" + ) + + # Admin downloads the graph as valid base64 + encoded = gql('query { receiveGraph(path: "jira") }')["data"]["receiveGraph"] + + create_role("analyst") + grant_namespace("analyst", "team", "WRITE") + + # Analyst sends the encoded graph to a new path + response = gql( + f'mutation {{ sendGraph(path: "team/copy", graph: "{encoded}", overwrite: false) }}', + headers=ANALYST_HEADERS, + ) + assert "errors" not in response, response + assert response["data"]["sendGraph"] == "team/copy" + + # Verify the copy exists and contains the expected node + check = gql('query { graph(path: "team/copy") { nodes { list { name } } } }') + names = [n["name"] for n in check["data"]["graph"]["nodes"]["list"]] + assert "alice" in names + + # --- moveGraph policy --- MOVE_TEAM_JIRA = """mutation { moveGraph(path: "team/jira", newPath: "team/jira-moved", overwrite: false) }""" @@ -1156,7 +1194,7 @@ def test_analyst_cannot_move_with_read_grant(): def test_analyst_can_create_namespaced_graph_with_namespace_write(): - """'a':'ro' user with namespace WRITE can create a graph inside that namespace.""" + """'access':'ro' user with namespace WRITE can create a graph inside that namespace.""" work_dir = tempfile.mkdtemp() with make_server(work_dir).start(): create_role("analyst") @@ -1168,7 +1206,7 @@ def test_analyst_can_create_namespaced_graph_with_namespace_write(): def test_analyst_cannot_create_graph_with_namespace_read_only(): - """'a':'ro' user with namespace READ (not WRITE) is denied by newGraph.""" + """'access':'ro' user with namespace READ (not WRITE) is denied by newGraph.""" work_dir = tempfile.mkdtemp() with make_server(work_dir).start(): create_role("analyst") @@ -1183,7 +1221,7 @@ def test_analyst_cannot_create_graph_with_namespace_read_only(): def test_analyst_cannot_access_permissions_query_entry_point(): - """'a':'ro' user is denied at the permissions query entry point, not just the individual ops. + """'access':'ro' user is denied at the permissions query entry point, not just the individual ops. This verifies the entry-point-level admin check added to query { permissions { ... } }. Even with full namespace WRITE, a non-admin JWT cannot reach the permissions resolver. @@ -1202,7 +1240,7 @@ def test_analyst_cannot_access_permissions_query_entry_point(): def test_analyst_cannot_access_permissions_mutation_entry_point(): - """'a':'ro' user is denied at the mutation { permissions { ... } } entry point. + """'access':'ro' user is denied at the mutation { permissions { ... } } entry point. Even with full namespace WRITE, a non-admin JWT is blocked before reaching any op. """ diff --git a/raphtory-graphql/src/model/mod.rs b/raphtory-graphql/src/model/mod.rs index a5f42d9920..cd4ff5c188 100644 --- a/raphtory-graphql/src/model/mod.rs +++ b/raphtory-graphql/src/model/mod.rs @@ -282,29 +282,22 @@ impl QueryRoot { let perms = if let Some(policy) = &data.auth_policy { let role = ctx.data::>().ok().and_then(|r| r.as_deref()); match policy.graph_permissions(ctx, path) { - Err(msg) => { - // Only surface the denial if the role already has INTROSPECT (or higher) - // on the parent namespace — they already know graphs exist there. - // Otherwise return null, indistinguishable from "graph not found". - let ns = parent_namespace(path); - if policy.namespace_permissions(ctx, ns) >= NamespacePermission::Introspect { - warn!( - role = role.unwrap_or(""), - graph = path, - "Access denied by auth policy" - ); - return Err(async_graphql::Error::new(msg)); - } else { - return Ok(None); - } + Err(_) => { + // No permission at all — return null, indistinguishable from "graph not found". + warn!( + role = role.unwrap_or(""), + graph = path, + "Access denied by auth policy" + ); + return Ok(None); } - Ok(perm) => perm - .at_least_read() - .ok_or_else(|| async_graphql::Error::new(format!( - "Access denied: role '{}' has introspect-only access to graph '{path}' — \ - READ is required to access graph data; use graphMetadata(path:) for counts and timestamps, or namespace listings to browse graphs", - role.unwrap_or("") - )))?, + Ok(perm) => match perm.at_least_read() { + // INTROSPECT-only — also return null. async-graphql makes `data` null + // when a nullable field resolver returns Err, so we always use Ok(None) + // to keep `data: {"graph": null}` consistent across all denied cases. + None => return Ok(None), + Some(p) => p, + }, } } else { GraphPermission::Write // no policy: unrestricted From 8baa1760378917e9d5657165b4b9d1bc183e73f0 Mon Sep 17 00:00:00 2001 From: shivamka1 <4599890+shivamka1@users.noreply.github.com> Date: Mon, 30 Mar 2026 12:59:57 +0100 Subject: [PATCH 51/64] GraphAccessFilter is now a OneOfInput enum --- raphtory-graphql/src/model/graph/filtering.rs | 23 ++-- raphtory-graphql/src/model/mod.rs | 126 ++++++++++++------ 2 files changed, 99 insertions(+), 50 deletions(-) diff --git a/raphtory-graphql/src/model/graph/filtering.rs b/raphtory-graphql/src/model/graph/filtering.rs index ce5efd743c..b2bfe9cc8b 100644 --- a/raphtory-graphql/src/model/graph/filtering.rs +++ b/raphtory-graphql/src/model/graph/filtering.rs @@ -1748,12 +1748,19 @@ impl TryFrom for DynView { /// Combined filter input covering all three filter levels (node, edge, graph-level). /// Used by `grantGraphFilteredReadOnly` to express a data-access restriction /// that is transparently applied whenever the role queries the graph. -#[derive(InputObject, Clone, Debug, Serialize, Deserialize)] -pub struct GraphAccessFilter { - #[serde(skip_serializing_if = "Option::is_none")] - pub node: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub edge: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub graph: Option, +/// Use `and` / `or` to compose multiple sub-filters. +#[derive(OneOfInput, Clone, Debug, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum GraphAccessFilter { + /// Filter by node properties, fields, or temporal state. + Node(GqlNodeFilter), + /// Filter by edge properties, source/destination, or temporal state. + Edge(GqlEdgeFilter), + /// Apply a graph-level view (window, snapshot, layer restriction, …). + Graph(GqlGraphFilter), + /// All sub-filters must pass (intersection). + And(Vec), + /// At least one sub-filter must pass (union within each filter type; + /// cross-type sub-filters are applied as independent restrictions). + Or(Vec), } diff --git a/raphtory-graphql/src/model/mod.rs b/raphtory-graphql/src/model/mod.rs index cd4ff5c188..85d2cb4aff 100644 --- a/raphtory-graphql/src/model/mod.rs +++ b/raphtory-graphql/src/model/mod.rs @@ -5,7 +5,7 @@ use crate::{ model::{ graph::{ collection::GqlCollection, - filtering::{GqlEdgeFilter, GqlGraphFilter, GqlNodeFilter, GraphAccessFilter}, + filtering::{GqlEdgeFilter, GqlNodeFilter, GraphAccessFilter}, graph::GqlGraph, index::IndexSpecInput, meta_graph::MetaGraph, @@ -45,6 +45,8 @@ use raphtory::{ use std::{ error::Error, fmt::{Display, Formatter}, + future::Future, + pin::Pin, sync::Arc, }; use tracing::warn; @@ -139,51 +141,91 @@ fn require_at_least_read( /// Applies a stored data filter (serialised as `serde_json::Value` with optional `node`, `edge`, /// `graph` keys) to a `DynamicGraph`, returning a new filtered view. -async fn apply_graph_filter( +fn apply_graph_filter( mut graph: DynamicGraph, filter: GraphAccessFilter, -) -> async_graphql::Result { - use raphtory::db::graph::views::filter::model::{ - edge_filter::CompositeEdgeFilter, node_filter::CompositeNodeFilter, DynView, - }; - - if let Some(gql_filter) = filter.node { - let raphtory_filter = CompositeNodeFilter::try_from(gql_filter) - .map_err(|e| async_graphql::Error::new(format!("node filter conversion: {e}")))?; - graph = blocking_compute({ - let g = graph.clone(); - move || g.filter(raphtory_filter) - }) - .await - .map_err(|e| async_graphql::Error::new(format!("node filter apply: {e}")))? - .into_dynamic(); - } - - if let Some(gql_filter) = filter.edge { - let raphtory_filter = CompositeEdgeFilter::try_from(gql_filter) - .map_err(|e| async_graphql::Error::new(format!("edge filter conversion: {e}")))?; - graph = blocking_compute({ - let g = graph.clone(); - move || g.filter(raphtory_filter) - }) - .await - .map_err(|e| async_graphql::Error::new(format!("edge filter apply: {e}")))? - .into_dynamic(); - } +) -> Pin> + Send>> { + Box::pin(async move { + use raphtory::db::graph::views::filter::model::{ + edge_filter::CompositeEdgeFilter, node_filter::CompositeNodeFilter, DynView, + }; - if let Some(gql_filter) = filter.graph { - let dyn_view = DynView::try_from(gql_filter) - .map_err(|e| async_graphql::Error::new(format!("graph filter conversion: {e}")))?; - graph = blocking_compute({ - let g = graph.clone(); - move || g.filter(dyn_view) - }) - .await - .map_err(|e| async_graphql::Error::new(format!("graph filter apply: {e}")))? - .into_dynamic(); - } + match filter { + GraphAccessFilter::Node(gql_filter) => { + let raphtory_filter = CompositeNodeFilter::try_from(gql_filter) + .map_err(|e| async_graphql::Error::new(format!("node filter conversion: {e}")))?; + graph = blocking_compute({ + let g = graph.clone(); + move || g.filter(raphtory_filter) + }) + .await + .map_err(|e| async_graphql::Error::new(format!("node filter apply: {e}")))? + .into_dynamic(); + } + GraphAccessFilter::Edge(gql_filter) => { + let raphtory_filter = CompositeEdgeFilter::try_from(gql_filter) + .map_err(|e| async_graphql::Error::new(format!("edge filter conversion: {e}")))?; + graph = blocking_compute({ + let g = graph.clone(); + move || g.filter(raphtory_filter) + }) + .await + .map_err(|e| async_graphql::Error::new(format!("edge filter apply: {e}")))? + .into_dynamic(); + } + GraphAccessFilter::Graph(gql_filter) => { + let dyn_view = DynView::try_from(gql_filter) + .map_err(|e| async_graphql::Error::new(format!("graph filter conversion: {e}")))?; + graph = blocking_compute({ + let g = graph.clone(); + move || g.filter(dyn_view) + }) + .await + .map_err(|e| async_graphql::Error::new(format!("graph filter apply: {e}")))? + .into_dynamic(); + } + GraphAccessFilter::And(filters) => { + for f in filters { + graph = apply_graph_filter(graph, f).await?; + } + } + GraphAccessFilter::Or(filters) => { + // Group same-type sub-filters and combine with native Or; + // cross-type sub-filters are applied as independent restrictions. + let mut node_fs: Vec = vec![]; + let mut edge_fs: Vec = vec![]; + let mut rest: Vec = vec![]; + for f in filters { + match f { + GraphAccessFilter::Node(n) => node_fs.push(n), + GraphAccessFilter::Edge(e) => edge_fs.push(e), + other => rest.push(other), + } + } + if !node_fs.is_empty() { + let combined = if node_fs.len() == 1 { + node_fs.pop().unwrap() + } else { + GqlNodeFilter::Or(node_fs) + }; + graph = apply_graph_filter(graph, GraphAccessFilter::Node(combined)).await?; + } + if !edge_fs.is_empty() { + let combined = if edge_fs.len() == 1 { + edge_fs.pop().unwrap() + } else { + GqlEdgeFilter::Or(edge_fs) + }; + graph = apply_graph_filter(graph, GraphAccessFilter::Edge(combined)).await?; + } + for f in rest { + graph = apply_graph_filter(graph, f).await?; + } + } + } - Ok(graph) + Ok(graph) + }) } /// Returns the namespace portion of a graph path: everything before the last `/`. From 09c8d69534003bdb049402a2fe7bc99e753a81f2 Mon Sep 17 00:00:00 2001 From: shivamka1 <4599890+shivamka1@users.noreply.github.com> Date: Mon, 30 Mar 2026 13:23:10 +0100 Subject: [PATCH 52/64] ref --- raphtory-graphql/src/model/graph/timeindex.rs | 62 +------------------ 1 file changed, 2 insertions(+), 60 deletions(-) diff --git a/raphtory-graphql/src/model/graph/timeindex.rs b/raphtory-graphql/src/model/graph/timeindex.rs index 7cc979dafc..6a9375c300 100644 --- a/raphtory-graphql/src/model/graph/timeindex.rs +++ b/raphtory-graphql/src/model/graph/timeindex.rs @@ -5,13 +5,13 @@ use raphtory_api::core::{ storage::timeindex::{AsTime, EventTime}, utils::time::{IntoTime, TryIntoTime}, }; -use serde::{Deserialize, Deserializer, Serialize, Serializer}; +use serde::{Deserialize, Serialize}; /// Input for primary time component. Expects Int, DateTime formatted String, or Object { timestamp, eventId } /// where the timestamp is either an Int or a DateTime formatted String, and eventId is a non-negative Int. /// Valid string formats are RFC3339, RFC2822, %Y-%m-%d, %Y-%m-%dT%H:%M:%S%.3f, %Y-%m-%dT%H:%M:%S%, /// %Y-%m-%d %H:%M:%S%.3f and %Y-%m-%d %H:%M:%S%. -#[derive(Scalar, Clone, Debug)] +#[derive(Scalar, Clone, Debug, Serialize, Deserialize)] #[graphql(name = "TimeInput")] pub struct GqlTimeInput(pub EventTime); @@ -87,64 +87,6 @@ impl IntoTime for GqlTimeInput { } } -impl Serialize for GqlTimeInput { - fn serialize(&self, serializer: S) -> Result { - // Stored as raw millisecond timestamp (matches how GQL sends an Int time) - self.0.t().serialize(serializer) - } -} - -impl<'de> Deserialize<'de> for GqlTimeInput { - fn deserialize>(deserializer: D) -> Result { - use serde::de::Error; - let v = serde_json::Value::deserialize(deserializer)?; - match v { - serde_json::Value::Number(n) => { - let millis = n - .as_i64() - .ok_or_else(|| D::Error::custom("time must be an i64 millisecond timestamp"))?; - Ok(GqlTimeInput(EventTime::start(millis))) - } - serde_json::Value::String(s) => s - .try_into_time() - .map(|t| GqlTimeInput(t.set_event_id(0))) - .map_err(|e| D::Error::custom(e.to_string())), - serde_json::Value::Object(obj) => { - let ts_val = obj - .get("timestamp") - .or_else(|| obj.get("time")) - .ok_or_else(|| D::Error::custom("time object must contain 'timestamp'"))?; - let ts = match ts_val { - serde_json::Value::Number(n) => n - .as_i64() - .ok_or_else(|| D::Error::custom("timestamp must be i64"))?, - serde_json::Value::String(s) => s - .try_into_time() - .map(|t| t.t()) - .map_err(|e| D::Error::custom(e.to_string()))?, - _ => return Err(D::Error::custom("timestamp must be number or string")), - }; - let event_id = if let Some(id_val) = obj.get("eventId").or_else(|| obj.get("id")) { - match id_val { - serde_json::Value::Number(n) => n - .as_u64() - .and_then(|u| usize::try_from(u).ok()) - .ok_or_else(|| { - D::Error::custom("eventId must be a non-negative integer") - })?, - _ => return Err(D::Error::custom("eventId must be a number")), - } - } else { - 0 - }; - Ok(GqlTimeInput(EventTime::new(ts, event_id))) - } - _ => Err(D::Error::custom( - "time must be a number (millis), string (datetime), or object {timestamp, eventId}", - )), - } - } -} pub fn dt_format_str_is_valid(fmt_str: &str) -> bool { !StrftimeItems::new(fmt_str).any(|it| matches!(it, Item::Error)) From 84679e3bb77798d3261f0ac938a24e8db0345122 Mon Sep 17 00:00:00 2001 From: shivamka1 <4599890+shivamka1@users.noreply.github.com> Date: Tue, 31 Mar 2026 12:31:58 +0100 Subject: [PATCH 53/64] remove default PermissionsPlugin from schema; add conditional RBAC registration via AtomicBool entrypoints and OneOfInput GraphAccessFilter with And/Or composability --- python/tests/test_permissions.py | 328 ++++++++++++++++++ raphtory-graphql/src/model/mod.rs | 17 +- raphtory-graphql/src/model/plugins/mod.rs | 3 + .../src/model/plugins/operation.rs | 42 --- .../model/plugins/permissions_entrypoint.rs | 59 ++++ .../src/model/plugins/permissions_plugin.rs | 31 +- 6 files changed, 410 insertions(+), 70 deletions(-) create mode 100644 raphtory-graphql/src/model/plugins/permissions_entrypoint.rs diff --git a/python/tests/test_permissions.py b/python/tests/test_permissions.py index 13399d8a1a..1040ded5f2 100644 --- a/python/tests/test_permissions.py +++ b/python/tests/test_permissions.py @@ -831,6 +831,334 @@ def test_analyst_sees_only_graph_filter_window(): }, f"expected all nodes for admin, got {admin_names}" +# --- Filter composition (And / Or) tests --- + + +def test_filter_and_node_node(): + """And([node, node]): both node predicates must match (intersection).""" + work_dir = tempfile.mkdtemp() + with make_server(work_dir).start(): + gql(CREATE_JIRA) + for name, region, role in [ + ("alice", "us-west", "admin"), + ("bob", "us-east", "admin"), + ("carol", "us-west", "user"), + ]: + resp = gql(f"""query {{ + updateGraph(path: "jira") {{ + addNode( + time: 1, name: "{name}", + properties: [ + {{ key: "region", value: {{ str: "{region}" }} }}, + {{ key: "role", value: {{ str: "{role}" }} }} + ] + ) {{ success }} + }} + }}""") + assert resp["data"]["updateGraph"]["addNode"]["success"] is True, resp + + create_role("analyst") + # region=us-west AND role=admin → only alice + grant_graph_filtered_read_only( + "analyst", + "jira", + '{ and: [' + '{ node: { property: { name: "region", where: { eq: { str: "us-west" } } } } },' + '{ node: { property: { name: "role", where: { eq: { str: "admin" } } } } }' + '] }', + ) + + QUERY_NODES = 'query { graph(path: "jira") { nodes { list { name } } } }' + analyst_names = { + n["name"] + for n in gql(QUERY_NODES, headers=ANALYST_HEADERS)["data"]["graph"]["nodes"]["list"] + } + assert analyst_names == {"alice"}, f"expected only alice, got {analyst_names}" + + +def test_filter_and_edge_edge(): + """And([edge, edge]): both edge predicates must match (intersection).""" + work_dir = tempfile.mkdtemp() + with make_server(work_dir).start(): + gql(CREATE_JIRA) + for src, dst, weight, kind in [ + ("a", "b", 3, "follows"), + ("b", "c", 7, "mentions"), + ("a", "c", 9, "follows"), + ]: + resp = gql(f"""query {{ + updateGraph(path: "jira") {{ + addEdge( + time: 1, src: "{src}", dst: "{dst}", + properties: [ + {{ key: "weight", value: {{ i64: {weight} }} }}, + {{ key: "kind", value: {{ str: "{kind}" }} }} + ] + ) {{ success }} + }} + }}""") + assert resp["data"]["updateGraph"]["addEdge"]["success"] is True, resp + + create_role("analyst") + # weight >= 5 AND kind=follows → only (a,c) weight=9 follows + grant_graph_filtered_read_only( + "analyst", + "jira", + '{ and: [' + '{ edge: { property: { name: "weight", where: { ge: { i64: 5 } } } } },' + '{ edge: { property: { name: "kind", where: { eq: { str: "follows" } } } } }' + '] }', + ) + + QUERY_EDGES = 'query { graph(path: "jira") { edges { list { src { name } dst { name } } } } }' + analyst_edges = { + (e["src"]["name"], e["dst"]["name"]) + for e in gql(QUERY_EDGES, headers=ANALYST_HEADERS)["data"]["graph"]["edges"]["list"] + } + assert analyst_edges == {("a", "c")}, f"expected only (a,c), got {analyst_edges}" + + +def test_filter_and_graph_graph(): + """And([graph, graph]): two graph-level views intersect (sequential narrowing).""" + work_dir = tempfile.mkdtemp() + with make_server(work_dir).start(): + gql(CREATE_JIRA) + for name, t in [("early", 1), ("middle", 10), ("late", 20)]: + resp = gql(f"""query {{ + updateGraph(path: "jira") {{ + addNode(time: {t}, name: "{name}") {{ success }} + }} + }}""") + assert resp["data"]["updateGraph"]["addNode"]["success"] is True, resp + + create_role("analyst") + # window [1,15) ∩ window [5,25) → effective [5,15) → only middle (t=10) + grant_graph_filtered_read_only( + "analyst", + "jira", + '{ and: [' + '{ graph: { window: { start: 1, end: 15 } } },' + '{ graph: { window: { start: 5, end: 25 } } }' + '] }', + ) + + QUERY_NODES = 'query { graph(path: "jira") { nodes { list { name } } } }' + analyst_names = { + n["name"] + for n in gql(QUERY_NODES, headers=ANALYST_HEADERS)["data"]["graph"]["nodes"]["list"] + } + assert analyst_names == {"middle"}, f"expected only middle, got {analyst_names}" + + +def test_filter_and_node_edge(): + """And([node, edge]): node filter applied first restricts nodes (and their edges), then edge filter further restricts.""" + work_dir = tempfile.mkdtemp() + with make_server(work_dir).start(): + gql(CREATE_JIRA) + for name, region in [("alice", "us-west"), ("bob", "us-east"), ("carol", "us-west")]: + resp = gql(f"""query {{ + updateGraph(path: "jira") {{ + addNode( + time: 1, name: "{name}", + properties: [{{ key: "region", value: {{ str: "{region}" }} }}] + ) {{ success }} + }} + }}""") + assert resp["data"]["updateGraph"]["addNode"]["success"] is True, resp + + for src, dst, weight in [("alice", "bob", 3), ("alice", "carol", 7), ("bob", "carol", 9)]: + resp = gql(f"""query {{ + updateGraph(path: "jira") {{ + addEdge( + time: 1, src: "{src}", dst: "{dst}", + properties: [{{ key: "weight", value: {{ i64: {weight} }} }}] + ) {{ success }} + }} + }}""") + assert resp["data"]["updateGraph"]["addEdge"]["success"] is True, resp + + create_role("analyst") + # Node(us-west) applied first: bob hidden, bob's edges hidden. + # Then Edge(weight≥5): of remaining edges (alice→carol weight=7), only alice→carol passes. + grant_graph_filtered_read_only( + "analyst", + "jira", + '{ and: [' + '{ node: { property: { name: "region", where: { eq: { str: "us-west" } } } } },' + '{ edge: { property: { name: "weight", where: { ge: { i64: 5 } } } } }' + '] }', + ) + + QUERY_NODES = 'query { graph(path: "jira") { nodes { list { name } } } }' + QUERY_EDGES = 'query { graph(path: "jira") { edges { list { src { name } dst { name } } } } }' + + analyst_names = { + n["name"] + for n in gql(QUERY_NODES, headers=ANALYST_HEADERS)["data"]["graph"]["nodes"]["list"] + } + assert analyst_names == {"alice", "carol"}, f"expected us-west nodes, got {analyst_names}" + + analyst_edges = { + (e["src"]["name"], e["dst"]["name"]) + for e in gql(QUERY_EDGES, headers=ANALYST_HEADERS)["data"]["graph"]["edges"]["list"] + } + # Sequential And: Node(us-west) hides bob and bob's edges, then Edge(weight≥5) keeps alice→carol (7). + assert analyst_edges == { + ("alice", "carol"), + }, f"expected only (alice,carol), got {analyst_edges}" + + +def test_filter_and_node_graph(): + """And([node, graph]): node property filter combined with a graph window.""" + work_dir = tempfile.mkdtemp() + with make_server(work_dir).start(): + gql(CREATE_JIRA) + for name, region, t in [ + ("alice", "us-west", 1), + ("bob", "us-west", 10), + ("carol", "us-east", 10), + ]: + resp = gql(f"""query {{ + updateGraph(path: "jira") {{ + addNode( + time: {t}, name: "{name}", + properties: [{{ key: "region", value: {{ str: "{region}" }} }}] + ) {{ success }} + }} + }}""") + assert resp["data"]["updateGraph"]["addNode"]["success"] is True, resp + + create_role("analyst") + # window [5,15): bob(t=10) + carol(t=10); then node us-west → only bob + grant_graph_filtered_read_only( + "analyst", + "jira", + '{ and: [' + '{ graph: { window: { start: 5, end: 15 } } },' + '{ node: { property: { name: "region", where: { eq: { str: "us-west" } } } } }' + '] }', + ) + + QUERY_NODES = 'query { graph(path: "jira") { nodes { list { name } } } }' + analyst_names = { + n["name"] + for n in gql(QUERY_NODES, headers=ANALYST_HEADERS)["data"]["graph"]["nodes"]["list"] + } + assert analyst_names == {"bob"}, f"expected only bob, got {analyst_names}" + + +def test_filter_and_edge_graph(): + """And([edge, graph]): edge property filter combined with a graph window.""" + work_dir = tempfile.mkdtemp() + with make_server(work_dir).start(): + gql(CREATE_JIRA) + for src, dst, weight, t in [ + ("a", "b", 3, 1), + ("b", "c", 7, 10), + ("a", "c", 9, 20), + ]: + resp = gql(f"""query {{ + updateGraph(path: "jira") {{ + addEdge( + time: {t}, src: "{src}", dst: "{dst}", + properties: [{{ key: "weight", value: {{ i64: {weight} }} }}] + ) {{ success }} + }} + }}""") + assert resp["data"]["updateGraph"]["addEdge"]["success"] is True, resp + + create_role("analyst") + # window [5,15): b→c(t=10); then edge weight≥5 → b→c(weight=7) passes + grant_graph_filtered_read_only( + "analyst", + "jira", + '{ and: [' + '{ graph: { window: { start: 5, end: 15 } } },' + '{ edge: { property: { name: "weight", where: { ge: { i64: 5 } } } } }' + '] }', + ) + + QUERY_EDGES = 'query { graph(path: "jira") { edges { list { src { name } dst { name } } } } }' + analyst_edges = { + (e["src"]["name"], e["dst"]["name"]) + for e in gql(QUERY_EDGES, headers=ANALYST_HEADERS)["data"]["graph"]["edges"]["list"] + } + assert analyst_edges == {("b", "c")}, f"expected only (b,c), got {analyst_edges}" + + +def test_filter_or_node_node(): + """Or([node, node]): nodes matching either predicate are visible (union).""" + work_dir = tempfile.mkdtemp() + with make_server(work_dir).start(): + gql(CREATE_JIRA) + for name, region in [("alice", "us-west"), ("bob", "us-east"), ("carol", "eu")]: + resp = gql(f"""query {{ + updateGraph(path: "jira") {{ + addNode( + time: 1, name: "{name}", + properties: [{{ key: "region", value: {{ str: "{region}" }} }}] + ) {{ success }} + }} + }}""") + assert resp["data"]["updateGraph"]["addNode"]["success"] is True, resp + + create_role("analyst") + # us-west OR us-east → alice + bob; carol(eu) filtered out + grant_graph_filtered_read_only( + "analyst", + "jira", + '{ or: [' + '{ node: { property: { name: "region", where: { eq: { str: "us-west" } } } } },' + '{ node: { property: { name: "region", where: { eq: { str: "us-east" } } } } }' + '] }', + ) + + QUERY_NODES = 'query { graph(path: "jira") { nodes { list { name } } } }' + analyst_names = { + n["name"] + for n in gql(QUERY_NODES, headers=ANALYST_HEADERS)["data"]["graph"]["nodes"]["list"] + } + assert analyst_names == {"alice", "bob"}, f"expected alice+bob, got {analyst_names}" + + +def test_filter_or_edge_edge(): + """Or([edge, edge]): edges matching either predicate are visible (union).""" + work_dir = tempfile.mkdtemp() + with make_server(work_dir).start(): + gql(CREATE_JIRA) + for src, dst, weight in [("a", "b", 3), ("b", "c", 7), ("a", "c", 9)]: + resp = gql(f"""query {{ + updateGraph(path: "jira") {{ + addEdge( + time: 1, src: "{src}", dst: "{dst}", + properties: [{{ key: "weight", value: {{ i64: {weight} }} }}] + ) {{ success }} + }} + }}""") + assert resp["data"]["updateGraph"]["addEdge"]["success"] is True, resp + + create_role("analyst") + # weight=3 OR weight=9 → (a,b) + (a,c); (b,c) weight=7 filtered out + grant_graph_filtered_read_only( + "analyst", + "jira", + '{ or: [' + '{ edge: { property: { name: "weight", where: { eq: { i64: 3 } } } } },' + '{ edge: { property: { name: "weight", where: { eq: { i64: 9 } } } } }' + '] }', + ) + + QUERY_EDGES = 'query { graph(path: "jira") { edges { list { src { name } dst { name } } } } }' + analyst_edges = { + (e["src"]["name"], e["dst"]["name"]) + for e in gql(QUERY_EDGES, headers=ANALYST_HEADERS)["data"]["graph"]["edges"]["list"] + } + assert analyst_edges == { + ("a", "b"), + ("a", "c"), + }, f"expected (a,b)+(a,c), got {analyst_edges}" + + # --- Namespace permission tests --- diff --git a/raphtory-graphql/src/model/mod.rs b/raphtory-graphql/src/model/mod.rs index 85d2cb4aff..f560905155 100644 --- a/raphtory-graphql/src/model/mod.rs +++ b/raphtory-graphql/src/model/mod.rs @@ -16,8 +16,8 @@ use crate::{ }, plugins::{ mutation_plugin::MutationPlugin, - permissions_plugin::{PermissionsPlugin, PermissionsQueryPlugin}, query_plugin::QueryPlugin, + PermissionsEntrypointMut, PermissionsEntrypointQuery, }, }, paths::{ExistingGraphFolder, ValidGraphPaths, ValidWriteableGraphFolder}, @@ -487,12 +487,6 @@ impl QueryRoot { String::from(version()) } - /// Returns the permissions namespace for inspecting roles and access policies (admin only). - async fn permissions<'a>(ctx: &Context<'a>) -> Result { - ctx.require_jwt_write_access() - .map_err(|_| async_graphql::Error::new("Access denied: write access required"))?; - Ok(PermissionsQueryPlugin::default()) - } } #[derive(MutationRoot)] @@ -508,13 +502,6 @@ impl Mut { MutationPlugin::default() } - /// Returns the permissions namespace for managing roles and access policies. - async fn permissions<'a>(ctx: &Context<'a>) -> Result { - ctx.require_jwt_write_access() - .map_err(|_| async_graphql::Error::new("Access denied: write access required"))?; - Ok(PermissionsPlugin::default()) - } - /// Delete graph from a path on the server. // If namespace is not provided, it will be set to the current working directory. async fn delete_graph<'a>(ctx: &Context<'a>, path: String) -> Result { @@ -716,4 +703,4 @@ impl Mut { } #[derive(App)] -pub struct App(QueryRoot, MutRoot, Mut); +pub struct App(QueryRoot, MutRoot, Mut, PermissionsEntrypointMut, PermissionsEntrypointQuery); diff --git a/raphtory-graphql/src/model/plugins/mod.rs b/raphtory-graphql/src/model/plugins/mod.rs index cc7d3f6704..8cab98fafa 100644 --- a/raphtory-graphql/src/model/plugins/mod.rs +++ b/raphtory-graphql/src/model/plugins/mod.rs @@ -7,10 +7,13 @@ pub mod graph_algorithm_plugin; pub mod mutation_entry_point; pub mod mutation_plugin; pub mod operation; +pub mod permissions_entrypoint; pub mod permissions_plugin; pub mod query_entry_point; pub mod query_plugin; +pub use permissions_entrypoint::{PermissionsEntrypointMut, PermissionsEntrypointQuery}; + pub type RegisterFunction = Box (Registry, Object) + Send>; /// Register an operation into the `PermissionsPlugin` entry point (mutation root). diff --git a/raphtory-graphql/src/model/plugins/operation.rs b/raphtory-graphql/src/model/plugins/operation.rs index 26aefa8cb4..43e7ae51f7 100644 --- a/raphtory-graphql/src/model/plugins/operation.rs +++ b/raphtory-graphql/src/model/plugins/operation.rs @@ -37,48 +37,6 @@ pub trait Operation<'a, A: Send + Sync + 'static> { } } -pub(crate) struct NoOpPermissions; - -impl<'a> Operation<'a, super::permissions_plugin::PermissionsPlugin> for NoOpPermissions { - type OutputType = String; - - fn output_type() -> TypeRef { - TypeRef::named_nn(TypeRef::STRING) - } - - fn args<'b>() -> Vec<(&'b str, TypeRef)> { - vec![] - } - - fn apply<'b>( - _entry_point: &super::permissions_plugin::PermissionsPlugin, - _ctx: ResolverContext<'b>, - ) -> BoxFuture<'b, FieldResult>>> { - Box::pin(async move { Ok(Some(FieldValue::value("no-op".to_owned()))) }) - } -} - -pub(crate) struct NoOpPermissionsQuery; - -impl<'a> Operation<'a, super::permissions_plugin::PermissionsQueryPlugin> for NoOpPermissionsQuery { - type OutputType = String; - - fn output_type() -> TypeRef { - TypeRef::named_nn(TypeRef::STRING) - } - - fn args<'b>() -> Vec<(&'b str, TypeRef)> { - vec![] - } - - fn apply<'b>( - _entry_point: &super::permissions_plugin::PermissionsQueryPlugin, - _ctx: ResolverContext<'b>, - ) -> BoxFuture<'b, FieldResult>>> { - Box::pin(async move { Ok(Some(FieldValue::value("no-op".to_owned()))) }) - } -} - pub(crate) struct NoOpMutation; impl<'a> Operation<'a, MutationPlugin> for NoOpMutation { diff --git a/raphtory-graphql/src/model/plugins/permissions_entrypoint.rs b/raphtory-graphql/src/model/plugins/permissions_entrypoint.rs new file mode 100644 index 0000000000..1cff80d53c --- /dev/null +++ b/raphtory-graphql/src/model/plugins/permissions_entrypoint.rs @@ -0,0 +1,59 @@ +use super::permissions_plugin::{ + PermissionsPlugin, PermissionsQueryPlugin, PERMISSIONS_MUT_ENTRYPOINT, + PERMISSIONS_QRY_ENTRYPOINT, +}; +use crate::auth::require_jwt_write_access_dynamic; +use async_graphql::dynamic::{Field, FieldFuture, FieldValue, TypeRef}; +use dynamic_graphql::internal::{Register, Registry}; +use std::sync::atomic::Ordering; + +/// Conditionally adds the `permissions` field to the root Mutation type. +/// Only registers when `register_permissions_entrypoint()` has been called +/// (i.e., when RBAC is configured via `raphtory-auth::init()`). +pub struct PermissionsEntrypointMut; + +/// Conditionally adds the `permissions` field to the root Query type. +/// Only registers when `register_permissions_query_entrypoint()` has been called. +pub struct PermissionsEntrypointQuery; + +impl Register for PermissionsEntrypointMut { + fn register(registry: Registry) -> Registry { + if !PERMISSIONS_MUT_ENTRYPOINT.load(Ordering::SeqCst) { + return registry; + } + let registry = registry.register::(); + registry.update_object("MutRoot", "PermissionsEntrypointMut", |obj| { + obj.field(Field::new( + "permissions", + TypeRef::named_nn("PermissionsPlugin"), + |ctx| { + FieldFuture::new(async move { + require_jwt_write_access_dynamic(&ctx)?; + Ok(Some(FieldValue::owned_any(PermissionsPlugin::default()))) + }) + }, + )) + }) + } +} + +impl Register for PermissionsEntrypointQuery { + fn register(registry: Registry) -> Registry { + if !PERMISSIONS_QRY_ENTRYPOINT.load(Ordering::SeqCst) { + return registry; + } + let registry = registry.register::(); + registry.update_object("QueryRoot", "PermissionsEntrypointQuery", |obj| { + obj.field(Field::new( + "permissions", + TypeRef::named_nn("PermissionsQueryPlugin"), + |ctx| { + FieldFuture::new(async move { + require_jwt_write_access_dynamic(&ctx)?; + Ok(Some(FieldValue::owned_any(PermissionsQueryPlugin::default()))) + }) + }, + )) + }) + } +} diff --git a/raphtory-graphql/src/model/plugins/permissions_plugin.rs b/raphtory-graphql/src/model/plugins/permissions_plugin.rs index 2a6e084363..187cb9f41a 100644 --- a/raphtory-graphql/src/model/plugins/permissions_plugin.rs +++ b/raphtory-graphql/src/model/plugins/permissions_plugin.rs @@ -1,16 +1,27 @@ -use super::{ - operation::{NoOpPermissions, NoOpPermissionsQuery, Operation}, - RegisterFunction, -}; +use super::RegisterFunction; use crate::model::plugins::entry_point::EntryPoint; use async_graphql::{dynamic::FieldValue, indexmap::IndexMap, Context}; use dynamic_graphql::internal::{OutputTypeName, Register, Registry, ResolveOwned, TypeName}; use once_cell::sync::Lazy; use std::{ borrow::Cow, - sync::{Mutex, MutexGuard}, + sync::{ + atomic::{AtomicBool, Ordering}, + Mutex, MutexGuard, + }, }; +pub(crate) static PERMISSIONS_MUT_ENTRYPOINT: AtomicBool = AtomicBool::new(false); +pub(crate) static PERMISSIONS_QRY_ENTRYPOINT: AtomicBool = AtomicBool::new(false); + +pub fn register_permissions_entrypoint() { + PERMISSIONS_MUT_ENTRYPOINT.store(true, Ordering::SeqCst); +} + +pub fn register_permissions_query_entrypoint() { + PERMISSIONS_QRY_ENTRYPOINT.store(true, Ordering::SeqCst); +} + pub static PERMISSIONS_MUTATIONS: Lazy>> = Lazy::new(|| Mutex::new(IndexMap::new())); @@ -22,10 +33,7 @@ pub struct PermissionsPlugin; impl<'a> EntryPoint<'a> for PermissionsPlugin { fn predefined_operations() -> IndexMap<&'static str, RegisterFunction> { - IndexMap::from([( - "NoOps", - Box::new(NoOpPermissions::register_operation) as RegisterFunction, - )]) + IndexMap::new() } fn lock_plugins() -> MutexGuard<'static, IndexMap> { @@ -59,10 +67,7 @@ pub struct PermissionsQueryPlugin; impl<'a> EntryPoint<'a> for PermissionsQueryPlugin { fn predefined_operations() -> IndexMap<&'static str, RegisterFunction> { - IndexMap::from([( - "NoOps", - Box::new(NoOpPermissionsQuery::register_operation) as RegisterFunction, - )]) + IndexMap::new() } fn lock_plugins() -> MutexGuard<'static, IndexMap> { From 0b970e296bce46a78af206470546132feae23409 Mon Sep 17 00:00:00 2001 From: shivamka1 <4599890+shivamka1@users.noreply.github.com> Date: Tue, 31 Mar 2026 12:59:06 +0100 Subject: [PATCH 54/64] raphtory-graphql/src/main.rs deleted. raphtory-server is now the single binary entry point. --- raphtory-graphql/src/main.rs | 6 ------ 1 file changed, 6 deletions(-) delete mode 100644 raphtory-graphql/src/main.rs diff --git a/raphtory-graphql/src/main.rs b/raphtory-graphql/src/main.rs deleted file mode 100644 index 9a15f7580b..0000000000 --- a/raphtory-graphql/src/main.rs +++ /dev/null @@ -1,6 +0,0 @@ -use std::io::Result as IoResult; - -#[tokio::main] -async fn main() -> IoResult<()> { - raphtory_graphql::cli::cli().await -} From 67d9935a7dc43220dea51513ced15f71a050deff Mon Sep 17 00:00:00 2001 From: shivamka1 <4599890+shivamka1@users.noreply.github.com> Date: Tue, 31 Mar 2026 13:20:39 +0100 Subject: [PATCH 55/64] fix: permission denial in graph/graphMetadata returns null not error, remove unused GqlGraph::permission field --- raphtory-graphql/src/model/graph/graph.rs | 16 ------- raphtory-graphql/src/model/mod.rs | 57 +++++++++-------------- 2 files changed, 22 insertions(+), 51 deletions(-) diff --git a/raphtory-graphql/src/model/graph/graph.rs b/raphtory-graphql/src/model/graph/graph.rs index 5965f49242..927379189c 100644 --- a/raphtory-graphql/src/model/graph/graph.rs +++ b/raphtory-graphql/src/model/graph/graph.rs @@ -1,5 +1,4 @@ use crate::{ - auth_policy::GraphPermission, data::Data, graph::GraphWithVectors, model::{ @@ -63,7 +62,6 @@ use std::{ pub(crate) struct GqlGraph { path: ExistingGraphFolder, graph: DynamicGraph, - permission: GraphPermission, } impl From for GqlGraph { @@ -77,19 +75,6 @@ impl GqlGraph { Self { path, graph: graph.into_dynamic(), - permission: GraphPermission::Write, - } - } - - pub fn new_with_permissions( - path: ExistingGraphFolder, - graph: G, - permission: GraphPermission, - ) -> Self { - Self { - path, - graph: graph.into_dynamic(), - permission, } } @@ -101,7 +86,6 @@ impl GqlGraph { Self { path: self.path.clone(), graph: graph_operation(&self.graph).into_dynamic(), - permission: self.permission.clone(), } } } diff --git a/raphtory-graphql/src/model/mod.rs b/raphtory-graphql/src/model/mod.rs index f560905155..3cc164257e 100644 --- a/raphtory-graphql/src/model/mod.rs +++ b/raphtory-graphql/src/model/mod.rs @@ -126,13 +126,20 @@ fn require_at_least_read( } } Ok(perm) => { - return Ok(perm.at_least_read().ok_or_else(|| { - async_graphql::Error::new(format!( + if let Some(p) = perm.at_least_read() { + return Ok(p); + } else { + warn!( + role = role.unwrap_or(""), + graph = path, + "Introspect-only access — graph() denied; use graphMetadata() instead" + ); + return Err(async_graphql::Error::new(format!( "Access denied: role '{}' has introspect-only access to graph '{path}' — \ use graphMetadata(path:) for counts and timestamps, or namespace listings to browse graphs", role.unwrap_or("") - )) - })?); + ))); + } } } } @@ -321,28 +328,12 @@ impl QueryRoot { async fn graph<'a>(ctx: &Context<'a>, path: &str) -> Result> { let data = ctx.data_unchecked::(); - let perms = if let Some(policy) = &data.auth_policy { - let role = ctx.data::>().ok().and_then(|r| r.as_deref()); - match policy.graph_permissions(ctx, path) { - Err(_) => { - // No permission at all — return null, indistinguishable from "graph not found". - warn!( - role = role.unwrap_or(""), - graph = path, - "Access denied by auth policy" - ); - return Ok(None); - } - Ok(perm) => match perm.at_least_read() { - // INTROSPECT-only — also return null. async-graphql makes `data` null - // when a nullable field resolver returns Err, so we always use Ok(None) - // to keep `data: {"graph": null}` consistent across all denied cases. - None => return Ok(None), - Some(p) => p, - }, - } - } else { - GraphPermission::Write // no policy: unrestricted + // Permission check: Err (denied or introspect-only) is converted to Ok(None) so the + // user sees null — indistinguishable from "graph not found". Warnings are logged inside + // require_at_least_read for cases where the user has namespace INTROSPECT visibility. + let perms = match require_at_least_read(ctx, &data.auth_policy, path) { + Ok(p) => p, + Err(_) => return Ok(None), }; let graph_with_vecs = data.get_graph(path).await?; @@ -357,11 +348,7 @@ impl QueryRoot { graph }; - Ok(Some(GqlGraph::new_with_permissions( - graph_with_vecs.folder, - graph, - perms, - ))) + Ok(Some(GqlGraph::new(graph_with_vecs.folder, graph))) } /// Returns lightweight metadata for a graph (node/edge counts, timestamps) without loading it. @@ -371,7 +358,7 @@ impl QueryRoot { if let Some(policy) = &data.auth_policy { let role = ctx.data::>().ok().and_then(|r| r.as_deref()); - if let Err(msg) = policy.graph_permissions(ctx, &path) { + if let Err(_) = policy.graph_permissions(ctx, &path) { let ns = parent_namespace(&path); if policy.namespace_permissions(ctx, ns) >= NamespacePermission::Introspect { warn!( @@ -379,10 +366,10 @@ impl QueryRoot { graph = path.as_str(), "Access denied by auth policy" ); - return Err(async_graphql::Error::new(msg)); - } else { - return Ok(None); } + // Always return null — permission denial is indistinguishable from "not found" + // from the user's perspective. The warning above is the only signal in the logs. + return Ok(None); } } From 3702d65da14e83babed52b3a55443998b4244f9d Mon Sep 17 00:00:00 2001 From: shivamka1 <4599890+shivamka1@users.noreply.github.com> Date: Tue, 31 Mar 2026 13:21:53 +0100 Subject: [PATCH 56/64] fmt --- raphtory-graphql/src/lib.rs | 2 +- raphtory-graphql/src/model/graph/namespace.rs | 9 ++-- raphtory-graphql/src/model/graph/timeindex.rs | 1 - raphtory-graphql/src/model/mod.rs | 51 ++++++++++++------- .../model/plugins/permissions_entrypoint.rs | 4 +- 5 files changed, 41 insertions(+), 26 deletions(-) diff --git a/raphtory-graphql/src/lib.rs b/raphtory-graphql/src/lib.rs index 4bc11e7694..885aae9408 100644 --- a/raphtory-graphql/src/lib.rs +++ b/raphtory-graphql/src/lib.rs @@ -1,5 +1,5 @@ pub use crate::{ - auth::{Access, require_jwt_write_access_dynamic}, + auth::{require_jwt_write_access_dynamic, Access}, model::graph::filtering::GraphAccessFilter, server::GraphServer, }; diff --git a/raphtory-graphql/src/model/graph/namespace.rs b/raphtory-graphql/src/model/graph/namespace.rs index 71b799795b..e80f09f4b3 100644 --- a/raphtory-graphql/src/model/graph/namespace.rs +++ b/raphtory-graphql/src/model/graph/namespace.rs @@ -142,9 +142,9 @@ fn is_graph_visible( policy: &Option>, g: &MetaGraph, ) -> bool { - policy.as_ref().map_or(true, |p| { - p.graph_permissions(ctx, &g.local_path()).is_ok() - }) + policy + .as_ref() + .map_or(true, |p| p.graph_permissions(ctx, &g.local_path()).is_ok()) } fn is_namespace_visible( @@ -223,7 +223,8 @@ impl Namespace { async fn items(&self, ctx: &Context<'_>) -> GqlCollection { let data = ctx.data_unchecked::(); let self_clone = self.clone(); - let all_items = blocking_compute(move || self_clone.get_children().collect::>()).await; + let all_items = + blocking_compute(move || self_clone.get_children().collect::>()).await; GqlCollection::new( all_items .into_iter() diff --git a/raphtory-graphql/src/model/graph/timeindex.rs b/raphtory-graphql/src/model/graph/timeindex.rs index 6a9375c300..840ef37688 100644 --- a/raphtory-graphql/src/model/graph/timeindex.rs +++ b/raphtory-graphql/src/model/graph/timeindex.rs @@ -87,7 +87,6 @@ impl IntoTime for GqlTimeInput { } } - pub fn dt_format_str_is_valid(fmt_str: &str) -> bool { !StrftimeItems::new(fmt_str).any(|it| matches!(it, Item::Error)) } diff --git a/raphtory-graphql/src/model/mod.rs b/raphtory-graphql/src/model/mod.rs index 3cc164257e..dc93bed419 100644 --- a/raphtory-graphql/src/model/mod.rs +++ b/raphtory-graphql/src/model/mod.rs @@ -15,9 +15,8 @@ use crate::{ vectorised_graph::GqlVectorisedGraph, }, plugins::{ - mutation_plugin::MutationPlugin, - query_plugin::QueryPlugin, - PermissionsEntrypointMut, PermissionsEntrypointQuery, + mutation_plugin::MutationPlugin, query_plugin::QueryPlugin, PermissionsEntrypointMut, + PermissionsEntrypointQuery, }, }, paths::{ExistingGraphFolder, ValidGraphPaths, ValidWriteableGraphFolder}, @@ -159,8 +158,9 @@ fn apply_graph_filter( match filter { GraphAccessFilter::Node(gql_filter) => { - let raphtory_filter = CompositeNodeFilter::try_from(gql_filter) - .map_err(|e| async_graphql::Error::new(format!("node filter conversion: {e}")))?; + let raphtory_filter = CompositeNodeFilter::try_from(gql_filter).map_err(|e| { + async_graphql::Error::new(format!("node filter conversion: {e}")) + })?; graph = blocking_compute({ let g = graph.clone(); move || g.filter(raphtory_filter) @@ -170,8 +170,9 @@ fn apply_graph_filter( .into_dynamic(); } GraphAccessFilter::Edge(gql_filter) => { - let raphtory_filter = CompositeEdgeFilter::try_from(gql_filter) - .map_err(|e| async_graphql::Error::new(format!("edge filter conversion: {e}")))?; + let raphtory_filter = CompositeEdgeFilter::try_from(gql_filter).map_err(|e| { + async_graphql::Error::new(format!("edge filter conversion: {e}")) + })?; graph = blocking_compute({ let g = graph.clone(); move || g.filter(raphtory_filter) @@ -181,8 +182,9 @@ fn apply_graph_filter( .into_dynamic(); } GraphAccessFilter::Graph(gql_filter) => { - let dyn_view = DynView::try_from(gql_filter) - .map_err(|e| async_graphql::Error::new(format!("graph filter conversion: {e}")))?; + let dyn_view = DynView::try_from(gql_filter).map_err(|e| { + async_graphql::Error::new(format!("graph filter conversion: {e}")) + })?; graph = blocking_compute({ let g = graph.clone(); move || g.filter(dyn_view) @@ -260,10 +262,12 @@ fn require_graph_write( p.graph_permissions(ctx, path) .map_err(|msg| async_graphql::Error::new(msg))? .at_least_write() - .ok_or_else(|| write_denied( - role, - format!("Access denied: WRITE permission required for graph '{path}'"), - ))?; + .ok_or_else(|| { + write_denied( + role, + format!("Access denied: WRITE permission required for graph '{path}'"), + ) + })?; Ok(()) } } @@ -304,10 +308,14 @@ fn require_graph_read_src( p.graph_permissions(ctx, path) .map_err(|msg| async_graphql::Error::new(msg))? .at_least_read() - .ok_or_else(|| write_denied( - role, - format!("Access denied: READ required on source graph '{path}' to {operation}"), - ))?; + .ok_or_else(|| { + write_denied( + role, + format!( + "Access denied: READ required on source graph '{path}' to {operation}" + ), + ) + })?; Ok(()) } } @@ -473,7 +481,6 @@ impl QueryRoot { async fn version<'a>(_ctx: &Context<'a>) -> String { String::from(version()) } - } #[derive(MutationRoot)] @@ -690,4 +697,10 @@ impl Mut { } #[derive(App)] -pub struct App(QueryRoot, MutRoot, Mut, PermissionsEntrypointMut, PermissionsEntrypointQuery); +pub struct App( + QueryRoot, + MutRoot, + Mut, + PermissionsEntrypointMut, + PermissionsEntrypointQuery, +); diff --git a/raphtory-graphql/src/model/plugins/permissions_entrypoint.rs b/raphtory-graphql/src/model/plugins/permissions_entrypoint.rs index 1cff80d53c..18e3235979 100644 --- a/raphtory-graphql/src/model/plugins/permissions_entrypoint.rs +++ b/raphtory-graphql/src/model/plugins/permissions_entrypoint.rs @@ -50,7 +50,9 @@ impl Register for PermissionsEntrypointQuery { |ctx| { FieldFuture::new(async move { require_jwt_write_access_dynamic(&ctx)?; - Ok(Some(FieldValue::owned_any(PermissionsQueryPlugin::default()))) + Ok(Some(FieldValue::owned_any( + PermissionsQueryPlugin::default(), + ))) }) }, )) From 387ecda95aaa3f24c2f243d80efc8113a5178363 Mon Sep 17 00:00:00 2001 From: shivamka1 <4599890+shivamka1@users.noreply.github.com> Date: Tue, 31 Mar 2026 13:52:55 +0100 Subject: [PATCH 57/64] fix error messages --- raphtory-graphql/src/model/mod.rs | 26 +++++++++++++++++++------- 1 file changed, 19 insertions(+), 7 deletions(-) diff --git a/raphtory-graphql/src/model/mod.rs b/raphtory-graphql/src/model/mod.rs index dc93bed419..a11fa5f2f2 100644 --- a/raphtory-graphql/src/model/mod.rs +++ b/raphtory-graphql/src/model/mod.rs @@ -48,7 +48,7 @@ use std::{ pin::Pin, sync::Arc, }; -use tracing::warn; +use tracing::{error, warn}; pub mod graph; pub mod plugins; @@ -159,38 +159,50 @@ fn apply_graph_filter( match filter { GraphAccessFilter::Node(gql_filter) => { let raphtory_filter = CompositeNodeFilter::try_from(gql_filter).map_err(|e| { - async_graphql::Error::new(format!("node filter conversion: {e}")) + error!(error = %e, "node filter conversion failed"); + async_graphql::Error::new("internal error applying access filter") })?; graph = blocking_compute({ let g = graph.clone(); move || g.filter(raphtory_filter) }) .await - .map_err(|e| async_graphql::Error::new(format!("node filter apply: {e}")))? + .map_err(|e| { + error!(error = %e, "node filter apply failed"); + async_graphql::Error::new("internal error applying access filter") + })? .into_dynamic(); } GraphAccessFilter::Edge(gql_filter) => { let raphtory_filter = CompositeEdgeFilter::try_from(gql_filter).map_err(|e| { - async_graphql::Error::new(format!("edge filter conversion: {e}")) + error!(error = %e, "edge filter conversion failed"); + async_graphql::Error::new("internal error applying access filter") })?; graph = blocking_compute({ let g = graph.clone(); move || g.filter(raphtory_filter) }) .await - .map_err(|e| async_graphql::Error::new(format!("edge filter apply: {e}")))? + .map_err(|e| { + error!(error = %e, "edge filter apply failed"); + async_graphql::Error::new("internal error applying access filter") + })? .into_dynamic(); } GraphAccessFilter::Graph(gql_filter) => { let dyn_view = DynView::try_from(gql_filter).map_err(|e| { - async_graphql::Error::new(format!("graph filter conversion: {e}")) + error!(error = %e, "graph filter conversion failed"); + async_graphql::Error::new("internal error applying access filter") })?; graph = blocking_compute({ let g = graph.clone(); move || g.filter(dyn_view) }) .await - .map_err(|e| async_graphql::Error::new(format!("graph filter apply: {e}")))? + .map_err(|e| { + error!(error = %e, "graph filter apply failed"); + async_graphql::Error::new("internal error applying access filter") + })? .into_dynamic(); } GraphAccessFilter::And(filters) => { From 95d20d8b97b9a0be44334953d109495c69925199 Mon Sep 17 00:00:00 2001 From: shivamka1 <4599890+shivamka1@users.noreply.github.com> Date: Tue, 31 Mar 2026 14:04:13 +0100 Subject: [PATCH 58/64] intro AuthPolicyError --- raphtory-graphql/src/auth_policy.rs | 22 +++++++++++++++++++++- raphtory-graphql/src/model/mod.rs | 8 ++++---- 2 files changed, 25 insertions(+), 5 deletions(-) diff --git a/raphtory-graphql/src/auth_policy.rs b/raphtory-graphql/src/auth_policy.rs index 8c0ab385fc..30696c3cc6 100644 --- a/raphtory-graphql/src/auth_policy.rs +++ b/raphtory-graphql/src/auth_policy.rs @@ -1,5 +1,25 @@ use crate::model::graph::filtering::GraphAccessFilter; +/// Opaque error returned by [`AuthorizationPolicy::graph_permissions`] when access is entirely +/// denied. The message is intended for logging only; callers must not surface it to end users. +#[derive(Debug)] +pub struct AuthPolicyError(String); + +impl AuthPolicyError { + pub fn new(msg: impl Into) -> Self { + Self(msg.into()) + } +} + +impl std::fmt::Display for AuthPolicyError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(&self.0) + } +} + +// async_graphql's blanket `impl From for Error` covers +// AuthPolicyError automatically via its Display impl. + /// The effective permission level a principal has on a specific graph. /// Variants are ordered by the hierarchy: `Write` > `Read{filter:None}` > `Read{filter:Some}` > `Introspect`. /// A filtered `Read` is less powerful than an unfiltered `Read` because it sees a restricted view. @@ -93,7 +113,7 @@ pub trait AuthorizationPolicy: Send + Sync + 'static { &self, ctx: &async_graphql::Context<'_>, path: &str, - ) -> Result; + ) -> Result; /// Resolves the effective namespace permission for a principal. /// Admin principals always yield `Write`. diff --git a/raphtory-graphql/src/model/mod.rs b/raphtory-graphql/src/model/mod.rs index a11fa5f2f2..86a284858e 100644 --- a/raphtory-graphql/src/model/mod.rs +++ b/raphtory-graphql/src/model/mod.rs @@ -1,6 +1,6 @@ use crate::{ auth::{AuthError, ContextValidation}, - auth_policy::{AuthorizationPolicy, GraphPermission, NamespacePermission}, + auth_policy::{AuthPolicyError, AuthorizationPolicy, GraphPermission, NamespacePermission}, data::Data, model::{ graph::{ @@ -118,7 +118,7 @@ fn require_at_least_read( graph = path, "Access denied by auth policy" ); - return Err(async_graphql::Error::new(msg)); + return Err(msg.into()); } else { // Don't leak graph existence — act as if it doesn't exist. return Err(async_graphql::Error::new(MissingGraph.to_string())); @@ -272,7 +272,7 @@ fn require_graph_write( Some(p) => { let role = ctx.data::>().ok().and_then(|r| r.as_deref()); p.graph_permissions(ctx, path) - .map_err(|msg| async_graphql::Error::new(msg))? + .map_err(async_graphql::Error::from)? .at_least_write() .ok_or_else(|| { write_denied( @@ -318,7 +318,7 @@ fn require_graph_read_src( Some(p) => { let role = ctx.data::>().ok().and_then(|r| r.as_deref()); p.graph_permissions(ctx, path) - .map_err(|msg| async_graphql::Error::new(msg))? + .map_err(async_graphql::Error::from)? .at_least_read() .ok_or_else(|| { write_denied( From 3dd54d28d7b0cac5e1ae8573caed56a64329944a Mon Sep 17 00:00:00 2001 From: shivamka1 <4599890+shivamka1@users.noreply.github.com> Date: Tue, 31 Mar 2026 14:41:20 +0100 Subject: [PATCH 59/64] fix tests --- python/tests/test_permissions.py | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/python/tests/test_permissions.py b/python/tests/test_permissions.py index 1040ded5f2..1e33acb775 100644 --- a/python/tests/test_permissions.py +++ b/python/tests/test_permissions.py @@ -307,6 +307,7 @@ def test_analyst_can_create_graph_in_namespace(): response = gql(CREATE_JIRA_NS, headers=ANALYST_HEADERS) assert "errors" not in response, response + assert response["data"]["newGraph"] is True def test_analyst_cannot_create_graph_outside_namespace(): @@ -319,6 +320,9 @@ def test_analyst_cannot_create_graph_outside_namespace(): response = gql(CREATE_JIRA, headers=ANALYST_HEADERS) # "jira" not under "team/" assert "errors" in response assert "Access denied" in response["errors"][0]["message"] + # Verify "jira" was not created as a side effect + ns_graphs = gql(QUERY_NS_GRAPHS)["data"]["root"]["graphs"]["list"] + assert "jira" not in [g["path"] for g in ns_graphs] def test_analyst_cannot_call_permissions_mutations(): @@ -334,6 +338,9 @@ def test_analyst_cannot_call_permissions_mutations(): ) assert "errors" in response assert "Access denied" in response["errors"][0]["message"] + # Verify "hacker" role was not created as a side effect + roles = gql("query { permissions { listRoles } }")["data"]["permissions"]["listRoles"] + assert "hacker" not in roles def test_admin_can_list_roles(): @@ -1370,6 +1377,9 @@ def test_analyst_cannot_delete_with_graph_write_only(): response = gql(DELETE_JIRA, headers=ANALYST_HEADERS) assert "errors" in response assert "Access denied" in response["errors"][0]["message"] + # Verify "jira" was not deleted as a side effect + check = gql(QUERY_JIRA) + assert check["data"]["graph"]["path"] == "jira" def test_analyst_cannot_delete_with_read_grant(): @@ -1383,6 +1393,9 @@ def test_analyst_cannot_delete_with_read_grant(): response = gql(DELETE_JIRA, headers=ANALYST_HEADERS) assert "errors" in response assert "Access denied" in response["errors"][0]["message"] + # Verify "jira" was not deleted as a side effect + check = gql(QUERY_JIRA) + assert check["data"]["graph"]["path"] == "jira" def test_analyst_can_delete_with_namespace_write(): @@ -1502,6 +1515,11 @@ def test_analyst_cannot_move_with_graph_write_only(): response = gql(MOVE_TEAM_JIRA, headers=ANALYST_HEADERS) assert "errors" in response assert "Access denied" in response["errors"][0]["message"] + # Verify "team/jira" still exists and "team/jira-moved" was not created + team_graphs = gql(QUERY_TEAM_GRAPHS)["data"]["namespace"]["graphs"]["list"] + paths = [g["path"] for g in team_graphs] + assert "team/jira" in paths + assert "team/jira-moved" not in paths def test_analyst_cannot_move_with_read_grant(): @@ -1516,6 +1534,11 @@ def test_analyst_cannot_move_with_read_grant(): response = gql(MOVE_TEAM_JIRA, headers=ANALYST_HEADERS) assert "errors" in response assert "Access denied" in response["errors"][0]["message"] + # Verify "team/jira" still exists and "team/jira-moved" was not created + team_graphs = gql(QUERY_TEAM_GRAPHS)["data"]["namespace"]["graphs"]["list"] + paths = [g["path"] for g in team_graphs] + assert "team/jira" in paths + assert "team/jira-moved" not in paths # --- newGraph namespace write enforcement --- @@ -1543,6 +1566,9 @@ def test_analyst_cannot_create_graph_with_namespace_read_only(): response = gql(CREATE_TEAM_JIRA, headers=ANALYST_HEADERS) assert "errors" in response assert "Access denied" in response["errors"][0]["message"] + # Verify "team/jira" was not created as a side effect — "team" namespace should be absent + children = gql(QUERY_NS_CHILDREN)["data"]["root"]["children"]["list"] + assert "team" not in [c["path"] for c in children] # --- permissions entry point admin gate --- From 8e082aaad1468f8a7d75953702b11beaf415286d Mon Sep 17 00:00:00 2001 From: shivamka1 <4599890+shivamka1@users.noreply.github.com> Date: Wed, 1 Apr 2026 12:48:42 +0100 Subject: [PATCH 60/64] support RSA --- python/tests/test_auth.py | 74 ++++++++++++++++++++++ raphtory-graphql/src/auth.rs | 3 +- raphtory-graphql/src/config/auth_config.rs | 54 ++++++++++++++-- 3 files changed, 125 insertions(+), 6 deletions(-) diff --git a/python/tests/test_auth.py b/python/tests/test_auth.py index 39c97d8adc..9c6fe3d52a 100644 --- a/python/tests/test_auth.py +++ b/python/tests/test_auth.py @@ -29,6 +29,38 @@ "Authorization": f"Bearer {WRITE_JWT}", } +# openssl genpkey -algorithm RSA -pkeyopt rsa_keygen_bits:2048 -out rsa-key.pem +# openssl pkey -in rsa-key.pem -pubout -outform DER | base64 | tr -d '\n' +RSA_PUB_KEY = "MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA4sqe3DlHB/DaSm8Ab99yKj0KDc/WZGFPwXeTbPwCMKKSEc8zuSuIZc/fHXLSORn1apMnDq3aLryfPwyNTbpvhGiYVyp76XQGwSlN+EF2TsJZVAzp4/EI+bnHeHyv2Yc5q6AkFtoBPNtAz2P/18g7Yv/eZqNNSd7FOeuRFRs9y0LkswvMelQmoMOK7UKdC00AyiGksvFvljNC70VT9b0uVHggJwUYT0hdCbdaDj2fCJZBEmTqBBr97u3fIHo5T41sIEEPgE2j368mI+uk6V1saEU1BU+hkcq56TabgVqUYZTln5Rdm1MuBsNz+NQwOmVxgPNo45H2cNwTfsPDAAESlwIDAQAB" +RSA_PRIVATE_KEY = """-----BEGIN PRIVATE KEY----- +MIIEvgIBADANBgkqhkiG9w0BAQEFAASCBKgwggSkAgEAAoIBAQDiyp7cOUcH8NpK +bwBv33IqPQoNz9ZkYU/Bd5Ns/AIwopIRzzO5K4hlz98dctI5GfVqkycOrdouvJ8/ +DI1Num+EaJhXKnvpdAbBKU34QXZOwllUDOnj8Qj5ucd4fK/ZhzmroCQW2gE820DP +Y//XyDti/95mo01J3sU565EVGz3LQuSzC8x6VCagw4rtQp0LTQDKIaSy8W+WM0Lv +RVP1vS5UeCAnBRhPSF0Jt1oOPZ8IlkESZOoEGv3u7d8gejlPjWwgQQ+ATaPfryYj +66TpXWxoRTUFT6GRyrnpNpuBWpRhlOWflF2bUy4Gw3P41DA6ZXGA82jjkfZw3BN+ +w8MAARKXAgMBAAECggEAWIH78nU2B97Syja8xGw/KUXODSreACnMDvRkKCXHkwR3 +HhUvmeXn4tf3uo3rhhZf5TpNhViK7C93tIrpAHswd0u8nFP7rNW3px3ADJE7oywM +4ZTymJ8iQhdjRd3fYPT5qEWkn/hvgDkO94EOwT8nEhFKUeMMUDZs4RhSdBrACHk0 +CrOC2S9xbgYb5OWGV6vkSqNB0k0Kv+LxU8sS46BLE7DxfpzSXDyeYaCAkk+wbwfb +hX7lysczbSl5l5Bulcf/LHL4Oa/5t+NcBZqyN6ylRXyqQ8LEdK4+TOJfvnePX1go +3rG4rtyaBCuW5JD1ytxUsyfh8WE4GinUbHWzxvaYQQKBgQD5PxF2CmqMY6yiaxU3 +0LFtRS9DtwIPnPX3Wdchq7ivSU1W6sHJjNfyEggi10DSOOINalRM/ZnVlDo8hJ3A +SybESWWzLuDZNAAAWkmoir0UpnURz847tKd8hJUivhsbdQBeKwaCuepcW6Hdwzh1 +JsJjXPovrzVGQe5FSRfBy7gswQKBgQDo78p/jEVHzuxHqSn3AsOdBdMZvPavpHb2 +Bx7tRhZOOp2QiGUHZLfjI++sQQyTu1PJqmmxOOF+eD/zkqCkLLeZsmRYOQVDOQDM +Z+u+zKYRj7KaWBeGB2Oy/WEU0pGnhyMB/T5iHmroO0Hn4gDHqkEDvwFI7SUjLNAK +1RjTxVgdVwKBgCRHNMBspbOHcoI1eeIk4x5Xepitk4Q4QWjeT7zb5MbGsZYcF1bB +xFC8pSiFEi9HDkgLmPeX1gNLTuquFtP9XEgnssDQ6vNSaUmj2qLIhtrxm4qbJ5Zz +JgmutpJW/1UQw5vxQUJX0y/cOoQvvRD4MkUKLHQyWVu/jvHQwL95anZBAoGBAIrZ +9aGWYe3uINaOth8yHJzLTgz3oS0OIoOBtyPFNaKoOihfxalklmDlmQbbN74QWl/K +H3qu52vWDnkJHI0Awujxd/NG+iYaIqm2AMcZgpzRRavPeyY/3WRiua4J3x035txW +swsWCrAoMp8hD0n16Q9smj14bzzKh7ENWeFSr7W9AoGBAMOSyRdVQxVHXagh3fAa ++FNbR8pFmQC6bQGCO74DzGe6uKYpgu+XD1yinufwwsXxjieDXCHkKTGR92Kzp5VY +Hp6HhhhCcXICRRnbxhvdpyaDbCQrT522bqRJ4rNmSVYOQQiD2vng/HVB2oWMVwa+ +fEtYNjbxjhX9qInHjHxeaNOp +-----END PRIVATE KEY-----""" + NEW_TEST_GRAPH = """mutation { newGraph(path:"test", graphType:EVENT) }""" QUERY_NAMESPACES = """query { namespaces { list{ path} } }""" @@ -215,6 +247,48 @@ def test_raphtory_client_write_denied_for_read_jwt(): client.new_graph("test", "EVENT") +# --- RSA JWT support --- + + +def test_rsa_signed_jwt_rs256_accepted(): + """Server configured with an RSA public key accepts RS256-signed JWTs.""" + work_dir = tempfile.mkdtemp() + with GraphServer(work_dir, auth_public_key=RSA_PUB_KEY).start(): + token = jwt.encode({"access": "ro"}, RSA_PRIVATE_KEY, algorithm="RS256") + response = requests.post( + RAPHTORY, + headers={"Authorization": f"Bearer {token}"}, + data=json.dumps({"query": QUERY_ROOT}), + ) + assert_successful_response(response) + + +def test_rsa_signed_jwt_rs512_accepted(): + """RS512 JWT is also accepted for the same RSA key (different hash, same key material).""" + work_dir = tempfile.mkdtemp() + with GraphServer(work_dir, auth_public_key=RSA_PUB_KEY).start(): + token = jwt.encode({"access": "ro"}, RSA_PRIVATE_KEY, algorithm="RS512") + response = requests.post( + RAPHTORY, + headers={"Authorization": f"Bearer {token}"}, + data=json.dumps({"query": QUERY_ROOT}), + ) + assert_successful_response(response) + + +def test_eddsa_jwt_rejected_against_rsa_key(): + """EdDSA JWT is rejected when the server is configured with an RSA public key.""" + work_dir = tempfile.mkdtemp() + with GraphServer(work_dir, auth_public_key=RSA_PUB_KEY).start(): + token = jwt.encode({"access": "ro"}, PRIVATE_KEY, algorithm="EdDSA") + response = requests.post( + RAPHTORY, + headers={"Authorization": f"Bearer {token}"}, + data=json.dumps({"query": QUERY_ROOT}), + ) + assert response.status_code == 401 + + def test_raphtory_client_read_jwt_can_receive_graph(): """RaphtoryClient initialized with a read JWT can download graphs.""" work_dir = tempfile.mkdtemp() diff --git a/raphtory-graphql/src/auth.rs b/raphtory-graphql/src/auth.rs index 3b9178af4d..e4bcdc9da1 100644 --- a/raphtory-graphql/src/auth.rs +++ b/raphtory-graphql/src/auth.rs @@ -211,7 +211,8 @@ fn is_query_heavy(query: &str) -> bool { fn extract_claims_from_header(header: &str, public_key: &PublicKey) -> Option { if header.starts_with("Bearer ") { let jwt = header.replace("Bearer ", ""); - let mut validation = Validation::new(Algorithm::EdDSA); + let mut validation = Validation::new(public_key.algorithms[0]); + validation.algorithms = public_key.algorithms.clone(); validation.set_required_spec_claims::(&[]); // we don't require 'exp' to be present let decoded = decode::(&jwt, &public_key.decoding_key, &validation); match decoded { diff --git a/raphtory-graphql/src/config/auth_config.rs b/raphtory-graphql/src/config/auth_config.rs index 91940f5fa9..8a29300cad 100644 --- a/raphtory-graphql/src/config/auth_config.rs +++ b/raphtory-graphql/src/config/auth_config.rs @@ -1,16 +1,52 @@ use base64::{prelude::BASE64_STANDARD, DecodeError, Engine}; -use jsonwebtoken::DecodingKey; +use jsonwebtoken::{Algorithm, DecodingKey}; use serde::{de, Deserialize, Deserializer, Serialize}; use spki::SubjectPublicKeyInfoRef; use std::fmt::Debug; pub const DEFAULT_REQUIRE_AUTH_FOR_READS: bool = true; -pub const PUBLIC_KEY_DECODING_ERR_MSG: &str = "Could not successfully decode the public key. Make sure you use the standard alphabet with padding"; +pub const PUBLIC_KEY_DECODING_ERR_MSG: &str = + "Could not decode public key. Provide a base64-encoded DER (X.509 SPKI) public key \ + for Ed25519 or RSA (2048-4096 bit)."; + +/// Describes one family of asymmetric public-key algorithms that Raphtory can validate JWTs with. +/// +/// To add support for a new algorithm family (e.g. EC/ECDSA), append one entry to +/// [`SUPPORTED_ALGORITHMS`] — no other code needs to change. +struct AlgorithmSpec { + /// X.509 SPKI algorithm OID string (e.g. `"1.3.101.112"` for Ed25519). + oid: &'static str, + /// Constructs the `DecodingKey` from the raw subject-public-key bytes extracted from the SPKI + /// structure (i.e. the inner key bytes, not the full DER-encoded SPKI wrapper). + make_key: fn(&[u8]) -> DecodingKey, + /// JWT algorithms accepted for this key family. All listed variants are allowed during + /// validation; the first entry is used as the `Validation` default. + algorithms: &'static [Algorithm], +} + +/// Registry of supported public-key algorithm families. +/// +/// # Adding a new family +/// Append an [`AlgorithmSpec`] entry here. `TryFrom for PublicKey` will pick it up +/// automatically — no other changes required. +const SUPPORTED_ALGORITHMS: &[AlgorithmSpec] = &[ + AlgorithmSpec { + oid: "1.3.101.112", // id-EdDSA (Ed25519) + make_key: DecodingKey::from_ed_der, + algorithms: &[Algorithm::EdDSA], + }, + AlgorithmSpec { + oid: "1.2.840.113549.1.1.1", // rsaEncryption (PKCS#1) + make_key: DecodingKey::from_rsa_der, + algorithms: &[Algorithm::RS256, Algorithm::RS384, Algorithm::RS512], + }, +]; #[derive(Clone)] pub struct PublicKey { source: String, pub(crate) decoding_key: DecodingKey, + pub(crate) algorithms: Vec, } impl PartialEq for PublicKey { @@ -23,8 +59,10 @@ impl PartialEq for PublicKey { pub enum PublicKeyError { #[error(transparent)] Base64(#[from] DecodeError), - #[error("The provided key is not a a valid X.509 Subject Public Key Info ASN.1 structure")] + #[error("The provided key is not a valid X.509 Subject Public Key Info ASN.1 structure")] Spki, + #[error("Key algorithm is not supported; see SUPPORTED_ALGORITHMS for accepted OIDs")] + UnsupportedAlgorithm, } impl TryFrom for PublicKey { @@ -33,10 +71,16 @@ impl TryFrom for PublicKey { let der = BASE64_STANDARD.decode(&value)?; let spki_ref = SubjectPublicKeyInfoRef::try_from(der.as_ref()).map_err(|_| PublicKeyError::Spki)?; - let decoding_key = DecodingKey::from_ed_der(spki_ref.subject_public_key.raw_bytes()); + let oid = spki_ref.algorithm.oid.to_string(); + let spec = SUPPORTED_ALGORITHMS + .iter() + .find(|s| s.oid == oid.as_str()) + .ok_or(PublicKeyError::UnsupportedAlgorithm)?; + let raw = spki_ref.subject_public_key.raw_bytes(); Ok(Self { source: value, - decoding_key, + decoding_key: (spec.make_key)(raw), + algorithms: spec.algorithms.to_vec(), }) } } From 1b06715233951238bee592af74629f0c4249490a Mon Sep 17 00:00:00 2001 From: shivamka1 <4599890+shivamka1@users.noreply.github.com> Date: Wed, 1 Apr 2026 18:17:16 +0100 Subject: [PATCH 61/64] use raphtory-server binary in stress test workflow --- .github/workflows/stress-test.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/stress-test.yml b/.github/workflows/stress-test.yml index 450c335aa2..bf8a52417b 100644 --- a/.github/workflows/stress-test.yml +++ b/.github/workflows/stress-test.yml @@ -37,8 +37,8 @@ jobs: env: RUST_BACKTRACE: 1 run: | - cargo build --package raphtory-graphql --bin raphtory-graphql --profile=build-fast - ./target/build-fast/raphtory-graphql server --work-dir graphs & + cargo build --package raphtory-server --bin raphtory-server --profile=build-fast + ./target/build-fast/raphtory-server server --work-dir graphs & cd graphql-bench make stress-test - name: Upload k6 report From cd8dfb312f978096d44ed919801de122d24807dc Mon Sep 17 00:00:00 2001 From: shivamka1 <4599890+shivamka1@users.noreply.github.com> Date: Wed, 1 Apr 2026 18:23:09 +0100 Subject: [PATCH 62/64] fix test --- Cargo.lock | 23 +++++++++++++++++++++-- raphtory-graphql/src/lib.rs | 2 +- 2 files changed, 22 insertions(+), 3 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index a89062d7f6..83224755e3 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1126,8 +1126,7 @@ dependencies = [ "slotmap", "snb", "tempfile", - "thiserror 2.0.18", - "tikv-jemallocator", + "thiserror 1.0.69", "tokio", "tracing", "tracing-test", @@ -6429,6 +6428,26 @@ dependencies = [ "ordered-float 2.10.1", ] +[[package]] +name = "tikv-jemalloc-sys" +version = "0.6.1+5.3.0-1-ge13ca993e8ccb9ba9847cc330696e02839f328f7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd8aa5b2ab86a2cefa406d889139c162cbb230092f7d1d7cbc1716405d852a3b" +dependencies = [ + "cc", + "libc", +] + +[[package]] +name = "tikv-jemallocator" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0359b4327f954e0567e69fb191cf1436617748813819c94b8cd4a431422d053a" +dependencies = [ + "libc", + "tikv-jemalloc-sys", +] + [[package]] name = "time" version = "0.3.47" diff --git a/raphtory-graphql/src/lib.rs b/raphtory-graphql/src/lib.rs index 547f1b1286..bff65ec87e 100644 --- a/raphtory-graphql/src/lib.rs +++ b/raphtory-graphql/src/lib.rs @@ -1070,7 +1070,7 @@ mod graphql_test { "##; let variables = json!({ "file": null, "overwrite": false }); - let mut req = Request::new(query).variables(Variables::from_json(variables)); + let mut req = Request::new(query).variables(Variables::from_json(variables)).data(Access::Rw); req.set_upload("variables.file", upload_val); let res = schema.execute(req).await; assert_eq!(res.errors, vec![]); From 6a70667def228f6395d878c5c4dc9802d45e706a Mon Sep 17 00:00:00 2001 From: shivamka1 <4599890+shivamka1@users.noreply.github.com> Date: Thu, 2 Apr 2026 05:36:47 +0100 Subject: [PATCH 63/64] fix tests --- raphtory-graphql/src/lib.rs | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/raphtory-graphql/src/lib.rs b/raphtory-graphql/src/lib.rs index bff65ec87e..462440bcec 100644 --- a/raphtory-graphql/src/lib.rs +++ b/raphtory-graphql/src/lib.rs @@ -1115,9 +1115,11 @@ mod graphql_test { sendGraph(path: "test", graph: $graph, overwrite: $overwrite) } "#; - let req = Request::new(query).variables(Variables::from_json( - json!({ "graph": graph_str, "overwrite": false }), - )); + let req = Request::new(query) + .variables(Variables::from_json( + json!({ "graph": graph_str, "overwrite": false }), + )) + .data(Access::Rw); let res = schema.execute(req).await; assert_eq!(res.errors, []); @@ -1630,7 +1632,7 @@ mod graphql_test { createSubgraph(parentPath: "graph", newPath: "graph2", nodes: ["1", "2"], overwrite: false) } "#; - let req = Request::new(req); + let req = Request::new(req).data(Access::Rw); let res = schema.execute(req).await; assert_eq!(res.errors, vec![]); let req = r#" @@ -1638,7 +1640,7 @@ mod graphql_test { createSubgraph(parentPath: "graph", newPath: "namespace1/graph3", nodes: ["2", "3", "4"], overwrite: false) } "#; - let req = Request::new(req); + let req = Request::new(req).data(Access::Rw); let res = schema.execute(req).await; assert_eq!(res.errors, vec![]); From 6e6e145641f8db7ec998dc0a658f00abe71e45ec Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Thu, 2 Apr 2026 05:53:36 +0000 Subject: [PATCH 64/64] chore: apply tidy-public auto-fixes --- Cargo.lock | 15 +- docs/reference/graphql/graphql_API.md | 78 +++------- python/tests/test_permissions.py | 202 ++++++++++++++++++-------- raphtory-graphql/schema.graphql | 26 +--- raphtory-graphql/src/lib.rs | 4 +- 5 files changed, 180 insertions(+), 145 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 83224755e3..b98ec4a4a1 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1093,22 +1093,18 @@ dependencies = [ "ahash", "arrow", "async-trait", - "bincode 1.3.3", "chrono", - "chrono-tz 0.8.6", + "chrono-tz 0.10.4", "comfy-table", - "crc32fast", "criterion", - "dashmap", "db4-storage", "env_logger 0.10.2", "fastrand", "flate2", "insta", - "itertools 0.14.0", + "itertools 0.13.0", "log", "nom", - "once_cell", "optd-core", "parking_lot", "proptest", @@ -1126,7 +1122,8 @@ dependencies = [ "slotmap", "snb", "tempfile", - "thiserror 1.0.69", + "thiserror 2.0.18", + "tikv-jemallocator", "tokio", "tracing", "tracing-test", @@ -3905,7 +3902,7 @@ dependencies = [ [[package]] name = "optd-core" -version = "0.1.0" +version = "0.17.0" dependencies = [ "anyhow", "bitvec", @@ -5991,7 +5988,7 @@ checksum = "1b6b67fb9a61334225b5b790716f609cd58395f895b3fe8b328786812a40bc3b" [[package]] name = "snb" -version = "0.1.0" +version = "0.17.0" dependencies = [ "chrono", "flate2", diff --git a/docs/reference/graphql/graphql_API.md b/docs/reference/graphql/graphql_API.md index 9b6647fbb7..2c0049945a 100644 --- a/docs/reference/graphql/graphql_API.md +++ b/docs/reference/graphql/graphql_API.md @@ -30,11 +30,26 @@ Hello world demo - + + + + + + + + + + + @@ -126,7 +141,8 @@ Returns a plugin. - - - - -
graphGraph!Graph Returns a graph +
pathString!
graphMetadataMetaGraph + +Returns lightweight metadata for a graph (node/edge counts, timestamps) without loading it. +Requires at least INTROSPECT permission. +
String! -Encodes graph and returns as string +Encodes graph and returns as string. +If the caller has filtered access, the returned graph is a materialized view of the filter. Returns:: Base64 url safe encoded string @@ -142,15 +158,6 @@ Returns:: Base64 url safe encoded string String!
permissionsPermissionsQueryPlugin! - -Returns the permissions namespace for inspecting roles and access policies (admin only). - -
@@ -172,15 +179,6 @@ Returns the permissions namespace for inspecting roles and access policies (admi Returns a collection of mutation plugins. - - - -permissions -PermissionsPlugin! - - -Returns the permissions namespace for managing roles and access policies. - @@ -5627,46 +5625,6 @@ will be returned. -### PermissionsPlugin - - - - - - - - - - - - - - - - - -
FieldArgumentTypeDescription
NoOpsString!
- -### PermissionsQueryPlugin - - - - - - - - - - - - - - - - - -
FieldArgumentTypeDescription
NoOpsString!
- ### Properties diff --git a/python/tests/test_permissions.py b/python/tests/test_permissions.py index 1e33acb775..43488acc9d 100644 --- a/python/tests/test_permissions.py +++ b/python/tests/test_permissions.py @@ -40,7 +40,9 @@ CREATE_JIRA = """mutation { newGraph(path:"jira", graphType:EVENT) }""" CREATE_ADMIN = """mutation { newGraph(path:"admin", graphType:EVENT) }""" CREATE_TEAM_JIRA = """mutation { newGraph(path:"team/jira", graphType:EVENT) }""" -CREATE_TEAM_CONFLUENCE = """mutation { newGraph(path:"team/confluence", graphType:EVENT) }""" +CREATE_TEAM_CONFLUENCE = ( + """mutation { newGraph(path:"team/confluence", graphType:EVENT) }""" +) CREATE_DEEP = """mutation { newGraph(path:"a/b/c", graphType:EVENT) }""" QUERY_TEAM_JIRA = """query { graph(path: "team/jira") { path } }""" QUERY_TEAM_GRAPHS = """query { namespace(path: "team") { graphs { list { path } } } }""" @@ -242,14 +244,18 @@ def test_namespace_grant_does_not_cover_root_level_graphs(): gql(CREATE_JIRA) gql(CREATE_TEAM_JIRA) create_role("analyst") - grant_namespace("analyst", "team", "READ") # covers team/jira but not root-level jira + grant_namespace( + "analyst", "team", "READ" + ) # covers team/jira but not root-level jira response = gql(QUERY_TEAM_JIRA, headers=ANALYST_HEADERS) assert "errors" not in response, response response = gql(QUERY_JIRA, headers=ANALYST_HEADERS) assert "errors" not in response, response - assert response["data"]["graph"] is None # root-level graph not covered by namespace grant + assert ( + response["data"]["graph"] is None + ) # root-level graph not covered by namespace grant # --- WRITE permission enforcement --- @@ -339,7 +345,9 @@ def test_analyst_cannot_call_permissions_mutations(): assert "errors" in response assert "Access denied" in response["errors"][0]["message"] # Verify "hacker" role was not created as a side effect - roles = gql("query { permissions { listRoles } }")["data"]["permissions"]["listRoles"] + roles = gql("query { permissions { listRoles } }")["data"]["permissions"][ + "listRoles" + ] assert "hacker" not in roles @@ -446,7 +454,10 @@ def test_grantgraph_introspect_rejected(): 'mutation { permissions { grantGraph(role: "analyst", path: "jira", permission: INTROSPECT) { success } } }' ) assert "errors" in response - assert "INTROSPECT cannot be granted on a graph" in response["errors"][0]["message"] + assert ( + "INTROSPECT cannot be granted on a graph" + in response["errors"][0]["message"] + ) def test_graph_metadata_allowed_with_introspect(): @@ -755,8 +766,7 @@ def test_receive_graph_with_filtered_access(): ("bob", "us-east"), ("carol", "us-west"), ]: - resp = gql( - f"""query {{ + resp = gql(f"""query {{ updateGraph(path: "jira") {{ addNode( time: 1, @@ -764,8 +774,7 @@ def test_receive_graph_with_filtered_access(): properties: [{{ key: "region", value: {{ str: "{region}" }} }}] ) {{ success }} }} - }}""" - ) + }}""") assert resp["data"]["updateGraph"]["addNode"]["success"] is True, resp create_role("analyst") @@ -869,16 +878,18 @@ def test_filter_and_node_node(): grant_graph_filtered_read_only( "analyst", "jira", - '{ and: [' + "{ and: [" '{ node: { property: { name: "region", where: { eq: { str: "us-west" } } } } },' '{ node: { property: { name: "role", where: { eq: { str: "admin" } } } } }' - '] }', + "] }", ) QUERY_NODES = 'query { graph(path: "jira") { nodes { list { name } } } }' analyst_names = { n["name"] - for n in gql(QUERY_NODES, headers=ANALYST_HEADERS)["data"]["graph"]["nodes"]["list"] + for n in gql(QUERY_NODES, headers=ANALYST_HEADERS)["data"]["graph"][ + "nodes" + ]["list"] } assert analyst_names == {"alice"}, f"expected only alice, got {analyst_names}" @@ -911,18 +922,22 @@ def test_filter_and_edge_edge(): grant_graph_filtered_read_only( "analyst", "jira", - '{ and: [' + "{ and: [" '{ edge: { property: { name: "weight", where: { ge: { i64: 5 } } } } },' '{ edge: { property: { name: "kind", where: { eq: { str: "follows" } } } } }' - '] }', + "] }", ) QUERY_EDGES = 'query { graph(path: "jira") { edges { list { src { name } dst { name } } } } }' analyst_edges = { (e["src"]["name"], e["dst"]["name"]) - for e in gql(QUERY_EDGES, headers=ANALYST_HEADERS)["data"]["graph"]["edges"]["list"] + for e in gql(QUERY_EDGES, headers=ANALYST_HEADERS)["data"]["graph"][ + "edges" + ]["list"] } - assert analyst_edges == {("a", "c")}, f"expected only (a,c), got {analyst_edges}" + assert analyst_edges == { + ("a", "c") + }, f"expected only (a,c), got {analyst_edges}" def test_filter_and_graph_graph(): @@ -943,16 +958,18 @@ def test_filter_and_graph_graph(): grant_graph_filtered_read_only( "analyst", "jira", - '{ and: [' - '{ graph: { window: { start: 1, end: 15 } } },' - '{ graph: { window: { start: 5, end: 25 } } }' - '] }', + "{ and: [" + "{ graph: { window: { start: 1, end: 15 } } }," + "{ graph: { window: { start: 5, end: 25 } } }" + "] }", ) QUERY_NODES = 'query { graph(path: "jira") { nodes { list { name } } } }' analyst_names = { n["name"] - for n in gql(QUERY_NODES, headers=ANALYST_HEADERS)["data"]["graph"]["nodes"]["list"] + for n in gql(QUERY_NODES, headers=ANALYST_HEADERS)["data"]["graph"][ + "nodes" + ]["list"] } assert analyst_names == {"middle"}, f"expected only middle, got {analyst_names}" @@ -962,7 +979,11 @@ def test_filter_and_node_edge(): work_dir = tempfile.mkdtemp() with make_server(work_dir).start(): gql(CREATE_JIRA) - for name, region in [("alice", "us-west"), ("bob", "us-east"), ("carol", "us-west")]: + for name, region in [ + ("alice", "us-west"), + ("bob", "us-east"), + ("carol", "us-west"), + ]: resp = gql(f"""query {{ updateGraph(path: "jira") {{ addNode( @@ -973,7 +994,11 @@ def test_filter_and_node_edge(): }}""") assert resp["data"]["updateGraph"]["addNode"]["success"] is True, resp - for src, dst, weight in [("alice", "bob", 3), ("alice", "carol", 7), ("bob", "carol", 9)]: + for src, dst, weight in [ + ("alice", "bob", 3), + ("alice", "carol", 7), + ("bob", "carol", 9), + ]: resp = gql(f"""query {{ updateGraph(path: "jira") {{ addEdge( @@ -990,10 +1015,10 @@ def test_filter_and_node_edge(): grant_graph_filtered_read_only( "analyst", "jira", - '{ and: [' + "{ and: [" '{ node: { property: { name: "region", where: { eq: { str: "us-west" } } } } },' '{ edge: { property: { name: "weight", where: { ge: { i64: 5 } } } } }' - '] }', + "] }", ) QUERY_NODES = 'query { graph(path: "jira") { nodes { list { name } } } }' @@ -1001,13 +1026,20 @@ def test_filter_and_node_edge(): analyst_names = { n["name"] - for n in gql(QUERY_NODES, headers=ANALYST_HEADERS)["data"]["graph"]["nodes"]["list"] + for n in gql(QUERY_NODES, headers=ANALYST_HEADERS)["data"]["graph"][ + "nodes" + ]["list"] } - assert analyst_names == {"alice", "carol"}, f"expected us-west nodes, got {analyst_names}" + assert analyst_names == { + "alice", + "carol", + }, f"expected us-west nodes, got {analyst_names}" analyst_edges = { (e["src"]["name"], e["dst"]["name"]) - for e in gql(QUERY_EDGES, headers=ANALYST_HEADERS)["data"]["graph"]["edges"]["list"] + for e in gql(QUERY_EDGES, headers=ANALYST_HEADERS)["data"]["graph"][ + "edges" + ]["list"] } # Sequential And: Node(us-west) hides bob and bob's edges, then Edge(weight≥5) keeps alice→carol (7). assert analyst_edges == { @@ -1040,16 +1072,18 @@ def test_filter_and_node_graph(): grant_graph_filtered_read_only( "analyst", "jira", - '{ and: [' - '{ graph: { window: { start: 5, end: 15 } } },' + "{ and: [" + "{ graph: { window: { start: 5, end: 15 } } }," '{ node: { property: { name: "region", where: { eq: { str: "us-west" } } } } }' - '] }', + "] }", ) QUERY_NODES = 'query { graph(path: "jira") { nodes { list { name } } } }' analyst_names = { n["name"] - for n in gql(QUERY_NODES, headers=ANALYST_HEADERS)["data"]["graph"]["nodes"]["list"] + for n in gql(QUERY_NODES, headers=ANALYST_HEADERS)["data"]["graph"][ + "nodes" + ]["list"] } assert analyst_names == {"bob"}, f"expected only bob, got {analyst_names}" @@ -1079,18 +1113,22 @@ def test_filter_and_edge_graph(): grant_graph_filtered_read_only( "analyst", "jira", - '{ and: [' - '{ graph: { window: { start: 5, end: 15 } } },' + "{ and: [" + "{ graph: { window: { start: 5, end: 15 } } }," '{ edge: { property: { name: "weight", where: { ge: { i64: 5 } } } } }' - '] }', + "] }", ) QUERY_EDGES = 'query { graph(path: "jira") { edges { list { src { name } dst { name } } } } }' analyst_edges = { (e["src"]["name"], e["dst"]["name"]) - for e in gql(QUERY_EDGES, headers=ANALYST_HEADERS)["data"]["graph"]["edges"]["list"] + for e in gql(QUERY_EDGES, headers=ANALYST_HEADERS)["data"]["graph"][ + "edges" + ]["list"] } - assert analyst_edges == {("b", "c")}, f"expected only (b,c), got {analyst_edges}" + assert analyst_edges == { + ("b", "c") + }, f"expected only (b,c), got {analyst_edges}" def test_filter_or_node_node(): @@ -1114,18 +1152,23 @@ def test_filter_or_node_node(): grant_graph_filtered_read_only( "analyst", "jira", - '{ or: [' + "{ or: [" '{ node: { property: { name: "region", where: { eq: { str: "us-west" } } } } },' '{ node: { property: { name: "region", where: { eq: { str: "us-east" } } } } }' - '] }', + "] }", ) QUERY_NODES = 'query { graph(path: "jira") { nodes { list { name } } } }' analyst_names = { n["name"] - for n in gql(QUERY_NODES, headers=ANALYST_HEADERS)["data"]["graph"]["nodes"]["list"] + for n in gql(QUERY_NODES, headers=ANALYST_HEADERS)["data"]["graph"][ + "nodes" + ]["list"] } - assert analyst_names == {"alice", "bob"}, f"expected alice+bob, got {analyst_names}" + assert analyst_names == { + "alice", + "bob", + }, f"expected alice+bob, got {analyst_names}" def test_filter_or_edge_edge(): @@ -1149,16 +1192,18 @@ def test_filter_or_edge_edge(): grant_graph_filtered_read_only( "analyst", "jira", - '{ or: [' + "{ or: [" '{ edge: { property: { name: "weight", where: { eq: { i64: 3 } } } } },' '{ edge: { property: { name: "weight", where: { eq: { i64: 9 } } } } }' - '] }', + "] }", ) QUERY_EDGES = 'query { graph(path: "jira") { edges { list { src { name } dst { name } } } } }' analyst_edges = { (e["src"]["name"], e["dst"]["name"]) - for e in gql(QUERY_EDGES, headers=ANALYST_HEADERS)["data"]["graph"]["edges"]["list"] + for e in gql(QUERY_EDGES, headers=ANALYST_HEADERS)["data"]["graph"][ + "edges" + ]["list"] } assert analyst_edges == { ("a", "b"), @@ -1266,12 +1311,22 @@ def test_discover_revoked_when_only_child_revoked(): create_role("analyst") grant_graph("analyst", "team/jira", "READ") - paths = [n["path"] for n in gql(QUERY_NS_CHILDREN, headers=ANALYST_HEADERS)["data"]["root"]["children"]["list"]] + paths = [ + n["path"] + for n in gql(QUERY_NS_CHILDREN, headers=ANALYST_HEADERS)["data"]["root"][ + "children" + ]["list"] + ] assert "team" in paths # baseline: DISCOVER present revoke_graph("analyst", "team/jira") - paths = [n["path"] for n in gql(QUERY_NS_CHILDREN, headers=ANALYST_HEADERS)["data"]["root"]["children"]["list"]] + paths = [ + n["path"] + for n in gql(QUERY_NS_CHILDREN, headers=ANALYST_HEADERS)["data"]["root"][ + "children" + ]["list"] + ] assert "team" not in paths # DISCOVER gone @@ -1286,11 +1341,21 @@ def test_discover_stays_when_one_of_two_children_revoked(): grant_graph("analyst", "team/confluence", "READ") revoke_graph("analyst", "team/jira") - paths = [n["path"] for n in gql(QUERY_NS_CHILDREN, headers=ANALYST_HEADERS)["data"]["root"]["children"]["list"]] + paths = [ + n["path"] + for n in gql(QUERY_NS_CHILDREN, headers=ANALYST_HEADERS)["data"]["root"][ + "children" + ]["list"] + ] assert "team" in paths # still visible via team/confluence revoke_graph("analyst", "team/confluence") - paths = [n["path"] for n in gql(QUERY_NS_CHILDREN, headers=ANALYST_HEADERS)["data"]["root"]["children"]["list"]] + paths = [ + n["path"] + for n in gql(QUERY_NS_CHILDREN, headers=ANALYST_HEADERS)["data"]["root"][ + "children" + ]["list"] + ] assert "team" not in paths # now gone @@ -1305,7 +1370,12 @@ def test_discover_stays_when_parent_has_explicit_namespace_read(): revoke_graph("analyst", "team/jira") - paths = [n["path"] for n in gql(QUERY_NS_CHILDREN, headers=ANALYST_HEADERS)["data"]["root"]["children"]["list"]] + paths = [ + n["path"] + for n in gql(QUERY_NS_CHILDREN, headers=ANALYST_HEADERS)["data"]["root"][ + "children" + ]["list"] + ] assert "team" in paths # still visible via explicit namespace READ @@ -1317,18 +1387,38 @@ def test_discover_revoked_for_nested_namespaces(): create_role("analyst") grant_graph("analyst", "a/b/c", "READ") # "a" and "a/b" both get DISCOVER - root_paths = [n["path"] for n in gql(QUERY_NS_CHILDREN, headers=ANALYST_HEADERS)["data"]["root"]["children"]["list"]] + root_paths = [ + n["path"] + for n in gql(QUERY_NS_CHILDREN, headers=ANALYST_HEADERS)["data"]["root"][ + "children" + ]["list"] + ] assert "a" in root_paths - a_paths = [n["path"] for n in gql(QUERY_A_CHILDREN, headers=ANALYST_HEADERS)["data"]["namespace"]["children"]["list"]] + a_paths = [ + n["path"] + for n in gql(QUERY_A_CHILDREN, headers=ANALYST_HEADERS)["data"][ + "namespace" + ]["children"]["list"] + ] assert "a/b" in a_paths revoke_graph("analyst", "a/b/c") - root_paths = [n["path"] for n in gql(QUERY_NS_CHILDREN, headers=ANALYST_HEADERS)["data"]["root"]["children"]["list"]] + root_paths = [ + n["path"] + for n in gql(QUERY_NS_CHILDREN, headers=ANALYST_HEADERS)["data"]["root"][ + "children" + ]["list"] + ] assert "a" not in root_paths - a_paths = [n["path"] for n in gql(QUERY_A_CHILDREN, headers=ANALYST_HEADERS)["data"]["namespace"]["children"]["list"]] + a_paths = [ + n["path"] + for n in gql(QUERY_A_CHILDREN, headers=ANALYST_HEADERS)["data"][ + "namespace" + ]["children"]["list"] + ] assert "a/b" not in a_paths @@ -1456,13 +1546,11 @@ def test_analyst_send_graph_valid_data_with_namespace_write(): with make_server(work_dir).start(): gql(CREATE_JIRA) # Add a node so the graph has content to verify after the roundtrip - gql( - """query { + gql("""query { updateGraph(path: "jira") { addNode(time: 1, name: "alice", properties: []) { success } } - }""" - ) + }""") # Admin downloads the graph as valid base64 encoded = gql('query { receiveGraph(path: "jira") }')["data"]["receiveGraph"] diff --git a/raphtory-graphql/schema.graphql b/raphtory-graphql/schema.graphql index 68275758b9..9327ef2fb6 100644 --- a/raphtory-graphql/schema.graphql +++ b/raphtory-graphql/schema.graphql @@ -1754,10 +1754,6 @@ type MutRoot { """ plugins: MutationPlugin! """ - Returns the permissions namespace for managing roles and access policies. - """ - permissions: PermissionsPlugin! - """ Delete graph from a path on the server. """ deleteGraph(path: String!): Boolean! @@ -2908,14 +2904,6 @@ type PathFromNodeWindowSet { list: [PathFromNode!]! } -type PermissionsPlugin { - NoOps: String! -} - -type PermissionsQueryPlugin { - NoOps: String! -} - """ Boolean expression over a property value. @@ -3148,7 +3136,12 @@ type QueryRoot { """ Returns a graph """ - graph(path: String!): Graph! + graph(path: String!): Graph + """ + Returns lightweight metadata for a graph (node/edge counts, timestamps) without loading it. + Requires at least INTROSPECT permission. + """ + graphMetadata(path: String!): MetaGraph """ Update graph query, has side effects to update graph state @@ -3184,16 +3177,13 @@ type QueryRoot { """ plugins: QueryPlugin! """ - Encodes graph and returns as string + Encodes graph and returns as string. + If the caller has filtered access, the returned graph is a materialized view of the filter. Returns:: Base64 url safe encoded string """ receiveGraph(path: String!): String! version: String! - """ - Returns the permissions namespace for inspecting roles and access policies (admin only). - """ - permissions: PermissionsQueryPlugin! } type ShortestPathOutput { diff --git a/raphtory-graphql/src/lib.rs b/raphtory-graphql/src/lib.rs index 462440bcec..d1b68476c2 100644 --- a/raphtory-graphql/src/lib.rs +++ b/raphtory-graphql/src/lib.rs @@ -1070,7 +1070,9 @@ mod graphql_test { "##; let variables = json!({ "file": null, "overwrite": false }); - let mut req = Request::new(query).variables(Variables::from_json(variables)).data(Access::Rw); + let mut req = Request::new(query) + .variables(Variables::from_json(variables)) + .data(Access::Rw); req.set_upload("variables.file", upload_val); let res = schema.execute(req).await; assert_eq!(res.errors, vec![]);