Skip to main content

pgmt/catalog/
grant.rs

1//! Fetch grants/privileges from PostgreSQL system catalogs
2use anyhow::Result;
3use sqlx::postgres::PgConnection;
4use tracing::info;
5
6use super::id::{DbObjectId, DependsOn};
7use super::target::AttrTarget;
8
9#[derive(Debug, Clone, PartialEq, Eq)]
10pub enum GranteeType {
11    Role(String),
12    Public,
13}
14
15#[derive(Debug, Clone)]
16pub struct Grant {
17    pub grantee: GranteeType,
18    pub target: AttrTarget,
19    pub privileges: Vec<String>, // e.g., ["SELECT", "INSERT"]
20    pub with_grant_option: bool,
21    pub depends_on: Vec<DbObjectId>,
22    pub object_owner: String, // Owner role name for this object
23    /// Whether this grant came from the default ACL (NULL ACL in pg_catalog).
24    /// true = object uses PostgreSQL defaults (e.g., PUBLIC has EXECUTE on functions)
25    /// false = object has explicit ACL (grants/revokes have been made)
26    pub is_default_acl: bool,
27}
28
29/// A stable, unique key for a grant's target, used for grant identity and for
30/// grouping grants by object. Mirrors the historical `type:schema.name` form.
31pub fn target_key(target: &AttrTarget) -> String {
32    if let Some(column) = target.column_name() {
33        let (schema, relation) = target.schema_and_name();
34        return format!("column:{}.{}.{}", schema, relation, column);
35    }
36    match &target.object {
37        DbObjectId::Table { schema, name } => format!("table:{}.{}", schema, name),
38        DbObjectId::View { schema, name } => format!("view:{}.{}", schema, name),
39        DbObjectId::Schema { name } => format!("schema:{}", name),
40        DbObjectId::Function {
41            schema,
42            name,
43            arguments,
44        } => format!("function:{}.{}({})", schema, name, arguments),
45        DbObjectId::Procedure {
46            schema,
47            name,
48            arguments,
49        } => format!("procedure:{}.{}({})", schema, name, arguments),
50        DbObjectId::Aggregate {
51            schema,
52            name,
53            arguments,
54        } => format!("aggregate:{}.{}({})", schema, name, arguments),
55        DbObjectId::Sequence { schema, name } => format!("sequence:{}.{}", schema, name),
56        DbObjectId::Type { schema, name } => format!("type:{}.{}", schema, name),
57        DbObjectId::Domain { schema, name } => format!("domain:{}.{}", schema, name),
58        // Not grantable object kinds.
59        other => other.to_string(),
60    }
61}
62
63impl Grant {
64    pub fn id(&self) -> String {
65        let grantee_str = match &self.grantee {
66            GranteeType::Role(name) => name.clone(),
67            GranteeType::Public => "public".to_string(),
68        };
69        format!("{}@{}", grantee_str, target_key(&self.target))
70    }
71}
72
73impl DependsOn for Grant {
74    fn id(&self) -> DbObjectId {
75        DbObjectId::Grant { id: self.id() }
76    }
77
78    fn depends_on(&self) -> &[DbObjectId] {
79        &self.depends_on
80    }
81}
82
83pub async fn fetch(conn: &mut PgConnection) -> Result<Vec<Grant>> {
84    let mut grants = Vec::new();
85
86    // Fetch table privileges
87    info!("Fetching table grants...");
88    grants.extend(fetch_table_privileges(&mut *conn).await?);
89
90    // Fetch view privileges
91    info!("Fetching view grants...");
92    grants.extend(fetch_view_privileges(&mut *conn).await?);
93
94    // Fetch column privileges (table and view columns)
95    info!("Fetching column grants...");
96    grants.extend(fetch_column_privileges(&mut *conn).await?);
97
98    // Fetch schema privileges
99    info!("Fetching schema grants...");
100    grants.extend(fetch_schema_privileges(&mut *conn).await?);
101
102    // Fetch function privileges
103    info!("Fetching function grants...");
104    grants.extend(fetch_function_privileges(&mut *conn).await?);
105
106    // Fetch sequence privileges
107    info!("Fetching sequence grants...");
108    grants.extend(fetch_sequence_privileges(&mut *conn).await?);
109
110    // Fetch type privileges
111    info!("Fetching type grants...");
112    grants.extend(fetch_type_privileges(&mut *conn).await?);
113
114    Ok(grants)
115}
116
117async fn fetch_table_privileges(conn: &mut PgConnection) -> Result<Vec<Grant>> {
118    let rows = sqlx::query!(
119        r#"
120        SELECT
121            n.nspname as "schema_name!",
122            c.relname as "table_name!",
123            CASE
124                WHEN acl.grantee = 0 THEN 'PUBLIC'
125                ELSE r.rolname
126            END as "grantee!",
127            acl.privilege_type as "privilege_type!",
128            CASE WHEN acl.is_grantable THEN 'YES' ELSE 'NO' END as "is_grantable!",
129            owner_role.rolname as "object_owner!",
130            CASE WHEN c.relacl IS NULL THEN true ELSE false END as "is_default_acl!"
131        FROM pg_class c
132        JOIN pg_namespace n ON c.relnamespace = n.oid
133        JOIN pg_roles owner_role ON c.relowner = owner_role.oid,
134        LATERAL aclexplode(COALESCE(c.relacl, acldefault('r', c.relowner))) AS acl
135        LEFT JOIN pg_roles r ON r.oid = acl.grantee
136        WHERE n.nspname NOT IN ('pg_catalog', 'information_schema', 'pg_toast')
137          AND c.relkind = 'r' -- tables only (views handled separately)
138          -- Exclude tables that belong to extensions
139          AND NOT EXISTS (
140              SELECT 1 FROM pg_depend dep
141              WHERE dep.objid = c.oid
142              AND dep.deptype = 'e'
143          )
144        ORDER BY n.nspname, c.relname, CASE WHEN acl.grantee = 0 THEN 'PUBLIC' ELSE r.rolname END, acl.privilege_type
145        "#
146    )
147    .fetch_all(&mut *conn)
148    .await?;
149
150    let mut result = Vec::new();
151    let mut current_grant: Option<Grant> = None;
152
153    for row in rows {
154        let grantee = if row.grantee == "PUBLIC" {
155            GranteeType::Public
156        } else {
157            GranteeType::Role(row.grantee.clone())
158        };
159
160        let target = AttrTarget::object(DbObjectId::Table {
161            schema: row.schema_name.clone(),
162            name: row.table_name.clone(),
163        });
164
165        let with_grant_option = row.is_grantable == "YES";
166
167        // Group privileges by grantee and object
168        match &mut current_grant {
169            Some(grant)
170                if grant.grantee == grantee
171                    && grant.target == target
172                    && grant.with_grant_option == with_grant_option =>
173            {
174                grant.privileges.push(row.privilege_type);
175            }
176            _ => {
177                if let Some(grant) = current_grant.take() {
178                    result.push(grant);
179                }
180
181                // Grants only depend on the target object, not the grantee role
182                // (roles are assumed to exist externally to pgmt)
183                let depends_on = vec![target.db_object_id()];
184
185                current_grant = Some(Grant {
186                    grantee,
187                    target,
188                    privileges: vec![row.privilege_type],
189                    with_grant_option,
190                    depends_on,
191                    object_owner: row.object_owner.clone(),
192                    is_default_acl: row.is_default_acl,
193                });
194            }
195        }
196    }
197
198    if let Some(grant) = current_grant {
199        result.push(grant);
200    }
201
202    Ok(result)
203}
204
205async fn fetch_view_privileges(conn: &mut PgConnection) -> Result<Vec<Grant>> {
206    let rows = sqlx::query!(
207        r#"
208        SELECT
209            n.nspname as "schema_name!",
210            c.relname as "view_name!",
211            CASE
212                WHEN acl.grantee = 0 THEN 'PUBLIC'
213                ELSE r.rolname
214            END as "grantee!",
215            acl.privilege_type as "privilege_type!",
216            CASE WHEN acl.is_grantable THEN 'YES' ELSE 'NO' END as "is_grantable!",
217            owner_role.rolname as "object_owner!",
218            CASE WHEN c.relacl IS NULL THEN true ELSE false END as "is_default_acl!"
219        FROM pg_class c
220        JOIN pg_namespace n ON c.relnamespace = n.oid
221        JOIN pg_roles owner_role ON c.relowner = owner_role.oid,
222        LATERAL aclexplode(COALESCE(c.relacl, acldefault('r', c.relowner))) AS acl
223        LEFT JOIN pg_roles r ON r.oid = acl.grantee
224        WHERE n.nspname NOT IN ('pg_catalog', 'information_schema', 'pg_toast')
225          AND c.relkind IN ('v', 'm') -- views and materialized views
226          -- Exclude views that belong to extensions
227          AND NOT EXISTS (
228              SELECT 1 FROM pg_depend dep
229              WHERE dep.objid = c.oid
230              AND dep.deptype = 'e'
231          )
232        ORDER BY n.nspname, c.relname, CASE WHEN acl.grantee = 0 THEN 'PUBLIC' ELSE r.rolname END, acl.privilege_type
233        "#
234    )
235    .fetch_all(&mut *conn)
236    .await?;
237
238    let mut result = Vec::new();
239    let mut current_grant: Option<Grant> = None;
240
241    for row in rows {
242        let grantee = if row.grantee == "PUBLIC" {
243            GranteeType::Public
244        } else {
245            GranteeType::Role(row.grantee.clone())
246        };
247
248        let target = AttrTarget::object(DbObjectId::View {
249            schema: row.schema_name.clone(),
250            name: row.view_name.clone(),
251        });
252
253        let with_grant_option = row.is_grantable == "YES";
254
255        // Group privileges by grantee and object
256        match &mut current_grant {
257            Some(grant)
258                if grant.grantee == grantee
259                    && grant.target == target
260                    && grant.with_grant_option == with_grant_option =>
261            {
262                grant.privileges.push(row.privilege_type);
263            }
264            _ => {
265                if let Some(grant) = current_grant.take() {
266                    result.push(grant);
267                }
268
269                // Grants only depend on the target object, not the grantee role
270                // (roles are assumed to exist externally to pgmt)
271                let depends_on = vec![target.db_object_id()];
272
273                current_grant = Some(Grant {
274                    grantee,
275                    target,
276                    privileges: vec![row.privilege_type],
277                    with_grant_option,
278                    depends_on,
279                    object_owner: row.object_owner.clone(),
280                    is_default_acl: row.is_default_acl,
281                });
282            }
283        }
284    }
285
286    if let Some(grant) = current_grant {
287        result.push(grant);
288    }
289
290    Ok(result)
291}
292
293async fn fetch_column_privileges(conn: &mut PgConnection) -> Result<Vec<Grant>> {
294    // Column privileges live in pg_attribute.attacl, which is NULL unless an
295    // explicit column grant has been made — so there is no default ACL to
296    // expand, and every row here is an explicit grant. attnum is a physical
297    // coordinate and never enters the model: we resolve to attname here and key
298    // grants on the column name only.
299    let rows = sqlx::query!(
300        r#"
301        SELECT
302            n.nspname as "schema_name!",
303            c.relname as "table_name!",
304            a.attname as "column_name!",
305            c.relkind::text as "relkind!",
306            CASE
307                WHEN acl.grantee = 0 THEN 'PUBLIC'
308                ELSE r.rolname
309            END as "grantee!",
310            acl.privilege_type as "privilege_type!",
311            CASE WHEN acl.is_grantable THEN 'YES' ELSE 'NO' END as "is_grantable!",
312            owner_role.rolname as "object_owner!"
313        FROM pg_class c
314        JOIN pg_namespace n ON c.relnamespace = n.oid
315        JOIN pg_roles owner_role ON c.relowner = owner_role.oid
316        JOIN pg_attribute a ON a.attrelid = c.oid
317        CROSS JOIN LATERAL aclexplode(a.attacl) AS acl
318        LEFT JOIN pg_roles r ON r.oid = acl.grantee
319        WHERE n.nspname NOT IN ('pg_catalog', 'information_schema', 'pg_toast')
320          AND c.relkind IN ('r', 'v', 'm', 'p') -- tables, views, matviews, partitioned tables
321          AND a.attnum > 0
322          AND NOT a.attisdropped
323          AND a.attacl IS NOT NULL
324          -- Exclude relations that belong to extensions
325          AND NOT EXISTS (
326              SELECT 1 FROM pg_depend dep
327              WHERE dep.objid = c.oid
328              AND dep.deptype = 'e'
329          )
330        ORDER BY n.nspname, c.relname, a.attname,
331                 CASE WHEN acl.grantee = 0 THEN 'PUBLIC' ELSE r.rolname END,
332                 acl.privilege_type
333        "#
334    )
335    .fetch_all(&mut *conn)
336    .await?;
337
338    let mut result = Vec::new();
339    let mut current_grant: Option<Grant> = None;
340
341    for row in rows {
342        let grantee = if row.grantee == "PUBLIC" {
343            GranteeType::Public
344        } else {
345            GranteeType::Role(row.grantee.clone())
346        };
347
348        // A column grant is ordered relative to its parent relation, which may be
349        // a table or a view.
350        let parent = if row.relkind == "v" || row.relkind == "m" {
351            DbObjectId::View {
352                schema: row.schema_name.clone(),
353                name: row.table_name.clone(),
354            }
355        } else {
356            DbObjectId::Table {
357                schema: row.schema_name.clone(),
358                name: row.table_name.clone(),
359            }
360        };
361
362        let target = AttrTarget::column(parent.clone(), row.column_name.clone());
363
364        let with_grant_option = row.is_grantable == "YES";
365
366        match &mut current_grant {
367            Some(grant)
368                if grant.grantee == grantee
369                    && grant.target == target
370                    && grant.with_grant_option == with_grant_option =>
371            {
372                grant.privileges.push(row.privilege_type);
373            }
374            _ => {
375                if let Some(grant) = current_grant.take() {
376                    result.push(grant);
377                }
378
379                current_grant = Some(Grant {
380                    grantee,
381                    target,
382                    privileges: vec![row.privilege_type],
383                    with_grant_option,
384                    depends_on: vec![parent],
385                    object_owner: row.object_owner.clone(),
386                    // attacl is never NULL here (filtered above), so these are
387                    // always explicit grants.
388                    is_default_acl: false,
389                });
390            }
391        }
392    }
393
394    if let Some(grant) = current_grant {
395        result.push(grant);
396    }
397
398    Ok(result)
399}
400
401async fn fetch_schema_privileges(conn: &mut PgConnection) -> Result<Vec<Grant>> {
402    let rows = sqlx::query!(
403        r#"
404        SELECT
405            n.nspname as "schema_name!",
406            CASE
407                WHEN acl.grantee = 0 THEN 'PUBLIC'
408                ELSE r.rolname
409            END as "grantee!",
410            acl.privilege_type as "privilege_type!",
411            CASE WHEN acl.is_grantable THEN 'YES' ELSE 'NO' END as "is_grantable!",
412            owner_role.rolname as "object_owner!",
413            CASE WHEN n.nspacl IS NULL THEN true ELSE false END as "is_default_acl!"
414        FROM pg_namespace n
415        JOIN pg_roles owner_role ON n.nspowner = owner_role.oid,
416        LATERAL aclexplode(COALESCE(n.nspacl, acldefault('n', n.nspowner))) AS acl
417        LEFT JOIN pg_roles r ON r.oid = acl.grantee
418        WHERE n.nspname NOT IN ('pg_catalog', 'information_schema', 'pg_toast', 'public')
419          AND NOT n.nspname LIKE 'pg_temp_%'
420          AND NOT n.nspname LIKE 'pg_toast_temp_%'
421        ORDER BY n.nspname, CASE WHEN acl.grantee = 0 THEN 'PUBLIC' ELSE r.rolname END, acl.privilege_type
422        "#
423    )
424    .fetch_all(&mut *conn)
425    .await?;
426
427    let mut result = Vec::new();
428    let mut current_grant: Option<Grant> = None;
429
430    for row in rows {
431        let grantee = if row.grantee == "PUBLIC" {
432            GranteeType::Public
433        } else {
434            GranteeType::Role(row.grantee.clone())
435        };
436
437        let target = AttrTarget::object(DbObjectId::Schema {
438            name: row.schema_name.clone(),
439        });
440
441        let with_grant_option = row.is_grantable == "YES";
442
443        match &mut current_grant {
444            Some(grant)
445                if grant.grantee == grantee
446                    && grant.target == target
447                    && grant.with_grant_option == with_grant_option =>
448            {
449                grant.privileges.push(row.privilege_type);
450            }
451            _ => {
452                if let Some(grant) = current_grant.take() {
453                    result.push(grant);
454                }
455
456                // Grants only depend on the target object, not the grantee role
457                // (roles are assumed to exist externally to pgmt)
458                let depends_on = vec![target.db_object_id()];
459
460                current_grant = Some(Grant {
461                    grantee,
462                    target,
463                    privileges: vec![row.privilege_type],
464                    with_grant_option,
465                    depends_on,
466                    object_owner: row.object_owner.clone(),
467                    is_default_acl: row.is_default_acl,
468                });
469            }
470        }
471    }
472
473    if let Some(grant) = current_grant {
474        result.push(grant);
475    }
476
477    Ok(result)
478}
479
480async fn fetch_function_privileges(conn: &mut PgConnection) -> Result<Vec<Grant>> {
481    let rows = sqlx::query!(
482        r#"
483        SELECT
484            n.nspname as "schema_name!",
485            p.proname as "function_name!",
486            p.prokind::text as "prokind!",
487            pg_get_function_identity_arguments(p.oid) as "arguments!",
488            CASE
489                WHEN acl.grantee = 0 THEN 'PUBLIC'
490                ELSE r.rolname
491            END as "grantee!",
492            acl.privilege_type as "privilege_type!",
493            CASE WHEN acl.is_grantable THEN 'YES' ELSE 'NO' END as "is_grantable!",
494            owner_role.rolname as "object_owner!",
495            CASE WHEN p.proacl IS NULL THEN true ELSE false END as "is_default_acl!"
496        FROM pg_proc p
497        JOIN pg_namespace n ON p.pronamespace = n.oid
498        JOIN pg_roles owner_role ON p.proowner = owner_role.oid,
499        LATERAL aclexplode(COALESCE(p.proacl, acldefault('f', p.proowner))) AS acl
500        LEFT JOIN pg_roles r ON r.oid = acl.grantee
501        WHERE n.nspname NOT IN ('pg_catalog', 'information_schema', 'pg_toast')
502          -- Exclude functions that belong to extensions
503          AND NOT EXISTS (
504              SELECT 1 FROM pg_depend dep
505              WHERE dep.objid = p.oid
506              AND dep.deptype = 'e'
507          )
508        ORDER BY n.nspname, p.proname, pg_get_function_identity_arguments(p.oid), CASE WHEN acl.grantee = 0 THEN 'PUBLIC' ELSE r.rolname END, acl.privilege_type
509        "#
510    )
511    .fetch_all(&mut *conn)
512    .await?;
513
514    let mut result = Vec::new();
515    let mut current_grant: Option<Grant> = None;
516
517    for row in rows {
518        let grantee = if row.grantee == "PUBLIC" {
519            GranteeType::Public
520        } else {
521            GranteeType::Role(row.grantee.clone())
522        };
523
524        // Use appropriate variant based on prokind:
525        // 'a' = aggregate, 'p' = procedure, others = function
526        let target = AttrTarget::object(match row.prokind.as_str() {
527            "a" => DbObjectId::Aggregate {
528                schema: row.schema_name.clone(),
529                name: row.function_name.clone(),
530                arguments: row.arguments.clone(),
531            },
532            "p" => DbObjectId::Procedure {
533                schema: row.schema_name.clone(),
534                name: row.function_name.clone(),
535                arguments: row.arguments.clone(),
536            },
537            _ => DbObjectId::Function {
538                schema: row.schema_name.clone(),
539                name: row.function_name.clone(),
540                arguments: row.arguments.clone(),
541            },
542        });
543
544        let with_grant_option = row.is_grantable == "YES";
545
546        match &mut current_grant {
547            Some(grant)
548                if grant.grantee == grantee
549                    && grant.target == target
550                    && grant.with_grant_option == with_grant_option =>
551            {
552                grant.privileges.push(row.privilege_type);
553            }
554            _ => {
555                if let Some(grant) = current_grant.take() {
556                    result.push(grant);
557                }
558
559                // Grants only depend on the target object, not the grantee role
560                // (roles are assumed to exist externally to pgmt)
561                let depends_on = vec![target.db_object_id()];
562
563                current_grant = Some(Grant {
564                    grantee,
565                    target,
566                    privileges: vec![row.privilege_type],
567                    with_grant_option,
568                    depends_on,
569                    object_owner: row.object_owner.clone(),
570                    is_default_acl: row.is_default_acl,
571                });
572            }
573        }
574    }
575
576    if let Some(grant) = current_grant {
577        result.push(grant);
578    }
579
580    Ok(result)
581}
582
583async fn fetch_sequence_privileges(conn: &mut PgConnection) -> Result<Vec<Grant>> {
584    let rows = sqlx::query!(
585        r#"
586        SELECT
587            n.nspname as "schema_name!",
588            c.relname as "sequence_name!",
589            CASE
590                WHEN acl.grantee = 0 THEN 'PUBLIC'
591                ELSE r.rolname
592            END as "grantee!",
593            acl.privilege_type as "privilege_type!",
594            CASE WHEN acl.is_grantable THEN 'YES' ELSE 'NO' END as "is_grantable!",
595            CASE WHEN c.relacl IS NULL THEN true ELSE false END as "is_default_acl!",
596            owner_role.rolname as "object_owner!"
597        FROM pg_class c
598        JOIN pg_namespace n ON c.relnamespace = n.oid
599        JOIN pg_roles owner_role ON c.relowner = owner_role.oid,
600        LATERAL aclexplode(COALESCE(c.relacl, acldefault('S', c.relowner))) AS acl
601        LEFT JOIN pg_roles r ON r.oid = acl.grantee
602        WHERE n.nspname NOT IN ('pg_catalog', 'information_schema', 'pg_toast')
603          AND c.relkind = 'S' -- sequences only
604          -- Exclude sequences that belong to extensions
605          AND NOT EXISTS (
606              SELECT 1 FROM pg_depend dep
607              WHERE dep.objid = c.oid
608              AND dep.deptype = 'e'
609          )
610        ORDER BY n.nspname, c.relname, CASE WHEN acl.grantee = 0 THEN 'PUBLIC' ELSE r.rolname END, acl.privilege_type
611        "#
612    )
613    .fetch_all(&mut *conn)
614    .await?;
615
616    let mut result = Vec::new();
617    let mut current_grant: Option<Grant> = None;
618
619    for row in rows {
620        let grantee = if row.grantee == "PUBLIC" {
621            GranteeType::Public
622        } else {
623            GranteeType::Role(row.grantee.clone())
624        };
625
626        let target = AttrTarget::object(DbObjectId::Sequence {
627            schema: row.schema_name.clone(),
628            name: row.sequence_name.clone(),
629        });
630
631        let with_grant_option = row.is_grantable == "YES";
632
633        match &mut current_grant {
634            Some(grant)
635                if grant.grantee == grantee
636                    && grant.target == target
637                    && grant.with_grant_option == with_grant_option =>
638            {
639                grant.privileges.push(row.privilege_type);
640            }
641            _ => {
642                if let Some(grant) = current_grant.take() {
643                    result.push(grant);
644                }
645
646                // Grants only depend on the target object, not the grantee role
647                // (roles are assumed to exist externally to pgmt)
648                let depends_on = vec![target.db_object_id()];
649
650                current_grant = Some(Grant {
651                    grantee,
652                    target,
653                    privileges: vec![row.privilege_type],
654                    with_grant_option,
655                    depends_on,
656                    object_owner: row.object_owner.clone(),
657                    is_default_acl: row.is_default_acl,
658                });
659            }
660        }
661    }
662
663    if let Some(grant) = current_grant {
664        result.push(grant);
665    }
666
667    Ok(result)
668}
669
670async fn fetch_type_privileges(conn: &mut PgConnection) -> Result<Vec<Grant>> {
671    let rows = sqlx::query!(
672        r#"
673        SELECT
674            n.nspname as "schema_name!",
675            t.typname as "type_name!",
676            t.typtype as "type_kind!",
677            CASE
678                WHEN acl.grantee = 0 THEN 'PUBLIC'
679                ELSE r.rolname
680            END as "grantee!",
681            acl.privilege_type as "privilege_type!",
682            CASE WHEN acl.is_grantable THEN 'YES' ELSE 'NO' END as "is_grantable!",
683            owner_role.rolname as "object_owner!",
684            CASE WHEN t.typacl IS NULL THEN true ELSE false END as "is_default_acl!"
685        FROM pg_type t
686        JOIN pg_namespace n ON t.typnamespace = n.oid
687        JOIN pg_roles owner_role ON t.typowner = owner_role.oid,
688        LATERAL aclexplode(COALESCE(t.typacl, acldefault('T', t.typowner))) AS acl
689        LEFT JOIN pg_roles r ON r.oid = acl.grantee
690        WHERE n.nspname NOT IN ('pg_catalog', 'information_schema', 'pg_toast')
691          AND t.typtype IN ('e', 'd', 'c')  -- Only enums, domains, and composite types
692          AND NOT EXISTS (
693              -- Exclude composite types that are automatically created for tables
694              SELECT 1 FROM pg_class c
695              WHERE c.relname = t.typname
696                AND c.relnamespace = t.typnamespace
697                AND c.relkind IN ('r', 'v', 'm', 'S')
698          )
699          AND NOT t.typname LIKE '\_%'  -- Exclude array types (they start with underscore)
700          -- Exclude types that belong to extensions
701          AND NOT EXISTS (
702              SELECT 1 FROM pg_depend dep
703              WHERE dep.objid = t.oid
704              AND dep.deptype = 'e'
705          )
706        ORDER BY n.nspname, t.typname, CASE WHEN acl.grantee = 0 THEN 'PUBLIC' ELSE r.rolname END, acl.privilege_type
707        "#
708    )
709    .fetch_all(&mut *conn)
710    .await?;
711
712    let mut result = Vec::new();
713    let mut current_grant: Option<Grant> = None;
714
715    for row in rows {
716        let grantee = if row.grantee == "PUBLIC" {
717            GranteeType::Public
718        } else {
719            GranteeType::Role(row.grantee.clone())
720        };
721
722        // Distinguish between domains and other types (typtype: 'd' for domain)
723        let target = AttrTarget::object(if row.type_kind == b'd' as i8 {
724            DbObjectId::Domain {
725                schema: row.schema_name.clone(),
726                name: row.type_name.clone(),
727            }
728        } else {
729            DbObjectId::Type {
730                schema: row.schema_name.clone(),
731                name: row.type_name.clone(),
732            }
733        });
734
735        let with_grant_option = row.is_grantable == "YES";
736
737        match &mut current_grant {
738            Some(grant)
739                if grant.grantee == grantee
740                    && grant.target == target
741                    && grant.with_grant_option == with_grant_option =>
742            {
743                grant.privileges.push(row.privilege_type);
744            }
745            _ => {
746                if let Some(grant) = current_grant.take() {
747                    result.push(grant);
748                }
749
750                // Grants only depend on the target object, not the grantee role
751                // (roles are assumed to exist externally to pgmt)
752                let depends_on = vec![target.db_object_id()];
753
754                current_grant = Some(Grant {
755                    grantee,
756                    target,
757                    privileges: vec![row.privilege_type],
758                    with_grant_option,
759                    depends_on,
760                    object_owner: row.object_owner.clone(),
761                    is_default_acl: row.is_default_acl,
762                });
763            }
764        }
765    }
766
767    if let Some(grant) = current_grant {
768        result.push(grant);
769    }
770
771    Ok(result)
772}