grant/
apply.rs

1use crate::config::{Config, Role, User as UserInConfig};
2use crate::connection::{DbConnection, User};
3use ansi_term::Colour::{Green, Purple, Red};
4use anyhow::{anyhow, Context, Result};
5use ascii_table::AsciiTable;
6use log::{error, info};
7use std::path::Path;
8
9/// Read the config from the given path and apply it to the database.
10/// If the dryrun flag is set, the changes will not be applied.
11pub fn apply(target: &Path, dryrun: bool) -> Result<()> {
12    let target = target.to_path_buf();
13
14    if target.is_dir() {
15        return Err(anyhow!(
16            "directory is not supported yet ({})",
17            target.display()
18        ));
19    }
20
21    let config = Config::new(&target)?;
22
23    info!("Applying configuration:\n{}", config);
24    let mut conn = DbConnection::new(&config)?;
25
26    let users_in_db = conn.get_users()?;
27    let users_in_config = config.users.clone();
28
29    // Apply users changes (new users, update password)
30    create_or_update_users(&mut conn, &users_in_db, &users_in_config, dryrun)?;
31
32    // Apply roles privileges to cluster (database role, schema role, table role)
33    create_or_update_privileges(&mut conn, &config, dryrun)?;
34
35    Ok(())
36}
37
38/// Apply all config files from the given directory.
39pub fn apply_all(target: &Path, dryrun: bool) -> Result<()> {
40    let target = target.to_path_buf();
41
42    // Scan recursively for config files (.yaml for .yml) in target directory
43    let mut config_files = Vec::new();
44    for entry in std::fs::read_dir(target)? {
45        let entry = entry?;
46        let path = entry.path();
47        if path.is_file() {
48            if let Some(ext) = path.extension() {
49                if ext == "yaml" || ext == "yml" {
50                    config_files.push(path);
51                }
52            }
53        }
54    }
55
56    // Apply each config file
57    for config_file in config_files {
58        info!("Applying configuration from {}", config_file.display());
59        apply(&config_file, dryrun)?;
60    }
61
62    Ok(())
63}
64
65/// Apply users from config to database
66///
67/// Get list users from database and compare with config users
68/// If user is in config but not in database, create it
69/// If user is in database but not in config, delete it
70/// If user is in both, compare passwords and update if needed
71///
72/// Show the summary as table of users created, updated, deleted
73fn create_or_update_users(
74    conn: &mut DbConnection,
75    users_in_db: &[User],
76    users_in_config: &[UserInConfig],
77    dryrun: bool,
78) -> Result<()> {
79    let mut summary = vec![vec!["User".to_string(), "Action".to_string()]];
80    summary.push(vec!["---".to_string(), "---".to_string()]);
81
82    // Create or update users in database
83    for user in users_in_config {
84        let user_in_db = users_in_db.iter().find(|&u| u.name == user.name);
85        match user_in_db {
86            // User in config and in database
87            Some(user_in_db) => {
88                // Update password if `update_password` is set to true
89                if user.update_password.unwrap_or(false) {
90                    let sql = user.to_sql_update()?;
91
92                    if dryrun {
93                        info!(
94                            "{}: {}",
95                            Purple.paint("Dry-run"),
96                            Purple.paint(sanitize_sql_for_logging(&sql))
97                        );
98                        summary.push(vec![
99                            user.name.to_string(),
100                            Green.paint("would update password").to_string(),
101                        ]);
102                    } else {
103                        conn.execute(&sql, &[])?;
104                        info!(
105                            "{}: {}",
106                            Green.paint("Success"),
107                            Purple.paint(sanitize_sql_for_logging(&sql))
108                        );
109                        summary.push(vec![user.name.clone(), "password updated".to_string()]);
110                    }
111                } else {
112                    // Do nothing if user is not changed
113                    summary.push(vec![
114                        user_in_db.name.clone(),
115                        "no action (already exists)".to_string(),
116                    ]);
117                }
118            }
119
120            // User in config but not in database
121            None => {
122                let sql = user.to_sql_create()?;
123
124                if dryrun {
125                    info!(
126                        "{}: {}",
127                        Purple.paint("Dry-run"),
128                        sanitize_sql_for_logging(&sql)
129                    );
130                    summary.push(vec![
131                        user.name.clone(),
132                        format!("would create (dryrun) {}", sanitize_sql_for_logging(&sql)),
133                    ]);
134                } else {
135                    conn.execute(&sql, &[])?;
136                    info!(
137                        "{}: {}",
138                        Green.paint("Success"),
139                        sanitize_sql_for_logging(&sql)
140                    );
141                    summary.push(vec![
142                        user.name.clone(),
143                        format!("created {}", sanitize_sql_for_logging(&sql)),
144                    ]);
145                }
146            }
147        }
148    }
149
150    // TODO: Support delete users in db that are not in config
151    for user in users_in_db {
152        if !users_in_config.iter().any(|u| u.name == user.name) {
153            // Update summary
154            summary.push(vec![
155                user.name.clone(),
156                "no action (not in config)".to_string(),
157            ]);
158        }
159    }
160
161    // Show summary
162    print_summary(summary);
163
164    Ok(())
165}
166
167/// Render role configuration to SQL and sync with database.
168/// If the privileges are not in the database, they will be granted to user.
169/// If the privileges are in the database, they will be updated.
170/// If the privileges are not in the configuration, they will be revoked from user.
171fn create_or_update_privileges(
172    conn: &mut DbConnection,
173    config: &Config,
174    dryrun: bool,
175) -> Result<()> {
176    let mut summary = vec![vec![
177        "User".to_string(),
178        "Role Name".to_string(),
179        "Detail".to_string(),
180        "Status".to_string(),
181    ]];
182    summary.push(vec![
183        "---".to_string(),
184        "---".to_string(),
185        "---".to_string(),
186        "---".to_string(),
187    ]);
188
189    // Loop through users in config
190    // Get the user Role object by the user.roles[*].name
191    // Apply the Role sql privileges to the cluster
192    for user in &config.users {
193        // Compare privileges on config and db
194        // If privileges on config are not in db, add them
195        // If privileges on db are not in config, remove them
196        for role_name in user.roles.iter() {
197            let role = config
198                .roles
199                .iter()
200                .find(|&r| r.find(role_name))
201                .ok_or_else(|| {
202                    anyhow!("Role '{}' not found for user '{}'", role_name, user.name)
203                })?;
204
205            // TODO: revoke if privileges on db are not in configuration
206
207            let sql = role.to_sql(&user.name);
208
209            let mut status = if dryrun {
210                "dry-run".to_string()
211            } else {
212                "updated".to_string()
213            };
214
215            if !dryrun {
216                match conn.execute(&sql, &[]) {
217                    Ok(nrows) => {
218                        info!(
219                            "{}: {} {}",
220                            Green.paint("Success"),
221                            Purple.paint(&sql),
222                            format!("(updated {} row(s))", nrows)
223                        );
224                        status = "updated".to_string();
225                    }
226                    Err(e) => {
227                        error!("{}: {}", Red.paint("Error"), sql);
228                        error!("  -> {}: {}", Red.paint("Error details"), e);
229                        status = "error".to_string();
230                        // Propagate the error instead of silently continuing
231                        return Err(e).context(format!(
232                            "Failed to execute privilege grant for user '{}' role '{}'",
233                            user.name, role_name
234                        ));
235                    }
236                }
237            } else {
238                info!("{}: {}", Purple.paint("Dry-run"), sql);
239            }
240
241            let detail = match role {
242                Role::Database(role) => format!("database{:?}", role.databases.clone()),
243                Role::Schema(role) => format!("schema{:?}", role.schemas.clone()),
244                Role::Table(role) => format!("table{:?}", role.tables.clone()),
245            };
246
247            // Update summary
248            summary.push(vec![
249                user.name.clone(),
250                role_name.clone(),
251                detail.to_string(),
252                status.to_string(),
253            ]);
254        }
255    }
256
257    // Show summary
258    print_summary(summary);
259
260    Ok(())
261}
262
263/// Sanitize SQL for logging to prevent password leakage
264/// Replace password values with [REDACTED]
265fn sanitize_sql_for_logging(sql: &str) -> String {
266    // Simple pattern: look for "PASSWORD '" and replace content until next "'"
267    let mut result = String::new();
268    let bytes = sql.as_bytes();
269    let mut i = 0;
270
271    while i < bytes.len() {
272        // Check for PASSWORD keyword (case-insensitive)
273        if i + 8 < bytes.len()
274            && &bytes[i..i + 8].to_ascii_uppercase() == b"PASSWORD"
275            && (i == 0 || !bytes[i - 1].is_ascii_alphanumeric())
276        {
277            result.push_str("PASSWORD");
278            i += 8;
279
280            // Skip whitespace
281            while i < bytes.len() && bytes[i].is_ascii_whitespace() {
282                result.push(bytes[i] as char);
283                i += 1;
284            }
285
286            // Replace quoted password with [REDACTED]
287            if i < bytes.len() && bytes[i] == b'\'' {
288                result.push('\'');
289                i += 1;
290
291                // Skip content until next unescaped quote
292                while i < bytes.len() {
293                    if bytes[i] == b'\'' {
294                        // Check for escaped quote (doubled single quote)
295                        if i + 1 < bytes.len() && bytes[i + 1] == b'\'' {
296                            i += 2; // Skip both quotes
297                            continue;
298                        }
299                        // Found closing quote
300                        result.push_str("[REDACTED]'");
301                        i += 1;
302                        break;
303                    }
304                    i += 1;
305                }
306            }
307        } else {
308            result.push(bytes[i] as char);
309            i += 1;
310        }
311    }
312
313    result
314}
315
316/// Print summary table
317/// TODO: Format the table, detect max size to console
318fn print_summary(summary: Vec<Vec<String>>) {
319    let ascii_table = AsciiTable::default();
320
321    info!("Summary:\n{}", ascii_table.format(summary));
322}