Skip to content

Commit

Permalink
Add support for properly configuring the CORS allowed origins header
Browse files Browse the repository at this point in the history
  • Loading branch information
EricWittmann committed Jan 23, 2025
1 parent 58606dd commit 3382f2c
Show file tree
Hide file tree
Showing 8 changed files with 225 additions and 17 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -10,4 +10,6 @@ public class EnvironmentVariables {
public static final String APICURIO_REST_DELETION_ARTIFACT_ENABLED = "APICURIO_REST_DELETION_ARTIFACT_ENABLED";
public static final String APICURIO_REST_DELETION_GROUP_ENABLED = "APICURIO_REST_DELETION_GROUP_ENABLED";

public static final String APICURIO_REST_MUTABILITY_ARTIFACT_VERSION_CONTENT_ENABLED = "APICURIO_REST_MUTABILITY_ARTIFACT-VERSION-CONTENT_ENABLED";

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
package io.apicurio.registry.operator.feat;

import io.apicurio.registry.operator.EnvironmentVariables;
import io.apicurio.registry.operator.api.v1.ApicurioRegistry3;
import io.apicurio.registry.operator.api.v1.ApicurioRegistry3Spec;
import io.apicurio.registry.operator.api.v1.spec.ComponentSpec;
import io.apicurio.registry.operator.resource.ResourceFactory;
import io.apicurio.registry.operator.utils.IngressUtils;
import io.fabric8.kubernetes.api.model.EnvVar;
import io.fabric8.kubernetes.api.model.EnvVarBuilder;

import java.util.Arrays;
import java.util.LinkedHashMap;
import java.util.Optional;
import java.util.Set;
import java.util.TreeSet;

/**
* Helper class used to handle CORS related configuration.
*/
public class Cors {
/**
* Configure the QUARKUS_HTTP_CORS_ORIGINS environment variable with the following:
* <ul>
* <li>Add the ingress host</li>
* <li>Include anything specifically configured for QUARKUS_HTTP_CORS_ORIGINS in the "env" section</li>
* </ul>
*
* @param primary
* @param envVars
*/
public static void configureAllowedOrigins(ApicurioRegistry3 primary,
LinkedHashMap<String, EnvVar> envVars) {
TreeSet<String> allowedOrigins = new TreeSet<>();

// If the QUARKUS_HTTP_CORS_ORIGINS env var is configured in the "env" section of the CR,
// then make sure to add those configured values to the set of allowed origins we want to
// configure.
Optional.ofNullable(primary.getSpec()).map(ApicurioRegistry3Spec::getApp).map(ComponentSpec::getEnv)
.ifPresent(env -> {
env.stream().filter(
envVar -> envVar.getName().equals(EnvironmentVariables.QUARKUS_HTTP_CORS_ORIGINS))
.forEach(envVar -> {
Optional.ofNullable(envVar.getValue()).ifPresent(envVarValue -> {
Arrays.stream(envVarValue.split(",")).forEach(allowedOrigins::add);
});
});
});

// If there is a configured Ingress host for the UI or the Studio UI, add them to the allowed origins.
Set.of(ResourceFactory.COMPONENT_UI, ResourceFactory.COMPONENT_STUDIO_UI).forEach(component -> {
String host = IngressUtils.getConfiguredHost(component, primary);
if (host != null) {
allowedOrigins.add("http://" + host);
allowedOrigins.add("https://" + host);
}
});

if (allowedOrigins.isEmpty()) {
allowedOrigins.add("*");
}

// Join the values in allowedOrigins into a String and set it as the new value of the env var.
String envVarValue = String.join(",", allowedOrigins);
envVars.put(EnvironmentVariables.QUARKUS_HTTP_CORS_ORIGINS, new EnvVarBuilder()
.withName(EnvironmentVariables.QUARKUS_HTTP_CORS_ORIGINS).withValue(envVarValue).build());
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
import io.apicurio.registry.operator.api.v1.spec.AppFeaturesSpec;
import io.apicurio.registry.operator.api.v1.spec.AppSpec;
import io.apicurio.registry.operator.api.v1.spec.StorageSpec;
import io.apicurio.registry.operator.feat.Cors;
import io.apicurio.registry.operator.feat.KafkaSql;
import io.apicurio.registry.operator.feat.PostgresSql;
import io.fabric8.kubernetes.api.model.Container;
Expand Down Expand Up @@ -60,7 +61,6 @@ protected Deployment desired(ApicurioRegistry3 primary, Context<ApicurioRegistry
// spotless:off
addEnvVar(envVars, new EnvVarBuilder().withName(EnvironmentVariables.QUARKUS_PROFILE).withValue("prod").build());
addEnvVar(envVars, new EnvVarBuilder().withName(EnvironmentVariables.QUARKUS_HTTP_ACCESS_LOG_ENABLED).withValue("true").build());
addEnvVar(envVars, new EnvVarBuilder().withName(EnvironmentVariables.QUARKUS_HTTP_CORS_ORIGINS).withValue("*").build());

// Enable deletes if configured in the CR
boolean allowDeletes = Optional.ofNullable(primary.getSpec().getApp())
Expand All @@ -72,18 +72,23 @@ protected Deployment desired(ApicurioRegistry3 primary, Context<ApicurioRegistry
addEnvVar(envVars, new EnvVarBuilder().withName(EnvironmentVariables.APICURIO_REST_DELETION_ARTIFACT_ENABLED).withValue("true").build());
addEnvVar(envVars, new EnvVarBuilder().withName(EnvironmentVariables.APICURIO_REST_DELETION_GROUP_ENABLED).withValue("true").build());
}
// spotless:on

// This is enabled only if Studio is deployed. It is based on Service in case a custom Ingress is
// used.
// Configure the CORS_ALLOWED_ORIGINS env var based on the ingress host
Cors.configureAllowedOrigins(primary, envVars);

// Enable the "mutability" feature in Registry, but only if Studio is deployed. It is based on Service
// in case a custom Ingress is used.
var sOpt = context.getSecondaryResource(STUDIO_UI_SERVICE_KEY.getKlass(),
STUDIO_UI_SERVICE_KEY.getDiscriminator());
sOpt.ifPresent(s -> {
addEnvVar(envVars,
new EnvVarBuilder().withName("APICURIO_REST_MUTABILITY_ARTIFACT-VERSION-CONTENT_ENABLED")
new EnvVarBuilder().withName(EnvironmentVariables.APICURIO_REST_MUTABILITY_ARTIFACT_VERSION_CONTENT_ENABLED)
.withValue("true").build());
});

// spotless:on

// Configure the storage (Postgresql or KafkaSql).
ofNullable(primary.getSpec()).map(ApicurioRegistry3Spec::getApp).map(AppSpec::getStorage)
.map(StorageSpec::getType).ifPresent(storageType -> {
switch (storageType) {
Expand All @@ -92,6 +97,7 @@ protected Deployment desired(ApicurioRegistry3 primary, Context<ApicurioRegistry
}
});

// Set the ENV VARs on the deployment's container spec.
var container = getContainerFromDeployment(d, REGISTRY_APP_CONTAINER_NAME);
container.setEnv(envVars.values().stream().toList());

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -28,21 +28,39 @@ public final class IngressUtils {
private IngressUtils() {
}

public static String getHost(String component, ApicurioRegistry3 p) {
/**
* Get the host configured in the ingress. If no host is configured in the ingress then a null is
* returned.
*
* @param component
* @param primary
*/
public static String getConfiguredHost(String component, ApicurioRegistry3 primary) {
String host = switch (component) {
case COMPONENT_APP -> ofNullable(p.getSpec()).map(ApicurioRegistry3Spec::getApp)
case COMPONENT_APP -> ofNullable(primary.getSpec()).map(ApicurioRegistry3Spec::getApp)
.map(AppSpec::getIngress).map(IngressSpec::getHost).filter(h -> !isBlank(h)).orElse(null);
case COMPONENT_UI -> ofNullable(p.getSpec()).map(ApicurioRegistry3Spec::getUi)
case COMPONENT_UI -> ofNullable(primary.getSpec()).map(ApicurioRegistry3Spec::getUi)
.map(UiSpec::getIngress).map(IngressSpec::getHost).filter(h -> !isBlank(h)).orElse(null);
case COMPONENT_STUDIO_UI ->
ofNullable(p.getSpec()).map(ApicurioRegistry3Spec::getStudioUi).map(StudioUiSpec::getIngress)
.map(IngressSpec::getHost).filter(h -> !isBlank(h)).orElse(null);
case COMPONENT_STUDIO_UI -> ofNullable(primary.getSpec()).map(ApicurioRegistry3Spec::getStudioUi)
.map(StudioUiSpec::getIngress).map(IngressSpec::getHost).filter(h -> !isBlank(h))
.orElse(null);
default -> throw new OperatorException("Unexpected value: " + component);
};
return host;
}

/**
* Get the host for an ingress. If not configured, a default value is returned.
*
* @param component
* @param primary
*/
public static String getHost(String component, ApicurioRegistry3 primary) {
String host = getConfiguredHost(component, primary);
if (host == null) {
// TODO: This is not used because of the current activation conditions.
host = "%s-%s.%s%s".formatted(p.getMetadata().getName(), component,
p.getMetadata().getNamespace(), Configuration.getDefaultBaseHost());
host = "%s-%s.%s%s".formatted(primary.getMetadata().getName(), component,
primary.getMetadata().getNamespace(), Configuration.getDefaultBaseHost());
}
log.debug("Host for component {} is {}", component, host);
return host;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,14 @@ public class Mapper {
YAML_MAPPER.configure(FAIL_ON_UNKNOWN_PROPERTIES, true);
}

public static <T> T deserialize(String data, Class<T> klass) {
try {
return YAML_MAPPER.readValue(data, klass);
} catch (JsonProcessingException ex) {
throw new OperatorException("Could not deserialize resource.", ex);
}
}

public static String toYAML(Object value) {
try {
return YAML_MAPPER.writeValueAsString(value);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,6 @@
import io.fabric8.kubernetes.api.model.EnvVar;
import io.quarkus.test.junit.QuarkusTest;
import org.junit.jupiter.api.Test;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import static io.apicurio.registry.operator.api.v1.ContainerNames.REGISTRY_APP_CONTAINER_NAME;
import static io.apicurio.registry.operator.resource.app.AppDeploymentResource.getContainerFromDeployment;
Expand All @@ -17,8 +15,6 @@
@QuarkusTest
public class AppFeaturesITTest extends ITBase {

private static final Logger log = LoggerFactory.getLogger(AppFeaturesITTest.class);

@Test
void testAllowDeletesTrue() {
ApicurioRegistry3 registry = ResourceFactory
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
package io.apicurio.registry.operator.it;

import io.apicurio.registry.operator.EnvironmentVariables;
import io.apicurio.registry.operator.api.v1.ApicurioRegistry3;
import io.apicurio.registry.operator.resource.ResourceFactory;
import io.fabric8.kubernetes.api.model.Container;
Expand All @@ -15,9 +16,11 @@

import java.net.URI;

import static io.apicurio.registry.operator.api.v1.ContainerNames.REGISTRY_APP_CONTAINER_NAME;
import static io.apicurio.registry.operator.api.v1.ContainerNames.REGISTRY_UI_CONTAINER_NAME;
import static io.apicurio.registry.operator.resource.ResourceFactory.COMPONENT_APP;
import static io.apicurio.registry.operator.resource.ResourceFactory.COMPONENT_UI;
import static io.apicurio.registry.operator.resource.app.AppDeploymentResource.getContainerFromDeployment;
import static io.restassured.RestAssured.given;
import static org.assertj.core.api.Assertions.assertThat;
import static org.awaitility.Awaitility.await;
Expand Down Expand Up @@ -70,6 +73,15 @@ void smoke() {
.get(0).getHost()).isEqualTo(registry.getSpec().getUi().getIngress().getHost());
return true;
});

// Check CORS allowed origins is set on the app, with the value based on the UI ingress host
var appEnv = getContainerFromDeployment(
client.apps().deployments().inNamespace(namespace)
.withName(registry.getMetadata().getName() + "-app-deployment").get(),
REGISTRY_APP_CONTAINER_NAME).getEnv();
assertThat(appEnv).map(ev -> ev.getName() + "=" + ev.getValue())
.contains(EnvironmentVariables.QUARKUS_HTTP_CORS_ORIGINS
+ "=http://simple-ui.apps.cluster.example,https://simple-ui.apps.cluster.example");
}

@Test
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
package io.apicurio.registry.operator.unit;

import io.apicurio.registry.operator.EnvironmentVariables;
import io.apicurio.registry.operator.OperatorException;
import io.apicurio.registry.operator.api.v1.ApicurioRegistry3;
import io.apicurio.registry.operator.feat.Cors;
import io.apicurio.registry.operator.utils.Mapper;
import io.fabric8.kubernetes.api.model.EnvVar;
import org.assertj.core.api.Assertions;
import org.junit.jupiter.api.Test;

import java.nio.charset.Charset;
import java.util.LinkedHashMap;
import java.util.Set;

public class CorsTest {

private static final String DEFAULT = """
apiVersion: registry.apicur.io/v1
kind: ApicurioRegistry3
metadata:
name: simple
spec: {}
""";

private static final String WITH_INGRESS = """
apiVersion: registry.apicur.io/v1
kind: ApicurioRegistry3
metadata:
name: simple
spec:
app:
ingress:
host: simple-app.apps.cluster.example
ui:
ingress:
host: simple-ui.apps.cluster.example
""";

private static final String WITH_ENV_VAR = """
apiVersion: registry.apicur.io/v1
kind: ApicurioRegistry3
metadata:
name: simple
spec:
app:
env:
- name: QUARKUS_HTTP_CORS_ORIGINS
value: https://ui.example.org
""";

private static final String WITH_ENV_VAR_AND_INGRESS = """
apiVersion: registry.apicur.io/v1
kind: ApicurioRegistry3
metadata:
name: simple
spec:
app:
ingress:
host: simple-app.apps.cluster.example
env:
- name: QUARKUS_HTTP_CORS_ORIGINS
value: https://ui.example.org
ui:
ingress:
host: simple-ui.apps.cluster.example
""";

@Test
public void testConfigureAllowedOrigins() throws Exception {
doTestAllowedOrigins(DEFAULT, "*");
doTestAllowedOrigins(WITH_INGRESS, "http://simple-ui.apps.cluster.example",
"https://simple-ui.apps.cluster.example");
doTestAllowedOrigins(WITH_ENV_VAR, "https://ui.example.org");
doTestAllowedOrigins(WITH_ENV_VAR_AND_INGRESS, "http://simple-ui.apps.cluster.example",
"https://simple-ui.apps.cluster.example", "https://ui.example.org");
}

private void doTestAllowedOrigins(String cr, String... values) {
ApicurioRegistry3 registry = Mapper.deserialize(cr, ApicurioRegistry3.class);

LinkedHashMap<String, EnvVar> envVars = new LinkedHashMap<>();
Cors.configureAllowedOrigins(registry, envVars);
Assertions.assertThat(envVars.keySet()).contains(EnvironmentVariables.QUARKUS_HTTP_CORS_ORIGINS);
String allowedOriginsValue = envVars.get(EnvironmentVariables.QUARKUS_HTTP_CORS_ORIGINS).getValue();
Set<String> allowedOrigins = Set.of(allowedOriginsValue.split(","));
Assertions.assertThat(allowedOrigins).containsExactlyInAnyOrder(values);
}

public static String load(String path) {
try (var stream = Thread.currentThread().getContextClassLoader().getResourceAsStream(path)) {
return new String(stream.readAllBytes(), Charset.defaultCharset());
} catch (Exception ex) {
throw new OperatorException("Could not read resource: " + path, ex);
}
}

}

0 comments on commit 3382f2c

Please sign in to comment.