Skip to content

Commit 07a339e

Browse files
authored
Merge pull request #51 from PostHog/tom/race-condition
Clear permission set list immediately on account change
2 parents fca1710 + 9908e85 commit 07a339e

2 files changed

Lines changed: 72 additions & 10 deletions

File tree

src/main.py

Lines changed: 33 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
import jmespath as jp
66
from slack_bolt import Ack, App, BoltContext
77
from slack_bolt.adapter.aws_lambda import SlackRequestHandler
8+
import slack_sdk.errors
89
from slack_sdk import WebClient
910
from slack_sdk.web.slack_response import SlackResponse
1011

@@ -808,7 +809,7 @@ def _get_cached_user_info(view_key: str, user_id: str, client: "WebClient") -> t
808809

809810

810811
@app.action(slack_helpers.RequestForAccessView.ACCOUNT_ACTION_ID)
811-
def handle_account_selection(ack: Ack, body: dict, client: WebClient) -> SlackResponse:
812+
def handle_account_selection(ack: Ack, body: dict, client: WebClient) -> SlackResponse | None:
812813
ack()
813814
logger.info("Handling account selection")
814815

@@ -824,11 +825,35 @@ def handle_account_selection(ack: Ack, body: dict, client: WebClient) -> SlackRe
824825
logger.info(f"Selected accounts: {account_ids}")
825826

826827
view_id = body["view"]["id"]
828+
view_hash = body["view"].get("hash")
829+
830+
def safe_views_update(view) -> SlackResponse | None: # noqa: ANN001, ANN202
831+
nonlocal view_hash
832+
try:
833+
response = client.views_update(view_id=view_id, view=view, hash=view_hash)
834+
except slack_sdk.errors.SlackApiError as e:
835+
error = e.response.get("error") if e.response else None
836+
# hash_conflict / view_expired = a newer handler already updated the view; skip silently.
837+
if error in {"hash_conflict", "view_expired", "not_found"}:
838+
logger.info(f"Skipping stale views_update: {error}")
839+
return None
840+
raise
841+
new_hash = response.data.get("view", {}).get("hash") if response.data else None # type: ignore[union-attr]
842+
if new_hash:
843+
view_hash = new_hash
844+
return response
827845

828846
# No accounts selected → show placeholder, disable submit
829847
if not account_ids:
830848
updated_view = slack_helpers.RequestForAccessView.build_no_permission_sets_view(view_blocks=body["view"]["blocks"])
831-
return client.views_update(view_id=view_id, view=updated_view)
849+
return safe_views_update(updated_view)
850+
851+
# Immediately replace stale permission set list with a loading placeholder before any AWS calls.
852+
loading_response = safe_views_update(slack_helpers.RequestForAccessView.show_permission_set_loading(body["view"]["blocks"]))
853+
if loading_response is None:
854+
# A newer handler is already in flight; let it produce the final view.
855+
return None
856+
current_blocks = loading_response.data["view"]["blocks"] # type: ignore[index]
832857

833858
# Get cached user info, re-fetching from Identity Center on cache miss
834859
# (e.g. when Lambda container was recycled between form load and account selection)
@@ -842,8 +867,8 @@ def handle_account_selection(ack: Ack, body: dict, client: WebClient) -> SlackRe
842867
logger.info(f"Valid permission sets for selected accounts and user: {valid_ps_names}")
843868

844869
if not valid_ps_names:
845-
updated_view = slack_helpers.RequestForAccessView.build_no_permission_sets_view(view_blocks=body["view"]["blocks"])
846-
return client.views_update(view_id=view_id, view=updated_view)
870+
updated_view = slack_helpers.RequestForAccessView.build_no_permission_sets_view(view_blocks=current_blocks)
871+
return safe_views_update(updated_view)
847872

848873
if "*" in valid_ps_names:
849874
permission_sets = sso.get_permission_sets_from_config_with_cache(sso_client=sso_client, s3_client=s3_client, cfg=cfg)
@@ -853,8 +878,8 @@ def handle_account_selection(ack: Ack, body: dict, client: WebClient) -> SlackRe
853878

854879
# Handle case where filtered list is empty (configured names don't exist in SSO)
855880
if not permission_sets:
856-
updated_view = slack_helpers.RequestForAccessView.build_no_permission_sets_view(view_blocks=body["view"]["blocks"])
857-
return client.views_update(view_id=view_id, view=updated_view)
881+
updated_view = slack_helpers.RequestForAccessView.build_no_permission_sets_view(view_blocks=current_blocks)
882+
return safe_views_update(updated_view)
858883

