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)