diff --git a/agents/common/src/main/java/com/dbx/agent/DdlBuilder.java b/agents/common/src/main/java/com/dbx/agent/DdlBuilder.java index 8c3b518dd4..926f6c1549 100644 --- a/agents/common/src/main/java/com/dbx/agent/DdlBuilder.java +++ b/agents/common/src/main/java/com/dbx/agent/DdlBuilder.java @@ -219,6 +219,118 @@ public final class DdlBuilder { return ddl.toString(); } + /** + * Appends trailing SQL (e.g. GRANT statements) after a CREATE TABLE DDL script. + * Returns the original DDL unchanged when {@code trailingSql} is blank. + */ + public static String appendTrailingSql(String ddl, String trailingSql) { + if (!notBlank(trailingSql)) { + return ddl == null ? "" : ddl; + } + String base = ddl == null ? "" : ddl.trim(); + if (base.isEmpty()) { + return trailingSql.trim(); + } + if (!base.endsWith(";")) { + base = base + ";"; + } + String grants = trailingSql.trim(); + return base + "\n\n" + grants + (grants.endsWith(";") ? "" : ";"); + } + + /** + * Builds Oracle-style object GRANT statements for table and column privileges. + * Privileges are grouped by grantee / grantable / privilege(+columns). + */ + public static String buildOracleObjectGrantSql( + String schema, + String table, + List privileges + ) { + if (privileges == null || privileges.isEmpty() || !notBlank(table)) { + return ""; + } + String tableRef = qualifiedName(schema, table, false); + + Map> tablePrivileges = new LinkedHashMap<>(); + Map> columnPrivileges = new LinkedHashMap<>(); + for (OracleObjectPrivilege privilege : privileges) { + if (privilege == null || !notBlank(privilege.grantee()) || !notBlank(privilege.privilege())) { + continue; + } + String privilegeName = privilege.privilege().trim().toUpperCase(Locale.ROOT); + String grantableKey = privilege.grantable() ? "1" : "0"; + if (notBlank(privilege.columnName())) { + String key = privilege.grantee() + "\u0000" + privilegeName + "\u0000" + grantableKey; + columnPrivileges + .computeIfAbsent(key, ignored -> new ArrayList<>()) + .add(privilege.columnName().trim()); + } else { + String key = privilege.grantee() + "\u0000" + grantableKey; + List names = tablePrivileges.computeIfAbsent(key, ignored -> new ArrayList<>()); + if (!names.contains(privilegeName)) { + names.add(privilegeName); + } + } + } + + List statements = new ArrayList<>(); + for (Map.Entry> entry : tablePrivileges.entrySet()) { + String[] parts = entry.getKey().split("\u0000", -1); + String grantee = parts[0]; + boolean grantable = "1".equals(parts[1]); + statements.add(formatOracleGrant( + join(entry.getValue(), ", "), + null, + tableRef, + grantee, + grantable + )); + } + for (Map.Entry> entry : columnPrivileges.entrySet()) { + String[] parts = entry.getKey().split("\u0000", -1); + String grantee = parts[0]; + String privilegeName = parts[1]; + boolean grantable = "1".equals(parts[2]); + List columns = new ArrayList<>(); + for (String column : entry.getValue()) { + String quoted = quoteIdent(column, false); + if (!columns.contains(quoted)) { + columns.add(quoted); + } + } + statements.add(formatOracleGrant( + privilegeName, + join(columns, ", "), + tableRef, + grantee, + grantable + )); + } + return join(statements, "\n"); + } + + private static String formatOracleGrant( + String privilegeList, + String columnList, + String tableRef, + String grantee, + boolean grantable + ) { + StringBuilder statement = new StringBuilder("GRANT "); + statement.append(privilegeList); + if (notBlank(columnList)) { + statement.append(" (").append(columnList).append(")"); + } + statement.append(" ON ").append(tableRef); + statement.append(" TO ").append(quoteIdent(grantee, false)); + if (grantable) { + statement.append(" WITH GRANT OPTION"); + } + statement.append(";"); + return statement.toString(); + } + private static List groupForeignKeys(List foreignKeys) { List result = new ArrayList<>(); Map namedGroups = new LinkedHashMap<>(); diff --git a/agents/common/src/main/java/com/dbx/agent/OracleObjectPrivilege.java b/agents/common/src/main/java/com/dbx/agent/OracleObjectPrivilege.java new file mode 100644 index 0000000000..5a01bcb67c --- /dev/null +++ b/agents/common/src/main/java/com/dbx/agent/OracleObjectPrivilege.java @@ -0,0 +1,38 @@ +package com.dbx.agent; + +/** + * Oracle-compatible object privilege row used when reconstructing GRANT DDL. + */ +public final class OracleObjectPrivilege { + private final String grantee; + private final String privilege; + private final boolean grantable; + private final String columnName; + + public OracleObjectPrivilege(String grantee, String privilege, boolean grantable) { + this(grantee, privilege, grantable, null); + } + + public OracleObjectPrivilege(String grantee, String privilege, boolean grantable, String columnName) { + this.grantee = grantee; + this.privilege = privilege; + this.grantable = grantable; + this.columnName = columnName; + } + + public String grantee() { + return grantee; + } + + public String privilege() { + return privilege; + } + + public boolean grantable() { + return grantable; + } + + public String columnName() { + return columnName; + } +} diff --git a/agents/common/src/test/java/com/dbx/agent/CommonJavaCompatibilityTest.java b/agents/common/src/test/java/com/dbx/agent/CommonJavaCompatibilityTest.java index ae787bcd9e..1af9a28dba 100644 --- a/agents/common/src/test/java/com/dbx/agent/CommonJavaCompatibilityTest.java +++ b/agents/common/src/test/java/com/dbx/agent/CommonJavaCompatibilityTest.java @@ -750,6 +750,36 @@ class CommonJavaCompatibilityTest { assertTrue(ddl.contains("\"NAME\" NVARCHAR(100) NOT NULL")); } + @Test + void appendsOracleObjectGrantSqlAfterTableDdl() { + String ddl = DdlBuilder.buildTableDdl( + "APP", + "USERS", + Collections.singletonList(new ColumnInfo("ID", "NUMBER", false, null, true)), + Collections.emptyList(), + Collections.emptyList() + ); + String grants = DdlBuilder.buildOracleObjectGrantSql( + "APP", + "USERS", + Arrays.asList( + new OracleObjectPrivilege("READER", "SELECT", false), + new OracleObjectPrivilege("READER", "INSERT", false), + new OracleObjectPrivilege("ADMIN", "SELECT", true), + new OracleObjectPrivilege("ANALYST", "UPDATE", false, "NAME") + ) + ); + + String combined = DdlBuilder.appendTrailingSql(ddl, grants); + + assertTrue(combined.contains("CREATE TABLE \"APP\".\"USERS\"")); + assertTrue(combined.contains("GRANT SELECT, INSERT ON \"APP\".\"USERS\" TO \"READER\";")); + assertTrue(combined.contains("GRANT SELECT ON \"APP\".\"USERS\" TO \"ADMIN\" WITH GRANT OPTION;")); + assertTrue(combined.contains("GRANT UPDATE (\"NAME\") ON \"APP\".\"USERS\" TO \"ANALYST\";")); + assertEquals("", DdlBuilder.buildOracleObjectGrantSql("APP", "USERS", Collections.emptyList())); + assertEquals(ddl, DdlBuilder.appendTrailingSql(ddl, " ")); + } + private static class MinimalAgent implements DatabaseAgent { @Override public void connect(ConnectParams params) { diff --git a/agents/drivers/oceanbase-oracle/src/main/java/com/dbx/agent/oceanbaseoracle/OceanBaseOracleAgent.java b/agents/drivers/oceanbase-oracle/src/main/java/com/dbx/agent/oceanbaseoracle/OceanBaseOracleAgent.java index 7cf380ff7c..40f4501ad9 100644 --- a/agents/drivers/oceanbase-oracle/src/main/java/com/dbx/agent/oceanbaseoracle/OceanBaseOracleAgent.java +++ b/agents/drivers/oceanbase-oracle/src/main/java/com/dbx/agent/oceanbaseoracle/OceanBaseOracleAgent.java @@ -14,6 +14,7 @@ import com.dbx.agent.MultiSessionJsonRpcServer; import com.dbx.agent.MetadataListConstraints; import com.dbx.agent.ObjectInfo; import com.dbx.agent.ObjectSource; +import com.dbx.agent.OracleObjectPrivilege; import com.dbx.agent.TableInfo; import com.dbx.agent.TriggerInfo; @@ -556,7 +557,141 @@ public final class OceanBaseOracleAgent extends ConfiguredJdbcAgent { // Table comment is optional; DDL generation should still succeed without it. } - return DdlBuilder.buildTableDdl(schema, table, getColumns(schema, table), indexes, foreignKeys, java.util.Collections.emptyList(), false, true, tableComment); + String ddl = DdlBuilder.buildTableDdl( + schema, + table, + getColumns(schema, table), + indexes, + foreignKeys, + java.util.Collections.emptyList(), + false, + true, + tableComment + ); + try { + String owner = normalizeSchema(schema); + return DdlBuilder.appendTrailingSql(ddl, queryObjectGrantSql(owner, table)); + } catch (RuntimeException | SQLException ignored) { + // Privilege metadata is optional; CREATE TABLE DDL should still succeed without it. + return ddl; + } + } + + private String queryObjectGrantSql(String owner, String table) throws SQLException { + List privileges = new ArrayList<>(); + privileges.addAll(queryTablePrivileges(owner, table)); + try { + privileges.addAll(queryColumnPrivileges(owner, table)); + } catch (SQLException ignored) { + // Column privileges are optional when table-level grants are available. + } + return DdlBuilder.buildOracleObjectGrantSql(owner, table, privileges); + } + + private List queryTablePrivileges(String owner, String table) throws SQLException { + // DBA_TAB_PRIVS shows every object grant (OWNER column). ALL_TAB_PRIVS is limited to + // grants where the session user is owner/grantor/grantee (or PUBLIC), so admins + // browsing another schema often see nothing unless we prefer the DBA view. + return queryFirstAvailablePrivileges(tablePrivilegeQueries(), owner, table, null); + } + + private List queryColumnPrivileges(String owner, String table) throws SQLException { + return queryFirstAvailablePrivileges(columnPrivilegeQueries(), owner, table, "COLUMN_NAME"); + } + + private List queryFirstAvailablePrivileges( + List queries, + String owner, + String table, + String columnNameField + ) throws SQLException { + SQLException firstError = null; + for (String sql : queries) { + try { + return queryPrivileges(sql, owner, table, columnNameField); + } catch (SQLException error) { + if (firstError == null) { + firstError = error; + } else { + firstError.addSuppressed(error); + } + } + } + throw firstError == null ? new SQLException("No privilege views available") : firstError; + } + + private static List tablePrivilegeQueries() { + return List.of( + """ + SELECT GRANTEE, PRIVILEGE, GRANTABLE + FROM DBA_TAB_PRIVS + WHERE OWNER = ? AND TABLE_NAME = ? + ORDER BY GRANTEE, PRIVILEGE + """.stripIndent().trim(), + """ + SELECT GRANTEE, PRIVILEGE, GRANTABLE + FROM SYS.DBA_TAB_PRIVS + WHERE OWNER = ? AND TABLE_NAME = ? + ORDER BY GRANTEE, PRIVILEGE + """.stripIndent().trim(), + """ + SELECT GRANTEE, PRIVILEGE, GRANTABLE + FROM ALL_TAB_PRIVS + WHERE TABLE_SCHEMA = ? AND TABLE_NAME = ? + ORDER BY GRANTEE, PRIVILEGE + """.stripIndent().trim() + ); + } + + private static List columnPrivilegeQueries() { + return List.of( + """ + SELECT GRANTEE, PRIVILEGE, GRANTABLE, COLUMN_NAME + FROM DBA_COL_PRIVS + WHERE OWNER = ? AND TABLE_NAME = ? + ORDER BY GRANTEE, COLUMN_NAME, PRIVILEGE + """.stripIndent().trim(), + """ + SELECT GRANTEE, PRIVILEGE, GRANTABLE, COLUMN_NAME + FROM SYS.DBA_COL_PRIVS + WHERE OWNER = ? AND TABLE_NAME = ? + ORDER BY GRANTEE, COLUMN_NAME, PRIVILEGE + """.stripIndent().trim(), + """ + SELECT GRANTEE, PRIVILEGE, GRANTABLE, COLUMN_NAME + FROM ALL_COL_PRIVS + WHERE TABLE_SCHEMA = ? AND TABLE_NAME = ? + ORDER BY GRANTEE, COLUMN_NAME, PRIVILEGE + """.stripIndent().trim() + ); + } + + private List queryPrivileges( + String sql, + String owner, + String table, + String columnNameField + ) throws SQLException { + List result = new ArrayList<>(); + try (var stmt = requireConnection().prepareStatement(sql)) { + stmt.setString(1, owner); + stmt.setString(2, table); + try (ResultSet rs = stmt.executeQuery()) { + while (rs.next()) { + result.add(new OracleObjectPrivilege( + rs.getString("GRANTEE"), + rs.getString("PRIVILEGE"), + isYes(rs.getString("GRANTABLE")), + columnNameField == null ? null : rs.getString(columnNameField) + )); + } + } + } + return result; + } + + private static boolean isYes(String value) { + return value != null && "YES".equalsIgnoreCase(value.trim()); } @Override diff --git a/agents/drivers/oceanbase-oracle/src/test/java/com/dbx/agent/oceanbaseoracle/OceanBaseOracleAgentTest.java b/agents/drivers/oceanbase-oracle/src/test/java/com/dbx/agent/oceanbaseoracle/OceanBaseOracleAgentTest.java index 48f2066b5a..342f57e9c5 100644 --- a/agents/drivers/oceanbase-oracle/src/test/java/com/dbx/agent/oceanbaseoracle/OceanBaseOracleAgentTest.java +++ b/agents/drivers/oceanbase-oracle/src/test/java/com/dbx/agent/oceanbaseoracle/OceanBaseOracleAgentTest.java @@ -439,7 +439,15 @@ class OceanBaseOracleAgentTest { columnResultSet(new Object[][]{ {"CREATED_AT", "TIMESTAMP", "N", null, null, null, null, "SYSDATE", "Created timestamp", 0}, {"INTERNAL_NOTE", "VARCHAR2", "Y", null, null, 100, 100, null, " ", 0} - }) + }), + resultSet( + new String[]{"GRANTEE", "PRIVILEGE", "GRANTABLE"}, + new Object[][]{} + ), + resultSet( + new String[]{"GRANTEE", "COLUMN_NAME", "PRIVILEGE", "GRANTABLE"}, + new Object[][]{} + ) )); String ddl = agent.getTableDdl("APP", "AUDIT_LOG"); @@ -451,6 +459,147 @@ class OceanBaseOracleAgentTest { ); Assertions.assertTrue(ddl.contains("\"INTERNAL_NOTE\" VARCHAR2(100)"), ddl); Assertions.assertFalse(ddl.contains("\"INTERNAL_NOTE\" IS"), ddl); + Assertions.assertFalse(ddl.contains("GRANT "), ddl); + } + + @Test + void tableDdlAppendsObjectGrantsFromDictionaryViews() { + List sql = new ArrayList<>(); + OceanBaseOracleAgent agent = new OceanBaseOracleAgent(); + TestSupport.setPrivateConnection(agent, preparedConnection(sql, + resultSet( + new String[]{"INDEX_NAME", "COLUMN_NAME", "COLUMN_POSITION", "UNIQUENESS", "CONSTRAINT_TYPE", "INDEX_TYPE"}, + new Object[][]{} + ), + resultSet( + new String[]{"CONSTRAINT_NAME", "COLUMN_NAME", "TABLE_NAME", "REF_COLUMN_NAME"}, + new Object[][]{} + ), + resultSet( + new String[]{"COMMENTS"}, + new Object[][]{{null}} + ), + columnResultSet(new Object[][]{ + {"ID", "NUMBER", "N", 19, 0, null, null, null, null, 1}, + {"NAME", "VARCHAR2", "Y", null, null, 100, 100, null, null, 0} + }), + resultSet( + new String[]{"GRANTEE", "PRIVILEGE", "GRANTABLE"}, + new Object[][]{ + {"READER", "SELECT", "NO"}, + {"READER", "INSERT", "NO"}, + {"ADMIN", "SELECT", "YES"} + } + ), + resultSet( + new String[]{"GRANTEE", "COLUMN_NAME", "PRIVILEGE", "GRANTABLE"}, + new Object[][]{ + {"ANALYST", "NAME", "UPDATE", "NO"} + } + ) + )); + + String ddl = agent.getTableDdl("APP", "USERS"); + + Assertions.assertTrue(ddl.contains("CREATE TABLE \"APP\".\"USERS\""), ddl); + Assertions.assertTrue(ddl.contains("GRANT SELECT, INSERT ON \"APP\".\"USERS\" TO \"READER\";"), ddl); + Assertions.assertTrue( + ddl.contains("GRANT SELECT ON \"APP\".\"USERS\" TO \"ADMIN\" WITH GRANT OPTION;"), + ddl + ); + Assertions.assertTrue( + ddl.contains("GRANT UPDATE (\"NAME\") ON \"APP\".\"USERS\" TO \"ANALYST\";"), + ddl + ); + Assertions.assertTrue( + sql.stream().anyMatch(statement -> statement.toUpperCase(Locale.ROOT).contains("FROM DBA_TAB_PRIVS")), + String.valueOf(sql) + ); + Assertions.assertTrue( + sql.stream().anyMatch(statement -> statement.toUpperCase(Locale.ROOT).contains("FROM DBA_COL_PRIVS")), + String.valueOf(sql) + ); + } + + @Test + void tableDdlFallsBackToAllTabPrivsWhenDbaViewUnavailable() { + List sql = new ArrayList<>(); + OceanBaseOracleAgent agent = new OceanBaseOracleAgent(); + TestSupport.setPrivateConnection(agent, dbaFallbackPrivilegeConnection(sql, + resultSet( + new String[]{"INDEX_NAME", "COLUMN_NAME", "COLUMN_POSITION", "UNIQUENESS", "CONSTRAINT_TYPE", "INDEX_TYPE"}, + new Object[][]{} + ), + resultSet( + new String[]{"CONSTRAINT_NAME", "COLUMN_NAME", "TABLE_NAME", "REF_COLUMN_NAME"}, + new Object[][]{} + ), + resultSet( + new String[]{"COMMENTS"}, + new Object[][]{{null}} + ), + columnResultSet(new Object[][]{ + {"ID", "NUMBER", "N", 19, 0, null, null, null, null, 1} + }), + resultSet( + new String[]{"GRANTEE", "PRIVILEGE", "GRANTABLE"}, + new Object[][]{ + {"READER", "SELECT", "NO"} + } + ), + resultSet( + new String[]{"GRANTEE", "COLUMN_NAME", "PRIVILEGE", "GRANTABLE"}, + new Object[][]{} + ) + )); + + String ddl = agent.getTableDdl("APP", "USERS"); + + Assertions.assertTrue(ddl.contains("GRANT SELECT ON \"APP\".\"USERS\" TO \"READER\";"), ddl); + Assertions.assertTrue( + sql.stream().anyMatch(statement -> statement.toUpperCase(Locale.ROOT).contains("FROM DBA_TAB_PRIVS")), + String.valueOf(sql) + ); + Assertions.assertTrue( + sql.stream().anyMatch(statement -> statement.toUpperCase(Locale.ROOT).contains("FROM ALL_TAB_PRIVS")), + String.valueOf(sql) + ); + Assertions.assertTrue( + sql.stream().anyMatch(statement -> statement.toUpperCase(Locale.ROOT).contains("FROM SYS.DBA_TAB_PRIVS")), + String.valueOf(sql) + ); + } + + @Test + void tableDdlKeepsCreateTableWhenPrivilegeQueriesFail() { + List sql = new ArrayList<>(); + OceanBaseOracleAgent agent = new OceanBaseOracleAgent(); + TestSupport.setPrivateConnection(agent, privilegeFailingDdlConnection(sql, + resultSet( + new String[]{"INDEX_NAME", "COLUMN_NAME", "COLUMN_POSITION", "UNIQUENESS", "CONSTRAINT_TYPE", "INDEX_TYPE"}, + new Object[][]{} + ), + resultSet( + new String[]{"CONSTRAINT_NAME", "COLUMN_NAME", "TABLE_NAME", "REF_COLUMN_NAME"}, + new Object[][]{} + ), + resultSet( + new String[]{"COMMENTS"}, + new Object[][]{{null}} + ), + columnResultSet(new Object[][]{ + {"ID", "NUMBER", "N", 19, 0, null, null, null, null, 1} + }) + )); + + String ddl = agent.getTableDdl("APP", "USERS"); + + Assertions.assertTrue(ddl.contains("CREATE TABLE \"APP\".\"USERS\""), ddl); + Assertions.assertFalse(ddl.contains("GRANT "), ddl); + Assertions.assertTrue( + sql.stream().anyMatch(statement -> statement.toUpperCase(Locale.ROOT).contains("FROM DBA_TAB_PRIVS")), + String.valueOf(sql) + ); } private static ResultSet columnResultSet(Object[][] rows) { @@ -496,6 +645,95 @@ class OceanBaseOracleAgentTest { }); } + private static Connection privilegeFailingDdlConnection(List sql, ResultSet... resultSets) { + int[] resultSetIndex = {0}; + return proxy(Connection.class, (method, args) -> { + if ("prepareStatement".equals(method.getName())) { + String statementSql = String.valueOf(args[0]); + sql.add(statementSql); + String normalized = statementSql.toUpperCase(Locale.ROOT); + if (normalized.contains("DBA_TAB_PRIVS") + || normalized.contains("ALL_TAB_PRIVS") + || normalized.contains("DBA_COL_PRIVS") + || normalized.contains("ALL_COL_PRIVS")) { + PreparedStatement failing = proxy(PreparedStatement.class, (statementMethod, statementArgs) -> { + if ("executeQuery".equals(statementMethod.getName())) { + throw new SQLException("privilege view unavailable"); + } + if ("setString".equals(statementMethod.getName()) + || "setInt".equals(statementMethod.getName()) + || "close".equals(statementMethod.getName())) { + return null; + } + return defaultValue(statementMethod.getReturnType()); + }); + return failing; + } + PreparedStatement statement = proxy(PreparedStatement.class, (statementMethod, statementArgs) -> { + if ("executeQuery".equals(statementMethod.getName())) { + int current = Math.min(resultSetIndex[0], resultSets.length - 1); + resultSetIndex[0] += 1; + return resultSets[current]; + } + if ("setString".equals(statementMethod.getName()) + || "setInt".equals(statementMethod.getName()) + || "close".equals(statementMethod.getName())) { + return null; + } + return defaultValue(statementMethod.getReturnType()); + }); + return statement; + } + if ("isClosed".equals(method.getName())) { + return false; + } + return defaultValue(method.getReturnType()); + }); + } + + private static Connection dbaFallbackPrivilegeConnection(List sql, ResultSet... resultSets) { + int[] resultSetIndex = {0}; + return proxy(Connection.class, (method, args) -> { + if ("prepareStatement".equals(method.getName())) { + String statementSql = String.valueOf(args[0]); + sql.add(statementSql); + String normalized = statementSql.toUpperCase(Locale.ROOT); + if (normalized.contains("DBA_TAB_PRIVS") || normalized.contains("DBA_COL_PRIVS")) { + PreparedStatement failing = proxy(PreparedStatement.class, (statementMethod, statementArgs) -> { + if ("executeQuery".equals(statementMethod.getName())) { + throw new SQLException("DBA privilege view unavailable"); + } + if ("setString".equals(statementMethod.getName()) + || "setInt".equals(statementMethod.getName()) + || "close".equals(statementMethod.getName())) { + return null; + } + return defaultValue(statementMethod.getReturnType()); + }); + return failing; + } + PreparedStatement statement = proxy(PreparedStatement.class, (statementMethod, statementArgs) -> { + if ("executeQuery".equals(statementMethod.getName())) { + int current = Math.min(resultSetIndex[0], resultSets.length - 1); + resultSetIndex[0] += 1; + return resultSets[current]; + } + if ("setString".equals(statementMethod.getName()) + || "setInt".equals(statementMethod.getName()) + || "close".equals(statementMethod.getName())) { + return null; + } + return defaultValue(statementMethod.getReturnType()); + }); + return statement; + } + if ("isClosed".equals(method.getName())) { + return false; + } + return defaultValue(method.getReturnType()); + }); + } + private static Connection schemaConnection(List sql, ResultSet resultSet) { Statement statement = proxy(Statement.class, (method, args) -> { if ("executeQuery".equals(method.getName())) {