From db862ccaeeb6b659c9a285d833c76ce13439868b Mon Sep 17 00:00:00 2001 From: JingsongLi Date: Thu, 4 Jun 2026 11:57:04 +0800 Subject: [PATCH 1/9] [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 --- .../java/org/apache/paimon/rest/RESTApi.java | 26 +++++++++++++++ .../org/apache/paimon/rest/ResourcePaths.java | 14 ++++++++ .../org/apache/paimon/catalog/Catalog.java | 16 +++++++++ .../paimon/catalog/DelegateCatalog.java | 5 +++ .../org/apache/paimon/rest/RESTCatalog.java | 33 +++++++++++++++++++ .../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 263c4e2c0640..26be5bcb409b 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 GetTableResponse getTableById(String tableId) { 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. + * + *

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 28f79d040995..6bb62d5c73c4 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 String table(String databaseName, String objectName) { 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 57fa040a2acd..1fd3a2495936 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 @@ void alterDatabase(String name, List changes, boolean ignoreIfNo */ 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. + * + *

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 0f18f7d04540..5e034bdff28a 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 Table getTable(Identifier identifier) throws TableNotExistException { 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 9d76cfdf4fef..3054a1218570 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 Table getTable(Identifier identifier) throws TableNotExistException { 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 loadSnapshot(Identifier identifier) throws TableNotExistException { @@ -480,6 +494,25 @@ private TableMetadata loadTableMetadata(Identifier identifier) throws TableNotEx return toTableMetadata(identifier.getDatabaseName(), response); } + /** + * Load table metadata via a view identifier (view penetration). + * + *

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 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 af5d94e3f632..2aca3a3de70e 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 @@ && isTableByIdRequest(request.getPath())) { 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 @@ && isTableByIdRequest(request.getPath())) { 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 @@ private MockResponse tableByIdHandle(String requestPath) throws Exception { 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; From 2427cbbfc52d57c1c75588a6dba21fa589e7451f Mon Sep 17 00:00:00 2001 From: JingsongLi Date: Thu, 4 Jun 2026 12:00:06 +0800 Subject: [PATCH 2/9] [rest] Rename Catalog interface method to getTableVia Co-Authored-By: Claude Opus 4.6 --- .../src/main/java/org/apache/paimon/catalog/Catalog.java | 2 +- .../main/java/org/apache/paimon/catalog/DelegateCatalog.java | 4 ++-- .../src/main/java/org/apache/paimon/rest/RESTCatalog.java | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) 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 1fd3a2495936..184ca596f031 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 @@ -169,7 +169,7 @@ void alterDatabase(String name, List changes, boolean ignoreIfNo * @return The requested table * @throws TableNotExistException if the target does not exist */ - default Table getTable(Identifier table, Identifier via) throws TableNotExistException { + default Table getTableVia(Identifier table, Identifier via) throws TableNotExistException { return getTable(table); } 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 5e034bdff28a..1fb5c9b1f27d 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 @@ -376,8 +376,8 @@ public Table getTable(Identifier identifier) throws TableNotExistException { } @Override - public Table getTable(Identifier table, Identifier via) throws TableNotExistException { - return wrapped.getTable(table, via); + public Table getTableVia(Identifier table, Identifier via) throws TableNotExistException { + return wrapped.getTableVia(table, via); } @Override 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 3054a1218570..bfb235f6101d 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 @@ -320,7 +320,7 @@ public Table getTable(Identifier identifier) throws TableNotExistException { } @Override - public Table getTable(Identifier table, Identifier via) throws TableNotExistException { + public Table getTableVia(Identifier table, Identifier via) throws TableNotExistException { return CatalogUtils.loadTable( this, table, From bbafa7e988fe7fa73b410f6bdeb24abe2e9cbb25 Mon Sep 17 00:00:00 2001 From: JingsongLi Date: Thu, 4 Jun 2026 12:09:54 +0800 Subject: [PATCH 3/9] [python] Add getTableVia endpoint for view penetration Mirror the Java implementation in Python: - ResourcePaths: add table_via() path method - RESTApi: add get_table_via() client method - Catalog: add get_table_via() with default fallback to get_table() - RESTCatalog: override to call the new REST endpoint Co-Authored-By: Claude Opus 4.6 --- paimon-python/pypaimon/api/resource_paths.py | 7 +++++ paimon-python/pypaimon/api/rest_api.py | 16 ++++++++++++ paimon-python/pypaimon/catalog/catalog.py | 9 +++++++ .../pypaimon/catalog/rest/rest_catalog.py | 26 +++++++++++++++++++ 4 files changed, 58 insertions(+) diff --git a/paimon-python/pypaimon/api/resource_paths.py b/paimon-python/pypaimon/api/resource_paths.py index fad221c3bf87..9c69b8e5a212 100644 --- a/paimon-python/pypaimon/api/resource_paths.py +++ b/paimon-python/pypaimon/api/resource_paths.py @@ -34,6 +34,7 @@ class ResourcePaths: BRANCHES = "branches" RENAME = "rename" FORWARD = "forward" + VIA = "via" def __init__(self, prefix: str): self.base_path = "/{}/{}".format(self.V1, prefix).rstrip("/") @@ -71,6 +72,12 @@ def table_token(self, database_name: str, table_name: str) -> str: return ("{}/{}/{}/{}/{}/token".format(self.base_path, self.DATABASES, RESTUtil.encode_string(database_name), self.TABLES, RESTUtil.encode_string(table_name))) + def table_via(self, database_name: str, table_name: str, via_db: str, via_object: str) -> str: + return "{}/{}/{}/{}/{}/{}/{}/{}".format( + self.base_path, self.DATABASES, RESTUtil.encode_string(database_name), + self.TABLES, RESTUtil.encode_string(table_name), self.VIA, + RESTUtil.encode_string(via_db), RESTUtil.encode_string(via_object)) + def rename_table(self) -> str: return "{}/{}/rename".format(self.base_path, self.TABLES) diff --git a/paimon-python/pypaimon/api/rest_api.py b/paimon-python/pypaimon/api/rest_api.py index b6ed08860c5f..4219fe4dd231 100755 --- a/paimon-python/pypaimon/api/rest_api.py +++ b/paimon-python/pypaimon/api/rest_api.py @@ -293,6 +293,22 @@ def get_table(self, identifier: Identifier) -> GetTableResponse: self.rest_auth_function, ) + def get_table_via(self, table: Identifier, via: Identifier) -> GetTableResponse: + """Get table via a view (view penetration). + + This API can only be called by trusted engines. The server must authenticate + whether the caller is a trusted engine. + """ + table_db, table_name = self.__validate_identifier(table) + via_db, via_object = self.__validate_identifier(via) + + return self.client.post_with_response_type( + self.resource_paths.table_via(table_db, table_name, via_db, via_object), + ForwardBranchRequest(), + GetTableResponse, + self.rest_auth_function, + ) + def drop_table(self, identifier: Identifier) -> GetTableResponse: database_name, table_name = self.__validate_identifier(identifier) diff --git a/paimon-python/pypaimon/catalog/catalog.py b/paimon-python/pypaimon/catalog/catalog.py index 4a364b06aab5..4d4487aa9665 100644 --- a/paimon-python/pypaimon/catalog/catalog.py +++ b/paimon-python/pypaimon/catalog/catalog.py @@ -89,6 +89,15 @@ def alter_database(self, name: str, changes: list): def get_table(self, identifier: Union[str, Identifier]) -> 'Table': """Get paimon table identified by the given Identifier.""" + def get_table_via(self, table: Union[str, Identifier], via: Union[str, Identifier]) -> 'Table': + """Get table via a view (view penetration). + + If the caller has permission on the view, they can access the underlying table. + This API can only be called by trusted engines. The server must authenticate + whether the caller is a trusted engine. + """ + return self.get_table(table) + @abstractmethod def create_table(self, identifier: Union[str, Identifier], schema: Schema, ignore_if_exists: bool): """Create table with schema.""" diff --git a/paimon-python/pypaimon/catalog/rest/rest_catalog.py b/paimon-python/pypaimon/catalog/rest/rest_catalog.py index d6e89d50b9b1..d1c51c8f2ea1 100644 --- a/paimon-python/pypaimon/catalog/rest/rest_catalog.py +++ b/paimon-python/pypaimon/catalog/rest/rest_catalog.py @@ -225,6 +225,23 @@ def get_table(self, identifier: Union[str, Identifier]): return self._load_system_table(identifier) return self._load_data_table(identifier) + def get_table_via(self, table: Union[str, Identifier], via: Union[str, Identifier]): + """Get table via a view (view penetration). + + This API can only be called by trusted engines. The server must authenticate + whether the caller is a trusted engine. + """ + if not isinstance(table, Identifier): + table = Identifier.from_string(table) + if not isinstance(via, Identifier): + via = Identifier.from_string(via) + return self.load_table( + table, + lambda path: self.file_io_for_data(path, table), + self.file_io_from_options, + lambda i: self._load_table_metadata_via(i, via), + ) + def _load_data_table(self, identifier: Identifier): return self.load_table( identifier, @@ -624,6 +641,15 @@ def load_table_metadata(self, identifier: Identifier) -> TableMetadata: except ForbiddenException as e: raise TableNoPermissionException(identifier) from e + def _load_table_metadata_via(self, table: Identifier, via: Identifier) -> TableMetadata: + try: + response = self.rest_api.get_table_via(table, via) + return self.to_table_metadata(table.get_database_name(), response) + except NoSuchResourceException as e: + raise TableNotExistException(table) from e + except ForbiddenException as e: + raise TableNoPermissionException(table) from e + def to_table_metadata(self, db: str, response: GetTableResponse) -> TableMetadata: schema = TableSchema.from_schema(response.schema_id, response.get_schema()) options: Dict[str, str] = dict(schema.options) From d35a91cb36fecc64a0511130ac84d72b8ed5ff18 Mon Sep 17 00:00:00 2001 From: JingsongLi Date: Thu, 4 Jun 2026 12:15:22 +0800 Subject: [PATCH 4/9] [rest] Add Java tests for getTableVia endpoint Test cases: - testGetTableVia: verifies table can be retrieved via view - testGetTableViaWhenTableNotExist: verifies TableNotExistException - testGetTableViaWhenTableNoPermission: verifies TableNoPermissionException Co-Authored-By: Claude Opus 4.6 --- .../apache/paimon/rest/RESTCatalogTest.java | 30 +++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/paimon-core/src/test/java/org/apache/paimon/rest/RESTCatalogTest.java b/paimon-core/src/test/java/org/apache/paimon/rest/RESTCatalogTest.java index 6ff873aed17d..0224ce7c6b7c 100644 --- a/paimon-core/src/test/java/org/apache/paimon/rest/RESTCatalogTest.java +++ b/paimon-core/src/test/java/org/apache/paimon/rest/RESTCatalogTest.java @@ -406,6 +406,36 @@ void testGetTableById() throws Exception { () -> restCatalog.getTableById("missing_table_id")); } + @Test + void testGetTableVia() throws Exception { + Identifier tableIdentifier = Identifier.create("test_table_db", "via_table"); + Identifier viewIdentifier = Identifier.create("test_table_db", "via_view"); + createTable(tableIdentifier, Maps.newHashMap(), Lists.newArrayList()); + Table table = restCatalog.getTableVia(tableIdentifier, viewIdentifier); + assertThat(table).isNotNull(); + assertThat(table.name()).isEqualTo("via_table"); + } + + @Test + void testGetTableViaWhenTableNotExist() { + Identifier tableIdentifier = Identifier.create("test_table_db", "non_exist_table"); + Identifier viewIdentifier = Identifier.create("test_table_db", "via_view"); + assertThrows( + Catalog.TableNotExistException.class, + () -> restCatalog.getTableVia(tableIdentifier, viewIdentifier)); + } + + @Test + void testGetTableViaWhenTableNoPermission() throws Exception { + Identifier tableIdentifier = Identifier.create("test_table_db", "no_perm_via_table"); + Identifier viewIdentifier = Identifier.create("test_table_db", "via_view"); + createTable(tableIdentifier, Maps.newHashMap(), Lists.newArrayList()); + revokeTablePermission(tableIdentifier); + assertThrows( + Catalog.TableNoPermissionException.class, + () -> restCatalog.getTableVia(tableIdentifier, viewIdentifier)); + } + @Test void renameWhenTargetTableExist() throws Exception { Identifier identifier = Identifier.create("test_table_db", "rename_table"); From 1564be81aacd45d0e0b669ad6456b98b49569895 Mon Sep 17 00:00:00 2001 From: JingsongLi Date: Thu, 4 Jun 2026 12:15:33 +0800 Subject: [PATCH 5/9] [python] Add tests for get_table_via endpoint Test cases: - test_get_table_via: verifies table can be retrieved via view - test_get_table_via_with_string_identifier: verifies string identifiers work - test_get_table_via_not_exist: verifies TableNotExistException Also adds _table_via_handle to test REST server mock. Co-Authored-By: Claude Opus 4.6 --- .../pypaimon/tests/rest/rest_catalog_test.py | 35 +++++++++++++++++++ .../pypaimon/tests/rest/rest_server.py | 17 +++++++++ 2 files changed, 52 insertions(+) diff --git a/paimon-python/pypaimon/tests/rest/rest_catalog_test.py b/paimon-python/pypaimon/tests/rest/rest_catalog_test.py index 295153cf3862..5b1cb7785ad4 100644 --- a/paimon-python/pypaimon/tests/rest/rest_catalog_test.py +++ b/paimon-python/pypaimon/tests/rest/rest_catalog_test.py @@ -369,5 +369,40 @@ def test_catalog_load_snapshot_after_rollback(self): self.assertEqual(table_snapshot.snapshot.id, 3) + def test_get_table_via(self): + """Test get table via view (view penetration).""" + table_name = "default.table_for_via" + pa_schema = pa.schema([('col1', pa.int32())]) + schema = Schema.from_pyarrow_schema(pa_schema) + self.rest_catalog.create_table(table_name, schema, False) + + table_identifier = Identifier.from_string(table_name) + view_identifier = Identifier.from_string("default.some_view") + + table = self.rest_catalog.get_table_via(table_identifier, view_identifier) + self.assertIsNotNone(table) + self.assertEqual(table.name(), "table_for_via") + + def test_get_table_via_with_string_identifier(self): + """Test get table via view using string identifiers.""" + table_name = "default.table_for_via_str" + pa_schema = pa.schema([('col1', pa.int32())]) + schema = Schema.from_pyarrow_schema(pa_schema) + self.rest_catalog.create_table(table_name, schema, False) + + table = self.rest_catalog.get_table_via(table_name, "default.some_view") + self.assertIsNotNone(table) + self.assertEqual(table.name(), "table_for_via_str") + + def test_get_table_via_not_exist(self): + """Test get table via view when table does not exist.""" + from pypaimon.catalog.catalog import TableNotExistException + table_identifier = Identifier.from_string("default.non_exist_via") + view_identifier = Identifier.from_string("default.some_view") + + with self.assertRaises(TableNotExistException): + self.rest_catalog.get_table_via(table_identifier, view_identifier) + + if __name__ == '__main__': unittest.main() diff --git a/paimon-python/pypaimon/tests/rest/rest_server.py b/paimon-python/pypaimon/tests/rest/rest_server.py index 55cf2d6baaee..d6f4e51ac7e2 100755 --- a/paimon-python/pypaimon/tests/rest/rest_server.py +++ b/paimon-python/pypaimon/tests/rest/rest_server.py @@ -555,6 +555,8 @@ def _handle_table_resource(self, method: str, path_parts: List[str], return self._branches_handle(method, data, lookup_identifier) else: return self._mock_response(ErrorResponse(None, None, "Not Found", 404), 404) + elif len(path_parts) == 5 and path_parts[3] == ResourcePaths.VIA: + return self._table_via_handle(lookup_identifier) elif len(path_parts) == 5 and path_parts[3] == ResourcePaths.TAGS: tag_name = RESTUtil.decode_string(path_parts[4]) return self._tag_handle(method, lookup_identifier, tag_name) @@ -1021,6 +1023,21 @@ def _table_handle(self, method: str, data: str, identifier: Identifier) -> Tuple return self._mock_response(ErrorResponse(None, None, "Method Not Allowed", 405), 405) + def _table_via_handle(self, identifier: Identifier) -> Tuple[str, int]: + """Handle get table via view (view penetration). + + This API can only be called by trusted engines. The server must authenticate + whether the caller is a trusted engine. + """ + if identifier.get_full_name() not in self.table_metadata_store: + raise TableNotExistException(identifier) + table_metadata = self.table_metadata_store[identifier.get_full_name()] + table_path = (f'file://{self.data_path}/{self.warehouse}/' + f'{identifier.get_database_name()}/{identifier.get_object_name()}') + schema = table_metadata.schema.to_schema() + response = self.mock_table(identifier, table_metadata, table_path, schema) + return self._mock_response(response, 200) + def _table_token_handle(self, method: str, identifier: Identifier) -> Tuple[str, int]: if method != "GET": return self._mock_response(ErrorResponse(None, None, "Method Not Allowed", 405), 405) From 50e603e5af96c2bba0509f34c0324ed863366cd0 Mon Sep 17 00:00:00 2001 From: JingsongLi Date: Thu, 4 Jun 2026 13:33:57 +0800 Subject: [PATCH 6/9] [rest] Fix tableVia dispatch: resources.length should be 6 The via path /databases/{db}/tables/{table}/via/{via_db}/{via_object} splits into 6 segments, not 5. Co-Authored-By: Claude Opus 4.6 --- .../src/test/java/org/apache/paimon/rest/RESTCatalogServer.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 2aca3a3de70e..91d5794494d5 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 @@ -414,7 +414,7 @@ && isTableByIdRequest(request.getPath())) { && ResourcePaths.TABLES.equals(resources[1]) && ResourcePaths.SNAPSHOTS.equals(resources[3]); boolean isTableVia = - resources.length == 5 + resources.length == 6 && ResourcePaths.TABLES.equals(resources[1]) && ResourcePaths.VIA.equals(resources[3]); boolean isTableAuth = From 5f6b755f52a1df71271bb1446596bbf1d90e0545 Mon Sep 17 00:00:00 2001 From: JingsongLi Date: Thu, 4 Jun 2026 13:34:06 +0800 Subject: [PATCH 7/9] [python] Fix tableVia dispatch length and flake8 lint - path_parts length should be 6, not 5 - Remove extra blank line (E303) Co-Authored-By: Claude Opus 4.6 --- paimon-python/pypaimon/tests/rest/rest_catalog_test.py | 1 - paimon-python/pypaimon/tests/rest/rest_server.py | 2 +- 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/paimon-python/pypaimon/tests/rest/rest_catalog_test.py b/paimon-python/pypaimon/tests/rest/rest_catalog_test.py index 5b1cb7785ad4..32d0acc459e8 100644 --- a/paimon-python/pypaimon/tests/rest/rest_catalog_test.py +++ b/paimon-python/pypaimon/tests/rest/rest_catalog_test.py @@ -368,7 +368,6 @@ def test_catalog_load_snapshot_after_rollback(self): self.assertIsNotNone(table_snapshot) self.assertEqual(table_snapshot.snapshot.id, 3) - def test_get_table_via(self): """Test get table via view (view penetration).""" table_name = "default.table_for_via" diff --git a/paimon-python/pypaimon/tests/rest/rest_server.py b/paimon-python/pypaimon/tests/rest/rest_server.py index d6f4e51ac7e2..f8aa5c0f27f1 100755 --- a/paimon-python/pypaimon/tests/rest/rest_server.py +++ b/paimon-python/pypaimon/tests/rest/rest_server.py @@ -555,7 +555,7 @@ def _handle_table_resource(self, method: str, path_parts: List[str], return self._branches_handle(method, data, lookup_identifier) else: return self._mock_response(ErrorResponse(None, None, "Not Found", 404), 404) - elif len(path_parts) == 5 and path_parts[3] == ResourcePaths.VIA: + elif len(path_parts) == 6 and path_parts[3] == ResourcePaths.VIA: return self._table_via_handle(lookup_identifier) elif len(path_parts) == 5 and path_parts[3] == ResourcePaths.TAGS: tag_name = RESTUtil.decode_string(path_parts[4]) From b94950b854dc5765f18214cc00339c41daf999e3 Mon Sep 17 00:00:00 2001 From: JingsongLi Date: Thu, 4 Jun 2026 14:00:10 +0800 Subject: [PATCH 8/9] [python] Fix test assertions and import path - Remove table.name() calls (FileStoreTable has no name method) - Fix TableNotExistException import from catalog_exception module Co-Authored-By: Claude Opus 4.6 --- paimon-python/pypaimon/tests/rest/rest_catalog_test.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/paimon-python/pypaimon/tests/rest/rest_catalog_test.py b/paimon-python/pypaimon/tests/rest/rest_catalog_test.py index 32d0acc459e8..dea230b1df8e 100644 --- a/paimon-python/pypaimon/tests/rest/rest_catalog_test.py +++ b/paimon-python/pypaimon/tests/rest/rest_catalog_test.py @@ -380,7 +380,6 @@ def test_get_table_via(self): table = self.rest_catalog.get_table_via(table_identifier, view_identifier) self.assertIsNotNone(table) - self.assertEqual(table.name(), "table_for_via") def test_get_table_via_with_string_identifier(self): """Test get table via view using string identifiers.""" @@ -391,11 +390,10 @@ def test_get_table_via_with_string_identifier(self): table = self.rest_catalog.get_table_via(table_name, "default.some_view") self.assertIsNotNone(table) - self.assertEqual(table.name(), "table_for_via_str") def test_get_table_via_not_exist(self): """Test get table via view when table does not exist.""" - from pypaimon.catalog.catalog import TableNotExistException + from pypaimon.catalog.catalog_exception import TableNotExistException table_identifier = Identifier.from_string("default.non_exist_via") view_identifier = Identifier.from_string("default.some_view") From d42e8934375034cf4ed7e663045f8a9b5eaf8acc Mon Sep 17 00:00:00 2001 From: JingsongLi Date: Thu, 4 Jun 2026 15:28:59 +0800 Subject: [PATCH 9/9] [rest] Address review: PrivilegedCatalog override and OpenAPI spec - Override getTableVia in PrivilegedCatalog to wrap FileStoreTable in PrivilegedFileStoreTable, matching the getTable pattern - Add /via/{viaDatabase}/{viaObject} endpoint to OpenAPI spec Co-Authored-By: Claude Opus 4.6 --- docs/static/rest-catalog-open-api.yaml | 63 +++++++++++++++++++ .../paimon/privilege/PrivilegedCatalog.java | 11 ++++ 2 files changed, 74 insertions(+) diff --git a/docs/static/rest-catalog-open-api.yaml b/docs/static/rest-catalog-open-api.yaml index e310f398f821..76f69f74cff3 100644 --- a/docs/static/rest-catalog-open-api.yaml +++ b/docs/static/rest-catalog-open-api.yaml @@ -740,6 +740,69 @@ paths: $ref: '#/components/responses/TableNotExistErrorResponse' "500": $ref: '#/components/responses/ServerErrorResponse' + /v1/{prefix}/databases/{database}/tables/{table}/via/{viaDatabase}/{viaObject}: + post: + tags: + - table + summary: Get table via view (view penetration) + description: > + Get the table metadata accessed via a view. If the caller has permission + on the view, they can access the underlying table referenced by the view. + This API can only be called by trusted engines. The server must + authenticate whether the caller is a trusted engine. + operationId: getTableVia + parameters: + - name: prefix + in: path + required: true + schema: + type: string + - name: database + in: path + required: true + schema: + type: string + - name: table + in: path + required: true + schema: + type: string + - name: viaDatabase + in: path + required: true + schema: + type: string + description: Database name of the view through which access is granted + - name: viaObject + in: path + required: true + schema: + type: string + description: Name of the view through which access is granted + responses: + "200": + description: Table metadata accessed via the view + content: + application/json: + schema: + $ref: '#/components/schemas/GetTableResponse' + "401": + $ref: '#/components/responses/UnauthorizedErrorResponse' + "403": + $ref: '#/components/responses/ForbiddenErrorResponse' + 404: + description: + Not Found + - TableNotExistException, table does not exist + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + examples: + TableNotExist: + $ref: '#/components/examples/TableNotExistError' + "500": + $ref: '#/components/responses/ServerErrorResponse' /v1/{prefix}/databases/{database}/tables/{table}/auth: post: tags: diff --git a/paimon-core/src/main/java/org/apache/paimon/privilege/PrivilegedCatalog.java b/paimon-core/src/main/java/org/apache/paimon/privilege/PrivilegedCatalog.java index b408055e5112..97fe1e8f4617 100644 --- a/paimon-core/src/main/java/org/apache/paimon/privilege/PrivilegedCatalog.java +++ b/paimon-core/src/main/java/org/apache/paimon/privilege/PrivilegedCatalog.java @@ -158,6 +158,17 @@ public Table getTable(Identifier identifier) throws TableNotExistException { } } + @Override + public Table getTableVia(Identifier table, Identifier via) throws TableNotExistException { + Table result = wrapped.getTableVia(table, via); + if (result instanceof FileStoreTable) { + return PrivilegedFileStoreTable.wrap( + (FileStoreTable) result, privilegeManager.getPrivilegeChecker(), table); + } else { + return result; + } + } + @Override public void markDonePartitions(Identifier identifier, List> partitions) throws TableNotExistException {