Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
6 changes: 6 additions & 0 deletions CONTEXT.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,12 @@ A chat between exactly two users, identified by `direct_key`, the canonical
makes opening one twice an upsert instead of a read-then-race.
_Avoid_: DM, 1:1

**Actor**:
The authenticated caller of a request: a user id proved by the JWT, carrying no
other user data and not implying the row still exists. Distinct from **Member**,
which is per-chat authorization for that actor.
_Avoid_: principal, current user, authenticated user

**Member**:
The `(chat_id, user_id)` row granting access to a chat, plus that user's read
marker. Necessary for every read or write on a chat; not sufficient for editing
Expand Down
6 changes: 6 additions & 0 deletions app/actor.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
import dataclasses


@dataclasses.dataclass(kw_only=True, frozen=True, slots=True)
class Actor:
id: int
21 changes: 5 additions & 16 deletions app/api/auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,37 +2,26 @@
import typing

import litestar
import modern_di_litestar
from litestar.config.app import AppConfig
from litestar.connection import ASGIConnection
from litestar.plugins import InitPlugin
from litestar.security.jwt import JWTCookieAuth, Token

from app import ioc
from app.database import resources as database_resources
from app.database import tables
from app.actor import Actor
from app.settings import settings


type AuthedRequest = litestar.Request[tables.UsersTable, Token, typing.Any]
type AuthedRequest = litestar.Request[Actor, Token, typing.Any]


async def retrieve_user_handler(token: Token, connection: ASGIConnection) -> tables.UsersTable | None:
# Auth middleware runs before request-scoped DI exists, so this opens its own session.
async def retrieve_user_handler(token: Token, _connection: ASGIConnection) -> Actor | None:
try:
user_id = int(token.sub)
return Actor(id=int(token.sub))
except ValueError:
return None
di_container: typing.Final = modern_di_litestar.fetch_di_container(connection.app)
engine: typing.Final = di_container.resolve_provider(ioc.Database.database_engine)
session: typing.Final = database_resources.create_session(engine)
try:
return await session.get(tables.UsersTable, user_id)
finally:
await database_resources.close_session(session)


