1use std::collections::BTreeSet;
12
13use crate::manifest::{ObjectType, Privilege, RoleRetirement};
14use crate::model::{
15 DefaultPrivKey, GrantKey, MembershipEdge, RoleAttribute, RoleGraph, RoleState,
16 default_schema_owner_privileges,
17};
18
19#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
36pub enum Change {
37 CreateRole { name: String, state: RoleState },
39
40 CreateSchema { name: String, owner: Option<String> },
42
43 AlterSchemaOwner { name: String, owner: String },
45
46 EnsureSchemaOwnerPrivileges {
48 name: String,
49 owner: String,
50 privileges: BTreeSet<Privilege>,
51 },
52
53 AlterRole {
55 name: String,
56 attributes: Vec<RoleAttribute>,
57 },
58
59 SetComment {
61 name: String,
62 comment: Option<String>,
63 },
64
65 Grant {
67 role: String,
68 privileges: BTreeSet<Privilege>,
69 object_type: ObjectType,
70 schema: Option<String>,
71 name: Option<String>,
72 },
73
74 Revoke {
76 role: String,
77 privileges: BTreeSet<Privilege>,
78 object_type: ObjectType,
79 schema: Option<String>,
80 name: Option<String>,
81 },
82
83 SetDefaultPrivilege {
85 owner: String,
86 schema: String,
87 on_type: ObjectType,
88 grantee: String,
89 privileges: BTreeSet<Privilege>,
90 },
91
92 RevokeDefaultPrivilege {
94 owner: String,
95 schema: String,
96 on_type: ObjectType,
97 grantee: String,
98 privileges: BTreeSet<Privilege>,
99 },
100
101 AddMember {
103 role: String,
104 member: String,
105 inherit: bool,
106 admin: bool,
107 },
108
109 RemoveMember { role: String, member: String },
111
112 ReassignOwned { from_role: String, to_role: String },
114
115 DropOwned { role: String },
117
118 TerminateSessions { role: String },
120
121 SetPassword { name: String, password: String },
131
132 DropRole { name: String },
134}
135
136#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, serde::Serialize)]
146pub enum ReconciliationMode {
147 #[default]
153 Authoritative,
154
155 Additive,
166
167 Adopt,
178}
179
180impl std::fmt::Display for ReconciliationMode {
181 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
182 match self {
183 ReconciliationMode::Authoritative => write!(f, "authoritative"),
184 ReconciliationMode::Additive => write!(f, "additive"),
185 ReconciliationMode::Adopt => write!(f, "adopt"),
186 }
187 }
188}
189
190pub fn filter_changes(changes: Vec<Change>, mode: ReconciliationMode) -> Vec<Change> {
198 match mode {
199 ReconciliationMode::Authoritative => changes,
200 ReconciliationMode::Additive => filter_additive_changes(changes),
201 ReconciliationMode::Adopt => changes
202 .into_iter()
203 .filter(|change| !is_role_drop_or_retirement(change))
204 .collect(),
205 }
206}
207
208fn filter_additive_changes(changes: Vec<Change>) -> Vec<Change> {
209 let skipped_owner_transfers: BTreeSet<(String, String)> = changes
210 .iter()
211 .filter_map(|change| match change {
212 Change::AlterSchemaOwner { name, owner } => Some((name.clone(), owner.clone())),
213 _ => None,
214 })
215 .collect();
216
217 changes
218 .into_iter()
219 .filter(|change| match change {
220 Change::EnsureSchemaOwnerPrivileges { name, owner, .. } => {
221 !skipped_owner_transfers.contains(&(name.clone(), owner.clone()))
222 }
223 Change::SetDefaultPrivilege { schema, owner, .. } => {
224 !skipped_owner_transfers.contains(&(schema.clone(), owner.clone()))
225 }
226 Change::AlterRole { .. } | Change::SetComment { .. } => false,
227 _ => !is_destructive(change),
228 })
229 .collect()
230}
231
232fn is_destructive(change: &Change) -> bool {
234 matches!(
235 change,
236 Change::AlterSchemaOwner { .. }
237 | Change::Revoke { .. }
238 | Change::RevokeDefaultPrivilege { .. }
239 | Change::RemoveMember { .. }
240 | Change::DropRole { .. }
241 | Change::DropOwned { .. }
242 | Change::ReassignOwned { .. }
243 | Change::TerminateSessions { .. }
244 )
245}
246
247fn is_role_drop_or_retirement(change: &Change) -> bool {
249 matches!(
250 change,
251 Change::DropRole { .. }
252 | Change::DropOwned { .. }
253 | Change::ReassignOwned { .. }
254 | Change::TerminateSessions { .. }
255 )
256}
257
258pub fn diff(current: &RoleGraph, desired: &RoleGraph) -> Vec<Change> {
267 let mut creates = Vec::new();
268 let mut alters = Vec::new();
269 let mut schema_changes = Vec::new();
270 let mut schema_grants = Vec::new();
271 let mut grants = Vec::new();
272 let mut set_defaults = Vec::new();
273 let mut add_members = Vec::new();
274 let mut remove_members = Vec::new();
275 let mut revoke_defaults = Vec::new();
276 let mut revokes = Vec::new();
277 let mut drops = Vec::new();
278
279 for (name, desired_state) in &desired.roles {
283 match current.roles.get(name) {
284 None => {
285 creates.push(Change::CreateRole {
286 name: name.clone(),
287 state: desired_state.clone(),
288 });
289 }
290 Some(current_state) => {
291 let attribute_changes = current_state.changed_attributes(desired_state);
293 if !attribute_changes.is_empty() {
294 alters.push(Change::AlterRole {
295 name: name.clone(),
296 attributes: attribute_changes,
297 });
298 }
299 if current_state.comment != desired_state.comment {
301 alters.push(Change::SetComment {
302 name: name.clone(),
303 comment: desired_state.comment.clone(),
304 });
305 }
306 }
307 }
308 }
309
310 for name in current.roles.keys() {
312 if !desired.roles.contains_key(name) {
313 drops.push(Change::DropRole { name: name.clone() });
314 }
315 }
316
317 diff_schemas(current, desired, &mut schema_changes, &mut schema_grants);
320
321 diff_grants(current, desired, &mut grants, &mut revokes);
324
325 diff_default_privileges(current, desired, &mut set_defaults, &mut revoke_defaults);
328
329 diff_memberships(current, desired, &mut add_members, &mut remove_members);
332
333 let mut changes = Vec::new();
335 changes.extend(creates);
336 changes.extend(alters);
337 changes.extend(schema_changes);
338 changes.extend(schema_grants);
339 changes.extend(grants);
340 changes.extend(set_defaults);
341 changes.extend(remove_members);
342 changes.extend(add_members);
343 changes.extend(revoke_defaults);
344 changes.extend(revokes);
345 changes.extend(drops);
346 changes
347}
348
349fn diff_schemas(
350 current: &RoleGraph,
351 desired: &RoleGraph,
352 schema_out: &mut Vec<Change>,
353 grant_out: &mut Vec<Change>,
354) {
355 for (name, desired_state) in &desired.schemas {
356 match current.schemas.get(name) {
357 None => schema_out.push(Change::CreateSchema {
358 name: name.clone(),
359 owner: desired_state.owner.clone(),
360 }),
361 Some(current_state) => {
362 if current_state.owner != desired_state.owner
363 && let Some(owner) = &desired_state.owner
364 {
365 schema_out.push(Change::AlterSchemaOwner {
366 name: name.clone(),
367 owner: owner.clone(),
368 });
369 }
370 }
371 }
372
373 let Some(owner) = desired_state.owner.as_deref() else {
374 continue;
375 };
376
377 if !current.schemas.contains_key(name) {
378 continue;
379 }
380
381 let expected_privileges = default_schema_owner_privileges(owner);
382 let current_privileges = current
383 .schemas
384 .get(name)
385 .map(|state| state.owner_privileges.clone())
386 .unwrap_or_default();
387 let missing_privileges: BTreeSet<Privilege> = expected_privileges
388 .difference(¤t_privileges)
389 .copied()
390 .collect();
391
392 if !missing_privileges.is_empty() {
393 grant_out.push(Change::EnsureSchemaOwnerPrivileges {
394 name: name.clone(),
395 owner: owner.to_string(),
396 privileges: missing_privileges,
397 });
398 }
399 }
400}
401
402pub fn apply_role_retirements(changes: Vec<Change>, retirements: &[RoleRetirement]) -> Vec<Change> {
408 if retirements.is_empty() {
409 return changes;
410 }
411
412 let retirement_by_role: std::collections::BTreeMap<&str, &RoleRetirement> = retirements
413 .iter()
414 .map(|retirement| (retirement.role.as_str(), retirement))
415 .collect();
416
417 let mut planned = Vec::with_capacity(changes.len());
418 for change in changes {
419 if let Change::DropRole { name } = &change
420 && let Some(retirement) = retirement_by_role.get(name.as_str())
421 {
422 if retirement.terminate_sessions {
423 planned.push(Change::TerminateSessions { role: name.clone() });
424 }
425 if let Some(successor) = &retirement.reassign_owned_to {
426 planned.push(Change::ReassignOwned {
427 from_role: name.clone(),
428 to_role: successor.clone(),
429 });
430 }
431 if retirement.drop_owned {
432 planned.push(Change::DropOwned { role: name.clone() });
433 }
434 }
435 planned.push(change);
436 }
437
438 planned
439}
440
441pub fn resolve_passwords(
451 roles: &[crate::manifest::RoleDefinition],
452) -> Result<std::collections::BTreeMap<String, String>, PasswordResolutionError> {
453 let mut resolved = std::collections::BTreeMap::new();
454 for role in roles {
455 if let Some(source) = &role.password {
456 let value = std::env::var(&source.from_env).map_err(|_| {
457 PasswordResolutionError::MissingEnvVar {
458 role: role.name.clone(),
459 env_var: source.from_env.clone(),
460 }
461 })?;
462 if value.is_empty() {
463 return Err(PasswordResolutionError::EmptyPassword {
464 role: role.name.clone(),
465 env_var: source.from_env.clone(),
466 });
467 }
468 resolved.insert(role.name.clone(), value);
469 }
470 }
471 Ok(resolved)
472}
473
474#[derive(Debug, thiserror::Error)]
476pub enum PasswordResolutionError {
477 #[error("environment variable \"{env_var}\" for role \"{role}\" password is not set")]
478 MissingEnvVar { role: String, env_var: String },
479
480 #[error("environment variable \"{env_var}\" for role \"{role}\" password is empty")]
481 EmptyPassword { role: String, env_var: String },
482}
483
484pub fn inject_password_changes(
497 changes: Vec<Change>,
498 resolved_passwords: &std::collections::BTreeMap<String, String>,
499) -> Vec<Change> {
500 if resolved_passwords.is_empty() {
501 return changes;
502 }
503
504 let created_roles: std::collections::BTreeSet<String> = changes
506 .iter()
507 .filter_map(|c| match c {
508 Change::CreateRole { name, .. } => Some(name.clone()),
509 _ => None,
510 })
511 .collect();
512
513 let mut result = Vec::with_capacity(changes.len() + resolved_passwords.len());
514
515 for change in changes {
517 if let Change::CreateRole { ref name, .. } = change
518 && let Some(password) = resolved_passwords.get(name.as_str())
519 {
520 let role_name = name.clone();
521 let verifier =
522 crate::scram::compute_verifier(password, crate::scram::DEFAULT_ITERATIONS);
523 result.push(change);
524 result.push(Change::SetPassword {
525 name: role_name,
526 password: verifier,
527 });
528 continue;
529 }
530 result.push(change);
531 }
532
533 for (role_name, password) in resolved_passwords {
535 if !created_roles.contains(role_name) {
536 let verifier =
537 crate::scram::compute_verifier(password, crate::scram::DEFAULT_ITERATIONS);
538 result.push(Change::SetPassword {
539 name: role_name.clone(),
540 password: verifier,
541 });
542 }
543 }
544
545 result
546}
547
548fn diff_grants(
553 current: &RoleGraph,
554 desired: &RoleGraph,
555 grants_out: &mut Vec<Change>,
556 revokes_out: &mut Vec<Change>,
557) {
558 for (key, desired_state) in &desired.grants {
561 match current.grants.get(key) {
562 None => {
563 grants_out.push(change_grant(key, &desired_state.privileges));
565 }
566 Some(current_state) => {
567 let to_add: BTreeSet<Privilege> = desired_state
569 .privileges
570 .difference(¤t_state.privileges)
571 .copied()
572 .collect();
573 let to_remove: BTreeSet<Privilege> = current_state
574 .privileges
575 .difference(&desired_state.privileges)
576 .copied()
577 .collect();
578
579 if !to_add.is_empty() {
580 grants_out.push(change_grant(key, &to_add));
581 }
582 if !to_remove.is_empty() {
583 revokes_out.push(change_revoke(key, &to_remove));
584 }
585 }
586 }
587 }
588
589 for (key, current_state) in ¤t.grants {
591 if !desired.grants.contains_key(key) {
592 revokes_out.push(change_revoke(key, ¤t_state.privileges));
593 }
594 }
595}
596
597fn change_grant(key: &GrantKey, privileges: &BTreeSet<Privilege>) -> Change {
598 Change::Grant {
599 role: key.role.clone(),
600 privileges: privileges.clone(),
601 object_type: key.object_type,
602 schema: key.schema.clone(),
603 name: key.name.clone(),
604 }
605}
606
607fn change_revoke(key: &GrantKey, privileges: &BTreeSet<Privilege>) -> Change {
608 Change::Revoke {
609 role: key.role.clone(),
610 privileges: privileges.clone(),
611 object_type: key.object_type,
612 schema: key.schema.clone(),
613 name: key.name.clone(),
614 }
615}
616
617fn diff_default_privileges(
622 current: &RoleGraph,
623 desired: &RoleGraph,
624 set_out: &mut Vec<Change>,
625 revoke_out: &mut Vec<Change>,
626) {
627 for (key, desired_state) in &desired.default_privileges {
628 match current.default_privileges.get(key) {
629 None => {
630 set_out.push(change_set_default(key, &desired_state.privileges));
631 }
632 Some(current_state) => {
633 let to_add: BTreeSet<Privilege> = desired_state
634 .privileges
635 .difference(¤t_state.privileges)
636 .copied()
637 .collect();
638 let to_remove: BTreeSet<Privilege> = current_state
639 .privileges
640 .difference(&desired_state.privileges)
641 .copied()
642 .collect();
643
644 if !to_add.is_empty() {
645 set_out.push(change_set_default(key, &to_add));
646 }
647 if !to_remove.is_empty() {
648 revoke_out.push(change_revoke_default(key, &to_remove));
649 }
650 }
651 }
652 }
653
654 for (key, current_state) in ¤t.default_privileges {
655 if !desired.default_privileges.contains_key(key) {
656 revoke_out.push(change_revoke_default(key, ¤t_state.privileges));
657 }
658 }
659}
660
661fn change_set_default(key: &DefaultPrivKey, privileges: &BTreeSet<Privilege>) -> Change {
662 Change::SetDefaultPrivilege {
663 owner: key.owner.clone(),
664 schema: key.schema.clone(),
665 on_type: key.on_type,
666 grantee: key.grantee.clone(),
667 privileges: privileges.clone(),
668 }
669}
670
671fn change_revoke_default(key: &DefaultPrivKey, privileges: &BTreeSet<Privilege>) -> Change {
672 Change::RevokeDefaultPrivilege {
673 owner: key.owner.clone(),
674 schema: key.schema.clone(),
675 on_type: key.on_type,
676 grantee: key.grantee.clone(),
677 privileges: privileges.clone(),
678 }
679}
680
681fn diff_memberships(
686 current: &RoleGraph,
687 desired: &RoleGraph,
688 add_out: &mut Vec<Change>,
689 remove_out: &mut Vec<Change>,
690) {
691 let current_map: std::collections::BTreeMap<(&str, &str), &MembershipEdge> = current
696 .memberships
697 .iter()
698 .map(|edge| ((edge.role.as_str(), edge.member.as_str()), edge))
699 .collect();
700 let desired_map: std::collections::BTreeMap<(&str, &str), &MembershipEdge> = desired
701 .memberships
702 .iter()
703 .map(|edge| ((edge.role.as_str(), edge.member.as_str()), edge))
704 .collect();
705
706 for (&(role, member), &desired_edge) in &desired_map {
709 match current_map.get(&(role, member)) {
710 None => {
711 add_out.push(Change::AddMember {
712 role: desired_edge.role.clone(),
713 member: desired_edge.member.clone(),
714 inherit: desired_edge.inherit,
715 admin: desired_edge.admin,
716 });
717 }
718 Some(current_edge) => {
719 if current_edge.inherit != desired_edge.inherit
720 || current_edge.admin != desired_edge.admin
721 {
722 remove_out.push(Change::RemoveMember {
724 role: current_edge.role.clone(),
725 member: current_edge.member.clone(),
726 });
727 add_out.push(Change::AddMember {
728 role: desired_edge.role.clone(),
729 member: desired_edge.member.clone(),
730 inherit: desired_edge.inherit,
731 admin: desired_edge.admin,
732 });
733 }
734 }
735 }
736 }
737
738 for &(role, member) in current_map.keys() {
740 if !desired_map.contains_key(&(role, member)) {
741 remove_out.push(Change::RemoveMember {
742 role: role.to_string(),
743 member: member.to_string(),
744 });
745 }
746 }
747}
748
749#[cfg(test)]
754mod tests {
755 use super::*;
756 use crate::model::{
757 DefaultPrivState, GrantState, SchemaState, default_schema_owner_privileges,
758 };
759
760 fn empty_graph() -> RoleGraph {
762 RoleGraph::default()
763 }
764
765 fn managed_schema(owner: &str) -> SchemaState {
766 SchemaState {
767 owner: Some(owner.to_string()),
768 owner_privileges: default_schema_owner_privileges(owner),
769 }
770 }
771
772 #[test]
773 fn diff_empty_to_empty_is_empty() {
774 let changes = diff(&empty_graph(), &empty_graph());
775 assert!(changes.is_empty());
776 }
777
778 #[test]
779 fn diff_creates_new_roles() {
780 let current = empty_graph();
781 let mut desired = empty_graph();
782 desired
783 .roles
784 .insert("new-role".to_string(), RoleState::default());
785
786 let changes = diff(¤t, &desired);
787 assert_eq!(changes.len(), 1);
788 assert!(matches!(&changes[0], Change::CreateRole { name, .. } if name == "new-role"));
789 }
790
791 #[test]
792 fn diff_drops_removed_roles() {
793 let mut current = empty_graph();
794 current
795 .roles
796 .insert("old-role".to_string(), RoleState::default());
797 let desired = empty_graph();
798
799 let changes = diff(¤t, &desired);
800 assert_eq!(changes.len(), 1);
801 assert!(matches!(&changes[0], Change::DropRole { name } if name == "old-role"));
802 }
803
804 #[test]
805 fn diff_alters_changed_role_attributes() {
806 let mut current = empty_graph();
807 current
808 .roles
809 .insert("role1".to_string(), RoleState::default());
810
811 let mut desired = empty_graph();
812 desired.roles.insert(
813 "role1".to_string(),
814 RoleState {
815 login: true,
816 ..RoleState::default()
817 },
818 );
819
820 let changes = diff(¤t, &desired);
821 assert_eq!(changes.len(), 1);
822 match &changes[0] {
823 Change::AlterRole { name, attributes } => {
824 assert_eq!(name, "role1");
825 assert!(attributes.contains(&RoleAttribute::Login(true)));
826 }
827 other => panic!("expected AlterRole, got: {other:?}"),
828 }
829 }
830
831 #[test]
832 fn diff_creates_missing_schema() {
833 let current = empty_graph();
834 let mut desired = empty_graph();
835 desired
836 .schemas
837 .insert("inventory".to_string(), managed_schema("inventory_owner"));
838
839 let changes = diff(¤t, &desired);
840 assert_eq!(changes.len(), 1);
841 assert!(matches!(
842 &changes[0],
843 Change::CreateSchema { name, owner }
844 if name == "inventory" && owner.as_deref() == Some("inventory_owner")
845 ));
846 }
847
848 #[test]
849 fn diff_alters_schema_owner_when_different() {
850 let mut current = empty_graph();
851 current
852 .schemas
853 .insert("inventory".to_string(), managed_schema("old_owner"));
854
855 let mut desired = empty_graph();
856 desired
857 .schemas
858 .insert("inventory".to_string(), managed_schema("new_owner"));
859
860 let changes = diff(¤t, &desired);
861 assert_eq!(changes.len(), 1);
862 assert!(matches!(
863 &changes[0],
864 Change::AlterSchemaOwner { name, owner }
865 if name == "inventory" && owner == "new_owner"
866 ));
867 }
868
869 #[test]
870 fn diff_does_not_alter_schema_owner_when_unmanaged() {
871 let mut current = empty_graph();
872 current
873 .schemas
874 .insert("inventory".to_string(), managed_schema("old_owner"));
875
876 let mut desired = empty_graph();
877 desired.schemas.insert(
878 "inventory".to_string(),
879 SchemaState {
880 owner: None,
881 owner_privileges: BTreeSet::new(),
882 },
883 );
884
885 let changes = diff(¤t, &desired);
886 assert!(changes.is_empty());
887 }
888
889 #[test]
890 fn diff_restores_missing_owner_schema_privileges() {
891 let mut current = empty_graph();
892 current.schemas.insert(
893 "inventory".to_string(),
894 SchemaState {
895 owner: Some("inventory_owner".to_string()),
896 owner_privileges: BTreeSet::from([Privilege::Usage]),
897 },
898 );
899
900 let mut desired = empty_graph();
901 desired
902 .schemas
903 .insert("inventory".to_string(), managed_schema("inventory_owner"));
904
905 let changes = diff(¤t, &desired);
906 assert_eq!(changes.len(), 1);
907 assert!(matches!(
908 &changes[0],
909 Change::EnsureSchemaOwnerPrivileges {
910 name,
911 owner,
912 privileges,
913 } if name == "inventory"
914 && owner == "inventory_owner"
915 && privileges == &BTreeSet::from([Privilege::Create])
916 ));
917 }
918
919 #[test]
920 fn diff_restores_owner_schema_privileges_after_transfer() {
921 let mut current = empty_graph();
922 current.schemas.insert(
923 "inventory".to_string(),
924 SchemaState {
925 owner: Some("old_owner".to_string()),
926 owner_privileges: BTreeSet::from([Privilege::Usage]),
927 },
928 );
929
930 let mut desired = empty_graph();
931 desired
932 .schemas
933 .insert("inventory".to_string(), managed_schema("new_owner"));
934
935 let changes = diff(¤t, &desired);
936 assert_eq!(changes.len(), 2);
937 assert!(matches!(
938 &changes[0],
939 Change::AlterSchemaOwner { name, owner }
940 if name == "inventory" && owner == "new_owner"
941 ));
942 assert!(matches!(
943 &changes[1],
944 Change::EnsureSchemaOwnerPrivileges {
945 name,
946 owner,
947 privileges,
948 } if name == "inventory"
949 && owner == "new_owner"
950 && privileges == &BTreeSet::from([Privilege::Create])
951 ));
952 }
953
954 #[test]
955 fn diff_grants_new_privileges() {
956 let current = empty_graph();
957 let mut desired = empty_graph();
958 let key = GrantKey {
959 role: "r1".to_string(),
960 object_type: ObjectType::Table,
961 schema: Some("public".to_string()),
962 name: Some("*".to_string()),
963 };
964 desired.grants.insert(
965 key,
966 GrantState {
967 privileges: BTreeSet::from([Privilege::Select, Privilege::Insert]),
968 },
969 );
970
971 let changes = diff(¤t, &desired);
972 assert_eq!(changes.len(), 1);
973 match &changes[0] {
974 Change::Grant {
975 role, privileges, ..
976 } => {
977 assert_eq!(role, "r1");
978 assert!(privileges.contains(&Privilege::Select));
979 assert!(privileges.contains(&Privilege::Insert));
980 }
981 other => panic!("expected Grant, got: {other:?}"),
982 }
983 }
984
985 #[test]
986 fn diff_revokes_removed_privileges() {
987 let mut current = empty_graph();
988 let key = GrantKey {
989 role: "r1".to_string(),
990 object_type: ObjectType::Table,
991 schema: Some("public".to_string()),
992 name: Some("*".to_string()),
993 };
994 current.grants.insert(
995 key.clone(),
996 GrantState {
997 privileges: BTreeSet::from([Privilege::Select, Privilege::Insert]),
998 },
999 );
1000
1001 let mut desired = empty_graph();
1002 desired.grants.insert(
1003 key,
1004 GrantState {
1005 privileges: BTreeSet::from([Privilege::Select]),
1006 },
1007 );
1008
1009 let changes = diff(¤t, &desired);
1010 assert_eq!(changes.len(), 1);
1011 match &changes[0] {
1012 Change::Revoke {
1013 role, privileges, ..
1014 } => {
1015 assert_eq!(role, "r1");
1016 assert!(privileges.contains(&Privilege::Insert));
1017 assert!(!privileges.contains(&Privilege::Select));
1018 }
1019 other => panic!("expected Revoke, got: {other:?}"),
1020 }
1021 }
1022
1023 #[test]
1024 fn diff_revokes_entire_grant_target_when_absent_from_desired() {
1025 let mut current = empty_graph();
1026 let key = GrantKey {
1027 role: "r1".to_string(),
1028 object_type: ObjectType::Schema,
1029 schema: None,
1030 name: Some("myschema".to_string()),
1031 };
1032 current.grants.insert(
1033 key,
1034 GrantState {
1035 privileges: BTreeSet::from([Privilege::Usage]),
1036 },
1037 );
1038 let desired = empty_graph();
1039
1040 let changes = diff(¤t, &desired);
1041 assert_eq!(changes.len(), 1);
1042 assert!(matches!(&changes[0], Change::Revoke { role, .. } if role == "r1"));
1043 }
1044
1045 #[test]
1046 fn diff_adds_memberships() {
1047 let current = empty_graph();
1048 let mut desired = empty_graph();
1049 desired.memberships.insert(MembershipEdge {
1050 role: "editors".to_string(),
1051 member: "user@example.com".to_string(),
1052 inherit: true,
1053 admin: false,
1054 });
1055
1056 let changes = diff(¤t, &desired);
1057 assert_eq!(changes.len(), 1);
1058 match &changes[0] {
1059 Change::AddMember {
1060 role,
1061 member,
1062 inherit,
1063 admin,
1064 } => {
1065 assert_eq!(role, "editors");
1066 assert_eq!(member, "user@example.com");
1067 assert!(*inherit);
1068 assert!(!admin);
1069 }
1070 other => panic!("expected AddMember, got: {other:?}"),
1071 }
1072 }
1073
1074 #[test]
1075 fn diff_removes_memberships() {
1076 let mut current = empty_graph();
1077 current.memberships.insert(MembershipEdge {
1078 role: "editors".to_string(),
1079 member: "old@example.com".to_string(),
1080 inherit: true,
1081 admin: false,
1082 });
1083 let desired = empty_graph();
1084
1085 let changes = diff(¤t, &desired);
1086 assert_eq!(changes.len(), 1);
1087 assert!(
1088 matches!(&changes[0], Change::RemoveMember { role, member } if role == "editors" && member == "old@example.com")
1089 );
1090 }
1091
1092 #[test]
1093 fn diff_re_grants_membership_when_flags_change() {
1094 let mut current = empty_graph();
1095 current.memberships.insert(MembershipEdge {
1096 role: "editors".to_string(),
1097 member: "user@example.com".to_string(),
1098 inherit: true,
1099 admin: false,
1100 });
1101
1102 let mut desired = empty_graph();
1103 desired.memberships.insert(MembershipEdge {
1104 role: "editors".to_string(),
1105 member: "user@example.com".to_string(),
1106 inherit: true,
1107 admin: true, });
1109
1110 let changes = diff(¤t, &desired);
1111 assert_eq!(changes.len(), 2);
1113 assert!(matches!(
1114 &changes[0],
1115 Change::RemoveMember { role, member }
1116 if role == "editors" && member == "user@example.com"
1117 ));
1118 assert!(matches!(
1119 &changes[1],
1120 Change::AddMember {
1121 role,
1122 member,
1123 admin: true,
1124 ..
1125 } if role == "editors" && member == "user@example.com"
1126 ));
1127 }
1128
1129 #[test]
1130 fn diff_default_privileges_add_and_revoke() {
1131 let mut current = empty_graph();
1132 let key = DefaultPrivKey {
1133 owner: "app_owner".to_string(),
1134 schema: "inventory".to_string(),
1135 on_type: ObjectType::Table,
1136 grantee: "inventory-editor".to_string(),
1137 };
1138 current.default_privileges.insert(
1139 key.clone(),
1140 DefaultPrivState {
1141 privileges: BTreeSet::from([Privilege::Select, Privilege::Delete]),
1142 },
1143 );
1144
1145 let mut desired = empty_graph();
1146 desired.default_privileges.insert(
1147 key,
1148 DefaultPrivState {
1149 privileges: BTreeSet::from([Privilege::Select, Privilege::Insert]),
1150 },
1151 );
1152
1153 let changes = diff(¤t, &desired);
1154 assert_eq!(changes.len(), 2);
1156 assert!(changes.iter().any(|c| matches!(
1157 c,
1158 Change::SetDefaultPrivilege { privileges, .. } if privileges.contains(&Privilege::Insert)
1159 )));
1160 assert!(changes.iter().any(|c| matches!(
1161 c,
1162 Change::RevokeDefaultPrivilege { privileges, .. } if privileges.contains(&Privilege::Delete)
1163 )));
1164 }
1165
1166 #[test]
1167 fn diff_ordering_creates_before_drops() {
1168 let mut current = empty_graph();
1169 current
1170 .roles
1171 .insert("old-role".to_string(), RoleState::default());
1172
1173 let mut desired = empty_graph();
1174 desired
1175 .roles
1176 .insert("new-role".to_string(), RoleState::default());
1177
1178 let changes = diff(¤t, &desired);
1179 assert_eq!(changes.len(), 2);
1180
1181 let create_idx = changes
1183 .iter()
1184 .position(|c| matches!(c, Change::CreateRole { .. }))
1185 .unwrap();
1186 let schema_idx = changes
1187 .iter()
1188 .position(|c| matches!(c, Change::CreateSchema { .. }))
1189 .unwrap_or(create_idx);
1190 let drop_idx = changes
1191 .iter()
1192 .position(|c| matches!(c, Change::DropRole { .. }))
1193 .unwrap();
1194 assert!(create_idx <= schema_idx);
1195 assert!(schema_idx < drop_idx);
1196 }
1197
1198 #[test]
1199 fn diff_identical_graphs_produce_no_changes() {
1200 let mut graph = empty_graph();
1201 graph
1202 .roles
1203 .insert("role1".to_string(), RoleState::default());
1204 graph.grants.insert(
1205 GrantKey {
1206 role: "role1".to_string(),
1207 object_type: ObjectType::Table,
1208 schema: Some("public".to_string()),
1209 name: Some("*".to_string()),
1210 },
1211 GrantState {
1212 privileges: BTreeSet::from([Privilege::Select]),
1213 },
1214 );
1215 graph.memberships.insert(MembershipEdge {
1216 role: "role1".to_string(),
1217 member: "user@example.com".to_string(),
1218 inherit: true,
1219 admin: false,
1220 });
1221
1222 let changes = diff(&graph, &graph);
1223 assert!(
1224 changes.is_empty(),
1225 "identical graphs should produce no changes"
1226 );
1227 }
1228
1229 #[test]
1231 fn manifest_to_diff_integration() {
1232 use crate::manifest::{expand_manifest, parse_manifest};
1233 use crate::model::RoleGraph;
1234
1235 let yaml = r#"
1236default_owner: app_owner
1237
1238profiles:
1239 editor:
1240 grants:
1241 - privileges: [USAGE]
1242 object: { type: schema }
1243 - privileges: [SELECT, INSERT, UPDATE, DELETE]
1244 object: { type: table, name: "*" }
1245 default_privileges:
1246 - privileges: [SELECT, INSERT, UPDATE, DELETE]
1247 on_type: table
1248
1249schemas:
1250 - name: inventory
1251 owner: inventory_owner
1252 profiles: [editor]
1253
1254memberships:
1255 - role: inventory-editor
1256 members:
1257 - name: "user@example.com"
1258"#;
1259 let manifest = parse_manifest(yaml).unwrap();
1260 let expanded = expand_manifest(&manifest).unwrap();
1261 let desired =
1262 RoleGraph::from_expanded(&expanded, manifest.default_owner.as_deref()).unwrap();
1263
1264 let current = RoleGraph::default();
1266 let changes = diff(¤t, &desired);
1267
1268 let create_count = changes
1270 .iter()
1271 .filter(|c| matches!(c, Change::CreateRole { .. }))
1272 .count();
1273 let create_schema_count = changes
1274 .iter()
1275 .filter(|c| matches!(c, Change::CreateSchema { .. }))
1276 .count();
1277 let grant_count = changes
1278 .iter()
1279 .filter(|c| matches!(c, Change::Grant { .. }))
1280 .count();
1281 let dp_count = changes
1282 .iter()
1283 .filter(|c| matches!(c, Change::SetDefaultPrivilege { .. }))
1284 .count();
1285 let member_count = changes
1286 .iter()
1287 .filter(|c| matches!(c, Change::AddMember { .. }))
1288 .count();
1289
1290 assert_eq!(create_count, 1);
1291 assert_eq!(create_schema_count, 1);
1292 assert_eq!(grant_count, 2); assert_eq!(dp_count, 1);
1294 assert_eq!(member_count, 1);
1295
1296 let no_changes = diff(&desired, &desired);
1298 assert!(no_changes.is_empty());
1299 }
1300
1301 fn all_change_variants() -> Vec<Change> {
1307 vec![
1308 Change::CreateRole {
1309 name: "new-role".to_string(),
1310 state: RoleState::default(),
1311 },
1312 Change::CreateSchema {
1313 name: "inventory".to_string(),
1314 owner: Some("inventory_owner".to_string()),
1315 },
1316 Change::AlterSchemaOwner {
1317 name: "catalog".to_string(),
1318 owner: "catalog_owner".to_string(),
1319 },
1320 Change::EnsureSchemaOwnerPrivileges {
1321 name: "catalog".to_string(),
1322 owner: "catalog_owner".to_string(),
1323 privileges: BTreeSet::from([Privilege::Create, Privilege::Usage]),
1324 },
1325 Change::AlterRole {
1326 name: "altered-role".to_string(),
1327 attributes: vec![RoleAttribute::Login(true)],
1328 },
1329 Change::SetComment {
1330 name: "commented-role".to_string(),
1331 comment: Some("hello".to_string()),
1332 },
1333 Change::Grant {
1334 role: "r1".to_string(),
1335 privileges: BTreeSet::from([Privilege::Select]),
1336 object_type: ObjectType::Table,
1337 schema: Some("public".to_string()),
1338 name: Some("*".to_string()),
1339 },
1340 Change::Revoke {
1341 role: "r1".to_string(),
1342 privileges: BTreeSet::from([Privilege::Insert]),
1343 object_type: ObjectType::Table,
1344 schema: Some("public".to_string()),
1345 name: Some("*".to_string()),
1346 },
1347 Change::SetDefaultPrivilege {
1348 owner: "owner".to_string(),
1349 schema: "public".to_string(),
1350 on_type: ObjectType::Table,
1351 grantee: "r1".to_string(),
1352 privileges: BTreeSet::from([Privilege::Select]),
1353 },
1354 Change::RevokeDefaultPrivilege {
1355 owner: "owner".to_string(),
1356 schema: "public".to_string(),
1357 on_type: ObjectType::Table,
1358 grantee: "r1".to_string(),
1359 privileges: BTreeSet::from([Privilege::Delete]),
1360 },
1361 Change::AddMember {
1362 role: "editors".to_string(),
1363 member: "user@example.com".to_string(),
1364 inherit: true,
1365 admin: false,
1366 },
1367 Change::RemoveMember {
1368 role: "editors".to_string(),
1369 member: "old@example.com".to_string(),
1370 },
1371 Change::TerminateSessions {
1372 role: "retired-role".to_string(),
1373 },
1374 Change::ReassignOwned {
1375 from_role: "retired-role".to_string(),
1376 to_role: "successor".to_string(),
1377 },
1378 Change::DropOwned {
1379 role: "retired-role".to_string(),
1380 },
1381 Change::DropRole {
1382 name: "retired-role".to_string(),
1383 },
1384 ]
1385 }
1386
1387 #[test]
1388 fn filter_authoritative_keeps_all_changes() {
1389 let changes = all_change_variants();
1390 let original_len = changes.len();
1391 let filtered = filter_changes(changes, ReconciliationMode::Authoritative);
1392 assert_eq!(filtered.len(), original_len);
1393 }
1394
1395 #[test]
1396 fn filter_additive_keeps_only_constructive_changes() {
1397 let filtered = filter_changes(all_change_variants(), ReconciliationMode::Additive);
1398
1399 assert_eq!(filtered.len(), 5);
1401
1402 for change in &filtered {
1404 assert!(
1405 !matches!(
1406 change,
1407 Change::AlterSchemaOwner { .. }
1408 | Change::EnsureSchemaOwnerPrivileges { .. }
1409 | Change::AlterRole { .. }
1410 | Change::SetComment { .. }
1411 | Change::Revoke { .. }
1412 | Change::RevokeDefaultPrivilege { .. }
1413 | Change::RemoveMember { .. }
1414 | Change::DropRole { .. }
1415 | Change::DropOwned { .. }
1416 | Change::ReassignOwned { .. }
1417 | Change::TerminateSessions { .. }
1418 ),
1419 "additive mode should not contain destructive change: {change:?}"
1420 );
1421 }
1422
1423 assert!(
1425 filtered
1426 .iter()
1427 .any(|c| matches!(c, Change::CreateRole { .. }))
1428 );
1429 assert!(
1430 filtered
1431 .iter()
1432 .any(|c| matches!(c, Change::CreateSchema { .. }))
1433 );
1434 assert!(
1435 filtered
1436 .iter()
1437 .all(|c| !matches!(c, Change::AlterRole { .. } | Change::SetComment { .. }))
1438 );
1439 assert!(filtered.iter().any(|c| matches!(c, Change::Grant { .. })));
1440 assert!(
1441 filtered
1442 .iter()
1443 .any(|c| matches!(c, Change::SetDefaultPrivilege { .. }))
1444 );
1445 assert!(
1446 filtered
1447 .iter()
1448 .any(|c| matches!(c, Change::AddMember { .. }))
1449 );
1450 }
1451
1452 #[test]
1453 fn filter_additive_skips_owner_bound_follow_ups_when_transfer_is_skipped() {
1454 let changes = vec![
1455 Change::AlterSchemaOwner {
1456 name: "inventory".to_string(),
1457 owner: "new_owner".to_string(),
1458 },
1459 Change::EnsureSchemaOwnerPrivileges {
1460 name: "inventory".to_string(),
1461 owner: "new_owner".to_string(),
1462 privileges: BTreeSet::from([Privilege::Create, Privilege::Usage]),
1463 },
1464 Change::SetDefaultPrivilege {
1465 owner: "new_owner".to_string(),
1466 schema: "inventory".to_string(),
1467 on_type: ObjectType::Table,
1468 grantee: "inventory-editor".to_string(),
1469 privileges: BTreeSet::from([Privilege::Select]),
1470 },
1471 Change::Grant {
1472 role: "inventory-editor".to_string(),
1473 privileges: BTreeSet::from([Privilege::Usage]),
1474 object_type: ObjectType::Schema,
1475 schema: None,
1476 name: Some("inventory".to_string()),
1477 },
1478 ];
1479
1480 let filtered = filter_changes(changes, ReconciliationMode::Additive);
1481 assert_eq!(filtered.len(), 1);
1482 assert!(matches!(&filtered[0], Change::Grant { role, .. } if role == "inventory-editor"));
1483 }
1484
1485 #[test]
1486 fn filter_adopt_keeps_revokes_but_not_drops() {
1487 let filtered = filter_changes(all_change_variants(), ReconciliationMode::Adopt);
1488
1489 assert_eq!(filtered.len(), 12);
1491
1492 for change in &filtered {
1494 assert!(
1495 !matches!(
1496 change,
1497 Change::DropRole { .. }
1498 | Change::DropOwned { .. }
1499 | Change::ReassignOwned { .. }
1500 | Change::TerminateSessions { .. }
1501 ),
1502 "adopt mode should not contain drop/retirement change: {change:?}"
1503 );
1504 }
1505
1506 assert!(filtered.iter().any(|c| matches!(c, Change::Revoke { .. })));
1508 assert!(
1509 filtered
1510 .iter()
1511 .any(|c| matches!(c, Change::RevokeDefaultPrivilege { .. }))
1512 );
1513 assert!(
1514 filtered
1515 .iter()
1516 .any(|c| matches!(c, Change::RemoveMember { .. }))
1517 );
1518 }
1519
1520 #[test]
1521 fn filter_additive_with_empty_input() {
1522 let filtered = filter_changes(vec![], ReconciliationMode::Additive);
1523 assert!(filtered.is_empty());
1524 }
1525
1526 #[test]
1527 fn filter_additive_only_destructive_changes_yields_empty() {
1528 let changes = vec![
1529 Change::Revoke {
1530 role: "r1".to_string(),
1531 privileges: BTreeSet::from([Privilege::Select]),
1532 object_type: ObjectType::Table,
1533 schema: Some("public".to_string()),
1534 name: Some("*".to_string()),
1535 },
1536 Change::DropRole {
1537 name: "old-role".to_string(),
1538 },
1539 ];
1540 let filtered = filter_changes(changes, ReconciliationMode::Additive);
1541 assert!(filtered.is_empty());
1542 }
1543
1544 #[test]
1545 fn filter_adopt_preserves_ordering() {
1546 let changes = vec![
1547 Change::CreateRole {
1548 name: "new-role".to_string(),
1549 state: RoleState::default(),
1550 },
1551 Change::Grant {
1552 role: "new-role".to_string(),
1553 privileges: BTreeSet::from([Privilege::Select]),
1554 object_type: ObjectType::Table,
1555 schema: Some("public".to_string()),
1556 name: Some("*".to_string()),
1557 },
1558 Change::Revoke {
1559 role: "existing-role".to_string(),
1560 privileges: BTreeSet::from([Privilege::Insert]),
1561 object_type: ObjectType::Table,
1562 schema: Some("public".to_string()),
1563 name: Some("*".to_string()),
1564 },
1565 Change::DropRole {
1566 name: "old-role".to_string(),
1567 },
1568 ];
1569
1570 let filtered = filter_changes(changes, ReconciliationMode::Adopt);
1571 assert_eq!(filtered.len(), 3);
1572 assert!(matches!(&filtered[0], Change::CreateRole { name, .. } if name == "new-role"));
1573 assert!(matches!(&filtered[1], Change::Grant { .. }));
1574 assert!(matches!(&filtered[2], Change::Revoke { .. }));
1575 }
1576
1577 #[test]
1578 fn reconciliation_mode_display() {
1579 assert_eq!(
1580 ReconciliationMode::Authoritative.to_string(),
1581 "authoritative"
1582 );
1583 assert_eq!(ReconciliationMode::Additive.to_string(), "additive");
1584 assert_eq!(ReconciliationMode::Adopt.to_string(), "adopt");
1585 }
1586
1587 #[test]
1588 fn reconciliation_mode_default_is_authoritative() {
1589 assert_eq!(
1590 ReconciliationMode::default(),
1591 ReconciliationMode::Authoritative
1592 );
1593 }
1594
1595 #[test]
1600 fn apply_role_retirements_inserts_cleanup_before_drop() {
1601 let changes = vec![
1602 Change::Grant {
1603 role: "analytics".to_string(),
1604 privileges: BTreeSet::from([Privilege::Select]),
1605 object_type: ObjectType::Table,
1606 schema: Some("public".to_string()),
1607 name: Some("*".to_string()),
1608 },
1609 Change::DropRole {
1610 name: "old-app".to_string(),
1611 },
1612 ];
1613
1614 let planned = apply_role_retirements(
1615 changes,
1616 &[crate::manifest::RoleRetirement {
1617 role: "old-app".to_string(),
1618 reassign_owned_to: Some("successor".to_string()),
1619 drop_owned: true,
1620 terminate_sessions: true,
1621 }],
1622 );
1623
1624 assert!(matches!(planned[0], Change::Grant { .. }));
1625 assert!(matches!(
1626 planned[1],
1627 Change::TerminateSessions { ref role } if role == "old-app"
1628 ));
1629 assert!(matches!(
1630 planned[2],
1631 Change::ReassignOwned {
1632 ref from_role,
1633 ref to_role
1634 } if from_role == "old-app" && to_role == "successor"
1635 ));
1636 assert!(matches!(
1637 planned[3],
1638 Change::DropOwned { ref role } if role == "old-app"
1639 ));
1640 assert!(matches!(
1641 planned[4],
1642 Change::DropRole { ref name } if name == "old-app"
1643 ));
1644 }
1645
1646 #[test]
1647 fn inject_password_for_new_role() {
1648 let changes = vec![Change::CreateRole {
1649 name: "app-svc".to_string(),
1650 state: RoleState::default(),
1651 }];
1652
1653 let mut passwords = std::collections::BTreeMap::new();
1654 passwords.insert("app-svc".to_string(), "secret123".to_string());
1655
1656 let result = inject_password_changes(changes, &passwords);
1657 assert_eq!(result.len(), 2);
1658 assert!(matches!(&result[0], Change::CreateRole { name, .. } if name == "app-svc"));
1659 assert!(
1660 matches!(&result[1], Change::SetPassword { name, password } if name == "app-svc" && password.starts_with("SCRAM-SHA-256$"))
1661 );
1662 }
1663
1664 #[test]
1665 fn inject_password_for_existing_role() {
1666 let changes = vec![Change::Grant {
1668 role: "app-svc".to_string(),
1669 privileges: BTreeSet::from([crate::manifest::Privilege::Select]),
1670 object_type: crate::manifest::ObjectType::Table,
1671 schema: Some("public".to_string()),
1672 name: Some("*".to_string()),
1673 }];
1674
1675 let mut passwords = std::collections::BTreeMap::new();
1676 passwords.insert("app-svc".to_string(), "secret123".to_string());
1677
1678 let result = inject_password_changes(changes, &passwords);
1679 assert_eq!(result.len(), 2);
1680 assert!(matches!(&result[0], Change::Grant { .. }));
1681 assert!(
1682 matches!(&result[1], Change::SetPassword { name, password } if name == "app-svc" && password.starts_with("SCRAM-SHA-256$"))
1683 );
1684 }
1685
1686 #[test]
1687 fn inject_password_empty_passwords_is_noop() {
1688 let changes = vec![Change::CreateRole {
1689 name: "app-svc".to_string(),
1690 state: RoleState::default(),
1691 }];
1692
1693 let passwords = std::collections::BTreeMap::new();
1694 let result = inject_password_changes(changes.clone(), &passwords);
1695 assert_eq!(result.len(), 1);
1696 }
1697
1698 #[test]
1699 fn resolve_passwords_missing_env_var() {
1700 let roles = vec![crate::manifest::RoleDefinition {
1701 name: "app-svc".to_string(),
1702 login: Some(true),
1703 password: Some(crate::manifest::PasswordSource {
1704 from_env: "PGROLES_TEST_MISSING_VAR_9a8b7c6d".to_string(),
1705 }),
1706 password_valid_until: None,
1707 superuser: None,
1708 createdb: None,
1709 createrole: None,
1710 inherit: None,
1711 replication: None,
1712 bypassrls: None,
1713 connection_limit: None,
1714 comment: None,
1715 }];
1716
1717 unsafe { std::env::remove_var("PGROLES_TEST_MISSING_VAR_9a8b7c6d") };
1720
1721 let result = resolve_passwords(&roles);
1722 assert!(result.is_err());
1723 let err = result.unwrap_err();
1724 assert!(
1725 matches!(err, PasswordResolutionError::MissingEnvVar { ref role, ref env_var }
1726 if role == "app-svc" && env_var == "PGROLES_TEST_MISSING_VAR_9a8b7c6d"),
1727 "expected MissingEnvVar, got: {err:?}"
1728 );
1729 }
1730
1731 #[test]
1732 fn resolve_passwords_empty_env_var() {
1733 let roles = vec![crate::manifest::RoleDefinition {
1734 name: "app-svc".to_string(),
1735 login: Some(true),
1736 password: Some(crate::manifest::PasswordSource {
1737 from_env: "PGROLES_TEST_EMPTY_VAR_1a2b3c4d".to_string(),
1738 }),
1739 password_valid_until: None,
1740 superuser: None,
1741 createdb: None,
1742 createrole: None,
1743 inherit: None,
1744 replication: None,
1745 bypassrls: None,
1746 connection_limit: None,
1747 comment: None,
1748 }];
1749
1750 unsafe { std::env::set_var("PGROLES_TEST_EMPTY_VAR_1a2b3c4d", "") };
1753
1754 let result = resolve_passwords(&roles);
1755
1756 unsafe { std::env::remove_var("PGROLES_TEST_EMPTY_VAR_1a2b3c4d") };
1758
1759 assert!(result.is_err());
1760 let err = result.unwrap_err();
1761 assert!(
1762 matches!(err, PasswordResolutionError::EmptyPassword { ref role, ref env_var }
1763 if role == "app-svc" && env_var == "PGROLES_TEST_EMPTY_VAR_1a2b3c4d"),
1764 "expected EmptyPassword, got: {err:?}"
1765 );
1766 }
1767
1768 #[test]
1769 fn resolve_passwords_happy_path() {
1770 let roles = vec![crate::manifest::RoleDefinition {
1771 name: "app-svc".to_string(),
1772 login: Some(true),
1773 password: Some(crate::manifest::PasswordSource {
1774 from_env: "PGROLES_TEST_RESOLVE_VAR_5e6f7g8h".to_string(),
1775 }),
1776 password_valid_until: None,
1777 superuser: None,
1778 createdb: None,
1779 createrole: None,
1780 inherit: None,
1781 replication: None,
1782 bypassrls: None,
1783 connection_limit: None,
1784 comment: None,
1785 }];
1786
1787 unsafe { std::env::set_var("PGROLES_TEST_RESOLVE_VAR_5e6f7g8h", "my_secret_pw") };
1789
1790 let result = resolve_passwords(&roles);
1791
1792 unsafe { std::env::remove_var("PGROLES_TEST_RESOLVE_VAR_5e6f7g8h") };
1793
1794 let resolved = result.expect("should succeed");
1795 assert_eq!(resolved.len(), 1);
1796 assert_eq!(resolved["app-svc"], "my_secret_pw");
1797 }
1798
1799 #[test]
1800 fn resolve_passwords_skips_roles_without_password() {
1801 let roles = vec![crate::manifest::RoleDefinition {
1802 name: "no-password".to_string(),
1803 login: Some(true),
1804 password: None,
1805 password_valid_until: None,
1806 superuser: None,
1807 createdb: None,
1808 createrole: None,
1809 inherit: None,
1810 replication: None,
1811 bypassrls: None,
1812 connection_limit: None,
1813 comment: None,
1814 }];
1815
1816 let result = resolve_passwords(&roles);
1817 let resolved = result.expect("should succeed");
1818 assert!(resolved.is_empty());
1819 }
1820
1821 #[test]
1822 fn inject_password_multiple_roles() {
1823 let changes = vec![
1824 Change::CreateRole {
1825 name: "role-a".to_string(),
1826 state: RoleState::default(),
1827 },
1828 Change::CreateRole {
1829 name: "role-b".to_string(),
1830 state: RoleState::default(),
1831 },
1832 Change::Grant {
1833 role: "role-c".to_string(),
1834 privileges: BTreeSet::from([crate::manifest::Privilege::Select]),
1835 object_type: crate::manifest::ObjectType::Table,
1836 schema: Some("public".to_string()),
1837 name: Some("*".to_string()),
1838 },
1839 ];
1840
1841 let mut passwords = std::collections::BTreeMap::new();
1842 passwords.insert("role-a".to_string(), "pw-a".to_string());
1843 passwords.insert("role-b".to_string(), "pw-b".to_string());
1844 passwords.insert("role-c".to_string(), "pw-c".to_string());
1845
1846 let result = inject_password_changes(changes, &passwords);
1847
1848 assert_eq!(result.len(), 6, "expected 6 changes, got: {result:?}");
1852 assert!(matches!(&result[0], Change::CreateRole { name, .. } if name == "role-a"));
1853 assert!(matches!(&result[1], Change::SetPassword { name, .. } if name == "role-a"));
1854 assert!(matches!(&result[2], Change::CreateRole { name, .. } if name == "role-b"));
1855 assert!(matches!(&result[3], Change::SetPassword { name, .. } if name == "role-b"));
1856 assert!(matches!(&result[4], Change::Grant { .. }));
1857 assert!(matches!(&result[5], Change::SetPassword { name, .. } if name == "role-c"));
1858 }
1859
1860 #[test]
1861 fn diff_detects_valid_until_change() {
1862 let mut current = empty_graph();
1863 current.roles.insert(
1864 "r1".to_string(),
1865 RoleState {
1866 login: true,
1867 ..RoleState::default()
1868 },
1869 );
1870
1871 let mut desired = empty_graph();
1872 desired.roles.insert(
1873 "r1".to_string(),
1874 RoleState {
1875 login: true,
1876 password_valid_until: Some("2025-12-31T00:00:00Z".to_string()),
1877 ..RoleState::default()
1878 },
1879 );
1880
1881 let changes = diff(¤t, &desired);
1882 assert_eq!(changes.len(), 1);
1883 match &changes[0] {
1884 Change::AlterRole { name, attributes } => {
1885 assert_eq!(name, "r1");
1886 assert!(attributes.contains(&RoleAttribute::ValidUntil(Some(
1887 "2025-12-31T00:00:00Z".to_string()
1888 ))));
1889 }
1890 other => panic!("expected AlterRole, got: {other:?}"),
1891 }
1892 }
1893
1894 #[test]
1895 fn diff_detects_valid_until_removal() {
1896 let mut current = empty_graph();
1897 current.roles.insert(
1898 "r1".to_string(),
1899 RoleState {
1900 login: true,
1901 password_valid_until: Some("2025-12-31T00:00:00Z".to_string()),
1902 ..RoleState::default()
1903 },
1904 );
1905
1906 let mut desired = empty_graph();
1907 desired.roles.insert(
1908 "r1".to_string(),
1909 RoleState {
1910 login: true,
1911 ..RoleState::default()
1912 },
1913 );
1914
1915 let changes = diff(¤t, &desired);
1916 assert_eq!(changes.len(), 1);
1917 match &changes[0] {
1918 Change::AlterRole { name, attributes } => {
1919 assert_eq!(name, "r1");
1920 assert!(attributes.contains(&RoleAttribute::ValidUntil(None)));
1921 }
1922 other => panic!("expected AlterRole, got: {other:?}"),
1923 }
1924 }
1925}