Skip to content

Commit 9be4ce4

Browse files
committed
ref(typing): Type request.user with service accounts outside an allowlist
request.user on a DRF Request can be a ServiceAccountPrincipal, but mypy didn't know, so nothing forced callers to narrow. The mypy plugin now types it as User | AnonymousUser | ServiceAccountPrincipal, except in the 23 files on tools/mypy_helpers/request_user_allowlist.txt, which keep today's type. ServiceAccountPrincipal.id is hidden from mypy but still raises UserRequired at runtime. A test fails if the allowlist grows or lists a deleted file.
1 parent 70e65f0 commit 9be4ce4

7 files changed

Lines changed: 127 additions & 8 deletions

File tree

‎src/AGENTS.md‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,9 @@ The `ViewerContext` contextvar carries the current viewer's identity. When the v
8080
- `requires_user = True` on an endpoint: the same check for the whole endpoint.
8181
- `set_request_principal(request, user, auth)`: the only place `request.user` and `request.auth` are set.
8282

83+
mypy types DRF `request.user` as `User | AnonymousUser | ServiceAccountPrincipal`, except in files on `tools/mypy_helpers/request_user_allowlist.txt`.
84+
Once a listed file narrows `request.user`, remove it from the list; never add to it.
85+
8386
A service account is a `ServiceAccountPrincipal`: no user; its access is `request.access`. Personal state (bookmarks, "is this mine?") needs a user; "who did this" fields record None. Never look rows up by a possibly-None user id: `filter(user_id=None)` matches rows with no user (S032).
8487

8588
Query "mine" and "not mine" with `owned_by_acting_user`, never `user_id=request.user.id`:

‎src/sentry/service_accounts/principal.py‎

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
from __future__ import annotations
22

33
import dataclasses
4-
from typing import NoReturn
4+
from typing import TYPE_CHECKING, NoReturn
55

66
from sentry.api.exceptions import UserRequired
77

@@ -52,9 +52,12 @@ class ServiceAccountPrincipal:
5252
is_sentry_app = False
5353
is_suspended = False
5454

55-
@property
56-
def id(self) -> ServiceAccountId:
57-
return ServiceAccountId(self.service_account_id)
55+
# Hidden from mypy so request.user.id needs narrowing; kept at runtime as a guard.
56+
if not TYPE_CHECKING:
57+
58+
@property
59+
def id(self) -> ServiceAccountId:
60+
return ServiceAccountId(self.service_account_id)
5861

5962
def get_display_name(self) -> str:
6063
return self.name

‎tests/sentry/auth/test_principal.py‎

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -234,7 +234,8 @@ def authenticate(self, request: Request) -> tuple[User, None] | None:
234234
return Request(RequestFactory().get("/"), authenticators=[Authentication()])
235235

236236

237-
SRC = Path(__file__).parents[3] / "src" / "sentry"
237+
ROOT = Path(__file__).parents[3]
238+
SRC = ROOT / "src" / "sentry"
238239

239240

240241
def _is_request_user(node: ast.AST) -> bool:
@@ -276,6 +277,7 @@ def _is_request_user_type_check(node: ast.AST) -> bool:
276277
# Lower these as callers move off. Exact, so the baseline can't drift back up.
277278
REQUEST_USER_ID_BASELINE = 135
278279
REQUEST_USER_TYPE_CHECK_BASELINE = 31
280+
REQUEST_USER_ALLOWLIST_BASELINE = 23
279281

280282

281283
def test_request_user_id_uses_only_go_down() -> None:
@@ -301,3 +303,19 @@ def test_request_user_type_checks_only_go_down() -> None:
301303
f"request.user type checks dropped to {count}; lower "
302304
"REQUEST_USER_TYPE_CHECK_BASELINE to match."
303305
)
306+
307+
308+
def test_request_user_mypy_allowlist_only_goes_down() -> None:
309+
allowlist = "tools/mypy_helpers/request_user_allowlist.txt"
310+
paths = (ROOT / allowlist).read_text().split()
311+
missing = [path for path in paths if not (ROOT / path).is_file()]
312+
assert not missing, f"Remove deleted files from {allowlist}: {missing}"
313+
count = len(paths)
314+
assert count <= REQUEST_USER_ALLOWLIST_BASELINE, (
315+
f"{count - REQUEST_USER_ALLOWLIST_BASELINE} new file(s) in {allowlist}, where mypy "
316+
"doesn't know request.user can be a service account. Don't add to it: narrow with "
317+
"has_user(request.user), acting_user_id(request.user) or require_user(request)."
318+
)
319+
assert count == REQUEST_USER_ALLOWLIST_BASELINE, (
320+
f"{allowlist} dropped to {count} files; lower REQUEST_USER_ALLOWLIST_BASELINE to match."
321+
)

