fix(auth): scope-фильтр на writer-side endpoints (Phase 2c security gap)

ScopeContext.canAccess(DataScope) — единая точка проверки доступа,
использует DataScope.visibleTo (PUBLIC видит PUBLIC, INTERNAL видит
PUBLIC+INTERNAL, RESTRICTED видит всё).

DictionaryRecordController:
- requireReadAccess на GET /{businessKey} и /{businessKey}/history
  → 404 dictionary_not_found если scope словаря недоступен (намеренно
  скрываем существование INTERNAL/RESTRICTED данных, защита от
  enumeration)
- requireWriteAccess на POST/PUT/DELETE
  → 403 scope_insufficient если scope недостаточен для записи

DictionaryDefinitionController:
- list() фильтрует список по canAccess (PUBLIC-юзер не видит INTERNAL
  словари в каталоге)
- get() → 404 если недоступен
- create()/update() → 403 если новый scope выше доступа юзера
  (защита от scope escalation через PUT)

AuthE2ETest.publicUser_cannotSeeInternalRecords: TODO snять, expectation
обновлён на isNotFound() (раньше было isOk + комментарий о gap).

До этого fix: PUBLIC-юзер мог GET /api/v1/dictionaries/{n}/records/{k}
INTERNAL-словарь на writer-side. Read-api (отдельный модуль) фильтровал
корректно. Issue найден через AuthE2ETest в Phase 2c.

Все 6 AuthE2ETest и 24 unit-теста rest-api зелёные.
This commit is contained in:
Zimin A.N.
2026-05-05 22:21:21 +03:00
parent 721607c246
commit 01cca3a6f4
4 changed files with 94 additions and 13 deletions
@@ -113,16 +113,13 @@ class AuthE2ETest {
.content(om.writeValueAsBytes(recBody)))
.andExpect(status().is2xxSuccessful());
// ⚠️ ИЗВЕСТНОЕ ПОВЕДЕНИЕ: writer-side endpoint /api/v1/dictionaries/{n}/records/{k}
// НЕ фильтрует по scope юзера. Public-юзер сейчас МОЖЕТ прочитать INTERNAL.
// Read-api (/api/v1/{n}/records/...) — фильтрует. Writer считается admin-only.
//
// Тест документирует текущее поведение. После security review (см. issue
// #scope-writer-filter) заменить на 403/404 expectation.
// Writer endpoint фильтрует по scope: PUBLIC-юзер не видит INTERNAL словарь.
// Возвращаем 404 (а не 403) — намеренно скрываем существование INTERNAL
// данных, как делает read-api. Защита от enumeration.
String publicToken = JwtTestSupport.signJwt(List.of("ordinis:client:public"), "user-public");
mvc.perform(get("/api/v1/dictionaries/{n}/records/{k}", dictName, key)
.header(HttpHeaders.AUTHORIZATION, "Bearer " + publicToken))
.andExpect(status().isOk()); // TODO: должно быть isForbidden() после fix scope filter
.andExpect(status().isNotFound());
}
@Test