Skip to content

Commit e1d08dc

Browse files
committed
Support GSA Impersonation
1 parent dfa828e commit e1d08dc

File tree

3 files changed

+103
-16
lines changed

3 files changed

+103
-16
lines changed

bot/connector-google-chat/README.md

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,14 +13,17 @@ Grant the following permissions:
1313
- `chat.bots.get`
1414
- `chat.bots.update`
1515

16-
> 💡 **Recommendation**: Create a specific role with these permissions and assign it to a service account.
16+
If you use **service account impersonation**, the *source* service account must also have:
17+
18+
- `roles/iam.serviceAccountTokenCreator` on the **target** service account
1719

1820
### Retrieve information from Google Cloud Console
1921

2022
| Required Element | Description |
2123
|---------------------|-----------------|
2224
| **Bot project number** | The numeric ID of your project (e.g., `37564789203`) |
2325
| **JSON credentials** | Service account credentials file |
26+
| **(Optional) GSA to impersonate** | Email of the target service account if using impersonation |
2427

2528
### Configure Google Chat API
2629

@@ -48,6 +51,7 @@ Authentication Audience → Project Number
4851
| **Application base URL** | `https://area-simple-teal.ngrok-free.app` |
4952
| **Bot project number** | `37564789203` |
5053
| **Service account credential json content** | `{"type": "service_account", ...}` |
54+
| **Service account to impersonate** (optional) | `bot-sa@project.iam.gserviceaccount.com` |
5155
| **Use condensed footnotes** | `1` = condensed, `0` = detailed |
5256

5357
---

bot/connector-google-chat/src/main/kotlin/GoogleChatConnector.kt

Lines changed: 21 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -91,12 +91,27 @@ class GoogleChatConnector(
9191
if (message != null) {
9292
callback as GoogleChatConnectorCallback
9393
executor.executeBlocking(Duration.ofMillis(delayInMs)) {
94-
chatService.spaces().messages().create(
95-
callback.spaceName,
96-
message.toGoogleMessage().setThread(Thread().setName(callback.threadName))
97-
)
98-
.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.
99-
.execute()
94+
try {
95+
logger.info {
96+
"Sending to Google Chat: space=${callback.spaceName}, thread=${callback.threadName}, message=${message.toGoogleMessage()}"
97+
}
98+
99+
val response = chatService
100+
.spaces()
101+
.messages()
102+
.create(
103+
callback.spaceName,
104+
message.toGoogleMessage().setThread(Thread().setName(callback.threadName))
105+
)
106+
.setMessageReplyOption("REPLY_MESSAGE_FALLBACK_TO_NEW_THREAD")
107+
.execute()
108+
109+
logger.info { "Google Chat API response: ${response?.name}" }
110+
} catch (e: Exception) {
111+
logger.error(e) {
112+
"Failed to send message to Google Chat (space=${callback.spaceName}, thread=${callback.threadName})"
113+
}
114+
}
100115
}
101116
}
102117
}

bot/connector-google-chat/src/main/kotlin/GoogleChatConnectorProvider.kt

Lines changed: 77 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,9 @@ import com.google.api.client.json.jackson2.JacksonFactory
3131
import com.google.api.services.chat.v1.HangoutsChat
3232
import com.google.auth.http.HttpCredentialsAdapter
3333
import com.google.auth.oauth2.GoogleCredentials
34+
import com.google.auth.oauth2.ImpersonatedCredentials
3435
import com.google.auth.oauth2.ServiceAccountCredentials
36+
import mu.KotlinLogging
3537
import java.io.ByteArrayInputStream
3638
import java.io.InputStream
3739
import kotlin.reflect.KClass
@@ -41,23 +43,36 @@ private const val SERVICE_CREDENTIAL_PATH_PARAMETER = "serviceCredentialPath"
4143
private const val SERVICE_CREDENTIAL_CONTENT_PARAMETER = "serviceCredentialContent"
4244
private const val BOT_PROJECT_NUMBER_PARAMETER = "botProjectNumber"
4345
private const val CONDENSED_FOOTNOTES_PARAMETER = "useCondensedFootnotes"
46+
private const val GSA_TO_IMPERSONATE_PARAMETER = "gsaToImpersonate"
4447

