Перейти к содержанию

Личность и право на свою бронь

Оглавление серии

Идемпотентность уже учитывает владельца, но сервис пока использует вымышленного участника 1 для любого запроса. В этой главе отделим получение личности от проверки доступа. Создадим явный учебный провайдер двух участников и добавим условие владельца при чтении. Результат — клиент не может выбрать actor_id в JSON и не получает чужую карточку, даже если знает её номер.

Учебный провайдер не является готовой аутентификацией для публичного сайта. Он выключен по умолчанию, разрешает только два заранее настроенных локальных токена и нужен для объяснения зависимости и ownership. Здесь нет регистрации, восстановления доступа, сессий или выпуска JWT. Не создаём видимость проверенного production-входа. Для действующей системы провайдер заменяется отдельно, а показанная проверка владельца остаётся предметным правилом после получения удостоверенной личности.

От заголовка к участнику

APIKeyHeader читает X-Demo-Token. auto_error=False позволяет нашему get_actor самостоятельно решить, какой отказ вернуть при отсутствии или неизвестном значении. Сначала проверяется demo_enabled. Если режим выключен, ответ 503 говорит, что учебный вход недоступен. Это не назначение владельца по умолчанию и не анонимный доступ. Заголовок не передаёт actor_id напрямую: он сопоставляется с настройкой на сервере.

get_actor сравнивает значение с двумя SecretStr, подготовленными читателем в собственном окружении. compare_digest используется для сравнения байтов. Это аккуратный способ сравнения, но сам по себе он не превращает toy-провайдер в полноценную систему безопасности. Ровно два разных настроенных значения нужны, чтобы участники действительно различались. Исходники и env.example не содержат работающих секретов; пустая или отсутствующая настройка не даёт успешного входа.

Успех возвращает число 1 или 2. Маршрут получает его через Annotated и Depends, а не через входной DTO. Когда сервис создаёт Booking, он использует этот server-side actor_id. Если клиент добавит actor_id к телу, BookingIn с extra="forbid" откажет. Сочетание этих правил закрывает распространённую предметную ошибку, когда пользователь якобы удостоверяется токеном, но владелец записи всё равно берётся из управляемого им JSON.

Полные файлы шага

Показанные файлы полностью совпадают с выбранным состоянием архива. Остальные согласованные файлы находятся в examples/fastapi-booking-lab/lesson-11/.

auth.py

from hmac import compare_digest
from typing import Annotated

from fastapi import Depends, HTTPException
from fastapi.security import APIKeyHeader

from settings import get_settings

header = APIKeyHeader(name="X-Demo-Token", auto_error=False)


def get_actor(token: Annotated[str | None, Depends(header)]) -> int:
    settings = get_settings()
    if not settings.demo_enabled:
        raise HTTPException(503, "Учебный вход выключен")
    if token is not None:
        pairs = [
            (1, settings.demo_first_token),
            (2, settings.demo_second_token),
        ]
        for actor_id, configured in pairs:
            if configured and compare_digest(
                token.encode(), configured.get_secret_value().encode()
            ):
                return actor_id
    raise HTTPException(401, "Неизвестный учебный токен")

Ожидаемый результат

Это ожидаемый договор для самостоятельного исследования; пример не выполнялся.

Учебный вход выключен → 503
Неизвестный токен → 401
Своя бронь → 200
Чужая или отсутствующая бронь → 404

Удостоверение и разрешение

Корректный токен означает, что учебный провайдер узнал одного из участников. Он ещё не разрешает все операции над всеми бронями. Для карточки SELECT содержит Booking.id == booking_id и Booking.actor_id == actor. Эти два условия отвечают за разные вопросы: какой объект нужен и принадлежит ли он текущему участнику. Если оставить только первое, а потом вернуть модель без проверки, знание чужого номера даст чужие данные.

В нашем договоре отсутствие и чужая бронь приводят к одному 404. Это позволяет не раскрывать существование записи чужому участнику. Такой выбор не универсален для любых проектов, но здесь он последователен: личная карточка доступна только владельцу. Важно применять правило и к дальнейшей отмене, и к списку. Защита только одной кнопки интерфейса не меняет того, что клиент может самостоятельно отправить HTTP-запрос.

Каталог ресурсов остаётся публичным учебным чтением, а предварительный просмотр проверяет только форму намерения. Он ничего не сохраняет и не объявляет участнику владение. Настоящие маршруты бронирования получают Actor. Это разделение видно в сигнатурах функций, поэтому полезно прочитать их рядом. Если будущая операция меняет личную запись, она должна получить личность и применить соответствующее условие, даже если её URL выглядит похожим на каталожный.

Ключи повтора тоже принадлежат владельцу

Два участника могут отправить одинаковый Idempotency-Key. Таблица разделяет их по actor_id, а область advisory lock также включает владельца. Поэтому второй участник не получит сохранённый ответ первого только из-за одинаковой строки ключа. При этом exclusion constraint продолжает защищать сам кабинет для всех владельцев. Личность разделяет операции пользователя, но не создаёт каждому отдельное физическое расписание одного и того же ресурса.

Ожидаемый сценарий использует две вымышленные настройки. Первый участник создаёт бронь и читает её номер. Второй с собственным значением заголовка запрашивает тот же номер и должен получить 404. Если второй пытается занять тот же кабинет в пересекающийся час с новым ключом, ожидается slot_busy: расписание общее. Это два разных отказа, вызванных разными правилами. Не используйте реальные аккаунты или настоящие данные для учебного сравнения.

Полный auth.py показан на этой странице. Согласованный main.py находится в lesson-11 и уже использует Actor при создании и чтении. Токены не выдаются ответом API и не включаются в BookingOut. Название SecretStr не разрешает печатать объект Settings целиком в клиентский ответ. При диагностике собственной среды отдельно различайте выключенный режим, отсутствующую настройку и неизвестное отправленное значение: они возникают на границе провайдера, до поиска конкретной брони.

Для самостоятельного разбора представьте, что номер брони случайно оказался в URL закладки другого человека. Проверка владельца всё равно должна отказать, поскольку идентификатор не является разрешением. Затем мысленно замените toy-провайдер настоящим источником личности: сервису по-прежнему нужен actor_id, а условия ownership не исчезают. Следующая глава добавит отмену своей брони и объяснит, почему история записи полезнее её физического удаления из таблицы.

Права должны сопровождать каждую выборку

Добавление Actor в сигнатуру ещё не означает, что маршрут ограничен своими записями. Если функция получает владельца, но не использует его в WHERE, она лишь знает личность и всё равно может отдать чужую карточку. Поэтому при чтении смотрим на пару: зависимость провайдера и условие предметного доступа. Это позволяет проверить правило статическим чтением, не полагаясь на наличие кнопки входа в интерфейсе.

Точно так же идентификатор в ответе не является секретом, который заменяет разрешение. Номера могут попадать в закладки, историю адресов и сообщения между людьми. Сервер должен принимать решение по удостоверенной личности и принадлежности объекта, даже если клиент знает правильный URL. В нашей модели нет административной роли, поэтому не добавляем скрытое исключение для особого номера токена. Если понадобятся сотрудники с доступом к общему расписанию, их действия и границы должны стать самостоятельным договором, а не веткой, которую обычный читатель случайно обнаружит в коде.

Документация и исходники

Механизмы главы сверены с первичными источниками: документация 1, документация 2, документация 3. Полный архив «Окна» содержит все шестнадцать состояний. Пакеты, приложение и миграции не запускались.