Permissions

Graphersal is an in-process, in-memory library: the program that holds the graph usually also writes the queries. Everything is therefore allowed by default, in the Rust API and in the Rhai script entry alike. A host that runs input it does not trust (a web form, a multi-tenant query service, a pasted snippet, an LLM) plugs in an authorizer: a graphersal::auth::Authorizer that is asked for every request a query makes, before the query runs.

Resource use is a separate concern, bounded by Resource Limits and Query Limits.

Requests: an action on a resource

Every step and every file or schema operation of a query makes one or more AccessRequests, an Action on a Resource:

Action
Readread data, the schema, a catalog definition or a file
Createcreate elements, write a file
Updatechange elements (properties, labels), the schema or an option
Deletedrop elements or properties
Executerun code: Rhai's import "module", calling a saved query
Custom(name)an action of an embedding server, never produced by the library
Resource
Data { element, label }graph data; element (Vertex/Edge) and label are set when the step knows them statically from the plan, None otherwise
Schemathe graph's schema
Definition { kind, name }a definition of the graph's catalog (a saved query, ...); kind and name are None for a request about all of them (listing)
File { path }a file, with the path as the script wrote it
Option { key }an administrative g.with() option (below)
Custom { kind, name }a resource of an embedding server (a database, a tenant, ...), never produced by the library

What each step requests is decided in one place in the engine (GraphStep::for_each_access):

QueryRequests
V() / out_v(), in_v(), both_v(), other_v()Read on Data(vertex)
E() / out_e(), in_e(), both_e()Read on Data(edge)
out(), in(), both()Read on Data(edge) and on Data(vertex)
subgraph("sg")Read on Data(vertex) (it copies the endpoints of the edges it receives)
every other step (has(), values(), limit(), count(), inject(), constant(), math(), as(), by(), ...)nothing
addV("person"), addV(["a", "b"])Create on Data(vertex "person"), one request per label (Data(vertex) without a static label)
addE("knows")Create on Data(edge "knows")
mergeV(..) / mergeE(..)Create and Update on Data(vertex) / Data(edge)
property(..), removeProperty(..)Update on Data
addLabel(..), dropLabel(..) / setLabel(..)Update on Data(vertex) / Data(edge)
drop()Delete on Data
infer_schema(), g.get_schema(), g.infer_schema(), g.diff_schema(..), g.validate_schema(..), g.validate_schema_patch(..)Read on Schema
g.statistics(), g.memory_usage()Read on Data
g.set_schema(..), g.patch_schema(..)Update on Schema (a schema is never read from a file)
list_definitions(kind) (Rust)Read on Definition(kind) (Definition for every kind)
get_definition(kind, name) (Rust)Read on Definition(kind "name")
set_definition(..), define_query(..) (Rust)Create on Definition(kind "name") for a new definition, Update when it replaces one
move_query(..), describe_query(..) (Rust)Update on Definition(query "name")
remove_definition(kind, name) (Rust)Delete on Definition(kind "name")
g.query("name", ..)Execute on Definition(query "name") before the body runs; the body's own requests then go through the host's authorizer under the saved query's read-only restriction (every write denied; see Saved Queries)
g.queries(), g.queries(folder)Read on Definition(query)
g.get_query("name")Read on Definition(query "name")
g.define_query(..), g.define_queries(..)Create on Definition(query "name") per query, Update when it replaces one
g.move_query(..), g.describe_query(..)Update on Definition(query "name")
g.drop_query("name")Delete on Definition(query "name")
g.define_compression(..)Create on Definition(compression "name"), Update when it replaces one
g.compressions()Read on Definition(compression)
g.recompress("name") / g.drop_compression("name")Update / Delete on Definition(compression "name")
g.mark("name")Update on Data
g.export_snapshot(path)Read on Data, Create on File(path)
g.export_graphml(path)Read on Data, Create on File(path)
g.import_graphml(path)Create on Data, Read on File(path)
g.export_graphson(path) / exportGraphsonRead on Data, Create on File(path)
g.import_graphson(path) / importGraphsonCreate on Data, Read on File(path)
GraphSource::file(path)Read on File(path)
import "module" (Rhai)Execute on File("module.rhai")
an administrative g.with(key, ..)Update on Option(key)

