Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
33 commits
Select commit Hold shift + click to select a range
7b05207
Add basic permissions proptest
fabubaker Apr 23, 2026
1498a32
Commit before merge
fabubaker Apr 24, 2026
7b8d05e
Prevent cleanup on Drop
fabubaker Apr 24, 2026
83bb443
Return created graph paths
fabubaker Apr 24, 2026
b6cac8d
Add proptest
fabubaker Apr 24, 2026
835263c
Prevent server drop from failing
fabubaker Apr 25, 2026
3e28146
Create roles
fabubaker Apr 26, 2026
eeff475
Merge branch 'db_v4' of github.com:Pometry/Raphtory into features/rba…
fabubaker Apr 27, 2026
af96723
Reuse client across requests
fabubaker Apr 27, 2026
b954d57
Generate strategy
fabubaker Apr 27, 2026
79026a4
Remove DISCOVER from namespace permissions
fabubaker Apr 27, 2026
b9b3b20
Remove in_components and use dfs
fabubaker Apr 28, 2026
64e07a9
Simplify building parent edges
fabubaker Apr 28, 2026
3bde749
Add test for checking parent grant override
fabubaker Apr 29, 2026
9e88638
Add test for most specific namespace grant
fabubaker Apr 29, 2026
ab448f8
Track permission grants in individual namespace trees
fabubaker Apr 29, 2026
31c87d1
Rework display for Permission
fabubaker Apr 30, 2026
7bc52c2
Simplify PermissionGrant
fabubaker Apr 30, 2026
7f7bc75
Tee up validate_grant
fabubaker Apr 30, 2026
1a15a0c
Add permission checkers
fabubaker Apr 30, 2026
269d4d1
Add assertions
fabubaker Apr 30, 2026
966972a
Move jwt code to jwt.rs
fabubaker May 1, 2026
3ca116c
Fix namespace introspect check
fabubaker May 1, 2026
447aef3
Set cache capacity to prevent eviction
fabubaker May 1, 2026
21fea7b
Modify can_introspect_namespace to account for children
fabubaker May 1, 2026
35c828a
Add can_write_namespace
fabubaker May 4, 2026
2aaa287
Create validate.rs
fabubaker May 4, 2026
4d2d8c7
Return Result
fabubaker May 4, 2026
dc2d5bf
Cover more permissions
fabubaker May 4, 2026
7202a59
Modify validate_namespace to use child names
fabubaker May 4, 2026
a7bb414
Fix root node generation
fabubaker May 5, 2026
d0eac20
Run fmt
fabubaker May 5, 2026
03853c7
Merge branch 'db_v4' of github.com:Pometry/Raphtory into features/rba…
fabubaker May 5, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

47 changes: 47 additions & 0 deletions python/tests/test_permissions.py
Original file line number Diff line number Diff line change
Expand Up @@ -1288,6 +1288,53 @@ def test_child_namespace_restriction_overrides_parent():
assert "team/restricted/secret" in paths
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

test_permissions is moved to pometry_storage

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Probably also makes sense to move the rbac prop tests as well to pometry-storage



def test_parent_does_not_override_child_namespace_restriction():
"""A direct child grant should remain effective after a broader parent grant."""
work_dir = tempfile.mkdtemp()
with make_server(work_dir).start():
gql(CREATE_DEEP)
create_role("analyst")

update_deep = """query { updateGraph(path: "a/b/c") { addNode(time: 1, name: "child_write_node") { success } } }"""

# Grant child graph WRITE first and verify write access works.
grant_graph("analyst", "a/b/c", "WRITE")
response = gql(update_deep, headers=ANALYST_HEADERS)
assert "errors" not in response, response
assert response["data"]["updateGraph"]["addNode"]["success"] is True

# Then grant parent namespace READ; child graph WRITE should still win.
grant_namespace("analyst", "a", "READ")
response = gql(update_deep, headers=ANALYST_HEADERS)
assert "errors" not in response, response
assert response["data"]["updateGraph"]["addNode"]["success"] is True


