-
Notifications
You must be signed in to change notification settings - Fork 68
Add proptest for testing RBAC permissions #2596
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: db_v4
Are you sure you want to change the base?
Changes from all commits
7b05207
1498a32
7b8d05e
83bb443
b6cac8d
835263c
3e28146
eeff475
af96723
b954d57
79026a4
b9b3b20
64e07a9
3bde749
9e88638
ab448f8
31c87d1
7bc52c2
7f7bc75
1a15a0c
269d4d1
966972a
3ca116c
447aef3
21fea7b
35c828a
2aaa287
4d2d8c7
dc2d5bf
7202a59
a7bb414
d0eac20
03853c7
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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}" | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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. |
||
| ))); | ||
| } | ||
|
|
||
|
|
||
| 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); | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The server is never explicitly stopped between iterations.
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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?
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Also we don't seem to be cleaning up the |
||
|
|
||
| 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); | ||
| } | ||
| } | ||
| } | ||
| ); | ||
| } | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
test_permissionsis moved topometry_storageThere was a problem hiding this comment.
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