Skip to content

feat(googlechat): keyless ADC auth + send-once for the unified adapter - #1512

Closed
sebastian-hsu wants to merge 2 commits into
openabdev:mainfrom
sebastian-hsu:feat/googlechat-adc-pr
Closed

feat(googlechat): keyless ADC auth + send-once for the unified adapter#1512
sebastian-hsu wants to merge 2 commits into
openabdev:mainfrom
sebastian-hsu:feat/googlechat-adc-pr

Conversation

@sebastian-hsu

@sebastian-hsu sebastian-hsu commented Aug 27, 2026

Copy link
Copy Markdown
Contributor

What problem does this solve?

The Google Chat gateway adapter can authenticate to the Chat API in only two ways today: a service-account JSON key mounted into the pod (saKeyJson), or a static pre-minted accessToken. Both mean handling a long-lived secret — the key file has to be created, mounted, rotated and protected; the static token expires and must be refreshed by hand.

For a bot that already runs on GCP as its own Chat-app service account (GCE / GKE Workload Identity), that key file is redundant attack surface: the workload can already prove it is the service account. This PR adds a third, keyless option — mint the chat.bot token from the pod's own GCP identity via the metadata server + IAM Credentials self-impersonation, with no key file to mount or leak.

It also corrects the Google Chat streaming declaration: the API has no usable per-token streaming, so the adapter now declares send-once instead of attempting a post-then-edit loop. The decisive reason is structural — the unified adapter's synthetic message id is not a valid resource name, so spaces.messages.patch rejects it with 400 INVALID_ARGUMENT before any edit applies; the documented 1 write/sec-per-space quota is a further constraint.

Closes #

Discord Discussion URL: https://discord.com/channels/1491295327620169908/1491365158868619404/1542421126976643112

Review Contract

Goal

Let a Google Chat bot running on GCP authenticate to the Chat API without a service-account key file, by minting a chat.bot-scoped token from its own GCP identity (ADC → GCE metadata server → IAM Credentials generateAccessToken, the service account impersonating itself). Also declare Google Chat as send-once so core stops attempting cosmetic edits the API rejects.

Non-goals

  • Not changing or removing the existing saKeyJson and static-accessToken paths — they stay, and keep precedence over ADC.
  • Not adding keyless auth for any other platform/adapter in this PR.
  • Not implementing real streaming for Google Chat (the write rate limit makes per-token editing impractical).
  • Not managing GCP IAM setup — the operator grants roles/iam.serviceAccountTokenCreator and enables the IAM Credentials API.

Accepted Residual Risks

  • GCP-only: the ADC path needs a reachable GCE metadata server (GCE / GKE). Off-GCP it fails; the operator must fall back to saKeyJson / accessToken. Documented in docs/google-chat.md, and it is opt-in (use_adc = false by default).
  • Requires self-impersonation IAM: the SA needs serviceAccountTokenCreator over itself and the IAM Credentials API enabled. A misconfiguration surfaces as an explicit token-mint error on the first send, not a silent failure.
  • Token freshness: the minted token is cached with a 300 s refresh margin (3600 s lifetime); a metadata / IAM Credentials outage blocks outbound until it recovers — the same failure class as the existing key/static-token paths.

Acceptance Criteria

  • With GOOGLE_CHAT_USE_ADC=true on a GCP workload running as the Chat-app SA (no key file mounted), the adapter mints a chat.bot token and posts a message successfully.
  • Auth precedence holds in get_token: saKeyJson (if set) > ADC (metadata) > static accessToken.
  • Google Chat is in NON_STREAMING_PLATFORMS; resolve_streaming forces send-once on both the embedded-dispatch and the WebSocket gateway paths (no post-then-edit).
  • platform-schema conformance passes: every source ref in docs/platforms/schema/googlechat.toml resolves to a real symbol.
  • cargo clippy --workspace and --features unified are clean; cargo test passes.

Follow-ups

  • MetadataTokenSource is general enough that keyless / workload-identity auth for other Google-API-backed adapters could reuse it — deferred, non-blocking.
  • The refresh margin (300 s) and token lifetime (3600 s) are currently fixed; making them configurable is a possible later hardening if a deployment needs it.

At a Glance

Outbound to Google Chat — token source (get_token precedence)

  saKeyJson set? ──yes──▶ SA-key JWT-bearer exchange ──▶ token_cache
      │no
      ▼
  use_adc = true? ──yes──▶ MetadataTokenSource
      │no                     │
      ▼                       │ 1. GET metadata server: default SA email + base token
  static accessToken          │ 2. IAM Credentials generateAccessToken
                              │    (SA impersonates ITSELF, scope = chat.bot)
                              │ 3. cache token (3600 s, 300 s refresh margin)
                              ▼
                         chat.bot access token ──▶ Google Chat REST API

Streaming: googlechat ∈ NON_STREAMING_PLATFORMS
  resolve_streaming(platform, adapter_prefers) ──▶ send-once
    · embedded dispatch path  ──▶ compute full reply, POST once
    · WebSocket gateway path  ──▶ same
  (no post-then-edit: synthetic unified_<hex> id → patch 400 INVALID_ARGUMENT;
   1/sec-per-space quota is a further documented constraint)

Prior Art & Industry Research

Scope: how comparable open-source agent gateways authenticate their Google Chat bot identity — downloaded service-account (SA) JSON key file vs. keyless (ADC / workload identity / self-impersonation) — and how they obtain/refresh the chat.bot access token. Both reference projects were read at main as of 2026-08-27; links point at file paths (line numbers drift).

OpenClaw (openclaw/openclaw) — largest open-source AI agent gateway

Google Chat support: yes, first-party plugin @openclaw/googlechat (HTTP webhook inbound, no Pub/Sub).

How they authenticate: downloaded SA JSON key file only — no keyless/ADC path. The channel doc's setup is literally "Create a Service Account … Keys → Add Key → JSON", and the plugin "authenticates exclusively as a service account with the chat.bot scope." Inbound verifies the request's Authorization: Bearer ID token against a configured audience.

  • Evidence: docs/channels/googlechat.md. Grepping that surface for adc|application default|workload|metadata|impersonat|keyless returns zero hits — keyless is genuinely absent, not just undocumented.

What we learn: even the largest gateway ships the long-lived-key pattern openab is moving away from → keyless is a real differentiator. Their per-space serialized outbound queue + write quotas corroborate our send-once decision.

Hermes Agent (NousResearch/hermes-agent) — self-hosted agent, 27+ platforms

Google Chat support: yes, plugins/platforms/google_chat/ (Pub/Sub-pull inbound + REST outbound).

How they authenticate: documented path is a downloaded SA JSON key (GOOGLE_CHAT_SERVICE_ACCOUNT_JSON), but the adapter code also supports keyless ADC as a fallback. _load_sa_credentials() priority: (1) explicit service_account_json, (2) GOOGLE_APPLICATION_CREDENTIALS, (3) google.auth.default() — ADC on Cloud Run / GCE / GKE with an attached workload identity, scopes chat.bot + pubsub.

  • Evidence: plugins/platforms/google_chat/adapter.py (_load_sa_credentials), doc google_chat.md.
  • Nuance: Hermes' keyless mode is plain google.auth.default() — it uses the attached SA's own metadata token directly. It does not call IAM Credentials generateAccessToken to self-impersonate, so the attached SA must itself carry chat.bot.

What we learn: closest prior art; confirms google.auth.default() on GCE/GKE as the sanctioned keyless entry point. openab's design is a stricter, more explicit superset — see the comparison and §Why.

Other industry practice

Comparison

