1pub mod cloud;
8mod defaults;
9mod memberships;
10mod privileges;
11mod public_grants;
12mod roles;
13mod safety;
14mod version;
15
16use std::collections::{BTreeMap, BTreeSet};
17use std::time::{Duration, Instant};
18
19use sqlx::PgPool;
20use thiserror::Error;
21use tracing::debug;
22
23use pgroles_core::manifest::{ObjectType, Privilege};
24use pgroles_core::model::RoleGraph;
25use pgroles_core::ownership::ManagedScope;
26
27pub use cloud::{CloudProvider, PrivilegeLevel, detect_privilege_level};
29pub use defaults::fetch_default_privileges;
30pub use memberships::fetch_memberships;
31pub use privileges::{
32 fetch_database_privileges, fetch_object_inventory, fetch_privileges, fetch_relation_inventory,
33};
34pub use public_grants::{PublicGrants, fetch_public_grants, format_public_grants};
35pub use roles::fetch_roles;
36pub use safety::{
37 DropRoleSafetyAssessment, DropRoleSafetyIssue, DropRoleSafetyReport, inspect_drop_role_safety,
38};
39pub use version::{PgVersion, detect_pg_version};
40
41#[derive(Debug, Error)]
46pub enum InspectError {
47 #[error("database query error: {0}")]
48 Database(#[from] sqlx::Error),
49}
50
51#[derive(Debug, Clone, Default, PartialEq, Eq)]
52pub struct InspectionDiagnostics {
53 pub unsatisfiable_wildcard_grants: Vec<UnsatisfiableWildcardGrant>,
54}
55
56impl InspectionDiagnostics {
57 pub fn is_empty(&self) -> bool {
58 self.unsatisfiable_wildcard_grants.is_empty()
59 }
60}
61
62impl std::fmt::Display for InspectionDiagnostics {
63 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
64 for (index, diagnostic) in self.unsatisfiable_wildcard_grants.iter().enumerate() {
65 if index > 0 {
66 writeln!(f)?;
67 }
68 write!(f, "{diagnostic}")?;
69 }
70 Ok(())
71 }
72}
73
74#[derive(Debug, Clone, PartialEq, Eq)]
75pub struct UnsatisfiableWildcardGrant {
76 pub role: String,
77 pub object_type: ObjectType,
78 pub schema: String,
79 pub privileges: std::collections::BTreeSet<Privilege>,
80 pub executor: String,
81 pub skipped_count: usize,
82 pub examples: Vec<UnsatisfiableWildcardObject>,
83}
84
85impl std::fmt::Display for UnsatisfiableWildcardGrant {
86 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
87 let privileges = self
88 .privileges
89 .iter()
90 .map(ToString::to_string)
91 .collect::<Vec<_>>()
92 .join(", ");
93 let examples = self
94 .examples
95 .iter()
96 .map(ToString::to_string)
97 .collect::<Vec<_>>()
98 .join("; ");
99 write!(
100 f,
101 "UnsatisfiableWildcardGrant: cannot fully satisfy wildcard grant \
102 {privileges} ON {} * IN SCHEMA \"{}\" TO \"{}\" as executor \"{}\"; \
103 {} matching object(s) are missing the desired privilege and are not grantable",
104 self.object_type, self.schema, self.role, self.executor, self.skipped_count
105 )?;
106 if !examples.is_empty() {
107 write!(f, " (examples: {examples})")?;
108 }
109 Ok(())
110 }
111}
112
113#[derive(Debug, Clone, PartialEq, Eq)]
114pub struct UnsatisfiableWildcardObject {
115 pub name: String,
116 pub owner: String,
117 pub privileges: std::collections::BTreeSet<Privilege>,
118}
119
120impl std::fmt::Display for UnsatisfiableWildcardObject {
121 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
122 let privileges = self
123 .privileges
124 .iter()
125 .map(ToString::to_string)
126 .collect::<Vec<_>>()
127 .join(", ");
128 write!(
129 f,
130 "\"{}\" owned by \"{}\" missing [{}]",
131 self.name, self.owner, privileges
132 )
133 }
134}
135
136#[derive(Debug, Clone)]
137pub struct InspectionResult {
138 pub graph: RoleGraph,
139 pub diagnostics: InspectionDiagnostics,
140 pub stats: InspectionStats,
141}
142
143#[derive(Debug, Clone, Default, PartialEq, Eq)]
144pub struct InspectionStats {
145 pub roles: usize,
146 pub memberships: usize,
147 pub schemas: usize,
148 pub grants: usize,
149 pub default_privileges: usize,
150 pub phase_durations: BTreeMap<&'static str, Duration>,
151 pub wildcard: WildcardInspectionStats,
152}
153
154impl InspectionStats {
155 fn record_phase(&mut self, phase: &'static str, duration: Duration) {
156 self.phase_durations.insert(phase, duration);
157 }
158}
159
160#[derive(Debug, Clone, Default, PartialEq, Eq)]
161pub struct WildcardInspectionStats {
162 pub configured_grants: usize,
163 pub configured_scopes: usize,
164 pub inventory_objects: usize,
165 pub unsatisfied_grants: usize,
166 pub unsatisfied_scopes: usize,
167 pub grantability_queries: usize,
168 pub grantability_objects: usize,
169}
170
171#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
172pub(crate) struct WildcardGrantPattern {
173 pub role: String,
174 pub object_type: pgroles_core::manifest::ObjectType,
175 pub schema: String,
176 pub privileges: std::collections::BTreeSet<pgroles_core::manifest::Privilege>,
180}
181
182#[derive(Debug, Clone)]
191pub struct InspectConfig {
192 pub managed_roles: Vec<String>,
195
196 pub managed_schemas: Vec<String>,
198
199 pub privilege_schemas: Vec<String>,
201
202 pub include_database_privileges: bool,
205
206 pub(crate) wildcard_grants: Vec<WildcardGrantPattern>,
208}
209
210impl InspectConfig {
211 pub fn from_expanded(
214 expanded: &pgroles_core::manifest::ExpandedManifest,
215 include_database_privileges: bool,
216 ) -> Self {
217 let mut managed_roles: BTreeSet<String> = BTreeSet::new();
218 let mut managed_schemas: BTreeSet<String> = BTreeSet::new();
219 type WildcardKey = (String, pgroles_core::manifest::ObjectType, String);
221 let mut wildcard_map: BTreeMap<WildcardKey, BTreeSet<pgroles_core::manifest::Privilege>> =
222 BTreeMap::new();
223
224 for role_def in &expanded.roles {
226 managed_roles.insert(role_def.name.clone());
227 }
228
229 for grant in &expanded.grants {
231 if let Some(ref schema) = grant.object.schema {
232 managed_schemas.insert(schema.clone());
233 }
234 if grant.object.object_type == pgroles_core::manifest::ObjectType::Schema
236 && let Some(ref name) = grant.object.name
237 {
238 managed_schemas.insert(name.clone());
239 }
240 if grant.object.name.as_deref() == Some("*")
241 && !matches!(
242 grant.object.object_type,
243 pgroles_core::manifest::ObjectType::Schema
244 | pgroles_core::manifest::ObjectType::Database
245 )
246 && let Some(schema) = &grant.object.schema
247 {
248 let key = (grant.role.clone(), grant.object.object_type, schema.clone());
249 wildcard_map
250 .entry(key)
251 .or_default()
252 .extend(grant.privileges.iter().copied());
253 }
254 }
255
256 for dp in &expanded.default_privileges {
258 managed_schemas.insert(dp.schema.clone());
259 }
260
261 for schema in &expanded.schemas {
262 managed_schemas.insert(schema.name.clone());
263 }
264
265 Self {
266 managed_roles: managed_roles.into_iter().collect(),
267 managed_schemas: managed_schemas.clone().into_iter().collect(),
268 privilege_schemas: managed_schemas.into_iter().collect(),
269 include_database_privileges,
270 wildcard_grants: wildcard_map
271 .into_iter()
272 .map(
273 |((role, object_type, schema), privileges)| WildcardGrantPattern {
274 role,
275 object_type,
276 schema,
277 privileges,
278 },
279 )
280 .collect(),
281 }
282 }
283
284 pub fn from_managed_scope(
288 scope: &ManagedScope,
289 expanded: &pgroles_core::manifest::ExpandedManifest,
290 include_database_privileges: bool,
291 ) -> Self {
292 let base = Self::from_expanded(expanded, include_database_privileges);
293
294 Self {
295 managed_roles: scope.roles.iter().cloned().collect(),
296 managed_schemas: scope.schemas.keys().cloned().collect(),
297 privilege_schemas: scope
298 .schemas
299 .iter()
300 .filter_map(|(schema, managed)| managed.bindings.then_some(schema.clone()))
301 .collect(),
302 include_database_privileges,
303 wildcard_grants: base
304 .wildcard_grants
305 .into_iter()
306 .filter(|pattern| {
307 scope
308 .schemas
309 .get(&pattern.schema)
310 .is_some_and(|managed| managed.bindings)
311 })
312 .collect(),
313 }
314 }
315
316 pub fn with_additional_roles<I>(mut self, roles: I) -> Self
318 where
319 I: IntoIterator<Item = String>,
320 {
321 let mut managed_roles: BTreeSet<String> = self.managed_roles.into_iter().collect();
322 managed_roles.extend(roles);
323 self.managed_roles = managed_roles.into_iter().collect();
324 self
325 }
326}
327
328#[derive(Debug, Clone)]
334pub struct InspectAllConfig {
335 pub exclude_system_roles: bool,
337}
338
339pub async fn inspect_all(
345 pool: &PgPool,
346 config: &InspectAllConfig,
347) -> Result<RoleGraph, InspectError> {
348 let mut graph = RoleGraph::default();
349
350 let _ = config.exclude_system_roles;
354 let role_rows = fetch_roles(pool, None).await?;
355 for row in &role_rows {
356 graph.roles.insert(row.rolname.clone(), row.to_role_state());
357 }
358 debug!(found = graph.roles.len(), "roles discovered for generation");
359
360 let role_names: Vec<String> = graph.roles.keys().cloned().collect();
361 let role_refs: Vec<&str> = role_names.iter().map(|s| s.as_str()).collect();
362
363 let schema_rows: Vec<(String,)> = sqlx::query_as(
365 r#"
366 SELECT nspname::text FROM pg_namespace
367 WHERE nspname NOT LIKE 'pg_%'
368 AND nspname <> 'information_schema'
369 ORDER BY nspname
370 "#,
371 )
372 .fetch_all(pool)
373 .await?;
374 let schema_names: Vec<String> = schema_rows.into_iter().map(|r| r.0).collect();
375 let schema_refs: Vec<&str> = schema_names.iter().map(|s| s.as_str()).collect();
376
377 let membership_rows = fetch_memberships(pool, Some(&role_refs)).await?;
379 for row in &membership_rows {
380 graph.memberships.insert(row.to_membership_edge());
381 }
382
383 let schema_rows = fetch_schemas(pool, &schema_refs).await?;
385 for row in &schema_rows {
386 graph.schemas.insert(
387 row.schema_name.clone(),
388 pgroles_core::model::SchemaState {
389 owner: Some(row.owner_name.clone()),
390 owner_privileges: row.owner_privileges(),
391 },
392 );
393 }
394
395 if graph.roles.is_empty() && graph.schemas.is_empty() {
396 return Ok(graph);
397 }
398
399 if !schema_refs.is_empty() {
401 let privilege_grants = privileges::fetch_privileges_with_wildcards(
402 pool,
403 &schema_refs,
404 &role_refs,
405 &[], )
407 .await?
408 .grants;
409 for (key, state) in privilege_grants {
410 graph.grants.insert(key, state);
411 }
412 remove_redundant_schema_owner_grants(&mut graph);
413 }
414
415 let db_grants = fetch_database_privileges(pool, &role_refs).await?;
417 for (key, state) in db_grants {
418 graph.grants.insert(key, state);
419 }
420
421 if !schema_refs.is_empty() {
423 let default_privs = fetch_default_privileges(pool, &schema_refs, &role_refs).await?;
424 for (key, state) in default_privs {
425 graph.default_privileges.insert(key, state);
426 }
427 }
428
429 Ok(graph)
430}
431
432pub async fn inspect(pool: &PgPool, config: &InspectConfig) -> Result<RoleGraph, InspectError> {
437 Ok(inspect_with_diagnostics(pool, config).await?.graph)
438}
439
440pub async fn inspect_with_diagnostics(
443 pool: &PgPool,
444 config: &InspectConfig,
445) -> Result<InspectionResult, InspectError> {
446 let mut graph = RoleGraph::default();
447 let mut diagnostics = InspectionDiagnostics::default();
448 let mut stats = InspectionStats::default();
449
450 let role_refs: Vec<&str> = config.managed_roles.iter().map(|s| s.as_str()).collect();
452 let schema_refs: Vec<&str> = config.managed_schemas.iter().map(|s| s.as_str()).collect();
453 let privilege_schema_refs: Vec<&str> = config
454 .privilege_schemas
455 .iter()
456 .map(|s| s.as_str())
457 .collect();
458
459 debug!(
461 count = role_refs.len(),
462 "inspecting managed roles from pg_roles"
463 );
464 let phase_started_at = Instant::now();
465 let role_rows = fetch_roles(pool, Some(&role_refs)).await?;
466 stats.record_phase("roles", phase_started_at.elapsed());
467 for row in &role_rows {
468 graph.roles.insert(row.rolname.clone(), row.to_role_state());
469 }
470 stats.roles = graph.roles.len();
471 debug!(found = graph.roles.len(), "roles inspected");
472
473 debug!("inspecting memberships from pg_auth_members");
475 let phase_started_at = Instant::now();
476 let membership_rows = fetch_memberships(pool, Some(&role_refs)).await?;
477 stats.record_phase("memberships", phase_started_at.elapsed());
478 for row in &membership_rows {
479 graph.memberships.insert(row.to_membership_edge());
480 }
481 stats.memberships = graph.memberships.len();
486 debug!(found = graph.memberships.len(), "memberships inspected");
487
488 if !schema_refs.is_empty() {
490 debug!(schemas = ?schema_refs, "inspecting schemas from pg_namespace");
491 let phase_started_at = Instant::now();
492 let schema_rows = fetch_schemas(pool, &schema_refs).await?;
493 stats.record_phase("schemas", phase_started_at.elapsed());
494 for row in &schema_rows {
495 graph.schemas.insert(
496 row.schema_name.clone(),
497 pgroles_core::model::SchemaState {
498 owner: Some(row.owner_name.clone()),
499 owner_privileges: row.owner_privileges(),
500 },
501 );
502 }
503 stats.schemas = graph.schemas.len();
504 debug!(found = graph.schemas.len(), "schemas inspected");
505 }
506
507 if !privilege_schema_refs.is_empty() {
509 debug!(
510 schemas = ?privilege_schema_refs,
511 "inspecting object privileges via aclexplode"
512 );
513 let phase_started_at = Instant::now();
514 let privilege_result = privileges::fetch_privileges_with_wildcards(
515 pool,
516 &privilege_schema_refs,
517 &role_refs,
518 &config.wildcard_grants,
519 )
520 .await?;
521 stats.record_phase("object_privileges", phase_started_at.elapsed());
522 stats.wildcard = privilege_result.wildcard_stats;
523 diagnostics
524 .unsatisfiable_wildcard_grants
525 .extend(privilege_result.diagnostics);
526 let privilege_grants = privilege_result.grants;
527 for (key, state) in privilege_grants {
528 graph.grants.insert(key, state);
529 }
530 remove_redundant_schema_owner_grants(&mut graph);
531 stats.grants = graph.grants.len();
532 debug!(found = graph.grants.len(), "privilege grants inspected");
533 }
534
535 if config.include_database_privileges {
537 debug!("inspecting database-level privileges");
538 let phase_started_at = Instant::now();
539 let db_grants = fetch_database_privileges(pool, &role_refs).await?;
540 stats.record_phase("database_privileges", phase_started_at.elapsed());
541 for (key, state) in db_grants {
542 graph.grants.insert(key, state);
543 }
544 stats.grants = graph.grants.len();
545 debug!(
546 total = graph.grants.len(),
547 "grants after database privileges"
548 );
549 }
550
551 if !privilege_schema_refs.is_empty() {
553 debug!("inspecting default privileges from pg_default_acl");
554 let phase_started_at = Instant::now();
555 let default_privs =
556 fetch_default_privileges(pool, &privilege_schema_refs, &role_refs).await?;
557 stats.record_phase("default_privileges", phase_started_at.elapsed());
558 for (key, state) in default_privs {
559 graph.default_privileges.insert(key, state);
560 }
561 stats.default_privileges = graph.default_privileges.len();
562 debug!(
563 found = graph.default_privileges.len(),
564 "default privileges inspected"
565 );
566 }
567
568 Ok(InspectionResult {
569 graph,
570 diagnostics,
571 stats,
572 })
573}
574
575pub async fn fetch_existing_schemas(
584 pool: &PgPool,
585) -> Result<std::collections::BTreeSet<String>, InspectError> {
586 let rows: Vec<(String,)> = sqlx::query_as(
587 r#"
588 SELECT nspname::text FROM pg_namespace
589 WHERE nspname NOT LIKE 'pg_%'
590 AND nspname <> 'information_schema'
591 "#,
592 )
593 .fetch_all(pool)
594 .await?;
595 Ok(rows.into_iter().map(|r| r.0).collect())
596}
597
598#[derive(Debug, sqlx::FromRow)]
599pub struct SchemaRow {
600 pub schema_name: String,
601 pub owner_name: String,
602 pub owner_has_create: bool,
603 pub owner_has_usage: bool,
604}
605
606impl SchemaRow {
607 fn owner_privileges(&self) -> BTreeSet<Privilege> {
608 let mut privileges = BTreeSet::new();
609 if self.owner_has_create {
610 privileges.insert(Privilege::Create);
611 }
612 if self.owner_has_usage {
613 privileges.insert(Privilege::Usage);
614 }
615 privileges
616 }
617}
618
619pub async fn fetch_schemas(
620 pool: &PgPool,
621 managed_schemas: &[&str],
622) -> Result<Vec<SchemaRow>, InspectError> {
623 let rows = sqlx::query_as::<_, SchemaRow>(
624 r#"
625 SELECT
626 n.nspname AS schema_name,
627 owner_role.rolname AS owner_name,
628 has_schema_privilege(owner_role.rolname, n.nspname, 'CREATE') AS owner_has_create,
629 has_schema_privilege(owner_role.rolname, n.nspname, 'USAGE') AS owner_has_usage
630 FROM pg_namespace n
631 JOIN pg_roles owner_role ON owner_role.oid = n.nspowner
632 WHERE n.nspname = ANY($1)
633 ORDER BY n.nspname
634 "#,
635 )
636 .bind(managed_schemas)
637 .fetch_all(pool)
638 .await?;
639 Ok(rows)
640}
641
642fn remove_redundant_schema_owner_grants(graph: &mut RoleGraph) {
643 graph.grants.retain(|key, _| {
647 if key.object_type != pgroles_core::manifest::ObjectType::Schema {
648 return true;
649 }
650
651 let Some(schema_name) = key.name.as_deref() else {
652 return true;
653 };
654
655 let Some(schema_state) = graph.schemas.get(schema_name) else {
656 return true;
657 };
658
659 schema_state.owner.as_deref() != Some(key.role.as_str())
660 });
661}
662
663#[cfg(test)]
668mod tests {
669 use super::*;
670 use pgroles_core::manifest::{expand_manifest, parse_manifest};
671 use pgroles_core::ownership::ManagedSchemaScope;
672
673 #[test]
674 fn inspect_config_from_expanded_manifest() {
675 let yaml = r#"
676default_owner: app_owner
677
678profiles:
679 editor:
680 grants:
681 - privileges: [USAGE]
682 object: { type: schema }
683 - privileges: [SELECT, INSERT]
684 object: { type: table, name: "*" }
685 default_privileges:
686 - privileges: [SELECT, INSERT]
687 on_type: table
688
689schemas:
690 - name: inventory
691 profiles: [editor]
692 - name: catalog
693 profiles: [editor]
694
695roles:
696 - name: analytics
697 login: true
698
699grants:
700 - role: analytics
701 privileges: [CONNECT]
702 object: { type: database, name: mydb }
703"#;
704 let manifest = parse_manifest(yaml).unwrap();
705 let expanded = expand_manifest(&manifest).unwrap();
706 let config = InspectConfig::from_expanded(&expanded, true);
707
708 assert_eq!(config.managed_roles.len(), 3);
710 assert!(
711 config
712 .managed_roles
713 .contains(&"inventory-editor".to_string())
714 );
715 assert!(config.managed_roles.contains(&"catalog-editor".to_string()));
716 assert!(config.managed_roles.contains(&"analytics".to_string()));
717
718 assert_eq!(config.managed_schemas.len(), 2);
720 assert!(config.managed_schemas.contains(&"inventory".to_string()));
721 assert!(config.managed_schemas.contains(&"catalog".to_string()));
722
723 assert!(config.include_database_privileges);
724 assert_eq!(config.privilege_schemas.len(), 2);
725 assert_eq!(config.wildcard_grants.len(), 2);
726 }
727
728 #[test]
729 fn inspect_config_can_include_retired_roles() {
730 let yaml = r#"
731roles:
732 - name: analytics
733"#;
734 let manifest = parse_manifest(yaml).unwrap();
735 let expanded = expand_manifest(&manifest).unwrap();
736 let config = InspectConfig::from_expanded(&expanded, false)
737 .with_additional_roles(vec!["legacy-app".to_string(), "analytics".to_string()]);
738
739 assert_eq!(config.managed_roles.len(), 2);
740 assert!(config.managed_roles.contains(&"analytics".to_string()));
741 assert!(config.managed_roles.contains(&"legacy-app".to_string()));
742 }
743
744 #[test]
745 fn inspect_config_from_managed_scope_limits_privileges_to_binding_schemas() {
746 let yaml = r#"
747default_owner: app_owner
748
749profiles:
750 editor:
751 grants:
752 - privileges: [USAGE]
753 object: { type: schema }
754
755schemas:
756 - name: inventory
757 owner: app_owner
758 profiles: [editor]
759
760roles:
761 - name: app_owner
762 login: false
763"#;
764 let manifest = parse_manifest(yaml).unwrap();
765 let expanded = expand_manifest(&manifest).unwrap();
766 let scope = ManagedScope {
767 roles: BTreeSet::from(["app_owner".to_string(), "inventory-editor".to_string()]),
768 schemas: BTreeMap::from([(
769 "inventory".to_string(),
770 ManagedSchemaScope {
771 owner: true,
772 bindings: false,
773 },
774 )]),
775 };
776
777 let config = InspectConfig::from_managed_scope(&scope, &expanded, false);
778
779 assert_eq!(config.managed_schemas, vec!["inventory".to_string()]);
780 assert!(config.privilege_schemas.is_empty());
781 assert!(config.wildcard_grants.is_empty());
782 }
783
784 #[test]
785 fn remove_redundant_schema_owner_grants_keeps_only_non_owner_schema_grants() {
786 let mut graph = RoleGraph::default();
787 graph.schemas.insert(
788 "inventory".to_string(),
789 pgroles_core::model::SchemaState {
790 owner: Some("inventory_owner".to_string()),
791 owner_privileges: [pgroles_core::manifest::Privilege::Create]
792 .into_iter()
793 .collect(),
794 },
795 );
796 graph.grants.insert(
797 pgroles_core::model::GrantKey {
798 role: "inventory_owner".to_string(),
799 object_type: pgroles_core::manifest::ObjectType::Schema,
800 schema: None,
801 name: Some("inventory".to_string()),
802 },
803 pgroles_core::model::GrantState {
804 privileges: [pgroles_core::manifest::Privilege::Usage]
805 .into_iter()
806 .collect(),
807 },
808 );
809 graph.grants.insert(
810 pgroles_core::model::GrantKey {
811 role: "inventory_reader".to_string(),
812 object_type: pgroles_core::manifest::ObjectType::Schema,
813 schema: None,
814 name: Some("inventory".to_string()),
815 },
816 pgroles_core::model::GrantState {
817 privileges: [pgroles_core::manifest::Privilege::Usage]
818 .into_iter()
819 .collect(),
820 },
821 );
822
823 remove_redundant_schema_owner_grants(&mut graph);
824
825 assert_eq!(graph.grants.len(), 1);
826 assert!(
827 graph
828 .grants
829 .keys()
830 .all(|key| key.role == "inventory_reader")
831 );
832 }
833}