у записи появился владелец: чужую больше не отдают

- колонка `owner` связью с `users` в обеих коллекциях новым шагом схемы
  `202608140001`; чтение задачи сужено владельцем, и чужая, ничья и
  несуществующая дают один ответ; правило просмотра файлов сужено им же
- приём по HTTP берёт владельца из сессии, а предъявителя без учётной записи
  пользователя отвергает до чтения тела: позже пришлось бы убирать уложенный
  файл, а уборки файлов сервис не умеет. Выборка воркера владельцем не сужается
- удаление учётной записи с записями отвергается стражем, и вешает его сама
  сборка хранилища: сборка, забывшая его позвать, теряла защиту молча
This commit is contained in:
av
2026-08-14 12:18:11 +03:00
parent b7d4660aef
commit 8af8ec2e54
46 changed files with 2520 additions and 101 deletions
+29 -3
View File
@@ -2,6 +2,7 @@ package http
import (
"context"
"errors"
"log/slog"
"net/http"
"time"
@@ -10,6 +11,7 @@ import (
"github.com/pocketbase/pocketbase/core"
"github.com/pocketbase/pocketbase/tools/router"
"git.vakhrushev.me/av/transcriber/internal/adapter/repo/pocketbase/migrations"
"git.vakhrushev.me/av/transcriber/internal/contract"
"git.vakhrushev.me/av/transcriber/internal/entity"
"git.vakhrushev.me/av/transcriber/internal/service"
@@ -51,7 +53,13 @@ func (h *TranscribeHandler) Register(r *router.Router[*core.RequestEvent]) {
// него не подпадает, часть её защищена ровно тем, что браузер заголовка сам
// не шлёт.
api.Bind(SessionFromCookie())
api.Bind(apis.RequireAuth())
// Коллекция названа поимённо, а не оставлена умолчанию. Без имени проверка
// пускает всякую учётную запись хранилища, включая владельца панели, — а
// записи в коллекции пользователей у него нет, и владельцем записи он стать
// не может. Отказ такому предъявителю обязан наступить здесь, до чтения
// тела: позже пришлось бы убирать уже уложенный файл, а уборки файлов
// сервис не умеет вовсе.
api.Bind(apis.RequireAuth(migrations.UsersCollection))
// Умолчание роутера хранилища — 32 МиБ на тело, и оно отсекало бы запись
// раньше обработчика, без строки в журнале приёма. Приём размеру не судья,
@@ -79,7 +87,11 @@ func (h *TranscribeHandler) CreateTranscribeJob(e *core.RequestEvent) error {
// (журнал запроса, сессия) при этом сохраняются, теряется только отмена.
ctx := context.WithoutCancel(e.Request.Context())
job, err := h.trsService.CreateJobFromApi(ctx, file, header.Filename)
// Владелец берётся из предъявленной сессии и ниоткуда больше: владелец,
// пришедший полем запроса, дал бы всякому вошедшему право завести запись на
// чужое имя. Проверка предъявителя стоит слоем выше, поэтому здесь `e.Auth`
// уже есть и принадлежит коллекции пользователей.
job, err := h.trsService.CreateJobFromApi(ctx, file, header.Filename, e.Auth.Id)
if err != nil {
// Второй раз отказ не логируем: приём назван конвенцией логирующей
// границей и уже написал о нём. Транспорт переводит ошибку в ответ.
@@ -96,8 +108,22 @@ func (h *TranscribeHandler) CreateTranscribeJob(e *core.RequestEvent) error {
func (h *TranscribeHandler) GetTranscribeJobStatus(e *core.RequestEvent) error {
jobID := e.Request.PathValue("id")
job, err := h.jobRepo.GetByID(jobID)
// Чужая задача, задача без владельца и несуществующая отвечают одним и тем
// же: хранилище отдаёт на все три ту же ошибку, а транспорт — тот же код и
// то же тело. Различать их наружу нельзя — по разнице ответов перебирается
// список заведённых задач.
job, err := h.jobRepo.GetByID(jobID, e.Auth.Id)
if err != nil {
// Наружу ответ один на все исходы, а в журнал они идут по-разному.
// «Задачи нет» и «задача чужая» — штатная работа разграничения, о ней
// писать нечего; всё прочее — отказ хранилища, и без этой строки он
// приходит отправителю как «вашей записи нет», а владелец сервиса об
// аварии не узнаёт ниоткуда. Журнал читает владелец, а не тот, кто
// перебирает, поэтому различать их здесь можно.
var notFound *contract.JobNotFoundError
if !errors.As(err, &notFound) {
h.logger.Error("Failed to read transcribe job", "error", err, "job_id", jobID)
}
return e.JSON(http.StatusNotFound, map[string]string{"error": "Job not found"})
}