1mod batch_exec;
2mod command;
3mod context;
4mod cursor;
5mod error;
6mod explain;
7mod factory;
8mod guard;
9mod query;
10mod session;
11mod streaming;
12
13pub use context::QueryExecutionContext;
14pub use error::GraphServiceError;
15
16use std::sync::atomic::AtomicU64;
17use std::sync::Arc;
18
19#[cfg(feature = "vector")]
20use linkrs_api::api_core::VectorApi;
21use linkrs_api::api_core::{QueryApi, SyncApi};
22use linkrs_metrics::StatsManager;
23use linkrs_query::query_manager::QueryManager;
24use linkrs_transaction::TransactionManager;
25
26use crate::auth::PasswordAuthenticator;
27use crate::config::BootstrapConfig;
28use crate::config::SecurityConfig;
29use crate::permission::PermissionManager;
30use crate::query::executor::streaming::pool::SharedScheduler;
31use crate::query::executor::streaming::query_registry::QueryRegistry;
32use crate::session::GraphSessionManager;
33use crate::storage::StorageClient;
34
35pub struct GraphService<S: StorageClient + Clone + 'static> {
36 session_manager: Arc<GraphSessionManager>,
37 query_api: Arc<parking_lot::RwLock<QueryApi<S>>>,
38 authenticator: PasswordAuthenticator,
39 permission_manager: Arc<PermissionManager>,
40 pub stats_manager: Arc<StatsManager>,
41 storage: Arc<S>,
42 #[cfg(feature = "vector")]
43 vector_api: Option<Arc<VectorApi>>,
44 sync_api: Option<Arc<SyncApi>>,
45
46 transaction_manager: Option<Arc<TransactionManager>>,
47
48 shared_scheduler: Arc<SharedScheduler>,
50 query_registry: Arc<QueryRegistry>,
52 query_manager: Arc<QueryManager>,
54
55 progress_rows_interval: u64,
57
58 next_query_id: AtomicU64,
60
61 bootstrap_config: BootstrapConfig,
64
65 security_config: SecurityConfig,
68}
69
70#[cfg(test)]
71mod tests {
72 use super::GraphService;
73 use crate::storage::{GraphStorage, MockStorage};
74 use linkrs_core::types::{SpaceInfo, SpaceSummary};
75 use std::sync::Arc;
76
77 struct MaintenanceTestService {
80 service: Arc<GraphService<GraphStorage>>,
81 space: SpaceSummary,
82 space_id: i64,
83 }
84
85 impl MaintenanceTestService {
86 async fn new() -> Self {
87 use crate::storage::{PropertyGraphConfig, StorageReader, StorageSchemaOps};
88
89 let storage = Arc::new(
90 GraphStorage::new_with_config(PropertyGraphConfig::test())
91 .expect("in-memory storage"),
92 );
93 {
94 let mut handle = (*storage).clone();
95 let mut space = SpaceInfo::new("maint_space".to_string())
96 .with_vid_type(linkrs_core::DataType::BigInt);
97 StorageSchemaOps::create_space(&mut handle, &mut space).expect("create space");
98 }
99 let space = storage
100 .list_spaces()
101 .expect("list spaces")
102 .into_iter()
103 .next()
104 .expect("space must exist");
105 let space_id = space.space_id as i64;
106 let service = GraphService::<GraphStorage>::new_for_test(
107 crate::config::Config::default(),
108 storage,
109 )
110 .await;
111 Self {
112 service,
113 space: SpaceSummary::from(&space),
114 space_id,
115 }
116 }
117
118 async fn session_with_role(&self, username: &str, role: linkrs_core::RoleType) -> i64 {
120 self.service
121 .permission_manager
122 .grant_role(username, self.space_id, role)
123 .expect("grant role");
124 let session = self
125 .service
126 .get_session_manager()
127 .create_session(username.to_string(), "127.0.0.1".to_string())
128 .await
129 .expect("create session");
130 session.set_space(self.space.clone());
131 session.id()
132 }
133 }
134
135 #[tokio::test]
136 async fn maintenance_statements_are_classified_above_read_only() {
137 use crate::graph_service::GraphService as TestService;
138 use crate::storage::MockStorage;
139
140 fn permission(text: &str) -> Option<linkrs_core::Permission> {
141 let stmt = TestService::<MockStorage>::parse_stmt(text)?;
142 TestService::<MockStorage>::permission_for_stmt(&stmt)
143 }
144
145 assert_eq!(
149 permission("PURGE EXPIRED IN maint_space"),
150 Some(linkrs_core::Permission::Schema)
151 );
152 assert_eq!(
153 permission("purge expired in maint_space"),
154 Some(linkrs_core::Permission::Schema)
155 );
156 assert_eq!(
157 permission("CHECKPOINT"),
158 Some(linkrs_core::Permission::Write)
159 );
160 assert_eq!(
161 permission("SHOW SESSIONS"),
162 Some(linkrs_core::Permission::Admin)
163 );
164 assert_eq!(
165 permission("SHOW QUERIES"),
166 Some(linkrs_core::Permission::Read)
167 );
168 assert_eq!(
169 permission("KILL QUERY 1, 2"),
170 Some(linkrs_core::Permission::Admin)
171 );
172 assert_eq!(
173 permission("MATCH (n) RETURN n"),
174 Some(linkrs_core::Permission::Read)
175 );
176 assert_eq!(
177 permission("UPDATE 1 ON Person SET age = 2"),
178 Some(linkrs_core::Permission::Write)
179 );
180 assert_eq!(
181 permission("CREATE TAG t (id: INT)"),
182 Some(linkrs_core::Permission::Schema)
183 );
184 assert_eq!(
185 permission("CREATE SPACE s"),
186 Some(linkrs_core::Permission::Admin)
187 );
188 assert_eq!(permission("CREATE USER u WITH PASSWORD 'x'"), None);
189 assert_eq!(permission("CREATEUSER u WITH PASSWORD 'x'"), None);
190 assert_eq!(permission("SELECT 1"), None);
191 }
192
193 #[tokio::test]
194 async fn user_management_spellings_share_one_gate() {
195 let fixture = MaintenanceTestService::new().await;
196 let service = &fixture.service;
197 let guest = fixture
198 .session_with_role("gate_guest", linkrs_core::RoleType::Guest)
199 .await;
200 for stmt in [
201 "CREATE USER gate_u WITH PASSWORD 'secret123'",
202 "CREATEUSER gate_u WITH PASSWORD 'secret123'",
203 "CREATE USER gate_u WITH PASSWORD 'secret123'",
204 "CHANGE PASSWORD guest_other 'old12345' TO 'new12345'",
205 "CHANGEPASSWORD guest_other 'old12345' TO 'new12345'",
206 ] {
207 let error = service
208 .execute(guest, stmt)
209 .await
210 .expect_err("guest must not pass the user gate");
211 assert!(
212 error.message().contains("Permission check failed"),
213 "unexpected error for {stmt}: {}",
214 error
215 );
216 let stream_error = match service.execute_stream(guest, stmt).await {
217 Ok(_) => panic!("guest must not pass the streaming gate for {stmt}"),
218 Err(error) => error,
219 };
220 assert!(
221 stream_error.message().contains("Permission check failed"),
222 "unexpected streaming error for {stmt}: {}",
223 stream_error
224 );
225 }
226 }
227
228 #[tokio::test]
229 async fn user_listing_reads_require_admin_or_self() {
230 use super::context::QueryExecutionContext;
231
232 let fixture = MaintenanceTestService::new().await;
233 let service = &fixture.service;
234 let admin = fixture
235 .session_with_role("read_admin", linkrs_core::RoleType::Admin)
236 .await;
237 let _user = fixture
238 .session_with_role("read_user", linkrs_core::RoleType::User)
239 .await;
240 let guest = fixture
241 .session_with_role("read_guest", linkrs_core::RoleType::Guest)
242 .await;
243
244 fn gate(
245 service: &GraphService<crate::storage::GraphStorage>,
246 username: &str,
247 space_id: i64,
248 stmt: &str,
249 ) -> Result<(), super::GraphServiceError> {
250 let context = QueryExecutionContext {
251 stmt,
252 parsed_ast: None,
253 space_id,
254 parameters: None,
255 session_variables: None,
256 };
257 service.check_query_permission(username, &context)
258 }
259
260 gate(service, "read_admin", fixture.space_id, "SHOW USERS").expect("admin lists users");
262 gate(
263 service,
264 "read_admin",
265 fixture.space_id,
266 "SHOW ROLES IN maint_space",
267 )
268 .expect("admin lists roles");
269 for stmt in ["SHOW USERS", "SHOW ROLES", "SHOW ROLES IN maint_space"] {
270 for username in ["read_user", "read_guest"] {
271 let error = gate(service, username, fixture.space_id, stmt)
272 .expect_err("non-admin must not list users/roles");
273 assert!(
274 error.message().contains("only Admin or God"),
275 "unexpected error for {username} {stmt}: {}",
276 error.message()
277 );
278 }
279 }
280
281 for username in ["read_admin", "read_user", "read_guest"] {
283 gate(
284 service,
285 username,
286 fixture.space_id,
287 &format!("DESCRIBE USER {username}"),
288 )
289 .unwrap_or_else(|_| panic!("{username} must describe self"));
290 }
291 for username in ["read_user", "read_guest"] {
292 let error = gate(
293 service,
294 username,
295 fixture.space_id,
296 "DESCRIBE USER someone_else",
297 )
298 .expect_err("non-admin must not describe others");
299 assert!(
300 error.message().contains("only Admin or God"),
301 "unexpected error: {}",
302 error.message()
303 );
304 }
305
306 let error = service
308 .execute(guest, "SHOW USERS")
309 .await
310 .expect_err("guest must not list users");
311 assert!(
312 error.message().contains("Permission check failed"),
313 "unexpected error: {}",
314 error.message()
315 );
316 service
317 .execute(admin, "SHOW USERS")
318 .await
319 .expect("admin lists users");
320 service
321 .execute_stream(admin, "SHOW ROLES")
322 .await
323 .expect("admin lists roles on the streaming entry");
324
325 service
327 .execute(admin, "CREATE USER self_user WITH PASSWORD 'SelfPass123'")
328 .await
329 .expect("admin creates the account");
330 let flown = service
331 .get_session_manager()
332 .create_session("self_user".to_string(), "127.0.0.1".to_string())
333 .await
334 .expect("create session");
335 flown.set_space(fixture.space.clone());
336 service
337 .execute(flown.id(), "DESCRIBE USER self_user")
338 .await
339 .expect("self-description succeeds");
340 let denied = service
341 .execute(guest, "DESCRIBE USER self_user")
342 .await
343 .expect_err("guest must not describe others");
344 assert!(
345 denied.message().contains("Permission check failed"),
346 "unexpected error: {}",
347 denied.message()
348 );
349 }
350
351 #[tokio::test]
352 async fn purge_expired_requires_maintenance_permission_on_every_entry() {
353 let fixture = MaintenanceTestService::new().await;
354 let stmt = "PURGE EXPIRED IN maint_space";
355
356 let guest = fixture
357 .session_with_role("guest_user", linkrs_core::RoleType::Guest)
358 .await;
359 let writer = fixture
360 .session_with_role("writer_user", linkrs_core::RoleType::User)
361 .await;
362 let dba = fixture
363 .session_with_role("dba_user", linkrs_core::RoleType::Dba)
364 .await;
365
366 for session_id in [guest, writer] {
368 let error = fixture
369 .service
370 .execute(session_id, stmt)
371 .await
372 .expect_err("role without maintenance rights must be rejected");
373 assert!(
374 error.message().contains("Permission check failed"),
375 "unexpected error: {}",
376 error
377 );
378 }
379 let stream_error = match fixture.service.execute_stream(guest, stmt).await {
381 Ok(_) => panic!("read-only role must be rejected on the streaming entry"),
382 Err(error) => error,
383 };
384 assert!(
385 stream_error.message().contains("Permission check failed"),
386 "unexpected error: {}",
387 stream_error
388 );
389
390 fixture
392 .service
393 .execute(dba, stmt)
394 .await
395 .expect("dba role runs the purge");
396 fixture
397 .service
398 .execute_stream(dba, stmt)
399 .await
400 .expect("dba role runs the purge on the streaming entry");
401 }
402
403 #[tokio::test]
404 async fn checkpoint_requires_write_permission_on_every_entry() {
405 let fixture = MaintenanceTestService::new().await;
406
407 let guest = fixture
408 .session_with_role("guest_user", linkrs_core::RoleType::Guest)
409 .await;
410 let writer = fixture
411 .session_with_role("writer_user", linkrs_core::RoleType::User)
412 .await;
413
414 let unary_error = fixture
415 .service
416 .execute(guest, "CHECKPOINT")
417 .await
418 .expect_err("read-only role must be rejected");
419 assert!(
420 unary_error.message().contains("Permission check failed"),
421 "unexpected error: {}",
422 unary_error
423 );
424 let stream_error = match fixture.service.execute_stream(guest, "CHECKPOINT").await {
425 Ok(_) => panic!("read-only role must be rejected on the streaming entry"),
426 Err(error) => error,
427 };
428 assert!(
429 stream_error.message().contains("Permission check failed"),
430 "unexpected error: {}",
431 stream_error
432 );
433
434 fixture
435 .service
436 .execute(writer, "CHECKPOINT")
437 .await
438 .expect("write role runs the checkpoint");
439 fixture
440 .service
441 .execute_stream(writer, "CHECKPOINT")
442 .await
443 .expect("write role runs the checkpoint on the streaming entry");
444 }
445
446 #[tokio::test]
447 async fn use_statement_binds_session_space_from_parsed_form() {
448 let fixture = MaintenanceTestService::new().await;
449 let session = fixture
450 .session_with_role("use_user", linkrs_core::RoleType::User)
451 .await;
452
453 for stmt in ["USE maint_space", "USE\tmaint_space", "USE\nmaint_space"] {
456 fixture
457 .service
458 .execute(session, stmt)
459 .await
460 .expect("space switch must execute");
461 let bound = fixture
462 .service
463 .get_session_manager()
464 .find_session(session)
465 .expect("session must exist")
466 .space()
467 .map(|space| space.id);
468 assert_eq!(
469 bound,
470 Some(fixture.space_id as u64),
471 "session space must bind for {stmt:?}",
472 );
473 }
474 }
475
476 #[tokio::test]
477 async fn use_statement_binds_session_space_on_the_streaming_entry() {
478 let fixture = MaintenanceTestService::new().await;
479 let session = fixture
480 .session_with_role("stream_use_user", linkrs_core::RoleType::User)
481 .await;
482
483 fixture
486 .service
487 .execute_stream(session, "USE\tmaint_space")
488 .await
489 .expect("streaming space switch must execute");
490 let bound = fixture
491 .service
492 .get_session_manager()
493 .find_session(session)
494 .expect("session must exist")
495 .space()
496 .map(|space| space.id);
497 assert_eq!(
498 bound,
499 Some(fixture.space_id as u64),
500 "streaming space switch must bind the session space",
501 );
502 }
503
504 #[tokio::test]
505 async fn session_inspection_serves_live_registries() {
506 let fixture = MaintenanceTestService::new().await;
507 let service = &fixture.service;
508 let admin = fixture
509 .session_with_role("inspect_admin", linkrs_core::RoleType::Admin)
510 .await;
511 let user = fixture
512 .session_with_role("inspect_user", linkrs_core::RoleType::User)
513 .await;
514
515 let sessions = service
517 .execute(admin, "SHOW SESSIONS")
518 .await
519 .expect("admin lists sessions");
520 assert!(sessions.columns().contains(&"session_id".to_string()));
521 assert!(
522 sessions.rows().iter().any(|row| row
523 .first()
524 .is_some_and(|value| *value == linkrs_core::Value::BigInt(admin))),
525 "own session row must be present"
526 );
527 let denied = service
528 .execute(user, "SHOW SESSIONS")
529 .await
530 .expect_err("user must not list sessions");
531 assert!(
532 denied.message().contains("Permission check failed"),
533 "unexpected error: {}",
534 denied.message()
535 );
536
537 let queries = service
539 .execute(user, "SHOW QUERIES")
540 .await
541 .expect("user lists queries");
542 assert!(queries.columns().contains(&"query_id".to_string()));
543 assert!(queries.rows().is_empty());
544
545 service
548 .query_manager()
549 .register_query_with_id(
550 424201,
551 admin,
552 "inspect_admin".to_string(),
553 None,
554 "MATCH (n) RETURN n".to_string(),
555 )
556 .expect("register tracked query");
557 let killed = service
558 .execute(admin, &format!("KILL QUERY {admin}, 424201"))
559 .await
560 .expect("kill tracked query");
561 assert_eq!(
562 killed.columns(),
563 &[
564 "session_id".to_string(),
565 "query_id".to_string(),
566 "status".to_string()
567 ]
568 );
569 assert_eq!(killed.rows().len(), 1);
570 let repeat = service
571 .execute(admin, &format!("KILL QUERY {admin}, 424201"))
572 .await
573 .expect_err("repeat kill must fail");
574 assert!(
575 repeat.message().contains("not running"),
576 "unexpected error: {}",
577 repeat.message()
578 );
579 let foreign = service
580 .execute(admin, "KILL QUERY 999999, 424201")
581 .await
582 .expect_err("unknown session must fail");
583 assert!(
584 foreign.message().contains("does not exist"),
585 "unexpected error: {}",
586 foreign.message()
587 );
588
589 service
591 .execute_stream(admin, "SHOW SESSIONS")
592 .await
593 .expect("streaming inspection must execute");
594 }
595
596 #[test]
597 fn write_and_schema_statements_map_above_read() {
598 use crate::graph_service::GraphService as TestService;
599 use crate::query::parser::ast::StmtCategory;
600 use crate::storage::MockStorage;
601
602 fn classified(text: &str) -> (Option<linkrs_core::Permission>, StmtCategory) {
603 let stmt = TestService::<MockStorage>::parse_stmt(text)
604 .unwrap_or_else(|| panic!("statement should parse: {text}"));
605 let category = stmt.category();
606 let permission = TestService::<MockStorage>::permission_for_stmt(&stmt);
607 (permission, category)
608 }
609
610 for (text, kind) in [
613 ("UPDATE 1 ON Person SET age = 2", "UPDATE"),
614 (
615 "UPSERT VERTEX ON Person SET name = 'Alice' WHERE id(vid) == 1",
616 "UPSERT",
617 ),
618 ("MERGE (v:Person {name: 'Alice'}) SET v.age = 30", "MERGE"),
619 ] {
620 let (permission, category) = classified(text);
621 assert_eq!(
622 permission,
623 Some(linkrs_core::Permission::Write),
624 "{kind} must require Write"
625 );
626 assert_eq!(category, StmtCategory::Dml, "{kind} must classify as DML");
627 let stmt = TestService::<MockStorage>::parse_stmt(text).expect("parses");
628 assert_eq!(stmt.kind(), kind);
629 }
630
631 for (text, expected) in [
633 ("CREATE TAG t (id: INT)", linkrs_core::Permission::Schema),
634 (
635 "CREATE INDEX idx ON Person (name)",
636 linkrs_core::Permission::Schema,
637 ),
638 ("CREATE SPACE s", linkrs_core::Permission::Admin),
639 ("DROP SPACE s", linkrs_core::Permission::Admin),
640 ] {
641 let (permission, category) = classified(text);
642 assert_eq!(permission, Some(expected), "{text} misclassified");
643 assert_eq!(category, StmtCategory::Ddl, "{text} must classify as DDL");
644 }
645 }
646
647 #[test]
648 fn transaction_command_classification() {
649 let begin = GraphService::<MockStorage>::parse_command("BEGIN READ ONLY")
650 .expect("BEGIN should classify")
651 .expect("BEGIN should be a command");
652 assert!(matches!(
653 begin.ast.stmt(),
654 crate::query::parser::ast::Stmt::BeginTransaction(ref s) if s.read_only == Some(true)
655 ));
656
657 let commit = GraphService::<MockStorage>::parse_command("COMMIT")
658 .expect("COMMIT should classify")
659 .expect("COMMIT should be a command");
660 assert!(matches!(
661 commit.ast.stmt(),
662 crate::query::parser::ast::Stmt::CommitTransaction(_)
663 ));
664
665 let rollback = GraphService::<MockStorage>::parse_command("ROLLBACK TO sp1")
666 .expect("ROLLBACK TO should classify")
667 .expect("ROLLBACK TO should be a command");
668 assert!(matches!(
669 rollback.ast.stmt(),
670 crate::query::parser::ast::Stmt::RollbackTransaction(ref s)
671 if s.savepoint_name.as_deref() == Some("sp1")
672 ));
673
674 let savepoint = GraphService::<MockStorage>::parse_command("SAVEPOINT sp1")
675 .expect("SAVEPOINT should classify")
676 .expect("SAVEPOINT should be a command");
677 assert!(matches!(
678 savepoint.ast.stmt(),
679 crate::query::parser::ast::Stmt::Savepoint(ref s) if s.name == "sp1"
680 ));
681
682 let release = GraphService::<MockStorage>::parse_command("RELEASE SAVEPOINT sp1")
683 .expect("RELEASE SAVEPOINT should classify")
684 .expect("RELEASE SAVEPOINT should be a command");
685 assert!(matches!(
686 release.ast.stmt(),
687 crate::query::parser::ast::Stmt::ReleaseSavepoint(ref s) if s.name == "sp1"
688 ));
689
690 let let_stmt = GraphService::<MockStorage>::parse_command("LET $x = 1 + 2")
691 .expect("LET should classify")
692 .expect("LET should be a command");
693 assert!(matches!(
694 let_stmt.ast.stmt(),
695 crate::query::parser::ast::Stmt::AssignVariable(ref s) if s.name == "x"
696 ));
697
698 assert!(
699 GraphService::<MockStorage>::parse_command("MATCH (n) RETURN n")
700 .unwrap()
701 .is_none()
702 );
703 assert!(GraphService::<MockStorage>::parse_command("COMMIT junk")
704 .unwrap()
705 .is_none());
706
707 let err =
708 GraphService::<MockStorage>::parse_command("LET $x").expect_err("LET $x must fail");
709 assert!(
710 err.message().contains("LET requires an assignment"),
711 "unexpected error: {}",
712 err
713 );
714
715 let bare_let =
716 GraphService::<MockStorage>::parse_command("LET").expect_err("bare LET must fail");
717 assert!(
718 bare_let.message().contains("Invalid session variable name"),
719 "unexpected error: {}",
720 err
721 );
722 }
723
724 #[test]
725 fn config_statements_are_classified_for_streaming_guard() {
726 assert!(GraphService::<MockStorage>::is_config_statement(
727 "UPDATE CONFIGS database max_sessions = 100"
728 ));
729 assert!(GraphService::<MockStorage>::is_config_statement(
730 " show configs database"
731 ));
732 assert!(!GraphService::<MockStorage>::is_config_statement(
733 "MATCH (n) RETURN n"
734 ));
735 assert!(!GraphService::<MockStorage>::is_config_statement(
736 "UPDATE 1 ON Person SET age = 2"
737 ));
738 }
739
740 #[test]
741 fn root_estimate_takes_the_last_marker() {
742 let plan = "info est_rows:10,other:1\nmore est_rows:250\ntail without marker";
743 assert_eq!(
744 GraphService::<MockStorage>::extract_root_estimate(plan),
745 Some(250)
746 );
747 }
748
749 #[test]
750 fn root_estimate_absent_without_markers() {
751 assert_eq!(
752 GraphService::<MockStorage>::extract_root_estimate("no estimates here"),
753 None
754 );
755 assert_eq!(
756 GraphService::<MockStorage>::extract_root_estimate("est_rows:"),
757 None
758 );
759 }
760}