mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-10-13 16:02:59 +00:00
feat: let a data table have no default role
A connection naming no role then holds its viewer's roles only and is refused without any. The roles drawer offers the choice, states what an unnamed connection gets for the current settings, says who gets admin while it is the default, and notes who owns a table created over several roles. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
a0b52744de
commit
cc05821989
@@ -1 +1 @@
|
||||
7e756258eeaa1a613510d5c4fc2184239f3b395e
|
||||
a5def184a29fffab8ed42c70c5a7445b2b3cbe98
|
||||
|
||||
@@ -1969,6 +1969,38 @@ async fn a_connection_naming_no_role_holds_the_default_one_and_the_viewers(
|
||||
let admin = connect(None, Some("test2@windmill.dev")).await?;
|
||||
assert_eq!(admin, connect(Some("admin"), None).await?);
|
||||
assert!(!admin["user"].as_str().unwrap().starts_with("wm_"));
|
||||
|
||||
// With no default role the viewer's roles are all there is, the one open to everyone
|
||||
// included, and a connection left with none is refused rather than resolved as anything.
|
||||
set_permissions("$none".to_string(), roles.clone()).await?;
|
||||
assert_eq!(
|
||||
connect(None, Some("test3@windmill.dev")).await?,
|
||||
two,
|
||||
"no default changes nothing for a viewer the roles name"
|
||||
);
|
||||
assert!(connect(None, None).await.is_err());
|
||||
// The same holds for a default the job's own identity is not a tenant of.
|
||||
let mut closed = roles.clone();
|
||||
closed[ids[0].as_str()] = json!({"tenants": []});
|
||||
set_permissions(ids[0].clone(), closed).await?;
|
||||
let as_member = |viewer: Option<&'static str>| {
|
||||
get_datatable_resource_from_db(
|
||||
&db,
|
||||
"test-workspace",
|
||||
"main",
|
||||
None,
|
||||
DatatableAccess::PermissionedAs {
|
||||
permissioned_as: "u/test-user-3",
|
||||
email: "test3@windmill.dev",
|
||||
end_user_email: viewer,
|
||||
},
|
||||
)
|
||||
};
|
||||
assert_eq!(
|
||||
as_member(Some("test3@windmill.dev")).await?["user"],
|
||||
names[1].as_str()
|
||||
);
|
||||
assert!(as_member(None).await.is_err());
|
||||
set_permissions(ids[0].clone(), roles).await?;
|
||||
|
||||
// A set login nobody connects with any more is dropped, a recent one is kept, and a
|
||||
|
||||
@@ -5568,6 +5568,9 @@ paths:
|
||||
type: boolean
|
||||
default_role:
|
||||
type: string
|
||||
description: >-
|
||||
The id of one of `roles`, `admin` (the default), or `$none` for no default
|
||||
role: a connection that names no role then holds its viewer's roles only.
|
||||
roles:
|
||||
type: array
|
||||
items:
|
||||
@@ -34782,6 +34785,7 @@ components:
|
||||
type: boolean
|
||||
default_role:
|
||||
type: string
|
||||
description: A role id, `admin`, or `$none` when the data table has no default role.
|
||||
roles:
|
||||
type: array
|
||||
items:
|
||||
|
||||
@@ -72,6 +72,11 @@ impl DatatableRoleCluster {
|
||||
/// created, renamed or dropped.
|
||||
pub const ADMIN_DATATABLE_ROLE: &str = "admin";
|
||||
|
||||
/// Stands in a data table's `default_role` for "none": a connection that names no role then holds
|
||||
/// its viewer's roles only, and is refused without any. `$` is outside both the role-name charset
|
||||
/// and the catalog ids, and a server predating it refuses it as a role the data table lacks.
|
||||
pub const NO_DEFAULT_DATATABLE_ROLE: &str = "$none";
|
||||
|
||||
/// The login the admin connection uses, and the role every created role is granted to — that
|
||||
/// membership is what later lets it `ALTER ... OWNER TO` a role and drop it.
|
||||
pub const CUSTOM_INSTANCE_USER: &str = "custom_instance_user";
|
||||
|
||||
@@ -1564,7 +1564,8 @@ pub struct DataTableReference {
|
||||
/// The access decision for one data table: which role a caller gets, and who may ask for each.
|
||||
#[derive(Deserialize, Serialize, Debug, Clone, Default)]
|
||||
pub struct DataTablePermissions {
|
||||
/// A role id from the instance catalog, or `admin`. Absent = `admin`.
|
||||
/// A role id from the instance catalog, `admin`, or
|
||||
/// [`crate::datatable_roles::NO_DEFAULT_DATATABLE_ROLE`]. Absent = `admin`.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub default_role: Option<String>,
|
||||
/// Keyed by instance role id, plus the reserved `admin` key. A role absent from this map
|
||||
@@ -2217,8 +2218,9 @@ pub async fn can_use_datatable_role_in_governing_workspace(
|
||||
/// back to admin.
|
||||
///
|
||||
/// A default role other than `admin` is a floor rather than the whole connection: a resolution
|
||||
/// naming no role also holds the roles whose tenant lists name the job's viewer, as one login that
|
||||
/// is a member of each. Being an admin adds none and `admin` is never among them, so a viewer
|
||||
/// naming no role holds it when the job's own identity may use it, plus the roles whose tenant
|
||||
/// lists name the job's viewer, as one login that is a member of each. With no role at all it is
|
||||
/// refused. Being an admin adds none of the viewer's and `admin` is never among them, so a viewer
|
||||
/// cannot be handed the connection that owns the tables and bypasses row-level security. A named
|
||||
/// role is that role alone.
|
||||
pub async fn get_datatable_resource_from_db(
|
||||
|
||||
Generated
+2
-2
@@ -6557,7 +6557,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, plus the roles of whoever is using the app unless that default is \`admin\`.
|
||||
**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, if it has one, plus the roles of whoever is using the app unless that default is \`admin\`.
|
||||
|
||||
## SQL Migrations (sql_to_apply/)
|
||||
|
||||
@@ -6863,7 +6863,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 call that passes no role connects as the data table's default role and, unless that default is \`admin\`, also as every role the person using the app is a tenant of. Row-level security policies written as \`pg_has_role(current_user, '<role>', 'member')\` then return that viewer's rows with nothing added to the runnable. Passing a role pins the connection to that one role for every viewer, so leave it out where rows should differ per viewer.
|
||||
6. **Per-viewer rows**: a data table with no entry in \`data.roles\` is called with no role. Unless its default role is \`admin\`, the connection then holds the default role, if it has one, plus every role the person using the app is a tenant of, and is refused when that is none. Row-level security policies written as \`pg_has_role(current_user, '<role>', 'member')\` return that viewer's rows with nothing added to the runnable. A role pins the connection to that one role for every viewer, so where rows should differ per viewer give the data table no \`data.roles\` entry.
|
||||
|
||||
\`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,10 +1,17 @@
|
||||
# Data table connections and the viewer's roles
|
||||
|
||||
A data table under roles (Enterprise Edition) resolves a connection that names a role to that
|
||||
role alone. A connection that names none gets the data table's default role, and when that
|
||||
default is not `admin` it is a floor: the connection also holds **every role the job's viewer is
|
||||
a tenant of**. Postgres row-level security can then answer per viewer from a backend runnable
|
||||
that runs as the app's publisher, with nothing written in the runnable.
|
||||
role alone. A connection that names none is resolved from the data table's default role:
|
||||
|
||||
| Default role | A connection naming no role holds |
|
||||
|---|---|
|
||||
| `admin` (until changed) | `admin`, as before viewer roles existed |
|
||||
| another role | that role when the job's own identity may use it, plus **every role the job's viewer is a tenant of** |
|
||||
| none (`NO_DEFAULT_DATATABLE_ROLE`) | the viewer's roles only |
|
||||
|
||||
A connection left with no role at all is refused. Postgres row-level security can then answer
|
||||
per viewer from a backend runnable that runs as the app's publisher, with nothing written in the
|
||||
runnable.
|
||||
|
||||
Policies test membership, which is true for a role's own login too, so one policy serves a named
|
||||
connection and an unnamed one:
|
||||
@@ -29,14 +36,21 @@ A policy names its role as text, so renaming the role breaks it.
|
||||
|
||||
- **A default of `admin` is not a floor.** `admin` owns the tables and bypasses row-level
|
||||
security, so nothing could be added to it, and it is the default until someone changes it.
|
||||
Such a data table resolves exactly as it did before viewer roles existed.
|
||||
- **The default role is still tenant-checked against the job's own identity**, the publisher for
|
||||
a publisher-mode app. The viewer's roles are added to a connection that was allowed, never a
|
||||
way into one that was not.
|
||||
- **No viewer means the default role alone, and nothing is refused.** A job without a recorded
|
||||
Such a data table resolves exactly as it did before viewer roles existed. Nothing ever sets a
|
||||
default back to `admin`: removing or deleting the default role leaves none.
|
||||
- **The default role is tenant-checked against the job's own identity**, the publisher for a
|
||||
publisher-mode app, and held only when that passes. The viewer's roles are added either way.
|
||||
- **No role at all is a refusal, never an empty answer.** There is no login that would read
|
||||
nothing: what a role sees is up to each table's grants and policies. A job without a recorded
|
||||
viewer (anonymous app viewer, schedule, trigger, a tool job started by an AI agent step), or
|
||||
one whose viewer is not a member of the governing workspace, connects as it always did. Access
|
||||
only grows with identity.
|
||||
whose viewer is not a member of the governing workspace, therefore needs a default it may use
|
||||
or a named role.
|
||||
- **A set login owns what it creates.** Postgres gives a new object to `current_user`, and
|
||||
default privileges go by the creating role, so a table created over a connection that holds
|
||||
several roles belongs to that set's login: no other role or viewer reaches it, and it passes to
|
||||
`custom_instance_user` when the login is swept. Unnamed connections are for rows. To create an
|
||||
object, name a role or run `SET ROLE <role>` first, which a set login may do for any role it
|
||||
holds. Migrations are not affected: one that declares no role runs as `admin`.
|
||||
- **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.
|
||||
@@ -53,9 +67,11 @@ A policy names its role as text, so renaming the role breaks it.
|
||||
comes back to it. The prefix is reserved in `validate_role_name`.
|
||||
- **Resolving stays cheap where no viewer could add anything.** The viewer is only looked up when
|
||||
some role other than the default and `admin` has tenants on that data table.
|
||||
- **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.
|
||||
- **A `datatable_role_set` row with a password names a login that can authenticate with it.** 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,
|
||||
and so is one whose row has an empty password: that is what a sweep leaves when it could
|
||||
neither drop a login nor let it back in, so the next sweep still finds it.
|
||||
- **Set logins are bounded by use, not by the number of role combinations.** One exists only for
|
||||
a set some viewer actually connected with, so there are at most as many as (viewer, data table)
|
||||
pairs. `last_used_at` is recorded at most once a day, and `sweep_unused_role_set_logins`, run
|
||||
@@ -65,7 +81,8 @@ A policy names its role as text, so renaming the role breaks it.
|
||||
- **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.
|
||||
- **A worker or server that predates this connects as the default role alone**, so a mixed fleet
|
||||
shows a viewer fewer rows, never more.
|
||||
shows a viewer fewer rows, never more. It refuses a data table with no default role, whose
|
||||
marker it reads as a role the data table lacks.
|
||||
|
||||
## Trust
|
||||
|
||||
|
||||
@@ -45,7 +45,12 @@
|
||||
import { favoriteManager } from './sidebar/FavoriteMenu.svelte'
|
||||
import DatatableRoleBadge from './DatatableRoleBadge.svelte'
|
||||
import type { AclTarget, Asset, DataTableTables } from '$lib/gen'
|
||||
import { ADMIN_DATATABLE_ROLE, datatableNameTakesRole, type DatatableRowAction } from './dbTypes'
|
||||
import {
|
||||
ADMIN_DATATABLE_ROLE,
|
||||
datatableNameTakesRole,
|
||||
namedDefaultRole,
|
||||
type DatatableRowAction
|
||||
} from './dbTypes'
|
||||
import TextInput from './text_input/TextInput.svelte'
|
||||
import Checkbox from './common/checkbox/Checkbox.svelte'
|
||||
import DbSchemaDiagram from './dbdiagram/DbSchemaDiagram.svelte'
|
||||
@@ -417,7 +422,10 @@
|
||||
if (!entry?.permissioned || !datatableNameTakesRole(datatable)) return undefined
|
||||
const roles = entry.usable_roles
|
||||
if (roles.length === 0) return { kind: 'none' }
|
||||
const role = (datatable === currentDatatable ? currentRole : undefined) ?? entry.default_role
|
||||
const role =
|
||||
(datatable === currentDatatable ? currentRole : undefined) ??
|
||||
namedDefaultRole(entry.default_role) ??
|
||||
roles[0]
|
||||
if (roles.length === 1 && roles[0] === ADMIN_DATATABLE_ROLE && role === ADMIN_DATATABLE_ROLE) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
@@ -32,6 +32,9 @@ export const isDbType = (str?: string): str is DbType => !!str && dbTypes.includ
|
||||
|
||||
/** The role every data table has: the one it connects as when it is not under roles. */
|
||||
export const ADMIN_DATATABLE_ROLE = 'admin'
|
||||
/** What `default_role` holds for a data table with no default role. Mirrors the backend's
|
||||
* `NO_DEFAULT_DATATABLE_ROLE`. */
|
||||
export const NO_DEFAULT_DATATABLE_ROLE = '$none'
|
||||
|
||||
/** What the server accepts in `-- role <name>` and `?role=<name>`. */
|
||||
export function isDatatableRoleName(name: string): boolean {
|
||||
@@ -52,7 +55,13 @@ export function defaultMigrationRole(
|
||||
permissioned: boolean | undefined,
|
||||
defaultRole: string | undefined
|
||||
): string | undefined {
|
||||
return permissioned && !datatableNameTakesRole(name) ? defaultRole : undefined
|
||||
return permissioned && !datatableNameTakesRole(name) ? namedDefaultRole(defaultRole) : undefined
|
||||
}
|
||||
|
||||
/** A data table's default role, or `undefined` when it has none: the server reports that as
|
||||
* `NO_DEFAULT_DATATABLE_ROLE`, which is not a role anything can connect as. */
|
||||
export function namedDefaultRole(defaultRole: string | undefined): string | undefined {
|
||||
return defaultRole === NO_DEFAULT_DATATABLE_ROLE ? undefined : defaultRole
|
||||
}
|
||||
|
||||
/** `datatable://<name>`, with `?role=<role>` when a role is named. Throws rather than build a
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
ADMIN_DATATABLE_ROLE,
|
||||
datatableNameTakesRole,
|
||||
defaultMigrationRole,
|
||||
namedDefaultRole,
|
||||
type DbInput
|
||||
} from '../dbTypes'
|
||||
import type { PendingRowAction, SelectedTable } from '../DBManager.svelte'
|
||||
@@ -173,15 +174,15 @@
|
||||
return rolesOfCurrent?.permissioned ? selectedRole : undefined
|
||||
}
|
||||
const entry = datatableTree.current.find((t) => t.datatable_name === datatable)
|
||||
return entry?.permissioned ? entry.default_role : undefined
|
||||
return entry?.permissioned ? namedDefaultRole(entry.default_role) : undefined
|
||||
}
|
||||
|
||||
function defaultRoleOf(datatable: string): string | undefined {
|
||||
if (datatable === selectedDatatable && rolesOfCurrent?.permissioned) {
|
||||
return rolesOfCurrent.default_role
|
||||
return namedDefaultRole(rolesOfCurrent.default_role)
|
||||
}
|
||||
const entry = datatableTree.current.find((t) => t.datatable_name === datatable)
|
||||
return entry?.permissioned ? entry.default_role : undefined
|
||||
return entry?.permissioned ? namedDefaultRole(entry.default_role) : undefined
|
||||
}
|
||||
|
||||
const tableDatatable = (t: SelectedTable) => t.datatable ?? selectedDatatable
|
||||
|
||||
@@ -23,7 +23,7 @@
|
||||
import { superadmin } from '$lib/stores'
|
||||
import { sendUserToast } from '$lib/toast'
|
||||
import { deepEqual } from 'fast-equals'
|
||||
import { ADMIN_DATATABLE_ROLE, isDatatableRoleName } from '../dbTypes'
|
||||
import { ADMIN_DATATABLE_ROLE, NO_DEFAULT_DATATABLE_ROLE, isDatatableRoleName } from '../dbTypes'
|
||||
import PgAclEditor from '../datatableAcl/PgAclEditor.svelte'
|
||||
import ConfirmationModal from '../common/confirmationModal/ConfirmationModal.svelte'
|
||||
import { createAsyncConfirmationModal } from '../common/confirmationModal/asyncConfirmationModal.svelte'
|
||||
@@ -222,18 +222,19 @@
|
||||
if (defaultRoleId === role.id) {
|
||||
const confirmed = await confirmationModal.ask({
|
||||
title: 'Remove the default role?',
|
||||
children: `Jobs that name no role connect as ${role.name ?? role.id}. Without it they connect as admin, which reads every row and ignores grants and row-level security.`,
|
||||
confirmationText: 'Remove and default to admin',
|
||||
children: `Jobs that name no role connect as ${role.name ?? role.id}. Without it the data table has no default role: they connect with the roles of the person running them, and are refused when that person has none.`,
|
||||
confirmationText: 'Remove',
|
||||
type: 'danger'
|
||||
})
|
||||
if (!confirmed) return
|
||||
defaultRoleId = ADMIN_DATATABLE_ROLE
|
||||
defaultRoleId = NO_DEFAULT_DATATABLE_ROLE
|
||||
}
|
||||
roles = roles.filter((r) => r.id !== role.id)
|
||||
}
|
||||
|
||||
const defaultRoleName = $derived(roles.find((r) => r.id === defaultRoleId)?.name ?? defaultRoleId)
|
||||
const defaultsToAdmin = $derived(defaultRoleId === ADMIN_DATATABLE_ROLE)
|
||||
const hasNoDefault = $derived(defaultRoleId === NO_DEFAULT_DATATABLE_ROLE)
|
||||
const hasOtherRoles = $derived(roles.some((r) => r.id !== ADMIN_DATATABLE_ROLE))
|
||||
|
||||
async function save() {
|
||||
@@ -405,8 +406,9 @@
|
||||
Default
|
||||
<Tooltip>
|
||||
The role a job gets when it names none: no `-- role` annotation, no `?role=` in
|
||||
the reference. Callers still have to be one of its tenants. Unless it is admin,
|
||||
the other roles the person running the job is a tenant of are added to it.
|
||||
the reference. There is always one choice: admin until changed, another role, or
|
||||
none. Unless it is admin, the roles the person running the job is a tenant of
|
||||
are added to it.
|
||||
</Tooltip>
|
||||
</Cell>
|
||||
<Cell head last />
|
||||
@@ -454,6 +456,29 @@
|
||||
</Cell>
|
||||
</Row>
|
||||
{/each}
|
||||
<Row>
|
||||
<Cell first class="w-56 align-top">
|
||||
<span class="text-xs text-secondary italic">No default role</span>
|
||||
</Cell>
|
||||
<Cell class="align-top">
|
||||
<span class="text-xs text-secondary">
|
||||
Jobs that name no role get only the roles of the person running them.
|
||||
</span>
|
||||
</Cell>
|
||||
<Cell class="w-20 align-top">
|
||||
<div class="flex justify-center">
|
||||
<Checkbox
|
||||
checked={hasNoDefault}
|
||||
disabled={!editable}
|
||||
title="Use no role when a job names none"
|
||||
onChange={() => {
|
||||
defaultRoleId = NO_DEFAULT_DATATABLE_ROLE
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</Cell>
|
||||
<Cell last class="w-10 align-top" />
|
||||
</Row>
|
||||
{#if editable}
|
||||
<Row>
|
||||
<Cell colspan={4} class="pt-2 pb-2">
|
||||
@@ -482,17 +507,35 @@
|
||||
</DataTable>
|
||||
|
||||
{#if defaultsToAdmin && hasOtherRoles}
|
||||
<Alert type="warning" title="Jobs that name no role connect as admin" size="xs">
|
||||
admin owns every table, so it reads every row and ignores grants and row-level
|
||||
security. The roles above only apply to jobs that name one. Make another role the
|
||||
default to have them apply to every job.
|
||||
<Alert type="warning" title="The default role is admin" size="xs">
|
||||
A job that names no role connects as admin when it runs as a workspace admin or as one
|
||||
of admin's tenants, which covers every viewer of an app an admin published. admin owns
|
||||
every table: it reads every row and ignores grants and row-level security. Everyone
|
||||
else's jobs that name no role are refused. Choose another default, or none, to have
|
||||
the roles above apply to them.
|
||||
</Alert>
|
||||
{:else if !defaultsToAdmin}
|
||||
<span class="text-xs text-secondary">
|
||||
A job that names no role connects as
|
||||
<span class="font-mono">{defaultRoleName}</span>, plus every other role here that the
|
||||
person running it is a tenant of. A job that names a role connects as that role alone.
|
||||
</span>
|
||||
<div class="flex flex-col gap-1 text-xs text-secondary">
|
||||
{#if hasNoDefault}
|
||||
<span>
|
||||
A job that names no role connects with the roles here that the person running it
|
||||
is a tenant of. It is refused when they have none, and so is a job with nobody
|
||||
behind it: a schedule, a trigger, an anonymous app viewer.
|
||||
</span>
|
||||
{:else}
|
||||
<span>
|
||||
A job that names no role connects as
|
||||
<span class="font-mono">{defaultRoleName}</span> when it may use it, plus every other
|
||||
role here that the person running it is a tenant of. With neither, it is refused.
|
||||
</span>
|
||||
{/if}
|
||||
<span>A job that names a role connects as that role alone.</span>
|
||||
<span>
|
||||
A table created over a connection that holds several roles belongs to that
|
||||
combination of roles alone. Run <span class="font-mono">SET ROLE</span> to one role before
|
||||
creating it, or name a role.
|
||||
</span>
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
@@ -1131,7 +1131,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 call that passes no role connects as the data table's default role and, unless that default is \`admin\`, also as every role the person using the app is a tenant of. Row-level security policies written as \`pg_has_role(current_user, '<role>', 'member')\` then return that viewer's rows with nothing added to the runnable. Passing a role pins the connection to that one role for every viewer, so leave it out where rows should differ per viewer.
|
||||
6. **Per-viewer rows**: a data table with no entry in \`data.roles\` is called with no role. Unless its default role is \`admin\`, the connection then holds the default role, if it has one, plus every role the person using the app is a tenant of, and is refused when that is none. Row-level security policies written as \`pg_has_role(current_user, '<role>', 'member')\` return that viewer's rows with nothing added to the runnable. A role pins the connection to that one role for every viewer, so where rows should differ per viewer give the data table no \`data.roles\` entry.
|
||||
|
||||
### Querying in TypeScript (Bun/Deno)
|
||||
|
||||
|
||||
@@ -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, plus the roles of whoever is using the app unless that default is `admin`.
|
||||
**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, if it has one, plus the roles of whoever is using the app unless that default is `admin`.
|
||||
|
||||
## SQL Migrations (sql_to_apply/)
|
||||
|
||||
@@ -494,7 +494,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 call that passes no role connects as the data table's default role and, unless that default is `admin`, also as every role the person using the app is a tenant of. Row-level security policies written as `pg_has_role(current_user, '<role>', 'member')` then return that viewer's rows with nothing added to the runnable. Passing a role pins the connection to that one role for every viewer, so leave it out where rows should differ per viewer.
|
||||
6. **Per-viewer rows**: a data table with no entry in `data.roles` is called with no role. Unless its default role is `admin`, the connection then holds the default role, if it has one, plus every role the person using the app is a tenant of, and is refused when that is none. Row-level security policies written as `pg_has_role(current_user, '<role>', 'member')` return that viewer's rows with nothing added to the runnable. A role pins the connection to that one role for every viewer, so where rows should differ per viewer give the data table no `data.roles` entry.
|
||||
|
||||
`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`.
|
||||
|
||||
|
||||
@@ -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, plus the roles of whoever is using the app unless that default is `admin`.
|
||||
**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, if it has one, plus the roles of whoever is using the app unless that default is `admin`.
|
||||
|
||||
## SQL Migrations (sql_to_apply/)
|
||||
|
||||
|
||||
@@ -264,7 +264,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 call that passes no role connects as the data table's default role and, unless that default is `admin`, also as every role the person using the app is a tenant of. Row-level security policies written as `pg_has_role(current_user, '<role>', 'member')` then return that viewer's rows with nothing added to the runnable. Passing a role pins the connection to that one role for every viewer, so leave it out where rows should differ per viewer.
|
||||
6. **Per-viewer rows**: a data table with no entry in `data.roles` is called with no role. Unless its default role is `admin`, the connection then holds the default role, if it has one, plus every role the person using the app is a tenant of, and is refused when that is none. Row-level security policies written as `pg_has_role(current_user, '<role>', 'member')` return that viewer's rows with nothing added to the runnable. A role pins the connection to that one role for every viewer, so where rows should differ per viewer give the data table no `data.roles` entry.
|
||||
<!-- 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`.
|
||||
|
||||
Reference in New Issue
Block a user