Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
32 commits
Select commit Hold shift + click to select a range
6c5d046
refactor: narrow RemoteAPI JSON payloads at the call sites that assum…
yyyyyyyan Sep 1, 2026
84a92ab
refactor: key the manager result type on the statement shape it executes
yyyyyyyan Sep 1, 2026
9431954
fix: give the nomad lazy export a real type at its annotation consumers
yyyyyyyan Sep 1, 2026
bfd699a
fix: make the abstract streaming methods async generators, as their o…
yyyyyyyan Sep 1, 2026
a56b52b
fix: narrow the app periodic-schedule union without going through cal…
yyyyyyyan Sep 1, 2026
b539925
test: annotate yielding fixtures as generators rather than as what th…
yyyyyyyan Sep 1, 2026
7aec49d
test: narrow the optionals these assertions already depend on
yyyyyyyan Sep 1, 2026
169f467
fix: state the concrete type where it is knowable, and pin it where F…
yyyyyyyan Sep 1, 2026
31926b6
fix: make the overrides honour the signatures they inherit
yyyyyyyan Sep 1, 2026
278fb23
fix: bind the names these reads depend on, on every path
yyyyyyyan Sep 1, 2026
cd8a4e2
fix: state what these functions return, and narrow what they read
yyyyyyyan Sep 1, 2026
6a60f8c
fix: finish the app-side tail, and say when a value is a type expression
yyyyyyyan Sep 1, 2026
2374aef
test: narrow the last of the optionals, and stop ratifying an inert i…
yyyyyyyan Sep 1, 2026
7442ffc
chore: register the two shapes with no in-tree fix, and re-measure th…
yyyyyyyan Sep 1, 2026
135e297
fix: place the registered suppressions on the lines ty reports
yyyyyyyan Sep 1, 2026
e6cc8b1
style: satisfy the SEP style gate on the lines this change adds
yyyyyyyan Sep 1, 2026
3bb0b9e
docs: satisfy the pre-push docstring, pagination and duplication gates
yyyyyyyan Sep 1, 2026
09f9289
docs: add the changelog fragment for the new upstream-payload error path
yyyyyyyan Sep 1, 2026
a89cb55
fix: make the narrowed contracts in this branch true at runtime
yyyyyyyan Sep 1, 2026
be6c1a6
fix: place the pagination pragma where its gate reads it, and complet…
yyyyyyyan Sep 1, 2026
8498398
style: use rST double backticks in the new crud docstrings
yyyyyyyan Sep 1, 2026
0a3183b
chore: refresh the committed OpenAPI spec and derive the stale-group …
yyyyyyyan Sep 1, 2026
f9643dc
fix: close the holes the widened signatures left open
yyyyyyyan Sep 1, 2026
6721844
fix: make _build_query's default builder solvable, and name the branc…
yyyyyyyan Sep 1, 2026
c431b7b
docs: record the ty baseline this branch actually measures, and the c…
yyyyyyyan Sep 1, 2026
bc75d54
refactor: apply the self-review pass — decorator-pinned response mode…
yyyyyyyan Sep 2, 2026
5f465e1
chore: merge origin/main into SEP-1908
yyyyyyyan Sep 2, 2026
12829fb
docs: re-measure the ty baseline after merging main
yyyyyyyan Sep 2, 2026
c5a1e1e
Merge branch 'main' into SEP-1908
yyyyyyyan Sep 3, 2026
b660876
chore: regenerate the API client types the refreshed spec changed
yyyyyyyan Sep 3, 2026
e9fe274
Merge remote-tracking branch 'origin/main' into SEP-1908
Copilot Sep 3, 2026
e6234d7
fix: reconcile main changes with strict type checking
Copilot Sep 3, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 12 additions & 8 deletions app/api/deps.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,14 +31,18 @@
HTTPUnauthorizedException,
InactiveUserException,
)
from app.core.auth.models import UserRole
from app.core.auth.models import BaseUser, UserRole
from app.core.auth.utils import get_user_model
from app.core.config import settings
from app.core.log import set_log_context
from app.core.security import is_bearer_authenticated, SAFE_HTTP_METHODS
from app.sep.config import sep_settings

logger = logging.getLogger(__name__)
#: The provider-selected concrete user class, resolved from configuration at
#: import time. Annotations below name ``BaseUser`` instead, which is the
#: strongest type that is statically knowable here; nothing reads those
#: annotations at runtime.
User = get_user_model()

oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/api/oauth/token")
Expand All @@ -59,7 +63,7 @@
)


