mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-10-03 16:02:12 +00:00
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:
co-authored by
Claude Opus 5.5
parent
ec6ec1b06f
commit
f4dcaf3e45
@@ -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(())
|
||||
}
|
||||
@@ -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:
|
||||
|
||||
@@ -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 }))
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user