mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-08-25 00:01:55 +00:00
feat(datatables): pick the role the database manager connects as
This commit is contained in:
@@ -123,7 +123,12 @@ async fn datatable_database_arg(
|
||||
.await?
|
||||
.ok_or_else(|| Error::internal_err(format!("datatable {datatable_name} not found")))?;
|
||||
|
||||
Ok(to_raw_value(&format!("datatable://{datatable_name}")))
|
||||
// Migrations change the schema and are recorded against objects root owns, so
|
||||
// they run as root rather than the data table's default role.
|
||||
Ok(to_raw_value(&format!(
|
||||
"datatable://{datatable_name}?role={}",
|
||||
windmill_common::workspaces::ROOT_DATATABLE_ROLE
|
||||
)))
|
||||
}
|
||||
|
||||
/// Run a migration's SQL as a normal Windmill `postgresql` job, permissioned as
|
||||
|
||||
@@ -31,8 +31,8 @@ use windmill_common::error::{pg_error_message, Error, JsonResult, Result};
|
||||
use windmill_common::query_builders::{render_db_quoted_identifier, DbType};
|
||||
use windmill_common::utils::{rd_string, require_admin};
|
||||
use windmill_common::workspaces::{
|
||||
datatable_pg_role_name, get_datatable_resource_from_db_unchecked, DataTable,
|
||||
DataTablePermissions, DataTableRole, ROOT_DATATABLE_ROLE,
|
||||
can_use_datatable_role, datatable_pg_role_name, get_datatable_resource_from_db_unchecked,
|
||||
DataTable, DataTablePermissions, DataTableRole, DATATABLE_TENANT_WILDCARD, ROOT_DATATABLE_ROLE,
|
||||
};
|
||||
use windmill_common::{PgDatabase, DB};
|
||||
|
||||
@@ -46,6 +46,10 @@ pub(crate) fn routes() -> Router {
|
||||
"/datatable_permissions/{datatable_name}/preview",
|
||||
post(preview_datatable_permissions),
|
||||
)
|
||||
.route(
|
||||
"/datatable_usable_roles/{datatable_name}",
|
||||
get(list_usable_datatable_roles),
|
||||
)
|
||||
}
|
||||
|
||||
/// A data table role as the UI sees it: the generated password never leaves the
|
||||
@@ -90,6 +94,15 @@ pub struct DatatableRoleRename {
|
||||
pub to: String,
|
||||
}
|
||||
|
||||
/// The roles the caller may actually run as, for pickers. Unlike the admin-only
|
||||
/// permissions view this exposes no tenant lists — only what the caller can use.
|
||||
#[derive(Serialize, Debug)]
|
||||
pub struct UsableDatatableRoles {
|
||||
pub enabled: bool,
|
||||
pub roles: Vec<String>,
|
||||
pub default_role: String,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Debug)]
|
||||
pub struct DatatablePermissionsPreview {
|
||||
pub statements: Vec<String>,
|
||||
@@ -141,10 +154,13 @@ fn validate_role_name(name: &str) -> Result<()> {
|
||||
}
|
||||
|
||||
fn validate_tenant(tenant: &str) -> Result<()> {
|
||||
if tenant == DATATABLE_TENANT_WILDCARD {
|
||||
return Ok(());
|
||||
}
|
||||
match tenant.split_once('/') {
|
||||
Some(("u" | "g" | "f", rest)) if !rest.is_empty() => Ok(()),
|
||||
_ => Err(Error::BadRequest(format!(
|
||||
"Invalid tenant '{tenant}': expected u/<user>, g/<group> or f/<folder>"
|
||||
"Invalid tenant '{tenant}': expected '*', u/<user>, g/<group> or f/<folder>"
|
||||
))),
|
||||
}
|
||||
}
|
||||
@@ -175,24 +191,23 @@ fn plan_role_changes(
|
||||
let mut warnings = Vec::new();
|
||||
|
||||
let mut dropped_pg_roles: HashSet<String> = HashSet::new();
|
||||
let drop_role = |statements: &mut Vec<PlannedStatement>,
|
||||
dropped: &mut HashSet<String>,
|
||||
pg_role: &str| {
|
||||
if !existing_pg_roles.contains(pg_role) {
|
||||
return;
|
||||
}
|
||||
dropped.insert(pg_role.to_string());
|
||||
let q = quote_ident(pg_role);
|
||||
// Give the objects back to root before dropping, else the DROP fails on
|
||||
// anything the role still owns. DROP OWNED then clears what is left:
|
||||
// privileges granted to it and its default-privilege entries.
|
||||
statements.push(PlannedStatement::plain(format!(
|
||||
"REASSIGN OWNED BY {q} TO {};",
|
||||
quote_ident(root_pg_role)
|
||||
)));
|
||||
statements.push(PlannedStatement::plain(format!("DROP OWNED BY {q};")));
|
||||
statements.push(PlannedStatement::plain(format!("DROP ROLE {q};")));
|
||||
};
|
||||
let drop_role =
|
||||
|statements: &mut Vec<PlannedStatement>, dropped: &mut HashSet<String>, pg_role: &str| {
|
||||
if !existing_pg_roles.contains(pg_role) {
|
||||
return;
|
||||
}
|
||||
dropped.insert(pg_role.to_string());
|
||||
let q = quote_ident(pg_role);
|
||||
// Give the objects back to root before dropping, else the DROP fails on
|
||||
// anything the role still owns. DROP OWNED then clears what is left:
|
||||
// privileges granted to it and its default-privilege entries.
|
||||
statements.push(PlannedStatement::plain(format!(
|
||||
"REASSIGN OWNED BY {q} TO {};",
|
||||
quote_ident(root_pg_role)
|
||||
)));
|
||||
statements.push(PlannedStatement::plain(format!("DROP OWNED BY {q};")));
|
||||
statements.push(PlannedStatement::plain(format!("DROP ROLE {q};")));
|
||||
};
|
||||
|
||||
if !req.enabled {
|
||||
for (name, role) in old_roles.iter() {
|
||||
@@ -359,7 +374,11 @@ fn plan_role_changes(
|
||||
.unwrap_or_else(|| rd_string(32));
|
||||
if old_pg != pg_rolename {
|
||||
if existing_pg_roles.contains(&old_pg) {
|
||||
pending_renames.push((old_pg.clone(), pg_rolename.clone(), password.clone()));
|
||||
pending_renames.push((
|
||||
old_pg.clone(),
|
||||
pg_rolename.clone(),
|
||||
password.clone(),
|
||||
));
|
||||
} else {
|
||||
warnings.push(format!(
|
||||
"Role '{from}' was expected to exist in the database as '{old_pg}' but does not; it will be created as '{pg_rolename}'."
|
||||
@@ -412,7 +431,11 @@ fn plan_role_changes(
|
||||
}
|
||||
}
|
||||
|
||||
statements.extend(order_renames(pending_renames, existing_pg_roles, &dropped_pg_roles)?);
|
||||
statements.extend(order_renames(
|
||||
pending_renames,
|
||||
existing_pg_roles,
|
||||
&dropped_pg_roles,
|
||||
)?);
|
||||
statements.extend(creates_sql);
|
||||
|
||||
Ok(RolePlan {
|
||||
@@ -420,8 +443,7 @@ fn plan_role_changes(
|
||||
permissions: DataTablePermissions {
|
||||
enabled: true,
|
||||
roles,
|
||||
default_role: (default_role != ROOT_DATATABLE_ROLE)
|
||||
.then(|| default_role.to_string()),
|
||||
default_role: (default_role != ROOT_DATATABLE_ROLE).then(|| default_role.to_string()),
|
||||
},
|
||||
warnings,
|
||||
})
|
||||
@@ -678,6 +700,34 @@ async fn get_datatable_permissions(
|
||||
}))
|
||||
}
|
||||
|
||||
/// List the roles `authed` may run this data table as. An unpermissioned data
|
||||
/// table reports `enabled: false` and no roles, so a picker can hide itself.
|
||||
async fn list_usable_datatable_roles(
|
||||
authed: ApiAuthed,
|
||||
Extension(db): Extension<DB>,
|
||||
Path((w_id, datatable_name)): Path<(String, String)>,
|
||||
) -> JsonResult<UsableDatatableRoles> {
|
||||
let datatable = read_datatable(&db, &w_id, &datatable_name).await?;
|
||||
let Some(permissions) = datatable.permissions.filter(|p| p.enabled) else {
|
||||
return Ok(Json(UsableDatatableRoles {
|
||||
enabled: false,
|
||||
roles: vec![],
|
||||
default_role: ROOT_DATATABLE_ROLE.to_string(),
|
||||
}));
|
||||
};
|
||||
let authed_ref = authed.to_authed_ref();
|
||||
Ok(Json(UsableDatatableRoles {
|
||||
enabled: true,
|
||||
default_role: permissions.default_role().to_string(),
|
||||
roles: permissions
|
||||
.roles
|
||||
.iter()
|
||||
.filter(|(_, role)| can_use_datatable_role(role, &authed_ref))
|
||||
.map(|(name, _)| name.clone())
|
||||
.collect(),
|
||||
}))
|
||||
}
|
||||
|
||||
async fn preview_datatable_permissions(
|
||||
authed: ApiAuthed,
|
||||
Extension(db): Extension<DB>,
|
||||
@@ -895,11 +945,11 @@ mod tests {
|
||||
.map(|r| datatable_pg_role_name(W_ID, DT, r))
|
||||
.collect();
|
||||
let req = SetDatatablePermissions {
|
||||
enabled: false,
|
||||
roles: vec![],
|
||||
default_role: None,
|
||||
renames: vec![],
|
||||
};
|
||||
enabled: false,
|
||||
roles: vec![],
|
||||
default_role: None,
|
||||
renames: vec![],
|
||||
};
|
||||
let plan = plan(
|
||||
Some(&old),
|
||||
&req,
|
||||
@@ -1033,11 +1083,11 @@ mod tests {
|
||||
fn a_role_the_config_lost_track_of_is_not_dropped() {
|
||||
let old = enabled_with(&["analyst"]);
|
||||
let req = SetDatatablePermissions {
|
||||
enabled: false,
|
||||
roles: vec![],
|
||||
default_role: None,
|
||||
renames: vec![],
|
||||
};
|
||||
enabled: false,
|
||||
roles: vec![],
|
||||
default_role: None,
|
||||
renames: vec![],
|
||||
};
|
||||
// The Postgres role is already gone, so planning its drop would fail the
|
||||
// whole transaction and wedge the opt-out.
|
||||
let plan = plan(Some(&old), &req, &[]).unwrap();
|
||||
@@ -1104,7 +1154,16 @@ mod tests {
|
||||
"{bad_role} should be rejected"
|
||||
);
|
||||
}
|
||||
for bad_tenant in ["alice", "x/alice", "u/", ""] {
|
||||
// The wildcard is the one tenant with no prefix.
|
||||
let req = SetDatatablePermissions {
|
||||
enabled: true,
|
||||
roles: vec![role("root", &["*"])],
|
||||
default_role: None,
|
||||
renames: vec![],
|
||||
};
|
||||
assert!(plan(None, &req, &[]).is_ok());
|
||||
|
||||
for bad_tenant in ["alice", "x/alice", "u/", "", "*/alice", "**"] {
|
||||
let req = SetDatatablePermissions {
|
||||
enabled: true,
|
||||
roles: vec![role("root", &[bad_tenant])],
|
||||
|
||||
@@ -5013,6 +5013,37 @@ paths:
|
||||
schema:
|
||||
type: string
|
||||
|
||||
/w/{workspace}/workspaces/datatable_usable_roles/{datatable_name}:
|
||||
get:
|
||||
summary: list the datatable roles the caller may run as
|
||||
operationId: listUsableDatatableRoles
|
||||
tags:
|
||||
- workspace
|
||||
parameters:
|
||||
- $ref: "#/components/parameters/WorkspaceId"
|
||||
- name: datatable_name
|
||||
in: path
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
responses:
|
||||
"200":
|
||||
description: usable roles
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
required: [enabled, roles, default_role]
|
||||
properties:
|
||||
enabled:
|
||||
type: boolean
|
||||
roles:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
default_role:
|
||||
type: string
|
||||
|
||||
/w/{workspace}/workspaces/datatable_permissions/{datatable_name}/preview:
|
||||
post:
|
||||
summary: preview the SQL a datatable permissions change would run (admins only)
|
||||
@@ -31472,7 +31503,7 @@ components:
|
||||
name:
|
||||
type: string
|
||||
tenants:
|
||||
description: who may run as this role, as u/<user>, g/<group> or f/<folder>
|
||||
description: who may run as this role, as '*' (everyone), u/<user>, g/<group> or f/<folder>
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
|
||||
@@ -1013,6 +1013,10 @@ pub struct DataTable {
|
||||
/// cannot be created, renamed or dropped.
|
||||
pub const ROOT_DATATABLE_ROLE: &str = "root";
|
||||
|
||||
/// Tenant matching every workspace member. Distinct from listing the `all` group,
|
||||
/// whose membership is bookkeeping that can drift; this one cannot.
|
||||
pub const DATATABLE_TENANT_WILDCARD: &str = "*";
|
||||
|
||||
#[derive(Deserialize, Serialize, Debug, Default, Clone)]
|
||||
pub struct DataTablePermissions {
|
||||
pub enabled: bool,
|
||||
@@ -1140,6 +1144,9 @@ pub fn datatable_pg_role_name(w_id: &str, datatable: &str, role: &str) -> String
|
||||
pub fn can_use_datatable_role(role: &DataTableRole, authed: &crate::db::AuthedRef<'_>) -> bool {
|
||||
*authed.is_admin
|
||||
|| role.tenants.iter().any(|tenant| {
|
||||
if tenant == DATATABLE_TENANT_WILDCARD {
|
||||
return true;
|
||||
}
|
||||
match tenant.split_once('/') {
|
||||
Some(("u", user)) => authed.username == user,
|
||||
Some(("g", group)) => authed.groups.iter().any(|g| g == group),
|
||||
@@ -1202,6 +1209,22 @@ fn datatable_not_found_error(name: &str, datatables: Option<&serde_json::Value>)
|
||||
))
|
||||
}
|
||||
|
||||
/// Split a `<name>?role=<role>` data table reference (the part after
|
||||
/// `datatable://`, or the whole thing for the implicit `datatable` form).
|
||||
///
|
||||
/// The role rides in the reference rather than only in a `-- role` annotation
|
||||
/// because a DuckDB script can attach several data tables under different roles,
|
||||
/// and because generated SQL — the database manager's, for instance — has no
|
||||
/// natural place to put a file-level annotation.
|
||||
pub fn parse_datatable_ref(reference: &str) -> (&str, Option<&str>) {
|
||||
let (name, query) = reference.split_once('?').unwrap_or((reference, ""));
|
||||
let role = query
|
||||
.split('&')
|
||||
.find_map(|param| param.strip_prefix("role="))
|
||||
.filter(|role| !role.is_empty());
|
||||
(name, role)
|
||||
}
|
||||
|
||||
/// Who a data table is being resolved for, when it is permissioned.
|
||||
pub enum DatatableAccess<'a> {
|
||||
/// Internal callers that have already authorized the access (or for which
|
||||
@@ -1225,15 +1248,28 @@ pub enum DatatableAccess<'a> {
|
||||
NoIdentity,
|
||||
}
|
||||
|
||||
/// Resolve a data table's connection credentials without authorizing the caller.
|
||||
/// Callers MUST have already authorized the access; anything running on behalf
|
||||
/// of a user should go through [`get_datatable_resource_from_db`] instead.
|
||||
/// Resolve a data table's connection credentials as `root`, without authorizing
|
||||
/// the caller.
|
||||
///
|
||||
/// Always `root`, never the configured default role: this is the connection that
|
||||
/// owns every object, and the internal machinery built on it — role DDL, migration
|
||||
/// bookkeeping, fork snapshots — needs those privileges. Callers MUST have already
|
||||
/// authorized the access; anything running on behalf of a user should go through
|
||||
/// [`get_datatable_resource_from_db`] instead.
|
||||
pub async fn get_datatable_resource_from_db_unchecked(
|
||||
db: &DB,
|
||||
w_id: &str,
|
||||
name: &str,
|
||||
) -> Result<serde_json::Value> {
|
||||
get_datatable_resource_inner(db, w_id, name, false, None, DatatableAccess::Unchecked).await
|
||||
get_datatable_resource_inner(
|
||||
db,
|
||||
w_id,
|
||||
name,
|
||||
false,
|
||||
Some(ROOT_DATATABLE_ROLE),
|
||||
DatatableAccess::Unchecked,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
/// Resolve a data table's connection credentials as `role` (default `root`),
|
||||
@@ -1264,7 +1300,15 @@ pub async fn get_datatable_replication_resource_from_db_unchecked(
|
||||
w_id: &str,
|
||||
name: &str,
|
||||
) -> Result<serde_json::Value> {
|
||||
get_datatable_resource_inner(db, w_id, name, true, None, DatatableAccess::Unchecked).await
|
||||
get_datatable_resource_inner(
|
||||
db,
|
||||
w_id,
|
||||
name,
|
||||
true,
|
||||
Some(ROOT_DATATABLE_ROLE),
|
||||
DatatableAccess::Unchecked,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
/// Resolve which postgres login the data table should be reached through, and
|
||||
@@ -2488,6 +2532,12 @@ mod tests {
|
||||
// A tenant list is a whitelist, so an empty one grants nobody...
|
||||
let empty = DataTableRole::default();
|
||||
assert!(!can_use_datatable_role(&empty, &alice.to_authed_ref()));
|
||||
// ...while the wildcard grants everyone, whatever they belong to.
|
||||
let wildcard = DataTableRole {
|
||||
tenants: vec![DATATABLE_TENANT_WILDCARD.to_string()],
|
||||
..Default::default()
|
||||
};
|
||||
assert!(can_use_datatable_role(&wildcard, &stranger.to_authed_ref()));
|
||||
// ...except admins, who reach every role so they cannot lock themselves
|
||||
// out of their own data table.
|
||||
let mut admin = authed("alice", &[], &[]);
|
||||
@@ -2554,7 +2604,11 @@ mod tests {
|
||||
},
|
||||
forked_from: None,
|
||||
migrations_enabled: None,
|
||||
permissions: Some(DataTablePermissions { enabled: true, roles: map, default_role: None }),
|
||||
permissions: Some(DataTablePermissions {
|
||||
enabled: true,
|
||||
roles: map,
|
||||
default_role: None,
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2594,6 +2648,22 @@ mod tests {
|
||||
assert_eq!(name, ROOT_DATATABLE_ROLE);
|
||||
}
|
||||
|
||||
/// The internal machinery — role DDL, migration bookkeeping, fork snapshots —
|
||||
/// is built on the unchecked resolution and needs root's privileges, so a
|
||||
/// configured default role must not divert it.
|
||||
#[test]
|
||||
fn the_unchecked_resolution_is_root_even_when_another_role_is_default() {
|
||||
let mut dt = permissioned(&[(ROOT_DATATABLE_ROLE, &[]), ("analyst", &[])]);
|
||||
dt.permissions.as_mut().unwrap().default_role = Some("analyst".to_string());
|
||||
|
||||
let (name, entry) = datatable_role_entry(&dt, "main", Some(ROOT_DATATABLE_ROLE))
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert_eq!(name, ROOT_DATATABLE_ROLE);
|
||||
// root reuses the data table's own connection rather than a created login.
|
||||
assert!(entry.pg_rolename.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn naming_a_role_on_an_unpermissioned_datatable_is_refused() {
|
||||
let mut dt = permissioned(&[(ROOT_DATATABLE_ROLE, &[]), ("analyst", &["u/alice"])]);
|
||||
|
||||
@@ -13,7 +13,7 @@ use windmill_common::error::{to_anyhow, Error, Result};
|
||||
use windmill_common::utils::sanitize_string_from_password;
|
||||
use windmill_common::worker::{get_memory, to_raw_value, Connection, SqlResultCollectionStrategy};
|
||||
use windmill_common::workspaces::{
|
||||
get_datatable_resource_from_db, get_ducklake_from_db_unchecked,
|
||||
get_datatable_resource_from_db, get_ducklake_from_db_unchecked, parse_datatable_ref,
|
||||
strip_fork_reserved_attach_args, DatatableAccess, DucklakeCatalogResourceType,
|
||||
};
|
||||
use windmill_common::PgDatabase;
|
||||
@@ -2542,18 +2542,6 @@ fn fork_defer_statements(
|
||||
Ok(stmts)
|
||||
}
|
||||
|
||||
/// Split a `datatable://<name>?role=<role>` reference. The role rides in the
|
||||
/// reference rather than in a file-level annotation because one DuckDB script can
|
||||
/// attach several data tables, each under a different role.
|
||||
fn parse_datatable_ref(reference: &str) -> (&str, Option<&str>) {
|
||||
let (name, query) = reference.split_once('?').unwrap_or((reference, ""));
|
||||
let role = query
|
||||
.split('&')
|
||||
.find_map(|param| param.strip_prefix("role="))
|
||||
.filter(|role| !role.is_empty());
|
||||
(name, role)
|
||||
}
|
||||
|
||||
async fn transform_attach_datatable(
|
||||
query: &str,
|
||||
conn: &Connection,
|
||||
|
||||
@@ -27,7 +27,9 @@ use windmill_common::error::{self, Error};
|
||||
use windmill_common::worker::{
|
||||
to_raw_value, Connection, SqlAnnotations, SqlResultCollectionStrategy, CLOUD_HOSTED,
|
||||
};
|
||||
use windmill_common::workspaces::{get_datatable_resource_from_db, DatatableAccess};
|
||||
use windmill_common::workspaces::{
|
||||
get_datatable_resource_from_db, parse_datatable_ref, DatatableAccess,
|
||||
};
|
||||
use windmill_common::{PgDatabase, PrepareQueryColumnInfo, PrepareQueryResult, DB};
|
||||
use windmill_parser::{Arg, Typ};
|
||||
use windmill_parser_sql::{
|
||||
@@ -592,10 +594,14 @@ pub async fn do_postgresql(
|
||||
} else {
|
||||
match pg_args.get("database").cloned() {
|
||||
Some(Value::String(db_str)) if db_str.starts_with("datatable://") => {
|
||||
let db_str = db_str.trim_start_matches("datatable://");
|
||||
let reference = db_str.trim_start_matches("datatable://");
|
||||
let (db_str, uri_role) = parse_datatable_ref(reference);
|
||||
// `-- role <name>` rather than an argument: an argument named
|
||||
// `role` would collide with a query parameter of that name.
|
||||
let role = SqlAnnotations::datatable_role(query);
|
||||
// `role` would collide with a query parameter of that name. It
|
||||
// wins over a role carried by the reference, which is how
|
||||
// generated SQL (the database manager's) selects one.
|
||||
let role = SqlAnnotations::datatable_role(query)
|
||||
.or_else(|| uri_role.map(|r| r.to_string()));
|
||||
Some(match conn {
|
||||
Connection::Http(client) => {
|
||||
get_datatable_resource_from_agent_http(
|
||||
|
||||
@@ -68,7 +68,10 @@
|
||||
function getDbSchemasPath(input: DbInput): string {
|
||||
switch (input.type) {
|
||||
case 'database':
|
||||
return input.resourcePath
|
||||
// The role is part of the identity: two roles on the same data table
|
||||
// may see different tables, so their schemas cannot share a cache
|
||||
// entry — and the same string is what selects the role downstream.
|
||||
return input.resourcePath + (input.role ? `?role=${input.role}` : '')
|
||||
case 'ducklake':
|
||||
return 'ducklake://' + input.ducklake
|
||||
}
|
||||
@@ -97,7 +100,7 @@
|
||||
if (input.type == 'database') {
|
||||
$dbSchemas[dbSchemasPath] = await getDbSchemas(
|
||||
input.resourceType,
|
||||
input.resourcePath,
|
||||
getDbSchemasPath(input),
|
||||
ws,
|
||||
(message: string) => sendUserToast(message, true)
|
||||
)
|
||||
|
||||
@@ -58,6 +58,54 @@
|
||||
}))
|
||||
)
|
||||
|
||||
// Roles the *caller* may use, so the picker never offers one that would be
|
||||
// refused. Absent/disabled permissions yield no roles and hide the picker.
|
||||
const usableRoles = resource(
|
||||
() => [ws, uriState.selectedDatatable] as const,
|
||||
async ([workspace, datatable]) => {
|
||||
if (!workspace || !datatable) return undefined
|
||||
try {
|
||||
return await WorkspaceService.listUsableDatatableRoles({ workspace, datatableName: datatable })
|
||||
} catch (e) {
|
||||
// Never leave the drawer waiting on this: fall back to the
|
||||
// unpermissioned shape so it opens and the server picks the role.
|
||||
console.error('Failed to load datatable roles:', e)
|
||||
return { enabled: false, roles: [], default_role: 'root' }
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
// The content must not mount until the role is settled: mounting is what fires
|
||||
// the schema and metadata queries, and a first round sent without a role would
|
||||
// run — and cache — as whatever the server defaults to.
|
||||
const roleSettled = $derived(
|
||||
!uriState.isDatatableInput ||
|
||||
(usableRoles.current !== undefined &&
|
||||
(!usableRoles.current.enabled ||
|
||||
usableRoles.current.roles.length === 0 ||
|
||||
uriState.selectedRole !== undefined))
|
||||
)
|
||||
|
||||
const roleItems = $derived(
|
||||
(usableRoles.current?.roles ?? []).map((r) => ({
|
||||
value: r,
|
||||
label: r === usableRoles.current?.default_role ? `${r} (default)` : r
|
||||
}))
|
||||
)
|
||||
|
||||
// Settle the role before anything queries the data table: the schema and
|
||||
// metadata fetches run as whatever role the input carries, so leaving it unset
|
||||
// until the user touches the picker would send the first — and cached — round
|
||||
// of queries as a role they may not be allowed to use.
|
||||
$effect(() => {
|
||||
const roles = usableRoles.current
|
||||
if (!roles?.enabled || uriState.selectedRole !== undefined) return
|
||||
const effective = roles.roles.includes(roles.default_role)
|
||||
? roles.default_role
|
||||
: roles.roles[0]
|
||||
if (effective) untrack(() => (uriState.selectedRole = effective))
|
||||
})
|
||||
|
||||
// Refetch datatables when switching to a datatable input
|
||||
$effect(() => {
|
||||
if (uriState.isDatatableInput) {
|
||||
@@ -176,8 +224,8 @@
|
||||
noPadding
|
||||
id="db-manager-drawer"
|
||||
>
|
||||
{#if uriState.effectiveInput && ws}
|
||||
{#key uriState.selectedDatatable}
|
||||
{#if uriState.effectiveInput && ws && roleSettled}
|
||||
{#key `${uriState.selectedDatatable}~${uriState.selectedRole ?? ''}`}
|
||||
<DBManagerContent
|
||||
bind:this={dbManagerContent}
|
||||
input={uriState.effectiveInput}
|
||||
@@ -205,6 +253,15 @@
|
||||
size="md"
|
||||
/>
|
||||
{/if}
|
||||
{#if usableRoles.current?.enabled && roleItems.length > 0}
|
||||
<Select
|
||||
transformInputSelectedText={(s) => `Role: ${s}`}
|
||||
items={roleItems}
|
||||
bind:value={uriState.selectedRole}
|
||||
placeholder="Role"
|
||||
size="md"
|
||||
/>
|
||||
{/if}
|
||||
{/if}
|
||||
{/snippet}
|
||||
</DBManagerContent>
|
||||
|
||||
@@ -5,7 +5,7 @@ import { isDbType } from './dbTypes'
|
||||
|
||||
/**
|
||||
* Single URL param `dbm` encodes the full DB manager state:
|
||||
* firstSegment~path~schema.table
|
||||
* firstSegment~path~schema.table~role
|
||||
*
|
||||
* firstSegment:
|
||||
* datatable – database with datatable:// resource (resourceType always postgresql)
|
||||
@@ -26,6 +26,10 @@ import { isDbType } from './dbTypes'
|
||||
* datatable~main~.customers (schema "public" implied)
|
||||
* ducklake~main~.orders (schema "main" implied)
|
||||
* postgresql~$res:u/user/my_pg~public.customers
|
||||
* datatable~main~~analyst (role only, no schema/table)
|
||||
*
|
||||
* role (fourth segment, optional, data tables only):
|
||||
* the data table role to connect as; omitted means its default role.
|
||||
*/
|
||||
|
||||
const dbManagerSchema = z.object({
|
||||
@@ -38,6 +42,7 @@ interface ParsedDbm {
|
||||
resType?: string
|
||||
schema?: string
|
||||
table?: string
|
||||
role?: string
|
||||
}
|
||||
|
||||
function parseDbm(raw: unknown): ParsedDbm | null {
|
||||
@@ -48,6 +53,7 @@ function parseDbm(raw: unknown): ParsedDbm | null {
|
||||
const firstSeg = parts[0]
|
||||
const path = parts[1]
|
||||
const schemaTable = parts[2] ?? ''
|
||||
const role = parts[3] || undefined
|
||||
|
||||
let type: ParsedDbm['type']
|
||||
let resType: string | undefined
|
||||
@@ -81,7 +87,7 @@ function parseDbm(raw: unknown): ParsedDbm | null {
|
||||
schema = defaultSchemas[type]
|
||||
}
|
||||
|
||||
return { type, path, resType, schema, table }
|
||||
return { type, path, resType, schema, table, role }
|
||||
}
|
||||
|
||||
const defaultSchemas: Record<string, string> = { datatable: 'public', ducklake: 'main' }
|
||||
@@ -97,6 +103,7 @@ function buildDbm(p: ParsedDbm): string {
|
||||
} else if (schema) {
|
||||
schemaTable = `${schema}.`
|
||||
}
|
||||
if (p.role) return `${firstSeg}~${p.path}~${schemaTable}~${p.role}`
|
||||
return schemaTable ? `${firstSeg}~${p.path}~${schemaTable}` : `${firstSeg}~${p.path}`
|
||||
}
|
||||
|
||||
@@ -105,6 +112,8 @@ export interface DbManagerUriState {
|
||||
readonly effectiveInput: DbInput | undefined
|
||||
readonly isDatatableInput: boolean
|
||||
selectedDatatable: string | undefined
|
||||
/** Data table role the drawer connects as; undefined means its default. */
|
||||
selectedRole: string | undefined
|
||||
selectedSchema: string | undefined
|
||||
selectedTable: string | undefined
|
||||
readonly open: boolean
|
||||
@@ -137,6 +146,7 @@ export function useDbManagerUriState(): DbManagerUriState {
|
||||
type: 'database' as const,
|
||||
resourceType: resType as DbType,
|
||||
resourcePath: parsed.type === 'datatable' ? `datatable://${parsed.path}` : parsed.path,
|
||||
role: parsed.type === 'datatable' ? parsed.role : undefined,
|
||||
specificSchema: parsed.schema,
|
||||
specificTable: parsed.table
|
||||
}
|
||||
@@ -194,7 +204,14 @@ export function useDbManagerUriState(): DbManagerUriState {
|
||||
return parsed?.type === 'datatable' ? parsed.path : undefined
|
||||
},
|
||||
set selectedDatatable(v: string | undefined) {
|
||||
if (v) updateField({ path: v })
|
||||
// Roles are per data table, so the current one cannot carry over.
|
||||
if (v) updateField({ path: v, role: undefined })
|
||||
},
|
||||
get selectedRole() {
|
||||
return parsed?.type === 'datatable' ? parsed.role : undefined
|
||||
},
|
||||
set selectedRole(v: string | undefined) {
|
||||
updateField({ role: v })
|
||||
},
|
||||
get selectedSchema() {
|
||||
return parsed?.schema
|
||||
|
||||
@@ -572,7 +572,9 @@ export function getDbType(input: DbInput): DbType {
|
||||
export function getDatabaseArg(input: DbInput | undefined) {
|
||||
if (input?.type === 'database') {
|
||||
if (input.resourcePath.startsWith('datatable://')) {
|
||||
return { database: input.resourcePath }
|
||||
// The role rides in the reference: generated SQL has no natural place
|
||||
// for the `-- role` annotation a hand-written script would use.
|
||||
return { database: input.resourcePath + (input.role ? `?role=${input.role}` : '') }
|
||||
} else {
|
||||
return { database: '$res:' + input.resourcePath }
|
||||
}
|
||||
|
||||
@@ -3,6 +3,9 @@ export type DbInput =
|
||||
type: 'database'
|
||||
resourceType: DbType
|
||||
resourcePath: string
|
||||
/** Data table role to connect as. Only meaningful for a `datatable://`
|
||||
* resourcePath; absent means the data table's default role. */
|
||||
role?: string
|
||||
specificSchema?: string
|
||||
specificTable?: string
|
||||
}
|
||||
|
||||
@@ -38,6 +38,9 @@
|
||||
} = $props()
|
||||
|
||||
const ROOT_ROLE = 'root'
|
||||
// Matches every workspace member, unlike the `all` group whose membership is
|
||||
// bookkeeping that can drift.
|
||||
const WILDCARD_TENANT = '*'
|
||||
|
||||
// Stable client-side id so a rename (A -> B) is sent as a rename rather than
|
||||
// read as a delete plus an add, which would drop the role's grants.
|
||||
@@ -66,6 +69,7 @@
|
||||
FolderService.listFolderNames({ workspace: ws })
|
||||
])
|
||||
return [
|
||||
{ value: WILDCARD_TENANT, label: 'Everyone', group: 'Anyone in the workspace' },
|
||||
...users.map((u) => ({ value: `u/${u}`, label: u, group: 'Users' })),
|
||||
...groups.map((g) => ({ value: `g/${g}`, label: g, group: 'Groups' })),
|
||||
...folders.map((f) => ({ value: `f/${f}`, label: f, group: 'Folders' }))
|
||||
@@ -257,8 +261,8 @@
|
||||
<Cell head>
|
||||
Default
|
||||
<Tooltip>
|
||||
The role a script gets when it names none — no `-- role` annotation, no
|
||||
`?role=` in an ATTACH. Callers still have to be one of its tenants.
|
||||
The role a script gets when it names none — no `-- role` annotation, no `?role=`
|
||||
in an ATTACH. Callers still have to be one of its tenants.
|
||||
</Tooltip>
|
||||
</Cell>
|
||||
<Cell head last />
|
||||
@@ -286,7 +290,7 @@
|
||||
items={tenantItems.current ?? []}
|
||||
bind:value={role.tenants}
|
||||
groupBy={(item) => item.group}
|
||||
placeholder="Everyone denied — add users, groups or folders"
|
||||
placeholder="Nobody — add Everyone, users, groups or folders"
|
||||
/>
|
||||
</Cell>
|
||||
<Cell class="w-20 align-top">
|
||||
|
||||
Reference in New Issue
Block a user