Only steps that reach graph data ask for a read: the sources (V(), E(), also in the middle of a traversal) and the adjacency steps. A value-only step works on what earlier steps produced, so it asks nothing: g.V().has("age", P.gt(30)).values("name").limit(2) asks exactly once, for Read on Data(vertex), and g.inject(1, 2).sum() runs under AccessPolicy::deny_all(). No live element handle survives a run: let v = g.V().next() is the vertex materialized as a map and inject() takes values, so g.inject(v) reads that copy and asks nothing.

Labels of reads are not reported: a request reports what the plan states, and a plan cannot say statically which labels a read reaches. Element- and label-level read security is a later storage decorator (Later). A policy that restricts one element kind or label must treat None as "possibly that one".

The ready-made policy: AccessPolicy

AccessPolicy allows or denies by action kind and resource kind, plus an optional file root. It covers what the shipped front ends need without a trait implementation of their own:

#![allow(unused)]
fn main() {
use graphersal::prelude::*;
use graphersal::auth::{AccessPolicy, ActionKind, ResourceKind};
use graphersal::catalog::Definition;

let query_service = AccessPolicy::read_only();             // Read on Data, Schema, Definition;
                                                           // Execute on Definition (saved queries)
let editor = AccessPolicy::read_only()
    .allow(ActionKind::Create, ResourceKind::Data)
    .allow(ActionKind::Update, ResourceKind::Data)
    .allow(ActionKind::Delete, ResourceKind::Data);
let server = AccessPolicy::allow_all().deny_resource(ResourceKind::File);
let nothing = AccessPolicy::deny_all();                    // a script can only compute
let _ = (query_service, editor, server, nothing);
}

Restricting a script

graphersal::script::engine(), graph_scope(g) and the eval_* functions without limits allow everything. The entries that take explicit limits take the authorizer too:

