mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-09-05 16:03:47 +00:00
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:
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user