Skip to main content

lago_api/
middleware.rs

1//! Axum middleware for policy and RBAC enforcement on HTTP routes.
2//!
3//! Maps HTTP write operations (PUT, POST, DELETE, PATCH) to synthetic
4//! tool names and evaluates them against the `PolicyEngine`. Read
5//! operations pass through without policy checks.
6//!
7//! After policy evaluation, RBAC is enforced based on the session tier
8//! derived from the session ID prefix:
9//! - `site-assets:` / `site-content:` → **public** (anonymous read-only)
10//! - `vault:` → **user** (only the owning user may read/write)
11//! - `agent:` → **agent** (only the owning agent may access)
12//! - Anything else → **default** (no additional RBAC restriction)
13
14use 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/// JSON body returned for policy denial responses.
29#[derive(Serialize)]
30struct PolicyDeniedBody {
31    error: String,
32    message: String,
33    rule_id: Option<String>,
34}
35
36/// Map an HTTP method + path to a synthetic tool name for policy evaluation.
37///
38/// Returns `None` for read operations (GET, HEAD, OPTIONS) which bypass policy.
39fn request_to_tool_name(method: &Method, path: &str) -> Option<String> {
40    // Only evaluate policy for write operations
41    if matches!(*method, Method::GET | Method::HEAD | Method::OPTIONS) {
42        return None;
43    }
44
45    // Derive tool name from the route pattern
46    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    // Extract the resource type from the path
55    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
74/// Extract a session ID from the request path, if present.
75///
76/// Matches patterns like `/v1/sessions/{id}/...`.
77fn 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/// Access tier derived from the session ID prefix.
90///
91/// Determines what RBAC rules apply to requests targeting a given session.
92#[derive(Debug, Clone, Copy, PartialEq, Eq)]
93enum SessionTier {
94    /// Public content — anonymous users may read, only admins may write.
95    Public,
96    /// User vault — only the owning user (or admin) may access.
97    User,
98    /// Agent session — only the owning agent (or admin) may access.
99    Agent,
100    /// No special tier — falls through to default RBAC/policy rules.
101    Default,
102}
103
104/// Classify a session ID into an access tier based on its prefix.
105///
106/// Convention:
107/// - `site-assets:*` or `site-content:*` → public (read-only for anonymous)
108/// - `vault:*` → user tier (owner-only access)
109/// - `agent:*` → agent tier (owner-only access)
110/// - Anything else → default (no additional RBAC restriction)
111fn 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
123/// Resolve a session UUID to its human-readable name by querying the journal.
124///
125/// Returns `None` if the session doesn't exist or the lookup fails.
126/// Falls back gracefully — RBAC will treat unknown sessions as `Default` tier.
127async 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
135/// Check whether a user has an admin role according to the RBAC manager.
136///
137/// Returns `true` if the RBAC manager has an explicit `Permission::Admin`
138/// for any role assigned to the given session ID.
139///
140/// Note: We cannot use `check_permission` for this because it returns
141/// `Allow` by default when no roles are assigned. Instead we inspect the
142/// role assignments and permissions directly.
143async 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    // Look up roles assigned to this session
153    let Some(role_names) = mgr.assignments().get(rbac_session_id) else {
154        return false;
155    };
156
157    // Check if any assigned role has the Admin permission
158    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
173/// Evaluate RBAC tier restrictions for the request.
174///
175/// Returns `None` if the request is allowed, or `Some(Response)` with a 403
176/// if the request is denied by RBAC.
177///
178/// The logic per tier:
179/// - **Public**: GET/HEAD allowed for everyone. Writes require admin role.
180/// - **User**: The requesting user's lago_session_id must match the target
181///   session, or the user must be admin. Anonymous requests are denied.
182/// - **Agent**: Same ownership check as User tier, using agent identity.
183/// - **Default**: No RBAC restriction (pass through).
184async fn check_rbac(
185    state: &AppState,
186    method: &Method,
187    session_id: &str,
188    user_ctx: Option<&UserContext>,
189) -> Option<Response> {
190    // If no RBAC manager is configured, skip enforcement entirely.
191    state.rbac_manager.as_ref()?;
192
193    // Resolve session name from journal — the URL path contains the UUID,
194    // but tier classification requires the session name (e.g., "site-assets:public").
195    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            // Read operations are always allowed for public sessions
201            if matches!(*method, Method::GET | Method::HEAD | Method::OPTIONS) {
202                return None;
203            }
204
205            // Write operations on public sessions require authentication.
206            // Any authenticated user (valid JWT) can manage public content —
207            // they possess the server's JWT secret, making them a trusted operator.
208            // Anonymous writes are denied.
209            match user_ctx {
210                Some(_) => None, // Authenticated → allowed
211                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            // Check if the requesting user owns this session.
228            // vault:{user_id} must match the user's lago_session_id.
229            if ctx.lago_session_id.as_str() == session_id {
230                return None;
231            }
232
233            // Fall back to admin check
234            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            // Check if the requesting user/agent owns this session
254            if ctx.lago_session_id.as_str() == session_id {
255                return None;
256            }
257
258            // Fall back to admin check
259            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            // No additional RBAC restriction for default-tier sessions
270            None
271        }
272    }
273}
274
275/// Build a 403 Forbidden response for RBAC denials.
276fn 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
285/// Axum middleware that evaluates write operations against the policy engine
286/// and enforces RBAC tier restrictions.
287///
288/// **Policy enforcement** (when `policy_engine` is `Some`): all write
289/// operations are evaluated. If the policy denies the operation, a 403
290/// response is returned with the denial explanation.
291///
292/// **RBAC enforcement** (when `rbac_manager` is `Some`): after the policy
293/// check passes, the session tier is evaluated. Public sessions allow
294/// anonymous reads but require admin for writes. User/agent sessions
295/// require ownership or admin role. See [`check_rbac`] for details.
296///
297/// Read operations bypass policy checks but still undergo RBAC tier checks.
298pub 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    // ── Phase 1: Policy engine check (write operations only) ──────────
308    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 => { /* continue to RBAC check */ }
344            }
345        }
346    }
347
348    // ── Phase 2: RBAC tier enforcement ────────────────────────────────
349    // Try to get UserContext from request extensions (injected by auth middleware
350    // on /v1/memory/* routes). If not present, attempt to extract directly from
351    // the Authorization header — this enables RBAC on non-auth-protected routes
352    // (e.g., /v1/sessions/:id/files/*) when a Bearer token is provided.
353    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
366/// Attempt to extract UserContext from a Bearer token in the Authorization header.
367///
368/// This enables RBAC enforcement on routes that don't have the auth middleware layer
369/// (everything except /v1/memory/*). Returns None if no token or invalid token.
370fn 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    // Create a synthetic UserContext without resolving the session
377    // (session resolution would require async, which we avoid here)
378    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    // ── Session tier mapping tests ────────────────────────────────────
427
428    #[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        // "vault" without colon should be Default, not User
464        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    // ── RBAC check_rbac tests ─────────────────────────────────────────
470
471    use lago_core::SessionId;
472    use lago_policy::RbacManager;
473    use std::time::Instant;
474    use tokio::sync::RwLock;
475
476    /// Build a minimal AppState with an optional RbacManager for testing.
477    ///
478    /// Uses a unique temp directory per call to avoid redb lock conflicts
479    /// when tests run in parallel.
480    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        // Public session write with no user — should be allowed when RBAC is disabled
519        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        // Default-tier sessions have no RBAC restriction
644        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        // Admin accessing someone else's vault
663        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        // Admin accessing an agent session
685        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}