Security

quarkus-topaz-security enforces Topaz decisions in a Quarkus application. It offers four enforcement points, and all of them fail closed:

  • A named HTTP security policy, topaz, applied to routes.

  • The @TopazAuthorize interceptor. It evaluates a Rego policy before a CDI bean method runs.

  • The @TopazCheck interceptor. It checks a directory relation before a CDI bean method runs.

  • An opt-in bridge from @PermissionsAllowed to Topaz policies and relations.

Topaz decides but does not authenticate. Configure an authentication mechanism first, such as OIDC, JWT or basic. Topaz does not validate a JWT that it receives. It only reads its sub claim.

Installation

Add the security artifact. It brings quarkus-topaz with it. quarkus-topaz holds the client and the Dev Service.

<dependency>
    <groupId>io.quarkiverse.topaz</groupId>
    <artifactId>quarkus-topaz-security</artifactId>
    <version>0</version>
</dependency>

The extension works with or without quarkus-rest. It declares quarkus-rest as a provided dependency, so it does not bring it in. With Quarkus REST, the extension derives the policy path and the resource context from the matched resource method. Without Quarkus REST, it derives them from the raw request path, and @TopazAuthorize needs an explicit path.

Route-level enforcement

Bind the topaz policy to paths in application.properties:

quarkus.topaz.enforcement.policy-root=todoApp
quarkus.http.auth.permission.secured.paths=/secured/*
quarkus.http.auth.permission.secured.policy=topaz
quarkus.http.auth.permission.secured.applies-to=jaxrs

Or bind it on a Quarkus REST resource class or method:

@Path("/secured/todos")
@AuthorizationPolicy(name = "topaz")
public class TodoResource {
    ...
}

The policy evaluates each request on a bound route against a Rego package named after the request. The configured decision must be true. It defaults to allowed. Any other result is a 403.

Use applies-to=jaxrs with Quarkus REST. Without it, the policy runs before Quarkus matches the request to a resource method, while no path template exists yet. The mapper then maps the raw request path, and path parameters end up literally in the policy path. Topaz finds no such policy. The policy denies the request and logs a WARN that names the path. @AuthorizationPolicy always runs after matching.

Pre-matching mode is for static, literal paths only. The raw path comes from the caller, and some segments could select another Rego package. The policy denies a request with such a segment before it asks Topaz. It logs a WARN that names the path. These segments are:

  • A segment that contains ., such as /todos.public.

  • A segment that starts with __ or {.

The application fails to start when the topaz policy is bound and quarkus.topaz.enforcement.policy-root is not set. Configuration or an annotation can bind the policy. When the application provides its own PolicyPathMapper bean, policy-root is not required and the extension skips the startup check.

Policy paths

The default PolicyPathMapper builds <policy-root>.<METHOD>.<path>. Each / becomes .. A template parameter {name} or {name:regex} becomes __name.

After matching, the path is the full JAX-RS template of the resource method. That template joins the class @Path with the method @Path. Before matching, the path is the request path without quarkus.http.root-path and then without quarkus.rest.path. The mapper does not remove @ApplicationPath. It removes these prefixes on every route under the policy, including non-REST routes. With policy-root=todoApp:

Request Mode Policy path

GET /secured/todos

applies-to=jaxrs or @AuthorizationPolicy

todoApp.GET.secured.todos

DELETE /secured/todos/todo-1 on @Path("{id}")

applies-to=jaxrs or @AuthorizationPolicy

todoApp.DELETE.secured.todos.__id

DELETE /secured/todos/todo-1

before matching (no applies-to=jaxrs)

todoApp.DELETE.secured.todos.todo-1: no such policy, denied

GET /secured/todos.public, GET /secured/todos/__id

before matching (no applies-to=jaxrs)

none: the segment is not allowed before matching, denied without a call to Topaz

The integration-tests module of this repository holds the matching Rego package for the delete route:

package todoApp.DELETE.secured.todos.__id

import future.keywords.if

default allowed = false

allowed if {
	ds.check({
		"object_type": "todo",
		"object_id": input.resource.id,
		"relation": "owner",
		"subject_type": "user",
		"subject_id": input.user.id,
	})
}

Identity

The default IdentityMapper turns the Quarkus SecurityIdentity into the identity context that the extension sends to Topaz:

Caller Identity context Notes

Anonymous

IDENTITY_TYPE_NONE

Only with quarkus.topaz.enforcement.anonymous=evaluate. With the default, deny, an anonymous request gets 401 before any call to Topaz.

Identity carrying a TokenCredential (OIDC, JWT)

IDENTITY_TYPE_JWT with the raw token

Topaz reads the sub claim and does not validate the token.

Any other identity

IDENTITY_TYPE_SUB with the principal name

For SUB and JWT, Topaz resolves the caller through the directory. It looks for an identity object whose id is the subject, with an identifier relation to a user. The policy sees that user as input.user. Users must exist in the directory. An unknown user is a Topaz NOT_FOUND error with code E30017, and the extension denies the request. The integration-tests manifest declares:

types:
  user: {}
  identity:
    relations:
      identifier: user

Resource context

The default ResourceContextMapper sends the path parameters of the matched resource method as input.resource. It adds the query parameters after them. A single query value becomes a string, and repeated values become a list. A path parameter wins over a query parameter of the same name. Before matching, there are no path parameters.

Decisions and failures

quarkus.topaz.enforcement.decision names the decision that must be true. The extension requests the additional-decisions in the same call. When that list is not empty, a permitted identity carries the attribute topaz.decisions. This attribute is a Map<String, Boolean> of every returned decision:

@Inject
SecurityIdentity identity;

Map<String, Boolean> decisions = identity.getAttribute("topaz.decisions"); // e.g. {allowed=true, visible=true}

quarkus.topaz.enforcement.timeout bounds the whole evaluation, including the mappers. It defaults to 5s. Each of these failures denies the request and logs a WARN that names the policy path:

  • A Topaz error.

  • A timeout.

  • An unknown user.

  • An unknown policy path.

  • A response without the decision.

  • A failing mapper.

An error never permits.

@TopazAuthorize

@TopazAuthorize evaluates a policy before a CDI bean method runs. On a class, it evaluates the policy before every business method:

@GET
@Path("todos")
@TopazAuthorize(path = "todoApp.GET.todos")
public List<String> list() { ... }

@PUT
@Path("todos/{id}")
@TopazAuthorize(decision = "allowed", decisions = { "audited" })
public Todo update(@PathParam("id") String id, @TopazResource("todo") Todo todo) { ... }
  • path: the policy path. When path is empty, the PolicyPathMapper derives it from the current request. A derived path always means "the policy of the HTTP request being served". Only resource methods may therefore omit path. The build fails on any other method, so set path there. The build accepts an empty path in two places:

    • On a method, the method has an HTTP method annotation, such as @GET or @POST, or one meta-annotated @HttpMethod.

    • On a class, the class is a JAX-RS resource. The class or an interface that it implements carries @Path.

  • decision: the decision that must be true. Empty means quarkus.topaz.enforcement.decision.

  • decisions: further decisions to request. Empty means quarkus.topaz.enforcement.additional-decisions.

A derived path also requires quarkus.topaz.enforcement.policy-root. The extension checks it at startup.

For example, with a POJO argument such as a plain record:

public record Todo(String id, String title, boolean completed) { }

@PUT
@Path("todos/{id}")
@TopazAuthorize(path = "todoApp.PUT.todos.__id")
public Todo update(@PathParam("id") String id, @TopazResource("todo") Todo todo) { ... }

Jackson turns the Todo into the map {"id": …​, "title": …​, "completed": …​}. The policy reads the title as input.resource.todo.title.

The resource context starts with the ResourceContextMapper output for the current request. Outside a request, that output is empty. The arguments annotated @TopazResource("key") overlay it. Jackson converts each such argument. Strings, numbers, booleans, maps and lists pass through, and other objects become maps of their properties. An argument replaces a mapper key of the same name.

The default resolver does not support sub-resource locators. The path prefix and the path parameters of the locator are not part of the derived template. For such resources, set path explicitly or bind the route policy by configuration.

A deny, an error or a timeout is a 403 with ForbiddenException. An anonymous caller gets a 401 unless anonymous=evaluate. The interceptor checks a method that returns Uni or Multi on subscription. Any other method waits for the decision, including one that returns CompletionStage. Such a method must run on a worker thread. On the event loop, it fails with IllegalStateException.

@TopazCheck

@TopazCheck checks a directory relation or permission between the caller and an object. It runs the check before a CDI bean method runs:

@DELETE
@Path("todos/{id}")
@TopazCheck(objectType = "todo", relation = "owner")
public void delete(@TopazObjectId @PathParam("id") String id) { ... }

@TopazCheck(objectType = "todo", relation = "can_delete")
public void archive(TodoRef ref) { ... }   // TodoRef has a field or getter annotated @TopazObjectId

The subject is the caller as the SubjectMapper maps it, with type subjectType. subjectType defaults to user. The object id comes from the first of these sources that applies:

  1. objectId, when set.

  2. The argument annotated @TopazObjectId.

  3. The @TopazObjectId field or getter of the first argument whose declared parameter type declares one. A superclass of that type counts too.

The extension converts the value with toString().

An anonymous caller always gets a 401, whatever anonymous says, because a relation check needs a subject. A failed check, a directory error or a timeout is a 403. A misconfiguration found at call time is a 403 too, with a WARN. These misconfigurations are a missing object id source, @TopazObjectId on two parameters, and a null or blank id. Uni, Multi and blocking methods dispatch as for @TopazAuthorize.

Both interceptors run at priority Interceptor.Priority.PLATFORM_BEFORE + 210. They run after the Quarkus security interceptors, such as @RolesAllowed and @Authenticated.

@PermissionsAllowed bridge

Set quarkus.topaz.enforcement.permissions.enabled=true to let Topaz answer @PermissionsAllowed for every authenticated identity. Quarkus fixes this key at build time, and the bridge is off by default. The extension adds the checker to the identity. The checker therefore also answers the permissions that configuration requires, not only the annotation. Configuration requires them through quarkus.http.auth.permission.<name>.permissions=…​:

@GET
@PermissionsAllowed("todoApp.GET.todos")
public List<String> list() { ... }

@DELETE
@Path("{id}")
@PermissionsAllowed(value = "todo#owner", permission = TopazRelation.class, params = "id")
public void delete(@PathParam("id") String id) { ... }
  • A plain permission name, a StringPermission, is a policy path. The bridge evaluates it with the configured decision and an empty resource context. The bridge does not check policy paths against policy-root. It does not support actions. It denies a permission with actions, such as @PermissionsAllowed("todoApp.GET.todos:write"), and logs a WARN.

  • A TopazRelation is a directory check. Its name is objectType#relation or objectType#relation@subjectType. The subject type defaults to user. Its object id is its constructor parameter id, and Quarkus matches that parameter by name. The secured method needs a parameter named id. Otherwise params names the parameter that supplies the id, or a param.property expression whose last segment is id. A malformed name or a missing id is a 403.

Each part of a TopazRelation name follows one rule. The parts are the object type, the relation and the optional subject type. Each part:

  • Has 3 to 64 ASCII characters.

  • Starts with a letter.

  • Ends with a letter or a digit.

  • Contains only letters, digits, ., _ and - in between.

The rule matches Topaz 0.33.22. Quarkus builds the permission when the secured method runs. Any other name fails at that point, and Quarkus denies the call with 403.

The bridge does not augment anonymous callers, so they get a 401. An error or a timeout denies.

Quarkus grants a permission when any permission of the identity implies it. Another SecurityIdentityAugmentor or a role mapping may grant a StringPermission of the same name. That permission then bypasses Topaz for that name. Nothing bypasses Topaz for a TopazRelation. Its implies always returns false, so only the Topaz check grants it.

Customizing

Each step is a CDI bean of an interface in io.quarkiverse.topaz.security. The default beans are @DefaultBean, so a bean of the interface in the application replaces them:

Interface Default

IdentityMapper

Anonymous → IDENTITY_TYPE_NONE, TokenCredential → IDENTITY_TYPE_JWT, otherwise IDENTITY_TYPE_SUB with the principal name.

PolicyPathMapper

<policy-root>.<METHOD>.<path>, see [policy-paths].

ResourceContextMapper

Path parameters, then query parameters. A path parameter wins.

SubjectMapper

The subject for @TopazCheck and TopazRelation. It is the JWT sub claim when the principal is a MicroProfile JsonWebToken, and the principal name otherwise. A token without sub gets a 401 from @TopazCheck and a 403 from the bridge.

ResourceMethodInfoResolver

The matched Quarkus REST resource method, with its HTTP method, full path template and path parameters. Nothing without Quarkus REST or before matching.

DecisionCache

Caches nothing.

@Singleton
public class TenantPolicyPathMapper implements PolicyPathMapper {

    @Override
    public String map(RoutingContext request, Optional<ResourceMethodInfo> method) {
        String tenant = request.request().getHeader("X-Tenant");
        return "tenants." + tenant + "." + request.request().method().name();
    }
}

A mapper that fails, returns null or exceeds the timeout denies the request. Set quarkus.topaz.enforcement.read-body=true when a custom mapper reads the request body. This property installs the body handler, so RoutingContext.body() is available. The default mappers do not read the body.

Mapper threading

One rule covers the four enforcement points. These are the route policy, @TopazAuthorize, @TopazCheck and the @PermissionsAllowed bridge. A mapper runs on a thread that may block, with the duplicated Vert.x context of the request current. The fast path is the one exception. When no component of an enforcement point blocks, the mappers run on the calling thread. quarkus.topaz.enforcement.timeout bounds the mappers, and a mapper that runs out of time causes a denial.

The extension picks the thread when the check subscribes. It takes the first branch that applies:

  1. When the fast path applies, the mappers run on the calling thread.

  2. When the caller and the calling thread may both block, the mappers also run on the calling thread.

  3. Otherwise the mappers run on a worker thread.

In branch 2, the caller is the secured method. Only a blocking method under @TopazAuthorize or @TopazCheck is a caller that may block. The route policy and the @PermissionsAllowed bridge never take branch 2. When the calling thread may not block, the result comes back on its duplicated Vert.x context.

On a blocking method, the mappers run on the calling thread. A mapper that hangs holds that thread until it returns. The timeout still turns the result into a denial, never a permit. Nothing interrupts a blocking mapper, so the 403 waits for the mapper to return.

A mapper that times out on a worker thread keeps that thread until it returns. A mapper that can hang can therefore exhaust the worker pool.

The @PermissionsAllowed bridge always runs its mapper on a worker thread, unless its fast path applies. A permission checker cannot see how the secured method dispatches. A blocking @PermissionsAllowed method holds a worker thread while it waits for the check. When the pool has no other free thread, the mapper cannot start before the timeout. This happens when the pool is exhausted or has one thread. The check then times out, and the bridge denies the call.

Each mapper interface has a blocking() method. It returns true by default, so a custom mapper runs on a thread that may block. The four default mappers return false. A subclass of a default mapper counts as custom unless it overrides blocking().

No mapper may use @RequestScoped beans. A mapper whose blocking() method returns false must not block. A mapper opts in to the fast path when it overrides blocking():

@Singleton
public class PrincipalSubjectMapper implements SubjectMapper {

    @Override
    public Uni<String> map(SecurityIdentity identity) {
        return Uni.createFrom().item(identity.getPrincipal().getName());
    }

    @Override
    public boolean blocking() {
        return false;
    }
}

The fast path of an enforcement point needs every component in its table row to be free of blocking. A mapper is free of blocking when its blocking() method returns false. The DecisionCache is free of blocking only when it is the built-in cache that caches nothing. The ResourceMethodInfoResolver is free of blocking only when it is one of the two built-in resolvers. A custom cache or resolver therefore rules out the fast path only for the enforcement points whose row lists it.

DecisionCache.get and put can run on the event loop, whether or not the mappers run on a worker thread. put runs on the thread that receives the Topaz answer. A custom cache must therefore not block.

Enforcement point Components

Route policy

ResourceMethodInfoResolver, PolicyPathMapper, IdentityMapper, ResourceContextMapper, DecisionCache

@TopazAuthorize

ResourceMethodInfoResolver, PolicyPathMapper, IdentityMapper, ResourceContextMapper, DecisionCache

@TopazCheck

SubjectMapper

@PermissionsAllowed with a policy name

IdentityMapper, DecisionCache

@PermissionsAllowed with a TopazRelation

SubjectMapper

A blocking call from an ordered Vert.x worker context gets a denial at the timeout when it reaches Topaz. The handler of a worker verticle runs on such a context. This holds for @TopazAuthorize, @TopazCheck and @PermissionsAllowed. The gRPC client delivers the response on that context, but the context is busy waiting for the response. This is a known limitation.

Decision cache

DecisionCache has get(key) and put(key, allowed). Keys are opaque 64-character lowercase hex SHA-256 digests. Each digest covers the identity type, the identity, the policy path, the decision and the resource context. A key never contains the identity, a token or the resource in clear. Still treat keys as private to the cache, since each key stands for the decision of one caller.

The extension stores only successful decisions and never caches an error. The cache applies to the route policy, @TopazAuthorize and plain @PermissionsAllowed names, not to relation checks. The implementation decides any expiry and size bounds.

Configuration

Property Meaning

quarkus.topaz.enforcement.policy-root

The Rego package prefix of derived policy paths. Startup requires it when the topaz policy is bound or a @TopazAuthorize without path exists. It is not required when the application provides its own PolicyPathMapper bean.

quarkus.topaz.enforcement.decision

The decision that must be true. Default allowed.

quarkus.topaz.enforcement.additional-decisions

Further decisions requested in the same call. The route policy exposes them as the topaz.decisions attribute.

quarkus.topaz.enforcement.anonymous

deny, the default: 401 without a call. evaluate: evaluated as IDENTITY_TYPE_NONE. @TopazCheck always rejects anonymous callers.

quarkus.topaz.enforcement.timeout

Deadline of one evaluation, mappers included. Default 5s.

quarkus.topaz.enforcement.read-body

Build time. Installs the body handler for custom mappers. Default false.

quarkus.topaz.enforcement.permissions.enabled

Build time. Enables the @PermissionsAllowed bridge. Default false.

The full reference is in Configuration reference.