Skip to content

Commit f082932

Browse files
authored
Add authentication requirement to /bugzilla_webhook endpoint (#828)
* Add token authentication to Webhook endpoint * Create `authenticated_client` fixture Use client fixtures in tests, rather than manually setting up clients * Update .secrets.baseline
1 parent c057e92 commit f082932

File tree

7 files changed

+104
-46
lines changed

7 files changed

+104
-46
lines changed

.env.example

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ LOG_FORMAT=text
44
LOG_LEVEL=debug
55
APP_RELOAD=True
66
APP_DEBUG=True
7+
JBI_API_KEY="fake_api_key"
78

89
# Jira API Secrets
910
JIRA_USERNAME="fake_jira_username"

.secrets.baseline

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -101,21 +101,28 @@
101101
],
102102
"results": {
103103
".env.example": [
104+
{
105+
"type": "Secret Keyword",
106+
"filename": ".env.example",
107+
"hashed_secret": "7dfe63b6762fc69b8e486a2bafa43b8f7d23b788",
108+
"is_verified": false,
109+
"line_number": 7
110+
},
104111
{
105112
"type": "Secret Keyword",
106113
"filename": ".env.example",
107114
"hashed_secret": "4b9a4ce92b6a01a4cd6ee1672d31c043f2ae79ab",
108115
"is_verified": false,
109-
"line_number": 10
116+
"line_number": 11
110117
},
111118
{
112119
"type": "Secret Keyword",
113120
"filename": ".env.example",
114121
"hashed_secret": "77ea6398f252999314d609a708842a49fc43e055",
115122
"is_verified": false,
116-
"line_number": 13
123+
"line_number": 14
117124
}
118125
]
119126
},
120-
"generated_at": "2023-04-25T15:55:52Z"
127+
"generated_at": "2024-01-29T20:59:20Z"
121128
}

jbi/environment.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ class Settings(BaseSettings):
3131
max_retries: int = 3
3232
# https://github.com/python/mypy/issues/12841
3333
env: Environment = Environment.NONPROD # type: ignore
34+
jbi_api_key: str
3435

3536
# Jira
3637
jira_base_url: str = "https://mozit-test.atlassian.net/"

jbi/router.py

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,14 @@
11
"""
22
Core FastAPI app (setup, middleware)
33
"""
4+
import secrets
45
from pathlib import Path
56
from typing import Annotated, Optional
67

7-
from fastapi import APIRouter, Body, Depends, Request, Response
8+
from fastapi import APIRouter, Body, Depends, HTTPException, Request, Response, status
89
from fastapi.encoders import jsonable_encoder
910
from fastapi.responses import HTMLResponse
11+
from fastapi.security import APIKeyHeader
1012
from fastapi.templating import Jinja2Templates
1113

1214
from jbi import bugzilla, jira
@@ -20,6 +22,7 @@
2022
VersionDep = Annotated[dict, Depends(get_version)]
2123
BugzillaServiceDep = Annotated[bugzilla.BugzillaService, Depends(bugzilla.get_service)]
2224
JiraServiceDep = Annotated[jira.JiraService, Depends(jira.get_service)]
25+
2326
router = APIRouter()
2427

2528

@@ -72,7 +75,22 @@ def version(version_json: VersionDep):
7275
return version_json
7376

7477

