mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-09-21 00:02:30 +00:00
1324 lines
48 KiB
Rust
1324 lines
48 KiB
Rust
//! Who may connect to a data table as which role, across the two shapes an entry can take: one
|
|
//! that owns its database, and a fork's pointer at it.
|
|
|
|
use serde_json::{json, Value};
|
|
use sqlx::{Pool, Postgres};
|
|
|
|
use windmill_test_utils::*;
|
|
|
|
fn client() -> reqwest::Client {
|
|
reqwest::Client::new()
|
|
}
|
|
|
|
fn authed(builder: reqwest::RequestBuilder, token: &str) -> reqwest::RequestBuilder {
|
|
builder.header("Authorization", format!("Bearer {token}"))
|
|
}
|
|
|
|
/// The `analytics` role's tenant list as stored, so a cascade can be observed directly.
|
|
async fn tenants(db: &Pool<Postgres>, w_id: &str) -> Vec<String> {
|
|
let value: Option<Value> = sqlx::query_scalar(
|
|
"SELECT datatable->'datatables'->'main'->'permissions'->'roles'->'role1'->'tenants'
|
|
FROM workspace_settings WHERE workspace_id = $1",
|
|
)
|
|
.bind(w_id)
|
|
.fetch_one(db)
|
|
.await
|
|
.unwrap();
|
|
serde_json::from_value(value.unwrap_or(json!([]))).unwrap()
|
|
}
|
|
|
|
#[sqlx::test(migrations = "../migrations", fixtures("base", "datatable_roles"))]
|
|
async fn freeing_a_principal_takes_its_datatable_tenant(db: Pool<Postgres>) -> anyhow::Result<()> {
|
|
initialize_tracing().await;
|
|
let server = ApiServer::start(db.clone()).await?;
|
|
let port = server.addr.port();
|
|
let base = format!("http://localhost:{port}/api/w/test-workspace");
|
|
|
|
assert_eq!(
|
|
tenants(&db, "test-workspace").await,
|
|
vec!["u/test-user-2", "g/analysts", "f/finance"]
|
|
);
|
|
|
|
let resp = authed(
|
|
client().delete(format!("{base}/groups/delete/analysts")),
|
|
"SECRET_TOKEN",
|
|
)
|
|
.send()
|
|
.await?;
|
|
assert_eq!(resp.status(), 200, "delete group: {}", resp.text().await?);
|
|
|
|
let resp = authed(
|
|
client().delete(format!("{base}/folders/delete/finance")),
|
|
"SECRET_TOKEN",
|
|
)
|
|
.send()
|
|
.await?;
|
|
assert_eq!(resp.status(), 200, "delete folder: {}", resp.text().await?);
|
|
|
|
let resp = authed(
|
|
client().delete(format!("{base}/users/delete/test-user-2")),
|
|
"SECRET_TOKEN",
|
|
)
|
|
.send()
|
|
.await?;
|
|
assert_eq!(resp.status(), 200, "delete user: {}", resp.text().await?);
|
|
|
|
// Leaving is the other way a membership ends, and there are two `/leave` routes — the one the
|
|
// UI and the generated client call is this one. A tenant left behind here comes back with the
|
|
// person on rejoin, or attaches to whoever takes the username next.
|
|
sqlx::query(
|
|
r#"UPDATE workspace_settings SET datatable = jsonb_set(datatable,
|
|
'{datatables,main,permissions,roles,role1,tenants}', '["u/test-user-3"]'::jsonb)
|
|
WHERE workspace_id = 'test-workspace'"#,
|
|
)
|
|
.execute(&db)
|
|
.await?;
|
|
let resp = authed(
|
|
client().post(format!("{base}/workspaces/leave")),
|
|
"SECRET_TOKEN_3",
|
|
)
|
|
.send()
|
|
.await?;
|
|
assert_eq!(resp.status(), 200, "leave: {}", resp.text().await?);
|
|
// Nothing left naming a principal that no longer exists: a later group or account reusing one
|
|
// of those names must not inherit the access this one had.
|
|
assert!(
|
|
tenants(&db, "test-workspace").await.is_empty(),
|
|
"leaving kept the tenant: {:?}",
|
|
tenants(&db, "test-workspace").await
|
|
);
|
|
Ok(())
|
|
}
|
|
|
|
#[cfg(all(feature = "private", feature = "enterprise"))]
|
|
#[sqlx::test(migrations = "../migrations", fixtures("base", "datatable_roles"))]
|
|
async fn a_fork_uses_the_data_table_it_points_at_but_never_administers_it(
|
|
db: Pool<Postgres>,
|
|
) -> anyhow::Result<()> {
|
|
initialize_tracing().await;
|
|
let server = ApiServer::start(db.clone()).await?;
|
|
let port = server.addr.port();
|
|
let fork = format!("http://localhost:{port}/api/w/wm-fork-dt/workspaces");
|
|
|
|
// `test-user-2` is an admin of the fork and a plain member of the parent. The roles they can
|
|
// use are the ones the parent's tenants give them there, not what their fork admin bit says.
|
|
let resp = authed(
|
|
client().get(format!("{fork}/datatable_usable_roles/main")),
|
|
"SECRET_TOKEN_2",
|
|
)
|
|
.send()
|
|
.await?;
|
|
assert_eq!(resp.status(), 200);
|
|
let body: Value = resp.json().await?;
|
|
assert_eq!(body["roles"], json!(["analytics"]), "{body}");
|
|
assert_eq!(body["default_role"], "analytics");
|
|
|
|
// The drawer names the workspace that decides, and refuses to let the fork edit it.
|
|
let resp = authed(
|
|
client().get(format!("{fork}/datatable_permissions/main")),
|
|
"SECRET_TOKEN_2",
|
|
)
|
|
.send()
|
|
.await?;
|
|
let body: Value = resp.json().await?;
|
|
assert_eq!(body["governing_workspace_id"], "test-workspace");
|
|
assert_eq!(body["editable"], false, "{body}");
|
|
|
|
let resp = authed(
|
|
client().post(format!("{fork}/datatable_permissions/main")),
|
|
"SECRET_TOKEN_2",
|
|
)
|
|
.json(&json!({"permissioned": true, "default_role": "admin",
|
|
"roles": [{"id": "admin", "tenants": ["*"]}]}))
|
|
.send()
|
|
.await?;
|
|
assert_eq!(
|
|
resp.status(),
|
|
401,
|
|
"a fork admin widened the parent's access"
|
|
);
|
|
|
|
// Nor by saving the settings form: the pointer is server-owned, so a payload naming the
|
|
// parent's database leaves the entry exactly as it was.
|
|
let resp = authed(
|
|
client().post(format!("{fork}/edit_datatable_config")),
|
|
"SECRET_TOKEN_2",
|
|
)
|
|
.json(&json!({
|
|
"settings": {"datatables": {"main": {
|
|
"database": {"resource_type": "instance", "resource_path": "dt_main"}
|
|
}}},
|
|
"renames": [], "deleted_datatables": []
|
|
}))
|
|
.send()
|
|
.await?;
|
|
assert_eq!(resp.status(), 200, "{}", resp.text().await?);
|
|
|
|
let entry: Option<Value> = sqlx::query_scalar(
|
|
"SELECT datatable->'datatables'->'main' FROM workspace_settings WHERE workspace_id = $1",
|
|
)
|
|
.bind("wm-fork-dt")
|
|
.fetch_one(&db)
|
|
.await?;
|
|
let entry = entry.unwrap();
|
|
assert_eq!(
|
|
entry["reference"]["workspace_id"], "test-workspace",
|
|
"{entry}"
|
|
);
|
|
assert!(entry["database"].is_null(), "{entry}");
|
|
Ok(())
|
|
}
|
|
|
|
#[cfg(all(feature = "private", feature = "enterprise"))]
|
|
#[sqlx::test(migrations = "../migrations", fixtures("base", "datatable_roles"))]
|
|
async fn a_second_entry_on_the_same_database_is_reported_rather_than_governed(
|
|
db: Pool<Postgres>,
|
|
) -> anyhow::Result<()> {
|
|
initialize_tracing().await;
|
|
// A copy of the parent's entry, as a fork created before data table roles would hold. It keeps
|
|
// its own access, so the owner is told about it instead of being told it is covered.
|
|
sqlx::query(
|
|
r#"UPDATE workspace_settings SET datatable = '{"datatables": {"copy": {
|
|
"database": {"resource_type": "instance", "resource_path": "dt_main"}}}}'::jsonb
|
|
WHERE workspace_id = 'wm-fork-dt'"#,
|
|
)
|
|
.execute(&db)
|
|
.await?;
|
|
|
|
let server = ApiServer::start(db.clone()).await?;
|
|
let port = server.addr.port();
|
|
let resp = authed(
|
|
client().get(format!(
|
|
"http://localhost:{port}/api/w/test-workspace/workspaces/datatable_permissions/main"
|
|
)),
|
|
"SECRET_TOKEN",
|
|
)
|
|
.send()
|
|
.await?;
|
|
let body: Value = resp.json().await?;
|
|
assert_eq!(
|
|
body["ungoverned_reachers"],
|
|
json!([{"workspace_id": "wm-fork-dt", "datatable": "copy"}]),
|
|
"{body}"
|
|
);
|
|
Ok(())
|
|
}
|
|
|
|
#[cfg(all(feature = "private", feature = "enterprise"))]
|
|
#[sqlx::test(migrations = "../migrations", fixtures("base", "datatable_roles"))]
|
|
async fn a_resource_backed_data_table_cannot_be_put_under_roles(
|
|
db: Pool<Postgres>,
|
|
) -> anyhow::Result<()> {
|
|
initialize_tracing().await;
|
|
// A role is a login on Windmill's own cluster. A resource-backed data table dials a host the
|
|
// workspace admin chose, so accepting one here would hand that host a real cluster credential.
|
|
sqlx::query(
|
|
r#"UPDATE workspace_settings SET datatable = '{"datatables": {"byo": {
|
|
"database": {"resource_type": "postgresql", "resource_path": "u/test-user/pg"}}}}'::jsonb
|
|
WHERE workspace_id = 'test-workspace'"#,
|
|
)
|
|
.execute(&db)
|
|
.await?;
|
|
|
|
let server = ApiServer::start(db.clone()).await?;
|
|
let port = server.addr.port();
|
|
let base = format!("http://localhost:{port}/api/w/test-workspace/workspaces");
|
|
|
|
let resp = authed(
|
|
client().get(format!("{base}/datatable_permissions/byo")),
|
|
"SECRET_TOKEN",
|
|
)
|
|
.send()
|
|
.await?;
|
|
let body: Value = resp.json().await?;
|
|
assert_eq!(body["supported"], false, "{body}");
|
|
|
|
let resp = authed(
|
|
client().post(format!("{base}/datatable_permissions/byo")),
|
|
"SECRET_TOKEN",
|
|
)
|
|
.json(&json!({"permissioned": true, "default_role": "role1",
|
|
"roles": [{"id": "role1", "tenants": ["*"]}]}))
|
|
.send()
|
|
.await?;
|
|
assert_eq!(resp.status(), 400, "{}", resp.text().await?);
|
|
Ok(())
|
|
}
|
|
|
|
#[sqlx::test(migrations = "../migrations", fixtures("base", "datatable_roles"))]
|
|
async fn a_fork_renaming_its_own_entry_leaves_the_governing_bookkeeping_alone(
|
|
db: Pool<Postgres>,
|
|
) -> anyhow::Result<()> {
|
|
initialize_tracing().await;
|
|
// The parent's migration definitions. A rename or delete through the fork's settings form
|
|
// resolves through the pointer, so without a guard it would relabel or wipe these.
|
|
sqlx::query(
|
|
"INSERT INTO datatable_migrations (workspace_id, datatable, timestamp, name, code_up)
|
|
VALUES ('test-workspace', 'main', 1, 'init', 'SELECT 1')",
|
|
)
|
|
.execute(&db)
|
|
.await?;
|
|
|
|
let server = ApiServer::start(db.clone()).await?;
|
|
let port = server.addr.port();
|
|
let resp = authed(
|
|
client().post(format!(
|
|
"http://localhost:{port}/api/w/wm-fork-dt/workspaces/edit_datatable_config"
|
|
)),
|
|
"SECRET_TOKEN_2",
|
|
)
|
|
.json(&json!({
|
|
"settings": {"datatables": {}},
|
|
"renames": [],
|
|
"deleted_datatables": ["main"]
|
|
}))
|
|
.send()
|
|
.await?;
|
|
assert_eq!(resp.status(), 200, "{}", resp.text().await?);
|
|
|
|
let left: i64 = sqlx::query_scalar(
|
|
"SELECT COUNT(*) FROM datatable_migrations WHERE workspace_id = 'test-workspace'",
|
|
)
|
|
.fetch_one(&db)
|
|
.await?;
|
|
assert_eq!(left, 1, "the fork's delete reached the parent's migrations");
|
|
Ok(())
|
|
}
|
|
|
|
#[cfg(all(feature = "private", feature = "enterprise"))]
|
|
#[sqlx::test(migrations = "../migrations", fixtures("base", "datatable_roles"))]
|
|
async fn a_caller_who_is_not_a_member_of_the_governing_workspace_reaches_nothing(
|
|
db: Pool<Postgres>,
|
|
) -> anyhow::Result<()> {
|
|
initialize_tracing().await;
|
|
// A fork member who was never added to the parent. Their fork membership says nothing there,
|
|
// and the email lookup that would evaluate them as a member of it finds no row.
|
|
sqlx::query(
|
|
"INSERT INTO usr (workspace_id, email, username, is_admin, role)
|
|
VALUES ('wm-fork-dt', 'test3@windmill.dev', 'test-user-3', false, 'User')",
|
|
)
|
|
.execute(&db)
|
|
.await?;
|
|
|
|
let server = ApiServer::start(db.clone()).await?;
|
|
let port = server.addr.port();
|
|
let resp = authed(
|
|
client().get(format!(
|
|
"http://localhost:{port}/api/w/wm-fork-dt/workspaces/datatable_usable_roles/main"
|
|
)),
|
|
"SECRET_TOKEN_3",
|
|
)
|
|
.send()
|
|
.await?;
|
|
assert_eq!(resp.status(), 200);
|
|
let body: Value = resp.json().await?;
|
|
assert_eq!(body["roles"], json!([]), "{body}");
|
|
Ok(())
|
|
}
|
|
|
|
#[cfg(all(feature = "private", feature = "enterprise"))]
|
|
#[sqlx::test(migrations = "../migrations", fixtures("base", "datatable_roles"))]
|
|
async fn a_caller_with_no_identity_reaches_a_permissioned_data_table_not_at_all(
|
|
db: Pool<Postgres>,
|
|
) -> anyhow::Result<()> {
|
|
use windmill_common::workspaces::{get_datatable_resource_from_db, DatatableAccess};
|
|
|
|
initialize_tracing().await;
|
|
// The compatibility story for an agent worker that predates data table roles and sends no job
|
|
// id: it keeps resolving an unpermissioned data table, and is refused on a permissioned one
|
|
// rather than handed an unattributed admin connection.
|
|
let refused = get_datatable_resource_from_db(
|
|
&db,
|
|
"test-workspace",
|
|
"main",
|
|
None,
|
|
DatatableAccess::NoIdentity,
|
|
)
|
|
.await;
|
|
assert!(refused.is_err(), "an unidentified caller was let in");
|
|
|
|
sqlx::query(
|
|
"UPDATE workspace_settings
|
|
SET datatable = datatable #- '{datatables,main,permissions}'
|
|
WHERE workspace_id = 'test-workspace'",
|
|
)
|
|
.execute(&db)
|
|
.await?;
|
|
let resolved = get_datatable_resource_from_db(
|
|
&db,
|
|
"test-workspace",
|
|
"main",
|
|
None,
|
|
DatatableAccess::NoIdentity,
|
|
)
|
|
.await?;
|
|
assert_eq!(resolved["dbname"], "dt_main", "{resolved}");
|
|
Ok(())
|
|
}
|
|
|
|
#[cfg(all(feature = "private", feature = "enterprise"))]
|
|
#[sqlx::test(migrations = "../migrations", fixtures("base", "datatable_roles"))]
|
|
async fn concurrent_role_creations_both_survive(db: Pool<Postgres>) -> anyhow::Result<()> {
|
|
initialize_tracing().await;
|
|
let server = ApiServer::start(db.clone()).await?;
|
|
let port = server.addr.port();
|
|
|
|
// Postgres roles are cluster-wide and this cluster is shared with every other test database,
|
|
// so the names have to be unique to this run.
|
|
let suffix: String = uuid::Uuid::new_v4().simple().to_string()[..8].to_string();
|
|
let names = [format!("wmtest_a_{suffix}"), format!("wmtest_b_{suffix}")];
|
|
|
|
// The cluster DDL is not visible to another transaction until commit, so without the lock both
|
|
// of these pass their `pg_roles` existence check and one loses — leaving a live cluster login
|
|
// the catalog never recorded.
|
|
let create = |name: String| async move {
|
|
let resp = authed(
|
|
client().post(format!(
|
|
"http://localhost:{port}/api/settings/datatable_roles"
|
|
)),
|
|
"SECRET_TOKEN",
|
|
)
|
|
.json(&json!({ "name": name }))
|
|
.send()
|
|
.await?;
|
|
let status = resp.status();
|
|
let body = resp.text().await?;
|
|
Ok::<_, anyhow::Error>((status, body))
|
|
};
|
|
let outcome = async {
|
|
let (a, b) = tokio::join!(create(names[0].clone()), create(names[1].clone()));
|
|
let (a, b) = (a?, b?);
|
|
assert_eq!(a.0, 200, "{}", a.1);
|
|
assert_eq!(b.0, 200, "{}", b.1);
|
|
|
|
let catalog = windmill_common::datatable_roles::read_role_catalog(&db).await?;
|
|
let recorded: Vec<&str> = catalog.values().map(|r| r.name.as_str()).collect();
|
|
for name in &names {
|
|
assert!(
|
|
recorded.contains(&name.as_str()),
|
|
"{name} is a live cluster login the catalog forgot: {recorded:?}"
|
|
);
|
|
}
|
|
Ok::<_, anyhow::Error>(())
|
|
}
|
|
.await;
|
|
|
|
// Roles are cluster-wide, so they outlive this test's throwaway database. Dropped whatever
|
|
// happened above — a failing run is exactly the one that created them and did not record them.
|
|
for name in &names {
|
|
let _ = sqlx::query(&format!("DROP ROLE IF EXISTS \"{name}\""))
|
|
.execute(&db)
|
|
.await;
|
|
}
|
|
outcome
|
|
}
|
|
|
|
#[cfg(all(feature = "private", feature = "enterprise"))]
|
|
#[sqlx::test(migrations = "../migrations", fixtures("base", "datatable_roles"))]
|
|
async fn a_role_delete_that_fails_part_way_leaves_the_role_disabled(
|
|
db: Pool<Postgres>,
|
|
) -> anyhow::Result<()> {
|
|
initialize_tracing().await;
|
|
let server = ApiServer::start(db.clone()).await?;
|
|
let port = server.addr.port();
|
|
let suffix: String = uuid::Uuid::new_v4().simple().to_string()[..8].to_string();
|
|
let name = format!("wmtest_del_{suffix}");
|
|
|
|
let outcome = async {
|
|
let created: Value = authed(
|
|
client().post(format!(
|
|
"http://localhost:{port}/api/settings/datatable_roles"
|
|
)),
|
|
"SECRET_TOKEN",
|
|
)
|
|
.json(&json!({ "name": name }))
|
|
.send()
|
|
.await?
|
|
.error_for_status()?
|
|
.json()
|
|
.await?;
|
|
let id = created["id"].as_str().unwrap().to_string();
|
|
|
|
// Each database's pass commits on its own, so one that cannot be reached fails the delete
|
|
// after the others may already have stripped the role.
|
|
sqlx::query(
|
|
"UPDATE global_settings SET value = jsonb_set(value, '{databases,wm_unreachable}', '{}')
|
|
WHERE name = 'custom_instance_pg_databases'",
|
|
)
|
|
.execute(&db)
|
|
.await?;
|
|
|
|
let resp = authed(
|
|
client().delete(format!(
|
|
"http://localhost:{port}/api/settings/datatable_roles/{id}"
|
|
)),
|
|
"SECRET_TOKEN",
|
|
)
|
|
.send()
|
|
.await?;
|
|
let status = resp.status();
|
|
let body = resp.text().await?;
|
|
assert_eq!(status, 400, "{body}");
|
|
|
|
let catalog = windmill_common::datatable_roles::read_role_catalog(&db).await?;
|
|
let role = catalog
|
|
.get(&id)
|
|
.expect("a failed delete keeps the entry to retry");
|
|
assert!(
|
|
!role.enabled,
|
|
"a half-deleted role is still enabled in the catalog"
|
|
);
|
|
let can_login: bool =
|
|
sqlx::query_scalar("SELECT rolcanlogin FROM pg_roles WHERE rolname = $1")
|
|
.bind(&name)
|
|
.fetch_one(&db)
|
|
.await?;
|
|
assert!(!can_login, "a half-deleted role can still log in");
|
|
Ok::<_, anyhow::Error>(())
|
|
}
|
|
.await;
|
|
|
|
let _ = sqlx::query(&format!("DROP ROLE IF EXISTS \"{name}\""))
|
|
.execute(&db)
|
|
.await;
|
|
outcome
|
|
}
|
|
|
|
#[cfg(all(feature = "private", feature = "enterprise"))]
|
|
#[sqlx::test(migrations = "../migrations", fixtures("base", "datatable_roles"))]
|
|
async fn renaming_a_governing_data_table_carries_its_forks(
|
|
db: Pool<Postgres>,
|
|
) -> anyhow::Result<()> {
|
|
initialize_tracing().await;
|
|
let server = ApiServer::start(db.clone()).await?;
|
|
let port = server.addr.port();
|
|
|
|
// A pointer names the governing data table by name, so a rename that does not follow leaves
|
|
// every fork resolving to nothing — the data table vanishes from their pickers and their jobs
|
|
// stop, with nothing in the renaming workspace to suggest why.
|
|
let resp = authed(
|
|
client().post(format!(
|
|
"http://localhost:{port}/api/w/test-workspace/workspaces/edit_datatable_config"
|
|
)),
|
|
"SECRET_TOKEN",
|
|
)
|
|
.json(&json!({
|
|
"settings": {"datatables": {"renamed": {
|
|
"database": {"resource_type": "instance", "resource_path": "dt_main"}
|
|
}}},
|
|
"renames": [{"from": "main", "to": "renamed"}],
|
|
"deleted_datatables": []
|
|
}))
|
|
.send()
|
|
.await?;
|
|
assert_eq!(resp.status(), 200, "{}", resp.text().await?);
|
|
|
|
let entry: Option<Value> = sqlx::query_scalar(
|
|
"SELECT datatable->'datatables'->'main' FROM workspace_settings WHERE workspace_id = $1",
|
|
)
|
|
.bind("wm-fork-dt")
|
|
.fetch_one(&db)
|
|
.await?;
|
|
let entry = entry.unwrap();
|
|
assert_eq!(entry["reference"]["datatable"], "renamed", "{entry}");
|
|
|
|
// And it still resolves, which is the thing the fork actually cares about.
|
|
let resp = authed(
|
|
client().get(format!(
|
|
"http://localhost:{port}/api/w/wm-fork-dt/workspaces/datatable_usable_roles/main"
|
|
)),
|
|
"SECRET_TOKEN_2",
|
|
)
|
|
.send()
|
|
.await?;
|
|
assert_eq!(resp.status(), 200, "{}", resp.text().await?);
|
|
Ok(())
|
|
}
|
|
|
|
#[sqlx::test(migrations = "../migrations", fixtures("base", "datatable_roles"))]
|
|
async fn a_rename_has_to_match_the_save_it_claims_to_describe(
|
|
db: Pool<Postgres>,
|
|
) -> anyhow::Result<()> {
|
|
initialize_tracing().await;
|
|
let server = ApiServer::start(db.clone()).await?;
|
|
let port = server.addr.port();
|
|
let url =
|
|
format!("http://localhost:{port}/api/w/test-workspace/workspaces/edit_datatable_config");
|
|
|
|
let instance =
|
|
|path: &str| json!({"database": {"resource_type": "instance", "resource_path": path}});
|
|
|
|
// Fork pointers are rewritten from the rename list, so a rename nobody performed moves every
|
|
// fork of one data table onto another. `main` survives this save, so it was not renamed.
|
|
let resp = authed(client().post(&url), "SECRET_TOKEN")
|
|
.json(&json!({
|
|
"settings": {"datatables": {"main": instance("dt_main"), "decoy": instance("dt_two")}},
|
|
"renames": [{"from": "main", "to": "decoy"}],
|
|
"deleted_datatables": []
|
|
}))
|
|
.send()
|
|
.await?;
|
|
assert_eq!(resp.status(), 400, "a forged rename was accepted");
|
|
|
|
let entry: Option<Value> = sqlx::query_scalar(
|
|
"SELECT datatable->'datatables'->'main'->'reference' FROM workspace_settings
|
|
WHERE workspace_id = $1",
|
|
)
|
|
.bind("wm-fork-dt")
|
|
.fetch_one(&db)
|
|
.await?;
|
|
assert_eq!(
|
|
entry.unwrap()["datatable"],
|
|
"main",
|
|
"the fork was repointed anyway"
|
|
);
|
|
|
|
// A swap is two renames whose sources and targets cross. It cannot be done one at a time —
|
|
// `datatables` is keyed by name — so refusing it would be a regression, and applying the two
|
|
// in order without a temporary name would carry `main`'s pointers back to `main`.
|
|
let resp = authed(client().post(&url), "SECRET_TOKEN")
|
|
.json(&json!({
|
|
"settings": {"datatables": {"main": instance("dt_two"), "other": instance("dt_main")}},
|
|
"renames": [{"from": "main", "to": "other"}, {"from": "other", "to": "main"}],
|
|
"deleted_datatables": []
|
|
}))
|
|
.send()
|
|
.await?;
|
|
// `other` does not exist yet, so this particular pair is still refused — the swap shape is
|
|
// covered by the pair below, which starts from two real data tables.
|
|
assert_eq!(resp.status(), 400, "{}", resp.text().await?);
|
|
|
|
sqlx::query(
|
|
r#"UPDATE workspace_settings SET datatable = jsonb_set(datatable, '{datatables,other}',
|
|
'{"database": {"resource_type": "instance", "resource_path": "dt_two"}}'::jsonb)
|
|
WHERE workspace_id = 'test-workspace'"#,
|
|
)
|
|
.execute(&db)
|
|
.await?;
|
|
|
|
let resp = authed(client().post(&url), "SECRET_TOKEN")
|
|
.json(&json!({
|
|
"settings": {"datatables": {"main": instance("dt_two"), "other": instance("dt_main")}},
|
|
"renames": [{"from": "main", "to": "other"}, {"from": "other", "to": "main"}],
|
|
"deleted_datatables": []
|
|
}))
|
|
.send()
|
|
.await?;
|
|
assert_eq!(
|
|
resp.status(),
|
|
200,
|
|
"a swap was refused: {}",
|
|
resp.text().await?
|
|
);
|
|
|
|
// The fork named `main`, which is now called `other`.
|
|
let entry: Option<Value> = sqlx::query_scalar(
|
|
"SELECT datatable->'datatables'->'main'->'reference' FROM workspace_settings
|
|
WHERE workspace_id = $1",
|
|
)
|
|
.bind("wm-fork-dt")
|
|
.fetch_one(&db)
|
|
.await?;
|
|
assert_eq!(
|
|
entry.unwrap()["datatable"],
|
|
"other",
|
|
"the swap did not carry the pointer"
|
|
);
|
|
Ok(())
|
|
}
|
|
|
|
#[sqlx::test(migrations = "../migrations", fixtures("base", "datatable_roles"))]
|
|
async fn a_data_table_under_roles_is_not_copied_into_a_fork(
|
|
db: Pool<Postgres>,
|
|
) -> anyhow::Result<()> {
|
|
initialize_tracing().await;
|
|
// `pg_dump` carries no roles and the restore drops ACLs, so a copy would arrive with the
|
|
// parent's tenants and none of the grants behind them: every role but admin denied by
|
|
// Postgres in a data table that reads as configured. Refuse the copy rather than ship that,
|
|
// and refuse it before any data moves.
|
|
let server = ApiServer::start(db.clone()).await?;
|
|
let port = server.addr.port();
|
|
|
|
// Both halves of the clone: the database the copy would land in, then the copy itself. The
|
|
// first has to refuse too, or a permissioned fork leaves an empty registered database that
|
|
// no data table entry names and nothing collects.
|
|
let resp = authed(
|
|
client().post(format!(
|
|
"http://localhost:{port}/api/w/test-workspace/workspaces/create_pg_database"
|
|
)),
|
|
"SECRET_TOKEN",
|
|
)
|
|
.json(&json!({"source": "datatable://main", "target_dbname": "wm_fork_dt_copy"}))
|
|
.send()
|
|
.await?;
|
|
assert_eq!(resp.status(), 400);
|
|
assert!(
|
|
resp.text().await?.contains("under roles"),
|
|
"the fork's database was created for a copy that cannot happen"
|
|
);
|
|
|
|
let resp = authed(
|
|
client().post(format!(
|
|
"http://localhost:{port}/api/w/test-workspace/workspaces/import_pg_database"
|
|
)),
|
|
"SECRET_TOKEN",
|
|
)
|
|
.json(
|
|
&json!({"source": "datatable://main", "target": "datatable://main",
|
|
"fork_behavior": "schema_only"}),
|
|
)
|
|
.send()
|
|
.await?;
|
|
assert_eq!(resp.status(), 400);
|
|
assert!(
|
|
resp.text().await?.contains("under roles"),
|
|
"the copy was refused for some other reason"
|
|
);
|
|
Ok(())
|
|
}
|
|
|
|
/// The fork's `forked_from` for one of its entries; `None` whether it is absent or `null`.
|
|
async fn forked_from_of(db: &Pool<Postgres>, name: &str) -> Option<Value> {
|
|
sqlx::query_scalar::<_, Option<Value>>(
|
|
"SELECT datatable->'datatables'->$1::text->'forked_from'
|
|
FROM workspace_settings WHERE workspace_id = 'wm-fork-dt'",
|
|
)
|
|
.bind(name)
|
|
.fetch_one(db)
|
|
.await
|
|
.unwrap()
|
|
.filter(|v| !v.is_null())
|
|
}
|
|
|
|
#[sqlx::test(migrations = "../migrations", fixtures("base", "datatable_roles"))]
|
|
async fn a_clone_stamp_is_carried_but_its_schema_baseline_advances(
|
|
db: Pool<Postgres>,
|
|
) -> anyhow::Result<()> {
|
|
initialize_tracing().await;
|
|
// Whether an entry is a clone is what marks its database droppable, so a save can neither
|
|
// stamp nor unstamp one. The schema baseline inside the stamp is what the fork's schema diff
|
|
// advances after applying a change; dropping it would offer that same change again.
|
|
sqlx::query(
|
|
r#"UPDATE workspace_settings SET datatable = '{"datatables": {
|
|
"clone": {"database": {"resource_type": "instance", "resource_path": "wm_fork_dt__clone"},
|
|
"forked_from": {"schema": {}}},
|
|
"plain": {"database": {"resource_type": "instance", "resource_path": "dt_plain"}}}}'::jsonb
|
|
WHERE workspace_id = 'wm-fork-dt'"#,
|
|
)
|
|
.execute(&db)
|
|
.await?;
|
|
let server = ApiServer::start(db.clone()).await?;
|
|
let url = format!(
|
|
"http://localhost:{}/api/w/wm-fork-dt/workspaces/edit_datatable_config",
|
|
server.addr.port()
|
|
);
|
|
let clone_db = json!({"resource_type": "instance", "resource_path": "wm_fork_dt__clone"});
|
|
let plain_db = json!({"resource_type": "instance", "resource_path": "dt_plain"});
|
|
let baseline = json!({"schema": {"public": {"orders": {"id": "int4"}}}});
|
|
|
|
let resp = authed(client().post(&url), "SECRET_TOKEN_2")
|
|
.json(&json!({"settings": {"datatables": {
|
|
"clone": {"database": clone_db, "forked_from": baseline},
|
|
"plain": {"database": plain_db, "forked_from": {"schema": {}}}
|
|
}}}))
|
|
.send()
|
|
.await?;
|
|
assert_eq!(resp.status(), 200, "{}", resp.text().await?);
|
|
assert_eq!(
|
|
forked_from_of(&db, "clone").await,
|
|
Some(baseline.clone()),
|
|
"the schema diff's baseline did not advance"
|
|
);
|
|
assert_eq!(
|
|
forked_from_of(&db, "plain").await,
|
|
None,
|
|
"a save stamped a clone"
|
|
);
|
|
|
|
let resp = authed(client().post(&url), "SECRET_TOKEN_2")
|
|
.json(&json!({"settings": {"datatables": {
|
|
"clone": {"database": clone_db}, "plain": {"database": plain_db}
|
|
}}}))
|
|
.send()
|
|
.await?;
|
|
assert_eq!(resp.status(), 200, "{}", resp.text().await?);
|
|
assert_eq!(
|
|
forked_from_of(&db, "clone").await,
|
|
Some(baseline),
|
|
"a save unstamped a clone"
|
|
);
|
|
Ok(())
|
|
}
|
|
|
|
#[cfg(all(feature = "private", feature = "enterprise"))]
|
|
#[sqlx::test(migrations = "../migrations", fixtures("base", "datatable_roles"))]
|
|
async fn roles_cannot_be_turned_on_while_a_trigger_streams_the_data_table(
|
|
db: Pool<Postgres>,
|
|
) -> anyhow::Result<()> {
|
|
initialize_tracing().await;
|
|
// A replication stream reads every row whatever the roles grant, so a data table carries one
|
|
// or the other. An enabled trigger on it — here a fork's, through its pointer — keeps roles
|
|
// from being turned on, and disabling it is what lets them on.
|
|
sqlx::query(
|
|
"UPDATE workspace_settings SET datatable = datatable #- '{datatables,main,permissions}'
|
|
WHERE workspace_id = 'test-workspace'",
|
|
)
|
|
.execute(&db)
|
|
.await?;
|
|
sqlx::query(
|
|
r#"INSERT INTO postgres_trigger (path, script_path, is_flow, workspace_id, edited_by,
|
|
postgres_resource_path, replication_slot_name, publication_name, permissioned_as, mode)
|
|
VALUES ('u/test-user-2/fork_stream', 'u/test-user-2/s', false, 'wm-fork-dt',
|
|
'test-user-2', 'datatable://main', 'slot_fork', 'pub_fork', 'u/test-user-2',
|
|
'enabled')"#,
|
|
)
|
|
.execute(&db)
|
|
.await?;
|
|
|
|
let server = ApiServer::start(db.clone()).await?;
|
|
let url = format!(
|
|
"http://localhost:{}/api/w/test-workspace/workspaces/datatable_permissions/main",
|
|
server.addr.port()
|
|
);
|
|
let turn_on = json!({"permissioned": true, "default_role": "admin",
|
|
"roles": [{"id": "admin", "tenants": ["*"]}]});
|
|
|
|
let resp = authed(client().post(&url), "SECRET_TOKEN")
|
|
.json(&turn_on)
|
|
.send()
|
|
.await?;
|
|
assert_eq!(resp.status(), 400);
|
|
assert!(
|
|
resp.text()
|
|
.await?
|
|
.contains("wm-fork-dt/u/test-user-2/fork_stream"),
|
|
"the refusal does not name the trigger to disable"
|
|
);
|
|
|
|
// Disabled, but its listener pinged just now and stops only at its next heartbeat.
|
|
sqlx::query(
|
|
"UPDATE postgres_trigger SET mode = 'disabled', server_id = NULL, last_server_ping = now()
|
|
WHERE path = 'u/test-user-2/fork_stream'",
|
|
)
|
|
.execute(&db)
|
|
.await?;
|
|
let resp = authed(client().post(&url), "SECRET_TOKEN")
|
|
.json(&turn_on)
|
|
.send()
|
|
.await?;
|
|
assert_eq!(
|
|
resp.status(),
|
|
400,
|
|
"roles went on while a disabled trigger's listener was still attached: {}",
|
|
resp.text().await?
|
|
);
|
|
|
|
sqlx::query(
|
|
"UPDATE postgres_trigger SET last_server_ping = now() - interval '20 seconds'
|
|
WHERE path = 'u/test-user-2/fork_stream'",
|
|
)
|
|
.execute(&db)
|
|
.await?;
|
|
let resp = authed(client().post(&url), "SECRET_TOKEN")
|
|
.json(&turn_on)
|
|
.send()
|
|
.await?;
|
|
assert_eq!(resp.status(), 200, "{}", resp.text().await?);
|
|
Ok(())
|
|
}
|
|
|
|
#[cfg(all(feature = "private", feature = "enterprise"))]
|
|
#[sqlx::test(migrations = "../migrations", fixtures("base", "datatable_roles"))]
|
|
async fn roles_going_on_wait_for_a_trigger_being_enabled(db: Pool<Postgres>) -> anyhow::Result<()> {
|
|
initialize_tracing().await;
|
|
sqlx::query(
|
|
"UPDATE workspace_settings SET datatable = datatable #- '{datatables,main,permissions}'
|
|
WHERE workspace_id = 'test-workspace'",
|
|
)
|
|
.execute(&db)
|
|
.await?;
|
|
|
|
// A trigger enable in flight: it holds the stream lock and its row is not committed yet, so a
|
|
// roles save that looked for streams now would miss it and its listener would connect to a
|
|
// data table it is about to be refused.
|
|
let mut enabling = db.begin().await?;
|
|
windmill_common::datatable_roles::lock_datatable_streams(&mut *enabling, false).await?;
|
|
sqlx::query(
|
|
r#"INSERT INTO postgres_trigger (path, script_path, is_flow, workspace_id, edited_by,
|
|
postgres_resource_path, replication_slot_name, publication_name, permissioned_as, mode)
|
|
VALUES ('u/test-user-2/racing_stream', 'u/test-user-2/s', false, 'wm-fork-dt',
|
|
'test-user-2', 'datatable://main', 'slot_race', 'pub_race', 'u/test-user-2',
|
|
'enabled')"#,
|
|
)
|
|
.execute(&mut *enabling)
|
|
.await?;
|
|
|
|
let server = ApiServer::start(db.clone()).await?;
|
|
let url = format!(
|
|
"http://localhost:{}/api/w/test-workspace/workspaces/datatable_permissions/main",
|
|
server.addr.port()
|
|
);
|
|
let save = tokio::spawn(
|
|
authed(client().post(&url), "SECRET_TOKEN")
|
|
.json(&json!({"permissioned": true, "default_role": "admin",
|
|
"roles": [{"id": "admin", "tenants": ["*"]}]}))
|
|
.send(),
|
|
);
|
|
tokio::time::sleep(std::time::Duration::from_millis(500)).await;
|
|
assert!(
|
|
!save.is_finished(),
|
|
"roles went on while a trigger was being enabled"
|
|
);
|
|
enabling.commit().await?;
|
|
|
|
let resp = save.await??;
|
|
assert_eq!(resp.status(), 400);
|
|
assert!(
|
|
resp.text()
|
|
.await?
|
|
.contains("wm-fork-dt/u/test-user-2/racing_stream"),
|
|
"the roles save missed the trigger enabled while it waited"
|
|
);
|
|
Ok(())
|
|
}
|
|
|
|
#[sqlx::test(migrations = "../migrations", fixtures("base", "datatable_roles"))]
|
|
async fn a_stored_name_containing_a_question_mark_resolves_as_itself(
|
|
db: Pool<Postgres>,
|
|
) -> anyhow::Result<()> {
|
|
initialize_tracing().await;
|
|
// Names could contain `?` before they were restricted, and such an entry is still stored.
|
|
sqlx::query(
|
|
"UPDATE workspace_settings
|
|
SET datatable = jsonb_set(datatable, '{datatables,legacy?dt}', datatable->'datatables'->'main')
|
|
WHERE workspace_id = 'test-workspace'",
|
|
)
|
|
.execute(&db)
|
|
.await?;
|
|
|
|
let resolve = |reference: &'static str| {
|
|
let db = db.clone();
|
|
async move {
|
|
windmill_common::workspaces::parse_datatable_ref_for(&db, "test-workspace", reference)
|
|
.await
|
|
}
|
|
};
|
|
assert_eq!(resolve("legacy?dt").await?, ("legacy?dt".to_string(), None));
|
|
assert_eq!(
|
|
resolve("main?role=analytics").await?,
|
|
("main".to_string(), Some("analytics".to_string()))
|
|
);
|
|
assert!(
|
|
resolve("main?dt").await.is_err(),
|
|
"an unknown parameter was ignored"
|
|
);
|
|
|
|
sqlx::query(
|
|
"UPDATE workspace_settings
|
|
SET datatable = jsonb_set(datatable, '{datatables,main?role=analytics}', datatable->'datatables'->'main')
|
|
WHERE workspace_id = 'test-workspace'",
|
|
)
|
|
.execute(&db)
|
|
.await?;
|
|
assert!(
|
|
resolve("main?role=analytics").await.is_err(),
|
|
"a reference naming both a stored data table and a role on another resolved to one of them"
|
|
);
|
|
Ok(())
|
|
}
|
|
|
|
#[sqlx::test(migrations = "../migrations", fixtures("base", "datatable_roles"))]
|
|
async fn a_settings_save_dropping_a_governing_entry_names_the_forks_it_strands(
|
|
db: Pool<Postgres>,
|
|
) -> anyhow::Result<()> {
|
|
initialize_tracing().await;
|
|
let server = ApiServer::start(db.clone()).await?;
|
|
// The whole map and no `deleted_datatables`, as a settings sync sends it.
|
|
let resp = authed(
|
|
client().post(format!(
|
|
"http://localhost:{}/api/w/test-workspace/workspaces/edit_datatable_config",
|
|
server.addr.port()
|
|
)),
|
|
"SECRET_TOKEN",
|
|
)
|
|
.json(&json!({ "settings": { "datatables": {} } }))
|
|
.send()
|
|
.await?;
|
|
let status = resp.status();
|
|
let body = resp.text().await?;
|
|
assert_eq!(status, 200, "{body}");
|
|
let result: Value = serde_json::from_str(&body)?;
|
|
assert!(
|
|
result["stranded_references"]
|
|
.as_array()
|
|
.is_some_and(|refs| refs.iter().any(|r| r["workspace_id"] == "wm-fork-dt")),
|
|
"the fork left pointing at nothing was not named: {body}"
|
|
);
|
|
Ok(())
|
|
}
|
|
|
|
#[sqlx::test(migrations = "../migrations", fixtures("base", "datatable_roles"))]
|
|
async fn an_entry_without_roles_cannot_newly_reach_a_database_under_roles(
|
|
db: Pool<Postgres>,
|
|
) -> anyhow::Result<()> {
|
|
initialize_tracing().await;
|
|
sqlx::query(
|
|
r#"UPDATE workspace_settings SET datatable = jsonb_set(datatable, '{datatables,other}',
|
|
'{"database": {"resource_type": "instance", "resource_path": "dt_other"}}')
|
|
WHERE workspace_id = 'test-workspace'"#,
|
|
)
|
|
.execute(&db)
|
|
.await?;
|
|
let server = ApiServer::start(db.clone()).await?;
|
|
let port = server.addr.port();
|
|
|
|
// Whole-map saves with no `renames`, as a settings sync sends them.
|
|
let dt_main =
|
|
json!({ "database": { "resource_type": "instance", "resource_path": "dt_main" } });
|
|
let dt_other =
|
|
json!({ "database": { "resource_type": "instance", "resource_path": "dt_other" } });
|
|
for (case, w_id, datatables) in [
|
|
(
|
|
"a rename to a new name",
|
|
"test-workspace",
|
|
json!({ "main_renamed": dt_main, "other": dt_other }),
|
|
),
|
|
(
|
|
"an existing name repointed",
|
|
"test-workspace",
|
|
json!({ "other": dt_main }),
|
|
),
|
|
(
|
|
"another workspace's entry",
|
|
"wm-fork-dt",
|
|
json!({ "direct": dt_main }),
|
|
),
|
|
] {
|
|
let resp = authed(
|
|
client().post(format!(
|
|
"http://localhost:{port}/api/w/{w_id}/workspaces/edit_datatable_config"
|
|
)),
|
|
"SECRET_TOKEN",
|
|
)
|
|
.json(&json!({ "settings": { "datatables": datatables } }))
|
|
.send()
|
|
.await?;
|
|
let status = resp.status();
|
|
let body = resp.text().await?;
|
|
assert!(
|
|
status == 400 && body.contains("which a data table under roles uses"),
|
|
"{case} reached the database under roles without them ({status}): {body}"
|
|
);
|
|
}
|
|
|
|
let still_governed: bool = sqlx::query_scalar(
|
|
"SELECT (datatable->'datatables'->'main') ? 'permissions' FROM workspace_settings
|
|
WHERE workspace_id = 'test-workspace'",
|
|
)
|
|
.fetch_one(&db)
|
|
.await?;
|
|
assert!(still_governed, "the refused save still took effect");
|
|
Ok(())
|
|
}
|
|
|
|
/// Browsing names the role it connects as, and a role the caller may not use is refused rather
|
|
/// than quietly listed as the default. The refusal is decided before connecting, so the fixture's
|
|
/// database never has to exist.
|
|
#[cfg(all(feature = "private", feature = "enterprise"))]
|
|
#[sqlx::test(migrations = "../migrations", fixtures("base", "datatable_roles"))]
|
|
async fn browsing_as_a_role_the_caller_may_not_use_is_refused(
|
|
db: Pool<Postgres>,
|
|
) -> anyhow::Result<()> {
|
|
initialize_tracing().await;
|
|
let server = ApiServer::start(db.clone()).await?;
|
|
let port = server.addr.port();
|
|
let base = format!("http://localhost:{port}/api/w/test-workspace/workspaces");
|
|
|
|
// `test-user-2` is a tenant of `analytics` only.
|
|
let resp = authed(
|
|
client().get(format!(
|
|
"{base}/list_datatable_tables?role_for=main&role=admin"
|
|
)),
|
|
"SECRET_TOKEN_2",
|
|
)
|
|
.send()
|
|
.await?;
|
|
assert_eq!(resp.status(), 200);
|
|
let body: Value = resp.json().await?;
|
|
let entry = body
|
|
.as_array()
|
|
.and_then(|a| a.iter().find(|e| e["datatable_name"] == "main"))
|
|
.expect("main is listed");
|
|
assert_eq!(entry["usable_roles"], json!(["analytics"]), "{entry}");
|
|
assert_eq!(entry["default_role"], "analytics", "{entry}");
|
|
assert_eq!(entry["permissioned"], true, "{entry}");
|
|
assert_eq!(entry["instance"], true, "{entry}");
|
|
let error = entry["error"].as_str().unwrap_or_default();
|
|
assert!(
|
|
error.contains("Not allowed to use role 'admin'"),
|
|
"listed as another role than the one asked for: {entry}"
|
|
);
|
|
|
|
let resp = authed(
|
|
client().get(format!(
|
|
"{base}/get_datatable_table_schema?datatable_name=main&schema_name=public&table_name=t&role=admin"
|
|
)),
|
|
"SECRET_TOKEN_2",
|
|
)
|
|
.send()
|
|
.await?;
|
|
let status = resp.status();
|
|
let text = resp.text().await?;
|
|
assert!(
|
|
text.contains("Not allowed to use role 'admin'"),
|
|
"{status}: {text}"
|
|
);
|
|
|
|
// A role means nothing without the data table it belongs to.
|
|
let resp = authed(
|
|
client().get(format!("{base}/list_datatable_tables?role=analytics")),
|
|
"SECRET_TOKEN_2",
|
|
)
|
|
.send()
|
|
.await?;
|
|
assert_eq!(resp.status(), 400, "{}", resp.text().await?);
|
|
Ok(())
|
|
}
|
|
|
|
#[sqlx::test(migrations = "../migrations", fixtures("base", "datatable_roles"))]
|
|
async fn an_alias_saved_elsewhere_waits_for_roles_going_on_for_its_database(
|
|
db: Pool<Postgres>,
|
|
) -> anyhow::Result<()> {
|
|
initialize_tracing().await;
|
|
sqlx::query(
|
|
r#"UPDATE workspace_settings SET datatable = jsonb_set(datatable, '{datatables,other}',
|
|
'{"database": {"resource_type": "instance", "resource_path": "dt_other"}}')
|
|
WHERE workspace_id = 'test-workspace'"#,
|
|
)
|
|
.execute(&db)
|
|
.await?;
|
|
|
|
// Roles going on for `dt_other`, not committed yet: it holds only its own workspace's settings
|
|
// row, so an alias saved from another workspace that looked for roles now would miss them.
|
|
let enabling = {
|
|
let mut tx = db.begin().await?;
|
|
windmill_common::datatable_roles::lock_instance_databases_governance(
|
|
&mut *tx,
|
|
["dt_other"],
|
|
)
|
|
.await?;
|
|
sqlx::query(
|
|
r#"UPDATE workspace_settings SET datatable = jsonb_set(datatable,
|
|
'{datatables,other,permissions}',
|
|
'{"default_role": "admin", "roles": {"admin": {"tenants": ["*"]}}}')
|
|
WHERE workspace_id = 'test-workspace'"#,
|
|
)
|
|
.execute(&mut *tx)
|
|
.await?;
|
|
tx
|
|
};
|
|
|
|
let server = ApiServer::start(db.clone()).await?;
|
|
let url = format!(
|
|
"http://localhost:{}/api/w/wm-fork-dt/workspaces/edit_datatable_config",
|
|
server.addr.port()
|
|
);
|
|
let save = tokio::spawn(
|
|
authed(client().post(&url), "SECRET_TOKEN")
|
|
.json(&json!({ "settings": { "datatables": {
|
|
"direct": { "database": { "resource_type": "instance", "resource_path": "dt_other" } }
|
|
} } }))
|
|
.send(),
|
|
);
|
|
tokio::time::sleep(std::time::Duration::from_millis(500)).await;
|
|
assert!(
|
|
!save.is_finished(),
|
|
"an alias was saved while roles were going on for its database"
|
|
);
|
|
enabling.commit().await?;
|
|
|
|
let resp = save.await??;
|
|
let status = resp.status();
|
|
let body = resp.text().await?;
|
|
assert!(
|
|
status == 400 && body.contains("which a data table under roles uses"),
|
|
"the alias reached the database whose roles went on while it waited ({status}): {body}"
|
|
);
|
|
Ok(())
|
|
}
|
|
|
|
#[sqlx::test(migrations = "../migrations", fixtures("base", "datatable_roles"))]
|
|
async fn a_fork_waits_for_a_rename_of_the_data_table_it_keeps(
|
|
db: Pool<Postgres>,
|
|
) -> anyhow::Result<()> {
|
|
initialize_tracing().await;
|
|
// A rename in flight holds the parent's settings row and moves only the pointers it can see; a
|
|
// fork being created is invisible to it, so the fork has to read the name the rename commits.
|
|
let mut renaming = db.begin().await?;
|
|
sqlx::query(
|
|
"SELECT 1 FROM workspace_settings WHERE workspace_id = 'test-workspace' FOR UPDATE",
|
|
)
|
|
.execute(&mut *renaming)
|
|
.await?;
|
|
sqlx::query(
|
|
"UPDATE workspace_settings SET datatable = jsonb_set(datatable #- '{datatables,main}',
|
|
'{datatables,renamed}', datatable->'datatables'->'main')
|
|
WHERE workspace_id = 'test-workspace'",
|
|
)
|
|
.execute(&mut *renaming)
|
|
.await?;
|
|
|
|
let server = ApiServer::start(db.clone()).await?;
|
|
let url = format!(
|
|
"http://localhost:{}/api/w/test-workspace/workspaces/create_fork",
|
|
server.addr.port()
|
|
);
|
|
let fork = tokio::spawn(
|
|
authed(client().post(&url), "SECRET_TOKEN")
|
|
.json(&json!({ "id": "wm-fork-race", "name": "race", "color": "#0000ff" }))
|
|
.send(),
|
|
);
|
|
tokio::time::sleep(std::time::Duration::from_millis(500)).await;
|
|
assert!(
|
|
!fork.is_finished(),
|
|
"the fork copied the parent's data tables while a rename held them"
|
|
);
|
|
renaming.commit().await?;
|
|
|
|
let resp = fork.await??;
|
|
assert!(resp.status().is_success(), "{}", resp.text().await?);
|
|
let datatables: Option<Value> = sqlx::query_scalar(
|
|
"SELECT datatable->'datatables' FROM workspace_settings WHERE workspace_id = 'wm-fork-race'",
|
|
)
|
|
.fetch_one(&db)
|
|
.await?;
|
|
let datatables = datatables.unwrap();
|
|
assert_eq!(
|
|
datatables["renamed"]["reference"],
|
|
json!({ "workspace_id": "test-workspace", "datatable": "renamed" }),
|
|
"{datatables}"
|
|
);
|
|
Ok(())
|
|
}
|
|
|
|
#[cfg(not(all(feature = "private", feature = "enterprise")))]
|
|
const ENTERPRISE_REFUSAL: &str = "Data table roles are a Windmill Enterprise Edition feature";
|
|
|
|
#[cfg(not(all(feature = "private", feature = "enterprise")))]
|
|
#[sqlx::test(migrations = "../migrations", fixtures("base", "datatable_roles"))]
|
|
async fn every_roles_route_is_an_enterprise_feature(db: Pool<Postgres>) -> anyhow::Result<()> {
|
|
initialize_tracing().await;
|
|
let server = ApiServer::start(db.clone()).await?;
|
|
let api = format!("http://localhost:{}/api", server.addr.port());
|
|
let dt = format!("{api}/w/test-workspace/workspaces");
|
|
|
|
for (method, url, body) in [
|
|
(
|
|
reqwest::Method::GET,
|
|
format!("{api}/settings/datatable_roles"),
|
|
None,
|
|
),
|
|
(
|
|
reqwest::Method::POST,
|
|
format!("{api}/settings/datatable_roles"),
|
|
Some(json!({ "name": "wmtest_ce" })),
|
|
),
|
|
(
|
|
reqwest::Method::POST,
|
|
format!("{api}/settings/datatable_roles/role1"),
|
|
Some(json!({ "enabled": false })),
|
|
),
|
|
(
|
|
reqwest::Method::DELETE,
|
|
format!("{api}/settings/datatable_roles/role1"),
|
|
None,
|
|
),
|
|
(
|
|
reqwest::Method::GET,
|
|
format!("{dt}/datatable_permissions/main"),
|
|
None,
|
|
),
|
|
(
|
|
reqwest::Method::POST,
|
|
format!("{dt}/datatable_permissions/main"),
|
|
Some(json!({ "permissioned": false })),
|
|
),
|
|
(
|
|
reqwest::Method::GET,
|
|
format!("{dt}/datatable_usable_roles/main"),
|
|
None,
|
|
),
|
|
] {
|
|
let mut request = authed(client().request(method.clone(), &url), "SECRET_TOKEN");
|
|
if let Some(body) = body {
|
|
request = request.json(&body);
|
|
}
|
|
let resp = request.send().await?;
|
|
let status = resp.status();
|
|
let text = resp.text().await?;
|
|
assert!(
|
|
status == 400 && text.contains(ENTERPRISE_REFUSAL),
|
|
"{method} {url} answered {status}: {text}"
|
|
);
|
|
}
|
|
|
|
// Refused, not acted on: the catalog row and the data table's roles are where they were.
|
|
let untouched: (i64, bool) = sqlx::query_as(
|
|
"SELECT (SELECT count(*) FROM datatable_role),
|
|
(datatable->'datatables'->'main') ? 'permissions'
|
|
FROM workspace_settings WHERE workspace_id = 'test-workspace'",
|
|
)
|
|
.fetch_one(&db)
|
|
.await?;
|
|
assert_eq!(untouched, (1, true));
|
|
Ok(())
|
|
}
|
|
|
|
#[cfg(not(all(feature = "private", feature = "enterprise")))]
|
|
#[sqlx::test(migrations = "../migrations", fixtures("base", "datatable_roles"))]
|
|
async fn without_the_enterprise_edition_a_data_table_under_roles_is_refused_a_connection(
|
|
db: Pool<Postgres>,
|
|
) -> anyhow::Result<()> {
|
|
use windmill_common::workspaces::{get_datatable_resource_from_db, DatatableAccess};
|
|
initialize_tracing().await;
|
|
|
|
// Saved under roles, as an enterprise build left it: refused whoever asks, never `admin`.
|
|
for access in [DatatableAccess::Unchecked, DatatableAccess::NoIdentity] {
|
|
let err = get_datatable_resource_from_db(&db, "test-workspace", "main", None, access)
|
|
.await
|
|
.expect_err("a data table under roles resolved");
|
|
assert!(err.to_string().contains(ENTERPRISE_REFUSAL), "{err}");
|
|
}
|
|
|
|
// Not under roles, it resolves as it always has, including when `admin` is named — which every
|
|
// migration does; naming any other role on it is refused.
|
|
sqlx::query(
|
|
"UPDATE workspace_settings SET datatable = datatable #- '{datatables,main,permissions}'
|
|
WHERE workspace_id = 'test-workspace'",
|
|
)
|
|
.execute(&db)
|
|
.await?;
|
|
for role in [None, Some("admin")] {
|
|
let resolved = get_datatable_resource_from_db(
|
|
&db,
|
|
"test-workspace",
|
|
"main",
|
|
role,
|
|
DatatableAccess::NoIdentity,
|
|
)
|
|
.await?;
|
|
assert_eq!(resolved["dbname"], "dt_main", "{resolved}");
|
|
}
|
|
let err = get_datatable_resource_from_db(
|
|
&db,
|
|
"test-workspace",
|
|
"main",
|
|
Some("analytics"),
|
|
DatatableAccess::Unchecked,
|
|
)
|
|
.await
|
|
.expect_err("a named role resolved");
|
|
assert!(err.to_string().contains(ENTERPRISE_REFUSAL), "{err}");
|
|
Ok(())
|
|
}
|