Bevor der in Teil 2 freigelegte Security-Kern auf einen zweiten Adapter übertragen werden kann, sind vier Vorfragen zu klären — vier Stellen, an denen sich seit Teil 2 etwas verschoben hat und an denen ehrliche Behandlung dem Verschweigen vorzuziehen ist. Die erste Verschiebung betrifft die Annotationen selbst: Eine Endpunkt-Annotation sieht anders aus als eine View-Annotation, weil eine View einen Eingang bereitstellt und ein Endpunkt eine Operation ausführt. Die zweite ist die Existenz zweier Decision-Typen im Kern, die in Teil 2 noch nicht im Bild waren. Die dritte ist der AccessContext, der zwischen Teil 2 und Teil 3 nicht erweitert, sondern umgeschnitten wurde und seine alte Form als Compatibility-Konstruktor bewahrt. Die vierte ist das Annotation-Modell, das Vaadin- und REST-Adapter im Kern gemeinsam nutzen.
Diese vier Übergangsthemen bilden den Inhalt des vorliegenden Teils. Erst wenn sie geklärt sind, lässt sich der REST-Adapter selbst sauber beschreiben — Gegenstand von Teil 3.2.
Sämtliche im Folgenden vorgestellten Quelltexte sind auf GitHub veröffentlicht und
unter https://3g3.eu/vaadin-security abrufbar.
Diagnose: warum ein REST-Endpunkt anders zu schützen ist als eine Vaadin-View
Wer den Schritt von Teil 2 zu Teil 3 ohne Umweg gehen will, neigt zu einer naheliegenden Vermutung: Ein REST-Endpunkt sei letztlich auch nur eine Sicht auf eine Ressource, die wie eine Vaadin-Route geschützt werde — mit derselben Annotation, derselben Rolle, demselben Mechanismus. Diese Vermutung ist verlockend, weil sie die in Teil 2 gewonnene Symmetrie fortführen würde. Sie ist allerdings in einem Punkt falsch, der für die gesamte folgende Erzählung tragend ist. Eine View bietet einen Eingang an. Ein Endpunkt führt eine konkrete Operation aus. Diese Verschiebung ändert nicht den Mechanismus, sondern nur das Vokabular.
Die Vaadin-Route aus Teil 1 trägt eine rollenbasiert formulierte Annotation. In der AdminView steht @VisibleFor(AuthorizationRole.ADMIN). Die Aussage ist klar: Wer hier hineindarf, muss die Rolle ADMIN tragen. Eine View kennt keine Operation; sie kennt nur das Betreten und das Verlassen, und für diese Frage genügt eine Rolle in fast allen Fällen. Auch die vorhandene Vaadin-Demo arbeitet konsequent auf dieser Ebene: MainView ist für jeden authentifizierten Benutzer offen, NerdView ist auf eine Rollengruppe beschränkt, AdminView auf eine einzelne Rolle. Drei Views, drei Rollen-Annotationen, ein einheitliches Modell.
Der REST-Endpunkt aus dem demo-rest-Modul tut etwas anderes, und sein Annotation-Vokabular spiegelt das wider. In der Methode DemoHandlers.deleteDocument steht @RequiresPermission(“document:delete”). Die Aussage ist nicht mehr „wer hier hineindarf”, sondern „wer das Löschen eines Dokuments ausführen darf“. Die Rolle ist damit nicht aufgelöst, sondern eine Schicht tiefer verschoben. Ein Editor darf in dieser Demo zwar viele Dinge mit Dokumenten tun — lesen, anlegen, ändern —, aber gerade nicht löschen. Diese Differenzierung wäre an einer Vaadin-Route zwar grundsätzlich möglich, würde dort jedoch selten getroffen, weil eine View das Löschen meist nicht als getrennte Sicht anbietet, sondern als Schaltfläche innerhalb einer Übersicht. Der Endpunkt hingegen ist die Operation; er hat keinen anderen Existenzgrund.
Die in “docs/demo-rest.md” ausgewiesene Endpoint-Tabelle macht diesen Unterschied im Großen sichtbar. Acht Endpunkte trägt die Demo. Vier davon sind anonym oder nur authentifiziert — der Login, der Logout, das /me, die /operations. Die übrigen vier tragen alle eine @RequiresPermission-Annotation, keine einzige eine @RequiresRole-Annotation. Das ist kein Zufall; es ist die natürliche Form für Operationen. Die Permissions selbst lesen sich wie ein Vokabular einer Domäne: document:read, document:create, document:update, document:delete, admin:access. Jede ist ein Verb auf einem Substantiv, jede beschreibt genau eine Befugnis, jede passt direkt auf einen Endpunkt.
Die Rolle ist deshalb nicht überflüssig. Sie wandert lediglich an einen anderen Ort. Im Modell der Demo ist die Rolle eine Bündelung mehrerer Permissions zu einer wiederverwendbaren Einheit. Der ROLE_VIEWER hat allein “document:read”. Der ROLE_EDITOR hat zusätzlich “document:create” und “document:update”. Der ROLE_ADMIN hat alle fünf Permissions, einschließlich document:delete und admin:access. Diese Auflösung ist für die Anwendung sichtbar, für den Endpunkt jedoch nicht: Der Endpunkt prüft die Permission, nicht die Rolle. Wer die Permission über eine Rolle erhält, wer sie direkt zugewiesen bekommt, wer sie über eine Gruppenzugehörigkeit erbt — das ist Sache der konsumierenden Anwendung. Der Endpunkt nimmt das Ergebnis als Berechtigung entgegen.
Genau hier liegt die didaktische Pointe der Diagnose. Die Verschiebung von Rolle zu Permission ist keine technische Frage und keine Frage der Bibliothek — sie ist eine Frage der Modellierung. Eine Bibliothek, die nur Rollen kennt, zwingt ihre Konsumenten dazu, jede neue Operation als neue Rolle zu modellieren oder die Prüfung in der Anwendungslogik selbst nachzubauen. Eine Bibliothek, die beide Begriffe gleichberechtigt führt, lässt die Wahl beim Konsumenten und unterstützt zugleich das natürliche Vokabular jeder Anbindung — Rollen für Eingänge, Permissions für Operationen. Das security-core-Modul tut genau das, und der REST-Adapter ist die erste Anbindung, in der dieser Doppelbegriff produktiv genutzt wird. Im Vaadin-Adapter aus Teil 1 und Teil 2 stand die Permission-Welt zwar bereits im Kern, blieb in den Beispielen jedoch experimentell; in Teil 3 wird sie tragend.
Wenn der REST-Endpunkt mit Permissions arbeitet und die Vaadin-View mit Rollen, müssen beide Welten denselben Kern nutzen, ohne dass eine die andere verdrängt. Beide müssen denselben Subject-Begriff sehen, denselben Annotation-Mechanismus, denselben Decision-Typ — oder zumindest eine Brücke zwischen den im Kern lebenden Decision-Typen. Genau das ist der Stand des heutigen Repositorys, und genau dorthin führen die nächsten beiden Kapitel. Kapitel 2 nimmt sich des Decision-Begriffs an und beschreibt die heute im Kern bestehende Doppelung zwischen AccessDecision und AuthorizationDecision als das, was sie ist: eine bewusste Migrationssituation mit einer Brücke. Kapitel 3 nimmt sich des AccessContext an, der zwischen Teil 2 und Teil 3 umgeschnitten wurde und seine alte Form als Compatibility-Konstruktor bewahrt.
Zwei Decision-Welten nebeneinander, und eine Brücke
Wer den security-core von Teil 2 kennt, erinnert sich an AccessDecision als einzige Decision-Form. Wer dasselbe Modul heute öffnet, findet zwei davon. Die ältere — AccessDecision mit ihren fünf Reroute-Varianten — lebt unverändert weiter. Daneben steht eine zweite, jüngere Form, die der REST-Adapter konsequent verwendet und die in Teil 2 noch nicht im Bild war. Diese Doppelung ist die augenfälligste Veränderung zwischen Teil 2 und Teil 3, und sie verdient eine ehrliche Behandlung statt einer kaschierenden.
Die jüngere Form trägt den Namen AuthorizationDecision und lebt in security-core neben AccessDecision. Sie ist als versiegelte Schnittstelle mit drei Varianten ausgeführt:
public sealed interface AuthorizationDecision
permits AuthorizationDecision.Granted,
AuthorizationDecision.Unauthenticated,
AuthorizationDecision.Forbidden {
record Granted() implements AuthorizationDecision {}
record Unauthenticated(String reason) implements AuthorizationDecision {}
record Forbidden(String reason) implements AuthorizationDecision {}
}
Drei Varianten genügen. Granted bedeutet, dass der Handler laufen darf. Unauthenticated bedeutet, dass kein Subject vorhanden ist und ein neuer Login erforderlich ist. Forbidden bedeutet, dass ein Subject vorhanden ist, ihm jedoch die erforderliche Berechtigung fehlt. Diese drei Fälle bilden die semantische Form jeder Autorisierungsentscheidung — unabhängig davon, ob das Ergebnis in eine HTTP-Antwort oder in eine Vaadin-Navigation übersetzt wird. Die JavaDoc von AuthorizationDecision macht das ausdrücklich, indem sie sagt, der Typ enthalte bewusst weder Vaadin-Routen noch HTTP-Statuscodes; die Übersetzung sei Sache der Adapter.
Der Vergleich mit AccessDecision aus Teil 2 ist aufschlussreich. Dort kennt die Schnittstelle fünf Varianten — Granted, Reroute, RerouteToError, RerouteWithParameter, RerouteWithParameters. Diese Fülle stammt nicht aus der Sache selbst, sondern aus den Vaadin-Aufrufen, die sie tragen muss: forwardTo, rerouteTo, rerouteToError und ihre parametrierten Verwandten. AccessDecision ist semantisch eine Form der Vaadin-Anweisung, wobei Granted als Sonderfall des Weitergehens gilt. AuthorizationDecision ist hingegen eine reine Entscheidung — sie beschreibt nicht, was geschehen soll. Die Übersetzung in Vaadin-Navigation oder HTTP-Status fällt vollständig in den Zuständigkeitsbereich des jeweiligen Adapters.
Diese Beobachtung ist nicht neu. Schon Teil 2 hatte am Ende seines zweiten Kapitels darauf hingewiesen, dass dieselbe AccessDecision von einem zweiten Mapper übersetzt werden könne, der nicht in Vaadin-Aufrufe abbildet, sondern in HTTP-Antworten. Der Code des Repositorys hat diese Aussage ernst genommen und die Konsequenz gezogen, allerdings auf eine Weise, die in Teil 2 nicht vorhergesagt war: Statt AccessDecision im Kern zu belassen und einen zweiten Mapper für HTTP zu schreiben, ist eine zweite, schmalere Decision-Form entstanden, die AccessDecision schrittweise ablöst — beginnend bei den Stellen, an denen die Reroute-Varianten ohnehin nicht passten. Der REST-Adapter hat keine Reroute-Varianten; er hat HTTP-Statuscodes. Drei semantische Varianten reichen ihm.
Damit stellt sich eine offene Frage: Wenn AuthorizationDecision die schmalere und semantisch reinere Form ist, warum lebt AccessDecision weiter? Die Antwort liegt in der Vaadin-Welt aus Teil 2. Dort gibt es projekt-eigene Evaluatoren wie MyRoleAccessEvaluator, die heute AccessDecision-Werte zurückgeben und in der bestehenden Demo zuverlässig funktionieren. Diese Evaluatoren auf einen Schlag auf AuthorizationDecision umzustellen, hieße, die Vaadin-Demo zu brechen, und hieße zugleich, jede Anwendung, die der Bibliothek aus Teil 2 gefolgt ist, zur sofortigen Anpassung zu zwingen. Die README markiert AccessDecision deshalb ausdrücklich als legacy form, kept for backward compatibility — keine Sackgasse, sondern eine bewusst stehengelassene Form für Anwendungen, die noch nicht migriert haben.
Die didaktisch tragende Stelle dieses Kapitels ist allerdings nicht die Existenz beider Formen, sondern die Brücke zwischen ihnen. Sie lebt im Vaadin-AuthorizationListener und akzeptiert beide Evaluator-Typen nebeneinander:
private AccessDecision evaluate(Object evaluator, AccessContext context, Annotation annotation) {
if (evaluator instanceof AccessEvaluator<?> accessEvaluator) {
return ((AccessEvaluator<Annotation>) accessEvaluator).evaluate(context, annotation);
}
if (evaluator instanceof AuthorizationEvaluator<?> authorizationEvaluator) {
AuthorizationDecision decision =
((AuthorizationEvaluator<Annotation>) authorizationEvaluator).evaluate(context, annotation);
return map(decision);
}
throw new IllegalStateException(
"Unsupported evaluator type: " + evaluator.getClass().getName());
}
Der Listener prüft den Typ des aufgelösten Evaluators und entscheidet anhand dessen, welcher Pfad verwendet wird. Ein klassischer AccessEvaluator aus Teil 2 wird ohne Umweg aufgerufen; sein Rückgabewert ist bereits eine AccessDecision. Ein neuerer AuthorizationEvaluator — etwa der im Kern lebende RequiresPermissionEvaluator oder RequiresRoleEvaluator — gibt eine AuthorizationDecision zurück, die durch eine kleine map-Methode in eine AccessDecision übersetzt wird. Diese Methode ist drei Switch-Zweige lang:
private AccessDecision map(AuthorizationDecision decision) {
return switch (decision) {
case AuthorizationDecision.Granted() -> AccessDecision.granted();
case AuthorizationDecision.Unauthenticated(String reason) -> AccessDecision.denied("login", false);
case AuthorizationDecision.Forbidden(String reason) ->
AccessDecision.deniedWithError(SecurityException.class, reason);
};
}
Drei Zweige, eine vollständige Übersetzung. Granted bleibt Granted; das Vokabular der Vaadin-Welt verwendet denselben Begriff wie das Vokabular der semantischen Welt. Unauthenticated wird zu einer Reroute zur Login-View; das ist die Vaadin-Form derselben Aussage. Forbidden wird zu einer RerouteToError mit SecurityException; das ist die Vaadin-Form von „Du hast keinen Zugriff, hier ist die Fehlerseite.” Die reason-Texte aus den AuthorizationDecision-Varianten werden im ersten Fall verworfen, weil die Login-View keinen Grund benötigt, und im zweiten Fall an die Fehlerseite weitergereicht.
Diese drei Zeilen sind die Stelle, an der die generischen Annotationen aus dem Kern auch in der bestehenden Vaadin-Demo nutzbar sind. Eine Anwendung, die heute mit @VisibleFor und MyRoleAccessEvaluator arbeitet, kann morgen einzelne Views auf @RequiresPermission umstellen, ohne dass der AuthorizationListener angepasst werden muss. Beide Annotationsstile koexistieren in derselben Demo, beide laufen durch dieselbe Listener-Sequenz, beide enden in einer AccessDecision. Das ist nicht die Art von Migration, die einen Stichtag verlangt; es ist die Art, die View für View erfolgen darf. Genau diese Eigenschaft macht die Doppelung der Decision-Typen erst gerechtfertigt — sie ist kein Übergangsschmerz, sondern eine Migrationsfreundlichkeit.
Eine letzte Beobachtung schließt das Kapitel ab. Der REST-Adapter stellt diese Brücke nicht her. Sein RestAuthorizationFilter akzeptiert ausschließlich AuthorizationEvaluator und wirft eine IllegalStateException, wenn ein Evaluator nur das ältere AccessEvaluator-Interface implementiert. Diese Asymmetrie ist gewollt. Die Vaadin-Welt hat eine Vergangenheit, die respektiert werden muss; die REST-Welt hat keine. Der REST-Adapter beginnt frei und muss nichts kompensieren. Wer einen Evaluator für REST schreibt, schreibt einen AuthorizationEvaluator — direkt in der Form, die der Kern semantisch trägt. Was im Vaadin-Adapter zwei Wege sind, ist im REST-Adapter ein Weg.
Damit ist die Decision-Frage geklärt. Es gibt zwei Formen im Kern, eine Brücke im Vaadin-Adapter, einen einzigen Pfad im REST-Adapter. Der nächste offene Punkt zwischen Teil 2 und der heutigen Implementierung betrifft den AccessContext. Auch er hat sich verändert, auch er trägt eine Compatibility-Konstruktion, und auch hier ist die ehrliche Erzählung dem Verschweigen vorzuziehen.
Der AccessContext zwischen Teil 2 und Teil 3, eine ehrliche Inventur
Wer den AccessContext aus Teil 2 vor Augen hat — drei Felder, schmaler Record, eine Demonstration darüber, wie wenig der Kern wissen muss, um eine Decision zu treffen — und dann ins aktuelle security-core-Modul schaut, erlebt einen kleinen Bruch. Aus den drei Feldern sind fünf geworden. Aus dem path, der Target-Klasse und der offenen Attributes-Map sind ein Optional<SecuritySubject>, ein resourceType, ein resourceName, eine operation und die attributes geworden. Die Reihenfolge ist neu, die Felder sind neu, der Vertrag ist neu. Aus Teil 2 steht nur noch die Attributes-Map am Ende der Liste. Diese Veränderung verdient eine ehrliche Behandlung, weil sie den Kern auf eine Weise berührt, die Teil 2 nicht vorhergesagt hat.
Zur Erinnerung: Die Form aus Teil 2 lautete
public record AccessContext(
String path,
Class<?> target,
Map<String, Object> attributes
) {}
und war damit absichtlich schmal gehalten. Drei Felder genügten, weil die Authorization-Phase einer Vaadin-Navigation nicht mehr benötigte: den Pfad als Identifikation, die Zielklasse als Trägerin der Annotation und eine offene Erweiterungsfläche für situative Daten. Was im Kontext nicht stand, war mindestens so wichtig wie das, was darin stand. Es gab keine HTTP-Methode, keine Operation, keine Ressource, kein Subject — nichts, was die Entscheidung für eine andere Anbindung als die View-Navigation vorzeitig hätte festlegen können. Der AccessContext war die kleinste mögliche Form, mit der ein generischer Evaluator entscheiden konnte.
Die heutige Form ist breiter und trägt fünf Komponenten:
public record AccessContext(
Optional<SecuritySubject> subject,
String resourceType,
String resourceName,
String operation,
Map<String, Object> attributes
) {
public AccessContext {
subject = subject == null ? Optional.empty() : subject;
resourceType = requireNotBlank(resourceType, "resourceType");
resourceName = requireNotBlank(resourceName, "resourceName");
operation = requireNotBlank(operation, "operation");
attributes = Map.copyOf(requireNonNull(attributes, "attributes must not be null"));
}
// ...
}
Jedes der vier neuen Felder hat einen klar benennbaren Grund. Das Subject lebt jetzt im Kontext, weil der REST-Adapter es bei der Subject-Auflösung aus dem Bearer-Token ohnehin produziert und der Evaluator es ohne Umweg über einen statischen Accessor benötigt — eine Beobachtung, die im Vaadin-Adapter der bisherigen Teile noch über den SubjectStore lief. Der resourceType unterscheidet zwischen vaadin-view und rest-endpoint und macht damit explizit, was zuvor aus dem Aufrufkontext implizit folgte. Der resourceName ist die konkrete Ressource — ein Pfad, ein Klassenname, ein logischer Bezeichner — und übernimmt die schmalere path-Information aus Teil 2 in adapter-neutraler Form. Die Operation benennt schließlich, was mit der Ressource geschehen soll: navigate für Vaadin, delete, read oder create für REST. Die Attributes-Map lebt unverändert weiter und nimmt alles auf, was sich nicht in eines der vier strukturierten Felder fügt, etwa die HTTP-Methode oder die Query-Parameter eines Endpunktaufrufs.
Die fünf Felder sind keine Erweiterung des dreiteiligen Kontexts aus Teil 2; sie sind dessen Ablösung. Diese Unterscheidung ist nicht akademisch. Eine Erweiterung ließe die alten Felder bestehen und fügte neue hinzu — der Vaadin-Code aus Teil 2 würde dann unverändert weiterlaufen, weil die alten drei Felder weiterhin existieren. Eine Ablösung ersetzt die alte Form durch eine neue mit anderen Feldnamen — der Vaadin-Code aus Teil 2 würde dann ohne Anpassung nicht mehr kompilieren. Das ist die ehrliche Beobachtung, und sie sollte beim Schreiben nicht kaschiert werden. Was zwischen Teil 2 und Teil 3 geschehen ist, ist ein Schnitt, kein Wachstum.
Hätte der Schnitt allerdings genau das bedeutet, hätten die Vaadin-Demo aus Teil 2 und die in Teil 1 entwickelten Beispiele jetzt nicht mehr funktioniert. An dieser Stelle setzt eine kleine, aber tragende Konstruktion an, die im selben Record lebt:
public AccessContext(String path, Class<?> target, Map<String, Object> attributes) {
this(
Optional.empty(),
"vaadin-view",
requireNonNull(target, "target must not be null").getSimpleName(),
"navigate",
vaadinAttributes(path, target, attributes));
}
Dieser Compatibility-Konstruktor nimmt die alte dreiteilige Signatur entgegen und übersetzt sie in die neue Form. Er setzt das subject auf Optional.empty() — die Vaadin-Welt aus Teil 2 hatte das Subject ohnehin nicht im Kontext, sondern im SubjectStore —, setzt den resourceType auf vaadin-view, übernimmt den Klassennamen des Navigation-Targets als resourceName, setzt die operation auf navigate und übergibt path und target gemeinsam mit der ursprünglichen Map an eine kleine Hilfsfunktion, die daraus eine erweiterte Attributes-Map baut. Das Ergebnis ist ein AccessContext der neuen Form, der sich aus Sicht des Aufrufers verhält, als wäre nichts geschehen.
Dazu kommen zwei Accessor-Methoden, path() und target(), die das alte Vokabular nach außen weiter bedienen. Beide lesen aus der Attributes-Map und geben — falls vorhanden — den ursprünglichen Pfad als String beziehungsweise das ursprüngliche Navigation-Target als Klasse zurück. Wer also einen AccessContext über den Compatibility-Konstruktor erzeugt und anschließend context.path() oder context.target() aufruft, erhält den Wert, den er aus Teil 2 erwarten würde. Beide Methoden sind nicht als veraltet markiert. Sie sind Teil des öffentlichen Vertrags und tragen die alte Form gleichberechtigt mit. Wer einen Vaadin-Evaluator aus Teil 2 schreibt, nutzt sie, ohne zu wissen, dass sie auf eine größere Struktur zugreifen.
Genau diese Eigenschaft ist die Pointe der Konstruktion. Der Bruch zwischen Teil 2 und Teil 3 ist im Code real — fünf Felder statt drei, neue Feldnamen, neue Pflichtprüfungen im kanonischen Konstruktor. Der Bruch ist im Aufrufer aber unsichtbar, solange dieser die alte Form nutzt. Eine Vaadin-Anwendung, die new AccessContext(“admin”, AdminView.class, Map.of()) schreibt, kompiliert weiterhin, läuft weiterhin, und ihr Evaluator sieht weiterhin einen AccessContext, dessen path()-Wert “admin” und dessen target()-Wert AdminView.class ist. Was sich ändert, ist allein die innere Form — und die ändert sich auf eine Weise, die für die folgenden Kapitel und die noch ausstehenden Teile der Serie wichtig wird, ohne die Erzählung aus Teil 1 und Teil 2 zu invalidieren.
Damit ist der zweite Übergangsbefund zwischen Teil 2 und Teil 3 erklärt. Der AccessContext wurde umgeschnitten, weil der REST-Adapter ein breiteres Vokabular benötigt; die alte Form lebt als Compatibility-Konstruktor mit zwei Accessor-Methoden weiter, weil die Vaadin-Welt aus Teil 2 unverändert tragen soll. Was bleibt, ist die Frage, in welcher Sprache das neue, breitere Vokabular formuliert wird — also welche Annotationen die Vaadin- und die REST-Adapter gemeinsam tragen, welcher Scanner sie auflöst und welches Permission-Modell die Demo dafür anbietet. Damit sind wir bei den Annotationen, beim SecurityAnnotationScanner und bei der Document-Demo des demo-rest-Moduls. Diese drei Themen werden in Kapitel 4 zusammengeführt.
Permissions, Rollen und der gemeinsame Annotation-Scanner
Wer von der Decision-Welt aus Kapitel 2 und der Kontext-Welt aus Kapitel 3 in die Annotation-Welt hinabsteigt, erwartet eine umfangreiche Mechanik mit vielen Klassen. Tatsächlich findet er das Gegenteil. Drei Annotationen, drei Evaluatoren, ein Scanner. Mehr ist es nicht. Vaadin- und REST-Adapter teilen sich diesen Mechanismus nicht durch Wiederverwendung über zwei Codepfade, sondern durch tatsächlich gemeinsamen Kerncode. Wer die Knappheit dieser Mechanik einmal verstanden hat, hat zugleich verstanden, warum die Erweiterung um einen weiteren Adapter — etwa für eine Hintergrundverarbeitung oder eine Messaging-Schicht — keine architektonische Frage mehr aufwirft, sondern nur noch eine Implementierungsfrage.
Das Bindeglied zwischen einer Restriktionsannotation und ihrem Evaluator ist eine kleine Meta-Annotation:
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.ANNOTATION_TYPE)
public @interface SecurityAnnotation {
Class<?> value();
}
@SecurityAnnotation lebt in security-core/…/authorization/annotations und wird ausschließlich auf Annotation-Typen gesetzt — niemals direkt auf Klassen, Methoden oder Felder. Wer eine neue Restriktionsannotation einführen will, schreibt einen Annotation-Typ und annotiert ihn mit @SecurityAnnotation(MyEvaluator.class). Damit ist die Bindung zwischen der fachlichen Aussage — ich bin eine Restriktion — und ihrer technischen Auswertung — dieser Evaluator-Typ kennt mich — ausgesprochen. Der Scanner im nächsten Absatz nutzt diese Bindung, ohne weitere Konfiguration zu erfordern.
Im Kern leben drei Annotationen, die @SecurityAnnotation tragen. Die erste ist @RequiresRole, die einen oder mehrere Rollennamen als String-Array entgegennimmt und damit den Vaadin-Annotationsstil aus Teil 1 in einer generischen, beide Adapter tragenden Form fortführt. Die zweite ist @RequiresPermission, die eine einzelne Permission als String entgegennimmt und an Endpunkten den natürlichen Stil bildet — eine Operation, eine Permission, ein Aufruf. Beide sind über die Meta-Annotation an einen festen Evaluator im Kern gebunden, RequiresRoleEvaluator beziehungsweise RequiresPermissionEvaluator, und sowohl auf Klassen als auch auf Methoden anwendbar. Die dritte Annotation ist @ProtectedBy, und sie weicht in einer wichtigen Weise von den ersten beiden ab: Sie gibt die Auswertung nicht an einen festen Evaluator im Kern ab, sondern an einen projekt-eigenen Evaluator, dessen Klasse die Annotation selbst als Wert trägt. Eine Methode mit @ProtectedBy(MyDomainEvaluator.class) wird genau von MyDomainEvaluator ausgewertet — derselbe Mechanismus wie bei den projekt-eigenen Annotationen aus Teil 1, nur ohne den Umweg über eine eigene Annotation. Der ProtectedByEvaluator im Kern leistet nichts weiter, als den im Annotation-Wert genannten Evaluator zu instanziieren und die Auswertung an ihn weiterzugeben.
Der SecurityAnnotationScanner aus dem Paket authorization.impl ist die Klasse, die diese drei Annotationen — und jede weitere, die @SecurityAnnotation trägt — auf einem AnnotatedElement findet. Seine zentrale Methode scan(AnnotatedElement) gibt entweder ein Optional mit einem Annotation-Evaluator-Paar zurück oder Optional.empty(), wenn kein passendes Element vorhanden ist. Drei Eigenschaften des Scanners sind hervorzuheben: Er ist gemeinsam, er cached, und er ist streng. Gemeinsam bedeutet das, dass derselbe Code im Vaadin-AuthorizationListener und im RestAuthorizationFilter lebt — beide Filter halten ihre eigene Scanner-Instanz, rufen aber denselben Scanner-Code aus security-core auf. Cached bedeutet, dass der Scanner die Ergebnisse pro AnnotatedElement zwischenspeichert, sodass Reflection-Aufrufe nur einmal pro View oder Methode erfolgen. Streng bedeutet, dass mehrere Restriktionsannotationen auf demselben Element zu einer IllegalStateException führen — eine Klasse, eine Restriktion, kein Verhandeln. Diese Strenge stammt bereits aus Teil 1 und wird hier konsequent fortgeführt.
So weit der Mechanismus. Was fehlt, ist die Sprache, in der eine konkrete Anwendung ihre Berechtigungen und Rollen formuliert. Hier endet die Bibliothek bewusst. security-core und security-rest definieren weder Permissions noch Rollen; das ist Sache der Anwendung oder ihres Demo-Moduls. Im Repository übernimmt demo-rest diese Rolle und stellt fünf Permissions als kleines, übersichtliches Modell bereit:
public enum DemoPermission {
DOCUMENT_READ("document:read"),
DOCUMENT_CREATE("document:create"),
DOCUMENT_UPDATE("document:update"),
DOCUMENT_DELETE("document:delete"),
ADMIN_ACCESS("admin:access");
// ...
}
Vier Permissions in der Dokumenten-Domäne und eine fünfte für den Admin-Bereich. Die Schreibweise mit Doppelpunkt — Substantiv und Verb durch einen Doppelpunkt getrennt — ist eine Konvention, keine technische Vorgabe; der PermissionName aus security-core nimmt jeden String entgegen. Der Vorteil der Konvention zeigt sich bei der Lektüre: Wer document:delete liest, weiß ohne Kontext, was gemeint ist.
Die Brücke zwischen Rollen und Permissions ist im DemoRolePermissionMapping formuliert, das den RolePermissionMapping-Vertrag aus dem Kern implementiert. In tabellarischer Lesart ergibt sich folgendes Bild:
ROLE_ADMIN -> document:read, document:create, document:update, document:delete, admin:access
ROLE_EDITOR -> document:read, document:create, document:update
ROLE_VIEWER -> document:read
Drei Rollen, drei Permission-Bündel. Ein Viewer darf nur lesen. Ein Editor darf zusätzlich anlegen und ändern, aber nicht löschen. Ein Admin darf alles, einschließlich des Admin-Bereichs. Die Rollen sind hier nicht primär — sie sind die wiederverwendbare Bündelung, die es einer Anwendung erspart, jedem Benutzer fünf Permissions einzeln zuzuweisen. Die Endpunkte selbst kennen nur Permissions; der Endpunkt DELETE /api/documents/{id} trägt @RequiresPermission(“document:delete”), nicht @RequiresRole(“ROLE_ADMIN”). Wer als Editor versucht, ein Dokument zu löschen, erhält ein 403, obwohl er drei der vier Dokument-Permissions trägt. Diese Trennung zwischen Operation auf Endpunkt-Seite und Bündelung auf Subject-Seite ist die fachliche Pointe des Demo-Modells und zugleich die Begründung dafür, warum der REST-Adapter mit Permissions arbeitet, der Vaadin-Adapter aus Teil 1 dagegen mit Rollen auskommen konnte: Eine View braucht selten die feine Auflösung; ein Endpunkt braucht sie regelmäßig.
Damit sind die drei Themen, die Kapitel 3 angekündigt hat, beieinander: die generischen Annotationen aus dem Kern, der gemeinsame Scanner als ihre Auflösungsstelle, und das Demo-Modell als ihre konkrete Belegung. Was bleibt, ist der Blick auf den REST-Adapter selbst — auf die drei kleinen Übersetzerklassen, die aus einer HTTP-Anfrage einen AccessContext machen, eine AuthorizationDecision in einen HTTP-Status umsetzen und das Subject aus dem Bearer-Token auflösen. Diese Trias steht im Mittelpunkt von Kapitel 5.

Damit sind die vier Übergangsthemen behandelt. Decision-Welten, AccessContext-Inventur und Annotation-Modell stehen geklärt vor uns. Was bleibt, ist der Blick auf den REST-Adapter selbst — auf die drei kleinen Übersetzerklassen, die aus einer HTTP-Anfrage einen AccessContext machen, eine AuthorizationDecision in einen HTTP-Status umsetzen und das Subject aus dem Bearer-Token auflösen. Diese Trias steht im Mittelpunkt von Teil 3.2.