Skip to content

Commit be5bcfc

Browse files
authored
Merge pull request #1370 from Open-Source-Legal/claude/resolve-issue-1333-22Y0E
Add type annotations to auth, users, and notifications packages
2 parents 36921ac + b68c1cb commit be5bcfc

117 files changed

Lines changed: 290 additions & 136 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

CHANGELOG.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
3838
- `.github/workflows/backend.yml`: `Run mypy` step added to the `linter` job; the preceding `pip install -r requirements/local.txt` step satisfies the django-stubs plugin.
3939
- `docs/typing/README.md`: how to run locally / via pre-commit / in the test container, plus the per-file graduation workflow.
4040
- `docs/typing/mypy_baseline.txt`: frozen error list (sorted for stable diffs) so follow-up issues can measure progress.
41+
- **Mypy: typed the auth, users, and notifications packages** (Issue #1333, follow-up to #1331): Brought `config/admin_auth`, `config/graphql_api_token_auth`, `opencontractserver/users`, and `opencontractserver/notifications` to full return-annotation coverage and graduated them out of the mypy baseline. These packages sit on hot paths for authentication, token verification, session handling, and notification dispatch; typing them documents invariants that used to live only in `CLAUDE.md`.
42+
- Signal handlers (`opencontractserver/users/signals.py`, `opencontractserver/notifications/signals.py`) now declare `sender: type[Model]`, `instance: Model`, `created: bool`, `**kwargs: Any`, and `-> None` so drive-by edits can't silently change the signal contract.
43+
- `opencontractserver/notifications/__init__.py` gained a module-level docstring calling out that the app deliberately diverges from `AnnotatePermissionsForReadMixin` — a single `recipient` FK is the authoritative visibility gate. The design note in `CLAUDE.md` is referenced so changes to one file flag the other.
44+
- `mypy.ini`: dropped `[mypy-config.admin_auth.*]`, `[mypy-config.graphql_api_token_auth.backends]`, and `[mypy-opencontractserver.users.models]` `ignore_errors` sections. Added a narrow `disable_error_code = django-manager-missing` on `opencontractserver.users.models` only, documented inline: `django-tree-queries`' `as_manager(with_tree_fields=True)` pattern used by `Corpus`, `CorpusFolder`, and `DocumentPath` creates manager classes at runtime that the `mypy_django_plugin` can't introspect, so reverse `_set` accessors on `User` can't resolve — a known plugin limitation, not a code error. Graduating those three models with explicit manager typing will let us delete the disable.
45+
- `docs/typing/mypy_baseline.txt`: pruned all 20 entries for the graduated modules.
46+
- Bug fixes uncovered by typing:
47+
- `config/graphql_api_token_auth/backends.py`: `ApiKeyBackend.authenticate_header` referenced `self.keyword` but the attribute was never declared. DRF calls this on 401 responses to build the `WWW-Authenticate` header, so an unauthenticated request would have hit `AttributeError` at response time. Added `keyword: str = "Token"` as a class attribute.
48+
- `opencontractserver/notifications/signals.py`: `create_moderation_notification` dereferenced `action.moderator.username` unconditionally, but the `ModerationAction.moderator` FK permits `NULL`. A moderation action created without a moderator would crash the signal handler (and therefore the originating save). Added an explicit early return with a debug log when `action.moderator is None`.
4149

4250
### Changed
4351

config/admin_auth/backends.py

Lines changed: 32 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -7,13 +7,18 @@
77
"""
88

99
import logging
10+
from typing import TYPE_CHECKING, Any, Optional
1011

1112
from django.conf import settings
1213
from django.contrib.auth import get_user_model
1314
from django.contrib.auth.backends import ModelBackend
15+
from django.http import HttpRequest
16+
17+
if TYPE_CHECKING:
18+
from opencontractserver.users.models import User
1419

1520
logger = logging.getLogger(__name__)
16-
User = get_user_model()
21+
UserModel = get_user_model()
1722

1823

1924
class Auth0AdminBackend(ModelBackend):
@@ -27,32 +32,47 @@ class Auth0AdminBackend(ModelBackend):
2732
the frontend Auth0 login flow, then back to admin.
2833
"""
2934

30-
def authenticate(self, request, auth0_user_id=None, **kwargs):
35+
def authenticate(
36+
self,
37+
request: Optional[HttpRequest],
38+
username: Optional[str] = None,
39+
password: Optional[str] = None,
40+
**kwargs: Any,
41+
) -> Optional["User"]:
3142
"""
3243
Authenticate a user by their Auth0 user ID (sub claim).
3344
3445
This is called after the user has authenticated via Auth0 on the
35-
frontend and the token has been validated.
46+
frontend and the token has been validated. ``username`` and
47+
``password`` are declared to match :class:`ModelBackend`'s
48+
signature (Django calls every backend with those kwargs) but are
49+
ignored here — credential-based login falls through to the
50+
default backend. The Auth0-specific ``auth0_user_id`` is passed
51+
via ``**kwargs``.
3652
3753
Args:
3854
request: The HTTP request object.
39-
auth0_user_id: The Auth0 user ID (sub claim from JWT).
40-
**kwargs: Additional keyword arguments (ignored).
55+
username: Accepted for :class:`ModelBackend` compatibility; ignored.
56+
password: Accepted for :class:`ModelBackend` compatibility; ignored.
57+
**kwargs: Additional credentials. The ``auth0_user_id`` key
58+
(Auth0 JWT ``sub`` claim) drives the lookup; other keys
59+
are ignored.
4160
4261
Returns:
43-
User: The authenticated user if valid and is_staff, None otherwise.
62+
The authenticated user if valid and ``is_staff``, otherwise ``None``.
4463
"""
4564
if not getattr(settings, "USE_AUTH0", False):
4665
return None
4766

67+
auth0_user_id: Optional[str] = kwargs.get("auth0_user_id")
4868
if not auth0_user_id:
4969
return None
5070

5171
try:
52-
user = User.objects.get(username=auth0_user_id)
72+
user = UserModel.objects.get(username=auth0_user_id)
5373
if user.is_active and user.is_staff:
5474
logger.info(
55-
"Auth0 admin authentication successful for user ID %s", user.id
75+
"Auth0 admin authentication successful for user ID %s", user.pk
5676
)
5777
return user
5878
else:
@@ -63,13 +83,13 @@ def authenticate(self, request, auth0_user_id=None, **kwargs):
6383
user.is_staff,
6484
)
6585
return None
66-
except User.DoesNotExist:
86+
except UserModel.DoesNotExist:
6787
logger.warning("Auth0 admin auth failed: user %s not found", auth0_user_id)
6888
return None
6989

