diff --git a/fe/fe-connector/README.md b/fe/fe-connector/README.md index cfcfd5d496c9a9..9308e2270b6327 100644 --- a/fe/fe-connector/README.md +++ b/fe/fe-connector/README.md @@ -319,9 +319,19 @@ Rules for `of(map)`: condition *holds*, so the lambda states the failure case — the opposite of Guava's `checkArgument`. Reversing it compiles cleanly and inverts the check. - **Mark secrets `sensitive = true`** and implement `toString()` as - `ConnectorPropertiesUtils.toMaskedString(this)`. That covers logs only; the - `SHOW CREATE CATALOG` masking is a separate, hand-maintained list in fe-core - (`DatasourcePrintableMap.SENSITIVE_KEY`). + `ConnectorPropertiesUtils.toMaskedString(this)`. That covers logs only. For + `SHOW CREATE CATALOG`, the audit log and the other places that print catalog + properties, also declare the keys in `ConnectorProvider#securitySurface()`: + pass `ConnectorPropertiesUtils.getSensitiveKeys()` (or the + metastore dispatcher's `MetaStoreProviders.sensitivePropertyKeys()`) to + `withSensitiveKeys(...)`, so the same mark drives both. A namespace passed + through to a client unenumerated (Trino's `trino.*`, Fluss's lake options) + is declared with `withSensitiveKeyPrefix(prefix, markers...)`. The engine + adds both to its masking list (`DatasourcePrintableMap`) when it registers + the provider. Storage credentials (`s3.secret_key`, ...) are already on that + list. A path is not a secret: don't mark a keytab or certificate *path* + `sensitive`, since the list is matched by key name across every catalog, + resource and storage vault. - **Never pass the raw map into anything that can reach user-visible output.** It holds the credentials. `ConnectorTableSchema`'s table properties are the trap: `SHOW CREATE TABLE` renders them unmasked, and the only thing keeping diff --git a/fe/fe-connector/fe-connector-adbc/src/main/java/org/apache/doris/connector/adbc/AdbcConnectorProvider.java b/fe/fe-connector/fe-connector-adbc/src/main/java/org/apache/doris/connector/adbc/AdbcConnectorProvider.java index 3baaed128a3020..c8f092d0924116 100644 --- a/fe/fe-connector/fe-connector-adbc/src/main/java/org/apache/doris/connector/adbc/AdbcConnectorProvider.java +++ b/fe/fe-connector/fe-connector-adbc/src/main/java/org/apache/doris/connector/adbc/AdbcConnectorProvider.java @@ -21,6 +21,8 @@ import org.apache.doris.connector.spi.Connector; import org.apache.doris.connector.spi.ConnectorContext; import org.apache.doris.connector.spi.ConnectorProvider; +import org.apache.doris.connector.spi.ConnectorSecuritySurface; +import org.apache.doris.foundation.property.ConnectorPropertiesUtils; import java.util.HashMap; import java.util.Map; @@ -78,4 +80,19 @@ public void validatePropertiesForUpdate( public Connector create(Map properties, ConnectorContext context) { return new AdbcConnector(properties, context); } + + /** + * The sensitive properties (the password), and the driver options under + * {@link AdbcCatalogProperties#DRIVER_OPTION_PREFIX}: each driver names its own credentials (an auth + * token, an authorization header, a private key), so a driver option is a secret when its name carries + * a credential-like word. Like {@link #getType()}, this must not touch an ADBC class: the engine asks + * for it before it discards a duplicate directory copy of the plugin. + */ + @Override + public ConnectorSecuritySurface securitySurface() { + return ConnectorSecuritySurface.EMPTY + .withSensitiveKeys(ConnectorPropertiesUtils.getSensitiveKeys(AdbcCatalogProperties.class)) + .withSensitiveKeyPrefix(AdbcCatalogProperties.DRIVER_OPTION_PREFIX, + "key", "secret", "password", "token", "credential", "authorization"); + } } diff --git a/fe/fe-connector/fe-connector-adbc/src/test/java/org/apache/doris/connector/adbc/AdbcConnectorProviderIsolationTest.java b/fe/fe-connector/fe-connector-adbc/src/test/java/org/apache/doris/connector/adbc/AdbcConnectorProviderIsolationTest.java index e2f155183af7c7..8398af1e42d643 100644 --- a/fe/fe-connector/fe-connector-adbc/src/test/java/org/apache/doris/connector/adbc/AdbcConnectorProviderIsolationTest.java +++ b/fe/fe-connector/fe-connector-adbc/src/test/java/org/apache/doris/connector/adbc/AdbcConnectorProviderIsolationTest.java @@ -75,6 +75,10 @@ void constructingTheProviderLoadsNoAdbcClass() throws Exception { "getType() must be answerable without any ADBC class"); Assertions.assertTrue(((ConnectorProvider) provider).isStandaloneCatalogType(), "adbc is a catalog type a user writes in CREATE CATALOG"); + // The engine reads the security surface when it registers the provider, before it can tell that a + // directory copy duplicates the built-in one, so it must be answerable without any ADBC class too. + Assertions.assertFalse(((ConnectorProvider) provider).securitySurface().sensitiveKeys().isEmpty(), + "securitySurface() must be answerable without any ADBC class"); Assertions.assertEquals(List.of(), loader.blockedNames(), "Provider discovery must not load any org.apache.arrow.adbc class"); diff --git a/fe/fe-connector/fe-connector-adbc/src/test/java/org/apache/doris/connector/adbc/AdbcConnectorProviderSecuritySurfaceTest.java b/fe/fe-connector/fe-connector-adbc/src/test/java/org/apache/doris/connector/adbc/AdbcConnectorProviderSecuritySurfaceTest.java new file mode 100644 index 00000000000000..a51dd42982ad85 --- /dev/null +++ b/fe/fe-connector/fe-connector-adbc/src/test/java/org/apache/doris/connector/adbc/AdbcConnectorProviderSecuritySurfaceTest.java @@ -0,0 +1,48 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +package org.apache.doris.connector.adbc; + +import org.apache.doris.connector.spi.ConnectorSecuritySurface; +import org.apache.doris.connector.spi.ConnectorSecuritySurface.SensitiveKeyPrefix; + +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; + +import java.util.Collections; + +/** + * Pins the ADBC connector's secrets: the password, and the driver options passed through under + * {@code adbc.} whose names carry a credential-like word, since each ADBC driver names its own credentials. + */ +public class AdbcConnectorProviderSecuritySurfaceTest { + + @Test + public void declaresThePasswordAndTheCredentialLikeDriverOptions() { + ConnectorSecuritySurface surface = new AdbcConnectorProvider().securitySurface(); + Assertions.assertEquals(Collections.singletonList("password"), surface.sensitiveKeys()); + Assertions.assertEquals(1, surface.sensitiveKeyPrefixes().size()); + SensitiveKeyPrefix driverOptions = surface.sensitiveKeyPrefixes().get(0); + // A driver option keeps its own adbc. name behind the pass-through prefix. + Assertions.assertTrue(driverOptions.covers("adbc.adbc.flight.sql.authorization_header")); + Assertions.assertTrue(driverOptions.covers("adbc.adbc.snowflake.sql.client_option.auth_token")); + Assertions.assertTrue(driverOptions.covers("adbc.adbc.snowflake.sql.client_option.jwt_private_key")); + Assertions.assertFalse(driverOptions.covers("adbc.adbc.snowflake.sql.db")); + Assertions.assertFalse(driverOptions.covers("uri")); + Assertions.assertTrue(surface.ssrfEndpointKeys().isEmpty()); + } +} diff --git a/fe/fe-connector/fe-connector-es/src/main/java/org/apache/doris/connector/es/EsCatalogProperties.java b/fe/fe-connector/fe-connector-es/src/main/java/org/apache/doris/connector/es/EsCatalogProperties.java index 576c8a280a0c92..7883df9cf00812 100644 --- a/fe/fe-connector/fe-connector-es/src/main/java/org/apache/doris/connector/es/EsCatalogProperties.java +++ b/fe/fe-connector/fe-connector-es/src/main/java/org/apache/doris/connector/es/EsCatalogProperties.java @@ -21,8 +21,10 @@ import org.apache.doris.foundation.property.ConnectorProperty; import org.apache.doris.foundation.property.ParamRules; +import java.util.HashSet; import java.util.LinkedHashMap; import java.util.Map; +import java.util.Set; /** * Everything a user writes in {@code CREATE CATALOG} for an Elasticsearch catalog, bound and checked. @@ -172,6 +174,19 @@ public static EsCatalogProperties of(Map properties) { return p; } + /** + * The names of the properties marked {@code sensitive}, each in both spellings a catalog may use: as + * declared and with {@link #ES_PROPERTIES_PREFIX}. + */ + public static Set sensitiveKeys() { + Set keys = new HashSet<>(); + for (String key : ConnectorPropertiesUtils.getSensitiveKeys(EsCatalogProperties.class)) { + keys.add(key); + keys.add(ES_PROPERTIES_PREFIX + key); + } + return keys; + } + /** * Returns the properties with {@link #ES_PROPERTIES_PREFIX} removed from every key that carries it. * diff --git a/fe/fe-connector/fe-connector-es/src/main/java/org/apache/doris/connector/es/EsConnectorProvider.java b/fe/fe-connector/fe-connector-es/src/main/java/org/apache/doris/connector/es/EsConnectorProvider.java index cc9b8a3a63707d..5bca276416afa0 100644 --- a/fe/fe-connector/fe-connector-es/src/main/java/org/apache/doris/connector/es/EsConnectorProvider.java +++ b/fe/fe-connector/fe-connector-es/src/main/java/org/apache/doris/connector/es/EsConnectorProvider.java @@ -20,6 +20,7 @@ import org.apache.doris.connector.spi.Connector; import org.apache.doris.connector.spi.ConnectorContext; import org.apache.doris.connector.spi.ConnectorProvider; +import org.apache.doris.connector.spi.ConnectorSecuritySurface; import java.util.Map; import java.util.Optional; @@ -56,4 +57,10 @@ public void validateProperties(Map properties) { public Connector create(Map properties, ConnectorContext context) { return new EsConnector(properties, context); } + + /** The engine masks the sensitive properties (the password) wherever it shows catalog properties. */ + @Override + public ConnectorSecuritySurface securitySurface() { + return ConnectorSecuritySurface.EMPTY.withSensitiveKeys(EsCatalogProperties.sensitiveKeys()); + } } diff --git a/fe/fe-connector/fe-connector-es/src/test/java/org/apache/doris/connector/es/EsConnectorProviderTest.java b/fe/fe-connector/fe-connector-es/src/test/java/org/apache/doris/connector/es/EsConnectorProviderTest.java index 9c42af9c7899bb..a5e0f85c437323 100644 --- a/fe/fe-connector/fe-connector-es/src/test/java/org/apache/doris/connector/es/EsConnectorProviderTest.java +++ b/fe/fe-connector/fe-connector-es/src/test/java/org/apache/doris/connector/es/EsConnectorProviderTest.java @@ -17,9 +17,13 @@ package org.apache.doris.connector.es; +import org.apache.doris.connector.spi.ConnectorSecuritySurface; + import org.junit.jupiter.api.Assertions; import org.junit.jupiter.api.Test; +import java.util.Arrays; +import java.util.HashSet; import java.util.Optional; /** What this connector declares to the engine before any catalog of its type is initialized. */ @@ -41,4 +45,13 @@ public void anEsCatalogIsNotForceInitializedForEventSync() { // idle es catalog just to look for one. Assertions.assertFalse(new EsConnectorProvider().providesEventSource()); } + + @Test + public void thePasswordIsASecretInBothSpellings() { + // A catalog may write any key with the legacy elasticsearch. prefix, so the engine masks both. + ConnectorSecuritySurface surface = new EsConnectorProvider().securitySurface(); + Assertions.assertEquals(new HashSet<>(Arrays.asList("password", "elasticsearch.password")), + new HashSet<>(surface.sensitiveKeys())); + Assertions.assertTrue(surface.sensitiveKeyPrefixes().isEmpty()); + } } diff --git a/fe/fe-connector/fe-connector-fluss/src/main/java/org/apache/doris/connector/fluss/FlussConnectorProvider.java b/fe/fe-connector/fe-connector-fluss/src/main/java/org/apache/doris/connector/fluss/FlussConnectorProvider.java index d21d22477e947d..71a718a5548e08 100644 --- a/fe/fe-connector/fe-connector-fluss/src/main/java/org/apache/doris/connector/fluss/FlussConnectorProvider.java +++ b/fe/fe-connector/fe-connector-fluss/src/main/java/org/apache/doris/connector/fluss/FlussConnectorProvider.java @@ -20,6 +20,7 @@ import org.apache.doris.connector.spi.Connector; import org.apache.doris.connector.spi.ConnectorContext; import org.apache.doris.connector.spi.ConnectorProvider; +import org.apache.doris.connector.spi.ConnectorSecuritySurface; import java.util.Map; @@ -48,4 +49,18 @@ public void validateProperties(Map properties) { public Connector create(Map properties, ConnectorContext context) { return new FlussConnector(FlussCatalogProperties.of(properties), context); } + + /** + * The fluss client's SASL credentials, a small and stable set of keys, and the lake options under + * {@link FlussCatalogProperties#LAKE_OPTION_PREFIX}: those are open-ended (new paimon / filesystem + * credential names may arrive without a Doris release), so a lake option is a secret when its name + * carries a credential-like word, and endpoints and other operational settings stay visible. + */ + @Override + public ConnectorSecuritySurface securitySurface() { + return ConnectorSecuritySurface.ofSensitiveKeys("fluss.client.security.sasl.username", + "fluss.client.security.sasl.password", "fluss.client.security.sasl.jaas.config") + .withSensitiveKeyPrefix(FlussCatalogProperties.LAKE_OPTION_PREFIX, + "key", "secret", "password", "token", "credential"); + } } diff --git a/fe/fe-connector/fe-connector-fluss/src/test/java/org/apache/doris/connector/fluss/FlussConnectorProviderTest.java b/fe/fe-connector/fe-connector-fluss/src/test/java/org/apache/doris/connector/fluss/FlussConnectorProviderTest.java index bdd740c9c3ff37..e6f8381fe0fa24 100644 --- a/fe/fe-connector/fe-connector-fluss/src/test/java/org/apache/doris/connector/fluss/FlussConnectorProviderTest.java +++ b/fe/fe-connector/fe-connector-fluss/src/test/java/org/apache/doris/connector/fluss/FlussConnectorProviderTest.java @@ -21,11 +21,14 @@ import org.apache.doris.connector.spi.ConnectorCapability; import org.apache.doris.connector.spi.ConnectorContext; import org.apache.doris.connector.spi.ConnectorProvider; +import org.apache.doris.connector.spi.ConnectorSecuritySurface; +import org.apache.doris.connector.spi.ConnectorSecuritySurface.SensitiveKeyPrefix; import org.junit.jupiter.api.Assertions; import org.junit.jupiter.api.Test; import java.io.IOException; +import java.util.Arrays; import java.util.HashMap; import java.util.Map; import java.util.ServiceLoader; @@ -111,4 +114,20 @@ public void declaresNestedColumnPruneButNotFieldIdAccessPath() throws IOExceptio connector.close(); } } + + @Test + public void declaresTheSaslCredentialsAndTheCredentialLikeLakeOptions() { + ConnectorSecuritySurface surface = new FlussConnectorProvider().securitySurface(); + Assertions.assertEquals(Arrays.asList("fluss.client.security.sasl.username", + "fluss.client.security.sasl.password", "fluss.client.security.sasl.jaas.config"), + surface.sensitiveKeys()); + Assertions.assertEquals(1, surface.sensitiveKeyPrefixes().size()); + SensitiveKeyPrefix lake = surface.sensitiveKeyPrefixes().get(0); + Assertions.assertTrue(lake.covers("fluss.lake.paimon.s3.secret-key")); + Assertions.assertTrue(lake.covers("fluss.lake.paimon.rest.token")); + Assertions.assertTrue(lake.covers("fluss.lake.paimon.azure.oauth2_client_secret")); + Assertions.assertFalse(lake.covers("fluss.lake.paimon.s3.endpoint")); + Assertions.assertFalse(lake.covers("fluss.lake.paimon.warehouse")); + Assertions.assertFalse(lake.covers("fluss.bootstrap.servers")); + } } diff --git a/fe/fe-connector/fe-connector-hive/src/main/java/org/apache/doris/connector/hive/HiveConnectorProvider.java b/fe/fe-connector/fe-connector-hive/src/main/java/org/apache/doris/connector/hive/HiveConnectorProvider.java index 6077bec17be06e..9d84bb8980ac63 100644 --- a/fe/fe-connector/fe-connector-hive/src/main/java/org/apache/doris/connector/hive/HiveConnectorProvider.java +++ b/fe/fe-connector/fe-connector-hive/src/main/java/org/apache/doris/connector/hive/HiveConnectorProvider.java @@ -17,6 +17,7 @@ package org.apache.doris.connector.hive; +import org.apache.doris.connector.metastore.spi.MetaStoreProviders; import org.apache.doris.connector.spi.Connector; import org.apache.doris.connector.spi.ConnectorContext; import org.apache.doris.connector.spi.ConnectorProvider; @@ -86,8 +87,10 @@ public void validatePropertiesForUpdate( public ConnectorSecuritySurface securitySurface() { // Every key the HMS client may take its thrift endpoint from: hive.metastore.uris, its documented alias // uri, and Hive 3's metastore.thrift.uris, which the client reads first. The engine's SSRF gate rejects - // an intranet target there at CREATE / ALTER CATALOG. + // an intranet target there at CREATE / ALTER CATALOG. The secrets are the metastore's sensitive + // properties (none today: a keytab is named by its path). return ConnectorSecuritySurface.ofSsrfEndpoints(HiveCatalogProperties.HIVE_METASTORE_URIS, - HiveCatalogProperties.URI, HiveCatalogProperties.METASTORE_THRIFT_URIS); + HiveCatalogProperties.URI, HiveCatalogProperties.METASTORE_THRIFT_URIS) + .withSensitiveKeys(MetaStoreProviders.sensitivePropertyKeys()); } } diff --git a/fe/fe-connector/fe-connector-hive/src/test/java/org/apache/doris/connector/hive/HiveConnectorProviderSecuritySurfaceTest.java b/fe/fe-connector/fe-connector-hive/src/test/java/org/apache/doris/connector/hive/HiveConnectorProviderSecuritySurfaceTest.java index 85816dc35a2637..2eaed39504bc4a 100644 --- a/fe/fe-connector/fe-connector-hive/src/test/java/org/apache/doris/connector/hive/HiveConnectorProviderSecuritySurfaceTest.java +++ b/fe/fe-connector/fe-connector-hive/src/test/java/org/apache/doris/connector/hive/HiveConnectorProviderSecuritySurfaceTest.java @@ -42,4 +42,14 @@ public void declaresMetastoreUriAsSsrfEndpoint() { Assertions.assertTrue(surface.jdbcDriverUrlKeys().isEmpty()); Assertions.assertNull(surface.jdbcDriverUrlFlavorKey()); } + + @Test + public void declaresNoKeytabPathAsASecret() { + // The secrets come from the metastore's sensitive properties; the keytabs are named by path, and + // masking hadoop.kerberos.keytab by name would also hide it in every storage vault and resource. + ConnectorSecuritySurface surface = new HiveConnectorProvider().securitySurface(); + Assertions.assertFalse(surface.sensitiveKeys().contains("hive.metastore.client.keytab")); + Assertions.assertFalse(surface.sensitiveKeys().contains("hadoop.kerberos.keytab")); + Assertions.assertTrue(surface.sensitiveKeyPrefixes().isEmpty()); + } } diff --git a/fe/fe-connector/fe-connector-iceberg/src/main/java/org/apache/doris/connector/iceberg/IcebergConnectorProvider.java b/fe/fe-connector/fe-connector-iceberg/src/main/java/org/apache/doris/connector/iceberg/IcebergConnectorProvider.java index 40b0a60cde6517..4f9bff2f213c6f 100644 --- a/fe/fe-connector/fe-connector-iceberg/src/main/java/org/apache/doris/connector/iceberg/IcebergConnectorProvider.java +++ b/fe/fe-connector/fe-connector-iceberg/src/main/java/org/apache/doris/connector/iceberg/IcebergConnectorProvider.java @@ -17,6 +17,7 @@ package org.apache.doris.connector.iceberg; +import org.apache.doris.connector.metastore.spi.MetaStoreProviders; import org.apache.doris.connector.spi.Connector; import org.apache.doris.connector.spi.ConnectorContext; import org.apache.doris.connector.spi.ConnectorProvider; @@ -90,6 +91,8 @@ public void validateCreateTable(Map properties) { public ConnectorSecuritySurface securitySurface() { // iceberg.jdbc.driver_url is a jar the connector loads, but only live on a jdbc-flavored catalog // (iceberg.catalog.type = jdbc); on a REST/HMS/... catalog it is dead config the gate must not touch. - return ConnectorSecuritySurface.ofJdbcDriverUrls("iceberg.catalog.type", "iceberg.jdbc.driver_url"); + // The secrets are those of every metastore flavor: the engine masks by key name alone. + return ConnectorSecuritySurface.ofJdbcDriverUrls("iceberg.catalog.type", "iceberg.jdbc.driver_url") + .withSensitiveKeys(MetaStoreProviders.sensitivePropertyKeys()); } } diff --git a/fe/fe-connector/fe-connector-iceberg/src/test/java/org/apache/doris/connector/iceberg/IcebergConnectorProviderSecuritySurfaceTest.java b/fe/fe-connector/fe-connector-iceberg/src/test/java/org/apache/doris/connector/iceberg/IcebergConnectorProviderSecuritySurfaceTest.java index b13437109bfc7b..27a09c46cbdd9a 100644 --- a/fe/fe-connector/fe-connector-iceberg/src/test/java/org/apache/doris/connector/iceberg/IcebergConnectorProviderSecuritySurfaceTest.java +++ b/fe/fe-connector/fe-connector-iceberg/src/test/java/org/apache/doris/connector/iceberg/IcebergConnectorProviderSecuritySurfaceTest.java @@ -39,4 +39,19 @@ public void declaresJdbcDriverUrlGatedByFlavor() { Assertions.assertEquals("iceberg.catalog.type", surface.jdbcDriverUrlFlavorKey()); Assertions.assertTrue(surface.ssrfEndpointKeys().isEmpty()); } + + @Test + public void declaresTheSecretsOfEveryMetastoreFlavor() { + // iceberg.jdbc.password was printed in clear by SHOW CREATE CATALOG before it was declared here: + // fe-core's own list does not know it. + ConnectorSecuritySurface surface = new IcebergConnectorProvider().securitySurface(); + for (String key : new String[] {"iceberg.jdbc.password", "iceberg.rest.oauth2.token", + "iceberg.rest.oauth2.credential", "aws.glue.session-token", "dlf.secret_key"}) { + Assertions.assertTrue(surface.sensitiveKeys().contains(key), key + " missing from " + + surface.sensitiveKeys()); + } + Assertions.assertFalse(surface.sensitiveKeys().contains("hadoop.kerberos.keytab"), + "a keytab is a path, not a secret"); + Assertions.assertFalse(surface.sensitiveKeys().contains("iceberg.jdbc.user")); + } } diff --git a/fe/fe-connector/fe-connector-jdbc/src/main/java/org/apache/doris/connector/jdbc/JdbcCatalogProperties.java b/fe/fe-connector/fe-connector-jdbc/src/main/java/org/apache/doris/connector/jdbc/JdbcCatalogProperties.java index 0433ffeb0a0aa5..d29ab22cd25a4e 100644 --- a/fe/fe-connector/fe-connector-jdbc/src/main/java/org/apache/doris/connector/jdbc/JdbcCatalogProperties.java +++ b/fe/fe-connector/fe-connector-jdbc/src/main/java/org/apache/doris/connector/jdbc/JdbcCatalogProperties.java @@ -23,8 +23,10 @@ import org.apache.doris.foundation.security.JdbcDriverUrlSecurity; import java.util.Collections; +import java.util.HashSet; import java.util.LinkedHashMap; import java.util.Map; +import java.util.Set; import java.util.function.UnaryOperator; /** @@ -304,6 +306,19 @@ private void checkBoolean(String key) { } } + /** + * The names of the properties marked {@code sensitive}, each in both spellings a catalog may use: as + * declared and with {@link #JDBC_PROPERTIES_PREFIX}. + */ + public static Set sensitiveKeys() { + Set keys = new HashSet<>(); + for (String key : ConnectorPropertiesUtils.getSensitiveKeys(JdbcCatalogProperties.class)) { + keys.add(key); + keys.add(JDBC_PROPERTIES_PREFIX + key); + } + return keys; + } + /** * Returns the properties with {@link #JDBC_PROPERTIES_PREFIX} removed from every key that carries * it, leaving an unprefixed key already present untouched -- the short spelling wins, as it did in diff --git a/fe/fe-connector/fe-connector-jdbc/src/main/java/org/apache/doris/connector/jdbc/JdbcConnectorProvider.java b/fe/fe-connector/fe-connector-jdbc/src/main/java/org/apache/doris/connector/jdbc/JdbcConnectorProvider.java index 725ea53fe94669..6c098610de33af 100644 --- a/fe/fe-connector/fe-connector-jdbc/src/main/java/org/apache/doris/connector/jdbc/JdbcConnectorProvider.java +++ b/fe/fe-connector/fe-connector-jdbc/src/main/java/org/apache/doris/connector/jdbc/JdbcConnectorProvider.java @@ -54,6 +54,8 @@ public void validateProperties(Map properties) { public ConnectorSecuritySurface securitySurface() { // driver_url (documented alias: jdbc.driver_url) is the jar the connector loads into the FE JVM; // the engine's operator gate applies jdbc_driver_secure_path / the mandatory rule on CREATE + ALTER. - return ConnectorSecuritySurface.ofJdbcDriverUrls(null, "driver_url", "jdbc.driver_url"); + // The engine masks the sensitive properties (the password) wherever it shows catalog properties. + return ConnectorSecuritySurface.ofJdbcDriverUrls(null, "driver_url", "jdbc.driver_url") + .withSensitiveKeys(JdbcCatalogProperties.sensitiveKeys()); } } diff --git a/fe/fe-connector/fe-connector-jdbc/src/test/java/org/apache/doris/connector/jdbc/JdbcConnectorProviderSecuritySurfaceTest.java b/fe/fe-connector/fe-connector-jdbc/src/test/java/org/apache/doris/connector/jdbc/JdbcConnectorProviderSecuritySurfaceTest.java index ad7c3d09087767..24e89adacdd3f6 100644 --- a/fe/fe-connector/fe-connector-jdbc/src/test/java/org/apache/doris/connector/jdbc/JdbcConnectorProviderSecuritySurfaceTest.java +++ b/fe/fe-connector/fe-connector-jdbc/src/test/java/org/apache/doris/connector/jdbc/JdbcConnectorProviderSecuritySurfaceTest.java @@ -23,6 +23,7 @@ import org.junit.jupiter.api.Test; import java.util.Arrays; +import java.util.HashSet; /** * Pins the JDBC connector's driver_url declaration: the engine's operator gate checks exactly these keys, @@ -37,4 +38,13 @@ public void declaresDriverUrlKeysAlwaysLive() { Assertions.assertNull(surface.jdbcDriverUrlFlavorKey()); Assertions.assertTrue(surface.ssrfEndpointKeys().isEmpty()); } + + @Test + public void declaresThePasswordInBothSpellings() { + // A catalog may write any key with the jdbc. prefix, so the engine has to mask both spellings. + ConnectorSecuritySurface surface = new JdbcConnectorProvider().securitySurface(); + Assertions.assertEquals(new HashSet<>(Arrays.asList("password", "jdbc.password")), + new HashSet<>(surface.sensitiveKeys())); + Assertions.assertTrue(surface.sensitiveKeyPrefixes().isEmpty()); + } } diff --git a/fe/fe-connector/fe-connector-maxcompute/src/main/java/org/apache/doris/connector/maxcompute/MaxComputeConnectorProvider.java b/fe/fe-connector/fe-connector-maxcompute/src/main/java/org/apache/doris/connector/maxcompute/MaxComputeConnectorProvider.java index b2547d3fc87312..59ed22c22c701a 100644 --- a/fe/fe-connector/fe-connector-maxcompute/src/main/java/org/apache/doris/connector/maxcompute/MaxComputeConnectorProvider.java +++ b/fe/fe-connector/fe-connector-maxcompute/src/main/java/org/apache/doris/connector/maxcompute/MaxComputeConnectorProvider.java @@ -21,6 +21,8 @@ import org.apache.doris.connector.spi.Connector; import org.apache.doris.connector.spi.ConnectorContext; import org.apache.doris.connector.spi.ConnectorProvider; +import org.apache.doris.connector.spi.ConnectorSecuritySurface; +import org.apache.doris.foundation.property.ConnectorPropertiesUtils; import java.util.Collections; import java.util.HashMap; @@ -88,4 +90,11 @@ public void validatePropertiesForUpdate( MCCatalogProperties.of(candidate).checkCreateTimeOnlyRules(); CacheSpec.checkWeightProperties(candidate, updatedProperties, "max_compute", "partition"); } + + /** The engine masks the sensitive properties (the access key pair) wherever it shows catalog properties. */ + @Override + public ConnectorSecuritySurface securitySurface() { + return ConnectorSecuritySurface.EMPTY + .withSensitiveKeys(ConnectorPropertiesUtils.getSensitiveKeys(MCCatalogProperties.class)); + } } diff --git a/fe/fe-connector/fe-connector-maxcompute/src/test/java/org/apache/doris/connector/maxcompute/MaxComputeConnectorProviderSecuritySurfaceTest.java b/fe/fe-connector/fe-connector-maxcompute/src/test/java/org/apache/doris/connector/maxcompute/MaxComputeConnectorProviderSecuritySurfaceTest.java new file mode 100644 index 00000000000000..95078a4416813f --- /dev/null +++ b/fe/fe-connector/fe-connector-maxcompute/src/test/java/org/apache/doris/connector/maxcompute/MaxComputeConnectorProviderSecuritySurfaceTest.java @@ -0,0 +1,42 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +package org.apache.doris.connector.maxcompute; + +import org.apache.doris.connector.spi.ConnectorSecuritySurface; + +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; + +import java.util.Arrays; +import java.util.HashSet; + +/** + * Pins the MaxCompute connector's secrets: the properties it marks sensitive, the access key pair. Before + * the connector declared them, fe-core masked only {@code mc.secret_key} and SHOW CREATE CATALOG printed + * {@code mc.access_key} in clear. + */ +public class MaxComputeConnectorProviderSecuritySurfaceTest { + + @Test + public void declaresTheAccessKeyPair() { + ConnectorSecuritySurface surface = new MaxComputeConnectorProvider().securitySurface(); + Assertions.assertEquals(new HashSet<>(Arrays.asList(MCCatalogProperties.ACCESS_KEY, + MCCatalogProperties.SECRET_KEY)), new HashSet<>(surface.sensitiveKeys())); + Assertions.assertTrue(surface.sensitiveKeyPrefixes().isEmpty()); + } +} diff --git a/fe/fe-connector/fe-connector-metastore-iceberg/src/main/java/org/apache/doris/connector/metastore/iceberg/jdbc/IcebergJdbcMetaStoreProvider.java b/fe/fe-connector/fe-connector-metastore-iceberg/src/main/java/org/apache/doris/connector/metastore/iceberg/jdbc/IcebergJdbcMetaStoreProvider.java index a6583d94dda424..54193dba958368 100644 --- a/fe/fe-connector/fe-connector-metastore-iceberg/src/main/java/org/apache/doris/connector/metastore/iceberg/jdbc/IcebergJdbcMetaStoreProvider.java +++ b/fe/fe-connector/fe-connector-metastore-iceberg/src/main/java/org/apache/doris/connector/metastore/iceberg/jdbc/IcebergJdbcMetaStoreProvider.java @@ -18,8 +18,10 @@ package org.apache.doris.connector.metastore.iceberg.jdbc; import org.apache.doris.connector.metastore.spi.MetaStoreProvider; +import org.apache.doris.foundation.property.ConnectorPropertiesUtils; import java.util.Map; +import java.util.Set; /** Selects the iceberg JDBC catalog backend ({@code iceberg.catalog.type == jdbc}). */ public final class IcebergJdbcMetaStoreProvider implements MetaStoreProvider { @@ -35,6 +37,11 @@ public IcebergJdbcMetaStoreProperties bind(Map properties, return IcebergJdbcMetaStoreProperties.of(properties); } + @Override + public Set sensitivePropertyKeys() { + return ConnectorPropertiesUtils.getSensitiveKeys(IcebergJdbcMetaStoreProperties.class); + } + @Override public String name() { return "JDBC"; diff --git a/fe/fe-connector/fe-connector-metastore-iceberg/src/test/java/org/apache/doris/connector/metastore/iceberg/IcebergMetaStoreProvidersDispatchTest.java b/fe/fe-connector/fe-connector-metastore-iceberg/src/test/java/org/apache/doris/connector/metastore/iceberg/IcebergMetaStoreProvidersDispatchTest.java index fd71e2295bcf79..ae35f2bf9eb8f5 100644 --- a/fe/fe-connector/fe-connector-metastore-iceberg/src/test/java/org/apache/doris/connector/metastore/iceberg/IcebergMetaStoreProvidersDispatchTest.java +++ b/fe/fe-connector/fe-connector-metastore-iceberg/src/test/java/org/apache/doris/connector/metastore/iceberg/IcebergMetaStoreProvidersDispatchTest.java @@ -31,6 +31,7 @@ import java.util.Collections; import java.util.HashMap; import java.util.Map; +import java.util.Set; /** * Verifies ServiceLoader discovery on the iceberg classpath: all 7 iceberg providers register and @@ -126,4 +127,18 @@ public void boundFlavorValidatesThroughDispatch() { () -> MetaStoreProviders.bindForType("glue", props, Collections.emptyMap()).validate()); Assertions.assertEquals("At least one of glue.access_key or glue.role_arn must be set", ex.getMessage()); } + + @Test + public void sensitiveKeysCoverEveryFlavorButNoKeytabPath() { + // The iceberg connector declares this union on its security surface, whatever flavor a catalog picks. + Set keys = MetaStoreProviders.sensitivePropertyKeys(); + for (String key : new String[] {"iceberg.jdbc.password", "iceberg.rest.oauth2.token", + "iceberg.rest.oauth2.credential", "aws.glue.session-token", "glue.secret_key", "dlf.secret_key", + "dlf.catalog.securityToken"}) { + Assertions.assertTrue(keys.contains(key), key + " missing from " + keys); + } + Assertions.assertFalse(keys.contains("hive.metastore.client.keytab"), keys.toString()); + Assertions.assertFalse(keys.contains("hadoop.kerberos.keytab"), keys.toString()); + Assertions.assertFalse(keys.contains("uri"), keys.toString()); + } } diff --git a/fe/fe-connector/fe-connector-metastore-paimon/src/test/java/org/apache/doris/connector/metastore/paimon/MetaStoreProvidersDispatchTest.java b/fe/fe-connector/fe-connector-metastore-paimon/src/test/java/org/apache/doris/connector/metastore/paimon/MetaStoreProvidersDispatchTest.java index ed2f4f1dbf5caf..33e3a7f8b4aa99 100644 --- a/fe/fe-connector/fe-connector-metastore-paimon/src/test/java/org/apache/doris/connector/metastore/paimon/MetaStoreProvidersDispatchTest.java +++ b/fe/fe-connector/fe-connector-metastore-paimon/src/test/java/org/apache/doris/connector/metastore/paimon/MetaStoreProvidersDispatchTest.java @@ -32,6 +32,7 @@ import java.util.Collections; import java.util.HashMap; import java.util.Map; +import java.util.Set; /** * Verifies the ServiceLoader-based discovery on the paimon classpath: all 4 paimon providers register, and @@ -147,10 +148,17 @@ public void bindForTypeUnknownFlavorThrows() { @Test public void providersExposeTheirSensitiveKeys() { - // The HMS provider surfaces its sensitive=true keytab keys (for masking when wired in P2-T03); + // The paimon connector declares the union on its security surface, so every backend's secrets are + // masked whatever paimon.catalog.type a catalog picks. A keytab is a path, not a secret, and // FileSystem has no sensitive fields -> empty (pins the default). - Assertions.assertTrue(new PaimonHmsMetaStoreProvider().sensitivePropertyKeys() + Set keys = MetaStoreProviders.sensitivePropertyKeys(); + for (String key : new String[] {"paimon.jdbc.password", "jdbc.password", "paimon.rest.dlf.access-key-id", + "paimon.rest.dlf.access-key-secret", "dlf.secret_key", "dlf.catalog.securityToken"}) { + Assertions.assertTrue(keys.contains(key), key + " missing from " + keys); + } + Assertions.assertFalse(new PaimonHmsMetaStoreProvider().sensitivePropertyKeys() .contains("hive.metastore.client.keytab")); + Assertions.assertFalse(keys.contains("hadoop.kerberos.keytab"), keys.toString()); Assertions.assertTrue(new PaimonFileSystemMetaStoreProvider().sensitivePropertyKeys().isEmpty()); } } diff --git a/fe/fe-connector/fe-connector-metastore-spi/src/main/java/org/apache/doris/connector/metastore/spi/AbstractHmsMetaStoreProperties.java b/fe/fe-connector/fe-connector-metastore-spi/src/main/java/org/apache/doris/connector/metastore/spi/AbstractHmsMetaStoreProperties.java index 6a6bc83979bc20..dbecd0e75a0fed 100644 --- a/fe/fe-connector/fe-connector-metastore-spi/src/main/java/org/apache/doris/connector/metastore/spi/AbstractHmsMetaStoreProperties.java +++ b/fe/fe-connector/fe-connector-metastore-spi/src/main/java/org/apache/doris/connector/metastore/spi/AbstractHmsMetaStoreProperties.java @@ -63,7 +63,9 @@ public abstract class AbstractHmsMetaStoreProperties extends AbstractMetaStorePr description = "The client principal of the hive metastore.") private String clientPrincipal = ""; - @ConnectorProperty(names = {"hive.metastore.client.keytab"}, required = false, sensitive = true, + // A keytab is named by its path, which is not a secret (the HDFS storage properties do not mark it either); + // marked sensitive, hadoop.kerberos.keytab would be masked for every catalog, resource and storage vault. + @ConnectorProperty(names = {"hive.metastore.client.keytab"}, required = false, description = "The client keytab of the hive metastore.") private String clientKeytab = ""; @@ -75,7 +77,7 @@ public abstract class AbstractHmsMetaStoreProperties extends AbstractMetaStorePr description = "The HDFS kerberos principal (kerberos fallback).") private String hdfsKerberosPrincipal = ""; - @ConnectorProperty(names = {"hadoop.kerberos.keytab"}, required = false, sensitive = true, + @ConnectorProperty(names = {"hadoop.kerberos.keytab"}, required = false, description = "The HDFS kerberos keytab (kerberos fallback).") private String hdfsKerberosKeytab = ""; diff --git a/fe/fe-connector/fe-connector-metastore-spi/src/main/java/org/apache/doris/connector/metastore/spi/MetaStoreProvider.java b/fe/fe-connector/fe-connector-metastore-spi/src/main/java/org/apache/doris/connector/metastore/spi/MetaStoreProvider.java index d60844a204d1e0..8e026bb2d7e50b 100644 --- a/fe/fe-connector/fe-connector-metastore-spi/src/main/java/org/apache/doris/connector/metastore/spi/MetaStoreProvider.java +++ b/fe/fe-connector/fe-connector-metastore-spi/src/main/java/org/apache/doris/connector/metastore/spi/MetaStoreProvider.java @@ -71,7 +71,11 @@ default boolean supports(Map properties) { */ P bind(Map properties, Map storageHadoopConfig); - /** Alias keys of sensitive properties (for masking in logs); empty by default. */ + /** + * Alias keys of sensitive properties; empty by default. They are masked in logs and, through the + * connector's security surface ({@link MetaStoreProviders#sensitivePropertyKeys}), wherever the engine + * shows catalog properties. + */ default Set sensitivePropertyKeys() { return Collections.emptySet(); } diff --git a/fe/fe-connector/fe-connector-metastore-spi/src/main/java/org/apache/doris/connector/metastore/spi/MetaStoreProviders.java b/fe/fe-connector/fe-connector-metastore-spi/src/main/java/org/apache/doris/connector/metastore/spi/MetaStoreProviders.java index 2962c6a544a2da..4d0f8984124327 100644 --- a/fe/fe-connector/fe-connector-metastore-spi/src/main/java/org/apache/doris/connector/metastore/spi/MetaStoreProviders.java +++ b/fe/fe-connector/fe-connector-metastore-spi/src/main/java/org/apache/doris/connector/metastore/spi/MetaStoreProviders.java @@ -20,9 +20,11 @@ import org.apache.doris.connector.metastore.MetaStoreProperties; import java.util.ArrayList; +import java.util.HashSet; import java.util.List; import java.util.Map; import java.util.ServiceLoader; +import java.util.Set; import java.util.stream.Collectors; /** @@ -99,6 +101,19 @@ public static MetaStoreProperties bindForType(String catalogType, Map sensitivePropertyKeys() { + Set keys = new HashSet<>(); + for (MetaStoreProvider provider : PROVIDERS) { + keys.addAll(provider.sensitivePropertyKeys()); + } + return keys; + } + /** Names of the registered providers (for diagnostics). */ public static List registeredNames() { return PROVIDERS.stream().map(MetaStoreProvider::name).collect(Collectors.toList()); diff --git a/fe/fe-connector/fe-connector-paimon/src/main/java/org/apache/doris/connector/paimon/PaimonConnectorProvider.java b/fe/fe-connector/fe-connector-paimon/src/main/java/org/apache/doris/connector/paimon/PaimonConnectorProvider.java index b09bbe094c4711..092de5c357bda5 100644 --- a/fe/fe-connector/fe-connector-paimon/src/main/java/org/apache/doris/connector/paimon/PaimonConnectorProvider.java +++ b/fe/fe-connector/fe-connector-paimon/src/main/java/org/apache/doris/connector/paimon/PaimonConnectorProvider.java @@ -17,6 +17,7 @@ package org.apache.doris.connector.paimon; +import org.apache.doris.connector.metastore.spi.MetaStoreProviders; import org.apache.doris.connector.spi.Connector; import org.apache.doris.connector.spi.ConnectorContext; import org.apache.doris.connector.spi.ConnectorProvider; @@ -102,8 +103,10 @@ public void validatePropertiesForUpdate( public ConnectorSecuritySurface securitySurface() { // paimon.jdbc.driver_url (documented alias: jdbc.driver_url) is a jar the connector loads, but only // live on a jdbc-flavored catalog (paimon.catalog.type = jdbc); otherwise it is dead config. + // The secrets are those of every metastore flavor: the engine masks by key name alone. return ConnectorSecuritySurface.ofJdbcDriverUrls( - "paimon.catalog.type", "paimon.jdbc.driver_url", "jdbc.driver_url"); + "paimon.catalog.type", "paimon.jdbc.driver_url", "jdbc.driver_url") + .withSensitiveKeys(MetaStoreProviders.sensitivePropertyKeys()); } } diff --git a/fe/fe-connector/fe-connector-paimon/src/test/java/org/apache/doris/connector/paimon/PaimonConnectorProviderSecuritySurfaceTest.java b/fe/fe-connector/fe-connector-paimon/src/test/java/org/apache/doris/connector/paimon/PaimonConnectorProviderSecuritySurfaceTest.java index 872d04e74db231..24a74f68f0168d 100644 --- a/fe/fe-connector/fe-connector-paimon/src/test/java/org/apache/doris/connector/paimon/PaimonConnectorProviderSecuritySurfaceTest.java +++ b/fe/fe-connector/fe-connector-paimon/src/test/java/org/apache/doris/connector/paimon/PaimonConnectorProviderSecuritySurfaceTest.java @@ -39,4 +39,19 @@ public void declaresJdbcDriverUrlKeysGatedByFlavor() { Assertions.assertEquals("paimon.catalog.type", surface.jdbcDriverUrlFlavorKey()); Assertions.assertTrue(surface.ssrfEndpointKeys().isEmpty()); } + + @Test + public void declaresTheSecretsOfEveryMetastoreFlavor() { + // Before these were declared, SHOW CREATE CATALOG printed the paimon jdbc password and the REST DLF + // key pair in clear: fe-core's own list knows neither spelling. + ConnectorSecuritySurface surface = new PaimonConnectorProvider().securitySurface(); + for (String key : new String[] {"paimon.jdbc.password", "jdbc.password", "paimon.rest.dlf.access-key-id", + "paimon.rest.dlf.access-key-secret", "dlf.secret_key", "dlf.catalog.securityToken"}) { + Assertions.assertTrue(surface.sensitiveKeys().contains(key), key + " missing from " + + surface.sensitiveKeys()); + } + Assertions.assertFalse(surface.sensitiveKeys().contains("hive.metastore.client.keytab"), + "a keytab is a path, not a secret"); + Assertions.assertFalse(surface.sensitiveKeys().contains("paimon.rest.token.provider")); + } } diff --git a/fe/fe-connector/fe-connector-spi/src/main/java/org/apache/doris/connector/spi/ConnectorProvider.java b/fe/fe-connector/fe-connector-spi/src/main/java/org/apache/doris/connector/spi/ConnectorProvider.java index 87a038b151255e..d8d546ddb20394 100644 --- a/fe/fe-connector/fe-connector-spi/src/main/java/org/apache/doris/connector/spi/ConnectorProvider.java +++ b/fe/fe-connector/fe-connector-spi/src/main/java/org/apache/doris/connector/spi/ConnectorProvider.java @@ -131,10 +131,12 @@ default void validateCreateTable(Map properties) { /** * The security-sensitive property surface of this connector: the user-supplied property keys the - * engine's CREATE / ALTER CATALOG security gates must police (SSRF endpoints, jdbc driver_url). The - * engine looks this up by type (without initializing a connector) so those gates stay connector-agnostic - * — it never hard-codes a catalog type's property names. Default {@link ConnectorSecuritySurface#EMPTY}: - * a connector that names no network endpoint and loads no driver jar needs nothing policed. + * engine's CREATE / ALTER CATALOG security gates must police (SSRF endpoints, jdbc driver_url), and the + * keys, or key namespaces, whose secret values it must mask when it shows catalog properties. The engine + * looks this up by type (without initializing a connector) so those policies stay connector-agnostic — it + * never hard-codes a catalog type's property names; the secrets are read once, when the provider is + * registered. Default {@link ConnectorSecuritySurface#EMPTY}: a connector that names no network + * endpoint, loads no driver jar and takes no secret needs nothing policed. */ default ConnectorSecuritySurface securitySurface() { return ConnectorSecuritySurface.EMPTY; diff --git a/fe/fe-connector/fe-connector-spi/src/main/java/org/apache/doris/connector/spi/ConnectorSecuritySurface.java b/fe/fe-connector/fe-connector-spi/src/main/java/org/apache/doris/connector/spi/ConnectorSecuritySurface.java index 3bad47c5d6ff00..6f1cd913dac706 100644 --- a/fe/fe-connector/fe-connector-spi/src/main/java/org/apache/doris/connector/spi/ConnectorSecuritySurface.java +++ b/fe/fe-connector/fe-connector-spi/src/main/java/org/apache/doris/connector/spi/ConnectorSecuritySurface.java @@ -17,15 +17,19 @@ package org.apache.doris.connector.spi; +import java.util.ArrayList; import java.util.Arrays; +import java.util.Collection; import java.util.Collections; import java.util.List; +import java.util.Locale; /** * The security-sensitive property surface a connector exposes to the engine's CREATE / ALTER CATALOG - * security gates. It lets those gates stay connector-agnostic: the engine applies its own policies — - * SSRF endpoint checking and the jdbc driver-url operator gate — to the property keys declared here, - * instead of branching on the catalog type. A connector that names no such property returns {@link #EMPTY}. + * security gates and to the masking of catalog properties. It lets both stay connector-agnostic: the engine + * applies its own policies — SSRF endpoint checking, the jdbc driver-url operator gate, and hiding secret + * values — to the property keys declared here, instead of branching on the catalog type. A connector that + * names no such property returns {@link #EMPTY}. * *

