/* * Author: Ruben Fiszel * Copyright: Windmill Labs, Inc 2022 * This file and its contents are licensed under the AGPLv3 License. * Please see the included NOTICE for copyright information and * LICENSE-AGPL for a copy of the license. */ //! Copying a data table's database for the fork being created. //! //! The fork request makes its copies before it writes the fork: each is created, filled and — for a //! data table under roles — given the source's owners and grants. What each copy was made from //! stays in the request ([`MadeCopy`]), so the fork's transaction checks it against the source as it //! is then, and a fork that fails drops the instance copies it made ([`drop_copies_after`]) and names //! the others, which live on servers of the workspace's own. use std::collections::BTreeSet; use windmill_api_auth::ApiAuthed; use windmill_common::datatable_roles::{lock_role_catalog, read_role_catalog_tx}; use windmill_common::error::{pg_error_message, Error, Result}; use windmill_common::utils::require_admin; use windmill_common::worker::CLOUD_HOSTED; use windmill_common::workspaces::{ get_datatable_resource_from_db_unchecked, DataTableDatabase, DataTableForkBehavior, GoverningDatatable, }; use windmill_common::{PgDatabase, DB}; use crate::datatable_acl::connect_with_notices; use crate::datatable_permissions::ensure_reaches_governing_datatable; use crate::workspaces::{ create_database_on_server, ensure_datatable_is_clonable, pg_dump_database, pg_import_dump, DumpFile, PgDumpOptions, }; /// A database this request created and filled for one data table of the fork. pub(crate) struct MadeCopy { /// The data table's name, in the parent and in the fork. pub(crate) name: String, pub(crate) dbname: String, pub(crate) behavior: DataTableForkBehavior, /// The database the source resolved to when it was copied. pub(crate) source_database: DataTableDatabase, /// Whether the source's owners and grants were replayed into the copy: it was under roles. pub(crate) replayed: bool, /// For a resource-backed copy, the resource and variables its connection was resolved from. pub(crate) connection: Option, } /// What a resource-backed data table's connection is resolved from: its resource, and every /// resource and variable that one references, as stored — and, for a secret kept in an external /// backend, the value that backend holds. The connection is resolved from the snapshot itself /// ([`ConnectionSnapshot::resolve`]), so it is exactly the one these rows describe. #[derive(PartialEq)] pub(crate) struct ConnectionSnapshot { root: String, resources: std::collections::BTreeMap>, /// Stored value and whether it is secret. variables: std::collections::BTreeMap>, external_secrets: std::collections::BTreeMap, } /// Read the [`ConnectionSnapshot`] of resource `resource_path` in workspace `w_id`. /// /// Authorization: none, and it holds secret values. Callers MUST have authorized using that /// resource, and never disclose the snapshot or what it resolves to. pub(crate) async fn connection_snapshot( db: &DB, conn: &mut sqlx::PgConnection, w_id: &str, resource_path: &str, ) -> Result { let root = resource_path.trim_start_matches("$res:").to_string(); let mut snapshot = ConnectionSnapshot { root: root.clone(), resources: Default::default(), variables: Default::default(), external_secrets: Default::default(), }; let mut pending = vec![root]; while let Some(path) = pending.pop() { if snapshot.resources.contains_key(&path) { continue; } let value: Option = sqlx::query_scalar( "SELECT value FROM resource WHERE workspace_id = $1 AND path = $2", ) .bind(w_id) .bind(&path) .fetch_optional(&mut *conn) .await? .flatten(); let mut strings = vec![]; collect_strings(value.as_ref(), &mut strings); for reference in strings { if let Some(var) = reference.strip_prefix("$var:") { if snapshot.variables.contains_key(var) { continue; } let row: Option<(String, bool)> = sqlx::query_as( "SELECT value, is_secret FROM variable WHERE workspace_id = $1 AND path = $2", ) .bind(w_id) .bind(var) .fetch_optional(&mut *conn) .await?; if let Some((stored, true)) = &row { if windmill_common::secret_backend::is_external_stored_value(stored) { let secret = windmill_common::secret_backend::get_secret_value( db, w_id, var, stored, ) .await?; snapshot.external_secrets.insert(var.to_string(), secret); } } snapshot.variables.insert(var.to_string(), row); } else if let Some(res) = reference.strip_prefix("$res:") { pending.push(res.to_string()); } } snapshot.resources.insert(path, value); } Ok(snapshot) } impl ConnectionSnapshot { /// The connection these rows resolve to, as the data table's own resolution would: references /// substituted, secrets decrypted. pub(crate) async fn resolve(&self, db: &DB, w_id: &str) -> Result { let root = self.resource(&self.root)?; self.substitute(db, w_id, root, 0).await } /// Whether the resource itself holds the connection's fields, which the fork repoints by setting /// its `dbname`, rather than being a reference to another resource. pub(crate) fn root_holds_connection(&self) -> bool { self.resource(&self.root).is_ok_and(|v| v.is_object()) } fn resource(&self, path: &str) -> Result<&serde_json::Value> { self.resources .get(path) .and_then(|v| v.as_ref()) .ok_or_else(|| Error::NotFound(format!("resource {path} does not exist"))) } fn substitute<'a>( &'a self, db: &'a DB, w_id: &'a str, value: &'a serde_json::Value, depth: usize, ) -> std::pin::Pin> + Send + 'a>> { Box::pin(async move { if depth > 32 { return Err(Error::BadRequest("resource references nest too deeply".to_string())); } Ok(match value { serde_json::Value::Object(map) => { let mut out = serde_json::Map::new(); for (key, val) in map { out.insert(key.clone(), self.substitute(db, w_id, val, depth).await?); } serde_json::Value::Object(out) } serde_json::Value::Array(items) => { let mut out = vec![]; for val in items { out.push(self.substitute(db, w_id, val, depth).await?); } serde_json::Value::Array(out) } serde_json::Value::String(s) if s.starts_with("$res:") => { let path = &s[5..]; self.substitute(db, w_id, self.resource(path)?, depth + 1).await? } serde_json::Value::String(s) if s.starts_with("$var:") => { let path = &s[5..]; let (stored, secret) = self .variables .get(path) .and_then(|v| v.as_ref()) .ok_or_else(|| Error::NotFound(format!("variable {path} does not exist")))?; serde_json::Value::String(match (secret, self.external_secrets.get(path)) { (false, _) => stored.clone(), (true, Some(external)) => external.clone(), (true, None) => windmill_common::variables::decrypt( &windmill_common::variables::build_crypt(db, w_id).await?, stored.clone(), ) .map_err(|e| { Error::internal_err(format!("Error decrypting variable {s}: {e}")) })?, }) } other => other.clone(), }) }) } } fn collect_strings<'a>(value: Option<&'a serde_json::Value>, out: &mut Vec<&'a str>) { match value { Some(serde_json::Value::String(s)) => out.push(s), Some(serde_json::Value::Array(items)) => { items.iter().for_each(|v| collect_strings(Some(v), out)) } Some(serde_json::Value::Object(map)) => { map.values().for_each(|v| collect_strings(Some(v), out)) } _ => {} } } /// What a data table of the fork should be copied as. pub(crate) struct CopyRequest<'a> { pub(crate) name: &'a str, pub(crate) dbname: &'a str, pub(crate) behavior: DataTableForkBehavior, } /// Check that `authed` may copy each data table of `parent_w_id` as asked, before anything is /// created. /// /// Who may copy is what it was before data table roles — anyone for the schema, an admin of the /// workspace for the rows — narrowed under roles to whoever may connect as one of them: even the /// schema alone lists every table, which under roles only role holders can read in the parent. pub(crate) async fn authorize_copies( db: &DB, authed: &ApiAuthed, parent_w_id: &str, requests: &[CopyRequest<'_>], ) -> Result<()> { for request in requests { match request.behavior { DataTableForkBehavior::KeepOriginal => { return Err(Error::BadRequest(format!( "Data table '{}' is kept, which copies nothing", request.name ))) } DataTableForkBehavior::SchemaOnly => {} DataTableForkBehavior::SchemaAndData => { require_admin(authed.is_admin, &authed.username)?; if *CLOUD_HOSTED { return Err(Error::BadRequest( "Cloning schema and data is not available on cloud".to_string(), )); } } } windmill_common::validate_dbname(request.dbname)?; if !request.dbname.starts_with("wm_fork_") { return Err(Error::BadRequest(format!( "Forked datatable database name '{}' must start with 'wm_fork_'", request.dbname ))); } let governing = ensure_datatable_is_clonable(db, parent_w_id, request.name).await?; ensure_reaches_governing_datatable(db, parent_w_id, request.name, &governing, authed) .await?; if governing.datatable.permissions.is_some() { crate::datatable_replay_oss::ensure_replay()?; } } Ok(()) } /// Make every copy in `requests`, in order, once [`authorize_copies`] allows them all. When one /// fails, the copies already made are dropped before the error is returned. pub(crate) async fn make_copies( db: &DB, authed: &ApiAuthed, parent_w_id: &str, requests: &[CopyRequest<'_>], ) -> Result> { authorize_copies(db, authed, parent_w_id, requests).await?; let mut copies = Vec::with_capacity(requests.len()); for request in requests { match make_copy(db, authed, parent_w_id, request).await { Ok(copy) => copies.push(copy), Err(e) => return Err(drop_copies_after(db, copies, e).await), } } Ok(copies) } /// Drop every copy, returning `error` — with what could not be dropped appended, since nothing /// else will name those databases again. pub(crate) async fn drop_copies_after(db: &DB, copies: Vec, error: Error) -> Error { let mut stranded = Vec::new(); for copy in copies { if let Err(e) = drop_copy(db, ©.source_database, ©.dbname).await { tracing::error!("Could not drop '{}' after a failed fork: {e}", copy.dbname); stranded.push(format!("'{}' ({e})", copy.dbname)); } } if stranded.is_empty() { error } else { Error::ExecutionErr(format!( "{error}. These databases created for the fork could not be dropped: {}", stranded.join(", ") )) } } async fn drop_copy(db: &DB, source_database: &DataTableDatabase, dbname: &str) -> Result<()> { if source_database.resource_type == windmill_common::workspaces::DataTableCatalogResourceType::Instance { let users = windmill_common::drop_unused_instance_database(db, dbname).await?; if users.is_empty() { Ok(()) } else { Err(Error::BadRequest(format!( "kept, since workspaces {} now use it", users.join(", ") ))) } } else { // On a server of the workspace's own, where a resource edited meanwhile can already name it // and nothing locks such an edit: dropping it could take someone's data. Err(Error::BadRequest( "kept on its PostgreSQL server, where it may already be in use; drop it there once it \ is not, before forking the same data table under this id again" .to_string(), )) } } async fn make_copy( db: &DB, authed: &ApiAuthed, parent_w_id: &str, request: &CopyRequest<'_>, ) -> Result { let governing = ensure_datatable_is_clonable(db, parent_w_id, request.name).await?; let source_database = governing.datatable.database.clone().ok_or_else(|| { Error::internal_err(format!( "Data table '{}' resolves to an entry that owns no database", request.name )) })?; let is_instance = governing.is_instance(); // Resolved from the snapshot, not read again: the connection the copy is made on is then // exactly what these rows describe, which the fork's own clone of them is checked against. let (server, connection): (PgDatabase, Option) = if is_instance { let server = serde_json::from_value( get_datatable_resource_from_db_unchecked(db, parent_w_id, request.name).await?, ) .map_err(|e| Error::internal_err(format!("Failed to parse database credentials: {e}")))?; (server, None) } else { let snapshot = connection_snapshot( db, &mut *db.acquire().await?, &governing.workspace_id, &source_database.resource_path, ) .await?; if !snapshot.root_holds_connection() { return Err(Error::BadRequest(format!( "Data table '{}' uses resource '{}', which only refers to another resource: the \ fork's copy of it could not be pointed at the copied database. Point the data \ table at the resource holding the connection, then fork again.", request.name, source_database.resource_path ))); } let server = serde_json::from_value(snapshot.resolve(db, &governing.workspace_id).await?) .map_err(|e| { Error::internal_err(format!("Failed to parse database credentials: {e}")) })?; (server, Some(snapshot)) }; // Dumped before the database exists, so a source that cannot be read leaves nothing behind. // Ownership never carries over: the restore runs as the target's connection user. Grants do, // except on the instance, where the replay below is what reproduces them. let dump = pg_dump_database( &server, PgDumpOptions { schema_only: request.behavior == DataTableForkBehavior::SchemaOnly, no_owner: true, no_acl: is_instance, ..Default::default() }, ) .await?; if is_instance { windmill_common::create_custom_instance_database(db, request.dbname, "datatable").await?; } else { create_database_on_server(db, &server, request.dbname).await?; } let target = PgDatabase { dbname: request.dbname.to_string(), ..server.clone() }; let filled = fill( db, authed, parent_w_id, request.name, &governing, &server, &target, &dump, ) .await; match filled { Ok(replayed) => Ok(MadeCopy { name: request.name.to_string(), dbname: request.dbname.to_string(), behavior: request.behavior, source_database, replayed, connection, }), Err(e) => { if let Err(drop_err) = drop_copy(db, &source_database, request.dbname).await { tracing::error!( "Could not drop '{}' after a failed copy of data table '{}': {drop_err}", request.dbname, request.name ); return Err(Error::ExecutionErr(format!( "{e}. The database '{}' created for the copy could not be dropped: {drop_err}", request.dbname ))); } Err(e) } } } /// Restore the dump into the new database and, for a data table under roles, replay the source's /// owners and grants into it. Returns whether it replayed. #[allow(clippy::too_many_arguments)] async fn fill( db: &DB, authed: &ApiAuthed, parent_w_id: &str, name: &str, governing: &GoverningDatatable, source: &PgDatabase, target: &PgDatabase, dump: &DumpFile, ) -> Result { pg_import_dump(target, dump).await?; // Held until the replay commits: a role renamed or dropped meanwhile would change what the // replay names, and a settings save could move the source onto another database or put it under // roles. Taken in the same order as the permissions save and the ACL apply. // On a connection of its own: the checks below take theirs from the pool, and a lock holder // drawn from the pool too could leave a small one with nothing to give them. let database_url = windmill_common::get_database_url().await?; let mut lock_holder = ::connect_with(&database_url.connect_options().await?) .await .map_err(|e| Error::internal_err(format!("Failed to connect to the database: {e}")))?; let mut tx = sqlx::Connection::begin(&mut lock_holder).await?; lock_role_catalog(&mut tx).await?; lock_settings_rows(&mut tx, parent_w_id, name).await?; // Everything so far was decided before the locks. let now = ensure_datatable_is_clonable(db, parent_w_id, name).await?; ensure_reaches_governing_datatable(db, parent_w_id, name, &now, authed).await?; if !same_database(&now.datatable.database, &governing.datatable.database) { return Err(Error::BadRequest(format!( "Data table '{name}' moved to another database while it was being copied; fork again" ))); } let replayed = now.datatable.permissions.is_some(); if replayed { crate::datatable_replay_oss::ensure_replay()?; let catalog = read_role_catalog_tx(&mut tx).await?; // The replay leaves `CONNECT` to the catalog, and creating the database only tried to set // it: a copy `PUBLIC` could still connect to would admit logins the source turns away. windmill_common::datatable_roles::converge_connect_grants_with( db, &target.dbname, &catalog, ) .await?; let catalog_roles: BTreeSet = catalog.values().map(|r| r.name.clone()).collect(); let (source, _source_notices) = connect_with_notices(db, source).await?; let (mut target, mut notices) = connect_with_notices(db, target).await?; // One transaction on the copy: a replay that stops halfway leaves objects owned by one role // and granted as another. let pg_tx = target.transaction().await.map_err(|e| { Error::internal_err(format!( "Failed to open a transaction on the copy: {}", pg_error_message(&e) )) })?; crate::datatable_replay_oss::replay_owners_and_grants( &source, &pg_tx, &mut notices, &catalog_roles, ) .await?; pg_tx.commit().await.map_err(|e| { Error::internal_err(format!( "Failed to commit the replayed grants: {}", pg_error_message(&e) )) })?; } tx.commit().await?; Ok(replayed) } /// Lock every settings row the resolution of data table `name` of `start_w_id` passes through, in /// the order it passes them — each pointer, the entry that owns the database, and each workspace its /// roles come from — and return them. Deleting or repointing any of them would leave a copy made /// for the resolution answering for another one. pub(crate) async fn lock_settings_rows( tx: &mut sqlx::Transaction<'_, sqlx::Postgres>, start_w_id: &str, name: &str, ) -> Result> { let mut path: Vec<(String, String)> = vec![]; let (mut w_id, mut datatable) = (start_w_id.to_string(), name.to_string()); loop { if path.contains(&(w_id.clone(), datatable.clone())) || path.len() > 32 { return Err(Error::BadRequest(format!( "Data table '{name}' of workspace '{start_w_id}' resolves through a cycle" ))); } let entry: Option = sqlx::query_scalar( "SELECT datatable->'datatables'->$2 FROM workspace_settings WHERE workspace_id = $1 FOR UPDATE", ) .bind(&w_id) .bind(&datatable) .fetch_optional(&mut **tx) .await? .flatten(); path.push((w_id.clone(), datatable.clone())); let entry: Option = entry.and_then(|e| serde_json::from_value(e).ok()); match entry.and_then(|e| e.reference.or(e.governed_by)) { Some(next) => (w_id, datatable) = (next.workspace_id, next.datatable), None => break, } } Ok(path.into_iter().map(|(w_id, _)| w_id).collect()) } pub(crate) fn same_database(a: &Option, b: &Option) -> bool { match (a, b) { (Some(a), Some(b)) => { a.resource_type == b.resource_type && a.resource_path == b.resource_path } _ => false, } }