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_column_level_grants, fetch_database_privileges, fetch_object_inventory, fetch_privileges,
33 fetch_relation_inventory,
34};
35pub use public_grants::{PublicGrants, fetch_public_grants, format_public_grants};
36pub use roles::fetch_roles;
37pub use safety::{
38 DropRoleSafetyAssessment, DropRoleSafetyIssue, DropRoleSafetyReport, inspect_drop_role_safety,
39};
40pub use version::{PgVersion, detect_pg_version};
41
42#[derive(Debug, Error)]
47pub enum InspectError {
48 #[error("database query error: {0}")]
49 Database(#[from] sqlx::Error),
50}
51
52#[derive(Debug, Clone, Default, PartialEq, Eq)]
53pub struct InspectionDiagnostics {
54 pub unsatisfiable_wildcard_grants: Vec<UnsatisfiableWildcardGrant>,
55 pub column_level_grants: Vec<ColumnLevelGrantDiagnostic>,
63}
64
65impl InspectionDiagnostics {
66 pub fn is_empty(&self) -> bool {
67 self.unsatisfiable_wildcard_grants.is_empty() && self.column_level_grants.is_empty()
68 }
69
70 pub fn blocking_message(&self) -> Option<String> {
76 if self.unsatisfiable_wildcard_grants.is_empty() {
77 return None;
78 }
79 Some(
80 self.unsatisfiable_wildcard_grants
81 .iter()
82 .map(ToString::to_string)
83 .collect::<Vec<_>>()
84 .join("\n"),
85 )
86 }
87}
88
89impl std::fmt::Display for InspectionDiagnostics {
96 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
97 let mut wrote_any = false;
98 for diagnostic in &self.unsatisfiable_wildcard_grants {
99 if wrote_any {
100 writeln!(f)?;
101 }
102 write!(f, "{diagnostic}")?;
103 wrote_any = true;
104 }
105 for diagnostic in &self.column_level_grants {
106 if wrote_any {
107 writeln!(f)?;
108 }
109 write!(f, "{diagnostic}")?;
110 wrote_any = true;
111 }
112 Ok(())
113 }
114}
115
116#[derive(Debug, Clone, PartialEq, Eq)]
117pub struct UnsatisfiableWildcardGrant {
118 pub role: String,
119 pub object_type: ObjectType,
120 pub schema: String,
121 pub privileges: std::collections::BTreeSet<Privilege>,
122 pub executor: String,
123 pub skipped_count: usize,
124 pub examples: Vec<UnsatisfiableWildcardObject>,
125}
126
127impl std::fmt::Display for UnsatisfiableWildcardGrant {
128 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
129 let privileges = self
130 .privileges
131 .iter()
132 .map(ToString::to_string)
133 .collect::<Vec<_>>()
134 .join(", ");
135 let examples = self
136 .examples
137 .iter()
138 .map(ToString::to_string)
139 .collect::<Vec<_>>()
140 .join("; ");
141 write!(
142 f,
143 "UnsatisfiableWildcardGrant: cannot fully satisfy wildcard grant \
144 {privileges} ON {} * IN SCHEMA \"{}\" TO \"{}\" as executor \"{}\"; \
145 {} matching object(s) are missing the desired privilege and are not grantable",
146 self.object_type, self.schema, self.role, self.executor, self.skipped_count
147 )?;
148 if !examples.is_empty() {
149 write!(f, " (examples: {examples})")?;
150 }
151 Ok(())
152 }
153}
154
155#[derive(Debug, Clone, PartialEq, Eq)]
163pub struct ColumnLevelGrantDiagnostic {
164 pub schema: String,
165 pub relation: String,
166 pub grantee: String,
169 pub columns: Vec<String>,
174 pub skipped_columns: usize,
176 pub privileges: std::collections::BTreeSet<Privilege>,
177}
178
179pub(crate) const COLUMN_LEVEL_GRANT_EXAMPLE_LIMIT: usize = 8;
182
183impl std::fmt::Display for ColumnLevelGrantDiagnostic {
184 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
185 let privileges = self
186 .privileges
187 .iter()
188 .map(ToString::to_string)
189 .collect::<Vec<_>>()
190 .join(", ");
191 let mut columns = self.columns.join(", ");
192 if self.skipped_columns > 0 {
193 columns.push_str(&format!(", … (+{} more)", self.skipped_columns));
194 }
195 write!(
196 f,
197 "ColumnLevelGrant: \"{}\".\"{}\" has column-level grant(s) [{privileges}] to \"{}\" \
198 on column(s) [{columns}]; pgroles does not manage column-level privileges — they are \
199 not diffed, revoked, or included in `generate` output. See \
200 https://hardbyte.github.io/pgroles/docs/limitations/ for details.",
201 self.schema, self.relation, self.grantee
202 )
203 }
204}
205
206#[derive(Debug, Clone, PartialEq, Eq)]
207pub struct UnsatisfiableWildcardObject {
208 pub name: String,
209 pub owner: String,
210 pub privileges: std::collections::BTreeSet<Privilege>,
211}
212
213impl std::fmt::Display for UnsatisfiableWildcardObject {
214 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
215 let privileges = self
216 .privileges
217 .iter()
218 .map(ToString::to_string)
219 .collect::<Vec<_>>()
220 .join(", ");
221 write!(
222 f,
223 "\"{}\" owned by \"{}\" missing [{}]",
224 self.name, self.owner, privileges
225 )
226 }
227}
228
229#[derive(Debug, Clone)]
230pub struct InspectionResult {
231 pub graph: RoleGraph,
232 pub diagnostics: InspectionDiagnostics,
233 pub stats: InspectionStats,
234}
235
236#[derive(Debug, Clone, Default, PartialEq, Eq)]
237pub struct InspectionStats {
238 pub roles: usize,
239 pub memberships: usize,
240 pub schemas: usize,
241 pub grants: usize,
242 pub default_privileges: usize,
243 pub phase_durations: BTreeMap<&'static str, Duration>,
244 pub wildcard: WildcardInspectionStats,
245}
246
247impl InspectionStats {
248 fn record_phase(&mut self, phase: &'static str, duration: Duration) {
249 self.phase_durations.insert(phase, duration);
250 }
251}
252
253#[derive(Debug, Clone, Default, PartialEq, Eq)]
254pub struct WildcardInspectionStats {
255 pub configured_grants: usize,
256 pub configured_scopes: usize,
257 pub inventory_objects: usize,
258 pub unsatisfied_grants: usize,
259 pub unsatisfied_scopes: usize,
260 pub grantability_queries: usize,
261 pub grantability_objects: usize,
262}
263
264#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
265pub(crate) struct WildcardGrantPattern {
266 pub role: String,
267 pub object_type: pgroles_core::manifest::ObjectType,
268 pub schema: String,
269 pub privileges: std::collections::BTreeSet<pgroles_core::manifest::Privilege>,
273}
274
275#[derive(Debug, Clone)]
284pub struct InspectConfig {
285 pub managed_roles: Vec<String>,
288
289 pub managed_schemas: Vec<String>,
291
292 pub privilege_schemas: Vec<String>,
294
295 pub include_database_privileges: bool,
298
299 pub(crate) wildcard_grants: Vec<WildcardGrantPattern>,
301}
302
303impl InspectConfig {
304 pub fn from_expanded(
307 expanded: &pgroles_core::manifest::ExpandedManifest,
308 include_database_privileges: bool,
309 ) -> Self {
310 let mut managed_roles: BTreeSet<String> = BTreeSet::new();
311 let mut managed_schemas: BTreeSet<String> = BTreeSet::new();
312 type WildcardKey = (String, pgroles_core::manifest::ObjectType, String);
314 let mut wildcard_map: BTreeMap<WildcardKey, BTreeSet<pgroles_core::manifest::Privilege>> =
315 BTreeMap::new();
316
317 for role_def in &expanded.roles {
319 managed_roles.insert(role_def.name.clone());
320 }
321
322 for grant in &expanded.grants {
324 if let Some(ref schema) = grant.object.schema {
325 managed_schemas.insert(schema.clone());
326 }
327 if grant.object.object_type == pgroles_core::manifest::ObjectType::Schema
329 && let Some(ref name) = grant.object.name
330 {
331 managed_schemas.insert(name.clone());
332 }
333 if grant.object.name.as_deref() == Some("*")
334 && !matches!(
335 grant.object.object_type,
336 pgroles_core::manifest::ObjectType::Schema
337 | pgroles_core::manifest::ObjectType::Database
338 )
339 && let Some(schema) = &grant.object.schema
340 {
341 let key = (grant.role.clone(), grant.object.object_type, schema.clone());
342 wildcard_map
343 .entry(key)
344 .or_default()
345 .extend(grant.privileges.iter().copied());
346 }
347 }
348
349 for dp in &expanded.default_privileges {
351 managed_schemas.insert(dp.schema.clone());
352 }
353
354 for schema in &expanded.schemas {
355 managed_schemas.insert(schema.name.clone());
356 }
357
358 Self {
359 managed_roles: managed_roles.into_iter().collect(),
360 managed_schemas: managed_schemas.clone().into_iter().collect(),
361 privilege_schemas: managed_schemas.into_iter().collect(),
362 include_database_privileges,
363 wildcard_grants: wildcard_map
364 .into_iter()
365 .map(
366 |((role, object_type, schema), privileges)| WildcardGrantPattern {
367 role,
368 object_type,
369 schema,
370 privileges,
371 },
372 )
373 .collect(),
374 }
375 }
376
377 pub fn from_managed_scope(
381 scope: &ManagedScope,
382 expanded: &pgroles_core::manifest::ExpandedManifest,
383 include_database_privileges: bool,
384 ) -> Self {
385 let base = Self::from_expanded(expanded, include_database_privileges);
386
387 Self {
388 managed_roles: scope.roles.iter().cloned().collect(),
389 managed_schemas: scope.schemas.keys().cloned().collect(),
390 privilege_schemas: scope
391 .schemas
392 .iter()
393 .filter_map(|(schema, managed)| managed.bindings.then_some(schema.clone()))
394 .collect(),
395 include_database_privileges,
396 wildcard_grants: base
397 .wildcard_grants
398 .into_iter()
399 .filter(|pattern| {
400 scope
401 .schemas
402 .get(&pattern.schema)
403 .is_some_and(|managed| managed.bindings)
404 })
405 .collect(),
406 }
407 }
408
409 pub fn with_additional_roles<I>(mut self, roles: I) -> Self
411 where
412 I: IntoIterator<Item = String>,
413 {
414 let mut managed_roles: BTreeSet<String> = self.managed_roles.into_iter().collect();
415 managed_roles.extend(roles);
416 self.managed_roles = managed_roles.into_iter().collect();
417 self
418 }
419}
420
421#[derive(Debug, Clone)]
427pub struct InspectAllConfig {
428 pub exclude_system_roles: bool,
430}
431
432pub async fn inspect_all(
438 pool: &PgPool,
439 config: &InspectAllConfig,
440) -> Result<RoleGraph, InspectError> {
441 let mut graph = RoleGraph::default();
442
443 let _ = config.exclude_system_roles;
447 let role_rows = fetch_roles(pool, None).await?;
448 for row in &role_rows {
449 graph.roles.insert(row.rolname.clone(), row.to_role_state());
450 }
451 debug!(found = graph.roles.len(), "roles discovered for generation");
452
453 let role_names: Vec<String> = graph.roles.keys().cloned().collect();
454 let role_refs: Vec<&str> = role_names.iter().map(|s| s.as_str()).collect();
455
456 let schema_rows: Vec<(String,)> = sqlx::query_as(
458 r#"
459 SELECT nspname::text FROM pg_namespace
460 WHERE nspname NOT LIKE 'pg_%'
461 AND nspname <> 'information_schema'
462 ORDER BY nspname
463 "#,
464 )
465 .fetch_all(pool)
466 .await?;
467 let schema_names: Vec<String> = schema_rows.into_iter().map(|r| r.0).collect();
468 let schema_refs: Vec<&str> = schema_names.iter().map(|s| s.as_str()).collect();
469
470 let membership_rows = fetch_memberships(pool, Some(&role_refs)).await?;
472 for row in &membership_rows {
473 graph.memberships.insert(row.to_membership_edge());
474 }
475
476 let schema_rows = fetch_schemas(pool, &schema_refs).await?;
478 for row in &schema_rows {
479 graph.schemas.insert(
480 row.schema_name.clone(),
481 pgroles_core::model::SchemaState {
482 owner: Some(row.owner_name.clone()),
483 owner_privileges: row.owner_privileges(),
484 },
485 );
486 }
487
488 if graph.roles.is_empty() && graph.schemas.is_empty() {
489 return Ok(graph);
490 }
491
492 if !schema_refs.is_empty() {
494 let privilege_grants = privileges::fetch_privileges_with_wildcards(
495 pool,
496 &schema_refs,
497 &role_refs,
498 &[], )
500 .await?
501 .grants;
502 for (key, state) in privilege_grants {
503 graph.grants.insert(key, state);
504 }
505 remove_redundant_schema_owner_grants(&mut graph);
506 }
507
508 let db_grants = fetch_database_privileges(pool, &role_refs).await?;
510 for (key, state) in db_grants {
511 graph.grants.insert(key, state);
512 }
513
514 if !schema_refs.is_empty() {
516 let default_privs = fetch_default_privileges(pool, &schema_refs, &role_refs).await?;
517 for (key, state) in default_privs {
518 graph.default_privileges.insert(key, state);
519 }
520 }
521
522 Ok(graph)
523}
524
525pub async fn inspect(pool: &PgPool, config: &InspectConfig) -> Result<RoleGraph, InspectError> {
530 Ok(inspect_with_diagnostics(pool, config).await?.graph)
531}
532
533pub async fn inspect_with_diagnostics(
536 pool: &PgPool,
537 config: &InspectConfig,
538) -> Result<InspectionResult, InspectError> {
539 let mut graph = RoleGraph::default();
540 let mut diagnostics = InspectionDiagnostics::default();
541 let mut stats = InspectionStats::default();
542
543 let role_refs: Vec<&str> = config.managed_roles.iter().map(|s| s.as_str()).collect();
545 let schema_refs: Vec<&str> = config.managed_schemas.iter().map(|s| s.as_str()).collect();
546 let privilege_schema_refs: Vec<&str> = config
547 .privilege_schemas
548 .iter()
549 .map(|s| s.as_str())
550 .collect();
551
552 debug!(
554 count = role_refs.len(),
555 "inspecting managed roles from pg_roles"
556 );
557 let phase_started_at = Instant::now();
558 let role_rows = fetch_roles(pool, Some(&role_refs)).await?;
559 stats.record_phase("roles", phase_started_at.elapsed());
560 for row in &role_rows {
561 graph.roles.insert(row.rolname.clone(), row.to_role_state());
562 }
563 stats.roles = graph.roles.len();
564 debug!(found = graph.roles.len(), "roles inspected");
565
566 debug!("inspecting memberships from pg_auth_members");
568 let phase_started_at = Instant::now();
569 let membership_rows = fetch_memberships(pool, Some(&role_refs)).await?;
570 stats.record_phase("memberships", phase_started_at.elapsed());
571 for row in &membership_rows {
572 graph.memberships.insert(row.to_membership_edge());
573 }
574 stats.memberships = graph.memberships.len();
579 debug!(found = graph.memberships.len(), "memberships inspected");
580
581 if !schema_refs.is_empty() {
583 debug!(schemas = ?schema_refs, "inspecting schemas from pg_namespace");
584 let phase_started_at = Instant::now();
585 let schema_rows = fetch_schemas(pool, &schema_refs).await?;
586 stats.record_phase("schemas", phase_started_at.elapsed());
587 for row in &schema_rows {
588 graph.schemas.insert(
589 row.schema_name.clone(),
590 pgroles_core::model::SchemaState {
591 owner: Some(row.owner_name.clone()),
592 owner_privileges: row.owner_privileges(),
593 },
594 );
595 }
596 stats.schemas = graph.schemas.len();
597 debug!(found = graph.schemas.len(), "schemas inspected");
598 }
599
600 if !privilege_schema_refs.is_empty() {
602 debug!(
603 schemas = ?privilege_schema_refs,
604 "inspecting object privileges via aclexplode"
605 );
606 let phase_started_at = Instant::now();
607 let privilege_result = privileges::fetch_privileges_with_wildcards(
608 pool,
609 &privilege_schema_refs,
610 &role_refs,
611 &config.wildcard_grants,
612 )
613 .await?;
614 stats.record_phase("object_privileges", phase_started_at.elapsed());
615 stats.wildcard = privilege_result.wildcard_stats;
616 diagnostics
617 .unsatisfiable_wildcard_grants
618 .extend(privilege_result.diagnostics);
619 let privilege_grants = privilege_result.grants;
620 for (key, state) in privilege_grants {
621 graph.grants.insert(key, state);
622 }
623 remove_redundant_schema_owner_grants(&mut graph);
624 stats.grants = graph.grants.len();
625 debug!(found = graph.grants.len(), "privilege grants inspected");
626
627 debug!(
628 schemas = ?privilege_schema_refs,
629 "inspecting column-level grants via pg_attribute.attacl"
630 );
631 let phase_started_at = Instant::now();
632 diagnostics.column_level_grants =
633 privileges::fetch_column_level_grants(pool, &privilege_schema_refs).await?;
634 stats.record_phase("column_level_grants", phase_started_at.elapsed());
635 if !diagnostics.column_level_grants.is_empty() {
636 debug!(
637 found = diagnostics.column_level_grants.len(),
638 "column-level grants detected (unmanaged)"
639 );
640 }
641 }
642
643 if config.include_database_privileges {
645 debug!("inspecting database-level privileges");
646 let phase_started_at = Instant::now();
647 let db_grants = fetch_database_privileges(pool, &role_refs).await?;
648 stats.record_phase("database_privileges", phase_started_at.elapsed());
649 for (key, state) in db_grants {
650 graph.grants.insert(key, state);
651 }
652 stats.grants = graph.grants.len();
653 debug!(
654 total = graph.grants.len(),
655 "grants after database privileges"
656 );
657 }
658
659 if !privilege_schema_refs.is_empty() {
661 debug!("inspecting default privileges from pg_default_acl");
662 let phase_started_at = Instant::now();
663 let default_privs =
664 fetch_default_privileges(pool, &privilege_schema_refs, &role_refs).await?;
665 stats.record_phase("default_privileges", phase_started_at.elapsed());
666 for (key, state) in default_privs {
667 graph.default_privileges.insert(key, state);
668 }
669 stats.default_privileges = graph.default_privileges.len();
670 debug!(
671 found = graph.default_privileges.len(),
672 "default privileges inspected"
673 );
674 }
675
676 Ok(InspectionResult {
677 graph,
678 diagnostics,
679 stats,
680 })
681}
682
683pub async fn fetch_existing_schemas(
692 pool: &PgPool,
693) -> Result<std::collections::BTreeSet<String>, InspectError> {
694 let rows: Vec<(String,)> = sqlx::query_as(
695 r#"
696 SELECT nspname::text FROM pg_namespace
697 WHERE nspname NOT LIKE 'pg_%'
698 AND nspname <> 'information_schema'
699 "#,
700 )
701 .fetch_all(pool)
702 .await?;
703 Ok(rows.into_iter().map(|r| r.0).collect())
704}
705
706#[derive(Debug, sqlx::FromRow)]
707pub struct SchemaRow {
708 pub schema_name: String,
709 pub owner_name: String,
710 pub owner_has_create: bool,
711 pub owner_has_usage: bool,
712}
713
714impl SchemaRow {
715 fn owner_privileges(&self) -> BTreeSet<Privilege> {
716 let mut privileges = BTreeSet::new();
717 if self.owner_has_create {
718 privileges.insert(Privilege::Create);
719 }
720 if self.owner_has_usage {
721 privileges.insert(Privilege::Usage);
722 }
723 privileges
724 }
725}
726
727pub async fn fetch_schemas(
728 pool: &PgPool,
729 managed_schemas: &[&str],
730) -> Result<Vec<SchemaRow>, InspectError> {
731 let rows = sqlx::query_as::<_, SchemaRow>(
732 r#"
733 SELECT
734 n.nspname AS schema_name,
735 owner_role.rolname AS owner_name,
736 has_schema_privilege(owner_role.rolname, n.nspname, 'CREATE') AS owner_has_create,
737 has_schema_privilege(owner_role.rolname, n.nspname, 'USAGE') AS owner_has_usage
738 FROM pg_namespace n
739 JOIN pg_roles owner_role ON owner_role.oid = n.nspowner
740 WHERE n.nspname = ANY($1)
741 ORDER BY n.nspname
742 "#,
743 )
744 .bind(managed_schemas)
745 .fetch_all(pool)
746 .await?;
747 Ok(rows)
748}
749
750fn remove_redundant_schema_owner_grants(graph: &mut RoleGraph) {
751 graph.grants.retain(|key, _| {
755 if key.object_type != pgroles_core::manifest::ObjectType::Schema {
756 return true;
757 }
758
759 let Some(schema_name) = key.name.as_deref() else {
760 return true;
761 };
762
763 let Some(schema_state) = graph.schemas.get(schema_name) else {
764 return true;
765 };
766
767 schema_state.owner.as_deref() != Some(key.role.as_str())
768 });
769}
770
771#[cfg(test)]
776mod tests {
777 use super::*;
778 use pgroles_core::manifest::{expand_manifest, parse_manifest};
779 use pgroles_core::ownership::ManagedSchemaScope;
780
781 #[test]
782 fn inspect_config_from_expanded_manifest() {
783 let yaml = r#"
784default_owner: app_owner
785
786profiles:
787 editor:
788 grants:
789 - privileges: [USAGE]
790 object: { type: schema }
791 - privileges: [SELECT, INSERT]
792 object: { type: table, name: "*" }
793 default_privileges:
794 - privileges: [SELECT, INSERT]
795 on_type: table
796
797schemas:
798 - name: inventory
799 profiles: [editor]
800 - name: catalog
801 profiles: [editor]
802
803roles:
804 - name: analytics
805 login: true
806
807grants:
808 - role: analytics
809 privileges: [CONNECT]
810 object: { type: database, name: mydb }
811"#;
812 let manifest = parse_manifest(yaml).unwrap();
813 let expanded = expand_manifest(&manifest).unwrap();
814 let config = InspectConfig::from_expanded(&expanded, true);
815
816 assert_eq!(config.managed_roles.len(), 3);
818 assert!(
819 config
820 .managed_roles
821 .contains(&"inventory-editor".to_string())
822 );
823 assert!(config.managed_roles.contains(&"catalog-editor".to_string()));
824 assert!(config.managed_roles.contains(&"analytics".to_string()));
825
826 assert_eq!(config.managed_schemas.len(), 2);
828 assert!(config.managed_schemas.contains(&"inventory".to_string()));
829 assert!(config.managed_schemas.contains(&"catalog".to_string()));
830
831 assert!(config.include_database_privileges);
832 assert_eq!(config.privilege_schemas.len(), 2);
833 assert_eq!(config.wildcard_grants.len(), 2);
834 }
835
836 #[test]
837 fn inspect_config_can_include_retired_roles() {
838 let yaml = r#"
839roles:
840 - name: analytics
841"#;
842 let manifest = parse_manifest(yaml).unwrap();
843 let expanded = expand_manifest(&manifest).unwrap();
844 let config = InspectConfig::from_expanded(&expanded, false)
845 .with_additional_roles(vec!["legacy-app".to_string(), "analytics".to_string()]);
846
847 assert_eq!(config.managed_roles.len(), 2);
848 assert!(config.managed_roles.contains(&"analytics".to_string()));
849 assert!(config.managed_roles.contains(&"legacy-app".to_string()));
850 }
851
852 #[test]
853 fn inspect_config_from_managed_scope_limits_privileges_to_binding_schemas() {
854 let yaml = r#"
855default_owner: app_owner
856
857profiles:
858 editor:
859 grants:
860 - privileges: [USAGE]
861 object: { type: schema }
862
863schemas:
864 - name: inventory
865 owner: app_owner
866 profiles: [editor]
867
868roles:
869 - name: app_owner
870 login: false
871"#;
872 let manifest = parse_manifest(yaml).unwrap();
873 let expanded = expand_manifest(&manifest).unwrap();
874 let scope = ManagedScope {
875 roles: BTreeSet::from(["app_owner".to_string(), "inventory-editor".to_string()]),
876 schemas: BTreeMap::from([(
877 "inventory".to_string(),
878 ManagedSchemaScope {
879 owner: true,
880 bindings: false,
881 },
882 )]),
883 };
884
885 let config = InspectConfig::from_managed_scope(&scope, &expanded, false);
886
887 assert_eq!(config.managed_schemas, vec!["inventory".to_string()]);
888 assert!(config.privilege_schemas.is_empty());
889 assert!(config.wildcard_grants.is_empty());
890 }
891
892 #[test]
893 fn remove_redundant_schema_owner_grants_keeps_only_non_owner_schema_grants() {
894 let mut graph = RoleGraph::default();
895 graph.schemas.insert(
896 "inventory".to_string(),
897 pgroles_core::model::SchemaState {
898 owner: Some("inventory_owner".to_string()),
899 owner_privileges: [pgroles_core::manifest::Privilege::Create]
900 .into_iter()
901 .collect(),
902 },
903 );
904 graph.grants.insert(
905 pgroles_core::model::GrantKey {
906 role: "inventory_owner".to_string(),
907 object_type: pgroles_core::manifest::ObjectType::Schema,
908 schema: None,
909 name: Some("inventory".to_string()),
910 },
911 pgroles_core::model::GrantState {
912 privileges: [pgroles_core::manifest::Privilege::Usage]
913 .into_iter()
914 .collect(),
915 },
916 );
917 graph.grants.insert(
918 pgroles_core::model::GrantKey {
919 role: "inventory_reader".to_string(),
920 object_type: pgroles_core::manifest::ObjectType::Schema,
921 schema: None,
922 name: Some("inventory".to_string()),
923 },
924 pgroles_core::model::GrantState {
925 privileges: [pgroles_core::manifest::Privilege::Usage]
926 .into_iter()
927 .collect(),
928 },
929 );
930
931 remove_redundant_schema_owner_grants(&mut graph);
932
933 assert_eq!(graph.grants.len(), 1);
934 assert!(
935 graph
936 .grants
937 .keys()
938 .all(|key| key.role == "inventory_reader")
939 );
940 }
941
942 fn sample_column_level_grant(grantee: &str, columns: &[&str]) -> ColumnLevelGrantDiagnostic {
943 ColumnLevelGrantDiagnostic {
944 schema: "inventory".to_string(),
945 relation: "widgets".to_string(),
946 grantee: grantee.to_string(),
947 columns: columns.iter().map(|c| c.to_string()).collect(),
948 skipped_columns: 0,
949 privileges: BTreeSet::from([Privilege::Select]),
950 }
951 }
952
953 #[test]
954 fn column_level_grant_display_names_schema_relation_and_grantee() {
955 let diagnostic = sample_column_level_grant("analytics", &["secret"]);
956 let rendered = diagnostic.to_string();
957
958 assert!(rendered.contains("ColumnLevelGrant"));
959 assert!(rendered.contains("\"inventory\".\"widgets\""));
960 assert!(rendered.contains("\"analytics\""));
961 assert!(rendered.contains("SELECT"));
962 assert!(rendered.contains("secret"));
963 assert!(rendered.contains("does not manage column-level privileges"));
964 assert!(rendered.contains("https://hardbyte.github.io/pgroles/docs/limitations/"));
965 }
966
967 #[test]
968 fn column_level_grant_display_renders_public_grantee_explicitly() {
969 let diagnostic = sample_column_level_grant("PUBLIC", &["secret"]);
970 let rendered = diagnostic.to_string();
971
972 assert!(rendered.contains("\"PUBLIC\""));
973 }
974
975 #[test]
976 fn column_level_grant_display_summarizes_skipped_columns() {
977 let mut diagnostic = sample_column_level_grant("analytics", &["col_a", "col_b"]);
980 diagnostic.skipped_columns = 4;
981
982 let rendered = diagnostic.to_string();
983 assert!(rendered.contains("col_a, col_b, … (+4 more)"));
984 }
985
986 #[test]
987 fn inspection_diagnostics_is_empty_requires_both_fields_empty() {
988 let mut diagnostics = InspectionDiagnostics::default();
989 assert!(diagnostics.is_empty());
990
991 diagnostics
992 .column_level_grants
993 .push(sample_column_level_grant("analytics", &["secret"]));
994 assert!(!diagnostics.is_empty());
995 }
996
997 #[test]
998 fn inspection_diagnostics_display_includes_column_level_grants() {
999 let mut diagnostics = InspectionDiagnostics::default();
1000 diagnostics
1001 .column_level_grants
1002 .push(sample_column_level_grant("analytics", &["secret"]));
1003
1004 let rendered = diagnostics.to_string();
1005 assert!(rendered.contains("ColumnLevelGrant"));
1006 }
1007
1008 #[test]
1009 fn inspection_diagnostics_display_joins_wildcard_and_column_level_diagnostics() {
1010 let mut diagnostics = InspectionDiagnostics::default();
1011 diagnostics
1012 .unsatisfiable_wildcard_grants
1013 .push(UnsatisfiableWildcardGrant {
1014 role: "reader".to_string(),
1015 object_type: ObjectType::Table,
1016 schema: "inventory".to_string(),
1017 privileges: BTreeSet::from([Privilege::Select]),
1018 executor: "app_owner".to_string(),
1019 skipped_count: 1,
1020 examples: vec![],
1021 });
1022 diagnostics
1023 .column_level_grants
1024 .push(sample_column_level_grant("analytics", &["secret"]));
1025
1026 let rendered = diagnostics.to_string();
1027 let wildcard_pos = rendered.find("UnsatisfiableWildcardGrant").unwrap();
1028 let column_pos = rendered.find("ColumnLevelGrant").unwrap();
1029 assert!(
1030 wildcard_pos < column_pos,
1031 "wildcard diagnostics should render before column-level diagnostics"
1032 );
1033 assert_eq!(rendered.lines().count(), 2);
1035 }
1036}