You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
Resolving channel credentials at dispatch time for multi-tenant bot platforms
Context
We operate a production multi-tenant bot platform built on the channel extensions (WeCom / Feishu / DingTalk). In this deployment shape, channel credentials are tenant data, not deployment configuration:
Each tenant connects their own bot app; credentials (app ids, secrets, encoding AES keys) live in a database, keyed by bot id, and the callback URL path carries that routing key.
Tenants are added, removed, and rotated at runtime — no restart, no redeploy, potentially dozens of edits per day.
The service runs multiple instances behind one callback domain.
Where the current pipeline assumes build-time credentials
Four points, all on current main:
Channels are constructed from resolved properties.ChannelFactory.create(channelId, routing, properties) (ChannelFactory.java:35) builds a channel from a static property map. For example, WeComChannel.fromProperties bakes WeComCrypto, WeComAccessTokenProvider and WeComOutboundClient into private final fields (WeComChannel.java:54-64) — for the lifetime of the channel instance, its credentials cannot change.
Inbound dispatch resolves a channel instance, not credentials. The callback controllers look up a fully-built channel by id — WeComCallbackController → WeComChannelRegistry.instance().get(channelId) (WeComCallbackController.java:117; same shape at FeishuCallbackController.java:79 and DingTalkCallbackController.java:82) → channel.crypto() — so the credentials used to verify/decrypt a request are whatever the resolved instance was constructed with.
The per-provider registries are process-wide static singletons (WeComChannelRegistry.java:32-41), bridging the factory-created channel and the Spring-scanned controller (the class javadoc notes neither knows about the other ahead of time).
The framework's own answer to a runtime change is rebuild-and-replace. The service scheduler refetches channel config entries and calls factory.create(...) again (SchedulerChannelRuntime.java:269-300); the harness ChannelManager swaps instances on register/unregister.
Why rebuild-and-replace is a poor fit for tenant credentials
Not incorrect — coarse:
A single secret rotation tears down and rebuilds a whole live object (token caches and any per-channel state discarded).
The swap races in-flight callbacks: a request that resolved the old instance decrypts with the outgoing key while the platform already expects the new one — transient verification failures on every tenant edit.
With N tenants × M providers, every edit anywhere means full object churn, when all the application actually needs is "look up the credential for this routing key" — a query it usually already performs elsewhere.
Stream-mode clients (DingTalk Stream) bind credentials at construction as well, so the assumption is not specific to the HTTP callback path.
Direction we'd like to discuss
The primitive layer already factors well for multi-tenancy — FeishuCrypto/WeComCrypto, the inbound mappers, the outbound clients, and (since the inbound-dedup SPI) the per-instance dedup component are all usable standalone or pluggable. The remaining build-time assumption is confined to the assembly/dispatch layer. A deliberately minimal shape to react to:
A per-provider credential resolver hook at the dispatch boundary: the callback controllers (or the per-provider registries) accept an optional resolver from routing key → credentials, with the static registry as the default when no resolver is registered.
The resolver is entirely application-owned — a static map, a DB query with a short cache, a control-plane call; the framework prescribes no storage.
Rotation becomes implicit and per-request: the resolver is consulted on each callback, so a rotation takes effect on the next request with no object swap and no lifecycle churn.
Open questions we don't have strong opinions on:
Routing-key granularity: channelId vs bot/app id vs something provider-specific.
Whether resolution should be strictly per-request or a cached/lease layer, and how such a cache would compose with access-token caching inside the outbound providers.
Whether the hook belongs at the controller/registry layer, or one level down where channel instances become credential-agnostic and receive credentials per dispatch.
#1966 asks for message-level context propagation (InboundMessage → MsgContext → RuntimeContext). This is a different layer — who owns channel identity and credentials at dispatch time. The two compose in a multi-tenant gateway, but neither subsumes the other.
Our stake
We currently keep our own callback controllers for exactly this reason and consume the framework's crypto/mapper/outbound primitives directly; with dedup now pluggable, credential lifecycle is the remaining gap that keeps a tenant-facing deployment off the shipped dispatch path. If the direction sounds reasonable, we'd be happy to contribute a prototype for one provider (e.g. WeCom) with tests.
reacted with thumbs up emoji reacted with thumbs down emoji reacted with laugh emoji reacted with hooray emoji reacted with confused emoji reacted with heart emoji reacted with rocket emoji reacted with eyes emoji
Uh oh!
There was an error while loading. Please reload this page.
Resolving channel credentials at dispatch time for multi-tenant bot platforms
Context
We operate a production multi-tenant bot platform built on the channel extensions (WeCom / Feishu / DingTalk). In this deployment shape, channel credentials are tenant data, not deployment configuration:
Where the current pipeline assumes build-time credentials
Four points, all on current
main:Channels are constructed from resolved properties.
ChannelFactory.create(channelId, routing, properties)(ChannelFactory.java:35) builds a channel from a static property map. For example,WeComChannel.fromPropertiesbakesWeComCrypto,WeComAccessTokenProviderandWeComOutboundClientintoprivate finalfields (WeComChannel.java:54-64) — for the lifetime of the channel instance, its credentials cannot change.Inbound dispatch resolves a channel instance, not credentials. The callback controllers look up a fully-built channel by id —
WeComCallbackController→WeComChannelRegistry.instance().get(channelId)(WeComCallbackController.java:117; same shape atFeishuCallbackController.java:79andDingTalkCallbackController.java:82) →channel.crypto()— so the credentials used to verify/decrypt a request are whatever the resolved instance was constructed with.The per-provider registries are process-wide static singletons (
WeComChannelRegistry.java:32-41), bridging the factory-created channel and the Spring-scanned controller (the class javadoc notes neither knows about the other ahead of time).The framework's own answer to a runtime change is rebuild-and-replace. The service scheduler refetches channel config entries and calls
factory.create(...)again (SchedulerChannelRuntime.java:269-300); the harnessChannelManagerswaps instances on register/unregister.Why rebuild-and-replace is a poor fit for tenant credentials
Not incorrect — coarse:
Direction we'd like to discuss
The primitive layer already factors well for multi-tenancy —
FeishuCrypto/WeComCrypto, the inbound mappers, the outbound clients, and (since the inbound-dedup SPI) the per-instance dedup component are all usable standalone or pluggable. The remaining build-time assumption is confined to the assembly/dispatch layer. A deliberately minimal shape to react to:Open questions we don't have strong opinions on:
channelIdvs bot/app id vs something provider-specific.Relationship to #1966
#1966 asks for message-level context propagation (
InboundMessage→MsgContext→RuntimeContext). This is a different layer — who owns channel identity and credentials at dispatch time. The two compose in a multi-tenant gateway, but neither subsumes the other.Our stake
We currently keep our own callback controllers for exactly this reason and consume the framework's crypto/mapper/outbound primitives directly; with dedup now pluggable, credential lifecycle is the remaining gap that keeps a tenant-facing deployment off the shipped dispatch path. If the direction sounds reasonable, we'd be happy to contribute a prototype for one provider (e.g. WeCom) with tests.
All reactions