diff --git a/backend/tests/path_autocomplete_scope.rs b/backend/tests/path_autocomplete_scope.rs new file mode 100644 index 0000000000..a4f9485952 --- /dev/null +++ b/backend/tests/path_autocomplete_scope.rs @@ -0,0 +1,62 @@ +//! `GET /path_autocomplete/list_paths` returns only the paths the caller can read, +//! and its cache never serves one caller's list to another. + +use sqlx::{Pool, Postgres}; +use windmill_test_utils::*; + +async fn list_paths(port: u16, token: &str) -> anyhow::Result> { + let resp = reqwest::Client::new() + .get(format!( + "http://localhost:{port}/api/w/test-workspace/path_autocomplete/list_paths" + )) + .header("Authorization", format!("Bearer {token}")) + .send() + .await?; + assert_eq!(resp.status(), 200, "list_paths: {}", resp.text().await?); + let body: serde_json::Value = resp.json().await?; + Ok(serde_json::from_value(body["paths"].clone())?) +} + +#[sqlx::test(fixtures("base"))] +async fn test_list_paths_is_scoped_to_caller(db: Pool) -> anyhow::Result<()> { + initialize_tracing().await; + + sqlx::query( + "INSERT INTO folder (workspace_id, name, display_name, owners, extra_perms) + VALUES ('test-workspace', 'secret', 'secret', '{}', '{}')", + ) + .execute(&db) + .await?; + for path in [ + "f/secret/hidden", + "u/test-user/private", + "u/test-user-2/mine", + ] { + sqlx::query( + "INSERT INTO variable (workspace_id, path, value, is_secret, description) + VALUES ('test-workspace', $1, 'x', false, '')", + ) + .bind(path) + .execute(&db) + .await?; + } + + let server = ApiServer::start(db.clone()).await?; + let port = server.addr.port(); + + // The admin's call fills the cache first, so a cache shared across callers + // would hand the admin's list to the non-admin. + assert_eq!( + list_paths(port, "SECRET_TOKEN").await?, + [ + "f/secret/hidden", + "u/test-user-2/mine", + "u/test-user/private" + ] + ); + assert_eq!( + list_paths(port, "SECRET_TOKEN_2").await?, + ["u/test-user-2/mine"] + ); + Ok(()) +} diff --git a/backend/windmill-api/openapi.yaml b/backend/windmill-api/openapi.yaml index 85ae9388c8..5f2bd9e66c 100644 --- a/backend/windmill-api/openapi.yaml +++ b/backend/windmill-api/openapi.yaml @@ -13526,7 +13526,7 @@ paths: Returns the flat list of all item paths visible to the caller across scripts, flows, apps, raw apps, variables, and resources. Intended to feed an entirely client-side path autocomplete UI: the frontend fetches - once (server caches per workspace for 60s) and performs all prefix/segment + once (server caches per caller for 60s) and performs all prefix/segment computation locally. Capped at 20,000 paths (5,000 per table). operationId: listPathAutocompletePaths tags: diff --git a/backend/windmill-api/src/path_autocomplete.rs b/backend/windmill-api/src/path_autocomplete.rs index 271e9497fe..fe3dd75ecd 100644 --- a/backend/windmill-api/src/path_autocomplete.rs +++ b/backend/windmill-api/src/path_autocomplete.rs @@ -16,23 +16,55 @@ use axum::{ routing::get, Json, Router, }; +use quick_cache::{sync::Cache, Weighter}; use serde::{Deserialize, Serialize}; -use windmill_common::error::JsonResult; +use windmill_common::{db::UserDB, error::JsonResult}; +use windmill_store::var_resource_cache::auth_identity; -use crate::db::{ApiAuthed, DB}; +use crate::db::ApiAuthed; // Per-table row cap is inlined into the SQL as `LIMIT 5000`. // With 6 tables, the absolute ceiling is ~30k paths pre-dedup. /// Final cap applied after dedup/sort. const MAX_PATHS: usize = 20_000; -/// TTL for the per-workspace path list cache. +/// TTL for the path list cache. const CACHE_TTL: Duration = Duration::from_secs(60); -/// Workspace-wide path list cache keyed by workspace_id only. -/// One entry per workspace shared across all users — autocomplete is a -/// navigation hint, not an access gate. Saves memory and warms faster. -static PATHS_CACHE: LazyLock>, Instant)>> = - LazyLock::new(|| quick_cache::sync::Cache::new(500)); +/// Total paths held across all cache entries. +const CACHE_MAX_PATHS: u64 = 2_000_000; + +type CacheKey = (String, String); +type CacheValue = (Arc>, Instant); + +#[derive(Clone)] +struct PathCountWeighter; + +impl Weighter for PathCountWeighter { + fn weight(&self, _key: &CacheKey, (paths, _): &CacheValue) -> u64 { + (paths.len() as u64).max(1) + } +} + +/// Keyed by (workspace_id, [`auth_identity`]): the list is read under the caller's RLS, +/// so an entry must only ever be served back to the same authorization context. +/// Weighted by path count because the key space grows with callers, not workspaces. +/// Single-sharded: quick_cache splits the weight budget across shards and refuses an +/// entry heavier than one shard's share, which would drop the largest workspaces. +static PATHS_CACHE: LazyLock> = + LazyLock::new(|| { + let options = quick_cache::OptionsBuilder::new() + .shards(1) + .estimated_items_capacity(1000) + .weight_capacity(CACHE_MAX_PATHS) + .build() + .expect("every cache option is set"); + Cache::with_options( + options, + PathCountWeighter, + Default::default(), + Default::default(), + ) + }); pub fn workspaced_service() -> Router { Router::new().route("/list_paths", get(list_paths)) @@ -53,20 +85,22 @@ struct ListPathsQuery { } async fn list_paths( - _authed: ApiAuthed, - Extension(db): Extension, + authed: ApiAuthed, + Extension(user_db): Extension, Path(w_id): Path, Query(ListPathsQuery { force }): Query, ) -> JsonResult { + let cache_key = (w_id, auth_identity(&authed)); if !force { - if let Some((cached, cached_at)) = PATHS_CACHE.get(&w_id) { + if let Some((cached, cached_at)) = PATHS_CACHE.get(&cache_key) { if cached_at.elapsed() < CACHE_TTL { return Ok(Json(ListPathsResponse { paths: cached })); } - PATHS_CACHE.remove(&w_id); + PATHS_CACHE.remove(&cache_key); } } + let mut tx = user_db.begin(&authed).await?; let mut paths: Vec = sqlx::query_scalar!( r#" SELECT path AS "path!" FROM ( @@ -83,16 +117,17 @@ async fn list_paths( (SELECT path FROM resource WHERE workspace_id = $1 LIMIT 5000) ) t "#, - &w_id, + &cache_key.0, ) - .fetch_all(&db) + .fetch_all(&mut *tx) .await?; + tx.commit().await?; paths.sort_unstable(); paths.truncate(MAX_PATHS); let paths = Arc::new(paths); - PATHS_CACHE.insert(w_id, (paths.clone(), Instant::now())); + PATHS_CACHE.insert(cache_key, (paths.clone(), Instant::now())); Ok(Json(ListPathsResponse { paths })) }