mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-10-13 00:03:00 +00:00
feat: connect to a data table with every role of the job's viewer
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
9fa9b77926
commit
87c87facd2
@@ -33,6 +33,9 @@ Open-source platform for internal tools, workflows, API integrations, background
|
||||
administers behind `external_instance` data tables and Ducklake catalogs: its invariants (one
|
||||
lifecycle lock, managed-object markers, the setup gate, per-cluster roles, fork copy ownership)
|
||||
and how to run one locally
|
||||
- **Data table connections as the viewer**: `docs/datatable-viewer-roles.md`, on `?as=viewer`: where
|
||||
the viewer comes from, why an admin's roles are not the viewer's, the per-role-set logins and
|
||||
the order their rows and logins are written in
|
||||
- **Enterprise**: `docs/enterprise.md` — EE file conventions and PR workflow
|
||||
- **Operator write rights**: `docs/operator-write-rights.md` — which `operator_settings` flags are
|
||||
enforced rather than cosmetic, and why a right that is granted-unless-withdrawn needs `Option`
|
||||
|
||||
@@ -1 +1 @@
|
||||
06610ecc950ed64a2709d7f1aa8c7b49a95fdbae
|
||||
06083a1d8060b2eb92df954c0aef4a9e7d857e28
|
||||
@@ -0,0 +1 @@
|
||||
DROP TABLE IF EXISTS datatable_role_set;
|
||||
@@ -0,0 +1,18 @@
|
||||
-- The logins behind a data table connection made as several roles at once: one Postgres login per
|
||||
-- distinct set of data table roles, a member of each. Which roles a login holds is its
|
||||
-- memberships on the cluster, and its name is derived from their catalog ids, so a row only has to
|
||||
-- carry the credential.
|
||||
--
|
||||
-- A row is written after its login exists and removed before the login is dropped, so a row always
|
||||
-- names a login that can authenticate with `pwd`.
|
||||
CREATE TABLE datatable_role_set (
|
||||
cluster VARCHAR(20) NOT NULL CHECK (cluster IN ('instance', 'external_instance')),
|
||||
name VARCHAR(63) NOT NULL,
|
||||
-- Generated by Windmill, never entered by anyone, and never leaves the server.
|
||||
pwd TEXT NOT NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
PRIMARY KEY (cluster, name)
|
||||
);
|
||||
|
||||
GRANT ALL ON datatable_role_set TO windmill_user;
|
||||
GRANT ALL ON datatable_role_set TO windmill_admin;
|
||||
@@ -1847,3 +1847,142 @@ async fn cleanup_waits_out_a_save_racing_it_for_an_instance_database(
|
||||
assert_eq!(users, vec!["test-workspace".to_string()]);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(all(feature = "private", feature = "enterprise"))]
|
||||
#[sqlx::test(migrations = "../migrations", fixtures("base", "datatable_roles"))]
|
||||
async fn connecting_as_the_viewer_takes_the_roles_that_name_them_and_no_other(
|
||||
db: Pool<Postgres>,
|
||||
) -> anyhow::Result<()> {
|
||||
use windmill_common::workspaces::{
|
||||
get_datatable_resource_from_db, DatatableAccess, DATATABLE_VIEWER_ROLE,
|
||||
};
|
||||
|
||||
initialize_tracing().await;
|
||||
let server = ApiServer::start(db.clone()).await?;
|
||||
let port = server.addr.port();
|
||||
let suffix: String = uuid::Uuid::new_v4().simple().to_string()[..8].to_string();
|
||||
let names = [
|
||||
format!("wmtest_emea_{suffix}"),
|
||||
format!("wmtest_amer_{suffix}"),
|
||||
];
|
||||
let roles_url = format!("http://localhost:{port}/api/settings/datatable_roles");
|
||||
const MEMBER_LOGINS: &str = "SELECT m.rolname::text FROM pg_auth_members am
|
||||
JOIN pg_roles r ON r.oid = am.roleid JOIN pg_roles m ON m.oid = am.member
|
||||
WHERE r.rolname = $1 AND m.rolname LIKE 'wm\\_set\\_%'";
|
||||
|
||||
// The job is the app publisher's in every case: only the viewer the server recorded differs.
|
||||
let as_viewer = |viewer: Option<&'static str>| {
|
||||
get_datatable_resource_from_db(
|
||||
&db,
|
||||
"test-workspace",
|
||||
"main",
|
||||
Some(DATATABLE_VIEWER_ROLE),
|
||||
DatatableAccess::PermissionedAs {
|
||||
permissioned_as: "u/test-user",
|
||||
email: "test@windmill.dev",
|
||||
end_user_email: viewer,
|
||||
},
|
||||
)
|
||||
};
|
||||
|
||||
let outcome = async {
|
||||
let mut ids = vec![];
|
||||
for name in &names {
|
||||
let created: Value = authed(client().post(&roles_url), "SECRET_TOKEN")
|
||||
.json(&json!({ "name": name }))
|
||||
.send()
|
||||
.await?
|
||||
.error_for_status()?
|
||||
.json()
|
||||
.await?;
|
||||
ids.push(created["id"].as_str().unwrap().to_string());
|
||||
}
|
||||
// `test-user-2` is named by both roles, one of them through a group, and by `admin`;
|
||||
// `test-user-3` by one; the workspace admin `test-user` by none.
|
||||
sqlx::query(
|
||||
"UPDATE workspace_settings
|
||||
SET datatable = jsonb_set(datatable, '{datatables,main,permissions,roles}', $1)
|
||||
WHERE workspace_id = 'test-workspace'",
|
||||
)
|
||||
.bind(json!({
|
||||
"admin": {"tenants": ["u/test-user-2"]},
|
||||
(ids[0].as_str()): {"tenants": ["u/test-user-2", "u/test-user-3"]},
|
||||
(ids[1].as_str()): {"tenants": ["g/analysts"]},
|
||||
}))
|
||||
.execute(&db)
|
||||
.await?;
|
||||
sqlx::query(
|
||||
"INSERT INTO usr_to_group (workspace_id, usr, group_)
|
||||
VALUES ('test-workspace', 'test-user-2', 'analysts')",
|
||||
)
|
||||
.execute(&db)
|
||||
.await?;
|
||||
|
||||
let both = as_viewer(Some("test2@windmill.dev")).await?;
|
||||
let login = both["user"].as_str().unwrap().to_string();
|
||||
assert!(login.starts_with("wm_set_"), "{login}");
|
||||
let mut member_of: Vec<String> = sqlx::query_scalar(
|
||||
"SELECT r.rolname::text FROM pg_auth_members am
|
||||
JOIN pg_roles r ON r.oid = am.roleid JOIN pg_roles m ON m.oid = am.member
|
||||
WHERE m.rolname = $1",
|
||||
)
|
||||
.bind(&login)
|
||||
.fetch_all(&db)
|
||||
.await?;
|
||||
member_of.sort();
|
||||
let mut expected = names.to_vec();
|
||||
expected.sort();
|
||||
assert_eq!(
|
||||
member_of, expected,
|
||||
"the login holds exactly the viewer's roles"
|
||||
);
|
||||
assert_eq!(as_viewer(Some("test2@windmill.dev")).await?, both);
|
||||
|
||||
let one = as_viewer(Some("test3@windmill.dev")).await?;
|
||||
assert_eq!(one["user"], names[0].as_str(), "one role is its own login");
|
||||
|
||||
// An admin may use every role, the one owning the tables included. As a viewer they get
|
||||
// what names them, which here is nothing.
|
||||
assert!(as_viewer(Some("test@windmill.dev")).await.is_err());
|
||||
// No recorded viewer must not fall back to the job's owner.
|
||||
assert!(as_viewer(None).await.is_err());
|
||||
|
||||
// A disabled role must not stay reachable through a login that is a member of it.
|
||||
authed(
|
||||
client().post(format!("{roles_url}/{}", ids[1])),
|
||||
"SECRET_TOKEN",
|
||||
)
|
||||
.json(&json!({ "enabled": false }))
|
||||
.send()
|
||||
.await?
|
||||
.error_for_status()?;
|
||||
let can_login: bool =
|
||||
sqlx::query_scalar("SELECT rolcanlogin FROM pg_roles WHERE rolname = $1")
|
||||
.bind(&login)
|
||||
.fetch_one(&db)
|
||||
.await?;
|
||||
assert!(!can_login, "{login} still logs in with a disabled role");
|
||||
let remaining = as_viewer(Some("test2@windmill.dev")).await?;
|
||||
assert_eq!(remaining["user"], names[0].as_str());
|
||||
Ok::<_, anyhow::Error>(())
|
||||
}
|
||||
.await;
|
||||
|
||||
// Roles are cluster-wide, so they outlive this test's throwaway database.
|
||||
for name in &names {
|
||||
let logins: Vec<String> = sqlx::query_scalar(MEMBER_LOGINS)
|
||||
.bind(name)
|
||||
.fetch_all(&db)
|
||||
.await
|
||||
.unwrap_or_default();
|
||||
for login in logins {
|
||||
let _ = sqlx::query(&format!("DROP ROLE IF EXISTS \"{login}\""))
|
||||
.execute(&db)
|
||||
.await;
|
||||
}
|
||||
let _ = sqlx::query(&format!("DROP ROLE IF EXISTS \"{name}\""))
|
||||
.execute(&db)
|
||||
.await;
|
||||
}
|
||||
outcome
|
||||
}
|
||||
|
||||
@@ -8982,6 +8982,7 @@ async fn run_preview_script(
|
||||
check_tag_available_for_workspace(&db, &w_id, &tag, &push_args, &authed).await?;
|
||||
|
||||
let scope_ceiling = windmill_api_auth::caller_scope_ceiling(&db, &authed).await?;
|
||||
let end_user_email = run_end_user_email(&db, &w_id, &authed, None).await?;
|
||||
let (uuid, tx) = push(
|
||||
&db,
|
||||
tx,
|
||||
@@ -9029,7 +9030,7 @@ async fn run_preview_script(
|
||||
None,
|
||||
Some(&authed.clone().into()),
|
||||
false,
|
||||
None,
|
||||
end_user_email,
|
||||
authed.trigger_or_fallback(None),
|
||||
None,
|
||||
scope_ceiling.as_deref(),
|
||||
@@ -9068,6 +9069,7 @@ async fn run_inline_preview_script(
|
||||
if let Some(job_id) = job_id {
|
||||
register_potential_assets_on_inline_execution(job_id, &w_id, &preview);
|
||||
}
|
||||
let end_user_email = run_end_user_email(&db, &w_id, &authed, None).await?;
|
||||
let utils = get_worker_internal_server_inline_utils()?;
|
||||
let result = utils.run_inline_preview_script.as_ref()(RunInlinePreviewScriptFnParams {
|
||||
content: preview.content,
|
||||
@@ -9078,6 +9080,7 @@ async fn run_inline_preview_script(
|
||||
created_by: authed.display_username().to_string(),
|
||||
permissioned_as: username_to_permissioned_as(&authed.username),
|
||||
permissioned_as_email: authed.email.clone(),
|
||||
end_user_email,
|
||||
lang: preview.language,
|
||||
job_dir: "".to_string(),
|
||||
worker_name: "".to_string(),
|
||||
@@ -9217,6 +9220,7 @@ async fn run_inline_script_inner(
|
||||
.to_string(),
|
||||
));
|
||||
}
|
||||
let end_user_email = run_end_user_email(&db, &w_id, &authed, None).await?;
|
||||
let utils = get_worker_internal_server_inline_utils()?;
|
||||
let authed_owned: windmill_common::db::Authed = authed.clone().into();
|
||||
let result = utils.run_inline_script.as_ref()(RunInlineScriptFnParams {
|
||||
@@ -9228,6 +9232,7 @@ async fn run_inline_script_inner(
|
||||
created_by: authed.display_username().to_string(),
|
||||
permissioned_as: username_to_permissioned_as(&authed.username),
|
||||
permissioned_as_email: authed.email.clone(),
|
||||
end_user_email,
|
||||
job_dir: "".to_string(),
|
||||
worker_name: "".to_string(),
|
||||
worker_dir: "".to_string(),
|
||||
@@ -9404,6 +9409,7 @@ async fn run_bundle_preview_script(
|
||||
// tracing::info!("is_tar 1: {is_tar}");
|
||||
// hmap.insert("")
|
||||
let scope_ceiling = windmill_api_auth::caller_scope_ceiling(&db, &authed).await?;
|
||||
let end_user_email = run_end_user_email(&db, &w_id, &authed, None).await?;
|
||||
let (uuid, ntx) = push(
|
||||
&db,
|
||||
ltx,
|
||||
@@ -9447,7 +9453,7 @@ async fn run_bundle_preview_script(
|
||||
None,
|
||||
Some(&authed.clone().into()),
|
||||
false,
|
||||
None,
|
||||
end_user_email,
|
||||
authed.trigger_or_fallback(None),
|
||||
None,
|
||||
scope_ceiling.as_deref(),
|
||||
@@ -10151,6 +10157,7 @@ async fn run_preview_flow_job(
|
||||
.await?;
|
||||
|
||||
let scope_ceiling = windmill_api_auth::caller_scope_ceiling(&db, &authed).await?;
|
||||
let end_user_email = run_end_user_email(&db, &w_id, &authed, None).await?;
|
||||
let (uuid, mut tx) = push(
|
||||
&db,
|
||||
tx,
|
||||
@@ -10182,7 +10189,7 @@ async fn run_preview_flow_job(
|
||||
None,
|
||||
Some(&authed.clone().into()),
|
||||
false,
|
||||
None,
|
||||
end_user_email,
|
||||
authed.trigger_or_fallback(None),
|
||||
None,
|
||||
scope_ceiling.as_deref(),
|
||||
|
||||
@@ -121,8 +121,13 @@ fn is_reserved_role_name(name: &str) -> bool {
|
||||
|| lower.starts_with("pg_")
|
||||
|| lower.starts_with("windmill_")
|
||||
|| lower.starts_with("custom_instance_")
|
||||
|| lower.starts_with(ROLE_SET_LOGIN_PREFIX)
|
||||
}
|
||||
|
||||
/// Prefix of the logins Windmill creates for a connection made as several roles at once. Reserved,
|
||||
/// so no data table role can be created under, renamed to or taken over at such a name.
|
||||
pub const ROLE_SET_LOGIN_PREFIX: &str = "wm_set_";
|
||||
|
||||
/// The charset is what makes every downstream interpolation safe: the name reaches Postgres as a
|
||||
/// quoted identifier, a `-- role <name>` annotation, and a `?role=` query parameter.
|
||||
pub fn validate_role_name(name: &str) -> Result<()> {
|
||||
|
||||
@@ -474,6 +474,8 @@ pub struct RunInlinePreviewScriptFnParams {
|
||||
pub created_by: String,
|
||||
pub permissioned_as: String,
|
||||
pub permissioned_as_email: String,
|
||||
/// The caller's recorded end user: `job_perms.end_user_email` of the calling job.
|
||||
pub end_user_email: Option<String>,
|
||||
pub base_internal_url: String,
|
||||
pub worker_name: String,
|
||||
pub conn: crate::worker::Connection,
|
||||
@@ -496,6 +498,8 @@ pub struct RunInlineScriptFnParams {
|
||||
pub created_by: String,
|
||||
pub permissioned_as: String,
|
||||
pub permissioned_as_email: String,
|
||||
/// The caller's recorded end user: `job_perms.end_user_email` of the calling job.
|
||||
pub end_user_email: Option<String>,
|
||||
pub base_internal_url: String,
|
||||
pub worker_name: String,
|
||||
pub conn: crate::worker::Connection,
|
||||
|
||||
@@ -1589,6 +1589,12 @@ pub struct DataTableRoleTenants {
|
||||
/// Every member of the governing workspace.
|
||||
pub const DATATABLE_TENANT_WILDCARD: &str = "*";
|
||||
|
||||
/// Stands in the `role` of a resolution for "every role the job's viewer is a tenant of", which
|
||||
/// callers write as `?as=viewer`. `$` is outside the role-name charset, so no role can be named
|
||||
/// this, and a server predating it refuses it as an unknown role rather than resolving the
|
||||
/// default one.
|
||||
pub const DATATABLE_VIEWER_ROLE: &str = "$viewer";
|
||||
|
||||
/// How deep a chain of pointer entries may go before it is called a loop. Data tables are not
|
||||
/// expected to chain at all — a fork points at its parent — so this only has to be generous
|
||||
/// enough to survive a fork of a fork.
|
||||
@@ -2161,9 +2167,13 @@ pub enum DatatableAccess<'a> {
|
||||
PermissionedAs {
|
||||
permissioned_as: &'a str,
|
||||
email: &'a str,
|
||||
/// The job's viewer: MUST be what the server recorded in `job_perms.end_user_email`,
|
||||
/// never an argument or anything else the job or its caller can set.
|
||||
end_user_email: Option<&'a str>,
|
||||
},
|
||||
/// A job identified by id; its owner is read from `v2_job`. For agent workers and anything
|
||||
/// else that authenticates as infrastructure rather than as the job's user.
|
||||
/// A job identified by id; its owner is read from `v2_job` and its viewer from `job_perms`.
|
||||
/// For agent workers and anything else that authenticates as infrastructure rather than as
|
||||
/// the job's user.
|
||||
Job(uuid::Uuid),
|
||||
/// No identity established. Unpermissioned data tables resolve as before; permissioned ones
|
||||
/// are refused, so a caller predating this feature fails closed.
|
||||
@@ -2211,6 +2221,12 @@ pub async fn can_use_datatable_role_in_governing_workspace(
|
||||
///
|
||||
/// The resolved role **logs in as itself**. Never `SET ROLE`: a script could `RESET ROLE` its way
|
||||
/// back to admin.
|
||||
///
|
||||
/// [`DATATABLE_VIEWER_ROLE`] resolves for the job's viewer rather than its owner: the roles whose
|
||||
/// tenant lists name the viewer, one login holding all of them. Being an admin adds none, and
|
||||
/// `admin` is never among them, so an app's viewer cannot be handed the connection that owns the
|
||||
/// tables and bypasses row-level security. A viewer with no role, or a job with none recorded, is
|
||||
/// refused.
|
||||
pub async fn get_datatable_resource_from_db(
|
||||
db: &DB,
|
||||
w_id: &str,
|
||||
@@ -2478,22 +2494,36 @@ pub async fn parse_datatable_ref_for(
|
||||
/// resolve the reference to the data table's default role, so `?Role=analytics` or a mistyped
|
||||
/// `?role=` would quietly connect as something the caller did not ask for — the same trap as a
|
||||
/// malformed `-- role` annotation, and `role` is the only parameter a reference takes.
|
||||
///
|
||||
/// `?as=viewer` asks for every role of the job's viewer instead of one by name, and comes back as
|
||||
/// [`DATATABLE_VIEWER_ROLE`].
|
||||
pub fn parse_datatable_ref(reference: &str) -> Result<(&str, Option<&str>)> {
|
||||
let (name, query) = reference.split_once('?').unwrap_or((reference, ""));
|
||||
let mut role = None;
|
||||
for param in query.split('&').filter(|p| !p.is_empty()) {
|
||||
let (key, value) = param.split_once('=').unwrap_or((param, ""));
|
||||
if !key.eq_ignore_ascii_case("role") {
|
||||
let as_viewer = key.eq_ignore_ascii_case("as");
|
||||
if !as_viewer && !key.eq_ignore_ascii_case("role") {
|
||||
return Err(Error::BadRequest(format!(
|
||||
"Data table reference '{name}' carries an unknown parameter '{key}'. \
|
||||
The only one it takes is `?role=<name>`."
|
||||
It takes `?role=<name>` or `?as=viewer`."
|
||||
)));
|
||||
}
|
||||
if role.is_some() {
|
||||
return Err(Error::BadRequest(format!(
|
||||
"Data table reference '{name}' names a role more than once."
|
||||
"Data table reference '{name}' takes one `role` or `as=viewer`, not several."
|
||||
)));
|
||||
}
|
||||
if as_viewer {
|
||||
if value != "viewer" {
|
||||
return Err(Error::BadRequest(format!(
|
||||
"Data table reference '{name}' has a malformed `as` parameter '{value}'. \
|
||||
Write it as `?as=viewer`."
|
||||
)));
|
||||
}
|
||||
role = Some(DATATABLE_VIEWER_ROLE);
|
||||
continue;
|
||||
}
|
||||
if value.is_empty() || !is_datatable_role_name(value) {
|
||||
return Err(Error::BadRequest(format!(
|
||||
"Data table reference '{name}' has a malformed role '{value}'. Write it as \
|
||||
@@ -3821,13 +3851,22 @@ mod tests {
|
||||
("sales", Some("analytics"))
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
parse_datatable_ref("sales?as=viewer").unwrap(),
|
||||
("sales", Some(DATATABLE_VIEWER_ROLE))
|
||||
);
|
||||
|
||||
// A query string that does not parse is refused rather than read as "no role": resolving
|
||||
// it to the data table's default would connect as a login the caller never asked for.
|
||||
// The viewer cannot be asked for through `role`, nor together with one.
|
||||
for malformed in [
|
||||
"sales?role=",
|
||||
"sales?role=an;alytics",
|
||||
"sales?x=1&role=analytics",
|
||||
"sales?role=a&role=b",
|
||||
"sales?as=owner",
|
||||
"sales?as=viewer&role=analytics",
|
||||
"sales?role=$viewer",
|
||||
] {
|
||||
assert!(
|
||||
parse_datatable_ref(malformed).is_err(),
|
||||
|
||||
@@ -2758,6 +2758,7 @@ async fn transform_attach_datatable(
|
||||
DatatableAccess::PermissionedAs {
|
||||
permissioned_as: &job.permissioned_as,
|
||||
email: &job.permissioned_as_email,
|
||||
end_user_email: job.permissioned_as_end_user_email.as_deref(),
|
||||
},
|
||||
)
|
||||
.await?
|
||||
|
||||
@@ -27,6 +27,7 @@ use windmill_common::worker::{
|
||||
};
|
||||
use windmill_common::workspaces::{
|
||||
get_datatable_resource_from_db, parse_datatable_ref, parse_datatable_ref_for, DatatableAccess,
|
||||
DATATABLE_VIEWER_ROLE,
|
||||
};
|
||||
use windmill_common::{PgDatabase, PrepareQueryColumnInfo, PrepareQueryResult, DB};
|
||||
use windmill_parser::{Arg, Typ};
|
||||
@@ -1171,6 +1172,23 @@ fn do_postgresql_inner<'a>(
|
||||
Ok(result_f.boxed())
|
||||
}
|
||||
|
||||
/// The annotation wins over a `?role=` in the reference, but naming a role while the reference
|
||||
/// asks for the viewer's is refused: either reading would connect as something the other half of
|
||||
/// the request did not ask for.
|
||||
fn requested_datatable_role<'a>(
|
||||
annotated: Option<&'a str>,
|
||||
uri_role: Option<&'a str>,
|
||||
) -> error::Result<Option<&'a str>> {
|
||||
if annotated.is_some() && uri_role == Some(DATATABLE_VIEWER_ROLE) {
|
||||
return Err(Error::BadRequest(
|
||||
"The query names a data table role (`-- role`) while its data table reference asks \
|
||||
for the viewer's roles (`?as=viewer`). Use one or the other."
|
||||
.to_string(),
|
||||
));
|
||||
}
|
||||
Ok(annotated.or(uri_role))
|
||||
}
|
||||
|
||||
pub async fn do_postgresql(
|
||||
job: &MiniPulledJob,
|
||||
client: &AuthedClient,
|
||||
@@ -1213,7 +1231,7 @@ pub async fn do_postgresql(
|
||||
client,
|
||||
name,
|
||||
&job.workspace_id,
|
||||
annotated.as_deref().or(uri_role),
|
||||
requested_datatable_role(annotated.as_deref(), uri_role)?,
|
||||
&job.id,
|
||||
)
|
||||
.await?
|
||||
@@ -1225,10 +1243,11 @@ pub async fn do_postgresql(
|
||||
db,
|
||||
&job.workspace_id,
|
||||
&name,
|
||||
annotated.as_deref().or(uri_role.as_deref()),
|
||||
requested_datatable_role(annotated.as_deref(), uri_role.as_deref())?,
|
||||
DatatableAccess::PermissionedAs {
|
||||
permissioned_as: &job.permissioned_as,
|
||||
email: &job.permissioned_as_email,
|
||||
end_user_email: job.permissioned_as_end_user_email.as_deref(),
|
||||
},
|
||||
)
|
||||
.await?
|
||||
|
||||
@@ -7253,7 +7253,7 @@ pub fn init_worker_internal_server_inline_utils(
|
||||
base_internal_url,
|
||||
killpill_rx: Arc::new(killpill_rx),
|
||||
run_inline_preview_script: Arc::new(|params| {
|
||||
let job = MiniPulledJob::new_inline(
|
||||
let mut job = MiniPulledJob::new_inline(
|
||||
params.workspace_id,
|
||||
params.args,
|
||||
params.created_by,
|
||||
@@ -7265,6 +7265,7 @@ pub fn init_worker_internal_server_inline_utils(
|
||||
"inline_preview".to_string(),
|
||||
Some(params.lang),
|
||||
);
|
||||
job.permissioned_as_end_user_email = params.end_user_email;
|
||||
Box::pin(async move {
|
||||
let mut mem_peak: i32 = -1;
|
||||
let mut canceled_by: Option<CanceledBy> = None;
|
||||
@@ -7314,7 +7315,7 @@ pub fn init_worker_internal_server_inline_utils(
|
||||
let content_info =
|
||||
get_script_content_by_hash(&script_hash, ¶ms.workspace_id, ¶ms.conn)
|
||||
.await?;
|
||||
let job = MiniPulledJob::new_inline(
|
||||
let mut job = MiniPulledJob::new_inline(
|
||||
params.workspace_id,
|
||||
params.args,
|
||||
params.created_by,
|
||||
@@ -7326,6 +7327,7 @@ pub fn init_worker_internal_server_inline_utils(
|
||||
"inline_run".to_string(),
|
||||
content_info.language,
|
||||
);
|
||||
job.permissioned_as_end_user_email = params.end_user_email;
|
||||
let mut mem_peak: i32 = -1;
|
||||
let mut canceled_by: Option<CanceledBy> = None;
|
||||
let mut column_order: Option<Vec<String>> = None;
|
||||
|
||||
Generated
+21
-2
@@ -1220,6 +1220,8 @@ parseS3Object(s3Object: S3Object): S3ObjectRecord
|
||||
* @param name - Database/datatable name (default: "main")
|
||||
* @param opts.role - Connect as this data table role instead of the data table's default one.
|
||||
* Only meaningful on a data table under roles, and only for a role you are a tenant of.
|
||||
* @param opts.as - \`"viewer"\` connects with every role of the job's viewer at once, so row-level
|
||||
* security policies see the user of the app rather than its publisher.
|
||||
* @returns SQL template function for building parameterized queries
|
||||
* @example
|
||||
* let sql = wmill.datatable()
|
||||
@@ -1232,6 +1234,9 @@ parseS3Object(s3Object: S3Object): S3ObjectRecord
|
||||
* @example
|
||||
* // Read through a restricted role
|
||||
* let sql = wmill.datatable("main", { role: "analytics" })
|
||||
* @example
|
||||
* // In an app's backend runnable: read what the app's viewer may read
|
||||
* let sql = wmill.datatable("main", { as: "viewer" })
|
||||
*/
|
||||
datatable(name: string = "main", opts?: DatatableOptions): DatatableSqlTemplateFunction
|
||||
|
||||
@@ -2063,6 +2068,8 @@ parseS3Object(s3Object: S3Object): S3ObjectRecord
|
||||
* @param name - Database/datatable name (default: "main")
|
||||
* @param opts.role - Connect as this data table role instead of the data table's default one.
|
||||
* Only meaningful on a data table under roles, and only for a role you are a tenant of.
|
||||
* @param opts.as - \`"viewer"\` connects with every role of the job's viewer at once, so row-level
|
||||
* security policies see the user of the app rather than its publisher.
|
||||
* @returns SQL template function for building parameterized queries
|
||||
* @example
|
||||
* let sql = wmill.datatable()
|
||||
@@ -2075,6 +2082,9 @@ parseS3Object(s3Object: S3Object): S3ObjectRecord
|
||||
* @example
|
||||
* // Read through a restricted role
|
||||
* let sql = wmill.datatable("main", { role: "analytics" })
|
||||
* @example
|
||||
* // In an app's backend runnable: read what the app's viewer may read
|
||||
* let sql = wmill.datatable("main", { as: "viewer" })
|
||||
*/
|
||||
datatable(name: string = "main", opts?: DatatableOptions): DatatableSqlTemplateFunction
|
||||
|
||||
@@ -3012,6 +3022,8 @@ parseS3Object(s3Object: S3Object): S3ObjectRecord
|
||||
* @param name - Database/datatable name (default: "main")
|
||||
* @param opts.role - Connect as this data table role instead of the data table's default one.
|
||||
* Only meaningful on a data table under roles, and only for a role you are a tenant of.
|
||||
* @param opts.as - \`"viewer"\` connects with every role of the job's viewer at once, so row-level
|
||||
* security policies see the user of the app rather than its publisher.
|
||||
* @returns SQL template function for building parameterized queries
|
||||
* @example
|
||||
* let sql = wmill.datatable()
|
||||
@@ -3024,6 +3036,9 @@ parseS3Object(s3Object: S3Object): S3ObjectRecord
|
||||
* @example
|
||||
* // Read through a restricted role
|
||||
* let sql = wmill.datatable("main", { role: "analytics" })
|
||||
* @example
|
||||
* // In an app's backend runnable: read what the app's viewer may read
|
||||
* let sql = wmill.datatable("main", { as: "viewer" })
|
||||
*/
|
||||
datatable(name: string = "main", opts?: DatatableOptions): DatatableSqlTemplateFunction
|
||||
|
||||
@@ -4892,10 +4907,13 @@ def send_teams_message(conversation_id: str, text: str, success: bool = True, ca
|
||||
# role: Connect as this data table role instead of the data table's default one.
|
||||
# Only meaningful on a data table under roles, and only for a role you are a
|
||||
# tenant of.
|
||||
# as_viewer: Connect with every role of the job's viewer at once: the user of the
|
||||
# app the job runs for, or whoever ran the script. The server resolves who that
|
||||
# is. Cannot be combined with role.
|
||||
#
|
||||
# Returns:
|
||||
# DataTableClient instance
|
||||
def datatable(name: str = 'main', *, role: Optional[str] = None)
|
||||
def datatable(name: str = 'main', *, role: Optional[str] = None, as_viewer: bool = False)
|
||||
|
||||
# Get a DuckLake client for DuckDB queries.
|
||||
#
|
||||
@@ -6522,7 +6540,7 @@ data:
|
||||
- \`<datatable>/<table>\` — Specific table in public schema
|
||||
- \`<datatable>/<schema>:<table>\` — Table in specific schema
|
||||
|
||||
**Roles:** when a datatable is under roles, its queries run as a role, which only reaches what it was granted. \`roles\` records the role the app uses each datatable through; the app's code must pass the same role: \`wmill.datatable('main', { role: 'analyst' })\` in TypeScript, \`wmill.datatable('main', role='analyst')\` in Python. A datatable without an entry is used as its default role.
|
||||
**Roles:** when a datatable is under roles, its queries run as a role, which only reaches what it was granted. \`roles\` records the role the app uses each datatable through; the app's code must pass the same role: \`wmill.datatable('main', { role: 'analyst' })\` in TypeScript, \`wmill.datatable('main', role='analyst')\` in Python. A datatable without an entry is used as its default role. To use a datatable with the roles of whoever is viewing the app rather than one fixed role, pass \`{ as: 'viewer' }\` (\`as_viewer=True\` in Python) instead of a role.
|
||||
|
||||
## SQL Migrations (sql_to_apply/)
|
||||
|
||||
@@ -6816,6 +6834,7 @@ Data tables are PostgreSQL databases managed by Windmill. Backend runnables quer
|
||||
3. **No DDL inside runnables**: runnables only read and write rows (SELECT, INSERT, UPDATE, DELETE) on existing tables. Never CREATE, ALTER or DROP a table from a runnable.
|
||||
4. **Qualify table names**: an unqualified name means the \`public\` schema, so write every other table as \`schema.table\`, in table creation and queries alike. The app's \`data\` config sets the default datatable and schema its tables go in; use them consistently across runnables.
|
||||
5. **Pass the role**: when the app's \`data.roles\` gives a data table a role, every \`wmill.datatable\` call on it passes that role (\`wmill.datatable('main', { role: 'analyst' })\` in TypeScript, \`wmill.datatable('main', role='analyst')\` in Python). The role only reaches what it was granted, so a query outside it fails with \`permission denied\`.
|
||||
6. **Per-viewer rows**: a runnable runs as the app's publisher, whoever is using the app. To have a data table under roles answer for the viewer instead, connect as the viewer: \`wmill.datatable('main', { as: 'viewer' })\` in TypeScript, \`wmill.datatable('main', as_viewer=True)\` in Python, \`datatable://main?as=viewer\` as a SQL runnable's database or in a DuckDB \`ATTACH\`. The connection then holds every role the viewer is a tenant of, so row-level security policies written as \`pg_has_role(current_user, '<role>', 'member')\` return that viewer's rows. The server works out who the viewer is; a viewer with no role on the data table, or an anonymous one, is refused. Never combine it with \`role\`.
|
||||
|
||||
\`wmill datatable list\` lists the workspace's data tables. Create or change tables with a migration in \`sql_to_apply/\` (see "SQL Migrations" above), then add them to \`data.tables\` in \`raw_app.yaml\`.
|
||||
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
# Data table connections as the viewer
|
||||
|
||||
A data table under roles (Enterprise Edition) normally resolves to one role: the one a caller
|
||||
names, or the data table's default. `?as=viewer` resolves to **every role the job's viewer is a
|
||||
tenant of**, so Postgres row-level security can answer per viewer from a backend runnable that
|
||||
runs as the app's publisher.
|
||||
|
||||
```ts
|
||||
wmill.datatable('main', { as: 'viewer' }) // TypeScript
|
||||
wmill.datatable('main', as_viewer=True) # Python
|
||||
ATTACH 'datatable://main?as=viewer' AS dt; -- DuckDB
|
||||
-- PostgreSQL script: `datatable://main?as=viewer` as its `database` argument
|
||||
```
|
||||
|
||||
Policies test membership, which is true for a role's own login too, so one policy serves both
|
||||
kinds of connection:
|
||||
|
||||
```sql
|
||||
CREATE POLICY emea ON orders FOR SELECT
|
||||
USING (region = 'emea' AND pg_has_role(current_user, 'sales_emea', 'member'));
|
||||
```
|
||||
|
||||
A policy names its role as text, so renaming the role breaks it.
|
||||
|
||||
## Code
|
||||
|
||||
| Where | What |
|
||||
|---|---|
|
||||
| `windmill-common/src/workspaces.rs` | `DATATABLE_VIEWER_ROLE`, `parse_datatable_ref`, `DatatableAccess` |
|
||||
| `windmill-common/src/datatable_roles_ee.rs` | `resolve_viewer_login`, `ensure_role_set_login`, `retire_role_set_logins_of` |
|
||||
| `windmill-api-jobs/src/execution.rs` | `run_end_user_email`, where a run's viewer is recorded |
|
||||
|
||||
## What holds it together
|
||||
|
||||
- **The viewer is `job_perms.end_user_email`, and nothing else.** The server records it when the
|
||||
run starts: the session behind an app request, the caller of a direct run or preview, and for a
|
||||
job started with another job's token, that job's viewer. No argument reaches it. A job without
|
||||
one (anonymous app viewer, schedule, trigger) is refused rather than resolved as its owner, which
|
||||
in a publisher-mode app would be the publisher.
|
||||
- **Only a tenant list naming the viewer counts.** A workspace admin may *use* every role,
|
||||
`admin` included, and `admin` is the connection that owns the tables and bypasses row-level
|
||||
security. The viewer's roles are those whose tenants name them (user, group, folder, `*`), and
|
||||
never `admin`.
|
||||
- **One role is that role's login. Several are a login per distinct set.** `wm_set_<hash>` is a
|
||||
member of exactly those roles, with `INHERIT`; the hash is over their catalog ids, so a rename
|
||||
moves nothing and a change in someone's groups resolves them to another login instead of
|
||||
editing one. Its privileges are its memberships, so `SET ROLE` only narrows and `RESET ROLE`
|
||||
comes back to it. The prefix is reserved in `validate_role_name`.
|
||||
- **A `datatable_role_set` row always names a login that can authenticate.** The row is written
|
||||
after the login exists and removed, in its own commit, before the login is dropped or made
|
||||
`NOLOGIN`. A login without a row is taken back with a new password on next use.
|
||||
- **Disabling or dropping a role retires every set login holding it.** `NOLOGIN` on a role does
|
||||
not reach a login that is a member of it.
|
||||
- **It travels in the reference, not in an annotation.** A worker that predates `?as=` refuses the
|
||||
reference as malformed. It would skip an unknown `-- ` annotation and connect as the default
|
||||
role, which is `admin` unless changed. For the same reason the request reaches an agent worker's
|
||||
server in the `role` parameter, as a name no role can have.
|
||||
|
||||
## Trust
|
||||
|
||||
A runnable connecting as the viewer runs its author's code with the viewer's reach into the data
|
||||
table, the same trust as an app in viewer execution mode or a script someone else wrote and you
|
||||
run.
|
||||
@@ -1456,7 +1456,9 @@ class Windmill:
|
||||
},
|
||||
)
|
||||
|
||||
def datatable(self, name: str = "main", *, role: Optional[str] = None):
|
||||
def datatable(
|
||||
self, name: str = "main", *, role: Optional[str] = None, as_viewer: bool = False
|
||||
):
|
||||
"""Get a DataTable client for SQL queries.
|
||||
|
||||
Args:
|
||||
@@ -1464,11 +1466,14 @@ class Windmill:
|
||||
role: Connect as this data table role instead of the data table's default one.
|
||||
Only meaningful on a data table under roles, and only for a role you are a
|
||||
tenant of.
|
||||
as_viewer: Connect with every role of the job's viewer at once: the user of the
|
||||
app the job runs for, or whoever ran the script. The server resolves who that
|
||||
is. Cannot be combined with role.
|
||||
|
||||
Returns:
|
||||
DataTableClient instance
|
||||
"""
|
||||
return DataTableClient(self, name, role=role)
|
||||
return DataTableClient(self, name, role=role, as_viewer=as_viewer)
|
||||
|
||||
def ducklake(self, name: str = "main"):
|
||||
"""Get a DuckLake client for DuckDB queries.
|
||||
@@ -2325,17 +2330,21 @@ def username_to_email(username: str) -> str:
|
||||
|
||||
|
||||
@init_global_client
|
||||
def datatable(name: str = "main", *, role: Optional[str] = None) -> DataTableClient:
|
||||
def datatable(
|
||||
name: str = "main", *, role: Optional[str] = None, as_viewer: bool = False
|
||||
) -> DataTableClient:
|
||||
"""Get a DataTable client for SQL queries.
|
||||
|
||||
Args:
|
||||
name: Database name (default: "main")
|
||||
role: Connect as this data table role instead of the data table's default one.
|
||||
as_viewer: Connect with every role of the job's viewer at once: the user of the app
|
||||
the job runs for, or whoever ran the script. Cannot be combined with role.
|
||||
|
||||
Returns:
|
||||
DataTableClient instance
|
||||
"""
|
||||
return _client.datatable(name, role=role)
|
||||
return _client.datatable(name, role=role, as_viewer=as_viewer)
|
||||
|
||||
@init_global_client
|
||||
def ducklake(name: str = "main") -> DucklakeClient:
|
||||
@@ -2418,20 +2427,30 @@ _ROLE_NAME_RE = re.compile(r"^[A-Za-z0-9_-]{1,63}$")
|
||||
class DataTableClient:
|
||||
"""Client for executing SQL queries against Windmill DataTables."""
|
||||
|
||||
def __init__(self, client: Windmill, name: str, role: Optional[str] = None):
|
||||
def __init__(
|
||||
self,
|
||||
client: Windmill,
|
||||
name: str,
|
||||
role: Optional[str] = None,
|
||||
as_viewer: bool = False,
|
||||
):
|
||||
"""Initialize DataTableClient.
|
||||
|
||||
Args:
|
||||
client: Windmill client instance
|
||||
name: DataTable name
|
||||
role: Data table role to connect as, or None for the data table's default
|
||||
as_viewer: Connect with every role of the job's viewer instead of one role
|
||||
"""
|
||||
if role is not None and not _ROLE_NAME_RE.match(role):
|
||||
raise ValueError(
|
||||
f"Invalid data table role '{role}': only letters, digits, '_' and '-' are allowed"
|
||||
)
|
||||
if as_viewer and role is not None:
|
||||
raise ValueError("A data table is used as a role or as the viewer, not both")
|
||||
self.client = client
|
||||
self.role = role
|
||||
self.as_viewer = as_viewer
|
||||
self.name, self.schema = parse_sql_client_name(name)
|
||||
def query(self, sql: str, *args) -> SqlQuery:
|
||||
"""Execute a SQL query against the DataTable.
|
||||
@@ -2460,7 +2479,13 @@ class DataTableClient:
|
||||
lambda sql: self.client.run_inline_script_preview(
|
||||
content=sql,
|
||||
language="postgresql",
|
||||
args={"database": f"datatable://{self.name}", **args_dict},
|
||||
# In the reference rather than an annotation: a worker that does not know `?as=`
|
||||
# refuses the reference, where it would skip an annotation and connect as the
|
||||
# default role.
|
||||
args={
|
||||
"database": f"datatable://{self.name}{'?as=viewer' if self.as_viewer else ''}",
|
||||
**args_dict,
|
||||
},
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@@ -1127,6 +1127,7 @@ Data tables are PostgreSQL databases managed by Windmill. Backend runnables quer
|
||||
3. **No DDL inside runnables**: runnables only read and write rows (SELECT, INSERT, UPDATE, DELETE) on existing tables. Never CREATE, ALTER or DROP a table from a runnable.
|
||||
4. **Qualify table names**: an unqualified name means the \`public\` schema, so write every other table as \`schema.table\`, in table creation and queries alike. The app's \`data\` config sets the default datatable and schema its tables go in; use them consistently across runnables.
|
||||
5. **Pass the role**: when the app's \`data.roles\` gives a data table a role, every \`wmill.datatable\` call on it passes that role (\`wmill.datatable('main', { role: 'analyst' })\` in TypeScript, \`wmill.datatable('main', role='analyst')\` in Python). The role only reaches what it was granted, so a query outside it fails with \`permission denied\`.
|
||||
6. **Per-viewer rows**: a runnable runs as the app's publisher, whoever is using the app. To have a data table under roles answer for the viewer instead, connect as the viewer: \`wmill.datatable('main', { as: 'viewer' })\` in TypeScript, \`wmill.datatable('main', as_viewer=True)\` in Python, \`datatable://main?as=viewer\` as a SQL runnable's database or in a DuckDB \`ATTACH\`. The connection then holds every role the viewer is a tenant of, so row-level security policies written as \`pg_has_role(current_user, '<role>', 'member')\` return that viewer's rows. The server works out who the viewer is; a viewer with no role on the data table, or an anonymous one, is refused. Never combine it with \`role\`.
|
||||
|
||||
### Querying in TypeScript (Bun/Deno)
|
||||
|
||||
@@ -2143,6 +2144,8 @@ parseS3Object(s3Object: S3Object): S3ObjectRecord
|
||||
* @param name - Database/datatable name (default: "main")
|
||||
* @param opts.role - Connect as this data table role instead of the data table's default one.
|
||||
* Only meaningful on a data table under roles, and only for a role you are a tenant of.
|
||||
* @param opts.as - \`"viewer"\` connects with every role of the job's viewer at once, so row-level
|
||||
* security policies see the user of the app rather than its publisher.
|
||||
* @returns SQL template function for building parameterized queries
|
||||
* @example
|
||||
* let sql = wmill.datatable()
|
||||
@@ -2155,6 +2158,9 @@ parseS3Object(s3Object: S3Object): S3ObjectRecord
|
||||
* @example
|
||||
* // Read through a restricted role
|
||||
* let sql = wmill.datatable("main", { role: "analytics" })
|
||||
* @example
|
||||
* // In an app's backend runnable: read what the app's viewer may read
|
||||
* let sql = wmill.datatable("main", { as: "viewer" })
|
||||
*/
|
||||
datatable(name: string = "main", opts?: DatatableOptions): DatatableSqlTemplateFunction
|
||||
|
||||
@@ -2690,10 +2696,13 @@ def send_teams_message(conversation_id: str, text: str, success: bool = True, ca
|
||||
# role: Connect as this data table role instead of the data table's default one.
|
||||
# Only meaningful on a data table under roles, and only for a role you are a
|
||||
# tenant of.
|
||||
# as_viewer: Connect with every role of the job's viewer at once: the user of the
|
||||
# app the job runs for, or whoever ran the script. The server resolves who that
|
||||
# is. Cannot be combined with role.
|
||||
#
|
||||
# Returns:
|
||||
# DataTableClient instance
|
||||
def datatable(name: str = 'main', *, role: Optional[str] = None)
|
||||
def datatable(name: str = 'main', *, role: Optional[str] = None, as_viewer: bool = False)
|
||||
|
||||
# Get a DuckLake client for DuckDB queries.
|
||||
#
|
||||
@@ -3451,6 +3460,8 @@ Create a SQL template function for PostgreSQL/datatable queries
|
||||
@param name - Database/datatable name (default: "main")
|
||||
@param opts.role - Connect as this data table role instead of the data table's default one.
|
||||
Only meaningful on a data table under roles, and only for a role you are a tenant of.
|
||||
@param opts.as - \`"viewer"\` connects with every role of the job's viewer at once, so row-level
|
||||
security policies see the user of the app rather than its publisher.
|
||||
@returns SQL template function for building parameterized queries
|
||||
@example
|
||||
let sql = wmill.datatable()
|
||||
@@ -3463,6 +3474,9 @@ await sql\`
|
||||
@example
|
||||
// Read through a restricted role
|
||||
let sql = wmill.datatable("main", { role: "analytics" })
|
||||
@example
|
||||
// In an app's backend runnable: read what the app's viewer may read
|
||||
let sql = wmill.datatable("main", { as: "viewer" })
|
||||
\`\`\`typescript
|
||||
function datatable(name: string = "main",
|
||||
opts?: DatatableOptions): DatatableSqlTemplateFunction
|
||||
@@ -3478,10 +3492,12 @@ Import: \`import wmill\`
|
||||
# Args:
|
||||
# name: Database name (default: "main")
|
||||
# role: Connect as this data table role instead of the data table's default one.
|
||||
# as_viewer: Connect with every role of the job's viewer at once: the user of the app
|
||||
# the job runs for, or whoever ran the script. Cannot be combined with role.
|
||||
#
|
||||
# Returns:
|
||||
# DataTableClient instance
|
||||
def datatable(name: str = 'main', *, role: Optional[str] = None) -> DataTableClient
|
||||
def datatable(name: str = 'main', *, role: Optional[str] = None, as_viewer: bool = False) -> DataTableClient
|
||||
|
||||
# Client for executing SQL queries against Windmill DataTables.
|
||||
class DataTableClient:
|
||||
@@ -3491,7 +3507,8 @@ class DataTableClient:
|
||||
# client: Windmill client instance
|
||||
# name: DataTable name
|
||||
# role: Data table role to connect as, or None for the data table's default
|
||||
def __init__(client: Windmill, name: str, role: Optional[str] = None)
|
||||
# as_viewer: Connect with every role of the job's viewer instead of one role
|
||||
def __init__(client: Windmill, name: str, role: Optional[str] = None, as_viewer: bool = False)
|
||||
|
||||
# Execute a SQL query against the DataTable.
|
||||
#
|
||||
|
||||
@@ -2020,6 +2020,8 @@ parseS3Object(s3Object: S3Object): S3ObjectRecord
|
||||
* @param name - Database/datatable name (default: "main")
|
||||
* @param opts.role - Connect as this data table role instead of the data table's default one.
|
||||
* Only meaningful on a data table under roles, and only for a role you are a tenant of.
|
||||
* @param opts.as - `"viewer"` connects with every role of the job's viewer at once, so row-level
|
||||
* security policies see the user of the app rather than its publisher.
|
||||
* @returns SQL template function for building parameterized queries
|
||||
* @example
|
||||
* let sql = wmill.datatable()
|
||||
@@ -2032,6 +2034,9 @@ parseS3Object(s3Object: S3Object): S3ObjectRecord
|
||||
* @example
|
||||
* // Read through a restricted role
|
||||
* let sql = wmill.datatable("main", { role: "analytics" })
|
||||
* @example
|
||||
* // In an app's backend runnable: read what the app's viewer may read
|
||||
* let sql = wmill.datatable("main", { as: "viewer" })
|
||||
*/
|
||||
datatable(name: string = "main", opts?: DatatableOptions): DatatableSqlTemplateFunction
|
||||
|
||||
@@ -2567,10 +2572,13 @@ def send_teams_message(conversation_id: str, text: str, success: bool = True, ca
|
||||
# role: Connect as this data table role instead of the data table's default one.
|
||||
# Only meaningful on a data table under roles, and only for a role you are a
|
||||
# tenant of.
|
||||
# as_viewer: Connect with every role of the job's viewer at once: the user of the
|
||||
# app the job runs for, or whoever ran the script. The server resolves who that
|
||||
# is. Cannot be combined with role.
|
||||
#
|
||||
# Returns:
|
||||
# DataTableClient instance
|
||||
def datatable(name: str = 'main', *, role: Optional[str] = None)
|
||||
def datatable(name: str = 'main', *, role: Optional[str] = None, as_viewer: bool = False)
|
||||
|
||||
# Get a DuckLake client for DuckDB queries.
|
||||
#
|
||||
|
||||
@@ -7,10 +7,12 @@ Import: `import wmill`
|
||||
# Args:
|
||||
# name: Database name (default: "main")
|
||||
# role: Connect as this data table role instead of the data table's default one.
|
||||
# as_viewer: Connect with every role of the job's viewer at once: the user of the app
|
||||
# the job runs for, or whoever ran the script. Cannot be combined with role.
|
||||
#
|
||||
# Returns:
|
||||
# DataTableClient instance
|
||||
def datatable(name: str = 'main', *, role: Optional[str] = None) -> DataTableClient
|
||||
def datatable(name: str = 'main', *, role: Optional[str] = None, as_viewer: bool = False) -> DataTableClient
|
||||
|
||||
# Client for executing SQL queries against Windmill DataTables.
|
||||
class DataTableClient:
|
||||
@@ -20,7 +22,8 @@ class DataTableClient:
|
||||
# client: Windmill client instance
|
||||
# name: DataTable name
|
||||
# role: Data table role to connect as, or None for the data table's default
|
||||
def __init__(client: Windmill, name: str, role: Optional[str] = None)
|
||||
# as_viewer: Connect with every role of the job's viewer instead of one role
|
||||
def __init__(client: Windmill, name: str, role: Optional[str] = None, as_viewer: bool = False)
|
||||
|
||||
# Execute a SQL query against the DataTable.
|
||||
#
|
||||
|
||||
@@ -64,6 +64,8 @@ Create a SQL template function for PostgreSQL/datatable queries
|
||||
@param name - Database/datatable name (default: "main")
|
||||
@param opts.role - Connect as this data table role instead of the data table's default one.
|
||||
Only meaningful on a data table under roles, and only for a role you are a tenant of.
|
||||
@param opts.as - `"viewer"` connects with every role of the job's viewer at once, so row-level
|
||||
security policies see the user of the app rather than its publisher.
|
||||
@returns SQL template function for building parameterized queries
|
||||
@example
|
||||
let sql = wmill.datatable()
|
||||
@@ -76,6 +78,9 @@ await sql`
|
||||
@example
|
||||
// Read through a restricted role
|
||||
let sql = wmill.datatable("main", { role: "analytics" })
|
||||
@example
|
||||
// In an app's backend runnable: read what the app's viewer may read
|
||||
let sql = wmill.datatable("main", { as: "viewer" })
|
||||
```typescript
|
||||
function datatable(name: string = "main",
|
||||
opts?: DatatableOptions): DatatableSqlTemplateFunction
|
||||
|
||||
@@ -488,10 +488,13 @@ def send_teams_message(conversation_id: str, text: str, success: bool = True, ca
|
||||
# role: Connect as this data table role instead of the data table's default one.
|
||||
# Only meaningful on a data table under roles, and only for a role you are a
|
||||
# tenant of.
|
||||
# as_viewer: Connect with every role of the job's viewer at once: the user of the
|
||||
# app the job runs for, or whoever ran the script. The server resolves who that
|
||||
# is. Cannot be combined with role.
|
||||
#
|
||||
# Returns:
|
||||
# DataTableClient instance
|
||||
def datatable(name: str = 'main', *, role: Optional[str] = None)
|
||||
def datatable(name: str = 'main', *, role: Optional[str] = None, as_viewer: bool = False)
|
||||
|
||||
# Get a DuckLake client for DuckDB queries.
|
||||
#
|
||||
|
||||
@@ -577,6 +577,8 @@ parseS3Object(s3Object: S3Object): S3ObjectRecord
|
||||
* @param name - Database/datatable name (default: "main")
|
||||
* @param opts.role - Connect as this data table role instead of the data table's default one.
|
||||
* Only meaningful on a data table under roles, and only for a role you are a tenant of.
|
||||
* @param opts.as - `"viewer"` connects with every role of the job's viewer at once, so row-level
|
||||
* security policies see the user of the app rather than its publisher.
|
||||
* @returns SQL template function for building parameterized queries
|
||||
* @example
|
||||
* let sql = wmill.datatable()
|
||||
@@ -589,6 +591,9 @@ parseS3Object(s3Object: S3Object): S3ObjectRecord
|
||||
* @example
|
||||
* // Read through a restricted role
|
||||
* let sql = wmill.datatable("main", { role: "analytics" })
|
||||
* @example
|
||||
* // In an app's backend runnable: read what the app's viewer may read
|
||||
* let sql = wmill.datatable("main", { as: "viewer" })
|
||||
*/
|
||||
datatable(name: string = "main", opts?: DatatableOptions): DatatableSqlTemplateFunction
|
||||
|
||||
|
||||
@@ -188,7 +188,7 @@ data:
|
||||
- `<datatable>/<table>` — Specific table in public schema
|
||||
- `<datatable>/<schema>:<table>` — Table in specific schema
|
||||
|
||||
**Roles:** when a datatable is under roles, its queries run as a role, which only reaches what it was granted. `roles` records the role the app uses each datatable through; the app's code must pass the same role: `wmill.datatable('main', { role: 'analyst' })` in TypeScript, `wmill.datatable('main', role='analyst')` in Python. A datatable without an entry is used as its default role.
|
||||
**Roles:** when a datatable is under roles, its queries run as a role, which only reaches what it was granted. `roles` records the role the app uses each datatable through; the app's code must pass the same role: `wmill.datatable('main', { role: 'analyst' })` in TypeScript, `wmill.datatable('main', role='analyst')` in Python. A datatable without an entry is used as its default role. To use a datatable with the roles of whoever is viewing the app rather than one fixed role, pass `{ as: 'viewer' }` (`as_viewer=True` in Python) instead of a role.
|
||||
|
||||
## SQL Migrations (sql_to_apply/)
|
||||
|
||||
@@ -482,6 +482,7 @@ Data tables are PostgreSQL databases managed by Windmill. Backend runnables quer
|
||||
3. **No DDL inside runnables**: runnables only read and write rows (SELECT, INSERT, UPDATE, DELETE) on existing tables. Never CREATE, ALTER or DROP a table from a runnable.
|
||||
4. **Qualify table names**: an unqualified name means the `public` schema, so write every other table as `schema.table`, in table creation and queries alike. The app's `data` config sets the default datatable and schema its tables go in; use them consistently across runnables.
|
||||
5. **Pass the role**: when the app's `data.roles` gives a data table a role, every `wmill.datatable` call on it passes that role (`wmill.datatable('main', { role: 'analyst' })` in TypeScript, `wmill.datatable('main', role='analyst')` in Python). The role only reaches what it was granted, so a query outside it fails with `permission denied`.
|
||||
6. **Per-viewer rows**: a runnable runs as the app's publisher, whoever is using the app. To have a data table under roles answer for the viewer instead, connect as the viewer: `wmill.datatable('main', { as: 'viewer' })` in TypeScript, `wmill.datatable('main', as_viewer=True)` in Python, `datatable://main?as=viewer` as a SQL runnable's database or in a DuckDB `ATTACH`. The connection then holds every role the viewer is a tenant of, so row-level security policies written as `pg_has_role(current_user, '<role>', 'member')` return that viewer's rows. The server works out who the viewer is; a viewer with no role on the data table, or an anonymous one, is refused. Never combine it with `role`.
|
||||
|
||||
`wmill datatable list` lists the workspace's data tables. Create or change tables with a migration in `sql_to_apply/` (see "SQL Migrations" above), then add them to `data.tables` in `raw_app.yaml`.
|
||||
|
||||
|
||||
@@ -773,6 +773,8 @@ parseS3Object(s3Object: S3Object): S3ObjectRecord
|
||||
* @param name - Database/datatable name (default: "main")
|
||||
* @param opts.role - Connect as this data table role instead of the data table's default one.
|
||||
* Only meaningful on a data table under roles, and only for a role you are a tenant of.
|
||||
* @param opts.as - `"viewer"` connects with every role of the job's viewer at once, so row-level
|
||||
* security policies see the user of the app rather than its publisher.
|
||||
* @returns SQL template function for building parameterized queries
|
||||
* @example
|
||||
* let sql = wmill.datatable()
|
||||
@@ -785,6 +787,9 @@ parseS3Object(s3Object: S3Object): S3ObjectRecord
|
||||
* @example
|
||||
* // Read through a restricted role
|
||||
* let sql = wmill.datatable("main", { role: "analytics" })
|
||||
* @example
|
||||
* // In an app's backend runnable: read what the app's viewer may read
|
||||
* let sql = wmill.datatable("main", { as: "viewer" })
|
||||
*/
|
||||
datatable(name: string = "main", opts?: DatatableOptions): DatatableSqlTemplateFunction
|
||||
|
||||
|
||||
@@ -785,6 +785,8 @@ parseS3Object(s3Object: S3Object): S3ObjectRecord
|
||||
* @param name - Database/datatable name (default: "main")
|
||||
* @param opts.role - Connect as this data table role instead of the data table's default one.
|
||||
* Only meaningful on a data table under roles, and only for a role you are a tenant of.
|
||||
* @param opts.as - `"viewer"` connects with every role of the job's viewer at once, so row-level
|
||||
* security policies see the user of the app rather than its publisher.
|
||||
* @returns SQL template function for building parameterized queries
|
||||
* @example
|
||||
* let sql = wmill.datatable()
|
||||
@@ -797,6 +799,9 @@ parseS3Object(s3Object: S3Object): S3ObjectRecord
|
||||
* @example
|
||||
* // Read through a restricted role
|
||||
* let sql = wmill.datatable("main", { role: "analytics" })
|
||||
* @example
|
||||
* // In an app's backend runnable: read what the app's viewer may read
|
||||
* let sql = wmill.datatable("main", { as: "viewer" })
|
||||
*/
|
||||
datatable(name: string = "main", opts?: DatatableOptions): DatatableSqlTemplateFunction
|
||||
|
||||
|
||||
@@ -775,6 +775,8 @@ parseS3Object(s3Object: S3Object): S3ObjectRecord
|
||||
* @param name - Database/datatable name (default: "main")
|
||||
* @param opts.role - Connect as this data table role instead of the data table's default one.
|
||||
* Only meaningful on a data table under roles, and only for a role you are a tenant of.
|
||||
* @param opts.as - `"viewer"` connects with every role of the job's viewer at once, so row-level
|
||||
* security policies see the user of the app rather than its publisher.
|
||||
* @returns SQL template function for building parameterized queries
|
||||
* @example
|
||||
* let sql = wmill.datatable()
|
||||
@@ -787,6 +789,9 @@ parseS3Object(s3Object: S3Object): S3ObjectRecord
|
||||
* @example
|
||||
* // Read through a restricted role
|
||||
* let sql = wmill.datatable("main", { role: "analytics" })
|
||||
* @example
|
||||
* // In an app's backend runnable: read what the app's viewer may read
|
||||
* let sql = wmill.datatable("main", { as: "viewer" })
|
||||
*/
|
||||
datatable(name: string = "main", opts?: DatatableOptions): DatatableSqlTemplateFunction
|
||||
|
||||
|
||||
@@ -696,10 +696,13 @@ def send_teams_message(conversation_id: str, text: str, success: bool = True, ca
|
||||
# role: Connect as this data table role instead of the data table's default one.
|
||||
# Only meaningful on a data table under roles, and only for a role you are a
|
||||
# tenant of.
|
||||
# as_viewer: Connect with every role of the job's viewer at once: the user of the
|
||||
# app the job runs for, or whoever ran the script. The server resolves who that
|
||||
# is. Cannot be combined with role.
|
||||
#
|
||||
# Returns:
|
||||
# DataTableClient instance
|
||||
def datatable(name: str = 'main', *, role: Optional[str] = None)
|
||||
def datatable(name: str = 'main', *, role: Optional[str] = None, as_viewer: bool = False)
|
||||
|
||||
# Get a DuckLake client for DuckDB queries.
|
||||
#
|
||||
|
||||
@@ -183,7 +183,7 @@ data:
|
||||
- `<datatable>/<table>` — Specific table in public schema
|
||||
- `<datatable>/<schema>:<table>` — Table in specific schema
|
||||
|
||||
**Roles:** when a datatable is under roles, its queries run as a role, which only reaches what it was granted. `roles` records the role the app uses each datatable through; the app's code must pass the same role: `wmill.datatable('main', { role: 'analyst' })` in TypeScript, `wmill.datatable('main', role='analyst')` in Python. A datatable without an entry is used as its default role.
|
||||
**Roles:** when a datatable is under roles, its queries run as a role, which only reaches what it was granted. `roles` records the role the app uses each datatable through; the app's code must pass the same role: `wmill.datatable('main', { role: 'analyst' })` in TypeScript, `wmill.datatable('main', role='analyst')` in Python. A datatable without an entry is used as its default role. To use a datatable with the roles of whoever is viewing the app rather than one fixed role, pass `{ as: 'viewer' }` (`as_viewer=True` in Python) instead of a role.
|
||||
|
||||
## SQL Migrations (sql_to_apply/)
|
||||
|
||||
|
||||
@@ -250,6 +250,7 @@ Data tables are PostgreSQL databases managed by Windmill. Backend runnables quer
|
||||
3. **No DDL inside runnables**: runnables only read and write rows (SELECT, INSERT, UPDATE, DELETE) on existing tables. Never CREATE, ALTER or DROP a table from a runnable.
|
||||
4. **Qualify table names**: an unqualified name means the `public` schema, so write every other table as `schema.table`, in table creation and queries alike. The app's `data` config sets the default datatable and schema its tables go in; use them consistently across runnables.
|
||||
5. **Pass the role**: when the app's `data.roles` gives a data table a role, every `wmill.datatable` call on it passes that role (`wmill.datatable('main', { role: 'analyst' })` in TypeScript, `wmill.datatable('main', role='analyst')` in Python). The role only reaches what it was granted, so a query outside it fails with `permission denied`.
|
||||
6. **Per-viewer rows**: a runnable runs as the app's publisher, whoever is using the app. To have a data table under roles answer for the viewer instead, connect as the viewer: `wmill.datatable('main', { as: 'viewer' })` in TypeScript, `wmill.datatable('main', as_viewer=True)` in Python, `datatable://main?as=viewer` as a SQL runnable's database or in a DuckDB `ATTACH`. The connection then holds every role the viewer is a tenant of, so row-level security policies written as `pg_has_role(current_user, '<role>', 'member')` return that viewer's rows. The server works out who the viewer is; a viewer with no role on the data table, or an anonymous one, is refused. Never combine it with `role`.
|
||||
<!-- cli-only -->
|
||||
|
||||
`wmill datatable list` lists the workspace's data tables. Create or change tables with a migration in `sql_to_apply/` (see "SQL Migrations" above), then add them to `data.tables` in `raw_app.yaml`.
|
||||
|
||||
Vendored
+1
@@ -87,6 +87,7 @@ export interface DatatableSqlTemplateFunction extends SqlTemplateFunction {
|
||||
|
||||
export interface DatatableOptions {
|
||||
role?: string;
|
||||
as?: "viewer";
|
||||
}
|
||||
export declare function datatable(name?: string, opts?: DatatableOptions): DatatableSqlTemplateFunction;
|
||||
export declare function ducklake(name: string): SqlTemplateFunction;
|
||||
|
||||
@@ -111,6 +111,12 @@ export interface DatatableSqlTemplateFunction extends SqlTemplateFunction {
|
||||
export interface DatatableOptions {
|
||||
/** The data table role to connect as. Omit to get the data table's default role. */
|
||||
role?: string;
|
||||
/**
|
||||
* `"viewer"` connects with every role of the job's viewer at once: the user of the app the job
|
||||
* runs for, or whoever ran the script. The server resolves who that is; it cannot be combined
|
||||
* with `role`.
|
||||
*/
|
||||
as?: "viewer";
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -140,17 +146,27 @@ const ROLE_NAME_RE = /^[A-Za-z0-9_-]{1,63}$/;
|
||||
function datatableProvider(
|
||||
name: string,
|
||||
schema?: string,
|
||||
role?: string
|
||||
opts?: DatatableOptions
|
||||
): SqlProvider {
|
||||
const role = opts?.role;
|
||||
if (role !== undefined && !ROLE_NAME_RE.test(role)) {
|
||||
throw new Error(
|
||||
`Invalid data table role '${role}': only letters, digits, '_' and '-' are allowed`
|
||||
);
|
||||
}
|
||||
const as = opts?.as;
|
||||
if (as !== undefined && as !== "viewer") {
|
||||
throw new Error(`Invalid data table option as: '${as}': the only value is 'viewer'`);
|
||||
}
|
||||
if (as !== undefined && role !== undefined) {
|
||||
throw new Error("A data table is used as a role or as the viewer, not both");
|
||||
}
|
||||
return {
|
||||
providerName: "datatable",
|
||||
language: "postgresql",
|
||||
extraArgs: { database: `datatable://${name}` },
|
||||
// In the reference rather than an annotation: a worker that does not know `?as=` refuses
|
||||
// the reference, where it would skip an annotation and connect as the default role.
|
||||
extraArgs: { database: `datatable://${name}${as ? "?as=viewer" : ""}` },
|
||||
annotations: () => (role !== undefined ? `-- role ${role}\n` : ""),
|
||||
formatArgDecl: (argNum) => `-- $${argNum} arg${argNum}`,
|
||||
formatArgUsage: (argNum, explicitType, inferredType) =>
|
||||
@@ -348,6 +364,8 @@ function buildSqlTemplateFunction(provider: SqlProvider): SqlTemplateFunction {
|
||||
* @param name - Database/datatable name (default: "main")
|
||||
* @param opts.role - Connect as this data table role instead of the data table's default one.
|
||||
* Only meaningful on a data table under roles, and only for a role you are a tenant of.
|
||||
* @param opts.as - `"viewer"` connects with every role of the job's viewer at once, so row-level
|
||||
* security policies see the user of the app rather than its publisher.
|
||||
* @returns SQL template function for building parameterized queries
|
||||
* @example
|
||||
* let sql = wmill.datatable()
|
||||
@@ -360,13 +378,16 @@ function buildSqlTemplateFunction(provider: SqlProvider): SqlTemplateFunction {
|
||||
* @example
|
||||
* // Read through a restricted role
|
||||
* let sql = wmill.datatable("main", { role: "analytics" })
|
||||
* @example
|
||||
* // In an app's backend runnable: read what the app's viewer may read
|
||||
* let sql = wmill.datatable("main", { as: "viewer" })
|
||||
*/
|
||||
export function datatable(
|
||||
name: string = "main",
|
||||
opts?: DatatableOptions
|
||||
): DatatableSqlTemplateFunction {
|
||||
let { name: n, schema } = parseName(name);
|
||||
let provider = datatableProvider(n, schema, opts?.role);
|
||||
let provider = datatableProvider(n, schema, opts);
|
||||
let sqlFn = buildSqlTemplateFunction(provider) as DatatableSqlTemplateFunction;
|
||||
// `.query(sql, ...params)` is for SQL strings that already contain
|
||||
// positional placeholders ($1, $2, ...). We DON'T go through the template
|
||||
|
||||
Reference in New Issue
Block a user