Skip to content

Commit cb21b23

Browse files
stevengillseratch
andauthored
Add org-level installation support (#148)
* added initial org app support * renamed org_dashboard_grant_access to enterprise_url * started implementing support for find_installation * Complete authorize implementation * Apply other required changes * Improve the user_token retrieval * Add default team_Id Co-authored-by: Kazuhiro Sera <[email protected]>
1 parent 00dc97c commit cb21b23

File tree

16 files changed

+975
-48
lines changed

16 files changed

+975
-48
lines changed
Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
# ------------------------------------------------
2+
# instead of slack_bolt in requirements.txt
3+
import sys
4+
5+
sys.path.insert(1, "..")
6+
# ------------------------------------------------
7+
8+
import logging
9+
10+
logging.basicConfig(level=logging.DEBUG)
11+
12+
from slack_bolt import App, BoltContext
13+
from slack_bolt.oauth import OAuthFlow
14+
from slack_sdk import WebClient
15+
16+
17+
app = App(oauth_flow=OAuthFlow.sqlite3(database="./slackapp.db"))
18+
19+
20+
@app.use
21+
def dump(context, next, logger):
22+
logger.info(context)
23+
next()
24+
25+
26+
@app.use
27+
def call_apis_with_team_id(context: BoltContext, client: WebClient, next):
28+
# client.users_list()
29+
client.bots_info(bot=context.bot_id)
30+
next()
31+
32+
33+
@app.event("app_mention")
34+
def handle_app_mentions(body, say, logger):
35+
logger.info(body)
36+
say("What's up?")
37+
38+
39+
@app.command("/org-level-command")
40+
def command(ack):
41+
ack("I got it!")
42+
43+
44+
@app.shortcut("org-level-shortcut")
45+
def shortcut(ack):
46+
ack()
47+
48+
49+
@app.event("team_access_granted")
50+
def team_access_granted(event):
51+
pass
52+
53+
54+
@app.event("team_access_revoked")
55+
def team_access_revoked(event):
56+
pass
57+
58+
59+
if __name__ == "__main__":
60+
app.start(3000)
61+
62+
# pip install slack_bolt
63+
# export SLACK_SIGNING_SECRET=***
64+
# export SLACK_BOT_TOKEN=xoxb-***
65+
# export SLACK_CLIENT_ID=111.111
66+
# export SLACK_CLIENT_SECRET=***
67+
# export SLACK_SCOPES=app_mentions:read,channels:history,im:history,chat:write
68+
# python oauth_app.py

setup.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,7 @@
3333
exclude=["examples", "integration_tests", "tests", "tests.*",]
3434
),
3535
include_package_data=True, # MANIFEST.in
36-
install_requires=["slack_sdk>=3.0.0",],
36+
install_requires=["slack_sdk==3.1.0b2",],
3737
setup_requires=["pytest-runner==5.2"],
3838
tests_require=test_dependencies,
3939
test_suite="tests",

slack_bolt/app/app.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -774,6 +774,7 @@ def _init_context(self, req: BoltRequest):
774774
ssl=self._client.ssl,
775775
proxy=self._client.proxy,
776776
headers=self._client.headers,
777+
team_id=req.context.team_id,
777778
)
778779
req.context["client"] = client_per_request
779780

slack_bolt/app/async_app.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -800,6 +800,7 @@ def _init_context(self, req: AsyncBoltRequest):
800800
session=self._async_client.session,
801801
trust_env_in_session=self._async_client.trust_env_in_session,
802802
headers=self._async_client.headers,
803+
team_id=req.context.team_id,
803804
)
804805
req.context["client"] = client_per_request
805806

slack_bolt/authorization/async_authorize.py

Lines changed: 74 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
from typing import Optional, Callable, Awaitable, Dict, Any
44

