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

roryqi pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/gravitino.git


The following commit(s) were added to refs/heads/main by this push:
     new ceefa9244d [#14594] feat(iceberg-rest): federate remote IRCs over a 
service account connection with Gravitino-issued credentials (#14595)
ceefa9244d is described below

commit ceefa9244dff5523246905457ebc61a6c09e08a6
Author: Mark Hoerth <[email protected]>
AuthorDate: Wed Nov 4 01:30:52 2026 -0800

    [#14594] feat(iceberg-rest): federate remote IRCs over a service account 
connection with Gravitino-issued credentials (#14595)
    
    ### What changes were proposed in this pull request?
    
    Five commits, readable one at a time:
    
    1. **Service account connection** (`iceberg-common`,
    `iceberg-rest-server`, `catalog-lakehouse-iceberg`). New catalog
    properties: `rest-backend-auth-type` (`user-token` by default, or
    `service-account`), `rest-backend-token`, `rest-backend-credential`,
    `rest-backend-oauth2-server-uri` and `rest-backend-scope`.
    - In service-account mode,
    `IcebergCatalogUtil.applyRestBackendAuthProperties` passes exactly one
    of the token or the client credential to Iceberg's OAuth2 auth manager,
    instead of installing `UserPrincipalForwardingAuthManager`.
       - Token and credential are hidden catalog properties.
    - The Gravitino API catalog and the Iceberg REST server both build the
    backend through this code, so both connect as the service account.
    - `IcebergCatalogWrapper.remoteCatalogAuthorizesUsers()` replaces
    `isRESTCatalog()` in
    `IcebergMetadataAuthorizationMethodInterceptor.shouldSkipAuthorization`.
    A service-account catalog is therefore always authorized locally,
    whatever `disable-rest-authz` is set to.
    - `IcebergCatalogWrapperManager` refuses to serve a service-account
    catalog when authorization is disabled.
    2. **Gravitino-issued storage credentials** (`iceberg-rest-server`). New
    property `rest-backend-credential-vending` (`remote` by default, or
    `gravitino`). A service-account catalog always uses `gravitino`, because
    the remote vends credentials for the service account rather than for the
    caller. In `gravitino` mode, `FederatedCatalogWrapper`:
       - never sends `X-Iceberg-Access-Delegation` to the remote;
    - drops remote `storage-credentials` and credential keys from load and
    scan responses;
    - issues credentials through
    `CatalogWrapperForREST.injectCredentialConfig` and
    `injectScanCredentials` for load, create, register, credential refresh
    and scan planning.
    
    Privilege follows
    `IcebergTableOperationExecutor.getCredentialPrivilege`: READ unless the
    caller holds MODIFY_TABLE. Create and register get WRITE, as for local
    catalogs.
    3. **Grants follow the table, not the name** (`api`, `core`,
    `catalog-lakehouse-iceberg`, `iceberg-rest-server`). New reserved table
    property `Table.PROPERTY_TABLE_UUID` (`table-uuid`), which the Iceberg
    catalog reports from table metadata. It is honored only in catalogs that
    declare it reserved.
    - `TableOperationDispatcher` records it on import and create, keeps it
    on alter and rename, and backfills it on registrations made before this
    change.
    - When a load finds a different identifier under the same name, it
    deletes the stale registration (with its grants, tags, owner and
    statistics) under the schema write lock and imports the new table.
    - On the Iceberg REST server, `IcebergTableHookDispatcher` makes the
    same check for load, credentials and scan planning. On a mismatch it
    replaces the registration, authorizes the caller again against the new
    registration, and reloads, so neither access nor credential scope
    follows the old table.
       - A rename keeps the UUID.
    4. **Purges go to the remote** (`iceberg-common`,
    `iceberg-rest-server`). `IcebergCatalogWrapper.purgeTable` forwards a
    purge on a REST catalog backend to the remote with `purgeRequested`, on
    both the Iceberg REST and Gravitino API paths. Before, it dropped the
    remote entry and then deleted the files from Gravitino, and an
    `X-Gravitino-Async-Purge` request queued a local cleanup job to do the
    same. A REST catalog backend never takes the async path now.
    
    5. **The authorizer learns of a replacement at once** (`core`). When the
    stale registration of a replaced table is deleted,
    `TableOperationDispatcher` notifies the authorizer, as a drop does.
    Without it, the authorizer's name-to-id cache still resolved the name to
    the old table, so the old grants applied to the request that discovered
    the replacement.
    
    ### Why are the changes needed?
    
    To federate remote Iceberg REST catalogs that cannot authorize
    Gravitino's users, with Gravitino as the single place access is granted,
    without handing clients the remote catalog's broad storage keys, without
    grants leaking to a table recreated under an old name, and without
    Gravitino deleting files the remote catalog owns.
    
    Fix: #14594
    
    ### Does this PR introduce _any_ user-facing change?
    
    - New Iceberg catalog / Iceberg REST service properties:
    `rest-backend-auth-type`, `rest-backend-token`,
    `rest-backend-credential`, `rest-backend-oauth2-server-uri`,
    `rest-backend-scope`, `rest-backend-credential-vending`. Defaults keep
    today's behavior.
    - New reserved Iceberg table property `table-uuid`, reported on load and
    rejected on create and alter.
    - Docs: `iceberg-rest-service.md` (REST backend section) and
    `lakehouse-iceberg-catalog.md`.
    
    ### How was this patch tested?
    
    Unit tests:
    - `TestIcebergCatalogUtil`: auth modes, the exactly-one-credential
    check, an invalid mode.
    - `TestIcebergMetadataAuthorizationMethodInterceptor`: a service-account
    catalog is not skipped.
    - `TestCatalogWrapperForREST`: gravitino mode, and service-account mode
    with default vending, send no delegation header, drop remote credentials
    and return provider-issued credentials.
    - `TestIcebergTableHookDispatcher`: a replaced registration is
    reconciled; a registration without a UUID is left alone.
    - `TestIcebergCatalogWrapper`: a purge on a REST catalog is forwarded
    with `purgeRequested` and loads nothing locally.
    - `TestIcebergAsyncPurge`: an async purge request on a REST catalog is
    forwarded inline and queues no job.
    - `TestTableOperationDispatcher`: the registration survives a reload, a
    replaced table gets a new registration, a missing UUID is backfilled,
    plus `isReplacedExternally` cases.
    
    End to end, against a live Cloudflare R2 Data Catalog federated with a
    service account connection and an R2 temporary-credential provider: a
    user granted SELECT_TABLE on one table received a credential that could
    list that table's location and nothing else, and could not write; a user
    with write privileges could write only inside the table's location;
    creating a table without CREATE_TABLE and loading as a non-member both
    returned 403; after the table was dropped and recreated directly in the
    remote catalog, the user granted SELECT on the old table got 403 on the
    first load of the new one.
    
    ### Known limitations, left for follow-up
    
    - **Multi-node edge case.** The stale-registration check reads the table
    entity through the entity cache. In a multi-node deployment, a node with
    a stale cache entry could, in a narrow window, drop a registration
    another node has just replaced. Deleting conditioned on the entity
    version would close it.
    - **Token fetch per call.** With an OAuth2 client credential (not a
    static token), each remote call through `callRemoteCatalog` fetches a
    fresh token. A per-wrapper auth session cache would remove that.
    - **Copied properties now rejected.** `table-uuid` is reserved, so flows
    that copy a loaded table's properties into a create, such as `CREATE
    TABLE LIKE`, must drop it. `sort-order` already behaves this way.
    
    🤖 Generated with [Claude Code](https://claude.com/claude-code)
    
    https://claude.ai/code/session_01Eu6fb6yGcAF4XcGDTmKE56
    
    ---------
    
    Co-authored-by: Claude Opus 5.5 <[email protected]>
    Co-authored-by: roryqi <[email protected]>
---
 .../lakehouse/iceberg/IcebergConstants.java        |  56 ++++++
 .../lakehouse/iceberg/IcebergPropertiesUtils.java  |  13 ++
 .../iceberg/IcebergCatalogPropertiesMetadata.java  |  42 ++++
 docs/iceberg-rest-service.md                       |  36 ++++
 docs/lakehouse-iceberg-catalog.md                  |   6 +
 .../gravitino/iceberg/common/IcebergConfig.java    |  57 ++++++
 .../iceberg/common/ops/IcebergCatalogWrapper.java  |  13 +-
 .../iceberg/common/utils/IcebergCatalogUtil.java   |  56 +++++-
 .../iceberg/common/TestIcebergConfig.java          |  36 ++++
 .../common/utils/TestIcebergCatalogUtil.java       |  86 +++++++++
 .../iceberg/service/CatalogWrapperForREST.java     |  34 +++-
 .../iceberg/service/FederatedCatalogWrapper.java   | 185 ++++++++++++++++--
 .../service/IcebergCatalogWrapperManager.java      |   9 +-
 .../dispatcher/IcebergTableOperationExecutor.java  |   6 +-
 ...bergMetadataAuthorizationMethodInterceptor.java |   9 +-
 .../iceberg/service/TestCatalogWrapperForREST.java | 214 +++++++++++++++++++++
 .../TestIcebergCatalogWrapperManagerForREST.java   |  62 ++++++
 .../service/dispatcher/TestIcebergAsyncPurge.java  |  17 ++
 ...bergMetadataAuthorizationMethodInterceptor.java |  28 ++-
 19 files changed, 935 insertions(+), 30 deletions(-)

diff --git 
a/catalogs/catalog-common/src/main/java/org/apache/gravitino/catalog/lakehouse/iceberg/IcebergConstants.java
 
b/catalogs/catalog-common/src/main/java/org/apache/gravitino/catalog/lakehouse/iceberg/IcebergConstants.java
index 8c0e1bb97f..7b528d5478 100644
--- 
a/catalogs/catalog-common/src/main/java/org/apache/gravitino/catalog/lakehouse/iceberg/IcebergConstants.java
+++ 
b/catalogs/catalog-common/src/main/java/org/apache/gravitino/catalog/lakehouse/iceberg/IcebergConstants.java
@@ -137,6 +137,62 @@ public class IcebergConstants {
   public static final String ICEBERG_REST_DEFAULT_CATALOG = "default_catalog";
   public static final String ICEBERG_REST_DEFAULT_DYNAMIC_CATALOG_NAME = 
"default-catalog-name";
   public static final String ICEBERG_REST_DISABLE_REST_AUTHZ = 
"disable-rest-authz";
+
+  /**
+   * Gravitino config key selecting how a REST catalog backend authenticates 
to the remote catalog:
+   * {@link #REST_BACKEND_AUTH_TYPE_USER_TOKEN} or {@link 
#REST_BACKEND_AUTH_TYPE_SERVICE_ACCOUNT}.
+   */
+  public static final String REST_BACKEND_AUTH_TYPE = "rest-backend-auth-type";
+
+  /** Forward the calling user's access token to the remote catalog, which 
authorizes the user. */
+  public static final String REST_BACKEND_AUTH_TYPE_USER_TOKEN = "user-token";
+
+  /**
+   * Connect to the remote catalog as a service account with a configured 
credential. Gravitino
+   * authorizes every request locally before proxying it.
+   */
+  public static final String REST_BACKEND_AUTH_TYPE_SERVICE_ACCOUNT = 
"service-account";
+
+  /** Gravitino config key for the service account's static bearer token. */
+  public static final String REST_BACKEND_TOKEN = "rest-backend-token";
+
+  /** Gravitino config key for the service account's OAuth2 client credential. 
*/
+  public static final String REST_BACKEND_CREDENTIAL = 
"rest-backend-credential";
+
+  /** Gravitino config key for the OAuth2 token endpoint used with the client 
credential. */
+  public static final String REST_BACKEND_OAUTH2_SERVER_URI = 
"rest-backend-oauth2-server-uri";
+
+  /** Gravitino config key for the OAuth2 scope requested with the client 
credential. */
+  public static final String REST_BACKEND_SCOPE = "rest-backend-scope";
+
+  /**
+   * Gravitino config key selecting who issues storage credentials for tables 
of a federated REST
+   * catalog: {@link #REST_BACKEND_STORAGE_CREDENTIAL_SOURCE_REMOTE} or {@link
+   * #REST_BACKEND_STORAGE_CREDENTIAL_SOURCE_LOCAL}.
+   */
+  public static final String REST_BACKEND_STORAGE_CREDENTIAL_SOURCE =
+      "rest-backend-storage-credential-source";
+
+  /** Pass through the storage credentials the remote catalog vends. */
+  public static final String REST_BACKEND_STORAGE_CREDENTIAL_SOURCE_REMOTE = 
"remote";
+
+  /**
+   * Do not request credentials from the remote catalog; issue them through 
this catalog's own
+   * credential providers, scoped to the table and to the caller's privilege.
+   */
+  public static final String REST_BACKEND_STORAGE_CREDENTIAL_SOURCE_LOCAL = 
"local";
+
+  /** Iceberg REST client property for a static bearer token. */
+  public static final String ICEBERG_REST_TOKEN = "token";
+
+  /** Iceberg REST client property for an OAuth2 client credential. */
+  public static final String ICEBERG_REST_CREDENTIAL = "credential";
+
+  /** Iceberg REST client property for the OAuth2 token endpoint. */
+  public static final String ICEBERG_REST_OAUTH2_SERVER_URI = 
"oauth2-server-uri";
+
+  /** Iceberg REST client property for the OAuth2 scope. */
+  public static final String ICEBERG_REST_SCOPE = "scope";
   /** Gravitino config key for REST catalog backend HTTP connection timeout. */
   public static final String REST_CATALOG_BACKEND_CLIENT_CONNECTION_TIMEOUT_MS 
=
       "rest-client-connection-timeout-ms";
diff --git 
a/catalogs/catalog-common/src/main/java/org/apache/gravitino/catalog/lakehouse/iceberg/IcebergPropertiesUtils.java
 
b/catalogs/catalog-common/src/main/java/org/apache/gravitino/catalog/lakehouse/iceberg/IcebergPropertiesUtils.java
index 4f7ae7bd7e..af0b6d4a51 100644
--- 
a/catalogs/catalog-common/src/main/java/org/apache/gravitino/catalog/lakehouse/iceberg/IcebergPropertiesUtils.java
+++ 
b/catalogs/catalog-common/src/main/java/org/apache/gravitino/catalog/lakehouse/iceberg/IcebergPropertiesUtils.java
@@ -111,6 +111,19 @@ public class IcebergPropertiesUtils {
     map.put(
         IcebergConstants.REST_CATALOG_BACKEND_CLIENT_SOCKET_TIMEOUT_MS,
         IcebergConstants.ICEBERG_REST_CLIENT_SOCKET_TIMEOUT_MS);
+    // The service account settings keep their Gravitino names here and are 
translated to Iceberg
+    // REST client properties only when the REST backend is built, so no other 
component (such as
+    // an engine connector converting catalog properties) receives the remote 
catalog's credential.
+    map.put(IcebergConstants.REST_BACKEND_AUTH_TYPE, 
IcebergConstants.REST_BACKEND_AUTH_TYPE);
+    map.put(IcebergConstants.REST_BACKEND_TOKEN, 
IcebergConstants.REST_BACKEND_TOKEN);
+    map.put(IcebergConstants.REST_BACKEND_CREDENTIAL, 
IcebergConstants.REST_BACKEND_CREDENTIAL);
+    map.put(
+        IcebergConstants.REST_BACKEND_OAUTH2_SERVER_URI,
+        IcebergConstants.REST_BACKEND_OAUTH2_SERVER_URI);
+    map.put(IcebergConstants.REST_BACKEND_SCOPE, 
IcebergConstants.REST_BACKEND_SCOPE);
+    map.put(
+        IcebergConstants.REST_BACKEND_STORAGE_CREDENTIAL_SOURCE,
+        IcebergConstants.REST_BACKEND_STORAGE_CREDENTIAL_SOURCE);
 
     GRAVITINO_CONFIG_TO_ICEBERG = Collections.unmodifiableMap(map);
 
diff --git 
a/catalogs/catalog-lakehouse-iceberg/src/main/java/org/apache/gravitino/catalog/lakehouse/iceberg/IcebergCatalogPropertiesMetadata.java
 
b/catalogs/catalog-lakehouse-iceberg/src/main/java/org/apache/gravitino/catalog/lakehouse/iceberg/IcebergCatalogPropertiesMetadata.java
index 407af67a33..fd27211797 100644
--- 
a/catalogs/catalog-lakehouse-iceberg/src/main/java/org/apache/gravitino/catalog/lakehouse/iceberg/IcebergCatalogPropertiesMetadata.java
+++ 
b/catalogs/catalog-lakehouse-iceberg/src/main/java/org/apache/gravitino/catalog/lakehouse/iceberg/IcebergCatalogPropertiesMetadata.java
@@ -132,6 +132,48 @@ public class IcebergCatalogPropertiesMetadata extends 
BaseCatalogPropertiesMetad
                 "HTTP socket timeout in milliseconds for the REST catalog 
backend",
                 false /* immutable */,
                 60000 /* defaultValue */,
+                false /* hidden */),
+            stringOptionalPropertyEntry(
+                IcebergConstants.REST_BACKEND_AUTH_TYPE,
+                "How the REST catalog backend authenticates to the remote 
catalog: user-token"
+                    + " forwards the calling user's token, service-account 
connects with a"
+                    + " configured credential and authorizes every request in 
Gravitino",
+                false /* immutable */,
+                IcebergConstants.REST_BACKEND_AUTH_TYPE_USER_TOKEN /* 
defaultValue */,
+                false /* hidden */),
+            stringOptionalPropertyEntry(
+                IcebergConstants.REST_BACKEND_TOKEN,
+                "Static bearer token the service account presents to the 
remote REST catalog",
+                false /* immutable */,
+                null /* defaultValue */,
+                true /* hidden */),
+            stringOptionalPropertyEntry(
+                IcebergConstants.REST_BACKEND_CREDENTIAL,
+                "OAuth2 client credential (client_id:client_secret) the 
service account uses"
+                    + " with the remote REST catalog",
+                false /* immutable */,
+                null /* defaultValue */,
+                true /* hidden */),
+            stringOptionalPropertyEntry(
+                IcebergConstants.REST_BACKEND_OAUTH2_SERVER_URI,
+                "OAuth2 token endpoint for the service account's client 
credential",
+                false /* immutable */,
+                null /* defaultValue */,
+                false /* hidden */),
+            stringOptionalPropertyEntry(
+                IcebergConstants.REST_BACKEND_SCOPE,
+                "OAuth2 scope requested with the service account's client 
credential",
+                false /* immutable */,
+                null /* defaultValue */,
+                false /* hidden */),
+            stringOptionalPropertyEntry(
+                IcebergConstants.REST_BACKEND_STORAGE_CREDENTIAL_SOURCE,
+                "Who issues storage credentials for a federated REST catalog: 
remote passes"
+                    + " through the remote catalog's credentials, local issues 
them through"
+                    + " this catalog's credential providers. Service-account 
connections always"
+                    + " use local credentials",
+                false /* immutable */,
+                IcebergConstants.REST_BACKEND_STORAGE_CREDENTIAL_SOURCE_REMOTE 
/* defaultValue */,
                 false /* hidden */));
     HashMap<String, PropertyEntry<?>> result = Maps.newHashMap();
     result.putAll(Maps.uniqueIndex(propertyEntries, PropertyEntry::getName));
diff --git a/docs/iceberg-rest-service.md b/docs/iceberg-rest-service.md
index 60584f2e9f..859dcd544b 100644
--- a/docs/iceberg-rest-service.md
+++ b/docs/iceberg-rest-service.md
@@ -260,6 +260,42 @@ IRC1 must also configure S3 configurations if the client 
side requests credentia
 If IRC2 does not enforce authorization, keeping 
`gravitino.iceberg-rest.disable-rest-authz=true` can leave operations 
unprotected. Set it to `false` to enforce authorization in IRC1.
 :::
 
+##### Service account connection
+
+By default IRC1 forwards each user's access token to IRC2, so IRC2 must trust 
the same identity provider and authorize every user itself. To federate a 
catalog that cannot do that, such as a managed catalog that accepts only its 
own API token, connect IRC1 to IRC2 as a service account. IRC1 then presents 
one configured credential on every request to IRC2 and authorizes each user 
against Gravitino's own grants before anything is proxied. A catalog connected 
as a service account always enf [...]
+
+| Configuration item                                    | Description          
                                                                                
                                               | Default value | Required       
                             |
+|-------------------------------------------------------|-----------------------------------------------------------------------------------------------------------------------------------------------------|---------------|---------------------------------------------|
+| `gravitino.iceberg-rest.rest-backend-auth-type`       | How IRC1 
authenticates to IRC2. `user-token` forwards the calling user's access token. 
`service-account` connects with the credential below.        | `user-token`  | 
No                                          |
+| `gravitino.iceberg-rest.rest-backend-token`           | Static bearer token 
the service account presents to IRC2.                                           
                                               | (none)        | One of token 
or credential with `service-account` |
+| `gravitino.iceberg-rest.rest-backend-credential`      | OAuth2 client 
credential (`client_id:client_secret`) the service account exchanges for a 
token.                                                    | (none)        | One 
of token or credential with `service-account` |
+| `gravitino.iceberg-rest.rest-backend-oauth2-server-uri` | OAuth2 token 
endpoint for the client credential. Defaults to IRC2's own token endpoint.      
                                                    | (none)        | No        
                                  |
+| `gravitino.iceberg-rest.rest-backend-scope`           | OAuth2 scope 
requested with the client credential.                                           
                                                      | (none)        | No      
                                    |
+
+##### Gravitino-issued storage credentials
+
+By default IRC1 passes through the storage credentials IRC2 vends. Some 
catalogs vend broad, long-lived credentials, for example the catalog token's 
own storage keys scoped to the whole bucket. Set 
`rest-backend-storage-credential-source` to `local` and IRC1 stops asking IRC2 
for credentials. It issues them through its own `credential-providers` instead, 
scoped to the table location, read-only unless the caller holds write 
privileges on the table, exactly as for a local catalog. Credenti [...]
+
+| Configuration item                                      | Description        
                                                                                
                                               | Default value | Required |
+|---------------------------------------------------------|---------------------------------------------------------------------------------------------------------------------------------------------------|---------------|----------|
+| `gravitino.iceberg-rest.rest-backend-storage-credential-source` | Who issues 
storage credentials for tables of the federated catalog. `remote` passes 
through IRC2's credentials. `local` uses IRC1's `credential-providers`. A 
`service-account` catalog always uses `local`, regardless of this setting. | 
`remote`      | No       |
+
+IRC1 configuration example federating a catalog that accepts only a static API 
token, with Gravitino issuing the storage credentials:
+
+```text
+gravitino.iceberg-rest.catalog-backend = rest
+gravitino.iceberg-rest.uri = https://catalog.example.com/v1
+gravitino.iceberg-rest.warehouse = example_warehouse
+gravitino.iceberg-rest.rest-backend-auth-type = service-account
+gravitino.iceberg-rest.rest-backend-token = xx
+gravitino.iceberg-rest.rest-backend-storage-credential-source = local
+gravitino.iceberg-rest.credential-providers = s3-token
+gravitino.iceberg-rest.s3-access-key-id = xx
+gravitino.iceberg-rest.s3-secret-access-key = xx
+gravitino.iceberg-rest.s3-region = xx
+gravitino.iceberg-rest.s3-role-arn = xx
+```
+
 `data-access` is returned in `/v1/config` defaults for REST clients:
 
 - `vended-credentials`: clients should request credential vending 
(`X-Iceberg-Access-Delegation: vended-credentials`).
diff --git a/docs/lakehouse-iceberg-catalog.md 
b/docs/lakehouse-iceberg-catalog.md
index 4a7c410b95..dfd5137633 100644
--- a/docs/lakehouse-iceberg-catalog.md
+++ b/docs/lakehouse-iceberg-catalog.md
@@ -87,6 +87,12 @@ The following properties tune REST backend behavior:
 | `data-access`                       | Data access mode for REST catalog 
backend. Supported values are `vended-credentials` and `remote-signing`. | 
(none)        | No       |
 | `rest-client-connection-timeout-ms` | The HTTP connection timeout in 
milliseconds for requests to the REST catalog backend.                      | 
10000         | No       |
 | `rest-client-socket-timeout-ms`     | The HTTP socket timeout in 
milliseconds for requests to the REST catalog backend.                          
| 60000         | No       |
+| `rest-backend-auth-type`           | How the REST backend authenticates to 
the remote catalog: `user-token` forwards the calling user's token, 
`service-account` connects with `rest-backend-token` or 
`rest-backend-credential` and authorizes every request in Gravitino. | 
user-token    | No       |
+| `rest-backend-token`               | Static bearer token for a 
`service-account` connection. Hidden from catalog responses.                    
 | (none)        | No       |
+| `rest-backend-credential`          | OAuth2 client credential 
(`client_id:client_secret`) for a `service-account` connection. Hidden from 
catalog responses. | (none)        | No       |
+| `rest-backend-oauth2-server-uri`   | OAuth2 token endpoint for 
`rest-backend-credential`.                                                      
 | (none)        | No       |
+| `rest-backend-scope`               | OAuth2 scope requested with 
`rest-backend-credential`.                                                     
| (none)        | No       |
+| `rest-backend-storage-credential-source`  | Who issues storage credentials 
through the Iceberg REST service: `remote` passes through the remote catalog's 
credentials, `local` issues them through this catalog's `credential-providers`. 
A `service-account` connection always uses `local`, regardless of this setting. 
| remote        | No       |
 
 - `vended-credentials`: request credential vending from the Iceberg REST 
server.
 - `remote-signing`: Gravitino doesn't support this mode yet.
diff --git 
a/iceberg/iceberg-common/src/main/java/org/apache/gravitino/iceberg/common/IcebergConfig.java
 
b/iceberg/iceberg-common/src/main/java/org/apache/gravitino/iceberg/common/IcebergConfig.java
index 17d0679c19..c8046ce331 100644
--- 
a/iceberg/iceberg-common/src/main/java/org/apache/gravitino/iceberg/common/IcebergConfig.java
+++ 
b/iceberg/iceberg-common/src/main/java/org/apache/gravitino/iceberg/common/IcebergConfig.java
@@ -240,6 +240,53 @@ public class IcebergConfig extends Config implements 
OverwriteDefaultConfig {
           .booleanConf()
           .createWithDefault(true);
 
+  /**
+   * How a REST catalog backend authenticates to the remote catalog. {@code 
user-token} forwards the
+   * calling user's access token; {@code service-account} connects with the 
configured service
+   * account credential and makes Gravitino authorize every request locally.
+   */
+  public static final ConfigEntry<String> REST_BACKEND_AUTH_TYPE =
+      new ConfigBuilder(IcebergConstants.REST_BACKEND_AUTH_TYPE)
+          .doc(
+              "How the REST catalog backend authenticates to the remote 
catalog. `user-token`"
+                  + " forwards the calling user's access token, and the remote 
catalog authorizes"
+                  + " the user. `service-account` connects with the credential 
in"
+                  + " `rest-backend-token` or `rest-backend-credential`, and 
Gravitino authorizes"
+                  + " every request before proxying it.")
+          .version(ConfigConstants.VERSION_2_0_0)
+          .stringConf()
+          .checkValue(
+              value ->
+                  
IcebergConstants.REST_BACKEND_AUTH_TYPE_USER_TOKEN.equals(value)
+                      || 
IcebergConstants.REST_BACKEND_AUTH_TYPE_SERVICE_ACCOUNT.equals(value),
+              "The value must be `user-token` or `service-account`")
+          
.createWithDefault(IcebergConstants.REST_BACKEND_AUTH_TYPE_USER_TOKEN);
+
+  /**
+   * Who issues storage credentials for tables of a federated REST catalog. 
{@code remote} passes
+   * through what the remote catalog vends; {@code local} issues credentials 
through this catalog's
+   * credential providers, scoped to the table and to the caller's privilege. 
Service-account
+   * connections always use local credentials.
+   */
+  public static final ConfigEntry<String> 
REST_BACKEND_STORAGE_CREDENTIAL_SOURCE =
+      new 
ConfigBuilder(IcebergConstants.REST_BACKEND_STORAGE_CREDENTIAL_SOURCE)
+          .doc(
+              "Who issues storage credentials for tables of a federated REST 
catalog. `remote`"
+                  + " passes through the credentials the remote catalog vends. 
`local` does not"
+                  + " request credentials from the remote catalog and issues 
them through this"
+                  + " catalog's `credential-providers`, scoped to the table 
location and to the"
+                  + " caller's privilege on the table. Service-account 
connections always use local"
+                  + " credentials.")
+          .version(ConfigConstants.VERSION_2_0_0)
+          .stringConf()
+          .checkValue(
+              value ->
+                  
IcebergConstants.REST_BACKEND_STORAGE_CREDENTIAL_SOURCE_REMOTE.equals(value)
+                      || 
IcebergConstants.REST_BACKEND_STORAGE_CREDENTIAL_SOURCE_LOCAL.equals(
+                          value),
+              "The value must be `local` or `remote`")
+          
.createWithDefault(IcebergConstants.REST_BACKEND_STORAGE_CREDENTIAL_SOURCE_REMOTE);
+
   /** HTTP connection timeout in milliseconds for the REST catalog backend. */
   public static final ConfigEntry<Integer> 
REST_CATALOG_BACKEND_CLIENT_CONNECTION_TIMEOUT_MS =
       new 
ConfigBuilder(IcebergConstants.REST_CATALOG_BACKEND_CLIENT_CONNECTION_TIMEOUT_MS)
@@ -432,6 +479,16 @@ public class IcebergConfig extends Config implements 
OverwriteDefaultConfig {
     return transformedConfig;
   }
 
+  /**
+   * Returns whether a REST catalog backend connects to the remote catalog as 
a service account.
+   *
+   * @return {@code true} if {@code rest-backend-auth-type} is {@code 
service-account}
+   */
+  public boolean isRestBackendServiceAccount() {
+    return IcebergConstants.REST_BACKEND_AUTH_TYPE_SERVICE_ACCOUNT.equals(
+        get(REST_BACKEND_AUTH_TYPE));
+  }
+
   @Override
   public Map<String, String> getOverwriteDefaultConfig() {
     return ImmutableMap.of(
diff --git 
a/iceberg/iceberg-common/src/main/java/org/apache/gravitino/iceberg/common/ops/IcebergCatalogWrapper.java
 
b/iceberg/iceberg-common/src/main/java/org/apache/gravitino/iceberg/common/ops/IcebergCatalogWrapper.java
index c36cc09d3e..1b5312ad7e 100644
--- 
a/iceberg/iceberg-common/src/main/java/org/apache/gravitino/iceberg/common/ops/IcebergCatalogWrapper.java
+++ 
b/iceberg/iceberg-common/src/main/java/org/apache/gravitino/iceberg/common/ops/IcebergCatalogWrapper.java
@@ -142,6 +142,15 @@ public class IcebergCatalogWrapper implements 
AutoCloseable {
     return icebergConfig;
   }
 
+  /**
+   * Invalidates cached metadata before a subclass changes or removes a table.
+   *
+   * @param tableIdentifier the table whose cached metadata must be invalidated
+   */
+  protected void invalidateTableMetadata(TableIdentifier tableIdentifier) {
+    getMetadataCache().invalidate(tableIdentifier);
+  }
+
   private SupportsNamespaces getNamespaceCatalog() {
     SupportsNamespaces namespaceCatalog = asNamespaceCatalog;
     if (namespaceCatalog != null) {
@@ -276,12 +285,12 @@ public class IcebergCatalogWrapper implements 
AutoCloseable {
   }
 
   public void dropTable(TableIdentifier tableIdentifier) {
-    getMetadataCache().invalidate(tableIdentifier);
+    invalidateTableMetadata(tableIdentifier);
     CatalogHandlers.dropTable(getCatalog(), tableIdentifier);
   }
 
   public void purgeTable(TableIdentifier tableIdentifier) {
-    getMetadataCache().invalidate(tableIdentifier);
+    invalidateTableMetadata(tableIdentifier);
     Table table = getCatalog().loadTable(tableIdentifier);
     if (!(table instanceof HasTableOperations)) {
       CatalogHandlers.purgeTable(getCatalog(), tableIdentifier);
diff --git 
a/iceberg/iceberg-common/src/main/java/org/apache/gravitino/iceberg/common/utils/IcebergCatalogUtil.java
 
b/iceberg/iceberg-common/src/main/java/org/apache/gravitino/iceberg/common/utils/IcebergCatalogUtil.java
index 59b8ce8104..d70e6d051f 100644
--- 
a/iceberg/iceberg-common/src/main/java/org/apache/gravitino/iceberg/common/utils/IcebergCatalogUtil.java
+++ 
b/iceberg/iceberg-common/src/main/java/org/apache/gravitino/iceberg/common/utils/IcebergCatalogUtil.java
@@ -272,8 +272,7 @@ public class IcebergCatalogUtil {
     Map<String, String> properties = 
Maps.newHashMap(icebergConfig.getIcebergCatalogProperties());
     applyDefaultResolvingFileIO(properties);
 
-    // REST catalog must use forward access token from the user request
-    properties.put(AuthProperties.AUTH_TYPE, 
UserPrincipalForwardingAuthManager.class.getName());
+    applyRestBackendAuthProperties(icebergConfig, properties);
     applyRestCatalogHttpTimeoutProperties(icebergConfig, properties);
 
     properties.forEach(hdfsConfiguration::set);
@@ -374,6 +373,59 @@ public class IcebergCatalogUtil {
     }
   }
 
+  /**
+   * Configures how the REST catalog backend authenticates to the remote 
catalog.
+   *
+   * <p>With {@code rest-backend-auth-type=user-token} (the default), the 
calling user's access
+   * token is forwarded on every request. With {@code service-account}, the 
configured static token
+   * ({@code rest-backend-token}) or OAuth2 client credential ({@code 
rest-backend-credential}, with
+   * optional {@code rest-backend-oauth2-server-uri} and {@code 
rest-backend-scope}) is passed to
+   * Iceberg's OAuth2 auth manager, so every request reaches the remote 
catalog as the service
+   * account. The Gravitino-named settings are removed from the client 
properties in both modes.
+   *
+   * @param icebergConfig the catalog configuration
+   * @param properties the Iceberg REST client properties to update
+   */
+  @VisibleForTesting
+  static void applyRestBackendAuthProperties(
+      IcebergConfig icebergConfig, Map<String, String> properties) {
+    String token = properties.remove(IcebergConstants.REST_BACKEND_TOKEN);
+    String credential = 
properties.remove(IcebergConstants.REST_BACKEND_CREDENTIAL);
+    String oauth2ServerUri = 
properties.remove(IcebergConstants.REST_BACKEND_OAUTH2_SERVER_URI);
+    String scope = properties.remove(IcebergConstants.REST_BACKEND_SCOPE);
+    properties.remove(IcebergConstants.REST_BACKEND_AUTH_TYPE);
+
+    if (!icebergConfig.isRestBackendServiceAccount()) {
+      properties.put(AuthProperties.AUTH_TYPE, 
UserPrincipalForwardingAuthManager.class.getName());
+      return;
+    }
+
+    boolean hasToken = StringUtils.isNotBlank(token);
+    boolean hasCredential = StringUtils.isNotBlank(credential);
+    if (hasToken == hasCredential) {
+      throw new IllegalArgumentException(
+          String.format(
+              "%s=%s requires exactly one of %s or %s",
+              IcebergConstants.REST_BACKEND_AUTH_TYPE,
+              IcebergConstants.REST_BACKEND_AUTH_TYPE_SERVICE_ACCOUNT,
+              IcebergConstants.REST_BACKEND_TOKEN,
+              IcebergConstants.REST_BACKEND_CREDENTIAL));
+    }
+
+    properties.put(AuthProperties.AUTH_TYPE, AuthProperties.AUTH_TYPE_OAUTH2);
+    if (hasToken) {
+      properties.put(IcebergConstants.ICEBERG_REST_TOKEN, token);
+    } else {
+      properties.put(IcebergConstants.ICEBERG_REST_CREDENTIAL, credential);
+      if (StringUtils.isNotBlank(oauth2ServerUri)) {
+        properties.put(IcebergConstants.ICEBERG_REST_OAUTH2_SERVER_URI, 
oauth2ServerUri);
+      }
+      if (StringUtils.isNotBlank(scope)) {
+        properties.put(IcebergConstants.ICEBERG_REST_SCOPE, scope);
+      }
+    }
+  }
+
   @VisibleForTesting
   static void applyRestCatalogHttpTimeoutProperties(
       IcebergConfig icebergConfig, Map<String, String> properties) {
diff --git 
a/iceberg/iceberg-common/src/test/java/org/apache/gravitino/iceberg/common/TestIcebergConfig.java
 
b/iceberg/iceberg-common/src/test/java/org/apache/gravitino/iceberg/common/TestIcebergConfig.java
index aa6ed3fd67..1a8827eed6 100644
--- 
a/iceberg/iceberg-common/src/test/java/org/apache/gravitino/iceberg/common/TestIcebergConfig.java
+++ 
b/iceberg/iceberg-common/src/test/java/org/apache/gravitino/iceberg/common/TestIcebergConfig.java
@@ -26,8 +26,44 @@ import 
org.apache.gravitino.iceberg.common.cache.LocalTableMetadataCache;
 import org.apache.gravitino.server.web.JettyServerConfig;
 import org.junit.jupiter.api.Assertions;
 import org.junit.jupiter.api.Test;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.ValueSource;
 
 public class TestIcebergConfig {
+  @Test
+  void testStorageCredentialSourceDefaults() {
+    IcebergConfig config = new IcebergConfig(ImmutableMap.of());
+    Assertions.assertEquals(
+        "remote", 
config.get(IcebergConfig.REST_BACKEND_STORAGE_CREDENTIAL_SOURCE));
+
+    IcebergConfig serviceAccountConfig =
+        new IcebergConfig(
+            ImmutableMap.of(IcebergConstants.REST_BACKEND_AUTH_TYPE, 
"service-account"));
+    Assertions.assertEquals(
+        "remote", 
serviceAccountConfig.get(IcebergConfig.REST_BACKEND_STORAGE_CREDENTIAL_SOURCE));
+  }
+
+  @ParameterizedTest
+  @ValueSource(strings = {"local", "remote"})
+  void testStorageCredentialSource(String source) {
+    IcebergConfig config =
+        new IcebergConfig(
+            
ImmutableMap.of(IcebergConstants.REST_BACKEND_STORAGE_CREDENTIAL_SOURCE, 
source));
+    Assertions.assertEquals(
+        source, 
config.get(IcebergConfig.REST_BACKEND_STORAGE_CREDENTIAL_SOURCE));
+  }
+
+  @ParameterizedTest
+  @ValueSource(strings = {"gravitino", "unknown"})
+  void testStorageCredentialSourceRejectsInvalidValues(String source) {
+    IcebergConfig config =
+        new IcebergConfig(
+            
ImmutableMap.of(IcebergConstants.REST_BACKEND_STORAGE_CREDENTIAL_SOURCE, 
source));
+    Assertions.assertThrows(
+        IllegalArgumentException.class,
+        () -> 
config.get(IcebergConfig.REST_BACKEND_STORAGE_CREDENTIAL_SOURCE));
+  }
+
   @Test
   public void testLoadIcebergConfig() {
     Map<String, String> properties =
diff --git 
a/iceberg/iceberg-common/src/test/java/org/apache/gravitino/iceberg/common/utils/TestIcebergCatalogUtil.java
 
b/iceberg/iceberg-common/src/test/java/org/apache/gravitino/iceberg/common/utils/TestIcebergCatalogUtil.java
index d8ce5cc18a..a7f562de46 100644
--- 
a/iceberg/iceberg-common/src/test/java/org/apache/gravitino/iceberg/common/utils/TestIcebergCatalogUtil.java
+++ 
b/iceberg/iceberg-common/src/test/java/org/apache/gravitino/iceberg/common/utils/TestIcebergCatalogUtil.java
@@ -30,6 +30,7 @@ import 
org.apache.gravitino.catalog.lakehouse.iceberg.IcebergConstants;
 import org.apache.gravitino.iceberg.common.ClosableJdbcCatalog;
 import org.apache.gravitino.iceberg.common.IcebergConfig;
 import org.apache.gravitino.iceberg.common.authentication.AuthenticationConfig;
+import 
org.apache.gravitino.iceberg.common.rest.auth.UserPrincipalForwardingAuthManager;
 import org.apache.gravitino.storage.GCSProperties;
 import org.apache.iceberg.CatalogProperties;
 import org.apache.iceberg.Schema;
@@ -41,6 +42,7 @@ import org.apache.iceberg.hive.HiveCatalog;
 import org.apache.iceberg.inmemory.InMemoryCatalog;
 import org.apache.iceberg.jdbc.JdbcCatalogWithMetadataLocationSupport;
 import org.apache.iceberg.jdbc.UncheckedSQLException;
+import org.apache.iceberg.rest.auth.AuthProperties;
 import org.apache.iceberg.types.Types;
 import org.junit.jupiter.api.AfterEach;
 import org.junit.jupiter.api.Assertions;
@@ -516,4 +518,88 @@ public class TestIcebergCatalogUtil {
             IcebergCatalogBackend.JDBC, new IcebergConfig(properties));
     Assertions.assertInstanceOf(ClosableJdbcCatalog.class, catalog2);
   }
+
+  @Test
+  void testRestBackendForwardsUserTokenByDefault() {
+    Map<String, String> properties = new HashMap<>();
+    properties.put(IcebergConstants.REST_BACKEND_TOKEN, "ignored");
+
+    IcebergCatalogUtil.applyRestBackendAuthProperties(
+        new IcebergConfig(new HashMap<>()), properties);
+
+    Assertions.assertEquals(
+        UserPrincipalForwardingAuthManager.class.getName(),
+        properties.get(AuthProperties.AUTH_TYPE));
+    
Assertions.assertFalse(properties.containsKey(IcebergConstants.REST_BACKEND_TOKEN));
+    
Assertions.assertFalse(properties.containsKey(IcebergConstants.ICEBERG_REST_TOKEN));
+  }
+
+  @Test
+  void testRestBackendServiceAccountWithToken() {
+    Map<String, String> config = serviceAccountConfig();
+    Map<String, String> properties = new HashMap<>();
+    properties.put(IcebergConstants.REST_BACKEND_TOKEN, "cf-token");
+    properties.put(IcebergConstants.REST_BACKEND_AUTH_TYPE, "service-account");
+
+    IcebergCatalogUtil.applyRestBackendAuthProperties(new 
IcebergConfig(config), properties);
+
+    Assertions.assertEquals(
+        AuthProperties.AUTH_TYPE_OAUTH2, 
properties.get(AuthProperties.AUTH_TYPE));
+    Assertions.assertEquals("cf-token", 
properties.get(IcebergConstants.ICEBERG_REST_TOKEN));
+    
Assertions.assertFalse(properties.containsKey(IcebergConstants.ICEBERG_REST_CREDENTIAL));
+    
Assertions.assertFalse(properties.containsKey(IcebergConstants.REST_BACKEND_TOKEN));
+    
Assertions.assertFalse(properties.containsKey(IcebergConstants.REST_BACKEND_AUTH_TYPE));
+  }
+
+  @Test
+  void testRestBackendServiceAccountWithClientCredential() {
+    Map<String, String> properties = new HashMap<>();
+    properties.put(IcebergConstants.REST_BACKEND_CREDENTIAL, "client:secret");
+    properties.put(IcebergConstants.REST_BACKEND_OAUTH2_SERVER_URI, 
"https://idp/token";);
+    properties.put(IcebergConstants.REST_BACKEND_SCOPE, "catalog");
+
+    IcebergCatalogUtil.applyRestBackendAuthProperties(
+        new IcebergConfig(serviceAccountConfig()), properties);
+
+    Assertions.assertEquals(
+        AuthProperties.AUTH_TYPE_OAUTH2, 
properties.get(AuthProperties.AUTH_TYPE));
+    Assertions.assertEquals(
+        "client:secret", 
properties.get(IcebergConstants.ICEBERG_REST_CREDENTIAL));
+    Assertions.assertEquals(
+        "https://idp/token";, 
properties.get(IcebergConstants.ICEBERG_REST_OAUTH2_SERVER_URI));
+    Assertions.assertEquals("catalog", 
properties.get(IcebergConstants.ICEBERG_REST_SCOPE));
+    
Assertions.assertFalse(properties.containsKey(IcebergConstants.ICEBERG_REST_TOKEN));
+  }
+
+  @Test
+  void testRestBackendServiceAccountRequiresExactlyOneCredential() {
+    IcebergConfig config = new IcebergConfig(serviceAccountConfig());
+    Assertions.assertThrows(
+        IllegalArgumentException.class,
+        () -> IcebergCatalogUtil.applyRestBackendAuthProperties(config, new 
HashMap<>()));
+
+    Map<String, String> both = new HashMap<>();
+    both.put(IcebergConstants.REST_BACKEND_TOKEN, "t");
+    both.put(IcebergConstants.REST_BACKEND_CREDENTIAL, "c:s");
+    Assertions.assertThrows(
+        IllegalArgumentException.class,
+        () -> IcebergCatalogUtil.applyRestBackendAuthProperties(config, both));
+  }
+
+  @Test
+  void testRestBackendAuthTypeRejectsUnknownValue() {
+    Map<String, String> config = new HashMap<>();
+    config.put(IcebergConstants.REST_BACKEND_AUTH_TYPE, "basic");
+    Assertions.assertThrows(
+        IllegalArgumentException.class,
+        () -> new IcebergConfig(config).isRestBackendServiceAccount());
+  }
+
+  private static Map<String, String> serviceAccountConfig() {
+    Map<String, String> config = new HashMap<>();
+    config.put(
+        IcebergConstants.REST_BACKEND_AUTH_TYPE,
+        IcebergConstants.REST_BACKEND_AUTH_TYPE_SERVICE_ACCOUNT);
+    return config;
+  }
 }
diff --git 
a/iceberg/iceberg-rest-server/src/main/java/org/apache/gravitino/iceberg/service/CatalogWrapperForREST.java
 
b/iceberg/iceberg-rest-server/src/main/java/org/apache/gravitino/iceberg/service/CatalogWrapperForREST.java
index 921a6bc127..1fde254d35 100644
--- 
a/iceberg/iceberg-rest-server/src/main/java/org/apache/gravitino/iceberg/service/CatalogWrapperForREST.java
+++ 
b/iceberg/iceberg-rest-server/src/main/java/org/apache/gravitino/iceberg/service/CatalogWrapperForREST.java
@@ -45,6 +45,7 @@ import 
org.apache.gravitino.credential.CredentialPropertyUtils;
 import org.apache.gravitino.credential.PathBasedCredentialContext;
 import org.apache.gravitino.iceberg.common.IcebergConfig;
 import org.apache.gravitino.iceberg.common.ops.IcebergCatalogWrapper;
+import 
org.apache.gravitino.iceberg.service.authorization.IcebergRESTServerContext;
 import org.apache.gravitino.iceberg.service.cache.ScanPlanCache;
 import org.apache.gravitino.iceberg.service.cache.ScanPlanCacheKey;
 import org.apache.gravitino.storage.GCSProperties;
@@ -126,6 +127,31 @@ public class CatalogWrapperForREST extends 
IcebergCatalogWrapper {
     this.scanPlanCache = loadScanPlanCache(config);
   }
 
+  /**
+   * Validates server settings required by this catalog before it serves 
requests.
+   *
+   * @param serverContext the REST server context
+   */
+  public void validateServerContext(IcebergRESTServerContext serverContext) {}
+
+  /**
+   * Reports whether this catalog forwards the caller's access token to a 
remote catalog.
+   *
+   * @return {@code false} for a local catalog backend
+   */
+  public boolean forwardsUserToken() {
+    return false;
+  }
+
+  /**
+   * Reports whether this server can enqueue file cleanup after dropping a 
table.
+   *
+   * @return {@code true} for a local catalog backend
+   */
+  public boolean supportsAsyncPurge() {
+    return true;
+  }
+
   public LoadTableResponse createTable(
       Namespace namespace, CreateTableRequest request, boolean 
requestCredential) {
     LoadTableResponse loadTableResponse = super.createTable(namespace, 
request);
@@ -608,8 +634,14 @@ public class CatalogWrapperForREST extends 
IcebergCatalogWrapper {
    * Inject vended credentials into a scan response using the already-loaded 
table, avoiding a
    * redundant {@code loadTable} call. Follows the same eligibility logic as 
{@link
    * #shouldGenerateCredential} and the same credential generation as {@link 
#getCredential}.
+   *
+   * @param tableIdentifier the table identifier
+   * @param table the loaded table
+   * @param response the scan response to add credentials to
+   * @param privilege the caller's privilege on the table
+   * @return the response with credentials, or unchanged if none could be 
generated
    */
-  private PlanTableScanResponse injectScanCredentials(
+  protected PlanTableScanResponse injectScanCredentials(
       TableIdentifier tableIdentifier,
       Table table,
       PlanTableScanResponse response,
diff --git 
a/iceberg/iceberg-rest-server/src/main/java/org/apache/gravitino/iceberg/service/FederatedCatalogWrapper.java
 
b/iceberg/iceberg-rest-server/src/main/java/org/apache/gravitino/iceberg/service/FederatedCatalogWrapper.java
index 17c3449c14..f28e3a65ea 100644
--- 
a/iceberg/iceberg-rest-server/src/main/java/org/apache/gravitino/iceberg/service/FederatedCatalogWrapper.java
+++ 
b/iceberg/iceberg-rest-server/src/main/java/org/apache/gravitino/iceberg/service/FederatedCatalogWrapper.java
@@ -21,6 +21,7 @@ package org.apache.gravitino.iceberg.service;
 
 import com.google.common.annotations.VisibleForTesting;
 import com.google.common.base.Preconditions;
+import com.google.common.collect.ImmutableList;
 import com.google.common.collect.ImmutableMap;
 import com.google.common.collect.Maps;
 import java.util.Collections;
@@ -29,8 +30,10 @@ import java.util.Map;
 import java.util.Optional;
 import java.util.function.Function;
 import java.util.stream.Collectors;
+import org.apache.gravitino.catalog.lakehouse.iceberg.IcebergConstants;
 import org.apache.gravitino.credential.CredentialPrivilege;
 import org.apache.gravitino.iceberg.common.IcebergConfig;
+import 
org.apache.gravitino.iceberg.service.authorization.IcebergRESTServerContext;
 import org.apache.iceberg.BaseTransaction;
 import org.apache.iceberg.CatalogProperties;
 import org.apache.iceberg.MetadataUpdate;
@@ -72,8 +75,13 @@ import 
org.apache.iceberg.rest.responses.PlanTableScanResponse;
  * <p>Federation-specific behavior is expressed through polymorphic overrides 
instead of {@code
  * instanceof RESTCatalog} checks scattered across the base class. Table load, 
create and register
  * use authenticated REST calls so {@code X-Iceberg-Access-Delegation} can be 
forwarded when the
- * client requested credential vending. Update still uses the Iceberg Catalog 
API. This wrapper
- * never injects Gravitino-generated credentials.
+ * client requested credential vending. Update still uses the Iceberg Catalog 
API.
+ *
+ * <p>By default the wrapper passes through the storage credentials the remote 
catalog vends and
+ * never injects Gravitino-generated credentials. With {@code
+ * rest-backend-storage-credential-source=local}, it never asks the remote 
catalog for credentials
+ * and instead issues them through this catalog's own credential providers, 
scoped to the table
+ * location and to the caller's privilege, exactly as for a local catalog.
  *
  * <p>Portions of the table update handling are derived from Apache Iceberg's 
{@code
  * org.apache.iceberg.rest.CatalogHandlers}:
@@ -86,6 +94,33 @@ public class FederatedCatalogWrapper extends 
CatalogWrapperForREST {
   private static final String X_ICEBERG_ACCESS_DELEGATION = 
"X-Iceberg-Access-Delegation";
   private static final String VENDED_CREDENTIALS = "vended-credentials";
 
+  /**
+   * Config key prefixes that carry storage credentials. With Gravitino 
credential vending, keys
+   * with these prefixes are dropped from remote responses so remote 
credentials never reach
+   * clients.
+   */
+  private static final List<String> REMOTE_CREDENTIAL_KEY_PREFIXES =
+      ImmutableList.of(
+          "s3.access-key-id",
+          "s3.secret-access-key",
+          "s3.session-token",
+          "s3.remote-signing-enabled",
+          "gcs.oauth2.",
+          "adls.sas-token",
+          "client.access-key-id",
+          "client.access-key-secret",
+          "client.security-token",
+          "client.refresh-credentials-",
+          "s3.refresh-credentials-",
+          "s3.sse.key",
+          "adls.token",
+          "adls.auth.shared-key.",
+          "gcs.client-lib-token",
+          "gcs.encryption-key",
+          "gcs.decryption-key",
+          "token",
+          "urn:ietf:params:oauth:token-type:");
+
   /**
    * Caches whether the remote catalog advertises the scan-plan endpoint. Only 
successful lookups
    * are cached, so a failure to reach the remote can be retried. Races just 
repeat an idempotent
@@ -93,6 +128,9 @@ public class FederatedCatalogWrapper extends 
CatalogWrapperForREST {
    */
   private volatile Boolean remoteSupportsScanPlan;
 
+  /** Whether storage credentials come from this catalog's providers rather 
than the remote. */
+  private final boolean vendCredentialsLocally;
+
   /**
    * Creates a federated wrapper.
    *
@@ -101,6 +139,62 @@ public class FederatedCatalogWrapper extends 
CatalogWrapperForREST {
    */
   public FederatedCatalogWrapper(String catalogName, IcebergConfig config) {
     super(catalogName, config);
+    // A service account's credentials reflect what the service account may 
do, not the caller,
+    // so a catalog connected as a service account never passes the remote's 
credentials through.
+    this.vendCredentialsLocally =
+        IcebergConstants.REST_BACKEND_STORAGE_CREDENTIAL_SOURCE_LOCAL.equals(
+                
config.get(IcebergConfig.REST_BACKEND_STORAGE_CREDENTIAL_SOURCE))
+            || config.isRestBackendServiceAccount();
+  }
+
+  /**
+   * Requires local authorization when the remote catalog is accessed as a 
service account.
+   *
+   * @param serverContext the REST server context
+   */
+  @Override
+  public void validateServerContext(IcebergRESTServerContext serverContext) {
+    if (getIcebergConfig().isRestBackendServiceAccount()
+        && !serverContext.isAuthorizationEnabled()) {
+      throw new IllegalArgumentException(
+          String.format(
+              "Catalog %s connects to its remote catalog as a service account 
(%s=%s), which"
+                  + " requires authorization to be enabled",
+              catalogCredentialManager.catalogName(),
+              IcebergConstants.REST_BACKEND_AUTH_TYPE,
+              IcebergConstants.REST_BACKEND_AUTH_TYPE_SERVICE_ACCOUNT));
+    }
+  }
+
+  /**
+   * Reports whether the remote catalog receives the calling user's access 
token.
+   *
+   * @return {@code true} for a user-token connection, or {@code false} for a 
service account
+   */
+  @Override
+  public boolean forwardsUserToken() {
+    return !getIcebergConfig().isRestBackendServiceAccount();
+  }
+
+  /**
+   * Keeps cleanup of the remote catalog's files on the remote server.
+   *
+   * @return {@code false}, since this server must forward purge requests to 
the remote catalog
+   */
+  @Override
+  public boolean supportsAsyncPurge() {
+    return false;
+  }
+
+  /**
+   * Forwards a purge to the remote catalog, which owns deletion of the 
table's files.
+   *
+   * @param tableIdentifier the table to purge
+   */
+  @Override
+  public void purgeTable(TableIdentifier tableIdentifier) {
+    invalidateTableMetadata(tableIdentifier);
+    CatalogHandlers.purgeTable(getCatalog(), tableIdentifier);
   }
 
   /**
@@ -120,7 +214,15 @@ public class FederatedCatalogWrapper extends 
CatalogWrapperForREST {
   @Override
   public LoadTableResponse createTable(
       Namespace namespace, CreateTableRequest request, boolean 
requestCredential) {
-    return createTableViaREST(namespace, request, requestCredential);
+    if (!vendCredentialsLocally) {
+      return createTableViaREST(namespace, request, requestCredential);
+    }
+    // The creator owns the new table, so it receives write credentials, as 
for a local catalog.
+    return withLocalCredentials(
+        TableIdentifier.of(namespace, request.name()),
+        createTableViaREST(namespace, request, false),
+        requestCredential,
+        CredentialPrivilege.WRITE);
   }
 
   /**
@@ -129,18 +231,23 @@ public class FederatedCatalogWrapper extends 
CatalogWrapperForREST {
    * <p>Always uses a dedicated REST GET rather than Iceberg's {@link 
RESTCatalog#loadTable}, which
    * cannot send {@code X-Iceberg-Access-Delegation}. When credential vending 
is requested the
    * header is forwarded so the remote catalog returns {@code 
storage-credentials} inline. Upstream
-   * credential refresh endpoints are rewritten to this IRC catalog. The 
{@code privilege} is
-   * ignored because the remote catalog decides what to vend.
+   * credential refresh endpoints are rewritten to this IRC catalog. With 
Gravitino credential
+   * vending, the remote is not asked for credentials and this catalog's 
providers issue them for
+   * the caller's {@code privilege}; otherwise the remote catalog decides what 
to vend.
    *
    * @param identifier the table identifier.
    * @param requestCredential whether the client requested vended credentials.
-   * @param privilege ignored; the remote REST catalog vends its own 
credentials.
+   * @param privilege the caller's privilege, used only with Gravitino 
credential vending.
    * @return the load-table response, including rewritten remote credentials 
when requested.
    */
   @Override
   public LoadTableResponse loadTable(
       TableIdentifier identifier, boolean requestCredential, 
CredentialPrivilege privilege) {
-    return loadTableViaREST(identifier, requestCredential);
+    if (!vendCredentialsLocally) {
+      return loadTableViaREST(identifier, requestCredential);
+    }
+    return withLocalCredentials(
+        identifier, loadTableViaREST(identifier, false), requestCredential, 
privilege);
   }
 
   /**
@@ -159,7 +266,16 @@ public class FederatedCatalogWrapper extends 
CatalogWrapperForREST {
   @Override
   public LoadTableResponse registerTable(
       Namespace namespace, RegisterTableRequest request, boolean 
requestCredential) {
-    return registerTableViaREST(namespace, request, requestCredential);
+    if (!vendCredentialsLocally) {
+      return registerTableViaREST(namespace, request, requestCredential);
+    }
+    // The registering user owns the table, so it receives write credentials, 
as for a local
+    // catalog.
+    return withLocalCredentials(
+        TableIdentifier.of(namespace, request.name()),
+        registerTableViaREST(namespace, request, false),
+        requestCredential,
+        CredentialPrivilege.WRITE);
   }
 
   @Override
@@ -175,20 +291,23 @@ public class FederatedCatalogWrapper extends 
CatalogWrapperForREST {
   }
 
   /**
-   * Fetches table credentials from the remote REST catalog instead of vending 
Gravitino-managed
-   * credentials. The {@code privilege} is ignored because the remote catalog 
decides what to vend.
+   * Fetches table credentials from the remote REST catalog, or, with 
Gravitino credential vending,
+   * issues them through this catalog's credential providers for the caller's 
{@code privilege}.
    *
    * <p>The upstream credentials are rewritten via {@link 
IcebergRESTUtils#rewriteTableCredentials}
    * so their {@code refresh-credentials-endpoint} points at this IRC catalog 
rather than the remote
    * catalog, consistent with the {@link #loadTable}/{@code createTable} 
federation paths.
    *
    * @param identifier the table identifier.
-   * @param privilege ignored; the remote REST catalog vends its own 
credentials.
+   * @param privilege the caller's privilege, used only with Gravitino 
credential vending.
    * @return the remote credentials with IRC-local refresh endpoints.
    */
   @Override
   public LoadCredentialsResponse getTableCredentials(
       TableIdentifier identifier, CredentialPrivilege privilege) {
+    if (vendCredentialsLocally) {
+      return super.getTableCredentials(identifier, privilege);
+    }
     LoadCredentialsResponse upstream =
         getRESTTableCredentials((RESTCatalog) getCatalog(), identifier);
     return IcebergRESTUtils.rewriteTableCredentials(
@@ -205,7 +324,7 @@ public class FederatedCatalogWrapper extends 
CatalogWrapperForREST {
    * @param tableIdentifier the table to scan.
    * @param scanRequest the scan request parameters.
    * @param requestCredentialVending whether the client requested vended 
credentials.
-   * @param privilege ignored; the remote REST catalog decides what to vend.
+   * @param privilege the caller's privilege, used only with Gravitino 
credential vending.
    * @return the scan response from the remote catalog, with rewritten 
credential refresh endpoints.
    */
   @Override
@@ -215,6 +334,17 @@ public class FederatedCatalogWrapper extends 
CatalogWrapperForREST {
       boolean requestCredentialVending,
       CredentialPrivilege privilege) {
     Table table = getCatalog().loadTable(tableIdentifier);
+    if (vendCredentialsLocally) {
+      // Drop anything the remote returned unasked, so its credentials never 
reach the client.
+      PlanTableScanResponse response =
+          IcebergRESTUtils.copyWithCredentials(
+              getRESTTablePlanScan(
+                  (RESTCatalog) getCatalog(), tableIdentifier, scanRequest, 
false, table.specs()),
+              Collections.emptyList());
+      return requestCredentialVending && 
!isLocalOrHdfsLocation(table.location())
+          ? injectScanCredentials(tableIdentifier, table, response, privilege)
+          : response;
+    }
     PlanTableScanResponse response =
         getRESTTablePlanScan(
             (RESTCatalog) getCatalog(),
@@ -527,6 +657,37 @@ public class FederatedCatalogWrapper extends 
CatalogWrapperForREST {
     return rewriteRemoteLoadTable(TableIdentifier.of(namespace, 
request.name()), upstream);
   }
 
+  /**
+   * Adds credentials issued by this catalog's credential providers to a 
response fetched from the
+   * remote catalog without vended credentials.
+   *
+   * @param identifier the table identifier
+   * @param remote the remote response
+   * @param requestCredential whether the client requested vended credentials
+   * @param privilege the caller's privilege on the table
+   * @return the response, with Gravitino-issued credentials when they were 
requested and never with
+   *     credentials from the remote catalog
+   */
+  private LoadTableResponse withLocalCredentials(
+      TableIdentifier identifier,
+      LoadTableResponse remote,
+      boolean requestCredential,
+      CredentialPrivilege privilege) {
+    // Drop anything the remote returned unasked, so its credentials never 
reach the client.
+    Map<String, String> config =
+        Maps.filterKeys(
+            remote.config(),
+            key -> 
REMOTE_CREDENTIAL_KEY_PREFIXES.stream().noneMatch(key::startsWith));
+    LoadTableResponse withoutRemoteCredentials =
+        LoadTableResponse.builder()
+            .withTableMetadata(remote.tableMetadata())
+            .addAllConfig(config)
+            .build();
+    return shouldGenerateCredential(withoutRemoteCredentials, 
requestCredential)
+        ? injectCredentialConfig(identifier, withoutRemoteCredentials, 
privilege)
+        : withoutRemoteCredentials;
+  }
+
   private LoadTableResponse rewriteRemoteLoadTable(
       TableIdentifier identifier, LoadTableResponse upstream) {
     return IcebergRESTUtils.rewriteLoadTableCredentials(
diff --git 
a/iceberg/iceberg-rest-server/src/main/java/org/apache/gravitino/iceberg/service/IcebergCatalogWrapperManager.java
 
b/iceberg/iceberg-rest-server/src/main/java/org/apache/gravitino/iceberg/service/IcebergCatalogWrapperManager.java
index a960f175e0..0bc3af66b4 100644
--- 
a/iceberg/iceberg-rest-server/src/main/java/org/apache/gravitino/iceberg/service/IcebergCatalogWrapperManager.java
+++ 
b/iceberg/iceberg-rest-server/src/main/java/org/apache/gravitino/iceberg/service/IcebergCatalogWrapperManager.java
@@ -142,7 +142,14 @@ public class IcebergCatalogWrapperManager implements 
AutoCloseable {
       throw new NoSuchCatalogException(
           "Couldn't find Iceberg configuration for catalog %s", catalogName);
     }
-    return createCatalogWrapper(catalogName, icebergConfig.get());
+    CatalogWrapperForREST wrapper = createCatalogWrapper(catalogName, 
icebergConfig.get());
+    try {
+      wrapper.validateServerContext(serverContext);
+      return wrapper;
+    } catch (RuntimeException e) {
+      closeIcebergCatalogWrapper(wrapper);
+      throw e;
+    }
   }
 
   // Overriding this method to create a new CatalogWrapperForREST for test;
diff --git 
a/iceberg/iceberg-rest-server/src/main/java/org/apache/gravitino/iceberg/service/dispatcher/IcebergTableOperationExecutor.java
 
b/iceberg/iceberg-rest-server/src/main/java/org/apache/gravitino/iceberg/service/dispatcher/IcebergTableOperationExecutor.java
index fac0cf4af1..aa88f08673 100644
--- 
a/iceberg/iceberg-rest-server/src/main/java/org/apache/gravitino/iceberg/service/dispatcher/IcebergTableOperationExecutor.java
+++ 
b/iceberg/iceberg-rest-server/src/main/java/org/apache/gravitino/iceberg/service/dispatcher/IcebergTableOperationExecutor.java
@@ -27,8 +27,8 @@ import org.apache.gravitino.NameIdentifier;
 import org.apache.gravitino.auth.AuthConstants;
 import org.apache.gravitino.catalog.lakehouse.iceberg.IcebergConstants;
 import org.apache.gravitino.credential.CredentialPrivilege;
-import org.apache.gravitino.iceberg.common.ops.IcebergCatalogWrapper;
 import org.apache.gravitino.iceberg.common.utils.IcebergIdentifierUtils;
+import org.apache.gravitino.iceberg.service.CatalogWrapperForREST;
 import org.apache.gravitino.iceberg.service.IcebergCatalogWrapperManager;
 import 
org.apache.gravitino.iceberg.service.authorization.IcebergRESTServerContext;
 import org.apache.gravitino.iceberg.service.cleanup.IcebergCleanupJob;
@@ -134,7 +134,7 @@ public class IcebergTableOperationExecutor implements 
IcebergTableOperationDispa
   @Override
   public void dropTable(
       IcebergRequestContext context, TableIdentifier tableIdentifier, boolean 
purgeRequested) {
-    IcebergCatalogWrapper wrapper =
+    CatalogWrapperForREST wrapper =
         icebergCatalogWrapperManager.getCatalogWrapper(context.catalogName());
     if (!purgeRequested) {
       wrapper.dropTable(tableIdentifier);
@@ -142,7 +142,7 @@ public class IcebergTableOperationExecutor implements 
IcebergTableOperationDispa
     }
 
     // Async cleanup is opt-in per request and only wired in auxiliary mode; 
otherwise purge inline.
-    if (!context.asyncPurge()) {
+    if (!context.asyncPurge() || !wrapper.supportsAsyncPurge()) {
       wrapper.purgeTable(tableIdentifier);
       return;
     }
diff --git 
a/iceberg/iceberg-rest-server/src/main/java/org/apache/gravitino/server/web/filter/IcebergMetadataAuthorizationMethodInterceptor.java
 
b/iceberg/iceberg-rest-server/src/main/java/org/apache/gravitino/server/web/filter/IcebergMetadataAuthorizationMethodInterceptor.java
index 66cefe6a58..eb744adf7f 100644
--- 
a/iceberg/iceberg-rest-server/src/main/java/org/apache/gravitino/server/web/filter/IcebergMetadataAuthorizationMethodInterceptor.java
+++ 
b/iceberg/iceberg-rest-server/src/main/java/org/apache/gravitino/server/web/filter/IcebergMetadataAuthorizationMethodInterceptor.java
@@ -31,7 +31,7 @@ import org.apache.gravitino.Entity;
 import org.apache.gravitino.Entity.EntityType;
 import org.apache.gravitino.NameIdentifier;
 import org.apache.gravitino.exceptions.NoSuchCatalogException;
-import org.apache.gravitino.iceberg.common.ops.IcebergCatalogWrapper;
+import org.apache.gravitino.iceberg.service.CatalogWrapperForREST;
 import org.apache.gravitino.iceberg.service.IcebergCatalogWrapperManager;
 import org.apache.gravitino.iceberg.service.IcebergExceptionMapper;
 import org.apache.gravitino.iceberg.service.IcebergRESTUtils;
@@ -209,7 +209,7 @@ public class IcebergMetadataAuthorizationMethodInterceptor
       return false;
     }
 
-    IcebergCatalogWrapper catalogWrapper;
+    CatalogWrapperForREST catalogWrapper;
     try {
       catalogWrapper = wrapperManager.getCatalogWrapper(catalogId.name());
     } catch (NoSuchCatalogException | IllegalArgumentException e) {
@@ -219,8 +219,9 @@ public class IcebergMetadataAuthorizationMethodInterceptor
       return false;
     }
     // When IRC2 is another Gravitino server, IRC1 acts as a proxy and does 
not perform
-    // authorization. IRC2 handles authorization.
-    return catalogWrapper.isRESTCatalog();
+    // authorization. IRC2 handles authorization. A REST backend connected as 
a service account is
+    // never skipped: the remote catalog sees only the service account, so 
IRC1 must authorize.
+    return catalogWrapper.forwardsUserToken();
   }
 
   /** Metrics are stored locally, so a downstream REST catalog cannot 
authorize the report. */
diff --git 
a/iceberg/iceberg-rest-server/src/test/java/org/apache/gravitino/iceberg/service/TestCatalogWrapperForREST.java
 
b/iceberg/iceberg-rest-server/src/test/java/org/apache/gravitino/iceberg/service/TestCatalogWrapperForREST.java
index bc57dc4ada..8019302009 100644
--- 
a/iceberg/iceberg-rest-server/src/test/java/org/apache/gravitino/iceberg/service/TestCatalogWrapperForREST.java
+++ 
b/iceberg/iceberg-rest-server/src/test/java/org/apache/gravitino/iceberg/service/TestCatalogWrapperForREST.java
@@ -22,14 +22,17 @@ package org.apache.gravitino.iceberg.service;
 import static org.mockito.Mockito.any;
 import static org.mockito.Mockito.anyString;
 import static org.mockito.Mockito.doAnswer;
+import static org.mockito.Mockito.inOrder;
 import static org.mockito.Mockito.mock;
 import static org.mockito.Mockito.never;
 import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.verifyNoMoreInteractions;
 import static org.mockito.Mockito.when;
 
 import com.google.common.collect.ImmutableMap;
 import com.sun.net.httpserver.HttpServer;
 import java.io.OutputStream;
+import java.lang.reflect.Field;
 import java.net.InetSocketAddress;
 import java.nio.charset.StandardCharsets;
 import java.nio.file.Path;
@@ -42,10 +45,14 @@ import java.util.Optional;
 import java.util.UUID;
 import java.util.concurrent.atomic.AtomicBoolean;
 import java.util.concurrent.atomic.AtomicReference;
+import javax.annotation.Nullable;
 import org.apache.gravitino.catalog.lakehouse.iceberg.IcebergConstants;
 import org.apache.gravitino.credential.CredentialConstants;
 import org.apache.gravitino.credential.CredentialPrivilege;
 import org.apache.gravitino.iceberg.common.IcebergConfig;
+import org.apache.gravitino.iceberg.common.cache.TableMetadataCache;
+import org.apache.gravitino.iceberg.common.ops.IcebergCatalogWrapper;
+import 
org.apache.gravitino.iceberg.service.authorization.IcebergRESTServerContext;
 import org.apache.gravitino.iceberg.service.cache.LocalScanPlanCache;
 import org.apache.gravitino.iceberg.service.extension.DummyCredentialProvider;
 import org.apache.iceberg.BaseTable;
@@ -91,11 +98,118 @@ import org.apache.iceberg.types.Types;
 import org.junit.jupiter.api.Assertions;
 import org.junit.jupiter.api.Test;
 import org.junit.jupiter.api.io.TempDir;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.CsvSource;
+import org.junit.jupiter.params.provider.ValueSource;
+import org.mockito.InOrder;
 
 public class TestCatalogWrapperForREST {
 
   private static final AtomicBoolean CONSTRUCTION_IN_PROGRESS = new 
AtomicBoolean(false);
 
+  @Test
+  void testLocalBackendPolicies() throws Exception {
+    IcebergConfig config =
+        new IcebergConfig(ImmutableMap.of(IcebergConstants.CATALOG_BACKEND, 
"memory"));
+    try (CatalogWrapperForREST wrapper = new CatalogWrapperForREST("local", 
config)) {
+      Assertions.assertFalse(wrapper.forwardsUserToken());
+      Assertions.assertTrue(wrapper.supportsAsyncPurge());
+      Assertions.assertDoesNotThrow(
+          () -> 
wrapper.validateServerContext(mock(IcebergRESTServerContext.class)));
+    }
+  }
+
+  @ParameterizedTest
+  @CsvSource({
+    "user-token, false",
+    "user-token, true",
+    "service-account, false",
+    "service-account, true"
+  })
+  void testFederatedBackendPolicies(String authType, boolean 
authorizationEnabled)
+      throws Exception {
+    IcebergConfig config =
+        new IcebergConfig(
+            ImmutableMap.of(
+                IcebergConstants.CATALOG_BACKEND,
+                "rest",
+                IcebergConstants.REST_BACKEND_AUTH_TYPE,
+                authType));
+    IcebergRESTServerContext context = mock(IcebergRESTServerContext.class);
+    when(context.isAuthorizationEnabled()).thenReturn(authorizationEnabled);
+    try (CatalogWrapperForREST wrapper = new FederatedCatalogWrapper("remote", 
config)) {
+      Assertions.assertEquals("user-token".equals(authType), 
wrapper.forwardsUserToken());
+      Assertions.assertFalse(wrapper.supportsAsyncPurge());
+      if ("service-account".equals(authType) && !authorizationEnabled) {
+        IllegalArgumentException failure =
+            Assertions.assertThrows(
+                IllegalArgumentException.class, () -> 
wrapper.validateServerContext(context));
+        Assertions.assertTrue(
+            failure.getMessage().contains("requires authorization to be 
enabled"));
+      } else {
+        Assertions.assertDoesNotThrow(() -> 
wrapper.validateServerContext(context));
+      }
+    }
+  }
+
+  @ParameterizedTest
+  @ValueSource(booleans = {true, false})
+  void testFederatedPurgeForwardsToRemoteCatalog(boolean tableExists) throws 
Exception {
+    TableIdentifier identifier = TableIdentifier.of("db", "table");
+    RESTCatalog catalog = mock(RESTCatalog.class);
+    when(catalog.dropTable(identifier, true)).thenReturn(tableExists);
+    IcebergConfig config =
+        new IcebergConfig(
+            ImmutableMap.of(
+                IcebergConstants.CATALOG_BACKEND,
+                "rest",
+                IcebergConstants.TABLE_METADATA_CACHE_IMPL,
+                ""));
+    TableMetadataCache cache = mock(TableMetadataCache.class);
+
+    try (CatalogWrapperForREST wrapper =
+        new StaticCatalogWrapperForREST("remote", config, catalog)) {
+      Field cacheField = 
IcebergCatalogWrapper.class.getDeclaredField("metadataCache");
+      cacheField.setAccessible(true);
+      cacheField.set(wrapper, cache);
+
+      if (tableExists) {
+        wrapper.purgeTable(identifier);
+      } else {
+        Assertions.assertThrows(NoSuchTableException.class, () -> 
wrapper.purgeTable(identifier));
+      }
+
+      InOrder order = inOrder(cache, catalog);
+      order.verify(cache).invalidate(identifier);
+      order.verify(catalog).dropTable(identifier, true);
+      verifyNoMoreInteractions(catalog);
+    }
+  }
+
+  @Test
+  void testFederatedPurgePropagatesRemoteFailure() throws Exception {
+    TableIdentifier identifier = TableIdentifier.of("db", "table");
+    RESTCatalog catalog = mock(RESTCatalog.class);
+    ForbiddenException failure = new ForbiddenException("Remote purge denied");
+    when(catalog.dropTable(identifier, true)).thenThrow(failure);
+    IcebergConfig config =
+        new IcebergConfig(
+            ImmutableMap.of(
+                IcebergConstants.CATALOG_BACKEND,
+                "rest",
+                IcebergConstants.TABLE_METADATA_CACHE_IMPL,
+                ""));
+
+    try (CatalogWrapperForREST wrapper =
+        new StaticCatalogWrapperForREST("remote", config, catalog)) {
+      Assertions.assertSame(
+          failure,
+          Assertions.assertThrows(ForbiddenException.class, () -> 
wrapper.purgeTable(identifier)));
+      verify(catalog).dropTable(identifier, true);
+      verifyNoMoreInteractions(catalog);
+    }
+  }
+
   @Test
   void testCheckPropertiesForCompatibility() {
     ImmutableMap<String, String> deprecatedMap = ImmutableMap.of("deprecated", 
"new");
@@ -834,6 +948,106 @@ public class TestCatalogWrapperForREST {
     }
   }
 
+  @ParameterizedTest
+  @CsvSource({
+    "user-token, local",
+    "service-account, local",
+    "service-account, remote",
+    "service-account, "
+  })
+  void testFederatedLoadTableWithLocalStorageCredentials(
+      String authType, @Nullable String credentialSource) throws Exception {
+    TableIdentifier table = TableIdentifier.of(Namespace.of("db"), "tbl");
+    TableMetadata metadata =
+        TableMetadataParser.fromJson(
+            "s3://bucket/db/tbl/metadata/v1.metadata.json",
+            TableMetadataParser.toJson(
+                TableMetadata.newTableMetadata(
+                    new Schema(Types.NestedField.required(1, "id", 
Types.IntegerType.get())),
+                    PartitionSpec.unpartitioned(),
+                    SortOrder.unsorted(),
+                    "s3://bucket/db/tbl",
+                    Collections.emptyMap())));
+    // The remote returns broad credentials even when none are requested. With 
Gravitino
+    // credential vending they must not reach the client.
+    Credential upstreamCredential =
+        IcebergRESTUtils.toRESTCredential(
+            "s3://bucket/",
+            ImmutableMap.of(
+                "s3.access-key-id", "upstream-key", "s3.secret-access-key", 
"upstream-secret"));
+    String upstreamJson =
+        LoadTableResponseParser.toJson(
+            LoadTableResponse.builder()
+                .withTableMetadata(metadata)
+                .addAllConfig(ImmutableMap.of("io-impl", 
"org.apache.iceberg.aws.s3.S3FileIO"))
+                .addCredential(upstreamCredential)
+                .build());
+
+    AtomicReference<String> accessDelegationHeader = new 
AtomicReference<>("unset");
+    HttpServer server = HttpServer.create(new InetSocketAddress(0), 0);
+    server.createContext(
+        "/",
+        exchange -> {
+          accessDelegationHeader.set(
+              
exchange.getRequestHeaders().getFirst("X-Iceberg-Access-Delegation"));
+          byte[] body = upstreamJson.getBytes(StandardCharsets.UTF_8);
+          exchange.getResponseHeaders().add("Content-Type", 
"application/json");
+          exchange.sendResponseHeaders(200, body.length);
+          try (OutputStream os = exchange.getResponseBody()) {
+            os.write(body);
+          }
+        });
+    server.start();
+    try {
+      String uri = "http://127.0.0.1:"; + server.getAddress().getPort();
+      RESTCatalog restCatalog = mock(RESTCatalog.class);
+      when(restCatalog.name()).thenReturn("upstream");
+      when(restCatalog.properties())
+          .thenReturn(
+              ImmutableMap.of(
+                  CatalogProperties.URI,
+                  uri,
+                  AuthProperties.AUTH_TYPE,
+                  AuthProperties.AUTH_TYPE_NONE,
+                  "prefix",
+                  "upstream"));
+
+      ImmutableMap.Builder<String, String> properties =
+          ImmutableMap.<String, String>builder()
+              .put(IcebergConstants.CATALOG_BACKEND, "memory")
+              .put(IcebergConstants.WAREHOUSE, "/tmp/warehouse")
+              .put(
+                  CredentialConstants.CREDENTIAL_PROVIDERS,
+                  DummyCredentialProvider.DUMMY_CREDENTIAL_TYPE)
+              .put(IcebergConstants.REST_BACKEND_AUTH_TYPE, authType);
+      if (credentialSource != null) {
+        
properties.put(IcebergConstants.REST_BACKEND_STORAGE_CREDENTIAL_SOURCE, 
credentialSource);
+      }
+      IcebergConfig config = new IcebergConfig(properties.build());
+      CatalogWrapperForREST wrapper = new StaticCatalogWrapperForREST("local", 
config, restCatalog);
+
+      LoadTableResponse response = wrapper.loadTable(table, true, 
CredentialPrivilege.READ);
+
+      Assertions.assertNull(accessDelegationHeader.get(), "remote must not be 
asked to vend");
+      Assertions.assertEquals(1, response.credentials().size());
+      Credential credential = response.credentials().get(0);
+      Assertions.assertEquals("s3://bucket/db/tbl", credential.prefix());
+      Assertions.assertNotEquals("upstream-key", 
credential.config().get("s3.access-key-id"));
+      Assertions.assertNotEquals("upstream-key", 
response.config().get("s3.access-key-id"));
+      Assertions.assertEquals(
+          "org.apache.iceberg.aws.s3.S3FileIO", 
response.config().get("io-impl"));
+
+      LoadTableResponse withoutRequest = wrapper.loadTable(table, false, 
CredentialPrivilege.READ);
+      Assertions.assertNull(accessDelegationHeader.get());
+      Assertions.assertTrue(
+          withoutRequest.credentials().stream()
+              .noneMatch(c -> 
"upstream-key".equals(c.config().get("s3.access-key-id"))),
+          "remote credentials returned without a request are passed through 
only in remote mode");
+    } finally {
+      server.stop(0);
+    }
+  }
+
   @Test
   void testFederatedLoadTableNoCredentials() throws Exception {
     TableIdentifier table = TableIdentifier.of(Namespace.of("db"), "tbl");
diff --git 
a/iceberg/iceberg-rest-server/src/test/java/org/apache/gravitino/iceberg/service/TestIcebergCatalogWrapperManagerForREST.java
 
b/iceberg/iceberg-rest-server/src/test/java/org/apache/gravitino/iceberg/service/TestIcebergCatalogWrapperManagerForREST.java
index 3a5e8925e1..fd07348c4b 100644
--- 
a/iceberg/iceberg-rest-server/src/test/java/org/apache/gravitino/iceberg/service/TestIcebergCatalogWrapperManagerForREST.java
+++ 
b/iceberg/iceberg-rest-server/src/test/java/org/apache/gravitino/iceberg/service/TestIcebergCatalogWrapperManagerForREST.java
@@ -34,6 +34,7 @@ import 
org.apache.gravitino.catalog.lakehouse.iceberg.IcebergConstants;
 import org.apache.gravitino.iceberg.common.IcebergConfig;
 import org.apache.gravitino.iceberg.common.ops.IcebergCatalogWrapper;
 import 
org.apache.gravitino.iceberg.service.authorization.IcebergRESTServerContext;
+import 
org.apache.gravitino.iceberg.service.provider.DynamicIcebergConfigProvider;
 import org.apache.gravitino.iceberg.service.provider.IcebergConfigProvider;
 import 
org.apache.gravitino.iceberg.service.provider.IcebergConfigProviderFactory;
 import org.junit.jupiter.api.AfterAll;
@@ -120,6 +121,67 @@ public class TestIcebergCatalogWrapperManagerForREST {
             
.contains("gravitino.iceberg-rest.catalog-config-provider=dynamic-config-provider"));
   }
 
+  @ParameterizedTest
+  @ValueSource(booleans = {false, true})
+  void testServiceAccountAuthorizationValidatedByWrapper(boolean 
authorizationEnabled)
+      throws Exception {
+    IcebergConfigProvider provider = 
Mockito.mock(DynamicIcebergConfigProvider.class);
+    IcebergConfig config =
+        new IcebergConfig(
+            ImmutableMap.of(
+                IcebergConstants.CATALOG_BACKEND,
+                "rest",
+                IcebergConstants.REST_BACKEND_AUTH_TYPE,
+                "service-account"));
+    
Mockito.when(provider.getIcebergCatalogConfig("remote")).thenReturn(Optional.of(config));
+    try (IcebergCatalogWrapperManager manager =
+        new IcebergCatalogWrapperManager(ImmutableMap.of(), provider, false, 
"metalake")) {
+      IcebergRESTServerContext.create(provider, authorizationEnabled, true, 
true, manager);
+      if (authorizationEnabled) {
+        Assertions.assertInstanceOf(
+            FederatedCatalogWrapper.class, 
manager.getCatalogWrapper("remote"));
+      } else {
+        IllegalArgumentException failure =
+            Assertions.assertThrows(
+                IllegalArgumentException.class, () -> 
manager.getCatalogWrapper("remote"));
+        Assertions.assertTrue(
+            failure.getMessage().contains("requires authorization to be 
enabled"));
+      }
+    }
+  }
+
+  @Test
+  void testRejectedWrapperIsClosedAndNotCached() throws Exception {
+    IcebergConfigProvider provider = Mockito.mock(IcebergConfigProvider.class);
+    IcebergConfig config = new IcebergConfig(ImmutableMap.of());
+    
Mockito.when(provider.getIcebergCatalogConfig("test")).thenReturn(Optional.of(config));
+    CatalogWrapperForREST wrapper = Mockito.mock(CatalogWrapperForREST.class);
+    Mockito.when(wrapper.getIcebergConfig()).thenReturn(config);
+    IllegalArgumentException failure = new IllegalArgumentException("invalid 
server configuration");
+    try (IcebergCatalogWrapperManager manager =
+        Mockito.spy(
+            new IcebergCatalogWrapperManager(ImmutableMap.of(), provider, 
false, "metalake"))) {
+      Mockito.doReturn(wrapper)
+          .when(manager)
+          .createCatalogWrapper(Mockito.eq("test"), Mockito.any());
+      IcebergRESTServerContext context =
+          IcebergRESTServerContext.create(provider, false, false, true, 
manager);
+      Mockito.doThrow(failure).when(wrapper).validateServerContext(context);
+
+      Assertions.assertSame(
+          failure,
+          Assertions.assertThrows(
+              IllegalArgumentException.class, () -> 
manager.getCatalogWrapper("test")));
+      Mockito.verify(wrapper).close();
+      Mockito.verify(wrapper, Mockito.never()).reloadHadoopConf();
+
+      Mockito.doNothing().when(wrapper).validateServerContext(context);
+      Assertions.assertSame(wrapper, manager.getCatalogWrapper("test"));
+      Mockito.verify(wrapper, Mockito.times(2)).validateServerContext(context);
+      Mockito.verify(wrapper).reloadHadoopConf();
+    }
+  }
+
   @Test
   public void testCreateFederatedWrapperForRestBackend() {
     IcebergConfig icebergConfig =
diff --git 
a/iceberg/iceberg-rest-server/src/test/java/org/apache/gravitino/iceberg/service/dispatcher/TestIcebergAsyncPurge.java
 
b/iceberg/iceberg-rest-server/src/test/java/org/apache/gravitino/iceberg/service/dispatcher/TestIcebergAsyncPurge.java
index 02ef946745..7b975f50ab 100644
--- 
a/iceberg/iceberg-rest-server/src/test/java/org/apache/gravitino/iceberg/service/dispatcher/TestIcebergAsyncPurge.java
+++ 
b/iceberg/iceberg-rest-server/src/test/java/org/apache/gravitino/iceberg/service/dispatcher/TestIcebergAsyncPurge.java
@@ -85,6 +85,7 @@ class TestIcebergAsyncPurge {
   @Test
   void testAsyncDropEnqueuesJob() {
     CatalogWrapperForREST wrapper = mock(CatalogWrapperForREST.class);
+    when(wrapper.supportsAsyncPurge()).thenReturn(true);
     IcebergCleanupManager cleanup = mock(IcebergCleanupManager.class);
     TableMetadata metadata = mock(TableMetadata.class);
     
when(metadata.metadataFileLocation()).thenReturn("s3://b/db/t/metadata/0.json");
@@ -124,6 +125,7 @@ class TestIcebergAsyncPurge {
   @Test
   void testFallbackToSyncWhenDisabled() {
     CatalogWrapperForREST wrapper = mock(CatalogWrapperForREST.class);
+    when(wrapper.supportsAsyncPurge()).thenReturn(true);
 
     tableExecutor(wrapper, Optional.empty()).dropTable(context(true), TABLE, 
true);
 
@@ -131,6 +133,21 @@ class TestIcebergAsyncPurge {
     verify(wrapper, never()).loadTableMetadata(any());
   }
 
+  @Test
+  void testAsyncPurgeIsForwardedForRestCatalog() {
+    // A remote REST catalog deletes its own files, so an async purge request 
is forwarded to it
+    // instead of being queued for this server to delete the files.
+    CatalogWrapperForREST wrapper = mock(CatalogWrapperForREST.class);
+    IcebergCleanupManager cleanup = mock(IcebergCleanupManager.class);
+    when(wrapper.supportsAsyncPurge()).thenReturn(false);
+
+    tableExecutor(wrapper, Optional.of(cleanup)).dropTable(context(true), 
TABLE, true);
+
+    verify(wrapper).purgeTable(TABLE);
+    verify(wrapper, never()).loadTableMetadata(any());
+    verify(cleanup, never()).addJob(any());
+  }
+
   @Test
   void testPlainDropWhenNotPurge() {
     CatalogWrapperForREST wrapper = mock(CatalogWrapperForREST.class);
diff --git 
a/iceberg/iceberg-rest-server/src/test/java/org/apache/gravitino/server/web/filter/TestIcebergMetadataAuthorizationMethodInterceptor.java
 
b/iceberg/iceberg-rest-server/src/test/java/org/apache/gravitino/server/web/filter/TestIcebergMetadataAuthorizationMethodInterceptor.java
index 6e2b26fd05..3ad571fd13 100644
--- 
a/iceberg/iceberg-rest-server/src/test/java/org/apache/gravitino/server/web/filter/TestIcebergMetadataAuthorizationMethodInterceptor.java
+++ 
b/iceberg/iceberg-rest-server/src/test/java/org/apache/gravitino/server/web/filter/TestIcebergMetadataAuthorizationMethodInterceptor.java
@@ -322,7 +322,7 @@ public class 
TestIcebergMetadataAuthorizationMethodInterceptor {
     RESTCatalog restCatalog = Mockito.mock(RESTCatalog.class);
     
Mockito.when(wrapperManager.getCatalogWrapper(TEST_CATALOG)).thenReturn(wrapper);
     Mockito.when(wrapper.getCatalog()).thenReturn(restCatalog);
-    Mockito.when(wrapper.isRESTCatalog()).thenReturn(true);
+    Mockito.when(wrapper.forwardsUserToken()).thenReturn(true);
     resetContext(wrapperManager, true);
 
     Method method =
@@ -379,6 +379,24 @@ public class 
TestIcebergMetadataAuthorizationMethodInterceptor {
             Map.of(Entity.EntityType.CATALOG, NameIdentifier.of(TEST_METALAKE, 
"glue_catalog"))));
   }
 
+  @Test
+  public void testServiceAccountRestCatalogDoesNotSkipAuthorization() {
+    // A REST backend connected as a service account presents one identity to 
the remote catalog,
+    // so the remote cannot authorize individual users and this server must.
+    IcebergCatalogWrapperManager wrapperManager = 
Mockito.mock(IcebergCatalogWrapperManager.class);
+    CatalogWrapperForREST wrapper = Mockito.mock(CatalogWrapperForREST.class);
+    
Mockito.when(wrapperManager.getCatalogWrapper(TEST_CATALOG)).thenReturn(wrapper);
+    Mockito.when(wrapper.isRESTCatalog()).thenReturn(true);
+    Mockito.when(wrapper.forwardsUserToken()).thenReturn(false);
+    resetContext(wrapperManager, true);
+
+    IcebergMetadataAuthorizationMethodInterceptor interceptor =
+        new IcebergMetadataAuthorizationMethodInterceptor();
+    assertFalse(
+        interceptor.shouldSkipAuthorization(
+            Map.of(Entity.EntityType.CATALOG, NameIdentifier.of(TEST_METALAKE, 
TEST_CATALOG))));
+  }
+
   @Test
   public void testConfigEndpointIsAuthorizedOnTheWarehouseCatalog() throws 
Exception {
     Assertions.assertTrue(
@@ -420,7 +438,7 @@ public class 
TestIcebergMetadataAuthorizationMethodInterceptor {
     IcebergCatalogWrapperManager wrapperManager = 
Mockito.mock(IcebergCatalogWrapperManager.class);
     CatalogWrapperForREST wrapper = Mockito.mock(CatalogWrapperForREST.class);
     
Mockito.when(wrapperManager.getCatalogWrapper(TEST_CATALOG)).thenReturn(wrapper);
-    Mockito.when(wrapper.isRESTCatalog()).thenReturn(true);
+    Mockito.when(wrapper.forwardsUserToken()).thenReturn(true);
     resetContext(wrapperManager, true);
 
     Method method =
@@ -447,7 +465,7 @@ public class 
TestIcebergMetadataAuthorizationMethodInterceptor {
     Catalog nonRestCatalog = Mockito.mock(Catalog.class);
     
Mockito.when(wrapperManager.getCatalogWrapper(TEST_CATALOG)).thenReturn(wrapper);
     Mockito.when(wrapper.getCatalog()).thenReturn(nonRestCatalog);
-    Mockito.when(wrapper.isRESTCatalog()).thenReturn(false);
+    Mockito.when(wrapper.forwardsUserToken()).thenReturn(false);
     resetContext(wrapperManager, true);
 
     Method method =
@@ -525,7 +543,7 @@ public class 
TestIcebergMetadataAuthorizationMethodInterceptor {
     RESTCatalog restCatalog = Mockito.mock(RESTCatalog.class);
     
Mockito.when(wrapperManager.getCatalogWrapper(TEST_CATALOG)).thenReturn(wrapper);
     Mockito.when(wrapper.getCatalog()).thenReturn(restCatalog);
-    Mockito.when(wrapper.isRESTCatalog()).thenReturn(true);
+    Mockito.when(wrapper.forwardsUserToken()).thenReturn(true);
     resetContext(wrapperManager, false);
 
     Method method =
@@ -560,7 +578,7 @@ public class 
TestIcebergMetadataAuthorizationMethodInterceptor {
     RESTCatalog restCatalog = Mockito.mock(RESTCatalog.class);
     
Mockito.when(wrapperManager.getCatalogWrapper(TEST_CATALOG)).thenReturn(wrapper);
     Mockito.when(wrapper.getCatalog()).thenReturn(restCatalog);
-    Mockito.when(wrapper.isRESTCatalog()).thenReturn(true);
+    Mockito.when(wrapper.forwardsUserToken()).thenReturn(true);
     resetContext(wrapperManager, true);
 
     Method method =

Reply via email to