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
@TopazAuthorizeinterceptor. It evaluates a Rego policy before a CDI bean method runs. -
The
@TopazCheckinterceptor. It checks a directory relation before a CDI bean method runs. -
An opt-in bridge from
@PermissionsAllowedto 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 |
|---|---|---|
|
|
|
|
|
|
|
before matching (no |
|
|
before matching (no |
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 |
|
Only with |
Identity carrying a |
|
Topaz reads the |
Any other identity |
|
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. Whenpathis empty, thePolicyPathMapperderives it from the current request. A derived path always means "the policy of the HTTP request being served". Only resource methods may therefore omitpath. The build fails on any other method, so setpaththere. The build accepts an emptypathin two places:-
On a method, the method has an HTTP method annotation, such as
@GETor@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 betrue. Empty meansquarkus.topaz.enforcement.decision. -
decisions: further decisions to request. Empty meansquarkus.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:
-
objectId, when set. -
The argument annotated
@TopazObjectId. -
The
@TopazObjectIdfield 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 againstpolicy-root. It does not support actions. It denies a permission with actions, such as@PermissionsAllowed("todoApp.GET.todos:write"), and logs a WARN. -
A
TopazRelationis a directory check. Its name isobjectType#relationorobjectType#relation@subjectType. The subject type defaults touser. Its object id is its constructor parameterid, and Quarkus matches that parameter by name. The secured method needs a parameter namedid. Otherwiseparamsnames the parameter that supplies the id, or aparam.propertyexpression whose last segment isid. 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 |
|---|---|
|
Anonymous → |
|
|
|
Path parameters, then query parameters. A path parameter wins. |
|
The subject for |
|
The matched Quarkus REST resource method, with its HTTP method, full path template and path parameters. Nothing without Quarkus REST or before matching. |
|
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:
-
When the fast path applies, the mappers run on the calling thread.
-
When the caller and the calling thread may both block, the mappers also run on the calling thread.
-
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 |
|
|
|
|
|
|
|
|
|
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 |
|---|---|
|
The Rego package prefix of derived policy paths. Startup requires it when
the |
|
The decision that must be |
|
Further decisions requested in the same call. The route policy
exposes them as the |
|
|
|
Deadline of one evaluation, mappers included. Default |
|
Build time. Installs the body handler for custom mappers. Default |
|
Build time. Enables the |
The full reference is in Configuration reference.