The keys are the connector's wire-stable, user-facing property names (documented aliases included). * They are declared here, on the provider, rather than read by the engine, because the engine must not @@ -34,18 +38,24 @@ public final class ConnectorSecuritySurface { /** For connectors that name no security-sensitive property. */ - public static final ConnectorSecuritySurface EMPTY = - new ConnectorSecuritySurface(Collections.emptyList(), Collections.emptyList(), null); + public static final ConnectorSecuritySurface EMPTY = new ConnectorSecuritySurface( + Collections.emptyList(), Collections.emptyList(), null, Collections.emptyList(), + Collections.emptyList()); private final List ssrfEndpointKeys; private final List jdbcDriverUrlKeys; private final String jdbcDriverUrlFlavorKey; + private final List sensitiveKeys; + private final List sensitiveKeyPrefixes; private ConnectorSecuritySurface(List ssrfEndpointKeys, List jdbcDriverUrlKeys, - String jdbcDriverUrlFlavorKey) { + String jdbcDriverUrlFlavorKey, List sensitiveKeys, + List sensitiveKeyPrefixes) { this.ssrfEndpointKeys = ssrfEndpointKeys; this.jdbcDriverUrlKeys = jdbcDriverUrlKeys; this.jdbcDriverUrlFlavorKey = jdbcDriverUrlFlavorKey; + this.sensitiveKeys = sensitiveKeys; + this.sensitiveKeyPrefixes = sensitiveKeyPrefixes; } /** @@ -59,7 +69,8 @@ private ConnectorSecuritySurface(List ssrfEndpointKeys, List jdb * placeholder. */ public static ConnectorSecuritySurface ofSsrfEndpoints(String... keys) { - return new ConnectorSecuritySurface(Arrays.asList(keys), Collections.emptyList(), null); + return new ConnectorSecuritySurface(Arrays.asList(keys), Collections.emptyList(), null, + Collections.emptyList(), Collections.emptyList()); } /** @@ -71,7 +82,69 @@ public static ConnectorSecuritySurface ofSsrfEndpoints(String... keys) { * @param keys the driver_url property names (documented aliases included) */ public static ConnectorSecuritySurface ofJdbcDriverUrls(String flavorKey, String... keys) { - return new ConnectorSecuritySurface(Collections.emptyList(), Arrays.asList(keys), flavorKey); + return new ConnectorSecuritySurface(Collections.emptyList(), Arrays.asList(keys), flavorKey, + Collections.emptyList(), Collections.emptyList()); + } + + /** + * Declares the property keys whose value is a secret, for a connector that names no other + * security-sensitive property. See {@link #withSensitiveKeys(Collection)} for what the engine does with + * them. + */ + public static ConnectorSecuritySurface ofSensitiveKeys(String... keys) { + return EMPTY.withSensitiveKeys(keys); + } + + /** + * Declares a namespace of secret keys, for a connector that names no other security-sensitive property. + * See {@link #withSensitiveKeyPrefix} for the keys it covers. + */ + public static ConnectorSecuritySurface ofSensitiveKeyPrefix(String prefix, String... markers) { + return EMPTY.withSensitiveKeyPrefix(prefix, markers); + } + + /** Same as {@link #withSensitiveKeys(Collection)}. */ + public ConnectorSecuritySurface withSensitiveKeys(String... keys) { + return withSensitiveKeys(Arrays.asList(keys)); + } + + /** + * Returns a copy of this surface that also declares {@code keys} as secrets: passwords, tokens, keys. + * The engine masks their values where it shows catalog properties, such as SHOW CREATE CATALOG, and in + * the text of the CREATE / ALTER CATALOG statements it keeps in the audit log, matching the key + * case-insensitively. The keys are registered with the provider at FE startup, so they cover catalogs + * created before an upgrade too, but not a catalog whose connector plugin is not loaded. Storage + * credentials the engine already masks for every catalog ({@code s3.secret_key} and the like) need not + * be declared. + * + *

