use std::collections::{BTreeMap, BTreeSet};
use crate::manifest::{Ensure, 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>,
#[serde(skip_serializing_if = "BTreeMap::is_empty", default)]
pub config: BTreeMap<String, 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,
config: BTreeMap::new(),
}
}
}
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(),
config: definition
.config
.iter()
.map(|(name, value)| {
let name = name.to_ascii_lowercase();
let value = if crate::guc::is_list_quote_parameter(&name) {
crate::guc::canonicalize_list_guc_value(&value.0)
} else {
value.0.clone()
};
(name, value)
})
.collect(),
}
}
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(),
));
}
for (parameter, value) in &other.config {
if self.config.get(parameter) != Some(value) {
changes.push(RoleAttribute::SetConfig(parameter.clone(), value.clone()));
}
}
for parameter in self.config.keys() {
if !other.config.contains_key(parameter) {
changes.push(RoleAttribute::ResetConfig(parameter.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>),
SetConfig(String, String),
ResetConfig(String),
}
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
pub struct SchemaState {
#[serde(skip_serializing_if = "Option::is_none")]
pub owner: Option<String>,
#[serde(skip_serializing_if = "BTreeSet::is_empty", default)]
pub owner_privileges: BTreeSet<Privilege>,
}
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub enum Grantee {
Public,
Role(String),
}
pub const PUBLIC_ROLE: &str = "PUBLIC";
impl Grantee {
pub fn parse(s: &str) -> Self {
if s == PUBLIC_ROLE {
Grantee::Public
} else {
Grantee::Role(s.to_string())
}
}
pub fn is_public(&self) -> bool {
matches!(self, Grantee::Public)
}
pub fn as_str(&self) -> &str {
match self {
Grantee::Public => PUBLIC_ROLE,
Grantee::Role(name) => name,
}
}
}
impl std::fmt::Display for Grantee {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(self.as_str())
}
}
impl From<&str> for Grantee {
fn from(s: &str) -> Self {
Grantee::parse(s)
}
}
impl serde::Serialize for Grantee {
fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
serializer.serialize_str(self.as_str())
}
}
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, serde::Serialize)]
#[serde(tag = "type", rename_all = "lowercase")]
pub enum DefaultPrivilegeScope {
Global,
Schema { schema: String },
}
impl DefaultPrivilegeScope {
pub fn schema(&self) -> Option<&str> {
match self {
DefaultPrivilegeScope::Global => None,
DefaultPrivilegeScope::Schema { schema } => Some(schema),
}
}
}
impl std::fmt::Display for DefaultPrivilegeScope {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
DefaultPrivilegeScope::Global => write!(f, "global scope"),
DefaultPrivilegeScope::Schema { schema } => write!(f, "schema \"{schema}\""),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, serde::Serialize)]
pub struct GrantKey {
pub role: Grantee,
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 scope: DefaultPrivilegeScope,
pub on_type: ObjectType,
pub grantee: Grantee,
}
#[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 schemas: BTreeMap<String, SchemaState>,
pub grants: BTreeMap<GrantKey, GrantState>,
pub default_privileges: BTreeMap<DefaultPrivKey, DefaultPrivState>,
pub memberships: BTreeSet<MembershipEdge>,
pub grant_absences: BTreeMap<GrantKey, BTreeSet<Privilege>>,
pub default_privilege_absences: BTreeMap<DefaultPrivKey, BTreeSet<Privilege>>,
}
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 schema in &expanded.schemas {
let owner = schema.owner.clone();
graph.schemas.insert(
schema.name.clone(),
SchemaState {
owner_privileges: owner
.as_deref()
.map(default_schema_owner_privileges)
.unwrap_or_default(),
owner,
},
);
}
for grant in &expanded.grants {
let key = grant_key_from_manifest(grant);
let privileges = match grant.ensure {
Ensure::Present => {
&mut graph
.grants
.entry(key)
.or_insert_with(|| GrantState {
privileges: BTreeSet::new(),
})
.privileges
}
Ensure::Absent => graph.grant_absences.entry(key).or_default(),
};
for privilege in &grant.privileges {
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();
let scope = default_priv.resolved_scope()?;
for grant in &default_priv.grant {
let grantee = grant.role.as_deref().map(Grantee::parse).ok_or_else(|| {
crate::manifest::ManifestError::MissingDefaultPrivilegeRole {
scope: scope.to_string(),
}
})?;
let key = DefaultPrivKey {
owner: owner.clone(),
scope: scope.clone(),
on_type: grant.on_type,
grantee,
};
let privileges = match grant.ensure {
Ensure::Present => {
&mut graph
.default_privileges
.entry(key)
.or_insert_with(|| DefaultPrivState {
privileges: BTreeSet::new(),
})
.privileges
}
Ensure::Absent => graph.default_privilege_absences.entry(key).or_default(),
};
for privilege in &grant.privileges {
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: Grantee::parse(&grant.role),
object_type: grant.object.object_type,
schema: grant.object.schema.clone(),
name: grant.object.name.clone(),
}
}
pub fn default_schema_owner_privileges(_owner: &str) -> BTreeSet<Privilege> {
[Privilege::Create, Privilege::Usage].into_iter().collect()
}
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(),
external: false,
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()),
config: Default::default(),
};
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 changed_attributes_detects_config_set_change_and_reset() {
let current = RoleState {
config: [
("role".to_string(), "combined".to_string()),
("statement_timeout".to_string(), "30s".to_string()),
]
.into_iter()
.collect(),
..RoleState::default()
};
let desired = RoleState {
config: [
("role".to_string(), "combined".to_string()),
("search_path".to_string(), "app".to_string()),
]
.into_iter()
.collect(),
..RoleState::default()
};
let changes = current.changed_attributes(&desired);
assert_eq!(changes.len(), 2);
assert!(changes.contains(&RoleAttribute::SetConfig(
"search_path".to_string(),
"app".to_string()
)));
assert!(changes.contains(&RoleAttribute::ResetConfig("statement_timeout".to_string())));
assert!(
!changes
.iter()
.any(|c| matches!(c, RoleAttribute::SetConfig(p, _) if p == "role"))
);
}
#[test]
fn profile_config_flows_through_to_generated_role_state() {
let yaml = r#"
profiles:
editor:
login: true
config:
search_path: "{schema}"
statement_timeout: "30s"
schemas:
- name: inventory
profiles: [editor]
"#;
let manifest = parse_manifest(yaml).unwrap();
let expanded = expand_manifest(&manifest).unwrap();
let graph = RoleGraph::from_expanded(&expanded, None).unwrap();
let role = graph
.roles
.get("inventory-editor")
.expect("generated role should be present");
assert_eq!(
role.config.get("search_path").map(String::as_str),
Some("inventory")
);
assert_eq!(
role.config.get("statement_timeout").map(String::as_str),
Some("30s")
);
}
#[test]
fn from_definition_lowercases_config_parameter_names() {
let yaml = r#"
roles:
- name: blue
login: true
config:
Role: combined
statement_timeout: "30000"
"#;
let manifest = parse_manifest(yaml).unwrap();
let graph = RoleGraph::from_expanded(&expand_manifest(&manifest).unwrap(), None).unwrap();
let config = &graph.roles["blue"].config;
assert_eq!(config.get("role").map(String::as_str), Some("combined"));
assert_eq!(
config.get("statement_timeout").map(String::as_str),
Some("30000")
);
}
#[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_eq!(graph.schemas.len(), 1);
assert_eq!(
graph.schemas["inventory"].owner.as_deref(),
Some("app_owner")
);
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.scope,
DefaultPrivilegeScope::Schema {
schema: "inventory".to_string()
}
);
assert_eq!(dp_key.on_type, ObjectType::Table);
assert_eq!(dp_key.grantee.as_str(), "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));
}
}