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 let created_roles: BTreeSet<String> = changes
262 .iter()
263 .filter_map(|change| match change {
264 Change::CreateRole { name, .. } => Some(name.clone()),
265 _ => None,
266 })
267 .collect();
268
269 changes
270 .into_iter()
271 .filter(|change| match change {
272 Change::EnsureSchemaOwnerPrivileges { name, owner, .. } => {
273 !skipped_owner_transfers.contains(&(name.clone(), owner.clone()))
274 }
275 Change::SetDefaultPrivilege { schema, owner, .. } => {
276 !skipped_owner_transfers.contains(&(schema.clone(), owner.clone()))
277 }
278 Change::AlterRole { name, attributes } => {
279 created_roles.contains(name)
280 && attributes
281 .iter()
282 .all(|attr| matches!(attr, RoleAttribute::SetConfig(..)))
283 }
284 Change::SetComment { .. } => false,
285 _ => !is_destructive(change),
286 })
287 .collect()
288}
289
290fn is_destructive(change: &Change) -> bool {
292 matches!(
293 change,
294 Change::AlterSchemaOwner { .. }
295 | Change::Revoke { .. }
296 | Change::RevokeDefaultPrivilege { .. }
297 | Change::RemoveMember { .. }
298 | Change::DropRole { .. }
299 | Change::DropOwned { .. }
300 | Change::ReassignOwned { .. }
301 | Change::TerminateSessions { .. }
302 )
303}
304
305fn is_role_drop_or_retirement(change: &Change) -> bool {
307 matches!(
308 change,
309 Change::DropRole { .. }
310 | Change::DropOwned { .. }
311 | Change::ReassignOwned { .. }
312 | Change::TerminateSessions { .. }
313 )
314}
315
316pub fn diff(current: &RoleGraph, desired: &RoleGraph) -> Vec<Change> {
325 let mut creates = Vec::new();
326 let mut alters = Vec::new();
327 let mut schema_changes = Vec::new();
328 let mut schema_grants = Vec::new();
329 let mut grants = Vec::new();
330 let mut set_defaults = Vec::new();
331 let mut add_members = Vec::new();
332 let mut remove_members = Vec::new();
333 let mut revoke_defaults = Vec::new();
334 let mut revokes = Vec::new();
335 let mut drops = Vec::new();
336
337 for (name, desired_state) in &desired.roles {
341 match current.roles.get(name) {
342 None => {
343 creates.push(Change::CreateRole {
344 name: name.clone(),
345 state: desired_state.clone(),
346 });
347 if !desired_state.config.is_empty() {
351 alters.push(Change::AlterRole {
352 name: name.clone(),
353 attributes: desired_state
354 .config
355 .iter()
356 .map(|(parameter, value)| {
357 RoleAttribute::SetConfig(parameter.clone(), value.clone())
358 })
359 .collect(),
360 });
361 }
362 }
363 Some(current_state) => {
364 let attribute_changes = current_state.changed_attributes(desired_state);
366 if !attribute_changes.is_empty() {
367 alters.push(Change::AlterRole {
368 name: name.clone(),
369 attributes: attribute_changes,
370 });
371 }
372 if current_state.comment != desired_state.comment {
374 alters.push(Change::SetComment {
375 name: name.clone(),
376 comment: desired_state.comment.clone(),
377 });
378 }
379 }
380 }
381 }
382
383 for name in current.roles.keys() {
385 if !desired.roles.contains_key(name) {
386 drops.push(Change::DropRole { name: name.clone() });
387 }
388 }
389
390 diff_schemas(current, desired, &mut schema_changes, &mut schema_grants);
393
394 diff_grants(current, desired, &mut grants, &mut revokes);
397
398 diff_default_privileges(current, desired, &mut set_defaults, &mut revoke_defaults);
401
402 diff_memberships(current, desired, &mut add_members, &mut remove_members);
405
406 let incoming_owners: BTreeSet<(&str, &str)> = schema_changes
415 .iter()
416 .filter_map(|change| match change {
417 Change::AlterSchemaOwner { name, owner } => Some((name.as_str(), owner.as_str())),
418 _ => None,
419 })
420 .collect();
421 if !incoming_owners.is_empty() {
422 revokes.retain(|change| match change {
423 Change::Revoke {
424 role,
425 object_type: ObjectType::Schema,
426 name: Some(schema_name),
427 ..
428 } => !incoming_owners.contains(&(schema_name.as_str(), role.as_str())),
429 _ => true,
430 });
431 }
432
433 let mut changes = Vec::new();
435 changes.extend(creates);
436 changes.extend(alters);
437 changes.extend(schema_changes);
438 changes.extend(schema_grants);
439 changes.extend(grants);
440 changes.extend(set_defaults);
441 changes.extend(remove_members);
442 changes.extend(add_members);
443 changes.extend(revoke_defaults);
444 changes.extend(revokes);
445 changes.extend(drops);
446 changes
447}
448
449fn diff_schemas(
450 current: &RoleGraph,
451 desired: &RoleGraph,
452 schema_out: &mut Vec<Change>,
453 grant_out: &mut Vec<Change>,
454) {
455 for (name, desired_state) in &desired.schemas {
456 match current.schemas.get(name) {
457 None => schema_out.push(Change::CreateSchema {
458 name: name.clone(),
459 owner: desired_state.owner.clone(),
460 }),
461 Some(current_state) => {
462 if current_state.owner != desired_state.owner
463 && let Some(owner) = &desired_state.owner
464 {
465 schema_out.push(Change::AlterSchemaOwner {
466 name: name.clone(),
467 owner: owner.clone(),
468 });
469 }
470 }
471 }
472
473 let Some(owner) = desired_state.owner.as_deref() else {
474 continue;
475 };
476
477 if !current.schemas.contains_key(name) {
478 continue;
479 }
480
481 let expected_privileges = default_schema_owner_privileges(owner);
482 let current_privileges = current
483 .schemas
484 .get(name)
485 .map(|state| state.owner_privileges.clone())
486 .unwrap_or_default();
487 let missing_privileges: BTreeSet<Privilege> = expected_privileges
488 .difference(¤t_privileges)
489 .copied()
490 .collect();
491
492 if !missing_privileges.is_empty() {
493 grant_out.push(Change::EnsureSchemaOwnerPrivileges {
494 name: name.clone(),
495 owner: owner.to_string(),
496 privileges: missing_privileges,
497 });
498 }
499 }
500}
501
502pub fn apply_role_retirements(changes: Vec<Change>, retirements: &[RoleRetirement]) -> Vec<Change> {
508 if retirements.is_empty() {
509 return changes;
510 }
511
512 let retirement_by_role: std::collections::BTreeMap<&str, &RoleRetirement> = retirements
513 .iter()
514 .map(|retirement| (retirement.role.as_str(), retirement))
515 .collect();
516
517 let mut planned = Vec::with_capacity(changes.len());
518 for change in changes {
519 if let Change::DropRole { name } = &change
520 && let Some(retirement) = retirement_by_role.get(name.as_str())
521 {
522 if retirement.terminate_sessions {
523 planned.push(Change::TerminateSessions { role: name.clone() });
524 }
525 if let Some(successor) = &retirement.reassign_owned_to {
526 planned.push(Change::ReassignOwned {
527 from_role: name.clone(),
528 to_role: successor.clone(),
529 });
530 }
531 if retirement.drop_owned {
532 planned.push(Change::DropOwned { role: name.clone() });
533 }
534 }
535 planned.push(change);
536 }
537
538 planned
539}
540
541pub fn resolve_passwords(
551 roles: &[crate::manifest::RoleDefinition],
552) -> Result<std::collections::BTreeMap<String, String>, PasswordResolutionError> {
553 let mut resolved = std::collections::BTreeMap::new();
554 for role in roles {
555 if role.external {
556 continue;
557 }
558 if let Some(source) = &role.password {
559 let value = std::env::var(&source.from_env).map_err(|_| {
560 PasswordResolutionError::MissingEnvVar {
561 role: role.name.clone(),
562 env_var: source.from_env.clone(),
563 }
564 })?;
565 if value.is_empty() {
566 return Err(PasswordResolutionError::EmptyPassword {
567 role: role.name.clone(),
568 env_var: source.from_env.clone(),
569 });
570 }
571 resolved.insert(role.name.clone(), value);
572 }
573 }
574 Ok(resolved)
575}
576
577#[derive(Debug, thiserror::Error)]
579pub enum PasswordResolutionError {
580 #[error("environment variable \"{env_var}\" for role \"{role}\" password is not set")]
581 MissingEnvVar { role: String, env_var: String },
582
583 #[error("environment variable \"{env_var}\" for role \"{role}\" password is empty")]
584 EmptyPassword { role: String, env_var: String },
585}
586
587pub fn inject_password_changes(
600 changes: Vec<Change>,
601 resolved_passwords: &std::collections::BTreeMap<String, String>,
602) -> Vec<Change> {
603 if resolved_passwords.is_empty() {
604 return changes;
605 }
606
607 let created_roles: std::collections::BTreeSet<String> = changes
609 .iter()
610 .filter_map(|c| match c {
611 Change::CreateRole { name, .. } => Some(name.clone()),
612 _ => None,
613 })
614 .collect();
615
616 let mut result = Vec::with_capacity(changes.len() + resolved_passwords.len());
617
618 for change in changes {
620 if let Change::CreateRole { ref name, .. } = change
621 && let Some(password) = resolved_passwords.get(name.as_str())
622 {
623 let role_name = name.clone();
624 let verifier =
625 crate::scram::compute_verifier(password, crate::scram::DEFAULT_ITERATIONS);
626 result.push(change);
627 result.push(Change::SetPassword {
628 name: role_name,
629 password: verifier,
630 });
631 continue;
632 }
633 result.push(change);
634 }
635
636 for (role_name, password) in resolved_passwords {
638 if !created_roles.contains(role_name) {
639 let verifier =
640 crate::scram::compute_verifier(password, crate::scram::DEFAULT_ITERATIONS);
641 result.push(Change::SetPassword {
642 name: role_name.clone(),
643 password: verifier,
644 });
645 }
646 }
647
648 result
649}
650
651fn diff_grants(
656 current: &RoleGraph,
657 desired: &RoleGraph,
658 grants_out: &mut Vec<Change>,
659 revokes_out: &mut Vec<Change>,
660) {
661 let desired_wildcards: BTreeMap<(&str, &Option<String>, ObjectType), &BTreeSet<Privilege>> =
681 desired
682 .grants
683 .iter()
684 .filter(|(k, _)| k.name.as_deref() == Some("*") && k.schema.is_some())
685 .map(|(k, v)| ((k.role.as_str(), &k.schema, k.object_type), &v.privileges))
686 .collect();
687
688 let shadow_filter = |key: &GrantKey, candidate: BTreeSet<Privilege>| -> BTreeSet<Privilege> {
692 if key.name.as_deref() == Some("*") {
693 return candidate;
694 }
695 match desired_wildcards.get(&(key.role.as_str(), &key.schema, key.object_type)) {
696 Some(wildcard_privileges) => {
697 candidate.difference(wildcard_privileges).copied().collect()
698 }
699 None => candidate,
700 }
701 };
702
703 for (key, desired_state) in &desired.grants {
706 match current.grants.get(key) {
707 None => {
708 grants_out.push(change_grant(key, &desired_state.privileges));
710 }
711 Some(current_state) => {
712 let to_add: BTreeSet<Privilege> = desired_state
714 .privileges
715 .difference(¤t_state.privileges)
716 .copied()
717 .collect();
718 let to_remove: BTreeSet<Privilege> = current_state
719 .privileges
720 .difference(&desired_state.privileges)
721 .copied()
722 .collect();
723 let to_remove = shadow_filter(key, to_remove);
724
725 if !to_add.is_empty() {
726 grants_out.push(change_grant(key, &to_add));
727 }
728 if !to_remove.is_empty() {
729 revokes_out.push(change_revoke(key, &to_remove));
730 }
731 }
732 }
733 }
734
735 for (key, current_state) in ¤t.grants {
738 if desired.grants.contains_key(key) {
739 continue;
740 }
741
742 let to_revoke = shadow_filter(key, current_state.privileges.clone());
743 if !to_revoke.is_empty() {
744 revokes_out.push(change_revoke(key, &to_revoke));
745 }
746 }
747}
748
749fn change_grant(key: &GrantKey, privileges: &BTreeSet<Privilege>) -> Change {
750 Change::Grant {
751 role: key.role.clone(),
752 privileges: privileges.clone(),
753 object_type: key.object_type,
754 schema: key.schema.clone(),
755 name: key.name.clone(),
756 }
757}
758
759fn change_revoke(key: &GrantKey, privileges: &BTreeSet<Privilege>) -> Change {
760 Change::Revoke {
761 role: key.role.clone(),
762 privileges: privileges.clone(),
763 object_type: key.object_type,
764 schema: key.schema.clone(),
765 name: key.name.clone(),
766 }
767}
768
769fn diff_default_privileges(
774 current: &RoleGraph,
775 desired: &RoleGraph,
776 set_out: &mut Vec<Change>,
777 revoke_out: &mut Vec<Change>,
778) {
779 for (key, desired_state) in &desired.default_privileges {
780 match current.default_privileges.get(key) {
781 None => {
782 set_out.push(change_set_default(key, &desired_state.privileges));
783 }
784 Some(current_state) => {
785 let to_add: BTreeSet<Privilege> = desired_state
786 .privileges
787 .difference(¤t_state.privileges)
788 .copied()
789 .collect();
790 let to_remove: BTreeSet<Privilege> = current_state
791 .privileges
792 .difference(&desired_state.privileges)
793 .copied()
794 .collect();
795
796 if !to_add.is_empty() {
797 set_out.push(change_set_default(key, &to_add));
798 }
799 if !to_remove.is_empty() {
800 revoke_out.push(change_revoke_default(key, &to_remove));
801 }
802 }
803 }
804 }
805
806 for (key, current_state) in ¤t.default_privileges {
807 if !desired.default_privileges.contains_key(key) {
808 revoke_out.push(change_revoke_default(key, ¤t_state.privileges));
809 }
810 }
811}
812
813fn change_set_default(key: &DefaultPrivKey, privileges: &BTreeSet<Privilege>) -> Change {
814 Change::SetDefaultPrivilege {
815 owner: key.owner.clone(),
816 schema: key.schema.clone(),
817 on_type: key.on_type,
818 grantee: key.grantee.clone(),
819 privileges: privileges.clone(),
820 }
821}
822
823fn change_revoke_default(key: &DefaultPrivKey, privileges: &BTreeSet<Privilege>) -> Change {
824 Change::RevokeDefaultPrivilege {
825 owner: key.owner.clone(),
826 schema: key.schema.clone(),
827 on_type: key.on_type,
828 grantee: key.grantee.clone(),
829 privileges: privileges.clone(),
830 }
831}
832
833fn diff_memberships(
838 current: &RoleGraph,
839 desired: &RoleGraph,
840 add_out: &mut Vec<Change>,
841 remove_out: &mut Vec<Change>,
842) {
843 let current_map: std::collections::BTreeMap<(&str, &str), &MembershipEdge> = current
848 .memberships
849 .iter()
850 .map(|edge| ((edge.role.as_str(), edge.member.as_str()), edge))
851 .collect();
852 let desired_map: std::collections::BTreeMap<(&str, &str), &MembershipEdge> = desired
853 .memberships
854 .iter()
855 .map(|edge| ((edge.role.as_str(), edge.member.as_str()), edge))
856 .collect();
857
858 for (&(role, member), &desired_edge) in &desired_map {
861 match current_map.get(&(role, member)) {
862 None => {
863 add_out.push(Change::AddMember {
864 role: desired_edge.role.clone(),
865 member: desired_edge.member.clone(),
866 inherit: desired_edge.inherit,
867 admin: desired_edge.admin,
868 });
869 }
870 Some(current_edge) => {
871 if current_edge.inherit != desired_edge.inherit
872 || current_edge.admin != desired_edge.admin
873 {
874 remove_out.push(Change::RemoveMember {
876 role: current_edge.role.clone(),
877 member: current_edge.member.clone(),
878 });
879 add_out.push(Change::AddMember {
880 role: desired_edge.role.clone(),
881 member: desired_edge.member.clone(),
882 inherit: desired_edge.inherit,
883 admin: desired_edge.admin,
884 });
885 }
886 }
887 }
888 }
889
890 for &(role, member) in current_map.keys() {
892 if !desired_map.contains_key(&(role, member)) {
893 remove_out.push(Change::RemoveMember {
894 role: role.to_string(),
895 member: member.to_string(),
896 });
897 }
898 }
899}
900
901#[cfg(test)]
906mod tests {
907 use super::*;
908 use crate::model::{
909 DefaultPrivState, GrantState, SchemaState, default_schema_owner_privileges,
910 };
911
912 fn empty_graph() -> RoleGraph {
914 RoleGraph::default()
915 }
916
917 fn managed_schema(owner: &str) -> SchemaState {
918 SchemaState {
919 owner: Some(owner.to_string()),
920 owner_privileges: default_schema_owner_privileges(owner),
921 }
922 }
923
924 fn role_definition(name: &str, external: bool) -> RoleDefinition {
925 RoleDefinition {
926 name: name.to_string(),
927 external,
928 login: None,
929 superuser: None,
930 createdb: None,
931 createrole: None,
932 inherit: None,
933 replication: None,
934 bypassrls: None,
935 connection_limit: None,
936 comment: None,
937 password: None,
938 password_valid_until: None,
939 config: Default::default(),
940 }
941 }
942
943 #[test]
944 fn diff_empty_to_empty_is_empty() {
945 let changes = diff(&empty_graph(), &empty_graph());
946 assert!(changes.is_empty());
947 }
948
949 #[test]
950 fn diff_creates_new_roles() {
951 let current = empty_graph();
952 let mut desired = empty_graph();
953 desired
954 .roles
955 .insert("new-role".to_string(), RoleState::default());
956
957 let changes = diff(¤t, &desired);
958 assert_eq!(changes.len(), 1);
959 assert!(matches!(&changes[0], Change::CreateRole { name, .. } if name == "new-role"));
960 }
961
962 #[test]
963 fn diff_drops_removed_roles() {
964 let mut current = empty_graph();
965 current
966 .roles
967 .insert("old-role".to_string(), RoleState::default());
968 let desired = empty_graph();
969
970 let changes = diff(¤t, &desired);
971 assert_eq!(changes.len(), 1);
972 assert!(matches!(&changes[0], Change::DropRole { name } if name == "old-role"));
973 }
974
975 #[test]
976 fn diff_alters_changed_role_attributes() {
977 let mut current = empty_graph();
978 current
979 .roles
980 .insert("role1".to_string(), RoleState::default());
981
982 let mut desired = empty_graph();
983 desired.roles.insert(
984 "role1".to_string(),
985 RoleState {
986 login: true,
987 ..RoleState::default()
988 },
989 );
990
991 let changes = diff(¤t, &desired);
992 assert_eq!(changes.len(), 1);
993 match &changes[0] {
994 Change::AlterRole { name, attributes } => {
995 assert_eq!(name, "role1");
996 assert!(attributes.contains(&RoleAttribute::Login(true)));
997 }
998 other => panic!("expected AlterRole, got: {other:?}"),
999 }
1000 }
1001
1002 #[test]
1003 fn owner_transfer_suppresses_revoke_of_incoming_owners_stale_grant() {
1004 let mut current = empty_graph();
1009 for role in ["w", "z", "bystander"] {
1010 current.roles.insert(role.to_string(), RoleState::default());
1011 }
1012 current.schemas.insert(
1013 "s".to_string(),
1014 SchemaState {
1015 owner: Some("w".to_string()),
1016 owner_privileges: default_schema_owner_privileges("w"),
1017 },
1018 );
1019 for grantee in ["z", "bystander"] {
1020 current.grants.insert(
1021 GrantKey {
1022 role: grantee.to_string(),
1023 object_type: ObjectType::Schema,
1024 schema: None,
1025 name: Some("s".to_string()),
1026 },
1027 GrantState {
1028 privileges: [Privilege::Usage].into_iter().collect(),
1029 },
1030 );
1031 }
1032
1033 let mut desired = empty_graph();
1034 for role in ["w", "z", "bystander"] {
1035 desired.roles.insert(role.to_string(), RoleState::default());
1036 }
1037 desired.schemas.insert(
1038 "s".to_string(),
1039 SchemaState {
1040 owner: Some("z".to_string()),
1041 owner_privileges: default_schema_owner_privileges("z"),
1042 },
1043 );
1044
1045 let changes = diff(¤t, &desired);
1046
1047 assert!(
1048 changes.iter().any(|c| matches!(
1049 c,
1050 Change::AlterSchemaOwner { name, owner } if name == "s" && owner == "z"
1051 )),
1052 "expected owner transfer in plan: {changes:?}"
1053 );
1054 assert!(
1057 !changes.iter().any(|c| matches!(
1058 c,
1059 Change::Revoke { role, object_type: ObjectType::Schema, name: Some(n), .. }
1060 if role == "z" && n == "s"
1061 )),
1062 "revoke against incoming owner must be suppressed: {changes:?}"
1063 );
1064 assert!(
1066 changes.iter().any(|c| matches!(
1067 c,
1068 Change::Revoke { role, object_type: ObjectType::Schema, name: Some(n), .. }
1069 if role == "bystander" && n == "s"
1070 )),
1071 "bystander's stale grant must still be revoked: {changes:?}"
1072 );
1073 }
1074
1075 #[test]
1076 fn diff_converges_role_config_via_manifest_pipeline() {
1077 let yaml = r#"
1080roles:
1081 - name: blue
1082 login: true
1083 config:
1084 role: combined
1085 - name: green
1086 login: true
1087 config:
1088 role: combined
1089 - name: combined
1090
1091memberships:
1092 - role: combined
1093 members:
1094 - name: blue
1095 - name: green
1096"#;
1097 let manifest = crate::manifest::parse_manifest(yaml).unwrap();
1098 let expanded = crate::manifest::expand_manifest(&manifest).unwrap();
1099 let desired = RoleGraph::from_expanded(&expanded, None).unwrap();
1100
1101 let changes = diff(&empty_graph(), &desired);
1103 let sql = crate::sql::render_all(&changes);
1104 assert!(sql.contains("ALTER ROLE \"blue\" SET \"role\" = 'combined';"));
1105 assert!(sql.contains("ALTER ROLE \"green\" SET \"role\" = 'combined';"));
1106 assert!(sql.contains("GRANT \"combined\" TO \"blue\""));
1107
1108 let changes = diff(&desired, &desired);
1110 assert!(changes.is_empty());
1111
1112 let mut current = desired.clone();
1114 current.roles.get_mut("green").unwrap().config.clear();
1115 current
1116 .roles
1117 .get_mut("blue")
1118 .unwrap()
1119 .config
1120 .insert("statement_timeout".to_string(), "10s".to_string());
1121 let changes = diff(¤t, &desired);
1122 let sql = crate::sql::render_all(&changes);
1123 assert!(sql.contains("ALTER ROLE \"green\" SET \"role\" = 'combined';"));
1124 assert!(sql.contains("ALTER ROLE \"blue\" RESET \"statement_timeout\";"));
1125 assert!(!sql.contains("ALTER ROLE \"blue\" SET"));
1126 }
1127
1128 #[test]
1129 fn external_role_filter_suppresses_lifecycle_and_granted_role_memberships() {
1130 let external = "analytics-admin@example.com";
1131 let mut current = empty_graph();
1132 current.roles.insert(
1133 external.to_string(),
1134 RoleState {
1135 login: true,
1136 ..RoleState::default()
1137 },
1138 );
1139 current.memberships.insert(MembershipEdge {
1140 role: external.to_string(),
1141 member: "cloudsqlsuperuser".to_string(),
1142 inherit: true,
1143 admin: false,
1144 });
1145
1146 let mut desired = empty_graph();
1147 desired
1148 .roles
1149 .insert(external.to_string(), RoleState::default());
1150
1151 let changes = diff(¤t, &desired);
1152 assert!(changes.iter().any(|change| {
1153 matches!(
1154 change,
1155 Change::AlterRole { name, attributes }
1156 if name == external && attributes.contains(&RoleAttribute::Login(false))
1157 )
1158 }));
1159 assert!(changes.iter().any(|change| {
1160 matches!(
1161 change,
1162 Change::RemoveMember { role, member }
1163 if role == external && member == "cloudsqlsuperuser"
1164 )
1165 }));
1166
1167 let filtered = filter_external_role_changes(changes, &[role_definition(external, true)]);
1168 assert!(filtered.is_empty());
1169 }
1170
1171 #[test]
1172 fn external_role_filter_keeps_external_role_as_managed_member() {
1173 let external = "team@example.com";
1174 let changes = vec![Change::RemoveMember {
1175 role: "kv-editor".to_string(),
1176 member: external.to_string(),
1177 }];
1178
1179 let filtered =
1180 filter_external_role_changes(changes.clone(), &[role_definition(external, true)]);
1181 assert_eq!(filtered, changes);
1182 }
1183
1184 #[test]
1185 fn diff_creates_missing_schema() {
1186 let current = empty_graph();
1187 let mut desired = empty_graph();
1188 desired
1189 .schemas
1190 .insert("inventory".to_string(), managed_schema("inventory_owner"));
1191
1192 let changes = diff(¤t, &desired);
1193 assert_eq!(changes.len(), 1);
1194 assert!(matches!(
1195 &changes[0],
1196 Change::CreateSchema { name, owner }
1197 if name == "inventory" && owner.as_deref() == Some("inventory_owner")
1198 ));
1199 }
1200
1201 #[test]
1202 fn diff_alters_schema_owner_when_different() {
1203 let mut current = empty_graph();
1204 current
1205 .schemas
1206 .insert("inventory".to_string(), managed_schema("old_owner"));
1207
1208 let mut desired = empty_graph();
1209 desired
1210 .schemas
1211 .insert("inventory".to_string(), managed_schema("new_owner"));
1212
1213 let changes = diff(¤t, &desired);
1214 assert_eq!(changes.len(), 1);
1215 assert!(matches!(
1216 &changes[0],
1217 Change::AlterSchemaOwner { name, owner }
1218 if name == "inventory" && owner == "new_owner"
1219 ));
1220 }
1221
1222 #[test]
1223 fn diff_does_not_alter_schema_owner_when_unmanaged() {
1224 let mut current = empty_graph();
1225 current
1226 .schemas
1227 .insert("inventory".to_string(), managed_schema("old_owner"));
1228
1229 let mut desired = empty_graph();
1230 desired.schemas.insert(
1231 "inventory".to_string(),
1232 SchemaState {
1233 owner: None,
1234 owner_privileges: BTreeSet::new(),
1235 },
1236 );
1237
1238 let changes = diff(¤t, &desired);
1239 assert!(changes.is_empty());
1240 }
1241
1242 #[test]
1243 fn diff_restores_missing_owner_schema_privileges() {
1244 let mut current = empty_graph();
1245 current.schemas.insert(
1246 "inventory".to_string(),
1247 SchemaState {
1248 owner: Some("inventory_owner".to_string()),
1249 owner_privileges: BTreeSet::from([Privilege::Usage]),
1250 },
1251 );
1252
1253 let mut desired = empty_graph();
1254 desired
1255 .schemas
1256 .insert("inventory".to_string(), managed_schema("inventory_owner"));
1257
1258 let changes = diff(¤t, &desired);
1259 assert_eq!(changes.len(), 1);
1260 assert!(matches!(
1261 &changes[0],
1262 Change::EnsureSchemaOwnerPrivileges {
1263 name,
1264 owner,
1265 privileges,
1266 } if name == "inventory"
1267 && owner == "inventory_owner"
1268 && privileges == &BTreeSet::from([Privilege::Create])
1269 ));
1270 }
1271
1272 #[test]
1273 fn diff_restores_owner_schema_privileges_after_transfer() {
1274 let mut current = empty_graph();
1275 current.schemas.insert(
1276 "inventory".to_string(),
1277 SchemaState {
1278 owner: Some("old_owner".to_string()),
1279 owner_privileges: BTreeSet::from([Privilege::Usage]),
1280 },
1281 );
1282
1283 let mut desired = empty_graph();
1284 desired
1285 .schemas
1286 .insert("inventory".to_string(), managed_schema("new_owner"));
1287
1288 let changes = diff(¤t, &desired);
1289 assert_eq!(changes.len(), 2);
1290 assert!(matches!(
1291 &changes[0],
1292 Change::AlterSchemaOwner { name, owner }
1293 if name == "inventory" && owner == "new_owner"
1294 ));
1295 assert!(matches!(
1296 &changes[1],
1297 Change::EnsureSchemaOwnerPrivileges {
1298 name,
1299 owner,
1300 privileges,
1301 } if name == "inventory"
1302 && owner == "new_owner"
1303 && privileges == &BTreeSet::from([Privilege::Create])
1304 ));
1305 }
1306
1307 #[test]
1308 fn diff_grants_new_privileges() {
1309 let current = empty_graph();
1310 let mut desired = empty_graph();
1311 let key = GrantKey {
1312 role: "r1".to_string(),
1313 object_type: ObjectType::Table,
1314 schema: Some("public".to_string()),
1315 name: Some("*".to_string()),
1316 };
1317 desired.grants.insert(
1318 key,
1319 GrantState {
1320 privileges: BTreeSet::from([Privilege::Select, Privilege::Insert]),
1321 },
1322 );
1323
1324 let changes = diff(¤t, &desired);
1325 assert_eq!(changes.len(), 1);
1326 match &changes[0] {
1327 Change::Grant {
1328 role, privileges, ..
1329 } => {
1330 assert_eq!(role, "r1");
1331 assert!(privileges.contains(&Privilege::Select));
1332 assert!(privileges.contains(&Privilege::Insert));
1333 }
1334 other => panic!("expected Grant, got: {other:?}"),
1335 }
1336 }
1337
1338 #[test]
1339 fn diff_revokes_removed_privileges() {
1340 let mut current = empty_graph();
1341 let key = GrantKey {
1342 role: "r1".to_string(),
1343 object_type: ObjectType::Table,
1344 schema: Some("public".to_string()),
1345 name: Some("*".to_string()),
1346 };
1347 current.grants.insert(
1348 key.clone(),
1349 GrantState {
1350 privileges: BTreeSet::from([Privilege::Select, Privilege::Insert]),
1351 },
1352 );
1353
1354 let mut desired = empty_graph();
1355 desired.grants.insert(
1356 key,
1357 GrantState {
1358 privileges: BTreeSet::from([Privilege::Select]),
1359 },
1360 );
1361
1362 let changes = diff(¤t, &desired);
1363 assert_eq!(changes.len(), 1);
1364 match &changes[0] {
1365 Change::Revoke {
1366 role, privileges, ..
1367 } => {
1368 assert_eq!(role, "r1");
1369 assert!(privileges.contains(&Privilege::Insert));
1370 assert!(!privileges.contains(&Privilege::Select));
1371 }
1372 other => panic!("expected Revoke, got: {other:?}"),
1373 }
1374 }
1375
1376 #[test]
1377 fn diff_revokes_entire_grant_target_when_absent_from_desired() {
1378 let mut current = empty_graph();
1379 let key = GrantKey {
1380 role: "r1".to_string(),
1381 object_type: ObjectType::Schema,
1382 schema: None,
1383 name: Some("myschema".to_string()),
1384 };
1385 current.grants.insert(
1386 key,
1387 GrantState {
1388 privileges: BTreeSet::from([Privilege::Usage]),
1389 },
1390 );
1391 let desired = empty_graph();
1392
1393 let changes = diff(¤t, &desired);
1394 assert_eq!(changes.len(), 1);
1395 assert!(matches!(&changes[0], Change::Revoke { role, .. } if role == "r1"));
1396 }
1397
1398 #[test]
1399 fn diff_adds_memberships() {
1400 let current = empty_graph();
1401 let mut desired = empty_graph();
1402 desired.memberships.insert(MembershipEdge {
1403 role: "editors".to_string(),
1404 member: "user@example.com".to_string(),
1405 inherit: true,
1406 admin: false,
1407 });
1408
1409 let changes = diff(¤t, &desired);
1410 assert_eq!(changes.len(), 1);
1411 match &changes[0] {
1412 Change::AddMember {
1413 role,
1414 member,
1415 inherit,
1416 admin,
1417 } => {
1418 assert_eq!(role, "editors");
1419 assert_eq!(member, "user@example.com");
1420 assert!(*inherit);
1421 assert!(!admin);
1422 }
1423 other => panic!("expected AddMember, got: {other:?}"),
1424 }
1425 }
1426
1427 #[test]
1428 fn diff_removes_memberships() {
1429 let mut current = empty_graph();
1430 current.memberships.insert(MembershipEdge {
1431 role: "editors".to_string(),
1432 member: "old@example.com".to_string(),
1433 inherit: true,
1434 admin: false,
1435 });
1436 let desired = empty_graph();
1437
1438 let changes = diff(¤t, &desired);
1439 assert_eq!(changes.len(), 1);
1440 assert!(
1441 matches!(&changes[0], Change::RemoveMember { role, member } if role == "editors" && member == "old@example.com")
1442 );
1443 }
1444
1445 #[test]
1446 fn diff_re_grants_membership_when_flags_change() {
1447 let mut current = empty_graph();
1448 current.memberships.insert(MembershipEdge {
1449 role: "editors".to_string(),
1450 member: "user@example.com".to_string(),
1451 inherit: true,
1452 admin: false,
1453 });
1454
1455 let mut desired = empty_graph();
1456 desired.memberships.insert(MembershipEdge {
1457 role: "editors".to_string(),
1458 member: "user@example.com".to_string(),
1459 inherit: true,
1460 admin: true, });
1462
1463 let changes = diff(¤t, &desired);
1464 assert_eq!(changes.len(), 2);
1466 assert!(matches!(
1467 &changes[0],
1468 Change::RemoveMember { role, member }
1469 if role == "editors" && member == "user@example.com"
1470 ));
1471 assert!(matches!(
1472 &changes[1],
1473 Change::AddMember {
1474 role,
1475 member,
1476 admin: true,
1477 ..
1478 } if role == "editors" && member == "user@example.com"
1479 ));
1480 }
1481
1482 #[test]
1483 fn diff_default_privileges_add_and_revoke() {
1484 let mut current = empty_graph();
1485 let key = DefaultPrivKey {
1486 owner: "app_owner".to_string(),
1487 schema: "inventory".to_string(),
1488 on_type: ObjectType::Table,
1489 grantee: "inventory-editor".to_string(),
1490 };
1491 current.default_privileges.insert(
1492 key.clone(),
1493 DefaultPrivState {
1494 privileges: BTreeSet::from([Privilege::Select, Privilege::Delete]),
1495 },
1496 );
1497
1498 let mut desired = empty_graph();
1499 desired.default_privileges.insert(
1500 key,
1501 DefaultPrivState {
1502 privileges: BTreeSet::from([Privilege::Select, Privilege::Insert]),
1503 },
1504 );
1505
1506 let changes = diff(¤t, &desired);
1507 assert_eq!(changes.len(), 2);
1509 assert!(changes.iter().any(|c| matches!(
1510 c,
1511 Change::SetDefaultPrivilege { privileges, .. } if privileges.contains(&Privilege::Insert)
1512 )));
1513 assert!(changes.iter().any(|c| matches!(
1514 c,
1515 Change::RevokeDefaultPrivilege { privileges, .. } if privileges.contains(&Privilege::Delete)
1516 )));
1517 }
1518
1519 #[test]
1520 fn diff_ordering_creates_before_drops() {
1521 let mut current = empty_graph();
1522 current
1523 .roles
1524 .insert("old-role".to_string(), RoleState::default());
1525
1526 let mut desired = empty_graph();
1527 desired
1528 .roles
1529 .insert("new-role".to_string(), RoleState::default());
1530
1531 let changes = diff(¤t, &desired);
1532 assert_eq!(changes.len(), 2);
1533
1534 let create_idx = changes
1536 .iter()
1537 .position(|c| matches!(c, Change::CreateRole { .. }))
1538 .unwrap();
1539 let schema_idx = changes
1540 .iter()
1541 .position(|c| matches!(c, Change::CreateSchema { .. }))
1542 .unwrap_or(create_idx);
1543 let drop_idx = changes
1544 .iter()
1545 .position(|c| matches!(c, Change::DropRole { .. }))
1546 .unwrap();
1547 assert!(create_idx <= schema_idx);
1548 assert!(schema_idx < drop_idx);
1549 }
1550
1551 #[test]
1552 fn diff_identical_graphs_produce_no_changes() {
1553 let mut graph = empty_graph();
1554 graph
1555 .roles
1556 .insert("role1".to_string(), RoleState::default());
1557 graph.grants.insert(
1558 GrantKey {
1559 role: "role1".to_string(),
1560 object_type: ObjectType::Table,
1561 schema: Some("public".to_string()),
1562 name: Some("*".to_string()),
1563 },
1564 GrantState {
1565 privileges: BTreeSet::from([Privilege::Select]),
1566 },
1567 );
1568 graph.memberships.insert(MembershipEdge {
1569 role: "role1".to_string(),
1570 member: "user@example.com".to_string(),
1571 inherit: true,
1572 admin: false,
1573 });
1574
1575 let changes = diff(&graph, &graph);
1576 assert!(
1577 changes.is_empty(),
1578 "identical graphs should produce no changes"
1579 );
1580 }
1581
1582 #[test]
1584 fn manifest_to_diff_integration() {
1585 use crate::manifest::{expand_manifest, parse_manifest};
1586 use crate::model::RoleGraph;
1587
1588 let yaml = r#"
1589default_owner: app_owner
1590
1591profiles:
1592 editor:
1593 grants:
1594 - privileges: [USAGE]
1595 object: { type: schema }
1596 - privileges: [SELECT, INSERT, UPDATE, DELETE]
1597 object: { type: table, name: "*" }
1598 default_privileges:
1599 - privileges: [SELECT, INSERT, UPDATE, DELETE]
1600 on_type: table
1601
1602schemas:
1603 - name: inventory
1604 owner: inventory_owner
1605 profiles: [editor]
1606
1607memberships:
1608 - role: inventory-editor
1609 members:
1610 - name: "user@example.com"
1611"#;
1612 let manifest = parse_manifest(yaml).unwrap();
1613 let expanded = expand_manifest(&manifest).unwrap();
1614 let desired =
1615 RoleGraph::from_expanded(&expanded, manifest.default_owner.as_deref()).unwrap();
1616
1617 let current = RoleGraph::default();
1619 let changes = diff(¤t, &desired);
1620
1621 let create_count = changes
1623 .iter()
1624 .filter(|c| matches!(c, Change::CreateRole { .. }))
1625 .count();
1626 let create_schema_count = changes
1627 .iter()
1628 .filter(|c| matches!(c, Change::CreateSchema { .. }))
1629 .count();
1630 let grant_count = changes
1631 .iter()
1632 .filter(|c| matches!(c, Change::Grant { .. }))
1633 .count();
1634 let dp_count = changes
1635 .iter()
1636 .filter(|c| matches!(c, Change::SetDefaultPrivilege { .. }))
1637 .count();
1638 let member_count = changes
1639 .iter()
1640 .filter(|c| matches!(c, Change::AddMember { .. }))
1641 .count();
1642
1643 assert_eq!(create_count, 1);
1644 assert_eq!(create_schema_count, 1);
1645 assert_eq!(grant_count, 2); assert_eq!(dp_count, 1);
1647 assert_eq!(member_count, 1);
1648
1649 let no_changes = diff(&desired, &desired);
1651 assert!(no_changes.is_empty());
1652 }
1653
1654 fn all_change_variants() -> Vec<Change> {
1660 vec![
1661 Change::CreateRole {
1662 name: "new-role".to_string(),
1663 state: RoleState::default(),
1664 },
1665 Change::CreateSchema {
1666 name: "inventory".to_string(),
1667 owner: Some("inventory_owner".to_string()),
1668 },
1669 Change::AlterSchemaOwner {
1670 name: "catalog".to_string(),
1671 owner: "catalog_owner".to_string(),
1672 },
1673 Change::EnsureSchemaOwnerPrivileges {
1674 name: "catalog".to_string(),
1675 owner: "catalog_owner".to_string(),
1676 privileges: BTreeSet::from([Privilege::Create, Privilege::Usage]),
1677 },
1678 Change::AlterRole {
1679 name: "altered-role".to_string(),
1680 attributes: vec![RoleAttribute::Login(true)],
1681 },
1682 Change::SetComment {
1683 name: "commented-role".to_string(),
1684 comment: Some("hello".to_string()),
1685 },
1686 Change::Grant {
1687 role: "r1".to_string(),
1688 privileges: BTreeSet::from([Privilege::Select]),
1689 object_type: ObjectType::Table,
1690 schema: Some("public".to_string()),
1691 name: Some("*".to_string()),
1692 },
1693 Change::Revoke {
1694 role: "r1".to_string(),
1695 privileges: BTreeSet::from([Privilege::Insert]),
1696 object_type: ObjectType::Table,
1697 schema: Some("public".to_string()),
1698 name: Some("*".to_string()),
1699 },
1700 Change::SetDefaultPrivilege {
1701 owner: "owner".to_string(),
1702 schema: "public".to_string(),
1703 on_type: ObjectType::Table,
1704 grantee: "r1".to_string(),
1705 privileges: BTreeSet::from([Privilege::Select]),
1706 },
1707 Change::RevokeDefaultPrivilege {
1708 owner: "owner".to_string(),
1709 schema: "public".to_string(),
1710 on_type: ObjectType::Table,
1711 grantee: "r1".to_string(),
1712 privileges: BTreeSet::from([Privilege::Delete]),
1713 },
1714 Change::AddMember {
1715 role: "editors".to_string(),
1716 member: "user@example.com".to_string(),
1717 inherit: true,
1718 admin: false,
1719 },
1720 Change::RemoveMember {
1721 role: "editors".to_string(),
1722 member: "old@example.com".to_string(),
1723 },
1724 Change::TerminateSessions {
1725 role: "retired-role".to_string(),
1726 },
1727 Change::ReassignOwned {
1728 from_role: "retired-role".to_string(),
1729 to_role: "successor".to_string(),
1730 },
1731 Change::DropOwned {
1732 role: "retired-role".to_string(),
1733 },
1734 Change::DropRole {
1735 name: "retired-role".to_string(),
1736 },
1737 ]
1738 }
1739
1740 #[test]
1741 fn filter_authoritative_keeps_all_changes() {
1742 let changes = all_change_variants();
1743 let original_len = changes.len();
1744 let filtered = filter_changes(changes, ReconciliationMode::Authoritative);
1745 assert_eq!(filtered.len(), original_len);
1746 }
1747
1748 #[test]
1749 fn filter_additive_keeps_only_constructive_changes() {
1750 let filtered = filter_changes(all_change_variants(), ReconciliationMode::Additive);
1751
1752 assert_eq!(filtered.len(), 5);
1754
1755 for change in &filtered {
1757 assert!(
1758 !matches!(
1759 change,
1760 Change::AlterSchemaOwner { .. }
1761 | Change::EnsureSchemaOwnerPrivileges { .. }
1762 | Change::AlterRole { .. }
1763 | Change::SetComment { .. }
1764 | Change::Revoke { .. }
1765 | Change::RevokeDefaultPrivilege { .. }
1766 | Change::RemoveMember { .. }
1767 | Change::DropRole { .. }
1768 | Change::DropOwned { .. }
1769 | Change::ReassignOwned { .. }
1770 | Change::TerminateSessions { .. }
1771 ),
1772 "additive mode should not contain destructive change: {change:?}"
1773 );
1774 }
1775
1776 assert!(
1778 filtered
1779 .iter()
1780 .any(|c| matches!(c, Change::CreateRole { .. }))
1781 );
1782 assert!(
1783 filtered
1784 .iter()
1785 .any(|c| matches!(c, Change::CreateSchema { .. }))
1786 );
1787 assert!(
1788 filtered
1789 .iter()
1790 .all(|c| !matches!(c, Change::AlterRole { .. } | Change::SetComment { .. }))
1791 );
1792 assert!(filtered.iter().any(|c| matches!(c, Change::Grant { .. })));
1793 assert!(
1794 filtered
1795 .iter()
1796 .any(|c| matches!(c, Change::SetDefaultPrivilege { .. }))
1797 );
1798 assert!(
1799 filtered
1800 .iter()
1801 .any(|c| matches!(c, Change::AddMember { .. }))
1802 );
1803 }
1804
1805 #[test]
1806 fn filter_additive_keeps_config_alters_for_roles_created_in_same_plan() {
1807 let changes = vec![
1812 Change::CreateRole {
1813 name: "blue".to_string(),
1814 state: RoleState::default(),
1815 },
1816 Change::AlterRole {
1817 name: "blue".to_string(),
1818 attributes: vec![RoleAttribute::SetConfig(
1819 "role".to_string(),
1820 "combined".to_string(),
1821 )],
1822 },
1823 ];
1824
1825 let filtered = filter_changes(changes, ReconciliationMode::Additive);
1826 assert_eq!(filtered.len(), 2);
1827 assert!(matches!(&filtered[1], Change::AlterRole { name, .. } if name == "blue"));
1828 }
1829
1830 #[test]
1831 fn filter_additive_drops_config_alters_for_pre_existing_roles() {
1832 let changes = vec![Change::AlterRole {
1835 name: "blue".to_string(),
1836 attributes: vec![
1837 RoleAttribute::SetConfig("role".to_string(), "combined".to_string()),
1838 RoleAttribute::ResetConfig("statement_timeout".to_string()),
1839 ],
1840 }];
1841
1842 let filtered = filter_changes(changes, ReconciliationMode::Additive);
1843 assert!(filtered.is_empty());
1844 }
1845
1846 #[test]
1847 fn filter_additive_drops_mixed_attribute_and_config_alters_even_for_created_roles() {
1848 let changes = vec![
1852 Change::CreateRole {
1853 name: "blue".to_string(),
1854 state: RoleState::default(),
1855 },
1856 Change::AlterRole {
1857 name: "blue".to_string(),
1858 attributes: vec![
1859 RoleAttribute::Login(true),
1860 RoleAttribute::SetConfig("role".to_string(), "combined".to_string()),
1861 ],
1862 },
1863 ];
1864
1865 let filtered = filter_changes(changes, ReconciliationMode::Additive);
1866 assert_eq!(filtered.len(), 1);
1867 assert!(matches!(&filtered[0], Change::CreateRole { .. }));
1868 }
1869
1870 #[test]
1871 fn filter_additive_skips_owner_bound_follow_ups_when_transfer_is_skipped() {
1872 let changes = vec![
1873 Change::AlterSchemaOwner {
1874 name: "inventory".to_string(),
1875 owner: "new_owner".to_string(),
1876 },
1877 Change::EnsureSchemaOwnerPrivileges {
1878 name: "inventory".to_string(),
1879 owner: "new_owner".to_string(),
1880 privileges: BTreeSet::from([Privilege::Create, Privilege::Usage]),
1881 },
1882 Change::SetDefaultPrivilege {
1883 owner: "new_owner".to_string(),
1884 schema: "inventory".to_string(),
1885 on_type: ObjectType::Table,
1886 grantee: "inventory-editor".to_string(),
1887 privileges: BTreeSet::from([Privilege::Select]),
1888 },
1889 Change::Grant {
1890 role: "inventory-editor".to_string(),
1891 privileges: BTreeSet::from([Privilege::Usage]),
1892 object_type: ObjectType::Schema,
1893 schema: None,
1894 name: Some("inventory".to_string()),
1895 },
1896 ];
1897
1898 let filtered = filter_changes(changes, ReconciliationMode::Additive);
1899 assert_eq!(filtered.len(), 1);
1900 assert!(matches!(&filtered[0], Change::Grant { role, .. } if role == "inventory-editor"));
1901 }
1902
1903 #[test]
1904 fn filter_adopt_keeps_revokes_but_not_drops() {
1905 let filtered = filter_changes(all_change_variants(), ReconciliationMode::Adopt);
1906
1907 assert_eq!(filtered.len(), 12);
1909
1910 for change in &filtered {
1912 assert!(
1913 !matches!(
1914 change,
1915 Change::DropRole { .. }
1916 | Change::DropOwned { .. }
1917 | Change::ReassignOwned { .. }
1918 | Change::TerminateSessions { .. }
1919 ),
1920 "adopt mode should not contain drop/retirement change: {change:?}"
1921 );
1922 }
1923
1924 assert!(filtered.iter().any(|c| matches!(c, Change::Revoke { .. })));
1926 assert!(
1927 filtered
1928 .iter()
1929 .any(|c| matches!(c, Change::RevokeDefaultPrivilege { .. }))
1930 );
1931 assert!(
1932 filtered
1933 .iter()
1934 .any(|c| matches!(c, Change::RemoveMember { .. }))
1935 );
1936 }
1937
1938 #[test]
1939 fn filter_additive_with_empty_input() {
1940 let filtered = filter_changes(vec![], ReconciliationMode::Additive);
1941 assert!(filtered.is_empty());
1942 }
1943
1944 #[test]
1945 fn filter_additive_only_destructive_changes_yields_empty() {
1946 let changes = vec![
1947 Change::Revoke {
1948 role: "r1".to_string(),
1949 privileges: BTreeSet::from([Privilege::Select]),
1950 object_type: ObjectType::Table,
1951 schema: Some("public".to_string()),
1952 name: Some("*".to_string()),
1953 },
1954 Change::DropRole {
1955 name: "old-role".to_string(),
1956 },
1957 ];
1958 let filtered = filter_changes(changes, ReconciliationMode::Additive);
1959 assert!(filtered.is_empty());
1960 }
1961
1962 #[test]
1963 fn filter_adopt_preserves_ordering() {
1964 let changes = vec![
1965 Change::CreateRole {
1966 name: "new-role".to_string(),
1967 state: RoleState::default(),
1968 },
1969 Change::Grant {
1970 role: "new-role".to_string(),
1971 privileges: BTreeSet::from([Privilege::Select]),
1972 object_type: ObjectType::Table,
1973 schema: Some("public".to_string()),
1974 name: Some("*".to_string()),
1975 },
1976 Change::Revoke {
1977 role: "existing-role".to_string(),
1978 privileges: BTreeSet::from([Privilege::Insert]),
1979 object_type: ObjectType::Table,
1980 schema: Some("public".to_string()),
1981 name: Some("*".to_string()),
1982 },
1983 Change::DropRole {
1984 name: "old-role".to_string(),
1985 },
1986 ];
1987
1988 let filtered = filter_changes(changes, ReconciliationMode::Adopt);
1989 assert_eq!(filtered.len(), 3);
1990 assert!(matches!(&filtered[0], Change::CreateRole { name, .. } if name == "new-role"));
1991 assert!(matches!(&filtered[1], Change::Grant { .. }));
1992 assert!(matches!(&filtered[2], Change::Revoke { .. }));
1993 }
1994
1995 #[test]
1996 fn reconciliation_mode_display() {
1997 assert_eq!(
1998 ReconciliationMode::Authoritative.to_string(),
1999 "authoritative"
2000 );
2001 assert_eq!(ReconciliationMode::Additive.to_string(), "additive");
2002 assert_eq!(ReconciliationMode::Adopt.to_string(), "adopt");
2003 }
2004
2005 #[test]
2006 fn reconciliation_mode_default_is_authoritative() {
2007 assert_eq!(
2008 ReconciliationMode::default(),
2009 ReconciliationMode::Authoritative
2010 );
2011 }
2012
2013 #[test]
2018 fn apply_role_retirements_inserts_cleanup_before_drop() {
2019 let changes = vec![
2020 Change::Grant {
2021 role: "analytics".to_string(),
2022 privileges: BTreeSet::from([Privilege::Select]),
2023 object_type: ObjectType::Table,
2024 schema: Some("public".to_string()),
2025 name: Some("*".to_string()),
2026 },
2027 Change::DropRole {
2028 name: "old-app".to_string(),
2029 },
2030 ];
2031
2032 let planned = apply_role_retirements(
2033 changes,
2034 &[crate::manifest::RoleRetirement {
2035 role: "old-app".to_string(),
2036 reassign_owned_to: Some("successor".to_string()),
2037 drop_owned: true,
2038 terminate_sessions: true,
2039 }],
2040 );
2041
2042 assert!(matches!(planned[0], Change::Grant { .. }));
2043 assert!(matches!(
2044 planned[1],
2045 Change::TerminateSessions { ref role } if role == "old-app"
2046 ));
2047 assert!(matches!(
2048 planned[2],
2049 Change::ReassignOwned {
2050 ref from_role,
2051 ref to_role
2052 } if from_role == "old-app" && to_role == "successor"
2053 ));
2054 assert!(matches!(
2055 planned[3],
2056 Change::DropOwned { ref role } if role == "old-app"
2057 ));
2058 assert!(matches!(
2059 planned[4],
2060 Change::DropRole { ref name } if name == "old-app"
2061 ));
2062 }
2063
2064 #[test]
2065 fn inject_password_for_new_role() {
2066 let changes = vec![Change::CreateRole {
2067 name: "app-svc".to_string(),
2068 state: RoleState::default(),
2069 }];
2070
2071 let mut passwords = std::collections::BTreeMap::new();
2072 passwords.insert("app-svc".to_string(), "secret123".to_string());
2073
2074 let result = inject_password_changes(changes, &passwords);
2075 assert_eq!(result.len(), 2);
2076 assert!(matches!(&result[0], Change::CreateRole { name, .. } if name == "app-svc"));
2077 assert!(
2078 matches!(&result[1], Change::SetPassword { name, password } if name == "app-svc" && password.starts_with("SCRAM-SHA-256$"))
2079 );
2080 }
2081
2082 #[test]
2083 fn inject_password_for_existing_role() {
2084 let changes = vec![Change::Grant {
2086 role: "app-svc".to_string(),
2087 privileges: BTreeSet::from([crate::manifest::Privilege::Select]),
2088 object_type: crate::manifest::ObjectType::Table,
2089 schema: Some("public".to_string()),
2090 name: Some("*".to_string()),
2091 }];
2092
2093 let mut passwords = std::collections::BTreeMap::new();
2094 passwords.insert("app-svc".to_string(), "secret123".to_string());
2095
2096 let result = inject_password_changes(changes, &passwords);
2097 assert_eq!(result.len(), 2);
2098 assert!(matches!(&result[0], Change::Grant { .. }));
2099 assert!(
2100 matches!(&result[1], Change::SetPassword { name, password } if name == "app-svc" && password.starts_with("SCRAM-SHA-256$"))
2101 );
2102 }
2103
2104 #[test]
2105 fn inject_password_empty_passwords_is_noop() {
2106 let changes = vec![Change::CreateRole {
2107 name: "app-svc".to_string(),
2108 state: RoleState::default(),
2109 }];
2110
2111 let passwords = std::collections::BTreeMap::new();
2112 let result = inject_password_changes(changes.clone(), &passwords);
2113 assert_eq!(result.len(), 1);
2114 }
2115
2116 #[test]
2117 fn resolve_passwords_missing_env_var() {
2118 let roles = vec![crate::manifest::RoleDefinition {
2119 name: "app-svc".to_string(),
2120 external: false,
2121 login: Some(true),
2122 password: Some(crate::manifest::PasswordSource {
2123 from_env: "PGROLES_TEST_MISSING_VAR_9a8b7c6d".to_string(),
2124 }),
2125 password_valid_until: None,
2126 config: Default::default(),
2127 superuser: None,
2128 createdb: None,
2129 createrole: None,
2130 inherit: None,
2131 replication: None,
2132 bypassrls: None,
2133 connection_limit: None,
2134 comment: None,
2135 }];
2136
2137 unsafe { std::env::remove_var("PGROLES_TEST_MISSING_VAR_9a8b7c6d") };
2140
2141 let result = resolve_passwords(&roles);
2142 assert!(result.is_err());
2143 let err = result.unwrap_err();
2144 assert!(
2145 matches!(err, PasswordResolutionError::MissingEnvVar { ref role, ref env_var }
2146 if role == "app-svc" && env_var == "PGROLES_TEST_MISSING_VAR_9a8b7c6d"),
2147 "expected MissingEnvVar, got: {err:?}"
2148 );
2149 }
2150
2151 #[test]
2152 fn resolve_passwords_empty_env_var() {
2153 let roles = vec![crate::manifest::RoleDefinition {
2154 name: "app-svc".to_string(),
2155 external: false,
2156 login: Some(true),
2157 password: Some(crate::manifest::PasswordSource {
2158 from_env: "PGROLES_TEST_EMPTY_VAR_1a2b3c4d".to_string(),
2159 }),
2160 password_valid_until: None,
2161 config: Default::default(),
2162 superuser: None,
2163 createdb: None,
2164 createrole: None,
2165 inherit: None,
2166 replication: None,
2167 bypassrls: None,
2168 connection_limit: None,
2169 comment: None,
2170 }];
2171
2172 unsafe { std::env::set_var("PGROLES_TEST_EMPTY_VAR_1a2b3c4d", "") };
2175
2176 let result = resolve_passwords(&roles);
2177
2178 unsafe { std::env::remove_var("PGROLES_TEST_EMPTY_VAR_1a2b3c4d") };
2180
2181 assert!(result.is_err());
2182 let err = result.unwrap_err();
2183 assert!(
2184 matches!(err, PasswordResolutionError::EmptyPassword { ref role, ref env_var }
2185 if role == "app-svc" && env_var == "PGROLES_TEST_EMPTY_VAR_1a2b3c4d"),
2186 "expected EmptyPassword, got: {err:?}"
2187 );
2188 }
2189
2190 #[test]
2191 fn resolve_passwords_happy_path() {
2192 let roles = vec![crate::manifest::RoleDefinition {
2193 name: "app-svc".to_string(),
2194 external: false,
2195 login: Some(true),
2196 password: Some(crate::manifest::PasswordSource {
2197 from_env: "PGROLES_TEST_RESOLVE_VAR_5e6f7g8h".to_string(),
2198 }),
2199 password_valid_until: None,
2200 config: Default::default(),
2201 superuser: None,
2202 createdb: None,
2203 createrole: None,
2204 inherit: None,
2205 replication: None,
2206 bypassrls: None,
2207 connection_limit: None,
2208 comment: None,
2209 }];
2210
2211 unsafe { std::env::set_var("PGROLES_TEST_RESOLVE_VAR_5e6f7g8h", "my_secret_pw") };
2213
2214 let result = resolve_passwords(&roles);
2215
2216 unsafe { std::env::remove_var("PGROLES_TEST_RESOLVE_VAR_5e6f7g8h") };
2217
2218 let resolved = result.expect("should succeed");
2219 assert_eq!(resolved.len(), 1);
2220 assert_eq!(resolved["app-svc"], "my_secret_pw");
2221 }
2222
2223 #[test]
2224 fn resolve_passwords_skips_external_roles() {
2225 let roles = vec![crate::manifest::RoleDefinition {
2226 name: "external-svc".to_string(),
2227 external: true,
2228 login: Some(true),
2229 password: Some(crate::manifest::PasswordSource {
2230 from_env: "PGROLES_TEST_EXTERNAL_MISSING_VAR_2b4d6f8h".to_string(),
2231 }),
2232 password_valid_until: None,
2233 config: Default::default(),
2234 superuser: None,
2235 createdb: None,
2236 createrole: None,
2237 inherit: None,
2238 replication: None,
2239 bypassrls: None,
2240 connection_limit: None,
2241 comment: None,
2242 }];
2243
2244 unsafe { std::env::remove_var("PGROLES_TEST_EXTERNAL_MISSING_VAR_2b4d6f8h") };
2246
2247 let resolved = resolve_passwords(&roles).expect("external role passwords are ignored");
2248 assert!(resolved.is_empty());
2249 }
2250
2251 #[test]
2252 fn resolve_passwords_skips_roles_without_password() {
2253 let roles = vec![crate::manifest::RoleDefinition {
2254 name: "no-password".to_string(),
2255 external: false,
2256 login: Some(true),
2257 password: None,
2258 password_valid_until: None,
2259 config: Default::default(),
2260 superuser: None,
2261 createdb: None,
2262 createrole: None,
2263 inherit: None,
2264 replication: None,
2265 bypassrls: None,
2266 connection_limit: None,
2267 comment: None,
2268 }];
2269
2270 let result = resolve_passwords(&roles);
2271 let resolved = result.expect("should succeed");
2272 assert!(resolved.is_empty());
2273 }
2274
2275 #[test]
2276 fn inject_password_multiple_roles() {
2277 let changes = vec![
2278 Change::CreateRole {
2279 name: "role-a".to_string(),
2280 state: RoleState::default(),
2281 },
2282 Change::CreateRole {
2283 name: "role-b".to_string(),
2284 state: RoleState::default(),
2285 },
2286 Change::Grant {
2287 role: "role-c".to_string(),
2288 privileges: BTreeSet::from([crate::manifest::Privilege::Select]),
2289 object_type: crate::manifest::ObjectType::Table,
2290 schema: Some("public".to_string()),
2291 name: Some("*".to_string()),
2292 },
2293 ];
2294
2295 let mut passwords = std::collections::BTreeMap::new();
2296 passwords.insert("role-a".to_string(), "pw-a".to_string());
2297 passwords.insert("role-b".to_string(), "pw-b".to_string());
2298 passwords.insert("role-c".to_string(), "pw-c".to_string());
2299
2300 let result = inject_password_changes(changes, &passwords);
2301
2302 assert_eq!(result.len(), 6, "expected 6 changes, got: {result:?}");
2306 assert!(matches!(&result[0], Change::CreateRole { name, .. } if name == "role-a"));
2307 assert!(matches!(&result[1], Change::SetPassword { name, .. } if name == "role-a"));
2308 assert!(matches!(&result[2], Change::CreateRole { name, .. } if name == "role-b"));
2309 assert!(matches!(&result[3], Change::SetPassword { name, .. } if name == "role-b"));
2310 assert!(matches!(&result[4], Change::Grant { .. }));
2311 assert!(matches!(&result[5], Change::SetPassword { name, .. } if name == "role-c"));
2312 }
2313
2314 #[test]
2315 fn diff_detects_valid_until_change() {
2316 let mut current = empty_graph();
2317 current.roles.insert(
2318 "r1".to_string(),
2319 RoleState {
2320 login: true,
2321 ..RoleState::default()
2322 },
2323 );
2324
2325 let mut desired = empty_graph();
2326 desired.roles.insert(
2327 "r1".to_string(),
2328 RoleState {
2329 login: true,
2330 password_valid_until: Some("2025-12-31T00:00:00Z".to_string()),
2331 config: Default::default(),
2332 ..RoleState::default()
2333 },
2334 );
2335
2336 let changes = diff(¤t, &desired);
2337 assert_eq!(changes.len(), 1);
2338 match &changes[0] {
2339 Change::AlterRole { name, attributes } => {
2340 assert_eq!(name, "r1");
2341 assert!(attributes.contains(&RoleAttribute::ValidUntil(Some(
2342 "2025-12-31T00:00:00Z".to_string()
2343 ))));
2344 }
2345 other => panic!("expected AlterRole, got: {other:?}"),
2346 }
2347 }
2348
2349 #[test]
2360 fn diff_does_not_revoke_per_name_grants_covered_by_desired_wildcard() {
2361 let role = "cdc-editor".to_string();
2362 let schema = "cdc".to_string();
2363 let object_type = ObjectType::Function;
2364
2365 let mut current = empty_graph();
2370 for fn_name in ["f1()", "f3()"] {
2371 current.grants.insert(
2372 GrantKey {
2373 role: role.clone(),
2374 object_type,
2375 schema: Some(schema.clone()),
2376 name: Some(fn_name.to_string()),
2377 },
2378 GrantState {
2379 privileges: BTreeSet::from([Privilege::Execute]),
2380 },
2381 );
2382 }
2383
2384 let mut desired = empty_graph();
2387 desired.grants.insert(
2388 GrantKey {
2389 role: role.clone(),
2390 object_type,
2391 schema: Some(schema.clone()),
2392 name: Some("*".to_string()),
2393 },
2394 GrantState {
2395 privileges: BTreeSet::from([Privilege::Execute]),
2396 },
2397 );
2398
2399 let changes = diff(¤t, &desired);
2400
2401 let revokes: Vec<_> = changes
2402 .iter()
2403 .filter(|c| matches!(c, Change::Revoke { .. }))
2404 .collect();
2405 assert!(
2406 revokes.is_empty(),
2407 "must not revoke per-name grants covered by desired wildcard \
2408 (would cause apply-order flap); got: {revokes:#?}"
2409 );
2410
2411 let grants: Vec<_> = changes
2412 .iter()
2413 .filter(|c| matches!(c, Change::Grant { .. }))
2414 .collect();
2415 assert_eq!(
2416 grants.len(),
2417 1,
2418 "expected a single wildcard GRANT to materialise ACLs on all functions; got: {grants:#?}"
2419 );
2420 match grants[0] {
2421 Change::Grant {
2422 role: r,
2423 name,
2424 privileges,
2425 ..
2426 } => {
2427 assert_eq!(r, &role);
2428 assert_eq!(name.as_deref(), Some("*"));
2429 assert!(privileges.contains(&Privilege::Execute));
2430 }
2431 other => panic!("expected wildcard Grant, got: {other:?}"),
2432 }
2433 }
2434
2435 #[test]
2447 fn diff_does_not_revoke_extra_privileges_covered_by_desired_wildcard() {
2448 let role = "viewer".to_string();
2449 let schema = "myschema".to_string();
2450 let object_type = ObjectType::Table;
2451
2452 let mut current = empty_graph();
2455 current.grants.insert(
2456 GrantKey {
2457 role: role.clone(),
2458 object_type,
2459 schema: Some(schema.clone()),
2460 name: Some("widgets".to_string()),
2461 },
2462 GrantState {
2463 privileges: BTreeSet::from([Privilege::Select, Privilege::Insert]),
2464 },
2465 );
2466
2467 let mut desired = empty_graph();
2469 desired.grants.insert(
2470 GrantKey {
2471 role: role.clone(),
2472 object_type,
2473 schema: Some(schema.clone()),
2474 name: Some("*".to_string()),
2475 },
2476 GrantState {
2477 privileges: BTreeSet::from([Privilege::Select]),
2478 },
2479 );
2480 desired.grants.insert(
2481 GrantKey {
2482 role: role.clone(),
2483 object_type,
2484 schema: Some(schema.clone()),
2485 name: Some("widgets".to_string()),
2486 },
2487 GrantState {
2488 privileges: BTreeSet::from([Privilege::Insert]),
2489 },
2490 );
2491
2492 let changes = diff(¤t, &desired);
2493
2494 let revokes: Vec<_> = changes
2495 .iter()
2496 .filter(|c| matches!(c, Change::Revoke { .. }))
2497 .collect();
2498 assert!(
2499 revokes.is_empty(),
2500 "must not revoke widgets SELECT — covered by desired wildcard; got: {revokes:#?}"
2501 );
2502
2503 let grants: Vec<_> = changes
2506 .iter()
2507 .filter(|c| matches!(c, Change::Grant { .. }))
2508 .collect();
2509 let has_wildcard_select_grant = grants.iter().any(|c| {
2510 matches!(
2511 c,
2512 Change::Grant {
2513 name,
2514 privileges,
2515 ..
2516 } if name.as_deref() == Some("*")
2517 && privileges.contains(&Privilege::Select)
2518 )
2519 });
2520 assert!(
2521 has_wildcard_select_grant,
2522 "expected wildcard GRANT for SELECT; got: {grants:#?}"
2523 );
2524 }
2525
2526 #[test]
2527 fn diff_detects_valid_until_removal() {
2528 let mut current = empty_graph();
2529 current.roles.insert(
2530 "r1".to_string(),
2531 RoleState {
2532 login: true,
2533 password_valid_until: Some("2025-12-31T00:00:00Z".to_string()),
2534 config: Default::default(),
2535 ..RoleState::default()
2536 },
2537 );
2538
2539 let mut desired = empty_graph();
2540 desired.roles.insert(
2541 "r1".to_string(),
2542 RoleState {
2543 login: true,
2544 ..RoleState::default()
2545 },
2546 );
2547
2548 let changes = diff(¤t, &desired);
2549 assert_eq!(changes.len(), 1);
2550 match &changes[0] {
2551 Change::AlterRole { name, attributes } => {
2552 assert_eq!(name, "r1");
2553 assert!(attributes.contains(&RoleAttribute::ValidUntil(None)));
2554 }
2555 other => panic!("expected AlterRole, got: {other:?}"),
2556 }
2557 }
2558}