use std::collections::BTreeSet;
use crate::manifest::{ObjectType, Privilege, RoleRetirement};
use crate::model::{DefaultPrivKey, GrantKey, MembershipEdge, RoleAttribute, RoleGraph, RoleState};
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
pub enum Change {
CreateRole { name: String, state: RoleState },
AlterRole {
name: String,
attributes: Vec<RoleAttribute>,
},
SetComment {
name: String,
comment: Option<String>,
},
Grant {
role: String,
privileges: BTreeSet<Privilege>,
object_type: ObjectType,
schema: Option<String>,
name: Option<String>,
},
Revoke {
role: String,
privileges: BTreeSet<Privilege>,
object_type: ObjectType,
schema: Option<String>,
name: Option<String>,
},
SetDefaultPrivilege {
owner: String,
schema: String,
on_type: ObjectType,
grantee: String,
privileges: BTreeSet<Privilege>,
},
RevokeDefaultPrivilege {
owner: String,
schema: String,
on_type: ObjectType,
grantee: String,
privileges: BTreeSet<Privilege>,
},
AddMember {
role: String,
member: String,
inherit: bool,
admin: bool,
},
RemoveMember { role: String, member: String },
ReassignOwned { from_role: String, to_role: String },
DropOwned { role: String },
TerminateSessions { role: String },
SetPassword { name: String, password: String },
DropRole { name: String },
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, serde::Serialize)]
pub enum ReconciliationMode {
#[default]
Authoritative,
Additive,
Adopt,
}
impl std::fmt::Display for ReconciliationMode {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
ReconciliationMode::Authoritative => write!(f, "authoritative"),
ReconciliationMode::Additive => write!(f, "additive"),
ReconciliationMode::Adopt => write!(f, "adopt"),
}
}
}
pub fn filter_changes(changes: Vec<Change>, mode: ReconciliationMode) -> Vec<Change> {
match mode {
ReconciliationMode::Authoritative => changes,
ReconciliationMode::Additive => changes
.into_iter()
.filter(|change| !is_destructive(change))
.collect(),
ReconciliationMode::Adopt => changes
.into_iter()
.filter(|change| !is_role_drop_or_retirement(change))
.collect(),
}
}
fn is_destructive(change: &Change) -> bool {
matches!(
change,
Change::Revoke { .. }
| Change::RevokeDefaultPrivilege { .. }
| Change::RemoveMember { .. }
| Change::DropRole { .. }
| Change::DropOwned { .. }
| Change::ReassignOwned { .. }
| Change::TerminateSessions { .. }
)
}
fn is_role_drop_or_retirement(change: &Change) -> bool {
matches!(
change,
Change::DropRole { .. }
| Change::DropOwned { .. }
| Change::ReassignOwned { .. }
| Change::TerminateSessions { .. }
)
}
pub fn diff(current: &RoleGraph, desired: &RoleGraph) -> Vec<Change> {
let mut creates = Vec::new();
let mut alters = Vec::new();
let mut grants = Vec::new();
let mut set_defaults = Vec::new();
let mut add_members = Vec::new();
let mut remove_members = Vec::new();
let mut revoke_defaults = Vec::new();
let mut revokes = Vec::new();
let mut drops = Vec::new();
for (name, desired_state) in &desired.roles {
match current.roles.get(name) {
None => {
creates.push(Change::CreateRole {
name: name.clone(),
state: desired_state.clone(),
});
}
Some(current_state) => {
let attribute_changes = current_state.changed_attributes(desired_state);
if !attribute_changes.is_empty() {
alters.push(Change::AlterRole {
name: name.clone(),
attributes: attribute_changes,
});
}
if current_state.comment != desired_state.comment {
alters.push(Change::SetComment {
name: name.clone(),
comment: desired_state.comment.clone(),
});
}
}
}
}
for name in current.roles.keys() {
if !desired.roles.contains_key(name) {
drops.push(Change::DropRole { name: name.clone() });
}
}
diff_grants(current, desired, &mut grants, &mut revokes);
diff_default_privileges(current, desired, &mut set_defaults, &mut revoke_defaults);
diff_memberships(current, desired, &mut add_members, &mut remove_members);
let mut changes = Vec::new();
changes.extend(creates);
changes.extend(alters);
changes.extend(grants);
changes.extend(set_defaults);
changes.extend(remove_members);
changes.extend(add_members);
changes.extend(revoke_defaults);
changes.extend(revokes);
changes.extend(drops);
changes
}
pub fn apply_role_retirements(changes: Vec<Change>, retirements: &[RoleRetirement]) -> Vec<Change> {
if retirements.is_empty() {
return changes;
}
let retirement_by_role: std::collections::BTreeMap<&str, &RoleRetirement> = retirements
.iter()
.map(|retirement| (retirement.role.as_str(), retirement))
.collect();
let mut planned = Vec::with_capacity(changes.len());
for change in changes {
if let Change::DropRole { name } = &change
&& let Some(retirement) = retirement_by_role.get(name.as_str())
{
if retirement.terminate_sessions {
planned.push(Change::TerminateSessions { role: name.clone() });
}
if let Some(successor) = &retirement.reassign_owned_to {
planned.push(Change::ReassignOwned {
from_role: name.clone(),
to_role: successor.clone(),
});
}
if retirement.drop_owned {
planned.push(Change::DropOwned { role: name.clone() });
}
}
planned.push(change);
}
planned
}
pub fn resolve_passwords(
roles: &[crate::manifest::RoleDefinition],
) -> Result<std::collections::BTreeMap<String, String>, PasswordResolutionError> {
let mut resolved = std::collections::BTreeMap::new();
for role in roles {
if let Some(source) = &role.password {
let value = std::env::var(&source.from_env).map_err(|_| {
PasswordResolutionError::MissingEnvVar {
role: role.name.clone(),
env_var: source.from_env.clone(),
}
})?;
if value.is_empty() {
return Err(PasswordResolutionError::EmptyPassword {
role: role.name.clone(),
env_var: source.from_env.clone(),
});
}
resolved.insert(role.name.clone(), value);
}
}
Ok(resolved)
}
#[derive(Debug, thiserror::Error)]
pub enum PasswordResolutionError {
#[error("environment variable \"{env_var}\" for role \"{role}\" password is not set")]
MissingEnvVar { role: String, env_var: String },
#[error("environment variable \"{env_var}\" for role \"{role}\" password is empty")]
EmptyPassword { role: String, env_var: String },
}
pub fn inject_password_changes(
changes: Vec<Change>,
resolved_passwords: &std::collections::BTreeMap<String, String>,
) -> Vec<Change> {
if resolved_passwords.is_empty() {
return changes;
}
let created_roles: std::collections::BTreeSet<String> = changes
.iter()
.filter_map(|c| match c {
Change::CreateRole { name, .. } => Some(name.clone()),
_ => None,
})
.collect();
let mut result = Vec::with_capacity(changes.len() + resolved_passwords.len());
for change in changes {
if let Change::CreateRole { ref name, .. } = change
&& let Some(password) = resolved_passwords.get(name.as_str())
{
let role_name = name.clone();
let verifier =
crate::scram::compute_verifier(password, crate::scram::DEFAULT_ITERATIONS);
result.push(change);
result.push(Change::SetPassword {
name: role_name,
password: verifier,
});
continue;
}
result.push(change);
}
for (role_name, password) in resolved_passwords {
if !created_roles.contains(role_name) {
let verifier =
crate::scram::compute_verifier(password, crate::scram::DEFAULT_ITERATIONS);
result.push(Change::SetPassword {
name: role_name.clone(),
password: verifier,
});
}
}
result
}
fn diff_grants(
current: &RoleGraph,
desired: &RoleGraph,
grants_out: &mut Vec<Change>,
revokes_out: &mut Vec<Change>,
) {
for (key, desired_state) in &desired.grants {
match current.grants.get(key) {
None => {
grants_out.push(change_grant(key, &desired_state.privileges));
}
Some(current_state) => {
let to_add: BTreeSet<Privilege> = desired_state
.privileges
.difference(¤t_state.privileges)
.copied()
.collect();
let to_remove: BTreeSet<Privilege> = current_state
.privileges
.difference(&desired_state.privileges)
.copied()
.collect();
if !to_add.is_empty() {
grants_out.push(change_grant(key, &to_add));
}
if !to_remove.is_empty() {
revokes_out.push(change_revoke(key, &to_remove));
}
}
}
}
for (key, current_state) in ¤t.grants {
if !desired.grants.contains_key(key) {
revokes_out.push(change_revoke(key, ¤t_state.privileges));
}
}
}
fn change_grant(key: &GrantKey, privileges: &BTreeSet<Privilege>) -> Change {
Change::Grant {
role: key.role.clone(),
privileges: privileges.clone(),
object_type: key.object_type,
schema: key.schema.clone(),
name: key.name.clone(),
}
}
fn change_revoke(key: &GrantKey, privileges: &BTreeSet<Privilege>) -> Change {
Change::Revoke {
role: key.role.clone(),
privileges: privileges.clone(),
object_type: key.object_type,
schema: key.schema.clone(),
name: key.name.clone(),
}
}
fn diff_default_privileges(
current: &RoleGraph,
desired: &RoleGraph,
set_out: &mut Vec<Change>,
revoke_out: &mut Vec<Change>,
) {
for (key, desired_state) in &desired.default_privileges {
match current.default_privileges.get(key) {
None => {
set_out.push(change_set_default(key, &desired_state.privileges));
}
Some(current_state) => {
let to_add: BTreeSet<Privilege> = desired_state
.privileges
.difference(¤t_state.privileges)
.copied()
.collect();
let to_remove: BTreeSet<Privilege> = current_state
.privileges
.difference(&desired_state.privileges)
.copied()
.collect();
if !to_add.is_empty() {
set_out.push(change_set_default(key, &to_add));
}
if !to_remove.is_empty() {
revoke_out.push(change_revoke_default(key, &to_remove));
}
}
}
}
for (key, current_state) in ¤t.default_privileges {
if !desired.default_privileges.contains_key(key) {
revoke_out.push(change_revoke_default(key, ¤t_state.privileges));
}
}
}
fn change_set_default(key: &DefaultPrivKey, privileges: &BTreeSet<Privilege>) -> Change {
Change::SetDefaultPrivilege {
owner: key.owner.clone(),
schema: key.schema.clone(),
on_type: key.on_type,
grantee: key.grantee.clone(),
privileges: privileges.clone(),
}
}
fn change_revoke_default(key: &DefaultPrivKey, privileges: &BTreeSet<Privilege>) -> Change {
Change::RevokeDefaultPrivilege {
owner: key.owner.clone(),
schema: key.schema.clone(),
on_type: key.on_type,
grantee: key.grantee.clone(),
privileges: privileges.clone(),
}
}
fn diff_memberships(
current: &RoleGraph,
desired: &RoleGraph,
add_out: &mut Vec<Change>,
remove_out: &mut Vec<Change>,
) {
let current_map: std::collections::BTreeMap<(&str, &str), &MembershipEdge> = current
.memberships
.iter()
.map(|edge| ((edge.role.as_str(), edge.member.as_str()), edge))
.collect();
let desired_map: std::collections::BTreeMap<(&str, &str), &MembershipEdge> = desired
.memberships
.iter()
.map(|edge| ((edge.role.as_str(), edge.member.as_str()), edge))
.collect();
for (&(role, member), &desired_edge) in &desired_map {
match current_map.get(&(role, member)) {
None => {
add_out.push(Change::AddMember {
role: desired_edge.role.clone(),
member: desired_edge.member.clone(),
inherit: desired_edge.inherit,
admin: desired_edge.admin,
});
}
Some(current_edge) => {
if current_edge.inherit != desired_edge.inherit
|| current_edge.admin != desired_edge.admin
{
remove_out.push(Change::RemoveMember {
role: current_edge.role.clone(),
member: current_edge.member.clone(),
});
add_out.push(Change::AddMember {
role: desired_edge.role.clone(),
member: desired_edge.member.clone(),
inherit: desired_edge.inherit,
admin: desired_edge.admin,
});
}
}
}
}
for &(role, member) in current_map.keys() {
if !desired_map.contains_key(&(role, member)) {
remove_out.push(Change::RemoveMember {
role: role.to_string(),
member: member.to_string(),
});
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::model::{DefaultPrivState, GrantState};
fn empty_graph() -> RoleGraph {
RoleGraph::default()
}
#[test]
fn diff_empty_to_empty_is_empty() {
let changes = diff(&empty_graph(), &empty_graph());
assert!(changes.is_empty());
}
#[test]
fn diff_creates_new_roles() {
let current = empty_graph();
let mut desired = empty_graph();
desired
.roles
.insert("new-role".to_string(), RoleState::default());
let changes = diff(¤t, &desired);
assert_eq!(changes.len(), 1);
assert!(matches!(&changes[0], Change::CreateRole { name, .. } if name == "new-role"));
}
#[test]
fn diff_drops_removed_roles() {
let mut current = empty_graph();
current
.roles
.insert("old-role".to_string(), RoleState::default());
let desired = empty_graph();
let changes = diff(¤t, &desired);
assert_eq!(changes.len(), 1);
assert!(matches!(&changes[0], Change::DropRole { name } if name == "old-role"));
}
#[test]
fn diff_alters_changed_role_attributes() {
let mut current = empty_graph();
current
.roles
.insert("role1".to_string(), RoleState::default());
let mut desired = empty_graph();
desired.roles.insert(
"role1".to_string(),
RoleState {
login: true,
..RoleState::default()
},
);
let changes = diff(¤t, &desired);
assert_eq!(changes.len(), 1);
match &changes[0] {
Change::AlterRole { name, attributes } => {
assert_eq!(name, "role1");
assert!(attributes.contains(&RoleAttribute::Login(true)));
}
other => panic!("expected AlterRole, got: {other:?}"),
}
}
#[test]
fn diff_grants_new_privileges() {
let current = empty_graph();
let mut desired = empty_graph();
let key = GrantKey {
role: "r1".to_string(),
object_type: ObjectType::Table,
schema: Some("public".to_string()),
name: Some("*".to_string()),
};
desired.grants.insert(
key,
GrantState {
privileges: BTreeSet::from([Privilege::Select, Privilege::Insert]),
},
);
let changes = diff(¤t, &desired);
assert_eq!(changes.len(), 1);
match &changes[0] {
Change::Grant {
role, privileges, ..
} => {
assert_eq!(role, "r1");
assert!(privileges.contains(&Privilege::Select));
assert!(privileges.contains(&Privilege::Insert));
}
other => panic!("expected Grant, got: {other:?}"),
}
}
#[test]
fn diff_revokes_removed_privileges() {
let mut current = empty_graph();
let key = GrantKey {
role: "r1".to_string(),
object_type: ObjectType::Table,
schema: Some("public".to_string()),
name: Some("*".to_string()),
};
current.grants.insert(
key.clone(),
GrantState {
privileges: BTreeSet::from([Privilege::Select, Privilege::Insert]),
},
);
let mut desired = empty_graph();
desired.grants.insert(
key,
GrantState {
privileges: BTreeSet::from([Privilege::Select]),
},
);
let changes = diff(¤t, &desired);
assert_eq!(changes.len(), 1);
match &changes[0] {
Change::Revoke {
role, privileges, ..
} => {
assert_eq!(role, "r1");
assert!(privileges.contains(&Privilege::Insert));
assert!(!privileges.contains(&Privilege::Select));
}
other => panic!("expected Revoke, got: {other:?}"),
}
}
#[test]
fn diff_revokes_entire_grant_target_when_absent_from_desired() {
let mut current = empty_graph();
let key = GrantKey {
role: "r1".to_string(),
object_type: ObjectType::Schema,
schema: None,
name: Some("myschema".to_string()),
};
current.grants.insert(
key,
GrantState {
privileges: BTreeSet::from([Privilege::Usage]),
},
);
let desired = empty_graph();
let changes = diff(¤t, &desired);
assert_eq!(changes.len(), 1);
assert!(matches!(&changes[0], Change::Revoke { role, .. } if role == "r1"));
}
#[test]
fn diff_adds_memberships() {
let current = empty_graph();
let mut desired = empty_graph();
desired.memberships.insert(MembershipEdge {
role: "editors".to_string(),
member: "user@example.com".to_string(),
inherit: true,
admin: false,
});
let changes = diff(¤t, &desired);
assert_eq!(changes.len(), 1);
match &changes[0] {
Change::AddMember {
role,
member,
inherit,
admin,
} => {
assert_eq!(role, "editors");
assert_eq!(member, "user@example.com");
assert!(*inherit);
assert!(!admin);
}
other => panic!("expected AddMember, got: {other:?}"),
}
}
#[test]
fn diff_removes_memberships() {
let mut current = empty_graph();
current.memberships.insert(MembershipEdge {
role: "editors".to_string(),
member: "old@example.com".to_string(),
inherit: true,
admin: false,
});
let desired = empty_graph();
let changes = diff(¤t, &desired);
assert_eq!(changes.len(), 1);
assert!(
matches!(&changes[0], Change::RemoveMember { role, member } if role == "editors" && member == "old@example.com")
);
}
#[test]
fn diff_re_grants_membership_when_flags_change() {
let mut current = empty_graph();
current.memberships.insert(MembershipEdge {
role: "editors".to_string(),
member: "user@example.com".to_string(),
inherit: true,
admin: false,
});
let mut desired = empty_graph();
desired.memberships.insert(MembershipEdge {
role: "editors".to_string(),
member: "user@example.com".to_string(),
inherit: true,
admin: true, });
let changes = diff(¤t, &desired);
assert_eq!(changes.len(), 2);
assert!(matches!(
&changes[0],
Change::RemoveMember { role, member }
if role == "editors" && member == "user@example.com"
));
assert!(matches!(
&changes[1],
Change::AddMember {
role,
member,
admin: true,
..
} if role == "editors" && member == "user@example.com"
));
}
#[test]
fn diff_default_privileges_add_and_revoke() {
let mut current = empty_graph();
let key = DefaultPrivKey {
owner: "app_owner".to_string(),
schema: "inventory".to_string(),
on_type: ObjectType::Table,
grantee: "inventory-editor".to_string(),
};
current.default_privileges.insert(
key.clone(),
DefaultPrivState {
privileges: BTreeSet::from([Privilege::Select, Privilege::Delete]),
},
);
let mut desired = empty_graph();
desired.default_privileges.insert(
key,
DefaultPrivState {
privileges: BTreeSet::from([Privilege::Select, Privilege::Insert]),
},
);
let changes = diff(¤t, &desired);
assert_eq!(changes.len(), 2);
assert!(changes.iter().any(|c| matches!(
c,
Change::SetDefaultPrivilege { privileges, .. } if privileges.contains(&Privilege::Insert)
)));
assert!(changes.iter().any(|c| matches!(
c,
Change::RevokeDefaultPrivilege { privileges, .. } if privileges.contains(&Privilege::Delete)
)));
}
#[test]
fn diff_ordering_creates_before_drops() {
let mut current = empty_graph();
current
.roles
.insert("old-role".to_string(), RoleState::default());
let mut desired = empty_graph();
desired
.roles
.insert("new-role".to_string(), RoleState::default());
let changes = diff(¤t, &desired);
assert_eq!(changes.len(), 2);
let create_idx = changes
.iter()
.position(|c| matches!(c, Change::CreateRole { .. }))
.unwrap();
let drop_idx = changes
.iter()
.position(|c| matches!(c, Change::DropRole { .. }))
.unwrap();
assert!(create_idx < drop_idx);
}
#[test]
fn diff_identical_graphs_produce_no_changes() {
let mut graph = empty_graph();
graph
.roles
.insert("role1".to_string(), RoleState::default());
graph.grants.insert(
GrantKey {
role: "role1".to_string(),
object_type: ObjectType::Table,
schema: Some("public".to_string()),
name: Some("*".to_string()),
},
GrantState {
privileges: BTreeSet::from([Privilege::Select]),
},
);
graph.memberships.insert(MembershipEdge {
role: "role1".to_string(),
member: "user@example.com".to_string(),
inherit: true,
admin: false,
});
let changes = diff(&graph, &graph);
assert!(
changes.is_empty(),
"identical graphs should produce no changes"
);
}
#[test]
fn manifest_to_diff_integration() {
use crate::manifest::{expand_manifest, parse_manifest};
use crate::model::RoleGraph;
let yaml = r#"
default_owner: app_owner
profiles:
editor:
grants:
- privileges: [USAGE]
object: { type: schema }
- privileges: [SELECT, INSERT, UPDATE, DELETE]
object: { type: table, name: "*" }
default_privileges:
- privileges: [SELECT, INSERT, UPDATE, DELETE]
on_type: table
schemas:
- name: inventory
profiles: [editor]
memberships:
- role: inventory-editor
members:
- name: "user@example.com"
"#;
let manifest = parse_manifest(yaml).unwrap();
let expanded = expand_manifest(&manifest).unwrap();
let desired =
RoleGraph::from_expanded(&expanded, manifest.default_owner.as_deref()).unwrap();
let current = RoleGraph::default();
let changes = diff(¤t, &desired);
let create_count = changes
.iter()
.filter(|c| matches!(c, Change::CreateRole { .. }))
.count();
let grant_count = changes
.iter()
.filter(|c| matches!(c, Change::Grant { .. }))
.count();
let dp_count = changes
.iter()
.filter(|c| matches!(c, Change::SetDefaultPrivilege { .. }))
.count();
let member_count = changes
.iter()
.filter(|c| matches!(c, Change::AddMember { .. }))
.count();
assert_eq!(create_count, 1);
assert_eq!(grant_count, 2); assert_eq!(dp_count, 1);
assert_eq!(member_count, 1);
let no_changes = diff(&desired, &desired);
assert!(no_changes.is_empty());
}
fn all_change_variants() -> Vec<Change> {
vec![
Change::CreateRole {
name: "new-role".to_string(),
state: RoleState::default(),
},
Change::AlterRole {
name: "altered-role".to_string(),
attributes: vec![RoleAttribute::Login(true)],
},
Change::SetComment {
name: "commented-role".to_string(),
comment: Some("hello".to_string()),
},
Change::Grant {
role: "r1".to_string(),
privileges: BTreeSet::from([Privilege::Select]),
object_type: ObjectType::Table,
schema: Some("public".to_string()),
name: Some("*".to_string()),
},
Change::Revoke {
role: "r1".to_string(),
privileges: BTreeSet::from([Privilege::Insert]),
object_type: ObjectType::Table,
schema: Some("public".to_string()),
name: Some("*".to_string()),
},
Change::SetDefaultPrivilege {
owner: "owner".to_string(),
schema: "public".to_string(),
on_type: ObjectType::Table,
grantee: "r1".to_string(),
privileges: BTreeSet::from([Privilege::Select]),
},
Change::RevokeDefaultPrivilege {
owner: "owner".to_string(),
schema: "public".to_string(),
on_type: ObjectType::Table,
grantee: "r1".to_string(),
privileges: BTreeSet::from([Privilege::Delete]),
},
Change::AddMember {
role: "editors".to_string(),
member: "user@example.com".to_string(),
inherit: true,
admin: false,
},
Change::RemoveMember {
role: "editors".to_string(),
member: "old@example.com".to_string(),
},
Change::TerminateSessions {
role: "retired-role".to_string(),
},
Change::ReassignOwned {
from_role: "retired-role".to_string(),
to_role: "successor".to_string(),
},
Change::DropOwned {
role: "retired-role".to_string(),
},
Change::DropRole {
name: "retired-role".to_string(),
},
]
}
#[test]
fn filter_authoritative_keeps_all_changes() {
let changes = all_change_variants();
let original_len = changes.len();
let filtered = filter_changes(changes, ReconciliationMode::Authoritative);
assert_eq!(filtered.len(), original_len);
}
#[test]
fn filter_additive_keeps_only_constructive_changes() {
let filtered = filter_changes(all_change_variants(), ReconciliationMode::Additive);
assert_eq!(filtered.len(), 6);
for change in &filtered {
assert!(
!matches!(
change,
Change::Revoke { .. }
| Change::RevokeDefaultPrivilege { .. }
| Change::RemoveMember { .. }
| Change::DropRole { .. }
| Change::DropOwned { .. }
| Change::ReassignOwned { .. }
| Change::TerminateSessions { .. }
),
"additive mode should not contain destructive change: {change:?}"
);
}
assert!(
filtered
.iter()
.any(|c| matches!(c, Change::CreateRole { .. }))
);
assert!(
filtered
.iter()
.any(|c| matches!(c, Change::AlterRole { .. }))
);
assert!(
filtered
.iter()
.any(|c| matches!(c, Change::SetComment { .. }))
);
assert!(filtered.iter().any(|c| matches!(c, Change::Grant { .. })));
assert!(
filtered
.iter()
.any(|c| matches!(c, Change::SetDefaultPrivilege { .. }))
);
assert!(
filtered
.iter()
.any(|c| matches!(c, Change::AddMember { .. }))
);
}
#[test]
fn filter_adopt_keeps_revokes_but_not_drops() {
let filtered = filter_changes(all_change_variants(), ReconciliationMode::Adopt);
assert_eq!(filtered.len(), 9);
for change in &filtered {
assert!(
!matches!(
change,
Change::DropRole { .. }
| Change::DropOwned { .. }
| Change::ReassignOwned { .. }
| Change::TerminateSessions { .. }
),
"adopt mode should not contain drop/retirement change: {change:?}"
);
}
assert!(filtered.iter().any(|c| matches!(c, Change::Revoke { .. })));
assert!(
filtered
.iter()
.any(|c| matches!(c, Change::RevokeDefaultPrivilege { .. }))
);
assert!(
filtered
.iter()
.any(|c| matches!(c, Change::RemoveMember { .. }))
);
}
#[test]
fn filter_additive_with_empty_input() {
let filtered = filter_changes(vec![], ReconciliationMode::Additive);
assert!(filtered.is_empty());
}
#[test]
fn filter_additive_only_destructive_changes_yields_empty() {
let changes = vec![
Change::Revoke {
role: "r1".to_string(),
privileges: BTreeSet::from([Privilege::Select]),
object_type: ObjectType::Table,
schema: Some("public".to_string()),
name: Some("*".to_string()),
},
Change::DropRole {
name: "old-role".to_string(),
},
];
let filtered = filter_changes(changes, ReconciliationMode::Additive);
assert!(filtered.is_empty());
}
#[test]
fn filter_adopt_preserves_ordering() {
let changes = vec![
Change::CreateRole {
name: "new-role".to_string(),
state: RoleState::default(),
},
Change::Grant {
role: "new-role".to_string(),
privileges: BTreeSet::from([Privilege::Select]),
object_type: ObjectType::Table,
schema: Some("public".to_string()),
name: Some("*".to_string()),
},
Change::Revoke {
role: "existing-role".to_string(),
privileges: BTreeSet::from([Privilege::Insert]),
object_type: ObjectType::Table,
schema: Some("public".to_string()),
name: Some("*".to_string()),
},
Change::DropRole {
name: "old-role".to_string(),
},
];
let filtered = filter_changes(changes, ReconciliationMode::Adopt);
assert_eq!(filtered.len(), 3);
assert!(matches!(&filtered[0], Change::CreateRole { name, .. } if name == "new-role"));
assert!(matches!(&filtered[1], Change::Grant { .. }));
assert!(matches!(&filtered[2], Change::Revoke { .. }));
}
#[test]
fn reconciliation_mode_display() {
assert_eq!(
ReconciliationMode::Authoritative.to_string(),
"authoritative"
);
assert_eq!(ReconciliationMode::Additive.to_string(), "additive");
assert_eq!(ReconciliationMode::Adopt.to_string(), "adopt");
}
#[test]
fn reconciliation_mode_default_is_authoritative() {
assert_eq!(
ReconciliationMode::default(),
ReconciliationMode::Authoritative
);
}
#[test]
fn apply_role_retirements_inserts_cleanup_before_drop() {
let changes = vec![
Change::Grant {
role: "analytics".to_string(),
privileges: BTreeSet::from([Privilege::Select]),
object_type: ObjectType::Table,
schema: Some("public".to_string()),
name: Some("*".to_string()),
},
Change::DropRole {
name: "old-app".to_string(),
},
];
let planned = apply_role_retirements(
changes,
&[crate::manifest::RoleRetirement {
role: "old-app".to_string(),
reassign_owned_to: Some("successor".to_string()),
drop_owned: true,
terminate_sessions: true,
}],
);
assert!(matches!(planned[0], Change::Grant { .. }));
assert!(matches!(
planned[1],
Change::TerminateSessions { ref role } if role == "old-app"
));
assert!(matches!(
planned[2],
Change::ReassignOwned {
ref from_role,
ref to_role
} if from_role == "old-app" && to_role == "successor"
));
assert!(matches!(
planned[3],
Change::DropOwned { ref role } if role == "old-app"
));
assert!(matches!(
planned[4],
Change::DropRole { ref name } if name == "old-app"
));
}
#[test]
fn inject_password_for_new_role() {
let changes = vec![Change::CreateRole {
name: "app-svc".to_string(),
state: RoleState::default(),
}];
let mut passwords = std::collections::BTreeMap::new();
passwords.insert("app-svc".to_string(), "secret123".to_string());
let result = inject_password_changes(changes, &passwords);
assert_eq!(result.len(), 2);
assert!(matches!(&result[0], Change::CreateRole { name, .. } if name == "app-svc"));
assert!(
matches!(&result[1], Change::SetPassword { name, password } if name == "app-svc" && password.starts_with("SCRAM-SHA-256$"))
);
}
#[test]
fn inject_password_for_existing_role() {
let changes = vec![Change::Grant {
role: "app-svc".to_string(),
privileges: BTreeSet::from([crate::manifest::Privilege::Select]),
object_type: crate::manifest::ObjectType::Table,
schema: Some("public".to_string()),
name: Some("*".to_string()),
}];
let mut passwords = std::collections::BTreeMap::new();
passwords.insert("app-svc".to_string(), "secret123".to_string());
let result = inject_password_changes(changes, &passwords);
assert_eq!(result.len(), 2);
assert!(matches!(&result[0], Change::Grant { .. }));
assert!(
matches!(&result[1], Change::SetPassword { name, password } if name == "app-svc" && password.starts_with("SCRAM-SHA-256$"))
);
}
#[test]
fn inject_password_empty_passwords_is_noop() {
let changes = vec![Change::CreateRole {
name: "app-svc".to_string(),
state: RoleState::default(),
}];
let passwords = std::collections::BTreeMap::new();
let result = inject_password_changes(changes.clone(), &passwords);
assert_eq!(result.len(), 1);
}
#[test]
fn resolve_passwords_missing_env_var() {
let roles = vec![crate::manifest::RoleDefinition {
name: "app-svc".to_string(),
login: Some(true),
password: Some(crate::manifest::PasswordSource {
from_env: "PGROLES_TEST_MISSING_VAR_9a8b7c6d".to_string(),
}),
password_valid_until: None,
superuser: None,
createdb: None,
createrole: None,
inherit: None,
replication: None,
bypassrls: None,
connection_limit: None,
comment: None,
}];
unsafe { std::env::remove_var("PGROLES_TEST_MISSING_VAR_9a8b7c6d") };
let result = resolve_passwords(&roles);
assert!(result.is_err());
let err = result.unwrap_err();
assert!(
matches!(err, PasswordResolutionError::MissingEnvVar { ref role, ref env_var }
if role == "app-svc" && env_var == "PGROLES_TEST_MISSING_VAR_9a8b7c6d"),
"expected MissingEnvVar, got: {err:?}"
);
}
#[test]
fn resolve_passwords_empty_env_var() {
let roles = vec![crate::manifest::RoleDefinition {
name: "app-svc".to_string(),
login: Some(true),
password: Some(crate::manifest::PasswordSource {
from_env: "PGROLES_TEST_EMPTY_VAR_1a2b3c4d".to_string(),
}),
password_valid_until: None,
superuser: None,
createdb: None,
createrole: None,
inherit: None,
replication: None,
bypassrls: None,
connection_limit: None,
comment: None,
}];
unsafe { std::env::set_var("PGROLES_TEST_EMPTY_VAR_1a2b3c4d", "") };
let result = resolve_passwords(&roles);
unsafe { std::env::remove_var("PGROLES_TEST_EMPTY_VAR_1a2b3c4d") };
assert!(result.is_err());
let err = result.unwrap_err();
assert!(
matches!(err, PasswordResolutionError::EmptyPassword { ref role, ref env_var }
if role == "app-svc" && env_var == "PGROLES_TEST_EMPTY_VAR_1a2b3c4d"),
"expected EmptyPassword, got: {err:?}"
);
}
#[test]
fn resolve_passwords_happy_path() {
let roles = vec![crate::manifest::RoleDefinition {
name: "app-svc".to_string(),
login: Some(true),
password: Some(crate::manifest::PasswordSource {
from_env: "PGROLES_TEST_RESOLVE_VAR_5e6f7g8h".to_string(),
}),
password_valid_until: None,
superuser: None,
createdb: None,
createrole: None,
inherit: None,
replication: None,
bypassrls: None,
connection_limit: None,
comment: None,
}];
unsafe { std::env::set_var("PGROLES_TEST_RESOLVE_VAR_5e6f7g8h", "my_secret_pw") };
let result = resolve_passwords(&roles);
unsafe { std::env::remove_var("PGROLES_TEST_RESOLVE_VAR_5e6f7g8h") };
let resolved = result.expect("should succeed");
assert_eq!(resolved.len(), 1);
assert_eq!(resolved["app-svc"], "my_secret_pw");
}
#[test]
fn resolve_passwords_skips_roles_without_password() {
let roles = vec![crate::manifest::RoleDefinition {
name: "no-password".to_string(),
login: Some(true),
password: None,
password_valid_until: None,
superuser: None,
createdb: None,
createrole: None,
inherit: None,
replication: None,
bypassrls: None,
connection_limit: None,
comment: None,
}];
let result = resolve_passwords(&roles);
let resolved = result.expect("should succeed");
assert!(resolved.is_empty());
}
#[test]
fn inject_password_multiple_roles() {
let changes = vec![
Change::CreateRole {
name: "role-a".to_string(),
state: RoleState::default(),
},
Change::CreateRole {
name: "role-b".to_string(),
state: RoleState::default(),
},
Change::Grant {
role: "role-c".to_string(),
privileges: BTreeSet::from([crate::manifest::Privilege::Select]),
object_type: crate::manifest::ObjectType::Table,
schema: Some("public".to_string()),
name: Some("*".to_string()),
},
];
let mut passwords = std::collections::BTreeMap::new();
passwords.insert("role-a".to_string(), "pw-a".to_string());
passwords.insert("role-b".to_string(), "pw-b".to_string());
passwords.insert("role-c".to_string(), "pw-c".to_string());
let result = inject_password_changes(changes, &passwords);
assert_eq!(result.len(), 6, "expected 6 changes, got: {result:?}");
assert!(matches!(&result[0], Change::CreateRole { name, .. } if name == "role-a"));
assert!(matches!(&result[1], Change::SetPassword { name, .. } if name == "role-a"));
assert!(matches!(&result[2], Change::CreateRole { name, .. } if name == "role-b"));
assert!(matches!(&result[3], Change::SetPassword { name, .. } if name == "role-b"));
assert!(matches!(&result[4], Change::Grant { .. }));
assert!(matches!(&result[5], Change::SetPassword { name, .. } if name == "role-c"));
}
#[test]
fn diff_detects_valid_until_change() {
let mut current = empty_graph();
current.roles.insert(
"r1".to_string(),
RoleState {
login: true,
..RoleState::default()
},
);
let mut desired = empty_graph();
desired.roles.insert(
"r1".to_string(),
RoleState {
login: true,
password_valid_until: Some("2025-12-31T00:00:00Z".to_string()),
..RoleState::default()
},
);
let changes = diff(¤t, &desired);
assert_eq!(changes.len(), 1);
match &changes[0] {
Change::AlterRole { name, attributes } => {
assert_eq!(name, "r1");
assert!(attributes.contains(&RoleAttribute::ValidUntil(Some(
"2025-12-31T00:00:00Z".to_string()
))));
}
other => panic!("expected AlterRole, got: {other:?}"),
}
}
#[test]
fn diff_detects_valid_until_removal() {
let mut current = empty_graph();
current.roles.insert(
"r1".to_string(),
RoleState {
login: true,
password_valid_until: Some("2025-12-31T00:00:00Z".to_string()),
..RoleState::default()
},
);
let mut desired = empty_graph();
desired.roles.insert(
"r1".to_string(),
RoleState {
login: true,
..RoleState::default()
},
);
let changes = diff(¤t, &desired);
assert_eq!(changes.len(), 1);
match &changes[0] {
Change::AlterRole { name, attributes } => {
assert_eq!(name, "r1");
assert!(attributes.contains(&RoleAttribute::ValidUntil(None)));
}
other => panic!("expected AlterRole, got: {other:?}"),
}
}
}