mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-10-14 00:02:55 +00:00
833 lines
30 KiB
Rust
833 lines
30 KiB
Rust
use serde_json::json;
|
|
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}"))
|
|
}
|
|
|
|
const MAIN_KEY: &str = "instance:dt_main";
|
|
|
|
/// A data table `main` on the instance database `dt_main`, in `w`.
|
|
async fn plant_main(db: &Pool<Postgres>, w: &str) {
|
|
sqlx::query(
|
|
"INSERT INTO workspace_settings (workspace_id, datatable) VALUES ($1, $2)
|
|
ON CONFLICT (workspace_id) DO UPDATE SET datatable = EXCLUDED.datatable",
|
|
)
|
|
.bind(w)
|
|
.bind(json!({
|
|
"datatables": {
|
|
"main": { "database": { "resource_type": "instance", "resource_path": "dt_main" } }
|
|
}
|
|
}))
|
|
.execute(db)
|
|
.await
|
|
.unwrap();
|
|
}
|
|
|
|
/// Permissions on a database, owned by `owner`, with one `analyst` role.
|
|
async fn plant_permissions(db: &Pool<Postgres>, key: &str, owner: &str, analyst_tenants: &[&str]) {
|
|
sqlx::query(
|
|
"INSERT INTO datatable_database_permissions (database_key, owner_workspace_id, permissions)
|
|
VALUES ($1, $2, $3)",
|
|
)
|
|
.bind(key)
|
|
.bind(owner)
|
|
.bind(json!({ "enabled": true, "roles": {
|
|
"admin": { "tenants": [] },
|
|
"analyst": { "tenants": analyst_tenants, "pg_rolename": "wm_analyst_x", "pg_password": "pw" }
|
|
}}))
|
|
.execute(db)
|
|
.await
|
|
.unwrap();
|
|
}
|
|
|
|
/// Who the `analyst` role of `key` currently lets run as it.
|
|
async fn tenants(db: &Pool<Postgres>, key: &str) -> Vec<String> {
|
|
let value: serde_json::Value = sqlx::query_scalar(
|
|
"SELECT permissions->'roles'->'analyst'->'tenants'
|
|
FROM datatable_database_permissions WHERE database_key = $1",
|
|
)
|
|
.bind(key)
|
|
.fetch_one(db)
|
|
.await
|
|
.unwrap();
|
|
serde_json::from_value(value).unwrap()
|
|
}
|
|
|
|
async fn usable_roles(port: u16, w: &str, datatable: &str, token: &str) -> serde_json::Value {
|
|
let resp = authed(
|
|
client().get(format!(
|
|
"http://localhost:{port}/api/w/{w}/workspaces/datatable_usable_roles/{datatable}"
|
|
)),
|
|
token,
|
|
)
|
|
.send()
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(resp.status(), 200, "{}", resp.text().await.unwrap());
|
|
resp.json().await.unwrap()
|
|
}
|
|
|
|
/// A tenant is a name, and a name outlives the principal that held it: whoever
|
|
/// takes it next would run as the role it still names. Every route that frees
|
|
/// one has to take it off the role, which is easy to miss from any single one of
|
|
/// them — so they are pinned together.
|
|
#[sqlx::test(migrations = "../migrations", fixtures("base"))]
|
|
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 ws = format!("http://localhost:{port}/api/w/test-workspace");
|
|
|
|
plant_main(&db, "test-workspace").await;
|
|
plant_permissions(
|
|
&db,
|
|
MAIN_KEY,
|
|
"test-workspace",
|
|
&[
|
|
"*",
|
|
"u/test-user-2",
|
|
"u/test-user-3",
|
|
"g/leaving_group",
|
|
"f/leaving_folder",
|
|
],
|
|
)
|
|
.await;
|
|
|
|
for (endpoint, body) in [
|
|
("groups/create", json!({ "name": "leaving_group" })),
|
|
("folders/create", json!({ "name": "leaving_folder" })),
|
|
] {
|
|
let resp = authed(client().post(format!("{ws}/{endpoint}")), "SECRET_TOKEN")
|
|
.json(&body)
|
|
.send()
|
|
.await?;
|
|
assert_eq!(resp.status(), 200, "{endpoint}: {}", resp.text().await?);
|
|
}
|
|
|
|
let resp = authed(
|
|
client().delete(format!("{ws}/groups/delete/leaving_group")),
|
|
"SECRET_TOKEN",
|
|
)
|
|
.send()
|
|
.await?;
|
|
assert_eq!(resp.status(), 200, "delete group: {}", resp.text().await?);
|
|
assert!(!tenants(&db, MAIN_KEY)
|
|
.await
|
|
.contains(&"g/leaving_group".to_string()));
|
|
|
|
let resp = authed(
|
|
client().delete(format!("{ws}/folders/delete/leaving_folder")),
|
|
"SECRET_TOKEN",
|
|
)
|
|
.send()
|
|
.await?;
|
|
assert_eq!(resp.status(), 200, "delete folder: {}", resp.text().await?);
|
|
assert!(!tenants(&db, MAIN_KEY)
|
|
.await
|
|
.contains(&"f/leaving_folder".to_string()));
|
|
|
|
// Leaving frees the username as surely as an admin removing the member does.
|
|
let resp = authed(client().post(format!("{ws}/users/leave")), "SECRET_TOKEN_2")
|
|
.send()
|
|
.await?;
|
|
assert_eq!(resp.status(), 200, "leave: {}", resp.text().await?);
|
|
assert!(!tenants(&db, MAIN_KEY)
|
|
.await
|
|
.contains(&"u/test-user-2".to_string()));
|
|
|
|
let resp = authed(
|
|
client().delete(format!(
|
|
"http://localhost:{port}/api/users/delete/test3@windmill.dev"
|
|
)),
|
|
"SECRET_TOKEN",
|
|
)
|
|
.send()
|
|
.await?;
|
|
assert_eq!(resp.status(), 200, "global delete: {}", resp.text().await?);
|
|
assert!(!tenants(&db, MAIN_KEY)
|
|
.await
|
|
.contains(&"u/test-user-3".to_string()));
|
|
|
|
// What no deletion named is left alone — the wildcard above all, which is
|
|
// not a principal and cannot be freed.
|
|
assert_eq!(tenants(&db, MAIN_KEY).await, vec!["*".to_string()]);
|
|
|
|
Ok(())
|
|
}
|
|
|
|
/// The permissions are the database's, so a fork's copy of the data table reaches
|
|
/// the same roles — evaluated as a member of the workspace that owns them. Being
|
|
/// admin of the fork, which any member is of a fork they made, counts for
|
|
/// nothing; a superadmin reaches every role from anywhere; and the roles are
|
|
/// managed from the owning workspace alone.
|
|
#[sqlx::test(migrations = "../migrations", fixtures("base"))]
|
|
async fn a_fork_copy_is_evaluated_as_a_member_of_the_owning_workspace(
|
|
db: Pool<Postgres>,
|
|
) -> anyhow::Result<()> {
|
|
initialize_tracing().await;
|
|
let server = ApiServer::start(db.clone()).await?;
|
|
let port = server.addr.port();
|
|
|
|
plant_main(&db, "test-workspace").await;
|
|
plant_permissions(&db, MAIN_KEY, "test-workspace", &["u/test-user-3"]).await;
|
|
sqlx::query(
|
|
"INSERT INTO workspace (id, name, owner, parent_workspace_id)
|
|
VALUES ('wm-fork-t', 'wm-fork-t', 'test2@windmill.dev', 'test-workspace')",
|
|
)
|
|
.execute(&db)
|
|
.await?;
|
|
plant_main(&db, "wm-fork-t").await;
|
|
// test-user-2 made the fork and is admin of it, and is no member of the parent.
|
|
sqlx::query(
|
|
"INSERT INTO usr (workspace_id, email, username, is_admin, role) VALUES
|
|
('wm-fork-t', 'test2@windmill.dev', 'test-user-2', true, 'Admin'),
|
|
('wm-fork-t', 'test3@windmill.dev', 'test-user-3', false, 'User')",
|
|
)
|
|
.execute(&db)
|
|
.await?;
|
|
sqlx::query(
|
|
"DELETE FROM usr WHERE workspace_id = 'test-workspace' AND email = 'test2@windmill.dev'",
|
|
)
|
|
.execute(&db)
|
|
.await?;
|
|
|
|
let fork_admin = usable_roles(port, "wm-fork-t", "main", "SECRET_TOKEN_2").await;
|
|
assert_eq!(fork_admin["enabled"], json!(true));
|
|
assert_eq!(fork_admin["roles"], json!([]), "{fork_admin}");
|
|
|
|
let tenant = usable_roles(port, "wm-fork-t", "main", "SECRET_TOKEN_3").await;
|
|
assert_eq!(tenant["roles"], json!(["analyst"]), "{tenant}");
|
|
|
|
let superadmin = usable_roles(port, "wm-fork-t", "main", "SECRET_TOKEN").await;
|
|
assert_eq!(
|
|
superadmin["roles"],
|
|
json!(["admin", "analyst"]),
|
|
"{superadmin}"
|
|
);
|
|
|
|
// Managed from the owning workspace: the fork's admin reads them, changes nothing.
|
|
let resp = authed(
|
|
client().get(format!(
|
|
"http://localhost:{port}/api/w/wm-fork-t/workspaces/datatable_permissions/main"
|
|
)),
|
|
"SECRET_TOKEN_2",
|
|
)
|
|
.send()
|
|
.await?;
|
|
assert_eq!(resp.status(), 200, "{}", resp.text().await?);
|
|
let info: serde_json::Value = resp.json().await?;
|
|
assert_eq!(info["owner_workspace_id"], json!("test-workspace"));
|
|
assert_eq!(info["editable"], json!(false));
|
|
let resp = authed(
|
|
client().post(format!(
|
|
"http://localhost:{port}/api/w/wm-fork-t/workspaces/datatable_permissions/main"
|
|
)),
|
|
"SECRET_TOKEN_2",
|
|
)
|
|
.json(&json!({ "enabled": true, "roles": [
|
|
{ "name": "admin", "tenants": [] }, { "name": "analyst", "tenants": ["u/test-user-2"] }
|
|
]}))
|
|
.send()
|
|
.await?;
|
|
let status = resp.status().as_u16();
|
|
let text = resp.text().await?;
|
|
assert_eq!(status, 401, "{text}");
|
|
assert!(
|
|
text.contains("managed from workspace 'test-workspace'"),
|
|
"{text}"
|
|
);
|
|
|
|
Ok(())
|
|
}
|
|
|
|
/// A resource-backed database is the host, port and database its resource
|
|
/// resolves to: entries naming it under other paths and other logins, in other
|
|
/// workspaces, reach the same permissions; a resource pointed at another database
|
|
/// reaches none.
|
|
#[sqlx::test(migrations = "../migrations", fixtures("base"))]
|
|
async fn entries_reaching_one_database_share_its_permissions(
|
|
db: Pool<Postgres>,
|
|
) -> anyhow::Result<()> {
|
|
initialize_tracing().await;
|
|
let server = ApiServer::start(db.clone()).await?;
|
|
let port = server.addr.port();
|
|
|
|
sqlx::query("INSERT INTO workspace (id, name, owner) VALUES ('other', 'other', 'test-user')")
|
|
.execute(&db)
|
|
.await?;
|
|
sqlx::query(
|
|
"INSERT INTO usr (workspace_id, email, username, is_admin, role)
|
|
VALUES ('other', 'test3@windmill.dev', 'user-three', false, 'User')",
|
|
)
|
|
.execute(&db)
|
|
.await?;
|
|
let plant = |w: &'static str, path: &'static str, user: &'static str, dbname: &'static str| {
|
|
let db = db.clone();
|
|
async move {
|
|
sqlx::query(
|
|
"INSERT INTO resource (workspace_id, path, value, resource_type, created_by, edited_at)
|
|
VALUES ($1, $2, $3, 'postgresql', 'test-user', now())",
|
|
)
|
|
.bind(w)
|
|
.bind(path)
|
|
.bind(json!({ "host": "db.example", "port": 5432, "dbname": dbname, "user": user, "password": "pw" }))
|
|
.execute(&db)
|
|
.await
|
|
.unwrap();
|
|
sqlx::query(
|
|
"INSERT INTO workspace_settings (workspace_id, datatable) VALUES ($1, $2)
|
|
ON CONFLICT (workspace_id) DO UPDATE SET datatable = EXCLUDED.datatable",
|
|
)
|
|
.bind(w)
|
|
.bind(json!({ "datatables": {
|
|
"byo": { "database": { "resource_type": "postgresql", "resource_path": path } }
|
|
}}))
|
|
.execute(&db)
|
|
.await
|
|
.unwrap();
|
|
}
|
|
};
|
|
plant("test-workspace", "u/test-user/pg", "app", "prod").await;
|
|
plant("other", "f/moved/pg", "postgres", "prod").await;
|
|
|
|
let key = windmill_common::workspaces::datatable_database_key(
|
|
&windmill_common::workspaces::DataTableDatabase {
|
|
resource_type: windmill_common::workspaces::DataTableCatalogResourceType::Postgresql,
|
|
resource_path: "u/test-user/pg".to_string(),
|
|
},
|
|
&json!({ "host": "db.example", "port": 5432, "dbname": "prod" }),
|
|
);
|
|
plant_permissions(&db, &key, "test-workspace", &["u/test-user-3"]).await;
|
|
|
|
// test-user-3 is `test-user-3` in the owning workspace and `user-three` in
|
|
// `other`: the tenant is matched where it was written.
|
|
let from_other = usable_roles(port, "other", "byo", "SECRET_TOKEN_3").await;
|
|
assert_eq!(from_other["enabled"], json!(true));
|
|
assert_eq!(from_other["roles"], json!(["analyst"]), "{from_other}");
|
|
|
|
sqlx::query(
|
|
"UPDATE resource SET value = jsonb_set(value, '{dbname}', '\"staging\"')
|
|
WHERE workspace_id = 'other' AND path = 'f/moved/pg'",
|
|
)
|
|
.execute(&db)
|
|
.await?;
|
|
let elsewhere = usable_roles(port, "other", "byo", "SECRET_TOKEN_3").await;
|
|
assert_eq!(elsewhere["enabled"], json!(false), "{elsewhere}");
|
|
|
|
Ok(())
|
|
}
|
|
|
|
/// The permissions a workspace owns follow it through a change of its id.
|
|
#[sqlx::test(migrations = "../migrations", fixtures("base"))]
|
|
async fn a_rename_moves_the_permissions_it_owns(db: Pool<Postgres>) -> anyhow::Result<()> {
|
|
initialize_tracing().await;
|
|
let server = ApiServer::start(db.clone()).await?;
|
|
let port = server.addr.port();
|
|
|
|
plant_main(&db, "test-workspace").await;
|
|
plant_permissions(&db, MAIN_KEY, "test-workspace", &["u/test-user-3"]).await;
|
|
|
|
let resp = authed(
|
|
client().post(format!(
|
|
"http://localhost:{port}/api/w/test-workspace/workspaces/change_workspace_id"
|
|
)),
|
|
"SECRET_TOKEN",
|
|
)
|
|
.json(&json!({ "new_id": "renamed-ws", "new_name": "Renamed" }))
|
|
.send()
|
|
.await?;
|
|
assert_eq!(resp.status(), 200, "{}", resp.text().await?);
|
|
|
|
let owner: String = sqlx::query_scalar(
|
|
"SELECT owner_workspace_id FROM datatable_database_permissions WHERE database_key = $1",
|
|
)
|
|
.bind(MAIN_KEY)
|
|
.fetch_one(&db)
|
|
.await?;
|
|
assert_eq!(owner, "renamed-ws");
|
|
let roles = usable_roles(port, "renamed-ws", "main", "SECRET_TOKEN_3").await;
|
|
assert_eq!(roles["roles"], json!(["analyst"]), "{roles}");
|
|
|
|
Ok(())
|
|
}
|
|
|
|
/// A fork that keeps the original copies the parent's entry verbatim, `forked_from`
|
|
/// included when the parent's own entry is a clone. The stamp means "cloned into
|
|
/// this workspace's own database" — it is what lets the fork's deletion drop that
|
|
/// database — so a copy must not carry one, and the config form may update the
|
|
/// schema snapshot inside an existing stamp but never add or remove one.
|
|
#[sqlx::test(migrations = "../migrations", fixtures("base"))]
|
|
async fn a_kept_original_does_not_inherit_the_clone_stamp(
|
|
db: Pool<Postgres>,
|
|
) -> anyhow::Result<()> {
|
|
initialize_tracing().await;
|
|
let server = ApiServer::start(db.clone()).await?;
|
|
let port = server.addr.port();
|
|
let ws = format!("http://localhost:{port}/api/w/test-workspace");
|
|
|
|
sqlx::query(
|
|
r#"UPDATE workspace_settings SET datatable = $1 WHERE workspace_id = 'test-workspace'"#,
|
|
)
|
|
.bind(json!({
|
|
"datatables": {
|
|
"byo": {
|
|
"database": { "resource_type": "postgresql", "resource_path": "u/test-user/pg" },
|
|
"forked_from": { "schema": {} }
|
|
}
|
|
}
|
|
}))
|
|
.execute(&db)
|
|
.await?;
|
|
|
|
let resp = authed(
|
|
client().post(format!("{ws}/workspaces/create_fork")),
|
|
"SECRET_TOKEN",
|
|
)
|
|
.json(&json!({ "id": "wm-fork-kept", "name": "kept" }))
|
|
.send()
|
|
.await?;
|
|
assert_eq!(resp.status(), 200, "{}", resp.text().await?);
|
|
|
|
let copy: serde_json::Value = sqlx::query_scalar(
|
|
"SELECT datatable->'datatables'->'byo' FROM workspace_settings WHERE workspace_id = 'wm-fork-kept'",
|
|
)
|
|
.fetch_one(&db)
|
|
.await?;
|
|
assert_eq!(copy["database"]["resource_path"], json!("u/test-user/pg"));
|
|
assert!(copy.get("forked_from").is_none(), "{copy}");
|
|
|
|
let save = |w: &'static str, datatables: serde_json::Value| async move {
|
|
let resp = authed(
|
|
client().post(format!(
|
|
"http://localhost:{port}/api/w/{w}/workspaces/edit_datatable_config"
|
|
)),
|
|
"SECRET_TOKEN",
|
|
)
|
|
.json(&json!({ "settings": { "datatables": datatables } }))
|
|
.send()
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(resp.status(), 200, "{}", resp.text().await.unwrap());
|
|
};
|
|
let stamp = |w: &'static str| {
|
|
let db = db.clone();
|
|
async move {
|
|
let entry: serde_json::Value = sqlx::query_scalar(
|
|
"SELECT datatable->'datatables'->'byo' FROM workspace_settings WHERE workspace_id = $1",
|
|
)
|
|
.bind(w)
|
|
.fetch_one(&db)
|
|
.await
|
|
.unwrap();
|
|
entry["forked_from"].clone()
|
|
}
|
|
};
|
|
let byo =
|
|
json!({ "database": { "resource_type": "postgresql", "resource_path": "u/test-user/pg" } });
|
|
// The form cannot stamp the copy...
|
|
save(
|
|
"wm-fork-kept",
|
|
json!({ "byo": {
|
|
"database": byo["database"], "forked_from": { "schema": {} }
|
|
}}),
|
|
)
|
|
.await;
|
|
assert_eq!(stamp("wm-fork-kept").await, serde_json::Value::Null);
|
|
// ...nor take the parent's stamp away, and it may update the snapshot inside it.
|
|
save("test-workspace", json!({ "byo": byo })).await;
|
|
assert_eq!(stamp("test-workspace").await, json!({ "schema": {} }));
|
|
save(
|
|
"test-workspace",
|
|
json!({ "byo": {
|
|
"database": byo["database"], "forked_from": { "schema": { "t": ["id"] } }
|
|
}}),
|
|
)
|
|
.await;
|
|
assert_eq!(
|
|
stamp("test-workspace").await,
|
|
json!({ "schema": { "t": ["id"] } })
|
|
);
|
|
|
|
Ok(())
|
|
}
|
|
|
|
/// A save carries the whole role list the drawer loaded, so it can name a tenant
|
|
/// another admin's deletion took off the role in between, and it can leave a
|
|
/// role stored migrations still carry in their `-- role` annotation undefined.
|
|
/// Both are refused rather than written; turning permissions off is not.
|
|
#[sqlx::test(migrations = "../migrations", fixtures("base"))]
|
|
async fn a_save_names_only_what_exists(db: Pool<Postgres>) -> anyhow::Result<()> {
|
|
initialize_tracing().await;
|
|
let server = ApiServer::start(db.clone()).await?;
|
|
let port = server.addr.port();
|
|
let ws = format!("http://localhost:{port}/api/w/test-workspace");
|
|
|
|
plant_main(&db, "test-workspace").await;
|
|
plant_permissions(&db, MAIN_KEY, "test-workspace", &["u/test-user"]).await;
|
|
// Spelled the way the parser accepts and a `-- role ` search would miss.
|
|
sqlx::query(
|
|
"INSERT INTO datatable_migrations (workspace_id, datatable, timestamp, name, code_up, code_down)
|
|
VALUES ('test-workspace', 'main', 1, 'add_orders', '--role analyst\nCREATE TABLE orders ()', NULL)",
|
|
)
|
|
.execute(&db)
|
|
.await?;
|
|
|
|
let preview = |body: serde_json::Value| {
|
|
let ws = ws.clone();
|
|
async move {
|
|
let resp = authed(
|
|
client().post(format!(
|
|
"{ws}/workspaces/datatable_permissions/main/preview"
|
|
)),
|
|
"SECRET_TOKEN",
|
|
)
|
|
.json(&body)
|
|
.send()
|
|
.await
|
|
.unwrap();
|
|
(resp.status().as_u16(), resp.text().await.unwrap())
|
|
}
|
|
};
|
|
|
|
let (status, text) = preview(json!({ "enabled": true, "roles": [
|
|
{ "name": "admin", "tenants": [] },
|
|
{ "name": "analyst", "tenants": ["u/test-user", "u/ghost", "g/nobody", "f/nowhere", "*"] }
|
|
]}))
|
|
.await;
|
|
assert_eq!(status, 400, "{text}");
|
|
assert!(text.contains("f/nowhere, g/nobody, u/ghost"), "{text}");
|
|
|
|
let (status, text) = preview(json!({ "enabled": true,
|
|
"roles": [{ "name": "admin", "tenants": [] }, { "name": "reader", "tenants": ["u/test-user"] }],
|
|
"renames": [{ "from": "analyst", "to": "reader" }]
|
|
}))
|
|
.await;
|
|
assert_eq!(status, 400, "{text}");
|
|
assert!(text.contains("'add_orders' (role 'analyst')"), "{text}");
|
|
|
|
let (status, text) = preview(json!({ "enabled": true,
|
|
"roles": [{ "name": "admin", "tenants": [] }]
|
|
}))
|
|
.await;
|
|
assert_eq!(status, 400, "{text}");
|
|
assert!(text.contains("'add_orders' (role 'analyst')"), "{text}");
|
|
|
|
// The roles are the database's: a migration of another entry reaching it,
|
|
// here an alias in the same workspace, is stranded all the same.
|
|
sqlx::query(
|
|
r#"UPDATE workspace_settings
|
|
SET datatable = jsonb_set(datatable, '{datatables,alias}',
|
|
'{"database": {"resource_type": "instance", "resource_path": "dt_main"}}')
|
|
WHERE workspace_id = 'test-workspace'"#,
|
|
)
|
|
.execute(&db)
|
|
.await?;
|
|
sqlx::query(
|
|
"UPDATE datatable_migrations SET datatable = 'alias' WHERE workspace_id = 'test-workspace'",
|
|
)
|
|
.execute(&db)
|
|
.await?;
|
|
let (status, text) = preview(json!({ "enabled": true,
|
|
"roles": [{ "name": "admin", "tenants": [] }]
|
|
}))
|
|
.await;
|
|
assert_eq!(status, 400, "{text}");
|
|
assert!(
|
|
text.contains("test-workspace/alias: 'add_orders' (role 'analyst')"),
|
|
"{text}"
|
|
);
|
|
sqlx::query("DELETE FROM datatable_migrations WHERE workspace_id = 'test-workspace'")
|
|
.execute(&db)
|
|
.await?;
|
|
|
|
// Turning permissions off ignores the submitted roles and is never refused,
|
|
// stale tenant or not: it gets as far as the database this test lacks.
|
|
let (_, text) = preview(json!({ "enabled": false, "roles": [
|
|
{ "name": "admin", "tenants": [] },
|
|
{ "name": "analyst", "tenants": ["u/ghost"] }
|
|
]}))
|
|
.await;
|
|
assert!(
|
|
!text.contains("no longer exist") && !text.contains("Migration(s)"),
|
|
"{text}"
|
|
);
|
|
|
|
// The same save with what exists gets past both checks, to the database this
|
|
// test does not have.
|
|
let (_, text) = preview(json!({ "enabled": true, "roles": [
|
|
{ "name": "admin", "tenants": [] },
|
|
{ "name": "analyst", "tenants": ["u/test-user", "*"] }
|
|
]}))
|
|
.await;
|
|
assert!(
|
|
!text.contains("no longer exist") && !text.contains("Migration(s)"),
|
|
"{text}"
|
|
);
|
|
|
|
Ok(())
|
|
}
|
|
|
|
/// Deleting a workspace takes only the roles it owns with it. A fork that held a
|
|
/// copy of the parent's data table owns nothing there, so its deletion leaves the
|
|
/// parent's row alone; the owner's own deletion drops its logins but leaves the
|
|
/// row, ownerless and closed: every role refused, only a superadmin through.
|
|
#[sqlx::test(migrations = "../migrations", fixtures("base"))]
|
|
async fn a_deletion_takes_only_the_permissions_it_owns(db: Pool<Postgres>) -> anyhow::Result<()> {
|
|
initialize_tracing().await;
|
|
let server = ApiServer::start(db.clone()).await?;
|
|
let port = server.addr.port();
|
|
|
|
plant_main(&db, "test-workspace").await;
|
|
plant_permissions(&db, MAIN_KEY, "test-workspace", &["u/test-user-3"]).await;
|
|
sqlx::query(
|
|
"INSERT INTO workspace (id, name, owner, parent_workspace_id)
|
|
VALUES ('wm-fork-t', 'wm-fork-t', 'test2@windmill.dev', 'test-workspace')",
|
|
)
|
|
.execute(&db)
|
|
.await?;
|
|
plant_main(&db, "wm-fork-t").await;
|
|
sqlx::query(
|
|
"INSERT INTO usr (workspace_id, email, username, is_admin, role) VALUES
|
|
('wm-fork-t', 'test2@windmill.dev', 'test-user-2', true, 'Admin'),
|
|
('wm-fork-t', 'test3@windmill.dev', 'test-user-3', false, 'User')",
|
|
)
|
|
.execute(&db)
|
|
.await?;
|
|
|
|
// The fork's owner deletes it: the parent's row is untouched.
|
|
let resp = authed(
|
|
client().delete(format!(
|
|
"http://localhost:{port}/api/workspaces/delete/wm-fork-t"
|
|
)),
|
|
"SECRET_TOKEN_2",
|
|
)
|
|
.send()
|
|
.await?;
|
|
assert_eq!(resp.status(), 200, "{}", resp.text().await?);
|
|
let owner: Option<String> = sqlx::query_scalar(
|
|
"SELECT owner_workspace_id FROM datatable_database_permissions WHERE database_key = $1",
|
|
)
|
|
.bind(MAIN_KEY)
|
|
.fetch_one(&db)
|
|
.await?;
|
|
assert_eq!(owner.as_deref(), Some("test-workspace"));
|
|
let roles = usable_roles(port, "test-workspace", "main", "SECRET_TOKEN_3").await;
|
|
assert_eq!(roles["roles"], json!(["analyst"]), "{roles}");
|
|
|
|
// Another workspace reaching the same database, then the owner is deleted.
|
|
sqlx::query(
|
|
"INSERT INTO workspace (id, name, owner) VALUES ('elsewhere', 'elsewhere', 'test-user')",
|
|
)
|
|
.execute(&db)
|
|
.await?;
|
|
plant_main(&db, "elsewhere").await;
|
|
sqlx::query(
|
|
"INSERT INTO usr (workspace_id, email, username, is_admin, role)
|
|
VALUES ('elsewhere', 'test3@windmill.dev', 'test-user-3', true, 'Admin')",
|
|
)
|
|
.execute(&db)
|
|
.await?;
|
|
let resp = authed(
|
|
client().delete(format!(
|
|
"http://localhost:{port}/api/workspaces/delete/test-workspace"
|
|
)),
|
|
"SECRET_TOKEN",
|
|
)
|
|
.send()
|
|
.await?;
|
|
assert_eq!(resp.status(), 200, "{}", resp.text().await?);
|
|
let owner: Option<String> = sqlx::query_scalar(
|
|
"SELECT owner_workspace_id FROM datatable_database_permissions WHERE database_key = $1",
|
|
)
|
|
.bind(MAIN_KEY)
|
|
.fetch_one(&db)
|
|
.await?;
|
|
assert_eq!(owner, None);
|
|
// Admin of `elsewhere`, and the tenant the row names: neither counts without an owner.
|
|
let closed = usable_roles(port, "elsewhere", "main", "SECRET_TOKEN_3").await;
|
|
assert_eq!(closed["enabled"], json!(true));
|
|
assert_eq!(closed["roles"], json!([]), "{closed}");
|
|
let superadmin = usable_roles(port, "elsewhere", "main", "SECRET_TOKEN").await;
|
|
assert_eq!(
|
|
superadmin["roles"],
|
|
json!(["admin", "analyst"]),
|
|
"{superadmin}"
|
|
);
|
|
// Nobody but a superadmin manages it now: an admin of `elsewhere` reads it, changes nothing.
|
|
let resp = authed(
|
|
client().get(format!(
|
|
"http://localhost:{port}/api/w/elsewhere/workspaces/datatable_permissions/main"
|
|
)),
|
|
"SECRET_TOKEN_3",
|
|
)
|
|
.send()
|
|
.await?;
|
|
let status = resp.status().as_u16();
|
|
let text = resp.text().await?;
|
|
assert_eq!(status, 200, "{text}");
|
|
let info: serde_json::Value = serde_json::from_str(&text)?;
|
|
assert_eq!(info["editable"], json!(false), "{info}");
|
|
|
|
Ok(())
|
|
}
|
|
|
|
/// An export carries a database's roles and tenants, never its login names or
|
|
/// passwords; importing them governs a database nobody governs yet, owned by
|
|
/// the importing workspace, with every role refused until a save creates the
|
|
/// logins. A database already governed is left alone and reported.
|
|
#[sqlx::test(migrations = "../migrations", fixtures("base"))]
|
|
async fn imported_permissions_govern_without_logins(db: Pool<Postgres>) -> anyhow::Result<()> {
|
|
initialize_tracing().await;
|
|
let server = ApiServer::start(db.clone()).await?;
|
|
let port = server.addr.port();
|
|
let ws = format!("http://localhost:{port}/api/w/test-workspace");
|
|
|
|
sqlx::query(
|
|
"INSERT INTO workspace_settings (workspace_id, datatable) VALUES ('test-workspace', $1)
|
|
ON CONFLICT (workspace_id) DO UPDATE SET datatable = EXCLUDED.datatable",
|
|
)
|
|
.bind(json!({ "datatables": {
|
|
"main": { "database": { "resource_type": "instance", "resource_path": "dt_main" } },
|
|
"other": { "database": { "resource_type": "instance", "resource_path": "dt_other" } }
|
|
}}))
|
|
.execute(&db)
|
|
.await?;
|
|
plant_permissions(&db, "instance:dt_other", "test-workspace", &["*"]).await;
|
|
let import = |rows: serde_json::Value| {
|
|
let ws = ws.clone();
|
|
async move {
|
|
let resp = authed(
|
|
client().post(format!("{ws}/workspaces/datatable_permissions_import")),
|
|
"SECRET_TOKEN",
|
|
)
|
|
.json(&rows)
|
|
.send()
|
|
.await
|
|
.unwrap();
|
|
let status = resp.status().as_u16();
|
|
let text = resp.text().await.unwrap();
|
|
(status, text)
|
|
}
|
|
};
|
|
let exported = json!([
|
|
{ "datatable": "main", "permissions": { "enabled": true, "roles": {
|
|
"admin": { "tenants": [] },
|
|
"analyst": { "tenants": ["u/test-user-3"], "pg_rolename": "wm_analyst_x", "pg_password": "leaked?" }
|
|
}}},
|
|
{ "datatable": "other", "permissions": { "enabled": true, "roles": { "admin": { "tenants": [] } } } }
|
|
]);
|
|
let (status, text) = import(exported).await;
|
|
assert_eq!(status, 200, "{text}");
|
|
assert_eq!(text, "[\"other\"]");
|
|
// A database is named through a data table of this workspace, never by key.
|
|
let (status, text) = import(json!([
|
|
{ "datatable": "nope", "permissions": { "enabled": true, "roles": { "admin": { "tenants": [] } } } }
|
|
]))
|
|
.await;
|
|
assert_eq!(status, 404, "{text}");
|
|
// An archive's tenant whose principal is gone is refused like a save's would be.
|
|
sqlx::query("DELETE FROM datatable_database_permissions WHERE database_key = $1")
|
|
.bind(MAIN_KEY)
|
|
.execute(&db)
|
|
.await?;
|
|
let (status, text) = import(json!([
|
|
{ "datatable": "main", "permissions": { "enabled": true, "roles": {
|
|
"admin": { "tenants": ["u/ghost"] }
|
|
}}}
|
|
]))
|
|
.await;
|
|
assert_eq!(status, 400, "{text}");
|
|
assert!(text.contains("u/ghost"), "{text}");
|
|
let (status, text) = import(json!([
|
|
{ "datatable": "main", "permissions": { "enabled": true, "roles": {
|
|
"admin": { "tenants": [] },
|
|
"analyst": { "tenants": ["u/test-user-3"], "pg_rolename": "wm_analyst_x", "pg_password": "leaked?" }
|
|
}}}
|
|
]))
|
|
.await;
|
|
assert_eq!(status, 200, "{text}");
|
|
|
|
let row: (Option<String>, serde_json::Value) = sqlx::query_as(
|
|
"SELECT owner_workspace_id, permissions FROM datatable_database_permissions WHERE database_key = $1",
|
|
)
|
|
.bind(MAIN_KEY)
|
|
.fetch_one(&db)
|
|
.await?;
|
|
assert_eq!(row.0.as_deref(), Some("test-workspace"));
|
|
// A login name is a cluster-wide identifier the next save would rename or
|
|
// reset, so it is not taken from an import either.
|
|
assert!(
|
|
row.1["roles"]["analyst"].get("pg_rolename").is_none(),
|
|
"{}",
|
|
row.1
|
|
);
|
|
assert!(
|
|
row.1["roles"]["analyst"].get("pg_password").is_none(),
|
|
"{}",
|
|
row.1
|
|
);
|
|
// The tenant is listed as usable; resolving the role, which has no login
|
|
// yet, gives the caller nothing rather than the owning connection.
|
|
let roles = usable_roles(port, "test-workspace", "main", "SECRET_TOKEN_3").await;
|
|
assert_eq!(roles["roles"], json!(["analyst"]), "{roles}");
|
|
let resp = authed(
|
|
client().get(format!(
|
|
"{ws}/workspaces/get_datatable_table_schema?datatable_name=main&schema_name=public&table_name=t&role=analyst"
|
|
)),
|
|
"SECRET_TOKEN_3",
|
|
)
|
|
.send()
|
|
.await?;
|
|
let status = resp.status().as_u16();
|
|
let text = resp.text().await?;
|
|
assert_eq!(status, 401, "{text}");
|
|
assert!(text.contains("has no login yet"), "{text}");
|
|
|
|
Ok(())
|
|
}
|
|
|
|
/// A clone of a permissioned data table would be a database of its own that
|
|
/// nothing governs, readable in full by every member of the fork: refused at the
|
|
/// one place a caller cannot go around, the fork creation that wires it in.
|
|
#[sqlx::test(migrations = "../migrations", fixtures("base"))]
|
|
async fn a_governed_datatable_cannot_be_cloned_into_a_fork(
|
|
db: Pool<Postgres>,
|
|
) -> anyhow::Result<()> {
|
|
initialize_tracing().await;
|
|
let server = ApiServer::start(db.clone()).await?;
|
|
let port = server.addr.port();
|
|
|
|
plant_main(&db, "test-workspace").await;
|
|
plant_permissions(&db, MAIN_KEY, "test-workspace", &["*"]).await;
|
|
let resp = authed(
|
|
client().post(format!(
|
|
"http://localhost:{port}/api/w/test-workspace/workspaces/create_fork"
|
|
)),
|
|
"SECRET_TOKEN",
|
|
)
|
|
.json(&json!({
|
|
"id": "wm-fork-clone", "name": "clone",
|
|
"forked_datatables": [{ "name": "main", "new_dbname": "wm_fork_clone_main" }]
|
|
}))
|
|
.send()
|
|
.await?;
|
|
let status = resp.status().as_u16();
|
|
let text = resp.text().await?;
|
|
assert_eq!(status, 400, "{text}");
|
|
assert!(text.contains("cannot be cloned"), "{text}");
|
|
let exists: bool =
|
|
sqlx::query_scalar("SELECT EXISTS (SELECT 1 FROM workspace WHERE id = 'wm-fork-clone')")
|
|
.fetch_one(&db)
|
|
.await?;
|
|
assert!(!exists);
|
|
|
|
Ok(())
|
|
}
|