Skip to main content

pgroles_inspect/
lib.rs

1//! Database introspection for pgroles.
2//!
3//! Queries `pg_catalog` tables to build a [`pgroles_core::model::RoleGraph`]
4//! representing the current state of roles, grants, default privileges, and
5//! memberships in a PostgreSQL database.
6
7pub 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
27// Re-export the sub-modules' public items for testing / advanced use.
28pub 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// ---------------------------------------------------------------------------
43// Errors
44// ---------------------------------------------------------------------------
45
46#[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    /// Column-level ACL entries (`GRANT ... (column) ON table TO role`) found
56    /// on relations inside managed schemas. pgroles does not manage
57    /// column-level privileges — these are surfaced as an advisory warning
58    /// during `diff`/`apply`, not a blocking error: unlike
59    /// [`unsatisfiable_wildcard_grants`](Self::unsatisfiable_wildcard_grants),
60    /// their presence never stops inspection or reconciliation from
61    /// proceeding.
62    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    /// Render the blocking diagnostics (unsatisfiable wildcard grants) as the
71    /// error message `diff`/`apply` and the operator fail with, one per line.
72    /// Returns `None` when nothing blocks reconciliation. Advisory
73    /// diagnostics ([`column_level_grants`](Self::column_level_grants)) are
74    /// deliberately excluded — callers surface those as warnings separately.
75    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
89/// Combined rendering of all diagnostics, blocking and advisory alike.
90///
91/// NOTE: production paths do NOT use this impl — the CLI and operator render
92/// [`InspectionDiagnostics::blocking_message`] for the failure path and
93/// iterate `column_level_grants` for the warning path separately, so severity
94/// stays visible. This impl exists for logging/debugging convenience.
95impl 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/// A column-level grant detected on a relation inside a managed schema,
156/// aggregated by `(schema, relation, grantee)`.
157///
158/// pgroles only manages table/view/etc.-level ACLs (`pg_class.relacl`); it
159/// never reads or writes `pg_attribute.attacl`. When column-level grants
160/// exist, the manifest is not the whole truth for that relation — this
161/// diagnostic surfaces that gap without attempting to manage it.
162#[derive(Debug, Clone, PartialEq, Eq)]
163pub struct ColumnLevelGrantDiagnostic {
164    pub schema: String,
165    pub relation: String,
166    /// The grantee role name, or the literal string `"PUBLIC"` for grants to
167    /// the PUBLIC pseudo-role (ACL grantee OID 0).
168    pub grantee: String,
169    /// Up to [`COLUMN_LEVEL_GRANT_EXAMPLE_LIMIT`] affected column names,
170    /// sorted; the overflow count lives in `skipped_columns`. Capped at
171    /// construction (like `UnsatisfiableWildcardGrant::examples`) so a wide
172    /// table doesn't keep thousands of names resident per diagnostic.
173    pub columns: Vec<String>,
174    /// Number of additional affected columns beyond `columns`.
175    pub skipped_columns: usize,
176    pub privileges: std::collections::BTreeSet<Privilege>,
177}
178
179/// Maximum number of column names carried by a [`ColumnLevelGrantDiagnostic`];
180/// the remainder is summarized as `skipped_columns` at aggregation time.
181pub(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    /// The desired privileges for this wildcard grant. Used to construct a
270    /// vacuously-satisfied wildcard when no objects of this type exist in the
271    /// schema, so the diff engine sees exact parity and produces no change.
272    pub privileges: std::collections::BTreeSet<pgroles_core::manifest::Privilege>,
273}
274
275// ---------------------------------------------------------------------------
276// Configuration
277// ---------------------------------------------------------------------------
278
279/// Configuration for what to inspect from the database.
280///
281/// Scoped to only the roles and schemas that the manifest manages, so we
282/// don't pull in the entire pg_catalog.
283#[derive(Debug, Clone)]
284pub struct InspectConfig {
285    /// The role names that the manifest manages (created by pgroles).
286    /// Privileges and memberships are filtered to only include these roles.
287    pub managed_roles: Vec<String>,
288
289    /// The schema names that the manifest manages for schema-owner inspection.
290    pub managed_schemas: Vec<String>,
291
292    /// The schema names whose grants/default privileges are managed.
293    pub privilege_schemas: Vec<String>,
294
295    /// Whether to also inspect database-level privileges (CONNECT, CREATE, TEMPORARY).
296    /// Usually only needed if the manifest includes database-level grants.
297    pub include_database_privileges: bool,
298
299    /// Wildcard grant selectors from the desired manifest.
300    pub(crate) wildcard_grants: Vec<WildcardGrantPattern>,
301}
302
303impl InspectConfig {
304    /// Create an `InspectConfig` from an expanded manifest by extracting
305    /// the unique set of managed role names and schema names.
306    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        // Key for deduplicating wildcard grants: (role, object_type, schema).
313        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        // Collect role names
318        for role_def in &expanded.roles {
319            managed_roles.insert(role_def.name.clone());
320        }
321
322        // Collect schema names from grants
323        for grant in &expanded.grants {
324            if let Some(ref schema) = grant.object.schema {
325                managed_schemas.insert(schema.clone());
326            }
327            // Schema-level grants use the name field as the schema name
328            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        // Collect schema names from default privileges
350        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    /// Create an `InspectConfig` from a managed scope plus an expanded desired
378    /// manifest so current-state inspection can be restricted to composed policy
379    /// boundaries.
380    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    /// Extend the managed role scope with additional explicit role names.
410    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// ---------------------------------------------------------------------------
422// Top-level inspect function
423// ---------------------------------------------------------------------------
424
425/// Configuration for unscoped inspection (used by `generate` command).
426#[derive(Debug, Clone)]
427pub struct InspectAllConfig {
428    /// Whether to exclude PostgreSQL system roles (pg_*, postgres).
429    pub exclude_system_roles: bool,
430}
431
432/// Inspect all non-system roles and their privileges for manifest generation.
433///
434/// Unlike [`inspect`], this does not require a manifest to scope the query.
435/// It discovers all user-defined roles, schemas they have access to, and
436/// reconstructs the full RoleGraph.
437pub async fn inspect_all(
438    pool: &PgPool,
439    config: &InspectAllConfig,
440) -> Result<RoleGraph, InspectError> {
441    let mut graph = RoleGraph::default();
442
443    // Fetch all non-system roles.
444    // fetch_roles(None) already excludes pg_* and postgres system roles.
445    // The exclude_system_roles flag is reserved for future use with broader filtering.
446    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    // Discover schemas these roles have access to
457    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    // Memberships
471    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    // Schemas
477    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    // Object privileges (no wildcard patterns for unscoped inspection)
493    if !schema_refs.is_empty() {
494        let privilege_grants = privileges::fetch_privileges_with_wildcards(
495            pool,
496            &schema_refs,
497            &role_refs,
498            &[], // no wildcard patterns
499        )
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    // Database privileges
509    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    // Default privileges
515    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
525/// Inspect the current state of the database and build a `RoleGraph`.
526///
527/// Queries roles, memberships, object privileges, and default privileges,
528/// scoped to the managed set defined by `config`.
529pub async fn inspect(pool: &PgPool, config: &InspectConfig) -> Result<RoleGraph, InspectError> {
530    Ok(inspect_with_diagnostics(pool, config).await?.graph)
531}
532
533/// Inspect the current database state and return diagnostics for desired-state
534/// intent that cannot be satisfied by the current executor.
535pub 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    // Build &str slices for the query functions
544    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    // --- Roles ---
553    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    // --- Memberships ---
567    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    // Also add memberships where the member (not the group) is a managed role.
575    // This captures cases like "user@example.com is a member of inventory-editor"
576    // where inventory-editor is the group (managed) and user@example.com is the member.
577    // The fetch above already handles this (filters on group role = managed).
578    stats.memberships = graph.memberships.len();
579    debug!(found = graph.memberships.len(), "memberships inspected");
580
581    // --- Schemas ---
582    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    // --- Object privileges ---
601    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    // --- Database-level privileges ---
644    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    // --- Default privileges ---
660    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
683/// Fetch the names of all non-system schemas in the target database.
684///
685/// Used for pre-flight validation — the operator checks that every schema
686/// referenced by a policy exists before rendering GRANT statements that would
687/// otherwise fail mid-transaction with `schema "X" does not exist`.
688///
689/// Returns a [`BTreeSet`] for efficient membership lookup. Excludes
690/// `pg_catalog`, `pg_toast`, other `pg_*` schemas, and `information_schema`.
691pub 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    // Keep ordinary owner CREATE/USAGE management in SchemaState instead of the
752    // grants map. This avoids noisy self-grants while still preserving drift
753    // when the owner's ordinary privileges have been revoked.
754    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// ---------------------------------------------------------------------------
772// Tests
773// ---------------------------------------------------------------------------
774
775#[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        // Managed roles: inventory-editor, catalog-editor, analytics
817        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        // Managed schemas: inventory, catalog
827        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        // Capping happens at aggregation time (see privileges.rs tests);
978        // Display just renders the carried examples plus the overflow count.
979        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        // Both diagnostics must be on their own line.
1034        assert_eq!(rendered.lines().count(), 2);
1035    }
1036}