Skip to content

Commit

Permalink
feat: postgresql user password rotation
Browse files Browse the repository at this point in the history
  • Loading branch information
bbortt committed Jul 7, 2024
1 parent 023ce6f commit 0e467ab
Show file tree
Hide file tree
Showing 21 changed files with 386 additions and 74 deletions.
8 changes: 8 additions & 0 deletions .github/workflows/build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
1 change: 1 addition & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
21 changes: 19 additions & 2 deletions DEVELOPMENT.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`:

Expand Down
4 changes: 3 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 3 additions & 1 deletion dev/config.yml
Original file line number Diff line number Diff line change
@@ -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'
1 change: 1 addition & 0 deletions dev/docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ services:
- '5432:5432'
volumes:
- demo-data:/var/lib/postgresql/data
- ./postgres:/docker-entrypoint-initdb.d

# Vault server
vault:
Expand Down
1 change: 1 addition & 0 deletions dev/podman.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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 \
Expand Down
2 changes: 2 additions & 0 deletions dev/postgres/init.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
CREATE USER user1 WITH PASSWORD 'initialpw';
CREATE USER user2 WITH PASSWORD 'initialpw';
4 changes: 3 additions & 1 deletion src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
30 changes: 30 additions & 0 deletions src/database.rs
Original file line number Diff line number Diff line change
@@ -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")
}
}
1 change: 1 addition & 0 deletions src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ use crate::workflow::rotate_secrets_using_switch_method;

mod cli;
mod config;
mod database;
mod password;
mod vault;
mod workflow;
Expand Down
20 changes: 11 additions & 9 deletions src/vault.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand All @@ -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(),
Expand All @@ -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(),
Expand All @@ -160,4 +154,12 @@ mod tests {
config.vault.address + "/"
);
}

fn mock_postgres_config() -> PostgresConfig {

Check warning on line 158 in src/vault.rs

View workflow job for this annotation

GitHub Actions / Rust Build

function `mock_postgres_config` is never used
PostgresConfig {
host: "".to_string(),
port: 1234,
database: "".to_string(),
}
}
}
109 changes: 69 additions & 40 deletions src/workflow.rs
Original file line number Diff line number Diff line change
@@ -1,15 +1,21 @@
use std::fmt::format;

Check warning on line 1 in src/workflow.rs

View workflow job for this annotation

GitHub Actions / Rust Build

unused import: `std::fmt::format`

Check warning on line 1 in src/workflow.rs

View workflow job for this annotation

GitHub Actions / Rust Build

unused import: `std::fmt::format`

Check warning on line 1 in src/workflow.rs

View workflow job for this annotation

GitHub Actions / Rust Build

unused import: `std::fmt::format`

use log::{debug, trace};
use vaultrs::auth::userpass::user::update_password;

Check warning on line 4 in src/workflow.rs

View workflow job for this annotation

GitHub Actions / Rust Build

unused import: `vaultrs::auth::userpass::user::update_password`

Check warning on line 4 in src/workflow.rs

View workflow job for this annotation

GitHub Actions / Rust Build

unused import: `vaultrs::auth::userpass::user::update_password`

Check warning on line 4 in src/workflow.rs

View workflow job for this annotation

GitHub Actions / Rust Build

unused import: `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;
Expand All @@ -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")
}
Expand All @@ -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;

Check warning on line 95 in src/workflow.rs

View workflow job for this annotation

GitHub Actions / Rust Build

unused import: `postgres::Client`

Check warning on line 95 in src/workflow.rs

View workflow job for this annotation

GitHub Actions / Rust Build

unused import: `postgres::Client`

Check warning on line 95 in src/workflow.rs

View workflow job for this annotation

GitHub Actions / Rust Build

unused import: `postgres::Client`

#[test]
fn switch_active_user_user1_active() {
Expand All @@ -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 {

Check warning on line 148 in src/workflow.rs

View workflow job for this annotation

GitHub Actions / Rust Build

variable does not need to be mutable

Check warning on line 148 in src/workflow.rs

View workflow job for this annotation

GitHub Actions / Rust Build

variable does not need to be mutable

Check warning on line 148 in src/workflow.rs

View workflow job for this annotation

GitHub Actions / Rust Build

variable does not need to be mutable
Expand Down
32 changes: 18 additions & 14 deletions tests/init_vault.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down Expand Up @@ -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);
}
Loading

0 comments on commit 0e467ab

Please sign in to comment.