diff --git a/bot/connector-google-chat/README.md b/bot/connector-google-chat/README.md index 2410079060..b6f8858d05 100644 --- a/bot/connector-google-chat/README.md +++ b/bot/connector-google-chat/README.md @@ -13,7 +13,9 @@ Grant the following permissions: - `chat.bots.get` - `chat.bots.update` -> 💡 **Recommendation**: Create a specific role with these permissions and assign it to a service account. +If you use **service account impersonation**, the *source* service account must also have: + +- `roles/iam.serviceAccountTokenCreator` on the **target** service account ### Retrieve information from Google Cloud Console @@ -21,6 +23,7 @@ Grant the following permissions: |---------------------|-----------------| | **Bot project number** | The numeric ID of your project (e.g., `37564789203`) | | **JSON credentials** | Service account credentials file | +| **(Optional) GSA to impersonate** | Email of the target service account if using impersonation | ### Configure Google Chat API @@ -48,6 +51,7 @@ Authentication Audience → Project Number | **Application base URL** | `https://area-simple-teal.ngrok-free.app` | | **Bot project number** | `37564789203` | | **Service account credential json content** | `{"type": "service_account", ...}` | +| **Service account to impersonate** (optional) | `bot-sa@project.iam.gserviceaccount.com` | | **Use condensed footnotes** | `1` = condensed, `0` = detailed | --- diff --git a/bot/connector-google-chat/src/main/kotlin/GoogleChatConnector.kt b/bot/connector-google-chat/src/main/kotlin/GoogleChatConnector.kt index 8bbe88a1ee..775fb9c267 100644 --- a/bot/connector-google-chat/src/main/kotlin/GoogleChatConnector.kt +++ b/bot/connector-google-chat/src/main/kotlin/GoogleChatConnector.kt @@ -91,12 +91,27 @@ class GoogleChatConnector( if (message != null) { callback as GoogleChatConnectorCallback executor.executeBlocking(Duration.ofMillis(delayInMs)) { - chatService.spaces().messages().create( - callback.spaceName, - message.toGoogleMessage().setThread(Thread().setName(callback.threadName)) - ) - .setMessageReplyOption("REPLY_MESSAGE_FALLBACK_TO_NEW_THREAD") // Creates the message as a reply to the thread specified by [thread ID] If it fails, the message starts a new thread instead. - .execute() + try { + logger.info { + "Sending to Google Chat: space=${callback.spaceName}, thread=${callback.threadName}, message=${message.toGoogleMessage()}" + } + + val response = chatService + .spaces() + .messages() + .create( + callback.spaceName, + message.toGoogleMessage().setThread(Thread().setName(callback.threadName)) + ) + .setMessageReplyOption("REPLY_MESSAGE_FALLBACK_TO_NEW_THREAD") + .execute() + + logger.info { "Google Chat API response: ${response?.name}" } + } catch (e: Exception) { + logger.error(e) { + "Failed to send message to Google Chat (space=${callback.spaceName}, thread=${callback.threadName})" + } + } } } } diff --git a/bot/connector-google-chat/src/main/kotlin/GoogleChatConnectorProvider.kt b/bot/connector-google-chat/src/main/kotlin/GoogleChatConnectorProvider.kt index a86947acff..c080fecc20 100644 --- a/bot/connector-google-chat/src/main/kotlin/GoogleChatConnectorProvider.kt +++ b/bot/connector-google-chat/src/main/kotlin/GoogleChatConnectorProvider.kt @@ -31,7 +31,9 @@ import com.google.api.client.json.jackson2.JacksonFactory import com.google.api.services.chat.v1.HangoutsChat import com.google.auth.http.HttpCredentialsAdapter import com.google.auth.oauth2.GoogleCredentials +import com.google.auth.oauth2.ImpersonatedCredentials import com.google.auth.oauth2.ServiceAccountCredentials +import mu.KotlinLogging import java.io.ByteArrayInputStream import java.io.InputStream import kotlin.reflect.KClass @@ -41,23 +43,36 @@ private const val SERVICE_CREDENTIAL_PATH_PARAMETER = "serviceCredentialPath" private const val SERVICE_CREDENTIAL_CONTENT_PARAMETER = "serviceCredentialContent" private const val BOT_PROJECT_NUMBER_PARAMETER = "botProjectNumber" private const val CONDENSED_FOOTNOTES_PARAMETER = "useCondensedFootnotes" +private const val GSA_TO_IMPERSONATE_PARAMETER = "gsaToImpersonate" internal object GoogleChatConnectorProvider : ConnectorProvider { + private val logger = KotlinLogging.logger {} + override val connectorType: ConnectorType get() = googleChatConnectorType override fun connector(connectorConfiguration: ConnectorConfiguration): Connector { with(connectorConfiguration) { - val credentialInputStream = - connectorConfiguration.parameters[SERVICE_CREDENTIAL_PATH_PARAMETER] - ?.let { resourceAsStream(it) } - ?: connectorConfiguration.parameters[SERVICE_CREDENTIAL_CONTENT_PARAMETER] - ?.let { ByteArrayInputStream(it.toByteArray()) } - ?: error("Service credential missing : either $SERVICE_CREDENTIAL_PATH_PARAMETER or $SERVICE_CREDENTIAL_CONTENT_PARAMETER must be provided") + val gsaToImpersonate = connectorConfiguration.parameters[GSA_TO_IMPERSONATE_PARAMETER] + + val credentials: GoogleCredentials = if (gsaToImpersonate.isNullOrBlank()) { + logger.info { "Using classic authentication mode with JSON credentials" } + val credentialInputStream = getCredentialInputStream(connectorConfiguration) + loadCredentials(credentialInputStream) + } else { + logger.info { "Using impersonation mode with GSA: $gsaToImpersonate" } + createImpersonatedCredentials(connectorConfiguration, gsaToImpersonate) + } + + try { + val token = credentials.refreshAccessToken() + logger.info { "Google credentials OK, access token valid until ${token.expirationTime}" } + } catch (e: Exception) { + logger.error(e) { "Unable to obtain access token for Google Chat API" } + } - val requestInitializer: HttpRequestInitializer = - HttpCredentialsAdapter(loadCredentials(credentialInputStream)) + val requestInitializer: HttpRequestInitializer = HttpCredentialsAdapter(credentials) val useCondensedFootnotes = connectorConfiguration.parameters[CONDENSED_FOOTNOTES_PARAMETER] == "1" @@ -85,6 +100,54 @@ internal object GoogleChatConnectorProvider : ConnectorProvider { } } + private fun createImpersonatedCredentials( + connectorConfiguration: ConnectorConfiguration, + targetServiceAccount: String + ): GoogleCredentials { + + val sourceCredentials = getSourceCredentials(connectorConfiguration) + + logger.info { "Source credentials: ${(sourceCredentials as? ServiceAccountCredentials)?.clientEmail}" } + logger.info { "Impersonating target GSA = $targetServiceAccount with scopes = $CHAT_SCOPE" } + + return ImpersonatedCredentials.create( + sourceCredentials, + targetServiceAccount, + null, + listOf(CHAT_SCOPE), + 3600, + null + ) + } + + private fun getSourceCredentials(connectorConfiguration: ConnectorConfiguration): GoogleCredentials { + return try { + val credentialInputStream = getCredentialInputStream(connectorConfiguration) + val creds = ServiceAccountCredentials.fromStream(credentialInputStream) + .createScoped("https://www.googleapis.com/auth/cloud-platform") + + logger.info { "Loaded explicit service account: ${(creds as ServiceAccountCredentials).clientEmail}" } + + creds + } catch (e: Exception) { + logger.info { "No explicit credentials found, using Application Default Credentials" } + GoogleCredentials.getApplicationDefault() + .createScoped("https://www.googleapis.com/auth/cloud-platform") + } + } + + private fun getCredentialInputStream(connectorConfiguration: ConnectorConfiguration): InputStream { + return connectorConfiguration.parameters[SERVICE_CREDENTIAL_PATH_PARAMETER] + ?.let { resourceAsStream(it) } + ?: connectorConfiguration.parameters[SERVICE_CREDENTIAL_CONTENT_PARAMETER] + ?.let { ByteArrayInputStream(it.toByteArray()) } + ?: error( + "Service credential missing: either " + + "$SERVICE_CREDENTIAL_PATH_PARAMETER or " + + "$SERVICE_CREDENTIAL_CONTENT_PARAMETER must be provided" + ) + } + private fun loadCredentials(inputStream: InputStream): GoogleCredentials = ServiceAccountCredentials .fromStream(inputStream) @@ -99,6 +162,11 @@ internal object GoogleChatConnectorProvider : ConnectorProvider { BOT_PROJECT_NUMBER_PARAMETER, true ), + ConnectorTypeConfigurationField( + "Service account email to impersonate (if provided, priority over JSON credentials)", + GSA_TO_IMPERSONATE_PARAMETER, + false + ), ConnectorTypeConfigurationField( "Service account credential file path (default : /service-account-{connectorId}.json)", SERVICE_CREDENTIAL_PATH_PARAMETER, @@ -122,4 +190,4 @@ internal object GoogleChatConnectorProvider : ConnectorProvider { setOf(GoogleChatConnectorTextMessageOut::class) } -internal class GoogleChatConnectorProviderService : ConnectorProvider by GoogleChatConnectorProvider +internal class GoogleChatConnectorProviderService : ConnectorProvider by GoogleChatConnectorProvider \ No newline at end of file