pub mod cloud;
mod defaults;
mod memberships;
mod privileges;
mod roles;
mod safety;
mod version;
use std::collections::BTreeSet;
use sqlx::PgPool;
use thiserror::Error;
use tracing::debug;
use pgroles_core::model::RoleGraph;
pub use cloud::{CloudProvider, PrivilegeLevel, detect_privilege_level};
pub use defaults::fetch_default_privileges;
pub use memberships::fetch_memberships;
pub use privileges::{fetch_database_privileges, fetch_privileges, fetch_relation_inventory};
pub use roles::fetch_roles;
pub use safety::{
DropRoleSafetyAssessment, DropRoleSafetyIssue, DropRoleSafetyReport, inspect_drop_role_safety,
};
pub use version::{PgVersion, detect_pg_version};
#[derive(Debug, Error)]
pub enum InspectError {
#[error("database query error: {0}")]
Database(#[from] sqlx::Error),
}
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
pub(crate) struct WildcardGrantPattern {
pub role: String,
pub object_type: pgroles_core::manifest::ObjectType,
pub schema: String,
}
#[derive(Debug, Clone)]
pub struct InspectConfig {
pub managed_roles: Vec<String>,
pub managed_schemas: Vec<String>,
pub include_database_privileges: bool,
pub(crate) wildcard_grants: Vec<WildcardGrantPattern>,
}
impl InspectConfig {
pub fn from_expanded(
expanded: &pgroles_core::manifest::ExpandedManifest,
include_database_privileges: bool,
) -> Self {
let mut managed_roles: BTreeSet<String> = BTreeSet::new();
let mut managed_schemas: BTreeSet<String> = BTreeSet::new();
let mut wildcard_grants: BTreeSet<WildcardGrantPattern> = BTreeSet::new();
for role_def in &expanded.roles {
managed_roles.insert(role_def.name.clone());
}
for grant in &expanded.grants {
if let Some(ref schema) = grant.object.schema {
managed_schemas.insert(schema.clone());
}
if grant.object.object_type == pgroles_core::manifest::ObjectType::Schema
&& let Some(ref name) = grant.object.name
{
managed_schemas.insert(name.clone());
}
if grant.object.name.as_deref() == Some("*")
&& !matches!(
grant.object.object_type,
pgroles_core::manifest::ObjectType::Schema
| pgroles_core::manifest::ObjectType::Database
)
&& let Some(schema) = &grant.object.schema
{
wildcard_grants.insert(WildcardGrantPattern {
role: grant.role.clone(),
object_type: grant.object.object_type,
schema: schema.clone(),
});
}
}
for dp in &expanded.default_privileges {
managed_schemas.insert(dp.schema.clone());
}
Self {
managed_roles: managed_roles.into_iter().collect(),
managed_schemas: managed_schemas.into_iter().collect(),
include_database_privileges,
wildcard_grants: wildcard_grants.into_iter().collect(),
}
}
pub fn with_additional_roles<I>(mut self, roles: I) -> Self
where
I: IntoIterator<Item = String>,
{
let mut managed_roles: BTreeSet<String> = self.managed_roles.into_iter().collect();
managed_roles.extend(roles);
self.managed_roles = managed_roles.into_iter().collect();
self
}
}
#[derive(Debug, Clone)]
pub struct InspectAllConfig {
pub exclude_system_roles: bool,
}
pub async fn inspect_all(
pool: &PgPool,
config: &InspectAllConfig,
) -> Result<RoleGraph, InspectError> {
let mut graph = RoleGraph::default();
let _ = config.exclude_system_roles;
let role_rows = fetch_roles(pool, None).await?;
for row in &role_rows {
graph.roles.insert(row.rolname.clone(), row.to_role_state());
}
debug!(found = graph.roles.len(), "roles discovered for generation");
if graph.roles.is_empty() {
return Ok(graph);
}
let role_names: Vec<String> = graph.roles.keys().cloned().collect();
let role_refs: Vec<&str> = role_names.iter().map(|s| s.as_str()).collect();
let schema_rows: Vec<(String,)> = sqlx::query_as(
r#"
SELECT nspname::text FROM pg_namespace
WHERE nspname NOT LIKE 'pg_%'
AND nspname <> 'information_schema'
ORDER BY nspname
"#,
)
.fetch_all(pool)
.await?;
let schema_names: Vec<String> = schema_rows.into_iter().map(|r| r.0).collect();
let schema_refs: Vec<&str> = schema_names.iter().map(|s| s.as_str()).collect();
let membership_rows = fetch_memberships(pool, Some(&role_refs)).await?;
for row in &membership_rows {
graph.memberships.insert(row.to_membership_edge());
}
if !schema_refs.is_empty() {
let privilege_grants = privileges::fetch_privileges_with_wildcards(
pool,
&schema_refs,
&role_refs,
&[], )
.await?;
for (key, state) in privilege_grants {
graph.grants.insert(key, state);
}
}
let db_grants = fetch_database_privileges(pool, &role_refs).await?;
for (key, state) in db_grants {
graph.grants.insert(key, state);
}
if !schema_refs.is_empty() {
let default_privs = fetch_default_privileges(pool, &schema_refs, &role_refs).await?;
for (key, state) in default_privs {
graph.default_privileges.insert(key, state);
}
}
Ok(graph)
}
pub async fn inspect(pool: &PgPool, config: &InspectConfig) -> Result<RoleGraph, InspectError> {
let mut graph = RoleGraph::default();
let role_refs: Vec<&str> = config.managed_roles.iter().map(|s| s.as_str()).collect();
let schema_refs: Vec<&str> = config.managed_schemas.iter().map(|s| s.as_str()).collect();
debug!(
count = role_refs.len(),
"inspecting managed roles from pg_roles"
);
let role_rows = fetch_roles(pool, Some(&role_refs)).await?;
for row in &role_rows {
graph.roles.insert(row.rolname.clone(), row.to_role_state());
}
debug!(found = graph.roles.len(), "roles inspected");
debug!("inspecting memberships from pg_auth_members");
let membership_rows = fetch_memberships(pool, Some(&role_refs)).await?;
for row in &membership_rows {
graph.memberships.insert(row.to_membership_edge());
}
debug!(found = graph.memberships.len(), "memberships inspected");
if !schema_refs.is_empty() {
debug!(
schemas = ?schema_refs,
"inspecting object privileges via aclexplode"
);
let privilege_grants = privileges::fetch_privileges_with_wildcards(
pool,
&schema_refs,
&role_refs,
&config.wildcard_grants,
)
.await?;
for (key, state) in privilege_grants {
graph.grants.insert(key, state);
}
debug!(found = graph.grants.len(), "privilege grants inspected");
}
if config.include_database_privileges {
debug!("inspecting database-level privileges");
let db_grants = fetch_database_privileges(pool, &role_refs).await?;
for (key, state) in db_grants {
graph.grants.insert(key, state);
}
debug!(
total = graph.grants.len(),
"grants after database privileges"
);
}
if !schema_refs.is_empty() {
debug!("inspecting default privileges from pg_default_acl");
let default_privs = fetch_default_privileges(pool, &schema_refs, &role_refs).await?;
for (key, state) in default_privs {
graph.default_privileges.insert(key, state);
}
debug!(
found = graph.default_privileges.len(),
"default privileges inspected"
);
}
Ok(graph)
}
#[cfg(test)]
mod tests {
use super::*;
use pgroles_core::manifest::{expand_manifest, parse_manifest};
#[test]
fn inspect_config_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]
- name: catalog
profiles: [editor]
roles:
- name: analytics
login: true
grants:
- role: analytics
privileges: [CONNECT]
object: { type: database, name: mydb }
"#;
let manifest = parse_manifest(yaml).unwrap();
let expanded = expand_manifest(&manifest).unwrap();
let config = InspectConfig::from_expanded(&expanded, true);
assert_eq!(config.managed_roles.len(), 3);
assert!(
config
.managed_roles
.contains(&"inventory-editor".to_string())
);
assert!(config.managed_roles.contains(&"catalog-editor".to_string()));
assert!(config.managed_roles.contains(&"analytics".to_string()));
assert_eq!(config.managed_schemas.len(), 2);
assert!(config.managed_schemas.contains(&"inventory".to_string()));
assert!(config.managed_schemas.contains(&"catalog".to_string()));
assert!(config.include_database_privileges);
assert_eq!(config.wildcard_grants.len(), 2);
}
#[test]
fn inspect_config_can_include_retired_roles() {
let yaml = r#"
roles:
- name: analytics
"#;
let manifest = parse_manifest(yaml).unwrap();
let expanded = expand_manifest(&manifest).unwrap();
let config = InspectConfig::from_expanded(&expanded, false)
.with_additional_roles(vec!["legacy-app".to_string(), "analytics".to_string()]);
assert_eq!(config.managed_roles.len(), 2);
assert!(config.managed_roles.contains(&"analytics".to_string()));
assert!(config.managed_roles.contains(&"legacy-app".to_string()));
}
}