diff --git a/docker-compose.yml b/docker-compose.yml index 2adce57..4b18ba7 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -21,7 +21,7 @@ services: LOG: debug TZ: "Europe/Paris" # TMPDIR: /scratch - EDGE_KEY: "eyJzZXJ2ZXJVcmwiOiJodHRwOi8vbG9jYWxob3N0Ojg4ODciLCJhZ2VudElkIjoiMjcyMWFiNGQtZDkzNi00NmFkLWE1MWEtOWVhYTk3ZjJhMjAwIiwibWFzdGVyS2V5QjY0IjoiQlhWM1hvbEM2NTZTVjdkTmdjV1BHUWxrKytycExJNmxHRGk3Q1BCNWllbz0ifQ==" + EDGE_KEY: "eyJzZXJ2ZXJVcmwiOiJodHRwOi8vbG9jYWxob3N0Ojg4ODciLCJhZ2VudElkIjoiN2ZjMGViODMtYTY0YS00NzI3LTg5ZjktNzFhZWJmZDQ3NmEyIiwibWFzdGVyS2V5QjY0IjoiQlhWM1hvbEM2NTZTVjdkTmdjV1BHUWxrKytycExJNmxHRGk3Q1BCNWllbz0ifQ==" #CHUNK_SIZE_MB: "1" #POOLING: 1 #RETRY_ATTEMPTS: 3 diff --git a/src/domain/postgres/backup.rs b/src/domain/postgres/backup.rs index 906cfd9..0903a9e 100644 --- a/src/domain/postgres/backup.rs +++ b/src/domain/postgres/backup.rs @@ -34,6 +34,14 @@ pub async fn run( let pg_dump = select_pg_path(&version).join("pg_dump"); logger.log("debug", format!("Using pg_dump at {:?}", pg_dump)); + let (excludes, invalid) = exclude_args(&cfg); + for key in invalid { + logger.log("warn", format!("Ignoring options.{} for {}: expected a list of patterns", key, cfg.name)); + } + if !excludes.is_empty() { + logger.log("info", format!("Excluding from dump of {}: {}", cfg.name, excludes.join(" "))); + } + match format { PostgresDumpFormat::Fc => { logger.log("info", format!("Running FC backup for {}", cfg.name)); @@ -46,6 +54,7 @@ pub async fn run( .arg("--port").arg(cfg.port.to_string()) .arg("--username").arg(&cfg.username) .arg("--dbname").arg(&cfg.database) + .args(&excludes) .arg("-Fc") .arg("-f").arg(&file_path) .arg("-v") @@ -97,6 +106,7 @@ pub async fn run( .arg("--port").arg(cfg.port.to_string()) .arg("--username").arg(&cfg.username) .arg("--dbname").arg(&cfg.database) + .args(&excludes) .arg("-Fd") .arg("-j").arg("4") .arg("-f").arg(&dump_dir) @@ -151,3 +161,23 @@ pub async fn run( }) .await? } + +pub(crate) fn exclude_args(cfg: &DatabaseConfig) -> (Vec, Vec<&'static str>) { + let mut args = Vec::new(); + let mut invalid = Vec::new(); + for (key, flag) in [("exclude_tables", "--exclude-table"), ("exclude_schemas", "--exclude-schema")] { + match cfg.options.get(key).map(|v| v.as_array()) { + None => {} + Some(Some(items)) => args.extend( + items + .iter() + .filter_map(|v| v.as_str()) + .map(str::trim) + .filter(|p| !p.is_empty()) + .map(|p| format!("{flag}={p}")), + ), + Some(None) => invalid.push(key), + } + } + (args, invalid) +} diff --git a/src/tests/domain/postgres.rs b/src/tests/domain/postgres.rs index ce33325..d34429a 100644 --- a/src/tests/domain/postgres.rs +++ b/src/tests/domain/postgres.rs @@ -316,6 +316,62 @@ async fn restore_run_unified_fc_roundtrip() { assert!(result.is_ok(), "restore::run failed: {:?}", result); } +#[tokio::test] +async fn backup_as_non_owner_succeeds_only_with_exclude_tables() { + init_tracing_for_test(); + + let (_container, admin) = create_config().await; + crate::domain::postgres::connection::connect(&admin) + .await + .unwrap() + .batch_execute( + "CREATE ROLE backup_ro LOGIN PASSWORD 'x'; + CREATE TABLE public.keep(id int); + CREATE TABLE public.search_index__x(id int); + GRANT SELECT ON public.keep TO backup_ro;", + ) + .await + .unwrap(); + + let mut ro = admin.clone(); + ro.username = "backup_ro".into(); + ro.password = "x".into(); + + let mut excluded = ro.clone(); + excluded + .options + .insert("exclude_tables".into(), serde_json::json!(["public.search_index__*"])); + + for format in [PostgresDumpFormat::Fc, PostgresDumpFormat::Fd] { + let dir = TempDir::new().unwrap(); + let denied = crate::domain::postgres::backup::run( + ro.clone(), format, dir.path().to_path_buf(), pg_dump_env(&ro), Arc::new(JobLogger::new()), + ) + .await; + assert!(denied.is_err(), "{format:?}: non-owner dump must fail without exclusions"); + + let dir = TempDir::new().unwrap(); + let logger = Arc::new(JobLogger::new()); + let dump = crate::domain::postgres::backup::run( + excluded.clone(), format, dir.path().to_path_buf(), pg_dump_env(&excluded), logger.clone(), + ) + .await + .unwrap_or_else(|e| panic!("{format:?}: dump with exclusions failed: {e:?}")); + + let entries = Arc::try_unwrap(logger).expect("single owner").into_entries(); + assert!(entries.iter().any(|e| e.message + == format!("Excluding from dump of {}: --exclude-table=public.search_index__*", excluded.name))); + + if format == PostgresDumpFormat::Fc { + let version = server_version(&admin).await.unwrap(); + let pg_restore = select_pg_path(&version).join(pg_restore_binary_name()); + let prepared = prepare_archive(format, &dump, &pg_restore, &JobLogger::new()).unwrap(); + assert!(prepared.toc().contains("TABLE public keep ")); + assert!(!prepared.toc().contains("search_index__x")); + } + } +} + #[tokio::test] async fn drop_all_schemas_removes_user_schema() { init_tracing_for_test(); @@ -924,6 +980,61 @@ mod clean_mode_tests { } } +mod exclude_args_tests { + use crate::domain::postgres::backup::exclude_args; + use crate::services::config::DatabaseConfig; + use serde_json::json; + + fn cfg(options: serde_json::Value) -> DatabaseConfig { + serde_json::from_value(json!({ + "name": "t", "database": "testdb", "type": "postgresql", + "username": "u", "password": "p", "port": 5432, "host": "localhost", + "generated_id": "00000000-0000-0000-0000-000000000000", + "path": "", "max_packet_size": "", "volume_name": "", + "options": options + })) + .unwrap() + } + + #[test] + fn no_options_yields_nothing() { + assert_eq!(exclude_args(&cfg(json!({}))), (vec![], vec![])); + assert_eq!( + exclude_args(&cfg(json!({"exclude_tables": [], "exclude_schemas": []}))), + (vec![], vec![]) + ); + } + + #[test] + fn lists_map_to_single_arg_flags_in_order() { + let (args, invalid) = exclude_args(&cfg(json!({ + "exclude_schemas": ["metabase_tmp"], + "exclude_tables": ["public.search_index__*", " audit.tmp_* ", "", " ", 42, "-f/tmp/x", "\"MyTable\""] + }))); + assert_eq!( + args, + vec![ + "--exclude-table=public.search_index__*", + "--exclude-table=audit.tmp_*", + "--exclude-table=-f/tmp/x", + "--exclude-table=\"MyTable\"", + "--exclude-schema=metabase_tmp", + ] + ); + assert!(invalid.is_empty()); + } + + #[test] + fn non_list_values_are_reported_and_ignored() { + let (args, invalid) = exclude_args(&cfg(json!({ + "exclude_tables": "public.search_index__*", + "exclude_schemas": {"a": 1} + }))); + assert!(args.is_empty()); + assert_eq!(invalid, vec!["exclude_tables", "exclude_schemas"]); + } +} + mod toc_tests { use crate::domain::postgres::restore::toc_creates_public_schema;