Annotation-driven access control for Quarkus services. Define roles and permissions in YAML, protect methods with @AuthzCheck, and let the framework handle token exchange, principal resolution, and permission enforcement.
- authz-core — framework-agnostic models, interfaces, and permission logic
- authz-quarkus — Quarkus integration (request filter, CDI interceptor, request-scoped principal)
- authz-testing — mock token exchange plugin for tests
Add the JitPack repository and dependencies:
repositories {
mavenCentral()
maven { url = uri("https://jitpack.io") }
}
dependencies {
implementation("com.github.incept5.authz-lib:authz-core:latest.release")
implementation("com.github.incept5.authz-lib:authz-quarkus:latest.release")
testImplementation("com.github.incept5.authz-lib:authz-testing:latest.release")
}authz:
roles:
- name: backoffice.admin
permissions:
- users:all
- orgs:all
- name: org.admin
permissions:
- users:read
- users:create
- users:update
- name: org.support
permissions:
- users:read@Singleton
@Authorized
class ExampleSecureService {
@AuthzCheck(ExampleAccessControl::class)
fun authorizedMethod(id: String): String {
return "Authorized method called with id: $id"
}
}
class ExampleAccessControl : BaseEntityAccessControl(
permission = Permission.of("example:read"),
entityType = "org",
extractEntityId = { ctx -> ctx.firstArg() }
)Provide a TokenExchangePlugin bean that validates incoming tokens and returns a PrincipalContext:
@Singleton
class MyTokenExchangePlugin : TokenExchangePlugin {
override fun exchangeToken(token: String): PrincipalContext? {
// Validate the token and return a PrincipalContext, or null if invalid.
// Return null to let the next plugin in the chain try.
}
}The PluggableTokenExchangeService iterates all registered plugins and returns the first non-null result. If no plugin succeeds, an AuthnException is thrown.
The AuthzFilter (a JAX-RS ContainerRequestFilter at AUTHENTICATION priority) handles the request lifecycle:
- Extracts the Bearer token from the
Authorizationheader - Calls
TokenExchangeService.exchangeToken(token)which delegates to registeredTokenExchangePlugininstances - Stores the resulting
PrincipalContextin the request-scopedRequestScopePrincipalService - Sets the JAX-RS
SecurityContexton the request so the principal is available viajakarta.ws.rs.core.SecurityContext.getUserPrincipal()
The principal can be accessed in two ways:
Via JAX-RS SecurityContext (standard approach):
@GET
fun getUser(@Context securityContext: SecurityContext): Response {
val principal = securityContext.userPrincipal as PrincipalContext
val userId = principal.getPrincipalId()
// ...
}Via RequestScopePrincipalService (CDI injection):
@Inject
lateinit var principalService: RequestScopePrincipalService
fun doSomething() {
val principal = principalService.ensurePrincipal()
// ...
}The SecurityContext also supports role checking:
securityContext.isUserInRole("backoffice.admin") // checks against global roles
securityContext.authenticationScheme // returns "Bearer"PrincipalContext extends java.security.Principal and carries the authenticated user's identity:
interface PrincipalContext : Principal {
fun getPrincipalId(): UUID
fun getGlobalRoles(): List<String>
fun getEntityRoles(): List<EntityRole>
}The default implementation is DefaultPrincipalContext. Libraries like platform-core-lib can extend this with richer types (e.g. ApiPrincipal) that carry additional token metadata while remaining compatible with the authz framework.
A role is a named set of permissions. Roles come in two flavours:
Global roles grant access across all entities. Example: backoffice.admin can access all merchants.
Entity roles scope permissions to specific entity instances:
data class EntityRole(
val type: String, // e.g. "org", "partner", "merchant"
val roles: List<String>,// e.g. ["org.admin"]
val ids: List<String> // e.g. ["org-123"]
)Example: org.admin with ids = ["org-123"] grants admin permissions only for that specific org.
Permissions follow the format <resource>:<operation>:
- resource — plural noun matching REST conventions (e.g.
users,payees,sms.messages) - operation — one of
create,read,update,delete, orall(shorthand for all four)
- name: org.admin
permissions:
- payees:all # equivalent to payees:create + payees:read + payees:update + payees:deleteThis section explains how services that depend on authz-lib wire up roles, resolve permissions, and enforce access at request time.
Services define their roles under incept5.authz.roles in application.yaml. The authz-quarkus module binds this config automatically via Quarkus @ConfigMapping:
incept5:
authz:
roles:
- name: backoffice.admin
permissions:
- ".*:all" # wildcard — matches any resource and operation
- name: partner.user
permissions:
- partner:read
- webhook:read
- name: partner.admin
extends-role: partner.user # inherits all of partner.user's permissions
permissions:
- partner:update
- webhook:create
- name: merchant.user
permissions:
- merchant:read
- name: merchant.admin
extends-role: merchant.user
permissions:
- merchant:updateEach entry becomes a Role object with a name, a list of permission strings, and an optional extendsRole pointer.
When a role declares extends-role, SimplePermissionService resolves permissions recursively at startup:
partner.adminextendspartner.user→ getspartner:update,webhook:createpluspartner:read,webhook:readmerchant.adminextendsmerchant.user→ getsmerchant:updateplusmerchant:read
Multi-level inheritance works too (e.g. super_admin → admin → user). Circular references are guarded against with a visited set.
Permissions use regex matching internally. A role with ".*:all" matches any resource:operation combination:
Permission.of("merchant:create").matches(Permission.of(".*:all")) → true
This makes backoffice.admin a super-admin that passes every permission check.
JWT Token → AuthzFilter → TokenExchangePlugin → PrincipalContext → @AuthzCheck
- AuthzFilter (a JAX-RS
ContainerRequestFilteratAUTHENTICATIONpriority) intercepts the request and extracts the Bearer token - TokenExchangePlugin validates the token and maps its claims to hierarchical roles. For example, a Supabase-based plugin might map:
entity_admin+entity_type=partner→partner.adminentity_user+entity_type=merchant→merchant.userplatform_admin→backoffice.admin
- The plugin returns a PrincipalContext containing
globalRolesandentityRoles(which include entity IDs) - The principal is stored in the request-scoped
RequestScopePrincipalServiceand the JAX-RSSecurityContext
Controllers and services annotate methods with @AuthzCheck(SomeAccessControl::class). Each AccessControl implementation calls:
ctx.authz().ensureOperationAllowedForPrincipal(Permission.of("webhook:read"))This resolves the principal's roles → collects all permissions (including inherited ones) → regex-matches against the required permission. If no match is found, a ForbiddenException (403) is thrown.
Beyond permission checks, the service layer verifies entity ownership. A partner.admin for partner A cannot access partner B's data — the BaseEntityAccessControl checks that the target entity ID appears in the principal's entityRoles[].ids.
Implement IgnoreAuthzFilterProvider to whitelist paths that bypass authentication entirely:
@Singleton
class MyIgnoreAuthzFilterProvider : IgnoreAuthzFilterProvider {
override fun ignoreRegexes(): List<String> = listOf(
"/api/v1/public/.*",
"/health.*"
)
}Matching paths skip the AuthzFilter, so no token is required.
Access control starts at the controller level and works in layers:
@Authorizedon the class — enables the authz interceptor for all methods@AuthzCheckon each method — binds a specificAccessControlclass that enforces permissions
@Path("/api/v1/users")
@Authorized
class UserController {
@Inject
lateinit var userService: UserService
@GET
@Path("/{userId}")
@AuthzCheck(ReadUserAccessControl::class)
fun getUser(@PathParam("userId") userId: UUID): UserResponse {
return userService.getUser(userId)
}
@POST
@AuthzCheck(CreateUserAccessControl::class)
fun createUser(request: CreateUserRequest): UserResponse {
return userService.createUser(request)
}
}Both @Authorized (class-level) and @AuthzCheck (method-level) are required. Without @Authorized, the interceptor is not activated and @AuthzCheck has no effect.
The AccessControl interface provides before and after hooks around the annotated method. Use ctx.authz() to access the AuthzContext helper methods.
Key principles:
- Backoffice users (global permissions) typically have full access — check with
principalHasGlobalPermission() - Entity users (partners, merchants) are restricted to their domain — match entity IDs from the path or result against the principal's entity IDs
- If the entity ID is in the path (e.g.
/partner/{partnerId}), validate inbefore()usingextractEntityId - If the entity ID is only in the result (e.g. reading a user whose entity isn't in the URL), validate in
after() - If the entity ID is not directly available, inject a repository to look it up (e.g. find a transaction by ID, then check its partnerId/merchantId)
When the entity ID is available from the method arguments (e.g. a path parameter or request body), use BaseEntityAccessControl:
class CreateUserAccessControl : BaseEntityAccessControl(
permission = Permission.of("users:create"),
entityType = "org",
extractEntityId = { ctx -> ctx.firstOfType(CreateUserRequest::class.java).orgId }
)This handles both backoffice (global permission → allow) and entity-scoped checks in a single line.
When the entity ID is not in the request path, enforce scoping in the after() hook by inspecting the result:
class ReadUserAccessControl : AccessControl<Any?> {
private val permission = Permission.of("user:read")
override fun before(ctx: DefaultAccessControlContext) {
// Ensure the principal has the permission at all (global or entity-level)
ctx.authz().ensureOperationAllowedForPrincipal(permission)
}
override fun after(result: Any?, ctx: DefaultAccessControlContext): Any? {
if (result !is UserResponse) return result
val targetEntityId = result.entityId ?: return result
// Backoffice users have global permission — allow access to any user
if (ctx.authz().principalHasGlobalPermission(permission)) {
return result
}
// Partner-scoped: target user's entityId must be in the principal's allowed partner IDs
if (ctx.authz().principalHasEntityRole("partner")) {
val allowedIds = ctx.authz().specificEntityIds(permission, "partner")
if (targetEntityId !in allowedIds) {
throw AuthzException(
AuthzErrorCodes.PERMISSION_DENIED,
"User access denied: principal does not have access to user in entity $targetEntityId"
)
}
return result
}
// Merchant-scoped: target user's entityId must be in the principal's allowed merchant IDs
if (ctx.authz().principalHasEntityRole("merchant")) {
val allowedIds = ctx.authz().specificEntityIds(permission, "merchant")
if (targetEntityId !in allowedIds) {
throw AuthzException(
AuthzErrorCodes.PERMISSION_DENIED,
"User access denied: principal does not have access to user in entity $targetEntityId"
)
}
return result
}
// No matching entity role and no global permission — deny
throw AuthzException(
AuthzErrorCodes.PERMISSION_DENIED,
"User access denied: principal has no entity scope for user in entity $targetEntityId"
)
}
}When you need to resolve the entity from another source (e.g. a transaction ID in the path), make the AccessControl class a CDI bean and inject a repository:
@ApplicationScoped
class ReadTransactionAccessControl : AccessControl<Any?> {
@Inject
lateinit var transactionRepository: TransactionRepository
private val permission = Permission.of("transaction:read")
override fun before(ctx: DefaultAccessControlContext) {
ctx.authz().ensureOperationAllowedForPrincipal(permission)
// Backoffice can access anything
if (ctx.authz().principalHasGlobalPermission(permission)) return
// Look up the transaction to find which entity it belongs to
val transactionId = ctx.firstArg<String>()
val transaction = transactionRepository.findById(transactionId)
?: throw AuthzException(AuthzErrorCodes.PERMISSION_DENIED, "Transaction not found")
// Check partner access
if (ctx.authz().principalHasEntityRole("partner")) {
val allowedIds = ctx.authz().specificEntityIds(permission, "partner")
if (transaction.partnerId !in allowedIds) {
throw AuthzException(AuthzErrorCodes.PERMISSION_DENIED, "Access denied to transaction")
}
return
}
// Check merchant access
if (ctx.authz().principalHasEntityRole("merchant")) {
val allowedIds = ctx.authz().specificEntityIds(permission, "merchant")
if (transaction.merchantId !in allowedIds) {
throw AuthzException(AuthzErrorCodes.PERMISSION_DENIED, "Access denied to transaction")
}
return
}
throw AuthzException(AuthzErrorCodes.PERMISSION_DENIED, "No entity scope for transaction")
}
}The ctx.authz() object provides the following methods for access control logic:
| Method | Use |
|---|---|
ensureOperationAllowedForPrincipal(perm) |
Pre-check: principal has the permission globally or for any entity |
principalHasGlobalPermission(perm) |
Check if backoffice-level (global) access — if true, skip entity checks |
principalHasEntityRole(type) |
Check if the principal has any role for an entity type (e.g. "partner") |
specificEntityIds(perm, type) |
Get the list of entity IDs the principal can access for a permission + type |
ensurePrincipalHasPermission(perm, type, entityId) |
All-in-one: checks global OR entity-scoped access for a specific entity ID |
principalHasPermission(perm, type, entityId) |
Boolean version of the above |
The authz-testing module provides MockTokenExchangeService, a TokenExchangePlugin that maps fixed token strings to principals:
| Token | Principal |
|---|---|
backoffice-admin-token |
Global role backoffice.admin |
no-roles-token |
No roles |
org-user-token |
Entity role org.user for entity org-1 |
Use it in integration tests by passing these tokens as Bearer tokens in the Authorization header.