diff --git a/apps/desktop/src/components/grid/DataGrid.vue b/apps/desktop/src/components/grid/DataGrid.vue index 2abe97838..7fe430aff 100644 --- a/apps/desktop/src/components/grid/DataGrid.vue +++ b/apps/desktop/src/components/grid/DataGrid.vue @@ -7130,7 +7130,7 @@ async function fetchDdl() { ddlLoading.value = true; try { // Preserve view identity so the backend loads the stored view source instead of synthesizing table DDL. - ddlContent.value = await api.getTableDdl(props.connectionId, props.database || "", props.tableMeta.schema || props.database || "", props.tableMeta.tableName, tableObjectSourceKind(props.tableMeta.tableType), props.tableMeta.catalog); + ddlContent.value = await api.getTableDisplayDdl(props.connectionId, props.database || "", props.tableMeta.schema || props.database || "", props.tableMeta.tableName, tableObjectSourceKind(props.tableMeta.tableType), props.tableMeta.catalog); } catch (e: any) { ddlContent.value = `-- Error: ${e}`; } finally { diff --git a/apps/desktop/src/components/objects/DdlViewDialog.vue b/apps/desktop/src/components/objects/DdlViewDialog.vue index 73690bb09..d42871095 100644 --- a/apps/desktop/src/components/objects/DdlViewDialog.vue +++ b/apps/desktop/src/components/objects/DdlViewDialog.vue @@ -61,7 +61,7 @@ watch( ddlLoading.value = true; try { const schema = props.schema || props.database; - const ddl = await api.getTableDdl(props.connectionId, props.database, schema, props.tableName, props.objectType, props.catalog); + const ddl = await api.getTableDisplayDdl(props.connectionId, props.database, schema, props.tableName, props.objectType, props.catalog); ddlContent.value = await formatSqlForDisplay(ddl, props.formatDialect ?? props.dialect, settingsStore.editorSettings.sqlFormatter); } catch (e: any) { ddlError.value = e?.message || String(e); @@ -176,7 +176,7 @@ function retry() { ddlContent.value = ""; const schema = props.schema || props.database; api - .getTableDdl(props.connectionId, props.database, schema, props.tableName, props.objectType) + .getTableDisplayDdl(props.connectionId, props.database, schema, props.tableName, props.objectType) .then(async (ddl) => { ddlContent.value = await formatSqlForDisplay(ddl, props.formatDialect ?? props.dialect, settingsStore.editorSettings.sqlFormatter); }) diff --git a/apps/desktop/src/components/objects/ObjectBrowser.vue b/apps/desktop/src/components/objects/ObjectBrowser.vue index df2fe3e6d..e00480c0c 100644 --- a/apps/desktop/src/components/objects/ObjectBrowser.vue +++ b/apps/desktop/src/components/objects/ObjectBrowser.vue @@ -934,7 +934,7 @@ async function fetchTableDdl() { tableDdlLoading.value = true; try { const schema = row.schema || selectedSchema.value || props.database; - const ddl = await api.getTableDdl(props.connection.id, props.database || "", schema, row.name, tableDdlObjectType(row.type), props.catalog); + const ddl = await api.getTableDisplayDdl(props.connection.id, props.database || "", schema, row.name, tableDdlObjectType(row.type), props.catalog); if (sidePanelGuard.isStale(epoch)) return; tableDdlContent.value = ddl; } catch (e: any) { diff --git a/apps/desktop/src/components/sidebar/SidebarTreeRuntimeHost.vue b/apps/desktop/src/components/sidebar/SidebarTreeRuntimeHost.vue index 14ac0c5a4..87cea522c 100644 --- a/apps/desktop/src/components/sidebar/SidebarTreeRuntimeHost.vue +++ b/apps/desktop/src/components/sidebar/SidebarTreeRuntimeHost.vue @@ -1248,9 +1248,9 @@ async function generateDdlTemplate() { const schema = node.schema || node.database; let ddl: string; if (node.type === "table") { - ddl = await api.getTableDdl(node.connectionId, node.database, schema, node.label, undefined, node.catalog); + ddl = await api.getTableDisplayDdl(node.connectionId, node.database, schema, node.label, undefined, node.catalog); } else if (node.type === "materialized_view") { - ddl = await api.getTableDdl(node.connectionId, node.database, schema, node.label, "MATERIALIZED_VIEW", node.catalog); + ddl = await api.getTableDisplayDdl(node.connectionId, node.database, schema, node.label, "MATERIALIZED_VIEW", node.catalog); } else { const result = await api.getObjectSource(node.connectionId, node.database, schema, node.label, "VIEW"); ddl = await buildViewDdl({ diff --git a/apps/desktop/src/components/structure/TableStructureEditor.vue b/apps/desktop/src/components/structure/TableStructureEditor.vue index 4b5493fea..81422e4c1 100644 --- a/apps/desktop/src/components/structure/TableStructureEditor.vue +++ b/apps/desktop/src/components/structure/TableStructureEditor.vue @@ -155,7 +155,7 @@ async function fetchDdl() { if (!props.connectionId || !props.database || !props.tableName || ddlFetched.value || !tableMetadataCapabilities.value.ddl) return; ddlLoading.value = true; try { - const ddl = await api.getTableDdl(props.connectionId, props.database, metadataSchema.value, props.tableName, undefined, props.catalog); + const ddl = await api.getTableDisplayDdl(props.connectionId, props.database, metadataSchema.value, props.tableName, undefined, props.catalog); ddlContent.value = await formatSqlForDisplay(ddl, sqlFormatDialectForDbType(databaseType.value), settingsStore.editorSettings.sqlFormatter); ddlFetched.value = true; } catch (e: any) { @@ -960,7 +960,7 @@ async function hydrateRestoredDraftFromDatabase() { let nextColumns = await api.getColumns(connectionId, database, schema, tableName, catalog); if (databaseType.value === "manticoresearch" && tableMetadataCapabilities.value.ddl) { try { - const ddl = await api.getTableDdl(connectionId, database, schema, tableName, undefined, catalog); + const ddl = await api.getTableDisplayDdl(connectionId, database, schema, tableName, undefined, catalog); ddlContent.value = await formatSqlForDisplay(ddl, sqlFormatDialectForDbType(databaseType.value), settingsStore.editorSettings.sqlFormatter); ddlFetched.value = true; nextColumns = applyManticoreDdlColumnExtras(nextColumns, ddl); @@ -1256,7 +1256,7 @@ async function loadStructure(silent = false, scope: TableStructureRefreshScope = if (nextColumns) { if (databaseType.value === "manticoresearch" && tableMetadataCapabilities.value.ddl) { try { - const ddl = await api.getTableDdl(connectionId, database, schema, tableName, undefined, catalog); + const ddl = await api.getTableDisplayDdl(connectionId, database, schema, tableName, undefined, catalog); ddlContent.value = await formatSqlForDisplay(ddl, sqlFormatDialectForDbType(databaseType.value), settingsStore.editorSettings.sqlFormatter); ddlFetched.value = true; nextColumns = applyManticoreDdlColumnExtras(nextColumns, ddl); diff --git a/apps/desktop/src/lib/__tests__/table/contentAreaObjectBrowserCatalog.spec.ts b/apps/desktop/src/lib/__tests__/table/contentAreaObjectBrowserCatalog.spec.ts index 48d945198..11759011f 100644 --- a/apps/desktop/src/lib/__tests__/table/contentAreaObjectBrowserCatalog.spec.ts +++ b/apps/desktop/src/lib/__tests__/table/contentAreaObjectBrowserCatalog.spec.ts @@ -5,6 +5,8 @@ const contentAreaSource = readFileSync(new URL("../../../components/layout/Conte const connectionTreeSource = readFileSync(new URL("../../../components/sidebar/ConnectionTree.vue", import.meta.url), "utf8"); const ddlViewDialogSource = readFileSync(new URL("../../../components/objects/DdlViewDialog.vue", import.meta.url), "utf8"); const objectBrowserSource = readFileSync(new URL("../../../components/objects/ObjectBrowser.vue", import.meta.url), "utf8"); +const fetchTableDdlSource = objectBrowserSource.match(/async function fetchTableDdl\(\)[\s\S]*?(?=\nasync function fetchTableColumns\()/)?.[0] ?? ""; +const exportStructureSource = objectBrowserSource.match(/async function exportStructure\([\s\S]*?(?=\nasync function exportDataLegacy\()/)?.[0] ?? ""; function openingTag(source: string, componentName: string): string { return source.match(new RegExp(`<${componentName}\\b[\\s\\S]*?>`))?.[0] ?? ""; @@ -24,7 +26,7 @@ describe("ContentArea external catalog wiring", () => { }); it("forwards the DDL dialog catalog to the metadata API", () => { - expect(ddlViewDialogSource).toMatch(/api\.getTableDdl\([\s\S]*?props\.objectType, props\.catalog\)/); + expect(ddlViewDialogSource).toMatch(/api\.getTableDisplayDdl\([\s\S]*?props\.objectType, props\.catalog\)/); }); }); @@ -43,3 +45,14 @@ describe("ContentArea object browser refresh wiring", () => { expect(objectBrowserSource).toMatch(/]*:title="refreshTooltip"[^>]*@click="reload">/); }); }); + +describe("ObjectBrowser DDL API boundaries", () => { + it("uses display DDL for the table information panel", () => { + expect(fetchTableDdlSource).toMatch(/api\.getTableDisplayDdl\([\s\S]*?props\.catalog\);/); + }); + + it("keeps structure exports on the portable base DDL", () => { + expect(exportStructureSource).toMatch(/api\.getTableDdl\([\s\S]*?props\.catalog\);/); + expect(exportStructureSource).not.toContain("api.getTableDisplayDdl("); + }); +}); diff --git a/apps/desktop/src/lib/backend/api.ts b/apps/desktop/src/lib/backend/api.ts index 99d3272af..72600987b 100644 --- a/apps/desktop/src/lib/backend/api.ts +++ b/apps/desktop/src/lib/backend/api.ts @@ -165,6 +165,7 @@ export const listConstraints = forward("listConstraints"); export const listPartitions = forward("listPartitions"); export const listSubpartitions = forward("listSubpartitions"); export const getTableDdl = forward("getTableDdl"); +export const getTableDisplayDdl = forward("getTableDisplayDdl"); export const listFunctions = forward("listFunctions"); export const listSequences = forward("listSequences"); export const listRules = forward("listRules"); diff --git a/apps/desktop/src/lib/backend/http.ts b/apps/desktop/src/lib/backend/http.ts index 67ba3d061..a97e14c2d 100644 --- a/apps/desktop/src/lib/backend/http.ts +++ b/apps/desktop/src/lib/backend/http.ts @@ -722,6 +722,10 @@ export async function getTableDdl(connectionId: string, database: string, schema return get(`/api/schema/ddl?${qs({ connection_id: connectionId, database, schema, table, object_type: objectType, catalog })}`); } +export async function getTableDisplayDdl(connectionId: string, database: string, schema: string, table: string, objectType?: ObjectSourceKind, catalog?: string): Promise { + return get(`/api/schema/ddl?${qs({ connection_id: connectionId, database, schema, table, object_type: objectType, catalog, include_postgres_access: true })}`); +} + export async function prepareSchemaDiff(options: SchemaDiffPreparationOptions): Promise { return post("/api/schema-diff/prepare", options); } diff --git a/apps/desktop/src/lib/backend/tauri.ts b/apps/desktop/src/lib/backend/tauri.ts index 1f0c6e84d..011fc66f3 100644 --- a/apps/desktop/src/lib/backend/tauri.ts +++ b/apps/desktop/src/lib/backend/tauri.ts @@ -1202,6 +1202,10 @@ export async function getTableDdl(connectionId: string, database: string, schema return invoke("get_table_ddl", { connectionId, database, schema, table, objectType, catalog }); } +export async function getTableDisplayDdl(connectionId: string, database: string, schema: string, table: string, objectType?: ObjectSourceKind, catalog?: string): Promise { + return invoke("get_table_ddl", { connectionId, database, schema, table, objectType, catalog, includePostgresAccess: true }); +} + export async function prepareSchemaDiff(options: SchemaDiffPreparationOptions): Promise { return invoke("prepare_schema_diff", { options }); } diff --git a/crates/dbx-core/src/db/postgres.rs b/crates/dbx-core/src/db/postgres.rs index b2b477f22..8d3dbb754 100644 --- a/crates/dbx-core/src/db/postgres.rs +++ b/crates/dbx-core/src/db/postgres.rs @@ -30,6 +30,22 @@ use crate::types::{ OwnerInfo, QueryResult, RuleInfo, SchemaInfo, SequenceInfo, TableInfo, TriggerInfo, }; +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct PostgresTablePrivilegeInfo { + pub grantor: String, + pub grantee: String, + pub privilege_type: String, + pub is_grantable: bool, + pub column_name: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct PostgresTableAccessInfo { + pub owner: String, + pub owner_default_privileges: Vec, + pub privileges: Vec, +} + fn pg_temporal_to_json_value(row: &Row, idx: usize) -> Option { if let Ok(v) = row.try_get::<_, DateTime>(idx) { return Some(serde_json::Value::String(format_pg_timestamptz(v))); @@ -3338,6 +3354,37 @@ const POSTGRES_OWNERS_SQL: &str = WHERE n.nspname = $1 \ AND c.relkind IN ('r', 'v', 'm', 'S', 'f', 'p')"; +const POSTGRES_TABLE_OWNER_SQL: &str = "SELECT pg_get_userbyid(c.relowner)::text, \ + ARRAY(SELECT default_acl.privilege_type::text \ + FROM pg_catalog.aclexplode(pg_catalog.acldefault('r', c.relowner)) default_acl \ + WHERE default_acl.grantee = c.relowner \ + ORDER BY default_acl.privilege_type) \ + FROM pg_catalog.pg_class c \ + JOIN pg_catalog.pg_namespace n ON n.oid = c.relnamespace \ + WHERE n.nspname = $1 AND c.relname = $2 AND c.relkind IN ('r', 'p') \ + ORDER BY c.oid LIMIT 1"; + +const POSTGRES_TABLE_ACL_PRIVILEGES_SQL: &str = + "SELECT CASE WHEN acl.grantee = 0 THEN 'PUBLIC' ELSE grantee.rolname END::text, \ + acl.privilege_type::text, acl.is_grantable, pg_get_userbyid(acl.grantor)::text \ + FROM pg_catalog.pg_class c \ + JOIN pg_catalog.pg_namespace n ON n.oid = c.relnamespace \ + JOIN LATERAL pg_catalog.aclexplode(COALESCE(c.relacl, pg_catalog.acldefault('r', c.relowner))) acl ON true \ + LEFT JOIN pg_catalog.pg_roles grantee ON grantee.oid = acl.grantee \ + WHERE n.nspname = $1 AND c.relname = $2 AND c.relkind IN ('r', 'p') \ + ORDER BY 4, 1, 2, 3"; + +const POSTGRES_COLUMN_ACL_PRIVILEGES_SQL: &str = + "SELECT CASE WHEN acl.grantee = 0 THEN 'PUBLIC' ELSE grantee.rolname END::text, \ + acl.privilege_type::text, acl.is_grantable, a.attname::text, pg_get_userbyid(acl.grantor)::text \ + FROM pg_catalog.pg_class c \ + JOIN pg_catalog.pg_namespace n ON n.oid = c.relnamespace \ + JOIN pg_catalog.pg_attribute a ON a.attrelid = c.oid AND a.attnum > 0 AND NOT a.attisdropped \ + JOIN LATERAL pg_catalog.aclexplode(a.attacl) acl ON true \ + LEFT JOIN pg_catalog.pg_roles grantee ON grantee.oid = acl.grantee \ + WHERE n.nspname = $1 AND c.relname = $2 AND c.relkind IN ('r', 'p') \ + ORDER BY 5, 1, 2, 3, 4"; + fn postgres_owner_object_type(relkind: &str) -> &str { match relkind { "r" => "TABLE", @@ -3815,6 +3862,53 @@ pub async fn list_owners(pool: &Pool, schema: &str) -> Result, St .collect()) } +pub async fn get_table_access(pool: &Pool, schema: &str, table: &str) -> Result { + let client = checkout_postgres_client(pool, None, super::connection_timeout()).await?; + let params: [&(dyn tokio_postgres::types::ToSql + Sync); 2] = [&schema, &table]; + let owner_rows = + postgres_query_cached(&client, POSTGRES_TABLE_OWNER_SQL, ¶ms).await.map_err(pg_error_to_string)?; + let owner_row = owner_rows.first().ok_or_else(|| "Table owner not found".to_string())?; + let owner = pg_row_try_string(owner_row, 0); + if owner.is_empty() { + return Err("Table owner not found".to_string()); + } + let owner_default_privileges = owner_row.try_get::<_, Vec>(1).unwrap_or_default(); + if owner_default_privileges.is_empty() { + return Err("Table owner default privileges are unavailable".to_string()); + } + + let (table_privileges, column_privileges) = tokio::try_join!( + postgres_query_cached(&client, POSTGRES_TABLE_ACL_PRIVILEGES_SQL, ¶ms), + postgres_query_cached(&client, POSTGRES_COLUMN_ACL_PRIVILEGES_SQL, ¶ms), + ) + .map_err(pg_error_to_string)?; + + let privileges = table_privileges + .iter() + .map(|row| PostgresTablePrivilegeInfo { + grantor: pg_row_try_string(row, 3), + grantee: pg_row_try_string(row, 0), + privilege_type: pg_row_try_string(row, 1), + is_grantable: pg_row_try_bool(row, 2).unwrap_or(false), + column_name: None, + }) + .chain(column_privileges.iter().map(|row| PostgresTablePrivilegeInfo { + grantor: pg_row_try_string(row, 4), + grantee: pg_row_try_string(row, 0), + privilege_type: pg_row_try_string(row, 1), + is_grantable: pg_row_try_bool(row, 2).unwrap_or(false), + column_name: Some(pg_row_try_string(row, 3)), + })) + .collect::>(); + if privileges.iter().any(|privilege| { + privilege.grantor.is_empty() || privilege.grantee.is_empty() || privilege.privilege_type.is_empty() + }) { + return Err("Table ACL metadata is incomplete".to_string()); + } + + Ok(PostgresTableAccessInfo { owner, owner_default_privileges, privileges }) +} + /// Execute multiple SQL statements in a single round-trip using batch_execute. /// Best for DDL scripts where per-statement affected-row counts are not needed. pub async fn execute_batch(pool: &Pool, statements: &[String]) -> Result<(), String> { @@ -4617,6 +4711,19 @@ mod tests { assert!(escaped.matches('"').count().is_multiple_of(2), "quote count should be even"); } + #[test] + fn postgres_table_access_reads_complete_catalog_acls() { + assert!(POSTGRES_TABLE_OWNER_SQL.contains("acldefault('r', c.relowner)")); + assert!( + POSTGRES_TABLE_ACL_PRIVILEGES_SQL.contains("COALESCE(c.relacl, pg_catalog.acldefault('r', c.relowner))") + ); + assert!(POSTGRES_COLUMN_ACL_PRIVILEGES_SQL.contains("aclexplode(a.attacl)")); + assert!(POSTGRES_TABLE_ACL_PRIVILEGES_SQL.contains("acl.grantee = 0 THEN 'PUBLIC'")); + assert!(POSTGRES_COLUMN_ACL_PRIVILEGES_SQL.contains("acl.grantee = 0 THEN 'PUBLIC'")); + assert!(POSTGRES_TABLE_ACL_PRIVILEGES_SQL.contains("pg_get_userbyid(acl.grantor)")); + assert!(POSTGRES_COLUMN_ACL_PRIVILEGES_SQL.contains("pg_get_userbyid(acl.grantor)")); + } + // --- query_result_row_limit --- #[test] diff --git a/crates/dbx-core/src/schema.rs b/crates/dbx-core/src/schema.rs index fa3ac00cc..e5e8a27b8 100644 --- a/crates/dbx-core/src/schema.rs +++ b/crates/dbx-core/src/schema.rs @@ -3,7 +3,7 @@ use crate::db; use crate::models::connection::{ConnectionConfig, DatabaseType}; use crate::query::{agent_execute_query_params, should_discard_pool_after_error, QueryExecutionOptions}; use serde::{Deserialize, Serialize}; -use std::collections::HashMap; +use std::collections::{BTreeMap, BTreeSet, HashMap}; use std::future::Future; use std::sync::Arc; use std::time::{Duration, Instant}; @@ -5645,6 +5645,29 @@ pub async fn get_table_ddl_core( schema: &str, table: &str, object_type: Option, +) -> Result { + get_table_ddl_core_with_options(state, connection_id, database, schema, table, object_type, false).await +} + +pub async fn get_table_display_ddl_core( + state: &AppState, + connection_id: &str, + database: &str, + schema: &str, + table: &str, + object_type: Option, +) -> Result { + get_table_ddl_core_with_options(state, connection_id, database, schema, table, object_type, true).await +} + +async fn get_table_ddl_core_with_options( + state: &AppState, + connection_id: &str, + database: &str, + schema: &str, + table: &str, + object_type: Option, + include_postgres_access: bool, ) -> Result { if crate::sql_dialect::parse_sqlserver_linked_schema_ref(schema).is_some() { return Err("DDL is not supported for SQL Server linked server tables".to_string()); @@ -5801,6 +5824,11 @@ pub async fn get_table_ddl_core( PoolKind::Postgres(p) if db_config.as_ref().is_some_and(is_cloudberry_config) => { cloudberry_ddl(p, schema, table).await } + PoolKind::Postgres(p) + if include_postgres_access && db_config.as_ref().is_some_and(is_native_postgres_config) => + { + pg_display_ddl(p, schema, table).await + } PoolKind::Postgres(p) => pg_ddl(p, schema, table).await, PoolKind::Sqlite(p) => sqlite_ddl(p, schema, table).await, PoolKind::Rqlite(client) => db::rqlite_driver::table_ddl(client, table).await, @@ -5818,6 +5846,10 @@ fn is_opengauss_family_config(config: &ConnectionConfig) -> bool { || matches!(config.driver_profile.as_deref(), Some("opengauss" | "gaussdb")) } +fn is_native_postgres_config(config: &ConnectionConfig) -> bool { + config.db_type == DatabaseType::Postgres && matches!(config.driver_profile.as_deref(), None | Some("postgres")) +} + fn is_cloudberry_config(config: &ConnectionConfig) -> bool { matches!(config.driver_profile.as_deref(), Some("cloudberry")) } @@ -7244,6 +7276,85 @@ mod ddl_tests { assert!(ddl.contains("COMMENT ON TABLE \"public\".\"users\" IS 'User table';")); } + #[test] + fn postgres_display_ddl_preserves_owner_revokes_and_grant_chain() { + use db::postgres::{PostgresTableAccessInfo, PostgresTablePrivilegeInfo}; + + let privilege = + |grantor: &str, grantee: &str, privilege_type: &str, is_grantable: bool, column_name: Option<&str>| { + PostgresTablePrivilegeInfo { + grantor: grantor.to_string(), + grantee: grantee.to_string(), + privilege_type: privilege_type.to_string(), + is_grantable, + column_name: column_name.map(str::to_string), + } + }; + let access = PostgresTableAccessInfo { + owner: "table\"owner".to_string(), + owner_default_privileges: vec!["DELETE", "INSERT", "SELECT", "UPDATE"] + .into_iter() + .map(str::to_string) + .collect(), + privileges: vec![ + privilege("table\"owner", "table\"owner", "SELECT", false, None), + privilege("table\"owner", "z manager", "SELECT", true, None), + privilege("table\"owner", "z manager", "SELECT", true, Some("customer_name")), + privilege("z manager", "a delegate", "SELECT", true, None), + privilege("a delegate", "reader role", "SELECT", false, Some("customer_name")), + privilege("z manager", "reader role", "SELECT", false, Some("customer_name")), + privilege("table\"owner", "PUBLIC", "INSERT", false, Some("customer_name")), + privilege("table\"owner", "PUBLIC", "INSERT", false, Some("amount")), + ], + }; + + let ddl = append_postgres_access_ddl( + "CREATE TABLE \"app\".\"orders\" (\n \"id\" bigint\n);\n".to_string(), + "app", + "orders", + &access, + ); + + assert!(ddl.contains("ALTER TABLE \"app\".\"orders\" OWNER TO \"table\"\"owner\";")); + assert!(ddl.contains("REVOKE DELETE, INSERT, UPDATE ON TABLE \"app\".\"orders\" FROM \"table\"\"owner\";")); + assert!(ddl.contains("GRANT INSERT (\"amount\", \"customer_name\") ON TABLE \"app\".\"orders\" TO PUBLIC;")); + assert!(ddl.contains("GRANT SELECT (\"customer_name\") ON TABLE \"app\".\"orders\" TO \"reader role\";")); + + let owner_role = ddl.find("SET ROLE \"table\"\"owner\";").unwrap(); + let manager_role = ddl.find("SET ROLE \"z manager\";").unwrap(); + let delegate_role = ddl.find("SET ROLE \"a delegate\";").unwrap(); + assert!(owner_role < manager_role && manager_role < delegate_role, "ddl: {ddl}"); + assert!(ddl[owner_role..manager_role] + .contains("GRANT SELECT ON TABLE \"app\".\"orders\" TO \"z manager\" WITH GRANT OPTION;")); + assert!(ddl[owner_role..manager_role].contains( + "GRANT SELECT (\"customer_name\") ON TABLE \"app\".\"orders\" TO \"z manager\" WITH GRANT OPTION;" + )); + assert!(ddl[manager_role..delegate_role] + .contains("GRANT SELECT ON TABLE \"app\".\"orders\" TO \"a delegate\" WITH GRANT OPTION;")); + assert!(ddl[delegate_role..] + .contains("GRANT SELECT (\"customer_name\") ON TABLE \"app\".\"orders\" TO \"reader role\";")); + } + + #[test] + fn postgres_display_ddl_can_revoke_all_owner_ordinary_privileges() { + let access = db::postgres::PostgresTableAccessInfo { + owner: "locked_owner".to_string(), + owner_default_privileges: vec!["INSERT", "SELECT", "UPDATE"].into_iter().map(str::to_string).collect(), + privileges: vec![], + }; + + let ddl = append_postgres_access_ddl( + "CREATE TABLE \"app\".\"locked\" (\"id\" bigint);".to_string(), + "app", + "locked", + &access, + ); + + assert!(ddl.contains("SET ROLE \"locked_owner\";")); + assert!(ddl.contains("REVOKE INSERT, SELECT, UPDATE ON TABLE \"app\".\"locked\" FROM \"locked_owner\";")); + assert!(ddl.ends_with("RESET ROLE;")); + } + #[test] fn postgres_table_ddl_omits_table_comment_when_empty() { let columns = vec![column("id", "integer")]; @@ -7546,6 +7657,215 @@ pub async fn pg_ddl(pool: &deadpool_postgres::Pool, schema: &str, table: &str) - )) } +async fn pg_display_ddl(pool: &deadpool_postgres::Pool, schema: &str, table: &str) -> Result { + let (ddl, access) = tokio::join!(pg_ddl(pool, schema, table), db::postgres::get_table_access(pool, schema, table)); + let ddl = ddl?; + match access { + Ok(access) => Ok(append_postgres_access_ddl(ddl, schema, table, &access)), + Err(error) => { + log::warn!( + "[schema][postgres:table-access-ddl-fallback] schema={} table={} error={}", + schema, + table, + error + ); + Ok(ddl) + } + } +} + +fn append_postgres_access_ddl( + mut ddl: String, + schema: &str, + table: &str, + access: &db::postgres::PostgresTableAccessInfo, +) -> String { + let table_name = format!("{}.{}", pg_ident(schema), pg_ident(table)); + ddl = ddl.trim_end().to_string(); + if !ddl.ends_with(';') { + ddl.push(';'); + } + ddl.push_str(&format!("\n\nALTER TABLE {table_name} OWNER TO {};", pg_ident(&access.owner))); + + let owner_privileges = access + .privileges + .iter() + .filter(|privilege| { + privilege.grantor == access.owner && privilege.grantee == access.owner && privilege.column_name.is_none() + }) + .map(|privilege| privilege.privilege_type.clone()) + .collect::>(); + let owner_revokes = access + .owner_default_privileges + .iter() + .filter(|privilege| !owner_privileges.contains(*privilege)) + .cloned() + .collect::>(); + let grants = normalized_postgres_grants(access); + let grantor_order = postgres_grantor_order(&access.owner, !owner_revokes.is_empty(), &grants); + + // PostgreSQL records the active granting role, so each batch must run in that role's context. + for grantor in grantor_order { + ddl.push_str(&format!("\n\nSET ROLE {};", pg_ident(&grantor))); + if grantor == access.owner && !owner_revokes.is_empty() { + ddl.push_str(&format!( + "\nREVOKE {} ON TABLE {table_name} FROM {};", + owner_revokes.iter().cloned().collect::>().join(", "), + pg_ident(&access.owner) + )); + } + append_postgres_grants_for_role(&mut ddl, &table_name, &grantor, &grants); + ddl.push_str("\nRESET ROLE;"); + } + + ddl +} + +#[derive(Debug, Clone, PartialEq, Eq)] +struct PostgresGrant { + grantor: String, + grantee: String, + privilege_type: String, + is_grantable: bool, + column_name: Option, +} + +fn normalized_postgres_grants(access: &db::postgres::PostgresTableAccessInfo) -> Vec { + let mut grants = BTreeMap::<(String, String, String, Option), bool>::new(); + for privilege in &access.privileges { + if privilege.grantor == access.owner && privilege.grantee == access.owner && privilege.column_name.is_none() { + continue; + } + *grants + .entry(( + privilege.grantor.clone(), + privilege.grantee.clone(), + privilege.privilege_type.clone(), + privilege.column_name.clone(), + )) + .or_default() |= privilege.is_grantable; + } + grants + .into_iter() + .map(|((grantor, grantee, privilege_type, column_name), is_grantable)| PostgresGrant { + grantor, + grantee, + privilege_type, + is_grantable, + column_name, + }) + .collect() +} + +fn postgres_grant_scope_covers(parent: &PostgresGrant, child: &PostgresGrant) -> bool { + if parent.privilege_type != child.privilege_type { + return false; + } + match (&parent.column_name, &child.column_name) { + (None, _) => true, + (Some(parent), Some(child)) => parent == child, + (Some(_), None) => false, + } +} + +fn postgres_grantor_order(owner: &str, include_owner: bool, grants: &[PostgresGrant]) -> Vec { + let mut remaining = grants.iter().map(|grant| grant.grantor.clone()).collect::>(); + if include_owner { + remaining.insert(owner.to_string()); + } + + let mut dependencies = BTreeMap::>::new(); + for child in grants.iter().filter(|grant| grant.grantor != owner) { + for parent in grants.iter().filter(|grant| { + grant.grantee == child.grantor + && grant.is_grantable + && grant.grantor != child.grantor + && postgres_grant_scope_covers(grant, child) + }) { + dependencies.entry(child.grantor.clone()).or_default().insert(parent.grantor.clone()); + } + } + + let mut order = Vec::with_capacity(remaining.len()); + if remaining.remove(owner) { + order.push(owner.to_string()); + } + while !remaining.is_empty() { + let ready = remaining + .iter() + .filter(|grantor| { + dependencies.get(*grantor).is_none_or(|required| required.iter().all(|role| !remaining.contains(role))) + }) + .cloned() + .collect::>(); + if ready.is_empty() { + order.extend(remaining); + break; + } + for grantor in ready { + remaining.remove(&grantor); + order.push(grantor); + } + } + order +} + +fn append_postgres_grants_for_role(ddl: &mut String, table_name: &str, grantor: &str, grants: &[PostgresGrant]) { + let mut table_grants = BTreeMap::<(String, bool), BTreeSet>::new(); + let mut column_grants = BTreeMap::<(String, bool), BTreeMap>>::new(); + for grant in grants.iter().filter(|grant| grant.grantor == grantor) { + if let Some(column) = &grant.column_name { + column_grants + .entry((grant.grantee.clone(), grant.is_grantable)) + .or_default() + .entry(grant.privilege_type.clone()) + .or_default() + .insert(column.clone()); + } else { + table_grants + .entry((grant.grantee.clone(), grant.is_grantable)) + .or_default() + .insert(grant.privilege_type.clone()); + } + } + + for ((grantee, is_grantable), privileges) in table_grants { + append_postgres_grant_statement( + ddl, + table_name, + &grantee, + is_grantable, + privileges.into_iter().collect::>().join(", "), + ); + } + for ((grantee, is_grantable), privileges) in column_grants { + let privileges = privileges + .into_iter() + .map(|(privilege, columns)| { + format!( + "{} ({})", + privilege, + columns.into_iter().map(|column| pg_ident(&column)).collect::>().join(", ") + ) + }) + .collect::>() + .join(", "); + append_postgres_grant_statement(ddl, table_name, &grantee, is_grantable, privileges); + } +} + +fn append_postgres_grant_statement( + ddl: &mut String, + table_name: &str, + grantee: &str, + is_grantable: bool, + privileges: String, +) { + let grantee = if grantee == "PUBLIC" { grantee.to_string() } else { pg_ident(grantee) }; + let grant_option = if is_grantable { " WITH GRANT OPTION" } else { "" }; + ddl.push_str(&format!("\nGRANT {privileges} ON TABLE {table_name} TO {grantee}{grant_option};")); +} + pub async fn opengauss_table_ddl(pool: &deadpool_postgres::Pool, schema: &str, table: &str) -> Result { let (ddl, trigger_definitions) = tokio::try_join!( async { first_string_cell(db::postgres::execute_query(pool, &opengauss_table_ddl_sql(schema, table)).await?) }, diff --git a/crates/dbx-web/src/routes/schema.rs b/crates/dbx-web/src/routes/schema.rs index 46fa945b1..ce789d2dc 100644 --- a/crates/dbx-web/src/routes/schema.rs +++ b/crates/dbx-web/src/routes/schema.rs @@ -25,6 +25,7 @@ pub struct SchemaQuery { pub table_name_filter: Option, pub apply_visible_filter: Option, pub client_session_id: Option, + pub include_postgres_access: Option, } #[derive(Deserialize)] @@ -461,6 +462,17 @@ pub async fn get_ddl( dbx_core::schema::get_doris_catalog_table_ddl_core(&state.app, &q.connection_id, &catalog, database, table) .await .map_err(AppError::from)? + } else if q.include_postgres_access.unwrap_or(false) { + dbx_core::schema::get_table_display_ddl_core( + &state.app, + &q.connection_id, + database, + schema, + table, + q.object_type, + ) + .await + .map_err(AppError::from)? } else { dbx_core::schema::get_table_ddl_core(&state.app, &q.connection_id, database, schema, table, q.object_type) .await diff --git a/src-tauri/src/commands/schema.rs b/src-tauri/src/commands/schema.rs index b03741700..1a3929562 100644 --- a/src-tauri/src/commands/schema.rs +++ b/src-tauri/src/commands/schema.rs @@ -431,12 +431,18 @@ pub async fn get_table_ddl( table: String, object_type: Option, catalog: Option, + include_postgres_access: Option, ) -> Result { if let Some(catalog) = external_doris_catalog(&state, &connection_id, catalog.as_deref()).await { return dbx_core::schema::get_doris_catalog_table_ddl_core(&state, &connection_id, &catalog, &database, &table) .await; } - dbx_core::schema::get_table_ddl_core(&state, &connection_id, &database, &schema, &table, object_type).await + if include_postgres_access.unwrap_or(false) { + dbx_core::schema::get_table_display_ddl_core(&state, &connection_id, &database, &schema, &table, object_type) + .await + } else { + dbx_core::schema::get_table_ddl_core(&state, &connection_id, &database, &schema, &table, object_type).await + } } #[tauri::command]