4548
internal object GoogleChatConnectorProvider : ConnectorProvider {
4649

50+
private val logger = KotlinLogging.logger {}
51+
4752
override val connectorType: ConnectorType get() = googleChatConnectorType
4853

4954
override fun connector(connectorConfiguration: ConnectorConfiguration): Connector {
5055
with(connectorConfiguration) {
5156

52-
val credentialInputStream =
53-
connectorConfiguration.parameters[SERVICE_CREDENTIAL_PATH_PARAMETER]
54-
?.let { resourceAsStream(it) }
55-
?: connectorConfiguration.parameters[SERVICE_CREDENTIAL_CONTENT_PARAMETER]
56-
?.let { ByteArrayInputStream(it.toByteArray()) }
57-
?: error("Service credential missing : either $SERVICE_CREDENTIAL_PATH_PARAMETER or $SERVICE_CREDENTIAL_CONTENT_PARAMETER must be provided")
57+
val gsaToImpersonate = connectorConfiguration.parameters[GSA_TO_IMPERSONATE_PARAMETER]
58+
59+
val credentials: GoogleCredentials = if (gsaToImpersonate.isNullOrBlank()) {
60+
logger.info { "Using classic authentication mode with JSON credentials" }
61+
val credentialInputStream = getCredentialInputStream(connectorConfiguration)
62+
loadCredentials(credentialInputStream)
63+
} else {
64+
logger.info { "Using impersonation mode with GSA: $gsaToImpersonate" }
65+
createImpersonatedCredentials(connectorConfiguration, gsaToImpersonate)
66+
}
67+
68+
try {
69+
val token = credentials.refreshAccessToken()
70+
logger.info { "Google credentials OK, access token valid until ${token.expirationTime}" }
71+
} catch (e: Exception) {
72+
logger.error(e) { "Unable to obtain access token for Google Chat API" }
73+
}
5874

59-
val requestInitializer: HttpRequestInitializer =
60-
HttpCredentialsAdapter(loadCredentials(credentialInputStream))
75+
val requestInitializer: HttpRequestInitializer = HttpCredentialsAdapter(credentials)
6176

6277
val useCondensedFootnotes =
6378
connectorConfiguration.parameters[CONDENSED_FOOTNOTES_PARAMETER] == "1"
@@ -85,6 +100,54 @@ internal object GoogleChatConnectorProvider : ConnectorProvider {
85100
}
86101
}
87102

103+
private fun createImpersonatedCredentials(
104+
connectorConfiguration: ConnectorConfiguration,
105+
targetServiceAccount: String
106+
): GoogleCredentials {
107+
108+
val sourceCredentials = getSourceCredentials(connectorConfiguration)
109+
110+
logger.info { "Source credentials: ${(sourceCredentials as? ServiceAccountCredentials)?.clientEmail}" }
111+
logger.info { "Impersonating target GSA = $targetServiceAccount with scopes = $CHAT_SCOPE" }
112+
113+
return ImpersonatedCredentials.create(
114+
sourceCredentials,
115+
targetServiceAccount,
116+
null,
117+
listOf(CHAT_SCOPE),
118+
3600,
119+
null
120+
)
121+
}
122+
123+
private fun getSourceCredentials(connectorConfiguration: ConnectorConfiguration): GoogleCredentials {
124+
return try {
125+
val credentialInputStream = getCredentialInputStream(connectorConfiguration)
126+
val creds = ServiceAccountCredentials.fromStream(credentialInputStream)
127+
.createScoped("https://www.googleapis.com/auth/cloud-platform")
128+
129+
logger.info { "Loaded explicit service account: ${(creds as ServiceAccountCredentials).clientEmail}" }
130+
131+
creds
132+
} catch (e: Exception) {
133+
logger.info { "No explicit credentials found, using Application Default Credentials" }
134+
GoogleCredentials.getApplicationDefault()
135+
.createScoped("https://www.googleapis.com/auth/cloud-platform")
136+
}
137+
}
138+
139+
private fun getCredentialInputStream(connectorConfiguration: ConnectorConfiguration): InputStream {
140+
return connectorConfiguration.parameters[SERVICE_CREDENTIAL_PATH_PARAMETER]
141+
?.let { resourceAsStream(it) }
142+
?: connectorConfiguration.parameters[SERVICE_CREDENTIAL_CONTENT_PARAMETER]
143+
?.let { ByteArrayInputStream(it.toByteArray()) }
144+
?: error(
145+
"Service credential missing: either " +
146+
"$SERVICE_CREDENTIAL_PATH_PARAMETER or " +
147+
"$SERVICE_CREDENTIAL_CONTENT_PARAMETER must be provided"
148+
)
149+
}
150+
88151
private fun loadCredentials(inputStream: InputStream): GoogleCredentials =
89152
ServiceAccountCredentials
90153
.fromStream(inputStream)
@@ -99,6 +162,11 @@ internal object GoogleChatConnectorProvider : ConnectorProvider {
99162
BOT_PROJECT_NUMBER_PARAMETER,
100163
true
101164
),
165+
ConnectorTypeConfigurationField(
166+
"Service account email to impersonate (if provided, priority over JSON credentials)",
167+
GSA_TO_IMPERSONATE_PARAMETER,
168+
false
169+
),
102170
ConnectorTypeConfigurationField(
103171
"Service account credential file path (default : /service-account-{connectorId}.json)",
104172
SERVICE_CREDENTIAL_PATH_PARAMETER,
@@ -122,4 +190,4 @@ internal object GoogleChatConnectorProvider : ConnectorProvider {
122190
setOf(GoogleChatConnectorTextMessageOut::class)
123191
}
124192

125-
internal class GoogleChatConnectorProviderService : ConnectorProvider by GoogleChatConnectorProvider
193+
internal class GoogleChatConnectorProviderService : ConnectorProvider by GoogleChatConnectorProvider

0 commit comments

Comments
 (0)