70-
def get_user(self, user_id):
90+
def get_user(self, user_id: int) -> Optional["User"]:
7191
"""Retrieve user by primary key."""
7292
try:
73-
return User.objects.get(pk=user_id)
74-
except User.DoesNotExist:
93+
return UserModel.objects.get(pk=user_id)
94+
except UserModel.DoesNotExist:
7595
return None

config/admin_auth/views.py

Lines changed: 33 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -7,12 +7,22 @@
77
"""
88

99
import logging
10+
from typing import TYPE_CHECKING, Optional
1011
from urllib.parse import urlencode
1112

1213
from django.conf import settings
1314
from django.contrib import admin, messages
1415
from django.contrib.auth import authenticate, login, logout
15-
from django.http import HttpResponse, HttpResponseNotAllowed, JsonResponse
16+
from django.http import (
17+
HttpRequest,
18+
HttpResponse,
19+
HttpResponseBase,
20+
HttpResponseNotAllowed,
21+
JsonResponse,
22+
)
23+
24+
if TYPE_CHECKING:
25+
from opencontractserver.users.models import User
1626
from django.shortcuts import redirect, render
1727
from django.urls import reverse
1828
from django.utils.decorators import method_decorator
@@ -35,17 +45,17 @@
3545
ADMIN_LOGIN_PAGE_RATE = RateLimits.ADMIN_LOGIN_PAGE
3646

3747

38-
def _get_login_url():
48+
def _get_login_url() -> str:
3949
"""Get the admin login URL using reverse() for proper URL resolution."""
4050
return reverse("admin_auth0_login")
4151

4252

43-
def _get_admin_index_url():
53+
def _get_admin_index_url() -> str:
4454
"""Get the admin index URL using reverse() for proper URL resolution."""
4555
return reverse("admin:index")
4656

4757

48-
def _get_next_url_from_request(request):
58+
def _get_next_url_from_request(request: HttpRequest) -> Optional[str]:
4959
"""
5060
Extract the 'next' parameter from request, checking POST first then GET.
5161
@@ -60,7 +70,11 @@ def _get_next_url_from_request(request):
6070
return request.POST.get("next") or request.GET.get("next")
6171

