Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
49 changes: 49 additions & 0 deletions docs/reference/graphql/graphql_API.md
Original file line number Diff line number Diff line change
Expand Up @@ -449,6 +449,55 @@ Base64-encoded bincode of the serialised graph.

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

</td>
</tr>
<tr>
<td colspan="2" valign="top"><strong id="mutroot.createnamespace">createNamespace</strong></td>
<td valign="top"><a href="#string">String</a>!</td>
<td>

Create an empty namespace at `path`.

Creates any missing parent namespaces along the way. Requires WRITE
permission on the parent namespace. Rejects paths that already host a
graph or an existing namespace, and paths that fail validation.

Returns:: the path of the created namespace

</td>
</tr>
<tr>
<td colspan="2" align="right" valign="top">path</td>
<td valign="top"><a href="#string">String</a>!</td>
<td>

Destination path relative to the root namespace.

</td>
</tr>
<tr>
<td colspan="2" valign="top"><strong id="mutroot.deletenamespace">deleteNamespace</strong></td>
<td valign="top"><a href="#boolean">Boolean</a>!</td>
<td>

Delete a namespace and all of its descendants (graphs and sub-namespaces).

Requires WRITE permission on the parent namespace, on the namespace
itself, and on every descendant graph and sub-namespace. Cached graphs
at any deleted path are invalidated. Rejects empty and non-existent
paths.

Returns:: true on success

</td>
</tr>
<tr>
<td colspan="2" align="right" valign="top">path</td>
<td valign="top"><a href="#string">String</a>!</td>
<td>

Path to delete relative to the root namespace.

