grant/apply.rs
1//! Apply configuration to database - the core GitOps engine.
2//!
3//! This module handles the synchronization of database state with YAML configuration files.
4//! It provides a declarative, idempotent approach to managing PostgreSQL/Redshift users
5//! and privileges.
6//!
7//! # Safety Features
8//!
9//! - **Dry-run mode**: Preview changes before applying
10//! - **Superuser protection**: Never automatically deletes superusers
11//! - **Opt-in deletion**: User deletion requires explicit `--delete-users` flag
12//! - **SQL injection prevention**: All identifiers properly escaped
13//! - **Transaction safety**: Errors are reported without failing entire operation
14//!
15//! # Behavior
16//!
17//! ## User Management
18//! - Creates users defined in config but missing from database
19//! - Updates passwords when `update_password: true` is set
20//! - Optionally deletes users in DB but not in config (with `--delete-users`)
21//! - Never deletes superusers (safety measure)
22//!
23//! ## Privilege Management
24//! - Grants all privileges defined in configuration
25//! - Idempotent: safe to run multiple times
26//! - **Does NOT automatically revoke** privileges removed from config
27//! - This is intentional to prevent accidental privilege loss
28//! - Use `--delete-users` for full reset, or manually revoke
29//!
30//! # Example
31//!
32//! ```bash
33//! # Preview changes
34//! grant apply -f config.yaml --dryrun
35//!
36//! # Apply changes
37//! grant apply -f config.yaml
38//!
39//! # Apply with user cleanup (destructive!)
40//! grant apply -f config.yaml --delete-users
41//! ```
42
43use crate::config::sql_utils::escape_identifier;
44use crate::config::{Config, Role, User as UserInConfig};
45use crate::connection::{DbConnection, User};
46use ansi_term::Colour::{Green, Purple, Red, Yellow};
47use anyhow::{anyhow, Context, Result};
48use ascii_table::AsciiTable;
49use log::{error, info, warn};
50use std::path::Path;
51use walkdir::WalkDir;
52
53/// Read the config from the given path and apply it to the database.
54/// If the dryrun flag is set, the changes will not be applied.
55/// If delete_users is true, users in DB but not in config will be deleted.
56pub fn apply(target: &Path, dryrun: bool, delete_users: bool) -> Result<()> {
57 let target = target.to_path_buf();
58
59 if target.is_dir() {
60 return Err(anyhow!(
61 "directory is not supported yet ({})",
62 target.display()
63 ));
64 }
65
66 let config = Config::new(&target)?;
67
68 info!("Applying configuration:\n{}", config);
69 let mut conn = DbConnection::new(&config)?;
70
71 let users_in_db = conn.get_users()?;
72
73 // Apply users changes (new users, update password, delete if enabled)
74 create_or_update_users(&mut conn, &users_in_db, &config.users, dryrun, delete_users)?;
75
76 // Apply roles privileges to cluster (database role, schema role, table role)
77 create_or_update_privileges(&mut conn, &config, dryrun)?;
78
79 Ok(())
80}
81
82/// Apply all config files from the given directory.
83/// If delete_users is true, users in DB but not in config will be deleted.
84pub fn apply_all(target: &Path, dryrun: bool, delete_users: bool) -> Result<()> {
85 let target = target.to_path_buf();
86
87 // Scan recursively for config files (.yaml or .yml) in target directory
88 let mut config_files = Vec::new();
89 for entry in WalkDir::new(&target) {
90 let entry = entry?;
91 let path = entry.path();
92 if path.is_file() {
93 if let Some(ext) = path.extension() {
94 if ext == "yaml" || ext == "yml" {
95 config_files.push(path.to_path_buf());
96 }
97 }
98 }
99 }
100
101 // Apply each config file
102 for config_file in config_files {
103 info!("Applying configuration from {}", config_file.display());
104 apply(&config_file, dryrun, delete_users)?;
105 }
106
107 Ok(())
108}
109
110/// Apply users from config to database
111///
112/// Get list users from database and compare with config users
113/// If user is in config but not in database, create it
114/// If user is in database but not in config, delete it (if delete_users is true)
115/// If user is in both, compare passwords and update if needed
116///
117/// Show the summary as table of users created, updated, deleted
118fn create_or_update_users(
119 conn: &mut DbConnection,
120 users_in_db: &[User],
121 users_in_config: &[UserInConfig],
122 dryrun: bool,
123 delete_users: bool,
124) -> Result<()> {
125 let mut summary = vec![vec!["User".to_string(), "Action".to_string()]];
126 summary.push(vec!["---".to_string(), "---".to_string()]);
127
128 // Create or update users in database
129 for user in users_in_config {
130 let user_in_db = users_in_db.iter().find(|&u| u.name == user.name);
131 match user_in_db {
132 // User in config and in database
133 Some(user_in_db) => {
134 // Update password if `update_password` is set to true
135 if user.update_password.unwrap_or(false) {
136 let sql = user.to_sql_update()?;
137
138 if dryrun {
139 info!(
140 "{}: {}",
141 Purple.paint("Dry-run"),
142 Purple.paint(sanitize_sql_for_logging(&sql))
143 );
144 summary.push(vec![
145 user.name.to_string(),
146 Green.paint("would update password").to_string(),
147 ]);
148 } else {
149 conn.execute(&sql, &[])?;
150 info!(
151 "{}: {}",
152 Green.paint("Success"),
153 Purple.paint(sanitize_sql_for_logging(&sql))
154 );
155 summary.push(vec![user.name.clone(), "password updated".to_string()]);
156 }
157 } else {
158 // Do nothing if user is not changed
159 summary.push(vec![
160 user_in_db.name.clone(),
161 "no action (already exists)".to_string(),
162 ]);
163 }
164 }
165
166 // User in config but not in database
167 None => {
168 let sql = user.to_sql_create()?;
169
170 if dryrun {
171 info!(
172 "{}: {}",
173 Purple.paint("Dry-run"),
174 sanitize_sql_for_logging(&sql)
175 );
176 summary.push(vec![
177 user.name.clone(),
178 format!("would create (dryrun) {}", sanitize_sql_for_logging(&sql)),
179 ]);
180 } else {
181 conn.execute(&sql, &[])?;
182 info!(
183 "{}: {}",
184 Green.paint("Success"),
185 sanitize_sql_for_logging(&sql)
186 );
187 summary.push(vec![
188 user.name.clone(),
189 format!("created {}", sanitize_sql_for_logging(&sql)),
190 ]);
191 }
192 }
193 }
194 }
195
196 // Delete users in database that are not in config (if delete_users flag is set)
197 for user in users_in_db {
198 if !users_in_config.iter().any(|u| u.name == user.name) {
199 if delete_users {
200 // Skip superusers - never delete them automatically
201 if user.user_super {
202 warn!(
203 "Skipping deletion of superuser '{}' (not in config but protected)",
204 user.name
205 );
206 summary.push(vec![
207 user.name.clone(),
208 Yellow.paint("skipped (superuser)").to_string(),
209 ]);
210 continue;
211 }
212
213 let sql = format!("DROP USER IF EXISTS {};", escape_identifier(&user.name));
214
215 if dryrun {
216 info!("{}: {}", Purple.paint("Dry-run"), Red.paint(&sql));
217 summary.push(vec![
218 user.name.clone(),
219 Red.paint("would delete").to_string(),
220 ]);
221 } else {
222 match conn.execute(&sql, &[]) {
223 Ok(_) => {
224 info!("{}: {}", Green.paint("Success"), Purple.paint(&sql));
225 summary.push(vec![user.name.clone(), Red.paint("deleted").to_string()]);
226 }
227 Err(e) => {
228 error!("{}: {}", Red.paint("Error"), sql);
229 error!(" -> {}: {}", Red.paint("Error details"), e);
230 summary.push(vec![
231 user.name.clone(),
232 Red.paint("error deleting").to_string(),
233 ]);
234 // Continue processing other users instead of failing completely
235 warn!("Failed to delete user '{}': {}", user.name, e);
236 }
237 }
238 }
239 } else {
240 // User exists in DB but not in config, and delete_users is false
241 summary.push(vec![
242 user.name.clone(),
243 Yellow
244 .paint("not in config (use --delete-users to remove)")
245 .to_string(),
246 ]);
247 }
248 }
249 }
250
251 // Show summary
252 print_summary(summary);
253
254 Ok(())
255}
256
257/// Render role configuration to SQL and grant privileges to users.
258///
259/// ## Behavior
260/// - Grants all privileges defined in the configuration
261/// - Idempotent: safe to run multiple times (GRANT doesn't fail if already granted)
262///
263/// ## Limitation
264/// **Privileges are NOT automatically revoked** when removed from configuration.
265/// This is by design to prevent accidental privilege loss.
266///
267/// To fully sync privileges with configuration:
268/// 1. Use `--delete-users` flag to remove and recreate users (destructive), or
269/// 2. Manually revoke privileges using SQL before re-applying config, or
270/// 3. Use the dry-run mode to generate SQL and manually review/apply
271///
272/// Future enhancement: Add `--revoke-unmanaged-privileges` flag for automatic revocation.
273fn create_or_update_privileges(
274 conn: &mut DbConnection,
275 config: &Config,
276 dryrun: bool,
277) -> Result<()> {
278 let mut summary = vec![vec![
279 "User".to_string(),
280 "Role Name".to_string(),
281 "Detail".to_string(),
282 "Status".to_string(),
283 ]];
284 summary.push(vec![
285 "---".to_string(),
286 "---".to_string(),
287 "---".to_string(),
288 "---".to_string(),
289 ]);
290
291 // Grant privileges to users based on configuration
292 // Note: This is additive - privileges are granted but not automatically revoked
293 // if removed from config. See function documentation for details.
294 for user in &config.users {
295 for role_name in user.roles.iter() {
296 let role = config
297 .roles
298 .iter()
299 .find(|&r| r.find(role_name))
300 .ok_or_else(|| {
301 anyhow!("Role '{}' not found for user '{}'", role_name, user.name)
302 })?;
303
304 let sql = role.to_sql(&user.name);
305
306 let mut status = if dryrun {
307 "dry-run".to_string()
308 } else {
309 "updated".to_string()
310 };
311
312 if !dryrun {
313 match conn.execute(&sql, &[]) {
314 Ok(nrows) => {
315 info!(
316 "{}: {} {}",
317 Green.paint("Success"),
318 Purple.paint(&sql),
319 format!("(updated {} row(s))", nrows)
320 );
321 status = "updated".to_string();
322 }
323 Err(e) => {
324 error!("{}: {}", Red.paint("Error"), sql);
325 error!(" -> {}: {}", Red.paint("Error details"), e);
326 status = "error".to_string();
327 // Propagate the error instead of silently continuing
328 return Err(e).context(format!(
329 "Failed to execute privilege grant for user '{}' role '{}'",
330 user.name, role_name
331 ));
332 }
333 }
334 } else {
335 info!("{}: {}", Purple.paint("Dry-run"), sql);
336 }
337
338 let detail = match role {
339 Role::Database(role) => format!("database{:?}", role.databases.clone()),
340 Role::Schema(role) => format!("schema{:?}", role.schemas.clone()),
341 Role::Table(role) => format!("table{:?}", role.tables.clone()),
342 };
343
344 // Update summary
345 summary.push(vec![
346 user.name.clone(),
347 role_name.clone(),
348 detail.to_string(),
349 status.to_string(),
350 ]);
351 }
352 }
353
354 // Show summary
355 print_summary(summary);
356
357 Ok(())
358}
359
360/// Sanitize SQL for logging to prevent password leakage
361/// Replace password values with [REDACTED]
362fn sanitize_sql_for_logging(sql: &str) -> String {
363 // Simple pattern: look for "PASSWORD '" and replace content until next "'"
364 let mut result = String::new();
365 let bytes = sql.as_bytes();
366 let mut i = 0;
367
368 while i < bytes.len() {
369 // Check for PASSWORD keyword (case-insensitive)
370 if i + 8 < bytes.len()
371 && &bytes[i..i + 8].to_ascii_uppercase() == b"PASSWORD"
372 && (i == 0 || !bytes[i - 1].is_ascii_alphanumeric())
373 {
374 result.push_str("PASSWORD");
375 i += 8;
376
377 // Skip whitespace
378 while i < bytes.len() && bytes[i].is_ascii_whitespace() {
379 result.push(bytes[i] as char);
380 i += 1;
381 }
382
383 // Replace quoted password with [REDACTED]
384 if i < bytes.len() && bytes[i] == b'\'' {
385 result.push('\'');
386 i += 1;
387
388 // Skip content until next unescaped quote
389 while i < bytes.len() {
390 if bytes[i] == b'\'' {
391 // Check for escaped quote (doubled single quote)
392 if i + 1 < bytes.len() && bytes[i + 1] == b'\'' {
393 i += 2; // Skip both quotes
394 continue;
395 }
396 // Found closing quote
397 result.push_str("[REDACTED]'");
398 i += 1;
399 break;
400 }
401 i += 1;
402 }
403 }
404 } else {
405 result.push(bytes[i] as char);
406 i += 1;
407 }
408 }
409
410 result
411}
412
413/// Print summary table
414/// TODO: Format the table, detect max size to console
415fn print_summary(summary: Vec<Vec<String>>) {
416 let ascii_table = AsciiTable::default();
417
418 info!("Summary:\n{}", ascii_table.format(summary));
419}