feat(datatables): data table roles in the DB manager and raw apps

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Diego Imbert
2026-09-16 15:23:20 +02:00
co-authored by Claude Opus 5
parent a1b91690fd
commit f36aa69fc3
54 changed files with 3275 additions and 1053 deletions
+1 -1
View File
@@ -1 +1 @@
6f26308c67acf9fcc45773b373aa30a2593b665c
6ced9da74422ae8feee10da70419ccedf313506c
@@ -1009,6 +1009,70 @@ async fn an_entry_without_roles_cannot_newly_reach_a_database_under_roles(
Ok(())
}
/// Browsing names the role it connects as, and a role the caller may not use is refused rather
/// than quietly listed as the default. The refusal is decided before connecting, so the fixture's
/// database never has to exist.
#[cfg(all(feature = "private", feature = "enterprise"))]
#[sqlx::test(migrations = "../migrations", fixtures("base", "datatable_roles"))]
async fn browsing_as_a_role_the_caller_may_not_use_is_refused(
db: Pool<Postgres>,
) -> anyhow::Result<()> {
initialize_tracing().await;
let server = ApiServer::start(db.clone()).await?;
let port = server.addr.port();
let base = format!("http://localhost:{port}/api/w/test-workspace/workspaces");
// `test-user-2` is a tenant of `analytics` only.
let resp = authed(
client().get(format!(
"{base}/list_datatable_tables?role_for=main&role=admin"
)),
"SECRET_TOKEN_2",
)
.send()
.await?;
assert_eq!(resp.status(), 200);
let body: Value = resp.json().await?;
let entry = body
.as_array()
.and_then(|a| a.iter().find(|e| e["datatable_name"] == "main"))
.expect("main is listed");
assert_eq!(entry["usable_roles"], json!(["analytics"]), "{entry}");
assert_eq!(entry["default_role"], "analytics", "{entry}");
assert_eq!(entry["permissioned"], true, "{entry}");
assert_eq!(entry["instance"], true, "{entry}");
let error = entry["error"].as_str().unwrap_or_default();
assert!(
error.contains("Not allowed to use role 'admin'"),
"listed as another role than the one asked for: {entry}"
);
let resp = authed(
client().get(format!(
"{base}/get_datatable_table_schema?datatable_name=main&schema_name=public&table_name=t&role=admin"
)),
"SECRET_TOKEN_2",
)
.send()
.await?;
let status = resp.status();
let text = resp.text().await?;
assert!(
text.contains("Not allowed to use role 'admin'"),
"{status}: {text}"
);
// A role means nothing without the data table it belongs to.
let resp = authed(
client().get(format!("{base}/list_datatable_tables?role=analytics")),
"SECRET_TOKEN_2",
)
.send()
.await?;
assert_eq!(resp.status(), 400, "{}", resp.text().await?);
Ok(())
}
#[cfg(not(all(feature = "private", feature = "enterprise")))]
const ENTERPRISE_REFUSAL: &str = "Data table roles are a Windmill Enterprise Edition feature";
@@ -13,7 +13,7 @@
#[cfg(all(feature = "private", feature = "enterprise"))]
pub(crate) use crate::datatable_permissions_ee::{
ensure_governs_datatable, ensure_reaches_datatable, get_datatable_permissions,
list_usable_datatable_roles, set_datatable_permissions,
list_usable_datatable_roles, set_datatable_permissions, usable_datatable_roles,
};
#[cfg(not(all(feature = "private", feature = "enterprise")))]
@@ -70,4 +70,28 @@ mod ce {
pub(crate) async fn list_usable_datatable_roles(_authed: ApiAuthed) -> Result<String> {
Err(unavailable())
}
pub(crate) struct UsableDatatableRoles {
pub(crate) permissioned: bool,
pub(crate) roles: Vec<String>,
pub(crate) default_role: String,
}
/// A data table not under roles is used as `admin`, as before roles existed. One under roles
/// is refused: no role of it can be connected as.
pub(crate) async fn usable_datatable_roles(
_db: &DB,
_authed: &ApiAuthed,
_w_id: &str,
governing: &GoverningDatatable,
) -> Result<UsableDatatableRoles> {
if governing.datatable.permissions.is_some() {
return Err(unavailable());
}
Ok(UsableDatatableRoles {
permissioned: false,
roles: vec![],
default_role: windmill_common::datatable_roles::ADMIN_DATATABLE_ROLE.to_string(),
})
}
}
+129 -19
View File
@@ -2269,6 +2269,25 @@ struct DataTableTables {
schemas: TableListMap,
#[serde(skip_serializing_if = "Option::is_none")]
error: Option<String>,
/// On the instance database: the only kind that can be under roles or have its access edited.
instance: bool,
permissioned: bool,
/// The roles this caller may connect as, by name; empty when not under roles.
usable_roles: Vec<String>,
default_role: String,
/// What the role the listing connected as may create.
can_create_schema: bool,
creatable_schemas: Vec<String>,
}
#[derive(Deserialize)]
struct ListDataTableTablesQuery {
/// List only this data table: each entry opens a connection to its database.
datatable_name: Option<String>,
/// The data table `role` applies to. Every other one is listed as its default role, since a
/// role name means nothing outside the data table it belongs to.
role_for: Option<String>,
role: Option<String>,
}
#[derive(Deserialize)]
@@ -2276,6 +2295,7 @@ struct GetDataTableSchemaQuery {
datatable_name: String,
schema_name: String,
table_name: String,
role: Option<String>,
}
#[derive(Serialize, Debug)]
@@ -2443,25 +2463,89 @@ async fn list_datatable_tables(
authed: ApiAuthed,
Extension(db): Extension<DB>,
Path(w_id): Path<String>,
Query(query): Query<ListDataTableTablesQuery>,
) -> JsonResult<Vec<DataTableTables>> {
let datatable_names = list_datatable_names(&db, &w_id).await?;
if query.role.is_some() && query.role_for.is_none() {
return Err(Error::BadRequest(
"`role` needs `role_for`, the data table it is a role of".to_string(),
));
}
if let (Some(only), Some(role_for)) =
(query.datatable_name.as_deref(), query.role_for.as_deref())
{
if only != role_for {
return Err(Error::BadRequest(format!(
"`role_for` names '{role_for}', which `datatable_name` leaves out of the listing"
)));
}
}
let mut datatable_names = list_datatable_names(&db, &w_id).await?;
for named in [query.role_for.as_deref(), query.datatable_name.as_deref()]
.into_iter()
.flatten()
{
if !datatable_names.iter().any(|n| n == named) {
return Err(Error::NotFound(format!(
"No data table named '{named}' in this workspace"
)));
}
}
if let Some(only) = query.datatable_name.as_deref() {
datatable_names.retain(|n| n == only);
}
let mut results = Vec::new();
for datatable_name in datatable_names {
let tables = match get_datatable_tables(&db, &authed, &w_id, &datatable_name).await {
Ok(schemas) => DataTableTables { datatable_name, schemas, error: None },
Err(e) => DataTableTables {
datatable_name,
schemas: HashMap::new(),
error: Some(e.to_string()),
},
};
results.push(tables);
let role = query
.role
.as_deref()
.filter(|_| query.role_for.as_deref() == Some(datatable_name.as_str()));
results.push(list_one_datatable_tables(&db, &authed, &w_id, datatable_name, role).await);
}
Ok(Json(results))
}
async fn list_one_datatable_tables(
db: &DB,
authed: &ApiAuthed,
w_id: &str,
datatable_name: String,
role: Option<&str>,
) -> DataTableTables {
let mut entry = DataTableTables {
datatable_name,
schemas: HashMap::new(),
error: None,
instance: false,
permissioned: false,
usable_roles: vec![],
default_role: windmill_common::datatable_roles::ADMIN_DATATABLE_ROLE.to_string(),
can_create_schema: false,
creatable_schemas: vec![],
};
let result: Result<()> = async {
let governing = resolve_governing_datatable(db, w_id, &entry.datatable_name).await?;
entry.instance = governing.is_instance();
let usable =
crate::datatable_permissions_oss::usable_datatable_roles(db, authed, w_id, &governing)
.await?;
entry.permissioned = usable.permissioned;
entry.usable_roles = usable.roles;
entry.default_role = usable.default_role;
let listing = get_datatable_tables(db, authed, w_id, &entry.datatable_name, role).await?;
entry.schemas = listing.schemas;
entry.can_create_schema = listing.can_create_schema;
entry.creatable_schemas = listing.creatable_schemas;
Ok(())
}
.await;
if let Err(e) = result {
entry.error = Some(e.to_string());
}
entry
}
async fn get_datatable_table_schema(
authed: ApiAuthed,
Extension(db): Extension<DB>,
@@ -2475,6 +2559,7 @@ async fn get_datatable_table_schema(
&query.datatable_name,
&query.schema_name,
&query.table_name,
query.role.as_deref(),
)
.await?;
@@ -2513,14 +2598,13 @@ async fn resolve_datatable_pg_as_caller(
authed: &ApiAuthed,
w_id: &str,
datatable_name: &str,
role: Option<&str>,
) -> Result<PgDatabase> {
let db_resource = get_datatable_resource_from_db(
db,
w_id,
datatable_name,
// The data table's default role. Browsing has no way to name another one yet; when the
// database manager grows a role picker it passes the pick through here.
None,
role,
DatatableAccess::Authed(authed.to_authed_ref()),
)
.await?;
@@ -2534,7 +2618,7 @@ async fn get_datatable_schema(
w_id: &str,
datatable_name: &str,
) -> Result<SchemaMap> {
let pg_db = resolve_datatable_pg_as_caller(db, authed, w_id, datatable_name).await?;
let pg_db = resolve_datatable_pg_as_caller(db, authed, w_id, datatable_name, None).await?;
// Connect to the datatable database
let (client, connection) = pg_db.connect(Some(db)).await?;
@@ -2622,13 +2706,20 @@ async fn get_datatable_schema(
Ok(schema_map)
}
struct DatatableTableListing {
schemas: TableListMap,
can_create_schema: bool,
creatable_schemas: Vec<String>,
}
async fn get_datatable_tables(
db: &DB,
authed: &ApiAuthed,
w_id: &str,
datatable_name: &str,
) -> Result<TableListMap> {
let pg_db = resolve_datatable_pg_as_caller(db, authed, w_id, datatable_name).await?;
role: Option<&str>,
) -> Result<DatatableTableListing> {
let pg_db = resolve_datatable_pg_as_caller(db, authed, w_id, datatable_name, role).await?;
let (client, connection) = pg_db.connect(Some(db)).await?;
tokio::spawn(async move {
@@ -2640,7 +2731,7 @@ async fn get_datatable_tables(
let schema_rows = client
.query(
r#"
SELECT nspname::text AS schema_name
SELECT nspname::text AS schema_name, has_schema_privilege(oid, 'CREATE') AS can_create
FROM pg_namespace
WHERE nspname NOT IN ('information_schema', 'pg_toast', 'pg_catalog')
AND nspname NOT LIKE 'pg_%'
@@ -2654,11 +2745,29 @@ async fn get_datatable_tables(
Error::internal_err(format!("Failed to query schemas: {}", pg_error_message(&e)))
})?;
let can_create_schema: bool = client
.query_one(
"SELECT has_database_privilege(current_database(), 'CREATE')",
&[],
)
.await
.map_err(|e| {
Error::internal_err(format!(
"Failed to read database privileges: {}",
pg_error_message(&e)
))
})?
.get(0);
let mut table_map: TableListMap = HashMap::new();
let mut creatable_schemas = Vec::new();
let schema_names: Vec<String> = schema_rows
.iter()
.map(|row| {
let name: String = row.get(0);
if row.get::<_, bool>(1) {
creatable_schemas.push(name.clone());
}
table_map.entry(name.clone()).or_default();
name
})
@@ -2688,7 +2797,7 @@ async fn get_datatable_tables(
table_map.entry(table_schema).or_default().push(table_name);
}
Ok(table_map)
Ok(DatatableTableListing { schemas: table_map, can_create_schema, creatable_schemas })
}
async fn get_datatable_table_columns(
@@ -2698,6 +2807,7 @@ async fn get_datatable_table_columns(
datatable_name: &str,
schema_name: &str,
table_name: &str,
role: Option<&str>,
) -> Result<ColumnMap> {
if is_system_pg_schema(schema_name) {
return Err(Error::BadRequest(format!(
@@ -2706,7 +2816,7 @@ async fn get_datatable_table_columns(
)));
}
let pg_db = resolve_datatable_pg_as_caller(db, authed, w_id, datatable_name).await?;
let pg_db = resolve_datatable_pg_as_caller(db, authed, w_id, datatable_name, role).await?;
let (client, connection) = pg_db.connect(Some(db)).await?;
tokio::spawn(async move {
+51 -1
View File
@@ -5488,6 +5488,21 @@ paths:
- workspace
parameters:
- $ref: "#/components/parameters/WorkspaceId"
- name: datatable_name
in: query
description: list only this data table; each listed data table opens a connection to its database
schema:
type: string
- name: role_for
in: query
description: the data table `role` applies to; every other one is listed as its default role
schema:
type: string
- name: role
in: query
description: the role to list `role_for` as; refused, in that entry's `error`, if the caller may not use it
schema:
type: string
responses:
"200":
description: table metadata of all datatables
@@ -5521,6 +5536,11 @@ paths:
required: true
schema:
type: string
- name: role
in: query
description: the data table role to read the table as; defaults to the data table's default role
schema:
type: string
responses:
"200":
description: schema of one datatable table
@@ -36053,7 +36073,17 @@ components:
DataTableTables:
type: object
required: [datatable_name, schemas]
required:
[
datatable_name,
schemas,
instance,
permissioned,
usable_roles,
default_role,
can_create_schema,
creatable_schemas,
]
properties:
datatable_name:
type: string
@@ -36066,6 +36096,26 @@ components:
type: string
error:
type: string
instance:
type: boolean
description: on the instance database, the only kind that can be under roles or have its access edited
permissioned:
type: boolean
usable_roles:
type: array
description: the roles the caller may connect as, by name; empty when not under roles
items:
type: string
default_role:
type: string
can_create_schema:
type: boolean
description: whether the role the listing connected as may create schemas
creatable_schemas:
type: array
description: the schemas the role the listing connected as may create in
items:
type: string
DataTableTableSchema:
type: object
+6 -1
View File
@@ -8701,7 +8701,12 @@ fn register_potential_assets_on_inline_execution(
.as_ref()
.and_then(|args| args.get("database"))
.map(|v| v.get().trim_matches('"'))
.and_then(|dt| dt.strip_prefix("datatable://"));
.and_then(|dt| dt.strip_prefix("datatable://"))
// `?role=` picks the connection, not the data table. Anything else after a `?` may be
// part of a name stored before names were restricted, so it stays.
.map(|dt| {
windmill_common::workspaces::parse_datatable_ref(dt).map_or(dt, |(name, _)| name)
});
if let Some(datatable) = datatable {
let re = regex::Regex::new(r#"SET search_path TO "([^"]+)";"#).unwrap();
let (schema, content) = if let Some(captures) = re.captures(&preview.content) {
@@ -15,7 +15,8 @@
use crate::error::Error;
/// What every roles path answers without the Enterprise Edition.
/// What every roles path answers without the Enterprise Edition. The frontend matches this exact
/// sentence (`datatableUsableRoles.ts`) to read the refusal as "not under roles": reword both.
pub fn datatable_roles_unavailable() -> Error {
Error::BadRequest("Data table roles are a Windmill Enterprise Edition feature".to_string())
}
+36 -1
View File
@@ -329,6 +329,7 @@ pub fn try_expand_internal_db_query(
"ALTER_TABLE" => expand_alter_table(json_str, db_type).map(ExpandedQuery::sql),
"CREATE_SCHEMA" => expand_create_schema(json_str, db_type).map(ExpandedQuery::sql),
"DROP_SCHEMA" => expand_drop_schema(json_str, db_type).map(ExpandedQuery::sql),
"RENAME_SCHEMA" => expand_rename_schema(json_str, db_type).map(ExpandedQuery::sql),
// Metadata queries
"LOAD_TABLE_METADATA" => expand_load_table_metadata(json_str, db_type),
"FOREIGN_KEYS" => expand_foreign_keys(json_str, db_type).map(ExpandedQuery::sql),
@@ -1716,6 +1717,13 @@ struct DropSchemaPayload {
ducklake: Option<String>,
}
#[derive(Deserialize)]
struct RenameSchemaPayload {
schema: String,
new_schema: String,
ducklake: Option<String>,
}
#[derive(Debug, Clone, Deserialize)]
struct TableEditorColumn {
name: String,
@@ -2004,6 +2012,23 @@ fn expand_drop_schema(json_str: &str, db_type: DbType) -> Result<String, String>
Ok(maybe_wrap_ducklake(query, p.ducklake.as_deref()))
}
fn expand_rename_schema(json_str: &str, db_type: DbType) -> Result<String, String> {
let p: RenameSchemaPayload = serde_json::from_str(json_str)
.map_err(|e| format!("Invalid RENAME_SCHEMA payload: {}", e))?;
if !matches!(db_type, DbType::Postgresql | DbType::Snowflake) || p.ducklake.is_some() {
return Err(format!(
"Renaming a schema is not supported on {:?}",
db_type
));
}
let query = format!(
"ALTER SCHEMA {} RENAME TO {};",
qi(&p.schema, db_type),
qi(&p.new_schema, db_type)
);
Ok(query)
}
fn expand_create_table(json_str: &str, db_type: DbType) -> Result<String, String> {
let p: CreateTablePayload = serde_json::from_str(json_str)
.map_err(|e| format!("Invalid CREATE_TABLE payload: {}", e))?;
@@ -2598,7 +2623,9 @@ WHERE table_catalog = current_database()",
)
} else {
(
"\nWHERE c.relkind = 'r' AND a.attnum > 0 AND NOT a.attisdropped\n AND ns.nspname != 'pg_catalog' AND ns.nspname != 'information_schema'".to_string(),
// pg_catalog is readable by everyone: without the privilege check this lists
// tables of schemas the connection's role cannot even enter.
"\nWHERE c.relkind = 'r' AND a.attnum > 0 AND NOT a.attisdropped\n AND ns.nspname != 'pg_catalog' AND ns.nspname != 'information_schema'\n AND has_schema_privilege(ns.oid, 'USAGE')".to_string(),
",\n ns.nspname AS schema_name,\n c.relname AS table_name".to_string(),
"\nJOIN pg_catalog.pg_class c ON a.attrelid = c.oid\nJOIN pg_catalog.pg_namespace ns ON c.relnamespace = ns.oid".to_string(),
"ns.nspname, c.relname, a.attnum".to_string(),
@@ -4101,6 +4128,13 @@ mod tests {
assert_eq!(sql, "DROP SCHEMA \"old_schema\" CASCADE;");
}
#[test]
fn test_expand_rename_schema() {
let marker = r#"-- WM_INTERNAL_DB_RENAME_SCHEMA {"schema":"old","new_schema":"new"}"#;
let sql = expand_code(marker, &ScriptLang::Postgresql);
assert_eq!(sql, "ALTER SCHEMA \"old\" RENAME TO \"new\";");
}
#[test]
fn test_expand_create_schema_with_ducklake() {
let marker = r#"-- WM_INTERNAL_DB_CREATE_SCHEMA {"schema":"s","ducklake":"lake"}"#;
@@ -4468,6 +4502,7 @@ mod tests {
assert!(sql.contains("schema_name"));
assert!(sql.contains("table_name"));
assert!(sql.contains("c.relkind = 'r'"));
assert!(sql.contains("has_schema_privilege(ns.oid, 'USAGE')"));
}
#[test]