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