6272

63-
def _get_safe_redirect_url(request, url=None, default=None):
73+
def _get_safe_redirect_url(
74+
request: HttpRequest,
75+
url: Optional[str] = None,
76+
default: Optional[str] = None,
77+
) -> str:
6478
"""
6579
Validate and return a safe redirect URL.
6680
@@ -103,7 +117,7 @@ def _get_safe_redirect_url(request, url=None, default=None):
103117
return default
104118

105119

106-
def _get_safe_logout_return_url(request):
120+
def _get_safe_logout_return_url(request: HttpRequest) -> str:
107121
"""
108122
Get a safe return URL for Auth0 logout.
109123
@@ -163,7 +177,7 @@ class Auth0AdminLoginView(View):
163177

164178
@method_decorator(csrf_protect)
165179
@method_decorator(view_ratelimit(rate=ADMIN_LOGIN_PAGE_RATE, block=False))
166-
def get(self, request):
180+
def get(self, request: HttpRequest) -> HttpResponseBase:
167181
"""Display the appropriate login form."""
168182
if getattr(request, "limited", False):
169183
logger.warning("Rate limit exceeded for admin login page GET")
@@ -203,7 +217,7 @@ def get(self, request):
203217

204218
@method_decorator(csrf_protect)
205219
@method_decorator(view_ratelimit(rate=ADMIN_LOGIN_RATE, block=False))
206-
def post(self, request):
220+
def post(self, request: HttpRequest) -> HttpResponseBase:
207221
"""Handle token-based login via POST or password authentication."""
208222
if getattr(request, "limited", False):
209223
logger.warning("Rate limit exceeded for admin login POST")
@@ -236,7 +250,7 @@ def post(self, request):
236250
return redirect(_get_login_url())
237251

238252
@staticmethod
239-
def _get_csp_nonce(request):
253+
def _get_csp_nonce(request: HttpRequest) -> str:
240254
"""Return the CSP nonce from the request, warning if it is missing.
241255
242256
An empty nonce would produce an invalid CSP directive that silently
@@ -252,12 +266,14 @@ def _get_csp_nonce(request):
252266
return ""
253267
return nonce
254268

255-
def _authenticate_with_token(self, request, token):
269+
def _authenticate_with_token(
270+
self, request: HttpRequest, token: str
271+
) -> HttpResponseBase:
256272
"""Authenticate user with Auth0 JWT token."""
257273
from config.jwt_utils import get_user_from_jwt_token
258274

259275
try:
260-
user = get_user_from_jwt_token(token)
276+
user: Optional["User"] = get_user_from_jwt_token(token)
261277

262278
if user and user.is_active:
263279
# Sync admin claims from token (only during admin login, not API requests)
@@ -282,12 +298,12 @@ def _authenticate_with_token(self, request, token):
282298
)
283299
# Validate redirect URL to prevent open redirect attacks
284300
next_url = _get_safe_redirect_url(request)
285-
logger.info("Admin login successful for user ID %s", user.id)
301+
logger.info("Admin login successful for user ID %s", user.pk)
286302
return redirect(next_url)
287303
else:
288304
logger.warning(
289305
"User ID %s denied admin access",
290-
user.id if user else "unknown",
306+
user.pk if user else "unknown",
291307
)
292308
messages.error(
293309
request, "You do not have permission to access the admin."
@@ -299,7 +315,7 @@ def _authenticate_with_token(self, request, token):
299315
messages.error(request, "Authentication failed. Please try again.")
300316
return redirect(_get_login_url())
301317

302-
def _sync_admin_claims(self, user, token):
318+
def _sync_admin_claims(self, user: "User", token: str) -> bool:
303319
"""
304320
Sync admin claims from Auth0 token to user model.
305321
@@ -324,7 +340,7 @@ def _sync_admin_claims(self, user, token):
324340
return sync_admin_claims_from_payload(user, payload)
325341
except Exception as e:
326342
# Log but don't fail authentication - claim sync is secondary
327-
logger.warning("Failed to sync admin claims for user ID %s: %s", user.id, e)
343+
logger.warning("Failed to sync admin claims for user ID %s: %s", user.pk, e)
328344
return False
329345

