Skip to content

Commit

Permalink
Merge pull request quarkusio#36759 from michalvavrik/feature/security…
Browse files Browse the repository at this point in the history
…-jpa-multitenancy-support

Security JPA: support Hibernate multitenancy
  • Loading branch information
yrodiere authored Oct 30, 2023
2 parents eb136e7 + 2ff3e63 commit 3d0df1d
Show file tree
Hide file tree
Showing 9 changed files with 259 additions and 33 deletions.
7 changes: 7 additions & 0 deletions docs/src/main/asciidoc/security-jpa.adoc
Original file line number Diff line number Diff line change
Expand Up @@ -185,6 +185,13 @@ For applications running in a production environment, do not store passwords as
However, it is possible to store passwords as plain text with the `@Password(PasswordType.CLEAR)` annotation when operating in a test environment.
====

[TIP]
====
The xref:hibernate-orm.adoc#multitenancy[Hibernate Multitenancy] is supported and you can store the user entity in a persistence unit with enabled multitenancy.
However, if your `io.quarkus.hibernate.orm.runtime.tenant.TenantResolver` must access the `io.vertx.ext.web.RoutingContext` to resolve request details, you must disable proactive authentication.
For more information about proactive authentication, please see the Quarkus xref:security-proactive-authentication.adoc[Proactive authentication] guide.
====

include::{generated-dir}/config/quarkus-security-jpa.adoc[opts=optional, leveloffset=+2]

== References
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,10 +13,10 @@

import jakarta.inject.Singleton;
import jakarta.persistence.EntityManager;
import jakarta.persistence.EntityManagerFactory;
import jakarta.persistence.Query;

import org.hibernate.Session;
import org.hibernate.SessionFactory;
import org.hibernate.SimpleNaturalIdLoadAccess;
import org.hibernate.annotations.NaturalId;
import org.jboss.jandex.AnnotationInstance;
Expand All @@ -42,7 +42,10 @@
import io.quarkus.gizmo.MethodDescriptor;
import io.quarkus.gizmo.ResultHandle;
import io.quarkus.hibernate.orm.PersistenceUnit;
import io.quarkus.hibernate.orm.deployment.PersistenceUnitDescriptorBuildItem;
import io.quarkus.hibernate.orm.runtime.migration.MultiTenancyStrategy;
import io.quarkus.panache.common.deployment.PanacheEntityClassesBuildItem;
import io.quarkus.runtime.configuration.ConfigurationException;
import io.quarkus.security.identity.SecurityIdentity;
import io.quarkus.security.identity.request.TrustedAuthenticationRequest;
import io.quarkus.security.identity.request.UsernamePasswordAuthenticationRequest;
Expand All @@ -56,7 +59,7 @@
class QuarkusSecurityJpaProcessor {

private static final DotName DOTNAME_NATURAL_ID = DotName.createSimple(NaturalId.class.getName());
private static final DotName ENTITY_MANAGER_FACTORY_FACTORY = DotName.createSimple(EntityManagerFactory.class.getName());
private static final DotName SESSION_FACTORY_FACTORY = DotName.createSimple(SessionFactory.class.getName());
private static final DotName JPA_IDENTITY_PROVIDER_NAME = DotName.createSimple(JpaIdentityProvider.class.getName());
private static final DotName JPA_TRUSTED_IDENTITY_PROVIDER_NAME = DotName
.createSimple(JpaTrustedIdentityProvider.class.getName());
Expand All @@ -68,19 +71,21 @@ FeatureBuildItem feature() {
}

@BuildStep
void configureJpaAuthConfig(ApplicationIndexBuildItem index,
BuildProducer<GeneratedBeanBuildItem> beanProducer,
void configureJpaAuthConfig(ApplicationIndexBuildItem index, List<PersistenceUnitDescriptorBuildItem> puDescriptors,
BuildProducer<GeneratedBeanBuildItem> beanProducer, SecurityJpaBuildTimeConfig secJpaConfig,
Optional<JpaSecurityDefinitionBuildItem> jpaSecurityDefinitionBuildItem,
PanacheEntityPredicateBuildItem panacheEntityPredicate) {

if (jpaSecurityDefinitionBuildItem.isPresent()) {
final boolean requireActiveCDIRequestContext = shouldActivateCDIReqCtx(puDescriptors, secJpaConfig);
JpaSecurityDefinition jpaSecurityDefinition = jpaSecurityDefinitionBuildItem.get().get();

generateIdentityProvider(index.getIndex(), jpaSecurityDefinition, jpaSecurityDefinition.passwordType(),
jpaSecurityDefinition.customPasswordProvider(), beanProducer, panacheEntityPredicate);
jpaSecurityDefinition.customPasswordProvider(), beanProducer, panacheEntityPredicate,
requireActiveCDIRequestContext);

generateTrustedIdentityProvider(index.getIndex(), jpaSecurityDefinition,
beanProducer, panacheEntityPredicate);
beanProducer, panacheEntityPredicate, requireActiveCDIRequestContext);
}
}

