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

- колонка `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
+228
View File
@@ -0,0 +1,228 @@
package http
import (
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/pocketbase/pocketbase/core"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
pbrepo "git.vakhrushev.me/av/transcriber/internal/adapter/repo/pocketbase"
"git.vakhrushev.me/av/transcriber/internal/entity"
)
// Проверки разграничения записей по владельцу. Все идут через собранный роутер:
// сужение живёт в хранилище, но судится по тому, что видит отправитель.
// newSecondAccount заводит вторую учётную запись с собственной сессией.
// Постоянный адрес почты первой занят, и повторное сохранение отвергается —
// адрес здесь свой.
func newSecondAccount(t *testing.T, app core.App) (*core.Record, string) {
t.Helper()
users, err := app.FindCollectionByNameOrId("users")
require.NoError(t, err)
record := core.NewRecord(users)
record.Set("email", "stranger@example.com")
record.Set("verified", true)
record.SetRandomPassword()
require.NoError(t, app.Save(record))
token, err := record.NewAuthToken()
require.NoError(t, err)
return record, token
}
// withSessionHeader предъявляет сессию заголовком. Собственная поверхность
// хранилища читается только так: слой, перекладывающий куку в заголовок, на неё
// намеренно не наведён — часть её защищена ровно тем, что браузер заголовка сам
// не шлёт.
func withSessionHeader(req *http.Request, session string) *http.Request {
req.Header.Set("Authorization", session)
return req
}
// serveAs шлёт запрос от имени названной сессии, а не сессии окружения.
func serveAs(env *testEnv, session string, w http.ResponseWriter, req *http.Request) {
req.AddCookie(&http.Cookie{Name: SessionCookieName, Value: session})
env.mux.ServeHTTP(w, req)
}
// Чужая задача неотличима от несуществующей: тот же код и то же тело. Разница
// ответов обратила бы опрос в перебор — по ней считывается, какие задачи
// заведены.
func TestGetTranscribeJobStatus_ForeignJobLooksMissing(t *testing.T) {
env := setupTestEnv(t, readableMetaViewer())
job := jobWithFile(t, env)
_, stranger := newSecondAccount(t, env.app)
foreign := httptest.NewRecorder()
serveAs(env, stranger, foreign, httptest.NewRequest("GET", "/api/status/"+job.Id, http.NoBody))
unknown := httptest.NewRecorder()
serveAs(env, stranger, unknown, httptest.NewRequest("GET", "/api/status/unknown0000000000", http.NoBody))
require.Equal(t, http.StatusNotFound, foreign.Code, "чужая задача не отдаётся")
assert.Equal(t, unknown.Code, foreign.Code, "код тот же, что у неизвестного идентификатора")
assert.JSONEq(t, unknown.Body.String(), foreign.Body.String(), "и тело то же")
// Ни состояния, ни текста расшифровки в теле нет.
assert.NotContains(t, foreign.Body.String(), entity.StateCreated)
assert.NotContains(t, foreign.Body.String(), "transcription_text")
}
// Задача, принятая ботом, владельца не имеет и не достаётся по API никому.
func TestGetTranscribeJobStatus_OwnerlessJobLooksMissing(t *testing.T) {
env := setupTestEnv(t, readableMetaViewer())
fileRepo := pbrepo.NewFileRepository(env.app)
work, err := fileRepo.Stage(".ogg", strings.NewReader("запись"))
require.NoError(t, err)
defer func() { require.NoError(t, work.Close()) }()
// Файл записи из Telegram владельца тоже не имеет.
file, err := fileRepo.CreateLocal("voice.ogg", work, "")
require.NoError(t, err)
job := &entity.TranscribeJob{
State: entity.StateCreated,
Source: entity.SourceTelegram,
FileID: &file.Id,
}
require.NoError(t, env.handler.jobRepo.Create(job))
w := httptest.NewRecorder()
env.serve(w, httptest.NewRequest("GET", "/api/status/"+job.Id, http.NoBody))
assert.Equal(t, http.StatusNotFound, w.Code)
}
// Владельцем принятой записи становится предъявитель сессии — и у задачи, и у
// её файла.
func TestCreateTranscribeJob_OwnerIsSession(t *testing.T) {
env := setupTestEnv(t, readableMetaViewer())
w := httptest.NewRecorder()
env.serve(w, createMultipartRequest(t, "sample.mp3", []byte("запись")))
require.Equal(t, http.StatusCreated, w.Code)
var response CreateTranscribeJobResponse
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &response))
record, err := env.app.FindRecordById("transcribe_jobs", response.JobID)
require.NoError(t, err)
assert.Equal(t, env.account.Id, record.GetString("owner"), "владелец задачи — предъявитель")
fileRecord, err := env.app.FindRecordById("files", record.GetString("file"))
require.NoError(t, err)
assert.Equal(t, env.account.Id, fileRecord.GetString("owner"), "владелец файла — он же")
}
// Владельца не задают запросом: своё значение в форме на результат не влияет.
func TestCreateTranscribeJob_OwnerFieldFromRequestIgnored(t *testing.T) {
env := setupTestEnv(t, readableMetaViewer())
_, stranger := newSecondAccount(t, env.app)
req := createMultipartRequest(t, "sample.mp3", []byte("запись"))
query := req.URL.Query()
query.Set("owner", stranger)
req.URL.RawQuery = query.Encode()
w := httptest.NewRecorder()
env.serve(w, req)
require.Equal(t, http.StatusCreated, w.Code)
var response CreateTranscribeJobResponse
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &response))
record, err := env.app.FindRecordById("transcribe_jobs", response.JobID)
require.NoError(t, err)
assert.Equal(t, env.account.Id, record.GetString("owner"))
}
// Предъявитель, чья сессия не даёт учётной записи пользователя, получает отказ
// до чтения тела. Владелец панели — именно такой: узнан он узнан, а записи в
// коллекции пользователей у него нет, и владельцем записи он стать не может.
//
// Отказ **до** укладки обязателен: позже пришлось бы убирать уже сохранённый
// файл, а уборки файлов сервис не умеет вовсе.
func TestCreateTranscribeJob_SuperuserSessionRejected(t *testing.T) {
env := setupTestEnv(t, readableMetaViewer())
superusers, err := env.app.FindCollectionByNameOrId(core.CollectionNameSuperusers)
require.NoError(t, err)
admin := core.NewRecord(superusers)
admin.Set("email", "owner@example.com")
admin.SetRandomPassword()
require.NoError(t, env.app.Save(admin))
token, err := admin.NewAuthToken()
require.NoError(t, err)
w := httptest.NewRecorder()
serveAs(env, token, w, createMultipartRequest(t, "sample.mp3", []byte("запись")))
assert.Equal(t, http.StatusForbidden, w.Code, "узнан, но не запись коллекции пользователей")
assert.Equal(t, 0, countJobs(t, env), "задачи не заведено")
assert.Equal(t, 0, countFiles(t, env), "и файла тоже")
}
// Чужой файл не отдаётся по ссылке, а свой отдаётся. Проверяется именно переход
// по ссылке: токен файла хранилище выдаёт на предъявителя, а не на файл, и отказ
// наступает на скачивании, где правило просмотра судит владельца. Проверка,
// написанная на выдачу токена, зеленела бы, не касаясь пути, по которому аудио и
// уходит.
func TestFileDownload_NarrowedByOwner(t *testing.T) {
env := setupTestEnv(t, readableMetaViewer())
job := jobWithFile(t, env)
_, stranger := newSecondAccount(t, env.app)
record, err := env.app.FindRecordById("files", *job.FileID)
require.NoError(t, err)
require.Equal(t, env.account.Id, record.GetString("owner"))
link := "/api/files/files/" + record.Id + "/" + record.GetString("file")
mine := httptest.NewRecorder()
env.mux.ServeHTTP(mine, withSessionHeader(
httptest.NewRequest("GET", link+"?token="+fileToken(t, env, env.session), http.NoBody), env.session))
foreign := httptest.NewRecorder()
env.mux.ServeHTTP(foreign, withSessionHeader(
httptest.NewRequest("GET", link+"?token="+fileToken(t, env, stranger), http.NoBody), stranger))
require.Equal(t, http.StatusOK, mine.Code, "свой файл отдаётся")
assert.Equal(t, "запись", mine.Body.String(), "и отдаётся содержимым")
assert.NotEqual(t, http.StatusOK, foreign.Code, "чужой файл не отдаётся")
assert.NotContains(t, foreign.Body.String(), "запись", "содержимого в отказе нет")
}
// fileToken берёт у хранилища токен файла для названной сессии. Токен выдаётся
// на предъявителя: о файле хранилище при выдаче не спрашивает.
func fileToken(t *testing.T, env *testEnv, session string) string {
t.Helper()
w := httptest.NewRecorder()
env.mux.ServeHTTP(w, withSessionHeader(
httptest.NewRequest("POST", "/api/files/token", http.NoBody), session))
require.Equal(t, http.StatusOK, w.Code, "токен файла выдаётся всякому вошедшему")
var body struct {
Token string `json:"token"`
}
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &body))
require.NotEmpty(t, body.Token)
return body.Token
}
+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"})
}
+12 -4
View File
@@ -273,10 +273,18 @@ func jobWithFile(t *testing.T, env *testEnv) *entity.TranscribeJob {
require.NoError(t, err)
defer func() { require.NoError(t, work.Close()) }()
file, err := repo.CreateLocal("sample.mp3", work)
// Владелец — учётная запись проверки: задача, пришедшая из веба, без
// владельца больше не заводится, и фикстура без него описывала бы состояние,
// которого в проде не бывает.
file, err := repo.CreateLocal("sample.mp3", work, env.account.Id)
require.NoError(t, err)
job := &entity.TranscribeJob{State: entity.StateCreated, Source: entity.SourceApi, FileID: &file.Id}
job := &entity.TranscribeJob{
State: entity.StateCreated,
Source: entity.SourceApi,
OwnerID: &env.account.Id,
FileID: &file.Id,
}
require.NoError(t, env.handler.jobRepo.Create(job))
return job
}
@@ -325,7 +333,7 @@ func TestCreateTranscribeJob_Success(t *testing.T) {
// отправитель получит идентификатор записи, которой не будет никогда.
require.Equal(t, 1, countJobs(t, env))
job, err := env.handler.jobRepo.GetByID(response.JobID)
job, err := env.handler.jobRepo.GetByID(response.JobID, env.account.Id)
require.NoError(t, err)
assert.Equal(t, entity.StateCreated, job.State)
require.NotNil(t, job.FileID)
@@ -595,7 +603,7 @@ func TestCreateTranscribeJob_JournalTracesRecord(t *testing.T) {
var response CreateTranscribeJobResponse
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &response))
job, err := env.handler.jobRepo.GetByID(response.JobID)
job, err := env.handler.jobRepo.GetByID(response.JobID, env.account.Id)
require.NoError(t, err)
require.NotNil(t, job.FileID)