#![allow(unused)]
fn main() {
use std::sync::Arc;
use graphersal::prelude::*;
use graphersal::auth::{AccessPolicy, Authorizer};
use graphersal::script::ScriptLimits;

let graph = Arc::new(GraphSource::tinkerpop_modern());
let read_only: Arc<dyn Authorizer> = Arc::new(AccessPolicy::read_only());
let names = graphersal::script::eval_value_with_limits(
    graph.clone(),
    r#"g.V().hasLabel("person").values("name").toList()"#,
    Default::default(),
    &ScriptLimits::default(),
    read_only.clone(),
);
assert!(names.is_ok());
let denied = graphersal::script::eval_value_with_limits(
    graph,
    r#"g.addV("x").next()"#,
    Default::default(),
    &ScriptLimits::default(),
    read_only,
);
assert!(denied
    .unwrap_err()
    .to_string()
    .contains(r#"Permission denied for 'add_v' (Create Data(vertex "x"))"#));
}

A host that drives its own engine passes the same authorizer to both halves of the entry: the engine (Rhai's import, and the graphs a script creates with GraphSource::empty() and friends) and the scope (the session's g):

#![allow(unused)]
fn main() {
use std::sync::Arc;
use graphersal::prelude::*;
use graphersal::auth::{AccessPolicy, Authorizer};
use graphersal::script::{engine_with_limits, graph_scope_with_options, ScriptLimits};

let graph = Arc::new(GraphSource::tinkerpop_modern());
let authorizer: Arc<dyn Authorizer> = Arc::new(AccessPolicy::read_only());
let engine = engine_with_limits(&ScriptLimits::default(), authorizer.clone());
let mut scope = graph_scope_with_options(graph, &[], authorizer);
}

A Rust-built traversal is checked the same way with GraphTraversalSource::with_authorizer(authorizer).

The front ends this project ships:

Front endPolicy
graphersal CLIeverything (AllowAll): a local tool over your own graph and files
Web playground and the dev server (graphersal --server)everything but files (AccessPolicy::allow_all().deny_resource(ResourceKind::File)): the browser has no file system; graphs are loaded and saved through the page
MCP endpoint of the dev serverthe same as the page; with --mcp-read-only, read_only_authorizer() of graphersal-session: no files, and no Create/Update/Delete on Data, Schema or Definition
Python binding (Graph.execute, query, dry_run)everything when the call names no policy=: a Python program is a trusted local host. Pass policy=graphersal.AccessPolicy.read_only() (or another AccessPolicy) per call for scripts you did not write

Writing an authorizer: roles from a token

The subject (who asks) is not part of the request. It lives inside the authorizer instance, so a server builds one authorizer per connection or session, for example from the roles in a JWT it has already verified, and maps them to actions and resources. Roles are not a library concept:

#![allow(unused)]
fn main() {
use std::sync::Arc;
use graphersal::prelude::*;
use graphersal::auth::{AccessRequest, Action, Authorizer, Denied, Resource};

/// Built per connection from the verified token's claims.
struct Session {
    roles: Vec<String>,
}

impl Session {
    fn has(&self, role: &str) -> bool {
        self.roles.iter().any(|r| r == role)
    }
}

impl Authorizer for Session {
    fn authorize(&self, request: &AccessRequest<'_>) -> Result<(), Denied> {
        let allowed = match (request.action, request.resource) {
            (_, _) if self.has("admin") => true,
            // Everybody reads data and the schema.
            (Action::Read, Resource::Data { .. } | Resource::Schema) => true,
            // Editors change data, except elements labelled `salary`.
            (Action::Create | Action::Update | Action::Delete, Resource::Data { label, .. }) => {
                self.has("editor") && label != Some("salary")
            }
            // Server-owned resources go through the same policy.
            (Action::Custom("open"), Resource::Custom { kind: "database", name }) => {
                name == Some("shared") || self.has("dba")
            }
            _ => false,
        };
        if allowed {
            Ok(())
        } else {
            Err(Denied::new(format!("{request} needs another role")))
        }
    }
}

let session = Arc::new(Session { roles: vec!["editor".into()] });
// The server checks its own resources at its own boundary ...
let open = AccessRequest::new(
    Action::Custom("open"),
    Resource::Custom { kind: "database", name: Some("shared") },
);
assert!(session.authorize(&open).is_ok());
// ... and hands the same authorizer to the script entry.
let graph = Arc::new(GraphSource::tinkerpop_modern());
let denied = graphersal::script::eval_value_with_limits(
    graph,
    r#"g.addV("salary").property("amount", 1).next()"#,
    Default::default(),
    &graphersal::script::ScriptLimits::default(),
    session,
);
assert!(denied.unwrap_err().to_string().contains("needs another role"));
}

Resource::Custom and Action::Custom are never produced by the library: they exist so that an embedding server can put its own resources (databases, tenants, endpoints) under the same policy and call authorize itself.

The check

The check runs once per execution, before anything runs, on the plan as written: before the optimizer, so no rule can hide a step by fusing it (add_property_fold folds property() into addV, count_pushdown folds count() into V(); the check still sees each of them). It recurses into every child traversal (union, where, by, repeat, sideEffect, ...), and it costs one pass over the steps. Each distinct request is asked once per execution: g.V().out().in() asks Read on Data(vertex) and on Data(edge), once each. There are no per-element checks. A denial is TraverserError::PermissionDenied { step, request, reason } naming the first step (in plan order) whose request was denied, the request (AccessRequestBuf, as_request() gives the action and the resource) and the authorizer's reason; nothing of the traversal has run. A denied step is located like a failing one: the error is the TraverserError::StepFailed chain of that step (at #1: v().add_v("x"), step_location(); step ids are positions of the plan as written) and the denial is its root_cause(). A denied with("key") option or a source method (file, schema) has no step and stays the bare denial. execute() returns the denial in its error instead of throwing.

"Once per execution" means once per traversal that runs. A script, and a saved query's body, may run several traversals: each one is checked when it runs, so a denial in a later one comes after the earlier ones have run (in the playground the whole script is one unit and is rolled back; a saved query only reads). A saved query is checked in two layers. g.query("name", ..) asks Execute on the query before its body runs. The body's traversals then go through the same check with the host's authorizer plus the saved query's read-only restriction: a step the host denies is denied inside the body too (the error carries a line in saved query: "name"), and every write is denied whatever the host allows. A traversal the query returns keeps that restriction, so g.query("name").drop() is denied as well. Nothing in a saved query can do more than its caller may.

Functions are never hidden: a denied step is still registered, listed by help() and completed by the playground. Only running it is denied.

Administrative g.with() options

A query may set the semantic options freely; setting an administrative one is an Update on Option(key):

OptionRequest
evaluationTimeout, repeat.max_loops, bulk.max_expansion, traversal.max_traversers, traversal.max_value_depth, traversal.max_materialized_bytes, traversal.max_string_bytes, memory.limit, memory.headroom (resource ceilings)Update on Option(key)
optimizer.disabled, optimizer.enabled, path.analysis, bulk.merge (engine switches)Update on Option(key)
repeat.order, bulk.one (withBulk(false)), random.seed, render.spelling, the display options (render.max_rows, ...), unknown keys (ignored by the engine)none

An option the host preset (graph_scope_with_options(graph, &[("evaluationTimeout", ..)], ..), or set on a Rust source before with_authorizer) is not the query's setting and asks nothing; a query that sets such a key to another value does. The presets apply to every source of the session: g, _g and __, and, when the engine is built with engine_with_options(&limits, authorizer, &options) and the same options, the sources a script creates itself (GraphSource::empty(), ...), so a script cannot escape a preset by starting a traversal elsewhere.

ExecutionPolicy is the complementary control on the storage: it fixes the value of an option (locked keys, defaults) for every entry, Rust included. Both apply; the authorization check runs first.

File access and the file root

An authorizer decides each file request; Authorizer::resolve_file(path) then maps the path to the file actually accessed (the default returns it unchanged). AccessPolicy::with_file_root(dir) confines every file access to dir:

  • a relative path is resolved under dir (g.export_graphml("out.graphml") writes dir/out.graphml);
  • a path that leaves dir is denied: .. past the root, an absolute path elsewhere, a symbolic link inside the root that points out of it;
  • Rhai's import "helpers" loads dir/helpers.rhai the same way;
  • the root must exist; on wasm32 (no file system) every rooted access is denied, never a panic.

The root does not allow file access by itself: the policy must also allow the action on ResourceKind::File. A host that reads files on behalf of a script calls policy.resolve_file(path) itself.

Graphs the script builds itself

A graph the script built itself is private to it: GraphSource::empty(), GraphSource::tinkerpop_modern(), GraphSource::file(path), schema.toGraph(), the scratch sources __ and _g, and the results of subgraph()/cap() and toGraph(). Changing it is not a change of the host graph, so a request that writes data or the schema of it (Create/Update/Delete on Data, Schema or Definition, AccessRequest::writes_graph) is allowed without asking the authorizer:

let sg = g.E().hasLabel("knows").subgraph("sg").cap("sg").next();
sg.addV("note").property("text", "mine").next();   // allowed with AccessPolicy::read_only()

Reading a private graph still asks for Read, and its file and option requests are asked as usual. Naming the host graph as the explicit target of a subgraph (GraphSource::tinkerpop_modern().withSideEffect("sg", g).E().subgraph("sg")) writes into the host graph: it asks for Create on Data (operation with_side_effect) and gets no relaxation. Such a target must be a TraversalGraph: a host graph of another storage is refused with GraphError::Unsupported (Set Side Effects).

A whole script as one unit

eval_value_atomic(graph, script, params, &limits, authorizer) (Transactions) checks every traversal the same way. While it runs, the script works on a private copy of the graph's lock, so it must not return a closure or function pointer (also inside an array or a map): a closure that captured g would point at an empty graph once the script ends. Such a script fails with ScriptError::ReturnedClosure and everything it changed is rolled back; return data or a traversal instead.

Later

Element- and label-level read security (a Filtered<S, Policy> storage decorator) and a ReadOnly<S> decorator are additive follow-ups; a future GQL/Cypher text front end uses the same authorizer.