def _build_service_principal(secret: str) -> User:
def _build_service_principal(secret: str) -> BaseUser:
"""Return a per-request copy of the service principal with ``access_token`` set.

``access_token`` is a property-backed private attribute on ``BaseUser``;
Expand All @@ -76,7 +80,7 @@ def _build_service_principal(secret: str) -> User:
return user


async def authenticate_bearer_token(token: str) -> User:
async def authenticate_bearer_token(token: str) -> BaseUser:
"""Return the authenticated user from an OAuth2 token.

When ``settings.SEP_INTERNAL_TOKEN`` is configured and the incoming Bearer
Expand Down Expand Up @@ -124,7 +128,7 @@ async def authenticate_bearer_token(token: str) -> User:
_RESOLVED_USERS_KEY: Final = "app.api.deps.resolved_users"


async def get_current_user(request: Request, token: AuthToken) -> User:
async def get_current_user(request: Request, token: AuthToken) -> BaseUser:
"""Return the authenticated user, resolving each credential once per request.

The cache is keyed on a digest of the credential, so a resolution is never
Expand All @@ -146,7 +150,7 @@ async def get_current_user(request: Request, token: AuthToken) -> User:
:raises BaseAuthProviderException: If the auth provider errors while
validating the credential.
"""
resolved: dict[bytes, User] = request.scope.setdefault(_RESOLVED_USERS_KEY, {})
resolved: dict[bytes, BaseUser] = request.scope.setdefault(_RESOLVED_USERS_KEY, {})
key = sha256(token.encode()).digest()
if (user := resolved.get(key)) is not None:
set_log_context(user=user.username)
Expand All @@ -156,10 +160,10 @@ async def get_current_user(request: Request, token: AuthToken) -> User:


IsAuthenticatedDep = Depends(get_current_user)
CurrentUser = Annotated[User, IsAuthenticatedDep]
CurrentUser = Annotated[BaseUser, IsAuthenticatedDep]


async def get_current_admin(current_user: CurrentUser) -> User:
async def get_current_admin(current_user: CurrentUser) -> BaseUser:
"""Return the authenticated admin from an OAuth2 token.

:param current_user: The current logged-in user.
Expand All @@ -176,7 +180,7 @@ async def get_current_admin(current_user: CurrentUser) -> User:
IsAdminDep = Depends(get_current_admin)


async def get_current_service_principal(current_user: CurrentUser) -> User:
async def get_current_service_principal(current_user: CurrentUser) -> BaseUser:
"""Return the authenticated caller only when it is the service principal.

Gates writes whose rows a syncer owns: those writes authenticate with
Expand Down
22 changes: 15 additions & 7 deletions app/api/routes/users.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,11 +16,13 @@
"""Define the API routes for User actions."""

import logging
from collections.abc import Sequence

from fastapi import APIRouter

from app.api.deps import CurrentUser, IsAdminDep
from app.core.auth.exceptions import HTTPForbiddenException
from app.core.auth.models import BaseUser
from app.core.auth.utils import get_user_model

logger = logging.getLogger(__name__)
Expand All @@ -30,18 +32,24 @@
User = get_user_model()


@router.get("/", dependencies=[IsAdminDep])
async def list_users() -> list[User]:
@router.get(
"/",
dependencies=[IsAdminDep],
# pagination-ok: the provider SDK returns the whole organization in one call
# (Casdoor /api/get-users, Grafana /api/org/users), so there is no upstream
# window to page against; the cardinality is the operator's own user count.
response_model=list[User], # ty: ignore[invalid-type-form]
)
async def list_users() -> Sequence[BaseUser]:
"""List users.

:return: The list of users.
:rtype: list[User]
"""
return await User.get_users()


@router.get("/me")
async def retrieve_current_user(current_user: CurrentUser) -> User:
@router.get("/me", response_model=User)
async def retrieve_current_user(current_user: CurrentUser) -> BaseUser:
"""Retrieve the current authenticated user.

:param current_user: The current authenticated user.
Expand All @@ -52,8 +60,8 @@ async def retrieve_current_user(current_user: CurrentUser) -> User:
return current_user


@router.get("/{username}")
async def retrieve_user(current_user: CurrentUser, username: str) -> User:
@router.get("/{username}", response_model=User)
async def retrieve_user(current_user: CurrentUser, username: str) -> BaseUser:
"""Retrieve a user by username.

:param current_user: The current authenticated user.
Expand Down
18 changes: 14 additions & 4 deletions app/core/alerts/providers/pagerduty.py
Original file line number Diff line number Diff line change
Expand Up @@ -109,13 +109,23 @@ async def get_api(self) -> RemoteAPI:
"""
return await settings.get_remote_api(endpoint=self.api_endpoint)

@validate_call
async def send_alert(self, alert: PagerDutyAlert) -> None:
async def send_alert(self, alert: Alert) -> None:
"""Send an alert to PagerDuty using the Events API v2.

