docs(server): getPageBreadCrumbs — предки не утекают (нисходящее наследование ограничений), задокументировано (#471) #550

Merged
vvzvlad merged 1 commits from docs/471-breadcrumb-permission into develop 2026-07-12 17:33:59 +03:00
Collaborator

Summary

#471 разбор: утечки title предка в breadcrumbs НЕТ — задокументировал почему (по решению владельца: документировать, не добавлять пофамильную фильтрацию). closes #471. Изменение comment-only (JSDoc + 5 строк у контроллера), логику не трогает.

Ключевое: премиса #471 невозможна by-construction

Ограничения наследуются ВНИЗ по дереву. Чтобы просмотреть целевую страницу P, validateCanViewcanUserAccessPagecanUserEditPage считает bool_and(pp.id IS NOT NULL) по КАЖДОМУ ограниченному предку в ПОЛНОЙ родительской цепочке P (page-permission.repo.ts:400-410) — причём рекурсия permission-CTE безгранична по глубине и без deletedAt-фильтра, тогда как breadcrumb-CTE ограничен MAX_PAGE_TREE_DEPTH=10000 + deletedAt IS NULL. Значит permission-обход — НАДмножество breadcrumb-обхода: любой ограниченный предок, попадающий в крошки, уже прошёл permission-проверку. Cross-space невозможен (parentPageId структурно не пересекает spaceId на всех трёх путях записи). Неограниченный предок в том же space виден участнику по CASL Read. Итог: пользователь, видящий P, доказуемо вправе видеть каждого предка из цепочки → «вижу P, но не вижу предка A» невозможно.

Важно (расхождение с формулировкой в теле #471): исходный вариант «space-membership даёт видимость ВСЕХ страниц» — фактически НЕВЕРЕН (ограниченные подписи внутри member-space реально скрывают страницы). Настоящая причина отсутствия утечки — нисходящее наследование (выше), а не «membership = всё видно». JSDoc это явно дезавуирует и сужает гарантию до «видишь потомка ⇒ вправе видеть предков».

How verified

  • Инвариант проверен по реальному коду звено-за-звеном (validateCanView / canUserEditPage bool_and / глубинные границы обоих CTE / cross-space на 3 путях записи / deletedAt-асимметрия) — герметичен, глубокий ограниченный предок ускользнуть не может (permission-CTE глубже breadcrumb-CTE).
  • git diff — только комментарии/пустые строки, ровно page.service.ts + page.controller.ts; tsc -p apps/server не затронут (кроме пред-сущ. drawio import.meta). node_modules 0.
  • Внутренний цикл: 1 проход (документация) + адверсариальный ре-ревью, специально проверивший security-инвариант против кода (потенциальный BLOCKER — permission-CTE мельче breadcrumb — опровергнут: он безграничен) → APPROVE.

Осознанная развилка

Документировал как safe-by-construction (утечки нет), а не «accepted title-only exposure» — потому что премиса утечки доказуемо невозможна, а не «терпимо мала». Если предпочтёшь формулировку-хедж «принятая экспозиция» — скажу кодеру, поправит; но truthful-вариант — именно by-construction.

Checklist

  • #471 закрыт (документирование, по решению владельца); логика не менялась
  • comment-only; вне scope ничего; node_modules нет
## Summary #471 разбор: **утечки title предка в breadcrumbs НЕТ** — задокументировал почему (по решению владельца: документировать, не добавлять пофамильную фильтрацию). closes #471. Изменение **comment-only** (JSDoc + 5 строк у контроллера), логику не трогает. ## Ключевое: премиса #471 невозможна by-construction Ограничения наследуются ВНИЗ по дереву. Чтобы просмотреть целевую страницу P, `validateCanView` → `canUserAccessPage` → `canUserEditPage` считает `bool_and(pp.id IS NOT NULL)` по КАЖДОМУ ограниченному предку в ПОЛНОЙ родительской цепочке P (`page-permission.repo.ts:400-410`) — причём **рекурсия permission-CTE безгранична по глубине и без `deletedAt`-фильтра**, тогда как breadcrumb-CTE ограничен `MAX_PAGE_TREE_DEPTH`=10000 + `deletedAt IS NULL`. Значит permission-обход — НАДмножество breadcrumb-обхода: любой ограниченный предок, попадающий в крошки, уже прошёл permission-проверку. Cross-space невозможен (`parentPageId` структурно не пересекает `spaceId` на всех трёх путях записи). Неограниченный предок в том же space виден участнику по CASL `Read`. Итог: пользователь, видящий P, доказуемо вправе видеть каждого предка из цепочки → «вижу P, но не вижу предка A» невозможно. **Важно (расхождение с формулировкой в теле #471):** исходный вариант «space-membership даёт видимость ВСЕХ страниц» — фактически НЕВЕРЕН (ограниченные подписи внутри member-space реально скрывают страницы). Настоящая причина отсутствия утечки — нисходящее наследование (выше), а не «membership = всё видно». JSDoc это явно дезавуирует и сужает гарантию до «видишь потомка ⇒ вправе видеть предков». ## How verified - Инвариант проверен по реальному коду звено-за-звеном (validateCanView / canUserEditPage bool_and / глубинные границы обоих CTE / cross-space на 3 путях записи / deletedAt-асимметрия) — герметичен, глубокий ограниченный предок ускользнуть не может (permission-CTE глубже breadcrumb-CTE). - `git diff` — только комментарии/пустые строки, ровно `page.service.ts` + `page.controller.ts`; `tsc -p apps/server` не затронут (кроме пред-сущ. drawio `import.meta`). node_modules 0. - Внутренний цикл: 1 проход (документация) + адверсариальный ре-ревью, специально проверивший security-инвариант против кода (потенциальный BLOCKER — permission-CTE мельче breadcrumb — опровергнут: он безграничен) → APPROVE. ## Осознанная развилка Документировал как **safe-by-construction (утечки нет)**, а не «accepted title-only exposure» — потому что премиса утечки доказуемо невозможна, а не «терпимо мала». Если предпочтёшь формулировку-хедж «принятая экспозиция» — скажу кодеру, поправит; но truthful-вариант — именно by-construction. ## Checklist - [x] #471 закрыт (документирование, по решению владельца); логика не менялась - [x] comment-only; вне scope ничего; node_modules нет
agent_coder added 1 commit 2026-07-12 06:04:04 +03:00
getPageBreadCrumbs returns the full ancestor chain filtered only by
deletedAt; the /breadcrumbs endpoint validates validateCanView on the
target page only. Document why this is safe rather than an accepted leak:
page restrictions inherit down the tree, so viewing a page implies the
right to view every ancestor (canUserAccessPage checks the full ancestor
chain). A hidden ancestor would already hide the target itself, making
per-ancestor filtering redundant. Owner decision: document, no logic change.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
agent_coder added the review/needs label 2026-07-12 06:04:05 +03:00
Collaborator

Ревью — документация breadcrumb-permission (#471). Вердикт: approved

Comment-only (+28, логика не тронута). Документируемый инвариант безопасности проверен против кода (сам + агент, независимо) — точен, не завышен. Эскалаций нет.

📋 Проверка doc-vs-code

Заявление дока: getPageBreadCrumbs отдаёт всю цепочку предков без пофамильной permission-фильтрации, и это НЕ утечка title, т.к. ограничения наследуются ВНИЗ: чтобы видеть целевую страницу, вызывающий уже обязан иметь доступ к КАЖДОМУ ограниченному предку.

Сверено:

  1. Permission-код реально это делаетcanUserEditPage (page-permission.repo.ts:383) строит WITH RECURSIVE ancestors от цели вверх по parent_page_id, bool_and(pp.id IS NOT NULL) (:410) → false, если у ЛЮБОГО ограниченного предка нет permission-строки. Полная цепочка ограниченных предков, не только прямой родитель/цель. validateCanViewcanUserAccessPage→это. Описание дока точное.
  2. Subset-аргумент держится (безопасное направление): обе CTE идут по тому же ребру parent_page_id от той же цели, но breadcrumb-CTE накладывает СТРОГО больше фильтров (deletedAt IS NULL на anchor+recursive, depth < MAX_PAGE_TREE_DEPTH), а permission-CTE — НИ deletedAt, НИ depth-бонд. Значит breadcrumb-предки ⊆ permission-проверенные. Крайние случаи (soft-deleted середина цепи → breadcrumb ОБРЫВАЕТСЯ там, отдаёт короче; cross-space parent → оба покрывают; цикл → permission безграничен, покрывает ≥) — нет случая, где breadcrumb вернёт предка, которого permission не проверил.
  3. Аудит вызывающих (git grep): единственный user-facing — /breadcrumbs (page.controller.ts:828) валидирует validateCanView(page,user) на ЦЕЛИ первым (:818); внутренний (page.service.ts:1009) — move cycle-check, читает только ancestor.id, титлы юзеру не отдаёт. Все прочие — тесты.
  4. Точность: оговорки дока верны — «может-видеть-потомка ⇒ может-видеть-предков, НЕ членство-видит-всё» (restricted-поддеревья прячут страницы от членов); «контент всё равно гейтится getPage/getNode re-check» (getPage//info перепроверяет validateCanViewWithPermissions).

Docs-only, гейт не нужен (комментарии не ломают tsc). Независимо подтверждено субагентом. База develop 03eafa6c. Готово к мержу.

## Ревью — документация breadcrumb-permission (#471). Вердикт: **approved** ✅ Comment-only (+28, логика не тронута). Документируемый инвариант безопасности **проверен против кода** (сам + агент, независимо) — точен, не завышен. Эскалаций нет. <details> <summary>📋 Проверка doc-vs-code</summary> **Заявление дока:** getPageBreadCrumbs отдаёт всю цепочку предков без пофамильной permission-фильтрации, и это НЕ утечка title, т.к. ограничения наследуются ВНИЗ: чтобы видеть целевую страницу, вызывающий уже обязан иметь доступ к КАЖДОМУ ограниченному предку. **Сверено:** 1. **Permission-код реально это делает** — `canUserEditPage` (`page-permission.repo.ts:383`) строит `WITH RECURSIVE ancestors` от цели вверх по `parent_page_id`, `bool_and(pp.id IS NOT NULL)` (`:410`) → false, если у ЛЮБОГО ограниченного предка нет permission-строки. Полная цепочка ограниченных предков, не только прямой родитель/цель. `validateCanView`→`canUserAccessPage`→это. Описание дока точное. 2. **Subset-аргумент держится (безопасное направление):** обе CTE идут по тому же ребру `parent_page_id` от той же цели, но breadcrumb-CTE накладывает СТРОГО больше фильтров (`deletedAt IS NULL` на anchor+recursive, `depth < MAX_PAGE_TREE_DEPTH`), а permission-CTE — НИ deletedAt, НИ depth-бонд. Значит breadcrumb-предки ⊆ permission-проверенные. Крайние случаи (soft-deleted середина цепи → breadcrumb ОБРЫВАЕТСЯ там, отдаёт короче; cross-space parent → оба покрывают; цикл → permission безграничен, покрывает ≥) — нет случая, где breadcrumb вернёт предка, которого permission не проверил. 3. **Аудит вызывающих (`git grep`):** единственный user-facing — `/breadcrumbs` (`page.controller.ts:828`) валидирует `validateCanView(page,user)` на ЦЕЛИ первым (`:818`); внутренний (`page.service.ts:1009`) — move cycle-check, читает только `ancestor.id`, титлы юзеру не отдаёт. Все прочие — тесты. 4. **Точность:** оговорки дока верны — «может-видеть-потомка ⇒ может-видеть-предков, НЕ членство-видит-всё» (restricted-поддеревья прячут страницы от членов); «контент всё равно гейтится getPage/getNode re-check» (`getPage`/`/info` перепроверяет `validateCanViewWithPermissions`). <sub>Docs-only, гейт не нужен (комментарии не ломают tsc). Независимо подтверждено субагентом. База develop `03eafa6c`. Готово к мержу.</sub> </details>
agent_reviewer added review/approved and removed review/needs labels 2026-07-12 06:29:07 +03:00
vvzvlad merged commit 11eb87d58a into develop 2026-07-12 17:33:59 +03:00
Sign in to join this conversation.