/* * Author: Ruben Fiszel * Copyright: Windmill Labs, Inc 2022 * This file and its contents are licensed under the AGPLv3 License. * Please see the included NOTICE for copyright information and * LICENSE-AGPL for a copy of the license. */ //! Read side of the metric catalog: what a DuckLake table declares (for the //! script editor drawer) and what is declared under a path prefix (for agents). use axum::{ extract::{Path, Query}, routing::get, Extension, Json, Router, }; use serde::{Deserialize, Serialize}; use windmill_api_auth::{build_scope_path_filter, ApiAuthed, ScopePathFilter}; use windmill_common::{ data_metrics::{canonical_table_path, MetricEntry}, db::UserDB, error::{Error, JsonResult}, }; pub fn workspaced_service() -> Router { Router::new().route("/list", get(list_metrics)) } #[derive(Deserialize)] struct ListQuery { /// `/`, with or without the `ducklake://` scheme. table: Option, /// Path prefix to scope to, e.g. `f/analytics`. path_prefix: Option, /// Results per page, clamped to `MAX_PER_PAGE`. Defaults to `MAX_PER_PAGE`. per_page: Option, /// Keyset cursor: echo the previous response's `next_cursor` fields. All four /// move together (they are the sort key); omit them for the first page. cursor_table: Option, cursor_kind: Option, cursor_name: Option, cursor_script: Option, } /// Position on the (table_path, kind, name, script_path) sort key. Only ever built /// from a row the caller received, so it never reveals a hidden row. #[derive(Serialize)] struct MetricCursor { table_path: String, kind: String, name: String, script_path: String, } /// One page. `next_cursor` present means more rows may follow (pass it back); /// absent means the catalog is exhausted. #[derive(Serialize)] struct MetricPage { metrics: Vec, #[serde(skip_serializing_if = "Option::is_none")] next_cursor: Option, } /// Builds the descendant pattern for a path prefix, anchored to the `/` boundary /// so `f/analytics` does not also match `f/analytics2`. LIKE wildcards in the /// caller's own text are escaped, since an unescaped `%` or `_` would silently /// widen the scope past the path they asked for. fn descendants_of(prefix: &str) -> String { let escaped = prefix .replace('\\', "\\\\") .replace('%', "\\%") .replace('_', "\\_"); format!("{escaped}/%") } /// Largest page a caller may request; the caller pages with the keyset cursor. const MAX_PER_PAGE: i64 = 1000; /// Lists declared measures and dimensions, optionally narrowed to one table or to /// a producing script path prefix. async fn list_metrics( authed: ApiAuthed, Extension(user_db): Extension, Path(w_id): Path, Query(q): Query, ) -> JsonResult { let table = q .table .as_deref() .filter(|t| !t.is_empty()) .map(canonical_table_path); // A prefix matches the path itself and everything beneath it, but not a // sibling folder that merely starts with the same characters. let prefix = q .path_prefix .as_deref() .map(|p| p.trim_end_matches('/')) .filter(|p| !p.is_empty()) .map(str::to_string); let prefix_descendants = prefix.as_deref().map(descendants_of); // The four cursor components are one value; a partial cursor would compare // against a tuple with a NULL (matching nothing) or silently restart. Reject it. let cursor_present = [ &q.cursor_table, &q.cursor_kind, &q.cursor_name, &q.cursor_script, ] .iter() .filter(|c| c.is_some()) .count(); if cursor_present != 0 && cursor_present != 4 { return Err(Error::BadRequest( "cursor_table, cursor_kind, cursor_name and cursor_script must be supplied together" .to_string(), )); } // RLS reflects the user's own permissions but says nothing about a token's path // scopes, so a scoped token must be filtered separately. This route has its own // `data_metrics` scope domain (not an alias of `scripts`, which would let a // metrics token reach every /scripts route). The grant is anchored on the // producing script's path, as with `list_macros`. It is pushed INTO the query, // not applied after the fetch: with scope filtering in SQL every returned row is // authorized, so the keyset cursor (the last returned row) can never name a row // the caller cannot see, and a post-fetch filter would instead let the page size // reveal the count of out-of-scope declarations under a table/prefix. let (scope_all, scope_exact, scope_prefix) = match build_scope_path_filter(&authed, "data_metrics", "read") { ScopePathFilter::AllowAll => (true, Vec::new(), Vec::new()), ScopePathFilter::Restricted { exact, prefix } => (false, exact, prefix), }; let per_page = q.per_page.unwrap_or(MAX_PER_PAGE).clamp(1, MAX_PER_PAGE); let mut tx = user_db.begin(&authed).await?; // Keyset paging: continue strictly after the previous page's last row on the // (table_path, kind, name, script_path) sort key, so total work over the whole // catalog is linear (offset paging re-reads every prior page). One extra row is // fetched to learn whether more remain without a second query. // // The EXISTS runs under `script`'s RLS on this authed connection, so a caller // only sees declarations from producers they can read. `data_metric` itself has // no RLS, so removing this predicate would expose every workspace metric. // Archived/deleted versions are excluded, else a renamed producer keeps serving // its old path's declarations. The token scope filter ($10 allow-all, else the // path is a $11 exact grant or sits at/under a $12 `prefix/*` grant on the `/` // boundary) mirrors `ScopePathFilter::allows`. The ORDER BY is a total order // (script_path breaks ties on table/kind/name), so keyset windows can't skip or // duplicate a row. let mut rows = sqlx::query_as!( MetricEntry, "SELECT script_path, table_path, kind, name, expr, filter \ FROM data_metric dm \ WHERE dm.workspace_id = $1 \ AND ($2::text IS NULL OR dm.table_path = $2) \ AND ($3::text IS NULL OR dm.script_path = $3 OR dm.script_path LIKE $4) \ AND ($6::text IS NULL OR \ (dm.table_path, dm.kind, dm.name, dm.script_path) > ($6, $7, $8, $9)) \ AND ( $10 \ OR dm.script_path = ANY($11) \ OR EXISTS ( SELECT 1 FROM unnest($12::text[]) AS pfx \ WHERE dm.script_path = pfx \ OR left(dm.script_path, length(pfx) + 1) = pfx || '/' ) ) \ AND EXISTS ( \ SELECT 1 FROM script s \ WHERE s.workspace_id = dm.workspace_id AND s.path = dm.script_path \ AND s.archived = false AND s.deleted = false \ ) \ ORDER BY dm.table_path, dm.kind, dm.name, dm.script_path \ LIMIT $5", &w_id, table.as_deref(), prefix.as_deref(), prefix_descendants.as_deref(), per_page + 1, q.cursor_table.as_deref(), q.cursor_kind.as_deref(), q.cursor_name.as_deref(), q.cursor_script.as_deref(), scope_all, &scope_exact[..], &scope_prefix[..], ) .fetch_all(&mut *tx) .await?; tx.commit().await?; // The probe row proves more remain; drop it and hand back its predecessor as the // cursor. Without it the page fit entirely, so there is no next page. let next_cursor = if rows.len() as i64 > per_page { rows.truncate(per_page as usize); rows.last().map(|last| MetricCursor { table_path: last.table_path.clone(), kind: last.kind.clone(), name: last.name.clone(), script_path: last.script_path.clone(), }) } else { None }; Ok(Json(MetricPage { metrics: rows, next_cursor })) }