</td>
</tr>
<tr>
Expand Down
31 changes: 31 additions & 0 deletions raphtory-graphql/schema.graphql
Original file line number Diff line number Diff line change
Expand Up @@ -2724,6 +2724,37 @@ type MutRoot {
overwrite: Boolean!
): String!
"""
Create an empty namespace at `path`.
Creates any missing parent namespaces along the way. Requires WRITE
permission on the parent namespace. Rejects paths that already host a
graph or an existing namespace, and paths that fail validation.
Returns:: the path of the created namespace
"""
createNamespace(
"""
Destination path relative to the root namespace.
"""
path: String!
): String!
"""
Delete a namespace and all of its descendants (graphs and sub-namespaces).
Requires WRITE permission on the parent namespace, on the namespace
itself, and on every descendant graph and sub-namespace. Cached graphs
at any deleted path are invalidated. Rejects empty and non-existent
paths.
Returns:: true on success
"""
deleteNamespace(
"""
Path to delete relative to the root namespace.
"""
path: String!
): Boolean!
"""
Returns a subgraph given a set of nodes from an existing graph in the server.
Returns::
Expand Down
14 changes: 12 additions & 2 deletions raphtory-graphql/src/auth.rs
Original file line number Diff line number Diff line change
Expand Up @@ -183,10 +183,10 @@ where
let req = batch_req.data(access).data(role);

let contains_update = match &req {
BatchRequest::Single(request) => request.query.contains("updateGraph"),
BatchRequest::Single(request) => is_exclusive_write(&request.query),
BatchRequest::Batch(requests) => requests
.iter()
.any(|request| request.query.contains("updateGraph")),
.any(|request| is_exclusive_write(&request.query)),
};
if contains_update {
if let Some(lock) = &self.lock {
Expand All @@ -207,6 +207,16 @@ where
}
}

fn is_exclusive_write(query: &str) -> bool {
is_operation(query, "updateGraph") || is_operation(query, "deleteNamespace")
}

fn is_operation(query: &str, op: &str) -> bool {
query
.split(|c: char| !c.is_alphanumeric() && c != '_')
.any(|token| token == op)
}

fn is_query_heavy(query: &str) -> bool {
query.contains("outComponent")
|| query.contains("inComponent")
Expand Down
55 changes: 55 additions & 0 deletions raphtory-graphql/src/auth_policy.rs
Original file line number Diff line number Diff line change
Expand Up @@ -136,3 +136,58 @@ pub trait AuthorizationPolicy: Send + Sync + 'static {
path: &str,
) -> NamespacePermission;
}

#[cfg(test)]
pub(crate) mod auth_policy_tests {
use super::{AuthPolicyError, AuthorizationPolicy, GraphPermission, NamespacePermission};
use std::collections::HashMap;

/// Test-only authorization policy: every path must be configured explicitly via
/// [`Self::with_namespace`] / [`Self::with_graph`]. Unknown namespaces default
/// to `NamespacePermission::Denied` and unknown graphs return `Err`. This is
/// stricter than the production policy's fail-open contract — that's
/// intentional, so a missing `with_*` call in a test surfaces as an obvious
/// failure rather than as a silent allow.
#[derive(Default)]
pub(crate) struct FakePolicy {
namespaces: HashMap<String, NamespacePermission>,
graphs: HashMap<String, GraphPermission>,
}

#[allow(dead_code)]
impl FakePolicy {
pub(crate) fn with_namespace(mut self, path: &str, perm: NamespacePermission) -> Self {
self.namespaces.insert(path.to_string(), perm);
self
}
pub(crate) fn with_graph(mut self, path: &str, perm: GraphPermission) -> Self {
self.graphs.insert(path.to_string(), perm);
self
}
}

impl AuthorizationPolicy for FakePolicy {
fn graph_permissions(
&self,
_ctx: &async_graphql::Context<'_>,
path: &str,
) -> Result<GraphPermission, AuthPolicyError> {
match self.graphs.get(path) {
Some(p) => Ok(p.clone()),
None => Err(AuthPolicyError::new(format!(
"no permission for graph {path}"
))),
}
}
fn namespace_permissions(
&self,
_ctx: &async_graphql::Context<'_>,
path: &str,
) -> NamespacePermission {
self.namespaces
.get(path)
.cloned()
.unwrap_or(NamespacePermission::Denied)
}
}
}
58 changes: 58 additions & 0 deletions raphtory-graphql/src/data.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@ use crate::{
blocking_io,
graph::{
filtering::{GraphAccessFilter, GraphRowFilter, HiddenKeys},
namespace::Namespace,
namespaced_item::NamespacedItem,
vectorised_graph::GqlVectorisedGraph,
},
},
Expand Down Expand Up @@ -323,6 +325,62 @@ impl Data {
Ok(())
}

pub async fn delete_namespace(
&self,
path: &str,
descendants: &Vec<NamespacedItem>,
) -> Result<(), DeletionError> {
if path.is_empty() {
return Err(DeletionError::PathValidation(
PathValidationError::EmptyPath,
));
}
let namespace = Namespace::try_new(self.work_dir.clone(), path.to_string())?;
let root = namespace.current_dir().to_path_buf();
let dirty_file = mark_dirty(&root).map_err(|err| {
DeletionError::from_inner(path, MutationErrorInner::InvalidInternal(err))
})?;
for item in descendants {
if let NamespacedItem::MetaGraph(g) = item {
self.invalidate(g.local_path()).await;
self.cache.remove(g.local_path()).await;
}
}
blocking_io(move || {
fs::remove_dir_all(&root)?;
fs::remove_file(dirty_file)?;
Ok::<_, MutationErrorInner>(())
})
.await
.map_err(|err| DeletionError::from_inner(path, err))?;
Ok(())
}

pub async fn create_namespace(&self, path: &str) -> Result<(), InsertionError> {
let target = crate::paths::validate_path_for_namespace_create(self.work_dir.clone(), path)?;
let mut cleanup_root = target.as_path();
while let Some(parent) = cleanup_root.parent() {
if parent.is_dir() {
break;
}
cleanup_root = parent;
}
let dirty_file = mark_dirty(cleanup_root).map_err(|err| {
InsertionError::from_inner(path, MutationErrorInner::InvalidInternal(err))
})?;
blocking_io(move || {
if let Some(parent) = target.parent() {
fs::create_dir_all(parent)?;
}
fs::create_dir(&target)?;
fs::remove_file(dirty_file)?;
Ok::<_, MutationErrorInner>(())
})
.await
.map_err(|err| InsertionError::from_inner(path, err))?;
Ok(())
}

async fn vectorise_with_template(
&self,
graph: MaterializedGraph,
Expand Down
Loading