mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-08-18 16:02:10 +00:00
fix(datatables): redact role passwords from settings exports and harden role naming
This commit is contained in:
@@ -258,7 +258,31 @@ fn plan_role_changes(
|
||||
)));
|
||||
}
|
||||
}
|
||||
let renamed_away: HashSet<&str> = rename_src.values().copied().collect();
|
||||
|
||||
// Which old role each requested role continues: its rename source if it was
|
||||
// renamed, else the old role of the same name. Matching on the requested name
|
||||
// alone would spare an old role that a *different* role is being renamed onto,
|
||||
// leaving the name occupied when the rename runs.
|
||||
let claimed: HashSet<&str> = requested
|
||||
.keys()
|
||||
.map(|name| {
|
||||
rename_src
|
||||
.get(name.as_str())
|
||||
.copied()
|
||||
.unwrap_or(name.as_str())
|
||||
})
|
||||
.collect();
|
||||
|
||||
// Drops come first so a name freed in this save can be reused by a rename or
|
||||
// a create in the same save: the other order aborts on "role already exists".
|
||||
for (name, role) in old_roles.iter() {
|
||||
if name == ROOT_DATATABLE_ROLE || claimed.contains(name.as_str()) {
|
||||
continue;
|
||||
}
|
||||
if let Some(pg_role) = role.pg_rolename.as_deref() {
|
||||
drop_role(&mut statements, pg_role);
|
||||
}
|
||||
}
|
||||
|
||||
let mut roles: BTreeMap<String, DataTableRole> = BTreeMap::new();
|
||||
|
||||
@@ -355,18 +379,6 @@ fn plan_role_changes(
|
||||
}
|
||||
}
|
||||
|
||||
for (name, role) in old_roles.iter() {
|
||||
if name == ROOT_DATATABLE_ROLE
|
||||
|| renamed_away.contains(name.as_str())
|
||||
|| requested.contains_key(name)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
if let Some(pg_role) = role.pg_rolename.as_deref() {
|
||||
drop_role(&mut statements, pg_role);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(RolePlan {
|
||||
statements,
|
||||
permissions: DataTablePermissions { enabled: true, roles },
|
||||
@@ -395,12 +407,14 @@ fn create_role_statement(pg_rolename: &str, password: &str) -> PlannedStatement
|
||||
/// unconditional: it stays a no-op in the common case instead of failing the
|
||||
/// whole transaction.
|
||||
fn grant_connect_statement(pg_rolename: &str, dbname: &str) -> PlannedStatement {
|
||||
let role_lit = quote_literal(pg_rolename);
|
||||
let db_lit = quote_literal(dbname);
|
||||
// The names are passed to `format(%I)` rather than interpolated as quoted
|
||||
// identifiers: this identifier sits inside a single-quoted EXECUTE string, so
|
||||
// a `'` in the database name — which comes from a user-editable postgres
|
||||
// resource — would otherwise close the literal and run as plpgsql of its own.
|
||||
PlannedStatement::plain(format!(
|
||||
"DO $$ BEGIN\n IF NOT has_database_privilege({}, {}, 'CONNECT') THEN\n EXECUTE 'GRANT CONNECT ON DATABASE {} TO {}';\n END IF;\nEND $$;",
|
||||
quote_literal(pg_rolename),
|
||||
quote_literal(dbname),
|
||||
quote_ident(dbname),
|
||||
quote_ident(pg_rolename)
|
||||
"DO $$ BEGIN\n IF NOT has_database_privilege({role_lit}, {db_lit}, 'CONNECT') THEN\n EXECUTE format('GRANT CONNECT ON DATABASE %I TO %I', {db_lit}, {role_lit});\n END IF;\nEND $$;"
|
||||
))
|
||||
}
|
||||
|
||||
@@ -786,13 +800,56 @@ mod tests {
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
sql(&plan).iter().filter(|s| s.starts_with("DROP ROLE")).count(),
|
||||
sql(&plan)
|
||||
.iter()
|
||||
.filter(|s| s.starts_with("DROP ROLE"))
|
||||
.count(),
|
||||
2
|
||||
);
|
||||
assert!(!plan.permissions.enabled);
|
||||
assert!(plan.permissions.roles.is_empty());
|
||||
}
|
||||
|
||||
/// A name freed by a delete must be reusable by a rename in the same save,
|
||||
/// which only holds if the drops are planned first.
|
||||
#[test]
|
||||
fn a_name_freed_in_the_same_save_can_be_reused() {
|
||||
let old = enabled_with(&["analyst", "reader"]);
|
||||
let existing: Vec<String> = ["analyst", "reader"]
|
||||
.iter()
|
||||
.map(|r| datatable_pg_role_name(W_ID, DT, r))
|
||||
.collect();
|
||||
// Delete `reader`, rename `analyst` into its place.
|
||||
let req = SetDatatablePermissions {
|
||||
enabled: true,
|
||||
roles: vec![role("root", &[]), role("reader", &[])],
|
||||
renames: vec![DatatableRoleRename {
|
||||
from: "analyst".to_string(),
|
||||
to: "reader".to_string(),
|
||||
}],
|
||||
};
|
||||
let plan = plan(
|
||||
Some(&old),
|
||||
&req,
|
||||
&existing.iter().map(|s| s.as_str()).collect::<Vec<_>>(),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let statements = sql(&plan);
|
||||
let drop_at = statements
|
||||
.iter()
|
||||
.position(|s| s.starts_with("DROP ROLE"))
|
||||
.expect("the freed role is dropped");
|
||||
let rename_at = statements
|
||||
.iter()
|
||||
.position(|s| s.starts_with("ALTER ROLE") && s.contains("RENAME TO"))
|
||||
.expect("the surviving role is renamed");
|
||||
assert!(
|
||||
drop_at < rename_at,
|
||||
"drop must precede the rename that reuses the name: {statements:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_role_the_config_lost_track_of_is_not_dropped() {
|
||||
let old = enabled_with(&["analyst"]);
|
||||
@@ -832,7 +889,10 @@ mod tests {
|
||||
roles: vec![role("root", &[]), role(bad_role, &[])],
|
||||
renames: vec![],
|
||||
};
|
||||
assert!(plan(None, &req, &[]).is_err(), "{bad_role} should be rejected");
|
||||
assert!(
|
||||
plan(None, &req, &[]).is_err(),
|
||||
"{bad_role} should be rejected"
|
||||
);
|
||||
}
|
||||
for bad_tenant in ["alice", "x/alice", "u/", ""] {
|
||||
let req = SetDatatablePermissions {
|
||||
|
||||
@@ -45,10 +45,11 @@ use windmill_common::workspaces::GitRepositorySettings;
|
||||
use windmill_common::workspaces::WorkspaceDeploymentUISettings;
|
||||
use windmill_common::workspaces::{
|
||||
check_deploy_rules, check_user_against_rule, get_datatable_resource_from_db,
|
||||
get_datatable_resource_from_db_unchecked, DatatableAccess,
|
||||
validate_dev_workspace_id, validate_fork_workspace_id, validate_workspace_name, DataTable,
|
||||
DataTableCatalogResourceType, DataTableForkBehavior, ProtectionRuleKind, ProtectionRules,
|
||||
ProtectionRuleset, RuleCheckResult, WorkspaceGitSyncSettings, DEV_WORKSPACE_LOCK_RULE_NAME,
|
||||
get_datatable_resource_from_db_unchecked, redact_datatable_settings_for_export,
|
||||
validate_dev_workspace_id,
|
||||
validate_fork_workspace_id, validate_workspace_name, DataTable, DataTableCatalogResourceType,
|
||||
DataTableForkBehavior, DatatableAccess, ProtectionRuleKind, ProtectionRules, ProtectionRuleset,
|
||||
RuleCheckResult, WorkspaceGitSyncSettings, DEV_WORKSPACE_LOCK_RULE_NAME,
|
||||
};
|
||||
use windmill_common::workspaces::{Ducklake, DucklakeCatalogResourceType};
|
||||
use windmill_common::PgDatabase;
|
||||
@@ -944,6 +945,7 @@ async fn get_settings(
|
||||
if let Some(git_sync) = settings.git_sync.as_mut() {
|
||||
redact_git_sync_webhook_secrets(git_sync);
|
||||
}
|
||||
settings.datatable = redact_datatable_settings_for_export(settings.datatable);
|
||||
|
||||
Ok(Json(settings))
|
||||
}
|
||||
|
||||
@@ -47,6 +47,7 @@ use windmill_common::runnable_settings::{ConcurrencySettings, DebouncingSettings
|
||||
use windmill_common::scripts::ScriptRunnableSettingsHandle;
|
||||
use windmill_common::utils::require_admin;
|
||||
use windmill_common::variables::decrypt;
|
||||
use windmill_common::workspaces::redact_datatable_settings_for_export;
|
||||
use windmill_common::worker::WINDMILL_DIR;
|
||||
use windmill_common::{
|
||||
db::UserDB,
|
||||
@@ -1493,7 +1494,7 @@ pub(crate) async fn tarball_workspace(
|
||||
mute_critical_alerts: row.mute_critical_alerts,
|
||||
color: row.color.clone(),
|
||||
operator_settings: row.operator_settings.clone(),
|
||||
datatable: row.datatable.clone(),
|
||||
datatable: redact_datatable_settings_for_export(row.datatable.clone()),
|
||||
slack_team_id: row.slack_team_id.clone(),
|
||||
slack_name: row.slack_name.clone(),
|
||||
slack_command_script: row.slack_command_script.clone(),
|
||||
@@ -1563,7 +1564,7 @@ pub(crate) async fn tarball_workspace(
|
||||
mute_critical_alerts: row.mute_critical_alerts,
|
||||
color: row.color,
|
||||
operator_settings: row.operator_settings,
|
||||
datatable: row.datatable,
|
||||
datatable: redact_datatable_settings_for_export(row.datatable),
|
||||
slack_team_id: row.slack_team_id,
|
||||
slack_name: row.slack_name,
|
||||
slack_command_script: row.slack_command_script,
|
||||
|
||||
@@ -1036,16 +1036,58 @@ pub struct DataTableRole {
|
||||
pub tenants: Vec<String>,
|
||||
}
|
||||
|
||||
/// Strip the generated role passwords out of a `workspace_settings.datatable`
|
||||
/// value.
|
||||
///
|
||||
/// Those passwords are direct database logins: anything that hands the settings
|
||||
/// blob outside the server — the settings endpoint, the workspace tarball, a
|
||||
/// git-synced `settings.yaml` — must go through this first, or the credential
|
||||
/// lands somewhere far weaker than the role it protects (a repository's history,
|
||||
/// a non-admin's export).
|
||||
pub fn redact_datatable_settings_for_export(
|
||||
datatable: Option<serde_json::Value>,
|
||||
) -> Option<serde_json::Value> {
|
||||
let mut datatable = datatable?;
|
||||
let Some(datatables) = datatable
|
||||
.get_mut("datatables")
|
||||
.and_then(|d| d.as_object_mut())
|
||||
else {
|
||||
return Some(datatable);
|
||||
};
|
||||
for (_, dt) in datatables.iter_mut() {
|
||||
let Some(roles) = dt
|
||||
.get_mut("permissions")
|
||||
.and_then(|p| p.get_mut("roles"))
|
||||
.and_then(|r| r.as_object_mut())
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
for (_, role) in roles.iter_mut() {
|
||||
if let Some(role) = role.as_object_mut() {
|
||||
role.remove("pg_password");
|
||||
}
|
||||
}
|
||||
}
|
||||
Some(datatable)
|
||||
}
|
||||
|
||||
/// Postgres caps identifiers at 63 bytes (NAMEDATALEN - 1) and silently
|
||||
/// truncates past it, which would collapse two distinct roles onto one.
|
||||
const PG_IDENTIFIER_MAX_LEN: usize = 63;
|
||||
|
||||
/// The postgres role backing `role` on `w_id`'s `datatable`.
|
||||
///
|
||||
/// Postgres roles are cluster-wide while data table names are per-workspace, so
|
||||
/// the workspace id has to be part of the name: without it two workspaces that
|
||||
/// both hold a `main` data table with an `analyst` role would silently share one
|
||||
/// role, and the second workspace's CREATE would fail.
|
||||
/// Postgres roles are cluster-wide while data table and role names are scoped to
|
||||
/// a workspace, so the name is `wm_<workspace>_<datatable>_<role>` plus a hash of
|
||||
/// the exact triple. Both halves are load-bearing:
|
||||
///
|
||||
/// - Without the workspace id, two workspaces that both hold a `main` data table
|
||||
/// with an `analyst` role would silently share one Postgres role.
|
||||
/// - Without the hash, the readable half would not identify the triple: it is
|
||||
/// sanitized (every character outside `[a-z0-9]` becomes `_`) and truncated to
|
||||
/// fit Postgres' 63-byte identifier limit, so `analyst-1` and `analyst_1` — and
|
||||
/// data table `sales_ro` + role `x` against data table `sales` + role `ro_x` —
|
||||
/// would collapse onto the same role and share its grants.
|
||||
pub fn datatable_pg_role_name(w_id: &str, datatable: &str, role: &str) -> String {
|
||||
fn sanitize(s: &str) -> String {
|
||||
s.chars()
|
||||
@@ -1058,23 +1100,28 @@ pub fn datatable_pg_role_name(w_id: &str, datatable: &str, role: &str) -> String
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
let name = format!(
|
||||
use sha2::{Digest, Sha256};
|
||||
// NUL-joined so the digest cannot be replayed by moving characters across the
|
||||
// field boundaries.
|
||||
let mut hasher = Sha256::new();
|
||||
for part in [w_id, datatable, role] {
|
||||
hasher.update(part.as_bytes());
|
||||
hasher.update([0u8]);
|
||||
}
|
||||
let digest = hasher.finalize();
|
||||
let discriminator = u32::from_be_bytes([digest[0], digest[1], digest[2], digest[3]]);
|
||||
|
||||
let readable = format!(
|
||||
"wm_{}_{}_{}",
|
||||
sanitize(w_id),
|
||||
sanitize(datatable),
|
||||
sanitize(role)
|
||||
);
|
||||
if name.len() <= PG_IDENTIFIER_MAX_LEN {
|
||||
return name;
|
||||
}
|
||||
// Truncating alone would let two long names collapse into one role, so the
|
||||
// discriminator is derived from the full name.
|
||||
use sha2::{Digest, Sha256};
|
||||
let hash = &Sha256::digest(name.as_bytes())[..4];
|
||||
let max_readable = PG_IDENTIFIER_MAX_LEN - 9; // "_" + 8 hex digits
|
||||
format!(
|
||||
"{}_{:08x}",
|
||||
&name[..PG_IDENTIFIER_MAX_LEN - 9],
|
||||
u32::from_be_bytes([hash[0], hash[1], hash[2], hash[3]])
|
||||
&readable[..readable.len().min(max_readable)],
|
||||
discriminator
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1217,14 +1264,17 @@ pub async fn get_datatable_replication_resource_from_db_unchecked(
|
||||
/// Returns the `(user, password)` to swap into the connection, or `None` when
|
||||
/// the data table's own credentials are to be used — which is every
|
||||
/// unpermissioned data table, and the `root` role of a permissioned one.
|
||||
async fn resolve_datatable_role(
|
||||
db: &DB,
|
||||
w_id: &str,
|
||||
/// Look up the role a resolution asks for, without authorizing it.
|
||||
///
|
||||
/// `Ok(None)` means the data table is unpermissioned and resolves through its own
|
||||
/// connection. On a permissioned one an unknown role is an error, and on an
|
||||
/// unpermissioned one so is naming any role other than `root` — silently ignoring
|
||||
/// it would run the script with more privileges than it asked for.
|
||||
fn datatable_role_entry<'a>(
|
||||
datatable: &'a DataTable,
|
||||
name: &str,
|
||||
datatable: &DataTable,
|
||||
role: Option<&str>,
|
||||
access: DatatableAccess<'_>,
|
||||
) -> Result<Option<(String, String)>> {
|
||||
) -> Result<Option<(&'a str, &'a DataTableRole)>> {
|
||||
let Some(permissions) = datatable.permissions.as_ref().filter(|p| p.enabled) else {
|
||||
return match role {
|
||||
Some(role) if role != ROOT_DATATABLE_ROLE => Err(Error::BadRequest(format!(
|
||||
@@ -1236,7 +1286,7 @@ async fn resolve_datatable_role(
|
||||
};
|
||||
|
||||
let role_name = role.unwrap_or(ROOT_DATATABLE_ROLE);
|
||||
let role_entry = permissions.roles.get(role_name).ok_or_else(|| {
|
||||
let (role_name, role_entry) = permissions.roles.get_key_value(role_name).ok_or_else(|| {
|
||||
Error::NotFound(format!(
|
||||
"Role '{role_name}' is not defined on data table '{name}'. Defined roles: {}.",
|
||||
permissions
|
||||
@@ -1247,6 +1297,20 @@ async fn resolve_datatable_role(
|
||||
.join(", ")
|
||||
))
|
||||
})?;
|
||||
Ok(Some((role_name.as_str(), role_entry)))
|
||||
}
|
||||
|
||||
async fn resolve_datatable_role(
|
||||
db: &DB,
|
||||
w_id: &str,
|
||||
name: &str,
|
||||
datatable: &DataTable,
|
||||
role: Option<&str>,
|
||||
access: DatatableAccess<'_>,
|
||||
) -> Result<Option<(String, String)>> {
|
||||
let Some((role_name, role_entry)) = datatable_role_entry(datatable, name, role)? else {
|
||||
return Ok(None);
|
||||
};
|
||||
|
||||
let allowed = match access {
|
||||
DatatableAccess::Unchecked => true,
|
||||
@@ -2422,26 +2486,123 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn datatable_pg_role_names_are_workspace_scoped_and_fit_postgres() {
|
||||
assert_eq!(
|
||||
datatable_pg_role_name("acme", "main", "analyst"),
|
||||
"wm_acme_main_analyst"
|
||||
);
|
||||
// Two workspaces must never land on the same cluster-wide role.
|
||||
fn datatable_pg_role_names_are_readable_and_never_collide() {
|
||||
assert!(datatable_pg_role_name("acme", "main", "analyst").starts_with("wm_acme_main_analyst_"));
|
||||
|
||||
// Every pair below sanitizes to the same readable half, so only the hash
|
||||
// keeps them apart — and they must stay apart: two Windmill roles sharing
|
||||
// one Postgres login would share its grants.
|
||||
let collide = [
|
||||
// different workspaces
|
||||
(("acme", "main", "analyst"), ("globex", "main", "analyst")),
|
||||
// '-' and '_' both sanitize to '_'
|
||||
(("acme", "main", "analyst-1"), ("acme", "main", "analyst_1")),
|
||||
// the field separator is itself '_', so the boundary can shift
|
||||
(("acme", "sales_ro", "x"), ("acme", "sales", "ro_x")),
|
||||
// case is folded
|
||||
(("acme", "main", "Analyst"), ("acme", "main", "analyst")),
|
||||
];
|
||||
for ((w1, d1, r1), (w2, d2, r2)) in collide {
|
||||
assert_ne!(
|
||||
datatable_pg_role_name(w1, d1, r1),
|
||||
datatable_pg_role_name(w2, d2, r2),
|
||||
"{w1}/{d1}/{r1} vs {w2}/{d2}/{r2}"
|
||||
);
|
||||
}
|
||||
|
||||
// Postgres silently truncates past 63 bytes, which would undo the above.
|
||||
for name in [
|
||||
datatable_pg_role_name(&"w".repeat(60), "main", "analyst"),
|
||||
datatable_pg_role_name("acme", &"d".repeat(200), &"r".repeat(60)),
|
||||
] {
|
||||
assert!(name.len() <= PG_IDENTIFIER_MAX_LEN, "{name}");
|
||||
}
|
||||
assert_ne!(
|
||||
datatable_pg_role_name("acme", "main", "analyst"),
|
||||
datatable_pg_role_name("globex", "main", "analyst")
|
||||
);
|
||||
// Postgres truncates past 63 bytes, so long names are hashed rather than
|
||||
// left to collide.
|
||||
let long = datatable_pg_role_name("w".repeat(60).as_str(), "main", "analyst");
|
||||
assert_eq!(long.len(), PG_IDENTIFIER_MAX_LEN);
|
||||
assert_ne!(
|
||||
long,
|
||||
datatable_pg_role_name("w".repeat(61).as_str(), "main", "analyst")
|
||||
datatable_pg_role_name(&"w".repeat(60), "main", "analyst"),
|
||||
datatable_pg_role_name(&"w".repeat(61), "main", "analyst")
|
||||
);
|
||||
}
|
||||
|
||||
fn permissioned(roles: &[(&str, &[&str])]) -> DataTable {
|
||||
let mut map = std::collections::BTreeMap::new();
|
||||
for (name, tenants) in roles {
|
||||
map.insert(
|
||||
name.to_string(),
|
||||
DataTableRole {
|
||||
pg_rolename: (*name != ROOT_DATATABLE_ROLE)
|
||||
.then(|| format!("wm_{name}")),
|
||||
pg_password: (*name != ROOT_DATATABLE_ROLE).then(|| "pwd".to_string()),
|
||||
tenants: tenants.iter().map(|t| t.to_string()).collect(),
|
||||
},
|
||||
);
|
||||
}
|
||||
DataTable {
|
||||
database: DataTableDatabase {
|
||||
resource_type: DataTableCatalogResourceType::Instance,
|
||||
resource_path: "db".to_string(),
|
||||
},
|
||||
forked_from: None,
|
||||
migrations_enabled: None,
|
||||
permissions: Some(DataTablePermissions { enabled: true, roles: map }),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn datatable_role_lookup_defaults_to_root_and_rejects_unknown_roles() {
|
||||
let dt = permissioned(&[(ROOT_DATATABLE_ROLE, &[]), ("analyst", &["u/alice"])]);
|
||||
|
||||
// No role named -> root, which reuses the data table's own connection.
|
||||
let (name, entry) = datatable_role_entry(&dt, "main", None).unwrap().unwrap();
|
||||
assert_eq!(name, ROOT_DATATABLE_ROLE);
|
||||
assert!(entry.pg_rolename.is_none());
|
||||
|
||||
let (name, entry) = datatable_role_entry(&dt, "main", Some("analyst"))
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert_eq!(name, "analyst");
|
||||
assert_eq!(entry.pg_rolename.as_deref(), Some("wm_analyst"));
|
||||
|
||||
assert!(datatable_role_entry(&dt, "main", Some("nope")).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn naming_a_role_on_an_unpermissioned_datatable_is_refused() {
|
||||
let mut dt = permissioned(&[(ROOT_DATATABLE_ROLE, &[]), ("analyst", &["u/alice"])]);
|
||||
dt.permissions.as_mut().unwrap().enabled = false;
|
||||
|
||||
// Silently ignoring the role would run the script as the data table's own
|
||||
// connection — more privilege than it asked for.
|
||||
assert!(datatable_role_entry(&dt, "main", Some("analyst")).is_err());
|
||||
// root and "no role" both mean the existing connection, so they are fine.
|
||||
assert!(datatable_role_entry(&dt, "main", None).unwrap().is_none());
|
||||
assert!(datatable_role_entry(&dt, "main", Some(ROOT_DATATABLE_ROLE))
|
||||
.unwrap()
|
||||
.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn datatable_settings_export_drops_role_passwords() {
|
||||
let settings = serde_json::json!({
|
||||
"datatables": {
|
||||
"main": {
|
||||
"database": { "resource_type": "instance", "resource_path": "db" },
|
||||
"permissions": { "enabled": true, "roles": {
|
||||
"root": { "tenants": [] },
|
||||
"analyst": { "pg_rolename": "wm_x", "pg_password": "s3cret", "tenants": ["u/alice"] }
|
||||
}}
|
||||
},
|
||||
"other": { "database": { "resource_type": "instance", "resource_path": "db2" } }
|
||||
}
|
||||
});
|
||||
let redacted = redact_datatable_settings_for_export(Some(settings)).unwrap();
|
||||
let analyst = &redacted["datatables"]["main"]["permissions"]["roles"]["analyst"];
|
||||
assert!(analyst.get("pg_password").is_none());
|
||||
// Everything else survives: the export is still a usable settings file.
|
||||
assert_eq!(analyst["pg_rolename"], "wm_x");
|
||||
assert_eq!(analyst["tenants"][0], "u/alice");
|
||||
assert_eq!(redacted["datatables"]["other"]["database"]["resource_path"], "db2");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_fork_branch() {
|
||||
// Generated fork (`wm-fork-abc`) and dev workspace (`staging`) forms.
|
||||
|
||||
@@ -217,7 +217,9 @@
|
||||
datatable={uriState.selectedDatatable}
|
||||
onSchemaChanged={refreshManager}
|
||||
/>
|
||||
<DataTablePermissionsButton workspace={ws} datatable={uriState.selectedDatatable} />
|
||||
{#if $superadmin || $userStore?.is_admin}
|
||||
<DataTablePermissionsButton workspace={ws} datatable={uriState.selectedDatatable} />
|
||||
{/if}
|
||||
{/if}
|
||||
{#if enableImportExport}
|
||||
<Button startIcon={{ icon: Download }} onClick={handleExportSchema}>Export</Button>
|
||||
|
||||
Reference in New Issue
Block a user