Dynamic Role-Based Access in Quarkus: Fine-Grained Security Without Redeploys
Markus

Markus @myfear

About: Markus is a Java Champion, former Java EE Expert Group member, founder of JavaLand, reputed speaker at Java conferences around the world, and a very well known figure in the Enterprise Java world.

Location:
Munich, Germany
Joined:
Mar 26, 2024

Dynamic Role-Based Access in Quarkus: Fine-Grained Security Without Redeploys

Publish Date: Aug 25
0 0

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
Enter fullscreen mode Exit fullscreen mode

What we added:

  • hibernate-orm-panache for entities/repositories

  • quarkus-jdbc-postgresql to trigger Dev Services PostgreSQL

  • quarkus-rest-jackson for REST + JSON

  • quarkus-smallrye-openapi for /q/openapi and /q/swagger-ui

  • quarkus-cache for permission caching

  • quarkus-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
Enter fullscreen mode Exit fullscreen mode

Add dev users:

src/main/resources/users.properties

admin=admin
alice=alice
Enter fullscreen mode Exit fullscreen mode

src/main/resources/roles.properties

admin=admin
alice=user
Enter fullscreen mode Exit fullscreen mode
  • admin has static role admin 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');
Enter fullscreen mode Exit fullscreen mode

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 omit id 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
}
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

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());
    }
}
Enter fullscreen mode Exit fullscreen mode

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;
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

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
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

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();
    }
}
Enter fullscreen mode Exit fullscreen mode

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();
    }
}
Enter fullscreen mode Exit fullscreen mode

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();
    }
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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}'
Enter fullscreen mode Exit fullscreen mode

Expected: JSON permission object with an id.

Now, list as alice again:

curl -u alice:alice http://localhost:8080/projects
Enter fullscreen mode Exit fullscreen mode

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"}'
Enter fullscreen mode Exit fullscreen mode

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}'
Enter fullscreen mode Exit fullscreen mode

Retry create:

curl -i -u alice:alice -X POST http://localhost:8080/projects \
  -H 'Content-Type: application/json' \
  -d '{"name":"Alpha","description":"Top secret"}'
Enter fullscreen mode Exit fullscreen mode

Expected: 200 with the created project JSON.

List again to see it:

curl -u alice:alice http://localhost:8080/projects
Enter fullscreen mode Exit fullscreen mode

Expected: 200 with the Alpha project.

Production notes

  • Identity : In production, use OIDC or enterprise SSO. Map the subject to your UserEntity.username. Keep the admin 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.

Comments 0 total

    Add comment