use std::collections::{BTreeMap, BTreeSet};
use crate::manifest::{ExpandedManifest, Grant, ObjectType, Privilege, RoleDefinition};
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
pub struct RoleState {
pub login: bool,
pub superuser: bool,
pub createdb: bool,
pub createrole: bool,
pub inherit: bool,
pub replication: bool,
pub bypassrls: bool,
pub connection_limit: i32,
pub comment: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub password_valid_until: Option<String>,
}
impl Default for RoleState {
fn default() -> Self {
Self {
login: false,
superuser: false,
createdb: false,
createrole: false,
inherit: true, replication: false,
bypassrls: false,
connection_limit: -1, comment: None,
password_valid_until: None,
}
}
}
impl RoleState {
pub fn from_definition(definition: &RoleDefinition) -> Self {
let defaults = Self::default();
Self {
login: definition.login.unwrap_or(defaults.login),
superuser: definition.superuser.unwrap_or(defaults.superuser),
createdb: definition.createdb.unwrap_or(defaults.createdb),
createrole: definition.createrole.unwrap_or(defaults.createrole),
inherit: definition.inherit.unwrap_or(defaults.inherit),
replication: definition.replication.unwrap_or(defaults.replication),
bypassrls: definition.bypassrls.unwrap_or(defaults.bypassrls),
connection_limit: definition
.connection_limit
.unwrap_or(defaults.connection_limit),
comment: definition.comment.clone(),
password_valid_until: definition.password_valid_until.clone(),
}
}
pub fn changed_attributes(&self, other: &RoleState) -> Vec<RoleAttribute> {
let mut changes = Vec::new();
if self.login != other.login {
changes.push(RoleAttribute::Login(other.login));
}
if self.superuser != other.superuser {
changes.push(RoleAttribute::Superuser(other.superuser));
}
if self.createdb != other.createdb {
changes.push(RoleAttribute::Createdb(other.createdb));
}
if self.createrole != other.createrole {
changes.push(RoleAttribute::Createrole(other.createrole));
}
if self.inherit != other.inherit {
changes.push(RoleAttribute::Inherit(other.inherit));
}
if self.replication != other.replication {
changes.push(RoleAttribute::Replication(other.replication));
}
if self.bypassrls != other.bypassrls {
changes.push(RoleAttribute::Bypassrls(other.bypassrls));
}
if self.connection_limit != other.connection_limit {
changes.push(RoleAttribute::ConnectionLimit(other.connection_limit));
}
if self.password_valid_until != other.password_valid_until {
changes.push(RoleAttribute::ValidUntil(
other.password_valid_until.clone(),
));
}
changes
}
}
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
pub enum RoleAttribute {
Login(bool),
Superuser(bool),
Createdb(bool),
Createrole(bool),
Inherit(bool),
Replication(bool),
Bypassrls(bool),
ConnectionLimit(i32),
ValidUntil(Option<String>),
}
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, serde::Serialize)]
pub struct GrantKey {
pub role: String,
pub object_type: ObjectType,
pub schema: Option<String>,
pub name: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
pub struct GrantState {
pub privileges: BTreeSet<Privilege>,
}
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, serde::Serialize)]
pub struct DefaultPrivKey {
pub owner: String,
pub schema: String,
pub on_type: ObjectType,
pub grantee: String,
}
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
pub struct DefaultPrivState {
pub privileges: BTreeSet<Privilege>,
}
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, serde::Serialize)]
pub struct MembershipEdge {
pub role: String,
pub member: String,
pub inherit: bool,
pub admin: bool,
}
#[derive(Debug, Clone, Default)]
pub struct RoleGraph {
pub roles: BTreeMap<String, RoleState>,
pub grants: BTreeMap<GrantKey, GrantState>,
pub default_privileges: BTreeMap<DefaultPrivKey, DefaultPrivState>,
pub memberships: BTreeSet<MembershipEdge>,
}
impl RoleGraph {
pub fn from_expanded(
expanded: &ExpandedManifest,
default_owner: Option<&str>,
) -> Result<Self, crate::manifest::ManifestError> {
let mut graph = Self::default();
for role_def in &expanded.roles {
let state = RoleState::from_definition(role_def);
graph.roles.insert(role_def.name.clone(), state);
}
for grant in &expanded.grants {
let key = grant_key_from_manifest(grant);
let entry = graph.grants.entry(key).or_insert_with(|| GrantState {
privileges: BTreeSet::new(),
});
for privilege in &grant.privileges {
entry.privileges.insert(*privilege);
}
}
for default_priv in &expanded.default_privileges {
let owner = default_priv
.owner
.as_deref()
.or(default_owner)
.unwrap_or("postgres")
.to_string();
for grant in &default_priv.grant {
let grantee = grant.role.clone().ok_or_else(|| {
crate::manifest::ManifestError::MissingDefaultPrivilegeRole {
schema: default_priv.schema.clone(),
}
})?;
let key = DefaultPrivKey {
owner: owner.clone(),
schema: default_priv.schema.clone(),
on_type: grant.on_type,
grantee,
};
let entry =
graph
.default_privileges
.entry(key)
.or_insert_with(|| DefaultPrivState {
privileges: BTreeSet::new(),
});
for privilege in &grant.privileges {
entry.privileges.insert(*privilege);
}
}
}
for membership in &expanded.memberships {
for member_spec in &membership.members {
graph.memberships.insert(MembershipEdge {
role: membership.role.clone(),
member: member_spec.name.clone(),
inherit: member_spec.inherit,
admin: member_spec.admin,
});
}
}
Ok(graph)
}
}
fn grant_key_from_manifest(grant: &Grant) -> GrantKey {
GrantKey {
role: grant.role.clone(),
object_type: grant.object.object_type,
schema: grant.object.schema.clone(),
name: grant.object.name.clone(),
}
}
impl PartialOrd for ObjectType {
fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
Some(self.cmp(other))
}
}
impl Ord for ObjectType {
fn cmp(&self, other: &Self) -> std::cmp::Ordering {
self.to_string().cmp(&other.to_string())
}
}
impl PartialOrd for Privilege {
fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
Some(self.cmp(other))
}
}
impl Ord for Privilege {
fn cmp(&self, other: &Self) -> std::cmp::Ordering {
self.to_string().cmp(&other.to_string())
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::manifest::{expand_manifest, parse_manifest};
#[test]
fn role_state_defaults_match_postgres() {
let state = RoleState::default();
assert!(!state.login);
assert!(!state.superuser);
assert!(!state.createdb);
assert!(!state.createrole);
assert!(state.inherit); assert!(!state.replication);
assert!(!state.bypassrls);
assert_eq!(state.connection_limit, -1);
}
#[test]
fn role_state_from_definition_applies_overrides() {
let definition = RoleDefinition {
name: "test".to_string(),
login: Some(true),
superuser: None,
createdb: Some(true),
createrole: None,
inherit: Some(false),
replication: None,
bypassrls: None,
connection_limit: Some(10),
comment: Some("test role".to_string()),
password: None,
password_valid_until: Some("2025-12-31T00:00:00Z".to_string()),
};
let state = RoleState::from_definition(&definition);
assert!(state.login);
assert!(!state.superuser); assert!(state.createdb);
assert!(!state.createrole); assert!(!state.inherit); assert_eq!(state.connection_limit, 10);
assert_eq!(state.comment, Some("test role".to_string()));
assert_eq!(
state.password_valid_until,
Some("2025-12-31T00:00:00Z".to_string())
);
}
#[test]
fn changed_attributes_detects_differences() {
let current = RoleState::default();
let desired = RoleState {
login: true,
connection_limit: 5,
..RoleState::default()
};
let changes = current.changed_attributes(&desired);
assert_eq!(changes.len(), 2);
assert!(changes.contains(&RoleAttribute::Login(true)));
assert!(changes.contains(&RoleAttribute::ConnectionLimit(5)));
}
#[test]
fn changed_attributes_empty_when_equal() {
let state = RoleState::default();
assert!(state.changed_attributes(&state.clone()).is_empty());
}
#[test]
fn role_graph_from_expanded_manifest() {
let yaml = r#"
default_owner: app_owner
profiles:
editor:
grants:
- privileges: [USAGE]
object: { type: schema }
- privileges: [SELECT, INSERT]
object: { type: table, name: "*" }
default_privileges:
- privileges: [SELECT, INSERT]
on_type: table
schemas:
- name: inventory
profiles: [editor]
roles:
- name: analytics
login: true
memberships:
- role: inventory-editor
members:
- name: "user@example.com"
inherit: true
"#;
let manifest = parse_manifest(yaml).unwrap();
let expanded = expand_manifest(&manifest).unwrap();
let graph = RoleGraph::from_expanded(&expanded, manifest.default_owner.as_deref()).unwrap();
assert_eq!(graph.roles.len(), 2);
assert!(graph.roles.contains_key("inventory-editor"));
assert!(graph.roles.contains_key("analytics"));
assert!(!graph.roles["inventory-editor"].login);
assert!(graph.roles["analytics"].login);
assert_eq!(graph.grants.len(), 2);
assert_eq!(graph.default_privileges.len(), 1);
let dp_key = graph.default_privileges.keys().next().unwrap();
assert_eq!(dp_key.owner, "app_owner");
assert_eq!(dp_key.schema, "inventory");
assert_eq!(dp_key.on_type, ObjectType::Table);
assert_eq!(dp_key.grantee, "inventory-editor");
let dp_privs = &graph.default_privileges.values().next().unwrap().privileges;
assert!(dp_privs.contains(&Privilege::Select));
assert!(dp_privs.contains(&Privilege::Insert));
assert_eq!(graph.memberships.len(), 1);
let edge = graph.memberships.iter().next().unwrap();
assert_eq!(edge.role, "inventory-editor");
assert_eq!(edge.member, "user@example.com");
assert!(edge.inherit);
assert!(!edge.admin);
}
#[test]
fn grant_privileges_merge_for_same_target() {
let yaml = r#"
roles:
- name: testrole
grants:
- role: testrole
privileges: [SELECT]
object: { type: table, schema: public, name: "*" }
- role: testrole
privileges: [INSERT, UPDATE]
object: { type: table, schema: public, name: "*" }
"#;
let manifest = parse_manifest(yaml).unwrap();
let expanded = expand_manifest(&manifest).unwrap();
let graph = RoleGraph::from_expanded(&expanded, None).unwrap();
assert_eq!(graph.grants.len(), 1);
let grant_state = graph.grants.values().next().unwrap();
assert_eq!(grant_state.privileges.len(), 3);
assert!(grant_state.privileges.contains(&Privilege::Select));
assert!(grant_state.privileges.contains(&Privilege::Insert));
assert!(grant_state.privileges.contains(&Privilege::Update));
}
}