From 24aef29b844e7123642bc2af7e3256ea72f36e4c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Michal=20Vav=C5=99=C3=ADk?= Date: Thu, 11 Jul 2024 14:48:15 +0200 Subject: [PATCH] Add Keycloak Authorization dynamic tenant config resolution --- .../security-keycloak-authorization.adoc | 59 ++ .../KeycloakPolicyEnforcerBuildStep.java | 29 +- .../keycloak/pep/PolicyEnforcerResolver.java | 18 + .../pep/TenantPolicyConfigResolver.java | 27 + .../DefaultPolicyEnforcerResolver.java | 119 ++++ .../KeycloakPolicyEnforcerAuthorizer.java | 176 ++++-- .../KeycloakPolicyEnforcerRecorder.java | 237 ------- .../KeycloakPolicyEnforcerTenantConfig.java | 37 +- ...loakPolicyEnforcerTenantConfigBuilder.java | 597 ++++++++++++++++++ .../runtime/KeycloakPolicyEnforcerUtil.java | 235 +++++++ .../pep/runtime/PolicyEnforcerResolver.java | 30 - .../keycloak/pep/runtime/VertxHttpFacade.java | 2 - .../DynamicTenantPolicyConfigResolver.java | 53 ++ .../it/keycloak/ProtectedScopeResource.java | 12 + .../it/keycloak/ProtectedTenantResource.java | 11 +- .../src/main/resources/application.properties | 5 + ...t.java => AbstractPolicyEnforcerTest.java} | 8 +- ...DynamicTenantConfigPolicyEnforcerTest.java | 413 ++++++++++++ .../it/keycloak/KeycloakLifecycleManager.java | 4 + .../keycloak/PolicyEnforcerInGraalITCase.java | 2 +- .../StaticTenantConfigPolicyEnforcerTest.java | 18 + 21 files changed, 1728 insertions(+), 364 deletions(-) create mode 100644 extensions/keycloak-authorization/runtime/src/main/java/io/quarkus/keycloak/pep/PolicyEnforcerResolver.java create mode 100644 extensions/keycloak-authorization/runtime/src/main/java/io/quarkus/keycloak/pep/TenantPolicyConfigResolver.java create mode 100644 extensions/keycloak-authorization/runtime/src/main/java/io/quarkus/keycloak/pep/runtime/DefaultPolicyEnforcerResolver.java create mode 100644 extensions/keycloak-authorization/runtime/src/main/java/io/quarkus/keycloak/pep/runtime/KeycloakPolicyEnforcerTenantConfigBuilder.java create mode 100644 extensions/keycloak-authorization/runtime/src/main/java/io/quarkus/keycloak/pep/runtime/KeycloakPolicyEnforcerUtil.java delete mode 100644 extensions/keycloak-authorization/runtime/src/main/java/io/quarkus/keycloak/pep/runtime/PolicyEnforcerResolver.java create mode 100644 integration-tests/keycloak-authorization/src/main/java/io/quarkus/it/keycloak/DynamicTenantPolicyConfigResolver.java rename integration-tests/keycloak-authorization/src/test/java/io/quarkus/it/keycloak/{PolicyEnforcerTest.java => AbstractPolicyEnforcerTest.java} (97%) create mode 100644 integration-tests/keycloak-authorization/src/test/java/io/quarkus/it/keycloak/DynamicTenantConfigPolicyEnforcerTest.java create mode 100644 integration-tests/keycloak-authorization/src/test/java/io/quarkus/it/keycloak/StaticTenantConfigPolicyEnforcerTest.java diff --git a/docs/src/main/asciidoc/security-keycloak-authorization.adoc b/docs/src/main/asciidoc/security-keycloak-authorization.adoc index a4d34e1407fd5..d2ed11cc92763 100644 --- a/docs/src/main/asciidoc/security-keycloak-authorization.adoc +++ b/docs/src/main/asciidoc/security-keycloak-authorization.adoc @@ -559,6 +559,65 @@ 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.quarkus.oidc.OidcRequestContext; +import io.quarkus.oidc.OidcTenantConfig; +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) { + this.enhancedTenantConfig = KeycloakPolicyEnforcerTenantConfig.builder(config) <1> + .paths("/enhanced-config") + .permissionName("Permission Name") + .get("read-scope") + .build(); + this.newTenantConfig = KeycloakPolicyEnforcerTenantConfig.builder() <2> + .paths("/new-config") + .claimInformationPoint(Map.of("claims", Map.of("grant", "{request.parameter['grant']}"))) + .build(); + } + + @Override + public Uni resolve(RoutingContext routingContext, OidcTenantConfig tenantConfig, + OidcRequestContext requestContext) { + String path = routingContext.normalizedPath(); + String tenantId = tenantConfig.tenantId.orElse(null); + 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 98f52ab562141..3e7a54d0a37e1 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.tls.TlsRegistryBuildItem; 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,9 @@ RequireBodyHandlerBuildItem requireBody(OidcBuildTimeConfig oidcBuildTimeConfig, public AdditionalBeanBuildItem beans(OidcBuildTimeConfig oidcBuildTimeConfig) { if (oidcBuildTimeConfig.enabled) { return AdditionalBeanBuildItem.builder().setUnremovable() - .addBeanClass(KeycloakPolicyEnforcerAuthorizer.class).build(); + .addBeanClass(KeycloakPolicyEnforcerAuthorizer.class) + .addBeanClass(DefaultPolicyEnforcerResolver.class) + .build(); } return null; } @@ -51,23 +47,6 @@ ExtensionSslNativeSupportBuildItem enableSslInNative() { return new ExtensionSslNativeSupportBuildItem(Feature.KEYCLOAK_AUTHORIZATION); } - @Record(ExecutionTime.RUNTIME_INIT) - @BuildStep - public SyntheticBeanBuildItem setup(OidcBuildTimeConfig oidcBuildTimeConfig, OidcConfig oidcRunTimeConfig, - KeycloakPolicyEnforcerConfig keycloakConfig, KeycloakPolicyEnforcerRecorder recorder, - HttpConfiguration httpConfiguration, TlsRegistryBuildItem tlsRegistryBuildItem) { - if (oidcBuildTimeConfig.enabled) { - return SyntheticBeanBuildItem.configure(PolicyEnforcerResolver.class).unremovable() - .types(PolicyEnforcerResolver.class) - .supplier(recorder.setup(oidcRunTimeConfig, keycloakConfig, httpConfiguration, - tlsRegistryBuildItem.registry())) - .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 0000000000000..7d785fbb96d80 --- /dev/null +++ b/extensions/keycloak-authorization/runtime/src/main/java/io/quarkus/keycloak/pep/PolicyEnforcerResolver.java @@ -0,0 +1,18 @@ +package io.quarkus.keycloak.pep; + +import org.keycloak.adapters.authorization.PolicyEnforcer; + +import io.quarkus.oidc.OidcTenantConfig; +import io.smallrye.mutiny.Uni; +import io.vertx.ext.web.RoutingContext; + +/** + * A {@link PolicyEnforcer} resolver. + */ +public interface PolicyEnforcerResolver { + + Uni resolvePolicyEnforcer(RoutingContext routingContext, OidcTenantConfig tenantConfig); + + 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 0000000000000..7e81e17f1c846 --- /dev/null +++ b/extensions/keycloak-authorization/runtime/src/main/java/io/quarkus/keycloak/pep/TenantPolicyConfigResolver.java @@ -0,0 +1,27 @@ +package io.quarkus.keycloak.pep; + +import io.quarkus.keycloak.pep.runtime.KeycloakPolicyEnforcerTenantConfig; +import io.quarkus.oidc.OidcRequestContext; +import io.quarkus.oidc.OidcTenantConfig; +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 routing context; nullable + * @param tenantConfig tenant config; never null + * @param requestContext request context; never null + * + * @return the tenant configuration. If the uni resolves to {@code null}, indicates that the default + * configuration/tenant should be chosen + */ + Uni resolve(RoutingContext routingContext, OidcTenantConfig tenantConfig, + OidcRequestContext requestContext); + +} 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 0000000000000..b0bddb4559237 --- /dev/null +++ b/extensions/keycloak-authorization/runtime/src/main/java/io/quarkus/keycloak/pep/runtime/DefaultPolicyEnforcerResolver.java @@ -0,0 +1,119 @@ +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.arc.InjectableInstance; +import io.quarkus.keycloak.pep.PolicyEnforcerResolver; +import io.quarkus.keycloak.pep.TenantPolicyConfigResolver; +import io.quarkus.oidc.OidcTenantConfig; +import io.quarkus.oidc.runtime.BlockingTaskRunner; +import io.quarkus.oidc.runtime.OidcConfig; +import io.quarkus.security.spi.runtime.BlockingSecurityExecutor; +import io.quarkus.tls.TlsConfiguration; +import io.quarkus.tls.TlsConfigurationRegistry; +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 BlockingTaskRunner requestContext; + private final Map namedPolicyEnforcers; + private final PolicyEnforcer defaultPolicyEnforcer; + private final long readTimeout; + private final boolean globalTrustAll; + + DefaultPolicyEnforcerResolver(OidcConfig oidcConfig, KeycloakPolicyEnforcerConfig config, + HttpConfiguration httpConfiguration, BlockingSecurityExecutor blockingSecurityExecutor, + Instance configResolver, + InjectableInstance tlsConfigRegistryInstance) { + this.readTimeout = httpConfiguration.readTimeout.toMillis(); + + if (tlsConfigRegistryInstance.isResolvable()) { + this.globalTrustAll = tlsConfigRegistryInstance.get().getDefault().map(TlsConfiguration::isTrustAll).orElse(false); + } else { + this.globalTrustAll = false; + } + + this.defaultPolicyEnforcer = createPolicyEnforcer(oidcConfig.defaultTenant, config.defaultTenant(), globalTrustAll); + this.namedPolicyEnforcers = createNamedPolicyEnforcers(oidcConfig, config, globalTrustAll); + if (configResolver.isResolvable()) { + this.dynamicConfigResolver = configResolver.get(); + this.requestContext = new BlockingTaskRunner<>(blockingSecurityExecutor); + } else { + this.dynamicConfigResolver = null; + this.requestContext = null; + } + } + + @Override + public Uni resolvePolicyEnforcer(RoutingContext routingContext, OidcTenantConfig tenantConfig) { + if (tenantConfig == null) { + return Uni.createFrom().item(defaultPolicyEnforcer); + } + if (dynamicConfigResolver == null) { + return Uni.createFrom().item(getStaticPolicyEnforcer(tenantConfig.tenantId.get())); + } else { + return getDynamicPolicyEnforcer(routingContext, tenantConfig) + .onItem().ifNull().continueWith(new Supplier() { + @Override + public PolicyEnforcer get() { + return getStaticPolicyEnforcer(tenantConfig.tenantId.get()); + } + }); + } + } + + @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, OidcTenantConfig config) { + return dynamicConfigResolver.resolve(routingContext, config, requestContext) + .onItem().ifNotNull().transform(new Function() { + @Override + public PolicyEnforcer apply(KeycloakPolicyEnforcerTenantConfig tenant) { + return createPolicyEnforcer(config, tenant, globalTrustAll); + } + }); + } + + 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); + } +} 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 fa7831fb6e266..00ee292e133ef 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,161 @@ 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.oidc.OidcTenantConfig; +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()) { + OidcTenantConfig tenantConfig = routingContext == null ? null + : routingContext.get(OidcTenantConfig.class.getName()); + return resolver.resolvePolicyEnforcer(routingContext, tenantConfig) + .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, routingContext.get(OidcTenantConfig.class.getName())) + .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 214ab3da39218..8c3fb55a758e9 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,36 +1,10 @@ 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.annotations.Recorder; -import io.quarkus.runtime.configuration.ConfigurationException; -import io.quarkus.tls.TlsConfiguration; -import io.quarkus.tls.TlsConfigurationRegistry; -import io.quarkus.vertx.http.runtime.HttpConfiguration; @Recorder public class KeycloakPolicyEnforcerRecorder { @@ -52,212 +26,6 @@ public boolean getAsBoolean() { }; } - public Supplier setup(OidcConfig oidcConfig, KeycloakPolicyEnforcerConfig config, - HttpConfiguration httpConfiguration, Supplier registrySupplier) { - TlsConfigurationRegistry registry = registrySupplier.get(); - boolean trustAll = false; - if (registry != null) { - trustAll = registry.getDefault().map(TlsConfiguration::isTrustAll).orElse(false); - } - - PolicyEnforcer defaultPolicyEnforcer = createPolicyEnforcer(oidcConfig.defaultTenant, config.defaultTenant(), - trustAll); - 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(), trustAll)); - } - return new Supplier() { - @Override - public PolicyEnforcerResolver get() { - return new PolicyEnforcerResolver(defaultPolicyEnforcer, policyEnforcerTenants, - httpConfiguration.readTimeout.toMillis()); - } - }; - } - - private static PolicyEnforcer createPolicyEnforcer(OidcTenantConfig oidcConfig, - KeycloakPolicyEnforcerTenantConfig keycloakPolicyEnforcerConfig, - boolean globalTrustAll) { - - 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 - : globalTrustAll; - 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; @@ -284,9 +52,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 tenant config; must not be null + * + * @return KeycloakPolicyEnforcerTenantConfigBuilder builder + */ + static KeycloakPolicyEnforcerTenantConfigBuilder builder(KeycloakPolicyEnforcerTenantConfig tenantConfig) { + Objects.requireNonNull(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 0000000000000..fe79e98af466e --- /dev/null +++ b/extensions/keycloak-authorization/runtime/src/main/java/io/quarkus/keycloak/pep/runtime/KeycloakPolicyEnforcerTenantConfigBuilder.java @@ -0,0 +1,597 @@ +package io.quarkus.keycloak.pep.runtime; + +import static org.keycloak.representations.adapters.config.PolicyEnforcerConfig.EnforcementMode.ENFORCING; + +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Optional; +import java.util.Set; +import java.util.function.BiConsumer; +import java.util.function.Function; +import java.util.random.RandomGenerator; + +import org.keycloak.representations.adapters.config.PolicyEnforcerConfig.EnforcementMode; +import org.keycloak.representations.adapters.config.PolicyEnforcerConfig.ScopeEnforcementMode; + +import io.quarkus.keycloak.pep.runtime.KeycloakPolicyEnforcerTenantConfig.KeycloakConfigPolicyEnforcer; +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 record KeycloakPolicyEnforcerTenantConfigImpl(int connectionPoolSize, + KeycloakConfigPolicyEnforcer policyEnforcer) implements KeycloakPolicyEnforcerTenantConfig { + } + + private record KeycloakConfigPolicyEnforcerImpl(Map paths, EnforcementMode enforcementMode, + boolean lazyLoadPaths, boolean httpMethodAsScope, ClaimInformationPointConfig claimInformationPoint, + PathCacheConfig pathCache) implements KeycloakConfigPolicyEnforcer { + } + + private record PathCacheConfigImpl(long lifespan, int maxEntries) implements PathCacheConfig { + } + + private record ClaimInformationPointConfigImpl(Map> simpleConfig, + Map>> complexConfig) implements ClaimInformationPointConfig { + } + + private record MethodConfigImpl(String method, List scopes, + ScopeEnforcementMode scopesEnforcementMode) implements MethodConfig { + } + + private record PathConfigImpl(Optional name, Optional path, Optional> paths, + Map methods, EnforcementMode enforcementMode, + ClaimInformationPointConfig claimInformationPoint) implements PathConfig { + } + + 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) { + connectionPoolSize = originalConfig.connectionPoolSize(); + var policyEnforcer = originalConfig.policyEnforcer(); + enforcementMode = policyEnforcer.enforcementMode(); + lazyLoadPaths = policyEnforcer.lazyLoadPaths(); + httpMethodAsScope = policyEnforcer.httpMethodAsScope(); + claimInformationPoint = policyEnforcer.claimInformationPoint(); + pathCache = policyEnforcer.pathCache(); + policyEnforcer.paths().forEach(new BiConsumer() { + @Override + public void accept(String name, PathConfig pathConfig) { + paths.put(name, new PathConfigBuilderImpl(KeycloakPolicyEnforcerTenantConfigBuilder.this, pathConfig)); + } + }); + } + + /** + * Creates immutable {@link KeycloakPolicyEnforcerTenantConfig}. + * Original builder can be safely re-used. The builder itself is not a thread-safe. + * + * @return KeycloakPolicyEnforcerTenantConfig + */ + public KeycloakPolicyEnforcerTenantConfig build() { + var pathConfigs = new HashMap(); + paths.forEach(new BiConsumer() { + @Override + public void accept(String name, PathConfigBuilderImpl pathConfigBuilder) { + var pathConfig = new PathConfigImpl(Optional.ofNullable(pathConfigBuilder.name), Optional.empty(), + Optional.of(List.copyOf(pathConfigBuilder.paths)), Map.copyOf(pathConfigBuilder.methods), + pathConfigBuilder.enforcementMode, pathConfigBuilder.claimInformationPointConfig); + pathConfigs.put(name, pathConfig); + } + }); + return new KeycloakPolicyEnforcerTenantConfigImpl(connectionPoolSize, new KeycloakConfigPolicyEnforcerImpl( + Map.copyOf(pathConfigs), enforcementMode, lazyLoadPaths, httpMethodAsScope, claimInformationPoint, pathCache)); + } + + public KeycloakPolicyEnforcerTenantConfigBuilder connectionPoolSize(int connectionPoolSize) { + this.connectionPoolSize = connectionPoolSize; + return this; + } + + public KeycloakPolicyEnforcerTenantConfigBuilder enforcementMode(EnforcementMode enforcementMode) { + Objects.requireNonNull(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; + } + + public KeycloakPolicyEnforcerTenantConfigBuilder pathCache(long lifespan) { + pathCache = new PathCacheConfigImpl(lifespan, pathCache == null ? 0 : pathCache.maxEntries()); + return this; + } + + public KeycloakPolicyEnforcerTenantConfigBuilder pathCache(int maxEntries) { + pathCache = new PathCacheConfigImpl(pathCache == null ? 0L : pathCache.lifespan(), maxEntries); + return this; + } + + public KeycloakPolicyEnforcerTenantConfigBuilder pathCache(int maxEntries, long lifespan) { + pathCache = new PathCacheConfigImpl(lifespan, maxEntries); + return this; + } + + public PathCacheConfigBuilder pathCache() { + return new PathCacheConfigBuilder(this); + } + + public KeycloakPolicyEnforcerTenantConfigBuilder claimInformationPoint(Map> simpleConfig) { + claimInformationPoint = new ClaimInformationPointConfigImpl(simpleConfig == null ? Map.of() : Map.copyOf(simpleConfig), + claimInformationPoint == null ? Map.of() : claimInformationPoint.complexConfig()); + return this; + } + + public KeycloakPolicyEnforcerTenantConfigBuilder claimInformationPoint(Map> simpleConfig, + Map>> complexConfig) { + claimInformationPoint = new ClaimInformationPointConfigImpl(simpleConfig == null ? Map.of() : Map.copyOf(simpleConfig), + complexConfig == null ? Map.of() : Map.copyOf(complexConfig)); + return this; + } + + public ClaimInformationPointConfigBuilder claimInformationPoint() { + return new ClaimInformationPointConfigBuilder<>() { + + @Override + public KeycloakPolicyEnforcerTenantConfigBuilder build() { + if (simpleConfig != null || complexConfig != null) { + return KeycloakPolicyEnforcerTenantConfigBuilder.this.claimInformationPoint(simpleConfig, complexConfig); + } + return KeycloakPolicyEnforcerTenantConfigBuilder.this; + } + }; + } + + /** + * Adds path with {@param name). + * + * @param name refers to the 'path1' from the 'quarkus.keycloak.policy-enforcer.paths."path1".*' config properties + * @param paths refers to the 'quarkus.keycloak.policy-enforcer.paths."paths".paths' configuration property + * @param enforcementMode enforcement mode + * @return PathConfigBuilder + */ + public PathConfigBuilder namedPaths(String name, EnforcementMode enforcementMode, String... paths) { + var pathConfigBuilder = namedPaths(name, paths); + pathConfigBuilder.enforcementMode(enforcementMode); + return pathConfigBuilder; + } + + /** + * Adds path with {@param name). + * + * @param name refers to the 'path1' from the 'quarkus.keycloak.policy-enforcer.paths."path1".*' config properties + * @param paths refers to the 'quarkus.keycloak.policy-enforcer.paths."paths".paths' configuration property + * @return PathConfigBuilder + */ + public PathConfigBuilder namedPaths(String name, String... paths) { + Objects.requireNonNull(name); + final PathConfigBuilderImpl pathConfigBuilder = this.paths.computeIfAbsent(name, + new Function() { + @Override + public PathConfigBuilderImpl apply(String ignored) { + return new PathConfigBuilderImpl(KeycloakPolicyEnforcerTenantConfigBuilder.this, null); + } + }); + if (paths != null && paths.length > 0) { + pathConfigBuilder.paths.addAll(Set.of(paths)); + } + return pathConfigBuilder; + } + + /** + * Adds paths with generated name. + * + * @param paths refers to the 'quarkus.keycloak.policy-enforcer.paths."paths".paths' configuration property + * @param enforcementMode enforcement mode + * @return PathConfigBuilder + */ + public PathConfigBuilder paths(EnforcementMode enforcementMode, String... paths) { + var pathConfigBuilder = paths(paths); + pathConfigBuilder.enforcementMode(enforcementMode); + return pathConfigBuilder; + } + + /** + * Adds paths with generated name. + * + * @param paths refers to the 'quarkus.keycloak.policy-enforcer.paths."paths".paths' configuration property + * @return PathConfigBuilder + */ + public PathConfigBuilder paths(String... paths) { + final String name; + if (paths == null || paths.length == 0) { + name = getRandomPathName(); + } else { + name = StringUtil.hyphenate(String.join("-", paths)); + } + return namedPaths(name, paths); + } + + private String getRandomPathName() { + String name; + do { + name = "path" + RandomGenerator.getDefault().nextInt(); + } while (paths.containsKey(name)); + return name; + } + + public sealed interface PathConfigBuilder permits PathConfigBuilderImpl { + + KeycloakPolicyEnforcerTenantConfigBuilder claimInformationPoint(Map> simpleConfig); + + KeycloakPolicyEnforcerTenantConfigBuilder claimInformationPoint(Map> simpleConfig, + Map>> complexConfig); + + ClaimInformationPointConfigBuilder claimInformationPoint(); + + KeycloakPolicyEnforcerTenantConfigBuilder enforcementMode(EnforcementMode enforcementMode); + + /** + * Makes this path specific for a POST method only. + * + * @param scopes optional scopes + * @return KeycloakPolicyEnforcerTenantConfigBuilder + */ + KeycloakPolicyEnforcerTenantConfigBuilder post(String... scopes); + + /** + * Makes this path specific for a POST method only. + * + * @param scopes optional scopes + * @param scopeEnforcementMode refers to the + * 'quarkus.keycloak.policy-enforcer.paths."paths".methods."methods".scopes-enforcement-mode' config property + * @return KeycloakPolicyEnforcerTenantConfigBuilder + */ + KeycloakPolicyEnforcerTenantConfigBuilder post(ScopeEnforcementMode scopeEnforcementMode, String... scopes); + + /** + * Makes this path specific for a HEAD method only. + * + * @param scopes optional scopes + * @return KeycloakPolicyEnforcerTenantConfigBuilder + */ + KeycloakPolicyEnforcerTenantConfigBuilder head(String... scopes); + + /** + * Makes this path specific for a HEAD method only. + * + * @param scopes optional scopes + * @param scopeEnforcementMode refers to the + * 'quarkus.keycloak.policy-enforcer.paths."paths".methods."methods".scopes-enforcement-mode' config property + * @return KeycloakPolicyEnforcerTenantConfigBuilder + */ + KeycloakPolicyEnforcerTenantConfigBuilder head(ScopeEnforcementMode scopeEnforcementMode, String... scopes); + + /** + * Makes this path specific for a GET method only. + * + * @param scopes optional scopes + * @return KeycloakPolicyEnforcerTenantConfigBuilder + */ + KeycloakPolicyEnforcerTenantConfigBuilder get(String... scopes); + + /** + * Makes this path specific for a GET method only. + * + * @param scopes optional scopes + * @param scopeEnforcementMode refers to the + * 'quarkus.keycloak.policy-enforcer.paths."paths".methods."methods".scopes-enforcement-mode' config property + * @return KeycloakPolicyEnforcerTenantConfigBuilder + */ + KeycloakPolicyEnforcerTenantConfigBuilder get(ScopeEnforcementMode scopeEnforcementMode, String... scopes); + + /** + * Makes this path specific for a PUT method only. + * + * @param scopes optional scopes + * @return KeycloakPolicyEnforcerTenantConfigBuilder + */ + KeycloakPolicyEnforcerTenantConfigBuilder put(String... scopes); + + /** + * Makes this path specific for a PUT method only. + * + * @param scopes optional scopes + * @param scopeEnforcementMode refers to the + * 'quarkus.keycloak.policy-enforcer.paths."paths".methods."methods".scopes-enforcement-mode' config property + * @return KeycloakPolicyEnforcerTenantConfigBuilder + */ + KeycloakPolicyEnforcerTenantConfigBuilder put(ScopeEnforcementMode scopeEnforcementMode, String... scopes); + + /** + * Makes this path specific for a PATCH method only. + * + * @param scopes optional scopes + * @return KeycloakPolicyEnforcerTenantConfigBuilder + */ + KeycloakPolicyEnforcerTenantConfigBuilder patch(String... scopes); + + /** + * Makes this path specific for a PATCH method only. + * + * @param scopes optional scopes + * @param scopeEnforcementMode refers to the + * 'quarkus.keycloak.policy-enforcer.paths."paths".methods."methods".scopes-enforcement-mode' config property + * @return KeycloakPolicyEnforcerTenantConfigBuilder + */ + KeycloakPolicyEnforcerTenantConfigBuilder patch(ScopeEnforcementMode scopeEnforcementMode, String... scopes); + + /** + * Make this path specific for the HTTP {@code method} only. + * + * @param method refers to the 'quarkus.keycloak.policy-enforcer.paths."paths".methods."methods".method' config property + * @param scopeEnforcementMode refers to the + * 'quarkus.keycloak.policy-enforcer.paths."paths".methods."methods".scopes-enforcement-mode' config property + * @param scopes refers to the 'quarkus.keycloak.policy-enforcer.paths."paths".methods."methods".scopes' config property + * @return KeycloakPolicyEnforcerTenantConfigBuilder + */ + KeycloakPolicyEnforcerTenantConfigBuilder method(String method, ScopeEnforcementMode scopeEnforcementMode, + String... scopes); + + /** + * Creates builder for a path method. + * Corresponds to configuration properties 'quarkus.keycloak.policy-enforcer.paths."paths".methods.*'. + * + * @return method builder + */ + MethodConfigBuilder method(); + + /** + * @param name permission name, as set by the 'quarkus.keycloak.policy-enforcer.paths."paths".name' config property + * @return PathConfigBuilder + */ + PathConfigBuilder permissionName(String name); + + /** + * Returns parent {@link KeycloakPolicyEnforcerTenantConfigBuilder}. + * Calling this method is purely optional. + * + * @return parent builder + */ + KeycloakPolicyEnforcerTenantConfigBuilder parent(); + } + + private static final class PathConfigBuilderImpl implements PathConfigBuilder { + private final KeycloakPolicyEnforcerTenantConfigBuilder builder; + private final Map methods = new HashMap<>(); + private final Set paths = new HashSet<>(); + private ClaimInformationPointConfig claimInformationPointConfig = new ClaimInformationPointConfigImpl(Map.of(), + Map.of()); + private EnforcementMode enforcementMode = ENFORCING; + private String name = null; + + private PathConfigBuilderImpl(KeycloakPolicyEnforcerTenantConfigBuilder builder, PathConfig pathConfig) { + this.builder = builder; + if (pathConfig != null) { + this.methods.putAll(pathConfig.methods()); + this.claimInformationPointConfig = pathConfig.claimInformationPoint(); + this.paths.addAll(pathConfig.paths().orElse(List.of())); + if (pathConfig.path().isPresent()) { + this.paths.add(pathConfig.path().get()); + } + this.enforcementMode = pathConfig.enforcementMode(); + } + } + + @Override + public KeycloakPolicyEnforcerTenantConfigBuilder claimInformationPoint( + Map> simpleConfig) { + claimInformationPointConfig = new ClaimInformationPointConfigImpl( + simpleConfig == null ? Map.of() : Map.copyOf(simpleConfig), + claimInformationPointConfig == null ? Map.of() : claimInformationPointConfig.complexConfig()); + return builder; + } + + @Override + public KeycloakPolicyEnforcerTenantConfigBuilder claimInformationPoint(Map> simpleConfig, + Map>> complexConfig) { + claimInformationPointConfig = new ClaimInformationPointConfigImpl( + simpleConfig == null ? Map.of() : Map.copyOf(simpleConfig), + complexConfig == null ? Map.of() : Map.copyOf(complexConfig)); + return builder; + } + + @Override + public ClaimInformationPointConfigBuilder claimInformationPoint() { + return new ClaimInformationPointConfigBuilder<>() { + + @Override + public PathConfigBuilder build() { + if (simpleConfig != null || complexConfig != null) { + PathConfigBuilderImpl.this.claimInformationPoint(simpleConfig, complexConfig); + } + return PathConfigBuilderImpl.this; + } + }; + } + + @Override + public PathConfigBuilder permissionName(String name) { + this.name = name; + return this; + } + + @Override + public KeycloakPolicyEnforcerTenantConfigBuilder enforcementMode(EnforcementMode enforcementMode) { + Objects.requireNonNull(enforcementMode); + this.enforcementMode = enforcementMode; + return builder; + } + + @Override + public KeycloakPolicyEnforcerTenantConfigBuilder post(String... scopes) { + return post(null, scopes); + } + + @Override + public KeycloakPolicyEnforcerTenantConfigBuilder post(ScopeEnforcementMode scopeEnforcementMode, String... scopes) { + return method("POST", scopeEnforcementMode, scopes); + } + + @Override + public KeycloakPolicyEnforcerTenantConfigBuilder head(String... scopes) { + return head(null, scopes); + } + + @Override + public KeycloakPolicyEnforcerTenantConfigBuilder head(ScopeEnforcementMode scopeEnforcementMode, String... scopes) { + return method("HEAD", scopeEnforcementMode, scopes); + } + + @Override + public KeycloakPolicyEnforcerTenantConfigBuilder get(String... scopes) { + return get(null, scopes); + } + + @Override + public KeycloakPolicyEnforcerTenantConfigBuilder get(ScopeEnforcementMode scopeEnforcementMode, String... scopes) { + return method("GET", scopeEnforcementMode, scopes); + } + + @Override + public KeycloakPolicyEnforcerTenantConfigBuilder put(String... scopes) { + return put(null, scopes); + } + + @Override + public KeycloakPolicyEnforcerTenantConfigBuilder put(ScopeEnforcementMode scopeEnforcementMode, String... scopes) { + return method("PUT", scopeEnforcementMode, scopes); + } + + @Override + public KeycloakPolicyEnforcerTenantConfigBuilder patch(String... scopes) { + return patch(null, scopes); + } + + @Override + public KeycloakPolicyEnforcerTenantConfigBuilder patch(ScopeEnforcementMode scopeEnforcementMode, String... scopes) { + return method("PATCH", scopeEnforcementMode, scopes); + } + + @Override + public KeycloakPolicyEnforcerTenantConfigBuilder method(String method, ScopeEnforcementMode scopeEnforcementMode, + String... scopes) { + Objects.requireNonNull(method); + if (scopeEnforcementMode == null) { + // default enforcement scope is ALL + scopeEnforcementMode = ScopeEnforcementMode.ALL; + } + methods.put(method.toLowerCase(), new MethodConfigImpl(method, List.of(scopes), scopeEnforcementMode)); + return builder; + } + + @Override + public MethodConfigBuilder method() { + return new MethodConfigBuilder(this); + } + + @Override + public KeycloakPolicyEnforcerTenantConfigBuilder parent() { + return builder; + } + } + + public static final class PathCacheConfigBuilder { + private final KeycloakPolicyEnforcerTenantConfigBuilder parent; + Integer maxEntries = null; + Long lifespan = null; + + private PathCacheConfigBuilder(KeycloakPolicyEnforcerTenantConfigBuilder parent) { + this.parent = parent; + } + + public PathCacheConfigBuilder lifespan(long lifespan) { + this.lifespan = lifespan; + return this; + } + + public PathCacheConfigBuilder maxEntries(int maxEntries) { + this.maxEntries = maxEntries; + return this; + } + + public KeycloakPolicyEnforcerTenantConfigBuilder build() { + if (maxEntries == null && lifespan == null) { + return parent; + } + if (maxEntries == null) { + return parent.pathCache(lifespan); + } + if (lifespan == null) { + return parent.pathCache(maxEntries); + } + return parent.pathCache(maxEntries, lifespan); + } + } + + public static abstract class ClaimInformationPointConfigBuilder { + protected Map> simpleConfig = null; + protected Map>> complexConfig = null; + + private ClaimInformationPointConfigBuilder() { + } + + public ClaimInformationPointConfigBuilder simpleConfig(Map> simpleConfig) { + this.simpleConfig = simpleConfig; + return this; + } + + public ClaimInformationPointConfigBuilder complexConfig( + Map>> complexConfig) { + this.complexConfig = complexConfig; + return this; + } + + public abstract T build(); + } + + public static final class MethodConfigBuilder { + private final PathConfigBuilder builder; + private String method; + private String[] scopes; + private ScopeEnforcementMode scopesEnforcementMode; + + private MethodConfigBuilder(PathConfigBuilder builder) { + this.builder = builder; + } + + public MethodConfigBuilder method(String method) { + this.method = method; + return this; + } + + public MethodConfigBuilder scopes(String... scopes) { + this.scopes = scopes; + return this; + } + + public MethodConfigBuilder scopesEnforcementMode(ScopeEnforcementMode scopesEnforcementMode) { + this.scopesEnforcementMode = scopesEnforcementMode; + return this; + } + + public PathConfigBuilder build() { + Objects.requireNonNull(method); + builder.method(method, scopesEnforcementMode, scopes == null ? new String[] {} : scopes); + return builder; + } + } +} 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 0000000000000..338cd53d0349e --- /dev/null +++ b/extensions/keycloak-authorization/runtime/src/main/java/io/quarkus/keycloak/pep/runtime/KeycloakPolicyEnforcerUtil.java @@ -0,0 +1,235 @@ +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; + } +} 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 ea7c5f056f968..0000000000000 --- 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 a2f1f5493a32d..13ae663ddac3e 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 0000000000000..4f6fdcb766dd9 --- /dev/null +++ b/integration-tests/keycloak-authorization/src/main/java/io/quarkus/it/keycloak/DynamicTenantPolicyConfigResolver.java @@ -0,0 +1,53 @@ +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.quarkus.oidc.OidcRequestContext; +import io.quarkus.oidc.OidcTenantConfig; +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) { + this.enhancedTenantConfig = KeycloakPolicyEnforcerTenantConfig.builder(enforcerConfig.defaultTenant()) + .paths("/api/permission/scopes/dynamic-way") + .permissionName("Scope Permission Resource") + .get("read") + .paths("/api/permission/scopes/dynamic-way-denied") + .permissionName("Scope Permission Resource") + .get("write") + .build(); + this.newTenantConfig = KeycloakPolicyEnforcerTenantConfig.builder() + .paths("/dynamic-permission-tenant") + .permissionName("Dynamic Config Permission Resource Tenant") + .claimInformationPoint(Map.of("claims", Map.of("static-claim", "static-claim"))) + .build(); + } + + @Override + public Uni resolve(RoutingContext routingContext, OidcTenantConfig tenantConfig, + OidcRequestContext requestContext) { + String path = routingContext.normalizedPath(); + String tenantId = tenantConfig.tenantId.orElse(null); + 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 1a9ead40b875c..56b1aa06a8d2c 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 02ff18aedeae7..d2f617bd2663d 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 d9cc1995242d5..4534f243273c0 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 1b470353dd7e4..40a3e0cfb5eb8 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 @@ -17,9 +17,7 @@ import org.junit.jupiter.api.AfterAll; import org.junit.jupiter.api.Test; -import io.quarkus.test.common.WithTestResource; 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; @@ -28,9 +26,7 @@ /** * @author Pedro Igor */ -@QuarkusTest -@WithTestResource(value = KeycloakLifecycleManager.class, restrictToAnnotatedClass = false) -public class PolicyEnforcerTest { +public abstract class AbstractPolicyEnforcerTest { private static final Duration REQUEST_TIMEOUT = Duration.ofSeconds(10); @TestHTTPResource @@ -241,7 +237,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 0000000000000..5bea1f51652f1 --- /dev/null +++ b/integration-tests/keycloak-authorization/src/test/java/io/quarkus/it/keycloak/DynamicTenantConfigPolicyEnforcerTest.java @@ -0,0 +1,413 @@ +package io.quarkus.it.keycloak; + +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.Assertions; +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.runtime.util.StringUtil; +import io.quarkus.test.common.WithTestResource; +import io.quarkus.test.junit.QuarkusTest; +import io.quarkus.test.junit.QuarkusTestProfile; +import io.quarkus.test.junit.TestProfile; + +@QuarkusTest +@WithTestResource(value = KeycloakLifecycleManager.class, restrictToAnnotatedClass = false) +@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")); + assertBuilderShortcuts(); + assertPathCacheConfigOnly(); + assertClaimInformationPointConfigOnly(); + assertMethodConfigOnly(); + } + + private static void assertBuilderPopulatedWithDefaultValues() { + var config = KeycloakPolicyEnforcerTenantConfig.builder().build(); + assertEquals(20, config.connectionPoolSize()); + assertTrue(config.policyEnforcer().lazyLoadPaths()); + assertEquals(ENFORCING, config.policyEnforcer().enforcementMode()); + assertTrue(config.policyEnforcer().paths().isEmpty()); + assertFalse(config.policyEnforcer().httpMethodAsScope()); + assertEquals(30000, config.policyEnforcer().pathCache().lifespan()); + assertEquals(1000, config.policyEnforcer().pathCache().maxEntries()); + assertTrue(config.policyEnforcer().claimInformationPoint().simpleConfig().isEmpty()); + assertTrue(config.policyEnforcer().claimInformationPoint().complexConfig().isEmpty()); + // now let's create path and see that all defaults are populated + config = KeycloakPolicyEnforcerTenantConfig.builder() + .paths("/api/one").method("TRACE", null) + .paths("/api/two").method("TRACE", null) + .build(); + config.policyEnforcer().paths().values().forEach(p -> { + assertEquals(ENFORCING, p.enforcementMode()); + assertTrue(p.claimInformationPoint().complexConfig().isEmpty()); + assertTrue(p.claimInformationPoint().simpleConfig().isEmpty()); + assertTrue(p.name().isEmpty()); + assertEquals(1, p.paths().get().size()); + assertEquals(1, p.methods().size()); + var method = p.methods().get("trace"); + assertNotNull(method); + // we didn't add scopes, and we are yet to require them + assertTrue(method.scopes().isEmpty()); + assertEquals("TRACE", method.method()); + // default scope enforcement is ALL + assertEquals(PolicyEnforcerConfig.ScopeEnforcementMode.ALL, method.scopesEnforcementMode()); + }); + // create config from previous config and alter one of paths + var builder = KeycloakPolicyEnforcerTenantConfig.builder(config); + var path = builder.paths("/api/one"); + path.permissionName("New Permission"); + path.claimInformationPoint(Map.of("key", Map.of("sub-key", "sub-value"))); + path.get(PolicyEnforcerConfig.ScopeEnforcementMode.DISABLED, "scope5").build(); + config = builder.build(); + // this path must be same as the before, we didn't change it + var p = config.policyEnforcer().paths().get("/api/two"); + assertNotNull(p); + assertEquals(ENFORCING, p.enforcementMode()); + assertTrue(p.claimInformationPoint().complexConfig().isEmpty()); + assertTrue(p.claimInformationPoint().simpleConfig().isEmpty()); + assertTrue(p.name().isEmpty()); + assertEquals(1, p.paths().get().size()); + assertEquals(1, p.methods().size()); + var method = p.methods().get("trace"); + assertNotNull(method); + // we didn't add scopes, and we are yet to require them + assertTrue(method.scopes().isEmpty()); + assertEquals("TRACE", method.method()); + // default scope enforcement is ALL + assertEquals(PolicyEnforcerConfig.ScopeEnforcementMode.ALL, method.scopesEnforcementMode()); + // this path must now have new method GET, scope 'scope5', simple config, new permission name and enforcement + // TRACE method was removed, we need to assert everything else is as it was + p = config.policyEnforcer().paths().get("/api/one"); + assertNotNull(p); + assertEquals(ENFORCING, p.enforcementMode()); + assertTrue(p.claimInformationPoint().complexConfig().isEmpty()); + assertFalse(p.claimInformationPoint().simpleConfig().isEmpty()); + assertEquals(1, p.claimInformationPoint().simpleConfig().size()); + assertFalse(p.claimInformationPoint().simpleConfig().get("key").isEmpty()); + assertEquals("sub-value", p.claimInformationPoint().simpleConfig().get("key").get("sub-key")); + assertFalse(p.name().isEmpty()); + assertEquals("New Permission", p.name().get()); + assertEquals(1, p.paths().get().size()); + assertEquals("/api/one", p.paths().get().get(0)); + assertEquals(2, p.methods().size()); + method = p.methods().get("trace"); + assertNotNull(method); + method = p.methods().get("get"); + // we didn't add scopes, and we are yet to require them + assertTrue(method.scopes().contains("scope5")); + assertEquals("GET", method.method()); + // default scope enforcement is ALL + assertEquals(PolicyEnforcerConfig.ScopeEnforcementMode.DISABLED, method.scopesEnforcementMode()); + // in addition to the GET and PUT and POST methods + builder = KeycloakPolicyEnforcerTenantConfig.builder(config); + path = builder.paths(PERMISSIVE, "/api/one"); + path.post("1", "2"); + path.put(PolicyEnforcerConfig.ScopeEnforcementMode.ANY, "3", "4"); + config = builder.build(); + var pathConfig = config.policyEnforcer().paths().get("/api/one"); + assertEquals(4, pathConfig.methods().size()); + assertEquals(PERMISSIVE, pathConfig.enforcementMode()); + var putMethod = pathConfig.methods().get("put"); + assertNotNull(putMethod); + assertTrue(putMethod.scopes().contains("3")); + assertTrue(putMethod.scopes().contains("4")); + assertEquals("PUT", putMethod.method()); + assertEquals(PolicyEnforcerConfig.ScopeEnforcementMode.ANY, putMethod.scopesEnforcementMode()); + var postMethod = pathConfig.methods().get("post"); + assertNotNull(postMethod); + assertEquals(PolicyEnforcerConfig.ScopeEnforcementMode.ALL, postMethod.scopesEnforcementMode()); + assertEquals("POST", postMethod.method()); + assertTrue(postMethod.scopes().contains("1")); + assertTrue(postMethod.scopes().contains("2")); + // test config with multiple path patterns + config = KeycloakPolicyEnforcerTenantConfig.builder().paths("/x", "/y").enforcementMode(PERMISSIVE).build(); + pathConfig = config.policyEnforcer().paths().entrySet().stream().findAny().get().getValue(); + assertEquals(2, pathConfig.paths().get().size()); + assertTrue(pathConfig.paths().get().contains("/x")); + assertTrue(pathConfig.paths().get().contains("/y")); + assertEquals(PERMISSIVE, pathConfig.enforcementMode()); + } + + private static void assertEveryConfigPropertyCanBeSet() { + var builder = KeycloakPolicyEnforcerTenantConfig.builder() + .enforcementMode(DISABLED) + .claimInformationPoint(Map.of("one", Map.of("two", "three")), Map.of("four", Map.of())) + .connectionPoolSize(-1) + .lazyLoadPaths(false) + .pathCache(5, 2) + .httpMethodAsScope(true); + var pathBuilder = builder.namedPaths("p1", PERMISSIVE, "path"); + pathBuilder.permissionName("n1"); + pathBuilder.method("method1", PolicyEnforcerConfig.ScopeEnforcementMode.DISABLED, "scope1", "scopes2"); + var config = builder.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("method1"); + 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 builder = KeycloakPolicyEnforcerTenantConfig.builder(originalConfig); + builder.namedPaths("2").enforcementMode(ENFORCING); + var pathBuilder = builder.namedPaths("3"); + pathBuilder.permissionName("some-name"); + pathBuilder.put(); + var enhancedConfig = builder.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 assertBuilderShortcuts() { + var config = KeycloakPolicyEnforcerTenantConfig.builder().paths("/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, String 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 pathMethod = config.policyEnforcer().paths().get(StringUtil.hyphenate(path)).methods().get(method.toLowerCase()); + assertEquals(method, pathMethod.method()); + assertTrue(pathMethod.scopes().contains(scope)); + } + + private static void assertPathCacheConfigOnly() { + // lifespan only + KeycloakPolicyEnforcerTenantConfig config = KeycloakPolicyEnforcerTenantConfig + .builder() + .pathCache(5L) + .build(); + assertEquals(5L, config.policyEnforcer().pathCache().lifespan()); + // test default value is kept for the max entries property + assertEquals(1000, config.policyEnforcer().pathCache().maxEntries()); + // max entries only; original config is enhanced to test that previous value is kept + config = KeycloakPolicyEnforcerTenantConfig + .builder(config) + .pathCache(2) + .build(); + assertEquals(5L, config.policyEnforcer().pathCache().lifespan()); + assertEquals(2, config.policyEnforcer().pathCache().maxEntries()); + // both lifespan and max entries + config = KeycloakPolicyEnforcerTenantConfig + .builder() + .pathCache(2, 5L) + .build(); + assertEquals(5L, config.policyEnforcer().pathCache().lifespan()); + assertEquals(2, config.policyEnforcer().pathCache().maxEntries()); + // builder + config = KeycloakPolicyEnforcerTenantConfig + .builder() + .pathCache() + .build().build(); + assertNotNull(config); + assertNotNull(config.policyEnforcer().pathCache()); + // check defaults in place + assertEquals(1000, config.policyEnforcer().pathCache().maxEntries()); + assertEquals(30000, config.policyEnforcer().pathCache().lifespan()); + config = KeycloakPolicyEnforcerTenantConfig + .builder() + .pathCache() + .maxEntries(123) + .build().build(); + assertEquals(123, config.policyEnforcer().pathCache().maxEntries()); + assertEquals(30000, config.policyEnforcer().pathCache().lifespan()); + config = KeycloakPolicyEnforcerTenantConfig + .builder() + .pathCache() + .lifespan(321) + .build().build(); + assertEquals(1000, config.policyEnforcer().pathCache().maxEntries()); + assertEquals(321, config.policyEnforcer().pathCache().lifespan()); + config = KeycloakPolicyEnforcerTenantConfig + .builder() + .pathCache() + .maxEntries(666) + .lifespan(555) + .build().build(); + assertEquals(666, config.policyEnforcer().pathCache().maxEntries()); + assertEquals(555, config.policyEnforcer().pathCache().lifespan()); + } + + private static void assertClaimInformationPointConfigOnly() { + var config = KeycloakPolicyEnforcerTenantConfig.builder() + .claimInformationPoint(Map.of("simple0", Map.of())) + .build(); + assertNotNull(config.policyEnforcer().claimInformationPoint()); + assertNotNull(config.policyEnforcer().claimInformationPoint().simpleConfig()); + assertFalse(config.policyEnforcer().claimInformationPoint().simpleConfig().isEmpty()); + assertTrue(config.policyEnforcer().claimInformationPoint().simpleConfig().containsKey("simple0")); + config = KeycloakPolicyEnforcerTenantConfig.builder().build(); + assertNotNull(config.policyEnforcer().claimInformationPoint()); + assertNotNull(config.policyEnforcer().claimInformationPoint().complexConfig()); + assertTrue(config.policyEnforcer().claimInformationPoint().complexConfig().isEmpty()); + assertNotNull(config.policyEnforcer().claimInformationPoint().simpleConfig()); + assertTrue(config.policyEnforcer().claimInformationPoint().simpleConfig().isEmpty()); + config = KeycloakPolicyEnforcerTenantConfig.builder() + .claimInformationPoint() + .complexConfig(Map.of("complex1", Map.of())) + .build().build(); + assertNotNull(config.policyEnforcer().claimInformationPoint()); + assertNotNull(config.policyEnforcer().claimInformationPoint().complexConfig()); + assertFalse(config.policyEnforcer().claimInformationPoint().complexConfig().isEmpty()); + assertTrue(config.policyEnforcer().claimInformationPoint().complexConfig().containsKey("complex1")); + config = KeycloakPolicyEnforcerTenantConfig.builder() + .claimInformationPoint() + .simpleConfig(Map.of("simple1", Map.of())) + .build().build(); + assertNotNull(config.policyEnforcer().claimInformationPoint()); + assertNotNull(config.policyEnforcer().claimInformationPoint().simpleConfig()); + assertFalse(config.policyEnforcer().claimInformationPoint().simpleConfig().isEmpty()); + assertTrue(config.policyEnforcer().claimInformationPoint().simpleConfig().containsKey("simple1")); + config = KeycloakPolicyEnforcerTenantConfig.builder() + .claimInformationPoint() + .simpleConfig(Map.of("simple2", Map.of())) + .complexConfig(Map.of("complex2", Map.of())) + .build().build(); + assertNotNull(config.policyEnforcer().claimInformationPoint()); + assertNotNull(config.policyEnforcer().claimInformationPoint().simpleConfig()); + assertFalse(config.policyEnforcer().claimInformationPoint().simpleConfig().isEmpty()); + assertTrue(config.policyEnforcer().claimInformationPoint().simpleConfig().containsKey("simple2")); + assertNotNull(config.policyEnforcer().claimInformationPoint().complexConfig()); + assertFalse(config.policyEnforcer().claimInformationPoint().complexConfig().isEmpty()); + assertTrue(config.policyEnforcer().claimInformationPoint().complexConfig().containsKey("complex2")); + config = KeycloakPolicyEnforcerTenantConfig.builder() + .paths("one") + .claimInformationPoint() + .complexConfig(Map.of("complex3", Map.of())) + .simpleConfig(Map.of("simple3", Map.of())) + .build() + .parent().build(); + assertNotNull(config.policyEnforcer().paths().get("one")); + assertNotNull(config.policyEnforcer().paths().get("one").claimInformationPoint()); + var claimInfoPointConfig = config.policyEnforcer().paths().get("one").claimInformationPoint(); + assertFalse(claimInfoPointConfig.simpleConfig().isEmpty()); + assertTrue(claimInfoPointConfig.simpleConfig().containsKey("simple3")); + assertNotNull(claimInfoPointConfig.complexConfig()); + assertFalse(claimInfoPointConfig.complexConfig().isEmpty()); + assertTrue(claimInfoPointConfig.complexConfig().containsKey("complex3")); + } + + private static void assertMethodConfigOnly() { + Assertions.assertThrows(NullPointerException.class, () -> KeycloakPolicyEnforcerTenantConfig.builder() + .paths("two") + .method() + .build()); + var config = KeycloakPolicyEnforcerTenantConfig.builder() + .paths("three") + .method() + .method("one") + .build() + .parent() + .build(); + var path = config.policyEnforcer().paths().get("three"); + assertNotNull(path); + assertFalse(path.methods().isEmpty()); + var method = path.methods().get("one"); + assertNotNull(method); + assertEquals("one", method.method()); + // assert defaults + assertEquals(PolicyEnforcerConfig.ScopeEnforcementMode.ALL, method.scopesEnforcementMode()); + assertNotNull(method.scopes()); + assertTrue(method.scopes().isEmpty()); + config = KeycloakPolicyEnforcerTenantConfig.builder() + .paths("four") + .method() + .method("two") + .scopes("one", "two") + .scopesEnforcementMode(PolicyEnforcerConfig.ScopeEnforcementMode.DISABLED) + .build() + .parent() + .build(); + path = config.policyEnforcer().paths().get("four"); + assertNotNull(path); + assertFalse(path.methods().isEmpty()); + method = path.methods().get("two"); + assertNotNull(method); + assertEquals("two", method.method()); + assertEquals(PolicyEnforcerConfig.ScopeEnforcementMode.DISABLED, method.scopesEnforcementMode()); + assertNotNull(method.scopes()); + assertFalse(method.scopes().isEmpty()); + assertTrue(method.scopes().contains("one")); + assertTrue(method.scopes().contains("two")); + } +} 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 e9e3b7a30533c..e9b5ea9d04f2c 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 23f8846d8192c..c39c631c08290 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 0000000000000..f43bb4c939508 --- /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.WithTestResource; +import io.quarkus.test.junit.QuarkusTest; + +@QuarkusTest +@WithTestResource(value = KeycloakLifecycleManager.class, restrictToAnnotatedClass = false) +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); + } +}