Files
curriculum-project-hub/.scratch/saas-production-readiness/assets/product-surface-feishu-primary-sources.md
T

334 lines
21 KiB
Markdown

# Feishu multi-app identity and event-routing primary sources
This note answers one narrow design question for the production SaaS audit:
what identity and routing boundaries apply when **every Hub Organization uses
its own Feishu App and bot**. External claims below use only first-party Feishu
Open Platform documentation. Statements labelled **Derived requirement** are
Hub design implications, not guarantees made by Feishu.
Research date: 2026-07-10.
## Executive answer
1. A customer-owned enterprise custom app is a single-tenant app: Feishu says
it is developed, reviewed, published, and used inside one tenant and cannot
be used by another organization. That fits “one Organization, one Feishu
App,” but it means the Hub is operating many independent app security and
identity domains, not one shared Feishu identity domain.
([custom apps versus store apps](https://open.feishu.cn/document/home/app-types-introduction/self-built-apps-and-store-apps))
2. A bare `open_id` is **not** a SaaS-global user key. It identifies a user
inside one app, and the same user has different Open IDs in different apps.
`user_id` is tenant-scoped; it is stable across apps in one tenant but
differs across tenants. `union_id` crosses apps only for apps from the same
application provider/developer; it differs between providers.
([user resource overview](https://open.feishu.cn/document/server-docs/contact-v3/user/field-overview?lang=zh-CN),
[official ID parameter definitions](https://open.feishu.cn/document/server-docs/contact-v3/department/search?lang=zh-CN),
[Union ID guide](https://open.feishu.cn/document/uAjLw4CM/ugTN1YjL4UTN24CO1UjN/trouble-shooting/how-to-obtain-union-id))
3. OAuth must be routed to the Organization's app **before** authorization.
The authorization request contains that app's App ID as `client_id`; the
callback contains `code` and the echoed `state`, not an independently
trustworthy Organization choice. Exchanging the code requires the matching
app's `client_id` and `client_secret`, and the same `redirect_uri`.
([obtain authorization code](https://open.feishu.cn/document/common-capabilities/sso/api/obtain-oauth-code),
[obtain user access token](https://open.feishu.cn/document/authentication-management/access-token/get-user-access-token))
4. A version 2 event or callback carries `header.app_id` and
`header.tenant_key` after parsing/decryption. An encrypted HTTP delivery,
however, exposes only an `encrypt` envelope before decryption, so a shared
endpoint cannot safely choose among many apps' Encrypt Keys from the body.
([callback structure](https://open.feishu.cn/document/event-subscription-guide/callback-subscription/callback-overview),
[encrypted Webhook delivery](https://open.feishu.cn/document/event-subscription-guide/event-subscriptions/event-subscription-configure-/choose-a-subscription-mode/send-notifications-to-developers-server?lang=zh-CN))
5. A WebSocket/long-connection client is authenticated with one app's App ID
and App Secret. Therefore its local client instance is the pre-authenticated
source-app route. It is not a process-global “all customer apps” listener.
([long-connection event subscription](https://open.feishu.cn/document/server-docs/event-subscription-guide/event-subscription-configure-/request-url-configuration-case?lang=zh-CN))
6. Feishu documents `chat_id` as the conversation identifier, but the cited
documentation does not promise that a raw `chat_id` is globally unique
across every tenant and app. Chat APIs explicitly reject an operator and
chat from different tenants and often require the app bot to be in the
chat. A bot is represented by its application's `app_id`.
([common parameters](https://open.feishu.cn/document/ukTMukTMukTM/uYTM5UjL2ETO14iNxkTN/terminology),
[get chat members](https://open.feishu.cn/document/server-docs/group/chat-member/get?lang=zh-CN),
[message resource](https://open.feishu.cn/document/server-docs/im-v1/message/intro))
## 1. Application and tenant boundary
### Official facts
- `app_id` is the immutable identifier generated by Feishu for an application.
`tenant_key` is the enterprise identifier; one `tenant_key` corresponds to
one enterprise in actual use.
([common parameters](https://open.feishu.cn/document/ukTMukTMukTM/uYTM5UjL2ETO14iNxkTN/terminology))
- An enterprise custom app is restricted to one tenant. Its developers,
administrators, and users belong to that tenant, and another organization
cannot use it. A store app is the contrasting model that can be installed by
multiple tenants.
([custom apps versus store apps](https://open.feishu.cn/document/home/app-types-introduction/self-built-apps-and-store-apps))
- Access tokens tell Feishu which app/user is calling and which tenant's data
is being accessed. A `tenant_access_token` calls as the application and its
data range is determined by that application's permissions.
([access-token overview](https://open.feishu.cn/document/server-docs/api-call-guide/calling-process/get-access-token))
- A custom app obtains its `tenant_access_token` using that app's `app_id` and
`app_secret`.
([custom-app tenant access token](https://open.feishu.cn/document/server-docs/authentication-management/access-token/tenant_access_token_internal?lang=zh-CN))
### Derived requirements
- Model a durable `FeishuAppConnection` (or equivalent) owned by exactly one
Hub Organization. At minimum it binds the internal connection ID,
`organization_id`, `app_id`, expected `tenant_key`, encrypted `app_secret`,
event/callback credentials, lifecycle state, and credential version.
- Do not select Feishu credentials from a process-global default. Every OAuth
exchange, token cache entry, OpenAPI call, Webhook verification, and
long-connection client must start from the Organization's connection.
- Pin the first verified `tenant_key` to the connection. Later login, event,
and callback data must match both the expected `app_id` and pinned
`tenant_key`; unknown or mismatched pairs must be rejected rather than
auto-attached to another Organization.
## 2. User identifier scopes
### Official scope matrix
| Identifier | Feishu's documented scope | Cross-app/tenant behavior | Availability caveat |
| --- | --- | --- | --- |
| `open_id` | User identity inside one application | The same user has a different `open_id` in different apps | The normal app-relative user identifier |
| `user_id` | User identity inside one tenant | Stable across all apps in the same tenant; different in tenant A and tenant B | Reading it is a sensitive field permission and is limited to custom apps in the cited user API |
| `union_id` | User identity across apps from the same application provider/developer | Same across that provider's apps; different across different providers | It does not make unrelated customer-owned apps one identity domain |
| `tenant_key` | Enterprise identity | One key corresponds to one enterprise | Returned by login and carried in events/callbacks |
Sources:
- Feishu's current user resource overview states that `user_id` is tenant
identity, `open_id` is app identity, and `union_id` is identity across apps
developed by the same application provider. It expressly says the same
user's Open ID differs between apps.
([user resource overview](https://open.feishu.cn/document/server-docs/contact-v3/user/field-overview?lang=zh-CN))
- Feishu's API parameter text is more explicit: the same user's User ID differs
between tenants but remains the same across apps within one tenant; the same
user's Union ID is the same under one developer and different under another.
([official ID parameter definitions](https://open.feishu.cn/document/server-docs/contact-v3/department/search?lang=zh-CN))
- Feishu describes Union ID as the identity linking one user across multiple
applications supplied by the same application service provider.
([Union ID guide](https://open.feishu.cn/document/uAjLw4CM/ugTN1YjL4UTN24CO1UjN/trouble-shooting/how-to-obtain-union-id))
- Feishu's common-error guidance says not to use an Open ID obtained from app A
in app B, and even a test app and its production app produce different Open
IDs for the same user. It also reports cross-tenant errors for both User ID
and Union ID use.
([common errors](https://open.feishu.cn/document/ukTMukTMukTM/ugjM14COyUjL4ITN?op_tracking=hc))
- The current login user-info API returns `open_id`, `union_id`, optional
sensitive `user_id`, and `tenant_key` from a `user_access_token`.
([get login user information](https://open.feishu.cn/document/server-docs/authentication-management/login-state-management/get?lang=zh-CN))
### Answer: can different customer Apps share one global `open_id` key?
**No.** This directly contradicts Feishu's documented Open ID semantics. Two
customer-owned custom apps are different applications, so the same person has
different Open IDs in them. Because those apps are independently owned custom
apps, the Hub also has no documented basis to assume they are applications of
one application provider and therefore no basis to auto-merge on `union_id`.
`user_id` cannot solve cross-customer identity either because it changes across
tenants.
Derived identity model:
- The safe external login key is `(feishu_app_connection_id, open_id)`; retain
`tenant_key`, `user_id`, and `union_id` as scoped attributes, not as an
unqualified SaaS-global primary key.
- If the product needs one Hub human account to participate in multiple
Organizations, represent each Feishu login as a separate external identity
attached through an explicit, separately verified account-linking flow. Do
not silently merge on Open ID, Union ID, email, phone, or display name.
- Do not make `user_id` mandatory for login unless every customer app is
required to obtain its sensitive field permission. `open_id` and
`tenant_key` are returned by the user-info flow without using `user_id` as
the login key.
- Replacing an Organization's Feishu App changes the application identity
domain and therefore its users' Open IDs. Treat app replacement as an
explicit connection migration/re-link operation, not an in-place secret
edit.
## 3. OAuth login must be app-routed
### Official facts
- The authorization URL requires `client_id`, which is the selected
application's App ID. `redirect_uri` must already appear in that
application's security settings. On success Feishu redirects with `code`
and, when supplied, the original `state`.
([obtain authorization code](https://open.feishu.cn/document/common-capabilities/sso/api/obtain-oauth-code))
- Feishu says `state` maintains request/callback context and must be compared
to prevent CSRF. Its official sample rejects a callback whose state differs
from the stored state.
([obtain authorization code](https://open.feishu.cn/document/common-capabilities/sso/api/obtain-oauth-code),
[user-access-token sample](https://open.feishu.cn/document/authentication-management/access-token/get-user-access-token))
- The code lasts five minutes and can be used once. Token exchange requires
`client_id`, `client_secret`, `code`, and, when used during authorization,
the same `redirect_uri`. Feishu defines error 20024 for a code/refresh token
that does not match the supplied client ID and error 20071 for a mismatched
redirect URI.
([obtain user access token](https://open.feishu.cn/document/authentication-management/access-token/get-user-access-token))
- Only redirect URLs configured for the selected application pass Feishu's
security check; an app may configure multiple URLs.
([access-token acquisition flow](https://open.feishu.cn/document/server-docs/api-call-guide/calling-process/get-access-token))
### Derived request flow
1. Begin at an Organization-qualified route or invitation, resolve exactly one
active `FeishuAppConnection`, and create a short-lived, one-time server-side
login transaction bound to that Organization, connection, redirect URI,
desired return path, and PKCE verifier if used.
2. Put only a high-entropy opaque transaction nonce in OAuth `state`; generate
the authorization URL with that connection's `app_id` as `client_id`.
3. On callback, atomically consume and validate `state` before selecting
credentials. The callback's untrusted query parameters must never be
allowed to choose another Organization or App.
4. Exchange `code` with the connection's `app_id`, decrypted `app_secret`, and
the exact stored redirect URI. Then call user-info and require its
`tenant_key` to equal the connection's pinned tenant.
5. Resolve or create the membership through
`(feishu_app_connection_id, open_id)`. Never search all Organizations for a
bare Open ID and accept the first match.
A single physical callback URL may be configured independently in multiple
apps, but a shared URL does not remove the need for state-bound app routing.
## 4. HTTP event and callback routing
### Official facts
- A version 2 callback header contains the application's Verification Token,
`event_type`, `tenant_key` (the callback application's tenant), and `app_id`
(the callback application's App ID).
([callback overview and structure](https://open.feishu.cn/document/event-subscription-guide/callback-subscription/callback-overview))
- Version 2 event examples likewise place `app_id` and `tenant_key` in the
event header.
([user-leaves-chat event](https://open.feishu.cn/document/server-docs/group/chat-member/event/deleted-2))
- Verification Token is an application validation identifier. Feishu says the
server can compare the event's token with that application's token to check
that a request is for the specified app. With an Encrypt Key, the request
signature is calculated from request timestamp, nonce, that app's Encrypt
Key, and the original request body.
([receive and verify events](https://open.feishu.cn/document/server-docs/event-subscription-guide/event-subscription-configure-/encrypt-key-encryption-configuration-case))
- When an Encrypt Key is configured, the HTTP request body visible before
decryption is an envelope containing only `{"encrypt":"..."}`; `app_id`
and `tenant_key` are inside the ciphertext.
([send events to developer server](https://open.feishu.cn/document/event-subscription-guide/event-subscriptions/event-subscription-configure-/choose-a-subscription-mode/send-notifications-to-developers-server?lang=zh-CN))
### Derived routing and verification requirements
- Give every Feishu connection a stable, unguessable callback route such as a
connection-specific path token. Resolve the connection from that route,
load exactly its Encrypt Key/Verification Token, verify and decrypt, then
require the authenticated payload's `app_id` and `tenant_key` to match the
route's connection.
- For an unencrypted common endpoint, `header.app_id` may identify a candidate
connection, but it is untrusted until that connection's signature/token has
been verified. Never route the side effect solely from an unverified body.
- A single encrypted URL shared by every customer app has no documented
pre-decryption app selector. Trying every tenant's secret is not an identity
protocol. Use connection-specific routing instead.
- Namespace delivery deduplication by the authenticated connection (for
example `(connection_id, event_id)`), and dispatch business work only after
app/tenant verification. Unknown, disabled, or mismatched connections fail
closed and produce an auditable security log.
## 5. Long-connection routing
### Official facts
- Feishu's SDK creates a WebSocket client with one app's required `APP_ID` and
`APP_SECRET`. Events subscribed by that application are then delivered over
that channel. Authentication is performed when the connection is
established.
([long-connection event subscription](https://open.feishu.cn/document/server-docs/event-subscription-guide/event-subscription-configure-/request-url-configuration-case?lang=zh-CN))
- Long-connection event subscription supports enterprise custom apps, not
store apps. Each app may establish at most 50 connections. If one app has
multiple clients, delivery is cluster-mode rather than broadcast: one random
client receives a message.
([long-connection event subscription](https://open.feishu.cn/document/server-docs/event-subscription-guide/event-subscription-configure-/request-url-configuration-case?lang=zh-CN))
- Long-connection callbacks use the same per-app App ID/App Secret client
construction and have the same cluster-mode single-recipient behavior.
([long-connection callback subscription](https://open.feishu.cn/document/event-subscription-guide/callback-subscription/step-1-choose-a-subscription-mode/configure-callback-request-address?lang=zh-CN))
### Derived routing requirements
- Create one logical client binding per enabled `FeishuAppConnection`, even if
an in-process supervisor manages many clients. Attach immutable
`organization_id` and `connection_id` context to each handler; the client
that received the frame is the pre-authenticated application route.
- Never infer the receiving Organization by looking up a bare actor Open ID or
chat ID. First use the authenticated client binding; then cross-check any
delivered `app_id`/`tenant_key` fields available in the event structure.
- Scaling several replicas for the same app does not create duplicate
broadcast processing according to Feishu's contract, but delivery still
requires application-level idempotency and health ownership. Capacity must
count clients per app against the documented 50-connection ceiling.
## 6. Bot, chat, and message identifiers
### Official facts
- In the message resource, a sender can be a user or an application. For an
application sender, `id_type` is `app_id`; the sender also carries a
`tenant_key`. Thus the Feishu bot identity used by messaging is its
application identity, not a Hub-global bot ID.
([message management overview](https://open.feishu.cn/document/server-docs/im-v1/message/intro))
- `chat_id` identifies a conversation, including direct and group chats.
Feishu's group resource also exposes the chat's `tenant_key`.
([common parameters](https://open.feishu.cn/document/ukTMukTMukTM/uYTM5UjL2ETO14iNxkTN/terminology),
[group management overview](https://open.feishu.cn/document/server-docs/group/chat/intro?lang=zh-CN))
- Chat APIs require the operator to be in the same tenant as an internal chat;
common errors include `Operator and chat can NOT be in different tenants`.
Many operations additionally require the calling app's bot to be in the
group and reject an app that is unavailable in that tenant.
([get chat members](https://open.feishu.cn/document/server-docs/group/chat-member/get?lang=zh-CN))
- The first bot direct-chat event calls `chat_id` the conversation ID between
**that robot and user** and includes `app_id`, `tenant_key`, and app-scoped
user Open IDs in the same event.
([user and bot first conversation](https://open.feishu.cn/document/server-docs/group/chat-member/event/bot-events?lang=zh-CN))
### What the official sources do not establish
The cited documentation calls `chat_id` a conversation/group identifier but
does not state that its value is globally unique across every Feishu tenant and
every independently created app. It does establish tenant and bot-membership
authorization boundaries. Under the repository rule that unspecified scope
must not be assumed, raw global uniqueness would be an unsupported inference.
Derived storage/call rules:
- Store chats as `(feishu_app_connection_id, chat_id)` and retain observed
`tenant_key` and chat type. Do the same for other resource identifiers unless
Feishu explicitly contracts a wider scope.
- Always call chat/message APIs with the token obtained from the chat's bound
connection. Never retry an authorization failure with another Organization's
token.
- Treat bot direct chats as connection-specific. A `chat_id` for user X's
conversation with Organization A's bot is not evidence of a conversation
with Organization B's bot.
- For group chats, two customer apps may both be members of a real Feishu
group, but that does not authorize the Hub to move configuration, Projects,
or memberships between Organizations. The authenticated connection remains
the tenant route and Hub Organization remains the authorization root.
## Minimum invariants for implementation review
1. `FeishuIdentity` uniqueness is `(connection_id, open_id)`, never bare
`open_id`.
2. OAuth `state` resolves one server-side transaction bound to one Organization
and connection; code exchange uses only that connection's credentials and
stored redirect URI.
3. Login accepts the returned identity only when `tenant_key` matches the
connection's pinned tenant.
4. HTTP Webhooks choose a connection-specific verification/decryption context
before trusting payload routing fields; authenticated `app_id` and
`tenant_key` must match it.
5. Every long-connection handler carries the local connection/Organization
binding of the App ID/App Secret client that received the frame.
6. Bot, chat, event-deduplication, token-cache, and user identifiers are all
namespaced by the Feishu connection unless Feishu explicitly guarantees a
wider scope.
7. A credential, app, or tenant mismatch is a hard failure with traceable
logging; there is no fallback to a default app or search across tenant
secrets.