Drei Stellen, an denen Vaadin in den Kern reicht
Wer die Implementierung aus Teil 1 daraufhin durchsieht, an welchen Stellen Vaadin in den vermeintlich generischen Kern eindringt, wird eine aufwendige Suche erwarten. Tatsächlich ist die Liste kurz: drei Klassen, drei Methoden, drei Symptome. Mehr ist es nicht. Diese Knappheit ist die eigentliche Pointe der Diagnose. Die ursprüngliche Architektur hat die Trennlinien bereits weitgehend gezogen, ohne sie als solche zu benennen. Was Teil 2 leistet, ist deshalb nicht Reparatur, sondern Vollendung.
Die erste Stelle liegt in der Access-Klasse selbst. Access ist ein versiegelter Wertetyp, der das Ergebnis einer Autorisierungsentscheidung repräsentiert — gewährt, umgeleitet, mit einer Fehlerseite beantwortet. Dieser Wert beschreibt jedoch nicht nur, was geschehen soll, sondern führt es auch aus:
public sealed interface Access {
void exec(BeforeEnterEvent event);
record Reroute(String target) implements Access {
public void exec(BeforeEnterEvent event) {
event.rerouteTo(target);
}
}
// weitere Varianten: Granted, RerouteToError, …
}
Hier verschmelzen zwei Verantwortlichkeiten in einem Typ. Access ist gleichzeitig die Beschreibung der Entscheidung und deren Vollzug. Der Wert kennt den BeforeEnterEvent, weil andernfalls niemand vorhanden wäre, der forwardTo, rerouteTo oder rerouteToError aufrufen würde. Genau diese Verschmelzung hat das letzte Kapitel von Teil 1 als didaktisch tragend markiert, das nun Teil 2 einläutet.
Die zweite Stelle ist eine Methodensignatur:
public interface AccessEvaluator<T extends Annotation> {
Access evaluate(Location location, Class<?> navigationTarget, T annotation);
}
Auch hier ist die Beobachtung leise. Der Evaluator selbst trifft eine reine Entscheidung — er prüft Rollen, vergleicht sie mit der Annotation, gibt einen Wert zurück. In dieser Logik kommt Vaadin nicht vor. Vaadin steht ausschließlich im Eingabeparameter: Location ist eine Klasse aus com.vaadin.flow.router. Damit ist der Evaluator in seiner gesamten Substanz Vaadin-frei, an genau einem Punkt jedoch durch seinen Aufrufkontext an Vaadin gebunden. Der Vertrag verlangt einen Vaadin-Typ, ohne dass die Implementierung ihn tatsächlich benötigt.
Die dritte Stelle liegt in der Subject-Verwaltung:
public final class SessionAccessor {
public static <T> Optional<T> currentSubject(Class<T> type) {
VaadinSession session = VaadinSession.getCurrent();
if (session == null) return Optional.empty();
return Optional.ofNullable(session.getAttribute(type));
}
}
Hier ist die Verbindung zum Framework offen erklärt. Das Subject lebt in der VaadinSession, und der Zugriff erfolgt über den statischen getCurrent()-Aufruf. Die Klasse verbirgt nicht, was sie ist. Genau das macht sie zum dritten klar identifizierbaren Schnittpunkt — kein verstecktes Leck, sondern eine bewusst gesetzte Brücke, die sich für Teil 2 in einen abstrakteren Vertrag überführen lässt.
Diese drei Stellen haben eine bemerkenswerte Eigenschaft: Sie sind die einzigen. Wer das api-Paket aus Teil 1 durchsieht, findet rund zehn Typen, die bereits ohne jeden Vaadin-Begriff auskommen — AuthenticationService und AuthorizationService mit ihren Providern, HasRoles, RoleName, HasPermissions, PermissionName, AccessEvaluatorProvider, AnnotationAccessEvaluatorPair, AccessPermittedException. Diese Typen sind nicht versehentlich neutral; sie sind es, weil die ursprüngliche Architektur zwischen fachlicher Aussage und technischer Anbindung sauber unterschieden hat. Die drei Schnittpunkte sind entsprechend keine Schwachstellen, sondern die letzten Stellen, an denen diese Trennung noch nicht ausformuliert ist.
Die folgenden drei Kapitel behandeln jeweils einen Schnitt. Kapitel 2 löst die Verschmelzung in Access auf, indem der ausführende Wertetyp in einen reinen Wertetyp umgewandelt wird; die Übersetzung in Vaadin-Aufrufe erfolgt im Adapter. Kapitel 3 ersetzt den Location-Parameter im Evaluator durch einen neutralen Kontext und zeigt, dass derselbe Konstruktionsschritt auf der Authentication-Seite ein zweites Mal durchgeführt wurde — eine Symmetrie, die im Code aus Teil 1 noch nicht sichtbar ist. Kapitel 4 hebt schließlich den SessionAccessor auf einen SubjectStore-Vertrag, dessen Vaadin-Implementierung im Adapter lebt und sich in das bereits etablierte SPI-Muster einfügt, das die Bibliothek für die übrigen Service-Punkte verwendet.
Die Decision als Wert
Der erste der drei Schnitte aus Kapitel 1 betrifft die Access-Klasse selbst. Sie war in Teil 1 ein versiegelter Wertetyp und zugleich eine Anweisung — sie beschrieb das Ergebnis der Autorisierung und führte es im selben Atemzug aus. Die Auflösung dieser Verschmelzung ist zugleich der einfachste und der lehrreichste der drei Umbauten. Einfach, weil sie an der Logik nichts ändert; die fünf Fälle, die Access zu beschreiben hatte, bleiben dieselben fünf. Lehrreich, weil sie zeigt, wie wenig Code es manchmal kostet, eine architektonisch tragende Linie sichtbar zu machen.
Die neue Form trägt den Namen AccessDecision. Sie lebt im Modul security-core, kennt keinen Vaadin-Typ mehr und ist als versiegelte Schnittstelle mit fünf Record-Varianten ausgeführt:
public sealed interface AccessDecision
permits AccessDecision.Granted,
AccessDecision.Reroute,
AccessDecision.RerouteToError,
AccessDecision.RerouteWithParameter,
AccessDecision.RerouteWithParameters {
record Granted() implements AccessDecision {}
record Reroute(String target, boolean asForward) implements AccessDecision {}
record RerouteToError(Class<? extends Exception> type, String message)
implements AccessDecision {}
record RerouteWithParameter<T>(String target, T parameter) implements AccessDecision {}
record RerouteWithParameters<T>(String target, List<T> parameters) implements AccessDecision {}
static AccessDecision granted() { return new Granted(); }
static AccessDecision reroute(String target, boolean asForward) {
return new Reroute(target, asForward);
}
// weitere Factory-Methoden: forward, reroute, rerouteToError,
// sowie die Kompatibilitätsformen denied und deniedWithError
}
Dieselben fünf Fälle wie in Teil 1, jedoch fehlt die exec(BeforeEnterEvent)-Methode. Bei ihr fehlen auch die Verweise auf forwardTo, rerouteTo und rerouteToError. Was zurückbleibt, ist eine Beschreibung — gewährt, umgeleitet, mit einer Fehlerseite beantwortet, mit oder ohne Parameter. Diese Beschreibung lässt sich jetzt in einem Test direkt mit assertInstanceOf(Granted.class, decision) prüfen, ohne dass eine Vaadin-Laufzeit benötigt wird. Auffällig sind die zusätzlichen statischen Factory-Methoden mit sprechenden Namen: granted, reroute, forward, rerouteToError. Sie bilden die fachliche Lesbarkeit der Decision an der Aufrufstelle ab und ergänzen die rein technischen Konstruktoren der Records. Die Formen denied und deniedWithError bewahren dabei die Begriffe aus Teil 1 als Aliase und vereinfachen damit die Migration bestehender Aufrufer.
Was im Kern verschwindet, taucht im Adapter wieder auf — und nirgends sonst. Die Klasse VaadinAccessDecisionMapper im Modul security-vaadin ist die einzige Stelle, an der AccessDecision auf Vaadin-Aufrufe trifft:
public final class VaadinAccessDecisionMapper {
public void apply(AccessDecision decision, BeforeEnterEvent event) {
switch (decision) {
case AccessDecision.Granted() -> { /* nichts zu tun */ }
case AccessDecision.Reroute(String route, boolean asForward) -> {
if (asForward) event.forwardTo(route);
else event.rerouteTo(route);
}
case AccessDecision.RerouteToError(Class<? extends Exception> type, String msg) -> {
if (msg != null) {
try {
event.rerouteToError(type.getDeclaredConstructor().newInstance(), msg);
} catch (ReflectiveOperationException e) {
event.rerouteToError(new RuntimeException("Access denied", e), msg);
}
} else {
event.rerouteToError(type);
}
}
case AccessDecision.RerouteWithParameter<?> r ->
event.rerouteTo(r.target(), r.parameter());
case AccessDecision.RerouteWithParameters<?> r ->
event.rerouteTo(r.target(), r.parameters());
}
}
}
Hier ist die Inversion sichtbar, die Kapitel 1 angekündigt hat. Die Vaadin-Aufrufe sind nicht verschwunden — sie haben jetzt einen Ort. Dieser Ort ist eine einzige Klasse, deren Aufgabe darin besteht, einen Wert in eine Handlung zu übersetzen. Der Mapper kennt keine Logik, keine Rollen, keine Subjects; er nimmt die fünf Decision-Fälle entgegen und wählt für jeden Fall den passenden Vaadin-Aufruf. Das vollständige Pattern Matching auf einer versiegelten Schnittstelle macht die Vollständigkeit zudem prüfbar — der Compiler weist eine fehlende Variante zurück, sodass spätere Erweiterungen der Decision auch im Mapper sichtbar werden müssen.
Was leistet diese Trennung jenseits der reinen Codeästhetik? Erstens lässt sich der Kern vollständig isoliert testen. Wer einen Evaluator schreibt, kann sein Verhalten ohne Vaadin-Laufzeit, ohne MockVaadinSession, ohne BeforeEnterEvent-Stub direkt gegen die Decision prüfen. Zweitens lassen sich alternative Entscheidungen hinzufügen, ohne den Adapter sofort zu verändern — der Compiler markiert die Stelle. Drittens, und das wird in Teil 3 die wichtigste Konsequenz sein, dieselbe AccessDecision kann von einem zweiten Mapper übersetzt werden, der nicht in Vaadin-Aufrufe abbildet, sondern in HTTP-Antworten. Granted bleibt Granted, ob es zu einem forwardTo oder zu einem 200 OK führt; die Entscheidung selbst weiß davon nichts. Diese Eigenschaft ist heute schon vorbereitet, ohne dass Teil 2 sie einlösen müsste.
Damit ist der erste der drei Schnitte vollzogen. Kapitel 3 nimmt sich den Zweiten vor: der Location in der Evaluator-Signatur. Es wird zeigen, dass auch dieser Umbau weniger Code kostet als erwartet — und dass derselbe Konstruktionsschritt im Code aus Teil 1 bereits ein zweites Mal durchgeführt wurde, nur an anderer Stelle und mit anderen Begriffen.
Zwei Entscheidungen, ein Service, zwei Kontexte
Der zweite Schnitt, den Kapitel 1 als Diagnose markiert hat, betrifft die Methodensignatur des AccessEvaluator. Aus der evaluate(Location, Class, T)-Form aus Teil 1 ist eine kompaktere Variante geworden, die den Vaadin-Typ aus dem Vertrag entfernt:
public interface AccessEvaluator<T extends Annotation> {
AccessDecision evaluate(AccessContext context, T annotation);
}
Statt Location und Class als zwei getrennte Parameter steht nun ein einziger Kontextrekord. Statt Access als Rückgabetyp die Decision aus Kapitel 2. Damit ist der AccessEvaluator nicht nur in seiner Implementierung, sondern auch in seiner Schnittstelle vollständig Vaadin-frei.
Der Kontext selbst ist absichtlich schmal gehalten:
public record AccessContext(
String path,
Class<?> target,
Map<String, Object> attributes
) {}
Drei Felder genügen für die Authorization-Phase: der Pfad, die Zielklasse und eine offene Erweiterungsfläche für situative Daten. Was im Kontext nicht steht, ist mindestens so wichtig wie das, was darin steht. Es gibt keine HTTP-Methode, keine Operation, keine Ressource, kein Subject — nichts, was die Decision für eine andere Anbindung als die View-Navigation vorzeitig festlegen würde. Der AccessContext ist die kleinste mögliche Form, mit der ein generischer Evaluator entscheiden kann.
Bis hierhin liest sich das wie ein einfacher Schnitt: eine Signatur, ein Kontextrekord, fertig. Bei genauerem Hinsehen wird allerdings sichtbar, dass derselbe Konstruktionsschritt im Code aus Teil 1 bereits ein zweites Mal durchgeführt wurde — leise, ohne als solcher benannt zu werden. Die Authentication-Phase, also die Frage „darf jemand diese Route überhaupt betreten, oder muss er sich erst anmelden?“, hatte ihre eigene, kleinere Decision-Welt: Allowed, LoginRequired, AlreadyLoggedIn, AccessDenied. Diese vier Varianten leben heute im Kern als versiegelte Schnittstelle NavigationAccessDecision — strukturell parallel zu AccessDecision, aber semantisch enger, weil sie nicht über Rollen entscheidet, sondern über das Vorhandensein eines Subjects.
Wer beide Phasen nebeneinanderlegt, sieht zwei Decision-Typen und zwei Kontexte. Die Decision-Typen unterscheiden sich in der Anzahl und der Bedeutung der Varianten; die Kontexte unterscheiden sich vor allem in ihrer Form. Ein direkter Vergleich macht das sichtbar:
public record NavigationSecurityContext(
Class<?> navigationTarget,
boolean restricted,
boolean subjectAvailable,
boolean isLoginTarget
) {}
public record AccessContext(
String path,
Class<?> target,
Map<String, Object> attributes
) {}
Der NavigationSecurityContext enthält ausschließlich boolesche Fakten und einen Verweis auf die Zielklasse. Der AccessContext enthält strukturierte Daten und eine offene Erweiterungsfläche. Diese Asymmetrie ist kein Versehen, sondern eine sehr genaue Abbildung der jeweiligen Aufgaben. Die Authentifizierung-Entscheidung ist im Wesentlichen kombinatorisch: Drei boolesche Fakten genügen, um die vier Decision-Varianten vollständig auszuwählen. Die Authorization-Entscheidung dagegen ist offen — sie hängt von Annotationen, Rollen, möglicherweise von Pfadparametern und künftig vielleicht noch von weiteren Kontextinformationen ab. Was für die eine Phase eine geschlossene Wahrheitstafel ist, braucht für die andere eine offene.
Beide Phasen werden vom selben Vaadin-freien Service bedient. Der NavigationAccessDecisionService enthält genau zwei Methoden, eine für jede Phase:
public final class NavigationAccessDecisionService {
public NavigationAccessDecision evaluateAuthentication(NavigationSecurityContext ctx) {
if (!ctx.restricted()) {
return NavigationAccessDecision.allowed();
}
if (!ctx.subjectAvailable()) {
if (ctx.isLoginTarget()) {
return NavigationAccessDecision.allowed();
}
return NavigationAccessDecision.loginRequired();
}
if (ctx.isLoginTarget()) {
return NavigationAccessDecision.alreadyLoggedIn();
}
return NavigationAccessDecision.allowed();
}
public NavigationAccessDecision evaluateAuthorization(
boolean hasRequiredAccess,
String alternativeRoute,
boolean asForward) {
if (hasRequiredAccess) {
return NavigationAccessDecision.allowed();
}
return NavigationAccessDecision.accessDenied(alternativeRoute, asForward);
}
}
Der Service kennt keine Vaadin-Klassen, keine HTTP-Begriffe, keine Datenbanken. Er ist ein reiner Algorithmus auf Werten — wenige if-Verzweigungen, die eine Decision zurückgeben. Damit gilt für ihn dieselbe Eigenschaft, die Kapitel 2 für AccessDecision festgehalten hat: Er lässt sich vollständig isoliert testen, und die Tests im Repository belegen das — NavigationAccessDecisionServiceTest deckt beide Phasen ab und benötigt keine Vaadin-Laufzeit.
Was diese Symmetrie zeigt, geht über die reine Mechanik hinaus. Die Bibliothek hat zweimal denselben Schritt vollzogen: das Trennen einer Phase in einen Vaadin-freien Entscheidungskern und einen adapternahen Vollzug. Einmal für die Authentifizierung, einmal für die Authorization. Beide Male endet der Kern an derselben Linie — bei einem Wert, den ein Adapter in eine Framework-Handlung übersetzt. Was als ein einzelner Schnitt am Evaluator begann, war in Wahrheit die zweite Anwendung eines Musters, das die Architektur bereits kannte, ohne es als solches zu benennen. Dieses Muster — Phase, Kontext, Service, Decision, Mapper — ist die eigentliche Form, die Teil 2 freilegt.
Das Subject jenseits der Vaadin-Session
Der dritte Schnitt aus der Diagnose von Kapitel 1 betrifft die Stelle, an der das Subject — also der angemeldete Benutzer — zwischen den Anfragen einer Sitzung gehalten wird. In Teil 1 war diese Stelle der SessionAccessor: eine Klasse mit statischen Methoden, die unmittelbar gegen VaadinSession.getCurrent() arbeiteten. Sie verbarg ihren Vaadin-Bezug nicht; sie machte ihn explizit. Genau diese Offenheit ist der Grund, warum der Schnitt hier am leichtesten fällt. Wo der Bezug klar benannt ist, lässt er sich auch klar abstrahieren.
Im Kern lebt der Vertrag jetzt als Schnittstelle:
public interface SubjectStore {
<T> Optional<T> currentSubject(Class<T> subjectType);
<T> void setCurrentSubject(T subject, Class<T> subjectType);
<T> void deleteCurrentSubject(Class<T> subjectType);
default <T> boolean hasSubject(Class<T> subjectType) {
return currentSubject(subjectType).isPresent();
}
}
Drei Operationen — Lesen, Setzen, Entfernen — und eine Standardimplementierung für die häufige Frage „ist überhaupt jemand angemeldet?“. Mehr braucht der Kern nicht zu wissen. Insbesondere weiß er nicht, wo das Subject tatsächlich gespeichert wird. Eine VaadinSession, eine HTTP-Session, ein In-Memory-Speicher für Tests, später vielleicht ein token-basierter Resolver für REST — der Vertrag schreibt nichts davon vor. Er beschreibt eine kleinste mögliche Form von Subject-Verwaltung.
Die Vaadin-Implementierung lebt als einzige produktive Implementierung im Adapter:
public final class VaadinSessionSubjectStore implements SubjectStore {
@Override
public <T> Optional<T> currentSubject(Class<T> subjectType) {
VaadinSession session = VaadinSession.getCurrent();
if (session == null) return Optional.empty();
return Optional.ofNullable(session.getAttribute(subjectType));
}
@Override
public <T> void setCurrentSubject(T subject, Class<T> subjectType) {
Objects.requireNonNull(subject, "subject must not be null");
VaadinSession session = VaadinSession.getCurrent();
Objects.requireNonNull(session,
"No active VaadinSession — setCurrentSubject must be called from a Vaadin request thread");
session.setAttribute(subjectType, subject);
}
@Override
public <T> void deleteCurrentSubject(Class<T> subjectType) {
VaadinSession session = VaadinSession.getCurrent();
if (session != null) {
session.setAttribute(subjectType, null);
}
}
}
Die Implementierung ist schmal, aber nicht naiv. Sie unterscheidet sauber zwischen den drei Zuständen einer Vaadin-Sitzung: Sitzungen, die noch nicht existieren — der Lesezugriff liefert ein leeres Optional —, Sitzungen, die existieren müssen — der Schreibzugriff prüft per requireNonNull, weil ein Setzen ohne Sitzung einem Programmierfehler entspricht — und Sitzungen, die möglicherweise schon vergangen sind — das Löschen verzichtet auf strenge Prüfung, weil das Ergebnis identisch wäre. Was im Kern ein abstrakter Vertrag ist, wird hier zu einer Implementierung, die sich an den tatsächlichen Lebenszyklus eines Vaadin-Requests orientiert.
Der zweite, weiterreichende Schritt ist der, mit dem der SubjectStore aufgelöst wird. Der Kern weiß, dass irgendwo eine Implementierung existiert — er weiß jedoch nicht, welche. Diese Auflösung übernimmt eine schmale Resolver-Klasse, die das Java-eigene SPI-Werkzeug nutzt:
public final class SubjectStores {
private static final AtomicReference<SubjectStore> SUBJECT_STORE_REF =
new AtomicReference<>();
private SubjectStores() { }
public static SubjectStore subjectStore() {
SubjectStore cached = SUBJECT_STORE_REF.get();
if (cached != null) {
return cached;
}
SubjectStore loaded = SecurityServiceResolver.requireSingleService(
SubjectStore.class,
ServiceLoader.load(SubjectStore.class));
SUBJECT_STORE_REF.compareAndSet(null, loaded);
return SUBJECT_STORE_REF.get();
}
// findSubjectStore(), setSubjectStore(SubjectStore), reset() — für Tests
}
Drei Eigenschaften dieser Klasse verdienen Aufmerksamkeit. Erstens, der Cache: Eine AtomicReference hält die einmal aufgelöste Implementierung fest, sodass spätere Aufrufe nicht erneut den Klassenpfad durchsuchen müssen. Zweitens, die Strenge: requireSingleService wirft eine IllegalStateException, wenn keine oder mehr als eine Implementierung registriert ist — und die Fehlermeldung benennt den fehlenden oder konkurrierenden FQN, schlägt die Korrektur vor und weist darauf hin, dass die Sicherheitsentscheidung sonst von der Klassenpfad-Reihenfolge abhinge. Drittens, die Testbarkeit: setSubjectStore und reset machen den Resolver für Unit-Tests beherrschbar, ohne dass META-INF/services-Dateien angelegt werden müssten.
Diese Klasse steht nicht allein. Im Kern gibt es mit SecurityServiceResolver einen Resolver für AuthenticationService und AuthorizationService, und im Adapter gibt es mit LoginListeners einen Resolver für den LoginListener. Beide folgen demselben Aufbau: privater Konstruktor, AtomicReference-Cache, requireSingleService für die strenge Form, findSingleService für die nachgiebige Form, identisch formulierte Fehlermeldungen für den Null- und den Mehrfachfall. Vier SPI-Auflösungspunkte — AuthenticationService, AuthorizationService, SubjectStore, LoginListener — werden auf diese Weise durch drei Resolver-Klassen einheitlich bedient. Dass SecurityServiceResolver zwei Service-Typen gemeinsam verwaltet, ist dabei kein Kompromiss, sondern eine bewusste fachliche Entscheidung: Authentifizierung und Autorisierung gehören in der Bibliothek zusammen, weshalb auch ihr Auflösungspunkt gemeinsam ist. Das Subject und der Login-Listener sind dagegen eigenständige Belange und erhalten jeweils einen eigenen Resolver.
Diese Konsistenz ist es, die den dritten Schnitt übertrifft. Aus einer reinen Trennung zwischen Vertrag und Vaadin-Implementierung entsteht ein wiedererkennbarer Mechanismus. Wer einen vierten Erweiterungspunkt hinzufügen wollte — etwa einen AuditTrail, einen RateLimiter, eine PasswordPolicy —, wüsste sofort, wie der zugehörige Resolver auszusehen hat. Die Bibliothek hat ihre eigene Erweiterungsmechanik nicht nur eingeführt, sondern auch dokumentiert — durch Wiederholung. Das ist eine Form von Disziplin, die keine separate Dokumentation erfordert, da der Code sie selbst dokumentiert.
Der Vaadin-Adapter als Übersetzer
Wer die Modulgrenzen aus den vorigen drei Kapiteln nebeneinanderlegt, sieht einen Kern, der weiß, was zu entscheiden ist, aber nicht, wer die Entscheidung umsetzt. Das ist nicht unvollständig, sondern absichtsvoll. Was im Kern absichtlich fehlt, fügt der Adapter mit drei kleinen Klassen wieder zusammen. VaadinAccessContextFactory baut den AccessContext aus dem BeforeEnterEvent — wenige Zeilen, im Wesentlichen event.getLocation().getPath() und event.getNavigationTarget(). VaadinAccessDecisionMapper, in Kapitel 2 bereits gezeigt, übersetzt die Decision in forwardTo, rerouteTo und rerouteToError. VaadinSessionSubjectStore wie in Kapitel 4 bereits gezeigt, den SubjectStore-Vertrag für VaadinSession. Drei Klassen, drei Aufgaben, jede in einem einzigen Satz beschreibbar.
Diese Trennung wird sichtbar an der Klasse, die sie zusammenruft. Der AuthorizationListener war in Teil 1 noch der Ort, an dem Annotation-Scan, Evaluator-Aufruf, Decision-Auswertung und Vaadin-Aktion ineinanderflossen. In Teil 2 ist er auf eine knappe Sequenz geschrumpft:
@Override
public void beforeEnter(BeforeEnterEvent event) {
Class<?> navigationTarget = event.getNavigationTarget();
scanner.scan(navigationTarget).ifPresent(pair -> {
Class<? extends AccessEvaluator<Annotation>> evaluatorClass = pair.accessEvaluatorClass();
requireNonNull(evaluatorClass,
"AccessEvaluator class must not be null for " + navigationTarget.getName());
AccessEvaluator<Annotation> evaluator = VaadinService.getCurrent()
.getInstantiator()
.getOrCreate(evaluatorClass);
requireNonNull(evaluator,
"Could not instantiate AccessEvaluator: " + evaluatorClass.getName());
Annotation annotation = pair.annotation();
logger().info("Evaluating access for {} with {}", event.getLocation(), annotation);
AccessContext context = contextFactory.create(event);
AccessDecision decision = evaluator.evaluate(context, annotation);
decisionMapper.apply(decision, event);
});
}
Drei Phasen, jede klar lesbar. Zuerst die Auflösung — der Scanner findet die Annotation am Navigationsziel, der Vaadin-Instanziator beschafft den passenden Evaluator. Dann die Entscheidung — die ContextFactory baut den neutralen Kontext, der Evaluator liefert eine Decision, beides, ohne dass dieser Listener den Inhalt der Decision kennen müsste. Schließlich der Vollzug — der Mapper setzt die Decision in Vaadin-Aufrufe um.
Was der Listener nicht mehr tut, ist mindestens so wichtig wie das, was er tut. Er kennt die Decision-Varianten nicht. Er enthält keine if-Verzweigung für Reroute, RerouteToError, Granted und die übrigen Varianten. Er kennt nicht einmal die Methoden forwardTo oder rerouteTo — sie stehen ausschließlich im VaadinAccessDecisionMapper. Damit ist diese Klasse zu dem geworden, was sie sein sollte: ein Vaadin-Anschluss, der das Framework mit dem Kern verkabelt und sich nicht an den Inhalten beteiligt.
Ein ehrliches Kapitel über den Adapter sollte allerdings auch darauf eingehen, wo diese Form noch nicht überall umgesetzt ist. Die Authentication-Phase wird vom LoginListener bedient, der seine Decision vom NavigationAccessDecisionService aus Kapitel 3 holt — aber die Übersetzung in Vaadin-Aufrufe nicht ausgelagert hat. Sie liegt weiterhin als private Methode im Listener selbst:
private void applyDecision(NavigationAccessDecision decision,
BeforeEnterEvent event,
Class<?> navigationTarget) {
switch (decision) {
case NavigationAccessDecision.Allowed() -> {
if (!navigationTarget.isAnnotationPresent(restrictionAnnotation())) {
notARestrictedTarget(navigationTarget);
} else {
logger().info("User is already logged in");
}
}
case NavigationAccessDecision.LoginRequired() -> {
logger().info("Login required — forwarding to login view");
event.forwardTo(loginNavigationTarget());
}
case NavigationAccessDecision.AlreadyLoggedIn() -> {
logger().info("Already logged in — forwarding to default view");
event.forwardTo(defaultNavigationTarget());
}
case NavigationAccessDecision.AccessDenied(String route, boolean asForward) -> {
if (asForward) event.forwardTo(route);
else event.rerouteTo(route);
}
}
}
Funktional ist das tadellos — das Pattern Matching ist vollständig, die Vaadin-Aufrufe sind korrekt, und die Decision-Welt aus Kapitel 3 wird sauber umgesetzt. Strukturell bleibt die Trennlinie hier jedoch eine Methodengrenze statt einer Klassengrenze. Eine analoge Klasse VaadinNavigationDecisionMapper wäre denkbar und würde die Symmetrie zum AuthorizationListener herstellen; sie existiert noch nicht. Der Artikel benennt das offen, statt es zu kaschieren — nicht weil es ein Mangel ist, sondern weil es zeigt, dass auch eine durchgearbeitete Architektur an einzelnen Stellen noch Spielraum hat.
Diese kleine Asymmetrie ist insofern lehrreich, als sie das Muster aus Kapitel 3 erneut beleuchtet. Dort ging es um die Symmetrie der beiden Decision-Welten im Kern; hier geht es um die noch unvollständige Symmetrie ihrer Adapter. Der Kern hat die Form bereits gefunden, der Adapter holt sie an einer Stelle noch ein. Das ist eine ehrliche, technisch präzise Bestandsaufnahme — und sie unterstreicht, wie weit die Trennung zwischen Kern und Adapter geht: Sie reicht so weit, dass eine fehlende kleine Klasse als sichtbarer Bruch in einer ansonsten konsequenten Linie auffällt.
Die neue Modulstruktur
Wer am Ende der vier vorigen Kapitel die entstandenen Klassen, Schnittstellen und Verträge nebeneinanderlegt, sieht eine Aufteilung, die sich nicht von außen aufdrängen ließ, sondern aus der Sache selbst hervorgegangen ist. Der eigentliche Modulwechsel von flow-security auf zwei Module, security-core und security-vaadin, ist deshalb eher ein Verwaltungsakt als eine Architekturentscheidung. Die Linien standen bereits — der Modulwechsel macht sie nur sichtbar.
Im Modul security-core lebt nun, was Vaadin nicht braucht. Das sind zunächst etwa zehn Typen, die schon in Teil 1 begrifflich neutral waren und ohne inhaltliche Änderung in den Kern wandern: AuthenticationService und AuthorizationService mit ihren Verträgen für Subject und Credentials, ihre Erweiterung PermissionAuthorizationService, die rollen- und permissionbezogenen Schnittstellen HasRoles, RoleName, HasPermissions und PermissionName, der zentrale Vertrag AccessEvaluator, die Hilfsstruktur AnnotationAccessEvaluatorPair sowie die Marker-Annotation @ExperimentalSecurityApi. Hinzu kommen die drei in den Kapiteln 2 bis 4 neu geschnittenen Bausteine: AccessDecision und AccessContext für die Authorization-Phase, SubjectStore für die Subject-Verwaltung — und in deren Folge die Vaadin-freie NavigationAccessDecision mit ihrem Service NavigationAccessDecisionService und dem NavigationSecurityContext. Die SPI-Auflösung übernehmen SecurityServiceResolver und SubjectStores. Der Annotation-Scan liegt im SecurityAnnotationScanner, die Meta-Annotation selbst heißt nun @SecurityAnnotation. Die rolle- und permissionbasierten Basisklassen RoleBasedAccessEvaluator und PermissionBasedAccessEvaluator mit ihren expliziten API-Schnittstellen vervollständigen das Bild. Insgesamt rund zwanzig Typen, jeder mit einer klaren Aufgabe, keiner mit einem Vaadin-Import.
Im Modul security-vaadin lebt, was Vaadin braucht. Das sind die beiden Listener, die das Framework an den Kern anschließen: AuthorizationListener und LoginListener. Hinzu kommen die abstrakte LoginView als Basisklasse für die konkrete Login-Maske einer Anwendung, der ApplicationServiceInitListener, der die Listener pro UI registriert, und der Resolver LoginListeners, der den LoginListener per ServiceLoader auflöst. Die drei Übersetzerklassen aus Kapitel 5 — VaadinAccessContextFactory, VaadinAccessDecisionMapper, VaadinSessionSubjectStore — schließen das Modul ab. Außerdem liegt hier die einzige produktive META-INF/services-Datei dieses Moduls, die den SubjectStore an die VaadinSessionSubjectStore-Implementierung bindet. Acht produktive Klassen plus eine Konfigurationsdatei.
Die Demoanwendung flow-security-test ist die ehrlichste Probe der Trennung. Wenn die Modulteilung wirklich entlang der fachlichen Linien verläuft, muss eine bestehende Anwendung sie ohne erhebliche Umbauten umsetzen können. Tatsächlich sind genau zwei Stellen substanziell angefasst worden. Die erste ist der MyRoleAccessEvaluator, der seine evaluate-Signatur an die neue Form mit AccessContext angepasst hat — ein Methodenkopf, ein neuer Parameter, derselbe Algorithmus. Die zweite ist die Subject-Brücke des Projekts: Wo immer das aktuelle Subject gelesen oder gesetzt wird — in MyLoginView beim Anmelden, in MySessionAccessor beim Prüfen der Sichtbarkeit —, geschieht das heute über SubjectStores statt über direkte Vaadin-Session-Zugriffe. Alles Übrige — MyUser, UserStorage, MyAuthenticationService, MyAuthorizationService, MyLoginListener, die View-Klassen MainView, AdminView, NerdView, die Annotation VisibleFor, das Enum AuthorizationRole — bleibt funktional unverändert. Nur die Imports ändern sich, weil die Pakete geändert wurden. Die META-INF/services-Dateien werden umbenannt, weil sich die FQNs ihrer Verträge verschoben haben; das ist eine mechanische Folgepflege, kein Eingriff.
Diese Bilanz hat eine ruhige, fast unauffällige Eigenschaft: Sie überrascht nicht. Wer die ursprüngliche Implementierung aus Teil 1 vor sich hatte, hätte für jede einzelne Klasse vorhersagen können, in welches Modul sie wandert. Genau das ist die Pointe dieses Kapitels. Eine Modulteilung, die niemanden überrascht, hat ihre fachlichen Linien richtig gewählt. Sie folgt nicht einem technischen Schnitt — etwa „alles, was eine Annotation trägt“ oder „alles, was im Paket xy liegt“ —, sondern einem inhaltlichen: was zur Entscheidung gehört gegenüber dem, was zur Übersetzung dieser Entscheidung in Vaadin-Handlungen gehört. Diese Trennung ist im Code aus Teil 1 bereits angelegt gewesen; Teil 2 hat sie an den drei letzten Stellen ausgezogen und die Module dann an die ohnehin sichtbaren Nahtstellen gelegt. Der Kern wird dadurch kleiner, der Adapter wird dadurch durchsichtiger, und die Anwendung merkt davon kaum etwas — die Eigenschaft einer guten Refaktorierung, die keine Begeisterung verlangt, sondern nur Aufmerksamkeit für das Bestehende.
Was diese Trennung trägt — und was sie für Teil 3 vorbereitet
Am Ende eines solchen Umbaus steht selten ein dramatisches Bild. Kein neuer Mechanismus, keine neue Funktion, keine neue Sichtweise, die in Teil 1 nicht zumindest geahnt worden wäre. Was steht, ist eine andere Sortierung des Vorliegenden. Genau das ist der Maßstab, an dem sich diese Bilanz messen lassen muss: Was trägt die neue Sortierung, was lässt sie zusätzlich sichtbar werden, und was bleibt offen?
Was sie trägt, ist zunächst eine technische Eigenschaft mit praktischen Folgen. Der Kern lässt sich vollständig isoliert testen. Die Tests im Repository belegen das auch — NavigationAccessDecisionServiceTest, SecurityAnnotationScannerTest, SecurityServiceResolverTest laufen, ohne dass eine Vaadin-Laufzeit hochgefahren werden müsste, ohne MockVaadinSession, ohne BeforeEnterEvent-Stub, ohne UI. Wer eine neue Decision-Variante hinzufügt, einen neuen Evaluator schreibt, einen alternativen SubjectStore baut, kann sein Verhalten gegen reine Werte prüfen. Auf der Adapterseite ist die Eigenschaft komplementär: Jede der drei Übersetzerklassen — VaadinAccessContextFactory, VaadinAccessDecisionMapper, VaadinSessionSubjectStore — hat eine Aufgabe, die sich in einem Satz beschreiben lässt. Jede ist klein genug, um vollständig im Kopf zu bleiben, und jede hat genau einen Grund, sich zu ändern. Das ist keine architektonische Eleganz um ihrer selbst willen, sondern die Wartungsfreundlichkeit als beobachtbare Größe.
Was sie über sich hinaus sichtbar werden lässt, ist eine API-Disziplin, die im Code aus Teil 1 noch nicht in dieser Klarheit erkennbar war. Zwei Stellen verdienen besondere Erwähnung. Erstens, die Trennung zwischen RoleBasedAccessEvaluatorAPI als reinem Vertrag und RoleBasedAccessEvaluator als Basisimplementierung. Wer die Bibliothek nutzt, kann den Vertrag implementieren, ohne die Basisklasse zu erben — ein scheinbar kleines Detail, das aber den Unterschied zwischen einer Bibliothek und einem Framework markiert: Die Konsumentenseite behält die Wahl. Zweitens, die @ExperimentalSecurityApi-Markierung, die den gesamten permission-basierten Zweig — PermissionBasedAccessEvaluator, PermissionName, HasPermissions, PermissionAuthorizationService — als veränderlich kennzeichnet. Diese Markierung ist nicht kosmetisch, sondern eine Einladung an Teil 3, das Permission-Modell für REST neu zu denken, ohne im stabilen Zweig Brüche zu erzeugen.
Was Teil 3 dieser Bilanz erbt, ist eine Erwartung, die so klein ist, dass sie fast bescheiden klingt. Es entsteht kein zweiter Kern. Derselbe AccessContext — heute schmal mit Pfad, Zielklasse und Attribute-Map, morgen ergänzt um HTTP-Methode und Ressource — bleibt das Eingabeobjekt. Dieselben Decisions — Granted, Reroute, RerouteToError — bleiben die Werte. Derselbe Evaluator-Vertrag bleibt der Entscheidungspunkt. Was hinzukommt, ist ein zweiter Adapter: ein anderer Enforcement Point, der nicht in einem BeforeEnterEvent-Listener sitzt, sondern in einem HTTP-Filter oder einer Handler-Kette; und ein anderes Mapping, das nicht auf forwardTo und rerouteTo abbildet, sondern auf 401 und 403 — beziehungsweise, im Granted-Fall, auf das Weiterreichen an den eigentlichen Handler. Das wäre die ehrliche Konsequenz der hier vollzogenen Trennung. Wenn sie trägt, muss Teil 3 einen Bruchteil dessen auf sich nehmen, was der Aufbau eines vollständigen REST-Security-Stacks normalerweise erfordert.
Mit derselben Aufrichtigkeit, die Teil 1 für seine Schlussbilanz gewählt hatte, ist auch hier eine Lücke zu nennen, die durch alle drei Schnitte hindurch unangetastet geblieben ist. Der UserStorage der Demo speichert die Anmeldedaten weiterhin im Klartext. Drei Credentials — admin/admin, user/user, demo/demo — liegen in einer ConcurrentHashMap, ohne Hash, ohne Salt, ohne Persistenz, ohne Audit. Das ist exakt der Stand aus Teil 1, und der Grund dafür ist nicht Nachlässigkeit, sondern so gewollt. Die Demo zeigt das Zusammenspiel der Bausteine, nicht ihre produktionsreife Verwendung. Der Kern selbst stellt keinen Klartext-Speicher bereit; er stellt einen AuthenticationService-Vertrag bereit, dessen Implementierung der jeweiligen Anwendung obliegt. Eine produktive Anwendung würde dort einen passwortgeschützten Repository-Zugriff, einen Force-Schutz davor und ein Audit-Log dahinter benötigen. Diese Ergänzungen liegen außerhalb der Bibliothek und der drei Teile dieser Serie; ihr Fehlen in der Demo ist der ehrlichste Hinweis darauf, wo die Verknüpfung der Bibliothek endet und die der Anwendung beginnt.
Damit endet Teil 2. Was am Anfang als Frage formuliert war, ob die Security-Logik aus Teil 1 von Vaadin getrennt werden kann, wurde mit drei Schnitten, drei Übersetzerklassen, einer ServiceLoader-Disziplin in vier Auflösungspunkten und zwei sehr kleinen Anpassungen in der Demoanwendung beantwortet. Sie kann. Und sie ist es jetzt. Was bleibt, ist die zweite Frage, die Teil 3 aufgreifen wird: ob derselbe Kern auch eine zweite Anbindung trägt, die nichts mit Navigation zu tun hat, sondern alles mit Endpunkten zu tun hat.
Happy Coding
Sven