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
@@ -49,6 +49,19 @@ public class ScopeContext {
return EnumSet.of(DataScope.PUBLIC);
}
/**
* True если пользовательский scope видит данные {@code dataScope}.
* Использует {@link DataScope#visibleTo(DataScope)}: PUBLIC видит PUBLIC,
* INTERNAL видит PUBLIC+INTERNAL, RESTRICTED видит всё.
*/
public boolean canAccess(DataScope dataScope) {
if (dataScope == null) return true;
for (DataScope userScope : currentScopes()) {
if (dataScope.visibleTo(userScope)) return true;
}
return false;
}
public Set<DataScope> resolveForRead(String queryAsScope) {
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
if (auth instanceof JwtAuthenticationToken jwtAuth) {