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