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

- колонка `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
@@ -0,0 +1,106 @@
package migrations
import (
"errors"
"fmt"
"github.com/pocketbase/pocketbase/core"
)
// up202608140001 заводит владельца записи.
//
// Колонка — связь с коллекцией пользователей: хранилище само следит, чтобы
// владельцем стояла существующая учётная запись, а не строка, похожая на её
// идентификатор.
//
// Пустое значение допустимо, и это решение с названной ценой. Записи, принятые
// ботом, владельца не имеют вовсе: связи чата Telegram с учётной записью сервис
// не ведёт, её заводит отдельная задача. Обязательность для приёма по HTTP
// держит поэтому сам приём, а не схема.
//
// Каскадное удаление выключено, но одного этого мало: при выключенном каскаде
// хранилище **вынимает** идентификатор из поля связи и сохраняет запись без
// проверок, то есть архив удалённого пользователя стал бы ничьим и не достался
// бы никому. Поэтому удаление учётной записи, у которой остались задачи,
// отвергается слоем приложения — `GuardOwnerDeletion`.
func up202608140001(app core.App) error {
users, err := app.FindCollectionByNameOrId(UsersCollection)
if err != nil {
return fmt.Errorf("failed to find users collection: %w", err)
}
jobs, err := app.FindCollectionByNameOrId(JobsCollection)
if err != nil {
return fmt.Errorf("failed to find jobs collection: %w", err)
}
jobs.Fields.Add(ownerField(users.Id))
if err := app.Save(jobs); err != nil {
return fmt.Errorf("failed to add owner to jobs: %w", err)
}
files, err := app.FindCollectionByNameOrId(FilesCollection)
if err != nil {
return fmt.Errorf("failed to find files collection: %w", err)
}
files.Fields.Add(ownerField(users.Id))
// Правило просмотра сужается владельцем. Прежнее пускало всякого узнанного:
// владельца у записи тогда не было, и сужать выборку было нечем. Без этой
// строки разграничение закрыло бы метаданные задачи и оставило открытым
// содержимое — то самое, что оно и заведено прятать: знание идентификатора
// файловой записи равнялось бы праву скачать чужое аудио.
files.ViewRule = ptr(`@request.auth.id != "" && owner = @request.auth.id`)
if err := app.Save(files); err != nil {
return fmt.Errorf("failed to narrow files by owner: %w", err)
}
return nil
}
// ownerField собирает описание колонки владельца. Обе коллекции получают
// одинаковую: разойдясь, они дали бы разное поведение у задачи и у её файла.
func ownerField(usersCollectionID string) *core.RelationField {
return &core.RelationField{
Name: "owner",
CollectionId: usersCollectionID,
MaxSelect: 1,
// Пустое значение допустимо — см. шапку шага. Умолчания у колонки нет:
// связь его не имеет по устройству, и запись не достаётся никому по
// недосмотру схемы.
Required: false,
// Удаление учётной записи не уносит её записи следом: сервис объявлен
// архивом. Что происходит вместо этого, держит `GuardOwnerDeletion`.
CascadeDelete: false,
}
}
// down202608140001 снимает колонку с обеих коллекций и возвращает правило
// просмотра файлов к тому, что стояло до шага, — «всякий узнанный».
func down202608140001(app core.App) error {
for _, name := range []string{JobsCollection, FilesCollection} {
collection, err := app.FindCollectionByNameOrId(name)
if err != nil {
return fmt.Errorf("failed to find collection %s: %w", name, err)
}
field := collection.Fields.GetByName("owner")
if field == nil {
return errors.New("collection " + name + " has no owner field")
}
collection.Fields.RemoveById(field.GetId())
if name == FilesCollection {
collection.ViewRule = ptr(`@request.auth.id != ""`)
}
if err := app.Save(collection); err != nil {
return fmt.Errorf("failed to drop owner from %s: %w", name, err)
}
}
return nil
}
@@ -24,6 +24,10 @@ import (
const (
FilesCollection = "files"
JobsCollection = "transcribe_jobs"
// UsersCollection заводит не наш шаг, а системный шаг библиотеки. Имя стоит
// здесь потому, что на него ссылаются и шаги схемы, и проверка предъявителя
// на приёме: строковый литерал в двух местах разошёлся бы молча.
UsersCollection = "users"
)
// Шаг регистрируется в списке приложения при загрузке пакета, а накатывает его
@@ -31,6 +35,7 @@ const (
func init() {
pbmigrations.Register(up202608110001, down202608110001, "202608110001_init.go")
pbmigrations.Register(up202608120001, down202608120001, "202608120001_oidc_login.go")
pbmigrations.Register(up202608140001, down202608140001, "202608140001_record_owner.go")
}
func ptr[T any](v T) *T { return &v }