From 0e467ab4e881b9c13b220dd3c80775180f32f47f Mon Sep 17 00:00:00 2001 From: Timon Borter Date: Fri, 5 Jul 2024 15:52:52 +0200 Subject: [PATCH] feat: postgresql user password rotation --- .github/workflows/build.yml | 8 + Cargo.toml | 1 + DEVELOPMENT.md | 21 +- README.md | 4 +- dev/config.yml | 4 +- dev/docker-compose.yml | 1 + dev/podman.sh | 1 + dev/postgres/init.sql | 2 + src/config.rs | 4 +- src/database.rs | 30 +++ src/main.rs | 1 + src/vault.rs | 20 +- src/workflow.rs | 109 ++++++---- tests/init_vault.rs | 32 +-- tests/resources/config/missing_postgresql.yml | 2 +- tests/resources/config/missing_vault.yml | 4 +- tests/resources/init_vault/invalid_url.yml | 6 +- tests/resources/init_vault/new_path.yml | 6 +- .../resources/rotate/non_existing_secret.yml | 7 + tests/resources/rotate/secrets.yml | 7 + tests/rotate.rs | 190 ++++++++++++++++++ 21 files changed, 386 insertions(+), 74 deletions(-) create mode 100644 dev/postgres/init.sql create mode 100644 src/database.rs create mode 100644 tests/resources/rotate/non_existing_secret.yml create mode 100644 tests/resources/rotate/secrets.yml create mode 100644 tests/rotate.rs diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index e511520..3246bbf 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -19,6 +19,14 @@ jobs: name: 'Rust Build' runs-on: ubuntu-latest services: + postgres: + image: postgres:12.19-alpine3.20 + ports: + - 5432:5432 + env: + POSTGRES_DB: demo + POSTGRES_USER: demo + POSTGRES_PASSWORD: demo_password vault: image: hashicorp/vault:1.17.1 ports: diff --git a/Cargo.toml b/Cargo.toml index c55a59c..869f4f8 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -13,6 +13,7 @@ serde_yaml = "0.9.34+deprecated" tokio = { version = "1.38.0", features = ["macros", "rt"] } vaultrs = "0.7.2" rand = "0.9.0-alpha.1" +postgres = "0.19.7" [dev-dependencies] assert_cmd = "2.0.14" diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md index 74b92b8..46091bf 100644 --- a/DEVELOPMENT.md +++ b/DEVELOPMENT.md @@ -52,8 +52,25 @@ npm ci --cache .npm - One to simulate the database of an application, used for secret rotation - **A Vault instance:** For managing secrets -Note that if using any of the below options, Vault will be accessible on http://localhost:8200. -The root token for development is 'root-token'. +Two options are provided for setting up the environment, either using `podman` or `docker-compose`. +Refer to the respective scripts ([`dev/podman.sh`](dev/podman.sh) and [`dev/docker-compose.yml`](dev/docker-compose.yml)) for detailed instructions. + +**Notes:** + +- If using any of these options, Vault will be accessible on http://localhost:8200. +- The provided "root-token" is for development only. Use strong, unique tokens in production and follow best practices for Vault token management. +- The demo database is initialized with sample users and credentials for demonstration purposes. After [having initialized Vault](#running-the-cli), you could configure these users for rotation, e.g. with the following secret value in `path/to/my/secret`: + +```json +{ + "postgresql_active_user": "user1", + "postgresql_active_user_password": "initialpw", + "postgresql_user_1": "user1", + "postgresql_user_1_password": "initialpw", + "postgresql_user_2": "user2", + "postgresql_user_2_password": "initialpw" +} +``` ### Setting up with `podman`: diff --git a/README.md b/README.md index 97d8d55..5279832 100644 --- a/README.md +++ b/README.md @@ -38,7 +38,9 @@ The configuration file is in YAML format and has the following structure: ```yaml postgres: - jdbc_url: 'jdbc:postgres://localhost:5432/demo' # Replace with your database URL + host: 'localhost' # Replace with your database host + port: 5432 # Replace with your database port + database: 'demo' # Replace with your database vault: address: 'http://localhost:8200' # Replace with your Vault address path: 'path/to/my/secret' # Replace with the desired path in Vault diff --git a/dev/config.yml b/dev/config.yml index 820ec71..0057483 100644 --- a/dev/config.yml +++ b/dev/config.yml @@ -1,5 +1,7 @@ postgres: - jdbc_url: 'jdbc:postgres://localhost:5432/demo' + host: 'localhost' + port: 5432 + database: 'demo' vault: address: 'http://localhost:8200' path: 'path/to/my/secret' diff --git a/dev/docker-compose.yml b/dev/docker-compose.yml index 3f6012c..d5c028c 100644 --- a/dev/docker-compose.yml +++ b/dev/docker-compose.yml @@ -26,6 +26,7 @@ services: - '5432:5432' volumes: - demo-data:/var/lib/postgresql/data + - ./postgres:/docker-entrypoint-initdb.d # Vault server vault: diff --git a/dev/podman.sh b/dev/podman.sh index b8bb016..9e59533 100755 --- a/dev/podman.sh +++ b/dev/podman.sh @@ -19,6 +19,7 @@ podman start postgres-vault || \ podman start postgres-demo || \ podman run -d --name postgres-demo \ -v "${DEMO_DATA_VOLUME}:/var/lib/postgresql/data" \ + -v "$(dirname "$0")/postgres:/docker-entrypoint-initdb.d" \ -e POSTGRES_DB=demo \ -e POSTGRES_USER=demo \ -e POSTGRES_PASSWORD=demo_password \ diff --git a/dev/postgres/init.sql b/dev/postgres/init.sql new file mode 100644 index 0000000..fd21127 --- /dev/null +++ b/dev/postgres/init.sql @@ -0,0 +1,2 @@ +CREATE USER user1 WITH PASSWORD 'initialpw'; +CREATE USER user2 WITH PASSWORD 'initialpw'; diff --git a/src/config.rs b/src/config.rs index 7946e04..89b4040 100644 --- a/src/config.rs +++ b/src/config.rs @@ -16,7 +16,9 @@ pub(crate) struct VaultConfig { #[derive(Clone, Deserialize, Debug)] pub(crate) struct PostgresConfig { - pub(crate) jdbc_url: String, + pub(crate) host: String, + pub(crate) port: u16, + pub(crate) database: String, } pub(crate) fn read_config(config_path: PathBuf) -> Config { diff --git a/src/database.rs b/src/database.rs new file mode 100644 index 0000000..7a7bfd7 --- /dev/null +++ b/src/database.rs @@ -0,0 +1,30 @@ +use postgres::{Client, NoTls}; + +use crate::config::{Config, PostgresConfig}; + +pub(crate) struct PostgresClient { + postgres_config: PostgresConfig, +} + +impl PostgresClient { + pub(crate) fn init(config: &Config) -> PostgresClient { + PostgresClient { + postgres_config: config.postgres.clone(), + } + } + + pub(crate) fn connect_for_user(&self, username: String, password: String) -> Client { + let host = self.postgres_config.host.as_str(); + let port = self.postgres_config.port; + let database = self.postgres_config.database.as_str(); + + Client::connect( + format!( + "host={host} port={port} dbname={database} user={username} password={password}" + ) + .as_str(), + NoTls, + ) + .expect("Failed to build PostgreSQL connection") + } +} diff --git a/src/main.rs b/src/main.rs index 4451a9f..564c018 100644 --- a/src/main.rs +++ b/src/main.rs @@ -10,6 +10,7 @@ use crate::workflow::rotate_secrets_using_switch_method; mod cli; mod config; +mod database; mod password; mod vault; mod workflow; diff --git a/src/vault.rs b/src/vault.rs index 8ba96a2..0c36aea 100644 --- a/src/vault.rs +++ b/src/vault.rs @@ -107,9 +107,7 @@ mod tests { #[test] fn successful_vault_connect() { let config = Config { - postgres: PostgresConfig { - jdbc_url: "".to_string(), - }, + postgres: mock_postgres_config(), vault: VaultConfig { address: "http://localhost:8200".to_string(), path: "path/to/my/secret".to_string(), @@ -127,9 +125,7 @@ mod tests { #[should_panic(expected = "Missing VAULT_TOKEN environment variable")] fn vault_connect_missing_token() { let config = Config { - postgres: PostgresConfig { - jdbc_url: "".to_string(), - }, + postgres: mock_postgres_config(), vault: VaultConfig { address: "http://localhost:8200".to_string(), path: "path/to/my/secret".to_string(), @@ -143,9 +139,7 @@ mod tests { #[test] fn get_vault_client_returns_client() { let config = Config { - postgres: PostgresConfig { - jdbc_url: "".to_string(), - }, + postgres: mock_postgres_config(), vault: VaultConfig { address: "http://localhost:8200".to_string(), path: "path/to/my/secret".to_string(), @@ -160,4 +154,12 @@ mod tests { config.vault.address + "/" ); } + + fn mock_postgres_config() -> PostgresConfig { + PostgresConfig { + host: "".to_string(), + port: 1234, + database: "".to_string(), + } + } } diff --git a/src/workflow.rs b/src/workflow.rs index 1a851b1..12505cc 100644 --- a/src/workflow.rs +++ b/src/workflow.rs @@ -1,15 +1,21 @@ +use std::fmt::format; + +use log::{debug, trace}; +use vaultrs::auth::userpass::user::update_password; + use crate::cli::RotateArgs; use crate::config::Config; +use crate::database::PostgresClient; use crate::password::generate_random_password; use crate::vault::{Vault, VaultStructure}; -use log::debug; -use vaultrs::auth::userpass::user::update_password; pub(crate) fn rotate_secrets_using_switch_method( rotate_args: &RotateArgs, config: &Config, vault: &mut Vault, ) { + let db: PostgresClient = PostgresClient::init(config); + debug!("Starting 'switch' workflow"); let vault_path = config.vault.clone().path; @@ -25,25 +31,24 @@ pub(crate) fn rotate_secrets_using_switch_method( let new_password: String = generate_random_password(rotate_args.password_length); - // TODO: PostgreSQL password change - - update_passive_user_password(&mut secret, new_password); + update_passive_user_postgres_password(&db, &mut secret, new_password); switch_active_user(&mut secret); vault .write_secret(&secret) - .expect("Failed to kick-off rotation workflow by switching active user"); + .expect("Failed to kick-off rotation workflow by switching active user - Vault is in an invalid state"); + + debug!("Active and passive users switched and synchronized into Vault"); // TODO: Trigger ArgoCD Sync let new_password: String = generate_random_password(rotate_args.password_length); - // TODO: PostgreSQL password change + update_passive_user_postgres_password(&db, &mut secret, new_password); - update_passive_user_password(&mut secret, new_password); vault .write_secret(&secret) - .expect("Failed to update PASSIVE user password after sync"); + .expect("Failed to update PASSIVE user password after sync - Vault is in an invalid state"); println!("Successfully rotated all secrets") } @@ -56,18 +61,38 @@ fn switch_active_user(secret: &mut VaultStructure) { secret.postgresql_active_user = secret.postgresql_user_1.clone(); secret.postgresql_active_user_password = secret.postgresql_user_1_password.clone() } + + trace!("Switched active and passive user in Vault secret (locally)") } -fn update_passive_user_password(secret: &mut VaultStructure, new_password: String) { - if secret.postgresql_active_user == secret.postgresql_user_1 { - secret.postgresql_user_2_password = new_password.clone(); - } else { - secret.postgresql_user_1_password = new_password.clone(); - } +fn update_passive_user_postgres_password( + db: &PostgresClient, + secret: &mut VaultStructure, + new_password: String, +) { + let (passive_user, passive_user_password) = + if secret.postgresql_active_user == secret.postgresql_user_1 { + let original_password = secret.postgresql_user_2_password.clone(); + secret.postgresql_user_2_password = new_password.clone(); + (secret.postgresql_user_2.clone(), original_password) + } else { + let original_password = secret.postgresql_user_1_password.clone(); + secret.postgresql_user_1_password = new_password.clone(); + (secret.postgresql_user_1.clone(), original_password) + }; + + let mut conn = db.connect_for_user(passive_user.clone(), passive_user_password); + let query = format!("ALTER ROLE {passive_user} WITH PASSWORD '{new_password}'"); + + conn.execute(query.as_str(), &[]) + .expect(format!("Failed to update password of '{passive_user}'").as_str()); + + debug!("Successfully rotated PostgreSQL password of passive user"); } mod tests { use super::*; + use postgres::Client; #[test] fn switch_active_user_user1_active() { @@ -89,31 +114,35 @@ mod tests { assert_eq!(secret.postgresql_active_user_password, "password1"); } - #[test] - fn update_passive_user_password_user1_active() { - let mut secret: VaultStructure = create_vault_structure_active_user_1(); - - let new_password = "new_password".to_string(); - - update_passive_user_password(&mut secret, new_password.clone()); - - assert_eq!(secret.postgresql_active_user, "user1"); - assert_eq!(secret.postgresql_active_user_password, "password1"); - assert_eq!(secret.postgresql_user_2_password, new_password); - } - - #[test] - fn update_passive_user_password_user2_active() { - let mut secret: VaultStructure = create_vault_structure_active_user_2(); - - let new_password = "new_password".to_string(); - - update_passive_user_password(&mut secret, new_password.clone()); - - assert_eq!(secret.postgresql_active_user, "user2"); - assert_eq!(secret.postgresql_active_user_password, "password2"); - assert_eq!(secret.postgresql_user_1_password, new_password); - } + // #[test] + // fn update_passive_user_password_user1_active() { + // let client = PropellerDBClient{}; + // + // let mut secret: VaultStructure = create_vault_structure_active_user_1(); + // + // let new_password = "new_password".to_string(); + // + // update_passive_user_postgres_password(client, & mut secret, new_password.clone()); + // + // assert_eq!(secret.postgresql_active_user, "user1"); + // assert_eq!(secret.postgresql_active_user_password, "password1"); + // assert_eq!(secret.postgresql_user_2_password, new_password); + // } + // + // #[test] + // fn update_passive_user_password_user2_active() { + // let client = PropellerDBClient{}; + // + // let mut secret: VaultStructure = create_vault_structure_active_user_2(); + // + // let new_password = "new_password".to_string(); + // + // update_passive_user_postgres_password(client,&mut secret, new_password.clone()); + // + // assert_eq!(secret.postgresql_active_user, "user2"); + // assert_eq!(secret.postgresql_active_user_password, "password2"); + // assert_eq!(secret.postgresql_user_1_password, new_password); + // } fn create_vault_structure_active_user_1() -> VaultStructure { let mut secret = VaultStructure { diff --git a/tests/init_vault.rs b/tests/init_vault.rs index 6534159..aa73b1e 100644 --- a/tests/init_vault.rs +++ b/tests/init_vault.rs @@ -23,25 +23,14 @@ fn init_vault_new_path() { .assert() .success() .stdout(contains( - "Successfully initialized Vault path 'path/to/my/secret'", + "Successfully initialized Vault path 'init/vault/new/path'", )); let client = Client::new(); - let url = "http://localhost:8200/v1/secret/data/path/to/my/secret"; + let url = "http://localhost:8200/v1/secret/data/init/vault/new/path"; let rt: Runtime = create_tokio_runtime(); - - let response: Response = rt - .block_on(client.get(url).header("X-Vault-Token", "root-token").send()) - .expect("Error receiving Vault data"); - - response - .error_for_status_ref() - .expect("Expected to reach Vault"); - - let json: Value = rt - .block_on(response.json()) - .expect("Failed to convert Vault response to JSON"); + let json = read_secret_as_json(client, url, rt); assert_json_value_equals(&json, "postgresql_active_user", "TBD"); assert_json_value_equals(&json, "postgresql_active_user_password", "TBD"); @@ -71,6 +60,21 @@ fn create_tokio_runtime() -> Runtime { .expect("Failed to build Vault connection") } +fn read_secret_as_json(client: Client, url: &str, rt: Runtime) -> Value { + let response: Response = rt + .block_on(client.get(url).header("X-Vault-Token", "root-token").send()) + .expect("Error receiving Vault data"); + + response + .error_for_status_ref() + .expect("Expected to reach Vault"); + + let json: Value = rt + .block_on(response.json()) + .expect("Failed to convert Vault response to JSON"); + json +} + fn assert_json_value_equals(json: &Value, key: &str, value: &str) { assert_eq!(json["data"]["data"][key].as_str().unwrap(), value); } diff --git a/tests/resources/config/missing_postgresql.yml b/tests/resources/config/missing_postgresql.yml index d1a2dd7..79b1d48 100644 --- a/tests/resources/config/missing_postgresql.yml +++ b/tests/resources/config/missing_postgresql.yml @@ -1,3 +1,3 @@ vault: address: 'http://localhost:1234' - path: 'path/to/my/secret' + path: 'config/missing/postgresql' diff --git a/tests/resources/config/missing_vault.yml b/tests/resources/config/missing_vault.yml index a7a94a6..3cde721 100644 --- a/tests/resources/config/missing_vault.yml +++ b/tests/resources/config/missing_vault.yml @@ -1,2 +1,4 @@ postgres: - jdbc_url: 'jdbc:postgres://localhost:5432/demo' + host: 'localhost' + port: 5432 + database: 'demo' diff --git a/tests/resources/init_vault/invalid_url.yml b/tests/resources/init_vault/invalid_url.yml index 65f32d4..d5ad38a 100644 --- a/tests/resources/init_vault/invalid_url.yml +++ b/tests/resources/init_vault/invalid_url.yml @@ -1,5 +1,7 @@ postgres: - jdbc_url: 'jdbc:postgres://localhost:5432/demo' + host: 'localhost' + port: 5432 + database: 'demo' vault: address: 'http://localhost:1234' - path: 'path/to/my/secret' + path: 'init/vault/invalid/url' diff --git a/tests/resources/init_vault/new_path.yml b/tests/resources/init_vault/new_path.yml index 820ec71..5fbf668 100644 --- a/tests/resources/init_vault/new_path.yml +++ b/tests/resources/init_vault/new_path.yml @@ -1,5 +1,7 @@ postgres: - jdbc_url: 'jdbc:postgres://localhost:5432/demo' + host: 'localhost' + port: 5432 + database: 'demo' vault: address: 'http://localhost:8200' - path: 'path/to/my/secret' + path: 'init/vault/new/path' diff --git a/tests/resources/rotate/non_existing_secret.yml b/tests/resources/rotate/non_existing_secret.yml new file mode 100644 index 0000000..6519b39 --- /dev/null +++ b/tests/resources/rotate/non_existing_secret.yml @@ -0,0 +1,7 @@ +postgres: + host: 'localhost' + port: 5432 + database: 'demo' +vault: + address: 'http://localhost:8200' + path: 'rotate/non/existing/path' diff --git a/tests/resources/rotate/secrets.yml b/tests/resources/rotate/secrets.yml new file mode 100644 index 0000000..668b189 --- /dev/null +++ b/tests/resources/rotate/secrets.yml @@ -0,0 +1,7 @@ +postgres: + host: 'localhost' + port: 5432 + database: 'demo' +vault: + address: 'http://localhost:8200' + path: 'rotate/secrets' diff --git a/tests/rotate.rs b/tests/rotate.rs new file mode 100644 index 0000000..f3b75dc --- /dev/null +++ b/tests/rotate.rs @@ -0,0 +1,190 @@ +use std::path::PathBuf; +use std::process::Command; + +use assert_cmd::cargo::cargo_bin; +use assert_cmd::prelude::*; +use lazy_static::lazy_static; +use postgres::NoTls; +use predicates::str::contains; +use reqwest::{Client, Response}; +use serde::{Deserialize, Serialize}; +use serde_json::Value; +use tokio::runtime::{Builder, Runtime}; + +lazy_static! { + static ref BIN_PATH: PathBuf = cargo_bin(env!("CARGO_PKG_NAME")); +} + +#[derive(Deserialize, Serialize)] +struct VaultSecret { + postgresql_active_user: String, + postgresql_active_user_password: String, + postgresql_user_1: String, + postgresql_user_1_password: String, + postgresql_user_2: String, + postgresql_user_2_password: String, +} + +#[derive(Deserialize, Serialize)] +struct VaultSecretDTO { + data: VaultSecret, +} + +#[test] +fn rotate_secrets() { + let http_client = Client::new(); + let url = "http://localhost:8200/v1/secret/data/rotate/secrets"; + + let rt: Runtime = create_tokio_runtime(); + + reset_vault_secret_path(&http_client, url, &rt); + + let mut postgres_client = connect_postgres_client("demo", "demo_password"); + reset_role_initial_password(&mut postgres_client, "user1"); + reset_role_initial_password(&mut postgres_client, "user2"); + + Command::new(&*BIN_PATH) + .arg("rotate") + .arg("-c") + .arg("tests/resources/rotate/secrets.yml") + .env("VAULT_TOKEN", "root-token") + .assert() + .success() + .stdout(contains("Successfully rotated all secrets")); + + let json = read_secret_as_json(http_client, url, rt); + + assert_eq!( + json["data"]["data"]["postgresql_active_user"] + .as_str() + .unwrap(), + "user2" + ); + assert_ne!( + json["data"]["data"]["postgresql_active_user_password"] + .as_str() + .unwrap(), + "initialpw" + ); + assert_eq!( + json["data"]["data"]["postgresql_user_1"].as_str().unwrap(), + "user1" + ); + assert_ne!( + json["data"]["data"]["postgresql_user_1_password"] + .as_str() + .unwrap(), + "initialpw" + ); + assert_eq!( + json["data"]["data"]["postgresql_user_2"].as_str().unwrap(), + "user2" + ); + assert_ne!( + json["data"]["data"]["postgresql_user_2_password"] + .as_str() + .unwrap(), + "initialpw" + ); + + connect_postgres_client( + "user1", + json["data"]["data"]["postgresql_user_1_password"] + .as_str() + .unwrap(), + ); + connect_postgres_client( + "user2", + json["data"]["data"]["postgresql_user_2_password"] + .as_str() + .unwrap(), + ); +} + +#[test] +fn rotate_non_existing_secret() { + Command::new(&*BIN_PATH) + .arg("rotate") + .arg("-c") + .arg("tests/resources/rotate/non_existing_secret.yml") + .env("VAULT_TOKEN", "root-token") + .assert() + .failure() + .stderr(contains( + "Failed to read path 'rotate/non/existing/path' - did you init Vault?", + )); +} +fn create_tokio_runtime() -> Runtime { + Builder::new_current_thread() + .enable_all() + .build() + .expect("Failed to build Vault connection") +} + +fn reset_vault_secret_path(client: &Client, url: &str, rt: &Runtime) { + let initial_secret = VaultSecretDTO { + data: VaultSecret { + postgresql_active_user: "user1".to_string(), + postgresql_active_user_password: "initialpw".to_string(), + postgresql_user_1: "user1".to_string(), + postgresql_user_1_password: "initialpw".to_string(), + postgresql_user_2: "user2".to_string(), + postgresql_user_2_password: "initialpw".to_string(), + }, + }; + + rt.block_on( + client + .post(url) + .header("X-Vault-Token", "root-token") + .json(&initial_secret) + .send(), + ) + .expect("Error initializing Vault for 'rotate_secrets'"); +} + +fn connect_postgres_client(user: &str, password: &str) -> postgres::Client { + let mut postgres_client = postgres::Client::connect( + format!("host=localhost port=5432 dbname=demo user={user} password={password}").as_str(), + NoTls, + ) + .expect("Failed to build PostgreSQL connection"); + postgres_client +} + +fn reset_role_initial_password(postgres_client: &mut postgres::Client, role: &str) { + match postgres_client.execute( + format!("CREATE USER {role} WITH PASSWORD 'initialpw'").as_str(), + &[], + ) { + Ok(_) => {} + Err(_) => { + postgres_client + .execute( + format!("ALTER ROLE {role} WITH PASSWORD 'initialpw'").as_str(), + &[], + ) + .expect(format!("Failed to reset '{role}'").as_str()); + } + } +} + +fn read_secret_as_json(http_client: Client, url: &str, rt: Runtime) -> Value { + let response: Response = rt + .block_on( + http_client + .get(url) + .header("X-Vault-Token", "root-token") + .send(), + ) + .expect("Error receiving Vault data"); + + response + .error_for_status_ref() + .expect("Expected to reach Vault"); + + let json: Value = rt + .block_on(response.json()) + .expect("Failed to convert Vault response to JSON"); + json +}