A connector that marks its secret properties {@code sensitive} passes their names here + * ({@code ConnectorPropertiesUtils.getSensitiveKeys}), so one mark covers its own logs and the engine's + * output alike. + */ + public ConnectorSecuritySurface withSensitiveKeys(Collection keys) { + List merged = new ArrayList<>(sensitiveKeys); + merged.addAll(keys); + return new ConnectorSecuritySurface(ssrfEndpointKeys, jdbcDriverUrlKeys, jdbcDriverUrlFlavorKey, + Collections.unmodifiableList(merged), sensitiveKeyPrefixes); + } + + /** + * Returns a copy of this surface that also declares as secrets the keys that start with {@code prefix} + * and whose rest, after the prefix, contains one of {@code markers} (words such as {@code "password"} + * or {@code "token"}); with no marker, every key under the prefix is a secret. The prefix and the + * markers are matched case-insensitively, and the engine masks a matching key wherever it masks one + * declared with {@link #withSensitiveKeys(Collection)}. + * + *

This is for a namespace the connector passes through to a client or library without knowing its + * option names, such as Fluss's lake options or Trino's connector properties: a credential option + * there can arrive with a new client release, so it cannot be declared by name. + */ + public ConnectorSecuritySurface withSensitiveKeyPrefix(String prefix, String... markers) { + List merged = new ArrayList<>(sensitiveKeyPrefixes); + merged.add(new SensitiveKeyPrefix(prefix, + Collections.unmodifiableList(new ArrayList<>(Arrays.asList(markers))))); + return new ConnectorSecuritySurface(ssrfEndpointKeys, jdbcDriverUrlKeys, jdbcDriverUrlFlavorKey, + sensitiveKeys, Collections.unmodifiableList(merged)); } /** The endpoint property keys to check against the SSRF policy; empty when none. */ @@ -91,4 +164,61 @@ public List jdbcDriverUrlKeys() { public String jdbcDriverUrlFlavorKey() { return jdbcDriverUrlFlavorKey; } + + /** The property keys whose values the engine masks when it shows catalog properties; empty when none. */ + public List sensitiveKeys() { + return sensitiveKeys; + } + + /** The namespaces of secret keys declared with {@link #withSensitiveKeyPrefix}; empty when none. */ + public List sensitiveKeyPrefixes() { + return sensitiveKeyPrefixes; + } + + /** A namespace of secret keys; see {@link #withSensitiveKeyPrefix} for the keys it covers. */ + public static final class SensitiveKeyPrefix { + private final String prefix; + private final List markers; + private final String lowerCasePrefix; + private final List lowerCaseMarkers; + + private SensitiveKeyPrefix(String prefix, List markers) { + this.prefix = prefix; + this.markers = markers; + this.lowerCasePrefix = prefix.toLowerCase(Locale.ROOT); + List lowerCase = new ArrayList<>(); + for (String marker : markers) { + lowerCase.add(marker.toLowerCase(Locale.ROOT)); + } + this.lowerCaseMarkers = lowerCase; + } + + /** The namespace, such as {@code "trino."}. */ + public String prefix() { + return prefix; + } + + /** The words one of which the rest of a key must contain; empty when every key under it is a secret. */ + public List markers() { + return markers; + } + + /** Whether {@code key} is one of the secrets this namespace covers. */ + public boolean covers(String key) { + String lowerCaseKey = key.toLowerCase(Locale.ROOT); + if (!lowerCaseKey.startsWith(lowerCasePrefix)) { + return false; + } + if (lowerCaseMarkers.isEmpty()) { + return true; + } + String rest = lowerCaseKey.substring(lowerCasePrefix.length()); + for (String marker : lowerCaseMarkers) { + if (rest.contains(marker)) { + return true; + } + } + return false; + } + } } diff --git a/fe/fe-connector/fe-connector-spi/src/test/java/org/apache/doris/connector/spi/ConnectorPluginSurfaceTest.java b/fe/fe-connector/fe-connector-spi/src/test/java/org/apache/doris/connector/spi/ConnectorPluginSurfaceTest.java index 73b1fd28d14c1d..c0b2be181873ea 100644 --- a/fe/fe-connector/fe-connector-spi/src/test/java/org/apache/doris/connector/spi/ConnectorPluginSurfaceTest.java +++ b/fe/fe-connector/fe-connector-spi/src/test/java/org/apache/doris/connector/spi/ConnectorPluginSurfaceTest.java @@ -89,7 +89,7 @@ public void connectorApiMajorTracksTheRecordedSurfaceChange() throws IOException version.load(in); } // Major 29 adds typed table-absence errors after the upstream API 28 changes. - Assertions.assertEquals("30.0", version.getProperty("api.version")); + Assertions.assertEquals("31.0", version.getProperty("api.version")); } /** Root entry points plus provider/handle types returned to connector plugins. */ diff --git a/fe/fe-connector/fe-connector-spi/src/test/java/org/apache/doris/connector/spi/ConnectorSecuritySurfaceTest.java b/fe/fe-connector/fe-connector-spi/src/test/java/org/apache/doris/connector/spi/ConnectorSecuritySurfaceTest.java new file mode 100644 index 00000000000000..cecb61eb91a696 --- /dev/null +++ b/fe/fe-connector/fe-connector-spi/src/test/java/org/apache/doris/connector/spi/ConnectorSecuritySurfaceTest.java @@ -0,0 +1,151 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +package org.apache.doris.connector.spi; + +import org.apache.doris.connector.spi.ConnectorSecuritySurface.SensitiveKeyPrefix; + +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; + +import java.util.Arrays; +import java.util.Collections; +import java.util.LinkedHashSet; +import java.util.List; + +public class ConnectorSecuritySurfaceTest { + + @Test + public void gateOnlySurfacesDeclareNoSecrets() { + Assertions.assertEquals(Collections.emptyList(), ConnectorSecuritySurface.EMPTY.sensitiveKeys()); + Assertions.assertEquals(Collections.emptyList(), + ConnectorSecuritySurface.ofSsrfEndpoints("x.uri").sensitiveKeys()); + Assertions.assertEquals(Collections.emptyList(), + ConnectorSecuritySurface.ofJdbcDriverUrls(null, "x.driver_url").sensitiveKeys()); + } + + @Test + public void ofSensitiveKeysDeclaresOnlySecrets() { + ConnectorSecuritySurface surface = ConnectorSecuritySurface.ofSensitiveKeys("x.token", "x.api-key"); + + Assertions.assertEquals(Arrays.asList("x.token", "x.api-key"), surface.sensitiveKeys()); + Assertions.assertEquals(Collections.emptyList(), surface.ssrfEndpointKeys()); + Assertions.assertEquals(Collections.emptyList(), surface.jdbcDriverUrlKeys()); + Assertions.assertNull(surface.jdbcDriverUrlFlavorKey()); + } + + @Test + public void withSensitiveKeysKeepsTheGateDeclarationsAndAppends() { + // A connector with a REST endpoint and a token needs both declarations on one surface. + ConnectorSecuritySurface ssrf = ConnectorSecuritySurface.ofSsrfEndpoints("x.uri"); + ConnectorSecuritySurface surface = ssrf.withSensitiveKeys("x.token").withSensitiveKeys("x.api-key"); + + Assertions.assertEquals(Collections.singletonList("x.uri"), surface.ssrfEndpointKeys()); + Assertions.assertEquals(Arrays.asList("x.token", "x.api-key"), surface.sensitiveKeys()); + + ConnectorSecuritySurface jdbc = ConnectorSecuritySurface.ofJdbcDriverUrls("x.type", "x.driver_url") + .withSensitiveKeys("x.password"); + Assertions.assertEquals("x.type", jdbc.jdbcDriverUrlFlavorKey()); + Assertions.assertEquals(Collections.singletonList("x.driver_url"), jdbc.jdbcDriverUrlKeys()); + Assertions.assertEquals(Collections.singletonList("x.password"), jdbc.sensitiveKeys()); + } + + @Test + public void declaringSecretsNeverChangesAnExistingSurface() { + // EMPTY is shared by every connector that declares nothing; a mutable surface would leak one + // connector's declaration into all of them. + ConnectorSecuritySurface ssrf = ConnectorSecuritySurface.ofSsrfEndpoints("x.uri"); + ConnectorSecuritySurface withSecret = ssrf.withSensitiveKeys("x.token"); + ConnectorSecuritySurface.ofSensitiveKeys("x.api-key"); + + Assertions.assertEquals(Collections.emptyList(), ssrf.sensitiveKeys()); + Assertions.assertEquals(Collections.emptyList(), ConnectorSecuritySurface.EMPTY.sensitiveKeys()); + Assertions.assertThrows(UnsupportedOperationException.class, + () -> withSecret.sensitiveKeys().add("x.other")); + } + + @Test + public void withSensitiveKeysTakesTheNamesAConnectorDerivesFromItsAnnotations() { + // getSensitiveKeys returns a Set; the collection form saves every connector an array copy. + ConnectorSecuritySurface surface = ConnectorSecuritySurface.ofSensitiveKeys("x.token") + .withSensitiveKeys(new LinkedHashSet<>(Arrays.asList("x.password", "x.secret"))); + + Assertions.assertEquals(Arrays.asList("x.token", "x.password", "x.secret"), surface.sensitiveKeys()); + Assertions.assertEquals(Collections.emptyList(), surface.sensitiveKeyPrefixes()); + } + + @Test + public void sensitiveKeyPrefixesKeepEveryOtherDeclarationAndAppend() { + ConnectorSecuritySurface surface = ConnectorSecuritySurface.ofSsrfEndpoints("x.uri") + .withSensitiveKeys("x.token") + .withSensitiveKeyPrefix("x.lake.", "key", "secret") + .withSensitiveKeyPrefix("x.raw."); + + Assertions.assertEquals(Collections.singletonList("x.uri"), surface.ssrfEndpointKeys()); + Assertions.assertEquals(Collections.singletonList("x.token"), surface.sensitiveKeys()); + List prefixes = surface.sensitiveKeyPrefixes(); + Assertions.assertEquals(2, prefixes.size()); + Assertions.assertEquals("x.lake.", prefixes.get(0).prefix()); + Assertions.assertEquals(Arrays.asList("key", "secret"), prefixes.get(0).markers()); + Assertions.assertEquals("x.raw.", prefixes.get(1).prefix()); + Assertions.assertEquals(Collections.emptyList(), prefixes.get(1).markers()); + + ConnectorSecuritySurface only = ConnectorSecuritySurface.ofSensitiveKeyPrefix("x.", "password"); + Assertions.assertEquals(Collections.emptyList(), only.sensitiveKeys()); + Assertions.assertEquals(Collections.emptyList(), only.ssrfEndpointKeys()); + Assertions.assertEquals("x.", only.sensitiveKeyPrefixes().get(0).prefix()); + } + + @Test + public void keyPrefixCoversTheCredentialLikeKeysUnderIt() { + SensitiveKeyPrefix lake = ConnectorSecuritySurface.ofSensitiveKeyPrefix("x.Lake.", "Secret", "token") + .sensitiveKeyPrefixes().get(0); + Assertions.assertTrue(lake.covers("x.lake.s3.secret-key")); + Assertions.assertTrue(lake.covers("X.LAKE.REST.TOKEN"), "prefix and markers match case-insensitively"); + Assertions.assertFalse(lake.covers("x.lake.s3.endpoint"), "a key naming no marker stays visible"); + Assertions.assertFalse(lake.covers("x.lakes.secret"), "only keys under the prefix"); + Assertions.assertFalse(lake.covers("y.x.lake.secret")); + + // Only the rest is searched, so a namespace whose own name holds a marker does not cover all of it. + SensitiveKeyPrefix tokens = ConnectorSecuritySurface.ofSensitiveKeyPrefix("x.token.", "token") + .sensitiveKeyPrefixes().get(0); + Assertions.assertFalse(tokens.covers("x.token.endpoint")); + Assertions.assertTrue(tokens.covers("x.token.refresh-token")); + + SensitiveKeyPrefix all = ConnectorSecuritySurface.ofSensitiveKeyPrefix("x.raw.").sensitiveKeyPrefixes() + .get(0); + Assertions.assertTrue(all.covers("x.raw.endpoint"), "with no marker every key under it is a secret"); + Assertions.assertFalse(all.covers("x.ra")); + } + + @Test + public void declaringAPrefixNeverChangesAnExistingSurfaceOrTheCallersArray() { + String[] markers = {"token"}; + ConnectorSecuritySurface keys = ConnectorSecuritySurface.ofSensitiveKeys("x.token"); + ConnectorSecuritySurface withPrefix = keys.withSensitiveKeyPrefix("x.", markers); + markers[0] = "changed"; + + Assertions.assertEquals(Collections.emptyList(), keys.sensitiveKeyPrefixes()); + Assertions.assertEquals(Collections.emptyList(), ConnectorSecuritySurface.EMPTY.sensitiveKeyPrefixes()); + Assertions.assertEquals(Collections.singletonList("token"), + withPrefix.sensitiveKeyPrefixes().get(0).markers()); + Assertions.assertThrows(UnsupportedOperationException.class, + () -> withPrefix.sensitiveKeyPrefixes().clear()); + Assertions.assertThrows(UnsupportedOperationException.class, + () -> withPrefix.sensitiveKeyPrefixes().get(0).markers().add("key")); + } +} diff --git a/fe/fe-connector/fe-connector-spi/src/test/resources/connector-plugin-surface.txt b/fe/fe-connector/fe-connector-spi/src/test/resources/connector-plugin-surface.txt index fdd2b94e68bf1d..a5375819f37739 100644 --- a/fe/fe-connector/fe-connector-spi/src/test/resources/connector-plugin-surface.txt +++ b/fe/fe-connector/fe-connector-spi/src/test/resources/connector-plugin-surface.txt @@ -96,8 +96,15 @@ org.apache.doris.connector.spi.ConnectorProvider#validatePropertiesForUpdate(jav org.apache.doris.connector.spi.ConnectorSecuritySurface#jdbcDriverUrlFlavorKey():java.lang.String org.apache.doris.connector.spi.ConnectorSecuritySurface#jdbcDriverUrlKeys():java.util.List org.apache.doris.connector.spi.ConnectorSecuritySurface#ofJdbcDriverUrls(java.lang.String,java.lang.String[]):org.apache.doris.connector.spi.ConnectorSecuritySurface +org.apache.doris.connector.spi.ConnectorSecuritySurface#ofSensitiveKeyPrefix(java.lang.String,java.lang.String[]):org.apache.doris.connector.spi.ConnectorSecuritySurface +org.apache.doris.connector.spi.ConnectorSecuritySurface#ofSensitiveKeys(java.lang.String[]):org.apache.doris.connector.spi.ConnectorSecuritySurface org.apache.doris.connector.spi.ConnectorSecuritySurface#ofSsrfEndpoints(java.lang.String[]):org.apache.doris.connector.spi.ConnectorSecuritySurface +org.apache.doris.connector.spi.ConnectorSecuritySurface#sensitiveKeyPrefixes():java.util.List +org.apache.doris.connector.spi.ConnectorSecuritySurface#sensitiveKeys():java.util.List org.apache.doris.connector.spi.ConnectorSecuritySurface#ssrfEndpointKeys():java.util.List +org.apache.doris.connector.spi.ConnectorSecuritySurface#withSensitiveKeyPrefix(java.lang.String,java.lang.String[]):org.apache.doris.connector.spi.ConnectorSecuritySurface +org.apache.doris.connector.spi.ConnectorSecuritySurface#withSensitiveKeys(java.lang.String[]):org.apache.doris.connector.spi.ConnectorSecuritySurface +org.apache.doris.connector.spi.ConnectorSecuritySurface#withSensitiveKeys(java.util.Collection):org.apache.doris.connector.spi.ConnectorSecuritySurface org.apache.doris.connector.spi.ConnectorSession#allocateTransactionId():long org.apache.doris.connector.spi.ConnectorSession#getBeExecVersion():int org.apache.doris.connector.spi.ConnectorSession#getCatalogId():long diff --git a/fe/fe-connector/fe-connector-trino/src/main/java/org/apache/doris/connector/trino/TrinoConnectorProvider.java b/fe/fe-connector/fe-connector-trino/src/main/java/org/apache/doris/connector/trino/TrinoConnectorProvider.java index 5faeb174c28536..731de000f4ca1b 100644 --- a/fe/fe-connector/fe-connector-trino/src/main/java/org/apache/doris/connector/trino/TrinoConnectorProvider.java +++ b/fe/fe-connector/fe-connector-trino/src/main/java/org/apache/doris/connector/trino/TrinoConnectorProvider.java @@ -20,6 +20,7 @@ import org.apache.doris.connector.spi.Connector; import org.apache.doris.connector.spi.ConnectorContext; import org.apache.doris.connector.spi.ConnectorProvider; +import org.apache.doris.connector.spi.ConnectorSecuritySurface; import java.util.Map; @@ -55,4 +56,16 @@ public Connector create(Map properties, ConnectorContext context public void validateProperties(Map properties) { TrinoCatalogProperties.of(properties); } + + /** + * The properties under {@link TrinoCatalogProperties#TRINO_PROPERTIES_PREFIX} go to the Trino connector + * unread, and each Trino connector names its own credentials ({@code trino.connection-password}, + * {@code trino.hive.s3.aws-secret-key}, ...), so one of them is a secret when its name carries a + * credential-like word. + */ + @Override + public ConnectorSecuritySurface securitySurface() { + return ConnectorSecuritySurface.ofSensitiveKeyPrefix(TrinoCatalogProperties.TRINO_PROPERTIES_PREFIX, + "key", "secret", "password", "token", "credential"); + } } diff --git a/fe/fe-connector/fe-connector-trino/src/test/java/org/apache/doris/connector/trino/TrinoConnectorProviderSecuritySurfaceTest.java b/fe/fe-connector/fe-connector-trino/src/test/java/org/apache/doris/connector/trino/TrinoConnectorProviderSecuritySurfaceTest.java new file mode 100644 index 00000000000000..ed4f76aa3d9001 --- /dev/null +++ b/fe/fe-connector/fe-connector-trino/src/test/java/org/apache/doris/connector/trino/TrinoConnectorProviderSecuritySurfaceTest.java @@ -0,0 +1,45 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +package org.apache.doris.connector.trino; + +import org.apache.doris.connector.spi.ConnectorSecuritySurface; +import org.apache.doris.connector.spi.ConnectorSecuritySurface.SensitiveKeyPrefix; + +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; + +/** + * Pins the Trino connector's secrets: its {@code trino.*} properties go to the Trino connector unread, so the + * credential-like ones are declared as a namespace rather than by name. + */ +public class TrinoConnectorProviderSecuritySurfaceTest { + + @Test + public void declaresTheCredentialLikeTrinoProperties() { + ConnectorSecuritySurface surface = new TrinoConnectorProvider().securitySurface(); + Assertions.assertTrue(surface.sensitiveKeys().isEmpty()); + Assertions.assertEquals(1, surface.sensitiveKeyPrefixes().size()); + SensitiveKeyPrefix trino = surface.sensitiveKeyPrefixes().get(0); + Assertions.assertTrue(trino.covers("trino.connection-password")); + Assertions.assertTrue(trino.covers("trino.hive.s3.aws-secret-key")); + Assertions.assertTrue(trino.covers("trino.bigquery.credentials-key")); + Assertions.assertFalse(trino.covers("trino.connector.name")); + Assertions.assertFalse(trino.covers("trino.connection-url")); + Assertions.assertFalse(trino.covers("trino.connection-user")); + } +} diff --git a/fe/fe-connector/pom.xml b/fe/fe-connector/pom.xml index f6ff0f9aeda315..0ab1906e4f4a42 100644 --- a/fe/fe-connector/pom.xml +++ b/fe/fe-connector/pom.xml @@ -55,7 +55,7 @@ under the License. of the latter two means bumping this property as well (and fe-extension-spi means bumping all five families). --> - 30.0 + 31.0 diff --git a/fe/fe-core/src/main/java/org/apache/doris/common/util/DatasourcePrintableMap.java b/fe/fe-core/src/main/java/org/apache/doris/common/util/DatasourcePrintableMap.java index ddead9547ae830..9e14c1a245dccb 100644 --- a/fe/fe-core/src/main/java/org/apache/doris/common/util/DatasourcePrintableMap.java +++ b/fe/fe-core/src/main/java/org/apache/doris/common/util/DatasourcePrintableMap.java @@ -18,16 +18,19 @@ package org.apache.doris.common.util; import org.apache.doris.common.maxcompute.MCProperties; +import org.apache.doris.connector.spi.ConnectorSecuritySurface; +import org.apache.doris.connector.spi.ConnectorSecuritySurface.SensitiveKeyPrefix; import org.apache.doris.foundation.util.BasicPrintableMap; import com.google.common.collect.Sets; import java.util.Arrays; import java.util.Collection; -import java.util.Locale; +import java.util.List; import java.util.Map; import java.util.Set; import java.util.TreeMap; +import java.util.concurrent.CopyOnWriteArrayList; public class DatasourcePrintableMap extends BasicPrintableMap { private boolean hidePassword; @@ -36,12 +39,19 @@ public class DatasourcePrintableMap extends BasicPrintableMap { public static final Set SENSITIVE_KEY; public static final Set HIDDEN_KEY; public static final String PASSWORD_MASK = "*XXX"; + // Namespaces of secret keys, see isSensitiveKey: the engine's own below, and the ones connector providers + // declare, added at FE startup. + private static final List SENSITIVE_KEY_PREFIXES = new CopyOnWriteArrayList<>(); static { SENSITIVE_KEY = Sets.newTreeSet(String.CASE_INSENSITIVE_ORDER); SENSITIVE_KEY.add("password"); SENSITIVE_KEY.add("kerberos_keytab_content"); SENSITIVE_KEY.add("bos_secret_accesskey"); + // jdbc.password, elasticsearch.password, the MaxCompute secret, the Iceberg REST keys and the Fluss + // entries below predate ConnectorSecuritySurface. Those connectors now declare their secrets + // themselves; the entries stay so that a catalog whose connector plugin is not loaded still prints + // them masked. A new connector key belongs on its connector's security surface, not here. SENSITIVE_KEY.add("jdbc.password"); // MongoDB URIs can embed credentials even when no separate password is configured. SENSITIVE_KEY.add("mongodb.uri"); @@ -78,11 +88,20 @@ public class DatasourcePrintableMap extends BasicPrintableMap { // carried sensitive keys). Note the overlap with the inlined storage keys below is // uneven and must NOT be relied on: iceberg.rest.secret-access-key aliases the (sensitive) S3 secret // key, but iceberg.rest.session-token aliases the S3 session-token field which is NOT sensitive, so - // omitting it here would silently unmask it. Keep in sync with the connector's sensitive REST keys. + // omitting it here would silently unmask it. SENSITIVE_KEY.add("iceberg.rest.oauth2.token"); SENSITIVE_KEY.add("iceberg.rest.oauth2.credential"); SENSITIVE_KEY.add("iceberg.rest.secret-access-key"); SENSITIVE_KEY.add("iceberg.rest.session-token"); + // Fluss client SASL credentials use a small, stable set of exact keys. The lake options are + // deliberately open-ended and namespaced, however: new paimon/filesystem credential names may arrive + // without a Doris release, so a lake option is a secret when its name carries a credential-like word, + // and endpoints and other operational settings stay visible. + SENSITIVE_KEY.add("fluss.client.security.sasl.username"); + SENSITIVE_KEY.add("fluss.client.security.sasl.password"); + SENSITIVE_KEY.add("fluss.client.security.sasl.jaas.config"); + SENSITIVE_KEY_PREFIXES.addAll(ConnectorSecuritySurface.ofSensitiveKeyPrefix("fluss.lake.paimon.", + "key", "secret", "password", "token", "credential").sensitiveKeyPrefixes()); // Inlined union of the legacy typed storage classes' @ConnectorProperty(sensitive = true) // key aliases (S3/GCS/Azure/OSS/OSS-HDFS/COS/OBS/Minio Properties). The set is // case-insensitive, so alias spellings differing only in case are listed once. Locked @@ -148,7 +167,9 @@ public void setAdditionalHiddenKeys(Set additionalHiddenKeys) { * cannot statically reference their typed properties classes the way the static block does for the * legacy {@code *Properties}. Instead, {@code FileSystemPluginManager} aggregates each loaded * provider's {@code sensitivePropertyKeys()} and registers them here at FE startup, before any - * SHOW CREATE / error-log printing occurs. + * SHOW CREATE / error-log printing occurs. {@code ConnectorPluginManager} does the same with each + * admitted connector provider's {@code securitySurface().sensitiveKeys()}, so fe-core needs no + * connector's secret property names. */ public static void registerSensitiveKeys(Collection keys) { if (keys == null) { @@ -160,12 +181,19 @@ public static void registerSensitiveKeys(Collection keys) { } /** - * Whether a property key must be masked in any user-visible catalog rendering. - * - *