jwt_cookie_auth: typing.Final = JWTCookieAuth[tables.UsersTable](
jwt_cookie_auth: typing.Final = JWTCookieAuth[Actor](
retrieve_user_handler=retrieve_user_handler,
token_secret=settings.jwt_secret,
default_token_expiration=datetime.timedelta(seconds=settings.jwt_lifetime_seconds),
Expand Down
8 changes: 6 additions & 2 deletions app/api/endpoints/auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
from app.api.auth import AuthedRequest, jwt_cookie_auth
from app.schemas import api as schemas
from app.use_cases.authenticate_user import AuthenticateUserUseCase
from app.use_cases.fetch_user import FetchUserUseCase
from app.use_cases.register_user import RegisterUserUseCase


Expand Down Expand Up @@ -48,8 +49,11 @@ async def logout() -> Response[None]:


@litestar.get("/auth/me/")
async def me(request: AuthedRequest) -> schemas.User:
return schemas.User.model_validate(request.user)
async def me(request: AuthedRequest, fetch_user_use_case: NamedDependency[FetchUserUseCase]) -> schemas.User:
user: typing.Final = await fetch_user_use_case(actor=request.user)
if user is None:
raise NotAuthorizedException(detail="Invalid authentication credentials")
return schemas.User.model_validate(user)


ROUTER: typing.Final = litestar.Router(
Expand Down
2 changes: 2 additions & 0 deletions app/ioc.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
from app.use_cases.fetch_chat import FetchChatUseCase
from app.use_cases.fetch_chats import FetchChatsUseCase
from app.use_cases.fetch_messages import FetchMessagesUseCase
from app.use_cases.fetch_user import FetchUserUseCase
from app.use_cases.mark_read import MarkReadUseCase
from app.use_cases.register_user import RegisterUserUseCase

Expand Down Expand Up @@ -66,6 +67,7 @@ class UseCases(Group, scope=Scope.REQUEST):
edit_message_use_case = providers.Factory(creator=EditMessageUseCase)
delete_message_use_case = providers.Factory(creator=DeleteMessageUseCase)
fetch_chats_use_case = providers.Factory(creator=FetchChatsUseCase)
fetch_user_use_case = providers.Factory(creator=FetchUserUseCase)
mark_read_use_case = providers.Factory(creator=MarkReadUseCase)


Expand Down
3 changes: 2 additions & 1 deletion app/use_cases/create_chat.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
from advanced_alchemy.exceptions import DuplicateKeyError
from db_retry import Transaction, postgres_retry

from app.actor import Actor
from app.database import tables
from app.exceptions import ValidationError
from app.repositories.chat_members_repository import ChatMembersRepository
Expand All @@ -21,7 +22,7 @@ class CreateChatUseCase:
chat_members_repository: ChatMembersRepository

@postgres_retry
async def __call__(self, *, actor: tables.UsersTable, data: CreateChatRequest) -> tuple[tables.ChatsTable, bool]:
async def __call__(self, *, actor: Actor, data: CreateChatRequest) -> tuple[tables.ChatsTable, bool]:
member_ids: typing.Final = {actor.id, *data.member_ids}
direct_key: str | None = None

Expand Down
3 changes: 2 additions & 1 deletion app/use_cases/create_message.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
from advanced_alchemy.exceptions import DuplicateKeyError
from db_retry import Transaction, postgres_retry

from app.actor import Actor
from app.database import tables
from app.exceptions import PermissionDeniedError
from app.repositories.chat_members_repository import ChatMembersRepository
Expand All @@ -21,7 +22,7 @@ class CreateMessageUseCase:

@postgres_retry
async def __call__(
self, *, actor: tables.UsersTable, chat_id: int, data: SendMessageRequest
self, *, actor: Actor, chat_id: int, data: SendMessageRequest
) -> tuple[tables.MessagesTable, bool]:
if not await self.chat_members_repository.is_member(chat_id, actor.id):
msg = "Not a member of this chat"
Expand Down
3 changes: 2 additions & 1 deletion app/use_cases/delete_message.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@

from db_retry import Transaction, postgres_retry

from app.actor import Actor
from app.database import tables
from app.repositories.chat_members_repository import ChatMembersRepository
from app.repositories.chats_repository import ChatsRepository
Expand All @@ -18,7 +19,7 @@ class DeleteMessageUseCase:
chats_repository: ChatsRepository

@postgres_retry
async def __call__(self, *, actor: tables.UsersTable, message_id: int) -> None:
async def __call__(self, *, actor: Actor, message_id: int) -> None:
async with self.transaction:
message = await fetch_message_for_author(
messages_repository=self.messages_repository,
Expand Down
5 changes: 2 additions & 3 deletions app/use_cases/edit_message.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@

from db_retry import Transaction, postgres_retry

from app.actor import Actor
from app.database import tables
from app.exceptions import ConflictError
from app.repositories.chat_members_repository import ChatMembersRepository
Expand All @@ -18,9 +19,7 @@ class EditMessageUseCase:
chat_members_repository: ChatMembersRepository

@postgres_retry
async def __call__(
self, *, actor: tables.UsersTable, message_id: int, data: EditMessageRequest
) -> tables.MessagesTable:
async def __call__(self, *, actor: Actor, message_id: int, data: EditMessageRequest) -> tables.MessagesTable:
async with self.transaction:
message = await fetch_message_for_author(
messages_repository=self.messages_repository,
Expand Down
3 changes: 2 additions & 1 deletion app/use_cases/fetch_chat.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

from db_retry import postgres_retry

from app.actor import Actor
from app.database import tables
from app.exceptions import PermissionDeniedError
from app.repositories.chat_members_repository import ChatMembersRepository
Expand All @@ -14,7 +15,7 @@ class FetchChatUseCase:
chat_members_repository: ChatMembersRepository

@postgres_retry
async def __call__(self, *, actor: tables.UsersTable, chat_id: int) -> tables.ChatsTable:
async def __call__(self, *, actor: Actor, chat_id: int) -> tables.ChatsTable:
if not await self.chat_members_repository.is_member(chat_id, actor.id):
msg = "Not a member of this chat"
raise PermissionDeniedError(msg)
Expand Down
3 changes: 2 additions & 1 deletion app/use_cases/fetch_chats.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@

from db_retry import postgres_retry

from app.actor import Actor
from app.database import tables
from app.repositories.chats_repository import ChatsRepository

Expand All @@ -12,5 +13,5 @@ class FetchChatsUseCase:
chats_repository: ChatsRepository

@postgres_retry
async def __call__(self, *, actor: tables.UsersTable) -> Sequence[tables.ChatsTable]:
async def __call__(self, *, actor: Actor) -> Sequence[tables.ChatsTable]:
return await self.chats_repository.list_for_user(actor.id)
3 changes: 2 additions & 1 deletion app/use_cases/fetch_messages.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

from db_retry import postgres_retry

from app.actor import Actor
from app.database import tables
from app.exceptions import PermissionDeniedError, ValidationError
from app.repositories.chat_members_repository import ChatMembersRepository
Expand All @@ -22,7 +23,7 @@ class FetchMessagesUseCase:
async def __call__(
self,
*,
actor: tables.UsersTable,
actor: Actor,
chat_id: int,
before_id: int | None = None,
after_id: int | None = None,
Expand Down
16 changes: 16 additions & 0 deletions app/use_cases/fetch_user.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import dataclasses

from db_retry import postgres_retry

from app.actor import Actor
from app.database import tables
from app.repositories.users_repository import UsersRepository


@dataclasses.dataclass(kw_only=True, frozen=True, slots=True)
class FetchUserUseCase:
users_repository: UsersRepository

@postgres_retry
async def __call__(self, *, actor: Actor) -> tables.UsersTable | None:
return await self.users_repository.get_one_or_none(id=actor.id)
5 changes: 2 additions & 3 deletions app/use_cases/mark_read.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@

from db_retry import Transaction, postgres_retry

from app.actor import Actor
from app.database import tables
from app.exceptions import PermissionDeniedError, ValidationError
from app.repositories.chat_members_repository import ChatMembersRepository
Expand All @@ -17,9 +18,7 @@ class MarkReadUseCase:
messages_repository: MessagesRepository

@postgres_retry
async def __call__(
self, *, actor: tables.UsersTable, chat_id: int, data: MarkReadRequest
) -> tables.ChatMembersTable:
async def __call__(self, *, actor: Actor, chat_id: int, data: MarkReadRequest) -> tables.ChatMembersTable:
member: typing.Final = await self.chat_members_repository.fetch_member(chat_id, actor.id)
if member is None:
msg = "Not a member of this chat"
Expand Down
3 changes: 2 additions & 1 deletion app/use_cases/message_authorization.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import typing

from app.actor import Actor
from app.database import tables
from app.exceptions import PermissionDeniedError
from app.repositories.chat_members_repository import ChatMembersRepository
Expand All @@ -10,7 +11,7 @@ async def fetch_message_for_author(
*,
messages_repository: MessagesRepository,
chat_members_repository: ChatMembersRepository,
actor: tables.UsersTable,
actor: Actor,
message_id: int,
action: str,
) -> tables.MessagesTable:
Expand Down
2 changes: 1 addition & 1 deletion docs/adr/0002-cookie-auth-not-bearer.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# Cookie auth, not a bearer header

**Decision:** The JWT travels in a cookie (`JWTCookieAuth[UsersTable]`), not in
**Decision:** The JWT travels in a cookie (`JWTCookieAuth[Actor]`), not in
an `Authorization: Bearer` header.

## Context
Expand Down
5 changes: 3 additions & 2 deletions docs/adr/0005-domain-error-vocabulary.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,8 +28,9 @@ Login failure is the mirror of that mistake, and it is why the login handler
raises Litestar's own `NotAuthorizedException` instead of
`PermissionDeniedError`: a bad credential is an *identification* failure, not
an authorization decision about an already-identified actor — at that point
there is no actor yet to authorize. It is the one place `litestar.exceptions`
is used deliberately.
there is no actor yet to authorize. `GET /api/auth/me/` raises it for the same
reason, on a token whose user row is gone. Those two are the only deliberate
uses of `litestar.exceptions`.

## Consequence

Expand Down
57 changes: 57 additions & 0 deletions docs/adr/0014-auth-carries-an-actor-id.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
# Authentication carries an actor id, not a user row

**Decision:** `retrieve_user_handler` resolves an `Actor` — a frozen dataclass
holding the `id` proved by the JWT — from `token.sub` alone, reading no row.

Auth middleware runs before request-scoped DI exists, so anything it loads must
come from a session it opens and closes itself. Loading the user row there cost
a second DB session per authenticated request against `db_pool_size=5` /
`db_max_overflow=0`, and handed every use case a detached ORM instance from a
closed session — safe only because every column happened to be loaded and
nobody mutated it. Against that, all ten reads of `actor` across
`app/use_cases/` were `actor.id`. `Actor` lives in `app/actor.py`, a top-level
module, so `app/use_cases/` never imports `app.api`.

## Rejected: loading the user row in middleware

The shape this replaces. Its one real benefit was that authentication proved
the user still existed; see Consequence for why that is not worth a session.
`GET /api/auth/me/` is the only consumer of the full row and now fetches it
through `FetchUserUseCase` on the request-scoped session, like every other
read. Reintroducing the lookup is invisible in API responses either way, which
is why the claim is pinned by
`test_retrieve_user_handler_resolves_an_actor_without_reading_the_database`
rather than by an endpoint test.

## Rejected: UUID or uuid7 user ids

Raised as the alternative to passing a bare integer around. There is one
writer, so there is no id-coordination problem to solve. `direct_key`
(`String(64)`) no longer fits two ids; three FK columns widen 8→16 bytes, one
of them indexed (`chat_members.user_id`); and `schemas.User.id` and
`member_ids` become a breaking API change. uuid7 is also the wrong tool for the
benefit usually wanted here — it publishes registration time in its first 48
bits. [`0001-sequence-ids-not-snowflakes.md`](0001-sequence-ids-not-snowflakes.md)
already carries the ordering argument for message ids.

## Rejected: an opaque public id

Deferred rather than refused. If opacity is ever wanted, the shape is the
two-id pattern — the BigInt PK stays internal, a separate opaque public id
faces outward — which is strictly additive on top of this decision. Adopting it
now would buy nothing and cost a second identifier to keep in agreement.

## Consequence

Authentication no longer proves the user exists. A token whose row is gone
authenticates: reads come back empty, writes hit the `messages.user_id` foreign
key. Nothing can reach that state today — there is no delete-user or
disable-user path — and the accepted cost is recorded in the invariant test's
docstring, not as a deferred item.

## Revisit trigger

A delete-user or disable-user path being added. It meets the same problem as
[`../../planning/deferred/2026-08-21-logout-does-not-revoke-jwt.md`](../../planning/deferred/2026-08-21-logout-does-not-revoke-jwt.md)
— a credential outliving what it names — and both should be solved once,
together.

This file was deleted.

Loading