-
Notifications
You must be signed in to change notification settings - Fork 346
Restrict Hackbot triggers to authorized user with editbugs permissions #6444
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: master
Are you sure you want to change the base?
Changes from all commits
1f5820a
ef9701c
8ae7728
f839db8
c755905
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,69 @@ | ||
| """Authorization checks for Phabricator webhook authors.""" | ||
|
|
||
| from __future__ import annotations | ||
|
|
||
| import asyncio | ||
| import time | ||
| from typing import TYPE_CHECKING | ||
|
|
||
| from cachetools import TTLCache | ||
|
|
||
| if TYPE_CHECKING: | ||
| from phabricator_client import PhabricatorClient | ||
|
|
||
|
|
||
| class PhabricatorAuthorizer: | ||
| """Cache-backed authorization checks against a Phabricator project.""" | ||
|
|
||
| def __init__( | ||
| self, | ||
| group_phid: str, | ||
| *, | ||
| cache_ttl_seconds: int = 300, | ||
| missing_member_refresh_cooldown_seconds: int = 30, | ||
| ) -> None: | ||
| self._group_phid = group_phid | ||
| self._members_cache: TTLCache[str, frozenset[str]] = TTLCache( | ||
| maxsize=1, | ||
| ttl=cache_ttl_seconds, | ||
| ) | ||
| self._members_lock = asyncio.Lock() | ||
| self._last_members_refresh = 0.0 | ||
| self._missing_member_refresh_cooldown_seconds = ( | ||
| missing_member_refresh_cooldown_seconds | ||
| ) | ||
|
|
||
| async def is_authorized(self, client: PhabricatorClient, author_phid: str) -> bool: | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I would pass the client to the constructor instead of here. |
||
| """Return whether an author belongs to the authorized project. | ||
|
|
||
| Known members use the cached project snapshot. An unknown author causes | ||
| one refresh so recently added members take effect promptly. Subsequent | ||
| unknown authors use a short cooldown to avoid a Phabricator request for | ||
| every unauthorized webhook delivery. | ||
| """ | ||
| cached_members = self._members_cache.get(self._group_phid) | ||
| if cached_members is not None and author_phid in cached_members: | ||
| return True | ||
|
|
||
| async with self._members_lock: | ||
| cached_members = self._members_cache.get(self._group_phid) | ||
| if cached_members is not None and author_phid in cached_members: | ||
| return True | ||
|
|
||
| now = time.monotonic() | ||
| if ( | ||
| cached_members is not None | ||
| and now - self._last_members_refresh | ||
| < self._missing_member_refresh_cooldown_seconds | ||
| ): | ||
| return False | ||
|
|
||
| members = await client.get_project_members(self._group_phid) | ||
| self._members_cache[self._group_phid] = members | ||
| self._last_members_refresh = time.monotonic() | ||
| return author_phid in members | ||
|
|
||
|
|
||
| # Members of this project are authorized to trigger Hackbot. | ||
| AUTHORIZED_GROUP_PHID = "PHID-PROJ-njo5uuqyyq3oijbkhy55" # bmo-editbugs-team | ||
| phabricator_authorizer = PhabricatorAuthorizer(AUTHORIZED_GROUP_PHID) | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Instead of using it as a singleton here, I would pass it as a dependency to the FastAPI route. The dependency injection with FastAPI makes it easier to avoid singletons. |
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -9,8 +9,11 @@ | |
| from __future__ import annotations | ||
|
|
||
| import logging | ||
| from dataclasses import dataclass | ||
| from typing import TYPE_CHECKING | ||
|
|
||
| from app.phabricator_authorization import phabricator_authorizer | ||
|
|
||
| if TYPE_CHECKING: | ||
| from phabricator_client import PhabricatorClient | ||
|
|
||
|
|
@@ -22,6 +25,12 @@ | |
| _COMMENT_TYPES = frozenset({"comment", "inline"}) | ||
|
|
||
|
|
||
| @dataclass(frozen=True) | ||
| class HackbotMention: | ||
| raw: str | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The name |
||
| author_phid: str | ||
|
|
||
|
|
||
| def triggering_transaction_phids(payload: dict) -> list[str]: | ||
| """The transaction PHIDs this delivery is about (from the webhook body).""" | ||
| return [ | ||
|
|
@@ -37,27 +46,35 @@ def find_hackbot_mentions( | |
| *, | ||
| bot_phid: str, | ||
| token: str, | ||
| ) -> list[str]: | ||
| """Return the text of every triggering comment that mentions ``token``. | ||
| ) -> list[HackbotMention]: | ||
| """Return every triggering comment that mentions ``token``. | ||
|
|
||
| Only considers transactions named in this delivery, of a comment type, not | ||
| authored by the bot itself (loop prevention). A single review can leave | ||
| several inline comments (each its own transaction), so all matches are | ||
| returned, in transaction order. At most one per transaction: a transaction's | ||
| ``comments`` list is that comment's version history, not distinct comments. | ||
| """ | ||
| matches: list[str] = [] | ||
| matches: list[HackbotMention] = [] | ||
| for transaction in transactions: | ||
| if transaction.get("phid") not in triggering_phids: | ||
| continue | ||
| if transaction.get("type") not in _COMMENT_TYPES: | ||
| continue | ||
| if bot_phid and transaction.get("authorPHID") == bot_phid: | ||
| author_phid = transaction.get("authorPHID") | ||
| if not author_phid: | ||
| continue | ||
| if bot_phid and author_phid == bot_phid: | ||
| continue | ||
| for comment in transaction.get("comments") or []: | ||
| raw = (comment.get("content") or {}).get("raw") or "" | ||
| if token in raw: | ||
| matches.append(raw) | ||
| matches.append( | ||
| HackbotMention( | ||
| raw=raw, | ||
| author_phid=author_phid, | ||
| ) | ||
| ) | ||
| break | ||
| return matches | ||
|
|
||
|
|
@@ -111,15 +128,29 @@ async def detect_mention_and_revision( | |
| revision can't be resolved, or it has no Bugzilla bug id (bug-fix needs one). | ||
| """ | ||
| transactions = await client.search_transactions(object_phid) | ||
| comments = find_hackbot_mentions( | ||
| mentions = find_hackbot_mentions( | ||
| transactions, | ||
| set(triggering_phids), | ||
| bot_phid=webhook.bot_phid, | ||
| token=webhook.mention_token, | ||
| ) | ||
| comments: list[str] = [] | ||
| for mention in mentions: | ||
| if await phabricator_authorizer.is_authorized( | ||
| client, | ||
| mention.author_phid, | ||
| ): | ||
| comments.append(mention.raw) | ||
| else: | ||
| log.warning( | ||
| "Ignoring %s mention from non-editbugs user %s on %s", | ||
| webhook.mention_token, | ||
| mention.author_phid, | ||
| object_phid, | ||
| ) | ||
| if not comments: | ||
| log.warning( | ||
| "No %s mention found in triggering transactions %s on %s", | ||
| "No actionable %s mention found in triggering transactions %s on %s", | ||
| webhook.mention_token, | ||
| triggering_phids, | ||
| object_phid, | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,28 @@ | ||
| """Tests for Phabricator webhook author authorization.""" | ||
|
|
||
| from unittest.mock import AsyncMock | ||
|
|
||
| from app.phabricator_authorization import PhabricatorAuthorizer | ||
|
|
||
|
|
||
| class _FakeClient: | ||
| def __init__(self, members: frozenset[str]) -> None: | ||
| self.get_project_members = AsyncMock(return_value=members) | ||
|
|
||
|
|
||
| async def test_is_authorized_uses_cached_member_list(): | ||
| authorizer = PhabricatorAuthorizer("PHID-PROJ-test") | ||
| client = _FakeClient(frozenset({"PHID-USER-authorized"})) | ||
|
|
||
| assert await authorizer.is_authorized(client, "PHID-USER-authorized") is True | ||
| assert await authorizer.is_authorized(client, "PHID-USER-authorized") is True | ||
| client.get_project_members.assert_awaited_once_with("PHID-PROJ-test") | ||
|
|
||
|
|
||
| async def test_is_authorized_refreshes_once_for_unknown_authors(): | ||
| authorizer = PhabricatorAuthorizer("PHID-PROJ-test") | ||
| client = _FakeClient(frozenset({"PHID-USER-authorized"})) | ||
|
|
||
| assert await authorizer.is_authorized(client, "PHID-USER-unknown-one") is False | ||
| assert await authorizer.is_authorized(client, "PHID-USER-unknown-two") is False | ||
| client.get_project_members.assert_awaited_once_with("PHID-PROJ-test") |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.