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