1use std::collections::BTreeSet;
12
13use crate::manifest::{ObjectType, Privilege, RoleRetirement};
14use crate::model::{DefaultPrivKey, GrantKey, MembershipEdge, RoleAttribute, RoleGraph, RoleState};
15
16#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
33pub enum Change {
34 CreateRole { name: String, state: RoleState },
36
37 AlterRole {
39 name: String,
40 attributes: Vec<RoleAttribute>,
41 },
42
43 SetComment {
45 name: String,
46 comment: Option<String>,
47 },
48
49 Grant {
51 role: String,
52 privileges: BTreeSet<Privilege>,
53 object_type: ObjectType,
54 schema: Option<String>,
55 name: Option<String>,
56 },
57
58 Revoke {
60 role: String,
61 privileges: BTreeSet<Privilege>,
62 object_type: ObjectType,
63 schema: Option<String>,
64 name: Option<String>,
65 },
66
67 SetDefaultPrivilege {
69 owner: String,
70 schema: String,
71 on_type: ObjectType,
72 grantee: String,
73 privileges: BTreeSet<Privilege>,
74 },
75
76 RevokeDefaultPrivilege {
78 owner: String,
79 schema: String,
80 on_type: ObjectType,
81 grantee: String,
82 privileges: BTreeSet<Privilege>,
83 },
84
85 AddMember {
87 role: String,
88 member: String,
89 inherit: bool,
90 admin: bool,
91 },
92
93 RemoveMember { role: String, member: String },
95
96 ReassignOwned { from_role: String, to_role: String },
98
99 DropOwned { role: String },
101
102 TerminateSessions { role: String },
104
105 SetPassword { name: String, password: String },
111
112 DropRole { name: String },
114}
115
116#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, serde::Serialize)]
126pub enum ReconciliationMode {
127 #[default]
133 Authoritative,
134
135 Additive,
146
147 Adopt,
158}
159
160impl std::fmt::Display for ReconciliationMode {
161 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
162 match self {
163 ReconciliationMode::Authoritative => write!(f, "authoritative"),
164 ReconciliationMode::Additive => write!(f, "additive"),
165 ReconciliationMode::Adopt => write!(f, "adopt"),
166 }
167 }
168}
169
170pub fn filter_changes(changes: Vec<Change>, mode: ReconciliationMode) -> Vec<Change> {
178 match mode {
179 ReconciliationMode::Authoritative => changes,
180 ReconciliationMode::Additive => changes
181 .into_iter()
182 .filter(|change| !is_destructive(change))
183 .collect(),
184 ReconciliationMode::Adopt => changes
185 .into_iter()
186 .filter(|change| !is_role_drop_or_retirement(change))
187 .collect(),
188 }
189}
190
191fn is_destructive(change: &Change) -> bool {
193 matches!(
194 change,
195 Change::Revoke { .. }
196 | Change::RevokeDefaultPrivilege { .. }
197 | Change::RemoveMember { .. }
198 | Change::DropRole { .. }
199 | Change::DropOwned { .. }
200 | Change::ReassignOwned { .. }
201 | Change::TerminateSessions { .. }
202 )
203}
204
205fn is_role_drop_or_retirement(change: &Change) -> bool {
207 matches!(
208 change,
209 Change::DropRole { .. }
210 | Change::DropOwned { .. }
211 | Change::ReassignOwned { .. }
212 | Change::TerminateSessions { .. }
213 )
214}
215
216pub fn diff(current: &RoleGraph, desired: &RoleGraph) -> Vec<Change> {
225 let mut creates = Vec::new();
226 let mut alters = Vec::new();
227 let mut grants = Vec::new();
228 let mut set_defaults = Vec::new();
229 let mut add_members = Vec::new();
230 let mut remove_members = Vec::new();
231 let mut revoke_defaults = Vec::new();
232 let mut revokes = Vec::new();
233 let mut drops = Vec::new();
234
235 for (name, desired_state) in &desired.roles {
239 match current.roles.get(name) {
240 None => {
241 creates.push(Change::CreateRole {
242 name: name.clone(),
243 state: desired_state.clone(),
244 });
245 }
246 Some(current_state) => {
247 let attribute_changes = current_state.changed_attributes(desired_state);
249 if !attribute_changes.is_empty() {
250 alters.push(Change::AlterRole {
251 name: name.clone(),
252 attributes: attribute_changes,
253 });
254 }
255 if current_state.comment != desired_state.comment {
257 alters.push(Change::SetComment {
258 name: name.clone(),
259 comment: desired_state.comment.clone(),
260 });
261 }
262 }
263 }
264 }
265
266 for name in current.roles.keys() {
268 if !desired.roles.contains_key(name) {
269 drops.push(Change::DropRole { name: name.clone() });
270 }
271 }
272
273 diff_grants(current, desired, &mut grants, &mut revokes);
276
277 diff_default_privileges(current, desired, &mut set_defaults, &mut revoke_defaults);
280
281 diff_memberships(current, desired, &mut add_members, &mut remove_members);
284
285 let mut changes = Vec::new();
287 changes.extend(creates);
288 changes.extend(alters);
289 changes.extend(grants);
290 changes.extend(set_defaults);
291 changes.extend(remove_members);
292 changes.extend(add_members);
293 changes.extend(revoke_defaults);
294 changes.extend(revokes);
295 changes.extend(drops);
296 changes
297}
298
299pub fn apply_role_retirements(changes: Vec<Change>, retirements: &[RoleRetirement]) -> Vec<Change> {
305 if retirements.is_empty() {
306 return changes;
307 }
308
309 let retirement_by_role: std::collections::BTreeMap<&str, &RoleRetirement> = retirements
310 .iter()
311 .map(|retirement| (retirement.role.as_str(), retirement))
312 .collect();
313
314 let mut planned = Vec::with_capacity(changes.len());
315 for change in changes {
316 if let Change::DropRole { name } = &change
317 && let Some(retirement) = retirement_by_role.get(name.as_str())
318 {
319 if retirement.terminate_sessions {
320 planned.push(Change::TerminateSessions { role: name.clone() });
321 }
322 if let Some(successor) = &retirement.reassign_owned_to {
323 planned.push(Change::ReassignOwned {
324 from_role: name.clone(),
325 to_role: successor.clone(),
326 });
327 }
328 if retirement.drop_owned {
329 planned.push(Change::DropOwned { role: name.clone() });
330 }
331 }
332 planned.push(change);
333 }
334
335 planned
336}
337
338pub fn resolve_passwords(
348 roles: &[crate::manifest::RoleDefinition],
349) -> Result<std::collections::BTreeMap<String, String>, PasswordResolutionError> {
350 let mut resolved = std::collections::BTreeMap::new();
351 for role in roles {
352 if let Some(source) = &role.password {
353 let value = std::env::var(&source.from_env).map_err(|_| {
354 PasswordResolutionError::MissingEnvVar {
355 role: role.name.clone(),
356 env_var: source.from_env.clone(),
357 }
358 })?;
359 if value.is_empty() {
360 return Err(PasswordResolutionError::EmptyPassword {
361 role: role.name.clone(),
362 env_var: source.from_env.clone(),
363 });
364 }
365 resolved.insert(role.name.clone(), value);
366 }
367 }
368 Ok(resolved)
369}
370
371#[derive(Debug, thiserror::Error)]
373pub enum PasswordResolutionError {
374 #[error("environment variable \"{env_var}\" for role \"{role}\" password is not set")]
375 MissingEnvVar { role: String, env_var: String },
376
377 #[error("environment variable \"{env_var}\" for role \"{role}\" password is empty")]
378 EmptyPassword { role: String, env_var: String },
379}
380
381pub fn inject_password_changes(
389 changes: Vec<Change>,
390 resolved_passwords: &std::collections::BTreeMap<String, String>,
391) -> Vec<Change> {
392 if resolved_passwords.is_empty() {
393 return changes;
394 }
395
396 let created_roles: std::collections::BTreeSet<String> = changes
398 .iter()
399 .filter_map(|c| match c {
400 Change::CreateRole { name, .. } => Some(name.clone()),
401 _ => None,
402 })
403 .collect();
404
405 let mut result = Vec::with_capacity(changes.len() + resolved_passwords.len());
406
407 for change in changes {
409 if let Change::CreateRole { ref name, .. } = change
410 && let Some(password) = resolved_passwords.get(name.as_str())
411 {
412 let role_name = name.clone();
413 let password = password.clone();
414 result.push(change);
415 result.push(Change::SetPassword {
416 name: role_name,
417 password,
418 });
419 continue;
420 }
421 result.push(change);
422 }
423
424 for (role_name, password) in resolved_passwords {
426 if !created_roles.contains(role_name) {
427 result.push(Change::SetPassword {
428 name: role_name.clone(),
429 password: password.clone(),
430 });
431 }
432 }
433
434 result
435}
436
437fn diff_grants(
442 current: &RoleGraph,
443 desired: &RoleGraph,
444 grants_out: &mut Vec<Change>,
445 revokes_out: &mut Vec<Change>,
446) {
447 for (key, desired_state) in &desired.grants {
450 match current.grants.get(key) {
451 None => {
452 grants_out.push(change_grant(key, &desired_state.privileges));
454 }
455 Some(current_state) => {
456 let to_add: BTreeSet<Privilege> = desired_state
458 .privileges
459 .difference(¤t_state.privileges)
460 .copied()
461 .collect();
462 let to_remove: BTreeSet<Privilege> = current_state
463 .privileges
464 .difference(&desired_state.privileges)
465 .copied()
466 .collect();
467
468 if !to_add.is_empty() {
469 grants_out.push(change_grant(key, &to_add));
470 }
471 if !to_remove.is_empty() {
472 revokes_out.push(change_revoke(key, &to_remove));
473 }
474 }
475 }
476 }
477
478 for (key, current_state) in ¤t.grants {
480 if !desired.grants.contains_key(key) {
481 revokes_out.push(change_revoke(key, ¤t_state.privileges));
482 }
483 }
484}
485
486fn change_grant(key: &GrantKey, privileges: &BTreeSet<Privilege>) -> Change {
487 Change::Grant {
488 role: key.role.clone(),
489 privileges: privileges.clone(),
490 object_type: key.object_type,
491 schema: key.schema.clone(),
492 name: key.name.clone(),
493 }
494}
495
496fn change_revoke(key: &GrantKey, privileges: &BTreeSet<Privilege>) -> Change {
497 Change::Revoke {
498 role: key.role.clone(),
499 privileges: privileges.clone(),
500 object_type: key.object_type,
501 schema: key.schema.clone(),
502 name: key.name.clone(),
503 }
504}
505
506fn diff_default_privileges(
511 current: &RoleGraph,
512 desired: &RoleGraph,
513 set_out: &mut Vec<Change>,
514 revoke_out: &mut Vec<Change>,
515) {
516 for (key, desired_state) in &desired.default_privileges {
517 match current.default_privileges.get(key) {
518 None => {
519 set_out.push(change_set_default(key, &desired_state.privileges));
520 }
521 Some(current_state) => {
522 let to_add: BTreeSet<Privilege> = desired_state
523 .privileges
524 .difference(¤t_state.privileges)
525 .copied()
526 .collect();
527 let to_remove: BTreeSet<Privilege> = current_state
528 .privileges
529 .difference(&desired_state.privileges)
530 .copied()
531 .collect();
532
533 if !to_add.is_empty() {
534 set_out.push(change_set_default(key, &to_add));
535 }
536 if !to_remove.is_empty() {
537 revoke_out.push(change_revoke_default(key, &to_remove));
538 }
539 }
540 }
541 }
542
543 for (key, current_state) in ¤t.default_privileges {
544 if !desired.default_privileges.contains_key(key) {
545 revoke_out.push(change_revoke_default(key, ¤t_state.privileges));
546 }
547 }
548}
549
550fn change_set_default(key: &DefaultPrivKey, privileges: &BTreeSet<Privilege>) -> Change {
551 Change::SetDefaultPrivilege {
552 owner: key.owner.clone(),
553 schema: key.schema.clone(),
554 on_type: key.on_type,
555 grantee: key.grantee.clone(),
556 privileges: privileges.clone(),
557 }
558}
559
560fn change_revoke_default(key: &DefaultPrivKey, privileges: &BTreeSet<Privilege>) -> Change {
561 Change::RevokeDefaultPrivilege {
562 owner: key.owner.clone(),
563 schema: key.schema.clone(),
564 on_type: key.on_type,
565 grantee: key.grantee.clone(),
566 privileges: privileges.clone(),
567 }
568}
569
570fn diff_memberships(
575 current: &RoleGraph,
576 desired: &RoleGraph,
577 add_out: &mut Vec<Change>,
578 remove_out: &mut Vec<Change>,
579) {
580 let current_map: std::collections::BTreeMap<(&str, &str), &MembershipEdge> = current
585 .memberships
586 .iter()
587 .map(|edge| ((edge.role.as_str(), edge.member.as_str()), edge))
588 .collect();
589 let desired_map: std::collections::BTreeMap<(&str, &str), &MembershipEdge> = desired
590 .memberships
591 .iter()
592 .map(|edge| ((edge.role.as_str(), edge.member.as_str()), edge))
593 .collect();
594
595 for (&(role, member), &desired_edge) in &desired_map {
598 match current_map.get(&(role, member)) {
599 None => {
600 add_out.push(Change::AddMember {
601 role: desired_edge.role.clone(),
602 member: desired_edge.member.clone(),
603 inherit: desired_edge.inherit,
604 admin: desired_edge.admin,
605 });
606 }
607 Some(current_edge) => {
608 if current_edge.inherit != desired_edge.inherit
609 || current_edge.admin != desired_edge.admin
610 {
611 remove_out.push(Change::RemoveMember {
613 role: current_edge.role.clone(),
614 member: current_edge.member.clone(),
615 });
616 add_out.push(Change::AddMember {
617 role: desired_edge.role.clone(),
618 member: desired_edge.member.clone(),
619 inherit: desired_edge.inherit,
620 admin: desired_edge.admin,
621 });
622 }
623 }
624 }
625 }
626
627 for &(role, member) in current_map.keys() {
629 if !desired_map.contains_key(&(role, member)) {
630 remove_out.push(Change::RemoveMember {
631 role: role.to_string(),
632 member: member.to_string(),
633 });
634 }
635 }
636}
637
638#[cfg(test)]
643mod tests {
644 use super::*;
645 use crate::model::{DefaultPrivState, GrantState};
646
647 fn empty_graph() -> RoleGraph {
649 RoleGraph::default()
650 }
651
652 #[test]
653 fn diff_empty_to_empty_is_empty() {
654 let changes = diff(&empty_graph(), &empty_graph());
655 assert!(changes.is_empty());
656 }
657
658 #[test]
659 fn diff_creates_new_roles() {
660 let current = empty_graph();
661 let mut desired = empty_graph();
662 desired
663 .roles
664 .insert("new-role".to_string(), RoleState::default());
665
666 let changes = diff(¤t, &desired);
667 assert_eq!(changes.len(), 1);
668 assert!(matches!(&changes[0], Change::CreateRole { name, .. } if name == "new-role"));
669 }
670
671 #[test]
672 fn diff_drops_removed_roles() {
673 let mut current = empty_graph();
674 current
675 .roles
676 .insert("old-role".to_string(), RoleState::default());
677 let desired = empty_graph();
678
679 let changes = diff(¤t, &desired);
680 assert_eq!(changes.len(), 1);
681 assert!(matches!(&changes[0], Change::DropRole { name } if name == "old-role"));
682 }
683
684 #[test]
685 fn diff_alters_changed_role_attributes() {
686 let mut current = empty_graph();
687 current
688 .roles
689 .insert("role1".to_string(), RoleState::default());
690
691 let mut desired = empty_graph();
692 desired.roles.insert(
693 "role1".to_string(),
694 RoleState {
695 login: true,
696 ..RoleState::default()
697 },
698 );
699
700 let changes = diff(¤t, &desired);
701 assert_eq!(changes.len(), 1);
702 match &changes[0] {
703 Change::AlterRole { name, attributes } => {
704 assert_eq!(name, "role1");
705 assert!(attributes.contains(&RoleAttribute::Login(true)));
706 }
707 other => panic!("expected AlterRole, got: {other:?}"),
708 }
709 }
710
711 #[test]
712 fn diff_grants_new_privileges() {
713 let current = empty_graph();
714 let mut desired = empty_graph();
715 let key = GrantKey {
716 role: "r1".to_string(),
717 object_type: ObjectType::Table,
718 schema: Some("public".to_string()),
719 name: Some("*".to_string()),
720 };
721 desired.grants.insert(
722 key,
723 GrantState {
724 privileges: BTreeSet::from([Privilege::Select, Privilege::Insert]),
725 },
726 );
727
728 let changes = diff(¤t, &desired);
729 assert_eq!(changes.len(), 1);
730 match &changes[0] {
731 Change::Grant {
732 role, privileges, ..
733 } => {
734 assert_eq!(role, "r1");
735 assert!(privileges.contains(&Privilege::Select));
736 assert!(privileges.contains(&Privilege::Insert));
737 }
738 other => panic!("expected Grant, got: {other:?}"),
739 }
740 }
741
742 #[test]
743 fn diff_revokes_removed_privileges() {
744 let mut current = empty_graph();
745 let key = GrantKey {
746 role: "r1".to_string(),
747 object_type: ObjectType::Table,
748 schema: Some("public".to_string()),
749 name: Some("*".to_string()),
750 };
751 current.grants.insert(
752 key.clone(),
753 GrantState {
754 privileges: BTreeSet::from([Privilege::Select, Privilege::Insert]),
755 },
756 );
757
758 let mut desired = empty_graph();
759 desired.grants.insert(
760 key,
761 GrantState {
762 privileges: BTreeSet::from([Privilege::Select]),
763 },
764 );
765
766 let changes = diff(¤t, &desired);
767 assert_eq!(changes.len(), 1);
768 match &changes[0] {
769 Change::Revoke {
770 role, privileges, ..
771 } => {
772 assert_eq!(role, "r1");
773 assert!(privileges.contains(&Privilege::Insert));
774 assert!(!privileges.contains(&Privilege::Select));
775 }
776 other => panic!("expected Revoke, got: {other:?}"),
777 }
778 }
779
780 #[test]
781 fn diff_revokes_entire_grant_target_when_absent_from_desired() {
782 let mut current = empty_graph();
783 let key = GrantKey {
784 role: "r1".to_string(),
785 object_type: ObjectType::Schema,
786 schema: None,
787 name: Some("myschema".to_string()),
788 };
789 current.grants.insert(
790 key,
791 GrantState {
792 privileges: BTreeSet::from([Privilege::Usage]),
793 },
794 );
795 let desired = empty_graph();
796
797 let changes = diff(¤t, &desired);
798 assert_eq!(changes.len(), 1);
799 assert!(matches!(&changes[0], Change::Revoke { role, .. } if role == "r1"));
800 }
801
802 #[test]
803 fn diff_adds_memberships() {
804 let current = empty_graph();
805 let mut desired = empty_graph();
806 desired.memberships.insert(MembershipEdge {
807 role: "editors".to_string(),
808 member: "user@example.com".to_string(),
809 inherit: true,
810 admin: false,
811 });
812
813 let changes = diff(¤t, &desired);
814 assert_eq!(changes.len(), 1);
815 match &changes[0] {
816 Change::AddMember {
817 role,
818 member,
819 inherit,
820 admin,
821 } => {
822 assert_eq!(role, "editors");
823 assert_eq!(member, "user@example.com");
824 assert!(*inherit);
825 assert!(!admin);
826 }
827 other => panic!("expected AddMember, got: {other:?}"),
828 }
829 }
830
831 #[test]
832 fn diff_removes_memberships() {
833 let mut current = empty_graph();
834 current.memberships.insert(MembershipEdge {
835 role: "editors".to_string(),
836 member: "old@example.com".to_string(),
837 inherit: true,
838 admin: false,
839 });
840 let desired = empty_graph();
841
842 let changes = diff(¤t, &desired);
843 assert_eq!(changes.len(), 1);
844 assert!(
845 matches!(&changes[0], Change::RemoveMember { role, member } if role == "editors" && member == "old@example.com")
846 );
847 }
848
849 #[test]
850 fn diff_re_grants_membership_when_flags_change() {
851 let mut current = empty_graph();
852 current.memberships.insert(MembershipEdge {
853 role: "editors".to_string(),
854 member: "user@example.com".to_string(),
855 inherit: true,
856 admin: false,
857 });
858
859 let mut desired = empty_graph();
860 desired.memberships.insert(MembershipEdge {
861 role: "editors".to_string(),
862 member: "user@example.com".to_string(),
863 inherit: true,
864 admin: true, });
866
867 let changes = diff(¤t, &desired);
868 assert_eq!(changes.len(), 2);
870 assert!(matches!(
871 &changes[0],
872 Change::RemoveMember { role, member }
873 if role == "editors" && member == "user@example.com"
874 ));
875 assert!(matches!(
876 &changes[1],
877 Change::AddMember {
878 role,
879 member,
880 admin: true,
881 ..
882 } if role == "editors" && member == "user@example.com"
883 ));
884 }
885
886 #[test]
887 fn diff_default_privileges_add_and_revoke() {
888 let mut current = empty_graph();
889 let key = DefaultPrivKey {
890 owner: "app_owner".to_string(),
891 schema: "inventory".to_string(),
892 on_type: ObjectType::Table,
893 grantee: "inventory-editor".to_string(),
894 };
895 current.default_privileges.insert(
896 key.clone(),
897 DefaultPrivState {
898 privileges: BTreeSet::from([Privilege::Select, Privilege::Delete]),
899 },
900 );
901
902 let mut desired = empty_graph();
903 desired.default_privileges.insert(
904 key,
905 DefaultPrivState {
906 privileges: BTreeSet::from([Privilege::Select, Privilege::Insert]),
907 },
908 );
909
910 let changes = diff(¤t, &desired);
911 assert_eq!(changes.len(), 2);
913 assert!(changes.iter().any(|c| matches!(
914 c,
915 Change::SetDefaultPrivilege { privileges, .. } if privileges.contains(&Privilege::Insert)
916 )));
917 assert!(changes.iter().any(|c| matches!(
918 c,
919 Change::RevokeDefaultPrivilege { privileges, .. } if privileges.contains(&Privilege::Delete)
920 )));
921 }
922
923 #[test]
924 fn diff_ordering_creates_before_drops() {
925 let mut current = empty_graph();
926 current
927 .roles
928 .insert("old-role".to_string(), RoleState::default());
929
930 let mut desired = empty_graph();
931 desired
932 .roles
933 .insert("new-role".to_string(), RoleState::default());
934
935 let changes = diff(¤t, &desired);
936 assert_eq!(changes.len(), 2);
937
938 let create_idx = changes
940 .iter()
941 .position(|c| matches!(c, Change::CreateRole { .. }))
942 .unwrap();
943 let drop_idx = changes
944 .iter()
945 .position(|c| matches!(c, Change::DropRole { .. }))
946 .unwrap();
947 assert!(create_idx < drop_idx);
948 }
949
950 #[test]
951 fn diff_identical_graphs_produce_no_changes() {
952 let mut graph = empty_graph();
953 graph
954 .roles
955 .insert("role1".to_string(), RoleState::default());
956 graph.grants.insert(
957 GrantKey {
958 role: "role1".to_string(),
959 object_type: ObjectType::Table,
960 schema: Some("public".to_string()),
961 name: Some("*".to_string()),
962 },
963 GrantState {
964 privileges: BTreeSet::from([Privilege::Select]),
965 },
966 );
967 graph.memberships.insert(MembershipEdge {
968 role: "role1".to_string(),
969 member: "user@example.com".to_string(),
970 inherit: true,
971 admin: false,
972 });
973
974 let changes = diff(&graph, &graph);
975 assert!(
976 changes.is_empty(),
977 "identical graphs should produce no changes"
978 );
979 }
980
981 #[test]
983 fn manifest_to_diff_integration() {
984 use crate::manifest::{expand_manifest, parse_manifest};
985 use crate::model::RoleGraph;
986
987 let yaml = r#"
988default_owner: app_owner
989
990profiles:
991 editor:
992 grants:
993 - privileges: [USAGE]
994 on: { type: schema }
995 - privileges: [SELECT, INSERT, UPDATE, DELETE]
996 on: { type: table, name: "*" }
997 default_privileges:
998 - privileges: [SELECT, INSERT, UPDATE, DELETE]
999 on_type: table
1000
1001schemas:
1002 - name: inventory
1003 profiles: [editor]
1004
1005memberships:
1006 - role: inventory-editor
1007 members:
1008 - name: "user@example.com"
1009"#;
1010 let manifest = parse_manifest(yaml).unwrap();
1011 let expanded = expand_manifest(&manifest).unwrap();
1012 let desired =
1013 RoleGraph::from_expanded(&expanded, manifest.default_owner.as_deref()).unwrap();
1014
1015 let current = RoleGraph::default();
1017 let changes = diff(¤t, &desired);
1018
1019 let create_count = changes
1021 .iter()
1022 .filter(|c| matches!(c, Change::CreateRole { .. }))
1023 .count();
1024 let grant_count = changes
1025 .iter()
1026 .filter(|c| matches!(c, Change::Grant { .. }))
1027 .count();
1028 let dp_count = changes
1029 .iter()
1030 .filter(|c| matches!(c, Change::SetDefaultPrivilege { .. }))
1031 .count();
1032 let member_count = changes
1033 .iter()
1034 .filter(|c| matches!(c, Change::AddMember { .. }))
1035 .count();
1036
1037 assert_eq!(create_count, 1);
1038 assert_eq!(grant_count, 2); assert_eq!(dp_count, 1);
1040 assert_eq!(member_count, 1);
1041
1042 let no_changes = diff(&desired, &desired);
1044 assert!(no_changes.is_empty());
1045 }
1046
1047 fn all_change_variants() -> Vec<Change> {
1053 vec![
1054 Change::CreateRole {
1055 name: "new-role".to_string(),
1056 state: RoleState::default(),
1057 },
1058 Change::AlterRole {
1059 name: "altered-role".to_string(),
1060 attributes: vec![RoleAttribute::Login(true)],
1061 },
1062 Change::SetComment {
1063 name: "commented-role".to_string(),
1064 comment: Some("hello".to_string()),
1065 },
1066 Change::Grant {
1067 role: "r1".to_string(),
1068 privileges: BTreeSet::from([Privilege::Select]),
1069 object_type: ObjectType::Table,
1070 schema: Some("public".to_string()),
1071 name: Some("*".to_string()),
1072 },
1073 Change::Revoke {
1074 role: "r1".to_string(),
1075 privileges: BTreeSet::from([Privilege::Insert]),
1076 object_type: ObjectType::Table,
1077 schema: Some("public".to_string()),
1078 name: Some("*".to_string()),
1079 },
1080 Change::SetDefaultPrivilege {
1081 owner: "owner".to_string(),
1082 schema: "public".to_string(),
1083 on_type: ObjectType::Table,
1084 grantee: "r1".to_string(),
1085 privileges: BTreeSet::from([Privilege::Select]),
1086 },
1087 Change::RevokeDefaultPrivilege {
1088 owner: "owner".to_string(),
1089 schema: "public".to_string(),
1090 on_type: ObjectType::Table,
1091 grantee: "r1".to_string(),
1092 privileges: BTreeSet::from([Privilege::Delete]),
1093 },
1094 Change::AddMember {
1095 role: "editors".to_string(),
1096 member: "user@example.com".to_string(),
1097 inherit: true,
1098 admin: false,
1099 },
1100 Change::RemoveMember {
1101 role: "editors".to_string(),
1102 member: "old@example.com".to_string(),
1103 },
1104 Change::TerminateSessions {
1105 role: "retired-role".to_string(),
1106 },
1107 Change::ReassignOwned {
1108 from_role: "retired-role".to_string(),
1109 to_role: "successor".to_string(),
1110 },
1111 Change::DropOwned {
1112 role: "retired-role".to_string(),
1113 },
1114 Change::DropRole {
1115 name: "retired-role".to_string(),
1116 },
1117 ]
1118 }
1119
1120 #[test]
1121 fn filter_authoritative_keeps_all_changes() {
1122 let changes = all_change_variants();
1123 let original_len = changes.len();
1124 let filtered = filter_changes(changes, ReconciliationMode::Authoritative);
1125 assert_eq!(filtered.len(), original_len);
1126 }
1127
1128 #[test]
1129 fn filter_additive_keeps_only_constructive_changes() {
1130 let filtered = filter_changes(all_change_variants(), ReconciliationMode::Additive);
1131
1132 assert_eq!(filtered.len(), 6);
1134
1135 for change in &filtered {
1137 assert!(
1138 !matches!(
1139 change,
1140 Change::Revoke { .. }
1141 | Change::RevokeDefaultPrivilege { .. }
1142 | Change::RemoveMember { .. }
1143 | Change::DropRole { .. }
1144 | Change::DropOwned { .. }
1145 | Change::ReassignOwned { .. }
1146 | Change::TerminateSessions { .. }
1147 ),
1148 "additive mode should not contain destructive change: {change:?}"
1149 );
1150 }
1151
1152 assert!(
1154 filtered
1155 .iter()
1156 .any(|c| matches!(c, Change::CreateRole { .. }))
1157 );
1158 assert!(
1159 filtered
1160 .iter()
1161 .any(|c| matches!(c, Change::AlterRole { .. }))
1162 );
1163 assert!(
1164 filtered
1165 .iter()
1166 .any(|c| matches!(c, Change::SetComment { .. }))
1167 );
1168 assert!(filtered.iter().any(|c| matches!(c, Change::Grant { .. })));
1169 assert!(
1170 filtered
1171 .iter()
1172 .any(|c| matches!(c, Change::SetDefaultPrivilege { .. }))
1173 );
1174 assert!(
1175 filtered
1176 .iter()
1177 .any(|c| matches!(c, Change::AddMember { .. }))
1178 );
1179 }
1180
1181 #[test]
1182 fn filter_adopt_keeps_revokes_but_not_drops() {
1183 let filtered = filter_changes(all_change_variants(), ReconciliationMode::Adopt);
1184
1185 assert_eq!(filtered.len(), 9);
1187
1188 for change in &filtered {
1190 assert!(
1191 !matches!(
1192 change,
1193 Change::DropRole { .. }
1194 | Change::DropOwned { .. }
1195 | Change::ReassignOwned { .. }
1196 | Change::TerminateSessions { .. }
1197 ),
1198 "adopt mode should not contain drop/retirement change: {change:?}"
1199 );
1200 }
1201
1202 assert!(filtered.iter().any(|c| matches!(c, Change::Revoke { .. })));
1204 assert!(
1205 filtered
1206 .iter()
1207 .any(|c| matches!(c, Change::RevokeDefaultPrivilege { .. }))
1208 );
1209 assert!(
1210 filtered
1211 .iter()
1212 .any(|c| matches!(c, Change::RemoveMember { .. }))
1213 );
1214 }
1215
1216 #[test]
1217 fn filter_additive_with_empty_input() {
1218 let filtered = filter_changes(vec![], ReconciliationMode::Additive);
1219 assert!(filtered.is_empty());
1220 }
1221
1222 #[test]
1223 fn filter_additive_only_destructive_changes_yields_empty() {
1224 let changes = vec![
1225 Change::Revoke {
1226 role: "r1".to_string(),
1227 privileges: BTreeSet::from([Privilege::Select]),
1228 object_type: ObjectType::Table,
1229 schema: Some("public".to_string()),
1230 name: Some("*".to_string()),
1231 },
1232 Change::DropRole {
1233 name: "old-role".to_string(),
1234 },
1235 ];
1236 let filtered = filter_changes(changes, ReconciliationMode::Additive);
1237 assert!(filtered.is_empty());
1238 }
1239
1240 #[test]
1241 fn filter_adopt_preserves_ordering() {
1242 let changes = vec![
1243 Change::CreateRole {
1244 name: "new-role".to_string(),
1245 state: RoleState::default(),
1246 },
1247 Change::Grant {
1248 role: "new-role".to_string(),
1249 privileges: BTreeSet::from([Privilege::Select]),
1250 object_type: ObjectType::Table,
1251 schema: Some("public".to_string()),
1252 name: Some("*".to_string()),
1253 },
1254 Change::Revoke {
1255 role: "existing-role".to_string(),
1256 privileges: BTreeSet::from([Privilege::Insert]),
1257 object_type: ObjectType::Table,
1258 schema: Some("public".to_string()),
1259 name: Some("*".to_string()),
1260 },
1261 Change::DropRole {
1262 name: "old-role".to_string(),
1263 },
1264 ];
1265
1266 let filtered = filter_changes(changes, ReconciliationMode::Adopt);
1267 assert_eq!(filtered.len(), 3);
1268 assert!(matches!(&filtered[0], Change::CreateRole { name, .. } if name == "new-role"));
1269 assert!(matches!(&filtered[1], Change::Grant { .. }));
1270 assert!(matches!(&filtered[2], Change::Revoke { .. }));
1271 }
1272
1273 #[test]
1274 fn reconciliation_mode_display() {
1275 assert_eq!(
1276 ReconciliationMode::Authoritative.to_string(),
1277 "authoritative"
1278 );
1279 assert_eq!(ReconciliationMode::Additive.to_string(), "additive");
1280 assert_eq!(ReconciliationMode::Adopt.to_string(), "adopt");
1281 }
1282
1283 #[test]
1284 fn reconciliation_mode_default_is_authoritative() {
1285 assert_eq!(
1286 ReconciliationMode::default(),
1287 ReconciliationMode::Authoritative
1288 );
1289 }
1290
1291 #[test]
1296 fn apply_role_retirements_inserts_cleanup_before_drop() {
1297 let changes = vec![
1298 Change::Grant {
1299 role: "analytics".to_string(),
1300 privileges: BTreeSet::from([Privilege::Select]),
1301 object_type: ObjectType::Table,
1302 schema: Some("public".to_string()),
1303 name: Some("*".to_string()),
1304 },
1305 Change::DropRole {
1306 name: "old-app".to_string(),
1307 },
1308 ];
1309
1310 let planned = apply_role_retirements(
1311 changes,
1312 &[crate::manifest::RoleRetirement {
1313 role: "old-app".to_string(),
1314 reassign_owned_to: Some("successor".to_string()),
1315 drop_owned: true,
1316 terminate_sessions: true,
1317 }],
1318 );
1319
1320 assert!(matches!(planned[0], Change::Grant { .. }));
1321 assert!(matches!(
1322 planned[1],
1323 Change::TerminateSessions { ref role } if role == "old-app"
1324 ));
1325 assert!(matches!(
1326 planned[2],
1327 Change::ReassignOwned {
1328 ref from_role,
1329 ref to_role
1330 } if from_role == "old-app" && to_role == "successor"
1331 ));
1332 assert!(matches!(
1333 planned[3],
1334 Change::DropOwned { ref role } if role == "old-app"
1335 ));
1336 assert!(matches!(
1337 planned[4],
1338 Change::DropRole { ref name } if name == "old-app"
1339 ));
1340 }
1341
1342 #[test]
1343 fn inject_password_for_new_role() {
1344 let changes = vec![Change::CreateRole {
1345 name: "app-svc".to_string(),
1346 state: RoleState::default(),
1347 }];
1348
1349 let mut passwords = std::collections::BTreeMap::new();
1350 passwords.insert("app-svc".to_string(), "secret123".to_string());
1351
1352 let result = inject_password_changes(changes, &passwords);
1353 assert_eq!(result.len(), 2);
1354 assert!(matches!(&result[0], Change::CreateRole { name, .. } if name == "app-svc"));
1355 assert!(
1356 matches!(&result[1], Change::SetPassword { name, password } if name == "app-svc" && password == "secret123")
1357 );
1358 }
1359
1360 #[test]
1361 fn inject_password_for_existing_role() {
1362 let changes = vec![Change::Grant {
1364 role: "app-svc".to_string(),
1365 privileges: BTreeSet::from([crate::manifest::Privilege::Select]),
1366 object_type: crate::manifest::ObjectType::Table,
1367 schema: Some("public".to_string()),
1368 name: Some("*".to_string()),
1369 }];
1370
1371 let mut passwords = std::collections::BTreeMap::new();
1372 passwords.insert("app-svc".to_string(), "secret123".to_string());
1373
1374 let result = inject_password_changes(changes, &passwords);
1375 assert_eq!(result.len(), 2);
1376 assert!(matches!(&result[0], Change::Grant { .. }));
1377 assert!(
1378 matches!(&result[1], Change::SetPassword { name, password } if name == "app-svc" && password == "secret123")
1379 );
1380 }
1381
1382 #[test]
1383 fn inject_password_empty_passwords_is_noop() {
1384 let changes = vec![Change::CreateRole {
1385 name: "app-svc".to_string(),
1386 state: RoleState::default(),
1387 }];
1388
1389 let passwords = std::collections::BTreeMap::new();
1390 let result = inject_password_changes(changes.clone(), &passwords);
1391 assert_eq!(result.len(), 1);
1392 }
1393
1394 #[test]
1395 fn resolve_passwords_missing_env_var() {
1396 let roles = vec![crate::manifest::RoleDefinition {
1397 name: "app-svc".to_string(),
1398 login: Some(true),
1399 password: Some(crate::manifest::PasswordSource {
1400 from_env: "PGROLES_TEST_MISSING_VAR_9a8b7c6d".to_string(),
1401 }),
1402 password_valid_until: None,
1403 superuser: None,
1404 createdb: None,
1405 createrole: None,
1406 inherit: None,
1407 replication: None,
1408 bypassrls: None,
1409 connection_limit: None,
1410 comment: None,
1411 }];
1412
1413 unsafe { std::env::remove_var("PGROLES_TEST_MISSING_VAR_9a8b7c6d") };
1416
1417 let result = resolve_passwords(&roles);
1418 assert!(result.is_err());
1419 let err = result.unwrap_err();
1420 assert!(
1421 matches!(err, PasswordResolutionError::MissingEnvVar { ref role, ref env_var }
1422 if role == "app-svc" && env_var == "PGROLES_TEST_MISSING_VAR_9a8b7c6d"),
1423 "expected MissingEnvVar, got: {err:?}"
1424 );
1425 }
1426
1427 #[test]
1428 fn resolve_passwords_empty_env_var() {
1429 let roles = vec![crate::manifest::RoleDefinition {
1430 name: "app-svc".to_string(),
1431 login: Some(true),
1432 password: Some(crate::manifest::PasswordSource {
1433 from_env: "PGROLES_TEST_EMPTY_VAR_1a2b3c4d".to_string(),
1434 }),
1435 password_valid_until: None,
1436 superuser: None,
1437 createdb: None,
1438 createrole: None,
1439 inherit: None,
1440 replication: None,
1441 bypassrls: None,
1442 connection_limit: None,
1443 comment: None,
1444 }];
1445
1446 unsafe { std::env::set_var("PGROLES_TEST_EMPTY_VAR_1a2b3c4d", "") };
1449
1450 let result = resolve_passwords(&roles);
1451
1452 unsafe { std::env::remove_var("PGROLES_TEST_EMPTY_VAR_1a2b3c4d") };
1454
1455 assert!(result.is_err());
1456 let err = result.unwrap_err();
1457 assert!(
1458 matches!(err, PasswordResolutionError::EmptyPassword { ref role, ref env_var }
1459 if role == "app-svc" && env_var == "PGROLES_TEST_EMPTY_VAR_1a2b3c4d"),
1460 "expected EmptyPassword, got: {err:?}"
1461 );
1462 }
1463
1464 #[test]
1465 fn resolve_passwords_happy_path() {
1466 let roles = vec![crate::manifest::RoleDefinition {
1467 name: "app-svc".to_string(),
1468 login: Some(true),
1469 password: Some(crate::manifest::PasswordSource {
1470 from_env: "PGROLES_TEST_RESOLVE_VAR_5e6f7g8h".to_string(),
1471 }),
1472 password_valid_until: None,
1473 superuser: None,
1474 createdb: None,
1475 createrole: None,
1476 inherit: None,
1477 replication: None,
1478 bypassrls: None,
1479 connection_limit: None,
1480 comment: None,
1481 }];
1482
1483 unsafe { std::env::set_var("PGROLES_TEST_RESOLVE_VAR_5e6f7g8h", "my_secret_pw") };
1485
1486 let result = resolve_passwords(&roles);
1487
1488 unsafe { std::env::remove_var("PGROLES_TEST_RESOLVE_VAR_5e6f7g8h") };
1489
1490 let resolved = result.expect("should succeed");
1491 assert_eq!(resolved.len(), 1);
1492 assert_eq!(resolved["app-svc"], "my_secret_pw");
1493 }
1494
1495 #[test]
1496 fn resolve_passwords_skips_roles_without_password() {
1497 let roles = vec![crate::manifest::RoleDefinition {
1498 name: "no-password".to_string(),
1499 login: Some(true),
1500 password: None,
1501 password_valid_until: None,
1502 superuser: None,
1503 createdb: None,
1504 createrole: None,
1505 inherit: None,
1506 replication: None,
1507 bypassrls: None,
1508 connection_limit: None,
1509 comment: None,
1510 }];
1511
1512 let result = resolve_passwords(&roles);
1513 let resolved = result.expect("should succeed");
1514 assert!(resolved.is_empty());
1515 }
1516
1517 #[test]
1518 fn inject_password_multiple_roles() {
1519 let changes = vec![
1520 Change::CreateRole {
1521 name: "role-a".to_string(),
1522 state: RoleState::default(),
1523 },
1524 Change::CreateRole {
1525 name: "role-b".to_string(),
1526 state: RoleState::default(),
1527 },
1528 Change::Grant {
1529 role: "role-c".to_string(),
1530 privileges: BTreeSet::from([crate::manifest::Privilege::Select]),
1531 object_type: crate::manifest::ObjectType::Table,
1532 schema: Some("public".to_string()),
1533 name: Some("*".to_string()),
1534 },
1535 ];
1536
1537 let mut passwords = std::collections::BTreeMap::new();
1538 passwords.insert("role-a".to_string(), "pw-a".to_string());
1539 passwords.insert("role-b".to_string(), "pw-b".to_string());
1540 passwords.insert("role-c".to_string(), "pw-c".to_string());
1541
1542 let result = inject_password_changes(changes, &passwords);
1543
1544 assert_eq!(result.len(), 6, "expected 6 changes, got: {result:?}");
1548 assert!(matches!(&result[0], Change::CreateRole { name, .. } if name == "role-a"));
1549 assert!(matches!(&result[1], Change::SetPassword { name, .. } if name == "role-a"));
1550 assert!(matches!(&result[2], Change::CreateRole { name, .. } if name == "role-b"));
1551 assert!(matches!(&result[3], Change::SetPassword { name, .. } if name == "role-b"));
1552 assert!(matches!(&result[4], Change::Grant { .. }));
1553 assert!(matches!(&result[5], Change::SetPassword { name, .. } if name == "role-c"));
1554 }
1555
1556 #[test]
1557 fn diff_detects_valid_until_change() {
1558 let mut current = empty_graph();
1559 current.roles.insert(
1560 "r1".to_string(),
1561 RoleState {
1562 login: true,
1563 ..RoleState::default()
1564 },
1565 );
1566
1567 let mut desired = empty_graph();
1568 desired.roles.insert(
1569 "r1".to_string(),
1570 RoleState {
1571 login: true,
1572 password_valid_until: Some("2025-12-31T00:00:00Z".to_string()),
1573 ..RoleState::default()
1574 },
1575 );
1576
1577 let changes = diff(¤t, &desired);
1578 assert_eq!(changes.len(), 1);
1579 match &changes[0] {
1580 Change::AlterRole { name, attributes } => {
1581 assert_eq!(name, "r1");
1582 assert!(attributes.contains(&RoleAttribute::ValidUntil(Some(
1583 "2025-12-31T00:00:00Z".to_string()
1584 ))));
1585 }
1586 other => panic!("expected AlterRole, got: {other:?}"),
1587 }
1588 }
1589
1590 #[test]
1591 fn diff_detects_valid_until_removal() {
1592 let mut current = empty_graph();
1593 current.roles.insert(
1594 "r1".to_string(),
1595 RoleState {
1596 login: true,
1597 password_valid_until: Some("2025-12-31T00:00:00Z".to_string()),
1598 ..RoleState::default()
1599 },
1600 );
1601
1602 let mut desired = empty_graph();
1603 desired.roles.insert(
1604 "r1".to_string(),
1605 RoleState {
1606 login: true,
1607 ..RoleState::default()
1608 },
1609 );
1610
1611 let changes = diff(¤t, &desired);
1612 assert_eq!(changes.len(), 1);
1613 match &changes[0] {
1614 Change::AlterRole { name, attributes } => {
1615 assert_eq!(name, "r1");
1616 assert!(attributes.contains(&RoleAttribute::ValidUntil(None)));
1617 }
1618 other => panic!("expected AlterRole, got: {other:?}"),
1619 }
1620 }
1621}