pub mod cloud;
mod defaults;
mod memberships;
mod privileges;
mod public_grants;
mod roles;
mod safety;
mod version;
use std::collections::{BTreeMap, BTreeSet};
use sqlx::PgPool;
use thiserror::Error;
use tracing::debug;
use pgroles_core::manifest::Privilege;
use pgroles_core::model::RoleGraph;
use pgroles_core::ownership::ManagedScope;
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_object_inventory, fetch_privileges, fetch_relation_inventory,
};
pub use public_grants::{PublicGrants, fetch_public_grants, format_public_grants};
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,
pub privileges: std::collections::BTreeSet<pgroles_core::manifest::Privilege>,
}
#[derive(Debug, Clone)]
pub struct InspectConfig {
pub managed_roles: Vec<String>,
pub managed_schemas: Vec<String>,
pub privilege_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();
type WildcardKey = (String, pgroles_core::manifest::ObjectType, String);
let mut wildcard_map: BTreeMap<WildcardKey, BTreeSet<pgroles_core::manifest::Privilege>> =
BTreeMap::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
{
let key = (grant.role.clone(), grant.object.object_type, schema.clone());
wildcard_map
.entry(key)
.or_default()
.extend(grant.privileges.iter().copied());
}
}
for dp in &expanded.default_privileges {
managed_schemas.insert(dp.schema.clone());
}
for schema in &expanded.schemas {
managed_schemas.insert(schema.name.clone());
}
Self {
managed_roles: managed_roles.into_iter().collect(),
managed_schemas: managed_schemas.clone().into_iter().collect(),
privilege_schemas: managed_schemas.into_iter().collect(),
include_database_privileges,
wildcard_grants: wildcard_map
.into_iter()
.map(
|((role, object_type, schema), privileges)| WildcardGrantPattern {
role,
object_type,
schema,
privileges,
},
)
.collect(),
}
}
pub fn from_managed_scope(
scope: &ManagedScope,
expanded: &pgroles_core::manifest::ExpandedManifest,
include_database_privileges: bool,
) -> Self {
let base = Self::from_expanded(expanded, include_database_privileges);
Self {
managed_roles: scope.roles.iter().cloned().collect(),
managed_schemas: scope.schemas.keys().cloned().collect(),
privilege_schemas: scope
.schemas
.iter()
.filter_map(|(schema, managed)| managed.bindings.then_some(schema.clone()))
.collect(),
include_database_privileges,
wildcard_grants: base
.wildcard_grants
.into_iter()
.filter(|pattern| {
scope
.schemas
.get(&pattern.schema)
.is_some_and(|managed| managed.bindings)
})
.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");
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());
}
let schema_rows = fetch_schemas(pool, &schema_refs).await?;
for row in &schema_rows {
graph.schemas.insert(
row.schema_name.clone(),
pgroles_core::model::SchemaState {
owner: Some(row.owner_name.clone()),
owner_privileges: row.owner_privileges(),
},
);
}
if graph.roles.is_empty() && graph.schemas.is_empty() {
return Ok(graph);
}
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);
}
remove_redundant_schema_owner_grants(&mut graph);
}
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();
let privilege_schema_refs: Vec<&str> = config
.privilege_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 schemas from pg_namespace");
let schema_rows = fetch_schemas(pool, &schema_refs).await?;
for row in &schema_rows {
graph.schemas.insert(
row.schema_name.clone(),
pgroles_core::model::SchemaState {
owner: Some(row.owner_name.clone()),
owner_privileges: row.owner_privileges(),
},
);
}
debug!(found = graph.schemas.len(), "schemas inspected");
}
if !privilege_schema_refs.is_empty() {
debug!(
schemas = ?privilege_schema_refs,
"inspecting object privileges via aclexplode"
);
let privilege_grants = privileges::fetch_privileges_with_wildcards(
pool,
&privilege_schema_refs,
&role_refs,
&config.wildcard_grants,
)
.await?;
for (key, state) in privilege_grants {
graph.grants.insert(key, state);
}
remove_redundant_schema_owner_grants(&mut graph);
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 !privilege_schema_refs.is_empty() {
debug!("inspecting default privileges from pg_default_acl");
let default_privs =
fetch_default_privileges(pool, &privilege_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)
}
pub async fn fetch_existing_schemas(
pool: &PgPool,
) -> Result<std::collections::BTreeSet<String>, InspectError> {
let rows: Vec<(String,)> = sqlx::query_as(
r#"
SELECT nspname::text FROM pg_namespace
WHERE nspname NOT LIKE 'pg_%'
AND nspname <> 'information_schema'
"#,
)
.fetch_all(pool)
.await?;
Ok(rows.into_iter().map(|r| r.0).collect())
}
#[derive(Debug, sqlx::FromRow)]
pub struct SchemaRow {
pub schema_name: String,
pub owner_name: String,
pub owner_has_create: bool,
pub owner_has_usage: bool,
}
impl SchemaRow {
fn owner_privileges(&self) -> BTreeSet<Privilege> {
let mut privileges = BTreeSet::new();
if self.owner_has_create {
privileges.insert(Privilege::Create);
}
if self.owner_has_usage {
privileges.insert(Privilege::Usage);
}
privileges
}
}
pub async fn fetch_schemas(
pool: &PgPool,
managed_schemas: &[&str],
) -> Result<Vec<SchemaRow>, InspectError> {
let rows = sqlx::query_as::<_, SchemaRow>(
r#"
SELECT
n.nspname AS schema_name,
owner_role.rolname AS owner_name,
has_schema_privilege(owner_role.rolname, n.nspname, 'CREATE') AS owner_has_create,
has_schema_privilege(owner_role.rolname, n.nspname, 'USAGE') AS owner_has_usage
FROM pg_namespace n
JOIN pg_roles owner_role ON owner_role.oid = n.nspowner
WHERE n.nspname = ANY($1)
ORDER BY n.nspname
"#,
)
.bind(managed_schemas)
.fetch_all(pool)
.await?;
Ok(rows)
}
fn remove_redundant_schema_owner_grants(graph: &mut RoleGraph) {
graph.grants.retain(|key, _| {
if key.object_type != pgroles_core::manifest::ObjectType::Schema {
return true;
}
let Some(schema_name) = key.name.as_deref() else {
return true;
};
let Some(schema_state) = graph.schemas.get(schema_name) else {
return true;
};
schema_state.owner.as_deref() != Some(key.role.as_str())
});
}
#[cfg(test)]
mod tests {
use super::*;
use pgroles_core::manifest::{expand_manifest, parse_manifest};
use pgroles_core::ownership::ManagedSchemaScope;
#[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.privilege_schemas.len(), 2);
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()));
}
#[test]
fn inspect_config_from_managed_scope_limits_privileges_to_binding_schemas() {
let yaml = r#"
default_owner: app_owner
profiles:
editor:
grants:
- privileges: [USAGE]
object: { type: schema }
schemas:
- name: inventory
owner: app_owner
profiles: [editor]
roles:
- name: app_owner
login: false
"#;
let manifest = parse_manifest(yaml).unwrap();
let expanded = expand_manifest(&manifest).unwrap();
let scope = ManagedScope {
roles: BTreeSet::from(["app_owner".to_string(), "inventory-editor".to_string()]),
schemas: BTreeMap::from([(
"inventory".to_string(),
ManagedSchemaScope {
owner: true,
bindings: false,
},
)]),
};
let config = InspectConfig::from_managed_scope(&scope, &expanded, false);
assert_eq!(config.managed_schemas, vec!["inventory".to_string()]);
assert!(config.privilege_schemas.is_empty());
assert!(config.wildcard_grants.is_empty());
}
#[test]
fn remove_redundant_schema_owner_grants_keeps_only_non_owner_schema_grants() {
let mut graph = RoleGraph::default();
graph.schemas.insert(
"inventory".to_string(),
pgroles_core::model::SchemaState {
owner: Some("inventory_owner".to_string()),
owner_privileges: [pgroles_core::manifest::Privilege::Create]
.into_iter()
.collect(),
},
);
graph.grants.insert(
pgroles_core::model::GrantKey {
role: "inventory_owner".to_string(),
object_type: pgroles_core::manifest::ObjectType::Schema,
schema: None,
name: Some("inventory".to_string()),
},
pgroles_core::model::GrantState {
privileges: [pgroles_core::manifest::Privilege::Usage]
.into_iter()
.collect(),
},
);
graph.grants.insert(
pgroles_core::model::GrantKey {
role: "inventory_reader".to_string(),
object_type: pgroles_core::manifest::ObjectType::Schema,
schema: None,
name: Some("inventory".to_string()),
},
pgroles_core::model::GrantState {
privileges: [pgroles_core::manifest::Privilege::Usage]
.into_iter()
.collect(),
},
);
remove_redundant_schema_owner_grants(&mut graph);
assert_eq!(graph.grants.len(), 1);
assert!(
graph
.grants
.keys()
.all(|key| key.role == "inventory_reader")
);
}
}