75-
@router.post("/bugzilla_webhook")
78+
header_scheme = APIKeyHeader(name="X-Api-Key")
79+
80+
81+
def api_key_auth(
82+
settings: SettingsDep, api_key: Annotated[str, Depends(header_scheme)]
83+
):
84+
if not secrets.compare_digest(api_key, settings.jbi_api_key):
85+
raise HTTPException(
86+
status_code=status.HTTP_401_UNAUTHORIZED, detail="Forbidden"
87+
)
88+
89+
90+
@router.post(
91+
"/bugzilla_webhook",
92+
dependencies=[Depends(api_key_auth)],
93+
)
7694
def bugzilla_webhook(
7795
request: Request,
7896
actions: ActionsDep,

tests/conftest.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,13 @@ def anon_client():
7878
return TestClient(app)
7979

8080

81+
@pytest.fixture
82+
def authenticated_client():
83+
"""An test client with a valid API key."""
84+
# api key for tests defined in .env.example
85+
return TestClient(app, headers={"X-Api-Key": "fake_api_key"})
86+
87+
8188
@pytest.fixture
8289
def settings():
8390
"""A test Settings object"""

tests/unit/test_app.py

Lines changed: 19 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,8 @@
22
from unittest.mock import patch
33

44
import pytest
5-
from fastapi.testclient import TestClient
65

7-
from jbi.app import app, traces_sampler
6+
from jbi.app import traces_sampler
87
from jbi.environment import get_settings
98

109

@@ -36,12 +35,15 @@ def test_request_summary_defaults_user_agent_to_empty_string(caplog, anon_client
3635
assert summary.agent == ""
3736

3837

39-
def test_422_errors_are_logged(webhook_request_factory, caplog):
38+
def test_422_errors_are_logged(authenticated_client, webhook_request_factory, caplog):
4039
webhook = webhook_request_factory.build(bug=None)
4140

42-
with TestClient(app) as anon_client:
43-
with caplog.at_level(logging.INFO):
44-
anon_client.post("/bugzilla_webhook", data=webhook.model_dump_json())
41+
with caplog.at_level(logging.INFO):
42+
authenticated_client.post(
43+
"/bugzilla_webhook",
44+
headers={"X-Api-Key": "fake_api_key"},
45+
data=webhook.model_dump_json(),
46+
)
4547

4648
logged = [r for r in caplog.records if r.name == "jbi.app"][0]
4749
assert logged.errors[0]["loc"] == ("body", "bug")
@@ -82,7 +84,9 @@ def test_errors_are_reported_to_sentry(anon_client, bugzilla_webhook_request):
8284
with patch("jbi.router.execute_action", side_effect=ValueError):
8385
with pytest.raises(ValueError):
8486
anon_client.post(
85-
"/bugzilla_webhook", data=bugzilla_webhook_request.model_dump_json()
87+
"/bugzilla_webhook",
88+
headers={"X-Api-Key": "fake_api_key"},
89+
data=bugzilla_webhook_request.model_dump_json(),
8690
)
8791

8892
assert mocked.called, "Sentry captured the exception"
@@ -91,6 +95,7 @@ def test_errors_are_reported_to_sentry(anon_client, bugzilla_webhook_request):
9195
def test_request_id_is_passed_down_to_logger_contexts(
9296
caplog,
9397
bugzilla_webhook_request,
98+
authenticated_client,
9499
mocked_jira,
95100
mocked_bugzilla,
96101
):
@@ -99,14 +104,13 @@ def test_request_id_is_passed_down_to_logger_contexts(
99104
"key": "JBI-1922",
100105
}
101106
with caplog.at_level(logging.DEBUG):
102-
with TestClient(app) as anon_client:
103-
anon_client.post(
104-
"/bugzilla_webhook",
105-
data=bugzilla_webhook_request.model_dump_json(),
106-
headers={
107-
"X-Request-Id": "foo-bar",
108-
},
109-
)
107+
authenticated_client.post(
108+
"/bugzilla_webhook",
109+
data=bugzilla_webhook_request.model_dump_json(),
110+
headers={
111+
"X-Request-Id": "foo-bar",
112+
},
113+
)
110114

111115
runner_logs = [r for r in caplog.records if r.name == "jbi.runner"]
112116
assert runner_logs[0].rid == "foo-bar"

tests/unit/test_router.py

Lines changed: 46 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,7 @@
44
from unittest import mock
55

66
import pytest
7-
from fastapi.testclient import TestClient
87

9-
from jbi.app import app
108
from jbi.environment import get_settings
119

1210

@@ -80,9 +78,7 @@ def test_statics_are_served(anon_client):
8078

8179

8280
def test_webhook_is_200_if_action_succeeds(
83-
bugzilla_webhook_request,
84-
mocked_jira,
85-
mocked_bugzilla,
81+
bugzilla_webhook_request, mocked_jira, mocked_bugzilla, authenticated_client
8682
):
8783
mocked_bugzilla.get_bug.return_value = bugzilla_webhook_request.bug
8884
mocked_bugzilla.update_bug.return_value = {
@@ -106,38 +102,62 @@ def test_webhook_is_200_if_action_succeeds(
106102
"self": f"{get_settings().jira_base_url}rest/api/2/issue/JBI-1922/remotelink/18936",
107103
}
108104

109-
with TestClient(app) as anon_client:
110-
response = anon_client.post(
111-
"/bugzilla_webhook", data=bugzilla_webhook_request.model_dump_json()
112-
)
113-
assert response
114-
assert response.status_code == 200
105+
response = authenticated_client.post(
106+
"/bugzilla_webhook",
107+
data=bugzilla_webhook_request.model_dump_json(),
108+
)
109+
assert response
110+
assert response.status_code == 200
115111

116112

117113
def test_webhook_is_200_if_action_raises_IgnoreInvalidRequestError(
118-
webhook_request_factory,
119-
mocked_bugzilla,
114+
webhook_request_factory, mocked_bugzilla, authenticated_client
120115
):
121116
webhook = webhook_request_factory(bug__whiteboard="unmatched")
122117
mocked_bugzilla.get_bug.return_value = webhook.bug
123118

124-
with TestClient(app) as anon_client:
125-
response = anon_client.post("/bugzilla_webhook", data=webhook.model_dump_json())
126-
assert response
127-
assert response.status_code == 200
128-
assert (
129-
response.json()["error"]
130-
== "no bug whiteboard matching action tags: devtest"
131-
)
119+
response = authenticated_client.post(
120+
"/bugzilla_webhook",
121+
data=webhook.model_dump_json(),
122+
)
123+
assert response
124+
assert response.status_code == 200
125+
assert response.json()["error"] == "no bug whiteboard matching action tags: devtest"
126+
127+
128+
def test_webhook_is_401_if_unathenticated(
129+
webhook_request_factory, mocked_bugzilla, anon_client
130+
):
131+
response = anon_client.post(
132+
"/bugzilla_webhook",
133+
data={},
134+
)
135+
assert response.status_code == 403
136+
137+
138+
def test_webhook_is_401_if_wrong_key(
139+
webhook_request_factory, mocked_bugzilla, anon_client
140+
):
141+
response = anon_client.post(
142+
"/bugzilla_webhook",
143+
headers={"X-Api-Key": "not the right key"},
144+
data={},
145+
)
146+
assert response.status_code == 401
132147

133148

134-
def test_webhook_is_422_if_bug_information_missing(webhook_request_factory):
149+
def test_webhook_is_422_if_bug_information_missing(
150+
webhook_request_factory, authenticated_client
151+
):
135152
webhook = webhook_request_factory.build(bug=None)
136153

137-
with TestClient(app) as anon_client:
138-
response = anon_client.post("/bugzilla_webhook", data=webhook.model_dump_json())
139-
assert response.status_code == 422
140-
assert response.json()["detail"][0]["loc"] == ["body", "bug"]
154+
response = authenticated_client.post(
155+
"/bugzilla_webhook",
156+
headers={"X-Api-Key": "fake_api_key"},
157+
data=webhook.model_dump_json(),
158+
)
159+
assert response.status_code == 422
160+
assert response.json()["detail"][0]["loc"] == ["body", "bug"]
141161

142162

143163
def test_read_version(anon_client):

0 commit comments

Comments
 (0)