‎tests/sentry/service_accounts/test_principal.py‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -83,11 +83,11 @@ def test_orm_lookup_by_id_raises(self) -> None:
8383
service_account_id=self.user.id, organization_id=self.organization.id, name="a"
8484
)
8585
with pytest.raises(UserRequired):
86-
OrganizationMember.objects.filter(user_id=principal.id).exists() # type: ignore[misc]
86+
OrganizationMember.objects.filter(user_id=principal.id).exists() # type: ignore[attr-defined]
8787

8888
def test_rpc_lookup_by_id_raises(self) -> None:
8989
principal = ServiceAccountPrincipal(
9090
service_account_id=self.user.id, organization_id=self.organization.id, name="a"
9191
)
9292
with pytest.raises(UserRequired):
93-
user_service.get_user(user_id=principal.id) # type: ignore[arg-type]
93+
user_service.get_user(user_id=principal.id) # type: ignore[attr-defined]

‎tests/tools/mypy_helpers/test_plugin.py‎

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,14 @@ def call_mypy(src: str, *, plugins: list[str] | None = None) -> tuple[int, str]:
4141
with open(os.path.join(auth_dir, "model.pyi"), "w") as f:
4242
f.write("class AuthenticatedToken: ...")
4343

44+
# stubs for request.user types
45+
users_dir = _fill_init_pyi(tmpdir, "sentry/users/models")
46+
with open(os.path.join(users_dir, "user.pyi"), "w") as f:
47+
f.write("class User: ...")
48+
sa_dir = _fill_init_pyi(tmpdir, "sentry/service_accounts")
49+
with open(os.path.join(sa_dir, "principal.pyi"), "w") as f:
50+
f.write("class ServiceAccountPrincipal: ...")
51+
4452
# stub for rest_framework.response.Response — make it Generic[T] with a
4553
# body-less overload, mirroring fixtures/stubs-for-mypy/rest_framework/
4654
# response.pyi. The Response-body-Any plugin hook needs this shape to
@@ -262,6 +270,21 @@ def test_adjusted_drf_request_auth() -> None:
262270
assert out == expected_plugins
263271

264272

