feat(datatables): gate role and ACL changes on the enterprise edition

Both planners now live in windmill-ee-private, reached through a dispatcher
that refuses in the open-source build, and an enterprise binary additionally
checks for an active license before planning anything. Reading a data table's
roles, its usable roles and a schema's owner and grants stays open, so an
instance that lapses can still see what it has; every mutation is refused.

The UI stops offering what the server would refuse: no Roles entry on a data
table, and no Permissions entry on a schema or table, without a license.
This commit is contained in:
Diego Imbert
2026-09-01 04:32:52 +02:00
parent 5f59b199b1
commit 28b31a437e
8 changed files with 143 additions and 1596 deletions
+1 -1
View File
@@ -1 +1 @@
56f5e6a82056fa63323d6d4e1ec44832e64512fc
e8e323558523176e544927b44acf084c27a1b2aa
@@ -35,7 +35,7 @@ use windmill_common::workspaces::{
};
use windmill_common::{PgDatabase, DB};
use crate::datatable_permissions::{connect_as_admin, quote_ident, read_datatable};
use crate::datatable_permissions::{connect_as_admin, read_datatable};
pub(crate) fn routes() -> Router {
Router::new()
@@ -68,29 +68,8 @@ pub enum AclTarget {
}
impl AclTarget {
/// The schema the target is in, absent for the database itself.
fn schema(&self) -> Option<&str> {
match self {
AclTarget::Database => None,
AclTarget::Schema { schema } => Some(schema),
AclTarget::Table { schema, .. } => Some(schema),
}
}
/// How the target reads in the statements that name it, `dbname` being the
/// database the connection is on — the target never carries it.
fn object(&self, dbname: &str) -> String {
match self {
AclTarget::Database => format!("DATABASE {}", quote_ident(dbname)),
AclTarget::Schema { schema } => format!("SCHEMA {}", quote_ident(schema)),
AclTarget::Table { schema, table } => {
format!("TABLE {}.{}", quote_ident(schema), quote_ident(table))
}
}
}
/// What it is called in a message.
fn label(&self, dbname: &str) -> String {
pub(crate) fn label(&self, dbname: &str) -> String {
match self {
AclTarget::Database => dbname.to_string(),
AclTarget::Schema { schema } => schema.clone(),
@@ -143,65 +122,6 @@ pub enum GrantScope {
FutureFunctions,
}
impl GrantScope {
/// The privileges Postgres accepts for what this scope names.
fn allowed_privileges(&self, target: &AclTarget) -> Result<&'static [&'static str]> {
Ok(match self {
GrantScope::Target => match target {
AclTarget::Database => DATABASE_PRIVILEGES,
AclTarget::Schema { .. } => SCHEMA_PRIVILEGES,
AclTarget::Table { .. } => TABLE_PRIVILEGES,
},
// Everything below reads `IN SCHEMA`, which a database target has
// none of: schemas are granted on one at a time.
_ if matches!(target, AclTarget::Database) => {
return Err(Error::BadRequest(
"A database can only be granted on itself".to_string(),
))
}
GrantScope::AllTables | GrantScope::FutureTables => TABLE_PRIVILEGES,
GrantScope::AllSequences | GrantScope::FutureSequences => SEQUENCE_PRIVILEGES,
GrantScope::AllFunctions | GrantScope::FutureFunctions => FUNCTION_PRIVILEGES,
})
}
fn is_future(&self) -> bool {
matches!(
self,
GrantScope::FutureTables | GrantScope::FutureSequences | GrantScope::FutureFunctions
)
}
/// The plural Postgres uses in `ON ALL <x> IN SCHEMA` and in
/// `ALTER DEFAULT PRIVILEGES ... ON <x>`.
fn object_plural(&self) -> Option<&'static str> {
match self {
GrantScope::Target => None,
GrantScope::AllTables | GrantScope::FutureTables => Some("TABLES"),
GrantScope::AllSequences | GrantScope::FutureSequences => Some("SEQUENCES"),
GrantScope::AllFunctions | GrantScope::FutureFunctions => Some("FUNCTIONS"),
}
}
}
/// `CREATE` on a database is the privilege to create schemas in it.
const DATABASE_PRIVILEGES: &[&str] = &["CONNECT", "CREATE", "TEMPORARY"];
const SCHEMA_PRIVILEGES: &[&str] = &["USAGE", "CREATE"];
const TABLE_PRIVILEGES: &[&str] = &[
"SELECT",
"INSERT",
"UPDATE",
"DELETE",
"TRUNCATE",
"REFERENCES",
"TRIGGER",
// Postgres 17. Accepted whatever the server's version, so that a grant read
// back from a 17 catalog can be revoked; an older server refuses it itself.
"MAINTAIN",
];
const SEQUENCE_PRIVILEGES: &[&str] = &["USAGE", "SELECT", "UPDATE"];
const FUNCTION_PRIVILEGES: &[&str] = &["EXECUTE"];
/// A change to plan. One at a time: each is confirmed against its own SQL.
#[derive(Deserialize, Serialize, Debug, Clone)]
#[serde(tag = "type", rename_all = "snake_case")]
@@ -425,212 +345,15 @@ fn windmill_role_of(roles: &BTreeMap<String, String>, pg_role: &str) -> String {
.unwrap_or_else(|| pg_role.to_string())
}
fn validate_privileges(privileges: &[String], allowed: &[&str]) -> Result<Vec<String>> {
if privileges.is_empty() {
return Err(Error::BadRequest("No privilege selected".to_string()));
}
privileges
.iter()
.map(|p| {
let upper = p.to_uppercase();
allowed
.iter()
.find(|a| **a == upper)
.map(|a| a.to_string())
.ok_or_else(|| {
Error::BadRequest(format!(
"Privilege '{p}' does not apply here; expected one of {}",
allowed.join(", ")
))
})
})
.collect()
}
/// The statements one change plans out, against Postgres role names.
///
/// Pure so the preview the user confirms is the same string that runs.
fn plan_statements(
target: &AclTarget,
change: &AclChange,
dbname: &str,
pg_role: &str,
other_pg_roles: &[String],
existing_objects: &[OwnedObject],
) -> Result<AclPlan> {
let role = quote_ident(pg_role);
// Only the scopes that name a schema use this, and those are refused on a
// database target.
let schema = target.schema().map(quote_ident).unwrap_or_default();
let mut statements = Vec::new();
let mut warnings = Vec::new();
match change {
AclChange::SetOwner { .. } => {
statements.push(format!("ALTER {} OWNER TO {}", target.object(dbname), role));
for object in existing_objects {
statements.push(format!(
"ALTER {} {} OWNER TO {}",
object.keyword,
object_ref(&schema, &object.name, object.args.as_deref()),
role
));
}
// Ownership cannot be set ahead of time: an object belongs to
// whoever creates it. Default privileges are what keeps the owner
// in reach of what the other roles create from here on — which only
// means something for a schema, the thing objects are created in.
for other in other_pg_roles
.iter()
.filter(|_| matches!(target, AclTarget::Schema { .. }))
{
for plural in ["TABLES", "SEQUENCES", "FUNCTIONS"] {
statements.push(format!(
"ALTER DEFAULT PRIVILEGES FOR ROLE {} IN SCHEMA {} GRANT ALL PRIVILEGES ON {} TO {}",
quote_ident(other),
schema,
plural,
role
));
}
}
if existing_objects.is_empty() && matches!(target, AclTarget::Schema { .. }) {
warnings.push(format!(
"{} holds no objects yet; only the schema itself changes hands.",
target.label(dbname)
));
}
}
AclChange::Grant { privileges, scope, .. }
| AclChange::Revoke { privileges, scope, .. } => {
let revoking = matches!(change, AclChange::Revoke { .. });
let objects: &[AclObject] = match change {
AclChange::Revoke { objects, .. } => objects,
_ => &[],
};
// Every object of one revoke is the same kind of thing, so the first
// decides which privileges are legal for all of them.
let object = objects.first();
// A named object decides which privileges are legal, not the scope:
// `ON ALL TABLES` grants read back per object and revoke per object.
let allowed = match object {
Some(o) => match object_keyword(&o.kind)? {
"SEQUENCE" => SEQUENCE_PRIVILEGES,
"FUNCTION" => FUNCTION_PRIVILEGES,
_ => TABLE_PRIVILEGES,
},
None => scope.allowed_privileges(target)?,
};
let privileges = validate_privileges(privileges, allowed)?;
let privileges = privileges.join(", ");
let statement = match (scope.is_future(), scope.object_plural()) {
(true, Some(plural)) => {
// Default privileges are recorded per creating role, so a
// rule has to be written for each of them.
let mut creators = other_pg_roles.to_vec();
creators.push(pg_role.to_string());
creators.sort();
creators.dedup();
for creator in creators {
statements.push(if revoking {
format!(
"ALTER DEFAULT PRIVILEGES FOR ROLE {} IN SCHEMA {} REVOKE {} ON {} FROM {}",
quote_ident(&creator), schema, privileges, plural, role
)
} else {
format!(
"ALTER DEFAULT PRIVILEGES FOR ROLE {} IN SCHEMA {} GRANT {} ON {} TO {}",
quote_ident(&creator), schema, privileges, plural, role
)
});
}
None
}
(false, Some(plural)) => Some(if revoking {
format!(
"REVOKE {} ON ALL {} IN SCHEMA {} FROM {}",
privileges, plural, schema, role
)
} else {
format!(
"GRANT {} ON ALL {} IN SCHEMA {} TO {}",
privileges, plural, schema, role
)
}),
(_, None) if !objects.is_empty() => {
for object in objects {
statements.push(format!(
"REVOKE {} ON {} {} FROM {}",
privileges,
object_keyword(&object.kind)?,
object_ref(&schema, &object.name, object.args.as_deref()),
role
));
}
None
}
(_, None) => Some(if revoking {
format!(
"REVOKE {} ON {} FROM {}",
privileges,
target.object(dbname),
role
)
} else {
format!(
"GRANT {} ON {} TO {}",
privileges,
target.object(dbname),
role
)
}),
};
if let Some(statement) = statement {
statements.push(statement);
}
if !revoking && matches!(scope, GrantScope::AllTables | GrantScope::FutureTables) {
warnings.push(
"Reaching a table also needs USAGE on the schema it lives in.".to_string(),
);
}
}
}
Ok(AclPlan { statements, warnings })
}
/// The keyword a `REVOKE ... ON` takes for one object, checked rather than
/// interpolated: it lands in SQL unquoted.
fn object_keyword(kind: &str) -> Result<&'static str> {
match kind.to_uppercase().as_str() {
"TABLE" | "VIEW" | "MATERIALIZED VIEW" | "FOREIGN TABLE" => Ok("TABLE"),
"SEQUENCE" => Ok("SEQUENCE"),
"FUNCTION" => Ok("FUNCTION"),
other => Err(Error::BadRequest(format!("Unknown object kind '{other}'"))),
}
}
/// An object whose ownership follows the schema's.
#[derive(Debug, PartialEq)]
struct OwnedObject {
name: String,
pub(crate) struct OwnedObject {
pub(crate) name: String,
/// The keyword `ALTER ... OWNER TO` takes for this kind of object.
keyword: &'static str,
pub(crate) keyword: &'static str,
/// Identity arguments of a routine, which is what tells two of the same
/// name apart. `None` for a relation.
args: Option<String>,
}
/// `"schema"."name"` — with `(args)` for a routine, which is not identified
/// without them. `quoted_schema` comes quoted already, being the same for a
/// whole plan.
fn object_ref(quoted_schema: &str, name: &str, args: Option<&str>) -> String {
format!(
"{}.{}{}",
quoted_schema,
quote_ident(name),
args.map(|a| format!("({a})")).unwrap_or_default()
)
pub(crate) args: Option<String>,
}
fn keyword_of_relkind(relkind: i8) -> Option<&'static str> {
@@ -981,6 +704,7 @@ async fn build_acl_plan(
datatable_name: &str,
req: &AclChangeRequest,
) -> Result<(tokio_postgres::Client, AclPlan, String)> {
crate::datatable_permissions::require_datatable_permissions_license().await?;
let (client, conn) =
connect_as_caller(db, authed, w_id, datatable_name, req.role.as_deref()).await?;
// What the caller's own role may change. Postgres cannot enforce the rule we
@@ -1016,7 +740,7 @@ async fn build_acl_plan(
}
_ => vec![],
};
let plan = plan_statements(
let plan = crate::datatable_acl_oss::plan_statements(
&req.target,
&req.change,
&conn.dbname,
@@ -1090,337 +814,3 @@ async fn apply_datatable_acl(
Ok(format!("Updated access on {}", req.target.label(&dbname)))
}
#[cfg(test)]
mod tests {
use super::*;
fn schema() -> AclTarget {
AclTarget::Schema { schema: "analytics".to_string() }
}
#[test]
fn set_owner_covers_the_schema_and_what_is_in_it() {
let objects = vec![
OwnedObject { name: "orders".to_string(), keyword: "TABLE", args: None },
OwnedObject { name: "orders_id_seq".to_string(), keyword: "SEQUENCE", args: None },
OwnedObject {
name: "total".to_string(),
keyword: "ROUTINE",
args: Some("integer, text".to_string()),
},
];
let plan = plan_statements(
&schema(),
&AclChange::SetOwner { role: "analyst".to_string() },
"dt_probe",
"wm_analyst_1",
&["wm_admin".to_string()],
&objects,
)
.unwrap();
assert_eq!(
plan.statements[..4],
[
r#"ALTER SCHEMA "analytics" OWNER TO "wm_analyst_1""#.to_string(),
r#"ALTER TABLE "analytics"."orders" OWNER TO "wm_analyst_1""#.to_string(),
r#"ALTER SEQUENCE "analytics"."orders_id_seq" OWNER TO "wm_analyst_1""#.to_string(),
// A routine is only named by its arguments.
r#"ALTER ROUTINE "analytics"."total"(integer, text) OWNER TO "wm_analyst_1""#
.to_string(),
]
);
// What the other roles create later stays within the owner's reach.
assert!(plan.statements.iter().any(|s| s
== r#"ALTER DEFAULT PRIVILEGES FOR ROLE "wm_admin" IN SCHEMA "analytics" GRANT ALL PRIVILEGES ON TABLES TO "wm_analyst_1""#));
assert!(plan.warnings.is_empty());
}
#[test]
fn an_empty_schema_says_so() {
let plan = plan_statements(
&schema(),
&AclChange::SetOwner { role: "analyst".to_string() },
"dt_probe",
"wm_analyst_1",
&[],
&[],
)
.unwrap();
assert_eq!(plan.statements.len(), 1);
assert_eq!(plan.warnings.len(), 1);
}
#[test]
fn grants_render_the_scope_they_name() {
let cases = [
(
GrantScope::Target,
vec!["USAGE".to_string()],
r#"GRANT USAGE ON SCHEMA "analytics" TO "wm_analyst_1""#,
),
(
GrantScope::AllTables,
vec!["SELECT".to_string(), "INSERT".to_string()],
r#"GRANT SELECT, INSERT ON ALL TABLES IN SCHEMA "analytics" TO "wm_analyst_1""#,
),
(
GrantScope::AllSequences,
vec!["USAGE".to_string()],
r#"GRANT USAGE ON ALL SEQUENCES IN SCHEMA "analytics" TO "wm_analyst_1""#,
),
];
for (scope, privileges, expected) in cases {
let plan = plan_statements(
&schema(),
&AclChange::Grant { role: "analyst".to_string(), privileges, scope },
"dt_probe",
"wm_analyst_1",
&[],
&[],
)
.unwrap();
assert_eq!(plan.statements[0], expected);
}
}
#[test]
fn future_grants_are_written_for_every_creating_role() {
let plan = plan_statements(
&schema(),
&AclChange::Grant {
role: "analyst".to_string(),
privileges: vec!["SELECT".to_string()],
scope: GrantScope::FutureTables,
},
"dt_probe",
"wm_analyst_1",
&["wm_admin".to_string()],
&[],
)
.unwrap();
assert_eq!(
plan.statements,
[
r#"ALTER DEFAULT PRIVILEGES FOR ROLE "wm_admin" IN SCHEMA "analytics" GRANT SELECT ON TABLES TO "wm_analyst_1""#,
r#"ALTER DEFAULT PRIVILEGES FOR ROLE "wm_analyst_1" IN SCHEMA "analytics" GRANT SELECT ON TABLES TO "wm_analyst_1""#,
]
);
}
#[test]
fn revoke_mirrors_grant() {
let plan = plan_statements(
&schema(),
&AclChange::Revoke {
role: "analyst".to_string(),
privileges: vec!["select".to_string()],
scope: GrantScope::AllTables,
objects: vec![],
},
"dt_probe",
"wm_analyst_1",
&[],
&[],
)
.unwrap();
assert_eq!(
plan.statements,
[r#"REVOKE SELECT ON ALL TABLES IN SCHEMA "analytics" FROM "wm_analyst_1""#]
);
}
#[test]
fn revoking_one_object_names_it() {
let plan = plan_statements(
&schema(),
&AclChange::Revoke {
role: "analyst".to_string(),
privileges: vec!["SELECT".to_string()],
scope: GrantScope::Target,
objects: vec![AclObject {
name: "orders".to_string(),
kind: "TABLE".to_string(),
args: None,
}],
},
"dt_probe",
"wm_analyst_1",
&[],
&[],
);
// SELECT is no schema privilege, but the object named is a table: what
// it is decides which privileges are legal.
assert_eq!(
plan.unwrap().statements,
[r#"REVOKE SELECT ON TABLE "analytics"."orders" FROM "wm_analyst_1""#]
);
}
#[test]
fn a_database_grants_the_right_to_create_schemas() {
let plan = plan_statements(
&AclTarget::Database,
&AclChange::Grant {
role: "analyst".to_string(),
privileges: vec!["CREATE".to_string()],
scope: GrantScope::Target,
},
"dt_probe",
"wm_analyst_1",
&[],
&[],
)
.unwrap();
assert_eq!(
plan.statements,
[r#"GRANT CREATE ON DATABASE "dt_probe" TO "wm_analyst_1""#]
);
// A database has no schemas to scope onto, and none of its privileges
// are a table's.
for change in [
AclChange::Grant {
role: "analyst".to_string(),
privileges: vec!["CREATE".to_string()],
scope: GrantScope::AllTables,
},
AclChange::Grant {
role: "analyst".to_string(),
privileges: vec!["SELECT".to_string()],
scope: GrantScope::Target,
},
] {
assert!(matches!(
plan_statements(
&AclTarget::Database,
&change,
"dt_probe",
"wm_analyst_1",
&[],
&[]
)
.unwrap_err(),
Error::BadRequest(_)
));
}
}
#[test]
fn a_tables_owner_change_is_only_that_table() {
let plan = plan_statements(
&AclTarget::Table { schema: "analytics".to_string(), table: "orders".to_string() },
&AclChange::SetOwner { role: "analyst".to_string() },
"dt_probe",
"wm_analyst_1",
&["wm_admin".to_string()],
&[],
)
.unwrap();
// Default privileges are about what gets created in a schema, which
// changing one table's owner says nothing about.
assert_eq!(
plan.statements,
[r#"ALTER TABLE "analytics"."orders" OWNER TO "wm_analyst_1""#]
);
assert!(plan.warnings.is_empty());
}
#[test]
fn several_objects_are_revoked_together() {
let plan = plan_statements(
&schema(),
&AclChange::Revoke {
role: "analyst".to_string(),
privileges: vec!["SELECT".to_string()],
scope: GrantScope::Target,
objects: vec![
AclObject { name: "a".to_string(), kind: "TABLE".to_string(), args: None },
AclObject { name: "b".to_string(), kind: "TABLE".to_string(), args: None },
],
},
"dt_probe",
"wm_analyst_1",
&[],
&[],
)
.unwrap();
assert_eq!(
plan.statements,
[
r#"REVOKE SELECT ON TABLE "analytics"."a" FROM "wm_analyst_1""#,
r#"REVOKE SELECT ON TABLE "analytics"."b" FROM "wm_analyst_1""#,
]
);
}
#[test]
fn a_privilege_the_object_does_not_have_is_refused() {
for (scope, privilege) in [
(GrantScope::Target, "SELECT"),
(GrantScope::AllTables, "CREATE"),
(GrantScope::AllFunctions, "SELECT"),
(GrantScope::AllTables, "SELECT; DROP TABLE x"),
] {
let err = plan_statements(
&schema(),
&AclChange::Grant {
role: "analyst".to_string(),
privileges: vec![privilege.to_string()],
scope,
},
"dt_probe",
"wm_analyst_1",
&[],
&[],
)
.unwrap_err();
assert!(
matches!(err, Error::BadRequest(_)),
"{privilege} on {scope:?}"
);
}
}
#[test]
fn identifiers_are_quoted() {
let plan = plan_statements(
&AclTarget::Schema { schema: "we\"ird".to_string() },
&AclChange::Grant {
role: "analyst".to_string(),
privileges: vec!["USAGE".to_string()],
scope: GrantScope::Target,
},
"dt_probe",
"ro\"le",
&[],
&[],
)
.unwrap();
assert_eq!(
plan.statements,
[r#"GRANT USAGE ON SCHEMA "we""ird" TO "ro""le""#]
);
}
#[test]
fn a_table_target_names_the_table() {
let plan = plan_statements(
&AclTarget::Table { schema: "analytics".to_string(), table: "orders".to_string() },
&AclChange::Grant {
role: "analyst".to_string(),
privileges: vec!["SELECT".to_string()],
scope: GrantScope::Target,
},
"dt_probe",
"wm_analyst_1",
&[],
&[],
)
.unwrap();
assert_eq!(
plan.statements,
[r#"GRANT SELECT ON TABLE "analytics"."orders" TO "wm_analyst_1""#]
);
}
}
@@ -0,0 +1,37 @@
/*
* Author: Ruben Fiszel
* Copyright: Windmill Labs, Inc 2022
* This file and its contents are licensed under the AGPLv3 License.
* Please see the included NOTICE for copyright information and
* LICENSE-AGPL for a copy of the license.
*/
//! Where the ACL planner comes from: the enterprise one, or a refusal.
//!
//! Reading who owns what stays open; every owner change and every grant is the
//! plan this returns, so an edition without the enterprise module cannot make
//! one.
#[cfg(feature = "private")]
#[allow(unused)]
pub(crate) use crate::datatable_acl_ee::*;
#[cfg(not(feature = "private"))]
use {
crate::datatable_acl::{AclChange, AclPlan, AclTarget, OwnedObject},
windmill_common::error::{Error, Result},
};
#[cfg(not(feature = "private"))]
pub(crate) fn plan_statements(
_target: &AclTarget,
_change: &AclChange,
_dbname: &str,
_pg_role: &str,
_other_pg_roles: &[String],
_existing_objects: &[OwnedObject],
) -> Result<AclPlan> {
Err(Error::BadRequest(
"Data table permissions are a Windmill Enterprise Edition feature".to_string(),
))
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,42 @@
/*
* Author: Ruben Fiszel
* Copyright: Windmill Labs, Inc 2022
* This file and its contents are licensed under the AGPLv3 License.
* Please see the included NOTICE for copyright information and
* LICENSE-AGPL for a copy of the license.
*/
//! Where the role planner comes from: the enterprise one, or a refusal.
//!
//! Every change to a data table's Postgres roles — creating, renaming, dropping
//! them — is the plan this returns, so an edition without the enterprise module
//! cannot make one.
#[cfg(feature = "private")]
#[allow(unused)]
pub(crate) use crate::datatable_permissions_ee::*;
#[cfg(not(feature = "private"))]
use {
crate::datatable_permissions::{DefaultAclRule, RolePlan, SetDatatablePermissions},
std::collections::HashSet,
windmill_common::error::{Error, Result},
windmill_common::workspaces::DataTablePermissions,
};
#[cfg(not(feature = "private"))]
pub(crate) fn plan_role_changes(
_w_id: &str,
_datatable: &str,
_dbname: &str,
_admin_pg_role: &str,
_old: Option<&DataTablePermissions>,
_req: &SetDatatablePermissions,
_existing_pg_roles: &HashSet<String>,
_public_schema_is_open: bool,
_default_acl_rules: &[DefaultAclRule],
) -> Result<RolePlan> {
Err(Error::BadRequest(
"Data table permissions are a Windmill Enterprise Edition feature".to_string(),
))
}
@@ -1,11 +1,17 @@
pub mod data_metrics;
pub mod datatable_acl;
pub mod datatable_acl_oss;
pub mod datatable_migrations;
pub mod datatable_permissions;
pub mod datatable_permissions_oss;
pub mod deployment_requests;
pub mod workspaces;
pub mod workspaces_extra;
pub mod workspaces_oss;
#[cfg(feature = "private")]
pub mod datatable_acl_ee;
#[cfg(feature = "private")]
pub mod datatable_permissions_ee;
#[cfg(feature = "private")]
pub mod workspaces_ee;
+4 -2
View File
@@ -1,5 +1,5 @@
<script lang="ts">
import { superadmin, userStore, type DBSchema } from '$lib/stores'
import { enterpriseLicense, superadmin, userStore, type DBSchema } from '$lib/stores'
import {
ChevronDownIcon,
EditIcon,
@@ -237,6 +237,8 @@
/** Whether the connected role may change access on an object: Postgres asks
* for membership of its owner, so for the rest the entry is not offered. */
function canManage(datatable: string | undefined, schemaKey: string, table?: string): boolean {
// The server refuses to plan any of it without a license.
if (!$enterpriseLicense) return false
// A workspace admin manages the data table itself, so nothing in it is
// hidden from them — `public` is owned by neither Windmill nor its roles.
if (canManageDatatable) return true
@@ -688,7 +690,7 @@
icon: HistoryIcon,
action: () => onDatatableAction?.(dt, 'migrations')
},
...(canManageDatatable
...(canManageDatatable && $enterpriseLicense
? [
{
displayName: 'Roles',
@@ -78,7 +78,7 @@
type GetSettingsResponse,
type TestDataTableConnectionResponse
} from '$lib/gen'
import { workspaceStore } from '$lib/stores'
import { enterpriseLicense, workspaceStore } from '$lib/stores'
import { createAsyncConfirmationModal } from '../common/confirmationModal/asyncConfirmationModal.svelte'
import ConfirmationModal from '../common/confirmationModal/ConfirmationModal.svelte'
import { resource } from 'runed'
@@ -469,13 +469,19 @@
tooltip: 'Save the settings first',
action: () => migrationsButtons[dataTable.name]?.open()
},
{
displayName: 'Permissions',
icon: KeyRound,
disabled: !!dirtyMap[dataTable.name],
tooltip: 'Save the settings first',
action: () => permissionsButtons[dataTable.name]?.openPermissions()
},
// The server refuses to plan a permissions change without a
// license, so the entry is not offered either.
...($enterpriseLicense
? [
{
displayName: 'Permissions',
icon: KeyRound,
disabled: !!dirtyMap[dataTable.name],
tooltip: 'Save the settings first',
action: () => permissionsButtons[dataTable.name]?.openPermissions()
}
]
: []),
{
displayName: 'Remove',
icon: Trash2,