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