273+
def test_drf_request_user_includes_service_accounts() -> None:
274+
src = """\
275+
from rest_framework.request import Request
276+
x: Request
277+
reveal_type(x.user)
278+
"""
279+
expected = """\
280+
<string>:3: note: Revealed type is "sentry.users.models.user.User | django.contrib.auth.models.AnonymousUser | sentry.service_accounts.principal.ServiceAccountPrincipal"
281+
Success: no issues found in 1 source file
282+
"""
283+
ret, out = call_mypy(src)
284+
assert ret == 0
285+
assert out == expected
286+
287+
265288
def test_csp_response_attribute() -> None:
266289
# technically undocumented -- django-csp's decorators usually do this
267290
src = """\

‎tools/mypy_helpers/plugin.py‎

Lines changed: 50 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,13 @@
11
from __future__ import annotations
22

33
import functools
4+
import os.path
45
from collections.abc import Callable
56
from typing import Any
67

78
from mypy.build import PRI_MYPY
89
from mypy.errorcodes import ATTR_DEFINED
10+
from mypy.lookup import lookup_fully_qualified
911
from mypy.messages import format_type
1012
from mypy.nodes import ARG_POS, DictExpr, ListExpr, MypyFile, StrExpr, TypeInfo
1113
from mypy.plugin import (
@@ -16,6 +18,7 @@
1618
MethodContext,
1719
MethodSigContext,
1820
Plugin,
21+
ReportConfigContext,
1922
SemanticAnalyzerPluginInterface,
2023
)
2124
from mypy.plugins.common import add_attribute_to_class, add_method_to_class
@@ -160,6 +163,40 @@ def _adjust_request_members(ctx: ClassDefContext) -> None:
160163
add_attribute_to_class(ctx.api, ctx.cls, "auth", _request_auth_tp(ctx.api))
161164

162165

166+
_REQUEST_USER_FULLNAME = "rest_framework.request.Request.user"
167+
_REQUEST_USER_TPS = (
168+
"sentry.users.models.user.User",
169+
"django.contrib.auth.models.AnonymousUser",
170+
"sentry.service_accounts.principal.ServiceAccountPrincipal",
171+
)
172+
_REPO_ROOT = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
173+
_REQUEST_USER_ALLOWLIST = os.path.join(os.path.dirname(__file__), "request_user_allowlist.txt")
174+
175+
176+
@functools.cache
177+
def _request_user_allowlist() -> frozenset[str]:
178+
with open(_REQUEST_USER_ALLOWLIST) as f:
179+
return frozenset(line.strip() for line in f if line.strip())
180+
181+
182+
def _is_request_user_allowlisted(path: str) -> bool:
183+
return os.path.relpath(os.path.abspath(path), _REPO_ROOT) in _request_user_allowlist()
184+
185+
186+
def _request_user_attribute(ctx: AttributeContext) -> Type:
187+
# sentry.auth.principal.set_request_principal can set a ServiceAccountPrincipal
188+
if _is_request_user_allowlisted(ctx.api.path):
189+
return ctx.default_attr_type
190+
# named_generic_type walks parent packages, which don't always list these submodules
191+
modules = ctx.api.modules # type: ignore[attr-defined]
192+
items: list[Type] = []
193+
for tp in _REQUEST_USER_TPS:
194+
st = lookup_fully_qualified(tp, modules, raise_on_missing=True)
195+
assert st is not None and isinstance(st.node, TypeInfo), tp
196+
items.append(Instance(st.node, ()))
197+
return UnionType(items)
198+
199+
163200
def _add_principal_members_to_anonymous_user(ctx: ClassDefContext) -> None:
164201
if ctx.cls.name == "AnonymousUser":
165202
# added by sentry.auth.principal.patch_request_principal
@@ -550,12 +587,24 @@ def get_attribute_hook(self, fullname: str) -> Callable[[AttributeContext], Type
550587
if fullname.startswith("sentry.utils.lazy_service_wrapper.LazyServiceWrapper."):
551588
_, attr = fullname.rsplit(".", 1)
552589
return functools.partial(_lazy_service_wrapper_attribute, attr=attr)
590+
elif fullname == _REQUEST_USER_FULLNAME:
591+
return _request_user_attribute
553592
else:
554593
return None
555594

595+
def report_config_data(self, ctx: ReportConfigContext) -> bool:
596+
# recheck a cached module when it joins or leaves the request.user allowlist
597+
return _is_request_user_allowlisted(ctx.path)
598+
556599
def get_additional_deps(self, file: MypyFile) -> list[tuple[int, str, int]]:
557-
if file.fullname in {"django.http", "django.http.request", "rest_framework.request"}:
600+
if file.fullname in {"django.http", "django.http.request"}:
558601
return [(PRI_MYPY, "sentry.auth.services.auth.model", -1)]
602+
elif file.fullname == "rest_framework.request":
603+
return [
604+
(PRI_MYPY, "sentry.auth.services.auth.model", -1),
605+
(PRI_MYPY, "sentry.users.models.user", -1),
606+
(PRI_MYPY, "sentry.service_accounts.principal", -1),
607+
]
559608
else:
560609
return []
561610

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
src/sentry/api/bases/organizationmember.py
2+
src/sentry/api/endpoints/organization_auth_tokens.py
3+
src/sentry/api/endpoints/organization_events.py
4+
src/sentry/api/endpoints/organization_events_stats.py
5+
src/sentry/api/endpoints/organization_releases.py
6+
src/sentry/api/endpoints/organization_trace_item_attributes.py
7+
src/sentry/api/endpoints/prompts_activity.py
8+
src/sentry/api/helpers/group_index/index.py
9+
src/sentry/core/endpoints/organization_member_details.py
10+
src/sentry/core/endpoints/organization_member_index.py
11+
src/sentry/core/endpoints/organization_member_requests_invite_index.py
12+
src/sentry/core/endpoints/organization_member_utils.py
13+
src/sentry/dashboards/endpoints/organization_dashboards.py
14+
src/sentry/data_export/endpoints/data_export.py
15+
src/sentry/issues/endpoints/group_event_details.py
16+
src/sentry/issues/endpoints/group_events.py
17+
src/sentry/issues/endpoints/group_integration_details.py
18+
src/sentry/issues/endpoints/organization_issues_count.py
19+
src/sentry/notebooks/endpoints/base.py
20+
src/sentry/seer/endpoints/organization_agent_token.py
21+
src/sentry/users/api/endpoints/user_authenticator_details.py
22+
src/sentry/web/client_config.py
23+
src/sentry/workflow_engine/endpoints/organization_detector_index.py

0 commit comments

Comments
 (0)