Skip to content

Commit 8babac6

Browse files
committed
Enable installation_store authorize to fallback to bots (prep for #254)
1 parent b594174 commit 8babac6

File tree

4 files changed

+413
-63
lines changed

4 files changed

+413
-63
lines changed

slack_bolt/authorization/async_authorize.py

Lines changed: 54 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -93,6 +93,7 @@ async def __call__(
9393
class AsyncInstallationStoreAuthorize(AsyncAuthorize):
9494
authorize_result_cache: Dict[str, AuthorizeResult]
9595
find_installation_available: Optional[bool]
96+
find_bot_available: Optional[bool]
9697

9798
def __init__(
9899
self,
@@ -110,6 +111,7 @@ def __init__(
110111
self.cache_enabled = cache_enabled
111112
self.authorize_result_cache = {}
112113
self.find_installation_available = None
114+
self.find_bot_available = None
113115

114116
async def __call__(
115117
self,
@@ -124,12 +126,15 @@ async def __call__(
124126
self.find_installation_available = hasattr(
125127
self.installation_store, "async_find_installation"
126128
)
129+
if self.find_bot_available is None:
130+
self.find_bot_available = hasattr(self.installation_store, "async_find_bot")
127131

128132
bot_token: Optional[str] = None
129133
user_token: Optional[str] = None
130134

131135
if not self.bot_only and self.find_installation_available:
132-
# since v1.1, this is the default way
136+
# Since v1.1, this is the default way.
137+
# If you want to use find_bot / delete_bot only, you can set bot_only as True.
133138
try:
134139
# Note that this is the latest information for the org/workspace.
135140
# The installer may not be the user associated with this incoming request.
@@ -140,47 +145,64 @@ async def __call__(
140145
team_id=team_id,
141146
is_enterprise_install=context.is_enterprise_install,
142147
)
143-
if installation is None:
144-
self._debug_log_for_not_found(enterprise_id, team_id)
145-
return None
146-
147-
if installation.user_id != user_id:
148-
# First off, remove the user token as the installer is a different user
149-
installation.user_token = None
150-
installation.user_scopes = []
151-
152-
# try to fetch the request user's installation
153-
# to reflect the user's access token if exists
154-
user_installation = (
155-
await self.installation_store.async_find_installation(
156-
enterprise_id=enterprise_id,
157-
team_id=team_id,
158-
user_id=user_id,
159-
is_enterprise_install=context.is_enterprise_install,
148+
149+
if installation is not None:
150+
if installation.user_id != user_id:
151+
# First off, remove the user token as the installer is a different user
152+
installation.user_token = None
153+
installation.user_scopes = []
154+
155+
# try to fetch the request user's installation
156+
# to reflect the user's access token if exists
157+
user_installation = (
158+
await self.installation_store.async_find_installation(
159+
enterprise_id=enterprise_id,
160+
team_id=team_id,
161+
user_id=user_id,
162+
is_enterprise_install=context.is_enterprise_install,
163+
)
160164
)
165+
if user_installation is not None:
166+
# Overwrite the installation with the one for this user
167+
installation = user_installation
168+
169+
bot_token, user_token = (
170+
installation.bot_token,
171+
installation.user_token,
161172
)
162-
if user_installation is not None:
163-
# Overwrite the installation with the one for this user
164-
installation = user_installation
165173

166-
bot_token, user_token = installation.bot_token, installation.user_token
167174
except NotImplementedError as _:
168175
self.find_installation_available = False
169176

170-
if self.bot_only or not self.find_installation_available:
171-
# Use find_bot to get bot value (legacy)
172-
bot: Optional[Bot] = await self.installation_store.async_find_bot(
173-
enterprise_id=enterprise_id,
174-
team_id=team_id,
175-
is_enterprise_install=context.is_enterprise_install,
177+
if (
178+
# If you intentionally use only find_bot / delete_bot,
179+
self.bot_only
180+
# If find_installation method is not available,
181+
or not self.find_installation_available
182+
# If find_installation did not return data and find_bot method is available,
183+
or (
184+
self.find_bot_available is True
185+
and bot_token is None
186+
and user_token is None
176187
)
177-
if bot is None:
178-
self._debug_log_for_not_found(enterprise_id, team_id)
179-
return None
180-
bot_token, user_token = bot.bot_token, None
188+
):
189+
try:
190+
bot: Optional[Bot] = await self.installation_store.async_find_bot(
191+
enterprise_id=enterprise_id,
192+
team_id=team_id,
193+
is_enterprise_install=context.is_enterprise_install,
194+
)
195+
if bot is not None:
196+
bot_token = bot.bot_token
197+
except NotImplementedError as _:
198+
self.find_bot_available = False
199+
except Exception as e:
200+
self.logger.info(f"Failed to call find_bot method: {e}")
181201

182202
token: Optional[str] = bot_token or user_token
183203
if token is None:
204+
# No valid token was found
205+
self._debug_log_for_not_found(enterprise_id, team_id)
184206
return None
185207

186208
# Check cache to see if the bot object already exists

slack_bolt/authorization/authorize.py

Lines changed: 50 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -96,6 +96,7 @@ class InstallationStoreAuthorize(Authorize):
9696
authorize_result_cache: Dict[str, AuthorizeResult]
9797
bot_only: bool
9898
find_installation_available: bool
99+
find_bot_available: bool
99100

100101
def __init__(
101102
self,
@@ -115,6 +116,7 @@ def __init__(
115116
self.find_installation_available = hasattr(
116117
installation_store, "find_installation"
117118
)
119+
self.find_bot_available = hasattr(installation_store, "find_bot")
118120

119121
def __call__(
120122
self,
@@ -129,7 +131,8 @@ def __call__(
129131
user_token: Optional[str] = None
130132

131133
if not self.bot_only and self.find_installation_available:
132-
# since v1.1, this is the default way
134+
# Since v1.1, this is the default way.
135+
# If you want to use find_bot / delete_bot only, you can set bot_only as True.
133136
try:
134137
# Note that this is the latest information for the org/workspace.
135138
# The installer may not be the user associated with this incoming request.
@@ -140,45 +143,61 @@ def __call__(
140143
team_id=team_id,
141144
is_enterprise_install=context.is_enterprise_install,
142145
)
143-
if installation is None:
144-
self._debug_log_for_not_found(enterprise_id, team_id)
145-
return None
146-
147-
if installation.user_id != user_id:
148-
# First off, remove the user token as the installer is a different user
149-
installation.user_token = None
150-
installation.user_scopes = []
151-
152-
# try to fetch the request user's installation
153-
# to reflect the user's access token if exists
154-
user_installation = self.installation_store.find_installation(
155-
enterprise_id=enterprise_id,
156-
team_id=team_id,
157-
user_id=user_id,
158-
is_enterprise_install=context.is_enterprise_install,
146+
if installation is not None:
147+
if installation.user_id != user_id:
148+
# First off, remove the user token as the installer is a different user
149+
installation.user_token = None
150+
installation.user_scopes = []
151+
152+
# try to fetch the request user's installation
153+
# to reflect the user's access token if exists
154+
user_installation = self.installation_store.find_installation(
155+
enterprise_id=enterprise_id,
156+
team_id=team_id,
157+
user_id=user_id,
158+
is_enterprise_install=context.is_enterprise_install,
159+
)
160+
if user_installation is not None:
161+
# Overwrite the installation with the one for this user
162+
installation = user_installation
163+
164+
bot_token, user_token = (
165+
installation.bot_token,
166+
installation.user_token,
159167
)
160-
if user_installation is not None:
161-
# Overwrite the installation with the one for this user
162-
installation = user_installation
163168

164-
bot_token, user_token = installation.bot_token, installation.user_token
165169
except NotImplementedError as _:
166170
self.find_installation_available = False
167171

168-
if self.bot_only or not self.find_installation_available:
169-
# Use find_bot to get bot value (legacy)
170-
bot: Optional[Bot] = self.installation_store.find_bot(
171-
enterprise_id=enterprise_id,
172-
team_id=team_id,
173-
is_enterprise_install=context.is_enterprise_install,
172+
if (
173+
# If you intentionally use only find_bot / delete_bot,
174+
self.bot_only
175+
# If find_installation method is not available,
176+
or not self.find_installation_available
177+
# If find_installation did not return data and find_bot method is available,
178+
or (
179+
self.find_bot_available is True
180+
and bot_token is None
181+
and user_token is None
174182
)
175-
if bot is None:
176-
self._debug_log_for_not_found(enterprise_id, team_id)
177-
return None
178-
bot_token, user_token = bot.bot_token, None
183+
):
184+
try:
185+
bot: Optional[Bot] = self.installation_store.find_bot(
186+
enterprise_id=enterprise_id,
187+
team_id=team_id,
188+
is_enterprise_install=context.is_enterprise_install,
189+
)
190+
if bot is not None:
191+
bot_token = bot.bot_token
192+
except NotImplementedError as _:
193+
self.find_bot_available = False
194+
except Exception as e:
195+
self.logger.info(f"Failed to call find_bot method: {e}")
179196

180197
token: Optional[str] = bot_token or user_token
181198
if token is None:
199+
# No valid token was found
200+
self._debug_log_for_not_found(enterprise_id, team_id)
182201
return None
183202

184203
# Check cache to see if the bot object already exists
Lines changed: 150 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,150 @@
1+
import json
2+
from time import time
3+
from typing import Optional
4+
from urllib.parse import quote
5+
6+
from slack_sdk import WebClient
7+
from slack_sdk.oauth import InstallationStore
8+
from slack_sdk.oauth.installation_store import Installation, Bot
9+
from slack_sdk.signature import SignatureVerifier
10+
11+
from slack_bolt import BoltRequest
12+
from slack_bolt.app import App
13+
from tests.mock_web_api_server import (
14+
setup_mock_web_api_server,
15+
cleanup_mock_web_api_server,
16+
assert_auth_test_count,
17+
)
18+
from tests.utils import remove_os_env_temporarily, restore_os_env
19+
20+
valid_token = "xoxb-valid"
21+
valid_user_token = "xoxp-valid"
22+
23+
24+
class MyInstallationStore(InstallationStore):
25+
def find_bot(
26+
self,
27+
*,
28+
enterprise_id: Optional[str],
29+
team_id: Optional[str],
30+
is_enterprise_install: Optional[bool] = False,
31+
) -> Optional[Bot]:
32+
return Bot(
33+
app_id="A111",
34+
enterprise_id="E111",
35+
team_id="T111",
36+
bot_token=valid_token,
37+
bot_id="B111",
38+
bot_user_id="W111",
39+
bot_scopes=["commands"],
40+
installed_at=time(),
41+
)
42+
43+
def find_installation(
44+
self,
45+
*,
46+
enterprise_id: Optional[str],
47+
team_id: Optional[str],
48+
user_id: Optional[str] = None,
49+
is_enterprise_install: Optional[bool] = False,
50+
) -> Optional[Installation]:
51+
return None
52+
53+
54+
class TestInstallationStoreAuthorize:
55+
signing_secret = "secret"
56+
mock_api_server_base_url = "http://localhost:8888"
57+
signature_verifier = SignatureVerifier(signing_secret)
58+
web_client = WebClient(
59+
token=valid_token,
60+
base_url=mock_api_server_base_url,
61+
)
62+
63+
def setup_method(self):
64+
self.old_os_env = remove_os_env_temporarily()
65+
setup_mock_web_api_server(self)
66+
67+
def teardown_method(self):
68+
cleanup_mock_web_api_server(self)
69+
restore_os_env(self.old_os_env)
70+
71+
def generate_signature(self, body: str, timestamp: str):
72+
return self.signature_verifier.generate_signature(
73+
body=body,
74+
timestamp=timestamp,
75+
)
76+
77+
def build_headers(self, timestamp: str, body: str):
78+
return {
79+
"content-type": ["application/x-www-form-urlencoded"],
80+
"x-slack-signature": [self.generate_signature(body, timestamp)],
81+
"x-slack-request-timestamp": [timestamp],
82+
}
83+
84+
def build_valid_request(self) -> BoltRequest:
85+
timestamp = str(int(time()))
86+
return BoltRequest(
87+
body=raw_body, headers=self.build_headers(timestamp, raw_body)
88+
)
89+
90+
def test_success(self):
91+
app = App(
92+
client=self.web_client,
93+
installation_store=MyInstallationStore(),
94+
signing_secret=self.signing_secret,
95+
)
96+
app.action("a")(simple_listener)
97+
98+
request = self.build_valid_request()
99+
response = app.dispatch(request)
100+
assert response.status == 200
101+
assert response.body == ""
102+
assert_auth_test_count(self, 1)
103+
104+
105+
body = {
106+
"type": "block_actions",
107+
"user": {
108+
"id": "W99999",
109+
"username": "primary-owner",
110+
"name": "primary-owner",
111+
"team_id": "T111",
112+
},
113+
"api_app_id": "A111",
114+
"token": "verification_token",
115+
"container": {
116+
"type": "message",
117+
"message_ts": "111.222",
118+
"channel_id": "C111",
119+
"is_ephemeral": True,
120+
},
121+
"trigger_id": "111.222.valid",
122+
"team": {
123+
"id": "T111",
124+
"domain": "workspace-domain",
125+
"enterprise_id": "E111",
126+
"enterprise_name": "Sandbox Org",
127+
},
128+
"channel": {"id": "C111", "name": "test-channel"},
129+
"response_url": "https://hooks.slack.com/actions/T111/111/random-value",
130+
"actions": [
131+
{
132+
"action_id": "a",
133+
"block_id": "b",
134+
"text": {"type": "plain_text", "text": "Button", "emoji": True},
135+
"value": "click_me_123",
136+
"type": "button",
137+
"action_ts": "1596530385.194939",
138+
}
139+
],
140+
}
141+
142+
raw_body = f"payload={quote(json.dumps(body))}"
143+
144+
145+
def simple_listener(ack, body, payload, action):
146+
assert body["trigger_id"] == "111.222.valid"
147+
assert body["actions"][0] == payload
148+
assert payload == action
149+
assert action["action_id"] == "a"
150+
ack()

0 commit comments

Comments
 (0)