mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-09-21 00:02:30 +00:00
feat(datatables): put a data table's connection under Postgres roles
A data table backed by the instance database resolved to exactly one Postgres connection, `custom_instance_user`, for everyone who could reach it at all. There was no way to say this job reads, that one writes, this one never sees the salaries table. A data table role is now a real Postgres login on the cluster, defined once for the instance by a superadmin and named exactly as they named it. A script that declares `-- role analytics` connects as `analytics`, and Postgres decides what it may touch — grants are ordinary SQL. Windmill answers only "may this caller ask for this role", from the tenant lists on the data table entry: `u/alice`, `g/analysts`, `f/finance` or `*`. A data table with no `permissions` block behaves exactly as before. Everything that opens a connection on someone's behalf goes through one chokepoint, `get_datatable_resource_from_db`, which takes the identity explicitly and fails closed when there is none. The role logs in as itself — never `SET ROLE`, which a script could `RESET ROLE` its way out of. A fork's data table entry becomes a pointer at the workspace that governs it rather than a copy of it. The settings clone used to hand a fork a byte-identical entry naming the parent's database, which a fork admin could edit to grant themselves `admin` there; a pointer has nothing local to edit, and its tenants are evaluated as a member of the governing workspace, by email. `permissions` is stripped from the workspace export and ignored on import: tenants name principals of one workspace, and a settings push is not where an access decision should be made. Operations that see the whole database whatever the roles grant stay with the governing workspace's admins: editing the roles, a migration that declares none, and opening a replication stream for a Postgres trigger or capture. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012ti5HyeTikPMYyW8YSdiHR
This commit is contained in:
co-authored by
Claude Opus 5
parent
3d08197182
commit
22b3e69c07
@@ -1430,16 +1430,19 @@ class Windmill:
|
||||
},
|
||||
)
|
||||
|
||||
def datatable(self, name: str = "main"):
|
||||
def datatable(self, name: str = "main", *, role: Optional[str] = None):
|
||||
"""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.
|
||||
Only meaningful on a data table under roles, and only for a role you are a
|
||||
tenant of.
|
||||
|
||||
Returns:
|
||||
DataTableClient instance
|
||||
"""
|
||||
return DataTableClient(self, name)
|
||||
return DataTableClient(self, name, role=role)
|
||||
|
||||
def ducklake(self, name: str = "main"):
|
||||
"""Get a DuckLake client for DuckDB queries.
|
||||
@@ -2278,16 +2281,17 @@ def username_to_email(username: str) -> str:
|
||||
|
||||
|
||||
@init_global_client
|
||||
def datatable(name: str = "main") -> DataTableClient:
|
||||
def datatable(name: str = "main", *, role: Optional[str] = None) -> 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.
|
||||
|
||||
Returns:
|
||||
DataTableClient instance
|
||||
"""
|
||||
return _client.datatable(name)
|
||||
return _client.datatable(name, role=role)
|
||||
|
||||
@init_global_client
|
||||
def ducklake(name: str = "main") -> DucklakeClient:
|
||||
@@ -2362,17 +2366,28 @@ def stream_result(stream) -> None:
|
||||
for text in stream:
|
||||
append_to_result_stream(text)
|
||||
|
||||
# Interpolated into a `-- role <name>` line, so a value carrying a newline could append
|
||||
# statements of its own. Mirrors the server's own role-name rule.
|
||||
_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):
|
||||
def __init__(self, client: Windmill, name: str, role: Optional[str] = None):
|
||||
"""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
|
||||
"""
|
||||
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"
|
||||
)
|
||||
self.client = client
|
||||
self.role = role
|
||||
self.name, self.schema = parse_sql_client_name(name)
|
||||
def query(self, sql: str, *args) -> SqlQuery:
|
||||
"""Execute a SQL query against the DataTable.
|
||||
@@ -2393,6 +2408,9 @@ class DataTableClient:
|
||||
args_dict[f"arg{i+1}"] = arg
|
||||
args_def += f"-- ${i+1} arg{i+1} ({infer_sql_type(arg)})\n"
|
||||
sql = args_def + sql
|
||||
# Must lead: the executor's annotation parser stops at the first non-comment line.
|
||||
if self.role is not None:
|
||||
sql = f"-- role {self.role}\n" + sql
|
||||
return SqlQuery(
|
||||
sql,
|
||||
lambda sql: self.client.run_inline_script_preview(
|
||||
|
||||
Reference in New Issue
Block a user