Hardcoding @RolesAllowed("manager")
works for demos. It crumbles when requirements change weekly. You need dynamic, fine-grained control you can change at runtime without redeploying.
We’ll build a database-driven authorization system in Quarkus where permissions are data, not code. An admin can grant or revoke CRUD rights for users and groups via REST. We’ll keep requests fast with a cache and enforce checks declaratively with an interceptor.
Thanks for reading The Main Thread! Subscribe for free to receive new posts and support my work.
You’ll run everything locally with Quarkus Dev Services. No Docker/Podman Compose files. If your machine uses Podman as the container runtime, Dev Services will use it automatically when configured as your default.
Bootstrap the project
quarkus create app com.example:dynamic-access \
--extension="hibernate-orm-panache,quarkus-jdbc-postgresql,quarkus-rest-jackson,quarkus-smallrye-openapi,quarkus-cache,quarkus-elytron-security-properties-file" \
--no-code
cd dynamic-access
What we added:
hibernate-orm-panache
for entities/repositoriesquarkus-jdbc-postgresql
to trigger Dev Services PostgreSQLquarkus-rest-jackson
for REST + JSONquarkus-smallrye-openapi
for/q/openapi
and/q/swagger-ui
quarkus-cache
for permission cachingquarkus-elytron-security-properties-file
for simple BASIC auth users in dev
You can find the full project in my Github repository. Do not forget to leave a star if you like it!
Configure Dev Services and security
Dev Services starts PostgreSQL automatically when you don’t set a JDBC URL. Keep config minimal:
src/main/resources/application.properties
# Hibernate schema generation for the demo
quarkus.hibernate-orm.database.generation=drop-and-create
# Optional: make the DB kind explicit
quarkus.datasource.db-kind=postgresql
# Dev-only HTTP basic auth using properties files
quarkus.http.auth.basic=true
quarkus.security.users.file.enabled=true
quarkus.security.users.file.plain-text=true
quarkus.security.users.file.users=users.properties
quarkus.security.users.file.roles=roles.properties
Add dev users:
src/main/resources/users.properties
admin=admin
alice=alice
src/main/resources/roles.properties
admin=admin
alice=user
admin
has static roleadmin
for protected admin endpoints.alice
is a regular user. We’ll grant her dynamic permissions through the API.
Seed alice and bob in the database:
src/main/resources/import.sql
-- Users
INSERT INTO app_users (id, username, password, role)
VALUES (1, 'admin', 'admin', 'admin');
INSERT INTO app_users (id, username, password, role)
VALUES (2, 'alice', 'alice', 'user');
-- Groups
INSERT INTO app_groups (id, name)
VALUES (1, 'project-managers');
-- Alice belongs to project-managers
INSERT INTO user_groups (user_id, group_id)
VALUES (2, 1);
-- Demo Projects
INSERT INTO projects (id, name, description)
VALUES
(1, 'Apollo', 'Internal knowledge base migration project'),
(2, 'Hermes', 'Next-generation messaging platform'),
(3, 'Zephyr', 'Performance tuning and optimization effort');
Notes:
The
id
values assume you’re controlling primary keys manually for demo purposes. If you’re using PostgreSQL with Hibernate auto-generation (SERIAL
/BIGSERIAL
), you can omitid
and let the DB generate it.These values (
password
column) are plain text only for demonstration. In production, always hash passwords.
Model “permissions as data”
A permission links a principal (user or group) to an action on a resource type.
Create the entities.
src/main/java/com/example/auth/Action.java
package com.example.auth;
public enum Action {
CREATE, READ, UPDATE, DELETE
}
src/main/java/com/example/auth/UserEntity.java
package com.example.auth;
import java.util.Set;
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.JoinTable;
import jakarta.persistence.ManyToMany;
import jakarta.persistence.Table;
@Entity
@Table(name = "app_users")
public class UserEntity extends PanacheEntity {
@Column(unique = true, nullable = false)
public String username;
// Not used for auth in this demo (we use properties-file). Keep for
// completeness.
public String password;
// Optional static role for coarse-grained checks
public String role;
@ManyToMany(fetch = FetchType.EAGER)
@JoinTable(name = "user_groups", joinColumns = @JoinColumn(name = "user_id"), inverseJoinColumns = @JoinColumn(name = "group_id"))
public Set<GroupEntity> groups;
}
src/main/java/com/example/auth/GroupEntity.java
package com.example.auth;
import io.quarkus.hibernate.orm.panache.PanacheEntity;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.Table;
@Entity
@Table(name = "app_groups")
public class GroupEntity extends PanacheEntity {
@Column(unique = true, nullable = false)
public String name;
}
src/main/java/com/example/auth/PermissionEntity.java
package com.example.auth;
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;
@Entity
@Table(name = "permissions")
public class PermissionEntity extends PanacheEntity {
@Column(nullable = false)
public String resourceType; // e.g. "Project"
@Enumerated(EnumType.STRING)
@Column(nullable = false)
public Action action;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "user_id")
public UserEntity user;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "group_id")
public GroupEntity group;
}
We’ll also add a tiny domain entity to protect.
src/main/java/com/example/project/Project.java
package com.example.project;
import io.quarkus.hibernate.orm.panache.PanacheEntity;
import jakarta.persistence.Entity;
import jakarta.persistence.Table;
@Entity
@Table(name = "projects")
public class Project extends PanacheEntity {
public String name;
public String description;
}
Cache user permissions for speed
We fetch all permissions for a user once, then check in memory.
src/main/java/com/example/auth/PermissionService.java
package com.example.auth;
import java.util.Set;
import java.util.stream.Collectors;
import io.quarkus.cache.CacheResult;
import jakarta.enterprise.context.ApplicationScoped;
import io.quarkus.logging.Log;
@ApplicationScoped
public class PermissionService {
@CacheResult(cacheName = "user-permissions")
public Set<String> getPermissionsForUser(String username) {
Log.info("--- DB HIT: permissions for " + username + " ---");
UserEntity user = UserEntity.find("username", username).firstResult();
if (user == null)
return Set.of();
var groupIds = (user.groups == null) ? Set.<Long>of()
: user.groups.stream().map(g -> g.id).collect(Collectors.toSet());
return PermissionEntity.<PermissionEntity>stream(
"(user.id = ?1 or group.id in ?2)", user.id, groupIds.isEmpty() ? Set.of(-1L) : groupIds)
.map(p -> p.resourceType + ":" + p.action)
.collect(Collectors.toSet());
}
}
Admin Service
We want to update the Permissions and encapsulate the logic in a separate service class:
src/main/java/com/example/auth/PermissionAdminService.java
package com.example.admin;
import com.example.auth.Action;
import com.example.auth.GroupEntity;
import com.example.auth.PermissionEntity;
import com.example.auth.UserEntity;
import io.quarkus.cache.CacheInvalidateAll;
import jakarta.enterprise.context.ApplicationScoped;
import jakarta.transaction.Transactional;
import jakarta.ws.rs.BadRequestException;
import jakarta.ws.rs.NotFoundException;
@ApplicationScoped
public class PermissionAdminService {
public static class PermissionRequest {
public String resourceType;
public Action action;
public Long userId;
public Long groupId;
}
@Transactional
@CacheInvalidateAll(cacheName = "user-permissions")
public PermissionResult grantPermission(PermissionRequest req) {
if (req == null || req.resourceType == null || req.action == null)
throw new BadRequestException("resourceType and action are required");
// Check for existing permission to prevent duplicates
PermissionEntity existing = null;
if (req.userId != null) {
UserEntity user = UserEntity.findById(req.userId);
if (user == null)
throw new NotFoundException("user not found: " + req.userId);
existing = PermissionEntity.find("resourceType = ?1 and action = ?2 and user.id = ?3",
req.resourceType, req.action, req.userId).firstResult();
} else if (req.groupId != null) {
GroupEntity group = GroupEntity.findById(req.groupId);
if (group == null)
throw new NotFoundException("group not found: " + req.groupId);
existing = PermissionEntity.find("resourceType = ?1 and action = ?2 and group.id = ?3",
req.resourceType, req.action, req.groupId).firstResult();
} else {
throw new BadRequestException("Either userId or groupId must be provided.");
}
if (existing != null) {
return new PermissionResult(existing, false); // Existing permission
}
// Create new permission
PermissionEntity p = new PermissionEntity();
p.resourceType = req.resourceType;
p.action = req.action;
if (req.userId != null) {
p.user = UserEntity.findById(req.userId);
} else {
p.group = GroupEntity.findById(req.groupId);
}
p.persist();
return new PermissionResult(p, true); // New permission created
}
@Transactional
@CacheInvalidateAll(cacheName = "user-permissions")
public boolean revokePermission(Long id) {
return PermissionEntity.deleteById(id);
}
public static class PermissionResult {
public final PermissionEntity permission;
public final boolean wasCreated;
public PermissionResult(PermissionEntity permission, boolean wasCreated) {
this.permission = permission;
this.wasCreated = wasCreated;
}
}
}
Central access control service
Read the current SecurityIdentity
, use cache, and allow a static admin
override.
src/main/java/com/example/auth/AccessControlService.java
package com.example.auth;
import java.util.Set;
import io.quarkus.security.identity.SecurityIdentity;
import jakarta.enterprise.context.ApplicationScoped;
import jakarta.inject.Inject;
@ApplicationScoped
public class AccessControlService {
@Inject
SecurityIdentity identity;
@Inject
PermissionService permissionService;
public boolean canPerformAction(String resourceType, Action action) {
if (identity.isAnonymous())
return false;
if (identity.hasRole("admin"))
return true;
String username = identity.getPrincipal().getName();
Set<String> permissions = permissionService.getPermissionsForUser(username);
// Check for exact permission first
if (permissions.contains(resourceType + ":" + action)) {
return true;
}
// Check for permission hierarchy - higher permissions include lower ones
return hasImpliedPermission(permissions, resourceType, action);
}
private boolean hasImpliedPermission(Set<String> permissions, String resourceType, Action requestedAction) {
// Define permission hierarchy: higher permissions imply lower ones
switch (requestedAction) {
case READ:
// READ is implied by CREATE, UPDATE, DELETE
return permissions.contains(resourceType + ":" + Action.CREATE) ||
permissions.contains(resourceType + ":" + Action.UPDATE) ||
permissions.contains(resourceType + ":" + Action.DELETE);
default:
return false; // No implied permissions for CREATE, UPDATE, DELETE
}
}
}
When checking READ permission:
Direct READ permission → Access granted
CREATE permission → Implies READ → Access granted
UPDATE permission → Implies READ → Access granted
DELETE permission → Implies READ → Access granted
When checking CREATE/UPDATE/DELETE permissions:
- Only exact permission grants access (no cascading upward)
Declarative checks with an interceptor
Annotate methods/classes with the required resource/action. The interceptor blocks unauthorized calls.
src/main/java/com/example/auth/CheckAccess.java
package com.example.auth;
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
import jakarta.enterprise.util.Nonbinding;
import jakarta.interceptor.InterceptorBinding;
@InterceptorBinding
@Retention(RetentionPolicy.RUNTIME)
@Target({ ElementType.METHOD, ElementType.TYPE })
public @interface CheckAccess {
@Nonbinding
String resourceType() default "";
@Nonbinding
Action action() default Action.READ;
}
src/main/java/com/example/auth/CheckAccessInterceptor.java
package com.example.auth;
import jakarta.annotation.Priority;
import jakarta.inject.Inject;
import jakarta.interceptor.AroundInvoke;
import jakarta.interceptor.Interceptor;
import jakarta.interceptor.InvocationContext;
import jakarta.ws.rs.ForbiddenException;
@CheckAccess
@Interceptor
@Priority(Interceptor.Priority.APPLICATION)
public class CheckAccessInterceptor {
@Inject
AccessControlService acs;
@AroundInvoke
public Object check(InvocationContext ctx) throws Exception {
CheckAccess ann = ctx.getMethod().getAnnotation(CheckAccess.class);
if (ann != null && !acs.canPerformAction(ann.resourceType(), ann.action())) {
throw new ForbiddenException("You do not have permission to perform this action.");
}
return ctx.proceed();
}
}
Protect a JAX-RS resource
A minimal Project API guarded by dynamic permissions.
src/main/java/com/example/project/ProjectResource.java
package com.example.project;
import java.util.List;
import com.example.auth.Action;
import com.example.auth.CheckAccess;
import jakarta.transaction.Transactional;
import jakarta.ws.rs.Consumes;
import jakarta.ws.rs.GET;
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("/projects")
@Produces(MediaType.APPLICATION_JSON)
@Consumes(MediaType.APPLICATION_JSON)
public class ProjectResource {
@GET
@CheckAccess(resourceType = "Project", action = Action.READ)
public Response list() {
List<Project> projects = Project.listAll();
return Response.ok(projects).build();
}
@POST
@Transactional
@CheckAccess(resourceType = "Project", action = Action.CREATE)
public Response create(Project project) {
project.persist();
return Response.status(Response.Status.CREATED).entity(project).build();
}
}
Admin APIs and cache invalidation
Admins can grant/revoke permissions at runtime. We clear the user-permission cache so changes take effect on the next request.
src/main/java/com/example/admin/PermissionAdminResource.java
package com.example.admin;
import com.example.auth.Action;
import com.example.auth.GroupEntity;
import com.example.auth.PermissionEntity;
import com.example.auth.UserEntity;
import io.quarkus.cache.CacheInvalidateAll;
import jakarta.annotation.security.RolesAllowed;
import jakarta.transaction.Transactional;
import jakarta.ws.rs.BadRequestException;
import jakarta.ws.rs.Consumes;
import jakarta.ws.rs.DELETE;
import jakarta.ws.rs.NotFoundException;
import jakarta.ws.rs.POST;
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("/admin/permissions")
@Produces(MediaType.APPLICATION_JSON)
@Consumes(MediaType.APPLICATION_JSON)
@RolesAllowed("admin")
public class PermissionAdminResource {
public static class PermissionRequest {
public String resourceType;
public Action action;
public Long userId;
public Long groupId;
}
@POST
@Transactional
@CacheInvalidateAll(cacheName = "user-permissions")
public Response grant(PermissionRequest req) {
if (req == null || req.resourceType == null || req.action == null)
throw new BadRequestException("resourceType and action are required");
PermissionEntity p = new PermissionEntity();
p.resourceType = req.resourceType;
p.action = req.action;
if (req.userId != null) {
p.user = UserEntity.findById(req.userId);
if (p.user == null)
throw new NotFoundException("user not found: " + req.userId);
} else if (req.groupId != null) {
p.group = GroupEntity.findById(req.groupId);
if (p.group == null)
throw new NotFoundException("group not found: " + req.groupId);
} else {
throw new BadRequestException("Either userId or groupId must be provided.");
}
p.persist();
return Response.status(Response.Status.CREATED).entity(p).build();
}
@DELETE
@Path("/{id}")
@Transactional
@CacheInvalidateAll(cacheName = "user-permissions")
public Response revoke(@PathParam("id") Long id) {
if (!PermissionEntity.deleteById(id))
throw new NotFoundException("permission not found: " + id);
return Response.noContent().build();
}
}
Security notes:
Only users with static role
admin
can call these endpoints.After any change, the
user-permissions
cache is cleared to avoid stale authorization.
Run and verify
Start in dev mode:
./mvnw quarkus:dev
Dev Services will pull and run PostgreSQL automatically. Watch the logs for the Dev Services banner and DB JDBC URL.
Open the Swagger UI at:
http://localhost:8080/q/swagger-ui
You can also test with curl
using Basic auth.
List projects as alice (should be 403 until we grant READ)
curl -i -u alice:alice http://localhost:8080/projects
Expected: 403 Forbidden
As admin, grant alice READ on Project
We’ve added Alice’s DB id with the import.sql so we know it. If you do this differently in your run, check select * from app_users
via your DB client or add a temporary finder resource in dev.
Grant Project READ to user Alice:
curl -i -u admin:admin -X POST http://localhost:8080/admin/permissions \
-H 'Content-Type: application/json' \
-d '{"resourceType":"Project","action":"READ","userId":2}'
Expected: JSON permission object with an id
.
Now, list as alice again:
curl -u alice:alice http://localhost:8080/projects
Expected: 200 [<all three projects>]
Create a project as alice (403), then grant CREATE, then retry
Try create:
curl -i -u alice:alice -X POST http://localhost:8080/projects \
-H 'Content-Type: application/json' \
-d '{"name":"Alpha","description":"Top secret"}'
Expected: 403 Forbidden
Grant CREATE:
curl -u admin:admin -X POST http://localhost:8080/admin/permissions \
-H 'Content-Type: application/json' \
-d '{"resourceType":"Project","action":"CREATE","userId":2}'
Retry create:
curl -i -u alice:alice -X POST http://localhost:8080/projects \
-H 'Content-Type: application/json' \
-d '{"name":"Alpha","description":"Top secret"}'
Expected: 200
with the created project JSON.
List again to see it:
curl -u alice:alice http://localhost:8080/projects
Expected: 200
with the Alpha
project.
Production notes
Identity : In production, use OIDC or enterprise SSO. Map the subject to your
UserEntity.username
. Keep theadmin
override only if your governance allows it.Distributed cache : In a cluster, back
quarkus-cache
with a distributed cache (e.g., Infinispan/Redis via appropriate extensions) or rely on short TTL and event-driven invalidation.Permissions scope : We used
resourceType
and CRUD actions. Extend with resource IDs or attribute-based rules if you need row-level security.Consistency : Admin endpoints clear the entire user-permission cache. For very large user bases, consider invalidating only impacted identities or using a cache key convention.
Auditing : Log grants/revokes and enforcement denials for compliance.
JWT claims alternative
While server-side caching is highly effective, another high-performance strategy is to embed permissions directly into the user's JWT. In this model, all of a user's permissions, like ["Project:CREATE", "Task:READ"]
, are fetched once at login and added to the JWT payload as a custom claim. This makes subsequent permission checks blazing fast and completely stateless, as it requires zero database or cache calls.
However, this approach comes with a significant security trade-off: stale data. Because the permissions are fixed for the token's lifetime, an administrator revoking a permission will not take effect until the user's token expires, which can be a major security risk. Furthermore, numerous permissions can lead to token bloat, increasing network overhead. In contrast, the recommended caching approach provides an excellent balance. It is nearly as fast while ensuring real-time data consistency through cache invalidation. For most applications where immediate permission revocation is a requirement, caching is the superior and safer choice.
Some thoughts on Permission Modeling and Cascading
Application logic based is what we implemented here. Keep permissions atomic in the DB and implement inheritance in AccessControlService
.
Advantage: fastest to ship, no schema churn, easy to evolve.
Disadvantage: every check needs the full set of leaf permissions in memory and your service code must stay consistent across endpoints.
Scaling: horizontal scaling works if you cache the flattened permission set per user. Invalidation is simple with cache busting on admin changes. Drawback is that complex hierarchies make the service logic harder to reason about and test.
Database-level hierarchy. Model implied rights in tables. Common patterns: a “permission_implies(permission_a, permission_b)” table, a role→permission join with a transitive closure table, or a bitmask per resource type.
Advantage: the DB answers “effective permissions” with one query. Easier to audit and report.
Disadvantage: writes get more complex. You need triggers or jobs to keep closures in sync.
Scaling: databases handle read heavy workloads well if you precompute closures or use materialized views. Caching works cleanly because you cache already-flattened permissions per user. Invalidation can be targeted to affected users.
Roles with inheritance. Introduce role→role inheritance and assign permissions to roles only.
Advantage: fewer rows to manage and clearer admin UX.
Disadvantage: debugging “why do I have this right” can be tricky across multiple inheritance levels.
Scaling: good fit for precomputation. Cache per-user “effective roles” and “effective permissions.” Invalidation scope remains manageable if you track which users are members of a changed role.
Attribute-Based Access Control (ABAC). Express rules like “project.ownerId == user.id implies UPDATE” or “department == user.department implies READ.”
Advantage: powerful and reduces permission sprawl.
Disadvantage: evaluation needs request context and resource attributes, so you cannot only rely on a static cached set.
Scaling: cache policy definitions and user attributes, but expect more cache misses due to per-resource evaluation. Consider a policy engine such as OPA for consistency and performance profiling.
Row-level security in the database. Use PostgreSQL RLS policies for “who can do what” and keep the app simple.
Advantage: single source of truth and strong data isolation.
Disadvantage: CRUD verbs still need to map to SQL capabilities, and complex business rules become harder to test outside the DB.
Scaling: read heavy is fine. Operational complexity rises for migrations, debugging, and cross-service reuse.
Rule of thumb: if your hierarchy is small and app-centric, keep it in application logic with a per-user cached, flattened set and clear invalidation. If you need auditability, reporting, and many teams manipulating rights, move the hierarchy into the database and precompute closures. For maximum read throughput at the edge, embed effective permissions in tokens and accept short-lived staleness.
You now have dynamic, real-time, database-driven authorization in Quarkus, with clean annotations on endpoints and fast permission checks.
Thanks for reading The Main Thread! Subscribe for free to receive new posts and support my work.