diff --git a/.github/workflows/bench-graphql.yml b/.github/workflows/bench-graphql.yml
index 22332a2b5e..8dd6a85dc3 100644
--- a/.github/workflows/bench-graphql.yml
+++ b/.github/workflows/bench-graphql.yml
@@ -40,8 +40,10 @@ jobs:
k6-version: '1.0.0'
- name: Run GraphQL benchmarks
run: cd graphql-bench && make bench-local
- - name: Restore metadata file
- run: git restore graphql-bench/data/apache/master # otherwise github-action-benchmark fails to create the commit
+ - name: Restore modified files
+ run: |
+ git restore Cargo.lock # modified by build; github-action-benchmark can't switch to gh-pages with dirty working tree
+ git restore graphql-bench/data/apache/master # otherwise github-action-benchmark fails to create the commit
- name: Print bench results
run: cat graphql-bench/output.json
- name: Store benchmark results from master branch
diff --git a/.github/workflows/benchmark.yml b/.github/workflows/benchmark.yml
index c8925c5684..fb8a561e63 100644
--- a/.github/workflows/benchmark.yml
+++ b/.github/workflows/benchmark.yml
@@ -49,8 +49,8 @@ jobs:
run: |
set -o pipefail
cargo bench --bench base --bench algobench -p raphtory-benchmark -- --output-format=bencher | tee benchmark-result.txt
- - name: Delete cargo.lock if it exists
- run: rm -f Cargo.lock
+ - name: Restore Cargo.lock to avoid dirty working tree
+ run: git checkout -- Cargo.lock
- name: Store benchmark results from master branch
if: github.ref == 'refs/heads/master'
uses: benchmark-action/github-action-benchmark@v1
diff --git a/.github/workflows/stress-test.yml b/.github/workflows/stress-test.yml
index 450c335aa2..bf8a52417b 100644
--- a/.github/workflows/stress-test.yml
+++ b/.github/workflows/stress-test.yml
@@ -37,8 +37,8 @@ jobs:
env:
RUST_BACKTRACE: 1
run: |
- cargo build --package raphtory-graphql --bin raphtory-graphql --profile=build-fast
- ./target/build-fast/raphtory-graphql server --work-dir graphs &
+ cargo build --package raphtory-server --bin raphtory-server --profile=build-fast
+ ./target/build-fast/raphtory-server server --work-dir graphs &
cd graphql-bench
make stress-test
- name: Upload k6 report
diff --git a/Cargo.lock b/Cargo.lock
index 8e09a82198..b98ec4a4a1 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -5024,6 +5024,10 @@ dependencies = [
"syn 2.0.117",
]
+[[package]]
+name = "raphtory-auth-noop"
+version = "0.17.0"
+
[[package]]
name = "raphtory-benchmark"
version = "0.17.0"
@@ -5145,7 +5149,17 @@ dependencies = [
"pyo3",
"pyo3-build-config",
"raphtory",
+ "raphtory-auth-noop",
+ "raphtory-graphql",
+]
+
+[[package]]
+name = "raphtory-server"
+version = "0.17.0"
+dependencies = [
+ "raphtory-auth-noop",
"raphtory-graphql",
+ "tokio",
]
[[package]]
diff --git a/Cargo.toml b/Cargo.toml
index c8aae7e8ea..aa4c77d321 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -7,14 +7,17 @@ members = [
"examples/custom-gql-apis",
"python",
"raphtory-graphql",
+ "raphtory-auth-noop",
+ "raphtory-server",
"raphtory-api",
"raphtory-core",
"raphtory-storage",
"raphtory-api-macros",
"raphtory-itertools",
"clam-core",
- "clam-core/snb"
- , "raphtory-itertools"]
+ "clam-core/snb",
+ "raphtory-itertools"
+]
default-members = ["raphtory"]
exclude = ["optd"]
resolver = "2"
@@ -193,3 +196,7 @@ disjoint-sets = "0.4.2"
[workspace.dependencies.storage]
package = "db4-storage"
path = "db4-storage"
+
+[workspace.dependencies.auth]
+package = "raphtory-auth-noop"
+path = "raphtory-auth-noop"
diff --git a/docs/reference/graphql/graphql_API.md b/docs/reference/graphql/graphql_API.md
index fb1f57bdce..2c0049945a 100644
--- a/docs/reference/graphql/graphql_API.md
+++ b/docs/reference/graphql/graphql_API.md
@@ -30,11 +30,26 @@ Hello world demo
@@ -126,7 +141,8 @@ Returns a plugin.
| String! |
-Encodes graph and returns as string
+Encodes graph and returns as string.
+If the caller has filtered access, the returned graph is a materialized view of the filter.
Returns:: Base64 url safe encoded string
diff --git a/python/Cargo.toml b/python/Cargo.toml
index 1767936c68..bbdb6b79ea 100644
--- a/python/Cargo.toml
+++ b/python/Cargo.toml
@@ -26,8 +26,8 @@ raphtory = { workspace = true, features = [
raphtory-graphql = { workspace = true, features = [
"python",
] }
-clam-core = { path = "../clam-core", version = "0.17.0", features = ["python"] }
-
+auth = { workspace = true }
+clam-core = { workspace = true, features = ["python"] }
[features]
extension-module = ["pyo3/extension-module"]
diff --git a/python/python/raphtory/graphql/__init__.pyi b/python/python/raphtory/graphql/__init__.pyi
index 22047a51d6..b2dd837a1a 100644
--- a/python/python/raphtory/graphql/__init__.pyi
+++ b/python/python/raphtory/graphql/__init__.pyi
@@ -44,6 +44,7 @@ __all__ = [
"decode_graph",
"schema",
"cli",
+ "has_permissions_extension",
]
class GraphServer(object):
@@ -61,7 +62,7 @@ class GraphServer(object):
otlp_tracing_service_name (str, optional): The OTLP tracing service name
config_path (str | PathLike, optional): Path to the config file
auth_public_key:
- auth_enabled_for_reads:
+ require_auth_for_reads:
create_index:
"""
@@ -77,9 +78,10 @@ class GraphServer(object):
otlp_agent_port: Optional[str] = None,
otlp_tracing_service_name: Optional[str] = None,
auth_public_key: Any = None,
- auth_enabled_for_reads: Any = None,
+ require_auth_for_reads: Any = None,
config_path: Optional[str | PathLike] = None,
create_index: Any = None,
+ permissions_store_path=None,
) -> GraphServer:
"""Create and return a new object. See help(type) for accurate signature."""
@@ -780,3 +782,5 @@ def schema():
"""
def cli(): ...
+def has_permissions_extension():
+ """Returns True if the permissions extension (raphtory-auth) is compiled in."""
diff --git a/python/src/lib.rs b/python/src/lib.rs
index 2b2d92569d..b1ca6c95d9 100644
--- a/python/src/lib.rs
+++ b/python/src/lib.rs
@@ -13,7 +13,8 @@ use raphtory_graphql::python::pymodule::base_graphql_module;
/// Raphtory graph analytics library
#[pymodule]
fn _raphtory(py: Python<'_>, m: &Bound) -> PyResult<()> {
- let _ = add_raphtory_classes(m);
+ auth::init();
+ add_raphtory_classes(m)?;
let graphql_module = base_graphql_module(py)?;
let algorithm_module = base_algorithm_module(py)?;
diff --git a/python/tests/test_auth.py b/python/tests/test_auth.py
index a9c733c0b8..9c6fe3d52a 100644
--- a/python/tests/test_auth.py
+++ b/python/tests/test_auth.py
@@ -19,16 +19,48 @@
RAPHTORY = "http://localhost:1736"
-READ_JWT = jwt.encode({"a": "ro"}, PRIVATE_KEY, algorithm="EdDSA")
+READ_JWT = jwt.encode({"access": "ro"}, PRIVATE_KEY, algorithm="EdDSA")
READ_HEADERS = {
"Authorization": f"Bearer {READ_JWT}",
}
-WRITE_JWT = jwt.encode({"a": "rw"}, PRIVATE_KEY, algorithm="EdDSA")
+WRITE_JWT = jwt.encode({"access": "rw"}, PRIVATE_KEY, algorithm="EdDSA")
WRITE_HEADERS = {
"Authorization": f"Bearer {WRITE_JWT}",
}
+# openssl genpkey -algorithm RSA -pkeyopt rsa_keygen_bits:2048 -out rsa-key.pem
+# openssl pkey -in rsa-key.pem -pubout -outform DER | base64 | tr -d '\n'
+RSA_PUB_KEY = "MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA4sqe3DlHB/DaSm8Ab99yKj0KDc/WZGFPwXeTbPwCMKKSEc8zuSuIZc/fHXLSORn1apMnDq3aLryfPwyNTbpvhGiYVyp76XQGwSlN+EF2TsJZVAzp4/EI+bnHeHyv2Yc5q6AkFtoBPNtAz2P/18g7Yv/eZqNNSd7FOeuRFRs9y0LkswvMelQmoMOK7UKdC00AyiGksvFvljNC70VT9b0uVHggJwUYT0hdCbdaDj2fCJZBEmTqBBr97u3fIHo5T41sIEEPgE2j368mI+uk6V1saEU1BU+hkcq56TabgVqUYZTln5Rdm1MuBsNz+NQwOmVxgPNo45H2cNwTfsPDAAESlwIDAQAB"
+RSA_PRIVATE_KEY = """-----BEGIN PRIVATE KEY-----
+MIIEvgIBADANBgkqhkiG9w0BAQEFAASCBKgwggSkAgEAAoIBAQDiyp7cOUcH8NpK
+bwBv33IqPQoNz9ZkYU/Bd5Ns/AIwopIRzzO5K4hlz98dctI5GfVqkycOrdouvJ8/
+DI1Num+EaJhXKnvpdAbBKU34QXZOwllUDOnj8Qj5ucd4fK/ZhzmroCQW2gE820DP
+Y//XyDti/95mo01J3sU565EVGz3LQuSzC8x6VCagw4rtQp0LTQDKIaSy8W+WM0Lv
+RVP1vS5UeCAnBRhPSF0Jt1oOPZ8IlkESZOoEGv3u7d8gejlPjWwgQQ+ATaPfryYj
+66TpXWxoRTUFT6GRyrnpNpuBWpRhlOWflF2bUy4Gw3P41DA6ZXGA82jjkfZw3BN+
+w8MAARKXAgMBAAECggEAWIH78nU2B97Syja8xGw/KUXODSreACnMDvRkKCXHkwR3
+HhUvmeXn4tf3uo3rhhZf5TpNhViK7C93tIrpAHswd0u8nFP7rNW3px3ADJE7oywM
+4ZTymJ8iQhdjRd3fYPT5qEWkn/hvgDkO94EOwT8nEhFKUeMMUDZs4RhSdBrACHk0
+CrOC2S9xbgYb5OWGV6vkSqNB0k0Kv+LxU8sS46BLE7DxfpzSXDyeYaCAkk+wbwfb
+hX7lysczbSl5l5Bulcf/LHL4Oa/5t+NcBZqyN6ylRXyqQ8LEdK4+TOJfvnePX1go
+3rG4rtyaBCuW5JD1ytxUsyfh8WE4GinUbHWzxvaYQQKBgQD5PxF2CmqMY6yiaxU3
+0LFtRS9DtwIPnPX3Wdchq7ivSU1W6sHJjNfyEggi10DSOOINalRM/ZnVlDo8hJ3A
+SybESWWzLuDZNAAAWkmoir0UpnURz847tKd8hJUivhsbdQBeKwaCuepcW6Hdwzh1
+JsJjXPovrzVGQe5FSRfBy7gswQKBgQDo78p/jEVHzuxHqSn3AsOdBdMZvPavpHb2
+Bx7tRhZOOp2QiGUHZLfjI++sQQyTu1PJqmmxOOF+eD/zkqCkLLeZsmRYOQVDOQDM
+Z+u+zKYRj7KaWBeGB2Oy/WEU0pGnhyMB/T5iHmroO0Hn4gDHqkEDvwFI7SUjLNAK
+1RjTxVgdVwKBgCRHNMBspbOHcoI1eeIk4x5Xepitk4Q4QWjeT7zb5MbGsZYcF1bB
+xFC8pSiFEi9HDkgLmPeX1gNLTuquFtP9XEgnssDQ6vNSaUmj2qLIhtrxm4qbJ5Zz
+JgmutpJW/1UQw5vxQUJX0y/cOoQvvRD4MkUKLHQyWVu/jvHQwL95anZBAoGBAIrZ
+9aGWYe3uINaOth8yHJzLTgz3oS0OIoOBtyPFNaKoOihfxalklmDlmQbbN74QWl/K
+H3qu52vWDnkJHI0Awujxd/NG+iYaIqm2AMcZgpzRRavPeyY/3WRiua4J3x035txW
+swsWCrAoMp8hD0n16Q9smj14bzzKh7ENWeFSr7W9AoGBAMOSyRdVQxVHXagh3fAa
++FNbR8pFmQC6bQGCO74DzGe6uKYpgu+XD1yinufwwsXxjieDXCHkKTGR92Kzp5VY
+Hp6HhhhCcXICRRnbxhvdpyaDbCQrT522bqRJ4rNmSVYOQQiD2vng/HVB2oWMVwa+
+fEtYNjbxjhX9qInHjHxeaNOp
+-----END PRIVATE KEY-----"""
+
NEW_TEST_GRAPH = """mutation { newGraph(path:"test", graphType:EVENT) }"""
QUERY_NAMESPACES = """query { namespaces { list{ path} } }"""
@@ -54,7 +86,7 @@ def test_expired_token():
work_dir = tempfile.mkdtemp()
with GraphServer(work_dir, auth_public_key=PUB_KEY).start():
exp = time() - 100
- token = jwt.encode({"a": "ro", "exp": exp}, PRIVATE_KEY, algorithm="EdDSA")
+ token = jwt.encode({"access": "ro", "exp": exp}, PRIVATE_KEY, algorithm="EdDSA")
headers = {
"Authorization": f"Bearer {token}",
}
@@ -63,7 +95,7 @@ def test_expired_token():
)
assert response.status_code == 401
- token = jwt.encode({"a": "rw", "exp": exp}, PRIVATE_KEY, algorithm="EdDSA")
+ token = jwt.encode({"access": "rw", "exp": exp}, PRIVATE_KEY, algorithm="EdDSA")
headers = {
"Authorization": f"Bearer {token}",
}
@@ -94,7 +126,7 @@ def test_default_read_access(query):
def test_disabled_read_access(query):
work_dir = tempfile.mkdtemp()
with GraphServer(
- work_dir, auth_public_key=PUB_KEY, auth_enabled_for_reads=False
+ work_dir, auth_public_key=PUB_KEY, require_auth_for_reads=False
).start():
add_test_graph()
data = json.dumps({"query": query})
@@ -206,6 +238,70 @@ def test_raphtory_client():
assert g.node("test") is not None
+def test_raphtory_client_write_denied_for_read_jwt():
+ """RaphtoryClient initialized with a read JWT is denied write operations."""
+ work_dir = tempfile.mkdtemp()
+ with GraphServer(work_dir, auth_public_key=PUB_KEY).start():
+ client = RaphtoryClient(url=RAPHTORY, token=READ_JWT)
+ with pytest.raises(Exception, match="requires write access"):
+ client.new_graph("test", "EVENT")
+
+
+# --- RSA JWT support ---
+
+
+def test_rsa_signed_jwt_rs256_accepted():
+ """Server configured with an RSA public key accepts RS256-signed JWTs."""
+ work_dir = tempfile.mkdtemp()
+ with GraphServer(work_dir, auth_public_key=RSA_PUB_KEY).start():
+ token = jwt.encode({"access": "ro"}, RSA_PRIVATE_KEY, algorithm="RS256")
+ response = requests.post(
+ RAPHTORY,
+ headers={"Authorization": f"Bearer {token}"},
+ data=json.dumps({"query": QUERY_ROOT}),
+ )
+ assert_successful_response(response)
+
+
+def test_rsa_signed_jwt_rs512_accepted():
+ """RS512 JWT is also accepted for the same RSA key (different hash, same key material)."""
+ work_dir = tempfile.mkdtemp()
+ with GraphServer(work_dir, auth_public_key=RSA_PUB_KEY).start():
+ token = jwt.encode({"access": "ro"}, RSA_PRIVATE_KEY, algorithm="RS512")
+ response = requests.post(
+ RAPHTORY,
+ headers={"Authorization": f"Bearer {token}"},
+ data=json.dumps({"query": QUERY_ROOT}),
+ )
+ assert_successful_response(response)
+
+
+def test_eddsa_jwt_rejected_against_rsa_key():
+ """EdDSA JWT is rejected when the server is configured with an RSA public key."""
+ work_dir = tempfile.mkdtemp()
+ with GraphServer(work_dir, auth_public_key=RSA_PUB_KEY).start():
+ token = jwt.encode({"access": "ro"}, PRIVATE_KEY, algorithm="EdDSA")
+ response = requests.post(
+ RAPHTORY,
+ headers={"Authorization": f"Bearer {token}"},
+ data=json.dumps({"query": QUERY_ROOT}),
+ )
+ assert response.status_code == 401
+
+
+def test_raphtory_client_read_jwt_can_receive_graph():
+ """RaphtoryClient initialized with a read JWT can download graphs."""
+ work_dir = tempfile.mkdtemp()
+ with GraphServer(work_dir, auth_public_key=PUB_KEY).start():
+ client = RaphtoryClient(url=RAPHTORY, token=WRITE_JWT)
+ client.new_graph("test", "EVENT")
+ client.remote_graph("test").add_node(0, "mynode")
+
+ client2 = RaphtoryClient(url=RAPHTORY, token=READ_JWT)
+ g = client2.receive_graph("test")
+ assert g.node("mynode") is not None
+
+
def test_upload_graph():
work_dir = tempfile.mkdtemp()
with GraphServer(work_dir, auth_public_key=PUB_KEY).start():
diff --git a/python/tests/test_permissions.py b/python/tests/test_permissions.py
new file mode 100644
index 0000000000..43488acc9d
--- /dev/null
+++ b/python/tests/test_permissions.py
@@ -0,0 +1,1735 @@
+import json
+import os
+import tempfile
+import requests
+import jwt
+import pytest
+from raphtory.graphql import GraphServer, RaphtoryClient, has_permissions_extension
+
+pytestmark = pytest.mark.skipif(
+ not has_permissions_extension(),
+ reason="raphtory-auth not compiled in (open-source build)",
+)
+
+# Reuse the same key pair as test_auth.py
+PUB_KEY = "MCowBQYDK2VwAyEADdrWr1kTLj+wSHlr45eneXmOjlHo3N1DjLIvDa2ozno="
+PRIVATE_KEY = """-----BEGIN PRIVATE KEY-----
+MC4CAQAwBQYDK2VwBCIEIFzEcSO/duEjjX4qKxDVy4uLqfmiEIA6bEw1qiPyzTQg
+-----END PRIVATE KEY-----"""
+
+RAPHTORY = "http://localhost:1736"
+
+ANALYST_JWT = jwt.encode(
+ {"access": "ro", "role": "analyst"}, PRIVATE_KEY, algorithm="EdDSA"
+)
+ANALYST_HEADERS = {"Authorization": f"Bearer {ANALYST_JWT}"}
+
+ADMIN_JWT = jwt.encode(
+ {"access": "rw", "role": "admin"}, PRIVATE_KEY, algorithm="EdDSA"
+)
+ADMIN_HEADERS = {"Authorization": f"Bearer {ADMIN_JWT}"}
+
+NO_ROLE_JWT = jwt.encode({"access": "ro"}, PRIVATE_KEY, algorithm="EdDSA")
+NO_ROLE_HEADERS = {"Authorization": f"Bearer {NO_ROLE_JWT}"}
+
+QUERY_JIRA = """query { graph(path: "jira") { path } }"""
+QUERY_ADMIN = """query { graph(path: "admin") { path } }"""
+QUERY_NS_GRAPHS = """query { root { graphs { list { path } } } }"""
+QUERY_NS_CHILDREN = """query { root { children { list { path } } } }"""
+QUERY_META_JIRA = """query { graphMetadata(path: "jira") { path nodeCount } }"""
+CREATE_JIRA = """mutation { newGraph(path:"jira", graphType:EVENT) }"""
+CREATE_ADMIN = """mutation { newGraph(path:"admin", graphType:EVENT) }"""
+CREATE_TEAM_JIRA = """mutation { newGraph(path:"team/jira", graphType:EVENT) }"""
+CREATE_TEAM_CONFLUENCE = (
+ """mutation { newGraph(path:"team/confluence", graphType:EVENT) }"""
+)
+CREATE_DEEP = """mutation { newGraph(path:"a/b/c", graphType:EVENT) }"""
+QUERY_TEAM_JIRA = """query { graph(path: "team/jira") { path } }"""
+QUERY_TEAM_GRAPHS = """query { namespace(path: "team") { graphs { list { path } } } }"""
+QUERY_A_CHILDREN = """query { namespace(path: "a") { children { list { path } } } }"""
+
+
+def gql(query: str, headers=None) -> dict:
+ h = headers if headers is not None else ADMIN_HEADERS
+ return requests.post(RAPHTORY, headers=h, data=json.dumps({"query": query})).json()
+
+
+def create_role(role: str) -> None:
+ gql(f'mutation {{ permissions {{ createRole(name: "{role}") {{ success }} }} }}')
+
+
+def grant_graph(role: str, path: str, permission: str) -> None:
+ gql(
+ f'mutation {{ permissions {{ grantGraph(role: "{role}", path: "{path}", permission: {permission}) {{ success }} }} }}'
+ )
+
+
+def grant_namespace(role: str, path: str, permission: str) -> None:
+ gql(
+ f'mutation {{ permissions {{ grantNamespace(role: "{role}", path: "{path}", permission: {permission}) {{ success }} }} }}'
+ )
+
+
+def revoke_graph(role: str, path: str) -> None:
+ gql(
+ f'mutation {{ permissions {{ revokeGraph(role: "{role}", path: "{path}") {{ success }} }} }}'
+ )
+
+
+def grant_graph_filtered_read_only(role: str, path: str, filter_gql: str) -> None:
+ """Call grantGraphFilteredReadOnly with a raw GQL filter fragment."""
+ resp = gql(
+ f'mutation {{ permissions {{ grantGraphFilteredReadOnly(role: "{role}", path: "{path}", filter: {filter_gql}) {{ success }} }} }}'
+ )
+ assert "errors" not in resp, f"grantGraphFilteredReadOnly failed: {resp}"
+
+
+def make_server(work_dir: str):
+ """Create a GraphServer wired with a permissions store at {work_dir}/permissions.json."""
+ return GraphServer(
+ work_dir,
+ auth_public_key=PUB_KEY,
+ permissions_store_path=os.path.join(work_dir, "permissions.json"),
+ )
+
+
+def test_analyst_can_access_permitted_graph():
+ work_dir = tempfile.mkdtemp()
+ with make_server(work_dir).start():
+ gql(CREATE_JIRA)
+ gql(CREATE_ADMIN)
+ create_role("analyst")
+ grant_graph("analyst", "jira", "READ")
+
+ response = gql(QUERY_JIRA, headers=ANALYST_HEADERS)
+ assert "errors" not in response, response
+ assert response["data"]["graph"]["path"] == "jira"
+
+
+def test_analyst_cannot_access_denied_graph():
+ work_dir = tempfile.mkdtemp()
+ with make_server(work_dir).start():
+ gql(CREATE_ADMIN)
+ create_role("analyst")
+ grant_graph("analyst", "jira", "READ") # only jira, not admin
+
+ # "admin" graph is silently null — analyst has no namespace INTROSPECT, so
+ # existence of "admin" is not revealed.
+ response = gql(QUERY_ADMIN, headers=ANALYST_HEADERS)
+ assert "errors" not in response, response
+ assert response["data"]["graph"] is None
+
+
+def test_admin_can_access_all_graphs():
+ work_dir = tempfile.mkdtemp()
+ with make_server(work_dir).start():
+ gql(CREATE_JIRA)
+ gql(CREATE_ADMIN)
+
+ for query in [QUERY_JIRA, QUERY_ADMIN]:
+ response = gql(query, headers=ADMIN_HEADERS)
+ assert "errors" not in response, response
+
+
+def test_no_role_is_denied_when_policy_is_active():
+ work_dir = tempfile.mkdtemp()
+ with make_server(work_dir).start():
+ gql(CREATE_JIRA)
+ create_role("analyst")
+ grant_graph("analyst", "jira", "READ")
+
+ response = gql(QUERY_JIRA, headers=NO_ROLE_HEADERS)
+ assert "errors" not in response, response
+ assert response["data"]["graph"] is None
+
+
+def test_unknown_role_is_denied_when_policy_is_active():
+ """JWT has a role claim but that role does not exist in the store → Denied.
+
+ Distinct from test_no_role_is_denied_when_policy_is_active: here the JWT
+ does carry a role claim ('analyst'), but 'analyst' was never created in the
+ store. Both paths deny, but via different branches of the policy flowchart.
+ """
+ work_dir = tempfile.mkdtemp()
+ with make_server(work_dir).start():
+ gql(CREATE_JIRA)
+ # Make the store non-empty with a different role — but never create "analyst"
+ create_role("other_team")
+
+ response = gql(QUERY_JIRA, headers=ANALYST_HEADERS) # JWT says role="analyst"
+ assert "errors" not in response, response
+ assert response["data"]["graph"] is None
+
+
+def test_empty_store_denies_non_admin():
+ """With an empty permissions store (no roles configured), non-admin users are denied."""
+ work_dir = tempfile.mkdtemp()
+ with make_server(work_dir).start():
+ gql(CREATE_JIRA)
+
+ response = gql(QUERY_JIRA, headers=ANALYST_HEADERS)
+ assert "errors" not in response, response
+ assert response["data"]["graph"] is None
+
+
+def test_empty_store_allows_admin():
+ """With an empty permissions store, admin (rw JWT) still gets full access."""
+ work_dir = tempfile.mkdtemp()
+ with make_server(work_dir).start():
+ gql(CREATE_JIRA)
+
+ response = gql(QUERY_JIRA, headers=ADMIN_HEADERS)
+ assert "errors" not in response, response
+ assert response["data"]["graph"]["path"] == "jira"
+
+
+def test_introspection_allowed_with_introspect_permission():
+ """Namespace INTROSPECT makes graphs visible in listings but graph() is denied."""
+ work_dir = tempfile.mkdtemp()
+ with make_server(work_dir).start():
+ gql(CREATE_TEAM_JIRA)
+ create_role("analyst")
+ grant_namespace("analyst", "team", "INTROSPECT")
+
+ # Namespace listing shows the graph as MetaGraph
+ response = gql(QUERY_TEAM_GRAPHS, headers=ANALYST_HEADERS)
+ assert "errors" not in response, response
+ paths = [g["path"] for g in response["data"]["namespace"]["graphs"]["list"]]
+ assert "team/jira" in paths
+
+ # graph() resolver returns null — INTROSPECT does not grant data access
+ response = gql(QUERY_TEAM_JIRA, headers=ANALYST_HEADERS)
+ assert "errors" not in response, response
+ assert response["data"]["graph"] is None
+
+
+def test_read_implies_introspect():
+ """READ also shows the graph in namespace listings (implies INTROSPECT)."""
+ work_dir = tempfile.mkdtemp()
+ with make_server(work_dir).start():
+ gql(CREATE_JIRA)
+ create_role("analyst")
+ grant_graph("analyst", "jira", "READ")
+
+ response = gql(QUERY_NS_GRAPHS, headers=ANALYST_HEADERS)
+ assert "errors" not in response, response
+ paths = [g["path"] for g in response["data"]["root"]["graphs"]["list"]]
+ assert "jira" in paths
+
+
+def test_permissions_update_via_mutation():
+ """Granting access via mutation takes effect immediately."""
+ work_dir = tempfile.mkdtemp()
+ with make_server(work_dir).start():
+ gql(CREATE_JIRA)
+ create_role("analyst")
+
+ # No grants yet — graph returns null (indistinguishable from "graph not found")
+ response = gql(QUERY_JIRA, headers=ANALYST_HEADERS)
+ assert "errors" not in response, response
+ assert response["data"]["graph"] is None
+
+ # Grant via mutation
+ grant_graph("analyst", "jira", "READ")
+
+ response = gql(QUERY_JIRA, headers=ANALYST_HEADERS)
+ assert "errors" not in response, response
+ assert response["data"]["graph"]["path"] == "jira"
+
+
+def test_namespace_grant_does_not_cover_root_level_graphs():
+ """Namespace grants only apply to graphs within that namespace; root-level graphs require explicit graph grants."""
+ work_dir = tempfile.mkdtemp()
+ with make_server(work_dir).start():
+ gql(CREATE_JIRA)
+ gql(CREATE_TEAM_JIRA)
+ create_role("analyst")
+ grant_namespace(
+ "analyst", "team", "READ"
+ ) # covers team/jira but not root-level jira
+
+ response = gql(QUERY_TEAM_JIRA, headers=ANALYST_HEADERS)
+ assert "errors" not in response, response
+
+ response = gql(QUERY_JIRA, headers=ANALYST_HEADERS)
+ assert "errors" not in response, response
+ assert (
+ response["data"]["graph"] is None
+ ) # root-level graph not covered by namespace grant
+
+
+# --- WRITE permission enforcement ---
+
+UPDATE_JIRA = """query { updateGraph(path: "jira") { addNode(time: 1, name: "test_node") { success } } }"""
+CREATE_JIRA_NS = """mutation { newGraph(path:"team/jira", graphType:EVENT) }"""
+
+
+def test_admin_bypasses_policy_for_reads():
+ """'access':'rw' admin can read any graph even without a role entry in the store."""
+ work_dir = tempfile.mkdtemp()
+ with make_server(work_dir).start():
+ gql(CREATE_JIRA)
+ # Policy is active (analyst role exists) but admin has no role entry
+ create_role("analyst")
+ grant_graph("analyst", "jira", "READ")
+
+ response = gql(QUERY_JIRA, headers=ADMIN_HEADERS)
+ assert "errors" not in response, response
+ assert response["data"]["graph"]["path"] == "jira"
+
+
+def test_analyst_can_write_with_write_grant():
+ """'access':'ro' user with WRITE grant on a specific graph can call updateGraph."""
+ work_dir = tempfile.mkdtemp()
+ with make_server(work_dir).start():
+ gql(CREATE_JIRA)
+ create_role("analyst")
+ grant_graph("analyst", "jira", "WRITE")
+
+ response = gql(UPDATE_JIRA, headers=ANALYST_HEADERS)
+ assert "errors" not in response, response
+
+
+def test_analyst_cannot_write_without_write_grant():
+ """'access':'ro' user with READ-only grant cannot call updateGraph."""
+ work_dir = tempfile.mkdtemp()
+ with make_server(work_dir).start():
+ gql(CREATE_JIRA)
+ create_role("analyst")
+ grant_graph("analyst", "jira", "READ") # READ only, no WRITE
+
+ response = gql(UPDATE_JIRA, 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_analyst_can_create_graph_in_namespace():
+ """'access':'ro' user with namespace WRITE grant can create a new graph in that namespace."""
+ work_dir = tempfile.mkdtemp()
+ with make_server(work_dir).start():
+ create_role("analyst")
+ grant_namespace("analyst", "team/", "WRITE")
+
+ response = gql(CREATE_JIRA_NS, headers=ANALYST_HEADERS)
+ assert "errors" not in response, response
+ assert response["data"]["newGraph"] is True
+
+
+def test_analyst_cannot_create_graph_outside_namespace():
+ """'access':'ro' user with namespace WRITE grant cannot create a graph outside that namespace."""
+ work_dir = tempfile.mkdtemp()
+ with make_server(work_dir).start():
+ create_role("analyst")
+ grant_namespace("analyst", "team/", "WRITE")
+
+ response = gql(CREATE_JIRA, headers=ANALYST_HEADERS) # "jira" not under "team/"
+ assert "errors" in response
+ assert "Access denied" in response["errors"][0]["message"]
+ # Verify "jira" was not created as a side effect
+ ns_graphs = gql(QUERY_NS_GRAPHS)["data"]["root"]["graphs"]["list"]
+ assert "jira" not in [g["path"] for g in ns_graphs]
+
+
+def test_analyst_cannot_call_permissions_mutations():
+ """'access':'ro' user with WRITE grant on a graph cannot manage roles/permissions."""
+ work_dir = tempfile.mkdtemp()
+ with make_server(work_dir).start():
+ create_role("analyst")
+ grant_namespace("analyst", "team", "WRITE")
+
+ response = gql(
+ 'mutation { permissions { createRole(name: "hacker") { success } } }',
+ headers=ANALYST_HEADERS,
+ )
+ assert "errors" in response
+ assert "Access denied" in response["errors"][0]["message"]
+ # Verify "hacker" role was not created as a side effect
+ roles = gql("query { permissions { listRoles } }")["data"]["permissions"][
+ "listRoles"
+ ]
+ assert "hacker" not in roles
+
+
+def test_admin_can_list_roles():
+ """'access':'rw' admin can query permissions { listRoles }."""
+ work_dir = tempfile.mkdtemp()
+ with make_server(work_dir).start():
+ create_role("analyst")
+
+ response = gql("query { permissions { listRoles } }", headers=ADMIN_HEADERS)
+ assert "errors" not in response, response
+ assert "analyst" in response["data"]["permissions"]["listRoles"]
+
+
+def test_analyst_cannot_list_roles():
+ """'access':'ro' user cannot query permissions { listRoles }."""
+ work_dir = tempfile.mkdtemp()
+ with make_server(work_dir).start():
+ create_role("analyst")
+
+ response = gql("query { permissions { listRoles } }", headers=ANALYST_HEADERS)
+ assert "errors" in response
+ assert "Access denied" in response["errors"][0]["message"]
+
+
+def test_admin_can_get_role():
+ """'access':'rw' admin can query permissions { getRole(...) }."""
+ work_dir = tempfile.mkdtemp()
+ with make_server(work_dir).start():
+ create_role("analyst")
+ grant_graph("analyst", "jira", "READ")
+
+ response = gql(
+ 'query { permissions { getRole(name: "analyst") { name graphs { path permission } } } }',
+ headers=ADMIN_HEADERS,
+ )
+ assert "errors" not in response, response
+ role_data = response["data"]["permissions"]["getRole"]
+ assert role_data["name"] == "analyst"
+ assert role_data["graphs"][0]["path"] == "jira"
+ assert role_data["graphs"][0]["permission"] == "READ"
+
+
+def test_analyst_cannot_get_role():
+ """'access':'ro' user cannot query permissions { getRole(...) }."""
+ work_dir = tempfile.mkdtemp()
+ with make_server(work_dir).start():
+ create_role("analyst")
+
+ response = gql(
+ 'query { permissions { getRole(name: "analyst") { name } } }',
+ headers=ANALYST_HEADERS,
+ )
+ assert "errors" in response
+ assert "Access denied" in response["errors"][0]["message"]
+
+
+def test_introspect_only_cannot_access_graph_data():
+ """Namespace INTROSPECT is denied by graph() — READ is required to access graph data."""
+ work_dir = tempfile.mkdtemp()
+ with make_server(work_dir).start():
+ gql(CREATE_TEAM_JIRA)
+ create_role("analyst")
+ grant_namespace("analyst", "team", "INTROSPECT") # no READ
+
+ response = gql(QUERY_TEAM_JIRA, headers=ANALYST_HEADERS)
+ assert "errors" not in response, response
+ assert response["data"]["graph"] is None
+
+
+def test_no_grant_hidden_from_namespace_and_graph():
+ """A role with no namespace INTROSPECT sees graph() as null, not an 'Access denied' error.
+
+ Returning an error would leak that the graph exists. Null is indistinguishable from
+ 'graph not found'. An error is only appropriate when the role already has INTROSPECT
+ on the namespace (and therefore can list the graph name anyway).
+ """
+ work_dir = tempfile.mkdtemp()
+ with make_server(work_dir).start():
+ gql(CREATE_JIRA)
+ create_role("analyst")
+ # analyst has no grant at all
+
+ # graph() returns null silently — does not reveal the graph exists
+ response = gql(QUERY_JIRA, headers=ANALYST_HEADERS)
+ assert "errors" not in response, response
+ assert response["data"]["graph"] is None
+
+ # namespace listing hides it
+ response = gql(QUERY_NS_GRAPHS, headers=ANALYST_HEADERS)
+ assert "errors" not in response, response
+ paths = [g["path"] for g in response["data"]["root"]["graphs"]["list"]]
+ assert "jira" not in paths
+
+
+def test_grantgraph_introspect_rejected():
+ """grantGraph with INTROSPECT permission is rejected — INTROSPECT is namespace-only."""
+ work_dir = tempfile.mkdtemp()
+ with make_server(work_dir).start():
+ gql(CREATE_JIRA)
+ create_role("analyst")
+
+ response = gql(
+ 'mutation { permissions { grantGraph(role: "analyst", path: "jira", permission: INTROSPECT) { success } } }'
+ )
+ assert "errors" in response
+ assert (
+ "INTROSPECT cannot be granted on a graph"
+ in response["errors"][0]["message"]
+ )
+
+
+def test_graph_metadata_allowed_with_introspect():
+ """graphMetadata is accessible with INTROSPECT permission (namespace grant)."""
+ work_dir = tempfile.mkdtemp()
+ with make_server(work_dir).start():
+ gql(CREATE_TEAM_JIRA)
+ create_role("analyst")
+ grant_namespace("analyst", "team", "INTROSPECT")
+
+ response = gql(
+ 'query { graphMetadata(path: "team/jira") { path nodeCount } }',
+ headers=ANALYST_HEADERS,
+ )
+ assert "errors" not in response, response
+ assert response["data"]["graphMetadata"]["path"] == "team/jira"
+
+ # graph() returns null — INTROSPECT does not grant data access
+ response = gql(QUERY_TEAM_JIRA, headers=ANALYST_HEADERS)
+ assert "errors" not in response, response
+ assert response["data"]["graph"] is None
+
+
+def test_graph_metadata_allowed_with_read():
+ """graphMetadata is also accessible with READ."""
+ work_dir = tempfile.mkdtemp()
+ with make_server(work_dir).start():
+ gql(CREATE_JIRA)
+ create_role("analyst")
+ grant_graph("analyst", "jira", "READ")
+
+ response = gql(QUERY_META_JIRA, headers=ANALYST_HEADERS)
+ assert "errors" not in response, response
+ assert response["data"]["graphMetadata"]["path"] == "jira"
+
+
+def test_graph_metadata_denied_without_grant():
+ """graphMetadata is denied when the role has no grant on the graph."""
+ work_dir = tempfile.mkdtemp()
+ with make_server(work_dir).start():
+ gql(CREATE_JIRA)
+ create_role("analyst")
+ # no grant on jira
+
+ response = gql(QUERY_META_JIRA, headers=ANALYST_HEADERS)
+ assert "errors" not in response, response
+ assert response["data"]["graphMetadata"] is None
+
+
+def test_analyst_sees_only_filtered_nodes():
+ """grantGraphFilteredReadOnly applies a node filter transparently for the role.
+
+ Admin sees all nodes; analyst only sees nodes matching the stored filter.
+ Calling grantGraph(READ) clears the filter and restores full access.
+ """
+ work_dir = tempfile.mkdtemp()
+ with make_server(work_dir).start():
+ # Create graph and add nodes with a "region" property
+ gql(CREATE_JIRA)
+ for name, region in [
+ ("alice", "us-west"),
+ ("bob", "us-east"),
+ ("carol", "us-west"),
+ ]:
+ resp = gql(f"""query {{
+ updateGraph(path: "jira") {{
+ addNode(
+ time: 1,
+ name: "{name}",
+ properties: [{{ key: "region", value: {{ str: "{region}" }} }}]
+ ) {{
+ success
+ node {{
+ name
+ }}
+ }}
+ }}
+ }}""")
+ assert resp["data"]["updateGraph"]["addNode"]["success"] is True, resp
+
+ create_role("analyst")
+ # Grant filtered read-only: analyst only sees nodes where region = "us-west"
+ grant_graph_filtered_read_only(
+ "analyst",
+ "jira",
+ '{ node: { property: { name: "region", where: { eq: { str: "us-west" } } } } }',
+ )
+
+ QUERY_NODES = 'query { graph(path: "jira") { nodes { list { name } } } }'
+
+ # Analyst should only see alice and carol (region=us-west)
+ analyst_response = gql(QUERY_NODES, headers=ANALYST_HEADERS)
+ assert "errors" not in analyst_response, analyst_response
+ analyst_names = {
+ n["name"] for n in analyst_response["data"]["graph"]["nodes"]["list"]
+ }
+ assert analyst_names == {
+ "alice",
+ "carol",
+ }, f"expected {{alice, carol}}, got {analyst_names}"
+
+ # Admin should see all three nodes (filter is bypassed for "access":"rw")
+ admin_response = gql(QUERY_NODES, headers=ADMIN_HEADERS)
+ assert "errors" not in admin_response, admin_response
+ admin_names = {
+ n["name"] for n in admin_response["data"]["graph"]["nodes"]["list"]
+ }
+ assert admin_names == {
+ "alice",
+ "bob",
+ "carol",
+ }, f"expected all 3 nodes, got {admin_names}"
+
+ # Clear the filter by calling grantGraph(READ) — analyst should now see all nodes
+ grant_graph("analyst", "jira", "READ")
+ analyst_response_after = gql(QUERY_NODES, headers=ANALYST_HEADERS)
+ assert "errors" not in analyst_response_after, analyst_response_after
+ names_after = {
+ n["name"] for n in analyst_response_after["data"]["graph"]["nodes"]["list"]
+ }
+ assert names_after == {
+ "alice",
+ "bob",
+ "carol",
+ }, f"after plain grant, expected all 3 nodes, got {names_after}"
+
+
+def test_analyst_sees_only_filtered_edges():
+ """grantGraphFilteredReadOnly with an edge filter hides edges that don't match.
+
+ Edges with weight >= 5 are visible; edges with weight < 5 are hidden.
+ Admin bypasses the filter and sees all edges.
+ """
+ work_dir = tempfile.mkdtemp()
+ with make_server(work_dir).start():
+ gql(CREATE_JIRA)
+ # Add three edges: (a->b weight=3), (b->c weight=7), (a->c weight=9)
+ for src, dst, weight in [("a", "b", 3), ("b", "c", 7), ("a", "c", 9)]:
+ resp = gql(f"""query {{
+ updateGraph(path: "jira") {{
+ addEdge(
+ time: 1,
+ src: "{src}",
+ dst: "{dst}",
+ properties: [{{ key: "weight", value: {{ i64: {weight} }} }}]
+ ) {{
+ success
+ edge {{
+ src {{ name }}
+ dst {{ name }}
+ }}
+ }}
+ }}
+ }}""")
+ assert resp["data"]["updateGraph"]["addEdge"]["success"] is True, resp
+
+ create_role("analyst")
+ # Only show edges where weight >= 5
+ grant_graph_filtered_read_only(
+ "analyst",
+ "jira",
+ '{ edge: { property: { name: "weight", where: { ge: { i64: 5 } } } } }',
+ )
+
+ QUERY_EDGES = 'query { graph(path: "jira") { edges { list { src { name } dst { name } } } } }'
+
+ analyst_response = gql(QUERY_EDGES, headers=ANALYST_HEADERS)
+ assert "errors" not in analyst_response, analyst_response
+ analyst_edges = {
+ (e["src"]["name"], e["dst"]["name"])
+ for e in analyst_response["data"]["graph"]["edges"]["list"]
+ }
+ assert analyst_edges == {
+ ("b", "c"),
+ ("a", "c"),
+ }, f"expected only heavy edges, got {analyst_edges}"
+
+ # Admin sees all three edges
+ admin_response = gql(QUERY_EDGES, headers=ADMIN_HEADERS)
+ assert "errors" not in admin_response, admin_response
+ admin_edges = {
+ (e["src"]["name"], e["dst"]["name"])
+ for e in admin_response["data"]["graph"]["edges"]["list"]
+ }
+ assert admin_edges == {
+ ("a", "b"),
+ ("b", "c"),
+ ("a", "c"),
+ }, f"expected all edges for admin, got {admin_edges}"
+
+
+def test_raphtory_client_analyst_can_query_permitted_graph():
+ """RaphtoryClient with analyst role can query a graph it has READ access to."""
+ work_dir = tempfile.mkdtemp()
+ with make_server(work_dir).start():
+ gql(CREATE_JIRA)
+ create_role("analyst")
+ grant_graph("analyst", "jira", "READ")
+
+ client = RaphtoryClient(url=RAPHTORY, token=ANALYST_JWT)
+ result = client.query(QUERY_JIRA)
+ assert result["graph"]["path"] == "jira"
+
+
+def test_raphtory_client_analyst_denied_unpermitted_graph():
+ """RaphtoryClient with analyst role gets null for a graph it has no grant for."""
+ work_dir = tempfile.mkdtemp()
+ with make_server(work_dir).start():
+ gql(CREATE_JIRA)
+ create_role("analyst")
+ # No grant on jira — graph returns null (indistinguishable from "graph not found")
+
+ client = RaphtoryClient(url=RAPHTORY, token=ANALYST_JWT)
+ response = client.query(QUERY_JIRA)
+ assert response["graph"] is None
+
+
+def test_raphtory_client_analyst_write_with_write_grant():
+ """RaphtoryClient with analyst role and WRITE grant can add nodes via remote_graph."""
+ work_dir = tempfile.mkdtemp()
+ with make_server(work_dir).start():
+ gql(CREATE_JIRA)
+ create_role("analyst")
+ grant_graph("analyst", "jira", "WRITE")
+
+ client = RaphtoryClient(url=RAPHTORY, token=ANALYST_JWT)
+ client.remote_graph("jira").add_node(1, "client_node")
+
+ client2 = RaphtoryClient(url=RAPHTORY, token=ADMIN_JWT)
+ received = client2.receive_graph("jira")
+ assert received.node("client_node") is not None
+
+
+def test_raphtory_client_analyst_write_denied_without_write_grant():
+ """RaphtoryClient with analyst role and READ-only grant cannot add nodes via remote_graph."""
+ work_dir = tempfile.mkdtemp()
+ with make_server(work_dir).start():
+ gql(CREATE_JIRA)
+ create_role("analyst")
+ grant_graph("analyst", "jira", "READ")
+
+ client = RaphtoryClient(url=RAPHTORY, token=ANALYST_JWT)
+ with pytest.raises(Exception, match="Access denied"):
+ client.remote_graph("jira").add_node(1, "client_node")
+
+
+def test_receive_graph_requires_read():
+ """receive_graph (graph download) requires at least READ; namespace INTROSPECT is not enough."""
+ work_dir = tempfile.mkdtemp()
+ with make_server(work_dir).start():
+ gql(CREATE_TEAM_JIRA)
+ create_role("analyst")
+
+ # No grant — looks like the graph doesn't exist (no information leakage)
+ client = RaphtoryClient(url=RAPHTORY, token=ANALYST_JWT)
+ with pytest.raises(Exception, match="does not exist"):
+ client.receive_graph("team/jira")
+
+ # Namespace INTROSPECT only — also denied for receive_graph, but now reveals access denied
+ grant_namespace("analyst", "team", "INTROSPECT")
+ with pytest.raises(Exception, match="Access denied"):
+ client.receive_graph("team/jira")
+
+ # READ — allowed
+ grant_namespace("analyst", "team", "READ")
+ g = client.receive_graph("team/jira")
+ assert g is not None
+
+
+def test_receive_graph_without_introspect_hides_existence():
+ """Without namespace INTROSPECT, receive_graph acts as if the graph does not exist.
+
+ This prevents information leakage: a role without any grants cannot distinguish
+ between 'graph does not exist' and 'graph exists but you are denied'.
+ """
+ work_dir = tempfile.mkdtemp()
+ with make_server(work_dir).start():
+ gql(CREATE_TEAM_JIRA)
+ create_role("analyst")
+
+ client = RaphtoryClient(url=RAPHTORY, token=ANALYST_JWT)
+
+ # No grants at all — error must be indistinguishable from a missing graph
+ with pytest.raises(Exception, match="does not exist") as exc_no_grant:
+ client.receive_graph("team/jira")
+
+ # Compare with a truly non-existent graph — error should look the same
+ with pytest.raises(Exception, match="does not exist") as exc_missing:
+ client.receive_graph("team/nonexistent")
+
+ assert "Access denied" not in str(exc_no_grant.value)
+ assert "Access denied" not in str(exc_missing.value)
+
+
+def test_receive_graph_with_filtered_access():
+ """receive_graph with grantGraphFilteredReadOnly returns a materialized view of the filtered graph.
+
+ The downloaded graph should only contain nodes/edges that pass the stored filter,
+ not the full unfiltered graph.
+ """
+ work_dir = tempfile.mkdtemp()
+ with make_server(work_dir).start():
+ gql(CREATE_JIRA)
+ for name, region in [
+ ("alice", "us-west"),
+ ("bob", "us-east"),
+ ("carol", "us-west"),
+ ]:
+ resp = gql(f"""query {{
+ updateGraph(path: "jira") {{
+ addNode(
+ time: 1,
+ name: "{name}",
+ properties: [{{ key: "region", value: {{ str: "{region}" }} }}]
+ ) {{ success }}
+ }}
+ }}""")
+ assert resp["data"]["updateGraph"]["addNode"]["success"] is True, resp
+
+ create_role("analyst")
+ grant_graph_filtered_read_only(
+ "analyst",
+ "jira",
+ '{ node: { property: { name: "region", where: { eq: { str: "us-west" } } } } }',
+ )
+
+ client = RaphtoryClient(url=RAPHTORY, token=ANALYST_JWT)
+ received = client.receive_graph("jira")
+
+ names = {n.name for n in received.nodes}
+ assert names == {"alice", "carol"}, f"Expected only us-west nodes, got: {names}"
+ assert "bob" not in names
+
+
+def test_analyst_sees_only_graph_filter_window():
+ """grantGraphFilteredReadOnly with a graph-level window filter restricts the temporal view.
+
+ Nodes added inside the window [5, 15) are visible; those outside are not.
+ Admin bypasses the filter and sees all nodes.
+ """
+ work_dir = tempfile.mkdtemp()
+ with make_server(work_dir).start():
+ gql(CREATE_JIRA)
+ # Add nodes at different timestamps: t=1 (outside), t=10 (inside), t=20 (outside)
+ for name, t in [("early", 1), ("middle", 10), ("late", 20)]:
+ resp = gql(f"""query {{
+ updateGraph(path: "jira") {{
+ addNode(time: {t}, name: "{name}") {{
+ success
+ node {{
+ name
+ }}
+ }}
+ }}
+ }}""")
+ assert resp["data"]["updateGraph"]["addNode"]["success"] is True, resp
+
+ create_role("analyst")
+ # Window [5, 15) — only "middle" (t=10) falls inside
+ grant_graph_filtered_read_only(
+ "analyst",
+ "jira",
+ "{ graph: { window: { start: 5, end: 15 } } }",
+ )
+
+ QUERY_NODES = 'query { graph(path: "jira") { nodes { list { name } } } }'
+
+ analyst_response = gql(QUERY_NODES, headers=ANALYST_HEADERS)
+ assert "errors" not in analyst_response, analyst_response
+ analyst_names = {
+ n["name"] for n in analyst_response["data"]["graph"]["nodes"]["list"]
+ }
+ assert analyst_names == {
+ "middle"
+ }, f"expected only 'middle' in window, got {analyst_names}"
+
+ # Admin sees all three nodes
+ admin_response = gql(QUERY_NODES, headers=ADMIN_HEADERS)
+ assert "errors" not in admin_response, admin_response
+ admin_names = {
+ n["name"] for n in admin_response["data"]["graph"]["nodes"]["list"]
+ }
+ assert admin_names == {
+ "early",
+ "middle",
+ "late",
+ }, f"expected all nodes for admin, got {admin_names}"
+
+
+# --- Filter composition (And / Or) tests ---
+
+
+def test_filter_and_node_node():
+ """And([node, node]): both node predicates must match (intersection)."""
+ work_dir = tempfile.mkdtemp()
+ with make_server(work_dir).start():
+ gql(CREATE_JIRA)
+ for name, region, role in [
+ ("alice", "us-west", "admin"),
+ ("bob", "us-east", "admin"),
+ ("carol", "us-west", "user"),
+ ]:
+ resp = gql(f"""query {{
+ updateGraph(path: "jira") {{
+ addNode(
+ time: 1, name: "{name}",
+ properties: [
+ {{ key: "region", value: {{ str: "{region}" }} }},
+ {{ key: "role", value: {{ str: "{role}" }} }}
+ ]
+ ) {{ success }}
+ }}
+ }}""")
+ assert resp["data"]["updateGraph"]["addNode"]["success"] is True, resp
+
+ create_role("analyst")
+ # region=us-west AND role=admin → only alice
+ grant_graph_filtered_read_only(
+ "analyst",
+ "jira",
+ "{ and: ["
+ '{ node: { property: { name: "region", where: { eq: { str: "us-west" } } } } },'
+ '{ node: { property: { name: "role", where: { eq: { str: "admin" } } } } }'
+ "] }",
+ )
+
+ QUERY_NODES = 'query { graph(path: "jira") { nodes { list { name } } } }'
+ analyst_names = {
+ n["name"]
+ for n in gql(QUERY_NODES, headers=ANALYST_HEADERS)["data"]["graph"][
+ "nodes"
+ ]["list"]
+ }
+ assert analyst_names == {"alice"}, f"expected only alice, got {analyst_names}"
+
+
+def test_filter_and_edge_edge():
+ """And([edge, edge]): both edge predicates must match (intersection)."""
+ work_dir = tempfile.mkdtemp()
+ with make_server(work_dir).start():
+ gql(CREATE_JIRA)
+ for src, dst, weight, kind in [
+ ("a", "b", 3, "follows"),
+ ("b", "c", 7, "mentions"),
+ ("a", "c", 9, "follows"),
+ ]:
+ resp = gql(f"""query {{
+ updateGraph(path: "jira") {{
+ addEdge(
+ time: 1, src: "{src}", dst: "{dst}",
+ properties: [
+ {{ key: "weight", value: {{ i64: {weight} }} }},
+ {{ key: "kind", value: {{ str: "{kind}" }} }}
+ ]
+ ) {{ success }}
+ }}
+ }}""")
+ assert resp["data"]["updateGraph"]["addEdge"]["success"] is True, resp
+
+ create_role("analyst")
+ # weight >= 5 AND kind=follows → only (a,c) weight=9 follows
+ grant_graph_filtered_read_only(
+ "analyst",
+ "jira",
+ "{ and: ["
+ '{ edge: { property: { name: "weight", where: { ge: { i64: 5 } } } } },'
+ '{ edge: { property: { name: "kind", where: { eq: { str: "follows" } } } } }'
+ "] }",
+ )
+
+ QUERY_EDGES = 'query { graph(path: "jira") { edges { list { src { name } dst { name } } } } }'
+ analyst_edges = {
+ (e["src"]["name"], e["dst"]["name"])
+ for e in gql(QUERY_EDGES, headers=ANALYST_HEADERS)["data"]["graph"][
+ "edges"
+ ]["list"]
+ }
+ assert analyst_edges == {
+ ("a", "c")
+ }, f"expected only (a,c), got {analyst_edges}"
+
+
+def test_filter_and_graph_graph():
+ """And([graph, graph]): two graph-level views intersect (sequential narrowing)."""
+ work_dir = tempfile.mkdtemp()
+ with make_server(work_dir).start():
+ gql(CREATE_JIRA)
+ for name, t in [("early", 1), ("middle", 10), ("late", 20)]:
+ resp = gql(f"""query {{
+ updateGraph(path: "jira") {{
+ addNode(time: {t}, name: "{name}") {{ success }}
+ }}
+ }}""")
+ assert resp["data"]["updateGraph"]["addNode"]["success"] is True, resp
+
+ create_role("analyst")
+ # window [1,15) ∩ window [5,25) → effective [5,15) → only middle (t=10)
+ grant_graph_filtered_read_only(
+ "analyst",
+ "jira",
+ "{ and: ["
+ "{ graph: { window: { start: 1, end: 15 } } },"
+ "{ graph: { window: { start: 5, end: 25 } } }"
+ "] }",
+ )
+
+ QUERY_NODES = 'query { graph(path: "jira") { nodes { list { name } } } }'
+ analyst_names = {
+ n["name"]
+ for n in gql(QUERY_NODES, headers=ANALYST_HEADERS)["data"]["graph"][
+ "nodes"
+ ]["list"]
+ }
+ assert analyst_names == {"middle"}, f"expected only middle, got {analyst_names}"
+
+
+def test_filter_and_node_edge():
+ """And([node, edge]): node filter applied first restricts nodes (and their edges), then edge filter further restricts."""
+ work_dir = tempfile.mkdtemp()
+ with make_server(work_dir).start():
+ gql(CREATE_JIRA)
+ for name, region in [
+ ("alice", "us-west"),
+ ("bob", "us-east"),
+ ("carol", "us-west"),
+ ]:
+ resp = gql(f"""query {{
+ updateGraph(path: "jira") {{
+ addNode(
+ time: 1, name: "{name}",
+ properties: [{{ key: "region", value: {{ str: "{region}" }} }}]
+ ) {{ success }}
+ }}
+ }}""")
+ assert resp["data"]["updateGraph"]["addNode"]["success"] is True, resp
+
+ for src, dst, weight in [
+ ("alice", "bob", 3),
+ ("alice", "carol", 7),
+ ("bob", "carol", 9),
+ ]:
+ resp = gql(f"""query {{
+ updateGraph(path: "jira") {{
+ addEdge(
+ time: 1, src: "{src}", dst: "{dst}",
+ properties: [{{ key: "weight", value: {{ i64: {weight} }} }}]
+ ) {{ success }}
+ }}
+ }}""")
+ assert resp["data"]["updateGraph"]["addEdge"]["success"] is True, resp
+
+ create_role("analyst")
+ # Node(us-west) applied first: bob hidden, bob's edges hidden.
+ # Then Edge(weight≥5): of remaining edges (alice→carol weight=7), only alice→carol passes.
+ grant_graph_filtered_read_only(
+ "analyst",
+ "jira",
+ "{ and: ["
+ '{ node: { property: { name: "region", where: { eq: { str: "us-west" } } } } },'
+ '{ edge: { property: { name: "weight", where: { ge: { i64: 5 } } } } }'
+ "] }",
+ )
+
+ QUERY_NODES = 'query { graph(path: "jira") { nodes { list { name } } } }'
+ QUERY_EDGES = 'query { graph(path: "jira") { edges { list { src { name } dst { name } } } } }'
+
+ analyst_names = {
+ n["name"]
+ for n in gql(QUERY_NODES, headers=ANALYST_HEADERS)["data"]["graph"][
+ "nodes"
+ ]["list"]
+ }
+ assert analyst_names == {
+ "alice",
+ "carol",
+ }, f"expected us-west nodes, got {analyst_names}"
+
+ analyst_edges = {
+ (e["src"]["name"], e["dst"]["name"])
+ for e in gql(QUERY_EDGES, headers=ANALYST_HEADERS)["data"]["graph"][
+ "edges"
+ ]["list"]
+ }
+ # Sequential And: Node(us-west) hides bob and bob's edges, then Edge(weight≥5) keeps alice→carol (7).
+ assert analyst_edges == {
+ ("alice", "carol"),
+ }, f"expected only (alice,carol), got {analyst_edges}"
+
+
+def test_filter_and_node_graph():
+ """And([node, graph]): node property filter combined with a graph window."""
+ work_dir = tempfile.mkdtemp()
+ with make_server(work_dir).start():
+ gql(CREATE_JIRA)
+ for name, region, t in [
+ ("alice", "us-west", 1),
+ ("bob", "us-west", 10),
+ ("carol", "us-east", 10),
+ ]:
+ resp = gql(f"""query {{
+ updateGraph(path: "jira") {{
+ addNode(
+ time: {t}, name: "{name}",
+ properties: [{{ key: "region", value: {{ str: "{region}" }} }}]
+ ) {{ success }}
+ }}
+ }}""")
+ assert resp["data"]["updateGraph"]["addNode"]["success"] is True, resp
+
+ create_role("analyst")
+ # window [5,15): bob(t=10) + carol(t=10); then node us-west → only bob
+ grant_graph_filtered_read_only(
+ "analyst",
+ "jira",
+ "{ and: ["
+ "{ graph: { window: { start: 5, end: 15 } } },"
+ '{ node: { property: { name: "region", where: { eq: { str: "us-west" } } } } }'
+ "] }",
+ )
+
+ QUERY_NODES = 'query { graph(path: "jira") { nodes { list { name } } } }'
+ analyst_names = {
+ n["name"]
+ for n in gql(QUERY_NODES, headers=ANALYST_HEADERS)["data"]["graph"][
+ "nodes"
+ ]["list"]
+ }
+ assert analyst_names == {"bob"}, f"expected only bob, got {analyst_names}"
+
+
+def test_filter_and_edge_graph():
+ """And([edge, graph]): edge property filter combined with a graph window."""
+ work_dir = tempfile.mkdtemp()
+ with make_server(work_dir).start():
+ gql(CREATE_JIRA)
+ for src, dst, weight, t in [
+ ("a", "b", 3, 1),
+ ("b", "c", 7, 10),
+ ("a", "c", 9, 20),
+ ]:
+ resp = gql(f"""query {{
+ updateGraph(path: "jira") {{
+ addEdge(
+ time: {t}, src: "{src}", dst: "{dst}",
+ properties: [{{ key: "weight", value: {{ i64: {weight} }} }}]
+ ) {{ success }}
+ }}
+ }}""")
+ assert resp["data"]["updateGraph"]["addEdge"]["success"] is True, resp
+
+ create_role("analyst")
+ # window [5,15): b→c(t=10); then edge weight≥5 → b→c(weight=7) passes
+ grant_graph_filtered_read_only(
+ "analyst",
+ "jira",
+ "{ and: ["
+ "{ graph: { window: { start: 5, end: 15 } } },"
+ '{ edge: { property: { name: "weight", where: { ge: { i64: 5 } } } } }'
+ "] }",
+ )
+
+ QUERY_EDGES = 'query { graph(path: "jira") { edges { list { src { name } dst { name } } } } }'
+ analyst_edges = {
+ (e["src"]["name"], e["dst"]["name"])
+ for e in gql(QUERY_EDGES, headers=ANALYST_HEADERS)["data"]["graph"][
+ "edges"
+ ]["list"]
+ }
+ assert analyst_edges == {
+ ("b", "c")
+ }, f"expected only (b,c), got {analyst_edges}"
+
+
+def test_filter_or_node_node():
+ """Or([node, node]): nodes matching either predicate are visible (union)."""
+ work_dir = tempfile.mkdtemp()
+ with make_server(work_dir).start():
+ gql(CREATE_JIRA)
+ for name, region in [("alice", "us-west"), ("bob", "us-east"), ("carol", "eu")]:
+ resp = gql(f"""query {{
+ updateGraph(path: "jira") {{
+ addNode(
+ time: 1, name: "{name}",
+ properties: [{{ key: "region", value: {{ str: "{region}" }} }}]
+ ) {{ success }}
+ }}
+ }}""")
+ assert resp["data"]["updateGraph"]["addNode"]["success"] is True, resp
+
+ create_role("analyst")
+ # us-west OR us-east → alice + bob; carol(eu) filtered out
+ grant_graph_filtered_read_only(
+ "analyst",
+ "jira",
+ "{ or: ["
+ '{ node: { property: { name: "region", where: { eq: { str: "us-west" } } } } },'
+ '{ node: { property: { name: "region", where: { eq: { str: "us-east" } } } } }'
+ "] }",
+ )
+
+ QUERY_NODES = 'query { graph(path: "jira") { nodes { list { name } } } }'
+ analyst_names = {
+ n["name"]
+ for n in gql(QUERY_NODES, headers=ANALYST_HEADERS)["data"]["graph"][
+ "nodes"
+ ]["list"]
+ }
+ assert analyst_names == {
+ "alice",
+ "bob",
+ }, f"expected alice+bob, got {analyst_names}"
+
+
+def test_filter_or_edge_edge():
+ """Or([edge, edge]): edges matching either predicate are visible (union)."""
+ work_dir = tempfile.mkdtemp()
+ with make_server(work_dir).start():
+ gql(CREATE_JIRA)
+ for src, dst, weight in [("a", "b", 3), ("b", "c", 7), ("a", "c", 9)]:
+ resp = gql(f"""query {{
+ updateGraph(path: "jira") {{
+ addEdge(
+ time: 1, src: "{src}", dst: "{dst}",
+ properties: [{{ key: "weight", value: {{ i64: {weight} }} }}]
+ ) {{ success }}
+ }}
+ }}""")
+ assert resp["data"]["updateGraph"]["addEdge"]["success"] is True, resp
+
+ create_role("analyst")
+ # weight=3 OR weight=9 → (a,b) + (a,c); (b,c) weight=7 filtered out
+ grant_graph_filtered_read_only(
+ "analyst",
+ "jira",
+ "{ or: ["
+ '{ edge: { property: { name: "weight", where: { eq: { i64: 3 } } } } },'
+ '{ edge: { property: { name: "weight", where: { eq: { i64: 9 } } } } }'
+ "] }",
+ )
+
+ QUERY_EDGES = 'query { graph(path: "jira") { edges { list { src { name } dst { name } } } } }'
+ analyst_edges = {
+ (e["src"]["name"], e["dst"]["name"])
+ for e in gql(QUERY_EDGES, headers=ANALYST_HEADERS)["data"]["graph"][
+ "edges"
+ ]["list"]
+ }
+ assert analyst_edges == {
+ ("a", "b"),
+ ("a", "c"),
+ }, f"expected (a,b)+(a,c), got {analyst_edges}"
+
+
+# --- Namespace permission tests ---
+
+
+def test_namespace_introspect_shows_graphs_in_listing():
+ """grantNamespace INTROSPECT: graphs appear in namespace listing but graph() is denied."""
+ work_dir = tempfile.mkdtemp()
+ with make_server(work_dir).start():
+ gql(CREATE_TEAM_JIRA)
+ create_role("analyst")
+ grant_namespace("analyst", "team", "INTROSPECT")
+
+ # Graphs visible as MetaGraph in namespace listing
+ response = gql(QUERY_TEAM_GRAPHS, headers=ANALYST_HEADERS)
+ assert "errors" not in response, response
+ paths = [g["path"] for g in response["data"]["namespace"]["graphs"]["list"]]
+ assert "team/jira" in paths
+
+ # Direct graph access returns null — INTROSPECT does not grant data access.
+ response = gql(QUERY_TEAM_JIRA, headers=ANALYST_HEADERS)
+ assert "errors" not in response, response
+ assert response["data"]["graph"] is None
+
+
+def test_namespace_read_exposes_graphs():
+ """grantNamespace READ: graphs in the namespace are fully accessible via graph()."""
+ work_dir = tempfile.mkdtemp()
+ with make_server(work_dir).start():
+ gql(CREATE_TEAM_JIRA)
+ create_role("analyst")
+ grant_namespace("analyst", "team", "READ")
+
+ response = gql(QUERY_TEAM_JIRA, headers=ANALYST_HEADERS)
+ assert "errors" not in response, response
+ assert response["data"]["graph"]["path"] == "team/jira"
+
+
+def test_child_namespace_restriction_overrides_parent():
+ """More-specific child namespace grant overrides a broader parent grant.
+
+ team → READ (parent)
+ team/restricted → INTROSPECT (child — more specific, should win)
+
+ Graphs under team/jira are reachable via READ (only parent matches).
+ Graphs under team/restricted/ are only introspectable — the child INTROSPECT
+ entry overrides the parent READ, so graph() is denied there.
+ """
+ work_dir = tempfile.mkdtemp()
+ with make_server(work_dir).start():
+ gql(CREATE_TEAM_JIRA)
+ gql("""mutation { newGraph(path:"team/restricted/secret", graphType:EVENT) }""")
+ create_role("analyst")
+ grant_namespace("analyst", "team", "READ")
+ grant_namespace("analyst", "team/restricted", "INTROSPECT")
+
+ # team/jira: only matched by "team" → READ — direct access allowed
+ response = gql(QUERY_TEAM_JIRA, headers=ANALYST_HEADERS)
+ assert "errors" not in response, response
+ assert response["data"]["graph"]["path"] == "team/jira"
+
+ # team/restricted/secret: "team/restricted" is the most specific match → INTROSPECT only
+ response = gql(
+ """query { graph(path: "team/restricted/secret") { path } }""",
+ headers=ANALYST_HEADERS,
+ )
+ assert "errors" not in response, response
+ assert response["data"]["graph"] is None
+
+ # But team/restricted/secret should still appear in the namespace listing
+ response = gql(
+ """query { namespace(path: "team/restricted") { graphs { list { path } } } }""",
+ headers=ANALYST_HEADERS,
+ )
+ assert "errors" not in response, response
+ paths = [g["path"] for g in response["data"]["namespace"]["graphs"]["list"]]
+ assert "team/restricted/secret" in paths
+
+
+def test_discover_derivation():
+ """grantGraph READ on a namespaced graph → ancestor namespace gets DISCOVER (visible in children)."""
+ work_dir = tempfile.mkdtemp()
+ with make_server(work_dir).start():
+ gql(CREATE_TEAM_JIRA)
+ create_role("analyst")
+ grant_graph("analyst", "team/jira", "READ") # no explicit namespace grant
+
+ # "team" namespace appears in root children due to DISCOVER derivation
+ response = gql(QUERY_NS_CHILDREN, headers=ANALYST_HEADERS)
+ assert "errors" not in response, response
+ paths = [n["path"] for n in response["data"]["root"]["children"]["list"]]
+ assert "team" in paths
+
+
+def test_discover_revoked_when_only_child_revoked():
+ """Revoking the only child READ grant removes DISCOVER from the parent namespace."""
+ work_dir = tempfile.mkdtemp()
+ with make_server(work_dir).start():
+ gql(CREATE_TEAM_JIRA)
+ create_role("analyst")
+ grant_graph("analyst", "team/jira", "READ")
+
+ paths = [
+ n["path"]
+ for n in gql(QUERY_NS_CHILDREN, headers=ANALYST_HEADERS)["data"]["root"][
+ "children"
+ ]["list"]
+ ]
+ assert "team" in paths # baseline: DISCOVER present
+
+ revoke_graph("analyst", "team/jira")
+
+ paths = [
+ n["path"]
+ for n in gql(QUERY_NS_CHILDREN, headers=ANALYST_HEADERS)["data"]["root"][
+ "children"
+ ]["list"]
+ ]
+ assert "team" not in paths # DISCOVER gone
+
+
+def test_discover_stays_when_one_of_two_children_revoked():
+ """DISCOVER persists while at least one child grant remains; clears only when all are revoked."""
+ work_dir = tempfile.mkdtemp()
+ with make_server(work_dir).start():
+ gql(CREATE_TEAM_JIRA)
+ gql(CREATE_TEAM_CONFLUENCE)
+ create_role("analyst")
+ grant_graph("analyst", "team/jira", "READ")
+ grant_graph("analyst", "team/confluence", "READ")
+
+ revoke_graph("analyst", "team/jira")
+ paths = [
+ n["path"]
+ for n in gql(QUERY_NS_CHILDREN, headers=ANALYST_HEADERS)["data"]["root"][
+ "children"
+ ]["list"]
+ ]
+ assert "team" in paths # still visible via team/confluence
+
+ revoke_graph("analyst", "team/confluence")
+ paths = [
+ n["path"]
+ for n in gql(QUERY_NS_CHILDREN, headers=ANALYST_HEADERS)["data"]["root"][
+ "children"
+ ]["list"]
+ ]
+ assert "team" not in paths # now gone
+
+
+def test_discover_stays_when_parent_has_explicit_namespace_read():
+ """Revoking a child graph READ does not remove an explicit namespace READ on the parent."""
+ work_dir = tempfile.mkdtemp()
+ with make_server(work_dir).start():
+ gql(CREATE_TEAM_JIRA)
+ create_role("analyst")
+ grant_graph("analyst", "team/jira", "READ")
+ grant_namespace("analyst", "team", "READ") # explicit, higher than DISCOVER
+
+ revoke_graph("analyst", "team/jira")
+
+ paths = [
+ n["path"]
+ for n in gql(QUERY_NS_CHILDREN, headers=ANALYST_HEADERS)["data"]["root"][
+ "children"
+ ]["list"]
+ ]
+ assert "team" in paths # still visible via explicit namespace READ
+
+
+def test_discover_revoked_for_nested_namespaces():
+ """Revoking the only deep grant removes DISCOVER from all ancestor namespaces."""
+ work_dir = tempfile.mkdtemp()
+ with make_server(work_dir).start():
+ gql(CREATE_DEEP)
+ create_role("analyst")
+ grant_graph("analyst", "a/b/c", "READ") # "a" and "a/b" both get DISCOVER
+
+ root_paths = [
+ n["path"]
+ for n in gql(QUERY_NS_CHILDREN, headers=ANALYST_HEADERS)["data"]["root"][
+ "children"
+ ]["list"]
+ ]
+ assert "a" in root_paths
+
+ a_paths = [
+ n["path"]
+ for n in gql(QUERY_A_CHILDREN, headers=ANALYST_HEADERS)["data"][
+ "namespace"
+ ]["children"]["list"]
+ ]
+ assert "a/b" in a_paths
+
+ revoke_graph("analyst", "a/b/c")
+
+ root_paths = [
+ n["path"]
+ for n in gql(QUERY_NS_CHILDREN, headers=ANALYST_HEADERS)["data"]["root"][
+ "children"
+ ]["list"]
+ ]
+ assert "a" not in root_paths
+
+ a_paths = [
+ n["path"]
+ for n in gql(QUERY_A_CHILDREN, headers=ANALYST_HEADERS)["data"][
+ "namespace"
+ ]["children"]["list"]
+ ]
+ assert "a/b" not in a_paths
+
+
+def test_no_namespace_grant_hidden_from_children():
+ """No grants at all → namespace is hidden from root children listing."""
+ work_dir = tempfile.mkdtemp()
+ with make_server(work_dir).start():
+ gql(CREATE_TEAM_JIRA)
+ create_role("analyst")
+ # analyst has no grants at all
+
+ response = gql(QUERY_NS_CHILDREN, headers=ANALYST_HEADERS)
+ assert "errors" not in response, response
+ paths = [n["path"] for n in response["data"]["root"]["children"]["list"]]
+ assert "team" not in paths
+
+
+# --- deleteGraph / sendGraph policy delegation ---
+
+DELETE_JIRA = """mutation { deleteGraph(path: "jira") }"""
+DELETE_TEAM_JIRA = """mutation { deleteGraph(path: "team/jira") }"""
+
+
+def test_analyst_can_delete_with_graph_and_namespace_write():
+ """deleteGraph requires WRITE on both the graph and its parent namespace."""
+ work_dir = tempfile.mkdtemp()
+ with make_server(work_dir).start():
+ gql(CREATE_TEAM_JIRA)
+ create_role("analyst")
+ grant_graph("analyst", "team/jira", "WRITE")
+ grant_namespace("analyst", "team", "WRITE")
+
+ response = gql(DELETE_TEAM_JIRA, headers=ANALYST_HEADERS)
+ assert "errors" not in response, response
+ assert response["data"]["deleteGraph"] is True
+
+
+def test_analyst_cannot_delete_with_graph_write_only():
+ """Graph WRITE alone is insufficient for deleteGraph — namespace WRITE is also required."""
+ work_dir = tempfile.mkdtemp()
+ with make_server(work_dir).start():
+ gql(CREATE_JIRA)
+ create_role("analyst")
+ grant_graph("analyst", "jira", "WRITE")
+
+ response = gql(DELETE_JIRA, headers=ANALYST_HEADERS)
+ assert "errors" in response
+ assert "Access denied" in response["errors"][0]["message"]
+ # Verify "jira" was not deleted as a side effect
+ check = gql(QUERY_JIRA)
+ assert check["data"]["graph"]["path"] == "jira"
+
+
+def test_analyst_cannot_delete_with_read_grant():
+ """'access':'ro' user with READ-only grant is denied by deleteGraph."""
+ work_dir = tempfile.mkdtemp()
+ with make_server(work_dir).start():
+ gql(CREATE_JIRA)
+ create_role("analyst")
+ grant_graph("analyst", "jira", "READ")
+
+ response = gql(DELETE_JIRA, headers=ANALYST_HEADERS)
+ assert "errors" in response
+ assert "Access denied" in response["errors"][0]["message"]
+ # Verify "jira" was not deleted as a side effect
+ check = gql(QUERY_JIRA)
+ assert check["data"]["graph"]["path"] == "jira"
+
+
+def test_analyst_can_delete_with_namespace_write():
+ """'access':'ro' user with namespace WRITE (cascades to graph WRITE) can delete a graph."""
+ work_dir = tempfile.mkdtemp()
+ with make_server(work_dir).start():
+ gql(CREATE_TEAM_JIRA)
+ create_role("analyst")
+ grant_namespace("analyst", "team", "WRITE")
+
+ response = gql(DELETE_TEAM_JIRA, headers=ANALYST_HEADERS)
+ assert "errors" not in response, response
+ assert response["data"]["deleteGraph"] is True
+
+
+def test_analyst_cannot_send_graph_without_namespace_write():
+ """'access':'ro' user without namespace WRITE is denied by sendGraph."""
+ work_dir = tempfile.mkdtemp()
+ with make_server(work_dir).start():
+ create_role("analyst")
+ grant_namespace("analyst", "team", "READ") # READ, not WRITE
+
+ response = gql(
+ 'mutation { sendGraph(path: "team/new", graph: "dummydata", overwrite: false) }',
+ headers=ANALYST_HEADERS,
+ )
+ assert "errors" in response
+ assert "Access denied" in response["errors"][0]["message"]
+
+
+def test_analyst_send_graph_passes_auth_with_namespace_write():
+ """'access':'ro' user with namespace WRITE passes the auth gate in sendGraph.
+
+ The request fails on graph decoding (invalid data), not on access control —
+ proving the namespace WRITE check is honoured.
+ """
+ work_dir = tempfile.mkdtemp()
+ with make_server(work_dir).start():
+ create_role("analyst")
+ grant_namespace("analyst", "team", "WRITE")
+
+ response = gql(
+ 'mutation { sendGraph(path: "team/new", graph: "not_valid_base64", overwrite: false) }',
+ headers=ANALYST_HEADERS,
+ )
+ # Auth passed — error is about graph decoding, not access
+ assert "errors" in response
+ assert "Access denied" not in response["errors"][0]["message"]
+
+
+def test_analyst_send_graph_valid_data_with_namespace_write():
+ """'access':'ro' user with namespace WRITE can successfully send a valid graph via sendGraph.
+
+ Admin creates a graph and downloads it; analyst with WRITE sends it to a new path.
+ The graph appears at the new path and its data matches the original.
+ """
+ work_dir = tempfile.mkdtemp()
+ with make_server(work_dir).start():
+ gql(CREATE_JIRA)
+ # Add a node so the graph has content to verify after the roundtrip
+ gql("""query {
+ updateGraph(path: "jira") {
+ addNode(time: 1, name: "alice", properties: []) { success }
+ }
+ }""")
+
+ # Admin downloads the graph as valid base64
+ encoded = gql('query { receiveGraph(path: "jira") }')["data"]["receiveGraph"]
+
+ create_role("analyst")
+ grant_namespace("analyst", "team", "WRITE")
+
+ # Analyst sends the encoded graph to a new path
+ response = gql(
+ f'mutation {{ sendGraph(path: "team/copy", graph: "{encoded}", overwrite: false) }}',
+ headers=ANALYST_HEADERS,
+ )
+ assert "errors" not in response, response
+ assert response["data"]["sendGraph"] == "team/copy"
+
+ # Verify the copy exists and contains the expected node
+ check = gql('query { graph(path: "team/copy") { nodes { list { name } } } }')
+ names = [n["name"] for n in check["data"]["graph"]["nodes"]["list"]]
+ assert "alice" in names
+
+
+# --- moveGraph policy ---
+
+MOVE_TEAM_JIRA = """mutation { moveGraph(path: "team/jira", newPath: "team/jira-moved", overwrite: false) }"""
+
+
+def test_analyst_can_move_with_graph_write_and_namespace_write():
+ """moveGraph requires WRITE on the source graph and its parent namespace, plus WRITE on the destination namespace."""
+ work_dir = tempfile.mkdtemp()
+ with make_server(work_dir).start():
+ gql(CREATE_TEAM_JIRA)
+ create_role("analyst")
+ grant_graph("analyst", "team/jira", "WRITE")
+ grant_namespace("analyst", "team", "WRITE")
+
+ response = gql(MOVE_TEAM_JIRA, headers=ANALYST_HEADERS)
+ assert "errors" not in response, response
+ assert response["data"]["moveGraph"] is True
+
+
+def test_analyst_cannot_move_with_graph_write_only():
+ """Graph WRITE alone is insufficient for moveGraph — namespace WRITE on source is also required."""
+ work_dir = tempfile.mkdtemp()
+ with make_server(work_dir).start():
+ gql(CREATE_TEAM_JIRA)
+ create_role("analyst")
+ grant_graph("analyst", "team/jira", "WRITE")
+ # no namespace grant → namespace WRITE check fails
+
+ response = gql(MOVE_TEAM_JIRA, headers=ANALYST_HEADERS)
+ assert "errors" in response
+ assert "Access denied" in response["errors"][0]["message"]
+ # Verify "team/jira" still exists and "team/jira-moved" was not created
+ team_graphs = gql(QUERY_TEAM_GRAPHS)["data"]["namespace"]["graphs"]["list"]
+ paths = [g["path"] for g in team_graphs]
+ assert "team/jira" in paths
+ assert "team/jira-moved" not in paths
+
+
+def test_analyst_cannot_move_with_read_grant():
+ """READ on source graph is insufficient for moveGraph — WRITE is required."""
+ work_dir = tempfile.mkdtemp()
+ with make_server(work_dir).start():
+ gql(CREATE_TEAM_JIRA)
+ create_role("analyst")
+ grant_graph("analyst", "team/jira", "READ")
+ grant_namespace("analyst", "team", "WRITE")
+
+ response = gql(MOVE_TEAM_JIRA, headers=ANALYST_HEADERS)
+ assert "errors" in response
+ assert "Access denied" in response["errors"][0]["message"]
+ # Verify "team/jira" still exists and "team/jira-moved" was not created
+ team_graphs = gql(QUERY_TEAM_GRAPHS)["data"]["namespace"]["graphs"]["list"]
+ paths = [g["path"] for g in team_graphs]
+ assert "team/jira" in paths
+ assert "team/jira-moved" not in paths
+
+
+# --- newGraph namespace write enforcement ---
+
+
+def test_analyst_can_create_namespaced_graph_with_namespace_write():
+ """'access':'ro' user with namespace WRITE can create a graph inside that namespace."""
+ work_dir = tempfile.mkdtemp()
+ with make_server(work_dir).start():
+ create_role("analyst")
+ grant_namespace("analyst", "team", "WRITE")
+
+ response = gql(CREATE_TEAM_JIRA, headers=ANALYST_HEADERS)
+ assert "errors" not in response, response
+ assert response["data"]["newGraph"] is True
+
+
+def test_analyst_cannot_create_graph_with_namespace_read_only():
+ """'access':'ro' user with namespace READ (not WRITE) is denied by newGraph."""
+ work_dir = tempfile.mkdtemp()
+ with make_server(work_dir).start():
+ create_role("analyst")
+ grant_namespace("analyst", "team", "READ")
+
+ response = gql(CREATE_TEAM_JIRA, headers=ANALYST_HEADERS)
+ assert "errors" in response
+ assert "Access denied" in response["errors"][0]["message"]
+ # Verify "team/jira" was not created as a side effect — "team" namespace should be absent
+ children = gql(QUERY_NS_CHILDREN)["data"]["root"]["children"]["list"]
+ assert "team" not in [c["path"] for c in children]
+
+
+# --- permissions entry point admin gate ---
+
+
+def test_analyst_cannot_access_permissions_query_entry_point():
+ """'access':'ro' user is denied at the permissions query entry point, not just the individual ops.
+
+ This verifies the entry-point-level admin check added to query { permissions { ... } }.
+ Even with full namespace WRITE, a non-admin JWT cannot reach the permissions resolver.
+ """
+ work_dir = tempfile.mkdtemp()
+ with make_server(work_dir).start():
+ create_role("analyst")
+ grant_namespace("analyst", "team", "WRITE") # full write, still not admin
+
+ response = gql(
+ "query { permissions { listRoles } }",
+ headers=ANALYST_HEADERS,
+ )
+ assert "errors" in response
+ assert "Access denied" in response["errors"][0]["message"]
+
+
+def test_analyst_cannot_access_permissions_mutation_entry_point():
+ """'access':'ro' user is denied at the mutation { permissions { ... } } entry point.
+
+ Even with full namespace WRITE, a non-admin JWT is blocked before reaching any op.
+ """
+ work_dir = tempfile.mkdtemp()
+ with make_server(work_dir).start():
+ create_role("analyst")
+ grant_namespace("analyst", "team", "WRITE") # full write, still not admin
+
+ response = gql(
+ 'mutation { permissions { createRole(name: "hacker") { success } } }',
+ headers=ANALYST_HEADERS,
+ )
+ assert "errors" in response
+ assert "Access denied" in response["errors"][0]["message"]
+
+
+# --- createIndex policy ---
+
+
+def test_analyst_can_create_index_with_graph_write():
+ """A user with WRITE on a graph can call createIndex (not admin-only)."""
+ work_dir = tempfile.mkdtemp()
+ with make_server(work_dir).start():
+ gql(CREATE_JIRA)
+ create_role("analyst")
+ grant_graph("analyst", "jira", "WRITE")
+
+ response = gql(
+ 'mutation { createIndex(path: "jira", inRam: true) }',
+ headers=ANALYST_HEADERS,
+ )
+ # Auth passed — success or a feature-not-compiled error, not an access denial
+ if "errors" in response:
+ assert "Access denied" not in response["errors"][0]["message"]
+
+
+def test_analyst_cannot_create_index_with_read_grant():
+ """READ on a graph is insufficient for createIndex — WRITE is required."""
+ work_dir = tempfile.mkdtemp()
+ with make_server(work_dir).start():
+ gql(CREATE_JIRA)
+ create_role("analyst")
+ grant_graph("analyst", "jira", "READ")
+
+ response = gql(
+ 'mutation { createIndex(path: "jira", inRam: true) }',
+ headers=ANALYST_HEADERS,
+ )
+ assert "errors" in response
+ assert "Access denied" in response["errors"][0]["message"]
diff --git a/python/tox.ini b/python/tox.ini
index b716ef94ef..a0b42869dd 100644
--- a/python/tox.ini
+++ b/python/tox.ini
@@ -10,7 +10,7 @@ package = wheel
wheel_build_env = .pkg
extras =
tox
- all, storage, auth, timezone: test
+ all, storage, auth, timezone, permissions: test
export: export
all: all
pass_env =
@@ -40,6 +40,9 @@ commands = pytest --nbmake --nbmake-timeout=1200 {tty:--color=yes} tests/test_ba
[testenv:auth]
commands = pytest tests/test_auth.py
+[testenv:permissions]
+commands = pytest tests/test_permissions.py
+
[testenv:vectors]
commands = pytest tests/test_vectors
diff --git a/raphtory-api/src/core/entities/properties/prop/mod.rs b/raphtory-api/src/core/entities/properties/prop/mod.rs
index 5aeeb202d7..4f563cdf57 100644
--- a/raphtory-api/src/core/entities/properties/prop/mod.rs
+++ b/raphtory-api/src/core/entities/properties/prop/mod.rs
@@ -1,7 +1,5 @@
pub mod arrow;
-
-mod prop_array;
-
+pub mod prop_array;
pub mod prop_col;
mod prop_enum;
mod prop_ref_enum;
diff --git a/raphtory-auth-noop/Cargo.toml b/raphtory-auth-noop/Cargo.toml
new file mode 100644
index 0000000000..7b20f3a52c
--- /dev/null
+++ b/raphtory-auth-noop/Cargo.toml
@@ -0,0 +1,6 @@
+[package]
+name = "raphtory-auth-noop"
+version.workspace = true
+edition.workspace = true
+
+[dependencies]
diff --git a/raphtory-auth-noop/src/lib.rs b/raphtory-auth-noop/src/lib.rs
new file mode 100644
index 0000000000..12cd021c75
--- /dev/null
+++ b/raphtory-auth-noop/src/lib.rs
@@ -0,0 +1 @@
+pub fn init() {}
diff --git a/raphtory-graphql/schema.graphql b/raphtory-graphql/schema.graphql
index bf1bbe56d1..9327ef2fb6 100644
--- a/raphtory-graphql/schema.graphql
+++ b/raphtory-graphql/schema.graphql
@@ -3136,7 +3136,12 @@ type QueryRoot {
"""
Returns a graph
"""
- graph(path: String!): Graph!
+ graph(path: String!): Graph
+ """
+ Returns lightweight metadata for a graph (node/edge counts, timestamps) without loading it.
+ Requires at least INTROSPECT permission.
+ """
+ graphMetadata(path: String!): MetaGraph
"""
Update graph query, has side effects to update graph state
@@ -3172,7 +3177,8 @@ type QueryRoot {
"""
plugins: QueryPlugin!
"""
- Encodes graph and returns as string
+ Encodes graph and returns as string.
+ If the caller has filtered access, the returned graph is a materialized view of the filter.
Returns:: Base64 url safe encoded string
"""
diff --git a/raphtory-graphql/src/auth.rs b/raphtory-graphql/src/auth.rs
index 1626bf38a3..e4bcdc9da1 100644
--- a/raphtory-graphql/src/auth.rs
+++ b/raphtory-graphql/src/auth.rs
@@ -16,18 +16,21 @@ use poem::{
use reqwest::header::AUTHORIZATION;
use serde::Deserialize;
use std::{sync::Arc, time::Duration};
-use tokio::sync::{RwLock, Semaphore};
+use tokio::sync::Semaphore;
+use tracing::{debug, warn};
#[derive(Clone, Debug, Deserialize, PartialEq)]
#[serde(rename_all = "lowercase")]
-pub(crate) enum Access {
+pub enum Access {
Ro,
Rw,
}
#[derive(Deserialize, Debug, Clone)]
pub(crate) struct TokenClaims {
- pub(crate) a: Access,
+ pub(crate) access: Access,
+ #[serde(default)]
+ pub(crate) role: Option,
}
// TODO: maybe this should be renamed as it doens't only take care of auth anymore
@@ -35,7 +38,7 @@ pub struct AuthenticatedGraphQL {
executor: E,
config: AuthConfig,
semaphore: Option,
- lock: Option>,
+ lock: Option>,
}
impl AuthenticatedGraphQL {
@@ -58,7 +61,7 @@ impl AuthenticatedGraphQL {
.and_then(|thread_safe| {
if thread_safe == "1" {
println!("Server running in threadsafe mode");
- Some(RwLock::new(()))
+ Some(tokio::sync::RwLock::new(()))
} else {
None
}
@@ -124,23 +127,28 @@ where
async fn call(&self, req: Request) -> Result {
// here ANY error when trying to validate the Authorization header is equivalent to it not being present at all
- let access = match &self.config.public_key {
+ let (access, role) = match &self.config.public_key {
Some(public_key) => {
- let presented_access = req
+ let claims = req
.header(AUTHORIZATION)
- .and_then(|header| extract_access_from_header(header, public_key));
- match presented_access {
- Some(access) => access,
+ .and_then(|header| extract_claims_from_header(header, public_key));
+ match claims {
+ Some(claims) => {
+ debug!(role = ?claims.role, "JWT validated successfully");
+ (claims.access, claims.role)
+ }
None => {
- if self.config.enabled_for_reads {
+ if self.config.require_auth_for_reads {
+ warn!("Request missing valid JWT — rejecting (require_auth_for_reads=true)");
return Err(Unauthorized(AuthError::RequireRead));
} else {
- Access::Ro // if read access is not required, we give read access to all requests
+ debug!("No valid JWT but require_auth_for_reads=false — granting read access");
+ (Access::Ro, None)
}
}
}
}
- None => Access::Rw, // if auth is not setup, we give write access to all requests
+ None => (Access::Rw, None), // if auth is not setup, we give write access to all requests
};
let is_accept_multipart_mixed = req
@@ -151,7 +159,7 @@ where
if is_accept_multipart_mixed {
let (req, mut body) = req.split();
let req = GraphQLRequest::from_request(&req, &mut body).await?;
- let req = req.0.data(access);
+ let req = req.0.data(access).data(role);
let stream = self.executor.execute_stream(req, None);
Ok(Response::builder()
.header("content-type", "multipart/mixed; boundary=graphql")
@@ -162,7 +170,7 @@ where
} else {
let (req, mut body) = req.split();
let req = GraphQLBatchRequest::from_request(&req, &mut body).await?;
- let req = req.0.data(access);
+ let req = req.0.data(access).data(role);
let contains_update = match &req {
BatchRequest::Single(request) => request.query.contains("updateGraph"),
@@ -200,28 +208,50 @@ fn is_query_heavy(query: &str) -> bool {
|| query.contains("inNeighbours")
}
-fn extract_access_from_header(header: &str, public_key: &PublicKey) -> Option {
+fn extract_claims_from_header(header: &str, public_key: &PublicKey) -> Option {
if header.starts_with("Bearer ") {
let jwt = header.replace("Bearer ", "");
- let mut validation = Validation::new(Algorithm::EdDSA);
+ let mut validation = Validation::new(public_key.algorithms[0]);
+ validation.algorithms = public_key.algorithms.clone();
validation.set_required_spec_claims::(&[]); // we don't require 'exp' to be present
let decoded = decode::(&jwt, &public_key.decoding_key, &validation);
- Some(decoded.ok()?.claims.a)
+ match decoded {
+ Ok(token_data) => Some(token_data.claims),
+ Err(e) => {
+ warn!(error = %e, "JWT signature validation failed");
+ None
+ }
+ }
} else {
+ warn!("Authorization header is missing or does not start with 'Bearer '");
None
}
}
pub(crate) trait ContextValidation {
- fn require_write_access(&self) -> Result<(), AuthError>;
+ fn require_jwt_write_access(&self) -> Result<(), AuthError>;
+}
+
+/// Check that the request carries a write-access JWT (`"access": "rw"`).
+/// For use in dynamic resolver ops that run under `query { ... }` and are
+/// therefore not covered by the `MutationAuth` extension.
+pub fn require_jwt_write_access_dynamic(
+ ctx: &async_graphql::dynamic::ResolverContext,
+) -> Result<(), async_graphql::Error> {
+ if ctx.data::().is_ok_and(|a| a == &Access::Rw) {
+ Ok(())
+ } else {
+ Err(async_graphql::Error::new(
+ "Access denied: write access required",
+ ))
+ }
}
impl<'a> ContextValidation for &Context<'a> {
- fn require_write_access(&self) -> Result<(), AuthError> {
- if self.data::().is_ok_and(|role| role == &Access::Rw) {
- Ok(())
- } else {
- Err(AuthError::RequireWrite)
+ fn require_jwt_write_access(&self) -> Result<(), AuthError> {
+ match self.data::() {
+ Ok(access) if access == &Access::Rw => Ok(()),
+ _ => Err(AuthError::RequireWrite),
}
}
}
@@ -249,10 +279,18 @@ impl Extension for MutationAuth {
.iter()
.any(|op| op.1.node.ty == OperationType::Mutation);
if mutation && ctx.data::() != Ok(&Access::Rw) {
- Err(AuthError::RequireWrite.into())
- } else {
- Ok(doc)
+ // If a policy is active, allow "ro" users through to resolvers —
+ // each resolver enforces its own per-graph or admin-only check.
+ // Without a policy (OSS), preserve the original blanket deny.
+ let policy_active = ctx
+ .data::()
+ .map(|d| d.auth_policy.is_some())
+ .unwrap_or(false);
+ if !policy_active {
+ return Err(AuthError::RequireWrite.into());
+ }
}
+ Ok(doc)
})
}
}
diff --git a/raphtory-graphql/src/auth_policy.rs b/raphtory-graphql/src/auth_policy.rs
new file mode 100644
index 0000000000..30696c3cc6
--- /dev/null
+++ b/raphtory-graphql/src/auth_policy.rs
@@ -0,0 +1,128 @@
+use crate::model::graph::filtering::GraphAccessFilter;
+
+/// Opaque error returned by [`AuthorizationPolicy::graph_permissions`] when access is entirely
+/// denied. The message is intended for logging only; callers must not surface it to end users.
+#[derive(Debug)]
+pub struct AuthPolicyError(String);
+
+impl AuthPolicyError {
+ pub fn new(msg: impl Into) -> Self {
+ Self(msg.into())
+ }
+}
+
+impl std::fmt::Display for AuthPolicyError {
+ fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+ f.write_str(&self.0)
+ }
+}
+
+// async_graphql's blanket `impl From for Error` covers
+// AuthPolicyError automatically via its Display impl.
+
+/// The effective permission level a principal has on a specific graph.
+/// Variants are ordered by the hierarchy: `Write` > `Read{filter:None}` > `Read{filter:Some}` > `Introspect`.
+/// A filtered `Read` is less powerful than an unfiltered `Read` because it sees a restricted view.
+#[derive(Clone)]
+pub enum GraphPermission {
+ /// May query graph metadata (counts, schema) but not read data.
+ Introspect,
+ /// May read graph data; optionally restricted by a data filter.
+ Read { filter: Option },
+ /// May read and mutate the graph (implies `Read` and `Introspect`, never filtered).
+ Write,
+}
+
+impl GraphPermission {
+ /// Numeric level used for ordering: `Introspect`=0, `Read{Some}`=1, `Read{None}`=2, `Write`=3.
+ fn level(&self) -> u8 {
+ match self {
+ GraphPermission::Introspect => 0,
+ GraphPermission::Read { filter: Some(_) } => 1,
+ GraphPermission::Read { filter: None } => 2,
+ GraphPermission::Write => 3,
+ }
+ }
+
+ /// Returns `true` if the permission level is `Read` or higher.
+ pub fn is_at_least_read(&self) -> bool {
+ self.level() >= 1
+ }
+
+ /// Returns `true` only for `Write` permission.
+ pub fn is_write(&self) -> bool {
+ self.level() >= 3
+ }
+
+ /// Returns `Some(self)` if at least `Read` (filtered or not), `None` otherwise.
+ /// Use with `?` to gate access and preserve the permission value for filter extraction.
+ pub fn at_least_read(self) -> Option {
+ self.is_at_least_read().then_some(self)
+ }
+
+ /// Returns `Some(self)` if `Write`, `None` otherwise.
+ pub fn at_least_write(self) -> Option {
+ self.is_write().then_some(self)
+ }
+}
+
+impl PartialEq for GraphPermission {
+ fn eq(&self, other: &Self) -> bool {
+ self.level() == other.level()
+ }
+}
+
+impl Eq for GraphPermission {}
+
+impl PartialOrd for GraphPermission {
+ fn partial_cmp(&self, other: &Self) -> Option {
+ Some(self.cmp(other))
+ }
+}
+
+impl Ord for GraphPermission {
+ fn cmp(&self, other: &Self) -> std::cmp::Ordering {
+ self.level().cmp(&other.level())
+ }
+}
+
+/// The effective permission level a principal has on a namespace.
+/// Variants are ordered lowest to highest so that `PartialOrd`/`Ord` reflect the hierarchy.
+#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord)]
+pub enum NamespacePermission {
+ /// No access — namespace is invisible.
+ Denied,
+ /// Namespace is visible in parent `children()` listings but cannot be browsed.
+ Discover,
+ /// Namespace is browseable; graphs inside are visible as MetaGraph in `graphs()`.
+ Introspect,
+ /// All descendant graphs are fully readable.
+ Read,
+ /// All descendants are writable; `newGraph` is allowed.
+ Write,
+}
+
+pub trait AuthorizationPolicy: Send + Sync + 'static {
+ /// Resolves the effective permission level for a principal on a graph.
+ /// Returns `Err(denial message)` only when access is entirely denied (not even introspect).
+ /// Admin principals (`"access": "rw"` JWT) always yield `Write`.
+ /// Empty store (no roles configured) yields `Read` — fail open for reads,
+ /// but write still requires an explicit `Write` grant.
+ /// The implementation is responsible for extracting principal identity from `ctx`.
+ fn graph_permissions(
+ &self,
+ ctx: &async_graphql::Context<'_>,
+ path: &str,
+ ) -> Result;
+
+ /// Resolves the effective namespace permission for a principal.
+ /// Admin principals always yield `Write`.
+ /// Empty store yields `Read` (fail open, consistent with graph_permissions).
+ /// Missing role yields `Denied`.
+ /// The implementation is responsible for extracting principal identity from `ctx`.
+ fn namespace_permissions(
+ &self,
+ ctx: &async_graphql::Context<'_>,
+ path: &str,
+ ) -> NamespacePermission;
+}
diff --git a/raphtory-graphql/src/cli.rs b/raphtory-graphql/src/cli.rs
index 4cc5190322..49da454e84 100644
--- a/raphtory-graphql/src/cli.rs
+++ b/raphtory-graphql/src/cli.rs
@@ -3,7 +3,7 @@ use crate::config::index_config::DEFAULT_CREATE_INDEX;
use crate::{
config::{
app_config::AppConfigBuilder,
- auth_config::{DEFAULT_AUTH_ENABLED_FOR_READS, PUBLIC_KEY_DECODING_ERR_MSG},
+ auth_config::{DEFAULT_REQUIRE_AUTH_FOR_READS, PUBLIC_KEY_DECODING_ERR_MSG},
cache_config::{DEFAULT_CAPACITY, DEFAULT_TTI_SECONDS},
log_config::DEFAULT_LOG_LEVEL,
otlp_config::{
@@ -12,7 +12,7 @@ use crate::{
},
},
model::App,
- server::DEFAULT_PORT,
+ server::{apply_server_extension, DEFAULT_PORT},
GraphServer,
};
use clap::{Parser, Subcommand};
@@ -75,12 +75,15 @@ struct ServerArgs {
#[arg(long, env = "RAPHTORY_AUTH_PUBLIC_KEY", default_value = None, help = "Public key for auth")]
auth_public_key: Option,
- #[arg(long, env = "RAPHTORY_AUTH_ENABLED_FOR_READS", default_value_t = DEFAULT_AUTH_ENABLED_FOR_READS, help = "Enable auth for reads")]
- auth_enabled_for_reads: bool,
+ #[arg(long, env = "RAPHTORY_REQUIRE_AUTH_FOR_READS", default_value_t = DEFAULT_REQUIRE_AUTH_FOR_READS, help = "Require JWT authentication for read requests (default: true)")]
+ require_auth_for_reads: bool,
#[arg(long, env = "RAPHTORY_PUBLIC_DIR", default_value = None, help = "Public directory path")]
public_dir: Option,
+ #[arg(long, env = "RAPHTORY_PERMISSIONS_STORE_PATH", default_value = None, help = "Path to the JSON permissions store file")]
+ permissions_store_path: Option,
+
#[cfg(feature = "search")]
#[arg(long, env = "RAPHTORY_CREATE_INDEX", default_value_t = DEFAULT_CREATE_INDEX, help = "Enable index creation")]
create_index: bool,
@@ -114,7 +117,7 @@ where
.with_auth_public_key(server_args.auth_public_key)
.expect(PUBLIC_KEY_DECODING_ERR_MSG)
.with_public_dir(server_args.public_dir)
- .with_auth_enabled_for_reads(server_args.auth_enabled_for_reads);
+ .with_require_auth_for_reads(server_args.require_auth_for_reads);
#[cfg(feature = "search")]
{
@@ -123,14 +126,15 @@ where
let app_config = Some(builder.build());
- GraphServer::new(
+ let server = GraphServer::new(
server_args.work_dir,
app_config,
None,
server_args.graph_config,
- )?
- .run_with_port(server_args.port)
- .await?;
+ )?;
+ let server =
+ apply_server_extension(server, server_args.permissions_store_path.as_deref());
+ server.run_with_port(server_args.port).await?;
}
}
Ok(())
diff --git a/raphtory-graphql/src/config/app_config.rs b/raphtory-graphql/src/config/app_config.rs
index 9404d678e6..56c6ba29a1 100644
--- a/raphtory-graphql/src/config/app_config.rs
+++ b/raphtory-graphql/src/config/app_config.rs
@@ -106,8 +106,8 @@ impl AppConfigBuilder {
Ok(self)
}
- pub fn with_auth_enabled_for_reads(mut self, enabled_for_reads: bool) -> Self {
- self.auth.enabled_for_reads = enabled_for_reads;
+ pub fn with_require_auth_for_reads(mut self, require_auth_for_reads: bool) -> Self {
+ self.auth.require_auth_for_reads = require_auth_for_reads;
self
}
@@ -195,8 +195,8 @@ pub fn load_config(
.with_auth_public_key(public_key)
.map_err(|_| ConfigError::Message(PUBLIC_KEY_DECODING_ERR_MSG.to_owned()))?;
}
- if let Ok(enabled_for_reads) = settings.get::("auth.enabled_for_reads") {
- app_config_builder = app_config_builder.with_auth_enabled_for_reads(enabled_for_reads);
+ if let Ok(require_auth_for_reads) = settings.get::("auth.require_auth_for_reads") {
+ app_config_builder = app_config_builder.with_require_auth_for_reads(require_auth_for_reads);
}
if let Ok(public_dir) = settings.get:: |