diff --git a/docs/src/main/asciidoc/security-keycloak-authorization.adoc b/docs/src/main/asciidoc/security-keycloak-authorization.adoc index 94d844883ebc91..b0040da1cc64b8 100644 --- a/docs/src/main/asciidoc/security-keycloak-authorization.adoc +++ b/docs/src/main/asciidoc/security-keycloak-authorization.adoc @@ -559,6 +559,60 @@ quarkus.keycloak.webapp-tenant.policy-enforcer.paths.1.paths=/api/permission quarkus.keycloak.webapp-tenant.policy-enforcer.paths.1.claim-information-point.claims.static-claim=static-claim ---- +== Dynamic tenant configuration resolution + +If you need a more dynamic configuration for the different tenants you want to support and don’t want to end up +with multiple entries in your configuration file, you can use the `io.quarkus.keycloak.pep.TenantPolicyConfigResolver`. + +This interface allows you to dynamically create tenant configurations at runtime: + +[source,java] +---- +package org.acme.security.keycloak.authorization; + +import java.util.Map; + +import jakarta.enterprise.context.ApplicationScoped; + +import io.quarkus.keycloak.pep.TenantPolicyConfigResolver; +import io.quarkus.keycloak.pep.runtime.KeycloakPolicyEnforcerConfig; +import io.quarkus.keycloak.pep.runtime.KeycloakPolicyEnforcerTenantConfig; +import io.smallrye.mutiny.Uni; +import io.vertx.ext.web.RoutingContext; + +@ApplicationScoped +public class CustomTenantPolicyConfigResolver implements TenantPolicyConfigResolver { + + private final KeycloakPolicyEnforcerTenantConfig enhancedTenantConfig; + private final KeycloakPolicyEnforcerTenantConfig newTenantConfig; + + public CustomTenantPolicyConfigResolver(KeycloakPolicyEnforcerConfig enforcerConfig) { + KeycloakPolicyEnforcerTenantConfig tenantConfig = enforcerConfig.defaultTenant(); + this.enhancedTenantConfig = KeycloakPolicyEnforcerTenantConfig.builder(tenantConfig) <1> + .path("/enhanced-config").name("Permission Name").get("read-scope") + .build(); + this.newTenantConfig = KeycloakPolicyEnforcerTenantConfig.builder() <2> + .path("/new-config").name("Permission Name").post() + .build(); + } + + @Override + public Uni resolve(RoutingContext routingContext, String tenantId, + KeycloakRequestContext requestContext) { + String path = routingContext.normalizedPath(); + if ("enhanced-config-tenant".equals(tenantId) && path.equals("/enhanced-config")) { + return Uni.createFrom().item(enhancedTenantConfig); + } else if ("new-config-tenant".equals(tenantId) && path.equals("/new-config")) { + return Uni.createFrom().item(newTenantConfig); + } + return Uni.createFrom().nullItem(); <3> + } +} +---- +<1> Create or update the `/enhanced-config` path in the default tenant config. +<2> Add `/new-config` path into tenant config populated with documented configuration default values. +<3> Use default static tenant configuration resolution based on the `application.properties` file and other SmallRye Config configuration sources. + == Configuration reference This configuration adheres to the official [Keycloak Policy Enforcer Configuration](https://www.keycloak.org/docs/latest/authorization_services/index.html#_enforcer_filter) guidelines. diff --git a/extensions/keycloak-authorization/deployment/src/main/java/io/quarkus/keycloak/pep/deployment/KeycloakPolicyEnforcerBuildStep.java b/extensions/keycloak-authorization/deployment/src/main/java/io/quarkus/keycloak/pep/deployment/KeycloakPolicyEnforcerBuildStep.java index 07064af6533c38..6f726ef905e600 100644 --- a/extensions/keycloak-authorization/deployment/src/main/java/io/quarkus/keycloak/pep/deployment/KeycloakPolicyEnforcerBuildStep.java +++ b/extensions/keycloak-authorization/deployment/src/main/java/io/quarkus/keycloak/pep/deployment/KeycloakPolicyEnforcerBuildStep.java @@ -2,26 +2,20 @@ import java.util.function.BooleanSupplier; -import jakarta.inject.Singleton; - import io.quarkus.arc.deployment.AdditionalBeanBuildItem; -import io.quarkus.arc.deployment.SyntheticBeanBuildItem; import io.quarkus.deployment.Feature; import io.quarkus.deployment.annotations.BuildStep; import io.quarkus.deployment.annotations.BuildSteps; import io.quarkus.deployment.annotations.ExecutionTime; import io.quarkus.deployment.annotations.Record; import io.quarkus.deployment.builditem.ExtensionSslNativeSupportBuildItem; +import io.quarkus.keycloak.pep.runtime.DefaultPolicyEnforcerResolver; import io.quarkus.keycloak.pep.runtime.KeycloakPolicyEnforcerAuthorizer; import io.quarkus.keycloak.pep.runtime.KeycloakPolicyEnforcerBuildTimeConfig; import io.quarkus.keycloak.pep.runtime.KeycloakPolicyEnforcerConfig; import io.quarkus.keycloak.pep.runtime.KeycloakPolicyEnforcerRecorder; -import io.quarkus.keycloak.pep.runtime.PolicyEnforcerResolver; import io.quarkus.oidc.deployment.OidcBuildTimeConfig; -import io.quarkus.oidc.runtime.OidcConfig; -import io.quarkus.runtime.TlsConfig; import io.quarkus.vertx.http.deployment.RequireBodyHandlerBuildItem; -import io.quarkus.vertx.http.runtime.HttpConfiguration; @BuildSteps(onlyIf = KeycloakPolicyEnforcerBuildStep.IsEnabled.class) public class KeycloakPolicyEnforcerBuildStep { @@ -41,7 +35,8 @@ RequireBodyHandlerBuildItem requireBody(OidcBuildTimeConfig oidcBuildTimeConfig, public AdditionalBeanBuildItem beans(OidcBuildTimeConfig oidcBuildTimeConfig) { if (oidcBuildTimeConfig.enabled) { return AdditionalBeanBuildItem.builder().setUnremovable() - .addBeanClass(KeycloakPolicyEnforcerAuthorizer.class).build(); + .addBeanClasses(KeycloakPolicyEnforcerAuthorizer.class, DefaultPolicyEnforcerResolver.class) + .build(); } return null; } @@ -51,22 +46,6 @@ ExtensionSslNativeSupportBuildItem enableSslInNative() { return new ExtensionSslNativeSupportBuildItem(Feature.KEYCLOAK_AUTHORIZATION); } - @Record(ExecutionTime.RUNTIME_INIT) - @BuildStep - public SyntheticBeanBuildItem setup(OidcBuildTimeConfig oidcBuildTimeConfig, OidcConfig oidcRunTimeConfig, - TlsConfig tlsConfig, KeycloakPolicyEnforcerConfig keycloakConfig, KeycloakPolicyEnforcerRecorder recorder, - HttpConfiguration httpConfiguration) { - if (oidcBuildTimeConfig.enabled) { - return SyntheticBeanBuildItem.configure(PolicyEnforcerResolver.class).unremovable() - .types(PolicyEnforcerResolver.class) - .supplier(recorder.setup(oidcRunTimeConfig, keycloakConfig, tlsConfig, httpConfiguration)) - .scope(Singleton.class) - .setRuntimeInit() - .done(); - } - return null; - } - public static class IsEnabled implements BooleanSupplier { KeycloakPolicyEnforcerBuildTimeConfig config; diff --git a/extensions/keycloak-authorization/runtime/src/main/java/io/quarkus/keycloak/pep/PolicyEnforcerResolver.java b/extensions/keycloak-authorization/runtime/src/main/java/io/quarkus/keycloak/pep/PolicyEnforcerResolver.java new file mode 100644 index 00000000000000..7ed1d01304bfec --- /dev/null +++ b/extensions/keycloak-authorization/runtime/src/main/java/io/quarkus/keycloak/pep/PolicyEnforcerResolver.java @@ -0,0 +1,17 @@ +package io.quarkus.keycloak.pep; + +import org.keycloak.adapters.authorization.PolicyEnforcer; + +import io.smallrye.mutiny.Uni; +import io.vertx.ext.web.RoutingContext; + +/** + * A {@link PolicyEnforcer} resolver. + */ +public interface PolicyEnforcerResolver { + + Uni resolvePolicyEnforcer(RoutingContext routingContext, String tenantId); + + long getReadTimeout(); + +} diff --git a/extensions/keycloak-authorization/runtime/src/main/java/io/quarkus/keycloak/pep/TenantPolicyConfigResolver.java b/extensions/keycloak-authorization/runtime/src/main/java/io/quarkus/keycloak/pep/TenantPolicyConfigResolver.java new file mode 100644 index 00000000000000..b1f627703d3728 --- /dev/null +++ b/extensions/keycloak-authorization/runtime/src/main/java/io/quarkus/keycloak/pep/TenantPolicyConfigResolver.java @@ -0,0 +1,34 @@ +package io.quarkus.keycloak.pep; + +import java.util.function.Supplier; + +import io.quarkus.keycloak.pep.runtime.KeycloakPolicyEnforcerTenantConfig; +import io.smallrye.mutiny.Uni; +import io.vertx.ext.web.RoutingContext; + +/** + * A tenant resolver is responsible for resolving the {@link KeycloakPolicyEnforcerTenantConfig} for tenants, dynamically. + */ +public interface TenantPolicyConfigResolver { + + /** + * Returns a {@link KeycloakPolicyEnforcerTenantConfig} given a {@code RoutingContext} and tenant id. + * + * @param routingContext the routing context + * @param tenantId tenant id + * @param requestContext request context + * + * @return the tenant configuration. If the uni resolves to {@code null}, indicates that the default + * configuration/tenant should be chosen + */ + Uni resolve(RoutingContext routingContext, String tenantId, + KeycloakRequestContext requestContext); + + /** + * Keycloak Context that can be used to run blocking tasks. + */ + interface KeycloakRequestContext { + Uni runBlocking(Supplier function); + } + +} diff --git a/extensions/keycloak-authorization/runtime/src/main/java/io/quarkus/keycloak/pep/runtime/DefaultPolicyEnforcerResolver.java b/extensions/keycloak-authorization/runtime/src/main/java/io/quarkus/keycloak/pep/runtime/DefaultPolicyEnforcerResolver.java new file mode 100644 index 00000000000000..42a0f0da8b82b0 --- /dev/null +++ b/extensions/keycloak-authorization/runtime/src/main/java/io/quarkus/keycloak/pep/runtime/DefaultPolicyEnforcerResolver.java @@ -0,0 +1,118 @@ +package io.quarkus.keycloak.pep.runtime; + +import static io.quarkus.keycloak.pep.runtime.KeycloakPolicyEnforcerUtil.createPolicyEnforcer; +import static io.quarkus.keycloak.pep.runtime.KeycloakPolicyEnforcerUtil.getOidcTenantConfig; + +import java.util.HashMap; +import java.util.Map; +import java.util.function.Function; +import java.util.function.Supplier; + +import jakarta.enterprise.inject.Instance; +import jakarta.inject.Singleton; + +import org.keycloak.adapters.authorization.PolicyEnforcer; + +import io.quarkus.keycloak.pep.PolicyEnforcerResolver; +import io.quarkus.keycloak.pep.TenantPolicyConfigResolver; +import io.quarkus.oidc.OidcTenantConfig; +import io.quarkus.oidc.runtime.OidcConfig; +import io.quarkus.runtime.TlsConfig; +import io.quarkus.security.spi.runtime.BlockingSecurityExecutor; +import io.quarkus.vertx.http.runtime.HttpConfiguration; +import io.smallrye.mutiny.Uni; +import io.vertx.ext.web.RoutingContext; + +@Singleton +public class DefaultPolicyEnforcerResolver implements PolicyEnforcerResolver { + + private final TenantPolicyConfigResolver dynamicConfigResolver; + private final TenantPolicyConfigResolver.KeycloakRequestContext requestContext; + private final Map namedPolicyEnforcers; + private final PolicyEnforcer defaultPolicyEnforcer; + private final long readTimeout; + private final boolean tlsConfigTrustAll; + private final OidcConfig oidcConfig; + + DefaultPolicyEnforcerResolver(OidcConfig oidcConfig, KeycloakPolicyEnforcerConfig config, TlsConfig tlsConfig, + HttpConfiguration httpConfiguration, BlockingSecurityExecutor blockingSecurityExecutor, + Instance configResolver) { + this.readTimeout = httpConfiguration.readTimeout.toMillis(); + this.oidcConfig = oidcConfig; + this.tlsConfigTrustAll = tlsConfig.trustAll; + this.defaultPolicyEnforcer = createPolicyEnforcer(oidcConfig.defaultTenant, config.defaultTenant(), tlsConfigTrustAll); + this.namedPolicyEnforcers = createNamedPolicyEnforcers(oidcConfig, config, tlsConfigTrustAll); + if (configResolver.isResolvable()) { + this.dynamicConfigResolver = configResolver.get(); + this.requestContext = createKeycloakRequestContext(blockingSecurityExecutor); + } else { + this.dynamicConfigResolver = null; + this.requestContext = null; + } + } + + @Override + public Uni resolvePolicyEnforcer(RoutingContext routingContext, String tenantId) { + if (dynamicConfigResolver == null) { + return Uni.createFrom().item(getStaticPolicyEnforcer(tenantId)); + } else { + return getDynamicPolicyEnforcer(routingContext, tenantId) + .onItem().ifNull().continueWith(new Supplier() { + @Override + public PolicyEnforcer get() { + return getStaticPolicyEnforcer(tenantId); + } + }); + } + } + + @Override + public long getReadTimeout() { + return readTimeout; + } + + PolicyEnforcer getStaticPolicyEnforcer(String tenantId) { + return tenantId != null && namedPolicyEnforcers.containsKey(tenantId) + ? namedPolicyEnforcers.get(tenantId) + : defaultPolicyEnforcer; + } + + boolean hasDynamicPolicyEnforcers() { + return dynamicConfigResolver != null; + } + + private Uni getDynamicPolicyEnforcer(RoutingContext routingContext, String tenantId) { + return dynamicConfigResolver.resolve(routingContext, tenantId, requestContext) + .onItem().ifNotNull().transform(new Function() { + @Override + public PolicyEnforcer apply(KeycloakPolicyEnforcerTenantConfig tenant) { + return createPolicyEnforcer(tenant, tlsConfigTrustAll, tenantId, oidcConfig); + } + }); + } + + private static Map createNamedPolicyEnforcers(OidcConfig oidcConfig, + KeycloakPolicyEnforcerConfig config, boolean tlsConfigTrustAll) { + if (config.namedTenants().isEmpty()) { + return Map.of(); + } + + Map policyEnforcerTenants = new HashMap<>(); + for (Map.Entry tenant : config.namedTenants().entrySet()) { + OidcTenantConfig oidcTenantConfig = getOidcTenantConfig(oidcConfig, tenant.getKey()); + policyEnforcerTenants.put(tenant.getKey(), + createPolicyEnforcer(oidcTenantConfig, tenant.getValue(), tlsConfigTrustAll)); + } + return Map.copyOf(policyEnforcerTenants); + } + + private static TenantPolicyConfigResolver.KeycloakRequestContext createKeycloakRequestContext( + BlockingSecurityExecutor blockingSecurityExecutor) { + return new TenantPolicyConfigResolver.KeycloakRequestContext() { + @Override + public Uni runBlocking(Supplier function) { + return blockingSecurityExecutor.executeBlocking(function); + } + }; + } +} diff --git a/extensions/keycloak-authorization/runtime/src/main/java/io/quarkus/keycloak/pep/runtime/KeycloakPolicyEnforcerAuthorizer.java b/extensions/keycloak-authorization/runtime/src/main/java/io/quarkus/keycloak/pep/runtime/KeycloakPolicyEnforcerAuthorizer.java index fa7831fb6e2661..14472e81622690 100644 --- a/extensions/keycloak-authorization/runtime/src/main/java/io/quarkus/keycloak/pep/runtime/KeycloakPolicyEnforcerAuthorizer.java +++ b/extensions/keycloak-authorization/runtime/src/main/java/io/quarkus/keycloak/pep/runtime/KeycloakPolicyEnforcerAuthorizer.java @@ -1,12 +1,13 @@ package io.quarkus.keycloak.pep.runtime; +import static io.quarkus.oidc.runtime.OidcUtils.TENANT_ID_ATTRIBUTE; + import java.security.Permission; -import java.util.HashMap; -import java.util.Map; -import java.util.function.BiFunction; import java.util.function.Function; +import java.util.function.Supplier; import jakarta.enterprise.context.RequestScoped; +import jakarta.enterprise.inject.Instance; import jakarta.enterprise.inject.Produces; import jakarta.inject.Inject; import jakarta.inject.Singleton; @@ -18,100 +19,158 @@ import org.keycloak.representations.adapters.config.PolicyEnforcerConfig.PathConfig; import io.quarkus.arc.Arc; +import io.quarkus.keycloak.pep.PolicyEnforcerResolver; import io.quarkus.oidc.AccessTokenCredential; +import io.quarkus.runtime.BlockingOperationControl; +import io.quarkus.runtime.BlockingOperationNotAllowedException; import io.quarkus.security.identity.SecurityIdentity; import io.quarkus.security.runtime.QuarkusSecurityIdentity; +import io.quarkus.security.spi.runtime.BlockingSecurityExecutor; +import io.quarkus.vertx.http.runtime.CurrentVertxRequest; import io.quarkus.vertx.http.runtime.security.HttpSecurityPolicy; import io.smallrye.mutiny.Uni; import io.vertx.ext.web.RoutingContext; @Singleton -public class KeycloakPolicyEnforcerAuthorizer - implements HttpSecurityPolicy, BiFunction { - private static final String TENANT_ID_ATTRIBUTE = "tenant-id"; +public class KeycloakPolicyEnforcerAuthorizer implements HttpSecurityPolicy { private static final String PERMISSIONS_ATTRIBUTE = "permissions"; + private static final String POLICY_ENFORCER = "io.quarkus.keycloak.pep.runtime.KeycloakPolicyEnforcerAuthorizer#POLICY_ENFORCER"; @Inject PolicyEnforcerResolver resolver; + @Inject + Instance identityInstance; + + @Inject + BlockingSecurityExecutor blockingExecutor; + @Override - public Uni checkPermission(RoutingContext request, Uni identity, + public Uni checkPermission(RoutingContext routingContext, Uni identity, AuthorizationRequestContext requestContext) { - return requestContext.runBlocking(request, identity, this); + return identity.flatMap(new Function>() { + @Override + public Uni apply(SecurityIdentity identity) { + if (identity.isAnonymous()) { + return resolver.resolvePolicyEnforcer(routingContext, null) + .flatMap(new Function>() { + @Override + public Uni apply(PolicyEnforcer policyEnforcer) { + storePolicyEnforcerOnContext(policyEnforcer, routingContext); + return blockingExecutor.executeBlocking(new Supplier() { + @Override + public PathConfig get() { + return policyEnforcer.getPathMatcher().matches(routingContext.normalizedPath()); + } + }).flatMap(new Function>() { + @Override + public Uni apply(PathConfig pathConfig) { + if (pathConfig != null + && pathConfig.getEnforcementMode() == EnforcementMode.ENFORCING) { + return Uni.createFrom().item(CheckResult.DENY); + } + return checkPermissionInternal(routingContext, identity); + } + }); + } + }); + } + return checkPermissionInternal(routingContext, identity); + } + }); } - @Override - public CheckResult apply(RoutingContext routingContext, SecurityIdentity identity) { + @Produces + @RequestScoped + public AuthzClient getAuthzClient() { + SecurityIdentity identity = identityInstance.get(); + final RoutingContext routingContext; + if (identity.getAttribute(RoutingContext.class.getName()) != null) { + routingContext = identity.getAttribute(RoutingContext.class.getName()); + } else { + routingContext = Arc.container().instance(CurrentVertxRequest.class).get().getCurrent(); + } - if (identity.isAnonymous()) { - PathConfig pathConfig = resolver.getPolicyEnforcer(null).getPathMatcher().matches( - routingContext.normalizedPath()); - if (pathConfig != null && pathConfig.getEnforcementMode() == EnforcementMode.ENFORCING) { - return CheckResult.DENY; + if (routingContext != null && routingContext.get(POLICY_ENFORCER) != null) { + return routingContext. get(POLICY_ENFORCER).getAuthzClient(); + } else if (BlockingOperationControl.isBlockingAllowed()) { + return resolver.resolvePolicyEnforcer(routingContext, identity.getAttribute(TENANT_ID_ATTRIBUTE)) + .await().indefinitely() + .getAuthzClient(); + } else { + if (resolver instanceof DefaultPolicyEnforcerResolver defaultResolver + && !defaultResolver.hasDynamicPolicyEnforcers()) { + return defaultResolver.getStaticPolicyEnforcer(identity.getAttribute(TENANT_ID_ATTRIBUTE)).getAuthzClient(); + } else { + // this shouldn't happen inside HTTP request as policy enforcer is in most cases accessible from context + // and the Authz client itself is blocking so users can as well inject it when on the worker thread + throw new BlockingOperationNotAllowedException(""" + You have attempted to inject AuthzClient on a IO thread. + This is not allowed when PolicyEnforcer is resolved dynamically as blocking operations are required. + Make sure you are injecting AuthzClient from a worker thread. + """); } } + } + private Uni checkPermissionInternal(RoutingContext routingContext, SecurityIdentity identity) { AccessTokenCredential credential = identity.getCredential(AccessTokenCredential.class); if (credential == null) { // SecurityIdentity has been created by the authentication mechanism other than quarkus-oidc - return CheckResult.PERMIT; + return Uni.createFrom().item(CheckResult.PERMIT); } VertxHttpFacade httpFacade = new VertxHttpFacade(routingContext, credential.getToken(), resolver.getReadTimeout()); - - PolicyEnforcer policyEnforcer = resolver.getPolicyEnforcer(identity.getAttribute(TENANT_ID_ATTRIBUTE)); - AuthorizationContext result = policyEnforcer.enforce(httpFacade, httpFacade); - - if (result.isGranted()) { - SecurityIdentity newIdentity = enhanceSecurityIdentity(identity, result); - return new CheckResult(true, newIdentity); - } - - return CheckResult.DENY; + return resolver.resolvePolicyEnforcer(routingContext, identity.getAttribute(TENANT_ID_ATTRIBUTE)) + .flatMap(new Function>() { + @Override + public Uni apply(PolicyEnforcer policyEnforcer) { + storePolicyEnforcerOnContext(policyEnforcer, routingContext); + return blockingExecutor.executeBlocking(new Supplier() { + @Override + public AuthorizationContext get() { + return policyEnforcer.enforce(httpFacade, httpFacade); + } + }); + } + }).map(new Function() { + @Override + public CheckResult apply(AuthorizationContext authorizationContext) { + if (authorizationContext.isGranted()) { + return new CheckResult(true, enhanceSecurityIdentity(identity, authorizationContext)); + } + return CheckResult.DENY; + } + }); } - @Produces - @RequestScoped - public AuthzClient getAuthzClient() { - SecurityIdentity identity = (SecurityIdentity) Arc.container().instance(SecurityIdentity.class).get(); - return resolver.getPolicyEnforcer(identity.getAttribute(TENANT_ID_ATTRIBUTE)).getAuthzClient(); + private static void storePolicyEnforcerOnContext(PolicyEnforcer policyEnforcer, RoutingContext routingContext) { + routingContext.put(POLICY_ENFORCER, policyEnforcer); } - private SecurityIdentity enhanceSecurityIdentity(SecurityIdentity current, - AuthorizationContext context) { - Map attributes = new HashMap<>(current.getAttributes()); - - if (context != null) { - attributes.put(PERMISSIONS_ATTRIBUTE, context.getPermissions()); - } - - return new QuarkusSecurityIdentity.Builder() - .addAttributes(attributes) - .setPrincipal(current.getPrincipal()) - .addRoles(current.getRoles()) - .addCredentials(current.getCredentials()) + private static SecurityIdentity enhanceSecurityIdentity(SecurityIdentity current, AuthorizationContext context) { + return QuarkusSecurityIdentity + .builder(current) + .addAttribute(PERMISSIONS_ATTRIBUTE, context.getPermissions()) .addPermissionChecker(new Function>() { @Override public Uni apply(Permission permission) { - if (context != null) { - String scopes = permission.getActions(); + String scopes = permission.getActions(); - if (scopes == null || scopes.isEmpty()) { - return Uni.createFrom().item(context.hasResourcePermission(permission.getName())); - } + if (scopes == null || scopes.isEmpty()) { + return Uni.createFrom().item(context.hasResourcePermission(permission.getName())); + } - for (String scope : scopes.split(",")) { - if (!context.hasPermission(permission.getName(), scope)) { - return Uni.createFrom().item(false); - } + for (String scope : scopes.split(",")) { + if (!context.hasPermission(permission.getName(), scope)) { + return Uni.createFrom().item(false); } - - return Uni.createFrom().item(true); } - return Uni.createFrom().item(false); + return Uni.createFrom().item(true); } - }).build(); + }) + .build(); } } diff --git a/extensions/keycloak-authorization/runtime/src/main/java/io/quarkus/keycloak/pep/runtime/KeycloakPolicyEnforcerRecorder.java b/extensions/keycloak-authorization/runtime/src/main/java/io/quarkus/keycloak/pep/runtime/KeycloakPolicyEnforcerRecorder.java index c18dddd48a9f07..1ad2da3b97e099 100644 --- a/extensions/keycloak-authorization/runtime/src/main/java/io/quarkus/keycloak/pep/runtime/KeycloakPolicyEnforcerRecorder.java +++ b/extensions/keycloak-authorization/runtime/src/main/java/io/quarkus/keycloak/pep/runtime/KeycloakPolicyEnforcerRecorder.java @@ -1,35 +1,9 @@ package io.quarkus.keycloak.pep.runtime; -import java.net.URI; -import java.util.HashMap; -import java.util.HashSet; import java.util.Map; -import java.util.Optional; -import java.util.Set; import java.util.function.BooleanSupplier; -import java.util.function.Function; -import java.util.function.Supplier; -import java.util.stream.Collectors; -import java.util.stream.Stream; -import org.keycloak.adapters.authorization.PolicyEnforcer; -import org.keycloak.representations.adapters.config.AdapterConfig; -import org.keycloak.representations.adapters.config.PolicyEnforcerConfig; - -import io.quarkus.keycloak.pep.runtime.KeycloakPolicyEnforcerTenantConfig.KeycloakConfigPolicyEnforcer.ClaimInformationPointConfig; -import io.quarkus.keycloak.pep.runtime.KeycloakPolicyEnforcerTenantConfig.KeycloakConfigPolicyEnforcer.MethodConfig; -import io.quarkus.keycloak.pep.runtime.KeycloakPolicyEnforcerTenantConfig.KeycloakConfigPolicyEnforcer.PathCacheConfig; -import io.quarkus.keycloak.pep.runtime.KeycloakPolicyEnforcerTenantConfig.KeycloakConfigPolicyEnforcer.PathConfig; -import io.quarkus.oidc.OIDCException; -import io.quarkus.oidc.OidcTenantConfig; -import io.quarkus.oidc.OidcTenantConfig.ApplicationType; -import io.quarkus.oidc.OidcTenantConfig.Roles.Source; -import io.quarkus.oidc.common.runtime.OidcCommonConfig.Tls.Verification; -import io.quarkus.oidc.runtime.OidcConfig; -import io.quarkus.runtime.TlsConfig; import io.quarkus.runtime.annotations.Recorder; -import io.quarkus.runtime.configuration.ConfigurationException; -import io.quarkus.vertx.http.runtime.HttpConfiguration; @Recorder public class KeycloakPolicyEnforcerRecorder { @@ -51,211 +25,12 @@ public boolean getAsBoolean() { }; } - public Supplier setup(OidcConfig oidcConfig, KeycloakPolicyEnforcerConfig config, - TlsConfig tlsConfig, HttpConfiguration httpConfiguration) { - PolicyEnforcer defaultPolicyEnforcer = createPolicyEnforcer(oidcConfig.defaultTenant, config.defaultTenant(), - tlsConfig); - Map policyEnforcerTenants = new HashMap(); - for (Map.Entry tenant : config.namedTenants().entrySet()) { - OidcTenantConfig oidcTenantConfig = oidcConfig.namedTenants.get(tenant.getKey()); - if (oidcTenantConfig == null) { - throw new ConfigurationException("Failed to find a matching OidcTenantConfig for tenant: " + tenant.getKey()); - } - policyEnforcerTenants.put(tenant.getKey(), createPolicyEnforcer(oidcTenantConfig, tenant.getValue(), tlsConfig)); - } - return new Supplier() { - @Override - public PolicyEnforcerResolver get() { - return new PolicyEnforcerResolver(defaultPolicyEnforcer, policyEnforcerTenants, - httpConfiguration.readTimeout.toMillis()); - } - }; - } - - private static PolicyEnforcer createPolicyEnforcer(OidcTenantConfig oidcConfig, - KeycloakPolicyEnforcerTenantConfig keycloakPolicyEnforcerConfig, - TlsConfig tlsConfig) { - - if (oidcConfig.applicationType.orElse(ApplicationType.SERVICE) == OidcTenantConfig.ApplicationType.WEB_APP - && oidcConfig.roles.source.orElse(null) != Source.accesstoken) { - throw new OIDCException("Application 'web-app' type is only supported if access token is the source of roles"); - } - - AdapterConfig adapterConfig = new AdapterConfig(); - String authServerUrl = oidcConfig.getAuthServerUrl().get(); - - try { - adapterConfig.setRealm(authServerUrl.substring(authServerUrl.lastIndexOf('/') + 1)); - adapterConfig.setAuthServerUrl(authServerUrl.substring(0, authServerUrl.lastIndexOf("/realms"))); - } catch (Exception cause) { - throw new ConfigurationException("Failed to parse the realm name.", cause); - } - - adapterConfig.setResource(oidcConfig.getClientId().get()); - adapterConfig.setCredentials(getCredentials(oidcConfig)); - - boolean trustAll = oidcConfig.tls.getVerification().isPresent() - ? oidcConfig.tls.getVerification().get() == Verification.NONE - : tlsConfig.trustAll; - if (trustAll) { - adapterConfig.setDisableTrustManager(true); - adapterConfig.setAllowAnyHostname(true); - } else if (oidcConfig.tls.trustStoreFile.isPresent()) { - adapterConfig.setTruststore(oidcConfig.tls.trustStoreFile.get().toString()); - adapterConfig.setTruststorePassword(oidcConfig.tls.trustStorePassword.orElse("password")); - if (Verification.CERTIFICATE_VALIDATION == oidcConfig.tls.verification.orElse(Verification.REQUIRED)) { - adapterConfig.setAllowAnyHostname(true); - } - } - adapterConfig.setConnectionPoolSize(keycloakPolicyEnforcerConfig.connectionPoolSize()); - - if (oidcConfig.proxy.host.isPresent()) { - String host = oidcConfig.proxy.host.get(); - if (!host.startsWith("http://") && !host.startsWith("https://")) { - host = URI.create(authServerUrl).getScheme() + "://" + host; - } - adapterConfig.setProxyUrl(host + ":" + oidcConfig.proxy.port); - } - - PolicyEnforcerConfig enforcerConfig = getPolicyEnforcerConfig(keycloakPolicyEnforcerConfig); - - adapterConfig.setPolicyEnforcerConfig(enforcerConfig); - - return PolicyEnforcer.builder() - .authServerUrl(adapterConfig.getAuthServerUrl()) - .realm(adapterConfig.getRealm()) - .clientId(adapterConfig.getResource()) - .credentials(adapterConfig.getCredentials()) - .bearerOnly(adapterConfig.isBearerOnly()) - .enforcerConfig(enforcerConfig) - .httpClient(new HttpClientBuilder().build(adapterConfig)) - .build(); - } - - private static Map getCredentials(OidcTenantConfig oidcConfig) { - Map credentials = new HashMap<>(); - Optional clientSecret = oidcConfig.getCredentials().getSecret(); - - if (clientSecret.isPresent()) { - credentials.put("secret", clientSecret.orElse(null)); - } - - return credentials; - } - - private static Map> getClaimInformationPointConfig(ClaimInformationPointConfig config) { - Map> cipConfig = new HashMap<>(); - - for (Map.Entry> entry : config.simpleConfig().entrySet()) { - if (!entry.getValue().isEmpty()) { - Map newConfig = new HashMap<>(); - for (Map.Entry e : entry.getValue().entrySet()) { - if (isNotComplexConfigKey(e.getKey())) { - newConfig.put(e.getKey(), e.getValue()); - } - } - if (!newConfig.isEmpty()) { - cipConfig.put(entry.getKey(), newConfig); - } - } - } - - for (Map.Entry>> entry : config.complexConfig().entrySet()) { - if (!entry.getValue().isEmpty()) { - Map newConfig = new HashMap<>(); - for (Map.Entry> e : entry.getValue().entrySet()) { - if (e.getValue() != null && !e.getValue().isEmpty()) { - // value can be empty when this key comes from the simple config - // see https://github.com/quarkusio/quarkus/issues/39315#issuecomment-1991604044 - newConfig.put(e.getKey(), e.getValue()); - } - } - if (!newConfig.isEmpty()) { - cipConfig.computeIfAbsent(entry.getKey(), s -> new HashMap<>()).putAll(newConfig); - } - } - } - - return cipConfig; - } - - private static PolicyEnforcerConfig getPolicyEnforcerConfig(KeycloakPolicyEnforcerTenantConfig config) { - PolicyEnforcerConfig enforcerConfig = new PolicyEnforcerConfig(); - - enforcerConfig.setLazyLoadPaths(config.policyEnforcer().lazyLoadPaths()); - enforcerConfig.setEnforcementMode(config.policyEnforcer().enforcementMode()); - enforcerConfig.setHttpMethodAsScope(config.policyEnforcer().httpMethodAsScope()); - - PathCacheConfig pathCache = config.policyEnforcer().pathCache(); - - PolicyEnforcerConfig.PathCacheConfig pathCacheConfig = new PolicyEnforcerConfig.PathCacheConfig(); - pathCacheConfig.setLifespan(pathCache.lifespan()); - pathCacheConfig.setMaxEntries(pathCache.maxEntries()); - enforcerConfig.setPathCacheConfig(pathCacheConfig); - - enforcerConfig.setClaimInformationPointConfig( - getClaimInformationPointConfig(config.policyEnforcer().claimInformationPoint())); - enforcerConfig.setPaths(config.policyEnforcer().paths().values().stream().flatMap( - new Function>() { - @Override - public Stream apply(PathConfig pathConfig) { - var paths = getPathConfigPaths(pathConfig); - if (paths.isEmpty()) { - return Stream.of(createKeycloakPathConfig(pathConfig, null)); - } else { - return paths.stream().map(new Function() { - @Override - public PolicyEnforcerConfig.PathConfig apply(String path) { - return createKeycloakPathConfig(pathConfig, path); - } - }); - } - } - }).collect(Collectors.toList())); - - return enforcerConfig; - } - - private static Set getPathConfigPaths(PathConfig pathConfig) { - Set paths = new HashSet<>(); - if (pathConfig.path().isPresent()) { - paths.add(pathConfig.path().get()); - } - if (pathConfig.paths().isPresent()) { - paths.addAll(pathConfig.paths().get()); - } - return paths; - } - - private static PolicyEnforcerConfig.PathConfig createKeycloakPathConfig(PathConfig pathConfig, String path) { - PolicyEnforcerConfig.PathConfig config1 = new PolicyEnforcerConfig.PathConfig(); - - config1.setName(pathConfig.name().orElse(null)); - config1.setPath(path); - config1.setEnforcementMode(pathConfig.enforcementMode()); - config1.setMethods(pathConfig.methods().values().stream().map( - new Function() { - @Override - public PolicyEnforcerConfig.MethodConfig apply(MethodConfig methodConfig) { - PolicyEnforcerConfig.MethodConfig mConfig = new PolicyEnforcerConfig.MethodConfig(); - - mConfig.setMethod(methodConfig.method()); - mConfig.setScopes(methodConfig.scopes()); - mConfig.setScopesEnforcementMode(methodConfig.scopesEnforcementMode()); - - return mConfig; - } - }).collect(Collectors.toList())); - config1.setClaimInformationPointConfig( - getClaimInformationPointConfig(pathConfig.claimInformationPoint())); - return config1; - } - private static boolean isBodyHandlerRequired(KeycloakPolicyEnforcerTenantConfig config) { if (isBodyClaimInformationPointDefined(config.policyEnforcer().claimInformationPoint().simpleConfig())) { return true; } - for (PathConfig path : config.policyEnforcer().paths().values()) { + for (KeycloakPolicyEnforcerTenantConfig.KeycloakConfigPolicyEnforcer.PathConfig path : config.policyEnforcer().paths() + .values()) { if (isBodyClaimInformationPointDefined(path.claimInformationPoint().simpleConfig())) { return true; } @@ -276,10 +51,4 @@ private static boolean isBodyClaimInformationPointDefined(Map> simpleConfig(); } } + + /** + * Creates {@link KeycloakPolicyEnforcerTenantConfig} builder populated with documented default values. + * + * @return KeycloakPolicyEnforcerTenantConfigBuilder builder + */ + static KeycloakPolicyEnforcerTenantConfigBuilder builder() { + var defaultTenantConfig = new SmallRyeConfigBuilder() + .withMapping(KeycloakPolicyEnforcerConfig.class) + .build() + .getConfigMapping(KeycloakPolicyEnforcerConfig.class) + .defaultTenant(); + return new KeycloakPolicyEnforcerTenantConfigBuilder(defaultTenantConfig); + } + + /** + * Creates {@link KeycloakPolicyEnforcerTenantConfig} builder populated with {@code tenantConfig} values. + * + * @param tenantConfig nullable tenant config; null value means that builder is not populated with sensible defaults + * + * @return KeycloakPolicyEnforcerTenantConfigBuilder builder + */ + static KeycloakPolicyEnforcerTenantConfigBuilder builder(KeycloakPolicyEnforcerTenantConfig tenantConfig) { + return new KeycloakPolicyEnforcerTenantConfigBuilder(tenantConfig); + } } diff --git a/extensions/keycloak-authorization/runtime/src/main/java/io/quarkus/keycloak/pep/runtime/KeycloakPolicyEnforcerTenantConfigBuilder.java b/extensions/keycloak-authorization/runtime/src/main/java/io/quarkus/keycloak/pep/runtime/KeycloakPolicyEnforcerTenantConfigBuilder.java new file mode 100644 index 00000000000000..afcb544578b735 --- /dev/null +++ b/extensions/keycloak-authorization/runtime/src/main/java/io/quarkus/keycloak/pep/runtime/KeycloakPolicyEnforcerTenantConfigBuilder.java @@ -0,0 +1,489 @@ +package io.quarkus.keycloak.pep.runtime; + +import static org.keycloak.representations.adapters.config.PolicyEnforcerConfig.EnforcementMode.ENFORCING; +import static org.keycloak.representations.adapters.config.PolicyEnforcerConfig.ScopeEnforcementMode.ALL; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collection; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.Set; +import java.util.function.Consumer; +import java.util.function.Supplier; + +import org.keycloak.representations.adapters.config.PolicyEnforcerConfig.EnforcementMode; +import org.keycloak.representations.adapters.config.PolicyEnforcerConfig.ScopeEnforcementMode; + +import io.quarkus.keycloak.pep.runtime.KeycloakPolicyEnforcerTenantConfig.KeycloakConfigPolicyEnforcer.ClaimInformationPointConfig; +import io.quarkus.keycloak.pep.runtime.KeycloakPolicyEnforcerTenantConfig.KeycloakConfigPolicyEnforcer.MethodConfig; +import io.quarkus.keycloak.pep.runtime.KeycloakPolicyEnforcerTenantConfig.KeycloakConfigPolicyEnforcer.PathCacheConfig; +import io.quarkus.keycloak.pep.runtime.KeycloakPolicyEnforcerTenantConfig.KeycloakConfigPolicyEnforcer.PathConfig; +import io.quarkus.runtime.util.StringUtil; + +public final class KeycloakPolicyEnforcerTenantConfigBuilder { + private final Map paths = new HashMap<>(); + private int connectionPoolSize; + private EnforcementMode enforcementMode; + private boolean lazyLoadPaths; + private boolean httpMethodAsScope; + private ClaimInformationPointConfig claimInformationPoint; + private PathCacheConfig pathCache; + + KeycloakPolicyEnforcerTenantConfigBuilder(KeycloakPolicyEnforcerTenantConfig originalConfig) { + if (originalConfig != null) { + connectionPoolSize = originalConfig.connectionPoolSize(); + var policyEnforcer = originalConfig.policyEnforcer(); + enforcementMode = policyEnforcer.enforcementMode(); + lazyLoadPaths = policyEnforcer.lazyLoadPaths(); + httpMethodAsScope = policyEnforcer.httpMethodAsScope(); + claimInformationPoint = policyEnforcer.claimInformationPoint(); + pathCache = policyEnforcer.pathCache(); + paths.putAll(new HashMap<>(policyEnforcer.paths())); + } + } + + public KeycloakPolicyEnforcerTenantConfig build() { + return new KeycloakPolicyEnforcerTenantConfig() { + + @Override + public int connectionPoolSize() { + return connectionPoolSize; + } + + @Override + public KeycloakConfigPolicyEnforcer policyEnforcer() { + return new KeycloakConfigPolicyEnforcer() { + @Override + public EnforcementMode enforcementMode() { + return enforcementMode; + } + + @Override + public Map paths() { + return Map.copyOf(paths); + } + + @Override + public PathCacheConfig pathCache() { + return pathCache; + } + + @Override + public boolean lazyLoadPaths() { + return lazyLoadPaths; + } + + @Override + public ClaimInformationPointConfig claimInformationPoint() { + return claimInformationPoint; + } + + @Override + public boolean httpMethodAsScope() { + return httpMethodAsScope; + } + }; + } + }; + } + + public KeycloakPolicyEnforcerTenantConfigBuilder connectionPoolSize(int connectionPoolSize) { + this.connectionPoolSize = connectionPoolSize; + return this; + } + + public KeycloakPolicyEnforcerTenantConfigBuilder enforcementMode(EnforcementMode enforcementMode) { + this.enforcementMode = enforcementMode; + return this; + } + + public KeycloakPolicyEnforcerTenantConfigBuilder lazyLoadPaths(boolean lazyLoadPaths) { + this.lazyLoadPaths = lazyLoadPaths; + return this; + } + + public KeycloakPolicyEnforcerTenantConfigBuilder httpMethodAsScope(boolean httpMethodAsScope) { + this.httpMethodAsScope = httpMethodAsScope; + return this; + } + + /** + * Adds path with generated name. + * + * @param path path + * @return PathConfigBuilder + */ + public PathConfigBuilder path(String path) { + return new PathConfigBuilder(this, StringUtil.hyphenate(path)).path(path); + } + + /** + * Adds paths with generated name. + * + * @param paths paths + * @return PathConfigBuilder + */ + public PathConfigBuilder paths(String... paths) { + var name = StringUtil.hyphenate(String.join("-", paths)); + return new PathConfigBuilder(this, name).paths(Arrays.stream(paths).toList()); + } + + public PathConfigBuilder addPath(String name) { + return new PathConfigBuilder(this, name); + } + + public PathCacheConfigBuilder pathCache() { + return new PathCacheConfigBuilder(this); + } + + public ClaimInformationPointConfigBuilder claimInformationPoint() { + return new ClaimInformationPointConfigBuilder<>(this, new Consumer() { + @Override + public void accept(ClaimInformationPointConfig claimInformationPointConfig) { + KeycloakPolicyEnforcerTenantConfigBuilder.this.claimInformationPoint = claimInformationPointConfig; + } + }, this.claimInformationPoint, new Supplier() { + @Override + public KeycloakPolicyEnforcerTenantConfig get() { + return KeycloakPolicyEnforcerTenantConfigBuilder.this.build(); + } + }); + } + + public static final class ClaimInformationPointConfigBuilder { + private final T builder; + private final Consumer setResult; + private Map>> complexConfig = Map.of(); + private Map> simpleConfig = Map.of(); + private final Supplier buildAll; + + public ClaimInformationPointConfigBuilder(T builder, Consumer setResult, + ClaimInformationPointConfig original, Supplier buildAll) { + this.buildAll = buildAll; + this.builder = builder; + this.setResult = setResult; + if (original != null) { + this.simpleConfig = original.simpleConfig(); + this.complexConfig = original.complexConfig(); + } + } + + public ClaimInformationPointConfigBuilder complexConfig( + Map>> complexConfig) { + this.complexConfig = Map.copyOf(complexConfig); + return this; + } + + public ClaimInformationPointConfigBuilder simpleConfig(Map> simpleConfig) { + this.simpleConfig = Map.copyOf(simpleConfig); + return this; + } + + public T build() { + setResult.accept(new ClaimInformationPointConfig() { + @Override + public Map>> complexConfig() { + return complexConfig; + } + + @Override + public Map> simpleConfig() { + return simpleConfig; + } + }); + return builder; + } + + public KeycloakPolicyEnforcerTenantConfig buildAll() { + build(); + return buildAll.get(); + } + } + + public static final class PathCacheConfigBuilder { + private final KeycloakPolicyEnforcerTenantConfigBuilder builder; + private int maxEntries; + private long lifespan; + + public PathCacheConfigBuilder(KeycloakPolicyEnforcerTenantConfigBuilder builder) { + this.builder = builder; + if (builder.pathCache != null) { + this.maxEntries = builder.pathCache.maxEntries(); + this.lifespan = builder.pathCache.lifespan(); + } + } + + public PathCacheConfigBuilder maxEntries(int maxEntries) { + this.maxEntries = maxEntries; + return this; + } + + public PathCacheConfigBuilder lifespan(long lifespan) { + this.lifespan = lifespan; + return this; + } + + public KeycloakPolicyEnforcerTenantConfigBuilder build() { + builder.pathCache = new PathCacheConfig() { + @Override + public int maxEntries() { + return maxEntries; + } + + @Override + public long lifespan() { + return lifespan; + } + }; + return builder; + } + + public KeycloakPolicyEnforcerTenantConfig buildAll() { + return build().build(); + } + } + + public static final class PathConfigBuilder { + private final KeycloakPolicyEnforcerTenantConfigBuilder builder; + private final String pathName; + private final Map methods = new HashMap<>(); + private ClaimInformationPointConfig claimInformationPointConfig = null; + private String name = null; + private final Set paths = new HashSet<>(); + private EnforcementMode enforcementMode = ENFORCING; + + public PathConfigBuilder(KeycloakPolicyEnforcerTenantConfigBuilder builder, String pathName) { + this.builder = builder; + this.pathName = pathName; + if (builder.paths.containsKey(pathName)) { + var path = builder.paths.get(pathName); + this.methods.putAll(path.methods()); + this.claimInformationPointConfig = path.claimInformationPoint(); + this.paths.addAll(path.paths().orElse(List.of())); + this.enforcementMode = path.enforcementMode(); + } + } + + public ClaimInformationPointConfigBuilder claimInformationPoint() { + return new ClaimInformationPointConfigBuilder<>(this, new Consumer() { + @Override + public void accept(ClaimInformationPointConfig claimInformationPointConfig) { + PathConfigBuilder.this.claimInformationPointConfig = claimInformationPointConfig; + } + }, this.claimInformationPointConfig, new Supplier() { + @Override + public KeycloakPolicyEnforcerTenantConfig get() { + return PathConfigBuilder.this.buildAll(); + } + }); + } + + /** + * @param name permission name + * @return PathConfigBuilder + */ + public PathConfigBuilder name(String name) { + this.name = name; + return this; + } + + public PathConfigBuilder path(String path) { + this.paths.add(path); + return this; + } + + public PathConfigBuilder paths(Collection paths) { + this.paths.addAll(paths); + return this; + } + + public Set getPaths() { + // in case someone needs to work with original set, like remove previous paths + return paths; + } + + public PathConfigBuilder enforcementMode(EnforcementMode enforcementMode) { + this.enforcementMode = enforcementMode; + return this; + } + + /** + * Makes this path specific for a POST method only. + * + * @param scopes optional scopes + * @return KeycloakPolicyEnforcerTenantConfigBuilder + */ + public KeycloakPolicyEnforcerTenantConfigBuilder post(String... scopes) { + return method(MethodConfigBuilder.Method.POST, scopes); + } + + /** + * Makes this path specific for a HEAD method only. + * + * @param scopes optional scopes + * @return KeycloakPolicyEnforcerTenantConfigBuilder + */ + public KeycloakPolicyEnforcerTenantConfigBuilder head(String... scopes) { + return method(MethodConfigBuilder.Method.HEAD, scopes); + } + + /** + * Makes this path specific for a GET method only. + * + * @param scopes optional scopes + * @return KeycloakPolicyEnforcerTenantConfigBuilder + */ + public KeycloakPolicyEnforcerTenantConfigBuilder get(String... scopes) { + return method(MethodConfigBuilder.Method.GET, scopes); + } + + /** + * Makes this path specific for a PUT method only. + * + * @param scopes optional scopes + * @return KeycloakPolicyEnforcerTenantConfigBuilder + */ + public KeycloakPolicyEnforcerTenantConfigBuilder put(String... scopes) { + return method(MethodConfigBuilder.Method.PUT, scopes); + } + + /** + * Makes this path specific for a PATCH method only. + * + * @param scopes optional scopes + * @return KeycloakPolicyEnforcerTenantConfigBuilder + */ + public KeycloakPolicyEnforcerTenantConfigBuilder patch(String... scopes) { + return method(MethodConfigBuilder.Method.PATCH, scopes); + } + + public KeycloakPolicyEnforcerTenantConfigBuilder method(MethodConfigBuilder.Method method, String... scopes) { + return addMethod(method.toString()).method(method.toString()).scopes(List.of(scopes)).build().build(); + } + + public MethodConfigBuilder addMethod(String name) { + return new MethodConfigBuilder(this, name); + } + + public KeycloakPolicyEnforcerTenantConfigBuilder build() { + if (claimInformationPointConfig == null) { + claimInformationPoint().build(); + } + builder.paths.put(pathName, new PathConfig() { + @Override + public Optional name() { + return Optional.ofNullable(name); + } + + @Override + public Optional path() { + return Optional.empty(); + } + + @Override + public Optional> paths() { + if (paths.isEmpty()) { + return Optional.empty(); + } else { + return Optional.of(List.copyOf(paths)); + } + } + + @Override + public Map methods() { + return methods; + } + + @Override + public EnforcementMode enforcementMode() { + return enforcementMode; + } + + @Override + public ClaimInformationPointConfig claimInformationPoint() { + return claimInformationPointConfig; + } + }); + return builder; + } + + public KeycloakPolicyEnforcerTenantConfig buildAll() { + return build().build(); + } + } + + public static final class MethodConfigBuilder { + private final PathConfigBuilder builder; + private final String name; + private final List scopes = new ArrayList<>(); + private String method = null; + private ScopeEnforcementMode scopeEnforcementMode = ALL; + + public enum Method { + GET, + POST, + PATCH, + PUT, + HEAD + } + + public MethodConfigBuilder(PathConfigBuilder builder, String name) { + this.builder = builder; + this.name = name; + if (builder.methods.containsKey(name)) { + var method = builder.methods.get(name); + this.method = method.method(); + this.scopeEnforcementMode = method.scopesEnforcementMode(); + this.scopes.addAll(method.scopes()); + } + } + + public MethodConfigBuilder method(String method) { + this.method = method; + return this; + } + + public MethodConfigBuilder scopes(List scopes) { + this.scopes.addAll(scopes); + return this; + } + + public MethodConfigBuilder scope(String scope) { + this.scopes.add(scope); + return this; + } + + public MethodConfigBuilder scopeEnforcementMode(ScopeEnforcementMode scopeEnforcementMode) { + this.scopeEnforcementMode = scopeEnforcementMode; + return this; + } + + public PathConfigBuilder build() { + builder.methods.put(name, new MethodConfig() { + @Override + public String method() { + return method; + } + + @Override + public List scopes() { + return List.copyOf(scopes); + } + + @Override + public ScopeEnforcementMode scopesEnforcementMode() { + return scopeEnforcementMode; + } + }); + return builder; + } + + public KeycloakPolicyEnforcerTenantConfig buildAll() { + return build().build().build(); + } + } +} diff --git a/extensions/keycloak-authorization/runtime/src/main/java/io/quarkus/keycloak/pep/runtime/KeycloakPolicyEnforcerUtil.java b/extensions/keycloak-authorization/runtime/src/main/java/io/quarkus/keycloak/pep/runtime/KeycloakPolicyEnforcerUtil.java new file mode 100644 index 00000000000000..76384be3de50d3 --- /dev/null +++ b/extensions/keycloak-authorization/runtime/src/main/java/io/quarkus/keycloak/pep/runtime/KeycloakPolicyEnforcerUtil.java @@ -0,0 +1,240 @@ +package io.quarkus.keycloak.pep.runtime; + +import static io.quarkus.oidc.runtime.OidcUtils.DEFAULT_TENANT_ID; + +import java.net.URI; +import java.util.HashMap; +import java.util.HashSet; +import java.util.Map; +import java.util.Optional; +import java.util.Set; +import java.util.function.Function; +import java.util.stream.Collectors; +import java.util.stream.Stream; + +import org.keycloak.adapters.authorization.PolicyEnforcer; +import org.keycloak.representations.adapters.config.AdapterConfig; +import org.keycloak.representations.adapters.config.PolicyEnforcerConfig; + +import io.quarkus.oidc.OIDCException; +import io.quarkus.oidc.OidcTenantConfig; +import io.quarkus.oidc.common.runtime.OidcCommonConfig; +import io.quarkus.oidc.runtime.OidcConfig; +import io.quarkus.runtime.configuration.ConfigurationException; + +public final class KeycloakPolicyEnforcerUtil { + + private KeycloakPolicyEnforcerUtil() { + // UTIL CLASS + } + + static PolicyEnforcer createPolicyEnforcer(OidcTenantConfig oidcConfig, + KeycloakPolicyEnforcerTenantConfig keycloakPolicyEnforcerConfig, + boolean tlsConfigTrustAll) { + + if (oidcConfig.applicationType + .orElse(OidcTenantConfig.ApplicationType.SERVICE) == OidcTenantConfig.ApplicationType.WEB_APP + && oidcConfig.roles.source.orElse(null) != OidcTenantConfig.Roles.Source.accesstoken) { + throw new OIDCException("Application 'web-app' type is only supported if access token is the source of roles"); + } + + AdapterConfig adapterConfig = new AdapterConfig(); + String authServerUrl = oidcConfig.getAuthServerUrl().get(); + + try { + adapterConfig.setRealm(authServerUrl.substring(authServerUrl.lastIndexOf('/') + 1)); + adapterConfig.setAuthServerUrl(authServerUrl.substring(0, authServerUrl.lastIndexOf("/realms"))); + } catch (Exception cause) { + throw new ConfigurationException("Failed to parse the realm name.", cause); + } + + adapterConfig.setResource(oidcConfig.getClientId().get()); + adapterConfig.setCredentials(getCredentials(oidcConfig)); + + boolean trustAll = oidcConfig.tls.getVerification().isPresent() + ? oidcConfig.tls.getVerification().get() == OidcCommonConfig.Tls.Verification.NONE + : tlsConfigTrustAll; + if (trustAll) { + adapterConfig.setDisableTrustManager(true); + adapterConfig.setAllowAnyHostname(true); + } else if (oidcConfig.tls.trustStoreFile.isPresent()) { + adapterConfig.setTruststore(oidcConfig.tls.trustStoreFile.get().toString()); + adapterConfig.setTruststorePassword(oidcConfig.tls.trustStorePassword.orElse("password")); + if (OidcCommonConfig.Tls.Verification.CERTIFICATE_VALIDATION == oidcConfig.tls.verification + .orElse(OidcCommonConfig.Tls.Verification.REQUIRED)) { + adapterConfig.setAllowAnyHostname(true); + } + } + adapterConfig.setConnectionPoolSize(keycloakPolicyEnforcerConfig.connectionPoolSize()); + + if (oidcConfig.proxy.host.isPresent()) { + String host = oidcConfig.proxy.host.get(); + if (!host.startsWith("http://") && !host.startsWith("https://")) { + host = URI.create(authServerUrl).getScheme() + "://" + host; + } + adapterConfig.setProxyUrl(host + ":" + oidcConfig.proxy.port); + } + + PolicyEnforcerConfig enforcerConfig = getPolicyEnforcerConfig(keycloakPolicyEnforcerConfig); + + adapterConfig.setPolicyEnforcerConfig(enforcerConfig); + + return PolicyEnforcer.builder() + .authServerUrl(adapterConfig.getAuthServerUrl()) + .realm(adapterConfig.getRealm()) + .clientId(adapterConfig.getResource()) + .credentials(adapterConfig.getCredentials()) + .bearerOnly(adapterConfig.isBearerOnly()) + .enforcerConfig(enforcerConfig) + .httpClient(new HttpClientBuilder().build(adapterConfig)) + .build(); + } + + private static Map getCredentials(OidcTenantConfig oidcConfig) { + Map credentials = new HashMap<>(); + Optional clientSecret = oidcConfig.getCredentials().getSecret(); + + if (clientSecret.isPresent()) { + credentials.put("secret", clientSecret.orElse(null)); + } + + return credentials; + } + + private static Map> getClaimInformationPointConfig( + KeycloakPolicyEnforcerTenantConfig.KeycloakConfigPolicyEnforcer.ClaimInformationPointConfig config) { + Map> cipConfig = new HashMap<>(); + + for (Map.Entry> entry : config.simpleConfig().entrySet()) { + if (!entry.getValue().isEmpty()) { + Map newConfig = new HashMap<>(); + for (Map.Entry e : entry.getValue().entrySet()) { + if (isNotComplexConfigKey(e.getKey())) { + newConfig.put(e.getKey(), e.getValue()); + } + } + if (!newConfig.isEmpty()) { + cipConfig.put(entry.getKey(), newConfig); + } + } + } + + for (Map.Entry>> entry : config.complexConfig().entrySet()) { + if (!entry.getValue().isEmpty()) { + Map newConfig = new HashMap<>(); + for (Map.Entry> e : entry.getValue().entrySet()) { + if (e.getValue() != null && !e.getValue().isEmpty()) { + // value can be empty when this key comes from the simple config + // see https://github.com/quarkusio/quarkus/issues/39315#issuecomment-1991604044 + newConfig.put(e.getKey(), e.getValue()); + } + } + if (!newConfig.isEmpty()) { + cipConfig.computeIfAbsent(entry.getKey(), s -> new HashMap<>()).putAll(newConfig); + } + } + } + + return cipConfig; + } + + private static PolicyEnforcerConfig getPolicyEnforcerConfig(KeycloakPolicyEnforcerTenantConfig config) { + PolicyEnforcerConfig enforcerConfig = new PolicyEnforcerConfig(); + + enforcerConfig.setLazyLoadPaths(config.policyEnforcer().lazyLoadPaths()); + enforcerConfig.setEnforcementMode(config.policyEnforcer().enforcementMode()); + enforcerConfig.setHttpMethodAsScope(config.policyEnforcer().httpMethodAsScope()); + + KeycloakPolicyEnforcerTenantConfig.KeycloakConfigPolicyEnforcer.PathCacheConfig pathCache = config.policyEnforcer() + .pathCache(); + + PolicyEnforcerConfig.PathCacheConfig pathCacheConfig = new PolicyEnforcerConfig.PathCacheConfig(); + pathCacheConfig.setLifespan(pathCache.lifespan()); + pathCacheConfig.setMaxEntries(pathCache.maxEntries()); + enforcerConfig.setPathCacheConfig(pathCacheConfig); + + enforcerConfig.setClaimInformationPointConfig( + getClaimInformationPointConfig(config.policyEnforcer().claimInformationPoint())); + enforcerConfig.setPaths(config.policyEnforcer().paths().values().stream().flatMap( + new Function>() { + @Override + public Stream apply( + KeycloakPolicyEnforcerTenantConfig.KeycloakConfigPolicyEnforcer.PathConfig pathConfig) { + var paths = getPathConfigPaths(pathConfig); + if (paths.isEmpty()) { + return Stream.of(createKeycloakPathConfig(pathConfig, null)); + } else { + return paths.stream().map(new Function() { + @Override + public PolicyEnforcerConfig.PathConfig apply(String path) { + return createKeycloakPathConfig(pathConfig, path); + } + }); + } + } + }).collect(Collectors.toList())); + + return enforcerConfig; + } + + private static Set getPathConfigPaths( + KeycloakPolicyEnforcerTenantConfig.KeycloakConfigPolicyEnforcer.PathConfig pathConfig) { + Set paths = new HashSet<>(); + if (pathConfig.path().isPresent()) { + paths.add(pathConfig.path().get()); + } + if (pathConfig.paths().isPresent()) { + paths.addAll(pathConfig.paths().get()); + } + return paths; + } + + private static PolicyEnforcerConfig.PathConfig createKeycloakPathConfig( + KeycloakPolicyEnforcerTenantConfig.KeycloakConfigPolicyEnforcer.PathConfig pathConfig, String path) { + PolicyEnforcerConfig.PathConfig config1 = new PolicyEnforcerConfig.PathConfig(); + + config1.setName(pathConfig.name().orElse(null)); + config1.setPath(path); + config1.setEnforcementMode(pathConfig.enforcementMode()); + config1.setMethods(pathConfig.methods().values().stream().map( + new Function() { + @Override + public PolicyEnforcerConfig.MethodConfig apply( + KeycloakPolicyEnforcerTenantConfig.KeycloakConfigPolicyEnforcer.MethodConfig methodConfig) { + PolicyEnforcerConfig.MethodConfig mConfig = new PolicyEnforcerConfig.MethodConfig(); + + mConfig.setMethod(methodConfig.method()); + mConfig.setScopes(methodConfig.scopes()); + mConfig.setScopesEnforcementMode(methodConfig.scopesEnforcementMode()); + + return mConfig; + } + }).collect(Collectors.toList())); + config1.setClaimInformationPointConfig( + getClaimInformationPointConfig(pathConfig.claimInformationPoint())); + return config1; + } + + private static boolean isNotComplexConfigKey(String key) { + // ignore complexConfig keys for reasons explained in the following comment: + // https://github.com/quarkusio/quarkus/issues/39315#issuecomment-1991604044 + return !key.contains("."); + } + + static OidcTenantConfig getOidcTenantConfig(OidcConfig oidcConfig, String tenant) { + if (tenant == null || DEFAULT_TENANT_ID.equals(tenant)) { + return oidcConfig.defaultTenant; + } + + OidcTenantConfig oidcTenantConfig = oidcConfig.namedTenants.get(tenant); + if (oidcTenantConfig == null) { + throw new ConfigurationException("Failed to find a matching OidcTenantConfig for tenant: " + tenant); + } + return oidcTenantConfig; + } + + static PolicyEnforcer createPolicyEnforcer(KeycloakPolicyEnforcerTenantConfig tenant, boolean tlsConfigTrustAll, + String tenantId, OidcConfig oidcConfig) { + return createPolicyEnforcer(getOidcTenantConfig(oidcConfig, tenantId), tenant, tlsConfigTrustAll); + } +} diff --git a/extensions/keycloak-authorization/runtime/src/main/java/io/quarkus/keycloak/pep/runtime/PolicyEnforcerResolver.java b/extensions/keycloak-authorization/runtime/src/main/java/io/quarkus/keycloak/pep/runtime/PolicyEnforcerResolver.java deleted file mode 100644 index ea7c5f056f9687..00000000000000 --- a/extensions/keycloak-authorization/runtime/src/main/java/io/quarkus/keycloak/pep/runtime/PolicyEnforcerResolver.java +++ /dev/null @@ -1,30 +0,0 @@ -package io.quarkus.keycloak.pep.runtime; - -import java.util.Map; - -import org.keycloak.adapters.authorization.PolicyEnforcer; - -public class PolicyEnforcerResolver { - - private final PolicyEnforcer defaultPolicyEnforcer; - private final Map policyEnforcerTenants; - private final long readTimeout; - - public PolicyEnforcerResolver(PolicyEnforcer defaultPolicyEnforcer, - Map policyEnforcerTenants, - final long readTimeout) { - this.defaultPolicyEnforcer = defaultPolicyEnforcer; - this.policyEnforcerTenants = policyEnforcerTenants; - this.readTimeout = readTimeout; - } - - public PolicyEnforcer getPolicyEnforcer(String tenantId) { - return tenantId != null && policyEnforcerTenants.containsKey(tenantId) - ? policyEnforcerTenants.get(tenantId) - : defaultPolicyEnforcer; - } - - public long getReadTimeout() { - return readTimeout; - } -} diff --git a/extensions/keycloak-authorization/runtime/src/main/java/io/quarkus/keycloak/pep/runtime/VertxHttpFacade.java b/extensions/keycloak-authorization/runtime/src/main/java/io/quarkus/keycloak/pep/runtime/VertxHttpFacade.java index a2f1f5493a32dd..13ae663ddac3e6 100644 --- a/extensions/keycloak-authorization/runtime/src/main/java/io/quarkus/keycloak/pep/runtime/VertxHttpFacade.java +++ b/extensions/keycloak-authorization/runtime/src/main/java/io/quarkus/keycloak/pep/runtime/VertxHttpFacade.java @@ -17,14 +17,12 @@ public class VertxHttpFacade implements HttpRequest, HttpResponse { - private final RoutingContext routingContext; private final long readTimeout; private final HttpRequest request; private final HttpResponse response; private final TokenPrincipal tokenPrincipal; public VertxHttpFacade(RoutingContext routingContext, String token, long readTimeout) { - this.routingContext = routingContext; this.readTimeout = readTimeout; this.request = createRequest(routingContext); this.response = createResponse(routingContext); diff --git a/integration-tests/keycloak-authorization/src/main/java/io/quarkus/it/keycloak/DynamicTenantPolicyConfigResolver.java b/integration-tests/keycloak-authorization/src/main/java/io/quarkus/it/keycloak/DynamicTenantPolicyConfigResolver.java new file mode 100644 index 00000000000000..11be78f6456464 --- /dev/null +++ b/integration-tests/keycloak-authorization/src/main/java/io/quarkus/it/keycloak/DynamicTenantPolicyConfigResolver.java @@ -0,0 +1,47 @@ +package io.quarkus.it.keycloak; + +import static io.quarkus.oidc.runtime.OidcUtils.DEFAULT_TENANT_ID; + +import java.util.Map; + +import jakarta.enterprise.context.ApplicationScoped; + +import io.quarkus.arc.profile.IfBuildProfile; +import io.quarkus.keycloak.pep.TenantPolicyConfigResolver; +import io.quarkus.keycloak.pep.runtime.KeycloakPolicyEnforcerConfig; +import io.quarkus.keycloak.pep.runtime.KeycloakPolicyEnforcerTenantConfig; +import io.smallrye.mutiny.Uni; +import io.vertx.ext.web.RoutingContext; + +@IfBuildProfile("dynamic-config-resolver") +@ApplicationScoped +public class DynamicTenantPolicyConfigResolver implements TenantPolicyConfigResolver { + + private final KeycloakPolicyEnforcerTenantConfig enhancedTenantConfig; + private final KeycloakPolicyEnforcerTenantConfig newTenantConfig; + + public DynamicTenantPolicyConfigResolver(KeycloakPolicyEnforcerConfig enforcerConfig) { + KeycloakPolicyEnforcerTenantConfig tenantConfig = enforcerConfig.defaultTenant(); + this.enhancedTenantConfig = KeycloakPolicyEnforcerTenantConfig.builder(tenantConfig) + .path("/api/permission/scopes/dynamic-way").name("Scope Permission Resource").get("read") + .path("/api/permission/scopes/dynamic-way-denied").name("Scope Permission Resource").get("write") + .build(); + this.newTenantConfig = KeycloakPolicyEnforcerTenantConfig.builder() + .path("/dynamic-permission-tenant") + .name("Dynamic Config Permission Resource Tenant") + .claimInformationPoint().simpleConfig(Map.of("claims", Map.of("static-claim", "static-claim"))) + .buildAll(); + } + + @Override + public Uni resolve(RoutingContext routingContext, String tenantId, + KeycloakRequestContext requestContext) { + String path = routingContext.normalizedPath(); + if (DEFAULT_TENANT_ID.equals(tenantId) && path.startsWith("/api/permission/scopes/dynamic-way")) { + return Uni.createFrom().item(enhancedTenantConfig); + } else if ("api-permission-tenant".equals(tenantId) && path.equals("/dynamic-permission-tenant")) { + return Uni.createFrom().item(newTenantConfig); + } + return Uni.createFrom().nullItem(); + } +} diff --git a/integration-tests/keycloak-authorization/src/main/java/io/quarkus/it/keycloak/ProtectedScopeResource.java b/integration-tests/keycloak-authorization/src/main/java/io/quarkus/it/keycloak/ProtectedScopeResource.java index 1a9ead40b875c7..56b1aa06a8d2ca 100644 --- a/integration-tests/keycloak-authorization/src/main/java/io/quarkus/it/keycloak/ProtectedScopeResource.java +++ b/integration-tests/keycloak-authorization/src/main/java/io/quarkus/it/keycloak/ProtectedScopeResource.java @@ -37,6 +37,18 @@ public Uni> standardWayDenied() { return Uni.createFrom().item(identity.> getAttribute("permissions")); } + @GET + @Path("/dynamic-way") + public Uni> dynamicWay() { + return Uni.createFrom().item(identity.> getAttribute("permissions")); + } + + @GET + @Path("/dynamic-way-denied") + public Uni> dynamicWayDenied() { + return Uni.createFrom().item(identity.> getAttribute("permissions")); + } + @GET @Path("/programmatic-way") public Uni> programmaticWay() { diff --git a/integration-tests/keycloak-authorization/src/main/java/io/quarkus/it/keycloak/ProtectedTenantResource.java b/integration-tests/keycloak-authorization/src/main/java/io/quarkus/it/keycloak/ProtectedTenantResource.java index 02ff18aedeae7e..d2f617bd2663d7 100644 --- a/integration-tests/keycloak-authorization/src/main/java/io/quarkus/it/keycloak/ProtectedTenantResource.java +++ b/integration-tests/keycloak-authorization/src/main/java/io/quarkus/it/keycloak/ProtectedTenantResource.java @@ -11,14 +11,21 @@ import io.quarkus.security.identity.SecurityIdentity; import io.smallrye.mutiny.Uni; -@Path("/api-permission-tenant") +@Path("") public class ProtectedTenantResource { @Inject SecurityIdentity identity; + @Path("api-permission-tenant") @GET - public Uni> permissions() { + public Uni> apiPermissions() { + return Uni.createFrom().item(identity.> getAttribute("permissions")); + } + + @Path("dynamic-permission-tenant") + @GET + public Uni> dynamicPermissions() { return Uni.createFrom().item(identity.> getAttribute("permissions")); } } diff --git a/integration-tests/keycloak-authorization/src/main/resources/application.properties b/integration-tests/keycloak-authorization/src/main/resources/application.properties index 18e8a230fc3cfa..e27701fabafcc2 100644 --- a/integration-tests/keycloak-authorization/src/main/resources/application.properties +++ b/integration-tests/keycloak-authorization/src/main/resources/application.properties @@ -72,11 +72,16 @@ quarkus.keycloak.policy-enforcer.paths.12.methods.get.scopes=write quarkus.oidc.api-permission-tenant.auth-server-url=${quarkus.oidc.auth-server-url} quarkus.oidc.api-permission-tenant.client-id=quarkus-app quarkus.oidc.api-permission-tenant.credentials.secret=secret +quarkus.oidc.api-permission-tenant.tenant-paths=/dynamic-permission-tenant quarkus.keycloak.api-permission-tenant.policy-enforcer.paths.1.name=Permission Resource Tenant quarkus.keycloak.api-permission-tenant.policy-enforcer.paths.1.paths=/api-permission-tenant quarkus.keycloak.api-permission-tenant.policy-enforcer.paths.1.claim-information-point.claims.static-claim=static-claim +# make sure path secured by dynamic config is accessible by default +quarkus.keycloak.api-permission-tenant.policy-enforcer.paths.2.paths=/dynamic-permission-tenant +quarkus.keycloak.api-permission-tenant.policy-enforcer.paths.2.enforcement-mode=DISABLED + # Web App Tenant quarkus.oidc.api-permission-webapp.auth-server-url=${quarkus.oidc.auth-server-url} quarkus.oidc.api-permission-webapp.client-id=quarkus-app diff --git a/integration-tests/keycloak-authorization/src/test/java/io/quarkus/it/keycloak/PolicyEnforcerTest.java b/integration-tests/keycloak-authorization/src/test/java/io/quarkus/it/keycloak/AbstractPolicyEnforcerTest.java similarity index 97% rename from integration-tests/keycloak-authorization/src/test/java/io/quarkus/it/keycloak/PolicyEnforcerTest.java rename to integration-tests/keycloak-authorization/src/test/java/io/quarkus/it/keycloak/AbstractPolicyEnforcerTest.java index 0f18d3dfc0a783..cecbe8646da680 100644 --- a/integration-tests/keycloak-authorization/src/test/java/io/quarkus/it/keycloak/PolicyEnforcerTest.java +++ b/integration-tests/keycloak-authorization/src/test/java/io/quarkus/it/keycloak/AbstractPolicyEnforcerTest.java @@ -18,9 +18,7 @@ import com.gargoylesoftware.htmlunit.html.HtmlPage; import com.gargoylesoftware.htmlunit.util.Cookie; -import io.quarkus.test.common.QuarkusTestResource; import io.quarkus.test.common.http.TestHTTPResource; -import io.quarkus.test.junit.QuarkusTest; import io.quarkus.test.keycloak.client.KeycloakTestClient; import io.vertx.core.Vertx; import io.vertx.core.json.JsonObject; @@ -29,9 +27,7 @@ /** * @author Pedro Igor */ -@QuarkusTest -@QuarkusTestResource(KeycloakLifecycleManager.class) -public class PolicyEnforcerTest { +public abstract class AbstractPolicyEnforcerTest { private static final Duration REQUEST_TIMEOUT = Duration.ofSeconds(10); @TestHTTPResource @@ -242,7 +238,7 @@ protected String getAccessToken(String userName) { return keycloakClient.getAccessToken(userName); } - private void assureGetPath(String path, int expectedStatusCode, String token, String body) { + protected void assureGetPath(String path, int expectedStatusCode, String token, String body) { var req = client.get(url.getPort(), url.getHost(), path); if (token != null) { req.bearerTokenAuthentication(token); diff --git a/integration-tests/keycloak-authorization/src/test/java/io/quarkus/it/keycloak/DynamicTenantConfigPolicyEnforcerTest.java b/integration-tests/keycloak-authorization/src/test/java/io/quarkus/it/keycloak/DynamicTenantConfigPolicyEnforcerTest.java new file mode 100644 index 00000000000000..541542f917516e --- /dev/null +++ b/integration-tests/keycloak-authorization/src/test/java/io/quarkus/it/keycloak/DynamicTenantConfigPolicyEnforcerTest.java @@ -0,0 +1,165 @@ +package io.quarkus.it.keycloak; + +import static io.quarkus.keycloak.pep.runtime.KeycloakPolicyEnforcerTenantConfigBuilder.MethodConfigBuilder.Method.GET; +import static io.quarkus.keycloak.pep.runtime.KeycloakPolicyEnforcerTenantConfigBuilder.MethodConfigBuilder.Method.HEAD; +import static io.quarkus.keycloak.pep.runtime.KeycloakPolicyEnforcerTenantConfigBuilder.MethodConfigBuilder.Method.PATCH; +import static io.quarkus.keycloak.pep.runtime.KeycloakPolicyEnforcerTenantConfigBuilder.MethodConfigBuilder.Method.POST; +import static io.quarkus.keycloak.pep.runtime.KeycloakPolicyEnforcerTenantConfigBuilder.MethodConfigBuilder.Method.PUT; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.keycloak.representations.adapters.config.PolicyEnforcerConfig.EnforcementMode.DISABLED; +import static org.keycloak.representations.adapters.config.PolicyEnforcerConfig.EnforcementMode.ENFORCING; +import static org.keycloak.representations.adapters.config.PolicyEnforcerConfig.EnforcementMode.PERMISSIVE; + +import java.util.List; +import java.util.Map; + +import jakarta.inject.Inject; + +import org.junit.jupiter.api.Test; +import org.keycloak.representations.adapters.config.PolicyEnforcerConfig; + +import io.quarkus.keycloak.pep.runtime.KeycloakPolicyEnforcerConfig; +import io.quarkus.keycloak.pep.runtime.KeycloakPolicyEnforcerTenantConfig; +import io.quarkus.keycloak.pep.runtime.KeycloakPolicyEnforcerTenantConfigBuilder.MethodConfigBuilder.Method; +import io.quarkus.runtime.util.StringUtil; +import io.quarkus.test.common.QuarkusTestResource; +import io.quarkus.test.junit.QuarkusTest; +import io.quarkus.test.junit.QuarkusTestProfile; +import io.quarkus.test.junit.TestProfile; + +@QuarkusTest +@QuarkusTestResource(KeycloakLifecycleManager.class) +@TestProfile(DynamicTenantConfigPolicyEnforcerTest.DynamicTenantConfigResolverProfile.class) +public class DynamicTenantConfigPolicyEnforcerTest extends AbstractPolicyEnforcerTest { + + @Inject + KeycloakPolicyEnforcerConfig enforcerConfig; + + public static class DynamicTenantConfigResolverProfile implements QuarkusTestProfile { + @Override + public String getConfigProfile() { + return "dynamic-config-resolver"; + } + } + + @Test + public void testDynamicConfigPermissionScopes() { + // 'jdoe' has scope 'read' and 'read' is required + assureGetPath("/api/permission/scopes/dynamic-way", 200, getAccessToken("jdoe"), "read"); + assureGetPath("//api/permission/scopes/dynamic-way", 200, getAccessToken("jdoe"), "read"); + + // 'jdoe' has scope 'read' while 'write' is required + assureGetPath("/api/permission/scopes/dynamic-way-denied", 403, getAccessToken("jdoe"), null); + assureGetPath("//api/permission/scopes/dynamic-way-denied", 403, getAccessToken("jdoe"), null); + } + + @Test + public void testDynamicConfigUserHasAdminRoleServiceTenant() { + assureGetPath("/dynamic-permission-tenant", 403, getAccessToken("alice"), null); + assureGetPath("//dynamic-permission-tenant", 403, getAccessToken("alice"), null); + + assureGetPath("/dynamic-permission-tenant", 403, getAccessToken("jdoe"), null); + assureGetPath("//dynamic-permission-tenant", 403, getAccessToken("jdoe"), null); + + assureGetPath("/dynamic-permission-tenant", 200, getAccessToken("admin"), "Permission Resource Tenant"); + assureGetPath("//dynamic-permission-tenant", 200, getAccessToken("admin"), "Permission Resource Tenant"); + } + + @Test + public void testKeycloakPolicyEnforcerTenantConfigBuilder() { + assertBuilderPopulatedWithDefaultValues(); + assertEveryConfigPropertyCanBeSet(); + assertTenantConfigEnhanced(enforcerConfig.namedTenants().get("api-permission-tenant")); + assertCleanTenantConfig(); + assertBuilderShortcuts(); + } + + private static void assertBuilderPopulatedWithDefaultValues() { + // smoke test + var config = KeycloakPolicyEnforcerTenantConfig.builder().build(); + assertEquals(20, config.connectionPoolSize()); + assertNotNull(config.policyEnforcer()); + assertEquals(PolicyEnforcerConfig.EnforcementMode.ENFORCING, config.policyEnforcer().enforcementMode()); + assertNotNull(config.policyEnforcer().pathCache()); + assertEquals(1000, config.policyEnforcer().pathCache().maxEntries()); + } + + private static void assertEveryConfigPropertyCanBeSet() { + var config = KeycloakPolicyEnforcerTenantConfig.builder() + .enforcementMode(DISABLED) + .claimInformationPoint().simpleConfig(Map.of("one", Map.of("two", "three"))) + .complexConfig(Map.of("four", Map.of())).build() + .connectionPoolSize(-1) + .lazyLoadPaths(false) + .pathCache().maxEntries(5).lifespan(2).build() + .addPath("p1").path("path").enforcementMode(PolicyEnforcerConfig.EnforcementMode.PERMISSIVE).name("n1") + .addMethod("method").scope("scope1").method("method1").scopes(List.of("scopes2")) + .scopeEnforcementMode(PolicyEnforcerConfig.ScopeEnforcementMode.DISABLED).build().build() + .httpMethodAsScope(true) + .build(); + assertEquals(DISABLED, config.policyEnforcer().enforcementMode()); + assertEquals(-1, config.connectionPoolSize()); + assertFalse(config.policyEnforcer().lazyLoadPaths()); + assertTrue(config.policyEnforcer().httpMethodAsScope()); + assertEquals("three", config.policyEnforcer().claimInformationPoint().simpleConfig().get("one").get("two")); + assertTrue(config.policyEnforcer().claimInformationPoint().complexConfig().get("four").isEmpty()); + assertEquals(5, config.policyEnforcer().pathCache().maxEntries()); + assertEquals(2, config.policyEnforcer().pathCache().lifespan()); + var path = config.policyEnforcer().paths().get("p1"); + assertEquals("n1", path.name().orElse(null)); + assertEquals("path", path.paths().orElse(List.of()).get(0)); + assertEquals(PERMISSIVE, path.enforcementMode()); + var method = path.methods().get("method"); + assertTrue(method.scopes().contains("scopes2")); + assertTrue(method.scopes().contains("scope1")); + assertEquals(PolicyEnforcerConfig.ScopeEnforcementMode.DISABLED, method.scopesEnforcementMode()); + } + + private static void assertTenantConfigEnhanced(KeycloakPolicyEnforcerTenantConfig originalConfig) { + var originalPath = originalConfig.policyEnforcer().paths().get("2"); + assertEquals(DISABLED, originalPath.enforcementMode()); + assertEquals("/dynamic-permission-tenant", originalPath.paths().orElse(List.of()).get(0)); + assertNull(originalConfig.policyEnforcer().paths().get("3")); + var enhancedConfig = KeycloakPolicyEnforcerTenantConfig.builder(originalConfig).addPath("2").enforcementMode(ENFORCING) + .build().addPath("3").name("some-name").addMethod("method3").method("put").build().build().build(); + var enhancedPath = enhancedConfig.policyEnforcer().paths().get("2"); + assertEquals(ENFORCING, enhancedPath.enforcementMode()); + assertEquals("/dynamic-permission-tenant", enhancedPath.paths().orElse(List.of()).get(0)); + assertNotNull(enhancedConfig.policyEnforcer().paths().get("3")); + assertEquals("some-name", enhancedConfig.policyEnforcer().paths().get("3").name().orElse(null)); + } + + private static void assertCleanTenantConfig() { + var config = KeycloakPolicyEnforcerTenantConfig.builder(null).build(); + // default is 20 + assertEquals(0, config.connectionPoolSize()); + // default is true + assertFalse(config.policyEnforcer().lazyLoadPaths()); + } + + private static void assertBuilderShortcuts() { + var config = KeycloakPolicyEnforcerTenantConfig.builder().path("/path-one").patch("scope1").build(); + assertMethod(config, PATCH, "/path-one", "scope1"); + config = KeycloakPolicyEnforcerTenantConfig.builder().paths("/path-two").put("scope2").build(); + assertMethod(config, PUT, "/path-two", "scope2"); + config = KeycloakPolicyEnforcerTenantConfig.builder().paths("/path-three").post("scope3").build(); + assertMethod(config, POST, "/path-three", "scope3"); + config = KeycloakPolicyEnforcerTenantConfig.builder().paths("/path-four").get("scope4").build(); + assertMethod(config, GET, "/path-four", "scope4"); + config = KeycloakPolicyEnforcerTenantConfig.builder().paths("/path-five").head("scope5").build(); + assertMethod(config, HEAD, "/path-five", "scope5"); + } + + private static void assertMethod(KeycloakPolicyEnforcerTenantConfig config, Method method, String path, String scope) { + assertTrue(config.policyEnforcer().paths().containsKey(StringUtil.hyphenate(path))); + assertTrue(config.policyEnforcer().paths().get(StringUtil.hyphenate(path)).paths().orElse(List.of()).contains(path)); + var patchMethod = config.policyEnforcer().paths().get(StringUtil.hyphenate(path)).methods() + .get(method.toString()); + assertEquals(method.toString(), patchMethod.method()); + assertTrue(patchMethod.scopes().contains(scope)); + } +} diff --git a/integration-tests/keycloak-authorization/src/test/java/io/quarkus/it/keycloak/KeycloakLifecycleManager.java b/integration-tests/keycloak-authorization/src/test/java/io/quarkus/it/keycloak/KeycloakLifecycleManager.java index e9e3b7a30533c3..e9b5ea9d04f2cb 100644 --- a/integration-tests/keycloak-authorization/src/test/java/io/quarkus/it/keycloak/KeycloakLifecycleManager.java +++ b/integration-tests/keycloak-authorization/src/test/java/io/quarkus/it/keycloak/KeycloakLifecycleManager.java @@ -102,6 +102,10 @@ private static void configurePermissionResourcePermission(ResourceServerRepresen createPermission(settings, createResource(settings, "Permission Resource Tenant", "/api-permission-tenant"), policyAdmin); + createPermission(settings, + createResource(settings, "Dynamic Config Permission Resource Tenant", "/dynamic-permission-tenant"), + policyAdmin); + PolicyRepresentation policyUser = createJSPolicy("Superuser Policy", "superuser-policy.js", settings); createPermission(settings, createResource(settings, "Permission Resource WebApp", "/api-permission-webapp"), diff --git a/integration-tests/keycloak-authorization/src/test/java/io/quarkus/it/keycloak/PolicyEnforcerInGraalITCase.java b/integration-tests/keycloak-authorization/src/test/java/io/quarkus/it/keycloak/PolicyEnforcerInGraalITCase.java index 23f8846d8192ce..c39c631c082904 100644 --- a/integration-tests/keycloak-authorization/src/test/java/io/quarkus/it/keycloak/PolicyEnforcerInGraalITCase.java +++ b/integration-tests/keycloak-authorization/src/test/java/io/quarkus/it/keycloak/PolicyEnforcerInGraalITCase.java @@ -10,7 +10,7 @@ * @author Pedro Igor */ @QuarkusIntegrationTest -public class PolicyEnforcerInGraalITCase extends PolicyEnforcerTest { +public class PolicyEnforcerInGraalITCase extends StaticTenantConfigPolicyEnforcerTest { @Test public void testPartyTokenRequest() { diff --git a/integration-tests/keycloak-authorization/src/test/java/io/quarkus/it/keycloak/StaticTenantConfigPolicyEnforcerTest.java b/integration-tests/keycloak-authorization/src/test/java/io/quarkus/it/keycloak/StaticTenantConfigPolicyEnforcerTest.java new file mode 100644 index 00000000000000..9aefe9ef83bd69 --- /dev/null +++ b/integration-tests/keycloak-authorization/src/test/java/io/quarkus/it/keycloak/StaticTenantConfigPolicyEnforcerTest.java @@ -0,0 +1,18 @@ +package io.quarkus.it.keycloak; + +import org.junit.jupiter.api.Test; + +import io.quarkus.test.common.QuarkusTestResource; +import io.quarkus.test.junit.QuarkusTest; + +@QuarkusTest +@QuarkusTestResource(KeycloakLifecycleManager.class) +public class StaticTenantConfigPolicyEnforcerTest extends AbstractPolicyEnforcerTest { + + @Test + public void testDynamicConfigNotApplied() { + // tests that paths secured by dynamic config is public when dynamic config resolver is not applied + assureGetPath("/api/permission/scopes/dynamic-way-denied", 200, getAccessToken("jdoe"), null); + assureGetPath("/dynamic-permission-tenant", 200, getAccessToken("jdoe"), null); + } +}