def test_most_specific_namespace_grant_applies():
"""Grant READ at a/b/c/d/e then WRITE at a/b/c.
The descendant graph a/b/c/d/e/f/g should retain READ-level access under specificity rules.
"""
CREATE_DEEP_LEAF = """mutation { newGraph(path:"a/b/c/d/e/f/g", graphType:EVENT) }"""
QUERY_DEEP_LEAF = """query { graph(path: "a/b/c/d/e/f/g") { path } }"""
UPDATE_DEEP_LEAF = """query { updateGraph(path: "a/b/c/d/e/f/g") { addNode(time: 1, name: "from_test") { success } } }"""
work_dir = tempfile.mkdtemp()
with make_server(work_dir).start():
gql(CREATE_DEEP_LEAF)
create_role("analyst")

grant_namespace("analyst", "a/b/c/d/e", "READ")
grant_namespace("analyst", "a/b/c", "WRITE")

response = gql(QUERY_DEEP_LEAF, headers=ANALYST_HEADERS)
assert "errors" not in response, response
assert response["data"]["graph"]["path"] == "a/b/c/d/e/f/g"

response = gql(UPDATE_DEEP_LEAF, headers=ANALYST_HEADERS)
assert response["data"] is None or response["data"].get("updateGraph") is None
assert "errors" in response
assert "Access denied" in response["errors"][0]["message"]


def test_discover_derivation():
"""grantGraph READ on a namespaced graph → ancestor namespace gets DISCOVER (visible in children)."""
work_dir = tempfile.mkdtemp()
Expand Down
8 changes: 7 additions & 1 deletion raphtory-graphql/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -64,13 +64,19 @@ clap = { workspace = true }
rust-embed = { workspace = true }



[dev-dependencies]
parking_lot = { workspace = true }
tempfile = { workspace = true }
pretty_assertions = { workspace = true }
raphtory = { workspace = true, features = ["test-utils"] }
arrow-array = { workspace = true }
auth = { workspace = true }
rand = { workspace = true }
proptest = { workspace = true }
jsonwebtoken = { workspace = true }
serde = { workspace = true }
serde_json = { workspace = true }


[features]
python = ["dep:pyo3", "raphtory/python"]
Expand Down
4 changes: 2 additions & 2 deletions raphtory-graphql/src/client/raphtory_client.rs
Original file line number Diff line number Diff line change
Expand Up @@ -116,9 +116,9 @@ impl RaphtoryGraphQLClient {
.join("\n\t"),
_ => format!("{}", errors),
};

return Err(ClientError::GraphQLErrors(format!(
"After sending query to the server:\n\t{}\nGot the following errors:\n\t{}",
query, message
"Error while executing query: {message}"
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This makes debugging harder. When a test fails you no longer know which query caused the error. Worth keeping the query in the message, even if reformatted.

)));
}

Expand Down
11 changes: 11 additions & 0 deletions raphtory-graphql/src/paths.rs
Original file line number Diff line number Diff line change
Expand Up @@ -220,12 +220,14 @@ pub(crate) fn create_valid_path(
if relative_path.contains(r"//") {
return Err(InvalidPathReason::DoubleForwardSlash.into());
}

if relative_path.contains(r"\") {
return Err(InvalidPathReason::BackslashError.into());
}

let mut full_path = base_path.clone();
let mut cleanup_marker = None;

// fail if any component is a Prefix (C://), tries to access root,
// tries to access a parent dir or is a symlink which could break out of the working dir
for component in user_facing_path.components() {
Expand All @@ -237,6 +239,7 @@ pub(crate) fn create_valid_path(
path: full_path.clone(),
dirty_marker: mark_dirty(&full_path)?,
});

fs::create_dir(&full_path)?;
}
}
Expand Down Expand Up @@ -329,11 +332,13 @@ impl ValidWriteableGraphFolder {
error,
}
})?;

