Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 4 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -393,7 +393,7 @@ AuthorizationTestUtil generates an authorization matrix for a running Spring MVC
This is useful for asserting that each endpoint is reachable by exactly the roles you expect.
We recommend to assert the authorization matrix using our [validation-file-assertions] library.

For every endpoint registered in the application's `RequestMappingHandlerMapping`,
For every endpoint registered in the application's `RequestMappingInfoHandlerMapping` beans,
the AuthorizationTestUtil issues one HTTP request per provided principal (plus one anonymous request)
and records which requests were *not* rejected with `401`/`403`/`405`.
Endpoints accessible to every provided principal render as `{ANY_ROLE}`;
Expand Down Expand Up @@ -466,13 +466,12 @@ class MyAuthorizationTest implements JUnit5ValidationFileAssertions {
int port;

@Autowired
@Qualifier("requestMappingHandlerMapping")
RequestMappingHandlerMapping handlerMapping;
Collection<RequestMappingInfoHandlerMapping> handlerMappings;

@Test
void authorizationMatrix() {
AuthorizationTestUtil authorizationTestUtil =
new AuthorizationTestUtil(handlerMapping, AuthorizationTestUtil.createRestClient(port));
new AuthorizationTestUtil(handlerMappings, AuthorizationTestUtil.createRestClient(port));
List<Credentials> credentials = List.of(
new BearerTokenCredentials("ADMIN", adminToken),
new BasicAuthCredentials("USER", "alice", "s3cret"));
Expand All @@ -486,7 +485,7 @@ class MyAuthorizationTest implements JUnit5ValidationFileAssertions {
void authorizationMatrixWithDPoP() {
String baseUrl = AuthorizationTestUtil.localBaseUrl(port);
AuthorizationTestUtil authorizationTestUtil =
new AuthorizationTestUtil(handlerMapping, AuthorizationTestUtil.createRestClient(baseUrl), baseUrl);
new AuthorizationTestUtil(handlerMappings, AuthorizationTestUtil.createRestClient(baseUrl), baseUrl);
List<Credentials> credentials = List.of(
new BearerTokenCredentials("ADMIN", adminToken),
new DPoPCredentials("USER", userAccessToken, userProofFactory));
Expand Down
1 change: 1 addition & 0 deletions spring-boot-tests/authorization-test/build.gradle
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
dependencies {
testImplementation platform("org.springframework.boot:spring-boot-dependencies:${rootProject.ext.springBootVersion}")
testImplementation "org.springframework.boot:spring-boot-starter-web"
testImplementation "org.springframework.boot:spring-boot-starter-actuator"
testImplementation "org.springframework.boot:spring-boot-starter-security"
testImplementation "org.springframework.boot:spring-boot-starter-oauth2-resource-server"
testImplementation "org.springframework.boot:spring-boot-starter-test"
Expand Down
Original file line number Diff line number Diff line change
@@ -1,30 +1,32 @@
| METHOD | PATH | ALLOWED_ROLES |
|---------|----------------|------------------|
| GET | /actuator/info | {⚠ PERMIT_ALL ⚠} |
| GET | /admin | ADMIN |
| POST | /admin | ADMIN |
| DELETE | /any-method | ADMIN |
| GET | /any-method | ADMIN |
| HEAD | /any-method | ADMIN |
| OPTIONS | /any-method | ADMIN |
| PATCH | /any-method | ADMIN |
| POST | /any-method | ADMIN |
| PUT | /any-method | ADMIN |
| GET | /any-role | {ANY_ROLE} |
| GET | /authenticated | {AUTHENTICATED} |
| DELETE | /error | {⚠ PERMIT_ALL ⚠} |
| GET | /error | {⚠ PERMIT_ALL ⚠} |
| HEAD | /error | {⚠ PERMIT_ALL ⚠} |
| OPTIONS | /error | {⚠ PERMIT_ALL ⚠} |
| PATCH | /error | {⚠ PERMIT_ALL ⚠} |
| POST | /error | {⚠ PERMIT_ALL ⚠} |
| PUT | /error | {⚠ PERMIT_ALL ⚠} |
| GET | /gone | USER |
| GET | /guest-only | GUEST |
| DELETE | /items/{id} | ADMIN |
| GET | /items/{id} | ADMIN |
| GET | /locked | |
| GET | /not-found | USER |
| GET | /public | {⚠ PERMIT_ALL ⚠} |
| GET | /server-error | USER |
| GET | /user | ADMIN<br>USER |
| METHOD | PATH | ALLOWED_ROLES |
|---------|---------------------|------------------|
| GET | /actuator | {⚠ PERMIT_ALL ⚠} |
| GET | /actuator/health | {⚠ PERMIT_ALL ⚠} |
| GET | /actuator/health/** | {⚠ PERMIT_ALL ⚠} |
| GET | /admin | ADMIN |
| POST | /admin | ADMIN |
| DELETE | /any-method | ADMIN |
| GET | /any-method | ADMIN |
| HEAD | /any-method | ADMIN |
| OPTIONS | /any-method | ADMIN |
| PATCH | /any-method | ADMIN |
| POST | /any-method | ADMIN |
| PUT | /any-method | ADMIN |
| GET | /any-role | {ANY_ROLE} |
| GET | /authenticated | {AUTHENTICATED} |
| DELETE | /error | {⚠ PERMIT_ALL ⚠} |
| GET | /error | {⚠ PERMIT_ALL ⚠} |
| HEAD | /error | {⚠ PERMIT_ALL ⚠} |
| OPTIONS | /error | {⚠ PERMIT_ALL ⚠} |
| PATCH | /error | {⚠ PERMIT_ALL ⚠} |
| POST | /error | {⚠ PERMIT_ALL ⚠} |
| PUT | /error | {⚠ PERMIT_ALL ⚠} |
| GET | /gone | USER |
| GET | /guest-only | GUEST |
| DELETE | /items/{id} | ADMIN |
| GET | /items/{id} | ADMIN |
| GET | /locked | |
| GET | /not-found | USER |
| GET | /public | {⚠ PERMIT_ALL ⚠} |
| GET | /server-error | USER |
| GET | /user | ADMIN<br>USER |
Original file line number Diff line number Diff line change
Expand Up @@ -81,11 +81,6 @@ public ResponseEntity<String> serverError() {
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body("boom");
}

@GetMapping("/actuator/info")
public String actuatorInfo() {
return "info";
}

@RequestMapping("/any-method")
public String anyMethod() {
return "any";
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
package de.cronn.testutils.authorization;

import java.util.Map;

import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.extension.ExtensionContext;
import org.junit.jupiter.api.extension.ParameterContext;
Expand All @@ -8,13 +10,13 @@
import org.springframework.boot.web.server.servlet.context.ServletWebServerApplicationContext;
import org.springframework.context.ApplicationContext;
import org.springframework.test.context.junit.jupiter.SpringExtension;
import org.springframework.web.servlet.mvc.method.annotation.RequestMappingHandlerMapping;
import org.springframework.web.servlet.mvc.method.RequestMappingInfoHandlerMapping;

/**
* JUnit 5 extension that resolves {@link AuthorizationTestUtil} as a method parameter.
*
* <p>Requires a {@link org.springframework.boot.test.context.SpringBootTest} with
* {@code webEnvironment = RANDOM_PORT}. Retrieves the {@code requestMappingHandlerMapping} bean
* {@code webEnvironment = RANDOM_PORT}. Retrieves all {@code RequestMappingInfoHandlerMapping} beans
* and the running server port from the Spring application context automatically.
*
* <p>Usage:
Expand Down Expand Up @@ -43,10 +45,10 @@ public Object resolveParameter(ParameterContext parameterContext, ExtensionConte
Assertions.assertInstanceOf(ServletWebServerApplicationContext.class, applicationContext);
ServletWebServerApplicationContext servletWebServerApplicationContext = (ServletWebServerApplicationContext) applicationContext;

RequestMappingHandlerMapping requestMappingHandlerMapping =
servletWebServerApplicationContext.getBean("requestMappingHandlerMapping", RequestMappingHandlerMapping.class);
Map<String, RequestMappingInfoHandlerMapping> requestMappingInfoHandlerMappings =
servletWebServerApplicationContext.getBeansOfType(RequestMappingInfoHandlerMapping.class);
int localServerPort = servletWebServerApplicationContext.getWebServer().getPort();
String baseUrl = "http://localhost:" + localServerPort;
return new AuthorizationTestUtil(requestMappingHandlerMapping, AuthorizationTestUtil.createRestClient(baseUrl), baseUrl);
return new AuthorizationTestUtil(requestMappingInfoHandlerMappings.values(), AuthorizationTestUtil.createRestClient(baseUrl), baseUrl);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
import java.util.EnumSet;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.stream.Collectors;

Expand All @@ -20,12 +21,12 @@
import org.springframework.web.client.RestClient;
import org.springframework.web.servlet.mvc.condition.PathPatternsRequestCondition;
import org.springframework.web.servlet.mvc.method.RequestMappingInfo;
import org.springframework.web.servlet.mvc.method.annotation.RequestMappingHandlerMapping;
import org.springframework.web.servlet.mvc.method.RequestMappingInfoHandlerMapping;

/**
* Utility for generating an endpoint authorization matrix as a Markdown table.
*
* <p>Discovers all endpoints registered in a {@link RequestMappingHandlerMapping}, calls each one
* <p>Discovers all endpoints registered in a {@link RequestMappingInfoHandlerMapping}, calls each one
* with the provided credentials, and produces a Markdown table listing which principals had access.
* The output format can be customized by passing a {@link ResultsRenderer}.
*
Expand All @@ -37,11 +38,11 @@
* For custom adaptations, construct the instance directly:
* <pre>{@code
* // bearer / basic auth only:
* new AuthorizationTestUtil(handlerMapping, AuthorizationTestUtil.createRestClient(localServerPort))
* new AuthorizationTestUtil(handlerMappings, AuthorizationTestUtil.createRestClient(localServerPort))
*
* // with DPoP support:
* String baseUrl = AuthorizationTestUtil.localBaseUrl(localServerPort);
* new AuthorizationTestUtil(handlerMapping, AuthorizationTestUtil.createRestClient(baseUrl), baseUrl)
* new AuthorizationTestUtil(handlerMappings, AuthorizationTestUtil.createRestClient(baseUrl), baseUrl)
* }</pre>
*/
public final class AuthorizationTestUtil {
Expand All @@ -54,7 +55,7 @@ public final class AuthorizationTestUtil {
private static final Set<RequestMethod> ALL_METHODS_EXCEPT_TRACE =
EnumSet.complementOf(EnumSet.of(RequestMethod.TRACE));

private final RequestMappingHandlerMapping handlerMapping;
private final Collection<RequestMappingInfoHandlerMapping> handlerMappings;
private final RestClient restClient;
@Nullable
private final String baseUrl;
Expand All @@ -64,15 +65,15 @@ public final class AuthorizationTestUtil {
*
* <p>Sufficient for bearer-token and HTTP Basic authentication. If you intend to use
* {@link DPoPCredentials}, use
* {@link #AuthorizationTestUtil(RequestMappingHandlerMapping, RestClient, String)} instead
* {@link #AuthorizationTestUtil(Collection, RestClient, String)} instead
* so that full absolute URIs can be constructed for DPoP proof generation.
*
* @param handlerMapping the {@link RequestMappingHandlerMapping} bean of the application
* @param handlerMappings the {@link RequestMappingInfoHandlerMapping} beans of the application
* @param restClient {@link RestClient} configured against the running application
* (must have a base URL set, or paths must resolve absolutely)
*/
public AuthorizationTestUtil(RequestMappingHandlerMapping handlerMapping, RestClient restClient) {
this(handlerMapping, restClient, null);
public AuthorizationTestUtil(Collection<RequestMappingInfoHandlerMapping> handlerMappings, RestClient restClient) {
this(handlerMappings, restClient, null);
}

/**
Expand All @@ -81,14 +82,14 @@ public AuthorizationTestUtil(RequestMappingHandlerMapping handlerMapping, RestCl
* <p>Required when using {@link DPoPCredentials}. For bearer-token and
* HTTP Basic authentication the two-argument constructor is sufficient.
*
* @param handlerMapping the {@link RequestMappingHandlerMapping} bean of the application
* @param handlerMappings the {@link RequestMappingInfoHandlerMapping} beans of the application
* @param restClient {@link RestClient} configured against the running application
* (must have a base URL set, or paths must resolve absolutely)
* @param baseUrl the base URL of the running application (e.g. {@code "http://localhost:8080"}),
* used to construct full absolute URIs for DPoP proof generation
*/
public AuthorizationTestUtil(RequestMappingHandlerMapping handlerMapping, RestClient restClient, String baseUrl) {
this.handlerMapping = handlerMapping;
public AuthorizationTestUtil(Collection<RequestMappingInfoHandlerMapping> handlerMappings, RestClient restClient, String baseUrl) {
this.handlerMappings = handlerMappings;
this.restClient = restClient;
this.baseUrl = baseUrl;
}
Expand Down Expand Up @@ -151,7 +152,7 @@ public String buildAuthorizationMatrix(
ResultsRenderer resultsRenderer) {

validateCredentials(credentials);
List<Endpoint> endpoints = discoverEndpoints(handlerMapping, ignoredPathPrefixes);
List<Endpoint> endpoints = discoverEndpoints(handlerMappings, ignoredPathPrefixes);
List<EndpointResult> results = testEndpoints(restClient, baseUrl, endpoints, credentials, authenticatedCredentials);
return resultsRenderer.render(results, credentials);
}
Expand All @@ -160,7 +161,7 @@ public String buildAuthorizationMatrix(
* Builds an internal {@link RestClient} from {@code baseUrl} with defensive 5s connect / 10s read timeouts.
*
* @param baseUrl base URL of the running application (e.g. {@code "http://localhost:8080"});
* paths discovered from {@code handlerMapping} are appended to it
* paths discovered from {@code handlerMappings} are appended to it
* @return a rest client that can be used for authorization tests
*/
public static RestClient createRestClient(String baseUrl) {
Expand Down Expand Up @@ -210,10 +211,15 @@ private static void validateCredentials(Collection<? extends Credentials> creden
}

private static List<Endpoint> discoverEndpoints(
RequestMappingHandlerMapping handlerMapping, List<String> ignoredPathPrefixes) {
Collection<RequestMappingInfoHandlerMapping> handlerMappings, List<String> ignoredPathPrefixes) {

Set<Endpoint> endpoints = new LinkedHashSet<>();
for (RequestMappingInfo info : handlerMapping.getHandlerMethods().keySet()) {
Set<RequestMappingInfo> requestMappingInfos = handlerMappings.stream()
.map(RequestMappingInfoHandlerMapping::getHandlerMethods)
.map(Map::keySet)
.flatMap(Collection::stream)
.collect(Collectors.toSet());
for (RequestMappingInfo info : requestMappingInfos) {
Set<String> paths = getPaths(info, ignoredPathPrefixes);
if (paths.isEmpty()) {
continue;
Expand Down Expand Up @@ -332,7 +338,7 @@ private static URI resolveUri(@Nullable String baseUrl, String encodedPath, Cred
if (credentials instanceof DPoPCredentials) {
if (baseUrl == null) {
throw new IllegalStateException(
"baseUrl is required when using DPoP credentials. Use AuthorizationTestUtil(handlerMapping, restClient, baseUrl) to provide it."
"baseUrl is required when using DPoP credentials. Use AuthorizationTestUtil(handlerMappings, restClient, baseUrl) to provide it."
);
}
return URI.create(baseUrl + encodedPath);
Expand Down