Propagace identity uživatele v Axon 5 query handlerech

Během interního testování nové verze jsme narazili na záhadný bug: vlastníci neviděli své vlastní entity na správcovské stránce. Chybová zpráva byla jednoduše “Unable to load” — přestože záznam v databázi prokazatelně existoval.
Naše aplikace používá Axon Framework 5 se Spring WebFlux a Netflix DGS (GraphQL). Query handler dělal něco zdánlivě rozumného — kontroloval, zda je autentizovaný uživatel vlastníkem, před vrácením INACTIVE záznamů:
@QueryHandler
public Mono<Venue> handle(FindVenueByIdQuery query) {
return venueRepository.findByIdWithAllRelationships(query.venueId().toString())
.filterWhen(venue -> canViewVenueReactive(venue))
.map(VenueNode::toDomain);
}
private Mono<Boolean> canViewVenueReactive(VenueNode venue) {
return ReactiveSecurityContextHolder.getContext()
.map(ctx -> ctx.getAuthentication().getName())
.map(userId -> isOwner(venue, userId) || venue.isActive())
.defaultIfEmpty(venue.isActive());
}Vidíte ten problém?
Proč je ReactiveSecurityContextHolder vždy prázdný uvnitř Axon handlerů
Reaktivní security context ve Spring WebFlux se propaguje přes Reactor Context — mechanismus vázaný na subscribera a připojený k reaktivnímu řetězci. Funguje skvěle v rámci jednoho reaktivního pipeline. Ale Axon queries přinášejí dvě kritická přerušení.
Přerušení 1: Přeskok scheduleru
Náš původní ReactiveQueryGateway obaloval Axon QueryGateway (který vrací CompletableFuture) do Mono s dedikovaným blokujícím schedulerem:
return Mono.fromFuture(() -> queryGateway.query(query, responseType))
.subscribeOn(blockingScheduler);subscribeOn(blockingScheduler) přesune vykonávání do jiného thread poolu. Zatímco Reactor Hooks.enableAutomaticContextPropagation() zvládá propagaci MDC přes přeskoky schedulerů, security context vyžaduje explicitní obnovu ThreadLocal — což Axon nedělá.
(Jak se ukáže dále, tento přeskok scheduleru je v Axon 5 zbytečný — ale hranice toFuture() níže rozbíjí propagaci kontextu tak jako tak.)
Přerušení 2: Hranice toFuture()
I kdyby security context přežil přeskok scheduleru, Axon interně volá .toFuture() na Mono vráceném z query handlerů. To vytváří nezávislou subscription — kompletně oddělený reaktivní řetězec, který nemá žádné povědomí o kontextu původního subscribera.
Výsledek: ReactiveSecurityContextHolder.getContext() uvnitř jakéhokoli Axon query handleru vrací Mono.empty(). Vždy.
Tři přístupy které jsme zvážili
Přístup A: Přidat callerId do query objektů
Rychlý fix: prostě vložit user ID do query záznamu.
public record FindVenueByIdQuery(VenueId venueId, String callerId) {}Zamítli jsme to. Query objekty jsou doménové koncepty — vyjadřují co chcete najít, ne kdo se ptá. Znečišťování každého query autentizačními záležitostmi porušuje CQRS principy a vytváří leaky abstrakci.
Přístup B: SecurityContext ThreadLocalAccessor
Zaregistrovat security context jako ThreadLocalAccessor s Micrometer ContextRegistry:
ContextRegistry.getInstance().registerThreadLocalAccessor("security",
() -> SecurityContextHolder.getContext(),
SecurityContextHolder::setContext,
SecurityContextHolder::clearContext);I toto jsme zamítli. Axon 5 se záměrně odklonil od thread-local vzorů. Hranice toFuture() tento přístup stále rozbíjí — ThreadLocalAccessors pomáhají jen s subscribeOn přeskoky v rámci jednoho Reactor řetězce, ne přes nezávislé subscriptions.
Přístup C: Axon MetaData (Co jsme zvolili)
Axon má prvotřídní mechanismus pro propagaci průřezových záležitostí se zprávami: MetaData. Každá Axon zpráva (příkazy, dotazy, události) může nést Map<String, String> metadat vedle payloadu.
Toto je CQRS-správný přístup:
- Query objekty zůstávají čistými doménovými objekty — žádné autentizační záležitosti
- Identita cestuje SE zprávou — ne jako ambient thread-local stav
- Funguje nezávisle na modelu threadingu — přeskoky schedulerů,
toFuture(), serializační hranice… nic z toho nevadí
Řešení: Auth-injecting Query Gateway
Klíčový API insight v Axon 5
QueryGateway v Axon 5 přímo neexponuje parametr pro metadata (na rozdíl od CommandGateway.send(command, metadata)).
Ale existuje workaround. Když se podíváme na DefaultQueryGateway.asQueryMessage():
private <Q, R> QueryMessage<Q, R> asQueryMessage(Q query, Class<R> responseType) {
if (query instanceof QueryMessage<?, ?> queryMessage) {
return (QueryMessage<Q, R>) queryMessage; // Použije se přímo!
}
// ... jinak obalí do GenericQueryMessage
}Pokud je query objekt již QueryMessage, Axon ho použije přímo — i s metadaty. Takže zkonstruujeme GenericQueryMessage s připojenými metadaty a předáme ho gateway.
Implementace
Rozšířili jsme náš existující ReactiveQueryGateway — jediné místo kudy procházejí všechna query volání:
@Component
public class DefaultReactiveQueryGateway implements ReactiveQueryGateway {
private final QueryGateway queryGateway;
private final ReactiveAuthenticationSupplier authenticationSupplier;
@Override
public <R, Q> Mono<R> query(Q query, Class<R> responseType) {
return buildAuthMetadata()
.flatMap(metadata -> {
Object queryWithMetadata = wrapWithMetadata(query, metadata);
return Mono.fromFuture(
() -> queryGateway.query(queryWithMetadata, responseType));
});
}
private Mono<Metadata> buildAuthMetadata() {
return authenticationSupplier.getAuthentication()
.map(auth -> {
Metadata metadata = Metadata.with("userId", auth.getName());
String roles = auth.getAuthorities().stream()
.map(GrantedAuthority::getAuthority)
.collect(Collectors.joining(","));
if (!roles.isEmpty()) {
metadata = metadata.and("roles", roles);
}
return metadata;
})
.defaultIfEmpty(Metadata.emptyInstance());
}
private <Q> Object wrapWithMetadata(Q query, Metadata metadata) {
if (metadata.isEmpty()) {
return query;
}
MessageType messageType = new MessageType(query.getClass());
return new GenericQueryMessage(
new GenericMessage(messageType, query, Map.copyOf(metadata)),
null
);
}
}Klíčové pořadí: buildAuthMetadata() běží v DGS reaktivním řetězci, kde ReactiveSecurityContextHolder funguje. Výsledek je zachycen v flatMap closuře před tím, než Mono.fromFuture() překročí asynchronní hranici.
Strana query handleru
Query handlery konzumují metadata přes @MetadataValue parameter injection:
@QueryHandler
public Mono<Venue> handle(FindVenueByIdQuery query,
@MetadataValue(value = "userId", required = false) String callerId,
@MetadataValue(value = "roles", required = false) String roles) {
return venueRepository.findByIdWithAllRelationships(query.venueId().toString())
.filter(venue -> canViewVenue(venue, callerId, roles))
.map(VenueNode::toDomain);
}
private boolean canViewVenue(VenueNode venue, String callerId, String roles) {
if (roles != null && roles.contains("ROLE_ADMIN")) {
return true;
}
if (callerId != null && venue.getCreatedBy() != null
&& venue.getCreatedBy().getId() != null
&& venue.getCreatedBy().getId().equals(callerId)) {
return true;
}
return venue.getStatusEnum() == VenueStatus.ACTIVE;
}Všimněte si: canViewVenue je nyní čistá funkce. Přijímá explicitní vstupy a vrací deterministický výsledek. Žádné Mono<Boolean>, žádný ReactiveSecurityContextHolder, žádný ambient stav. Je testovatelná, debugovatelná a správná z principu.
Query objekt zůstává čistý
public record FindVenueByIdQuery(VenueId venueId) {
public static FindVenueByIdQuery of(String id) {
return new FindVenueByIdQuery(VenueId.of(id));
}
}Žádné callerId. Žádné security záležitosti. Jen doménový dotaz.
Architektura v kostce
security context dostupný"] Extract["getAuthentication()"] Meta["Metadata.with(userId, roles)"] Wrap["GenericQueryMessage(query, metadata)"] Gateway["queryGateway.query()"] Future["Mono.fromFuture()
asynchronní hranice — kontext ztracen, nevadí!"] Bus["Axon QueryBus"] Handler["@QueryHandler"] UserId["@MetadataValue userId ✓"] Roles["@MetadataValue roles ✓"] DGS --> Extract --> Meta --> Wrap --> Gateway --> Future --> Bus --> Handler Handler --> UserId Handler --> Roles style DGS fill:#2d6a4f,color:#fff style Future fill:#9d4b00,color:#fff style UserId fill:#1b4332,color:#fff style Roles fill:#1b4332,color:#fff
Více než identita: Propagace rolí
Prvotní implementace propagovala jen userId. To vyřešilo problém viditelnosti pro vlastníky, ale vytvořilo subtilnější bug: administrátoři kteří nebyli vlastníky neviděli INACTIVE záznamy — ani v detailním zobrazení, ani v seznamu.
DGS vrstva používá @PreAuthorize("hasRole('ADMIN')") pro admin endpointy, takže GraphQL požadavek uspěje. Ale canViewVenue() v query handleru nedokázalo rozlišit administrátora od běžného uživatele.
Oprava: rozšířit metadata o role. Klíčový poznatek je, že pokud query handler potřebuje jakýkoli security context pro rozhodnutí, tento kontext musí cestovat jako metadata. Anotace @PreAuthorize a kontrola canViewVenue() slouží různým účelům:
@PreAuthorizeje brána — může tento uživatel tuto operaci vůbec zavolat?canViewVenue()je filtr — které výsledky má tento uživatel vidět?
Obě potřebují informace o rolích, ale přistupují k nim z různých vrstev. Gateway překlenuje tuto mezeru.
Zbavení se blokujícího scheduleru
Náš původní ReactiveQueryGateway používal subscribeOn(blockingScheduler) aby nezabíral Netty event-loop vlákna — rozumná opatrnost, pokud by queryGateway.query() mohl blokovat. Ale v Axon 5 se SimpleQueryBus je celá cesta query dispatchingu neblokující:
queryGateway.query()provádí lehkou synchronní práci — routing, obalení zprávy — a ihned vracíCompletableFuture- Query handler vrací
Mono<T>, který Axon konvertuje přestoFuture()— neblokující operace, která pouze propojí completion signál CompletableFuturese dokončí, když handler’sMonoemituje, na tom scheduleru, na kterém reaktivní řetězec již běžel
V celé této cestě není žádné blokující I/O. subscribeOn(blockingScheduler) pouze přidával zbytečný context switch — a o jednu pohyblivou část méně znamená o jednu věc méně, která může rozbít propagaci kontextu.
Upozornění: Pokud používáte AxonServerQueryBus (připojení k Axon Serveru), je zde serializace a gRPC I/O. I to je v Axon 5 převážně asynchronní, ale pokud zaznamenáte vyhladovění Netty vláken pod vysokou zátěží, omezený scheduler pro gateway volání může stále dávat smysl. Nejdříve profilujte — nepřidávejte ho preventivně.
Poučení
Zprávy by měly být soběstačné. V CQRS systému patří průřezové záležitosti do metadat zpráv, ne do reaktivního kontextu nebo thread-locals. Začněte s
userId, ale počítejte s rolemi a dalším security kontextem.Extrahujte kontext brzy, propagujte explicitně. DGS vrstva je poslední místo, kde je reaktivní security context dostupný. Extrahujte co potřebujete tam a předejte to dál — nespoléhejte na to, že přežije hranice frameworku.
Gateway vrstvy jsou silné choke pointy. Modifikací jediné třídy jsme transparentně přidali auth injekci ke všem query voláním bez úpravy jakéhokoli volajícího. Infrastrukturní záležitosti patří do infrastrukturního kódu.
Čisté funkce vítězí nad reaktivními context lookups. Nahrazení
canViewVenueReactive()(vracející Mono, závislé na kontextu) zacanViewVenue()(vracející boolean, explicitní vstupy) udělalo kód testovatelnějším, debugovatelnějším a prokazatelně správným.API Axon 5 má mezery — ale workaroundy existují.
QueryGatewaynepodporuje parametry metadat přímo, ale kontrolainstanceof QueryMessagevasQueryMessage()poskytuje čistý workaround.
Poznámka k migraci na Axon 5
Pokud migrujete z Axon 4:
@MetaDataValue(Axon 4) je nyní@MetadataValue(Axon 5) — všimněte si malého ’d'- Balíček se změnil z
org.axonframework.messaging.annotationnaorg.axonframework.messaging.core.annotation MessageTypeje nyní record s konstruktoremMessageType(Class<?>)- Signatura konstruktoru
GenericMessage:GenericMessage(MessageType, Object, Map<String, String>)
Shrnutí
Reaktivní security context nepřežije hranice Axon query handlerů v Spring WebFlux aplikacích. Místo boje s frameworkem pomocí thread-local propagačních hacků se opřete o vlastní messaging model Axonu: extrahujte identitu a role na okraji, připojte je jako MetaData a čtěte je pomocí @MetadataValue ve svých handlerech. Query objekty zůstanou čisté, handlery se stanou čistými funkcemi a oprava je transparentní pro každého volajícího v systému.
