1use std::sync::Arc;
15
16use axum::extract::Request;
17use axum::http::{Method, StatusCode};
18use axum::middleware::Next;
19use axum::response::{IntoResponse, Response};
20use serde::Serialize;
21
22use lago_auth::UserContext;
23use lago_core::event::PolicyDecisionKind;
24use lago_core::policy::PolicyContext;
25
26use crate::state::AppState;
27
28#[derive(Serialize)]
30struct PolicyDeniedBody {
31 error: String,
32 message: String,
33 rule_id: Option<String>,
34}
35
36fn request_to_tool_name(method: &Method, path: &str) -> Option<String> {
40 if matches!(*method, Method::GET | Method::HEAD | Method::OPTIONS) {
42 return None;
43 }
44
45 let action = match *method {
47 Method::PUT => "write",
48 Method::POST => "create",
49 Method::DELETE => "delete",
50 Method::PATCH => "patch",
51 _ => "unknown",
52 };
53
54 let resource = if path.contains("/blobs/") {
56 "blob"
57 } else if path.contains("/files/") {
58 "file"
59 } else if path.contains("/branches") {
60 "branch"
61 } else if path.contains("/sessions") {
62 "session"
63 } else if path.contains("/memory/") {
64 "memory"
65 } else if path.contains("/snapshots") {
66 "snapshot"
67 } else {
68 "http"
69 };
70
71 Some(format!("http.{resource}.{action}"))
72}
73
74fn extract_session_id(path: &str) -> String {
78 let parts: Vec<&str> = path.split('/').collect();
79 for (i, part) in parts.iter().enumerate() {
80 if *part == "sessions" {
81 if let Some(id) = parts.get(i + 1) {
82 return id.to_string();
83 }
84 }
85 }
86 "anonymous".to_string()
87}
88
89#[derive(Debug, Clone, Copy, PartialEq, Eq)]
93enum SessionTier {
94 Public,
96 User,
98 Agent,
100 Default,
102}
103
104fn map_session_to_tier(session_id: &str) -> SessionTier {
112 if session_id.starts_with("site-assets:") || session_id.starts_with("site-content:") {
113 SessionTier::Public
114 } else if session_id.starts_with("vault:") {
115 SessionTier::User
116 } else if session_id.starts_with("agent:") {
117 SessionTier::Agent
118 } else {
119 SessionTier::Default
120 }
121}
122
123async fn resolve_session_name(state: &AppState, session_id: &str) -> Option<String> {
128 let sid = lago_core::SessionId::from_string(session_id);
129 match state.journal.get_session(&sid).await {
130 Ok(Some(session)) => Some(session.config.name),
131 _ => None,
132 }
133}
134
135async fn has_admin_role(state: &AppState, rbac_session_id: &str) -> bool {
144 use lago_policy::Permission;
145
146 let Some(ref rbac) = state.rbac_manager else {
147 return false;
148 };
149
150 let mgr = rbac.read().await;
151
152 let Some(role_names) = mgr.assignments().get(rbac_session_id) else {
154 return false;
155 };
156
157 for role_name in role_names {
159 if let Some(role) = mgr.roles().get(role_name) {
160 if role
161 .permissions
162 .iter()
163 .any(|p| matches!(p, Permission::Admin))
164 {
165 return true;
166 }
167 }
168 }
169
170 false
171}
172
173async fn check_rbac(
185 state: &AppState,
186 method: &Method,
187 session_id: &str,
188 user_ctx: Option<&UserContext>,
189) -> Option<Response> {
190 state.rbac_manager.as_ref()?;
192
193 let session_name = resolve_session_name(state, session_id).await;
196 let tier = map_session_to_tier(session_name.as_deref().unwrap_or(session_id));
197
198 match tier {
199 SessionTier::Public => {
200 if matches!(*method, Method::GET | Method::HEAD | Method::OPTIONS) {
202 return None;
203 }
204
205 match user_ctx {
210 Some(_) => None, None => Some(rbac_denied(
212 "write operations on public sessions require authentication",
213 )),
214 }
215 }
216
217 SessionTier::User => {
218 let ctx = match user_ctx {
219 Some(c) => c,
220 None => {
221 return Some(rbac_denied(
222 "authentication required to access user vault sessions",
223 ));
224 }
225 };
226
227 if ctx.lago_session_id.as_str() == session_id {
230 return None;
231 }
232
233 if has_admin_role(state, ctx.lago_session_id.as_str()).await {
235 return None;
236 }
237
238 Some(rbac_denied(
239 "access denied: you do not own this vault session",
240 ))
241 }
242
243 SessionTier::Agent => {
244 let ctx = match user_ctx {
245 Some(c) => c,
246 None => {
247 return Some(rbac_denied(
248 "authentication required to access agent sessions",
249 ));
250 }
251 };
252
253 if ctx.lago_session_id.as_str() == session_id {
255 return None;
256 }
257
258 if has_admin_role(state, ctx.lago_session_id.as_str()).await {
260 return None;
261 }
262
263 Some(rbac_denied(
264 "access denied: you do not own this agent session",
265 ))
266 }
267
268 SessionTier::Default => {
269 None
271 }
272 }
273}
274
275fn rbac_denied(message: &str) -> Response {
277 let body = PolicyDeniedBody {
278 error: "rbac_denied".to_string(),
279 message: message.to_string(),
280 rule_id: None,
281 };
282 (StatusCode::FORBIDDEN, axum::Json(body)).into_response()
283}
284
285pub async fn policy_middleware(
299 axum::extract::State(state): axum::extract::State<Arc<AppState>>,
300 request: Request,
301 next: Next,
302) -> Response {
303 let method = request.method().clone();
304 let path = request.uri().path().to_string();
305 let session_id = extract_session_id(&path);
306
307 if let Some(ref policy_engine) = state.policy_engine {
309 if let Some(tool_name) = request_to_tool_name(&method, &path) {
310 let ctx = PolicyContext {
311 tool_name,
312 arguments: serde_json::json!({}),
313 category: Some("http".to_string()),
314 risk: None,
315 session_id: session_id.clone(),
316 role: None,
317 sandbox_tier: None,
318 };
319
320 let decision = policy_engine.evaluate(&ctx);
321
322 match decision.decision {
323 PolicyDecisionKind::Deny => {
324 let body = PolicyDeniedBody {
325 error: "policy_denied".to_string(),
326 message: decision
327 .explanation
328 .unwrap_or_else(|| "operation denied by policy".to_string()),
329 rule_id: decision.rule_id,
330 };
331 return (StatusCode::FORBIDDEN, axum::Json(body)).into_response();
332 }
333 PolicyDecisionKind::RequireApproval => {
334 let body = PolicyDeniedBody {
335 error: "approval_required".to_string(),
336 message: decision
337 .explanation
338 .unwrap_or_else(|| "operation requires approval".to_string()),
339 rule_id: decision.rule_id,
340 };
341 return (StatusCode::FORBIDDEN, axum::Json(body)).into_response();
342 }
343 PolicyDecisionKind::Allow => { }
344 }
345 }
346 }
347
348 let user_ctx = request
354 .extensions()
355 .get::<UserContext>()
356 .cloned()
357 .or_else(|| try_extract_user_context(&request, &state));
358
359 if let Some(deny_response) = check_rbac(&state, &method, &session_id, user_ctx.as_ref()).await {
360 return deny_response;
361 }
362
363 next.run(request).await
364}
365
366fn try_extract_user_context(request: &Request, state: &AppState) -> Option<UserContext> {
371 let auth_layer = state.auth.as_ref()?;
372 let auth_header = request.headers().get("authorization")?.to_str().ok()?;
373 let token = lago_auth::jwt::extract_bearer_token(auth_header).ok()?;
374 let claims = lago_auth::jwt::validate_jwt(token, &auth_layer.jwt_secret).ok()?;
375
376 Some(UserContext {
379 user_id: claims.sub,
380 email: claims.email,
381 lago_session_id: lago_core::SessionId::from_string("authenticated"),
382 })
383}
384
385#[cfg(test)]
386mod tests {
387 use super::*;
388
389 #[test]
390 fn read_methods_bypass_policy() {
391 assert!(request_to_tool_name(&Method::GET, "/v1/blobs/abc").is_none());
392 assert!(request_to_tool_name(&Method::HEAD, "/v1/blobs/abc").is_none());
393 assert!(request_to_tool_name(&Method::OPTIONS, "/v1/blobs/abc").is_none());
394 }
395
396 #[test]
397 fn write_methods_get_tool_names() {
398 assert_eq!(
399 request_to_tool_name(&Method::PUT, "/v1/blobs/abc"),
400 Some("http.blob.write".to_string())
401 );
402 assert_eq!(
403 request_to_tool_name(&Method::POST, "/v1/sessions"),
404 Some("http.session.create".to_string())
405 );
406 assert_eq!(
407 request_to_tool_name(&Method::DELETE, "/v1/sessions/sid/files/foo.rs"),
408 Some("http.file.delete".to_string())
409 );
410 assert_eq!(
411 request_to_tool_name(&Method::PATCH, "/v1/sessions/sid/files/foo.rs"),
412 Some("http.file.patch".to_string())
413 );
414 }
415
416 #[test]
417 fn session_id_extracted_from_path() {
418 assert_eq!(
419 extract_session_id("/v1/sessions/my-session/files/foo.rs"),
420 "my-session"
421 );
422 assert_eq!(extract_session_id("/v1/blobs/abc"), "anonymous");
423 assert_eq!(extract_session_id("/v1/sessions/abc123/branches"), "abc123");
424 }
425
426 #[test]
429 fn tier_public_site_assets() {
430 assert_eq!(
431 map_session_to_tier("site-assets:images"),
432 SessionTier::Public
433 );
434 }
435
436 #[test]
437 fn tier_public_site_content() {
438 assert_eq!(
439 map_session_to_tier("site-content:blog"),
440 SessionTier::Public
441 );
442 }
443
444 #[test]
445 fn tier_user_vault() {
446 assert_eq!(map_session_to_tier("vault:user-123"), SessionTier::User);
447 }
448
449 #[test]
450 fn tier_agent() {
451 assert_eq!(map_session_to_tier("agent:arcan-01"), SessionTier::Agent);
452 }
453
454 #[test]
455 fn tier_default_for_unknown_prefix() {
456 assert_eq!(map_session_to_tier("my-session"), SessionTier::Default);
457 assert_eq!(map_session_to_tier("anonymous"), SessionTier::Default);
458 assert_eq!(map_session_to_tier("dev-branch"), SessionTier::Default);
459 }
460
461 #[test]
462 fn tier_prefix_must_include_colon() {
463 assert_eq!(map_session_to_tier("vault"), SessionTier::Default);
465 assert_eq!(map_session_to_tier("agent"), SessionTier::Default);
466 assert_eq!(map_session_to_tier("site-assets"), SessionTier::Default);
467 }
468
469 use lago_core::SessionId;
472 use lago_policy::RbacManager;
473 use std::time::Instant;
474 use tokio::sync::RwLock;
475
476 fn test_state(rbac: Option<RbacManager>) -> (Arc<AppState>, tempfile::TempDir) {
481 let tmp = tempfile::tempdir().unwrap();
482 let data_dir = tmp.path().to_path_buf();
483 let blob_store = Arc::new(lago_store::BlobStore::open(data_dir.join("blobs")).unwrap());
484 let journal: Arc<dyn lago_core::Journal> =
485 Arc::new(lago_journal::RedbJournal::open(data_dir.join("journal.redb")).unwrap());
486
487 let recorder = metrics_exporter_prometheus::PrometheusBuilder::new().build_recorder();
488 let prometheus_handle = recorder.handle();
489 let _ = metrics::set_global_recorder(recorder);
490
491 let state = Arc::new(AppState {
492 journal,
493 blob_store,
494 data_dir,
495 started_at: Instant::now(),
496 auth: None,
497 policy_engine: None,
498 rbac_manager: rbac.map(|m| Arc::new(RwLock::new(m))),
499 hook_runner: None,
500 rate_limiter: None,
501 prometheus_handle,
502 manifest_cache: tokio::sync::RwLock::new(std::collections::HashMap::new()),
503 });
504 (state, tmp)
505 }
506
507 fn make_user_ctx(session_id: &str) -> UserContext {
508 UserContext {
509 user_id: "user-1".to_string(),
510 email: "test@example.com".to_string(),
511 lago_session_id: SessionId::from_string(session_id),
512 }
513 }
514
515 #[tokio::test]
516 async fn rbac_disabled_allows_everything() {
517 let (state, _tmp) = test_state(None);
518 let result = check_rbac(&state, &Method::PUT, "site-assets:img", None).await;
520 assert!(result.is_none(), "RBAC disabled should allow all requests");
521 }
522
523 #[tokio::test]
524 async fn rbac_public_allows_get_without_auth() {
525 let (state, _tmp) = test_state(Some(RbacManager::new()));
526 let result = check_rbac(&state, &Method::GET, "site-assets:img", None).await;
527 assert!(result.is_none(), "public GET should be allowed anonymously");
528 }
529
530 #[tokio::test]
531 async fn rbac_public_allows_head_without_auth() {
532 let (state, _tmp) = test_state(Some(RbacManager::new()));
533 let result = check_rbac(&state, &Method::HEAD, "site-content:blog", None).await;
534 assert!(
535 result.is_none(),
536 "public HEAD should be allowed anonymously"
537 );
538 }
539
540 #[tokio::test]
541 async fn rbac_public_denies_put_without_auth() {
542 let (state, _tmp) = test_state(Some(RbacManager::new()));
543 let result = check_rbac(&state, &Method::PUT, "site-assets:img", None).await;
544 assert!(result.is_some(), "public PUT without auth should be denied");
545 }
546
547 #[tokio::test]
548 async fn rbac_public_allows_put_for_authenticated_user() {
549 let (state, _tmp) = test_state(Some(RbacManager::new()));
550 let user = make_user_ctx("vault:user-1");
551 let result = check_rbac(&state, &Method::PUT, "site-assets:img", Some(&user)).await;
552 assert!(
553 result.is_none(),
554 "public PUT by authenticated user should be allowed"
555 );
556 }
557
558 #[tokio::test]
559 async fn rbac_public_allows_put_for_admin() {
560 use lago_policy::{Permission, Role};
561
562 let mut rbac = RbacManager::new();
563 rbac.add_role(Role {
564 name: "admin".to_string(),
565 permissions: vec![Permission::Admin],
566 });
567 rbac.assign_role("vault:admin-user", "admin");
568
569 let (state, _tmp) = test_state(Some(rbac));
570 let user = make_user_ctx("vault:admin-user");
571
572 let result = check_rbac(&state, &Method::PUT, "site-assets:img", Some(&user)).await;
573 assert!(result.is_none(), "public PUT by admin should be allowed");
574 }
575
576 #[tokio::test]
577 async fn rbac_vault_owner_allowed() {
578 let (state, _tmp) = test_state(Some(RbacManager::new()));
579 let user = make_user_ctx("vault:user-1");
580 let result = check_rbac(&state, &Method::PUT, "vault:user-1", Some(&user)).await;
581 assert!(
582 result.is_none(),
583 "vault owner should be allowed to write own session"
584 );
585 }
586
587 #[tokio::test]
588 async fn rbac_vault_non_owner_denied() {
589 let (state, _tmp) = test_state(Some(RbacManager::new()));
590 let user = make_user_ctx("vault:user-1");
591 let result = check_rbac(&state, &Method::GET, "vault:user-2", Some(&user)).await;
592 assert!(
593 result.is_some(),
594 "non-owner should be denied access to another user's vault"
595 );
596 }
597
598 #[tokio::test]
599 async fn rbac_vault_anonymous_denied() {
600 let (state, _tmp) = test_state(Some(RbacManager::new()));
601 let result = check_rbac(&state, &Method::GET, "vault:user-1", None).await;
602 assert!(
603 result.is_some(),
604 "anonymous access to vault should be denied"
605 );
606 }
607
608 #[tokio::test]
609 async fn rbac_agent_owner_allowed() {
610 let (state, _tmp) = test_state(Some(RbacManager::new()));
611 let user = make_user_ctx("agent:arcan-01");
612 let result = check_rbac(&state, &Method::PUT, "agent:arcan-01", Some(&user)).await;
613 assert!(
614 result.is_none(),
615 "agent owner should be allowed to access own session"
616 );
617 }
618
619 #[tokio::test]
620 async fn rbac_agent_non_owner_denied() {
621 let (state, _tmp) = test_state(Some(RbacManager::new()));
622 let user = make_user_ctx("agent:arcan-01");
623 let result = check_rbac(&state, &Method::GET, "agent:arcan-02", Some(&user)).await;
624 assert!(
625 result.is_some(),
626 "non-owner should be denied access to another agent's session"
627 );
628 }
629
630 #[tokio::test]
631 async fn rbac_agent_anonymous_denied() {
632 let (state, _tmp) = test_state(Some(RbacManager::new()));
633 let result = check_rbac(&state, &Method::GET, "agent:arcan-01", None).await;
634 assert!(
635 result.is_some(),
636 "anonymous access to agent session should be denied"
637 );
638 }
639
640 #[tokio::test]
641 async fn rbac_default_tier_allows_all() {
642 let (state, _tmp) = test_state(Some(RbacManager::new()));
643 let result = check_rbac(&state, &Method::PUT, "my-session", None).await;
645 assert!(result.is_none(), "default tier should allow all operations");
646 }
647
648 #[tokio::test]
649 async fn rbac_admin_bypasses_vault_ownership() {
650 use lago_policy::{Permission, Role};
651
652 let mut rbac = RbacManager::new();
653 rbac.add_role(Role {
654 name: "admin".to_string(),
655 permissions: vec![Permission::Admin],
656 });
657 rbac.assign_role("vault:admin-user", "admin");
658
659 let (state, _tmp) = test_state(Some(rbac));
660 let user = make_user_ctx("vault:admin-user");
661
662 let result = check_rbac(&state, &Method::PUT, "vault:other-user", Some(&user)).await;
664 assert!(
665 result.is_none(),
666 "admin should bypass vault ownership check"
667 );
668 }
669
670 #[tokio::test]
671 async fn rbac_admin_bypasses_agent_ownership() {
672 use lago_policy::{Permission, Role};
673
674 let mut rbac = RbacManager::new();
675 rbac.add_role(Role {
676 name: "admin".to_string(),
677 permissions: vec![Permission::Admin],
678 });
679 rbac.assign_role("vault:admin-user", "admin");
680
681 let (state, _tmp) = test_state(Some(rbac));
682 let user = make_user_ctx("vault:admin-user");
683
684 let result = check_rbac(&state, &Method::DELETE, "agent:arcan-01", Some(&user)).await;
686 assert!(
687 result.is_none(),
688 "admin should bypass agent ownership check"
689 );
690 }
691
692 #[tokio::test]
693 async fn rbac_public_allows_delete_for_authenticated_user() {
694 let (state, _tmp) = test_state(Some(RbacManager::new()));
695 let user = make_user_ctx("vault:user-1");
696 let result = check_rbac(&state, &Method::DELETE, "site-content:blog", Some(&user)).await;
697 assert!(
698 result.is_none(),
699 "DELETE on public session by authenticated user should be allowed"
700 );
701 }
702
703 #[tokio::test]
704 async fn rbac_public_post_denied_without_admin() {
705 let (state, _tmp) = test_state(Some(RbacManager::new()));
706 let result = check_rbac(&state, &Method::POST, "site-assets:css", None).await;
707 assert!(
708 result.is_some(),
709 "POST on public session without admin should be denied"
710 );
711 }
712}