Skip to content

Commit bb2e81b

Browse files
committed
feat(slack): external installation provider for bot token management (vercel/chat#467)
Port of upstream c46fdb6. Adds SlackAdapterConfig.installation_provider (SlackInstallationProvider protocol) for multi-workspace apps using external token management (e.g. Vercel Connect). When set, the adapter bypasses internal StateAdapter storage for token lookups on incoming webhooks — the provider is authoritative (no state fallback) and read-only (set_installation / delete_installation / OAuth callback still write to internal state). Enterprise Grid support rides along: org-wide installs (is_enterprise_install) resolve by enterprise_id instead of team_id across event_callback, slash command, and interactive payload entry paths; RequestContext carries enterprise_id/is_enterprise_install; attachment fetch_metadata captures enterpriseId/isEnterpriseInstall (omitted when absent) and rehydrate_attachment routes through _resolve_token_for_team so the provider is honored after a JSON roundtrip. Composes with the existing bot_token resolver design (see docs/UPSTREAM_SYNC.md non-parity rows): a configured default bot_token (static or resolver) still selects single-workspace mode and bypasses per-installation resolution entirely; _get_token / client caches are untouched. https://claude.ai/code/session_013zwTcMek5rNqBTQvs2oF64 (cherry picked from commit ee1e30703514d8a871d7f64c0ccc17c0774a7d6d)
1 parent 20cd921 commit bb2e81b

3 files changed

Lines changed: 731 additions & 35 deletions

File tree

src/chat_sdk/adapters/slack/adapter.py

Lines changed: 153 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@
2222
from collections import OrderedDict
2323
from collections.abc import AsyncIterable, Awaitable, Callable
2424
from contextvars import ContextVar
25+
from dataclasses import dataclass, replace
2526
from datetime import datetime, timezone
2627
from typing import Any, NoReturn, TypedDict, cast
2728
from urllib.parse import parse_qs, urlparse
@@ -52,6 +53,7 @@
5253
SlackBotToken,
5354
SlackBotTokenResolver,
5455
SlackInstallation,
56+
SlackInstallationProvider,
5557
SlackThreadId,
5658
SlackWebhookVerifier,
5759
)
@@ -207,6 +209,19 @@ def _make_slack_lookup_failed(user_id: str) -> SlackUserCacheEntry:
207209
# ---------------------------------------------------------------------------
208210

209211

212+
@dataclass
213+
class _InstallationInfo:
214+
"""Installation identity extracted from an interactive payload.
215+
216+
``installation_id`` is the team ID -- or the enterprise ID for
217+
Enterprise Grid org-wide installs (``is_enterprise_install``).
218+
"""
219+
220+
installation_id: str
221+
is_enterprise_install: bool
222+
enterprise_id: str | None = None
223+
224+
210225
def _find_next_mention(text: str) -> int:
211226
"""Find the next ``<@`` or ``<#`` mention in *text*."""
212227
at_idx = text.find("<@")
@@ -487,6 +502,9 @@ def __init__(self, config: SlackAdapterConfig | None = None) -> None:
487502
else:
488503
self._client_secret = None
489504
self._installation_key_prefix = config.installation_key_prefix or "slack:installation"
505+
# External installation provider (e.g. Vercel Connect). When set,
506+
# per-installation token lookups bypass internal StateAdapter storage.
507+
self._installation_provider: SlackInstallationProvider | None = config.installation_provider
490508

