use sqlx::PgPool;
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum CloudProvider {
AwsRds,
GcpCloudSql,
GcpAlloyDb,
AzureFlexible,
Unknown,
}
impl std::fmt::Display for CloudProvider {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
CloudProvider::AwsRds => write!(f, "AWS RDS/Aurora"),
CloudProvider::GcpCloudSql => write!(f, "Google Cloud SQL"),
CloudProvider::GcpAlloyDb => write!(f, "Google AlloyDB"),
CloudProvider::AzureFlexible => write!(f, "Azure Flexible Server"),
CloudProvider::Unknown => write!(f, "unknown"),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum PrivilegeLevel {
Superuser,
CloudSuperuser(CloudProvider),
Regular,
}
impl PrivilegeLevel {
pub fn can_grant_superuser(&self) -> bool {
matches!(self, PrivilegeLevel::Superuser)
}
pub fn can_manage_roles(&self) -> bool {
!matches!(self, PrivilegeLevel::Regular)
}
pub fn can_set_bypassrls(&self) -> bool {
matches!(self, PrivilegeLevel::Superuser)
}
pub fn can_set_replication(&self) -> bool {
matches!(self, PrivilegeLevel::Superuser)
}
pub fn unsupported_attributes(&self) -> Vec<&'static str> {
match self {
PrivilegeLevel::Superuser => vec![],
PrivilegeLevel::CloudSuperuser(_) => {
vec!["SUPERUSER", "REPLICATION", "BYPASSRLS"]
}
PrivilegeLevel::Regular => {
vec![
"SUPERUSER",
"CREATEDB",
"CREATEROLE",
"REPLICATION",
"BYPASSRLS",
]
}
}
}
}
impl std::fmt::Display for PrivilegeLevel {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
PrivilegeLevel::Superuser => write!(f, "superuser"),
PrivilegeLevel::CloudSuperuser(provider) => {
write!(f, "cloud superuser ({provider})")
}
PrivilegeLevel::Regular => write!(f, "regular user"),
}
}
}
pub async fn detect_privilege_level(pool: &PgPool) -> Result<PrivilegeLevel, sqlx::Error> {
let is_superuser: (bool,) =
sqlx::query_as("SELECT rolsuper FROM pg_roles WHERE rolname = current_user")
.fetch_one(pool)
.await?;
if is_superuser.0 {
return Ok(PrivilegeLevel::Superuser);
}
let cloud_roles: Vec<(String,)> = sqlx::query_as(
r#"
SELECT gr.rolname::text
FROM pg_auth_members m
JOIN pg_roles gr ON gr.oid = m.roleid
JOIN pg_roles mr ON mr.oid = m.member
WHERE mr.rolname = current_user
AND gr.rolname IN ('rds_superuser', 'cloudsqlsuperuser', 'alloydbsuperuser', 'azure_pg_admin')
"#,
)
.fetch_all(pool)
.await?;
for (role_name,) in &cloud_roles {
match role_name.as_str() {
"rds_superuser" => return Ok(PrivilegeLevel::CloudSuperuser(CloudProvider::AwsRds)),
"cloudsqlsuperuser" => {
return Ok(PrivilegeLevel::CloudSuperuser(CloudProvider::GcpCloudSql));
}
"alloydbsuperuser" => {
return Ok(PrivilegeLevel::CloudSuperuser(CloudProvider::GcpAlloyDb));
}
"azure_pg_admin" => {
return Ok(PrivilegeLevel::CloudSuperuser(CloudProvider::AzureFlexible));
}
_ => {}
}
}
Ok(PrivilegeLevel::Regular)
}
pub fn validate_changes_for_privilege_level(
changes: &[pgroles_core::diff::Change],
level: &PrivilegeLevel,
) -> Vec<String> {
use pgroles_core::diff::Change;
use pgroles_core::model::RoleAttribute;
if matches!(level, PrivilegeLevel::Superuser) {
return vec![];
}
let mut warnings = Vec::new();
let unsupported = level.unsupported_attributes();
for change in changes {
match change {
Change::CreateRole { name, state } => {
if state.superuser && unsupported.contains(&"SUPERUSER") {
warnings.push(format!(
"Cannot create role \"{name}\" with SUPERUSER — {level} lacks this privilege"
));
}
if state.replication && unsupported.contains(&"REPLICATION") {
warnings.push(format!(
"Cannot create role \"{name}\" with REPLICATION — {level} lacks this privilege"
));
}
if state.bypassrls && unsupported.contains(&"BYPASSRLS") {
warnings.push(format!(
"Cannot create role \"{name}\" with BYPASSRLS — {level} lacks this privilege"
));
}
}
Change::CreateSchema { .. } | Change::AlterSchemaOwner { .. } => {}
Change::AlterRole { name, attributes } => {
for attr in attributes {
let attr_name = match attr {
RoleAttribute::Superuser(true) => Some("SUPERUSER"),
RoleAttribute::Replication(true) => Some("REPLICATION"),
RoleAttribute::Bypassrls(true) => Some("BYPASSRLS"),
_ => None,
};
if let Some(attr_name) = attr_name
&& unsupported.contains(&attr_name)
{
warnings.push(format!(
"Cannot alter role \"{name}\" to set {attr_name} — {level} lacks this privilege"
));
}
}
}
_ => {}
}
}
warnings
}
#[cfg(test)]
mod tests {
use super::*;
use pgroles_core::diff::Change;
use pgroles_core::model::{RoleAttribute, RoleState};
#[test]
fn superuser_has_no_unsupported_attrs() {
assert!(
PrivilegeLevel::Superuser
.unsupported_attributes()
.is_empty()
);
}
#[test]
fn cloud_superuser_cannot_grant_superuser() {
let level = PrivilegeLevel::CloudSuperuser(CloudProvider::AwsRds);
assert!(!level.can_grant_superuser());
assert!(level.can_manage_roles());
assert!(level.unsupported_attributes().contains(&"SUPERUSER"));
}
#[test]
fn regular_user_limited_capabilities() {
let level = PrivilegeLevel::Regular;
assert!(!level.can_grant_superuser());
assert!(!level.can_manage_roles());
assert!(level.unsupported_attributes().len() >= 4);
}
#[test]
fn validate_warns_on_superuser_creation_with_cloud_provider() {
let level = PrivilegeLevel::CloudSuperuser(CloudProvider::GcpCloudSql);
let changes = vec![Change::CreateRole {
name: "admin".to_string(),
state: RoleState {
superuser: true,
..RoleState::default()
},
}];
let warnings = validate_changes_for_privilege_level(&changes, &level);
assert_eq!(warnings.len(), 1);
assert!(warnings[0].contains("SUPERUSER"));
assert!(warnings[0].contains("cloud superuser"));
}
#[test]
fn validate_warns_on_alter_bypassrls_with_cloud_provider() {
let level = PrivilegeLevel::CloudSuperuser(CloudProvider::AwsRds);
let changes = vec![Change::AlterRole {
name: "service".to_string(),
attributes: vec![RoleAttribute::Bypassrls(true)],
}];
let warnings = validate_changes_for_privilege_level(&changes, &level);
assert_eq!(warnings.len(), 1);
assert!(warnings[0].contains("BYPASSRLS"));
}
#[test]
fn validate_no_warnings_for_superuser() {
let level = PrivilegeLevel::Superuser;
let changes = vec![Change::CreateRole {
name: "admin".to_string(),
state: RoleState {
superuser: true,
bypassrls: true,
replication: true,
..RoleState::default()
},
}];
let warnings = validate_changes_for_privilege_level(&changes, &level);
assert!(warnings.is_empty());
}
#[test]
fn display_formats() {
assert_eq!(
PrivilegeLevel::CloudSuperuser(CloudProvider::AwsRds).to_string(),
"cloud superuser (AWS RDS/Aurora)"
);
assert_eq!(
PrivilegeLevel::CloudSuperuser(CloudProvider::GcpAlloyDb).to_string(),
"cloud superuser (Google AlloyDB)"
);
assert_eq!(PrivilegeLevel::Superuser.to_string(), "superuser");
assert_eq!(PrivilegeLevel::Regular.to_string(), "regular user");
}
}