Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 4 additions & 8 deletions docs/hackbot/triggers.md
Original file line number Diff line number Diff line change
Expand Up @@ -137,10 +137,10 @@ Guards, each closing a specific failure mode:
- **Latest flag wins** — BMO orders flags by id, so the last matching one is the newly
requested one.

Authorization is Bugzilla's own: anyone who can set a needinfo on the bot can ask it for
something. There is no separate group check like the Phabricator trigger's
`bmo-editbugs-team`, because a private bug is already excluded and the flag itself is the
request.
Only requesters in Bugzilla's `editbugs` group are authorized (all Mozilla Corporation
members belong to this group) — see
[bugzilla_authorization.py](../../services/hackbot-api/app/bugzilla_authorization.py).
Membership is checked per login through Bugzilla's REST API.

The receiver passes the requester's login and the change timestamp to the agent as context
for locating the accompanying comment — a needinfo may be filed without one, in which case
Expand All @@ -153,7 +153,3 @@ existing one. The needinfo flag is cleared automatically as a recorded
`bugzilla.update_bug` action once the run produces at least one other action, coalesced with
the reply comment into a single Bugzilla transaction (see [actions.md](actions.md)). A run
that records nothing leaves the flag standing.

Configuration is three env vars — `BUGZILLA_WEBHOOK_SECRET` (required, no default),
`BUGZILLA_WEBHOOK_BOT_LOGIN` and `BUGZILLA_WEBHOOK_DEDUPE_TTL_SECONDS`; see
[deployment.md](deployment.md).
62 changes: 62 additions & 0 deletions services/hackbot-api/app/bugzilla_authorization.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
"""Authorization checks for Bugzilla webhook actors."""

from __future__ import annotations

import httpx
from cachetools import TTLCache

AUTHORIZED_GROUP_NAME = "editbugs"

_REQUEST_TIMEOUT_SECONDS = 30


class BugzillaAuthorizer:
"""Cache-backed per-user authorization checks against a Bugzilla group."""

def __init__(
self,
api_url: str,
api_key: str,
authorized_group_name: str,
*,
cache_ttl_seconds: int = 300,
cache_maxsize: int = 4096,
) -> None:
self._api_url = api_url.rstrip("/")
self._api_key = api_key
self._authorized_group_name = authorized_group_name
self._cache: TTLCache[str, bool] = TTLCache(
maxsize=cache_maxsize,
ttl=cache_ttl_seconds,
)

async def is_authorized(self, login: str) -> bool:
"""Return whether a Bugzilla login belongs to the authorized group."""
login = login.lower()

cached = self._cache.get(login)
if cached is not None:
return cached

authorized = await self._is_user_in_group(login, self._authorized_group_name)
self._cache[login] = authorized
return authorized

# TODO: Move this REST call to a shared Bugzilla client library (#6459).
async def _is_user_in_group(self, login: str, group_name: str) -> bool:
"""Return whether a Bugzilla account exists and belongs to a group."""
async with httpx.AsyncClient(timeout=_REQUEST_TIMEOUT_SECONDS) as client:
response = await client.get(
f"{self._api_url}/user",

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: I would use the GET /rest/user/(id_or_name) since we need one user only.

params={
"names": login,
"groups": group_name,
"include_fields": "name",
# Report an unknown login in ``faults`` instead of failing
# the request, so it maps to "not authorized", not a 500.
"permissive": "1",
},
headers={"X-Bugzilla-API-Key": self._api_key},
)
response.raise_for_status()
return bool(response.json().get("users"))
5 changes: 4 additions & 1 deletion services/hackbot-api/app/bugzilla_webhook.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ class BugzillaNeedinfoEvent:
bug_id: int
flag_id: int
comment: str
user_login: str


