1use std::collections::{BTreeMap, 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 let desired_wildcards: BTreeMap<(&str, &Option<String>, ObjectType), &BTreeSet<Privilege>> =
578 desired
579 .grants
580 .iter()
581 .filter(|(k, _)| k.name.as_deref() == Some("*") && k.schema.is_some())
582 .map(|(k, v)| ((k.role.as_str(), &k.schema, k.object_type), &v.privileges))
583 .collect();
584
585 let shadow_filter = |key: &GrantKey, candidate: BTreeSet<Privilege>| -> BTreeSet<Privilege> {
589 if key.name.as_deref() == Some("*") {
590 return candidate;
591 }
592 match desired_wildcards.get(&(key.role.as_str(), &key.schema, key.object_type)) {
593 Some(wildcard_privileges) => {
594 candidate.difference(wildcard_privileges).copied().collect()
595 }
596 None => candidate,
597 }
598 };
599
600 for (key, desired_state) in &desired.grants {
603 match current.grants.get(key) {
604 None => {
605 grants_out.push(change_grant(key, &desired_state.privileges));
607 }
608 Some(current_state) => {
609 let to_add: BTreeSet<Privilege> = desired_state
611 .privileges
612 .difference(¤t_state.privileges)
613 .copied()
614 .collect();
615 let to_remove: BTreeSet<Privilege> = current_state
616 .privileges
617 .difference(&desired_state.privileges)
618 .copied()
619 .collect();
620 let to_remove = shadow_filter(key, to_remove);
621
622 if !to_add.is_empty() {
623 grants_out.push(change_grant(key, &to_add));
624 }
625 if !to_remove.is_empty() {
626 revokes_out.push(change_revoke(key, &to_remove));
627 }
628 }
629 }
630 }
631
632 for (key, current_state) in ¤t.grants {
635 if desired.grants.contains_key(key) {
636 continue;
637 }
638
639 let to_revoke = shadow_filter(key, current_state.privileges.clone());
640 if !to_revoke.is_empty() {
641 revokes_out.push(change_revoke(key, &to_revoke));
642 }
643 }
644}
645
646fn change_grant(key: &GrantKey, privileges: &BTreeSet<Privilege>) -> Change {
647 Change::Grant {
648 role: key.role.clone(),
649 privileges: privileges.clone(),
650 object_type: key.object_type,
651 schema: key.schema.clone(),
652 name: key.name.clone(),
653 }
654}
655
656fn change_revoke(key: &GrantKey, privileges: &BTreeSet<Privilege>) -> Change {
657 Change::Revoke {
658 role: key.role.clone(),
659 privileges: privileges.clone(),
660 object_type: key.object_type,
661 schema: key.schema.clone(),
662 name: key.name.clone(),
663 }
664}
665
666fn diff_default_privileges(
671 current: &RoleGraph,
672 desired: &RoleGraph,
673 set_out: &mut Vec<Change>,
674 revoke_out: &mut Vec<Change>,
675) {
676 for (key, desired_state) in &desired.default_privileges {
677 match current.default_privileges.get(key) {
678 None => {
679 set_out.push(change_set_default(key, &desired_state.privileges));
680 }
681 Some(current_state) => {
682 let to_add: BTreeSet<Privilege> = desired_state
683 .privileges
684 .difference(¤t_state.privileges)
685 .copied()
686 .collect();
687 let to_remove: BTreeSet<Privilege> = current_state
688 .privileges
689 .difference(&desired_state.privileges)
690 .copied()
691 .collect();
692
693 if !to_add.is_empty() {
694 set_out.push(change_set_default(key, &to_add));
695 }
696 if !to_remove.is_empty() {
697 revoke_out.push(change_revoke_default(key, &to_remove));
698 }
699 }
700 }
701 }
702
703 for (key, current_state) in ¤t.default_privileges {
704 if !desired.default_privileges.contains_key(key) {
705 revoke_out.push(change_revoke_default(key, ¤t_state.privileges));
706 }
707 }
708}
709
710fn change_set_default(key: &DefaultPrivKey, privileges: &BTreeSet<Privilege>) -> Change {
711 Change::SetDefaultPrivilege {
712 owner: key.owner.clone(),
713 schema: key.schema.clone(),
714 on_type: key.on_type,
715 grantee: key.grantee.clone(),
716 privileges: privileges.clone(),
717 }
718}
719
720fn change_revoke_default(key: &DefaultPrivKey, privileges: &BTreeSet<Privilege>) -> Change {
721 Change::RevokeDefaultPrivilege {
722 owner: key.owner.clone(),
723 schema: key.schema.clone(),
724 on_type: key.on_type,
725 grantee: key.grantee.clone(),
726 privileges: privileges.clone(),
727 }
728}
729
730fn diff_memberships(
735 current: &RoleGraph,
736 desired: &RoleGraph,
737 add_out: &mut Vec<Change>,
738 remove_out: &mut Vec<Change>,
739) {
740 let current_map: std::collections::BTreeMap<(&str, &str), &MembershipEdge> = current
745 .memberships
746 .iter()
747 .map(|edge| ((edge.role.as_str(), edge.member.as_str()), edge))
748 .collect();
749 let desired_map: std::collections::BTreeMap<(&str, &str), &MembershipEdge> = desired
750 .memberships
751 .iter()
752 .map(|edge| ((edge.role.as_str(), edge.member.as_str()), edge))
753 .collect();
754
755 for (&(role, member), &desired_edge) in &desired_map {
758 match current_map.get(&(role, member)) {
759 None => {
760 add_out.push(Change::AddMember {
761 role: desired_edge.role.clone(),
762 member: desired_edge.member.clone(),
763 inherit: desired_edge.inherit,
764 admin: desired_edge.admin,
765 });
766 }
767 Some(current_edge) => {
768 if current_edge.inherit != desired_edge.inherit
769 || current_edge.admin != desired_edge.admin
770 {
771 remove_out.push(Change::RemoveMember {
773 role: current_edge.role.clone(),
774 member: current_edge.member.clone(),
775 });
776 add_out.push(Change::AddMember {
777 role: desired_edge.role.clone(),
778 member: desired_edge.member.clone(),
779 inherit: desired_edge.inherit,
780 admin: desired_edge.admin,
781 });
782 }
783 }
784 }
785 }
786
787 for &(role, member) in current_map.keys() {
789 if !desired_map.contains_key(&(role, member)) {
790 remove_out.push(Change::RemoveMember {
791 role: role.to_string(),
792 member: member.to_string(),
793 });
794 }
795 }
796}
797
798#[cfg(test)]
803mod tests {
804 use super::*;
805 use crate::model::{
806 DefaultPrivState, GrantState, SchemaState, default_schema_owner_privileges,
807 };
808
809 fn empty_graph() -> RoleGraph {
811 RoleGraph::default()
812 }
813
814 fn managed_schema(owner: &str) -> SchemaState {
815 SchemaState {
816 owner: Some(owner.to_string()),
817 owner_privileges: default_schema_owner_privileges(owner),
818 }
819 }
820
821 #[test]
822 fn diff_empty_to_empty_is_empty() {
823 let changes = diff(&empty_graph(), &empty_graph());
824 assert!(changes.is_empty());
825 }
826
827 #[test]
828 fn diff_creates_new_roles() {
829 let current = empty_graph();
830 let mut desired = empty_graph();
831 desired
832 .roles
833 .insert("new-role".to_string(), RoleState::default());
834
835 let changes = diff(¤t, &desired);
836 assert_eq!(changes.len(), 1);
837 assert!(matches!(&changes[0], Change::CreateRole { name, .. } if name == "new-role"));
838 }
839
840 #[test]
841 fn diff_drops_removed_roles() {
842 let mut current = empty_graph();
843 current
844 .roles
845 .insert("old-role".to_string(), RoleState::default());
846 let desired = empty_graph();
847
848 let changes = diff(¤t, &desired);
849 assert_eq!(changes.len(), 1);
850 assert!(matches!(&changes[0], Change::DropRole { name } if name == "old-role"));
851 }
852
853 #[test]
854 fn diff_alters_changed_role_attributes() {
855 let mut current = empty_graph();
856 current
857 .roles
858 .insert("role1".to_string(), RoleState::default());
859
860 let mut desired = empty_graph();
861 desired.roles.insert(
862 "role1".to_string(),
863 RoleState {
864 login: true,
865 ..RoleState::default()
866 },
867 );
868
869 let changes = diff(¤t, &desired);
870 assert_eq!(changes.len(), 1);
871 match &changes[0] {
872 Change::AlterRole { name, attributes } => {
873 assert_eq!(name, "role1");
874 assert!(attributes.contains(&RoleAttribute::Login(true)));
875 }
876 other => panic!("expected AlterRole, got: {other:?}"),
877 }
878 }
879
880 #[test]
881 fn diff_creates_missing_schema() {
882 let current = empty_graph();
883 let mut desired = empty_graph();
884 desired
885 .schemas
886 .insert("inventory".to_string(), managed_schema("inventory_owner"));
887
888 let changes = diff(¤t, &desired);
889 assert_eq!(changes.len(), 1);
890 assert!(matches!(
891 &changes[0],
892 Change::CreateSchema { name, owner }
893 if name == "inventory" && owner.as_deref() == Some("inventory_owner")
894 ));
895 }
896
897 #[test]
898 fn diff_alters_schema_owner_when_different() {
899 let mut current = empty_graph();
900 current
901 .schemas
902 .insert("inventory".to_string(), managed_schema("old_owner"));
903
904 let mut desired = empty_graph();
905 desired
906 .schemas
907 .insert("inventory".to_string(), managed_schema("new_owner"));
908
909 let changes = diff(¤t, &desired);
910 assert_eq!(changes.len(), 1);
911 assert!(matches!(
912 &changes[0],
913 Change::AlterSchemaOwner { name, owner }
914 if name == "inventory" && owner == "new_owner"
915 ));
916 }
917
918 #[test]
919 fn diff_does_not_alter_schema_owner_when_unmanaged() {
920 let mut current = empty_graph();
921 current
922 .schemas
923 .insert("inventory".to_string(), managed_schema("old_owner"));
924
925 let mut desired = empty_graph();
926 desired.schemas.insert(
927 "inventory".to_string(),
928 SchemaState {
929 owner: None,
930 owner_privileges: BTreeSet::new(),
931 },
932 );
933
934 let changes = diff(¤t, &desired);
935 assert!(changes.is_empty());
936 }
937
938 #[test]
939 fn diff_restores_missing_owner_schema_privileges() {
940 let mut current = empty_graph();
941 current.schemas.insert(
942 "inventory".to_string(),
943 SchemaState {
944 owner: Some("inventory_owner".to_string()),
945 owner_privileges: BTreeSet::from([Privilege::Usage]),
946 },
947 );
948
949 let mut desired = empty_graph();
950 desired
951 .schemas
952 .insert("inventory".to_string(), managed_schema("inventory_owner"));
953
954 let changes = diff(¤t, &desired);
955 assert_eq!(changes.len(), 1);
956 assert!(matches!(
957 &changes[0],
958 Change::EnsureSchemaOwnerPrivileges {
959 name,
960 owner,
961 privileges,
962 } if name == "inventory"
963 && owner == "inventory_owner"
964 && privileges == &BTreeSet::from([Privilege::Create])
965 ));
966 }
967
968 #[test]
969 fn diff_restores_owner_schema_privileges_after_transfer() {
970 let mut current = empty_graph();
971 current.schemas.insert(
972 "inventory".to_string(),
973 SchemaState {
974 owner: Some("old_owner".to_string()),
975 owner_privileges: BTreeSet::from([Privilege::Usage]),
976 },
977 );
978
979 let mut desired = empty_graph();
980 desired
981 .schemas
982 .insert("inventory".to_string(), managed_schema("new_owner"));
983
984 let changes = diff(¤t, &desired);
985 assert_eq!(changes.len(), 2);
986 assert!(matches!(
987 &changes[0],
988 Change::AlterSchemaOwner { name, owner }
989 if name == "inventory" && owner == "new_owner"
990 ));
991 assert!(matches!(
992 &changes[1],
993 Change::EnsureSchemaOwnerPrivileges {
994 name,
995 owner,
996 privileges,
997 } if name == "inventory"
998 && owner == "new_owner"
999 && privileges == &BTreeSet::from([Privilege::Create])
1000 ));
1001 }
1002
1003 #[test]
1004 fn diff_grants_new_privileges() {
1005 let current = empty_graph();
1006 let mut desired = empty_graph();
1007 let key = GrantKey {
1008 role: "r1".to_string(),
1009 object_type: ObjectType::Table,
1010 schema: Some("public".to_string()),
1011 name: Some("*".to_string()),
1012 };
1013 desired.grants.insert(
1014 key,
1015 GrantState {
1016 privileges: BTreeSet::from([Privilege::Select, Privilege::Insert]),
1017 },
1018 );
1019
1020 let changes = diff(¤t, &desired);
1021 assert_eq!(changes.len(), 1);
1022 match &changes[0] {
1023 Change::Grant {
1024 role, privileges, ..
1025 } => {
1026 assert_eq!(role, "r1");
1027 assert!(privileges.contains(&Privilege::Select));
1028 assert!(privileges.contains(&Privilege::Insert));
1029 }
1030 other => panic!("expected Grant, got: {other:?}"),
1031 }
1032 }
1033
1034 #[test]
1035 fn diff_revokes_removed_privileges() {
1036 let mut current = empty_graph();
1037 let key = GrantKey {
1038 role: "r1".to_string(),
1039 object_type: ObjectType::Table,
1040 schema: Some("public".to_string()),
1041 name: Some("*".to_string()),
1042 };
1043 current.grants.insert(
1044 key.clone(),
1045 GrantState {
1046 privileges: BTreeSet::from([Privilege::Select, Privilege::Insert]),
1047 },
1048 );
1049
1050 let mut desired = empty_graph();
1051 desired.grants.insert(
1052 key,
1053 GrantState {
1054 privileges: BTreeSet::from([Privilege::Select]),
1055 },
1056 );
1057
1058 let changes = diff(¤t, &desired);
1059 assert_eq!(changes.len(), 1);
1060 match &changes[0] {
1061 Change::Revoke {
1062 role, privileges, ..
1063 } => {
1064 assert_eq!(role, "r1");
1065 assert!(privileges.contains(&Privilege::Insert));
1066 assert!(!privileges.contains(&Privilege::Select));
1067 }
1068 other => panic!("expected Revoke, got: {other:?}"),
1069 }
1070 }
1071
1072 #[test]
1073 fn diff_revokes_entire_grant_target_when_absent_from_desired() {
1074 let mut current = empty_graph();
1075 let key = GrantKey {
1076 role: "r1".to_string(),
1077 object_type: ObjectType::Schema,
1078 schema: None,
1079 name: Some("myschema".to_string()),
1080 };
1081 current.grants.insert(
1082 key,
1083 GrantState {
1084 privileges: BTreeSet::from([Privilege::Usage]),
1085 },
1086 );
1087 let desired = empty_graph();
1088
1089 let changes = diff(¤t, &desired);
1090 assert_eq!(changes.len(), 1);
1091 assert!(matches!(&changes[0], Change::Revoke { role, .. } if role == "r1"));
1092 }
1093
1094 #[test]
1095 fn diff_adds_memberships() {
1096 let current = empty_graph();
1097 let mut desired = empty_graph();
1098 desired.memberships.insert(MembershipEdge {
1099 role: "editors".to_string(),
1100 member: "user@example.com".to_string(),
1101 inherit: true,
1102 admin: false,
1103 });
1104
1105 let changes = diff(¤t, &desired);
1106 assert_eq!(changes.len(), 1);
1107 match &changes[0] {
1108 Change::AddMember {
1109 role,
1110 member,
1111 inherit,
1112 admin,
1113 } => {
1114 assert_eq!(role, "editors");
1115 assert_eq!(member, "user@example.com");
1116 assert!(*inherit);
1117 assert!(!admin);
1118 }
1119 other => panic!("expected AddMember, got: {other:?}"),
1120 }
1121 }
1122
1123 #[test]
1124 fn diff_removes_memberships() {
1125 let mut current = empty_graph();
1126 current.memberships.insert(MembershipEdge {
1127 role: "editors".to_string(),
1128 member: "old@example.com".to_string(),
1129 inherit: true,
1130 admin: false,
1131 });
1132 let desired = empty_graph();
1133
1134 let changes = diff(¤t, &desired);
1135 assert_eq!(changes.len(), 1);
1136 assert!(
1137 matches!(&changes[0], Change::RemoveMember { role, member } if role == "editors" && member == "old@example.com")
1138 );
1139 }
1140
1141 #[test]
1142 fn diff_re_grants_membership_when_flags_change() {
1143 let mut current = empty_graph();
1144 current.memberships.insert(MembershipEdge {
1145 role: "editors".to_string(),
1146 member: "user@example.com".to_string(),
1147 inherit: true,
1148 admin: false,
1149 });
1150
1151 let mut desired = empty_graph();
1152 desired.memberships.insert(MembershipEdge {
1153 role: "editors".to_string(),
1154 member: "user@example.com".to_string(),
1155 inherit: true,
1156 admin: true, });
1158
1159 let changes = diff(¤t, &desired);
1160 assert_eq!(changes.len(), 2);
1162 assert!(matches!(
1163 &changes[0],
1164 Change::RemoveMember { role, member }
1165 if role == "editors" && member == "user@example.com"
1166 ));
1167 assert!(matches!(
1168 &changes[1],
1169 Change::AddMember {
1170 role,
1171 member,
1172 admin: true,
1173 ..
1174 } if role == "editors" && member == "user@example.com"
1175 ));
1176 }
1177
1178 #[test]
1179 fn diff_default_privileges_add_and_revoke() {
1180 let mut current = empty_graph();
1181 let key = DefaultPrivKey {
1182 owner: "app_owner".to_string(),
1183 schema: "inventory".to_string(),
1184 on_type: ObjectType::Table,
1185 grantee: "inventory-editor".to_string(),
1186 };
1187 current.default_privileges.insert(
1188 key.clone(),
1189 DefaultPrivState {
1190 privileges: BTreeSet::from([Privilege::Select, Privilege::Delete]),
1191 },
1192 );
1193
1194 let mut desired = empty_graph();
1195 desired.default_privileges.insert(
1196 key,
1197 DefaultPrivState {
1198 privileges: BTreeSet::from([Privilege::Select, Privilege::Insert]),
1199 },
1200 );
1201
1202 let changes = diff(¤t, &desired);
1203 assert_eq!(changes.len(), 2);
1205 assert!(changes.iter().any(|c| matches!(
1206 c,
1207 Change::SetDefaultPrivilege { privileges, .. } if privileges.contains(&Privilege::Insert)
1208 )));
1209 assert!(changes.iter().any(|c| matches!(
1210 c,
1211 Change::RevokeDefaultPrivilege { privileges, .. } if privileges.contains(&Privilege::Delete)
1212 )));
1213 }
1214
1215 #[test]
1216 fn diff_ordering_creates_before_drops() {
1217 let mut current = empty_graph();
1218 current
1219 .roles
1220 .insert("old-role".to_string(), RoleState::default());
1221
1222 let mut desired = empty_graph();
1223 desired
1224 .roles
1225 .insert("new-role".to_string(), RoleState::default());
1226
1227 let changes = diff(¤t, &desired);
1228 assert_eq!(changes.len(), 2);
1229
1230 let create_idx = changes
1232 .iter()
1233 .position(|c| matches!(c, Change::CreateRole { .. }))
1234 .unwrap();
1235 let schema_idx = changes
1236 .iter()
1237 .position(|c| matches!(c, Change::CreateSchema { .. }))
1238 .unwrap_or(create_idx);
1239 let drop_idx = changes
1240 .iter()
1241 .position(|c| matches!(c, Change::DropRole { .. }))
1242 .unwrap();
1243 assert!(create_idx <= schema_idx);
1244 assert!(schema_idx < drop_idx);
1245 }
1246
1247 #[test]
1248 fn diff_identical_graphs_produce_no_changes() {
1249 let mut graph = empty_graph();
1250 graph
1251 .roles
1252 .insert("role1".to_string(), RoleState::default());
1253 graph.grants.insert(
1254 GrantKey {
1255 role: "role1".to_string(),
1256 object_type: ObjectType::Table,
1257 schema: Some("public".to_string()),
1258 name: Some("*".to_string()),
1259 },
1260 GrantState {
1261 privileges: BTreeSet::from([Privilege::Select]),
1262 },
1263 );
1264 graph.memberships.insert(MembershipEdge {
1265 role: "role1".to_string(),
1266 member: "user@example.com".to_string(),
1267 inherit: true,
1268 admin: false,
1269 });
1270
1271 let changes = diff(&graph, &graph);
1272 assert!(
1273 changes.is_empty(),
1274 "identical graphs should produce no changes"
1275 );
1276 }
1277
1278 #[test]
1280 fn manifest_to_diff_integration() {
1281 use crate::manifest::{expand_manifest, parse_manifest};
1282 use crate::model::RoleGraph;
1283
1284 let yaml = r#"
1285default_owner: app_owner
1286
1287profiles:
1288 editor:
1289 grants:
1290 - privileges: [USAGE]
1291 object: { type: schema }
1292 - privileges: [SELECT, INSERT, UPDATE, DELETE]
1293 object: { type: table, name: "*" }
1294 default_privileges:
1295 - privileges: [SELECT, INSERT, UPDATE, DELETE]
1296 on_type: table
1297
1298schemas:
1299 - name: inventory
1300 owner: inventory_owner
1301 profiles: [editor]
1302
1303memberships:
1304 - role: inventory-editor
1305 members:
1306 - name: "user@example.com"
1307"#;
1308 let manifest = parse_manifest(yaml).unwrap();
1309 let expanded = expand_manifest(&manifest).unwrap();
1310 let desired =
1311 RoleGraph::from_expanded(&expanded, manifest.default_owner.as_deref()).unwrap();
1312
1313 let current = RoleGraph::default();
1315 let changes = diff(¤t, &desired);
1316
1317 let create_count = changes
1319 .iter()
1320 .filter(|c| matches!(c, Change::CreateRole { .. }))
1321 .count();
1322 let create_schema_count = changes
1323 .iter()
1324 .filter(|c| matches!(c, Change::CreateSchema { .. }))
1325 .count();
1326 let grant_count = changes
1327 .iter()
1328 .filter(|c| matches!(c, Change::Grant { .. }))
1329 .count();
1330 let dp_count = changes
1331 .iter()
1332 .filter(|c| matches!(c, Change::SetDefaultPrivilege { .. }))
1333 .count();
1334 let member_count = changes
1335 .iter()
1336 .filter(|c| matches!(c, Change::AddMember { .. }))
1337 .count();
1338
1339 assert_eq!(create_count, 1);
1340 assert_eq!(create_schema_count, 1);
1341 assert_eq!(grant_count, 2); assert_eq!(dp_count, 1);
1343 assert_eq!(member_count, 1);
1344
1345 let no_changes = diff(&desired, &desired);
1347 assert!(no_changes.is_empty());
1348 }
1349
1350 fn all_change_variants() -> Vec<Change> {
1356 vec![
1357 Change::CreateRole {
1358 name: "new-role".to_string(),
1359 state: RoleState::default(),
1360 },
1361 Change::CreateSchema {
1362 name: "inventory".to_string(),
1363 owner: Some("inventory_owner".to_string()),
1364 },
1365 Change::AlterSchemaOwner {
1366 name: "catalog".to_string(),
1367 owner: "catalog_owner".to_string(),
1368 },
1369 Change::EnsureSchemaOwnerPrivileges {
1370 name: "catalog".to_string(),
1371 owner: "catalog_owner".to_string(),
1372 privileges: BTreeSet::from([Privilege::Create, Privilege::Usage]),
1373 },
1374 Change::AlterRole {
1375 name: "altered-role".to_string(),
1376 attributes: vec![RoleAttribute::Login(true)],
1377 },
1378 Change::SetComment {
1379 name: "commented-role".to_string(),
1380 comment: Some("hello".to_string()),
1381 },
1382 Change::Grant {
1383 role: "r1".to_string(),
1384 privileges: BTreeSet::from([Privilege::Select]),
1385 object_type: ObjectType::Table,
1386 schema: Some("public".to_string()),
1387 name: Some("*".to_string()),
1388 },
1389 Change::Revoke {
1390 role: "r1".to_string(),
1391 privileges: BTreeSet::from([Privilege::Insert]),
1392 object_type: ObjectType::Table,
1393 schema: Some("public".to_string()),
1394 name: Some("*".to_string()),
1395 },
1396 Change::SetDefaultPrivilege {
1397 owner: "owner".to_string(),
1398 schema: "public".to_string(),
1399 on_type: ObjectType::Table,
1400 grantee: "r1".to_string(),
1401 privileges: BTreeSet::from([Privilege::Select]),
1402 },
1403 Change::RevokeDefaultPrivilege {
1404 owner: "owner".to_string(),
1405 schema: "public".to_string(),
1406 on_type: ObjectType::Table,
1407 grantee: "r1".to_string(),
1408 privileges: BTreeSet::from([Privilege::Delete]),
1409 },
1410 Change::AddMember {
1411 role: "editors".to_string(),
1412 member: "user@example.com".to_string(),
1413 inherit: true,
1414 admin: false,
1415 },
1416 Change::RemoveMember {
1417 role: "editors".to_string(),
1418 member: "old@example.com".to_string(),
1419 },
1420 Change::TerminateSessions {
1421 role: "retired-role".to_string(),
1422 },
1423 Change::ReassignOwned {
1424 from_role: "retired-role".to_string(),
1425 to_role: "successor".to_string(),
1426 },
1427 Change::DropOwned {
1428 role: "retired-role".to_string(),
1429 },
1430 Change::DropRole {
1431 name: "retired-role".to_string(),
1432 },
1433 ]
1434 }
1435
1436 #[test]
1437 fn filter_authoritative_keeps_all_changes() {
1438 let changes = all_change_variants();
1439 let original_len = changes.len();
1440 let filtered = filter_changes(changes, ReconciliationMode::Authoritative);
1441 assert_eq!(filtered.len(), original_len);
1442 }
1443
1444 #[test]
1445 fn filter_additive_keeps_only_constructive_changes() {
1446 let filtered = filter_changes(all_change_variants(), ReconciliationMode::Additive);
1447
1448 assert_eq!(filtered.len(), 5);
1450
1451 for change in &filtered {
1453 assert!(
1454 !matches!(
1455 change,
1456 Change::AlterSchemaOwner { .. }
1457 | Change::EnsureSchemaOwnerPrivileges { .. }
1458 | Change::AlterRole { .. }
1459 | Change::SetComment { .. }
1460 | Change::Revoke { .. }
1461 | Change::RevokeDefaultPrivilege { .. }
1462 | Change::RemoveMember { .. }
1463 | Change::DropRole { .. }
1464 | Change::DropOwned { .. }
1465 | Change::ReassignOwned { .. }
1466 | Change::TerminateSessions { .. }
1467 ),
1468 "additive mode should not contain destructive change: {change:?}"
1469 );
1470 }
1471
1472 assert!(
1474 filtered
1475 .iter()
1476 .any(|c| matches!(c, Change::CreateRole { .. }))
1477 );
1478 assert!(
1479 filtered
1480 .iter()
1481 .any(|c| matches!(c, Change::CreateSchema { .. }))
1482 );
1483 assert!(
1484 filtered
1485 .iter()
1486 .all(|c| !matches!(c, Change::AlterRole { .. } | Change::SetComment { .. }))
1487 );
1488 assert!(filtered.iter().any(|c| matches!(c, Change::Grant { .. })));
1489 assert!(
1490 filtered
1491 .iter()
1492 .any(|c| matches!(c, Change::SetDefaultPrivilege { .. }))
1493 );
1494 assert!(
1495 filtered
1496 .iter()
1497 .any(|c| matches!(c, Change::AddMember { .. }))
1498 );
1499 }
1500
1501 #[test]
1502 fn filter_additive_skips_owner_bound_follow_ups_when_transfer_is_skipped() {
1503 let changes = vec![
1504 Change::AlterSchemaOwner {
1505 name: "inventory".to_string(),
1506 owner: "new_owner".to_string(),
1507 },
1508 Change::EnsureSchemaOwnerPrivileges {
1509 name: "inventory".to_string(),
1510 owner: "new_owner".to_string(),
1511 privileges: BTreeSet::from([Privilege::Create, Privilege::Usage]),
1512 },
1513 Change::SetDefaultPrivilege {
1514 owner: "new_owner".to_string(),
1515 schema: "inventory".to_string(),
1516 on_type: ObjectType::Table,
1517 grantee: "inventory-editor".to_string(),
1518 privileges: BTreeSet::from([Privilege::Select]),
1519 },
1520 Change::Grant {
1521 role: "inventory-editor".to_string(),
1522 privileges: BTreeSet::from([Privilege::Usage]),
1523 object_type: ObjectType::Schema,
1524 schema: None,
1525 name: Some("inventory".to_string()),
1526 },
1527 ];
1528
1529 let filtered = filter_changes(changes, ReconciliationMode::Additive);
1530 assert_eq!(filtered.len(), 1);
1531 assert!(matches!(&filtered[0], Change::Grant { role, .. } if role == "inventory-editor"));
1532 }
1533
1534 #[test]
1535 fn filter_adopt_keeps_revokes_but_not_drops() {
1536 let filtered = filter_changes(all_change_variants(), ReconciliationMode::Adopt);
1537
1538 assert_eq!(filtered.len(), 12);
1540
1541 for change in &filtered {
1543 assert!(
1544 !matches!(
1545 change,
1546 Change::DropRole { .. }
1547 | Change::DropOwned { .. }
1548 | Change::ReassignOwned { .. }
1549 | Change::TerminateSessions { .. }
1550 ),
1551 "adopt mode should not contain drop/retirement change: {change:?}"
1552 );
1553 }
1554
1555 assert!(filtered.iter().any(|c| matches!(c, Change::Revoke { .. })));
1557 assert!(
1558 filtered
1559 .iter()
1560 .any(|c| matches!(c, Change::RevokeDefaultPrivilege { .. }))
1561 );
1562 assert!(
1563 filtered
1564 .iter()
1565 .any(|c| matches!(c, Change::RemoveMember { .. }))
1566 );
1567 }
1568
1569 #[test]
1570 fn filter_additive_with_empty_input() {
1571 let filtered = filter_changes(vec![], ReconciliationMode::Additive);
1572 assert!(filtered.is_empty());
1573 }
1574
1575 #[test]
1576 fn filter_additive_only_destructive_changes_yields_empty() {
1577 let changes = vec![
1578 Change::Revoke {
1579 role: "r1".to_string(),
1580 privileges: BTreeSet::from([Privilege::Select]),
1581 object_type: ObjectType::Table,
1582 schema: Some("public".to_string()),
1583 name: Some("*".to_string()),
1584 },
1585 Change::DropRole {
1586 name: "old-role".to_string(),
1587 },
1588 ];
1589 let filtered = filter_changes(changes, ReconciliationMode::Additive);
1590 assert!(filtered.is_empty());
1591 }
1592
1593 #[test]
1594 fn filter_adopt_preserves_ordering() {
1595 let changes = vec![
1596 Change::CreateRole {
1597 name: "new-role".to_string(),
1598 state: RoleState::default(),
1599 },
1600 Change::Grant {
1601 role: "new-role".to_string(),
1602 privileges: BTreeSet::from([Privilege::Select]),
1603 object_type: ObjectType::Table,
1604 schema: Some("public".to_string()),
1605 name: Some("*".to_string()),
1606 },
1607 Change::Revoke {
1608 role: "existing-role".to_string(),
1609 privileges: BTreeSet::from([Privilege::Insert]),
1610 object_type: ObjectType::Table,
1611 schema: Some("public".to_string()),
1612 name: Some("*".to_string()),
1613 },
1614 Change::DropRole {
1615 name: "old-role".to_string(),
1616 },
1617 ];
1618
1619 let filtered = filter_changes(changes, ReconciliationMode::Adopt);
1620 assert_eq!(filtered.len(), 3);
1621 assert!(matches!(&filtered[0], Change::CreateRole { name, .. } if name == "new-role"));
1622 assert!(matches!(&filtered[1], Change::Grant { .. }));
1623 assert!(matches!(&filtered[2], Change::Revoke { .. }));
1624 }
1625
1626 #[test]
1627 fn reconciliation_mode_display() {
1628 assert_eq!(
1629 ReconciliationMode::Authoritative.to_string(),
1630 "authoritative"
1631 );
1632 assert_eq!(ReconciliationMode::Additive.to_string(), "additive");
1633 assert_eq!(ReconciliationMode::Adopt.to_string(), "adopt");
1634 }
1635
1636 #[test]
1637 fn reconciliation_mode_default_is_authoritative() {
1638 assert_eq!(
1639 ReconciliationMode::default(),
1640 ReconciliationMode::Authoritative
1641 );
1642 }
1643
1644 #[test]
1649 fn apply_role_retirements_inserts_cleanup_before_drop() {
1650 let changes = vec![
1651 Change::Grant {
1652 role: "analytics".to_string(),
1653 privileges: BTreeSet::from([Privilege::Select]),
1654 object_type: ObjectType::Table,
1655 schema: Some("public".to_string()),
1656 name: Some("*".to_string()),
1657 },
1658 Change::DropRole {
1659 name: "old-app".to_string(),
1660 },
1661 ];
1662
1663 let planned = apply_role_retirements(
1664 changes,
1665 &[crate::manifest::RoleRetirement {
1666 role: "old-app".to_string(),
1667 reassign_owned_to: Some("successor".to_string()),
1668 drop_owned: true,
1669 terminate_sessions: true,
1670 }],
1671 );
1672
1673 assert!(matches!(planned[0], Change::Grant { .. }));
1674 assert!(matches!(
1675 planned[1],
1676 Change::TerminateSessions { ref role } if role == "old-app"
1677 ));
1678 assert!(matches!(
1679 planned[2],
1680 Change::ReassignOwned {
1681 ref from_role,
1682 ref to_role
1683 } if from_role == "old-app" && to_role == "successor"
1684 ));
1685 assert!(matches!(
1686 planned[3],
1687 Change::DropOwned { ref role } if role == "old-app"
1688 ));
1689 assert!(matches!(
1690 planned[4],
1691 Change::DropRole { ref name } if name == "old-app"
1692 ));
1693 }
1694
1695 #[test]
1696 fn inject_password_for_new_role() {
1697 let changes = vec![Change::CreateRole {
1698 name: "app-svc".to_string(),
1699 state: RoleState::default(),
1700 }];
1701
1702 let mut passwords = std::collections::BTreeMap::new();
1703 passwords.insert("app-svc".to_string(), "secret123".to_string());
1704
1705 let result = inject_password_changes(changes, &passwords);
1706 assert_eq!(result.len(), 2);
1707 assert!(matches!(&result[0], Change::CreateRole { name, .. } if name == "app-svc"));
1708 assert!(
1709 matches!(&result[1], Change::SetPassword { name, password } if name == "app-svc" && password.starts_with("SCRAM-SHA-256$"))
1710 );
1711 }
1712
1713 #[test]
1714 fn inject_password_for_existing_role() {
1715 let changes = vec![Change::Grant {
1717 role: "app-svc".to_string(),
1718 privileges: BTreeSet::from([crate::manifest::Privilege::Select]),
1719 object_type: crate::manifest::ObjectType::Table,
1720 schema: Some("public".to_string()),
1721 name: Some("*".to_string()),
1722 }];
1723
1724 let mut passwords = std::collections::BTreeMap::new();
1725 passwords.insert("app-svc".to_string(), "secret123".to_string());
1726
1727 let result = inject_password_changes(changes, &passwords);
1728 assert_eq!(result.len(), 2);
1729 assert!(matches!(&result[0], Change::Grant { .. }));
1730 assert!(
1731 matches!(&result[1], Change::SetPassword { name, password } if name == "app-svc" && password.starts_with("SCRAM-SHA-256$"))
1732 );
1733 }
1734
1735 #[test]
1736 fn inject_password_empty_passwords_is_noop() {
1737 let changes = vec![Change::CreateRole {
1738 name: "app-svc".to_string(),
1739 state: RoleState::default(),
1740 }];
1741
1742 let passwords = std::collections::BTreeMap::new();
1743 let result = inject_password_changes(changes.clone(), &passwords);
1744 assert_eq!(result.len(), 1);
1745 }
1746
1747 #[test]
1748 fn resolve_passwords_missing_env_var() {
1749 let roles = vec![crate::manifest::RoleDefinition {
1750 name: "app-svc".to_string(),
1751 login: Some(true),
1752 password: Some(crate::manifest::PasswordSource {
1753 from_env: "PGROLES_TEST_MISSING_VAR_9a8b7c6d".to_string(),
1754 }),
1755 password_valid_until: None,
1756 superuser: None,
1757 createdb: None,
1758 createrole: None,
1759 inherit: None,
1760 replication: None,
1761 bypassrls: None,
1762 connection_limit: None,
1763 comment: None,
1764 }];
1765
1766 unsafe { std::env::remove_var("PGROLES_TEST_MISSING_VAR_9a8b7c6d") };
1769
1770 let result = resolve_passwords(&roles);
1771 assert!(result.is_err());
1772 let err = result.unwrap_err();
1773 assert!(
1774 matches!(err, PasswordResolutionError::MissingEnvVar { ref role, ref env_var }
1775 if role == "app-svc" && env_var == "PGROLES_TEST_MISSING_VAR_9a8b7c6d"),
1776 "expected MissingEnvVar, got: {err:?}"
1777 );
1778 }
1779
1780 #[test]
1781 fn resolve_passwords_empty_env_var() {
1782 let roles = vec![crate::manifest::RoleDefinition {
1783 name: "app-svc".to_string(),
1784 login: Some(true),
1785 password: Some(crate::manifest::PasswordSource {
1786 from_env: "PGROLES_TEST_EMPTY_VAR_1a2b3c4d".to_string(),
1787 }),
1788 password_valid_until: None,
1789 superuser: None,
1790 createdb: None,
1791 createrole: None,
1792 inherit: None,
1793 replication: None,
1794 bypassrls: None,
1795 connection_limit: None,
1796 comment: None,
1797 }];
1798
1799 unsafe { std::env::set_var("PGROLES_TEST_EMPTY_VAR_1a2b3c4d", "") };
1802
1803 let result = resolve_passwords(&roles);
1804
1805 unsafe { std::env::remove_var("PGROLES_TEST_EMPTY_VAR_1a2b3c4d") };
1807
1808 assert!(result.is_err());
1809 let err = result.unwrap_err();
1810 assert!(
1811 matches!(err, PasswordResolutionError::EmptyPassword { ref role, ref env_var }
1812 if role == "app-svc" && env_var == "PGROLES_TEST_EMPTY_VAR_1a2b3c4d"),
1813 "expected EmptyPassword, got: {err:?}"
1814 );
1815 }
1816
1817 #[test]
1818 fn resolve_passwords_happy_path() {
1819 let roles = vec![crate::manifest::RoleDefinition {
1820 name: "app-svc".to_string(),
1821 login: Some(true),
1822 password: Some(crate::manifest::PasswordSource {
1823 from_env: "PGROLES_TEST_RESOLVE_VAR_5e6f7g8h".to_string(),
1824 }),
1825 password_valid_until: None,
1826 superuser: None,
1827 createdb: None,
1828 createrole: None,
1829 inherit: None,
1830 replication: None,
1831 bypassrls: None,
1832 connection_limit: None,
1833 comment: None,
1834 }];
1835
1836 unsafe { std::env::set_var("PGROLES_TEST_RESOLVE_VAR_5e6f7g8h", "my_secret_pw") };
1838
1839 let result = resolve_passwords(&roles);
1840
1841 unsafe { std::env::remove_var("PGROLES_TEST_RESOLVE_VAR_5e6f7g8h") };
1842
1843 let resolved = result.expect("should succeed");
1844 assert_eq!(resolved.len(), 1);
1845 assert_eq!(resolved["app-svc"], "my_secret_pw");
1846 }
1847
1848 #[test]
1849 fn resolve_passwords_skips_roles_without_password() {
1850 let roles = vec![crate::manifest::RoleDefinition {
1851 name: "no-password".to_string(),
1852 login: Some(true),
1853 password: None,
1854 password_valid_until: None,
1855 superuser: None,
1856 createdb: None,
1857 createrole: None,
1858 inherit: None,
1859 replication: None,
1860 bypassrls: None,
1861 connection_limit: None,
1862 comment: None,
1863 }];
1864
1865 let result = resolve_passwords(&roles);
1866 let resolved = result.expect("should succeed");
1867 assert!(resolved.is_empty());
1868 }
1869
1870 #[test]
1871 fn inject_password_multiple_roles() {
1872 let changes = vec![
1873 Change::CreateRole {
1874 name: "role-a".to_string(),
1875 state: RoleState::default(),
1876 },
1877 Change::CreateRole {
1878 name: "role-b".to_string(),
1879 state: RoleState::default(),
1880 },
1881 Change::Grant {
1882 role: "role-c".to_string(),
1883 privileges: BTreeSet::from([crate::manifest::Privilege::Select]),
1884 object_type: crate::manifest::ObjectType::Table,
1885 schema: Some("public".to_string()),
1886 name: Some("*".to_string()),
1887 },
1888 ];
1889
1890 let mut passwords = std::collections::BTreeMap::new();
1891 passwords.insert("role-a".to_string(), "pw-a".to_string());
1892 passwords.insert("role-b".to_string(), "pw-b".to_string());
1893 passwords.insert("role-c".to_string(), "pw-c".to_string());
1894
1895 let result = inject_password_changes(changes, &passwords);
1896
1897 assert_eq!(result.len(), 6, "expected 6 changes, got: {result:?}");
1901 assert!(matches!(&result[0], Change::CreateRole { name, .. } if name == "role-a"));
1902 assert!(matches!(&result[1], Change::SetPassword { name, .. } if name == "role-a"));
1903 assert!(matches!(&result[2], Change::CreateRole { name, .. } if name == "role-b"));
1904 assert!(matches!(&result[3], Change::SetPassword { name, .. } if name == "role-b"));
1905 assert!(matches!(&result[4], Change::Grant { .. }));
1906 assert!(matches!(&result[5], Change::SetPassword { name, .. } if name == "role-c"));
1907 }
1908
1909 #[test]
1910 fn diff_detects_valid_until_change() {
1911 let mut current = empty_graph();
1912 current.roles.insert(
1913 "r1".to_string(),
1914 RoleState {
1915 login: true,
1916 ..RoleState::default()
1917 },
1918 );
1919
1920 let mut desired = empty_graph();
1921 desired.roles.insert(
1922 "r1".to_string(),
1923 RoleState {
1924 login: true,
1925 password_valid_until: Some("2025-12-31T00:00:00Z".to_string()),
1926 ..RoleState::default()
1927 },
1928 );
1929
1930 let changes = diff(¤t, &desired);
1931 assert_eq!(changes.len(), 1);
1932 match &changes[0] {
1933 Change::AlterRole { name, attributes } => {
1934 assert_eq!(name, "r1");
1935 assert!(attributes.contains(&RoleAttribute::ValidUntil(Some(
1936 "2025-12-31T00:00:00Z".to_string()
1937 ))));
1938 }
1939 other => panic!("expected AlterRole, got: {other:?}"),
1940 }
1941 }
1942
1943 #[test]
1954 fn diff_does_not_revoke_per_name_grants_covered_by_desired_wildcard() {
1955 let role = "cdc-editor".to_string();
1956 let schema = "cdc".to_string();
1957 let object_type = ObjectType::Function;
1958
1959 let mut current = empty_graph();
1964 for fn_name in ["f1()", "f3()"] {
1965 current.grants.insert(
1966 GrantKey {
1967 role: role.clone(),
1968 object_type,
1969 schema: Some(schema.clone()),
1970 name: Some(fn_name.to_string()),
1971 },
1972 GrantState {
1973 privileges: BTreeSet::from([Privilege::Execute]),
1974 },
1975 );
1976 }
1977
1978 let mut desired = empty_graph();
1981 desired.grants.insert(
1982 GrantKey {
1983 role: role.clone(),
1984 object_type,
1985 schema: Some(schema.clone()),
1986 name: Some("*".to_string()),
1987 },
1988 GrantState {
1989 privileges: BTreeSet::from([Privilege::Execute]),
1990 },
1991 );
1992
1993 let changes = diff(¤t, &desired);
1994
1995 let revokes: Vec<_> = changes
1996 .iter()
1997 .filter(|c| matches!(c, Change::Revoke { .. }))
1998 .collect();
1999 assert!(
2000 revokes.is_empty(),
2001 "must not revoke per-name grants covered by desired wildcard \
2002 (would cause apply-order flap); got: {revokes:#?}"
2003 );
2004
2005 let grants: Vec<_> = changes
2006 .iter()
2007 .filter(|c| matches!(c, Change::Grant { .. }))
2008 .collect();
2009 assert_eq!(
2010 grants.len(),
2011 1,
2012 "expected a single wildcard GRANT to materialise ACLs on all functions; got: {grants:#?}"
2013 );
2014 match grants[0] {
2015 Change::Grant {
2016 role: r,
2017 name,
2018 privileges,
2019 ..
2020 } => {
2021 assert_eq!(r, &role);
2022 assert_eq!(name.as_deref(), Some("*"));
2023 assert!(privileges.contains(&Privilege::Execute));
2024 }
2025 other => panic!("expected wildcard Grant, got: {other:?}"),
2026 }
2027 }
2028
2029 #[test]
2041 fn diff_does_not_revoke_extra_privileges_covered_by_desired_wildcard() {
2042 let role = "viewer".to_string();
2043 let schema = "myschema".to_string();
2044 let object_type = ObjectType::Table;
2045
2046 let mut current = empty_graph();
2049 current.grants.insert(
2050 GrantKey {
2051 role: role.clone(),
2052 object_type,
2053 schema: Some(schema.clone()),
2054 name: Some("widgets".to_string()),
2055 },
2056 GrantState {
2057 privileges: BTreeSet::from([Privilege::Select, Privilege::Insert]),
2058 },
2059 );
2060
2061 let mut desired = empty_graph();
2063 desired.grants.insert(
2064 GrantKey {
2065 role: role.clone(),
2066 object_type,
2067 schema: Some(schema.clone()),
2068 name: Some("*".to_string()),
2069 },
2070 GrantState {
2071 privileges: BTreeSet::from([Privilege::Select]),
2072 },
2073 );
2074 desired.grants.insert(
2075 GrantKey {
2076 role: role.clone(),
2077 object_type,
2078 schema: Some(schema.clone()),
2079 name: Some("widgets".to_string()),
2080 },
2081 GrantState {
2082 privileges: BTreeSet::from([Privilege::Insert]),
2083 },
2084 );
2085
2086 let changes = diff(¤t, &desired);
2087
2088 let revokes: Vec<_> = changes
2089 .iter()
2090 .filter(|c| matches!(c, Change::Revoke { .. }))
2091 .collect();
2092 assert!(
2093 revokes.is_empty(),
2094 "must not revoke widgets SELECT — covered by desired wildcard; got: {revokes:#?}"
2095 );
2096
2097 let grants: Vec<_> = changes
2100 .iter()
2101 .filter(|c| matches!(c, Change::Grant { .. }))
2102 .collect();
2103 let has_wildcard_select_grant = grants.iter().any(|c| {
2104 matches!(
2105 c,
2106 Change::Grant {
2107 name,
2108 privileges,
2109 ..
2110 } if name.as_deref() == Some("*")
2111 && privileges.contains(&Privilege::Select)
2112 )
2113 });
2114 assert!(
2115 has_wildcard_select_grant,
2116 "expected wildcard GRANT for SELECT; got: {grants:#?}"
2117 );
2118 }
2119
2120 #[test]
2121 fn diff_detects_valid_until_removal() {
2122 let mut current = empty_graph();
2123 current.roles.insert(
2124 "r1".to_string(),
2125 RoleState {
2126 login: true,
2127 password_valid_until: Some("2025-12-31T00:00:00Z".to_string()),
2128 ..RoleState::default()
2129 },
2130 );
2131
2132 let mut desired = empty_graph();
2133 desired.roles.insert(
2134 "r1".to_string(),
2135 RoleState {
2136 login: true,
2137 ..RoleState::default()
2138 },
2139 );
2140
2141 let changes = diff(¤t, &desired);
2142 assert_eq!(changes.len(), 1);
2143 match &changes[0] {
2144 Change::AlterRole { name, attributes } => {
2145 assert_eq!(name, "r1");
2146 assert!(attributes.contains(&RoleAttribute::ValidUntil(None)));
2147 }
2148 other => panic!("expected AlterRole, got: {other:?}"),
2149 }
2150 }
2151}