330346

@@ -337,7 +353,7 @@ class Auth0AdminLogoutView(View):
337353
"""
338354

339355
@method_decorator(csrf_protect)
340-
def post(self, request):
356+
def post(self, request: HttpRequest) -> HttpResponseBase:
341357
"""Log out the user and redirect appropriately."""
342358
logout(request)
343359

@@ -355,7 +371,7 @@ def post(self, request):
355371

356372
return redirect(_get_admin_index_url())
357373

358-
def get(self, request):
374+
def get(self, request: HttpRequest) -> HttpResponse:
359375
"""
360376
Reject GET requests for logout.
361377

config/graphql_api_token_auth/backends.py

Lines changed: 33 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,20 @@
1+
"""
2+
API-token authentication backend.
3+
4+
Works with any token model that exposes ``key`` (str) and ``user``
5+
(AbstractBaseUser) — not limited to DRF's built-in
6+
:class:`rest_framework.authtoken.models.Token`. The prefix expected in
7+
the ``Authorization`` header is read from ``settings.API_TOKEN_PREFIX``
8+
so the deployment can brand it (``Api-Token``, ``Bearer``, etc.).
9+
"""
10+
111
import logging
12+
from typing import Any, Optional
213

314
from django.conf import settings
415
from django.contrib.auth import get_user_model
16+
from django.contrib.auth.models import AbstractBaseUser
17+
from django.http import HttpRequest
518
from django.utils.translation import gettext_lazy as _
619
from rest_framework import exceptions
720

@@ -12,10 +25,23 @@
1225

1326

1427
class ApiKeyBackend:
28+
"""
29+
Authenticate GraphQL/DRF requests against an API-token model.
1530
16-
model = None
31+
A custom token model may be supplied by setting
32+
:attr:`ApiKeyBackend.model`. It must expose:
33+
34+
* ``key`` — the opaque string identifying the token
35+
* ``user`` — the :class:`AbstractBaseUser` the token belongs to
36+
"""
1737

18-
def get_model(self):
38+
model: Optional[type[Any]] = None
39+
# Used by DRF to populate the ``WWW-Authenticate`` response header on
40+
# 401 responses (see :meth:`authenticate_header`). Declared as a
41+
# class attribute so mypy knows the attribute always exists.
42+
keyword: str = "Token"
43+
44+
def get_model(self) -> type[Any]:
1945
logger.debug("Getting token model")
2046
if self.model is not None:
2147
return self.model
@@ -25,13 +51,9 @@ def get_model(self):
2551
logger.debug("Using default Token model")
2652
return Token
2753

28-
"""
29-
A custom token model may be used, but must have the following properties.
30-
* key -- The string identifying the token
31-
* user -- The user to which the token belongs
32-
"""
33-
34-
def authenticate(self, request=None, **kwargs):
54+
def authenticate(
55+
self, request: Optional[HttpRequest] = None, **kwargs: Any
56+
) -> Optional[AbstractBaseUser]:
3557
logger.debug("Starting authentication process")
3658

3759
if request is None:
@@ -66,7 +88,7 @@ def authenticate(self, request=None, **kwargs):
6688

6789
return self.authenticate_credentials(token)
6890

69-
def authenticate_credentials(self, key):
91+
def authenticate_credentials(self, key: str) -> AbstractBaseUser:
7092
logger.debug("Authenticating credentials")
7193
model = self.get_model()
7294
try:
@@ -85,6 +107,6 @@ def authenticate_credentials(self, key):
85107
logger.debug(f"Successfully authenticated user: {token.user.username}")
86108
return token.user
87109

88-
def authenticate_header(self, request):
110+
def authenticate_header(self, request: HttpRequest) -> str:
89111
logger.debug("Getting authentication header")
90112
return self.keyword

0 commit comments

Comments
 (0)