Most connectors register exact aliases. Fluss lake options are deliberately open-ended and - * namespaced, however: new paimon/filesystem credential names may arrive without a Doris release. - * Match credential-like tails only inside that namespace so endpoints and other operational settings - * remain visible. Fluss client SASL credentials use a small, stable set of exact keys. + * Registers a namespace of secret keys that a connector provider declared with + * {@link ConnectorSecuritySurface#withSensitiveKeyPrefix}. {@code ConnectorPluginManager} registers them + * with the provider at FE startup, as it does the provider's exact keys through + * {@link #registerSensitiveKeys}. + */ + public static void registerSensitiveKeyPrefix(SensitiveKeyPrefix keyPrefix) { + SENSITIVE_KEY_PREFIXES.add(keyPrefix); + } + + /** + * Whether a property key must be masked in any user-visible catalog rendering: it is on the list, or it + * falls in a namespace of secret keys. The list and the namespaces are the engine's own plus those of + * every connector and filesystem provider registered at FE startup. */ public static boolean isSensitiveKey(Object key) { if (!(key instanceof String)) { @@ -175,19 +203,12 @@ public static boolean isSensitiveKey(Object key) { if (SENSITIVE_KEY.contains(stringKey)) { return true; } - String lower = stringKey.toLowerCase(Locale.ROOT); - if (lower.equals("fluss.client.security.sasl.username") - || lower.equals("fluss.client.security.sasl.password") - || lower.equals("fluss.client.security.sasl.jaas.config")) { - return true; - } - String lakePrefix = "fluss.lake.paimon."; - if (!lower.startsWith(lakePrefix)) { - return false; + for (SensitiveKeyPrefix keyPrefix : SENSITIVE_KEY_PREFIXES) { + if (keyPrefix.covers(stringKey)) { + return true; + } } - String tail = lower.substring(lakePrefix.length()); - return tail.contains("key") || tail.contains("secret") || tail.contains("password") - || tail.contains("token") || tail.contains("credential"); + return false; } @Override diff --git a/fe/fe-core/src/main/java/org/apache/doris/connector/ConnectorPluginManager.java b/fe/fe-core/src/main/java/org/apache/doris/connector/ConnectorPluginManager.java index e660a0425855c8..1d536cf9d69f4a 100644 --- a/fe/fe-core/src/main/java/org/apache/doris/connector/ConnectorPluginManager.java +++ b/fe/fe-core/src/main/java/org/apache/doris/connector/ConnectorPluginManager.java @@ -17,10 +17,13 @@ package org.apache.doris.connector; +import org.apache.doris.common.util.DatasourcePrintableMap; import org.apache.doris.connector.spi.Connector; import org.apache.doris.connector.spi.ConnectorConfFile; import org.apache.doris.connector.spi.ConnectorContext; import org.apache.doris.connector.spi.ConnectorProvider; +import org.apache.doris.connector.spi.ConnectorSecuritySurface; +import org.apache.doris.connector.spi.ConnectorSecuritySurface.SensitiveKeyPrefix; import org.apache.doris.datasource.CatalogFactory; import org.apache.doris.extension.loader.ApiVersionGate; import org.apache.doris.extension.loader.ClassLoadingPolicy; @@ -188,19 +191,22 @@ void loadBuiltins(ClassLoader classLoader) { providerClass.getName(), e); continue; } + ConnectorSecuritySurface securitySurface; try { - // Snapshot self-reported metadata before publishing the provider - // so one throwing implementation is rejected cleanly instead of - // aborting startup or being active without an inventory row. + // Snapshot self-reported metadata (the security surface included) before publishing the + // provider so one throwing implementation is rejected cleanly instead of + // aborting startup or being active without an inventory row. A surface built through the + // provider's own ServiceLoader fails with ServiceConfigurationError, as construction can. + securitySurface = provider.securitySurface(); PluginRegistry.getInstance().registerBuiltin(PLUGIN_FAMILY, provider); - } catch (RuntimeException e) { + } catch (ServiceConfigurationError | RuntimeException e) { LOG.warn("Skip built-in connector provider {}: self-reported metadata failed", providerClass.getName(), e); continue; } // Deliberately outside that catch: registerDiscovered's fail-loud // IllegalStateException reports a build error, not a "skip this one" condition. - if (registerDiscovered(provider, true)) { + if (registerDiscovered(provider, securitySurface, true)) { LOG.info("Registered built-in connector provider: {}", provider.getType()); } } @@ -212,15 +218,19 @@ void loadBuiltins(ClassLoader classLoader) { * {@link ConnectorProvider#supports} matches case-insensitively and catalog types are lower-cased before * routing). Refusing a reserved name here is what makes it impossible for a plugin to shadow a catalog type * the engine implements itself — routing order then cannot matter. The engine names it claims for - * {@code CREATE TABLE ... ENGINE=} go through the same two checks. + * {@code CREATE TABLE ... ENGINE=} go through the same two checks. An admitted provider's declared secrets + * (the keys and key namespaces on its {@code securitySurface()}) join the engine's masking list. * + * @param securitySurface the provider's {@code securitySurface()}, which the caller reads before it + * publishes anything: it is plugin code that may throw * @param failFast {@code true} for the classpath batch: two providers claiming one type name there is a * build error and must fail loud. {@code false} for the plugin-directory batch: that is a * deployment accident, so the offender is skipped and logged, preserving * {@link #loadPlugins}'s partial-success contract (one bad plugin dir must not stop FE). * @return true if the provider was admitted */ - boolean registerDiscovered(ConnectorProvider provider, boolean failFast) { + boolean registerDiscovered(ConnectorProvider provider, ConnectorSecuritySurface securitySurface, + boolean failFast) { String type = provider.getType(); Set engineNames = provider.acceptedCreateTableEngineNames(); String problem = typeNameProblem(type); @@ -242,10 +252,19 @@ boolean registerDiscovered(ConnectorProvider provider, boolean failFast) { for (String engineName : engineNames) { claimedEngineNames.add(engineName.toLowerCase()); } + registerSecrets(securitySurface); providers.add(provider); return true; } + /** Adds the secret keys and key namespaces a provider declares to the engine's masking list. */ + private static void registerSecrets(ConnectorSecuritySurface securitySurface) { + DatasourcePrintableMap.registerSensitiveKeys(securitySurface.sensitiveKeys()); + for (SensitiveKeyPrefix keyPrefix : securitySurface.sensitiveKeyPrefixes()) { + DatasourcePrintableMap.registerSensitiveKeyPrefix(keyPrefix); + } + } + private static String typeNameProblem(String type) { if (type == null || type.trim().isEmpty()) { return "getType() returned a blank type name"; @@ -314,9 +333,23 @@ public void loadPlugins(List pluginRoots) { runtimeManager.discard(handle.getPluginName()); continue; } + ConnectorProvider provider = handle.getFactory(); + // Snapshot the security surface before publishing anything: it is plugin code, and one bad plugin + // dir must not stop FE. A connector may build it through its own ServiceLoader, as Hive, Iceberg and + // Paimon do for their metastore backends, and a descriptor naming a backend that cannot be loaded + // fails that with ServiceConfigurationError, an Error. + ConnectorSecuritySurface securitySurface; + try { + securitySurface = provider.securitySurface(); + } catch (RuntimeException | LinkageError | ServiceConfigurationError e) { + runtimeManager.discard(handle.getPluginName()); + LOG.warn("Skip connector plugin '{}' from {}: securitySurface() failed", + handle.getPluginName(), handle.getPluginDir(), e); + continue; + } // The inventory row is written only for a provider that was actually admitted, so // information_schema.extensions never lists a connector the routing table cannot reach. - if (registerDiscovered(handle.getFactory(), false)) { + if (registerDiscovered(provider, securitySurface, false)) { PluginRegistry.getInstance().registerExternal(PLUGIN_FAMILY, handle); loadConnectorConfig(handle); LOG.info("Loaded connector plugin: name={}, pluginDir={}, jarCount={}", @@ -548,6 +581,7 @@ public void validateCreateTable(String catalogType, Map properti */ public void registerProvider(ConnectorProvider provider) { providers.add(0, provider); + registerSecrets(provider.securitySurface()); } private List providerNames() { diff --git a/fe/fe-core/src/main/java/org/apache/doris/nereids/parser/LogicalPlanBuilderForEncryption.java b/fe/fe-core/src/main/java/org/apache/doris/nereids/parser/LogicalPlanBuilderForEncryption.java index c1552b58f83afc..a3b4227fc39780 100644 --- a/fe/fe-core/src/main/java/org/apache/doris/nereids/parser/LogicalPlanBuilderForEncryption.java +++ b/fe/fe-core/src/main/java/org/apache/doris/nereids/parser/LogicalPlanBuilderForEncryption.java @@ -115,6 +115,15 @@ public LogicalPlan visitCreateCatalog(DorisParser.CreateCatalogContext ctx) { return super.visitCreateCatalog(ctx); } + // alter catalog clause (ALTER CATALOG ... SET PROPERTIES(...)) + @Override + public LogicalPlan visitAlterCatalogProperties(DorisParser.AlterCatalogPropertiesContext ctx) { + DorisParser.PropertyItemListContext properties = ctx.propertyItemList(); + encryptProperty(visitPropertyItemList(properties), properties.start.getStartIndex(), + properties.stop.getStopIndex()); + return super.visitAlterCatalogProperties(ctx); + } + // create repository clause (CREATE [READ ONLY] REPOSITORY ... WITH ... PROPERTIES(...)) @Override public LogicalPlan visitCreateRepository(DorisParser.CreateRepositoryContext ctx) { diff --git a/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/plans/commands/AlterCatalogPropertiesCommand.java b/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/plans/commands/AlterCatalogPropertiesCommand.java index 042a552f2d127f..0229b690f2d62a 100644 --- a/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/plans/commands/AlterCatalogPropertiesCommand.java +++ b/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/plans/commands/AlterCatalogPropertiesCommand.java @@ -30,7 +30,7 @@ /** * Represents the command for ALTER CATALOG ... SET PROPERTIES. */ -public class AlterCatalogPropertiesCommand extends AlterCatalogCommand { +public class AlterCatalogPropertiesCommand extends AlterCatalogCommand implements NeedAuditEncryption { private final Map newProperties; public AlterCatalogPropertiesCommand(String catalogName, Map properties) { @@ -50,4 +50,9 @@ public void doRun(ConnectContext ctx, StmtExecutor executor) throws Exception { public R accept(PlanVisitor visitor, C context) { return visitor.visitAlterCatalogPropertiesCommand(this, context); } + + @Override + public boolean needAuditEncryption() { + return true; + } } diff --git a/fe/fe-core/src/test/java/org/apache/doris/common/util/DatasourcePrintableMapTest.java b/fe/fe-core/src/test/java/org/apache/doris/common/util/DatasourcePrintableMapTest.java index 304b7b18573122..0960cbdea77cb5 100644 --- a/fe/fe-core/src/test/java/org/apache/doris/common/util/DatasourcePrintableMapTest.java +++ b/fe/fe-core/src/test/java/org/apache/doris/common/util/DatasourcePrintableMapTest.java @@ -17,6 +17,8 @@ package org.apache.doris.common.util; +import org.apache.doris.connector.spi.ConnectorSecuritySurface; + import org.junit.jupiter.api.Assertions; import org.junit.jupiter.api.Test; @@ -226,6 +228,7 @@ public void testFlussNamespacedCredentialsAreMaskedWithoutEnumeratingProviders() properties.put("fluss.lake.paimon.oss.secret-key", "oss-secret"); properties.put("fluss.lake.paimon.azure.oauth2_client_secret", "azure-secret"); properties.put("fluss.lake.paimon.rest.token", "rest-token"); + properties.put("FLUSS.LAKE.PAIMON.GCS.Secret-Key", "upper-case-secret"); properties.put("fluss.lake.paimon.s3.endpoint", "http://minio:9000"); String result = new DatasourcePrintableMap<>(properties, "=", false, false, true).toString(); @@ -236,6 +239,7 @@ public void testFlussNamespacedCredentialsAreMaskedWithoutEnumeratingProviders() Assertions.assertFalse(result.contains("oss-secret"), result); Assertions.assertFalse(result.contains("azure-secret"), result); Assertions.assertFalse(result.contains("rest-token"), result); + Assertions.assertFalse(result.contains("upper-case-secret"), result); Assertions.assertTrue(result.contains("fluss.lake.paimon.s3.endpoint = http://minio:9000"), result); } @@ -333,4 +337,29 @@ public void testRegisterSensitiveKeysMasksNewlyRegisteredKey() { Assertions.assertTrue(result.contains( "PROVIDER_CONTRIBUTED_SECRET_KEY = " + DatasourcePrintableMap.PASSWORD_MASK)); } + + @Test + public void testRegisterSensitiveKeyPrefixMasksTheKeysItCovers() { + // Simulates connector providers declaring pass-through namespaces at startup; the list is + // process-wide, hence prefixes no other test uses. + ConnectorSecuritySurface surface = ConnectorSecuritySurface + .ofSensitiveKeyPrefix("prefix_test_passthrough.", "Password", "token") + .withSensitiveKeyPrefix("prefix_test_all."); + surface.sensitiveKeyPrefixes().forEach(DatasourcePrintableMap::registerSensitiveKeyPrefix); + + Assertions.assertTrue(DatasourcePrintableMap.isSensitiveKey("prefix_test_passthrough.connection-password")); + Assertions.assertTrue(DatasourcePrintableMap.isSensitiveKey("PREFIX_TEST_PASSTHROUGH.auth.TOKEN")); + Assertions.assertFalse(DatasourcePrintableMap.isSensitiveKey("prefix_test_passthrough.connection-url")); + // Only the rest after the prefix is searched, and only under the prefix. + Assertions.assertFalse(DatasourcePrintableMap.isSensitiveKey("other.prefix_test_passthrough.password")); + Assertions.assertTrue(DatasourcePrintableMap.isSensitiveKey("prefix_test_all.endpoint")); + Assertions.assertFalse(DatasourcePrintableMap.isSensitiveKey("prefix_test_al.endpoint")); + + Map testMap = new HashMap<>(); + testMap.put("prefix_test_passthrough.connection-password", "super_secret_value"); + testMap.put("prefix_test_passthrough.connection-url", "jdbc:x://host"); + String result = new DatasourcePrintableMap<>(testMap, "=", false, false, true).toString(); + Assertions.assertFalse(result.contains("super_secret_value"), result); + Assertions.assertTrue(result.contains("prefix_test_passthrough.connection-url = jdbc:x://host"), result); + } } diff --git a/fe/fe-core/src/test/java/org/apache/doris/connector/ConnectorPluginManagerTest.java b/fe/fe-core/src/test/java/org/apache/doris/connector/ConnectorPluginManagerTest.java index 643b7bbeeeee09..f7883fd2542644 100644 --- a/fe/fe-core/src/test/java/org/apache/doris/connector/ConnectorPluginManagerTest.java +++ b/fe/fe-core/src/test/java/org/apache/doris/connector/ConnectorPluginManagerTest.java @@ -17,12 +17,18 @@ package org.apache.doris.connector; +import org.apache.doris.common.util.DatasourcePrintableMap; import org.apache.doris.connector.spi.Connector; import org.apache.doris.connector.spi.ConnectorContext; import org.apache.doris.connector.spi.ConnectorMetadata; import org.apache.doris.connector.spi.ConnectorProvider; +import org.apache.doris.connector.spi.ConnectorSecuritySurface; import org.apache.doris.connector.spi.ConnectorSession; +import org.apache.doris.connectorconf.testplugins.BackendDiscoveringConnectorProvider; import org.apache.doris.datasource.CatalogFactory; +import org.apache.doris.extension.loader.PluginRegistry; +import org.apache.doris.pluginapiversion.testplugins.VersionProbeConnectorProvider; +import org.apache.doris.utframe.PluginJarWriter; import org.junit.jupiter.api.Assertions; import org.junit.jupiter.api.BeforeEach; @@ -39,6 +45,7 @@ import java.util.Arrays; import java.util.Collections; import java.util.HashSet; +import java.util.LinkedHashMap; import java.util.Map; import java.util.Properties; import java.util.Set; @@ -174,10 +181,10 @@ void duplicateTypeNameOnClasspathFailsLoud() { // Type names are the identity CREATE CATALOG routes on and the anchor of source-prefixed namespaces. // Two providers claiming one name on the classpath is a build error, and the winner would be decided by // ServiceLoader order — silently, differently per build. - Assertions.assertTrue(manager.registerDiscovered(createProvider("dup", true, "first"), true)); + Assertions.assertTrue(admit(createProvider("dup", true, "first"), true)); IllegalStateException e = Assertions.assertThrows(IllegalStateException.class, - () -> manager.registerDiscovered(createProvider("DUP", true, "second"), true), + () -> admit(createProvider("DUP", true, "second"), true), "a classpath duplicate must fail loud, and case must not be a way around it"); Assertions.assertTrue(e.getMessage().contains("already claimed"), e.getMessage()); } @@ -187,8 +194,8 @@ void duplicateTypeNameInPluginDirectoryIsSkippedButDoesNotStopFe() { // Same conflict, different blame: two plugin directories shipping one type name is a deployment // accident. loadPlugins promises partial success — one bad plugin dir must not keep FE from starting — // so the offender is skipped and the incumbent keeps serving. - Assertions.assertTrue(manager.registerDiscovered(createProvider("dup", true, "first"), false)); - Assertions.assertFalse(manager.registerDiscovered(createProvider("dup", true, "second"), false), + Assertions.assertTrue(admit(createProvider("dup", true, "first"), false)); + Assertions.assertFalse(admit(createProvider("dup", true, "second"), false), "the second claimant must be refused, not silently appended"); Assertions.assertEquals(Collections.singletonList("dup"), manager.getRegisteredTypes(), @@ -205,10 +212,10 @@ void providerClaimingAnEngineBuiltinCatalogTypeIsRefused() { // creates. Refusing it at registration means the shadowing case cannot arise at all. for (String builtin : new String[] {"doris", "test", "lakesoul"}) { Assertions.assertTrue(CatalogFactory.isBuiltinCatalogType(builtin), builtin); - Assertions.assertFalse(manager.registerDiscovered(createProvider(builtin, true, "x"), false), + Assertions.assertFalse(admit(createProvider(builtin, true, "x"), false), "a plugin must not be allowed to claim engine built-in type '" + builtin + "'"); Assertions.assertThrows(IllegalStateException.class, - () -> manager.registerDiscovered(createProvider(builtin.toUpperCase(), true, "x"), + () -> admit(createProvider(builtin.toUpperCase(), true, "x"), true), "on the classpath the same violation must fail loud, case-insensitively"); } @@ -220,16 +227,77 @@ void providerClaimingAnEngineBuiltinCatalogTypeIsRefused() { void blankTypeNameIsRefused() { // getType() is now the sole admission ticket for third-party code; a blank name would otherwise sit in // the registry and match nothing, or match everything the moment someone compares loosely. - Assertions.assertFalse(manager.registerDiscovered(createProvider(" ", true, "x"), false), + Assertions.assertFalse(admit(createProvider(" ", true, "x"), false), "a blank type name must be refused"); Assertions.assertTrue(manager.getRegisteredTypes().isEmpty()); } + @Test + void admittedProviderSecretKeysAreMaskedInShownCatalogProperties() { + // SHOW CREATE CATALOG masks only the keys on DatasourcePrintableMap's list. A connector's own secret + // keys must reach that list through its security surface, so fe-core never has to name them. The + // list is process-wide, hence key names no other test uses. + String token = "test.connector.admitted.bearer-token"; + String passThroughSecret = "test.connector.admitted.passthrough.client-secret"; + Assertions.assertFalse(DatasourcePrintableMap.isSensitiveKey(token)); + Assertions.assertFalse(DatasourcePrintableMap.isSensitiveKey(passThroughSecret)); + + Assertions.assertTrue(admit(createProvider("secret_type", + ConnectorSecuritySurface.ofSensitiveKeys(token) + .withSensitiveKeyPrefix("test.connector.admitted.passthrough.", "secret")), false)); + + Assertions.assertTrue(DatasourcePrintableMap.isSensitiveKey(token.toUpperCase()), + "declared keys must match case-insensitively, like the engine's own list"); + Assertions.assertTrue(DatasourcePrintableMap.isSensitiveKey(passThroughSecret.toUpperCase()), + "a declared namespace covers the credential-like keys under it, case-insensitively"); + Map properties = new LinkedHashMap<>(); + properties.put("type", "secret_type"); + properties.put(token, "token-value"); + properties.put(passThroughSecret, "client-secret-value"); + properties.put("test.connector.admitted.passthrough.endpoint", "http://host:1"); + // The rendering SHOW CREATE CATALOG uses (CatalogMgr#showCreateCatalog). + String shown = new DatasourcePrintableMap<>(properties, "=", true, true, true, true).toString(); + Assertions.assertFalse(shown.contains("token-value"), shown); + Assertions.assertFalse(shown.contains("client-secret-value"), shown); + Assertions.assertTrue(shown.contains("\"test.connector.admitted.passthrough.endpoint\" = \"http://host:1\""), + "a key under the namespace without a marker stays visible: " + shown); + Assertions.assertTrue(shown.contains("\"" + token + "\" = \"" + DatasourcePrintableMap.PASSWORD_MASK + "\""), + shown); + Assertions.assertTrue(shown.contains("\"type\" = \"secret_type\""), "undeclared keys stay visible: " + shown); + } + + @Test + void refusedProviderDoesNotExtendTheMaskingList() { + // Only a provider that will serve catalogs contributes keys; a refused claimant leaves no trace. + String token = "test.connector.refused.bearer-token"; + Assertions.assertTrue(admit(createProvider("dup", true, "first"), false)); + Assertions.assertFalse(admit(createProvider("dup", + ConnectorSecuritySurface.ofSensitiveKeys(token) + .withSensitiveKeyPrefix("test.connector.refused.passthrough.")), false)); + + Assertions.assertFalse(DatasourcePrintableMap.isSensitiveKey(token)); + Assertions.assertFalse(DatasourcePrintableMap.isSensitiveKey("test.connector.refused.passthrough.x")); + } + + @Test + void registerProviderMasksDeclaredSecretKeysToo() { + // Tests stand in for a real plugin through registerProvider, so it must mask like the admission path. + String token = "test.connector.override.api-key"; + manager.registerProvider(createProvider("override_type", + ConnectorSecuritySurface.ofSsrfEndpoints("override.uri").withSensitiveKeys(token) + .withSensitiveKeyPrefix("test.connector.override.passthrough.", "password"))); + + Assertions.assertTrue(DatasourcePrintableMap.isSensitiveKey(token)); + Assertions.assertTrue(DatasourcePrintableMap.isSensitiveKey("test.connector.override.passthrough.password")); + Assertions.assertFalse(DatasourcePrintableMap.isSensitiveKey("override.uri"), + "an endpoint key is policed, not masked"); + } + @Test void registerProviderStillShadowsADiscoveredType() { // registerProvider exists to stand in for a real plugin in tests (several rely on shadowing a real // type name). The uniqueness check must not reach it, or those tests lose their only seam. - Assertions.assertTrue(manager.registerDiscovered(createProvider("iceberg", true, "real"), false)); + Assertions.assertTrue(admit(createProvider("iceberg", true, "real"), false)); manager.registerProvider(createProvider("iceberg", true, "override")); Connector connector = manager.createConnector("iceberg", Collections.emptyMap(), testContext); @@ -274,6 +342,62 @@ private static int kernelApiMajor() throws IOException { return Integer.parseInt(declared.substring(0, declared.indexOf('.'))); } + @Test + void classpathProviderWhoseSecuritySurfaceThrowsIsSkippedNotFatal(@TempDir Path tempDir) throws Exception { + // A connector's security surface is plugin code. When it throws, FE skips the provider, as it does a + // filesystem provider whose sensitivePropertyKeys() throws, and starts. + Path jar = createClasspathProviderJar(tempDir.resolve("throwing-surface.jar"), kernelApiMajor() + ".0", + ThrowingSurfaceClasspathProvider.class); + ConnectorPluginManager loading = new ConnectorPluginManager(); + try (URLClassLoader loader = providerClassLoader(jar, ThrowingSurfaceClasspathProvider.class)) { + Assertions.assertDoesNotThrow(() -> loading.loadBuiltins(loader)); + } + Assertions.assertFalse(loading.getRegisteredTypes().contains("throwing-surface-test")); + } + + @Test + void classpathProviderWhoseSecuritySurfaceFailsInItsOwnServiceLoaderIsSkippedNotFatal(@TempDir Path tempDir) + throws Exception { + // Hive, Iceberg and Paimon read their metastore backends through a ServiceLoader to build their security + // surfaces. A service descriptor naming a backend that is not there fails that lookup with + // ServiceConfigurationError, an Error: FE must skip the provider and start all the same. + Path jar = createClasspathProviderJar(tempDir.resolve("backend-discovering.jar"), kernelApiMajor() + ".0", + BackendDiscoveringConnectorProvider.class); + Path leftBehind = writeBackendDescriptorJar(tempDir.resolve("left-behind.jar")); + ConnectorPluginManager loading = new ConnectorPluginManager(); + try (URLClassLoader loader = + providerClassLoader(jar, BackendDiscoveringConnectorProvider.class, leftBehind)) { + Assertions.assertDoesNotThrow(() -> loading.loadBuiltins(loader)); + } + Assertions.assertFalse(loading.getRegisteredTypes().contains(BackendDiscoveringConnectorProvider.TYPE)); + } + + @Test + void pluginWhoseSecuritySurfaceFailsInItsOwnServiceLoaderIsSkippedNotFatal(@TempDir Path tempDir) + throws Exception { + // The same failure from a plugin directory, as a jar left in lib/ by an earlier release causes when its + // descriptor names a backend the plugin no longer ships. loadPlugins promises partial success: the plugin + // is skipped, and the plugin next to it still loads. + String apiVersion = kernelApiMajor() + ".0"; + Path root = tempDir.resolve("connector-plugins"); + PluginJarWriter.writePluginRoot(root, "backend-discovering", BackendDiscoveringConnectorProvider.class, + ConnectorProvider.class, "Doris-Connector-Plugin-Api-Version", apiVersion, + Collections.singletonList(BackendDiscoveringConnectorProvider.Backend.class)); + writeBackendDescriptorJar(root.resolve("backend-discovering").resolve("lib").resolve("left-behind.jar")); + PluginJarWriter.writePluginRoot(root, "version-probe", VersionProbeConnectorProvider.class, + ConnectorProvider.class, "Doris-Connector-Plugin-Api-Version", apiVersion, Collections.emptyList()); + ConnectorPluginManager loading = new ConnectorPluginManager(); + try { + Assertions.assertDoesNotThrow(() -> loading.loadPlugins(Collections.singletonList(root))); + Assertions.assertFalse(loading.getRegisteredTypes().contains(BackendDiscoveringConnectorProvider.TYPE)); + Assertions.assertTrue(loading.getRegisteredTypes().contains("version_probe"), + loading.getRegisteredTypes().toString()); + } finally { + // loadPlugins writes inventory rows into a process-wide singleton. + PluginRegistry.getInstance().clearForTest(); + } + } + @Test void incompatibleClasspathProviderIsRejectedBeforeConstruction(@TempDir Path tempDir) throws Exception { System.clearProperty(CLASSPATH_PROVIDER_CONSTRUCTED); @@ -294,9 +418,9 @@ void duplicateCreateTableEngineNameIsRefused() { // Engine names route CREATE TABLE ... ENGINE= the same way type names route CREATE CATALOG. Two // plugins answering to one engine name would make the statement mean whichever registered first, so // the conflict is refused where it can still be refused: at registration. - Assertions.assertTrue(manager.registerDiscovered( + Assertions.assertTrue(admit( createProviderWithEngines("first_type", "shared_engine"), false)); - Assertions.assertFalse(manager.registerDiscovered( + Assertions.assertFalse(admit( createProviderWithEngines("second_type", "SHARED_ENGINE"), false), "a second claimant of the same engine name must be refused, case-insensitively"); @@ -304,7 +428,7 @@ void duplicateCreateTableEngineNameIsRefused() { "the refused provider must not end up in the registry"); IllegalStateException e = Assertions.assertThrows(IllegalStateException.class, - () -> manager.registerDiscovered(createProviderWithEngines("third_type", "shared_engine"), true), + () -> admit(createProviderWithEngines("third_type", "shared_engine"), true), "on the classpath the same conflict must fail loud"); Assertions.assertTrue(e.getMessage().contains("already claimed"), e.getMessage()); } @@ -315,7 +439,7 @@ void providerClaimingAnEngineReservedEngineNameIsRefused() { // owe the user a specific "use X instead" message from InternalCatalog. A plugin claiming one would // silently take over a statement the engine answers for. for (String reserved : new String[] {"olap", "mysql", "odbc", "broker"}) { - Assertions.assertFalse(manager.registerDiscovered( + Assertions.assertFalse(admit( createProviderWithEngines("t_" + reserved, reserved.toUpperCase()), false), "a plugin must not be allowed to claim reserved engine name '" + reserved + "'"); } @@ -327,10 +451,10 @@ void providerClaimingAnEngineReservedEngineNameIsRefused() { void refusedProviderDoesNotLeaveItsTypeOrEngineNameClaimed() { // The checks run before anything is claimed, so a provider rejected for one reason must not poison the // name it never got. Otherwise a bad plugin directory could permanently disable a good one. - Assertions.assertFalse(manager.registerDiscovered(createProviderWithEngines("good_type", "olap"), false), + Assertions.assertFalse(admit(createProviderWithEngines("good_type", "olap"), false), "rejected for the reserved engine name"); - Assertions.assertTrue(manager.registerDiscovered(createProviderWithEngines("good_type", "good_engine"), + Assertions.assertTrue(admit(createProviderWithEngines("good_type", "good_engine"), false), "the type name must still be free after the earlier provider was refused"); Assertions.assertEquals(Collections.singletonList("good_type"), manager.getRegisteredTypes()); @@ -372,14 +496,36 @@ public Connector create(Map properties, ConnectorContext context } } + public static class ThrowingSurfaceClasspathProvider implements ConnectorProvider { + @Override + public String getType() { + return "throwing-surface-test"; + } + + @Override + public Connector create(Map properties, ConnectorContext context) { + return null; + } + + @Override + public ConnectorSecuritySurface securitySurface() { + throw new IllegalStateException("the connector could not build its security surface"); + } + } + private static Path createClasspathProviderJar(Path jarPath, String apiVersion) throws IOException { + return createClasspathProviderJar(jarPath, apiVersion, ClasspathProvider.class); + } + + private static Path createClasspathProviderJar(Path jarPath, String apiVersion, + Class providerClass) throws IOException { Manifest manifest = new Manifest(); manifest.getMainAttributes().put(Attributes.Name.MANIFEST_VERSION, "1.0"); manifest.getMainAttributes().putValue("Doris-Connector-Plugin-Api-Version", apiVersion); - String classEntry = ClasspathProvider.class.getName().replace('.', '/') + ".class"; + String classEntry = providerClass.getName().replace('.', '/') + ".class"; try (JarOutputStream jar = new JarOutputStream(Files.newOutputStream(jarPath), manifest)) { jar.putNextEntry(new JarEntry(classEntry)); - try (InputStream bytes = ClasspathProvider.class.getClassLoader().getResourceAsStream(classEntry)) { + try (InputStream bytes = providerClass.getClassLoader().getResourceAsStream(classEntry)) { Assertions.assertNotNull(bytes, "provider class bytes"); byte[] buffer = new byte[8192]; int read; @@ -389,17 +535,27 @@ private static Path createClasspathProviderJar(Path jarPath, String apiVersion) } jar.closeEntry(); jar.putNextEntry(new JarEntry("META-INF/services/" + ConnectorProvider.class.getName())); - jar.write((ClasspathProvider.class.getName() + "\n").getBytes(StandardCharsets.UTF_8)); + jar.write((providerClass.getName() + "\n").getBytes(StandardCharsets.UTF_8)); jar.closeEntry(); } return jarPath; } private static URLClassLoader providerClassLoader(Path jarPath) throws IOException { - return new URLClassLoader(new URL[] {jarPath.toUri().toURL()}, ConnectorProvider.class.getClassLoader()) { + return providerClassLoader(jarPath, ClasspathProvider.class); + } + + private static URLClassLoader providerClassLoader(Path jarPath, Class providerClass, + Path... moreJars) throws IOException { + URL[] urls = new URL[moreJars.length + 1]; + urls[0] = jarPath.toUri().toURL(); + for (int i = 0; i < moreJars.length; i++) { + urls[i + 1] = moreJars[i].toUri().toURL(); + } + return new URLClassLoader(urls, ConnectorProvider.class.getClassLoader()) { @Override protected Class loadClass(String name, boolean resolve) throws ClassNotFoundException { - if (name.equals(ClasspathProvider.class.getName())) { + if (name.equals(providerClass.getName())) { synchronized (getClassLoadingLock(name)) { Class loaded = findLoadedClass(name); if (loaded == null) { @@ -416,10 +572,49 @@ protected Class loadClass(String name, boolean resolve) throws ClassNotFoundE }; } + /** + * Writes a jar holding only a service descriptor for {@link BackendDiscoveringConnectorProvider.Backend} that + * names a class no jar has, as a jar left behind by an earlier release would. + */ + private static Path writeBackendDescriptorJar(Path jarPath) throws IOException { + Files.createDirectories(jarPath.getParent()); + try (JarOutputStream jar = new JarOutputStream(Files.newOutputStream(jarPath))) { + jar.putNextEntry(new JarEntry( + "META-INF/services/" + BackendDiscoveringConnectorProvider.Backend.class.getName())); + jar.write("org.apache.doris.connectorconf.testplugins.RemovedBackend\n".getBytes(StandardCharsets.UTF_8)); + jar.closeEntry(); + } + return jarPath; + } + + /** Admits {@code provider} as both loading paths do: with its security surface read up front. */ + private boolean admit(ConnectorProvider provider, boolean failFast) { + return manager.registerDiscovered(provider, provider.securitySurface(), failFast); + } + private static ConnectorProvider createProvider(String type) { return createProvider(type, true, ""); } + private static ConnectorProvider createProvider(String type, ConnectorSecuritySurface surface) { + return new ConnectorProvider() { + @Override + public String getType() { + return type; + } + + @Override + public Connector create(Map properties, ConnectorContext context) { + return new TaggedConnector(""); + } + + @Override + public ConnectorSecuritySurface securitySurface() { + return surface; + } + }; + } + private static ConnectorProvider createProvider(String type, boolean standalone, String tag) { return new ConnectorProvider() { @Override diff --git a/fe/fe-core/src/test/java/org/apache/doris/connectorconf/testplugins/BackendDiscoveringConnectorProvider.java b/fe/fe-core/src/test/java/org/apache/doris/connectorconf/testplugins/BackendDiscoveringConnectorProvider.java new file mode 100644 index 00000000000000..5f8c5e8f968b34 --- /dev/null +++ b/fe/fe-core/src/test/java/org/apache/doris/connectorconf/testplugins/BackendDiscoveringConnectorProvider.java @@ -0,0 +1,69 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +package org.apache.doris.connectorconf.testplugins; + +import org.apache.doris.connector.spi.Connector; +import org.apache.doris.connector.spi.ConnectorContext; +import org.apache.doris.connector.spi.ConnectorProvider; +import org.apache.doris.connector.spi.ConnectorSecuritySurface; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.ServiceLoader; + +/** + * A connector provider that builds its security surface from backends it discovers through its own + * {@link ServiceLoader}, the way the Hive, Iceberg and Paimon providers declare their metastore backends' secret + * keys through {@code MetaStoreProviders}. Its class bytes are copied into a temporary jar, so a test can load it + * the way FE loads a shipped connector. + * + *

When a service descriptor in the provider's jars names a backend that cannot be loaded, the lookup throws + * {@link java.util.ServiceConfigurationError}, which is an {@link Error} and neither a {@link RuntimeException} + * nor a {@link LinkageError}. + * + *

It lives outside {@code org.apache.doris.connector.} for the reason {@link ConfProbeConnectorProviderA} does. + */ +public class BackendDiscoveringConnectorProvider implements ConnectorProvider { + + public static final String TYPE = "backend_discovering"; + + /** What the provider's backends implement; its jars register them under this interface's name. */ + public interface Backend { + String sensitiveKey(); + } + + @Override + public String getType() { + return TYPE; + } + + @Override + public Connector create(Map properties, ConnectorContext context) { + throw new UnsupportedOperationException(TYPE + " exists to be admitted or skipped at load time"); + } + + @Override + public ConnectorSecuritySurface securitySurface() { + List keys = new ArrayList<>(); + for (Backend backend : ServiceLoader.load(Backend.class, getClass().getClassLoader())) { + keys.add(backend.sensitiveKey()); + } + return ConnectorSecuritySurface.EMPTY.withSensitiveKeys(keys); + } +} diff --git a/fe/fe-core/src/test/java/org/apache/doris/nereids/parser/EncryptSQLTest.java b/fe/fe-core/src/test/java/org/apache/doris/nereids/parser/EncryptSQLTest.java index 5ccb3ca9400524..e047f38e0d58cc 100644 --- a/fe/fe-core/src/test/java/org/apache/doris/nereids/parser/EncryptSQLTest.java +++ b/fe/fe-core/src/test/java/org/apache/doris/nereids/parser/EncryptSQLTest.java @@ -161,6 +161,20 @@ public void testEncryption() throws Exception { + ")"; parseAndCheck(sql, res); + // ALTER CATALOG sets the same secrets as CREATE CATALOG, so its audit text is masked the same way. + sql = "ALTER CATALOG ctl SET PROPERTIES(" + + " \"s3.endpoint\" = \"s3.us-east-1.amazonaws.com\"," + + " \"password\" = \"abc\"," + + " \"s3.secret_key\" = \"abc\"" + + ")"; + + res = "ALTER CATALOG ctl SET PROPERTIES(" + + " \"s3.endpoint\" = \"s3.us-east-1.amazonaws.com\"," + + " \"password\" = \"*XXX\"," + + " \"s3.secret_key\" = \"*XXX\"" + + ")"; + parseAndCheck(sql, res); + sql = "CREATE TABLE mysql_tbl(" + " k1 DATE," + " k2 INT," diff --git a/regression-test/suites/audit/test_external_catalog_credential_masking.groovy b/regression-test/suites/audit/test_external_catalog_credential_masking.groovy index b7b1bd4aebc530..b13febbe1a96eb 100644 --- a/regression-test/suites/audit/test_external_catalog_credential_masking.groovy +++ b/regression-test/suites/audit/test_external_catalog_credential_masking.groovy @@ -18,18 +18,22 @@ suite("test_external_catalog_credential_masking", "p0,nonConcurrent") { String credentialCatalog = "audit_iceberg_credential_masking" String tokenCatalog = "audit_iceberg_token_masking" + String jdbcCatalog = "audit_iceberg_jdbc_password_masking" // Low-entropy placeholders keep this negative masking test distinct from real credentials. String accessKey = "aaaaaaaaaaaaaaaaaaaa" String secretKey = "bbbbbbbbbbbbbbbbbbbb" String oauthCredential = "cccccccccccccccccccc" String oauthToken = "dddddddddddddddddddd" + String jdbcPassword = "eeeeeeeeeeeeeeeeeeee" + String alteredJdbcPassword = "ffffffffffffffffffff" - def waitForAuditStatement = { String catalogName -> + // The audit log files CREATE CATALOG under stmt_type OTHER and ALTER CATALOG under ALTER. + def waitForAuditStatement = { String catalogName, String statement = "create catalog", String stmtType = "OTHER" -> String query = """ select stmt from __internal_schema.audit_log - where stmt_type = 'OTHER' - and lower(stmt) like '%create catalog ${catalogName}%' + where stmt_type = '${stmtType}' + and lower(stmt) like '%${statement} ${catalogName}%' and instr(stmt, '*XXX') > 0 order by time asc limit 1 @@ -51,6 +55,7 @@ suite("test_external_catalog_credential_masking", "p0,nonConcurrent") { try { sql """drop catalog if exists ${credentialCatalog}""" sql """drop catalog if exists ${tokenCatalog}""" + sql """drop catalog if exists ${jdbcCatalog}""" sql """truncate table __internal_schema.audit_log""" sql """ @@ -76,6 +81,38 @@ suite("test_external_catalog_credential_masking", "p0,nonConcurrent") { 'iceberg.rest.oauth2.token' = '${oauthToken}' ) """ + // iceberg.jdbc.password is on no list fe-core keeps: only the iceberg connector knows it, and + // declares it on its security surface. The catalog is never initialized, so nothing connects. + sql """ + create catalog ${jdbcCatalog} properties ( + 'type' = 'iceberg', + 'iceberg.catalog.type' = 'jdbc', + 'uri' = 'jdbc:mysql://127.0.0.1:1/iceberg', + 'iceberg.jdbc.catalog_name' = 'audit_masking', + 'iceberg.jdbc.user' = 'iceberg', + 'iceberg.jdbc.password' = '${jdbcPassword}', + 'warehouse' = 's3://audit-masking/warehouse', + 's3.access_key' = '${accessKey}', + 's3.secret_key' = '${secretKey}', + 's3.endpoint' = 'http://127.0.0.1:1', + 's3.region' = 'us-east-1' + ) + """ + String shownJdbc = sql("show create catalog ${jdbcCatalog}")[0][1].toString() + assertFalse(shownJdbc.contains(jdbcPassword)) + assertTrue(shownJdbc.contains('"iceberg.jdbc.password" = "*XXX"')) + assertTrue(shownJdbc.contains('"iceberg.jdbc.user" = "iceberg"')) + + // ALTER CATALOG sets the same secrets, so its statement text must be masked in the audit log too. + sql """ + alter catalog ${jdbcCatalog} set properties ( + 'iceberg.jdbc.password' = '${alteredJdbcPassword}', + 's3.secret_key' = '${secretKey}' + ) + """ + shownJdbc = sql("show create catalog ${jdbcCatalog}")[0][1].toString() + assertFalse(shownJdbc.contains(alteredJdbcPassword)) + assertTrue(shownJdbc.contains('"iceberg.jdbc.password" = "*XXX"')) sql """call flush_audit_log()""" // Audit statements are independently persisted after parsing, so they must use the @@ -91,9 +128,20 @@ suite("test_external_catalog_credential_masking", "p0,nonConcurrent") { String tokenStmt = waitForAuditStatement(tokenCatalog) assertFalse(tokenStmt.contains(oauthToken)) assertTrue(tokenStmt.contains('"iceberg.rest.oauth2.token" = "*XXX"')) + + String jdbcCreateStmt = waitForAuditStatement(jdbcCatalog) + assertFalse(jdbcCreateStmt.contains(jdbcPassword)) + assertTrue(jdbcCreateStmt.contains('"iceberg.jdbc.password" = "*XXX"')) + + String jdbcAlterStmt = waitForAuditStatement(jdbcCatalog, "alter catalog", "ALTER") + assertFalse(jdbcAlterStmt.contains(alteredJdbcPassword)) + assertFalse(jdbcAlterStmt.contains(secretKey)) + assertTrue(jdbcAlterStmt.contains('"iceberg.jdbc.password" = "*XXX"')) + assertTrue(jdbcAlterStmt.contains('"s3.secret_key" = "*XXX"')) } finally { sql """drop catalog if exists ${credentialCatalog}""" sql """drop catalog if exists ${tokenCatalog}""" + sql """drop catalog if exists ${jdbcCatalog}""" } }) }