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:
Ruben Fiszel
2026-10-12 20:39:46 +02:00
co-authored by Claude Opus 5.5
parent 9fa9b77926
commit 87c87facd2
30 changed files with 462 additions and 34 deletions
+3
View File
@@ -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
View File
@@ -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
}
+10 -3
View File
@@ -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<()> {
+4
View File
@@ -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,
+44 -5
View File
@@ -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?
+21 -2
View File
@@ -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?
+4 -2
View File
@@ -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, &params.workspace_id, &params.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;
+21 -2
View File
@@ -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\`.
+63
View File
@@ -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.
+31 -6
View File
@@ -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,
},
)
)
+20 -3
View File
@@ -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.
#
+9 -1
View File
@@ -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
+4 -1
View File
@@ -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.
#
+1 -1
View File
@@ -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/)
+1
View File
@@ -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`.
+1
View File
@@ -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;
+24 -3
View File
@@ -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