Expand All @@ -90,7 +95,7 @@ InjectionPointTransformerBuildItem transformer(SecurityJpaBuildTimeConfig config

@Override
public boolean appliesTo(Type requiredType) {
return requiredType.name().equals(ENTITY_MANAGER_FACTORY_FACTORY);
return requiredType.name().equals(SESSION_FACTORY_FACTORY);
}

public void transform(TransformationContext context) {
Expand Down Expand Up @@ -123,7 +128,8 @@ private Set<String> collectPanacheEntities(List<PanacheEntityClassesBuildItem> p

private void generateIdentityProvider(Index index, JpaSecurityDefinition jpaSecurityDefinition,
AnnotationValue passwordTypeValue, AnnotationValue passwordProviderValue,
BuildProducer<GeneratedBeanBuildItem> beanProducer, PanacheEntityPredicateBuildItem panacheEntityPredicate) {
BuildProducer<GeneratedBeanBuildItem> beanProducer, PanacheEntityPredicateBuildItem panacheEntityPredicate,
boolean requireActiveCDIRequestContext) {
GeneratedBeanGizmoAdaptor gizmoAdaptor = new GeneratedBeanGizmoAdaptor(beanProducer);

String name = jpaSecurityDefinition.annotatedClass.name() + "__JpaIdentityProviderImpl";
Expand All @@ -137,6 +143,10 @@ private void generateIdentityProvider(Index index, JpaSecurityDefinition jpaSecu
.setModifiers(Modifier.PRIVATE)
.getFieldDescriptor();

if (requireActiveCDIRequestContext) {
activateCDIRequestContext(classCreator);
}

try (MethodCreator methodCreator = classCreator.getMethodCreator("authenticate", SecurityIdentity.class,
EntityManager.class, UsernamePasswordAuthenticationRequest.class)) {
methodCreator.setModifiers(Modifier.PUBLIC);
Expand All @@ -161,7 +171,8 @@ private void generateIdentityProvider(Index index, JpaSecurityDefinition jpaSecu
}

private void generateTrustedIdentityProvider(Index index, JpaSecurityDefinition jpaSecurityDefinition,
BuildProducer<GeneratedBeanBuildItem> beanProducer, PanacheEntityPredicateBuildItem panacheEntityPredicate) {
BuildProducer<GeneratedBeanBuildItem> beanProducer, PanacheEntityPredicateBuildItem panacheEntityPredicate,
boolean requireActiveCDIRequestContext) {
GeneratedBeanGizmoAdaptor gizmoAdaptor = new GeneratedBeanGizmoAdaptor(beanProducer);

String name = jpaSecurityDefinition.annotatedClass.name() + "__JpaTrustedIdentityProviderImpl";
Expand All @@ -175,6 +186,10 @@ private void generateTrustedIdentityProvider(Index index, JpaSecurityDefinition
EntityManager.class, TrustedAuthenticationRequest.class)) {
methodCreator.setModifiers(Modifier.PUBLIC);

if (requireActiveCDIRequestContext) {
activateCDIRequestContext(classCreator);
}

ResultHandle username = methodCreator.invokeVirtualMethod(
MethodDescriptor.ofMethod(TrustedAuthenticationRequest.class, "getPrincipal", String.class),
methodCreator.getMethodParam(1));
Expand Down Expand Up @@ -234,6 +249,30 @@ private ResultHandle lookupUserById(JpaSecurityDefinition jpaSecurityDefinition,
return user;
}

private static void activateCDIRequestContext(ClassCreator classCreator) {
try (MethodCreator methodCreator = classCreator.getMethodCreator("requireActiveCDIRequestContext",
DotName.createSimple(boolean.class.getName()).toString())) {
methodCreator.setModifiers(Modifier.PROTECTED);
methodCreator.returnBoolean(true);
}
}

private static boolean shouldActivateCDIReqCtx(List<PersistenceUnitDescriptorBuildItem> puDescriptors,
SecurityJpaBuildTimeConfig secJpaConfig) {
var descriptor = puDescriptors.stream()
.filter(desc -> secJpaConfig.persistenceUnitName().equals(desc.getPersistenceUnitName())).findFirst();
if (descriptor.isEmpty()) {
throw new ConfigurationException("Persistence unit '" + secJpaConfig.persistenceUnitName()
+ "' specified with the 'quarkus.security-jpa.persistence-unit-name' configuration property"
+ " does not exist. Please set valid persistence unit name.");
}
// 'io.quarkus.hibernate.orm.runtime.tenant.TenantResolver' is only resolved when CDI request context is active
// we need to active request context even when TenantResolver is @ApplicationScoped for tenant to be set
// see io.quarkus.hibernate.orm.runtime.tenant.HibernateCurrentTenantIdentifierResolver.resolveCurrentTenantIdentifier
// for more information
return descriptor.get().getConfig().getMultiTenancyStrategy() != MultiTenancyStrategy.NONE;
}

static final class EnabledIfNonDefaultPersistenceUnit implements BooleanSupplier {

private final boolean useNonDefaultPersistenceUnit;
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
package io.quarkus.security.jpa;

import jakarta.enterprise.context.RequestScoped;
import jakarta.inject.Inject;

import io.quarkus.hibernate.orm.PersistenceUnitExtension;
import io.quarkus.hibernate.orm.runtime.tenant.TenantResolver;
import io.vertx.ext.web.RoutingContext;

@PersistenceUnitExtension
@RequestScoped
public class CustomHibernateTenantResolver implements TenantResolver {

static volatile boolean useRoutingContext = false;

@Inject
RoutingContext routingContext;

@Override
public String getDefaultTenantId() {
return "one";
}

@Override
public String resolveTenantId() {
if (useRoutingContext) {
var tenant = routingContext.queryParam("tenant");
if (!tenant.isEmpty()) {
return tenant.get(0);
}
}
return "two";
}

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
package io.quarkus.security.jpa;

import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.RegisterExtension;

import io.quarkus.test.QuarkusUnitTest;
import io.restassured.RestAssured;

public class EagerAuthMultiTenantPersistenceUnitTest extends JpaSecurityRealmTest {

@RegisterExtension
static final QuarkusUnitTest config = new QuarkusUnitTest()
.withApplicationRoot((jar) -> jar
.addClasses(testClasses)
.addClass(MinimalUserEntity.class)
.addClass(CustomHibernateTenantResolver.class)
.addAsResource("minimal-config/import.sql", "import.sql")
.addAsResource("multitenant-persistence-unit/application.properties", "application.properties"));

@Test
public void testRoutingCtxAccessInsideTenantResolver() {
// RoutingContext is not used inside TenantResolver to resolve tenant
RestAssured.given().auth().preemptive().basic("user", "user").when().get("/jaxrs-secured/roles-class/routing-context")
.then().statusCode(200);

// RoutingContext is used and proactive auth is enabled => expect error
CustomHibernateTenantResolver.useRoutingContext = true;
try {
RestAssured.given().auth().preemptive().basic("user", "user").queryParam("tenant", "two").when()
.get("/jaxrs-secured/roles-class")
.then().statusCode(500);
} finally {
CustomHibernateTenantResolver.useRoutingContext = false;
}
}

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
package io.quarkus.security.jpa;

import org.hamcrest.Matchers;
import org.jboss.shrinkwrap.api.asset.StringAsset;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.RegisterExtension;

import io.quarkus.test.QuarkusUnitTest;
import io.restassured.RestAssured;

public class LazyAuthMultiTenantPersistenceUnitTest {

@RegisterExtension
static final QuarkusUnitTest config = new QuarkusUnitTest()
.withApplicationRoot((jar) -> jar
.addClasses(MinimalUserEntity.class, CustomHibernateTenantResolver.class, RolesEndpointClassLevel.class)
.addAsResource("minimal-config/import.sql", "import.sql")
.addAsResource("multitenant-persistence-unit/application.properties", "application.properties")
.addAsResource(new StringAsset("quarkus.http.auth.proactive=false\n"),
"META-INF/microprofile-config.properties"));

@Test
public void testRoutingCtxAccessInsideTenantResolver() {
// RoutingContext is used and proactive auth is disabled => no issues
CustomHibernateTenantResolver.useRoutingContext = true;
try {
// tenant 'one'
RestAssured.given().auth().preemptive().basic("user", "user")
.queryParam("tenant", "one").when().get("/roles-class/routing-context").then()
.statusCode(200).body(Matchers.is("true"));
// tenant 'two'
RestAssured.given().auth().preemptive().basic("user", "user")
.queryParam("tenant", "two").when().get("/roles-class/routing-context").then()
.statusCode(200).body(Matchers.is("true"));
// tenant 'unknown'
RestAssured.given().auth().preemptive().basic("user", "user")
.queryParam("tenant", "unknown").when().get("/roles-class/routing-context").then()
.statusCode(500);
} finally {
CustomHibernateTenantResolver.useRoutingContext = false;
}
}

}
Original file line number Diff line number Diff line change
@@ -1,20 +1,33 @@
package io.quarkus.security.jpa;

import jakarta.annotation.security.RolesAllowed;
import jakarta.inject.Inject;
import jakarta.ws.rs.GET;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.core.Context;
import jakarta.ws.rs.core.SecurityContext;

import io.vertx.ext.web.RoutingContext;

/**
* Test JAXRS endpoint with RolesAllowed specified at the class level
*/
@Path("/roles-class")
@RolesAllowed("user")
public class RolesEndpointClassLevel {

@Inject
RoutingContext routingContext;

@GET
public String echo(@Context SecurityContext sec) {
return "Hello " + sec.getUserPrincipal().getName();
}

@Path("routing-context")
@GET
public boolean hasRoutingContext() {
return routingContext != null;
}

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
quarkus.datasource.db-kind=h2
quarkus.datasource.username=sa
quarkus.datasource.password=sa
quarkus.datasource.jdbc.url=jdbc:h2:mem:default

quarkus.datasource.one.db-kind=h2
quarkus.datasource.one.username=sa
quarkus.datasource.one.password=sa
quarkus.datasource.one.jdbc.url=jdbc:h2:mem:shared

quarkus.datasource.two.db-kind=h2
quarkus.datasource.two.username=sa
quarkus.datasource.two.password=sa
quarkus.datasource.two.jdbc.url=jdbc:h2:mem:shared

quarkus.hibernate-orm.multitenant=DATABASE
quarkus.hibernate-orm.sql-load-script=import.sql
quarkus.hibernate-orm.database.generation=drop-and-create
quarkus.hibernate-orm.packages=io.quarkus.security.jpa
Loading

0 comments on commit 3d0df1d

Please sign in to comment.