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(changes, ctx)
118}
119
120pub fn format_plan_json(changes: &[Change]) -> Result<String> {
122 serde_json::to_string_pretty(changes).map_err(|err| anyhow::anyhow!("{err}"))
123}
124
125#[derive(Debug, Default, PartialEq, Eq)]
127pub struct PlanSummary {
128 pub roles_created: usize,
129 pub roles_altered: usize,
130 pub roles_dropped: usize,
131 pub comments_changed: usize,
132 pub sessions_terminated: usize,
133 pub ownerships_reassigned: usize,
134 pub owned_objects_dropped: usize,
135 pub grants: usize,
136 pub revokes: usize,
137 pub default_privileges_set: usize,
138 pub default_privileges_revoked: usize,
139 pub members_added: usize,
140 pub members_removed: usize,
141 pub passwords_set: usize,
142}
143
144impl PlanSummary {
145 pub fn from_changes(changes: &[Change]) -> Self {
147 let mut summary = Self::default();
148 for change in changes {
149 match change {
150 Change::CreateRole { .. } => summary.roles_created += 1,
151 Change::AlterRole { .. } => summary.roles_altered += 1,
152 Change::DropRole { .. } => summary.roles_dropped += 1,
153 Change::SetComment { .. } => summary.comments_changed += 1,
154 Change::TerminateSessions { .. } => summary.sessions_terminated += 1,
155 Change::ReassignOwned { .. } => summary.ownerships_reassigned += 1,
156 Change::DropOwned { .. } => summary.owned_objects_dropped += 1,
157 Change::Grant { .. } => summary.grants += 1,
158 Change::Revoke { .. } => summary.revokes += 1,
159 Change::SetDefaultPrivilege { .. } => summary.default_privileges_set += 1,
160 Change::RevokeDefaultPrivilege { .. } => summary.default_privileges_revoked += 1,
161 Change::AddMember { .. } => summary.members_added += 1,
162 Change::RemoveMember { .. } => summary.members_removed += 1,
163 Change::SetPassword { .. } => summary.passwords_set += 1,
164 }
165 }
166 summary
167 }
168
169 pub fn total(&self) -> usize {
171 self.roles_created
172 + self.roles_altered
173 + self.roles_dropped
174 + self.comments_changed
175 + self.sessions_terminated
176 + self.ownerships_reassigned
177 + self.owned_objects_dropped
178 + self.grants
179 + self.revokes
180 + self.default_privileges_set
181 + self.default_privileges_revoked
182 + self.members_added
183 + self.members_removed
184 + self.passwords_set
185 }
186
187 pub fn is_empty(&self) -> bool {
189 self.total() == 0
190 }
191
192 pub fn has_structural_changes(&self) -> bool {
198 self.total() - self.passwords_set > 0
199 }
200}
201
202impl std::fmt::Display for PlanSummary {
203 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
204 if self.is_empty() {
205 return write!(f, "No changes needed. Database is in sync with manifest.");
206 }
207
208 writeln!(f, "Plan: {} change(s)", self.total())?;
209
210 let items: Vec<(&str, usize)> = vec![
211 ("role(s) to create", self.roles_created),
212 ("role(s) to alter", self.roles_altered),
213 ("role(s) to drop", self.roles_dropped),
214 ("comment(s) to change", self.comments_changed),
215 ("session termination step(s)", self.sessions_terminated),
216 ("ownership reassignment(s)", self.ownerships_reassigned),
217 ("DROP OWNED cleanup step(s)", self.owned_objects_dropped),
218 ("grant(s) to add", self.grants),
219 ("grant(s) to revoke", self.revokes),
220 ("default privilege(s) to set", self.default_privileges_set),
221 (
222 "default privilege(s) to revoke",
223 self.default_privileges_revoked,
224 ),
225 ("membership(s) to add", self.members_added),
226 ("membership(s) to remove", self.members_removed),
227 ("password(s) to set", self.passwords_set),
228 ];
229
230 for (label, count) in items {
231 if count > 0 {
232 writeln!(f, " {count} {label}")?;
233 }
234 }
235 Ok(())
236 }
237}
238
239pub fn format_validation_result(validated: &ValidatedManifest) -> String {
241 let mut output = String::new();
242 output.push_str("Manifest is valid.\n");
243 output.push_str(&format!(
244 " {} role(s) defined\n",
245 validated.expanded.roles.len()
246 ));
247 output.push_str(&format!(
248 " {} grant(s) defined\n",
249 validated.expanded.grants.len()
250 ));
251 output.push_str(&format!(
252 " {} default privilege(s) defined\n",
253 validated.expanded.default_privileges.len()
254 ));
255 output.push_str(&format!(
256 " {} membership(s) defined\n",
257 validated.expanded.memberships.len()
258 ));
259 output
260}
261
262pub fn format_role_graph_summary(graph: &RoleGraph) -> String {
268 let mut output = String::new();
269 output.push_str(&format!("Roles: {}\n", graph.roles.len()));
270 output.push_str(&format!("Grants: {}\n", graph.grants.len()));
271 output.push_str(&format!(
272 "Default privileges: {}\n",
273 graph.default_privileges.len()
274 ));
275 output.push_str(&format!("Memberships: {}\n", graph.memberships.len()));
276 output
277}
278
279#[cfg(test)]
284mod tests {
285 use super::*;
286
287 const MINIMAL_MANIFEST: &str = r#"
288default_owner: app_owner
289
290roles:
291 - name: analytics
292 login: true
293 comment: "Analytics read-only role"
294
295grants:
296 - role: analytics
297 privileges: [CONNECT]
298 on: { type: database, name: mydb }
299"#;
300
301 const PROFILE_MANIFEST: &str = r#"
302default_owner: app_owner
303
304profiles:
305 editor:
306 grants:
307 - privileges: [USAGE]
308 on: { type: schema }
309 - privileges: [SELECT, INSERT, UPDATE, DELETE]
310 on: { type: table, name: "*" }
311 default_privileges:
312 - privileges: [SELECT, INSERT, UPDATE, DELETE]
313 on_type: table
314 viewer:
315 grants:
316 - privileges: [USAGE]
317 on: { type: schema }
318 - privileges: [SELECT]
319 on: { type: table, name: "*" }
320 default_privileges:
321 - privileges: [SELECT]
322 on_type: table
323
324schemas:
325 - name: inventory
326 profiles: [editor, viewer]
327 - name: catalog
328 profiles: [viewer]
329
330roles:
331 - name: app-service
332 login: true
333
334grants:
335 - role: app-service
336 privileges: [CONNECT]
337 on: { type: database, name: mydb }
338
339memberships:
340 - role: inventory-editor
341 members:
342 - name: app-service
343"#;
344
345 const INVALID_YAML: &str = r#"
346this is: [not: valid yaml: [[
347"#;
348
349 const UNDEFINED_PROFILE: &str = r#"
350profiles:
351 editor:
352 grants: []
353
354schemas:
355 - name: myschema
356 profiles: [nonexistent]
357"#;
358
359 #[test]
364 fn parse_valid_manifest() {
365 let result = parse(MINIMAL_MANIFEST);
366 assert!(result.is_ok());
367 let manifest = result.unwrap();
368 assert_eq!(manifest.default_owner, Some("app_owner".to_string()));
369 assert_eq!(manifest.roles.len(), 1);
370 assert_eq!(manifest.roles[0].name, "analytics");
371 }
372
373 #[test]
374 fn parse_invalid_yaml() {
375 let result = parse(INVALID_YAML);
376 assert!(result.is_err());
377 let err_msg = result.unwrap_err().to_string();
378 assert!(err_msg.contains("YAML parse error"), "got: {err_msg}");
379 }
380
381 #[test]
386 fn expand_profile_manifest() {
387 let expanded = parse_and_expand(PROFILE_MANIFEST).unwrap();
388
389 assert_eq!(expanded.roles.len(), 4);
391
392 let role_names: Vec<&str> = expanded.roles.iter().map(|r| r.name.as_str()).collect();
393 assert!(role_names.contains(&"inventory-editor"));
394 assert!(role_names.contains(&"inventory-viewer"));
395 assert!(role_names.contains(&"catalog-viewer"));
396 assert!(role_names.contains(&"app-service"));
397 }
398
399 #[test]
400 fn expand_undefined_profile_fails() {
401 let result = parse_and_expand(UNDEFINED_PROFILE);
402 assert!(result.is_err());
403 let err_msg = result.unwrap_err().to_string();
404 assert!(
405 err_msg.contains("nonexistent"),
406 "expected error about 'nonexistent' profile, got: {err_msg}"
407 );
408 }
409
410 #[test]
415 fn validate_builds_role_graph() {
416 let validated = validate_manifest(PROFILE_MANIFEST).unwrap();
417
418 assert_eq!(validated.desired.roles.len(), 4);
420 assert!(validated.desired.roles.contains_key("inventory-editor"));
421 assert!(validated.desired.roles.contains_key("app-service"));
422
423 assert!(!validated.desired.grants.is_empty());
425
426 assert!(!validated.desired.memberships.is_empty());
428 }
429
430 #[test]
435 fn plan_from_empty_creates_roles() {
436 let validated = validate_manifest(PROFILE_MANIFEST).unwrap();
437 let current = RoleGraph::default(); let changes = compute_plan(¤t, &validated.desired);
440 assert!(!changes.is_empty());
441
442 let summary = PlanSummary::from_changes(&changes);
443 assert_eq!(summary.roles_created, 4); assert!(summary.grants > 0);
445 assert!(!summary.is_empty());
446 }
447
448 #[test]
449 fn plan_no_changes_when_in_sync() {
450 let validated = validate_manifest(MINIMAL_MANIFEST).unwrap();
451 let current = validated.desired.clone();
453
454 let changes = compute_plan(¤t, &validated.desired);
455 let summary = PlanSummary::from_changes(&changes);
456 assert!(summary.is_empty());
457 assert_eq!(summary.total(), 0);
458 }
459
460 #[test]
461 fn format_plan_sql_produces_sql() {
462 let validated = validate_manifest(MINIMAL_MANIFEST).unwrap();
463 let current = RoleGraph::default();
464 let changes = compute_plan(¤t, &validated.desired);
465
466 let sql_output = format_plan_sql(&changes);
467 assert!(
468 sql_output.contains("CREATE ROLE"),
469 "expected CREATE ROLE in: {sql_output}"
470 );
471 assert!(
472 sql_output.contains("\"analytics\""),
473 "expected quoted role name in: {sql_output}"
474 );
475 }
476
477 #[test]
478 fn planned_role_drops_only_returns_drop_changes() {
479 let changes = vec![
480 Change::CreateRole {
481 name: "new-role".to_string(),
482 state: pgroles_core::model::RoleState::default(),
483 },
484 Change::DropRole {
485 name: "old-role".to_string(),
486 },
487 Change::DropRole {
488 name: "stale-role".to_string(),
489 },
490 ];
491
492 assert_eq!(
493 planned_role_drops(&changes),
494 vec!["old-role".to_string(), "stale-role".to_string()]
495 );
496 }
497
498 #[test]
499 fn apply_role_retirements_updates_plan_summary() {
500 let changes = apply_role_retirements(
501 vec![Change::DropRole {
502 name: "legacy-app".to_string(),
503 }],
504 &[pgroles_core::manifest::RoleRetirement {
505 role: "legacy-app".to_string(),
506 reassign_owned_to: Some("app-owner".to_string()),
507 drop_owned: true,
508 terminate_sessions: true,
509 }],
510 );
511
512 let summary = PlanSummary::from_changes(&changes);
513 assert_eq!(summary.roles_dropped, 1);
514 assert_eq!(summary.sessions_terminated, 1);
515 assert_eq!(summary.ownerships_reassigned, 1);
516 assert_eq!(summary.owned_objects_dropped, 1);
517 assert_eq!(summary.total(), 4);
518 }
519
520 #[test]
525 fn plan_summary_display_empty() {
526 let summary = PlanSummary::default();
527 let display = summary.to_string();
528 assert!(display.contains("No changes needed"));
529 }
530
531 #[test]
532 fn plan_summary_display_with_changes() {
533 let summary = PlanSummary {
534 roles_created: 2,
535 grants: 5,
536 members_added: 1,
537 ..Default::default()
538 };
539 let display = summary.to_string();
540 assert!(display.contains("8 change(s)"), "got: {display}");
541 assert!(display.contains("2 role(s) to create"), "got: {display}");
542 assert!(display.contains("5 grant(s) to add"), "got: {display}");
543 assert!(display.contains("1 membership(s) to add"), "got: {display}");
544 assert!(!display.contains("to drop"), "got: {display}");
546 assert!(!display.contains("to revoke"), "got: {display}");
547 }
548
549 #[test]
554 fn validation_result_shows_counts() {
555 let validated = validate_manifest(PROFILE_MANIFEST).unwrap();
556 let output = format_validation_result(&validated);
557 assert!(output.contains("Manifest is valid"), "got: {output}");
558 assert!(output.contains("4 role(s)"), "got: {output}");
559 }
560
561 #[test]
566 fn read_nonexistent_file_fails() {
567 let result = read_manifest_file(Path::new("/tmp/nonexistent-pgroles-test.yaml"));
568 assert!(result.is_err());
569 let err_msg = format!("{:#}", result.unwrap_err());
570 assert!(
571 err_msg.contains("failed to read manifest file"),
572 "got: {err_msg}"
573 );
574 }
575
576 #[test]
581 fn role_graph_summary_format() {
582 let validated = validate_manifest(MINIMAL_MANIFEST).unwrap();
583 let summary = format_role_graph_summary(&validated.desired);
584 assert!(summary.contains("Roles: 1"), "got: {summary}");
585 }
586
587 #[test]
592 fn has_structural_changes_true_for_non_password_changes() {
593 let summary = PlanSummary {
594 roles_created: 1,
595 grants: 2,
596 ..Default::default()
597 };
598 assert!(summary.has_structural_changes());
599 }
600
601 #[test]
602 fn has_structural_changes_false_for_password_only() {
603 let summary = PlanSummary {
604 passwords_set: 3,
605 ..Default::default()
606 };
607 assert!(
608 !summary.has_structural_changes(),
609 "password-only plan should NOT be considered structural drift"
610 );
611 }
612
613 #[test]
614 fn has_structural_changes_true_for_mixed() {
615 let summary = PlanSummary {
616 roles_created: 1,
617 passwords_set: 2,
618 ..Default::default()
619 };
620 assert!(
621 summary.has_structural_changes(),
622 "mixed plan with structural + password changes IS structural drift"
623 );
624 }
625
626 #[test]
627 fn has_structural_changes_false_for_empty() {
628 let summary = PlanSummary::default();
629 assert!(!summary.has_structural_changes());
630 }
631
632 #[test]
633 fn plan_summary_displays_password_count() {
634 let summary = PlanSummary {
635 passwords_set: 2,
636 roles_created: 1,
637 ..Default::default()
638 };
639 let display = summary.to_string();
640 assert!(display.contains("2 password(s) to set"), "got: {display}");
641 assert!(display.contains("3 change(s)"), "got: {display}");
642 }
643
644 #[test]
649 fn additive_mode_filters_revokes_from_plan() {
650 use pgroles_core::diff::{ReconciliationMode, filter_changes};
651 use pgroles_core::model::RoleState;
652
653 let validated = validate_manifest(PROFILE_MANIFEST).unwrap();
654
655 let mut current = validated.desired.clone();
656 current
657 .roles
658 .insert("stale-role".to_string(), RoleState::default());
659
660 let changes = compute_plan(¤t, &validated.desired);
661 assert!(changes.iter().any(|c| matches!(
662 c,
663 pgroles_core::diff::Change::DropRole { name } if name == "stale-role"
664 )));
665
666 let filtered = filter_changes(changes, ReconciliationMode::Additive);
667 assert!(
668 !filtered
669 .iter()
670 .any(|c| matches!(c, pgroles_core::diff::Change::DropRole { .. })),
671 "additive mode should filter out DropRole"
672 );
673 }
674
675 #[test]
676 fn adopt_mode_filters_drops_but_keeps_revokes() {
677 use pgroles_core::diff::{ReconciliationMode, filter_changes};
678 use pgroles_core::manifest::{ObjectType, Privilege};
679 use pgroles_core::model::{GrantKey, GrantState, RoleState};
680 use std::collections::BTreeSet;
681
682 let validated = validate_manifest(MINIMAL_MANIFEST).unwrap();
683
684 let mut current = validated.desired.clone();
685 current
686 .roles
687 .insert("stale-role".to_string(), RoleState::default());
688 current.grants.insert(
689 GrantKey {
690 role: "analytics".to_string(),
691 object_type: ObjectType::Table,
692 schema: Some("public".to_string()),
693 name: Some("*".to_string()),
694 },
695 GrantState {
696 privileges: BTreeSet::from([Privilege::Select]),
697 },
698 );
699
700 let changes = compute_plan(¤t, &validated.desired);
701
702 let filtered = filter_changes(changes, ReconciliationMode::Adopt);
703 assert!(
704 !filtered
705 .iter()
706 .any(|c| matches!(c, pgroles_core::diff::Change::DropRole { .. })),
707 "adopt mode should filter out DropRole"
708 );
709 assert!(
710 filtered
711 .iter()
712 .any(|c| matches!(c, pgroles_core::diff::Change::Revoke { .. })),
713 "adopt mode should keep Revoke changes"
714 );
715 }
716 #[test]
721 fn plan_json_produces_valid_json() {
722 let validated = validate_manifest(MINIMAL_MANIFEST).unwrap();
723 let current = RoleGraph::default();
724 let changes = compute_plan(¤t, &validated.desired);
725
726 let json_output = format_plan_json(&changes).unwrap();
727 let parsed: serde_json::Value = serde_json::from_str(&json_output).unwrap();
729 assert!(parsed.is_array());
730 let text = json_output.to_string();
732 assert!(text.contains("CreateRole"), "got: {text}");
733 assert!(text.contains("analytics"), "got: {text}");
734 }
735}