From 7de83562c0b1401e099093ada39ddc4d3eea7400 Mon Sep 17 00:00:00 2001 From: Stefan Bischof Date: Sun, 26 Jul 2026 09:53:17 +0200 Subject: [PATCH] feat(jdbc,dialect): read role memberships and principals, render grants - MetadataProvider.getAllRoleMemberships/getAllPrincipals for PG, Oracle, MSSQL, MariaDB (Is_role) and MySQL (KIND_UNKNOWN - no structural user/role distinction) - GRANT/REVOKE/CREATE ROLE rendering in DdlGenerator incl. column restrictions; MSSQL, Oracle and H2 overrides - schema privileges deliberately no longer read (PG) Signed-off-by: Stefan Bischof --- .../dialect/api/generator/DdlGenerator.java | 102 ++++++++++++++++++ .../daanse/sql/dialect/db/h2/H2Dialect.java | 6 ++ .../MicrosoftSqlServerDialect.java | 30 ++++++ .../sql/dialect/db/oracle/OracleDialect.java | 19 ++++ .../daanse/sql/jdbc/api/MetadataProvider.java | 18 ++++ .../sql/jdbc/api/meta/StructureInfo.java | 10 ++ .../jdbc/api/schema/DatabasePrincipal.java | 40 +++++++ .../sql/jdbc/api/schema/RoleMembership.java | 35 ++++++ .../sql/jdbc/impl/DatabaseServiceImpl.java | 8 +- .../metadata/MariaDbMetadataProvider.java | 43 ++++++++ .../MicrosoftSqlServerMetadataProvider.java | 46 ++++++++ .../jdbc/metadata/MySqlMetadataProvider.java | 44 ++++++++ .../jdbc/metadata/OracleMetadataProvider.java | 55 ++++++++++ .../metadata/PostgreSqlMetadataProvider.java | 55 ++++++++-- .../metadata/MariaDBMetadataProviderTest.java | 30 ++++++ .../jdbc/record/meta/StructureInfoRecord.java | 35 +++++- .../schema/DatabasePrincipalRecord.java | 21 ++++ .../record/schema/RoleMembershipRecord.java | 25 +++++ 18 files changed, 610 insertions(+), 12 deletions(-) create mode 100644 jdbc/api/src/main/java/org/eclipse/daanse/sql/jdbc/api/schema/DatabasePrincipal.java create mode 100644 jdbc/api/src/main/java/org/eclipse/daanse/sql/jdbc/api/schema/RoleMembership.java create mode 100644 jdbc/record/src/main/java/org/eclipse/daanse/sql/jdbc/record/schema/DatabasePrincipalRecord.java create mode 100644 jdbc/record/src/main/java/org/eclipse/daanse/sql/jdbc/record/schema/RoleMembershipRecord.java diff --git a/dialect/api/src/main/java/org/eclipse/daanse/sql/dialect/api/generator/DdlGenerator.java b/dialect/api/src/main/java/org/eclipse/daanse/sql/dialect/api/generator/DdlGenerator.java index e0d7199..27fc332 100644 --- a/dialect/api/src/main/java/org/eclipse/daanse/sql/dialect/api/generator/DdlGenerator.java +++ b/dialect/api/src/main/java/org/eclipse/daanse/sql/dialect/api/generator/DdlGenerator.java @@ -702,4 +702,106 @@ private static String schemaKey(TableReference tbl) { String c = tbl.schema().flatMap(SchemaReference::catalog).map(CatalogReference::name).orElse(""); return c + "" + s; } + + // -------------------- Privileges (GRANT/REVOKE) -------------------- + // The grantor of a privilege is never rendered — it is implied by the + // session executing the statement. + + /** {@code CREATE ROLE "x"}; users are not creatable (no credentials). */ + default String createRole(String roleName) { + return "CREATE ROLE " + quoteIdentifier(roleName); + } + + /** {@code DROP ROLE "x"}. */ + default String dropRole(String roleName) { + return "DROP ROLE " + quoteIdentifier(roleName); + } + + /** {@code GRANT ON "s"."t" TO "g" [WITH GRANT OPTION]}. */ + default String grantTablePrivilege(String action, TableReference table, String grantee, + boolean withGrantOption) { + return grantTablePrivilege(action, table, List.of(), grantee, withGrantOption); + } + + /** {@code GRANT ("c1", "c2") ON "s"."t" TO "g"} — empty columns = whole table. */ + default String grantTablePrivilege(String action, TableReference table, List columns, + String grantee, boolean withGrantOption) { + StringBuilder sb = new StringBuilder("GRANT ").append(action); + appendColumnRestriction(sb, columns); + sb.append(" ON ").append(qualified(table)).append(" TO ").append(quoteIdentifier(grantee)); + if (withGrantOption) { + sb.append(" WITH GRANT OPTION"); + } + return sb.toString(); + } + + /** {@code REVOKE ON "s"."t" FROM "g"}. */ + default String revokeTablePrivilege(String action, TableReference table, String grantee) { + return revokeTablePrivilege(action, table, List.of(), grantee); + } + + /** {@code REVOKE ("c1", "c2") ON "s"."t" FROM "g"}. */ + default String revokeTablePrivilege(String action, TableReference table, List columns, + String grantee) { + StringBuilder sb = new StringBuilder("REVOKE ").append(action); + appendColumnRestriction(sb, columns); + sb.append(" ON ").append(qualified(table)).append(" FROM ").append(quoteIdentifier(grantee)); + return sb.toString(); + } + + private void appendColumnRestriction(StringBuilder sb, List columns) { + if (columns != null && !columns.isEmpty()) { + sb.append(" ("); + for (int i = 0; i < columns.size(); i++) { + if (i > 0) { + sb.append(", "); + } + sb.append(quoteIdentifier(columns.get(i))); + } + sb.append(')'); + } + } + + /** + * {@code GRANT EXECUTE ON FUNCTION|PROCEDURE "s"."f" TO "g"}. No signature is + * rendered — PG accepts the bare name while it is unambiguous. + */ + default String grantExecute(String schemaName, String routineName, boolean isFunction, String grantee, + boolean withGrantOption) { + StringBuilder sb = new StringBuilder("GRANT EXECUTE ON ") + .append(isFunction ? "FUNCTION " : "PROCEDURE ") + .append(qualifiedRoutine(schemaName, routineName)) + .append(" TO ").append(quoteIdentifier(grantee)); + if (withGrantOption) { + sb.append(" WITH GRANT OPTION"); + } + return sb.toString(); + } + + /** {@code REVOKE EXECUTE ON FUNCTION|PROCEDURE "s"."f" FROM "g"}. */ + default String revokeExecute(String schemaName, String routineName, boolean isFunction, String grantee) { + return "REVOKE EXECUTE ON " + (isFunction ? "FUNCTION " : "PROCEDURE ") + + qualifiedRoutine(schemaName, routineName) + " FROM " + quoteIdentifier(grantee); + } + + /** {@code GRANT "role" TO "g" [WITH ADMIN OPTION]} — role membership. */ + default String grantRole(String roleName, String grantee, boolean withAdminOption) { + StringBuilder sb = new StringBuilder("GRANT ").append(quoteIdentifier(roleName)) + .append(" TO ").append(quoteIdentifier(grantee)); + if (withAdminOption) { + sb.append(" WITH ADMIN OPTION"); + } + return sb.toString(); + } + + /** {@code REVOKE "role" FROM "g"} — removes a role membership. */ + default String revokeRole(String roleName, String grantee) { + return "REVOKE " + quoteIdentifier(roleName) + " FROM " + quoteIdentifier(grantee); + } + + /** Schema-qualified routine name; the bare name when no schema is given. */ + default String qualifiedRoutine(String schemaName, String routineName) { + return schemaName == null || schemaName.isBlank() ? quoteIdentifier(routineName) + : quoteIdentifier(schemaName, routineName); + } } diff --git a/dialect/db/h2/src/main/java/org/eclipse/daanse/sql/dialect/db/h2/H2Dialect.java b/dialect/db/h2/src/main/java/org/eclipse/daanse/sql/dialect/db/h2/H2Dialect.java index fbc7733..5fa5599 100644 --- a/dialect/db/h2/src/main/java/org/eclipse/daanse/sql/dialect/db/h2/H2Dialect.java +++ b/dialect/db/h2/src/main/java/org/eclipse/daanse/sql/dialect/db/h2/H2Dialect.java @@ -148,4 +148,10 @@ public boolean supportsNthValueIgnoreNulls() { public boolean supportsListAgg() { return true; } + + /** H2 has no ADMIN OPTION on role grants — the flag is ignored. */ + @Override + public String grantRole(String roleName, String grantee, boolean withAdminOption) { + return "GRANT " + quoteIdentifier(roleName) + " TO " + quoteIdentifier(grantee); + } } diff --git a/dialect/db/mssqlserver/src/main/java/org/eclipse/daanse/sql/dialect/db/mssqlserver/MicrosoftSqlServerDialect.java b/dialect/db/mssqlserver/src/main/java/org/eclipse/daanse/sql/dialect/db/mssqlserver/MicrosoftSqlServerDialect.java index de8cf7a..8af6452 100644 --- a/dialect/db/mssqlserver/src/main/java/org/eclipse/daanse/sql/dialect/db/mssqlserver/MicrosoftSqlServerDialect.java +++ b/dialect/db/mssqlserver/src/main/java/org/eclipse/daanse/sql/dialect/db/mssqlserver/MicrosoftSqlServerDialect.java @@ -646,4 +646,34 @@ private String spRename(String quotedSourcePath, String newName, String objectTy sb.append(", '").append(objectType).append("'"); return sb.toString(); } + + /** SQL Server memberships are {@code ALTER ROLE}; there is no ADMIN OPTION — the flag is ignored. */ + @Override + public String grantRole(String roleName, String grantee, boolean withAdminOption) { + return "ALTER ROLE " + quoteIdentifier(roleName) + " ADD MEMBER " + quoteIdentifier(grantee); + } + + @Override + public String revokeRole(String roleName, String grantee) { + return "ALTER ROLE " + quoteIdentifier(roleName) + " DROP MEMBER " + quoteIdentifier(grantee); + } + + /** SQL Server takes no FUNCTION/PROCEDURE keyword in GRANT EXECUTE. */ + @Override + public String grantExecute(String schemaName, String routineName, boolean isFunction, String grantee, + boolean withGrantOption) { + StringBuilder sb = new StringBuilder("GRANT EXECUTE ON ") + .append(qualifiedRoutine(schemaName, routineName)) + .append(" TO ").append(quoteIdentifier(grantee)); + if (withGrantOption) { + sb.append(" WITH GRANT OPTION"); + } + return sb.toString(); + } + + @Override + public String revokeExecute(String schemaName, String routineName, boolean isFunction, String grantee) { + return "REVOKE EXECUTE ON " + qualifiedRoutine(schemaName, routineName) + " FROM " + + quoteIdentifier(grantee); + } } diff --git a/dialect/db/oracle/src/main/java/org/eclipse/daanse/sql/dialect/db/oracle/OracleDialect.java b/dialect/db/oracle/src/main/java/org/eclipse/daanse/sql/dialect/db/oracle/OracleDialect.java index 0978fa3..b6f5a09 100644 --- a/dialect/db/oracle/src/main/java/org/eclipse/daanse/sql/dialect/db/oracle/OracleDialect.java +++ b/dialect/db/oracle/src/main/java/org/eclipse/daanse/sql/dialect/db/oracle/OracleDialect.java @@ -619,4 +619,23 @@ public String alterColumnDropDefault(TableReference table, String columnName) { } // RENAME COLUMN/TABLE/INDEX/CONSTRAINT inherit the SQL-99 default. + + /** Oracle takes no FUNCTION/PROCEDURE keyword in GRANT EXECUTE. */ + @Override + public String grantExecute(String schemaName, String routineName, boolean isFunction, String grantee, + boolean withGrantOption) { + StringBuilder sb = new StringBuilder("GRANT EXECUTE ON ") + .append(qualifiedRoutine(schemaName, routineName)) + .append(" TO ").append(quoteIdentifier(grantee)); + if (withGrantOption) { + sb.append(" WITH GRANT OPTION"); + } + return sb.toString(); + } + + @Override + public String revokeExecute(String schemaName, String routineName, boolean isFunction, String grantee) { + return "REVOKE EXECUTE ON " + qualifiedRoutine(schemaName, routineName) + " FROM " + + quoteIdentifier(grantee); + } } diff --git a/jdbc/api/src/main/java/org/eclipse/daanse/sql/jdbc/api/MetadataProvider.java b/jdbc/api/src/main/java/org/eclipse/daanse/sql/jdbc/api/MetadataProvider.java index d341ef1..3517e5c 100644 --- a/jdbc/api/src/main/java/org/eclipse/daanse/sql/jdbc/api/MetadataProvider.java +++ b/jdbc/api/src/main/java/org/eclipse/daanse/sql/jdbc/api/MetadataProvider.java @@ -368,6 +368,24 @@ default Optional> g return Optional.empty(); } + /** + * Role membership edges (GRANT role TO grantee); no JDBC equivalent. + * Empty when the dialect keeps none or the current user may not read them. + */ + default Optional> getAllRoleMemberships( + Connection connection) throws SQLException { + return Optional.empty(); + } + + /** + * Database principals (users and roles), built-ins included; no JDBC + * equivalent. Empty when unreadable for the current user. + */ + default Optional> getAllPrincipals( + Connection connection) throws SQLException { + return Optional.empty(); + } + /** * Per-table alternative to * {@link java.sql.DatabaseMetaData#getColumnPrivileges(String, String, String, String)}. diff --git a/jdbc/api/src/main/java/org/eclipse/daanse/sql/jdbc/api/meta/StructureInfo.java b/jdbc/api/src/main/java/org/eclipse/daanse/sql/jdbc/api/meta/StructureInfo.java index 335ab39..f977dba 100644 --- a/jdbc/api/src/main/java/org/eclipse/daanse/sql/jdbc/api/meta/StructureInfo.java +++ b/jdbc/api/src/main/java/org/eclipse/daanse/sql/jdbc/api/meta/StructureInfo.java @@ -110,4 +110,14 @@ default List objectPrivi return List.of(); } + /** @return role membership edges (GRANT role TO grantee), empty list if not available */ + default List roleMemberships() { + return List.of(); + } + + /** @return database principals (users and roles), empty list if not available */ + default List principals() { + return List.of(); + } + } diff --git a/jdbc/api/src/main/java/org/eclipse/daanse/sql/jdbc/api/schema/DatabasePrincipal.java b/jdbc/api/src/main/java/org/eclipse/daanse/sql/jdbc/api/schema/DatabasePrincipal.java new file mode 100644 index 0000000..f4916d9 --- /dev/null +++ b/jdbc/api/src/main/java/org/eclipse/daanse/sql/jdbc/api/schema/DatabasePrincipal.java @@ -0,0 +1,40 @@ +/* +* Copyright (c) 2026 Contributors to the Eclipse Foundation. +* +* This program and the accompanying materials are made +* available under the terms of the Eclipse Public License 2.0 +* which is available at https://www.eclipse.org/legal/epl-2.0/ +* +* SPDX-License-Identifier: EPL-2.0 +* +* Contributors: +* SmartCity Jena - initial +* Stefan Bischof (bipolis.org) - initial +*/ +package org.eclipse.daanse.sql.jdbc.api.schema; + +/** + * A database principal — user or role — from the dialect's principal catalog; + * no JDBC equivalent exists. Built-ins are not filtered. + */ +public interface DatabasePrincipal { + + /** Canonical {@link #kind()} for login-capable principals. */ + String KIND_USER = "USER"; + + /** Canonical {@link #kind()} for grantable/joinable principals (roles, groups). */ + String KIND_ROLE = "ROLE"; + + /** + * Canonical {@link #kind()} when the dialect does not separate users from + * roles structurally (MySQL: "little to distinguish them") — no basis for + * provisioning decisions. + */ + String KIND_UNKNOWN = "UNKNOWN"; + + /** The principal's name. */ + String name(); + + /** Canonical kind: {@link #KIND_USER}, {@link #KIND_ROLE} or {@link #KIND_UNKNOWN}. */ + String kind(); +} diff --git a/jdbc/api/src/main/java/org/eclipse/daanse/sql/jdbc/api/schema/RoleMembership.java b/jdbc/api/src/main/java/org/eclipse/daanse/sql/jdbc/api/schema/RoleMembership.java new file mode 100644 index 0000000..0d8ccc2 --- /dev/null +++ b/jdbc/api/src/main/java/org/eclipse/daanse/sql/jdbc/api/schema/RoleMembership.java @@ -0,0 +1,35 @@ +/* +* Copyright (c) 2026 Contributors to the Eclipse Foundation. +* +* This program and the accompanying materials are made +* available under the terms of the Eclipse Public License 2.0 +* which is available at https://www.eclipse.org/legal/epl-2.0/ +* +* SPDX-License-Identifier: EPL-2.0 +* +* Contributors: +* SmartCity Jena - initial +* Stefan Bischof (bipolis.org) - initial +*/ +package org.eclipse.daanse.sql.jdbc.api.schema; + +import java.util.Optional; + +/** + * A role membership edge — {@code GRANT role TO grantee} — from the dialect's + * membership catalog; no JDBC equivalent exists. + */ +public interface RoleMembership { + + /** Principal (user or role) that received the role. */ + String grantee(); + + /** The granted role. */ + String role(); + + /** Principal that granted the membership (or empty if unknown). */ + Optional grantor(); + + /** WITH ADMIN OPTION: "YES", "NO", or empty if unknown. */ + Optional adminOption(); +} diff --git a/jdbc/impl/src/main/java/org/eclipse/daanse/sql/jdbc/impl/DatabaseServiceImpl.java b/jdbc/impl/src/main/java/org/eclipse/daanse/sql/jdbc/impl/DatabaseServiceImpl.java index ed65d05..c54accd 100644 --- a/jdbc/impl/src/main/java/org/eclipse/daanse/sql/jdbc/impl/DatabaseServiceImpl.java +++ b/jdbc/impl/src/main/java/org/eclipse/daanse/sql/jdbc/impl/DatabaseServiceImpl.java @@ -269,10 +269,16 @@ protected MetaInfo readMetaInfoWithProvider(Connection connection, DatabaseMetaD List objectPrivileges = provider.getAllObjectPrivileges(connection, null, null).orElse(List.of()); + List roleMemberships = + provider.getAllRoleMemberships(connection).orElse(List.of()); + + List principals = + provider.getAllPrincipals(connection).orElse(List.of()); + StructureInfo structureInfo = new StructureInfoRecord(catalogs, schemas, tables, columns, importedKeys, primaryKeys, triggers, sequences, checkConstraints, uniqueConstraints, userDefinedTypes, viewDefinitions, procedures, functions, materializedViews, partitions, - tablePrivileges, List.copyOf(columnPrivileges), objectPrivileges); + tablePrivileges, List.copyOf(columnPrivileges), objectPrivileges, roleMemberships, principals); return new MetaInfoRecord(databaseInfo, structureInfo, identifierInfo, typeInfos, indexInfos); } diff --git a/jdbc/metadata/src/main/java/org/eclipse/daanse/sql/jdbc/metadata/MariaDbMetadataProvider.java b/jdbc/metadata/src/main/java/org/eclipse/daanse/sql/jdbc/metadata/MariaDbMetadataProvider.java index bd72e3f..6b8b159 100644 --- a/jdbc/metadata/src/main/java/org/eclipse/daanse/sql/jdbc/metadata/MariaDbMetadataProvider.java +++ b/jdbc/metadata/src/main/java/org/eclipse/daanse/sql/jdbc/metadata/MariaDbMetadataProvider.java @@ -932,4 +932,47 @@ UniqueConstraint build() { return new UniqueConstraintRecord(constraintName, tableRef, colRefs); } } + + @Override + public Optional> getAllRoleMemberships(Connection connection) + throws SQLException { + // mysql.roles_mapping; needs privileges on the mysql schema. + String sql = """ + SELECT User AS grantee, Role AS role_name, Admin_option + FROM mysql.roles_mapping + ORDER BY grantee, role_name + """; + List result = new ArrayList<>(); + try (PreparedStatement ps = connection.prepareStatement(sql); + ResultSet rs = ps.executeQuery()) { + while (rs.next()) { + result.add(new org.eclipse.daanse.sql.jdbc.record.schema.RoleMembershipRecord( + rs.getString("grantee"), rs.getString("role_name"), + Optional.empty(), + Optional.of("Y".equalsIgnoreCase(rs.getString("Admin_option")) ? "YES" : "NO"))); + } + } catch (SQLException e) { + return Optional.empty(); + } + return Optional.of(List.copyOf(result)); + } + + @Override + public Optional> getAllPrincipals(Connection connection) + throws SQLException { + // MariaDB flags roles first-class (Is_role). + String sql = "SELECT User, Is_role FROM mysql.user ORDER BY User"; + List result = new ArrayList<>(); + try (PreparedStatement ps = connection.prepareStatement(sql); + ResultSet rs = ps.executeQuery()) { + while (rs.next()) { + result.add(new org.eclipse.daanse.sql.jdbc.record.schema.DatabasePrincipalRecord(rs.getString("User"), + "Y".equalsIgnoreCase(rs.getString("Is_role")) ? org.eclipse.daanse.sql.jdbc.api.schema.DatabasePrincipal.KIND_ROLE + : org.eclipse.daanse.sql.jdbc.api.schema.DatabasePrincipal.KIND_USER)); + } + } catch (SQLException e) { + return Optional.empty(); + } + return Optional.of(List.copyOf(result)); + } } diff --git a/jdbc/metadata/src/main/java/org/eclipse/daanse/sql/jdbc/metadata/MicrosoftSqlServerMetadataProvider.java b/jdbc/metadata/src/main/java/org/eclipse/daanse/sql/jdbc/metadata/MicrosoftSqlServerMetadataProvider.java index 7317957..d9f47d5 100644 --- a/jdbc/metadata/src/main/java/org/eclipse/daanse/sql/jdbc/metadata/MicrosoftSqlServerMetadataProvider.java +++ b/jdbc/metadata/src/main/java/org/eclipse/daanse/sql/jdbc/metadata/MicrosoftSqlServerMetadataProvider.java @@ -1087,4 +1087,50 @@ UniqueConstraint build() { return new UniqueConstraintRecord(constraintName, tableRef, colRefs); } } + + @Override + public Optional> getAllRoleMemberships(Connection connection) + throws SQLException { + // No ADMIN OPTION and no grantor on SQL Server memberships. + String sql = """ + SELECT rp.name AS role_name, mp.name AS grantee + FROM sys.database_role_members drm + JOIN sys.database_principals rp ON rp.principal_id = drm.role_principal_id + JOIN sys.database_principals mp ON mp.principal_id = drm.member_principal_id + ORDER BY grantee, role_name + """; + List result = new ArrayList<>(); + try (PreparedStatement ps = connection.prepareStatement(sql); + ResultSet rs = ps.executeQuery()) { + while (rs.next()) { + result.add(new org.eclipse.daanse.sql.jdbc.record.schema.RoleMembershipRecord( + rs.getString("grantee"), rs.getString("role_name"), + Optional.empty(), Optional.empty())); + } + } + return Optional.of(List.copyOf(result)); + } + + @Override + public Optional> getAllPrincipals(Connection connection) + throws SQLException { + // S/U = users; R/G = roles (groups join like roles). Application roles + // ('A') are excluded — not grantable to principals. + String sql = """ + SELECT name, type FROM sys.database_principals + WHERE type IN ('S', 'U', 'R', 'G') + ORDER BY name + """; + List result = new ArrayList<>(); + try (PreparedStatement ps = connection.prepareStatement(sql); + ResultSet rs = ps.executeQuery()) { + while (rs.next()) { + String type = rs.getString("type"); + boolean user = "S".equalsIgnoreCase(type) || "U".equalsIgnoreCase(type); + result.add(new org.eclipse.daanse.sql.jdbc.record.schema.DatabasePrincipalRecord(rs.getString("name"), + user ? org.eclipse.daanse.sql.jdbc.api.schema.DatabasePrincipal.KIND_USER : org.eclipse.daanse.sql.jdbc.api.schema.DatabasePrincipal.KIND_ROLE)); + } + } + return Optional.of(List.copyOf(result)); + } } diff --git a/jdbc/metadata/src/main/java/org/eclipse/daanse/sql/jdbc/metadata/MySqlMetadataProvider.java b/jdbc/metadata/src/main/java/org/eclipse/daanse/sql/jdbc/metadata/MySqlMetadataProvider.java index 8d7d923..a12309c 100644 --- a/jdbc/metadata/src/main/java/org/eclipse/daanse/sql/jdbc/metadata/MySqlMetadataProvider.java +++ b/jdbc/metadata/src/main/java/org/eclipse/daanse/sql/jdbc/metadata/MySqlMetadataProvider.java @@ -883,4 +883,48 @@ UniqueConstraint build() { return new UniqueConstraintRecord(constraintName, tableRef, colRefs); } } + + @Override + public Optional> getAllRoleMemberships(Connection connection) + throws SQLException { + // mysql.role_edges (8.0+); needs privileges on the mysql schema. + String sql = """ + SELECT FROM_USER AS role_name, TO_USER AS grantee, WITH_ADMIN_OPTION + FROM mysql.role_edges + ORDER BY grantee, role_name + """; + List result = new ArrayList<>(); + try (PreparedStatement ps = connection.prepareStatement(sql); + ResultSet rs = ps.executeQuery()) { + while (rs.next()) { + result.add(new org.eclipse.daanse.sql.jdbc.record.schema.RoleMembershipRecord( + rs.getString("grantee"), rs.getString("role_name"), + Optional.empty(), + Optional.of("Y".equalsIgnoreCase(rs.getString("WITH_ADMIN_OPTION")) ? "YES" : "NO"))); + } + } catch (SQLException e) { + return Optional.empty(); + } + return Optional.of(List.copyOf(result)); + } + + @Override + public Optional> getAllPrincipals(Connection connection) + throws SQLException { + // MySQL keeps no structural user/role distinction ("little to + // distinguish them") — every principal reports KIND_UNKNOWN. + // mysql.user needs privileges on the mysql schema. + String sql = "SELECT user FROM mysql.user ORDER BY user"; + List result = new ArrayList<>(); + try (PreparedStatement ps = connection.prepareStatement(sql); + ResultSet rs = ps.executeQuery()) { + while (rs.next()) { + result.add(new org.eclipse.daanse.sql.jdbc.record.schema.DatabasePrincipalRecord(rs.getString("user"), + org.eclipse.daanse.sql.jdbc.api.schema.DatabasePrincipal.KIND_UNKNOWN)); + } + } catch (SQLException e) { + return Optional.empty(); + } + return Optional.of(List.copyOf(result)); + } } diff --git a/jdbc/metadata/src/main/java/org/eclipse/daanse/sql/jdbc/metadata/OracleMetadataProvider.java b/jdbc/metadata/src/main/java/org/eclipse/daanse/sql/jdbc/metadata/OracleMetadataProvider.java index 968cab3..9d766bd 100644 --- a/jdbc/metadata/src/main/java/org/eclipse/daanse/sql/jdbc/metadata/OracleMetadataProvider.java +++ b/jdbc/metadata/src/main/java/org/eclipse/daanse/sql/jdbc/metadata/OracleMetadataProvider.java @@ -1545,4 +1545,59 @@ private static boolean isNumericJdbc(java.sql.JDBCType t) { default -> false; }; } + + @Override + public Optional> getAllRoleMemberships(Connection connection) + throws SQLException { + // DBA_ROLE_PRIVS; fallback: own memberships via USER_ROLE_PRIVS. + List result = new ArrayList<>(); + try (PreparedStatement ps = connection.prepareStatement( + "SELECT GRANTEE, GRANTED_ROLE, ADMIN_OPTION FROM DBA_ROLE_PRIVS ORDER BY GRANTEE, GRANTED_ROLE"); + ResultSet rs = ps.executeQuery()) { + while (rs.next()) { + result.add(new org.eclipse.daanse.sql.jdbc.record.schema.RoleMembershipRecord( + rs.getString("GRANTEE"), rs.getString("GRANTED_ROLE"), + Optional.empty(), Optional.ofNullable(rs.getString("ADMIN_OPTION")))); + } + return Optional.of(List.copyOf(result)); + } catch (SQLException e) { + result.clear(); + } + try (PreparedStatement ps = connection.prepareStatement( + "SELECT USERNAME, GRANTED_ROLE, ADMIN_OPTION FROM USER_ROLE_PRIVS ORDER BY GRANTED_ROLE"); + ResultSet rs = ps.executeQuery()) { + while (rs.next()) { + result.add(new org.eclipse.daanse.sql.jdbc.record.schema.RoleMembershipRecord( + rs.getString("USERNAME"), rs.getString("GRANTED_ROLE"), + Optional.empty(), Optional.ofNullable(rs.getString("ADMIN_OPTION")))); + } + } + return Optional.of(List.copyOf(result)); + } + + @Override + public Optional> getAllPrincipals(Connection connection) + throws SQLException { + // DBA_USERS/DBA_ROLES need elevated rights; without them: empty. + List result = new ArrayList<>(); + try (PreparedStatement ps = connection.prepareStatement( + "SELECT USERNAME FROM DBA_USERS ORDER BY USERNAME"); + ResultSet rs = ps.executeQuery()) { + while (rs.next()) { + result.add(new org.eclipse.daanse.sql.jdbc.record.schema.DatabasePrincipalRecord(rs.getString("USERNAME"), org.eclipse.daanse.sql.jdbc.api.schema.DatabasePrincipal.KIND_USER)); + } + } catch (SQLException e) { + return Optional.empty(); + } + try (PreparedStatement ps = connection.prepareStatement( + "SELECT ROLE FROM DBA_ROLES ORDER BY ROLE"); + ResultSet rs = ps.executeQuery()) { + while (rs.next()) { + result.add(new org.eclipse.daanse.sql.jdbc.record.schema.DatabasePrincipalRecord(rs.getString("ROLE"), org.eclipse.daanse.sql.jdbc.api.schema.DatabasePrincipal.KIND_ROLE)); + } + } catch (SQLException e) { + return Optional.empty(); + } + return Optional.of(List.copyOf(result)); + } } diff --git a/jdbc/metadata/src/main/java/org/eclipse/daanse/sql/jdbc/metadata/PostgreSqlMetadataProvider.java b/jdbc/metadata/src/main/java/org/eclipse/daanse/sql/jdbc/metadata/PostgreSqlMetadataProvider.java index 53bbe7c..b4bb807 100644 --- a/jdbc/metadata/src/main/java/org/eclipse/daanse/sql/jdbc/metadata/PostgreSqlMetadataProvider.java +++ b/jdbc/metadata/src/main/java/org/eclipse/daanse/sql/jdbc/metadata/PostgreSqlMetadataProvider.java @@ -982,16 +982,8 @@ public Optional> ge String schemaName = resolveSchema(schema, connection); List result = new ArrayList<>(); - String schemaSql = """ - SELECT n.nspname AS object_name, 'SCHEMA' AS object_kind, - pg_get_userbyid(a.grantor) AS grantor, - CASE WHEN a.grantee = 0 THEN 'PUBLIC' ELSE pg_get_userbyid(a.grantee) END AS grantee, - a.privilege_type, a.is_grantable - FROM pg_namespace n, aclexplode(n.nspacl) a - WHERE n.nspname = ? - """; - readObjectPrivileges(connection, schemaSql, schemaName, null, result); - + // Schema privileges are deliberately not read — support is too opaque + // across dialects (Oracle: schema == user). String databaseSql = """ SELECT d.datname AS object_name, 'DATABASE' AS object_kind, pg_get_userbyid(a.grantor) AS grantor, @@ -1145,4 +1137,47 @@ UniqueConstraint build() { return new UniqueConstraintRecord(constraintName, tableRef, colRefs); } } + + @Override + public Optional> getAllRoleMemberships(Connection connection) + throws SQLException { + // pg_auth_members: every membership edge incl. grantor and ADMIN OPTION. + String sql = """ + SELECT m.rolname AS grantee, r.rolname AS role_name, + g.rolname AS grantor, am.admin_option + FROM pg_auth_members am + JOIN pg_roles r ON r.oid = am.roleid + JOIN pg_roles m ON m.oid = am.member + LEFT JOIN pg_roles g ON g.oid = am.grantor + ORDER BY grantee, role_name + """; + List result = new ArrayList<>(); + try (PreparedStatement ps = connection.prepareStatement(sql); + ResultSet rs = ps.executeQuery()) { + while (rs.next()) { + result.add(new org.eclipse.daanse.sql.jdbc.record.schema.RoleMembershipRecord( + rs.getString("grantee"), rs.getString("role_name"), + Optional.ofNullable(rs.getString("grantor")), + Optional.of(rs.getBoolean("admin_option") ? "YES" : "NO"))); + } + } + return Optional.of(List.copyOf(result)); + } + + @Override + public Optional> getAllPrincipals(Connection connection) + throws SQLException { + // pg_roles is world-readable; rolcanlogin separates users from roles. + String sql = "SELECT rolname, rolcanlogin FROM pg_roles ORDER BY rolname"; + List result = new ArrayList<>(); + try (PreparedStatement ps = connection.prepareStatement(sql); + ResultSet rs = ps.executeQuery()) { + while (rs.next()) { + result.add(new org.eclipse.daanse.sql.jdbc.record.schema.DatabasePrincipalRecord(rs.getString("rolname"), + rs.getBoolean("rolcanlogin") ? org.eclipse.daanse.sql.jdbc.api.schema.DatabasePrincipal.KIND_USER + : org.eclipse.daanse.sql.jdbc.api.schema.DatabasePrincipal.KIND_ROLE)); + } + } + return Optional.of(List.copyOf(result)); + } } diff --git a/jdbc/metadata/src/test/java/org/eclipse/daanse/sql/jdbc/metadata/MariaDBMetadataProviderTest.java b/jdbc/metadata/src/test/java/org/eclipse/daanse/sql/jdbc/metadata/MariaDBMetadataProviderTest.java index ea57300..c67b6e3 100644 --- a/jdbc/metadata/src/test/java/org/eclipse/daanse/sql/jdbc/metadata/MariaDBMetadataProviderTest.java +++ b/jdbc/metadata/src/test/java/org/eclipse/daanse/sql/jdbc/metadata/MariaDBMetadataProviderTest.java @@ -26,6 +26,8 @@ import org.eclipse.daanse.sql.jdbc.api.meta.IndexInfo; import org.eclipse.daanse.sql.jdbc.api.meta.IndexInfoItem; import org.eclipse.daanse.sql.jdbc.api.schema.CheckConstraint; +import org.eclipse.daanse.sql.jdbc.api.schema.DatabasePrincipal; +import org.eclipse.daanse.sql.jdbc.api.schema.RoleMembership; import org.eclipse.daanse.sql.model.schema.ColumnReference; import org.eclipse.daanse.sql.jdbc.api.schema.Function; import org.eclipse.daanse.sql.jdbc.api.schema.ImportedKey; @@ -709,4 +711,32 @@ private static IndexInfoItem findIndexItem(List indexInfos, String in .filter(item -> item.indexName().isPresent() && indexName.equalsIgnoreCase(item.indexName().get())) .findFirst().orElseThrow(() -> new AssertionError("Index not found: " + indexName)); } + + @Test + void rolePrincipalsAndMembershipsAreReadable() throws Exception { + // MariaDB flags roles first-class (mysql.user.Is_role) — unlike MySQL, + // where users and roles are structurally indistinguishable. + try (Statement stmt = connection.createStatement()) { + stmt.execute("CREATE ROLE mdb_parent"); + stmt.execute("CREATE ROLE mdb_child"); + stmt.execute("GRANT mdb_parent TO mdb_child WITH ADMIN OPTION"); + } + + List principals = provider.getAllPrincipals(connection).orElseThrow(); + assertThat(principals.stream() + .filter(p -> DatabasePrincipal.KIND_ROLE.equals(p.kind())) + .map(DatabasePrincipal::name)) + .contains("mdb_parent", "mdb_child"); + assertThat(principals.stream() + .filter(p -> "root".equals(p.name())) + .findFirst().orElseThrow().kind()) + .isEqualTo(DatabasePrincipal.KIND_USER); + + List memberships = provider.getAllRoleMemberships(connection).orElseThrow(); + assertThat(memberships).anySatisfy(m -> { + assertThat(m.grantee()).isEqualTo("mdb_child"); + assertThat(m.role()).isEqualTo("mdb_parent"); + assertThat(m.adminOption()).contains("YES"); + }); + } } diff --git a/jdbc/record/src/main/java/org/eclipse/daanse/sql/jdbc/record/meta/StructureInfoRecord.java b/jdbc/record/src/main/java/org/eclipse/daanse/sql/jdbc/record/meta/StructureInfoRecord.java index fd6eabf..a9dcd66 100644 --- a/jdbc/record/src/main/java/org/eclipse/daanse/sql/jdbc/record/meta/StructureInfoRecord.java +++ b/jdbc/record/src/main/java/org/eclipse/daanse/sql/jdbc/record/meta/StructureInfoRecord.java @@ -30,6 +30,8 @@ import org.eclipse.daanse.sql.model.schema.SchemaReference; import org.eclipse.daanse.sql.jdbc.api.schema.Sequence; import org.eclipse.daanse.sql.jdbc.api.schema.TableDefinition; +import org.eclipse.daanse.sql.jdbc.api.schema.DatabasePrincipal; +import org.eclipse.daanse.sql.jdbc.api.schema.RoleMembership; import org.eclipse.daanse.sql.jdbc.api.schema.TablePrivilege; import org.eclipse.daanse.sql.model.schema.Trigger; import org.eclipse.daanse.sql.jdbc.api.schema.UniqueConstraint; @@ -55,7 +57,38 @@ public record StructureInfoRecord( List partitions, List tablePrivileges, List columnPrivileges, - List objectPrivileges) implements StructureInfo { + List objectPrivileges, + List roleMemberships, + List principals) implements StructureInfo { + + /** Compatibility constructor without principals. */ + public StructureInfoRecord(List catalogs, List schemas, + List tables, List columns, List importedKeys, + List primaryKeys, List triggers, List sequences, + List checkConstraints, List uniqueConstraints, + List userDefinedTypes, List viewDefinitions, + List procedures, List functions, List materializedViews, + List partitions, List tablePrivileges, + List columnPrivileges, List objectPrivileges, + List roleMemberships) { + this(catalogs, schemas, tables, columns, importedKeys, primaryKeys, triggers, sequences, checkConstraints, + uniqueConstraints, userDefinedTypes, viewDefinitions, procedures, functions, materializedViews, + partitions, tablePrivileges, columnPrivileges, objectPrivileges, roleMemberships, List.of()); + } + + /** Compatibility constructor without role memberships. */ + public StructureInfoRecord(List catalogs, List schemas, + List tables, List columns, List importedKeys, + List primaryKeys, List triggers, List sequences, + List checkConstraints, List uniqueConstraints, + List userDefinedTypes, List viewDefinitions, + List procedures, List functions, List materializedViews, + List partitions, List tablePrivileges, + List columnPrivileges, List objectPrivileges) { + this(catalogs, schemas, tables, columns, importedKeys, primaryKeys, triggers, sequences, checkConstraints, + uniqueConstraints, userDefinedTypes, viewDefinitions, procedures, functions, materializedViews, + partitions, tablePrivileges, columnPrivileges, objectPrivileges, List.of()); + } /** Compatibility constructor without object privileges. */ public StructureInfoRecord(List catalogs, List schemas, diff --git a/jdbc/record/src/main/java/org/eclipse/daanse/sql/jdbc/record/schema/DatabasePrincipalRecord.java b/jdbc/record/src/main/java/org/eclipse/daanse/sql/jdbc/record/schema/DatabasePrincipalRecord.java new file mode 100644 index 0000000..a09afed --- /dev/null +++ b/jdbc/record/src/main/java/org/eclipse/daanse/sql/jdbc/record/schema/DatabasePrincipalRecord.java @@ -0,0 +1,21 @@ +/* +* Copyright (c) 2026 Contributors to the Eclipse Foundation. +* +* This program and the accompanying materials are made +* available under the terms of the Eclipse Public License 2.0 +* which is available at https://www.eclipse.org/legal/epl-2.0/ +* +* SPDX-License-Identifier: EPL-2.0 +* +* Contributors: +* SmartCity Jena - initial +* Stefan Bischof (bipolis.org) - initial +*/ +package org.eclipse.daanse.sql.jdbc.record.schema; + +import org.eclipse.daanse.sql.jdbc.api.schema.DatabasePrincipal; + +public record DatabasePrincipalRecord( + String name, + String kind) implements DatabasePrincipal { +} diff --git a/jdbc/record/src/main/java/org/eclipse/daanse/sql/jdbc/record/schema/RoleMembershipRecord.java b/jdbc/record/src/main/java/org/eclipse/daanse/sql/jdbc/record/schema/RoleMembershipRecord.java new file mode 100644 index 0000000..6dcd594 --- /dev/null +++ b/jdbc/record/src/main/java/org/eclipse/daanse/sql/jdbc/record/schema/RoleMembershipRecord.java @@ -0,0 +1,25 @@ +/* +* Copyright (c) 2026 Contributors to the Eclipse Foundation. +* +* This program and the accompanying materials are made +* available under the terms of the Eclipse Public License 2.0 +* which is available at https://www.eclipse.org/legal/epl-2.0/ +* +* SPDX-License-Identifier: EPL-2.0 +* +* Contributors: +* SmartCity Jena - initial +* Stefan Bischof (bipolis.org) - initial +*/ +package org.eclipse.daanse.sql.jdbc.record.schema; + +import java.util.Optional; + +import org.eclipse.daanse.sql.jdbc.api.schema.RoleMembership; + +public record RoleMembershipRecord( + String grantee, + String role, + Optional grantor, + Optional adminOption) implements RoleMembership { +}