fix(teams): route approval-card authorization through the shared gateway check - #93516
shanthans-es wants to merge 2 commits into
Conversation
…way check _on_card_action() authorized Adaptive Card button clicks (Allow/Deny on exec-approval prompts) by comparing the clicker's raw AAD object ID / conversation ID against TEAMS_ALLOWED_USERS. Every Teams setup doc has operators put an email/UPN in that variable, which never equals the GUID Teams hands back on an invoke activity -- so the comparison rejected every clicker unconditionally, including users whose plain messages the bot already accepted via pairing-store approval. Slack's and Telegram's equivalent button handlers avoid this by delegating to GatewayRunner._is_user_authorized() (allowlist UNION pairing-store); Teams' handler was the one adapter with its own bespoke, untested identity check. Adds _is_card_action_authorized(), which builds a SessionSource via the adapter's existing build_source() and calls the shared runner check, falling back to the old raw-ID comparison only when the runner isn't reachable (preserving the prior default-deny posture in that case). New regression test proves this against the original code: with TEAMS_ALLOWED_USERS set to an email (as documented) and the clicker authorized only via a stubbed pairing-store-backed runner check, the old code always returned "Not authorized"; the fix does not.
Overall: the right fix — routing card-action auth through the shared runner check makes approval buttons follow the same allowlist ∪ pairing-store semantics as plain messages, and the new
Minor: |
…n fallback Addresses review feedback on NousResearch#93516: - _is_card_action_authorized() hardcoded chat_type="dm" for every click, so a group/channel-scoped policy (group_policy, owner-only-in-groups) never applied to a button click the way it applies to a message from the same conversation -- a clicker a group policy would reject as a sender could still approve dangerous commands by clicking the card. Factored the message path's conversation_type -> chat_type mapping into _chat_type_for_conversation() and reused it in both places. - The env-only fallback (runner unreachable) read TEAMS_ALLOWED_USERS / TEAMS_ALLOW_ALL_USERS via raw os.getenv, breaking the multiplex fail-closed contract: under gateway.multiplex_profiles, os.environ holds the default profile's values, so a secondary-profile Teams bot could authorize a click against the wrong profile's allowlist. Reused the adapter's existing _get_scoped_secret() helper, already used for TEAMS_CLIENT_SECRET, for both reads. - Bumped the "falling back to env-only auth" log lines from debug to warning, including a new one for the previously-silent case where no runner is reachable at all. New tests: a groupChat click asserts source.chat_type == "group" (fails pre-fix against the hardcoded "dm"), and a fallback-path test asserts the scoped-secret reader is consulted, not os.environ (fails pre-fix). 29/29 relevant tests pass.
|
Thanks for the review — pushed 2c18647 addressing points 1 and 2:
Also bumped the "falling back to env-only auth" logging from debug to warning per your note, including a new warning for the previously-silent no-runner-reachable case.
29/29 relevant tests pass ( |
|
Bumping this — hit the exact bug again today (2026-09-20) in production: a Teams approval card for another agent's tirith security-scan flag came through, and none of Allow Once/Allow Session/Deny registered a click, consistent with the auth-check bug this PR fixes. Would appreciate a maintainer look when you get a chance. |
What does this PR do?
Fixes Teams approval-card buttons (Allow Once / Allow Session / Always Allow / Deny) rejecting every clicker with "⛔ Not authorized" — a much narrower bug than it first looked like, and unrelated to invoke-handling itself (
_on_card_actionand the underlyingadaptiveCard/actionwiring are fully implemented and working).The actual cause:
_on_card_action()authorizes the click by comparing the clicker's raw AAD object ID / Teams conversation ID againstTEAMS_ALLOWED_USERS. Every setup doc (and the interactivehermes setup teamsprompt) has operators put an email/UPN in that variable. An email never equals the GUID Teams hands back on an invoke activity, so the comparison rejects every clicker unconditionally — including an operator whose plain DMs the bot already accepts fine via pairing-store approval, since regular messages go through a different, correct code path.Slack's and Telegram's equivalent button handlers avoid this exact trap by delegating to
GatewayRunner._is_user_authorized()(which checks the env allowlist UNION the pairing store, and normalizes identity correctly per platform). Teams' card-action handler was the one adapter with its own bespoke, untested identity comparison instead of using that shared path.Related
Not a duplicate of #75800 (open) — that PR fixes a session-binding/IDOR issue in the same handler; this fixes authorization identity matching. Both touch
_on_card_action()inplugins/platforms/teams/adapter.pyand may want to be sequenced/rebased against each other, but they're independent bugs.No existing issue filed for this one — found via source-level investigation of a "Teams approval buttons never work" report, not a live GitHub search hit. Happy to file one first if maintainers prefer that workflow.
Changes Made
plugins/platforms/teams/adapter.py: replace_on_card_action's inline env-var/GUID comparison with a new_is_card_action_authorized()that builds aSessionSourcevia the adapter's existingbuild_source()and calls the sharedGatewayRunner._is_user_authorized(), falling back to the old raw-ID comparison only when the runner isn't reachable (same default-deny posture as before in that fallback).tests/gateway/test_teams.py: addsTestTeamsCardActionAuthorization(4 tests) — no prior test coverage existed for_on_card_actionat all.How to Test
Sabotage check: reverted
adapter.pyagainst the new tests —test_allowed_via_shared_runner_despite_mismatched_env_identityfails on the original code (AssertionError: '⛔ Not authorized.' != '⛔ Not authorized.'), confirming it exercises the real bug. With the fix, all pass.Also ran
tests/gateway/test_slack_approval_buttons.pyandtests/gateway/test_config_driven_access_policy.py(the shared-authz surfaces this now shares code with) — no regressions, 100 passed total.ruff checkclean on both changed files.🤖 Generated with Claude Code