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) => match key {
495 Some("role_pattern") => s == DEFAULT_ROLE_PATTERN,
496 _ => false,
497 },
498 _ => false,
499 }
500}
501
502pub fn format_bundle_validation_result(validated: &ValidatedBundle) -> String {
504 let mut output = String::new();
505 output.push_str("Policy bundle is valid.\n");
506 output.push_str(&format!(
507 " {} source document(s) loaded\n",
508 validated.documents.len()
509 ));
510 output.push_str(&format!(
511 " {} shared profile(s) defined\n",
512 validated.bundle.shared.profiles.len()
513 ));
514 output.push_str(&format!(
515 " {} schema(s) defined\n",
516 validated.composed.expanded.schemas.len()
517 ));
518 output.push_str(&format!(
519 " {} role(s) defined\n",
520 validated.composed.expanded.roles.len()
521 ));
522 output.push_str(&format!(
523 " {} grant(s) defined\n",
524 validated.composed.expanded.grants.len()
525 ));
526 output.push_str(&format!(
527 " {} default privilege(s) defined\n",
528 validated.composed.expanded.default_privileges.len()
529 ));
530 output.push_str(&format!(
531 " {} membership(s) defined\n",
532 validated.composed.expanded.memberships.len()
533 ));
534 output
535}
536
537pub fn format_managed_scope_summary(scope: &ManagedScope) -> String {
539 let mut output = String::new();
540 output.push_str("Managed scope:\n");
541 output.push_str(&format!(" {} role(s)\n", scope.roles.len()));
542 output.push_str(&format!(" {} schema(s)\n", scope.schemas.len()));
543
544 let owner_schemas: Vec<&str> = scope
545 .schemas
546 .iter()
547 .filter_map(|(schema, managed)| managed.owner.then_some(schema.as_str()))
548 .collect();
549 let binding_schemas: Vec<&str> = scope
550 .schemas
551 .iter()
552 .filter_map(|(schema, managed)| managed.bindings.then_some(schema.as_str()))
553 .collect();
554
555 output.push_str(&format!(
556 " owner-managed schema(s): {}\n",
557 owner_schemas.len()
558 ));
559 if !owner_schemas.is_empty() {
560 output.push_str(&format!(" owner scope: {}\n", owner_schemas.join(", ")));
561 }
562
563 output.push_str(&format!(
564 " binding-managed schema(s): {}\n",
565 binding_schemas.len()
566 ));
567 if !binding_schemas.is_empty() {
568 output.push_str(&format!(
569 " binding scope: {}\n",
570 binding_schemas.join(", ")
571 ));
572 }
573
574 output
575}
576
577pub fn format_role_graph_summary(graph: &RoleGraph) -> String {
583 let mut output = String::new();
584 output.push_str(&format!("Roles: {}\n", graph.roles.len()));
585 for (name, state) in &graph.roles {
586 let login_marker = if state.login { "LOGIN" } else { "NOLOGIN" };
587 output.push_str(&format!(" {name} ({login_marker})\n"));
588 }
589 output.push_str(&format!("Schemas: {}\n", graph.schemas.len()));
590 for (name, state) in &graph.schemas {
591 match &state.owner {
592 Some(owner) => output.push_str(&format!(" {name} (owner: {owner})\n")),
593 None => output.push_str(&format!(" {name}\n")),
594 }
595 }
596 output.push_str(&format!("Grants: {}\n", graph.grants.len()));
597 output.push_str(&format!(
598 "Default privileges: {}\n",
599 graph.default_privileges.len()
600 ));
601 output.push_str(&format!("Memberships: {}\n", graph.memberships.len()));
602 for edge in &graph.memberships {
603 output.push_str(&format!(" {} -> {}\n", edge.member, edge.role));
604 }
605 output
606}
607
608#[cfg(test)]
613mod tests {
614 use super::*;
615 use pgroles_core::ownership::ManagedSchemaScope;
616
617 const MINIMAL_MANIFEST: &str = r#"
618default_owner: app_owner
619
620schemas:
621 - name: analytics
622 owner: app_owner
623 profiles: []
624
625roles:
626 - name: analytics
627 login: true
628 comment: "Analytics read-only role"
629
630grants:
631 - role: analytics
632 privileges: [CONNECT]
633 object: { type: database, name: mydb }
634"#;
635
636 const PROFILE_MANIFEST: &str = r#"
637default_owner: app_owner
638
639profiles:
640 editor:
641 grants:
642 - privileges: [USAGE]
643 object: { type: schema }
644 - privileges: [SELECT, INSERT, UPDATE, DELETE]
645 object: { type: table, name: "*" }
646 default_privileges:
647 - privileges: [SELECT, INSERT, UPDATE, DELETE]
648 on_type: table
649 viewer:
650 grants:
651 - privileges: [USAGE]
652 object: { type: schema }
653 - privileges: [SELECT]
654 object: { type: table, name: "*" }
655 default_privileges:
656 - privileges: [SELECT]
657 on_type: table
658
659schemas:
660 - name: inventory
661 profiles: [editor, viewer]
662 - name: catalog
663 profiles: [viewer]
664
665roles:
666 - name: app-service
667 login: true
668
669grants:
670 - role: app-service
671 privileges: [CONNECT]
672 object: { type: database, name: mydb }
673
674memberships:
675 - role: inventory-editor
676 members:
677 - name: app-service
678"#;
679
680 const INVALID_YAML: &str = r#"
681this is: [not: valid yaml: [[
682"#;
683
684 const UNDEFINED_PROFILE: &str = r#"
685profiles:
686 editor:
687 grants: []
688
689schemas:
690 - name: myschema
691 profiles: [nonexistent]
692"#;
693
694 #[test]
699 fn parse_valid_manifest() {
700 let result = parse(MINIMAL_MANIFEST);
701 assert!(result.is_ok());
702 let manifest = result.unwrap();
703 assert_eq!(manifest.default_owner, Some("app_owner".to_string()));
704 assert_eq!(manifest.roles.len(), 1);
705 assert_eq!(manifest.roles[0].name, "analytics");
706 }
707
708 #[test]
709 fn parse_invalid_yaml() {
710 let result = parse(INVALID_YAML);
711 assert!(result.is_err());
712 let err_msg = result.unwrap_err().to_string();
713 assert!(err_msg.contains("YAML parse error"), "got: {err_msg}");
714 }
715
716 #[test]
721 fn expand_profile_manifest() {
722 let expanded = parse_and_expand(PROFILE_MANIFEST).unwrap();
723
724 assert_eq!(expanded.schemas.len(), 2);
725 assert_eq!(expanded.roles.len(), 4);
727
728 let role_names: Vec<&str> = expanded.roles.iter().map(|r| r.name.as_str()).collect();
729 assert!(role_names.contains(&"inventory-editor"));
730 assert!(role_names.contains(&"inventory-viewer"));
731 assert!(role_names.contains(&"catalog-viewer"));
732 assert!(role_names.contains(&"app-service"));
733 }
734
735 #[test]
736 fn expand_undefined_profile_fails() {
737 let result = parse_and_expand(UNDEFINED_PROFILE);
738 assert!(result.is_err());
739 let err_msg = result.unwrap_err().to_string();
740 assert!(
741 err_msg.contains("nonexistent"),
742 "expected error about 'nonexistent' profile, got: {err_msg}"
743 );
744 }
745
746 #[test]
751 fn validate_builds_role_graph() {
752 let validated = validate_manifest(PROFILE_MANIFEST).unwrap();
753
754 assert_eq!(validated.desired.roles.len(), 4);
756 assert!(validated.desired.roles.contains_key("inventory-editor"));
757 assert!(validated.desired.roles.contains_key("app-service"));
758
759 assert!(!validated.desired.grants.is_empty());
761
762 assert!(!validated.desired.memberships.is_empty());
764 }
765
766 #[test]
771 fn plan_from_empty_creates_roles() {
772 let validated = validate_manifest(PROFILE_MANIFEST).unwrap();
773 let current = RoleGraph::default(); let changes = compute_plan(¤t, &validated.desired);
776 assert!(!changes.is_empty());
777
778 let summary = PlanSummary::from_changes(&changes);
779 assert_eq!(summary.roles_created, 4); assert_eq!(summary.schemas_created, 2); assert!(summary.grants > 0);
782 assert!(!summary.is_empty());
783 }
784
785 #[test]
786 fn plan_no_changes_when_in_sync() {
787 let validated = validate_manifest(MINIMAL_MANIFEST).unwrap();
788 let current = validated.desired.clone();
790
791 let changes = compute_plan(¤t, &validated.desired);
792 let summary = PlanSummary::from_changes(&changes);
793 assert!(summary.is_empty());
794 assert_eq!(summary.total(), 0);
795 }
796
797 #[test]
798 fn format_plan_sql_produces_sql() {
799 let validated = validate_manifest(MINIMAL_MANIFEST).unwrap();
800 let current = RoleGraph::default();
801 let changes = compute_plan(¤t, &validated.desired);
802
803 let sql_output = format_plan_sql(&changes);
804 assert!(
805 sql_output.contains("CREATE SCHEMA"),
806 "expected CREATE SCHEMA in: {sql_output}"
807 );
808 assert!(
809 sql_output.contains("CREATE ROLE"),
810 "expected CREATE ROLE in: {sql_output}"
811 );
812 assert!(
813 sql_output.contains("\"analytics\""),
814 "expected quoted role name in: {sql_output}"
815 );
816 }
817
818 #[test]
819 fn planned_role_drops_only_returns_drop_changes() {
820 let changes = vec![
821 Change::CreateRole {
822 name: "new-role".to_string(),
823 state: pgroles_core::model::RoleState::default(),
824 },
825 Change::DropRole {
826 name: "old-role".to_string(),
827 },
828 Change::DropRole {
829 name: "stale-role".to_string(),
830 },
831 ];
832
833 assert_eq!(
834 planned_role_drops(&changes),
835 vec!["old-role".to_string(), "stale-role".to_string()]
836 );
837 }
838
839 #[test]
840 fn apply_role_retirements_updates_plan_summary() {
841 let changes = apply_role_retirements(
842 vec![Change::DropRole {
843 name: "legacy-app".to_string(),
844 }],
845 &[pgroles_core::manifest::RoleRetirement {
846 role: "legacy-app".to_string(),
847 reassign_owned_to: Some("app-owner".to_string()),
848 drop_owned: true,
849 terminate_sessions: true,
850 }],
851 );
852
853 let summary = PlanSummary::from_changes(&changes);
854 assert_eq!(summary.roles_dropped, 1);
855 assert_eq!(summary.sessions_terminated, 1);
856 assert_eq!(summary.ownerships_reassigned, 1);
857 assert_eq!(summary.owned_objects_dropped, 1);
858 assert_eq!(summary.total(), 4);
859 }
860
861 #[test]
866 fn plan_summary_display_empty() {
867 let summary = PlanSummary::default();
868 let display = summary.to_string();
869 assert!(display.contains("No changes needed"));
870 }
871
872 #[test]
873 fn plan_summary_display_with_changes() {
874 let summary = PlanSummary {
875 roles_created: 2,
876 schemas_created: 1,
877 grants: 5,
878 members_added: 1,
879 ..Default::default()
880 };
881 let display = summary.to_string();
882 assert!(display.contains("9 change(s)"), "got: {display}");
883 assert!(display.contains("2 role(s) to create"), "got: {display}");
884 assert!(display.contains("1 schema(s) to create"), "got: {display}");
885 assert!(display.contains("5 grant(s) to add"), "got: {display}");
886 assert!(display.contains("1 membership(s) to add"), "got: {display}");
887 assert!(!display.contains("to drop"), "got: {display}");
889 assert!(!display.contains("to revoke"), "got: {display}");
890 }
891
892 #[test]
897 fn validation_result_shows_counts() {
898 let validated = validate_manifest(PROFILE_MANIFEST).unwrap();
899 let output = format_validation_result(&validated);
900 assert!(output.contains("Manifest is valid"), "got: {output}");
901 assert!(output.contains("2 schema(s)"), "got: {output}");
902 assert!(output.contains("4 role(s)"), "got: {output}");
903 }
904
905 #[test]
906 fn managed_scope_summary_lists_owner_and_binding_facets() {
907 let scope = ManagedScope {
908 roles: ["app".to_string(), "app_owner".to_string()]
909 .into_iter()
910 .collect(),
911 schemas: [
912 (
913 "inventory".to_string(),
914 ManagedSchemaScope {
915 owner: true,
916 bindings: true,
917 },
918 ),
919 (
920 "audit".to_string(),
921 ManagedSchemaScope {
922 owner: true,
923 bindings: false,
924 },
925 ),
926 ]
927 .into_iter()
928 .collect(),
929 };
930
931 let output = format_managed_scope_summary(&scope);
932
933 assert!(output.contains("Managed scope:"), "got: {output}");
934 assert!(output.contains("2 role(s)"), "got: {output}");
935 assert!(output.contains("2 schema(s)"), "got: {output}");
936 assert!(
937 output.contains("owner scope: audit, inventory"),
938 "got: {output}"
939 );
940 assert!(output.contains("binding scope: inventory"), "got: {output}");
941 }
942
943 #[test]
948 fn read_nonexistent_file_fails() {
949 let result = read_manifest_file(Path::new("/tmp/nonexistent-pgroles-test.yaml"));
950 assert!(result.is_err());
951 let err_msg = format!("{:#}", result.unwrap_err());
952 assert!(
953 err_msg.contains("failed to read manifest file"),
954 "got: {err_msg}"
955 );
956 }
957
958 #[test]
963 fn role_graph_summary_format() {
964 let validated = validate_manifest(MINIMAL_MANIFEST).unwrap();
965 let summary = format_role_graph_summary(&validated.desired);
966 assert!(summary.contains("Roles: 1"), "got: {summary}");
967 assert!(summary.contains("Schemas: 1"), "got: {summary}");
968 assert!(summary.contains("analytics (LOGIN)"), "got: {summary}");
969 }
970
971 #[test]
976 fn has_structural_changes_true_for_non_password_changes() {
977 let summary = PlanSummary {
978 roles_created: 1,
979 schemas_created: 1,
980 grants: 2,
981 ..Default::default()
982 };
983 assert!(summary.has_structural_changes());
984 }
985
986 #[test]
987 fn has_structural_changes_false_for_password_only() {
988 let summary = PlanSummary {
989 passwords_set: 3,
990 ..Default::default()
991 };
992 assert!(
993 !summary.has_structural_changes(),
994 "password-only plan should NOT be considered structural drift"
995 );
996 }
997
998 #[test]
999 fn has_structural_changes_true_for_mixed() {
1000 let summary = PlanSummary {
1001 roles_created: 1,
1002 passwords_set: 2,
1003 ..Default::default()
1004 };
1005 assert!(
1006 summary.has_structural_changes(),
1007 "mixed plan with structural + password changes IS structural drift"
1008 );
1009 }
1010
1011 #[test]
1012 fn has_structural_changes_false_for_empty() {
1013 let summary = PlanSummary::default();
1014 assert!(!summary.has_structural_changes());
1015 }
1016
1017 #[test]
1018 fn plan_summary_displays_password_count() {
1019 let summary = PlanSummary {
1020 passwords_set: 2,
1021 roles_created: 1,
1022 ..Default::default()
1023 };
1024 let display = summary.to_string();
1025 assert!(display.contains("2 password(s) to set"), "got: {display}");
1026 assert!(display.contains("3 change(s)"), "got: {display}");
1027 }
1028
1029 #[test]
1034 fn additive_mode_filters_revokes_from_plan() {
1035 use pgroles_core::diff::{ReconciliationMode, filter_changes};
1036 use pgroles_core::model::RoleState;
1037
1038 let validated = validate_manifest(PROFILE_MANIFEST).unwrap();
1039
1040 let mut current = validated.desired.clone();
1041 current
1042 .roles
1043 .insert("stale-role".to_string(), RoleState::default());
1044
1045 let changes = compute_plan(¤t, &validated.desired);
1046 assert!(changes.iter().any(|c| matches!(
1047 c,
1048 pgroles_core::diff::Change::DropRole { name } if name == "stale-role"
1049 )));
1050
1051 let filtered = filter_changes(changes, ReconciliationMode::Additive);
1052 assert!(
1053 !filtered
1054 .iter()
1055 .any(|c| matches!(c, pgroles_core::diff::Change::DropRole { .. })),
1056 "additive mode should filter out DropRole"
1057 );
1058 }
1059
1060 #[test]
1061 fn adopt_mode_filters_drops_but_keeps_revokes() {
1062 use pgroles_core::diff::{ReconciliationMode, filter_changes};
1063 use pgroles_core::manifest::{ObjectType, Privilege};
1064 use pgroles_core::model::{GrantKey, GrantState, RoleState};
1065 use std::collections::BTreeSet;
1066
1067 let validated = validate_manifest(MINIMAL_MANIFEST).unwrap();
1068
1069 let mut current = validated.desired.clone();
1070 current
1071 .roles
1072 .insert("stale-role".to_string(), RoleState::default());
1073 current.grants.insert(
1074 GrantKey {
1075 role: "analytics".to_string(),
1076 object_type: ObjectType::Table,
1077 schema: Some("public".to_string()),
1078 name: Some("*".to_string()),
1079 },
1080 GrantState {
1081 privileges: BTreeSet::from([Privilege::Select]),
1082 },
1083 );
1084
1085 let changes = compute_plan(¤t, &validated.desired);
1086
1087 let filtered = filter_changes(changes, ReconciliationMode::Adopt);
1088 assert!(
1089 !filtered
1090 .iter()
1091 .any(|c| matches!(c, pgroles_core::diff::Change::DropRole { .. })),
1092 "adopt mode should filter out DropRole"
1093 );
1094 assert!(
1095 filtered
1096 .iter()
1097 .any(|c| matches!(c, pgroles_core::diff::Change::Revoke { .. })),
1098 "adopt mode should keep Revoke changes"
1099 );
1100 }
1101 #[test]
1106 fn plan_json_produces_valid_json() {
1107 let validated = validate_manifest(MINIMAL_MANIFEST).unwrap();
1108 let current = RoleGraph::default();
1109 let changes = compute_plan(¤t, &validated.desired);
1110
1111 let json_output = format_plan_json(&changes).unwrap();
1112 let parsed: serde_json::Value = serde_json::from_str(&json_output).unwrap();
1114 assert!(parsed.is_array());
1115 let text = json_output.to_string();
1117 assert!(text.contains("CreateRole"), "got: {text}");
1118 assert!(text.contains("analytics"), "got: {text}");
1119 }
1120
1121 #[test]
1122 fn format_plan_json_redacts_passwords() {
1123 let changes = vec![Change::SetPassword {
1124 name: "app-svc".to_string(),
1125 password: "super-secret".to_string(),
1126 }];
1127
1128 let json = format_plan_json(&changes).expect("json formatting should succeed");
1129 assert!(json.contains("[REDACTED]"), "got: {json}");
1130 assert!(!json.contains("super-secret"), "got: {json}");
1131 }
1132
1133 #[test]
1134 fn bundle_plan_json_includes_scope_and_ownership_annotations() {
1135 let bundle = composition::parse_policy_bundle(
1136 r#"
1137sources:
1138 - file: app.yaml
1139"#,
1140 )
1141 .unwrap();
1142 let documents = vec![composition::PolicyDocument {
1143 source: "app.yaml".to_string(),
1144 fragment: composition::parse_policy_fragment(
1145 r#"
1146policy:
1147 name: app
1148scope:
1149 roles: [app]
1150roles:
1151 - name: app
1152 login: false
1153"#,
1154 )
1155 .unwrap(),
1156 }];
1157 let composed = composition::compose_bundle(&bundle, &documents).unwrap();
1158 let changes = compute_plan(&RoleGraph::default(), &composed.desired);
1159
1160 let json_output = format_bundle_plan_json(&changes, &composed).unwrap();
1161 let parsed: serde_json::Value = serde_json::from_str(&json_output).unwrap();
1162
1163 assert!(parsed.is_object());
1164 assert_eq!(parsed["schema_version"], "pgroles.bundle_plan.v1");
1165 assert_eq!(parsed["managed_scope"]["roles"][0], "app");
1166 assert_eq!(parsed["changes"][0]["owner"]["document"], "app");
1167 assert_eq!(parsed["changes"][0]["owner"]["managed_key"]["kind"], "role");
1168 assert_eq!(parsed["changes"][0]["owner"]["managed_key"]["name"], "app");
1169 }
1170
1171 #[test]
1172 fn format_plan_sql_redacts_passwords() {
1173 let changes = vec![Change::SetPassword {
1174 name: "app-svc".to_string(),
1175 password: "super-secret".to_string(),
1176 }];
1177
1178 let sql = format_plan_sql_with_context(&changes, &sql::SqlContext::default());
1179 assert!(sql.contains("[REDACTED]"), "got: {sql}");
1180 assert!(!sql.contains("super-secret"), "got: {sql}");
1181 }
1182
1183 #[test]
1184 fn format_applied_uses_applied_header() {
1185 let summary = PlanSummary {
1186 roles_created: 1,
1187 schemas_created: 1,
1188 grants: 2,
1189 ..Default::default()
1190 };
1191
1192 let display = summary.format_applied();
1193 assert!(
1194 display.starts_with("Applied: 4 change(s)\n"),
1195 "got: {display}"
1196 );
1197 assert!(display.contains("1 role(s) to create"), "got: {display}");
1198 assert!(display.contains("1 schema(s) to create"), "got: {display}");
1199 assert!(display.contains("2 grant(s) to add"), "got: {display}");
1200 assert!(!display.contains("Plan:"), "got: {display}");
1201 }
1202
1203 fn validated_bundle_for_render() -> ValidatedBundle {
1208 let bundle = composition::parse_policy_bundle(
1209 r#"
1210sources:
1211 - file: platform.yaml
1212 - file: app.yaml
1213"#,
1214 )
1215 .unwrap();
1216 let documents = vec![
1217 composition::PolicyDocument {
1218 source: "platform.yaml".to_string(),
1219 fragment: composition::parse_policy_fragment(
1220 r#"
1221policy:
1222 name: platform
1223scope:
1224 roles: [app_owner]
1225 schemas:
1226 - name: inventory
1227 facets: [owner]
1228roles:
1229 - name: app_owner
1230 login: false
1231schemas:
1232 - name: inventory
1233 owner: app_owner
1234"#,
1235 )
1236 .unwrap(),
1237 },
1238 composition::PolicyDocument {
1239 source: "app.yaml".to_string(),
1240 fragment: composition::parse_policy_fragment(
1241 r#"
1242policy:
1243 name: app
1244scope:
1245 roles: [app_service]
1246roles:
1247 - name: app_service
1248 login: true
1249"#,
1250 )
1251 .unwrap(),
1252 },
1253 ];
1254 let composed = composition::compose_bundle(&bundle, &documents).unwrap();
1255 ValidatedBundle {
1256 bundle,
1257 documents,
1258 composed,
1259 }
1260 }
1261
1262 #[test]
1263 fn rendered_bundle_round_trips_to_equivalent_expansion() {
1264 let validated = validated_bundle_for_render();
1265 let rendered = format_rendered_bundle(&validated, "bundle.yaml", true).unwrap();
1266
1267 let reparsed = validate_manifest(&rendered).expect("rendered output must validate");
1268
1269 use std::collections::BTreeSet;
1273 let original_roles: BTreeSet<_> = validated
1274 .composed
1275 .expanded
1276 .roles
1277 .iter()
1278 .map(|r| r.name.clone())
1279 .collect();
1280 let rendered_roles: BTreeSet<_> = reparsed
1281 .expanded
1282 .roles
1283 .iter()
1284 .map(|r| r.name.clone())
1285 .collect();
1286 assert_eq!(rendered_roles, original_roles);
1287
1288 let original_schemas: BTreeSet<_> = validated
1289 .composed
1290 .expanded
1291 .schemas
1292 .iter()
1293 .map(|s| s.name.clone())
1294 .collect();
1295 let rendered_schemas: BTreeSet<_> = reparsed
1296 .expanded
1297 .schemas
1298 .iter()
1299 .map(|s| s.name.clone())
1300 .collect();
1301 assert_eq!(rendered_schemas, original_schemas);
1302
1303 assert_eq!(
1304 reparsed.expanded.grants.len(),
1305 validated.composed.expanded.grants.len()
1306 );
1307 assert_eq!(
1308 reparsed.expanded.default_privileges.len(),
1309 validated.composed.expanded.default_privileges.len()
1310 );
1311 assert_eq!(
1312 reparsed.expanded.memberships.len(),
1313 validated.composed.expanded.memberships.len()
1314 );
1315 }
1316
1317 #[test]
1318 fn rendered_bundle_header_records_source_and_fragments() {
1319 let validated = validated_bundle_for_render();
1320 let rendered = format_rendered_bundle(&validated, "prod.yaml", true).unwrap();
1321
1322 assert!(rendered.starts_with("# Rendered by `pgroles render-bundle`."));
1323 assert!(rendered.contains("# Source bundle: prod.yaml"));
1324 assert!(rendered.contains("# - platform.yaml (platform)"));
1325 assert!(rendered.contains("# - app.yaml (app)"));
1326 }
1327
1328 #[test]
1329 fn rendered_bundle_header_records_manifest_schema_version() {
1330 let validated = validated_bundle_for_render();
1336 let rendered = format_rendered_bundle(&validated, "bundle.yaml", true).unwrap();
1337 assert!(
1338 rendered.contains(&format!("# Manifest schema: {RENDERED_MANIFEST_SCHEMA}")),
1339 "header must record manifest schema, got: {rendered}"
1340 );
1341 }
1342
1343 #[test]
1344 fn rendered_bundle_strips_empty_collections_and_nulls() {
1345 let validated = validated_bundle_for_render();
1346 let rendered = format_rendered_bundle(&validated, "bundle.yaml", false).unwrap();
1347
1348 assert!(
1350 !rendered.contains("auth_providers: []"),
1351 "rendered output must not include empty auth_providers, got: {rendered}"
1352 );
1353 assert!(
1354 !rendered.contains("grants: []"),
1355 "rendered output must not include empty grants, got: {rendered}"
1356 );
1357 assert!(
1358 !rendered.contains("default_privileges: []"),
1359 "rendered output must not include empty default_privileges, got: {rendered}"
1360 );
1361 assert!(
1362 !rendered.contains("memberships: []"),
1363 "rendered output must not include empty memberships, got: {rendered}"
1364 );
1365 assert!(
1366 !rendered.contains("profiles: {}"),
1367 "rendered output must not include empty top-level profiles, got: {rendered}"
1368 );
1369 assert!(
1370 !rendered.contains("retirements: []"),
1371 "rendered output must not include empty retirements, got: {rendered}"
1372 );
1373 assert!(
1375 !rendered.contains("login: null"),
1376 "rendered output must not include null login, got: {rendered}"
1377 );
1378 assert!(
1379 !rendered.contains("inherit: null"),
1380 "rendered output must not include null inherit, got: {rendered}"
1381 );
1382 assert!(
1385 !rendered.contains("role_pattern:"),
1386 "rendered output must elide default role_pattern, got: {rendered}"
1387 );
1388 }
1389
1390 #[test]
1391 fn rendered_bundle_no_header_emits_only_yaml() {
1392 let validated = validated_bundle_for_render();
1393 let rendered = format_rendered_bundle(&validated, "bundle.yaml", false).unwrap();
1394
1395 assert!(
1396 !rendered.starts_with('#'),
1397 "without header, output should start with YAML, got: {rendered}"
1398 );
1399 validate_manifest(&rendered).expect("rendered output must validate");
1401 }
1402
1403 #[test]
1404 fn rendered_bundle_is_deterministic() {
1405 let validated = validated_bundle_for_render();
1406 let first = format_rendered_bundle(&validated, "bundle.yaml", true).unwrap();
1407 let second = format_rendered_bundle(&validated, "bundle.yaml", true).unwrap();
1408 assert_eq!(first, second);
1409 }
1410
1411 #[test]
1412 fn rendered_bundle_preserves_required_empty_sequences() {
1413 let bundle = composition::parse_policy_bundle(
1417 r#"
1418sources:
1419 - file: app.yaml
1420"#,
1421 )
1422 .unwrap();
1423 let documents = vec![composition::PolicyDocument {
1424 source: "app.yaml".to_string(),
1425 fragment: composition::parse_policy_fragment(
1426 r#"
1427policy:
1428 name: app
1429scope:
1430 roles: [empty_group]
1431roles:
1432 - name: empty_group
1433 login: false
1434memberships:
1435 - role: empty_group
1436 members: []
1437"#,
1438 )
1439 .unwrap(),
1440 }];
1441 let composed = composition::compose_bundle(&bundle, &documents).unwrap();
1442 let validated = ValidatedBundle {
1443 bundle,
1444 documents,
1445 composed,
1446 };
1447
1448 let rendered = format_rendered_bundle(&validated, "bundle.yaml", false).unwrap();
1449
1450 assert!(
1452 rendered.contains("members:"),
1453 "required `members` field must not be stripped, got: {rendered}"
1454 );
1455
1456 validate_manifest(&rendered)
1458 .expect("rendered output with empty required sequence must still parse");
1459 }
1460
1461 #[test]
1462 fn rendered_bundle_preserves_referenced_empty_profiles() {
1463 let bundle = composition::parse_policy_bundle(
1468 r#"
1469shared:
1470 profiles:
1471 noop: {}
1472sources:
1473 - file: app.yaml
1474"#,
1475 )
1476 .unwrap();
1477 let documents = vec![composition::PolicyDocument {
1478 source: "app.yaml".to_string(),
1479 fragment: composition::parse_policy_fragment(
1480 r#"
1481policy:
1482 name: app
1483scope:
1484 schemas:
1485 - name: inventory
1486 facets: [bindings]
1487schemas:
1488 - name: inventory
1489 profiles: [noop]
1490"#,
1491 )
1492 .unwrap(),
1493 }];
1494 let composed = composition::compose_bundle(&bundle, &documents).unwrap();
1495 let validated = ValidatedBundle {
1496 bundle,
1497 documents,
1498 composed,
1499 };
1500
1501 let rendered = format_rendered_bundle(&validated, "bundle.yaml", false).unwrap();
1502
1503 assert!(
1504 rendered.contains("noop: {}"),
1505 "empty referenced profile must be preserved, got: {rendered}"
1506 );
1507 let reparsed = validate_manifest(&rendered)
1508 .expect("rendered output with an empty referenced profile must still parse");
1509 assert!(
1510 reparsed
1511 .expanded
1512 .roles
1513 .iter()
1514 .any(|role| role.name == "inventory-noop"),
1515 "empty profile must still expand to its schema/profile role"
1516 );
1517 }
1518}