use std::collections::{BTreeMap, BTreeSet};
use pgroles_core::diff::{Change, ReconciliationMode, diff, filter_changes};
use pgroles_core::manifest::{ObjectType, Privilege};
use pgroles_core::model::{
DefaultPrivKey, DefaultPrivState, GrantKey, GrantState, MembershipEdge, RoleAttribute,
RoleGraph, RoleState, SchemaState, default_schema_owner_privileges,
};
struct Rng(u64);
impl Rng {
fn new(seed: u64) -> Self {
Self(if seed == 0 {
0x9E37_79B9_7F4A_7C15
} else {
seed
})
}
fn next_u64(&mut self) -> u64 {
let mut x = self.0;
x ^= x >> 12;
x ^= x << 25;
x ^= x >> 27;
self.0 = x;
x.wrapping_mul(0x2545_F491_4F6C_DD1D)
}
fn usize(&mut self, modulus: usize) -> usize {
if modulus == 0 {
return 0;
}
(self.next_u64() as usize) % modulus
}
fn bool(&mut self) -> bool {
self.next_u64() & 1 == 1
}
}
fn gen_priv_set(rng: &mut Rng) -> BTreeSet<Privilege> {
use Privilege::*;
let pool = [
Select, Insert, Update, Delete, Truncate, References, Trigger, Execute, Usage, Create,
Connect, Temporary,
];
let n = rng.usize(4) + 1; let mut out = BTreeSet::new();
for _ in 0..n {
out.insert(pool[rng.usize(pool.len())]);
}
out
}
fn gen_config(rng: &mut Rng, messy: bool) -> BTreeMap<String, String> {
let mut m = BTreeMap::new();
let n = if messy { rng.usize(4) } else { rng.usize(3) };
for _ in 0..n {
let (k, v): (String, String) = match rng.usize(5) {
0 => ("role".into(), format!("r{}", rng.usize(5))),
1 => (
"search_path".into(),
["public", "app, public", "app"][rng.usize(3)].into(),
),
2 => (
"statement_timeout".into(),
["10s", "30s", "0"][rng.usize(3)].into(),
),
3 => ("app.foo".into(), ["on", "off"][rng.usize(2)].into()),
_ => ("app.bar".into(), format!("{}", rng.usize(100))),
};
m.insert(k, v);
}
m
}
fn gen_role_state(rng: &mut Rng, messy: bool) -> RoleState {
RoleState {
login: rng.bool(),
superuser: messy && rng.usize(4) == 0,
createdb: rng.bool(),
createrole: messy && rng.bool(),
inherit: rng.usize(4) != 0, replication: messy && rng.usize(5) == 0,
bypassrls: messy && rng.usize(5) == 0,
connection_limit: if rng.usize(3) == 0 {
rng.usize(20) as i32
} else {
-1
},
comment: if rng.bool() {
Some(format!("comment-{}", rng.usize(5)))
} else {
None
},
password_valid_until: if messy && rng.usize(3) == 0 {
Some(format!("20{}0-01-01T00:00:00Z", rng.usize(3) + 2))
} else {
None
},
config: gen_config(rng, messy),
}
}
fn gen_schemas(rng: &mut Rng, allow_none: bool) -> BTreeMap<String, SchemaState> {
let mut out = BTreeMap::new();
let n = rng.usize(4); for i in 0..n {
let name = format!("s{i}");
let owner = if allow_none && rng.usize(4) == 0 {
None
} else {
Some(format!("own{}", rng.usize(3)))
};
let owner_privileges = match &owner {
Some(o) => default_schema_owner_privileges(o),
None => BTreeSet::new(),
};
out.insert(
name,
SchemaState {
owner,
owner_privileges,
},
);
}
out
}
fn gen_grants(rng: &mut Rng, roles: &[String]) -> BTreeMap<GrantKey, GrantState> {
let mut out = BTreeMap::new();
if roles.is_empty() {
return out;
}
let n = rng.usize(9); for _ in 0..n {
let role = roles[rng.usize(roles.len())].clone();
let (object_type, schema, name) = match rng.usize(4) {
0 => (ObjectType::Schema, None, Some(format!("s{}", rng.usize(3)))),
1 => (
ObjectType::Table,
Some(format!("s{}", rng.usize(3))),
Some(format!("t{}", rng.usize(3))),
),
2 => (
ObjectType::Sequence,
Some(format!("s{}", rng.usize(3))),
Some(format!("seq{}", rng.usize(3))),
),
_ => (
ObjectType::Function,
Some(format!("s{}", rng.usize(3))),
Some(format!("fn{}", rng.usize(3))),
),
};
out.insert(
GrantKey {
role,
object_type,
schema,
name,
},
GrantState {
privileges: gen_priv_set(rng),
},
);
}
out
}
fn gen_default_privs(
rng: &mut Rng,
roles: &[String],
) -> BTreeMap<DefaultPrivKey, DefaultPrivState> {
let mut out = BTreeMap::new();
if roles.is_empty() {
return out;
}
let n = rng.usize(5); for _ in 0..n {
let on_type = [
ObjectType::Table,
ObjectType::Sequence,
ObjectType::Function,
][rng.usize(3)];
out.insert(
DefaultPrivKey {
owner: format!("own{}", rng.usize(3)),
schema: format!("s{}", rng.usize(3)),
on_type,
grantee: roles[rng.usize(roles.len())].clone(),
},
DefaultPrivState {
privileges: gen_priv_set(rng),
},
);
}
out
}
fn gen_memberships(rng: &mut Rng, roles: &[String]) -> BTreeSet<MembershipEdge> {
let mut out = BTreeSet::new();
if roles.is_empty() {
return out;
}
let mut seen: BTreeSet<(String, String)> = BTreeSet::new();
let n = rng.usize(5); for _ in 0..n {
let role = roles[rng.usize(roles.len())].clone();
let member = if rng.bool() {
roles[rng.usize(roles.len())].clone()
} else {
format!("user{}@example.com", rng.usize(4))
};
if role == member || !seen.insert((role.clone(), member.clone())) {
continue;
}
out.insert(MembershipEdge {
role,
member,
inherit: rng.bool(),
admin: rng.bool(),
});
}
out
}
fn gen_graph(rng: &mut Rng, allow_none: bool, messy: bool) -> RoleGraph {
let role_count = rng.usize(6); let mut roles = BTreeMap::new();
for i in 0..role_count {
roles.insert(format!("r{i}"), gen_role_state(rng, messy));
}
let role_names: Vec<String> = roles.keys().cloned().collect();
RoleGraph {
roles,
schemas: gen_schemas(rng, allow_none),
grants: gen_grants(rng, &role_names),
default_privileges: gen_default_privs(rng, &role_names),
memberships: gen_memberships(rng, &role_names),
}
}
fn derive_current(rng: &mut Rng, desired: &RoleGraph) -> RoleGraph {
let mut c = desired.clone();
for name in desired.roles.keys().cloned().collect::<Vec<_>>() {
match rng.usize(6) {
0 => {
c.roles.remove(&name); }
1 => {
if let Some(st) = c.roles.get_mut(&name) {
st.login = !st.login;
if rng.bool() {
st.createdb = !st.createdb;
}
if rng.bool() {
st.connection_limit = if st.connection_limit == -1 { 7 } else { -1 };
}
if rng.bool() {
st.inherit = !st.inherit;
}
if rng.bool() {
st.password_valid_until = match &st.password_valid_until {
Some(_) => None,
None => Some("2099-01-01T00:00:00Z".to_string()),
};
}
}
}
2 => {
if let Some(st) = c.roles.get_mut(&name) {
st.config
.insert("app.stray".to_string(), format!("{}", rng.usize(9)));
if let Some(k) = st.config.keys().next().cloned()
&& rng.bool()
{
st.config.insert(k, format!("changed{}", rng.usize(9)));
}
}
}
3 => {
if let Some(st) = c.roles.get_mut(&name) {
st.comment = match &st.comment {
Some(_) => None,
None => Some("drifted".to_string()),
};
}
}
_ => {}
}
}
for i in 0..rng.usize(3) {
c.roles
.insert(format!("stray{i}"), gen_role_state(rng, true));
}
for k in c.grants.keys().cloned().collect::<Vec<_>>() {
match rng.usize(4) {
0 => {
c.grants.remove(&k);
}
1 => {
if let Some(gs) = c.grants.get_mut(&k) {
gs.privileges.insert(Privilege::Truncate); }
}
2 => {
if let Some(gs) = c.grants.get_mut(&k)
&& gs.privileges.len() > 1
{
let p = *gs.privileges.iter().next().unwrap();
gs.privileges.remove(&p); }
}
_ => {}
}
}
for _ in 0..rng.usize(3) {
c.grants.insert(
GrantKey {
role: format!("r{}", rng.usize(6)),
object_type: ObjectType::Table,
schema: Some(format!("s{}", rng.usize(3))),
name: Some(format!("stray{}", rng.usize(3))),
},
GrantState {
privileges: [Privilege::Select].into_iter().collect(),
},
);
}
for k in c.default_privileges.keys().cloned().collect::<Vec<_>>() {
match rng.usize(4) {
0 => {
c.default_privileges.remove(&k);
}
1 => {
if let Some(ds) = c.default_privileges.get_mut(&k) {
ds.privileges.insert(Privilege::Truncate);
}
}
2 => {
if let Some(ds) = c.default_privileges.get_mut(&k)
&& ds.privileges.len() > 1
{
let p = *ds.privileges.iter().next().unwrap();
ds.privileges.remove(&p);
}
}
_ => {}
}
}
for _ in 0..rng.usize(2) {
c.default_privileges.insert(
DefaultPrivKey {
owner: format!("own{}", rng.usize(3)),
schema: format!("s{}", rng.usize(3)),
on_type: ObjectType::Table,
grantee: format!("r{}", rng.usize(6)),
},
DefaultPrivState {
privileges: [Privilege::Select].into_iter().collect(),
},
);
}
for e in c.memberships.iter().cloned().collect::<Vec<_>>() {
match rng.usize(4) {
0 => {
c.memberships.remove(&e); }
1 => {
c.memberships.remove(&e);
c.memberships.insert(MembershipEdge {
inherit: !e.inherit,
..e.clone()
});
}
_ => {}
}
}
for _ in 0..rng.usize(2) {
c.memberships.insert(MembershipEdge {
role: format!("r{}", rng.usize(6)),
member: format!("stray{}@x.example", rng.usize(3)),
inherit: rng.bool(),
admin: rng.bool(),
});
}
for name in c.schemas.keys().cloned().collect::<Vec<_>>() {
match rng.usize(5) {
0 => {
c.schemas.remove(&name); }
1 => {
if let Some(ss) = c.schemas.get_mut(&name) {
ss.owner_privileges.clear(); }
}
2 => {
if let Some(ss) = c.schemas.get_mut(&name) {
ss.owner_privileges = [Privilege::Usage].into_iter().collect(); }
}
3 => {
if let Some(ss) = c.schemas.get_mut(&name) {
ss.owner = Some(format!("drift{}", rng.usize(3)));
}
}
_ => {}
}
}
c
}
fn apply_attribute(state: &mut RoleState, attr: &RoleAttribute) {
match attr {
RoleAttribute::Login(v) => state.login = *v,
RoleAttribute::Superuser(v) => state.superuser = *v,
RoleAttribute::Createdb(v) => state.createdb = *v,
RoleAttribute::Createrole(v) => state.createrole = *v,
RoleAttribute::Inherit(v) => state.inherit = *v,
RoleAttribute::Replication(v) => state.replication = *v,
RoleAttribute::Bypassrls(v) => state.bypassrls = *v,
RoleAttribute::ConnectionLimit(v) => state.connection_limit = *v,
RoleAttribute::ValidUntil(v) => state.password_valid_until = v.clone(),
RoleAttribute::SetConfig(k, v) => {
state.config.insert(k.clone(), v.clone());
}
RoleAttribute::ResetConfig(k) => {
state.config.remove(k);
}
}
}
fn apply_changes(graph: &RoleGraph, changes: &[Change]) -> RoleGraph {
let mut g = graph.clone();
for change in changes {
match change {
Change::CreateRole { name, state } => {
g.roles.insert(name.clone(), state.clone());
}
Change::AlterRole { name, attributes } => {
let state = g
.roles
.get_mut(name)
.unwrap_or_else(|| panic!("AlterRole on absent role {name:?}"));
for attr in attributes {
apply_attribute(state, attr);
}
}
Change::SetComment { name, comment } => {
let state = g
.roles
.get_mut(name)
.unwrap_or_else(|| panic!("SetComment on absent role {name:?}"));
state.comment = comment.clone();
}
Change::DropRole { name } => {
g.roles.remove(name);
}
Change::CreateSchema { name, owner } => {
let owner_privileges = match owner {
Some(o) => default_schema_owner_privileges(o),
None => BTreeSet::new(),
};
g.schemas.insert(
name.clone(),
SchemaState {
owner: owner.clone(),
owner_privileges,
},
);
}
Change::AlterSchemaOwner { name, owner } => {
let state = g
.schemas
.get_mut(name)
.unwrap_or_else(|| panic!("AlterSchemaOwner on absent schema {name:?}"));
state.owner = Some(owner.clone());
state.owner_privileges =
[Privilege::Create, Privilege::Usage].into_iter().collect();
g.grants.remove(&GrantKey {
role: owner.clone(),
object_type: ObjectType::Schema,
schema: None,
name: Some(name.clone()),
});
}
Change::EnsureSchemaOwnerPrivileges {
name, privileges, ..
} => {
let state = g.schemas.get_mut(name).unwrap_or_else(|| {
panic!("EnsureSchemaOwnerPrivileges on absent schema {name:?}")
});
for p in privileges {
state.owner_privileges.insert(*p);
}
}
Change::Grant {
role,
privileges,
object_type,
schema,
name,
} => {
let key = GrantKey {
role: role.clone(),
object_type: *object_type,
schema: schema.clone(),
name: name.clone(),
};
let entry = g.grants.entry(key).or_insert_with(|| GrantState {
privileges: BTreeSet::new(),
});
for p in privileges {
entry.privileges.insert(*p);
}
}
Change::Revoke {
role,
privileges,
object_type,
schema,
name,
} => {
let key = GrantKey {
role: role.clone(),
object_type: *object_type,
schema: schema.clone(),
name: name.clone(),
};
let now_empty = if let Some(entry) = g.grants.get_mut(&key) {
for p in privileges {
entry.privileges.remove(p);
}
entry.privileges.is_empty()
} else {
false
};
if now_empty {
g.grants.remove(&key);
}
}
Change::SetDefaultPrivilege {
owner,
schema,
on_type,
grantee,
privileges,
} => {
let key = DefaultPrivKey {
owner: owner.clone(),
schema: schema.clone(),
on_type: *on_type,
grantee: grantee.clone(),
};
let entry = g
.default_privileges
.entry(key)
.or_insert_with(|| DefaultPrivState {
privileges: BTreeSet::new(),
});
for p in privileges {
entry.privileges.insert(*p);
}
}
Change::RevokeDefaultPrivilege {
owner,
schema,
on_type,
grantee,
privileges,
} => {
let key = DefaultPrivKey {
owner: owner.clone(),
schema: schema.clone(),
on_type: *on_type,
grantee: grantee.clone(),
};
let now_empty = if let Some(entry) = g.default_privileges.get_mut(&key) {
for p in privileges {
entry.privileges.remove(p);
}
entry.privileges.is_empty()
} else {
false
};
if now_empty {
g.default_privileges.remove(&key);
}
}
Change::AddMember {
role,
member,
inherit,
admin,
} => {
g.memberships
.retain(|e| !(e.role == *role && e.member == *member));
g.memberships.insert(MembershipEdge {
role: role.clone(),
member: member.clone(),
inherit: *inherit,
admin: *admin,
});
}
Change::RemoveMember { role, member } => {
g.memberships
.retain(|e| !(e.role == *role && e.member == *member));
}
Change::SetPassword { .. }
| Change::ReassignOwned { .. }
| Change::DropOwned { .. }
| Change::TerminateSessions { .. } => {
panic!("diff() should never emit {change:?}");
}
}
}
g
}
fn graph_mismatch(got: &RoleGraph, want: &RoleGraph) -> Option<String> {
if got.roles != want.roles {
return Some(format!(
"roles differ:\n got {:#?}\n want {:#?}",
got.roles, want.roles
));
}
if got.schemas != want.schemas {
return Some(format!(
"schemas differ:\n got {:#?}\n want {:#?}",
got.schemas, want.schemas
));
}
if got.grants != want.grants {
return Some(format!(
"grants differ:\n got {:#?}\n want {:#?}",
got.grants, want.grants
));
}
if got.default_privileges != want.default_privileges {
return Some(format!(
"default_privileges differ:\n got {:#?}\n want {:#?}",
got.default_privileges, want.default_privileges
));
}
if got.memberships != want.memberships {
return Some(format!(
"memberships differ:\n got {:#?}\n want {:#?}",
got.memberships, want.memberships
));
}
None
}
const ITERATIONS: usize = 200;
#[test]
fn self_diff_is_empty() {
let mut outer = Rng::new(0x5E1F_5E1F);
for _ in 0..ITERATIONS {
let seed = outer.next_u64();
let mut rng = Rng::new(seed);
let manifest_shaped = gen_graph(&mut rng, true, false);
assert!(
diff(&manifest_shaped, &manifest_shaped).is_empty(),
"seed {seed} [manifest]: self-diff was non-empty: {:#?}",
diff(&manifest_shaped, &manifest_shaped)
);
let messy = gen_graph(&mut rng, true, true);
assert!(
diff(&messy, &messy).is_empty(),
"seed {seed} [messy]: self-diff was non-empty: {:#?}",
diff(&messy, &messy)
);
}
let mut g = RoleGraph::default();
g.schemas.insert(
"s".to_string(),
SchemaState {
owner: None,
owner_privileges: [Privilege::Select, Privilege::Usage].into_iter().collect(),
},
);
assert!(diff(&g, &g).is_empty());
}
fn check_convergence(current: &RoleGraph, desired: &RoleGraph, seed: u64, label: &str) {
let changes = diff(current, desired);
let converged = apply_changes(current, &changes);
if let Some(msg) = graph_mismatch(&converged, desired) {
panic!(
"seed {seed} [{label}]: convergence violated.\n{msg}\n\n--- CURRENT ---\n{current:#?}\n--- CHANGES ---\n{changes:#?}"
);
}
let residual = diff(&converged, desired);
assert!(
residual.is_empty(),
"seed {seed} [{label}]: not idempotent, residual changes: {residual:#?}"
);
}
#[test]
fn convergence_and_idempotence() {
let mut outer = Rng::new(0xC0FF_EE00);
for _ in 0..ITERATIONS {
let seed = outer.next_u64();
let mut rng = Rng::new(seed);
let desired = gen_graph(&mut rng, false, true);
let current = derive_current(&mut rng, &desired);
check_convergence(¤t, &desired, seed, "derive");
let b = gen_graph(&mut rng, false, true);
let mut a = gen_graph(&mut rng, true, true);
a.schemas.retain(|k, _| b.schemas.contains_key(k));
check_convergence(&a, &b, seed, "independent");
}
}
#[test]
fn determinism() {
let mut outer = Rng::new(0xD37E_2711);
for _ in 0..ITERATIONS {
let seed = outer.next_u64();
let mut rng = Rng::new(seed);
let desired = gen_graph(&mut rng, true, true);
let current = derive_current(&mut rng, &desired);
let first = diff(¤t, &desired);
let second = diff(¤t, &desired);
assert_eq!(
first, second,
"seed {seed}: diff was non-deterministic across two calls"
);
}
}
#[test]
fn additive_mode_soundness() {
let mut outer = Rng::new(0xADD1_71DE_u64);
for _ in 0..ITERATIONS {
let seed = outer.next_u64();
let mut rng = Rng::new(seed);
let desired = gen_graph(&mut rng, false, true);
let current = derive_current(&mut rng, &desired);
let filtered = filter_changes(diff(¤t, &desired), ReconciliationMode::Additive);
let created: BTreeSet<&str> = filtered
.iter()
.filter_map(|c| match c {
Change::CreateRole { name, .. } => Some(name.as_str()),
_ => None,
})
.collect();
for change in &filtered {
match change {
Change::Revoke { .. }
| Change::RevokeDefaultPrivilege { .. }
| Change::RemoveMember { .. }
| Change::DropRole { .. }
| Change::AlterSchemaOwner { .. } => {
panic!("seed {seed}: additive mode retained destructive change: {change:?}");
}
Change::AlterRole { name, attributes } => {
assert!(
attributes
.iter()
.all(|a| matches!(a, RoleAttribute::SetConfig(..))),
"seed {seed}: additive AlterRole has non-SetConfig attribute: {change:?}"
);
assert!(
created.contains(name.as_str()),
"seed {seed}: additive AlterRole for a role without a CreateRole in the plan: {change:?}"
);
}
_ => {}
}
}
}
}