1use 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
16pub 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
26pub fn parse(yaml: &str) -> Result<PolicyManifest> {
32 manifest::parse_manifest(yaml).map_err(|err| anyhow::anyhow!("{err}"))
33}
34
35pub 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
41pub 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
59pub struct ValidatedManifest {
61 pub manifest: PolicyManifest,
62 pub expanded: ExpandedManifest,
63 pub desired: RoleGraph,
64}
65
66pub fn compute_plan(current: &RoleGraph, desired: &RoleGraph) -> Vec<Change> {
72 diff::diff(current, desired)
73}
74
75pub 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
86pub fn apply_role_retirements(changes: Vec<Change>, retirements: &[RoleRetirement]) -> Vec<Change> {
88 diff::apply_role_retirements(changes, retirements)
89}
90
91pub fn resolve_passwords(
93 expanded: &ExpandedManifest,
94) -> Result<std::collections::BTreeMap<String, String>> {
95 diff::resolve_passwords(&expanded.roles).map_err(|err| anyhow::anyhow!("{err}"))
96}
97
98pub fn inject_password_changes(
100 changes: Vec<Change>,
101 resolved_passwords: &std::collections::BTreeMap<String, String>,
102) -> Vec<Change> {
103 diff::inject_password_changes(changes, resolved_passwords)
104}
105
106pub fn format_plan_sql(changes: &[Change]) -> String {
112 sql::render_all(changes)
113}
114
115pub fn format_plan_sql_with_context(changes: &[Change], ctx: &sql::SqlContext) -> String {
117 sql::render_all_with_context(&redacted_changes(changes), ctx)
118}
119
120pub fn format_plan_json(changes: &[Change]) -> Result<String> {
122 serde_json::to_string_pretty(&redacted_changes(changes)).map_err(|err| anyhow::anyhow!("{err}"))
123}
124
125fn redacted_changes(changes: &[Change]) -> Vec<Change> {
126 changes
127 .iter()
128 .map(|change| match change {
129 Change::SetPassword { name, .. } => Change::SetPassword {
130 name: name.clone(),
131 password: "[REDACTED]".to_string(),
132 },
133 other => other.clone(),
134 })
135 .collect()
136}
137
138#[derive(Debug, Default, PartialEq, Eq)]
140pub struct PlanSummary {
141 pub roles_created: usize,
142 pub roles_altered: usize,
143 pub roles_dropped: usize,
144 pub comments_changed: usize,
145 pub sessions_terminated: usize,
146 pub ownerships_reassigned: usize,
147 pub owned_objects_dropped: usize,
148 pub grants: usize,
149 pub revokes: usize,
150 pub default_privileges_set: usize,
151 pub default_privileges_revoked: usize,
152 pub members_added: usize,
153 pub members_removed: usize,
154 pub passwords_set: usize,
155}
156
157impl PlanSummary {
158 pub fn from_changes(changes: &[Change]) -> Self {
160 let mut summary = Self::default();
161 for change in changes {
162 match change {
163 Change::CreateRole { .. } => summary.roles_created += 1,
164 Change::AlterRole { .. } => summary.roles_altered += 1,
165 Change::DropRole { .. } => summary.roles_dropped += 1,
166 Change::SetComment { .. } => summary.comments_changed += 1,
167 Change::TerminateSessions { .. } => summary.sessions_terminated += 1,
168 Change::ReassignOwned { .. } => summary.ownerships_reassigned += 1,
169 Change::DropOwned { .. } => summary.owned_objects_dropped += 1,
170 Change::Grant { .. } => summary.grants += 1,
171 Change::Revoke { .. } => summary.revokes += 1,
172 Change::SetDefaultPrivilege { .. } => summary.default_privileges_set += 1,
173 Change::RevokeDefaultPrivilege { .. } => summary.default_privileges_revoked += 1,
174 Change::AddMember { .. } => summary.members_added += 1,
175 Change::RemoveMember { .. } => summary.members_removed += 1,
176 Change::SetPassword { .. } => summary.passwords_set += 1,
177 }
178 }
179 summary
180 }
181
182 pub fn total(&self) -> usize {
184 self.roles_created
185 + self.roles_altered
186 + self.roles_dropped
187 + self.comments_changed
188 + self.sessions_terminated
189 + self.ownerships_reassigned
190 + self.owned_objects_dropped
191 + self.grants
192 + self.revokes
193 + self.default_privileges_set
194 + self.default_privileges_revoked
195 + self.members_added
196 + self.members_removed
197 + self.passwords_set
198 }
199
200 pub fn is_empty(&self) -> bool {
202 self.total() == 0
203 }
204
205 pub fn has_structural_changes(&self) -> bool {
211 self.total() - self.passwords_set > 0
212 }
213
214 pub fn format_plan(&self) -> String {
215 self.format_with_header("Plan")
216 }
217
218 pub fn format_applied(&self) -> String {
219 self.format_with_header("Applied")
220 }
221
222 fn format_with_header(&self, header: &str) -> String {
223 if self.is_empty() {
224 return "No changes needed. Database is in sync with manifest.".to_string();
225 }
226
227 let mut output = String::new();
228 output.push_str(&format!("{header}: {} change(s)\n", self.total()));
229
230 let items: Vec<(&str, usize)> = vec![
231 ("role(s) to create", self.roles_created),
232 ("role(s) to alter", self.roles_altered),
233 ("role(s) to drop", self.roles_dropped),
234 ("comment(s) to change", self.comments_changed),
235 ("session termination step(s)", self.sessions_terminated),
236 ("ownership reassignment(s)", self.ownerships_reassigned),
237 ("DROP OWNED cleanup step(s)", self.owned_objects_dropped),
238 ("grant(s) to add", self.grants),
239 ("grant(s) to revoke", self.revokes),
240 ("default privilege(s) to set", self.default_privileges_set),
241 (
242 "default privilege(s) to revoke",
243 self.default_privileges_revoked,
244 ),
245 ("membership(s) to add", self.members_added),
246 ("membership(s) to remove", self.members_removed),
247 ("password(s) to set", self.passwords_set),
248 ];
249
250 for (label, count) in items {
251 if count > 0 {
252 output.push_str(&format!(" {count} {label}\n"));
253 }
254 }
255
256 output
257 }
258}
259
260impl std::fmt::Display for PlanSummary {
261 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
262 write!(f, "{}", self.format_plan())
263 }
264}
265
266pub fn format_validation_result(validated: &ValidatedManifest) -> String {
268 let mut output = String::new();
269 output.push_str("Manifest is valid.\n");
270 output.push_str(&format!(
271 " {} role(s) defined\n",
272 validated.expanded.roles.len()
273 ));
274 output.push_str(&format!(
275 " {} grant(s) defined\n",
276 validated.expanded.grants.len()
277 ));
278 output.push_str(&format!(
279 " {} default privilege(s) defined\n",
280 validated.expanded.default_privileges.len()
281 ));
282 output.push_str(&format!(
283 " {} membership(s) defined\n",
284 validated.expanded.memberships.len()
285 ));
286 output
287}
288
289pub fn format_role_graph_summary(graph: &RoleGraph) -> String {
295 let mut output = String::new();
296 output.push_str(&format!("Roles: {}\n", graph.roles.len()));
297 output.push_str(&format!("Grants: {}\n", graph.grants.len()));
298 output.push_str(&format!(
299 "Default privileges: {}\n",
300 graph.default_privileges.len()
301 ));
302 output.push_str(&format!("Memberships: {}\n", graph.memberships.len()));
303 output
304}
305
306#[cfg(test)]
311mod tests {
312 use super::*;
313
314 const MINIMAL_MANIFEST: &str = r#"
315default_owner: app_owner
316
317roles:
318 - name: analytics
319 login: true
320 comment: "Analytics read-only role"
321
322grants:
323 - role: analytics
324 privileges: [CONNECT]
325 on: { type: database, name: mydb }
326"#;
327
328 const PROFILE_MANIFEST: &str = r#"
329default_owner: app_owner
330
331profiles:
332 editor:
333 grants:
334 - privileges: [USAGE]
335 on: { type: schema }
336 - privileges: [SELECT, INSERT, UPDATE, DELETE]
337 on: { type: table, name: "*" }
338 default_privileges:
339 - privileges: [SELECT, INSERT, UPDATE, DELETE]
340 on_type: table
341 viewer:
342 grants:
343 - privileges: [USAGE]
344 on: { type: schema }
345 - privileges: [SELECT]
346 on: { type: table, name: "*" }
347 default_privileges:
348 - privileges: [SELECT]
349 on_type: table
350
351schemas:
352 - name: inventory
353 profiles: [editor, viewer]
354 - name: catalog
355 profiles: [viewer]
356
357roles:
358 - name: app-service
359 login: true
360
361grants:
362 - role: app-service
363 privileges: [CONNECT]
364 on: { type: database, name: mydb }
365
366memberships:
367 - role: inventory-editor
368 members:
369 - name: app-service
370"#;
371
372 const INVALID_YAML: &str = r#"
373this is: [not: valid yaml: [[
374"#;
375
376 const UNDEFINED_PROFILE: &str = r#"
377profiles:
378 editor:
379 grants: []
380
381schemas:
382 - name: myschema
383 profiles: [nonexistent]
384"#;
385
386 #[test]
391 fn parse_valid_manifest() {
392 let result = parse(MINIMAL_MANIFEST);
393 assert!(result.is_ok());
394 let manifest = result.unwrap();
395 assert_eq!(manifest.default_owner, Some("app_owner".to_string()));
396 assert_eq!(manifest.roles.len(), 1);
397 assert_eq!(manifest.roles[0].name, "analytics");
398 }
399
400 #[test]
401 fn parse_invalid_yaml() {
402 let result = parse(INVALID_YAML);
403 assert!(result.is_err());
404 let err_msg = result.unwrap_err().to_string();
405 assert!(err_msg.contains("YAML parse error"), "got: {err_msg}");
406 }
407
408 #[test]
413 fn expand_profile_manifest() {
414 let expanded = parse_and_expand(PROFILE_MANIFEST).unwrap();
415
416 assert_eq!(expanded.roles.len(), 4);
418
419 let role_names: Vec<&str> = expanded.roles.iter().map(|r| r.name.as_str()).collect();
420 assert!(role_names.contains(&"inventory-editor"));
421 assert!(role_names.contains(&"inventory-viewer"));
422 assert!(role_names.contains(&"catalog-viewer"));
423 assert!(role_names.contains(&"app-service"));
424 }
425
426 #[test]
427 fn expand_undefined_profile_fails() {
428 let result = parse_and_expand(UNDEFINED_PROFILE);
429 assert!(result.is_err());
430 let err_msg = result.unwrap_err().to_string();
431 assert!(
432 err_msg.contains("nonexistent"),
433 "expected error about 'nonexistent' profile, got: {err_msg}"
434 );
435 }
436
437 #[test]
442 fn validate_builds_role_graph() {
443 let validated = validate_manifest(PROFILE_MANIFEST).unwrap();
444
445 assert_eq!(validated.desired.roles.len(), 4);
447 assert!(validated.desired.roles.contains_key("inventory-editor"));
448 assert!(validated.desired.roles.contains_key("app-service"));
449
450 assert!(!validated.desired.grants.is_empty());
452
453 assert!(!validated.desired.memberships.is_empty());
455 }
456
457 #[test]
462 fn plan_from_empty_creates_roles() {
463 let validated = validate_manifest(PROFILE_MANIFEST).unwrap();
464 let current = RoleGraph::default(); let changes = compute_plan(¤t, &validated.desired);
467 assert!(!changes.is_empty());
468
469 let summary = PlanSummary::from_changes(&changes);
470 assert_eq!(summary.roles_created, 4); assert!(summary.grants > 0);
472 assert!(!summary.is_empty());
473 }
474
475 #[test]
476 fn plan_no_changes_when_in_sync() {
477 let validated = validate_manifest(MINIMAL_MANIFEST).unwrap();
478 let current = validated.desired.clone();
480
481 let changes = compute_plan(¤t, &validated.desired);
482 let summary = PlanSummary::from_changes(&changes);
483 assert!(summary.is_empty());
484 assert_eq!(summary.total(), 0);
485 }
486
487 #[test]
488 fn format_plan_sql_produces_sql() {
489 let validated = validate_manifest(MINIMAL_MANIFEST).unwrap();
490 let current = RoleGraph::default();
491 let changes = compute_plan(¤t, &validated.desired);
492
493 let sql_output = format_plan_sql(&changes);
494 assert!(
495 sql_output.contains("CREATE ROLE"),
496 "expected CREATE ROLE in: {sql_output}"
497 );
498 assert!(
499 sql_output.contains("\"analytics\""),
500 "expected quoted role name in: {sql_output}"
501 );
502 }
503
504 #[test]
505 fn planned_role_drops_only_returns_drop_changes() {
506 let changes = vec![
507 Change::CreateRole {
508 name: "new-role".to_string(),
509 state: pgroles_core::model::RoleState::default(),
510 },
511 Change::DropRole {
512 name: "old-role".to_string(),
513 },
514 Change::DropRole {
515 name: "stale-role".to_string(),
516 },
517 ];
518
519 assert_eq!(
520 planned_role_drops(&changes),
521 vec!["old-role".to_string(), "stale-role".to_string()]
522 );
523 }
524
525 #[test]
526 fn apply_role_retirements_updates_plan_summary() {
527 let changes = apply_role_retirements(
528 vec![Change::DropRole {
529 name: "legacy-app".to_string(),
530 }],
531 &[pgroles_core::manifest::RoleRetirement {
532 role: "legacy-app".to_string(),
533 reassign_owned_to: Some("app-owner".to_string()),
534 drop_owned: true,
535 terminate_sessions: true,
536 }],
537 );
538
539 let summary = PlanSummary::from_changes(&changes);
540 assert_eq!(summary.roles_dropped, 1);
541 assert_eq!(summary.sessions_terminated, 1);
542 assert_eq!(summary.ownerships_reassigned, 1);
543 assert_eq!(summary.owned_objects_dropped, 1);
544 assert_eq!(summary.total(), 4);
545 }
546
547 #[test]
552 fn plan_summary_display_empty() {
553 let summary = PlanSummary::default();
554 let display = summary.to_string();
555 assert!(display.contains("No changes needed"));
556 }
557
558 #[test]
559 fn plan_summary_display_with_changes() {
560 let summary = PlanSummary {
561 roles_created: 2,
562 grants: 5,
563 members_added: 1,
564 ..Default::default()
565 };
566 let display = summary.to_string();
567 assert!(display.contains("8 change(s)"), "got: {display}");
568 assert!(display.contains("2 role(s) to create"), "got: {display}");
569 assert!(display.contains("5 grant(s) to add"), "got: {display}");
570 assert!(display.contains("1 membership(s) to add"), "got: {display}");
571 assert!(!display.contains("to drop"), "got: {display}");
573 assert!(!display.contains("to revoke"), "got: {display}");
574 }
575
576 #[test]
581 fn validation_result_shows_counts() {
582 let validated = validate_manifest(PROFILE_MANIFEST).unwrap();
583 let output = format_validation_result(&validated);
584 assert!(output.contains("Manifest is valid"), "got: {output}");
585 assert!(output.contains("4 role(s)"), "got: {output}");
586 }
587
588 #[test]
593 fn read_nonexistent_file_fails() {
594 let result = read_manifest_file(Path::new("/tmp/nonexistent-pgroles-test.yaml"));
595 assert!(result.is_err());
596 let err_msg = format!("{:#}", result.unwrap_err());
597 assert!(
598 err_msg.contains("failed to read manifest file"),
599 "got: {err_msg}"
600 );
601 }
602
603 #[test]
608 fn role_graph_summary_format() {
609 let validated = validate_manifest(MINIMAL_MANIFEST).unwrap();
610 let summary = format_role_graph_summary(&validated.desired);
611 assert!(summary.contains("Roles: 1"), "got: {summary}");
612 }
613
614 #[test]
619 fn has_structural_changes_true_for_non_password_changes() {
620 let summary = PlanSummary {
621 roles_created: 1,
622 grants: 2,
623 ..Default::default()
624 };
625 assert!(summary.has_structural_changes());
626 }
627
628 #[test]
629 fn has_structural_changes_false_for_password_only() {
630 let summary = PlanSummary {
631 passwords_set: 3,
632 ..Default::default()
633 };
634 assert!(
635 !summary.has_structural_changes(),
636 "password-only plan should NOT be considered structural drift"
637 );
638 }
639
640 #[test]
641 fn has_structural_changes_true_for_mixed() {
642 let summary = PlanSummary {
643 roles_created: 1,
644 passwords_set: 2,
645 ..Default::default()
646 };
647 assert!(
648 summary.has_structural_changes(),
649 "mixed plan with structural + password changes IS structural drift"
650 );
651 }
652
653 #[test]
654 fn has_structural_changes_false_for_empty() {
655 let summary = PlanSummary::default();
656 assert!(!summary.has_structural_changes());
657 }
658
659 #[test]
660 fn plan_summary_displays_password_count() {
661 let summary = PlanSummary {
662 passwords_set: 2,
663 roles_created: 1,
664 ..Default::default()
665 };
666 let display = summary.to_string();
667 assert!(display.contains("2 password(s) to set"), "got: {display}");
668 assert!(display.contains("3 change(s)"), "got: {display}");
669 }
670
671 #[test]
676 fn additive_mode_filters_revokes_from_plan() {
677 use pgroles_core::diff::{ReconciliationMode, filter_changes};
678 use pgroles_core::model::RoleState;
679
680 let validated = validate_manifest(PROFILE_MANIFEST).unwrap();
681
682 let mut current = validated.desired.clone();
683 current
684 .roles
685 .insert("stale-role".to_string(), RoleState::default());
686
687 let changes = compute_plan(¤t, &validated.desired);
688 assert!(changes.iter().any(|c| matches!(
689 c,
690 pgroles_core::diff::Change::DropRole { name } if name == "stale-role"
691 )));
692
693 let filtered = filter_changes(changes, ReconciliationMode::Additive);
694 assert!(
695 !filtered
696 .iter()
697 .any(|c| matches!(c, pgroles_core::diff::Change::DropRole { .. })),
698 "additive mode should filter out DropRole"
699 );
700 }
701
702 #[test]
703 fn adopt_mode_filters_drops_but_keeps_revokes() {
704 use pgroles_core::diff::{ReconciliationMode, filter_changes};
705 use pgroles_core::manifest::{ObjectType, Privilege};
706 use pgroles_core::model::{GrantKey, GrantState, RoleState};
707 use std::collections::BTreeSet;
708
709 let validated = validate_manifest(MINIMAL_MANIFEST).unwrap();
710
711 let mut current = validated.desired.clone();
712 current
713 .roles
714 .insert("stale-role".to_string(), RoleState::default());
715 current.grants.insert(
716 GrantKey {
717 role: "analytics".to_string(),
718 object_type: ObjectType::Table,
719 schema: Some("public".to_string()),
720 name: Some("*".to_string()),
721 },
722 GrantState {
723 privileges: BTreeSet::from([Privilege::Select]),
724 },
725 );
726
727 let changes = compute_plan(¤t, &validated.desired);
728
729 let filtered = filter_changes(changes, ReconciliationMode::Adopt);
730 assert!(
731 !filtered
732 .iter()
733 .any(|c| matches!(c, pgroles_core::diff::Change::DropRole { .. })),
734 "adopt mode should filter out DropRole"
735 );
736 assert!(
737 filtered
738 .iter()
739 .any(|c| matches!(c, pgroles_core::diff::Change::Revoke { .. })),
740 "adopt mode should keep Revoke changes"
741 );
742 }
743 #[test]
748 fn plan_json_produces_valid_json() {
749 let validated = validate_manifest(MINIMAL_MANIFEST).unwrap();
750 let current = RoleGraph::default();
751 let changes = compute_plan(¤t, &validated.desired);
752
753 let json_output = format_plan_json(&changes).unwrap();
754 let parsed: serde_json::Value = serde_json::from_str(&json_output).unwrap();
756 assert!(parsed.is_array());
757 let text = json_output.to_string();
759 assert!(text.contains("CreateRole"), "got: {text}");
760 assert!(text.contains("analytics"), "got: {text}");
761 }
762
763 #[test]
764 fn format_plan_json_redacts_passwords() {
765 let changes = vec![Change::SetPassword {
766 name: "app-svc".to_string(),
767 password: "super-secret".to_string(),
768 }];
769
770 let json = format_plan_json(&changes).expect("json formatting should succeed");
771 assert!(json.contains("[REDACTED]"), "got: {json}");
772 assert!(!json.contains("super-secret"), "got: {json}");
773 }
774
775 #[test]
776 fn format_plan_sql_redacts_passwords() {
777 let changes = vec![Change::SetPassword {
778 name: "app-svc".to_string(),
779 password: "super-secret".to_string(),
780 }];
781
782 let sql = format_plan_sql_with_context(&changes, &sql::SqlContext::default());
783 assert!(sql.contains("[REDACTED]"), "got: {sql}");
784 assert!(!sql.contains("super-secret"), "got: {sql}");
785 }
786
787 #[test]
788 fn format_applied_uses_applied_header() {
789 let summary = PlanSummary {
790 roles_created: 1,
791 grants: 2,
792 ..Default::default()
793 };
794
795 let display = summary.format_applied();
796 assert!(
797 display.starts_with("Applied: 3 change(s)\n"),
798 "got: {display}"
799 );
800 assert!(display.contains("1 role(s) to create"), "got: {display}");
801 assert!(display.contains("2 grant(s) to add"), "got: {display}");
802 assert!(!display.contains("Plan:"), "got: {display}");
803 }
804}