This is an automated email from the ASF dual-hosted git repository.

Gabriel39 pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/doris.git


The following commit(s) were added to refs/heads/master by this push:
     new 56dfbb1c273 [feature](fe) Add encrypted Iceberg read admission checks 
(#70159)
56dfbb1c273 is described below

commit 56dfbb1c273050aa3cc0f32d20a242be24af4900
Author: Gabriel <[email protected]>
AuthorDate: Wed Nov 4 15:15:15 2026 +0800

    [feature](fe) Add encrypted Iceberg read admission checks (#70159)
    
    ### What problem does this PR solve?
    
    Related PR: #70087
    
    Problem Summary:
    
    Enabling Parquet modular encryption in Arrow does not implement native
    encrypted Iceberg reads in Doris. Scan planning currently has no shared
    admission check for an encrypted table. Ordinary scans can emit ranges
    without establishing encryption support, while metadata-only COUNT and
    system-table routes can bypass the native data reader altogether.
    
    For example, a v3 table whose SDK metadata contains `encryption.key-id`
    can reach scan planning and COUNT pushdown without any proof that the
    selected backends understand encrypted Iceberg tasks. This PR
    establishes the first-stage read contract before native execution is
    enabled.
    
    - Add `enable_encryption_read` to the Iceberg plugin's `iceberg.conf`,
    defaulting to `false`. This is deployment configuration requiring an FE
    restart, with no catalog/session override or fe.conf fallback.
    - Share admission checks across table metadata and snapshot binding,
    ordinary scans, COUNT eligibility/pushdown, streaming scans, system
    tables and explicitly empty snapshot pins.
    - Detect encryption from the table key property, retained v3 encryption
    keys, or a non-plaintext SDK manager. Removing the current write key
    must not admit old encrypted snapshots as plaintext.
    - Return stable classifications for disabled reads, unsupported
    catalogs/table formats/custom managers, and unavailable backend
    capabilities. Error text excludes key identifiers and encryption
    objects.
    - Even with the switch on, reject encrypted execution until backend
    capability negotiation and native readers exist. A declared encrypted
    table is rejected before KMS manager initialization or manifest I/O.
    Plaintext scans and exact metadata counts retain their behavior.
    
    The included developer contract defines the initial HMS / v3 / native
    Parquet `AES_GCM_V1` target and the requirements for explicit BE
    advertisement, placement, retries, key ownership and redaction. This PR
    deliberately adds no SPI/wire fields and grants no encrypted-write
    capability. Actual per-file format/algorithm validation and undeclared
    externally encrypted files remain part of the subsequent planner/reader
    work.
    
    ### Validation
    
    ```sh
    JAVA_TOOL_OPTIONS='-XX:ActiveProcessorCount=8' mvn -f fe/pom.xml -pl 
:fe-connector-iceberg -am package     -Dmaven.build.cache.enabled=false
    mvn -f fe/pom.xml -pl :fe-connector-iceberg checkstyle:check
    ```
    
    - The complete reactor package succeeded, including connector packaging
    and upstream module tests. Iceberg connector results: **1923 tests
    reported, 1909 passed, 14 existing skips, 0 failures, 0 errors**.
    - Added 8 contract tests and 1 configuration test. Real SDK metadata
    fixtures cover empty/populated tables, direct/cached acquisition,
    statement scopes, metadata binding, COUNT, streaming, system tables,
    retained keys, unsupported catalogs/v2/custom managers, error redaction
    and rejection before KMS initialization or manifest/data I/O.
    - Before the implementation, five initial contract tests failed because
    encrypted reads were admitted; the plaintext compatibility test passed.
    The final added contract/configuration tests all pass.
    - FE Checkstyle: **0 violations**. Verified that the plugin zip includes
    the admission class and the updated configuration template.
    - Local HTTP mock servers were run without proxy interception. The test
    JVM uses 8 processors so the existing cancellation fixtures do not
    trigger the SDK's automatic manifest-merge threshold. No existing test
    was changed or excluded for this.
    
    SQL regression coverage is included in
    `test_iceberg_encryption_read_guard`:
    
    ```sh
    bash run-regression-test.sh --run -d external_table_p0/iceberg \
        -s test_iceberg_encryption_read_guard
    ```
    
    - Built the full FE from this PR using `build.sh --fe`, and ran the
    suite with an isolated FE and an existing ASAN BE against the standard
    REST/MinIO fixture.
    - The SQL suite directly asserts the production default,
    `READ_DISABLED`, without a separate regression option for the expected
    FE switch state. It also sets a catalog-level
    `enable_encryption_read=true` to verify that a catalog cannot bypass the
    deployment-level setting.
    - Default plugin configuration: passed normal expected-output
    comparison; **96 admission-error assertions**, with both table-cache
    settings, both COUNT pushdown settings, populated/empty declarations,
    repeated queries, SELECT, COUNT, DESC and `$files` / `$snapshots` /
    `$position_deletes`. Plaintext rows and counts remain correct.
    - Connector unit tests explicitly construct both enabled and disabled
    plugin contexts; enabled REST contexts reject with
    `UNSUPPORTED_CATALOG`, and eligible HMS contexts reject with
    `BACKEND_CAPABILITY_UNAVAILABLE`. The ordinary SQL regression does not
    require an enabled FE deployment.
    - Mutation control: in a temporary runtime-only artifact, replaced the
    admission helper with a no-op. The suite failed at the first
    declared-table SELECT because the required `READ_DISABLED` error was
    absent. Restored and hash-verified the unmodified production artifact
    before the passing runs; no test hook is included in the source change.
    - The expected `.out` file was generated by the standard regression
    runner and then used for normal comparison. The suite was rerun against
    its existing remote tables, exercising repeatable fixture cleanup. FE
    Checkstyle was rechecked: 0 violations.
    
    These SQL tests use real v3 tables and plaintext data files with
    synthetic encryption declarations to isolate admission from decryption
    failures. They validate the FE/SQL guard, not native ciphertext
    decoding. Live KMS decryption, mixed-version cluster integration and
    full encrypted-query execution remain subsequent work.
    
    ### Release note
    
    Declared encrypted Iceberg tables now fail with clear read-admission
    errors. The plugin read switch defaults off, and enabling it cannot
    bypass unavailable backend support or enable encrypted writes.
    
    ### Check List (For Author)
    
    - Test
        - [x] Unit Test
        - [x] Regression test
        - [ ] Manual test
    - Behavior changed:
    - [x] Yes. Declared encrypted tables are rejected consistently before
    scan execution or metadata-count shortcuts.
    - Does this need documentation?
    - [x] Yes. Developer contract and configuration comments are included in
    this PR; public query documentation follows when encrypted execution is
    implemented.
    
    ### Check List (For Reviewer who merge this PR)
    
    - [ ] Confirm the release note
    - [ ] Confirm test cases
    - [ ] Confirm document
    - [ ] Add branch pick label
---
 fe/fe-connector/README.md                          |   4 +
 fe/fe-connector/docs/iceberg-encryption-read.md    | 118 ++++++++
 .../doris/connector/iceberg/IcebergConf.java       |  14 +-
 .../iceberg/IcebergConnectorMetadata.java          |  19 +-
 .../connector/iceberg/IcebergEncryptionRead.java   |  73 +++++
 .../connector/iceberg/IcebergScanPlanProvider.java |  15 +-
 .../src/main/resources/iceberg.conf.template       |  11 +-
 .../doris/connector/iceberg/FakeIcebergTable.java  |   3 +-
 .../iceberg/IcebergConnectorConfTest.java          |  14 +-
 .../iceberg/IcebergEncryptionReadTest.java         | 297 +++++++++++++++++++++
 .../iceberg/test_iceberg_encryption_read_guard.out |  44 +++
 .../test_iceberg_encryption_read_guard.groovy      | 141 ++++++++++
 12 files changed, 736 insertions(+), 17 deletions(-)

diff --git a/fe/fe-connector/README.md b/fe/fe-connector/README.md
index abac08783f1..1b61b82c0ab 100644
--- a/fe/fe-connector/README.md
+++ b/fe/fe-connector/README.md
@@ -129,6 +129,10 @@ and reports observed partition, file, split, and 
unfinished-producer counts.
 producer count. A positive value caps producers per scan; a whole-scan 
streaming
 source uses one producer.
 
+**Iceberg encryption.** The [encrypted-read admission 
contract](docs/iceberg-encryption-read.md)
+defines the default-off configuration, initial scope and backend negotiation
+requirements. Encrypted scan execution remains unavailable at this stage.
+
 **Classloading.** Plugins load child-first, each carrying its own runtime
 closure. Wherever engine code crosses into a plugin — or a bundled library
 resolves classes by name, or a plugin spawns worker threads — the
diff --git a/fe/fe-connector/docs/iceberg-encryption-read.md 
b/fe/fe-connector/docs/iceberg-encryption-read.md
new file mode 100644
index 00000000000..ae840b30e95
--- /dev/null
+++ b/fe/fe-connector/docs/iceberg-encryption-read.md
@@ -0,0 +1,118 @@
+# Iceberg encrypted-read contract
+
+This is the admission contract for the first stage of native Iceberg encrypted
+reads. Encrypted queries remain unavailable until key transport, native 
readers,
+delete handling and backend capability negotiation are implemented. Building
+Arrow with Parquet modular encryption alone does not establish those 
capabilities.
+
+## Configuration and admission
+
+`enable_encryption_read` in the Iceberg plugin's `iceberg.conf` defaults to
+`false`. It is deployment configuration: set it consistently on every FE and
+restart the FE after changes. It is neither a catalog property nor a session
+variable. Enabling it expresses operator intent; it does not override missing
+execution capabilities or enable encrypted writes.
+
+Plaintext tables retain their existing paths, including metadata-only counts.
+For encrypted tables the same check applies to metadata binding, snapshot
+resolution, ordinary scans, metadata tables, streaming scans and COUNT 
pushdown.
+The check runs against the base table before constructing a metadata table or
+reading its manifests. An empty snapshot does not exempt an encrypted table.
+
+The initial target matrix is:
+
+| Dimension | Initial target | Current admission |
+| --- | --- | --- |
+| Catalog | HMS with the SDK standard encryption manager and an 
operator-configured KMS | Other catalogs are rejected |
+| Table format | Iceberg v3 | Other versions are rejected for encrypted reads |
+| Data and delete files | Native Parquet modular encryption, `AES_GCM_V1` | No 
encrypted ranges are emitted yet |
+| Manifest/list files | SDK-managed encryption | No plaintext fallback is 
allowed |
+| Backend | Explicit encrypted-read protocol and reader capability on every 
eligible BE | Capability is currently unverified, so admission fails |
+| Writes | Outside this read contract | The read switch grants no write 
capability |
+
+Table encryption is recognized from `encryption.key-id`, retained v3
+`encryption-keys`, or a non-plaintext SDK encryption manager. Retained keys 
matter
+when an administrator removes the current write key while older snapshots still
+reference encrypted files. The check must not log key identifiers, key 
metadata,
+KMS credentials or key material.
+
+The writer's `write.format.default` is not evidence of the format of existing
+files. File format, algorithm, key metadata and AAD must be validated on the
+actual data/delete tasks by the subsequent planning and reader implementations.
+This stage rejects encrypted tables before that work, even with the switch on.
+It does not inspect every plaintext table's manifests just to discover 
encryption.
+Externally encrypted files with no Iceberg encryption declaration are outside
+this table-level contract; data-task validation must reject them when 
encountered.
+
+## Failure classification
+
+Errors contain a stable `Iceberg encryption [CLASSIFICATION]` prefix:
+
+| Classification | Meaning |
+| --- | --- |
+| `READ_DISABLED` | Encrypted reads were not enabled in the plugin 
configuration |
+| `UNSUPPORTED_CATALOG` | The catalog is outside the initial HMS scope |
+| `UNSUPPORTED_TABLE_FORMAT` | The encrypted table is not v3 |
+| `UNSUPPORTED_ENCRYPTION_MANAGER` | Encryption detection observed a custom 
SDK encryption manager |
+| `BACKEND_CAPABILITY_UNAVAILABLE` | The engine cannot prove encrypted-read 
support |
+
+The first applicable failure is returned in this order. For a table with an
+encryption declaration, backend admission fails without initializing its KMS
+manager; manager and per-file validation belong to subsequent read 
implementation.
+A rejected read must
+not fall back to plaintext, metadata counts, a JNI reader or another catalog.
+Plaintext reads in those catalogs continue to work.
+
+## Backend negotiation required before enabling execution
+
+The protocol change must introduce an explicit, versioned 
encrypted-Iceberg-read
+capability, advertised by each BE. An absent field, an older BE, an unknown
+capability or a build without PME support means **unsupported**. A generic
+execution version, the FE's Iceberg version and the FE configuration switch are
+not substitutes for this advertisement.
+
+The scheduler must restrict placement to compatible BEs and fail if it cannot
+establish that all selected execution targets support the complete 
encrypted-read
+contract. Reassignment and retries must repeat that validation. The native 
reader
+must also reject an encrypted range it cannot handle, so a stale heartbeat 
cannot
+silently downgrade a read. FE-to-BE key transport must have explicit ownership,
+bounded lifetime and redaction rules; keys must not enter explain output, 
profiles
+or persistent scan caches.
+
+Until those protocol and reader changes land together, the connector 
deliberately
+reports `BACKEND_CAPABILITY_UNAVAILABLE` rather than manufacturing a successful
+capability result. No SPI or wire-format capability is added by this first 
stage.
+
+See the upstream [Iceberg encryption 
documentation](https://iceberg.apache.org/docs/nightly/encryption/)
+and [table specification](https://iceberg.apache.org/spec/#encryption-keys) for
+the SDK encryption scheme and retained table keys.
+
+## SQL regression
+
+`test_iceberg_encryption_read_guard` uses the standard external Iceberg REST 
and
+MinIO fixture. With `enableIcebergTest=true`, run:
+
+```sh
+bash run-regression-test.sh --run -d external_table_p0/iceberg \
+    -s test_iceberg_encryption_read_guard
+```
+
+The suite writes ordinary v3 tables, then uses the REST fixture to add an
+encryption declaration to a populated table and an empty table. These are
+metadata admission fixtures, not encrypted payloads: the expected failure must
+precede file decryption and requires no KMS. It checks ordinary SELECT, COUNT
+with pushdown enabled/disabled, DESC and system tables, with the table cache
+enabled/disabled and repeated reads. Plaintext row/count results are checked
+before and after rejection. Missing fixture services fail the enabled suite.
+
+The suite runs against the default FE deployment, with `enable_encryption_read`
+absent or set to `false` in every FE's Iceberg plugin configuration. It always
+expects `READ_DISABLED`, including when the catalog requests encrypted reads;
+there is no separate regression setting for the expected deployment state.
+An incorrect error classification fails the suite.
+
+Connector unit tests explicitly construct enabled and disabled plugin contexts
+to cover both switch states, unsupported catalogs and table formats, retained
+v3 keys, custom managers and unavailable backend capabilities. The SQL suite
+and these unit tests validate admission; they do not exercise native decryption
+or grant encrypted-write support.
diff --git 
a/fe/fe-connector/fe-connector-iceberg/src/main/java/org/apache/doris/connector/iceberg/IcebergConf.java
 
b/fe/fe-connector/fe-connector-iceberg/src/main/java/org/apache/doris/connector/iceberg/IcebergConf.java
index f8d1f377d56..79c73f229b6 100644
--- 
a/fe/fe-connector/fe-connector-iceberg/src/main/java/org/apache/doris/connector/iceberg/IcebergConf.java
+++ 
b/fe/fe-connector/fe-connector-iceberg/src/main/java/org/apache/doris/connector/iceberg/IcebergConf.java
@@ -23,10 +23,11 @@ import org.apache.doris.connector.spi.ConnectorContext;
 /**
  * This connector's deployment-level settings — the "B class" of the connector 
property convention, as
  * opposed to the per-catalog properties in {@link IcebergCatalogProperties}. 
These are read from the
- * plugin's own {@code iceberg.conf} (named after {@code 
ConnectorProvider.name()}), each falling back to
- * the {@code ENV_} name below it, which is the fe.conf key it used to live 
under and still works.
+ * plugin's own {@code iceberg.conf} (named after {@code 
ConnectorProvider.name()}). Existing shared settings
+ * fall back to the {@code ENV_} name below them; new settings have no fe.conf 
fallback.
  *
- * <p>Both conf keys are shared with other connectors at the fe.conf end — one 
{@code jdbc_drivers_dir}
+ * <p>The driver and metastore conf keys are shared with other connectors at 
the fe.conf end — one
+ * {@code jdbc_drivers_dir}
  * and one {@code hive_metastore_client_timeout_second} serve jdbc, iceberg 
and paimon. A plugin conf
  * cannot express that, so a deployment that moves to these files sets the 
value in each plugin's own
  * conf. That is the accepted cost of a per-plugin file; the fe.conf keys stay 
as the shared fallback.
@@ -35,6 +36,7 @@ public final class IcebergConf {
 
     public static final String CONF_DRIVERS_DIR = "drivers_dir";
     public static final String CONF_METASTORE_CLIENT_TIMEOUT_SECOND = 
"metastore_client_timeout_second";
+    public static final String CONF_ENABLE_ENCRYPTION_READ = 
"enable_encryption_read";
 
     /** The fe.conf name of {@link #CONF_DRIVERS_DIR}, forwarded through the 
engine environment. */
     public static final String ENV_JDBC_DRIVERS_DIR = "jdbc_drivers_dir";
@@ -49,6 +51,12 @@ public final class IcebergConf {
     private IcebergConf() {
     }
 
+    /** Operator intent only: the read contract independently requires 
verified backend capabilities. */
+    public static boolean encryptionReadEnabled(ConnectorContext context) {
+        return context != null && Boolean.parseBoolean(
+                ConnectorConf.get(context, CONF_ENABLE_ENCRYPTION_READ, null, 
"false"));
+    }
+
     /**
      * The directory a bare driver jar name resolves under, from this plugin's 
own {@code iceberg.conf}
      * or fe.conf's {@code jdbc_drivers_dir}. Null when neither names one — 
{@code JdbcDriverSupport}
diff --git 
a/fe/fe-connector/fe-connector-iceberg/src/main/java/org/apache/doris/connector/iceberg/IcebergConnectorMetadata.java
 
b/fe/fe-connector/fe-connector-iceberg/src/main/java/org/apache/doris/connector/iceberg/IcebergConnectorMetadata.java
index e55a9e372d0..ac669d74d13 100644
--- 
a/fe/fe-connector/fe-connector-iceberg/src/main/java/org/apache/doris/connector/iceberg/IcebergConnectorMetadata.java
+++ 
b/fe/fe-connector/fe-connector-iceberg/src/main/java/org/apache/doris/connector/iceberg/IcebergConnectorMetadata.java
@@ -782,18 +782,22 @@ public class IcebergConnectorMetadata implements 
ConnectorMetadata {
      * the sys-table path ({@link #loadSysTable}), which builds its metadata 
view from this same scoped base.
      */
     private Table resolveTableForRead(ConnectorSession session, 
IcebergTableHandle handle) {
+        Table table;
         if (tableCache != null) {
-            return IcebergStatementScope.sharedBorrowedTable(session, 
handle.getDbName(), handle.getTableName(),
+            table = IcebergStatementScope.sharedBorrowedTable(session, 
handle.getDbName(), handle.getTableName(),
                     () -> 
tableCache.borrow(TableIdentifier.of(handle.getDbName(), handle.getTableName()),
                             () -> catalogOps.loadTable(handle.getDbName(), 
handle.getTableName())),
                     () -> catalogOps.loadTable(handle.getDbName(), 
handle.getTableName()));
+        } else {
+            table = resourceTracker == null
+                    ? IcebergStatementScope.sharedTable(session, 
handle.getDbName(), handle.getTableName(),
+                            () -> catalogOps.loadTable(handle.getDbName(), 
handle.getTableName()))
+                    : IcebergStatementScope.sharedTrackedTable(session, 
handle.getDbName(), handle.getTableName(),
+                            resourceTracker, () -> 
catalogOps.loadTable(handle.getDbName(), handle.getTableName()),
+                            value -> 
IcebergConnector.cachedTableCleanup(value, catalogProps.getFlavor()));
         }
-        return resourceTracker == null
-                ? IcebergStatementScope.sharedTable(session, 
handle.getDbName(), handle.getTableName(),
-                        () -> catalogOps.loadTable(handle.getDbName(), 
handle.getTableName()))
-                : IcebergStatementScope.sharedTrackedTable(session, 
handle.getDbName(), handle.getTableName(),
-                        resourceTracker, () -> 
catalogOps.loadTable(handle.getDbName(), handle.getTableName()),
-                        table -> IcebergConnector.cachedTableCleanup(table, 
catalogProps.getFlavor()));
+        IcebergEncryptionRead.check(table, catalogProps, context);
+        return table;
     }
 
     /**
@@ -2795,6 +2799,7 @@ public class IcebergConnectorMetadata implements 
ConnectorMetadata {
     }
 
     private IcebergLatestSnapshotCache.CachedSnapshot latestSnapshotPin(Table 
table) {
+        IcebergEncryptionRead.check(table, catalogProps, context);
         Snapshot current = table.currentSnapshot();
         ConnectorMvccPartitionView.Style emptyPartitionStyle = 
IcebergPartitionUtils.isValidRelatedTable(table)
                 ? ConnectorMvccPartitionView.Style.RANGE
diff --git 
a/fe/fe-connector/fe-connector-iceberg/src/main/java/org/apache/doris/connector/iceberg/IcebergEncryptionRead.java
 
b/fe/fe-connector/fe-connector-iceberg/src/main/java/org/apache/doris/connector/iceberg/IcebergEncryptionRead.java
new file mode 100644
index 00000000000..45faf6b70f0
--- /dev/null
+++ 
b/fe/fe-connector/fe-connector-iceberg/src/main/java/org/apache/doris/connector/iceberg/IcebergEncryptionRead.java
@@ -0,0 +1,73 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements.  See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership.  The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License.  You may obtain a copy of the License at
+//
+//   http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied.  See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+package org.apache.doris.connector.iceberg;
+
+import org.apache.doris.connector.spi.ConnectorContext;
+import org.apache.doris.connector.spi.DorisConnectorException;
+
+import org.apache.iceberg.HasTableOperations;
+import org.apache.iceberg.Table;
+import org.apache.iceberg.TableMetadata;
+import org.apache.iceberg.TableProperties;
+import org.apache.iceberg.encryption.EncryptionManager;
+import org.apache.iceberg.encryption.PlaintextEncryptionManager;
+import org.apache.iceberg.encryption.StandardEncryptionManager;
+
+/** Shared admission contract; encrypted ranges remain forbidden until the 
engine can prove BE support. */
+final class IcebergEncryptionRead {
+    private IcebergEncryptionRead() {
+    }
+
+    static void check(Table table, IcebergCatalogProperties catalog, 
ConnectorContext context) {
+        TableMetadata metadata = table instanceof HasTableOperations
+                ? ((HasTableOperations) table).operations().current() : null;
+        // Removing the current write key does not make old snapshots 
plaintext. Retained table keys must
+        // fence metadata-only COUNT and system tables before either path can 
bypass native reader admission.
+        boolean declared = 
table.properties().containsKey(TableProperties.ENCRYPTION_TABLE_KEY)
+                || (metadata != null && !metadata.encryptionKeys().isEmpty());
+        EncryptionManager manager = declared ? null : table.encryption();
+        if (!declared && (manager == null || manager instanceof 
PlaintextEncryptionManager)) {
+            return;
+        }
+        if (!IcebergConf.encryptionReadEnabled(context)) {
+            throw failure("READ_DISABLED", "Encrypted Iceberg reads are 
disabled; enable_encryption_read in "
+                    + "iceberg.conf defaults to false");
+        }
+        if (!IcebergCatalogProperties.TYPE_HMS.equals(catalog.getFlavor())) {
+            throw failure("UNSUPPORTED_CATALOG", "Encrypted Iceberg reads 
require an HMS catalog");
+        }
+        if (metadata == null || metadata.formatVersion() != 3) {
+            throw failure("UNSUPPORTED_TABLE_FORMAT", "Encrypted Iceberg reads 
require table format v3");
+        }
+        // A declared table needs no KMS initialization just to reject an 
unverified execution path.
+        // Custom managers observed during detection can already be classified 
without resolving any keys.
+        if (manager != null && !(manager instanceof 
StandardEncryptionManager)) {
+            throw failure("UNSUPPORTED_ENCRYPTION_MANAGER", "Encrypted Iceberg 
reads require the SDK standard "
+                    + "encryption scheme with native Parquet AES_GCM_V1 
files");
+        }
+        // A PME-enabled Arrow build does not prove key-transport, 
delete-reader or mixed-version scheduling
+        // support. Until explicit BE negotiation is wired, even an enabled FE 
must never emit encrypted tasks.
+        throw failure("BACKEND_CAPABILITY_UNAVAILABLE", "Encrypted Iceberg 
read capability is not verified "
+                + "by the engine; native encrypted scan execution is not 
available");
+    }
+
+    private static DorisConnectorException failure(String classification, 
String message) {
+        // Never attach table properties, key identifiers or SDK encryption 
objects to this error.
+        return new DorisConnectorException("Iceberg encryption [" + 
classification + "]: " + message);
+    }
+}
diff --git 
a/fe/fe-connector/fe-connector-iceberg/src/main/java/org/apache/doris/connector/iceberg/IcebergScanPlanProvider.java
 
b/fe/fe-connector/fe-connector-iceberg/src/main/java/org/apache/doris/connector/iceberg/IcebergScanPlanProvider.java
index 0944c2bbfea..7076b1a4720 100644
--- 
a/fe/fe-connector/fe-connector-iceberg/src/main/java/org/apache/doris/connector/iceberg/IcebergScanPlanProvider.java
+++ 
b/fe/fe-connector/fe-connector-iceberg/src/main/java/org/apache/doris/connector/iceberg/IcebergScanPlanProvider.java
@@ -489,6 +489,9 @@ public class IcebergScanPlanProvider implements 
ConnectorScanPlanProvider {
     public long streamingSplitEstimate(ConnectorSession session, 
ConnectorTableHandle handle,
             Optional<ConnectorExpression> filter, boolean countPushdown) {
         IcebergTableHandle iceHandle = (IcebergTableHandle) handle;
+        if (iceHandle.isResolvedEmptySnapshot() && !iceHandle.isSystemTable()) 
{
+            resolveTable(session, iceHandle);
+        }
         if (iceHandle.isResolvedEmptySnapshot() || iceHandle.isSystemTable()
                 || !sessionBool(session, ENABLE_EXTERNAL_TABLE_BATCH_MODE, 
true)) {
             return -1;
@@ -570,6 +573,7 @@ public class IcebergScanPlanProvider implements 
ConnectorScanPlanProvider {
         IcebergTableHandle iceHandle = (IcebergTableHandle) handle;
         validateMetadataColumnReader(session, columns);
         if (iceHandle.isResolvedEmptySnapshot()) {
+            resolveTable(session, iceHandle);
             // The batch decision is made before the engine pins MVCC; once 
pinned empty, streaming must
             // preserve that boundary instead of interpreting Iceberg's 
sentinel as the latest snapshot.
             return emptySplitSource();
@@ -866,7 +870,9 @@ public class IcebergScanPlanProvider implements 
ConnectorScanPlanProvider {
             Optional<ConnectorExpression> filter,
             boolean countPushdown) {
         IcebergTableHandle iceHandle = (IcebergTableHandle) handle;
-        if (iceHandle.isResolvedEmptySnapshot() && 
!isSnapshotIndependentSystemTable(iceHandle)) {
+        if (iceHandle.isResolvedEmptySnapshot() && !iceHandle.isSystemTable()) 
{
+            // Empty pins still need admission: otherwise COUNT/empty scans 
bypass the encryption fence.
+            resolveTable(session, iceHandle);
             // Iceberg has no snapshot id that can represent "before the first 
commit". Returning no ranges is
             // the read-side MVCC fence; otherwise a refreshed Table would 
turn -1 into "latest" and expose a
             // concurrent first append to MERGE after its anti-join had 
already decided the row was absent. Static
@@ -1084,6 +1090,11 @@ public class IcebergScanPlanProvider implements 
ConnectorScanPlanProvider {
     private List<ConnectorScanRange> doPlanSystemTableScan(IcebergTableHandle 
handle,
             List<ConnectorColumnHandle> columns, Optional<ConnectorExpression> 
filter,
             ConnectorSession session) {
+        if (handle.isResolvedEmptySnapshot() && 
!isSnapshotIndependentSystemTable(handle)) {
+            // Keep the empty-table fence inside the same auth scope as every 
other system-table load.
+            resolveSysBaseTable(session, handle);
+            return Collections.emptyList();
+        }
         if (IcebergLabelsSysTable.isLabels(handle)) {
             return doPlanLabelsSystemTableScan(handle, columns, session);
         }
@@ -3625,6 +3636,7 @@ public class IcebergScanPlanProvider implements 
ConnectorScanPlanProvider {
                                 TableIdentifier.of(handle.getDbName(), 
handle.getTableName()), directLoader),
                         directLoader);
         rejectServerSideScanPlanning(raw, handle);
+        IcebergEncryptionRead.check(raw, catalogProps, context);
         return wrapTableForScan(raw);
     }
 
@@ -3715,6 +3727,7 @@ public class IcebergScanPlanProvider implements 
ConnectorScanPlanProvider {
                                 directLoader),
                         directLoader);
         rejectServerSideScanPlanning(base, handle);
+        IcebergEncryptionRead.check(base, catalogProps, context);
         return base;
     }
 
diff --git 
a/fe/fe-connector/fe-connector-iceberg/src/main/resources/iceberg.conf.template 
b/fe/fe-connector/fe-connector-iceberg/src/main/resources/iceberg.conf.template
index 668c9328ac3..1fe0309ff9b 100644
--- 
a/fe/fe-connector/fe-connector-iceberg/src/main/resources/iceberg.conf.template
+++ 
b/fe/fe-connector/fe-connector-iceberg/src/main/resources/iceberg.conf.template
@@ -5,10 +5,10 @@
 # This file must exist on EVERY FE node -- it is not replicated through Doris 
metadata.
 # Changes take effect after an FE restart.
 #
-# Every setting below is optional. Left commented out, each falls back to the 
fe.conf key named
-# after it, and then to the built-in default -- so an existing deployment 
needs no change here.
+# Every setting below is optional. Existing shared settings fall back to the 
fe.conf key named
+# after them, and then to the built-in default. New settings use only their 
documented default.
 #
-# NOTE: both settings below are shared with other connectors at the fe.conf 
end -- one
+# NOTE: the driver and metastore settings are shared with other connectors at 
the fe.conf end -- one
 # jdbc_drivers_dir and one hive_metastore_client_timeout_second serve jdbc, 
iceberg and paimon.
 # A per-plugin conf cannot express that, so if you move them here you must 
also set them in
 # jdbc.conf and paimon.conf. Leaving them commented out keeps the single 
shared fe.conf value.
@@ -21,3 +21,8 @@
 # unless the catalog overrides it. Falls back to fe.conf's 
hive_metastore_client_timeout_second,
 # whose default is 10.
 # metastore_client_timeout_second=10
+
+# Encrypted-read admission intent only; default false, with no fe.conf 
fallback.
+# Enabling this does not enable encrypted writes or bypass missing backend 
read capabilities.
+# Encrypted scan execution remains unavailable until explicit backend 
negotiation and readers exist.
+# enable_encryption_read=false
diff --git 
a/fe/fe-connector/fe-connector-iceberg/src/test/java/org/apache/doris/connector/iceberg/FakeIcebergTable.java
 
b/fe/fe-connector/fe-connector-iceberg/src/test/java/org/apache/doris/connector/iceberg/FakeIcebergTable.java
index bb14a86c934..39347bf4b31 100644
--- 
a/fe/fe-connector/fe-connector-iceberg/src/test/java/org/apache/doris/connector/iceberg/FakeIcebergTable.java
+++ 
b/fe/fe-connector/fe-connector-iceberg/src/test/java/org/apache/doris/connector/iceberg/FakeIcebergTable.java
@@ -42,6 +42,7 @@ import org.apache.iceberg.UpdatePartitionSpec;
 import org.apache.iceberg.UpdateProperties;
 import org.apache.iceberg.UpdateSchema;
 import org.apache.iceberg.encryption.EncryptionManager;
+import org.apache.iceberg.encryption.PlaintextEncryptionManager;
 import org.apache.iceberg.io.FileIO;
 import org.apache.iceberg.io.LocationProvider;
 
@@ -298,7 +299,7 @@ final class FakeIcebergTable implements Table {
 
     @Override
     public EncryptionManager encryption() {
-        throw new UnsupportedOperationException();
+        return PlaintextEncryptionManager.instance();
     }
 
     @Override
diff --git 
a/fe/fe-connector/fe-connector-iceberg/src/test/java/org/apache/doris/connector/iceberg/IcebergConnectorConfTest.java
 
b/fe/fe-connector/fe-connector-iceberg/src/test/java/org/apache/doris/connector/iceberg/IcebergConnectorConfTest.java
index f166b94eaa0..2c8ddac01fe 100644
--- 
a/fe/fe-connector/fe-connector-iceberg/src/test/java/org/apache/doris/connector/iceberg/IcebergConnectorConfTest.java
+++ 
b/fe/fe-connector/fe-connector-iceberg/src/test/java/org/apache/doris/connector/iceberg/IcebergConnectorConfTest.java
@@ -27,8 +27,8 @@ import java.util.HashMap;
 import java.util.Map;
 
 /**
- * How this connector's two deployment-level settings are resolved: its own 
{@code iceberg.conf}
- * first, then the fe.conf key each used to live under.
+ * Deployment-level settings use {@code iceberg.conf}; existing shared 
settings also retain their
+ * legacy fe.conf fallback.
  *
  * <p>Asserted rather than trusted, because both failure directions are 
silent. Reading fe.conf first
  * would make an administrator's edit to iceberg.conf do nothing. Dropping the 
fe.conf fallback would
@@ -37,6 +37,16 @@ import java.util.Map;
  */
 public class IcebergConnectorConfTest {
 
+    @Test
+    public void encryptionReadIsDefaultOffAndUsesOnlyPluginConfiguration() {
+        Assertions.assertFalse(IcebergConf.encryptionReadEnabled(null));
+        
Assertions.assertFalse(IcebergConf.encryptionReadEnabled(context(Map.of(), 
Map.of())));
+        Assertions.assertFalse(IcebergConf.encryptionReadEnabled(
+                context(Map.of(), 
Map.of(IcebergConf.CONF_ENABLE_ENCRYPTION_READ, "true"))));
+        Assertions.assertTrue(IcebergConf.encryptionReadEnabled(
+                context(Map.of(IcebergConf.CONF_ENABLE_ENCRYPTION_READ, 
"true"), Map.of())));
+    }
+
     @Test
     public void driversDirPrefersThePluginConfThenFeConf() {
         Assertions.assertEquals("/from/plugin/conf", IcebergConf.driversDir(
diff --git 
a/fe/fe-connector/fe-connector-iceberg/src/test/java/org/apache/doris/connector/iceberg/IcebergEncryptionReadTest.java
 
b/fe/fe-connector/fe-connector-iceberg/src/test/java/org/apache/doris/connector/iceberg/IcebergEncryptionReadTest.java
new file mode 100644
index 00000000000..32a0afe94a8
--- /dev/null
+++ 
b/fe/fe-connector/fe-connector-iceberg/src/test/java/org/apache/doris/connector/iceberg/IcebergEncryptionReadTest.java
@@ -0,0 +1,297 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements.  See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership.  The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License.  You may obtain a copy of the License at
+//
+//   http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied.  See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+package org.apache.doris.connector.iceberg;
+
+import org.apache.doris.connector.spi.ConnectorContext;
+import org.apache.doris.connector.spi.ConnectorSession;
+import org.apache.doris.connector.spi.ConnectorStatementScope;
+import org.apache.doris.connector.spi.scan.ConnectorScanRequest;
+
+import com.fasterxml.jackson.databind.node.ObjectNode;
+import org.apache.iceberg.BaseTable;
+import org.apache.iceberg.DataFiles;
+import org.apache.iceberg.PartitionSpec;
+import org.apache.iceberg.Schema;
+import org.apache.iceberg.StaticTableOperations;
+import org.apache.iceberg.Table;
+import org.apache.iceberg.TableMetadata;
+import org.apache.iceberg.TableMetadataParser;
+import org.apache.iceberg.TableProperties;
+import org.apache.iceberg.catalog.Namespace;
+import org.apache.iceberg.catalog.TableIdentifier;
+import org.apache.iceberg.encryption.EncryptedInputFile;
+import org.apache.iceberg.encryption.EncryptedOutputFile;
+import org.apache.iceberg.encryption.EncryptionManager;
+import org.apache.iceberg.inmemory.InMemoryCatalog;
+import org.apache.iceberg.io.FileIO;
+import org.apache.iceberg.io.InputFile;
+import org.apache.iceberg.io.OutputFile;
+import org.apache.iceberg.types.Types;
+import org.apache.iceberg.util.JsonUtil;
+import org.junit.jupiter.api.Assertions;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.function.Executable;
+
+import java.util.List;
+import java.util.Map;
+import java.util.Optional;
+
+/** Real SDK tables exercise scan, metadata and COUNT entry points without 
reading data files. */
+public class IcebergEncryptionReadTest {
+    private static final Schema SCHEMA = new 
Schema(Types.NestedField.required(1, "id", Types.LongType.get()));
+    private static final IcebergTableHandle HANDLE = new 
IcebergTableHandle("db", "tbl");
+
+    @Test
+    public void encryptedReadsAreDisabledAcrossPlanningRoutes() throws 
Exception {
+        for (boolean populated : new boolean[] {false, true}) {
+            for (boolean cached : new boolean[] {false, true}) {
+                IcebergTableCache cache = new IcebergTableCache(100, 10);
+                try (InMemoryCatalog catalog = catalog()) {
+                    Table table = encryptedView(table(catalog, populated), 3);
+                    RecordingIcebergCatalogOps ops = ops(table);
+                    IcebergScanPlanProvider provider = new 
IcebergScanPlanProvider(properties("hms"),
+                            ignored -> ops, context(Map.of()), null, cached ? 
cache : null);
+                    for (ConnectorStatementScope scope : 
List.of(ConnectorStatementScope.NONE,
+                            new TestStatementScope())) {
+                        ConnectorSession session = new 
IcebergStatementScopeTest.ScopeSession(1L, "query", scope);
+                        try {
+                            reject("READ_DISABLED", () -> 
provider.planScan(session, request(HANDLE, false)));
+                            reject("READ_DISABLED", () -> 
provider.planScan(session, request(HANDLE, true)));
+                            reject("READ_DISABLED", () -> 
provider.canServeMetadataOnlyCount(
+                                    session, HANDLE, Optional.empty()));
+                            reject("READ_DISABLED", () -> 
provider.streamingSplitEstimate(
+                                    session, HANDLE, Optional.empty(), false));
+                            reject("READ_DISABLED", () -> 
provider.streamSplits(session, request(HANDLE, false)));
+                            for (String name : List.of("files", "snapshots", 
"position_deletes", "labels")) {
+                                IcebergTableHandle sys = 
IcebergTableHandle.forSystemTable(
+                                        "db", "tbl", name, -1L, null, -1L);
+                                reject("READ_DISABLED", () -> 
provider.planScan(session, request(sys, false)));
+                            }
+                        } finally {
+                            scope.closeAll();
+                        }
+                    }
+                } finally {
+                    cache.close();
+                }
+            }
+        }
+    }
+
+    @Test
+    public void 
enablingReadStillRequiresSupportedCatalogAndBackendCapability() throws 
Exception {
+        try (InMemoryCatalog catalog = catalog()) {
+            Table table = encryptedView(table(catalog, true), 3);
+            for (String flavor : List.of("hms", "rest", "hadoop", "jdbc", 
"glue", "dlf", "s3tables")) {
+                IcebergScanPlanProvider provider = new 
IcebergScanPlanProvider(properties(flavor),
+                        ops(table), context(Map.of("enable_encryption_read", 
"true")));
+                reject(flavor.equals("hms") ? "BACKEND_CAPABILITY_UNAVAILABLE" 
: "UNSUPPORTED_CATALOG",
+                        () -> provider.planScan(null, request(HANDLE, true)));
+            }
+        }
+    }
+
+    @Test
+    public void unsupportedTableVersionHasAnExplicitError() throws Exception {
+        try (InMemoryCatalog catalog = catalog()) {
+            Table table = encryptedView(table(catalog, false), 2);
+            IcebergScanPlanProvider provider = new 
IcebergScanPlanProvider(properties("hms"),
+                    ops(table), context(Map.of("enable_encryption_read", 
"true")));
+            reject("UNSUPPORTED_TABLE_FORMAT", () -> provider.planScan(null, 
request(HANDLE, false)));
+        }
+    }
+
+    @Test
+    public void metadataBindingCannotBypassReadChecks() throws Exception {
+        try (InMemoryCatalog catalog = catalog()) {
+            Table table = encryptedView(table(catalog, true), 3);
+            IcebergConnectorMetadata metadata = new IcebergConnectorMetadata(
+                    ops(table), properties("hms"), context(Map.of()));
+            reject("READ_DISABLED", () -> metadata.getTableSchema(null, 
HANDLE));
+            reject("READ_DISABLED", () -> metadata.beginQuerySnapshot(null, 
HANDLE));
+            IcebergTableHandle sys = IcebergTableHandle.forSystemTable("db", 
"tbl", "files", -1L, null, -1L);
+            reject("READ_DISABLED", () -> metadata.getTableSchema(null, sys));
+        }
+    }
+
+    @Test
+    public void 
retainedEncryptionKeysAndEmptySnapshotPinsCannotBypassReadChecks() throws 
Exception {
+        try (InMemoryCatalog catalog = catalog()) {
+            Table table = table(catalog, false);
+            ObjectNode json = (ObjectNode) JsonUtil.mapper().readTree(
+                    TableMetadataParser.toJson(((BaseTable) 
table).operations().current()));
+            ObjectNode key = json.putArray("encryption-keys").addObject();
+            key.put("key-id", "test-key");
+            key.put("encrypted-key-metadata", "AA==");
+            Table retained = new BaseTable(new StaticTableOperations(
+                    TableMetadataParser.fromJson(json.toString()), 
table.io()), "db.tbl");
+            IcebergScanPlanProvider provider = new 
IcebergScanPlanProvider(properties("hms"), ops(retained));
+            IcebergTableHandle empty = HANDLE.withSnapshot(-1L, null, 0L);
+            reject("READ_DISABLED", () -> provider.planScan(null, 
request(empty, false)));
+            reject("READ_DISABLED", () -> provider.streamSplits(null, 
request(empty, false)));
+            reject("READ_DISABLED", () -> provider.planScan(null, 
request(HANDLE, true)));
+        }
+    }
+
+    @Test
+    public void customEncryptionManagerIsDetectedWithoutTableProperties() 
throws Exception {
+        try (InMemoryCatalog catalog = catalog()) {
+            Table table = table(catalog, false);
+            StaticTableOperations operations = new StaticTableOperations(
+                    ((BaseTable) table).operations().current(), new 
NoReadFileIO()) {
+                @Override
+                public EncryptionManager encryption() {
+                    return new EncryptionManager() {
+                        @Override
+                        public InputFile decrypt(EncryptedInputFile input) {
+                            throw new AssertionError("Admission must not 
decrypt files");
+                        }
+
+                        @Override
+                        public EncryptedOutputFile encrypt(OutputFile output) {
+                            throw new AssertionError("Read admission must not 
encrypt files");
+                        }
+                    };
+                }
+            };
+            Table custom = new BaseTable(operations, "db.tbl");
+            reject("READ_DISABLED", () -> new 
IcebergScanPlanProvider(properties("hms"), ops(custom))
+                    .planScan(null, request(HANDLE, false)));
+            reject("UNSUPPORTED_ENCRYPTION_MANAGER", () -> new 
IcebergScanPlanProvider(properties("hms"),
+                    ops(custom), context(Map.of("enable_encryption_read", 
"true")))
+                    .planScan(null, request(HANDLE, false)));
+        }
+    }
+
+    @Test
+    public void unverifiedBackendRejectsBeforeKmsManagerInitialization() 
throws Exception {
+        try (InMemoryCatalog catalog = catalog()) {
+            BaseTable encrypted = (BaseTable) encryptedView(table(catalog, 
false), 3);
+            Table table = new BaseTable(new StaticTableOperations(
+                    encrypted.operations().current(), new NoReadFileIO()) {
+                @Override
+                public EncryptionManager encryption() {
+                    throw new AssertionError("Unverified execution must not 
initialize a KMS client");
+                }
+            }, "db.tbl");
+            IcebergScanPlanProvider provider = new 
IcebergScanPlanProvider(properties("hms"), ops(table),
+                    context(Map.of("enable_encryption_read", "true")));
+            reject("BACKEND_CAPABILITY_UNAVAILABLE", () -> 
provider.planScan(null, request(HANDLE, false)));
+        }
+    }
+
+    @Test
+    public void plaintextScansAndCountsKeepWorkingWithEitherSwitchValue() 
throws Exception {
+        try (InMemoryCatalog catalog = catalog()) {
+            Table table = table(catalog, true);
+            for (String enabled : List.of("false", "true")) {
+                IcebergScanPlanProvider provider = new 
IcebergScanPlanProvider(properties("hadoop"),
+                        ops(table), context(Map.of("enable_encryption_read", 
enabled)));
+                Assertions.assertEquals(1, provider.planScan(null, 
request(HANDLE, false)).size());
+                Assertions.assertEquals(10L, provider.planScan(null, 
request(HANDLE, true))
+                        .get(0).getPushDownRowCount());
+                Assertions.assertTrue(provider.canServeMetadataOnlyCount(null, 
HANDLE, Optional.empty()));
+            }
+        }
+    }
+
+    private static InMemoryCatalog catalog() {
+        InMemoryCatalog catalog = new InMemoryCatalog();
+        catalog.initialize("test", Map.of());
+        catalog.createNamespace(Namespace.of("db"));
+        return catalog;
+    }
+
+    private static Table table(InMemoryCatalog catalog, boolean populated) {
+        Table table = catalog.createTable(TableIdentifier.of("db", "tbl"), 
SCHEMA, PartitionSpec.unpartitioned(),
+                null, Map.of("format-version", "3"));
+        if (populated) {
+            
table.newAppend().appendFile(DataFiles.builder(table.spec()).withPath("memory:///data.parquet")
+                    
.withFormat("parquet").withFileSizeInBytes(128).withRecordCount(10).build()).commit();
+        }
+        return table;
+    }
+
+    private static Table encryptedView(Table table, int version) throws 
Exception {
+        // Metadata-only fixtures must not need a KMS or a usable key to 
exercise admission control.
+        TableMetadata metadata = ((BaseTable) table).operations().current();
+        ObjectNode json = (ObjectNode) 
JsonUtil.mapper().readTree(TableMetadataParser.toJson(metadata));
+        json.put("format-version", version);
+        json.with("properties").put(TableProperties.ENCRYPTION_TABLE_KEY, 
"test-key");
+        metadata = TableMetadataParser.fromJson(json.toString());
+        return new BaseTable(new StaticTableOperations(metadata, new 
NoReadFileIO()), "db.tbl");
+    }
+
+    private static RecordingIcebergCatalogOps ops(Table table) {
+        RecordingIcebergCatalogOps ops = new RecordingIcebergCatalogOps();
+        ops.table = table;
+        ops.tableExists = true;
+        return ops;
+    }
+
+    private static IcebergCatalogProperties properties(String flavor) {
+        return IcebergCatalogProperties.of(Map.of("iceberg.catalog.type", 
flavor));
+    }
+
+    private static ConnectorContext context(Map<String, String> config) {
+        return new ConnectorContext() {
+            @Override
+            public String getCatalogName() {
+                return "test";
+            }
+
+            @Override
+            public long getCatalogId() {
+                return 1L;
+            }
+
+            @Override
+            public Map<String, String> getConnectorConfig() {
+                return config;
+            }
+        };
+    }
+
+    private static ConnectorScanRequest request(IcebergTableHandle handle, 
boolean count) {
+        return ConnectorScanRequest.builder(handle, 
List.of()).countPushdown(count).build();
+    }
+
+    private static void reject(String classification, Executable action) {
+        RuntimeException failure = 
Assertions.assertThrows(RuntimeException.class, action);
+        Assertions.assertTrue(failure.getMessage().contains("Iceberg 
encryption [" + classification + "]"),
+                failure::getMessage);
+        Assertions.assertFalse(failure.getMessage().contains("test-key"), "key 
identifiers must not be disclosed");
+    }
+
+    private static final class NoReadFileIO implements FileIO {
+        @Override
+        public InputFile newInputFile(String path) {
+            throw new AssertionError("Admission must precede manifest and data 
I/O");
+        }
+
+        @Override
+        public OutputFile newOutputFile(String path) {
+            throw new AssertionError("Read admission must not write files");
+        }
+
+        @Override
+        public void deleteFile(String path) {
+            throw new AssertionError("Read admission must not delete files");
+        }
+    }
+}
diff --git 
a/regression-test/data/external_table_p0/iceberg/test_iceberg_encryption_read_guard.out
 
b/regression-test/data/external_table_p0/iceberg/test_iceberg_encryption_read_guard.out
new file mode 100644
index 00000000000..4acedd38509
--- /dev/null
+++ 
b/regression-test/data/external_table_p0/iceberg/test_iceberg_encryption_read_guard.out
@@ -0,0 +1,44 @@
+-- This file is automatically generated. You should know what you did if you 
want to edit this
+-- !before_declaration --
+1
+2
+3
+
+-- !plain_rows --
+1
+2
+3
+
+-- !plain_empty --
+0
+
+-- !plain_count --
+3
+
+-- !plain_count --
+3
+
+-- !plain_after_rejections --
+1
+2
+3
+
+-- !plain_rows --
+1
+2
+3
+
+-- !plain_empty --
+0
+
+-- !plain_count --
+3
+
+-- !plain_count --
+3
+
+-- !plain_after_rejections --
+1
+2
+3
+
diff --git 
a/regression-test/suites/external_table_p0/iceberg/test_iceberg_encryption_read_guard.groovy
 
b/regression-test/suites/external_table_p0/iceberg/test_iceberg_encryption_read_guard.groovy
new file mode 100644
index 00000000000..dbd07e0e81d
--- /dev/null
+++ 
b/regression-test/suites/external_table_p0/iceberg/test_iceberg_encryption_read_guard.groovy
@@ -0,0 +1,141 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements.  See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership.  The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License.  You may obtain a copy of the License at
+//
+//   http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied.  See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+import groovy.json.JsonOutput
+import groovy.json.JsonSlurper
+
+suite("test_iceberg_encryption_read_guard", "p0,external") {
+    if 
(!"true".equalsIgnoreCase(context.config.otherConfigs.get("enableIcebergTest")))
 {
+        return
+    }
+    String host = context.config.otherConfigs.get("externalEnvIp")
+    String restPort = context.config.otherConfigs.get("iceberg_rest_uri_port")
+    String minioPort = context.config.otherConfigs.get("iceberg_minio_port")
+    String uri = "http://${host}:${restPort}";
+    String db = "iceberg_encryption_read_guard_db"
+
+    def restRequest = { String method, String path, Map body, List<Integer> 
statuses ->
+        def connection = (HttpURLConnection) new 
URL("${uri}${path}").openConnection()
+        connection.setRequestMethod(method)
+        connection.setConnectTimeout(10000)
+        connection.setReadTimeout(30000)
+        try {
+            if (body != null) {
+                connection.setRequestProperty("Content-Type", 
"application/json")
+                connection.setDoOutput(true)
+                connection.outputStream.withCloseable {
+                    it.write(JsonOutput.toJson(body).getBytes("UTF-8"))
+                }
+            }
+            int status = connection.responseCode
+            assertTrue(statuses.contains(status), "Iceberg fixture ${method} 
${path} returned ${status}")
+            // Released REST fixtures may return 200 with no body for DELETE 
instead of 204.
+            if (status == 200 && method != "DELETE") {
+                return connection.inputStream.withCloseable { new 
JsonSlurper().parse(it) }
+            }
+            return null
+        } finally {
+            connection.disconnect()
+        }
+    }
+
+    def createCatalog = { String name, String cacheEnabled ->
+        sql "DROP CATALOG IF EXISTS ${name}"
+        sql """CREATE CATALOG ${name} PROPERTIES (
+            'type' = 'iceberg', 'iceberg.catalog.type' = 'rest', 'uri' = 
'${uri}',
+            's3.access_key' = 'admin', 's3.secret_key' = 'password',
+            's3.endpoint' = 'http://${host}:${minioPort}', 's3.region' = 
'us-east-1',
+            'use_path_style' = 'true', 'meta.cache.iceberg.table.enable' = 
'${cacheEnabled}',
+            'enable_encryption_read' = 'true'
+        )"""
+    }
+
+    // Admission is determined by SDK metadata, before file decryption. Keep 
the files plaintext so
+    // this regression needs no KMS and cannot mistake a late decryption error 
for the admission error.
+    createCatalog("encryption_guard_seed", "false")
+    sql "CREATE DATABASE IF NOT EXISTS encryption_guard_seed.${db}"
+    for (String table : ["plain_data", "plain_empty", "declared_data", 
"declared_empty"]) {
+        // Remove previous declarations through the fixture, since Doris 
correctly refuses to bind them.
+        // Only this suite's tables are touched; keep all tables after the run 
for failure inspection.
+        restRequest("DELETE", "/v1/namespaces/${db}/tables/${table}", null, 
[200, 204, 404])
+    }
+    sql """CREATE TABLE encryption_guard_seed.${db}.plain_data (id INT)
+        PROPERTIES ('format-version' = '3')"""
+    sql """CREATE TABLE encryption_guard_seed.${db}.plain_empty (id INT)
+        PROPERTIES ('format-version' = '3')"""
+    sql """CREATE TABLE encryption_guard_seed.${db}.declared_data (id INT)
+        PROPERTIES ('format-version' = '3')"""
+    sql """CREATE TABLE encryption_guard_seed.${db}.declared_empty (id INT)
+        PROPERTIES ('format-version' = '3')"""
+    sql "INSERT INTO encryption_guard_seed.${db}.plain_data VALUES (1), (2), 
(3)"
+    sql "INSERT INTO encryption_guard_seed.${db}.declared_data VALUES (1), 
(2), (3)"
+    order_qt_before_declaration "SELECT id FROM 
encryption_guard_seed.${db}.declared_data ORDER BY id"
+
+    for (String table : ["declared_data", "declared_empty"]) {
+        String path = "/v1/namespaces/${db}/tables/${table}"
+        def loaded = restRequest("GET", path, null, [200])
+        restRequest("POST", path, [
+            requirements: [[type: "assert-table-uuid", uuid: 
loaded.metadata["table-uuid"]]],
+            updates: [[action: "set-properties", updates: 
["encryption.key-id": "test-key"]]]
+        ], [200])
+        def declared = restRequest("GET", path, null, [200])
+        assertEquals("test-key", 
declared.metadata.properties["encryption.key-id"],
+                "The remote table must actually declare encryption")
+    }
+
+    try {
+        for (String cacheEnabled : ["false", "true"]) {
+            String catalog = "encryption_guard_cache_${cacheEnabled}"
+            // New catalogs isolate read admission from the pre-declaration 
binding and its schema cache.
+            // The catalog property above must never override the plugin's 
deployment-level switch.
+            createCatalog(catalog, cacheEnabled)
+            order_qt_plain_rows "SELECT id FROM ${catalog}.${db}.plain_data 
ORDER BY id"
+            qt_plain_empty "SELECT COUNT(*) FROM ${catalog}.${db}.plain_empty"
+            for (String countPushdown : ["true", "false"]) {
+                sql "SET 
enable_count_push_down_for_external_table=${countPushdown}"
+                qt_plain_count "SELECT COUNT(*) FROM 
${catalog}.${db}.plain_data"
+                // Repeated failures exercise a warmed table cache as well as 
the initial acquisition.
+                for (int attempt = 0; attempt < 2; attempt++) {
+                    for (String table : ["declared_data", "declared_empty"]) {
+                        // Assert the default-off deployment contract even 
when the catalog requests encryption.
+                        test {
+                            sql "SELECT id FROM ${catalog}.${db}.${table} 
ORDER BY id"
+                            exception "Iceberg encryption [READ_DISABLED]"
+                        }
+                        test {
+                            sql "SELECT COUNT(*) FROM 
${catalog}.${db}.${table}"
+                            exception "Iceberg encryption [READ_DISABLED]"
+                        }
+                        test {
+                            sql "DESC ${catalog}.${db}.${table}"
+                            exception "Iceberg encryption [READ_DISABLED]"
+                        }
+                        for (String systemTable : ["files", "snapshots", 
"position_deletes"]) {
+                            test {
+                                sql "SELECT COUNT(*) FROM 
${catalog}.${db}.`${table}\$${systemTable}`"
+                                exception "Iceberg encryption [READ_DISABLED]"
+                            }
+                        }
+                    }
+                }
+            }
+            order_qt_plain_after_rejections "SELECT id FROM 
${catalog}.${db}.plain_data ORDER BY id"
+        }
+    } finally {
+        sql "UNSET VARIABLE enable_count_push_down_for_external_table"
+    }
+}


---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]

Reply via email to