1use std::path::Path;
8
9use anyhow::{Context, Result};
10
11use pgroles_core::composition::{self, ComposedPolicy, PolicyBundle, PolicyDocument};
12use pgroles_core::diff::{self, Change};
13use pgroles_core::manifest::{self, ExpandedManifest, PolicyManifest, RoleRetirement};
14use pgroles_core::model::RoleGraph;
15use pgroles_core::ownership::ManagedScope;
16use pgroles_core::report::{self, PlanOutputMode};
17use pgroles_core::sql;
18
19pub fn read_manifest_file(path: &Path) -> Result<String> {
25 std::fs::read_to_string(path)
26 .with_context(|| format!("failed to read manifest file: {}", path.display()))
27}
28
29pub fn parse(yaml: &str) -> Result<PolicyManifest> {
35 manifest::parse_manifest(yaml).map_err(|err| anyhow::anyhow!("{err}"))
36}
37
38pub fn parse_and_expand(yaml: &str) -> Result<ExpandedManifest> {
40 let policy_manifest = parse(yaml)?;
41 manifest::expand_manifest(&policy_manifest).map_err(|err| anyhow::anyhow!("{err}"))
42}
43
44pub fn validate_manifest(yaml: &str) -> Result<ValidatedManifest> {
47 let policy_manifest = parse(yaml)?;
48
49 if policy_manifest.roles.is_empty()
50 && policy_manifest.schemas.is_empty()
51 && policy_manifest.grants.is_empty()
52 && policy_manifest.memberships.is_empty()
53 {
54 tracing::warn!(
55 "manifest defines no roles, schemas, grants, or memberships — is the file correct?"
56 );
57 }
58
59 let expanded =
60 manifest::expand_manifest(&policy_manifest).map_err(|err| anyhow::anyhow!("{err}"))?;
61
62 let default_owner = policy_manifest.default_owner.as_deref();
63 let desired = RoleGraph::from_expanded(&expanded, default_owner)
64 .map_err(|err| anyhow::anyhow!("{err}"))?;
65
66 Ok(ValidatedManifest {
67 manifest: policy_manifest,
68 expanded,
69 desired,
70 })
71}
72
73pub struct ValidatedManifest {
75 pub manifest: PolicyManifest,
76 pub expanded: ExpandedManifest,
77 pub desired: RoleGraph,
78}
79
80pub fn validate_bundle_file(path: &Path) -> Result<ValidatedBundle> {
82 let yaml = read_manifest_file(path)?;
83 let bundle = composition::parse_policy_bundle(&yaml).map_err(|err| anyhow::anyhow!("{err}"))?;
84 let documents = load_policy_documents(path, &bundle)?;
85 let composed =
86 composition::compose_bundle(&bundle, &documents).map_err(|err| anyhow::anyhow!("{err}"))?;
87
88 Ok(ValidatedBundle {
89 bundle,
90 documents,
91 composed,
92 })
93}
94
95fn load_policy_documents(path: &Path, bundle: &PolicyBundle) -> Result<Vec<PolicyDocument>> {
96 let base_dir = path
97 .parent()
98 .with_context(|| format!("bundle path has no parent directory: {}", path.display()))?;
99
100 bundle
101 .sources
102 .iter()
103 .map(|source| {
104 let source_path = base_dir.join(&source.file);
105 let yaml = read_manifest_file(&source_path)?;
106 let fragment = composition::parse_policy_fragment(&yaml)
107 .map_err(|err| anyhow::anyhow!("{err}"))
108 .with_context(|| {
109 format!("failed to parse policy document: {}", source_path.display())
110 })?;
111 Ok(PolicyDocument {
112 source: source.file.clone(),
113 fragment,
114 })
115 })
116 .collect()
117}
118
119pub struct ValidatedBundle {
121 pub bundle: PolicyBundle,
122 pub documents: Vec<PolicyDocument>,
123 pub composed: ComposedPolicy,
124}
125
126pub fn compute_plan(current: &RoleGraph, desired: &RoleGraph) -> Vec<Change> {
132 diff::diff(current, desired)
133}
134
135pub fn planned_role_drops(changes: &[Change]) -> Vec<String> {
137 changes
138 .iter()
139 .filter_map(|change| match change {
140 Change::DropRole { name } => Some(name.clone()),
141 _ => None,
142 })
143 .collect()
144}
145
146pub fn apply_role_retirements(changes: Vec<Change>, retirements: &[RoleRetirement]) -> Vec<Change> {
148 diff::apply_role_retirements(changes, retirements)
149}
150
151pub fn resolve_passwords(
153 expanded: &ExpandedManifest,
154) -> Result<std::collections::BTreeMap<String, String>> {
155 diff::resolve_passwords(&expanded.roles).map_err(|err| anyhow::anyhow!("{err}"))
156}
157
158pub fn inject_password_changes(
160 changes: Vec<Change>,
161 resolved_passwords: &std::collections::BTreeMap<String, String>,
162) -> Vec<Change> {
163 diff::inject_password_changes(changes, resolved_passwords)
164}
165
166pub fn format_plan_sql(changes: &[Change]) -> String {
172 sql::render_all(changes)
173}
174
175pub fn format_plan_sql_with_context(changes: &[Change], ctx: &sql::SqlContext) -> String {
177 sql::render_all_with_context(
178 &report::shape_plan_changes(changes, PlanOutputMode::Redacted),
179 ctx,
180 )
181}
182
183pub fn format_plan_json(changes: &[Change]) -> Result<String> {
185 report::render_plan_json(changes, PlanOutputMode::Redacted)
186 .map_err(|err| anyhow::anyhow!("{err}"))
187}
188
189pub fn format_bundle_plan_json(changes: &[Change], composed: &ComposedPolicy) -> Result<String> {
191 report::render_bundle_plan_json(
192 changes,
193 &composed.report_context(),
194 PlanOutputMode::Redacted,
195 )
196 .map_err(|err| anyhow::anyhow!("{err}"))
197}
198
199#[derive(Debug, Default, PartialEq, Eq)]
201pub struct PlanSummary {
202 pub roles_created: usize,
203 pub roles_altered: usize,
204 pub schemas_created: usize,
205 pub schema_owners_altered: usize,
206 pub roles_dropped: usize,
207 pub comments_changed: usize,
208 pub sessions_terminated: usize,
209 pub ownerships_reassigned: usize,
210 pub owned_objects_dropped: usize,
211 pub grants: usize,
212 pub revokes: usize,
213 pub default_privileges_set: usize,
214 pub default_privileges_revoked: usize,
215 pub members_added: usize,
216 pub members_removed: usize,
217 pub passwords_set: usize,
218}
219
220impl PlanSummary {
221 pub fn from_changes(changes: &[Change]) -> Self {
223 let mut summary = Self::default();
224 for change in changes {
225 match change {
226 Change::CreateRole { .. } => summary.roles_created += 1,
227 Change::CreateSchema { .. } => summary.schemas_created += 1,
228 Change::AlterSchemaOwner { .. } => summary.schema_owners_altered += 1,
229 Change::AlterRole { .. } => summary.roles_altered += 1,
230 Change::DropRole { .. } => summary.roles_dropped += 1,
231 Change::SetComment { .. } => summary.comments_changed += 1,
232 Change::TerminateSessions { .. } => summary.sessions_terminated += 1,
233 Change::ReassignOwned { .. } => summary.ownerships_reassigned += 1,
234 Change::DropOwned { .. } => summary.owned_objects_dropped += 1,
235 Change::Grant { .. } | Change::EnsureSchemaOwnerPrivileges { .. } => {
236 summary.grants += 1
237 }
238 Change::Revoke { .. } => summary.revokes += 1,
239 Change::SetDefaultPrivilege { .. } => summary.default_privileges_set += 1,
240 Change::RevokeDefaultPrivilege { .. } => summary.default_privileges_revoked += 1,
241 Change::AddMember { .. } => summary.members_added += 1,
242 Change::RemoveMember { .. } => summary.members_removed += 1,
243 Change::SetPassword { .. } => summary.passwords_set += 1,
244 }
245 }
246 summary
247 }
248
249 pub fn total(&self) -> usize {
251 self.roles_created
252 + self.roles_altered
253 + self.schemas_created
254 + self.schema_owners_altered
255 + self.roles_dropped
256 + self.comments_changed
257 + self.sessions_terminated
258 + self.ownerships_reassigned
259 + self.owned_objects_dropped
260 + self.grants
261 + self.revokes
262 + self.default_privileges_set
263 + self.default_privileges_revoked
264 + self.members_added
265 + self.members_removed
266 + self.passwords_set
267 }
268
269 pub fn is_empty(&self) -> bool {
271 self.total() == 0
272 }
273
274 pub fn has_structural_changes(&self) -> bool {
280 self.total() - self.passwords_set > 0
281 }
282
283 pub fn format_plan(&self) -> String {
284 self.format_with_header("Plan")
285 }
286
287 pub fn format_applied(&self) -> String {
288 self.format_with_header("Applied")
289 }
290
291 fn format_with_header(&self, header: &str) -> String {
292 if self.is_empty() {
293 return "No changes needed. Database is in sync with manifest.".to_string();
294 }
295
296 let mut output = String::new();
297 output.push_str(&format!("{header}: {} change(s)\n", self.total()));
298
299 let items: Vec<(&str, usize)> = vec![
300 ("role(s) to create", self.roles_created),
301 ("role(s) to alter", self.roles_altered),
302 ("schema(s) to create", self.schemas_created),
303 ("schema owner change(s)", self.schema_owners_altered),
304 ("role(s) to drop", self.roles_dropped),
305 ("comment(s) to change", self.comments_changed),
306 ("session termination step(s)", self.sessions_terminated),
307 ("ownership reassignment(s)", self.ownerships_reassigned),
308 ("DROP OWNED cleanup step(s)", self.owned_objects_dropped),
309 ("grant(s) to add", self.grants),
310 ("grant(s) to revoke", self.revokes),
311 ("default privilege(s) to set", self.default_privileges_set),
312 (
313 "default privilege(s) to revoke",
314 self.default_privileges_revoked,
315 ),
316 ("membership(s) to add", self.members_added),
317 ("membership(s) to remove", self.members_removed),
318 ("password(s) to set", self.passwords_set),
319 ];
320
321 for (label, count) in items {
322 if count > 0 {
323 output.push_str(&format!(" {count} {label}\n"));
324 }
325 }
326
327 output
328 }
329}
330
331impl std::fmt::Display for PlanSummary {
332 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
333 write!(f, "{}", self.format_plan())
334 }
335}
336
337pub fn format_validation_result(validated: &ValidatedManifest) -> String {
339 let mut output = String::new();
340 output.push_str("Manifest is valid.\n");
341 output.push_str(&format!(
342 " {} schema(s) defined\n",
343 validated.expanded.schemas.len()
344 ));
345 output.push_str(&format!(
346 " {} role(s) defined\n",
347 validated.expanded.roles.len()
348 ));
349 output.push_str(&format!(
350 " {} grant(s) defined\n",
351 validated.expanded.grants.len()
352 ));
353 output.push_str(&format!(
354 " {} default privilege(s) defined\n",
355 validated.expanded.default_privileges.len()
356 ));
357 output.push_str(&format!(
358 " {} membership(s) defined\n",
359 validated.expanded.memberships.len()
360 ));
361 output
362}
363
364pub fn format_rendered_bundle(
386 validated: &ValidatedBundle,
387 source_label: &str,
388 include_header: bool,
389) -> Result<String> {
390 let raw =
391 serde_yaml::to_value(&validated.composed.manifest).map_err(|err| anyhow::anyhow!(err))?;
392 let cleaned = strip_manifest_defaults(raw);
393 let body = serde_yaml::to_string(&cleaned).map_err(|err| anyhow::anyhow!(err))?;
394
395 if !include_header {
396 return Ok(body);
397 }
398
399 let mut header = String::new();
400 header.push_str("# Rendered by `pgroles render-bundle`.\n");
401 header.push_str("# Do not edit by hand — regenerate from the source bundle.\n");
402 header.push_str(&format!("# Source bundle: {source_label}\n"));
403 header.push_str(&format!("# Manifest schema: {RENDERED_MANIFEST_SCHEMA}\n"));
404 header.push_str("# Fragments:\n");
405 for document in &validated.documents {
406 let label = document.fragment.policy.name.as_deref();
407 match label {
408 Some(name) => header.push_str(&format!("# - {} ({name})\n", document.source)),
409 None => header.push_str(&format!("# - {}\n", document.source)),
410 }
411 }
412 header.push_str("#\n");
413 Ok(format!("{header}{body}"))
414}
415
416pub const RENDERED_MANIFEST_SCHEMA: &str = "pgroles.manifest.v1";
422
423const DEFAULT_ROLE_PATTERN: &str = "{schema}-{profile}";
427
428fn strip_manifest_defaults(value: serde_yaml::Value) -> serde_yaml::Value {
440 strip_manifest_defaults_at(value, &[])
441}
442
443fn strip_manifest_defaults_at(value: serde_yaml::Value, path: &[String]) -> serde_yaml::Value {
444 use serde_yaml::Value;
445 match value {
446 Value::Mapping(map) => {
447 let mut out = serde_yaml::Mapping::new();
448 for (k, v) in map {
449 let mut child_path = path.to_vec();
450 if let Some(key) = k.as_str() {
451 child_path.push(key.to_string());
452 }
453 let cleaned = strip_manifest_defaults_at(v, &child_path);
454 if is_strippable(&child_path, &cleaned) {
455 continue;
456 }
457 out.insert(k, cleaned);
458 }
459 Value::Mapping(out)
460 }
461 Value::Sequence(seq) => Value::Sequence(
462 seq.into_iter()
463 .map(|item| strip_manifest_defaults_at(item, path))
464 .collect(),
465 ),
466 other => other,
467 }
468}
469
470fn is_strippable(path: &[String], value: &serde_yaml::Value) -> bool {
471 use serde_yaml::Value;
472 let key = path.last().map(String::as_str);
473 match value {
474 Value::Null => true,
475 Value::Sequence(s) if s.is_empty() => {
476 !matches!(key, Some("privileges") | Some("grant") | Some("members"))
486 }
487 Value::Mapping(m) if m.is_empty() => {
488 matches!(path, [field] if field == "profiles")
493 }
494 Value::String(s) => {
495 matches!(path, [first, last] if first == "schemas" && last == "role_pattern")
502 && s == DEFAULT_ROLE_PATTERN
503 }
504 _ => false,
505 }
506}
507
508pub fn format_bundle_validation_result(validated: &ValidatedBundle) -> String {
510 let mut output = String::new();
511 output.push_str("Policy bundle is valid.\n");
512 output.push_str(&format!(
513 " {} source document(s) loaded\n",
514 validated.documents.len()
515 ));
516 output.push_str(&format!(
517 " {} shared profile(s) defined\n",
518 validated.bundle.shared.profiles.len()
519 ));
520 output.push_str(&format!(
521 " {} schema(s) defined\n",
522 validated.composed.expanded.schemas.len()
523 ));
524 output.push_str(&format!(
525 " {} role(s) defined\n",
526 validated.composed.expanded.roles.len()
527 ));
528 output.push_str(&format!(
529 " {} grant(s) defined\n",
530 validated.composed.expanded.grants.len()
531 ));
532 output.push_str(&format!(
533 " {} default privilege(s) defined\n",
534 validated.composed.expanded.default_privileges.len()
535 ));
536 output.push_str(&format!(
537 " {} membership(s) defined\n",
538 validated.composed.expanded.memberships.len()
539 ));
540 output
541}
542
543pub fn format_managed_scope_summary(scope: &ManagedScope) -> String {
545 let mut output = String::new();
546 output.push_str("Managed scope:\n");
547 output.push_str(&format!(" {} role(s)\n", scope.roles.len()));
548 output.push_str(&format!(" {} schema(s)\n", scope.schemas.len()));
549
550 let owner_schemas: Vec<&str> = scope
551 .schemas
552 .iter()
553 .filter_map(|(schema, managed)| managed.owner.then_some(schema.as_str()))
554 .collect();
555 let binding_schemas: Vec<&str> = scope
556 .schemas
557 .iter()
558 .filter_map(|(schema, managed)| managed.bindings.then_some(schema.as_str()))
559 .collect();
560
561 output.push_str(&format!(
562 " owner-managed schema(s): {}\n",
563 owner_schemas.len()
564 ));
565 if !owner_schemas.is_empty() {
566 output.push_str(&format!(" owner scope: {}\n", owner_schemas.join(", ")));
567 }
568
569 output.push_str(&format!(
570 " binding-managed schema(s): {}\n",
571 binding_schemas.len()
572 ));
573 if !binding_schemas.is_empty() {
574 output.push_str(&format!(
575 " binding scope: {}\n",
576 binding_schemas.join(", ")
577 ));
578 }
579
580 output
581}
582
583pub fn format_role_graph_summary(graph: &RoleGraph) -> String {
589 let mut output = String::new();
590 output.push_str(&format!("Roles: {}\n", graph.roles.len()));
591 for (name, state) in &graph.roles {
592 let login_marker = if state.login { "LOGIN" } else { "NOLOGIN" };
593 output.push_str(&format!(" {name} ({login_marker})\n"));
594 }
595 output.push_str(&format!("Schemas: {}\n", graph.schemas.len()));
596 for (name, state) in &graph.schemas {
597 match &state.owner {
598 Some(owner) => output.push_str(&format!(" {name} (owner: {owner})\n")),
599 None => output.push_str(&format!(" {name}\n")),
600 }
601 }
602 output.push_str(&format!("Grants: {}\n", graph.grants.len()));
603 output.push_str(&format!(
604 "Default privileges: {}\n",
605 graph.default_privileges.len()
606 ));
607 output.push_str(&format!("Memberships: {}\n", graph.memberships.len()));
608 for edge in &graph.memberships {
609 output.push_str(&format!(" {} -> {}\n", edge.member, edge.role));
610 }
611 output
612}
613
614#[cfg(test)]
619mod tests {
620 use super::*;
621 use pgroles_core::ownership::ManagedSchemaScope;
622
623 const MINIMAL_MANIFEST: &str = r#"
624default_owner: app_owner
625
626schemas:
627 - name: analytics
628 owner: app_owner
629 profiles: []
630
631roles:
632 - name: analytics
633 login: true
634 comment: "Analytics read-only role"
635
636grants:
637 - role: analytics
638 privileges: [CONNECT]
639 object: { type: database, name: mydb }
640"#;
641
642 const PROFILE_MANIFEST: &str = r#"
643default_owner: app_owner
644
645profiles:
646 editor:
647 grants:
648 - privileges: [USAGE]
649 object: { type: schema }
650 - privileges: [SELECT, INSERT, UPDATE, DELETE]
651 object: { type: table, name: "*" }
652 default_privileges:
653 - privileges: [SELECT, INSERT, UPDATE, DELETE]
654 on_type: table
655 viewer:
656 grants:
657 - privileges: [USAGE]
658 object: { type: schema }
659 - privileges: [SELECT]
660 object: { type: table, name: "*" }
661 default_privileges:
662 - privileges: [SELECT]
663 on_type: table
664
665schemas:
666 - name: inventory
667 profiles: [editor, viewer]
668 - name: catalog
669 profiles: [viewer]
670
671roles:
672 - name: app-service
673 login: true
674
675grants:
676 - role: app-service
677 privileges: [CONNECT]
678 object: { type: database, name: mydb }
679
680memberships:
681 - role: inventory-editor
682 members:
683 - name: app-service
684"#;
685
686 const INVALID_YAML: &str = r#"
687this is: [not: valid yaml: [[
688"#;
689
690 const UNDEFINED_PROFILE: &str = r#"
691profiles:
692 editor:
693 grants: []
694
695schemas:
696 - name: myschema
697 profiles: [nonexistent]
698"#;
699
700 #[test]
705 fn parse_valid_manifest() {
706 let result = parse(MINIMAL_MANIFEST);
707 assert!(result.is_ok());
708 let manifest = result.unwrap();
709 assert_eq!(manifest.default_owner, Some("app_owner".to_string()));
710 assert_eq!(manifest.roles.len(), 1);
711 assert_eq!(manifest.roles[0].name, "analytics");
712 }
713
714 #[test]
715 fn parse_invalid_yaml() {
716 let result = parse(INVALID_YAML);
717 assert!(result.is_err());
718 let err_msg = result.unwrap_err().to_string();
719 assert!(err_msg.contains("YAML parse error"), "got: {err_msg}");
720 }
721
722 #[test]
727 fn expand_profile_manifest() {
728 let expanded = parse_and_expand(PROFILE_MANIFEST).unwrap();
729
730 assert_eq!(expanded.schemas.len(), 2);
731 assert_eq!(expanded.roles.len(), 4);
733
734 let role_names: Vec<&str> = expanded.roles.iter().map(|r| r.name.as_str()).collect();
735 assert!(role_names.contains(&"inventory-editor"));
736 assert!(role_names.contains(&"inventory-viewer"));
737 assert!(role_names.contains(&"catalog-viewer"));
738 assert!(role_names.contains(&"app-service"));
739 }
740
741 #[test]
742 fn expand_undefined_profile_fails() {
743 let result = parse_and_expand(UNDEFINED_PROFILE);
744 assert!(result.is_err());
745 let err_msg = result.unwrap_err().to_string();
746 assert!(
747 err_msg.contains("nonexistent"),
748 "expected error about 'nonexistent' profile, got: {err_msg}"
749 );
750 }
751
752 #[test]
757 fn validate_builds_role_graph() {
758 let validated = validate_manifest(PROFILE_MANIFEST).unwrap();
759
760 assert_eq!(validated.desired.roles.len(), 4);
762 assert!(validated.desired.roles.contains_key("inventory-editor"));
763 assert!(validated.desired.roles.contains_key("app-service"));
764
765 assert!(!validated.desired.grants.is_empty());
767
768 assert!(!validated.desired.memberships.is_empty());
770 }
771
772 #[test]
777 fn plan_from_empty_creates_roles() {
778 let validated = validate_manifest(PROFILE_MANIFEST).unwrap();
779 let current = RoleGraph::default(); let changes = compute_plan(¤t, &validated.desired);
782 assert!(!changes.is_empty());
783
784 let summary = PlanSummary::from_changes(&changes);
785 assert_eq!(summary.roles_created, 4); assert_eq!(summary.schemas_created, 2); assert!(summary.grants > 0);
788 assert!(!summary.is_empty());
789 }
790
791 #[test]
792 fn plan_no_changes_when_in_sync() {
793 let validated = validate_manifest(MINIMAL_MANIFEST).unwrap();
794 let current = validated.desired.clone();
796
797 let changes = compute_plan(¤t, &validated.desired);
798 let summary = PlanSummary::from_changes(&changes);
799 assert!(summary.is_empty());
800 assert_eq!(summary.total(), 0);
801 }
802
803 #[test]
804 fn format_plan_sql_produces_sql() {
805 let validated = validate_manifest(MINIMAL_MANIFEST).unwrap();
806 let current = RoleGraph::default();
807 let changes = compute_plan(¤t, &validated.desired);
808
809 let sql_output = format_plan_sql(&changes);
810 assert!(
811 sql_output.contains("CREATE SCHEMA"),
812 "expected CREATE SCHEMA in: {sql_output}"
813 );
814 assert!(
815 sql_output.contains("CREATE ROLE"),
816 "expected CREATE ROLE in: {sql_output}"
817 );
818 assert!(
819 sql_output.contains("\"analytics\""),
820 "expected quoted role name in: {sql_output}"
821 );
822 }
823
824 #[test]
825 fn planned_role_drops_only_returns_drop_changes() {
826 let changes = vec![
827 Change::CreateRole {
828 name: "new-role".to_string(),
829 state: pgroles_core::model::RoleState::default(),
830 },
831 Change::DropRole {
832 name: "old-role".to_string(),
833 },
834 Change::DropRole {
835 name: "stale-role".to_string(),
836 },
837 ];
838
839 assert_eq!(
840 planned_role_drops(&changes),
841 vec!["old-role".to_string(), "stale-role".to_string()]
842 );
843 }
844
845 #[test]
846 fn apply_role_retirements_updates_plan_summary() {
847 let changes = apply_role_retirements(
848 vec![Change::DropRole {
849 name: "legacy-app".to_string(),
850 }],
851 &[pgroles_core::manifest::RoleRetirement {
852 role: "legacy-app".to_string(),
853 reassign_owned_to: Some("app-owner".to_string()),
854 drop_owned: true,
855 terminate_sessions: true,
856 }],
857 );
858
859 let summary = PlanSummary::from_changes(&changes);
860 assert_eq!(summary.roles_dropped, 1);
861 assert_eq!(summary.sessions_terminated, 1);
862 assert_eq!(summary.ownerships_reassigned, 1);
863 assert_eq!(summary.owned_objects_dropped, 1);
864 assert_eq!(summary.total(), 4);
865 }
866
867 #[test]
872 fn plan_summary_display_empty() {
873 let summary = PlanSummary::default();
874 let display = summary.to_string();
875 assert!(display.contains("No changes needed"));
876 }
877
878 #[test]
879 fn plan_summary_display_with_changes() {
880 let summary = PlanSummary {
881 roles_created: 2,
882 schemas_created: 1,
883 grants: 5,
884 members_added: 1,
885 ..Default::default()
886 };
887 let display = summary.to_string();
888 assert!(display.contains("9 change(s)"), "got: {display}");
889 assert!(display.contains("2 role(s) to create"), "got: {display}");
890 assert!(display.contains("1 schema(s) to create"), "got: {display}");
891 assert!(display.contains("5 grant(s) to add"), "got: {display}");
892 assert!(display.contains("1 membership(s) to add"), "got: {display}");
893 assert!(!display.contains("to drop"), "got: {display}");
895 assert!(!display.contains("to revoke"), "got: {display}");
896 }
897
898 #[test]
903 fn validation_result_shows_counts() {
904 let validated = validate_manifest(PROFILE_MANIFEST).unwrap();
905 let output = format_validation_result(&validated);
906 assert!(output.contains("Manifest is valid"), "got: {output}");
907 assert!(output.contains("2 schema(s)"), "got: {output}");
908 assert!(output.contains("4 role(s)"), "got: {output}");
909 }
910
911 #[test]
912 fn managed_scope_summary_lists_owner_and_binding_facets() {
913 let scope = ManagedScope {
914 roles: ["app".to_string(), "app_owner".to_string()]
915 .into_iter()
916 .collect(),
917 schemas: [
918 (
919 "inventory".to_string(),
920 ManagedSchemaScope {
921 owner: true,
922 bindings: true,
923 },
924 ),
925 (
926 "audit".to_string(),
927 ManagedSchemaScope {
928 owner: true,
929 bindings: false,
930 },
931 ),
932 ]
933 .into_iter()
934 .collect(),
935 };
936
937 let output = format_managed_scope_summary(&scope);
938
939 assert!(output.contains("Managed scope:"), "got: {output}");
940 assert!(output.contains("2 role(s)"), "got: {output}");
941 assert!(output.contains("2 schema(s)"), "got: {output}");
942 assert!(
943 output.contains("owner scope: audit, inventory"),
944 "got: {output}"
945 );
946 assert!(output.contains("binding scope: inventory"), "got: {output}");
947 }
948
949 #[test]
954 fn read_nonexistent_file_fails() {
955 let result = read_manifest_file(Path::new("/tmp/nonexistent-pgroles-test.yaml"));
956 assert!(result.is_err());
957 let err_msg = format!("{:#}", result.unwrap_err());
958 assert!(
959 err_msg.contains("failed to read manifest file"),
960 "got: {err_msg}"
961 );
962 }
963
964 #[test]
969 fn role_graph_summary_format() {
970 let validated = validate_manifest(MINIMAL_MANIFEST).unwrap();
971 let summary = format_role_graph_summary(&validated.desired);
972 assert!(summary.contains("Roles: 1"), "got: {summary}");
973 assert!(summary.contains("Schemas: 1"), "got: {summary}");
974 assert!(summary.contains("analytics (LOGIN)"), "got: {summary}");
975 }
976
977 #[test]
982 fn has_structural_changes_true_for_non_password_changes() {
983 let summary = PlanSummary {
984 roles_created: 1,
985 schemas_created: 1,
986 grants: 2,
987 ..Default::default()
988 };
989 assert!(summary.has_structural_changes());
990 }
991
992 #[test]
993 fn has_structural_changes_false_for_password_only() {
994 let summary = PlanSummary {
995 passwords_set: 3,
996 ..Default::default()
997 };
998 assert!(
999 !summary.has_structural_changes(),
1000 "password-only plan should NOT be considered structural drift"
1001 );
1002 }
1003
1004 #[test]
1005 fn has_structural_changes_true_for_mixed() {
1006 let summary = PlanSummary {
1007 roles_created: 1,
1008 passwords_set: 2,
1009 ..Default::default()
1010 };
1011 assert!(
1012 summary.has_structural_changes(),
1013 "mixed plan with structural + password changes IS structural drift"
1014 );
1015 }
1016
1017 #[test]
1018 fn has_structural_changes_false_for_empty() {
1019 let summary = PlanSummary::default();
1020 assert!(!summary.has_structural_changes());
1021 }
1022
1023 #[test]
1024 fn plan_summary_displays_password_count() {
1025 let summary = PlanSummary {
1026 passwords_set: 2,
1027 roles_created: 1,
1028 ..Default::default()
1029 };
1030 let display = summary.to_string();
1031 assert!(display.contains("2 password(s) to set"), "got: {display}");
1032 assert!(display.contains("3 change(s)"), "got: {display}");
1033 }
1034
1035 #[test]
1040 fn additive_mode_filters_revokes_from_plan() {
1041 use pgroles_core::diff::{ReconciliationMode, filter_changes};
1042 use pgroles_core::model::RoleState;
1043
1044 let validated = validate_manifest(PROFILE_MANIFEST).unwrap();
1045
1046 let mut current = validated.desired.clone();
1047 current
1048 .roles
1049 .insert("stale-role".to_string(), RoleState::default());
1050
1051 let changes = compute_plan(¤t, &validated.desired);
1052 assert!(changes.iter().any(|c| matches!(
1053 c,
1054 pgroles_core::diff::Change::DropRole { name } if name == "stale-role"
1055 )));
1056
1057 let filtered = filter_changes(changes, ReconciliationMode::Additive);
1058 assert!(
1059 !filtered
1060 .iter()
1061 .any(|c| matches!(c, pgroles_core::diff::Change::DropRole { .. })),
1062 "additive mode should filter out DropRole"
1063 );
1064 }
1065
1066 #[test]
1067 fn adopt_mode_filters_drops_but_keeps_revokes() {
1068 use pgroles_core::diff::{ReconciliationMode, filter_changes};
1069 use pgroles_core::manifest::{ObjectType, Privilege};
1070 use pgroles_core::model::{GrantKey, GrantState, RoleState};
1071 use std::collections::BTreeSet;
1072
1073 let validated = validate_manifest(MINIMAL_MANIFEST).unwrap();
1074
1075 let mut current = validated.desired.clone();
1076 current
1077 .roles
1078 .insert("stale-role".to_string(), RoleState::default());
1079 current.grants.insert(
1080 GrantKey {
1081 role: "analytics".to_string(),
1082 object_type: ObjectType::Table,
1083 schema: Some("public".to_string()),
1084 name: Some("*".to_string()),
1085 },
1086 GrantState {
1087 privileges: BTreeSet::from([Privilege::Select]),
1088 },
1089 );
1090
1091 let changes = compute_plan(¤t, &validated.desired);
1092
1093 let filtered = filter_changes(changes, ReconciliationMode::Adopt);
1094 assert!(
1095 !filtered
1096 .iter()
1097 .any(|c| matches!(c, pgroles_core::diff::Change::DropRole { .. })),
1098 "adopt mode should filter out DropRole"
1099 );
1100 assert!(
1101 filtered
1102 .iter()
1103 .any(|c| matches!(c, pgroles_core::diff::Change::Revoke { .. })),
1104 "adopt mode should keep Revoke changes"
1105 );
1106 }
1107 #[test]
1112 fn plan_json_produces_valid_json() {
1113 let validated = validate_manifest(MINIMAL_MANIFEST).unwrap();
1114 let current = RoleGraph::default();
1115 let changes = compute_plan(¤t, &validated.desired);
1116
1117 let json_output = format_plan_json(&changes).unwrap();
1118 let parsed: serde_json::Value = serde_json::from_str(&json_output).unwrap();
1120 assert!(parsed.is_array());
1121 let text = json_output.to_string();
1123 assert!(text.contains("CreateRole"), "got: {text}");
1124 assert!(text.contains("analytics"), "got: {text}");
1125 }
1126
1127 #[test]
1128 fn format_plan_json_redacts_passwords() {
1129 let changes = vec![Change::SetPassword {
1130 name: "app-svc".to_string(),
1131 password: "super-secret".to_string(),
1132 }];
1133
1134 let json = format_plan_json(&changes).expect("json formatting should succeed");
1135 assert!(json.contains("[REDACTED]"), "got: {json}");
1136 assert!(!json.contains("super-secret"), "got: {json}");
1137 }
1138
1139 #[test]
1140 fn bundle_plan_json_includes_scope_and_ownership_annotations() {
1141 let bundle = composition::parse_policy_bundle(
1142 r#"
1143sources:
1144 - file: app.yaml
1145"#,
1146 )
1147 .unwrap();
1148 let documents = vec![composition::PolicyDocument {
1149 source: "app.yaml".to_string(),
1150 fragment: composition::parse_policy_fragment(
1151 r#"
1152policy:
1153 name: app
1154scope:
1155 roles: [app]
1156roles:
1157 - name: app
1158 login: false
1159"#,
1160 )
1161 .unwrap(),
1162 }];
1163 let composed = composition::compose_bundle(&bundle, &documents).unwrap();
1164 let changes = compute_plan(&RoleGraph::default(), &composed.desired);
1165
1166 let json_output = format_bundle_plan_json(&changes, &composed).unwrap();
1167 let parsed: serde_json::Value = serde_json::from_str(&json_output).unwrap();
1168
1169 assert!(parsed.is_object());
1170 assert_eq!(parsed["schema_version"], "pgroles.bundle_plan.v1");
1171 assert_eq!(parsed["managed_scope"]["roles"][0], "app");
1172 assert_eq!(parsed["changes"][0]["owner"]["document"], "app");
1173 assert_eq!(parsed["changes"][0]["owner"]["managed_key"]["kind"], "role");
1174 assert_eq!(parsed["changes"][0]["owner"]["managed_key"]["name"], "app");
1175 }
1176
1177 #[test]
1178 fn format_plan_sql_redacts_passwords() {
1179 let changes = vec![Change::SetPassword {
1180 name: "app-svc".to_string(),
1181 password: "super-secret".to_string(),
1182 }];
1183
1184 let sql = format_plan_sql_with_context(&changes, &sql::SqlContext::default());
1185 assert!(sql.contains("[REDACTED]"), "got: {sql}");
1186 assert!(!sql.contains("super-secret"), "got: {sql}");
1187 }
1188
1189 #[test]
1190 fn format_applied_uses_applied_header() {
1191 let summary = PlanSummary {
1192 roles_created: 1,
1193 schemas_created: 1,
1194 grants: 2,
1195 ..Default::default()
1196 };
1197
1198 let display = summary.format_applied();
1199 assert!(
1200 display.starts_with("Applied: 4 change(s)\n"),
1201 "got: {display}"
1202 );
1203 assert!(display.contains("1 role(s) to create"), "got: {display}");
1204 assert!(display.contains("1 schema(s) to create"), "got: {display}");
1205 assert!(display.contains("2 grant(s) to add"), "got: {display}");
1206 assert!(!display.contains("Plan:"), "got: {display}");
1207 }
1208
1209 fn validated_bundle_for_render() -> ValidatedBundle {
1214 let bundle = composition::parse_policy_bundle(
1215 r#"
1216sources:
1217 - file: platform.yaml
1218 - file: app.yaml
1219"#,
1220 )
1221 .unwrap();
1222 let documents = vec![
1223 composition::PolicyDocument {
1224 source: "platform.yaml".to_string(),
1225 fragment: composition::parse_policy_fragment(
1226 r#"
1227policy:
1228 name: platform
1229scope:
1230 roles: [app_owner]
1231 schemas:
1232 - name: inventory
1233 facets: [owner]
1234roles:
1235 - name: app_owner
1236 login: false
1237schemas:
1238 - name: inventory
1239 owner: app_owner
1240"#,
1241 )
1242 .unwrap(),
1243 },
1244 composition::PolicyDocument {
1245 source: "app.yaml".to_string(),
1246 fragment: composition::parse_policy_fragment(
1247 r#"
1248policy:
1249 name: app
1250scope:
1251 roles: [app_service]
1252roles:
1253 - name: app_service
1254 login: true
1255"#,
1256 )
1257 .unwrap(),
1258 },
1259 ];
1260 let composed = composition::compose_bundle(&bundle, &documents).unwrap();
1261 ValidatedBundle {
1262 bundle,
1263 documents,
1264 composed,
1265 }
1266 }
1267
1268 #[test]
1269 fn rendered_bundle_round_trips_to_equivalent_expansion() {
1270 let validated = validated_bundle_for_render();
1271 let rendered = format_rendered_bundle(&validated, "bundle.yaml", true).unwrap();
1272
1273 let reparsed = validate_manifest(&rendered).expect("rendered output must validate");
1274
1275 use std::collections::BTreeSet;
1279 let original_roles: BTreeSet<_> = validated
1280 .composed
1281 .expanded
1282 .roles
1283 .iter()
1284 .map(|r| r.name.clone())
1285 .collect();
1286 let rendered_roles: BTreeSet<_> = reparsed
1287 .expanded
1288 .roles
1289 .iter()
1290 .map(|r| r.name.clone())
1291 .collect();
1292 assert_eq!(rendered_roles, original_roles);
1293
1294 let original_schemas: BTreeSet<_> = validated
1295 .composed
1296 .expanded
1297 .schemas
1298 .iter()
1299 .map(|s| s.name.clone())
1300 .collect();
1301 let rendered_schemas: BTreeSet<_> = reparsed
1302 .expanded
1303 .schemas
1304 .iter()
1305 .map(|s| s.name.clone())
1306 .collect();
1307 assert_eq!(rendered_schemas, original_schemas);
1308
1309 assert_eq!(
1310 reparsed.expanded.grants.len(),
1311 validated.composed.expanded.grants.len()
1312 );
1313 assert_eq!(
1314 reparsed.expanded.default_privileges.len(),
1315 validated.composed.expanded.default_privileges.len()
1316 );
1317 assert_eq!(
1318 reparsed.expanded.memberships.len(),
1319 validated.composed.expanded.memberships.len()
1320 );
1321
1322 use std::collections::BTreeMap;
1325 let config_by_role = |expanded: &pgroles_core::manifest::ExpandedManifest| {
1326 expanded
1327 .roles
1328 .iter()
1329 .map(|r| (r.name.clone(), r.config.clone()))
1330 .collect::<BTreeMap<_, _>>()
1331 };
1332 assert_eq!(
1333 config_by_role(&reparsed.expanded),
1334 config_by_role(&validated.composed.expanded)
1335 );
1336 }
1337
1338 #[test]
1339 fn render_preserves_config_parameter_named_role_pattern() {
1340 let yaml = r#"
1346schemas:
1347 - name: inventory
1348 profiles: []
1349 role_pattern: "{schema}-{profile}"
1350
1351roles:
1352 - name: app
1353 config:
1354 role_pattern: "{schema}-{profile}"
1355"#;
1356 let value: serde_yaml::Value = serde_yaml::from_str(yaml).unwrap();
1357 let stripped = strip_manifest_defaults(value);
1358 let out = serde_yaml::to_string(&stripped).unwrap();
1359
1360 assert_eq!(
1362 out.matches("role_pattern").count(),
1363 1,
1364 "expected exactly the config entry to survive, got:\n{out}"
1365 );
1366 let reparsed: serde_yaml::Value = serde_yaml::from_str(&out).unwrap();
1368 let config_value = reparsed["roles"][0]["config"]["role_pattern"]
1369 .as_str()
1370 .expect("config.role_pattern must survive rendering");
1371 assert_eq!(config_value, "{schema}-{profile}");
1372 }
1373
1374 #[test]
1375 fn rendered_bundle_header_records_source_and_fragments() {
1376 let validated = validated_bundle_for_render();
1377 let rendered = format_rendered_bundle(&validated, "prod.yaml", true).unwrap();
1378
1379 assert!(rendered.starts_with("# Rendered by `pgroles render-bundle`."));
1380 assert!(rendered.contains("# Source bundle: prod.yaml"));
1381 assert!(rendered.contains("# - platform.yaml (platform)"));
1382 assert!(rendered.contains("# - app.yaml (app)"));
1383 }
1384
1385 #[test]
1386 fn rendered_bundle_header_records_manifest_schema_version() {
1387 let validated = validated_bundle_for_render();
1393 let rendered = format_rendered_bundle(&validated, "bundle.yaml", true).unwrap();
1394 assert!(
1395 rendered.contains(&format!("# Manifest schema: {RENDERED_MANIFEST_SCHEMA}")),
1396 "header must record manifest schema, got: {rendered}"
1397 );
1398 }
1399
1400 #[test]
1401 fn rendered_bundle_strips_empty_collections_and_nulls() {
1402 let validated = validated_bundle_for_render();
1403 let rendered = format_rendered_bundle(&validated, "bundle.yaml", false).unwrap();
1404
1405 assert!(
1407 !rendered.contains("auth_providers: []"),
1408 "rendered output must not include empty auth_providers, got: {rendered}"
1409 );
1410 assert!(
1411 !rendered.contains("grants: []"),
1412 "rendered output must not include empty grants, got: {rendered}"
1413 );
1414 assert!(
1415 !rendered.contains("default_privileges: []"),
1416 "rendered output must not include empty default_privileges, got: {rendered}"
1417 );
1418 assert!(
1419 !rendered.contains("memberships: []"),
1420 "rendered output must not include empty memberships, got: {rendered}"
1421 );
1422 assert!(
1423 !rendered.contains("profiles: {}"),
1424 "rendered output must not include empty top-level profiles, got: {rendered}"
1425 );
1426 assert!(
1427 !rendered.contains("retirements: []"),
1428 "rendered output must not include empty retirements, got: {rendered}"
1429 );
1430 assert!(
1432 !rendered.contains("login: null"),
1433 "rendered output must not include null login, got: {rendered}"
1434 );
1435 assert!(
1436 !rendered.contains("inherit: null"),
1437 "rendered output must not include null inherit, got: {rendered}"
1438 );
1439 assert!(
1442 !rendered.contains("role_pattern:"),
1443 "rendered output must elide default role_pattern, got: {rendered}"
1444 );
1445 }
1446
1447 #[test]
1448 fn rendered_bundle_no_header_emits_only_yaml() {
1449 let validated = validated_bundle_for_render();
1450 let rendered = format_rendered_bundle(&validated, "bundle.yaml", false).unwrap();
1451
1452 assert!(
1453 !rendered.starts_with('#'),
1454 "without header, output should start with YAML, got: {rendered}"
1455 );
1456 validate_manifest(&rendered).expect("rendered output must validate");
1458 }
1459
1460 #[test]
1461 fn rendered_bundle_is_deterministic() {
1462 let validated = validated_bundle_for_render();
1463 let first = format_rendered_bundle(&validated, "bundle.yaml", true).unwrap();
1464 let second = format_rendered_bundle(&validated, "bundle.yaml", true).unwrap();
1465 assert_eq!(first, second);
1466 }
1467
1468 #[test]
1469 fn rendered_bundle_preserves_required_empty_sequences() {
1470 let bundle = composition::parse_policy_bundle(
1474 r#"
1475sources:
1476 - file: app.yaml
1477"#,
1478 )
1479 .unwrap();
1480 let documents = vec![composition::PolicyDocument {
1481 source: "app.yaml".to_string(),
1482 fragment: composition::parse_policy_fragment(
1483 r#"
1484policy:
1485 name: app
1486scope:
1487 roles: [empty_group]
1488roles:
1489 - name: empty_group
1490 login: false
1491memberships:
1492 - role: empty_group
1493 members: []
1494"#,
1495 )
1496 .unwrap(),
1497 }];
1498 let composed = composition::compose_bundle(&bundle, &documents).unwrap();
1499 let validated = ValidatedBundle {
1500 bundle,
1501 documents,
1502 composed,
1503 };
1504
1505 let rendered = format_rendered_bundle(&validated, "bundle.yaml", false).unwrap();
1506
1507 assert!(
1509 rendered.contains("members:"),
1510 "required `members` field must not be stripped, got: {rendered}"
1511 );
1512
1513 validate_manifest(&rendered)
1515 .expect("rendered output with empty required sequence must still parse");
1516 }
1517
1518 #[test]
1519 fn rendered_bundle_preserves_referenced_empty_profiles() {
1520 let bundle = composition::parse_policy_bundle(
1525 r#"
1526shared:
1527 profiles:
1528 noop: {}
1529sources:
1530 - file: app.yaml
1531"#,
1532 )
1533 .unwrap();
1534 let documents = vec![composition::PolicyDocument {
1535 source: "app.yaml".to_string(),
1536 fragment: composition::parse_policy_fragment(
1537 r#"
1538policy:
1539 name: app
1540scope:
1541 schemas:
1542 - name: inventory
1543 facets: [bindings]
1544schemas:
1545 - name: inventory
1546 profiles: [noop]
1547"#,
1548 )
1549 .unwrap(),
1550 }];
1551 let composed = composition::compose_bundle(&bundle, &documents).unwrap();
1552 let validated = ValidatedBundle {
1553 bundle,
1554 documents,
1555 composed,
1556 };
1557
1558 let rendered = format_rendered_bundle(&validated, "bundle.yaml", false).unwrap();
1559
1560 assert!(
1561 rendered.contains("noop: {}"),
1562 "empty referenced profile must be preserved, got: {rendered}"
1563 );
1564 let reparsed = validate_manifest(&rendered)
1565 .expect("rendered output with an empty referenced profile must still parse");
1566 assert!(
1567 reparsed
1568 .expanded
1569 .roles
1570 .iter()
1571 .any(|role| role.name == "inventory-noop"),
1572 "empty profile must still expand to its schema/profile role"
1573 );
1574 }
1575}