def detect_needinfo_request(
Expand Down Expand Up @@ -71,4 +72,6 @@ def detect_needinfo_request(
"A needinfo may be requested without a comment, so use the surrounding "
"bug context if none exists."
)
return BugzillaNeedinfoEvent(bug_id=bug_id, flag_id=flag_id, comment=comment)
return BugzillaNeedinfoEvent(
bug_id=bug_id, flag_id=flag_id, comment=comment, user_login=actor_login
)
3 changes: 3 additions & 0 deletions services/hackbot-api/app/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,9 @@ class Settings(BaseSettings):
# BUGZILLA_WEBHOOK_DEDUPE_TTL_SECONDS.
bugzilla_webhook: BugzillaWebhookSettings

bugzilla_api_url: str = "https://bugzilla.mozilla.org/rest"
bugzilla_api_key: str

slack: SlackSettings

# The webhook receiver triggers runs over the public API (rather than calling
Expand Down
23 changes: 23 additions & 0 deletions services/hackbot-api/app/routers/webhooks.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
require_bugzilla_webhook_secret,
require_phabricator_signature,
)
from app.bugzilla_authorization import AUTHORIZED_GROUP_NAME, BugzillaAuthorizer
from app.bugzilla_webhook import detect_needinfo_request
from app.config import settings
from app.phabricator_authorization import (
Expand Down Expand Up @@ -52,6 +53,19 @@ def get_phabricator_authorizer(
return authorizer


def get_bugzilla_authorizer(request: Request) -> BugzillaAuthorizer:
"""Dependency: lazily create the app-scoped authorizer and its user cache."""
authorizer = getattr(request.app.state, "bugzilla_authorizer", None)
if authorizer is None:
authorizer = BugzillaAuthorizer(
settings.bugzilla_api_url,
settings.bugzilla_api_key,
AUTHORIZED_GROUP_NAME,
)
request.app.state.bugzilla_authorizer = authorizer
return authorizer


# Best-effort dedupe of retried deliveries, keyed by triggering transaction PHID.
# Per-instance and reset on restart; a durable dedupe (using the DB) can replace
# this if needed. Sized well above the number of mentions expected in a window.
Expand Down Expand Up @@ -149,6 +163,7 @@ async def phabricator_webhook(
async def bugzilla_webhook(
request: Request,
api_client: HackbotClient = Depends(get_hackbot_client),
authorizer: BugzillaAuthorizer = Depends(get_bugzilla_authorizer),
) -> dict:
"""Trigger a bug-fix follow-up for a bot-directed ``needinfo?`` change."""
payload = await request.json()
Expand All @@ -170,6 +185,14 @@ async def bugzilla_webhook(
)
return {"status": "ignored", "reason": "duplicate delivery"}

if not await authorizer.is_authorized(detected.user_login):
log.info(
"Ignored Bugzilla needinfo webhook for bug %s: %s is not authorized",
detected.bug_id,
detected.user_login,
)
return {"status": "ignored", "reason": "unauthorized user"}

run = await api_client.trigger_run(
"bug-fix",
{
Expand Down
1 change: 1 addition & 0 deletions services/hackbot-api/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ dependencies = [
"google-auth>=2.29.0",
"sentry-sdk>=2.51.0",
"cachetools>=5.3.0",
"httpx>=0.26.0",
"slack-sdk>=3.27.0",
"python-multipart>=0.0.9",
"hackbot-client",
Expand Down
1 change: 1 addition & 0 deletions services/hackbot-api/tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,4 +10,5 @@
os.environ.setdefault("WEBHOOK_SECRET", "test-webhook-secret")
os.environ.setdefault("BUGZILLA_WEBHOOK_SECRET", "test-bugzilla-webhook-secret")
os.environ.setdefault("BUGZILLA_WEBHOOK_BOT_LOGIN", "hackbot@mozilla.tld")
os.environ.setdefault("BUGZILLA_API_KEY", "test-bugzilla-api-key")
os.environ.setdefault("SLACK_SIGNING_SECRET", "test-signing-secret")
115 changes: 115 additions & 0 deletions services/hackbot-api/tests/test_bugzilla_authorization.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
"""Tests for Bugzilla webhook actor authorization."""

from unittest.mock import AsyncMock

import httpx
from app.bugzilla_authorization import AUTHORIZED_GROUP_NAME, BugzillaAuthorizer

BUGZILLA_API_KEY = "test-bugzilla-api-key"


def _authorizer(member: bool) -> tuple[BugzillaAuthorizer, AsyncMock]:
"""An authorizer whose membership lookup is stubbed to ``member``."""
authorizer = BugzillaAuthorizer(
"https://bugzilla.example.com/rest",
BUGZILLA_API_KEY,
AUTHORIZED_GROUP_NAME,
)
lookup = AsyncMock(return_value=member)
authorizer._is_user_in_group = lookup
return authorizer, lookup


async def test_is_authorized_caches_positive_lookup():
authorizer, lookup = _authorizer(member=True)

assert await authorizer.is_authorized("dev@mozilla.com") is True
assert await authorizer.is_authorized("dev@mozilla.com") is True
lookup.assert_awaited_once_with("dev@mozilla.com", AUTHORIZED_GROUP_NAME)


async def test_is_authorized_caches_negative_lookup():
authorizer, lookup = _authorizer(member=False)

assert await authorizer.is_authorized("someone@example.com") is False
assert await authorizer.is_authorized("someone@example.com") is False
lookup.assert_awaited_once_with("someone@example.com", AUTHORIZED_GROUP_NAME)


async def test_is_authorized_normalizes_login_case():
authorizer, lookup = _authorizer(member=True)

assert await authorizer.is_authorized("Dev@Mozilla.com") is True
assert await authorizer.is_authorized("dev@mozilla.com") is True
lookup.assert_awaited_once_with("dev@mozilla.com", AUTHORIZED_GROUP_NAME)


# --- the membership lookup itself, on BMO's captured payload shapes ---


def _http_authorizer(
monkeypatch, json_body: dict
) -> tuple[BugzillaAuthorizer, list[httpx.Request]]:
"""An authorizer whose HTTP layer replays ``json_body``, capturing requests."""
requests: list[httpx.Request] = []

def handler(request: httpx.Request) -> httpx.Response:
requests.append(request)
return httpx.Response(200, json=json_body)

real_async_client = httpx.AsyncClient
monkeypatch.setattr(
httpx,
"AsyncClient",
lambda **kwargs: real_async_client(
transport=httpx.MockTransport(handler), **kwargs
),
)
authorizer = BugzillaAuthorizer(
"https://bugzilla.example.com/rest",
BUGZILLA_API_KEY,
AUTHORIZED_GROUP_NAME,
)
return authorizer, requests


async def test_lookup_authorizes_group_member(monkeypatch):
authorizer, requests = _http_authorizer(
monkeypatch, {"users": [{"name": "dev@mozilla.com"}], "faults": []}
)

assert await authorizer.is_authorized("dev@mozilla.com") is True

request = requests[0]
assert request.url.host == "bugzilla.example.com"
assert request.url.path == "/rest/user"
assert request.url.params["names"] == "dev@mozilla.com"
assert request.url.params["groups"] == AUTHORIZED_GROUP_NAME
assert request.url.params["permissive"] == "1"
assert request.headers["X-Bugzilla-API-Key"] == BUGZILLA_API_KEY


async def test_lookup_rejects_non_member(monkeypatch):
# An existing account outside the group is filtered out server-side
# (live BMO shape: empty ``users``, empty ``faults``).
authorizer, _ = _http_authorizer(monkeypatch, {"users": [], "faults": []})
assert await authorizer.is_authorized("outsider@example.com") is False


async def test_lookup_rejects_unknown_user(monkeypatch):
# With permissive=1, BMO reports an unknown login as a 200 with the error
# in ``faults`` and an empty ``users`` list (live BMO shape).
authorizer, _ = _http_authorizer(
monkeypatch,
{
"users": [],
"faults": [
{
"error": True,
"name": "ghost@example.com",
"message": "There is no user named 'ghost@example.com'.",
}
],
},
)
assert await authorizer.is_authorized("ghost@example.com") is False
43 changes: 38 additions & 5 deletions services/hackbot-api/tests/test_webhooks.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,8 @@
Covers HMAC signature verification, mention detection / loop prevention, the
revision -> (revision_id, bug_id) resolution, and the route's ignore/trigger
branches. Bugzilla coverage includes shared-secret auth, structured needinfo
detection, self/private-event suppression, dedupe, and dispatch retry behavior.
detection, self/private-event suppression, user authorization, dedupe, and
dispatch retry behavior.
"""

import hashlib
Expand Down Expand Up @@ -480,6 +481,7 @@ def test_detect_bugzilla_needinfo_from_captured_payload_shape():
assert detected is not None
assert detected.bug_id == 2022889
assert detected.flag_id == 2187233
assert detected.user_login == "gmierzwinski@mozilla.com"
assert "gmierzwinski@mozilla.com" in detected.comment
assert "2026-08-07T18:00:05" in detected.comment

Expand Down Expand Up @@ -541,22 +543,32 @@ async def trigger_run(self, agent_name, inputs):


class _FakeAuthorizer:
async def is_authorized(self, author_phid):
return True
def __init__(self, allowed: bool = True):
self.allowed = allowed
self.checked = []

async def is_authorized(self, actor):
self.checked.append(actor)
return self.allowed


@pytest.fixture
def authorizer():
return _FakeAuthorizer()


@pytest.fixture
def bugzilla_authorizer():
return _FakeAuthorizer()


@pytest.fixture
def phab_client():
return object()


@pytest.fixture
def client(monkeypatch, authorizer, phab_client):
def client(monkeypatch, authorizer, bugzilla_authorizer, phab_client):
monkeypatch.setattr(settings, "external_api_key", "test-api-key")
monkeypatch.setattr(settings.webhook, "secret", SECRET)
monkeypatch.setattr(settings.bugzilla_webhook, "secret", BUGZILLA_SECRET)
Expand All @@ -566,6 +578,9 @@ def client(monkeypatch, authorizer, phab_client):
webhooks._seen_bugzilla_events.clear()
app.dependency_overrides[webhooks.get_phabricator_client] = lambda: phab_client
app.dependency_overrides[webhooks.get_phabricator_authorizer] = lambda: authorizer
app.dependency_overrides[webhooks.get_bugzilla_authorizer] = lambda: (
bugzilla_authorizer
)
try:
yield TestClient(app)
finally:
Expand Down Expand Up @@ -745,7 +760,7 @@ def test_bugzilla_route_ignores_non_matching_event(client):
}


def test_bugzilla_route_triggers_run(client):
def test_bugzilla_route_triggers_run(client, bugzilla_authorizer):
fake_api = _FakeHackbotClient()
app.dependency_overrides[webhooks.get_hackbot_client] = lambda: fake_api

Expand All @@ -772,6 +787,24 @@ def test_bugzilla_route_triggers_run(client):
},
)
]
assert bugzilla_authorizer.checked == ["gmierzwinski@mozilla.com"]


def test_bugzilla_route_ignores_unauthorized_actor(client, bugzilla_authorizer):
bugzilla_authorizer.allowed = False
fake_api = _FakeHackbotClient()
app.dependency_overrides[webhooks.get_hackbot_client] = lambda: fake_api
payload = _bugzilla_payload()
detected = detect_needinfo_request(payload, bot_login=BUGZILLA_BOT_LOGIN)

response = _post_bugzilla(client, payload)

assert response.status_code == 202
assert response.json() == {"status": "ignored", "reason": "unauthorized user"}
assert fake_api.calls == []
# The event stays unconsumed: the same flag can still trigger a run once
# the actor is authorized.
assert f"ni{detected.flag_id}" not in webhooks._seen_bugzilla_events


def test_bugzilla_route_dedupes_retry_but_not_later_event(client):
Expand Down
Loading