Skip to content

Commit 4f37ebc

Browse files
leplatremBryan Sieber
andauthored
Add Jira permission check in heartbeat (fixes #105) (#165)
* Fetch permissions in heartbeat * Obtain projects permissions in parallel calls * Updating call to jira_client as get_permissions * Updating tests * Refactor: fetch and validation are now separate functions; JIRA_REQUIRED_PERMISSIONS changed to an env var. * Use custom Jira client to avoid waiting for release * Fix linting and mocking * Manage list of required permissions at action module level (#177) * Manage list of required permissions at action module level * Fix list of missing permissions * Use atlassian-python-api @ master instead of release + inherited class Co-authored-by: Bryan Sieber <[email protected]>
1 parent 323a169 commit 4f37ebc

File tree

8 files changed

+152
-8
lines changed

8 files changed

+152
-8
lines changed

jbi/actions/README.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,8 @@ Let's create a `new_action`!
1313
```python
1414
from jbi import ActionResult, Operation
1515

16+
JIRA_REQUIRED_PERMISSIONS = {"CREATE_ISSUES"}
17+
1618
def init(jira_project_key, optional_param=42):
1719

1820
def execute(payload) -> ActionResult:
@@ -28,6 +30,7 @@ Let's create a `new_action`!
2830
1. The returned `ActionResult` features a boolean to indicate whether something was performed or not, along with a `Dict` (used as a response to the WebHook endpoint).
2931

3032
1. Use the `payload` to perform the desired processing!
33+
1. List the required Jira permissions to be set on projects that will use this action in the `JIRA_REQUIRED_PERMISSIONS` constant. The list of built-in permissions is [available on Atlanssian API docs](https://developer.atlassian.com/cloud/jira/platform/rest/v3/api-group-permission-schemes/#built-in-permissions).
3134
1. Use the available service calls from `jbi/services.py` (or make new ones)
3235
1. Update the `README.md` to document your action
3336
1. Now the action `jbi.actions.my_team_actions` can be used in the YAML configuration, under the `module` key.

jbi/actions/default.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,13 @@
1616

1717
logger = logging.getLogger(__name__)
1818

19+
JIRA_REQUIRED_PERMISSIONS = {
20+
"ADD_COMMENTS",
21+
"CREATE_ISSUES",
22+
"DELETE_ISSUES",
23+
"EDIT_ISSUES",
24+
}
25+
1926

2027
def init(jira_project_key, **kwargs):
2128
"""Function that takes required and optional params and returns a callable object"""

jbi/actions/default_with_assignee_and_status.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,12 +10,18 @@
1010
import logging
1111

1212
from jbi import Operation
13+
from jbi.actions.default import (
14+
JIRA_REQUIRED_PERMISSIONS as DEFAULT_JIRA_REQUIRED_PERMISSIONS,
15+
)
1316
from jbi.actions.default import DefaultExecutor
1417
from jbi.bugzilla import BugzillaBug, BugzillaWebhookRequest
1518

1619
logger = logging.getLogger(__name__)
1720

1821

22+
JIRA_REQUIRED_PERMISSIONS = DEFAULT_JIRA_REQUIRED_PERMISSIONS
23+
24+
1925
def init(status_map=None, **kwargs):
2026
"""Function that takes required and optional params and returns a callable object"""
2127
return AssigneeAndStatusExecutor(status_map=status_map or {}, **kwargs)

jbi/models.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ class Action(YamlModel):
2525
allow_private: bool = False
2626
parameters: dict = {}
2727
_caller: Callable = PrivateAttr(default=None)
28+
_required_jira_permissions: Set[str] = PrivateAttr(default=None)
2829

2930
@property
3031
def caller(self) -> Callable:
@@ -35,6 +36,15 @@ def caller(self) -> Callable:
3536
self._caller = initialized
3637
return self._caller
3738

39+
@property
40+
def required_jira_permissions(self) -> Set[str]:
41+
"""Return the required Jira permissions for this action to be executed."""
42+
if not self._required_jira_permissions:
43+
action_module: ModuleType = importlib.import_module(self.module)
44+
perms = getattr(action_module, "JIRA_REQUIRED_PERMISSIONS")
45+
self._required_jira_permissions = perms
46+
return self._required_jira_permissions
47+
3848
@root_validator
3949
def validate_action_config(cls, values): # pylint: disable=no-self-argument
4050
"""Validate action: exists, has init function, and has expected params"""

jbi/services.py

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
"""Services and functions that can be used to create custom actions"""
2+
import concurrent.futures
23
import logging
34
from typing import Dict, List
45

@@ -112,6 +113,7 @@ def _jira_check_health(actions: Actions) -> ServiceHealth:
112113
health: ServiceHealth = {
113114
"up": is_up,
114115
"all_projects_are_visible": is_up and _all_jira_projects_visible(jira, actions),
116+
"all_projects_have_permissions": _all_jira_projects_permissions(jira, actions),
115117
}
116118
return health
117119

@@ -127,6 +129,68 @@ def _all_jira_projects_visible(jira, actions: Actions) -> bool:
127129
return not missing_projects
128130

129131

132+
def _all_jira_projects_permissions(jira, actions: Actions):
133+
"""Fetches and validates that required permissions exist for the configured projects"""
134+
all_projects_perms = _fetch_jira_project_permissions(actions, jira)
135+
return _validate_jira_permissions(all_projects_perms)
136+
137+
138+
def _fetch_jira_project_permissions(actions, jira):
139+
"""Fetches permissions for the configured projects"""
140+
required_perms_by_project = {
141+
action.parameters["jira_project_key"]: action.required_jira_permissions
142+
for action in actions
143+
if "jira_project_key" in action.parameters
144+
}
145+
146+
all_projects_perms = {}
147+
# Query permissions for all configured projects in parallel threads.
148+
with concurrent.futures.ThreadPoolExecutor(max_workers=4) as executor:
149+
futures_to_projects = {
150+
executor.submit(
151+
jira.get_permissions,
152+
project_key=project_key,
153+
permissions=",".join(required_permissions),
154+
): project_key
155+
for project_key, required_permissions in required_perms_by_project.items()
156+
}
157+
# Obtain futures' results unordered.
158+
for future in concurrent.futures.as_completed(futures_to_projects):
159+
project_key = futures_to_projects[future]
160+
response = future.result()
161+
all_projects_perms[project_key] = (
162+
required_perms_by_project[project_key],
163+
response["permissions"],
164+
)
165+
return all_projects_perms
166+
167+
168+
def _validate_jira_permissions(all_projects_perms):
169+
"""Validates permissions for the configured projects"""
170+
misconfigured = []
171+
for project_key, (required_perms, obtained_perms) in all_projects_perms.items():
172+
missing = required_perms - set(obtained_perms.keys())
173+
not_given = set(
174+
entry["key"]
175+
for entry in obtained_perms.values()
176+
if not entry["havePermission"]
177+
)
178+
if missing | not_given:
179+
misconfigured.append((project_key, missing | not_given))
180+
for project_key, missing in misconfigured:
181+
logger.error(
182+
"Configured credentials don't have permissions %s on Jira project %s",
183+
",".join(missing),
184+
project_key,
185+
extra={
186+
"jira": {
187+
"project": project_key,
188+
}
189+
},
190+
)
191+
return not misconfigured
192+
193+
130194
def jbi_service_health_map(actions: Actions):
131195
"""Returns dictionary of health check's for Bugzilla and Jira Services"""
132196
return {

poetry.lock

Lines changed: 12 additions & 7 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ fastapi = "^0.79.0"
1111
pydantic = {version = "^1.9.1", extras = ["dotenv", "email"]}
1212
uvicorn = {extras = ["standard"], version = "^0.18.2"}
1313
python-bugzilla = "^3.2.0"
14-
atlassian-python-api = "^3.25.0"
14+
atlassian-python-api = { git = "https://github.com/atlassian-api/atlassian-python-api.git", rev = "82293a2ac9bfa" }
1515
dockerflow = "2022.7.0"
1616
Jinja2 = "^3.1.2"
1717
pydantic-yaml = {extras = ["pyyaml","ruamel"], version = "^0.8.0"}

tests/unit/test_router.py

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -135,6 +135,7 @@ def test_read_heartbeat_all_services_fail(anon_client, mocked_jira, mocked_bugzi
135135
"jira": {
136136
"up": False,
137137
"all_projects_are_visible": False,
138+
"all_projects_have_permissions": False,
138139
},
139140
"bugzilla": {
140141
"up": False,
@@ -154,6 +155,7 @@ def test_read_heartbeat_jira_services_fails(anon_client, mocked_jira, mocked_bug
154155
"jira": {
155156
"up": False,
156157
"all_projects_are_visible": False,
158+
"all_projects_have_permissions": False,
157159
},
158160
"bugzilla": {
159161
"up": True,
@@ -176,6 +178,7 @@ def test_read_heartbeat_bugzilla_services_fails(
176178
"jira": {
177179
"up": True,
178180
"all_projects_are_visible": True,
181+
"all_projects_have_permissions": False,
179182
},
180183
"bugzilla": {
181184
"up": False,
@@ -188,6 +191,14 @@ def test_read_heartbeat_success(anon_client, mocked_jira, mocked_bugzilla):
188191
mocked_bugzilla().logged_in = True
189192
mocked_jira().get_server_info.return_value = {}
190193
mocked_jira().projects.return_value = [{"key": "DevTest"}]
194+
mocked_jira().get_permissions.return_value = {
195+
"permissions": {
196+
"ADD_COMMENTS": {"havePermission": True},
197+
"CREATE_ISSUES": {"havePermission": True},
198+
"EDIT_ISSUES": {"havePermission": True},
199+
"DELETE_ISSUES": {"havePermission": True},
200+
},
201+
}
191202

192203
resp = anon_client.get("/__heartbeat__")
193204

@@ -196,6 +207,7 @@ def test_read_heartbeat_success(anon_client, mocked_jira, mocked_bugzilla):
196207
"jira": {
197208
"up": True,
198209
"all_projects_are_visible": True,
210+
"all_projects_have_permissions": True,
199211
},
200212
"bugzilla": {
201213
"up": True,
@@ -215,6 +227,35 @@ def test_jira_heartbeat_visible_projects(anon_client, mocked_jira, mocked_bugzil
215227
"jira": {
216228
"up": True,
217229
"all_projects_are_visible": False,
230+
"all_projects_have_permissions": False,
231+
},
232+
"bugzilla": {
233+
"up": True,
234+
},
235+
}
236+
237+
238+
def test_jira_heartbeat_missing_permissions(anon_client, mocked_jira, mocked_bugzilla):
239+
"""/__heartbeat__ fails if configured projects don't match."""
240+
mocked_bugzilla().logged_in = True
241+
mocked_jira().get_server_info.return_value = {}
242+
mocked_jira().get_project_permission_scheme.return_value = {
243+
"permissions": {
244+
"ADD_COMMENTS": {"havePermission": True},
245+
"CREATE_ISSUES": {"havePermission": True},
246+
"EDIT_ISSUES": {"havePermission": False},
247+
"DELETE_ISSUES": {"havePermission": True},
248+
},
249+
}
250+
251+
resp = anon_client.get("/__heartbeat__")
252+
253+
assert resp.status_code == 503
254+
assert resp.json() == {
255+
"jira": {
256+
"up": True,
257+
"all_projects_are_visible": False,
258+
"all_projects_have_permissions": False,
218259
},
219260
"bugzilla": {
220261
"up": True,
@@ -227,6 +268,14 @@ def test_head_heartbeat(anon_client, mocked_jira, mocked_bugzilla):
227268
mocked_bugzilla().logged_in = True
228269
mocked_jira().get_server_info.return_value = {}
229270
mocked_jira().projects.return_value = [{"key": "DevTest"}]
271+
mocked_jira().get_permissions.return_value = {
272+
"permissions": {
273+
"ADD_COMMENTS": {"havePermission": True},
274+
"CREATE_ISSUES": {"havePermission": True},
275+
"EDIT_ISSUES": {"havePermission": True},
276+
"DELETE_ISSUES": {"havePermission": True},
277+
},
278+
}
230279

231280
resp = anon_client.head("/__heartbeat__")
232281

0 commit comments

Comments
 (0)