55
from slack_sdk.errors import SlackApiError
6-
from slack_sdk.oauth.installation_store import Bot
6+
from slack_sdk.oauth.installation_store import Bot, Installation
77
from slack_sdk.oauth.installation_store.async_installation_store import (
88
AsyncInstallationStore,
99
)
@@ -92,6 +92,7 @@ async def __call__(
9292

9393
class AsyncInstallationStoreAuthorize(AsyncAuthorize):
9494
authorize_result_cache: Dict[str, AuthorizeResult] = {}
95+
find_installation_available: Optional[bool]
9596

9697
def __init__(
9798
self,
@@ -103,6 +104,7 @@ def __init__(
103104
self.logger = logger
104105
self.installation_store = installation_store
105106
self.cache_enabled = cache_enabled
107+
self.find_installation_available = None
106108

107109
async def __call__(
108110
self,
@@ -112,31 +114,88 @@ async def __call__(
112114
team_id: str,
113115
user_id: Optional[str],
114116
) -> Optional[AuthorizeResult]:
115-
bot: Optional[Bot] = await self.installation_store.async_find_bot(
116-
enterprise_id=enterprise_id, team_id=team_id,
117-
)
118-
if bot is None:
119-
self.logger.debug(
120-
f"No installation data found "
121-
f"for enterprise_id: {enterprise_id} team_id: {team_id}"
117+
118+
if self.find_installation_available is None:
119+
self.find_installation_available = hasattr(
120+
self.installation_store, "async_find_installation"
122121
)
122+
123+
bot_token: Optional[str] = None
124+
user_token: Optional[str] = None
125+
126+
if self.find_installation_available:
127+
# since v1.1, this is the default way
128+
try:
129+
installation: Optional[
130+
Installation
131+
] = await self.installation_store.async_find_installation(
132+
enterprise_id=enterprise_id,
133+
team_id=team_id,
134+
is_enterprise_install=context.is_enterprise_install,
135+
)
136+
if installation is None:
137+
self._debug_log_for_not_found(enterprise_id, team_id)
138+
return None
139+
140+
if installation.user_id != user_id:
141+
# try to fetch the request user's installation
142+
# to reflect the user's access token if exists
143+
user_installation = await self.installation_store.async_find_installation(
144+
enterprise_id=enterprise_id,
145+
team_id=team_id,
146+
user_id=user_id,
147+
is_enterprise_install=context.is_enterprise_install,
148+
)
149+
if user_installation is not None:
150+
installation = user_installation
151+
152+
bot_token, user_token = installation.bot_token, installation.user_token
153+
except NotImplementedError as _:
154+
self.find_installation_available = False
155+
156+
if not self.find_installation_available:
157+
# Use find_bot to get bot value (legacy)
158+
bot: Optional[Bot] = await self.installation_store.async_find_bot(
159+
enterprise_id=enterprise_id,
160+
team_id=team_id,
161+
is_enterprise_install=context.is_enterprise_install,
162+
)
163+
if bot is None:
164+
self._debug_log_for_not_found(enterprise_id, team_id)
165+
return None
166+
bot_token, user_token = bot.bot_token, None
167+
168+
token: Optional[str] = bot_token or user_token
169+
if token is None:
123170
return None
124171

125-
if self.cache_enabled and bot.bot_token in self.authorize_result_cache:
126-
return self.authorize_result_cache[bot.bot_token]
172+
# Check cache to see if the bot object already exists
173+
if self.cache_enabled and token in self.authorize_result_cache:
174+
return self.authorize_result_cache[token]
175+
127176
try:
128-
auth_result = await context.client.auth_test(token=bot.bot_token)
177+
auth_test_api_response = await context.client.auth_test(token=token)
129178
authorize_result = AuthorizeResult.from_auth_test_response(
130-
auth_test_response=auth_result,
131-
bot_token=bot.bot_token,
132-
user_token=None, # Not yet supported
179+
auth_test_response=auth_test_api_response,
180+
bot_token=bot_token,
181+
user_token=user_token,
133182
)
134183
if self.cache_enabled:
135-
self.authorize_result_cache[bot.bot_token] = authorize_result
184+
self.authorize_result_cache[token] = authorize_result
136185
return authorize_result
137186
except SlackApiError as err:
138187
self.logger.debug(
139188
f"The stored bot token for enterprise_id: {enterprise_id} team_id: {team_id} "
140189
f"is no longer valid. (response: {err.response})"
141190
)
142191
return None
192+
193+
# ------------------------------------------------
194+
195+
def _debug_log_for_not_found(
196+
self, enterprise_id: Optional[str], team_id: Optional[str]
197+
):
198+
self.logger.debug(
199+
"No installation data found "
200+
f"for enterprise_id: {enterprise_id} team_id: {team_id}"
201+
)

slack_bolt/authorization/authorize.py

Lines changed: 71 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
from slack_sdk.errors import SlackApiError
66
from slack_sdk.oauth import InstallationStore
77
from slack_sdk.oauth.installation_store import Bot
8+
from slack_sdk.oauth.installation_store.models.installation import Installation
89

910
from slack_bolt.authorization.authorize_args import AuthorizeArgs
1011
from slack_bolt.authorization.authorize_result import AuthorizeResult
@@ -90,6 +91,7 @@ def __call__(
9091

9192
class InstallationStoreAuthorize(Authorize):
9293
authorize_result_cache: Dict[str, AuthorizeResult] = {}
94+
find_installation_available: bool
9395

9496
def __init__(
9597
self,
@@ -101,6 +103,9 @@ def __init__(
101103
self.logger = logger
102104
self.installation_store = installation_store
103105
self.cache_enabled = cache_enabled
106+
self.find_installation_available = hasattr(
107+
installation_store, "find_installation"
108+
)
104109

105110
def __call__(
106111
self,
@@ -110,31 +115,83 @@ def __call__(
110115
team_id: str,
111116
user_id: Optional[str],
112117
) -> Optional[AuthorizeResult]:
113-
bot: Optional[Bot] = self.installation_store.find_bot(
114-
enterprise_id=enterprise_id, team_id=team_id,
115-
)
116-
if bot is None:
117-
self.logger.debug(
118-
f"No installation data found "
119-
f"for enterprise_id: {enterprise_id} team_id: {team_id}"
118+
119+
bot_token: Optional[str] = None
120+
user_token: Optional[str] = None
121+
122+
if self.find_installation_available:
123+
# since v1.1, this is the default way
124+
try:
125+
installation: Optional[
126+
Installation
127+
] = self.installation_store.find_installation(
128+
enterprise_id=enterprise_id,
129+
team_id=team_id,
130+
is_enterprise_install=context.is_enterprise_install,
131+
)
132+
if installation is None:
133+
self._debug_log_for_not_found(enterprise_id, team_id)
134+
return None
135+
136+
if installation.user_id != user_id:
137+
# try to fetch the request user's installation
138+
# to reflect the user's access token if exists
139+
user_installation = self.installation_store.find_installation(
140+
enterprise_id=enterprise_id,
141+
team_id=team_id,
142+
user_id=user_id,
143+
is_enterprise_install=context.is_enterprise_install,
144+
)
145+
if user_installation is not None:
146+
installation = user_installation
147+
148+
bot_token, user_token = installation.bot_token, installation.user_token
149+
except NotImplementedError as _:
150+
self.find_installation_available = False
151+
152+
if not self.find_installation_available:
153+
# Use find_bot to get bot value (legacy)
154+
bot: Optional[Bot] = self.installation_store.find_bot(
155+
enterprise_id=enterprise_id,
156+
team_id=team_id,
157+
is_enterprise_install=context.is_enterprise_install,
120158
)
159+
if bot is None:
160+
self._debug_log_for_not_found(enterprise_id, team_id)
161+
return None
162+
bot_token, user_token = bot.bot_token, None
163+
164+
token: Optional[str] = bot_token or user_token
165+
if token is None:
121166
return None
122167

123-
if self.cache_enabled and bot.bot_token in self.authorize_result_cache:
124-
return self.authorize_result_cache[bot.bot_token]
168+
# Check cache to see if the bot object already exists
169+
if self.cache_enabled and token in self.authorize_result_cache:
170+
return self.authorize_result_cache[token]
171+
125172
try:
126-
auth_result = context.client.auth_test(token=bot.bot_token)
173+
auth_test_api_response = context.client.auth_test(token=token)
127174
authorize_result = AuthorizeResult.from_auth_test_response(
128-
auth_test_response=auth_result,
129-
bot_token=bot.bot_token,
130-
user_token=None, # Not yet supported
175+
auth_test_response=auth_test_api_response,
176+
bot_token=bot_token,
177+
user_token=user_token,
131178
)
132179
if self.cache_enabled:
133-
self.authorize_result_cache[bot.bot_token] = authorize_result
180+
self.authorize_result_cache[token] = authorize_result
134181
return authorize_result
135182
except SlackApiError as err:
136183
self.logger.debug(
137184
f"The stored bot token for enterprise_id: {enterprise_id} team_id: {team_id} "
138185
f"is no longer valid. (response: {err.response})"
139186
)
140187
return None
188+
189+
# ------------------------------------------------
190+
191+
def _debug_log_for_not_found(
192+
self, enterprise_id: Optional[str], team_id: Optional[str]
193+
):
194+
self.logger.debug(
195+
"No installation data found "
196+
f"for enterprise_id: {enterprise_id} team_id: {team_id}"
197+
)

slack_bolt/context/base_context.py

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,8 +18,12 @@ def enterprise_id(self) -> Optional[str]:
1818
return self.get("enterprise_id")
1919

2020
@property
21-
def team_id(self) -> str:
22-
return self["team_id"]
21+
def is_enterprise_install(self) -> Optional[bool]:
22+
return self.get("is_enterprise_install")
23+
24+
@property
25+
def team_id(self) -> Optional[str]:
26+
return self.get("team_id")
2327

2428
@property
2529
def user_id(self) -> Optional[str]:

slack_bolt/middleware/authorization/internals.py

Lines changed: 4 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -29,21 +29,15 @@ def _is_ssl_check(req: Union[BoltRequest, "AsyncBoltRequest"]) -> bool: # type:
2929
)
3030

3131

32-
def _is_uninstallation_event(req: Union[BoltRequest, "AsyncBoltRequest"]) -> bool: # type: ignore
33-
return (
34-
req is not None
35-
and req.body is not None
36-
and req.body.get("type") == "event_callback"
37-
and req.body.get("event", {}).get("type") == "app_uninstalled"
38-
)
32+
no_auth_test_events = ["app_uninstalled", "tokens_revoked", "team_access_revoked"]
3933

4034

41-
def _is_tokens_revoked_event(req: Union[BoltRequest, "AsyncBoltRequest"]) -> bool: # type: ignore
35+
def _is_no_auth_test_events(req: Union[BoltRequest, "AsyncBoltRequest"]) -> bool: # type: ignore
4236
return (
4337
req is not None
4438
and req.body is not None
4539
and req.body.get("type") == "event_callback"
46-
and req.body.get("event", {}).get("type") == "tokens_revoked"
40+
and req.body.get("event", {}).get("type") in no_auth_test_events
4741
)
4842

4943

@@ -52,7 +46,7 @@ def _is_no_auth_required(req: Union[BoltRequest, "AsyncBoltRequest"]) -> bool:
5246

5347

5448
def _is_no_auth_test_call_required(req: Union[BoltRequest, "AsyncBoltRequest"]) -> bool: # type: ignore
55-
return _is_uninstallation_event(req) or _is_tokens_revoked_event(req)
49+
return _is_no_auth_test_events(req)
5650

5751

5852
def _build_error_response() -> BoltResponse:

0 commit comments

Comments
 (0)