This is an automated email from the ASF dual-hosted git repository. JingsongLi pushed a commit to branch rest-add-table-via-view-endpoint in repository https://gitbox.apache.org/repos/asf/paimon.git
commit db862ccaeeb6b659c9a285d833c76ce13439868b Author: JingsongLi <[email protected]> AuthorDate: Thu Jun 4 11:57:04 2026 +0800 [rest] Add view penetration endpoint for table access via view Add POST /v1/{prefix}/databases/{db}/tables/{table}/via/{via_db}/{via_object} endpoint that enables view penetration: if the caller has permission on a view, they can access the underlying table referenced by that view. This API can only be called by trusted engines. The server must authenticate whether the caller is a trusted engine. Co-Authored-By: Claude Opus 4.6 <[email protected]> --- .../main/java/org/apache/paimon/rest/RESTApi.java | 26 +++++++++++++++++ .../java/org/apache/paimon/rest/ResourcePaths.java | 14 +++++++++ .../java/org/apache/paimon/catalog/Catalog.java | 16 +++++++++++ .../org/apache/paimon/catalog/DelegateCatalog.java | 5 ++++ .../java/org/apache/paimon/rest/RESTCatalog.java | 33 ++++++++++++++++++++++ .../org/apache/paimon/rest/RESTCatalogServer.java | 32 +++++++++++++++++++++ 6 files changed, 126 insertions(+) diff --git a/paimon-api/src/main/java/org/apache/paimon/rest/RESTApi.java b/paimon-api/src/main/java/org/apache/paimon/rest/RESTApi.java index 263c4e2c06..26be5bcb40 100644 --- a/paimon-api/src/main/java/org/apache/paimon/rest/RESTApi.java +++ b/paimon-api/src/main/java/org/apache/paimon/rest/RESTApi.java @@ -519,6 +519,32 @@ public class RESTApi { return client.get(resourcePaths.table(tableId), GetTableResponse.class, restAuthFunction); } + /** + * Get table via a view (view penetration). If the caller has permission on the view identified + * by {@code via}, they can access the underlying table referenced by the view. + * + * <p>This API can only be called by trusted engines. The server must authenticate whether the + * caller is a trusted engine. + * + * @param table database name and table name of the target table. + * @param via database name and object name of the view through which access is granted. + * @return {@link GetTableResponse} + * @throws NoSuchResourceException Exception thrown on HTTP 404 means the table or view not + * exists + * @throws ForbiddenException Exception thrown on HTTP 403 means don't have the permission + */ + public GetTableResponse getTableVia(Identifier table, Identifier via) { + return client.post( + resourcePaths.tableVia( + table.getDatabaseName(), + table.getObjectName(), + via.getDatabaseName(), + via.getObjectName()), + new ForwardBranchRequest(), + GetTableResponse.class, + restAuthFunction); + } + /** * Load latest snapshot for table. * diff --git a/paimon-api/src/main/java/org/apache/paimon/rest/ResourcePaths.java b/paimon-api/src/main/java/org/apache/paimon/rest/ResourcePaths.java index 28f79d0409..6bb62d5c73 100644 --- a/paimon-api/src/main/java/org/apache/paimon/rest/ResourcePaths.java +++ b/paimon-api/src/main/java/org/apache/paimon/rest/ResourcePaths.java @@ -43,6 +43,7 @@ public class ResourcePaths { protected static final String FUNCTIONS = "functions"; protected static final String FUNCTION_DETAILS = "function-details"; protected static final String ID = "id"; + protected static final String VIA = "via"; private static final Joiner SLASH = Joiner.on("/").skipNulls(); @@ -94,6 +95,19 @@ public class ResourcePaths { encodeString(objectName)); } + public String tableVia(String databaseName, String objectName, String viaDb, String viaObject) { + return SLASH.join( + V1, + prefix, + DATABASES, + encodeString(databaseName), + TABLES, + encodeString(objectName), + VIA, + encodeString(viaDb), + encodeString(viaObject)); + } + public String renameTable() { return SLASH.join(V1, prefix, TABLES, "rename"); } diff --git a/paimon-core/src/main/java/org/apache/paimon/catalog/Catalog.java b/paimon-core/src/main/java/org/apache/paimon/catalog/Catalog.java index 57fa040a2a..1fd3a24959 100644 --- a/paimon-core/src/main/java/org/apache/paimon/catalog/Catalog.java +++ b/paimon-core/src/main/java/org/apache/paimon/catalog/Catalog.java @@ -157,6 +157,22 @@ public interface Catalog extends AutoCloseable { */ Table getTable(Identifier identifier) throws TableNotExistException; + /** + * Return a {@link Table} identified by the given {@link Identifier}, accessed via a view (view + * penetration). If the caller has permission on the view, they can access the underlying table. + * + * <p>This API can only be called by trusted engines. The server must authenticate whether the + * caller is a trusted engine. + * + * @param table Path of the target table + * @param via Path of the view through which access is granted + * @return The requested table + * @throws TableNotExistException if the target does not exist + */ + default Table getTable(Identifier table, Identifier via) throws TableNotExistException { + return getTable(table); + } + /** * Return a {@link Table} identified by the given tableId. * diff --git a/paimon-core/src/main/java/org/apache/paimon/catalog/DelegateCatalog.java b/paimon-core/src/main/java/org/apache/paimon/catalog/DelegateCatalog.java index 0f18f7d045..5e034bdff2 100644 --- a/paimon-core/src/main/java/org/apache/paimon/catalog/DelegateCatalog.java +++ b/paimon-core/src/main/java/org/apache/paimon/catalog/DelegateCatalog.java @@ -375,6 +375,11 @@ public abstract class DelegateCatalog implements Catalog { return wrapped.getTable(identifier); } + @Override + public Table getTable(Identifier table, Identifier via) throws TableNotExistException { + return wrapped.getTable(table, via); + } + @Override public View getView(Identifier identifier) throws ViewNotExistException { return wrapped.getView(identifier); diff --git a/paimon-core/src/main/java/org/apache/paimon/rest/RESTCatalog.java b/paimon-core/src/main/java/org/apache/paimon/rest/RESTCatalog.java index 9d76cfdf4f..3054a12185 100644 --- a/paimon-core/src/main/java/org/apache/paimon/rest/RESTCatalog.java +++ b/paimon-core/src/main/java/org/apache/paimon/rest/RESTCatalog.java @@ -319,6 +319,20 @@ public class RESTCatalog implements Catalog { true); } + @Override + public Table getTable(Identifier table, Identifier via) throws TableNotExistException { + return CatalogUtils.loadTable( + this, + table, + path -> fileIOForData(path, table), + this::fileIOFromOptions, + i -> loadTableMetadataVia(i, via), + null, + null, + context, + true); + } + @Override public Optional<TableSnapshot> loadSnapshot(Identifier identifier) throws TableNotExistException { @@ -480,6 +494,25 @@ public class RESTCatalog implements Catalog { return toTableMetadata(identifier.getDatabaseName(), response); } + /** + * Load table metadata via a view identifier (view penetration). + * + * <p>This API can only be called by trusted engines. The server must authenticate whether the + * caller is a trusted engine. + */ + public TableMetadata loadTableMetadataVia(Identifier table, Identifier via) + throws TableNotExistException { + GetTableResponse response; + try { + response = api.getTableVia(table, via); + } catch (NoSuchResourceException e) { + throw new TableNotExistException(table); + } catch (ForbiddenException e) { + throw new TableNoPermissionException(table, e); + } + return toTableMetadata(table.getDatabaseName(), response); + } + private TableMetadata toTableMetadata(String db, GetTableResponse response) { TableSchema schema = TableSchema.create(response.getSchemaId(), response.getSchema()); Map<String, String> options = new HashMap<>(schema.options()); diff --git a/paimon-core/src/test/java/org/apache/paimon/rest/RESTCatalogServer.java b/paimon-core/src/test/java/org/apache/paimon/rest/RESTCatalogServer.java index af5d94e3f6..2aca3a3de7 100644 --- a/paimon-core/src/test/java/org/apache/paimon/rest/RESTCatalogServer.java +++ b/paimon-core/src/test/java/org/apache/paimon/rest/RESTCatalogServer.java @@ -413,6 +413,10 @@ public class RESTCatalogServer { resources.length == 5 && ResourcePaths.TABLES.equals(resources[1]) && ResourcePaths.SNAPSHOTS.equals(resources[3]); + boolean isTableVia = + resources.length == 5 + && ResourcePaths.TABLES.equals(resources[1]) + && ResourcePaths.VIA.equals(resources[3]); boolean isTableAuth = resources.length == 4 && ResourcePaths.TABLES.equals(resources[1]) @@ -529,6 +533,8 @@ public class RESTCatalogServer { return resetConsumer(identifier, restAuthParameter.data()); } else if (isLoadSnapshot) { return loadSnapshot(identifier, resources[4]); + } else if (isTableVia) { + return tableViaHandle(identifier); } else if (isTableAuth) { return authTable(identifier, restAuthParameter.data()); } else if (isCommitSnapshot) { @@ -1701,6 +1707,32 @@ public class RESTCatalogServer { 404); } + // This API can only be called by trusted engines. The server must authenticate + // whether the caller is a trusted engine. + private MockResponse tableViaHandle(Identifier identifier) throws Exception { + if (noPermissionTables.contains(identifier.getFullName())) { + throw new Catalog.TableNoPermissionException(identifier); + } + TableMetadata tableMetadata = tableMetadataStore.get(identifier.getFullName()); + Schema schema = tableMetadata.schema().toSchema(); + String path = schema.options().remove(PATH.key()); + RESTResponse response = + new GetTableResponse( + tableMetadata.uuid(), + identifier.getDatabaseName(), + identifier.getObjectName(), + path, + tableMetadata.isExternal(), + tableMetadata.schema().id(), + schema, + "owner", + 1L, + "created", + 1L, + "updated"); + return mockResponse(response, 200); + } + private MockResponse tableHandle(String method, String data, Identifier identifier) throws Exception { RESTResponse response;
