diff --git a/docs/specs/432-asset-resolve-authorization-cache.md b/docs/specs/432-asset-resolve-authorization-cache.md new file mode 100644 index 00000000..8593bd54 --- /dev/null +++ b/docs/specs/432-asset-resolve-authorization-cache.md @@ -0,0 +1,371 @@ +# ТЗ #432 — Ограниченный resolve и единая проверка целостности изображений + +- Issue: https://github.com/Matysh/houseplan-card/issues/432 +- Приоритет / тип: P2 · bug · security +- Трек: полный — меняются два backend endpoint, публичный контракт доступа и + стоимость файловых операций; критерии `small` из `PROCESS.md` не выполняются +- Связано: #51 (custom decor images), #131 (полный View read-only-пользователя), + #421/#430 (исполняемые отрицательные доказательства) +- Решение владельца: Default по Q1 принят в issue 2026-09-03 + +## 1. Сценарий + +**Персона:** домочадец без права редактирования либо администратор, открывающий +полный House Plan / отдельную карточку пространства. На плане есть загруженные +растровые или SVG-изображения декоративного слоя. + +При загрузке View frontend разрешает сохранённые `asset_id`, подписывает URL и +рисует изображения. Параллельно прямой либо ошибочный клиент может многократно +вызывать `houseplan/assets/resolve` и GET тех же файлов. Проверка целостности не +должна превращать обычную загрузку или злоупотребление API в сотни мегабайт +повторного чтения с диска. + +Задача обслуживает J1/J4/J6: View остаётся полным для household members, +пользовательский файл проверяется до показа, а интеграция остаётся устойчивой. + +## 2. Что человек увидит до и после + +**До:** сохранённые изображения отображаются, но каждый resolve/GET заново +читает файл целиком и считает SHA-256. Повторные или параллельные обращения могут +нагружать диск и задерживать Home Assistant. Попытка закрыть дыру обычной +write-проверкой, наоборот, убрала бы изображения у read-only-пользователя. + +**После:** те же сохранённые изображения без новых сообщений и настроек видны +администратору, домочадцу, в full card и space card. Неизменившийся blob +хешируется один раз и переиспользуется обеими transport-поверхностями; +read-only-пользователь не может использовать resolve как просмотр всего +внутреннего asset-каталога. + +## 3. Подтверждённая проблема + +1. `ws_assets_resolve()` принимает до 200 id, сканирует каталог и для каждого + совпавшего blob выполняет `path.read_bytes()` + SHA-256. При лимите 2 МиБ на + файл это до 400 МиБ чтения за один вызов и снова столько же за следующий. +2. `HouseplanContentView.get()` перед каждым GET asset повторяет тот же полный + `read_bytes()` + SHA-256. Заголовок `immutable` не защищает от прямого клиента + и не объединяет full card со space card. +3. Общего cache/single-flight нет: два одновременных запроса могут независимо + хешировать один и тот же blob. +4. `assets/resolve` не требует готового runtime и не различает writer и + read-only user. +5. Действующее ТЗ #51 намеренно называет resolve `authenticated read`; обе + карточки вызывают его в View. Поэтому безусловный `_check_write()` нарушит + #131 и целевую персону из `docs/SCOPE.md`. + +## 4. Решение владельца + +Принят Default: + +- non-admin при `admin_only` продолжает видеть сохранённые декоративные + изображения; +- такой пользователь может разрешать только `asset_id`, на которые ссылается + текущий сохранённый config; +- writer может разрешать любой существующий asset для редакторских сценариев; +- authenticated/signed GET точного content URL сохраняется; +- WS resolve и HTTP GET разделяют один ограниченный cache/single-flight по пути + и файловой сигнатуре. + +## 5. Скоуп + +В задачу входят: + +1. готовый runtime как обязательная предпосылка `assets/resolve`; +2. least-privilege фильтр requested ids для read-only connection; +3. сохранение полного resolve-контракта для `may_write == true`, включая случай + `admin_only: false`; +4. прямое чтение metadata только для разрешённых requested ids вместо полного + сканирования каталога; +5. один общий для WS и HTTP bounded integrity verifier; +6. cache по каноническому пути и файловой сигнатуре, включая размер и точные + timestamps; cache хранит вычисленный digest, а не bytes; +7. single-flight для параллельной проверки одной файловой версии; +8. потоковый SHA-256 ограниченными chunks без `Path.read_bytes()`; +9. invalidation при изменении файловой сигнатуры, bounded eviction и fail-dark + при исчезновении, I/O error, смене файла во время чтения или неверном hash; +10. backend/HA tests и постоянные mutation-witness для дорогих защит; +11. уточнение архитектурной и compatibility-документации, changelog RU/EN. + +## 6. Не-скоуп + +- изменение форматов PNG/JPEG/WebP/SVG, upload validation или лимита 2 МиБ; +- изменение namespace-квоты 200 файлов / 256 МиБ; +- новые rate limits, user-visible ошибки, repair, diagnostics или настройки; +- скрытие сохранённых изображений от household members; +- превращение content GET в writer-only endpoint; +- изменение signed URL, срока подписи, URL-формата, CSP, MIME или streaming + `FileResponse`; +- удаление файлов, сборка мусора либо пересмотр standing rule из `SCOPE.md`; +- config/schema migration, новые persisted/compatibility-поля; +- frontend batching/cache, рендер, редакторы, touch-жесты и i18n; +- общий cache для plans, manuals, export/import и других файлов House Plan. + +## 7. Контракт доступа к `houseplan/assets/resolve` + +### 7.1. Предпосылки + +- HA WebSocket authentication остаётся внешней обязательной границей. +- Handler первым получает runtime через действующий fail-closed путь. Если + интеграция не готова, возвращается `not_ready`; каталог и blobs не читаются. +- `may_write(hass, connection.user)` остаётся единственным определением writer: + admin при `admin_only: true` либо любой authenticated user при + `admin_only: false`. + +### 7.2. Writer + +Writer может запросить любой корректный `asset_id` в пределах существующего +лимита сообщения. Для каждого id сервер напрямую читает одноимённую metadata +запись и проверяет соответствующий blob. Существующий ответ сохраняется: +валидный asset входит в `assets`, отсутствующий/невалидный/повреждённый — в +`missing`; дубликат присутствует не более одного раза. + +### 7.3. Read-only user + +Под `runtime.write_lock` берётся короткий coherent snapshot сохранённого config +и из него существующим `asset_refs()` строится множество разрешённых id. Lock +не удерживается во время metadata I/O или хеширования. + +- Запрошенный id из множества используется так же, как у writer. +- Запрошенный id вне множества сразу попадает в `missing` и не вызывает чтение + его metadata, stat либо blob. +- Ответ не различает «не существует», «повреждён» и «не разрешён». Это сохраняет + partial resolve и не создаёт existence oracle. +- Один запрещённый id не отменяет разрешённые элементы той же пачки. + +Config может измениться сразу после snapshot; это допустимая read-consistency. +Следующий resolve увидит новую сохранённую ревизию. Файл не удаляется на одном +факте исчезновения ссылки. + +## 8. Контракт content GET + +`GET /api/houseplan/content/assets/_/.` сохраняет существующие два +пути доступа: authenticated request либо валидная HA-подпись. Membership в +текущем config повторно не проверяется: подписанный URL обязан работать, а +content-addressed hash практически не перебирается. + +До `FileResponse` asset проходит тот же integrity verifier, что WS. Неверный +digest, исчезновение или ошибка чтения дают прежний 404. Valid response +сохраняет exact MIME, CSP для SVG, `nosniff`, immutable private cache header и +потоковую отдачу. Plans/files этой задачей не меняются. + +## 9. Integrity cache и ограничение стоимости + +### 9.1. Identity + +Cache key включает resolved canonical path; запись содержит файловую сигнатуру +и фактический SHA-256. Сигнатура включает как минимум `size`, `mtime_ns` и +`ctime_ns` (либо документированную точную платформенную замену). Ожидаемый hash +сравнивается с digest, а не становится единственным доказательством cache hit. + +Перед использованием hit выполняется `stat`. Несовпадение сигнатуры означает +miss. После холодного чтения выполняется повторный `stat`; если файл изменился +во время вычисления, результат не публикуется и запрос fail-dark либо делает не +более одной повторной стабильной попытки. Бесконечного retry нет. + +### 9.2. Стоимость и память + +- Blob читается фиксированными chunks; полные bytes не сохраняются в памяти. +- Неизменившаяся файловая версия хешируется один раз на жизнь cache независимо + от того, пришёл первый запрос через WS или HTTP. +- Одновременные проверки одного key/signature выполняют ровно одно чтение; + остальные ждут тот же результат. Ошибка также будит ожидающих и не оставляет + key навсегда in-flight. +- Разные файлы не обязаны выполняться последовательно; реализация не должна + держать один глобальный lock на протяжении всех чтений. +- Cache ограничен не более чем 256 entries и вытесняет least-recently-used либо + эквивалентно детерминированный старый entry. +- Cached digest/negative integrity result применим только к той же сигнатуре. + Missing path не кешируется бессрочно без файловой сигнатуры. +- Cache memory-only, не входит в config/diagnostics/export/backup и очищается при + перезапуске HA. Persisted invalidation или миграция не нужны. + +### 9.3. Прямой metadata lookup + +Resolve не вызывает полный `read_catalog(root)`. Для каждого уникального +разрешённого id читается только `.json`; запись проходит те же проверки +формы, extension, id и наличия blob, что каталог. Shared helper обязан оставлять +`assets/list` и resolve согласованными, чтобы две копии validation не разошлись. + +## 10. Ошибки и совместимость + +- Public success payload `{assets, missing}` и metadata row не меняются. +- `not_ready` — единственная новая наблюдаемая ошибка для вызова в момент, когда + config entry не загружена; это тот же lifecycle-контракт остальных WS-команд. +- Read-only forbidden id становится `missing`, не `unauthorized`. +- I/O/JSON/stat/hash failures не содержат disk path или exception в ответе. +- Existing valid configs, exports/imports и image records читаются без миграции. +- Новый frontend со старым backend и старый frontend с новым backend продолжают + работать в пределах контракта #51; capability version не повышается. + +## 11. UX, accessibility, touch и i18n + +Новых контролов, текстов, focus/keyboard semantics и переводов нет. Full card и +space card рисуют тот же image либо существующий missing-placeholder. View, +kiosk, phone и tablet обязаны сохранить parity для read-only user; редакторы +остаются доступны только по действующему `can_write`. + +Golden и browser smoke не требуются: рендер и frontend не меняются. Read-only +View доказывается backend permission-контрактом плюс существующими frontend +unit tests вызова resolve; код-ревью отдельно проверяет, что frontend не получил +writer-only зависимость. + +## 12. Затронутые модули + +Ожидаемый набор; имена нового helper могут быть уточнены без изменения +контракта: + +- `custom_components/houseplan/decor_assets.py` либо новый чистый модуль рядом — + direct metadata lookup и bounded single-flight integrity cache; +- `custom_components/houseplan/websocket_api.py` — runtime/access filter и + использование общего verifier; +- `custom_components/houseplan/http_api.py` — тот же verifier перед asset + `FileResponse`; +- `custom_components/houseplan/__init__.py` / runtime helper — один cache на HA + instance с корректным lifecycle; +- `tests_backend/test_decor_assets.py` — чистые cache/direct-lookup тесты; +- `tests_backend/test_ha_websocket.py` — HA permission, WS/HTTP и shared-cache + integration tests; +- `scripts/mutation-gate.mjs` — постоянные отрицательные свидетели; +- `docs/ARCHITECTURE.md`, `docs/CONFIG-COMPATIBILITY.md`, changelog RU/EN. + +`src/**`, frontend bundle и i18n не должны меняться, если реализация не обнаружит +отдельный, заранее согласованный compatibility blocker. + +## 13. Критерии приёмки + +- **AC1 (backend/HA).** При `admin_only: true` read-only user успешно разрешает + сохранённый referenced asset; full и space View не получают writer-only + зависимости. +- **AC2 (backend/HA, security).** Тот же user получает unreferenced id в + `missing`, тогда как referenced id из той же пачки остаётся в `assets`; + metadata/stat/blob запрещённого id не читаются. Writer разрешает оба, а при + `admin_only: false` обычный authenticated user имеет writer-контракт. +- **AC3 (backend/HA, lifecycle).** Без loaded runtime resolve отвечает + `not_ready` до любых filesystem operations. +- **AC4 (backend/unit).** Resolve читает metadata только уникальных разрешённых + requested ids и не сканирует остальные catalog rows; malformed/mismatched row + fail-dark и согласована с `read_catalog()`. +- **AC5 (backend/HA, performance).** Последовательные WS resolve и HTTP GET + одного неизменившегося valid blob в любом порядке вызывают одно потоковое + вычисление SHA-256 на общую файловую версию. +- **AC6 (backend/unit, performance).** N параллельных проверок одного + path/signature выполняют один loader/hash, получают одинаковый результат и не + оставляют in-flight state после success или exception. Проверки разных paths + могут продвигаться независимо. +- **AC7 (backend/unit/HA, integrity).** Изменение signature инвалидирует hit; + заменённый corrupt blob становится `missing` в WS и 404 в HTTP. Смена файла во + время чтения не кеширует неподтверждённый digest. Повторный запрос той же + corrupt signature не перечитывает blob. +- **AC8 (backend/unit, budget).** Cache хранит не более 256 entries, вытесняет + старые, не хранит bytes и вычисляет digest chunks без `Path.read_bytes()`. +- **AC9 (backend/HA, compatibility).** Authenticated и signed valid GET сохраняют + body, MIME/security/cache headers и streaming `FileResponse`; plans/files + остаются вне нового verifier. +- **AC10 (review/docs).** Payload, capability, config schema, imports/exports, + frontend, i18n и URL не меняются; architecture/compatibility docs и оба + changelog описывают новый access/cost contract. +- **AC11 (mutation gate).** Для дорогих защит зарегистрированы и исполнены + постоянные свидетели: снятие read-only membership filter краснит AC2; отключение + cache hit/single-flight краснит AC5/AC6; принятие digest после смены signature + краснит AC7. Штатное дерево проходит те же guards зелёным. + +## 14. Таблица защитных доказательств + +Эта таблица обязательна для handoff и code-review по правилу #435; точные имена +могут быть уточнены, но третий столбец не может исчезнуть. + +| AC | Чем доказан | Чем обязан покраснеть | +|---|---|---| +| AC2 | HA test `test_decor_asset_resolve_readonly_is_limited_to_referenced_ids` | мутант удаляет membership filter до metadata lookup; unreferenced id появляется в `assets` либо вызывает I/O | +| AC3 | HA test `test_decor_asset_resolve_requires_runtime_before_io` | мутант удаляет `_runtime()`/ранний return; filesystem probe фиксирует обращение | +| AC4 | pure/HA test direct lookup со сторонними catalog rows | мутант возвращает `read_catalog(root)`; sentinel metadata вне request читается | +| AC5 | HA test WS → HTTP и HTTP → WS с hash counter | мутант всегда объявляет cache miss; counter становится больше 1 | +| AC6 | pure threaded single-flight test с управляемым barrier/loader | мутант удаляет in-flight coordination; loader вызывается N раз | +| AC7 | pure + HA test смены signature и mid-read mutation | мутант игнорирует signature/post-read stat; старый/нестабильный digest принимается | +| AC8 | pure LRU/chunk-reader tests | мутант снимает eviction либо заменяет chunk loop на `read_bytes()`; size/reader sentinel нарушается | +| AC9 | существующий и расширенный signed-content HA test | мутант обходит verifier для HTTP либо меняет headers/FileResponse; corrupt body отдаётся или contract assertions падают | + +AC2/AC5/AC6/AC7, которые зависят от HA либо concurrency и не гарантированно +воспроизводятся локально у ревьюера, получают persistent entries в +`scripts/mutation-gate.mjs`. Для чистого AC8 допустим адресный red proof с +выводом в документе ревью. + +## 15. План автотестов + +1. Расширить #51 HA fixture двумя assets: один referenced, второй нет; выполнить + resolve read-only и writer connections при обоих значениях `admin_only`. +2. Подменить direct metadata/stat/hash seams счётчиками и доказать, что forbidden + id не достигает файловой системы, а unrelated catalog row не сканируется. +3. Вызвать handler без runtime и проверить `not_ready` + нулевые I/O counters. +4. Чисто протестировать hit/miss, LRU boundary 256/257, changed size/timestamps, + cached corrupt digest и bounded retry при изменении во время чтения. +5. Через управляемые threads/barriers одновременно проверить один и разные keys; + тест не использует sleep и имеет bounded join/timeout только как защиту от + deadlock. +6. В HA test последовательно вызвать WS и signed HTTP (затем обратный порядок) + и проверить единый hash counter, 200/404 и неизменные headers/body. +7. Запустить каждый mutation witness: исправное дерево зелёное, мутированное + падает именно целевым assertion, а не import/timeout ошибкой. +8. Implementation gate: `npm run typecheck`, `npm test`, `npm run build`; + backend HA-harness — Linux CI. Browser/golden/performance smoke не выбираются, + если `smoke-select` не обнаружит расширение frontend/visible surface. + +## 16. Производительность и безопасность + +- Повторный неизменившийся blob: 0 прочитанных content bytes для hash; допустим + один `stat` и bounded cache lookup. +- Холодный blob: не более его фактического размера, читаемого chunks; параллельные + запросы одной версии не умножают bytes. +- Память cache: O(256) metadata/digests/in-flight records, без blob bytes. +- Resolve I/O: O(число уникальных разрешённых requested ids), а не O(весь каталог). +- Read-only user не получает metadata unreferenced asset и не может заставить + verifier прочитать его через WS resolve. +- Секреты, локальные пути и причины fail-dark не входят в transport response. + +## 17. Риски + +- **Stale cache скроет повреждение.** Смягчение: precise signature до hit и + повторный stat после чтения; AC7 с отрицательным witness. +- **Single-flight deadlock после исключения.** Смягчение: cleanup/notify в + `finally`, детерминированный concurrent error test. +- **Глобальный lock сериализует разные images.** Смягчение: in-flight ownership + по key, AC6 отдельно запускает два paths. +- **Read-only View случайно станет writer-only.** Смягчение: referenced success + закреплён AC1 для обеих карточек как blocking compatibility invariant. +- **Partial batch выдаст existence oracle.** Смягчение: forbidden id неотличим от + missing/corrupt и не отменяет разрешённые rows. +- **Config меняется между auth snapshot и resolve.** Смягчение: snapshot короткий, + file deletion по inference запрещено, следующий load пересинхронизирует View. + +## 18. Откат + +Откат — revert backend helper и его вызовов, возврат прежних resolve/GET путей. +Persisted state, config, assets и migration rollback отсутствуют. Security и +performance защиты не имеют runtime-флага: временное отключение cache не должно +молча отключать membership guard или integrity check. + +## 19. Release-артефакты + +- `docs/CHANGELOG.md` и `docs/CHANGELOG.ru.md`: кратко описать сохранение + read-only View и устранение повторного хеширования (`User-Visible: yes`); +- `docs/ARCHITECTURE.md`: access matrix resolve/GET и shared verifier lifecycle; +- `docs/CONFIG-COMPATIBILITY.md`: отсутствие schema/capability migration и + rolling compatibility; +- `scripts/mutation-gate.mjs`: security/performance/integrity witnesses; +- user guide, i18n, screenshots/golden: без изменений; +- handoff содержит точный SHA, HA test names, hash/I/O counters и результаты + каждого отрицательного witness. + +## 20. Принятые технические предположения + +- Forbidden read-only id возвращается как `missing`, а не ошибкой всего вызова: + это сохраняет partial batching и не раскрывает существование файла. +- Cache принадлежит HA instance и лениво доступен обоим endpoint; конкретное + место хранения (`hass.data` либо эквивалентный runtime service) не является + persisted контрактом. +- Лимит cache 256 покрывает максимальные 200 promoted assets с небольшим + служебным запасом и остаётся явной тестируемой константой. +- Exact cache signature включает `ctime_ns` сверх предложенных issue + path/mtime/size: это усиливает invalidation без изменения пользовательского + контракта. +- Ссылки на строки ориентировочны; реализация привязывается к символам и + поведению, если `dev` сдвинется до начала разработки. diff --git a/docs/specs/README.md b/docs/specs/README.md index 9dea0d5a..c2082368 100644 --- a/docs/specs/README.md +++ b/docs/specs/README.md @@ -167,6 +167,7 @@ GitHub Issues и GitHub Projects (v2) остаются единственным | [#421](https://github.com/Matysh/houseplan-card/issues/421) Отрицательные доказательства для трёх защитных проверок | [421-negative-test-proofs.md](421-negative-test-proofs.md) | | [#426](https://github.com/Matysh/houseplan-card/issues/426) Отключение информационного окна комнаты при наведении | [426-room-hover-tooltip-toggle.md](426-room-hover-tooltip-toggle.md) | | [#431](https://github.com/Matysh/houseplan-card/issues/431) Канонизация координат пользовательских изображений | [431-image-coordinate-canonicalization.md](431-image-coordinate-canonicalization.md) | +| [#432](https://github.com/Matysh/houseplan-card/issues/432) Ограниченный resolve и единая проверка целостности изображений | [432-asset-resolve-authorization-cache.md](432-asset-resolve-authorization-cache.md) | ## P3