diff --git a/dev/environment b/dev/environment index 81be81df0db3..1d7695c25208 100644 --- a/dev/environment +++ b/dev/environment @@ -78,3 +78,8 @@ HCAPTCHA_SECRET_KEY=0x0000000000000000000000000000000000000000 # Example of Captcha backend configuration # CAPTCHA_BACKEND=warehouse.captcha.hcaptcha.Service + +# Example of HelpScout configuration +# HELPSCOUT_WAREHOUSE_APP_ID="an insecure helpscout app id" +# HELPSCOUT_WAREHOUSE_APP_SECRET="an insecure helpscout app secret" +# HELPSCOUT_WAREHOUSE_MAILBOX_ID=123456789 diff --git a/tests/unit/accounts/test_models.py b/tests/unit/accounts/test_models.py index 02889c6eede2..6938a7d5ebc0 100644 --- a/tests/unit/accounts/test_models.py +++ b/tests/unit/accounts/test_models.py @@ -194,7 +194,12 @@ def test_acl(self, db_session): True, False, False, - ["group:admins", "group:moderators", "group:psf_staff"], + [ + "group:admins", + "group:moderators", + "group:observers", + "group:psf_staff", + ], ), ( False, @@ -206,7 +211,12 @@ def test_acl(self, db_session): True, True, False, - ["group:admins", "group:moderators", "group:psf_staff"], + [ + "group:admins", + "group:moderators", + "group:observers", + "group:psf_staff", + ], ), ( False, diff --git a/tests/unit/accounts/test_security_policy.py b/tests/unit/accounts/test_security_policy.py index 0eefbc19ef4d..d460136a0751 100644 --- a/tests/unit/accounts/test_security_policy.py +++ b/tests/unit/accounts/test_security_policy.py @@ -215,7 +215,14 @@ def test_identity_missing_route(self, monkeypatch): assert add_vary_cb.calls == [pretend.call("Cookie")] assert request.add_response_callback.calls == [pretend.call(vary_cb)] - def test_identity_invalid_route(self, monkeypatch): + @pytest.mark.parametrize( + "route_name", + [ + "forklift.legacy.file_upload", + "api.echo", + ], + ) + def test_identity_invalid_route(self, route_name, monkeypatch): session_helper_obj = pretend.stub() session_helper_cls = pretend.call_recorder(lambda: session_helper_obj) monkeypatch.setattr( @@ -230,7 +237,7 @@ def test_identity_invalid_route(self, monkeypatch): request = pretend.stub( add_response_callback=pretend.call_recorder(lambda cb: None), - matched_route=pretend.stub(name="forklift.legacy.file_upload"), + matched_route=pretend.stub(name=route_name), banned=pretend.stub(by_ip=lambda ip_address: False), remote_addr="1.2.3.4", ) diff --git a/tests/unit/api/test_echo.py b/tests/unit/api/test_echo.py new file mode 100644 index 000000000000..6d95a16a0834 --- /dev/null +++ b/tests/unit/api/test_echo.py @@ -0,0 +1,111 @@ +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import pytest + +from pyramid.httpexceptions import HTTPAccepted, HTTPBadRequest + +from tests.common.db.packaging import ProjectFactory +from warehouse.api.echo import api_echo, api_projects_observations + + +class TestAPI: + def test_echo(self, pyramid_request, pyramid_user): + assert api_echo(pyramid_request) == {"username": pyramid_user.username} + + +class TestAPIProjectObservations: + def test_missing_fields(self, pyramid_request): + project = ProjectFactory.create() + pyramid_request.json_body = {} + + with pytest.raises(HTTPBadRequest) as exc: + api_projects_observations(project, pyramid_request) + + assert exc.value.json == { + "error": "missing required fields", + "missing": ["kind", "summary"], + } + + def test_invalid_kind(self, pyramid_request): + project = ProjectFactory.create() + pyramid_request.json_body = {"kind": "invalid", "summary": "test"} + + with pytest.raises(HTTPBadRequest) as exc: + api_projects_observations(project, pyramid_request) + + assert exc.value.json == { + "error": "invalid kind", + "kind": "invalid", + "project": project.name, + } + + def test_malware_missing_inspector_url(self, pyramid_request): + project = ProjectFactory.create() + pyramid_request.json_body = {"kind": "is_malware", "summary": "test"} + + with pytest.raises(HTTPBadRequest) as exc: + api_projects_observations(project, pyramid_request) + + assert exc.value.json == { + "error": "missing required fields", + "missing": ["inspector_url"], + "project": project.name, + } + + def test_malware_invalid_inspector_url(self, pyramid_request): + project = ProjectFactory.create() + pyramid_request.json_body = { + "kind": "is_malware", + "summary": "test", + "inspector_url": "invalid", + } + + with pytest.raises(HTTPBadRequest) as exc: + api_projects_observations(project, pyramid_request) + + assert exc.value.json == { + "error": "invalid inspector_url", + "inspector_url": "invalid", + "project": project.name, + } + + def test_valid_malware_observation(self, db_request, pyramid_user): + project = ProjectFactory.create() + db_request.json_body = { + "kind": "is_malware", + "summary": "test", + "inspector_url": "https://inspector.pypi.io/...", + } + + response = api_projects_observations(project, db_request) + + assert isinstance(response, HTTPAccepted) + assert response.json_body == { + "project": project.name, + "thanks": "for the observation", + } + + def test_valid_spam_observation(self, db_request, pyramid_user): + project = ProjectFactory.create() + db_request.json_body = { + "kind": "is_spam", + "summary": "test", + } + + response = api_projects_observations(project, db_request) + + assert isinstance(response, HTTPAccepted) + assert response.json_body == { + "project": project.name, + "thanks": "for the observation", + } diff --git a/tests/unit/observations/test_tasks.py b/tests/unit/observations/test_tasks.py new file mode 100644 index 000000000000..55325cdd65db --- /dev/null +++ b/tests/unit/observations/test_tasks.py @@ -0,0 +1,79 @@ +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import pretend +import pytest +import requests +import responses + +from warehouse.observations.models import ObservationKind +from warehouse.observations.tasks import ( + execute_observation_report, + report_observation_to_helpscout, +) + +from ...common.db.accounts import UserFactory +from ...common.db.packaging import ProjectFactory + + +def test_execute_observation_report(app_config): + _delay = pretend.call_recorder(lambda x: None) + app_config.task = lambda x: pretend.stub(delay=_delay) + observation = pretend.stub(id=pretend.stub()) + session = pretend.stub(info={"warehouse.observations.new": {observation}}) + + execute_observation_report(app_config, session) + + assert _delay.calls == [pretend.call(observation.id)] + + +@responses.activate +@pytest.mark.parametrize( + "kind", [ObservationKind.IsMalware, ObservationKind.SomethingElse] +) +@pytest.mark.parametrize("payload", [{}, {"foo": "bar"}]) +def test_report_observation_to_helpscout(kind, payload, db_request, monkeypatch): + db_request.registry.settings = {"helpscout.app_secret": "fake-sekret"} + # Mock out the authentication to HelpScout + monkeypatch.setattr( + "warehouse.observations.tasks._authenticate_helpscout", + lambda x: "SOME_TOKEN", + ) + + # Create an Observation + user = UserFactory.create() + db_request.user = user + project = ProjectFactory.create() + observation = project.record_observation( + request=db_request, + kind=kind, + summary="Project Observation", + payload=payload, + actor=user, + ) + # Need to flush the session to ensure the Observation has an ID + db_request.db.flush() + + db_request.http = requests.Session() + + responses.add( + responses.POST, + "https://api.helpscout.net/v2/conversations", + json={"id": 123}, + ) + + report_observation_to_helpscout(None, db_request, observation.id) + + assert len(responses.calls) == 1 + assert ( + responses.calls[0].request.url == "https://api.helpscout.net/v2/conversations" + ) diff --git a/tests/unit/packaging/test_models.py b/tests/unit/packaging/test_models.py index b1cabd52c556..351f28b3bcf1 100644 --- a/tests/unit/packaging/test_models.py +++ b/tests/unit/packaging/test_models.py @@ -188,6 +188,11 @@ def test_acl(self, db_session): Permissions.AdminRoleDelete, ), ), + ( + Allow, + "group:observers", + Permissions.APIObservationsAdd, + ), ] + sorted( [(Allow, f"oidc:{publisher.id}", ["upload"])], key=lambda x: x[1] ) + sorted( @@ -472,6 +477,11 @@ def test_acl(self, db_session): Permissions.AdminRoleDelete, ), ), + ( + Allow, + "group:observers", + Permissions.APIObservationsAdd, + ), ] + sorted( [ (Allow, f"user:{owner1.user.id}", ["manage:project", "upload"]), diff --git a/tests/unit/test_config.py b/tests/unit/test_config.py index 0b616ab9ef09..b46788315570 100644 --- a/tests/unit/test_config.py +++ b/tests/unit/test_config.py @@ -520,5 +520,13 @@ def test_root_factory_access_control_list(): Permissions.AdminSponsorsWrite, ), ), + ( + Allow, + "group:observers", + ( + Permissions.APIEcho, + Permissions.APIObservationsAdd, + ), + ), (Allow, Authenticated, "manage:user"), ] diff --git a/tests/unit/test_routes.py b/tests/unit/test_routes.py index 2ddea61abfd2..a059cd080d74 100644 --- a/tests/unit/test_routes.py +++ b/tests/unit/test_routes.py @@ -533,6 +533,19 @@ def add_policy(name, filename): traverse="/{name}/", domain=warehouse, ), + # API URLs + pretend.call( + "api.echo", + "/danger-api/echo", + domain=warehouse, + ), + pretend.call( + "api.projects.observations", + "/danger-api/projects/{name}/observations", + factory="warehouse.packaging.models:ProjectFactory", + traverse="/{name}", + domain=warehouse, + ), # Mock URLs pretend.call( "mock.billing.checkout-session", diff --git a/tests/unit/test_views.py b/tests/unit/test_views.py index b7b3a86dedab..93138e09e789 100644 --- a/tests/unit/test_views.py +++ b/tests/unit/test_views.py @@ -35,6 +35,7 @@ current_user_indicator, flash_messages, forbidden, + forbidden_api, forbidden_include, force_status, health, @@ -318,6 +319,18 @@ def test_forbidden_include(self): assert resp.content_length == 0 +class TestForbiddenAPIView: + def test_forbidden_api(self): + exc = pretend.stub() + request = pretend.stub() + + resp = forbidden_api(exc, request) + + assert resp.status_code == 403 + assert resp.content_type == "application/json" + assert resp.json_body == {"message": "Access was denied to this resource."} + + class TestServiceUnavailableView: def test_renders_503(self, pyramid_config, pyramid_request): renderer = pyramid_config.testing_add_renderer("503.html") diff --git a/warehouse/accounts/models.py b/warehouse/accounts/models.py index ffec187b5ee2..b9dcf2ded4ed 100644 --- a/warehouse/accounts/models.py +++ b/warehouse/accounts/models.py @@ -93,6 +93,9 @@ class User(SitemapMixin, HasObserversMixin, HasEvents, db.Model): is_superuser: Mapped[bool_false] is_moderator: Mapped[bool_false] is_psf_staff: Mapped[bool_false] + is_observer: Mapped[bool_false] = mapped_column( + comment="Is this user allowed to add Observations?" + ) prohibit_password_reset: Mapped[bool_false] hide_avatar: Mapped[bool_false] date_joined: Mapped[datetime_now | None] @@ -250,6 +253,8 @@ def __principals__(self) -> list[str]: principals.append("group:moderators") if self.is_psf_staff or self.is_superuser: principals.append("group:psf_staff") + if self.is_observer or self.is_superuser: + principals.append("group:observers") return principals diff --git a/warehouse/accounts/security_policy.py b/warehouse/accounts/security_policy.py index 10d9ab6f7d9a..ab5e15932638 100644 --- a/warehouse/accounts/security_policy.py +++ b/warehouse/accounts/security_policy.py @@ -56,6 +56,17 @@ def identity(self, request): if request.matched_route.name == "forklift.legacy.file_upload": return None + # TODO: This feels wrong - special casing for paths and + # prefixes isn't sustainable. + # May need to revisit https://github.com/pypi/warehouse/pull/13854 + # Without this guard, we raise a RuntimeError related to `uses_session`, + # because the `SessionAuthenticationHelper()` is called with no session. + # Alternately, we could wrap the call to `authenticated_userid` in a + # try/except RuntimeError block, but that feels like a band-aid. + # Session authentication cannot be used for /api routes + if request.matched_route.name.startswith("api."): + return None + userid = self._session_helper.authenticated_userid(request) request._unauthenticated_userid = userid diff --git a/warehouse/admin/templates/admin/users/detail.html b/warehouse/admin/templates/admin/users/detail.html index ff78af50c6a6..6a891976dd52 100644 --- a/warehouse/admin/templates/admin/users/detail.html +++ b/warehouse/admin/templates/admin/users/detail.html @@ -264,6 +264,7 @@

