Beyond @RolesAllowed: Fine-Grained RBAC in Quarkus
Secure every document with per-object permissions, JWT identity, and a lean 100-line service.
Most business systems eventually need more than a single admin or user role. Auditors want to know exactly who could read a tax document last quarter, tenants insist that one customer never sees another customer’s data, and regulators expect least-privilege enforcement to be demonstrable. Static annotations such as @RolesAllowed("admin")
solve none of this because they bind users to roles, not users to objects.
A rights matrix, user × resource × permission, answers the need, and Quarkus makes the implementation surprisingly compact. You are about to build a document service where every document carries its own ACL, enforced by Quarkus’ permission checker API and protected by JWTs. You can learn more about Quarkus’ security in this guide.
Project setup
Create a new project and pull in REST, persistence, and security:
mvn io.quarkus.platform:quarkus-maven-plugin:create \
-DprojectGroupId=com.example \
-DprojectArtifactId=granular-rbac-tutorial \
-Dextensions="rest-jackson,hibernate-orm-panache,jdbc-postgresql,smallrye-jwt, hibernate-validator"
cd granular-rbac-tutorial
Add BCrypt for password hashing to pom.xml:
<dependency>
<groupId>org.mindrot</groupId>
<artifactId>jbcrypt</artifactId>
<version>0.4</version>
</dependency>
If you want to get started from the full example, check out my Github repository.
Configuration
Replace src/main/resources/application.properties:
# --- datasource ---
quarkus.datasource.db-kind=postgresql
# generate schema on every dev run
quarkus.hibernate-orm.database.generation=drop-and-create
quarkus.hibernate-orm.log.sql=true
# --- JWT ---
mp.jwt.verify.issuer=https://example.com/issuer
mp.jwt.verify.publickey.location=META-INF/resources/publicKey.pem
smallrye.jwt.sign.key.location=keys/privateKey.pem
smallrye.jwt.new-token.claims-to-string=upn,groups
Generate the key pair once:
mkdir keys
openssl genrsa -out keys/privateKey.pem 2048
openssl rsa -in keys/privateKey.pem -pubout -out keys/publicKey.pem
mv keys/publicKey.pem src/main/resources/META-INF/resources/
Keep privateKey.pem outside version control; Quarkus will read it from keys/ during dev.
Domain model
A robust security model starts in the database. Before you write a single permission check, you need a schema that makes intent explicit; who owns a document, which actions exist, and how a single lookup can tell Quarkus whether a user may proceed. The forthcoming entities therefore capture three essential facts: users, documents, and the rights that connect them.
DocumentRight.java
package com.example.entity;
public enum DocumentRight {
READ, WRITE, DELETE, SHARE
}
User.java
package com.example.entity;
import org.mindrot.jbcrypt.BCrypt;
import io.quarkus.hibernate.orm.panache.PanacheEntity;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.Table;
@Entity
@Table(name = "app_users") // “user” is reserved in Postgres
public class User extends PanacheEntity {
@Column(unique = true, nullable = false)
public String username;
@Column(nullable = false)
public String password;
public static User findByUsername(String username) {
return find("username", username).firstResult();
}
public static User add(String username, String rawPassword) {
User u = new User();
u.username = username;
u.password = BCrypt.hashpw(rawPassword, BCrypt.gensalt());
u.persist();
return u;
}
}
Document.java
package com.example.entity;
import java.time.LocalDateTime;
import io.quarkus.hibernate.orm.panache.PanacheEntity;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.FetchType;
import jakarta.persistence.JoinColumn;
import jakarta.persistence.ManyToOne;
import jakarta.persistence.PrePersist;
import jakarta.persistence.PreUpdate;
import jakarta.persistence.Table;
@Entity
@Table(name = "documents")
public class Document extends PanacheEntity {
@Column(nullable = false)
public String title;
@Column(columnDefinition = "text")
public String content;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "owner_id", nullable = false)
public User owner;
@Column(name = "created_at", nullable = false, updatable = false)
public LocalDateTime createdAt;
@Column(name = "updated_at")
public LocalDateTime updatedAt;
@PrePersist
void initTimestamps() {
createdAt = LocalDateTime.now();
}
@PreUpdate
void touch() {
updatedAt = LocalDateTime.now();
}
}
UserDocumentRight.java
package com.example.entity;
import io.quarkus.hibernate.orm.panache.PanacheEntity;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.EnumType;
import jakarta.persistence.Enumerated;
import jakarta.persistence.FetchType;
import jakarta.persistence.JoinColumn;
import jakarta.persistence.ManyToOne;
import jakarta.persistence.Table;
import jakarta.persistence.UniqueConstraint;
@Entity
@Table(name = "user_document_rights", uniqueConstraints = @UniqueConstraint(columnNames = { "user_id", "document_id",
"right_type" }))
public class UserDocumentRight extends PanacheEntity {
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "user_id", nullable = false)
public User user;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "document_id", nullable = false)
public Document document;
@Enumerated(EnumType.STRING)
@Column(name = "right_type", nullable = false)
public DocumentRight right;
public static boolean hasRight(Long userId, Long docId, DocumentRight r) {
return count("user.id = ?1 and document.id = ?2 and right = ?3",
userId, docId, r) > 0;
}
}
The complete Domain model looks like this:
Authentication and JWT issuance
Every permission check ultimately relies on a trustworthy identity. If you cannot prove who the caller is, a perfectly modelled rights matrix is worthless. Quarkus solves identification with JSON Web Tokens: a signed, tamper-evident bundle of claims that travels with every request. In this section you will expose two minimal endpoints: register and login. We hash user passwords securely, mint a JWT on successful login, and include a custom user_id
claim your permission checker can consume without additional database round-trips.
DTO
package com.example.dto;
public class AuthRequest {
public String username;
public String password;
}
AuthResource.java
package com.example.controller;
import java.time.Duration;
import org.eclipse.microprofile.jwt.Claims;
import org.mindrot.jbcrypt.BCrypt;
import com.example.dto.AuthRequest;
import com.example.entity.User;
import io.smallrye.jwt.build.Jwt;
import jakarta.enterprise.context.ApplicationScoped;
import jakarta.transaction.Transactional;
import jakarta.validation.Valid;
import jakarta.ws.rs.Consumes;
import jakarta.ws.rs.POST;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.Produces;
import jakarta.ws.rs.core.MediaType;
import jakarta.ws.rs.core.Response;
@Path("/auth")
@ApplicationScoped
@Consumes(MediaType.APPLICATION_JSON)
@Produces(MediaType.APPLICATION_JSON)
public class AuthResource {
@POST
@Path("/register")
@Transactional
public Response register(@Valid AuthRequest req) {
if (User.findByUsername(req.username) != null)
return Response.status(Response.Status.CONFLICT).build();
User.add(req.username, req.password);
return Response.status(Response.Status.CREATED).build();
}
@POST
@Path("/login")
@Transactional
public Response login(@Valid AuthRequest req) {
User u = User.findByUsername(req.username);
if (u == null || !BCrypt.checkpw(req.password, u.password))
return Response.status(Response.Status.UNAUTHORIZED).build();
String token = Jwt.issuer("https://example.com/issuer")
.upn(u.username)
.claim(Claims.email.name(), u.username + "@example.com")
.claim("user_id", u.id.toString())
.expiresIn(Duration.ofHours(1))
.sign();
return Response.ok(token).build();
}
}
Permission service
The PermissionService
is the single authority that answers the question, “May this user perform this action on this document?” It collapses all business rules—ownership implies full access, explicit rights grant limited access, only owners or users with SHARE
may delegate or revoke, into one place, so controllers stay slim and security logic remains testable and cacheable.
PermissionService.java
package com.example.service;
import com.example.entity.Document;
import com.example.entity.DocumentRight;
import com.example.entity.User;
import com.example.entity.UserDocumentRight;
import jakarta.enterprise.context.ApplicationScoped;
import jakarta.transaction.Transactional;
@ApplicationScoped
public class PermissionService {
public boolean hasPermission(Long userId, Long docId, DocumentRight right) {
if (userId == null || docId == null)
return false;
Document doc = Document.findById(docId);
if (doc == null)
return false;
if (doc.owner.id.equals(userId))
return true; // owner = all rights
return UserDocumentRight.hasRight(userId, docId, right);
}
@Transactional
public void grantPermission(User target, Document doc,
DocumentRight right, User granter) {
if (!hasPermission(granter.id, doc.id, DocumentRight.SHARE))
throw new SecurityException("Missing SHARE on document " + doc.id);
if (UserDocumentRight.hasRight(target.id, doc.id, right))
return;
UserDocumentRight r = new UserDocumentRight();
r.user = target;
r.document = doc;
r.right = right;
r.persist();
}
@Transactional
public long revokePermission(User target, Document doc,
DocumentRight right, User revoker) {
if (!hasPermission(revoker.id, doc.id, DocumentRight.SHARE))
throw new SecurityException("Missing SHARE on document " + doc.id);
if (doc.owner.id.equals(target.id))
throw new IllegalArgumentException("Cannot revoke owner rights");
return UserDocumentRight.delete("user=?1 and document=?2 and right=?3",
target, doc, right);
}
}
Quarkus permission checker
The permission checker is the glue between your declarative @PermissionsAllowed
annotations and the imperative logic in PermissionService
. At build time Quarkus maps each permission string to the corresponding @PermissionChecker
method, injects the current SecurityIdentity
, resolves any path parameters, and calls that method before the target endpoint is executed. This keeps authorization decisions type safe, centralised, and free from runtime reflection overhead.
DocumentPermissionChecker.java
package com.example.security;
import org.eclipse.microprofile.jwt.JsonWebToken;
import com.example.entity.DocumentRight;
import com.example.service.PermissionService;
import io.quarkus.security.PermissionChecker;
import io.quarkus.security.identity.SecurityIdentity;
import jakarta.enterprise.context.ApplicationScoped;
import jakarta.inject.Inject;
@ApplicationScoped
public class DocumentPermissionChecker {
@Inject
PermissionService perms;
@PermissionChecker("document-read")
public boolean canRead(SecurityIdentity identity, Long id) {
return check(identity, id, DocumentRight.READ);
}
@PermissionChecker("document-write")
public boolean canWrite(SecurityIdentity identity, Long id) {
return check(identity, id, DocumentRight.WRITE);
}
@PermissionChecker("document-delete")
public boolean canDelete(SecurityIdentity identity, Long id) {
return check(identity, id, DocumentRight.DELETE);
}
@PermissionChecker("document-share")
public boolean canShare(SecurityIdentity identity, Long id) {
return check(identity, id, DocumentRight.SHARE);
}
private boolean check(SecurityIdentity identity, Long id, DocumentRight r) {
if (identity.isAnonymous())
return false;
JsonWebToken jwt = (JsonWebToken) identity.getPrincipal();
String uid = jwt.getClaim("user_id");
return uid != null && perms.hasPermission(Long.parseLong(uid), id, r);
}
}
Document resource
DocumentResource
presents the REST facade that consumers interact with: CRUD endpoints for documents plus ACL management endpoints for granting and revoking rights. Each method stays thin because it delegates authorization to @PermissionsAllowed
and business logic to PermissionService
, leaving just request mapping, DTO conversion, and transaction boundaries.
DTOs
package com.example.dto;
import java.time.LocalDateTime;
public class DocumentDto {
public Long id;
public String title;
public String content;
public String ownerUsername;
public LocalDateTime createdAt;
public LocalDateTime updatedAt;
}
package com.example.dto;
import com.example.entity.DocumentRight;
public class PermissionDto {
public String targetUsername;
public DocumentRight right;
}
DocumentResource.java
package com.example.controller;
import org.eclipse.microprofile.jwt.JsonWebToken;
import com.example.dto.DocumentDto;
import com.example.dto.PermissionDto;
import com.example.entity.Document;
import com.example.entity.User;
import com.example.service.PermissionService;
import io.quarkus.security.Authenticated;
import io.quarkus.security.PermissionsAllowed;
import io.quarkus.security.identity.SecurityIdentity;
import jakarta.inject.Inject;
import jakarta.transaction.Transactional;
import jakarta.ws.rs.Consumes;
import jakarta.ws.rs.DELETE;
import jakarta.ws.rs.GET;
import jakarta.ws.rs.NotFoundException;
import jakarta.ws.rs.POST;
import jakarta.ws.rs.PUT;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.PathParam;
import jakarta.ws.rs.Produces;
import jakarta.ws.rs.core.MediaType;
import jakarta.ws.rs.core.Response;
@Path("/documents")
@Authenticated
@Consumes(MediaType.APPLICATION_JSON)
@Produces(MediaType.APPLICATION_JSON)
public class DocumentResource {
@Inject
SecurityIdentity identity;
@Inject
PermissionService perms;
private User currentUser() {
JsonWebToken jwt = (JsonWebToken) identity.getPrincipal();
String userId = jwt.getClaim("user_id");
Long id = Long.parseLong(userId);
return User.findById(id);
}
@POST
@Transactional
public Response create(DocumentDto in) {
User me = currentUser();
Document d = new Document();
d.title = in.title;
d.content = in.content;
d.owner = me;
d.persistAndFlush();
return Response.status(Response.Status.CREATED).entity(toDto(d)).build();
}
@GET
@Path("/{id}")
@PermissionsAllowed("document-read")
public DocumentDto get(@PathParam("id") Long id) {
Document d = Document.findById(id);
if (d == null)
throw new NotFoundException();
return toDto(d);
}
@PUT
@Path("/{id}")
@PermissionsAllowed("document-write")
@Transactional
public DocumentDto update(@PathParam("id") Long id, DocumentDto in) {
Document d = Document.findById(id);
if (d == null)
throw new NotFoundException();
d.title = in.title;
d.content = in.content;
return toDto(d);
}
@DELETE
@Path("/{id}")
@PermissionsAllowed("document-delete")
@Transactional
public Response delete(@PathParam("id") Long id) {
if (!Document.deleteById(id))
throw new NotFoundException();
return Response.noContent().build();
}
// --- ACL endpoints ----------------------------------------------------
@POST
@Path("/{id}/permissions")
@PermissionsAllowed("document-share")
@Transactional
public Response grant(@PathParam("id") Long id, PermissionDto dto) {
User me = currentUser();
Document doc = Document.findById(id);
if (doc == null)
throw new NotFoundException();
User target = User.findByUsername(dto.targetUsername);
if (target == null)
throw new NotFoundException("User not found");
perms.grantPermission(target, doc, dto.right, me);
return Response.ok("granted").build();
}
@DELETE
@Path("/{id}/permissions")
@PermissionsAllowed("document-share")
@Transactional
public Response revoke(@PathParam("id") Long id, PermissionDto dto) {
User me = currentUser();
Document doc = Document.findById(id);
if (doc == null)
throw new NotFoundException();
User target = User.findByUsername(dto.targetUsername);
if (target == null)
throw new NotFoundException("User not found");
perms.revokePermission(target, doc, dto.right, me);
return Response.ok("revoked").build();
}
private static DocumentDto toDto(Document d) {
DocumentDto out = new DocumentDto();
out.id = d.id;
out.title = d.title;
out.content = d.content;
out.ownerUsername = d.owner.username;
out.createdAt = d.createdAt;
out.updatedAt = d.updatedAt;
return out;
}
}
Run and exercise the API
Start Quarkus in dev mode. Dev Services launches PostgreSQL automatically if Docker is available.
./mvnw quarkus:dev
Register users
curl -X POST localhost:8080/auth/register -H 'Content-Type: application/json' \
-d '{"username":"alice","password":"pw"}'
curl -X POST localhost:8080/auth/register -H 'Content-Type: application/json' \
-d '{"username":"bob","password":"pw"}'
Login
ALICE=$(curl -sX POST localhost:8080/auth/login -H 'Content-Type: application/json' \
-d '{"username":"alice","password":"pw"}')
BOB=$(curl -sX POST localhost:8080/auth/login -H 'Content-Type: application/json' \
-d '{"username":"bob","password":"pw"}')
Create, share, enforce
# alice creates a document
curl -X POST localhost:8080/documents -H "Authorization: Bearer $ALICE" \
-H 'Content-Type: application/json' \
-d '{"title":"Phoenix","content":"top secret"}'
# bob cannot read it yet
curl -i localhost:8080/documents/1 -H "Authorization: Bearer $BOB"
# 403
# alice grants READ
curl -X POST localhost:8080/documents/1/permissions -H "Authorization: Bearer $ALICE" \
-H 'Content-Type: application/json' \
-d '{"targetUsername":"bob","right":"READ"}'
# bob succeeds
curl -i localhost:8080/documents/1 -H "Authorization: Bearer $BOB"
# 200
High-Level Permission Checking System
This is a granular Role-Based Access Control (RBAC) system for document management with the following key concepts:
Core Components:
JWT Authentication: Users authenticate and receive JWT tokens containing their user_id
Document Ownership: Each document has an owner who automatically has all permissions
Granular Permissions: Four specific rights can be granted to users:
READ - View document content
WRITE - Modify document content
DELETE - Remove the document
SHARE - Grant/revoke permissions to other users
Permission Storage: Explicit permissions are stored in UserDocumentRight entities
Permission Checking Flow:
Request arrives with JWT token containing user_id
Quarkus Security validates JWT and extracts user identity
Permission Checker intercepts method calls annotated with @PermissionsAllowed
Permission Service evaluates access:
If user is document owner → ALLOW (all rights)
Otherwise, check explicit permissions in database
Decision is made to allow or deny the request
Here's the mermaid diagram showing the complete flow:
What next?
Cache hot paths. Annotate PermissionService.hasPermission
with @CacheResult
once Quarkus-cache is on the classpath.
Index for scale. Add a composite index on (user_id, document_id, right_type)
in the user_document_rights
table if your workload exceeds hundreds of thousands of rows.
Model inheritance. Introduce a Team
entity and resolve permissions by membership to reflect organisational structure.
Audit everything. Wrap grantPermission
and revokePermission
with an @TransactionalEventObserver
that emits immutable audit records.
Small modules, explicit business rules, one authority point. That is sustainable enterprise security.