if !path.cleanup.is_some() {
return Err(PathValidationError::GraphExistsError(
relative_path.to_string(),
));
}

Self::new(path, relative_path)
}

Expand Down Expand Up @@ -567,15 +572,21 @@ pub(crate) fn mark_dirty(path: &Path) -> Result<PathBuf, InternalPathValidationE
.to_str()
.ok_or(InternalPathValidationError::NonUTFCharacters)?
.to_string();

let parent = path
.parent()
.ok_or(InternalPathValidationError::MissingParent)?;

ensure_clean_folder(parent)?;

let dirty_file_path = parent.join(DIRTY_PATH);
let mut dirty_file = File::create_new(&dirty_file_path)?;

dirty_file.write_all(&serde_json::to_vec(&RelativePath { path: cleanup_path })?)?;

// make sure the dirty path is properly recorded before we proceed!
dirty_file.sync_all()?;

Ok(dirty_file_path)
}

Expand Down
204 changes: 204 additions & 0 deletions raphtory-graphql/tests/permissions.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,204 @@
mod utils;

use std::{ops::RangeInclusive, str::FromStr};

use proptest::prelude::*;
use raphtory::{
db::{api::view::MaterializedGraph, graph::node::NodeView},
prelude::{GraphViewOps, NodeViewOps, Prop, PropUnwrap, PropertiesOps},
};
use raphtory_graphql::client::raphtory_client::RaphtoryGraphQLClient;
use url::Url;

use utils::{
jwt::{user_jwt, ADMIN_JWT, PUB_KEY},
strategy::{permissions_strategy, GrantType, Permission, PermissionGrant},
};

use utils::graphql::{create_grant, create_graph, create_role, get_client, start_server};

use crate::utils::validate::{validate_graph_grant, validate_namespace_grant};

const PORT: u16 = 43871;

// Track a permission grant across the given namespace tree.
fn track_grant(grant: &PermissionGrant, user_trees: &[MaterializedGraph]) {
let user_id = grant.user_id;
let path = grant.path.as_str();
let permission = grant.permission;

let user_tree = &user_trees[user_id];

// Find the node in the tree corresponding to the given path.
let node_name = path.split('/').last().unwrap();
let node = user_tree.node(node_name).unwrap();

// Update the node's direct permission.
node.update_metadata(vec![
("permission", Prop::Str(permission.to_string().into())),
("direct", Prop::Bool(true)),
])
.unwrap();

// Propagate discover to ancestor namespaces.
propagate_up(path, user_tree);

// Namespace grants also propagate to descendants.
if grant.grant_type == GrantType::Namespace {
propagate_down(path, user_tree, permission);
}
}

// Propagate discover permissions to ancestor namespaces.
fn propagate_up(path: &str, user_tree: &MaterializedGraph) {
// Apply discover to each node in the path.
for node_name in path.split('/').filter(|segment| !segment.is_empty()) {
let node = user_tree.node(node_name).unwrap();

// Discover permissions have least precedence, set them if no other permission is set.
if node.metadata().get("permission").is_none() {
node.update_metadata(vec![
(
"permission",
Prop::Str(Permission::Discover.to_string().into()),
),
("direct", Prop::Bool(false)),
])
.unwrap();
}
}
}

// Propagates a permission to descendant namespaces and graphs.
fn propagate_down(path: &str, user_tree: &MaterializedGraph, permission: Permission) {
let node_name = path.split('/').last().unwrap();
let node = user_tree.node(node_name).unwrap();
let mut stack = Vec::new();

// Start by propagating to the immediate children.
for neighbour in node.out_neighbours() {
stack.push(neighbour);
}

while let Some(node) = stack.pop() {
let node_permission = node.metadata().get("permission");

if let Some(_) = node_permission {
let direct = node.metadata().get("direct").unwrap().into_bool().unwrap();

// Direct permissions override inherited permissions, skip this node
// and stop propagating.
if direct {
continue;
}
}

node.update_metadata(vec![
("permission", Prop::Str(permission.to_string().into())),
("direct", Prop::Bool(false)),
])
.unwrap();

for neighbour in node.out_neighbours() {
stack.push(neighbour);
}
}
}