:param alert: The alert to be sent.
:type alert: PagerDutyAlert
The dispatcher hands every provider the base :class:`Alert`, so the
PagerDuty-specific fields are resolved here rather than by widening what
the base contract promises. Carries no ``validate_call``: that would
coerce the argument to :class:`Alert` first, and ``AlertSeverity`` has
neither the lowercase values nor the name-or-value lookup that
``PagerDutyAlertSeverity`` accepts, so a mapping naming ``"critical"``
would be rejected before reaching the conversion below.

:param alert: The alert to be sent, as an :class:`Alert`, a
:class:`PagerDutyAlert`, or a mapping of either's fields.
:raises ValidationError: If ``alert`` carries no PagerDuty severity.
"""
if not isinstance(alert, PagerDutyAlert):
alert = PagerDutyAlert.model_validate(alert, from_attributes=True)
pagerduty_api = await self.get_api()
await pagerduty_api.post(
"enqueue",
Expand Down
5 changes: 2 additions & 3 deletions app/core/auth/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -367,14 +367,13 @@ async def get_user(cls, username: NonEmptyStr) -> Self:

@classmethod
@abstractmethod
async def get_users(cls) -> list[Self]:
async def get_users(cls) -> Sequence[Self]:
"""Retrieve all users.

This method must be overridden in subclasses to provide specific logic for
retrieving all users from the data store.

:return: A list of user instances.
:rtype: list[Self]
:return: A sequence of user instances.
"""

@classmethod
Expand Down
8 changes: 7 additions & 1 deletion app/core/auth/providers/casdoor/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -254,12 +254,18 @@ async def get_users(cls) -> list[Self]:
return [cls(**user_data) for user_data in users_data]

@classmethod
async def from_token_payload(cls, token_payload: CasdoorTokenPayload) -> Self:
async def from_token_payload(cls, token_payload: BaseTokenPayload) -> Self:
"""Create an instance of ``CasdoorUser`` from a ``CasdoorTokenPayload``.

:param token_payload: The Casdoor token payload containing user information.
:return: An instance of ``CasdoorUser``.
:raises TypeError: If the payload is not a ``CasdoorTokenPayload``.
"""
if not isinstance(token_payload, CasdoorTokenPayload):
raise TypeError(
f"CasdoorUser requires a CasdoorTokenPayload, got "
f"{type(token_payload).__name__}"
)
return await cls.get_user(token_payload.username)

@classmethod
Expand Down
67 changes: 42 additions & 25 deletions app/core/auth/providers/casdoor/sdk.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@
BaseAuthProviderException,
HTTPUnauthorizedException,
)
from app.core.requests import RemoteAPI
from app.core.requests import as_json_object, JSONBody, RemoteAPI
from app.core.utils.fields import NonEmptyStr, RelativeFilePathField, StrHttpUrl, URL


Expand Down Expand Up @@ -167,20 +167,21 @@ def get_frontend_url(self, base_url: URL | None = None) -> URL:
"port": self.front_endpoint.port or base_url.port,
"path": self.front_endpoint.path or base_url.path,
}
return self.front_endpoint.replace(**url_data)
return URL(str(self.front_endpoint.replace(**url_data)))

async def request(
self,
method: str,
path: str,
**kwargs: Any,
) -> dict[str, Any] | list[dict[str, Any]]:
) -> JSONBody:
"""Perform an HTTP request and return the JSON response.

:param method: The HTTP method to use for the request.
:param path: The API endpoint path to request.
:param kwargs: Additional keyword arguments to pass to the request.
:return: The JSON response as a Python object.
:return: The JSON response as a Python object, or ``None`` when Casdoor
answers HTTP 204 with no body.
:raises HTTPException: If the request returns an error response.
"""
try:
Expand Down Expand Up @@ -213,7 +214,9 @@ async def refresh_token_request(
"scope": scope,
"refresh_token": refresh_token,
}
return await self.post("/api/login/oauth/refresh_token", json=data)
return as_json_object(
await self.post("/api/login/oauth/refresh_token", json=data)
)

async def get_access_token(
self,
Expand Down Expand Up @@ -253,7 +256,9 @@ async def get_access_token(
data["grant_type"] = "password"
invalid_grant_message = "Invalid username or password."
try:
return await self.post("/api/login/oauth/access_token", json=data)
return as_json_object(
await self.post("/api/login/oauth/access_token", json=data)
)
except HTTPException as exc:
if exc.headers and exc.headers.get("X-Error-Code") == "invalid_grant":
raise HTTPUnauthorizedException(invalid_grant_message) from None
Expand All @@ -264,7 +269,7 @@ async def get_version_info(self) -> dict[str, Any]:

:return: A dictinoary containin Casdoor's version details.
"""
version = await self.get("/api/get-version-info")
version = as_json_object(await self.get("/api/get-version-info"))
return version["data"]

