forked from bai/curriculum-project-hub
docs: resolve product surface audit
This commit is contained in:
@@ -0,0 +1,333 @@
|
||||
# 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.
|
||||
Reference in New Issue
Block a user