1use std::collections::{BTreeMap, BTreeSet};
12
13use crate::manifest::{ObjectType, Privilege, RoleDefinition, 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
208pub fn filter_external_role_changes(changes: Vec<Change>, roles: &[RoleDefinition]) -> Vec<Change> {
214 let external_roles: BTreeSet<&str> = roles
215 .iter()
216 .filter(|role| role.external)
217 .map(|role| role.name.as_str())
218 .collect();
219
220 if external_roles.is_empty() {
221 return changes;
222 }
223
224 changes
225 .into_iter()
226 .filter(|change| !is_external_role_change(change, &external_roles))
227 .collect()
228}
229
230fn is_external_role_change(change: &Change, external_roles: &BTreeSet<&str>) -> bool {
231 match change {
232 Change::CreateRole { name, .. }
233 | Change::AlterRole { name, .. }
234 | Change::SetComment { name, .. }
235 | Change::SetPassword { name, .. }
236 | Change::DropRole { name } => external_roles.contains(name.as_str()),
237 Change::AddMember { role, .. } | Change::RemoveMember { role, .. } => {
238 external_roles.contains(role.as_str())
239 }
240 Change::TerminateSessions { role }
241 | Change::DropOwned { role }
242 | Change::ReassignOwned {
243 from_role: role, ..
244 } => external_roles.contains(role.as_str()),
245 _ => false,
246 }
247}
248
249fn filter_additive_changes(changes: Vec<Change>) -> Vec<Change> {
250 let skipped_owner_transfers: BTreeSet<(String, String)> = changes
251 .iter()
252 .filter_map(|change| match change {
253 Change::AlterSchemaOwner { name, owner } => Some((name.clone(), owner.clone())),
254 _ => None,
255 })
256 .collect();
257
258 changes
259 .into_iter()
260 .filter(|change| match change {
261 Change::EnsureSchemaOwnerPrivileges { name, owner, .. } => {
262 !skipped_owner_transfers.contains(&(name.clone(), owner.clone()))
263 }
264 Change::SetDefaultPrivilege { schema, owner, .. } => {
265 !skipped_owner_transfers.contains(&(schema.clone(), owner.clone()))
266 }
267 Change::AlterRole { .. } | Change::SetComment { .. } => false,
268 _ => !is_destructive(change),
269 })
270 .collect()
271}
272
273fn is_destructive(change: &Change) -> bool {
275 matches!(
276 change,
277 Change::AlterSchemaOwner { .. }
278 | Change::Revoke { .. }
279 | Change::RevokeDefaultPrivilege { .. }
280 | Change::RemoveMember { .. }
281 | Change::DropRole { .. }
282 | Change::DropOwned { .. }
283 | Change::ReassignOwned { .. }
284 | Change::TerminateSessions { .. }
285 )
286}
287
288fn is_role_drop_or_retirement(change: &Change) -> bool {
290 matches!(
291 change,
292 Change::DropRole { .. }
293 | Change::DropOwned { .. }
294 | Change::ReassignOwned { .. }
295 | Change::TerminateSessions { .. }
296 )
297}
298
299pub fn diff(current: &RoleGraph, desired: &RoleGraph) -> Vec<Change> {
308 let mut creates = Vec::new();
309 let mut alters = Vec::new();
310 let mut schema_changes = Vec::new();
311 let mut schema_grants = Vec::new();
312 let mut grants = Vec::new();
313 let mut set_defaults = Vec::new();
314 let mut add_members = Vec::new();
315 let mut remove_members = Vec::new();
316 let mut revoke_defaults = Vec::new();
317 let mut revokes = Vec::new();
318 let mut drops = Vec::new();
319
320 for (name, desired_state) in &desired.roles {
324 match current.roles.get(name) {
325 None => {
326 creates.push(Change::CreateRole {
327 name: name.clone(),
328 state: desired_state.clone(),
329 });
330 }
331 Some(current_state) => {
332 let attribute_changes = current_state.changed_attributes(desired_state);
334 if !attribute_changes.is_empty() {
335 alters.push(Change::AlterRole {
336 name: name.clone(),
337 attributes: attribute_changes,
338 });
339 }
340 if current_state.comment != desired_state.comment {
342 alters.push(Change::SetComment {
343 name: name.clone(),
344 comment: desired_state.comment.clone(),
345 });
346 }
347 }
348 }
349 }
350
351 for name in current.roles.keys() {
353 if !desired.roles.contains_key(name) {
354 drops.push(Change::DropRole { name: name.clone() });
355 }
356 }
357
358 diff_schemas(current, desired, &mut schema_changes, &mut schema_grants);
361
362 diff_grants(current, desired, &mut grants, &mut revokes);
365
366 diff_default_privileges(current, desired, &mut set_defaults, &mut revoke_defaults);
369
370 diff_memberships(current, desired, &mut add_members, &mut remove_members);
373
374 let mut changes = Vec::new();
376 changes.extend(creates);
377 changes.extend(alters);
378 changes.extend(schema_changes);
379 changes.extend(schema_grants);
380 changes.extend(grants);
381 changes.extend(set_defaults);
382 changes.extend(remove_members);
383 changes.extend(add_members);
384 changes.extend(revoke_defaults);
385 changes.extend(revokes);
386 changes.extend(drops);
387 changes
388}
389
390fn diff_schemas(
391 current: &RoleGraph,
392 desired: &RoleGraph,
393 schema_out: &mut Vec<Change>,
394 grant_out: &mut Vec<Change>,
395) {
396 for (name, desired_state) in &desired.schemas {
397 match current.schemas.get(name) {
398 None => schema_out.push(Change::CreateSchema {
399 name: name.clone(),
400 owner: desired_state.owner.clone(),
401 }),
402 Some(current_state) => {
403 if current_state.owner != desired_state.owner
404 && let Some(owner) = &desired_state.owner
405 {
406 schema_out.push(Change::AlterSchemaOwner {
407 name: name.clone(),
408 owner: owner.clone(),
409 });
410 }
411 }
412 }
413
414 let Some(owner) = desired_state.owner.as_deref() else {
415 continue;
416 };
417
418 if !current.schemas.contains_key(name) {
419 continue;
420 }
421
422 let expected_privileges = default_schema_owner_privileges(owner);
423 let current_privileges = current
424 .schemas
425 .get(name)
426 .map(|state| state.owner_privileges.clone())
427 .unwrap_or_default();
428 let missing_privileges: BTreeSet<Privilege> = expected_privileges
429 .difference(¤t_privileges)
430 .copied()
431 .collect();
432
433 if !missing_privileges.is_empty() {
434 grant_out.push(Change::EnsureSchemaOwnerPrivileges {
435 name: name.clone(),
436 owner: owner.to_string(),
437 privileges: missing_privileges,
438 });
439 }
440 }
441}
442
443pub fn apply_role_retirements(changes: Vec<Change>, retirements: &[RoleRetirement]) -> Vec<Change> {
449 if retirements.is_empty() {
450 return changes;
451 }
452
453 let retirement_by_role: std::collections::BTreeMap<&str, &RoleRetirement> = retirements
454 .iter()
455 .map(|retirement| (retirement.role.as_str(), retirement))
456 .collect();
457
458 let mut planned = Vec::with_capacity(changes.len());
459 for change in changes {
460 if let Change::DropRole { name } = &change
461 && let Some(retirement) = retirement_by_role.get(name.as_str())
462 {
463 if retirement.terminate_sessions {
464 planned.push(Change::TerminateSessions { role: name.clone() });
465 }
466 if let Some(successor) = &retirement.reassign_owned_to {
467 planned.push(Change::ReassignOwned {
468 from_role: name.clone(),
469 to_role: successor.clone(),
470 });
471 }
472 if retirement.drop_owned {
473 planned.push(Change::DropOwned { role: name.clone() });
474 }
475 }
476 planned.push(change);
477 }
478
479 planned
480}
481
482pub fn resolve_passwords(
492 roles: &[crate::manifest::RoleDefinition],
493) -> Result<std::collections::BTreeMap<String, String>, PasswordResolutionError> {
494 let mut resolved = std::collections::BTreeMap::new();
495 for role in roles {
496 if role.external {
497 continue;
498 }
499 if let Some(source) = &role.password {
500 let value = std::env::var(&source.from_env).map_err(|_| {
501 PasswordResolutionError::MissingEnvVar {
502 role: role.name.clone(),
503 env_var: source.from_env.clone(),
504 }
505 })?;
506 if value.is_empty() {
507 return Err(PasswordResolutionError::EmptyPassword {
508 role: role.name.clone(),
509 env_var: source.from_env.clone(),
510 });
511 }
512 resolved.insert(role.name.clone(), value);
513 }
514 }
515 Ok(resolved)
516}
517
518#[derive(Debug, thiserror::Error)]
520pub enum PasswordResolutionError {
521 #[error("environment variable \"{env_var}\" for role \"{role}\" password is not set")]
522 MissingEnvVar { role: String, env_var: String },
523
524 #[error("environment variable \"{env_var}\" for role \"{role}\" password is empty")]
525 EmptyPassword { role: String, env_var: String },
526}
527
528pub fn inject_password_changes(
541 changes: Vec<Change>,
542 resolved_passwords: &std::collections::BTreeMap<String, String>,
543) -> Vec<Change> {
544 if resolved_passwords.is_empty() {
545 return changes;
546 }
547
548 let created_roles: std::collections::BTreeSet<String> = changes
550 .iter()
551 .filter_map(|c| match c {
552 Change::CreateRole { name, .. } => Some(name.clone()),
553 _ => None,
554 })
555 .collect();
556
557 let mut result = Vec::with_capacity(changes.len() + resolved_passwords.len());
558
559 for change in changes {
561 if let Change::CreateRole { ref name, .. } = change
562 && let Some(password) = resolved_passwords.get(name.as_str())
563 {
564 let role_name = name.clone();
565 let verifier =
566 crate::scram::compute_verifier(password, crate::scram::DEFAULT_ITERATIONS);
567 result.push(change);
568 result.push(Change::SetPassword {
569 name: role_name,
570 password: verifier,
571 });
572 continue;
573 }
574 result.push(change);
575 }
576
577 for (role_name, password) in resolved_passwords {
579 if !created_roles.contains(role_name) {
580 let verifier =
581 crate::scram::compute_verifier(password, crate::scram::DEFAULT_ITERATIONS);
582 result.push(Change::SetPassword {
583 name: role_name.clone(),
584 password: verifier,
585 });
586 }
587 }
588
589 result
590}
591
592fn diff_grants(
597 current: &RoleGraph,
598 desired: &RoleGraph,
599 grants_out: &mut Vec<Change>,
600 revokes_out: &mut Vec<Change>,
601) {
602 let desired_wildcards: BTreeMap<(&str, &Option<String>, ObjectType), &BTreeSet<Privilege>> =
622 desired
623 .grants
624 .iter()
625 .filter(|(k, _)| k.name.as_deref() == Some("*") && k.schema.is_some())
626 .map(|(k, v)| ((k.role.as_str(), &k.schema, k.object_type), &v.privileges))
627 .collect();
628
629 let shadow_filter = |key: &GrantKey, candidate: BTreeSet<Privilege>| -> BTreeSet<Privilege> {
633 if key.name.as_deref() == Some("*") {
634 return candidate;
635 }
636 match desired_wildcards.get(&(key.role.as_str(), &key.schema, key.object_type)) {
637 Some(wildcard_privileges) => {
638 candidate.difference(wildcard_privileges).copied().collect()
639 }
640 None => candidate,
641 }
642 };
643
644 for (key, desired_state) in &desired.grants {
647 match current.grants.get(key) {
648 None => {
649 grants_out.push(change_grant(key, &desired_state.privileges));
651 }
652 Some(current_state) => {
653 let to_add: BTreeSet<Privilege> = desired_state
655 .privileges
656 .difference(¤t_state.privileges)
657 .copied()
658 .collect();
659 let to_remove: BTreeSet<Privilege> = current_state
660 .privileges
661 .difference(&desired_state.privileges)
662 .copied()
663 .collect();
664 let to_remove = shadow_filter(key, to_remove);
665
666 if !to_add.is_empty() {
667 grants_out.push(change_grant(key, &to_add));
668 }
669 if !to_remove.is_empty() {
670 revokes_out.push(change_revoke(key, &to_remove));
671 }
672 }
673 }
674 }
675
676 for (key, current_state) in ¤t.grants {
679 if desired.grants.contains_key(key) {
680 continue;
681 }
682
683 let to_revoke = shadow_filter(key, current_state.privileges.clone());
684 if !to_revoke.is_empty() {
685 revokes_out.push(change_revoke(key, &to_revoke));
686 }
687 }
688}
689
690fn change_grant(key: &GrantKey, privileges: &BTreeSet<Privilege>) -> Change {
691 Change::Grant {
692 role: key.role.clone(),
693 privileges: privileges.clone(),
694 object_type: key.object_type,
695 schema: key.schema.clone(),
696 name: key.name.clone(),
697 }
698}
699
700fn change_revoke(key: &GrantKey, privileges: &BTreeSet<Privilege>) -> Change {
701 Change::Revoke {
702 role: key.role.clone(),
703 privileges: privileges.clone(),
704 object_type: key.object_type,
705 schema: key.schema.clone(),
706 name: key.name.clone(),
707 }
708}
709
710fn diff_default_privileges(
715 current: &RoleGraph,
716 desired: &RoleGraph,
717 set_out: &mut Vec<Change>,
718 revoke_out: &mut Vec<Change>,
719) {
720 for (key, desired_state) in &desired.default_privileges {
721 match current.default_privileges.get(key) {
722 None => {
723 set_out.push(change_set_default(key, &desired_state.privileges));
724 }
725 Some(current_state) => {
726 let to_add: BTreeSet<Privilege> = desired_state
727 .privileges
728 .difference(¤t_state.privileges)
729 .copied()
730 .collect();
731 let to_remove: BTreeSet<Privilege> = current_state
732 .privileges
733 .difference(&desired_state.privileges)
734 .copied()
735 .collect();
736
737 if !to_add.is_empty() {
738 set_out.push(change_set_default(key, &to_add));
739 }
740 if !to_remove.is_empty() {
741 revoke_out.push(change_revoke_default(key, &to_remove));
742 }
743 }
744 }
745 }
746
747 for (key, current_state) in ¤t.default_privileges {
748 if !desired.default_privileges.contains_key(key) {
749 revoke_out.push(change_revoke_default(key, ¤t_state.privileges));
750 }
751 }
752}
753
754fn change_set_default(key: &DefaultPrivKey, privileges: &BTreeSet<Privilege>) -> Change {
755 Change::SetDefaultPrivilege {
756 owner: key.owner.clone(),
757 schema: key.schema.clone(),
758 on_type: key.on_type,
759 grantee: key.grantee.clone(),
760 privileges: privileges.clone(),
761 }
762}
763
764fn change_revoke_default(key: &DefaultPrivKey, privileges: &BTreeSet<Privilege>) -> Change {
765 Change::RevokeDefaultPrivilege {
766 owner: key.owner.clone(),
767 schema: key.schema.clone(),
768 on_type: key.on_type,
769 grantee: key.grantee.clone(),
770 privileges: privileges.clone(),
771 }
772}
773
774fn diff_memberships(
779 current: &RoleGraph,
780 desired: &RoleGraph,
781 add_out: &mut Vec<Change>,
782 remove_out: &mut Vec<Change>,
783) {
784 let current_map: std::collections::BTreeMap<(&str, &str), &MembershipEdge> = current
789 .memberships
790 .iter()
791 .map(|edge| ((edge.role.as_str(), edge.member.as_str()), edge))
792 .collect();
793 let desired_map: std::collections::BTreeMap<(&str, &str), &MembershipEdge> = desired
794 .memberships
795 .iter()
796 .map(|edge| ((edge.role.as_str(), edge.member.as_str()), edge))
797 .collect();
798
799 for (&(role, member), &desired_edge) in &desired_map {
802 match current_map.get(&(role, member)) {
803 None => {
804 add_out.push(Change::AddMember {
805 role: desired_edge.role.clone(),
806 member: desired_edge.member.clone(),
807 inherit: desired_edge.inherit,
808 admin: desired_edge.admin,
809 });
810 }
811 Some(current_edge) => {
812 if current_edge.inherit != desired_edge.inherit
813 || current_edge.admin != desired_edge.admin
814 {
815 remove_out.push(Change::RemoveMember {
817 role: current_edge.role.clone(),
818 member: current_edge.member.clone(),
819 });
820 add_out.push(Change::AddMember {
821 role: desired_edge.role.clone(),
822 member: desired_edge.member.clone(),
823 inherit: desired_edge.inherit,
824 admin: desired_edge.admin,
825 });
826 }
827 }
828 }
829 }
830
831 for &(role, member) in current_map.keys() {
833 if !desired_map.contains_key(&(role, member)) {
834 remove_out.push(Change::RemoveMember {
835 role: role.to_string(),
836 member: member.to_string(),
837 });
838 }
839 }
840}
841
842#[cfg(test)]
847mod tests {
848 use super::*;
849 use crate::model::{
850 DefaultPrivState, GrantState, SchemaState, default_schema_owner_privileges,
851 };
852
853 fn empty_graph() -> RoleGraph {
855 RoleGraph::default()
856 }
857
858 fn managed_schema(owner: &str) -> SchemaState {
859 SchemaState {
860 owner: Some(owner.to_string()),
861 owner_privileges: default_schema_owner_privileges(owner),
862 }
863 }
864
865 fn role_definition(name: &str, external: bool) -> RoleDefinition {
866 RoleDefinition {
867 name: name.to_string(),
868 external,
869 login: None,
870 superuser: None,
871 createdb: None,
872 createrole: None,
873 inherit: None,
874 replication: None,
875 bypassrls: None,
876 connection_limit: None,
877 comment: None,
878 password: None,
879 password_valid_until: None,
880 }
881 }
882
883 #[test]
884 fn diff_empty_to_empty_is_empty() {
885 let changes = diff(&empty_graph(), &empty_graph());
886 assert!(changes.is_empty());
887 }
888
889 #[test]
890 fn diff_creates_new_roles() {
891 let current = empty_graph();
892 let mut desired = empty_graph();
893 desired
894 .roles
895 .insert("new-role".to_string(), RoleState::default());
896
897 let changes = diff(¤t, &desired);
898 assert_eq!(changes.len(), 1);
899 assert!(matches!(&changes[0], Change::CreateRole { name, .. } if name == "new-role"));
900 }
901
902 #[test]
903 fn diff_drops_removed_roles() {
904 let mut current = empty_graph();
905 current
906 .roles
907 .insert("old-role".to_string(), RoleState::default());
908 let desired = empty_graph();
909
910 let changes = diff(¤t, &desired);
911 assert_eq!(changes.len(), 1);
912 assert!(matches!(&changes[0], Change::DropRole { name } if name == "old-role"));
913 }
914
915 #[test]
916 fn diff_alters_changed_role_attributes() {
917 let mut current = empty_graph();
918 current
919 .roles
920 .insert("role1".to_string(), RoleState::default());
921
922 let mut desired = empty_graph();
923 desired.roles.insert(
924 "role1".to_string(),
925 RoleState {
926 login: true,
927 ..RoleState::default()
928 },
929 );
930
931 let changes = diff(¤t, &desired);
932 assert_eq!(changes.len(), 1);
933 match &changes[0] {
934 Change::AlterRole { name, attributes } => {
935 assert_eq!(name, "role1");
936 assert!(attributes.contains(&RoleAttribute::Login(true)));
937 }
938 other => panic!("expected AlterRole, got: {other:?}"),
939 }
940 }
941
942 #[test]
943 fn external_role_filter_suppresses_lifecycle_and_granted_role_memberships() {
944 let external = "analytics-admin@example.com";
945 let mut current = empty_graph();
946 current.roles.insert(
947 external.to_string(),
948 RoleState {
949 login: true,
950 ..RoleState::default()
951 },
952 );
953 current.memberships.insert(MembershipEdge {
954 role: external.to_string(),
955 member: "cloudsqlsuperuser".to_string(),
956 inherit: true,
957 admin: false,
958 });
959
960 let mut desired = empty_graph();
961 desired
962 .roles
963 .insert(external.to_string(), RoleState::default());
964
965 let changes = diff(¤t, &desired);
966 assert!(changes.iter().any(|change| {
967 matches!(
968 change,
969 Change::AlterRole { name, attributes }
970 if name == external && attributes.contains(&RoleAttribute::Login(false))
971 )
972 }));
973 assert!(changes.iter().any(|change| {
974 matches!(
975 change,
976 Change::RemoveMember { role, member }
977 if role == external && member == "cloudsqlsuperuser"
978 )
979 }));
980
981 let filtered = filter_external_role_changes(changes, &[role_definition(external, true)]);
982 assert!(filtered.is_empty());
983 }
984
985 #[test]
986 fn external_role_filter_keeps_external_role_as_managed_member() {
987 let external = "team@example.com";
988 let changes = vec![Change::RemoveMember {
989 role: "kv-editor".to_string(),
990 member: external.to_string(),
991 }];
992
993 let filtered =
994 filter_external_role_changes(changes.clone(), &[role_definition(external, true)]);
995 assert_eq!(filtered, changes);
996 }
997
998 #[test]
999 fn diff_creates_missing_schema() {
1000 let current = empty_graph();
1001 let mut desired = empty_graph();
1002 desired
1003 .schemas
1004 .insert("inventory".to_string(), managed_schema("inventory_owner"));
1005
1006 let changes = diff(¤t, &desired);
1007 assert_eq!(changes.len(), 1);
1008 assert!(matches!(
1009 &changes[0],
1010 Change::CreateSchema { name, owner }
1011 if name == "inventory" && owner.as_deref() == Some("inventory_owner")
1012 ));
1013 }
1014
1015 #[test]
1016 fn diff_alters_schema_owner_when_different() {
1017 let mut current = empty_graph();
1018 current
1019 .schemas
1020 .insert("inventory".to_string(), managed_schema("old_owner"));
1021
1022 let mut desired = empty_graph();
1023 desired
1024 .schemas
1025 .insert("inventory".to_string(), managed_schema("new_owner"));
1026
1027 let changes = diff(¤t, &desired);
1028 assert_eq!(changes.len(), 1);
1029 assert!(matches!(
1030 &changes[0],
1031 Change::AlterSchemaOwner { name, owner }
1032 if name == "inventory" && owner == "new_owner"
1033 ));
1034 }
1035
1036 #[test]
1037 fn diff_does_not_alter_schema_owner_when_unmanaged() {
1038 let mut current = empty_graph();
1039 current
1040 .schemas
1041 .insert("inventory".to_string(), managed_schema("old_owner"));
1042
1043 let mut desired = empty_graph();
1044 desired.schemas.insert(
1045 "inventory".to_string(),
1046 SchemaState {
1047 owner: None,
1048 owner_privileges: BTreeSet::new(),
1049 },
1050 );
1051
1052 let changes = diff(¤t, &desired);
1053 assert!(changes.is_empty());
1054 }
1055
1056 #[test]
1057 fn diff_restores_missing_owner_schema_privileges() {
1058 let mut current = empty_graph();
1059 current.schemas.insert(
1060 "inventory".to_string(),
1061 SchemaState {
1062 owner: Some("inventory_owner".to_string()),
1063 owner_privileges: BTreeSet::from([Privilege::Usage]),
1064 },
1065 );
1066
1067 let mut desired = empty_graph();
1068 desired
1069 .schemas
1070 .insert("inventory".to_string(), managed_schema("inventory_owner"));
1071
1072 let changes = diff(¤t, &desired);
1073 assert_eq!(changes.len(), 1);
1074 assert!(matches!(
1075 &changes[0],
1076 Change::EnsureSchemaOwnerPrivileges {
1077 name,
1078 owner,
1079 privileges,
1080 } if name == "inventory"
1081 && owner == "inventory_owner"
1082 && privileges == &BTreeSet::from([Privilege::Create])
1083 ));
1084 }
1085
1086 #[test]
1087 fn diff_restores_owner_schema_privileges_after_transfer() {
1088 let mut current = empty_graph();
1089 current.schemas.insert(
1090 "inventory".to_string(),
1091 SchemaState {
1092 owner: Some("old_owner".to_string()),
1093 owner_privileges: BTreeSet::from([Privilege::Usage]),
1094 },
1095 );
1096
1097 let mut desired = empty_graph();
1098 desired
1099 .schemas
1100 .insert("inventory".to_string(), managed_schema("new_owner"));
1101
1102 let changes = diff(¤t, &desired);
1103 assert_eq!(changes.len(), 2);
1104 assert!(matches!(
1105 &changes[0],
1106 Change::AlterSchemaOwner { name, owner }
1107 if name == "inventory" && owner == "new_owner"
1108 ));
1109 assert!(matches!(
1110 &changes[1],
1111 Change::EnsureSchemaOwnerPrivileges {
1112 name,
1113 owner,
1114 privileges,
1115 } if name == "inventory"
1116 && owner == "new_owner"
1117 && privileges == &BTreeSet::from([Privilege::Create])
1118 ));
1119 }
1120
1121 #[test]
1122 fn diff_grants_new_privileges() {
1123 let current = empty_graph();
1124 let mut desired = empty_graph();
1125 let key = GrantKey {
1126 role: "r1".to_string(),
1127 object_type: ObjectType::Table,
1128 schema: Some("public".to_string()),
1129 name: Some("*".to_string()),
1130 };
1131 desired.grants.insert(
1132 key,
1133 GrantState {
1134 privileges: BTreeSet::from([Privilege::Select, Privilege::Insert]),
1135 },
1136 );
1137
1138 let changes = diff(¤t, &desired);
1139 assert_eq!(changes.len(), 1);
1140 match &changes[0] {
1141 Change::Grant {
1142 role, privileges, ..
1143 } => {
1144 assert_eq!(role, "r1");
1145 assert!(privileges.contains(&Privilege::Select));
1146 assert!(privileges.contains(&Privilege::Insert));
1147 }
1148 other => panic!("expected Grant, got: {other:?}"),
1149 }
1150 }
1151
1152 #[test]
1153 fn diff_revokes_removed_privileges() {
1154 let mut current = empty_graph();
1155 let key = GrantKey {
1156 role: "r1".to_string(),
1157 object_type: ObjectType::Table,
1158 schema: Some("public".to_string()),
1159 name: Some("*".to_string()),
1160 };
1161 current.grants.insert(
1162 key.clone(),
1163 GrantState {
1164 privileges: BTreeSet::from([Privilege::Select, Privilege::Insert]),
1165 },
1166 );
1167
1168 let mut desired = empty_graph();
1169 desired.grants.insert(
1170 key,
1171 GrantState {
1172 privileges: BTreeSet::from([Privilege::Select]),
1173 },
1174 );
1175
1176 let changes = diff(¤t, &desired);
1177 assert_eq!(changes.len(), 1);
1178 match &changes[0] {
1179 Change::Revoke {
1180 role, privileges, ..
1181 } => {
1182 assert_eq!(role, "r1");
1183 assert!(privileges.contains(&Privilege::Insert));
1184 assert!(!privileges.contains(&Privilege::Select));
1185 }
1186 other => panic!("expected Revoke, got: {other:?}"),
1187 }
1188 }
1189
1190 #[test]
1191 fn diff_revokes_entire_grant_target_when_absent_from_desired() {
1192 let mut current = empty_graph();
1193 let key = GrantKey {
1194 role: "r1".to_string(),
1195 object_type: ObjectType::Schema,
1196 schema: None,
1197 name: Some("myschema".to_string()),
1198 };
1199 current.grants.insert(
1200 key,
1201 GrantState {
1202 privileges: BTreeSet::from([Privilege::Usage]),
1203 },
1204 );
1205 let desired = empty_graph();
1206
1207 let changes = diff(¤t, &desired);
1208 assert_eq!(changes.len(), 1);
1209 assert!(matches!(&changes[0], Change::Revoke { role, .. } if role == "r1"));
1210 }
1211
1212 #[test]
1213 fn diff_adds_memberships() {
1214 let current = empty_graph();
1215 let mut desired = empty_graph();
1216 desired.memberships.insert(MembershipEdge {
1217 role: "editors".to_string(),
1218 member: "user@example.com".to_string(),
1219 inherit: true,
1220 admin: false,
1221 });
1222
1223 let changes = diff(¤t, &desired);
1224 assert_eq!(changes.len(), 1);
1225 match &changes[0] {
1226 Change::AddMember {
1227 role,
1228 member,
1229 inherit,
1230 admin,
1231 } => {
1232 assert_eq!(role, "editors");
1233 assert_eq!(member, "user@example.com");
1234 assert!(*inherit);
1235 assert!(!admin);
1236 }
1237 other => panic!("expected AddMember, got: {other:?}"),
1238 }
1239 }
1240
1241 #[test]
1242 fn diff_removes_memberships() {
1243 let mut current = empty_graph();
1244 current.memberships.insert(MembershipEdge {
1245 role: "editors".to_string(),
1246 member: "old@example.com".to_string(),
1247 inherit: true,
1248 admin: false,
1249 });
1250 let desired = empty_graph();
1251
1252 let changes = diff(¤t, &desired);
1253 assert_eq!(changes.len(), 1);
1254 assert!(
1255 matches!(&changes[0], Change::RemoveMember { role, member } if role == "editors" && member == "old@example.com")
1256 );
1257 }
1258
1259 #[test]
1260 fn diff_re_grants_membership_when_flags_change() {
1261 let mut current = empty_graph();
1262 current.memberships.insert(MembershipEdge {
1263 role: "editors".to_string(),
1264 member: "user@example.com".to_string(),
1265 inherit: true,
1266 admin: false,
1267 });
1268
1269 let mut desired = empty_graph();
1270 desired.memberships.insert(MembershipEdge {
1271 role: "editors".to_string(),
1272 member: "user@example.com".to_string(),
1273 inherit: true,
1274 admin: true, });
1276
1277 let changes = diff(¤t, &desired);
1278 assert_eq!(changes.len(), 2);
1280 assert!(matches!(
1281 &changes[0],
1282 Change::RemoveMember { role, member }
1283 if role == "editors" && member == "user@example.com"
1284 ));
1285 assert!(matches!(
1286 &changes[1],
1287 Change::AddMember {
1288 role,
1289 member,
1290 admin: true,
1291 ..
1292 } if role == "editors" && member == "user@example.com"
1293 ));
1294 }
1295
1296 #[test]
1297 fn diff_default_privileges_add_and_revoke() {
1298 let mut current = empty_graph();
1299 let key = DefaultPrivKey {
1300 owner: "app_owner".to_string(),
1301 schema: "inventory".to_string(),
1302 on_type: ObjectType::Table,
1303 grantee: "inventory-editor".to_string(),
1304 };
1305 current.default_privileges.insert(
1306 key.clone(),
1307 DefaultPrivState {
1308 privileges: BTreeSet::from([Privilege::Select, Privilege::Delete]),
1309 },
1310 );
1311
1312 let mut desired = empty_graph();
1313 desired.default_privileges.insert(
1314 key,
1315 DefaultPrivState {
1316 privileges: BTreeSet::from([Privilege::Select, Privilege::Insert]),
1317 },
1318 );
1319
1320 let changes = diff(¤t, &desired);
1321 assert_eq!(changes.len(), 2);
1323 assert!(changes.iter().any(|c| matches!(
1324 c,
1325 Change::SetDefaultPrivilege { privileges, .. } if privileges.contains(&Privilege::Insert)
1326 )));
1327 assert!(changes.iter().any(|c| matches!(
1328 c,
1329 Change::RevokeDefaultPrivilege { privileges, .. } if privileges.contains(&Privilege::Delete)
1330 )));
1331 }
1332
1333 #[test]
1334 fn diff_ordering_creates_before_drops() {
1335 let mut current = empty_graph();
1336 current
1337 .roles
1338 .insert("old-role".to_string(), RoleState::default());
1339
1340 let mut desired = empty_graph();
1341 desired
1342 .roles
1343 .insert("new-role".to_string(), RoleState::default());
1344
1345 let changes = diff(¤t, &desired);
1346 assert_eq!(changes.len(), 2);
1347
1348 let create_idx = changes
1350 .iter()
1351 .position(|c| matches!(c, Change::CreateRole { .. }))
1352 .unwrap();
1353 let schema_idx = changes
1354 .iter()
1355 .position(|c| matches!(c, Change::CreateSchema { .. }))
1356 .unwrap_or(create_idx);
1357 let drop_idx = changes
1358 .iter()
1359 .position(|c| matches!(c, Change::DropRole { .. }))
1360 .unwrap();
1361 assert!(create_idx <= schema_idx);
1362 assert!(schema_idx < drop_idx);
1363 }
1364
1365 #[test]
1366 fn diff_identical_graphs_produce_no_changes() {
1367 let mut graph = empty_graph();
1368 graph
1369 .roles
1370 .insert("role1".to_string(), RoleState::default());
1371 graph.grants.insert(
1372 GrantKey {
1373 role: "role1".to_string(),
1374 object_type: ObjectType::Table,
1375 schema: Some("public".to_string()),
1376 name: Some("*".to_string()),
1377 },
1378 GrantState {
1379 privileges: BTreeSet::from([Privilege::Select]),
1380 },
1381 );
1382 graph.memberships.insert(MembershipEdge {
1383 role: "role1".to_string(),
1384 member: "user@example.com".to_string(),
1385 inherit: true,
1386 admin: false,
1387 });
1388
1389 let changes = diff(&graph, &graph);
1390 assert!(
1391 changes.is_empty(),
1392 "identical graphs should produce no changes"
1393 );
1394 }
1395
1396 #[test]
1398 fn manifest_to_diff_integration() {
1399 use crate::manifest::{expand_manifest, parse_manifest};
1400 use crate::model::RoleGraph;
1401
1402 let yaml = r#"
1403default_owner: app_owner
1404
1405profiles:
1406 editor:
1407 grants:
1408 - privileges: [USAGE]
1409 object: { type: schema }
1410 - privileges: [SELECT, INSERT, UPDATE, DELETE]
1411 object: { type: table, name: "*" }
1412 default_privileges:
1413 - privileges: [SELECT, INSERT, UPDATE, DELETE]
1414 on_type: table
1415
1416schemas:
1417 - name: inventory
1418 owner: inventory_owner
1419 profiles: [editor]
1420
1421memberships:
1422 - role: inventory-editor
1423 members:
1424 - name: "user@example.com"
1425"#;
1426 let manifest = parse_manifest(yaml).unwrap();
1427 let expanded = expand_manifest(&manifest).unwrap();
1428 let desired =
1429 RoleGraph::from_expanded(&expanded, manifest.default_owner.as_deref()).unwrap();
1430
1431 let current = RoleGraph::default();
1433 let changes = diff(¤t, &desired);
1434
1435 let create_count = changes
1437 .iter()
1438 .filter(|c| matches!(c, Change::CreateRole { .. }))
1439 .count();
1440 let create_schema_count = changes
1441 .iter()
1442 .filter(|c| matches!(c, Change::CreateSchema { .. }))
1443 .count();
1444 let grant_count = changes
1445 .iter()
1446 .filter(|c| matches!(c, Change::Grant { .. }))
1447 .count();
1448 let dp_count = changes
1449 .iter()
1450 .filter(|c| matches!(c, Change::SetDefaultPrivilege { .. }))
1451 .count();
1452 let member_count = changes
1453 .iter()
1454 .filter(|c| matches!(c, Change::AddMember { .. }))
1455 .count();
1456
1457 assert_eq!(create_count, 1);
1458 assert_eq!(create_schema_count, 1);
1459 assert_eq!(grant_count, 2); assert_eq!(dp_count, 1);
1461 assert_eq!(member_count, 1);
1462
1463 let no_changes = diff(&desired, &desired);
1465 assert!(no_changes.is_empty());
1466 }
1467
1468 fn all_change_variants() -> Vec<Change> {
1474 vec![
1475 Change::CreateRole {
1476 name: "new-role".to_string(),
1477 state: RoleState::default(),
1478 },
1479 Change::CreateSchema {
1480 name: "inventory".to_string(),
1481 owner: Some("inventory_owner".to_string()),
1482 },
1483 Change::AlterSchemaOwner {
1484 name: "catalog".to_string(),
1485 owner: "catalog_owner".to_string(),
1486 },
1487 Change::EnsureSchemaOwnerPrivileges {
1488 name: "catalog".to_string(),
1489 owner: "catalog_owner".to_string(),
1490 privileges: BTreeSet::from([Privilege::Create, Privilege::Usage]),
1491 },
1492 Change::AlterRole {
1493 name: "altered-role".to_string(),
1494 attributes: vec![RoleAttribute::Login(true)],
1495 },
1496 Change::SetComment {
1497 name: "commented-role".to_string(),
1498 comment: Some("hello".to_string()),
1499 },
1500 Change::Grant {
1501 role: "r1".to_string(),
1502 privileges: BTreeSet::from([Privilege::Select]),
1503 object_type: ObjectType::Table,
1504 schema: Some("public".to_string()),
1505 name: Some("*".to_string()),
1506 },
1507 Change::Revoke {
1508 role: "r1".to_string(),
1509 privileges: BTreeSet::from([Privilege::Insert]),
1510 object_type: ObjectType::Table,
1511 schema: Some("public".to_string()),
1512 name: Some("*".to_string()),
1513 },
1514 Change::SetDefaultPrivilege {
1515 owner: "owner".to_string(),
1516 schema: "public".to_string(),
1517 on_type: ObjectType::Table,
1518 grantee: "r1".to_string(),
1519 privileges: BTreeSet::from([Privilege::Select]),
1520 },
1521 Change::RevokeDefaultPrivilege {
1522 owner: "owner".to_string(),
1523 schema: "public".to_string(),
1524 on_type: ObjectType::Table,
1525 grantee: "r1".to_string(),
1526 privileges: BTreeSet::from([Privilege::Delete]),
1527 },
1528 Change::AddMember {
1529 role: "editors".to_string(),
1530 member: "user@example.com".to_string(),
1531 inherit: true,
1532 admin: false,
1533 },
1534 Change::RemoveMember {
1535 role: "editors".to_string(),
1536 member: "old@example.com".to_string(),
1537 },
1538 Change::TerminateSessions {
1539 role: "retired-role".to_string(),
1540 },
1541 Change::ReassignOwned {
1542 from_role: "retired-role".to_string(),
1543 to_role: "successor".to_string(),
1544 },
1545 Change::DropOwned {
1546 role: "retired-role".to_string(),
1547 },
1548 Change::DropRole {
1549 name: "retired-role".to_string(),
1550 },
1551 ]
1552 }
1553
1554 #[test]
1555 fn filter_authoritative_keeps_all_changes() {
1556 let changes = all_change_variants();
1557 let original_len = changes.len();
1558 let filtered = filter_changes(changes, ReconciliationMode::Authoritative);
1559 assert_eq!(filtered.len(), original_len);
1560 }
1561
1562 #[test]
1563 fn filter_additive_keeps_only_constructive_changes() {
1564 let filtered = filter_changes(all_change_variants(), ReconciliationMode::Additive);
1565
1566 assert_eq!(filtered.len(), 5);
1568
1569 for change in &filtered {
1571 assert!(
1572 !matches!(
1573 change,
1574 Change::AlterSchemaOwner { .. }
1575 | Change::EnsureSchemaOwnerPrivileges { .. }
1576 | Change::AlterRole { .. }
1577 | Change::SetComment { .. }
1578 | Change::Revoke { .. }
1579 | Change::RevokeDefaultPrivilege { .. }
1580 | Change::RemoveMember { .. }
1581 | Change::DropRole { .. }
1582 | Change::DropOwned { .. }
1583 | Change::ReassignOwned { .. }
1584 | Change::TerminateSessions { .. }
1585 ),
1586 "additive mode should not contain destructive change: {change:?}"
1587 );
1588 }
1589
1590 assert!(
1592 filtered
1593 .iter()
1594 .any(|c| matches!(c, Change::CreateRole { .. }))
1595 );
1596 assert!(
1597 filtered
1598 .iter()
1599 .any(|c| matches!(c, Change::CreateSchema { .. }))
1600 );
1601 assert!(
1602 filtered
1603 .iter()
1604 .all(|c| !matches!(c, Change::AlterRole { .. } | Change::SetComment { .. }))
1605 );
1606 assert!(filtered.iter().any(|c| matches!(c, Change::Grant { .. })));
1607 assert!(
1608 filtered
1609 .iter()
1610 .any(|c| matches!(c, Change::SetDefaultPrivilege { .. }))
1611 );
1612 assert!(
1613 filtered
1614 .iter()
1615 .any(|c| matches!(c, Change::AddMember { .. }))
1616 );
1617 }
1618
1619 #[test]
1620 fn filter_additive_skips_owner_bound_follow_ups_when_transfer_is_skipped() {
1621 let changes = vec![
1622 Change::AlterSchemaOwner {
1623 name: "inventory".to_string(),
1624 owner: "new_owner".to_string(),
1625 },
1626 Change::EnsureSchemaOwnerPrivileges {
1627 name: "inventory".to_string(),
1628 owner: "new_owner".to_string(),
1629 privileges: BTreeSet::from([Privilege::Create, Privilege::Usage]),
1630 },
1631 Change::SetDefaultPrivilege {
1632 owner: "new_owner".to_string(),
1633 schema: "inventory".to_string(),
1634 on_type: ObjectType::Table,
1635 grantee: "inventory-editor".to_string(),
1636 privileges: BTreeSet::from([Privilege::Select]),
1637 },
1638 Change::Grant {
1639 role: "inventory-editor".to_string(),
1640 privileges: BTreeSet::from([Privilege::Usage]),
1641 object_type: ObjectType::Schema,
1642 schema: None,
1643 name: Some("inventory".to_string()),
1644 },
1645 ];
1646
1647 let filtered = filter_changes(changes, ReconciliationMode::Additive);
1648 assert_eq!(filtered.len(), 1);
1649 assert!(matches!(&filtered[0], Change::Grant { role, .. } if role == "inventory-editor"));
1650 }
1651
1652 #[test]
1653 fn filter_adopt_keeps_revokes_but_not_drops() {
1654 let filtered = filter_changes(all_change_variants(), ReconciliationMode::Adopt);
1655
1656 assert_eq!(filtered.len(), 12);
1658
1659 for change in &filtered {
1661 assert!(
1662 !matches!(
1663 change,
1664 Change::DropRole { .. }
1665 | Change::DropOwned { .. }
1666 | Change::ReassignOwned { .. }
1667 | Change::TerminateSessions { .. }
1668 ),
1669 "adopt mode should not contain drop/retirement change: {change:?}"
1670 );
1671 }
1672
1673 assert!(filtered.iter().any(|c| matches!(c, Change::Revoke { .. })));
1675 assert!(
1676 filtered
1677 .iter()
1678 .any(|c| matches!(c, Change::RevokeDefaultPrivilege { .. }))
1679 );
1680 assert!(
1681 filtered
1682 .iter()
1683 .any(|c| matches!(c, Change::RemoveMember { .. }))
1684 );
1685 }
1686
1687 #[test]
1688 fn filter_additive_with_empty_input() {
1689 let filtered = filter_changes(vec![], ReconciliationMode::Additive);
1690 assert!(filtered.is_empty());
1691 }
1692
1693 #[test]
1694 fn filter_additive_only_destructive_changes_yields_empty() {
1695 let changes = vec![
1696 Change::Revoke {
1697 role: "r1".to_string(),
1698 privileges: BTreeSet::from([Privilege::Select]),
1699 object_type: ObjectType::Table,
1700 schema: Some("public".to_string()),
1701 name: Some("*".to_string()),
1702 },
1703 Change::DropRole {
1704 name: "old-role".to_string(),
1705 },
1706 ];
1707 let filtered = filter_changes(changes, ReconciliationMode::Additive);
1708 assert!(filtered.is_empty());
1709 }
1710
1711 #[test]
1712 fn filter_adopt_preserves_ordering() {
1713 let changes = vec![
1714 Change::CreateRole {
1715 name: "new-role".to_string(),
1716 state: RoleState::default(),
1717 },
1718 Change::Grant {
1719 role: "new-role".to_string(),
1720 privileges: BTreeSet::from([Privilege::Select]),
1721 object_type: ObjectType::Table,
1722 schema: Some("public".to_string()),
1723 name: Some("*".to_string()),
1724 },
1725 Change::Revoke {
1726 role: "existing-role".to_string(),
1727 privileges: BTreeSet::from([Privilege::Insert]),
1728 object_type: ObjectType::Table,
1729 schema: Some("public".to_string()),
1730 name: Some("*".to_string()),
1731 },
1732 Change::DropRole {
1733 name: "old-role".to_string(),
1734 },
1735 ];
1736
1737 let filtered = filter_changes(changes, ReconciliationMode::Adopt);
1738 assert_eq!(filtered.len(), 3);
1739 assert!(matches!(&filtered[0], Change::CreateRole { name, .. } if name == "new-role"));
1740 assert!(matches!(&filtered[1], Change::Grant { .. }));
1741 assert!(matches!(&filtered[2], Change::Revoke { .. }));
1742 }
1743
1744 #[test]
1745 fn reconciliation_mode_display() {
1746 assert_eq!(
1747 ReconciliationMode::Authoritative.to_string(),
1748 "authoritative"
1749 );
1750 assert_eq!(ReconciliationMode::Additive.to_string(), "additive");
1751 assert_eq!(ReconciliationMode::Adopt.to_string(), "adopt");
1752 }
1753
1754 #[test]
1755 fn reconciliation_mode_default_is_authoritative() {
1756 assert_eq!(
1757 ReconciliationMode::default(),
1758 ReconciliationMode::Authoritative
1759 );
1760 }
1761
1762 #[test]
1767 fn apply_role_retirements_inserts_cleanup_before_drop() {
1768 let changes = vec![
1769 Change::Grant {
1770 role: "analytics".to_string(),
1771 privileges: BTreeSet::from([Privilege::Select]),
1772 object_type: ObjectType::Table,
1773 schema: Some("public".to_string()),
1774 name: Some("*".to_string()),
1775 },
1776 Change::DropRole {
1777 name: "old-app".to_string(),
1778 },
1779 ];
1780
1781 let planned = apply_role_retirements(
1782 changes,
1783 &[crate::manifest::RoleRetirement {
1784 role: "old-app".to_string(),
1785 reassign_owned_to: Some("successor".to_string()),
1786 drop_owned: true,
1787 terminate_sessions: true,
1788 }],
1789 );
1790
1791 assert!(matches!(planned[0], Change::Grant { .. }));
1792 assert!(matches!(
1793 planned[1],
1794 Change::TerminateSessions { ref role } if role == "old-app"
1795 ));
1796 assert!(matches!(
1797 planned[2],
1798 Change::ReassignOwned {
1799 ref from_role,
1800 ref to_role
1801 } if from_role == "old-app" && to_role == "successor"
1802 ));
1803 assert!(matches!(
1804 planned[3],
1805 Change::DropOwned { ref role } if role == "old-app"
1806 ));
1807 assert!(matches!(
1808 planned[4],
1809 Change::DropRole { ref name } if name == "old-app"
1810 ));
1811 }
1812
1813 #[test]
1814 fn inject_password_for_new_role() {
1815 let changes = vec![Change::CreateRole {
1816 name: "app-svc".to_string(),
1817 state: RoleState::default(),
1818 }];
1819
1820 let mut passwords = std::collections::BTreeMap::new();
1821 passwords.insert("app-svc".to_string(), "secret123".to_string());
1822
1823 let result = inject_password_changes(changes, &passwords);
1824 assert_eq!(result.len(), 2);
1825 assert!(matches!(&result[0], Change::CreateRole { name, .. } if name == "app-svc"));
1826 assert!(
1827 matches!(&result[1], Change::SetPassword { name, password } if name == "app-svc" && password.starts_with("SCRAM-SHA-256$"))
1828 );
1829 }
1830
1831 #[test]
1832 fn inject_password_for_existing_role() {
1833 let changes = vec![Change::Grant {
1835 role: "app-svc".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 let mut passwords = std::collections::BTreeMap::new();
1843 passwords.insert("app-svc".to_string(), "secret123".to_string());
1844
1845 let result = inject_password_changes(changes, &passwords);
1846 assert_eq!(result.len(), 2);
1847 assert!(matches!(&result[0], Change::Grant { .. }));
1848 assert!(
1849 matches!(&result[1], Change::SetPassword { name, password } if name == "app-svc" && password.starts_with("SCRAM-SHA-256$"))
1850 );
1851 }
1852
1853 #[test]
1854 fn inject_password_empty_passwords_is_noop() {
1855 let changes = vec![Change::CreateRole {
1856 name: "app-svc".to_string(),
1857 state: RoleState::default(),
1858 }];
1859
1860 let passwords = std::collections::BTreeMap::new();
1861 let result = inject_password_changes(changes.clone(), &passwords);
1862 assert_eq!(result.len(), 1);
1863 }
1864
1865 #[test]
1866 fn resolve_passwords_missing_env_var() {
1867 let roles = vec![crate::manifest::RoleDefinition {
1868 name: "app-svc".to_string(),
1869 external: false,
1870 login: Some(true),
1871 password: Some(crate::manifest::PasswordSource {
1872 from_env: "PGROLES_TEST_MISSING_VAR_9a8b7c6d".to_string(),
1873 }),
1874 password_valid_until: None,
1875 superuser: None,
1876 createdb: None,
1877 createrole: None,
1878 inherit: None,
1879 replication: None,
1880 bypassrls: None,
1881 connection_limit: None,
1882 comment: None,
1883 }];
1884
1885 unsafe { std::env::remove_var("PGROLES_TEST_MISSING_VAR_9a8b7c6d") };
1888
1889 let result = resolve_passwords(&roles);
1890 assert!(result.is_err());
1891 let err = result.unwrap_err();
1892 assert!(
1893 matches!(err, PasswordResolutionError::MissingEnvVar { ref role, ref env_var }
1894 if role == "app-svc" && env_var == "PGROLES_TEST_MISSING_VAR_9a8b7c6d"),
1895 "expected MissingEnvVar, got: {err:?}"
1896 );
1897 }
1898
1899 #[test]
1900 fn resolve_passwords_empty_env_var() {
1901 let roles = vec![crate::manifest::RoleDefinition {
1902 name: "app-svc".to_string(),
1903 external: false,
1904 login: Some(true),
1905 password: Some(crate::manifest::PasswordSource {
1906 from_env: "PGROLES_TEST_EMPTY_VAR_1a2b3c4d".to_string(),
1907 }),
1908 password_valid_until: None,
1909 superuser: None,
1910 createdb: None,
1911 createrole: None,
1912 inherit: None,
1913 replication: None,
1914 bypassrls: None,
1915 connection_limit: None,
1916 comment: None,
1917 }];
1918
1919 unsafe { std::env::set_var("PGROLES_TEST_EMPTY_VAR_1a2b3c4d", "") };
1922
1923 let result = resolve_passwords(&roles);
1924
1925 unsafe { std::env::remove_var("PGROLES_TEST_EMPTY_VAR_1a2b3c4d") };
1927
1928 assert!(result.is_err());
1929 let err = result.unwrap_err();
1930 assert!(
1931 matches!(err, PasswordResolutionError::EmptyPassword { ref role, ref env_var }
1932 if role == "app-svc" && env_var == "PGROLES_TEST_EMPTY_VAR_1a2b3c4d"),
1933 "expected EmptyPassword, got: {err:?}"
1934 );
1935 }
1936
1937 #[test]
1938 fn resolve_passwords_happy_path() {
1939 let roles = vec![crate::manifest::RoleDefinition {
1940 name: "app-svc".to_string(),
1941 external: false,
1942 login: Some(true),
1943 password: Some(crate::manifest::PasswordSource {
1944 from_env: "PGROLES_TEST_RESOLVE_VAR_5e6f7g8h".to_string(),
1945 }),
1946 password_valid_until: None,
1947 superuser: None,
1948 createdb: None,
1949 createrole: None,
1950 inherit: None,
1951 replication: None,
1952 bypassrls: None,
1953 connection_limit: None,
1954 comment: None,
1955 }];
1956
1957 unsafe { std::env::set_var("PGROLES_TEST_RESOLVE_VAR_5e6f7g8h", "my_secret_pw") };
1959
1960 let result = resolve_passwords(&roles);
1961
1962 unsafe { std::env::remove_var("PGROLES_TEST_RESOLVE_VAR_5e6f7g8h") };
1963
1964 let resolved = result.expect("should succeed");
1965 assert_eq!(resolved.len(), 1);
1966 assert_eq!(resolved["app-svc"], "my_secret_pw");
1967 }
1968
1969 #[test]
1970 fn resolve_passwords_skips_external_roles() {
1971 let roles = vec![crate::manifest::RoleDefinition {
1972 name: "external-svc".to_string(),
1973 external: true,
1974 login: Some(true),
1975 password: Some(crate::manifest::PasswordSource {
1976 from_env: "PGROLES_TEST_EXTERNAL_MISSING_VAR_2b4d6f8h".to_string(),
1977 }),
1978 password_valid_until: None,
1979 superuser: None,
1980 createdb: None,
1981 createrole: None,
1982 inherit: None,
1983 replication: None,
1984 bypassrls: None,
1985 connection_limit: None,
1986 comment: None,
1987 }];
1988
1989 unsafe { std::env::remove_var("PGROLES_TEST_EXTERNAL_MISSING_VAR_2b4d6f8h") };
1991
1992 let resolved = resolve_passwords(&roles).expect("external role passwords are ignored");
1993 assert!(resolved.is_empty());
1994 }
1995
1996 #[test]
1997 fn resolve_passwords_skips_roles_without_password() {
1998 let roles = vec![crate::manifest::RoleDefinition {
1999 name: "no-password".to_string(),
2000 external: false,
2001 login: Some(true),
2002 password: None,
2003 password_valid_until: None,
2004 superuser: None,
2005 createdb: None,
2006 createrole: None,
2007 inherit: None,
2008 replication: None,
2009 bypassrls: None,
2010 connection_limit: None,
2011 comment: None,
2012 }];
2013
2014 let result = resolve_passwords(&roles);
2015 let resolved = result.expect("should succeed");
2016 assert!(resolved.is_empty());
2017 }
2018
2019 #[test]
2020 fn inject_password_multiple_roles() {
2021 let changes = vec![
2022 Change::CreateRole {
2023 name: "role-a".to_string(),
2024 state: RoleState::default(),
2025 },
2026 Change::CreateRole {
2027 name: "role-b".to_string(),
2028 state: RoleState::default(),
2029 },
2030 Change::Grant {
2031 role: "role-c".to_string(),
2032 privileges: BTreeSet::from([crate::manifest::Privilege::Select]),
2033 object_type: crate::manifest::ObjectType::Table,
2034 schema: Some("public".to_string()),
2035 name: Some("*".to_string()),
2036 },
2037 ];
2038
2039 let mut passwords = std::collections::BTreeMap::new();
2040 passwords.insert("role-a".to_string(), "pw-a".to_string());
2041 passwords.insert("role-b".to_string(), "pw-b".to_string());
2042 passwords.insert("role-c".to_string(), "pw-c".to_string());
2043
2044 let result = inject_password_changes(changes, &passwords);
2045
2046 assert_eq!(result.len(), 6, "expected 6 changes, got: {result:?}");
2050 assert!(matches!(&result[0], Change::CreateRole { name, .. } if name == "role-a"));
2051 assert!(matches!(&result[1], Change::SetPassword { name, .. } if name == "role-a"));
2052 assert!(matches!(&result[2], Change::CreateRole { name, .. } if name == "role-b"));
2053 assert!(matches!(&result[3], Change::SetPassword { name, .. } if name == "role-b"));
2054 assert!(matches!(&result[4], Change::Grant { .. }));
2055 assert!(matches!(&result[5], Change::SetPassword { name, .. } if name == "role-c"));
2056 }
2057
2058 #[test]
2059 fn diff_detects_valid_until_change() {
2060 let mut current = empty_graph();
2061 current.roles.insert(
2062 "r1".to_string(),
2063 RoleState {
2064 login: true,
2065 ..RoleState::default()
2066 },
2067 );
2068
2069 let mut desired = empty_graph();
2070 desired.roles.insert(
2071 "r1".to_string(),
2072 RoleState {
2073 login: true,
2074 password_valid_until: Some("2025-12-31T00:00:00Z".to_string()),
2075 ..RoleState::default()
2076 },
2077 );
2078
2079 let changes = diff(¤t, &desired);
2080 assert_eq!(changes.len(), 1);
2081 match &changes[0] {
2082 Change::AlterRole { name, attributes } => {
2083 assert_eq!(name, "r1");
2084 assert!(attributes.contains(&RoleAttribute::ValidUntil(Some(
2085 "2025-12-31T00:00:00Z".to_string()
2086 ))));
2087 }
2088 other => panic!("expected AlterRole, got: {other:?}"),
2089 }
2090 }
2091
2092 #[test]
2103 fn diff_does_not_revoke_per_name_grants_covered_by_desired_wildcard() {
2104 let role = "cdc-editor".to_string();
2105 let schema = "cdc".to_string();
2106 let object_type = ObjectType::Function;
2107
2108 let mut current = empty_graph();
2113 for fn_name in ["f1()", "f3()"] {
2114 current.grants.insert(
2115 GrantKey {
2116 role: role.clone(),
2117 object_type,
2118 schema: Some(schema.clone()),
2119 name: Some(fn_name.to_string()),
2120 },
2121 GrantState {
2122 privileges: BTreeSet::from([Privilege::Execute]),
2123 },
2124 );
2125 }
2126
2127 let mut desired = empty_graph();
2130 desired.grants.insert(
2131 GrantKey {
2132 role: role.clone(),
2133 object_type,
2134 schema: Some(schema.clone()),
2135 name: Some("*".to_string()),
2136 },
2137 GrantState {
2138 privileges: BTreeSet::from([Privilege::Execute]),
2139 },
2140 );
2141
2142 let changes = diff(¤t, &desired);
2143
2144 let revokes: Vec<_> = changes
2145 .iter()
2146 .filter(|c| matches!(c, Change::Revoke { .. }))
2147 .collect();
2148 assert!(
2149 revokes.is_empty(),
2150 "must not revoke per-name grants covered by desired wildcard \
2151 (would cause apply-order flap); got: {revokes:#?}"
2152 );
2153
2154 let grants: Vec<_> = changes
2155 .iter()
2156 .filter(|c| matches!(c, Change::Grant { .. }))
2157 .collect();
2158 assert_eq!(
2159 grants.len(),
2160 1,
2161 "expected a single wildcard GRANT to materialise ACLs on all functions; got: {grants:#?}"
2162 );
2163 match grants[0] {
2164 Change::Grant {
2165 role: r,
2166 name,
2167 privileges,
2168 ..
2169 } => {
2170 assert_eq!(r, &role);
2171 assert_eq!(name.as_deref(), Some("*"));
2172 assert!(privileges.contains(&Privilege::Execute));
2173 }
2174 other => panic!("expected wildcard Grant, got: {other:?}"),
2175 }
2176 }
2177
2178 #[test]
2190 fn diff_does_not_revoke_extra_privileges_covered_by_desired_wildcard() {
2191 let role = "viewer".to_string();
2192 let schema = "myschema".to_string();
2193 let object_type = ObjectType::Table;
2194
2195 let mut current = empty_graph();
2198 current.grants.insert(
2199 GrantKey {
2200 role: role.clone(),
2201 object_type,
2202 schema: Some(schema.clone()),
2203 name: Some("widgets".to_string()),
2204 },
2205 GrantState {
2206 privileges: BTreeSet::from([Privilege::Select, Privilege::Insert]),
2207 },
2208 );
2209
2210 let mut desired = empty_graph();
2212 desired.grants.insert(
2213 GrantKey {
2214 role: role.clone(),
2215 object_type,
2216 schema: Some(schema.clone()),
2217 name: Some("*".to_string()),
2218 },
2219 GrantState {
2220 privileges: BTreeSet::from([Privilege::Select]),
2221 },
2222 );
2223 desired.grants.insert(
2224 GrantKey {
2225 role: role.clone(),
2226 object_type,
2227 schema: Some(schema.clone()),
2228 name: Some("widgets".to_string()),
2229 },
2230 GrantState {
2231 privileges: BTreeSet::from([Privilege::Insert]),
2232 },
2233 );
2234
2235 let changes = diff(¤t, &desired);
2236
2237 let revokes: Vec<_> = changes
2238 .iter()
2239 .filter(|c| matches!(c, Change::Revoke { .. }))
2240 .collect();
2241 assert!(
2242 revokes.is_empty(),
2243 "must not revoke widgets SELECT — covered by desired wildcard; got: {revokes:#?}"
2244 );
2245
2246 let grants: Vec<_> = changes
2249 .iter()
2250 .filter(|c| matches!(c, Change::Grant { .. }))
2251 .collect();
2252 let has_wildcard_select_grant = grants.iter().any(|c| {
2253 matches!(
2254 c,
2255 Change::Grant {
2256 name,
2257 privileges,
2258 ..
2259 } if name.as_deref() == Some("*")
2260 && privileges.contains(&Privilege::Select)
2261 )
2262 });
2263 assert!(
2264 has_wildcard_select_grant,
2265 "expected wildcard GRANT for SELECT; got: {grants:#?}"
2266 );
2267 }
2268
2269 #[test]
2270 fn diff_detects_valid_until_removal() {
2271 let mut current = empty_graph();
2272 current.roles.insert(
2273 "r1".to_string(),
2274 RoleState {
2275 login: true,
2276 password_valid_until: Some("2025-12-31T00:00:00Z".to_string()),
2277 ..RoleState::default()
2278 },
2279 );
2280
2281 let mut desired = empty_graph();
2282 desired.roles.insert(
2283 "r1".to_string(),
2284 RoleState {
2285 login: true,
2286 ..RoleState::default()
2287 },
2288 );
2289
2290 let changes = diff(¤t, &desired);
2291 assert_eq!(changes.len(), 1);
2292 match &changes[0] {
2293 Change::AlterRole { name, attributes } => {
2294 assert_eq!(name, "r1");
2295 assert!(attributes.contains(&RoleAttribute::ValidUntil(None)));
2296 }
2297 other => panic!("expected AlterRole, got: {other:?}"),
2298 }
2299 }
2300}