async def introspect_token(
Expand Down Expand Up @@ -292,10 +297,12 @@ async def introspect_token(
token_type_hint = token_type.replace("-", "_")
else:
token_type_hint = token_type
return await self.post(
"/api/login/oauth/introspect",
data={"token": token, "token_type_hint": token_type_hint},
headers={"Content-Type": "application/x-www-form-urlencoded"},
return as_json_object(
await self.post(
"/api/login/oauth/introspect",
data={"token": token, "token_type_hint": token_type_hint},
headers={"Content-Type": "application/x-www-form-urlencoded"},
)
)

async def get_token(self, token_id: str) -> dict[str, Any]:
Expand All @@ -306,7 +313,9 @@ async def get_token(self, token_id: str) -> dict[str, Any]:
:param token_id: The ID of the token to retrieve.
:return: The token details retrieved from Casdoor.
"""
response = await self.get("/api/get-token", params={"id": token_id})
response = as_json_object(
await self.get("/api/get-token", params={"id": token_id})
)
return response["data"]

async def get_tokens(
Expand All @@ -330,14 +339,18 @@ async def get_tokens(
"pageSize": page_size,
"p": 1,
}
tokens = await self.get(
"/api/get-tokens",
params=params,
tokens: dict[str, Any] | None = as_json_object(
await self.get(
"/api/get-tokens",
params=params,
)
)
max_page = ceil((tokens.get("data2") or 0) / page_size)
while params["p"] <= max_page:
if tokens is None:
tokens = await self.get("/api/get-tokens", params=params)
tokens = as_json_object(
await self.get("/api/get-tokens", params=params)
)
for token in tokens["data"]:
if username is None or token["user"] == username:
yield token
Expand Down Expand Up @@ -370,7 +383,7 @@ async def delete_token(self, token: dict[str, Any]) -> bool:
:param token: The token to delete.
:return: Whether the token was deleted.
"""
response = await self.post("/api/delete-token", json=token)
response = as_json_object(await self.post("/api/delete-token", json=token))
return response["data"].lower() == "affected"

@alru_cache(ttl=300)
Expand All @@ -381,8 +394,8 @@ async def get_users(self) -> list[dict[str, Any]]:

:return: A list of user data.
"""
users = await self.get(
"/api/get-users", params={"owner": self.organization_name}
users = as_json_object(
await self.get("/api/get-users", params={"owner": self.organization_name})
)
return users["data"]

Expand All @@ -394,9 +407,11 @@ async def get_user(self, username: str) -> dict[str, Any]:
:param username: The username of the user to retrieve.
:return: A dictionary containing the user's information.
"""
user = await self.get(
"/api/get-user",
params={"id": f"{self.organization_name}/{username}"},
user = as_json_object(
await self.get(
"/api/get-user",
params={"id": f"{self.organization_name}/{username}"},
)
)
return user["data"]

Expand All @@ -408,8 +423,10 @@ async def get_user_application(self, username: str) -> dict[str, Any]:
:param username: The username of the user to retrieve.
:return: A dictionary containing the user's information.
"""
user = await self.get(
"/api/get-user-application",
params={"id": f"{self.organization_name}/{username}"},
user = as_json_object(
await self.get(
"/api/get-user-application",
params={"id": f"{self.organization_name}/{username}"},
)
)
return user["data"]
8 changes: 8 additions & 0 deletions app/core/auth/providers/grafana/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -640,7 +640,13 @@ async def from_bearer(cls, token: str) -> Self:
:raises ValidationError: If the assertion is tampered, malformed, expired
against the lifetime of every accepted type, or of a type this
surface does not accept.
:raises GrafanaException: If the accepted-type set is empty, so the loop
ends with nothing tried and no error to re-raise. Unreachable while
``_BEARER_TOKEN_TYPES`` is a non-empty literal; it is what makes the
re-raise below answer for a name that is otherwise only bound inside
the loop.
"""
last_error: ValidationError | None = None
for token_type in _BEARER_TOKEN_TYPES:
try:
user = cls.model_validate(token, context={"token_type": token_type})
Expand All @@ -649,6 +655,8 @@ async def from_bearer(cls, token: str) -> Self:
continue
user.access_token = token
return user
if last_error is None:
raise GrafanaException("No bearer token type is accepted on this surface.")
raise last_error

@classmethod
Expand Down
Loading
Loading