Skip to main content

pgroles_cli/
lib.rs

1//! Testable CLI logic for pgroles.
2//!
3//! All pure functions that don't require a live database connection live here.
4//! The binary (`main.rs`) delegates to these, making validation, plan formatting,
5//! and output rendering fully unit-testable.
6
7use std::path::Path;
8
9use anyhow::{Context, Result};
10
11use pgroles_core::diff::{self, Change};
12use pgroles_core::manifest::{self, ExpandedManifest, PolicyManifest, RoleRetirement};
13use pgroles_core::model::RoleGraph;
14use pgroles_core::sql;
15
16// ---------------------------------------------------------------------------
17// File loading
18// ---------------------------------------------------------------------------
19
20/// Read a manifest file from disk and return the raw YAML string.
21pub fn read_manifest_file(path: &Path) -> Result<String> {
22    std::fs::read_to_string(path)
23        .with_context(|| format!("failed to read manifest file: {}", path.display()))
24}
25
26// ---------------------------------------------------------------------------
27// Validation pipeline (pure — no DB)
28// ---------------------------------------------------------------------------
29
30/// Parse and validate a YAML string into a `PolicyManifest`.
31pub fn parse(yaml: &str) -> Result<PolicyManifest> {
32    manifest::parse_manifest(yaml).map_err(|err| anyhow::anyhow!("{err}"))
33}
34
35/// Parse, validate, and expand a manifest YAML string into an `ExpandedManifest`.
36pub fn parse_and_expand(yaml: &str) -> Result<ExpandedManifest> {
37    let policy_manifest = parse(yaml)?;
38    manifest::expand_manifest(&policy_manifest).map_err(|err| anyhow::anyhow!("{err}"))
39}
40
41/// Full validation: parse, expand, and build a RoleGraph from a manifest string.
42/// Returns the expanded manifest and the desired RoleGraph.
43pub fn validate_manifest(yaml: &str) -> Result<ValidatedManifest> {
44    let policy_manifest = parse(yaml)?;
45    let expanded =
46        manifest::expand_manifest(&policy_manifest).map_err(|err| anyhow::anyhow!("{err}"))?;
47
48    let default_owner = policy_manifest.default_owner.as_deref();
49    let desired = RoleGraph::from_expanded(&expanded, default_owner)
50        .map_err(|err| anyhow::anyhow!("{err}"))?;
51
52    Ok(ValidatedManifest {
53        manifest: policy_manifest,
54        expanded,
55        desired,
56    })
57}
58
59/// The result of successfully validating a manifest.
60pub struct ValidatedManifest {
61    pub manifest: PolicyManifest,
62    pub expanded: ExpandedManifest,
63    pub desired: RoleGraph,
64}
65
66// ---------------------------------------------------------------------------
67// Plan computation (pure — given both role graphs)
68// ---------------------------------------------------------------------------
69
70/// Compute the list of changes needed to bring `current` state to `desired` state.
71pub fn compute_plan(current: &RoleGraph, desired: &RoleGraph) -> Vec<Change> {
72    diff::diff(current, desired)
73}
74
75/// Collect the role names that the current plan intends to drop.
76pub fn planned_role_drops(changes: &[Change]) -> Vec<String> {
77    changes
78        .iter()
79        .filter_map(|change| match change {
80            Change::DropRole { name } => Some(name.clone()),
81            _ => None,
82        })
83        .collect()
84}
85
86/// Insert explicit retirement actions before any matching role drops.
87pub fn apply_role_retirements(changes: Vec<Change>, retirements: &[RoleRetirement]) -> Vec<Change> {
88    diff::apply_role_retirements(changes, retirements)
89}
90
91// ---------------------------------------------------------------------------
92// Output formatting
93// ---------------------------------------------------------------------------
94
95/// Format a plan as SQL statements.
96pub fn format_plan_sql(changes: &[Change]) -> String {
97    sql::render_all(changes)
98}
99
100/// Format a plan as SQL statements using an explicit SQL context.
101pub fn format_plan_sql_with_context(changes: &[Change], ctx: &sql::SqlContext) -> String {
102    sql::render_all_with_context(changes, ctx)
103}
104
105/// Format a plan as JSON for machine consumption.
106pub fn format_plan_json(changes: &[Change]) -> Result<String> {
107    serde_json::to_string_pretty(changes).map_err(|err| anyhow::anyhow!("{err}"))
108}
109
110/// Summary statistics for a plan.
111#[derive(Debug, Default, PartialEq, Eq)]
112pub struct PlanSummary {
113    pub roles_created: usize,
114    pub roles_altered: usize,
115    pub roles_dropped: usize,
116    pub comments_changed: usize,
117    pub sessions_terminated: usize,
118    pub ownerships_reassigned: usize,
119    pub owned_objects_dropped: usize,
120    pub grants: usize,
121    pub revokes: usize,
122    pub default_privileges_set: usize,
123    pub default_privileges_revoked: usize,
124    pub members_added: usize,
125    pub members_removed: usize,
126}
127
128impl PlanSummary {
129    /// Compute summary statistics from a list of changes.
130    pub fn from_changes(changes: &[Change]) -> Self {
131        let mut summary = Self::default();
132        for change in changes {
133            match change {
134                Change::CreateRole { .. } => summary.roles_created += 1,
135                Change::AlterRole { .. } => summary.roles_altered += 1,
136                Change::DropRole { .. } => summary.roles_dropped += 1,
137                Change::SetComment { .. } => summary.comments_changed += 1,
138                Change::TerminateSessions { .. } => summary.sessions_terminated += 1,
139                Change::ReassignOwned { .. } => summary.ownerships_reassigned += 1,
140                Change::DropOwned { .. } => summary.owned_objects_dropped += 1,
141                Change::Grant { .. } => summary.grants += 1,
142                Change::Revoke { .. } => summary.revokes += 1,
143                Change::SetDefaultPrivilege { .. } => summary.default_privileges_set += 1,
144                Change::RevokeDefaultPrivilege { .. } => summary.default_privileges_revoked += 1,
145                Change::AddMember { .. } => summary.members_added += 1,
146                Change::RemoveMember { .. } => summary.members_removed += 1,
147            }
148        }
149        summary
150    }
151
152    /// Total number of changes in the plan.
153    pub fn total(&self) -> usize {
154        self.roles_created
155            + self.roles_altered
156            + self.roles_dropped
157            + self.comments_changed
158            + self.sessions_terminated
159            + self.ownerships_reassigned
160            + self.owned_objects_dropped
161            + self.grants
162            + self.revokes
163            + self.default_privileges_set
164            + self.default_privileges_revoked
165            + self.members_added
166            + self.members_removed
167    }
168
169    /// True if the plan has no changes.
170    pub fn is_empty(&self) -> bool {
171        self.total() == 0
172    }
173}
174
175impl std::fmt::Display for PlanSummary {
176    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
177        if self.is_empty() {
178            return write!(f, "No changes needed. Database is in sync with manifest.");
179        }
180
181        writeln!(f, "Plan: {} change(s)", self.total())?;
182
183        let items: Vec<(&str, usize)> = vec![
184            ("role(s) to create", self.roles_created),
185            ("role(s) to alter", self.roles_altered),
186            ("role(s) to drop", self.roles_dropped),
187            ("comment(s) to change", self.comments_changed),
188            ("session termination step(s)", self.sessions_terminated),
189            ("ownership reassignment(s)", self.ownerships_reassigned),
190            ("DROP OWNED cleanup step(s)", self.owned_objects_dropped),
191            ("grant(s) to add", self.grants),
192            ("grant(s) to revoke", self.revokes),
193            ("default privilege(s) to set", self.default_privileges_set),
194            (
195                "default privilege(s) to revoke",
196                self.default_privileges_revoked,
197            ),
198            ("membership(s) to add", self.members_added),
199            ("membership(s) to remove", self.members_removed),
200        ];
201
202        for (label, count) in items {
203            if count > 0 {
204                writeln!(f, "  {count} {label}")?;
205            }
206        }
207        Ok(())
208    }
209}
210
211/// Format validation results for human-readable output.
212pub fn format_validation_result(validated: &ValidatedManifest) -> String {
213    let mut output = String::new();
214    output.push_str("Manifest is valid.\n");
215    output.push_str(&format!(
216        "  {} role(s) defined\n",
217        validated.expanded.roles.len()
218    ));
219    output.push_str(&format!(
220        "  {} grant(s) defined\n",
221        validated.expanded.grants.len()
222    ));
223    output.push_str(&format!(
224        "  {} default privilege(s) defined\n",
225        validated.expanded.default_privileges.len()
226    ));
227    output.push_str(&format!(
228        "  {} membership(s) defined\n",
229        validated.expanded.memberships.len()
230    ));
231    output
232}
233
234// ---------------------------------------------------------------------------
235// Inspect output formatting
236// ---------------------------------------------------------------------------
237
238/// Format a RoleGraph as a human-readable summary.
239pub fn format_role_graph_summary(graph: &RoleGraph) -> String {
240    let mut output = String::new();
241    output.push_str(&format!("Roles: {}\n", graph.roles.len()));
242    output.push_str(&format!("Grants: {}\n", graph.grants.len()));
243    output.push_str(&format!(
244        "Default privileges: {}\n",
245        graph.default_privileges.len()
246    ));
247    output.push_str(&format!("Memberships: {}\n", graph.memberships.len()));
248    output
249}
250
251// ---------------------------------------------------------------------------
252// Tests
253// ---------------------------------------------------------------------------
254
255#[cfg(test)]
256mod tests {
257    use super::*;
258
259    const MINIMAL_MANIFEST: &str = r#"
260default_owner: app_owner
261
262roles:
263  - name: analytics
264    login: true
265    comment: "Analytics read-only role"
266
267grants:
268  - role: analytics
269    privileges: [CONNECT]
270    on: { type: database, name: mydb }
271"#;
272
273    const PROFILE_MANIFEST: &str = r#"
274default_owner: app_owner
275
276profiles:
277  editor:
278    grants:
279      - privileges: [USAGE]
280        on: { type: schema }
281      - privileges: [SELECT, INSERT, UPDATE, DELETE]
282        on: { type: table, name: "*" }
283    default_privileges:
284      - privileges: [SELECT, INSERT, UPDATE, DELETE]
285        on_type: table
286  viewer:
287    grants:
288      - privileges: [USAGE]
289        on: { type: schema }
290      - privileges: [SELECT]
291        on: { type: table, name: "*" }
292    default_privileges:
293      - privileges: [SELECT]
294        on_type: table
295
296schemas:
297  - name: inventory
298    profiles: [editor, viewer]
299  - name: catalog
300    profiles: [viewer]
301
302roles:
303  - name: app-service
304    login: true
305
306grants:
307  - role: app-service
308    privileges: [CONNECT]
309    on: { type: database, name: mydb }
310
311memberships:
312  - role: inventory-editor
313    members:
314      - name: app-service
315"#;
316
317    const INVALID_YAML: &str = r#"
318this is: [not: valid yaml: [[
319"#;
320
321    const UNDEFINED_PROFILE: &str = r#"
322profiles:
323  editor:
324    grants: []
325
326schemas:
327  - name: myschema
328    profiles: [nonexistent]
329"#;
330
331    // -----------------------------------------------------------------------
332    // parse
333    // -----------------------------------------------------------------------
334
335    #[test]
336    fn parse_valid_manifest() {
337        let result = parse(MINIMAL_MANIFEST);
338        assert!(result.is_ok());
339        let manifest = result.unwrap();
340        assert_eq!(manifest.default_owner, Some("app_owner".to_string()));
341        assert_eq!(manifest.roles.len(), 1);
342        assert_eq!(manifest.roles[0].name, "analytics");
343    }
344
345    #[test]
346    fn parse_invalid_yaml() {
347        let result = parse(INVALID_YAML);
348        assert!(result.is_err());
349        let err_msg = result.unwrap_err().to_string();
350        assert!(err_msg.contains("YAML parse error"), "got: {err_msg}");
351    }
352
353    // -----------------------------------------------------------------------
354    // parse_and_expand
355    // -----------------------------------------------------------------------
356
357    #[test]
358    fn expand_profile_manifest() {
359        let expanded = parse_and_expand(PROFILE_MANIFEST).unwrap();
360
361        // inventory-editor, inventory-viewer, catalog-viewer, app-service
362        assert_eq!(expanded.roles.len(), 4);
363
364        let role_names: Vec<&str> = expanded.roles.iter().map(|r| r.name.as_str()).collect();
365        assert!(role_names.contains(&"inventory-editor"));
366        assert!(role_names.contains(&"inventory-viewer"));
367        assert!(role_names.contains(&"catalog-viewer"));
368        assert!(role_names.contains(&"app-service"));
369    }
370
371    #[test]
372    fn expand_undefined_profile_fails() {
373        let result = parse_and_expand(UNDEFINED_PROFILE);
374        assert!(result.is_err());
375        let err_msg = result.unwrap_err().to_string();
376        assert!(
377            err_msg.contains("nonexistent"),
378            "expected error about 'nonexistent' profile, got: {err_msg}"
379        );
380    }
381
382    // -----------------------------------------------------------------------
383    // validate_manifest
384    // -----------------------------------------------------------------------
385
386    #[test]
387    fn validate_builds_role_graph() {
388        let validated = validate_manifest(PROFILE_MANIFEST).unwrap();
389
390        // Check the desired graph has the expected roles
391        assert_eq!(validated.desired.roles.len(), 4);
392        assert!(validated.desired.roles.contains_key("inventory-editor"));
393        assert!(validated.desired.roles.contains_key("app-service"));
394
395        // Check grants were expanded
396        assert!(!validated.desired.grants.is_empty());
397
398        // Check memberships
399        assert!(!validated.desired.memberships.is_empty());
400    }
401
402    // -----------------------------------------------------------------------
403    // compute_plan + format
404    // -----------------------------------------------------------------------
405
406    #[test]
407    fn plan_from_empty_creates_roles() {
408        let validated = validate_manifest(PROFILE_MANIFEST).unwrap();
409        let current = RoleGraph::default(); // empty database
410
411        let changes = compute_plan(&current, &validated.desired);
412        assert!(!changes.is_empty());
413
414        let summary = PlanSummary::from_changes(&changes);
415        assert_eq!(summary.roles_created, 4); // inventory-editor, inventory-viewer, catalog-viewer, app-service
416        assert!(summary.grants > 0);
417        assert!(!summary.is_empty());
418    }
419
420    #[test]
421    fn plan_no_changes_when_in_sync() {
422        let validated = validate_manifest(MINIMAL_MANIFEST).unwrap();
423        // Simulate a DB that already has the desired state
424        let current = validated.desired.clone();
425
426        let changes = compute_plan(&current, &validated.desired);
427        let summary = PlanSummary::from_changes(&changes);
428        assert!(summary.is_empty());
429        assert_eq!(summary.total(), 0);
430    }
431
432    #[test]
433    fn format_plan_sql_produces_sql() {
434        let validated = validate_manifest(MINIMAL_MANIFEST).unwrap();
435        let current = RoleGraph::default();
436        let changes = compute_plan(&current, &validated.desired);
437
438        let sql_output = format_plan_sql(&changes);
439        assert!(
440            sql_output.contains("CREATE ROLE"),
441            "expected CREATE ROLE in: {sql_output}"
442        );
443        assert!(
444            sql_output.contains("\"analytics\""),
445            "expected quoted role name in: {sql_output}"
446        );
447    }
448
449    #[test]
450    fn planned_role_drops_only_returns_drop_changes() {
451        let changes = vec![
452            Change::CreateRole {
453                name: "new-role".to_string(),
454                state: pgroles_core::model::RoleState::default(),
455            },
456            Change::DropRole {
457                name: "old-role".to_string(),
458            },
459            Change::DropRole {
460                name: "stale-role".to_string(),
461            },
462        ];
463
464        assert_eq!(
465            planned_role_drops(&changes),
466            vec!["old-role".to_string(), "stale-role".to_string()]
467        );
468    }
469
470    #[test]
471    fn apply_role_retirements_updates_plan_summary() {
472        let changes = apply_role_retirements(
473            vec![Change::DropRole {
474                name: "legacy-app".to_string(),
475            }],
476            &[pgroles_core::manifest::RoleRetirement {
477                role: "legacy-app".to_string(),
478                reassign_owned_to: Some("app-owner".to_string()),
479                drop_owned: true,
480                terminate_sessions: true,
481            }],
482        );
483
484        let summary = PlanSummary::from_changes(&changes);
485        assert_eq!(summary.roles_dropped, 1);
486        assert_eq!(summary.sessions_terminated, 1);
487        assert_eq!(summary.ownerships_reassigned, 1);
488        assert_eq!(summary.owned_objects_dropped, 1);
489        assert_eq!(summary.total(), 4);
490    }
491
492    // -----------------------------------------------------------------------
493    // PlanSummary display
494    // -----------------------------------------------------------------------
495
496    #[test]
497    fn plan_summary_display_empty() {
498        let summary = PlanSummary::default();
499        let display = summary.to_string();
500        assert!(display.contains("No changes needed"));
501    }
502
503    #[test]
504    fn plan_summary_display_with_changes() {
505        let summary = PlanSummary {
506            roles_created: 2,
507            grants: 5,
508            members_added: 1,
509            ..Default::default()
510        };
511        let display = summary.to_string();
512        assert!(display.contains("8 change(s)"), "got: {display}");
513        assert!(display.contains("2 role(s) to create"), "got: {display}");
514        assert!(display.contains("5 grant(s) to add"), "got: {display}");
515        assert!(display.contains("1 membership(s) to add"), "got: {display}");
516        // Should not mention zero-count items
517        assert!(!display.contains("to drop"), "got: {display}");
518        assert!(!display.contains("to revoke"), "got: {display}");
519    }
520
521    // -----------------------------------------------------------------------
522    // format_validation_result
523    // -----------------------------------------------------------------------
524
525    #[test]
526    fn validation_result_shows_counts() {
527        let validated = validate_manifest(PROFILE_MANIFEST).unwrap();
528        let output = format_validation_result(&validated);
529        assert!(output.contains("Manifest is valid"), "got: {output}");
530        assert!(output.contains("4 role(s)"), "got: {output}");
531    }
532
533    // -----------------------------------------------------------------------
534    // read_manifest_file
535    // -----------------------------------------------------------------------
536
537    #[test]
538    fn read_nonexistent_file_fails() {
539        let result = read_manifest_file(Path::new("/tmp/nonexistent-pgroles-test.yaml"));
540        assert!(result.is_err());
541        let err_msg = format!("{:#}", result.unwrap_err());
542        assert!(
543            err_msg.contains("failed to read manifest file"),
544            "got: {err_msg}"
545        );
546    }
547
548    // -----------------------------------------------------------------------
549    // format_role_graph_summary
550    // -----------------------------------------------------------------------
551
552    #[test]
553    fn role_graph_summary_format() {
554        let validated = validate_manifest(MINIMAL_MANIFEST).unwrap();
555        let summary = format_role_graph_summary(&validated.desired);
556        assert!(summary.contains("Roles: 1"), "got: {summary}");
557    }
558
559    // -----------------------------------------------------------------------
560    // format_plan_json
561    // -----------------------------------------------------------------------
562
563    #[test]
564    fn plan_json_produces_valid_json() {
565        let validated = validate_manifest(MINIMAL_MANIFEST).unwrap();
566        let current = RoleGraph::default();
567        let changes = compute_plan(&current, &validated.desired);
568
569        let json_output = format_plan_json(&changes).unwrap();
570        // Should be parseable JSON
571        let parsed: serde_json::Value = serde_json::from_str(&json_output).unwrap();
572        assert!(parsed.is_array());
573        // Should contain CreateRole
574        let text = json_output.to_string();
575        assert!(text.contains("CreateRole"), "got: {text}");
576        assert!(text.contains("analytics"), "got: {text}");
577    }
578}