ADR 0011 — Connection credential isolation across workflows and runs¶
Status: Accepted (Phase 1 + Phase 2)
Date: 2026-07-07
Related: ADR 0003, Connections reference, engine/services/orchestrator/app/oauth_token_manager.py, engine/services/orchestrator/app/connections.py
Context¶
Operators asked whether an HTTP connection authenticated as user A in workflow A can leak into workflow B where user B should be used.
MeshFlows separates two concerns:
| Layer | What is shared | Credential risk |
|---|---|---|
| HTTP transport | httpx connection pool (orchestrator → egress pods) |
None — auth is per-request headers, not socket state |
| Credential / token cache | Process-wide OAuth and provider token caches | Yes — if cache keys are too coarse |
Before this ADR:
- OAuth tokens were cached keyed on connection name only, so two workflows embedding the same name with different
client_id/client_secretcould reuse the wrong token once OAuth was wired to the execution path. OAuthTokenManagerexisted but was not used duringservice_callexecution; rawoauthblocks were merged into provider payloads.workflow_callpassed the parent merged connection dict to child workflows instead of re-merging registry + child embedded connections.X-Root-Run-IDwas sent to providers for future per-run isolation but not consumed by egress caches.
Decision¶
1. Cache key = connection name + OAuth identity digest¶
OAuth client-credentials tokens are cached under:
{connection_name}|{sha256(oauth_identity)[:16]}
where oauth_identity is the stable JSON of token_url, client_id, client_secret, scope, and audience after $secret: resolution.
Rationale: Client-credentials tokens are application-level credentials. Runs that share the same resolved OAuth config should share a token. Runs with different configs must not collide even when the connection name is the same (e.g. embedded overrides per workflow).
For delegated OAuth (authorization_method ≠ client_credentials), the cache key also includes |run:{X-Root-Run-ID} when the orchestrator passes a root run id (Phase 2).
2. Execution path: resolve OAuth before egress¶
On service_call steps with a connection: that defines oauth:
- Resolve
$secret:references in the OAuth block. - Obtain a bearer token via
OAuthTokenManager. - Strip
oauthfrom the outbound provider payload. - Inject
Authorization: Bearer …intodefault_headers(merged with any existing headers).
Raw client secrets must never be forwarded to egress microservices.
3. Effective connections per workflow document¶
At each top-level invocation:
effective = registry_connections ∪ embedded_workflow_connections # embedded wins on name clash
workflow_call must recompute effective_connections_for_workflow(child_doc) from the registry snapshot, not inherit the parent's embedded overrides.
4. Transport pooling unchanged¶
The shared orchestrator httpx.AsyncClient remains process-wide. This ADR does not introduce per-run HTTP clients; transport reuse is safe because headers are set per request.
5. Phase 2 — per-run isolation for delegated credentials¶
| Mechanism | Behaviour |
|---|---|
meshflows_egress.isolation |
Shared helpers: is_delegated_oauth(), egress_cache_key_suffix() |
Orchestrator OAuthTokenManager |
Appends \|run:{root_run_id} when OAuth is delegated and X-Root-Run-ID is set |
egress-google-workspace |
HTTP middleware captures X-Root-Run-ID; token cache includes run suffix when impersonate_user is set |
| Client credentials | Unchanged — tokens remain shared across runs with the same OAuth identity |
workflow_runner passes effective_root_run_id into build_connection_fields_for_service_call → oauth_token_getter.
6. Future work (Phase 3)¶
| Item | Intent |
|---|---|
| Parallel scope isolation | ADR 0003 / 0004 — separate connection views per lane (if ever required) |
| Additional egress providers | Reuse meshflows_egress.isolation wherever provider-side token caches exist |
Consequences¶
- Workflows A and B with different connection names or different embedded OAuth configs under the same name are isolated for token reuse.
- Workflows that intentionally share a global registry connection continue to share credentials (by design).
- Inbound gateway user identity (JWT) is not automatically mapped to outbound connection credentials; use separate connections or explicit
impersonate_user(Google Workspace). DELETE /internal/oauth/tokens/{connection_name}clears all cache entries whose key starts with{connection_name}|(credential rotation).
Mapping to code¶
| Mechanism | Location |
|---|---|
| OAuth cache key | oauth_token_manager.oauth_token_cache_key() |
| Strip oauth + inject bearer | connections.build_connection_fields_for_service_call() |
service_call wiring |
workflow_runner.run_workflow() |
| Registry + embedded merge | connections.effective_connections_for_workflow() |
| Child workflow connections | workflow_call branch in workflow_runner |
| Egress run isolation helpers | engine/meshflows_egress/isolation.py |
| Google Workspace run-scoped cache | engine/services/google-workspace/app/main.py middleware + _token_cache_key |