Using RBAC with OpenAPI and vert.x
I'm stronlgy in favour of Contract-First Development, when in comes to APIs. All invested parties, including your future self, agree on a neutral format, that both API providers and consumers will stick to. For REST APIs that is the OpenAPI spec
A popular critique of that approach is that it reeks of Big Design Upfront, happily skipping over the fact that nothing stops the teams to iterate over the specification too, one path, one schema at the time
The source of truth
The specification becomes the single authorative source for endpoints, security requirements, data formats and responses. While it is possible to generate the spec from source code, like. e.g. Spring or Quarkus, I see clear advantages to provide the specification standalone. Create it with a tool like Apicur.io, Stoplight or APIGit. Or use a plugin (or another) in your IDE or the other one
Once you have your first draft, you want to implement it server side. Eclipse vert.x offers the Vert.x OpenAPI Router for exactly that. You can get more details from my OpenAPI talk, or by peeking into the sample project (which used vert.x inside Quarkus).
Roles in @Annotations
Most of the RBAC (Role Based Access Control) usen Annotations like e.g. Quarkus. You simply decorate a method:
@GET
@Path("helloworld")
@RolesAllowed({ "Coda", "Moonshadow" })
@Produces(MediaType.TEXT_PLAIN)
public String helloWorld(@Context SecurityContext ctx) {
return "Hello world builders";
}
This is nice and dandy and makes the Java source code eaasy to understand. But it does nothing to help a consumer of the API, who wouldn't access the backend's source code, to understand the API requirements. So it is better to define the roles in the OpenAPI contract.
Roles in OpenAPI
Neither the 3.0.x nor the 3.1.x mention "roles" anywhere. The closest match can be found as "scope" in the OAuth2 security object. Scopes are not roles and I don't advocate overloading of terms. So my approach is to introduce a specification extension, as proposed by the OpenAPI standard. In my OpenAPI file I have, on the level of operation, an array at x-roles
containing the roles that are permitted to run that operation.
From OpenAPI to vert.x
The sequence to get there is:
- Load the specification
- Loop through the routes
- Check if they have roles, if yes add an AuthorizationHandler
- load the actual handler doing the work
public class Demo extends AbstractVerticle {
@Override
public void start(Promise<Void> startPromise) throws Exception {
OpenAPIContract.from(this.getVertx(), "openapi.json")
.compose(this::defineRouterActions)
.compose(router -> server.requestHandler(router).listen(8080))
.onFailure(startPromise::fail)
.onSuccess(r -> {
System.out.printf("%nServer up and running on port %s%n%n", r.actualPort());
startPromise.complete();
});
}
Future<Router> defineRouterActions(final OpenAPIContract contract) {
final RouterBuilder builder = RouterBuilder.create(this.getVertx(), contract);
builder.getRoutes().forEach(this::setupRoute);
return Future.succeededFuture(builder.createRouter());
}
void setupRoute(final OpenAPIRoute route) {
Operation operation = route.getOperation();
Object roles = operation.getExtensions().get("x-roles");
if ((roles instanceof JsonArray) && (!(JsonArray) roles).isEmpty()) {
route.addHandler(RoleAuthortizationHandler.get((JsonArray) roles));
}
// Here would be the actual handler
}
}
The interesting part is the RoleAuthortizationHandler
that implements AuthorizationHandler and checks if the current user has the rquired role. The static method ensures, that we reuse already defined handlers.
public class RoleAuthortizationHandler implements AuthorizationHandler {
static final Map<String, RoleAuthortizationHandler> HANDLERS = new HashMap<>();
public static RoleAuthortizationHandler get(JsonArray roles) {
RoleAuthortizationHandler handler = HANDLERS.get(roles.encode());
if (handler == null) {
handler = new RoleAuthortizationHandler(roles);
HANDLERS.put(roles.encode(), handler);
}
return handler;
}
private final Set<String> permittedRoles = new HashSet<>();
RoleAuthortizationHandler(JsonArray roles) {
roles.stream().map(Object::toString).forEach(permittedRoles::add);
}
@Override
public void handle(RoutingContext ctx) {
UserContext userCtx = ctx.user();
if (userCtx == null || !userCtx.authenticated()) {
ctx.fail(401);
return;
}
JsonArray roles = userCtx.get().principal().getJsonArray("groups", new JsonArray());
permittedRoles.stream()
.filter(roles::contains)
.findAny()
.ifPresentOrElse(
role -> ctx.next(),
() -> ctx.fail(403));
}
@Override
public AuthorizationHandler addAuthorizationProvider(
AuthorizationProvider authorizationProvider) {
throw new UnsupportedOperationException(
"Method 'addAuthorizationProvider' is not supported");
}
@Override
public AuthorizationHandler variableConsumer(
BiConsumer<RoutingContext, AuthorizationContext> handler) {
throw new UnsupportedOperationException("Method 'variableConsumer' is not supported");
}
}
Check out the sample project, as usual YMMV
Posted by Stephan H Wissel on 08 November 2024 | Comments (0) | categories: Java OpenAPI vert.x