fn validate_grant(
path: &str,
node: &NodeView<'_, MaterializedGraph>,
client: &RaphtoryGraphQLClient,
) {
let permission = node
.metadata()
.get("permission")
.and_then(|p| p.into_str())
.and_then(|s| Permission::from_str(s.as_ref()).ok());

let is_graph_path = node.out_neighbours().is_empty();

if is_graph_path {
validate_graph_grant(path, permission, client);
} else {
let children: Vec<String> = node
.out_neighbours()
.into_iter()
.map(|n| n.name().to_string())
.collect();

validate_namespace_grant(path, permission, client, &children);
}
}

#[test]
fn permissions_proptest() {
const PROPTEST_CASES: u32 = 10;
const NAMESPACE_SIZE: RangeInclusive<usize> = 1..=100;
const NUM_GRANTS: RangeInclusive<usize> = 1..=100;
const NUM_USERS: RangeInclusive<usize> = 1..=20;

proptest!(
ProptestConfig::with_cases(PROPTEST_CASES),
|(case in permissions_strategy(NAMESPACE_SIZE, NUM_GRANTS, NUM_USERS))| {
let total_graphs = case.graph_paths.len() as u64;
let (_server, _tempdir) = start_server(PORT, PUB_KEY, total_graphs);
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The server is never explicitly stopped between iterations. _server is just dropped, which aborts the background task without sending a shutdown signal. The OS socket is left open, so the next iteration's start_server call might fail to bind port 43871 with "address already in use". The abort via drop is fast enough that the OS usually reclaims the socket before the next iteration tries to bind but it's a race condition. Under load, a slow CI machine, or with OS scheduling delays, the socket won't be released in time and the test will fail.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Probably, we reuse the same server for all iterations, cleaning up before next iteration or use OS-assigned port per iteration?

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Also we don't seem to be cleaning up the _tempdir. Leaves directories on disk permanently.


let url = Url::parse(&format!("http://127.0.0.1:{PORT}")).unwrap();
let admin_client = get_client(url.clone(), ADMIN_JWT.to_string()).unwrap();

let tree = case.namespace_tree;

// Create graphs on the server.
for path in &case.graph_paths {
create_graph(path, &admin_client).unwrap();
}

let mut user_trees = Vec::with_capacity(case.num_users);

// Create roles and separate namespace trees for each user.
for i in 0..case.num_users {
let role = format!("user_{i}");
create_role(&role, &admin_client).unwrap();

let user_tree = tree.materialize().unwrap();
user_trees.push(user_tree);
}

// Create grants on the server and track them locally.
for grant in &case.grants {
create_grant(grant, &admin_client).unwrap();
track_grant(grant, &user_trees);
}

// Validate grants across graph paths for each user.
for path in &case.graph_paths {
for (user_id, user_tree) in user_trees.iter().enumerate() {
let node_name = path.split('/').last().unwrap();
let node = user_tree.node(node_name).unwrap();

let role = format!("user_{user_id}");
let user_client = get_client(url.clone(), user_jwt(&role)).unwrap();
validate_grant(path.as_str(), &node, &user_client);
}
}

// Validate grants across namespace paths for each user.
for path in &case.namespace_paths {
for (user_id, user_tree) in user_trees.iter().enumerate() {
let node_name = path.split('/').last().unwrap();
let node = user_tree.node(node_name).unwrap();

// Ignore namespaces with no children since permissions are irrelevant.
if node.out_neighbours().is_empty() {
continue;
}

let role = format!("user_{user_id}");
let user_client = get_client(url.clone(), user_jwt(&role)).unwrap();
validate_grant(path.as_str(), &node, &user_client);
}
}
}
);
}
Loading
Loading