Project Google Chat Auth model How the bot token is obtained
OpenClaw Yes (HTTP webhook) Key file only — SA JSON, chat.bot; no keyless path Google client libs mint/refresh from the SA private key
Hermes Agent Yes (Pub/Sub + REST) Key file (docs) + keyless ADC fallback (code) explicit JSON → GOOGLE_APPLICATION_CREDENTIALSplain google.auth.default() (attached SA's own token); no self-impersonation
Google (official) recommends keyless / short-lived; SA key = last resort ADC + workload identity; short-lived tokens via generateAccessToken
openab (this PR) Yes Keyless — ADC + metadata + IAM generateAccessToken self-impersonation ADC base identity from metadata → self-impersonate the Chat-bot SA → short-lived chat.bot token; auto-refresh, no key on disk

Proposed Solution

Keyless ADC outbound token (MetadataTokenSource, crates/openab-gateway/src/adapters/googlechat.rs). A new token source that, when use_adc is set:

  1. reads the default service-account email and a base access token from the GCE metadata server;
  2. calls IAM Credentials generateAccessToken so the service account impersonates itself, scoped to https://www.googleapis.com/auth/chat.bot;
  3. caches the result behind an RwLock with a double-checked refresh, using the same 300 s margin as the existing paths (ttl_from_expire_time clamps the lifetime to [0, 3600]).

get_token gains a strict precedence: token_cache (SA-key exchange) → metadata_source (ADC) → static access_token. Wiring: GoogleChatConfig.use_adc (config.rs), plumbed through GatewayGoogleChatConfig and the three from_parts call sites (lib.rs, src/main.rs), surfaced as GOOGLE_CHAT_USE_ADC and the chart's gateway.googleChat.useAdc.

Send-once streaming. NON_STREAMING_PLATFORMS (renamed from NON_EDITABLE_PLATFORMS, gateway.rs) now gates a new resolve_streaming(platform, adapter_prefers_streaming) (adapter.rs) used by the embedded stream_prompt_blocks path, mirroring what platform_supports_streaming already did for the WebSocket path. Google Chat therefore never attempts a post-then-edit loop. The schema (googlechat.toml) is updated from partial to not_implemented with the rationale, and its source refs point at the real symbols.

Why this approach?

Weighed against the alternatives from the research:

  • vs. OpenClaw / Hermes' documented key-file path. A downloaded SA JSON is a long-lived secret (Google notes such keys can be valid for years) that must be stored, mounted, rotated, and kept out of logs / backups / images — a standing exfiltration target. Removing it shrinks the on-disk credential surface to zero and eliminates key rotation, matching Google's explicit "avoid SA keys" guidance. This is the primary driver.
  • vs. Hermes' plain-ADC (google.auth.default()) keyless fallback. Plain ADC forces the node / workload identity to be the Chat bot — it must carry chat.bot directly. Self-impersonation decouples the runtime identity from the bot identity: the node keeps a minimal identity and is granted roles/iam.serviceAccountTokenCreator on a dedicated Chat-bot SA, and we request precisely the chat.bot scope via generateAccessToken regardless of the node SA's default scopes. Cleaner for least-privilege and multi-tenant separation; tokens are short-lived and auto-refresh with nothing persisted. Crucially, chat.bot is a Workspace scope and is not a subset of cloud-platform, so no ?scopes= parameter on the metadata token can produce it — generateAccessToken self-impersonation is therefore not merely cleaner here, it is the only viable keyless way to obtain a chat.bot token.

Accepted trade-offs (honest costs):

  1. Environment coupling — works only where a metadata server + ADC exist (GCE / GKE / Cloud Run). Off-GCP there is no metadata identity, so the SA-key path stays as an explicit fallback (not a default), mirroring Hermes' priority chain.
  2. IAM prerequisite — the runtime SA must hold roles/iam.serviceAccountTokenCreator on the target/self SA, plus one extra hop to the IAM Credentials API per token mint. This is a deliberate, auditable IAM binding replacing an unauditable file on disk.

The feature is opt-in (use_adc = false by default); the key-file and static-token paths are untouched for everyone else.

Alternatives Considered

  • Plain ADC (google.auth.default()), like Hermes Agent's keyless fallback. Simpler, but it makes the node/workload identity be the bot (the attached SA must carry chat.bot). We chose generateAccessToken self-impersonation instead to decouple the runtime identity from the bot identity and request exactly the chat.bot scope — see §Why.
  • Keep only the SA-key JSON file. Rejected as the default for GCP-hosted bots: a long-lived secret that must be mounted, protected and rotated, when the workload can already prove its identity. Retained as a fallback.
  • Static pre-minted accessToken. Kept as a fallback, but not a fix: it expires and needs out-of-band refresh.
  • Full Workload Identity Federation (off-GCP → GCP). Out of scope — this feature targets workloads already running on GCP as the Chat-app SA; WIF for external identities is a larger, separate design.
  • Attempt cosmetic streaming with rate-limit backoff. Rejected: the unified adapter's synthetic unified_<hex> id is not a valid resource name, so patch rejects it with 400 INVALID_ARGUMENT before any edit — per-token editing cannot work at all; the documented 1 write/sec-per-space quota is a further constraint. Send-once is what the API actually supports.

Validation

Run locally on stable pinned to the CI toolchain (2026-07-13 ≈ 1.97.0):

Rust:

  • cargo clippy --workspace -- -D warnings — clean
  • cargo clippy --workspace --features unified -- -D warnings — clean
  • cargo test -p openab-gateway passes, incl. config_first_conformance (every_platform_env_var_has_a_config_section_field)
  • platform-schema conformance — all four source refs in googlechat.toml verified to resolve (gateway.rs#NON_STREAMING_PLATFORMS, adapter.rs#resolve_streaming, adapter.rs#uses_native_streaming, googlechat.rs#MetadataTokenSource). The crate's own test wasn't run locally (my .claude/worktrees/ checkout nests under the repo, which confuses cargo's standalone-workspace resolution — a local artifact only); CI runs it on a flat checkout.

Helm (chart touched — gateway.yaml, values.yaml):

  • Change is one additive conditional (useAdcGOOGLE_CHAT_USE_ADC env, plus useAdc in the $hasGoogleChat guard). No existing charts/openab/tests/*_test.yaml asserts the gateway Google Chat env, so it does not disturb current unittests; helm unittest charts/openab runs on CI (helm not installed locally).

Manual:

  • On a GKE workload running as the Chat-app SA with GOOGLE_CHAT_USE_ADC=true and no key file: the bot mints a chat.bot token and replies in a space; confirm precedence by additionally setting saKeyJson and observing the key path win.

Note: my machine's default stable is 1.98, whose newer useless_format / doc_lazy_continuation lints flag pre-existing code (e.g. setup/wizard.rs) that the CI toolchain does not; the doc-comment lint on the new resolve_streaming doc was the one real hit and is fixed in this branch. The unrelated openab-core secrets::tests::resolve_exec_nonzero_exit failure is a pre-existing, environment-specific subprocess-error-string assertion (that file is not touched by this PR).

@openab-app openab-app Bot added the closing-soon PR missing Discord Discussion URL — will auto-close in 24 hours. label Aug 27, 2026
@sebastian-hsu
sebastian-hsu force-pushed the feat/googlechat-adc-pr branch from 639b61f to f7baf96 Compare August 27, 2026 06:28
@openab-app openab-app Bot removed the closing-soon PR missing Discord Discussion URL — will auto-close in 24 hours. label Aug 27, 2026

@canyugs canyugs left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Round 1 review — verified by live deployment, not just reading

I ran the auth flow against the real GCP and Google Chat APIs rather than only reading the diff, because the novel part of this PR is an interaction with two external services that unit tests can only assert against the author's own assumptions. All identifiers below are redacted; no token values are reproduced.

The keyless ADC design works and I recommend it lands. I posted a real message to a real space with a token minted entirely keylessly:

POST /v1/projects/-/serviceAccounts/<sa>:generateAccessToken
  → HTTP 200, expireTime 2026-08-27T15:34:25Z
tokeninfo on the minted token
  → scope: https://www.googleapis.com/auth/chat.bot   (and nothing else)
POST /v1/spaces/<space>/messages
  → HTTP 200, sender.type = BOT

That third line is the important one. The base token was cloud-platform-scoped and the minted token carries only chat.bot. This empirically validates the central argument in §Why for generateAccessToken self-impersonation over Hermes' plain google.auth.default() — the scope really is narrowed, and the runtime identity really is decoupled from the bot identity. Worth noting that the official docs make this argument even stronger than the PR does: chat.bot is a Workspace scope and is not a subset of cloud-platform, so no ?scopes= parameter on the metadata server can produce it. Self-impersonation is not merely cleaner here, it is the only viable keyless path. I'd put that in §Why.

I also confirmed the explicit IAM grant in the docs is genuinely required: roles/owner does not include iam.serviceAccounts.getAccessToken, so an owner still gets IAM_PERMISSION_DENIED without the serviceAccountTokenCreator binding.

Cross-checks against official docs that all hold: the projects/-/ wildcard is required as used; iam.serviceAccounts.getAccessToken is contained in roles/iam.serviceAccountTokenCreator; accessToken/expireTime are exactly the response fields; expireTime is RFC 3339 and may carry fractional digits or a non-Z offset, both of which parse_from_rfc3339 accepts; the metadata token endpoint returns {"access_token":…,"expires_in":…,"token_type":…}, matching the parsing and the wiremock fixture. The chat.bot scope requires no admin approval, so no domain-wide delegation is needed for an impersonated token.

I verified the OpenClaw half of §Prior Art directly against a local checkout: extensions/googlechat/ has zero genuine hits for adc|application.default|workload.identity|metadata.google|impersonat|generateAccessToken|keyless (the one apparent match is readCredentialsFile matching adc case-insensitively). The claim holds. I did not verify the Hermes Agent claims.


Recommended blockers

1. Stale base — check fails on a lint this PR did not introduce

cargo clippy --workspace -- -D warnings fails at crates/openab-core/src/setup/wizard.rs:163 (useless_format). That line was fixed on main in #1511; this branch's merge base predates it. Merging origin/main clears it — I verified locally on the merged tree:

cargo clippy --workspace -- -D warnings                     → clean
cargo clippy --workspace --features unified -- -D warnings   → clean
cargo test -p openab-gateway                                 → 315 passed, 0 failed
cargo test --manifest-path crates/platform-schema/Cargo.toml  → 17 passed

main touches 6 files, none overlapping this PR, so no conflicts. Incidentally the platform-schema conformance run that §Validation lists as unverified locally does pass — all four source refs resolve.

The one openab-core failure, secrets::tests::resolve_exec_nonzero_exit, also fails on unmodified origin/main on macOS, so §Validation's characterisation of it as pre-existing and environment-specific is accurate.

The smoke-test (Dockerfile.hermes) failure is an upstream install-script download returning curl exit 22; smoke-test-unified (hermes) is green. Unrelated.

2. docs/google-chat.md Option C will fail as written on GCE

Calling generateAccessToken requires the caller's token to carry https://www.googleapis.com/auth/iam or cloud-platform. The metadata server returns a token bounded by the instance's access scopes, and GCE's default scope set contains neither. Reproduced with a base token restricted to exactly the GCE defaults, same SA and same IAM binding that succeeded above:

base scope: devstorage.read_only logging.write monitoring.write
            service.management.readonly servicecontrol trace.append

POST …:generateAccessToken
  → HTTP 403 PERMISSION_DENIED
    "Request had insufficient authentication scopes."

So the IAM prerequisites can be entirely correct and this still fails. Option C lists GKE / GCE / Cloud Run and the prerequisites cover the IAM binding and API enablement, but not the access scope. GKE Workload Identity and Cloud Run are cloud-platform-scoped and unaffected; a default-scope GCE VM is not, and VM scopes are immutable after creation (gcloud compute instances set-scopes plus a stop/start). Please add the requirement, and ideally name the 403 string so operators can match on it — it says "scopes", not "permission", which is the only signal distinguishing it from a missing IAM role.

3. The streaming rationale in googlechat.toml asserts behaviour I could not reproduce

The recorded rationale for send-once is that per-token editing "would immediately 429" under the 1 write/sec/space limit. I could not reproduce that:

8 sequential PATCH to one message (~3/s)   → 8 × HTTP 200
25 concurrent PATCH to the same message     → 25 × HTTP 200

No 429 at any point. The quota table does document per-space writes as 1/second, but enforcement is evidently burst-tolerant. Caveat: my space was a DM, and the same page notes additional internal limits that are not exposed, so I am not claiming the limit does not exist — only that "would immediately 429" is not supported by observation.

That matters because the second, structural reason is solid and is the one that actually justifies send-once. A synthetic id cannot be patched at all:

PATCH /v1/spaces/<space>/messages/unified_a1b2c3d4e5f6
  → HTTP 400 INVALID_ARGUMENT
    "Missing or malformed message resource name in the request…"
PATCH /v1/spaces/<space>/messages/<real id>
  → HTTP 200

It is 400 INVALID_ARGUMENT, not 404 — the id does not parse as a resource name, so this fails earlier than the PR describes. 404 appears in the PR body, the googlechat.toml note, and the gateway.rs comment.

The conclusion is right; send-once is correct. I'm asking for the recorded reasoning to be corrected because docs/platforms/schema/googlechat.toml is durable rationale future maintainers will cite. Suggest leading with the resource-name failure and its real status code, and citing the quota as a documented constraint rather than an asserted runtime behaviour.


Contract challenges (Round 1)

The residual-risk claim about diagnosability does not match the code. §Accepted Residual Risks states a misconfiguration "surfaces as an explicit token-mint error on the first send, not a silent failure." On the reply path, get_token() returning None yields:

info!(text = %reply.content.text,
      "googlechat reply (dry-run, no credentials configured)");
// → GatewayResponse { success: false, error: "no credentials configured" }

For the ADC case that message is wrong — credentials are configured (use_adc = true); the mint failed. The real cause is on a separate error! line from get_token. And the reply is dropped, so the Chat user sees nothing at all. So it is a silent failure from the user's side and a misleading one from the operator's side. Combined with finding 2, an operator who follows Option C onto a default-scope GCE VM gets: no reply, a log line blaming missing credentials, and the real 403 elsewhere. I'd either amend the claim or distinguish the ADC failure in that message.

The environment constraint is stated as the wrong axis. §Accepted Residual Risks frames it as GCP-only versus off-GCP. The sharper constraint is the access scope: an on-GCP GCE workload can fail too. Worth restating in those terms, since that is what an operator has to check.

Suggested Acceptance Criterion. The existing manual criterion covers the success path on GKE. Given finding 2, please also cover a default-scope GCE VM and assert the failure is diagnosable, so criterion 2 above cannot silently regress.


Non-blocking

  • from_parts comment contradicts the code. The comment says ADC is enabled "only when no SA key was resolved," but adapter.metadata_source = use_adc.then(MetadataTokenSource::new) is unconditional; only the info! is gated. Behaviour is correct because get_token short-circuits on token_cache. Related: if sa_key_file is unreadable or the JSON is invalid, both paths only warn! and ADC silently takes over — an unannounced identity switch. A warn! naming the fallback would help.
  • ADC_TOKEN_LIFETIME_SECS comment. "IAM caps impersonated tokens at 3600s" — 1 hour is the default maximum, extendable to 12 hours via constraints/iam.allowServiceAccountCredentialLifetimeExtension. The clamp is safely conservative, but the comment reads as an absolute cap.
  • expireTime fallback is unreachable. The API reference states the expiration time is always set, so .unwrap_or(ADC_TOKEN_LIFETIME_SECS) will not trigger. Harmless; the Err(_) arm inside ttl_from_expire_time is the one doing real defensive work.
  • Cache is disabled for short-lived tokens. elapsed < ttl.saturating_sub(300) means any ttl <= 300 mints on every send. Same formula as GoogleChatTokenCache, so not new, but ADC pays an extra IAM hop per mint.
  • Case-sensitive bool parsing, duplicated three times. v == "true" || v == "1" in config.rs and twice in lib.rs means GOOGLE_CHAT_USE_ADC=True silently does nothing. config.rs already has env_flag_true_one, which is case-insensitive. Consistent with the adjacent allow_all_users precedent, so it's a judgement call.
  • The env path for use_adc is untested. The resolve test only exercises use_adc: Some(true); GOOGLE_CHAT_USE_ADC is added to the remove_var list but never set and asserted — which is precisely where the previous item would bite.
  • MetadataTokenSource::{metadata_base, iam_credentials_base} are pub. Documented as test seams, but as public fields production code can retarget the exchange, and the bearer sent there is a live metadata token. A private field with a #[cfg(test)] setter would keep the seam without widening the credential path.
  • Dockerfile.claude's OPENAB_BUILD_FEATURES looks like scope creep. Nothing in the repo passes it; the comment says "the reviewer image passes googlechat" but no such caller exists here. It's also in a file AGENTS.md marks deprecated in favour of Dockerfile.unified, which already builds --features unified and therefore already includes googlechat. Suggest dropping it from this PR.
  • IAM propagation takes about 30 seconds. The serviceAccountTokenCreator binding needed roughly 30 s before impersonation succeeded. refresh() has no retry, so the first send right after a fresh deployment can fail. Fine operationally, worth a line in the docs.

One underclaimed win

resolve_streaming fixes more than Google Chat. Following the call chain, Dispatcher::stream_prompt_blocks delegates to AdapterRouter::stream_prompt_blocks, which reads adapter.use_streaming() — the unified adapter's global Telegram flag — not the platform_supports_streaming-gated local in run_gateway_adapter (that one only feeds GatewayAdapter::use_streaming on the standalone path). So in unified/embedded mode line and lineworks were also inheriting the Telegram flag and attempting edits on platforms with no edit API. This PR fixes that too. Worth stating in the description so reviewers see the real blast radius.


Summary

The ADC half is sound and I verified it end to end against live APIs. My recommended blockers are one rebase, one documentation gap that will break real GCE deployments, and one correction to durable recorded rationale — none of them defects in the ADC implementation itself. Everything else is non-blocking.

I don't have maintainer rights here, so this is a review comment rather than a formal block, and the contract freeze is @thepagent's call.

Evidence gathered against a scratch GCP project I own; the temporary human-account impersonation grant used for testing has been removed.

@thepagent

Copy link
Copy Markdown
Collaborator

CI and smoke test failing. Changing status to Draft until fixed.

@thepagent
thepagent marked this pull request as draft August 27, 2026 16:40
@sebastian-hsu
sebastian-hsu force-pushed the feat/googlechat-adc-pr branch from f7baf96 to 0b57f3b Compare August 28, 2026 00:29
@sebastian-hsu

Copy link
Copy Markdown
Contributor Author

Thanks @canyugs — verifying the auth flow against real GCP/Chat rather than the diff is exactly the right lens here, and the scope-narrowing evidence (a cloud-platform-scoped base token producing a chat.bot-only minted token) is the strongest possible confirmation of the §Why argument. All three blockers are addressed.

1. Stale base — clippy on pre-existing wizard.rs. Rebased onto current origin/main (now includes #1511), which clears the setup/wizard.rs:163 useless_format. Verified on the rebased tree: cargo clippy --workspace -- -D warnings and --features unified both clean, cargo test -p openab-gateway green. The openab-core secrets::tests::resolve_exec_nonzero_exit failure is pre-existing on unmodified main, as you confirmed.

2. docs/google-chat.md Option C — GCE access scope. Added the missing requirement: the metadata base token must carry cloud-platform (or .../auth/iam) scope, because generateAccessToken requires it on the caller. A default-scope GCE VM returns 403 PERMISSION_DENIED: "Request had insufficient authentication scopes." even with the IAM binding correct — I named that string and pointed out it says scopes, not permission, so operators can tell it apart from a missing role. Also noted GKE Workload Identity / Cloud Run are cloud-platform-scoped and unaffected, and that GCE scopes are immutable after creation (set-scopes + stop/start).

3. googlechat.toml streaming rationale. Corrected to lead with the decisive, reproducible reason: the synthetic unified_<hex> id is not a valid resource name, so patch rejects it with 400 INVALID_ARGUMENT — not 404, and it fails before any edit applies. The 1 write/sec-per-space quota is now cited as a documented constraint on high-frequency editing, not an asserted "would immediately 429" (your 8-sequential / 25-concurrent PATCH-all-200 result is noted — enforcement is burst-tolerant). The same 404→400 / quota-framing correction is applied in the PR body (What problem / At a Glance / Alternatives).

§Why now also carries your stronger point: chat.bot is a Workspace scope and is not a subset of cloud-platform, so no ?scopes= on the metadata token can produce it — generateAccessToken self-impersonation is the only viable keyless path, not merely the cleaner one.

On CI: the smoke-test (Dockerfile.hermes) failure is the upstream install-script curl exit 22 you identified (the -unified hermes variant is green) — unrelated to this change.

Reviewed head is now the rebased commit. Thanks again for the thoroughness.

@canyugs canyugs left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Round 2 — fix verification

Scoped per docs/review-contract.md for later rounds: unresolved Round 1 findings, changes since the last reviewed commit, regressions from those changes, and Acceptance Criteria compliance. No new architecture discovery.

Reviewed head: 0b57f3ba (previous: f7baf967).

All three Round 1 blockers verified fixed

1. Stale base. origin/main is now an ancestor of the head, so the wizard.rs:163 useless_format from the old merge base is gone. Independently re-run on 0b57f3ba:

cargo clippy --workspace -- -D warnings                       → clean
cargo clippy --workspace --features unified -- -D warnings     → clean
cargo test -p openab-gateway                                   → 315 passed, 0 failed
cargo test --locked -p platform-schema (standalone manifest)    → 17 passed, 0 failed

The check job has flipped from fail to pass on CI, matching.

2. GCE access scope, docs/google-chat.md. Resolved, and more completely than I asked. Option C now states that the metadata base token must carry cloud-platform (or .../auth/iam) because generateAccessToken requires it on the caller; reproduces the exact 403 PERMISSION_DENIED: "Request had insufficient authentication scopes."; tells operators to match on scopes rather than permission to distinguish it from a missing role; notes GKE Workload Identity and Cloud Run satisfy it automatically; and covers the part that actually bites, that GCE scopes are immutable after creation, with the set-scopes plus stop/start remedy. That is the failure mode from Round 1 finding 2 fully described.

3. googlechat.toml streaming rationale. Resolved. The note now leads with the decisive and reproducible reason — the synthetic unified_<hex> id is not a valid resource name, so patch rejects it with 400 INVALID_ARGUMENT before any content is applied — and demotes the quota to "a documented constraint on high-frequency editing rather than an observed hard failure, since enforcement is burst-tolerant in practice." That is an accurate description of what I measured. 404 no longer appears in any Google Chat surface.

The §Why addition is a good call: stating that chat.bot is a Workspace scope and not a subset of cloud-platform, so no ?scopes= on the metadata token can produce it, turns the design rationale from a preference into a necessity.

Regression check: clean, and narrowly so

Comparing each head's net diff against its own merge base, file by file, only two files differ between f7baf967 and 0b57f3ba:

* changed   docs/google-chat.md
* changed   docs/platforms/schema/googlechat.toml
= unchanged Dockerfile.claude, charts/…/gateway.yaml, charts/…/values.yaml,
            config.toml.example, crates/openab-core/src/{adapter,config,gateway}.rs,
            crates/openab-gateway/src/adapters/googlechat.rs,
            crates/openab-gateway/src/lib.rs,
            crates/openab-gateway/tests/config_first_conformance.rs,
            docs/config-reference.md, docs/platforms/schema/lineworks.toml,
            src/main.rs

The file list is identical at both heads and the Rust delta is empty, so there is no behavioural regression surface introduced by this round. The rebase is clean with nothing smuggled in.

Unresolved from Round 1 — ORIGINAL, and I am not blocking on any of them

Recording these so they are not lost, not to reopen them. The Round 1 contract challenges and the ten non-blocking items are unchanged in 0b57f3ba. My position is that none of them meet the Late Blocker Gate and they should not hold this PR:

  • §Accepted Residual Risks still reads "A misconfiguration surfaces as an explicit token-mint error on the first send, not a silent failure," and still frames the constraint as GCP-only. Both are now narrower than what docs/google-chat.md itself says after fix 2, so the contract text and the docs disagree. Since the docs are where operators look and they are now correct, this is bookkeeping.
  • No Acceptance Criterion covers the default-scope GCE case, so fix 2 is documentation-only with no test guarding it. Worth a Follow-up entry rather than a blocker.
  • The ten non-blocking items from Round 1 stand as written, including the from_parts comment still contradicting the unconditional metadata_source assignment, the ADC_TOKEN_LIFETIME_SECS comment still describing 3600 s as a hard cap when it is the default maximum, and Dockerfile.claude's unused OPENAB_BUILD_FEATURES.

Two process notes, neither a review finding:

  • I do not see a contract freeze record from the maintainer, so Round 1 was never formally closed under the lifecycle in docs/review-contract.md. I have applied later-round scoping anyway, since the practical effect is the same.
  • The PR is still a Draft, set on the previous head over CI failures that are now fixed. That status is currently self-sustaining: docker-smoke-test.yml and docker-smoke-test-unified.yml both gate on if: github.event.pull_request.draft == false, so the smoke tests cannot run to clear the condition they were paused for. Someone with write access will need to mark it Ready.

Bottom line

Every blocker I raised in Round 1 is fixed and independently verified, this round introduced no regressions, and the only code-affecting criteria I can check locally all pass. No blockers remain from my side. The remaining items are contract wording and optional hardening, and the freeze and merge decisions are @thepagent's.

@sebastian-hsu
sebastian-hsu marked this pull request as ready for review August 28, 2026 05:56
@sebastian-hsu

Copy link
Copy Markdown
Contributor Author

Marking ready for review. All three Round 1 blockers are fixed and independently re-verified in @canyugs' Round 2: cargo clippy --workspace (and --features unified) clean on the rebased head, gateway/platform-schema tests green, and the docs (google-chat.md GCE access-scope + 403 string) and googlechat.toml rationale (patch of the synthetic id is 400 INVALID_ARGUMENT, quota framed as a documented constraint) corrected.

The docker-smoke-test / docker-smoke-test-unified jobs are gated on draft == false, so they couldn't run while this sat in Draft — moving it out of Draft lets them run. @thepagent — the contract freeze and merge decisions remain yours.

@chaodu-obk

This comment has been minimized.

@sebastian-hsu

Copy link
Copy Markdown
Contributor Author

Thanks @chaodu-obk — the escalations are fair; F1 (auth-principal selection, not just a stale comment) and F5 (a concrete exfil path, not just a test seam) are the right lens. All eight findings (F1–F8) are addressed. The rebuilt image was also verified end-to-end on a live GCP workload: keyless ADC minted a chat.bot token (ttl ~3600 s) and the bot delivered a Chat reply.

F1 — a configured-but-unreadable SA key no longer falls open silently. from_parts now tracks whether a key was configured (key_configured = sa_key_json.is_some() || sa_key_file.is_some()), independently of whether it parsed. When use_adc is set and a configured key failed to load, it emits an explicit warn! naming the identity switch ("SA key was configured but could not be loaded; falling back to the keyless ADC (workload) identity — this is NOT the configured key identity"). Two regression tests added: from_parts_malformed_key_with_use_adc_installs_adc and from_parts_unreadable_key_file_with_use_adc_installs_adc, both asserting token_cache.is_none() && metadata_source.is_some().

F2 — ADC failure now falls through to the static token. The metadata_source arm no longer returns None on error; it logs the degradation and falls through to self.access_token. A deployment that configures both use_adc and a static access_token (as a deliberate fallback) keeps replying during a metadata/IAM outage.

F3 — TTL edge cases + fixture. Added refresh_threshold(ttl) = ttl.saturating_sub(TOKEN_REFRESH_MARGIN_SECS.min(ttl / 2)), so the refresh margin is clamped to ttl/2; short-TTL tokens no longer re-mint on every send. A ttl == 0 result is served once with a warn! and not cached. The wiremock fixture's expireTime is now far-future (2099-01-01T00:00:00Z), so the test no longer asserts that an already-expired token is served.

F4 — serve-stale-on-error. Inside the refresh window, a transient mint error now serves the still-valid cached token (while elapsed < ttl) instead of erroring, and only errors after real expiry. Same pattern applied to both MetadataTokenSource::get_token and GoogleChatTokenCache::get_token.

F5 — endpoint-override exfil path closed. metadata_base / iam_credentials_base are now private; the source is constructed only via new() (hardcoded https production endpoints) or a test-only with_bases() (wiremock). The struct owns a reqwest::Client built with redirect(Policy::none()), used for both the metadata and IAM calls. With the fields no longer runtime-mutable and redirects disabled, the pub-override bearer-exfil vector is removed at the type level and a redirect to an impostor host can no longer carry the metadata bearer.

F6 — key→ADC migration cleanup documented. Added a migration note to docs/google-chat.md Option C: when switching an existing release from an SA key to ADC, delete the orphaned Secret (kubectl delete secret <the Secret that held the key>), since helm.sh/resource-policy: keep otherwise leaves the key material in the cluster.

F7 — stale streaming bullet fixed. docs/google-chat.md:223 now describes send-once (Google Chat is in NON_STREAMING_PLATFORMS; the synthetic message id can't be patched → 400 INVALID_ARGUMENT; 1 write/sec-per-space quota), replacing the old edit-in-place bullet.

F8 — dropped OPENAB_BUILD_FEATURES. Dockerfile.claude is reverted to origin/main; the unused build arg and its comment are gone, so this PR no longer touches that file.

On the non-blocking items you and @canyugs agree on (case-sensitive bool parsing ×3, the untested GOOGLE_CHAT_USE_ADC env path, IAM propagation retry): left as recorded for a follow-up. Happy to fold the bool-parsing + env-path tests into this PR instead if you'd rather not split them.

Verified on the rebased tree: cargo clippy (default and --features unified) clean, cargo test -p openab-gateway green — 317 tests, including the two new F1 regression tests. New head: 882bd620.

@chaodu-obk

This comment has been minimized.

@sebastian-hsu

Copy link
Copy Markdown
Contributor Author

Thanks @chaodu-obk — F10 fixed, and thanks for the F1–F8 fix-verification. Both gateway.rs comments now lead with the structural reason and frame the quota as a documented constraint, matching the schema/docs/PR-body rationale:

  • crates/openab-core/src/gateway.rs (NON_STREAMING_PLATFORMS doc): the googlechat bullet now reads — the unified adapter's synthetic unified_<hex> message id is not a valid resource name, so patch rejects it with 400 INVALID_ARGUMENT before any edit applies; the documented 1 write/sec-per-space quota (create + patch + delete combined) further constrains high-frequency editing.
  • crates/openab-core/src/gateway.rs (test googlechat_rate_limit_forces_send_once): reworded to the same framing — 400 INVALID_ARGUMENT first, quota as a documented constraint, no "immediate 429".

Comment-only change: the delta from the previous head is exactly these two comment blocks (zero code lines), so the F1–F8 fixes you verified at 882bd620 are unchanged. New head: f0d196fd.

@chaodu-obk

This comment has been minimized.

@sebastian-hsu

Copy link
Copy Markdown
Contributor Author

Thanks @chaodu-obk — F12 and F13 fixed.

  • F12 — added a GOOGLE_CHAT_USE_ADC row to the "Environment Variables (Gateway)" table in docs/google-chat.md, alongside the other auth options: No | false | Keyless ADC auth via the GCE metadata server + IAM Credentials generateAccessToken self-impersonation (GCP-hosted only) — see Option C. Ignored when SA_KEY_JSON/SA_KEY_FILE is set.
  • F13 — reworded both adapter.rs comments off the quota-causal framing:
    • resolve_streaming doc: now defers to NON_STREAMING_PLATFORMS for the per-platform rationale (which carries the 400 INVALID_ARGUMENT structural reason) instead of citing the quota as the cause.
    • test resolve_streaming_forces_send_once_for_acp_and_googlechat: now reads "synthetic id can't be patched (400 INVALID_ARGUMENT) → send-once".
  • Optional non-blocking (the gateway.rs test name googlechat_rate_limit_forces_send_once): left as-is to avoid churn, per your note — happy to rename if you'd prefer.

Comment/doc-only change: the delta from the previous head is the two adapter.rs comment blocks plus one docs table row (zero code lines), so the F1–F10 fixes verified at f0d196f are untouched. New head: 4aa4d056.

@chaodu-obk

This comment has been minimized.

@chaodu-obk

This comment has been minimized.

@chaodu-obk

chaodu-obk Bot commented Aug 31, 2026

Copy link
Copy Markdown
Contributor

Maintainer follow-up on review round 5: all open findings (F15-F28) are fixed in commit bd62ee49, carried by companion PR #1513 (this bot cannot push to the fork branch). @sebastian-hsu - preferred path: pull that commit into feat/googlechat-adc-pr (git fetch https://github.com/openabdev/openab.git fix/pr-1512-review-f15-f28 && git cherry-pick bd62ee49), which updates this PR and supersedes #1513. F29 (token-source consolidation) is tracked separately in #1514.

- F15: bound every token-mint request (SA-key exchange, metadata, IAM
  Credentials) with a 10s TOKEN_REQUEST_TIMEOUT so a hung connection
  cannot stall senders behind the cache write lock or defeat the
  ADC -> static token degradation path
- F16: reject empty/whitespace minted tokens at all three extraction
  sites (SA-key exchange, metadata base token, generateAccessToken)
  so a malformed response follows the degradation path instead of
  being cached as valid
- F17: correct the shorthand precedence wording in config.toml.example,
  config.rs, config-reference.md, google-chat.md env table, and
  values.yaml to name the configured-but-unloadable-key -> ADC fallback
- F19: refuse edit_message for non-resource-name (synthetic unified_)
  ids locally instead of sending a doomed patch (400 INVALID_ARGUMENT)
- F20: cross-reference the two sibling streaming gates
  (resolve_streaming / platform_supports_streaming) in both docs
- F21: document get_token precedence and its asymmetric failure
  behavior at the function
- F22: replace from_parts' five positional args with a named
  GoogleChatParts struct; all call sites and tests name their fields
- F23: install metadata_source only when no SA key loaded, so the
  code encodes the precedence it documents
- F24: drop private review-numbering labels (F1/F2/F4/F5) from
  source comments and test comments
- F25: fix the self-contradictory 'immutable after creation' GCE
  scope wording in docs/google-chat.md Option C
- F26: identify the orphaned Secret (agentFullname convention +
  discovery commands) in the key-to-ADC migration note
- F27: log the resolved service-account identity on successful mint
- F28: classify generateAccessToken failures (insufficient_scope /
  missing_role / api_not_enabled) in the error string

New regression tests: loaded-key-suppresses-ADC-source, blank-minted-
token rejection (wiremock), synthetic-id edit_message no-op (wiremock,
expect(0)), and error-classification table.
@sebastian-hsu

Copy link
Copy Markdown
Contributor Author

Thanks @chaodu-obk — F15–F28 addressed. I took the preferred path from your round-5 follow-up: cherry-picked bd62ee49 (the F15–F28 fix commit) into feat/googlechat-adc-pr, so this PR now carries the fixes and supersedes #1513. F29 (token-source consolidation) is tracked in #1514, as noted.

Verified before pushing:

  • cargo test -p openab-gateway green — 321 tests, including the four new regression tests: loaded-key-suppresses-ADC-source, blank-minted-token rejection (wiremock), synthetic-id edit_message no-op (wiremock, expect(0)), and the error-classification table.
  • The rebuilt image was deployed to a live GCP workload and exercised end-to-end: googlechat ADC token minted (chat.bot, ttl 3599s) service_account=<workload SA> — F27's new identity log line — followed by a delivered Chat reply. F15's request timeout and F16's blank-token rejection are on the failure paths, so a healthy mint does not exercise them; they are covered by the new wiremock tests.

The delta from the previously reviewed head (4aa4d056) is exactly bd62ee49 (F15–F28). New head: bf6ee1ed.

@chaodu-obk

This comment has been minimized.

@chaodu-obk chaodu-obk Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Important

CHANGES REQUESTED ⚠️ - The submitted ADC flow is prohibited self-impersonation, and send-once replies do not propagate delivery failures.

Consolidated review: #1512 (comment)

// 3. Exchange the base token for a chat.bot-scoped token via IAM
// Credentials generateAccessToken (the SA impersonates itself).
let url = format!(
"{}/v1/projects/-/serviceAccounts/{email}:generateAccessToken",

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 F1 - Replace prohibited access-token self-impersonation

This URL targets the same service account whose short-lived metadata access token authenticates the request. Google Cloud explicitly prohibits using a service account's short-lived credential to generate a new access token for that same service account and documents FAILED_PRECONDITION.

Official reference: https://docs.cloud.google.com/iam/docs/service-account-creds#self-impersonation

Requested change: require a distinct Google Chat target service account, grant the runtime service account Token Creator on that target, and reject runtime/target equality before obtaining the base token.

@sebastian-hsu

Copy link
Copy Markdown
Contributor Author

Thanks @chaodu-obk. On F1 I'd push back with current evidence; F2 I read as pre-existing send-once semantics rather than a defect this PR introduces. I've left #1513 / 3a1ce860 unpulled pending this discussion.

F1 — self-impersonation is working in this configuration; it is not returning FAILED_PRECONDITION.

The keyless mint was exercised end-to-end on a live GCP workload today, with the same runtime identity that authenticates the request as the generateAccessToken target, requesting the chat.bot scope:

googlechat ADC token minted (chat.bot, ttl 3599s) service_account=<runtime SA>
→ Chat reply delivered

This is not a stale or different-identity result. The same SA authenticates and is the target; the only difference is the requested scope (cloud-platform base → chat.bot). It succeeds because the runtime SA holds roles/iam.serviceAccountTokenCreator on itself — the supported pattern for minting a token at a different scope — which is distinct from the prohibited "new token for the same SA" no-op. The FAILED_PRECONDITION self-impersonation error is real when that self-binding is absent (or for a same-scope loop); it does not match the observed behavior here. §Why already establishes that chat.bot is a Workspace scope unreachable via the metadata ?scopes= parameter, so generateAccessToken is the only keyless path — and it is empirically working.

That said, a distinct Chat target SA (runtime SA impersonates a separate Chat SA) is a valid and arguably more portable alternative. I'd frame it as an optional deployment topology rather than a correctness fix, since it adds a second SA and cross-project IAM surface. I'm happy to (a) document it as an alternative in Option C, and (b) add a defensive log/guard if a self-impersonation config ever does return FAILED_PRECONDITION. Whether to mandate a distinct SA vs. allow self-impersonation seems like a maintainer call given the live validation, so I'd rather not silently redesign the auth model around a documented case that this configuration does not hit.

F2 — real behavior, but pre-existing send-once semantics on the WebSocket path, not a defect introduced here.

Correct that send_gateway_reply creates a request_id only when streaming, so a send-once reply returns gw_sent without awaiting a GatewayResponse. But that is the existing contract for every NON_STREAMING_PLATFORMS member (LINE, LINE WORKS) — the in-code comments describe it as intentional legacy for adapters that don't emit GatewayResponse for replies. This PR makes googlechat consistent with them; it doesn't alter that path's ack semantics. The cited gateway.rs:282-349 is also the WebSocket-gateway path; the embedded/unified adapter posts directly and surfaces the Chat API Result.

I do agree googlechat can report a delivery failure (unlike LINE), so surfacing it is a genuine improvement. I'd propose tracking it as a follow-up next to F29 — send-once acknowledgement modeled as a capability separate from cosmetic streaming, applied to every send-once platform that can ack — rather than folding a cross-platform ack redesign into this PR. If you'd prefer it in-scope here, I can add a googlechat-scoped version that awaits the adapter Result and returns Err on a non-2xx.

Head unchanged (bf6ee1ed).

@github-actions github-actions Bot added the closing-soon PR missing Discord Discussion URL — will auto-close in 24 hours. label Sep 3, 2026
@github-actions

github-actions Bot commented Sep 3, 2026

Copy link
Copy Markdown

Caution

This PR has been waiting on the author for more than 2 days (labeled pending-contributor since 2026-09-01).
It will be automatically closed in 24 hours if there is no update.

@sebastian-hsu — You must add a new comment on this PR to remove the closing-soon label and keep it open. Pushing commits alone is not sufficient. Feel free to reopen a new PR later if it gets closed and you want to pick it back up.

@sebastian-hsu

Copy link
Copy Markdown
Contributor Author

Still active — keeping this open.

Status at head bf6ee1ed (all 42 checks green):

  • F1–F28 from the earlier rounds are all implemented and verified; the round-5/6 fixes came in via bd62ee49, cherry-picked here, which supersedes fix(googlechat): supported keyless ADC and reliable send-once #1513. F29 is tracked in Consolidate googlechat token-source cache machinery (review F29 follow-up) #1514.
  • The two open items are round-7's F1 and F2, which I responded to on Sep 1:
    • F1 (Critical — "prohibited self-impersonation"): this flow is working in production. A live GCP workload minted a chat.bot token via generateAccessToken with the same runtime SA as both caller and target — only the requested scope differs — and delivered a Chat reply. Same identity, current, not a stale result. It works because the runtime SA holds roles/iam.serviceAccountTokenCreator on itself, the supported different-scope pattern, which is distinct from the prohibited same-scope no-op. I'd rather not redesign the auth model around a documented case this configuration does not hit — but I'm happy to document the distinct-target-SA topology as an alternative, and to add a defensive guard/log if that call ever does return FAILED_PRECONDITION.
    • F2 (send-once delivery acknowledgement): real behavior, but it is the pre-existing contract for every NON_STREAMING_PLATFORMS member (LINE, LINE WORKS) on the WebSocket path — this PR doesn't change those ack semantics. Happy to add a googlechat-scoped version here if preferred, or track it as a follow-up alongside F29.

@thepagent — this is now a design judgement rather than a defect, and the empirical result contradicts the documented failure mode. Could you rule on whether self-impersonation is acceptable here, or whether a distinct Chat target service account should be mandated? I'll implement whichever you decide — likewise on whether F2 belongs in this PR or as a follow-up.

@github-actions

github-actions Bot commented Sep 4, 2026

Copy link
Copy Markdown

🔒 Auto-closing: this PR has had the closing-soon label for more than 1 days without activity from the author.

If you'd like to continue working on this, please submit a new PR and link to this one if necessary.

@github-actions github-actions Bot closed this Sep 4, 2026
@thepagent thepagent reopened this Sep 4, 2026
@chaodu-obk

chaodu-obk Bot commented Sep 4, 2026

Copy link
Copy Markdown
Contributor

Important

CHANGES REQUESTED ⚠️ - Group revalidation at the exact head confirms two blockers: the keyless path uses a Google-prohibited access-token self-impersonation flow, and forcing Google Chat send-once prevents delivery failures from reaching core.

What This PR Does

This PR adds keyless Google Chat authentication through GCE metadata and IAM Credentials, preserves the existing SA-key and static-token paths, and forces Google Chat into send-once mode because the unified synthetic message id cannot be patched.

How It Works

MetadataTokenSource reads the attached runtime service account email and short-lived access token from the metadata server, calls generateAccessToken for that same email with the chat.bot scope, and caches the result. Core adds Google Chat to NON_STREAMING_PLATFORMS, so normal replies are posted once instead of using placeholder edits. The latest commit also contains the previously requested timeout, validation, configuration, documentation, and operability hardening.

Findings

# Severity Finding Location
1 🔴 Critical The ADC flow authenticates with a service account's short-lived access token and requests a new access token for the same service account. Google explicitly prohibits this access-token self-impersonation and documents FAILED_PRECONDITION; changing only the requested scope does not create an exception. crates/openab-gateway/src/adapters/googlechat.rs:1182-1197
2 🟡 Important Google Chat is forced send-once, but core creates a request ID only when streaming == true. Adapter auth/API failure responses are therefore uncorrelated, and core returns synthetic gw_sent success without knowing whether delivery failed. crates/openab-core/src/gateway.rs:282-349; crates/openab-gateway/src/adapters/googlechat.rs:472-574
3 🟢 Praise The prior F15-F28 hardening is present, cache consolidation is tracked in #1514, the send-once policy is consistently applied to both dispatch paths, and the exact-head CI history is green. -
Finding Details

🔴 F1: Replace prohibited access-token self-impersonation

The metadata endpoint returns a short-lived access token for the default runtime service account. The implementation then puts that same metadata email in the generateAccessToken target URL and authenticates the request with the metadata token.

Google Cloud's current official service-account credentials documentation explicitly states that the Service Account Credentials API prohibits "using a short-lived credential for a service account to generate a new access token for the service account." It documents this failure:

FAILED_PRECONDITION: You can't create a token for the same service account that you used to authenticate the request.

Official reference: https://docs.cloud.google.com/iam/docs/service-account-creds#self-impersonation (last updated 2026-08-28 UTC).

The external live result is useful evidence about one observed deployment, but it does not supersede the current vendor contract. The official rule is based on caller and target identity, not whether the requested scope differs. This also exposes a rationale mismatch: the PR body claims runtime/bot identity separation, while the implementation always uses the metadata service account as both caller and target.

Requested change: require a distinct Google Chat target service account, grant the attached runtime service account roles/iam.serviceAccountTokenCreator on that target, call generateAccessToken for the target, and reject runtime/target equality before requesting the metadata base token. Companion PR #1513 implements this supported topology at 3a1ce860.

🟡 F2: Preserve delivery acknowledgement independently of streaming

send_gateway_reply creates request_id only when self.streaming is true. This PR deliberately makes Google Chat non-streaming, so normal Google Chat replies on the WebSocket path carry no request ID. GoogleChatAdapter::handle_reply emits auth, empty-message, API, and chunk failure GatewayResponse values only when a request ID exists. Those failures therefore cannot match a pending request, and core takes the no-ack branch that returns gw_sent.

This is in scope even though LINE and LINE WORKS already use legacy fire-and-forget semantics: before this PR, Google Chat could be configured to stream and therefore carried an acknowledgement ID; adding it to NON_STREAMING_PLATFORMS makes the no-ack behavior unconditional for this platform. The embedded path can surface its direct adapter result, but that does not repair the WebSocket path changed here.

Requested change: model normal-reply acknowledgement separately from cosmetic streaming. Google Chat should remain send-once while carrying and awaiting a request ID; success=false, promised-channel closure, and promised acknowledgement timeout should return an error to core. Companion PR #1513 implements this separation and regression coverage at 3a1ce860.

🟢 F3: What is done well

See "What's Good" below.

Group Review Summary

Reviewer Angle Verdict Resolution
Reviewer A Architecture LGTM No architecture blocker; the runtime/bot identity-decoupling rationale mismatch is folded into F1.
Reviewer B Performance and correctness Changes requested Independently confirmed F1 and F2 against the exact head and official vendor documentation.
Reviewer C Simplicity and YAGNI Follow-ups Duplicate token-cache machinery is already tracked by #1514. The best-effort GCP error classifier retains status and raw body, so it is accepted as a non-blocking trade-off rather than a new blocker.

Addressing External Reviewer Feedback

@canyugs (Rounds 1 and 2)

Live GCP testing showed same-service-account minting produced a chat.bot token, and the original stale-base, GCE-scope, and streaming-rationale blockers were fixed.

Partially superseded by authoritative vendor documentation: the original blockers remain fixed, but Google's current service-account credential contract explicitly prohibits the exact short-lived-access-token self-impersonation performed here. F1 follows that higher-authority contract while preserving the empirical result as an important discrepancy to report upstream.

@sebastian-hsu

Different requested scope plus a self serviceAccountTokenCreator binding makes this a supported pattern; F2 is pre-existing send-once behavior and could be a follow-up.

Not accepted for F1: the official prohibition does not contain a different-scope exception; it keys on a short-lived caller credential and the same target service account. Not deferred for F2: this PR newly forces Google Chat from configurable streaming/acknowledged behavior to unconditional send-once/no-ack behavior on the WebSocket path, so the false-success regression belongs to this change. The F15-F28 fixes are present, and F29 is acceptably tracked by #1514.

@thepagent

CI and smoke tests were failing, so the PR was moved to Draft.

Resolved for the reviewed commit: the PR is open and ready for review. Earlier exact-head runs at bf6ee1ed completed green. Reopening the PR triggered a fresh run; conformance, Helm, validation, and most smoke jobs are green, while check, build-builder, and a small number of smoke jobs were still running at publication time.

Baseline Check
  • PR opened: 2026-08-27.
  • Reviewed head: bf6ee1ede0d7aeb78b863a1259cbab4e5fe9ea9c.
  • Declared base branch: main; base head at review time: 474bbadb9041723d0bf7cbf7e78c980182a1a97f.
  • Merge base: d4f376f670982b2c93d5f58fddc2422fb19dd924.
  • Three-dot diff: 14 files, +879/-57.
  • Main already has SA-key exchange, static-token auth, and the WebSocket-side non-editable-platform gate.
  • Net-new value: keyless ADC wiring, a shared send-once gate for embedded and WebSocket paths, and the prior F15-F28 hardening.

Validation

  • Clean detached worktree at the exact reviewed SHA.
  • git diff --check origin/main...HEAD: clean.
  • Official Google IAM service-account credential contract re-read directly; the self-impersonation prohibition and documented FAILED_PRECONDITION are explicit.
  • Local Rust tests could not be rerun because this environment has neither cargo nor rustc.
  • Earlier exact-head CI completed successfully. The PR-reopen rerun was mostly green but still in progress as noted above.
  • Companion PR fix(googlechat): supported keyless ADC and reliable send-once #1513 at 3a1ce860 demonstrates that both requested changes are implementable.

5. Three Reasons We Might Not Need This PR

  1. The central auth topology is unsupported as submitted - merging it would advertise a keyless option that the current official Google contract prohibits.
  2. Two independent changes are bundled - the valid send-once correction can land independently from the more complex ADC identity design, reducing rollback and review risk.
  3. A supported implementation already exists separately - companion PR fix(googlechat): supported keyless ADC and reliable send-once #1513 contains the distinct-target and delivery-ack corrections, while Consolidate googlechat token-source cache machinery (review F29 follow-up) #1514 tracks cache consolidation; maintaining divergent implementations adds integration risk.
What's Good (🟢)
  • F15-F28 are implemented with bounded token requests, non-empty token validation, private/no-redirect credential endpoints, clearer precedence, and improved diagnostics.
  • The send-once rationale is now consistent across core, schema, and operator docs.
  • GoogleChatParts replaces fragile positional construction, and resolve_streaming centralizes the shared policy for both dispatch paths.
  • Cache consolidation is explicitly tracked in Consolidate googlechat token-source cache machinery (review F29 follow-up) #1514 instead of forcing a speculative abstraction into this PR.
  • The exact reviewed head has a previously green full CI run, and the reopen rerun has shown no failure so far.

@chaodu-agent

Copy link
Copy Markdown
Collaborator

Important

CHANGES REQUESTED ⚠️ — Concurs with the standing blockers (self-impersonation contract violation; send-once removing delivery acknowledgement). This is a supplementary pass that adds hardening findings on the keyless path and its tests; it does not restate the two primary blockers.

Scope of this review

Independent read of the reconciled PR diff only — 14 files, +879/-57 against merge-base d4f376f, head bf6ee1e. (A three-dot main... diff is misleading here: local main has advanced past the merge-base and pulls in already-merged subsystems; reviewed against the merge-base.)

The decisive blocker is the ADC self-impersonation contract issue already raised; nothing below supersedes it. If that forces a redesign to a distinct-target-SA topology, findings 1–2 are revisited as part of that rewrite.

Findings (supplementary)

# Severity Finding Location
1 🔴 No-redirect client fails open on builder error crates/openab-gateway/src/adapters/googlechat.rs:1069-1072
2 🔴 Metadata/IAM client does not disable proxies crates/openab-gateway/src/adapters/googlechat.rs:1069
3 🟡 New ADC unit tests touch network/filesystem without #[ignore] crates/openab-gateway/src/adapters/googlechat.rs:2216, 2302, 2370
4 🟡 Doubled, self-contradictory doc comment on the streaming gate crates/openab-core/src/gateway.rs:43-69
5 🟡 ADC→static-token fallback crosses credentials on an unverifiable identity claim crates/openab-gateway/src/adapters/googlechat.rs:383-401
Finding Details

🔴 F1: no-redirect client fails open

client: reqwest::Client::builder()
    .redirect(reqwest::redirect::Policy::none())
    .build()
    .unwrap_or_default(),

The adjacent comment states the metadata bearer must never follow a redirect to a third host. But on a builder error unwrap_or_default() yields a default client, which does follow redirects — silently defeating the stated guarantee. A builder failure is near-impossible in practice and reqwest also strips Authorization across hosts, so real-world impact is low; nonetheless the fallback contradicts the invariant. Use .expect("no-redirect client") (fail loud at construction) or propagate a Result.

🔴 F2: proxies not disabled on the token-mint client

The client is built without .no_proxy(). reqwest honours HTTP_PROXY/HTTPS_PROXY by default, so the plaintext metadata access-token response (and the IAM generateAccessToken bearer call) can be routed through an operator- or attacker-controlled proxy host. The metadata endpoint in particular should be reached with a client that has proxies disabled.

🟡 F3: test isolation

metadata_token_source_mints_chat_bot_token and adc_takes_precedence_over_static_access_token stand up a Wiremock loopback HTTP server; from_parts_unreadable_key_file_with_use_adc_installs_adc reads the filesystem. All are #[tokio::test]/#[test] with no #[ignore]. Per this repo's unit-test conventions, a unit test that touches network or filesystem must be an #[ignore]d integration test (or driven through a hand-written transport seam). Refactor behind an injected transport, or mark them #[ignore].

🟡 F4: contradictory doc comment

gateway.rs:43-59 still carries the old paragraph — "…messaging API cannot edit a message after it is sent" and "any new gateway platform that lacks a message-edit API MUST be added here" — immediately above the new NON_STREAMING_PLATFORMS doc (line 60+) which admits Google Chat has an edit API but can't sustain per-token editing. The next platform-adder reads two conflicting admission criteria at the same site. Delete the stale paragraph; make the single criterion "cosmetic streaming not viable".

🟡 F5: cross-credential fallback

get_token degrades an ADC mint failure to the static access_token (:383-401) on the rationale that both are "the same workload's credential". That is not verifiable in code — a configured static token can belong to a different SA. Defensible as documented, but consider hard-failing ADC by default and gating cross-credential degradation behind an explicit opt-in.

Test coverage gaps (non-blocking)
  • No test pins the SA-key hard-fail asymmetry (an SA-key exchange error must return None without consulting an installed metadata_source/static token).
  • Serve-stale-on-refresh-failure branches (both GoogleChatTokenCache and MetadataTokenSource) are untested.
  • No test asserts caching (a second get_token should not re-hit the mock).
  • The no-redirect client behaviour is untested — a Wiremock 302 test would also have caught F1.
Verified
  • Read at head bf6ee1e; line citations confirmed against the blob at that SHA.
  • Send-once gating verified correct on both paths — embedded (adapter.rs resolve_streaming) and WebSocket (gateway.rs platform_supports_streaming) — with a defence-in-depth edit_message guard rejecting non-spaces/ ids.
  • Config→env→Helm→docs→schema wiring for use_adc is complete and defaults false; existing SA-key/static paths untouched.
  • Not built locally; no build/test signal claimed here beyond source review.

@thepagent

Copy link
Copy Markdown
Collaborator

close in favor of #1513

@thepagent thepagent closed this Sep 4, 2026
@sebastian-hsu

Copy link
Copy Markdown
Contributor Author

Correction, for anyone reading this thread later: my push-back on the round-7 F1 finding above was wrong.

I argued that keyless ADC self-impersonation was acceptable because the requested scope differed (cloud-platformchat.bot) and the SA held serviceAccountTokenCreator on itself. I had not read the cited documentation when I wrote that. The documented rule turns on the caller's credential type and caller-identity == target-identity; scope is not part of the criterion, and the only exception is a self-signed JWT. My "different scope" rationale was an inference I presented as fact.

The observation that it worked in practice was real, but the right reading of that is "not currently enforced here", not "supported" — and that is not a basis for a production auth path.

We have since adopted the distinct-target design from #1513 in production and verified it, including a cross-project target. Full correction and the production validation are on #1513.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

closing-soon PR missing Discord Discussion URL — will auto-close in 24 hours. pending-contributor

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants