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 4cccb1327e [#12569] feat(spark-connector): add token auth and key
caches on identity (#12570)
4cccb1327e is described below
commit 4cccb1327e1a082ecf59e0f0ae31e251a1e17a44
Author: Mark Hoerth <[email protected]>
AuthorDate: Sun Aug 23 04:32:34 2026 -0700
[#12569] feat(spark-connector): add token auth and key caches on identity
(#12570)
### What changes were proposed in this pull request?
Two changes to the Spark connector that only make sense together.
**1. Give the caches an eviction policy.** `GravitinoCatalogManager`'s
catalog cache was built with a bare `Caffeine.newBuilder().build()`, so
it had no bound and no expiry. It now expires by write, controlled by
`spark.sql.gravitino.catalogCacheTtlSec`.
**2. Add a `token` auth type, and key the caches on the identity that
token carries.** The single `GravitinoClient` built at driver init
becomes a per-identity cache of clients, bounded and expiring by access,
closing each client on eviction so it does not leak its HTTP connection
pool. The catalog cache key becomes `identity.key() + ":" +
catalogName`.
The token is resolved on every request rather than captured once,
reading the active Spark session's configuration in preference to the
application's, and `spark.sql.gravitino.token.file` in preference to
`spark.sql.gravitino.token.value`. Identity comes from the JWT claim
named by `spark.sql.gravitino.token.principalFields`, defaulting to
`sub`. The signature is not verified, because validating the token is
the server's job and this value only partitions a cache. Opaque, non-JWT
tokens fall back to a hash of the token.
`GravitinoCatalogManager` stays a singleton and
`getGravitinoCatalogInfo(String)` keeps its signature, so `BaseCatalog`
is untouched.
One note for anyone touching shading later: `GravitinoIdentity` parses
the JWT payload with Jackson, and it is the first class in
`spark-common` to do so. The `spark-runtime` jars exclude
`com.fasterxml.jackson` from relocation, so this resolves against the
Jackson that Spark puts on the driver classpath rather than the
relocated copy bundled via `client-java-runtime`. The surface used is
`ObjectMapper.readTree` plus `JsonNode.get/isNull/asText`, stable across
the Jackson versions shipped by Spark 3.3 through 3.5. I verified it by
running the class from the shaded 3.3 runtime jar against only Spark
3.3-era Jackson, with a negative control that fails with
`NoClassDefFoundError` when Jackson is removed.
### Why are the changes needed?
The half that bites today, under any auth type, is the missing eviction.
`loadCatalog` is a REST call the server authorizes; once it has
succeeded, the result is served for the remaining life of the driver and
no further request reaches Gravitino. A grant revoked in Gravitino
therefore keeps being honoured by a running Spark application.
The identity-blind cache key is not exploitable on main, because
`simple`, `basic`, `oauth2` and `kerberos` all resolve exactly one
credential per application, so there is never a second identity to
confuse. It arms the moment a per-user credential mode exists. The
cached value is not inert metadata: `GravitinoMetalake.loadCatalog`
returns `DTOConverters.toCatalog(name, dto, restClient)`, the returned
`RelationalCatalog` stores that client in `BaseSchemaCatalog`'s
`protected final RESTClient restClient`, `asTableCatalog()` returns
`this`, and `listTables` issues its request on that same field. So a
session served another session's cached entry would transmit under the
other session's credential.
That is the argument for landing both halves in one PR. Eviction without
an identity-aware key still mixes users once per-user tokens exist. An
identity-aware key without eviction still serves revoked grants.
There is also no existing way to present a bearer token obtained outside
Spark, which is the gap #11181 describes, and adding one is precisely
what makes the cache key defect live.
Fix: #12569
Related: #11181 and its PR #11182 ask for the bearer-token-file half of
this.
### Does this PR introduce _any_ user-facing change?
New property keys, all optional, no change to existing behaviour:
- `spark.sql.gravitino.authType=token`, a fifth valid value alongside
the existing four.
- `spark.sql.gravitino.token.value` and
`spark.sql.gravitino.token.file`, the file taking precedence.
- `spark.sql.gravitino.token.principalFields`, default `sub`, intended
to match the server's `gravitino.authenticator.oauth.principalFields`.
- `spark.sql.gravitino.clientCacheMaxSize` (100),
`spark.sql.gravitino.clientCacheTtlSec` (3600),
`spark.sql.gravitino.catalogCacheTtlSec` (300).
The property suffixes are added to `AuthProperties` next to
`basic.username` and `oauth2.serverUri`, namespaced by auth type, so the
Trino connector can reuse them.
`simple`, `basic`, `oauth2` and `kerberos` keep a single
application-wide identity and are unaffected, other than the catalog
cache now expiring, which is the intended fix.
Two limits worth stating rather than hiding, both documented in
`docs/spark-connector/spark-connector.md`:
- The catalog list registered with Spark at driver startup is resolved
with the application's identity, so a user may see a catalog name they
are not then allowed to open.
- This governs metadata resolution only. Executors read data with the
credentials the underlying catalog was built with.
### How was this patch tested?
`./gradlew :spark-connector:spark-common:test -PskipITs` is green, 124
tests.
New `TestGravitinoCatalogManager` covers seven cases: two subjects with
different `sub` build two clients and issue two loads; the same `sub`
shares one client and one load; opaque non-JWT tokens partition by token
value; `authType=simple` keeps one identity across two sessions;
`close()` closes every cached client; exceeding `clientCacheMaxSize`
evicts and closes; and a catalog entry past `catalogCacheTtlSec` is
reloaded rather than served stale.
The first of those is the regression test for the cache key. I checked
that it actually bites by reverting `cacheKey` to return the catalog
name alone, which fails four of the seven, then restored it.
New cases in `TestGravitinoDriverPlugin` cover the provider: `Bearer
<token>` as UTF-8 bytes, re-resolution per request so a changed value is
picked up, `token.file` taking precedence and being re-read after a
rewrite, and a missing token failing with a message naming both
properties.
Separately, to confirm that a cached `Catalog` really does carry the
client that loaded it rather than re-resolving one, I ran a throwaway
probe against the `client-java` MockServer harness: two clients with
distinct bearer tokens, catalog loaded by A, handed to B through a plain
map, `listTables()` called by B. The request went out as `Bearer
TOKEN-ALICE`, while the control of B calling through its own client went
out as `Bearer TOKEN-BOB`. That probe is not included here, since it
tests client behaviour that is working as designed.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
https://claude.ai/code/session_01WAqUbBPofTzWbFYD8EGu1R
Co-authored-by: Mark Hoerth <[email protected]>
Co-authored-by: Claude Opus 5 (1M context) <[email protected]>
---
.../org/apache/gravitino/auth/AuthProperties.java | 20 ++
docs/spark-connector/spark-connector.md | 30 +++
.../spark/connector/GravitinoSparkConfig.java | 18 ++
.../connector/catalog/GravitinoCatalogManager.java | 176 +++++++++++--
.../spark/connector/catalog/GravitinoIdentity.java | 141 ++++++++++
.../connector/plugin/GravitinoDriverPlugin.java | 86 ++++++-
.../catalog/TestBaseCatalogAuthorization.java | 4 +-
.../catalog/TestGravitinoCatalogManager.java | 283 +++++++++++++++++++++
.../connector/glue/TestGravitinoGlueCatalog.java | 3 +-
.../plugin/TestGravitinoDriverPlugin.java | 89 +++++++
10 files changed, 828 insertions(+), 22 deletions(-)
diff --git
a/catalogs/catalog-common/src/main/java/org/apache/gravitino/auth/AuthProperties.java
b/catalogs/catalog-common/src/main/java/org/apache/gravitino/auth/AuthProperties.java
index 160b9d29b5..f2f6e0d069 100644
---
a/catalogs/catalog-common/src/main/java/org/apache/gravitino/auth/AuthProperties.java
+++
b/catalogs/catalog-common/src/main/java/org/apache/gravitino/auth/AuthProperties.java
@@ -28,6 +28,7 @@ public class AuthProperties {
public static final String BASIC_AUTH_TYPE = "basic";
public static final String OAUTH2_AUTH_TYPE = "oauth2";
public static final String KERBEROS_AUTH_TYPE = "kerberos";
+ public static final String TOKEN_AUTH_TYPE = "token";
/** The configuration key for the built-in IdP username used in Basic
authentication. */
public static final String GRAVITINO_BASIC_USERNAME = "basic.username";
@@ -48,6 +49,21 @@ public class AuthProperties {
/** The configuration key for the scope of the token. */
public static final String GRAVITINO_OAUTH2_SCOPE = "oauth2.scope";
+ // token
+ /** The configuration key for the bearer token presented in Token
authentication. */
+ public static final String GRAVITINO_TOKEN_VALUE = "token.value";
+
+ /**
+ * The configuration key for the file holding the bearer token presented in
Token authentication.
+ */
+ public static final String GRAVITINO_TOKEN_FILE = "token.file";
+
+ /**
+ * The configuration key for the ordered claim names identifying the caller
carried in the bearer
+ * token. Mirrors the server's {@code
gravitino.authenticator.oauth.principalFields}.
+ */
+ public static final String GRAVITINO_TOKEN_PRINCIPAL_FIELDS =
"token.principalFields";
+
public static boolean isKerberos(String authType) {
return KERBEROS_AUTH_TYPE.equalsIgnoreCase(authType);
}
@@ -64,5 +80,9 @@ public class AuthProperties {
return BASIC_AUTH_TYPE.equalsIgnoreCase(authType);
}
+ public static boolean isToken(String authType) {
+ return TOKEN_AUTH_TYPE.equalsIgnoreCase(authType);
+ }
+
private AuthProperties() {}
}
diff --git a/docs/spark-connector/spark-connector.md
b/docs/spark-connector/spark-connector.md
index 5a3d8562c1..704a62e469 100644
--- a/docs/spark-connector/spark-connector.md
+++ b/docs/spark-connector/spark-connector.md
@@ -34,6 +34,13 @@ The Apache Gravitino Spark connector leverages the Spark
DataSourceV2 interface
| spark.sql.gravitino.enableIcebergSupport | string | `false` | Set to
`true` to use Iceberg catalog.
| No |
| spark.sql.gravitino.enablePaimonSupport | string | `false` | Set to
`true` to use Paimon catalog.
| No |
| spark.sql.gravitino.client. | string | (none) | The
configuration key prefix for the Gravitino client config.
| No |
+| spark.sql.gravitino.authType | string | `simple` | The
authentication type, one of `simple`, `basic`, `oauth2`, `kerberos`, `token`.
| No |
+| spark.sql.gravitino.token.value | string | (none) | The
bearer token to present to Gravitino, used when `authType` is `token`.
| No |
+| spark.sql.gravitino.token.file | string | (none) | Path to
a file holding the bearer token. Takes precedence over `token.value` and is
re-read per request. | No |
+| spark.sql.gravitino.token.principalFields | string | `sub` | Comma
separated, ordered JWT claim names used to identify the caller in `token` mode.
| No |
+| spark.sql.gravitino.clientCacheMaxSize | int | `100` | The
maximum number of Gravitino clients cached, one per identity.
| No |
+| spark.sql.gravitino.clientCacheTtlSec | long | `3600` | Evicts a
cached Gravitino client this many seconds after it was last used.
| No |
+| spark.sql.gravitino.catalogCacheTtlSec | long | `300` | Evicts a
cached catalog this many seconds after it was loaded.
| No |
To configure the Gravitino client, use properties prefixed with
`spark.sql.gravitino.client.`. These properties will be passed to the Gravitino
client after removing the `spark.sql.` prefix.
@@ -41,6 +48,29 @@ To configure the Gravitino client, use properties prefixed
with `spark.sql.gravi
**Note:** Invalid configuration properties will result in exceptions. Please
see [Gravitino Java client
configurations](../how-to-use-gravitino-client.md#java-client-configuration)
for more support client configuration.
+### Per-user identity in `token` mode
+
+When `spark.sql.gravitino.authType` is `token`, the connector presents the
bearer token found in
+`spark.sql.gravitino.token.file`, or failing that
`spark.sql.gravitino.token.value`, resolving it again on
+every request and reading the active Spark session's configuration in
preference to the
+application's. The identity of the caller is taken from the JWT claim named by
+`spark.sql.gravitino.token.principalFields`, which should be set to match the
server's
+`gravitino.authenticator.oauth.principalFields` so that the connector and the
server agree on who
+is asking. The claim is read without verifying the signature, because
validating the token remains
+the server's job; the value is used only to partition the connector's caches.
An opaque,
+non-JWT token is partitioned by a hash of the token instead.
+
+Gravitino clients and the catalog metadata they load are cached per identity,
so two users sharing
+one Spark driver never see each other's cached metadata. All other
authentication types keep a
+single application-wide identity and are unaffected by any of this.
+
+Two limits are worth stating plainly:
+
+* The list of catalogs registered with Spark at driver startup is resolved
with the application's
+ identity, so a user may see a catalog name that they are not then allowed to
open.
+* This governs metadata resolution only. Data is read by the executors using
the credentials that
+ the underlying catalog was built with, not the end user's token.
+
```shell
./bin/spark-sql -v \
--conf
spark.plugins="org.apache.gravitino.spark.connector.plugin.GravitinoSparkPlugin"
\
diff --git
a/spark-connector/spark-common/src/main/java/org/apache/gravitino/spark/connector/GravitinoSparkConfig.java
b/spark-connector/spark-common/src/main/java/org/apache/gravitino/spark/connector/GravitinoSparkConfig.java
index be108c9af1..ed82ed23d9 100644
---
a/spark-connector/spark-common/src/main/java/org/apache/gravitino/spark/connector/GravitinoSparkConfig.java
+++
b/spark-connector/spark-common/src/main/java/org/apache/gravitino/spark/connector/GravitinoSparkConfig.java
@@ -49,6 +49,24 @@ public class GravitinoSparkConfig {
public static final String GRAVITINO_KERBEROS_PRINCIPAL =
"spark.kerberos.principal";
public static final String GRAVITINO_KERBEROS_KEYTAB_FILE_PATH =
"spark.kerberos.keytab";
+ public static final String GRAVITINO_TOKEN_VALUE =
+ GRAVITINO_PREFIX + AuthProperties.GRAVITINO_TOKEN_VALUE;
+ public static final String GRAVITINO_TOKEN_FILE =
+ GRAVITINO_PREFIX + AuthProperties.GRAVITINO_TOKEN_FILE;
+ public static final String GRAVITINO_TOKEN_PRINCIPAL_FIELDS =
+ GRAVITINO_PREFIX + AuthProperties.GRAVITINO_TOKEN_PRINCIPAL_FIELDS;
+ public static final String GRAVITINO_TOKEN_PRINCIPAL_FIELDS_DEFAULT = "sub";
+
+ public static final String GRAVITINO_CLIENT_CACHE_MAX_SIZE =
+ GRAVITINO_PREFIX + "clientCacheMaxSize";
+ public static final int GRAVITINO_CLIENT_CACHE_MAX_SIZE_DEFAULT = 100;
+ public static final String GRAVITINO_CLIENT_CACHE_TTL_SEC =
+ GRAVITINO_PREFIX + "clientCacheTtlSec";
+ public static final long GRAVITINO_CLIENT_CACHE_TTL_SEC_DEFAULT = 3600;
+ public static final String GRAVITINO_CATALOG_CACHE_TTL_SEC =
+ GRAVITINO_PREFIX + "catalogCacheTtlSec";
+ public static final long GRAVITINO_CATALOG_CACHE_TTL_SEC_DEFAULT = 300;
+
public static final String GRAVITINO_HIVE_METASTORE_URI = "metastore.uris";
public static final String SPARK_HIVE_METASTORE_URI = "hive.metastore.uris";
diff --git
a/spark-connector/spark-common/src/main/java/org/apache/gravitino/spark/connector/catalog/GravitinoCatalogManager.java
b/spark-connector/spark-common/src/main/java/org/apache/gravitino/spark/connector/catalog/GravitinoCatalogManager.java
index a484972656..21b7c520c8 100644
---
a/spark-connector/spark-common/src/main/java/org/apache/gravitino/spark/connector/catalog/GravitinoCatalogManager.java
+++
b/spark-connector/spark-common/src/main/java/org/apache/gravitino/spark/connector/catalog/GravitinoCatalogManager.java
@@ -20,34 +20,110 @@ package org.apache.gravitino.spark.connector.catalog;
import com.github.benmanes.caffeine.cache.Cache;
import com.github.benmanes.caffeine.cache.Caffeine;
+import com.google.common.annotations.VisibleForTesting;
import com.google.common.base.Preconditions;
-import com.google.common.base.Supplier;
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableMap;
+import java.time.Duration;
import java.util.Arrays;
+import java.util.List;
import java.util.Map;
+import java.util.function.Function;
+import java.util.stream.Collectors;
import org.apache.gravitino.Catalog;
+import org.apache.gravitino.auth.AuthProperties;
import org.apache.gravitino.client.GravitinoClient;
+import org.apache.gravitino.spark.connector.ConnectorConstants;
+import org.apache.gravitino.spark.connector.GravitinoSparkConfig;
+import org.apache.gravitino.spark.connector.plugin.GravitinoDriverPlugin;
+import org.apache.spark.SparkConf;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
-/** GravitinoCatalogManager is used to retrieve catalogs from Apache Gravitino
server. */
+/**
+ * GravitinoCatalogManager is used to retrieve catalogs from Apache Gravitino
server.
+ *
+ * <p>The manager itself stays a JVM-wide singleton, but the clients inside it
are cached per {@link
+ * GravitinoIdentity} so that a shared Spark driver does not serve one user's
catalog metadata to
+ * another. Every auth type other than {@code token} resolves to a single
application-wide identity,
+ * which keeps those deployments behaving exactly as before.
+ */
public class GravitinoCatalogManager {
private static final Logger LOG =
LoggerFactory.getLogger(GravitinoCatalogManager.class);
private static GravitinoCatalogManager gravitinoCatalogManager;
private volatile boolean isClosed = false;
+ private final SparkConf sparkConf;
+ private final String authType;
+ private final List<String> principalFields;
+ private final Function<GravitinoIdentity, GravitinoClient> clientBuilder;
+ private final Cache<GravitinoIdentity, GravitinoClient> clients;
private final Cache<String, Catalog> gravitinoCatalogs;
- private final GravitinoClient gravitinoClient;
+ private volatile Map<String, Catalog> applicationCatalogs =
ImmutableMap.of();
- private GravitinoCatalogManager(Supplier<GravitinoClient> clientBuilder) {
- this.gravitinoClient = clientBuilder.get();
- // Will not evict catalog by default
- this.gravitinoCatalogs = Caffeine.newBuilder().build();
+ private GravitinoCatalogManager(
+ SparkConf sparkConf, Function<GravitinoIdentity, GravitinoClient>
clientBuilder) {
+ this.sparkConf = sparkConf;
+ this.clientBuilder = clientBuilder;
+ this.authType =
+ sparkConf.get(GravitinoSparkConfig.GRAVITINO_AUTH_TYPE,
AuthProperties.SIMPLE_AUTH_TYPE);
+ this.principalFields =
+ Arrays.stream(
+ sparkConf
+ .get(
+ GravitinoSparkConfig.GRAVITINO_TOKEN_PRINCIPAL_FIELDS,
+
GravitinoSparkConfig.GRAVITINO_TOKEN_PRINCIPAL_FIELDS_DEFAULT)
+ .split(ConnectorConstants.COMMA))
+ .map(String::trim)
+ .filter(field -> !field.isEmpty())
+ .collect(ImmutableList.toImmutableList());
+ this.clients =
+ Caffeine.newBuilder()
+ .maximumSize(
+ sparkConf.getInt(
+ GravitinoSparkConfig.GRAVITINO_CLIENT_CACHE_MAX_SIZE,
+
GravitinoSparkConfig.GRAVITINO_CLIENT_CACHE_MAX_SIZE_DEFAULT))
+ .expireAfterAccess(
+ Duration.ofSeconds(
+ sparkConf.getLong(
+ GravitinoSparkConfig.GRAVITINO_CLIENT_CACHE_TTL_SEC,
+
GravitinoSparkConfig.GRAVITINO_CLIENT_CACHE_TTL_SEC_DEFAULT)))
+ // An evicted client still owns an HTTP connection pool, so it
must be closed.
+ .<GravitinoIdentity, GravitinoClient>removalListener(
+ (identity, client, cause) -> closeClient(identity, client))
+ .build();
+ this.gravitinoCatalogs =
+ Caffeine.newBuilder()
+ // A permission revoked in Gravitino must eventually stop being
served from cache.
+ .expireAfterWrite(
+ Duration.ofSeconds(
+ sparkConf.getLong(
+ GravitinoSparkConfig.GRAVITINO_CATALOG_CACHE_TTL_SEC,
+
GravitinoSparkConfig.GRAVITINO_CATALOG_CACHE_TTL_SEC_DEFAULT)))
+ .build();
}
- public static GravitinoCatalogManager create(Supplier<GravitinoClient>
clientBuilder) {
+ /**
+ * Creates the singleton GravitinoCatalogManager.
+ *
+ * @param sparkConf the application Spark configuration, used to read the
cache settings and, in
+ * {@code token} mode, to resolve the bearer token of the current request
+ * @param applicationUser the user the Spark application runs as, recorded
for diagnostics only
+ * and never used to derive an identity
+ * @param clientBuilder builds a Gravitino client for a given identity
+ * @return the created GravitinoCatalogManager
+ */
+ public static GravitinoCatalogManager create(
+ SparkConf sparkConf,
+ String applicationUser,
+ Function<GravitinoIdentity, GravitinoClient> clientBuilder) {
Preconditions.checkState(
gravitinoCatalogManager == null, "Should not create duplicate
GravitinoCatalogManager");
- gravitinoCatalogManager = new GravitinoCatalogManager(clientBuilder);
+ gravitinoCatalogManager = new GravitinoCatalogManager(sparkConf,
clientBuilder);
+ LOG.info(
+ "Created GravitinoCatalogManager for Spark user {} with auth type {}.",
+ applicationUser,
+ gravitinoCatalogManager.authType);
return gravitinoCatalogManager;
}
@@ -62,35 +138,97 @@ public class GravitinoCatalogManager {
public void close() {
Preconditions.checkState(!isClosed, "Gravitino Catalog is already closed");
isClosed = true;
- gravitinoClient.close();
+ // Caffeine dispatches the removal listener asynchronously, so shutdown
closes explicitly.
+ clients.asMap().forEach(GravitinoCatalogManager::closeClient);
+ clients.invalidateAll();
+ gravitinoCatalogs.invalidateAll();
+ applicationCatalogs = ImmutableMap.of();
gravitinoCatalogManager = null;
}
public Catalog getGravitinoCatalogInfo(String name) {
+ GravitinoIdentity identity = currentIdentity();
try {
- return gravitinoCatalogs.get(name, catalogName ->
loadCatalog(catalogName));
+ return gravitinoCatalogs.get(cacheKey(identity, name), key ->
loadCatalog(identity, name));
} catch (Exception e) {
LOG.error(String.format("Load catalog %s failed", name), e);
throw new RuntimeException(e);
}
}
+ /**
+ * Loads the relational catalogs visible to the application identity. This
runs at driver init,
+ * before any Spark session exists, so it never consults session state.
+ */
public void loadRelationalCatalogs() {
- Catalog[] catalogs = gravitinoClient.listCatalogsInfo();
- Arrays.stream(catalogs)
- .filter(catalog -> Catalog.Type.RELATIONAL.equals(catalog.type()))
- .forEach(catalog -> gravitinoCatalogs.put(catalog.name(), catalog));
+ GravitinoIdentity identity = applicationIdentity();
+ Catalog[] catalogs = getClient(identity).listCatalogsInfo();
+ Map<String, Catalog> relationalCatalogs =
+ Arrays.stream(catalogs)
+ .filter(catalog -> Catalog.Type.RELATIONAL.equals(catalog.type()))
+ .collect(
+ Collectors.toMap(Catalog::name, catalog -> catalog, (first,
second) -> second));
+ relationalCatalogs.forEach(
+ (name, catalog) -> gravitinoCatalogs.put(cacheKey(identity, name),
catalog));
+ this.applicationCatalogs = ImmutableMap.copyOf(relationalCatalogs);
}
+ /**
+ * Returns the catalogs loaded by {@link #loadRelationalCatalogs()}, that
is, the catalogs the
+ * application identity can see.
+ *
+ * @return the catalogs registered at driver startup, keyed by catalog name
+ */
public Map<String, Catalog> getCatalogs() {
- return gravitinoCatalogs.asMap();
+ return applicationCatalogs;
+ }
+
+ /**
+ * Resolves the identity of the caller. Outside {@code token} mode this is
the application
+ * identity and no session state is consulted at all. In {@code token} mode
the bearer token is
+ * read exactly as the request-time provider reads it, active session
configuration first and
+ * application configuration second, and the identity is derived from that
token alone.
+ *
+ * @return the identity of the current caller
+ */
+ @VisibleForTesting
+ GravitinoIdentity currentIdentity() {
+ if (!AuthProperties.isToken(authType)) {
+ return applicationIdentity();
+ }
+ return GravitinoIdentity.fromToken(
+ GravitinoDriverPlugin.resolveToken(sparkConf), principalFields);
+ }
+
+ @VisibleForTesting
+ GravitinoClient getClient(GravitinoIdentity identity) {
+ return clients.get(identity, clientBuilder);
+ }
+
+ private GravitinoIdentity applicationIdentity() {
+ return GravitinoIdentity.application(authType);
}
- private Catalog loadCatalog(String catalogName) {
- Catalog catalog = gravitinoClient.loadCatalog(catalogName);
+ private Catalog loadCatalog(GravitinoIdentity identity, String catalogName) {
+ Catalog catalog = getClient(identity).loadCatalog(catalogName);
Preconditions.checkArgument(
Catalog.Type.RELATIONAL.equals(catalog.type()), "Only support
relational catalog");
- LOG.info("Load catalog {} from Gravitino successfully.", catalogName);
+ LOG.info("Load catalog {} from Gravitino successfully for {}.",
catalogName, identity);
return catalog;
}
+
+ private static String cacheKey(GravitinoIdentity identity, String
catalogName) {
+ return identity.key() + ":" + catalogName;
+ }
+
+ private static void closeClient(GravitinoIdentity identity, GravitinoClient
client) {
+ if (client == null) {
+ return;
+ }
+ try {
+ client.close();
+ } catch (Exception e) {
+ LOG.warn("Failed to close the Gravitino client of {}.", identity, e);
+ }
+ }
}
diff --git
a/spark-connector/spark-common/src/main/java/org/apache/gravitino/spark/connector/catalog/GravitinoIdentity.java
b/spark-connector/spark-common/src/main/java/org/apache/gravitino/spark/connector/catalog/GravitinoIdentity.java
new file mode 100644
index 0000000000..050ef15825
--- /dev/null
+++
b/spark-connector/spark-common/src/main/java/org/apache/gravitino/spark/connector/catalog/GravitinoIdentity.java
@@ -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.
+ */
+package org.apache.gravitino.spark.connector.catalog;
+
+import com.fasterxml.jackson.databind.JsonNode;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.google.common.hash.Hashing;
+import java.nio.charset.StandardCharsets;
+import java.util.Base64;
+import java.util.List;
+import java.util.Objects;
+import org.apache.commons.lang3.StringUtils;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * The identity a Gravitino request is made on behalf of. It exists only to
partition the client and
+ * catalog caches held by {@link GravitinoCatalogManager}; it is never
presented to the Gravitino
+ * server and never used to make a trust decision.
+ *
+ * <p>The safety property this class must uphold is that the cache key is at
least as fine-grained
+ * as the principal the Gravitino server derives from the same credential, and
never exactly equal
+ * to it in the sense of being coarser. A key finer than the server's
principal costs a redundant
+ * cache entry and nothing more. A key coarser than the server's principal
would let one user be
+ * served another user's cached metadata, which is precisely the bug this
class exists to prevent.
+ * When in doubt, be finer.
+ *
+ * <p>For that reason the claim read out of a JWT is taken without verifying
the signature.
+ * Validating the token is the server's job, and a value used only to split a
cache must not be
+ * mistaken for a verified assertion about who is calling.
+ */
+public final class GravitinoIdentity {
+
+ private static final Logger LOG =
LoggerFactory.getLogger(GravitinoIdentity.class);
+
+ private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper();
+
+ /** Prefix of every token-derived key, so it can never collide with an
auth-type key. */
+ private static final String TOKEN_KEY_PREFIX = "token:";
+
+ private static final String HASHED_KEY_PREFIX = TOKEN_KEY_PREFIX + "sha256:";
+
+ private static final int JWT_PART_COUNT = 3;
+
+ private final String key;
+
+ private GravitinoIdentity(String key) {
+ this.key = key;
+ }
+
+ /**
+ * Returns the single identity shared by every auth type that resolves one
credential for the
+ * whole Spark application, namely {@code simple}, {@code basic}, {@code
oauth2} and {@code
+ * kerberos}. Those deployments keep exactly one identity per application.
+ *
+ * @param authType the configured auth type, used verbatim as the key
+ * @return the application-wide identity for that auth type
+ */
+ public static GravitinoIdentity application(String authType) {
+ return new GravitinoIdentity(String.valueOf(authType));
+ }
+
+ /**
+ * Derives an identity from a bearer token.
+ *
+ * <p>When the token is a three-part JWT whose payload parses, the first of
{@code
+ * principalFields} that is present and non-blank supplies the key.
Otherwise, and for opaque
+ * access tokens generally, the key falls back to the hex SHA-256 of the
token. The signature is
+ * never verified.
+ *
+ * @param token the bearer token, never logged and never stored on the
returned identity
+ * @param principalFields the ordered claim names to try, mirroring the
server's {@code
+ * gravitino.authenticator.oauth.principalFields}
+ * @return an identity suitable for use as a cache key
+ */
+ public static GravitinoIdentity fromToken(String token, List<String>
principalFields) {
+ String[] parts = token.split("\\.");
+ if (parts.length == JWT_PART_COUNT) {
+ try {
+ JsonNode payload =
OBJECT_MAPPER.readTree(Base64.getUrlDecoder().decode(parts[1].trim()));
+ for (String field : principalFields) {
+ JsonNode value = payload.get(field);
+ if (value != null && !value.isNull() &&
StringUtils.isNotBlank(value.asText())) {
+ return new GravitinoIdentity(TOKEN_KEY_PREFIX + field + ":" +
value.asText());
+ }
+ }
+ LOG.debug("No principal field of {} found in the token payload.",
principalFields);
+ } catch (Exception e) {
+ LOG.debug("Cannot parse the token payload as a JWT, falling back to a
token hash.", e);
+ }
+ }
+ return new GravitinoIdentity(
+ HASHED_KEY_PREFIX + Hashing.sha256().hashString(token,
StandardCharsets.UTF_8));
+ }
+
+ /**
+ * Returns the cache key of this identity. It never is, and never contains,
a raw token.
+ *
+ * @return the cache key
+ */
+ public String key() {
+ return key;
+ }
+
+ @Override
+ public boolean equals(Object o) {
+ if (this == o) {
+ return true;
+ }
+ if (!(o instanceof GravitinoIdentity)) {
+ return false;
+ }
+ return Objects.equals(key, ((GravitinoIdentity) o).key);
+ }
+
+ @Override
+ public int hashCode() {
+ return Objects.hash(key);
+ }
+
+ @Override
+ public String toString() {
+ return "GravitinoIdentity{key=" + key + "}";
+ }
+}
diff --git
a/spark-connector/spark-common/src/main/java/org/apache/gravitino/spark/connector/plugin/GravitinoDriverPlugin.java
b/spark-connector/spark-common/src/main/java/org/apache/gravitino/spark/connector/plugin/GravitinoDriverPlugin.java
index 90e7830bff..c27f78dce4 100644
---
a/spark-connector/spark-common/src/main/java/org/apache/gravitino/spark/connector/plugin/GravitinoDriverPlugin.java
+++
b/spark-connector/spark-common/src/main/java/org/apache/gravitino/spark/connector/plugin/GravitinoDriverPlugin.java
@@ -27,6 +27,11 @@ import com.google.common.annotations.VisibleForTesting;
import com.google.common.base.Preconditions;
import com.google.common.collect.ImmutableMap;
import java.io.File;
+import java.io.IOException;
+import java.io.UncheckedIOException;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.Paths;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collections;
@@ -40,6 +45,7 @@ import javax.annotation.Nullable;
import org.apache.commons.lang3.StringUtils;
import org.apache.gravitino.Catalog;
import org.apache.gravitino.auth.AuthProperties;
+import org.apache.gravitino.client.CustomTokenProvider;
import org.apache.gravitino.client.DefaultOAuth2TokenProvider;
import org.apache.gravitino.client.GravitinoClient;
import org.apache.gravitino.client.GravitinoClient.ClientBuilder;
@@ -55,9 +61,11 @@ import org.apache.spark.SparkConf;
import org.apache.spark.SparkContext;
import org.apache.spark.api.plugin.DriverPlugin;
import org.apache.spark.api.plugin.PluginContext;
+import org.apache.spark.sql.SparkSession;
import org.apache.spark.sql.internal.StaticSQLConf;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
+import scala.Option;
/**
* GravitinoDriverPlugin creates GravitinoCatalogManager to fetch catalogs
from Apache Gravitino and
@@ -115,7 +123,9 @@ public class GravitinoDriverPlugin implements DriverPlugin {
this.catalogManager =
GravitinoCatalogManager.create(
- () ->
+ conf,
+ sc.sparkUser(),
+ identity ->
createGravitinoClient(
gravitinoUri, metalake, conf, sc.sparkUser(),
gravitinoClientConfig));
catalogManager.loadRelationalCatalogs();
@@ -249,6 +259,8 @@ public class GravitinoDriverPlugin implements DriverPlugin {
.withKeyTabFile(new File(keyTabFile))
.build();
builder.withKerberosAuth(kerberosTokenProvider);
+ } else if (AuthProperties.isToken(authType)) {
+ builder.withCustomTokenAuth(new DynamicBearerTokenProvider(sparkConf));
} else {
throw new UnsupportedOperationException("Unsupported auth type: " +
authType);
}
@@ -267,6 +279,78 @@ public class GravitinoDriverPlugin implements DriverPlugin
{
return sparkConf.get(configKey, null);
}
+ /**
+ * Resolves the bearer token to present to Gravitino for the operation
running on the current
+ * thread.
+ *
+ * <p>The active Spark session's configuration wins over the application
configuration, so a
+ * shared driver can carry a different end user's token per session, and
{@code tokenFile} wins
+ * over {@code token}, so a token refreshed on disk is picked up without
touching the
+ * configuration. Neither the token nor any prefix of it is ever logged.
+ *
+ * @param sparkConf the application Spark configuration, used when no
session supplies a token
+ * @return the bearer token, never blank
+ * @throws IllegalArgumentException if neither token property is set
+ * @throws UncheckedIOException if the configured token file cannot be read
+ */
+ public static String resolveToken(SparkConf sparkConf) {
+ String tokenFile =
+ getSessionOrApplicationConfig(sparkConf,
GravitinoSparkConfig.GRAVITINO_TOKEN_FILE);
+ String token =
+ StringUtils.isNotBlank(tokenFile)
+ ? readTokenFile(tokenFile)
+ : getSessionOrApplicationConfig(sparkConf,
GravitinoSparkConfig.GRAVITINO_TOKEN_VALUE);
+ Preconditions.checkArgument(
+ StringUtils.isNotBlank(token),
+ String.format(
+ "Either %s or %s should be set when %s is %s",
+ GravitinoSparkConfig.GRAVITINO_TOKEN_VALUE,
+ GravitinoSparkConfig.GRAVITINO_TOKEN_FILE,
+ GravitinoSparkConfig.GRAVITINO_AUTH_TYPE,
+ AuthProperties.TOKEN_AUTH_TYPE));
+ return token.trim();
+ }
+
+ private static String readTokenFile(String tokenFile) {
+ try {
+ return new String(Files.readAllBytes(Paths.get(tokenFile)),
StandardCharsets.UTF_8).trim();
+ } catch (IOException e) {
+ throw new UncheckedIOException("Failed to read the Gravitino token file
" + tokenFile, e);
+ }
+ }
+
+ @Nullable
+ private static String getSessionOrApplicationConfig(SparkConf sparkConf,
String configKey) {
+ Option<SparkSession> activeSession = SparkSession.getActiveSession();
+ if (activeSession.isDefined()) {
+ Option<String> sessionValue =
activeSession.get().conf().getOption(configKey);
+ if (sessionValue.isDefined() &&
StringUtils.isNotBlank(sessionValue.get())) {
+ return sessionValue.get();
+ }
+ }
+ return getOptionalConfig(sparkConf, configKey);
+ }
+
+ /**
+ * Presents a bearer token that is resolved again on every request rather
than captured once. See
+ * {@link #resolveToken(SparkConf)} for where the token comes from.
+ */
+ @VisibleForTesting
+ static final class DynamicBearerTokenProvider extends CustomTokenProvider {
+
+ private final SparkConf sparkConf;
+
+ DynamicBearerTokenProvider(SparkConf sparkConf) {
+ this.sparkConf = sparkConf;
+ this.schemeName = "Bearer";
+ }
+
+ @Override
+ protected String getCustomTokenInfo() {
+ return resolveToken(sparkConf);
+ }
+ }
+
@VisibleForTesting
public static Map<String, String> extractGravitinoClientConfig(SparkConf
conf) {
return
Optional.ofNullable(conf.getAllWithPrefix(GRAVITINO_CLIENT_CONFIG_PREFIX))
diff --git
a/spark-connector/spark-common/src/test/java/org/apache/gravitino/spark/connector/catalog/TestBaseCatalogAuthorization.java
b/spark-connector/spark-common/src/test/java/org/apache/gravitino/spark/connector/catalog/TestBaseCatalogAuthorization.java
index 37ef2c501a..a15e4dfdd4 100644
---
a/spark-connector/spark-common/src/test/java/org/apache/gravitino/spark/connector/catalog/TestBaseCatalogAuthorization.java
+++
b/spark-connector/spark-common/src/test/java/org/apache/gravitino/spark/connector/catalog/TestBaseCatalogAuthorization.java
@@ -39,6 +39,7 @@ import
org.apache.gravitino.spark.connector.PropertiesConverter;
import org.apache.gravitino.spark.connector.SparkTransformConverter;
import org.apache.gravitino.spark.connector.SparkTypeConverter;
import org.apache.gravitino.spark.connector.authorization.AuthorizationTable;
+import org.apache.spark.SparkConf;
import org.apache.spark.sql.connector.catalog.Identifier;
import org.apache.spark.sql.connector.catalog.Table;
import org.apache.spark.sql.util.CaseInsensitiveStringMap;
@@ -59,7 +60,8 @@ public class TestBaseCatalogAuthorization {
@BeforeAll
void initCatalogManager() {
- GravitinoCatalogManager.create(() -> mock(GravitinoClient.class));
+ GravitinoCatalogManager.create(
+ new SparkConf(false), "user", identity -> mock(GravitinoClient.class));
}
@AfterAll
diff --git
a/spark-connector/spark-common/src/test/java/org/apache/gravitino/spark/connector/catalog/TestGravitinoCatalogManager.java
b/spark-connector/spark-common/src/test/java/org/apache/gravitino/spark/connector/catalog/TestGravitinoCatalogManager.java
new file mode 100644
index 0000000000..3a8eec8f84
--- /dev/null
+++
b/spark-connector/spark-common/src/test/java/org/apache/gravitino/spark/connector/catalog/TestGravitinoCatalogManager.java
@@ -0,0 +1,283 @@
+/*
+ * 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.gravitino.spark.connector.catalog;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNotSame;
+import static org.junit.jupiter.api.Assertions.assertSame;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.ArgumentMatchers.anyString;
+import static org.mockito.Mockito.doAnswer;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+
+import java.nio.charset.StandardCharsets;
+import java.util.ArrayList;
+import java.util.Base64;
+import java.util.List;
+import java.util.concurrent.atomic.AtomicBoolean;
+import java.util.concurrent.atomic.AtomicInteger;
+import java.util.function.BooleanSupplier;
+import java.util.function.Function;
+import org.apache.gravitino.Catalog;
+import org.apache.gravitino.auth.AuthProperties;
+import org.apache.gravitino.client.GravitinoClient;
+import org.apache.gravitino.spark.connector.GravitinoSparkConfig;
+import org.apache.spark.SparkConf;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.Test;
+
+/**
+ * Verifies that GravitinoCatalogManager partitions its client and catalog
caches by the identity
+ * carried in the bearer token, and that no other auth type changes behaviour.
+ */
+public class TestGravitinoCatalogManager {
+
+ private static final String CATALOG_NAME = "test_catalog";
+
+ private ClientFactory clientFactory;
+
+ @AfterEach
+ void closeManager() {
+ try {
+ GravitinoCatalogManager.get().close();
+ } catch (IllegalStateException e) {
+ // The test closed the manager itself.
+ }
+ }
+
+ @Test
+ void testDifferentSubjectsDoNotShareCatalogCache() {
+ SparkConf sparkConf = tokenConf();
+ GravitinoCatalogManager manager = createManager(sparkConf);
+
+ sparkConf.set(GravitinoSparkConfig.GRAVITINO_TOKEN_VALUE, jwt("alice"));
+ Catalog aliceCatalog = manager.getGravitinoCatalogInfo(CATALOG_NAME);
+ sparkConf.set(GravitinoSparkConfig.GRAVITINO_TOKEN_VALUE, jwt("bob"));
+ Catalog bobCatalog = manager.getGravitinoCatalogInfo(CATALOG_NAME);
+
+ assertEquals(2, clientFactory.clientCount());
+ assertEquals(2, clientFactory.loadCount());
+ assertNotSame(aliceCatalog, bobCatalog);
+ }
+
+ @Test
+ void testSameSubjectSharesClientAndCatalogCache() {
+ SparkConf sparkConf = tokenConf();
+ GravitinoCatalogManager manager = createManager(sparkConf);
+
+ sparkConf.set(GravitinoSparkConfig.GRAVITINO_TOKEN_VALUE, jwt("alice",
"first"));
+ Catalog first = manager.getGravitinoCatalogInfo(CATALOG_NAME);
+ sparkConf.set(GravitinoSparkConfig.GRAVITINO_TOKEN_VALUE, jwt("alice",
"second"));
+ Catalog second = manager.getGravitinoCatalogInfo(CATALOG_NAME);
+
+ assertEquals(1, clientFactory.clientCount());
+ assertEquals(1, clientFactory.loadCount());
+ assertSame(first, second);
+ }
+
+ @Test
+ void testOpaqueTokensArePartitionedByTokenValue() {
+ SparkConf sparkConf = tokenConf();
+ GravitinoCatalogManager manager = createManager(sparkConf);
+
+ sparkConf.set(GravitinoSparkConfig.GRAVITINO_TOKEN_VALUE,
"opaque-token-one");
+ manager.getGravitinoCatalogInfo(CATALOG_NAME);
+ sparkConf.set(GravitinoSparkConfig.GRAVITINO_TOKEN_VALUE,
"opaque-token-one");
+ manager.getGravitinoCatalogInfo(CATALOG_NAME);
+
+ assertEquals(1, clientFactory.clientCount());
+ assertEquals(1, clientFactory.loadCount());
+
+ sparkConf.set(GravitinoSparkConfig.GRAVITINO_TOKEN_VALUE,
"opaque-token-two");
+ manager.getGravitinoCatalogInfo(CATALOG_NAME);
+
+ assertEquals(2, clientFactory.clientCount());
+ assertEquals(2, clientFactory.loadCount());
+ }
+
+ @Test
+ void testSimpleAuthKeepsOneApplicationIdentity() {
+ SparkConf sparkConf = new SparkConf(false);
+ sparkConf.set(GravitinoSparkConfig.GRAVITINO_AUTH_TYPE,
AuthProperties.SIMPLE_AUTH_TYPE);
+ GravitinoCatalogManager manager = createManager(sparkConf);
+
+ // Tokens are irrelevant outside token mode: both sessions must resolve to
one identity.
+ sparkConf.set(GravitinoSparkConfig.GRAVITINO_TOKEN_VALUE, jwt("alice"));
+ Catalog first = manager.getGravitinoCatalogInfo(CATALOG_NAME);
+ sparkConf.set(GravitinoSparkConfig.GRAVITINO_TOKEN_VALUE, jwt("bob"));
+ Catalog second = manager.getGravitinoCatalogInfo(CATALOG_NAME);
+
+ assertEquals(1, clientFactory.clientCount());
+ assertEquals(1, clientFactory.loadCount());
+ assertSame(first, second);
+ }
+
+ @Test
+ void testCloseClosesEveryCachedClient() {
+ SparkConf sparkConf = tokenConf();
+ GravitinoCatalogManager manager = createManager(sparkConf);
+
+ for (String user : new String[] {"alice", "bob", "carol"}) {
+ sparkConf.set(GravitinoSparkConfig.GRAVITINO_TOKEN_VALUE, jwt(user));
+ manager.getGravitinoCatalogInfo(CATALOG_NAME);
+ }
+ assertEquals(3, clientFactory.clientCount());
+
+ manager.close();
+
+ assertEquals(3, clientFactory.closedCount());
+ }
+
+ @Test
+ void testClientCacheEvictsAndClosesEvictedClient() {
+ SparkConf sparkConf = tokenConf();
+ sparkConf.set(GravitinoSparkConfig.GRAVITINO_CLIENT_CACHE_MAX_SIZE, "2");
+ GravitinoCatalogManager manager = createManager(sparkConf);
+
+ for (String user : new String[] {"alice", "bob", "carol"}) {
+ sparkConf.set(GravitinoSparkConfig.GRAVITINO_TOKEN_VALUE, jwt(user));
+ manager.getGravitinoCatalogInfo(CATALOG_NAME);
+ }
+
+ assertEquals(3, clientFactory.clientCount());
+ // Caffeine evicts and dispatches the removal listener asynchronously, and
a read keeps the
+ // cache draining its buffers while we wait.
+ assertTrue(
+ await(
+ () -> {
+ manager.getGravitinoCatalogInfo(CATALOG_NAME);
+ return clientFactory.closedCount() >= 1;
+ }),
+ "Exceeding the client cache size should evict and close a client");
+ }
+
+ @Test
+ void testCatalogCacheEntryExpires() throws InterruptedException {
+ SparkConf sparkConf = tokenConf();
+ sparkConf.set(GravitinoSparkConfig.GRAVITINO_CATALOG_CACHE_TTL_SEC, "1");
+ GravitinoCatalogManager manager = createManager(sparkConf);
+ sparkConf.set(GravitinoSparkConfig.GRAVITINO_TOKEN_VALUE, jwt("alice"));
+
+ manager.getGravitinoCatalogInfo(CATALOG_NAME);
+ assertEquals(1, clientFactory.loadCount());
+
+ Thread.sleep(1500);
+ manager.getGravitinoCatalogInfo(CATALOG_NAME);
+
+ assertEquals(2, clientFactory.loadCount(), "A stale catalog entry must be
reloaded");
+ // The client is cached independently of the catalog entry.
+ assertEquals(1, clientFactory.clientCount());
+ }
+
+ private GravitinoCatalogManager createManager(SparkConf sparkConf) {
+ clientFactory = new ClientFactory();
+ return GravitinoCatalogManager.create(sparkConf, "spark-user",
clientFactory);
+ }
+
+ private static SparkConf tokenConf() {
+ SparkConf sparkConf = new SparkConf(false);
+ sparkConf.set(GravitinoSparkConfig.GRAVITINO_AUTH_TYPE,
AuthProperties.TOKEN_AUTH_TYPE);
+ return sparkConf;
+ }
+
+ /** Builds an unsigned three part JWT. Nothing verifies it, so no signing
key is needed. */
+ private static String jwt(String subject) {
+ return jwt(subject, "unused");
+ }
+
+ private static String jwt(String subject, String jwtId) {
+ return base64Url("{\"alg\":\"none\",\"typ\":\"JWT\"}")
+ + "."
+ + base64Url(String.format("{\"sub\":\"%s\",\"jti\":\"%s\"}", subject,
jwtId))
+ + ".signature";
+ }
+
+ private static String base64Url(String value) {
+ return Base64.getUrlEncoder()
+ .withoutPadding()
+ .encodeToString(value.getBytes(StandardCharsets.UTF_8));
+ }
+
+ private static boolean await(BooleanSupplier condition) {
+ for (int i = 0; i < 100; i++) {
+ if (condition.getAsBoolean()) {
+ return true;
+ }
+ try {
+ Thread.sleep(50);
+ } catch (InterruptedException e) {
+ Thread.currentThread().interrupt();
+ return false;
+ }
+ }
+ return condition.getAsBoolean();
+ }
+
+ /** Hands out a distinct mock client per identity and counts what the
manager asks of it. */
+ private static class ClientFactory implements Function<GravitinoIdentity,
GravitinoClient> {
+
+ private final List<AtomicBoolean> closedFlags = new ArrayList<>();
+ private final AtomicInteger clients = new AtomicInteger();
+ private final AtomicInteger loads = new AtomicInteger();
+
+ @Override
+ public GravitinoClient apply(GravitinoIdentity identity) {
+ clients.incrementAndGet();
+ GravitinoClient client = mock(GravitinoClient.class);
+ when(client.loadCatalog(anyString()))
+ .thenAnswer(
+ invocation -> {
+ loads.incrementAndGet();
+ Catalog catalog = mock(Catalog.class);
+ when(catalog.type()).thenReturn(Catalog.Type.RELATIONAL);
+ when(catalog.name()).thenReturn(invocation.getArgument(0));
+ return catalog;
+ });
+ // Closing twice must not be counted twice: the shutdown path closes
explicitly and the
+ // removal listener may then fire for the same client.
+ AtomicBoolean closed = new AtomicBoolean(false);
+ synchronized (closedFlags) {
+ closedFlags.add(closed);
+ }
+ doAnswer(
+ invocation -> {
+ closed.set(true);
+ return null;
+ })
+ .when(client)
+ .close();
+ return client;
+ }
+
+ int clientCount() {
+ return clients.get();
+ }
+
+ int loadCount() {
+ return loads.get();
+ }
+
+ int closedCount() {
+ synchronized (closedFlags) {
+ return (int) closedFlags.stream().filter(AtomicBoolean::get).count();
+ }
+ }
+ }
+}
diff --git
a/spark-connector/spark-common/src/test/java/org/apache/gravitino/spark/connector/glue/TestGravitinoGlueCatalog.java
b/spark-connector/spark-common/src/test/java/org/apache/gravitino/spark/connector/glue/TestGravitinoGlueCatalog.java
index af980f7309..364dcc74e2 100644
---
a/spark-connector/spark-common/src/test/java/org/apache/gravitino/spark/connector/glue/TestGravitinoGlueCatalog.java
+++
b/spark-connector/spark-common/src/test/java/org/apache/gravitino/spark/connector/glue/TestGravitinoGlueCatalog.java
@@ -43,6 +43,7 @@ import
org.apache.gravitino.spark.connector.SparkTransformConverter;
import org.apache.gravitino.spark.connector.SparkTypeConverter;
import org.apache.gravitino.spark.connector.catalog.GravitinoCatalogManager;
import org.apache.iceberg.spark.SparkCatalog;
+import org.apache.spark.SparkConf;
import org.apache.spark.sql.catalyst.analysis.NoSuchFunctionException;
import org.apache.spark.sql.catalyst.analysis.NoSuchTableException;
import org.apache.spark.sql.connector.catalog.Identifier;
@@ -66,7 +67,7 @@ public class TestGravitinoGlueCatalog {
// GravitinoGlueCatalog extends BaseCatalog which calls
GravitinoCatalogManager.get()
// in its constructor, so we must initialize the manager first.
GravitinoClient mockClient = mock(GravitinoClient.class);
- GravitinoCatalogManager.create(() -> mockClient);
+ GravitinoCatalogManager.create(new SparkConf(false), "user", identity ->
mockClient);
}
@AfterAll
diff --git
a/spark-connector/spark-common/src/test/java/org/apache/gravitino/spark/connector/plugin/TestGravitinoDriverPlugin.java
b/spark-connector/spark-common/src/test/java/org/apache/gravitino/spark/connector/plugin/TestGravitinoDriverPlugin.java
index 7da747c98d..da92012e3d 100644
---
a/spark-connector/spark-common/src/test/java/org/apache/gravitino/spark/connector/plugin/TestGravitinoDriverPlugin.java
+++
b/spark-connector/spark-common/src/test/java/org/apache/gravitino/spark/connector/plugin/TestGravitinoDriverPlugin.java
@@ -21,12 +21,21 @@ package org.apache.gravitino.spark.connector.plugin;
import static org.junit.jupiter.api.Assertions.assertEquals;
+import com.google.common.collect.ImmutableMap;
+import java.io.IOException;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import org.apache.gravitino.auth.AuthProperties;
+import org.apache.gravitino.spark.connector.GravitinoSparkConfig;
import
org.apache.gravitino.spark.connector.authorization.GravitinoAuthorizationSparkSessionExtensions;
+import
org.apache.gravitino.spark.connector.plugin.GravitinoDriverPlugin.DynamicBearerTokenProvider;
import org.apache.iceberg.spark.extensions.IcebergSparkSessionExtensions;
import org.apache.spark.SparkConf;
import org.apache.spark.sql.internal.StaticSQLConf;
import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
public class TestGravitinoDriverPlugin {
@@ -58,4 +67,84 @@ public class TestGravitinoDriverPlugin {
assertEquals(extension,
sparkConf.get(StaticSQLConf.SPARK_SESSION_EXTENSIONS().key()));
}
+
+ @Test
+ void testTokenAuthTypeBuildsClient() {
+ SparkConf sparkConf = tokenAuthConf();
+ sparkConf.set(GravitinoSparkConfig.GRAVITINO_TOKEN_VALUE, "a-token");
+
+ // The client cannot reach a server here, but it must get past auth
configuration first: an
+ // unsupported auth type or a missing token would fail before any
connection is attempted.
+ Exception e =
+ Assertions.assertThrows(
+ Exception.class,
+ () ->
+ GravitinoDriverPlugin.createGravitinoClient(
+ "http://127.0.0.1:1", "metalake", sparkConf, "user",
ImmutableMap.of()));
+ Assertions.assertFalse(e instanceof UnsupportedOperationException,
e.toString());
+ Assertions.assertFalse(e instanceof IllegalArgumentException,
e.toString());
+ }
+
+ @Test
+ void testTokenProviderReturnsBearerToken() {
+ SparkConf sparkConf = tokenAuthConf();
+ sparkConf.set(GravitinoSparkConfig.GRAVITINO_TOKEN_VALUE, "a-token");
+
+ assertEquals(
+ "Bearer a-token",
+ new String(
+ new DynamicBearerTokenProvider(sparkConf).getTokenData(),
StandardCharsets.UTF_8));
+ }
+
+ @Test
+ void testTokenIsResolvedOnEveryRequest() {
+ SparkConf sparkConf = tokenAuthConf();
+ sparkConf.set(GravitinoSparkConfig.GRAVITINO_TOKEN_VALUE, "first-token");
+ DynamicBearerTokenProvider provider = new
DynamicBearerTokenProvider(sparkConf);
+
+ assertEquals("Bearer first-token", new String(provider.getTokenData(),
StandardCharsets.UTF_8));
+
+ sparkConf.set(GravitinoSparkConfig.GRAVITINO_TOKEN_VALUE, "second-token");
+
+ assertEquals(
+ "Bearer second-token", new String(provider.getTokenData(),
StandardCharsets.UTF_8));
+ }
+
+ @Test
+ void testTokenFileTakesPrecedenceAndIsRereadEveryRequest(@TempDir Path
tempDir)
+ throws IOException {
+ Path tokenFile = tempDir.resolve("token");
+ Files.write(tokenFile, "file-token\n".getBytes(StandardCharsets.UTF_8));
+ SparkConf sparkConf = tokenAuthConf();
+ sparkConf.set(GravitinoSparkConfig.GRAVITINO_TOKEN_VALUE, "conf-token");
+ sparkConf.set(GravitinoSparkConfig.GRAVITINO_TOKEN_FILE,
tokenFile.toString());
+ DynamicBearerTokenProvider provider = new
DynamicBearerTokenProvider(sparkConf);
+
+ assertEquals("Bearer file-token", new String(provider.getTokenData(),
StandardCharsets.UTF_8));
+
+ Files.write(tokenFile, "rotated-token\n".getBytes(StandardCharsets.UTF_8));
+
+ assertEquals(
+ "Bearer rotated-token", new String(provider.getTokenData(),
StandardCharsets.UTF_8));
+ }
+
+ @Test
+ void testTokenAuthTypeWithoutTokenFails() {
+ SparkConf sparkConf = tokenAuthConf();
+
+ IllegalArgumentException e =
+ Assertions.assertThrows(
+ IllegalArgumentException.class,
+ () ->
+ GravitinoDriverPlugin.createGravitinoClient(
+ "http://127.0.0.1:1", "metalake", sparkConf, "user",
ImmutableMap.of()));
+
Assertions.assertTrue(e.getMessage().contains(GravitinoSparkConfig.GRAVITINO_TOKEN_VALUE));
+
Assertions.assertTrue(e.getMessage().contains(GravitinoSparkConfig.GRAVITINO_TOKEN_FILE));
+ }
+
+ private static SparkConf tokenAuthConf() {
+ SparkConf sparkConf = new SparkConf(false);
+ sparkConf.set(GravitinoSparkConfig.GRAVITINO_AUTH_TYPE,
AuthProperties.TOKEN_AUTH_TYPE);
+ return sparkConf;
+ }
}