From 40605d3ad751111a8eba899e6e2895de897f5e8f Mon Sep 17 00:00:00 2001 From: Pierre Villard Date: Mon, 31 Aug 2026 19:04:41 +0200 Subject: [PATCH 1/4] NIFI-16260 - Add a Database Password Provider for GCP CloudSQL --- .../nifi-gcp-processors/pom.xml | 5 + .../gcp/cloudsql/CloudSqlDatabaseType.java | 47 + ...cpCloudSqlIamDatabasePasswordProvider.java | 677 +++++++ .../CredentialPropertyDescriptors.java | 7 + ...IdentityFederationCredentialsStrategy.java | 17 +- .../GCPCredentialsControllerService.java | 19 +- ...g.apache.nifi.controller.ControllerService | 1 + .../additionalDetails.md | 118 ++ .../additionalDetails.md | 38 +- ...oudSqlIamDatabasePasswordProviderTest.java | 1554 +++++++++++++++++ ...tityFederationCredentialsStrategyTest.java | 131 ++ .../service/GCPCredentialsServiceTest.java | 149 +- 12 files changed, 2742 insertions(+), 21 deletions(-) create mode 100644 nifi-extension-bundles/nifi-gcp-bundle/nifi-gcp-processors/src/main/java/org/apache/nifi/processors/gcp/cloudsql/CloudSqlDatabaseType.java create mode 100644 nifi-extension-bundles/nifi-gcp-bundle/nifi-gcp-processors/src/main/java/org/apache/nifi/processors/gcp/cloudsql/GcpCloudSqlIamDatabasePasswordProvider.java create mode 100644 nifi-extension-bundles/nifi-gcp-bundle/nifi-gcp-processors/src/main/resources/docs/org.apache.nifi.processors.gcp.cloudsql.GcpCloudSqlIamDatabasePasswordProvider/additionalDetails.md create mode 100644 nifi-extension-bundles/nifi-gcp-bundle/nifi-gcp-processors/src/test/java/org/apache/nifi/processors/gcp/cloudsql/GcpCloudSqlIamDatabasePasswordProviderTest.java create mode 100644 nifi-extension-bundles/nifi-gcp-bundle/nifi-gcp-processors/src/test/java/org/apache/nifi/processors/gcp/credentials/factory/strategies/WorkloadIdentityFederationCredentialsStrategyTest.java diff --git a/nifi-extension-bundles/nifi-gcp-bundle/nifi-gcp-processors/pom.xml b/nifi-extension-bundles/nifi-gcp-bundle/nifi-gcp-processors/pom.xml index d9b2a406c054..acf145127e18 100644 --- a/nifi-extension-bundles/nifi-gcp-bundle/nifi-gcp-processors/pom.xml +++ b/nifi-extension-bundles/nifi-gcp-bundle/nifi-gcp-processors/pom.xml @@ -72,6 +72,11 @@ 2.12.0-SNAPSHOT provided + + org.apache.nifi + nifi-dbcp-service-api + provided + org.apache.nifi nifi-resource-transfer diff --git a/nifi-extension-bundles/nifi-gcp-bundle/nifi-gcp-processors/src/main/java/org/apache/nifi/processors/gcp/cloudsql/CloudSqlDatabaseType.java b/nifi-extension-bundles/nifi-gcp-bundle/nifi-gcp-processors/src/main/java/org/apache/nifi/processors/gcp/cloudsql/CloudSqlDatabaseType.java new file mode 100644 index 000000000000..5ed71b15706f --- /dev/null +++ b/nifi-extension-bundles/nifi-gcp-bundle/nifi-gcp-processors/src/main/java/org/apache/nifi/processors/gcp/cloudsql/CloudSqlDatabaseType.java @@ -0,0 +1,47 @@ +/* + * 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.nifi.processors.gcp.cloudsql; + +import org.apache.nifi.components.DescribedValue; + +public enum CloudSqlDatabaseType implements DescribedValue { + POSTGRESQL("PostgreSQL", "Use Cloud SQL IAM database authentication for PostgreSQL."), + MYSQL("MySQL", "Use Cloud SQL IAM database authentication for MySQL over a direct JDBC/TLS connection."); + + private final String displayName; + private final String description; + + CloudSqlDatabaseType(final String displayName, final String description) { + this.displayName = displayName; + this.description = description; + } + + @Override + public String getValue() { + return name(); + } + + @Override + public String getDisplayName() { + return displayName; + } + + @Override + public String getDescription() { + return description; + } +} diff --git a/nifi-extension-bundles/nifi-gcp-bundle/nifi-gcp-processors/src/main/java/org/apache/nifi/processors/gcp/cloudsql/GcpCloudSqlIamDatabasePasswordProvider.java b/nifi-extension-bundles/nifi-gcp-bundle/nifi-gcp-processors/src/main/java/org/apache/nifi/processors/gcp/cloudsql/GcpCloudSqlIamDatabasePasswordProvider.java new file mode 100644 index 000000000000..e5d7b8fe306a --- /dev/null +++ b/nifi-extension-bundles/nifi-gcp-bundle/nifi-gcp-processors/src/main/java/org/apache/nifi/processors/gcp/cloudsql/GcpCloudSqlIamDatabasePasswordProvider.java @@ -0,0 +1,677 @@ +/* + * 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.nifi.processors.gcp.cloudsql; + +import com.google.auth.oauth2.AccessToken; +import com.google.auth.oauth2.GoogleCredentials; +import com.google.auth.oauth2.IdentityPoolCredentials; +import com.google.auth.oauth2.ImpersonatedCredentials; +import org.apache.commons.lang3.StringUtils; +import org.apache.nifi.annotation.documentation.CapabilityDescription; +import org.apache.nifi.annotation.documentation.Tags; +import org.apache.nifi.annotation.lifecycle.OnDisabled; +import org.apache.nifi.annotation.lifecycle.OnEnabled; +import org.apache.nifi.components.ConfigVerificationResult; +import org.apache.nifi.components.ConfigVerificationResult.Outcome; +import org.apache.nifi.components.PropertyDescriptor; +import org.apache.nifi.components.PropertyValue; +import org.apache.nifi.controller.AbstractControllerService; +import org.apache.nifi.controller.ConfigurationContext; +import org.apache.nifi.controller.VerifiableControllerService; +import org.apache.nifi.dbcp.api.DatabasePasswordProvider; +import org.apache.nifi.dbcp.api.DatabasePasswordRequestContext; +import org.apache.nifi.gcp.credentials.service.GCPCredentialsService; +import org.apache.nifi.logging.ComponentLog; +import org.apache.nifi.processor.exception.ProcessException; +import org.apache.nifi.reporting.InitializationException; + +import java.io.IOException; +import java.net.URI; +import java.net.URLDecoder; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Objects; +import java.util.Optional; +import java.util.Set; +import java.util.function.Consumer; + +@Tags({"gcp", "cloud sql", "postgresql", "mysql", "iam", "jdbc", "password"}) +@CapabilityDescription(""" + Generates Google Cloud SQL IAM authentication tokens for Cloud SQL database connections. + PostgreSQL and MySQL are supported. + The generated access token replaces the database user password so that NiFi does not need to store long-lived credentials inside DBCP services. + """) +public class GcpCloudSqlIamDatabasePasswordProvider extends AbstractControllerService implements DatabasePasswordProvider, VerifiableControllerService { + + static final String SQLSERVICE_LOGIN_SCOPE = "https://www.googleapis.com/auth/sqlservice.login"; + static final String FAILED_PASSWORD_MESSAGE = "Failed to generate Cloud SQL IAM database password"; + static final String POSTGRESQL_SSLMODE_PROPERTY = "sslmode"; + static final String MALFORMED_SSLMODE_MESSAGE = "PostgreSQL sslmode in JDBC URL is malformed for Cloud SQL IAM authentication"; + static final String VERIFY_DATABASE_TYPE_STEP = "Resolve Database Type"; + static final String VERIFY_SCOPE_STEP = "Resolve Cloud SQL scoped credentials"; + static final String VERIFY_TOKEN_STEP = "Acquire Cloud SQL IAM access token"; + static final String VERIFY_DATABASE_TYPE_UNSUPPORTED = "Configured Database Type is not supported for Cloud SQL IAM authentication."; + static final String VERIFY_CREDENTIALS_UNAVAILABLE = "Configured GCP Credentials Provider Service did not return Google credentials."; + static final String VERIFY_SCOPED_CREDENTIALS_UNAVAILABLE = "Failed to create Cloud SQL scoped credentials from the configured provider."; + static final String VERIFY_IMPERSONATION_REQUIRED = "Target service account impersonation is required for Workload Identity Federation Cloud SQL authentication."; + static final String VERIFY_TOKEN_ACQUISITION_FAILED = "Failed to acquire a Cloud SQL IAM access token from the scoped credential."; + static final String VERIFY_TOKEN_MISSING = "Scoped credential refresh did not return a non-empty Cloud SQL IAM access token."; + static final String MYSQL_DRIVER_CLASS_NAME = "com.mysql.cj.jdbc.Driver"; + static final String MYSQL_JDBC_URL_PREFIX = "jdbc:mysql://"; + static final String MYSQL_SSL_MODE_PROPERTY = "sslMode"; + static final String MYSQL_USER_PROPERTY = "user"; + static final String MYSQL_PASSWORD_PROPERTY = "password"; + static final String MYSQL_DISABLED_AUTHENTICATION_PLUGINS_PROPERTY = "disabledAuthenticationPlugins"; + static final String MYSQL_USE_SSL_PROPERTY = "useSSL"; + static final String MYSQL_REQUIRE_SSL_PROPERTY = "requireSSL"; + static final String MYSQL_VERIFY_SERVER_CERTIFICATE_PROPERTY = "verifyServerCertificate"; + static final String MALFORMED_MYSQL_JDBC_URL_MESSAGE = "MySQL JDBC URL properties are malformed for Cloud SQL IAM authentication"; + static final String MYSQL_JDBC_URL_REQUIRED_MESSAGE = "MySQL JDBC URL must use the standard single-host jdbc:mysql:// format for Cloud SQL IAM authentication"; + static final String MYSQL_DRIVER_CLASS_REQUIRED_MESSAGE = "MySQL driver class must be configured as com.mysql.cj.jdbc.Driver for Cloud SQL IAM authentication"; + static final String MYSQL_SSL_MODE_REQUIRED_MESSAGE = "MySQL sslMode must be configured as REQUIRED, VERIFY_CA, or VERIFY_IDENTITY for Cloud SQL IAM authentication"; + static final String MYSQL_URL_CREDENTIALS_UNSUPPORTED_MESSAGE = "MySQL JDBC URL must not define user or password for Cloud SQL IAM authentication"; + static final String MYSQL_CONNECTION_PROPERTIES_USER_UNSUPPORTED_MESSAGE = "MySQL DBCP connection properties must not define user for Cloud SQL IAM authentication"; + static final String MYSQL_DISABLED_CLEAR_PASSWORD_UNSUPPORTED_MESSAGE = + "MySQL disabledAuthenticationPlugins must not disable the clear-password authentication plugin required for Cloud SQL IAM authentication"; + static final String MYSQL_LEGACY_TLS_PROPERTIES_UNSUPPORTED_MESSAGE = + "MySQL legacy TLS properties useSSL, requireSSL, and verifyServerCertificate are not supported for Cloud SQL IAM authentication"; + private static final List SAFE_GOOGLE_AUTH_IO_MESSAGES = List.of( + "Unable to refresh sourceCredentials", + "Error requesting access token", + "Unexpected error refreshing access token", + "Error parsing expireTime:" + ); + + private static final Set ACCEPTED_POSTGRESQL_SSL_MODES = Set.of("prefer", "require", "verify-ca", "verify-full"); + private static final Set ACCEPTED_MYSQL_SSL_MODES = Set.of("REQUIRED", "VERIFY_CA", "VERIFY_IDENTITY"); + private static final Set DISABLED_MYSQL_CLEAR_PASSWORD_PLUGIN_NAMES = Set.of( + "mysql_clear_password", + "com.mysql.cj.protocol.a.authentication.mysqlclearpasswordplugin" + ); + private static final Set LEGACY_MYSQL_TLS_PROPERTIES = Set.of( + MYSQL_USE_SSL_PROPERTY, + MYSQL_REQUIRE_SSL_PROPERTY, + MYSQL_VERIFY_SERVER_CERTIFICATE_PROPERTY + ); + + static final PropertyDescriptor GCP_CREDENTIALS_PROVIDER_SERVICE = new PropertyDescriptor.Builder() + .name("GCP Credentials Provider Service") + .description("Controller Service that provides the Google credentials used to request Cloud SQL IAM authentication tokens.") + .identifiesControllerService(GCPCredentialsService.class) + .required(true) + .build(); + + static final PropertyDescriptor DATABASE_TYPE = new PropertyDescriptor.Builder() + .name("Database Type") + .description("Cloud SQL database engine to authenticate. PostgreSQL and MySQL are supported.") + .required(true) + .allowableValues(CloudSqlDatabaseType.class) + .defaultValue(CloudSqlDatabaseType.POSTGRESQL) + .build(); + + private static final List PROPERTY_DESCRIPTORS = List.of( + GCP_CREDENTIALS_PROVIDER_SERVICE, + DATABASE_TYPE + ); + + private volatile GoogleCredentials scopedCredentials; + private volatile CloudSqlDatabaseType databaseType; + + @Override + protected List getSupportedPropertyDescriptors() { + return PROPERTY_DESCRIPTORS; + } + + @OnEnabled + public void onEnabled(final ConfigurationContext context) throws InitializationException { + final CloudSqlDatabaseType configuredDatabaseType = resolveEnabledDatabaseType(context); + final GoogleCredentials createdScopedCredentials = createSqlLoginScopedCredentials(resolveGoogleCredentials(context)); + if (createdScopedCredentials == null) { + throw new InitializationException(FAILED_PASSWORD_MESSAGE); + } + rejectIdentityPoolCredentialsOnEnable(createdScopedCredentials); + + databaseType = configuredDatabaseType; + scopedCredentials = createdScopedCredentials; + } + + @OnDisabled + public void onDisabled() { + scopedCredentials = null; + databaseType = null; + } + + @Override + public char[] getPassword(final DatabasePasswordRequestContext requestContext) { + Objects.requireNonNull(requestContext, "Database Password Request Context required"); + + final GoogleCredentials credentials = scopedCredentials; + final CloudSqlDatabaseType configuredDatabaseType = databaseType; + if (credentials == null || configuredDatabaseType == null) { + throw new ProcessException(FAILED_PASSWORD_MESSAGE); + } + + validateRequest(requestContext, configuredDatabaseType); + rejectIdentityPoolCredentialsOnPasswordGeneration(credentials); + + final AccessToken accessToken = refreshAccessToken(credentials); + if (!hasTokenValue(accessToken)) { + throw new ProcessException(FAILED_PASSWORD_MESSAGE); + } + + return accessToken.getTokenValue().toCharArray(); + } + + @Override + public List verify(final ConfigurationContext context, final ComponentLog verificationLogger, + final Map attributes) { + final List results = new ArrayList<>(2); + final CloudSqlDatabaseType configuredDatabaseType; + + try { + configuredDatabaseType = resolveConfiguredDatabaseType(context); + } catch (final IllegalArgumentException e) { + results.add(buildVerificationResult(VERIFY_DATABASE_TYPE_STEP, Outcome.FAILED, VERIFY_DATABASE_TYPE_UNSUPPORTED)); + return results; + } + + final GoogleCredentials googleCredentials; + + try { + googleCredentials = resolveGoogleCredentials(context); + } catch (final RuntimeException e) { + results.add(buildVerificationResult(VERIFY_SCOPE_STEP, Outcome.FAILED, VERIFY_CREDENTIALS_UNAVAILABLE)); + return results; + } + + if (googleCredentials == null) { + results.add(buildVerificationResult(VERIFY_SCOPE_STEP, Outcome.FAILED, VERIFY_CREDENTIALS_UNAVAILABLE)); + return results; + } + + final GoogleCredentials scopedVerificationCredentials; + try { + scopedVerificationCredentials = createSqlLoginScopedCredentials(googleCredentials); + } catch (final RuntimeException e) { + results.add(buildVerificationResult(VERIFY_SCOPE_STEP, Outcome.FAILED, VERIFY_SCOPED_CREDENTIALS_UNAVAILABLE)); + return results; + } + + if (scopedVerificationCredentials == null) { + results.add(buildVerificationResult(VERIFY_SCOPE_STEP, Outcome.FAILED, VERIFY_SCOPED_CREDENTIALS_UNAVAILABLE)); + return results; + } + + final ConfigVerificationResult scopedCredentialResult = describeScopedCredential(scopedVerificationCredentials, configuredDatabaseType); + results.add(scopedCredentialResult); + if (scopedCredentialResult.getOutcome() == Outcome.FAILED) { + return results; + } + + final AccessToken accessToken; + try { + accessToken = scopedVerificationCredentials.refreshAccessToken(); + } catch (final IOException | RuntimeException e) { + results.add(buildVerificationResult(VERIFY_TOKEN_STEP, Outcome.FAILED, VERIFY_TOKEN_ACQUISITION_FAILED)); + return results; + } + + if (!hasTokenValue(accessToken)) { + results.add(buildVerificationResult(VERIFY_TOKEN_STEP, Outcome.FAILED, VERIFY_TOKEN_MISSING)); + return results; + } + + results.add(buildTokenVerificationResult(scopedVerificationCredentials, configuredDatabaseType)); + return results; + } + + private CloudSqlDatabaseType resolveEnabledDatabaseType(final ConfigurationContext context) throws InitializationException { + try { + return resolveConfiguredDatabaseType(context); + } catch (final IllegalArgumentException e) { + throw new InitializationException(VERIFY_DATABASE_TYPE_UNSUPPORTED, e); + } + } + + private CloudSqlDatabaseType resolveConfiguredDatabaseType(final ConfigurationContext context) { + final PropertyValue propertyValue = context.getProperty(DATABASE_TYPE); + final CloudSqlDatabaseType configuredDatabaseType = propertyValue.asAllowableValue(CloudSqlDatabaseType.class); + if (configuredDatabaseType == null) { + throw new IllegalArgumentException("Database Type must be configured"); + } + + return configuredDatabaseType; + } + + private AccessToken refreshAccessToken(final GoogleCredentials credentials) { + try { + credentials.refreshIfExpired(); + } catch (final IOException e) { + if (isSafeGoogleAuthRefreshException(e)) { + throw new ProcessException(FAILED_PASSWORD_MESSAGE, e); + } + throw new ProcessException(FAILED_PASSWORD_MESSAGE); + } catch (final RuntimeException e) { + throw new ProcessException(FAILED_PASSWORD_MESSAGE); + } + + return credentials.getAccessToken(); + } + + private boolean isSafeGoogleAuthRefreshException(final IOException exception) { + final String message = exception.getMessage(); + final boolean knownSafeMessage = message != null && SAFE_GOOGLE_AUTH_IO_MESSAGES.stream() + .anyMatch(message::startsWith); + if (!knownSafeMessage) { + return false; + } + + for (final StackTraceElement stackTraceElement : exception.getStackTrace()) { + if (stackTraceElement.getClassName().startsWith("com.google.auth.oauth2.")) { + return true; + } + } + + return false; + } + + private GoogleCredentials resolveGoogleCredentials(final ConfigurationContext context) { + final GCPCredentialsService credentialsService = context.getProperty(GCP_CREDENTIALS_PROVIDER_SERVICE) + .asControllerService(GCPCredentialsService.class); + if (credentialsService == null) { + return null; + } + + return credentialsService.getGoogleCredentials(); + } + + private GoogleCredentials createSqlLoginScopedCredentials(final GoogleCredentials googleCredentials) { + if (googleCredentials == null) { + return null; + } + + return googleCredentials.createScoped(List.of(SQLSERVICE_LOGIN_SCOPE)); + } + + private void rejectIdentityPoolCredentialsOnEnable(final GoogleCredentials credentials) throws InitializationException { + if (credentials instanceof IdentityPoolCredentials) { + throw new InitializationException(VERIFY_IMPERSONATION_REQUIRED); + } + } + + private void rejectIdentityPoolCredentialsOnPasswordGeneration(final GoogleCredentials credentials) { + if (credentials instanceof IdentityPoolCredentials) { + throw new ProcessException(FAILED_PASSWORD_MESSAGE); + } + } + + private ConfigVerificationResult describeScopedCredential(final GoogleCredentials scopedVerificationCredentials, + final CloudSqlDatabaseType configuredDatabaseType) { + if (scopedVerificationCredentials instanceof ImpersonatedCredentials) { + return buildVerificationResult( + VERIFY_SCOPE_STEP, + Outcome.SUCCESSFUL, + ("Resolved Database Type %s, resolved Google credentials from the configured provider, and created " + + "a Cloud SQL scoped ImpersonatedCredentials instance. Target service account " + + "impersonation is active.") + .formatted(configuredDatabaseType.getDisplayName()) + ); + } + + if (scopedVerificationCredentials instanceof IdentityPoolCredentials) { + return buildVerificationResult( + VERIFY_SCOPE_STEP, + Outcome.FAILED, + "Resolved Database Type %s, but %s" + .formatted(configuredDatabaseType.getDisplayName(), VERIFY_IMPERSONATION_REQUIRED) + ); + } + + return buildVerificationResult( + VERIFY_SCOPE_STEP, + Outcome.SUCCESSFUL, + "Resolved Database Type %s, resolved Google credentials from the configured provider, and created a Cloud SQL scoped %s instance." + .formatted(configuredDatabaseType.getDisplayName(), scopedVerificationCredentials.getClass().getSimpleName()) + ); + } + + private ConfigVerificationResult buildTokenVerificationResult(final GoogleCredentials scopedVerificationCredentials, + final CloudSqlDatabaseType configuredDatabaseType) { + if (scopedVerificationCredentials instanceof ImpersonatedCredentials) { + return buildVerificationResult( + VERIFY_TOKEN_STEP, + Outcome.SUCCESSFUL, + ("Acquired a non-empty Cloud SQL IAM access token for %s from the scoped credential. This verifies live " + + "subject token exchange, Google STS, and target service account impersonation, but does " + + "not connect to the selected database. Use DBCP Verify for the end-to-end database check.") + .formatted(configuredDatabaseType.getDisplayName()) + ); + } + + return buildVerificationResult( + VERIFY_TOKEN_STEP, + Outcome.SUCCESSFUL, + ("Acquired a non-empty Cloud SQL IAM access token for %s from the scoped credential. This verifies live " + + "Cloud SQL IAM token acquisition for the current principal, but does not connect to the selected database. Use " + + "DBCP Verify for the end-to-end database check.") + .formatted(configuredDatabaseType.getDisplayName()) + ); + } + + private boolean hasTokenValue(final AccessToken accessToken) { + return accessToken != null && StringUtils.isNotBlank(accessToken.getTokenValue()); + } + + private ConfigVerificationResult buildVerificationResult(final String stepName, final Outcome outcome, final String explanation) { + return new ConfigVerificationResult.Builder() + .verificationStepName(stepName) + .outcome(outcome) + .explanation(explanation) + .build(); + } + + private void validateRequest(final DatabasePasswordRequestContext requestContext, final CloudSqlDatabaseType configuredDatabaseType) { + final Consumer validator = switch (configuredDatabaseType) { + case POSTGRESQL -> this::validatePostgresqlRequest; + case MYSQL -> this::validateMySqlRequest; + }; + validator.accept(requestContext); + } + + private void validatePostgresqlRequest(final DatabasePasswordRequestContext requestContext) { + validatePostgresqlDatabaseUser(requestContext.getDatabaseUser()); + validatePostgresqlSslMode(requestContext); + } + + private void validatePostgresqlDatabaseUser(final String databaseUser) { + if (StringUtils.isBlank(databaseUser)) { + throw new ProcessException("Database Username must be configured for Cloud SQL IAM authentication"); + } + } + + private void validatePostgresqlSslMode(final DatabasePasswordRequestContext requestContext) { + final String sslMode = resolvePostgresqlSslMode(requestContext); + if (sslMode == null) { + throw new ProcessException("PostgreSQL sslmode must be configured for Cloud SQL IAM authentication"); + } + + if (!ACCEPTED_POSTGRESQL_SSL_MODES.contains(sslMode)) { + throw new ProcessException("PostgreSQL sslmode [%s] is not supported for Cloud SQL IAM authentication".formatted(sslMode)); + } + } + + private String resolvePostgresqlSslMode(final DatabasePasswordRequestContext requestContext) { + final Optional jdbcUrlSslMode = findJdbcUrlPropertyIgnoreCase(requestContext.getJdbcUrl(), POSTGRESQL_SSLMODE_PROPERTY, MALFORMED_SSLMODE_MESSAGE); + if (jdbcUrlSslMode.isPresent()) { + return normalizePostgresqlSslMode(jdbcUrlSslMode.get()); + } + + final String propertySslMode = findConnectionPropertyIgnoreCase(requestContext.getConnectionProperties(), POSTGRESQL_SSLMODE_PROPERTY).orElse(null); + return normalizePostgresqlSslMode(propertySslMode); + } + + private void validateMySqlRequest(final DatabasePasswordRequestContext requestContext) { + validateMySqlDatabaseUser(requestContext.getDatabaseUser()); + validateMySqlDriverClassName(requestContext.getDriverClassName()); + validateMySqlJdbcUrl(requestContext.getJdbcUrl()); + validateMySqlUrlCredentials(requestContext.getJdbcUrl()); + validateMySqlConnectionPropertyUser(requestContext.getConnectionProperties()); + validateMySqlLegacyTlsProperties(requestContext); + validateMySqlSslMode(requestContext); + validateMySqlAuthenticationPlugins(requestContext); + } + + private void validateMySqlDatabaseUser(final String databaseUser) { + if (StringUtils.isBlank(databaseUser)) { + throw new ProcessException("Database Username must be configured for Cloud SQL IAM authentication"); + } + } + + private void validateMySqlDriverClassName(final String driverClassName) { + if (!MYSQL_DRIVER_CLASS_NAME.equals(StringUtils.trimToEmpty(driverClassName))) { + throw new ProcessException(MYSQL_DRIVER_CLASS_REQUIRED_MESSAGE); + } + } + + private void validateMySqlJdbcUrl(final String jdbcUrl) { + final String trimmedJdbcUrl = StringUtils.trimToEmpty(jdbcUrl); + if (!trimmedJdbcUrl.startsWith(MYSQL_JDBC_URL_PREFIX)) { + throw new ProcessException(MYSQL_JDBC_URL_REQUIRED_MESSAGE); + } + + validateMySqlJdbcUrlEncoding(trimmedJdbcUrl); + + final URI mysqlUri; + try { + mysqlUri = URI.create(trimmedJdbcUrl.substring("jdbc:".length())); + } catch (final IllegalArgumentException e) { + throw new ProcessException(MYSQL_JDBC_URL_REQUIRED_MESSAGE); + } + + final String rawAuthority = mysqlUri.getRawAuthority(); + if (!"mysql".equalsIgnoreCase(mysqlUri.getScheme()) + || StringUtils.isBlank(rawAuthority) + || StringUtils.isBlank(mysqlUri.getHost()) + || rawAuthority.contains(",")) { + throw new ProcessException(MYSQL_JDBC_URL_REQUIRED_MESSAGE); + } + + if (mysqlUri.getRawUserInfo() != null) { + throw new ProcessException(MYSQL_URL_CREDENTIALS_UNSUPPORTED_MESSAGE); + } + } + + private void validateMySqlJdbcUrlEncoding(final String jdbcUrl) { + final int queryStart = jdbcUrl.indexOf('?'); + if (queryStart >= 0 && queryStart < jdbcUrl.length() - 1) { + final String query = jdbcUrl.substring(queryStart + 1); + for (final String parameter : query.split("&")) { + final int delimiterIndex = parameter.indexOf('='); + final String rawName = delimiterIndex >= 0 ? parameter.substring(0, delimiterIndex) : parameter; + final String rawValue = delimiterIndex >= 0 ? parameter.substring(delimiterIndex + 1) : ""; + urlDecode(rawName, MALFORMED_MYSQL_JDBC_URL_MESSAGE); + urlDecode(rawValue, MALFORMED_MYSQL_JDBC_URL_MESSAGE); + } + } + } + + private void validateMySqlUrlCredentials(final String jdbcUrl) { + if (findJdbcUrlPropertyIgnoreCase(jdbcUrl, MYSQL_USER_PROPERTY, MALFORMED_MYSQL_JDBC_URL_MESSAGE).isPresent() + || findJdbcUrlPropertyIgnoreCase(jdbcUrl, MYSQL_PASSWORD_PROPERTY, MALFORMED_MYSQL_JDBC_URL_MESSAGE).isPresent()) { + throw new ProcessException(MYSQL_URL_CREDENTIALS_UNSUPPORTED_MESSAGE); + } + } + + private void validateMySqlConnectionPropertyUser(final Map connectionProperties) { + if (findConnectionPropertyIgnoreCase(connectionProperties, MYSQL_USER_PROPERTY).isPresent()) { + throw new ProcessException(MYSQL_CONNECTION_PROPERTIES_USER_UNSUPPORTED_MESSAGE); + } + } + + private void validateMySqlLegacyTlsProperties(final DatabasePasswordRequestContext requestContext) { + for (final String propertyName : LEGACY_MYSQL_TLS_PROPERTIES) { + if (findJdbcUrlPropertyIgnoreCase(requestContext.getJdbcUrl(), propertyName, MALFORMED_MYSQL_JDBC_URL_MESSAGE).isPresent() + || findConnectionPropertyIgnoreCase(requestContext.getConnectionProperties(), propertyName).isPresent()) { + throw new ProcessException(MYSQL_LEGACY_TLS_PROPERTIES_UNSUPPORTED_MESSAGE); + } + } + } + + private void validateMySqlSslMode(final DatabasePasswordRequestContext requestContext) { + if (hasNonCanonicalConnectionProperty(requestContext.getConnectionProperties(), MYSQL_SSL_MODE_PROPERTY) + || hasNonCanonicalJdbcUrlProperty(requestContext.getJdbcUrl(), MYSQL_SSL_MODE_PROPERTY, MALFORMED_MYSQL_JDBC_URL_MESSAGE)) { + throw new ProcessException(MYSQL_SSL_MODE_REQUIRED_MESSAGE); + } + + final String sslMode = resolveExactMySqlSslMode(requestContext) + .map(this::normalizeMySqlSslMode) + .orElse(null); + if (sslMode == null || !ACCEPTED_MYSQL_SSL_MODES.contains(sslMode)) { + throw new ProcessException(MYSQL_SSL_MODE_REQUIRED_MESSAGE); + } + } + + private void validateMySqlAuthenticationPlugins(final DatabasePasswordRequestContext requestContext) { + validateMySqlDisabledAuthenticationPlugins( + findConnectionPropertyIgnoreCase(requestContext.getConnectionProperties(), MYSQL_DISABLED_AUTHENTICATION_PLUGINS_PROPERTY) + ); + validateMySqlDisabledAuthenticationPlugins( + findJdbcUrlPropertyIgnoreCase(requestContext.getJdbcUrl(), MYSQL_DISABLED_AUTHENTICATION_PLUGINS_PROPERTY, MALFORMED_MYSQL_JDBC_URL_MESSAGE) + ); + } + + private void validateMySqlDisabledAuthenticationPlugins(final Optional disabledAuthenticationPlugins) { + if (disabledAuthenticationPlugins.isEmpty()) { + return; + } + + for (final String disabledAuthenticationPlugin : disabledAuthenticationPlugins.get().split(",")) { + final String normalizedPlugin = StringUtils.trimToEmpty(disabledAuthenticationPlugin).toLowerCase(Locale.ROOT); + if (DISABLED_MYSQL_CLEAR_PASSWORD_PLUGIN_NAMES.contains(normalizedPlugin)) { + throw new ProcessException(MYSQL_DISABLED_CLEAR_PASSWORD_UNSUPPORTED_MESSAGE); + } + } + } + + private Optional resolveExactMySqlSslMode(final DatabasePasswordRequestContext requestContext) { + final Optional connectionProperty = findConnectionPropertyExact(requestContext.getConnectionProperties(), MYSQL_SSL_MODE_PROPERTY); + if (connectionProperty.isPresent()) { + return connectionProperty; + } + + final Optional jdbcUrlProperty = findJdbcUrlPropertyExact(requestContext.getJdbcUrl(), MYSQL_SSL_MODE_PROPERTY, MALFORMED_MYSQL_JDBC_URL_MESSAGE); + if (jdbcUrlProperty.isPresent()) { + return jdbcUrlProperty; + } + + return Optional.empty(); + } + + private Optional findJdbcUrlPropertyExact(final String jdbcUrl, final String propertyName, final String malformedPropertyMessage) { + return findJdbcUrlProperty(jdbcUrl, propertyName, malformedPropertyMessage, true); + } + + private Optional findJdbcUrlPropertyIgnoreCase(final String jdbcUrl, final String propertyName, final String malformedPropertyMessage) { + return findJdbcUrlProperty(jdbcUrl, propertyName, malformedPropertyMessage, false); + } + + private Optional findJdbcUrlProperty(final String jdbcUrl, final String propertyName, final String malformedPropertyMessage, + final boolean exactMatch) { + if (StringUtils.isBlank(jdbcUrl)) { + return Optional.empty(); + } + + final String normalizedJdbcUrl = jdbcUrl.startsWith("jdbc:") ? jdbcUrl.substring(5) : jdbcUrl; + final int queryStart = normalizedJdbcUrl.indexOf('?'); + if (queryStart < 0 || queryStart == normalizedJdbcUrl.length() - 1) { + return Optional.empty(); + } + + final String query = normalizedJdbcUrl.substring(queryStart + 1); + String lastValue = null; + for (final String parameter : query.split("&")) { + if (parameter.isEmpty()) { + continue; + } + + final int delimiterIndex = parameter.indexOf('='); + final String decodedName = urlDecode(delimiterIndex >= 0 ? parameter.substring(0, delimiterIndex) : parameter, malformedPropertyMessage); + if (!propertyNamesMatch(propertyName, decodedName, exactMatch)) { + continue; + } + + final String rawValue = delimiterIndex >= 0 ? parameter.substring(delimiterIndex + 1) : ""; + lastValue = urlDecode(rawValue, malformedPropertyMessage); + } + + return Optional.ofNullable(lastValue); + } + + private Optional findConnectionPropertyExact(final Map connectionProperties, final String propertyName) { + return Optional.ofNullable(connectionProperties.get(propertyName)); + } + + private Optional findConnectionPropertyIgnoreCase(final Map connectionProperties, final String propertyName) { + for (final Map.Entry entry : connectionProperties.entrySet()) { + if (propertyName.equalsIgnoreCase(entry.getKey())) { + return Optional.ofNullable(entry.getValue()); + } + } + + return Optional.empty(); + } + + private boolean hasNonCanonicalJdbcUrlProperty(final String jdbcUrl, final String propertyName, final String malformedPropertyMessage) { + if (StringUtils.isBlank(jdbcUrl)) { + return false; + } + + final String normalizedJdbcUrl = jdbcUrl.startsWith("jdbc:") ? jdbcUrl.substring(5) : jdbcUrl; + final int queryStart = normalizedJdbcUrl.indexOf('?'); + if (queryStart < 0 || queryStart == normalizedJdbcUrl.length() - 1) { + return false; + } + + final String query = normalizedJdbcUrl.substring(queryStart + 1); + for (final String parameter : query.split("&")) { + if (parameter.isEmpty()) { + continue; + } + + final int delimiterIndex = parameter.indexOf('='); + final String decodedName = urlDecode(delimiterIndex >= 0 ? parameter.substring(0, delimiterIndex) : parameter, malformedPropertyMessage); + if (propertyName.equalsIgnoreCase(decodedName) && !propertyName.equals(decodedName)) { + return true; + } + } + + return false; + } + + private boolean hasNonCanonicalConnectionProperty(final Map connectionProperties, final String propertyName) { + for (final String configuredPropertyName : connectionProperties.keySet()) { + if (propertyName.equalsIgnoreCase(configuredPropertyName) && !propertyName.equals(configuredPropertyName)) { + return true; + } + } + + return false; + } + + private boolean propertyNamesMatch(final String expectedPropertyName, final String configuredPropertyName, final boolean exactMatch) { + return exactMatch ? expectedPropertyName.equals(configuredPropertyName) : expectedPropertyName.equalsIgnoreCase(configuredPropertyName); + } + + private String normalizePostgresqlSslMode(final String sslMode) { + return sslMode == null ? null : StringUtils.trimToEmpty(sslMode).toLowerCase(Locale.ROOT); + } + + private String normalizeMySqlSslMode(final String sslMode) { + return sslMode == null ? null : StringUtils.trimToEmpty(sslMode).toUpperCase(Locale.ROOT); + } + + private String urlDecode(final String value, final String malformedPropertyMessage) { + try { + return URLDecoder.decode(value, StandardCharsets.UTF_8); + } catch (final IllegalArgumentException e) { + throw new ProcessException(malformedPropertyMessage); + } + } +} diff --git a/nifi-extension-bundles/nifi-gcp-bundle/nifi-gcp-processors/src/main/java/org/apache/nifi/processors/gcp/credentials/factory/CredentialPropertyDescriptors.java b/nifi-extension-bundles/nifi-gcp-bundle/nifi-gcp-processors/src/main/java/org/apache/nifi/processors/gcp/credentials/factory/CredentialPropertyDescriptors.java index c0c0f8014911..24a81b118d90 100644 --- a/nifi-extension-bundles/nifi-gcp-bundle/nifi-gcp-processors/src/main/java/org/apache/nifi/processors/gcp/credentials/factory/CredentialPropertyDescriptors.java +++ b/nifi-extension-bundles/nifi-gcp-bundle/nifi-gcp-processors/src/main/java/org/apache/nifi/processors/gcp/credentials/factory/CredentialPropertyDescriptors.java @@ -132,6 +132,13 @@ private CredentialPropertyDescriptors() { } .dependsOn(AUTHENTICATION_STRATEGY, AuthenticationStrategy.WORKLOAD_IDENTITY_FEDERATION.getValue()) .build(); + public static final PropertyDescriptor TARGET_SERVICE_ACCOUNT = new PropertyDescriptor.Builder() + .name("Target Service Account") + .description("Target Google service account email to impersonate after Workload Identity Federation token exchange.") + .addValidator(StandardValidators.NON_BLANK_VALIDATOR) + .dependsOn(AUTHENTICATION_STRATEGY, AuthenticationStrategy.WORKLOAD_IDENTITY_FEDERATION.getValue()) + .build(); + public static final PropertyDescriptor DELEGATION_STRATEGY = new PropertyDescriptor.Builder() .name("Delegation Strategy") .required(true) diff --git a/nifi-extension-bundles/nifi-gcp-bundle/nifi-gcp-processors/src/main/java/org/apache/nifi/processors/gcp/credentials/factory/strategies/WorkloadIdentityFederationCredentialsStrategy.java b/nifi-extension-bundles/nifi-gcp-bundle/nifi-gcp-processors/src/main/java/org/apache/nifi/processors/gcp/credentials/factory/strategies/WorkloadIdentityFederationCredentialsStrategy.java index dfe261e895a5..b25e790517f5 100644 --- a/nifi-extension-bundles/nifi-gcp-bundle/nifi-gcp-processors/src/main/java/org/apache/nifi/processors/gcp/credentials/factory/strategies/WorkloadIdentityFederationCredentialsStrategy.java +++ b/nifi-extension-bundles/nifi-gcp-bundle/nifi-gcp-processors/src/main/java/org/apache/nifi/processors/gcp/credentials/factory/strategies/WorkloadIdentityFederationCredentialsStrategy.java @@ -20,6 +20,7 @@ import com.google.auth.oauth2.GoogleCredentials; import com.google.auth.oauth2.IdentityPoolCredentials; import com.google.auth.oauth2.IdentityPoolSubjectTokenSupplier; +import com.google.auth.oauth2.ImpersonatedCredentials; import org.apache.commons.lang3.StringUtils; import org.apache.nifi.components.PropertyDescriptor; import org.apache.nifi.controller.ConfigurationContext; @@ -60,6 +61,7 @@ public GoogleCredentials getGoogleCredentials(final ConfigurationContext context final String scopeValue = context.getProperty(CredentialPropertyDescriptors.WORKLOAD_IDENTITY_SCOPE).getValue(); final String tokenEndpoint = context.getProperty(CredentialPropertyDescriptors.WORKLOAD_IDENTITY_TOKEN_ENDPOINT).getValue(); final String subjectTokenType = context.getProperty(CredentialPropertyDescriptors.WORKLOAD_IDENTITY_SUBJECT_TOKEN_TYPE).getValue(); + final String targetServiceAccount = context.getProperty(CredentialPropertyDescriptors.TARGET_SERVICE_ACCOUNT).getValue(); final List scopes = parseScopes(scopeValue); final IdentityPoolSubjectTokenSupplier tokenSupplier = createSubjectTokenSupplier(subjectTokenProvider); @@ -77,7 +79,20 @@ public GoogleCredentials getGoogleCredentials(final ConfigurationContext context builder.setHttpTransportFactory(transportFactory); } - return builder.build(); + final IdentityPoolCredentials sourceCredentials = builder.build(); + if (StringUtils.isBlank(targetServiceAccount)) { + return sourceCredentials; + } + + final ImpersonatedCredentials.Builder impersonatedCredentialsBuilder = ImpersonatedCredentials.newBuilder() + .setSourceCredentials(sourceCredentials) + .setTargetPrincipal(targetServiceAccount) + .setScopes(Collections.emptyList()); + if (transportFactory != null) { + impersonatedCredentialsBuilder.setHttpTransportFactory(transportFactory); + } + + return impersonatedCredentialsBuilder.build(); } private IdentityPoolSubjectTokenSupplier createSubjectTokenSupplier(final OAuth2AccessTokenProvider tokenProvider) { diff --git a/nifi-extension-bundles/nifi-gcp-bundle/nifi-gcp-processors/src/main/java/org/apache/nifi/processors/gcp/credentials/service/GCPCredentialsControllerService.java b/nifi-extension-bundles/nifi-gcp-bundle/nifi-gcp-processors/src/main/java/org/apache/nifi/processors/gcp/credentials/service/GCPCredentialsControllerService.java index 25cda97bc5de..c90388ef8f95 100644 --- a/nifi-extension-bundles/nifi-gcp-bundle/nifi-gcp-processors/src/main/java/org/apache/nifi/processors/gcp/credentials/service/GCPCredentialsControllerService.java +++ b/nifi-extension-bundles/nifi-gcp-bundle/nifi-gcp-processors/src/main/java/org/apache/nifi/processors/gcp/credentials/service/GCPCredentialsControllerService.java @@ -18,6 +18,7 @@ import com.google.auth.http.HttpTransportFactory; import com.google.auth.oauth2.GoogleCredentials; +import org.apache.commons.lang3.StringUtils; import org.apache.nifi.annotation.documentation.CapabilityDescription; import org.apache.nifi.annotation.documentation.Tags; import org.apache.nifi.annotation.lifecycle.OnEnabled; @@ -55,6 +56,7 @@ import static org.apache.nifi.processors.gcp.credentials.factory.CredentialPropertyDescriptors.LEGACY_USE_COMPUTE_ENGINE_CREDENTIALS; import static org.apache.nifi.processors.gcp.credentials.factory.CredentialPropertyDescriptors.SERVICE_ACCOUNT_JSON; import static org.apache.nifi.processors.gcp.credentials.factory.CredentialPropertyDescriptors.SERVICE_ACCOUNT_JSON_FILE; +import static org.apache.nifi.processors.gcp.credentials.factory.CredentialPropertyDescriptors.TARGET_SERVICE_ACCOUNT; import static org.apache.nifi.processors.gcp.credentials.factory.CredentialPropertyDescriptors.WORKLOAD_IDENTITY_AUDIENCE; import static org.apache.nifi.processors.gcp.credentials.factory.CredentialPropertyDescriptors.WORKLOAD_IDENTITY_SCOPE; import static org.apache.nifi.processors.gcp.credentials.factory.CredentialPropertyDescriptors.WORKLOAD_IDENTITY_SUBJECT_TOKEN_PROVIDER; @@ -84,6 +86,7 @@ public class GCPCredentialsControllerService extends AbstractControllerService i WORKLOAD_IDENTITY_TOKEN_ENDPOINT, WORKLOAD_IDENTITY_SUBJECT_TOKEN_PROVIDER, WORKLOAD_IDENTITY_SUBJECT_TOKEN_TYPE, + TARGET_SERVICE_ACCOUNT, ProxyConfiguration.createProxyConfigPropertyDescriptor(ProxyAwareTransportFactory.PROXY_SPECS), DELEGATION_STRATEGY, DELEGATION_USER @@ -106,6 +109,20 @@ public GoogleCredentials getGoogleCredentials() throws ProcessException { protected Collection customValidate(final ValidationContext validationContext) { final List results = new ArrayList<>(); ProxyConfiguration.validateProxySpec(validationContext, results, ProxyAwareTransportFactory.PROXY_SPECS); + + final String targetServiceAccount = validationContext.getProperty(TARGET_SERVICE_ACCOUNT).getValue(); + final AuthenticationStrategy authenticationStrategy = validationContext.getProperty(AUTHENTICATION_STRATEGY) + .asAllowableValue(AuthenticationStrategy.class); + if (StringUtils.isNotBlank(targetServiceAccount) + && authenticationStrategy != AuthenticationStrategy.WORKLOAD_IDENTITY_FEDERATION) { + results.add(new ValidationResult.Builder() + .subject(TARGET_SERVICE_ACCOUNT.getDisplayName()) + .input(targetServiceAccount) + .valid(false) + .explanation("Target Service Account requires Workload Identity Federation") + .build()); + } + return results; } @@ -117,7 +134,7 @@ public List verify(final ConfigurationContext context, result = new ConfigVerificationResult.Builder() .verificationStepName("Provide Google Credentials") .outcome(Outcome.SUCCESSFUL) - .explanation(String.format("Successfully provided [%s] as Google Credentials", credentials.getClass().getSimpleName())) + .explanation("Successfully provided [%s] as Google Credentials".formatted(credentials.getClass().getSimpleName())) .build(); } catch (final IOException e) { result = new ConfigVerificationResult.Builder() diff --git a/nifi-extension-bundles/nifi-gcp-bundle/nifi-gcp-processors/src/main/resources/META-INF/services/org.apache.nifi.controller.ControllerService b/nifi-extension-bundles/nifi-gcp-bundle/nifi-gcp-processors/src/main/resources/META-INF/services/org.apache.nifi.controller.ControllerService index f4fa86f59e35..29d279ca0609 100644 --- a/nifi-extension-bundles/nifi-gcp-bundle/nifi-gcp-processors/src/main/resources/META-INF/services/org.apache.nifi.controller.ControllerService +++ b/nifi-extension-bundles/nifi-gcp-bundle/nifi-gcp-processors/src/main/resources/META-INF/services/org.apache.nifi.controller.ControllerService @@ -12,5 +12,6 @@ # 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. +org.apache.nifi.processors.gcp.cloudsql.GcpCloudSqlIamDatabasePasswordProvider org.apache.nifi.processors.gcp.credentials.service.GCPCredentialsControllerService org.apache.nifi.processors.gcp.storage.GCSFileResourceService diff --git a/nifi-extension-bundles/nifi-gcp-bundle/nifi-gcp-processors/src/main/resources/docs/org.apache.nifi.processors.gcp.cloudsql.GcpCloudSqlIamDatabasePasswordProvider/additionalDetails.md b/nifi-extension-bundles/nifi-gcp-bundle/nifi-gcp-processors/src/main/resources/docs/org.apache.nifi.processors.gcp.cloudsql.GcpCloudSqlIamDatabasePasswordProvider/additionalDetails.md new file mode 100644 index 000000000000..ab9028ebb50a --- /dev/null +++ b/nifi-extension-bundles/nifi-gcp-bundle/nifi-gcp-processors/src/main/resources/docs/org.apache.nifi.processors.gcp.cloudsql.GcpCloudSqlIamDatabasePasswordProvider/additionalDetails.md @@ -0,0 +1,118 @@ + + +## Summary + +`GcpCloudSqlIamDatabasePasswordProvider` generates a short-lived Cloud SQL IAM login token and supplies it as the +database password for a DBCP service. Use it when you want NiFi to connect to Cloud SQL without storing a long-lived +database password in NiFi. + +The provider supports Cloud SQL for PostgreSQL and Cloud SQL for MySQL over direct JDBC connections. When a DBCP service +references this provider, the static DBCP password property is ignored. + +## Usage + +1. Configure `GCPCredentialsControllerService` so NiFi can obtain Google credentials. +2. Create and enable `GcpCloudSqlIamDatabasePasswordProvider`. +3. Set **GCP Credentials Provider Service** to the credentials service. +4. Set **Database Type** to match the target engine: `POSTGRESQL` or `MYSQL`. +5. Configure the DBCP service with the JDBC URL, driver, database user, and **Database Password Provider** set to + `GcpCloudSqlIamDatabasePasswordProvider`. +6. Run **Verify** on the provider, then run **Verify** on the DBCP service. + +Create the Cloud SQL IAM database user separately and grant the database privileges required by your application. + +## Workload Identity Federation + +For Workload Identity Federation, `GCPCredentialsControllerService` must be configured with **Target Service Account**. +The workload identity principal must also have `roles/iam.workloadIdentityUser` on that target service account. + +Without both of these settings, token acquisition for Cloud SQL IAM authentication will not succeed. + +## PostgreSQL Configuration + +Cloud SQL for PostgreSQL expects the DBCP **Database User** to match the IAM identity used for login. + +| Setting | Value | +|---|---| +| Database Type | `POSTGRESQL` | +| Driver | PostgreSQL JDBC driver | +| JDBC URL | `jdbc:postgresql://:5432/?sslmode=require` | +| Accepted `sslmode` values | `prefer`, `require`, `verify-ca`, `verify-full` | +| Rejected `sslmode` values | missing, `disable`, `allow` | +| Database User for Google user | full email address | +| Database User for service account | service-account email without `.gserviceaccount.com` | + +Example service-account mapping: + +- Target service account: `nifi-sa@my-project.iam.gserviceaccount.com` +- DBCP **Database User**: `nifi-sa@my-project.iam` + +## MySQL Configuration + +Cloud SQL for MySQL uses the full service-account email when the IAM database user is created, but the JDBC login name +must be only the portion before `@`. + +| Setting | Value | +|---|---| +| Database Type | `MYSQL` | +| Driver Class Name | `com.mysql.cj.jdbc.Driver` | +| Driver Location(s) | compatible MySQL Connector/J driver jar provided to the DBCP service | +| JDBC URL | `jdbc:mysql://:3306/?sslMode=REQUIRED` | +| Accepted `sslMode` values | `REQUIRED`, `VERIFY_CA`, `VERIFY_IDENTITY` | +| Rejected `sslMode` values | missing, `DISABLED`, `PREFERRED` | +| Required property spelling | `sslMode` | +| Database User | service-account identifier before `@` | + +Example service-account mapping: + +- IAM database user created in Cloud SQL: `nifi-sa@my-project.iam.gserviceaccount.com` +- DBCP **Database User**: `nifi-sa` + +For MySQL, install or provide a compatible MySQL Connector/J driver. Do not set `user` or `password` in JDBC URL +parameters, and do not set `user` in DBCP connection properties. Do not disable the MySQL clear-password authentication +plugin required by Cloud SQL IAM authentication. + +## Verify and Troubleshooting + +`GcpCloudSqlIamDatabasePasswordProvider` **Verify** checks that NiFi can obtain a token. DBCP **Verify** checks the +actual database connection using the configured URL, driver, TLS settings, database user, and password provider. + +If provider **Verify** fails: + +- Confirm the referenced `GCPCredentialsControllerService` is enabled. +- For Workload Identity Federation, confirm **Target Service Account** is set and the workload identity principal has + `roles/iam.workloadIdentityUser` on that service account. + +If PostgreSQL authentication fails: + +- Confirm the DBCP **Database User** matches the expected PostgreSQL IAM username form. +- Confirm the JDBC configuration provides an accepted `sslmode` value. + +If MySQL authentication fails: + +- Confirm the IAM database user was created with the full service-account email. +- Confirm the DBCP **Database User** uses only the portion before `@`. +- Confirm the driver class is `com.mysql.cj.jdbc.Driver` and the JDBC URL uses `jdbc:mysql://` with a single host. + +If MySQL configuration is rejected before connection: + +- Confirm `sslMode` is present and spelled exactly `sslMode`. +- Remove conflicting TLS properties such as `useSSL`, `requireSSL`, or `verifyServerCertificate`. +- Remove `user` or `password` from the JDBC URL, and remove `user` from DBCP connection properties. +- Confirm `disabledAuthenticationPlugins` does not disable the required clear-password plugin. + +If provider **Verify** succeeds but DBCP **Verify** fails, token acquisition is working and the problem is in the JDBC +connection configuration, network path, TLS settings, driver setup, database user, or database privileges. \ No newline at end of file diff --git a/nifi-extension-bundles/nifi-gcp-bundle/nifi-gcp-processors/src/main/resources/docs/org.apache.nifi.processors.gcp.credentials.service.GCPCredentialsControllerService/additionalDetails.md b/nifi-extension-bundles/nifi-gcp-bundle/nifi-gcp-processors/src/main/resources/docs/org.apache.nifi.processors.gcp.credentials.service.GCPCredentialsControllerService/additionalDetails.md index fc8d0b6acf55..25fdbd89923e 100644 --- a/nifi-extension-bundles/nifi-gcp-bundle/nifi-gcp-processors/src/main/resources/docs/org.apache.nifi.processors.gcp.credentials.service.GCPCredentialsControllerService/additionalDetails.md +++ b/nifi-extension-bundles/nifi-gcp-bundle/nifi-gcp-processors/src/main/resources/docs/org.apache.nifi.processors.gcp.credentials.service.GCPCredentialsControllerService/additionalDetails.md @@ -108,8 +108,8 @@ Record the audience string printed by the command; it must be copied into NiFi ### 2. Authorize the workload identity principal for Google Cloud resources -The STS-issued access token represents the workload identity principal itself. Grant IAM roles to that identity on -projects or specific resources: +When **Target Service Account** is left blank, the STS-issued access token represents the workload identity principal +itself. Grant IAM roles to that identity on projects or specific resources: ```bash # Project scoped @@ -123,8 +123,17 @@ gcloud storage buckets add-iam-policy-binding gs://MY_BUCKET \ --role="roles/storage.objectViewer" ``` -`IDENTITY_SUBJECT` must match the claim you mapped in the provider (for example `assertion.sub`). Service-account -impersonation is not yet supported, so grant roles directly to the workload identity principal. +`IDENTITY_SUBJECT` must match the claim you mapped in the provider (for example `assertion.sub`). + +When you set **Target Service Account**, also grant `roles/iam.workloadIdentityUser` on that service account to the +same workload identity subject so Google can perform service-account impersonation: + +```bash +gcloud iam service-accounts add-iam-policy-binding \ + "" \ + --role="roles/iam.workloadIdentityUser" \ + --member="principal://iam.googleapis.com/projects//locations/global/workloadIdentityPools/nifi-pool/subject/IDENTITY_SUBJECT" +``` ### 3. Configure NiFi properties (Workload Identity strategy selected) @@ -135,26 +144,29 @@ impersonation is not yet supported, so grant roles directly to the workload iden | **STS Token Endpoint** | Optional override for the Google STS endpoint; leave blank to use `https://sts.googleapis.com/v1/token`. | | **Subject Token Provider** | Controller Service that retrieves the upstream workload identity token (JWT or access token). The token must contain the claims referenced by your attribute mapping. | | **Subject Token Type** | Defaults to `urn:ietf:params:oauth:token-type:jwt`. Choose the alternate access-token type only when the upstream provider issues OAuth access tokens instead of JWTs. | +| **Target Service Account** | Optional. Set this only when downstream components must impersonate a Google service account. When configured, the controller service returns generic impersonated credentials and dependent components apply any service-specific scopes when they first use the credential. | | **Proxy Configuration Service** | Optional controller service allowing NiFi to reach STS through HTTP/SOCKS proxies. | -Once these properties are set, enable GCPCredentialsControllerService. Processors referencing it immediately obtain -`IdentityPoolCredentials`, and Google’s libraries refresh access tokens automatically using the configured subject --token provider. +Once these properties are set, enable GCPCredentialsControllerService. When **Target Service Account** is blank, +processors and controller services reference direct `IdentityPoolCredentials`. When **Target Service Account** is set, +they reference impersonated credentials layered over the workload identity source credential. ### Verification workflow 1. Enable or refresh the Subject Token Provider controller service. -2. Use the **Verify** action on GCPCredentialsControllerService. Successful verification confirms that NiFi can - exchange the subject token with Google STS using the configured proxy, audience, and scopes. -3. Enable dependent processors. No additional controller services are required. +2. Use the **Verify** action on GCPCredentialsControllerService. Successful verification confirms only that NiFi can + construct the configured credential object from the current properties. +3. Verify does not perform STS exchange, service-account impersonation, or network reachability checks. The first token + refresh happens when a dependent processor or controller service actually uses the credential. +4. Enable the dependent components that reference this controller service. Product-specific validation happens there. ### Troubleshooting | Symptom | Guidance | | --- | --- | -| `403 Caller does not have storage.objects.list` | Confirm the workload identity principal has the required IAM role: `gcloud projects get-iam-policy` / `gcloud storage buckets get-iam-policy`. Ensure the attribute mapping emits the same subject referenced in IAM. | -| STS errors during verification | Double-check the **Audience** string and **STS Token Endpoint**. Use DEBUG logs or the Verify dialog output to inspect the STS response. Ensure the subject token includes the mapped claims. | -| Access token rejected by Google APIs | Call the API directly with the federated token (for example, `curl -H "Authorization: Bearer TOKEN" https://storage.googleapis.com/...`). If it still fails, revisit IAM bindings or scope selection. | +| `403 Caller does not have storage.objects.list` | Confirm the active identity has the required IAM role. Without **Target Service Account**, grant roles directly to the workload identity principal. With **Target Service Account**, confirm both the impersonated service account permissions and the `roles/iam.workloadIdentityUser` binding for the workload identity subject. | +| Verify succeeds but the dependent component fails on first use | Verify only constructs credentials. Recheck the **Audience**, **STS Token Endpoint**, upstream subject token, proxy reachability, and any optional **Target Service Account** setting at the dependent component that triggers the first refresh. | +| Access token rejected by Google APIs | Revisit IAM bindings and scope selection for the dependent component. If the component requires a service-specific scope or impersonated identity, confirm that **Target Service Account** is set and that the downstream component supports the needed scope. | | Need to rotate upstream tokens | The controller service requests a fresh subject token 60 seconds before expiry. Trigger **Refresh** on the Subject Token Provider to invalidate cached tokens immediately. | --- diff --git a/nifi-extension-bundles/nifi-gcp-bundle/nifi-gcp-processors/src/test/java/org/apache/nifi/processors/gcp/cloudsql/GcpCloudSqlIamDatabasePasswordProviderTest.java b/nifi-extension-bundles/nifi-gcp-bundle/nifi-gcp-processors/src/test/java/org/apache/nifi/processors/gcp/cloudsql/GcpCloudSqlIamDatabasePasswordProviderTest.java new file mode 100644 index 000000000000..8f9fcf8fb727 --- /dev/null +++ b/nifi-extension-bundles/nifi-gcp-bundle/nifi-gcp-processors/src/test/java/org/apache/nifi/processors/gcp/cloudsql/GcpCloudSqlIamDatabasePasswordProviderTest.java @@ -0,0 +1,1554 @@ +/* + * 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.nifi.processors.gcp.cloudsql; + +import com.google.auth.oauth2.AccessToken; +import com.google.auth.oauth2.GoogleCredentials; +import com.google.auth.oauth2.IdentityPoolCredentials; +import com.google.auth.oauth2.ImpersonatedCredentials; +import org.apache.nifi.components.AllowableValue; +import org.apache.nifi.components.ConfigVerificationResult; +import org.apache.nifi.components.PropertyDescriptor; +import org.apache.nifi.components.PropertyValue; +import org.apache.nifi.controller.AbstractControllerService; +import org.apache.nifi.controller.ConfigurationContext; +import org.apache.nifi.dbcp.api.DatabasePasswordProvider; +import org.apache.nifi.dbcp.api.DatabasePasswordRequestContext; +import org.apache.nifi.gcp.credentials.service.GCPCredentialsService; +import org.apache.nifi.logging.ComponentLog; +import org.apache.nifi.processor.exception.ProcessException; +import org.apache.nifi.reporting.InitializationException; +import org.apache.nifi.util.LogMessage; +import org.apache.nifi.util.MockComponentLog; +import org.apache.nifi.util.NoOpProcessor; +import org.apache.nifi.util.TestRunner; +import org.apache.nifi.util.TestRunners; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; +import org.mockito.Mockito; +import org.slf4j.helpers.MessageFormatter; + +import java.io.IOException; +import java.io.InputStream; +import java.lang.reflect.Field; +import java.nio.charset.StandardCharsets; +import java.time.Instant; +import java.util.ArrayList; +import java.util.IdentityHashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.stream.Stream; + +import static org.apache.nifi.components.ConfigVerificationResult.Outcome.FAILED; +import static org.apache.nifi.components.ConfigVerificationResult.Outcome.SUCCESSFUL; +import static org.apache.nifi.processors.gcp.cloudsql.GcpCloudSqlIamDatabasePasswordProvider.DATABASE_TYPE; +import static org.apache.nifi.processors.gcp.cloudsql.GcpCloudSqlIamDatabasePasswordProvider.FAILED_PASSWORD_MESSAGE; +import static org.apache.nifi.processors.gcp.cloudsql.GcpCloudSqlIamDatabasePasswordProvider.GCP_CREDENTIALS_PROVIDER_SERVICE; +import static org.apache.nifi.processors.gcp.cloudsql.GcpCloudSqlIamDatabasePasswordProvider.MALFORMED_MYSQL_JDBC_URL_MESSAGE; +import static org.apache.nifi.processors.gcp.cloudsql.GcpCloudSqlIamDatabasePasswordProvider.MALFORMED_SSLMODE_MESSAGE; +import static org.apache.nifi.processors.gcp.cloudsql.GcpCloudSqlIamDatabasePasswordProvider.SQLSERVICE_LOGIN_SCOPE; +import static org.apache.nifi.processors.gcp.cloudsql.GcpCloudSqlIamDatabasePasswordProvider.VERIFY_CREDENTIALS_UNAVAILABLE; +import static org.apache.nifi.processors.gcp.cloudsql.GcpCloudSqlIamDatabasePasswordProvider.VERIFY_IMPERSONATION_REQUIRED; +import static org.apache.nifi.processors.gcp.cloudsql.GcpCloudSqlIamDatabasePasswordProvider.VERIFY_SCOPED_CREDENTIALS_UNAVAILABLE; +import static org.apache.nifi.processors.gcp.cloudsql.GcpCloudSqlIamDatabasePasswordProvider.VERIFY_SCOPE_STEP; +import static org.apache.nifi.processors.gcp.cloudsql.GcpCloudSqlIamDatabasePasswordProvider.VERIFY_TOKEN_ACQUISITION_FAILED; +import static org.apache.nifi.processors.gcp.cloudsql.GcpCloudSqlIamDatabasePasswordProvider.VERIFY_TOKEN_MISSING; +import static org.apache.nifi.processors.gcp.cloudsql.GcpCloudSqlIamDatabasePasswordProvider.VERIFY_TOKEN_STEP; +import static org.junit.jupiter.api.Assertions.assertArrayEquals; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNotSame; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +class GcpCloudSqlIamDatabasePasswordProviderTest { + + private static final String CREDENTIALS_SERVICE_ID = "gcpCredentials"; + private static final String PASSWORD_PROVIDER_ID = "cloudSqlIamProvider"; + private static final String POSTGRES_DRIVER_CLASS = "org.postgresql.Driver"; + private static final String MYSQL_DRIVER_CLASS = "com.mysql.cj.jdbc.Driver"; + private static final String DATABASE_USER = "service-account@test-project.iam"; + private static final String MYSQL_DATABASE_USER = "service-account"; + private static final String JDBC_URL = "jdbc:postgresql://example:5432/database?sslmode=require"; + private static final String MYSQL_JDBC_URL = "jdbc:mysql://example:3306/database?sslMode=REQUIRED"; + private static final String TOKEN_VALUE = "cloud-sql-token"; + private static final String REFRESHED_TOKEN_VALUE = "refreshed-cloud-sql-token"; + private static final String LEAK_SENTINEL = "sentinel-token-value"; + + private ExecutorService executorService; + + @AfterEach + void tearDown() { + if (executorService != null) { + executorService.shutdownNow(); + } + } + + @Test + void testDatabaseTypeDescriptorSupportsPostgreSqlAndMySqlAndDefaultsToPostgreSql() { + final PropertyDescriptor descriptor = DATABASE_TYPE; + + assertEquals(CloudSqlDatabaseType.POSTGRESQL.getValue(), descriptor.getDefaultValue()); + assertTrue(descriptor.isRequired()); + assertEquals("Database Type", descriptor.getName()); + assertEquals(List.of(CloudSqlDatabaseType.POSTGRESQL.getValue(), CloudSqlDatabaseType.MYSQL.getValue()), descriptor.getAllowableValues().stream() + .map(AllowableValue::getValue) + .toList()); + } + + @Test + void testOnEnabledCachesScopedCredentialAndReusesIt() throws Exception { + final TestScopedGoogleCredentials scopedCredentials = new TestScopedGoogleCredentials(accessToken(TOKEN_VALUE, 15)); + final RootGoogleCredentials rootCredentials = new RootGoogleCredentials(scopedCredentials); + final TestRunner runner = configureRunner(rootCredentials); + final GcpCloudSqlIamDatabasePasswordProvider provider = getProviderImplementation(runner); + + assertEquals(1, rootCredentials.getCreateScopedCount()); + assertEquals(List.of(SQLSERVICE_LOGIN_SCOPE), rootCredentials.getLastRequestedScopes()); + assertEquals(CloudSqlDatabaseType.POSTGRESQL, getDatabaseType(provider)); + assertSame(scopedCredentials, getScopedCredentials(provider)); + + assertEquals(TOKEN_VALUE, new String(provider.getPassword(requestContext(JDBC_URL, DATABASE_USER, Map.of())))); + assertEquals(TOKEN_VALUE, new String(provider.getPassword(requestContext(JDBC_URL, DATABASE_USER, Map.of())))); + + assertEquals(1, rootCredentials.getCreateScopedCount()); + assertEquals(0, scopedCredentials.getRefreshAccessTokenCount()); + } + + @Test + void testOnDisabledClearsCachedCredentialAndDatabaseType() throws Exception { + final TestScopedGoogleCredentials scopedCredentials = new TestScopedGoogleCredentials(accessToken(TOKEN_VALUE, 15)); + final RootGoogleCredentials rootCredentials = new RootGoogleCredentials(scopedCredentials); + final TestRunner runner = configureRunner(rootCredentials); + final GcpCloudSqlIamDatabasePasswordProvider provider = getProviderImplementation(runner); + + runner.disableControllerService(provider); + + assertNull(getScopedCredentials(provider)); + assertNull(getDatabaseType(provider)); + + final ProcessException exception = assertThrows(ProcessException.class, + () -> provider.getPassword(requestContext(JDBC_URL, DATABASE_USER, Map.of()))); + + assertEquals(FAILED_PASSWORD_MESSAGE, exception.getMessage()); + assertNull(exception.getCause()); + } + + @Test + void testOnEnabledCachesScopedCredentialForMySqlAndUsesMySqlValidation() throws Exception { + final TestScopedGoogleCredentials scopedCredentials = new TestScopedGoogleCredentials(accessToken(TOKEN_VALUE, 15)); + final RootGoogleCredentials rootCredentials = new RootGoogleCredentials(scopedCredentials); + final TestRunner runner = configureRunner(rootCredentials, true, CloudSqlDatabaseType.MYSQL); + final GcpCloudSqlIamDatabasePasswordProvider provider = getProviderImplementation(runner); + + assertEquals(1, rootCredentials.getCreateScopedCount()); + assertEquals(List.of(SQLSERVICE_LOGIN_SCOPE), rootCredentials.getLastRequestedScopes()); + assertEquals(CloudSqlDatabaseType.MYSQL, getDatabaseType(provider)); + assertSame(scopedCredentials, getScopedCredentials(provider)); + + assertEquals(TOKEN_VALUE, new String(provider.getPassword(requestContext(MYSQL_JDBC_URL, MYSQL_DATABASE_USER, MYSQL_DRIVER_CLASS, Map.of())))); + assertEquals(0, scopedCredentials.getRefreshAccessTokenCount()); + } + + @Test + void testOnDisabledClearsCachedCredentialAndDatabaseTypeForMySql() throws Exception { + final TestScopedGoogleCredentials scopedCredentials = new TestScopedGoogleCredentials(accessToken(TOKEN_VALUE, 15)); + final RootGoogleCredentials rootCredentials = new RootGoogleCredentials(scopedCredentials); + final TestRunner runner = configureRunner(rootCredentials, true, CloudSqlDatabaseType.MYSQL); + final GcpCloudSqlIamDatabasePasswordProvider provider = getProviderImplementation(runner); + + runner.disableControllerService(provider); + + assertNull(getScopedCredentials(provider)); + assertNull(getDatabaseType(provider)); + + final ProcessException exception = assertThrows(ProcessException.class, + () -> provider.getPassword(requestContext(MYSQL_JDBC_URL, MYSQL_DATABASE_USER, MYSQL_DRIVER_CLASS, Map.of()))); + + assertEquals(FAILED_PASSWORD_MESSAGE, exception.getMessage()); + assertNull(exception.getCause()); + } + + @Test + void testVerifyImpersonatedCredentialsAcquireLiveToken() throws Exception { + final ImpersonatedCredentials scopedCredentials = impersonatedCredentials(accessToken(TOKEN_VALUE, 15)); + final RootGoogleCredentials rootCredentials = new RootGoogleCredentials(scopedCredentials); + final TestRunner runner = configureRunner(rootCredentials); + final GcpCloudSqlIamDatabasePasswordProvider provider = getProviderImplementation(runner); + + final List results = runner.verify(provider, Map.of()); + + assertEquals(2, results.size()); + assertVerificationResult(results.get(0), VERIFY_SCOPE_STEP, SUCCESSFUL, + "Resolved Database Type PostgreSQL"); + assertVerificationResult(results.get(0), VERIFY_SCOPE_STEP, SUCCESSFUL, + "created a Cloud SQL scoped ImpersonatedCredentials instance. Target service account impersonation is active."); + assertVerificationResult(results.get(1), VERIFY_TOKEN_STEP, SUCCESSFUL, + "Acquired a non-empty Cloud SQL IAM access token for PostgreSQL"); + assertVerificationResult(results.get(1), VERIFY_TOKEN_STEP, SUCCESSFUL, + "verifies live subject token exchange, Google STS, and target service account impersonation"); + assertEquals(2, rootCredentials.getCreateScopedCount()); + Mockito.verify(scopedCredentials).refreshAccessToken(); + } + + @Test + void testVerifyIdentityPoolCredentialsRequiresImpersonation() throws Exception { + final IdentityPoolCredentials scopedCredentials = identityPoolCredentials(accessToken(TOKEN_VALUE, 15)); + final RootGoogleCredentials rootCredentials = new RootGoogleCredentials(scopedCredentials); + final TestRunner runner = configureRunner(rootCredentials, false); + final GcpCloudSqlIamDatabasePasswordProvider provider = getProviderImplementation(runner); + + final List results = runner.verify(provider, Map.of()); + + assertEquals(1, results.size()); + assertVerificationResult(results.getFirst(), VERIFY_SCOPE_STEP, FAILED, "Resolved Database Type PostgreSQL"); + assertVerificationResult(results.getFirst(), VERIFY_SCOPE_STEP, FAILED, VERIFY_IMPERSONATION_REQUIRED); + Mockito.verify(scopedCredentials, Mockito.never()).refreshAccessToken(); + } + + @Test + void testOnEnabledRejectsIdentityPoolCredentialsBeforePublishingState() throws Exception { + final IdentityPoolCredentials scopedCredentials = identityPoolCredentials(accessToken(TOKEN_VALUE, 15)); + final GcpCloudSqlIamDatabasePasswordProvider provider = new GcpCloudSqlIamDatabasePasswordProvider(); + final ConfigurationContext context = mock(ConfigurationContext.class); + final PropertyValue credentialsPropertyValue = mock(PropertyValue.class); + final PropertyValue databaseTypePropertyValue = mock(PropertyValue.class); + final GCPCredentialsService credentialsService = mock(GCPCredentialsService.class); + + when(context.getProperty(DATABASE_TYPE)).thenReturn(databaseTypePropertyValue); + when(databaseTypePropertyValue.asAllowableValue(CloudSqlDatabaseType.class)).thenReturn(CloudSqlDatabaseType.POSTGRESQL); + when(context.getProperty(GCP_CREDENTIALS_PROVIDER_SERVICE)).thenReturn(credentialsPropertyValue); + when(credentialsPropertyValue.asControllerService(GCPCredentialsService.class)).thenReturn(credentialsService); + when(credentialsService.getGoogleCredentials()).thenReturn(new RootGoogleCredentials(scopedCredentials)); + + final InitializationException exception = assertThrows(InitializationException.class, + () -> provider.onEnabled(context)); + + assertEquals(VERIFY_IMPERSONATION_REQUIRED, exception.getMessage()); + assertNull(getScopedCredentials(provider)); + assertNull(getDatabaseType(provider)); + Mockito.verify(scopedCredentials, Mockito.never()).refreshAccessToken(); + } + + @Test + void testGetPasswordRejectsIdentityPoolCredentialsBeforeRefresh() throws Exception { + final IdentityPoolCredentials scopedCredentials = identityPoolCredentials(accessToken(TOKEN_VALUE, -15)); + final GcpCloudSqlIamDatabasePasswordProvider provider = new GcpCloudSqlIamDatabasePasswordProvider(); + setDatabaseType(provider, CloudSqlDatabaseType.POSTGRESQL); + setScopedCredentials(provider, scopedCredentials); + + final ProcessException exception = assertThrows(ProcessException.class, + () -> provider.getPassword(requestContext(JDBC_URL, DATABASE_USER, Map.of()))); + + assertEquals(FAILED_PASSWORD_MESSAGE, exception.getMessage()); + assertNull(exception.getCause()); + Mockito.verify(scopedCredentials, Mockito.never()).refreshAccessToken(); + } + + @Test + void testInvalidDatabaseTypePropertyIsRejectedByValidation() throws Exception { + final TestRunner runner = TestRunners.newTestRunner(NoOpProcessor.class); + + final TestGCPCredentialsService credentialsService = new TestGCPCredentialsService(new RootGoogleCredentials(new TestScopedGoogleCredentials(accessToken(TOKEN_VALUE, 15)))); + runner.addControllerService(CREDENTIALS_SERVICE_ID, credentialsService); + runner.enableControllerService(credentialsService); + + final GcpCloudSqlIamDatabasePasswordProvider provider = new GcpCloudSqlIamDatabasePasswordProvider(); + runner.addControllerService(PASSWORD_PROVIDER_ID, provider); + runner.setProperty(provider, GCP_CREDENTIALS_PROVIDER_SERVICE, CREDENTIALS_SERVICE_ID); + runner.setProperty(provider, DATABASE_TYPE, "SQLSERVER"); + + runner.assertNotValid(provider); + } + + @Test + void testVerifyNullCredentialsFails() throws Exception { + final TestRunner runner = configureRunner(null, false); + final GcpCloudSqlIamDatabasePasswordProvider provider = getProviderImplementation(runner); + + final List results = runner.verify(provider, Map.of()); + + assertEquals(1, results.size()); + assertVerificationResult(results.getFirst(), VERIFY_SCOPE_STEP, FAILED, VERIFY_CREDENTIALS_UNAVAILABLE); + } + + @Test + void testVerifyScopedCredentialCreationReturningNullFails() throws Exception { + final RootGoogleCredentials rootCredentials = new RootGoogleCredentials((GoogleCredentials) null); + final TestRunner runner = configureRunner(rootCredentials, false); + final GcpCloudSqlIamDatabasePasswordProvider provider = getProviderImplementation(runner); + + final List results = runner.verify(provider, Map.of()); + + assertEquals(1, results.size()); + assertVerificationResult(results.getFirst(), VERIFY_SCOPE_STEP, FAILED, VERIFY_SCOPED_CREDENTIALS_UNAVAILABLE); + assertEquals(List.of(SQLSERVICE_LOGIN_SCOPE), rootCredentials.getLastRequestedScopes()); + } + + @Test + void testVerifyScopedCredentialCreationFailureIsSanitized() throws Exception { + final RootGoogleCredentials rootCredentials = new RootGoogleCredentials(new IllegalStateException(LEAK_SENTINEL)); + final TestRunner runner = configureRunner(rootCredentials, false); + final GcpCloudSqlIamDatabasePasswordProvider provider = getProviderImplementation(runner); + + final List results = runner.verify(provider, Map.of()); + + assertEquals(1, results.size()); + assertVerificationResult(results.getFirst(), VERIFY_SCOPE_STEP, FAILED, VERIFY_SCOPED_CREDENTIALS_UNAVAILABLE); + assertFalse(results.getFirst().getExplanation().contains(LEAK_SENTINEL)); + assertNoLogMessagesContain(runner.getControllerServiceLogger(PASSWORD_PROVIDER_ID), LEAK_SENTINEL); + } + + @Test + void testVerifyRefreshIOExceptionIsSanitized() throws Exception { + final ImpersonatedCredentials scopedCredentials = impersonatedCredentials(ioException(LEAK_SENTINEL, "com.google.auth.oauth2.ImpersonatedCredentials")); + final TestRunner runner = configureRunner(new RootGoogleCredentials(scopedCredentials)); + final GcpCloudSqlIamDatabasePasswordProvider provider = getProviderImplementation(runner); + + final List results = runner.verify(provider, Map.of()); + + assertEquals(2, results.size()); + assertVerificationResult(results.get(0), VERIFY_SCOPE_STEP, SUCCESSFUL, + "created a Cloud SQL scoped ImpersonatedCredentials instance. Target service account impersonation is active."); + assertVerificationResult(results.get(1), VERIFY_TOKEN_STEP, FAILED, VERIFY_TOKEN_ACQUISITION_FAILED); + assertFalse(results.get(1).getExplanation().contains(LEAK_SENTINEL)); + assertNoLogMessagesContain(runner.getControllerServiceLogger(PASSWORD_PROVIDER_ID), LEAK_SENTINEL); + } + + @Test + void testVerifyRefreshRuntimeFailureIsSanitized() throws Exception { + final ImpersonatedCredentials scopedCredentials = impersonatedCredentials(new IllegalStateException(LEAK_SENTINEL)); + final TestRunner runner = configureRunner(new RootGoogleCredentials(scopedCredentials)); + final GcpCloudSqlIamDatabasePasswordProvider provider = getProviderImplementation(runner); + + final List results = runner.verify(provider, Map.of()); + + assertEquals(2, results.size()); + assertVerificationResult(results.get(1), VERIFY_TOKEN_STEP, FAILED, VERIFY_TOKEN_ACQUISITION_FAILED); + assertNoLogMessagesContain(runner.getControllerServiceLogger(PASSWORD_PROVIDER_ID), LEAK_SENTINEL); + } + + @Test + void testVerifyNullAccessTokenFails() throws Exception { + final ImpersonatedCredentials scopedCredentials = impersonatedCredentials((AccessToken) null); + final TestRunner runner = configureRunner(new RootGoogleCredentials(scopedCredentials)); + final GcpCloudSqlIamDatabasePasswordProvider provider = getProviderImplementation(runner); + + final List results = runner.verify(provider, Map.of()); + + assertEquals(2, results.size()); + assertVerificationResult(results.get(1), VERIFY_TOKEN_STEP, FAILED, VERIFY_TOKEN_MISSING); + } + + @Test + void testVerifyBlankAccessTokenFails() throws Exception { + final ImpersonatedCredentials scopedCredentials = impersonatedCredentials(accessToken(" ", 15)); + final TestRunner runner = configureRunner(new RootGoogleCredentials(scopedCredentials)); + final GcpCloudSqlIamDatabasePasswordProvider provider = getProviderImplementation(runner); + + final List results = runner.verify(provider, Map.of()); + + assertEquals(2, results.size()); + assertVerificationResult(results.get(1), VERIFY_TOKEN_STEP, FAILED, VERIFY_TOKEN_MISSING); + } + + @Test + void testVerifyUsesFreshScopedCredentialWithoutMutatingEnabledState() throws Exception { + final TestScopedGoogleCredentials enabledScopedCredentials = new TestScopedGoogleCredentials(accessToken(TOKEN_VALUE, 15)); + final TestScopedGoogleCredentials verificationScopedCredentials = new TestScopedGoogleCredentials(accessToken(REFRESHED_TOKEN_VALUE, 15)); + verificationScopedCredentials.setRefreshedAccessToken(accessToken(REFRESHED_TOKEN_VALUE, 15)); + final RootGoogleCredentials rootCredentials = new RootGoogleCredentials(enabledScopedCredentials, verificationScopedCredentials); + final TestRunner runner = configureRunner(rootCredentials); + final GcpCloudSqlIamDatabasePasswordProvider provider = getProviderImplementation(runner); + + final List results = runner.verify(provider, Map.of()); + final char[] password = provider.getPassword(requestContext(JDBC_URL, DATABASE_USER, Map.of())); + + assertEquals(2, rootCredentials.getCreateScopedCount()); + assertEquals(0, enabledScopedCredentials.getRefreshAccessTokenCount()); + assertEquals(1, verificationScopedCredentials.getRefreshAccessTokenCount()); + assertFalse(results.get(1).getExplanation().contains("subject token exchange")); + assertTrue(results.get(1).getExplanation().contains("Cloud SQL IAM token acquisition for the current principal")); + assertVerificationResult(results.get(1), VERIFY_TOKEN_STEP, SUCCESSFUL, + "Use DBCP Verify for the end-to-end database check."); + assertArrayEquals(TOKEN_VALUE.toCharArray(), password); + } + + @Test + void testVerifyUsesSqlServiceLoginScope() throws Exception { + final GcpCloudSqlIamDatabasePasswordProvider provider = new GcpCloudSqlIamDatabasePasswordProvider(); + final ConfigurationContext context = mock(ConfigurationContext.class); + final PropertyValue credentialsPropertyValue = mock(PropertyValue.class); + final PropertyValue databaseTypePropertyValue = mock(PropertyValue.class); + final GCPCredentialsService credentialsService = mock(GCPCredentialsService.class); + final RootGoogleCredentials rootCredentials = new RootGoogleCredentials(new TestScopedGoogleCredentials(accessToken(TOKEN_VALUE, 15))); + + when(context.getProperty(DATABASE_TYPE)).thenReturn(databaseTypePropertyValue); + when(databaseTypePropertyValue.asAllowableValue(CloudSqlDatabaseType.class)).thenReturn(CloudSqlDatabaseType.POSTGRESQL); + when(context.getProperty(GCP_CREDENTIALS_PROVIDER_SERVICE)).thenReturn(credentialsPropertyValue); + when(credentialsPropertyValue.asControllerService(GCPCredentialsService.class)).thenReturn(credentialsService); + when(credentialsService.getGoogleCredentials()).thenReturn(rootCredentials); + + final List results = provider.verify(context, mock(ComponentLog.class), Map.of()); + + assertEquals(2, results.size()); + assertEquals(List.of(SQLSERVICE_LOGIN_SCOPE), rootCredentials.getLastRequestedScopes()); + assertVerificationResult(results.getFirst(), VERIFY_SCOPE_STEP, SUCCESSFUL, "Resolved Database Type PostgreSQL"); + } + + @ParameterizedTest(name = "verify wording for {0}") + @MethodSource("verifySuccessContexts") + void testVerifySuccessWordingIsGenericForSelectedDatabaseType(final CloudSqlDatabaseType databaseType, + final GoogleCredentials scopedCredentials, + final String scopeMessage, + final String tokenMessage) throws Exception { + if (scopedCredentials instanceof TestScopedGoogleCredentials testScopedGoogleCredentials) { + testScopedGoogleCredentials.setRefreshedAccessToken(accessToken(TOKEN_VALUE, 15)); + } + final TestRunner runner = configureRunner(new RootGoogleCredentials(scopedCredentials), true, databaseType); + final GcpCloudSqlIamDatabasePasswordProvider provider = getProviderImplementation(runner); + + final List results = runner.verify(provider, Map.of()); + + assertEquals(2, results.size()); + assertVerificationResult(results.get(0), VERIFY_SCOPE_STEP, SUCCESSFUL, "Resolved Database Type %s".formatted(databaseType.getDisplayName())); + assertVerificationResult(results.get(0), VERIFY_SCOPE_STEP, SUCCESSFUL, scopeMessage); + assertVerificationResult(results.get(1), VERIFY_TOKEN_STEP, SUCCESSFUL, + "Acquired a non-empty Cloud SQL IAM access token for %s".formatted(databaseType.getDisplayName())); + assertVerificationResult(results.get(1), VERIFY_TOKEN_STEP, SUCCESSFUL, tokenMessage); + assertVerificationResult(results.get(1), VERIFY_TOKEN_STEP, SUCCESSFUL, + "does not connect to the selected database. Use DBCP Verify for the end-to-end database check."); + } + + @Test + void testChainedControllerServiceResolution() throws Exception { + final TestScopedGoogleCredentials scopedCredentials = new TestScopedGoogleCredentials(accessToken(TOKEN_VALUE, 15)); + final RootGoogleCredentials rootCredentials = new RootGoogleCredentials(scopedCredentials); + final TestRunner runner = configureRunner(rootCredentials); + + final DatabasePasswordProvider provider = getProvider(runner); + final char[] password = provider.getPassword(requestContext(JDBC_URL, DATABASE_USER, Map.of())); + + assertArrayEquals(TOKEN_VALUE.toCharArray(), password); + } + + @Test + void testFreshTokenDoesNotRefresh() throws Exception { + final TestScopedGoogleCredentials scopedCredentials = new TestScopedGoogleCredentials(accessToken(TOKEN_VALUE, 15)); + final TestRunner runner = configureRunner(new RootGoogleCredentials(scopedCredentials)); + + final DatabasePasswordProvider provider = getProvider(runner); + final char[] password = provider.getPassword(requestContext(JDBC_URL, DATABASE_USER, Map.of())); + + assertArrayEquals(TOKEN_VALUE.toCharArray(), password); + assertEquals(0, scopedCredentials.getRefreshAccessTokenCount()); + } + + @Test + void testExpiredTokenRefreshes() throws Exception { + final TestScopedGoogleCredentials scopedCredentials = new TestScopedGoogleCredentials(accessToken(TOKEN_VALUE, -15)); + scopedCredentials.setRefreshedAccessToken(accessToken(REFRESHED_TOKEN_VALUE, 15)); + final TestRunner runner = configureRunner(new RootGoogleCredentials(scopedCredentials)); + + final DatabasePasswordProvider provider = getProvider(runner); + final char[] password = provider.getPassword(requestContext(JDBC_URL, DATABASE_USER, Map.of())); + + assertArrayEquals(REFRESHED_TOKEN_VALUE.toCharArray(), password); + assertEquals(1, scopedCredentials.getRefreshAccessTokenCount()); + } + + @Test + void testConcurrentGetPasswordPerformsSingleRefresh() throws Exception { + final BlockingScopedGoogleCredentials scopedCredentials = new BlockingScopedGoogleCredentials(); + scopedCredentials.setRefreshedAccessToken(accessToken(REFRESHED_TOKEN_VALUE, 15)); + final TestRunner runner = configureRunner(new RootGoogleCredentials(scopedCredentials)); + final DatabasePasswordProvider provider = getProvider(runner); + + executorService = Executors.newFixedThreadPool(2); + final CountDownLatch startLatch = new CountDownLatch(1); + final Future first = executorService.submit(() -> getPasswordAfterStart(provider, startLatch)); + final Future second = executorService.submit(() -> getPasswordAfterStart(provider, startLatch)); + + startLatch.countDown(); + assertTrue(scopedCredentials.awaitRefreshEntry()); + scopedCredentials.releaseRefresh(); + + assertArrayEquals(REFRESHED_TOKEN_VALUE.toCharArray(), first.get(5, TimeUnit.SECONDS)); + assertArrayEquals(REFRESHED_TOKEN_VALUE.toCharArray(), second.get(5, TimeUnit.SECONDS)); + assertEquals(1, scopedCredentials.getRefreshAccessTokenCount()); + } + + @Test + void testNullRequestContextRejected() throws Exception { + final TestRunner runner = configureRunner(new RootGoogleCredentials(new TestScopedGoogleCredentials(accessToken(TOKEN_VALUE, 15)))); + final DatabasePasswordProvider provider = getProvider(runner); + + final NullPointerException exception = assertThrows(NullPointerException.class, () -> provider.getPassword(null)); + + assertEquals("Database Password Request Context required", exception.getMessage()); + } + + @Test + void testBlankDatabaseUserRejectedBeforeRefresh() throws Exception { + final TestScopedGoogleCredentials scopedCredentials = new TestScopedGoogleCredentials(accessToken(TOKEN_VALUE, -15)); + scopedCredentials.setRefreshedAccessToken(accessToken(REFRESHED_TOKEN_VALUE, 15)); + final TestRunner runner = configureRunner(new RootGoogleCredentials(scopedCredentials)); + final DatabasePasswordProvider provider = getProvider(runner); + + final ProcessException exception = assertThrows(ProcessException.class, + () -> provider.getPassword(requestContext(JDBC_URL, " ", Map.of()))); + + assertEquals("Database Username must be configured for Cloud SQL IAM authentication", exception.getMessage()); + assertEquals(0, scopedCredentials.getRefreshAccessTokenCount()); + } + + @Test + void testBlankMySqlDatabaseUserRejectedBeforeRefresh() throws Exception { + final TestScopedGoogleCredentials scopedCredentials = new TestScopedGoogleCredentials(accessToken(TOKEN_VALUE, -15)); + scopedCredentials.setRefreshedAccessToken(accessToken(REFRESHED_TOKEN_VALUE, 15)); + final TestRunner runner = configureRunner(new RootGoogleCredentials(scopedCredentials), true, CloudSqlDatabaseType.MYSQL); + final DatabasePasswordProvider provider = getProvider(runner); + + final ProcessException exception = assertThrows(ProcessException.class, + () -> provider.getPassword(requestContext(MYSQL_JDBC_URL, " ", MYSQL_DRIVER_CLASS, Map.of()))); + + assertEquals("Database Username must be configured for Cloud SQL IAM authentication", exception.getMessage()); + assertEquals(0, scopedCredentials.getRefreshAccessTokenCount()); + } + + @ParameterizedTest(name = "accepted sslmode {0} from URL") + @MethodSource("acceptedUrlSslModes") + void testAcceptedUrlSslModes(final String sslMode) throws Exception { + final TestScopedGoogleCredentials scopedCredentials = new TestScopedGoogleCredentials(accessToken(TOKEN_VALUE, 15)); + final TestRunner runner = configureRunner(new RootGoogleCredentials(scopedCredentials)); + final DatabasePasswordProvider provider = getProvider(runner); + + final char[] password = provider.getPassword(requestContext( + "jdbc:postgresql://example:5432/database?sslmode=%s".formatted(sslMode), + DATABASE_USER, + Map.of("sslmode", "disable") + )); + + assertArrayEquals(TOKEN_VALUE.toCharArray(), password); + } + + @Test + void testAcceptedCaseInsensitiveUrlSslModeNameAndDecodedValue() throws Exception { + final TestScopedGoogleCredentials scopedCredentials = new TestScopedGoogleCredentials(accessToken(TOKEN_VALUE, 15)); + final TestRunner runner = configureRunner(new RootGoogleCredentials(scopedCredentials)); + final DatabasePasswordProvider provider = getProvider(runner); + + final char[] password = provider.getPassword(requestContext( + "jdbc:postgresql://example:5432/database?SslMode=verify%2Dfull", + DATABASE_USER, + Map.of("sslmode", "disable") + )); + + assertArrayEquals(TOKEN_VALUE.toCharArray(), password); + } + + @ParameterizedTest(name = "accepted sslmode {0} from connection properties") + @MethodSource("acceptedPropertySslModes") + void testAcceptedConnectionPropertySslModes(final String sslMode) throws Exception { + final TestScopedGoogleCredentials scopedCredentials = new TestScopedGoogleCredentials(accessToken(TOKEN_VALUE, 15)); + final TestRunner runner = configureRunner(new RootGoogleCredentials(scopedCredentials)); + final DatabasePasswordProvider provider = getProvider(runner); + + final char[] password = provider.getPassword(requestContext( + "jdbc:postgresql://example:5432/database", + DATABASE_USER, + Map.of("sslmode", sslMode) + )); + + assertArrayEquals(TOKEN_VALUE.toCharArray(), password); + } + + @Test + void testAcceptedCaseInsensitiveConnectionPropertyName() throws Exception { + final TestScopedGoogleCredentials scopedCredentials = new TestScopedGoogleCredentials(accessToken(TOKEN_VALUE, 15)); + final TestRunner runner = configureRunner(new RootGoogleCredentials(scopedCredentials)); + final DatabasePasswordProvider provider = getProvider(runner); + + final char[] password = provider.getPassword(requestContext( + "jdbc:postgresql://example:5432/database", + DATABASE_USER, + Map.of("SSLMODE", "require") + )); + + assertArrayEquals(TOKEN_VALUE.toCharArray(), password); + } + + @Test + void testMissingSslModeRejectedBeforeRefresh() throws Exception { + final TestScopedGoogleCredentials scopedCredentials = new TestScopedGoogleCredentials(accessToken(TOKEN_VALUE, -15)); + scopedCredentials.setRefreshedAccessToken(accessToken(REFRESHED_TOKEN_VALUE, 15)); + final TestRunner runner = configureRunner(new RootGoogleCredentials(scopedCredentials)); + final DatabasePasswordProvider provider = getProvider(runner); + + final ProcessException exception = assertThrows(ProcessException.class, + () -> provider.getPassword(requestContext("jdbc:postgresql://example:5432/database", DATABASE_USER, Map.of()))); + + assertEquals("PostgreSQL sslmode must be configured for Cloud SQL IAM authentication", exception.getMessage()); + assertEquals(0, scopedCredentials.getRefreshAccessTokenCount()); + } + + @ParameterizedTest(name = "rejected sslmode {0}") + @MethodSource("rejectedSslModeContexts") + void testRejectedSslModes(final String jdbcUrl, final Map connectionProperties, final String expectedSslMode) throws Exception { + final TestScopedGoogleCredentials scopedCredentials = new TestScopedGoogleCredentials(accessToken(TOKEN_VALUE, -15)); + scopedCredentials.setRefreshedAccessToken(accessToken(REFRESHED_TOKEN_VALUE, 15)); + final TestRunner runner = configureRunner(new RootGoogleCredentials(scopedCredentials)); + final DatabasePasswordProvider provider = getProvider(runner); + + final ProcessException exception = assertThrows(ProcessException.class, + () -> provider.getPassword(requestContext(jdbcUrl, DATABASE_USER, connectionProperties))); + + assertEquals("PostgreSQL sslmode [%s] is not supported for Cloud SQL IAM authentication".formatted(expectedSslMode), exception.getMessage()); + assertEquals(0, scopedCredentials.getRefreshAccessTokenCount()); + } + + @Test + void testUrlSslModeTakesPrecedenceOverConnectionProperties() throws Exception { + final TestScopedGoogleCredentials scopedCredentials = new TestScopedGoogleCredentials(accessToken(TOKEN_VALUE, 15)); + final TestRunner runner = configureRunner(new RootGoogleCredentials(scopedCredentials)); + final DatabasePasswordProvider provider = getProvider(runner); + + final char[] password = provider.getPassword(requestContext( + "jdbc:postgresql://example:5432/database?sslmode=require", + DATABASE_USER, + Map.of("sslmode", "disable") + )); + + assertArrayEquals(TOKEN_VALUE.toCharArray(), password); + } + + @Test + void testDuplicateUrlSslModeLastInsecureValueRejectedBeforeRefresh() throws Exception { + final TestScopedGoogleCredentials scopedCredentials = new TestScopedGoogleCredentials(accessToken(TOKEN_VALUE, -15)); + scopedCredentials.setRefreshedAccessToken(accessToken(REFRESHED_TOKEN_VALUE, 15)); + final TestRunner runner = configureRunner(new RootGoogleCredentials(scopedCredentials)); + final DatabasePasswordProvider provider = getProvider(runner); + + final ProcessException exception = assertThrows(ProcessException.class, + () -> provider.getPassword(requestContext( + "jdbc:postgresql://example:5432/database?sslmode=require&sslmode=disable", + DATABASE_USER, + Map.of("sslmode", "verify-full") + ))); + + assertEquals("PostgreSQL sslmode [disable] is not supported for Cloud SQL IAM authentication", exception.getMessage()); + assertEquals(0, scopedCredentials.getRefreshAccessTokenCount()); + } + + @Test + void testDuplicateUrlSslModeLastSecureValueAccepted() throws Exception { + final TestScopedGoogleCredentials scopedCredentials = new TestScopedGoogleCredentials(accessToken(TOKEN_VALUE, 15)); + final TestRunner runner = configureRunner(new RootGoogleCredentials(scopedCredentials)); + final DatabasePasswordProvider provider = getProvider(runner); + + final char[] password = provider.getPassword(requestContext( + "jdbc:postgresql://example:5432/database?sslmode=disable&sslmode=require", + DATABASE_USER, + Map.of("sslmode", "disable") + )); + + assertArrayEquals(TOKEN_VALUE.toCharArray(), password); + } + + @Test + void testMalformedUrlEncodedSslModeValueRejectedBeforeRefresh() throws Exception { + final TestScopedGoogleCredentials scopedCredentials = new TestScopedGoogleCredentials(accessToken(TOKEN_VALUE, -15)); + scopedCredentials.setRefreshedAccessToken(accessToken(REFRESHED_TOKEN_VALUE, 15)); + final TestRunner runner = configureRunner(new RootGoogleCredentials(scopedCredentials)); + final DatabasePasswordProvider provider = getProvider(runner); + + final ProcessException exception = assertThrows(ProcessException.class, + () -> provider.getPassword(requestContext( + "jdbc:postgresql://example:5432/database?sslmode=%GG", + DATABASE_USER, + Map.of("sslmode", "require") + ))); + + assertEquals(MALFORMED_SSLMODE_MESSAGE, exception.getMessage()); + assertNull(exception.getCause()); + assertEquals(0, scopedCredentials.getRefreshAccessTokenCount()); + } + + @Test + void testMalformedUrlEncodedSslModeNameRejectedBeforeRefresh() throws Exception { + final TestScopedGoogleCredentials scopedCredentials = new TestScopedGoogleCredentials(accessToken(TOKEN_VALUE, -15)); + scopedCredentials.setRefreshedAccessToken(accessToken(REFRESHED_TOKEN_VALUE, 15)); + final TestRunner runner = configureRunner(new RootGoogleCredentials(scopedCredentials)); + final DatabasePasswordProvider provider = getProvider(runner); + + final ProcessException exception = assertThrows(ProcessException.class, + () -> provider.getPassword(requestContext( + "jdbc:postgresql://example:5432/database?sslmo%G=require", + DATABASE_USER, + Map.of("sslmode", "require") + ))); + + assertEquals(MALFORMED_SSLMODE_MESSAGE, exception.getMessage()); + assertNull(exception.getCause()); + assertEquals(0, scopedCredentials.getRefreshAccessTokenCount()); + } + + @ParameterizedTest(name = "accepted MySQL sslMode {0} from URL") + @MethodSource("acceptedMySqlUrlSslModes") + void testAcceptedMySqlUrlSslModes(final String sslMode) throws Exception { + final TestScopedGoogleCredentials scopedCredentials = new TestScopedGoogleCredentials(accessToken(TOKEN_VALUE, 15)); + final TestRunner runner = configureRunner(new RootGoogleCredentials(scopedCredentials), true, CloudSqlDatabaseType.MYSQL); + final DatabasePasswordProvider provider = getProvider(runner); + + final char[] password = provider.getPassword(requestContext( + "jdbc:mysql://example:3306/database?sslMode=%s".formatted(sslMode), + MYSQL_DATABASE_USER, + MYSQL_DRIVER_CLASS, + Map.of() + )); + + assertArrayEquals(TOKEN_VALUE.toCharArray(), password); + } + + @ParameterizedTest(name = "accepted MySQL sslMode {0} from connection properties") + @MethodSource("acceptedMySqlPropertySslModes") + void testAcceptedMySqlConnectionPropertySslModes(final String sslMode) throws Exception { + final TestScopedGoogleCredentials scopedCredentials = new TestScopedGoogleCredentials(accessToken(TOKEN_VALUE, 15)); + final TestRunner runner = configureRunner(new RootGoogleCredentials(scopedCredentials), true, CloudSqlDatabaseType.MYSQL); + final DatabasePasswordProvider provider = getProvider(runner); + + final char[] password = provider.getPassword(requestContext( + "jdbc:mysql://example:3306/database", + MYSQL_DATABASE_USER, + MYSQL_DRIVER_CLASS, + Map.of("sslMode", sslMode) + )); + + assertArrayEquals(TOKEN_VALUE.toCharArray(), password); + } + + @Test + void testRejectedMySqlCaseInsensitiveUrlSslModeNameAndDecodedValueBeforeRefresh() throws Exception { + final TestScopedGoogleCredentials scopedCredentials = new TestScopedGoogleCredentials(accessToken(TOKEN_VALUE, -15)); + scopedCredentials.setRefreshedAccessToken(accessToken(REFRESHED_TOKEN_VALUE, 15)); + final TestRunner runner = configureRunner(new RootGoogleCredentials(scopedCredentials), true, CloudSqlDatabaseType.MYSQL); + final DatabasePasswordProvider provider = getProvider(runner); + + final ProcessException exception = assertThrows(ProcessException.class, + () -> provider.getPassword(requestContext( + "jdbc:mysql://example:3306/database?SslMode=verify%5Fidentity", + MYSQL_DATABASE_USER, + MYSQL_DRIVER_CLASS, + Map.of("sslMode", "VERIFY_CA") + ))); + + assertEquals("MySQL sslMode must be configured as REQUIRED, VERIFY_CA, or VERIFY_IDENTITY for Cloud SQL IAM authentication", exception.getMessage()); + assertEquals(0, scopedCredentials.getRefreshAccessTokenCount()); + } + + @Test + void testRejectedMySqlCaseInsensitiveConnectionPropertyNameBeforeRefresh() throws Exception { + final TestScopedGoogleCredentials scopedCredentials = new TestScopedGoogleCredentials(accessToken(TOKEN_VALUE, -15)); + scopedCredentials.setRefreshedAccessToken(accessToken(REFRESHED_TOKEN_VALUE, 15)); + final TestRunner runner = configureRunner(new RootGoogleCredentials(scopedCredentials), true, CloudSqlDatabaseType.MYSQL); + final DatabasePasswordProvider provider = getProvider(runner); + + final ProcessException exception = assertThrows(ProcessException.class, + () -> provider.getPassword(requestContext( + "jdbc:mysql://example:3306/database", + MYSQL_DATABASE_USER, + MYSQL_DRIVER_CLASS, + Map.of("SSLMODE", "required") + ))); + + assertEquals("MySQL sslMode must be configured as REQUIRED, VERIFY_CA, or VERIFY_IDENTITY for Cloud SQL IAM authentication", exception.getMessage()); + assertEquals(0, scopedCredentials.getRefreshAccessTokenCount()); + } + + @Test + void testMySqlConnectionPropertiesSslModeTakesPrecedenceOverJdbcUrlBeforeRefresh() throws Exception { + final TestScopedGoogleCredentials scopedCredentials = new TestScopedGoogleCredentials(accessToken(TOKEN_VALUE, -15)); + scopedCredentials.setRefreshedAccessToken(accessToken(REFRESHED_TOKEN_VALUE, 15)); + final TestRunner runner = configureRunner(new RootGoogleCredentials(scopedCredentials), true, CloudSqlDatabaseType.MYSQL); + final DatabasePasswordProvider provider = getProvider(runner); + + final ProcessException exception = assertThrows(ProcessException.class, + () -> provider.getPassword(requestContext( + "jdbc:mysql://example:3306/database?sslMode=REQUIRED", + MYSQL_DATABASE_USER, + MYSQL_DRIVER_CLASS, + Map.of("sslMode", "DISABLED") + ))); + + assertEquals("MySQL sslMode must be configured as REQUIRED, VERIFY_CA, or VERIFY_IDENTITY for Cloud SQL IAM authentication", exception.getMessage()); + assertEquals(0, scopedCredentials.getRefreshAccessTokenCount()); + } + + @Test + void testMySqlDuplicateUrlSslModeLastInsecureValueRejectedBeforeRefresh() throws Exception { + final TestScopedGoogleCredentials scopedCredentials = new TestScopedGoogleCredentials(accessToken(TOKEN_VALUE, -15)); + scopedCredentials.setRefreshedAccessToken(accessToken(REFRESHED_TOKEN_VALUE, 15)); + final TestRunner runner = configureRunner(new RootGoogleCredentials(scopedCredentials), true, CloudSqlDatabaseType.MYSQL); + final DatabasePasswordProvider provider = getProvider(runner); + + final ProcessException exception = assertThrows(ProcessException.class, + () -> provider.getPassword(requestContext( + "jdbc:mysql://example:3306/database?sslMode=REQUIRED&sslMode=DISABLED", + MYSQL_DATABASE_USER, + MYSQL_DRIVER_CLASS, + Map.of() + ))); + + assertEquals("MySQL sslMode must be configured as REQUIRED, VERIFY_CA, or VERIFY_IDENTITY for Cloud SQL IAM authentication", exception.getMessage()); + assertEquals(0, scopedCredentials.getRefreshAccessTokenCount()); + } + + @Test + void testMySqlDuplicateUrlSslModeLastSecureValueAccepted() throws Exception { + final TestScopedGoogleCredentials scopedCredentials = new TestScopedGoogleCredentials(accessToken(TOKEN_VALUE, 15)); + final TestRunner runner = configureRunner(new RootGoogleCredentials(scopedCredentials), true, CloudSqlDatabaseType.MYSQL); + final DatabasePasswordProvider provider = getProvider(runner); + + final char[] password = provider.getPassword(requestContext( + "jdbc:mysql://example:3306/database?sslMode=PREFERRED&sslMode=VERIFY_CA", + MYSQL_DATABASE_USER, + MYSQL_DRIVER_CLASS, + Map.of() + )); + + assertArrayEquals(TOKEN_VALUE.toCharArray(), password); + } + + @Test + void testMySqlExactConnectionPropertySslModeOverridesMissingJdbcUrlSslMode() throws Exception { + final TestScopedGoogleCredentials scopedCredentials = new TestScopedGoogleCredentials(accessToken(TOKEN_VALUE, 15)); + final TestRunner runner = configureRunner(new RootGoogleCredentials(scopedCredentials), true, CloudSqlDatabaseType.MYSQL); + final DatabasePasswordProvider provider = getProvider(runner); + + final char[] password = provider.getPassword(requestContext( + "jdbc:mysql://example:3306/database", + MYSQL_DATABASE_USER, + MYSQL_DRIVER_CLASS, + Map.of("sslMode", "VERIFY_CA") + )); + + assertArrayEquals(TOKEN_VALUE.toCharArray(), password); + } + + @Test + void testMissingMySqlSslModeRejectedBeforeRefresh() throws Exception { + final TestScopedGoogleCredentials scopedCredentials = new TestScopedGoogleCredentials(accessToken(TOKEN_VALUE, -15)); + scopedCredentials.setRefreshedAccessToken(accessToken(REFRESHED_TOKEN_VALUE, 15)); + final TestRunner runner = configureRunner(new RootGoogleCredentials(scopedCredentials), true, CloudSqlDatabaseType.MYSQL); + final DatabasePasswordProvider provider = getProvider(runner); + + final ProcessException exception = assertThrows(ProcessException.class, + () -> provider.getPassword(requestContext("jdbc:mysql://example:3306/database", MYSQL_DATABASE_USER, MYSQL_DRIVER_CLASS, Map.of()))); + + assertEquals("MySQL sslMode must be configured as REQUIRED, VERIFY_CA, or VERIFY_IDENTITY for Cloud SQL IAM authentication", exception.getMessage()); + assertEquals(0, scopedCredentials.getRefreshAccessTokenCount()); + } + + @ParameterizedTest(name = "rejected MySQL sslMode case {0}") + @MethodSource("rejectedMySqlSslModeContexts") + void testRejectedMySqlSslModes(final String jdbcUrl, final Map connectionProperties) throws Exception { + final TestScopedGoogleCredentials scopedCredentials = new TestScopedGoogleCredentials(accessToken(TOKEN_VALUE, -15)); + scopedCredentials.setRefreshedAccessToken(accessToken(REFRESHED_TOKEN_VALUE, 15)); + final TestRunner runner = configureRunner(new RootGoogleCredentials(scopedCredentials), true, CloudSqlDatabaseType.MYSQL); + final DatabasePasswordProvider provider = getProvider(runner); + + final ProcessException exception = assertThrows(ProcessException.class, + () -> provider.getPassword(requestContext(jdbcUrl, MYSQL_DATABASE_USER, MYSQL_DRIVER_CLASS, connectionProperties))); + + assertEquals("MySQL sslMode must be configured as REQUIRED, VERIFY_CA, or VERIFY_IDENTITY for Cloud SQL IAM authentication", exception.getMessage()); + assertEquals(0, scopedCredentials.getRefreshAccessTokenCount()); + } + + @Test + void testMalformedUrlEncodedMySqlSslModeValueRejectedBeforeRefresh() throws Exception { + final TestScopedGoogleCredentials scopedCredentials = new TestScopedGoogleCredentials(accessToken(TOKEN_VALUE, -15)); + scopedCredentials.setRefreshedAccessToken(accessToken(REFRESHED_TOKEN_VALUE, 15)); + final TestRunner runner = configureRunner(new RootGoogleCredentials(scopedCredentials), true, CloudSqlDatabaseType.MYSQL); + final DatabasePasswordProvider provider = getProvider(runner); + + final ProcessException exception = assertThrows(ProcessException.class, + () -> provider.getPassword(requestContext( + "jdbc:mysql://example:3306/database?sslMode=%GG", + MYSQL_DATABASE_USER, + MYSQL_DRIVER_CLASS, + Map.of("sslMode", "REQUIRED") + ))); + + assertEquals(MALFORMED_MYSQL_JDBC_URL_MESSAGE, exception.getMessage()); + assertNull(exception.getCause()); + assertEquals(0, scopedCredentials.getRefreshAccessTokenCount()); + } + + @Test + void testMalformedUrlEncodedMySqlPropertyNameRejectedBeforeRefresh() throws Exception { + final TestScopedGoogleCredentials scopedCredentials = new TestScopedGoogleCredentials(accessToken(TOKEN_VALUE, -15)); + scopedCredentials.setRefreshedAccessToken(accessToken(REFRESHED_TOKEN_VALUE, 15)); + final TestRunner runner = configureRunner(new RootGoogleCredentials(scopedCredentials), true, CloudSqlDatabaseType.MYSQL); + final DatabasePasswordProvider provider = getProvider(runner); + + final ProcessException exception = assertThrows(ProcessException.class, + () -> provider.getPassword(requestContext( + "jdbc:mysql://example:3306/database?sslMo%G=REQUIRED", + MYSQL_DATABASE_USER, + MYSQL_DRIVER_CLASS, + Map.of() + ))); + + assertEquals(MALFORMED_MYSQL_JDBC_URL_MESSAGE, exception.getMessage()); + assertNull(exception.getCause()); + assertEquals(0, scopedCredentials.getRefreshAccessTokenCount()); + } + + @ParameterizedTest(name = "rejected MySQL driver/url case {0}") + @MethodSource("rejectedMySqlDriverAndUrlContexts") + void testRejectedMySqlDriverAndJdbcUrlBeforeRefresh(final String jdbcUrl, + final String driverClassName, + final String expectedMessage) throws Exception { + final TestScopedGoogleCredentials scopedCredentials = new TestScopedGoogleCredentials(accessToken(TOKEN_VALUE, -15)); + scopedCredentials.setRefreshedAccessToken(accessToken(REFRESHED_TOKEN_VALUE, 15)); + final TestRunner runner = configureRunner(new RootGoogleCredentials(scopedCredentials), true, CloudSqlDatabaseType.MYSQL); + final DatabasePasswordProvider provider = getProvider(runner); + + final ProcessException exception = assertThrows(ProcessException.class, + () -> provider.getPassword(requestContext(jdbcUrl, MYSQL_DATABASE_USER, driverClassName, Map.of("sslMode", "REQUIRED")))); + + assertEquals(expectedMessage, exception.getMessage()); + assertEquals(0, scopedCredentials.getRefreshAccessTokenCount()); + } + + @ParameterizedTest(name = "rejected MySQL URL credentials case {0}") + @MethodSource("rejectedMySqlUrlCredentialContexts") + void testMySqlUrlCredentialsRejectedBeforeRefresh(final String jdbcUrl, + final String expectedMessage) throws Exception { + final TestScopedGoogleCredentials scopedCredentials = new TestScopedGoogleCredentials(accessToken(TOKEN_VALUE, -15)); + scopedCredentials.setRefreshedAccessToken(accessToken(REFRESHED_TOKEN_VALUE, 15)); + final TestRunner runner = configureRunner(new RootGoogleCredentials(scopedCredentials), true, CloudSqlDatabaseType.MYSQL); + final DatabasePasswordProvider provider = getProvider(runner); + + final ProcessException exception = assertThrows(ProcessException.class, + () -> provider.getPassword(requestContext(jdbcUrl, MYSQL_DATABASE_USER, MYSQL_DRIVER_CLASS, Map.of("sslMode", "REQUIRED")))); + + assertEquals(expectedMessage, exception.getMessage()); + assertEquals(0, scopedCredentials.getRefreshAccessTokenCount()); + } + + @Test + void testMySqlDisabledClearPasswordPluginInConnectionPropertiesRejectedBeforeRefresh() throws Exception { + final TestScopedGoogleCredentials scopedCredentials = new TestScopedGoogleCredentials(accessToken(TOKEN_VALUE, -15)); + scopedCredentials.setRefreshedAccessToken(accessToken(REFRESHED_TOKEN_VALUE, 15)); + final TestRunner runner = configureRunner(new RootGoogleCredentials(scopedCredentials), true, CloudSqlDatabaseType.MYSQL); + final DatabasePasswordProvider provider = getProvider(runner); + + final ProcessException exception = assertThrows(ProcessException.class, + () -> provider.getPassword(requestContext( + MYSQL_JDBC_URL, + MYSQL_DATABASE_USER, + MYSQL_DRIVER_CLASS, + Map.of("disabledAuthenticationPlugins", " sha256_password , MYSQL_CLEAR_PASSWORD ") + ))); + + assertEquals("MySQL disabledAuthenticationPlugins must not disable the clear-password authentication plugin required for Cloud SQL IAM authentication", exception.getMessage()); + assertEquals(0, scopedCredentials.getRefreshAccessTokenCount()); + } + + @Test + void testMySqlDisabledClearPasswordPluginRejectedWhenConfiguredOnlyInJdbcUrl() throws Exception { + final TestScopedGoogleCredentials scopedCredentials = new TestScopedGoogleCredentials(accessToken(TOKEN_VALUE, -15)); + scopedCredentials.setRefreshedAccessToken(accessToken(REFRESHED_TOKEN_VALUE, 15)); + final TestRunner runner = configureRunner(new RootGoogleCredentials(scopedCredentials), true, CloudSqlDatabaseType.MYSQL); + final DatabasePasswordProvider provider = getProvider(runner); + + final ProcessException exception = assertThrows(ProcessException.class, + () -> provider.getPassword(requestContext( + "jdbc:mysql://example:3306/database?sslMode=REQUIRED&disabledAuthenticationPlugins=mysql_clear_password", + MYSQL_DATABASE_USER, + MYSQL_DRIVER_CLASS, + Map.of() + ))); + + assertEquals("MySQL disabledAuthenticationPlugins must not disable the clear-password authentication plugin required for Cloud SQL IAM authentication", exception.getMessage()); + assertEquals(0, scopedCredentials.getRefreshAccessTokenCount()); + } + + @Test + void testMySqlDisabledClearPasswordPluginInJdbcUrlRejectedBeforeRefresh() throws Exception { + final TestScopedGoogleCredentials scopedCredentials = new TestScopedGoogleCredentials(accessToken(TOKEN_VALUE, -15)); + scopedCredentials.setRefreshedAccessToken(accessToken(REFRESHED_TOKEN_VALUE, 15)); + final TestRunner runner = configureRunner(new RootGoogleCredentials(scopedCredentials), true, CloudSqlDatabaseType.MYSQL); + final DatabasePasswordProvider provider = getProvider(runner); + + final ProcessException exception = assertThrows(ProcessException.class, + () -> provider.getPassword(requestContext( + "jdbc:mysql://example:3306/database?sslMode=REQUIRED&disabledAuthenticationPlugins=com.mysql.cj.protocol.a.authentication.MysqlClearPasswordPlugin", + MYSQL_DATABASE_USER, + MYSQL_DRIVER_CLASS, + Map.of("disabledAuthenticationPlugins", "sha256_password") + ))); + + assertEquals("MySQL disabledAuthenticationPlugins must not disable the clear-password authentication plugin required for Cloud SQL IAM authentication", exception.getMessage()); + assertEquals(0, scopedCredentials.getRefreshAccessTokenCount()); + } + + @ParameterizedTest(name = "rejected MySQL legacy TLS property {0}") + @MethodSource("rejectedMySqlLegacyTlsPropertyContexts") + void testMySqlLegacyTlsPropertiesRejectedBeforeRefresh(final String jdbcUrl, + final Map connectionProperties) throws Exception { + final TestScopedGoogleCredentials scopedCredentials = new TestScopedGoogleCredentials(accessToken(TOKEN_VALUE, -15)); + scopedCredentials.setRefreshedAccessToken(accessToken(REFRESHED_TOKEN_VALUE, 15)); + final TestRunner runner = configureRunner(new RootGoogleCredentials(scopedCredentials), true, CloudSqlDatabaseType.MYSQL); + final DatabasePasswordProvider provider = getProvider(runner); + + final ProcessException exception = assertThrows(ProcessException.class, + () -> provider.getPassword(requestContext(jdbcUrl, MYSQL_DATABASE_USER, MYSQL_DRIVER_CLASS, connectionProperties))); + + assertEquals("MySQL legacy TLS properties useSSL, requireSSL, and verifyServerCertificate are not supported for Cloud SQL IAM authentication", exception.getMessage()); + assertEquals(0, scopedCredentials.getRefreshAccessTokenCount()); + } + + @Test + void testMySqlConnectionPropertyUserRejectedBeforeRefresh() throws Exception { + final TestScopedGoogleCredentials scopedCredentials = new TestScopedGoogleCredentials(accessToken(TOKEN_VALUE, -15)); + scopedCredentials.setRefreshedAccessToken(accessToken(REFRESHED_TOKEN_VALUE, 15)); + final TestRunner runner = configureRunner(new RootGoogleCredentials(scopedCredentials), true, CloudSqlDatabaseType.MYSQL); + final DatabasePasswordProvider provider = getProvider(runner); + + final ProcessException exception = assertThrows(ProcessException.class, + () -> provider.getPassword(requestContext( + MYSQL_JDBC_URL, + MYSQL_DATABASE_USER, + MYSQL_DRIVER_CLASS, + Map.of("user", "override-user") + ))); + + assertEquals("MySQL DBCP connection properties must not define user for Cloud SQL IAM authentication", exception.getMessage()); + assertEquals(0, scopedCredentials.getRefreshAccessTokenCount()); + } + + @Test + void testNullAccessTokenRejected() throws Exception { + final TestScopedGoogleCredentials scopedCredentials = new TestScopedGoogleCredentials(null); + scopedCredentials.setRefreshedAccessToken(null); + final TestRunner runner = configureRunner(new RootGoogleCredentials(scopedCredentials)); + final DatabasePasswordProvider provider = getProvider(runner); + + final ProcessException exception = assertThrows(ProcessException.class, + () -> provider.getPassword(requestContext(JDBC_URL, DATABASE_USER, Map.of()))); + + assertEquals(FAILED_PASSWORD_MESSAGE, exception.getMessage()); + } + + @Test + void testBlankAccessTokenRejected() throws Exception { + final TestScopedGoogleCredentials scopedCredentials = new TestScopedGoogleCredentials(null); + scopedCredentials.setRefreshedAccessToken(accessToken(" ", 15)); + final TestRunner runner = configureRunner(new RootGoogleCredentials(scopedCredentials)); + final DatabasePasswordProvider provider = getProvider(runner); + + final ProcessException exception = assertThrows(ProcessException.class, + () -> provider.getPassword(requestContext(JDBC_URL, DATABASE_USER, Map.of()))); + + assertEquals(FAILED_PASSWORD_MESSAGE, exception.getMessage()); + } + + @Test + void testArbitraryRefreshFailureIsSanitized() throws Exception { + final TestScopedGoogleCredentials scopedCredentials = new TestScopedGoogleCredentials(null); + scopedCredentials.setRefreshException(ioException(LEAK_SENTINEL, "org.example.CustomCredentials")); + final TestRunner runner = configureRunner(new RootGoogleCredentials(scopedCredentials)); + final DatabasePasswordProvider provider = getProvider(runner); + + final ProcessException exception = assertThrows(ProcessException.class, + () -> provider.getPassword(requestContext(JDBC_URL, DATABASE_USER, Map.of()))); + + assertEquals(FAILED_PASSWORD_MESSAGE, exception.getMessage()); + assertNull(exception.getCause()); + assertFalse(exception.getMessage().contains(LEAK_SENTINEL)); + assertNoLogMessagesContain(runner.getControllerServiceLogger(PASSWORD_PROVIDER_ID), LEAK_SENTINEL); + } + + @Test + void testUnknownGoogleAuthRefreshMessageIsSanitized() throws Exception { + final TestScopedGoogleCredentials scopedCredentials = new TestScopedGoogleCredentials(null); + final IOException ioException = ioException(LEAK_SENTINEL, "com.google.auth.oauth2.ImpersonatedCredentials"); + scopedCredentials.setRefreshException(ioException); + final TestRunner runner = configureRunner(new RootGoogleCredentials(scopedCredentials)); + final DatabasePasswordProvider provider = getProvider(runner); + + final ProcessException exception = assertThrows(ProcessException.class, + () -> provider.getPassword(requestContext(JDBC_URL, DATABASE_USER, Map.of()))); + + assertEquals(FAILED_PASSWORD_MESSAGE, exception.getMessage()); + assertNull(exception.getCause()); + } + + @Test + void testGoogleAuthRefreshFailurePreservesCause() throws Exception { + final TestScopedGoogleCredentials scopedCredentials = new TestScopedGoogleCredentials(null); + final IOException ioException = ioException("Error requesting access token", "com.google.auth.oauth2.ImpersonatedCredentials"); + scopedCredentials.setRefreshException(ioException); + final TestRunner runner = configureRunner(new RootGoogleCredentials(scopedCredentials)); + final DatabasePasswordProvider provider = getProvider(runner); + + final ProcessException exception = assertThrows(ProcessException.class, + () -> provider.getPassword(requestContext(JDBC_URL, DATABASE_USER, Map.of()))); + + assertEquals(FAILED_PASSWORD_MESSAGE, exception.getMessage()); + assertSame(ioException, exception.getCause()); + } + + @Test + void testGetPasswordReturnsFreshCharacterArrayEachCall() throws Exception { + final TestScopedGoogleCredentials scopedCredentials = new TestScopedGoogleCredentials(accessToken(TOKEN_VALUE, 15)); + final TestRunner runner = configureRunner(new RootGoogleCredentials(scopedCredentials)); + final DatabasePasswordProvider provider = getProvider(runner); + + final char[] firstPassword = provider.getPassword(requestContext(JDBC_URL, DATABASE_USER, Map.of())); + firstPassword[0] = 'X'; + final char[] secondPassword = provider.getPassword(requestContext(JDBC_URL, DATABASE_USER, Map.of())); + + assertNotSame(firstPassword, secondPassword); + assertArrayEquals(TOKEN_VALUE.toCharArray(), secondPassword); + } + + @Test + void testControllerServiceRegistrationContainsProvider() throws IOException { + final String resourcePath = "META-INF/services/org.apache.nifi.controller.ControllerService"; + try (InputStream inputStream = GcpCloudSqlIamDatabasePasswordProvider.class.getClassLoader().getResourceAsStream(resourcePath)) { + assertNotNull(inputStream); + final String registeredServices = new String(inputStream.readAllBytes(), StandardCharsets.UTF_8); + assertTrue(registeredServices.contains(GcpCloudSqlIamDatabasePasswordProvider.class.getName())); + } + } + + @Test + void testAdditionalDetailsResourceDocumentsSupportedPath() throws IOException { + final String resourcePath = "docs/%s/additionalDetails.md".formatted(GcpCloudSqlIamDatabasePasswordProvider.class.getName()); + try (InputStream inputStream = GcpCloudSqlIamDatabasePasswordProvider.class.getClassLoader().getResourceAsStream(resourcePath)) { + assertNotNull(inputStream); + final String additionalDetails = new String(inputStream.readAllBytes(), StandardCharsets.UTF_8); + assertTrue(additionalDetails.contains("Database Type")); + assertTrue(additionalDetails.contains("supports Cloud SQL for PostgreSQL and Cloud SQL for MySQL")); + assertTrue(additionalDetails.contains("roles/iam.workloadIdentityUser")); + assertTrue(additionalDetails.contains("must be configured with **Target Service Account**")); + assertTrue(additionalDetails.contains("jdbc:postgresql://:5432/?sslmode=require")); + assertTrue(additionalDetails.contains("jdbc:mysql://:3306/?sslMode=REQUIRED")); + assertTrue(additionalDetails.contains("com.mysql.cj.jdbc.Driver")); + assertTrue(additionalDetails.contains("**Verify** checks that NiFi can obtain a token")); + assertTrue(additionalDetails.contains("DBCP **Verify** checks the")); + } + } + + private static Stream acceptedUrlSslModes() { + return Stream.of( + Arguments.of("prefer"), + Arguments.of("require"), + Arguments.of("verify-ca"), + Arguments.of("verify-full") + ); + } + + private static Stream acceptedPropertySslModes() { + return acceptedUrlSslModes(); + } + + private static Stream acceptedMySqlUrlSslModes() { + return Stream.of( + Arguments.of("REQUIRED"), + Arguments.of("VERIFY_CA"), + Arguments.of("VERIFY_IDENTITY") + ); + } + + private static Stream acceptedMySqlPropertySslModes() { + return acceptedMySqlUrlSslModes(); + } + + private static Stream rejectedSslModeContexts() { + return Stream.of( + Arguments.of("jdbc:postgresql://example:5432/database?sslmode=disable", Map.of("sslmode", "require"), "disable"), + Arguments.of("jdbc:postgresql://example:5432/database?sslmode=allow", Map.of("sslmode", "verify-full"), "allow"), + Arguments.of("jdbc:postgresql://example:5432/database", Map.of("sslmode", "disable"), "disable"), + Arguments.of("jdbc:postgresql://example:5432/database", Map.of("sslmode", "allow"), "allow") + ); + } + + private static Stream rejectedMySqlSslModeContexts() { + return Stream.of( + Arguments.of("jdbc:mysql://example:3306/database?sslMode=REQUIRED", Map.of("sslMode", "DISABLED")), + Arguments.of("jdbc:mysql://example:3306/database?sslMode=VERIFY_CA", Map.of("sslMode", "PREFERRED")), + Arguments.of("jdbc:mysql://example:3306/database", Map.of("sslMode", "DISABLED")), + Arguments.of("jdbc:mysql://example:3306/database", Map.of("sslMode", "PREFERRED")) + ); + } + + private static Stream rejectedMySqlDriverAndUrlContexts() { + return Stream.of( + Arguments.of(MYSQL_JDBC_URL, POSTGRES_DRIVER_CLASS, + "MySQL driver class must be configured as com.mysql.cj.jdbc.Driver for Cloud SQL IAM authentication"), + Arguments.of("jdbc:mariadb://example:3306/database?sslMode=REQUIRED", MYSQL_DRIVER_CLASS, + "MySQL JDBC URL must use the standard single-host jdbc:mysql:// format for Cloud SQL IAM authentication"), + Arguments.of("jdbc:mysql://example:3306,demo:3307/database?sslMode=REQUIRED", MYSQL_DRIVER_CLASS, + "MySQL JDBC URL must use the standard single-host jdbc:mysql:// format for Cloud SQL IAM authentication") + ); + } + + private static Stream rejectedMySqlLegacyTlsPropertyContexts() { + return Stream.of( + Arguments.of("jdbc:mysql://example:3306/database?sslMode=REQUIRED&useSSL=false", Map.of()), + Arguments.of("jdbc:mysql://example:3306/database?sslMode=REQUIRED&requireSSL=true", Map.of()), + Arguments.of("jdbc:mysql://example:3306/database?sslMode=REQUIRED&verifyServerCertificate=false", Map.of()), + Arguments.of("jdbc:mysql://example:3306/database?sslMode=REQUIRED", Map.of("useSSL", "false")), + Arguments.of("jdbc:mysql://example:3306/database?sslMode=REQUIRED", Map.of("requireSSL", "true")), + Arguments.of("jdbc:mysql://example:3306/database?sslMode=REQUIRED", Map.of("verifyServerCertificate", "false")) + ); + } + + private static Stream rejectedMySqlUrlCredentialContexts() { + return Stream.of( + Arguments.of("jdbc:mysql://iam-user@example:3306/database?sslMode=REQUIRED", + "MySQL JDBC URL must not define user or password for Cloud SQL IAM authentication"), + Arguments.of("jdbc:mysql://example:3306/database?sslMode=REQUIRED&user=override-user", + "MySQL JDBC URL must not define user or password for Cloud SQL IAM authentication"), + Arguments.of("jdbc:mysql://example:3306/database?sslMode=REQUIRED&password=override-password", + "MySQL JDBC URL must not define user or password for Cloud SQL IAM authentication") + ); + } + + private static Stream verifySuccessContexts() throws IOException { + return Stream.of( + Arguments.of( + CloudSqlDatabaseType.POSTGRESQL, + new TestScopedGoogleCredentials(accessToken(TOKEN_VALUE, 15)), + "created a Cloud SQL scoped TestScopedGoogleCredentials instance.", + "Cloud SQL IAM token acquisition for the current principal" + ), + Arguments.of( + CloudSqlDatabaseType.MYSQL, + new TestScopedGoogleCredentials(accessToken(TOKEN_VALUE, 15)), + "created a Cloud SQL scoped TestScopedGoogleCredentials instance.", + "Cloud SQL IAM token acquisition for the current principal" + ) + ); + } + + private TestRunner configureRunner(final GoogleCredentials rootCredentials) throws Exception { + return configureRunner(rootCredentials, true); + } + + private TestRunner configureRunner(final GoogleCredentials rootCredentials, final boolean enableProvider) throws Exception { + return configureRunner(rootCredentials, enableProvider, null); + } + + private TestRunner configureRunner(final GoogleCredentials rootCredentials, final boolean enableProvider, + final CloudSqlDatabaseType databaseType) throws Exception { + final TestRunner runner = TestRunners.newTestRunner(NoOpProcessor.class); + + final TestGCPCredentialsService credentialsService = new TestGCPCredentialsService(rootCredentials); + runner.addControllerService(CREDENTIALS_SERVICE_ID, credentialsService); + runner.enableControllerService(credentialsService); + + final GcpCloudSqlIamDatabasePasswordProvider provider = new GcpCloudSqlIamDatabasePasswordProvider(); + runner.addControllerService(PASSWORD_PROVIDER_ID, provider); + runner.setProperty(provider, GCP_CREDENTIALS_PROVIDER_SERVICE, CREDENTIALS_SERVICE_ID); + if (databaseType != null) { + runner.setProperty(provider, DATABASE_TYPE, databaseType.getValue()); + } + if (enableProvider) { + runner.enableControllerService(provider); + runner.assertValid(provider); + } + + return runner; + } + + private DatabasePasswordProvider getProvider(final TestRunner runner) { + return (DatabasePasswordProvider) runner.getProcessContext() + .getControllerServiceLookup() + .getControllerService(PASSWORD_PROVIDER_ID); + } + + private GcpCloudSqlIamDatabasePasswordProvider getProviderImplementation(final TestRunner runner) { + return (GcpCloudSqlIamDatabasePasswordProvider) getProvider(runner); + } + + private DatabasePasswordRequestContext requestContext(final String jdbcUrl, final String databaseUser, final Map connectionProperties) { + return requestContext(jdbcUrl, databaseUser, POSTGRES_DRIVER_CLASS, connectionProperties); + } + + private DatabasePasswordRequestContext requestContext(final String jdbcUrl, final String databaseUser, + final String driverClassName, final Map connectionProperties) { + return DatabasePasswordRequestContext.builder() + .jdbcUrl(jdbcUrl) + .databaseUser(databaseUser) + .driverClassName(driverClassName) + .connectionProperties(connectionProperties) + .build(); + } + + private char[] getPasswordAfterStart(final DatabasePasswordProvider provider, final CountDownLatch startLatch) throws InterruptedException { + startLatch.await(5, TimeUnit.SECONDS); + return provider.getPassword(requestContext(JDBC_URL, DATABASE_USER, Map.of())); + } + + private static AccessToken accessToken(final String tokenValue, final long offsetMinutes) { + return tokenValue == null ? null : new AccessToken(tokenValue, java.util.Date.from(Instant.now().plusSeconds(offsetMinutes * 60))); + } + + private static IOException ioException(final String message, final String className) { + final IOException ioException = new IOException(message); + ioException.setStackTrace(new StackTraceElement[]{new StackTraceElement(className, "refreshAccessToken", "Source.java", 1)}); + return ioException; + } + + private static void assertVerificationResult(final ConfigVerificationResult result, final String stepName, + final ConfigVerificationResult.Outcome outcome, final String explanationFragment) { + assertEquals(stepName, result.getVerificationStepName()); + assertEquals(outcome, result.getOutcome()); + assertTrue(result.getExplanation().contains(explanationFragment), result::getExplanation); + } + + private static void assertNoLogMessagesContain(final MockComponentLog logger, final String value) { + final List logMessages = new ArrayList<>(); + logMessages.addAll(logger.getInfoMessages()); + logMessages.addAll(logger.getWarnMessages()); + logMessages.addAll(logger.getErrorMessages()); + + for (final LogMessage logMessage : logMessages) { + final String rawMessage = logMessage.getMsg(); + assertFalse(rawMessage != null && rawMessage.contains(value)); + final Object[] args = logMessage.getArgs(); + final String formattedMessage = MessageFormatter.arrayFormat(rawMessage, args == null ? new Object[0] : args).getMessage(); + assertFalse(formattedMessage != null && formattedMessage.contains(value)); + if (args != null) { + for (final Object arg : args) { + final String argValue = arg == null ? null : arg.toString(); + assertFalse(argValue != null && argValue.contains(value)); + } + } + assertThrowableChainDoesNotContain(logMessage.getThrowable(), value, java.util.Collections.newSetFromMap(new IdentityHashMap<>())); + } + } + + private static void assertThrowableChainDoesNotContain(final Throwable throwable, final String value, final Set visited) { + if (throwable == null || !visited.add(throwable)) { + return; + } + + final String message = throwable.getMessage(); + assertFalse(message != null && message.contains(value)); + + for (final Throwable suppressed : throwable.getSuppressed()) { + assertThrowableChainDoesNotContain(suppressed, value, visited); + } + + assertThrowableChainDoesNotContain(throwable.getCause(), value, visited); + } + + private static GoogleCredentials getScopedCredentials(final GcpCloudSqlIamDatabasePasswordProvider provider) throws ReflectiveOperationException { + final Field field = GcpCloudSqlIamDatabasePasswordProvider.class.getDeclaredField("scopedCredentials"); + field.setAccessible(true); + return (GoogleCredentials) field.get(provider); + } + + private static CloudSqlDatabaseType getDatabaseType(final GcpCloudSqlIamDatabasePasswordProvider provider) throws ReflectiveOperationException { + final Field field = GcpCloudSqlIamDatabasePasswordProvider.class.getDeclaredField("databaseType"); + field.setAccessible(true); + return (CloudSqlDatabaseType) field.get(provider); + } + + private static void setScopedCredentials(final GcpCloudSqlIamDatabasePasswordProvider provider, final GoogleCredentials credentials) throws ReflectiveOperationException { + final Field field = GcpCloudSqlIamDatabasePasswordProvider.class.getDeclaredField("scopedCredentials"); + field.setAccessible(true); + field.set(provider, credentials); + } + + private static void setDatabaseType(final GcpCloudSqlIamDatabasePasswordProvider provider, final CloudSqlDatabaseType configuredDatabaseType) throws ReflectiveOperationException { + final Field field = GcpCloudSqlIamDatabasePasswordProvider.class.getDeclaredField("databaseType"); + field.setAccessible(true); + field.set(provider, configuredDatabaseType); + } + + private static final class TestGCPCredentialsService extends AbstractControllerService implements GCPCredentialsService { + private final GoogleCredentials googleCredentials; + + private TestGCPCredentialsService(final GoogleCredentials googleCredentials) { + this.googleCredentials = googleCredentials; + } + + @Override + public GoogleCredentials getGoogleCredentials() { + return googleCredentials; + } + } + + private static final class RootGoogleCredentials extends GoogleCredentials { + private final AtomicInteger createScopedCount = new AtomicInteger(); + private final GoogleCredentials[] scopedCredentials; + private final RuntimeException createScopedException; + private final AtomicInteger scopedCredentialIndex = new AtomicInteger(); + private volatile List lastRequestedScopes = List.of(); + + private RootGoogleCredentials(final GoogleCredentials scopedCredentials) { + this.scopedCredentials = new GoogleCredentials[]{scopedCredentials}; + this.createScopedException = null; + } + + private RootGoogleCredentials(final RuntimeException createScopedException) { + this.scopedCredentials = new GoogleCredentials[0]; + this.createScopedException = createScopedException; + } + + private RootGoogleCredentials(final GoogleCredentials firstScopedCredentials, final GoogleCredentials secondScopedCredentials) { + this.scopedCredentials = new GoogleCredentials[]{firstScopedCredentials, secondScopedCredentials}; + this.createScopedException = null; + } + + @Override + public GoogleCredentials createScoped(final java.util.Collection scopes) { + createScopedCount.incrementAndGet(); + lastRequestedScopes = List.copyOf(scopes); + if (createScopedException != null) { + throw createScopedException; + } + + final int index = Math.min(scopedCredentialIndex.getAndIncrement(), scopedCredentials.length - 1); + return scopedCredentials.length == 0 ? null : scopedCredentials[index]; + } + + private int getCreateScopedCount() { + return createScopedCount.get(); + } + + private List getLastRequestedScopes() { + return lastRequestedScopes; + } + } + + private static class TestScopedGoogleCredentials extends GoogleCredentials { + private final AtomicInteger refreshAccessTokenCount = new AtomicInteger(); + private volatile AccessToken refreshedAccessToken; + private volatile IOException refreshException; + private volatile RuntimeException runtimeException; + + private TestScopedGoogleCredentials(final AccessToken initialAccessToken) { + super(initialAccessToken); + } + + @Override + public AccessToken refreshAccessToken() throws IOException { + refreshAccessTokenCount.incrementAndGet(); + if (refreshException != null) { + throw refreshException; + } + if (runtimeException != null) { + throw runtimeException; + } + return refreshedAccessToken; + } + + protected void setRefreshedAccessToken(final AccessToken refreshedAccessToken) { + this.refreshedAccessToken = refreshedAccessToken; + } + + protected void setRefreshException(final IOException refreshException) { + this.refreshException = refreshException; + } + + protected void setRuntimeException(final RuntimeException runtimeException) { + this.runtimeException = runtimeException; + } + + protected int getRefreshAccessTokenCount() { + return refreshAccessTokenCount.get(); + } + } + + private static ImpersonatedCredentials impersonatedCredentials(final AccessToken accessToken) throws IOException { + final ImpersonatedCredentials credentials = mock(ImpersonatedCredentials.class); + when(credentials.refreshAccessToken()).thenReturn(accessToken); + return credentials; + } + + private static ImpersonatedCredentials impersonatedCredentials(final IOException exception) throws IOException { + final ImpersonatedCredentials credentials = mock(ImpersonatedCredentials.class); + when(credentials.refreshAccessToken()).thenThrow(exception); + return credentials; + } + + private static ImpersonatedCredentials impersonatedCredentials(final RuntimeException exception) throws IOException { + final ImpersonatedCredentials credentials = mock(ImpersonatedCredentials.class); + when(credentials.refreshAccessToken()).thenThrow(exception); + return credentials; + } + + private static IdentityPoolCredentials identityPoolCredentials(final AccessToken accessToken) throws IOException { + final IdentityPoolCredentials credentials = mock(IdentityPoolCredentials.class); + when(credentials.refreshAccessToken()).thenReturn(accessToken); + return credentials; + } + + private static final class BlockingScopedGoogleCredentials extends TestScopedGoogleCredentials { + private final CountDownLatch refreshEnteredLatch = new CountDownLatch(1); + private final CountDownLatch releaseRefreshLatch = new CountDownLatch(1); + + private BlockingScopedGoogleCredentials() { + super(null); + } + + @Override + public AccessToken refreshAccessToken() throws IOException { + refreshEnteredLatch.countDown(); + try { + if (!releaseRefreshLatch.await(5, TimeUnit.SECONDS)) { + throw new IOException("Timed out waiting for refresh release"); + } + } catch (final InterruptedException e) { + Thread.currentThread().interrupt(); + throw new IOException("Interrupted while waiting for refresh release", e); + } + return super.refreshAccessToken(); + } + + private boolean awaitRefreshEntry() throws InterruptedException { + return refreshEnteredLatch.await(5, TimeUnit.SECONDS); + } + + private void releaseRefresh() { + releaseRefreshLatch.countDown(); + } + } +} diff --git a/nifi-extension-bundles/nifi-gcp-bundle/nifi-gcp-processors/src/test/java/org/apache/nifi/processors/gcp/credentials/factory/strategies/WorkloadIdentityFederationCredentialsStrategyTest.java b/nifi-extension-bundles/nifi-gcp-bundle/nifi-gcp-processors/src/test/java/org/apache/nifi/processors/gcp/credentials/factory/strategies/WorkloadIdentityFederationCredentialsStrategyTest.java new file mode 100644 index 000000000000..be4d13ff010a --- /dev/null +++ b/nifi-extension-bundles/nifi-gcp-bundle/nifi-gcp-processors/src/test/java/org/apache/nifi/processors/gcp/credentials/factory/strategies/WorkloadIdentityFederationCredentialsStrategyTest.java @@ -0,0 +1,131 @@ +/* + * 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.nifi.processors.gcp.credentials.factory.strategies; + +import com.google.api.client.http.HttpTransport; +import com.google.api.client.http.javanet.NetHttpTransport; +import com.google.auth.http.HttpTransportFactory; +import com.google.auth.oauth2.GoogleCredentials; +import com.google.auth.oauth2.IdentityPoolCredentials; +import com.google.auth.oauth2.ImpersonatedCredentials; +import org.apache.nifi.components.PropertyValue; +import org.apache.nifi.controller.AbstractControllerService; +import org.apache.nifi.controller.ConfigurationContext; +import org.apache.nifi.oauth2.AccessToken; +import org.apache.nifi.oauth2.OAuth2AccessTokenProvider; +import org.apache.nifi.processors.gcp.credentials.factory.CredentialPropertyDescriptors; +import org.junit.jupiter.api.Test; + +import java.io.IOException; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertInstanceOf; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +class WorkloadIdentityFederationCredentialsStrategyTest { + private static final String AUDIENCE = "projects/123456789/locations/global/workloadIdentityPools/pool/providers/provider"; + private static final String SCOPE = "https://www.googleapis.com/auth/cloud-platform"; + private static final String TOKEN_ENDPOINT = "https://sts.googleapis.com/v1/token"; + private static final String SUBJECT_TOKEN_TYPE = "urn:ietf:params:oauth:token-type:jwt"; + private static final String TARGET_SERVICE_ACCOUNT = "target-account@test-project.iam.gserviceaccount.com"; + private static final String SQLSERVICE_LOGIN_SCOPE = "https://www.googleapis.com/auth/sqlservice.login"; + + private final WorkloadIdentityFederationCredentialsStrategy strategy = new WorkloadIdentityFederationCredentialsStrategy(); + + @Test + void testReturnsIdentityPoolCredentialsWhenTargetServiceAccountNotConfigured() throws IOException { + final ConfigurationContext context = mockConfigurationContext(null); + + final GoogleCredentials credentials = strategy.getGoogleCredentials(context, transportFactory()); + + assertInstanceOf(IdentityPoolCredentials.class, credentials); + } + + @Test + void testReturnsImpersonatedCredentialsWithEmptyScopesAndPreservedTransportFactory() throws IOException { + final HttpTransportFactory transportFactory = transportFactory(); + final ConfigurationContext context = mockConfigurationContext(TARGET_SERVICE_ACCOUNT); + + final GoogleCredentials credentials = strategy.getGoogleCredentials(context, transportFactory); + + final ImpersonatedCredentials impersonatedCredentials = assertInstanceOf(ImpersonatedCredentials.class, credentials); + assertEquals(TARGET_SERVICE_ACCOUNT, impersonatedCredentials.getAccount()); + assertInstanceOf(IdentityPoolCredentials.class, impersonatedCredentials.getSourceCredentials()); + assertTrue(impersonatedCredentials.toBuilder().getScopes().isEmpty()); + assertTrue(impersonatedCredentials.createScopedRequired()); + assertSame(transportFactory, impersonatedCredentials.toBuilder().getHttpTransportFactory()); + + final GoogleCredentials scopedCredentials = credentials.createScoped(List.of(SQLSERVICE_LOGIN_SCOPE)); + + final ImpersonatedCredentials scopedImpersonatedCredentials = assertInstanceOf(ImpersonatedCredentials.class, scopedCredentials); + assertEquals(TARGET_SERVICE_ACCOUNT, scopedImpersonatedCredentials.getAccount()); + assertSame(impersonatedCredentials.getSourceCredentials(), scopedImpersonatedCredentials.getSourceCredentials()); + assertEquals(List.of(SQLSERVICE_LOGIN_SCOPE), scopedImpersonatedCredentials.toBuilder().getScopes()); + assertFalse(scopedImpersonatedCredentials.createScopedRequired()); + assertSame(transportFactory, scopedImpersonatedCredentials.toBuilder().getHttpTransportFactory()); + } + + private ConfigurationContext mockConfigurationContext(final String targetServiceAccount) { + final ConfigurationContext context = mock(ConfigurationContext.class); + final PropertyValue audiencePropertyValue = stringPropertyValue(AUDIENCE); + final PropertyValue scopePropertyValue = stringPropertyValue(SCOPE); + final PropertyValue tokenEndpointPropertyValue = stringPropertyValue(TOKEN_ENDPOINT); + final PropertyValue subjectTokenTypePropertyValue = stringPropertyValue(SUBJECT_TOKEN_TYPE); + final PropertyValue targetServiceAccountPropertyValue = stringPropertyValue(targetServiceAccount); + + when(context.getProperty(CredentialPropertyDescriptors.WORKLOAD_IDENTITY_AUDIENCE)).thenReturn(audiencePropertyValue); + when(context.getProperty(CredentialPropertyDescriptors.WORKLOAD_IDENTITY_SCOPE)).thenReturn(scopePropertyValue); + when(context.getProperty(CredentialPropertyDescriptors.WORKLOAD_IDENTITY_TOKEN_ENDPOINT)).thenReturn(tokenEndpointPropertyValue); + when(context.getProperty(CredentialPropertyDescriptors.WORKLOAD_IDENTITY_SUBJECT_TOKEN_TYPE)).thenReturn(subjectTokenTypePropertyValue); + when(context.getProperty(CredentialPropertyDescriptors.TARGET_SERVICE_ACCOUNT)).thenReturn(targetServiceAccountPropertyValue); + + final PropertyValue subjectTokenProviderProperty = mock(PropertyValue.class); + when(subjectTokenProviderProperty.asControllerService(OAuth2AccessTokenProvider.class)).thenReturn(new MockOAuth2AccessTokenProvider()); + when(context.getProperty(CredentialPropertyDescriptors.WORKLOAD_IDENTITY_SUBJECT_TOKEN_PROVIDER)).thenReturn(subjectTokenProviderProperty); + return context; + } + + private PropertyValue stringPropertyValue(final String value) { + final PropertyValue propertyValue = mock(PropertyValue.class); + when(propertyValue.getValue()).thenReturn(value); + return propertyValue; + } + + private HttpTransportFactory transportFactory() { + final HttpTransport transport = new NetHttpTransport(); + return () -> transport; + } + + private static final class MockOAuth2AccessTokenProvider extends AbstractControllerService implements OAuth2AccessTokenProvider { + @Override + public AccessToken getAccessDetails() { + final AccessToken accessToken = new AccessToken(); + accessToken.setAccessToken("subject-token"); + accessToken.setExpiresIn(3600L); + return accessToken; + } + + @Override + public void refreshAccessDetails() { + } + } +} diff --git a/nifi-extension-bundles/nifi-gcp-bundle/nifi-gcp-processors/src/test/java/org/apache/nifi/processors/gcp/credentials/service/GCPCredentialsServiceTest.java b/nifi-extension-bundles/nifi-gcp-bundle/nifi-gcp-processors/src/test/java/org/apache/nifi/processors/gcp/credentials/service/GCPCredentialsServiceTest.java index 538374668ad6..50746bebf0c6 100644 --- a/nifi-extension-bundles/nifi-gcp-bundle/nifi-gcp-processors/src/test/java/org/apache/nifi/processors/gcp/credentials/service/GCPCredentialsServiceTest.java +++ b/nifi-extension-bundles/nifi-gcp-bundle/nifi-gcp-processors/src/test/java/org/apache/nifi/processors/gcp/credentials/service/GCPCredentialsServiceTest.java @@ -16,9 +16,12 @@ */ package org.apache.nifi.processors.gcp.credentials.service; +import com.google.auth.oauth2.ComputeEngineCredentials; import com.google.auth.oauth2.GoogleCredentials; import com.google.auth.oauth2.IdentityPoolCredentials; +import com.google.auth.oauth2.ImpersonatedCredentials; import com.google.auth.oauth2.ServiceAccountCredentials; +import org.apache.nifi.components.ValidationResult; import org.apache.nifi.controller.AbstractControllerService; import org.apache.nifi.gcp.credentials.service.GCPCredentialsService; import org.apache.nifi.oauth2.AccessToken; @@ -28,21 +31,37 @@ import org.apache.nifi.util.TestRunners; import org.junit.jupiter.api.Test; +import java.io.IOException; +import java.io.InputStream; +import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.nio.file.Paths; +import java.util.ArrayList; +import java.util.Collection; +import java.util.List; import static org.apache.nifi.processors.gcp.credentials.factory.CredentialPropertyDescriptors.AUTHENTICATION_STRATEGY; import static org.apache.nifi.processors.gcp.credentials.factory.CredentialPropertyDescriptors.SERVICE_ACCOUNT_JSON; import static org.apache.nifi.processors.gcp.credentials.factory.CredentialPropertyDescriptors.SERVICE_ACCOUNT_JSON_FILE; +import static org.apache.nifi.processors.gcp.credentials.factory.CredentialPropertyDescriptors.TARGET_SERVICE_ACCOUNT; import static org.apache.nifi.processors.gcp.credentials.factory.CredentialPropertyDescriptors.WORKLOAD_IDENTITY_AUDIENCE; import static org.apache.nifi.processors.gcp.credentials.factory.CredentialPropertyDescriptors.WORKLOAD_IDENTITY_SCOPE; import static org.apache.nifi.processors.gcp.credentials.factory.CredentialPropertyDescriptors.WORKLOAD_IDENTITY_SUBJECT_TOKEN_PROVIDER; import static org.apache.nifi.processors.gcp.credentials.factory.CredentialPropertyDescriptors.WORKLOAD_IDENTITY_SUBJECT_TOKEN_TYPE; import static org.apache.nifi.processors.gcp.credentials.factory.CredentialPropertyDescriptors.WORKLOAD_IDENTITY_TOKEN_ENDPOINT; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertInstanceOf; import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; public class GCPCredentialsServiceTest { + private static final String WORKLOAD_IDENTITY_AUDIENCE_VALUE = "projects/123456789/locations/global/workloadIdentityPools/pool/providers/provider"; + private static final String WORKLOAD_IDENTITY_SCOPE_VALUE = "https://www.googleapis.com/auth/cloud-platform"; + private static final String WORKLOAD_IDENTITY_TOKEN_ENDPOINT_VALUE = "https://sts.googleapis.com/v1/token"; + private static final String WORKLOAD_IDENTITY_SUBJECT_TOKEN_TYPE_VALUE = "urn:ietf:params:oauth:token-type:jwt"; + private static final String TARGET_SERVICE_ACCOUNT_VALUE = "target-account@test-project.iam.gserviceaccount.com"; + @Test public void testToString() throws Exception { // toString method shouldn't cause an exception @@ -121,12 +140,7 @@ public void testWorkloadIdentityFederationCredentials() throws Exception { runner.addControllerService("subjectTokenProvider", subjectTokenProvider); runner.enableControllerService(subjectTokenProvider); - runner.setProperty(serviceImpl, AUTHENTICATION_STRATEGY, AuthenticationStrategy.WORKLOAD_IDENTITY_FEDERATION.getValue()); - runner.setProperty(serviceImpl, WORKLOAD_IDENTITY_AUDIENCE, "projects/123456789/locations/global/workloadIdentityPools/pool/providers/provider"); - runner.setProperty(serviceImpl, WORKLOAD_IDENTITY_SCOPE, "https://www.googleapis.com/auth/cloud-platform"); - runner.setProperty(serviceImpl, WORKLOAD_IDENTITY_TOKEN_ENDPOINT, "https://sts.googleapis.com/v1/token"); - runner.setProperty(serviceImpl, WORKLOAD_IDENTITY_SUBJECT_TOKEN_TYPE, "urn:ietf:params:oauth:token-type:jwt"); - runner.setProperty(serviceImpl, WORKLOAD_IDENTITY_SUBJECT_TOKEN_PROVIDER, "subjectTokenProvider"); + configureWorkloadIdentityFederation(runner, serviceImpl); runner.enableControllerService(serviceImpl); runner.assertValid(serviceImpl); @@ -140,6 +154,44 @@ public void testWorkloadIdentityFederationCredentials() throws Exception { assertEquals(IdentityPoolCredentials.class, credentials.getClass()); } + @Test + public void testWorkloadIdentityFederationImpersonationCredentials() throws Exception { + final TestRunner runner = TestRunners.newTestRunner(MockCredentialsServiceProcessor.class); + final GCPCredentialsControllerService serviceImpl = new GCPCredentialsControllerService(); + runner.addControllerService("gcpCredentialsProvider", serviceImpl); + + final MockOAuth2AccessTokenProvider subjectTokenProvider = new MockOAuth2AccessTokenProvider(); + runner.addControllerService("subjectTokenProvider", subjectTokenProvider); + runner.enableControllerService(subjectTokenProvider); + + configureWorkloadIdentityFederation(runner, serviceImpl); + runner.setProperty(serviceImpl, TARGET_SERVICE_ACCOUNT, TARGET_SERVICE_ACCOUNT_VALUE); + + runner.enableControllerService(serviceImpl); + runner.assertValid(serviceImpl); + + final GCPCredentialsService service = (GCPCredentialsService) runner.getProcessContext() + .getControllerServiceLookup().getControllerService("gcpCredentialsProvider"); + + assertNotNull(service); + final GoogleCredentials credentials = service.getGoogleCredentials(); + assertNotNull(credentials); + final ImpersonatedCredentials impersonatedCredentials = assertInstanceOf(ImpersonatedCredentials.class, credentials); + assertEquals(TARGET_SERVICE_ACCOUNT_VALUE, impersonatedCredentials.getAccount()); + assertInstanceOf(IdentityPoolCredentials.class, impersonatedCredentials.getSourceCredentials()); + assertTrue(impersonatedCredentials.createScopedRequired()); + + final GoogleCredentials scopedCredentials = credentials.createScoped(List.of("https://www.googleapis.com/auth/sqlservice.login")); + + final ImpersonatedCredentials scopedImpersonatedCredentials = assertInstanceOf(ImpersonatedCredentials.class, scopedCredentials); + assertEquals(TARGET_SERVICE_ACCOUNT_VALUE, scopedImpersonatedCredentials.getAccount()); + assertEquals(impersonatedCredentials.getSourceCredentials(), scopedImpersonatedCredentials.getSourceCredentials()); + assertEquals(List.of("https://www.googleapis.com/auth/sqlservice.login"), scopedImpersonatedCredentials.toBuilder().getScopes()); + assertFalse(scopedImpersonatedCredentials.createScopedRequired()); + assertNotNull(impersonatedCredentials.toBuilder().getHttpTransportFactory()); + assertEquals(impersonatedCredentials.toBuilder().getHttpTransportFactory(), scopedImpersonatedCredentials.toBuilder().getHttpTransportFactory()); + } + @Test public void testBadFileCredentials() throws Exception { final TestRunner runner = TestRunners.newTestRunner(MockCredentialsServiceProcessor.class); @@ -166,6 +218,69 @@ public void testMultipleCredentialSourcesRemainValid() throws Exception { runner.assertValid(serviceImpl); } + @Test + public void testComputeEngineCredentials() throws Exception { + final TestRunner runner = TestRunners.newTestRunner(MockCredentialsServiceProcessor.class); + final GCPCredentialsControllerService serviceImpl = new GCPCredentialsControllerService(); + runner.addControllerService("gcpCredentialsProvider", serviceImpl); + + runner.setProperty(serviceImpl, AUTHENTICATION_STRATEGY, AuthenticationStrategy.COMPUTE_ENGINE.getValue()); + runner.enableControllerService(serviceImpl); + + runner.assertValid(serviceImpl); + + final GCPCredentialsService service = (GCPCredentialsService) runner.getProcessContext() + .getControllerServiceLookup().getControllerService("gcpCredentialsProvider"); + + assertNotNull(service); + final GoogleCredentials credentials = service.getGoogleCredentials(); + assertNotNull(credentials); + + assertEquals(ComputeEngineCredentials.class, credentials.getClass(), + "Credentials class should be equal"); + } + + @Test + public void testTargetServiceAccountBlankRejected() throws Exception { + final TestRunner runner = TestRunners.newTestRunner(MockCredentialsServiceProcessor.class); + final GCPCredentialsControllerService serviceImpl = new GCPCredentialsControllerService(); + runner.addControllerService("gcpCredentialsProvider", serviceImpl); + + final MockOAuth2AccessTokenProvider subjectTokenProvider = new MockOAuth2AccessTokenProvider(); + runner.addControllerService("subjectTokenProvider", subjectTokenProvider); + runner.enableControllerService(subjectTokenProvider); + + configureWorkloadIdentityFederation(runner, serviceImpl); + runner.setProperty(serviceImpl, TARGET_SERVICE_ACCOUNT, " "); + + assertHasInvalidResult(runner.validate(serviceImpl), "must contain at least one character that is not white space"); + } + + @Test + public void testTargetServiceAccountRequiresWorkloadIdentityFederation() throws Exception { + final TestRunner runner = TestRunners.newTestRunner(MockCredentialsServiceProcessor.class); + final GCPCredentialsControllerService serviceImpl = new GCPCredentialsControllerService(); + runner.addControllerService("gcpCredentialsProvider", serviceImpl); + + runner.setProperty(serviceImpl, AUTHENTICATION_STRATEGY, AuthenticationStrategy.APPLICATION_DEFAULT.getValue()); + runner.setProperty(serviceImpl, TARGET_SERVICE_ACCOUNT, TARGET_SERVICE_ACCOUNT_VALUE); + + assertHasInvalidResult(runner.validate(serviceImpl), "Target Service Account requires Workload Identity Federation"); + } + + @Test + public void testAdditionalDetailsDocumentTargetServiceAccountVerificationSemantics() throws IOException { + final String resourcePath = "docs/%s/additionalDetails.md".formatted(GCPCredentialsControllerService.class.getName()); + try (InputStream inputStream = GCPCredentialsControllerService.class.getClassLoader().getResourceAsStream(resourcePath)) { + assertNotNull(inputStream); + final String additionalDetails = new String(inputStream.readAllBytes(), StandardCharsets.UTF_8); + assertTrue(additionalDetails.contains("Target Service Account")); + assertTrue(additionalDetails.contains("roles/iam.workloadIdentityUser")); + assertTrue(additionalDetails.contains("Successful verification confirms only that NiFi can")); + assertTrue(additionalDetails.contains("Verify does not perform STS exchange")); + } + } + @Test public void testRawJsonCredentials() throws Exception { final String jsonRead = new String( @@ -194,6 +309,28 @@ public void testRawJsonCredentials() throws Exception { "Credentials class should be equal"); } + private static void configureWorkloadIdentityFederation(final TestRunner runner, final GCPCredentialsControllerService serviceImpl) { + runner.setProperty(serviceImpl, AUTHENTICATION_STRATEGY, AuthenticationStrategy.WORKLOAD_IDENTITY_FEDERATION.getValue()); + runner.setProperty(serviceImpl, WORKLOAD_IDENTITY_AUDIENCE, WORKLOAD_IDENTITY_AUDIENCE_VALUE); + runner.setProperty(serviceImpl, WORKLOAD_IDENTITY_SCOPE, WORKLOAD_IDENTITY_SCOPE_VALUE); + runner.setProperty(serviceImpl, WORKLOAD_IDENTITY_TOKEN_ENDPOINT, WORKLOAD_IDENTITY_TOKEN_ENDPOINT_VALUE); + runner.setProperty(serviceImpl, WORKLOAD_IDENTITY_SUBJECT_TOKEN_TYPE, WORKLOAD_IDENTITY_SUBJECT_TOKEN_TYPE_VALUE); + runner.setProperty(serviceImpl, WORKLOAD_IDENTITY_SUBJECT_TOKEN_PROVIDER, "subjectTokenProvider"); + } + + private static void assertHasInvalidResult(final Collection validationResults, final String explanationFragment) { + final List explanations = new ArrayList<>(validationResults.size()); + for (final ValidationResult validationResult : validationResults) { + if (!validationResult.isValid()) { + explanations.add(validationResult.getExplanation()); + } + } + + final boolean explanationFound = explanations.stream() + .anyMatch(explanation -> explanation != null && explanation.contains(explanationFragment)); + assertTrue(explanationFound, () -> "Expected invalid result containing [%s] but found %s".formatted(explanationFragment, explanations)); + } + private static final class MockOAuth2AccessTokenProvider extends AbstractControllerService implements OAuth2AccessTokenProvider { private static final String ACCESS_TOKEN_VALUE = "federated-access-token"; private static final long EXPIRES_IN_SECONDS = 3600; From 195d17a6c989555bff0c8a751c33c535d2ddd607 Mon Sep 17 00:00:00 2001 From: Pierre Villard Date: Tue, 1 Sep 2026 00:13:04 +0200 Subject: [PATCH 2/4] review --- .../gcp/cloudsql/CloudSqlDatabaseType.java | 47 - ...cpCloudSqlIamDatabasePasswordProvider.java | 472 +------ .../additionalDetails.md | 49 +- ...oudSqlIamDatabasePasswordProviderTest.java | 1105 ++--------------- 4 files changed, 172 insertions(+), 1501 deletions(-) delete mode 100644 nifi-extension-bundles/nifi-gcp-bundle/nifi-gcp-processors/src/main/java/org/apache/nifi/processors/gcp/cloudsql/CloudSqlDatabaseType.java diff --git a/nifi-extension-bundles/nifi-gcp-bundle/nifi-gcp-processors/src/main/java/org/apache/nifi/processors/gcp/cloudsql/CloudSqlDatabaseType.java b/nifi-extension-bundles/nifi-gcp-bundle/nifi-gcp-processors/src/main/java/org/apache/nifi/processors/gcp/cloudsql/CloudSqlDatabaseType.java deleted file mode 100644 index 5ed71b15706f..000000000000 --- a/nifi-extension-bundles/nifi-gcp-bundle/nifi-gcp-processors/src/main/java/org/apache/nifi/processors/gcp/cloudsql/CloudSqlDatabaseType.java +++ /dev/null @@ -1,47 +0,0 @@ -/* - * 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.nifi.processors.gcp.cloudsql; - -import org.apache.nifi.components.DescribedValue; - -public enum CloudSqlDatabaseType implements DescribedValue { - POSTGRESQL("PostgreSQL", "Use Cloud SQL IAM database authentication for PostgreSQL."), - MYSQL("MySQL", "Use Cloud SQL IAM database authentication for MySQL over a direct JDBC/TLS connection."); - - private final String displayName; - private final String description; - - CloudSqlDatabaseType(final String displayName, final String description) { - this.displayName = displayName; - this.description = description; - } - - @Override - public String getValue() { - return name(); - } - - @Override - public String getDisplayName() { - return displayName; - } - - @Override - public String getDescription() { - return description; - } -} diff --git a/nifi-extension-bundles/nifi-gcp-bundle/nifi-gcp-processors/src/main/java/org/apache/nifi/processors/gcp/cloudsql/GcpCloudSqlIamDatabasePasswordProvider.java b/nifi-extension-bundles/nifi-gcp-bundle/nifi-gcp-processors/src/main/java/org/apache/nifi/processors/gcp/cloudsql/GcpCloudSqlIamDatabasePasswordProvider.java index e5d7b8fe306a..857337456c39 100644 --- a/nifi-extension-bundles/nifi-gcp-bundle/nifi-gcp-processors/src/main/java/org/apache/nifi/processors/gcp/cloudsql/GcpCloudSqlIamDatabasePasswordProvider.java +++ b/nifi-extension-bundles/nifi-gcp-bundle/nifi-gcp-processors/src/main/java/org/apache/nifi/processors/gcp/cloudsql/GcpCloudSqlIamDatabasePasswordProvider.java @@ -28,7 +28,6 @@ import org.apache.nifi.components.ConfigVerificationResult; import org.apache.nifi.components.ConfigVerificationResult.Outcome; import org.apache.nifi.components.PropertyDescriptor; -import org.apache.nifi.components.PropertyValue; import org.apache.nifi.controller.AbstractControllerService; import org.apache.nifi.controller.ConfigurationContext; import org.apache.nifi.controller.VerifiableControllerService; @@ -40,17 +39,10 @@ import org.apache.nifi.reporting.InitializationException; import java.io.IOException; -import java.net.URI; -import java.net.URLDecoder; -import java.nio.charset.StandardCharsets; import java.util.ArrayList; import java.util.List; -import java.util.Locale; import java.util.Map; import java.util.Objects; -import java.util.Optional; -import java.util.Set; -import java.util.function.Consumer; @Tags({"gcp", "cloud sql", "postgresql", "mysql", "iam", "jdbc", "password"}) @CapabilityDescription(""" @@ -62,54 +54,13 @@ public class GcpCloudSqlIamDatabasePasswordProvider extends AbstractControllerSe static final String SQLSERVICE_LOGIN_SCOPE = "https://www.googleapis.com/auth/sqlservice.login"; static final String FAILED_PASSWORD_MESSAGE = "Failed to generate Cloud SQL IAM database password"; - static final String POSTGRESQL_SSLMODE_PROPERTY = "sslmode"; - static final String MALFORMED_SSLMODE_MESSAGE = "PostgreSQL sslmode in JDBC URL is malformed for Cloud SQL IAM authentication"; - static final String VERIFY_DATABASE_TYPE_STEP = "Resolve Database Type"; - static final String VERIFY_SCOPE_STEP = "Resolve Cloud SQL scoped credentials"; + static final String VERIFY_SCOPE_STEP = "Resolve GCP credentials"; static final String VERIFY_TOKEN_STEP = "Acquire Cloud SQL IAM access token"; - static final String VERIFY_DATABASE_TYPE_UNSUPPORTED = "Configured Database Type is not supported for Cloud SQL IAM authentication."; static final String VERIFY_CREDENTIALS_UNAVAILABLE = "Configured GCP Credentials Provider Service did not return Google credentials."; - static final String VERIFY_SCOPED_CREDENTIALS_UNAVAILABLE = "Failed to create Cloud SQL scoped credentials from the configured provider."; + static final String VERIFY_SCOPED_CREDENTIALS_UNAVAILABLE = "Failed to apply the Cloud SQL login scope to the configured Google credentials."; static final String VERIFY_IMPERSONATION_REQUIRED = "Target service account impersonation is required for Workload Identity Federation Cloud SQL authentication."; - static final String VERIFY_TOKEN_ACQUISITION_FAILED = "Failed to acquire a Cloud SQL IAM access token from the scoped credential."; - static final String VERIFY_TOKEN_MISSING = "Scoped credential refresh did not return a non-empty Cloud SQL IAM access token."; - static final String MYSQL_DRIVER_CLASS_NAME = "com.mysql.cj.jdbc.Driver"; - static final String MYSQL_JDBC_URL_PREFIX = "jdbc:mysql://"; - static final String MYSQL_SSL_MODE_PROPERTY = "sslMode"; - static final String MYSQL_USER_PROPERTY = "user"; - static final String MYSQL_PASSWORD_PROPERTY = "password"; - static final String MYSQL_DISABLED_AUTHENTICATION_PLUGINS_PROPERTY = "disabledAuthenticationPlugins"; - static final String MYSQL_USE_SSL_PROPERTY = "useSSL"; - static final String MYSQL_REQUIRE_SSL_PROPERTY = "requireSSL"; - static final String MYSQL_VERIFY_SERVER_CERTIFICATE_PROPERTY = "verifyServerCertificate"; - static final String MALFORMED_MYSQL_JDBC_URL_MESSAGE = "MySQL JDBC URL properties are malformed for Cloud SQL IAM authentication"; - static final String MYSQL_JDBC_URL_REQUIRED_MESSAGE = "MySQL JDBC URL must use the standard single-host jdbc:mysql:// format for Cloud SQL IAM authentication"; - static final String MYSQL_DRIVER_CLASS_REQUIRED_MESSAGE = "MySQL driver class must be configured as com.mysql.cj.jdbc.Driver for Cloud SQL IAM authentication"; - static final String MYSQL_SSL_MODE_REQUIRED_MESSAGE = "MySQL sslMode must be configured as REQUIRED, VERIFY_CA, or VERIFY_IDENTITY for Cloud SQL IAM authentication"; - static final String MYSQL_URL_CREDENTIALS_UNSUPPORTED_MESSAGE = "MySQL JDBC URL must not define user or password for Cloud SQL IAM authentication"; - static final String MYSQL_CONNECTION_PROPERTIES_USER_UNSUPPORTED_MESSAGE = "MySQL DBCP connection properties must not define user for Cloud SQL IAM authentication"; - static final String MYSQL_DISABLED_CLEAR_PASSWORD_UNSUPPORTED_MESSAGE = - "MySQL disabledAuthenticationPlugins must not disable the clear-password authentication plugin required for Cloud SQL IAM authentication"; - static final String MYSQL_LEGACY_TLS_PROPERTIES_UNSUPPORTED_MESSAGE = - "MySQL legacy TLS properties useSSL, requireSSL, and verifyServerCertificate are not supported for Cloud SQL IAM authentication"; - private static final List SAFE_GOOGLE_AUTH_IO_MESSAGES = List.of( - "Unable to refresh sourceCredentials", - "Error requesting access token", - "Unexpected error refreshing access token", - "Error parsing expireTime:" - ); - - private static final Set ACCEPTED_POSTGRESQL_SSL_MODES = Set.of("prefer", "require", "verify-ca", "verify-full"); - private static final Set ACCEPTED_MYSQL_SSL_MODES = Set.of("REQUIRED", "VERIFY_CA", "VERIFY_IDENTITY"); - private static final Set DISABLED_MYSQL_CLEAR_PASSWORD_PLUGIN_NAMES = Set.of( - "mysql_clear_password", - "com.mysql.cj.protocol.a.authentication.mysqlclearpasswordplugin" - ); - private static final Set LEGACY_MYSQL_TLS_PROPERTIES = Set.of( - MYSQL_USE_SSL_PROPERTY, - MYSQL_REQUIRE_SSL_PROPERTY, - MYSQL_VERIFY_SERVER_CERTIFICATE_PROPERTY - ); + static final String VERIFY_TOKEN_ACQUISITION_FAILED = "Failed to acquire a Cloud SQL IAM access token."; + static final String VERIFY_TOKEN_MISSING = "Cloud SQL IAM access token was empty."; static final PropertyDescriptor GCP_CREDENTIALS_PROVIDER_SERVICE = new PropertyDescriptor.Builder() .name("GCP Credentials Provider Service") @@ -118,21 +69,11 @@ public class GcpCloudSqlIamDatabasePasswordProvider extends AbstractControllerSe .required(true) .build(); - static final PropertyDescriptor DATABASE_TYPE = new PropertyDescriptor.Builder() - .name("Database Type") - .description("Cloud SQL database engine to authenticate. PostgreSQL and MySQL are supported.") - .required(true) - .allowableValues(CloudSqlDatabaseType.class) - .defaultValue(CloudSqlDatabaseType.POSTGRESQL) - .build(); - private static final List PROPERTY_DESCRIPTORS = List.of( - GCP_CREDENTIALS_PROVIDER_SERVICE, - DATABASE_TYPE + GCP_CREDENTIALS_PROVIDER_SERVICE ); private volatile GoogleCredentials scopedCredentials; - private volatile CloudSqlDatabaseType databaseType; @Override protected List getSupportedPropertyDescriptors() { @@ -141,21 +82,12 @@ protected List getSupportedPropertyDescriptors() { @OnEnabled public void onEnabled(final ConfigurationContext context) throws InitializationException { - final CloudSqlDatabaseType configuredDatabaseType = resolveEnabledDatabaseType(context); - final GoogleCredentials createdScopedCredentials = createSqlLoginScopedCredentials(resolveGoogleCredentials(context)); - if (createdScopedCredentials == null) { - throw new InitializationException(FAILED_PASSWORD_MESSAGE); - } - rejectIdentityPoolCredentialsOnEnable(createdScopedCredentials); - - databaseType = configuredDatabaseType; - scopedCredentials = createdScopedCredentials; + scopedCredentials = requireScopedCredentials(context); } @OnDisabled public void onDisabled() { scopedCredentials = null; - databaseType = null; } @Override @@ -163,12 +95,10 @@ public char[] getPassword(final DatabasePasswordRequestContext requestContext) { Objects.requireNonNull(requestContext, "Database Password Request Context required"); final GoogleCredentials credentials = scopedCredentials; - final CloudSqlDatabaseType configuredDatabaseType = databaseType; - if (credentials == null || configuredDatabaseType == null) { + if (credentials == null) { throw new ProcessException(FAILED_PASSWORD_MESSAGE); } - validateRequest(requestContext, configuredDatabaseType); rejectIdentityPoolCredentialsOnPasswordGeneration(credentials); final AccessToken accessToken = refreshAccessToken(credentials); @@ -183,14 +113,6 @@ public char[] getPassword(final DatabasePasswordRequestContext requestContext) { public List verify(final ConfigurationContext context, final ComponentLog verificationLogger, final Map attributes) { final List results = new ArrayList<>(2); - final CloudSqlDatabaseType configuredDatabaseType; - - try { - configuredDatabaseType = resolveConfiguredDatabaseType(context); - } catch (final IllegalArgumentException e) { - results.add(buildVerificationResult(VERIFY_DATABASE_TYPE_STEP, Outcome.FAILED, VERIFY_DATABASE_TYPE_UNSUPPORTED)); - return results; - } final GoogleCredentials googleCredentials; @@ -219,7 +141,7 @@ public List verify(final ConfigurationContext context, return results; } - final ConfigVerificationResult scopedCredentialResult = describeScopedCredential(scopedVerificationCredentials, configuredDatabaseType); + final ConfigVerificationResult scopedCredentialResult = describeScopedCredential(scopedVerificationCredentials); results.add(scopedCredentialResult); if (scopedCredentialResult.getOutcome() == Outcome.FAILED) { return results; @@ -238,60 +160,47 @@ public List verify(final ConfigurationContext context, return results; } - results.add(buildTokenVerificationResult(scopedVerificationCredentials, configuredDatabaseType)); + results.add(buildTokenVerificationResult()); return results; } - private CloudSqlDatabaseType resolveEnabledDatabaseType(final ConfigurationContext context) throws InitializationException { + private GoogleCredentials requireScopedCredentials(final ConfigurationContext context) throws InitializationException { + final GoogleCredentials googleCredentials; try { - return resolveConfiguredDatabaseType(context); - } catch (final IllegalArgumentException e) { - throw new InitializationException(VERIFY_DATABASE_TYPE_UNSUPPORTED, e); + googleCredentials = resolveGoogleCredentials(context); + } catch (final RuntimeException e) { + throw new InitializationException(VERIFY_CREDENTIALS_UNAVAILABLE, e); } - } - private CloudSqlDatabaseType resolveConfiguredDatabaseType(final ConfigurationContext context) { - final PropertyValue propertyValue = context.getProperty(DATABASE_TYPE); - final CloudSqlDatabaseType configuredDatabaseType = propertyValue.asAllowableValue(CloudSqlDatabaseType.class); - if (configuredDatabaseType == null) { - throw new IllegalArgumentException("Database Type must be configured"); + if (googleCredentials == null) { + throw new InitializationException(VERIFY_CREDENTIALS_UNAVAILABLE); } - return configuredDatabaseType; + final GoogleCredentials credentials; + try { + credentials = createSqlLoginScopedCredentials(googleCredentials); + } catch (final RuntimeException e) { + throw new InitializationException(VERIFY_SCOPED_CREDENTIALS_UNAVAILABLE, e); + } + + if (credentials == null) { + throw new InitializationException(VERIFY_SCOPED_CREDENTIALS_UNAVAILABLE); + } + + rejectIdentityPoolCredentialsOnEnable(credentials); + return credentials; } private AccessToken refreshAccessToken(final GoogleCredentials credentials) { try { credentials.refreshIfExpired(); - } catch (final IOException e) { - if (isSafeGoogleAuthRefreshException(e)) { - throw new ProcessException(FAILED_PASSWORD_MESSAGE, e); - } - throw new ProcessException(FAILED_PASSWORD_MESSAGE); - } catch (final RuntimeException e) { + } catch (final IOException | RuntimeException e) { throw new ProcessException(FAILED_PASSWORD_MESSAGE); } return credentials.getAccessToken(); } - private boolean isSafeGoogleAuthRefreshException(final IOException exception) { - final String message = exception.getMessage(); - final boolean knownSafeMessage = message != null && SAFE_GOOGLE_AUTH_IO_MESSAGES.stream() - .anyMatch(message::startsWith); - if (!knownSafeMessage) { - return false; - } - - for (final StackTraceElement stackTraceElement : exception.getStackTrace()) { - if (stackTraceElement.getClassName().startsWith("com.google.auth.oauth2.")) { - return true; - } - } - - return false; - } - private GoogleCredentials resolveGoogleCredentials(final ConfigurationContext context) { final GCPCredentialsService credentialsService = context.getProperty(GCP_CREDENTIALS_PROVIDER_SERVICE) .asControllerService(GCPCredentialsService.class); @@ -322,16 +231,12 @@ private void rejectIdentityPoolCredentialsOnPasswordGeneration(final GoogleCrede } } - private ConfigVerificationResult describeScopedCredential(final GoogleCredentials scopedVerificationCredentials, - final CloudSqlDatabaseType configuredDatabaseType) { + private ConfigVerificationResult describeScopedCredential(final GoogleCredentials scopedVerificationCredentials) { if (scopedVerificationCredentials instanceof ImpersonatedCredentials) { return buildVerificationResult( VERIFY_SCOPE_STEP, Outcome.SUCCESSFUL, - ("Resolved Database Type %s, resolved Google credentials from the configured provider, and created " - + "a Cloud SQL scoped ImpersonatedCredentials instance. Target service account " - + "impersonation is active.") - .formatted(configuredDatabaseType.getDisplayName()) + "Resolved GCP credentials and Cloud SQL login scope. Target service account impersonation is active." ); } @@ -339,39 +244,22 @@ private ConfigVerificationResult describeScopedCredential(final GoogleCredential return buildVerificationResult( VERIFY_SCOPE_STEP, Outcome.FAILED, - "Resolved Database Type %s, but %s" - .formatted(configuredDatabaseType.getDisplayName(), VERIFY_IMPERSONATION_REQUIRED) + VERIFY_IMPERSONATION_REQUIRED ); } return buildVerificationResult( VERIFY_SCOPE_STEP, Outcome.SUCCESSFUL, - "Resolved Database Type %s, resolved Google credentials from the configured provider, and created a Cloud SQL scoped %s instance." - .formatted(configuredDatabaseType.getDisplayName(), scopedVerificationCredentials.getClass().getSimpleName()) + "Resolved GCP credentials and Cloud SQL login scope." ); } - private ConfigVerificationResult buildTokenVerificationResult(final GoogleCredentials scopedVerificationCredentials, - final CloudSqlDatabaseType configuredDatabaseType) { - if (scopedVerificationCredentials instanceof ImpersonatedCredentials) { - return buildVerificationResult( - VERIFY_TOKEN_STEP, - Outcome.SUCCESSFUL, - ("Acquired a non-empty Cloud SQL IAM access token for %s from the scoped credential. This verifies live " - + "subject token exchange, Google STS, and target service account impersonation, but does " - + "not connect to the selected database. Use DBCP Verify for the end-to-end database check.") - .formatted(configuredDatabaseType.getDisplayName()) - ); - } - + private ConfigVerificationResult buildTokenVerificationResult() { return buildVerificationResult( VERIFY_TOKEN_STEP, Outcome.SUCCESSFUL, - ("Acquired a non-empty Cloud SQL IAM access token for %s from the scoped credential. This verifies live " - + "Cloud SQL IAM token acquisition for the current principal, but does not connect to the selected database. Use " - + "DBCP Verify for the end-to-end database check.") - .formatted(configuredDatabaseType.getDisplayName()) + "Acquired a Cloud SQL IAM access token. Use DBCP Verify to validate the database connection." ); } @@ -386,292 +274,4 @@ private ConfigVerificationResult buildVerificationResult(final String stepName, .explanation(explanation) .build(); } - - private void validateRequest(final DatabasePasswordRequestContext requestContext, final CloudSqlDatabaseType configuredDatabaseType) { - final Consumer validator = switch (configuredDatabaseType) { - case POSTGRESQL -> this::validatePostgresqlRequest; - case MYSQL -> this::validateMySqlRequest; - }; - validator.accept(requestContext); - } - - private void validatePostgresqlRequest(final DatabasePasswordRequestContext requestContext) { - validatePostgresqlDatabaseUser(requestContext.getDatabaseUser()); - validatePostgresqlSslMode(requestContext); - } - - private void validatePostgresqlDatabaseUser(final String databaseUser) { - if (StringUtils.isBlank(databaseUser)) { - throw new ProcessException("Database Username must be configured for Cloud SQL IAM authentication"); - } - } - - private void validatePostgresqlSslMode(final DatabasePasswordRequestContext requestContext) { - final String sslMode = resolvePostgresqlSslMode(requestContext); - if (sslMode == null) { - throw new ProcessException("PostgreSQL sslmode must be configured for Cloud SQL IAM authentication"); - } - - if (!ACCEPTED_POSTGRESQL_SSL_MODES.contains(sslMode)) { - throw new ProcessException("PostgreSQL sslmode [%s] is not supported for Cloud SQL IAM authentication".formatted(sslMode)); - } - } - - private String resolvePostgresqlSslMode(final DatabasePasswordRequestContext requestContext) { - final Optional jdbcUrlSslMode = findJdbcUrlPropertyIgnoreCase(requestContext.getJdbcUrl(), POSTGRESQL_SSLMODE_PROPERTY, MALFORMED_SSLMODE_MESSAGE); - if (jdbcUrlSslMode.isPresent()) { - return normalizePostgresqlSslMode(jdbcUrlSslMode.get()); - } - - final String propertySslMode = findConnectionPropertyIgnoreCase(requestContext.getConnectionProperties(), POSTGRESQL_SSLMODE_PROPERTY).orElse(null); - return normalizePostgresqlSslMode(propertySslMode); - } - - private void validateMySqlRequest(final DatabasePasswordRequestContext requestContext) { - validateMySqlDatabaseUser(requestContext.getDatabaseUser()); - validateMySqlDriverClassName(requestContext.getDriverClassName()); - validateMySqlJdbcUrl(requestContext.getJdbcUrl()); - validateMySqlUrlCredentials(requestContext.getJdbcUrl()); - validateMySqlConnectionPropertyUser(requestContext.getConnectionProperties()); - validateMySqlLegacyTlsProperties(requestContext); - validateMySqlSslMode(requestContext); - validateMySqlAuthenticationPlugins(requestContext); - } - - private void validateMySqlDatabaseUser(final String databaseUser) { - if (StringUtils.isBlank(databaseUser)) { - throw new ProcessException("Database Username must be configured for Cloud SQL IAM authentication"); - } - } - - private void validateMySqlDriverClassName(final String driverClassName) { - if (!MYSQL_DRIVER_CLASS_NAME.equals(StringUtils.trimToEmpty(driverClassName))) { - throw new ProcessException(MYSQL_DRIVER_CLASS_REQUIRED_MESSAGE); - } - } - - private void validateMySqlJdbcUrl(final String jdbcUrl) { - final String trimmedJdbcUrl = StringUtils.trimToEmpty(jdbcUrl); - if (!trimmedJdbcUrl.startsWith(MYSQL_JDBC_URL_PREFIX)) { - throw new ProcessException(MYSQL_JDBC_URL_REQUIRED_MESSAGE); - } - - validateMySqlJdbcUrlEncoding(trimmedJdbcUrl); - - final URI mysqlUri; - try { - mysqlUri = URI.create(trimmedJdbcUrl.substring("jdbc:".length())); - } catch (final IllegalArgumentException e) { - throw new ProcessException(MYSQL_JDBC_URL_REQUIRED_MESSAGE); - } - - final String rawAuthority = mysqlUri.getRawAuthority(); - if (!"mysql".equalsIgnoreCase(mysqlUri.getScheme()) - || StringUtils.isBlank(rawAuthority) - || StringUtils.isBlank(mysqlUri.getHost()) - || rawAuthority.contains(",")) { - throw new ProcessException(MYSQL_JDBC_URL_REQUIRED_MESSAGE); - } - - if (mysqlUri.getRawUserInfo() != null) { - throw new ProcessException(MYSQL_URL_CREDENTIALS_UNSUPPORTED_MESSAGE); - } - } - - private void validateMySqlJdbcUrlEncoding(final String jdbcUrl) { - final int queryStart = jdbcUrl.indexOf('?'); - if (queryStart >= 0 && queryStart < jdbcUrl.length() - 1) { - final String query = jdbcUrl.substring(queryStart + 1); - for (final String parameter : query.split("&")) { - final int delimiterIndex = parameter.indexOf('='); - final String rawName = delimiterIndex >= 0 ? parameter.substring(0, delimiterIndex) : parameter; - final String rawValue = delimiterIndex >= 0 ? parameter.substring(delimiterIndex + 1) : ""; - urlDecode(rawName, MALFORMED_MYSQL_JDBC_URL_MESSAGE); - urlDecode(rawValue, MALFORMED_MYSQL_JDBC_URL_MESSAGE); - } - } - } - - private void validateMySqlUrlCredentials(final String jdbcUrl) { - if (findJdbcUrlPropertyIgnoreCase(jdbcUrl, MYSQL_USER_PROPERTY, MALFORMED_MYSQL_JDBC_URL_MESSAGE).isPresent() - || findJdbcUrlPropertyIgnoreCase(jdbcUrl, MYSQL_PASSWORD_PROPERTY, MALFORMED_MYSQL_JDBC_URL_MESSAGE).isPresent()) { - throw new ProcessException(MYSQL_URL_CREDENTIALS_UNSUPPORTED_MESSAGE); - } - } - - private void validateMySqlConnectionPropertyUser(final Map connectionProperties) { - if (findConnectionPropertyIgnoreCase(connectionProperties, MYSQL_USER_PROPERTY).isPresent()) { - throw new ProcessException(MYSQL_CONNECTION_PROPERTIES_USER_UNSUPPORTED_MESSAGE); - } - } - - private void validateMySqlLegacyTlsProperties(final DatabasePasswordRequestContext requestContext) { - for (final String propertyName : LEGACY_MYSQL_TLS_PROPERTIES) { - if (findJdbcUrlPropertyIgnoreCase(requestContext.getJdbcUrl(), propertyName, MALFORMED_MYSQL_JDBC_URL_MESSAGE).isPresent() - || findConnectionPropertyIgnoreCase(requestContext.getConnectionProperties(), propertyName).isPresent()) { - throw new ProcessException(MYSQL_LEGACY_TLS_PROPERTIES_UNSUPPORTED_MESSAGE); - } - } - } - - private void validateMySqlSslMode(final DatabasePasswordRequestContext requestContext) { - if (hasNonCanonicalConnectionProperty(requestContext.getConnectionProperties(), MYSQL_SSL_MODE_PROPERTY) - || hasNonCanonicalJdbcUrlProperty(requestContext.getJdbcUrl(), MYSQL_SSL_MODE_PROPERTY, MALFORMED_MYSQL_JDBC_URL_MESSAGE)) { - throw new ProcessException(MYSQL_SSL_MODE_REQUIRED_MESSAGE); - } - - final String sslMode = resolveExactMySqlSslMode(requestContext) - .map(this::normalizeMySqlSslMode) - .orElse(null); - if (sslMode == null || !ACCEPTED_MYSQL_SSL_MODES.contains(sslMode)) { - throw new ProcessException(MYSQL_SSL_MODE_REQUIRED_MESSAGE); - } - } - - private void validateMySqlAuthenticationPlugins(final DatabasePasswordRequestContext requestContext) { - validateMySqlDisabledAuthenticationPlugins( - findConnectionPropertyIgnoreCase(requestContext.getConnectionProperties(), MYSQL_DISABLED_AUTHENTICATION_PLUGINS_PROPERTY) - ); - validateMySqlDisabledAuthenticationPlugins( - findJdbcUrlPropertyIgnoreCase(requestContext.getJdbcUrl(), MYSQL_DISABLED_AUTHENTICATION_PLUGINS_PROPERTY, MALFORMED_MYSQL_JDBC_URL_MESSAGE) - ); - } - - private void validateMySqlDisabledAuthenticationPlugins(final Optional disabledAuthenticationPlugins) { - if (disabledAuthenticationPlugins.isEmpty()) { - return; - } - - for (final String disabledAuthenticationPlugin : disabledAuthenticationPlugins.get().split(",")) { - final String normalizedPlugin = StringUtils.trimToEmpty(disabledAuthenticationPlugin).toLowerCase(Locale.ROOT); - if (DISABLED_MYSQL_CLEAR_PASSWORD_PLUGIN_NAMES.contains(normalizedPlugin)) { - throw new ProcessException(MYSQL_DISABLED_CLEAR_PASSWORD_UNSUPPORTED_MESSAGE); - } - } - } - - private Optional resolveExactMySqlSslMode(final DatabasePasswordRequestContext requestContext) { - final Optional connectionProperty = findConnectionPropertyExact(requestContext.getConnectionProperties(), MYSQL_SSL_MODE_PROPERTY); - if (connectionProperty.isPresent()) { - return connectionProperty; - } - - final Optional jdbcUrlProperty = findJdbcUrlPropertyExact(requestContext.getJdbcUrl(), MYSQL_SSL_MODE_PROPERTY, MALFORMED_MYSQL_JDBC_URL_MESSAGE); - if (jdbcUrlProperty.isPresent()) { - return jdbcUrlProperty; - } - - return Optional.empty(); - } - - private Optional findJdbcUrlPropertyExact(final String jdbcUrl, final String propertyName, final String malformedPropertyMessage) { - return findJdbcUrlProperty(jdbcUrl, propertyName, malformedPropertyMessage, true); - } - - private Optional findJdbcUrlPropertyIgnoreCase(final String jdbcUrl, final String propertyName, final String malformedPropertyMessage) { - return findJdbcUrlProperty(jdbcUrl, propertyName, malformedPropertyMessage, false); - } - - private Optional findJdbcUrlProperty(final String jdbcUrl, final String propertyName, final String malformedPropertyMessage, - final boolean exactMatch) { - if (StringUtils.isBlank(jdbcUrl)) { - return Optional.empty(); - } - - final String normalizedJdbcUrl = jdbcUrl.startsWith("jdbc:") ? jdbcUrl.substring(5) : jdbcUrl; - final int queryStart = normalizedJdbcUrl.indexOf('?'); - if (queryStart < 0 || queryStart == normalizedJdbcUrl.length() - 1) { - return Optional.empty(); - } - - final String query = normalizedJdbcUrl.substring(queryStart + 1); - String lastValue = null; - for (final String parameter : query.split("&")) { - if (parameter.isEmpty()) { - continue; - } - - final int delimiterIndex = parameter.indexOf('='); - final String decodedName = urlDecode(delimiterIndex >= 0 ? parameter.substring(0, delimiterIndex) : parameter, malformedPropertyMessage); - if (!propertyNamesMatch(propertyName, decodedName, exactMatch)) { - continue; - } - - final String rawValue = delimiterIndex >= 0 ? parameter.substring(delimiterIndex + 1) : ""; - lastValue = urlDecode(rawValue, malformedPropertyMessage); - } - - return Optional.ofNullable(lastValue); - } - - private Optional findConnectionPropertyExact(final Map connectionProperties, final String propertyName) { - return Optional.ofNullable(connectionProperties.get(propertyName)); - } - - private Optional findConnectionPropertyIgnoreCase(final Map connectionProperties, final String propertyName) { - for (final Map.Entry entry : connectionProperties.entrySet()) { - if (propertyName.equalsIgnoreCase(entry.getKey())) { - return Optional.ofNullable(entry.getValue()); - } - } - - return Optional.empty(); - } - - private boolean hasNonCanonicalJdbcUrlProperty(final String jdbcUrl, final String propertyName, final String malformedPropertyMessage) { - if (StringUtils.isBlank(jdbcUrl)) { - return false; - } - - final String normalizedJdbcUrl = jdbcUrl.startsWith("jdbc:") ? jdbcUrl.substring(5) : jdbcUrl; - final int queryStart = normalizedJdbcUrl.indexOf('?'); - if (queryStart < 0 || queryStart == normalizedJdbcUrl.length() - 1) { - return false; - } - - final String query = normalizedJdbcUrl.substring(queryStart + 1); - for (final String parameter : query.split("&")) { - if (parameter.isEmpty()) { - continue; - } - - final int delimiterIndex = parameter.indexOf('='); - final String decodedName = urlDecode(delimiterIndex >= 0 ? parameter.substring(0, delimiterIndex) : parameter, malformedPropertyMessage); - if (propertyName.equalsIgnoreCase(decodedName) && !propertyName.equals(decodedName)) { - return true; - } - } - - return false; - } - - private boolean hasNonCanonicalConnectionProperty(final Map connectionProperties, final String propertyName) { - for (final String configuredPropertyName : connectionProperties.keySet()) { - if (propertyName.equalsIgnoreCase(configuredPropertyName) && !propertyName.equals(configuredPropertyName)) { - return true; - } - } - - return false; - } - - private boolean propertyNamesMatch(final String expectedPropertyName, final String configuredPropertyName, final boolean exactMatch) { - return exactMatch ? expectedPropertyName.equals(configuredPropertyName) : expectedPropertyName.equalsIgnoreCase(configuredPropertyName); - } - - private String normalizePostgresqlSslMode(final String sslMode) { - return sslMode == null ? null : StringUtils.trimToEmpty(sslMode).toLowerCase(Locale.ROOT); - } - - private String normalizeMySqlSslMode(final String sslMode) { - return sslMode == null ? null : StringUtils.trimToEmpty(sslMode).toUpperCase(Locale.ROOT); - } - - private String urlDecode(final String value, final String malformedPropertyMessage) { - try { - return URLDecoder.decode(value, StandardCharsets.UTF_8); - } catch (final IllegalArgumentException e) { - throw new ProcessException(malformedPropertyMessage); - } - } } diff --git a/nifi-extension-bundles/nifi-gcp-bundle/nifi-gcp-processors/src/main/resources/docs/org.apache.nifi.processors.gcp.cloudsql.GcpCloudSqlIamDatabasePasswordProvider/additionalDetails.md b/nifi-extension-bundles/nifi-gcp-bundle/nifi-gcp-processors/src/main/resources/docs/org.apache.nifi.processors.gcp.cloudsql.GcpCloudSqlIamDatabasePasswordProvider/additionalDetails.md index ab9028ebb50a..713c01abe1ca 100644 --- a/nifi-extension-bundles/nifi-gcp-bundle/nifi-gcp-processors/src/main/resources/docs/org.apache.nifi.processors.gcp.cloudsql.GcpCloudSqlIamDatabasePasswordProvider/additionalDetails.md +++ b/nifi-extension-bundles/nifi-gcp-bundle/nifi-gcp-processors/src/main/resources/docs/org.apache.nifi.processors.gcp.cloudsql.GcpCloudSqlIamDatabasePasswordProvider/additionalDetails.md @@ -19,18 +19,17 @@ database password for a DBCP service. Use it when you want NiFi to connect to Cloud SQL without storing a long-lived database password in NiFi. -The provider supports Cloud SQL for PostgreSQL and Cloud SQL for MySQL over direct JDBC connections. When a DBCP service -references this provider, the static DBCP password property is ignored. +The provider works with Cloud SQL for PostgreSQL and Cloud SQL for MySQL. When a DBCP service references this provider, +the static DBCP password property is ignored. ## Usage 1. Configure `GCPCredentialsControllerService` so NiFi can obtain Google credentials. 2. Create and enable `GcpCloudSqlIamDatabasePasswordProvider`. 3. Set **GCP Credentials Provider Service** to the credentials service. -4. Set **Database Type** to match the target engine: `POSTGRESQL` or `MYSQL`. -5. Configure the DBCP service with the JDBC URL, driver, database user, and **Database Password Provider** set to +4. Configure the DBCP service with the JDBC URL, driver, database user, and **Database Password Provider** set to `GcpCloudSqlIamDatabasePasswordProvider`. -6. Run **Verify** on the provider, then run **Verify** on the DBCP service. +5. Run **Verify** on the provider, then run **Verify** on the DBCP service. Create the Cloud SQL IAM database user separately and grant the database privileges required by your application. @@ -39,19 +38,18 @@ Create the Cloud SQL IAM database user separately and grant the database privile For Workload Identity Federation, `GCPCredentialsControllerService` must be configured with **Target Service Account**. The workload identity principal must also have `roles/iam.workloadIdentityUser` on that target service account. -Without both of these settings, token acquisition for Cloud SQL IAM authentication will not succeed. +Without target service account impersonation, token acquisition for Cloud SQL IAM authentication will not succeed. ## PostgreSQL Configuration -Cloud SQL for PostgreSQL expects the DBCP **Database User** to match the IAM identity used for login. +Cloud SQL for PostgreSQL expects the DBCP **Database User** to match the IAM identity used for login. Configure the +DBCP service with a PostgreSQL JDBC driver and PostgreSQL JDBC URL. Configure TLS in the DBCP service as required for +your environment. | Setting | Value | |---|---| -| Database Type | `POSTGRESQL` | -| Driver | PostgreSQL JDBC driver | +| Driver Class Name | `org.postgresql.Driver` | | JDBC URL | `jdbc:postgresql://:5432/?sslmode=require` | -| Accepted `sslmode` values | `prefer`, `require`, `verify-ca`, `verify-full` | -| Rejected `sslmode` values | missing, `disable`, `allow` | | Database User for Google user | full email address | | Database User for service account | service-account email without `.gserviceaccount.com` | @@ -63,17 +61,14 @@ Example service-account mapping: ## MySQL Configuration Cloud SQL for MySQL uses the full service-account email when the IAM database user is created, but the JDBC login name -must be only the portion before `@`. +must be only the portion before `@`. Configure the DBCP service with a compatible MySQL Connector/J driver and MySQL +JDBC URL. Configure TLS in the DBCP service as required for your environment. | Setting | Value | |---|---| -| Database Type | `MYSQL` | | Driver Class Name | `com.mysql.cj.jdbc.Driver` | | Driver Location(s) | compatible MySQL Connector/J driver jar provided to the DBCP service | | JDBC URL | `jdbc:mysql://:3306/?sslMode=REQUIRED` | -| Accepted `sslMode` values | `REQUIRED`, `VERIFY_CA`, `VERIFY_IDENTITY` | -| Rejected `sslMode` values | missing, `DISABLED`, `PREFERRED` | -| Required property spelling | `sslMode` | | Database User | service-account identifier before `@` | Example service-account mapping: @@ -81,10 +76,6 @@ Example service-account mapping: - IAM database user created in Cloud SQL: `nifi-sa@my-project.iam.gserviceaccount.com` - DBCP **Database User**: `nifi-sa` -For MySQL, install or provide a compatible MySQL Connector/J driver. Do not set `user` or `password` in JDBC URL -parameters, and do not set `user` in DBCP connection properties. Do not disable the MySQL clear-password authentication -plugin required by Cloud SQL IAM authentication. - ## Verify and Troubleshooting `GcpCloudSqlIamDatabasePasswordProvider` **Verify** checks that NiFi can obtain a token. DBCP **Verify** checks the @@ -96,23 +87,5 @@ If provider **Verify** fails: - For Workload Identity Federation, confirm **Target Service Account** is set and the workload identity principal has `roles/iam.workloadIdentityUser` on that service account. -If PostgreSQL authentication fails: - -- Confirm the DBCP **Database User** matches the expected PostgreSQL IAM username form. -- Confirm the JDBC configuration provides an accepted `sslmode` value. - -If MySQL authentication fails: - -- Confirm the IAM database user was created with the full service-account email. -- Confirm the DBCP **Database User** uses only the portion before `@`. -- Confirm the driver class is `com.mysql.cj.jdbc.Driver` and the JDBC URL uses `jdbc:mysql://` with a single host. - -If MySQL configuration is rejected before connection: - -- Confirm `sslMode` is present and spelled exactly `sslMode`. -- Remove conflicting TLS properties such as `useSSL`, `requireSSL`, or `verifyServerCertificate`. -- Remove `user` or `password` from the JDBC URL, and remove `user` from DBCP connection properties. -- Confirm `disabledAuthenticationPlugins` does not disable the required clear-password plugin. - If provider **Verify** succeeds but DBCP **Verify** fails, token acquisition is working and the problem is in the JDBC connection configuration, network path, TLS settings, driver setup, database user, or database privileges. \ No newline at end of file diff --git a/nifi-extension-bundles/nifi-gcp-bundle/nifi-gcp-processors/src/test/java/org/apache/nifi/processors/gcp/cloudsql/GcpCloudSqlIamDatabasePasswordProviderTest.java b/nifi-extension-bundles/nifi-gcp-bundle/nifi-gcp-processors/src/test/java/org/apache/nifi/processors/gcp/cloudsql/GcpCloudSqlIamDatabasePasswordProviderTest.java index 8f9fcf8fb727..3a8523de23a0 100644 --- a/nifi-extension-bundles/nifi-gcp-bundle/nifi-gcp-processors/src/test/java/org/apache/nifi/processors/gcp/cloudsql/GcpCloudSqlIamDatabasePasswordProviderTest.java +++ b/nifi-extension-bundles/nifi-gcp-bundle/nifi-gcp-processors/src/test/java/org/apache/nifi/processors/gcp/cloudsql/GcpCloudSqlIamDatabasePasswordProviderTest.java @@ -20,7 +20,6 @@ import com.google.auth.oauth2.GoogleCredentials; import com.google.auth.oauth2.IdentityPoolCredentials; import com.google.auth.oauth2.ImpersonatedCredentials; -import org.apache.nifi.components.AllowableValue; import org.apache.nifi.components.ConfigVerificationResult; import org.apache.nifi.components.PropertyDescriptor; import org.apache.nifi.components.PropertyValue; @@ -29,7 +28,6 @@ import org.apache.nifi.dbcp.api.DatabasePasswordProvider; import org.apache.nifi.dbcp.api.DatabasePasswordRequestContext; import org.apache.nifi.gcp.credentials.service.GCPCredentialsService; -import org.apache.nifi.logging.ComponentLog; import org.apache.nifi.processor.exception.ProcessException; import org.apache.nifi.reporting.InitializationException; import org.apache.nifi.util.LogMessage; @@ -39,15 +37,13 @@ import org.apache.nifi.util.TestRunners; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.Test; -import org.junit.jupiter.params.ParameterizedTest; -import org.junit.jupiter.params.provider.Arguments; -import org.junit.jupiter.params.provider.MethodSource; import org.mockito.Mockito; import org.slf4j.helpers.MessageFormatter; import java.io.IOException; import java.io.InputStream; import java.lang.reflect.Field; +import java.lang.reflect.Method; import java.nio.charset.StandardCharsets; import java.time.Instant; import java.util.ArrayList; @@ -61,15 +57,10 @@ import java.util.concurrent.Future; import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicInteger; -import java.util.stream.Stream; import static org.apache.nifi.components.ConfigVerificationResult.Outcome.FAILED; import static org.apache.nifi.components.ConfigVerificationResult.Outcome.SUCCESSFUL; -import static org.apache.nifi.processors.gcp.cloudsql.GcpCloudSqlIamDatabasePasswordProvider.DATABASE_TYPE; -import static org.apache.nifi.processors.gcp.cloudsql.GcpCloudSqlIamDatabasePasswordProvider.FAILED_PASSWORD_MESSAGE; import static org.apache.nifi.processors.gcp.cloudsql.GcpCloudSqlIamDatabasePasswordProvider.GCP_CREDENTIALS_PROVIDER_SERVICE; -import static org.apache.nifi.processors.gcp.cloudsql.GcpCloudSqlIamDatabasePasswordProvider.MALFORMED_MYSQL_JDBC_URL_MESSAGE; -import static org.apache.nifi.processors.gcp.cloudsql.GcpCloudSqlIamDatabasePasswordProvider.MALFORMED_SSLMODE_MESSAGE; import static org.apache.nifi.processors.gcp.cloudsql.GcpCloudSqlIamDatabasePasswordProvider.SQLSERVICE_LOGIN_SCOPE; import static org.apache.nifi.processors.gcp.cloudsql.GcpCloudSqlIamDatabasePasswordProvider.VERIFY_CREDENTIALS_UNAVAILABLE; import static org.apache.nifi.processors.gcp.cloudsql.GcpCloudSqlIamDatabasePasswordProvider.VERIFY_IMPERSONATION_REQUIRED; @@ -94,12 +85,9 @@ class GcpCloudSqlIamDatabasePasswordProviderTest { private static final String CREDENTIALS_SERVICE_ID = "gcpCredentials"; private static final String PASSWORD_PROVIDER_ID = "cloudSqlIamProvider"; - private static final String POSTGRES_DRIVER_CLASS = "org.postgresql.Driver"; - private static final String MYSQL_DRIVER_CLASS = "com.mysql.cj.jdbc.Driver"; + private static final String DRIVER_CLASS = "org.postgresql.Driver"; private static final String DATABASE_USER = "service-account@test-project.iam"; - private static final String MYSQL_DATABASE_USER = "service-account"; - private static final String JDBC_URL = "jdbc:postgresql://example:5432/database?sslmode=require"; - private static final String MYSQL_JDBC_URL = "jdbc:mysql://example:3306/database?sslMode=REQUIRED"; + private static final String JDBC_URL = "jdbc:postgresql://example:5432/database"; private static final String TOKEN_VALUE = "cloud-sql-token"; private static final String REFRESHED_TOKEN_VALUE = "refreshed-cloud-sql-token"; private static final String LEAK_SENTINEL = "sentinel-token-value"; @@ -114,15 +102,12 @@ void tearDown() { } @Test - void testDatabaseTypeDescriptorSupportsPostgreSqlAndMySqlAndDefaultsToPostgreSql() { - final PropertyDescriptor descriptor = DATABASE_TYPE; - - assertEquals(CloudSqlDatabaseType.POSTGRESQL.getValue(), descriptor.getDefaultValue()); - assertTrue(descriptor.isRequired()); - assertEquals("Database Type", descriptor.getName()); - assertEquals(List.of(CloudSqlDatabaseType.POSTGRESQL.getValue(), CloudSqlDatabaseType.MYSQL.getValue()), descriptor.getAllowableValues().stream() - .map(AllowableValue::getValue) - .toList()); + void testSupportedPropertyDescriptorsContainOnlyCredentialsService() throws Exception { + final List descriptors = getSupportedPropertyDescriptors(new GcpCloudSqlIamDatabasePasswordProvider()); + + assertEquals(1, descriptors.size()); + assertEquals(GCP_CREDENTIALS_PROVIDER_SERVICE, descriptors.get(0)); + assertTrue(descriptors.get(0).isRequired()); } @Test @@ -134,104 +119,72 @@ void testOnEnabledCachesScopedCredentialAndReusesIt() throws Exception { assertEquals(1, rootCredentials.getCreateScopedCount()); assertEquals(List.of(SQLSERVICE_LOGIN_SCOPE), rootCredentials.getLastRequestedScopes()); - assertEquals(CloudSqlDatabaseType.POSTGRESQL, getDatabaseType(provider)); assertSame(scopedCredentials, getScopedCredentials(provider)); - - assertEquals(TOKEN_VALUE, new String(provider.getPassword(requestContext(JDBC_URL, DATABASE_USER, Map.of())))); - assertEquals(TOKEN_VALUE, new String(provider.getPassword(requestContext(JDBC_URL, DATABASE_USER, Map.of())))); - - assertEquals(1, rootCredentials.getCreateScopedCount()); + assertEquals(TOKEN_VALUE, new String(provider.getPassword(requestContext()))); + assertEquals(TOKEN_VALUE, new String(provider.getPassword(requestContext()))); assertEquals(0, scopedCredentials.getRefreshAccessTokenCount()); } @Test - void testOnDisabledClearsCachedCredentialAndDatabaseType() throws Exception { - final TestScopedGoogleCredentials scopedCredentials = new TestScopedGoogleCredentials(accessToken(TOKEN_VALUE, 15)); - final RootGoogleCredentials rootCredentials = new RootGoogleCredentials(scopedCredentials); - final TestRunner runner = configureRunner(rootCredentials); + void testOnDisabledClearsCachedCredential() throws Exception { + final TestRunner runner = configureRunner(new RootGoogleCredentials(new TestScopedGoogleCredentials(accessToken(TOKEN_VALUE, 15))), true); final GcpCloudSqlIamDatabasePasswordProvider provider = getProviderImplementation(runner); runner.disableControllerService(provider); assertNull(getScopedCredentials(provider)); - assertNull(getDatabaseType(provider)); - final ProcessException exception = assertThrows(ProcessException.class, - () -> provider.getPassword(requestContext(JDBC_URL, DATABASE_USER, Map.of()))); + final ProcessException exception = assertThrows(ProcessException.class, () -> provider.getPassword(requestContext())); - assertEquals(FAILED_PASSWORD_MESSAGE, exception.getMessage()); + assertTrue(exception.getMessage().contains("Cloud SQL IAM")); assertNull(exception.getCause()); } @Test - void testOnEnabledCachesScopedCredentialForMySqlAndUsesMySqlValidation() throws Exception { - final TestScopedGoogleCredentials scopedCredentials = new TestScopedGoogleCredentials(accessToken(TOKEN_VALUE, 15)); - final RootGoogleCredentials rootCredentials = new RootGoogleCredentials(scopedCredentials); - final TestRunner runner = configureRunner(rootCredentials, true, CloudSqlDatabaseType.MYSQL); - final GcpCloudSqlIamDatabasePasswordProvider provider = getProviderImplementation(runner); - - assertEquals(1, rootCredentials.getCreateScopedCount()); - assertEquals(List.of(SQLSERVICE_LOGIN_SCOPE), rootCredentials.getLastRequestedScopes()); - assertEquals(CloudSqlDatabaseType.MYSQL, getDatabaseType(provider)); - assertSame(scopedCredentials, getScopedCredentials(provider)); - - assertEquals(TOKEN_VALUE, new String(provider.getPassword(requestContext(MYSQL_JDBC_URL, MYSQL_DATABASE_USER, MYSQL_DRIVER_CLASS, Map.of())))); - assertEquals(0, scopedCredentials.getRefreshAccessTokenCount()); - } - - @Test - void testOnDisabledClearsCachedCredentialAndDatabaseTypeForMySql() throws Exception { - final TestScopedGoogleCredentials scopedCredentials = new TestScopedGoogleCredentials(accessToken(TOKEN_VALUE, 15)); + void testVerifyImpersonatedCredentialsAcquireLiveToken() throws Exception { + final ImpersonatedCredentials scopedCredentials = impersonatedCredentials(accessToken(TOKEN_VALUE, 15)); final RootGoogleCredentials rootCredentials = new RootGoogleCredentials(scopedCredentials); - final TestRunner runner = configureRunner(rootCredentials, true, CloudSqlDatabaseType.MYSQL); + final TestRunner runner = configureRunner(rootCredentials); final GcpCloudSqlIamDatabasePasswordProvider provider = getProviderImplementation(runner); - runner.disableControllerService(provider); - - assertNull(getScopedCredentials(provider)); - assertNull(getDatabaseType(provider)); - - final ProcessException exception = assertThrows(ProcessException.class, - () -> provider.getPassword(requestContext(MYSQL_JDBC_URL, MYSQL_DATABASE_USER, MYSQL_DRIVER_CLASS, Map.of()))); + final List results = runner.verify(provider, Map.of()); - assertEquals(FAILED_PASSWORD_MESSAGE, exception.getMessage()); - assertNull(exception.getCause()); + assertEquals(2, results.size()); + assertVerificationResult(results.get(0), VERIFY_SCOPE_STEP, SUCCESSFUL, "impersonation"); + assertVerificationResult(results.get(1), VERIFY_TOKEN_STEP, SUCCESSFUL, "DBCP Verify"); + assertEquals(2, rootCredentials.getCreateScopedCount()); + Mockito.verify(scopedCredentials).refreshAccessToken(); } @Test - void testVerifyImpersonatedCredentialsAcquireLiveToken() throws Exception { - final ImpersonatedCredentials scopedCredentials = impersonatedCredentials(accessToken(TOKEN_VALUE, 15)); - final RootGoogleCredentials rootCredentials = new RootGoogleCredentials(scopedCredentials); + void testVerifyUsesFreshScopedCredentialWithoutMutatingEnabledState() throws Exception { + final TestScopedGoogleCredentials enabledScopedCredentials = new TestScopedGoogleCredentials(accessToken(TOKEN_VALUE, 15)); + final TestScopedGoogleCredentials verificationScopedCredentials = new TestScopedGoogleCredentials(accessToken(REFRESHED_TOKEN_VALUE, -15)); + verificationScopedCredentials.setRefreshedAccessToken(accessToken(REFRESHED_TOKEN_VALUE, 15)); + final RootGoogleCredentials rootCredentials = new RootGoogleCredentials(enabledScopedCredentials, verificationScopedCredentials); final TestRunner runner = configureRunner(rootCredentials); final GcpCloudSqlIamDatabasePasswordProvider provider = getProviderImplementation(runner); final List results = runner.verify(provider, Map.of()); + final char[] password = provider.getPassword(requestContext()); - assertEquals(2, results.size()); - assertVerificationResult(results.get(0), VERIFY_SCOPE_STEP, SUCCESSFUL, - "Resolved Database Type PostgreSQL"); - assertVerificationResult(results.get(0), VERIFY_SCOPE_STEP, SUCCESSFUL, - "created a Cloud SQL scoped ImpersonatedCredentials instance. Target service account impersonation is active."); - assertVerificationResult(results.get(1), VERIFY_TOKEN_STEP, SUCCESSFUL, - "Acquired a non-empty Cloud SQL IAM access token for PostgreSQL"); - assertVerificationResult(results.get(1), VERIFY_TOKEN_STEP, SUCCESSFUL, - "verifies live subject token exchange, Google STS, and target service account impersonation"); assertEquals(2, rootCredentials.getCreateScopedCount()); - Mockito.verify(scopedCredentials).refreshAccessToken(); + assertEquals(0, enabledScopedCredentials.getRefreshAccessTokenCount()); + assertEquals(1, verificationScopedCredentials.getRefreshAccessTokenCount()); + assertVerificationResult(results.get(1), VERIFY_TOKEN_STEP, SUCCESSFUL, "Cloud SQL IAM access token"); + assertArrayEquals(TOKEN_VALUE.toCharArray(), password); } @Test void testVerifyIdentityPoolCredentialsRequiresImpersonation() throws Exception { final IdentityPoolCredentials scopedCredentials = identityPoolCredentials(accessToken(TOKEN_VALUE, 15)); - final RootGoogleCredentials rootCredentials = new RootGoogleCredentials(scopedCredentials); - final TestRunner runner = configureRunner(rootCredentials, false); + final TestRunner runner = configureRunner(new RootGoogleCredentials(scopedCredentials), false); final GcpCloudSqlIamDatabasePasswordProvider provider = getProviderImplementation(runner); final List results = runner.verify(provider, Map.of()); assertEquals(1, results.size()); - assertVerificationResult(results.getFirst(), VERIFY_SCOPE_STEP, FAILED, "Resolved Database Type PostgreSQL"); - assertVerificationResult(results.getFirst(), VERIFY_SCOPE_STEP, FAILED, VERIFY_IMPERSONATION_REQUIRED); + assertVerificationResult(results.get(0), VERIFY_SCOPE_STEP, FAILED, VERIFY_IMPERSONATION_REQUIRED); Mockito.verify(scopedCredentials, Mockito.never()).refreshAccessToken(); } @@ -241,64 +194,42 @@ void testOnEnabledRejectsIdentityPoolCredentialsBeforePublishingState() throws E final GcpCloudSqlIamDatabasePasswordProvider provider = new GcpCloudSqlIamDatabasePasswordProvider(); final ConfigurationContext context = mock(ConfigurationContext.class); final PropertyValue credentialsPropertyValue = mock(PropertyValue.class); - final PropertyValue databaseTypePropertyValue = mock(PropertyValue.class); final GCPCredentialsService credentialsService = mock(GCPCredentialsService.class); - when(context.getProperty(DATABASE_TYPE)).thenReturn(databaseTypePropertyValue); - when(databaseTypePropertyValue.asAllowableValue(CloudSqlDatabaseType.class)).thenReturn(CloudSqlDatabaseType.POSTGRESQL); when(context.getProperty(GCP_CREDENTIALS_PROVIDER_SERVICE)).thenReturn(credentialsPropertyValue); when(credentialsPropertyValue.asControllerService(GCPCredentialsService.class)).thenReturn(credentialsService); when(credentialsService.getGoogleCredentials()).thenReturn(new RootGoogleCredentials(scopedCredentials)); - final InitializationException exception = assertThrows(InitializationException.class, - () -> provider.onEnabled(context)); + final InitializationException exception = assertThrows(InitializationException.class, () -> provider.onEnabled(context)); - assertEquals(VERIFY_IMPERSONATION_REQUIRED, exception.getMessage()); + assertTrue(exception.getMessage().contains("impersonation")); assertNull(getScopedCredentials(provider)); - assertNull(getDatabaseType(provider)); - Mockito.verify(scopedCredentials, Mockito.never()).refreshAccessToken(); } @Test - void testGetPasswordRejectsIdentityPoolCredentialsBeforeRefresh() throws Exception { - final IdentityPoolCredentials scopedCredentials = identityPoolCredentials(accessToken(TOKEN_VALUE, -15)); - final GcpCloudSqlIamDatabasePasswordProvider provider = new GcpCloudSqlIamDatabasePasswordProvider(); - setDatabaseType(provider, CloudSqlDatabaseType.POSTGRESQL); - setScopedCredentials(provider, scopedCredentials); + void testVerifyNullCredentialsFails() throws Exception { + final TestRunner runner = configureRunner(null, false); + final GcpCloudSqlIamDatabasePasswordProvider provider = getProviderImplementation(runner); - final ProcessException exception = assertThrows(ProcessException.class, - () -> provider.getPassword(requestContext(JDBC_URL, DATABASE_USER, Map.of()))); + final List results = runner.verify(provider, Map.of()); - assertEquals(FAILED_PASSWORD_MESSAGE, exception.getMessage()); - assertNull(exception.getCause()); - Mockito.verify(scopedCredentials, Mockito.never()).refreshAccessToken(); + assertEquals(1, results.size()); + assertVerificationResult(results.get(0), VERIFY_SCOPE_STEP, FAILED, VERIFY_CREDENTIALS_UNAVAILABLE); } @Test - void testInvalidDatabaseTypePropertyIsRejectedByValidation() throws Exception { - final TestRunner runner = TestRunners.newTestRunner(NoOpProcessor.class); - - final TestGCPCredentialsService credentialsService = new TestGCPCredentialsService(new RootGoogleCredentials(new TestScopedGoogleCredentials(accessToken(TOKEN_VALUE, 15)))); - runner.addControllerService(CREDENTIALS_SERVICE_ID, credentialsService); - runner.enableControllerService(credentialsService); - + void testOnEnabledNullCredentialsFails() throws Exception { final GcpCloudSqlIamDatabasePasswordProvider provider = new GcpCloudSqlIamDatabasePasswordProvider(); - runner.addControllerService(PASSWORD_PROVIDER_ID, provider); - runner.setProperty(provider, GCP_CREDENTIALS_PROVIDER_SERVICE, CREDENTIALS_SERVICE_ID); - runner.setProperty(provider, DATABASE_TYPE, "SQLSERVER"); - - runner.assertNotValid(provider); - } + final ConfigurationContext context = mock(ConfigurationContext.class); + final PropertyValue credentialsPropertyValue = mock(PropertyValue.class); - @Test - void testVerifyNullCredentialsFails() throws Exception { - final TestRunner runner = configureRunner(null, false); - final GcpCloudSqlIamDatabasePasswordProvider provider = getProviderImplementation(runner); + when(context.getProperty(GCP_CREDENTIALS_PROVIDER_SERVICE)).thenReturn(credentialsPropertyValue); + when(credentialsPropertyValue.asControllerService(GCPCredentialsService.class)).thenReturn(null); - final List results = runner.verify(provider, Map.of()); + final InitializationException exception = assertThrows(InitializationException.class, () -> provider.onEnabled(context)); - assertEquals(1, results.size()); - assertVerificationResult(results.getFirst(), VERIFY_SCOPE_STEP, FAILED, VERIFY_CREDENTIALS_UNAVAILABLE); + assertTrue(exception.getMessage().contains("credentials")); + assertNull(getScopedCredentials(provider)); } @Test @@ -310,43 +241,42 @@ void testVerifyScopedCredentialCreationReturningNullFails() throws Exception { final List results = runner.verify(provider, Map.of()); assertEquals(1, results.size()); - assertVerificationResult(results.getFirst(), VERIFY_SCOPE_STEP, FAILED, VERIFY_SCOPED_CREDENTIALS_UNAVAILABLE); + assertVerificationResult(results.get(0), VERIFY_SCOPE_STEP, FAILED, VERIFY_SCOPED_CREDENTIALS_UNAVAILABLE); assertEquals(List.of(SQLSERVICE_LOGIN_SCOPE), rootCredentials.getLastRequestedScopes()); } @Test - void testVerifyScopedCredentialCreationFailureIsSanitized() throws Exception { - final RootGoogleCredentials rootCredentials = new RootGoogleCredentials(new IllegalStateException(LEAK_SENTINEL)); - final TestRunner runner = configureRunner(rootCredentials, false); + void testVerifyScopedCredentialCreationFailureIsReported() throws Exception { + final TestRunner runner = configureRunner(new RootGoogleCredentials(new IllegalStateException(LEAK_SENTINEL)), false); final GcpCloudSqlIamDatabasePasswordProvider provider = getProviderImplementation(runner); final List results = runner.verify(provider, Map.of()); assertEquals(1, results.size()); - assertVerificationResult(results.getFirst(), VERIFY_SCOPE_STEP, FAILED, VERIFY_SCOPED_CREDENTIALS_UNAVAILABLE); - assertFalse(results.getFirst().getExplanation().contains(LEAK_SENTINEL)); - assertNoLogMessagesContain(runner.getControllerServiceLogger(PASSWORD_PROVIDER_ID), LEAK_SENTINEL); + assertVerificationResult(results.get(0), VERIFY_SCOPE_STEP, FAILED, VERIFY_SCOPED_CREDENTIALS_UNAVAILABLE); + assertFalse(results.get(0).getExplanation().contains(LEAK_SENTINEL)); } @Test - void testVerifyRefreshIOExceptionIsSanitized() throws Exception { - final ImpersonatedCredentials scopedCredentials = impersonatedCredentials(ioException(LEAK_SENTINEL, "com.google.auth.oauth2.ImpersonatedCredentials")); - final TestRunner runner = configureRunner(new RootGoogleCredentials(scopedCredentials)); - final GcpCloudSqlIamDatabasePasswordProvider provider = getProviderImplementation(runner); + void testOnEnabledScopedCredentialCreationReturningNullFails() throws Exception { + final GcpCloudSqlIamDatabasePasswordProvider provider = new GcpCloudSqlIamDatabasePasswordProvider(); + final ConfigurationContext context = mock(ConfigurationContext.class); + final PropertyValue credentialsPropertyValue = mock(PropertyValue.class); + final GCPCredentialsService credentialsService = mock(GCPCredentialsService.class); - final List results = runner.verify(provider, Map.of()); + when(context.getProperty(GCP_CREDENTIALS_PROVIDER_SERVICE)).thenReturn(credentialsPropertyValue); + when(credentialsPropertyValue.asControllerService(GCPCredentialsService.class)).thenReturn(credentialsService); + when(credentialsService.getGoogleCredentials()).thenReturn(new RootGoogleCredentials((GoogleCredentials) null)); - assertEquals(2, results.size()); - assertVerificationResult(results.get(0), VERIFY_SCOPE_STEP, SUCCESSFUL, - "created a Cloud SQL scoped ImpersonatedCredentials instance. Target service account impersonation is active."); - assertVerificationResult(results.get(1), VERIFY_TOKEN_STEP, FAILED, VERIFY_TOKEN_ACQUISITION_FAILED); - assertFalse(results.get(1).getExplanation().contains(LEAK_SENTINEL)); - assertNoLogMessagesContain(runner.getControllerServiceLogger(PASSWORD_PROVIDER_ID), LEAK_SENTINEL); + final InitializationException exception = assertThrows(InitializationException.class, () -> provider.onEnabled(context)); + + assertTrue(exception.getMessage().contains("scope")); + assertNull(getScopedCredentials(provider)); } @Test - void testVerifyRefreshRuntimeFailureIsSanitized() throws Exception { - final ImpersonatedCredentials scopedCredentials = impersonatedCredentials(new IllegalStateException(LEAK_SENTINEL)); + void testVerifyRefreshFailureIsSanitized() throws Exception { + final ImpersonatedCredentials scopedCredentials = impersonatedCredentials(ioException(LEAK_SENTINEL)); final TestRunner runner = configureRunner(new RootGoogleCredentials(scopedCredentials)); final GcpCloudSqlIamDatabasePasswordProvider provider = getProviderImplementation(runner); @@ -354,6 +284,7 @@ void testVerifyRefreshRuntimeFailureIsSanitized() throws Exception { assertEquals(2, results.size()); assertVerificationResult(results.get(1), VERIFY_TOKEN_STEP, FAILED, VERIFY_TOKEN_ACQUISITION_FAILED); + assertFalse(results.get(1).getExplanation().contains(LEAK_SENTINEL)); assertNoLogMessagesContain(runner.getControllerServiceLogger(PASSWORD_PROVIDER_ID), LEAK_SENTINEL); } @@ -361,9 +292,8 @@ void testVerifyRefreshRuntimeFailureIsSanitized() throws Exception { void testVerifyNullAccessTokenFails() throws Exception { final ImpersonatedCredentials scopedCredentials = impersonatedCredentials((AccessToken) null); final TestRunner runner = configureRunner(new RootGoogleCredentials(scopedCredentials)); - final GcpCloudSqlIamDatabasePasswordProvider provider = getProviderImplementation(runner); - final List results = runner.verify(provider, Map.of()); + final List results = runner.verify(getProviderImplementation(runner), Map.of()); assertEquals(2, results.size()); assertVerificationResult(results.get(1), VERIFY_TOKEN_STEP, FAILED, VERIFY_TOKEN_MISSING); @@ -373,101 +303,19 @@ void testVerifyNullAccessTokenFails() throws Exception { void testVerifyBlankAccessTokenFails() throws Exception { final ImpersonatedCredentials scopedCredentials = impersonatedCredentials(accessToken(" ", 15)); final TestRunner runner = configureRunner(new RootGoogleCredentials(scopedCredentials)); - final GcpCloudSqlIamDatabasePasswordProvider provider = getProviderImplementation(runner); - final List results = runner.verify(provider, Map.of()); + final List results = runner.verify(getProviderImplementation(runner), Map.of()); assertEquals(2, results.size()); assertVerificationResult(results.get(1), VERIFY_TOKEN_STEP, FAILED, VERIFY_TOKEN_MISSING); } - @Test - void testVerifyUsesFreshScopedCredentialWithoutMutatingEnabledState() throws Exception { - final TestScopedGoogleCredentials enabledScopedCredentials = new TestScopedGoogleCredentials(accessToken(TOKEN_VALUE, 15)); - final TestScopedGoogleCredentials verificationScopedCredentials = new TestScopedGoogleCredentials(accessToken(REFRESHED_TOKEN_VALUE, 15)); - verificationScopedCredentials.setRefreshedAccessToken(accessToken(REFRESHED_TOKEN_VALUE, 15)); - final RootGoogleCredentials rootCredentials = new RootGoogleCredentials(enabledScopedCredentials, verificationScopedCredentials); - final TestRunner runner = configureRunner(rootCredentials); - final GcpCloudSqlIamDatabasePasswordProvider provider = getProviderImplementation(runner); - - final List results = runner.verify(provider, Map.of()); - final char[] password = provider.getPassword(requestContext(JDBC_URL, DATABASE_USER, Map.of())); - - assertEquals(2, rootCredentials.getCreateScopedCount()); - assertEquals(0, enabledScopedCredentials.getRefreshAccessTokenCount()); - assertEquals(1, verificationScopedCredentials.getRefreshAccessTokenCount()); - assertFalse(results.get(1).getExplanation().contains("subject token exchange")); - assertTrue(results.get(1).getExplanation().contains("Cloud SQL IAM token acquisition for the current principal")); - assertVerificationResult(results.get(1), VERIFY_TOKEN_STEP, SUCCESSFUL, - "Use DBCP Verify for the end-to-end database check."); - assertArrayEquals(TOKEN_VALUE.toCharArray(), password); - } - - @Test - void testVerifyUsesSqlServiceLoginScope() throws Exception { - final GcpCloudSqlIamDatabasePasswordProvider provider = new GcpCloudSqlIamDatabasePasswordProvider(); - final ConfigurationContext context = mock(ConfigurationContext.class); - final PropertyValue credentialsPropertyValue = mock(PropertyValue.class); - final PropertyValue databaseTypePropertyValue = mock(PropertyValue.class); - final GCPCredentialsService credentialsService = mock(GCPCredentialsService.class); - final RootGoogleCredentials rootCredentials = new RootGoogleCredentials(new TestScopedGoogleCredentials(accessToken(TOKEN_VALUE, 15))); - - when(context.getProperty(DATABASE_TYPE)).thenReturn(databaseTypePropertyValue); - when(databaseTypePropertyValue.asAllowableValue(CloudSqlDatabaseType.class)).thenReturn(CloudSqlDatabaseType.POSTGRESQL); - when(context.getProperty(GCP_CREDENTIALS_PROVIDER_SERVICE)).thenReturn(credentialsPropertyValue); - when(credentialsPropertyValue.asControllerService(GCPCredentialsService.class)).thenReturn(credentialsService); - when(credentialsService.getGoogleCredentials()).thenReturn(rootCredentials); - - final List results = provider.verify(context, mock(ComponentLog.class), Map.of()); - - assertEquals(2, results.size()); - assertEquals(List.of(SQLSERVICE_LOGIN_SCOPE), rootCredentials.getLastRequestedScopes()); - assertVerificationResult(results.getFirst(), VERIFY_SCOPE_STEP, SUCCESSFUL, "Resolved Database Type PostgreSQL"); - } - - @ParameterizedTest(name = "verify wording for {0}") - @MethodSource("verifySuccessContexts") - void testVerifySuccessWordingIsGenericForSelectedDatabaseType(final CloudSqlDatabaseType databaseType, - final GoogleCredentials scopedCredentials, - final String scopeMessage, - final String tokenMessage) throws Exception { - if (scopedCredentials instanceof TestScopedGoogleCredentials testScopedGoogleCredentials) { - testScopedGoogleCredentials.setRefreshedAccessToken(accessToken(TOKEN_VALUE, 15)); - } - final TestRunner runner = configureRunner(new RootGoogleCredentials(scopedCredentials), true, databaseType); - final GcpCloudSqlIamDatabasePasswordProvider provider = getProviderImplementation(runner); - - final List results = runner.verify(provider, Map.of()); - - assertEquals(2, results.size()); - assertVerificationResult(results.get(0), VERIFY_SCOPE_STEP, SUCCESSFUL, "Resolved Database Type %s".formatted(databaseType.getDisplayName())); - assertVerificationResult(results.get(0), VERIFY_SCOPE_STEP, SUCCESSFUL, scopeMessage); - assertVerificationResult(results.get(1), VERIFY_TOKEN_STEP, SUCCESSFUL, - "Acquired a non-empty Cloud SQL IAM access token for %s".formatted(databaseType.getDisplayName())); - assertVerificationResult(results.get(1), VERIFY_TOKEN_STEP, SUCCESSFUL, tokenMessage); - assertVerificationResult(results.get(1), VERIFY_TOKEN_STEP, SUCCESSFUL, - "does not connect to the selected database. Use DBCP Verify for the end-to-end database check."); - } - - @Test - void testChainedControllerServiceResolution() throws Exception { - final TestScopedGoogleCredentials scopedCredentials = new TestScopedGoogleCredentials(accessToken(TOKEN_VALUE, 15)); - final RootGoogleCredentials rootCredentials = new RootGoogleCredentials(scopedCredentials); - final TestRunner runner = configureRunner(rootCredentials); - - final DatabasePasswordProvider provider = getProvider(runner); - final char[] password = provider.getPassword(requestContext(JDBC_URL, DATABASE_USER, Map.of())); - - assertArrayEquals(TOKEN_VALUE.toCharArray(), password); - } - @Test void testFreshTokenDoesNotRefresh() throws Exception { final TestScopedGoogleCredentials scopedCredentials = new TestScopedGoogleCredentials(accessToken(TOKEN_VALUE, 15)); - final TestRunner runner = configureRunner(new RootGoogleCredentials(scopedCredentials)); + final DatabasePasswordProvider provider = getProvider(configureRunner(new RootGoogleCredentials(scopedCredentials))); - final DatabasePasswordProvider provider = getProvider(runner); - final char[] password = provider.getPassword(requestContext(JDBC_URL, DATABASE_USER, Map.of())); + final char[] password = provider.getPassword(requestContext()); assertArrayEquals(TOKEN_VALUE.toCharArray(), password); assertEquals(0, scopedCredentials.getRefreshAccessTokenCount()); @@ -477,10 +325,9 @@ void testFreshTokenDoesNotRefresh() throws Exception { void testExpiredTokenRefreshes() throws Exception { final TestScopedGoogleCredentials scopedCredentials = new TestScopedGoogleCredentials(accessToken(TOKEN_VALUE, -15)); scopedCredentials.setRefreshedAccessToken(accessToken(REFRESHED_TOKEN_VALUE, 15)); - final TestRunner runner = configureRunner(new RootGoogleCredentials(scopedCredentials)); + final DatabasePasswordProvider provider = getProvider(configureRunner(new RootGoogleCredentials(scopedCredentials))); - final DatabasePasswordProvider provider = getProvider(runner); - final char[] password = provider.getPassword(requestContext(JDBC_URL, DATABASE_USER, Map.of())); + final char[] password = provider.getPassword(requestContext()); assertArrayEquals(REFRESHED_TOKEN_VALUE.toCharArray(), password); assertEquals(1, scopedCredentials.getRefreshAccessTokenCount()); @@ -490,8 +337,7 @@ void testExpiredTokenRefreshes() throws Exception { void testConcurrentGetPasswordPerformsSingleRefresh() throws Exception { final BlockingScopedGoogleCredentials scopedCredentials = new BlockingScopedGoogleCredentials(); scopedCredentials.setRefreshedAccessToken(accessToken(REFRESHED_TOKEN_VALUE, 15)); - final TestRunner runner = configureRunner(new RootGoogleCredentials(scopedCredentials)); - final DatabasePasswordProvider provider = getProvider(runner); + final DatabasePasswordProvider provider = getProvider(configureRunner(new RootGoogleCredentials(scopedCredentials))); executorService = Executors.newFixedThreadPool(2); final CountDownLatch startLatch = new CountDownLatch(1); @@ -508,637 +354,61 @@ void testConcurrentGetPasswordPerformsSingleRefresh() throws Exception { } @Test - void testNullRequestContextRejected() throws Exception { - final TestRunner runner = configureRunner(new RootGoogleCredentials(new TestScopedGoogleCredentials(accessToken(TOKEN_VALUE, 15)))); - final DatabasePasswordProvider provider = getProvider(runner); - - final NullPointerException exception = assertThrows(NullPointerException.class, () -> provider.getPassword(null)); - - assertEquals("Database Password Request Context required", exception.getMessage()); - } - - @Test - void testBlankDatabaseUserRejectedBeforeRefresh() throws Exception { - final TestScopedGoogleCredentials scopedCredentials = new TestScopedGoogleCredentials(accessToken(TOKEN_VALUE, -15)); - scopedCredentials.setRefreshedAccessToken(accessToken(REFRESHED_TOKEN_VALUE, 15)); - final TestRunner runner = configureRunner(new RootGoogleCredentials(scopedCredentials)); - final DatabasePasswordProvider provider = getProvider(runner); - - final ProcessException exception = assertThrows(ProcessException.class, - () -> provider.getPassword(requestContext(JDBC_URL, " ", Map.of()))); - - assertEquals("Database Username must be configured for Cloud SQL IAM authentication", exception.getMessage()); - assertEquals(0, scopedCredentials.getRefreshAccessTokenCount()); - } - - @Test - void testBlankMySqlDatabaseUserRejectedBeforeRefresh() throws Exception { - final TestScopedGoogleCredentials scopedCredentials = new TestScopedGoogleCredentials(accessToken(TOKEN_VALUE, -15)); - scopedCredentials.setRefreshedAccessToken(accessToken(REFRESHED_TOKEN_VALUE, 15)); - final TestRunner runner = configureRunner(new RootGoogleCredentials(scopedCredentials), true, CloudSqlDatabaseType.MYSQL); - final DatabasePasswordProvider provider = getProvider(runner); - - final ProcessException exception = assertThrows(ProcessException.class, - () -> provider.getPassword(requestContext(MYSQL_JDBC_URL, " ", MYSQL_DRIVER_CLASS, Map.of()))); - - assertEquals("Database Username must be configured for Cloud SQL IAM authentication", exception.getMessage()); - assertEquals(0, scopedCredentials.getRefreshAccessTokenCount()); - } - - @ParameterizedTest(name = "accepted sslmode {0} from URL") - @MethodSource("acceptedUrlSslModes") - void testAcceptedUrlSslModes(final String sslMode) throws Exception { - final TestScopedGoogleCredentials scopedCredentials = new TestScopedGoogleCredentials(accessToken(TOKEN_VALUE, 15)); - final TestRunner runner = configureRunner(new RootGoogleCredentials(scopedCredentials)); - final DatabasePasswordProvider provider = getProvider(runner); - - final char[] password = provider.getPassword(requestContext( - "jdbc:postgresql://example:5432/database?sslmode=%s".formatted(sslMode), - DATABASE_USER, - Map.of("sslmode", "disable") - )); - - assertArrayEquals(TOKEN_VALUE.toCharArray(), password); - } - - @Test - void testAcceptedCaseInsensitiveUrlSslModeNameAndDecodedValue() throws Exception { - final TestScopedGoogleCredentials scopedCredentials = new TestScopedGoogleCredentials(accessToken(TOKEN_VALUE, 15)); - final TestRunner runner = configureRunner(new RootGoogleCredentials(scopedCredentials)); - final DatabasePasswordProvider provider = getProvider(runner); - - final char[] password = provider.getPassword(requestContext( - "jdbc:postgresql://example:5432/database?SslMode=verify%2Dfull", - DATABASE_USER, - Map.of("sslmode", "disable") - )); - - assertArrayEquals(TOKEN_VALUE.toCharArray(), password); - } - - @ParameterizedTest(name = "accepted sslmode {0} from connection properties") - @MethodSource("acceptedPropertySslModes") - void testAcceptedConnectionPropertySslModes(final String sslMode) throws Exception { - final TestScopedGoogleCredentials scopedCredentials = new TestScopedGoogleCredentials(accessToken(TOKEN_VALUE, 15)); - final TestRunner runner = configureRunner(new RootGoogleCredentials(scopedCredentials)); - final DatabasePasswordProvider provider = getProvider(runner); - - final char[] password = provider.getPassword(requestContext( - "jdbc:postgresql://example:5432/database", - DATABASE_USER, - Map.of("sslmode", sslMode) - )); - - assertArrayEquals(TOKEN_VALUE.toCharArray(), password); - } - - @Test - void testAcceptedCaseInsensitiveConnectionPropertyName() throws Exception { - final TestScopedGoogleCredentials scopedCredentials = new TestScopedGoogleCredentials(accessToken(TOKEN_VALUE, 15)); - final TestRunner runner = configureRunner(new RootGoogleCredentials(scopedCredentials)); - final DatabasePasswordProvider provider = getProvider(runner); - - final char[] password = provider.getPassword(requestContext( - "jdbc:postgresql://example:5432/database", - DATABASE_USER, - Map.of("SSLMODE", "require") - )); - - assertArrayEquals(TOKEN_VALUE.toCharArray(), password); - } - - @Test - void testMissingSslModeRejectedBeforeRefresh() throws Exception { - final TestScopedGoogleCredentials scopedCredentials = new TestScopedGoogleCredentials(accessToken(TOKEN_VALUE, -15)); - scopedCredentials.setRefreshedAccessToken(accessToken(REFRESHED_TOKEN_VALUE, 15)); - final TestRunner runner = configureRunner(new RootGoogleCredentials(scopedCredentials)); - final DatabasePasswordProvider provider = getProvider(runner); - - final ProcessException exception = assertThrows(ProcessException.class, - () -> provider.getPassword(requestContext("jdbc:postgresql://example:5432/database", DATABASE_USER, Map.of()))); - - assertEquals("PostgreSQL sslmode must be configured for Cloud SQL IAM authentication", exception.getMessage()); - assertEquals(0, scopedCredentials.getRefreshAccessTokenCount()); - } - - @ParameterizedTest(name = "rejected sslmode {0}") - @MethodSource("rejectedSslModeContexts") - void testRejectedSslModes(final String jdbcUrl, final Map connectionProperties, final String expectedSslMode) throws Exception { - final TestScopedGoogleCredentials scopedCredentials = new TestScopedGoogleCredentials(accessToken(TOKEN_VALUE, -15)); - scopedCredentials.setRefreshedAccessToken(accessToken(REFRESHED_TOKEN_VALUE, 15)); - final TestRunner runner = configureRunner(new RootGoogleCredentials(scopedCredentials)); - final DatabasePasswordProvider provider = getProvider(runner); - - final ProcessException exception = assertThrows(ProcessException.class, - () -> provider.getPassword(requestContext(jdbcUrl, DATABASE_USER, connectionProperties))); - - assertEquals("PostgreSQL sslmode [%s] is not supported for Cloud SQL IAM authentication".formatted(expectedSslMode), exception.getMessage()); - assertEquals(0, scopedCredentials.getRefreshAccessTokenCount()); - } - - @Test - void testUrlSslModeTakesPrecedenceOverConnectionProperties() throws Exception { - final TestScopedGoogleCredentials scopedCredentials = new TestScopedGoogleCredentials(accessToken(TOKEN_VALUE, 15)); - final TestRunner runner = configureRunner(new RootGoogleCredentials(scopedCredentials)); - final DatabasePasswordProvider provider = getProvider(runner); - - final char[] password = provider.getPassword(requestContext( - "jdbc:postgresql://example:5432/database?sslmode=require", - DATABASE_USER, - Map.of("sslmode", "disable") - )); - - assertArrayEquals(TOKEN_VALUE.toCharArray(), password); - } - - @Test - void testDuplicateUrlSslModeLastInsecureValueRejectedBeforeRefresh() throws Exception { - final TestScopedGoogleCredentials scopedCredentials = new TestScopedGoogleCredentials(accessToken(TOKEN_VALUE, -15)); - scopedCredentials.setRefreshedAccessToken(accessToken(REFRESHED_TOKEN_VALUE, 15)); - final TestRunner runner = configureRunner(new RootGoogleCredentials(scopedCredentials)); - final DatabasePasswordProvider provider = getProvider(runner); - - final ProcessException exception = assertThrows(ProcessException.class, - () -> provider.getPassword(requestContext( - "jdbc:postgresql://example:5432/database?sslmode=require&sslmode=disable", - DATABASE_USER, - Map.of("sslmode", "verify-full") - ))); - - assertEquals("PostgreSQL sslmode [disable] is not supported for Cloud SQL IAM authentication", exception.getMessage()); - assertEquals(0, scopedCredentials.getRefreshAccessTokenCount()); - } - - @Test - void testDuplicateUrlSslModeLastSecureValueAccepted() throws Exception { - final TestScopedGoogleCredentials scopedCredentials = new TestScopedGoogleCredentials(accessToken(TOKEN_VALUE, 15)); - final TestRunner runner = configureRunner(new RootGoogleCredentials(scopedCredentials)); - final DatabasePasswordProvider provider = getProvider(runner); - - final char[] password = provider.getPassword(requestContext( - "jdbc:postgresql://example:5432/database?sslmode=disable&sslmode=require", - DATABASE_USER, - Map.of("sslmode", "disable") - )); - - assertArrayEquals(TOKEN_VALUE.toCharArray(), password); - } - - @Test - void testMalformedUrlEncodedSslModeValueRejectedBeforeRefresh() throws Exception { - final TestScopedGoogleCredentials scopedCredentials = new TestScopedGoogleCredentials(accessToken(TOKEN_VALUE, -15)); - scopedCredentials.setRefreshedAccessToken(accessToken(REFRESHED_TOKEN_VALUE, 15)); - final TestRunner runner = configureRunner(new RootGoogleCredentials(scopedCredentials)); - final DatabasePasswordProvider provider = getProvider(runner); - - final ProcessException exception = assertThrows(ProcessException.class, - () -> provider.getPassword(requestContext( - "jdbc:postgresql://example:5432/database?sslmode=%GG", - DATABASE_USER, - Map.of("sslmode", "require") - ))); - - assertEquals(MALFORMED_SSLMODE_MESSAGE, exception.getMessage()); - assertNull(exception.getCause()); - assertEquals(0, scopedCredentials.getRefreshAccessTokenCount()); - } - - @Test - void testMalformedUrlEncodedSslModeNameRejectedBeforeRefresh() throws Exception { - final TestScopedGoogleCredentials scopedCredentials = new TestScopedGoogleCredentials(accessToken(TOKEN_VALUE, -15)); - scopedCredentials.setRefreshedAccessToken(accessToken(REFRESHED_TOKEN_VALUE, 15)); - final TestRunner runner = configureRunner(new RootGoogleCredentials(scopedCredentials)); - final DatabasePasswordProvider provider = getProvider(runner); - - final ProcessException exception = assertThrows(ProcessException.class, - () -> provider.getPassword(requestContext( - "jdbc:postgresql://example:5432/database?sslmo%G=require", - DATABASE_USER, - Map.of("sslmode", "require") - ))); - - assertEquals(MALFORMED_SSLMODE_MESSAGE, exception.getMessage()); - assertNull(exception.getCause()); - assertEquals(0, scopedCredentials.getRefreshAccessTokenCount()); - } - - @ParameterizedTest(name = "accepted MySQL sslMode {0} from URL") - @MethodSource("acceptedMySqlUrlSslModes") - void testAcceptedMySqlUrlSslModes(final String sslMode) throws Exception { - final TestScopedGoogleCredentials scopedCredentials = new TestScopedGoogleCredentials(accessToken(TOKEN_VALUE, 15)); - final TestRunner runner = configureRunner(new RootGoogleCredentials(scopedCredentials), true, CloudSqlDatabaseType.MYSQL); - final DatabasePasswordProvider provider = getProvider(runner); - - final char[] password = provider.getPassword(requestContext( - "jdbc:mysql://example:3306/database?sslMode=%s".formatted(sslMode), - MYSQL_DATABASE_USER, - MYSQL_DRIVER_CLASS, - Map.of() - )); - - assertArrayEquals(TOKEN_VALUE.toCharArray(), password); - } - - @ParameterizedTest(name = "accepted MySQL sslMode {0} from connection properties") - @MethodSource("acceptedMySqlPropertySslModes") - void testAcceptedMySqlConnectionPropertySslModes(final String sslMode) throws Exception { - final TestScopedGoogleCredentials scopedCredentials = new TestScopedGoogleCredentials(accessToken(TOKEN_VALUE, 15)); - final TestRunner runner = configureRunner(new RootGoogleCredentials(scopedCredentials), true, CloudSqlDatabaseType.MYSQL); - final DatabasePasswordProvider provider = getProvider(runner); - - final char[] password = provider.getPassword(requestContext( - "jdbc:mysql://example:3306/database", - MYSQL_DATABASE_USER, - MYSQL_DRIVER_CLASS, - Map.of("sslMode", sslMode) - )); - - assertArrayEquals(TOKEN_VALUE.toCharArray(), password); - } - - @Test - void testRejectedMySqlCaseInsensitiveUrlSslModeNameAndDecodedValueBeforeRefresh() throws Exception { - final TestScopedGoogleCredentials scopedCredentials = new TestScopedGoogleCredentials(accessToken(TOKEN_VALUE, -15)); - scopedCredentials.setRefreshedAccessToken(accessToken(REFRESHED_TOKEN_VALUE, 15)); - final TestRunner runner = configureRunner(new RootGoogleCredentials(scopedCredentials), true, CloudSqlDatabaseType.MYSQL); - final DatabasePasswordProvider provider = getProvider(runner); - - final ProcessException exception = assertThrows(ProcessException.class, - () -> provider.getPassword(requestContext( - "jdbc:mysql://example:3306/database?SslMode=verify%5Fidentity", - MYSQL_DATABASE_USER, - MYSQL_DRIVER_CLASS, - Map.of("sslMode", "VERIFY_CA") - ))); - - assertEquals("MySQL sslMode must be configured as REQUIRED, VERIFY_CA, or VERIFY_IDENTITY for Cloud SQL IAM authentication", exception.getMessage()); - assertEquals(0, scopedCredentials.getRefreshAccessTokenCount()); - } - - @Test - void testRejectedMySqlCaseInsensitiveConnectionPropertyNameBeforeRefresh() throws Exception { - final TestScopedGoogleCredentials scopedCredentials = new TestScopedGoogleCredentials(accessToken(TOKEN_VALUE, -15)); - scopedCredentials.setRefreshedAccessToken(accessToken(REFRESHED_TOKEN_VALUE, 15)); - final TestRunner runner = configureRunner(new RootGoogleCredentials(scopedCredentials), true, CloudSqlDatabaseType.MYSQL); - final DatabasePasswordProvider provider = getProvider(runner); - - final ProcessException exception = assertThrows(ProcessException.class, - () -> provider.getPassword(requestContext( - "jdbc:mysql://example:3306/database", - MYSQL_DATABASE_USER, - MYSQL_DRIVER_CLASS, - Map.of("SSLMODE", "required") - ))); - - assertEquals("MySQL sslMode must be configured as REQUIRED, VERIFY_CA, or VERIFY_IDENTITY for Cloud SQL IAM authentication", exception.getMessage()); - assertEquals(0, scopedCredentials.getRefreshAccessTokenCount()); - } - - @Test - void testMySqlConnectionPropertiesSslModeTakesPrecedenceOverJdbcUrlBeforeRefresh() throws Exception { - final TestScopedGoogleCredentials scopedCredentials = new TestScopedGoogleCredentials(accessToken(TOKEN_VALUE, -15)); - scopedCredentials.setRefreshedAccessToken(accessToken(REFRESHED_TOKEN_VALUE, 15)); - final TestRunner runner = configureRunner(new RootGoogleCredentials(scopedCredentials), true, CloudSqlDatabaseType.MYSQL); - final DatabasePasswordProvider provider = getProvider(runner); - - final ProcessException exception = assertThrows(ProcessException.class, - () -> provider.getPassword(requestContext( - "jdbc:mysql://example:3306/database?sslMode=REQUIRED", - MYSQL_DATABASE_USER, - MYSQL_DRIVER_CLASS, - Map.of("sslMode", "DISABLED") - ))); - - assertEquals("MySQL sslMode must be configured as REQUIRED, VERIFY_CA, or VERIFY_IDENTITY for Cloud SQL IAM authentication", exception.getMessage()); - assertEquals(0, scopedCredentials.getRefreshAccessTokenCount()); - } - - @Test - void testMySqlDuplicateUrlSslModeLastInsecureValueRejectedBeforeRefresh() throws Exception { - final TestScopedGoogleCredentials scopedCredentials = new TestScopedGoogleCredentials(accessToken(TOKEN_VALUE, -15)); - scopedCredentials.setRefreshedAccessToken(accessToken(REFRESHED_TOKEN_VALUE, 15)); - final TestRunner runner = configureRunner(new RootGoogleCredentials(scopedCredentials), true, CloudSqlDatabaseType.MYSQL); - final DatabasePasswordProvider provider = getProvider(runner); - - final ProcessException exception = assertThrows(ProcessException.class, - () -> provider.getPassword(requestContext( - "jdbc:mysql://example:3306/database?sslMode=REQUIRED&sslMode=DISABLED", - MYSQL_DATABASE_USER, - MYSQL_DRIVER_CLASS, - Map.of() - ))); - - assertEquals("MySQL sslMode must be configured as REQUIRED, VERIFY_CA, or VERIFY_IDENTITY for Cloud SQL IAM authentication", exception.getMessage()); - assertEquals(0, scopedCredentials.getRefreshAccessTokenCount()); - } - - @Test - void testMySqlDuplicateUrlSslModeLastSecureValueAccepted() throws Exception { - final TestScopedGoogleCredentials scopedCredentials = new TestScopedGoogleCredentials(accessToken(TOKEN_VALUE, 15)); - final TestRunner runner = configureRunner(new RootGoogleCredentials(scopedCredentials), true, CloudSqlDatabaseType.MYSQL); - final DatabasePasswordProvider provider = getProvider(runner); - - final char[] password = provider.getPassword(requestContext( - "jdbc:mysql://example:3306/database?sslMode=PREFERRED&sslMode=VERIFY_CA", - MYSQL_DATABASE_USER, - MYSQL_DRIVER_CLASS, - Map.of() - )); - - assertArrayEquals(TOKEN_VALUE.toCharArray(), password); - } - - @Test - void testMySqlExactConnectionPropertySslModeOverridesMissingJdbcUrlSslMode() throws Exception { - final TestScopedGoogleCredentials scopedCredentials = new TestScopedGoogleCredentials(accessToken(TOKEN_VALUE, 15)); - final TestRunner runner = configureRunner(new RootGoogleCredentials(scopedCredentials), true, CloudSqlDatabaseType.MYSQL); - final DatabasePasswordProvider provider = getProvider(runner); - - final char[] password = provider.getPassword(requestContext( - "jdbc:mysql://example:3306/database", - MYSQL_DATABASE_USER, - MYSQL_DRIVER_CLASS, - Map.of("sslMode", "VERIFY_CA") - )); - - assertArrayEquals(TOKEN_VALUE.toCharArray(), password); - } - - @Test - void testMissingMySqlSslModeRejectedBeforeRefresh() throws Exception { - final TestScopedGoogleCredentials scopedCredentials = new TestScopedGoogleCredentials(accessToken(TOKEN_VALUE, -15)); - scopedCredentials.setRefreshedAccessToken(accessToken(REFRESHED_TOKEN_VALUE, 15)); - final TestRunner runner = configureRunner(new RootGoogleCredentials(scopedCredentials), true, CloudSqlDatabaseType.MYSQL); - final DatabasePasswordProvider provider = getProvider(runner); - - final ProcessException exception = assertThrows(ProcessException.class, - () -> provider.getPassword(requestContext("jdbc:mysql://example:3306/database", MYSQL_DATABASE_USER, MYSQL_DRIVER_CLASS, Map.of()))); - - assertEquals("MySQL sslMode must be configured as REQUIRED, VERIFY_CA, or VERIFY_IDENTITY for Cloud SQL IAM authentication", exception.getMessage()); - assertEquals(0, scopedCredentials.getRefreshAccessTokenCount()); - } - - @ParameterizedTest(name = "rejected MySQL sslMode case {0}") - @MethodSource("rejectedMySqlSslModeContexts") - void testRejectedMySqlSslModes(final String jdbcUrl, final Map connectionProperties) throws Exception { - final TestScopedGoogleCredentials scopedCredentials = new TestScopedGoogleCredentials(accessToken(TOKEN_VALUE, -15)); - scopedCredentials.setRefreshedAccessToken(accessToken(REFRESHED_TOKEN_VALUE, 15)); - final TestRunner runner = configureRunner(new RootGoogleCredentials(scopedCredentials), true, CloudSqlDatabaseType.MYSQL); - final DatabasePasswordProvider provider = getProvider(runner); - - final ProcessException exception = assertThrows(ProcessException.class, - () -> provider.getPassword(requestContext(jdbcUrl, MYSQL_DATABASE_USER, MYSQL_DRIVER_CLASS, connectionProperties))); - - assertEquals("MySQL sslMode must be configured as REQUIRED, VERIFY_CA, or VERIFY_IDENTITY for Cloud SQL IAM authentication", exception.getMessage()); - assertEquals(0, scopedCredentials.getRefreshAccessTokenCount()); - } - - @Test - void testMalformedUrlEncodedMySqlSslModeValueRejectedBeforeRefresh() throws Exception { - final TestScopedGoogleCredentials scopedCredentials = new TestScopedGoogleCredentials(accessToken(TOKEN_VALUE, -15)); - scopedCredentials.setRefreshedAccessToken(accessToken(REFRESHED_TOKEN_VALUE, 15)); - final TestRunner runner = configureRunner(new RootGoogleCredentials(scopedCredentials), true, CloudSqlDatabaseType.MYSQL); - final DatabasePasswordProvider provider = getProvider(runner); - - final ProcessException exception = assertThrows(ProcessException.class, - () -> provider.getPassword(requestContext( - "jdbc:mysql://example:3306/database?sslMode=%GG", - MYSQL_DATABASE_USER, - MYSQL_DRIVER_CLASS, - Map.of("sslMode", "REQUIRED") - ))); - - assertEquals(MALFORMED_MYSQL_JDBC_URL_MESSAGE, exception.getMessage()); - assertNull(exception.getCause()); - assertEquals(0, scopedCredentials.getRefreshAccessTokenCount()); - } - - @Test - void testMalformedUrlEncodedMySqlPropertyNameRejectedBeforeRefresh() throws Exception { - final TestScopedGoogleCredentials scopedCredentials = new TestScopedGoogleCredentials(accessToken(TOKEN_VALUE, -15)); - scopedCredentials.setRefreshedAccessToken(accessToken(REFRESHED_TOKEN_VALUE, 15)); - final TestRunner runner = configureRunner(new RootGoogleCredentials(scopedCredentials), true, CloudSqlDatabaseType.MYSQL); - final DatabasePasswordProvider provider = getProvider(runner); - - final ProcessException exception = assertThrows(ProcessException.class, - () -> provider.getPassword(requestContext( - "jdbc:mysql://example:3306/database?sslMo%G=REQUIRED", - MYSQL_DATABASE_USER, - MYSQL_DRIVER_CLASS, - Map.of() - ))); - - assertEquals(MALFORMED_MYSQL_JDBC_URL_MESSAGE, exception.getMessage()); - assertNull(exception.getCause()); - assertEquals(0, scopedCredentials.getRefreshAccessTokenCount()); - } - - @ParameterizedTest(name = "rejected MySQL driver/url case {0}") - @MethodSource("rejectedMySqlDriverAndUrlContexts") - void testRejectedMySqlDriverAndJdbcUrlBeforeRefresh(final String jdbcUrl, - final String driverClassName, - final String expectedMessage) throws Exception { - final TestScopedGoogleCredentials scopedCredentials = new TestScopedGoogleCredentials(accessToken(TOKEN_VALUE, -15)); - scopedCredentials.setRefreshedAccessToken(accessToken(REFRESHED_TOKEN_VALUE, 15)); - final TestRunner runner = configureRunner(new RootGoogleCredentials(scopedCredentials), true, CloudSqlDatabaseType.MYSQL); - final DatabasePasswordProvider provider = getProvider(runner); - - final ProcessException exception = assertThrows(ProcessException.class, - () -> provider.getPassword(requestContext(jdbcUrl, MYSQL_DATABASE_USER, driverClassName, Map.of("sslMode", "REQUIRED")))); - - assertEquals(expectedMessage, exception.getMessage()); - assertEquals(0, scopedCredentials.getRefreshAccessTokenCount()); - } - - @ParameterizedTest(name = "rejected MySQL URL credentials case {0}") - @MethodSource("rejectedMySqlUrlCredentialContexts") - void testMySqlUrlCredentialsRejectedBeforeRefresh(final String jdbcUrl, - final String expectedMessage) throws Exception { - final TestScopedGoogleCredentials scopedCredentials = new TestScopedGoogleCredentials(accessToken(TOKEN_VALUE, -15)); - scopedCredentials.setRefreshedAccessToken(accessToken(REFRESHED_TOKEN_VALUE, 15)); - final TestRunner runner = configureRunner(new RootGoogleCredentials(scopedCredentials), true, CloudSqlDatabaseType.MYSQL); - final DatabasePasswordProvider provider = getProvider(runner); - - final ProcessException exception = assertThrows(ProcessException.class, - () -> provider.getPassword(requestContext(jdbcUrl, MYSQL_DATABASE_USER, MYSQL_DRIVER_CLASS, Map.of("sslMode", "REQUIRED")))); - - assertEquals(expectedMessage, exception.getMessage()); - assertEquals(0, scopedCredentials.getRefreshAccessTokenCount()); - } - - @Test - void testMySqlDisabledClearPasswordPluginInConnectionPropertiesRejectedBeforeRefresh() throws Exception { - final TestScopedGoogleCredentials scopedCredentials = new TestScopedGoogleCredentials(accessToken(TOKEN_VALUE, -15)); - scopedCredentials.setRefreshedAccessToken(accessToken(REFRESHED_TOKEN_VALUE, 15)); - final TestRunner runner = configureRunner(new RootGoogleCredentials(scopedCredentials), true, CloudSqlDatabaseType.MYSQL); - final DatabasePasswordProvider provider = getProvider(runner); - - final ProcessException exception = assertThrows(ProcessException.class, - () -> provider.getPassword(requestContext( - MYSQL_JDBC_URL, - MYSQL_DATABASE_USER, - MYSQL_DRIVER_CLASS, - Map.of("disabledAuthenticationPlugins", " sha256_password , MYSQL_CLEAR_PASSWORD ") - ))); - - assertEquals("MySQL disabledAuthenticationPlugins must not disable the clear-password authentication plugin required for Cloud SQL IAM authentication", exception.getMessage()); - assertEquals(0, scopedCredentials.getRefreshAccessTokenCount()); - } - - @Test - void testMySqlDisabledClearPasswordPluginRejectedWhenConfiguredOnlyInJdbcUrl() throws Exception { - final TestScopedGoogleCredentials scopedCredentials = new TestScopedGoogleCredentials(accessToken(TOKEN_VALUE, -15)); - scopedCredentials.setRefreshedAccessToken(accessToken(REFRESHED_TOKEN_VALUE, 15)); - final TestRunner runner = configureRunner(new RootGoogleCredentials(scopedCredentials), true, CloudSqlDatabaseType.MYSQL); - final DatabasePasswordProvider provider = getProvider(runner); - - final ProcessException exception = assertThrows(ProcessException.class, - () -> provider.getPassword(requestContext( - "jdbc:mysql://example:3306/database?sslMode=REQUIRED&disabledAuthenticationPlugins=mysql_clear_password", - MYSQL_DATABASE_USER, - MYSQL_DRIVER_CLASS, - Map.of() - ))); - - assertEquals("MySQL disabledAuthenticationPlugins must not disable the clear-password authentication plugin required for Cloud SQL IAM authentication", exception.getMessage()); - assertEquals(0, scopedCredentials.getRefreshAccessTokenCount()); - } - - @Test - void testMySqlDisabledClearPasswordPluginInJdbcUrlRejectedBeforeRefresh() throws Exception { - final TestScopedGoogleCredentials scopedCredentials = new TestScopedGoogleCredentials(accessToken(TOKEN_VALUE, -15)); - scopedCredentials.setRefreshedAccessToken(accessToken(REFRESHED_TOKEN_VALUE, 15)); - final TestRunner runner = configureRunner(new RootGoogleCredentials(scopedCredentials), true, CloudSqlDatabaseType.MYSQL); - final DatabasePasswordProvider provider = getProvider(runner); - - final ProcessException exception = assertThrows(ProcessException.class, - () -> provider.getPassword(requestContext( - "jdbc:mysql://example:3306/database?sslMode=REQUIRED&disabledAuthenticationPlugins=com.mysql.cj.protocol.a.authentication.MysqlClearPasswordPlugin", - MYSQL_DATABASE_USER, - MYSQL_DRIVER_CLASS, - Map.of("disabledAuthenticationPlugins", "sha256_password") - ))); - - assertEquals("MySQL disabledAuthenticationPlugins must not disable the clear-password authentication plugin required for Cloud SQL IAM authentication", exception.getMessage()); - assertEquals(0, scopedCredentials.getRefreshAccessTokenCount()); - } - - @ParameterizedTest(name = "rejected MySQL legacy TLS property {0}") - @MethodSource("rejectedMySqlLegacyTlsPropertyContexts") - void testMySqlLegacyTlsPropertiesRejectedBeforeRefresh(final String jdbcUrl, - final Map connectionProperties) throws Exception { - final TestScopedGoogleCredentials scopedCredentials = new TestScopedGoogleCredentials(accessToken(TOKEN_VALUE, -15)); - scopedCredentials.setRefreshedAccessToken(accessToken(REFRESHED_TOKEN_VALUE, 15)); - final TestRunner runner = configureRunner(new RootGoogleCredentials(scopedCredentials), true, CloudSqlDatabaseType.MYSQL); - final DatabasePasswordProvider provider = getProvider(runner); - - final ProcessException exception = assertThrows(ProcessException.class, - () -> provider.getPassword(requestContext(jdbcUrl, MYSQL_DATABASE_USER, MYSQL_DRIVER_CLASS, connectionProperties))); - - assertEquals("MySQL legacy TLS properties useSSL, requireSSL, and verifyServerCertificate are not supported for Cloud SQL IAM authentication", exception.getMessage()); - assertEquals(0, scopedCredentials.getRefreshAccessTokenCount()); - } - - @Test - void testMySqlConnectionPropertyUserRejectedBeforeRefresh() throws Exception { - final TestScopedGoogleCredentials scopedCredentials = new TestScopedGoogleCredentials(accessToken(TOKEN_VALUE, -15)); - scopedCredentials.setRefreshedAccessToken(accessToken(REFRESHED_TOKEN_VALUE, 15)); - final TestRunner runner = configureRunner(new RootGoogleCredentials(scopedCredentials), true, CloudSqlDatabaseType.MYSQL); - final DatabasePasswordProvider provider = getProvider(runner); - - final ProcessException exception = assertThrows(ProcessException.class, - () -> provider.getPassword(requestContext( - MYSQL_JDBC_URL, - MYSQL_DATABASE_USER, - MYSQL_DRIVER_CLASS, - Map.of("user", "override-user") - ))); - - assertEquals("MySQL DBCP connection properties must not define user for Cloud SQL IAM authentication", exception.getMessage()); - assertEquals(0, scopedCredentials.getRefreshAccessTokenCount()); - } - - @Test - void testNullAccessTokenRejected() throws Exception { + void testNullAccessTokenRejectedForPasswordGeneration() throws Exception { final TestScopedGoogleCredentials scopedCredentials = new TestScopedGoogleCredentials(null); scopedCredentials.setRefreshedAccessToken(null); - final TestRunner runner = configureRunner(new RootGoogleCredentials(scopedCredentials)); - final DatabasePasswordProvider provider = getProvider(runner); + final DatabasePasswordProvider provider = getProvider(configureRunner(new RootGoogleCredentials(scopedCredentials))); - final ProcessException exception = assertThrows(ProcessException.class, - () -> provider.getPassword(requestContext(JDBC_URL, DATABASE_USER, Map.of()))); + final ProcessException exception = assertThrows(ProcessException.class, () -> provider.getPassword(requestContext())); - assertEquals(FAILED_PASSWORD_MESSAGE, exception.getMessage()); + assertTrue(exception.getMessage().contains("Cloud SQL IAM")); } @Test - void testBlankAccessTokenRejected() throws Exception { + void testBlankAccessTokenRejectedForPasswordGeneration() throws Exception { final TestScopedGoogleCredentials scopedCredentials = new TestScopedGoogleCredentials(null); scopedCredentials.setRefreshedAccessToken(accessToken(" ", 15)); - final TestRunner runner = configureRunner(new RootGoogleCredentials(scopedCredentials)); - final DatabasePasswordProvider provider = getProvider(runner); + final DatabasePasswordProvider provider = getProvider(configureRunner(new RootGoogleCredentials(scopedCredentials))); - final ProcessException exception = assertThrows(ProcessException.class, - () -> provider.getPassword(requestContext(JDBC_URL, DATABASE_USER, Map.of()))); + final ProcessException exception = assertThrows(ProcessException.class, () -> provider.getPassword(requestContext())); - assertEquals(FAILED_PASSWORD_MESSAGE, exception.getMessage()); + assertTrue(exception.getMessage().contains("Cloud SQL IAM")); } @Test - void testArbitraryRefreshFailureIsSanitized() throws Exception { + void testRefreshFailureIsSanitizedForPasswordGeneration() throws Exception { final TestScopedGoogleCredentials scopedCredentials = new TestScopedGoogleCredentials(null); - scopedCredentials.setRefreshException(ioException(LEAK_SENTINEL, "org.example.CustomCredentials")); + scopedCredentials.setRefreshException(ioException(LEAK_SENTINEL)); final TestRunner runner = configureRunner(new RootGoogleCredentials(scopedCredentials)); final DatabasePasswordProvider provider = getProvider(runner); - final ProcessException exception = assertThrows(ProcessException.class, - () -> provider.getPassword(requestContext(JDBC_URL, DATABASE_USER, Map.of()))); + final ProcessException exception = assertThrows(ProcessException.class, () -> provider.getPassword(requestContext())); - assertEquals(FAILED_PASSWORD_MESSAGE, exception.getMessage()); + assertTrue(exception.getMessage().contains("Cloud SQL IAM")); assertNull(exception.getCause()); - assertFalse(exception.getMessage().contains(LEAK_SENTINEL)); assertNoLogMessagesContain(runner.getControllerServiceLogger(PASSWORD_PROVIDER_ID), LEAK_SENTINEL); } @Test - void testUnknownGoogleAuthRefreshMessageIsSanitized() throws Exception { - final TestScopedGoogleCredentials scopedCredentials = new TestScopedGoogleCredentials(null); - final IOException ioException = ioException(LEAK_SENTINEL, "com.google.auth.oauth2.ImpersonatedCredentials"); - scopedCredentials.setRefreshException(ioException); - final TestRunner runner = configureRunner(new RootGoogleCredentials(scopedCredentials)); - final DatabasePasswordProvider provider = getProvider(runner); + void testIdentityPoolCredentialsFailClosedAtRuntime() throws Exception { + final GcpCloudSqlIamDatabasePasswordProvider provider = getProviderImplementation( + configureRunner(new RootGoogleCredentials(new TestScopedGoogleCredentials(accessToken(TOKEN_VALUE, 15))))); - final ProcessException exception = assertThrows(ProcessException.class, - () -> provider.getPassword(requestContext(JDBC_URL, DATABASE_USER, Map.of()))); + setScopedCredentials(provider, identityPoolCredentials(accessToken(TOKEN_VALUE, 15))); - assertEquals(FAILED_PASSWORD_MESSAGE, exception.getMessage()); - assertNull(exception.getCause()); - } + final ProcessException exception = assertThrows(ProcessException.class, () -> provider.getPassword(requestContext())); - @Test - void testGoogleAuthRefreshFailurePreservesCause() throws Exception { - final TestScopedGoogleCredentials scopedCredentials = new TestScopedGoogleCredentials(null); - final IOException ioException = ioException("Error requesting access token", "com.google.auth.oauth2.ImpersonatedCredentials"); - scopedCredentials.setRefreshException(ioException); - final TestRunner runner = configureRunner(new RootGoogleCredentials(scopedCredentials)); - final DatabasePasswordProvider provider = getProvider(runner); - - final ProcessException exception = assertThrows(ProcessException.class, - () -> provider.getPassword(requestContext(JDBC_URL, DATABASE_USER, Map.of()))); - - assertEquals(FAILED_PASSWORD_MESSAGE, exception.getMessage()); - assertSame(ioException, exception.getCause()); + assertTrue(exception.getMessage().contains("Cloud SQL IAM")); } @Test void testGetPasswordReturnsFreshCharacterArrayEachCall() throws Exception { - final TestScopedGoogleCredentials scopedCredentials = new TestScopedGoogleCredentials(accessToken(TOKEN_VALUE, 15)); - final TestRunner runner = configureRunner(new RootGoogleCredentials(scopedCredentials)); - final DatabasePasswordProvider provider = getProvider(runner); + final DatabasePasswordProvider provider = getProvider(configureRunner( + new RootGoogleCredentials(new TestScopedGoogleCredentials(accessToken(TOKEN_VALUE, 15))))); - final char[] firstPassword = provider.getPassword(requestContext(JDBC_URL, DATABASE_USER, Map.of())); + final char[] firstPassword = provider.getPassword(requestContext()); firstPassword[0] = 'X'; - final char[] secondPassword = provider.getPassword(requestContext(JDBC_URL, DATABASE_USER, Map.of())); + final char[] secondPassword = provider.getPassword(requestContext()); assertNotSame(firstPassword, secondPassword); assertArrayEquals(TOKEN_VALUE.toCharArray(), secondPassword); @@ -1160,121 +430,23 @@ void testAdditionalDetailsResourceDocumentsSupportedPath() throws IOException { try (InputStream inputStream = GcpCloudSqlIamDatabasePasswordProvider.class.getClassLoader().getResourceAsStream(resourcePath)) { assertNotNull(inputStream); final String additionalDetails = new String(inputStream.readAllBytes(), StandardCharsets.UTF_8); - assertTrue(additionalDetails.contains("Database Type")); - assertTrue(additionalDetails.contains("supports Cloud SQL for PostgreSQL and Cloud SQL for MySQL")); + assertTrue(additionalDetails.contains("Cloud SQL for PostgreSQL")); + assertTrue(additionalDetails.contains("Cloud SQL for MySQL")); assertTrue(additionalDetails.contains("roles/iam.workloadIdentityUser")); - assertTrue(additionalDetails.contains("must be configured with **Target Service Account**")); - assertTrue(additionalDetails.contains("jdbc:postgresql://:5432/?sslmode=require")); - assertTrue(additionalDetails.contains("jdbc:mysql://:3306/?sslMode=REQUIRED")); - assertTrue(additionalDetails.contains("com.mysql.cj.jdbc.Driver")); - assertTrue(additionalDetails.contains("**Verify** checks that NiFi can obtain a token")); - assertTrue(additionalDetails.contains("DBCP **Verify** checks the")); + assertTrue(additionalDetails.contains("sslmode=require")); + assertTrue(additionalDetails.contains("sslMode=REQUIRED")); + assertTrue(additionalDetails.contains("DBCP **Verify**")); + assertFalse(additionalDetails.contains("Database Type")); + assertFalse(additionalDetails.contains("disabledAuthenticationPlugins")); + assertFalse(additionalDetails.contains("useSSL")); } } - private static Stream acceptedUrlSslModes() { - return Stream.of( - Arguments.of("prefer"), - Arguments.of("require"), - Arguments.of("verify-ca"), - Arguments.of("verify-full") - ); - } - - private static Stream acceptedPropertySslModes() { - return acceptedUrlSslModes(); - } - - private static Stream acceptedMySqlUrlSslModes() { - return Stream.of( - Arguments.of("REQUIRED"), - Arguments.of("VERIFY_CA"), - Arguments.of("VERIFY_IDENTITY") - ); - } - - private static Stream acceptedMySqlPropertySslModes() { - return acceptedMySqlUrlSslModes(); - } - - private static Stream rejectedSslModeContexts() { - return Stream.of( - Arguments.of("jdbc:postgresql://example:5432/database?sslmode=disable", Map.of("sslmode", "require"), "disable"), - Arguments.of("jdbc:postgresql://example:5432/database?sslmode=allow", Map.of("sslmode", "verify-full"), "allow"), - Arguments.of("jdbc:postgresql://example:5432/database", Map.of("sslmode", "disable"), "disable"), - Arguments.of("jdbc:postgresql://example:5432/database", Map.of("sslmode", "allow"), "allow") - ); - } - - private static Stream rejectedMySqlSslModeContexts() { - return Stream.of( - Arguments.of("jdbc:mysql://example:3306/database?sslMode=REQUIRED", Map.of("sslMode", "DISABLED")), - Arguments.of("jdbc:mysql://example:3306/database?sslMode=VERIFY_CA", Map.of("sslMode", "PREFERRED")), - Arguments.of("jdbc:mysql://example:3306/database", Map.of("sslMode", "DISABLED")), - Arguments.of("jdbc:mysql://example:3306/database", Map.of("sslMode", "PREFERRED")) - ); - } - - private static Stream rejectedMySqlDriverAndUrlContexts() { - return Stream.of( - Arguments.of(MYSQL_JDBC_URL, POSTGRES_DRIVER_CLASS, - "MySQL driver class must be configured as com.mysql.cj.jdbc.Driver for Cloud SQL IAM authentication"), - Arguments.of("jdbc:mariadb://example:3306/database?sslMode=REQUIRED", MYSQL_DRIVER_CLASS, - "MySQL JDBC URL must use the standard single-host jdbc:mysql:// format for Cloud SQL IAM authentication"), - Arguments.of("jdbc:mysql://example:3306,demo:3307/database?sslMode=REQUIRED", MYSQL_DRIVER_CLASS, - "MySQL JDBC URL must use the standard single-host jdbc:mysql:// format for Cloud SQL IAM authentication") - ); - } - - private static Stream rejectedMySqlLegacyTlsPropertyContexts() { - return Stream.of( - Arguments.of("jdbc:mysql://example:3306/database?sslMode=REQUIRED&useSSL=false", Map.of()), - Arguments.of("jdbc:mysql://example:3306/database?sslMode=REQUIRED&requireSSL=true", Map.of()), - Arguments.of("jdbc:mysql://example:3306/database?sslMode=REQUIRED&verifyServerCertificate=false", Map.of()), - Arguments.of("jdbc:mysql://example:3306/database?sslMode=REQUIRED", Map.of("useSSL", "false")), - Arguments.of("jdbc:mysql://example:3306/database?sslMode=REQUIRED", Map.of("requireSSL", "true")), - Arguments.of("jdbc:mysql://example:3306/database?sslMode=REQUIRED", Map.of("verifyServerCertificate", "false")) - ); - } - - private static Stream rejectedMySqlUrlCredentialContexts() { - return Stream.of( - Arguments.of("jdbc:mysql://iam-user@example:3306/database?sslMode=REQUIRED", - "MySQL JDBC URL must not define user or password for Cloud SQL IAM authentication"), - Arguments.of("jdbc:mysql://example:3306/database?sslMode=REQUIRED&user=override-user", - "MySQL JDBC URL must not define user or password for Cloud SQL IAM authentication"), - Arguments.of("jdbc:mysql://example:3306/database?sslMode=REQUIRED&password=override-password", - "MySQL JDBC URL must not define user or password for Cloud SQL IAM authentication") - ); - } - - private static Stream verifySuccessContexts() throws IOException { - return Stream.of( - Arguments.of( - CloudSqlDatabaseType.POSTGRESQL, - new TestScopedGoogleCredentials(accessToken(TOKEN_VALUE, 15)), - "created a Cloud SQL scoped TestScopedGoogleCredentials instance.", - "Cloud SQL IAM token acquisition for the current principal" - ), - Arguments.of( - CloudSqlDatabaseType.MYSQL, - new TestScopedGoogleCredentials(accessToken(TOKEN_VALUE, 15)), - "created a Cloud SQL scoped TestScopedGoogleCredentials instance.", - "Cloud SQL IAM token acquisition for the current principal" - ) - ); - } - private TestRunner configureRunner(final GoogleCredentials rootCredentials) throws Exception { return configureRunner(rootCredentials, true); } private TestRunner configureRunner(final GoogleCredentials rootCredentials, final boolean enableProvider) throws Exception { - return configureRunner(rootCredentials, enableProvider, null); - } - - private TestRunner configureRunner(final GoogleCredentials rootCredentials, final boolean enableProvider, - final CloudSqlDatabaseType databaseType) throws Exception { final TestRunner runner = TestRunners.newTestRunner(NoOpProcessor.class); final TestGCPCredentialsService credentialsService = new TestGCPCredentialsService(rootCredentials); @@ -1284,9 +456,6 @@ private TestRunner configureRunner(final GoogleCredentials rootCredentials, fina final GcpCloudSqlIamDatabasePasswordProvider provider = new GcpCloudSqlIamDatabasePasswordProvider(); runner.addControllerService(PASSWORD_PROVIDER_ID, provider); runner.setProperty(provider, GCP_CREDENTIALS_PROVIDER_SERVICE, CREDENTIALS_SERVICE_ID); - if (databaseType != null) { - runner.setProperty(provider, DATABASE_TYPE, databaseType.getValue()); - } if (enableProvider) { runner.enableControllerService(provider); runner.assertValid(provider); @@ -1305,33 +474,26 @@ private GcpCloudSqlIamDatabasePasswordProvider getProviderImplementation(final T return (GcpCloudSqlIamDatabasePasswordProvider) getProvider(runner); } - private DatabasePasswordRequestContext requestContext(final String jdbcUrl, final String databaseUser, final Map connectionProperties) { - return requestContext(jdbcUrl, databaseUser, POSTGRES_DRIVER_CLASS, connectionProperties); - } - - private DatabasePasswordRequestContext requestContext(final String jdbcUrl, final String databaseUser, - final String driverClassName, final Map connectionProperties) { + private DatabasePasswordRequestContext requestContext() { return DatabasePasswordRequestContext.builder() - .jdbcUrl(jdbcUrl) - .databaseUser(databaseUser) - .driverClassName(driverClassName) - .connectionProperties(connectionProperties) + .jdbcUrl(JDBC_URL) + .databaseUser(DATABASE_USER) + .driverClassName(DRIVER_CLASS) + .connectionProperties(Map.of()) .build(); } private char[] getPasswordAfterStart(final DatabasePasswordProvider provider, final CountDownLatch startLatch) throws InterruptedException { startLatch.await(5, TimeUnit.SECONDS); - return provider.getPassword(requestContext(JDBC_URL, DATABASE_USER, Map.of())); + return provider.getPassword(requestContext()); } private static AccessToken accessToken(final String tokenValue, final long offsetMinutes) { return tokenValue == null ? null : new AccessToken(tokenValue, java.util.Date.from(Instant.now().plusSeconds(offsetMinutes * 60))); } - private static IOException ioException(final String message, final String className) { - final IOException ioException = new IOException(message); - ioException.setStackTrace(new StackTraceElement[]{new StackTraceElement(className, "refreshAccessToken", "Source.java", 1)}); - return ioException; + private static IOException ioException(final String message) { + return new IOException(message); } private static void assertVerificationResult(final ConfigVerificationResult result, final String stepName, @@ -1384,22 +546,19 @@ private static GoogleCredentials getScopedCredentials(final GcpCloudSqlIamDataba return (GoogleCredentials) field.get(provider); } - private static CloudSqlDatabaseType getDatabaseType(final GcpCloudSqlIamDatabasePasswordProvider provider) throws ReflectiveOperationException { - final Field field = GcpCloudSqlIamDatabasePasswordProvider.class.getDeclaredField("databaseType"); - field.setAccessible(true); - return (CloudSqlDatabaseType) field.get(provider); - } - - private static void setScopedCredentials(final GcpCloudSqlIamDatabasePasswordProvider provider, final GoogleCredentials credentials) throws ReflectiveOperationException { + private static void setScopedCredentials(final GcpCloudSqlIamDatabasePasswordProvider provider, final GoogleCredentials credentials) + throws ReflectiveOperationException { final Field field = GcpCloudSqlIamDatabasePasswordProvider.class.getDeclaredField("scopedCredentials"); field.setAccessible(true); field.set(provider, credentials); } - private static void setDatabaseType(final GcpCloudSqlIamDatabasePasswordProvider provider, final CloudSqlDatabaseType configuredDatabaseType) throws ReflectiveOperationException { - final Field field = GcpCloudSqlIamDatabasePasswordProvider.class.getDeclaredField("databaseType"); - field.setAccessible(true); - field.set(provider, configuredDatabaseType); + @SuppressWarnings("unchecked") + private static List getSupportedPropertyDescriptors(final GcpCloudSqlIamDatabasePasswordProvider provider) + throws ReflectiveOperationException { + final Method method = GcpCloudSqlIamDatabasePasswordProvider.class.getDeclaredMethod("getSupportedPropertyDescriptors"); + method.setAccessible(true); + return (List) method.invoke(provider); } private static final class TestGCPCredentialsService extends AbstractControllerService implements GCPCredentialsService { @@ -1462,7 +621,6 @@ private static class TestScopedGoogleCredentials extends GoogleCredentials { private final AtomicInteger refreshAccessTokenCount = new AtomicInteger(); private volatile AccessToken refreshedAccessToken; private volatile IOException refreshException; - private volatile RuntimeException runtimeException; private TestScopedGoogleCredentials(final AccessToken initialAccessToken) { super(initialAccessToken); @@ -1474,9 +632,6 @@ public AccessToken refreshAccessToken() throws IOException { if (refreshException != null) { throw refreshException; } - if (runtimeException != null) { - throw runtimeException; - } return refreshedAccessToken; } @@ -1488,10 +643,6 @@ protected void setRefreshException(final IOException refreshException) { this.refreshException = refreshException; } - protected void setRuntimeException(final RuntimeException runtimeException) { - this.runtimeException = runtimeException; - } - protected int getRefreshAccessTokenCount() { return refreshAccessTokenCount.get(); } @@ -1509,12 +660,6 @@ private static ImpersonatedCredentials impersonatedCredentials(final IOException return credentials; } - private static ImpersonatedCredentials impersonatedCredentials(final RuntimeException exception) throws IOException { - final ImpersonatedCredentials credentials = mock(ImpersonatedCredentials.class); - when(credentials.refreshAccessToken()).thenThrow(exception); - return credentials; - } - private static IdentityPoolCredentials identityPoolCredentials(final AccessToken accessToken) throws IOException { final IdentityPoolCredentials credentials = mock(IdentityPoolCredentials.class); when(credentials.refreshAccessToken()).thenReturn(accessToken); From a667dd31e3d95da428fa3a7e9a810425f7f813e5 Mon Sep 17 00:00:00 2001 From: Pierre Villard Date: Thu, 3 Sep 2026 09:56:12 +0200 Subject: [PATCH 3/4] review --- ...cpCloudSqlIamDatabasePasswordProvider.java | 60 +++++++------------ ...oudSqlIamDatabasePasswordProviderTest.java | 41 ++++--------- 2 files changed, 31 insertions(+), 70 deletions(-) diff --git a/nifi-extension-bundles/nifi-gcp-bundle/nifi-gcp-processors/src/main/java/org/apache/nifi/processors/gcp/cloudsql/GcpCloudSqlIamDatabasePasswordProvider.java b/nifi-extension-bundles/nifi-gcp-bundle/nifi-gcp-processors/src/main/java/org/apache/nifi/processors/gcp/cloudsql/GcpCloudSqlIamDatabasePasswordProvider.java index 857337456c39..824d24f83013 100644 --- a/nifi-extension-bundles/nifi-gcp-bundle/nifi-gcp-processors/src/main/java/org/apache/nifi/processors/gcp/cloudsql/GcpCloudSqlIamDatabasePasswordProvider.java +++ b/nifi-extension-bundles/nifi-gcp-bundle/nifi-gcp-processors/src/main/java/org/apache/nifi/processors/gcp/cloudsql/GcpCloudSqlIamDatabasePasswordProvider.java @@ -60,7 +60,6 @@ public class GcpCloudSqlIamDatabasePasswordProvider extends AbstractControllerSe static final String VERIFY_SCOPED_CREDENTIALS_UNAVAILABLE = "Failed to apply the Cloud SQL login scope to the configured Google credentials."; static final String VERIFY_IMPERSONATION_REQUIRED = "Target service account impersonation is required for Workload Identity Federation Cloud SQL authentication."; static final String VERIFY_TOKEN_ACQUISITION_FAILED = "Failed to acquire a Cloud SQL IAM access token."; - static final String VERIFY_TOKEN_MISSING = "Cloud SQL IAM access token was empty."; static final PropertyDescriptor GCP_CREDENTIALS_PROVIDER_SERVICE = new PropertyDescriptor.Builder() .name("GCP Credentials Provider Service") @@ -113,55 +112,36 @@ public char[] getPassword(final DatabasePasswordRequestContext requestContext) { public List verify(final ConfigurationContext context, final ComponentLog verificationLogger, final Map attributes) { final List results = new ArrayList<>(2); - - final GoogleCredentials googleCredentials; - - try { - googleCredentials = resolveGoogleCredentials(context); - } catch (final RuntimeException e) { - results.add(buildVerificationResult(VERIFY_SCOPE_STEP, Outcome.FAILED, VERIFY_CREDENTIALS_UNAVAILABLE)); - return results; - } - - if (googleCredentials == null) { + final GoogleCredentials scopedVerificationCredentials = resolveVerificationCredentials(context); + if (scopedVerificationCredentials == null) { results.add(buildVerificationResult(VERIFY_SCOPE_STEP, Outcome.FAILED, VERIFY_CREDENTIALS_UNAVAILABLE)); - return results; + } else { + final ConfigVerificationResult scopedCredentialResult = describeScopedCredential(scopedVerificationCredentials); + results.add(scopedCredentialResult); + if (scopedCredentialResult.getOutcome() == Outcome.SUCCESSFUL) { + results.add(verifyAccessToken(scopedVerificationCredentials)); + } } + return results; + } - final GoogleCredentials scopedVerificationCredentials; + private GoogleCredentials resolveVerificationCredentials(final ConfigurationContext context) { try { - scopedVerificationCredentials = createSqlLoginScopedCredentials(googleCredentials); + return createSqlLoginScopedCredentials(resolveGoogleCredentials(context)); } catch (final RuntimeException e) { - results.add(buildVerificationResult(VERIFY_SCOPE_STEP, Outcome.FAILED, VERIFY_SCOPED_CREDENTIALS_UNAVAILABLE)); - return results; - } - - if (scopedVerificationCredentials == null) { - results.add(buildVerificationResult(VERIFY_SCOPE_STEP, Outcome.FAILED, VERIFY_SCOPED_CREDENTIALS_UNAVAILABLE)); - return results; - } - - final ConfigVerificationResult scopedCredentialResult = describeScopedCredential(scopedVerificationCredentials); - results.add(scopedCredentialResult); - if (scopedCredentialResult.getOutcome() == Outcome.FAILED) { - return results; + return null; } + } - final AccessToken accessToken; + private ConfigVerificationResult verifyAccessToken(final GoogleCredentials credentials) { try { - accessToken = scopedVerificationCredentials.refreshAccessToken(); + final AccessToken accessToken = credentials.refreshAccessToken(); + return hasTokenValue(accessToken) + ? buildTokenVerificationResult() + : buildVerificationResult(VERIFY_TOKEN_STEP, Outcome.FAILED, VERIFY_TOKEN_ACQUISITION_FAILED); } catch (final IOException | RuntimeException e) { - results.add(buildVerificationResult(VERIFY_TOKEN_STEP, Outcome.FAILED, VERIFY_TOKEN_ACQUISITION_FAILED)); - return results; - } - - if (!hasTokenValue(accessToken)) { - results.add(buildVerificationResult(VERIFY_TOKEN_STEP, Outcome.FAILED, VERIFY_TOKEN_MISSING)); - return results; + return buildVerificationResult(VERIFY_TOKEN_STEP, Outcome.FAILED, VERIFY_TOKEN_ACQUISITION_FAILED); } - - results.add(buildTokenVerificationResult()); - return results; } private GoogleCredentials requireScopedCredentials(final ConfigurationContext context) throws InitializationException { diff --git a/nifi-extension-bundles/nifi-gcp-bundle/nifi-gcp-processors/src/test/java/org/apache/nifi/processors/gcp/cloudsql/GcpCloudSqlIamDatabasePasswordProviderTest.java b/nifi-extension-bundles/nifi-gcp-bundle/nifi-gcp-processors/src/test/java/org/apache/nifi/processors/gcp/cloudsql/GcpCloudSqlIamDatabasePasswordProviderTest.java index 3a8523de23a0..ad5d8804828f 100644 --- a/nifi-extension-bundles/nifi-gcp-bundle/nifi-gcp-processors/src/test/java/org/apache/nifi/processors/gcp/cloudsql/GcpCloudSqlIamDatabasePasswordProviderTest.java +++ b/nifi-extension-bundles/nifi-gcp-bundle/nifi-gcp-processors/src/test/java/org/apache/nifi/processors/gcp/cloudsql/GcpCloudSqlIamDatabasePasswordProviderTest.java @@ -64,10 +64,8 @@ import static org.apache.nifi.processors.gcp.cloudsql.GcpCloudSqlIamDatabasePasswordProvider.SQLSERVICE_LOGIN_SCOPE; import static org.apache.nifi.processors.gcp.cloudsql.GcpCloudSqlIamDatabasePasswordProvider.VERIFY_CREDENTIALS_UNAVAILABLE; import static org.apache.nifi.processors.gcp.cloudsql.GcpCloudSqlIamDatabasePasswordProvider.VERIFY_IMPERSONATION_REQUIRED; -import static org.apache.nifi.processors.gcp.cloudsql.GcpCloudSqlIamDatabasePasswordProvider.VERIFY_SCOPED_CREDENTIALS_UNAVAILABLE; import static org.apache.nifi.processors.gcp.cloudsql.GcpCloudSqlIamDatabasePasswordProvider.VERIFY_SCOPE_STEP; import static org.apache.nifi.processors.gcp.cloudsql.GcpCloudSqlIamDatabasePasswordProvider.VERIFY_TOKEN_ACQUISITION_FAILED; -import static org.apache.nifi.processors.gcp.cloudsql.GcpCloudSqlIamDatabasePasswordProvider.VERIFY_TOKEN_MISSING; import static org.apache.nifi.processors.gcp.cloudsql.GcpCloudSqlIamDatabasePasswordProvider.VERIFY_TOKEN_STEP; import static org.junit.jupiter.api.Assertions.assertArrayEquals; import static org.junit.jupiter.api.Assertions.assertEquals; @@ -91,6 +89,7 @@ class GcpCloudSqlIamDatabasePasswordProviderTest { private static final String TOKEN_VALUE = "cloud-sql-token"; private static final String REFRESHED_TOKEN_VALUE = "refreshed-cloud-sql-token"; private static final String LEAK_SENTINEL = "sentinel-token-value"; + private static final String CLOUD_SQL_IAM = "Cloud SQL IAM"; private ExecutorService executorService; @@ -136,7 +135,7 @@ void testOnDisabledClearsCachedCredential() throws Exception { final ProcessException exception = assertThrows(ProcessException.class, () -> provider.getPassword(requestContext())); - assertTrue(exception.getMessage().contains("Cloud SQL IAM")); + assertTrue(exception.getMessage().contains(CLOUD_SQL_IAM)); assertNull(exception.getCause()); } @@ -171,7 +170,7 @@ void testVerifyUsesFreshScopedCredentialWithoutMutatingEnabledState() throws Exc assertEquals(2, rootCredentials.getCreateScopedCount()); assertEquals(0, enabledScopedCredentials.getRefreshAccessTokenCount()); assertEquals(1, verificationScopedCredentials.getRefreshAccessTokenCount()); - assertVerificationResult(results.get(1), VERIFY_TOKEN_STEP, SUCCESSFUL, "Cloud SQL IAM access token"); + assertVerificationResult(results.get(1), VERIFY_TOKEN_STEP, SUCCESSFUL, CLOUD_SQL_IAM); assertArrayEquals(TOKEN_VALUE.toCharArray(), password); } @@ -241,7 +240,7 @@ void testVerifyScopedCredentialCreationReturningNullFails() throws Exception { final List results = runner.verify(provider, Map.of()); assertEquals(1, results.size()); - assertVerificationResult(results.get(0), VERIFY_SCOPE_STEP, FAILED, VERIFY_SCOPED_CREDENTIALS_UNAVAILABLE); + assertVerificationResult(results.get(0), VERIFY_SCOPE_STEP, FAILED, VERIFY_CREDENTIALS_UNAVAILABLE); assertEquals(List.of(SQLSERVICE_LOGIN_SCOPE), rootCredentials.getLastRequestedScopes()); } @@ -253,7 +252,7 @@ void testVerifyScopedCredentialCreationFailureIsReported() throws Exception { final List results = runner.verify(provider, Map.of()); assertEquals(1, results.size()); - assertVerificationResult(results.get(0), VERIFY_SCOPE_STEP, FAILED, VERIFY_SCOPED_CREDENTIALS_UNAVAILABLE); + assertVerificationResult(results.get(0), VERIFY_SCOPE_STEP, FAILED, VERIFY_CREDENTIALS_UNAVAILABLE); assertFalse(results.get(0).getExplanation().contains(LEAK_SENTINEL)); } @@ -296,7 +295,7 @@ void testVerifyNullAccessTokenFails() throws Exception { final List results = runner.verify(getProviderImplementation(runner), Map.of()); assertEquals(2, results.size()); - assertVerificationResult(results.get(1), VERIFY_TOKEN_STEP, FAILED, VERIFY_TOKEN_MISSING); + assertVerificationResult(results.get(1), VERIFY_TOKEN_STEP, FAILED, VERIFY_TOKEN_ACQUISITION_FAILED); } @Test @@ -307,7 +306,7 @@ void testVerifyBlankAccessTokenFails() throws Exception { final List results = runner.verify(getProviderImplementation(runner), Map.of()); assertEquals(2, results.size()); - assertVerificationResult(results.get(1), VERIFY_TOKEN_STEP, FAILED, VERIFY_TOKEN_MISSING); + assertVerificationResult(results.get(1), VERIFY_TOKEN_STEP, FAILED, VERIFY_TOKEN_ACQUISITION_FAILED); } @Test @@ -361,7 +360,7 @@ void testNullAccessTokenRejectedForPasswordGeneration() throws Exception { final ProcessException exception = assertThrows(ProcessException.class, () -> provider.getPassword(requestContext())); - assertTrue(exception.getMessage().contains("Cloud SQL IAM")); + assertTrue(exception.getMessage().contains(CLOUD_SQL_IAM)); } @Test @@ -372,7 +371,7 @@ void testBlankAccessTokenRejectedForPasswordGeneration() throws Exception { final ProcessException exception = assertThrows(ProcessException.class, () -> provider.getPassword(requestContext())); - assertTrue(exception.getMessage().contains("Cloud SQL IAM")); + assertTrue(exception.getMessage().contains(CLOUD_SQL_IAM)); } @Test @@ -384,7 +383,7 @@ void testRefreshFailureIsSanitizedForPasswordGeneration() throws Exception { final ProcessException exception = assertThrows(ProcessException.class, () -> provider.getPassword(requestContext())); - assertTrue(exception.getMessage().contains("Cloud SQL IAM")); + assertTrue(exception.getMessage().contains(CLOUD_SQL_IAM)); assertNull(exception.getCause()); assertNoLogMessagesContain(runner.getControllerServiceLogger(PASSWORD_PROVIDER_ID), LEAK_SENTINEL); } @@ -398,7 +397,7 @@ void testIdentityPoolCredentialsFailClosedAtRuntime() throws Exception { final ProcessException exception = assertThrows(ProcessException.class, () -> provider.getPassword(requestContext())); - assertTrue(exception.getMessage().contains("Cloud SQL IAM")); + assertTrue(exception.getMessage().contains(CLOUD_SQL_IAM)); } @Test @@ -424,24 +423,6 @@ void testControllerServiceRegistrationContainsProvider() throws IOException { } } - @Test - void testAdditionalDetailsResourceDocumentsSupportedPath() throws IOException { - final String resourcePath = "docs/%s/additionalDetails.md".formatted(GcpCloudSqlIamDatabasePasswordProvider.class.getName()); - try (InputStream inputStream = GcpCloudSqlIamDatabasePasswordProvider.class.getClassLoader().getResourceAsStream(resourcePath)) { - assertNotNull(inputStream); - final String additionalDetails = new String(inputStream.readAllBytes(), StandardCharsets.UTF_8); - assertTrue(additionalDetails.contains("Cloud SQL for PostgreSQL")); - assertTrue(additionalDetails.contains("Cloud SQL for MySQL")); - assertTrue(additionalDetails.contains("roles/iam.workloadIdentityUser")); - assertTrue(additionalDetails.contains("sslmode=require")); - assertTrue(additionalDetails.contains("sslMode=REQUIRED")); - assertTrue(additionalDetails.contains("DBCP **Verify**")); - assertFalse(additionalDetails.contains("Database Type")); - assertFalse(additionalDetails.contains("disabledAuthenticationPlugins")); - assertFalse(additionalDetails.contains("useSSL")); - } - } - private TestRunner configureRunner(final GoogleCredentials rootCredentials) throws Exception { return configureRunner(rootCredentials, true); } From 550aaa8235c9994d2d1ce4523e52bd794a81a251 Mon Sep 17 00:00:00 2001 From: Pierre Villard Date: Sat, 5 Sep 2026 22:14:23 +0200 Subject: [PATCH 4/4] review --- ...cpCloudSqlIamDatabasePasswordProvider.java | 35 ++------ .../additionalDetails.md | 12 +-- ...oudSqlIamDatabasePasswordProviderTest.java | 81 ++++++++----------- .../service/GCPCredentialsServiceTest.java | 16 ---- 4 files changed, 47 insertions(+), 97 deletions(-) diff --git a/nifi-extension-bundles/nifi-gcp-bundle/nifi-gcp-processors/src/main/java/org/apache/nifi/processors/gcp/cloudsql/GcpCloudSqlIamDatabasePasswordProvider.java b/nifi-extension-bundles/nifi-gcp-bundle/nifi-gcp-processors/src/main/java/org/apache/nifi/processors/gcp/cloudsql/GcpCloudSqlIamDatabasePasswordProvider.java index 824d24f83013..fb886529f9db 100644 --- a/nifi-extension-bundles/nifi-gcp-bundle/nifi-gcp-processors/src/main/java/org/apache/nifi/processors/gcp/cloudsql/GcpCloudSqlIamDatabasePasswordProvider.java +++ b/nifi-extension-bundles/nifi-gcp-bundle/nifi-gcp-processors/src/main/java/org/apache/nifi/processors/gcp/cloudsql/GcpCloudSqlIamDatabasePasswordProvider.java @@ -18,7 +18,6 @@ import com.google.auth.oauth2.AccessToken; import com.google.auth.oauth2.GoogleCredentials; -import com.google.auth.oauth2.IdentityPoolCredentials; import com.google.auth.oauth2.ImpersonatedCredentials; import org.apache.commons.lang3.StringUtils; import org.apache.nifi.annotation.documentation.CapabilityDescription; @@ -58,7 +57,6 @@ public class GcpCloudSqlIamDatabasePasswordProvider extends AbstractControllerSe static final String VERIFY_TOKEN_STEP = "Acquire Cloud SQL IAM access token"; static final String VERIFY_CREDENTIALS_UNAVAILABLE = "Configured GCP Credentials Provider Service did not return Google credentials."; static final String VERIFY_SCOPED_CREDENTIALS_UNAVAILABLE = "Failed to apply the Cloud SQL login scope to the configured Google credentials."; - static final String VERIFY_IMPERSONATION_REQUIRED = "Target service account impersonation is required for Workload Identity Federation Cloud SQL authentication."; static final String VERIFY_TOKEN_ACQUISITION_FAILED = "Failed to acquire a Cloud SQL IAM access token."; static final PropertyDescriptor GCP_CREDENTIALS_PROVIDER_SERVICE = new PropertyDescriptor.Builder() @@ -98,8 +96,6 @@ public char[] getPassword(final DatabasePasswordRequestContext requestContext) { throw new ProcessException(FAILED_PASSWORD_MESSAGE); } - rejectIdentityPoolCredentialsOnPasswordGeneration(credentials); - final AccessToken accessToken = refreshAccessToken(credentials); if (!hasTokenValue(accessToken)) { throw new ProcessException(FAILED_PASSWORD_MESSAGE); @@ -112,34 +108,36 @@ public char[] getPassword(final DatabasePasswordRequestContext requestContext) { public List verify(final ConfigurationContext context, final ComponentLog verificationLogger, final Map attributes) { final List results = new ArrayList<>(2); - final GoogleCredentials scopedVerificationCredentials = resolveVerificationCredentials(context); + final GoogleCredentials scopedVerificationCredentials = resolveVerificationCredentials(context, verificationLogger); if (scopedVerificationCredentials == null) { results.add(buildVerificationResult(VERIFY_SCOPE_STEP, Outcome.FAILED, VERIFY_CREDENTIALS_UNAVAILABLE)); } else { final ConfigVerificationResult scopedCredentialResult = describeScopedCredential(scopedVerificationCredentials); results.add(scopedCredentialResult); if (scopedCredentialResult.getOutcome() == Outcome.SUCCESSFUL) { - results.add(verifyAccessToken(scopedVerificationCredentials)); + results.add(verifyAccessToken(scopedVerificationCredentials, verificationLogger)); } } return results; } - private GoogleCredentials resolveVerificationCredentials(final ConfigurationContext context) { + private GoogleCredentials resolveVerificationCredentials(final ConfigurationContext context, final ComponentLog verificationLogger) { try { return createSqlLoginScopedCredentials(resolveGoogleCredentials(context)); } catch (final RuntimeException e) { + verificationLogger.error("Failed to resolve scoped Google credentials", e); return null; } } - private ConfigVerificationResult verifyAccessToken(final GoogleCredentials credentials) { + private ConfigVerificationResult verifyAccessToken(final GoogleCredentials credentials, final ComponentLog verificationLogger) { try { final AccessToken accessToken = credentials.refreshAccessToken(); return hasTokenValue(accessToken) ? buildTokenVerificationResult() : buildVerificationResult(VERIFY_TOKEN_STEP, Outcome.FAILED, VERIFY_TOKEN_ACQUISITION_FAILED); } catch (final IOException | RuntimeException e) { + verificationLogger.error("Failed to acquire Cloud SQL IAM access token", e); return buildVerificationResult(VERIFY_TOKEN_STEP, Outcome.FAILED, VERIFY_TOKEN_ACQUISITION_FAILED); } } @@ -167,7 +165,6 @@ private GoogleCredentials requireScopedCredentials(final ConfigurationContext co throw new InitializationException(VERIFY_SCOPED_CREDENTIALS_UNAVAILABLE); } - rejectIdentityPoolCredentialsOnEnable(credentials); return credentials; } @@ -199,18 +196,6 @@ private GoogleCredentials createSqlLoginScopedCredentials(final GoogleCredential return googleCredentials.createScoped(List.of(SQLSERVICE_LOGIN_SCOPE)); } - private void rejectIdentityPoolCredentialsOnEnable(final GoogleCredentials credentials) throws InitializationException { - if (credentials instanceof IdentityPoolCredentials) { - throw new InitializationException(VERIFY_IMPERSONATION_REQUIRED); - } - } - - private void rejectIdentityPoolCredentialsOnPasswordGeneration(final GoogleCredentials credentials) { - if (credentials instanceof IdentityPoolCredentials) { - throw new ProcessException(FAILED_PASSWORD_MESSAGE); - } - } - private ConfigVerificationResult describeScopedCredential(final GoogleCredentials scopedVerificationCredentials) { if (scopedVerificationCredentials instanceof ImpersonatedCredentials) { return buildVerificationResult( @@ -220,14 +205,6 @@ private ConfigVerificationResult describeScopedCredential(final GoogleCredential ); } - if (scopedVerificationCredentials instanceof IdentityPoolCredentials) { - return buildVerificationResult( - VERIFY_SCOPE_STEP, - Outcome.FAILED, - VERIFY_IMPERSONATION_REQUIRED - ); - } - return buildVerificationResult( VERIFY_SCOPE_STEP, Outcome.SUCCESSFUL, diff --git a/nifi-extension-bundles/nifi-gcp-bundle/nifi-gcp-processors/src/main/resources/docs/org.apache.nifi.processors.gcp.cloudsql.GcpCloudSqlIamDatabasePasswordProvider/additionalDetails.md b/nifi-extension-bundles/nifi-gcp-bundle/nifi-gcp-processors/src/main/resources/docs/org.apache.nifi.processors.gcp.cloudsql.GcpCloudSqlIamDatabasePasswordProvider/additionalDetails.md index 713c01abe1ca..6898fb5d2d94 100644 --- a/nifi-extension-bundles/nifi-gcp-bundle/nifi-gcp-processors/src/main/resources/docs/org.apache.nifi.processors.gcp.cloudsql.GcpCloudSqlIamDatabasePasswordProvider/additionalDetails.md +++ b/nifi-extension-bundles/nifi-gcp-bundle/nifi-gcp-processors/src/main/resources/docs/org.apache.nifi.processors.gcp.cloudsql.GcpCloudSqlIamDatabasePasswordProvider/additionalDetails.md @@ -35,10 +35,10 @@ Create the Cloud SQL IAM database user separately and grant the database privile ## Workload Identity Federation -For Workload Identity Federation, `GCPCredentialsControllerService` must be configured with **Target Service Account**. -The workload identity principal must also have `roles/iam.workloadIdentityUser` on that target service account. - -Without target service account impersonation, token acquisition for Cloud SQL IAM authentication will not succeed. +For the Snowflake Workload Identity Federation configuration tested with this provider, configure +`GCPCredentialsControllerService` with **Target Service Account**. The workload identity principal must also have +`roles/iam.workloadIdentityUser` on that target service account. Other Google credential configurations can be used +when they can acquire a token accepted by Cloud SQL for the configured database user. ## PostgreSQL Configuration @@ -84,8 +84,8 @@ actual database connection using the configured URL, driver, TLS settings, datab If provider **Verify** fails: - Confirm the referenced `GCPCredentialsControllerService` is enabled. -- For Workload Identity Federation, confirm **Target Service Account** is set and the workload identity principal has - `roles/iam.workloadIdentityUser` on that service account. +- For Workload Identity Federation using service-account impersonation, confirm **Target Service Account** is set and + the workload identity principal has `roles/iam.workloadIdentityUser` on that service account. If provider **Verify** succeeds but DBCP **Verify** fails, token acquisition is working and the problem is in the JDBC connection configuration, network path, TLS settings, driver setup, database user, or database privileges. \ No newline at end of file diff --git a/nifi-extension-bundles/nifi-gcp-bundle/nifi-gcp-processors/src/test/java/org/apache/nifi/processors/gcp/cloudsql/GcpCloudSqlIamDatabasePasswordProviderTest.java b/nifi-extension-bundles/nifi-gcp-bundle/nifi-gcp-processors/src/test/java/org/apache/nifi/processors/gcp/cloudsql/GcpCloudSqlIamDatabasePasswordProviderTest.java index ad5d8804828f..024e4b2a6eb2 100644 --- a/nifi-extension-bundles/nifi-gcp-bundle/nifi-gcp-processors/src/test/java/org/apache/nifi/processors/gcp/cloudsql/GcpCloudSqlIamDatabasePasswordProviderTest.java +++ b/nifi-extension-bundles/nifi-gcp-bundle/nifi-gcp-processors/src/test/java/org/apache/nifi/processors/gcp/cloudsql/GcpCloudSqlIamDatabasePasswordProviderTest.java @@ -47,6 +47,9 @@ import java.nio.charset.StandardCharsets; import java.time.Instant; import java.util.ArrayList; +import java.util.Collection; +import java.util.Collections; +import java.util.Date; import java.util.IdentityHashMap; import java.util.List; import java.util.Map; @@ -63,7 +66,6 @@ import static org.apache.nifi.processors.gcp.cloudsql.GcpCloudSqlIamDatabasePasswordProvider.GCP_CREDENTIALS_PROVIDER_SERVICE; import static org.apache.nifi.processors.gcp.cloudsql.GcpCloudSqlIamDatabasePasswordProvider.SQLSERVICE_LOGIN_SCOPE; import static org.apache.nifi.processors.gcp.cloudsql.GcpCloudSqlIamDatabasePasswordProvider.VERIFY_CREDENTIALS_UNAVAILABLE; -import static org.apache.nifi.processors.gcp.cloudsql.GcpCloudSqlIamDatabasePasswordProvider.VERIFY_IMPERSONATION_REQUIRED; import static org.apache.nifi.processors.gcp.cloudsql.GcpCloudSqlIamDatabasePasswordProvider.VERIFY_SCOPE_STEP; import static org.apache.nifi.processors.gcp.cloudsql.GcpCloudSqlIamDatabasePasswordProvider.VERIFY_TOKEN_ACQUISITION_FAILED; import static org.apache.nifi.processors.gcp.cloudsql.GcpCloudSqlIamDatabasePasswordProvider.VERIFY_TOKEN_STEP; @@ -175,34 +177,17 @@ void testVerifyUsesFreshScopedCredentialWithoutMutatingEnabledState() throws Exc } @Test - void testVerifyIdentityPoolCredentialsRequiresImpersonation() throws Exception { + void testVerifyIdentityPoolCredentialsAcquireToken() throws Exception { final IdentityPoolCredentials scopedCredentials = identityPoolCredentials(accessToken(TOKEN_VALUE, 15)); final TestRunner runner = configureRunner(new RootGoogleCredentials(scopedCredentials), false); final GcpCloudSqlIamDatabasePasswordProvider provider = getProviderImplementation(runner); final List results = runner.verify(provider, Map.of()); - assertEquals(1, results.size()); - assertVerificationResult(results.get(0), VERIFY_SCOPE_STEP, FAILED, VERIFY_IMPERSONATION_REQUIRED); - Mockito.verify(scopedCredentials, Mockito.never()).refreshAccessToken(); - } - - @Test - void testOnEnabledRejectsIdentityPoolCredentialsBeforePublishingState() throws Exception { - final IdentityPoolCredentials scopedCredentials = identityPoolCredentials(accessToken(TOKEN_VALUE, 15)); - final GcpCloudSqlIamDatabasePasswordProvider provider = new GcpCloudSqlIamDatabasePasswordProvider(); - final ConfigurationContext context = mock(ConfigurationContext.class); - final PropertyValue credentialsPropertyValue = mock(PropertyValue.class); - final GCPCredentialsService credentialsService = mock(GCPCredentialsService.class); - - when(context.getProperty(GCP_CREDENTIALS_PROVIDER_SERVICE)).thenReturn(credentialsPropertyValue); - when(credentialsPropertyValue.asControllerService(GCPCredentialsService.class)).thenReturn(credentialsService); - when(credentialsService.getGoogleCredentials()).thenReturn(new RootGoogleCredentials(scopedCredentials)); - - final InitializationException exception = assertThrows(InitializationException.class, () -> provider.onEnabled(context)); - - assertTrue(exception.getMessage().contains("impersonation")); - assertNull(getScopedCredentials(provider)); + assertEquals(2, results.size()); + assertVerificationResult(results.get(0), VERIFY_SCOPE_STEP, SUCCESSFUL, "Cloud SQL login scope"); + assertVerificationResult(results.get(1), VERIFY_TOKEN_STEP, SUCCESSFUL, CLOUD_SQL_IAM); + Mockito.verify(scopedCredentials).refreshAccessToken(); } @Test @@ -254,6 +239,11 @@ void testVerifyScopedCredentialCreationFailureIsReported() throws Exception { assertEquals(1, results.size()); assertVerificationResult(results.get(0), VERIFY_SCOPE_STEP, FAILED, VERIFY_CREDENTIALS_UNAVAILABLE); assertFalse(results.get(0).getExplanation().contains(LEAK_SENTINEL)); + assertVerificationExceptionLogged( + runner.getControllerServiceLogger(PASSWORD_PROVIDER_ID), + "Failed to resolve scoped Google credentials", + LEAK_SENTINEL + ); } @Test @@ -284,7 +274,11 @@ void testVerifyRefreshFailureIsSanitized() throws Exception { assertEquals(2, results.size()); assertVerificationResult(results.get(1), VERIFY_TOKEN_STEP, FAILED, VERIFY_TOKEN_ACQUISITION_FAILED); assertFalse(results.get(1).getExplanation().contains(LEAK_SENTINEL)); - assertNoLogMessagesContain(runner.getControllerServiceLogger(PASSWORD_PROVIDER_ID), LEAK_SENTINEL); + assertVerificationExceptionLogged( + runner.getControllerServiceLogger(PASSWORD_PROVIDER_ID), + "Failed to acquire Cloud SQL IAM access token", + LEAK_SENTINEL + ); } @Test @@ -388,18 +382,6 @@ void testRefreshFailureIsSanitizedForPasswordGeneration() throws Exception { assertNoLogMessagesContain(runner.getControllerServiceLogger(PASSWORD_PROVIDER_ID), LEAK_SENTINEL); } - @Test - void testIdentityPoolCredentialsFailClosedAtRuntime() throws Exception { - final GcpCloudSqlIamDatabasePasswordProvider provider = getProviderImplementation( - configureRunner(new RootGoogleCredentials(new TestScopedGoogleCredentials(accessToken(TOKEN_VALUE, 15))))); - - setScopedCredentials(provider, identityPoolCredentials(accessToken(TOKEN_VALUE, 15))); - - final ProcessException exception = assertThrows(ProcessException.class, () -> provider.getPassword(requestContext())); - - assertTrue(exception.getMessage().contains(CLOUD_SQL_IAM)); - } - @Test void testGetPasswordReturnsFreshCharacterArrayEachCall() throws Exception { final DatabasePasswordProvider provider = getProvider(configureRunner( @@ -470,7 +452,7 @@ private char[] getPasswordAfterStart(final DatabasePasswordProvider provider, fi } private static AccessToken accessToken(final String tokenValue, final long offsetMinutes) { - return tokenValue == null ? null : new AccessToken(tokenValue, java.util.Date.from(Instant.now().plusSeconds(offsetMinutes * 60))); + return tokenValue == null ? null : new AccessToken(tokenValue, Date.from(Instant.now().plusSeconds(offsetMinutes * 60))); } private static IOException ioException(final String message) { @@ -502,10 +484,24 @@ private static void assertNoLogMessagesContain(final MockComponentLog logger, fi assertFalse(argValue != null && argValue.contains(value)); } } - assertThrowableChainDoesNotContain(logMessage.getThrowable(), value, java.util.Collections.newSetFromMap(new IdentityHashMap<>())); + assertThrowableChainDoesNotContain(logMessage.getThrowable(), value, Collections.newSetFromMap(new IdentityHashMap<>())); } } + private static void assertVerificationExceptionLogged(final MockComponentLog logger, final String message, final String exceptionMessage) { + assertTrue(logger.getErrorMessages().stream().anyMatch(logMessage -> { + final Object[] arguments = logMessage.getArgs(); + final Throwable throwable = logMessage.getThrowable(); + final boolean throwableMatched = throwable != null && exceptionMessage.equals(throwable.getMessage()); + final boolean argumentMatched = arguments != null + && List.of(arguments).stream() + .filter(Throwable.class::isInstance) + .map(Throwable.class::cast) + .anyMatch(argument -> exceptionMessage.equals(argument.getMessage())); + return logMessage.getMsg().contains(message) && (throwableMatched || argumentMatched); + })); + } + private static void assertThrowableChainDoesNotContain(final Throwable throwable, final String value, final Set visited) { if (throwable == null || !visited.add(throwable)) { return; @@ -527,13 +523,6 @@ private static GoogleCredentials getScopedCredentials(final GcpCloudSqlIamDataba return (GoogleCredentials) field.get(provider); } - private static void setScopedCredentials(final GcpCloudSqlIamDatabasePasswordProvider provider, final GoogleCredentials credentials) - throws ReflectiveOperationException { - final Field field = GcpCloudSqlIamDatabasePasswordProvider.class.getDeclaredField("scopedCredentials"); - field.setAccessible(true); - field.set(provider, credentials); - } - @SuppressWarnings("unchecked") private static List getSupportedPropertyDescriptors(final GcpCloudSqlIamDatabasePasswordProvider provider) throws ReflectiveOperationException { @@ -578,7 +567,7 @@ private RootGoogleCredentials(final GoogleCredentials firstScopedCredentials, fi } @Override - public GoogleCredentials createScoped(final java.util.Collection scopes) { + public GoogleCredentials createScoped(final Collection scopes) { createScopedCount.incrementAndGet(); lastRequestedScopes = List.copyOf(scopes); if (createScopedException != null) { diff --git a/nifi-extension-bundles/nifi-gcp-bundle/nifi-gcp-processors/src/test/java/org/apache/nifi/processors/gcp/credentials/service/GCPCredentialsServiceTest.java b/nifi-extension-bundles/nifi-gcp-bundle/nifi-gcp-processors/src/test/java/org/apache/nifi/processors/gcp/credentials/service/GCPCredentialsServiceTest.java index 50746bebf0c6..890d41757d64 100644 --- a/nifi-extension-bundles/nifi-gcp-bundle/nifi-gcp-processors/src/test/java/org/apache/nifi/processors/gcp/credentials/service/GCPCredentialsServiceTest.java +++ b/nifi-extension-bundles/nifi-gcp-bundle/nifi-gcp-processors/src/test/java/org/apache/nifi/processors/gcp/credentials/service/GCPCredentialsServiceTest.java @@ -31,9 +31,6 @@ import org.apache.nifi.util.TestRunners; import org.junit.jupiter.api.Test; -import java.io.IOException; -import java.io.InputStream; -import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.nio.file.Paths; import java.util.ArrayList; @@ -268,19 +265,6 @@ public void testTargetServiceAccountRequiresWorkloadIdentityFederation() throws assertHasInvalidResult(runner.validate(serviceImpl), "Target Service Account requires Workload Identity Federation"); } - @Test - public void testAdditionalDetailsDocumentTargetServiceAccountVerificationSemantics() throws IOException { - final String resourcePath = "docs/%s/additionalDetails.md".formatted(GCPCredentialsControllerService.class.getName()); - try (InputStream inputStream = GCPCredentialsControllerService.class.getClassLoader().getResourceAsStream(resourcePath)) { - assertNotNull(inputStream); - final String additionalDetails = new String(inputStream.readAllBytes(), StandardCharsets.UTF_8); - assertTrue(additionalDetails.contains("Target Service Account")); - assertTrue(additionalDetails.contains("roles/iam.workloadIdentityUser")); - assertTrue(additionalDetails.contains("Successful verification confirms only that NiFi can")); - assertTrue(additionalDetails.contains("Verify does not perform STS exchange")); - } - } - @Test public void testRawJsonCredentials() throws Exception { final String jsonRead = new String(