859884
# Classify permission sets as auto-approved vs requires-approval
860885
auto_approved_arns: set[str] | None = None
@@ -882,12 +907,12 @@ def approver_group_resolver(group_ids: frozenset[str]) -> set[str]:
882907
)
883908

884909
updated_view = slack_helpers.RequestForAccessView.update_with_permission_sets(
885-
view_blocks=body["view"]["blocks"],
910+
view_blocks=current_blocks,
886911
permission_sets=permission_sets,
887912
display_names=cfg.permission_set_display_names,
888913
auto_approved_arns=auto_approved_arns,
889914
)
890-
return client.views_update(view_id=view_id, view=updated_view)
915+
return safe_views_update(updated_view)
891916

892917

893918
# Early Revoke Handlers

src/slack_helpers.py

Lines changed: 39 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,7 @@ class RequestForAccessView:
6363

6464
LOADING_BLOCK_ID = "loading"
6565
PERMISSION_SET_PLACEHOLDER_BLOCK_ID = "permission_set_placeholder"
66+
PERMISSION_SET_LOADING_BLOCK_ID = "permission_set_loading"
6667

6768
@classmethod
6869
def build(cls) -> View:
@@ -205,6 +206,39 @@ def build_permission_set_placeholder_block(cls) -> InputBlock:
205206
),
206207
)
207208

209+
@classmethod
210+
def build_permission_set_loading_block(cls) -> InputBlock:
211+
return InputBlock(
212+
block_id=cls.PERMISSION_SET_LOADING_BLOCK_ID,
213+
label=PlainTextObject(text="Permission set"),
214+
element=StaticSelectElement(
215+
action_id=cls.PERMISSION_SET_ACTION_ID + "_loading",
216+
placeholder=PlainTextObject(text=":hourglass: Loading permission sets..."),
217+
options=[Option(text=PlainTextObject(text="—"), value="_loading")],
218+
),
219+
)
220+
221+
@classmethod
222+
def show_permission_set_loading(cls, view_blocks: list) -> View:
223+
view = cls.build()
224+
view.submit_disabled = True # type: ignore[attr-defined]
225+
blocks = remove_blocks(
226+
view_blocks,
227+
block_ids=[
228+
cls.PERMISSION_SET_PLACEHOLDER_BLOCK_ID,
229+
cls.PERMISSION_SET_BLOCK_ID,
230+
cls.PERMISSION_SET_LOADING_BLOCK_ID,
231+
"no_eligible_accounts",
232+
],
233+
)
234+
blocks = insert_blocks(
235+
blocks=blocks,
236+
blocks_to_insert=[cls.build_permission_set_loading_block()],
237+
after_block_id=cls.ACCOUNT_BLOCK_ID,
238+
)
239+
view.blocks = blocks
240+
return view
241+
208242
@classmethod
209243
def update_with_accounts(cls, accounts: list[entities.aws.Account]) -> View:
210244
view = cls.build()
@@ -230,7 +264,10 @@ def update_with_permission_sets(
230264
view = cls.build()
231265
view.submit_disabled = False # type: ignore[attr-defined]
232266
# Start from the current blocks, remove placeholder
233-
blocks = remove_blocks(view_blocks, block_ids=[cls.PERMISSION_SET_PLACEHOLDER_BLOCK_ID, cls.PERMISSION_SET_BLOCK_ID])
267+
blocks = remove_blocks(
268+
view_blocks,
269+
block_ids=[cls.PERMISSION_SET_PLACEHOLDER_BLOCK_ID, cls.PERMISSION_SET_BLOCK_ID, cls.PERMISSION_SET_LOADING_BLOCK_ID],
270+
)
234271
# Insert permission set dropdown after account dropdown
235272
blocks = insert_blocks(
236273
blocks=blocks,
@@ -288,7 +325,7 @@ def build_no_permission_sets_view(cls, view_blocks: list) -> View:
288325
view.submit_disabled = True # type: ignore[attr-defined]
289326
blocks = remove_blocks(
290327
view_blocks,
291-
block_ids=[cls.PERMISSION_SET_PLACEHOLDER_BLOCK_ID, cls.PERMISSION_SET_BLOCK_ID],
328+
block_ids=[cls.PERMISSION_SET_PLACEHOLDER_BLOCK_ID, cls.PERMISSION_SET_BLOCK_ID, cls.PERMISSION_SET_LOADING_BLOCK_ID],
292329
)
293330
blocks = insert_blocks(
294331
blocks=blocks,

0 commit comments

Comments
 (0)