From 8a471bb0d2b4c8fb4f31d729c76f03ab2e150eb3 Mon Sep 17 00:00:00 2001 From: Steven Hawkins Date: Thu, 5 Feb 2026 04:16:29 -0500 Subject: [PATCH] Operator logic for clients in admin api v2 (#45316) Operator logic for clients in admin api v2 Closes #46022 Signed-off-by: Steven Hawkins --- operator/pom.xml | 12 + .../java/org/keycloak/operator/Constants.java | 7 + .../KeycloakAdminSecretDependentResource.java | 1 - .../KeycloakClientBaseController.java | 398 ++++++++++++++++++ .../controllers/KeycloakController.java | 2 +- .../KeycloakDeploymentDependentResource.java | 15 +- .../controllers/KeycloakDistConfigurator.java | 4 +- .../KeycloakOIDCClientController.java | 80 ++++ .../KeycloakRealmImportController.java | 4 +- .../KeycloakSAMLClientController.java | 41 ++ .../KeycloakServiceDependentResource.java | 9 +- .../crds/v2alpha1/StatusCondition.java | 20 +- .../v2alpha1/client/KeycloakClientSpec.java | 67 +++ .../v2alpha1/client/KeycloakClientStatus.java | 75 ++++ .../client/KeycloakClientStatusCondition.java | 35 ++ .../v2alpha1/client/KeycloakOIDCClient.java | 50 +++ .../KeycloakOIDCClientRepresentation.java | 51 +++ .../client/KeycloakOIDCClientSpec.java | 25 ++ .../v2alpha1/client/KeycloakSAMLClient.java | 50 +++ .../KeycloakSAMLClientRepresentation.java | 26 ++ .../client/KeycloakSAMLClientSpec.java | 25 ++ .../v2alpha1/deployment/KeycloakSpec.java | 13 + .../deployment/KeycloakStatusAggregator.java | 11 +- .../v2alpha1/deployment/spec/AdminSpec.java | 22 + .../KeycloakClientBaseControllerTest.java | 29 ++ .../operator/testsuite/apiserver/CRDTest.java | 21 + .../integration/BaseOperatorTest.java | 15 +- .../integration/KeycloakClientTest.java | 177 ++++++++ .../test/resources/example-mtls-secret.yaml | 94 +++++ .../example-mtls-truststore-secret.yaml | 41 ++ ...-serialization-keycloak-oidc-client-cr.yml | 13 + .../quarkus/runtime/oas/OASModelFilter.java | 8 +- .../admin/v2/BaseClientRepresentation.java | 23 +- .../admin/v2/OIDCClientRepresentation.java | 15 +- .../keycloak/admin/api/client/ClientApi.java | 1 + .../admin/api/client/DefaultClientApi.java | 2 +- .../admin/client/v2/ClientApiV2Test.java | 3 + 37 files changed, 1440 insertions(+), 45 deletions(-) create mode 100644 operator/src/main/java/org/keycloak/operator/controllers/KeycloakClientBaseController.java create mode 100644 operator/src/main/java/org/keycloak/operator/controllers/KeycloakOIDCClientController.java create mode 100644 operator/src/main/java/org/keycloak/operator/controllers/KeycloakSAMLClientController.java create mode 100644 operator/src/main/java/org/keycloak/operator/crds/v2alpha1/client/KeycloakClientSpec.java create mode 100644 operator/src/main/java/org/keycloak/operator/crds/v2alpha1/client/KeycloakClientStatus.java create mode 100644 operator/src/main/java/org/keycloak/operator/crds/v2alpha1/client/KeycloakClientStatusCondition.java create mode 100644 operator/src/main/java/org/keycloak/operator/crds/v2alpha1/client/KeycloakOIDCClient.java create mode 100644 operator/src/main/java/org/keycloak/operator/crds/v2alpha1/client/KeycloakOIDCClientRepresentation.java create mode 100644 operator/src/main/java/org/keycloak/operator/crds/v2alpha1/client/KeycloakOIDCClientSpec.java create mode 100644 operator/src/main/java/org/keycloak/operator/crds/v2alpha1/client/KeycloakSAMLClient.java create mode 100644 operator/src/main/java/org/keycloak/operator/crds/v2alpha1/client/KeycloakSAMLClientRepresentation.java create mode 100644 operator/src/main/java/org/keycloak/operator/crds/v2alpha1/client/KeycloakSAMLClientSpec.java create mode 100644 operator/src/main/java/org/keycloak/operator/crds/v2alpha1/deployment/spec/AdminSpec.java create mode 100644 operator/src/test/java/org/keycloak/operator/controllers/KeycloakClientBaseControllerTest.java create mode 100644 operator/src/test/java/org/keycloak/operator/testsuite/integration/KeycloakClientTest.java create mode 100644 operator/src/test/resources/example-mtls-secret.yaml create mode 100644 operator/src/test/resources/example-mtls-truststore-secret.yaml create mode 100644 operator/src/test/resources/test-serialization-keycloak-oidc-client-cr.yml diff --git a/operator/pom.xml b/operator/pom.xml index e05a59c6db6..c7fe147977d 100644 --- a/operator/pom.xml +++ b/operator/pom.xml @@ -111,6 +111,18 @@ org.keycloak keycloak-core + + org.keycloak + keycloak-admin-client + + + org.keycloak + keycloak-admin-v2-api + + + org.keycloak + keycloak-admin-v2-rest + diff --git a/operator/src/main/java/org/keycloak/operator/Constants.java b/operator/src/main/java/org/keycloak/operator/Constants.java index 9afdeadc571..9bd4fa0c25b 100644 --- a/operator/src/main/java/org/keycloak/operator/Constants.java +++ b/operator/src/main/java/org/keycloak/operator/Constants.java @@ -16,6 +16,7 @@ */ package org.keycloak.operator; +import java.time.Duration; import java.util.Collections; import java.util.List; import java.util.Map; @@ -41,9 +42,13 @@ public final class Constants { public static final String KEYCLOAK_UPDATE_REVISION_ANNOTATION = "operator.keycloak.org/update-revision"; public static final String KEYCLOAK_UPDATE_HASH_ANNOTATION = "operator.keycloak.org/update-hash"; public static final String APP_LABEL = "app"; + public static final String CLIENT_ID_KEY = "client-id"; + public static final String CLIENT_SECRET_KEY = "client-secret"; public static final String DEFAULT_LABELS_AS_STRING = "app=keycloak,app.kubernetes.io/managed-by=keycloak-operator"; + public static final String AUTHORIZATION_HEADER = "Authorization"; + public static final Map DEFAULT_LABELS = Collections .unmodifiableMap(Stream.of(DEFAULT_LABELS_AS_STRING.split(",")).map(s -> s.split("=")) .collect(Collectors.toMap(e -> e[0], e -> e[1], (u1, u2) -> u1, TreeMap::new))); @@ -83,4 +88,6 @@ public final class Constants { public static final String KEYCLOAK_HTTP_MANAGEMENT_RELATIVE_PATH_KEY = "http-management-relative-path"; public static final String KEYCLOAK_NETWORK_POLICY_SUFFIX = "-network-policy"; + + public static final Duration RETRY_DURATION = Duration.ofSeconds(10); } diff --git a/operator/src/main/java/org/keycloak/operator/controllers/KeycloakAdminSecretDependentResource.java b/operator/src/main/java/org/keycloak/operator/controllers/KeycloakAdminSecretDependentResource.java index a794f788868..edab9457525 100644 --- a/operator/src/main/java/org/keycloak/operator/controllers/KeycloakAdminSecretDependentResource.java +++ b/operator/src/main/java/org/keycloak/operator/controllers/KeycloakAdminSecretDependentResource.java @@ -45,7 +45,6 @@ public class KeycloakAdminSecretDependentResource extends KubernetesDependentRes .addToLabels(Utils.allInstanceLabels(primary)) .withNamespace(primary.getMetadata().getNamespace()) .endMetadata() - .withType("Opaque") .withType("kubernetes.io/basic-auth") .addToData("username", Utils.asBase64("temp-admin")) .addToData("password", Utils.asBase64(UUID.randomUUID().toString().replace("-", ""))) diff --git a/operator/src/main/java/org/keycloak/operator/controllers/KeycloakClientBaseController.java b/operator/src/main/java/org/keycloak/operator/controllers/KeycloakClientBaseController.java new file mode 100644 index 00000000000..b24d67530e1 --- /dev/null +++ b/operator/src/main/java/org/keycloak/operator/controllers/KeycloakClientBaseController.java @@ -0,0 +1,398 @@ +/* + * Copyright 2021 Red Hat, Inc. and/or its affiliates + * and other contributors as indicated by the @author tags. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.keycloak.operator.controllers; + +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.lang.reflect.Field; +import java.net.HttpURLConnection; +import java.nio.charset.StandardCharsets; +import java.security.KeyManagementException; +import java.security.KeyStore; +import java.security.KeyStoreException; +import java.security.NoSuchAlgorithmException; +import java.security.UnrecoverableKeyException; +import java.security.cert.CertificateException; +import java.security.cert.CertificateFactory; +import java.security.cert.X509Certificate; +import java.security.spec.InvalidKeySpecException; +import java.util.ArrayList; +import java.util.Base64; +import java.util.Comparator; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.concurrent.TimeUnit; +import java.util.function.Function; +import javax.net.ssl.KeyManager; +import javax.net.ssl.KeyManagerFactory; +import javax.net.ssl.SSLContext; +import javax.net.ssl.TrustManagerFactory; + +import jakarta.inject.Inject; +import jakarta.ws.rs.Path; +import jakarta.ws.rs.PathParam; +import jakarta.ws.rs.WebApplicationException; +import jakarta.ws.rs.client.Client; +import jakarta.ws.rs.client.ClientBuilder; +import jakarta.ws.rs.client.WebTarget; + +import org.keycloak.OAuth2Constants; +import org.keycloak.admin.api.AdminApi; +import org.keycloak.admin.api.client.ClientApi; +import org.keycloak.admin.client.ClientBuilderWrapper; +import org.keycloak.admin.client.KeycloakBuilder; +import org.keycloak.operator.Config; +import org.keycloak.operator.Constants; +import org.keycloak.operator.Utils; +import org.keycloak.operator.crds.v2alpha1.client.KeycloakClientSpec; +import org.keycloak.operator.crds.v2alpha1.client.KeycloakClientStatus; +import org.keycloak.operator.crds.v2alpha1.client.KeycloakClientStatusBuilder; +import org.keycloak.operator.crds.v2alpha1.client.KeycloakClientStatusCondition; +import org.keycloak.operator.crds.v2alpha1.deployment.Keycloak; +import org.keycloak.operator.crds.v2alpha1.deployment.KeycloakStatusAggregator; +import org.keycloak.operator.crds.v2alpha1.deployment.KeycloakStatusCondition; +import org.keycloak.operator.crds.v2alpha1.deployment.spec.HttpSpec; +import org.keycloak.representations.admin.v2.BaseClientRepresentation; + +import io.fabric8.kubernetes.api.model.Secret; +import io.fabric8.kubernetes.client.CustomResource; +import io.fabric8.kubernetes.client.KubernetesClient; +import io.fabric8.kubernetes.client.internal.CertUtils; +import io.javaoperatorsdk.operator.api.reconciler.Cleaner; +import io.javaoperatorsdk.operator.api.reconciler.Context; +import io.javaoperatorsdk.operator.api.reconciler.DeleteControl; +import io.javaoperatorsdk.operator.api.reconciler.ErrorStatusUpdateControl; +import io.javaoperatorsdk.operator.api.reconciler.Reconciler; +import io.javaoperatorsdk.operator.api.reconciler.UpdateControl; +import io.quarkus.logging.Log; +import org.apache.http.conn.ssl.NoopHostnameVerifier; + +import static org.keycloak.operator.crds.v2alpha1.CRDUtils.isTlsConfigured; + +/** + * Base class for Client controllers. + * + * @param custom resource type + * @param base server type for the Client + * @param spec refined type for the Client + */ +public abstract class KeycloakClientBaseController, KeycloakClientStatus>, T extends BaseClientRepresentation, S extends BaseClientRepresentation> + implements Reconciler, Cleaner { + + private static final String CLIENT_API_VERSION = "v2"; + private static final String HTTPS = "https"; + + static class KeycloakClientStatusAggregator { + Long generation; + KeycloakClientStatus existingStatus; + Map existingConditions; + Map newConditions = new LinkedHashMap(); + + KeycloakClientStatusAggregator(CustomResource resource) { + this.generation = resource.getMetadata().getGeneration(); + this.existingStatus = Optional.ofNullable(resource.getStatus()).orElse(new KeycloakClientStatus()); + existingConditions = KeycloakStatusAggregator.getConditionMap(existingStatus.getConditions()); + } + + void setCondition(String type, Boolean status, String message) { + KeycloakClientStatusCondition condition = new KeycloakClientStatusCondition(); + condition.setType(type); + condition.setStatus(status); + condition.setMessage(message); + condition.setObservedGeneration(generation); + newConditions.put(type, condition); // No aggregation yet + } + + KeycloakClientStatus build() { + KeycloakClientStatusBuilder statusBuilder = new KeycloakClientStatusBuilder(); + String now = Utils.iso8601Now(); + statusBuilder.withObservedGeneration(generation); + newConditions.values().forEach(c -> KeycloakStatusAggregator.updateConditionFromExisting(c, existingConditions, now)); + existingConditions.putAll(newConditions); + existingConditions.computeIfAbsent(KeycloakStatusCondition.HAS_ERRORS, + k -> new KeycloakClientStatusCondition(KeycloakStatusCondition.HAS_ERRORS, false, null, now, + generation)); + statusBuilder.withConditions(new ArrayList<>(existingConditions.values().stream().sorted(Comparator.comparing(KeycloakClientStatusCondition::getType)).toList())); + return statusBuilder.build(); + } + + public KeycloakClientStatus getExistingStatus() { + return existingStatus; + } + + } + + @Inject + Config config; + + @Override + public UpdateControl reconcile(R resource, Context context) throws Exception { + String kcName = resource.getSpec().getKeycloakCRName(); + + // TODO: this should be obtained from an informer instead + // they can't be shared directly across controllers, so we'd have to inject the + // KeycloakController and access via a reference to a saved context + Keycloak keycloak = context.getClient().resources(Keycloak.class) + .inNamespace(resource.getMetadata().getNamespace()).withName(kcName).require(); + + KeycloakClientStatusAggregator statusAggregator = new KeycloakClientStatusAggregator(resource); + + S client = resource.getSpec().getClient(); + // first convert to the target representation - the spec representation is specialized + var map = context.getClient().getKubernetesSerialization().convertValue(client, Map.class); + map.put(BaseClientRepresentation.DISCRIMINATOR_FIELD, client.getProtocol()); + T rep = context.getClient().getKubernetesSerialization().convertValue(map, getTargetRepresentation()); + // then let the controller subclass apply specific handling + boolean poll = prepareRepresentation(client, rep, context); + rep.setClientId(resource.getMetadata().getName()); + + String hash = Utils.hash(List.of(rep)); + + if (!hash.equals(statusAggregator.getExistingStatus().getHash())) { + var response = invoke(resource, context, keycloak, clientApi -> { + return clientApi.createOrUpdateClient(rep); + }); + + // if not ok response, throw exception to allow the retry loop + // TODO however not all errors (something not validating) should get retried every 10 seconds + // that should instead get captured in the status + if (response.getStatus() != HttpURLConnection.HTTP_OK && response.getStatus() != HttpURLConnection.HTTP_CREATED) { + String message = response.hasEntity() ? response.readEntity(String.class) : ""; + throw new RuntimeException("Client update operation not sucessful with status code " + response.getStatus() + " : " + message); + } + } + + statusAggregator.setCondition(KeycloakClientStatusCondition.HAS_ERRORS, false, null); + KeycloakClientStatus status = statusAggregator.build(); + status.setHash(hash); + UpdateControl updateControl; + + if (status.equals(resource.getStatus())) { + updateControl = UpdateControl.noUpdate(); + } else { + resource.setStatus(status); + updateControl = UpdateControl.patchStatus(resource); + } + + if (poll) { + updateControl.rescheduleAfter(config.keycloak().pollIntervalSeconds(), TimeUnit.SECONDS); + } + + return updateControl; + } + + abstract boolean prepareRepresentation(S crRepresentation, T targetRepresentation, Context context); + + abstract Class getTargetRepresentation(); + + /** + * Uses a finalizer to ensure clients are not orphaned unless a user goes out of + * their way to do so + */ + @Override + public DeleteControl cleanup(R resource, Context context) throws Exception { + String kcName = resource.getSpec().getKeycloakCRName(); + + Keycloak keycloak = context.getClient().resources(Keycloak.class) + .inNamespace(resource.getMetadata().getNamespace()).withName(kcName).get(); + + if (keycloak == null) { + return DeleteControl.defaultDelete(); + } + + invoke(resource, context, keycloak, client -> { + try { + client.deleteClient(); + } catch (WebApplicationException e) { + if (e.getResponse().getStatus() != 404) { + throw e; + } + } + return null; + }); + + return DeleteControl.defaultDelete(); + } + + @Override + public ErrorStatusUpdateControl updateErrorStatus(R resource, Context context, Exception e) { + Log.error("--- Error reconciling", e); + + KeycloakClientStatusAggregator status = new KeycloakClientStatusAggregator(resource); + status.setCondition(KeycloakClientStatusCondition.HAS_ERRORS, true, "Error performing operations:\n" + e.getMessage()); + resource.setStatus(status.build()); + + return ErrorStatusUpdateControl.patchStatus(resource).rescheduleAfter(Constants.RETRY_DURATION); + } + + @Path("admin/api") + public interface AdminRootV2 { + + @Path("{realmName}") + AdminApi adminApi(@PathParam("realmName") String realmName); + + } + + //TODO: for local testing only - consider removing + private String addressOverride; + + public void setAddressOverride(String addressOverride) { + this.addressOverride = addressOverride; + } + + private V invoke(R resource, Context context, Keycloak keycloak, + Function action) { + try (var kcAdmin = getAdminClient(context.getClient(), keycloak, addressOverride)) { + var target = getWebTarget(kcAdmin); + AdminRootV2 root = org.keycloak.admin.client.Keycloak.getClientProvider().targetProxy(target, + AdminRootV2.class); + return action.apply(root.adminApi(resource.getSpec().getRealm()).clients(CLIENT_API_VERSION) + .client(resource.getMetadata().getName())); + } + } + + private WebTarget getWebTarget(org.keycloak.admin.client.Keycloak kcAdmin) { + // TODO: change the api + try { + Field field = kcAdmin.getClass().getDeclaredField("target"); + field.setAccessible(true); + return (WebTarget)field.get(kcAdmin); + } catch (Exception e) { + throw new RuntimeException(e); + } + } + + public static org.keycloak.admin.client.Keycloak getAdminClient(KubernetesClient client, Keycloak keycloak, String addressOverride) { + Secret adminSecret = client.resources(Secret.class) + .inNamespace(keycloak.getMetadata().getNamespace()) + .withName(keycloak.getMetadata().getName() + "-admin").require(); + + String adminUrl = getAdminUrl(keycloak, client, addressOverride); + + Client restEasyClient = null; + + // create a custom client if using https/mtls + if (adminUrl.startsWith(HTTPS)) { + restEasyClient = createRestEasyClient(client, keycloak, restEasyClient); + } + + return KeycloakBuilder.builder() + .serverUrl(adminUrl) + .realm("master") // TODO: could be configured differently + // TODO: validate these fields + .clientId(new String(Base64.getDecoder().decode(adminSecret.getData().get(Constants.CLIENT_ID_KEY)), + StandardCharsets.UTF_8)) + .clientSecret(new String(Base64.getDecoder().decode(adminSecret.getData().get(Constants.CLIENT_SECRET_KEY)), + StandardCharsets.UTF_8)) + .grantType(OAuth2Constants.CLIENT_CREDENTIALS) + .resteasyClient(restEasyClient) + .build(); + } + + private static Client createRestEasyClient(KubernetesClient client, Keycloak keycloak, Client restEasyClient) { + // add server cert trust + String tlsSecretName = keycloak.getSpec().getHttpSpec().getTlsSecret(); + Secret tlsSecret = client.resources(Secret.class) + .inNamespace(keycloak.getMetadata().getNamespace()).withName(tlsSecretName).require(); + byte[] certBytes = Base64.getDecoder().decode(tlsSecret.getData().get("tls.crt")); + + try { + CertificateFactory cf = CertificateFactory.getInstance("X.509"); + X509Certificate cert = (X509Certificate) cf.generateCertificate(new ByteArrayInputStream(certBytes)); + TrustManagerFactory tmf = TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm()); + KeyStore ks = KeyStore.getInstance(KeyStore.getDefaultType()); + ks.load(null); + ks.setCertificateEntry("cert", cert); + tmf.init(ks); + SSLContext sslContext = SSLContext.getInstance("TLS"); + KeyManager[] keyManagers = createKeyManagers(client, keycloak); + + sslContext.init(keyManagers, tmf.getTrustManagers(), null); + + ClientBuilder clientBuilder = ClientBuilderWrapper.create(sslContext, false); + + // because we trust only the server cert, disable hostname verification + // - only if the tlsSecret is compromised and traffic to the service hostname can be hijacked, + // would this be a problem + // + // TODO: could warn if a ca cert is set as the server certificate + clientBuilder.hostnameVerifier(NoopHostnameVerifier.INSTANCE); + + restEasyClient = clientBuilder.build(); + } catch (CertificateException | NoSuchAlgorithmException | KeyStoreException | IOException + | KeyManagementException | UnrecoverableKeyException | InvalidKeySpecException e) { + throw new RuntimeException(e); + } + return restEasyClient; + } + + private static KeyManager[] createKeyManagers(KubernetesClient client, Keycloak keycloak) + throws UnrecoverableKeyException, KeyStoreException, NoSuchAlgorithmException, CertificateException, + InvalidKeySpecException, IOException { + if (keycloak.getSpec().getAdminSpec() == null) { + return null; + } + String clientTlsSecretName = keycloak.getSpec().getAdminSpec().getTlsSecret(); + if (clientTlsSecretName == null) { + return null; + } + Secret clientTlsSecret = client.resources(Secret.class) + .inNamespace(keycloak.getMetadata().getNamespace()).withName(clientTlsSecretName).require(); + + byte[] certBytes = Base64.getDecoder().decode(clientTlsSecret.getData().get("tls.crt")); + byte[] keyBytes = Base64.getDecoder().decode(clientTlsSecret.getData().get("tls.key")); + + KeyStore store = null; + // TODO: key type algorithm type could be specifiable in the CR, inferred in a better way (not sure where the quarkus logic is for this), or + // in some cases specified in the files - BEGIN RSA PRIVATE KEY + try { + store = CertUtils.createKeyStore(new ByteArrayInputStream(certBytes), new ByteArrayInputStream(keyBytes), "RSA", null, null, null); + } catch (Exception e) { + store = CertUtils.createKeyStore(new ByteArrayInputStream(certBytes), new ByteArrayInputStream(keyBytes), "EC", null, null, null); + } + + KeyManagerFactory kmf = KeyManagerFactory.getInstance(KeyManagerFactory.getDefaultAlgorithm()); + kmf.init(store, null); + return kmf.getKeyManagers(); + } + + private static String getAdminUrl(Keycloak keycloak, KubernetesClient client, String addressOverride) { + boolean httpEnabled = KeycloakServiceDependentResource.isHttpEnabled(keycloak); + // for now preferring to use http if available + boolean https = isTlsConfigured(keycloak) && !httpEnabled; + String protocol = https?HTTPS:"http"; + String address = addressOverride; + + int port = https?HttpSpec.httpsPort(keycloak):HttpSpec.httpPort(keycloak); + + if (address == null) { + // uses the service host - TODO: assumes the operator and the keycloak instance are in the same cluster + // this may not eventually hold if we are flexible about where the kube client can target + address = String.format("%s.%s.svc:%s", KeycloakServiceDependentResource.getServiceName(keycloak), + keycloak.getMetadata().getNamespace(), port); + } + + var relativePath = KeycloakDeploymentDependentResource.readConfigurationValue(Constants.KEYCLOAK_HTTP_RELATIVE_PATH_KEY, keycloak, client) + .map(path -> !path.isEmpty() && !path.startsWith("/") ? "/" + path : path) + .orElse(""); + + return String.format("%s://%s%s", protocol, address, relativePath); + } + +} diff --git a/operator/src/main/java/org/keycloak/operator/controllers/KeycloakController.java b/operator/src/main/java/org/keycloak/operator/controllers/KeycloakController.java index d861e4b45c3..b4c567bc65b 100644 --- a/operator/src/main/java/org/keycloak/operator/controllers/KeycloakController.java +++ b/operator/src/main/java/org/keycloak/operator/controllers/KeycloakController.java @@ -175,7 +175,7 @@ public class KeycloakController implements Reconciler { var statefulSet = context.getSecondaryResource(StatefulSet.class); if (!status.isReady()) { - updateControl.rescheduleAfter(10, TimeUnit.SECONDS); + updateControl.rescheduleAfter(Constants.RETRY_DURATION); } else if (statefulSet.filter(watchedResources::isWatching).isPresent()) { updateControl.rescheduleAfter(config.keycloak().pollIntervalSeconds(), TimeUnit.SECONDS); } diff --git a/operator/src/main/java/org/keycloak/operator/controllers/KeycloakDeploymentDependentResource.java b/operator/src/main/java/org/keycloak/operator/controllers/KeycloakDeploymentDependentResource.java index d1ac3e826d4..e034d1346d9 100644 --- a/operator/src/main/java/org/keycloak/operator/controllers/KeycloakDeploymentDependentResource.java +++ b/operator/src/main/java/org/keycloak/operator/controllers/KeycloakDeploymentDependentResource.java @@ -66,6 +66,7 @@ import io.fabric8.kubernetes.api.model.VolumeBuilder; import io.fabric8.kubernetes.api.model.VolumeMountBuilder; import io.fabric8.kubernetes.api.model.apps.StatefulSet; import io.fabric8.kubernetes.api.model.apps.StatefulSetBuilder; +import io.fabric8.kubernetes.client.KubernetesClient; import io.javaoperatorsdk.operator.api.config.informer.Informer; import io.javaoperatorsdk.operator.api.reconciler.Context; import io.javaoperatorsdk.operator.api.reconciler.dependent.DependentResource; @@ -606,7 +607,7 @@ public class KeycloakDeploymentDependentResource extends CRUDKubernetesDependent return keycloak.getMetadata().getName(); } - static Optional readConfigurationValue(String key, Keycloak keycloakCR, Context context) { + static Optional readConfigurationValue(String key, Keycloak keycloakCR, KubernetesClient client) { return Optional.ofNullable(keycloakCR.getSpec()).map(KeycloakSpec::getAdditionalOptions) .flatMap(l -> l.stream().filter(sc -> sc.getName().equals(key)).findFirst().map(serverConfigValue -> { if (serverConfigValue.getValue() != null) { @@ -616,7 +617,7 @@ public class KeycloakDeploymentDependentResource extends CRUDKubernetesDependent if (secretSelector == null) { throw new IllegalStateException("Secret " + serverConfigValue.getName() + " not defined"); } - var secret = context.getClient().secrets().inNamespace(keycloakCR.getMetadata().getNamespace()).withName(secretSelector.getName()).get(); + var secret = client.secrets().inNamespace(keycloakCR.getMetadata().getNamespace()).withName(secretSelector.getName()).get(); if (secret == null) { throw new IllegalStateException("Secret " + secretSelector.getName() + " not found in cluster"); } @@ -675,14 +676,14 @@ public class KeycloakDeploymentDependentResource extends CRUDKubernetesDependent int port; String portName; - var legacy = readConfigurationValue(LEGACY_MANAGEMENT_ENABLED, keycloakCR, context).map(Boolean::valueOf).orElse(false); + var legacy = readConfigurationValue(LEGACY_MANAGEMENT_ENABLED, keycloakCR, context.getClient()).map(Boolean::valueOf).orElse(false); - var healthManagementEnabled = readConfigurationValue(CRDUtils.HTTP_MANAGEMENT_HEALTH_ENABLED, keycloakCR, context).map(Boolean::valueOf).orElse(true); + var healthManagementEnabled = readConfigurationValue(CRDUtils.HTTP_MANAGEMENT_HEALTH_ENABLED, keycloakCR, context.getClient()).map(Boolean::valueOf).orElse(true); if (!legacy && (!health || healthManagementEnabled)) { port = HttpManagementSpec.managementPort(keycloakCR); portName = Constants.KEYCLOAK_MANAGEMENT_PORT_NAME; - if (readConfigurationValue(HTTP_MANAGEMENT_SCHEME, keycloakCR, context).filter("http"::equals).isPresent()) { + if (readConfigurationValue(HTTP_MANAGEMENT_SCHEME, keycloakCR, context.getClient()).filter("http"::equals).isPresent()) { protocol = "HTTP"; } } else { @@ -690,8 +691,8 @@ public class KeycloakDeploymentDependentResource extends CRUDKubernetesDependent portName = tls ? Constants.KEYCLOAK_HTTPS_PORT_NAME : Constants.KEYCLOAK_HTTP_PORT_NAME; } - var relativePath = readConfigurationValue(Constants.KEYCLOAK_HTTP_MANAGEMENT_RELATIVE_PATH_KEY, keycloakCR, context) - .or(() -> readConfigurationValue(Constants.KEYCLOAK_HTTP_RELATIVE_PATH_KEY, keycloakCR, context)) + var relativePath = readConfigurationValue(Constants.KEYCLOAK_HTTP_MANAGEMENT_RELATIVE_PATH_KEY, keycloakCR, context.getClient()) + .or(() -> readConfigurationValue(Constants.KEYCLOAK_HTTP_RELATIVE_PATH_KEY, keycloakCR, context.getClient())) .map(path -> !path.endsWith("/") ? path + "/" : path) .orElse("/"); diff --git a/operator/src/main/java/org/keycloak/operator/controllers/KeycloakDistConfigurator.java b/operator/src/main/java/org/keycloak/operator/controllers/KeycloakDistConfigurator.java index b38b4adaa3a..0dcaa9de01d 100644 --- a/operator/src/main/java/org/keycloak/operator/controllers/KeycloakDistConfigurator.java +++ b/operator/src/main/java/org/keycloak/operator/controllers/KeycloakDistConfigurator.java @@ -114,9 +114,9 @@ public class KeycloakDistConfigurator { optionMapper(keycloakCR -> keycloakCR.getSpec().getBootstrapAdminSpec()) .mapOption("bootstrap-admin-client-id", - spec -> Optional.ofNullable(spec.getService()).map(BootstrapAdminSpec.Service::getSecret).map(s -> new SecretKeySelector("client-id", s, null)).orElse(null)) + spec -> Optional.ofNullable(spec.getService()).map(BootstrapAdminSpec.Service::getSecret).map(s -> new SecretKeySelector(Constants.CLIENT_ID_KEY, s, null)).orElse(null)) .mapOption("bootstrap-admin-client-secret", - spec -> Optional.ofNullable(spec.getService()).map(BootstrapAdminSpec.Service::getSecret).map(s -> new SecretKeySelector("client-secret", s, null)).orElse(null)); + spec -> Optional.ofNullable(spec.getService()).map(BootstrapAdminSpec.Service::getSecret).map(s -> new SecretKeySelector(Constants.CLIENT_SECRET_KEY, s, null)).orElse(null)); } void configureHostname() { diff --git a/operator/src/main/java/org/keycloak/operator/controllers/KeycloakOIDCClientController.java b/operator/src/main/java/org/keycloak/operator/controllers/KeycloakOIDCClientController.java new file mode 100644 index 00000000000..43f43cf47d0 --- /dev/null +++ b/operator/src/main/java/org/keycloak/operator/controllers/KeycloakOIDCClientController.java @@ -0,0 +1,80 @@ +/* + * Copyright 2021 Red Hat, Inc. and/or its affiliates + * and other contributors as indicated by the @author tags. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.keycloak.operator.controllers; + +import java.nio.charset.StandardCharsets; +import java.util.Base64; + +import org.keycloak.operator.crds.v2alpha1.client.KeycloakOIDCClient; +import org.keycloak.operator.crds.v2alpha1.client.KeycloakOIDCClientRepresentation; +import org.keycloak.representations.admin.v2.OIDCClientRepresentation; +import org.keycloak.representations.admin.v2.OIDCClientRepresentation.Auth; + +import io.fabric8.kubernetes.api.model.Secret; +import io.fabric8.kubernetes.api.model.SecretKeySelector; +import io.fabric8.kubernetes.client.ResourceNotFoundException; +import io.javaoperatorsdk.operator.api.reconciler.Context; +import io.javaoperatorsdk.operator.api.reconciler.ControllerConfiguration; + +@ControllerConfiguration +public class KeycloakOIDCClientController extends KeycloakClientBaseController { + + @Override + Class getTargetRepresentation() { + return OIDCClientRepresentation.class; + } + + @Override + boolean prepareRepresentation( + KeycloakOIDCClientRepresentation crRepresentation, OIDCClientRepresentation targetRepresentation, + Context context) { + boolean poll = false; + // create the payload via inlining of the secret + Auth auth = crRepresentation.getAuth(); + if (auth != null) { + SecretKeySelector secretSelector = context.getClient().getKubernetesSerialization().convertValue(auth.getAdditionalFields().get("secretRef"), SecretKeySelector.class); + targetRepresentation.getAuth().getAdditionalFields().remove("secretRef"); + if (secretSelector != null) { + poll = true; + + boolean optional = Boolean.TRUE.equals(secretSelector.getOptional()); + + String namespace = context.getPrimaryResource().getMetadata().getNamespace(); + Secret secret = context.getClient().resources(Secret.class) + .inNamespace(namespace).withName(secretSelector.getName()).get(); + + if (secret == null) { + if (!optional) { + throw new ResourceNotFoundException(String.format("Secret %s/%s not found", namespace, secretSelector.getName())); + } + } else { + String value = secret.getData().get(secretSelector.getKey()); + + if (value == null) { + if (!optional) { + throw new ResourceNotFoundException(String.format("Secret key %s in %s/%s not found", secretSelector.getKey(), namespace, secretSelector.getName())); + } + } else { + targetRepresentation.getAuth().setSecret(new String(Base64.getDecoder().decode(value), StandardCharsets.UTF_8)); + } + } + } + } + return poll; + } + +} diff --git a/operator/src/main/java/org/keycloak/operator/controllers/KeycloakRealmImportController.java b/operator/src/main/java/org/keycloak/operator/controllers/KeycloakRealmImportController.java index a21ea20986e..5c6bb033fe0 100644 --- a/operator/src/main/java/org/keycloak/operator/controllers/KeycloakRealmImportController.java +++ b/operator/src/main/java/org/keycloak/operator/controllers/KeycloakRealmImportController.java @@ -17,11 +17,11 @@ package org.keycloak.operator.controllers; import java.util.Optional; -import java.util.concurrent.TimeUnit; import jakarta.inject.Inject; import org.keycloak.operator.Config; +import org.keycloak.operator.Constants; import org.keycloak.operator.ContextUtils; import org.keycloak.operator.crds.v2alpha1.deployment.Keycloak; import org.keycloak.operator.crds.v2alpha1.realmimport.KeycloakRealmImport; @@ -91,7 +91,7 @@ public class KeycloakRealmImportController implements Reconciler { + + @Override + Class getTargetRepresentation() { + return SAMLClientRepresentation.class; + } + + @Override + boolean prepareRepresentation(KeycloakSAMLClientRepresentation crRepresentation, + SAMLClientRepresentation targetRepresentation, Context context) { + // Nothing to do, and no polling + return false; + } + +} diff --git a/operator/src/main/java/org/keycloak/operator/controllers/KeycloakServiceDependentResource.java b/operator/src/main/java/org/keycloak/operator/controllers/KeycloakServiceDependentResource.java index 0971033ba44..250790665b2 100644 --- a/operator/src/main/java/org/keycloak/operator/controllers/KeycloakServiceDependentResource.java +++ b/operator/src/main/java/org/keycloak/operator/controllers/KeycloakServiceDependentResource.java @@ -51,8 +51,7 @@ public class KeycloakServiceDependentResource extends CRUDKubernetesDependentRes var builder = new ServiceSpecBuilder().withSelector(Utils.allInstanceLabels(keycloak)); boolean tlsConfigured = isTlsConfigured(keycloak); - Optional httpSpec = Optional.ofNullable(keycloak.getSpec().getHttpSpec()); - boolean httpEnabled = httpSpec.map(HttpSpec::getHttpEnabled).orElse(false); + boolean httpEnabled = isHttpEnabled(keycloak); if (!tlsConfigured || httpEnabled) { builder.addNewPort() .withPort(HttpSpec.httpPort(keycloak)) @@ -77,6 +76,12 @@ public class KeycloakServiceDependentResource extends CRUDKubernetesDependentRes return builder.build(); } + static boolean isHttpEnabled(Keycloak keycloak) { + Optional httpSpec = Optional.ofNullable(keycloak.getSpec().getHttpSpec()); + boolean httpEnabled = httpSpec.map(HttpSpec::getHttpEnabled).orElse(false); + return httpEnabled; + } + @Override protected Service desired(Keycloak primary, Context context) { diff --git a/operator/src/main/java/org/keycloak/operator/crds/v2alpha1/StatusCondition.java b/operator/src/main/java/org/keycloak/operator/crds/v2alpha1/StatusCondition.java index b366bddb866..1d9b19ae10e 100644 --- a/operator/src/main/java/org/keycloak/operator/crds/v2alpha1/StatusCondition.java +++ b/operator/src/main/java/org/keycloak/operator/crds/v2alpha1/StatusCondition.java @@ -40,6 +40,18 @@ public class StatusCondition { private String lastTransitionTime; private Long observedGeneration; + public StatusCondition() { + } + + public StatusCondition(String type, Boolean status, String message, String lastTransitionTime, + Long observedGeneration) { + this.type = type; + this.message = message; + this.lastTransitionTime = lastTransitionTime; + this.observedGeneration = observedGeneration; + this.setStatus(status); + } + public String getType() { return type; } @@ -103,8 +115,12 @@ public class StatusCondition { @Override public boolean equals(Object o) { - if (this == o) return true; - if (o == null || getClass() != o.getClass()) return false; + if (this == o) { + return true; + } + if (o == null || getClass() != o.getClass()) { + return false; + } StatusCondition that = (StatusCondition) o; return Objects.equals(getType(), that.getType()) && Objects.equals(getStatus(), that.getStatus()) && Objects.equals(getMessage(), that.getMessage()) && Objects.equals(getLastTransitionTime(), that.getLastTransitionTime()) diff --git a/operator/src/main/java/org/keycloak/operator/crds/v2alpha1/client/KeycloakClientSpec.java b/operator/src/main/java/org/keycloak/operator/crds/v2alpha1/client/KeycloakClientSpec.java new file mode 100644 index 00000000000..644f4dd5108 --- /dev/null +++ b/operator/src/main/java/org/keycloak/operator/crds/v2alpha1/client/KeycloakClientSpec.java @@ -0,0 +1,67 @@ +/* + * Copyright 2024 Red Hat, Inc. and/or its affiliates + * and other contributors as indicated by the @author tags. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.keycloak.operator.crds.v2alpha1.client; + +import org.keycloak.representations.admin.v2.BaseClientRepresentation; + +import com.fasterxml.jackson.annotation.JsonPropertyDescription; +import io.fabric8.generator.annotation.Required; +import io.fabric8.generator.annotation.ValidationRule; +import io.sundr.builder.annotations.Buildable; + +@Buildable(editableEnabled = false, builderPackage = "io.fabric8.kubernetes.api.builder", lazyCollectionInitEnabled = false) +public class KeycloakClientSpec { + + @Required + @JsonPropertyDescription("The name of the Keycloak CR to reference, in the same namespace.") + @ValidationRule(value = "self == oldSelf", message = "keycloakCrName is immutable") + private String keycloakCRName; + + @Required + @JsonPropertyDescription("The realm of the Client") + @ValidationRule(value = "self == oldSelf", message = "realm is immutable") + private String realm; + + @Required + private T client; + + public String getRealm() { + return realm; + } + + public void setRealm(String realm) { + this.realm = realm; + } + + public T getClient() { + return client; + } + + public void setClient(T client) { + this.client = client; + } + + public String getKeycloakCRName() { + return keycloakCRName; + } + + public void setKeycloakCRName(String keycloakCRName) { + this.keycloakCRName = keycloakCRName; + } + +} diff --git a/operator/src/main/java/org/keycloak/operator/crds/v2alpha1/client/KeycloakClientStatus.java b/operator/src/main/java/org/keycloak/operator/crds/v2alpha1/client/KeycloakClientStatus.java new file mode 100644 index 00000000000..5c5382a6919 --- /dev/null +++ b/operator/src/main/java/org/keycloak/operator/crds/v2alpha1/client/KeycloakClientStatus.java @@ -0,0 +1,75 @@ +/* + * Copyright 2024 Red Hat, Inc. and/or its affiliates + * and other contributors as indicated by the @author tags. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.keycloak.operator.crds.v2alpha1.client; + +import java.util.ArrayList; +import java.util.List; +import java.util.Objects; + +import io.sundr.builder.annotations.Buildable; + +@Buildable(editableEnabled = false, builderPackage = "io.fabric8.kubernetes.api.builder", lazyCollectionInitEnabled = false) +public class KeycloakClientStatus { + + private Long observedGeneration; + + private String hash; + + private List conditions = new ArrayList(); + + // TODO: will the id or anything else be generated such that it needs to be in the status + + public Long getObservedGeneration() { + return observedGeneration; + } + + public void setObservedGeneration(Long observedGeneration) { + this.observedGeneration = observedGeneration; + } + + public List getConditions() { + return conditions; + } + + public void setConditions(List conditions) { + this.conditions = conditions; + } + + public String getHash() { + return hash; + } + + public void setHash(String hash) { + this.hash = hash; + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + KeycloakClientStatus status = (KeycloakClientStatus) o; + return Objects.equals(getConditions(), status.getConditions()) + && Objects.equals(getHash(), status.getHash()) + && Objects.equals(getObservedGeneration(), status.getObservedGeneration()); + } + + @Override + public int hashCode() { + return Objects.hash(getConditions(), getHash(), getObservedGeneration()); + } +} diff --git a/operator/src/main/java/org/keycloak/operator/crds/v2alpha1/client/KeycloakClientStatusCondition.java b/operator/src/main/java/org/keycloak/operator/crds/v2alpha1/client/KeycloakClientStatusCondition.java new file mode 100644 index 00000000000..970a3f92fb3 --- /dev/null +++ b/operator/src/main/java/org/keycloak/operator/crds/v2alpha1/client/KeycloakClientStatusCondition.java @@ -0,0 +1,35 @@ +/* + * Copyright 2022 Red Hat, Inc. and/or its affiliates + * and other contributors as indicated by the @author tags. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.keycloak.operator.crds.v2alpha1.client; + +import org.keycloak.operator.crds.v2alpha1.StatusCondition; + +// TODO: we may want to simply eliminate this until a specialization is needed +public class KeycloakClientStatusCondition extends StatusCondition { + public static final String HAS_ERRORS = "HasErrors"; + + public KeycloakClientStatusCondition() { + + } + + public KeycloakClientStatusCondition(String type, Boolean status, String message, String lastTransitionTime, + Long observedGeneration) { + super(type, status, message, lastTransitionTime, observedGeneration); + } + +} diff --git a/operator/src/main/java/org/keycloak/operator/crds/v2alpha1/client/KeycloakOIDCClient.java b/operator/src/main/java/org/keycloak/operator/crds/v2alpha1/client/KeycloakOIDCClient.java new file mode 100644 index 00000000000..1e78fcd5c80 --- /dev/null +++ b/operator/src/main/java/org/keycloak/operator/crds/v2alpha1/client/KeycloakOIDCClient.java @@ -0,0 +1,50 @@ +/* + * Copyright 2024 Red Hat, Inc. and/or its affiliates + * and other contributors as indicated by the @author tags. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.keycloak.operator.crds.v2alpha1.client; + +import org.keycloak.operator.Constants; + +import io.fabric8.generator.annotation.Required; +import io.fabric8.kubernetes.api.model.Namespaced; +import io.fabric8.kubernetes.client.CustomResource; +import io.fabric8.kubernetes.model.annotation.Group; +import io.fabric8.kubernetes.model.annotation.Version; +import io.quarkiverse.operatorsdk.annotations.CSVMetadata; +import io.sundr.builder.annotations.Buildable; +import io.sundr.builder.annotations.BuildableReference; + +@CSVMetadata( + description="Represents a Keycloak OIDC Client", + displayName="KeycloakOIDCClient" + ) + @Group(Constants.CRDS_GROUP) + @Version(Constants.CRDS_VERSION) + @Buildable(editableEnabled = false, builderPackage = "io.fabric8.kubernetes.api.builder", + lazyCollectionInitEnabled = false, refs = { + @BuildableReference(io.fabric8.kubernetes.api.model.ObjectMeta.class), + @BuildableReference(io.fabric8.kubernetes.client.CustomResource.class), + }) +public class KeycloakOIDCClient extends CustomResource implements Namespaced { + + @Required + @Override + public KeycloakOIDCClientSpec getSpec() { + return super.getSpec(); + } + +} diff --git a/operator/src/main/java/org/keycloak/operator/crds/v2alpha1/client/KeycloakOIDCClientRepresentation.java b/operator/src/main/java/org/keycloak/operator/crds/v2alpha1/client/KeycloakOIDCClientRepresentation.java new file mode 100644 index 00000000000..a7f51641af8 --- /dev/null +++ b/operator/src/main/java/org/keycloak/operator/crds/v2alpha1/client/KeycloakOIDCClientRepresentation.java @@ -0,0 +1,51 @@ +package org.keycloak.operator.crds.v2alpha1.client; + +import org.keycloak.representations.admin.v2.OIDCClientRepresentation; + +import com.fasterxml.jackson.annotation.JsonIgnore; +import com.fasterxml.jackson.annotation.JsonPropertyDescription; +import com.fasterxml.jackson.annotation.JsonTypeInfo; +import com.fasterxml.jackson.annotation.JsonTypeInfo.Id; +import io.fabric8.crd.generator.annotation.SchemaSwap; +import io.fabric8.kubernetes.api.model.SecretKeySelector; +import io.sundr.builder.annotations.Buildable; + +@JsonTypeInfo(use = Id.NONE) +@Buildable(editableEnabled = false, builderPackage = "io.fabric8.kubernetes.api.builder", lazyCollectionInitEnabled = false) +@SchemaSwap(fieldName = "auth", originalType = KeycloakOIDCClientRepresentation.class, targetType = KeycloakOIDCClientRepresentation.AuthWithSecretRef.class) +public class KeycloakOIDCClientRepresentation extends OIDCClientRepresentation { + + public static class AuthWithSecretRef extends OIDCClientRepresentation.Auth { + + private SecretKeySelector secretRef; + + @JsonPropertyDescription("Secret containing the client secret") + public SecretKeySelector getSecretRef() { + return secretRef; + } + + public void setSecretRef(SecretKeySelector secretRef) { + this.secretRef = secretRef; + } + + @JsonIgnore + @Override + public String getSecret() { + return super.getSecret(); + } + + } + + @JsonIgnore + @Override + public String getProtocol() { + return super.getProtocol(); + } + + @JsonIgnore + @Override + public String getClientId() { + return super.getClientId(); + } + +} diff --git a/operator/src/main/java/org/keycloak/operator/crds/v2alpha1/client/KeycloakOIDCClientSpec.java b/operator/src/main/java/org/keycloak/operator/crds/v2alpha1/client/KeycloakOIDCClientSpec.java new file mode 100644 index 00000000000..8f464661198 --- /dev/null +++ b/operator/src/main/java/org/keycloak/operator/crds/v2alpha1/client/KeycloakOIDCClientSpec.java @@ -0,0 +1,25 @@ +/* + * Copyright 2024 Red Hat, Inc. and/or its affiliates + * and other contributors as indicated by the @author tags. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.keycloak.operator.crds.v2alpha1.client; + +import io.sundr.builder.annotations.Buildable; + +@Buildable(editableEnabled = false, builderPackage = "io.fabric8.kubernetes.api.builder", lazyCollectionInitEnabled = false) +public class KeycloakOIDCClientSpec extends KeycloakClientSpec { + +} diff --git a/operator/src/main/java/org/keycloak/operator/crds/v2alpha1/client/KeycloakSAMLClient.java b/operator/src/main/java/org/keycloak/operator/crds/v2alpha1/client/KeycloakSAMLClient.java new file mode 100644 index 00000000000..4c15746d964 --- /dev/null +++ b/operator/src/main/java/org/keycloak/operator/crds/v2alpha1/client/KeycloakSAMLClient.java @@ -0,0 +1,50 @@ +/* + * Copyright 2024 Red Hat, Inc. and/or its affiliates + * and other contributors as indicated by the @author tags. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.keycloak.operator.crds.v2alpha1.client; + +import org.keycloak.operator.Constants; + +import io.fabric8.generator.annotation.Required; +import io.fabric8.kubernetes.api.model.Namespaced; +import io.fabric8.kubernetes.client.CustomResource; +import io.fabric8.kubernetes.model.annotation.Group; +import io.fabric8.kubernetes.model.annotation.Version; +import io.quarkiverse.operatorsdk.annotations.CSVMetadata; +import io.sundr.builder.annotations.Buildable; +import io.sundr.builder.annotations.BuildableReference; + +@CSVMetadata( + description="Represents a Keycloak SAML Client", + displayName="KeycloakSAMLClient" + ) + @Group(Constants.CRDS_GROUP) + @Version(Constants.CRDS_VERSION) + @Buildable(editableEnabled = false, builderPackage = "io.fabric8.kubernetes.api.builder", + lazyCollectionInitEnabled = false, refs = { + @BuildableReference(io.fabric8.kubernetes.api.model.ObjectMeta.class), + @BuildableReference(io.fabric8.kubernetes.client.CustomResource.class), + }) +public class KeycloakSAMLClient extends CustomResource implements Namespaced { + + @Required + @Override + public KeycloakSAMLClientSpec getSpec() { + return super.getSpec(); + } + +} diff --git a/operator/src/main/java/org/keycloak/operator/crds/v2alpha1/client/KeycloakSAMLClientRepresentation.java b/operator/src/main/java/org/keycloak/operator/crds/v2alpha1/client/KeycloakSAMLClientRepresentation.java new file mode 100644 index 00000000000..55f1558f0e3 --- /dev/null +++ b/operator/src/main/java/org/keycloak/operator/crds/v2alpha1/client/KeycloakSAMLClientRepresentation.java @@ -0,0 +1,26 @@ +package org.keycloak.operator.crds.v2alpha1.client; + +import org.keycloak.representations.admin.v2.SAMLClientRepresentation; + +import com.fasterxml.jackson.annotation.JsonIgnore; +import com.fasterxml.jackson.annotation.JsonTypeInfo; +import com.fasterxml.jackson.annotation.JsonTypeInfo.Id; +import io.sundr.builder.annotations.Buildable; + +@JsonTypeInfo(use = Id.NONE) +@Buildable(editableEnabled = false, builderPackage = "io.fabric8.kubernetes.api.builder", lazyCollectionInitEnabled = false) +public class KeycloakSAMLClientRepresentation extends SAMLClientRepresentation { + + @JsonIgnore + @Override + public String getProtocol() { + return super.getProtocol(); + } + + @JsonIgnore + @Override + public String getClientId() { + return super.getClientId(); + } + +} diff --git a/operator/src/main/java/org/keycloak/operator/crds/v2alpha1/client/KeycloakSAMLClientSpec.java b/operator/src/main/java/org/keycloak/operator/crds/v2alpha1/client/KeycloakSAMLClientSpec.java new file mode 100644 index 00000000000..3beb1e428ba --- /dev/null +++ b/operator/src/main/java/org/keycloak/operator/crds/v2alpha1/client/KeycloakSAMLClientSpec.java @@ -0,0 +1,25 @@ +/* + * Copyright 2024 Red Hat, Inc. and/or its affiliates + * and other contributors as indicated by the @author tags. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.keycloak.operator.crds.v2alpha1.client; + +import io.sundr.builder.annotations.Buildable; + +@Buildable(editableEnabled = false, builderPackage = "io.fabric8.kubernetes.api.builder", lazyCollectionInitEnabled = false) +public class KeycloakSAMLClientSpec extends KeycloakClientSpec { + +} diff --git a/operator/src/main/java/org/keycloak/operator/crds/v2alpha1/deployment/KeycloakSpec.java b/operator/src/main/java/org/keycloak/operator/crds/v2alpha1/deployment/KeycloakSpec.java index 96522b0c8ec..20e96afac05 100644 --- a/operator/src/main/java/org/keycloak/operator/crds/v2alpha1/deployment/KeycloakSpec.java +++ b/operator/src/main/java/org/keycloak/operator/crds/v2alpha1/deployment/KeycloakSpec.java @@ -21,6 +21,7 @@ import java.util.LinkedHashMap; import java.util.List; import java.util.Map; +import org.keycloak.operator.crds.v2alpha1.deployment.spec.AdminSpec; import org.keycloak.operator.crds.v2alpha1.deployment.spec.BootstrapAdminSpec; import org.keycloak.operator.crds.v2alpha1.deployment.spec.CacheSpec; import org.keycloak.operator.crds.v2alpha1.deployment.spec.DatabaseSpec; @@ -170,6 +171,10 @@ public class KeycloakSpec { @JsonPropertyDescription("Set this to to false to disable automounting the default ServiceAccount Token and Service CA. This is enabled by default.") private Boolean automountServiceAccountToken; + @JsonProperty("admin") + @JsonPropertyDescription("In this section you can find all properties related to making admin connections from the operator to the server. These settings are not used by the server.") + private AdminSpec adminSpec; + public HttpSpec getHttpSpec() { return httpSpec; } @@ -410,4 +415,12 @@ public class KeycloakSpec { public void setAutomountServiceAccountToken(Boolean automountServiceAccountToken) { this.automountServiceAccountToken = automountServiceAccountToken; } + + public AdminSpec getAdminSpec() { + return adminSpec; + } + + public void setAdminSpec(AdminSpec adminSpec) { + this.adminSpec = adminSpec; + } } diff --git a/operator/src/main/java/org/keycloak/operator/crds/v2alpha1/deployment/KeycloakStatusAggregator.java b/operator/src/main/java/org/keycloak/operator/crds/v2alpha1/deployment/KeycloakStatusAggregator.java index 56c1efa05fd..09a4519cef1 100644 --- a/operator/src/main/java/org/keycloak/operator/crds/v2alpha1/deployment/KeycloakStatusAggregator.java +++ b/operator/src/main/java/org/keycloak/operator/crds/v2alpha1/deployment/KeycloakStatusAggregator.java @@ -27,6 +27,7 @@ import java.util.function.Function; import java.util.stream.Collectors; import org.keycloak.operator.Utils; +import org.keycloak.operator.crds.v2alpha1.StatusCondition; /** * @author Vaclav Muzikar @@ -57,9 +58,9 @@ public class KeycloakStatusAggregator { * @param generation the observedGeneration for conditions */ public KeycloakStatusAggregator(KeycloakStatus current, Long generation) { - if (current != null) { // 6.7 fabric8 no longer requires this null check + if (current != null) { statusBuilder = new KeycloakStatusBuilder(current); - existingConditions = Optional.ofNullable(current.getConditions()).orElse(List.of()).stream().collect(Collectors.toMap(KeycloakStatusCondition::getType, Function.identity())); + existingConditions = getConditionMap(current.getConditions()); } else { statusBuilder = new KeycloakStatusBuilder(); existingConditions = Map.of(); @@ -76,6 +77,10 @@ public class KeycloakStatusAggregator { updateType.setType(KeycloakStatusCondition.UPDATE_TYPE); } + public static Map getConditionMap(List conditions) { + return Optional.ofNullable(conditions).orElse(List.of()).stream().collect(Collectors.toMap(StatusCondition::getType, Function.identity())); + } + public KeycloakStatusAggregator addNotReadyMessage(String message) { readyCondition.setStatus(false); readyCondition.setObservedGeneration(observedGeneration); @@ -165,7 +170,7 @@ public class KeycloakStatusAggregator { .build(); } - static void updateConditionFromExisting(KeycloakStatusCondition condition, Map existingConditions, String now) { + public static void updateConditionFromExisting(StatusCondition condition, Map existingConditions, String now) { var existing = existingConditions.get(condition.getType()); if (existing == null) { if (condition.getObservedGeneration() != null) { diff --git a/operator/src/main/java/org/keycloak/operator/crds/v2alpha1/deployment/spec/AdminSpec.java b/operator/src/main/java/org/keycloak/operator/crds/v2alpha1/deployment/spec/AdminSpec.java new file mode 100644 index 00000000000..2072d83bb04 --- /dev/null +++ b/operator/src/main/java/org/keycloak/operator/crds/v2alpha1/deployment/spec/AdminSpec.java @@ -0,0 +1,22 @@ +package org.keycloak.operator.crds.v2alpha1.deployment.spec; + +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonPropertyDescription; +import io.sundr.builder.annotations.Buildable; + +@JsonInclude(JsonInclude.Include.NON_NULL) +@Buildable(editableEnabled = false, builderPackage = "io.fabric8.kubernetes.api.builder") +public class AdminSpec { + + @JsonPropertyDescription("If mTLS is required, this references a secret containing the client TLS configuration for the admin client. Reference: https://kubernetes.io/docs/concepts/configuration/secret/#tls-secrets.") + private String tlsSecret; + + public String getTlsSecret() { + return tlsSecret; + } + + public void setTlsSecret(String tlsSecret) { + this.tlsSecret = tlsSecret; + } + +} diff --git a/operator/src/test/java/org/keycloak/operator/controllers/KeycloakClientBaseControllerTest.java b/operator/src/test/java/org/keycloak/operator/controllers/KeycloakClientBaseControllerTest.java new file mode 100644 index 00000000000..ab5ac1010db --- /dev/null +++ b/operator/src/test/java/org/keycloak/operator/controllers/KeycloakClientBaseControllerTest.java @@ -0,0 +1,29 @@ +package org.keycloak.operator.controllers; + +import org.keycloak.operator.crds.v2alpha1.client.KeycloakClientStatusCondition; +import org.keycloak.operator.crds.v2alpha1.client.KeycloakOIDCClientBuilder; + +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +public class KeycloakClientBaseControllerTest { + + @Test + public void testErrorStatus() { + var client = new KeycloakOIDCClientBuilder().withNewMetadata().endMetadata().build(); + var agg = new KeycloakClientBaseController.KeycloakClientStatusAggregator(client); + agg.setCondition(KeycloakClientStatusCondition.HAS_ERRORS, true, "some error"); + var status = agg.build(); + var condition = status.getConditions().get(0); + assertEquals(KeycloakClientStatusCondition.HAS_ERRORS, condition.getType()); + assertEquals(true, condition.getStatus()); + assertEquals("some error", condition.getMessage()); + + client.getMetadata().setGeneration(1L); + client.setStatus(status); + agg = new KeycloakClientBaseController.KeycloakClientStatusAggregator(client); + agg.setCondition(KeycloakClientStatusCondition.HAS_ERRORS, false, ""); + } + +} diff --git a/operator/src/test/java/org/keycloak/operator/testsuite/apiserver/CRDTest.java b/operator/src/test/java/org/keycloak/operator/testsuite/apiserver/CRDTest.java index 9238078fc45..6d1558348ed 100644 --- a/operator/src/test/java/org/keycloak/operator/testsuite/apiserver/CRDTest.java +++ b/operator/src/test/java/org/keycloak/operator/testsuite/apiserver/CRDTest.java @@ -19,6 +19,8 @@ package org.keycloak.operator.testsuite.apiserver; import java.io.FileNotFoundException; +import org.keycloak.operator.crds.v2alpha1.client.KeycloakOIDCClient; +import org.keycloak.operator.crds.v2alpha1.client.KeycloakOIDCClientBuilder; import org.keycloak.operator.crds.v2alpha1.deployment.Keycloak; import org.keycloak.operator.crds.v2alpha1.deployment.KeycloakBuilder; import org.keycloak.operator.crds.v2alpha1.deployment.KeycloakStatusAggregator; @@ -52,6 +54,25 @@ public class CRDTest { BaseOperatorTest.createCRDs(client); } + @Test + public void testOIDCCLientWithoutRequiredFields() { + KeycloakOIDCClient cr = new KeycloakOIDCClientBuilder() + .withNewMetadata() + .withName("invalid-client") + .endMetadata() + .withNewSpec() + .endSpec() + .build(); + + var eMsg = assertThrows(KubernetesClientException.class, () -> client.resource(cr).create()).getMessage(); + assertThat(eMsg).contains("spec.keycloakCRName: Required value", "spec.client: Required value", "spec.realm: Required value"); + } + + @Test + public void testOIDCCLient() { + roundTrip("/test-serialization-keycloak-oidc-client-cr.yml", KeycloakOIDCClient.class); + } + @Test public void testRealmImport() { roundTrip("/test-serialization-realmimport-cr.yml", KeycloakRealmImport.class); diff --git a/operator/src/test/java/org/keycloak/operator/testsuite/integration/BaseOperatorTest.java b/operator/src/test/java/org/keycloak/operator/testsuite/integration/BaseOperatorTest.java index 920517e4850..af90c27d795 100644 --- a/operator/src/test/java/org/keycloak/operator/testsuite/integration/BaseOperatorTest.java +++ b/operator/src/test/java/org/keycloak/operator/testsuite/integration/BaseOperatorTest.java @@ -45,8 +45,12 @@ import jakarta.enterprise.util.TypeLiteral; import org.keycloak.operator.Constants; import org.keycloak.operator.controllers.KeycloakController; import org.keycloak.operator.controllers.KeycloakDeploymentDependentResource; +import org.keycloak.operator.controllers.KeycloakOIDCClientController; import org.keycloak.operator.controllers.KeycloakRealmImportController; +import org.keycloak.operator.controllers.KeycloakSAMLClientController; import org.keycloak.operator.controllers.KeycloakUpdateJobDependentResource; +import org.keycloak.operator.crds.v2alpha1.client.KeycloakOIDCClient; +import org.keycloak.operator.crds.v2alpha1.client.KeycloakSAMLClient; import org.keycloak.operator.crds.v2alpha1.deployment.Keycloak; import org.keycloak.operator.crds.v2alpha1.deployment.KeycloakBuilder; import org.keycloak.operator.crds.v2alpha1.deployment.KeycloakSpecBuilder; @@ -249,10 +253,14 @@ public enum OperatorDeployment {local_apiserver,local,remote} public static void createCRDs(KubernetesClient client) throws FileNotFoundException { K8sUtils.set(client, new FileInputStream(TARGET_KUBERNETES_GENERATED_YML_FOLDER + "keycloaks.k8s.keycloak.org-v1.yml")); K8sUtils.set(client, new FileInputStream(TARGET_KUBERNETES_GENERATED_YML_FOLDER + "keycloakrealmimports.k8s.keycloak.org-v1.yml")); + K8sUtils.set(client, new FileInputStream(TARGET_KUBERNETES_GENERATED_YML_FOLDER + "keycloakoidcclients.k8s.keycloak.org-v1.yml")); + K8sUtils.set(client, new FileInputStream(TARGET_KUBERNETES_GENERATED_YML_FOLDER + "keycloaksamlclients.k8s.keycloak.org-v1.yml")); K8sUtils.set(client, BaseOperatorTest.class.getResourceAsStream("/service-monitor-crds.yml")); Awaitility.await().pollInterval(100, TimeUnit.MILLISECONDS).untilAsserted(() -> client.resources(Keycloak.class).list()); Awaitility.await().pollInterval(100, TimeUnit.MILLISECONDS).untilAsserted(() -> client.resources(KeycloakRealmImport.class).list()); + Awaitility.await().pollInterval(100, TimeUnit.MILLISECONDS).untilAsserted(() -> client.resources(KeycloakOIDCClient.class).list()); + Awaitility.await().pollInterval(100, TimeUnit.MILLISECONDS).untilAsserted(() -> client.resources(KeycloakSAMLClient.class).list()); Awaitility.await().pollInterval(100, TimeUnit.MILLISECONDS).untilAsserted(() -> client.resources(ServiceMonitor.class).list()); } @@ -358,7 +366,7 @@ public enum OperatorDeployment {local_apiserver,local,remote} // this can be simplified to just the root deletion after we pick up the fix // it can be further simplified after https://github.com/fabric8io/kubernetes-client/issues/5838 // to just a timed foreground deletion - var roots = List.of(Keycloak.class, KeycloakRealmImport.class); + var roots = List.of(Keycloak.class, KeycloakRealmImport.class, KeycloakOIDCClient.class, KeycloakSAMLClient.class); roots.forEach(c -> k8sclient.resources(c).delete()); // enforce that at least the statefulset are gone try { @@ -562,8 +570,9 @@ public enum OperatorDeployment {local_apiserver,local,remote} // Avoid issues with Event Informers between tests Log.info("Removing Controllers and application scoped DRs from CDI"); - Stream.of(KeycloakController.class, KeycloakRealmImportController.class, KeycloakUpdateJobDependentResource.class) - .forEach(c -> CDI.current().destroy(CDI.current().select(c).get())); + Stream.of(KeycloakController.class, KeycloakRealmImportController.class, KeycloakOIDCClientController.class, + KeycloakSAMLClientController.class, KeycloakUpdateJobDependentResource.class) + .forEach(c -> CDI.current().destroy(CDI.current().select(c).get())); } public static String getCurrentNamespace() { diff --git a/operator/src/test/java/org/keycloak/operator/testsuite/integration/KeycloakClientTest.java b/operator/src/test/java/org/keycloak/operator/testsuite/integration/KeycloakClientTest.java new file mode 100644 index 00000000000..a58b3804004 --- /dev/null +++ b/operator/src/test/java/org/keycloak/operator/testsuite/integration/KeycloakClientTest.java @@ -0,0 +1,177 @@ +/* + * Copyright 2022 Red Hat, Inc. and/or its affiliates + * and other contributors as indicated by the @author tags. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.keycloak.operator.testsuite.integration; + +import java.util.Map; +import java.util.Optional; +import java.util.concurrent.TimeUnit; + +import jakarta.enterprise.inject.spi.CDI; +import jakarta.inject.Inject; + +import org.keycloak.operator.Config; +import org.keycloak.operator.Constants; +import org.keycloak.operator.Utils; +import org.keycloak.operator.controllers.KeycloakClientBaseController; +import org.keycloak.operator.controllers.KeycloakOIDCClientController; +import org.keycloak.operator.crds.v2alpha1.client.KeycloakClientStatusCondition; +import org.keycloak.operator.crds.v2alpha1.client.KeycloakOIDCClient; +import org.keycloak.operator.crds.v2alpha1.client.KeycloakOIDCClientBuilder; +import org.keycloak.operator.crds.v2alpha1.client.KeycloakOIDCClientRepresentation.AuthWithSecretRef; +import org.keycloak.operator.crds.v2alpha1.deployment.Keycloak; +import org.keycloak.operator.crds.v2alpha1.deployment.ValueOrSecret; +import org.keycloak.operator.crds.v2alpha1.deployment.spec.AdminSpec; +import org.keycloak.operator.crds.v2alpha1.deployment.spec.BootstrapAdminSpec; +import org.keycloak.operator.crds.v2alpha1.deployment.spec.FeatureSpecBuilder; +import org.keycloak.operator.crds.v2alpha1.deployment.spec.TruststoreBuilder; +import org.keycloak.operator.testsuite.apiserver.DisabledIfApiServerTest; +import org.keycloak.operator.testsuite.utils.K8sUtils; + +import io.fabric8.kubernetes.api.model.Secret; +import io.fabric8.kubernetes.api.model.SecretBuilder; +import io.fabric8.kubernetes.api.model.SecretKeySelector; +import io.fabric8.kubernetes.api.model.ServiceBuilder; +import io.quarkus.test.junit.QuarkusTest; +import org.awaitility.Awaitility; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; + +import static org.keycloak.operator.testsuite.utils.K8sUtils.deployKeycloak; + +import static org.junit.jupiter.api.Assertions.assertNull; + +@DisabledIfApiServerTest +@Tag(BaseOperatorTest.SLOW) +@QuarkusTest +public class KeycloakClientTest extends BaseOperatorTest { + + private static final String CLIENT_SECRET = "client-secret"; + private static final String CLIENT_TRUSTSTORE_SECRET = "client-truststore-secret"; + + private static final String NODEPORT_SERVICE = "nodeport-service"; + + @Inject + Config config; + + static String initCustomBootstrapAdminServiceAccount(Keycloak kc) { + String secretName = kc.getMetadata().getName() + "-admin"; + // fluents don't seem to work here because of the inner classes + kc.getSpec().setBootstrapAdminSpec(new BootstrapAdminSpec()); + kc.getSpec().getBootstrapAdminSpec().setService(new BootstrapAdminSpec.Service()); + kc.getSpec().getBootstrapAdminSpec().getService().setSecret(secretName); + k8sclient.resource(new SecretBuilder().withNewMetadata().withName(secretName).endMetadata() + .addToStringData(Constants.CLIENT_ID_KEY, "admin-service") + .addToStringData(Constants.CLIENT_SECRET_KEY, "secret").build()).serverSideApply(); + return secretName; + } + + @AfterEach + public void afterEach() { + k8sclient.services().withName(NODEPORT_SERVICE).delete(); + k8sclient.secrets().withName(CLIENT_SECRET).delete(); + } + + @Test + public void testBasicClientCreationAndDeletionHttp() throws InterruptedException { + helpTestBasicClientCreationAndDeletion(false); + } + + @Test + public void testBasicClientCreationAndDeletionHttps() throws InterruptedException { + helpTestBasicClientCreationAndDeletion(true); + } + + public void helpTestBasicClientCreationAndDeletion(boolean https) throws InterruptedException { + var kc = getTestKeycloakDeployment(false); + kc.getSpec().setStartOptimized(false); + kc.getSpec().getHostnameSpec().setHostname("example.com"); + // TODO will need validation that this is enabled + kc.getSpec().setFeatureSpec(new FeatureSpecBuilder().withEnabledFeatures("client-admin-api:v2").build()); + if (!https) { + kc.getSpec().getHttpSpec().setTlsSecret(null); + kc.getSpec().getHttpSpec().setHttpEnabled(true); + } else { + AdminSpec adminSpec = new AdminSpec(); + K8sUtils.set(k8sclient, K8sUtils.getResourceFromFile("/example-mtls-secret.yaml", Secret.class)); + adminSpec.setTlsSecret("example-mtls-secret"); + kc.getSpec().setAdminSpec(adminSpec); + K8sUtils.set(k8sclient, getClass().getResourceAsStream("/example-mtls-truststore-secret.yaml")); + kc.getSpec().getTruststores().put("example", new TruststoreBuilder().withNewSecret().withName("example-mtls-truststore-secret").endSecret().build()); + kc.getSpec().getAdditionalOptions().add(new ValueOrSecret("https-client-auth", "required")); + kc.getSpec().getAdditionalOptions().add(new ValueOrSecret("https-management-client-auth", "none")); + } + + // TODO: for the sake of testing, this uses the built-in bootstrap admin + // we don't expect users to do this + initCustomBootstrapAdminServiceAccount(kc); + var deploymentName = kc.getMetadata().getName(); + deployKeycloak(k8sclient, kc, true); + + Map labels = Utils.allInstanceLabels(kc); + labels.put("app.kubernetes.io/component", "server"); + + var nodeport = new ServiceBuilder().withNewMetadata().withName(NODEPORT_SERVICE).endMetadata().withNewSpec() + .withType("NodePort").addToSelector(labels).addNewPort().withPort(https?Constants.KEYCLOAK_HTTPS_PORT:Constants.KEYCLOAK_HTTP_PORT) + .endPort().endSpec().build(); + nodeport = k8sclient.resource(nodeport).serverSideApply(); + int port = nodeport.getSpec().getPorts().get(0).getNodePort(); + + String addressOverride = kubernetesIp + ":" + port; + if (operatorDeployment == OperatorDeployment.local) { + CDI.current().select(KeycloakOIDCClientController.class).get().setAddressOverride(addressOverride); + } + + AuthWithSecretRef auth = new AuthWithSecretRef(); + auth.setMethod("client-jwt"); + auth.setSecretRef(new SecretKeySelector("secret", CLIENT_SECRET, null)); + KeycloakOIDCClient client = new KeycloakOIDCClientBuilder().withNewMetadata().withName("test-client") + .endMetadata().withNewSpec().withRealm("master").withKeycloakCRName(deploymentName).withNewClient() + .withAuth(auth) + .withEnabled(true).endClient().endSpec().build(); + + K8sUtils.set(k8sclient, client); + + Awaitility.await() + .until(() -> Optional.ofNullable(k8sclient.resource(client).get().getStatus()).filter(s -> s.getConditions().stream() + .anyMatch(c -> Boolean.TRUE.equals(c.getStatus()) + && KeycloakClientStatusCondition.HAS_ERRORS.equals(c.getType()) + && c.getMessage().contains(CLIENT_SECRET))).isPresent()); + + K8sUtils.set(k8sclient, new SecretBuilder().withNewMetadata().withName(CLIENT_SECRET).endMetadata() + .addToStringData("secret", "1234567890").build()); + + Awaitility.await() + .until(() -> k8sclient.resource(client).get().getStatus().getConditions().stream() + .noneMatch(c -> Boolean.TRUE.equals(c.getStatus()) + && KeycloakClientStatusCondition.HAS_ERRORS.equals(c.getType()))); + + // TODO: a success or ready status? + + try (var adminClient = KeycloakClientBaseController.getAdminClient(k8sclient, kc, addressOverride)) { + Awaitility.await().until(() -> adminClient.realm("master").clients().findAll().stream().anyMatch(cr -> cr.getClientId().equals("test-client"))); + + k8sclient.resource(client).withTimeout(10, TimeUnit.SECONDS).delete(); + + Awaitility.await().until(() -> adminClient.realm("master").clients().findAll().stream().noneMatch(cr -> cr.getClientId().equals("test-client"))); + } + + assertNull(k8sclient.resource(client).get()); + } + +} diff --git a/operator/src/test/resources/example-mtls-secret.yaml b/operator/src/test/resources/example-mtls-secret.yaml new file mode 100644 index 00000000000..a2677dd6b3e --- /dev/null +++ b/operator/src/test/resources/example-mtls-secret.yaml @@ -0,0 +1,94 @@ +apiVersion: v1 +kind: Secret +metadata: + name: example-mtls-secret +stringData: + tls.crt: | + -----BEGIN CERTIFICATE----- + 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 + -----END CERTIFICATE----- + + tls.key: | + -----BEGIN PRIVATE KEY----- + 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 + n1iJiw+VDDvHFCu4nUQGcpOopqGNeg== + -----END PRIVATE KEY----- + +type: kubernetes.io/tls diff --git a/operator/src/test/resources/example-mtls-truststore-secret.yaml b/operator/src/test/resources/example-mtls-truststore-secret.yaml new file mode 100644 index 00000000000..b515842f905 --- /dev/null +++ b/operator/src/test/resources/example-mtls-truststore-secret.yaml @@ -0,0 +1,41 @@ +apiVersion: v1 +kind: Secret +metadata: + name: example-mtls-truststore-secret +stringData: + cert.pem: | + -----BEGIN CERTIFICATE----- + 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 + djkdxZ0= + -----END CERTIFICATE----- + +type: Opaque \ No newline at end of file diff --git a/operator/src/test/resources/test-serialization-keycloak-oidc-client-cr.yml b/operator/src/test/resources/test-serialization-keycloak-oidc-client-cr.yml new file mode 100644 index 00000000000..3fe3b5e0e8b --- /dev/null +++ b/operator/src/test/resources/test-serialization-keycloak-oidc-client-cr.yml @@ -0,0 +1,13 @@ +apiVersion: k8s.keycloak.org/v2alpha1 +kind: KeycloakOIDCClient +metadata: + name: test-serialization-oidc-client +spec: + keycloakCRName: kc + realm: my-realm + client: + description: 'a client' + auth: + secretRef: + name: my-secret + key: secret-key \ No newline at end of file diff --git a/quarkus/runtime/src/main/java/org/keycloak/quarkus/runtime/oas/OASModelFilter.java b/quarkus/runtime/src/main/java/org/keycloak/quarkus/runtime/oas/OASModelFilter.java index 7b0ea62daab..5b88ac49f66 100644 --- a/quarkus/runtime/src/main/java/org/keycloak/quarkus/runtime/oas/OASModelFilter.java +++ b/quarkus/runtime/src/main/java/org/keycloak/quarkus/runtime/oas/OASModelFilter.java @@ -192,13 +192,13 @@ public class OASModelFilter implements OASFilter { // Validations AnnotationValue useValue = typeInfoAnnotation.value("use"); - if (useValue == null || !JsonTypeInfo.Id.SIMPLE_NAME.name().equals(useValue.asEnum())) { - throw new IllegalArgumentException(parentClassInfo.simpleName() + ": JsonTypeInfo annotation must have use=SIMPLE_NAME."); + if (useValue == null || !JsonTypeInfo.Id.NAME.name().equals(useValue.asEnum())) { + throw new IllegalArgumentException(parentClassInfo.simpleName() + ": JsonTypeInfo annotation must have use=NAME."); } AnnotationValue includeValue = typeInfoAnnotation.value("include"); - if (includeValue != null && !JsonTypeInfo.As.PROPERTY.name().equals(includeValue.asEnum())) { - throw new IllegalArgumentException(parentClassInfo.simpleName() + ": JsonTypeInfo annotation must have include=PROPERTY, or include must not be set."); + if (includeValue != null && !JsonTypeInfo.As.EXISTING_PROPERTY.name().equals(includeValue.asEnum())) { + throw new IllegalArgumentException(parentClassInfo.simpleName() + ": JsonTypeInfo annotation must have include=EXISTING_PROPERTY, or include must not be set."); } String discriminatorPropertyName = Optional.of(typeInfoAnnotation.value("property")).map(AnnotationValue::asString).orElse(""); diff --git a/rest/admin-v2/api/src/main/java/org/keycloak/representations/admin/v2/BaseClientRepresentation.java b/rest/admin-v2/api/src/main/java/org/keycloak/representations/admin/v2/BaseClientRepresentation.java index b87d4967413..93131fb754e 100644 --- a/rest/admin-v2/api/src/main/java/org/keycloak/representations/admin/v2/BaseClientRepresentation.java +++ b/rest/admin-v2/api/src/main/java/org/keycloak/representations/admin/v2/BaseClientRepresentation.java @@ -1,7 +1,6 @@ package org.keycloak.representations.admin.v2; import java.util.LinkedHashSet; -import java.util.Map; import java.util.Objects; import java.util.Set; @@ -9,8 +8,6 @@ import jakarta.validation.constraints.NotBlank; import org.keycloak.representations.admin.v2.validation.CreateClient; -import com.fasterxml.jackson.annotation.JsonAnyGetter; -import com.fasterxml.jackson.annotation.JsonIgnore; import com.fasterxml.jackson.annotation.JsonInclude; import com.fasterxml.jackson.annotation.JsonPropertyDescription; import com.fasterxml.jackson.annotation.JsonSubTypes; @@ -18,13 +15,13 @@ import com.fasterxml.jackson.annotation.JsonTypeInfo; import org.hibernate.validator.constraints.URL; @JsonTypeInfo( - use = JsonTypeInfo.Id.SIMPLE_NAME, - include = JsonTypeInfo.As.PROPERTY, + use = JsonTypeInfo.Id.NAME, + include = JsonTypeInfo.As.EXISTING_PROPERTY, property = BaseClientRepresentation.DISCRIMINATOR_FIELD ) @JsonSubTypes({ - @JsonSubTypes.Type(value = OIDCClientRepresentation.class, name = "openid-connect"), - @JsonSubTypes.Type(value = SAMLClientRepresentation.class, name = "saml") + @JsonSubTypes.Type(value = OIDCClientRepresentation.class, name = OIDCClientRepresentation.PROTOCOL), + @JsonSubTypes.Type(value = SAMLClientRepresentation.class, name = SAMLClientRepresentation.PROTOCOL) }) public abstract class BaseClientRepresentation extends BaseRepresentation { public static final String DISCRIMINATOR_FIELD = "protocol"; @@ -110,17 +107,19 @@ public abstract class BaseClientRepresentation extends BaseRepresentation { this.roles = roles; } - @JsonIgnore public abstract String getProtocol(); - @JsonAnyGetter - public Map getAdditionalFields() { - return additionalFields; + void setProtocol(String protocol) { + if (!getProtocol().equals(protocol)) { + throw new IllegalArgumentException(); + } } @Override public boolean equals(Object o) { - if (!(o instanceof BaseClientRepresentation that)) return false; + if (!(o instanceof BaseClientRepresentation that)) { + return false; + } return Objects.equals(clientId, that.clientId) && Objects.equals(displayName, that.displayName) && Objects.equals(description, that.description) && Objects.equals(enabled, that.enabled) && Objects.equals(appUrl, that.appUrl) && Objects.equals(redirectUris, that.redirectUris) && Objects.equals(roles, that.roles) && Objects.equals(additionalFields, that.additionalFields); } diff --git a/rest/admin-v2/api/src/main/java/org/keycloak/representations/admin/v2/OIDCClientRepresentation.java b/rest/admin-v2/api/src/main/java/org/keycloak/representations/admin/v2/OIDCClientRepresentation.java index 98665c5eaf2..12a8ec29025 100644 --- a/rest/admin-v2/api/src/main/java/org/keycloak/representations/admin/v2/OIDCClientRepresentation.java +++ b/rest/admin-v2/api/src/main/java/org/keycloak/representations/admin/v2/OIDCClientRepresentation.java @@ -84,8 +84,7 @@ public class OIDCClientRepresentation extends BaseClientRepresentation { return PROTOCOL; } - @JsonInclude(JsonInclude.Include.NON_ABSENT) - public static class Auth { + public static class Auth extends BaseRepresentation { @JsonPropertyDescription("Which authentication method is used for this client") private String method; @@ -122,7 +121,9 @@ public class OIDCClientRepresentation extends BaseClientRepresentation { @Override public boolean equals(Object o) { - if (!(o instanceof Auth auth)) return false; + if (!(o instanceof Auth auth)) { + return false; + } return Objects.equals(method, auth.method) && Objects.equals(secret, auth.secret) && Objects.equals(certificate, auth.certificate); } @@ -134,8 +135,12 @@ public class OIDCClientRepresentation extends BaseClientRepresentation { @Override public boolean equals(Object o) { - if (!(o instanceof OIDCClientRepresentation that)) return false; - if (!super.equals(o)) return false; + if (!(o instanceof OIDCClientRepresentation that)) { + return false; + } + if (!super.equals(o)) { + return false; + } return Objects.equals(loginFlows, that.loginFlows) && Objects.equals(auth, that.auth) && Objects.equals(webOrigins, that.webOrigins) && Objects.equals(serviceAccountRoles, that.serviceAccountRoles); } diff --git a/rest/admin-v2/rest/src/main/java/org/keycloak/admin/api/client/ClientApi.java b/rest/admin-v2/rest/src/main/java/org/keycloak/admin/api/client/ClientApi.java index fb020de7a14..bf284ee429c 100644 --- a/rest/admin-v2/rest/src/main/java/org/keycloak/admin/api/client/ClientApi.java +++ b/rest/admin-v2/rest/src/main/java/org/keycloak/admin/api/client/ClientApi.java @@ -35,6 +35,7 @@ public interface ClientApi { @Produces(MediaType.APPLICATION_JSON) BaseClientRepresentation patchClient(JsonNode patch); + // TODO marked as producing json, but does not return anything @DELETE @Produces(MediaType.APPLICATION_JSON) void deleteClient(); diff --git a/rest/admin-v2/rest/src/main/java/org/keycloak/admin/api/client/DefaultClientApi.java b/rest/admin-v2/rest/src/main/java/org/keycloak/admin/api/client/DefaultClientApi.java index 8c2c6245c36..42972055846 100644 --- a/rest/admin-v2/rest/src/main/java/org/keycloak/admin/api/client/DefaultClientApi.java +++ b/rest/admin-v2/rest/src/main/java/org/keycloak/admin/api/client/DefaultClientApi.java @@ -111,7 +111,7 @@ public class DefaultClientApi implements ClientApi { } static void validateUnknownFields(BaseClientRepresentation rep) { - if (rep.getAdditionalFields().keySet().stream().anyMatch(k -> !k.equals(BaseClientRepresentation.DISCRIMINATOR_FIELD))) { + if (!rep.getAdditionalFields().keySet().isEmpty()) { throw new WebApplicationException("Payload contains unknown fields: " + rep.getAdditionalFields().keySet(), Response.Status.BAD_REQUEST); } } diff --git a/rest/admin-v2/tests/src/test/java/org/keycloak/tests/admin/client/v2/ClientApiV2Test.java b/rest/admin-v2/tests/src/test/java/org/keycloak/tests/admin/client/v2/ClientApiV2Test.java index bc449448549..0e49f918b62 100644 --- a/rest/admin-v2/tests/src/test/java/org/keycloak/tests/admin/client/v2/ClientApiV2Test.java +++ b/rest/admin-v2/tests/src/test/java/org/keycloak/tests/admin/client/v2/ClientApiV2Test.java @@ -269,6 +269,9 @@ public class ClientApiV2Test { samlRep.setForcePostBinding(true); samlRep.setFrontChannelLogout(false); + String rep = mapper.writeValueAsString(samlRep); + System.out.println(rep); + samlRequest.setEntity(new StringEntity(mapper.writeValueAsString(samlRep))); try (var response = client.execute(samlRequest)) {