mirror of
https://github.com/keycloak/keycloak.git
synced 2026-02-18 18:37:54 -05:00
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:
parent
85d9360e45
commit
8a471bb0d2
37 changed files with 1440 additions and 45 deletions
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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("-", "")))
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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("/");
|
||||
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -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) {
|
||||
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -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());
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -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();
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -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();
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -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> {
|
||||
|
||||
}
|
||||
|
|
@ -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();
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -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();
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -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> {
|
||||
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -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, "");
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
}
|
||||
|
||||
}
|
||||
94
operator/src/test/resources/example-mtls-secret.yaml
Normal file
94
operator/src/test/resources/example-mtls-secret.yaml
Normal file
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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("");
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)) {
|
||||
|
|
|
|||
Loading…
Reference in a new issue