Operator logic for clients in admin api v2 (#45316)

Operator logic for clients in admin api v2

Closes #46022

Signed-off-by: Steven Hawkins <shawkins@redhat.com>
This commit is contained in:
Steven Hawkins 2026-02-05 04:16:29 -05:00 committed by GitHub
parent 85d9360e45
commit 8a471bb0d2
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
37 changed files with 1440 additions and 45 deletions

View file

@ -111,6 +111,18 @@
<groupId>org.keycloak</groupId>
<artifactId>keycloak-core</artifactId>
</dependency>
<dependency>
<groupId>org.keycloak</groupId>
<artifactId>keycloak-admin-client</artifactId>
</dependency>
<dependency>
<groupId>org.keycloak</groupId>
<artifactId>keycloak-admin-v2-api</artifactId>
</dependency>
<dependency>
<groupId>org.keycloak</groupId>
<artifactId>keycloak-admin-v2-rest</artifactId>
</dependency>
<!-- Test -->
<dependency>

View file

@ -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<String, String> 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);
}

View file

@ -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("-", "")))

View file

@ -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 <R> custom resource type
* @param <T> base server type for the Client
* @param <S> spec refined type for the Client
*/
public abstract class KeycloakClientBaseController<R extends CustomResource<? extends KeycloakClientSpec<S>, KeycloakClientStatus>, T extends BaseClientRepresentation, S extends BaseClientRepresentation>
implements Reconciler<R>, Cleaner<R> {
private static final String CLIENT_API_VERSION = "v2";
private static final String HTTPS = "https";
static class KeycloakClientStatusAggregator {
Long generation;
KeycloakClientStatus existingStatus;
Map<String, KeycloakClientStatusCondition> existingConditions;
Map<String, KeycloakClientStatusCondition> newConditions = new LinkedHashMap<String, KeycloakClientStatusCondition>();
KeycloakClientStatusAggregator(CustomResource<?, KeycloakClientStatus> 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<R> reconcile(R resource, Context<R> 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<R> 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<T> 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<R> 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<R> updateErrorStatus(R resource, Context<R> 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> V invoke(R resource, Context<?> context, Keycloak keycloak,
Function<ClientApi, V> 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);
}
}

View file

@ -175,7 +175,7 @@ public class KeycloakController implements Reconciler<Keycloak> {
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);
}

View file

@ -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<String> readConfigurationValue(String key, Keycloak keycloakCR, Context<Keycloak> context) {
static Optional<String> 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("/");

View file

@ -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() {

View file

@ -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<KeycloakOIDCClient, OIDCClientRepresentation, KeycloakOIDCClientRepresentation> {
@Override
Class<OIDCClientRepresentation> 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;
}
}

View file

@ -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<KeycloakRealmIm
}
if (!status.isDone()) {
updateControl.rescheduleAfter(10, TimeUnit.SECONDS);
updateControl.rescheduleAfter(Constants.RETRY_DURATION);
}
return updateControl;

View file

@ -0,0 +1,41 @@
/*
* 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 org.keycloak.operator.crds.v2alpha1.client.KeycloakSAMLClient;
import org.keycloak.operator.crds.v2alpha1.client.KeycloakSAMLClientRepresentation;
import org.keycloak.representations.admin.v2.SAMLClientRepresentation;
import io.javaoperatorsdk.operator.api.reconciler.Context;
import io.javaoperatorsdk.operator.api.reconciler.ControllerConfiguration;
@ControllerConfiguration
public class KeycloakSAMLClientController extends KeycloakClientBaseController<KeycloakSAMLClient, SAMLClientRepresentation, KeycloakSAMLClientRepresentation> {
@Override
Class<SAMLClientRepresentation> getTargetRepresentation() {
return SAMLClientRepresentation.class;
}
@Override
boolean prepareRepresentation(KeycloakSAMLClientRepresentation crRepresentation,
SAMLClientRepresentation targetRepresentation, Context<?> context) {
// Nothing to do, and no polling
return false;
}
}

View file

@ -51,8 +51,7 @@ public class KeycloakServiceDependentResource extends CRUDKubernetesDependentRes
var builder = new ServiceSpecBuilder().withSelector(Utils.allInstanceLabels(keycloak));
boolean tlsConfigured = isTlsConfigured(keycloak);
Optional<HttpSpec> 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> httpSpec = Optional.ofNullable(keycloak.getSpec().getHttpSpec());
boolean httpEnabled = httpSpec.map(HttpSpec::getHttpEnabled).orElse(false);
return httpEnabled;
}
@Override
protected Service desired(Keycloak primary, Context<Keycloak> context) {

View file

@ -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())

View file

@ -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<T extends BaseClientRepresentation> {
@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;
}
}

View file

@ -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<KeycloakClientStatusCondition> conditions = new ArrayList<KeycloakClientStatusCondition>();
// 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<KeycloakClientStatusCondition> getConditions() {
return conditions;
}
public void setConditions(List<KeycloakClientStatusCondition> 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());
}
}

View file

@ -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);
}
}

View file

@ -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<KeycloakOIDCClientSpec, KeycloakClientStatus> implements Namespaced {
@Required
@Override
public KeycloakOIDCClientSpec getSpec() {
return super.getSpec();
}
}

View file

@ -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();
}
}

View file

@ -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<KeycloakOIDCClientRepresentation> {
}

View file

@ -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<KeycloakSAMLClientSpec, KeycloakClientStatus> implements Namespaced {
@Required
@Override
public KeycloakSAMLClientSpec getSpec() {
return super.getSpec();
}
}

View file

@ -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();
}
}

View file

@ -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<KeycloakSAMLClientRepresentation> {
}

View file

@ -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;
}
}

View file

@ -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 <vmuzikar@redhat.com>
@ -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 <T extends StatusCondition> Map<String, T> getConditionMap(List<T> 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<String, KeycloakStatusCondition> existingConditions, String now) {
public static void updateConditionFromExisting(StatusCondition condition, Map<String, ? extends StatusCondition> existingConditions, String now) {
var existing = existingConditions.get(condition.getType());
if (existing == null) {
if (condition.getObservedGeneration() != null) {

View file

@ -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;
}
}

View file

@ -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, "");
}
}

View file

@ -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);

View file

@ -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() {

View file

@ -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<String, String> 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());
}
}

View file

@ -0,0 +1,94 @@
apiVersion: v1
kind: Secret
metadata:
name: example-mtls-secret
stringData:
tls.crt: |
-----BEGIN CERTIFICATE-----
MIIFlzCCA3+gAwIBAgIUSgCySOOBfCodc0g6+LB4ll8oABYwDQYJKoZIhvcNAQEL
BQAwYDELMAkGA1UEBhMCVVMxEzARBgNVBAgMCkFjbWUgU3RhdGUxEjAQBgNVBAcM
CUFjbWUgQ2l0eTESMBAGA1UECgwJQWNtZSBJbmMuMRQwEgYDVQQDDAtleGFtcGxl
LmNvbTAeFw0yNjAxMjcyMTMwMzhaFw0zMDAxMjYyMTMwMzhaMGcxCzAJBgNVBAYT
AlVTMRMwEQYDVQQIDApBY21lIFN0YXRlMRIwEAYDVQQHDAlBY21lIENpdHkxEjAQ
BgNVBAoMCUFjbWUgSW5jLjEbMBkGA1UEAwwSY2xpZW50LmV4YW1wbGUuY29tMIIC
IjANBgkqhkiG9w0BAQEFAAOCAg8AMIICCgKCAgEAubXIU97DXaGxSJXbQ7I+zTuk
HI4dj+8D3WYh4l4ISB7/mm68kVc0lZaT6MGhzAiS6JJS7x0Zoi2o/8QIuZBM8ogl
l37gyIaXJI7JSOqFN6CPlyac8tSZOPLrMOiUVkmbsBI+se2F95Ix8dAIH2j6LTDa
kmc7SlepMJibBxXv3drqrf8zRlvk8CFJ5eJwfOdWEdKm/S9HYSsTUPnwfPxiSHvS
PUC6kFa48babV0lisgdoz3cFgYcaxTt/9wPiyQpJPaceYy30EOV2e+VLBvqGN1GT
jdYBi6cS+t5R63pJRzzUe5Jbrduzt40w2w2WbwpryM9s1akogjg90bshMWHdt4wz
6g2N9FVKF6VTsAVFRuU+lR2aTrm3Q3mOA+nLYFYJCH68ITlBhJexzUpv3jGIMZBC
2ZAfMEgJHRUMRA5Ieg3NJbNg2cppVT8E3sSVAHwYR7TvFjNbTm2ge9ZTTVT/A8Es
zP9xQD2SZ7uOC+dpWKyw+/L+SmvKTCpX6khf5vsJ9Gl60vrxplDt69uLm+Np2HDb
NJdDOt4ny3gu1elZgctd4IXaV4c/SPjYbz8XGr2Ed9pol0LAaIVzPFP0rBPBnLAz
2bTI+QBKl8MgCjesGWmkR6clvcUqij0OUrNldfteZAuUFie4YmV23JGKgnUHJXGm
KTPa1FCkCLy3e6xU+NkCAwEAAaNCMEAwHQYDVR0OBBYEFMSBHB9uehVdngsbfvBm
lkJ1YdzIMB8GA1UdIwQYMBaAFFR3WqdAzOnkPJgVmQOkZS9XK/31MA0GCSqGSIb3
DQEBCwUAA4ICAQAaRjV578UU3hg4luGP3KSsFQiOGio9C6DQYSEzF5CPgaFBfbD9
dLmTcDwzlrA9ShpConGjDh8FYowAeuFpJbcjJ7pQDWOz5ga1HccI1bMlPimme9Yr
+uyOiujIyGOC7eemK+5i4FTCpUmP59mRPavCnJyRkPpXuKyxCuubG4Vcz0RXunlH
OuKtMhoDwpTDr+BDEQEQ2geBEqB5yQ0TkoIQID3FG3/hcRwouG7u6xZs2mg3jEB0
D11atfQfDp1GKzQOUFmWk0ToAggwhGGIUj1XbhJONh7N6tq/m9bULt865VfXjkVX
Eyh8PU4dLf2p02loYT3W+IO+Ub75ojIjH83z/oyp2qhbqHaNweW2T+zwlYJgfZDR
1vFMUlGxcV+ZuYZ0zlVvtCXIEEASC8ypkOV7Z+W63DcuJN7SHlnCdC6o8XrK3hRz
BZrf0T7TxDZs5gW4h99eCgGr/Q/63CZr4ER6j/WmvRjLM0seaBes/opu3Bu0P82T
qF6dwZCp13n+UiP7j1Rek3T29HWwkWlsLnRWvdxKaFmgv7ZPCM/X8RtGqssQmCSy
kAizfXdRytq/1wga91nIG9x1S5NQqnU1u/quynB10chQY/FRRQQTws+fYBImsv5v
qGkxjsO497OOKJ/pcMi3rO5YAwIDydhwaUZUVQfInZVrjrXkPSjUMcRHJg==
-----END CERTIFICATE-----
tls.key: |
-----BEGIN PRIVATE KEY-----
MIIJQgIBADANBgkqhkiG9w0BAQEFAASCCSwwggkoAgEAAoICAQC5tchT3sNdobFI
ldtDsj7NO6Qcjh2P7wPdZiHiXghIHv+abryRVzSVlpPowaHMCJLoklLvHRmiLaj/
xAi5kEzyiCWXfuDIhpckjslI6oU3oI+XJpzy1Jk48usw6JRWSZuwEj6x7YX3kjHx
0AgfaPotMNqSZztKV6kwmJsHFe/d2uqt/zNGW+TwIUnl4nB851YR0qb9L0dhKxNQ
+fB8/GJIe9I9QLqQVrjxtptXSWKyB2jPdwWBhxrFO3/3A+LJCkk9px5jLfQQ5XZ7
5UsG+oY3UZON1gGLpxL63lHreklHPNR7klut27O3jTDbDZZvCmvIz2zVqSiCOD3R
uyExYd23jDPqDY30VUoXpVOwBUVG5T6VHZpOubdDeY4D6ctgVgkIfrwhOUGEl7HN
Sm/eMYgxkELZkB8wSAkdFQxEDkh6Dc0ls2DZymlVPwTexJUAfBhHtO8WM1tObaB7
1lNNVP8DwSzM/3FAPZJnu44L52lYrLD78v5Ka8pMKlfqSF/m+wn0aXrS+vGmUO3r
24ub42nYcNs0l0M63ifLeC7V6VmBy13ghdpXhz9I+NhvPxcavYR32miXQsBohXM8
U/SsE8GcsDPZtMj5AEqXwyAKN6wZaaRHpyW9xSqKPQ5Ss2V1+15kC5QWJ7hiZXbc
kYqCdQclcaYpM9rUUKQIvLd7rFT42QIDAQABAoICABjv7b2YEoM2h4LStLB2H/QZ
MnlZ0A3C2lxW2Zf3E9pIvKlKfhcaLwfN7NX6zQouizj1unM8uh4f+YCWx1Z7p7Qt
pLafeqRoBlBfaBda0+wGAHdwikTFWD81x2/i21XBk/QPDyIqCKzs5w4B5wVTaGdn
FwKadXNkgBCe0rp0hKg1kavZsqibsGCvvPzvQjb+LX2T3DPwBFVqSQa8+UEfTDyl
e97DxrRDy5k8Rxxv0J6isL1NzeHNzZd3MkDDjHwoiHCMn1+mt7kJHGGYsabMlKHw
WgWtcLmwWV5x5MLfwd/yAtUGcI8mYGShGKgMPrYcdSAa/RICdrETHPdWedI9bQh8
yXZOQVXd4m5xQ8ogF2uDpJ0tkmkgh9ubyu81jBt2V595KfbHuYrmOePaWHcTMI4T
0CatmSHaXVXJZxJkjvUSZODI8H2tG8NlOUDsOusspGvbMt+SQKVVHyNUreiwWGHj
IfPF7uOEE82A1edNT5OurNXadD6HjYXLtOjtCNogsc7VS/IQLlls9WFoR4jUqcrw
8eMU9NfViwFVwclkLOFtKsZK8SuQKKTnF+8o3S5z5LqBcHfqqzOfeFA6vNxzJ62Q
9hazdsdMcH2kPUdP2SZc+CXuH14upVrUbAszUfQqnWwP6ZdiB12LtoMtjAlr7iDC
VobzMbpvPyJl7UORupm9AoIBAQDxu/jHyUS/9abQNX07hn7+/w8S0KRyqKXYBjKZ
i9WvkK2cnAINRr9NUogczxvWax2jKyNmSUKG8ovu0YCy4dOWWz93igiXZ2SzzQIy
4tbAt7bXgO5wRJazLt1SUIJeVZGw1Fs/rvXcRYxqRJaEnbuZeK1CiHYuS8HmMHF6
iLCCKgpy+pqw22J9ISahP9QYHB1IpCLSIxeCjvE/AgNcikq14KoBfX9S1lS1/wb7
PDDI99SRrYQxbQ2K0ZQrSSc2nDKZ9VhMGeko3SdtM0BiTnBMxVgyjj6a7c70MBiQ
07oYdE38qYZQXANLw0ltOXzeNLODLN1X/uVMd5TwzUmDOwBHAoIBAQDEq2tGEdQY
JdHWIR2L2H0CviMpFQusBMAQZ7PSYIny4HuEwP1yRULeRVbWrdFnx0ZHkx2auCWk
BZ9kPlFpz0SZJtolu4hNWaPzY6qlijEJW/51Etle/nz+OBX2tvldM46k5vBLBY1x
WTjt6f3SEUE4thr9AJ30cEalKGnYBNSgkw2Q1qKeYJJe3U/XQbFZuAloF3UsqIlf
zOXMXbBj19Pgaew4YFcLzeTcYVshzOmYJSnZQmZMoGfik3hED21lyXW+NYRSO2Dt
iDzksLsjsPe4O/ylBv+xa9zU6PteN+F5J+Rae3WVjquiJn4MzkEeF9LV5PCLWQ9J
jkC9/ZqS4u3fAoIBAHIedk0C9GTW+IBUsCFY1j1Vde1A+FF00o5QJrUcMa3nVD6Z
29IesxMywjUvhQnNmbk9FUIllbWVbYA6AVLxj2zs+OJbFME9O2oyfzY8pntmf3fv
UyFHEAnZNvy0K0eTh+r95XIEC+eIIcjNRm2m9Th3ovvE5l3mv8wG1JuvSfy5EiPD
sSGLAEzoSI9ZTaxwIVb4vcOMc33cM4G2VpXqZ2jDfh6j+2bE7krY8ZfLi5Bkh0Ka
ssyOmhUN6bAhodSDGtRZ4exTUyJKfWFtD5kZRKTJiWCcjGuhltDqn75HZhRDW8nK
0jC/r4Kl19UrjYptQM3NcVUobWGTFoozr9+3C+MCggEAXRc6vr7/orJ8IZwpLSG7
AopXgEGq9bCF7P35OxJhGaqyLMNg9C7emPE/SnyaC0Ji/MwhDjQt9PaIXN22kZv/
P3MJfSTIPry4gyNhCdxgm6qExou2gmV5aqfHlbFEVZ5q3ZlGkmw9aDKwZMUGVOG/
+oUQP0OEgMiIV/LKLusSbjNND4rZDJhvkCG3gg9hUDNxmGjKGOpppAQLnfGW5Zuq
eaYJnyHS8g5yTvJyPYyN4Wtt5J2uaITgx3nASo723GBAsFkKmhXrKmP7VtBktF82
0mjqjH7ElwmUTN8+5HkU69E7IK0hmjoe+bC4p30Vi5YBQSeNyJfOSaXg9U1OVkq+
BwKCAQEAiBS21BXoLRax9k0J4OIgGmwE0ABPygzqUJM2wx/5QivIhZLTkzc6lh10
cj42DHkOyVC5BAeBVLg8AA0JKVEIpIQj8Cxfrd22z2Zn2r+4Du+QoVySPnRhP1SD
cRMVayI16pu3llZ5TxWGT2PfI389uwABIgUiRjgJa67OzG1oX3/PyRHBAQ5Yrda6
ajHQD+1ZExoRhrblo0Hfbi2dmULOFcNWRmN0zXtC5b6XiIBpEF82xRqmo1iY88Z4
cDRQbypRIIT74xMInfYOC/3E2GjJqU6/SiASvgwSYsqzVN/IvWrEPXvJr/QRXuqh
n1iJiw+VDDvHFCu4nUQGcpOopqGNeg==
-----END PRIVATE KEY-----
type: kubernetes.io/tls

View file

@ -0,0 +1,41 @@
apiVersion: v1
kind: Secret
metadata:
name: example-mtls-truststore-secret
stringData:
cert.pem: |
-----BEGIN CERTIFICATE-----
MIIFoTCCA4mgAwIBAgIUfv0YZv82APUg8fEmXa0jP0Jd4EAwDQYJKoZIhvcNAQEL
BQAwYDELMAkGA1UEBhMCVVMxEzARBgNVBAgMCkFjbWUgU3RhdGUxEjAQBgNVBAcM
CUFjbWUgQ2l0eTESMBAGA1UECgwJQWNtZSBJbmMuMRQwEgYDVQQDDAtleGFtcGxl
LmNvbTAeFw0yNjAxMjcyMTMwMDlaFw0zNjAxMjUyMTMwMDlaMGAxCzAJBgNVBAYT
AlVTMRMwEQYDVQQIDApBY21lIFN0YXRlMRIwEAYDVQQHDAlBY21lIENpdHkxEjAQ
BgNVBAoMCUFjbWUgSW5jLjEUMBIGA1UEAwwLZXhhbXBsZS5jb20wggIiMA0GCSqG
SIb3DQEBAQUAA4ICDwAwggIKAoICAQDAPoaqOvhawXCnkgA6zHZJUOj7W3iN9HYa
9jPXcK3qINiGliYbExHtOqCfWGYU1aXA+ErB1kMV4YWTFBmsyAknkmcRoEhtbwxN
tt3c6EBxKQcdfkCpIo4EKtKLbvdru1Hv+GsQ1I+R2KKbaK7WcA6O8wwKkyoCA/TS
FWck7F9nju6jn/7J7dTgu2qCVrb6rDf9MyMZeIzTD5+BiFe/z3pXEyzBDPv0aV1b
kRyvbmVGUMKmQQXSNWYai7+1/B8/D7katGXhD7LYVu9MFRepf32SxBJu5ZSSXely
zP04vJn7hiRoudp1Q0NkxI8ZPl8OnBCnhsYgxkAcdltGnfzrGKW+R2hEJdtqynAV
hPILNtoFokMPTaVhPGTafR3qIT6v7Fv0Ln52UVshFecYwyTPucQ7GVXXg9wrV9US
rNRt8If83r/6zV4uefAdjtFduEZ/0jGzmpibcMt4ahxwzMQWUXQS8Ycb8k2hIfzE
cEheUj5YwDN7JPxSejVxc4q8wl620r7jkkrAtmC+1ajJU9eKZ5jl8yEk9JDdBt7K
qv9mL6pQice/xZo2jVUcNIUSGSFUjNG6Ry6qGeO1pPS8mM+76CNeevn+kOsswwOz
3CW9TZ7jxVmE121m4QwTRMelvLPzpeWQKsnMSw0MI7VMQdjP+64o7ujtIz7Db9q0
jud96OSSfQIDAQABo1MwUTAdBgNVHQ4EFgQUVHdap0DM6eQ8mBWZA6RlL1cr/fUw
HwYDVR0jBBgwFoAUVHdap0DM6eQ8mBWZA6RlL1cr/fUwDwYDVR0TAQH/BAUwAwEB
/zANBgkqhkiG9w0BAQsFAAOCAgEAbjrugdatkuV/bixu1KaQ0EbfZPLvMqwlk3TU
IjH5f0IT+e93SbQsCFzIFAhjWbt9EAZdkzhD3NzDYs7QA5Edo0gPf5ami+j9fUM0
+qt+ugYfzPMzbOTnRsdy5IGYQmqG+vZ4T99HLPrgzYQgOezV8UowFN1ChmWCEpU+
1mkfO5s35jwj/24NvoImmpc9TEjx9KzgCwyNJQ3nfMIB1I8qXi7Ee1zpHKR+dtYq
i1o+d1uAVcVI/KdvK+lWsCkpcqmEjRorXAWfbMlZkq/yKSeuWh566gDAqJMzgHr9
96H8KMo2pWkdn9JoVcitF4srVGCRJkqJ/1BwvVSSS9ZGzIDjOpbEGsAUoW+e1ABq
C/fYOdqxGrqhbbcIhhkLLuFBKdZuNNA3wga8Gd2fpr1B5v/hb0M5sAuw/IXg5XSl
WRMfPqD4UYxabR6KnGCvq0QxXVXp4zbXfZnu9iICLlfCY6CBm+f88w9p8USWDubi
gaxbEsw/j9t/KAcErPLEK2CnW9UNjfBw2M7HBxm5haYndLW6OIh5K768/de0VTXm
/Si3dwnh+dHrwF+ES1mXLMmt5CkE6LE7GrRT+3af2zMzEIPOCqN0+dNPXp/IV3WN
TM2IBTsBtWCuqCmeTd5uB9X9JGuD1gQ06FpGSZv6aeZA/HOY2EIQ/WPabvGdQaZG
djkdxZ0=
-----END CERTIFICATE-----
type: Opaque

View file

@ -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

View file

@ -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("");

View file

@ -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<String, Object> 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);
}

View file

@ -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);
}

View file

@ -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();

View file

@ -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);
}
}

View file

@ -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)) {