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_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// ---------------------------------------------------------------------------
42// Errors
43// ---------------------------------------------------------------------------
44
45#[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    /// The desired privileges for this wildcard grant. Used to construct a
177    /// vacuously-satisfied wildcard when no objects of this type exist in the
178    /// schema, so the diff engine sees exact parity and produces no change.
179    pub privileges: std::collections::BTreeSet<pgroles_core::manifest::Privilege>,
180}
181
182// ---------------------------------------------------------------------------
183// Configuration
184// ---------------------------------------------------------------------------
185
186/// Configuration for what to inspect from the database.
187///
188/// Scoped to only the roles and schemas that the manifest manages, so we
189/// don't pull in the entire pg_catalog.
190#[derive(Debug, Clone)]
191pub struct InspectConfig {
192    /// The role names that the manifest manages (created by pgroles).
193    /// Privileges and memberships are filtered to only include these roles.
194    pub managed_roles: Vec<String>,
195
196    /// The schema names that the manifest manages for schema-owner inspection.
197    pub managed_schemas: Vec<String>,
198
199    /// The schema names whose grants/default privileges are managed.
200    pub privilege_schemas: Vec<String>,
201
202    /// Whether to also inspect database-level privileges (CONNECT, CREATE, TEMPORARY).
203    /// Usually only needed if the manifest includes database-level grants.
204    pub include_database_privileges: bool,
205
206    /// Wildcard grant selectors from the desired manifest.
207    pub(crate) wildcard_grants: Vec<WildcardGrantPattern>,
208}
209
210impl InspectConfig {
211    /// Create an `InspectConfig` from an expanded manifest by extracting
212    /// the unique set of managed role names and schema names.
213    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        // Key for deduplicating wildcard grants: (role, object_type, schema).
220        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        // Collect role names
225        for role_def in &expanded.roles {
226            managed_roles.insert(role_def.name.clone());
227        }
228
229        // Collect schema names from grants
230        for grant in &expanded.grants {
231            if let Some(ref schema) = grant.object.schema {
232                managed_schemas.insert(schema.clone());
233            }
234            // Schema-level grants use the name field as the schema name
235            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        // Collect schema names from default privileges
257        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    /// Create an `InspectConfig` from a managed scope plus an expanded desired
285    /// manifest so current-state inspection can be restricted to composed policy
286    /// boundaries.
287    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    /// Extend the managed role scope with additional explicit role names.
317    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// ---------------------------------------------------------------------------
329// Top-level inspect function
330// ---------------------------------------------------------------------------
331
332/// Configuration for unscoped inspection (used by `generate` command).
333#[derive(Debug, Clone)]
334pub struct InspectAllConfig {
335    /// Whether to exclude PostgreSQL system roles (pg_*, postgres).
336    pub exclude_system_roles: bool,
337}
338
339/// Inspect all non-system roles and their privileges for manifest generation.
340///
341/// Unlike [`inspect`], this does not require a manifest to scope the query.
342/// It discovers all user-defined roles, schemas they have access to, and
343/// reconstructs the full RoleGraph.
344pub async fn inspect_all(
345    pool: &PgPool,
346    config: &InspectAllConfig,
347) -> Result<RoleGraph, InspectError> {
348    let mut graph = RoleGraph::default();
349
350    // Fetch all non-system roles.
351    // fetch_roles(None) already excludes pg_* and postgres system roles.
352    // The exclude_system_roles flag is reserved for future use with broader filtering.
353    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    // Discover schemas these roles have access to
364    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    // Memberships
378    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    // Schemas
384    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    // Object privileges (no wildcard patterns for unscoped inspection)
400    if !schema_refs.is_empty() {
401        let privilege_grants = privileges::fetch_privileges_with_wildcards(
402            pool,
403            &schema_refs,
404            &role_refs,
405            &[], // no wildcard patterns
406        )
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    // Database privileges
416    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    // Default privileges
422    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
432/// Inspect the current state of the database and build a `RoleGraph`.
433///
434/// Queries roles, memberships, object privileges, and default privileges,
435/// scoped to the managed set defined by `config`.
436pub async fn inspect(pool: &PgPool, config: &InspectConfig) -> Result<RoleGraph, InspectError> {
437    Ok(inspect_with_diagnostics(pool, config).await?.graph)
438}
439
440/// Inspect the current database state and return diagnostics for desired-state
441/// intent that cannot be satisfied by the current executor.
442pub 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    // Build &str slices for the query functions
451    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    // --- Roles ---
460    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    // --- Memberships ---
474    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    // Also add memberships where the member (not the group) is a managed role.
482    // This captures cases like "user@example.com is a member of inventory-editor"
483    // where inventory-editor is the group (managed) and user@example.com is the member.
484    // The fetch above already handles this (filters on group role = managed).
485    stats.memberships = graph.memberships.len();
486    debug!(found = graph.memberships.len(), "memberships inspected");
487
488    // --- Schemas ---
489    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    // --- Object privileges ---
508    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    // --- Database-level privileges ---
536    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    // --- Default privileges ---
552    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
575/// Fetch the names of all non-system schemas in the target database.
576///
577/// Used for pre-flight validation — the operator checks that every schema
578/// referenced by a policy exists before rendering GRANT statements that would
579/// otherwise fail mid-transaction with `schema "X" does not exist`.
580///
581/// Returns a [`BTreeSet`] for efficient membership lookup. Excludes
582/// `pg_catalog`, `pg_toast`, other `pg_*` schemas, and `information_schema`.
583pub 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    // Keep ordinary owner CREATE/USAGE management in SchemaState instead of the
644    // grants map. This avoids noisy self-grants while still preserving drift
645    // when the owner's ordinary privileges have been revoked.
646    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// ---------------------------------------------------------------------------
664// Tests
665// ---------------------------------------------------------------------------
666
667#[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        // Managed roles: inventory-editor, catalog-editor, analytics
709        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        // Managed schemas: inventory, catalog
719        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}