Skip to content

Commit

Permalink
Security via operator, OIDC trusted certificates (#1314)
Browse files Browse the repository at this point in the history
* Update operator for security configuration
* Validate configuration earlier in reconciliation, set conditions
* add security testing, minor re-factoring
* fix Sonar issues
* Configure truststore for OIDC provider
* Exclude auth for non-API paths, fix status update, fix dep status chk
* map UI variable for PEM truststore
* always pull images without SHAs
* fix: default replica values in deployment status to zero
* Add test for OIDC truststore w/JKS to PEM conversion
* Remove dead code, additional testing for invalid CR scenario
* Use TLS + truststore with Keycloak for API OIDC tests
* Use predefined set of unauthenticated paths instead of non-`/api` paths
* Use secure file attributes for temp truststore, add JavaDoc comment
* Resolve issues from review feedback and Sonar scanning

Signed-off-by: Michael Edgar <[email protected]>
MikeEdgar authored Jan 9, 2025

Verified

This commit was created on GitHub.com and signed with GitHub’s verified signature.
1 parent 08bd404 commit 49e2ad1
Showing 37 changed files with 2,772 additions and 916 deletions.
Original file line number Diff line number Diff line change
@@ -81,6 +81,8 @@ public class ConsoleAuthenticationMechanism implements HttpAuthenticationMechani
.setPrincipal(new QuarkusPrincipal("ANONYMOUS"))
.build();

private static final Set<String> UNAUTHENTICATED_PATHS = Set.of("/health", "/metrics", "/openapi", "/swagger-ui");

@Inject
Logger log;

@@ -102,6 +104,12 @@ boolean oidcEnabled() {

@Override
public Uni<SecurityIdentity> authenticate(RoutingContext context, IdentityProviderManager identityProviderManager) {
final String requestPath = context.normalizedPath();

if (UNAUTHENTICATED_PATHS.stream().anyMatch(requestPath::startsWith)) {
return Uni.createFrom().nullItem();
}

if (oidcEnabled()) {
return oidc.authenticate(context, identityProviderManager)
.map(identity -> augmentIdentity(context, identity))
@@ -171,7 +179,13 @@ public Uni<ChallengeData> getChallenge(RoutingContext context) {
var category = ErrorCategory.get(ErrorCategory.NotAuthenticated.class);
Error error = category.createError("Authentication credentials missing or invalid", null, null);
var responseBody = new ErrorResponse(List.of(error));
return new PayloadChallengeData(data, responseBody);
return (ChallengeData) new PayloadChallengeData(data, responseBody);
})
.onFailure().recoverWithItem(t -> {
var category = ErrorCategory.get(ErrorCategory.ServerError.class);
Error error = category.createError("Authentication failed due to internal server error", null, null);
var responseBody = new ErrorResponse(List.of(error));
return new PayloadChallengeData(500, null, null, responseBody);
});
}

Original file line number Diff line number Diff line change
@@ -1,16 +1,32 @@
package com.github.streamshub.console.api.security;

import java.io.File;
import java.io.FileOutputStream;
import java.io.IOException;
import java.io.OutputStream;
import java.io.UncheckedIOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.attribute.PosixFilePermissions;
import java.security.KeyStore;
import java.util.List;
import java.util.Optional;
import java.util.UUID;

import jakarta.annotation.PostConstruct;
import jakarta.enterprise.context.ApplicationScoped;
import jakarta.inject.Inject;

import org.eclipse.microprofile.config.inject.ConfigProperty;
import org.jboss.logging.Logger;

import com.github.streamshub.console.config.ConsoleConfig;

import io.quarkus.oidc.OidcRequestContext;
import io.quarkus.oidc.OidcTenantConfig;
import io.quarkus.oidc.TenantConfigResolver;
import io.quarkus.tls.TlsConfiguration;
import io.quarkus.tls.TlsConfigurationRegistry;
import io.smallrye.mutiny.Uni;
import io.vertx.ext.web.RoutingContext;

@@ -22,6 +38,16 @@
@ApplicationScoped
public class OidcTenantConfigResolver implements TenantConfigResolver {

@Inject
Logger logger;

@Inject
@ConfigProperty(name = "console.work-path")
String workPath;

@Inject
TlsConfigurationRegistry tlsRegistry;

@Inject
ConsoleConfig consoleConfig;

@@ -40,6 +66,56 @@ void initialize() {
if (oidc.getIssuer() != null) {
oidcConfig.getToken().setIssuer(oidc.getIssuer());
}

getTlsConfiguration().map(TlsConfiguration::getTrustStore).ifPresentOrElse(
this::configureTruststore,
() -> logger.infof("No truststore configured for OIDC provider")
);
}

Optional<TlsConfiguration> getTlsConfiguration() {
String dotSeparatedSource = "oidc.provider.trust";
String dashSeparatedSource = "oidc-provider-trust";
return tlsRegistry.get(dotSeparatedSource).or(() -> tlsRegistry.get(dashSeparatedSource));
}

/**
* The OIDC subsystem takes the path to a truststore, so we need to write the
* one from the TLS registry to a working file to provide to OIDC. This should
* no longer be necessary in the next Quarkus LTS where OIDC is aware of the TLS
* registry.
*/
void configureTruststore(KeyStore truststore) {
File workDir = new File(workPath);
Path truststorePath;
File truststoreFile;

try {
truststorePath = Files.createTempFile(
workDir.toPath(),
"oidc-provider-trust",
"." + truststore.getType(),
PosixFilePermissions.asFileAttribute(PosixFilePermissions.fromString("rw-------")));
truststoreFile = truststorePath.toFile();
truststoreFile.deleteOnExit();
} catch (IOException e) {
throw new UncheckedIOException(e);
}

String secret = UUID.randomUUID().toString();

try (OutputStream out = new FileOutputStream(truststoreFile)) {
truststore.store(out, secret.toCharArray());
} catch (Exception e) {
throw new RuntimeException(e);
}

// No default provided, set to empty to avoid NPE
oidcConfig.tls.trustStoreProvider = Optional.empty();
oidcConfig.tls.setTrustStoreFile(truststorePath);
oidcConfig.tls.setTrustStorePassword(secret);
// Future: map the certificate alias if provided
// oidcConfig.tls.setTrustStoreCertAlias(null);
}

@Override
1 change: 1 addition & 0 deletions api/src/main/resources/application.properties
Original file line number Diff line number Diff line change
@@ -68,6 +68,7 @@ quarkus.arc.exclude-types=io.apicurio.registry.rest.JacksonDateTimeCustomizer
quarkus.index-dependency.strimzi-api.group-id=io.strimzi
quarkus.index-dependency.strimzi-api.artifact-id=api

console.work-path=${java.io.tmpdir}
console.kafka.admin.request.timeout.ms=10000
console.kafka.admin.default.api.timeout.ms=10000

Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
package com.github.streamshub.console.kafka.systemtest.deployment;

import java.io.File;
import java.io.IOException;
import java.io.InputStream;
import java.io.UncheckedIOException;
import java.nio.file.Files;
import java.time.Duration;
import java.util.Map;

@@ -12,6 +14,8 @@
import org.testcontainers.containers.wait.strategy.Wait;
import org.testcontainers.images.builder.Transferable;

import com.github.streamshub.console.test.TlsHelper;

import io.quarkus.test.common.QuarkusTestResourceLifecycleManager;

public class KeycloakResourceManager implements QuarkusTestResourceLifecycleManager {
@@ -29,26 +33,56 @@ public Map<String, String> start() {
throw new UncheckedIOException(ioe);
}

int port = 8443;
TlsHelper tls = TlsHelper.newInstance();
String keystorePath = "/opt/keycloak/keystore.p12";

keycloak = new GenericContainer<>("quay.io/keycloak/keycloak:26.0")
.withLogConsumer(new Slf4jLogConsumer(LoggerFactory.getLogger("systemtests.keycloak"), true))
.withExposedPorts(8080)
.withExposedPorts(port)
.withEnv(Map.of(
"KC_BOOTSTRAP_ADMIN_USERNAME", "admin",
"KC_BOOTSTRAP_ADMIN_PASSWORD", "admin",
"PROXY_ADDRESS_FORWARDING", "true"))
.withCopyToContainer(
Transferable.of(tls.getKeyStoreBytes()),
keystorePath)
.withCopyToContainer(
Transferable.of(realmConfig),
"/opt/keycloak/data/import/console-realm.json")
.withCommand("start", "--hostname=localhost", "--http-enabled=true", "--import-realm")
.waitingFor(Wait.forHttp("/realms/console-authz").withStartupTimeout(Duration.ofMinutes(1)));
.withCommand(
"start",
"--hostname=localhost",
"--http-enabled=false",
"--https-key-store-file=%s".formatted(keystorePath),
"--https-key-store-password=%s".formatted(String.copyValueOf(tls.getPassphrase())),
"--import-realm"
)
.waitingFor(Wait.forHttps("/realms/console-authz")
.allowInsecure()
.withStartupTimeout(Duration.ofMinutes(1)));

File truststoreFile;

try {
truststoreFile = File.createTempFile("oidc-provider-trust", "." + tls.getTrustStore().getType());
Files.write(truststoreFile.toPath(), tls.getTrustStoreBytes());
truststoreFile.deleteOnExit();
} catch (IOException e) {
throw new UncheckedIOException(e);
}

keycloak.start();

String urlTemplate = "http://localhost:%d/realms/console-authz";
var oidcUrl = urlTemplate.formatted(keycloak.getMappedPort(8080));
String urlTemplate = "https://localhost:%d/realms/console-authz";
var oidcUrl = urlTemplate.formatted(keycloak.getMappedPort(port));
return Map.of(
"console.test.oidc-url", oidcUrl,
"console.test.oidc-issuer", urlTemplate.formatted(8080));
"console.test.oidc-host", "localhost:%d".formatted(port),
"console.test.oidc-issuer", urlTemplate.formatted(port),
"quarkus.tls.\"oidc-provider-trust\".trust-store.jks.path", truststoreFile.getAbsolutePath(),
"quarkus.tls.\"oidc-provider-trust\".trust-store.jks.password", String.copyValueOf(tls.getPassphrase())
);
}

@Override
Original file line number Diff line number Diff line change
@@ -4,25 +4,41 @@
import java.io.StringReader;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpClient.Version;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.util.UUID;

import javax.net.ssl.SSLContext;

import jakarta.enterprise.inject.spi.CDI;
import jakarta.json.Json;
import jakarta.json.JsonObject;
import jakarta.json.JsonReader;
import jakarta.ws.rs.core.HttpHeaders;

import org.eclipse.microprofile.config.Config;

import io.quarkus.tls.TlsConfigurationRegistry;
import io.restassured.http.Header;

public class TokenUtils {

final String tokenEndpoint;
final String tokenEndpointHost;
final SSLContext tls;

public TokenUtils(Config config) {
this.tokenEndpoint = config.getValue("console.test.oidc-url", String.class) + "/protocol/openid-connect/token";
this.tokenEndpointHost = config.getValue("console.test.oidc-host", String.class);

var tlsRegistry = CDI.current().select(TlsConfigurationRegistry.class).get();

try {
tls = tlsRegistry.get("oidc-provider-trust").get().createSSLContext();
} catch (Exception e) {
throw new RuntimeException(e);
}
}

public Header authorizationHeader(String username) {
@@ -47,11 +63,14 @@ public JsonObject getTokenObject(String username) {
+ "password=%1$s-password&"
+ "client_id=console-client", username);

HttpClient client = HttpClient.newBuilder().build();
HttpClient client = HttpClient.newBuilder()
.sslContext(tls)
.version(Version.HTTP_1_1)
.build();

HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create(tokenEndpoint))
.header("Host", "localhost:8080")
.header("Host", tokenEndpointHost)
.header("Content-Type", "application/x-www-form-urlencoded")
.POST(HttpRequest.BodyPublishers.ofString(form))
.build();
226 changes: 226 additions & 0 deletions api/src/test/java/com/github/streamshub/console/test/TlsHelper.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,226 @@
package com.github.streamshub.console.test;

import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.math.BigInteger;
import java.nio.charset.StandardCharsets;
import java.security.GeneralSecurityException;
import java.security.KeyPair;
import java.security.KeyPairGenerator;
import java.security.KeyStore;
import java.security.SecureRandom;
import java.security.Security;
import java.security.cert.Certificate;
import java.security.cert.CertificateEncodingException;
import java.security.cert.X509Certificate;
import java.time.LocalDateTime;
import java.time.ZoneOffset;
import java.util.Base64;
import java.util.Date;
import java.util.UUID;

import org.bouncycastle.asn1.x500.X500Name;
import org.bouncycastle.asn1.x509.BasicConstraints;
import org.bouncycastle.asn1.x509.Extension;
import org.bouncycastle.asn1.x509.GeneralName;
import org.bouncycastle.asn1.x509.GeneralNames;
import org.bouncycastle.asn1.x509.KeyUsage;
import org.bouncycastle.cert.X509CertificateHolder;
import org.bouncycastle.cert.X509v3CertificateBuilder;
import org.bouncycastle.cert.jcajce.JcaX509CertificateConverter;
import org.bouncycastle.cert.jcajce.JcaX509ExtensionUtils;
import org.bouncycastle.cert.jcajce.JcaX509v3CertificateBuilder;
import org.bouncycastle.jce.provider.BouncyCastleProvider;
import org.bouncycastle.operator.ContentSigner;
import org.bouncycastle.operator.OperatorCreationException;
import org.bouncycastle.operator.jcajce.JcaContentSignerBuilder;
import org.bouncycastle.pkcs.PKCS10CertificationRequest;
import org.bouncycastle.pkcs.PKCS10CertificationRequestBuilder;
import org.bouncycastle.pkcs.jcajce.JcaPKCS10CertificationRequestBuilder;

public class TlsHelper {

private static final String BC_PROVIDER = "BC";
private static final String KEY_ALGORITHM = "RSA";
private static final String SIGNATURE_ALGORITHM = "SHA256withRSA";
private static final String PKCS12 = "PKCS12";

static {
// Add the BouncyCastle Provider
Security.addProvider(new BouncyCastleProvider());
}

public static TlsHelper newInstance() {
try {
return new TlsHelper();
} catch (Exception e) {
throw new RuntimeException(e);
}
}

private Certificate rootCA;
private KeyStore keyStore;
private KeyStore trustStore;
private final char[] passphrase = UUID.randomUUID().toString().toCharArray();

public Certificate getRootCA() {
return rootCA;
}

public String getRootCAPem() {
try {
return pemEncodeCertificate(rootCA);
} catch (CertificateEncodingException | IOException e) {
throw new RuntimeException(e);
}
}

public KeyStore getKeyStore() {
return keyStore;
}

public byte[] getKeyStoreBytes() {
return getBytes(keyStore, passphrase);
}

public KeyStore getTrustStore() {
return trustStore;
}

public byte[] getTrustStoreBytes() {
return getBytes(trustStore, passphrase);
}

public char[] getPassphrase() {
return passphrase;
}

private byte[] getBytes(KeyStore store, char[] passphrase) {
try {
return toByteArray(store, passphrase);
} catch (GeneralSecurityException | IOException e) {
throw new RuntimeException(e);
}
}

private TlsHelper() throws Exception {
// Initialize a new KeyPair generator
KeyPairGenerator keyPairGenerator = KeyPairGenerator.getInstance(KEY_ALGORITHM, BC_PROVIDER);
keyPairGenerator.initialize(2048);

LocalDateTime now = LocalDateTime.now();
Date notBefore = Date.from(now.minusDays(1).toInstant(ZoneOffset.UTC));
Date notAfter = Date.from(now.plusYears(1).toInstant(ZoneOffset.UTC));

KeyPair rootKeyPair = keyPairGenerator.generateKeyPair();
X509Certificate rootCert = buildCACertificate(rootKeyPair, notBefore, notAfter);

KeyPair issuedCertKeyPair = keyPairGenerator.generateKeyPair();
Certificate issuedCert = buildServerCertificate(issuedCertKeyPair, rootKeyPair, rootCert, notBefore, notAfter);

rootCA = rootCert;

trustStore = KeyStore.getInstance("JKS");
trustStore.load(null, passphrase);
trustStore.setCertificateEntry("CACert", rootCert);

keyStore = KeyStore.getInstance(PKCS12, BC_PROVIDER);
keyStore.load(null, passphrase);
keyStore.setKeyEntry("localhost", issuedCertKeyPair.getPrivate(), null, new Certificate[] {
issuedCert,
rootCert
});
keyStore.setCertificateEntry("CACert", rootCert);
}

private X509Certificate buildCACertificate(KeyPair keyPair, Date notBefore, Date notAfter)
throws OperatorCreationException, IOException, GeneralSecurityException {

BigInteger rootSerialNum = new BigInteger(Long.toString(new SecureRandom().nextLong()));

// Issued By and Issued To same for root certificate
X500Name rootCertIssuer = new X500Name("CN=root-cert");
X500Name rootCertSubject = rootCertIssuer;
ContentSigner rootCertContentSigner = new JcaContentSignerBuilder(SIGNATURE_ALGORITHM).setProvider(BC_PROVIDER)
.build(keyPair.getPrivate());
X509v3CertificateBuilder rootCertBuilder = new JcaX509v3CertificateBuilder(rootCertIssuer, rootSerialNum,
notBefore, notAfter, rootCertSubject, keyPair.getPublic());

// Add Extensions
// A BasicConstraint to mark root certificate as CA certificate
JcaX509ExtensionUtils rootCertExtUtils = new JcaX509ExtensionUtils();
rootCertBuilder.addExtension(Extension.basicConstraints, true, new BasicConstraints(true));
rootCertBuilder.addExtension(Extension.subjectKeyIdentifier, false,
rootCertExtUtils.createSubjectKeyIdentifier(keyPair.getPublic()));

// Create a cert holder and export to X509Certificate
X509CertificateHolder rootCertHolder = rootCertBuilder.build(rootCertContentSigner);
return new JcaX509CertificateConverter().setProvider(BC_PROVIDER)
.getCertificate(rootCertHolder);
}

private Certificate buildServerCertificate(KeyPair keyPair, KeyPair signerKeyPair, X509Certificate signerCert, Date notBefore, Date notAfter)
throws GeneralSecurityException, IOException, OperatorCreationException {

// Generate a new KeyPair and sign it using the Root Cert Private Key
// by generating a CSR (Certificate Signing Request)
X500Name issuedCertSubject = new X500Name("CN=localhost,O=com.github.streamshub");
BigInteger issuedCertSerialNum = new BigInteger(Long.toString(new SecureRandom().nextLong()));

PKCS10CertificationRequestBuilder p10Builder = new JcaPKCS10CertificationRequestBuilder(issuedCertSubject,
keyPair.getPublic());
JcaContentSignerBuilder csrBuilder = new JcaContentSignerBuilder(SIGNATURE_ALGORITHM).setProvider(BC_PROVIDER);

// Sign the new KeyPair with the root cert Private Key
ContentSigner csrContentSigner = csrBuilder.build(signerKeyPair.getPrivate());
PKCS10CertificationRequest csr = p10Builder.build(csrContentSigner);

// Use the Signed KeyPair and CSR to generate an issued Certificate
// Here serial number is randomly generated. In general, CAs use
// a sequence to generate Serial number and avoid collisions
var issuer = new X500Name(signerCert.getSubjectX500Principal().getName());
X509v3CertificateBuilder issuedCertBuilder = new X509v3CertificateBuilder(issuer, issuedCertSerialNum,
notBefore, notAfter, csr.getSubject(), csr.getSubjectPublicKeyInfo());

JcaX509ExtensionUtils issuedCertExtUtils = new JcaX509ExtensionUtils();

// Add Extensions
// Use BasicConstraints to say that this Cert is not a CA
issuedCertBuilder.addExtension(Extension.basicConstraints, true, new BasicConstraints(false));

// Add Issuer cert identifier as Extension
issuedCertBuilder.addExtension(Extension.authorityKeyIdentifier, false, issuedCertExtUtils.createAuthorityKeyIdentifier(signerCert));

// Add intended key usage extension if needed
issuedCertBuilder.addExtension(Extension.keyUsage, false, new KeyUsage(KeyUsage.digitalSignature | KeyUsage.keyEncipherment));

// Add DNS name is cert is to used for SSL
GeneralNames subjectAltName = new GeneralNames(new GeneralName[] {
new GeneralName(GeneralName.dNSName, "localhost")
});
issuedCertBuilder.addExtension(Extension.subjectAlternativeName, false, subjectAltName);

X509CertificateHolder issuedCertHolder = issuedCertBuilder.build(csrContentSigner);
X509Certificate issuedCert = new JcaX509CertificateConverter().setProvider(BC_PROVIDER)
.getCertificate(issuedCertHolder);

// Verify the issued cert signature against the root (issuer) cert
issuedCert.verify(signerCert.getPublicKey(), BC_PROVIDER);
return issuedCert;
}

private byte[] toByteArray(KeyStore store, char[] passphrase) throws GeneralSecurityException, IOException {
ByteArrayOutputStream buffer = new ByteArrayOutputStream();
store.store(buffer, passphrase);
return buffer.toByteArray();
}

private String pemEncodeCertificate(Certificate certificate) throws IOException, CertificateEncodingException {
ByteArrayOutputStream certificateOut = new ByteArrayOutputStream();
certificateOut.write("-----BEGIN CERTIFICATE-----\n".getBytes(StandardCharsets.UTF_8));
certificateOut.write(Base64.getMimeEncoder(80, new byte[] {'\n'}).encode(certificate.getEncoded()));
certificateOut.write("\n-----END CERTIFICATE-----\n".getBytes(StandardCharsets.UTF_8));
certificateOut.close();
return new String(certificateOut.toByteArray(), StandardCharsets.UTF_8);
}
}
5 changes: 5 additions & 0 deletions operator/pom.xml
Original file line number Diff line number Diff line change
@@ -40,6 +40,11 @@
<groupId>jakarta.validation</groupId>
<artifactId>jakarta.validation-api</artifactId>
</dependency>
<dependency>
<groupId>io.quarkus</groupId>
<artifactId>quarkus-hibernate-validator</artifactId>
</dependency>

<!-- Needed to generate OLM bundle. If you're not interested in this,
you can remove this dependency -->
<dependency>
Original file line number Diff line number Diff line change
@@ -5,9 +5,12 @@
import java.util.Collections;
import java.util.Map;
import java.util.concurrent.TimeUnit;
import java.util.function.Predicate;
import java.util.stream.Collectors;

import com.github.streamshub.console.api.v1alpha1.Console;
import com.github.streamshub.console.api.v1alpha1.status.Condition;
import com.github.streamshub.console.api.v1alpha1.status.ConditionBuilder;
import com.github.streamshub.console.dependents.ConsoleClusterRole;
import com.github.streamshub.console.dependents.ConsoleClusterRoleBinding;
import com.github.streamshub.console.dependents.ConsoleDeployment;
@@ -23,6 +26,7 @@
import com.github.streamshub.console.dependents.PrometheusDeployment;
import com.github.streamshub.console.dependents.PrometheusService;
import com.github.streamshub.console.dependents.PrometheusServiceAccount;
import com.github.streamshub.console.dependents.ConfigurationProcessor;
import com.github.streamshub.console.dependents.conditions.DeploymentReadyCondition;
import com.github.streamshub.console.dependents.conditions.IngressReadyCondition;
import com.github.streamshub.console.dependents.conditions.PrometheusPrecondition;
@@ -56,25 +60,33 @@
interval = 60,
timeUnit = TimeUnit.SECONDS),
dependents = {
@Dependent(
name = ConfigurationProcessor.NAME,
type = ConfigurationProcessor.class,
readyPostcondition = ConfigurationProcessor.Postcondition.class),
@Dependent(
name = PrometheusClusterRole.NAME,
type = PrometheusClusterRole.class,
dependsOn = ConfigurationProcessor.NAME,
reconcilePrecondition = PrometheusPrecondition.class),
@Dependent(
name = PrometheusServiceAccount.NAME,
type = PrometheusServiceAccount.class,
dependsOn = ConfigurationProcessor.NAME,
reconcilePrecondition = PrometheusPrecondition.class),
@Dependent(
name = PrometheusClusterRoleBinding.NAME,
type = PrometheusClusterRoleBinding.class,
reconcilePrecondition = PrometheusPrecondition.class,
dependsOn = {
ConfigurationProcessor.NAME,
PrometheusClusterRole.NAME,
PrometheusServiceAccount.NAME
}),
@Dependent(
name = PrometheusConfigMap.NAME,
type = PrometheusConfigMap.class,
dependsOn = ConfigurationProcessor.NAME,
reconcilePrecondition = PrometheusPrecondition.class),
@Dependent(
name = PrometheusDeployment.NAME,
@@ -94,10 +106,12 @@
}),
@Dependent(
name = ConsoleClusterRole.NAME,
type = ConsoleClusterRole.class),
type = ConsoleClusterRole.class,
dependsOn = ConfigurationProcessor.NAME),
@Dependent(
name = ConsoleServiceAccount.NAME,
type = ConsoleServiceAccount.class),
type = ConsoleServiceAccount.class,
dependsOn = ConfigurationProcessor.NAME),
@Dependent(
name = ConsoleClusterRoleBinding.NAME,
type = ConsoleClusterRoleBinding.class,
@@ -114,10 +128,12 @@
}),
@Dependent(
name = ConsoleSecret.NAME,
type = ConsoleSecret.class),
type = ConsoleSecret.class,
dependsOn = ConfigurationProcessor.NAME),
@Dependent(
name = ConsoleService.NAME,
type = ConsoleService.class),
type = ConsoleService.class,
dependsOn = ConfigurationProcessor.NAME),
@Dependent(
name = ConsoleIngress.NAME,
type = ConsoleIngress.class,
@@ -199,7 +215,8 @@ public Map<String, EventSource> prepareEventSources(EventSourceContext<Console>
@Override
public UpdateControl<Console> reconcile(Console resource, Context<Console> context) {
determineReadyCondition(resource, context);
return UpdateControl.patchStatus(resource);
resource.getStatus().clearStaleConditions();
return UpdateControl.updateStatus(resource);
}

@Override
@@ -209,11 +226,6 @@ public ErrorStatusUpdateControl<Console> updateErrorStatus(Console resource,

determineReadyCondition(resource, context);

var status = resource.getOrCreateStatus();
var warning = status.getCondition("Warning");
warning.setStatus("True");
warning.setReason("ReconcileException");

Throwable rootCause = e;

while (rootCause.getCause() != null && rootCause.getCause() != rootCause) {
@@ -230,8 +242,19 @@ public ErrorStatusUpdateControl<Console> updateErrorStatus(Console resource,
message = rootCause.getMessage();
}

warning.setMessage(message);
return ErrorStatusUpdateControl.patchStatus(resource);
var status = resource.getStatus();

status.updateCondition(new ConditionBuilder()
.withType(Condition.Types.ERROR)
.withStatus("True")
.withLastTransitionTime(Instant.now().toString())
.withReason(Condition.Reasons.RECONCILIATION_EXCEPTION)
.withMessage(message)
.build());

status.clearStaleConditions();

return ErrorStatusUpdateControl.updateStatus(resource);
}

@Override
@@ -243,11 +266,11 @@ private void determineReadyCondition(Console resource, Context<Console> context)
var result = context.managedDependentResourceContext().getWorkflowReconcileResult();
var status = resource.getOrCreateStatus();
var readyCondition = status.getCondition("Ready");
var notReady = result.map(r -> r.getNotReadyDependents());
boolean isReady = notReady.filter(Collection::isEmpty).map(r -> Boolean.TRUE)
.orElse(Boolean.FALSE);
var notReady = result.map(r -> r.getNotReadyDependents()).filter(Predicate.not(Collection::isEmpty));
boolean isReady = notReady.isEmpty();

String readyStatus = isReady ? "True" : "False";
readyCondition.setActive(true);

if (!readyStatus.equals(readyCondition.getStatus())) {
readyCondition.setStatus(readyStatus);
@@ -257,15 +280,20 @@ private void determineReadyCondition(Console resource, Context<Console> context)
if (isReady) {
readyCondition.setReason(null);
readyCondition.setMessage("All resources ready");
status.clearCondition("Warning");
} else {
readyCondition.setReason("DependentsNotReady");
readyCondition.setMessage(notReady.map(Collection::stream)
.map(deps -> "Resources not ready: %s"
.formatted(deps.map(ConsoleResource.class::cast)
var notReadyResources = notReady.get();

if (notReadyResources.stream().anyMatch(ConfigurationProcessor.class::isInstance)) {
readyCondition.setReason(Condition.Reasons.INVALID_CONFIGURATION);
readyCondition.setMessage("Console resource configuration is invalid");
} else {
readyCondition.setReason(Condition.Reasons.DEPENDENTS_NOT_READY);
readyCondition.setMessage("Resources not ready: %s"
.formatted(notReadyResources.stream()
.map(ConsoleResource.class::cast)
.map(r -> "%s[%s]".formatted(r.getClass().getSimpleName(), r.instanceName(resource)))
.collect(Collectors.joining("; "))))
.orElse(""));
.collect(Collectors.joining("; "))));
}
}
}
}
Original file line number Diff line number Diff line change
@@ -5,6 +5,7 @@

import com.fasterxml.jackson.annotation.JsonInclude;
import com.github.streamshub.console.api.v1alpha1.spec.metrics.MetricsSource;
import com.github.streamshub.console.api.v1alpha1.spec.security.GlobalSecurity;

import io.fabric8.generator.annotation.Required;
import io.fabric8.kubernetes.api.model.EnvVar;
@@ -28,6 +29,8 @@ public class ConsoleSpec {

Images images;

GlobalSecurity security;

List<MetricsSource> metricsSources;

List<SchemaRegistry> schemaRegistries;
@@ -52,6 +55,14 @@ public void setImages(Images images) {
this.images = images;
}

public GlobalSecurity getSecurity() {
return security;
}

public void setSecurity(GlobalSecurity security) {
this.security = security;
}

public List<MetricsSource> getMetricsSources() {
return metricsSources;
}
Original file line number Diff line number Diff line change
@@ -2,6 +2,7 @@

import com.fasterxml.jackson.annotation.JsonInclude;
import com.fasterxml.jackson.annotation.JsonPropertyDescription;
import com.github.streamshub.console.api.v1alpha1.spec.security.KafkaSecurity;

import io.fabric8.generator.annotation.Required;
import io.fabric8.generator.annotation.ValidationRule;
@@ -51,6 +52,14 @@ public class KafkaCluster {

private Credentials credentials;

@JsonPropertyDescription("""
Security configuration to be applied only to this Kafka cluster. This \
includes the configuration of subjects (e.g. non-OIDC Kafka users), role \
policies for this cluster's resources, and audit rules for access to \
cluster's resources.
""")
private KafkaSecurity security;

@JsonPropertyDescription("""
Name of a configured Prometheus metrics source to use for this Kafka \
cluster to display resource utilization charts in the console.
@@ -111,6 +120,14 @@ public void setCredentials(Credentials credentials) {
this.credentials = credentials;
}

public KafkaSecurity getSecurity() {
return security;
}

public void setSecurity(KafkaSecurity security) {
this.security = security;
}

public String getMetricsSource() {
return metricsSource;
}
Original file line number Diff line number Diff line change
@@ -4,15 +4,18 @@
import com.fasterxml.jackson.annotation.JsonProperty;
import com.fasterxml.jackson.annotation.JsonPropertyDescription;

import io.fabric8.generator.annotation.Required;
import io.sundr.builder.annotations.Buildable;

@Buildable
@JsonInclude(JsonInclude.Include.NON_NULL)
public class TrustStore {

@Required
@JsonProperty("type")
private Type type; // NOSONAR

@Required
@JsonProperty("content")
@JsonPropertyDescription("Content of the trust store")
private Value content;
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
package com.github.streamshub.console.api.v1alpha1.spec;

import com.fasterxml.jackson.annotation.JsonIgnore;
import com.fasterxml.jackson.annotation.JsonInclude;
import com.fasterxml.jackson.annotation.JsonProperty;
import com.fasterxml.jackson.annotation.JsonPropertyDescription;
@@ -19,18 +18,6 @@ public class Value {
@JsonPropertyDescription("Reference to an external source to use for this value")
private ValueReference valueFrom;

public Value() {
}

private Value(String value) {
this.value = value;
}

@JsonIgnore
public static Value of(String value) {
return value != null ? new Value(value) : null;
}

public String getValue() {
return value;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
package com.github.streamshub.console.api.v1alpha1.spec.security;

import java.util.Locale;

import com.fasterxml.jackson.annotation.JsonCreator;

import io.fabric8.generator.annotation.Required;
import io.sundr.builder.annotations.Buildable;

@Buildable(editableEnabled = false)
public class AuditRule extends Rule {

@Required
Decision decision;

public Decision getDecision() {
return decision;
}

public void setDecision(Decision decision) {
this.decision = decision;
}

public enum Decision {
ALLOWED,
DENIED,
ALL;

@JsonCreator
public static Decision forValue(String value) {
if ("*".equals(value)) {
return ALL;
}
return valueOf(value.toUpperCase(Locale.ROOT));
}
}

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
package com.github.streamshub.console.api.v1alpha1.spec.security;

import io.sundr.builder.annotations.Buildable;

@Buildable(editableEnabled = false)
public class GlobalSecurity extends Security {

private Oidc oidc;

public Oidc getOidc() {
return oidc;
}

public void setOidc(Oidc oidc) {
this.oidc = oidc;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
package com.github.streamshub.console.api.v1alpha1.spec.security;

import io.sundr.builder.annotations.Buildable;

@Buildable(editableEnabled = false)
public class KafkaSecurity extends Security {
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
package com.github.streamshub.console.api.v1alpha1.spec.security;

import com.fasterxml.jackson.annotation.JsonPropertyDescription;
import com.github.streamshub.console.api.v1alpha1.spec.TrustStore;
import com.github.streamshub.console.api.v1alpha1.spec.Value;

import io.fabric8.generator.annotation.Required;
import io.sundr.builder.annotations.Buildable;

@Buildable(editableEnabled = false)
public class Oidc {

@Required
private String authServerUrl;
private String issuer;
@Required
private String clientId;
@Required
private Value clientSecret;

@JsonPropertyDescription("""
Trust store configuration for when the OIDC provider uses \
TLS certificates signed by an unknown CA.
""")
private TrustStore trustStore;

public String getAuthServerUrl() {
return authServerUrl;
}

public void setAuthServerUrl(String authServerUrl) {
this.authServerUrl = authServerUrl;
}

public String getIssuer() {
return issuer;
}

public void setIssuer(String issuer) {
this.issuer = issuer;
}

public String getClientId() {
return clientId;
}

public void setClientId(String clientId) {
this.clientId = clientId;
}

public Value getClientSecret() {
return clientSecret;
}

public void setClientSecret(Value clientSecret) {
this.clientSecret = clientSecret;
}

public TrustStore getTrustStore() {
return trustStore;
}

public void setTrustStore(TrustStore trustStore) {
this.trustStore = trustStore;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
package com.github.streamshub.console.api.v1alpha1.spec.security;

import java.util.List;

import io.fabric8.generator.annotation.Required;
import io.sundr.builder.annotations.Buildable;

@Buildable(editableEnabled = false)
public class Role {

@Required
private String name;

private List<Rule> rules;

public String getName() {
return name;
}

public void setName(String name) {
this.name = name;
}

public List<Rule> getRules() {
return rules;
}

public void setRules(List<Rule> rules) {
this.rules = rules;
}

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
package com.github.streamshub.console.api.v1alpha1.spec.security;

import java.util.List;
import java.util.Locale;

import com.fasterxml.jackson.annotation.JsonCreator;

import io.fabric8.generator.annotation.Required;
import io.sundr.builder.annotations.Buildable;

@Buildable(editableEnabled = false)
public class Rule {

/**
* Resources to which this rule applies (required)
*/
@Required
List<String> resources;

/**
* Specific resource names to which this rule applies (optional)
*/
List<String> resourceNames;

/**
* Privileges/actions that may be performed for subjects having this rule
*/
@Required
List<Privilege> privileges;

public List<String> getResources() {
return resources;
}

public void setResources(List<String> resources) {
this.resources = resources;
}

public List<String> getResourceNames() {
return resourceNames;
}

public void setResourceNames(List<String> resourceNames) {
this.resourceNames = resourceNames;
}

public List<Privilege> getPrivileges() {
return privileges;
}

public void setPrivileges(List<Privilege> privileges) {
this.privileges = privileges;
}

public enum Privilege {
CREATE,
DELETE,
GET,
LIST,
UPDATE,
ALL;

@JsonCreator
public static Privilege forValue(String value) {
if ("*".equals(value)) {
return ALL;
}
return valueOf(value.toUpperCase(Locale.ROOT));
}
}

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
package com.github.streamshub.console.api.v1alpha1.spec.security;

import java.util.List;

import io.sundr.builder.annotations.Buildable;

@Buildable(editableEnabled = false)
public abstract class Security {

private List<Subject> subjects;
private List<Role> roles;
private List<AuditRule> audit;

public List<Subject> getSubjects() {
return subjects;
}

public void setSubjects(List<Subject> subjects) {
this.subjects = subjects;
}

public List<Role> getRoles() {
return roles;
}

public void setRoles(List<Role> roles) {
this.roles = roles;
}

public List<AuditRule> getAudit() {
return audit;
}

public void setAudit(List<AuditRule> audit) {
this.audit = audit;
}

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
package com.github.streamshub.console.api.v1alpha1.spec.security;

import java.util.List;

import io.fabric8.generator.annotation.Required;
import io.sundr.builder.annotations.Buildable;

@Buildable(editableEnabled = false)
public class Subject {

private String claim;
@Required
private List<String> include;
private List<String> roleNames;

public String getClaim() {
return claim;
}

public void setClaim(String claim) {
this.claim = claim;
}

public List<String> getInclude() {
return include;
}

public void setInclude(List<String> include) {
this.include = include;
}

public List<String> getRoleNames() {
return roleNames;
}

public void setRoleNames(List<String> roleNames) {
this.roleNames = roleNames;
}

}
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
package com.github.streamshub.console.api.v1alpha1.status;

import java.util.Objects;

import com.fasterxml.jackson.annotation.JsonIgnore;
import com.fasterxml.jackson.annotation.JsonInclude;
import com.fasterxml.jackson.annotation.JsonPropertyDescription;

@@ -15,6 +18,9 @@ public class Condition {
private String type;
private String lastTransitionTime;

@JsonIgnore
private boolean active = false;

@JsonPropertyDescription("The status of the condition, either True, False or Unknown.")
public String getStatus() {
return status;
@@ -61,4 +67,73 @@ public String getMessage() {
public void setMessage(String message) {
this.message = message;
}

public boolean isActive() {
return active;
}

public void setActive(boolean active) {
this.active = active;
}

@Override
public int hashCode() {
return Objects.hash(message, reason, status, type);
}

/**
* For the purposes of equality, we do not consider the
* {@link lastTransitionTime} or {@link active}. The {@link active} flag is only
* used within a single reconcile cycle and determines which conditions should
* be set in the CR status and which are no longer relevant and may be removed.
*/
@Override
public boolean equals(Object obj) {
if (this == obj)
return true;
if (obj == null)
return false;
if (!(obj instanceof Condition))
return false;
Condition other = (Condition) obj;
return Objects.equals(message, other.message)
&& Objects.equals(reason, other.reason)
&& Objects.equals(status, other.status)
&& Objects.equals(type, other.type);
}

@Override
public String toString() {
return """
{ \
type = "%s", \
status = "%s", \
reason = "%s", \
message = "%s", \
lastTransitionTime = "%s" \
}""".formatted(type, status, reason, message, lastTransitionTime);
}

/**
* Constant values for the types used for conditions
*/
public static final class Types {
private Types() {
}

public static final String READY = "Ready";
public static final String ERROR = "Error";
}

/**
* Constant values for the reasons used for conditions
*/
public static final class Reasons {
private Reasons() {
}

public static final String DEPENDENTS_NOT_READY = "DependentsNotReady";
public static final String INVALID_CONFIGURATION = "InvalidConfiguration";
public static final String RECONCILIATION_EXCEPTION = "ReconciliationException";
}
}
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
package com.github.streamshub.console.api.v1alpha1.status;

import java.time.Instant;
import java.util.ArrayList;
import java.util.List;
import java.util.Comparator;
import java.util.Set;
import java.util.TreeSet;
import java.util.function.Predicate;

import com.fasterxml.jackson.annotation.JsonIgnore;
import com.fasterxml.jackson.annotation.JsonInclude;
@@ -14,14 +16,20 @@
@JsonInclude(JsonInclude.Include.NON_NULL)
public class ConsoleStatus extends ObservedGenerationAwareStatus {

private List<Condition> conditions = new ArrayList<>();
private final Set<Condition> conditions = new TreeSet<>(Comparator
.comparing(Condition::getType).reversed()
.thenComparing(Condition::getLastTransitionTime)
.thenComparing(Condition::getStatus, Comparator.nullsLast(String::compareTo))
.thenComparing(Condition::getReason, Comparator.nullsLast(String::compareTo))
.thenComparing(Condition::getMessage, Comparator.nullsLast(String::compareTo)));

public List<Condition> getConditions() {
public Set<Condition> getConditions() {
return conditions;
}

public void setConditions(List<Condition> conditions) {
this.conditions = conditions;
@JsonIgnore
public boolean hasCondition(String type) {
return conditions.stream().anyMatch(c -> type.equals(c.getType()));
}

@JsonIgnore
@@ -40,7 +48,19 @@ public Condition getCondition(String type) {
}

@JsonIgnore
public void clearCondition(String type) {
conditions.removeIf(c -> type.equals(c.getType()));
public void updateCondition(Condition condition) {
condition.setActive(true);

conditions.stream()
.filter(condition::equals)
.findFirst()
.ifPresentOrElse(
c -> c.setActive(true),
() -> conditions.add(condition));
}

@JsonIgnore
public void clearStaleConditions() {
conditions.removeIf(Predicate.not(Condition::isActive));
}
}

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
@@ -67,9 +67,12 @@ protected Deployment desired(Console primary, Context<Console> context) {

var envVars = new ArrayList<>(coalesce(primary.getSpec().getEnv(), Collections::emptyList));

var trustResources = getTrustResources(context);
var trustResources = getTrustResources("TrustStoreResources", context);
envVars.addAll(getResourcesByType(trustResources, EnvVar.class));

var trustResourcesUI = getTrustResources("TrustStoreResourcesUI", context);
var envVarsUI = getResourcesByType(trustResourcesUI, EnvVar.class);

return desired.edit()
.editMetadata()
.withName(name)
@@ -97,11 +100,13 @@ protected Deployment desired(Console primary, Context<Console> context) {
.addAllToVolumes(getResourcesByType(trustResources, Volume.class))
.editMatchingContainer(c -> "console-api".equals(c.getName()))
.withImage(imageAPI)
.withImagePullPolicy(pullPolicy(imageAPI))
.addAllToVolumeMounts(getResourcesByType(trustResources, VolumeMount.class))
.addAllToEnv(envVars)
.endContainer()
.editMatchingContainer(c -> "console-ui".equals(c.getName()))
.withImage(imageUI)
.withImagePullPolicy(pullPolicy(imageUI))
.editMatchingEnv(env -> "NEXTAUTH_URL".equals(env.getName()))
.withValue(getAttribute(context, ConsoleIngress.NAME + ".url", String.class))
.endEnv()
@@ -112,6 +117,7 @@ protected Deployment desired(Console primary, Context<Console> context) {
.endSecretKeyRef()
.endValueFrom()
.endEnv()
.addAllToEnv(envVarsUI)
.endContainer()
.endSpec()
.endTemplate()
@@ -120,8 +126,8 @@ protected Deployment desired(Console primary, Context<Console> context) {
}

@SuppressWarnings("unchecked")
<R extends KubernetesResource> Map<Class<R>, List<R>> getTrustResources(Context<Console> context) {
return context.managedDependentResourceContext().getMandatory("TrustStoreResources", Map.class);
<R extends KubernetesResource> Map<Class<R>, List<R>> getTrustResources(String key, Context<Console> context) {
return context.managedDependentResourceContext().getMandatory(key, Map.class);
}

@SuppressWarnings("unchecked")
@@ -130,4 +136,8 @@ <R extends KubernetesResource> List<R> getResourcesByType(
Class<R> key) {
return (List<R>) resources.getOrDefault(key, Collections.emptyList());
}

private String pullPolicy(String image) {
return image.contains("sha256:") ? "IfNotPresent" : "Always";
}
}
Original file line number Diff line number Diff line change
@@ -6,7 +6,6 @@
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.util.Base64;
import java.util.Comparator;
import java.util.HexFormat;
import java.util.LinkedHashMap;
@@ -17,6 +16,7 @@
import java.util.function.Supplier;

import com.github.streamshub.console.api.v1alpha1.Console;
import com.github.streamshub.console.dependents.support.ConfigSupport;

import io.fabric8.kubernetes.api.model.HasMetadata;
import io.javaoperatorsdk.operator.api.reconciler.Context;
@@ -105,16 +105,14 @@ default String serializeDigest(Context<Console> context, String digestName) {
}

default String encodeString(String value) {
return Base64.getEncoder().encodeToString(value.getBytes(StandardCharsets.UTF_8));
return ConfigSupport.encodeString(value);
}

default String decodeString(String encodedValue) {
return new String(Base64.getDecoder().decode(encodedValue), StandardCharsets.UTF_8);
return ConfigSupport.decodeString(encodedValue);
}

default <T> List<T> coalesce(List<T> value, Supplier<List<T>> defaultValue) {
return value != null ? value : defaultValue.get();
}


}

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
package com.github.streamshub.console.dependents.conditions;

import java.util.Objects;

import org.jboss.logging.Logger;

import com.github.streamshub.console.api.v1alpha1.Console;
@@ -18,10 +20,47 @@ public boolean isMet(DependentResource<Deployment, Console> dependentResource, C
return dependentResource.getSecondaryResource(primary, context).map(this::isReady).orElse(false);
}

/**
* Check the deployment's status in a similar way to kubectl.
*
* @see https://github.com/kubernetes/kubectl/blob/24d21a0ee42ecb5e5bed731f36b2d2c9c0244c35/pkg/polymorphichelpers/rollout_status.go#L76-L89
*/
private boolean isReady(Deployment deployment) {
var readyReplicas = deployment.getStatus().getReadyReplicas();
var ready = deployment.getSpec().getReplicas().equals(readyReplicas);
LOGGER.debugf("Deployment %s ready: %s", deployment.getMetadata().getName(), ready);
return ready;
String deploymentName = deployment.getMetadata().getName();
var status = deployment.getStatus();
var deploymentTimedOut = status.getConditions().stream()
.filter(c -> "Progressing".equals(c.getType()))
.findFirst()
.map(c -> "ProgressDeadlineExceeded".equals(c.getReason()))
.orElse(false)
.booleanValue();

if (deploymentTimedOut) {
LOGGER.warnf("Deployment %s has timed out", deployment.getMetadata().getName());
return false;
}

var desiredReplicas = deployment.getSpec().getReplicas();
var replicas = Objects.requireNonNullElse(status.getReplicas(), 0);
var updatedReplicas = Objects.requireNonNullElse(status.getUpdatedReplicas(), 0);
var availableReplicas = Objects.requireNonNullElse(status.getAvailableReplicas(), 0);

if (desiredReplicas != null && updatedReplicas < desiredReplicas) {
LOGGER.debugf("Waiting for deployment %s rollout to finish: %d out of %d new replicas have been updated...", deploymentName, updatedReplicas, desiredReplicas);
return false;
}

if (replicas > updatedReplicas) {
LOGGER.debugf("Waiting for deployment %s rollout to finish: %d old replicas are pending termination...", deploymentName, replicas - updatedReplicas);
return false;
}

if (availableReplicas < updatedReplicas) {
LOGGER.debugf("Waiting for deployment %s rollout to finish: %d of %d updated replicas are available...", deploymentName, availableReplicas, updatedReplicas);
return false;
}

LOGGER.debugf("Deployment %s ready", deployment.getMetadata().getName());
return true;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,175 @@
package com.github.streamshub.console.dependents.support;

import java.nio.charset.StandardCharsets;
import java.util.Base64;
import java.util.Map;
import java.util.Optional;
import java.util.function.Function;

import com.github.streamshub.console.ReconciliationException;
import com.github.streamshub.console.api.v1alpha1.Console;
import com.github.streamshub.console.api.v1alpha1.spec.ConfigVars;
import com.github.streamshub.console.api.v1alpha1.spec.Value;
import com.github.streamshub.console.api.v1alpha1.spec.ValueReference;

import io.fabric8.kubernetes.api.model.ConfigMap;
import io.fabric8.kubernetes.api.model.ConfigMapKeySelector;
import io.fabric8.kubernetes.api.model.HasMetadata;
import io.fabric8.kubernetes.api.model.Secret;
import io.fabric8.kubernetes.api.model.SecretKeySelector;
import io.fabric8.kubernetes.client.KubernetesClientException;
import io.javaoperatorsdk.operator.api.reconciler.Context;

public class ConfigSupport {

private ConfigSupport() {
}

public static String encodeString(String value) {
return encodeBytes(value.getBytes(StandardCharsets.UTF_8));
}

public static String encodeBytes(byte[] value) {
return Base64.getEncoder().encodeToString(value);
}

public static String decodeString(String encodedValue) {
return new String(decodeBytes(encodedValue), StandardCharsets.UTF_8);
}

public static byte[] decodeBytes(String encodedValue) {
return Base64.getDecoder().decode(encodedValue);
}

public static void setConfigVars(Console primary, Context<Console> context, Map<String, String> target, ConfigVars source) {
String namespace = primary.getMetadata().getNamespace();

source.getValuesFrom().stream().forEach(fromSource -> {
String prefix = fromSource.getPrefix();
var configMapRef = fromSource.getConfigMapRef();
var secretRef = fromSource.getSecretRef();

if (configMapRef != null) {
copyData(context, target, ConfigMap.class, namespace, configMapRef.getName(), prefix, configMapRef.getOptional(), ConfigMap::getData);
}

if (secretRef != null) {
copyData(context, target, Secret.class, namespace, secretRef.getName(), prefix, secretRef.getOptional(), Secret::getData);
}
});

source.getValues().forEach(configVar -> target.put(configVar.getName(), configVar.getValue()));
}

@SuppressWarnings("java:S107") // Ignore Sonar warning for too many args
public static <S extends HasMetadata> void copyData(Context<Console> context,
Map<String, String> target,
Class<S> sourceType,
String namespace,
String name,
String prefix,
Boolean optional,
Function<S, Map<String, String>> dataProvider) {

S source = getResource(context, sourceType, namespace, name, Boolean.TRUE.equals(optional));

if (source != null) {
copyData(target, dataProvider.apply(source), prefix, Secret.class.equals(sourceType));
}
}

public static void copyData(Map<String, String> target, Map<String, String> source, String prefix, boolean decode) {
source.forEach((key, value) -> {
if (prefix != null) {
key = prefix + key;
}
target.put(key, decode ? decodeString(value) : value);
});
}

/**
* Fetch the value from the given valueSpec. The return value
* will be the decoded raw bytes from the data source.
*/
public static byte[] getValue(Context<Console> context, String namespace, Value valueSpec) {
if (valueSpec == null) {
return null; // NOSONAR : empty array is not wanted when the valueSpec is null
}

return Optional.ofNullable(valueSpec.getValue())
.map(ConfigSupport::toBytes)
.or(() -> Optional.ofNullable(valueSpec.getValueFrom())
.map(ValueReference::getConfigMapKeyRef)
.flatMap(ref -> getValue(context, namespace, ref)))
.or(() -> Optional.ofNullable(valueSpec.getValueFrom())
.map(ValueReference::getSecretKeyRef)
.flatMap(ref -> getValue(context, namespace, ref)))
.orElse(null);
}

private static byte[] toBytes(String value) {
return value.getBytes(StandardCharsets.UTF_8);
}

private static Optional<byte[]> getValue(Context<Console> context,
String namespace,
ConfigMapKeySelector ref) {

ConfigMap source = getResource(context, ConfigMap.class, namespace, ref.getName(), Boolean.TRUE.equals(ref.getOptional()));

if (source != null) {
return Optional.ofNullable(source.getData())
.map(data -> data.get(ref.getKey()))
.map(ConfigSupport::toBytes)
.or(() -> Optional.ofNullable(source.getBinaryData())
.map(data -> data.get(ref.getKey()))
.map(ConfigSupport::decodeBytes));
}

return Optional.empty();
}

private static Optional<byte[]> getValue(Context<Console> context,
String namespace,
SecretKeySelector ref) {

Secret source = getResource(context, Secret.class, namespace, ref.getName(), Boolean.TRUE.equals(ref.getOptional()));

if (source != null) {
return Optional.ofNullable(source.getData())
.map(data -> data.get(ref.getKey()))
.map(ConfigSupport::decodeBytes);
}

return Optional.empty();
}

public static <T extends HasMetadata> T getResource(
Context<Console> context, Class<T> resourceType, String namespace, String name) {
return getResource(context, resourceType, namespace, name, false);
}

public static <T extends HasMetadata> T getResource(
Context<Console> context, Class<T> resourceType, String namespace, String name, boolean optional) {

T resource;

try {
resource = context.getClient()
.resources(resourceType)
.inNamespace(namespace)
.withName(name)
.get();
} catch (KubernetesClientException e) {
throw new ReconciliationException("Failed to retrieve %s resource: %s/%s. Message: %s"
.formatted(resourceType.getSimpleName(), namespace, name, e.getMessage()));
}

if (resource == null && !optional) {
throw new ReconciliationException("No such %s resource: %s/%s".formatted(resourceType.getSimpleName(), namespace, name));
}

return resource;
}

}
Original file line number Diff line number Diff line change
@@ -13,7 +13,7 @@ spec:
spec:
serviceAccountName: placeholder
volumes:
- name: cache
- name: work
emptyDir: {}
- name: config
secret:
@@ -29,9 +29,13 @@ spec:
- name: config
mountPath: /deployments/console-config.yaml
subPath: console-config.yaml
- name: work
mountPath: /deployments/work
env:
- name: CONSOLE_CONFIG_PATH
value: /deployments/console-config.yaml
- name: CONSOLE_WORK_PATH
value: /deployments/work
startupProbe:
httpGet:
path: /health/started
@@ -75,11 +79,11 @@ spec:
- name: console-ui
image: quay.io/streamshub/console-ui
volumeMounts:
- name: cache
mountPath: /app/.next/cache
- name: config
mountPath: /app/console-config.yaml
subPath: console-config.yaml
- name: work
mountPath: /app/.next/cache
ports:
- containerPort: 3000
name: http

Large diffs are not rendered by default.

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
@@ -0,0 +1,317 @@
package com.github.streamshub.console;

import java.time.Duration;
import java.util.Base64;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.UUID;
import java.util.function.Consumer;

import jakarta.inject.Inject;

import org.eclipse.microprofile.config.Config;
import org.jboss.logging.Logger;
import org.junit.jupiter.api.BeforeEach;

import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.dataformat.yaml.YAMLFactory;
import com.github.streamshub.console.api.v1alpha1.Console;
import com.github.streamshub.console.api.v1alpha1.ConsoleBuilder;
import com.github.streamshub.console.api.v1alpha1.status.Condition;
import com.github.streamshub.console.config.ConsoleConfig;
import com.github.streamshub.console.dependents.ConsoleResource;
import com.github.streamshub.console.dependents.ConsoleSecret;

import io.fabric8.kubernetes.api.model.ConfigMap;
import io.fabric8.kubernetes.api.model.HasMetadata;
import io.fabric8.kubernetes.api.model.NamespaceBuilder;
import io.fabric8.kubernetes.api.model.ObjectMetaBuilder;
import io.fabric8.kubernetes.api.model.Secret;
import io.fabric8.kubernetes.api.model.apiextensions.v1.CustomResourceDefinitionBuilder;
import io.fabric8.kubernetes.api.model.apps.Deployment;
import io.fabric8.kubernetes.api.model.networking.v1.Ingress;
import io.fabric8.kubernetes.client.KubernetesClient;
import io.javaoperatorsdk.operator.Operator;
import io.strimzi.api.kafka.Crds;
import io.strimzi.api.kafka.model.kafka.Kafka;
import io.strimzi.api.kafka.model.kafka.KafkaBuilder;
import io.strimzi.api.kafka.model.kafka.listener.KafkaListenerAuthenticationScramSha512;
import io.strimzi.api.kafka.model.kafka.listener.KafkaListenerType;
import io.strimzi.api.kafka.model.user.KafkaUser;

import static org.awaitility.Awaitility.await;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertTrue;

abstract class ConsoleReconcilerTestBase {

private static final Logger LOGGER = Logger.getLogger(ConsoleReconcilerTestBase.class);

protected static final Duration LIMIT = Duration.ofSeconds(10);
protected static final ObjectMapper YAML = new ObjectMapper(new YAMLFactory());

protected static final String KAFKA_NS = "ns1";
protected static final String KAFKA_NAME = "kafka-1";

protected static final String CONSOLE_NS = "ns2";
protected static final String CONSOLE_NAME = "console-1";

@Inject
KubernetesClient client;

@Inject
Config config;

@Inject
Operator operator;

Kafka kafkaCR;

public static <T extends HasMetadata> T apply(KubernetesClient client, T resource) {
client.resource(resource).serverSideApply();
return client.resource(resource).patchStatus();
}

@BeforeEach
void setUp() {
client.resource(Crds.kafka()).serverSideApply();
client.resource(Crds.kafkaUser()).serverSideApply();
client.resource(new CustomResourceDefinitionBuilder()
.withNewMetadata()
.withName("routes.route.openshift.io")
.endMetadata()
.withNewSpec()
.withScope("Namespaced")
.withGroup("route.openshift.io")
.addNewVersion()
.withName("v1")
.withNewSubresources()
.withNewStatus()
.endStatus()
.endSubresources()
.withNewSchema()
.withNewOpenAPIV3Schema()
.withType("object")
.withXKubernetesPreserveUnknownFields(true)
.endOpenAPIV3Schema()
.endSchema()
.withStorage(true)
.withServed(true)
.endVersion()
.withNewNames()
.withSingular("route")
.withPlural("routes")
.withKind("Route")
.endNames()
.endSpec()
.build())
.serverSideApply();

var allConsoles = client.resources(Console.class).inAnyNamespace();
var allKafkas = client.resources(Kafka.class).inAnyNamespace();
var allKafkaUsers = client.resources(KafkaUser.class).inAnyNamespace();
var allDeployments = client.resources(Deployment.class).inAnyNamespace().withLabels(ConsoleResource.MANAGEMENT_LABEL);
var allConfigMaps = client.resources(ConfigMap.class).inAnyNamespace().withLabels(ConsoleResource.MANAGEMENT_LABEL);
var allSecrets = client.resources(Secret.class).inAnyNamespace().withLabels(ConsoleResource.MANAGEMENT_LABEL);
var allIngresses = client.resources(Ingress.class).inAnyNamespace().withLabels(ConsoleResource.MANAGEMENT_LABEL);

allConsoles.delete();
allKafkas.delete();
allKafkaUsers.delete();
allDeployments.delete();
allConfigMaps.delete();
allSecrets.delete();
allIngresses.delete();

await().atMost(LIMIT).untilAsserted(() -> {
assertTrue(allConsoles.list().getItems().isEmpty());
assertTrue(allKafkas.list().getItems().isEmpty());
assertTrue(allKafkaUsers.list().getItems().isEmpty());
assertTrue(allDeployments.list().getItems().isEmpty());
assertTrue(allConfigMaps.list().getItems().isEmpty());
assertTrue(allSecrets.list().getItems().isEmpty());
assertTrue(allIngresses.list().getItems().isEmpty());
});

operator.start();

client.resource(new NamespaceBuilder()
.withNewMetadata()
.withName(KAFKA_NS)
.withLabels(Map.of("streamshub-operator/test", "true"))
.endMetadata()
.build())
.serverSideApply();

kafkaCR = new KafkaBuilder()
.withNewMetadata()
.withName(KAFKA_NAME)
.withNamespace(KAFKA_NS)
.endMetadata()
.withNewSpec()
.withNewKafka()
.addNewListener()
.withName("listener1")
.withType(KafkaListenerType.INGRESS)
.withPort(9093)
.withTls(true)
.withAuth(new KafkaListenerAuthenticationScramSha512())
.endListener()
.endKafka()
.endSpec()
.withNewStatus()
.withClusterId(UUID.randomUUID().toString())
.addNewListener()
.withName("listener1")
.addNewAddress()
.withHost("kafka-bootstrap.example.com")
.withPort(9093)
.endAddress()
.endListener()
.endStatus()
.build();

kafkaCR = apply(client, kafkaCR);

client.resource(new NamespaceBuilder()
.withNewMetadata()
.withName(CONSOLE_NS)
.withLabels(Map.of("streamshub-operator/test", "true"))
.endMetadata()
.build())
.serverSideApply();
}

Console createConsole(ConsoleBuilder builder) {
var meta = new ObjectMetaBuilder(builder.getMetadata())
.withNamespace(CONSOLE_NS)
.withName(CONSOLE_NAME)
.build();

builder = builder.withMetadata(meta);

return client.resource(builder.build()).create();
}

void awaitReady(Console resource) {
await().ignoreException(NullPointerException.class).atMost(LIMIT).untilAsserted(() -> {
var console = client.resources(Console.class)
.inNamespace(resource.getMetadata().getNamespace())
.withName(resource.getMetadata().getName())
.get();

assertEquals(1, console.getStatus().getConditions().size());
var condition = console.getStatus().getConditions().iterator().next();

assertEquals(Condition.Types.READY, condition.getType(), condition::toString);
assertEquals("True", condition.getStatus(), condition::toString);
assertNull(condition.getReason());
assertEquals("All resources ready", condition.getMessage(), condition::toString);
});
}

void awaitDependentsNotReady(Console resource, String... dependents) {
await().ignoreException(NullPointerException.class).atMost(LIMIT).untilAsserted(() -> {
var console = client.resources(Console.class)
.inNamespace(resource.getMetadata().getNamespace())
.withName(resource.getMetadata().getName())
.get();

assertEquals(1, console.getStatus().getConditions().size());
var condition = console.getStatus().getConditions().iterator().next();

assertEquals(Condition.Types.READY, condition.getType(), condition::toString);
assertEquals("False", condition.getStatus(), condition::toString);
assertEquals(Condition.Reasons.DEPENDENTS_NOT_READY, condition.getReason(), condition::toString);

for (String dependent : dependents) {
assertTrue(condition.getMessage().contains(dependent));
}
});
}

void assertInvalidConfiguration(Console resource, Consumer<List<Condition>> assertion) {
await().ignoreException(NullPointerException.class).atMost(LIMIT).untilAsserted(() -> {
var console = client.resources(Console.class)
.inNamespace(resource.getMetadata().getNamespace())
.withName(resource.getMetadata().getName())
.get();

var conditions = console.getStatus().getConditions();
assertTrue(conditions.size() > 1);

var readyCondition = conditions.iterator().next();
assertEquals(Condition.Types.READY, readyCondition.getType(), readyCondition::toString);
assertEquals("False", readyCondition.getStatus(), readyCondition::toString);
assertEquals(Condition.Reasons.INVALID_CONFIGURATION, readyCondition.getReason(), readyCondition::toString);

// Ready is always sorted as the first condition for ease of reference
List<Condition> errors = List.copyOf(conditions).subList(1, conditions.size());

assertion.accept(errors);
});
}

void assertConsoleConfig(Consumer<ConsoleConfig> assertion) {
await().ignoreException(NullPointerException.class).atMost(LIMIT).untilAsserted(() -> {
var consoleSecret = client.secrets()
.inNamespace(CONSOLE_NS)
.withName(CONSOLE_NAME + "-" + ConsoleSecret.NAME)
.get();

assertNotNull(consoleSecret);

String configEncoded = consoleSecret.getData().get("console-config.yaml");
byte[] configDecoded = Base64.getDecoder().decode(configEncoded);

LOGGER.debugf("config YAML: %s", new String(configDecoded));

ConsoleConfig consoleConfig = YAML.readValue(configDecoded, ConsoleConfig.class);
assertion.accept(consoleConfig);
});
}

void setConsoleIngressReady(Console consoleCR) {
var consoleIngress = client.network().v1().ingresses()
.inNamespace(consoleCR.getMetadata().getNamespace())
.withName("%s-console-ingress".formatted(consoleCR.getMetadata().getName()))
.get();

consoleIngress = consoleIngress.edit()
.editOrNewStatus()
.withNewLoadBalancer()
.addNewIngress()
.withHostname("ingress.example.com")
.endIngress()
.endLoadBalancer()
.endStatus()
.build();
client.resource(consoleIngress).patchStatus();
LOGGER.info("Set ingress status for Console ingress");
}

Deployment setDeploymentReady(Console consoleCR, String deploymentName) {
var deployment = client.apps().deployments()
.inNamespace(consoleCR.getMetadata().getNamespace())
.withName("%s-%s".formatted(consoleCR.getMetadata().getName(), deploymentName))
.editStatus(this::setReady);
LOGGER.infof("Set ready replicas for deployment: %s", deploymentName);
return deployment;
}

Deployment setReady(Deployment deployment) {
int desiredReplicas = Optional.ofNullable(deployment.getSpec().getReplicas()).orElse(1);

return deployment.edit()
.editOrNewStatus()
.withReplicas(desiredReplicas)
.withUpdatedReplicas(desiredReplicas)
.withAvailableReplicas(desiredReplicas)
.withReadyReplicas(desiredReplicas)
.endStatus()
.build();
}
}
Binary file not shown.
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
-----BEGIN CERTIFICATE-----
MIIDMjCCAhqgAwIBAgIIMYTYDdowPTswDQYJKoZIhvcNAQELBQAwNzESMBAGA1UE
CxMJb3BlbnNoaWZ0MSEwHwYDVQQDExhrdWJlLWFwaXNlcnZlci1sYi1zaWduZXIw
HhcNMjQxMDIxMDc1NjU5WhcNMzQxMDE5MDc1NjU5WjA3MRIwEAYDVQQLEwlvcGVu
c2hpZnQxITAfBgNVBAMTGGt1YmUtYXBpc2VydmVyLWxiLXNpZ25lcjCCASIwDQYJ
KoZIhvcNAQEBBQADggEPADCCAQoCggEBALnWRIe7l/94zj624Ax8lGDdfoppPO9i
EtnkBBjAhkfe3ChnL33b+edeGf9lfIxZYbMVng+tEZhq2RHrp40ZSA1BZ74TwTaQ
1FfaSLU1dMNIWvgudNQMcgDNXTxXRamup5/wZ5udKYUBLVPvFEvmJ+je9QCwEGKQ
JrpDX+aKJOLPKyxVox6ZcqBTKJts+/f6fEqrbDwdlQhGAZRSsfXYZgufSSvRO7gN
67tp3KJ9OhuEzvkMoSFvvQxPmxVlWGKdkZFdKNU1vSI0aOBLlCWHOBmk+Wvu/fTi
iuBybWooaQIvu3CSEXGSJaQ+b4Ol/Uj0EO70Q58HzFndst6wxwD+ZGECAwEAAaNC
MEAwDgYDVR0PAQH/BAQDAgKkMA8GA1UdEwEB/wQFMAMBAf8wHQYDVR0OBBYEFAj8
c+01Kl+BDcIolkavVzAU1G41MA0GCSqGSIb3DQEBCwUAA4IBAQBL97dVCrkgo6ca
7/4nZoP1i7owpWV0hfJWU/TKSFBa7Vzbe7xWyT/HBzjPikoUwZEpH7rZAcHwcYQr
tTozW/zDOZS98cnrepY/tAXVi7Hz5wnuaguI3iwFaIVh9OR8FBZ5TAMaXGW1mYEg
q0jNZY5cbFm+3bacRKSF//hS/3nms3o3b6uni2f4rZGED4iW+zK2qXZfz+B/uCwA
1KoHt3TxZsJ8scVXCMJQi7T0cdjR9pGucCRVFoXKxGE0sIL28ajBdOAIalTh/Wh4
F6n73xM2Ao84Qi1qk6HrWJHQb1DY/L/+tZc5TlEHbAZwHR3Y8nYpxTlvNYTcuft+
dGv8WhYX
-----END CERTIFICATE-----
-----BEGIN CERTIFICATE-----
MIIDQDCCAiigAwIBAgIINykiDqNyBqwwDQYJKoZIhvcNAQELBQAwPjESMBAGA1UE
CxMJb3BlbnNoaWZ0MSgwJgYDVQQDEx9rdWJlLWFwaXNlcnZlci1sb2NhbGhvc3Qt
c2lnbmVyMB4XDTI0MTAyMTA3NTY1OFoXDTM0MTAxOTA3NTY1OFowPjESMBAGA1UE
CxMJb3BlbnNoaWZ0MSgwJgYDVQQDEx9rdWJlLWFwaXNlcnZlci1sb2NhbGhvc3Qt
c2lnbmVyMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAw7zTqYxszydv
LjATLq9ca+rf9URZfiBhKSVWFbM7tRH/gUVsdTPsmq35FfUKFCn6T9UHT+rgOGKA
Z+PqMoDAMOe2QAYPdPUGPHgy5Op2iLfFPagUuOA28avqdryRUXfjMqZpx0EEg6kp
X9O5nOfVKBNYdSlWB5ZGvWl4rUuuUyU+OrzDnyvozRrEvbUt0bLMX4JdYT3u7mlB
MAP+UXKg3qxes3huHP7PSbXRGCV2o5zZzmy0WSxPx2xg0BN3DQnIqxYx8o2w0A8O
Cy1tZAduQZWP33uluFMubRCws3pjRsIUJozhM+POjbEI3e2nmwPRmIuwkJ+u3gYV
58tlkahuBwIDAQABo0IwQDAOBgNVHQ8BAf8EBAMCAqQwDwYDVR0TAQH/BAUwAwEB
/zAdBgNVHQ4EFgQUSCtp8dg6T1kO1V7KFkeP6Ioo7RYwDQYJKoZIhvcNAQELBQAD
ggEBACTBDHiGLxRzkT++oWh8n9aYVo9kmq3pCy5xyh0vDGY5YublnrLIX0bf8aL9
rpGrs9WKqi2z/1HtUm6XNJP9eO+Vt08FUP63RSkPQpU+w4qwbsKqu6frbyyIddpK
caUDWA1ggor3aV7umz2F4n6wVg2HDKDBrR/JHeEpWFOeXX2KeDFkomTot93RtCkd
yo6Y/6PYVFfj+SW9rI+b1WQ9U0BqRjgFzRPzp9wryx6c8n05mTcH3C6vxwrifr8v
NLaxI+xVaMtLALJ6pK1lVAPOkS0J5JaewZqOCXySTf2lKn6KC0xJYC0vHdKf27oi
UU33fkByf2O1XayqoEfCXjZ6uPA=
-----END CERTIFICATE-----
-----BEGIN CERTIFICATE-----
MIIDTDCCAjSgAwIBAgIIJM+hLK9EGE0wDQYJKoZIhvcNAQELBQAwRDESMBAGA1UE
CxMJb3BlbnNoaWZ0MS4wLAYDVQQDEyVrdWJlLWFwaXNlcnZlci1zZXJ2aWNlLW5l
dHdvcmstc2lnbmVyMB4XDTI0MTAyMTA3NTY1OVoXDTM0MTAxOTA3NTY1OVowRDES
MBAGA1UECxMJb3BlbnNoaWZ0MS4wLAYDVQQDEyVrdWJlLWFwaXNlcnZlci1zZXJ2
aWNlLW5ldHdvcmstc2lnbmVyMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKC
AQEAoSQzZgP2w02xuh7zlmzAN4hYg2Saz9NMescrUlfCCtqNIVJPCCTx45m2tbhF
jUNv84zjtrJ81BOugyEGQchdjJGoKUdGNcpVUC1Ts/jrRnyuVrmvifjgRl1lFi6u
l5G0jGsgv7Z2W1JT5EpIfaA0qlsUnecdQtm7qienUeWRO9HfYJ08eNaUF+zAB6JA
i8I6AzTl6rJT33EmPymNpXrHFVgr/IDHs2jFakjTauPIGScRxFRze0JTMSvlz/8j
YIu6g59THds1ROm2+NYblcES3zuZeHQ9n4iRalIH8pQ8LQ9lQJ79S4dkYYo++Klu
W60erjZt20zdMgIKnwTtnAY0mQIDAQABo0IwQDAOBgNVHQ8BAf8EBAMCAqQwDwYD
VR0TAQH/BAUwAwEB/zAdBgNVHQ4EFgQUMWWSZ6Bg1Rs/dGd+Yvl4m05aDowwDQYJ
KoZIhvcNAQELBQADggEBAEc3IMR1duWEuwIqNQC42TX5upKEAM/d065A4kjiLn3k
ACyFoB7gqA55fy23kTDsAPqcRPeSvwJstUOxIq0eP1q+HUFbdPXa2ORiTmPihY/u
oagSkEMQvSi6vTFKl2wTrdpHZzfk1FgYn6kLwX8LYXyHziS6uRxQHPIvUmgCIs6I
MeHWgJh2rKhX37YD6aW7uv2waif5qs6/pUhDqVoafqmvXp4FMNBLbA6JDE8PcVHA
rFHOvhQJ0FtT4VPwll9/VR9aE7pWBZA6fwvbThWB8WILFGsXbF3bcuR0fu1WZte0
RtkV+Ps4/zb9TZAkdy1TcIRxvSxVrarHUJRNdtCZfUk=
-----END CERTIFICATE-----
-----BEGIN CERTIFICATE-----
MIIDlzCCAn+gAwIBAgIIK6oSarI03tkwDQYJKoZIhvcNAQELBQAwWTFXMFUGA1UE
AwxOb3BlbnNoaWZ0LWt1YmUtYXBpc2VydmVyLW9wZXJhdG9yX2xvY2FsaG9zdC1y
ZWNvdmVyeS1zZXJ2aW5nLXNpZ25lckAxNzI5NDk4Njk0MB4XDTI0MTAyMTA4MTgx
NFoXDTM0MTAxOTA4MTgxNVowWTFXMFUGA1UEAwxOb3BlbnNoaWZ0LWt1YmUtYXBp
c2VydmVyLW9wZXJhdG9yX2xvY2FsaG9zdC1yZWNvdmVyeS1zZXJ2aW5nLXNpZ25l
ckAxNzI5NDk4Njk0MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAyZPM
58DFXDJfatBHxoxJY2gpSI3rSxF7RHxmIghQxVQQHMD2ny4gkuflyrUYO2VnVZ+v
RfLxl0lhTdx0hLiKwniWqt6rmj+7/0oVbEW2cbWd+OVDos841LxhLZOwrHh+WcQW
spH3NgczP846uPg/yAKEUWX0xAej0lfD1//qr+VdUbhtx7xDl42Jzzt/Me9WS1Lh
J+tHU8Ooa+U2yTX/mGjQgwxfB4qoczoXvpvv1hO35g/wHiKeuOvUCmgzYrPQPCFK
3lx3ETKzC1m+MiRqYYqMDW4DCD43khSTF7XAbFjSMKqk6KrOi4xqhmjyTRZDyX7m
wRun84930lP3/U1ZgQIDAQABo2MwYTAOBgNVHQ8BAf8EBAMCAqQwDwYDVR0TAQH/
BAUwAwEB/zAdBgNVHQ4EFgQU6ZTgKTS9WAZ8+8OZZvy5Xu9wMnwwHwYDVR0jBBgw
FoAU6ZTgKTS9WAZ8+8OZZvy5Xu9wMnwwDQYJKoZIhvcNAQELBQADggEBAFO6LKg7
ILwywt/52kbfPRrEvpb5p3T4ANs1c50sU0YewbvT1phhbX0xG63kNm6isuZSLCie
7aNLDuEAjv4HmY4QffGvKHgyIQsII8+/W7JmS+nRgPEI6Yj3tJmy3gvN3X0xrBdt
S96+jCag1aR58zJ9imRaZOBNNlE4aedbvllFZ2k4Gk4BSZjqSJhNZSPaZmWUNsAH
nq/t16ZKs43aLtwqBRTI3ssGmcZjMTNeFVXVV0/WmjIRAnBvJHipzwZvZEyqw+EL
aJIz1fio6X1uGPyIBr4sp/p7Do0eUc+euEi1kLmctsDmntfJzD/WJLDh1qf7t/Ko
5NspdFu8g+UVpsg=
-----END CERTIFICATE-----
-----BEGIN CERTIFICATE-----
MIIDWzCCAkOgAwIBAgIIIO4v2+5yCt0wDQYJKoZIhvcNAQELBQAwJjEkMCIGA1UE
AwwbaW5ncmVzcy1vcGVyYXRvckAxNzI5NDk4Mjk0MB4XDTI0MTAyMTA4MTEzNVoX
DTI2MTAyMTA4MTEzNlowHTEbMBkGA1UEAwwSKi5hcHBzLWNyYy50ZXN0aW5nMIIB
IjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA46vu506RtAKNTOemgaD1kryD
x0QzhjqyaS6XLUhiEpimfaSl1erPJJyjpvPylZpXMPqodQbTVaCOhbmNPFOFygEL
sfB1mpIGKNokTtm2mHE+YhfMLhAOb0OuSAEybk3EqZGxLrUQwVp88owYJHe3bPAG
wG6mMR6Sn/eL3shxpWfgpmVYnATyt2/7qpdTequxyOhasr2KIxD4ScvCwzycgTGS
xqEV2rSczmyZRWNdyw8p+V8394Uow5r7W9s2mUYC/KF6fvAs2RavKtSFD7ZLoBBW
xJmkzrT1uq5duOGgadnKZBo3IMj45zG8PooZ3cDepAVk3+vG3MfTpWHvZFOdywID
AQABo4GVMIGSMA4GA1UdDwEB/wQEAwIFoDATBgNVHSUEDDAKBggrBgEFBQcDATAM
BgNVHRMBAf8EAjAAMB0GA1UdDgQWBBS82jT8YPTXx+3VZ4i5DEfntaJzxzAfBgNV
HSMEGDAWgBTp+7an7Jz7tRjmpnBveqo1hpNIsTAdBgNVHREEFjAUghIqLmFwcHMt
Y3JjLnRlc3RpbmcwDQYJKoZIhvcNAQELBQADggEBAFZ7O3aoPG+OMnVuLlg6S6/I
d1UeyBLdftVWzeB2Y0yzCb6PCPyuj1CVT7k64mqFLCYp/HT7E5QgmsFUDz62Q0Os
bLhKoRcBnPZnk2m58QQf8h4rzvc1oEgrhhYP3KW+RLfmwfAIVLXDzhXhEOejaC+B
mkvYhRsYCW7X9hc/+UrhCWpLFXASXTtJIitbNRHVFfgRMm71mh9NhwFRAQBexwO6
0ZG0MKapIaYWVknGrNPDTauoXaxyX6WQxM3VqLujCzHKz2OQmsylIcCXG/xtUfhU
JjBImOIsbWvvE3KWfPCA+rXg1MzoUagmECfjkq9uABqooIUxxksCRTb1wqj3DW0=
-----END CERTIFICATE-----
-----BEGIN CERTIFICATE-----
MIIDDDCCAfSgAwIBAgIBATANBgkqhkiG9w0BAQsFADAmMSQwIgYDVQQDDBtpbmdy
ZXNzLW9wZXJhdG9yQDE3Mjk0OTgyOTQwHhcNMjQxMDIxMDgxMTMzWhcNMjYxMDIx
MDgxMTM0WjAmMSQwIgYDVQQDDBtpbmdyZXNzLW9wZXJhdG9yQDE3Mjk0OTgyOTQw
ggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQCpCyfKVGuk6af3hAdOqaPi
cEpF3NHMhZDfBHcnCSVz5U7NiBVkDBuYoQbHrFFNnnAO1TWH/ztwLtjH8odUelWa
84Ue7xpDOrCDgNBIEsB7ymoV4oyRw3DVuuC9kfAWx6+YhuP6hEltOVwJvXdC50A7
SKQTuDUSjF7VZF1RXQW8CBJO+2/cwuXhC+O9z3VHuSTzAUFDsycTnVwC8uB7Ycn/
7t/UVspP9Es0YMHlmdw6eobGm3xm14UqCKYkySygtXWPTfqPXonfDIMQeu0E0eil
Cg/TSSvm4CJB1u1JrpehzDsVUEOZPnBuO8axY2Rv5MBE3mMJVWzUN6oj5OOp06Gz
AgMBAAGjRTBDMA4GA1UdDwEB/wQEAwICpDASBgNVHRMBAf8ECDAGAQH/AgEAMB0G
A1UdDgQWBBTp+7an7Jz7tRjmpnBveqo1hpNIsTANBgkqhkiG9w0BAQsFAAOCAQEA
DIfuWUfB+lgrOE6qTpF5R+lbBu9oQr7XXLYFnOBjSdTr/V7tJr6GmBO5G9vVm57N
bAGQekVLDMtjvbbHtM3wmOW7O5g0wykMl/uHiHKbtfYEZ89CLxxdYOjQpzgzJHhF
QSfpvdFFG55+/9Gdb1yUJHZ5P54UgVGNtiX3Hnch/FwU5avPD6PAr5a4OrSp++/S
zB4Aw1vhO+4uage+j/TW6uF3YJQT/thVWG8z2vXJuej+i/HBjiviHMEQVdC13LaI
uy7oM4B/BrDebtUD+blCgOYZs24sWu2eiCtqKW5dtxveNcN/Hq1py4xPOqkESfic
29vnSmtl5vitgVMcMNKneQ==
-----END CERTIFICATE-----
12 changes: 8 additions & 4 deletions ui/app/api/auth/[...nextauth]/oidc.ts
Original file line number Diff line number Diff line change
@@ -3,7 +3,6 @@ import { Session, TokenSet } from "next-auth";
import { JWT } from "next-auth/jwt";
import { OAuthConfig } from "next-auth/providers/index";
import config from '@/utils/config';
import { redirect } from 'next/navigation'

const log = logger.child({ module: "oidc" });

@@ -14,7 +13,8 @@ class OpenIdConnect {
constructor(
authServerUrl: string | null,
clientId: string | null,
clientSecret: string | null
clientSecret: string | null,
truststore: string | null,
) {
if (clientId && clientSecret && authServerUrl) {
this.provider = {
@@ -34,6 +34,9 @@ class OpenIdConnect {
image: profile.image,
}
},
httpOptions: {
ca: truststore ?? undefined
}
}
} else {
this.provider = null;
@@ -51,7 +54,7 @@ class OpenIdConnect {
log.trace(`wellKnown endpoint: ${discoveryEndpoint}`);
const response = await fetch(discoveryEndpoint);
const discovery = await response.json();

_tokenEndpoint = discovery.token_endpoint;
log.trace(`token endpoint: ${_tokenEndpoint}`);

@@ -190,6 +193,7 @@ export default async function oidcSource() {
return new OpenIdConnect(
oidcConfig?.authServerUrl ?? null,
oidcConfig?.clientId ?? null,
oidcConfig?.clientSecret ?? null
oidcConfig?.clientSecret ?? null,
oidcConfig?.truststore ?? null,
);
};
2 changes: 2 additions & 0 deletions ui/environment.d.ts
Original file line number Diff line number Diff line change
@@ -6,5 +6,7 @@ namespace NodeJS {
NEXT_PUBLIC_PRODUCTIZED_BUILD?: "true" | "false";
LOG_LEVEL?: "fatal" | "error" | "warn" | "info" | "debug" | "trace";
CONSOLE_MODE?: "read-only" | "read-write";
CONSOLE_CONFIG_PATH: string;
CONSOLE_SECURITY_OIDC_TRUSTSTORE?: string;
}
}
2 changes: 2 additions & 0 deletions ui/utils/config.ts
Original file line number Diff line number Diff line change
@@ -10,6 +10,7 @@ export interface OidcConfig {
authServerUrl: string | null;
clientId: string | null;
clientSecret: string | null;
truststore: string | null;
}

export interface GlobalSecurityConfig {
@@ -37,6 +38,7 @@ async function getOrLoadConfig(): Promise<ConsoleConfig> {
authServerUrl: cfg.security?.oidc?.authServerUrl ?? null,
clientId: cfg.security?.oidc?.clientId ?? null,
clientSecret: cfg.security?.oidc?.clientSecret ?? null,
truststore: process.env.CONSOLE_SECURITY_OIDC_TRUSTSTORE ?? null,
}
}
};

0 comments on commit 49e2ad1

Please sign in to comment.