Lock Down `PanacheEntityResource` Without Throwing Away Codegen
Read vs admin permissions, Keycloak tokens, and curl-proof boundaries.
Generated CRUD endpoints are great until you need real security. In the early demo phase, PanacheEntityResource is a nice shortcut. You define an entity, expose one interface, and Quarkus generates the REST layer for you. The problem starts when your API stops being a demo and turns into something that different users should access in different ways.
Most developers fix that by giving up the generated endpoint and writing a JAX-RS resource by hand. They add @RolesAllowed, copy the CRUD methods, and slowly rebuild what the framework already gave them. The generated endpoint still saves some time in the first hour, but after that you are back in boilerplate land. The convenience stops when production requirements kick in.
This matters because security is not a decoration you add later. Once your service handles real data, “everyone can call every generated method” is a production bug. A read-only user should not delete records. A service account that can ingest data should not automatically be able to rewrite historical state. If you do not draw those boundaries clearly, your API breaks at the authorization layer. The persistence layer cannot replace explicit access rules.
Quarkus 3.31 fixed a missing piece here: you can put @PermissionsAllowed on the REST Data Panache interface methods that Quarkus generates. The security rule lives where the operation is declared. You keep the code generation and you still get fine-grained access control per operation.
In this tutorial, we’ll build a small SwiftShip-style service with a generated Shipment endpoint. We’ll secure read operations with shipment:read and write operations with shipment:admin, using Keycloak client scopes and the token endpoint scope parameter so Quarkus OIDC maps granted scopes to @PermissionsAllowed. We’ll use Quarkus Dev Services to start PostgreSQL and Keycloak for us, and we’ll verify the behavior with real tokens and curl calls. By the end, you’ll have an end-to-end example that works locally and shows exactly where the permission boundary lives.
Prerequisites
You do not need a large setup for this tutorial, but you do need the usual Quarkus local development tools. We assume you are comfortable reading REST endpoints, editing application.properties, and testing secured APIs with bearer tokens.
Java 21 installed
Quarkus CLI installed
Podman installed
jqinstalled for token extraction in shell commandsBasic understanding of REST and OpenID Connect (OIDC)
Project Setup
Let’s create the project:
quarkus create app dev.myfear.swiftship:permissions-demo \
--extension='hibernate-orm-rest-data-panache,rest-jackson,jdbc-postgresql,oidc,smallrye-openapi' \
--no-code
cd permissions-demoWhat these extensions do:
hibernate-orm-rest-data-panache— wires Hibernate ORM Panache with REST Data Panache (pulls inrest-data-panacheandhibernate-orm-panache) and generates CRUD endpoints from your resource interfacerest-jackson— registers the Quarkus REST (JAX-RS) stack with Jackson; without a REST extension, the generated resource would not be mounted and you would see404on/shipmentjdbc-postgresql— gives us PostgreSQL connectivity, and Dev Services supportoidc— integrates the application with Keycloak for bearer token authenticationsmallrye-openapi— exposes the generated endpoints in OpenAPI, which is useful for verification
If you use Maven, add io.rest-assured:rest-assured (scope test) next to quarkus-junit for the optional test at the end. The Quarkus BOM (bill of materials) manages the RestAssured version when you omit an explicit version on that dependency.
Now create the package structure:
mkdir -p src/main/java/dev/myfear/swiftship
mkdir -p src/main/resources
mkdir -p src/test/java/dev/myfear/swiftshipImplementing the Shipment entity
We start with the entity because REST Data Panache generates the endpoint from the data model. Keep it simple. We do not need relationships, validation groups, or DTO mapping here. We want the security behavior to stay visible.
Create src/main/java/dev/myfear/swiftship/Shipment.java:
package dev.myfear.swiftship;
import io.quarkus.hibernate.orm.panache.PanacheEntity;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.Table;
@Entity
@Table(name = "shipment")
public class Shipment extends PanacheEntity {
@Column(name = "tracking_number")
public String trackingNumber;
@Column(name = "destination")
public String destination;
@Column(name = "status")
public String status;
}This class gives us exactly what we need. PanacheEntity provides the generated numeric id, and the three fields are enough to test list, get, create, update, and delete operations. We map the table to shipment and columns to snake_case so import.sql matches PostgreSQL reliably; JSON responses still use the Java property names (trackingNumber, and so on).
The important limit here is also obvious: this entity does not protect anything by itself. It defines persistence shape. Authorization is a separate concern. If an endpoint exists for this entity and no security rule blocks access, the entity will happily be returned, inserted, updated, or deleted. That is why the next step matters.
Implementing the generated resource with permissions
Here’s the core of the tutorial. We declare the generated CRUD contract and put permission annotations on the methods we want to secure. There is still no implementation class. Quarkus generates the JAX-RS endpoint during the build.
Create src/main/java/dev/myfear/swiftship/ShipmentResource.java:
package dev.myfear.swiftship;
import java.util.List;
import io.quarkus.hibernate.orm.rest.data.panache.PanacheEntityResource;
import io.quarkus.panache.common.Page;
import io.quarkus.panache.common.Sort;
import io.quarkus.security.PermissionsAllowed;
public interface ShipmentResource extends PanacheEntityResource<Shipment, Long> {
@Override
@PermissionsAllowed("shipment:read")
List<Shipment> list(Page page, Sort sort);
@Override
@PermissionsAllowed("shipment:read")
long count();
@Override
@PermissionsAllowed("shipment:read")
Shipment get(Long id);
@Override
@PermissionsAllowed("shipment:admin")
Shipment add(Shipment shipment);
@Override
@PermissionsAllowed("shipment:admin")
Shipment update(Long id, Shipment shipment);
@Override
@PermissionsAllowed("shipment:admin")
boolean delete(Long id);
}
This is the whole trick. PanacheEntityResource extends RestDataResource; the default method signatures use List, Page, and Sort for listing, return Shipment from add, boolean from delete, and expose count() as a separate read operation. We redeclare those methods as abstract overrides only to add annotations. We do not write a resource class, and we do not call the database ourselves. Quarkus reads this interface at build time and generates the REST endpoint with the security checks attached. Use the io.quarkus.hibernate.orm.rest.data.panache.PanacheEntityResource import for the Hibernate ORM variant.
What does this guarantee? It guarantees that generated read operations (including list, get, and count) require shipment:read, and generated write operations require shipment:admin, once the identity carries those permissions—for this tutorial, via OIDC access-token scopes. It does not add record-level rules by itself. If a caller presents a token with shipment:read, they can read every shipment exposed by these methods. This is operation-level authorization. Tenant isolation needs extra work in your queries.
Here @PermissionsAllowed fits better than @RolesAllowed. A permission like shipment:admin is an application capability. A role like shipment-admin is one identity-provider-specific assignment. The permission stays stable in your code. The role mapping can change in configuration.
Configuring Quarkus, PostgreSQL, and OpenID Connect (OIDC)
Now we wire the application to PostgreSQL and Keycloak. In dev mode, Quarkus Dev Services starts both containers for us. We only need to describe the application behavior.
Create src/main/resources/application.properties:
quarkus.datasource.db-kind=postgresql
quarkus.hibernate-orm.schema-management.strategy=drop-and-create
quarkus.oidc.application-type=service
quarkus.oidc.client-id=swiftship
quarkus.oidc.credentials.secret=secret
quarkus.keycloak.devservices.realm-path=quarkus-realm.json
# Default Dev Services use a random host port; pin 8180 so manual curl examples match startup.
quarkus.keycloak.devservices.port=8180
# Keycloak container clock and host JVM can drift; without leeway, short-lived tokens fail exp
# validation and every Bearer call returns 401 even with a freshly obtained access token.
%test.quarkus.oidc.token.lifespan-grace=600These are intentionally small settings. quarkus.keycloak.devservices.port=8180 fixes the Keycloak container to a known host port. If you omit it, Quarkus picks a random mapped port (Dev Services for OIDC); the curl snippets below assume 8180, so without this property your token request hits the wrong port. quarkus.hibernate-orm.schema-management.strategy=drop-and-create replaces the older quarkus.hibernate-orm.database.generation property (deprecated from Quarkus 3.23 onward). drop-and-create is fine for local development because it makes the tutorial repeatable. It is not a production choice. In production, this would destroy state on every restart. There you would use schema migration with Flyway or Liquibase.
The OpenID Connect settings tell Quarkus to validate bearer tokens for a service-style API. We are not building a browser login flow here. We want access tokens you get from Keycloak, sent in the Authorization header.
OAuth2 scopes in the access token and @PermissionsAllowed
@PermissionsAllowed checks java.security.Permission instances on the SecurityIdentity (by default StringPermission), not only JWT roles.
For OIDC bearer tokens, Quarkus maps the access token’s scope claim into those permissions: each space-separated scope string becomes a permission. As with role-based examples elsewhere in the docs, a value that contains a colon is parsed into permission name and action. So scope shipment:read matches @PermissionsAllowed("shipment:read"), and shipment:admin matches the admin operations.
In this tutorial, Keycloak client scopes supply those strings. We attach shipment:read and shipment:admin as optional client scopes on the swiftship client. At the token endpoint, the client passes the desired scopes in the scope form field (space-separated). Keycloak returns an access token whose scope lists what was granted; Quarkus turns that into permissions—no SecurityIdentityAugmentor and no realm roles on the users.
Minimal realm caveat: the JSON below only defines our two custom client scopes. It does not include Keycloak’s built-in openid, profile, and email scope definitions. Requesting those together with shipment:read would yield invalid_scope until you add the usual OIDC client scopes to the realm. For this walkthrough, request only application scopes—for example scope=shipment:read for Alice, or scope=shipment:read shipment:admin for Bob.
The client sets fullScopeAllowed: false so only assigned scopes are valid; optional scopes must be requested explicitly.
Configuring the Keycloak realm
We need two users (passwords only—no realm roles for capabilities). Whether Alice or Bob can read or admin shipments is determined by which scopes the token request asks for. Dev Services imports this realm automatically.
Create src/main/resources/quarkus-realm.json:
{
"realm": "quarkus",
"enabled": true,
"clientScopes": [
{
"name": "shipment:read",
"protocol": "openid-connect",
"attributes": {
"include.in.token.scope": "true",
"display.on.consent.screen": "false"
}
},
{
"name": "shipment:admin",
"protocol": "openid-connect",
"attributes": {
"include.in.token.scope": "true",
"display.on.consent.screen": "false"
}
}
],
"clients": [
{
"clientId": "swiftship",
"enabled": true,
"publicClient": false,
"secret": "secret",
"directAccessGrantsEnabled": true,
"standardFlowEnabled": true,
"serviceAccountsEnabled": false,
"fullScopeAllowed": false,
"optionalClientScopes": [
"shipment:read",
"shipment:admin"
]
}
],
"users": [
{
"username": "alice",
"enabled": true,
"emailVerified": true,
"credentials": [
{ "type": "password", "value": "alice" }
]
},
{
"username": "bob",
"enabled": true,
"emailVerified": true,
"credentials": [
{ "type": "password", "value": "bob" }
]
}
]
}Alice and Bob are equivalent in Keycloak; curl (or your client) chooses scope= per call. That keeps the demo focused on @PermissionsAllowed and token scopes. In production you would tie scope issuance to user attributes, client policies, or authorization services—not ad-hoc scope strings from the client unless you trust that caller.
Loading test data
A CRUD API without data does not tell us much. We seed two rows on startup so the read endpoints have something to return immediately.
Create src/main/resources/import.sql:
INSERT INTO shipment (id, tracking_number, destination, status) VALUES (1, 'SWS-001', 'Berlin', 'IN_TRANSIT');
INSERT INTO shipment (id, tracking_number, destination, status) VALUES (2, 'SWS-002', 'Amsterdam', 'DELIVERED');This script matches the shipment table and @Column names from the entity. It works with our drop-and-create dev setup. On each restart, the schema is recreated and the same two shipments appear again.
You get deterministic verification. Safe production seeding is a different problem. import.sql is useful for tests, demos, and tutorials. It is not how you manage production reference data.
Starting the application
Start the application in dev mode:
quarkus devIf you generated the project with Maven and the wrapper, the same thing is:
./mvnw quarkus:devQuarkus now starts the application, a PostgreSQL container, and a Keycloak container. Wait until startup finishes and then open the Dev UI if you want to inspect the running services: Dev UI
You can also inspect the OpenAPI document to confirm the generated shipment endpoint exists: OpenAPI
Verification
Let’s prove the behavior with real requests.
Get a token for Alice
With quarkus dev already running (and Keycloak reachable on 8180 when quarkus.keycloak.devservices.port is set as below), open a second terminal and request a token:
export ALICE_TOKEN=$(curl -s -X POST \
http://localhost:8180/realms/quarkus/protocol/openid-connect/token \
-d "client_id=swiftship" \
-d "client_secret=secret" \
-d "username=alice" \
-d "password=alice" \
-d "grant_type=password" \
-d "scope=shipment:read" | jq -r '.access_token')To confirm the token exists:
echo $ALICE_TOKEN | cut -c1-40You should see the first part of a JSON Web Token (JWT). If that line is blank or shows null, the token call failed: run the same curl without -s (or add -S) so errors are visible, confirm jq is installed, and confirm Keycloak is really on 8180 (startup log, Dev UI, or the quarkus.oidc.auth-server-url value Quarkus printed). If you removed quarkus.keycloak.devservices.port, replace 8180 in the URL with whatever host port Dev Services mapped for Keycloak.
Alice can list shipments
Call the generated list endpoint:
curl -s \
-H "Authorization: Bearer $ALICE_TOKEN" \
http://localhost:8080/shipment | jq .Expected output:
[
{
"id": 1,
"trackingNumber": "SWS-001",
"destination": "Berlin",
"status": "IN_TRANSIT"
},
{
"id": 2,
"trackingNumber": "SWS-002",
"destination": "Amsterdam",
"status": "DELIVERED"
}
]This verifies that @PermissionsAllowed("shipment:read") on the list operation is enforced and satisfied for Alice.
Alice can get one shipment
curl -s \
-H "Authorization: Bearer $ALICE_TOKEN" \
http://localhost:8080/shipment/1 | jq .Expected output:
{
"id": 1,
"trackingNumber": "SWS-001",
"destination": "Berlin",
"status": "IN_TRANSIT"
}Alice cannot delete
curl -i -X DELETE \
-H "Authorization: Bearer $ALICE_TOKEN" \
http://localhost:8080/shipment/1Expected output:
HTTP/1.1 403 ForbiddenThis is the critical check. Alice is authenticated, so this is not a 401. She is blocked because she lacks shipment:admin, so the correct response is 403 Forbidden.
Get a token for Bob
Now request a token for the admin user:
export BOB_TOKEN=$(curl -s -X POST \
http://localhost:8180/realms/quarkus/protocol/openid-connect/token \
-d "client_id=swiftship" \
-d "client_secret=secret" \
-d "username=bob" \
-d "password=bob" \
-d "grant_type=password" \
-d "scope=shipment:read shipment:admin" | jq -r '.access_token')Bob can delete
curl -i -X DELETE \
-H "Authorization: Bearer $BOB_TOKEN" \
http://localhost:8080/shipment/1Expected output:
HTTP/1.1 204 No ContentNow list the shipments again:
curl -s \
-H "Authorization: Bearer $BOB_TOKEN" \
http://localhost:8080/shipment | jq .Expected output:
[
{
"id": 2,
"trackingNumber": "SWS-002",
"destination": "Amsterdam",
"status": "DELIVERED"
}
]That confirms the delete operation really ran. Shipment 1 is gone from the list.
Unauthenticated requests fail
Finally, call the endpoint without a token:
curl -i http://localhost:8080/shipmentExpected output:
HTTP/1.1 401 UnauthorizedThat verifies the full security flow. No token means authentication fails before the permission layer is even evaluated.
Optional integration test
Manual curl verification is useful. You still want automated checks in the codebase. The tests below assert anonymous access is rejected, a reader can load GET /shipment/1, and the same reader cannot delete.
Create src/test/java/dev/myfear/swiftship/ShipmentSecurityTest.java:
package dev.myfear.swiftship;
import static io.restassured.RestAssured.given;
import static org.hamcrest.Matchers.equalTo;
import org.eclipse.microprofile.config.inject.ConfigProperty;
import org.junit.jupiter.api.Test;
import io.quarkus.test.junit.QuarkusTest;
import io.restassured.http.ContentType;
@QuarkusTest
class ShipmentSecurityTest {
@ConfigProperty(name = "quarkus.oidc.auth-server-url")
String authServerUrl;
@Test
void anonymousUserCannotListShipments() {
given()
.when().get("/shipment")
.then()
.statusCode(401);
}
@Test
void readerCanGetShipmentById() {
String token = accessToken("alice", "alice", "shipment:read");
given()
.header("Authorization", "Bearer " + token)
.when().get("/shipment/1")
.then()
.statusCode(200)
.body("id", equalTo(1))
.body("trackingNumber", equalTo("SWS-001"))
.body("destination", equalTo("Berlin"))
.body("status", equalTo("IN_TRANSIT"));
}
@Test
void readerCannotDeleteShipment() {
String token = accessToken("alice", "alice", "shipment:read");
given()
.header("Authorization", "Bearer " + token)
.when().delete("/shipment/1")
.then()
.statusCode(403);
}
private String accessToken(String username, String password, String scope) {
String tokenUrl = authServerUrl + "/protocol/openid-connect/token";
return given()
.contentType(ContentType.URLENC)
.formParam("client_id", "swiftship")
.formParam("client_secret", "secret")
.formParam("username", username)
.formParam("password", password)
.formParam("grant_type", "password")
.formParam("scope", scope)
.when()
.post(tokenUrl)
.then()
.statusCode(200)
.extract()
.path("access_token");
}
}
The positive read test depends on requesting shipment:read in scope. If you omit scope or request scopes the client is not allowed to use, you get invalid_scope at the token endpoint, or 403 on the API when the token lacks the right permissions.
This suite does not cover every path, but it pins the two boundaries that matter for this API: readers can read, and readers cannot delete.
In @QuarkusTest, Keycloak Dev Services often uses a random host port unless you set quarkus.keycloak.devservices.port. The test code builds the token URL from quarkus.oidc.auth-server-url, so it stays correct. The application.properties in this tutorial pins 8180 for dev so the curl examples match; for tests you can add e.g. %test.quarkus.keycloak.devservices.port=… if you want a fixed port there too.
If Bearer requests in tests return 401 even with a token you just got, check exp: Keycloak’s container clock and the host JVM can skew enough that short-lived access tokens look expired to Quarkus. For the test profile only, add something like %test.quarkus.oidc.token.lifespan-grace=600 (seconds of leeway on expiry) in application.properties so ./mvnw verify stays stable. Do not treat that as a production setting.
Run the test with ./mvnw verify (or your IDE’s JUnit runner). That starts PostgreSQL and Keycloak via Dev Services, so Podman (or a compatible container runtime) must be available.
Production Hardening
What happens under load
The endpoint generation does not change how authorization behaves under concurrency. Every incoming request still goes through authentication, identity resolution, and permission checks before the CRUD operation runs. So you do not get a “fast path” around security just because the endpoint is generated.
The important part is that this only protects the operation boundary. If 500 valid admin requests call DELETE /shipment/{id} at once, the permission layer does not serialize them. It only decides whether each caller is allowed to try. Database correctness is still handled by your persistence model and transaction boundaries.
What this does not protect
@PermissionsAllowed("shipment:read") protects the entire list, get, and count operations. It does not protect fields inside the response. If your entity contains sensitive columns such as internal cost data, this approach does not hide them from authorized readers. For that, you need data transfer objects (DTOs), projections, or response filtering.
The same is true for tenant isolation. If user A should only see shipments for customer A, and user B should only see shipments for customer B, you need a query constraint tied to the authenticated identity on top of operation-level checks. The generated endpoint can still help, but your repository logic has to enforce that boundary.
Failure behavior matters
One good thing about declarative security on generated endpoints is consistency. You do not risk forgetting to add an annotation to one custom method because there is no custom method. The security rule sits right on the operation declaration.
I have seen the manual alternative fail in real projects. A team rewrites generated CRUD as a resource class to add authorization. Six months later, someone adds a “temporary” admin shortcut endpoint for a migration, forgets the annotation, and that endpoint survives the release. Generated code saves time. It also removes places where humans forget things.
Conclusion
We built a generated CRUD API for Shipment, secured it with @PermissionsAllowed, issued OAuth2 scopes from Keycloak client scopes via the token endpoint’s scope parameter, and let Quarkus OIDC map those scopes to permissions. PostgreSQL and Keycloak Dev Services back the app; curl and tests show read versus admin behavior. The security rule stays on the operation declaration; how callers get scopes in production is a separate policy concern.


