Status: Accepted
Date: 2026-04-18
В agenda-сервисе есть секретные вопросы — вопросы, доступ к которым ограничен определённым пользователям (например, решения по крупным сделкам с ограниченным кругом членов комитета). Это row-level security.
Agenda реализует это через QuestionConverter.toProtectedOrSelf():
isCurrentUserHasAccess(questionId) для запрашивающего юзера = false, agenda не отдаёт реальные данные вопроса. Вместо этого возвращает stub: id=UUID.randomUUID(), questionName="PROTECTED_QUESTION", status=PROTECTED, secret=true, accessForbidden=true.Protocol-service в референсе делает проверку в Phase 1:
questions.stream()
.filter(QuestionDto::isAccessForbidden)
.findAny()
.ifPresent(q -> { throw RestException.notFound("Access denied..."); });
Две проблемы в реф-реализации:
notFound (HTTP 404) — семантически неверно, это Forbidden (403). Клиент по 404 не поймёт, что это security-отказ.accessForbidden утекает в domain layer (в референсе — часть QuestionDto, которая проходит через весь pipeline).Бизнес-смысл проверки:
Если инициатор генерации не имеет доступа к части вопросов повестки — генерировать нельзя:
Проверка доступа делается на ACL-границе, не в domain-слое.
Question не содержит accessForbiddenDomain value object — чистый: только бизнес-атрибуты (id, agendaId, name, committeeCode, ...). Security-флаги — не его ответственность.
AgendaMapperВ integration/agenda/AgendaMapper при маппинге AgendaFeignDto → Agenda:
static Agenda toDomain(AgendaFeignDto dto) {
// RLS check — если хоть один вопрос помечен accessForbidden → сразу 403
for (var questionDto : dto.questions()) {
if (questionDto.accessForbidden()) {
throw new DomainException.Forbidden(
"access to protected question in agenda " + dto.id() + " is denied"
);
}
}
// safe to map — все вопросы доступны
return new Agenda(..., questions, ...);
}
Это fail-fast на границе: в domain никогда не попадёт "forbidden" вопрос. Весь pipeline выше работает с гарантией что все Question — легитимные.
DomainException.ForbiddenДобавляем в sealed hierarchy (core/domain/error/DomainException.java):
public static final class Forbidden extends DomainException {
private final String reason;
public Forbidden(String reason) {
super("Access forbidden: " + reason);
this.reason = reason;
}
public String reason() { return reason; }
}
GlobalExceptionHandler получает новый case:
Forbidden → HTTP 403, ProblemDetail type=/problems/forbidden, title="Access forbidden".Auto-generation через sync v1 вызывается от service-account'а agenda-state-machine. Предположительно этот аккаунт имеет full access — agenda не ставит accessForbidden=true при его запросах. Но нужно подтверждение. Записано в questions-to-business.md.
Если подтвердится — наш код не требует differentiation: проверка в ACL работает одинаково для любого вызова, просто для service-account она никогда не сработает (agenda отдаст честные данные).
Positive:
AgendaProvider.loadSnapshot/getAgenda защищены автоматически.Negative:
DomainException один новый подтип — небольшое расширение API.operation/resource. Пока YAGNI, эволюционируем при необходимости.accessForbidden в domain Question: грязнит value-object, требует проверки в каждом consumer'е (генератор должен помнить про forbidden). Отвергнуто.NotFound как в реф-коде: сохраняет legacy-поведение, но семантически неверно. Легаси agenda-state-machine скорее всего всё равно не различает 403 vs 404 в sync v1 — но для task-based и frontend 403 явно лучше (фронт может показать "нет доступа" баннер).accessForbidden=true