fix: list only the paths the caller can read in path autocomplete (#11388)

* fix: list only the paths the caller can read in path autocomplete

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* fix: bound the path autocomplete cache by total path count

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Ruben Fiszel
2026-09-28 17:55:16 +02:00
committed by GitHub
co-authored by Claude Opus 5.5
parent ec6ec1b06f
commit f4dcaf3e45
3 changed files with 113 additions and 16 deletions
+62
View File
@@ -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<Vec<String>> {
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<Postgres>) -> 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(())
}
+1 -1
View File
@@ -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:
+50 -15
View File
@@ -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<quick_cache::sync::Cache<String, (Arc<Vec<String>>, 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<Vec<String>>, Instant);
#[derive(Clone)]
struct PathCountWeighter;
impl Weighter<CacheKey, CacheValue> 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<Cache<CacheKey, CacheValue, PathCountWeighter>> =
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<DB>,
authed: ApiAuthed,
Extension(user_db): Extension<UserDB>,
Path(w_id): Path<String>,
Query(ListPathsQuery { force }): Query<ListPathsQuery>,
) -> JsonResult<ListPathsResponse> {
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<String> = 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 }))
}