MBL
Go / Проекты / CarDealership / Car Dealership — безопасность и отказоустойчивость
Go сложный

Car Dealership — безопасность и отказоустойчивость

projectsjavamicroservicesgrpcsecurity

Архитектурная схема (archify) →

Два инженерных решения на реальном коде: кто может смотреть чужой заказ, и что происходит, когда storage-service не отвечает.

Ролевой доступ: HTTP-уровень плюс точечная проверка

SecurityConfig — stateless JWT resource server, свой JSON вместо стандартной страницы ошибки на 401/403:

.oauth2ResourceServer(oauth2 -> oauth2
    .jwt(jwt -> jwt.jwtAuthenticationConverter(keycloakJwtConverter))
    .authenticationEntryPoint((request, response, ex) -> {
        writeError(response, HttpServletResponse.SC_UNAUTHORIZED, "Unauthorized", ex.getMessage());
    })
)

Этого достаточно для ролей вроде MANAGER/ADMIN — они проверяются декларативно на уровне HTTP. Но «клиент видит только свои заказы» ролью не выразить — это не про роль, а про конкретную запись. Для этого — OrderSecurityService, отдельный бин с методом isOwner:

@Component("orderSecurity")
public class OrderSecurityService {
    public boolean isOwner(UUID orderId, Authentication authentication) {
        Jwt jwt = (Jwt) authentication.getPrincipal();
        String email = jwt.getClaim("email");
        if (email == null) return false;
        return orderRepository.findById(orderId)
                .map(order -> email.equals(order.getClient().getEmail()))
                .orElse(false);
    }
}

Достаёт email прямо из JWT-claim (не из БД пользователей — токен уже содержит всё нужное), сравнивает с email клиента заказа. Подключается через @PreAuthorize("@orderSecurity.isOwner(#orderId, authentication)") на нужном методе — то есть проверка живёт рядом с бизнес-логикой, а не размазана по контроллеру.

gRPC: дедлайн в конфиге, деградация в коде

Дедлайн — не хардкод в клиенте, а настройка application.yml:

grpc:
  client:
    storage-service:
      address: static://localhost:9091
      deadline: 5000

Сам клиент просто вызывает stub и ловит StatusRuntimeException:

public List<CarResponse> getAvailableCars() {
    try {
        GetAvailableCarsResponse response = stub.getAvailableCars(...);
        return response.getCarsList().stream().map(CarProtoMapper::toResponse).toList();
    } catch (StatusRuntimeException e) {
        throw CarProtoMapper.mapError(e);
    }
}

Вся логика деградации — в одном месте, CarProtoMapper.mapError:

static RuntimeException mapError(StatusRuntimeException e) {
    return switch (e.getStatus().getCode()) {
        case NOT_FOUND -> new EntityNotFoundException(e.getStatus().getDescription());
        case UNAVAILABLE, DEADLINE_EXCEEDED -> new ServiceUnavailableException("StorageService is unavailable");
        default -> new RuntimeException("gRPC error: " + e.getStatus());
    };
}

Если storage-service не отвечает 5 секунд (настроенный дедлайн) или физически недоступен — клиент REST-API order-service получает не зависшую ручку и не голый gRPC-статус, а понятный доменный ServiceUnavailableException, который дальше маппится в HTTP 503. Один switch на весь код деградации — не разбросанные по вызовам try/catch с одинаковой логикой в каждом месте.

Если спросят на собесе "а что если storage упадёт совсем": ответ ровно этот код — клиент order-service получит 503 максимум через 5 секунд, не зависнет, и это единая точка обработки, а не догадка на каждом конкретном вызове.

Самопроверка 0 / 2
Понимаю, как OrderSecurityService проверяет, что клиент видит только свой заказ
Знаю, как UNAVAILABLE/DEADLINE_EXCEEDED от gRPC превращается в осмысленный 503, а не зависшую ручку
Как усвоено?