Skip to main content

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}