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