491509
# ``is not None`` (not truthiness) so an explicit ``encryption_key=""``
492510
# is treated as "user explicitly opted out" and is NOT silently
@@ -973,30 +991,79 @@ async def with_bot_token_async(self, token: str, fn: Callable[[], Awaitable[Any]
973991
# Private helpers - token resolution
974992
# ==================================================================
975993

976-
async def _resolve_token_for_team(self, team_id: str) -> RequestContext | None:
977-
"""Resolve the bot token for a team from the state adapter."""
994+
async def _resolve_token_for_team(
995+
self, installation_id: str, is_enterprise_install: bool = False
996+
) -> RequestContext | None:
997+
"""Resolve the bot token for an installation.
998+
999+
Checks the external installation provider first (e.g. Vercel
1000+
Connect); when no provider is configured, falls back to the
1001+
internal state adapter.
1002+
1003+
``installation_id`` is the ``team_id`` -- or the ``enterprise_id``
1004+
for Enterprise Grid org-wide installs (``is_enterprise_install``).
1005+
"""
9781006
try:
979-
installation = await self.get_installation(team_id)
1007+
# Check external installation provider first (e.g. Vercel Connect)
1008+
if self._installation_provider is not None:
1009+
installation = await self._installation_provider.get_installation(
1010+
installation_id, is_enterprise_install
1011+
)
1012+
if installation:
1013+
return RequestContext(
1014+
token=installation.bot_token,
1015+
bot_user_id=installation.bot_user_id,
1016+
)
1017+
self._logger.warn(
1018+
"No installation found from provider",
1019+
{"installationId": installation_id, "isEnterpriseInstall": is_enterprise_install},
1020+
)
1021+
return None
1022+
# Fall back to internal state adapter
1023+
installation = await self.get_installation(installation_id)
9801024
if installation:
9811025
return RequestContext(
9821026
token=installation.bot_token,
9831027
bot_user_id=installation.bot_user_id,
9841028
)
985-
self._logger.warn("No installation found for team", {"teamId": team_id})
1029+
self._logger.warn(
1030+
"No installation found for team",
1031+
{"installationId": installation_id, "isEnterpriseInstall": is_enterprise_install},
1032+
)
9861033
return None
9871034
except Exception as exc:
988-
self._logger.error("Failed to resolve token for team", {"teamId": team_id, "error": exc})
1035+
self._logger.error(
1036+
"Failed to resolve token for team",
1037+
{"installationId": installation_id, "isEnterpriseInstall": is_enterprise_install, "error": exc},
1038+
)
9891039
return None
9901040

991-
def _extract_team_id_from_interactive(self, body: str) -> str | None:
992-
"""Extract team_id from an interactive payload (form-urlencoded)."""
1041+
def _extract_installation_from_interactive(self, body: str) -> _InstallationInfo | None:
1042+
"""Extract installation info from an interactive payload (form-urlencoded).
1043+
1044+
For Enterprise Grid org-wide installs, the installation ID is the
1045+
enterprise ID; otherwise it is the team ID.
1046+
"""
9931047
try:
9941048
params = parse_qs(body)
9951049
payload_str = params.get("payload", [None])[0]
9961050
if not payload_str:
9971051
return None
9981052
payload = json.loads(payload_str)
999-
return payload.get("team", {}).get("id") or payload.get("team_id")
1053+
is_enterprise_install = bool(payload.get("is_enterprise_install"))
1054+
enterprise = payload.get("enterprise") or {}
1055+
enterprise_id = enterprise.get("id") or payload.get("enterprise_id") or None
1056+
team = payload.get("team") or {}
1057+
team_id = team.get("id") or payload.get("team_id") or None
1058+
installation_id = enterprise_id if is_enterprise_install else team_id
1059+
1060+
if not installation_id:
1061+
return None
1062+
return _InstallationInfo(
1063+
installation_id=installation_id,
1064+
is_enterprise_install=is_enterprise_install,
1065+
enterprise_id=enterprise_id,
1066+
)
10001067
except Exception:
10011068
return None
10021069

@@ -1359,24 +1426,47 @@ async def handle_webhook(self, request: Any, options: WebhookOptions | None = No
13591426

13601427
# Slash command
13611428
if "command" in params and "payload" not in params:
1362-
team_id = (params.get("team_id") or [None])[0]
1363-
if not self._is_single_workspace and team_id:
1364-
ctx = await self._resolve_token_for_team(team_id)
1365-
if ctx:
1366-
tok = self._request_context.set(ctx)
1367-
try:
1368-
return await self._handle_slash_command(params, options)
1369-
finally:
1370-
self._request_context.reset(tok)
1371-
self._logger.warn("Could not resolve token for slash command")
1429+
if not self._is_single_workspace:
1430+
# For Enterprise Grid org-wide installs, use enterprise_id;
1431+
# otherwise use team_id.
1432+
is_enterprise_install = (params.get("is_enterprise_install") or [None])[0] == "true"
1433+
enterprise_id = (params.get("enterprise_id") or [None])[0]
1434+
team_id = (params.get("team_id") or [None])[0]
1435+
installation_id = enterprise_id if is_enterprise_install else team_id
1436+
1437+
if installation_id:
1438+
ctx = await self._resolve_token_for_team(installation_id, is_enterprise_install)
1439+
if ctx:
1440+
ctx = replace(
1441+
ctx,
1442+
enterprise_id=enterprise_id,
1443+
is_enterprise_install=is_enterprise_install,
1444+
)
1445+
tok = self._request_context.set(ctx)
1446+
try:
1447+
return await self._handle_slash_command(params, options)
1448+
finally:
1449+
self._request_context.reset(tok)
1450+
self._logger.warn(
1451+
"Could not resolve token for slash command",
1452+
{"installationId": installation_id, "isEnterpriseInstall": is_enterprise_install},
1453+
)
13721454
return await self._handle_slash_command(params, options)
13731455

13741456
# Interactive payload
13751457
if not self._is_single_workspace:
1376-
team_id_interactive = self._extract_team_id_from_interactive(body)
1377-
if team_id_interactive:
1378-
ctx = await self._resolve_token_for_team(team_id_interactive)
1458+
installation_info = self._extract_installation_from_interactive(body)
1459+
if installation_info:
1460+
ctx = await self._resolve_token_for_team(
1461+
installation_info.installation_id,
1462+
installation_info.is_enterprise_install,
1463+
)
13791464
if ctx:
1465+
ctx = replace(
1466+
ctx,
1467+
enterprise_id=installation_info.enterprise_id,
1468+
is_enterprise_install=installation_info.is_enterprise_install,
1469+
)
13801470
tok = self._request_context.set(ctx)
13811471
try:
13821472
return await self._handle_interactive_payload(body, options)
@@ -1406,15 +1496,27 @@ async def handle_webhook(self, request: Any, options: WebhookOptions | None = No
14061496
# isolated -- the ContextVar change does not leak back to the caller
14071497
# and does not need an explicit reset.
14081498
if not self._is_single_workspace and payload.get("type") == "event_callback":
1409-
team_id_event = payload.get("team_id")
1410-
if team_id_event:
1411-
ctx = await self._resolve_token_for_team(team_id_event)
1499+
# For Enterprise Grid org-wide installs, use enterprise_id;
1500+
# otherwise use team_id.
1501+
is_enterprise_install = bool(payload.get("is_enterprise_install"))
1502+
installation_id = payload.get("enterprise_id") if is_enterprise_install else payload.get("team_id")
1503+
1504+
if installation_id:
1505+
ctx = await self._resolve_token_for_team(installation_id, is_enterprise_install)
14121506
if ctx:
1507+
ctx = replace(
1508+
ctx,
1509+
enterprise_id=payload.get("enterprise_id"),
1510+
is_enterprise_install=is_enterprise_install,
1511+
)
14131512
isolated = contextvars.copy_context()
14141513
isolated.run(self._request_context.set, ctx)
14151514
isolated.run(self._process_event_payload, payload, options)
14161515
return {"body": "ok", "status": 200}
1417-
self._logger.warn("Could not resolve token for team", {"teamId": team_id_event})
1516+
self._logger.warn(
1517+
"Could not resolve token for installation",
1518+
{"installationId": installation_id, "isEnterpriseInstall": is_enterprise_install},
1519+
)
14181520
return {"body": "ok", "status": 200}
14191521

14201522
# Single-workspace mode or fallback
@@ -3201,13 +3303,17 @@ def _create_attachment(self, file: dict[str, Any], team_id: str | None = None) -
32013303
the queue/debounce path JSON-serializes the message.
32023304
"""
32033305
url = file.get("url_private")
3204-
# Capture per-request token from the active webhook context so
3205-
# ``fetch_data`` can run later without being inside the ContextVar
3206-
# frame (e.g. after the message has been queued + rehydrated).
3306+
# Capture per-request context (token + Enterprise Grid info) from the
3307+
# active webhook context so ``fetch_data`` can run later without being
3308+
# inside the ContextVar frame (e.g. after the message has been queued
3309+
# + rehydrated), and ``rehydrate_attachment`` can resolve tokens
3310+
# through the same lookup logic on a different process invocation.
32073311
# For single-workspace mode the default provider is re-resolved at
32083312
# fetch time so dynamic ``bot_token`` resolvers honor rotation.
32093313
ctx = self._request_context.get()
32103314
ctx_token: str | None = ctx.token if ctx and ctx.token else None
3315+
ctx_enterprise_id: str | None = ctx.enterprise_id if ctx else None
3316+
ctx_is_enterprise_install: bool = bool(ctx.is_enterprise_install) if ctx else False
32113317

32123318
mimetype = file.get("mimetype", "")
32133319
att_type: str = "file"
@@ -3227,6 +3333,12 @@ async def fetch_data() -> bytes:
32273333
fetch_meta["url"] = url
32283334
if team_id:
32293335
fetch_meta["teamId"] = team_id
3336+
# Omit the Enterprise Grid keys entirely when absent (hazard #7:
3337+
# omitted keys, not serialized ``None``/false values).
3338+
if ctx_enterprise_id:
3339+
fetch_meta["enterpriseId"] = ctx_enterprise_id
3340+
if ctx_is_enterprise_install:
3341+
fetch_meta["isEnterpriseInstall"] = "true"
32303342

32313343
return Attachment(
32323344
type=att_type, # type: ignore[arg-type]
@@ -3312,20 +3424,28 @@ def rehydrate_attachment(self, attachment: Attachment) -> Attachment:
33123424
meta_url = meta.get("url")
33133425
url = meta_url if meta_url is not None else attachment.url
33143426
team_id = meta.get("teamId")
3427+
enterprise_id = meta.get("enterpriseId")
3428+
is_enterprise_install = meta.get("isEnterpriseInstall") == "true"
33153429
if not url:
33163430
return attachment
33173431

33183432
adapter = self
33193433

33203434
async def fetch_data() -> bytes:
3321-
if team_id:
3322-
installation = await adapter.get_installation(team_id)
3323-
if installation is None:
3435+
installation_id = enterprise_id if is_enterprise_install else team_id
3436+
if installation_id:
3437+
# Route through ``_resolve_token_for_team`` so
3438+
# ``installation_provider`` (when configured) is honored --
3439+
# otherwise this falls back to internal state via
3440+
# ``get_installation``, matching the prior behavior.
3441+
ctx = await adapter._resolve_token_for_team(installation_id, is_enterprise_install)
3442+
if ctx is None:
33243443
raise AuthenticationError(
33253444
"slack",
3326-
f"Installation not found for team {team_id}",
3445+
f"Installation not found for "
3446+
f"{'enterprise' if is_enterprise_install else 'team'} {installation_id}",
33273447
)
3328-
token = installation.bot_token
3448+
token = ctx.token
33293449
else:
33303450
# Use the async resolver so a dynamic ``bot_token`` provider
33313451
# is invoked at fetch time (rotation-safe).

src/chat_sdk/adapters/slack/types.py

Lines changed: 43 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44

55
from collections.abc import Awaitable, Callable
66
from dataclasses import dataclass
7-
from typing import Any, Literal, TypeAlias, TypedDict
7+
from typing import Any, Literal, Protocol, TypeAlias, TypedDict
88

99
from chat_sdk.logger import Logger
1010

@@ -75,8 +75,21 @@ class SlackAdapterConfig:
7575
# If provided, bot tokens stored via set_installation() will be encrypted at rest.
7676
encryption_key: str | None = None
7777
# Prefix for the state key used to store workspace installations.
78-
# Defaults to ``slack:installation``. The full key will be ``{prefix}:{team_id}``.
78+
# Defaults to ``slack:installation``. The full key will be ``{prefix}:{team_id}``
79+
# (or ``{prefix}:{enterprise_id}`` for Enterprise Grid org-wide installs).
7980
installation_key_prefix: str = "slack:installation"
81+
# External installation provider for multi-workspace apps using external
82+
# token management (e.g. Vercel Connect). When set, the adapter bypasses
83+
# internal StateAdapter storage for token lookups on incoming webhooks.
84+
#
85+
# For Enterprise Grid org-wide installs, ``installation_id`` will be the
86+
# enterprise ID; otherwise it will be the team ID.
87+
#
88+
# Precedence: a configured default ``bot_token`` (single-workspace mode,
89+
# static or resolver) still wins — per-installation resolution (and thus
90+
# this provider) only runs in multi-workspace mode. See the resolver rows
91+
# in docs/UPSTREAM_SYNC.md.
92+
installation_provider: SlackInstallationProvider | None = None
8093
# Logger instance for error reporting. Defaults to ConsoleLogger.
8194
logger: Logger | None = None
8295
# Connection mode: ``"webhook"`` (default) or ``"socket"``. When set to
@@ -128,6 +141,26 @@ class SlackInstallation:
128141
team_name: str | None = None
129142

130143

144+
class SlackInstallationProvider(Protocol):
145+
"""External installation provider for multi-workspace token management.
146+
147+
Implementations resolve a :class:`SlackInstallation` from an external
148+
system (e.g. Vercel Connect) instead of the adapter's internal
149+
StateAdapter storage. ``installation_id`` is the ``enterprise_id`` for
150+
Enterprise Grid org-wide installs (``is_enterprise_install=True``),
151+
otherwise the ``team_id``. Return ``None`` when no installation exists.
152+
153+
The provider is read-only: ``set_installation`` / ``delete_installation``
154+
/ ``handle_oauth_callback`` continue to write to the internal state
155+
adapter, so callers using a provider should manage their own writes
156+
through their external system.
157+
"""
158+
159+
def get_installation(
160+
self, installation_id: str, is_enterprise_install: bool
161+
) -> Awaitable[SlackInstallation | None]: ...
162+
163+
131164
# =============================================================================
132165
# Thread ID
133166
# =============================================================================
@@ -321,9 +354,13 @@ class SlackWebhookPayload(TypedDict, total=False):
321354
"""Slack webhook payload envelope."""
322355

323356
challenge: str
357+
# Enterprise ID for Enterprise Grid org-wide installs
358+
enterprise_id: str
324359
event: Any # SlackEventUnion
325360
event_id: str
326361
event_time: int
362+
# Whether this is an Enterprise Grid org-wide install
363+
is_enterprise_install: bool
327364
# Whether this event occurred in an externally shared channel (Slack Connect)
328365
is_ext_shared_channel: bool
329366
team_id: str
@@ -465,3 +502,7 @@ class RequestContext:
465502
token: str
466503
bot_user_id: str | None = None
467504
is_ext_shared_channel: bool | None = None
505+
# Enterprise ID for Enterprise Grid org-wide installs
506+
enterprise_id: str | None = None
507+
# Whether this request came from an Enterprise Grid org-wide install
508+
is_enterprise_install: bool | None = None

0 commit comments

Comments
 (0)