1use std::collections::{BTreeMap, BTreeSet};
12
13use crate::manifest::{ObjectType, Privilege, RoleDefinition, RoleRetirement};
14use crate::model::{
15 DefaultPrivKey, DefaultPrivilegeScope, GrantKey, Grantee, MembershipEdge, RoleAttribute,
16 RoleGraph, RoleState, 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: Grantee,
68 privileges: BTreeSet<Privilege>,
69 object_type: ObjectType,
70 schema: Option<String>,
71 name: Option<String>,
72 },
73
74 Revoke {
76 role: Grantee,
77 privileges: BTreeSet<Privilege>,
78 object_type: ObjectType,
79 schema: Option<String>,
80 name: Option<String>,
81 },
82
83 SetDefaultPrivilege {
85 owner: String,
86 scope: DefaultPrivilegeScope,
87 on_type: ObjectType,
88 grantee: Grantee,
89 privileges: BTreeSet<Privilege>,
90 },
91
92 RevokeDefaultPrivilege {
94 owner: String,
95 scope: DefaultPrivilegeScope,
96 on_type: ObjectType,
97 grantee: Grantee,
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 additive_ignores_absence_assertions(desired: &RoleGraph, mode: ReconciliationMode) -> bool {
215 mode == ReconciliationMode::Additive
216 && (!desired.grant_absences.is_empty() || !desired.default_privilege_absences.is_empty())
217}
218
219pub fn filter_external_role_changes(changes: Vec<Change>, roles: &[RoleDefinition]) -> Vec<Change> {
225 let external_roles: BTreeSet<&str> = roles
226 .iter()
227 .filter(|role| role.external)
228 .map(|role| role.name.as_str())
229 .collect();
230
231 if external_roles.is_empty() {
232 return changes;
233 }
234
235 changes
236 .into_iter()
237 .filter(|change| !is_external_role_change(change, &external_roles))
238 .collect()
239}
240
241fn is_external_role_change(change: &Change, external_roles: &BTreeSet<&str>) -> bool {
242 match change {
243 Change::CreateRole { name, .. }
244 | Change::AlterRole { name, .. }
245 | Change::SetComment { name, .. }
246 | Change::SetPassword { name, .. }
247 | Change::DropRole { name } => external_roles.contains(name.as_str()),
248 Change::AddMember { role, .. } | Change::RemoveMember { role, .. } => {
249 external_roles.contains(role.as_str())
250 }
251 Change::TerminateSessions { role }
252 | Change::DropOwned { role }
253 | Change::ReassignOwned {
254 from_role: role, ..
255 } => external_roles.contains(role.as_str()),
256 _ => false,
257 }
258}
259
260fn filter_additive_changes(changes: Vec<Change>) -> Vec<Change> {
261 let skipped_owner_transfers: BTreeSet<(String, String)> = changes
262 .iter()
263 .filter_map(|change| match change {
264 Change::AlterSchemaOwner { name, owner } => Some((name.clone(), owner.clone())),
265 _ => None,
266 })
267 .collect();
268
269 let created_roles: BTreeSet<String> = changes
273 .iter()
274 .filter_map(|change| match change {
275 Change::CreateRole { name, .. } => Some(name.clone()),
276 _ => None,
277 })
278 .collect();
279
280 changes
281 .into_iter()
282 .filter(|change| match change {
283 Change::EnsureSchemaOwnerPrivileges { name, owner, .. } => {
284 !skipped_owner_transfers.contains(&(name.clone(), owner.clone()))
285 }
286 Change::SetDefaultPrivilege {
287 scope: DefaultPrivilegeScope::Schema { schema },
288 owner,
289 ..
290 } => !skipped_owner_transfers.contains(&(schema.clone(), owner.clone())),
291 Change::AlterRole { name, attributes } => {
292 created_roles.contains(name)
293 && attributes
294 .iter()
295 .all(|attr| matches!(attr, RoleAttribute::SetConfig(..)))
296 }
297 Change::SetComment { .. } => false,
298 _ => !is_destructive(change),
299 })
300 .collect()
301}
302
303fn is_destructive(change: &Change) -> bool {
305 matches!(
306 change,
307 Change::AlterSchemaOwner { .. }
308 | Change::Revoke { .. }
309 | Change::RevokeDefaultPrivilege { .. }
310 | Change::RemoveMember { .. }
311 | Change::DropRole { .. }
312 | Change::DropOwned { .. }
313 | Change::ReassignOwned { .. }
314 | Change::TerminateSessions { .. }
315 )
316}
317
318fn is_role_drop_or_retirement(change: &Change) -> bool {
320 matches!(
321 change,
322 Change::DropRole { .. }
323 | Change::DropOwned { .. }
324 | Change::ReassignOwned { .. }
325 | Change::TerminateSessions { .. }
326 )
327}
328
329pub fn diff(current: &RoleGraph, desired: &RoleGraph) -> Vec<Change> {
338 let mut creates = Vec::new();
339 let mut alters = Vec::new();
340 let mut schema_changes = Vec::new();
341 let mut schema_grants = Vec::new();
342 let mut grants = Vec::new();
343 let mut set_defaults = Vec::new();
344 let mut add_members = Vec::new();
345 let mut remove_members = Vec::new();
346 let mut revoke_defaults = Vec::new();
347 let mut revokes = Vec::new();
348 let mut drops = Vec::new();
349
350 for (name, desired_state) in &desired.roles {
354 match current.roles.get(name) {
355 None => {
356 creates.push(Change::CreateRole {
357 name: name.clone(),
358 state: desired_state.clone(),
359 });
360 if !desired_state.config.is_empty() {
364 alters.push(Change::AlterRole {
365 name: name.clone(),
366 attributes: desired_state
367 .config
368 .iter()
369 .map(|(parameter, value)| {
370 RoleAttribute::SetConfig(parameter.clone(), value.clone())
371 })
372 .collect(),
373 });
374 }
375 }
376 Some(current_state) => {
377 let attribute_changes = current_state.changed_attributes(desired_state);
379 if !attribute_changes.is_empty() {
380 alters.push(Change::AlterRole {
381 name: name.clone(),
382 attributes: attribute_changes,
383 });
384 }
385 if current_state.comment != desired_state.comment {
387 alters.push(Change::SetComment {
388 name: name.clone(),
389 comment: desired_state.comment.clone(),
390 });
391 }
392 }
393 }
394 }
395
396 for name in current.roles.keys() {
398 if !desired.roles.contains_key(name) {
399 drops.push(Change::DropRole { name: name.clone() });
400 }
401 }
402
403 diff_schemas(current, desired, &mut schema_changes, &mut schema_grants);
406
407 diff_grants(current, desired, &mut grants, &mut revokes);
410
411 diff_default_privileges(current, desired, &mut set_defaults, &mut revoke_defaults);
414
415 diff_memberships(current, desired, &mut add_members, &mut remove_members);
418
419 let incoming_owners: BTreeSet<(&str, &str)> = schema_changes
428 .iter()
429 .filter_map(|change| match change {
430 Change::AlterSchemaOwner { name, owner } => Some((name.as_str(), owner.as_str())),
431 _ => None,
432 })
433 .collect();
434 if !incoming_owners.is_empty() {
435 revokes.retain(|change| match change {
436 Change::Revoke {
437 role,
438 object_type: ObjectType::Schema,
439 name: Some(schema_name),
440 ..
441 } => !incoming_owners.contains(&(schema_name.as_str(), role.as_str())),
442 _ => true,
443 });
444 }
445
446 let mut changes = Vec::new();
448 changes.extend(creates);
449 changes.extend(alters);
450 changes.extend(schema_changes);
451 changes.extend(schema_grants);
452 changes.extend(grants);
453 changes.extend(set_defaults);
454 changes.extend(remove_members);
455 changes.extend(add_members);
456 changes.extend(revoke_defaults);
457 changes.extend(revokes);
458 changes.extend(drops);
459 changes
460}
461
462fn diff_schemas(
463 current: &RoleGraph,
464 desired: &RoleGraph,
465 schema_out: &mut Vec<Change>,
466 grant_out: &mut Vec<Change>,
467) {
468 for (name, desired_state) in &desired.schemas {
469 let owner_changed = current
470 .schemas
471 .get(name)
472 .is_some_and(|current_state| current_state.owner != desired_state.owner);
473 match current.schemas.get(name) {
474 None => schema_out.push(Change::CreateSchema {
475 name: name.clone(),
476 owner: desired_state.owner.clone(),
477 }),
478 Some(current_state) => {
479 if current_state.owner != desired_state.owner
480 && let Some(owner) = &desired_state.owner
481 {
482 schema_out.push(Change::AlterSchemaOwner {
483 name: name.clone(),
484 owner: owner.clone(),
485 });
486 }
487 }
488 }
489
490 let Some(owner) = desired_state.owner.as_deref() else {
491 continue;
492 };
493
494 if !current.schemas.contains_key(name) {
495 continue;
496 }
497
498 let expected_privileges = default_schema_owner_privileges(owner);
499 let current_privileges = if owner_changed {
506 BTreeSet::new()
507 } else {
508 current
509 .schemas
510 .get(name)
511 .map(|state| state.owner_privileges.clone())
512 .unwrap_or_default()
513 };
514 let missing_privileges: BTreeSet<Privilege> = expected_privileges
515 .difference(¤t_privileges)
516 .copied()
517 .collect();
518
519 if !missing_privileges.is_empty() {
520 grant_out.push(Change::EnsureSchemaOwnerPrivileges {
521 name: name.clone(),
522 owner: owner.to_string(),
523 privileges: missing_privileges,
524 });
525 }
526 }
527}
528
529pub fn apply_role_retirements(changes: Vec<Change>, retirements: &[RoleRetirement]) -> Vec<Change> {
535 if retirements.is_empty() {
536 return changes;
537 }
538
539 let retirement_by_role: std::collections::BTreeMap<&str, &RoleRetirement> = retirements
540 .iter()
541 .map(|retirement| (retirement.role.as_str(), retirement))
542 .collect();
543
544 let mut planned = Vec::with_capacity(changes.len());
545 for change in changes {
546 if let Change::DropRole { name } = &change
547 && let Some(retirement) = retirement_by_role.get(name.as_str())
548 {
549 if retirement.terminate_sessions {
550 planned.push(Change::TerminateSessions { role: name.clone() });
551 }
552 if let Some(successor) = &retirement.reassign_owned_to {
553 planned.push(Change::ReassignOwned {
554 from_role: name.clone(),
555 to_role: successor.clone(),
556 });
557 }
558 if retirement.drop_owned {
559 planned.push(Change::DropOwned { role: name.clone() });
560 }
561 }
562 planned.push(change);
563 }
564
565 planned
566}
567
568pub fn resolve_passwords(
578 roles: &[crate::manifest::RoleDefinition],
579) -> Result<std::collections::BTreeMap<String, String>, PasswordResolutionError> {
580 let mut resolved = std::collections::BTreeMap::new();
581 for role in roles {
582 if role.external {
583 continue;
584 }
585 if let Some(source) = &role.password {
586 let value = std::env::var(&source.from_env).map_err(|_| {
587 PasswordResolutionError::MissingEnvVar {
588 role: role.name.clone(),
589 env_var: source.from_env.clone(),
590 }
591 })?;
592 if value.is_empty() {
593 return Err(PasswordResolutionError::EmptyPassword {
594 role: role.name.clone(),
595 env_var: source.from_env.clone(),
596 });
597 }
598 resolved.insert(role.name.clone(), value);
599 }
600 }
601 Ok(resolved)
602}
603
604#[derive(Debug, thiserror::Error)]
606pub enum PasswordResolutionError {
607 #[error("environment variable \"{env_var}\" for role \"{role}\" password is not set")]
608 MissingEnvVar { role: String, env_var: String },
609
610 #[error("environment variable \"{env_var}\" for role \"{role}\" password is empty")]
611 EmptyPassword { role: String, env_var: String },
612}
613
614pub fn inject_password_changes(
627 changes: Vec<Change>,
628 resolved_passwords: &std::collections::BTreeMap<String, String>,
629) -> Vec<Change> {
630 if resolved_passwords.is_empty() {
631 return changes;
632 }
633
634 let created_roles: std::collections::BTreeSet<String> = changes
636 .iter()
637 .filter_map(|c| match c {
638 Change::CreateRole { name, .. } => Some(name.clone()),
639 _ => None,
640 })
641 .collect();
642
643 let mut result = Vec::with_capacity(changes.len() + resolved_passwords.len());
644
645 for change in changes {
647 if let Change::CreateRole { ref name, .. } = change
648 && let Some(password) = resolved_passwords.get(name.as_str())
649 {
650 let role_name = name.clone();
651 let verifier =
652 crate::scram::compute_verifier(password, crate::scram::DEFAULT_ITERATIONS);
653 result.push(change);
654 result.push(Change::SetPassword {
655 name: role_name,
656 password: verifier,
657 });
658 continue;
659 }
660 result.push(change);
661 }
662
663 for (role_name, password) in resolved_passwords {
665 if !created_roles.contains(role_name) {
666 let verifier =
667 crate::scram::compute_verifier(password, crate::scram::DEFAULT_ITERATIONS);
668 result.push(Change::SetPassword {
669 name: role_name.clone(),
670 password: verifier,
671 });
672 }
673 }
674
675 result
676}
677
678fn diff_grants(
683 current: &RoleGraph,
684 desired: &RoleGraph,
685 grants_out: &mut Vec<Change>,
686 revokes_out: &mut Vec<Change>,
687) {
688 let desired_wildcards: BTreeMap<(&Grantee, &Option<String>, ObjectType), &BTreeSet<Privilege>> =
708 desired
709 .grants
710 .iter()
711 .filter(|(k, _)| k.name.as_deref() == Some("*") && k.schema.is_some())
712 .map(|(k, v)| ((&k.role, &k.schema, k.object_type), &v.privileges))
713 .collect();
714
715 let absence_wildcards: BTreeMap<(&Grantee, &Option<String>, ObjectType), &BTreeSet<Privilege>> =
719 desired
720 .grant_absences
721 .iter()
722 .filter(|(k, _)| k.name.as_deref() == Some("*") && k.schema.is_some())
723 .map(|(k, v)| ((&k.role, &k.schema, k.object_type), v))
724 .collect();
725
726 let shadow_filter = |key: &GrantKey, candidate: BTreeSet<Privilege>| -> BTreeSet<Privilege> {
730 if key.name.as_deref() == Some("*") {
731 return candidate;
732 }
733 let mut filtered = candidate;
734 let selector = (&key.role, &key.schema, key.object_type);
735 if let Some(wildcard_privileges) = desired_wildcards.get(&selector) {
736 filtered = filtered.difference(wildcard_privileges).copied().collect();
737 }
738 if let Some(absent_privileges) = absence_wildcards.get(&selector) {
739 filtered = filtered.difference(absent_privileges).copied().collect();
740 }
741 filtered
742 };
743
744 let mut revokes: BTreeMap<GrantKey, BTreeSet<Privilege>> = BTreeMap::new();
747
748 for (key, desired_state) in &desired.grants {
751 match current.grants.get(key) {
752 None => {
753 grants_out.push(change_grant(key, &desired_state.privileges));
755 }
756 Some(current_state) => {
757 let to_add: BTreeSet<Privilege> = desired_state
759 .privileges
760 .difference(¤t_state.privileges)
761 .copied()
762 .collect();
763 if !to_add.is_empty() {
764 grants_out.push(change_grant(key, &to_add));
765 }
766
767 if key.role.is_public() {
771 continue;
772 }
773
774 let to_remove: BTreeSet<Privilege> = current_state
775 .privileges
776 .difference(&desired_state.privileges)
777 .copied()
778 .collect();
779 let to_remove = shadow_filter(key, to_remove);
780 if !to_remove.is_empty() {
781 revokes.entry(key.clone()).or_default().extend(to_remove);
782 }
783 }
784 }
785 }
786
787 for (key, current_state) in ¤t.grants {
791 if desired.grants.contains_key(key) || key.role.is_public() {
792 continue;
793 }
794
795 let to_revoke = shadow_filter(key, current_state.privileges.clone());
796 if !to_revoke.is_empty() {
797 revokes.entry(key.clone()).or_default().extend(to_revoke);
798 }
799 }
800
801 for (key, absent_privileges) in &desired.grant_absences {
806 let held: BTreeSet<Privilege> = if key.name.as_deref() == Some("*") {
807 let range_start = GrantKey {
808 role: key.role.clone(),
809 object_type: key.object_type,
810 schema: key.schema.clone(),
811 name: None,
812 };
813 current
814 .grants
815 .range(range_start..)
816 .take_while(|(k, _)| {
817 k.role == key.role && k.object_type == key.object_type && k.schema == key.schema
818 })
819 .flat_map(|(_, state)| state.privileges.iter().copied())
820 .collect()
821 } else {
822 current
823 .grants
824 .get(key)
825 .map(|state| state.privileges.clone())
826 .unwrap_or_default()
827 };
828
829 let to_revoke: BTreeSet<Privilege> =
830 absent_privileges.intersection(&held).copied().collect();
831 if !to_revoke.is_empty() {
832 revokes.entry(key.clone()).or_default().extend(to_revoke);
833 }
834 }
835
836 for (key, privileges) in &revokes {
837 revokes_out.push(change_revoke(key, privileges));
838 }
839}
840
841fn change_grant(key: &GrantKey, privileges: &BTreeSet<Privilege>) -> Change {
842 Change::Grant {
843 role: key.role.clone(),
844 privileges: privileges.clone(),
845 object_type: key.object_type,
846 schema: key.schema.clone(),
847 name: key.name.clone(),
848 }
849}
850
851fn change_revoke(key: &GrantKey, privileges: &BTreeSet<Privilege>) -> Change {
852 Change::Revoke {
853 role: key.role.clone(),
854 privileges: privileges.clone(),
855 object_type: key.object_type,
856 schema: key.schema.clone(),
857 name: key.name.clone(),
858 }
859}
860
861fn diff_default_privileges(
866 current: &RoleGraph,
867 desired: &RoleGraph,
868 set_out: &mut Vec<Change>,
869 revoke_out: &mut Vec<Change>,
870) {
871 let mut revokes: BTreeMap<DefaultPrivKey, BTreeSet<Privilege>> = BTreeMap::new();
874
875 for (key, desired_state) in &desired.default_privileges {
876 match current.default_privileges.get(key) {
877 None => {
878 set_out.push(change_set_default(key, &desired_state.privileges));
879 }
880 Some(current_state) => {
881 let to_add: BTreeSet<Privilege> = desired_state
882 .privileges
883 .difference(¤t_state.privileges)
884 .copied()
885 .collect();
886 if !to_add.is_empty() {
887 set_out.push(change_set_default(key, &to_add));
888 }
889
890 if key.grantee.is_public() {
893 continue;
894 }
895
896 let to_remove: BTreeSet<Privilege> = current_state
897 .privileges
898 .difference(&desired_state.privileges)
899 .copied()
900 .collect();
901 if !to_remove.is_empty() {
902 revokes.entry(key.clone()).or_default().extend(to_remove);
903 }
904 }
905 }
906 }
907
908 for (key, current_state) in ¤t.default_privileges {
909 if desired.default_privileges.contains_key(key) || key.grantee.is_public() {
910 continue;
911 }
912 revokes
913 .entry(key.clone())
914 .or_default()
915 .extend(current_state.privileges.iter().copied());
916 }
917
918 for (key, absent_privileges) in &desired.default_privilege_absences {
925 let current_privileges = current
926 .default_privileges
927 .get(key)
928 .map(|state| state.privileges.clone())
929 .unwrap_or_else(|| builtin_defaults_for_new_owner(current, desired, key));
930 let to_revoke: BTreeSet<Privilege> = absent_privileges
931 .intersection(¤t_privileges)
932 .copied()
933 .collect();
934 if !to_revoke.is_empty() {
935 revokes.entry(key.clone()).or_default().extend(to_revoke);
936 }
937 }
938
939 for (key, privileges) in &revokes {
940 revoke_out.push(change_revoke_default(key, privileges));
941 }
942}
943
944fn builtin_defaults_for_new_owner(
945 current: &RoleGraph,
946 desired: &RoleGraph,
947 key: &DefaultPrivKey,
948) -> BTreeSet<Privilege> {
949 if current.roles.contains_key(&key.owner)
950 || !desired.roles.contains_key(&key.owner)
951 || key.scope != DefaultPrivilegeScope::Global
952 || !key.grantee.is_public()
953 {
954 return BTreeSet::new();
955 }
956
957 match key.on_type {
958 ObjectType::Function => BTreeSet::from([Privilege::Execute]),
959 ObjectType::Type => BTreeSet::from([Privilege::Usage]),
960 _ => BTreeSet::new(),
961 }
962}
963
964fn change_set_default(key: &DefaultPrivKey, privileges: &BTreeSet<Privilege>) -> Change {
965 Change::SetDefaultPrivilege {
966 owner: key.owner.clone(),
967 scope: key.scope.clone(),
968 on_type: key.on_type,
969 grantee: key.grantee.clone(),
970 privileges: privileges.clone(),
971 }
972}
973
974fn change_revoke_default(key: &DefaultPrivKey, privileges: &BTreeSet<Privilege>) -> Change {
975 Change::RevokeDefaultPrivilege {
976 owner: key.owner.clone(),
977 scope: key.scope.clone(),
978 on_type: key.on_type,
979 grantee: key.grantee.clone(),
980 privileges: privileges.clone(),
981 }
982}
983
984fn diff_memberships(
989 current: &RoleGraph,
990 desired: &RoleGraph,
991 add_out: &mut Vec<Change>,
992 remove_out: &mut Vec<Change>,
993) {
994 let current_map: std::collections::BTreeMap<(&str, &str), &MembershipEdge> = current
999 .memberships
1000 .iter()
1001 .map(|edge| ((edge.role.as_str(), edge.member.as_str()), edge))
1002 .collect();
1003 let desired_map: std::collections::BTreeMap<(&str, &str), &MembershipEdge> = desired
1004 .memberships
1005 .iter()
1006 .map(|edge| ((edge.role.as_str(), edge.member.as_str()), edge))
1007 .collect();
1008
1009 for (&(role, member), &desired_edge) in &desired_map {
1012 match current_map.get(&(role, member)) {
1013 None => {
1014 add_out.push(Change::AddMember {
1015 role: desired_edge.role.clone(),
1016 member: desired_edge.member.clone(),
1017 inherit: desired_edge.inherit,
1018 admin: desired_edge.admin,
1019 });
1020 }
1021 Some(current_edge) => {
1022 if current_edge.inherit != desired_edge.inherit
1023 || current_edge.admin != desired_edge.admin
1024 {
1025 remove_out.push(Change::RemoveMember {
1027 role: current_edge.role.clone(),
1028 member: current_edge.member.clone(),
1029 });
1030 add_out.push(Change::AddMember {
1031 role: desired_edge.role.clone(),
1032 member: desired_edge.member.clone(),
1033 inherit: desired_edge.inherit,
1034 admin: desired_edge.admin,
1035 });
1036 }
1037 }
1038 }
1039 }
1040
1041 for &(role, member) in current_map.keys() {
1043 if !desired_map.contains_key(&(role, member)) {
1044 remove_out.push(Change::RemoveMember {
1045 role: role.to_string(),
1046 member: member.to_string(),
1047 });
1048 }
1049 }
1050}
1051
1052#[cfg(test)]
1057mod tests {
1058 use super::*;
1059 use crate::model::{
1060 DefaultPrivState, GrantState, SchemaState, default_schema_owner_privileges,
1061 };
1062
1063 fn empty_graph() -> RoleGraph {
1065 RoleGraph::default()
1066 }
1067
1068 fn managed_schema(owner: &str) -> SchemaState {
1069 SchemaState {
1070 owner: Some(owner.to_string()),
1071 owner_privileges: default_schema_owner_privileges(owner),
1072 }
1073 }
1074
1075 fn role_definition(name: &str, external: bool) -> RoleDefinition {
1076 RoleDefinition {
1077 name: name.to_string(),
1078 external,
1079 login: None,
1080 superuser: None,
1081 createdb: None,
1082 createrole: None,
1083 inherit: None,
1084 replication: None,
1085 bypassrls: None,
1086 connection_limit: None,
1087 comment: None,
1088 password: None,
1089 password_valid_until: None,
1090 config: Default::default(),
1091 }
1092 }
1093
1094 #[test]
1095 fn diff_empty_to_empty_is_empty() {
1096 let changes = diff(&empty_graph(), &empty_graph());
1097 assert!(changes.is_empty());
1098 }
1099
1100 #[test]
1101 fn diff_creates_new_roles() {
1102 let current = empty_graph();
1103 let mut desired = empty_graph();
1104 desired
1105 .roles
1106 .insert("new-role".to_string(), RoleState::default());
1107
1108 let changes = diff(¤t, &desired);
1109 assert_eq!(changes.len(), 1);
1110 assert!(matches!(&changes[0], Change::CreateRole { name, .. } if name == "new-role"));
1111 }
1112
1113 #[test]
1114 fn diff_drops_removed_roles() {
1115 let mut current = empty_graph();
1116 current
1117 .roles
1118 .insert("old-role".to_string(), RoleState::default());
1119 let desired = empty_graph();
1120
1121 let changes = diff(¤t, &desired);
1122 assert_eq!(changes.len(), 1);
1123 assert!(matches!(&changes[0], Change::DropRole { name } if name == "old-role"));
1124 }
1125
1126 #[test]
1127 fn diff_alters_changed_role_attributes() {
1128 let mut current = empty_graph();
1129 current
1130 .roles
1131 .insert("role1".to_string(), RoleState::default());
1132
1133 let mut desired = empty_graph();
1134 desired.roles.insert(
1135 "role1".to_string(),
1136 RoleState {
1137 login: true,
1138 ..RoleState::default()
1139 },
1140 );
1141
1142 let changes = diff(¤t, &desired);
1143 assert_eq!(changes.len(), 1);
1144 match &changes[0] {
1145 Change::AlterRole { name, attributes } => {
1146 assert_eq!(name, "role1");
1147 assert!(attributes.contains(&RoleAttribute::Login(true)));
1148 }
1149 other => panic!("expected AlterRole, got: {other:?}"),
1150 }
1151 }
1152
1153 #[test]
1154 fn owner_transfer_suppresses_revoke_of_incoming_owners_stale_grant() {
1155 let mut current = empty_graph();
1160 for role in ["w", "z", "bystander"] {
1161 current.roles.insert(role.to_string(), RoleState::default());
1162 }
1163 current.schemas.insert(
1164 "s".to_string(),
1165 SchemaState {
1166 owner: Some("w".to_string()),
1167 owner_privileges: default_schema_owner_privileges("w"),
1168 },
1169 );
1170 for grantee in ["z", "bystander"] {
1171 current.grants.insert(
1172 GrantKey {
1173 role: grantee.into(),
1174 object_type: ObjectType::Schema,
1175 schema: None,
1176 name: Some("s".to_string()),
1177 },
1178 GrantState {
1179 privileges: [Privilege::Usage].into_iter().collect(),
1180 },
1181 );
1182 }
1183
1184 let mut desired = empty_graph();
1185 for role in ["w", "z", "bystander"] {
1186 desired.roles.insert(role.to_string(), RoleState::default());
1187 }
1188 desired.schemas.insert(
1189 "s".to_string(),
1190 SchemaState {
1191 owner: Some("z".to_string()),
1192 owner_privileges: default_schema_owner_privileges("z"),
1193 },
1194 );
1195
1196 let changes = diff(¤t, &desired);
1197
1198 assert!(
1199 changes.iter().any(|c| matches!(
1200 c,
1201 Change::AlterSchemaOwner { name, owner } if name == "s" && owner == "z"
1202 )),
1203 "expected owner transfer in plan: {changes:?}"
1204 );
1205 assert!(
1208 !changes.iter().any(|c| matches!(
1209 c,
1210 Change::Revoke { role, object_type: ObjectType::Schema, name: Some(n), .. }
1211 if role.as_str() == "z" && n == "s"
1212 )),
1213 "revoke against incoming owner must be suppressed: {changes:?}"
1214 );
1215 assert!(
1217 changes.iter().any(|c| matches!(
1218 c,
1219 Change::Revoke { role, object_type: ObjectType::Schema, name: Some(n), .. }
1220 if role.as_str() == "bystander" && n == "s"
1221 )),
1222 "bystander's stale grant must still be revoked: {changes:?}"
1223 );
1224 }
1225
1226 #[test]
1227 fn diff_converges_role_config_via_manifest_pipeline() {
1228 let yaml = r#"
1231roles:
1232 - name: blue
1233 login: true
1234 config:
1235 role: combined
1236 - name: green
1237 login: true
1238 config:
1239 role: combined
1240 - name: combined
1241
1242memberships:
1243 - role: combined
1244 members:
1245 - name: blue
1246 - name: green
1247"#;
1248 let manifest = crate::manifest::parse_manifest(yaml).unwrap();
1249 let expanded = crate::manifest::expand_manifest(&manifest).unwrap();
1250 let desired = RoleGraph::from_expanded(&expanded, None).unwrap();
1251
1252 let changes = diff(&empty_graph(), &desired);
1254 let sql = crate::sql::render_all(&changes);
1255 assert!(sql.contains("ALTER ROLE \"blue\" SET \"role\" = 'combined';"));
1256 assert!(sql.contains("ALTER ROLE \"green\" SET \"role\" = 'combined';"));
1257 assert!(sql.contains("GRANT \"combined\" TO \"blue\""));
1258
1259 let changes = diff(&desired, &desired);
1261 assert!(changes.is_empty());
1262
1263 let mut current = desired.clone();
1265 current.roles.get_mut("green").unwrap().config.clear();
1266 current
1267 .roles
1268 .get_mut("blue")
1269 .unwrap()
1270 .config
1271 .insert("statement_timeout".to_string(), "10s".to_string());
1272 let changes = diff(¤t, &desired);
1273 let sql = crate::sql::render_all(&changes);
1274 assert!(sql.contains("ALTER ROLE \"green\" SET \"role\" = 'combined';"));
1275 assert!(sql.contains("ALTER ROLE \"blue\" RESET \"statement_timeout\";"));
1276 assert!(!sql.contains("ALTER ROLE \"blue\" SET"));
1277 }
1278
1279 #[test]
1280 fn external_role_filter_suppresses_lifecycle_and_granted_role_memberships() {
1281 let external = "analytics-admin@example.com";
1282 let mut current = empty_graph();
1283 current.roles.insert(
1284 external.to_string(),
1285 RoleState {
1286 login: true,
1287 ..RoleState::default()
1288 },
1289 );
1290 current.memberships.insert(MembershipEdge {
1291 role: external.to_string(),
1292 member: "cloudsqlsuperuser".to_string(),
1293 inherit: true,
1294 admin: false,
1295 });
1296
1297 let mut desired = empty_graph();
1298 desired
1299 .roles
1300 .insert(external.to_string(), RoleState::default());
1301
1302 let changes = diff(¤t, &desired);
1303 assert!(changes.iter().any(|change| {
1304 matches!(
1305 change,
1306 Change::AlterRole { name, attributes }
1307 if name == external && attributes.contains(&RoleAttribute::Login(false))
1308 )
1309 }));
1310 assert!(changes.iter().any(|change| {
1311 matches!(
1312 change,
1313 Change::RemoveMember { role, member }
1314 if role == external && member == "cloudsqlsuperuser"
1315 )
1316 }));
1317
1318 let filtered = filter_external_role_changes(changes, &[role_definition(external, true)]);
1319 assert!(filtered.is_empty());
1320 }
1321
1322 #[test]
1323 fn external_role_filter_keeps_external_role_as_managed_member() {
1324 let external = "team@example.com";
1325 let changes = vec![Change::RemoveMember {
1326 role: "kv-editor".to_string(),
1327 member: external.to_string(),
1328 }];
1329
1330 let filtered =
1331 filter_external_role_changes(changes.clone(), &[role_definition(external, true)]);
1332 assert_eq!(filtered, changes);
1333 }
1334
1335 #[test]
1336 fn diff_creates_missing_schema() {
1337 let current = empty_graph();
1338 let mut desired = empty_graph();
1339 desired
1340 .schemas
1341 .insert("inventory".to_string(), managed_schema("inventory_owner"));
1342
1343 let changes = diff(¤t, &desired);
1344 assert_eq!(changes.len(), 1);
1345 assert!(matches!(
1346 &changes[0],
1347 Change::CreateSchema { name, owner }
1348 if name == "inventory" && owner.as_deref() == Some("inventory_owner")
1349 ));
1350 }
1351
1352 #[test]
1353 fn diff_alters_schema_owner_when_different() {
1354 let mut current = empty_graph();
1355 current
1356 .schemas
1357 .insert("inventory".to_string(), managed_schema("old_owner"));
1358
1359 let mut desired = empty_graph();
1360 desired
1361 .schemas
1362 .insert("inventory".to_string(), managed_schema("new_owner"));
1363
1364 let changes = diff(¤t, &desired);
1365 assert_eq!(changes.len(), 2);
1366 assert!(matches!(
1367 &changes[0],
1368 Change::AlterSchemaOwner { name, owner }
1369 if name == "inventory" && owner == "new_owner"
1370 ));
1371 assert!(matches!(
1372 &changes[1],
1373 Change::EnsureSchemaOwnerPrivileges { name, owner, privileges }
1374 if name == "inventory"
1375 && owner == "new_owner"
1376 && privileges == &BTreeSet::from([Privilege::Create, Privilege::Usage])
1377 ));
1378 }
1379
1380 #[test]
1381 fn diff_does_not_alter_schema_owner_when_unmanaged() {
1382 let mut current = empty_graph();
1383 current
1384 .schemas
1385 .insert("inventory".to_string(), managed_schema("old_owner"));
1386
1387 let mut desired = empty_graph();
1388 desired.schemas.insert(
1389 "inventory".to_string(),
1390 SchemaState {
1391 owner: None,
1392 owner_privileges: BTreeSet::new(),
1393 },
1394 );
1395
1396 let changes = diff(¤t, &desired);
1397 assert!(changes.is_empty());
1398 }
1399
1400 #[test]
1401 fn diff_restores_missing_owner_schema_privileges() {
1402 let mut current = empty_graph();
1403 current.schemas.insert(
1404 "inventory".to_string(),
1405 SchemaState {
1406 owner: Some("inventory_owner".to_string()),
1407 owner_privileges: BTreeSet::from([Privilege::Usage]),
1408 },
1409 );
1410
1411 let mut desired = empty_graph();
1412 desired
1413 .schemas
1414 .insert("inventory".to_string(), managed_schema("inventory_owner"));
1415
1416 let changes = diff(¤t, &desired);
1417 assert_eq!(changes.len(), 1);
1418 assert!(matches!(
1419 &changes[0],
1420 Change::EnsureSchemaOwnerPrivileges {
1421 name,
1422 owner,
1423 privileges,
1424 } if name == "inventory"
1425 && owner == "inventory_owner"
1426 && privileges == &BTreeSet::from([Privilege::Create])
1427 ));
1428 }
1429
1430 #[test]
1431 fn diff_restores_owner_schema_privileges_after_transfer() {
1432 let mut current = empty_graph();
1433 current.schemas.insert(
1434 "inventory".to_string(),
1435 SchemaState {
1436 owner: Some("old_owner".to_string()),
1437 owner_privileges: BTreeSet::from([Privilege::Usage]),
1438 },
1439 );
1440
1441 let mut desired = empty_graph();
1442 desired
1443 .schemas
1444 .insert("inventory".to_string(), managed_schema("new_owner"));
1445
1446 let changes = diff(¤t, &desired);
1447 assert_eq!(changes.len(), 2);
1448 assert!(matches!(
1449 &changes[0],
1450 Change::AlterSchemaOwner { name, owner }
1451 if name == "inventory" && owner == "new_owner"
1452 ));
1453 assert!(matches!(
1454 &changes[1],
1455 Change::EnsureSchemaOwnerPrivileges {
1456 name,
1457 owner,
1458 privileges,
1459 } if name == "inventory"
1460 && owner == "new_owner"
1461 && privileges == &BTreeSet::from([Privilege::Create, Privilege::Usage])
1462 ));
1463 }
1464
1465 #[test]
1466 fn diff_grants_new_privileges() {
1467 let current = empty_graph();
1468 let mut desired = empty_graph();
1469 let key = GrantKey {
1470 role: "r1".into(),
1471 object_type: ObjectType::Table,
1472 schema: Some("public".to_string()),
1473 name: Some("*".to_string()),
1474 };
1475 desired.grants.insert(
1476 key,
1477 GrantState {
1478 privileges: BTreeSet::from([Privilege::Select, Privilege::Insert]),
1479 },
1480 );
1481
1482 let changes = diff(¤t, &desired);
1483 assert_eq!(changes.len(), 1);
1484 match &changes[0] {
1485 Change::Grant {
1486 role, privileges, ..
1487 } => {
1488 assert_eq!(role.as_str(), "r1");
1489 assert!(privileges.contains(&Privilege::Select));
1490 assert!(privileges.contains(&Privilege::Insert));
1491 }
1492 other => panic!("expected Grant, got: {other:?}"),
1493 }
1494 }
1495
1496 #[test]
1497 fn diff_revokes_removed_privileges() {
1498 let mut current = empty_graph();
1499 let key = GrantKey {
1500 role: "r1".into(),
1501 object_type: ObjectType::Table,
1502 schema: Some("public".to_string()),
1503 name: Some("*".to_string()),
1504 };
1505 current.grants.insert(
1506 key.clone(),
1507 GrantState {
1508 privileges: BTreeSet::from([Privilege::Select, Privilege::Insert]),
1509 },
1510 );
1511
1512 let mut desired = empty_graph();
1513 desired.grants.insert(
1514 key,
1515 GrantState {
1516 privileges: BTreeSet::from([Privilege::Select]),
1517 },
1518 );
1519
1520 let changes = diff(¤t, &desired);
1521 assert_eq!(changes.len(), 1);
1522 match &changes[0] {
1523 Change::Revoke {
1524 role, privileges, ..
1525 } => {
1526 assert_eq!(role.as_str(), "r1");
1527 assert!(privileges.contains(&Privilege::Insert));
1528 assert!(!privileges.contains(&Privilege::Select));
1529 }
1530 other => panic!("expected Revoke, got: {other:?}"),
1531 }
1532 }
1533
1534 #[test]
1535 fn diff_revokes_entire_grant_target_when_absent_from_desired() {
1536 let mut current = empty_graph();
1537 let key = GrantKey {
1538 role: "r1".into(),
1539 object_type: ObjectType::Schema,
1540 schema: None,
1541 name: Some("myschema".to_string()),
1542 };
1543 current.grants.insert(
1544 key,
1545 GrantState {
1546 privileges: BTreeSet::from([Privilege::Usage]),
1547 },
1548 );
1549 let desired = empty_graph();
1550
1551 let changes = diff(¤t, &desired);
1552 assert_eq!(changes.len(), 1);
1553 assert!(matches!(&changes[0], Change::Revoke { role, .. } if role.as_str() == "r1"));
1554 }
1555
1556 #[test]
1557 fn diff_adds_memberships() {
1558 let current = empty_graph();
1559 let mut desired = empty_graph();
1560 desired.memberships.insert(MembershipEdge {
1561 role: "editors".to_string(),
1562 member: "user@example.com".to_string(),
1563 inherit: true,
1564 admin: false,
1565 });
1566
1567 let changes = diff(¤t, &desired);
1568 assert_eq!(changes.len(), 1);
1569 match &changes[0] {
1570 Change::AddMember {
1571 role,
1572 member,
1573 inherit,
1574 admin,
1575 } => {
1576 assert_eq!(role, "editors");
1577 assert_eq!(member, "user@example.com");
1578 assert!(*inherit);
1579 assert!(!admin);
1580 }
1581 other => panic!("expected AddMember, got: {other:?}"),
1582 }
1583 }
1584
1585 #[test]
1586 fn diff_removes_memberships() {
1587 let mut current = empty_graph();
1588 current.memberships.insert(MembershipEdge {
1589 role: "editors".to_string(),
1590 member: "old@example.com".to_string(),
1591 inherit: true,
1592 admin: false,
1593 });
1594 let desired = empty_graph();
1595
1596 let changes = diff(¤t, &desired);
1597 assert_eq!(changes.len(), 1);
1598 assert!(
1599 matches!(&changes[0], Change::RemoveMember { role, member } if role == "editors" && member == "old@example.com")
1600 );
1601 }
1602
1603 #[test]
1604 fn diff_re_grants_membership_when_flags_change() {
1605 let mut current = empty_graph();
1606 current.memberships.insert(MembershipEdge {
1607 role: "editors".to_string(),
1608 member: "user@example.com".to_string(),
1609 inherit: true,
1610 admin: false,
1611 });
1612
1613 let mut desired = empty_graph();
1614 desired.memberships.insert(MembershipEdge {
1615 role: "editors".to_string(),
1616 member: "user@example.com".to_string(),
1617 inherit: true,
1618 admin: true, });
1620
1621 let changes = diff(¤t, &desired);
1622 assert_eq!(changes.len(), 2);
1624 assert!(matches!(
1625 &changes[0],
1626 Change::RemoveMember { role, member }
1627 if role == "editors" && member == "user@example.com"
1628 ));
1629 assert!(matches!(
1630 &changes[1],
1631 Change::AddMember {
1632 role,
1633 member,
1634 admin: true,
1635 ..
1636 } if role == "editors" && member == "user@example.com"
1637 ));
1638 }
1639
1640 #[test]
1641 fn diff_default_privileges_add_and_revoke() {
1642 let mut current = empty_graph();
1643 let key = DefaultPrivKey {
1644 owner: "app_owner".to_string(),
1645 scope: DefaultPrivilegeScope::Schema {
1646 schema: "inventory".to_string(),
1647 },
1648 on_type: ObjectType::Table,
1649 grantee: "inventory-editor".into(),
1650 };
1651 current.default_privileges.insert(
1652 key.clone(),
1653 DefaultPrivState {
1654 privileges: BTreeSet::from([Privilege::Select, Privilege::Delete]),
1655 },
1656 );
1657
1658 let mut desired = empty_graph();
1659 desired.default_privileges.insert(
1660 key,
1661 DefaultPrivState {
1662 privileges: BTreeSet::from([Privilege::Select, Privilege::Insert]),
1663 },
1664 );
1665
1666 let changes = diff(¤t, &desired);
1667 assert_eq!(changes.len(), 2);
1669 assert!(changes.iter().any(|c| matches!(
1670 c,
1671 Change::SetDefaultPrivilege { privileges, .. } if privileges.contains(&Privilege::Insert)
1672 )));
1673 assert!(changes.iter().any(|c| matches!(
1674 c,
1675 Change::RevokeDefaultPrivilege { privileges, .. } if privileges.contains(&Privilege::Delete)
1676 )));
1677 }
1678
1679 #[test]
1680 fn diff_ordering_creates_before_drops() {
1681 let mut current = empty_graph();
1682 current
1683 .roles
1684 .insert("old-role".to_string(), RoleState::default());
1685
1686 let mut desired = empty_graph();
1687 desired
1688 .roles
1689 .insert("new-role".to_string(), RoleState::default());
1690
1691 let changes = diff(¤t, &desired);
1692 assert_eq!(changes.len(), 2);
1693
1694 let create_idx = changes
1696 .iter()
1697 .position(|c| matches!(c, Change::CreateRole { .. }))
1698 .unwrap();
1699 let schema_idx = changes
1700 .iter()
1701 .position(|c| matches!(c, Change::CreateSchema { .. }))
1702 .unwrap_or(create_idx);
1703 let drop_idx = changes
1704 .iter()
1705 .position(|c| matches!(c, Change::DropRole { .. }))
1706 .unwrap();
1707 assert!(create_idx <= schema_idx);
1708 assert!(schema_idx < drop_idx);
1709 }
1710
1711 #[test]
1712 fn diff_identical_graphs_produce_no_changes() {
1713 let mut graph = empty_graph();
1714 graph
1715 .roles
1716 .insert("role1".to_string(), RoleState::default());
1717 graph.grants.insert(
1718 GrantKey {
1719 role: "role1".into(),
1720 object_type: ObjectType::Table,
1721 schema: Some("public".to_string()),
1722 name: Some("*".to_string()),
1723 },
1724 GrantState {
1725 privileges: BTreeSet::from([Privilege::Select]),
1726 },
1727 );
1728 graph.memberships.insert(MembershipEdge {
1729 role: "role1".to_string(),
1730 member: "user@example.com".to_string(),
1731 inherit: true,
1732 admin: false,
1733 });
1734
1735 let changes = diff(&graph, &graph);
1736 assert!(
1737 changes.is_empty(),
1738 "identical graphs should produce no changes"
1739 );
1740 }
1741
1742 #[test]
1744 fn manifest_to_diff_integration() {
1745 use crate::manifest::{expand_manifest, parse_manifest};
1746 use crate::model::RoleGraph;
1747
1748 let yaml = r#"
1749default_owner: app_owner
1750
1751profiles:
1752 editor:
1753 grants:
1754 - privileges: [USAGE]
1755 object: { type: schema }
1756 - privileges: [SELECT, INSERT, UPDATE, DELETE]
1757 object: { type: table, name: "*" }
1758 default_privileges:
1759 - privileges: [SELECT, INSERT, UPDATE, DELETE]
1760 on_type: table
1761
1762schemas:
1763 - name: inventory
1764 owner: inventory_owner
1765 profiles: [editor]
1766
1767memberships:
1768 - role: inventory-editor
1769 members:
1770 - name: "user@example.com"
1771"#;
1772 let manifest = parse_manifest(yaml).unwrap();
1773 let expanded = expand_manifest(&manifest).unwrap();
1774 let desired =
1775 RoleGraph::from_expanded(&expanded, manifest.default_owner.as_deref()).unwrap();
1776
1777 let current = RoleGraph::default();
1779 let changes = diff(¤t, &desired);
1780
1781 let create_count = changes
1783 .iter()
1784 .filter(|c| matches!(c, Change::CreateRole { .. }))
1785 .count();
1786 let create_schema_count = changes
1787 .iter()
1788 .filter(|c| matches!(c, Change::CreateSchema { .. }))
1789 .count();
1790 let grant_count = changes
1791 .iter()
1792 .filter(|c| matches!(c, Change::Grant { .. }))
1793 .count();
1794 let dp_count = changes
1795 .iter()
1796 .filter(|c| matches!(c, Change::SetDefaultPrivilege { .. }))
1797 .count();
1798 let member_count = changes
1799 .iter()
1800 .filter(|c| matches!(c, Change::AddMember { .. }))
1801 .count();
1802
1803 assert_eq!(create_count, 1);
1804 assert_eq!(create_schema_count, 1);
1805 assert_eq!(grant_count, 2); assert_eq!(dp_count, 1);
1807 assert_eq!(member_count, 1);
1808
1809 let no_changes = diff(&desired, &desired);
1811 assert!(no_changes.is_empty());
1812 }
1813
1814 fn all_change_variants() -> Vec<Change> {
1820 vec![
1821 Change::CreateRole {
1822 name: "new-role".to_string(),
1823 state: RoleState::default(),
1824 },
1825 Change::CreateSchema {
1826 name: "inventory".to_string(),
1827 owner: Some("inventory_owner".to_string()),
1828 },
1829 Change::AlterSchemaOwner {
1830 name: "catalog".to_string(),
1831 owner: "catalog_owner".to_string(),
1832 },
1833 Change::EnsureSchemaOwnerPrivileges {
1834 name: "catalog".to_string(),
1835 owner: "catalog_owner".to_string(),
1836 privileges: BTreeSet::from([Privilege::Create, Privilege::Usage]),
1837 },
1838 Change::AlterRole {
1839 name: "altered-role".to_string(),
1840 attributes: vec![RoleAttribute::Login(true)],
1841 },
1842 Change::SetComment {
1843 name: "commented-role".to_string(),
1844 comment: Some("hello".to_string()),
1845 },
1846 Change::Grant {
1847 role: "r1".into(),
1848 privileges: BTreeSet::from([Privilege::Select]),
1849 object_type: ObjectType::Table,
1850 schema: Some("public".to_string()),
1851 name: Some("*".to_string()),
1852 },
1853 Change::Revoke {
1854 role: "r1".into(),
1855 privileges: BTreeSet::from([Privilege::Insert]),
1856 object_type: ObjectType::Table,
1857 schema: Some("public".to_string()),
1858 name: Some("*".to_string()),
1859 },
1860 Change::SetDefaultPrivilege {
1861 owner: "owner".to_string(),
1862 scope: DefaultPrivilegeScope::Schema {
1863 schema: "public".to_string(),
1864 },
1865 on_type: ObjectType::Table,
1866 grantee: "r1".into(),
1867 privileges: BTreeSet::from([Privilege::Select]),
1868 },
1869 Change::RevokeDefaultPrivilege {
1870 owner: "owner".to_string(),
1871 scope: DefaultPrivilegeScope::Schema {
1872 schema: "public".to_string(),
1873 },
1874 on_type: ObjectType::Table,
1875 grantee: "r1".into(),
1876 privileges: BTreeSet::from([Privilege::Delete]),
1877 },
1878 Change::AddMember {
1879 role: "editors".to_string(),
1880 member: "user@example.com".to_string(),
1881 inherit: true,
1882 admin: false,
1883 },
1884 Change::RemoveMember {
1885 role: "editors".to_string(),
1886 member: "old@example.com".to_string(),
1887 },
1888 Change::TerminateSessions {
1889 role: "retired-role".to_string(),
1890 },
1891 Change::ReassignOwned {
1892 from_role: "retired-role".to_string(),
1893 to_role: "successor".to_string(),
1894 },
1895 Change::DropOwned {
1896 role: "retired-role".to_string(),
1897 },
1898 Change::DropRole {
1899 name: "retired-role".to_string(),
1900 },
1901 ]
1902 }
1903
1904 #[test]
1905 fn filter_authoritative_keeps_all_changes() {
1906 let changes = all_change_variants();
1907 let original_len = changes.len();
1908 let filtered = filter_changes(changes, ReconciliationMode::Authoritative);
1909 assert_eq!(filtered.len(), original_len);
1910 }
1911
1912 #[test]
1913 fn filter_additive_keeps_only_constructive_changes() {
1914 let filtered = filter_changes(all_change_variants(), ReconciliationMode::Additive);
1915
1916 assert_eq!(filtered.len(), 5);
1918
1919 for change in &filtered {
1921 assert!(
1922 !matches!(
1923 change,
1924 Change::AlterSchemaOwner { .. }
1925 | Change::EnsureSchemaOwnerPrivileges { .. }
1926 | Change::AlterRole { .. }
1927 | Change::SetComment { .. }
1928 | Change::Revoke { .. }
1929 | Change::RevokeDefaultPrivilege { .. }
1930 | Change::RemoveMember { .. }
1931 | Change::DropRole { .. }
1932 | Change::DropOwned { .. }
1933 | Change::ReassignOwned { .. }
1934 | Change::TerminateSessions { .. }
1935 ),
1936 "additive mode should not contain destructive change: {change:?}"
1937 );
1938 }
1939
1940 assert!(
1942 filtered
1943 .iter()
1944 .any(|c| matches!(c, Change::CreateRole { .. }))
1945 );
1946 assert!(
1947 filtered
1948 .iter()
1949 .any(|c| matches!(c, Change::CreateSchema { .. }))
1950 );
1951 assert!(
1952 filtered
1953 .iter()
1954 .all(|c| !matches!(c, Change::AlterRole { .. } | Change::SetComment { .. }))
1955 );
1956 assert!(filtered.iter().any(|c| matches!(c, Change::Grant { .. })));
1957 assert!(
1958 filtered
1959 .iter()
1960 .any(|c| matches!(c, Change::SetDefaultPrivilege { .. }))
1961 );
1962 assert!(
1963 filtered
1964 .iter()
1965 .any(|c| matches!(c, Change::AddMember { .. }))
1966 );
1967 }
1968
1969 #[test]
1970 fn filter_additive_keeps_config_alters_for_roles_created_in_same_plan() {
1971 let changes = vec![
1976 Change::CreateRole {
1977 name: "blue".to_string(),
1978 state: RoleState::default(),
1979 },
1980 Change::AlterRole {
1981 name: "blue".to_string(),
1982 attributes: vec![RoleAttribute::SetConfig(
1983 "role".to_string(),
1984 "combined".to_string(),
1985 )],
1986 },
1987 ];
1988
1989 let filtered = filter_changes(changes, ReconciliationMode::Additive);
1990 assert_eq!(filtered.len(), 2);
1991 assert!(matches!(&filtered[1], Change::AlterRole { name, .. } if name == "blue"));
1992 }
1993
1994 #[test]
1995 fn filter_additive_drops_config_alters_for_pre_existing_roles() {
1996 let changes = vec![Change::AlterRole {
1999 name: "blue".to_string(),
2000 attributes: vec![
2001 RoleAttribute::SetConfig("role".to_string(), "combined".to_string()),
2002 RoleAttribute::ResetConfig("statement_timeout".to_string()),
2003 ],
2004 }];
2005
2006 let filtered = filter_changes(changes, ReconciliationMode::Additive);
2007 assert!(filtered.is_empty());
2008 }
2009
2010 #[test]
2011 fn filter_additive_drops_mixed_attribute_and_config_alters_even_for_created_roles() {
2012 let changes = vec![
2016 Change::CreateRole {
2017 name: "blue".to_string(),
2018 state: RoleState::default(),
2019 },
2020 Change::AlterRole {
2021 name: "blue".to_string(),
2022 attributes: vec![
2023 RoleAttribute::Login(true),
2024 RoleAttribute::SetConfig("role".to_string(), "combined".to_string()),
2025 ],
2026 },
2027 ];
2028
2029 let filtered = filter_changes(changes, ReconciliationMode::Additive);
2030 assert_eq!(filtered.len(), 1);
2031 assert!(matches!(&filtered[0], Change::CreateRole { .. }));
2032 }
2033
2034 #[test]
2035 fn filter_additive_skips_owner_bound_follow_ups_when_transfer_is_skipped() {
2036 let changes = vec![
2037 Change::AlterSchemaOwner {
2038 name: "inventory".to_string(),
2039 owner: "new_owner".to_string(),
2040 },
2041 Change::EnsureSchemaOwnerPrivileges {
2042 name: "inventory".to_string(),
2043 owner: "new_owner".to_string(),
2044 privileges: BTreeSet::from([Privilege::Create, Privilege::Usage]),
2045 },
2046 Change::SetDefaultPrivilege {
2047 owner: "new_owner".to_string(),
2048 scope: DefaultPrivilegeScope::Schema {
2049 schema: "inventory".to_string(),
2050 },
2051 on_type: ObjectType::Table,
2052 grantee: "inventory-editor".into(),
2053 privileges: BTreeSet::from([Privilege::Select]),
2054 },
2055 Change::Grant {
2056 role: "inventory-editor".into(),
2057 privileges: BTreeSet::from([Privilege::Usage]),
2058 object_type: ObjectType::Schema,
2059 schema: None,
2060 name: Some("inventory".to_string()),
2061 },
2062 ];
2063
2064 let filtered = filter_changes(changes, ReconciliationMode::Additive);
2065 assert_eq!(filtered.len(), 1);
2066 assert!(
2067 matches!(&filtered[0], Change::Grant { role, .. } if role.as_str() == "inventory-editor")
2068 );
2069 }
2070
2071 #[test]
2072 fn filter_adopt_keeps_revokes_but_not_drops() {
2073 let filtered = filter_changes(all_change_variants(), ReconciliationMode::Adopt);
2074
2075 assert_eq!(filtered.len(), 12);
2077
2078 for change in &filtered {
2080 assert!(
2081 !matches!(
2082 change,
2083 Change::DropRole { .. }
2084 | Change::DropOwned { .. }
2085 | Change::ReassignOwned { .. }
2086 | Change::TerminateSessions { .. }
2087 ),
2088 "adopt mode should not contain drop/retirement change: {change:?}"
2089 );
2090 }
2091
2092 assert!(filtered.iter().any(|c| matches!(c, Change::Revoke { .. })));
2094 assert!(
2095 filtered
2096 .iter()
2097 .any(|c| matches!(c, Change::RevokeDefaultPrivilege { .. }))
2098 );
2099 assert!(
2100 filtered
2101 .iter()
2102 .any(|c| matches!(c, Change::RemoveMember { .. }))
2103 );
2104 }
2105
2106 #[test]
2107 fn filter_additive_with_empty_input() {
2108 let filtered = filter_changes(vec![], ReconciliationMode::Additive);
2109 assert!(filtered.is_empty());
2110 }
2111
2112 #[test]
2113 fn filter_additive_only_destructive_changes_yields_empty() {
2114 let changes = vec![
2115 Change::Revoke {
2116 role: "r1".into(),
2117 privileges: BTreeSet::from([Privilege::Select]),
2118 object_type: ObjectType::Table,
2119 schema: Some("public".to_string()),
2120 name: Some("*".to_string()),
2121 },
2122 Change::DropRole {
2123 name: "old-role".to_string(),
2124 },
2125 ];
2126 let filtered = filter_changes(changes, ReconciliationMode::Additive);
2127 assert!(filtered.is_empty());
2128 }
2129
2130 #[test]
2131 fn filter_adopt_preserves_ordering() {
2132 let changes = vec![
2133 Change::CreateRole {
2134 name: "new-role".to_string(),
2135 state: RoleState::default(),
2136 },
2137 Change::Grant {
2138 role: "new-role".into(),
2139 privileges: BTreeSet::from([Privilege::Select]),
2140 object_type: ObjectType::Table,
2141 schema: Some("public".to_string()),
2142 name: Some("*".to_string()),
2143 },
2144 Change::Revoke {
2145 role: "existing-role".into(),
2146 privileges: BTreeSet::from([Privilege::Insert]),
2147 object_type: ObjectType::Table,
2148 schema: Some("public".to_string()),
2149 name: Some("*".to_string()),
2150 },
2151 Change::DropRole {
2152 name: "old-role".to_string(),
2153 },
2154 ];
2155
2156 let filtered = filter_changes(changes, ReconciliationMode::Adopt);
2157 assert_eq!(filtered.len(), 3);
2158 assert!(matches!(&filtered[0], Change::CreateRole { name, .. } if name == "new-role"));
2159 assert!(matches!(&filtered[1], Change::Grant { .. }));
2160 assert!(matches!(&filtered[2], Change::Revoke { .. }));
2161 }
2162
2163 #[test]
2164 fn reconciliation_mode_display() {
2165 assert_eq!(
2166 ReconciliationMode::Authoritative.to_string(),
2167 "authoritative"
2168 );
2169 assert_eq!(ReconciliationMode::Additive.to_string(), "additive");
2170 assert_eq!(ReconciliationMode::Adopt.to_string(), "adopt");
2171 }
2172
2173 #[test]
2174 fn reconciliation_mode_default_is_authoritative() {
2175 assert_eq!(
2176 ReconciliationMode::default(),
2177 ReconciliationMode::Authoritative
2178 );
2179 }
2180
2181 #[test]
2186 fn apply_role_retirements_inserts_cleanup_before_drop() {
2187 let changes = vec![
2188 Change::Grant {
2189 role: "analytics".into(),
2190 privileges: BTreeSet::from([Privilege::Select]),
2191 object_type: ObjectType::Table,
2192 schema: Some("public".to_string()),
2193 name: Some("*".to_string()),
2194 },
2195 Change::DropRole {
2196 name: "old-app".to_string(),
2197 },
2198 ];
2199
2200 let planned = apply_role_retirements(
2201 changes,
2202 &[crate::manifest::RoleRetirement {
2203 role: "old-app".to_string(),
2204 reassign_owned_to: Some("successor".to_string()),
2205 drop_owned: true,
2206 terminate_sessions: true,
2207 }],
2208 );
2209
2210 assert!(matches!(planned[0], Change::Grant { .. }));
2211 assert!(matches!(
2212 planned[1],
2213 Change::TerminateSessions { ref role } if role == "old-app"
2214 ));
2215 assert!(matches!(
2216 planned[2],
2217 Change::ReassignOwned {
2218 ref from_role,
2219 ref to_role
2220 } if from_role == "old-app" && to_role == "successor"
2221 ));
2222 assert!(matches!(
2223 planned[3],
2224 Change::DropOwned { ref role } if role == "old-app"
2225 ));
2226 assert!(matches!(
2227 planned[4],
2228 Change::DropRole { ref name } if name == "old-app"
2229 ));
2230 }
2231
2232 #[test]
2233 fn inject_password_for_new_role() {
2234 let changes = vec![Change::CreateRole {
2235 name: "app-svc".to_string(),
2236 state: RoleState::default(),
2237 }];
2238
2239 let mut passwords = std::collections::BTreeMap::new();
2240 passwords.insert("app-svc".to_string(), "secret123".to_string());
2241
2242 let result = inject_password_changes(changes, &passwords);
2243 assert_eq!(result.len(), 2);
2244 assert!(matches!(&result[0], Change::CreateRole { name, .. } if name == "app-svc"));
2245 assert!(
2246 matches!(&result[1], Change::SetPassword { name, password } if name == "app-svc" && password.starts_with("SCRAM-SHA-256$"))
2247 );
2248 }
2249
2250 #[test]
2251 fn inject_password_for_existing_role() {
2252 let changes = vec![Change::Grant {
2254 role: "app-svc".into(),
2255 privileges: BTreeSet::from([crate::manifest::Privilege::Select]),
2256 object_type: crate::manifest::ObjectType::Table,
2257 schema: Some("public".to_string()),
2258 name: Some("*".to_string()),
2259 }];
2260
2261 let mut passwords = std::collections::BTreeMap::new();
2262 passwords.insert("app-svc".to_string(), "secret123".to_string());
2263
2264 let result = inject_password_changes(changes, &passwords);
2265 assert_eq!(result.len(), 2);
2266 assert!(matches!(&result[0], Change::Grant { .. }));
2267 assert!(
2268 matches!(&result[1], Change::SetPassword { name, password } if name == "app-svc" && password.starts_with("SCRAM-SHA-256$"))
2269 );
2270 }
2271
2272 #[test]
2273 fn inject_password_empty_passwords_is_noop() {
2274 let changes = vec![Change::CreateRole {
2275 name: "app-svc".to_string(),
2276 state: RoleState::default(),
2277 }];
2278
2279 let passwords = std::collections::BTreeMap::new();
2280 let result = inject_password_changes(changes.clone(), &passwords);
2281 assert_eq!(result.len(), 1);
2282 }
2283
2284 #[test]
2285 fn resolve_passwords_missing_env_var() {
2286 let roles = vec![crate::manifest::RoleDefinition {
2287 name: "app-svc".to_string(),
2288 external: false,
2289 login: Some(true),
2290 password: Some(crate::manifest::PasswordSource {
2291 from_env: "PGROLES_TEST_MISSING_VAR_9a8b7c6d".to_string(),
2292 }),
2293 password_valid_until: None,
2294 config: Default::default(),
2295 superuser: None,
2296 createdb: None,
2297 createrole: None,
2298 inherit: None,
2299 replication: None,
2300 bypassrls: None,
2301 connection_limit: None,
2302 comment: None,
2303 }];
2304
2305 unsafe { std::env::remove_var("PGROLES_TEST_MISSING_VAR_9a8b7c6d") };
2308
2309 let result = resolve_passwords(&roles);
2310 assert!(result.is_err());
2311 let err = result.unwrap_err();
2312 assert!(
2313 matches!(err, PasswordResolutionError::MissingEnvVar { ref role, ref env_var }
2314 if role == "app-svc" && env_var == "PGROLES_TEST_MISSING_VAR_9a8b7c6d"),
2315 "expected MissingEnvVar, got: {err:?}"
2316 );
2317 }
2318
2319 #[test]
2320 fn resolve_passwords_empty_env_var() {
2321 let roles = vec![crate::manifest::RoleDefinition {
2322 name: "app-svc".to_string(),
2323 external: false,
2324 login: Some(true),
2325 password: Some(crate::manifest::PasswordSource {
2326 from_env: "PGROLES_TEST_EMPTY_VAR_1a2b3c4d".to_string(),
2327 }),
2328 password_valid_until: None,
2329 config: Default::default(),
2330 superuser: None,
2331 createdb: None,
2332 createrole: None,
2333 inherit: None,
2334 replication: None,
2335 bypassrls: None,
2336 connection_limit: None,
2337 comment: None,
2338 }];
2339
2340 unsafe { std::env::set_var("PGROLES_TEST_EMPTY_VAR_1a2b3c4d", "") };
2343
2344 let result = resolve_passwords(&roles);
2345
2346 unsafe { std::env::remove_var("PGROLES_TEST_EMPTY_VAR_1a2b3c4d") };
2348
2349 assert!(result.is_err());
2350 let err = result.unwrap_err();
2351 assert!(
2352 matches!(err, PasswordResolutionError::EmptyPassword { ref role, ref env_var }
2353 if role == "app-svc" && env_var == "PGROLES_TEST_EMPTY_VAR_1a2b3c4d"),
2354 "expected EmptyPassword, got: {err:?}"
2355 );
2356 }
2357
2358 #[test]
2359 fn resolve_passwords_happy_path() {
2360 let roles = vec![crate::manifest::RoleDefinition {
2361 name: "app-svc".to_string(),
2362 external: false,
2363 login: Some(true),
2364 password: Some(crate::manifest::PasswordSource {
2365 from_env: "PGROLES_TEST_RESOLVE_VAR_5e6f7g8h".to_string(),
2366 }),
2367 password_valid_until: None,
2368 config: Default::default(),
2369 superuser: None,
2370 createdb: None,
2371 createrole: None,
2372 inherit: None,
2373 replication: None,
2374 bypassrls: None,
2375 connection_limit: None,
2376 comment: None,
2377 }];
2378
2379 unsafe { std::env::set_var("PGROLES_TEST_RESOLVE_VAR_5e6f7g8h", "my_secret_pw") };
2381
2382 let result = resolve_passwords(&roles);
2383
2384 unsafe { std::env::remove_var("PGROLES_TEST_RESOLVE_VAR_5e6f7g8h") };
2385
2386 let resolved = result.expect("should succeed");
2387 assert_eq!(resolved.len(), 1);
2388 assert_eq!(resolved["app-svc"], "my_secret_pw");
2389 }
2390
2391 #[test]
2392 fn resolve_passwords_skips_external_roles() {
2393 let roles = vec![crate::manifest::RoleDefinition {
2394 name: "external-svc".to_string(),
2395 external: true,
2396 login: Some(true),
2397 password: Some(crate::manifest::PasswordSource {
2398 from_env: "PGROLES_TEST_EXTERNAL_MISSING_VAR_2b4d6f8h".to_string(),
2399 }),
2400 password_valid_until: None,
2401 config: Default::default(),
2402 superuser: None,
2403 createdb: None,
2404 createrole: None,
2405 inherit: None,
2406 replication: None,
2407 bypassrls: None,
2408 connection_limit: None,
2409 comment: None,
2410 }];
2411
2412 unsafe { std::env::remove_var("PGROLES_TEST_EXTERNAL_MISSING_VAR_2b4d6f8h") };
2414
2415 let resolved = resolve_passwords(&roles).expect("external role passwords are ignored");
2416 assert!(resolved.is_empty());
2417 }
2418
2419 #[test]
2420 fn resolve_passwords_skips_roles_without_password() {
2421 let roles = vec![crate::manifest::RoleDefinition {
2422 name: "no-password".to_string(),
2423 external: false,
2424 login: Some(true),
2425 password: None,
2426 password_valid_until: None,
2427 config: Default::default(),
2428 superuser: None,
2429 createdb: None,
2430 createrole: None,
2431 inherit: None,
2432 replication: None,
2433 bypassrls: None,
2434 connection_limit: None,
2435 comment: None,
2436 }];
2437
2438 let result = resolve_passwords(&roles);
2439 let resolved = result.expect("should succeed");
2440 assert!(resolved.is_empty());
2441 }
2442
2443 #[test]
2444 fn inject_password_multiple_roles() {
2445 let changes = vec![
2446 Change::CreateRole {
2447 name: "role-a".to_string(),
2448 state: RoleState::default(),
2449 },
2450 Change::CreateRole {
2451 name: "role-b".to_string(),
2452 state: RoleState::default(),
2453 },
2454 Change::Grant {
2455 role: "role-c".into(),
2456 privileges: BTreeSet::from([crate::manifest::Privilege::Select]),
2457 object_type: crate::manifest::ObjectType::Table,
2458 schema: Some("public".to_string()),
2459 name: Some("*".to_string()),
2460 },
2461 ];
2462
2463 let mut passwords = std::collections::BTreeMap::new();
2464 passwords.insert("role-a".to_string(), "pw-a".to_string());
2465 passwords.insert("role-b".to_string(), "pw-b".to_string());
2466 passwords.insert("role-c".to_string(), "pw-c".to_string());
2467
2468 let result = inject_password_changes(changes, &passwords);
2469
2470 assert_eq!(result.len(), 6, "expected 6 changes, got: {result:?}");
2474 assert!(matches!(&result[0], Change::CreateRole { name, .. } if name == "role-a"));
2475 assert!(matches!(&result[1], Change::SetPassword { name, .. } if name == "role-a"));
2476 assert!(matches!(&result[2], Change::CreateRole { name, .. } if name == "role-b"));
2477 assert!(matches!(&result[3], Change::SetPassword { name, .. } if name == "role-b"));
2478 assert!(matches!(&result[4], Change::Grant { .. }));
2479 assert!(matches!(&result[5], Change::SetPassword { name, .. } if name == "role-c"));
2480 }
2481
2482 #[test]
2483 fn diff_detects_valid_until_change() {
2484 let mut current = empty_graph();
2485 current.roles.insert(
2486 "r1".to_string(),
2487 RoleState {
2488 login: true,
2489 ..RoleState::default()
2490 },
2491 );
2492
2493 let mut desired = empty_graph();
2494 desired.roles.insert(
2495 "r1".to_string(),
2496 RoleState {
2497 login: true,
2498 password_valid_until: Some("2025-12-31T00:00:00Z".to_string()),
2499 config: Default::default(),
2500 ..RoleState::default()
2501 },
2502 );
2503
2504 let changes = diff(¤t, &desired);
2505 assert_eq!(changes.len(), 1);
2506 match &changes[0] {
2507 Change::AlterRole { name, attributes } => {
2508 assert_eq!(name, "r1");
2509 assert!(attributes.contains(&RoleAttribute::ValidUntil(Some(
2510 "2025-12-31T00:00:00Z".to_string()
2511 ))));
2512 }
2513 other => panic!("expected AlterRole, got: {other:?}"),
2514 }
2515 }
2516
2517 #[test]
2528 fn diff_does_not_revoke_per_name_grants_covered_by_desired_wildcard() {
2529 let role: Grantee = "cdc-editor".into();
2530 let schema = "cdc".to_string();
2531 let object_type = ObjectType::Function;
2532
2533 let mut current = empty_graph();
2538 for fn_name in ["f1()", "f3()"] {
2539 current.grants.insert(
2540 GrantKey {
2541 role: role.clone(),
2542 object_type,
2543 schema: Some(schema.clone()),
2544 name: Some(fn_name.to_string()),
2545 },
2546 GrantState {
2547 privileges: BTreeSet::from([Privilege::Execute]),
2548 },
2549 );
2550 }
2551
2552 let mut desired = empty_graph();
2555 desired.grants.insert(
2556 GrantKey {
2557 role: role.clone(),
2558 object_type,
2559 schema: Some(schema.clone()),
2560 name: Some("*".to_string()),
2561 },
2562 GrantState {
2563 privileges: BTreeSet::from([Privilege::Execute]),
2564 },
2565 );
2566
2567 let changes = diff(¤t, &desired);
2568
2569 let revokes: Vec<_> = changes
2570 .iter()
2571 .filter(|c| matches!(c, Change::Revoke { .. }))
2572 .collect();
2573 assert!(
2574 revokes.is_empty(),
2575 "must not revoke per-name grants covered by desired wildcard \
2576 (would cause apply-order flap); got: {revokes:#?}"
2577 );
2578
2579 let grants: Vec<_> = changes
2580 .iter()
2581 .filter(|c| matches!(c, Change::Grant { .. }))
2582 .collect();
2583 assert_eq!(
2584 grants.len(),
2585 1,
2586 "expected a single wildcard GRANT to materialise ACLs on all functions; got: {grants:#?}"
2587 );
2588 match grants[0] {
2589 Change::Grant {
2590 role: r,
2591 name,
2592 privileges,
2593 ..
2594 } => {
2595 assert_eq!(r, &role);
2596 assert_eq!(name.as_deref(), Some("*"));
2597 assert!(privileges.contains(&Privilege::Execute));
2598 }
2599 other => panic!("expected wildcard Grant, got: {other:?}"),
2600 }
2601 }
2602
2603 #[test]
2615 fn diff_does_not_revoke_extra_privileges_covered_by_desired_wildcard() {
2616 let role: Grantee = "viewer".into();
2617 let schema = "myschema".to_string();
2618 let object_type = ObjectType::Table;
2619
2620 let mut current = empty_graph();
2623 current.grants.insert(
2624 GrantKey {
2625 role: role.clone(),
2626 object_type,
2627 schema: Some(schema.clone()),
2628 name: Some("widgets".to_string()),
2629 },
2630 GrantState {
2631 privileges: BTreeSet::from([Privilege::Select, Privilege::Insert]),
2632 },
2633 );
2634
2635 let mut desired = empty_graph();
2637 desired.grants.insert(
2638 GrantKey {
2639 role: role.clone(),
2640 object_type,
2641 schema: Some(schema.clone()),
2642 name: Some("*".to_string()),
2643 },
2644 GrantState {
2645 privileges: BTreeSet::from([Privilege::Select]),
2646 },
2647 );
2648 desired.grants.insert(
2649 GrantKey {
2650 role: role.clone(),
2651 object_type,
2652 schema: Some(schema.clone()),
2653 name: Some("widgets".to_string()),
2654 },
2655 GrantState {
2656 privileges: BTreeSet::from([Privilege::Insert]),
2657 },
2658 );
2659
2660 let changes = diff(¤t, &desired);
2661
2662 let revokes: Vec<_> = changes
2663 .iter()
2664 .filter(|c| matches!(c, Change::Revoke { .. }))
2665 .collect();
2666 assert!(
2667 revokes.is_empty(),
2668 "must not revoke widgets SELECT — covered by desired wildcard; got: {revokes:#?}"
2669 );
2670
2671 let grants: Vec<_> = changes
2674 .iter()
2675 .filter(|c| matches!(c, Change::Grant { .. }))
2676 .collect();
2677 let has_wildcard_select_grant = grants.iter().any(|c| {
2678 matches!(
2679 c,
2680 Change::Grant {
2681 name,
2682 privileges,
2683 ..
2684 } if name.as_deref() == Some("*")
2685 && privileges.contains(&Privilege::Select)
2686 )
2687 });
2688 assert!(
2689 has_wildcard_select_grant,
2690 "expected wildcard GRANT for SELECT; got: {grants:#?}"
2691 );
2692 }
2693
2694 #[test]
2695 fn diff_detects_valid_until_removal() {
2696 let mut current = empty_graph();
2697 current.roles.insert(
2698 "r1".to_string(),
2699 RoleState {
2700 login: true,
2701 password_valid_until: Some("2025-12-31T00:00:00Z".to_string()),
2702 config: Default::default(),
2703 ..RoleState::default()
2704 },
2705 );
2706
2707 let mut desired = empty_graph();
2708 desired.roles.insert(
2709 "r1".to_string(),
2710 RoleState {
2711 login: true,
2712 ..RoleState::default()
2713 },
2714 );
2715
2716 let changes = diff(¤t, &desired);
2717 assert_eq!(changes.len(), 1);
2718 match &changes[0] {
2719 Change::AlterRole { name, attributes } => {
2720 assert_eq!(name, "r1");
2721 assert!(attributes.contains(&RoleAttribute::ValidUntil(None)));
2722 }
2723 other => panic!("expected AlterRole, got: {other:?}"),
2724 }
2725 }
2726
2727 fn public_function_key(schema: &str, name: &str) -> GrantKey {
2732 GrantKey {
2733 role: Grantee::Public,
2734 object_type: ObjectType::Function,
2735 schema: Some(schema.to_string()),
2736 name: Some(name.to_string()),
2737 }
2738 }
2739
2740 fn graph_with_public_grant(schema: &str, name: &str, privileges: &[Privilege]) -> RoleGraph {
2741 let mut graph = RoleGraph::default();
2742 graph.grants.insert(
2743 public_function_key(schema, name),
2744 GrantState {
2745 privileges: privileges.iter().copied().collect(),
2746 },
2747 );
2748 graph
2749 }
2750
2751 #[test]
2752 fn absence_revokes_only_the_privileges_actually_held() {
2753 let current = graph_with_public_grant("api", "f()", &[Privilege::Execute]);
2754 let mut desired = RoleGraph::default();
2755 desired.grant_absences.insert(
2756 public_function_key("api", "f()"),
2757 [Privilege::Execute, Privilege::Usage].into_iter().collect(),
2758 );
2759
2760 let changes = diff(¤t, &desired);
2761 assert_eq!(
2762 changes,
2763 vec![Change::Revoke {
2764 role: Grantee::Public,
2765 privileges: [Privilege::Execute].into_iter().collect(),
2766 object_type: ObjectType::Function,
2767 schema: Some("api".to_string()),
2768 name: Some("f()".to_string()),
2769 }]
2770 );
2771 }
2772
2773 #[test]
2774 fn absence_of_a_privilege_that_is_not_held_plans_nothing() {
2775 let current = graph_with_public_grant("api", "f()", &[Privilege::Execute]);
2776 let mut desired = RoleGraph::default();
2777 desired.grant_absences.insert(
2778 public_function_key("api", "f()"),
2779 [Privilege::Usage].into_iter().collect(),
2780 );
2781
2782 assert!(diff(¤t, &desired).is_empty());
2783 }
2784
2785 #[test]
2786 fn wildcard_absence_emits_one_revoke_covering_every_matching_object() {
2787 let mut current = RoleGraph::default();
2788 for name in ["f(integer)", "f(text)", "g()"] {
2789 current.grants.insert(
2790 public_function_key("api", name),
2791 GrantState {
2792 privileges: [Privilege::Execute].into_iter().collect(),
2793 },
2794 );
2795 }
2796
2797 let mut desired = RoleGraph::default();
2798 desired.grant_absences.insert(
2799 GrantKey {
2800 role: Grantee::Public,
2801 object_type: ObjectType::Function,
2802 schema: Some("api".to_string()),
2803 name: Some("*".to_string()),
2804 },
2805 [Privilege::Execute].into_iter().collect(),
2806 );
2807
2808 let changes = diff(¤t, &desired);
2809 assert_eq!(
2810 changes,
2811 vec![Change::Revoke {
2812 role: Grantee::Public,
2813 privileges: [Privilege::Execute].into_iter().collect(),
2814 object_type: ObjectType::Function,
2815 schema: Some("api".to_string()),
2816 name: Some("*".to_string()),
2817 }]
2818 );
2819 }
2820
2821 #[test]
2822 fn wildcard_absence_over_an_empty_scope_is_vacuously_converged() {
2823 let mut desired = RoleGraph::default();
2824 desired.grant_absences.insert(
2825 GrantKey {
2826 role: Grantee::Public,
2827 object_type: ObjectType::Function,
2828 schema: Some("api".to_string()),
2829 name: Some("*".to_string()),
2830 },
2831 [Privilege::Execute].into_iter().collect(),
2832 );
2833
2834 assert!(diff(&RoleGraph::default(), &desired).is_empty());
2835 }
2836
2837 #[test]
2838 fn unmentioned_public_grants_are_never_revoked() {
2839 let current = graph_with_public_grant("api", "f()", &[Privilege::Execute]);
2842 assert!(diff(¤t, &RoleGraph::default()).is_empty());
2843
2844 let mut desired = RoleGraph::default();
2846 desired.grants.insert(
2847 public_function_key("api", "f()"),
2848 GrantState {
2849 privileges: [Privilege::Usage].into_iter().collect(),
2850 },
2851 );
2852 let changes = diff(¤t, &desired);
2853 assert!(
2854 changes
2855 .iter()
2856 .all(|change| !matches!(change, Change::Revoke { .. })),
2857 "unexpected revoke in {changes:?}"
2858 );
2859 }
2860
2861 #[test]
2862 fn a_key_hit_by_convergence_and_absence_emits_one_merged_revoke() {
2863 let key = GrantKey {
2864 role: "reader".into(),
2865 object_type: ObjectType::Table,
2866 schema: Some("app".to_string()),
2867 name: Some("t".to_string()),
2868 };
2869 let mut current = RoleGraph::default();
2870 current.grants.insert(
2871 key.clone(),
2872 GrantState {
2873 privileges: [Privilege::Select, Privilege::Insert, Privilege::Delete]
2874 .into_iter()
2875 .collect(),
2876 },
2877 );
2878
2879 let mut desired = RoleGraph::default();
2880 desired.grants.insert(
2881 key.clone(),
2882 GrantState {
2883 privileges: [Privilege::Select].into_iter().collect(),
2884 },
2885 );
2886 desired
2887 .grant_absences
2888 .insert(key, [Privilege::Delete].into_iter().collect());
2889
2890 let changes = diff(¤t, &desired);
2891 let revokes: Vec<&Change> = changes
2892 .iter()
2893 .filter(|change| matches!(change, Change::Revoke { .. }))
2894 .collect();
2895 assert_eq!(revokes.len(), 1, "expected one merged revoke: {revokes:?}");
2896 let Change::Revoke { privileges, .. } = revokes[0] else {
2897 unreachable!()
2898 };
2899 assert_eq!(
2900 *privileges,
2901 [Privilege::Insert, Privilege::Delete]
2902 .into_iter()
2903 .collect::<BTreeSet<_>>()
2904 );
2905 }
2906
2907 #[test]
2908 fn default_privilege_absence_revokes_in_both_scopes() {
2909 let global_key = DefaultPrivKey {
2910 owner: "owner".to_string(),
2911 scope: DefaultPrivilegeScope::Global,
2912 on_type: ObjectType::Function,
2913 grantee: Grantee::Public,
2914 };
2915 let schema_key = DefaultPrivKey {
2916 owner: "owner".to_string(),
2917 scope: DefaultPrivilegeScope::Schema {
2918 schema: "api".to_string(),
2919 },
2920 on_type: ObjectType::Function,
2921 grantee: Grantee::Public,
2922 };
2923
2924 let mut current = RoleGraph::default();
2925 for key in [&global_key, &schema_key] {
2926 current.default_privileges.insert(
2927 key.clone(),
2928 DefaultPrivState {
2929 privileges: [Privilege::Execute].into_iter().collect(),
2930 },
2931 );
2932 }
2933
2934 let mut desired = RoleGraph::default();
2935 for key in [&global_key, &schema_key] {
2936 desired
2937 .default_privilege_absences
2938 .insert(key.clone(), [Privilege::Execute].into_iter().collect());
2939 }
2940
2941 let changes = diff(¤t, &desired);
2942 assert_eq!(changes.len(), 2, "{changes:?}");
2943 assert!(changes.iter().all(|change| matches!(
2944 change,
2945 Change::RevokeDefaultPrivilege {
2946 grantee: Grantee::Public,
2947 ..
2948 }
2949 )));
2950 }
2951
2952 #[test]
2953 fn global_public_absence_revokes_builtin_for_owner_created_in_same_plan() {
2954 let key = DefaultPrivKey {
2955 owner: "new_owner".to_string(),
2956 scope: DefaultPrivilegeScope::Global,
2957 on_type: ObjectType::Function,
2958 grantee: Grantee::Public,
2959 };
2960 let current = RoleGraph::default();
2961 let mut desired = RoleGraph::default();
2962 desired
2963 .roles
2964 .insert("new_owner".to_string(), RoleState::default());
2965 desired
2966 .default_privilege_absences
2967 .insert(key, BTreeSet::from([Privilege::Execute]));
2968
2969 let changes = diff(¤t, &desired);
2970 assert_eq!(changes.len(), 2, "{changes:?}");
2971 assert!(matches!(
2972 &changes[0],
2973 Change::CreateRole { name, .. } if name == "new_owner"
2974 ));
2975 assert!(matches!(
2976 &changes[1],
2977 Change::RevokeDefaultPrivilege {
2978 owner,
2979 scope: DefaultPrivilegeScope::Global,
2980 on_type: ObjectType::Function,
2981 grantee: Grantee::Public,
2982 privileges,
2983 } if owner == "new_owner" && privileges == &BTreeSet::from([Privilege::Execute])
2984 ));
2985 }
2986
2987 #[test]
2988 fn global_public_type_absence_revokes_builtin_for_owner_created_in_same_plan() {
2989 let key = DefaultPrivKey {
2990 owner: "new_owner".to_string(),
2991 scope: DefaultPrivilegeScope::Global,
2992 on_type: ObjectType::Type,
2993 grantee: Grantee::Public,
2994 };
2995 let current = RoleGraph::default();
2996 let mut desired = RoleGraph::default();
2997 desired
2998 .roles
2999 .insert("new_owner".to_string(), RoleState::default());
3000 desired
3001 .default_privilege_absences
3002 .insert(key, BTreeSet::from([Privilege::Usage]));
3003
3004 let changes = diff(¤t, &desired);
3005 assert_eq!(changes.len(), 2, "{changes:?}");
3006 assert!(matches!(
3007 &changes[0],
3008 Change::CreateRole { name, .. } if name == "new_owner"
3009 ));
3010 assert!(matches!(
3011 &changes[1],
3012 Change::RevokeDefaultPrivilege {
3013 owner,
3014 scope: DefaultPrivilegeScope::Global,
3015 on_type: ObjectType::Type,
3016 grantee: Grantee::Public,
3017 privileges,
3018 } if owner == "new_owner" && privileges == &BTreeSet::from([Privilege::Usage])
3019 ));
3020 }
3021
3022 #[test]
3023 fn schema_public_absence_does_not_invent_a_builtin_for_new_owner() {
3024 let key = DefaultPrivKey {
3025 owner: "new_owner".to_string(),
3026 scope: DefaultPrivilegeScope::Schema {
3027 schema: "api".to_string(),
3028 },
3029 on_type: ObjectType::Function,
3030 grantee: Grantee::Public,
3031 };
3032 let current = RoleGraph::default();
3033 let mut desired = RoleGraph::default();
3034 desired
3035 .roles
3036 .insert("new_owner".to_string(), RoleState::default());
3037 desired
3038 .default_privilege_absences
3039 .insert(key, BTreeSet::from([Privilege::Execute]));
3040
3041 let changes = diff(¤t, &desired);
3042 assert_eq!(changes.len(), 1, "{changes:?}");
3043 assert!(matches!(&changes[0], Change::CreateRole { .. }));
3044 }
3045
3046 #[test]
3047 fn additive_mode_ignores_absence_while_adopt_and_authoritative_apply_it() {
3048 let current = graph_with_public_grant("api", "f()", &[Privilege::Execute]);
3049 let mut desired = RoleGraph::default();
3050 desired.grant_absences.insert(
3051 public_function_key("api", "f()"),
3052 [Privilege::Execute].into_iter().collect(),
3053 );
3054 desired
3056 .roles
3057 .insert("newcomer".to_string(), RoleState::default());
3058
3059 let changes = diff(¤t, &desired);
3060
3061 let additive = filter_changes(changes.clone(), ReconciliationMode::Additive);
3062 assert!(
3063 !additive
3064 .iter()
3065 .any(|change| matches!(change, Change::Revoke { .. })),
3066 "additive must ignore absence: {additive:?}"
3067 );
3068 assert!(
3069 additive
3070 .iter()
3071 .any(|change| matches!(change, Change::CreateRole { .. })),
3072 "additive must keep the rest of the plan"
3073 );
3074
3075 for mode in [ReconciliationMode::Adopt, ReconciliationMode::Authoritative] {
3076 let filtered = filter_changes(changes.clone(), mode);
3077 assert!(
3078 filtered
3079 .iter()
3080 .any(|change| matches!(change, Change::Revoke { .. })),
3081 "{mode:?} must apply absence"
3082 );
3083 }
3084
3085 assert!(additive_ignores_absence_assertions(
3086 &desired,
3087 ReconciliationMode::Additive
3088 ));
3089 assert!(!additive_ignores_absence_assertions(
3090 &desired,
3091 ReconciliationMode::Adopt
3092 ));
3093 assert!(!additive_ignores_absence_assertions(
3094 &RoleGraph::default(),
3095 ReconciliationMode::Additive
3096 ));
3097 }
3098}