GEDOPLAN
RESTContainerQuarkus

Quarkus REST Security Testing für Faulpelze

RESTContainerQuarkus
quarkus rest faul2 png

Ich bin faul. Zumindest, wenn es ums Schreiben von Tests geht, möchte ich möglichst wenig Aufwand mit möglichst großem Benefit. In meinen Projekten kommen oft rollenbasierte Berechtigungen für unsere REST-Endpunkte zum Einsatz, die sich einfach per Annotation deklarieren lassen:

    @GET
    @Produces(MediaType.TEXT_PLAIN)
    @RolesAllowed({Role.RoleNames.USER, Role.RoleNames.ADMIN})
    public String hello() {
        return "Hello from Quarkus REST";
    }

Mit der entsprechenden Abhängigkeit im Projekt (quarkus-test-security) lässt sich das Ganze auch prima in einem Test-Case validieren:

    @Test
    @TestSecurity(user = "testUser", roles = Role.RoleNames.USER)
    public void testWithStandardAnnotation() {
        given()
                .when()
                   .get("/hello")
                .then()
                   .statusCode(200)
                   .body(is("Hello from Quarkus REST"));
    }

Diesen Test müsste ich nun eigentlich auch für den Admin wiederholen und eigentlich müsste ich nun auch prüfen, dass jede einzelne andere Rolle hier abgewiesen wird. Mein KI-Tool erzeugt die passenden Tests natürlich innerhalb von 20,5 Sekunden, also gar kein Problem. Das Resultat ist aber eine unübersichtliche Anzahl von Test-Fällen, die ich weiterhin pflegen muss. Wünschenswert wäre doch vielmehr:

test method to result
  • Deklaration der erlaubten Rollen
    • = einzelne Validierung der erlaubten Rollen (200)
    • = einzelne Validierung der nicht erlaubten Rollen (403)
    • = Validierung von anonymem Zugriff (401)

Die grundlegendsten aller Berechtigungsprüfungen sind erledigt. Und das mit einer einzelnen Annotation.

Das Faultier hat gesiegt. Bevor wir uns jetzt aber wieder einem Schläfchen widmen, schauen wir uns noch an, wie das Ganze funktioniert.

@TestRoleSecurity

Die grundlegende Idee ist die Implementierung einer eigenen ArgumentSource, die basierend auf den definierten Rollen unsere Testmethoden mit entsprechenden Parametern, aber auch mit einer entsprechenden Security-Konfiguration aufruft. @TestRoleSecurity ist erst einmal eine eigene Annotation, die einige Parameter entgegennimmt, um das Verhalten ggf. an konkreten Stellen anzupassen. Spannender ist die Verwendung der Annotation: @ArgumentsSource, die unsere Annotation mit ArgumentSource verbindet, wie wir gleich sehen werden:

@Target({ ElementType.ANNOTATION_TYPE, ElementType.METHOD, ElementType.TYPE })
@Retention(RetentionPolicy.RUNTIME)
@Documented
@Inherited
@ArgumentsSource(TestRoleSecurityProvider.class)
public @interface TestRoleSecurity {

    // die Testmethode wird in aller Regel alle erlaubten Rollen angeben, 
   // ggf. macht es aber auch mal Sinn die nicht erlaubten zu definieren
    Role[] allowedFor() default { };
    Role[] deniedFor() default { };

    int returnCodeAllowed() default 200;
    int returnCodeDenied() default 403;
    int returnCodeNotAuthorized() default 401;

    boolean testAllRoles() default true;
    boolean testNotAuthorized() default true;
}
TestRoleSecurityProvider

Die Implementierung sieht nun wie folgt aus: Grundsätzlich werden, basierend auf den in der Annotation angegebenen Rollen, die Aufrufe / Argumente der Testmethode definiert. Beim Durchlauf des Argumenten-Streams wird die entsprechende Rolle im Security Context gesetzt:

public class TestRoleSecurityProvider extends AnnotationBasedArgumentsProvider<TestRoleSecurity> {

@Override
protected Stream<? extends Arguments> provideArguments(ParameterDeclarations parameters,
                                                       ExtensionContext context,
                                                       TestRoleSecurity roleSource) {
    return getArgumentsFromSource(roleSource);
}

private Stream<Arguments> getArgumentsFromSource(TestRoleSecurity roleSource) {
    // Mapping der definierten Rollen auf ihre StatusCodes (erlaubt / nicht erlaubt)
    Map<Role, Integer> rolesToStatusCode = new EnumMap<>(Role.class);
    Arrays.stream(roleSource.allowedFor()).forEach(allowedRole -> rolesToStatusCode.put(allowedRole, roleSource.returnCodeAllowed()));
    Arrays.stream(roleSource.deniedFor()).forEach(deniedRole -> rolesToStatusCode.put(deniedRole, roleSource.returnCodeDenied()));

    // Restliche Rollen / Anonym ergänzen
    if (roleSource.testAllRoles()) {
        Arrays.stream(Role.values()).filter(r -> !rolesToStatusCode.containsKey(r)).forEach(role -> rolesToStatusCode.put(role, roleSource.returnCodeDenied()));
    }
    var additional = Stream.of(roleSource.testNotAuthorized() ? Arguments.of(null, roleSource.returnCodeNotAuthorized()) : null);

    return Stream.concat
                    (
                            additional,
                            rolesToStatusCode.entrySet().stream().map(e -> Arguments.of(e.getKey(), e.getValue()))
                    )
            .filter(Objects::nonNull).peek(p -> auth(p.get()[0]));
}

/**
 * Erzeugt und registriert einen Principal basierend auf dem Argument-Stream
 *
 * @param role
 */
private void auth(Object role) {
    final ArcContainer container = Arc.container();
    container.select(TestAuthController.class).get().setEnabled(true);
    SecurityIdentity user = null;
    if (role != null) {
        user = QuarkusSecurityIdentity.builder()
                .setPrincipal(new QuarkusPrincipal("TestUser"))
                .addRoles(Collections.singleton(role.toString())).build();
    }
    container.select(TestIdentityAssociation.class).get().setTestIdentity(user);
}
}
Verwendung

Wir können in unseren Tests nun entweder zwei unterschiedliche Testmethoden implementieren, um Fehler- und Erfolgsfall abzudecken oder via REST Assured auch die inhaltliche Prüfung nur dann ausführen, wenn wir eine 200er Response haben:

    @ParameterizedTest
    @TestRoleSecurity(allowedFor = {Role.USER, Role.ADMIN}, testAllRoles = false, testNotAuthorized = false)
    void whenAllowedTextShouldBeReturned(Role role, int returnCode) {
        given()
                .when().get("/hello")
                .then()
                .statusCode(returnCode);
    }


    @ParameterizedTest
    @TestRoleSecurity(allowedFor = {Role.USER, Role.ADMIN})
    public void whenAllowedTextShouldBeReturned(Role role, int returnCode) {
        Response response = given()
                .when()
                .get("/hello")
                .then().statusCode(returnCode)
                .extract().response();

        if (response.statusCode() == 200) {
            // inhaltliche Prüfung
            assertThat(response.body().print(), is("Hello from Quarkus REST"));
        }
    }
Weil ich ja doch nicht faul bin: hier alles noch mal auf Github

Schreibe einen Kommentar

Deine E-Mail-Adresse wird nicht veröffentlicht. Erforderliche Felder sind mit * markiert

Bitte füllen Sie dieses Feld aus.
Bitte füllen Sie dieses Feld aus.
Bitte gib eine gültige E-Mail-Adresse ein.
Sie müssen den Bedingungen zustimmen, um fortzufahren.

Autor

Diesen Artikel teilen

LinkedIn
Xing

Gibt es noch Fragen?

Fragen beantworten wir sehr gerne! Schreibe uns einfach per Kontaktformular.

Kurse

weitere Blogbeiträge

pages g14a0ddbef 640
Spring

Spring Boot + Jasper Font Extensions

Jasper Reports bietet in der Java-Welt eine tolle Möglichkeit, um Reports für die unterschiedlichsten Einsatzzwecke zu generieren. Gerade im Zusammenspiel…
signal store png
Angular, Webprogrammierung

Angular und NgRx SignalStore

NgRx bietet seit geraumer Zeit eine Bibliothek für das State-Management in Angular Anwendungen. Die Diskussionen um die Notwendigkeit einer solchen…

Work Life Balance. Jobs bei Gedoplan

We are looking for you!

Lust bei GEDOPLAN mitzuarbeiten? Wir suchen immer Verstärkung – egal ob Entwickler, Dozent, Trainerberater oder für unser IT-Marketing! Schau doch einfach mal auf unsere Jobseiten! Wir freuen uns auf Dich!

Work Life Balance. Jobs bei Gedoplan

We are looking for you!

Lust bei GEDOPLAN mitzuarbeiten? Wir suchen immer Verstärkung – egal ob Entwickler, Dozent, Trainerberater oder für unser IT-Marketing! Schau doch einfach mal auf unsere Jobseiten! Wir freuen uns auf Dich!