Skip to content

Commit fbe7bf5

Browse files
louischgithub-actions[bot]shivamka1
authored
Add namespace creation/deletion to graphql (#2608)
* Add namespace creation/deletion to graphql Add TestSetup struct, setup_with_graphs, run_mutation, and assert_is_namespace_dir helpers to mod graphql_test in raphtory-graphql/src/lib.rs for use by namespace tests in later tasks. Previously called validate_path_for_insert which created a graph-folder skeleton + dirty marker on disk and leaked them, so the new namespace appeared as a MetaGraph. Now uses validate_path_for_namespace_create plus fs::create_dir_all. test: createNamespace creates nested directories test: createNamespace rejects path of existing graph test: createNamespace rejects path of existing namespace test: createNamespace rejects invalid paths test: tighten createNamespace existing-namespace error check test: add FakePolicy and setup_with_policy helpers test: createNamespace denied without parent write test: tighten FakePolicy docs and silence dead-code warning test: deleteNamespace removes empty namespace test: deleteNamespace removes namespace with children test: deleteNamespace rejects empty path test: deleteNamespace rejects non-existent path test: deleteNamespace denied when descendant graph unwritable test: deleteNamespace invalidates cached graphs test: clarify deleteNamespace denied-test comments feat(graphql): deleteNamespace infrastructure - auth.rs: add is_exclusive_write so deleteNamespace acquires the exclusive write lock alongside updateGraph - namespace.rs: expose current_dir() and relative_path() accessors used by Mut::delete_namespace and the data layer * Mark paths dirty before cache eviction and in create_namespace * chore: apply tidy-public auto-fixes * Fix race condition in create_namespace * Add tests asserting failure due to lack of permissions * chore: apply tidy-public auto-fixes --------- Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com> Co-authored-by: Shivam <4599890+shivamka1@users.noreply.github.com>
1 parent 3bb20c3 commit fbe7bf5

10 files changed

Lines changed: 785 additions & 4 deletions

File tree

docs/reference/graphql/graphql_API.md

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -449,6 +449,55 @@ Base64-encoded bincode of the serialised graph.
449449

450450
If true, replace any graph already at `path`.
451451

452+
</td>
453+
</tr>
454+
<tr>
455+
<td colspan="2" valign="top"><strong id="mutroot.createnamespace">createNamespace</strong></td>
456+
<td valign="top"><a href="#string">String</a>!</td>
457+
<td>
458+
459+
Create an empty namespace at `path`.
460+
461+
Creates any missing parent namespaces along the way. Requires WRITE
462+
permission on the parent namespace. Rejects paths that already host a
463+
graph or an existing namespace, and paths that fail validation.
464+
465+
Returns:: the path of the created namespace
466+
467+
</td>
468+
</tr>
469+
<tr>
470+
<td colspan="2" align="right" valign="top">path</td>
471+
<td valign="top"><a href="#string">String</a>!</td>
472+
<td>
473+
474+
Destination path relative to the root namespace.
475+
476+
</td>
477+
</tr>
478+
<tr>
479+
<td colspan="2" valign="top"><strong id="mutroot.deletenamespace">deleteNamespace</strong></td>
480+
<td valign="top"><a href="#boolean">Boolean</a>!</td>
481+
<td>
482+
483+
Delete a namespace and all of its descendants (graphs and sub-namespaces).
484+
485+
Requires WRITE permission on the parent namespace, on the namespace
486+
itself, and on every descendant graph and sub-namespace. Cached graphs
487+
at any deleted path are invalidated. Rejects empty and non-existent
488+
paths.
489+
490+
Returns:: true on success
491+
492+
</td>
493+
</tr>
494+
<tr>
495+
<td colspan="2" align="right" valign="top">path</td>
496+
<td valign="top"><a href="#string">String</a>!</td>
497+
<td>
498+
499+
Path to delete relative to the root namespace.
500+
452501
</td>
453502
</tr>
454503
<tr>

raphtory-graphql/schema.graphql

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2724,6 +2724,37 @@ type MutRoot {
27242724
overwrite: Boolean!
27252725
): String!
27262726
"""
2727+
Create an empty namespace at `path`.
2728+
2729+
Creates any missing parent namespaces along the way. Requires WRITE
2730+
permission on the parent namespace. Rejects paths that already host a
2731+
graph or an existing namespace, and paths that fail validation.
2732+
2733+
Returns:: the path of the created namespace
2734+
"""
2735+
createNamespace(
2736+
"""
2737+
Destination path relative to the root namespace.
2738+
"""
2739+
path: String!
2740+
): String!
2741+
"""
2742+
Delete a namespace and all of its descendants (graphs and sub-namespaces).
2743+
2744+
Requires WRITE permission on the parent namespace, on the namespace
2745+
itself, and on every descendant graph and sub-namespace. Cached graphs
2746+
at any deleted path are invalidated. Rejects empty and non-existent
2747+
paths.
2748+
2749+
Returns:: true on success
2750+
"""
2751+
deleteNamespace(
2752+
"""
2753+
Path to delete relative to the root namespace.
2754+
"""
2755+
path: String!
2756+
): Boolean!
2757+
"""
27272758
Returns a subgraph given a set of nodes from an existing graph in the server.
27282759
27292760
Returns::

raphtory-graphql/src/auth.rs

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -183,10 +183,10 @@ where
183183
let req = batch_req.data(access).data(role);
184184

185185
let contains_update = match &req {
186-
BatchRequest::Single(request) => request.query.contains("updateGraph"),
186+
BatchRequest::Single(request) => is_exclusive_write(&request.query),
187187
BatchRequest::Batch(requests) => requests
188188
.iter()
189-
.any(|request| request.query.contains("updateGraph")),
189+
.any(|request| is_exclusive_write(&request.query)),
190190
};
191191
if contains_update {
192192
if let Some(lock) = &self.lock {
@@ -207,6 +207,16 @@ where
207207
}
208208
}
209209

210+
fn is_exclusive_write(query: &str) -> bool {
211+
is_operation(query, "updateGraph") || is_operation(query, "deleteNamespace")
212+
}
213+
214+
fn is_operation(query: &str, op: &str) -> bool {
215+
query
216+
.split(|c: char| !c.is_alphanumeric() && c != '_')
217+
.any(|token| token == op)
218+
}
219+
210220
fn is_query_heavy(query: &str) -> bool {
211221
query.contains("outComponent")
212222
|| query.contains("inComponent")

raphtory-graphql/src/auth_policy.rs

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -136,3 +136,58 @@ pub trait AuthorizationPolicy: Send + Sync + 'static {
136136
path: &str,
137137
) -> NamespacePermission;
138138
}
139+
140+
#[cfg(test)]
141+
pub(crate) mod auth_policy_tests {
142+
use super::{AuthPolicyError, AuthorizationPolicy, GraphPermission, NamespacePermission};
143+
use std::collections::HashMap;
144+
145+
/// Test-only authorization policy: every path must be configured explicitly via
146+
/// [`Self::with_namespace`] / [`Self::with_graph`]. Unknown namespaces default
147+
/// to `NamespacePermission::Denied` and unknown graphs return `Err`. This is
148+
/// stricter than the production policy's fail-open contract — that's
149+
/// intentional, so a missing `with_*` call in a test surfaces as an obvious
150+
/// failure rather than as a silent allow.
151+
#[derive(Default)]
152+
pub(crate) struct FakePolicy {
153+
namespaces: HashMap<String, NamespacePermission>,
154+
graphs: HashMap<String, GraphPermission>,
155+
}
156+
157+
#[allow(dead_code)]
158+
impl FakePolicy {
159+
pub(crate) fn with_namespace(mut self, path: &str, perm: NamespacePermission) -> Self {
160+
self.namespaces.insert(path.to_string(), perm);
161+
self
162+
}
163+
pub(crate) fn with_graph(mut self, path: &str, perm: GraphPermission) -> Self {
164+
self.graphs.insert(path.to_string(), perm);
165+
self
166+
}
167+
}
168+
169+
impl AuthorizationPolicy for FakePolicy {
170+
fn graph_permissions(
171+
&self,
172+
_ctx: &async_graphql::Context<'_>,
173+
path: &str,
174+
) -> Result<GraphPermission, AuthPolicyError> {
175+
match self.graphs.get(path) {
176+
Some(p) => Ok(p.clone()),
177+
None => Err(AuthPolicyError::new(format!(
178+
"no permission for graph {path}"
179+
))),
180+
}
181+
}
182+
fn namespace_permissions(
183+
&self,
184+
_ctx: &async_graphql::Context<'_>,
185+
path: &str,
186+
) -> NamespacePermission {
187+
self.namespaces
188+
.get(path)
189+
.cloned()
190+
.unwrap_or(NamespacePermission::Denied)
191+
}
192+
}
193+
}

raphtory-graphql/src/data.rs

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,8 @@ use crate::{
77
blocking_io,
88
graph::{
99
filtering::{GraphAccessFilter, GraphRowFilter, HiddenKeys},
10+
namespace::Namespace,
11+
namespaced_item::NamespacedItem,
1012
vectorised_graph::GqlVectorisedGraph,
1113
},
1214
},
@@ -323,6 +325,62 @@ impl Data {
323325
Ok(())
324326
}
325327

328+
pub async fn delete_namespace(
329+
&self,
330+
path: &str,
331+
descendants: &Vec<NamespacedItem>,
332+
) -> Result<(), DeletionError> {
333+
if path.is_empty() {
334+
return Err(DeletionError::PathValidation(
335+
PathValidationError::EmptyPath,
336+
));
337+
}
338+
let namespace = Namespace::try_new(self.work_dir.clone(), path.to_string())?;
339+
let root = namespace.current_dir().to_path_buf();
340+
let dirty_file = mark_dirty(&root).map_err(|err| {
341+
DeletionError::from_inner(path, MutationErrorInner::InvalidInternal(err))
342+
})?;
343+
for item in descendants {
344+
if let NamespacedItem::MetaGraph(g) = item {
345+
self.invalidate(g.local_path()).await;
346+
self.cache.remove(g.local_path()).await;
347+
}
348+
}
349+
blocking_io(move || {
350+
fs::remove_dir_all(&root)?;
351+
fs::remove_file(dirty_file)?;
352+
Ok::<_, MutationErrorInner>(())
353+
})
354+
.await
355+
.map_err(|err| DeletionError::from_inner(path, err))?;
356+
Ok(())
357+
}
358+
359+
pub async fn create_namespace(&self, path: &str) -> Result<(), InsertionError> {
360+
let target = crate::paths::validate_path_for_namespace_create(self.work_dir.clone(), path)?;
361+
let mut cleanup_root = target.as_path();
362+
while let Some(parent) = cleanup_root.parent() {
363+
if parent.is_dir() {
364+
break;
365+
}
366+
cleanup_root = parent;
367+
}
368+
let dirty_file = mark_dirty(cleanup_root).map_err(|err| {
369+
InsertionError::from_inner(path, MutationErrorInner::InvalidInternal(err))
370+
})?;
371+
blocking_io(move || {
372+
if let Some(parent) = target.parent() {
373+
fs::create_dir_all(parent)?;
374+
}
375+
fs::create_dir(&target)?;
376+
fs::remove_file(dirty_file)?;
377+
Ok::<_, MutationErrorInner>(())
378+
})
379+
.await
380+
.map_err(|err| InsertionError::from_inner(path, err))?;
381+
Ok(())
382+
}
383+
326384
async fn vectorise_with_template(
327385
&self,
328386
graph: MaterializedGraph,

0 commit comments

Comments
 (0)