Permissions

{{ render_checkbox("Superuser", form.is_superuser, "is-superuser")}} {{ render_checkbox("Moderator", form.is_moderator, "is-moderator")}} {{ render_checkbox("PSF Staff", form.is_psf_staff, "is-psf-staff")}} + {{ render_checkbox("Observer", form.is_observer, "is-observer")}}
diff --git a/warehouse/admin/views/users.py b/warehouse/admin/views/users.py index ab59dab0e1b4..9f3c95622ac6 100644 --- a/warehouse/admin/views/users.py +++ b/warehouse/admin/views/users.py @@ -91,6 +91,7 @@ class UserForm(forms.Form): is_superuser = wtforms.fields.BooleanField() is_moderator = wtforms.fields.BooleanField() is_psf_staff = wtforms.fields.BooleanField() + is_observer = wtforms.fields.BooleanField() prohibit_password_reset = wtforms.fields.BooleanField() hide_avatar = wtforms.fields.BooleanField() diff --git a/warehouse/api/README.md b/warehouse/api/README.md new file mode 100644 index 000000000000..6e0adccdbff8 --- /dev/null +++ b/warehouse/api/README.md @@ -0,0 +1,13 @@ +# api + +This section of the codebase is responsible for the user-facing API views +and associated logic. + +Structurally, there are some other modules here that are API-interactions, +but do not live under the `/api/*` route namespace. +They may be refactored to another location at some future point. + +We have API endpoints that pre-date the `/api/*` namespace, +see https://warehouse.pypa.io/api-reference/index.html for more. + +All APIs under the `/api/*` namespace are JSON-only. diff --git a/warehouse/api/echo.py b/warehouse/api/echo.py new file mode 100644 index 000000000000..bcd9c3a22f9f --- /dev/null +++ b/warehouse/api/echo.py @@ -0,0 +1,144 @@ +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +from __future__ import annotations + +import typing + +from pyramid.httpexceptions import HTTPAccepted, HTTPBadRequest +from pyramid.view import view_config + +from warehouse.authnz import Permissions +from warehouse.observations.models import OBSERVATION_KIND_MAP, ObservationKind + +if typing.TYPE_CHECKING: + from pyramid.request import Request + + from warehouse.packaging.models import Project + + +# ### DANGER ZONE ### # +# These views are a v0, danger, all-bets-are-off version of the API. +# We may change the API at any time, and we may remove it entirely +# without notice, but we'll try to tell the folks we know are testing +# it before we do. + + +# TODO: Move this to a more general-purpose API view helper module +def api_v0_view_config(**kwargs): + """ + A helper decorator that is used to create a view configuration that is + useful for API version 0 views. Usage: + + @api_v0_view_config( + route_name="api.projects", + permission=Permissions.API..., + ) + def ... + """ + + # Prevent developers forgetting to set a permission + if "permission" not in kwargs: # pragma: no cover (safety check) + raise TypeError("`permission` keyword is is required") + + # Set defaults for API views + kwargs.update( + accept="application/vnd.pypi.api-v0-danger+json", + renderer="json", + require_csrf=False, + # TODO: Can we apply a macaroon-based rate limiter here, + # and how might we set specific rate limits for user/project owners? + ) + + def _wrapper(wrapped): + return view_config(**kwargs)(wrapped) + + return _wrapper + + +@api_v0_view_config( + route_name="api.echo", + permission=Permissions.APIEcho, +) +def api_echo(request: Request): + return { + "username": request.user.username, + } + + +@api_v0_view_config( + route_name="api.projects.observations", + permission=Permissions.APIObservationsAdd, + require_methods=["POST"], +) +def api_projects_observations( + project: Project, request: Request +) -> HTTPAccepted | HTTPBadRequest: + data = request.json_body + + # TODO: Are there better mechanisms for validating the payload? + # Maybe adopt https://github.com/Pylons/pyramid_openapi3 - too big? + required_fields = {"kind", "summary"} + if not required_fields.issubset(data.keys()): + raise HTTPBadRequest( + json={ + "error": "missing required fields", + "missing": sorted(list(required_fields - data.keys())), + }, + ) + try: + # get the correct mapping for the `kind` field + kind = OBSERVATION_KIND_MAP[data["kind"]] + except KeyError: + raise HTTPBadRequest( + json={ + "error": "invalid kind", + "kind": data["kind"], + "project": project.name, + } + ) + + # TODO: Another case of needing more complex validation + if kind == ObservationKind.IsMalware: + if "inspector_url" not in data: + raise HTTPBadRequest( + json={ + "error": "missing required fields", + "missing": ["inspector_url"], + "project": project.name, + }, + ) + if "inspector_url" in data and not data["inspector_url"].startswith( + "https://inspector.pypi.io/" + ): + raise HTTPBadRequest( + json={ + "error": "invalid inspector_url", + "inspector_url": data["inspector_url"], + "project": project.name, + }, + ) + + project.record_observation( + request=request, + kind=kind, + actor=request.user, + summary=data["summary"], + payload=data, + ) + + return HTTPAccepted( + json={ + # TODO: What should we return to the caller? + "project": project.name, + "thanks": "for the observation", + }, + ) diff --git a/warehouse/authnz/_permissions.py b/warehouse/authnz/_permissions.py index d1264c8b946b..4f9ebc84e499 100644 --- a/warehouse/authnz/_permissions.py +++ b/warehouse/authnz/_permissions.py @@ -33,6 +33,7 @@ class Permissions(StrEnum): Keep the list alphabetized. Add spacing between logical groupings. """ + # Admin Permissions AdminBannerRead = "admin:banner:read" AdminBannerWrite = "admin:banner:write" @@ -75,3 +76,7 @@ class Permissions(StrEnum): AdminUsersRead = "admin:users:read" AdminUsersWrite = "admin:users:write" + + # API Permissions + APIEcho = "api:echo" + APIObservationsAdd = "api:observations:add" diff --git a/warehouse/config.py b/warehouse/config.py index 8a30870da03a..22912f76c1a3 100644 --- a/warehouse/config.py +++ b/warehouse/config.py @@ -127,6 +127,14 @@ class RootFactory: Permissions.AdminSponsorsWrite, ), ), + ( + Allow, + "group:observers", + ( + Permissions.APIEcho, + Permissions.APIObservationsAdd, + ), + ), (Allow, Authenticated, "manage:user"), ] @@ -349,6 +357,9 @@ def configure(settings=None): maybe_set( settings, "admin.helpscout.app_secret", "HELPSCOUT_APP_SECRET", default=None ) + maybe_set(settings, "helpscout.app_id", "HELPSCOUT_WAREHOUSE_APP_ID") + maybe_set(settings, "helpscout.app_secret", "HELPSCOUT_WAREHOUSE_APP_SECRET") + maybe_set(settings, "helpscout.mailbox_id", "HELPSCOUT_WAREHOUSE_MAILBOX_ID") # Configure our ratelimiters maybe_set( diff --git a/warehouse/locale/messages.pot b/warehouse/locale/messages.pot index e5a63e39fa32..4eb7244714d4 100644 --- a/warehouse/locale/messages.pot +++ b/warehouse/locale/messages.pot @@ -10,7 +10,7 @@ msgid "" " action." msgstr "" -#: warehouse/views.py:283 +#: warehouse/views.py:294 msgid "Locale updated" msgstr "" diff --git a/warehouse/macaroons/security_policy.py b/warehouse/macaroons/security_policy.py index 3489591dbcd4..12a120720790 100644 --- a/warehouse/macaroons/security_policy.py +++ b/warehouse/macaroons/security_policy.py @@ -17,6 +17,7 @@ from zope.interface import implementer from warehouse.accounts.interfaces import IUserService +from warehouse.authnz import Permissions from warehouse.cache.http import add_vary_callback from warehouse.errors import WarehouseDenied from warehouse.macaroons import InvalidMacaroonError @@ -154,7 +155,14 @@ def permits(self, request, context, permission): # doesn't really make a lot of sense here and it makes things more # complicated if we want to allow the use of macaroons for actions other # than uploading. - if permission not in ["upload"]: + if permission not in [ + "upload", + # TODO: Adding API-specific routes here is not sustainable. However, + # removing this guard would allow Macaroons to be used for Session-based + # operations, bypassing any 2FA requirements. + Permissions.APIEcho, + Permissions.APIObservationsAdd, + ]: return WarehouseDenied( f"API tokens are not valid for permission: {permission}!", reason="invalid_permission", diff --git a/warehouse/observations/tasks.py b/warehouse/observations/tasks.py new file mode 100644 index 000000000000..dd938c22abc5 --- /dev/null +++ b/warehouse/observations/tasks.py @@ -0,0 +1,162 @@ +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +from __future__ import annotations + +import json +import typing + +from base64 import b64encode +from textwrap import dedent + +from warehouse import db, tasks + +from .models import OBSERVATION_KIND_MAP, Observation, ObservationKind + +if typing.TYPE_CHECKING: + from uuid import UUID + + from pyramid.request import Request + from sqlalchemy.orm import Session as SA_Session + + from warehouse.config import Configurator + + +@db.listens_for(db.Session, "after_flush") +def new_observation_created(_config, session: SA_Session, _flush_context): + # Go through each new, changed, and deleted object and attempt to store + # a cache key that we'll want to purge when the session has been committed. + for obj in session.new: + if isinstance(obj, Observation): + # Add to `session.info` so we can access it in the after_commit listener. + session.info.setdefault("warehouse.observations.new", set()).add(obj) + + +@db.listens_for(db.Session, "after_commit") +def execute_observation_report(config: Configurator, session: SA_Session): + # Fetch the observations from the session. + observations = session.info.pop("warehouse.observations.new", set()) + for obj in observations: + # We pass the ID of the Observation, not the Observation itself, + # because the Observation object is not currently JSON-serializable. + config.task(report_observation_to_helpscout).delay(obj.id) + + +@tasks.task(bind=True, ignore_result=True, acks_late=True) +def report_observation_to_helpscout(task, request: Request, model_id: UUID) -> None: + """ + Report an Observation to HelpScout for further tracking. + + NOTE: Not using one of the existing `helpscout` libraries, + because they all seem to be focused on the HelpScout API v1, + which is deprecated. The v2 API is a bit more complex, but + we can use the `requests` library directly to make the calls. + If we see that there's further usage of the HelpScout API, + we can look at creating a more general-purpose library/module. + """ + # Fetch the Observation from the database + model = request.db.query(Observation).get(model_id) + + # TODO: What do we do for Release/File/User/etc? + # Maybe need a mapping of ObservationType and the name we want to use. + target_name = model.related.name + + # Add new Conversation to HelpScout for tracking purposes + convo_text = dedent( + f""" + Kind: {model.kind} + Summary: {model.summary} + Model Name: {model.__class__.__name__} + + Project URL: https://pypi.org/project/{target_name}/ + """ + ) + + if OBSERVATION_KIND_MAP[model.kind] == ObservationKind.IsMalware: + convo_text += dedent( + f""" + Inspector URL: {model.payload.get("inspector_url")} + """ + ) + + # If no secret is supplied, bypass HelpScout API and print. + if not request.registry.settings.get("helpscout.app_secret"): # pragma: no cover + output = ( + dedent( + f""" + type: email + observer: {model.observer.parent.username} + customer: {model.observer.parent.email} + subject: Observation Report for {target_name} + """ + ) + + convo_text + ) + print(output) + return + + _helpscout_bearer_token = _authenticate_helpscout(request) + _helpscout_mailbox_id = request.registry.settings.get("helpscout.mailbox_id") + + request_json = { + "type": "email", + "customer": {"email": model.observer.parent.email}, + "subject": f"Observation Report for {target_name}", + "mailboxId": _helpscout_mailbox_id, + "status": "active", + "threads": [ + { + "type": "customer", + "customer": {"email": model.observer.parent.email}, + "text": convo_text, + }, + ], + "tags": ["observation"], + } + + # if a model has a payload, add it as an attachment. + if model.payload: + request_json["threads"][0]["attachments"] = [ + { + "fileName": f"observation-{target_name}-{model.created}.json", + "mimeType": "application/json", + "data": b64encode(json.dumps(model.payload).encode("utf-8")).decode( + "utf-8" + ), + } + ] + + resp = request.http.post( + "https://api.helpscout.net/v2/conversations", + headers={"Authorization": f"Bearer {_helpscout_bearer_token}"}, + json=request_json, + timeout=10, + ) + resp.raise_for_status() + + +def _authenticate_helpscout(request: Request) -> str: # pragma: no cover (manual test) + """ + Perform the authentication dance with HelpScout to get a bearer token. + https://developer.helpscout.com/mailbox-api/overview/authentication/#client-credentials-flow + """ + helpscout_app_id = request.registry.settings.get("helpscout.app_id") + helpscout_app_secret = request.registry.settings.get("helpscout.app_secret") + + auth_token_response = request.http.post( + "https://api.helpscout.net/v2/oauth2/token", + auth=(helpscout_app_id, helpscout_app_secret), + json={"grant_type": "client_credentials"}, + timeout=10, + ) + auth_token_response.raise_for_status() + + return auth_token_response.json()["access_token"] diff --git a/warehouse/packaging/models.py b/warehouse/packaging/models.py index d0ee33857942..13ba1358dff4 100644 --- a/warehouse/packaging/models.py +++ b/warehouse/packaging/models.py @@ -288,6 +288,7 @@ def __acl__(self): Permissions.AdminRoleDelete, ), ), + (Allow, "group:observers", Permissions.APIObservationsAdd), ] # The project has zero or more OIDC publishers registered to it, diff --git a/warehouse/routes.py b/warehouse/routes.py index b7cea0c669c6..5b0e22b22405 100644 --- a/warehouse/routes.py +++ b/warehouse/routes.py @@ -539,6 +539,19 @@ def includeme(config): domain=warehouse, ) + config.add_route( + "api.echo", + "/danger-api/echo", + domain=warehouse, + ) + config.add_route( + "api.projects.observations", + "/danger-api/projects/{name}/observations", + factory="warehouse.packaging.models:ProjectFactory", + traverse="/{name}", + domain=warehouse, + ) + # Mock URLs config.add_route( "mock.billing.checkout-session", diff --git a/warehouse/views.py b/warehouse/views.py index cb11f78afddc..4c17a5f6c266 100644 --- a/warehouse/views.py +++ b/warehouse/views.py @@ -180,6 +180,17 @@ def forbidden_include(exc, request): return HTTPForbidden() +@forbidden_view_config(path_info=r"^/api/") +@exception_view_config(PredicateMismatch, path_info=r"^/api/") +def forbidden_api(exc, request): + # If the forbidden error is for an API endpoint, return a JSON response + # instead of redirecting + return HTTPForbidden( + json={"message": "Access was denied to this resource."}, + content_type="application/json", + ) + + @view_config(context=DatabaseNotAvailableError) def service_unavailable(exc, request): return httpexception_view(HTTPServiceUnavailable(), request)