diff --git a/api-sheriff/pom.xml b/api-sheriff/pom.xml index ba59ebc..a332555 100644 --- a/api-sheriff/pom.xml +++ b/api-sheriff/pom.xml @@ -30,6 +30,16 @@ cui-java-tools + + + com.fasterxml.jackson.dataformat + jackson-dataformat-yaml + + + com.networknt + json-schema-validator + + io.quarkus diff --git a/api-sheriff/src/main/java/de/cuioss/sheriff/api/config/ConfigLogMessages.java b/api-sheriff/src/main/java/de/cuioss/sheriff/api/config/ConfigLogMessages.java new file mode 100644 index 0000000..9c93f0d --- /dev/null +++ b/api-sheriff/src/main/java/de/cuioss/sheriff/api/config/ConfigLogMessages.java @@ -0,0 +1,76 @@ +/* + * Copyright © 2022 CUI-OpenSource-Software (info@cuioss.de) + * + * 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 de.cuioss.sheriff.api.config; + +import de.cuioss.tools.logging.LogRecord; +import de.cuioss.tools.logging.LogRecordModel; +import lombok.experimental.UtilityClass; + +/** + * DSL-style {@link LogRecord} catalogue for the configuration subsystem. + *

+ * Structured {@code INFO} / {@code ERROR} messages carry the {@code ApiSheriff} + * prefix and a stable numeric identifier, continuing the shared identifier space + * used by the proxy edge, so they are greppable and assertable. {@code DEBUG} / + * {@code TRACE} diagnostics use the logger directly and are not catalogued here. + *

+ * The catalogue lives in the framework-agnostic {@code ...config} package because + * the cui-tools {@link LogRecord} abstraction carries no framework dependency + * (ADR-0005). + * + * @author API Sheriff Team + * @since 1.0 + */ +@UtilityClass +public final class ConfigLogMessages { + + private static final String PREFIX = "ApiSheriff"; + + /** + * Info-level messages (INFO range 1-99). + */ + @UtilityClass + public static final class INFO { + + /** The configuration was loaded, validated, and assembled successfully. */ + public static final LogRecord CONFIG_LOADED = LogRecordModel.builder() + .prefix(PREFIX) + .identifier(2) + .template("Configuration loaded successfully (config_version='%s')") + .build(); + } + + /** + * Error-level messages (ERROR range 200-299). + */ + @UtilityClass + public static final class ERROR { + + /** A single collected configuration violation, one per problem found. */ + public static final LogRecord CONFIG_VALIDATION_FAILED = LogRecordModel.builder() + .prefix(PREFIX) + .identifier(200) + .template("Invalid configuration in '%s' at '%s': %s") + .build(); + + /** Startup is aborted because the configuration is invalid. */ + public static final LogRecord CONFIG_STARTUP_ABORTED = LogRecordModel.builder() + .prefix(PREFIX) + .identifier(201) + .template("Refusing to start — configuration is invalid: %s") + .build(); + } +} diff --git a/api-sheriff/src/main/java/de/cuioss/sheriff/api/config/RouteTableBuilder.java b/api-sheriff/src/main/java/de/cuioss/sheriff/api/config/RouteTableBuilder.java new file mode 100644 index 0000000..cc255d6 --- /dev/null +++ b/api-sheriff/src/main/java/de/cuioss/sheriff/api/config/RouteTableBuilder.java @@ -0,0 +1,211 @@ +/* + * Copyright © 2022 CUI-OpenSource-Software (info@cuioss.de) + * + * 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 de.cuioss.sheriff.api.config; + +import java.io.Serial; +import java.util.ArrayList; +import java.util.Collections; +import java.util.Comparator; +import java.util.EnumSet; +import java.util.List; +import java.util.Objects; +import java.util.Optional; + +import de.cuioss.sheriff.api.config.model.AuthConfig; +import de.cuioss.sheriff.api.config.model.EndpointConfig; +import de.cuioss.sheriff.api.config.model.GatewayConfig; +import de.cuioss.sheriff.api.config.model.HttpMethod; +import de.cuioss.sheriff.api.config.model.MatchConfig; +import de.cuioss.sheriff.api.config.model.MatchConfig.HeaderMatcher; +import de.cuioss.sheriff.api.config.model.Protocol; +import de.cuioss.sheriff.api.config.model.ResolvedRoute; +import de.cuioss.sheriff.api.config.model.ResolvedTopology; +import de.cuioss.sheriff.api.config.model.ResolvedUpstream; +import de.cuioss.sheriff.api.config.model.RouteConfig; +import de.cuioss.sheriff.api.config.model.RouteTable; +import de.cuioss.sheriff.api.config.model.UpstreamConfig; +import de.cuioss.sheriff.api.config.model.UpstreamDefaultsConfig; + +/** + * Assembles the immutable {@link RouteTable} from the validated configuration + * (pipeline step 8). + *

+ * The builder merges the routes of the enabled endpoints only — disabled + * endpoints contribute no rows — orders them by descending {@code path_prefix} + * length (most specific first), enforces same-prefix disjointness, and + * materializes each route's effective auth, effective {@code allowed_methods}, and + * effective retry / not-modified toggles into a {@link ResolvedRoute}. The three + * inheritance chains are resolved here, once, so the request pipeline never + * re-implements them. + *

+ * Framework-agnostic (ADR-0005): the collaborators are supplied as method + * arguments and the builder carries no framework imports. + * + * @author API Sheriff Team + * @since 1.0 + */ +public final class RouteTableBuilder { + + private static final List STANDARD_ALLOWED_METHODS = List.copyOf(EnumSet.allOf(HttpMethod.class)); + + /** + * Builds the route table from the enabled endpoints and the resolved topology. + * + * @param gateway the bound gateway document + * @param endpoints the endpoints to merge; disabled entries are skipped + * @param topology the resolved topology providing each endpoint's upstream + * @return the immutable, longest-prefix-ordered route table + * @throws RouteTableException when an enabled endpoint's alias does not resolve + * or two same-prefix routes are not disjoint + */ + public RouteTable build(GatewayConfig gateway, List endpoints, ResolvedTopology topology) { + Objects.requireNonNull(gateway, "gateway"); + Objects.requireNonNull(endpoints, "endpoints"); + Objects.requireNonNull(topology, "topology"); + + List resolved = new ArrayList<>(); + for (EndpointConfig endpoint : endpoints) { + if (!endpoint.enabled()) { + continue; + } + ResolvedUpstream upstream = topology.lookup(endpoint.baseUrl()).orElseThrow(() -> new RouteTableException( + "unresolved topology alias for enabled endpoint '%s': %s".formatted(endpoint.id(), + endpoint.baseUrl()))); + UpstreamDefaultsConfig defaults = resolveDefaults(gateway, endpoint); + List allowedMethods = effectiveAllowedMethods(gateway, endpoint); + for (RouteConfig route : endpoint.routes()) { + resolved.add(resolveRoute(route, endpoint, upstream, defaults, allowedMethods)); + } + } + + resolved.sort(Comparator.comparingInt((ResolvedRoute route) -> route.pathPrefix().length()).reversed() + .thenComparing(ResolvedRoute::pathPrefix)); + enforceDisjointness(resolved); + return new RouteTable(resolved); + } + + private static ResolvedRoute resolveRoute(RouteConfig route, EndpointConfig endpoint, ResolvedUpstream upstream, + UpstreamDefaultsConfig defaults, List allowedMethods) { + AuthConfig auth = route.auth().orElse(endpoint.auth()); + boolean retryEnabled = route.upstream() + .flatMap(UpstreamConfig::retry) + .flatMap(UpstreamConfig.Retry::enabled) + .orElse(defaults.retryEnabled()); + boolean notModifiedEnabled = route.upstream() + .flatMap(UpstreamConfig::notModified) + .flatMap(UpstreamConfig.NotModified::enabled) + .orElse(defaults.notModifiedEnabled()); + return ResolvedRoute.builder() + .id(route.id()) + .protocol(route.protocol().orElse(Protocol.HTTP)) + .match(route.match()) + .effectiveAuth(auth) + .effectiveAllowedMethods(allowedMethods) + .retryEnabled(retryEnabled) + .notModifiedEnabled(notModifiedEnabled) + .upstream(upstream) + .build(); + } + + private static UpstreamDefaultsConfig resolveDefaults(GatewayConfig gateway, EndpointConfig endpoint) { + UpstreamDefaultsConfig global = gateway.upstreamDefaults().orElseGet(UpstreamDefaultsConfig::defaults); + return endpoint.upstreamDefaults().orElse(global); + } + + private static List effectiveAllowedMethods(GatewayConfig gateway, EndpointConfig endpoint) { + if (!endpoint.allowedMethods().isEmpty()) { + return List.copyOf(endpoint.allowedMethods()); + } + if (!gateway.allowedMethods().isEmpty()) { + return List.copyOf(gateway.allowedMethods()); + } + return STANDARD_ALLOWED_METHODS; + } + + private static void enforceDisjointness(List routes) { + for (int i = 0; i < routes.size(); i++) { + for (int j = i + 1; j < routes.size(); j++) { + ResolvedRoute first = routes.get(i); + ResolvedRoute second = routes.get(j); + if (first.pathPrefix().equals(second.pathPrefix()) && overlaps(first.match(), second.match())) { + throw new RouteTableException( + "routes '%s' and '%s' share prefix '%s' and are not disjoint".formatted(first.id(), + second.id(), first.pathPrefix())); + } + } + } + } + + private static boolean overlaps(MatchConfig first, MatchConfig second) { + return hostsOverlap(first, second) && methodsOverlap(first, second) && !headersDistinguish(first, second); + } + + private static boolean hostsOverlap(MatchConfig first, MatchConfig second) { + return first.host().isEmpty() || second.host().isEmpty() || first.host().equals(second.host()); + } + + private static boolean methodsOverlap(MatchConfig first, MatchConfig second) { + return first.methods().isEmpty() || second.methods().isEmpty() + || !Collections.disjoint(first.methods(), second.methods()); + } + + private static boolean headersDistinguish(MatchConfig first, MatchConfig second) { + for (HeaderMatcher headerA : first.headers()) { + for (HeaderMatcher headerB : second.headers()) { + if (headerA.name().equalsIgnoreCase(headerB.name()) + && (valuesDistinguish(headerA, headerB) || presenceDistinguishes(headerA, headerB))) { + return true; + } + } + } + return false; + } + + private static boolean valuesDistinguish(HeaderMatcher headerA, HeaderMatcher headerB) { + Optional valueA = headerA.value(); + Optional valueB = headerB.value(); + return valueA.isPresent() && valueB.isPresent() && !valueA.get().equals(valueB.get()); + } + + private static boolean presenceDistinguishes(HeaderMatcher headerA, HeaderMatcher headerB) { + Optional presentA = headerA.present(); + Optional presentB = headerB.present(); + return presentA.isPresent() && presentB.isPresent() && !presentA.get().equals(presentB.get()); + } + + /** + * Signals a route-table assembly failure: an enabled endpoint whose alias does + * not resolve, or two same-prefix routes that are not disjoint. Both are boot + * failures for an otherwise structurally valid configuration. + * + * @author API Sheriff Team + * @since 1.0 + */ + public static final class RouteTableException extends IllegalStateException { + + @Serial + private static final long serialVersionUID = 1L; + + /** + * Creates the exception with the given detail message. + * + * @param message the human-readable description of the assembly failure + */ + public RouteTableException(String message) { + super(message); + } + } +} diff --git a/api-sheriff/src/main/java/de/cuioss/sheriff/api/config/load/ConfigError.java b/api-sheriff/src/main/java/de/cuioss/sheriff/api/config/load/ConfigError.java new file mode 100644 index 0000000..67f4d40 --- /dev/null +++ b/api-sheriff/src/main/java/de/cuioss/sheriff/api/config/load/ConfigError.java @@ -0,0 +1,51 @@ +/* + * Copyright © 2022 CUI-OpenSource-Software (info@cuioss.de) + * + * 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 de.cuioss.sheriff.api.config.load; + +import java.io.Serial; +import java.io.Serializable; +import java.util.Objects; + +/** + * A single configuration error, carrying the source file, the location within that + * file, and a human-readable message. + *

+ * The {@link ConfigLoader} collects these across the whole configuration set and + * raises them together as a {@link ConfigLoadException}, so an operator sees every + * problem in one boot attempt rather than one per restart. + * + * @param file the configuration file the error originates from (e.g. + * {@code gateway.yaml} or {@code endpoints/orders.yaml}) + * @param pointer the location within the file — a JSON-pointer / JSONPath-style + * instance location, empty for whole-file errors + * @param message the human-readable description of the problem + * @author API Sheriff Team + * @since 1.0 + */ +public record ConfigError(String file, String pointer, String message) implements Serializable { + + @Serial + private static final long serialVersionUID = 1L; + + /** + * Canonical constructor requiring all components to be non-null. + */ + public ConfigError { + Objects.requireNonNull(file, "file"); + Objects.requireNonNull(pointer, "pointer"); + Objects.requireNonNull(message, "message"); + } +} diff --git a/api-sheriff/src/main/java/de/cuioss/sheriff/api/config/load/ConfigLoadException.java b/api-sheriff/src/main/java/de/cuioss/sheriff/api/config/load/ConfigLoadException.java new file mode 100644 index 0000000..7b9d9af --- /dev/null +++ b/api-sheriff/src/main/java/de/cuioss/sheriff/api/config/load/ConfigLoadException.java @@ -0,0 +1,68 @@ +/* + * Copyright © 2022 CUI-OpenSource-Software (info@cuioss.de) + * + * 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 de.cuioss.sheriff.api.config.load; + +import java.io.Serial; +import java.io.Serializable; +import java.util.List; +import java.util.stream.Collectors; + +/** + * Raised when configuration loading fails, carrying the complete set of + * {@link ConfigError}s discovered in a single loading pass. + *

+ * The loader aggregates every schema violation, secret-resolution failure, and + * binding error rather than failing on the first, so this exception describes all + * problems at once. It is a fail-fast boot signal: the application refuses to start + * when it is thrown. + * + * @author API Sheriff Team + * @since 1.0 + */ +public class ConfigLoadException extends Exception implements Serializable { + + @Serial + private static final long serialVersionUID = 1L; + + private final List errors; + + /** + * Creates the exception from the collected errors. + * + * @param errors the non-empty, aggregated set of configuration errors; copied + * defensively into an unmodifiable list + */ + public ConfigLoadException(List errors) { + super(buildMessage(errors)); + this.errors = List.copyOf(errors); + } + + /** + * Returns the aggregated configuration errors. + * + * @return the unmodifiable list of every error discovered during loading + */ + public List errors() { + return errors; + } + + private static String buildMessage(List errors) { + return "Configuration loading failed with %d error(s):%n%s".formatted(errors.size(), + errors.stream() + .map(e -> " - %s [%s]: %s".formatted(e.file(), e.pointer(), e.message())) + .collect(Collectors.joining(System.lineSeparator()))); + } +} diff --git a/api-sheriff/src/main/java/de/cuioss/sheriff/api/config/load/ConfigLoader.java b/api-sheriff/src/main/java/de/cuioss/sheriff/api/config/load/ConfigLoader.java new file mode 100644 index 0000000..4193ff4 --- /dev/null +++ b/api-sheriff/src/main/java/de/cuioss/sheriff/api/config/load/ConfigLoader.java @@ -0,0 +1,326 @@ +/* + * Copyright © 2022 CUI-OpenSource-Software (info@cuioss.de) + * + * 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 de.cuioss.sheriff.api.config.load; + +import java.io.IOException; +import java.io.InputStream; +import java.io.Reader; +import java.io.Serializable; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.List; +import java.util.Objects; +import java.util.Set; +import java.util.function.Consumer; +import java.util.stream.Stream; + +import com.fasterxml.jackson.core.JsonParser; +import com.fasterxml.jackson.databind.DeserializationContext; +import com.fasterxml.jackson.databind.JsonDeserializer; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.MapperFeature; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.PropertyNamingStrategies; +import com.fasterxml.jackson.databind.module.SimpleModule; +import com.fasterxml.jackson.databind.node.ArrayNode; +import com.fasterxml.jackson.databind.node.ObjectNode; +import com.fasterxml.jackson.databind.node.TextNode; +import com.fasterxml.jackson.datatype.jdk8.Jdk8Module; +import com.fasterxml.jackson.dataformat.yaml.YAMLMapper; + +import com.networknt.schema.JsonSchema; +import com.networknt.schema.JsonSchemaFactory; +import com.networknt.schema.SpecVersion; +import com.networknt.schema.ValidationMessage; +import de.cuioss.sheriff.api.config.model.EndpointConfig; +import de.cuioss.sheriff.api.config.model.GatewayConfig; +import de.cuioss.sheriff.api.config.model.UpstreamDefaultsConfig; +import org.jspecify.annotations.Nullable; + +/** + * Reads, validates, and binds the file-based API Sheriff configuration. + *

+ * {@link #load()} runs the boot pipeline over the configuration directory: it reads + * {@code gateway.yaml} and every {@code endpoints/*.yaml} file (sorted for + * deterministic error output), validates each document against the bundled D2 JSON + * Schemas, resolves {@code ${ENV_VAR}} secret references, and binds the valid trees + * to the immutable {@link de.cuioss.sheriff.api.config.model} records. Every problem + * is collected — never fail on the first — and raised together as a + * {@link ConfigLoadException}. + *

+ * The endpoint-enablement resolution, topology-alias resolution, cross-cutting + * semantic validation, and route-table assembly steps of the full boot sequence are + * layered on by later deliverables; this loader owns steps 1-4 (read, + * schema-validate, secret-resolve, bind) and produces the bound model. + *

+ * Framework-agnostic seam (ADR-0005). The configuration directory + * and the {@link EnvSecretResolver} are constructor-injected; the loader carries no + * framework imports. Instances are stateless between {@link #load()} calls and safe + * to reuse. + * + * @author API Sheriff Team + * @since 1.0 + */ +public final class ConfigLoader { + + private static final String GATEWAY_FILE = "gateway.yaml"; + private static final String ENDPOINTS_DIR = "endpoints"; + private static final String ENDPOINT_ROOT = "endpoint"; + private static final String ENABLED_FIELD = "enabled"; + + private final Path configDir; + private final EnvSecretResolver secretResolver; + private final ObjectMapper mapper; + private final JsonSchema gatewaySchema; + private final JsonSchema endpointSchema; + + /** + * Creates a loader for the given configuration directory. + * + * @param configDir the directory holding {@code gateway.yaml} and the + * {@code endpoints/} subdirectory + * @param secretResolver the resolver for {@code ${ENV_VAR}} secret references + */ + public ConfigLoader(Path configDir, EnvSecretResolver secretResolver) { + this.configDir = Objects.requireNonNull(configDir, "configDir"); + this.secretResolver = Objects.requireNonNull(secretResolver, "secretResolver"); + this.mapper = buildMapper(); + JsonSchemaFactory factory = JsonSchemaFactory.getInstance(SpecVersion.VersionFlag.V202012); + this.gatewaySchema = loadSchema(factory, "/schema/gateway.schema.json"); + this.endpointSchema = loadSchema(factory, "/schema/endpoint.schema.json"); + } + + /** + * Loads and binds the configuration. + * + * @return the bound gateway document and endpoints + * @throws ConfigLoadException aggregating every schema, secret, and binding + * error discovered in this pass + */ + public LoadedConfig load() throws ConfigLoadException { + List errors = new ArrayList<>(); + + GatewayConfig gateway = loadGateway(errors); + List endpoints = loadEndpoints(errors); + + if (!errors.isEmpty()) { + throw new ConfigLoadException(errors); + } + return new LoadedConfig(Objects.requireNonNull(gateway, "gateway"), List.copyOf(endpoints)); + } + + private @Nullable GatewayConfig loadGateway(List errors) { + JsonNode node = readYaml(configDir.resolve(GATEWAY_FILE), GATEWAY_FILE, errors); + if (node == null) { + return null; + } + validate(gatewaySchema, node, GATEWAY_FILE, errors); + resolveSecrets(node, GATEWAY_FILE, "", errors); + if (hasErrorsFor(GATEWAY_FILE, errors)) { + return null; + } + return bind(node, GatewayConfig.class, GATEWAY_FILE, errors); + } + + private List loadEndpoints(List errors) { + List endpoints = new ArrayList<>(); + for (Path path : listEndpointFiles(errors)) { + EndpointConfig endpoint = loadEndpoint(path, errors); + if (endpoint != null) { + endpoints.add(endpoint); + } + } + return endpoints; + } + + private @Nullable EndpointConfig loadEndpoint(Path path, List errors) { + String file = ENDPOINTS_DIR + "/" + path.getFileName(); + JsonNode root = readYaml(path, file, errors); + if (root == null) { + return null; + } + validate(endpointSchema, root, file, errors); + resolveSecrets(root, file, "", errors); + if (hasErrorsFor(file, errors)) { + return null; + } + JsonNode block = root.get(ENDPOINT_ROOT); + applyEnabledDefault(block); + return bind(block, EndpointConfig.class, file, errors); + } + + private List listEndpointFiles(List errors) { + Path dir = configDir.resolve(ENDPOINTS_DIR); + if (!Files.isDirectory(dir)) { + return List.of(); + } + try (Stream entries = Files.list(dir)) { + return entries + .filter(Files::isRegularFile) + .filter(p -> p.getFileName().toString().endsWith(".yaml")) + .sorted(Comparator.comparing(p -> p.getFileName().toString())) + .toList(); + } catch (IOException e) { + errors.add(new ConfigError(ENDPOINTS_DIR, "", "cannot list endpoint files: " + e.getMessage())); + return List.of(); + } + } + + private @Nullable JsonNode readYaml(Path path, String file, List errors) { + if (!Files.isRegularFile(path)) { + errors.add(new ConfigError(file, "", "configuration file not found")); + return null; + } + try (Reader reader = Files.newBufferedReader(path)) { + return mapper.readTree(reader); + } catch (IOException e) { + errors.add(new ConfigError(file, "", "cannot read configuration file: " + e.getMessage())); + return null; + } + } + + private static void validate(JsonSchema schema, JsonNode node, String file, List errors) { + Set messages = schema.validate(node); + for (ValidationMessage message : messages) { + errors.add(new ConfigError(file, message.getInstanceLocation().toString(), message.getMessage())); + } + } + + private void resolveSecrets(JsonNode node, String file, String pointer, List errors) { + if (node instanceof ObjectNode object) { + List names = new ArrayList<>(); + object.fieldNames().forEachRemaining(names::add); + for (String name : names) { + resolveChild(object.get(name), file, pointer + "/" + name, errors, + resolved -> object.put(name, resolved)); + } + } else if (node instanceof ArrayNode array) { + for (int index = 0; index < array.size(); index++) { + int position = index; + resolveChild(array.get(index), file, pointer + "/" + index, errors, + resolved -> array.set(position, TextNode.valueOf(resolved))); + } + } + } + + private void resolveChild(JsonNode child, String file, String pointer, List errors, + Consumer replacer) { + if (child.isTextual() && secretResolver.hasReference(child.asText())) { + try { + replacer.accept(secretResolver.resolve(child.asText())); + } catch (EnvSecretResolver.MissingVariableException e) { + errors.add(new ConfigError(file, pointer, e.getMessage())); + } + } else { + resolveSecrets(child, file, pointer, errors); + } + } + + private static void applyEnabledDefault(JsonNode endpointBlock) { + if (endpointBlock instanceof ObjectNode object && !object.has(ENABLED_FIELD)) { + object.put(ENABLED_FIELD, true); + } + } + + private @Nullable T bind(JsonNode node, Class type, String file, List errors) { + try { + return mapper.treeToValue(node, type); + } catch (IOException | IllegalArgumentException e) { + errors.add(new ConfigError(file, "", "binding failed: " + e.getMessage())); + return null; + } + } + + private static boolean hasErrorsFor(String file, List errors) { + return errors.stream().anyMatch(error -> error.file().equals(file)); + } + + private static ObjectMapper buildMapper() { + SimpleModule module = new SimpleModule(); + module.addDeserializer(UpstreamDefaultsConfig.class, new UpstreamDefaultsDeserializer()); + // Register the Jdk8 module explicitly (Optional support) rather than via + // findAndAddModules()'s ServiceLoader discovery, which does not resolve in a + // native image and leaves Optional-typed record components unbindable. + return YAMLMapper.builder() + .enable(MapperFeature.ACCEPT_CASE_INSENSITIVE_ENUMS) + .propertyNamingStrategy(PropertyNamingStrategies.SNAKE_CASE) + .addModule(new Jdk8Module()) + .addModule(module) + .build(); + } + + private static JsonSchema loadSchema(JsonSchemaFactory factory, String resource) { + try (InputStream stream = ConfigLoader.class.getResourceAsStream(resource)) { + if (stream == null) { + throw new IllegalStateException("Missing bundled schema resource: " + resource); + } + return factory.getSchema(stream); + } catch (IOException e) { + throw new IllegalStateException("Cannot read bundled schema resource: " + resource, e); + } + } + + /** + * The bound configuration produced by {@link #load()}. + * + * @param gateway the bound global {@code gateway.yaml} document + * @param endpoints the bound endpoint documents in deterministic (file-sorted) + * order, empty when none are declared + * @author API Sheriff Team + * @since 1.0 + */ + public record LoadedConfig(GatewayConfig gateway, List endpoints) { + + /** + * Canonical constructor requiring {@code gateway} and defensively copying + * {@code endpoints}. + */ + public LoadedConfig { + Objects.requireNonNull(gateway, "gateway"); + endpoints = endpoints == null ? List.of() : List.copyOf(endpoints); + } + } + + /** + * Binds the nested {@code upstream_defaults} YAML block + * ({@code {retry:{enabled}, not_modified:{enabled}}}) to the flat + * {@link UpstreamDefaultsConfig} record, defaulting each toggle to {@code true} + * when absent. + */ + private static final class UpstreamDefaultsDeserializer extends JsonDeserializer + implements Serializable { + + private static final long serialVersionUID = 1L; + + @Override + public UpstreamDefaultsConfig deserialize(JsonParser parser, DeserializationContext context) + throws IOException { + JsonNode node = parser.readValueAsTree(); + return new UpstreamDefaultsConfig(readEnabled(node, "retry"), readEnabled(node, "not_modified")); + } + + private static boolean readEnabled(JsonNode node, String block) { + JsonNode blockNode = node.get(block); + if (blockNode == null) { + return true; + } + JsonNode enabled = blockNode.get(ENABLED_FIELD); + return enabled == null || enabled.asBoolean(true); + } + } +} diff --git a/api-sheriff/src/main/java/de/cuioss/sheriff/api/config/load/EnvSecretResolver.java b/api-sheriff/src/main/java/de/cuioss/sheriff/api/config/load/EnvSecretResolver.java new file mode 100644 index 0000000..4cf2c74 --- /dev/null +++ b/api-sheriff/src/main/java/de/cuioss/sheriff/api/config/load/EnvSecretResolver.java @@ -0,0 +1,126 @@ +/* + * Copyright © 2022 CUI-OpenSource-Software (info@cuioss.de) + * + * 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 de.cuioss.sheriff.api.config.load; + +import java.util.Objects; +import java.util.function.UnaryOperator; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +import org.jspecify.annotations.Nullable; + +/** + * Resolves {@code ${ENV_VAR}} secret references embedded in configuration string + * values against a supplied environment lookup. + *

+ * A reference names an environment variable using the pattern + * {@code ${NAME}} where {@code NAME} starts with a letter or underscore and + * continues with letters, digits, or underscores. Every reference in a value is + * substituted; a reference whose variable is absent is a hard failure surfaced as a + * {@link MissingVariableException} so the loader can record it as a + * {@link ConfigError}. + *

+ * The environment lookup is constructor-injected (defaulting to + * {@link System#getenv(String)}), keeping the resolver framework-agnostic and + * deterministically testable. + * + * @author API Sheriff Team + * @since 1.0 + */ +public final class EnvSecretResolver { + + private static final Pattern REFERENCE = Pattern.compile("\\$\\{([A-Za-z_]\\w*)}"); + + private final UnaryOperator<@Nullable String> lookup; + + /** + * Creates a resolver backed by the process environment + * ({@link System#getenv(String)}). + */ + public EnvSecretResolver() { + this(System::getenv); + } + + /** + * Creates a resolver backed by the supplied lookup. + * + * @param lookup maps an environment-variable name to its value, or {@code null} + * when the variable is undefined + */ + public EnvSecretResolver(UnaryOperator<@Nullable String> lookup) { + this.lookup = Objects.requireNonNull(lookup, "lookup"); + } + + /** + * Reports whether the value contains at least one {@code ${VAR}} reference. + * + * @param value the raw configuration value + * @return {@code true} when a reference is present + */ + public boolean hasReference(String value) { + return REFERENCE.matcher(value).find(); + } + + /** + * Substitutes every {@code ${VAR}} reference in the value with the resolved + * environment value. + * + * @param value the raw configuration value + * @return the value with all references substituted + * @throws MissingVariableException when a referenced variable is undefined + */ + public String resolve(String value) { + Matcher matcher = REFERENCE.matcher(value); + StringBuilder result = new StringBuilder(); + while (matcher.find()) { + String name = matcher.group(1); + String resolved = lookup.apply(name); + if (resolved == null) { + throw new MissingVariableException(name); + } + matcher.appendReplacement(result, Matcher.quoteReplacement(resolved)); + } + matcher.appendTail(result); + return result.toString(); + } + + /** + * Signals that a referenced environment variable is undefined. + * + * @author API Sheriff Team + * @since 1.0 + */ + public static final class MissingVariableException extends RuntimeException { + + private static final long serialVersionUID = 1L; + + private final String variableName; + + MissingVariableException(String variableName) { + super("Unresolved environment variable: " + variableName); + this.variableName = variableName; + } + + /** + * Returns the name of the undefined environment variable. + * + * @return the missing variable name + */ + public String variableName() { + return variableName; + } + } +} diff --git a/api-sheriff/src/main/java/de/cuioss/sheriff/api/config/load/package-info.java b/api-sheriff/src/main/java/de/cuioss/sheriff/api/config/load/package-info.java new file mode 100644 index 0000000..abdf29a --- /dev/null +++ b/api-sheriff/src/main/java/de/cuioss/sheriff/api/config/load/package-info.java @@ -0,0 +1,45 @@ +/* + * Copyright © 2022 CUI-OpenSource-Software (info@cuioss.de) + * + * 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. + */ +/** + * Boot-time loader for the API Sheriff file-based configuration. + *

+ * {@link de.cuioss.sheriff.api.config.load.ConfigLoader} reads {@code gateway.yaml} + * and the {@code endpoints/*.yaml} files from the configuration directory, + * validates each document against the bundled D2 JSON Schemas + * (draft 2020-12, via {@code com.networknt:json-schema-validator}), resolves + * {@code ${ENV_VAR}} secret references through + * {@link de.cuioss.sheriff.api.config.load.EnvSecretResolver}, and binds the valid + * trees to the immutable {@link de.cuioss.sheriff.api.config.model} records with + * Jackson. + *

+ * Error aggregation. Loading never fails on the first problem: all + * schema violations, secret-resolution failures, and binding errors are collected — + * each carrying its source file and a JSON-pointer location — and raised together as + * a single {@link de.cuioss.sheriff.api.config.load.ConfigLoadException}. + *

+ * Framework-agnostic seam (ADR-0005). The loader carries no CDI, + * Quarkus, or framework imports; its configuration directory and secret resolver are + * supplied by constructor parameters. The cross-cutting semantic validation, + * endpoint-enablement resolution, topology-alias resolution, and route-table + * assembly are layered on by later deliverables. + * + * @author API Sheriff Team + * @since 1.0 + */ +@NullMarked +package de.cuioss.sheriff.api.config.load; + +import org.jspecify.annotations.NullMarked; diff --git a/api-sheriff/src/main/java/de/cuioss/sheriff/api/config/model/AuthConfig.java b/api-sheriff/src/main/java/de/cuioss/sheriff/api/config/model/AuthConfig.java new file mode 100644 index 0000000..50a3727 --- /dev/null +++ b/api-sheriff/src/main/java/de/cuioss/sheriff/api/config/model/AuthConfig.java @@ -0,0 +1,48 @@ +/* + * Copyright © 2022 CUI-OpenSource-Software (info@cuioss.de) + * + * 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 de.cuioss.sheriff.api.config.model; + +import java.util.List; +import java.util.Objects; + +import lombok.Builder; + +/** + * The {@code auth} block, declarable at endpoint level (mandatory default + * posture) and per route (wholesale override). + *

+ * {@code require} is one of {@code none} / {@code bearer} / {@code session}; the + * value set is enforced by the configuration validator. {@code required_scopes} + * is valid at either level; because override is wholesale, a route-level block + * that omits it drops endpoint-level scope enforcement for that route. + * + * @param require the authentication requirement (mandatory) + * @param requiredScopes the scopes enforced for this posture, empty when none + * @author API Sheriff Team + * @since 1.0 + */ +@Builder +public record AuthConfig(String require, List requiredScopes) { + + /** + * Canonical constructor defensively copying {@code requiredScopes} into an + * unmodifiable list and normalizing an absent list to empty. + */ + public AuthConfig { + Objects.requireNonNull(require, "require"); + requiredScopes = requiredScopes == null ? List.of() : List.copyOf(requiredScopes); + } +} diff --git a/api-sheriff/src/main/java/de/cuioss/sheriff/api/config/model/EndpointConfig.java b/api-sheriff/src/main/java/de/cuioss/sheriff/api/config/model/EndpointConfig.java new file mode 100644 index 0000000..0c8e9eb --- /dev/null +++ b/api-sheriff/src/main/java/de/cuioss/sheriff/api/config/model/EndpointConfig.java @@ -0,0 +1,64 @@ +/* + * Copyright © 2022 CUI-OpenSource-Software (info@cuioss.de) + * + * 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 de.cuioss.sheriff.api.config.model; + +import java.util.List; +import java.util.Objects; +import java.util.Optional; + +import lombok.Builder; + +/** + * One {@code endpoints/*.yaml} file's {@code endpoint} block. + *

+ * {@code id} is mandatory and unique across all endpoint files (a duplicate + * fails the boot). {@code enabled} defaults to {@code true}; a disabled endpoint + * is inert (its routes are not merged and its alias need not resolve). + * {@code allowedMethods}, when non-empty, replaces the global + * {@code gateway.allowed_methods} wholesale for this endpoint (no inheritance); an + * empty list means the global list applies. The endpoint-level + * {@code upstreamDefaults}, when present, replaces the global block wholesale for + * this endpoint's routes. + * + * @param id the unique endpoint id (mandatory) + * @param enabled whether the endpoint is active + * @param baseUrl the topology alias (mandatory) + * @param auth the mandatory default auth posture for the routes + * @param allowedMethods the per-endpoint verb allowlist, empty meaning the + * global list applies + * @param upstreamDefaults the endpoint-level retry/not-modified defaults, empty + * when the global block applies + * @param routes the routes declared by this endpoint, empty when none + * @author API Sheriff Team + * @since 1.0 + */ +@Builder +public record EndpointConfig(String id, boolean enabled, String baseUrl, AuthConfig auth, + List allowedMethods, Optional upstreamDefaults, List routes) { + + /** + * Canonical constructor requiring the mandatory fields, defensively copying the + * collections, and normalizing absent components. + */ + public EndpointConfig { + Objects.requireNonNull(id, "id"); + Objects.requireNonNull(baseUrl, "baseUrl"); + Objects.requireNonNull(auth, "auth"); + allowedMethods = allowedMethods == null ? List.of() : List.copyOf(allowedMethods); + upstreamDefaults = Objects.requireNonNullElse(upstreamDefaults, Optional.empty()); + routes = routes == null ? List.of() : List.copyOf(routes); + } +} diff --git a/api-sheriff/src/main/java/de/cuioss/sheriff/api/config/model/ForwardConfig.java b/api-sheriff/src/main/java/de/cuioss/sheriff/api/config/model/ForwardConfig.java new file mode 100644 index 0000000..d91210a --- /dev/null +++ b/api-sheriff/src/main/java/de/cuioss/sheriff/api/config/model/ForwardConfig.java @@ -0,0 +1,50 @@ +/* + * Copyright © 2022 CUI-OpenSource-Software (info@cuioss.de) + * + * 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 de.cuioss.sheriff.api.config.model; + +import java.util.List; +import java.util.Map; + +import lombok.Builder; + +/** + * The per-route {@code forward} block: a deny-by-default allowlist of what + * crosses to the upstream. + *

+ * {@code headers_allow} / {@code query_allow} are allowlists — anything not + * listed is not forwarded. {@code set_headers} adds static, gateway-controlled + * headers. The mediated {@code Authorization} and the regenerated + * forwarding-header set are injected automatically and are never configured here. + * + * @param headersAllow the forwardable request-header names, empty when none + * @param queryAllow the forwardable query-parameter names, empty when none + * @param setHeaders static gateway-set headers, empty when none + * @author API Sheriff Team + * @since 1.0 + */ +@Builder +public record ForwardConfig(List headersAllow, List queryAllow, Map setHeaders) { + + /** + * Canonical constructor defensively copying the collections into unmodifiable + * copies and normalizing absent collections to empty. + */ + public ForwardConfig { + headersAllow = headersAllow == null ? List.of() : List.copyOf(headersAllow); + queryAllow = queryAllow == null ? List.of() : List.copyOf(queryAllow); + setHeaders = setHeaders == null ? Map.of() : Map.copyOf(setHeaders); + } +} diff --git a/api-sheriff/src/main/java/de/cuioss/sheriff/api/config/model/ForwardedConfig.java b/api-sheriff/src/main/java/de/cuioss/sheriff/api/config/model/ForwardedConfig.java new file mode 100644 index 0000000..cea491c --- /dev/null +++ b/api-sheriff/src/main/java/de/cuioss/sheriff/api/config/model/ForwardedConfig.java @@ -0,0 +1,53 @@ +/* + * Copyright © 2022 CUI-OpenSource-Software (info@cuioss.de) + * + * 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 de.cuioss.sheriff.api.config.model; + +import java.util.List; +import java.util.Objects; +import java.util.Optional; + +import lombok.Builder; + +/** + * The global {@code forwarded} block of {@code gateway.yaml}: the + * forwarded-header trust policy. + *

+ * {@code trusted_proxies} is a mandatory CIDR allowlist (which may be empty, + * meaning forwarding headers are never trusted). {@code emit} is one of + * {@code x-forwarded} / {@code both}; the value set is enforced by the + * configuration validator. + * + * @param trustedProxies the trusted-proxy CIDRs, empty when forwarding headers + * are never trusted + * @param trustSchemeHost whether the sanitized forwarded scheme/host/port/prefix + * is honored, empty when omitted + * @param emit the canonical output mode, empty when omitted + * @author API Sheriff Team + * @since 1.0 + */ +@Builder +public record ForwardedConfig(List trustedProxies, Optional trustSchemeHost, Optional emit) { + + /** + * Canonical constructor defensively copying {@code trustedProxies} and + * normalizing absent components. + */ + public ForwardedConfig { + trustedProxies = trustedProxies == null ? List.of() : List.copyOf(trustedProxies); + trustSchemeHost = Objects.requireNonNullElse(trustSchemeHost, Optional.empty()); + emit = Objects.requireNonNullElse(emit, Optional.empty()); + } +} diff --git a/api-sheriff/src/main/java/de/cuioss/sheriff/api/config/model/GatewayConfig.java b/api-sheriff/src/main/java/de/cuioss/sheriff/api/config/model/GatewayConfig.java new file mode 100644 index 0000000..8ff6d0b --- /dev/null +++ b/api-sheriff/src/main/java/de/cuioss/sheriff/api/config/model/GatewayConfig.java @@ -0,0 +1,69 @@ +/* + * Copyright © 2022 CUI-OpenSource-Software (info@cuioss.de) + * + * 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 de.cuioss.sheriff.api.config.model; + +import java.util.List; +import java.util.Objects; +import java.util.Optional; + +import lombok.Builder; + +/** + * The root {@code gateway.yaml} configuration document: the global settings the + * gateway applies across all endpoints. + *

+ * {@code allowedMethods} is the global positive HTTP-verb allowlist (empty means + * the standard set applies, materialized per route by the route-table builder). + * {@code upstreamDefaults} carries the global retry/not-modified defaults; an + * endpoint block replaces it wholesale for that endpoint's routes. + * + * @param version the config schema version (unknown values are refused + * by the validator) + * @param metadata the audit-stamp metadata, empty when omitted + * @param tls the TLS settings, empty when omitted + * @param securityHeaders the response-header middleware settings, empty when + * omitted + * @param securityDefaults the global security-filter baseline, empty when omitted + * @param allowedMethods the global verb allowlist, empty meaning the standard set + * @param upstreamDefaults the global retry/not-modified defaults, empty when omitted + * @param forwarded the forwarded-header trust policy, empty when omitted + * @param tokenValidation the offline bearer-validation settings, empty when omitted + * @param oidc the confidential-client settings, empty when omitted + * @author API Sheriff Team + * @since 1.0 + */ +@Builder +public record GatewayConfig(int version, Optional metadata, Optional tls, + Optional securityHeaders, Optional securityDefaults, + List allowedMethods, Optional upstreamDefaults, + Optional forwarded, Optional tokenValidation, Optional oidc) { + + /** + * Canonical constructor defensively copying {@code allowedMethods} and + * normalizing absent optional blocks to {@link Optional#empty()}. + */ + public GatewayConfig { + metadata = Objects.requireNonNullElse(metadata, Optional.empty()); + tls = Objects.requireNonNullElse(tls, Optional.empty()); + securityHeaders = Objects.requireNonNullElse(securityHeaders, Optional.empty()); + securityDefaults = Objects.requireNonNullElse(securityDefaults, Optional.empty()); + allowedMethods = allowedMethods == null ? List.of() : List.copyOf(allowedMethods); + upstreamDefaults = Objects.requireNonNullElse(upstreamDefaults, Optional.empty()); + forwarded = Objects.requireNonNullElse(forwarded, Optional.empty()); + tokenValidation = Objects.requireNonNullElse(tokenValidation, Optional.empty()); + oidc = Objects.requireNonNullElse(oidc, Optional.empty()); + } +} diff --git a/api-sheriff/src/main/java/de/cuioss/sheriff/api/config/model/HttpMethod.java b/api-sheriff/src/main/java/de/cuioss/sheriff/api/config/model/HttpMethod.java new file mode 100644 index 0000000..4c18e01 --- /dev/null +++ b/api-sheriff/src/main/java/de/cuioss/sheriff/api/config/model/HttpMethod.java @@ -0,0 +1,45 @@ +/* + * Copyright © 2022 CUI-OpenSource-Software (info@cuioss.de) + * + * 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 de.cuioss.sheriff.api.config.model; + +/** + * The proxyable HTTP verbs the gateway is permitted to serve. + *

+ * {@code TRACE} and {@code CONNECT} are deliberately absent: they are not + * proxyable through the data plane and are never permitted. Any configuration + * naming either verb is rejected by the configuration validator with a boot + * failure — they are simply not representable in the model. + * + * @author API Sheriff Team + * @since 1.0 + */ +public enum HttpMethod { + + /** HTTP {@code GET}. */ + GET, + /** HTTP {@code HEAD}. */ + HEAD, + /** HTTP {@code POST}. */ + POST, + /** HTTP {@code PUT}. */ + PUT, + /** HTTP {@code PATCH}. */ + PATCH, + /** HTTP {@code DELETE}. */ + DELETE, + /** HTTP {@code OPTIONS}. */ + OPTIONS +} diff --git a/api-sheriff/src/main/java/de/cuioss/sheriff/api/config/model/IssuerConfig.java b/api-sheriff/src/main/java/de/cuioss/sheriff/api/config/model/IssuerConfig.java new file mode 100644 index 0000000..1fd7072 --- /dev/null +++ b/api-sheriff/src/main/java/de/cuioss/sheriff/api/config/model/IssuerConfig.java @@ -0,0 +1,70 @@ +/* + * Copyright © 2022 CUI-OpenSource-Software (info@cuioss.de) + * + * 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 de.cuioss.sheriff.api.config.model; + +import java.util.Objects; +import java.util.Optional; + +import lombok.Builder; + +/** + * A single {@code token_validation.issuers[]} entry. + * + * @param name the unique issuer label used in logs and metrics (mandatory) + * @param issuer the expected {@code iss} claim (mandatory) + * @param audience the expected {@code aud} claim, empty when omitted + * @param jwks the key material for signature verification, empty when omitted + * @author API Sheriff Team + * @since 1.0 + */ +@Builder +public record IssuerConfig(String name, String issuer, Optional audience, Optional jwks) { + + /** + * Canonical constructor requiring the mandatory fields and normalizing absent + * optionals to {@link Optional#empty()}. + */ + public IssuerConfig { + Objects.requireNonNull(name, "name"); + Objects.requireNonNull(issuer, "issuer"); + audience = Objects.requireNonNullElse(audience, Optional.empty()); + jwks = Objects.requireNonNullElse(jwks, Optional.empty()); + } + + /** + * Key material for signature verification. + * + * @param source the key source ({@code http} / {@code file} / {@code inline}), + * mandatory + * @param url the JWKS URL (for {@code source: http}), empty otherwise + * @param file the JWKS file path (for {@code source: file}), empty otherwise + * @author API Sheriff Team + * @since 1.0 + */ + @Builder + public record Jwks(String source, Optional url, Optional file) { + + /** + * Canonical constructor requiring {@code source} and normalizing absent + * optionals to {@link Optional#empty()}. + */ + public Jwks { + Objects.requireNonNull(source, "source"); + url = Objects.requireNonNullElse(url, Optional.empty()); + file = Objects.requireNonNullElse(file, Optional.empty()); + } + } +} diff --git a/api-sheriff/src/main/java/de/cuioss/sheriff/api/config/model/MatchConfig.java b/api-sheriff/src/main/java/de/cuioss/sheriff/api/config/model/MatchConfig.java new file mode 100644 index 0000000..c5d887d --- /dev/null +++ b/api-sheriff/src/main/java/de/cuioss/sheriff/api/config/model/MatchConfig.java @@ -0,0 +1,72 @@ +/* + * Copyright © 2022 CUI-OpenSource-Software (info@cuioss.de) + * + * 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 de.cuioss.sheriff.api.config.model; + +import java.util.List; +import java.util.Objects; +import java.util.Optional; + +import lombok.Builder; + +/** + * The per-route {@code match} block. Matchers compose with AND semantics: a + * route matches only if every declared matcher holds. + * + * @param pathPrefix the literal path prefix and precedence key (mandatory) + * @param methods the matched HTTP methods, empty meaning all methods + * @param host the exact host match, empty when omitted + * @param headers the header matchers, empty when none + * @author API Sheriff Team + * @since 1.0 + */ +@Builder +public record MatchConfig(String pathPrefix, List methods, Optional host, + List headers) { + + /** + * Canonical constructor requiring {@code pathPrefix}, defensively copying the + * collections, and normalizing absent components. + */ + public MatchConfig { + Objects.requireNonNull(pathPrefix, "pathPrefix"); + methods = methods == null ? List.of() : List.copyOf(methods); + host = Objects.requireNonNullElse(host, Optional.empty()); + headers = headers == null ? List.of() : List.copyOf(headers); + } + + /** + * A single header matcher: presence or exact value. + * + * @param name the header name (mandatory) + * @param present whether the header must merely be present, empty when omitted + * @param value the exact required value, empty when omitted + * @author API Sheriff Team + * @since 1.0 + */ + @Builder + public record HeaderMatcher(String name, Optional present, Optional value) { + + /** + * Canonical constructor requiring {@code name} and normalizing absent + * optionals. + */ + public HeaderMatcher { + Objects.requireNonNull(name, "name"); + present = Objects.requireNonNullElse(present, Optional.empty()); + value = Objects.requireNonNullElse(value, Optional.empty()); + } + } +} diff --git a/api-sheriff/src/main/java/de/cuioss/sheriff/api/config/model/Metadata.java b/api-sheriff/src/main/java/de/cuioss/sheriff/api/config/model/Metadata.java new file mode 100644 index 0000000..fe366e3 --- /dev/null +++ b/api-sheriff/src/main/java/de/cuioss/sheriff/api/config/model/Metadata.java @@ -0,0 +1,40 @@ +/* + * Copyright © 2022 CUI-OpenSource-Software (info@cuioss.de) + * + * 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 de.cuioss.sheriff.api.config.model; + +import java.util.Objects; +import java.util.Optional; + +/** + * The optional {@code metadata} block of {@code gateway.yaml}. + *

+ * {@code config_version} is an audit stamp only — logged at boot and exposed for + * operational traceability, with no runtime behaviour. + * + * @param configVersion the audit-stamp config version, empty when omitted + * @author API Sheriff Team + * @since 1.0 + */ +public record Metadata(Optional configVersion) { + + /** + * Canonical constructor normalizing an absent {@code configVersion} to + * {@link Optional#empty()}. + */ + public Metadata { + configVersion = Objects.requireNonNullElse(configVersion, Optional.empty()); + } +} diff --git a/api-sheriff/src/main/java/de/cuioss/sheriff/api/config/model/OidcConfig.java b/api-sheriff/src/main/java/de/cuioss/sheriff/api/config/model/OidcConfig.java new file mode 100644 index 0000000..a267c1a --- /dev/null +++ b/api-sheriff/src/main/java/de/cuioss/sheriff/api/config/model/OidcConfig.java @@ -0,0 +1,230 @@ +/* + * Copyright © 2022 CUI-OpenSource-Software (info@cuioss.de) + * + * 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 de.cuioss.sheriff.api.config.model; + +import java.util.List; +import java.util.Objects; +import java.util.Optional; + +import lombok.Builder; + +/** + * The global {@code oidc} block of {@code gateway.yaml}: the confidential-client + * configuration used by the BFF variants. Ignored when no route's effective auth + * is {@code require: session}. + * + * @param issuer the OIDC issuer, empty when omitted + * @param clientId the client id, empty when omitted + * @param clientSecret the client secret ({@code ${ENV_VAR}} reference), empty + * when omitted + * @param scopes the requested scopes, empty when none + * @param redirectUri the gateway callback URI, empty when omitted + * @param logout the logout settings, empty when omitted + * @param session the session settings, empty when omitted + * @param stepUp the step-up authentication settings, empty when omitted + * @author API Sheriff Team + * @since 1.0 + */ +@Builder +public record OidcConfig(Optional issuer, Optional clientId, Optional clientSecret, + List scopes, Optional redirectUri, Optional logout, Optional session, + Optional stepUp) { + + /** + * Canonical constructor defensively copying {@code scopes} and normalizing + * absent components. + */ + public OidcConfig { + issuer = Objects.requireNonNullElse(issuer, Optional.empty()); + clientId = Objects.requireNonNullElse(clientId, Optional.empty()); + clientSecret = Objects.requireNonNullElse(clientSecret, Optional.empty()); + scopes = scopes == null ? List.of() : List.copyOf(scopes); + redirectUri = Objects.requireNonNullElse(redirectUri, Optional.empty()); + logout = Objects.requireNonNullElse(logout, Optional.empty()); + session = Objects.requireNonNullElse(session, Optional.empty()); + stepUp = Objects.requireNonNullElse(stepUp, Optional.empty()); + } + + /** + * Overridden to redact {@link #clientSecret()}. The default record + * {@code toString()} would otherwise print the resolved secret value verbatim + * — {@link de.cuioss.sheriff.api.config.load.ConfigLoader} substitutes the + * {@code ${ENV_VAR}} reference with the real secret before binding, so an + * unredacted {@code toString()} would leak it into any log line, exception + * message, or debugger view that captures this instance. + * + * @return a string representation with {@code clientSecret} redacted + */ + @Override + public String toString() { + return "OidcConfig[issuer=%s, clientId=%s, clientSecret=%s, scopes=%s, redirectUri=%s, logout=%s, session=%s, stepUp=%s]" + .formatted(issuer, clientId, redact(clientSecret), scopes, redirectUri, logout, session, stepUp); + } + + /** + * Redacts a secret-bearing {@link Optional} for display, preserving presence + * without exposing the value. + * + * @param secret the secret-bearing optional to redact + * @return {@code "Optional[***REDACTED***]"} when present, {@code "Optional.empty"} otherwise + */ + private static String redact(Optional secret) { + return secret.isPresent() ? "Optional[***REDACTED***]" : "Optional.empty"; + } + + /** + * RP-initiated logout settings. + * + * @param path the gateway-served logout path, empty when omitted + * @param postLogoutRedirectUri the gateway-owned return leg, empty when omitted + * @param finalRedirect the application landing URL after logout, empty + * when omitted + * @param backchannelPath the back-channel logout receiver path, empty when + * omitted + * @author API Sheriff Team + * @since 1.0 + */ + @Builder + public record Logout(Optional path, Optional postLogoutRedirectUri, Optional finalRedirect, + Optional backchannelPath) { + + /** + * Canonical constructor normalizing absent components to {@link Optional#empty()}. + */ + public Logout { + path = Objects.requireNonNullElse(path, Optional.empty()); + postLogoutRedirectUri = Objects.requireNonNullElse(postLogoutRedirectUri, Optional.empty()); + finalRedirect = Objects.requireNonNullElse(finalRedirect, Optional.empty()); + backchannelPath = Objects.requireNonNullElse(backchannelPath, Optional.empty()); + } + } + + /** + * Session settings. The exhibited fields span both modes: {@code store} is + * server-mode only, the encryption keys are cookie-mode only. + * + * @param mode the session mode ({@code cookie} / {@code server}), empty + * when omitted + * @param store the server-mode store, empty when omitted + * @param cookieName the session cookie name, empty when omitted + * @param encryptionKey the cookie-mode AES key ({@code ${ENV_VAR}} reference), + * empty when omitted + * @param previousKey the optional decrypt-only rotation key, empty when omitted + * @param ttlSeconds the absolute session lifetime in seconds, empty when + * omitted + * @param csrf the CSRF settings, empty when omitted + * @param refresh the token-refresh settings, empty when omitted + * @author API Sheriff Team + * @since 1.0 + */ + @Builder + public record Session(Optional mode, Optional store, Optional cookieName, + Optional encryptionKey, Optional previousKey, Optional ttlSeconds, + Optional csrf, Optional refresh) { + + /** + * Canonical constructor normalizing absent components to {@link Optional#empty()}. + */ + public Session { + mode = Objects.requireNonNullElse(mode, Optional.empty()); + store = Objects.requireNonNullElse(store, Optional.empty()); + cookieName = Objects.requireNonNullElse(cookieName, Optional.empty()); + encryptionKey = Objects.requireNonNullElse(encryptionKey, Optional.empty()); + previousKey = Objects.requireNonNullElse(previousKey, Optional.empty()); + ttlSeconds = Objects.requireNonNullElse(ttlSeconds, Optional.empty()); + csrf = Objects.requireNonNullElse(csrf, Optional.empty()); + refresh = Objects.requireNonNullElse(refresh, Optional.empty()); + } + + /** + * Overridden to redact {@link #encryptionKey()} and {@link #previousKey()}. + * The default record {@code toString()} would otherwise print the resolved + * cookie-encryption key values verbatim into any log line, exception message, + * or debugger view that captures this instance. + * + * @return a string representation with both key fields redacted + */ + @Override + public String toString() { + return "Session[mode=%s, store=%s, cookieName=%s, encryptionKey=%s, previousKey=%s, ttlSeconds=%s, csrf=%s, refresh=%s]" + .formatted(mode, store, cookieName, redact(encryptionKey), redact(previousKey), ttlSeconds, csrf, + refresh); + } + } + + /** + * CSRF settings for {@code require: session} routes. + * + * @param trustedOrigins the browser origins allowed on unsafe methods, empty + * when defaulting to the {@code redirect_uri} origin + * @author API Sheriff Team + * @since 1.0 + */ + public record Csrf(List trustedOrigins) { + + /** + * Canonical constructor defensively copying {@code trustedOrigins} and + * normalizing an absent list to empty. + */ + public Csrf { + trustedOrigins = trustedOrigins == null ? List.of() : List.copyOf(trustedOrigins); + } + } + + /** + * Transparent token-refresh settings. + * + * @param enabled whether refresh is enabled, empty when omitted + * @param leewaySeconds how long before expiry to refresh, empty when omitted + * @param onFailure the on-failure behaviour ({@code reauthenticate} / + * {@code reject}), empty when omitted + * @author API Sheriff Team + * @since 1.0 + */ + @Builder + public record Refresh(Optional enabled, Optional leewaySeconds, Optional onFailure) { + + /** + * Canonical constructor normalizing absent components to {@link Optional#empty()}. + */ + public Refresh { + enabled = Objects.requireNonNullElse(enabled, Optional.empty()); + leewaySeconds = Objects.requireNonNullElse(leewaySeconds, Optional.empty()); + onFailure = Objects.requireNonNullElse(onFailure, Optional.empty()); + } + } + + /** + * RFC 9470 step-up authentication settings. + * + * @param enabled whether step-up is honored, empty when omitted + * @param honorUpstreamChallenge whether upstream challenges are honored, empty + * when omitted + * @author API Sheriff Team + * @since 1.0 + */ + @Builder + public record StepUp(Optional enabled, Optional honorUpstreamChallenge) { + + /** + * Canonical constructor normalizing absent components to {@link Optional#empty()}. + */ + public StepUp { + enabled = Objects.requireNonNullElse(enabled, Optional.empty()); + honorUpstreamChallenge = Objects.requireNonNullElse(honorUpstreamChallenge, Optional.empty()); + } + } +} diff --git a/api-sheriff/src/main/java/de/cuioss/sheriff/api/config/model/Protocol.java b/api-sheriff/src/main/java/de/cuioss/sheriff/api/config/model/Protocol.java new file mode 100644 index 0000000..a77586e --- /dev/null +++ b/api-sheriff/src/main/java/de/cuioss/sheriff/api/config/model/Protocol.java @@ -0,0 +1,39 @@ +/* + * Copyright © 2022 CUI-OpenSource-Software (info@cuioss.de) + * + * 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 de.cuioss.sheriff.api.config.model; + +/** + * The application protocol a route serves. + *

+ * The value selects handshake / streaming behaviour only (WebSocket upgrade, + * gRPC streaming); the security semantics of each protocol are defined by the + * architecture, not by the model. {@link #HTTP} is the default when a route + * omits {@code protocol}. + * + * @author API Sheriff Team + * @since 1.0 + */ +public enum Protocol { + + /** Plain HTTP (the default). */ + HTTP, + /** gRPC (carried over HTTP/2). */ + GRPC, + /** GraphQL over HTTP. */ + GRAPHQL, + /** WebSocket (HTTP upgrade). */ + WEBSOCKET +} diff --git a/api-sheriff/src/main/java/de/cuioss/sheriff/api/config/model/RateLimitConfig.java b/api-sheriff/src/main/java/de/cuioss/sheriff/api/config/model/RateLimitConfig.java new file mode 100644 index 0000000..d4df545 --- /dev/null +++ b/api-sheriff/src/main/java/de/cuioss/sheriff/api/config/model/RateLimitConfig.java @@ -0,0 +1,44 @@ +/* + * Copyright © 2022 CUI-OpenSource-Software (info@cuioss.de) + * + * 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 de.cuioss.sheriff.api.config.model; + +import java.util.Objects; +import java.util.Optional; + +import lombok.Builder; + +/** + * The reserved per-route {@code rate_limit} block. + *

+ * The rate-limiting feature is deferred; the block is accepted and ignored so + * the schema need not change when the feature ships. + * + * @param requestsPerSecond the sustained request rate, empty when omitted + * @param burst the burst allowance, empty when omitted + * @author API Sheriff Team + * @since 1.0 + */ +@Builder +public record RateLimitConfig(Optional requestsPerSecond, Optional burst) { + + /** + * Canonical constructor normalizing absent components to {@link Optional#empty()}. + */ + public RateLimitConfig { + requestsPerSecond = Objects.requireNonNullElse(requestsPerSecond, Optional.empty()); + burst = Objects.requireNonNullElse(burst, Optional.empty()); + } +} diff --git a/api-sheriff/src/main/java/de/cuioss/sheriff/api/config/model/ResolvedRoute.java b/api-sheriff/src/main/java/de/cuioss/sheriff/api/config/model/ResolvedRoute.java new file mode 100644 index 0000000..ebf4479 --- /dev/null +++ b/api-sheriff/src/main/java/de/cuioss/sheriff/api/config/model/ResolvedRoute.java @@ -0,0 +1,77 @@ +/* + * Copyright © 2022 CUI-OpenSource-Software (info@cuioss.de) + * + * 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 de.cuioss.sheriff.api.config.model; + +import java.util.List; +import java.util.Objects; + +import lombok.Builder; + +/** + * One route with every inherited setting materialized once, at boot, into its + * effective value. + *

+ * The route-table builder resolves the three inheritance chains — effective auth + * (route override wholesale, else the endpoint default), effective + * {@code allowed_methods} (endpoint list if declared, else the global list, else + * the standard set), and the effective retry / not-modified toggles (global + * {@code upstream_defaults}, wholesale-replaced by an endpoint block, overridden + * per route) — so downstream pipeline code consumes the already resolved values + * and never re-implements the inheritance. + * + * @param id the route id (mandatory) + * @param protocol the effective protocol (defaults to + * {@link Protocol#HTTP}) + * @param match the matcher set (mandatory) + * @param effectiveAuth the materialized auth posture (mandatory) + * @param effectiveAllowedMethods the materialized verb allowlist, never empty for + * a resolved route + * @param retryEnabled the materialized upstream-retry toggle + * @param notModifiedEnabled the materialized HTTP-304 not-modified toggle + * @param upstream the resolved upstream target for the route's + * endpoint (mandatory) + * @author API Sheriff Team + * @since 1.0 + */ +@Builder +public record ResolvedRoute(String id, Protocol protocol, MatchConfig match, AuthConfig effectiveAuth, + List effectiveAllowedMethods, boolean retryEnabled, boolean notModifiedEnabled, + ResolvedUpstream upstream) { + + /** + * Canonical constructor requiring the mandatory components, defensively copying + * {@code effectiveAllowedMethods}, and defaulting an absent {@code protocol} to + * {@link Protocol#HTTP}. + */ + public ResolvedRoute { + Objects.requireNonNull(id, "id"); + protocol = protocol == null ? Protocol.HTTP : protocol; + Objects.requireNonNull(match, "match"); + Objects.requireNonNull(effectiveAuth, "effectiveAuth"); + effectiveAllowedMethods = effectiveAllowedMethods == null ? List.of() : List.copyOf(effectiveAllowedMethods); + Objects.requireNonNull(upstream, "upstream"); + } + + /** + * The route's {@code path_prefix} — the precedence and ordering key carried by + * the {@link #match()} block. + * + * @return the literal path prefix of this route + */ + public String pathPrefix() { + return match.pathPrefix(); + } +} diff --git a/api-sheriff/src/main/java/de/cuioss/sheriff/api/config/model/ResolvedTopology.java b/api-sheriff/src/main/java/de/cuioss/sheriff/api/config/model/ResolvedTopology.java new file mode 100644 index 0000000..50107c8 --- /dev/null +++ b/api-sheriff/src/main/java/de/cuioss/sheriff/api/config/model/ResolvedTopology.java @@ -0,0 +1,51 @@ +/* + * Copyright © 2022 CUI-OpenSource-Software (info@cuioss.de) + * + * 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 de.cuioss.sheriff.api.config.model; + +import java.util.Map; +import java.util.Optional; + +/** + * The immutable, fully-resolved topology: every topology alias referenced by an + * enabled endpoint mapped to its decomposed {@link ResolvedUpstream}. + *

+ * Aliases referenced only by disabled endpoints are absent, since disabled + * endpoints are exempt from alias resolution. + * + * @param aliases the alias-to-upstream mapping, empty when nothing is resolved + * @author API Sheriff Team + * @since 1.0 + */ +public record ResolvedTopology(Map aliases) { + + /** + * Canonical constructor defensively copying {@code aliases} into an unmodifiable + * map and normalizing an absent map to empty. + */ + public ResolvedTopology { + aliases = aliases == null ? Map.of() : Map.copyOf(aliases); + } + + /** + * Looks up the resolved upstream for a topology alias. + * + * @param alias the topology alias + * @return the resolved upstream, or empty when the alias is not resolved + */ + public Optional lookup(String alias) { + return Optional.ofNullable(aliases.get(alias)); + } +} diff --git a/api-sheriff/src/main/java/de/cuioss/sheriff/api/config/model/ResolvedUpstream.java b/api-sheriff/src/main/java/de/cuioss/sheriff/api/config/model/ResolvedUpstream.java new file mode 100644 index 0000000..9dbd549 --- /dev/null +++ b/api-sheriff/src/main/java/de/cuioss/sheriff/api/config/model/ResolvedUpstream.java @@ -0,0 +1,46 @@ +/* + * Copyright © 2022 CUI-OpenSource-Software (info@cuioss.de) + * + * 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 de.cuioss.sheriff.api.config.model; + +import java.util.Objects; + +/** + * A topology alias decomposed once, at boot, into its upstream URL components + * (ADR-0004). + *

+ * Decomposing the configured URL a single time — rather than re-parsing it on every + * request — is the decompose-on-read contract: the hot path consumes the already + * split scheme, host, port, and base path. + * + * @param scheme the URL scheme (e.g. {@code https}) + * @param host the upstream host + * @param port the upstream port; the scheme default when the URL omits it + * @param basePath the base path prefix, empty when the URL carries none + * @author API Sheriff Team + * @since 1.0 + */ +public record ResolvedUpstream(String scheme, String host, int port, String basePath) { + + /** + * Canonical constructor requiring {@code scheme} and {@code host} and normalizing + * an absent {@code basePath} to the empty string. + */ + public ResolvedUpstream { + Objects.requireNonNull(scheme, "scheme"); + Objects.requireNonNull(host, "host"); + basePath = basePath == null ? "" : basePath; + } +} diff --git a/api-sheriff/src/main/java/de/cuioss/sheriff/api/config/model/RouteConfig.java b/api-sheriff/src/main/java/de/cuioss/sheriff/api/config/model/RouteConfig.java new file mode 100644 index 0000000..182a6c6 --- /dev/null +++ b/api-sheriff/src/main/java/de/cuioss/sheriff/api/config/model/RouteConfig.java @@ -0,0 +1,62 @@ +/* + * Copyright © 2022 CUI-OpenSource-Software (info@cuioss.de) + * + * 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 de.cuioss.sheriff.api.config.model; + +import java.util.Objects; +import java.util.Optional; + +import lombok.Builder; + +/** + * A single {@code routes[]} entry of an endpoint file. + *

+ * An absent {@code auth} block inherits the endpoint's {@code auth} wholesale; a + * route-level block replaces it wholesale (no field merging). An absent + * {@code protocol} means {@link Protocol#HTTP}. + * + * @param id the route id, unique across all endpoint files (mandatory) + * @param protocol the served protocol, empty meaning HTTP + * @param match the matcher set (mandatory) + * @param auth the route-level auth override, empty when inheriting the + * endpoint default + * @param securityFilter the route-level security filter, empty when the global + * default applies + * @param forward the forwarding allowlist, empty when nothing is forwarded + * @param upstream the upstream target settings, empty when omitted + * @param rateLimit the reserved rate-limit block, empty when omitted + * @author API Sheriff Team + * @since 1.0 + */ +@Builder +public record RouteConfig(String id, Optional protocol, MatchConfig match, Optional auth, + Optional securityFilter, Optional forward, Optional upstream, + Optional rateLimit) { + + /** + * Canonical constructor requiring {@code id} and {@code match} and normalizing + * absent optionals to {@link Optional#empty()}. + */ + public RouteConfig { + Objects.requireNonNull(id, "id"); + Objects.requireNonNull(match, "match"); + protocol = Objects.requireNonNullElse(protocol, Optional.empty()); + auth = Objects.requireNonNullElse(auth, Optional.empty()); + securityFilter = Objects.requireNonNullElse(securityFilter, Optional.empty()); + forward = Objects.requireNonNullElse(forward, Optional.empty()); + upstream = Objects.requireNonNullElse(upstream, Optional.empty()); + rateLimit = Objects.requireNonNullElse(rateLimit, Optional.empty()); + } +} diff --git a/api-sheriff/src/main/java/de/cuioss/sheriff/api/config/model/RouteTable.java b/api-sheriff/src/main/java/de/cuioss/sheriff/api/config/model/RouteTable.java new file mode 100644 index 0000000..20abc6e --- /dev/null +++ b/api-sheriff/src/main/java/de/cuioss/sheriff/api/config/model/RouteTable.java @@ -0,0 +1,82 @@ +/* + * Copyright © 2022 CUI-OpenSource-Software (info@cuioss.de) + * + * 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 de.cuioss.sheriff.api.config.model; + +import java.util.List; +import java.util.Objects; +import java.util.Optional; + +/** + * The immutable, longest-prefix-ordered table of fully materialized routes the + * gateway serves. + *

+ * The table is assembled once at boot from the enabled endpoints (disabled + * endpoints contribute no rows) by the route-table builder. Its {@code routes} + * are ordered most-specific-first — by descending {@code path_prefix} length — so + * the first prefix match found by {@link #lookup(String)} is the most specific + * route for a request path. The hot path consumes the already materialized + * {@link ResolvedRoute} values and never re-derives inheritance. + * + * @param routes the resolved routes, ordered longest {@code path_prefix} first, + * empty when no enabled endpoint declares a route + * @author API Sheriff Team + * @since 1.0 + */ +public record RouteTable(List routes) { + + /** + * Canonical constructor defensively copying {@code routes} into an unmodifiable + * list and normalizing an absent list to empty. + */ + public RouteTable { + routes = routes == null ? List.of() : List.copyOf(routes); + } + + /** + * Finds the most specific route whose {@code path_prefix} is a prefix of the + * given request path. + *

+ * Because the routes are ordered longest-prefix-first, the first prefix match + * is the most specific one. This is a path-prefix lookup only; the full matcher + * set (host, methods, headers) is applied by the request pipeline. + * + * @param path the request path to resolve + * @return the most specific prefix-matching route, or empty when none matches + */ + public Optional lookup(String path) { + Objects.requireNonNull(path, "path"); + return routes.stream().filter(route -> matchesPrefix(path, route.pathPrefix())).findFirst(); + } + + /** + * Tests whether {@code path} is covered by {@code prefix} on segment boundaries. + *

+ * A path matches a prefix when it equals the prefix exactly or continues it at a + * segment boundary, so {@code /proxy} matches {@code /proxy} and {@code /proxy/x} + * but not {@code /proxy-helper}. A prefix that already ends in {@code /} is treated + * as a segment boundary directly. + * + * @param path the request path to test + * @param prefix the route {@code path_prefix} to test against + * @return {@code true} when the path is at or below the prefix on a segment boundary + */ + private static boolean matchesPrefix(String path, String prefix) { + if (path.equals(prefix)) { + return true; + } + return prefix.endsWith("/") ? path.startsWith(prefix) : path.startsWith(prefix + "/"); + } +} diff --git a/api-sheriff/src/main/java/de/cuioss/sheriff/api/config/model/SecurityDefaultsConfig.java b/api-sheriff/src/main/java/de/cuioss/sheriff/api/config/model/SecurityDefaultsConfig.java new file mode 100644 index 0000000..16248f0 --- /dev/null +++ b/api-sheriff/src/main/java/de/cuioss/sheriff/api/config/model/SecurityDefaultsConfig.java @@ -0,0 +1,42 @@ +/* + * Copyright © 2022 CUI-OpenSource-Software (info@cuioss.de) + * + * 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 de.cuioss.sheriff.api.config.model; + +import java.util.Objects; +import java.util.Optional; + +/** + * The global {@code security_defaults} block of {@code gateway.yaml}. + *

+ * {@code profile} selects the inbound-filter baseline preset + * ({@code default} / {@code strict} / {@code lenient}) applied to every route + * that does not declare its own {@code security_filter}. The value set is + * enforced by the configuration validator, not by the model. + * + * @param profile the baseline security-filter profile, empty when omitted + * @author API Sheriff Team + * @since 1.0 + */ +public record SecurityDefaultsConfig(Optional profile) { + + /** + * Canonical constructor normalizing an absent {@code profile} to + * {@link Optional#empty()}. + */ + public SecurityDefaultsConfig { + profile = Objects.requireNonNullElse(profile, Optional.empty()); + } +} diff --git a/api-sheriff/src/main/java/de/cuioss/sheriff/api/config/model/SecurityFilterConfig.java b/api-sheriff/src/main/java/de/cuioss/sheriff/api/config/model/SecurityFilterConfig.java new file mode 100644 index 0000000..df13ff1 --- /dev/null +++ b/api-sheriff/src/main/java/de/cuioss/sheriff/api/config/model/SecurityFilterConfig.java @@ -0,0 +1,69 @@ +/* + * Copyright © 2022 CUI-OpenSource-Software (info@cuioss.de) + * + * 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 de.cuioss.sheriff.api.config.model; + +import java.util.List; +import java.util.Objects; +import java.util.Optional; + +import lombok.Builder; + +/** + * The per-route {@code security_filter} block: allowlists and limits (explicitly + * not a WAF). The optional baseline {@code profile} is overridden by the limits + * below. + * + * @param profile the baseline preset, empty when the global default + * applies + * @param allowedPaths the path allowlist entries, empty when none + * @param maxHeaderCount the maximum header count, empty when omitted + * @param maxHeaderValueLength the maximum single-header-value length in + * characters, empty when omitted + * @param maxQueryParams the maximum query-parameter count, empty when omitted + * @param maxParamValueLength the maximum single-parameter-value length in + * characters, empty when omitted + * @param maxBodyBytes the maximum request-body size in bytes, empty when + * omitted + * @param allowedHeaderNames the header-name allowlist, empty when none + * @param blockedHeaderNames the header-name blocklist, empty when none + * @param allowedContentTypes the request {@code Content-Type} allowlist, empty + * when none + * @author API Sheriff Team + * @since 1.0 + */ +@Builder +public record SecurityFilterConfig(Optional profile, List allowedPaths, Optional maxHeaderCount, + Optional maxHeaderValueLength, Optional maxQueryParams, Optional maxParamValueLength, + Optional maxBodyBytes, List allowedHeaderNames, List blockedHeaderNames, + List allowedContentTypes) { + + /** + * Canonical constructor defensively copying collections and normalizing absent + * components. + */ + public SecurityFilterConfig { + profile = Objects.requireNonNullElse(profile, Optional.empty()); + allowedPaths = allowedPaths == null ? List.of() : List.copyOf(allowedPaths); + maxHeaderCount = Objects.requireNonNullElse(maxHeaderCount, Optional.empty()); + maxHeaderValueLength = Objects.requireNonNullElse(maxHeaderValueLength, Optional.empty()); + maxQueryParams = Objects.requireNonNullElse(maxQueryParams, Optional.empty()); + maxParamValueLength = Objects.requireNonNullElse(maxParamValueLength, Optional.empty()); + maxBodyBytes = Objects.requireNonNullElse(maxBodyBytes, Optional.empty()); + allowedHeaderNames = allowedHeaderNames == null ? List.of() : List.copyOf(allowedHeaderNames); + blockedHeaderNames = blockedHeaderNames == null ? List.of() : List.copyOf(blockedHeaderNames); + allowedContentTypes = allowedContentTypes == null ? List.of() : List.copyOf(allowedContentTypes); + } +} diff --git a/api-sheriff/src/main/java/de/cuioss/sheriff/api/config/model/SecurityHeadersConfig.java b/api-sheriff/src/main/java/de/cuioss/sheriff/api/config/model/SecurityHeadersConfig.java new file mode 100644 index 0000000..61b8865 --- /dev/null +++ b/api-sheriff/src/main/java/de/cuioss/sheriff/api/config/model/SecurityHeadersConfig.java @@ -0,0 +1,99 @@ +/* + * Copyright © 2022 CUI-OpenSource-Software (info@cuioss.de) + * + * 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 de.cuioss.sheriff.api.config.model; + +import java.util.List; +import java.util.Objects; +import java.util.Optional; + +import lombok.Builder; + +/** + * The global {@code security_headers} block of {@code gateway.yaml}: response + * header middleware applied to every response. + * + * @param hsts the HSTS settings, empty when omitted + * @param contentTypeNosniff whether {@code X-Content-Type-Options: nosniff} is + * emitted, empty when omitted + * @param frameDeny whether {@code X-Frame-Options: DENY} is emitted, + * empty when omitted + * @param cors the CORS settings, empty when omitted + * @author API Sheriff Team + * @since 1.0 + */ +@Builder +public record SecurityHeadersConfig(Optional hsts, Optional contentTypeNosniff, + Optional frameDeny, Optional cors) { + + /** + * Canonical constructor normalizing absent components to {@link Optional#empty()}. + */ + public SecurityHeadersConfig { + hsts = Objects.requireNonNullElse(hsts, Optional.empty()); + contentTypeNosniff = Objects.requireNonNullElse(contentTypeNosniff, Optional.empty()); + frameDeny = Objects.requireNonNullElse(frameDeny, Optional.empty()); + cors = Objects.requireNonNullElse(cors, Optional.empty()); + } + + /** + * {@code Strict-Transport-Security} settings. + * + * @param maxAge the {@code max-age} in seconds, empty when omitted + * @param includeSubdomains whether {@code includeSubDomains} is set, empty when + * omitted + * @author API Sheriff Team + * @since 1.0 + */ + @Builder + public record Hsts(Optional maxAge, Optional includeSubdomains) { + + /** + * Canonical constructor normalizing absent components to {@link Optional#empty()}. + */ + public Hsts { + maxAge = Objects.requireNonNullElse(maxAge, Optional.empty()); + includeSubdomains = Objects.requireNonNullElse(includeSubdomains, Optional.empty()); + } + } + + /** + * CORS preflight / response handling. Disabled by default. + * + * @param enabled whether CORS handling is enabled, empty when omitted + * @param allowedOrigins the exact allowed origins, empty when none + * @param allowedMethods the allowed methods, empty when none + * @param allowedHeaders the allowed request headers, empty when none + * @param allowCredentials whether credentials are allowed, empty when omitted + * @author API Sheriff Team + * @since 1.0 + */ + @Builder + public record Cors(Optional enabled, List allowedOrigins, List allowedMethods, + List allowedHeaders, Optional allowCredentials) { + + /** + * Canonical constructor defensively copying collections and normalizing + * absent components. + */ + public Cors { + enabled = Objects.requireNonNullElse(enabled, Optional.empty()); + allowedOrigins = allowedOrigins == null ? List.of() : List.copyOf(allowedOrigins); + allowedMethods = allowedMethods == null ? List.of() : List.copyOf(allowedMethods); + allowedHeaders = allowedHeaders == null ? List.of() : List.copyOf(allowedHeaders); + allowCredentials = Objects.requireNonNullElse(allowCredentials, Optional.empty()); + } + } +} diff --git a/api-sheriff/src/main/java/de/cuioss/sheriff/api/config/model/TlsConfig.java b/api-sheriff/src/main/java/de/cuioss/sheriff/api/config/model/TlsConfig.java new file mode 100644 index 0000000..3cf27cd --- /dev/null +++ b/api-sheriff/src/main/java/de/cuioss/sheriff/api/config/model/TlsConfig.java @@ -0,0 +1,70 @@ +/* + * Copyright © 2022 CUI-OpenSource-Software (info@cuioss.de) + * + * 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 de.cuioss.sheriff.api.config.model; + +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Optional; + +import lombok.Builder; + +/** + * The global {@code tls} block of {@code gateway.yaml}. + * + * @param minVersion the minimum negotiated TLS version, empty when omitted + * @param alpn the advertised ALPN protocols, empty when omitted + * @param passthroughSni a map of SNI hostname to topology alias relayed at L4 + * without decryption, empty when omitted + * @param mtls the mutual-TLS settings, empty when omitted + * @author API Sheriff Team + * @since 1.0 + */ +@Builder +public record TlsConfig(Optional minVersion, List alpn, Map passthroughSni, + Optional mtls) { + + /** + * Canonical constructor defensively copying collections and normalizing absent + * components. + */ + public TlsConfig { + minVersion = Objects.requireNonNullElse(minVersion, Optional.empty()); + alpn = alpn == null ? List.of() : List.copyOf(alpn); + passthroughSni = passthroughSni == null ? Map.of() : Map.copyOf(passthroughSni); + mtls = Objects.requireNonNullElse(mtls, Optional.empty()); + } + + /** + * Mutual-TLS settings for terminated connections. + * + * @param enabled whether client-certificate verification is required + * @param clientCa the client-CA path, empty when omitted + * @author API Sheriff Team + * @since 1.0 + */ + @Builder + public record Mtls(boolean enabled, Optional clientCa) { + + /** + * Canonical constructor normalizing an absent {@code clientCa} to + * {@link Optional#empty()}. + */ + public Mtls { + clientCa = Objects.requireNonNullElse(clientCa, Optional.empty()); + } + } +} diff --git a/api-sheriff/src/main/java/de/cuioss/sheriff/api/config/model/TokenValidationConfig.java b/api-sheriff/src/main/java/de/cuioss/sheriff/api/config/model/TokenValidationConfig.java new file mode 100644 index 0000000..8ce8c88 --- /dev/null +++ b/api-sheriff/src/main/java/de/cuioss/sheriff/api/config/model/TokenValidationConfig.java @@ -0,0 +1,39 @@ +/* + * Copyright © 2022 CUI-OpenSource-Software (info@cuioss.de) + * + * 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 de.cuioss.sheriff.api.config.model; + +import java.util.List; + +/** + * The global {@code token_validation} block of {@code gateway.yaml}. + *

+ * It configures offline bearer validation for {@code require: bearer} routes; + * it is required when any route's effective auth is {@code bearer}. + * + * @param issuers the accepted identity providers, empty when omitted + * @author API Sheriff Team + * @since 1.0 + */ +public record TokenValidationConfig(List issuers) { + + /** + * Canonical constructor defensively copying {@code issuers} into an + * unmodifiable list and normalizing an absent list to empty. + */ + public TokenValidationConfig { + issuers = issuers == null ? List.of() : List.copyOf(issuers); + } +} diff --git a/api-sheriff/src/main/java/de/cuioss/sheriff/api/config/model/UpstreamConfig.java b/api-sheriff/src/main/java/de/cuioss/sheriff/api/config/model/UpstreamConfig.java new file mode 100644 index 0000000..64f74ae --- /dev/null +++ b/api-sheriff/src/main/java/de/cuioss/sheriff/api/config/model/UpstreamConfig.java @@ -0,0 +1,119 @@ +/* + * Copyright © 2022 CUI-OpenSource-Software (info@cuioss.de) + * + * 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 de.cuioss.sheriff.api.config.model; + +import java.util.Objects; +import java.util.Optional; + +import lombok.Builder; + +/** + * The per-route {@code upstream} block. + *

+ * The per-route {@code retry.enabled} / {@code not_modified.enabled} toggles are + * optional overrides of the resolved endpoint/global {@code upstream_defaults}; + * an absent toggle inherits the resolved value. The effective values are + * materialized once by the route-table builder. + * + * @param path the upstream path that replaces the matched prefix, + * empty when omitted + * @param connectTimeoutMs the connect timeout in milliseconds, empty when omitted + * @param readTimeoutMs the read timeout in milliseconds, empty when omitted + * @param retry the retry settings, empty when omitted + * @param notModified the HTTP-304 not-modified settings, empty when omitted + * @param circuitBreaker the circuit-breaker settings, empty when omitted + * @author API Sheriff Team + * @since 1.0 + */ +@Builder +public record UpstreamConfig(Optional path, Optional connectTimeoutMs, Optional readTimeoutMs, + Optional retry, Optional notModified, Optional circuitBreaker) { + + /** + * Canonical constructor normalizing absent components to {@link Optional#empty()}. + */ + public UpstreamConfig { + path = Objects.requireNonNullElse(path, Optional.empty()); + connectTimeoutMs = Objects.requireNonNullElse(connectTimeoutMs, Optional.empty()); + readTimeoutMs = Objects.requireNonNullElse(readTimeoutMs, Optional.empty()); + retry = Objects.requireNonNullElse(retry, Optional.empty()); + notModified = Objects.requireNonNullElse(notModified, Optional.empty()); + circuitBreaker = Objects.requireNonNullElse(circuitBreaker, Optional.empty()); + } + + /** + * Per-route retry settings. + * + * @param enabled whether retry is enabled for this route; empty inherits + * the resolved endpoint/global default + * @param maxAttempts the maximum retry attempts, empty when omitted + * @param idempotentOnly whether only idempotent methods are retried, empty when + * omitted + * @author API Sheriff Team + * @since 1.0 + */ + @Builder + public record Retry(Optional enabled, Optional maxAttempts, Optional idempotentOnly) { + + /** + * Canonical constructor normalizing absent components to {@link Optional#empty()}. + */ + public Retry { + enabled = Objects.requireNonNullElse(enabled, Optional.empty()); + maxAttempts = Objects.requireNonNullElse(maxAttempts, Optional.empty()); + idempotentOnly = Objects.requireNonNullElse(idempotentOnly, Optional.empty()); + } + } + + /** + * Per-route HTTP-304 not-modified settings. + * + * @param enabled whether not-modified handling is enabled for this route; empty + * inherits the resolved endpoint/global default + * @author API Sheriff Team + * @since 1.0 + */ + public record NotModified(Optional enabled) { + + /** + * Canonical constructor normalizing an absent {@code enabled} to + * {@link Optional#empty()}. + */ + public NotModified { + enabled = Objects.requireNonNullElse(enabled, Optional.empty()); + } + } + + /** + * Per-route circuit-breaker settings. + * + * @param failures the failure threshold before opening, empty when omitted + * @param resetMs the reset window in milliseconds, empty when omitted + * @author API Sheriff Team + * @since 1.0 + */ + @Builder + public record CircuitBreaker(Optional failures, Optional resetMs) { + + /** + * Canonical constructor normalizing absent components to {@link Optional#empty()}. + */ + public CircuitBreaker { + failures = Objects.requireNonNullElse(failures, Optional.empty()); + resetMs = Objects.requireNonNullElse(resetMs, Optional.empty()); + } + } +} diff --git a/api-sheriff/src/main/java/de/cuioss/sheriff/api/config/model/UpstreamDefaultsConfig.java b/api-sheriff/src/main/java/de/cuioss/sheriff/api/config/model/UpstreamDefaultsConfig.java new file mode 100644 index 0000000..44711dc --- /dev/null +++ b/api-sheriff/src/main/java/de/cuioss/sheriff/api/config/model/UpstreamDefaultsConfig.java @@ -0,0 +1,45 @@ +/* + * Copyright © 2022 CUI-OpenSource-Software (info@cuioss.de) + * + * 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 de.cuioss.sheriff.api.config.model; + +/** + * The {@code upstream_defaults} block declarable at two scopes — global + * ({@code gateway.yaml}) and per endpoint ({@code endpoints/*.yaml}). + *

+ * It carries the retry and HTTP-304 not-modified toggles, both defaulting to + * {@code true}. When an endpoint file declares its own {@code upstream_defaults} + * block, it replaces the global block wholesale for that endpoint's + * routes (no field merging); the effective per-route values are materialized by + * the route-table builder. + * + * @param retryEnabled whether upstream retry is enabled (default {@code true}) + * @param notModifiedEnabled whether HTTP-304 not-modified handling is enabled + * (default {@code true}) + * @author API Sheriff Team + * @since 1.0 + */ +public record UpstreamDefaultsConfig(boolean retryEnabled, boolean notModifiedEnabled) { + + /** + * The default {@code upstream_defaults} — both toggles {@code true} — applied + * when no block is declared at a given scope. + * + * @return a defaults instance with retry and not-modified both enabled + */ + public static UpstreamDefaultsConfig defaults() { + return new UpstreamDefaultsConfig(true, true); + } +} diff --git a/api-sheriff/src/main/java/de/cuioss/sheriff/api/config/model/package-info.java b/api-sheriff/src/main/java/de/cuioss/sheriff/api/config/model/package-info.java new file mode 100644 index 0000000..85359a6 --- /dev/null +++ b/api-sheriff/src/main/java/de/cuioss/sheriff/api/config/model/package-info.java @@ -0,0 +1,46 @@ +/* + * Copyright © 2022 CUI-OpenSource-Software (info@cuioss.de) + * + * 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. + */ +/** + * Immutable configuration model for the API Sheriff gateway. + *

+ * The records in this package are the binding target for the YAML configuration + * loader: {@code gateway.yaml} binds to {@link de.cuioss.sheriff.api.config.model.GatewayConfig} + * and each {@code endpoints/*.yaml} file binds to + * {@link de.cuioss.sheriff.api.config.model.EndpointConfig}. Configuration is + * static, file-based, and immutable: it is read once at startup, + * validated in full, frozen into these value objects, and never mutated at + * runtime. + *

+ * Immutability and thread-safety. Every type here is an + * immutable Java record. Collection components are defensively copied into + * unmodifiable collections by the canonical constructors, and absent optional + * scalars are represented as {@link java.util.Optional} (never {@code null}) and + * absent collections as empty collections (never {@code null}). Instances are + * therefore safe to publish and share across threads without external + * synchronization. + *

+ * Framework-agnostic seam (ADR-0005). This package carries no + * CDI, Quarkus, Vert.x, MicroProfile, or Micrometer imports. Collaborators are + * supplied by constructor parameters; the framework-bound wiring lives in the + * edge package. + * + * @author API Sheriff Team + * @since 1.0 + */ +@NullMarked +package de.cuioss.sheriff.api.config.model; + +import org.jspecify.annotations.NullMarked; diff --git a/api-sheriff/src/main/java/de/cuioss/sheriff/api/config/package-info.java b/api-sheriff/src/main/java/de/cuioss/sheriff/api/config/package-info.java new file mode 100644 index 0000000..fdf592a --- /dev/null +++ b/api-sheriff/src/main/java/de/cuioss/sheriff/api/config/package-info.java @@ -0,0 +1,38 @@ +/* + * Copyright © 2022 CUI-OpenSource-Software (info@cuioss.de) + * + * 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. + */ +/** + * Framework-agnostic configuration assembly for the API Sheriff gateway. + *

+ * This package hosts the collaborators that turn the bound, validated + * configuration model into the runtime-ready {@link de.cuioss.sheriff.api.config.model.RouteTable}: + * the {@link de.cuioss.sheriff.api.config.RouteTableBuilder} that materializes the + * effective per-route settings, and the {@link de.cuioss.sheriff.api.config.ConfigLogMessages} + * structured-log catalogue. The framework-bound wiring — the CDI producer that + * drives the boot pipeline and fails startup on invalid configuration — lives in + * the {@code de.cuioss.sheriff.api.quarkus} edge package (ADR-0005); nothing here + * carries a CDI, Quarkus, or MicroProfile import. + *

+ * Null-safety. The package is {@link org.jspecify.annotations.NullMarked}: + * references are non-null by default, with any nullable component annotated + * explicitly. + * + * @author API Sheriff Team + * @since 1.0 + */ +@NullMarked +package de.cuioss.sheriff.api.config; + +import org.jspecify.annotations.NullMarked; diff --git a/api-sheriff/src/main/java/de/cuioss/sheriff/api/config/topology/EndpointEnablementResolver.java b/api-sheriff/src/main/java/de/cuioss/sheriff/api/config/topology/EndpointEnablementResolver.java new file mode 100644 index 0000000..69a401e --- /dev/null +++ b/api-sheriff/src/main/java/de/cuioss/sheriff/api/config/topology/EndpointEnablementResolver.java @@ -0,0 +1,100 @@ +/* + * Copyright © 2022 CUI-OpenSource-Software (info@cuioss.de) + * + * 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 de.cuioss.sheriff.api.config.topology; + +import java.util.List; +import java.util.Locale; +import java.util.Objects; +import java.util.function.UnaryOperator; + +import de.cuioss.sheriff.api.config.model.EndpointConfig; +import org.jspecify.annotations.Nullable; + +/** + * Resolves the effective enabled state of each endpoint (pipeline step 5), applying + * the {@code ENDPOINT__ENABLED} environment override on top of the file + * {@code enabled} field. + *

+ * Precedence: the environment variable wins over the file value, which itself + * defaults to {@code true}. The variable name derives from the endpoint id + * uppercased with every non-alphanumeric character mapped to an underscore + * ({@code order-api} → {@code ENDPOINT_ORDER_API_ENABLED}). Disabled endpoints are + * dropped before route-table assembly and are exempt from alias resolution and the + * disjointness / passthrough checks, though they remain schema-validated. + *

+ * Framework-agnostic (ADR-0005): the environment lookup is constructor-injected. + * + * @author API Sheriff Team + * @since 1.0 + */ +public final class EndpointEnablementResolver { + + private final UnaryOperator<@Nullable String> environment; + + /** + * Creates a resolver backed by the process environment + * ({@link System#getenv(String)}). + */ + public EndpointEnablementResolver() { + this(System::getenv); + } + + /** + * Creates a resolver backed by the supplied environment lookup. + * + * @param environment maps an environment-variable name to its value, or + * {@code null} when undefined + */ + public EndpointEnablementResolver(UnaryOperator<@Nullable String> environment) { + this.environment = Objects.requireNonNull(environment, "environment"); + } + + /** + * Reports whether the endpoint is effectively enabled. + * + * @param endpoint the endpoint to evaluate + * @return the environment override when set, otherwise the file {@code enabled} + * value + */ + public boolean isEnabled(EndpointConfig endpoint) { + String override = environment.apply(environmentVariableName(endpoint.id())); + if (override != null) { + return Boolean.parseBoolean(override.trim()); + } + return endpoint.enabled(); + } + + /** + * Filters the endpoints down to those effectively enabled, preserving order. + * + * @param endpoints the schema-validated endpoints + * @return the enabled endpoints + */ + public List enabledEndpoints(List endpoints) { + return endpoints.stream().filter(this::isEnabled).toList(); + } + + /** + * Derives the enablement-override environment variable name for an endpoint id. + * + * @param id the endpoint id + * @return the {@code ENDPOINT__ENABLED} variable name + */ + public static String environmentVariableName(String id) { + String normalized = id.toUpperCase(Locale.ROOT).replaceAll("[^A-Z0-9]", "_"); + return "ENDPOINT_" + normalized + "_ENABLED"; + } +} diff --git a/api-sheriff/src/main/java/de/cuioss/sheriff/api/config/topology/TopologyResolver.java b/api-sheriff/src/main/java/de/cuioss/sheriff/api/config/topology/TopologyResolver.java new file mode 100644 index 0000000..2484b9a --- /dev/null +++ b/api-sheriff/src/main/java/de/cuioss/sheriff/api/config/topology/TopologyResolver.java @@ -0,0 +1,182 @@ +/* + * Copyright © 2022 CUI-OpenSource-Software (info@cuioss.de) + * + * 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 de.cuioss.sheriff.api.config.topology; + +import java.io.IOException; +import java.io.Reader; +import java.io.Serial; +import java.net.URI; +import java.net.URISyntaxException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Objects; +import java.util.Properties; +import java.util.function.UnaryOperator; +import java.util.regex.Pattern; + +import de.cuioss.sheriff.api.config.model.EndpointConfig; +import de.cuioss.sheriff.api.config.model.ResolvedTopology; +import de.cuioss.sheriff.api.config.model.ResolvedUpstream; +import org.jspecify.annotations.Nullable; + +/** + * Resolves topology aliases to decomposed upstreams for enabled endpoints (pipeline + * step 6). + *

+ * Each alias (pattern {@code [A-Z][A-Z0-9_]*}) is resolved with the precedence + * {@code TOPOLOGY_} environment variable over the {@code topology.properties} + * file value, validated as a well-formed absolute URL, and decomposed once into a + * {@link ResolvedUpstream} (ADR-0004). Only aliases referenced by enabled + * endpoints are resolved; an alias referenced by an enabled endpoint that resolves + * to neither an environment nor a file value is a boot failure, while a disabled + * endpoint's alias need not resolve. + *

+ * Framework-agnostic (ADR-0005): the environment lookup is constructor-injected. + * + * @author API Sheriff Team + * @since 1.0 + */ +public final class TopologyResolver { + + private static final Pattern ALIAS = Pattern.compile("[A-Z][A-Z0-9_]*"); + private static final int HTTP_PORT = 80; + private static final int HTTPS_PORT = 443; + + private final UnaryOperator<@Nullable String> environment; + + /** + * Creates a resolver backed by the process environment + * ({@link System#getenv(String)}). + */ + public TopologyResolver() { + this(System::getenv); + } + + /** + * Creates a resolver backed by the supplied environment lookup. + * + * @param environment maps an environment-variable name to its value, or + * {@code null} when undefined + */ + public TopologyResolver(UnaryOperator<@Nullable String> environment) { + this.environment = Objects.requireNonNull(environment, "environment"); + } + + /** + * Resolves and decomposes the topology aliases referenced by the enabled + * endpoints. + * + * @param topologyFile the {@code topology.properties} file (may be absent) + * @param enabledEndpoints the endpoints already filtered to those enabled + * @return the immutable resolved topology + * @throws TopologyResolutionException when a referenced alias is unresolved or + * resolves to a malformed URL + */ + public ResolvedTopology resolve(Path topologyFile, List enabledEndpoints) { + Map fileAliases = readProperties(topologyFile); + Map resolved = new LinkedHashMap<>(); + for (EndpointConfig endpoint : enabledEndpoints) { + String alias = endpoint.baseUrl(); + if (resolved.containsKey(alias)) { + continue; + } + String value = resolveValue(alias, fileAliases); + if (value == null) { + throw new TopologyResolutionException( + "Unresolved topology alias '%s' referenced by enabled endpoint '%s'" + .formatted(alias, endpoint.id())); + } + resolved.put(alias, decompose(alias, value.trim())); + } + return new ResolvedTopology(resolved); + } + + private @Nullable String resolveValue(String alias, Map fileAliases) { + String override = environment.apply("TOPOLOGY_" + alias); + if (override != null) { + return override; + } + return fileAliases.get(alias); + } + + private static Map readProperties(Path topologyFile) { + if (!Files.isRegularFile(topologyFile)) { + return Map.of(); + } + Properties properties = new Properties(); + try (Reader reader = Files.newBufferedReader(topologyFile)) { + properties.load(reader); + } catch (IOException e) { + throw new TopologyResolutionException("Cannot read topology file: " + topologyFile, e); + } + Map aliases = new LinkedHashMap<>(); + for (String name : properties.stringPropertyNames()) { + if (ALIAS.matcher(name).matches()) { + aliases.put(name, properties.getProperty(name)); + } + } + return aliases; + } + + private static ResolvedUpstream decompose(String alias, String url) { + URI uri; + try { + uri = new URI(url); + } catch (URISyntaxException e) { + throw new TopologyResolutionException("Malformed topology URL for alias '%s': %s".formatted(alias, url), e); + } + if (uri.getScheme() == null || uri.getHost() == null) { + throw new TopologyResolutionException( + "Topology URL for alias '%s' must be absolute with scheme and host: %s".formatted(alias, url)); + } + String scheme = uri.getScheme().toLowerCase(Locale.ROOT); + int port = uri.getPort() != -1 ? uri.getPort() : defaultPort(scheme); + String basePath = uri.getPath() == null ? "" : uri.getPath(); + return new ResolvedUpstream(scheme, uri.getHost(), port, basePath); + } + + private static int defaultPort(String scheme) { + return switch (scheme) { + case "http" -> HTTP_PORT; + case "https" -> HTTPS_PORT; + default -> -1; + }; + } + + /** + * Signals that a topology alias could not be resolved or decomposed. + * + * @author API Sheriff Team + * @since 1.0 + */ + public static final class TopologyResolutionException extends RuntimeException { + + @Serial + private static final long serialVersionUID = 1L; + + TopologyResolutionException(String message) { + super(message); + } + + TopologyResolutionException(String message, Throwable cause) { + super(message, cause); + } + } +} diff --git a/api-sheriff/src/main/java/de/cuioss/sheriff/api/config/topology/package-info.java b/api-sheriff/src/main/java/de/cuioss/sheriff/api/config/topology/package-info.java new file mode 100644 index 0000000..9514d76 --- /dev/null +++ b/api-sheriff/src/main/java/de/cuioss/sheriff/api/config/topology/package-info.java @@ -0,0 +1,37 @@ +/* + * Copyright © 2022 CUI-OpenSource-Software (info@cuioss.de) + * + * 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. + */ +/** + * Endpoint-enablement and topology-alias resolution for the boot pipeline. + *

+ * {@link de.cuioss.sheriff.api.config.topology.EndpointEnablementResolver} computes + * each endpoint's effective enabled state (environment override over the file + * default) and drops disabled endpoints, and + * {@link de.cuioss.sheriff.api.config.topology.TopologyResolver} resolves the + * topology aliases referenced by the surviving enabled endpoints — applying the + * {@code TOPOLOGY_} environment override, validating each URL, and + * decomposing it once into a + * {@link de.cuioss.sheriff.api.config.model.ResolvedUpstream} (ADR-0004). + *

+ * Framework-agnostic seam (ADR-0005). Both resolvers take their + * environment lookup as a constructor parameter and carry no framework imports. + * + * @author API Sheriff Team + * @since 1.0 + */ +@NullMarked +package de.cuioss.sheriff.api.config.topology; + +import org.jspecify.annotations.NullMarked; diff --git a/api-sheriff/src/main/java/de/cuioss/sheriff/api/config/validation/ConfigValidator.java b/api-sheriff/src/main/java/de/cuioss/sheriff/api/config/validation/ConfigValidator.java new file mode 100644 index 0000000..234503e --- /dev/null +++ b/api-sheriff/src/main/java/de/cuioss/sheriff/api/config/validation/ConfigValidator.java @@ -0,0 +1,264 @@ +/* + * Copyright © 2022 CUI-OpenSource-Software (info@cuioss.de) + * + * 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 de.cuioss.sheriff.api.config.validation; + +import java.util.ArrayList; +import java.util.EnumSet; +import java.util.HashSet; +import java.util.List; +import java.util.Objects; +import java.util.Set; + +import de.cuioss.sheriff.api.config.load.ConfigError; +import de.cuioss.sheriff.api.config.model.AuthConfig; +import de.cuioss.sheriff.api.config.model.EndpointConfig; +import de.cuioss.sheriff.api.config.model.GatewayConfig; +import de.cuioss.sheriff.api.config.model.HttpMethod; +import de.cuioss.sheriff.api.config.model.OidcConfig; +import de.cuioss.sheriff.api.config.model.ResolvedTopology; +import de.cuioss.sheriff.api.config.model.RouteConfig; +import de.cuioss.sheriff.api.config.validation.rule.ValidationRule; + +/** + * Runs the cross-cutting configuration rules (pipeline step 7) that cannot be + * expressed structurally in the D2 JSON Schemas, aggregating every violation in a + * single pass. + *

+ * Each rule is a {@link ValidationRule}; {@link #validate} executes them all and + * returns the combined, file- and path-annotated {@link ConfigError} list — never + * stopping at the first problem. Disabled endpoints have already been dropped by the + * enablement resolver, so the rules that only concern live routes (alias + * resolvability, effective-method membership) see enabled endpoints only. + * Structural verb rules (a config naming {@code TRACE}/{@code CONNECT}) are enforced + * upstream by the schema and the {@link HttpMethod} enum, which cannot represent + * those verbs, so no post-binding rule is needed for them. + *

+ * Framework-agnostic (ADR-0005): the rule set is supplied at construction and the + * validator carries no framework imports. + * + * @author API Sheriff Team + * @since 1.0 + */ +public final class ConfigValidator { + + private static final int SUPPORTED_VERSION = 1; + private static final String GATEWAY_FILE = "gateway.yaml"; + private static final String TRUST_ALL_IPV4 = "0.0.0.0/0"; + private static final String TRUST_ALL_IPV6 = "::/0"; + private static final String WILDCARD_ORIGIN = "*"; + private static final int MILLIS_PER_SECOND = 1000; + + private static final List DEFAULT_RULES = List.of( + (gateway, endpoints, topology, errors) -> validateVersion(gateway, errors), + (gateway, endpoints, topology, errors) -> validateEndpointIdUniqueness(endpoints, errors), + (gateway, endpoints, topology, errors) -> validateRouteIdUniqueness(endpoints, errors), + (gateway, endpoints, topology, errors) -> validateBaseUrlResolvable(endpoints, topology, errors), + (gateway, endpoints, topology, errors) -> validateEffectiveAuth(gateway, endpoints, errors), + (gateway, endpoints, topology, errors) -> validateMethodMembership(gateway, endpoints, errors), + (gateway, endpoints, topology, errors) -> validateWholeSecondTimeouts(endpoints, errors), + (gateway, endpoints, topology, errors) -> validateForwardedTrust(gateway, errors), + (gateway, endpoints, topology, errors) -> validateCors(gateway, errors), + (gateway, endpoints, topology, errors) -> validateSessionMode(gateway, errors)); + + private final List rules; + + /** + * Creates a validator with the built-in rule set. + */ + public ConfigValidator() { + this(DEFAULT_RULES); + } + + /** + * Creates a validator with a custom rule set. + * + * @param rules the rules to run, in order + */ + public ConfigValidator(List rules) { + this.rules = List.copyOf(Objects.requireNonNull(rules, "rules")); + } + + /** + * Runs every rule and returns all violations discovered in one pass. + * + * @param gateway the bound gateway document + * @param enabledEndpoints the endpoints filtered to those enabled + * @param topology the resolved topology + * @return the aggregated, unmodifiable list of violations, empty when valid + */ + public List validate(GatewayConfig gateway, List enabledEndpoints, + ResolvedTopology topology) { + List errors = new ArrayList<>(); + for (ValidationRule rule : rules) { + rule.validate(gateway, enabledEndpoints, topology, errors); + } + return List.copyOf(errors); + } + + private static void validateVersion(GatewayConfig gateway, List errors) { + if (gateway.version() != SUPPORTED_VERSION) { + errors.add(new ConfigError(GATEWAY_FILE, "/version", + "unsupported config version %d (supported: %d)".formatted(gateway.version(), SUPPORTED_VERSION))); + } + } + + private static void validateEndpointIdUniqueness(List endpoints, List errors) { + Set seen = new HashSet<>(); + for (EndpointConfig endpoint : endpoints) { + if (!seen.add(endpoint.id())) { + errors.add(new ConfigError(endpointFile(endpoint), "/endpoint/id", + "duplicate endpoint id: " + endpoint.id())); + } + } + } + + private static void validateRouteIdUniqueness(List endpoints, List errors) { + Set seen = new HashSet<>(); + for (EndpointConfig endpoint : endpoints) { + for (RouteConfig route : endpoint.routes()) { + if (!seen.add(route.id())) { + errors.add(new ConfigError(endpointFile(endpoint), "/endpoint/routes", + "duplicate route id: " + route.id())); + } + } + } + } + + private static void validateBaseUrlResolvable(List endpoints, ResolvedTopology topology, + List errors) { + for (EndpointConfig endpoint : endpoints) { + if (topology.lookup(endpoint.baseUrl()).isEmpty()) { + errors.add(new ConfigError(endpointFile(endpoint), "/endpoint/base_url", + "unresolved topology alias: " + endpoint.baseUrl())); + } + } + } + + private static void validateEffectiveAuth(GatewayConfig gateway, List endpoints, + List errors) { + Set requires = new HashSet<>(); + for (EndpointConfig endpoint : endpoints) { + for (RouteConfig route : endpoint.routes()) { + requires.add(effectiveRequire(endpoint, route)); + } + } + if (requires.contains("bearer") + && gateway.tokenValidation().map(tv -> tv.issuers().isEmpty()).orElse(true)) { + errors.add(new ConfigError(GATEWAY_FILE, "/token_validation", + "effective auth 'bearer' requires token_validation with at least one issuer")); + } + if (requires.contains("session") && gateway.oidc().isEmpty()) { + errors.add(new ConfigError(GATEWAY_FILE, "/oidc", "effective auth 'session' requires an oidc block")); + } + } + + private static void validateMethodMembership(GatewayConfig gateway, List endpoints, + List errors) { + for (EndpointConfig endpoint : endpoints) { + Set allowed = effectiveAllowedMethods(gateway, endpoint); + for (RouteConfig route : endpoint.routes()) { + for (HttpMethod method : route.match().methods()) { + if (!allowed.contains(method)) { + errors.add(new ConfigError(endpointFile(endpoint), "/endpoint/routes", + "route '%s' matches method %s outside the effective allowed_methods" + .formatted(route.id(), method))); + } + } + } + } + } + + private static void validateWholeSecondTimeouts(List endpoints, List errors) { + for (EndpointConfig endpoint : endpoints) { + for (RouteConfig route : endpoint.routes()) { + route.upstream().ifPresent(upstream -> { + upstream.connectTimeoutMs().ifPresent(value -> + requireWholeSecond(endpoint, route, "connect_timeout_ms", value, errors)); + upstream.readTimeoutMs().ifPresent(value -> + requireWholeSecond(endpoint, route, "read_timeout_ms", value, errors)); + }); + } + } + } + + private static void requireWholeSecond(EndpointConfig endpoint, RouteConfig route, String field, int value, + List errors) { + if (value <= 0) { + errors.add(new ConfigError(endpointFile(endpoint), + "/endpoint/routes/%s/upstream/%s".formatted(route.id(), field), + "%s must be a positive whole-second multiple of %d ms, was %d" + .formatted(field, MILLIS_PER_SECOND, value))); + return; + } + if (value % MILLIS_PER_SECOND != 0) { + errors.add(new ConfigError(endpointFile(endpoint), + "/endpoint/routes/%s/upstream/%s".formatted(route.id(), field), + "%s must be a whole-second multiple of %d ms, was %d".formatted(field, MILLIS_PER_SECOND, value))); + } + } + + private static void validateForwardedTrust(GatewayConfig gateway, List errors) { + gateway.forwarded().ifPresent(forwarded -> { + for (String cidr : forwarded.trustedProxies()) { + if (TRUST_ALL_IPV4.equals(cidr) || TRUST_ALL_IPV6.equals(cidr)) { + errors.add(new ConfigError(GATEWAY_FILE, "/forwarded/trusted_proxies", + "trust-all CIDR is not permitted: " + cidr)); + } + } + }); + } + + private static void validateCors(GatewayConfig gateway, List errors) { + gateway.securityHeaders().flatMap(headers -> headers.cors()).ifPresent(cors -> { + if (cors.allowCredentials().orElse(false) && cors.allowedOrigins().contains(WILDCARD_ORIGIN)) { + errors.add(new ConfigError(GATEWAY_FILE, "/security_headers/cors", + "wildcard origin '*' is not permitted together with allow_credentials")); + } + }); + } + + private static void validateSessionMode(GatewayConfig gateway, List errors) { + gateway.oidc().flatMap(OidcConfig::session).ifPresent(session -> + session.mode().ifPresent(mode -> { + if ("cookie".equals(mode) && session.encryptionKey().isEmpty()) { + errors.add(new ConfigError(GATEWAY_FILE, "/oidc/session/encryption_key", + "cookie session mode requires an encryption_key")); + } + if ("server".equals(mode) && session.store().isEmpty()) { + errors.add(new ConfigError(GATEWAY_FILE, "/oidc/session/store", + "server session mode requires a store")); + } + })); + } + + private static String effectiveRequire(EndpointConfig endpoint, RouteConfig route) { + return route.auth().map(AuthConfig::require).orElse(endpoint.auth().require()); + } + + private static Set effectiveAllowedMethods(GatewayConfig gateway, EndpointConfig endpoint) { + if (!endpoint.allowedMethods().isEmpty()) { + return EnumSet.copyOf(endpoint.allowedMethods()); + } + if (!gateway.allowedMethods().isEmpty()) { + return EnumSet.copyOf(gateway.allowedMethods()); + } + return EnumSet.allOf(HttpMethod.class); + } + + private static String endpointFile(EndpointConfig endpoint) { + return "endpoints/" + endpoint.id() + ".yaml"; + } +} diff --git a/api-sheriff/src/main/java/de/cuioss/sheriff/api/config/validation/package-info.java b/api-sheriff/src/main/java/de/cuioss/sheriff/api/config/validation/package-info.java new file mode 100644 index 0000000..95a38d4 --- /dev/null +++ b/api-sheriff/src/main/java/de/cuioss/sheriff/api/config/validation/package-info.java @@ -0,0 +1,36 @@ +/* + * Copyright © 2022 CUI-OpenSource-Software (info@cuioss.de) + * + * 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. + */ +/** + * Cross-cutting configuration validation for the boot pipeline. + *

+ * {@link de.cuioss.sheriff.api.config.validation.ConfigValidator} runs the + * {@link de.cuioss.sheriff.api.config.validation.rule.ValidationRule} set that + * enforces the semantic rules the structural JSON Schemas cannot express — endpoint + * and route id uniqueness, alias resolvability for enabled endpoints, effective-auth + * dependencies, effective allowed-method membership, whole-second timeouts, + * forwarded trust-all rejection, CORS wildcard-with-credentials rejection, and + * session-mode conditionals — collecting every violation in a single pass. + *

+ * Framework-agnostic seam (ADR-0005). The validator and its rules + * carry no framework imports; the rule set is supplied at construction. + * + * @author API Sheriff Team + * @since 1.0 + */ +@NullMarked +package de.cuioss.sheriff.api.config.validation; + +import org.jspecify.annotations.NullMarked; diff --git a/api-sheriff/src/main/java/de/cuioss/sheriff/api/config/validation/rule/ValidationRule.java b/api-sheriff/src/main/java/de/cuioss/sheriff/api/config/validation/rule/ValidationRule.java new file mode 100644 index 0000000..f170baa --- /dev/null +++ b/api-sheriff/src/main/java/de/cuioss/sheriff/api/config/validation/rule/ValidationRule.java @@ -0,0 +1,51 @@ +/* + * Copyright © 2022 CUI-OpenSource-Software (info@cuioss.de) + * + * 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 de.cuioss.sheriff.api.config.validation.rule; + +import java.util.List; + +import de.cuioss.sheriff.api.config.load.ConfigError; +import de.cuioss.sheriff.api.config.model.EndpointConfig; +import de.cuioss.sheriff.api.config.model.GatewayConfig; +import de.cuioss.sheriff.api.config.model.ResolvedTopology; + +/** + * A single cross-cutting configuration validation rule. + *

+ * A rule inspects the bound gateway document, the enabled endpoints, and the + * resolved topology, and appends a {@link ConfigError} for each violation + * it finds to the shared error list. A rule never throws for a validation failure + * and never stops at the first problem — the aggregating + * {@link de.cuioss.sheriff.api.config.validation.ConfigValidator} runs every rule so + * all violations surface in one pass. + * + * @author API Sheriff Team + * @since 1.0 + */ +@FunctionalInterface +public interface ValidationRule { + + /** + * Validates the configuration, appending an error per violation. + * + * @param gateway the bound gateway document + * @param enabledEndpoints the endpoints filtered to those enabled + * @param topology the resolved topology for the enabled endpoints + * @param errors the shared, mutable error accumulator to append to + */ + void validate(GatewayConfig gateway, List enabledEndpoints, ResolvedTopology topology, + List errors); +} diff --git a/api-sheriff/src/main/java/de/cuioss/sheriff/api/config/validation/rule/package-info.java b/api-sheriff/src/main/java/de/cuioss/sheriff/api/config/validation/rule/package-info.java new file mode 100644 index 0000000..aa4d6dd --- /dev/null +++ b/api-sheriff/src/main/java/de/cuioss/sheriff/api/config/validation/rule/package-info.java @@ -0,0 +1,30 @@ +/* + * Copyright © 2022 CUI-OpenSource-Software (info@cuioss.de) + * + * 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. + */ +/** + * The {@link de.cuioss.sheriff.api.config.validation.rule.ValidationRule} contract: + * the functional interface a single cross-cutting configuration rule implements, + * appending a {@link de.cuioss.sheriff.api.config.load.ConfigError} per violation to + * the shared accumulator so the + * {@link de.cuioss.sheriff.api.config.validation.ConfigValidator} can report every + * violation in one pass. + * + * @author API Sheriff Team + * @since 1.0 + */ +@NullMarked +package de.cuioss.sheriff.api.config.validation.rule; + +import org.jspecify.annotations.NullMarked; diff --git a/api-sheriff/src/main/java/de/cuioss/sheriff/api/gateway/proxy/ProxyConfiguration.java b/api-sheriff/src/main/java/de/cuioss/sheriff/api/gateway/proxy/ProxyConfiguration.java deleted file mode 100644 index 5e8ce7d..0000000 --- a/api-sheriff/src/main/java/de/cuioss/sheriff/api/gateway/proxy/ProxyConfiguration.java +++ /dev/null @@ -1,54 +0,0 @@ -/* - * Copyright © 2022 CUI-OpenSource-Software (info@cuioss.de) - * - * 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 de.cuioss.sheriff.api.gateway.proxy; - -import io.smallrye.config.ConfigMapping; -import io.smallrye.config.WithDefault; - -/** - * Interim proxy route configuration, bound to the {@code sheriff.proxy.*} - * MicroProfile config namespace. - *

- * This is deliberately minimal: a single catch-all route defined by a matching - * path prefix and a single upstream base URL. It is replaced by the real - * configuration subsystem in Plan 02 and the real request pipeline in Plan 03. - * - * @author API Sheriff Team - * @since 1.0 - */ -@ConfigMapping(prefix = "sheriff.proxy") -public interface ProxyConfiguration { - - /** - * The path prefix that activates proxying. Requests whose path starts with - * {@code /} are forwarded to {@link #upstreamUrl()}; the prefix - * is stripped and the remaining path is appended to the upstream URL. - * Everything else is left to the default {@code 404} (deny-by-default). - * - * @return the activating path prefix, never {@code null} - */ - @WithDefault("/proxy") - String pathPrefix(); - - /** - * The upstream base URL that matched requests are forwarded to. The request - * path remainder (after the prefix) and query string are appended to it. - * - * @return the upstream base URL, never {@code null} - */ - @WithDefault("http://localhost:8080") - String upstreamUrl(); -} diff --git a/api-sheriff/src/main/java/de/cuioss/sheriff/api/gateway/proxy/ProxyRoute.java b/api-sheriff/src/main/java/de/cuioss/sheriff/api/gateway/proxy/ProxyRoute.java index 8a83f3e..19c30a8 100644 --- a/api-sheriff/src/main/java/de/cuioss/sheriff/api/gateway/proxy/ProxyRoute.java +++ b/api-sheriff/src/main/java/de/cuioss/sheriff/api/gateway/proxy/ProxyRoute.java @@ -21,9 +21,13 @@ import java.net.http.HttpRequest; import java.net.http.HttpResponse; import java.time.Duration; +import java.util.Optional; import java.util.Set; import java.util.concurrent.ExecutorService; +import de.cuioss.sheriff.api.config.model.ResolvedRoute; +import de.cuioss.sheriff.api.config.model.ResolvedUpstream; +import de.cuioss.sheriff.api.config.model.RouteTable; import de.cuioss.sheriff.api.gateway.proxy.ProxyLogMessages.INFO; import de.cuioss.sheriff.api.gateway.proxy.ProxyLogMessages.WARN; import de.cuioss.tools.logging.CuiLogger; @@ -39,19 +43,24 @@ import jakarta.inject.Inject; /** - * Interim minimal reverse proxy: a single catch-all Vert.x route on the data - * plane that forwards matched requests to a configured upstream. + * Interim minimal reverse proxy: a Vert.x route per configured + * {@code path_prefix} that forwards matched requests to the upstream resolved for + * that route. *

- * Behaviour (deliberately interim — Plan 03 keeps this edge and replaces the + * The routing table is the {@link RouteTable} assembled by the configuration + * subsystem (Deliverable 7); this edge sources each route's upstream from the + * table's {@link ResolvedUpstream} rather than from a single static configuration + * bean. Behaviour (deliberately interim — Plan 03 keeps this edge and replaces the * internals with the real request pipeline): *

* @@ -80,23 +89,22 @@ public class ProxyRoute { "connection", "keep-alive", "proxy-authenticate", "proxy-authorization", "te", "trailer", "transfer-encoding", "upgrade", "content-length"); - private final ProxyConfiguration config; + private final RouteTable routeTable; private final ExecutorService virtualThreadExecutor; private final HttpClient httpClient; - private final String upstreamBaseUrl; /** * Creates the proxy route. * - * @param config the interim proxy configuration + * @param routeTable the assembled route table sourcing each route's + * upstream * @param virtualThreadExecutor the Quarkus-managed virtual-thread executor * (thread names use {@code quarkus.virtual-threads.name-prefix}) */ @Inject - public ProxyRoute(ProxyConfiguration config, @VirtualThreads ExecutorService virtualThreadExecutor) { - this.config = config; + public ProxyRoute(RouteTable routeTable, @VirtualThreads ExecutorService virtualThreadExecutor) { + this.routeTable = routeTable; this.virtualThreadExecutor = virtualThreadExecutor; - this.upstreamBaseUrl = stripTrailingSlash(config.upstreamUrl()); this.httpClient = HttpClient.newBuilder() .version(HttpClient.Version.HTTP_1_1) .connectTimeout(Duration.ofSeconds(10)) @@ -105,37 +113,50 @@ public ProxyRoute(ProxyConfiguration config, @VirtualThreads ExecutorService vir } /** - * Registers the catch-all proxy route on the data-plane router at startup. + * Registers one catch-all proxy route per {@code path_prefix} in the route + * table on the data-plane router at startup. * * @param router the Vert.x web router, observed during Quarkus startup */ public void registerRoutes(@Observes Router router) { - String routePath = stripTrailingSlash(config.pathPrefix()) + "/*"; - router.route(routePath) - .handler(BodyHandler.create()) - .handler(ctx -> virtualThreadExecutor.execute(() -> forward(ctx))); - LOGGER.info(INFO.ROUTE_REGISTERED, config.pathPrefix(), upstreamBaseUrl); + for (ResolvedRoute route : routeTable.routes()) { + String routePath = stripTrailingSlash(route.pathPrefix()) + "/*"; + router.route(routePath) + .handler(BodyHandler.create()) + .handler(ctx -> virtualThreadExecutor.execute(() -> forward(ctx))); + LOGGER.info(INFO.ROUTE_REGISTERED, route.pathPrefix(), upstreamBaseUrl(route.upstream())); + } } /** - * Forwards a single request to the upstream. Runs on a virtual thread; the - * response is written back on the Vert.x context. + * Forwards a single request to the upstream resolved for its matching route. + * Runs on a virtual thread; the response is written back on the Vert.x context. * * @param ctx the routing context of the matched request */ private void forward(RoutingContext ctx) { - String prefix = stripTrailingSlash(config.pathPrefix()); + String rawUri = ctx.request().uri(); + int queryStart = rawUri.indexOf('?'); + String rawPath = queryStart < 0 ? rawUri : rawUri.substring(0, queryStart); + String rawQuery = queryStart < 0 ? "" : rawUri.substring(queryStart); + + Optional match = routeTable.lookup(rawPath); + if (match.isEmpty()) { + ctx.vertx().runOnContext(v -> { + if (!ctx.response().ended()) { + ctx.response().setStatusCode(404).end(); + } + }); + return; + } + + String prefix = stripTrailingSlash(match.get().pathPrefix()); + String upstreamBaseUrl = upstreamBaseUrl(match.get().upstream()); // Everything that can throw (substring, URI parsing, send) stays inside the // try so any failure yields a 502 rather than an uncaught error that would // leave the client hanging on the virtual thread. String target = upstreamBaseUrl; try { - // Derive the remainder from the raw (still percent-encoded) request URI so - // the original path/query encoding is preserved without double-encoding. - String rawUri = ctx.request().uri(); - int queryStart = rawUri.indexOf('?'); - String rawPath = queryStart < 0 ? rawUri : rawUri.substring(0, queryStart); - String rawQuery = queryStart < 0 ? "" : rawUri.substring(queryStart); String remainder = rawPath.substring(prefix.length()); target = upstreamBaseUrl + remainder + rawQuery; @@ -188,6 +209,11 @@ private void failBadGateway(RoutingContext ctx, String target, Exception cause) }); } + private static String upstreamBaseUrl(ResolvedUpstream upstream) { + return stripTrailingSlash("%s://%s:%d%s".formatted(upstream.scheme(), upstream.host(), upstream.port(), + upstream.basePath())); + } + private static String stripTrailingSlash(String value) { return value.endsWith("/") ? value.substring(0, value.length() - 1) : value; } diff --git a/api-sheriff/src/main/java/de/cuioss/sheriff/api/gateway/proxy/package-info.java b/api-sheriff/src/main/java/de/cuioss/sheriff/api/gateway/proxy/package-info.java index 0f4e124..a9fb77b 100644 --- a/api-sheriff/src/main/java/de/cuioss/sheriff/api/gateway/proxy/package-info.java +++ b/api-sheriff/src/main/java/de/cuioss/sheriff/api/gateway/proxy/package-info.java @@ -16,12 +16,13 @@ /** * Interim minimal reverse-proxy edge for the API Sheriff gateway. *

- * A single catch-all Vert.x route ({@link de.cuioss.sheriff.api.gateway.proxy.ProxyRoute}) - * forwards requests matching a configured path prefix - * ({@link de.cuioss.sheriff.api.gateway.proxy.ProxyConfiguration}) to an upstream - * URL, executing the blocking forward on a virtual thread. This is the outermost - * shell only: it is kept as the edge and has its internals replaced by the real - * request pipeline in Plan 03. + * One Vert.x route per {@code path_prefix} + * ({@link de.cuioss.sheriff.api.gateway.proxy.ProxyRoute}) forwards matched + * requests to the upstream resolved for that route by the configuration + * subsystem's {@link de.cuioss.sheriff.api.config.model.RouteTable}, executing the + * blocking forward on a virtual thread. This is the outermost shell only: it is + * kept as the edge and has its internals replaced by the real request pipeline in + * Plan 03. * * @since 1.0 */ diff --git a/api-sheriff/src/main/java/de/cuioss/sheriff/api/quarkus/ConfigModelReflection.java b/api-sheriff/src/main/java/de/cuioss/sheriff/api/quarkus/ConfigModelReflection.java new file mode 100644 index 0000000..c50f4ff --- /dev/null +++ b/api-sheriff/src/main/java/de/cuioss/sheriff/api/quarkus/ConfigModelReflection.java @@ -0,0 +1,94 @@ +/* + * Copyright © 2022 CUI-OpenSource-Software (info@cuioss.de) + * + * 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 de.cuioss.sheriff.api.quarkus; + +import de.cuioss.sheriff.api.config.model.AuthConfig; +import de.cuioss.sheriff.api.config.model.EndpointConfig; +import de.cuioss.sheriff.api.config.model.ForwardConfig; +import de.cuioss.sheriff.api.config.model.ForwardedConfig; +import de.cuioss.sheriff.api.config.model.GatewayConfig; +import de.cuioss.sheriff.api.config.model.HttpMethod; +import de.cuioss.sheriff.api.config.model.IssuerConfig; +import de.cuioss.sheriff.api.config.model.MatchConfig; +import de.cuioss.sheriff.api.config.model.Metadata; +import de.cuioss.sheriff.api.config.model.OidcConfig; +import de.cuioss.sheriff.api.config.model.Protocol; +import de.cuioss.sheriff.api.config.model.RateLimitConfig; +import de.cuioss.sheriff.api.config.model.RouteConfig; +import de.cuioss.sheriff.api.config.model.SecurityDefaultsConfig; +import de.cuioss.sheriff.api.config.model.SecurityFilterConfig; +import de.cuioss.sheriff.api.config.model.SecurityHeadersConfig; +import de.cuioss.sheriff.api.config.model.TlsConfig; +import de.cuioss.sheriff.api.config.model.TokenValidationConfig; +import de.cuioss.sheriff.api.config.model.UpstreamConfig; +import de.cuioss.sheriff.api.config.model.UpstreamDefaultsConfig; +import io.quarkus.runtime.annotations.RegisterForReflection; + +/** + * Registers the framework-agnostic configuration model records for reflection so + * Jackson can bind the YAML configuration trees into them in a native image. + *

+ * The {@link de.cuioss.sheriff.api.config.model} records carry no framework imports + * (ADR-0005 seam); this framework-bound holder consolidates their native + * reflection registration in one place — listing every Jackson-bound record, its + * nested records, and the bound enums — rather than annotating each model type. + * The class has no runtime behavior; it exists solely to carry the + * {@link RegisterForReflection} targets. + * + * @author API Sheriff Team + * @since 1.0 + */ +@RegisterForReflection(targets = { + GatewayConfig.class, + EndpointConfig.class, + Metadata.class, + TlsConfig.class, + TlsConfig.Mtls.class, + SecurityHeadersConfig.class, + SecurityHeadersConfig.Hsts.class, + SecurityHeadersConfig.Cors.class, + SecurityDefaultsConfig.class, + SecurityFilterConfig.class, + UpstreamDefaultsConfig.class, + ForwardedConfig.class, + ForwardConfig.class, + TokenValidationConfig.class, + IssuerConfig.class, + IssuerConfig.Jwks.class, + OidcConfig.class, + OidcConfig.Logout.class, + OidcConfig.Session.class, + OidcConfig.Csrf.class, + OidcConfig.Refresh.class, + OidcConfig.StepUp.class, + AuthConfig.class, + RouteConfig.class, + MatchConfig.class, + MatchConfig.HeaderMatcher.class, + UpstreamConfig.class, + UpstreamConfig.Retry.class, + UpstreamConfig.NotModified.class, + UpstreamConfig.CircuitBreaker.class, + RateLimitConfig.class, + HttpMethod.class, + Protocol.class +}) +public final class ConfigModelReflection { + + private ConfigModelReflection() { + // Reflection-registration holder; not instantiable. + } +} diff --git a/api-sheriff/src/main/java/de/cuioss/sheriff/api/quarkus/ConfigProducer.java b/api-sheriff/src/main/java/de/cuioss/sheriff/api/quarkus/ConfigProducer.java new file mode 100644 index 0000000..8cf6bfd --- /dev/null +++ b/api-sheriff/src/main/java/de/cuioss/sheriff/api/quarkus/ConfigProducer.java @@ -0,0 +1,155 @@ +/* + * Copyright © 2022 CUI-OpenSource-Software (info@cuioss.de) + * + * 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 de.cuioss.sheriff.api.quarkus; + +import java.nio.file.Path; +import java.util.List; + +import de.cuioss.sheriff.api.config.ConfigLogMessages; +import de.cuioss.sheriff.api.config.RouteTableBuilder; +import de.cuioss.sheriff.api.config.load.ConfigError; +import de.cuioss.sheriff.api.config.load.ConfigLoadException; +import de.cuioss.sheriff.api.config.load.ConfigLoader; +import de.cuioss.sheriff.api.config.load.EnvSecretResolver; +import de.cuioss.sheriff.api.config.model.EndpointConfig; +import de.cuioss.sheriff.api.config.model.GatewayConfig; +import de.cuioss.sheriff.api.config.model.Metadata; +import de.cuioss.sheriff.api.config.model.ResolvedTopology; +import de.cuioss.sheriff.api.config.model.RouteTable; +import de.cuioss.sheriff.api.config.topology.TopologyResolver; +import de.cuioss.sheriff.api.config.validation.ConfigValidator; +import de.cuioss.tools.logging.CuiLogger; +import io.quarkus.runtime.StartupEvent; +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.enterprise.event.Observes; +import jakarta.enterprise.inject.Produces; +import jakarta.inject.Singleton; +import org.eclipse.microprofile.config.inject.ConfigProperty; + +/** + * The framework-bound edge (ADR-0005 seam) that assembles the file-based + * configuration once, at boot, and exposes the immutable result as CDI beans. + *

+ * It drives the framework-agnostic boot pipeline — {@link ConfigLoader} (read, + * schema-validate, secret-resolve, bind) → endpoint-enablement filter → + * {@link TopologyResolver} → {@link ConfigValidator} → {@link RouteTableBuilder} — + * supplying every collaborator by construction. On the first collected violation + * the producer logs every problem through structured ERROR + * {@link ConfigLogMessages} records and throws, so Quarkus exits non-zero and never + * serves on partial configuration. On success it emits the {@code CONFIG_LOADED} + * INFO record carrying the audit {@code config_version} and publishes the bound + * {@link GatewayConfig} and assembled {@link RouteTable} as {@code @ApplicationScoped} + * beans. + * + * @author API Sheriff Team + * @since 1.0 + */ +@ApplicationScoped +public class ConfigProducer { + + private static final CuiLogger LOGGER = new CuiLogger(ConfigProducer.class); + private static final String TOPOLOGY_FILE = "topology.properties"; + + @ConfigProperty(name = "sheriff.config.dir", defaultValue = "config") + String configDir; + + private GatewayConfig gateway; + private RouteTable routeTable; + private boolean built; + + /** + * Forces eager assembly at boot so an invalid configuration fails startup + * before any request is served. + * + * @param event the Quarkus startup event + */ + void onStartup(@Observes StartupEvent event) { + buildOnce(); + } + + /** + * Produces the bound global gateway document. + *

+ * {@link Singleton} (a pseudo-scope, no client proxy) because + * {@link GatewayConfig} is a {@code record}: ArC cannot subclass a final type to + * build the proxy a normal scope such as {@code @ApplicationScoped} would require. + * The bean is immutable and assembled once at boot, so a single instance is exact. + * + * @return the immutable, validated {@link GatewayConfig} + */ + @Produces + @Singleton + public GatewayConfig gatewayConfig() { + buildOnce(); + return gateway; + } + + /** + * Produces the assembled route table. + *

+ * {@link Singleton} (a pseudo-scope, no client proxy) because {@link RouteTable} + * is a {@code record}: ArC cannot subclass a final type to build the proxy a + * normal scope such as {@code @ApplicationScoped} would require. The bean is + * immutable and assembled once at boot, so a single instance is exact. + * + * @return the immutable, longest-prefix-ordered {@link RouteTable} + */ + @Produces + @Singleton + public RouteTable routeTable() { + buildOnce(); + return routeTable; + } + + private synchronized void buildOnce() { + if (built) { + return; + } + Path directory = Path.of(configDir); + try { + ConfigLoader.LoadedConfig loaded = new ConfigLoader(directory, new EnvSecretResolver()).load(); + List enabled = loaded.endpoints().stream().filter(EndpointConfig::enabled).toList(); + ResolvedTopology topology = new TopologyResolver().resolve(directory.resolve(TOPOLOGY_FILE), enabled); + List violations = new ConfigValidator().validate(loaded.gateway(), enabled, topology); + if (!violations.isEmpty()) { + abort(violations); + } + this.gateway = loaded.gateway(); + this.routeTable = new RouteTableBuilder().build(loaded.gateway(), enabled, topology); + this.built = true; + LOGGER.info(ConfigLogMessages.INFO.CONFIG_LOADED, configVersion(gateway)); + } catch (ConfigLoadException e) { + abort(e.errors()); + } catch (TopologyResolver.TopologyResolutionException | RouteTableBuilder.RouteTableException e) { + LOGGER.error(e, ConfigLogMessages.ERROR.CONFIG_STARTUP_ABORTED, e.getMessage()); + throw new IllegalStateException("Refusing to start — configuration is invalid", e); + } + } + + private static void abort(List violations) { + for (ConfigError violation : violations) { + LOGGER.error(ConfigLogMessages.ERROR.CONFIG_VALIDATION_FAILED, violation.file(), violation.pointer(), + violation.message()); + } + String summary = "%d configuration violation(s)".formatted(violations.size()); + LOGGER.error(ConfigLogMessages.ERROR.CONFIG_STARTUP_ABORTED, summary); + throw new IllegalStateException("Refusing to start — " + summary); + } + + private static String configVersion(GatewayConfig gateway) { + return gateway.metadata().flatMap(Metadata::configVersion).orElse("unversioned"); + } +} diff --git a/api-sheriff/src/main/resources/application.properties b/api-sheriff/src/main/resources/application.properties index 07bcfc6..1bafb92 100644 --- a/api-sheriff/src/main/resources/application.properties +++ b/api-sheriff/src/main/resources/application.properties @@ -20,7 +20,7 @@ quarkus.tls.alpn=true # Native Image Configuration quarkus.native.additional-build-args=--enable-url-protocols=https,--enable-http,--enable-https,-O2 -quarkus.native.resources.includes=**/*.p12,**/*.crt,**/*.key +quarkus.native.resources.includes=**/*.p12,**/*.crt,**/*.key,schema/*.json quarkus.native.monitoring=jfr # Java 25 Mandrel builder image for JEP 491 (virtual threads without pinning) — see ADR-0001 quarkus.native.builder-image=quay.io/quarkus/ubi9-quarkus-mandrel-builder-image:jdk-25 @@ -28,10 +28,10 @@ quarkus.native.builder-image=quay.io/quarkus/ubi9-quarkus-mandrel-builder-image: # Virtual Threads Configuration quarkus.virtual-threads.name-prefix=api-sheriff -# Proxy Configuration (interim — replaced by the real config subsystem in Plan 02) -# Requests under / are forwarded to ; everything else -> 404. -sheriff.proxy.path-prefix=/proxy -sheriff.proxy.upstream-url=http://localhost:8080 +# Configuration subsystem — the directory holding gateway.yaml, the endpoints/ +# subdirectory, and topology.properties. Read once at startup by ConfigProducer, +# which validates the whole set and fails fast (non-zero exit) on any violation. +sheriff.config.dir=config # Logging quarkus.log.level=INFO diff --git a/api-sheriff/src/main/resources/schema/endpoint.schema.json b/api-sheriff/src/main/resources/schema/endpoint.schema.json new file mode 100644 index 0000000..89a2a5d --- /dev/null +++ b/api-sheriff/src/main/resources/schema/endpoint.schema.json @@ -0,0 +1,180 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://api-sheriff.cuioss.de/schema/endpoint.schema.json", + "title": "API Sheriff endpoints/*.yaml", + "description": "JSON Schema for a single endpoint file under endpoints/*.yaml. Mirrors doc/configuration.adoc field-for-field. Endpoint-id uniqueness across files, upstream_defaults wholesale-replacement, and allowed_methods no-inheritance semantics are enforced in code, not schema. Unknown keys are rejected at every object level (additionalProperties: false).", + "type": "object", + "additionalProperties": false, + "required": ["endpoint"], + "$defs": { + "httpVerb": { + "type": "string", + "enum": ["GET", "HEAD", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"] + }, + "auth": { + "type": "object", + "additionalProperties": false, + "required": ["require"], + "properties": { + "require": { "type": "string", "enum": ["none", "bearer", "session"] }, + "required_scopes": { "type": "array", "items": { "type": "string" } } + } + }, + "upstreamDefaults": { + "type": "object", + "additionalProperties": false, + "properties": { + "retry": { + "type": "object", + "additionalProperties": false, + "properties": { + "enabled": { "type": "boolean", "default": true } + } + }, + "not_modified": { + "type": "object", + "additionalProperties": false, + "properties": { + "enabled": { "type": "boolean", "default": true } + } + } + } + } + }, + "properties": { + "endpoint": { + "type": "object", + "additionalProperties": false, + "required": ["id", "base_url", "auth", "routes"], + "properties": { + "id": { + "type": "string", + "pattern": "^[a-z][a-z0-9-]*$", + "description": "Unique endpoint name (mandatory). Uniqueness across all endpoint files is enforced in code." + }, + "enabled": { + "type": "boolean", + "default": true, + "description": "Whether this endpoint is active. Defaults to true. Overridable per environment by ENDPOINT__ENABLED." + }, + "base_url": { + "type": "string", + "description": "Topology alias resolved from topology.properties / TOPOLOGY_. Never a concrete host." + }, + "auth": { "$ref": "#/$defs/auth" }, + "allowed_methods": { + "type": "array", + "description": "Optional per-endpoint verb allowlist. REPLACES the global gateway.allowed_methods wholesale for this endpoint (no inheritance). TRACE and CONNECT are excluded from the enum.", + "items": { "$ref": "#/$defs/httpVerb" } + }, + "upstream_defaults": { + "$ref": "#/$defs/upstreamDefaults", + "description": "Optional endpoint-level retry / not-modified defaults. When present, replaces the global upstream_defaults wholesale for this endpoint." + }, + "routes": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": false, + "required": ["id", "match"], + "properties": { + "id": { "type": "string" }, + "protocol": { "type": "string", "enum": ["http", "grpc", "graphql", "websocket"] }, + "match": { + "type": "object", + "additionalProperties": false, + "required": ["path_prefix"], + "properties": { + "path_prefix": { "type": "string" }, + "methods": { "type": "array", "items": { "$ref": "#/$defs/httpVerb" } }, + "host": { "type": "string" }, + "headers": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": false, + "required": ["name"], + "properties": { + "name": { "type": "string" }, + "present": { "type": "boolean" }, + "value": { "type": "string" } + } + } + } + } + }, + "auth": { "$ref": "#/$defs/auth" }, + "security_filter": { + "type": "object", + "additionalProperties": false, + "properties": { + "profile": { "type": "string", "enum": ["default", "strict", "lenient"] }, + "allowed_paths": { "type": "array", "items": { "type": "string" } }, + "max_header_count": { "type": "integer" }, + "max_header_value_length": { "type": "integer" }, + "max_query_params": { "type": "integer" }, + "max_param_value_length": { "type": "integer" }, + "max_body_bytes": { "type": "integer" }, + "allowed_header_names": { "type": "array", "items": { "type": "string" } }, + "blocked_header_names": { "type": "array", "items": { "type": "string" } }, + "allowed_content_types": { "type": "array", "items": { "type": "string" } } + } + }, + "forward": { + "type": "object", + "additionalProperties": false, + "properties": { + "headers_allow": { "type": "array", "items": { "type": "string" } }, + "query_allow": { "type": "array", "items": { "type": "string" } }, + "set_headers": { "type": "object", "additionalProperties": { "type": "string" } } + } + }, + "upstream": { + "type": "object", + "additionalProperties": false, + "properties": { + "path": { "type": "string" }, + "connect_timeout_ms": { "type": "integer" }, + "read_timeout_ms": { "type": "integer" }, + "retry": { + "type": "object", + "additionalProperties": false, + "properties": { + "enabled": { "type": "boolean" }, + "max_attempts": { "type": "integer" }, + "idempotent_only": { "type": "boolean" } + } + }, + "not_modified": { + "type": "object", + "additionalProperties": false, + "properties": { + "enabled": { "type": "boolean" } + } + }, + "circuit_breaker": { + "type": "object", + "additionalProperties": false, + "properties": { + "failures": { "type": "integer" }, + "reset_ms": { "type": "integer" } + } + } + } + }, + "rate_limit": { + "type": "object", + "additionalProperties": false, + "description": "Reserved; the rate-limit feature is deferred. Accepted and ignored.", + "properties": { + "requests_per_second": { "type": "integer" }, + "burst": { "type": "integer" } + } + } + } + } + } + } + } + } +} diff --git a/api-sheriff/src/main/resources/schema/gateway.schema.json b/api-sheriff/src/main/resources/schema/gateway.schema.json new file mode 100644 index 0000000..a1287ca --- /dev/null +++ b/api-sheriff/src/main/resources/schema/gateway.schema.json @@ -0,0 +1,208 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://api-sheriff.cuioss.de/schema/gateway.schema.json", + "title": "API Sheriff gateway.yaml", + "description": "JSON Schema for the global gateway.yaml configuration document. Mirrors doc/configuration.adoc field-for-field. Unknown keys are rejected at every object level (additionalProperties: false).", + "type": "object", + "additionalProperties": false, + "required": ["version"], + "$defs": { + "httpVerb": { + "type": "string", + "enum": ["GET", "HEAD", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"] + }, + "upstreamDefaults": { + "type": "object", + "additionalProperties": false, + "properties": { + "retry": { + "type": "object", + "additionalProperties": false, + "properties": { + "enabled": { "type": "boolean", "default": true } + } + }, + "not_modified": { + "type": "object", + "additionalProperties": false, + "properties": { + "enabled": { "type": "boolean", "default": true } + } + } + } + } + }, + "properties": { + "version": { + "type": "integer", + "description": "Config schema version; unknown values are refused in code." + }, + "metadata": { + "type": "object", + "additionalProperties": false, + "properties": { + "config_version": { "type": "string" } + } + }, + "tls": { + "type": "object", + "additionalProperties": false, + "properties": { + "min_version": { "type": "string", "enum": ["1.2", "1.3"] }, + "alpn": { "type": "array", "items": { "type": "string" } }, + "passthrough_sni": { + "type": "object", + "description": "Map of SNI hostname -> topology alias, relayed at L4 without decryption.", + "additionalProperties": { "type": "string" } + }, + "mtls": { + "type": "object", + "additionalProperties": false, + "required": ["enabled"], + "properties": { + "enabled": { "type": "boolean" }, + "client_ca": { "type": "string" } + } + } + } + }, + "security_headers": { + "type": "object", + "additionalProperties": false, + "properties": { + "hsts": { + "type": "object", + "additionalProperties": false, + "properties": { + "max_age": { "type": "integer" }, + "include_subdomains": { "type": "boolean" } + } + }, + "content_type_nosniff": { "type": "boolean" }, + "frame_deny": { "type": "boolean" }, + "cors": { + "type": "object", + "additionalProperties": false, + "properties": { + "enabled": { "type": "boolean" }, + "allowed_origins": { "type": "array", "items": { "type": "string" } }, + "allowed_methods": { "type": "array", "items": { "type": "string" } }, + "allowed_headers": { "type": "array", "items": { "type": "string" } }, + "allow_credentials": { "type": "boolean" } + } + } + } + }, + "security_defaults": { + "type": "object", + "additionalProperties": false, + "properties": { + "profile": { "type": "string", "enum": ["default", "strict", "lenient"] } + } + }, + "allowed_methods": { + "type": "array", + "description": "Global positive HTTP-verb allowlist. TRACE and CONNECT are never permitted and are excluded from the enum.", + "items": { "$ref": "#/$defs/httpVerb" } + }, + "upstream_defaults": { + "$ref": "#/$defs/upstreamDefaults", + "description": "Global retry / HTTP-304 not-modified defaults (both default true). An endpoint upstream_defaults block replaces this wholesale." + }, + "forwarded": { + "type": "object", + "additionalProperties": false, + "properties": { + "trusted_proxies": { "type": "array", "items": { "type": "string" } }, + "trust_scheme_host": { "type": "boolean" }, + "emit": { "type": "string", "enum": ["x-forwarded", "both"] } + } + }, + "token_validation": { + "type": "object", + "additionalProperties": false, + "properties": { + "issuers": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": false, + "required": ["name", "issuer"], + "properties": { + "name": { "type": "string" }, + "issuer": { "type": "string" }, + "audience": { "type": "string" }, + "jwks": { + "type": "object", + "additionalProperties": false, + "required": ["source"], + "properties": { + "source": { "type": "string", "enum": ["http", "file", "inline"] }, + "url": { "type": "string" }, + "file": { "type": "string" } + } + } + } + } + } + } + }, + "oidc": { + "type": "object", + "additionalProperties": false, + "properties": { + "issuer": { "type": "string" }, + "client_id": { "type": "string" }, + "client_secret": { "type": "string" }, + "scopes": { "type": "array", "items": { "type": "string" } }, + "redirect_uri": { "type": "string" }, + "logout": { + "type": "object", + "additionalProperties": false, + "properties": { + "path": { "type": "string" }, + "post_logout_redirect_uri": { "type": "string" }, + "final_redirect": { "type": "string" }, + "backchannel_path": { "type": "string" } + } + }, + "session": { + "type": "object", + "additionalProperties": false, + "properties": { + "mode": { "type": "string", "enum": ["cookie", "server"] }, + "store": { "type": "string", "enum": ["memory"] }, + "cookie_name": { "type": "string" }, + "encryption_key": { "type": "string" }, + "previous_key": { "type": "string" }, + "ttl_seconds": { "type": "integer" }, + "csrf": { + "type": "object", + "additionalProperties": false, + "properties": { + "trusted_origins": { "type": "array", "items": { "type": "string" } } + } + }, + "refresh": { + "type": "object", + "additionalProperties": false, + "properties": { + "enabled": { "type": "boolean" }, + "leeway_seconds": { "type": "integer" }, + "on_failure": { "type": "string", "enum": ["reauthenticate", "reject"] } + } + } + } + }, + "step_up": { + "type": "object", + "additionalProperties": false, + "properties": { + "enabled": { "type": "boolean" }, + "honor_upstream_challenge": { "type": "boolean" } + } + } + } + } + } +} diff --git a/api-sheriff/src/test/java/de/cuioss/sheriff/api/config/RouteTableBuilderTest.java b/api-sheriff/src/test/java/de/cuioss/sheriff/api/config/RouteTableBuilderTest.java new file mode 100644 index 0000000..1f8b201 --- /dev/null +++ b/api-sheriff/src/test/java/de/cuioss/sheriff/api/config/RouteTableBuilderTest.java @@ -0,0 +1,395 @@ +/* + * Copyright © 2022 CUI-OpenSource-Software (info@cuioss.de) + * + * 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 de.cuioss.sheriff.api.config; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.EnumSet; +import java.util.HashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Optional; + + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +import de.cuioss.sheriff.api.config.model.AuthConfig; +import de.cuioss.sheriff.api.config.model.EndpointConfig; +import de.cuioss.sheriff.api.config.model.GatewayConfig; +import de.cuioss.sheriff.api.config.model.HttpMethod; +import de.cuioss.sheriff.api.config.model.MatchConfig; +import de.cuioss.sheriff.api.config.model.MatchConfig.HeaderMatcher; +import de.cuioss.sheriff.api.config.model.ResolvedRoute; +import de.cuioss.sheriff.api.config.model.ResolvedTopology; +import de.cuioss.sheriff.api.config.model.ResolvedUpstream; +import de.cuioss.sheriff.api.config.model.RouteConfig; +import de.cuioss.sheriff.api.config.model.RouteTable; +import de.cuioss.sheriff.api.config.model.UpstreamConfig; +import de.cuioss.sheriff.api.config.model.UpstreamDefaultsConfig; + +/** + * Tests for {@link RouteTableBuilder}: enabled-only merge, longest-prefix + * ordering, same-prefix disjointness enforcement, and the materialization of + * effective auth, effective {@code allowed_methods}, and the three-level + * retry / not-modified override chain. + */ +class RouteTableBuilderTest { + + private final RouteTableBuilder builder = new RouteTableBuilder(); + + // --- fixture helpers ------------------------------------------------- + + private static GatewayConfig.GatewayConfigBuilder gateway() { + return GatewayConfig.builder().version(1); + } + + private static ResolvedTopology topologyWith(String... aliases) { + Map map = new HashMap<>(); + for (String alias : aliases) { + map.put(alias, new ResolvedUpstream("https", alias.toLowerCase(Locale.ROOT) + ".internal", 443, "")); + } + return new ResolvedTopology(map); + } + + private static MatchConfig match(String pathPrefix, HttpMethod... methods) { + return MatchConfig.builder().pathPrefix(pathPrefix).methods(List.of(methods)).build(); + } + + private static RouteConfig route(String id, HttpMethod... methods) { + return RouteConfig.builder().id(id).match(match("/" + id, methods)).build(); + } + + private static RouteConfig routeWithPrefix(String id, String pathPrefix, HttpMethod... methods) { + return RouteConfig.builder().id(id).match(match(pathPrefix, methods)).build(); + } + + private static RouteConfig routeWithHeader(String id, String pathPrefix, HeaderMatcher header) { + MatchConfig match = MatchConfig.builder().pathPrefix(pathPrefix).headers(List.of(header)).build(); + return RouteConfig.builder().id(id).match(match).build(); + } + + private static RouteConfig routeWithToggles(String id, Boolean retry, Boolean notModified) { + UpstreamConfig.UpstreamConfigBuilder upstream = UpstreamConfig.builder(); + if (retry != null) { + upstream.retry(Optional.of(UpstreamConfig.Retry.builder().enabled(Optional.of(retry)).build())); + } + if (notModified != null) { + upstream.notModified(Optional.of(new UpstreamConfig.NotModified(Optional.of(notModified)))); + } + return RouteConfig.builder().id(id).match(match("/" + id)).upstream(Optional.of(upstream.build())).build(); + } + + private static EndpointConfig.EndpointConfigBuilder endpoint(String id, String alias) { + return EndpointConfig.builder().id(id).enabled(true).baseUrl(alias).auth(new AuthConfig("none", List.of())); + } + + private static ResolvedRoute find(RouteTable table, String id) { + return table.routes().stream().filter(route -> route.id().equals(id)).findFirst().orElseThrow(); + } + + @Nested + @DisplayName("Merge, ordering, and disjointness") + class MergeOrderDisjointness { + + @Test + @DisplayName("Should merge enabled endpoints only, skipping disabled ones") + void shouldMergeEnabledEndpointsOnly() { + EndpointConfig enabled = endpoint("orders", "ORDERS") + .routes(List.of(route("orders-get", HttpMethod.GET))).build(); + EndpointConfig disabled = endpoint("legacy", "LEGACY").enabled(false) + .routes(List.of(route("legacy-get", HttpMethod.GET))).build(); + + RouteTable table = builder.build(gateway().build(), List.of(enabled, disabled), topologyWith("ORDERS")); + + assertEquals(1, table.routes().size(), "the disabled endpoint should contribute no rows"); + assertEquals("orders-get", table.routes().getFirst().id()); + } + + @Test + @DisplayName("Should order routes by descending path_prefix length") + void shouldOrderByLongestPrefixFirst() { + EndpointConfig endpoint = endpoint("orders", "ORDERS") + .routes(List.of(routeWithPrefix("short", "/a", HttpMethod.GET), + routeWithPrefix("long", "/a/b/c", HttpMethod.GET), + routeWithPrefix("mid", "/a/b", HttpMethod.GET))) + .build(); + + RouteTable table = builder.build(gateway().build(), List.of(endpoint), topologyWith("ORDERS")); + + assertEquals(List.of("long", "mid", "short"), + table.routes().stream().map(ResolvedRoute::id).toList()); + } + + @Test + @DisplayName("Should reject two same-prefix routes that overlap on method") + void shouldRejectNonDisjointSamePrefixRoutes() { + EndpointConfig endpoint = endpoint("orders", "ORDERS") + .routes(List.of(routeWithPrefix("first", "/x", HttpMethod.GET), + routeWithPrefix("second", "/x", HttpMethod.GET))) + .build(); + + assertThrows(RouteTableBuilder.RouteTableException.class, + () -> builder.build(gateway().build(), List.of(endpoint), topologyWith("ORDERS"))); + } + + @Test + @DisplayName("Should accept two same-prefix routes disjoint by method") + void shouldAcceptSamePrefixRoutesDisjointByMethod() { + EndpointConfig endpoint = endpoint("orders", "ORDERS") + .routes(List.of(routeWithPrefix("reader", "/x", HttpMethod.GET), + routeWithPrefix("writer", "/x", HttpMethod.POST))) + .build(); + + RouteTable table = builder.build(gateway().build(), List.of(endpoint), topologyWith("ORDERS")); + + assertEquals(2, table.routes().size(), "method-disjoint same-prefix routes should both survive"); + } + + @Test + @DisplayName("Should reject an unresolved alias for an enabled endpoint") + void shouldRejectUnresolvedAliasForEnabledEndpoint() { + EndpointConfig endpoint = endpoint("orders", "MISSING") + .routes(List.of(route("r", HttpMethod.GET))).build(); + + assertThrows(RouteTableBuilder.RouteTableException.class, + () -> builder.build(gateway().build(), List.of(endpoint), topologyWith("ORDERS"))); + } + + @Test + @DisplayName("Should resolve each route's upstream from the topology") + void shouldResolveUpstreamTarget() { + EndpointConfig endpoint = endpoint("orders", "ORDERS") + .routes(List.of(route("r", HttpMethod.GET))).build(); + + RouteTable table = builder.build(gateway().build(), List.of(endpoint), topologyWith("ORDERS")); + + assertEquals("orders.internal", find(table, "r").upstream().host()); + } + + @Test + @DisplayName("Should look up the most specific prefix match") + void shouldLookUpMostSpecificPrefix() { + EndpointConfig endpoint = endpoint("orders", "ORDERS") + .routes(List.of(routeWithPrefix("broad", "/a", HttpMethod.GET), + routeWithPrefix("narrow", "/a/b", HttpMethod.GET))) + .build(); + + RouteTable table = builder.build(gateway().build(), List.of(endpoint), topologyWith("ORDERS")); + + assertEquals("narrow", table.lookup("/a/b/item").orElseThrow().id()); + assertEquals("broad", table.lookup("/a/other").orElseThrow().id()); + assertTrue(table.lookup("/unmatched").isEmpty()); + } + + @Test + @DisplayName("Should not match a prefix that only shares a leading substring with the path") + void shouldMatchOnSegmentBoundaryOnly() { + EndpointConfig endpoint = endpoint("orders", "ORDERS") + .routes(List.of(routeWithPrefix("proxy", "/proxy", HttpMethod.GET))) + .build(); + + RouteTable table = builder.build(gateway().build(), List.of(endpoint), topologyWith("ORDERS")); + + assertTrue(table.lookup("/proxy-helper").isEmpty(), + "/proxy-helper must not match the prefix /proxy across a segment boundary"); + assertEquals("proxy", table.lookup("/proxy").orElseThrow().id(), + "an exact prefix match must resolve the route"); + assertEquals("proxy", table.lookup("/proxy/items").orElseThrow().id(), + "a child path must match the prefix on the segment boundary"); + } + + @Test + @DisplayName("Should treat a prefix already ending in a slash as a segment boundary") + void shouldMatchTrailingSlashPrefix() { + EndpointConfig endpoint = endpoint("orders", "ORDERS") + .routes(List.of(routeWithPrefix("api", "/api/", HttpMethod.GET))) + .build(); + + RouteTable table = builder.build(gateway().build(), List.of(endpoint), topologyWith("ORDERS")); + + assertEquals("api", table.lookup("/api/orders").orElseThrow().id()); + assertTrue(table.lookup("/api-internal").isEmpty(), + "/api-internal must not match the prefix /api/"); + } + + @Test + @DisplayName("Should accept same-prefix routes made disjoint by mutually exclusive header presence") + void shouldAcceptSamePrefixRoutesDisjointByHeaderPresence() { + RouteConfig present = routeWithHeader("present", "/x", + HeaderMatcher.builder().name("X-Debug").present(Optional.of(true)).build()); + RouteConfig absent = routeWithHeader("absent", "/x", + HeaderMatcher.builder().name("X-Debug").present(Optional.of(false)).build()); + EndpointConfig endpoint = endpoint("orders", "ORDERS").routes(List.of(present, absent)).build(); + + RouteTable table = builder.build(gateway().build(), List.of(endpoint), topologyWith("ORDERS")); + + assertEquals(2, table.routes().size(), + "presence-disjoint same-prefix routes should both survive"); + } + + @Test + @DisplayName("Should reject same-prefix routes whose header matchers only agree on presence") + void shouldRejectSamePrefixRoutesWithMatchingHeaderPresence() { + RouteConfig first = routeWithHeader("first", "/x", + HeaderMatcher.builder().name("X-Debug").present(Optional.of(true)).build()); + RouteConfig second = routeWithHeader("second", "/x", + HeaderMatcher.builder().name("X-Debug").present(Optional.of(true)).build()); + EndpointConfig endpoint = endpoint("orders", "ORDERS").routes(List.of(first, second)).build(); + + assertThrows(RouteTableBuilder.RouteTableException.class, + () -> builder.build(gateway().build(), List.of(endpoint), topologyWith("ORDERS"))); + } + } + + @Nested + @DisplayName("Effective auth materialization") + class EffectiveAuth { + + @Test + @DisplayName("Should apply a route-level auth override wholesale") + void shouldApplyRouteAuthOverride() { + RouteConfig secured = RouteConfig.builder().id("secured").match(match("/secured", HttpMethod.GET)) + .auth(Optional.of(new AuthConfig("bearer", List.of("read")))).build(); + EndpointConfig endpoint = endpoint("orders", "ORDERS").routes(List.of(secured)).build(); + + RouteTable table = builder.build(gateway().build(), List.of(endpoint), topologyWith("ORDERS")); + + assertEquals("bearer", find(table, "secured").effectiveAuth().require()); + } + + @Test + @DisplayName("Should inherit the endpoint default auth when the route omits it") + void shouldInheritEndpointAuth() { + EndpointConfig endpoint = EndpointConfig.builder().id("orders").enabled(true).baseUrl("ORDERS") + .auth(new AuthConfig("session", List.of())).routes(List.of(route("r", HttpMethod.GET))).build(); + + RouteTable table = builder.build(gateway().build(), List.of(endpoint), topologyWith("ORDERS")); + + assertEquals("session", find(table, "r").effectiveAuth().require()); + } + } + + @Nested + @DisplayName("Effective allowed_methods resolution") + class EffectiveAllowedMethods { + + @Test + @DisplayName("Should let the endpoint list replace the global list wholesale, even for a verb the global omits") + void shouldLetEndpointReplaceGlobalWholesale() { + GatewayConfig config = gateway().allowedMethods(List.of(HttpMethod.GET)).build(); + EndpointConfig endpoint = endpoint("orders", "ORDERS").allowedMethods(List.of(HttpMethod.PUT)) + .routes(List.of(route("r", HttpMethod.PUT))).build(); + + RouteTable table = builder.build(config, List.of(endpoint), topologyWith("ORDERS")); + + assertEquals(List.of(HttpMethod.PUT), find(table, "r").effectiveAllowedMethods()); + } + + @Test + @DisplayName("Should fall back to the global list when the endpoint declares none") + void shouldFallBackToGlobalList() { + GatewayConfig config = gateway().allowedMethods(List.of(HttpMethod.GET, HttpMethod.POST)).build(); + EndpointConfig endpoint = endpoint("orders", "ORDERS") + .routes(List.of(route("r", HttpMethod.GET))).build(); + + RouteTable table = builder.build(config, List.of(endpoint), topologyWith("ORDERS")); + + assertEquals(List.of(HttpMethod.GET, HttpMethod.POST), find(table, "r").effectiveAllowedMethods()); + } + + @Test + @DisplayName("Should fall back to the standard set when neither level declares a list") + void shouldFallBackToStandardSet() { + EndpointConfig endpoint = endpoint("orders", "ORDERS") + .routes(List.of(route("r", HttpMethod.GET))).build(); + + RouteTable table = builder.build(gateway().build(), List.of(endpoint), topologyWith("ORDERS")); + + List effective = find(table, "r").effectiveAllowedMethods(); + assertEquals(EnumSet.allOf(HttpMethod.class).size(), effective.size()); + assertTrue(effective.containsAll(EnumSet.allOf(HttpMethod.class)), + "the standard set should contain every representable verb"); + } + } + + @Nested + @DisplayName("Effective retry / not-modified resolution") + class EffectiveUpstreamDefaults { + + @Test + @DisplayName("Should default both toggles to true when nothing is declared") + void shouldDefaultBothTogglesTrue() { + EndpointConfig endpoint = endpoint("orders", "ORDERS") + .routes(List.of(route("r", HttpMethod.GET))).build(); + + RouteTable table = builder.build(gateway().build(), List.of(endpoint), topologyWith("ORDERS")); + + ResolvedRoute resolved = find(table, "r"); + assertTrue(resolved.retryEnabled(), "retry should default to true"); + assertTrue(resolved.notModifiedEnabled(), "not-modified should default to true"); + } + + @Test + @DisplayName("Should let the endpoint upstream_defaults replace the global block wholesale") + void shouldLetEndpointDefaultsReplaceGlobal() { + GatewayConfig config = gateway() + .upstreamDefaults(Optional.of(new UpstreamDefaultsConfig(true, true))).build(); + EndpointConfig endpoint = endpoint("orders", "ORDERS") + .upstreamDefaults(Optional.of(new UpstreamDefaultsConfig(false, false))) + .routes(List.of(route("r", HttpMethod.GET))).build(); + + RouteTable table = builder.build(config, List.of(endpoint), topologyWith("ORDERS")); + + ResolvedRoute resolved = find(table, "r"); + assertFalse(resolved.retryEnabled(), "endpoint block should replace the global retry toggle"); + assertFalse(resolved.notModifiedEnabled(), "endpoint block should replace the global not-modified toggle"); + } + + @Test + @DisplayName("Should let a per-route toggle override the resolved endpoint value") + void shouldLetPerRouteToggleOverride() { + EndpointConfig endpoint = endpoint("orders", "ORDERS") + .upstreamDefaults(Optional.of(new UpstreamDefaultsConfig(false, false))) + .routes(List.of(routeWithToggles("r", true, null))).build(); + + RouteTable table = builder.build(gateway().build(), List.of(endpoint), topologyWith("ORDERS")); + + ResolvedRoute resolved = find(table, "r"); + assertTrue(resolved.retryEnabled(), "the per-route retry override should win"); + assertFalse(resolved.notModifiedEnabled(), "the absent per-route toggle should inherit the endpoint value"); + } + + @Test + @DisplayName("Should inherit the resolved value when a per-route toggle is absent") + void shouldInheritWhenPerRouteToggleAbsent() { + EndpointConfig endpoint = endpoint("orders", "ORDERS") + .upstreamDefaults(Optional.of(new UpstreamDefaultsConfig(false, true))) + .routes(List.of(routeWithToggles("r", null, null))).build(); + + RouteTable table = builder.build(gateway().build(), List.of(endpoint), topologyWith("ORDERS")); + + ResolvedRoute resolved = find(table, "r"); + assertFalse(resolved.retryEnabled(), "absent retry toggle should inherit the endpoint value"); + assertTrue(resolved.notModifiedEnabled(), "absent not-modified toggle should inherit the endpoint value"); + } + } +} diff --git a/api-sheriff/src/test/java/de/cuioss/sheriff/api/config/load/ConfigLoaderTest.java b/api-sheriff/src/test/java/de/cuioss/sheriff/api/config/load/ConfigLoaderTest.java new file mode 100644 index 0000000..8661679 --- /dev/null +++ b/api-sheriff/src/test/java/de/cuioss/sheriff/api/config/load/ConfigLoaderTest.java @@ -0,0 +1,181 @@ +/* + * Copyright © 2022 CUI-OpenSource-Software (info@cuioss.de) + * + * 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 de.cuioss.sheriff.api.config.load; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.io.IOException; +import java.io.InputStream; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.StandardCopyOption; +import java.util.List; +import java.util.Map; +import java.util.Optional; + + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import de.cuioss.sheriff.api.config.model.EndpointConfig; +import de.cuioss.sheriff.api.config.model.GatewayConfig; +import de.cuioss.sheriff.api.config.model.HttpMethod; +import de.cuioss.sheriff.api.config.model.Protocol; +import de.cuioss.sheriff.api.config.model.RouteConfig; +import de.cuioss.sheriff.api.config.model.UpstreamDefaultsConfig; + +/** + * Tests for {@link ConfigLoader}: binding a valid {@code gateway.yaml} (including + * secret resolution and the flattened {@code upstream_defaults} block), endpoint + * binding with the {@code enabled} default, and the aggregated, path-annotated error + * reporting for schema violations, missing secrets, and a missing gateway file. + */ +class ConfigLoaderTest { + + @TempDir + Path configDir; + + private ConfigLoader loader(Map environment) { + return new ConfigLoader(configDir, new EnvSecretResolver(environment::get)); + } + + private void copyFixtureAs(String resource, String relativeTarget) throws IOException { + Path target = configDir.resolve(relativeTarget); + Files.createDirectories(target.getParent()); + try (InputStream in = getClass().getResourceAsStream(resource)) { + assertNotNull(in, "fixture not on classpath: " + resource); + Files.copy(in, target, StandardCopyOption.REPLACE_EXISTING); + } + } + + private void writeConfig(String relativeTarget, String content) throws IOException { + Path target = configDir.resolve(relativeTarget); + Files.createDirectories(target.getParent()); + Files.writeString(target, content); + } + + @Test + void bindsValidGatewayConfigAndResolvesSecrets() throws Exception { + copyFixtureAs("/config/valid/gateway.yaml", "gateway.yaml"); + + ConfigLoader.LoadedConfig loaded = loader(Map.of("OIDC_CLIENT_SECRET", "s3cr3t")).load(); + + GatewayConfig gateway = loaded.gateway(); + assertEquals(1, gateway.version()); + assertEquals(List.of(HttpMethod.GET, HttpMethod.POST, HttpMethod.PUT, HttpMethod.DELETE), + gateway.allowedMethods()); + assertEquals(Optional.of("1.3"), gateway.tls().orElseThrow().minVersion()); + assertEquals(new UpstreamDefaultsConfig(true, false), gateway.upstreamDefaults().orElseThrow()); + assertEquals(Optional.of("s3cr3t"), gateway.oidc().orElseThrow().clientSecret()); + assertEquals(1, gateway.tokenValidation().orElseThrow().issuers().size()); + assertEquals("primary", gateway.tokenValidation().orElseThrow().issuers().getFirst().name()); + assertTrue(loaded.endpoints().isEmpty()); + } + + @Test + void reportsMissingEnvSecretWithPointer() throws Exception { + copyFixtureAs("/config/valid/gateway.yaml", "gateway.yaml"); + + ConfigLoadException exception = assertThrows(ConfigLoadException.class, () -> loader(Map.of()).load()); + + assertTrue(exception.errors().stream() + .anyMatch(error -> error.pointer().contains("client_secret") + && error.message().contains("OIDC_CLIENT_SECRET")), + () -> "expected a pointer-annotated missing-secret error, got: " + exception.errors()); + } + + @Test + void aggregatesSchemaErrorsWithPathAnnotation() throws Exception { + copyFixtureAs("/config/invalid/unknown-key.yaml", "gateway.yaml"); + + ConfigLoadException exception = assertThrows(ConfigLoadException.class, () -> loader(Map.of()).load()); + + assertFalse(exception.errors().isEmpty()); + assertTrue(exception.errors().stream().allMatch(error -> "gateway.yaml".equals(error.file()))); + assertTrue(exception.errors().stream().anyMatch(error -> error.pointer().contains("min_version")), + () -> "expected a path-annotated enum violation, got: " + exception.errors()); + } + + @Test + void reportsMissingGatewayFile() { + ConfigLoadException exception = assertThrows(ConfigLoadException.class, () -> loader(Map.of()).load()); + + assertTrue(exception.errors().stream() + .anyMatch(error -> "gateway.yaml".equals(error.file()) + && error.message().contains("not found")), + () -> "expected a missing-file error, got: " + exception.errors()); + } + + @Test + void bindsEndpointApplyingEnabledDefaultAndFlattenedUpstreamDefaults() throws Exception { + writeConfig("gateway.yaml", "version: 1\n"); + writeConfig("endpoints/orders.yaml", """ + endpoint: + id: orders + base_url: ORDERS + auth: + require: bearer + required_scopes: ["orders.read"] + allowed_methods: ["GET", "POST"] + upstream_defaults: + retry: + enabled: false + not_modified: + enabled: true + routes: + - id: orders-read + protocol: http + match: + path_prefix: /orders + methods: ["GET"] + auth: + require: bearer + upstream: + path: /v1/orders + retry: + enabled: true + max_attempts: 3 + """); + + ConfigLoader.LoadedConfig loaded = loader(Map.of()).load(); + + assertEquals(1, loaded.endpoints().size()); + EndpointConfig endpoint = loaded.endpoints().getFirst(); + assertEquals("orders", endpoint.id()); + assertTrue(endpoint.enabled(), "an endpoint omitting 'enabled' defaults to enabled"); + assertEquals(List.of(HttpMethod.GET, HttpMethod.POST), endpoint.allowedMethods()); + assertEquals(new UpstreamDefaultsConfig(false, true), endpoint.upstreamDefaults().orElseThrow()); + assertEquals(1, endpoint.routes().size()); + RouteConfig route = endpoint.routes().getFirst(); + assertEquals("orders-read", route.id()); + assertEquals(Optional.of(Protocol.HTTP), route.protocol()); + assertTrue(route.upstream().orElseThrow().retry().isPresent()); + } + + @Test + void loadsWithoutEndpointsWhenDirectoryAbsent() throws Exception { + writeConfig("gateway.yaml", "version: 1\n"); + + ConfigLoader.LoadedConfig loaded = loader(Map.of()).load(); + + assertEquals(1, loaded.gateway().version()); + assertTrue(loaded.endpoints().isEmpty()); + } +} diff --git a/api-sheriff/src/test/java/de/cuioss/sheriff/api/config/load/EnvSecretResolverTest.java b/api-sheriff/src/test/java/de/cuioss/sheriff/api/config/load/EnvSecretResolverTest.java new file mode 100644 index 0000000..fd4bb7f --- /dev/null +++ b/api-sheriff/src/test/java/de/cuioss/sheriff/api/config/load/EnvSecretResolverTest.java @@ -0,0 +1,84 @@ +/* + * Copyright © 2022 CUI-OpenSource-Software (info@cuioss.de) + * + * 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 de.cuioss.sheriff.api.config.load; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.Map; + + +import org.junit.jupiter.api.Test; + +import de.cuioss.sheriff.api.config.load.EnvSecretResolver.MissingVariableException; + +/** + * Tests for {@link EnvSecretResolver}: reference detection, single / embedded / + * multiple substitution, pass-through of plain values, and the missing-variable + * failure. + */ +class EnvSecretResolverTest { + + private static EnvSecretResolver resolverWith(Map environment) { + return new EnvSecretResolver(environment::get); + } + + @Test + void detectsPresenceOfReference() { + EnvSecretResolver resolver = resolverWith(Map.of()); + assertTrue(resolver.hasReference("prefix-${TOKEN}-suffix")); + assertFalse(resolver.hasReference("no-reference-here")); + } + + @Test + void resolvesSingleReference() { + EnvSecretResolver resolver = resolverWith(Map.of("SECRET", "s3cr3t")); + assertEquals("s3cr3t", resolver.resolve("${SECRET}")); + } + + @Test + void resolvesEmbeddedReference() { + EnvSecretResolver resolver = resolverWith(Map.of("HOST", "example.com")); + assertEquals("https://example.com/callback", resolver.resolve("https://${HOST}/callback")); + } + + @Test + void resolvesMultipleReferences() { + EnvSecretResolver resolver = resolverWith(Map.of("USER", "sheriff", "PASS", "pw")); + assertEquals("sheriff:pw", resolver.resolve("${USER}:${PASS}")); + } + + @Test + void passesPlainValueThroughUnchanged() { + EnvSecretResolver resolver = resolverWith(Map.of()); + assertEquals("plain-value", resolver.resolve("plain-value")); + } + + @Test + void throwsNamingTheMissingVariable() { + EnvSecretResolver resolver = resolverWith(Map.of()); + MissingVariableException exception = assertThrows(MissingVariableException.class, + () -> resolver.resolve("${ABSENT}")); + assertEquals("ABSENT", exception.variableName()); + } + + @Test + void defaultConstructorResolvesPlainValueWithoutTouchingEnvironment() { + assertEquals("plain", new EnvSecretResolver().resolve("plain")); + } +} diff --git a/api-sheriff/src/test/java/de/cuioss/sheriff/api/config/model/ConfigModelContractTest.java b/api-sheriff/src/test/java/de/cuioss/sheriff/api/config/model/ConfigModelContractTest.java new file mode 100644 index 0000000..f0836f9 --- /dev/null +++ b/api-sheriff/src/test/java/de/cuioss/sheriff/api/config/model/ConfigModelContractTest.java @@ -0,0 +1,557 @@ +/* + * Copyright © 2022 CUI-OpenSource-Software (info@cuioss.de) + * + * 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 de.cuioss.sheriff.api.config.model; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.stream.Stream; + + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Named; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; + +/** + * Value-object contract test suite for the immutable configuration model records + * under {@code de.cuioss.sheriff.api.config.model}. + *

+ * The records are the binding target for the YAML loader (D4); their behavioural + * contract is entirely value-based. This suite verifies: + *

+ */ +class ConfigModelContractTest { + + // --- Shared fixtures --------------------------------------------------- + + private static AuthConfig auth() { + return new AuthConfig("bearer", List.of("read")); + } + + private static GatewayConfig gatewayConfig() { + return GatewayConfig.builder() + .version(1) + .metadata(Optional.of(new Metadata(Optional.of("2024-01")))) + .tls(Optional.of(tlsConfig())) + .securityHeaders(Optional.of(securityHeadersConfig())) + .securityDefaults(Optional.of(new SecurityDefaultsConfig(Optional.of("strict")))) + .allowedMethods(List.of(HttpMethod.GET, HttpMethod.POST)) + .upstreamDefaults(Optional.of(UpstreamDefaultsConfig.defaults())) + .forwarded(Optional.of(new ForwardedConfig(List.of("10.0.0.0/8"), Optional.of(true), Optional.of("both")))) + .tokenValidation(Optional.of(tokenValidationConfig())) + .oidc(Optional.of(oidcConfig())) + .build(); + } + + private static TlsConfig tlsConfig() { + return TlsConfig.builder() + .minVersion(Optional.of("TLSv1.3")) + .alpn(List.of("h2", "http/1.1")) + .passthroughSni(Map.of("internal.example.com", "internal-alias")) + .mtls(Optional.of(new TlsConfig.Mtls(true, Optional.of("/etc/ca.pem")))) + .build(); + } + + private static SecurityHeadersConfig securityHeadersConfig() { + return SecurityHeadersConfig.builder() + .hsts(Optional.of(new SecurityHeadersConfig.Hsts(Optional.of(31536000), Optional.of(true)))) + .contentTypeNosniff(Optional.of(true)) + .frameDeny(Optional.of(true)) + .cors(Optional.of(new SecurityHeadersConfig.Cors(Optional.of(true), List.of("https://app.example.com"), + List.of("GET"), List.of("Authorization"), Optional.of(false)))) + .build(); + } + + private static TokenValidationConfig tokenValidationConfig() { + return new TokenValidationConfig(List.of(issuerConfig())); + } + + private static IssuerConfig issuerConfig() { + return IssuerConfig.builder() + .name("primary") + .issuer("https://issuer.example.com") + .audience(Optional.of("api-sheriff")) + .jwks(Optional.of(new IssuerConfig.Jwks("http", Optional.of("https://issuer.example.com/jwks"), + Optional.empty()))) + .build(); + } + + private static OidcConfig oidcConfig() { + return OidcConfig.builder() + .issuer(Optional.of("https://issuer.example.com")) + .clientId(Optional.of("sheriff")) + .clientSecret(Optional.of("${OIDC_SECRET}")) + .scopes(List.of("openid", "profile")) + .redirectUri(Optional.of("https://gw.example.com/callback")) + .logout(Optional.of(new OidcConfig.Logout(Optional.of("/logout"), Optional.of("/post-logout"), + Optional.of("/home"), Optional.of("/backchannel")))) + .session(Optional.of(OidcConfig.Session.builder() + .mode(Optional.of("cookie")) + .cookieName(Optional.of("sid")) + .encryptionKey(Optional.of("${SESSION_KEY}")) + .ttlSeconds(Optional.of(3600)) + .csrf(Optional.of(new OidcConfig.Csrf(List.of("https://app.example.com")))) + .refresh(Optional.of(new OidcConfig.Refresh(Optional.of(true), Optional.of(60), + Optional.of("reauthenticate")))) + .build())) + .stepUp(Optional.of(new OidcConfig.StepUp(Optional.of(true), Optional.of(false)))) + .build(); + } + + private static EndpointConfig endpointConfig() { + return EndpointConfig.builder() + .id("orders") + .enabled(true) + .baseUrl("orders-service") + .auth(auth()) + .allowedMethods(List.of(HttpMethod.GET, HttpMethod.POST)) + .upstreamDefaults(Optional.of(UpstreamDefaultsConfig.defaults())) + .routes(List.of(routeConfig())) + .build(); + } + + private static RouteConfig routeConfig() { + return RouteConfig.builder() + .id("orders-read") + .protocol(Optional.of(Protocol.HTTP)) + .match(matchConfig()) + .auth(Optional.of(auth())) + .securityFilter(Optional.of(securityFilterConfig())) + .forward(Optional.of(new ForwardConfig(List.of("Accept"), List.of("page"), + Map.of("X-Gateway", "api-sheriff")))) + .upstream(Optional.of(upstreamConfig())) + .rateLimit(Optional.of(new RateLimitConfig(Optional.of(100), Optional.of(200)))) + .build(); + } + + private static MatchConfig matchConfig() { + return MatchConfig.builder() + .pathPrefix("/orders") + .methods(List.of(HttpMethod.GET)) + .host(Optional.of("api.example.com")) + .headers(List.of(new MatchConfig.HeaderMatcher("X-Tenant", Optional.of(true), Optional.of("acme")))) + .build(); + } + + private static SecurityFilterConfig securityFilterConfig() { + return SecurityFilterConfig.builder() + .profile(Optional.of("strict")) + .allowedPaths(List.of("/orders")) + .maxHeaderCount(Optional.of(50)) + .maxHeaderValueLength(Optional.of(4096)) + .maxQueryParams(Optional.of(32)) + .maxParamValueLength(Optional.of(1024)) + .maxBodyBytes(Optional.of(1048576)) + .allowedHeaderNames(List.of("Accept")) + .blockedHeaderNames(List.of("X-Debug")) + .allowedContentTypes(List.of("application/json")) + .build(); + } + + private static UpstreamConfig upstreamConfig() { + return UpstreamConfig.builder() + .path(Optional.of("/v1/orders")) + .connectTimeoutMs(Optional.of(2000)) + .readTimeoutMs(Optional.of(5000)) + .retry(Optional.of(new UpstreamConfig.Retry(Optional.of(true), Optional.of(3), Optional.of(true)))) + .notModified(Optional.of(new UpstreamConfig.NotModified(Optional.of(true)))) + .circuitBreaker(Optional.of(new UpstreamConfig.CircuitBreaker(Optional.of(5), Optional.of(30000)))) + .build(); + } + + // --- equals / hashCode / toString contract ---------------------------- + + @Nested + @DisplayName("equals / hashCode / toString contract") + class ValueObjectContract { + + private static Arguments voCase(String name, Object subject, Object equalCopy, Object different) { + return Arguments.of(Named.named(name, subject), equalCopy, different); + } + + static Stream valueObjects() { + return Stream.of( + voCase("GatewayConfig", gatewayConfig(), gatewayConfig(), + GatewayConfig.builder().version(99).build()), + voCase("Metadata", new Metadata(Optional.of("v1")), new Metadata(Optional.of("v1")), + new Metadata(Optional.of("v2"))), + voCase("TlsConfig", tlsConfig(), tlsConfig(), TlsConfig.builder().build()), + voCase("TlsConfig.Mtls", new TlsConfig.Mtls(true, Optional.of("/ca")), + new TlsConfig.Mtls(true, Optional.of("/ca")), new TlsConfig.Mtls(false, Optional.empty())), + voCase("SecurityHeadersConfig", securityHeadersConfig(), securityHeadersConfig(), + SecurityHeadersConfig.builder().build()), + voCase("SecurityHeadersConfig.Hsts", + new SecurityHeadersConfig.Hsts(Optional.of(1), Optional.of(true)), + new SecurityHeadersConfig.Hsts(Optional.of(1), Optional.of(true)), + new SecurityHeadersConfig.Hsts(Optional.of(2), Optional.of(false))), + voCase("SecurityHeadersConfig.Cors", + new SecurityHeadersConfig.Cors(Optional.of(true), List.of("a"), List.of("GET"), + List.of("Accept"), Optional.of(false)), + new SecurityHeadersConfig.Cors(Optional.of(true), List.of("a"), List.of("GET"), + List.of("Accept"), Optional.of(false)), + new SecurityHeadersConfig.Cors(Optional.of(false), List.of("b"), List.of("POST"), + List.of("Authorization"), Optional.of(true))), + voCase("SecurityDefaultsConfig", new SecurityDefaultsConfig(Optional.of("strict")), + new SecurityDefaultsConfig(Optional.of("strict")), + new SecurityDefaultsConfig(Optional.of("lenient"))), + voCase("ForwardedConfig", + new ForwardedConfig(List.of("10.0.0.0/8"), Optional.of(true), Optional.of("both")), + new ForwardedConfig(List.of("10.0.0.0/8"), Optional.of(true), Optional.of("both")), + new ForwardedConfig(List.of("192.168.0.0/16"), Optional.of(false), + Optional.of("x-forwarded"))), + voCase("TokenValidationConfig", tokenValidationConfig(), tokenValidationConfig(), + new TokenValidationConfig(List.of())), + voCase("IssuerConfig", issuerConfig(), issuerConfig(), + new IssuerConfig("other", "https://other", Optional.empty(), Optional.empty())), + voCase("IssuerConfig.Jwks", + new IssuerConfig.Jwks("http", Optional.of("https://j"), Optional.empty()), + new IssuerConfig.Jwks("http", Optional.of("https://j"), Optional.empty()), + new IssuerConfig.Jwks("file", Optional.empty(), Optional.of("/jwks.json"))), + voCase("OidcConfig", oidcConfig(), oidcConfig(), OidcConfig.builder().build()), + voCase("OidcConfig.Logout", + new OidcConfig.Logout(Optional.of("/l"), Optional.empty(), Optional.empty(), + Optional.empty()), + new OidcConfig.Logout(Optional.of("/l"), Optional.empty(), Optional.empty(), + Optional.empty()), + new OidcConfig.Logout(Optional.of("/other"), Optional.empty(), Optional.empty(), + Optional.empty())), + voCase("OidcConfig.Csrf", new OidcConfig.Csrf(List.of("https://a")), + new OidcConfig.Csrf(List.of("https://a")), new OidcConfig.Csrf(List.of("https://b"))), + voCase("OidcConfig.Refresh", + new OidcConfig.Refresh(Optional.of(true), Optional.of(60), Optional.of("reject")), + new OidcConfig.Refresh(Optional.of(true), Optional.of(60), Optional.of("reject")), + new OidcConfig.Refresh(Optional.of(false), Optional.empty(), Optional.empty())), + voCase("OidcConfig.StepUp", new OidcConfig.StepUp(Optional.of(true), Optional.of(false)), + new OidcConfig.StepUp(Optional.of(true), Optional.of(false)), + new OidcConfig.StepUp(Optional.of(false), Optional.of(true))), + voCase("UpstreamDefaultsConfig", new UpstreamDefaultsConfig(true, true), + new UpstreamDefaultsConfig(true, true), new UpstreamDefaultsConfig(false, true)), + voCase("EndpointConfig", endpointConfig(), endpointConfig(), EndpointConfig.builder() + .id("other").baseUrl("svc").auth(auth()).build()), + voCase("AuthConfig", auth(), auth(), new AuthConfig("none", List.of())), + voCase("RouteConfig", routeConfig(), routeConfig(), + RouteConfig.builder().id("other").match(matchConfig()).build()), + voCase("MatchConfig", matchConfig(), matchConfig(), + MatchConfig.builder().pathPrefix("/other").build()), + voCase("MatchConfig.HeaderMatcher", + new MatchConfig.HeaderMatcher("X-Key", Optional.of(true), Optional.of("v")), + new MatchConfig.HeaderMatcher("X-Key", Optional.of(true), Optional.of("v")), + new MatchConfig.HeaderMatcher("X-Other", Optional.empty(), Optional.empty())), + voCase("SecurityFilterConfig", securityFilterConfig(), securityFilterConfig(), + SecurityFilterConfig.builder().build()), + voCase("ForwardConfig", + new ForwardConfig(List.of("Accept"), List.of("page"), Map.of("X-Gateway", "sheriff")), + new ForwardConfig(List.of("Accept"), List.of("page"), Map.of("X-Gateway", "sheriff")), + new ForwardConfig(List.of(), List.of(), Map.of())), + voCase("UpstreamConfig", upstreamConfig(), upstreamConfig(), UpstreamConfig.builder().build()), + voCase("UpstreamConfig.Retry", + new UpstreamConfig.Retry(Optional.of(true), Optional.of(3), Optional.of(true)), + new UpstreamConfig.Retry(Optional.of(true), Optional.of(3), Optional.of(true)), + new UpstreamConfig.Retry(Optional.of(false), Optional.empty(), Optional.empty())), + voCase("UpstreamConfig.NotModified", new UpstreamConfig.NotModified(Optional.of(true)), + new UpstreamConfig.NotModified(Optional.of(true)), + new UpstreamConfig.NotModified(Optional.of(false))), + voCase("UpstreamConfig.CircuitBreaker", + new UpstreamConfig.CircuitBreaker(Optional.of(5), Optional.of(30000)), + new UpstreamConfig.CircuitBreaker(Optional.of(5), Optional.of(30000)), + new UpstreamConfig.CircuitBreaker(Optional.of(1), Optional.of(1))), + voCase("RateLimitConfig", new RateLimitConfig(Optional.of(100), Optional.of(200)), + new RateLimitConfig(Optional.of(100), Optional.of(200)), + new RateLimitConfig(Optional.of(1), Optional.of(1)))); + } + + @ParameterizedTest(name = "{0}") + @MethodSource("valueObjects") + void shouldHonorValueObjectContract(Object subject, Object equalCopy, Object different) { + assertEquals(subject, equalCopy, "instances built from equal components must be equal"); + assertEquals(subject.hashCode(), equalCopy.hashCode(), "equal instances must share a hashCode"); + assertNotEquals(subject, different, "instances with differing components must not be equal"); + assertNotEquals(null, subject, "a value object is never equal to null"); + assertNotEquals("not-a-config", subject, "a value object is never equal to a foreign type"); + assertNotNull(subject.toString(), "toString must be present"); + } + } + + // --- Builder equivalence ---------------------------------------------- + + @Nested + @DisplayName("Lombok @Builder equivalence with the canonical constructor") + class BuilderEquivalence { + + @Test + void authConfigBuilderMatchesConstructor() { + AuthConfig viaCtor = new AuthConfig("bearer", List.of("read")); + AuthConfig viaBuilder = AuthConfig.builder().require("bearer").requiredScopes(List.of("read")).build(); + assertEquals(viaCtor, viaBuilder); + } + + @Test + void gatewayConfigBuilderMatchesConstructor() { + GatewayConfig viaCtor = new GatewayConfig(2, Optional.empty(), Optional.empty(), Optional.empty(), + Optional.empty(), List.of(HttpMethod.GET), Optional.empty(), Optional.empty(), Optional.empty(), + Optional.empty()); + GatewayConfig viaBuilder = GatewayConfig.builder().version(2).allowedMethods(List.of(HttpMethod.GET)) + .build(); + assertEquals(viaCtor, viaBuilder); + } + + @Test + void endpointConfigBuilderMatchesConstructor() { + AuthConfig auth = auth(); + EndpointConfig viaCtor = new EndpointConfig("orders", true, "orders-service", auth, + List.of(HttpMethod.GET), Optional.empty(), List.of()); + EndpointConfig viaBuilder = EndpointConfig.builder().id("orders").enabled(true).baseUrl("orders-service") + .auth(auth).allowedMethods(List.of(HttpMethod.GET)).build(); + assertEquals(viaCtor, viaBuilder); + } + } + + // --- Null-normalization ------------------------------------------------ + + @Nested + @DisplayName("Canonical constructors normalize absent components") + class NullNormalization { + + @Test + void gatewayConfigNormalizesAllAbsentComponents() { + GatewayConfig cfg = new GatewayConfig(1, null, null, null, null, null, null, null, null, null); + assertTrue(cfg.metadata().isEmpty()); + assertTrue(cfg.tls().isEmpty()); + assertTrue(cfg.securityHeaders().isEmpty()); + assertTrue(cfg.securityDefaults().isEmpty()); + assertTrue(cfg.allowedMethods().isEmpty()); + assertTrue(cfg.upstreamDefaults().isEmpty()); + assertTrue(cfg.forwarded().isEmpty()); + assertTrue(cfg.tokenValidation().isEmpty()); + assertTrue(cfg.oidc().isEmpty()); + } + + @Test + void endpointConfigNormalizesAbsentCollectionsAndOptionals() { + EndpointConfig cfg = new EndpointConfig("id", true, "url", auth(), null, null, null); + assertTrue(cfg.allowedMethods().isEmpty()); + assertTrue(cfg.upstreamDefaults().isEmpty()); + assertTrue(cfg.routes().isEmpty()); + } + + @Test + void collectionBearingRecordsNormalizeNullToEmpty() { + assertTrue(new AuthConfig("none", null).requiredScopes().isEmpty()); + assertTrue(new TokenValidationConfig(null).issuers().isEmpty()); + assertTrue(new ForwardedConfig(null, null, null).trustedProxies().isEmpty()); + assertTrue(new ForwardConfig(null, null, null).setHeaders().isEmpty()); + assertTrue(new TlsConfig(null, null, null, null).alpn().isEmpty()); + assertTrue(new TlsConfig(null, null, null, null).passthroughSni().isEmpty()); + assertTrue(new MatchConfig("/p", null, null, null).methods().isEmpty()); + assertTrue(new SecurityFilterConfig(null, null, null, null, null, null, null, null, null, null) + .allowedPaths().isEmpty()); + assertTrue(new OidcConfig.Csrf(null).trustedOrigins().isEmpty()); + } + } + + // --- Defensive copy ---------------------------------------------------- + + @Nested + @DisplayName("Collection components are defensively copied and unmodifiable") + class DefensiveCopy { + + @Test + void listComponentIsDecoupledFromTheSource() { + List source = new ArrayList<>(List.of(HttpMethod.GET)); + GatewayConfig cfg = GatewayConfig.builder().version(1).allowedMethods(source).build(); + source.add(HttpMethod.POST); + assertEquals(List.of(HttpMethod.GET), cfg.allowedMethods(), + "mutating the source list after construction must not affect the record"); + } + + @Test + void listComponentIsUnmodifiable() { + GatewayConfig cfg = GatewayConfig.builder().version(1).allowedMethods(List.of(HttpMethod.GET)).build(); + assertThrows(UnsupportedOperationException.class, () -> cfg.allowedMethods().add(HttpMethod.PUT)); + } + + @Test + void mapComponentIsDecoupledAndUnmodifiable() { + Map source = new HashMap<>(); + source.put("X-Gateway", "sheriff"); + ForwardConfig cfg = ForwardConfig.builder().setHeaders(source).build(); + source.put("X-Extra", "leak"); + assertEquals(Map.of("X-Gateway", "sheriff"), cfg.setHeaders()); + assertThrows(UnsupportedOperationException.class, () -> cfg.setHeaders().put("X-New", "v")); + } + } + + // --- Mandatory fields -------------------------------------------------- + + @Nested + @DisplayName("Mandatory fields are rejected with NullPointerException") + class MandatoryFields { + + @Test + void endpointConfigRequiresIdBaseUrlAndAuth() { + AuthConfig auth = auth(); + assertThrows(NullPointerException.class, + () -> new EndpointConfig(null, true, "url", auth, List.of(), Optional.empty(), List.of())); + assertThrows(NullPointerException.class, + () -> new EndpointConfig("id", true, null, auth, List.of(), Optional.empty(), List.of())); + assertThrows(NullPointerException.class, + () -> new EndpointConfig("id", true, "url", null, List.of(), Optional.empty(), List.of())); + } + + @Test + void authConfigRequiresRequire() { + NullPointerException ex = assertThrows(NullPointerException.class, + () -> new AuthConfig(null, List.of())); + assertEquals("require", ex.getMessage()); + } + + @Test + void issuerConfigRequiresNameAndIssuer() { + assertThrows(NullPointerException.class, + () -> new IssuerConfig(null, "iss", Optional.empty(), Optional.empty())); + assertThrows(NullPointerException.class, + () -> new IssuerConfig("name", null, Optional.empty(), Optional.empty())); + } + + @Test + void jwksRequiresSource() { + assertThrows(NullPointerException.class, + () -> new IssuerConfig.Jwks(null, Optional.empty(), Optional.empty())); + } + + @Test + void matchConfigRequiresPathPrefix() { + assertThrows(NullPointerException.class, () -> new MatchConfig(null, List.of(), Optional.empty(), List.of())); + } + + @Test + void headerMatcherRequiresName() { + assertThrows(NullPointerException.class, + () -> new MatchConfig.HeaderMatcher(null, Optional.empty(), Optional.empty())); + } + + @Test + void routeConfigRequiresIdAndMatch() { + MatchConfig match = matchConfig(); + assertThrows(NullPointerException.class, () -> new RouteConfig(null, Optional.empty(), match, + Optional.empty(), Optional.empty(), Optional.empty(), Optional.empty(), Optional.empty())); + assertThrows(NullPointerException.class, () -> new RouteConfig("id", Optional.empty(), null, + Optional.empty(), Optional.empty(), Optional.empty(), Optional.empty(), Optional.empty())); + } + } + + // --- New fields introduced for this deliverable ------------------------ + + @Nested + @DisplayName("New endpoint-enablement / method-allowlist fields") + class NewFields { + + @Test + void endpointConfigExposesIdEnabledAndAllowedMethods() { + EndpointConfig cfg = EndpointConfig.builder() + .id("orders") + .enabled(true) + .baseUrl("orders-service") + .auth(auth()) + .allowedMethods(List.of(HttpMethod.GET, HttpMethod.POST)) + .build(); + assertEquals("orders", cfg.id()); + assertTrue(cfg.enabled()); + assertEquals(List.of(HttpMethod.GET, HttpMethod.POST), cfg.allowedMethods()); + } + + @Test + void endpointConfigEnabledDefaultsToFalseWhenUnsetOnTheRecord() { + EndpointConfig cfg = EndpointConfig.builder().id("orders").baseUrl("orders-service").auth(auth()).build(); + assertFalse(cfg.enabled(), "the record itself applies no default; the YAML loader owns the true default"); + } + + @Test + void gatewayConfigExposesAllowedMethods() { + GatewayConfig cfg = GatewayConfig.builder().version(1).allowedMethods(List.of(HttpMethod.DELETE)).build(); + assertEquals(List.of(HttpMethod.DELETE), cfg.allowedMethods()); + } + } + + // --- Enum contracts ---------------------------------------------------- + + @Nested + @DisplayName("HttpMethod enum contract") + class HttpMethodEnum { + + @Test + void exposesExactlyTheProxyableVerbsInOrder() { + assertEquals(List.of(HttpMethod.GET, HttpMethod.HEAD, HttpMethod.POST, HttpMethod.PUT, HttpMethod.PATCH, + HttpMethod.DELETE, HttpMethod.OPTIONS), List.of(HttpMethod.values())); + } + + @Test + void doesNotRepresentTraceOrConnect() { + assertThrows(IllegalArgumentException.class, () -> HttpMethod.valueOf("TRACE")); + assertThrows(IllegalArgumentException.class, () -> HttpMethod.valueOf("CONNECT")); + } + } + + @Nested + @DisplayName("Protocol enum contract") + class ProtocolEnum { + + @Test + void exposesTheSupportedProtocols() { + assertEquals(List.of(Protocol.HTTP, Protocol.GRPC, Protocol.GRAPHQL, Protocol.WEBSOCKET), + List.of(Protocol.values())); + } + } + + // --- Static factory ---------------------------------------------------- + + @Nested + @DisplayName("UpstreamDefaultsConfig static factory") + class UpstreamDefaultsFactory { + + @Test + void defaultsEnableRetryAndNotModified() { + UpstreamDefaultsConfig defaults = UpstreamDefaultsConfig.defaults(); + assertTrue(defaults.retryEnabled()); + assertTrue(defaults.notModifiedEnabled()); + assertEquals(new UpstreamDefaultsConfig(true, true), defaults); + } + } +} diff --git a/api-sheriff/src/test/java/de/cuioss/sheriff/api/config/topology/EndpointEnablementResolverTest.java b/api-sheriff/src/test/java/de/cuioss/sheriff/api/config/topology/EndpointEnablementResolverTest.java new file mode 100644 index 0000000..a6de0f9 --- /dev/null +++ b/api-sheriff/src/test/java/de/cuioss/sheriff/api/config/topology/EndpointEnablementResolverTest.java @@ -0,0 +1,86 @@ +/* + * Copyright © 2022 CUI-OpenSource-Software (info@cuioss.de) + * + * 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 de.cuioss.sheriff.api.config.topology; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.List; +import java.util.Locale; +import java.util.Map; + + +import org.junit.jupiter.api.Test; + +import de.cuioss.sheriff.api.config.model.AuthConfig; +import de.cuioss.sheriff.api.config.model.EndpointConfig; + +/** + * Tests for {@link EndpointEnablementResolver}: environment override precedence over + * the file {@code enabled} default, the id-to-variable-name derivation, and filtering + * to the enabled endpoints. + */ +class EndpointEnablementResolverTest { + + private static EndpointConfig endpoint(String id, boolean enabled) { + return EndpointConfig.builder() + .id(id) + .enabled(enabled) + .baseUrl(id.toUpperCase(Locale.ROOT)) + .auth(new AuthConfig("none", List.of())) + .build(); + } + + private static EndpointEnablementResolver resolverWith(Map environment) { + return new EndpointEnablementResolver(environment::get); + } + + @Test + void environmentOverrideDisablesAFileEnabledEndpoint() { + EndpointEnablementResolver resolver = resolverWith(Map.of("ENDPOINT_ORDERS_ENABLED", "false")); + assertFalse(resolver.isEnabled(endpoint("orders", true))); + } + + @Test + void environmentOverrideEnablesAFileDisabledEndpoint() { + EndpointEnablementResolver resolver = resolverWith(Map.of("ENDPOINT_ORDERS_ENABLED", "true")); + assertTrue(resolver.isEnabled(endpoint("orders", false))); + } + + @Test + void fallsBackToFileValueWhenNoOverride() { + EndpointEnablementResolver resolver = resolverWith(Map.of()); + assertTrue(resolver.isEnabled(endpoint("orders", true))); + assertFalse(resolver.isEnabled(endpoint("orders", false))); + } + + @Test + void derivesEnvironmentVariableNameFromEndpointId() { + assertEquals("ENDPOINT_ORDER_API_ENABLED", EndpointEnablementResolver.environmentVariableName("order-api")); + assertEquals("ENDPOINT_ORDERS_ENABLED", EndpointEnablementResolver.environmentVariableName("orders")); + } + + @Test + void filtersDownToEnabledEndpoints() { + EndpointEnablementResolver resolver = resolverWith(Map.of("ENDPOINT_USERS_ENABLED", "false")); + List enabled = resolver.enabledEndpoints(List.of( + endpoint("orders", true), + endpoint("users", true), + endpoint("legacy", false))); + assertEquals(List.of("orders"), enabled.stream().map(EndpointConfig::id).toList()); + } +} diff --git a/api-sheriff/src/test/java/de/cuioss/sheriff/api/config/topology/TopologyResolverTest.java b/api-sheriff/src/test/java/de/cuioss/sheriff/api/config/topology/TopologyResolverTest.java new file mode 100644 index 0000000..e76a84f --- /dev/null +++ b/api-sheriff/src/test/java/de/cuioss/sheriff/api/config/topology/TopologyResolverTest.java @@ -0,0 +1,148 @@ +/* + * Copyright © 2022 CUI-OpenSource-Software (info@cuioss.de) + * + * 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 de.cuioss.sheriff.api.config.topology; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.stream.Stream; + + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; + +import de.cuioss.sheriff.api.config.model.AuthConfig; +import de.cuioss.sheriff.api.config.model.EndpointConfig; +import de.cuioss.sheriff.api.config.model.ResolvedTopology; +import de.cuioss.sheriff.api.config.model.ResolvedUpstream; +import de.cuioss.sheriff.api.config.topology.TopologyResolver.TopologyResolutionException; + +/** + * Tests for {@link TopologyResolver}: file / environment resolution precedence, URL + * decomposition with scheme-default ports, and the boot failures for malformed or + * missing aliases referenced by enabled endpoints. + */ +class TopologyResolverTest { + + @TempDir + Path directory; + + private static EndpointConfig endpointFor(String alias) { + return EndpointConfig.builder() + .id(alias.toLowerCase(Locale.ROOT)) + .enabled(true) + .baseUrl(alias) + .auth(new AuthConfig("none", List.of())) + .build(); + } + + private Path topologyFile(String content) throws IOException { + Path file = directory.resolve("topology.properties"); + Files.writeString(file, content); + return file; + } + + private static TopologyResolver resolverWith(Map environment) { + return new TopologyResolver(environment::get); + } + + @Test + void resolvesFromFileAndDecomposesComponents() throws Exception { + Path file = topologyFile("ORDERS=https://orders.internal:8443/api\n"); + + ResolvedTopology topology = resolverWith(Map.of()).resolve(file, List.of(endpointFor("ORDERS"))); + + ResolvedUpstream upstream = topology.lookup("ORDERS").orElseThrow(); + assertEquals("https", upstream.scheme()); + assertEquals("orders.internal", upstream.host()); + assertEquals(8443, upstream.port()); + assertEquals("/api", upstream.basePath()); + } + + @Test + void defaultsPortFromSchemeWhenAbsent() throws Exception { + Path file = topologyFile("USERS=https://users.internal\nORDERS=http://orders.internal\n"); + + ResolvedTopology topology = resolverWith(Map.of()) + .resolve(file, List.of(endpointFor("USERS"), endpointFor("ORDERS"))); + + assertEquals(443, topology.lookup("USERS").orElseThrow().port()); + assertEquals(80, topology.lookup("ORDERS").orElseThrow().port()); + assertEquals("", topology.lookup("USERS").orElseThrow().basePath()); + } + + @Test + void environmentOverrideWinsOverFile() throws Exception { + Path file = topologyFile("ORDERS=https://file.host:1000/file\n"); + + ResolvedTopology topology = resolverWith(Map.of("TOPOLOGY_ORDERS", "https://env.host:2000/env")) + .resolve(file, List.of(endpointFor("ORDERS"))); + + ResolvedUpstream upstream = topology.lookup("ORDERS").orElseThrow(); + assertEquals("env.host", upstream.host()); + assertEquals(2000, upstream.port()); + } + + @Test + void trimsSurroundingWhitespaceBeforeParsing() throws Exception { + Path file = topologyFile("ORDERS= https://orders.internal:8443/api \n"); + + ResolvedTopology topology = resolverWith(Map.of()).resolve(file, List.of(endpointFor("ORDERS"))); + + ResolvedUpstream upstream = topology.lookup("ORDERS").orElseThrow(); + assertEquals("orders.internal", upstream.host()); + assertEquals(8443, upstream.port()); + assertEquals("/api", upstream.basePath()); + } + + @ParameterizedTest(name = "rejects {1}") + @MethodSource("invalidTopologies") + void rejectsInvalidTopology(String topologyContent, String scenario, String endpointAlias) throws Exception { + Path file = topologyFile(topologyContent); + TopologyResolver resolver = resolverWith(Map.of()); + List endpoints = List.of(endpointFor(endpointAlias)); + + assertThrows(TopologyResolutionException.class, + () -> resolver.resolve(file, endpoints)); + } + + static Stream invalidTopologies() { + return Stream.of( + Arguments.of("ORDERS=http://orders internal:8443\n", "malformed url", "ORDERS"), + Arguments.of("ORDERS=orders.internal:8443\n", "url without scheme or host", "ORDERS"), + Arguments.of("OTHER=https://other.internal\n", "missing alias referenced by enabled endpoint", + "MISSING")); + } + + @Test + void resolvesNothingWhenNoEnabledEndpointsAndToleratesAbsentFile() { + Path absent = directory.resolve("does-not-exist.properties"); + + ResolvedTopology topology = resolverWith(Map.of()).resolve(absent, List.of()); + + assertTrue(topology.aliases().isEmpty()); + } +} diff --git a/api-sheriff/src/test/java/de/cuioss/sheriff/api/config/validation/ConfigValidatorTest.java b/api-sheriff/src/test/java/de/cuioss/sheriff/api/config/validation/ConfigValidatorTest.java new file mode 100644 index 0000000..5926321 --- /dev/null +++ b/api-sheriff/src/test/java/de/cuioss/sheriff/api/config/validation/ConfigValidatorTest.java @@ -0,0 +1,408 @@ +/* + * Copyright © 2022 CUI-OpenSource-Software (info@cuioss.de) + * + * 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 de.cuioss.sheriff.api.config.validation; + +import static org.junit.jupiter.api.Assertions.assertAll; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.HashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Optional; + + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +import de.cuioss.sheriff.api.config.load.ConfigError; +import de.cuioss.sheriff.api.config.model.AuthConfig; +import de.cuioss.sheriff.api.config.model.EndpointConfig; +import de.cuioss.sheriff.api.config.model.ForwardedConfig; +import de.cuioss.sheriff.api.config.model.GatewayConfig; +import de.cuioss.sheriff.api.config.model.HttpMethod; +import de.cuioss.sheriff.api.config.model.IssuerConfig; +import de.cuioss.sheriff.api.config.model.MatchConfig; +import de.cuioss.sheriff.api.config.model.OidcConfig; +import de.cuioss.sheriff.api.config.model.ResolvedTopology; +import de.cuioss.sheriff.api.config.model.ResolvedUpstream; +import de.cuioss.sheriff.api.config.model.RouteConfig; +import de.cuioss.sheriff.api.config.model.SecurityHeadersConfig; +import de.cuioss.sheriff.api.config.model.TokenValidationConfig; +import de.cuioss.sheriff.api.config.model.UpstreamConfig; + +/** + * Tests for {@link ConfigValidator}: one negative case per enforced cross-cutting + * rule, the structural {@code TRACE}/{@code CONNECT} rejection, and the + * single-pass aggregation contract that reports every violation together rather + * than stopping at the first. + */ +class ConfigValidatorTest { + + private final ConfigValidator validator = new ConfigValidator(); + + // --- fixture helpers ------------------------------------------------- + + private static GatewayConfig.GatewayConfigBuilder validGateway() { + return GatewayConfig.builder().version(1); + } + + private static ResolvedTopology topologyWith(String... aliases) { + Map map = new HashMap<>(); + for (String alias : aliases) { + map.put(alias, new ResolvedUpstream("https", alias.toLowerCase(Locale.ROOT) + ".internal", 443, "")); + } + return new ResolvedTopology(map); + } + + private static MatchConfig match(String pathPrefix, HttpMethod... methods) { + return MatchConfig.builder().pathPrefix(pathPrefix).methods(List.of(methods)).build(); + } + + private static RouteConfig route(String id, HttpMethod... methods) { + return RouteConfig.builder().id(id).match(match("/" + id, methods)).build(); + } + + private static EndpointConfig endpoint(String id, String alias, List allowedMethods, + RouteConfig... routes) { + return EndpointConfig.builder() + .id(id) + .enabled(true) + .baseUrl(alias) + .auth(new AuthConfig("none", List.of())) + .allowedMethods(allowedMethods) + .routes(List.of(routes)) + .build(); + } + + private static void assertHasError(List errors, String pointerContains, String messageContains) { + assertTrue(errors.stream() + .anyMatch(e -> e.pointer().contains(pointerContains) && e.message().contains(messageContains)), + () -> "expected an error whose pointer contains '" + pointerContains + "' and message contains '" + + messageContains + "', but got: " + errors); + } + + @Nested + @DisplayName("A well-formed configuration") + class ValidConfiguration { + + @Test + @DisplayName("Should report no violations") + void shouldReportNoViolations() { + GatewayConfig gateway = validGateway().build(); + EndpointConfig endpoint = endpoint("orders", "ORDERS", List.of(), route("orders-list", HttpMethod.GET)); + + List errors = validator.validate(gateway, List.of(endpoint), topologyWith("ORDERS")); + + assertTrue(errors.isEmpty(), () -> "expected no violations, got: " + errors); + } + } + + @Nested + @DisplayName("Each enforced rule") + class RuleViolations { + + @Test + @DisplayName("Should reject an unsupported config version") + void shouldRejectUnsupportedVersion() { + GatewayConfig gateway = GatewayConfig.builder().version(2).build(); + EndpointConfig endpoint = endpoint("orders", "ORDERS", List.of(), route("r", HttpMethod.GET)); + + List errors = validator.validate(gateway, List.of(endpoint), topologyWith("ORDERS")); + + assertHasError(errors, "/version", "unsupported config version"); + } + + @Test + @DisplayName("Should reject a duplicate endpoint id across endpoint files") + void shouldRejectDuplicateEndpointId() { + EndpointConfig first = endpoint("orders", "ORDERS", List.of(), route("r1", HttpMethod.GET)); + EndpointConfig second = endpoint("orders", "USERS", List.of(), route("r2", HttpMethod.GET)); + + List errors = validator.validate(validGateway().build(), List.of(first, second), + topologyWith("ORDERS", "USERS")); + + assertHasError(errors, "/endpoint/id", "duplicate endpoint id: orders"); + } + + @Test + @DisplayName("Should reject a duplicate route id across endpoint files") + void shouldRejectDuplicateRouteId() { + EndpointConfig first = endpoint("ep-a", "ORDERS", List.of(), route("shared", HttpMethod.GET)); + EndpointConfig second = endpoint("ep-b", "USERS", List.of(), route("shared", HttpMethod.GET)); + + List errors = validator.validate(validGateway().build(), List.of(first, second), + topologyWith("ORDERS", "USERS")); + + assertHasError(errors, "/endpoint/routes", "duplicate route id: shared"); + } + + @Test + @DisplayName("Should reject an enabled endpoint whose base_url alias does not resolve") + void shouldRejectUnresolvedAliasForEnabledEndpoint() { + EndpointConfig endpoint = endpoint("orders", "MISSING", List.of(), route("r", HttpMethod.GET)); + + List errors = validator.validate(validGateway().build(), List.of(endpoint), + topologyWith("ORDERS")); + + assertHasError(errors, "/endpoint/base_url", "unresolved topology alias: MISSING"); + } + + @Test + @DisplayName("Should reject effective auth 'bearer' without a token_validation issuer") + void shouldRejectBearerWithoutIssuer() { + EndpointConfig endpoint = EndpointConfig.builder() + .id("orders").enabled(true).baseUrl("ORDERS") + .auth(new AuthConfig("bearer", List.of())) + .routes(List.of(route("r", HttpMethod.GET))) + .build(); + + List errors = validator.validate(validGateway().build(), List.of(endpoint), + topologyWith("ORDERS")); + + assertHasError(errors, "/token_validation", "requires token_validation with at least one issuer"); + } + + @Test + @DisplayName("Should reject effective auth 'session' without an oidc block") + void shouldRejectSessionWithoutOidc() { + EndpointConfig endpoint = EndpointConfig.builder() + .id("orders").enabled(true).baseUrl("ORDERS") + .auth(new AuthConfig("session", List.of())) + .routes(List.of(route("r", HttpMethod.GET))) + .build(); + + List errors = validator.validate(validGateway().build(), List.of(endpoint), + topologyWith("ORDERS")); + + assertHasError(errors, "/oidc", "requires an oidc block"); + } + + @Test + @DisplayName("Should reject a route matching a method outside the effective allowed_methods") + void shouldRejectMethodOutsideEffectiveAllowedMethods() { + EndpointConfig endpoint = endpoint("orders", "ORDERS", List.of(HttpMethod.GET), + route("orders-post", HttpMethod.POST)); + + List errors = validator.validate(validGateway().build(), List.of(endpoint), + topologyWith("ORDERS")); + + assertHasError(errors, "/endpoint/routes", "outside the effective allowed_methods"); + } + + @Test + @DisplayName("Should reject a non whole-second upstream timeout") + void shouldRejectNonWholeSecondTimeout() { + RouteConfig route = RouteConfig.builder() + .id("r").match(match("/r", HttpMethod.GET)) + .upstream(Optional.of(UpstreamConfig.builder().connectTimeoutMs(Optional.of(1500)).build())) + .build(); + EndpointConfig endpoint = EndpointConfig.builder() + .id("orders").enabled(true).baseUrl("ORDERS") + .auth(new AuthConfig("none", List.of())) + .routes(List.of(route)) + .build(); + + List errors = validator.validate(validGateway().build(), List.of(endpoint), + topologyWith("ORDERS")); + + assertHasError(errors, "connect_timeout_ms", "must be a whole-second multiple"); + } + + @Test + @DisplayName("Should reject a non-positive upstream timeout") + void shouldRejectNonPositiveTimeout() { + RouteConfig route = RouteConfig.builder() + .id("r").match(match("/r", HttpMethod.GET)) + .upstream(Optional.of(UpstreamConfig.builder().readTimeoutMs(Optional.of(0)).build())) + .build(); + EndpointConfig endpoint = EndpointConfig.builder() + .id("orders").enabled(true).baseUrl("ORDERS") + .auth(new AuthConfig("none", List.of())) + .routes(List.of(route)) + .build(); + + List errors = validator.validate(validGateway().build(), List.of(endpoint), + topologyWith("ORDERS")); + + assertHasError(errors, "read_timeout_ms", "must be a positive whole-second multiple"); + } + + @Test + @DisplayName("Should reject a negative upstream timeout") + void shouldRejectNegativeTimeout() { + RouteConfig route = RouteConfig.builder() + .id("r").match(match("/r", HttpMethod.GET)) + .upstream(Optional.of(UpstreamConfig.builder().connectTimeoutMs(Optional.of(-1000)).build())) + .build(); + EndpointConfig endpoint = EndpointConfig.builder() + .id("orders").enabled(true).baseUrl("ORDERS") + .auth(new AuthConfig("none", List.of())) + .routes(List.of(route)) + .build(); + + List errors = validator.validate(validGateway().build(), List.of(endpoint), + topologyWith("ORDERS")); + + assertHasError(errors, "connect_timeout_ms", "must be a positive whole-second multiple"); + } + + @Test + @DisplayName("Should accept a positive whole-second upstream timeout") + void shouldAcceptPositiveWholeSecondTimeout() { + RouteConfig route = RouteConfig.builder() + .id("r").match(match("/r", HttpMethod.GET)) + .upstream(Optional.of(UpstreamConfig.builder().readTimeoutMs(Optional.of(2000)).build())) + .build(); + EndpointConfig endpoint = EndpointConfig.builder() + .id("orders").enabled(true).baseUrl("ORDERS") + .auth(new AuthConfig("none", List.of())) + .routes(List.of(route)) + .build(); + + List errors = validator.validate(validGateway().build(), List.of(endpoint), + topologyWith("ORDERS")); + + assertTrue(errors.isEmpty(), () -> "expected no violations for a positive whole-second timeout, got: " + + errors); + } + + @Test + @DisplayName("Should reject a trust-all CIDR in forwarded.trusted_proxies") + void shouldRejectTrustAllCidr() { + GatewayConfig gateway = validGateway() + .forwarded(Optional.of(ForwardedConfig.builder().trustedProxies(List.of("0.0.0.0/0")).build())) + .build(); + EndpointConfig endpoint = endpoint("orders", "ORDERS", List.of(), route("r", HttpMethod.GET)); + + List errors = validator.validate(gateway, List.of(endpoint), topologyWith("ORDERS")); + + assertHasError(errors, "/forwarded/trusted_proxies", "trust-all CIDR is not permitted: 0.0.0.0/0"); + } + + @Test + @DisplayName("Should reject a wildcard CORS origin combined with allow_credentials") + void shouldRejectWildcardOriginWithCredentials() { + GatewayConfig gateway = validGateway() + .securityHeaders(Optional.of(SecurityHeadersConfig.builder() + .cors(Optional.of(SecurityHeadersConfig.Cors.builder() + .allowedOrigins(List.of("*")) + .allowCredentials(Optional.of(true)) + .build())) + .build())) + .build(); + EndpointConfig endpoint = endpoint("orders", "ORDERS", List.of(), route("r", HttpMethod.GET)); + + List errors = validator.validate(gateway, List.of(endpoint), topologyWith("ORDERS")); + + assertHasError(errors, "/security_headers/cors", "wildcard origin '*' is not permitted"); + } + + @Test + @DisplayName("Should reject cookie session mode without an encryption_key") + void shouldRejectCookieSessionWithoutEncryptionKey() { + GatewayConfig gateway = validGateway() + .oidc(Optional.of(OidcConfig.builder() + .session(Optional.of(OidcConfig.Session.builder() + .mode(Optional.of("cookie")) + .build())) + .build())) + .build(); + EndpointConfig endpoint = endpoint("orders", "ORDERS", List.of(), route("r", HttpMethod.GET)); + + List errors = validator.validate(gateway, List.of(endpoint), topologyWith("ORDERS")); + + assertHasError(errors, "/oidc/session/encryption_key", "cookie session mode requires an encryption_key"); + } + + @Test + @DisplayName("Should reject server session mode without a store") + void shouldRejectServerSessionWithoutStore() { + GatewayConfig gateway = validGateway() + .oidc(Optional.of(OidcConfig.builder() + .session(Optional.of(OidcConfig.Session.builder() + .mode(Optional.of("server")) + .build())) + .build())) + .build(); + EndpointConfig endpoint = endpoint("orders", "ORDERS", List.of(), route("r", HttpMethod.GET)); + + List errors = validator.validate(gateway, List.of(endpoint), topologyWith("ORDERS")); + + assertHasError(errors, "/oidc/session/store", "server session mode requires a store"); + } + + @Test + @DisplayName("Should accept effective auth 'bearer' when a token_validation issuer is present") + void shouldAcceptBearerWithIssuer() { + GatewayConfig gateway = validGateway() + .tokenValidation(Optional.of(new TokenValidationConfig( + List.of(IssuerConfig.builder().name("primary").issuer("https://idp.example").build())))) + .build(); + EndpointConfig endpoint = EndpointConfig.builder() + .id("orders").enabled(true).baseUrl("ORDERS") + .auth(new AuthConfig("bearer", List.of())) + .routes(List.of(route("r", HttpMethod.GET))) + .build(); + + List errors = validator.validate(gateway, List.of(endpoint), topologyWith("ORDERS")); + + assertTrue(errors.isEmpty(), () -> "expected no violations for a valid bearer config, got: " + errors); + } + } + + @Nested + @DisplayName("The TRACE / CONNECT verbs") + class StructuralVerbRejection { + + @Test + @DisplayName("Should not be representable in the HttpMethod model") + void shouldNotBeRepresentableInModel() { + assertAll("forbidden verbs are absent from the enum", + () -> assertThrows(IllegalArgumentException.class, () -> HttpMethod.valueOf("TRACE"), + "TRACE must not be a representable HttpMethod"), + () -> assertThrows(IllegalArgumentException.class, () -> HttpMethod.valueOf("CONNECT"), + "CONNECT must not be a representable HttpMethod")); + } + } + + @Nested + @DisplayName("The aggregating validate pass") + class Aggregation { + + @Test + @DisplayName("Should report every violation together in a single pass") + void shouldReportEveryViolationInOnePass() { + GatewayConfig gateway = GatewayConfig.builder().version(2).build(); + EndpointConfig endpoint = EndpointConfig.builder() + .id("orders").enabled(true).baseUrl("MISSING") + .auth(new AuthConfig("none", List.of())) + .allowedMethods(List.of(HttpMethod.GET)) + .routes(List.of(route("orders-post", HttpMethod.POST))) + .build(); + + List errors = validator.validate(gateway, List.of(endpoint), topologyWith("ORDERS")); + + assertAll("all three independent violations surface together", + () -> assertTrue(errors.size() >= 3, () -> "expected at least three violations, got: " + errors), + () -> assertHasError(errors, "/version", "unsupported config version"), + () -> assertHasError(errors, "/endpoint/base_url", "unresolved topology alias: MISSING"), + () -> assertHasError(errors, "/endpoint/routes", "outside the effective allowed_methods")); + } + } +} diff --git a/api-sheriff/src/test/java/de/cuioss/sheriff/api/config/validation/rule/ValidationRuleTest.java b/api-sheriff/src/test/java/de/cuioss/sheriff/api/config/validation/rule/ValidationRuleTest.java new file mode 100644 index 0000000..a3871af --- /dev/null +++ b/api-sheriff/src/test/java/de/cuioss/sheriff/api/config/validation/rule/ValidationRuleTest.java @@ -0,0 +1,104 @@ +/* + * Copyright © 2022 CUI-OpenSource-Software (info@cuioss.de) + * + * 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 de.cuioss.sheriff.api.config.validation.rule; + +import static org.junit.jupiter.api.Assertions.assertAll; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; + + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import de.cuioss.sheriff.api.config.load.ConfigError; +import de.cuioss.sheriff.api.config.model.GatewayConfig; +import de.cuioss.sheriff.api.config.model.ResolvedTopology; + +/** + * Tests for the {@link ValidationRule} functional-interface contract: a rule + * appends a {@link ConfigError} per violation to the shared accumulator, + * leaves it untouched when nothing is wrong, may append several violations in one + * pass, and never replaces the caller's list so that consecutive rules accumulate + * into it. + */ +class ValidationRuleTest { + + private static final GatewayConfig GATEWAY = GatewayConfig.builder().version(1).build(); + private static final ResolvedTopology TOPOLOGY = new ResolvedTopology(Map.of()); + + @Test + @DisplayName("Should append one error per violation to the shared accumulator") + void shouldAppendOneErrorPerViolation() { + ValidationRule rule = (gateway, endpoints, topology, errors) -> + errors.add(new ConfigError("gateway.yaml", "/version", "boom")); + List errors = new ArrayList<>(); + + rule.validate(GATEWAY, List.of(), TOPOLOGY, errors); + + assertAll("single appended violation", + () -> assertEquals(1, errors.size(), "exactly one error should be appended"), + () -> assertEquals("boom", errors.getFirst().message(), "the appended message should be preserved")); + } + + @Test + @DisplayName("Should leave the accumulator untouched when there is no violation") + void shouldLeaveAccumulatorUntouchedWhenNoViolation() { + ValidationRule rule = (gateway, endpoints, topology, errors) -> { + // a compliant configuration appends nothing + }; + List errors = new ArrayList<>(); + + rule.validate(GATEWAY, List.of(), TOPOLOGY, errors); + + assertTrue(errors.isEmpty(), () -> "expected no errors, got: " + errors); + } + + @Test + @DisplayName("Should append several violations in a single pass without stopping at the first") + void shouldAppendSeveralViolationsInSinglePass() { + ValidationRule rule = (gateway, endpoints, topology, errors) -> { + errors.add(new ConfigError("a.yaml", "/x", "first")); + errors.add(new ConfigError("a.yaml", "/y", "second")); + }; + List errors = new ArrayList<>(); + + rule.validate(GATEWAY, List.of(), TOPOLOGY, errors); + + assertEquals(2, errors.size(), "both violations from one pass should be present"); + } + + @Test + @DisplayName("Should accumulate into the shared list across consecutive rules") + void shouldAccumulateAcrossConsecutiveRules() { + ValidationRule first = (gateway, endpoints, topology, errors) -> + errors.add(new ConfigError("a.yaml", "/x", "first")); + ValidationRule second = (gateway, endpoints, topology, errors) -> + errors.add(new ConfigError("b.yaml", "/y", "second")); + List errors = new ArrayList<>(); + + first.validate(GATEWAY, List.of(), TOPOLOGY, errors); + second.validate(GATEWAY, List.of(), TOPOLOGY, errors); + + assertAll("violations from both rules coexist", + () -> assertEquals(2, errors.size(), "both rules should contribute to the same list"), + () -> assertEquals("first", errors.getFirst().message(), "the first rule's error should be retained"), + () -> assertEquals("second", errors.get(1).message(), "the second rule's error should be appended")); + } +} diff --git a/api-sheriff/src/test/java/de/cuioss/sheriff/api/gateway/proxy/ProxyRouteTest.java b/api-sheriff/src/test/java/de/cuioss/sheriff/api/gateway/proxy/ProxyRouteTest.java index 183b84a..e994f75 100644 --- a/api-sheriff/src/test/java/de/cuioss/sheriff/api/gateway/proxy/ProxyRouteTest.java +++ b/api-sheriff/src/test/java/de/cuioss/sheriff/api/gateway/proxy/ProxyRouteTest.java @@ -48,18 +48,23 @@ * body to the upstream, hop-by-hop header stripping, and {@code 404} * deny-by-default for unmatched paths. *

- * The upstream is a {@link MockWebServer} started on the fixed port that - * {@code sheriff.proxy.upstream-url} points to in the test configuration — - * {@code @QuarkusTest} freezes config at boot, so a dynamically-assigned port - * cannot be injected. The mock echoes each received request back as JSON, so the - * assertions verify exactly what the gateway forwarded. + * The proxy edge sources its upstream from the {@code RouteTable} the + * configuration subsystem assembles at boot: the test config + * ({@code src/test/resources/config/testboot}) declares a single {@code /proxy} + * route ({@code endpoints/proxy.yaml}) whose {@code UPSTREAM} topology alias + * ({@code topology.properties}) resolves to the {@link MockWebServer} started on + * the fixed port below. There is no static {@code ProxyConfiguration} bean any + * more. The fixed port is required because {@code @QuarkusTest} freezes config at + * boot, so a dynamically-assigned port cannot be injected. The mock echoes each + * received request back as JSON, so the assertions verify exactly what the gateway + * forwarded from the RouteTable-sourced upstream. */ @QuarkusTest @EnableMockWebServer(manualStart = true) @ModuleDispatcher class ProxyRouteTest { - /** Must match {@code sheriff.proxy.upstream-url} in {@code src/test/resources/application.properties}. */ + /** Must match the {@code UPSTREAM} alias port in {@code config/testboot/topology.properties}. */ private static final int UPSTREAM_PORT = 19191; @BeforeAll diff --git a/api-sheriff/src/test/java/de/cuioss/sheriff/api/quarkus/ConfigFailFastTest.java b/api-sheriff/src/test/java/de/cuioss/sheriff/api/quarkus/ConfigFailFastTest.java new file mode 100644 index 0000000..cc60ca7 --- /dev/null +++ b/api-sheriff/src/test/java/de/cuioss/sheriff/api/quarkus/ConfigFailFastTest.java @@ -0,0 +1,73 @@ +/* + * Copyright © 2022 CUI-OpenSource-Software (info@cuioss.de) + * + * 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 de.cuioss.sheriff.api.quarkus; + +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import java.net.URISyntaxException; +import java.nio.file.Path; + + +import org.junit.jupiter.api.Test; + +import de.cuioss.test.juli.LogAsserts; +import de.cuioss.test.juli.TestLogLevel; +import de.cuioss.test.juli.junit5.EnableTestLogger; + +/** + * Tests that {@link ConfigProducer} fails fast on an invalid configuration: the + * bean producer and the startup observer both throw, and every collected violation + * plus the abort are logged as structured ERROR records — so Quarkus exits non-zero + * rather than serving on partial configuration. + */ +@EnableTestLogger +class ConfigFailFastTest { + + private static ConfigProducer producerForBrokenConfig() throws URISyntaxException { + var resource = ConfigFailFastTest.class.getResource("/config/broken"); + assertNotNull(resource, "broken config fixture must be on the test classpath"); + ConfigProducer producer = new ConfigProducer(); + producer.configDir = Path.of(resource.toURI()).toString(); + return producer; + } + + @Test + void shouldThrowFromProducerOnBrokenConfig() throws Exception { + ConfigProducer producer = producerForBrokenConfig(); + + assertThrows(IllegalStateException.class, producer::gatewayConfig, + "producing a bean from a broken config must fail fast"); + } + + @Test + void shouldFailStartupOnBrokenConfig() throws Exception { + ConfigProducer producer = producerForBrokenConfig(); + + assertThrows(IllegalStateException.class, () -> producer.onStartup(null), + "startup must abort on a broken config"); + } + + @Test + void shouldLogEveryViolationAndTheAbort() throws Exception { + ConfigProducer producer = producerForBrokenConfig(); + + assertThrows(IllegalStateException.class, producer::gatewayConfig); + + LogAsserts.assertLogMessagePresentContaining(TestLogLevel.ERROR, "Invalid configuration"); + LogAsserts.assertLogMessagePresentContaining(TestLogLevel.ERROR, "Refusing to start"); + } +} diff --git a/api-sheriff/src/test/java/de/cuioss/sheriff/api/quarkus/ConfigProducerTest.java b/api-sheriff/src/test/java/de/cuioss/sheriff/api/quarkus/ConfigProducerTest.java new file mode 100644 index 0000000..1c32d34 --- /dev/null +++ b/api-sheriff/src/test/java/de/cuioss/sheriff/api/quarkus/ConfigProducerTest.java @@ -0,0 +1,101 @@ +/* + * Copyright © 2022 CUI-OpenSource-Software (info@cuioss.de) + * + * 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 de.cuioss.sheriff.api.quarkus; + +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; + + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import de.cuioss.sheriff.api.config.model.GatewayConfig; +import de.cuioss.sheriff.api.config.model.RouteTable; +import de.cuioss.test.juli.LogAsserts; +import de.cuioss.test.juli.TestLogLevel; +import de.cuioss.test.juli.junit5.EnableTestLogger; + +/** + * Tests for {@link ConfigProducer}: CDI bean assembly from a valid configuration + * directory, the memoized single build, eager startup assembly, and the + * {@code CONFIG_LOADED} INFO log record. + */ +@EnableTestLogger +class ConfigProducerTest { + + private static final String VALID_GATEWAY = """ + version: 1 + metadata: + config_version: "2026-07-13" + """; + + @TempDir + Path configDir; + + private ConfigProducer producerForValidConfig() throws IOException { + Files.writeString(configDir.resolve("gateway.yaml"), VALID_GATEWAY); + ConfigProducer producer = new ConfigProducer(); + producer.configDir = configDir.toString(); + return producer; + } + + @Test + void shouldProduceBeansFromValidConfig() throws Exception { + ConfigProducer producer = producerForValidConfig(); + + GatewayConfig gateway = producer.gatewayConfig(); + RouteTable routeTable = producer.routeTable(); + + assertEquals(1, gateway.version(), "the bound gateway should carry the configured version"); + assertNotNull(routeTable, "the route table bean should be produced"); + assertTrue(routeTable.routes().isEmpty(), "a config with no endpoints yields an empty route table"); + } + + @Test + void shouldLogConfigLoadedOnSuccess() throws Exception { + ConfigProducer producer = producerForValidConfig(); + + producer.gatewayConfig(); + + LogAsserts.assertLogMessagePresentContaining(TestLogLevel.INFO, "Configuration loaded successfully"); + } + + @Test + void shouldBuildOnceAndCacheTheResult() throws Exception { + ConfigProducer producer = producerForValidConfig(); + + GatewayConfig first = producer.gatewayConfig(); + GatewayConfig second = producer.gatewayConfig(); + + assertSame(first, second, "the pipeline should be assembled once and cached"); + } + + @Test + void shouldAssembleEagerlyOnStartupForValidConfig() throws Exception { + ConfigProducer producer = producerForValidConfig(); + + assertDoesNotThrow(() -> producer.onStartup(null), + "a valid configuration should assemble without failing startup"); + assertNotNull(producer.gatewayConfig(), "beans should be available after startup assembly"); + } +} diff --git a/api-sheriff/src/test/resources/application.properties b/api-sheriff/src/test/resources/application.properties index 5a705b3..065eb81 100644 --- a/api-sheriff/src/test/resources/application.properties +++ b/api-sheriff/src/test/resources/application.properties @@ -4,7 +4,9 @@ quarkus.tls.key-store.pem.0.key=../integration-tests/src/main/docker/certificate quarkus.management.enabled=false quarkus.log.file.enable=false -# Proxy route under test — upstream is a MockWebServer started on this fixed port -# by ProxyRouteTest (Quarkus freezes config at boot, so a dynamic port cannot be used). -sheriff.proxy.path-prefix=/proxy -sheriff.proxy.upstream-url=http://localhost:19191 +# Minimal valid boot config so ConfigProducer's fail-fast startup succeeds inside +# the full-container @QuarkusTest boot (see src/test/resources/config/testboot). +# The proxy edge under test sources its upstream from the RouteTable assembled from +# this directory (endpoints/proxy.yaml -> UPSTREAM topology alias), not from any +# static sheriff.proxy.* configuration. +sheriff.config.dir=target/test-classes/config/testboot diff --git a/api-sheriff/src/test/resources/config/broken/gateway.yaml b/api-sheriff/src/test/resources/config/broken/gateway.yaml new file mode 100644 index 0000000..3bf9fd7 --- /dev/null +++ b/api-sheriff/src/test/resources/config/broken/gateway.yaml @@ -0,0 +1,6 @@ +# Intentionally broken gateway configuration used by ConfigFailFastTest. +# It violates the D2 gateway schema in two ways: 'version' is a string rather than +# an integer, and 'unknown_key' is not an allowed top-level property +# (additionalProperties: false). Loading it must fail fast at boot. +version: "not-an-integer" +unknown_key: true diff --git a/api-sheriff/src/test/resources/config/invalid/unknown-key.yaml b/api-sheriff/src/test/resources/config/invalid/unknown-key.yaml new file mode 100644 index 0000000..5dafb13 --- /dev/null +++ b/api-sheriff/src/test/resources/config/invalid/unknown-key.yaml @@ -0,0 +1,6 @@ +version: 1 +# Unknown top-level key: rejected by additionalProperties:false. +bogus_top_level: true +tls: + # Out-of-enum value: only "1.2" and "1.3" are permitted. + min_version: "1.1" diff --git a/api-sheriff/src/test/resources/config/testboot/endpoints/proxy.yaml b/api-sheriff/src/test/resources/config/testboot/endpoints/proxy.yaml new file mode 100644 index 0000000..cae0de7 --- /dev/null +++ b/api-sheriff/src/test/resources/config/testboot/endpoints/proxy.yaml @@ -0,0 +1,15 @@ +# Proxy endpoint for the @QuarkusTest boot configuration. +# +# Declares the single /proxy route ProxyRouteTest exercises. Its upstream is the +# UPSTREAM topology alias (topology.properties -> http://localhost:19191), so the +# assembled RouteTable carries one route whose ResolvedUpstream the proxy edge +# forwards to. This replaces the removed static ProxyConfiguration bean. +endpoint: + id: proxy + base_url: UPSTREAM + auth: + require: none + routes: + - id: proxy-all + match: + path_prefix: /proxy diff --git a/api-sheriff/src/test/resources/config/testboot/gateway.yaml b/api-sheriff/src/test/resources/config/testboot/gateway.yaml new file mode 100644 index 0000000..397dfee --- /dev/null +++ b/api-sheriff/src/test/resources/config/testboot/gateway.yaml @@ -0,0 +1,8 @@ +# Minimal valid boot configuration for the @QuarkusTest container. +# The sibling endpoints/proxy.yaml + topology.properties add the single /proxy +# route that ProxyRouteTest exercises, so ConfigProducer assembles a RouteTable +# with one route during full-container tests (GatewayResourceTest, ProxyRouteTest). +# Referenced by sheriff.config.dir in the test application.properties. +version: 1 +metadata: + config_version: "test-boot" diff --git a/api-sheriff/src/test/resources/config/testboot/topology.properties b/api-sheriff/src/test/resources/config/testboot/topology.properties new file mode 100644 index 0000000..c78a1eb --- /dev/null +++ b/api-sheriff/src/test/resources/config/testboot/topology.properties @@ -0,0 +1,6 @@ +# Topology aliases for the @QuarkusTest boot configuration. +# +# UPSTREAM points at the MockWebServer that ProxyRouteTest starts on the fixed +# port 19191. The proxy edge sources this upstream from the assembled RouteTable +# (endpoints/proxy.yaml -> UPSTREAM), no longer from a static ProxyConfiguration. +UPSTREAM=http://localhost:19191 diff --git a/api-sheriff/src/test/resources/config/valid/gateway.yaml b/api-sheriff/src/test/resources/config/valid/gateway.yaml new file mode 100644 index 0000000..4de7f53 --- /dev/null +++ b/api-sheriff/src/test/resources/config/valid/gateway.yaml @@ -0,0 +1,38 @@ +version: 1 +metadata: + config_version: "2024-05-01" +tls: + min_version: "1.3" + alpn: ["h2", "http/1.1"] +security_headers: + content_type_nosniff: true + frame_deny: true + hsts: + max_age: 31536000 + include_subdomains: true +security_defaults: + profile: "strict" +allowed_methods: ["GET", "POST", "PUT", "DELETE"] +upstream_defaults: + retry: + enabled: true + not_modified: + enabled: false +forwarded: + trusted_proxies: ["10.0.0.0/8"] + trust_scheme_host: true + emit: "both" +token_validation: + issuers: + - name: "primary" + issuer: "https://issuer.example.com" + audience: "api-sheriff" + jwks: + source: "http" + url: "https://issuer.example.com/jwks" +oidc: + issuer: "https://issuer.example.com" + client_id: "sheriff" + client_secret: "${OIDC_CLIENT_SECRET}" + scopes: ["openid", "profile"] + redirect_uri: "https://gw.example.com/callback" diff --git a/api-sheriff/src/test/resources/config/valid/topology.properties b/api-sheriff/src/test/resources/config/valid/topology.properties new file mode 100644 index 0000000..2240b07 --- /dev/null +++ b/api-sheriff/src/test/resources/config/valid/topology.properties @@ -0,0 +1,5 @@ +# Topology alias -> upstream URL mappings. +# Aliases match [A-Z][A-Z0-9_]*; TOPOLOGY_ environment variables override +# these file values at boot. Consumed by the topology resolver (Deliverable 5). +ORDERS=https://orders.internal:8443/api +USERS=https://users.internal:8443 diff --git a/doc/LogMessages.adoc b/doc/LogMessages.adoc index e2721cf..bc83876 100644 --- a/doc/LogMessages.adoc +++ b/doc/LogMessages.adoc @@ -30,6 +30,7 @@ diagnostics use the logger directly and are not catalogued. |=== |ID |Component |Message |Description |ApiSheriff-001 |PROXY |Proxy route registered: path-prefix='%s' upstream='%s' |Logged once at startup when the catch-all proxy route is registered on the data plane +|ApiSheriff-002 |CONFIG |Configuration loaded successfully (config_version='%s') |Logged once at startup after the file-based configuration is read, validated, and assembled into the route table |=== == WARN Level (100-199) @@ -39,3 +40,12 @@ diagnostics use the logger directly and are not catalogued. |ID |Component |Message |Description |ApiSheriff-100 |PROXY |Proxy request to upstream '%s' failed: %s |Logged when forwarding a request to the configured upstream fails; the gateway responds with 502 |=== + +== ERROR Level (200-299) + +[cols="1,1,2,2", options="header"] +|=== +|ID |Component |Message |Description +|ApiSheriff-200 |CONFIG |Invalid configuration in '%s' at '%s': %s |Logged once per collected configuration violation (offending file, JSON pointer, and detail) during fail-fast startup validation +|ApiSheriff-201 |CONFIG |Refusing to start — configuration is invalid: %s |Logged after the violations when startup is aborted; Quarkus then exits non-zero and never serves on partial configuration +|=== diff --git a/doc/adr/0005-module-structure.adoc b/doc/adr/0005-module-structure.adoc index 89bcef8..873da87 100644 --- a/doc/adr/0005-module-structure.adoc +++ b/doc/adr/0005-module-structure.adoc @@ -12,7 +12,7 @@ API Sheriff's production code currently lives in a single deployable Quarkus mod (`api-sheriff`), alongside the `integration-tests` and `benchmarks` coordinator modules. As the request pipeline (link:../plan/03-request-pipeline.adoc[Plan 03]) lands, that module gains a substantial body of logic that is *not* inherently Quarkus-specific: the immutable -configuration model and validators (link:../plan/02-configuration-management.adoc[Plan 02]), +configuration model and validators (Plan 02, delivered), the event/counter system, the route table and matchers, the forward policy, and the boot-time assembly of per-route runtime objects. Only a thin outer layer is genuinely framework-bound: the CDI producers/observers, the Vert.x HTTP edge, the MicroProfile-config plumbing, and the diff --git a/doc/configuration.adoc b/doc/configuration.adoc index 82eebc8..d1d586e 100644 --- a/doc/configuration.adoc +++ b/doc/configuration.adoc @@ -1,7 +1,9 @@ = API Sheriff -- Configuration Model -:toc: +:toc: left :toclevels: 3 +:toc-title: Table of Contents :sectnums: +:source-highlighter: highlight.js == Principles @@ -84,6 +86,38 @@ conf/ The manifest's dynamic `/api/gateway-config.json` discovery is deferred; `metadata.config_version` is retained now only as an audit stamp. +[[_schemas]] +== Schemas + +Both configuration artifacts are validated against bundled *JSON Schema* documents (Draft +2020-12) before binding, so a structurally malformed file is rejected with a precise +file-and-JSON-pointer error before any immutable value object is built: + +[cols="1,3"] +|=== +| Schema | Validates + +| `schema/gateway.schema.json` +| The global `gateway.yaml` document -- `version` (the only required key), `metadata`, `tls`, + `security_headers`, `security_defaults`, `allowed_methods`, `upstream_defaults`, `forwarded`, + `token_validation` and `oidc`. + +| `schema/endpoint.schema.json` +| A single `endpoints/*.yaml` file -- the `endpoint` block (`id`, `enabled`, `base_url`, `auth`, + `allowed_methods`, `upstream_defaults`) and its `routes[]`, each route's `match`, `auth`, + `security_filter`, `forward`, `upstream` and reserved `rate_limit`. +|=== + +Both schemas set `additionalProperties: false` at *every* object level, so an *unknown key* -- a +typo or a stale field -- fails the boot rather than being silently ignored. The schemas mirror +this document field-for-field and are the *structural* gate only. The *cross-cutting* rules a +schema cannot express are enforced in code and listed under +link:#_configuration_validation_rules_summary[Configuration Validation Rules]: endpoint- and +route-`id` uniqueness across files, `upstream_defaults` and `allowed_methods` wholesale-replacement, +`allowed_methods` verb membership, topology-alias resolvability, and same-prefix disjointness. Every +violation -- from the schema gate and the code rules alike -- is collected in a single pass and +reported together, never one at a time, and any violation refuses the boot. + == Gateway Configuration (`gateway.yaml`) [source,yaml] @@ -743,6 +777,47 @@ downstream call -- stateless, per node; `cui-http` supplies retry and timeouts b (see link:architecture.adoc#_downstream_client[Architecture -- Downstream Client]). The upstream host is never written literally here -- only the alias-relative `path`. +[[_upstream_defaults]] +=== `upstream_defaults` + +Two upstream behaviour toggles -- upstream *retry* and HTTP *304 Not Modified* handling -- +declarable at two scopes, `gateway.yaml` (global) and per `endpoint`, and overridable per route. +Each route's effective value is materialized *once* at boot into the route table, so the request +pipeline reads a single resolved `true`/`false` and never re-derives the inheritance. + +[cols="1,3"] +|=== +| Field | Meaning + +| `retry.enabled` +| Whether idempotent upstream calls for the route are retried. This toggle only *gates* retry; + the retry *policy* (attempt count, idempotent-only) lives in the route's `upstream.retry` -- + see link:#_upstream[`upstream`]. + +| `not_modified.enabled` +| Whether the route participates in conditional-request / HTTP `304 Not Modified` handling. +|=== + +*Resolution (wholesale, no merge).* Each route's effective `retry` / `not_modified` toggle is, +in precedence order: + +* the route's own `upstream.retry.enabled` / `upstream.not_modified.enabled` if declared; else +* the *endpoint-level* `upstream_defaults` block if the endpoint declares one -- it *replaces* + the global block wholesale for that endpoint's routes (the same no-merge rule `auth` and + link:#_allowed_methods[`allowed_methods`] use); else +* the *global* `gateway.upstream_defaults` block if declared; else +* the built-in default -- both toggles *enabled*. + +Both `upstream_defaults` blocks (global and per endpoint) share one shape; an omitted toggle +defaults to `true`: + +[source,yaml] +---- +upstream_defaults: + retry: { enabled: true } + not_modified: { enabled: false } +---- + === `rate_limit` Reserved schema for per-node, in-memory token-bucket rate limiting diff --git a/doc/plan/01-base-implementation.adoc b/doc/plan/01-base-implementation.adoc index b335a42..0052ce0 100644 --- a/doc/plan/01-base-implementation.adoc +++ b/doc/plan/01-base-implementation.adoc @@ -122,7 +122,7 @@ One real end-to-end path, deliberately interim but on the final architecture lin * A Vert.x route handler (`quarkus-vertx-http`, already present transitively) registered as catch-all on the data plane, executing per-request on a *virtual thread*. * One route configured via MicroProfile config properties (interim; replaced by the real - config subsystem in link:02-configuration-management.adoc[Plan 02]): + config subsystem in Plan 02, delivered): `sheriff.proxy.path-prefix` and `sheriff.proxy.upstream-url`. * Forwarding: method + path remainder + query + body pass through; hop-by-hop headers stripped; JDK `HttpClient` blocking `send()` on the virtual thread (the `cui-http` @@ -207,7 +207,7 @@ must be green after every one. == Out of Scope -* Real configuration files/schemas (link:02-configuration-management.adoc[Plan 02]). +* Real configuration files/schemas (Plan 02, delivered). * Security filtering, token validation, forward policy, events/metrics/error contract (link:03-request-pipeline.adoc[Plan 03]). * gRPC/WebSocket/GraphQL processors, TLS passthrough, mTLS, BFF variants (later plans). diff --git a/doc/plan/02-configuration-management.adoc b/doc/plan/02-configuration-management.adoc deleted file mode 100644 index 98cdb5b..0000000 --- a/doc/plan/02-configuration-management.adoc +++ /dev/null @@ -1,238 +0,0 @@ -= Plan 02 -- Configuration Management -:toc: -:toclevels: 3 -:sectnums: - -== Goal - -Implement the complete configuration subsystem described in -link:../configuration.adoc[Configuration Model]: a JSON Schema per config artifact, -loading and resolution logic (`gateway.yaml`, `endpoints/*.yaml`, `topology.properties`), -full boot-time validation with fail-fast semantics, and immutable value objects the rest of -the gateway consumes. At the end of this plan, configuration management is *feature-complete* -for the initial (Static-mode) scope -- even where the features consuming a given block -(e.g. `oidc`) are not built yet, the block parses, validates, and freezes. - -== Required Reading (before implementation) - -Read these documents *in full* before touching code; the validation rules and semantics are -specified there, not here: - -. link:../configuration.adoc[Configuration Model] -- the authoritative schema, field - reference, defaults-for-omitted-blocks, route-ordering/disjointness rules, and the - link:../configuration.adoc#_configuration_validation_rules_summary[validation-rules summary]. -. link:../adr/0004-topology-indirection.adoc[ADR-0004] -- alias -> URL indirection, - `TOPOLOGY_` env override, decompose-on-read. -. link:../adr/0002-initial-scope.adoc[ADR-0002] -- Static mode only; `metadata.config_version` - is an audit stamp. -. link:../architecture.adoc[Architecture], section "Configuration Subsystem" -- immutability - as a security property, refuse-to-start semantics. -. link:01-base-implementation.adoc[Plan 01] -- the minimal proxy this plan replaces the - hardcoded configuration of. - -== Prerequisites - -* Plan 01 delivered: modules build locally and on CI; a minimal proxy exists with a - *hardcoded/property-based* single route that this plan replaces with real config files. - -== Design Decisions - -=== D1 -- Schema technology: JSON Schema (draft 2020-12), validated at boot - -One schema file per YAML artifact: - -[cols="1,2,2"] -|=== -| Artifact | Schema file | Referenced from - -| `gateway.yaml` -| `api-sheriff/src/main/resources/schema/gateway.schema.json` -| `# yaml-language-server: $schema=...` header comment in every sample/test config; linked - from `configuration.adoc` - -| `endpoints/*.yaml` -| `api-sheriff/src/main/resources/schema/endpoint.schema.json` -| same mechanism - -| `topology.properties` -| *no schema* -- `.properties` has no schema ecosystem; validated programmatically - (alias pattern `[A-Z][A-Z0-9_]*`, value must parse as URL) -| validation rules documented in `configuration.adoc` -|=== - -Schema posture: - -* `"additionalProperties": false` everywhere -- unknown keys fail the boot, per - link:../configuration.adoc[Configuration Model] ("unknown keys ... fail the boot"). -* The schema encodes *structure and per-field constraints* (types, enums such as - `require: none|bearer|session`, `emit: x-forwarded|both`, ranges, required blocks such as - `endpoint.auth`). *Cross-cutting rules* (route disjointness, alias resolution, - effective-auth -> required blocks, passthrough/host collisions, timeout whole-second rule, - trust-all CIDR rejection, `${ENV_VAR}`-only secrets) are code -- a `ConfigValidator` layer - that runs after schema validation and reports *all* violations, not just the first. -* The schema files are also the editor/IDE contract (yaml-language-server) and are linked - from the documentation -- one source of truth for structure. - -=== D2 -- Parsing/binding stack (requires dependency approval) - -Parse YAML with *Jackson* (`jackson-dataformat-yaml`, via the Quarkus-managed Jackson) into a -tree, validate the tree against the schema with *`com.networknt:json-schema-validator`*, then -bind to immutable Java records. Rationale: schema violations report JSON-Pointer paths (good -boot errors), Jackson is already on the classpath (`quarkus-resteasy-jackson`), and the -networknt validator is a plain library that works in native image. - -[IMPORTANT] -==== -New dependencies -- *require explicit user approval before adding* (CLAUDE.md rule): - -* `com.fasterxml.jackson.dataformat:jackson-dataformat-yaml` (version from the Quarkus BOM) -* `com.networknt:json-schema-validator` (schema validation at boot) - -Fallback if the networknt dependency is rejected: keep the schema files as documentation/IDE -artifacts only and express *all* structural rules in the programmatic validator. The plan's -deliverables do not change, only the implementation of the structural half. -==== - -=== D3 -- Configuration object model - -Immutable Java records under `de.cuioss.sheriff.api.config.model`: - -* `GatewayConfig` (root aggregate) -- `TlsConfig`, `SecurityHeadersConfig`, - `SecurityDefaultsConfig`, `ForwardedConfig`, `TokenValidationConfig` (+`IssuerConfig` - mirror), `OidcConfig` (parsed and validated now; consumed by later BFF plans), `Metadata`. -* `EndpointConfig` -- `id` (the unique endpoint name), `enabled` (default `true`), - `baseUrlAlias`, mandatory `AuthConfig`, optional `allowedMethods` (verb allowlist), and - `List`. -* `RouteConfig` -- `MatchConfig`, `AuthConfig` (nullable = inherit endpoint), - `SecurityFilterConfig`, `ForwardConfig`, `UpstreamConfig`, reserved `RateLimitConfig`, - `protocol` enum. -* `ResolvedTopology` -- alias -> `ResolvedUpstream{scheme, host, port, basePath}` - (decompose-once, ADR-0004). -* `RouteTable` -- the merged, disjointness-checked, longest-prefix-ordered immutable table - with the *effective auth* (endpoint default or wholesale route override) *and effective - `allowedMethods`* (endpoint list, else global, else the standard set -- no inheritance) - materialized per route, so downstream pipeline code never re-implements inheritance. Only - routes of *enabled* endpoints are included; a disabled endpoint (`enabled: false` or - `ENDPOINT__ENABLED=false`) contributes no rows and is exempt from alias resolution and - disjointness checks. - -Empty collections over null; `Optional` for optional scalar returns; Lombok only where records -don't fit. Every package gets `package-info.java`. - -=== D4 -- Loading pipeline and CDI wiring - ----- -config dir (property `sheriff.config.dir`, default `conf/`; overridable via -SHERIFF_CONFIG_DIR for the container) - | - v -1. read gateway.yaml, endpoints/*.yaml (sorted, for deterministic error output), - topology.properties -2. schema-validate each YAML document (D1/D2) -> collect ALL errors -3. bind to records (D3) -4. resolve ${ENV_VAR} secret references -> missing env var = boot error -5. resolve endpoint enablement (env ENDPOINT__ENABLED wins over file `enabled`); - drop disabled endpoints before the checks that only concern live routes -6. resolve topology aliases (env TOPOLOGY_ wins over file), decompose URLs - -- only for ENABLED endpoints' aliases -7. cross-validation (ConfigValidator: the full rule list from configuration.adoc - "Configuration Validation Rules (summary)") -> collect ALL errors -8. build RouteTable (enabled endpoints only; merge, disjointness check, longest-prefix - ordering, effective-auth + effective-allowed_methods materialization; weakening - overrides logged as boot-log WARNs) -9. freeze -> GatewayConfig + RouteTable exposed as @ApplicationScoped CDI beans ----- - -* Failure semantics: any error in steps 1-8 -> log every collected violation with - file/pointer context, then *fail startup* (throw from the CDI producer / startup observer; - Quarkus exits non-zero). Never serve traffic on partial config. -* `CONFIG_LOADED` INFO LogRecord (with `metadata.config_version` audit stamp) on success; - validation failures use structured ERROR LogRecords. Document all new records in - `doc/LogMessages.adoc` (create it -- CLAUDE.md requires it, it does not exist yet). -* The producer replaces Plan 01's hardcoded route: the minimal proxy now reads `RouteTable`. - -=== D5 -- What "reference the schema" means concretely - -* Sample config set under `api-sheriff/src/test/resources/config/valid/` (used by tests) and - a runnable example under `integration-tests/src/main/docker/sheriff-config/` (mounted into - the container) -- every YAML file carries the `$schema` header comment. -* `configuration.adoc` gains a short "Schemas" section linking the two schema files as the - machine-readable contract (doc change, same PR). - -== Work Breakdown - -. *Schemas* -- author `gateway.schema.json` + `endpoint.schema.json` mirroring - `configuration.adoc` exactly (every field of the two exhibits, enums, required lists, - `additionalProperties: false`). Review against the doc field-by-field; the doc governs. -. *Model records* (D3) + builders where needed; unit tests via `cui-test-value-objects` / - `cui-test-generator` contracts. -. *Loader* -- YAML read + schema validation + binding; error aggregation with - file/JSON-Pointer context. -. *Topology resolution* -- properties parsing, env override precedence, URL decomposition; - unit tests incl. malformed URL, missing alias, env-wins-over-file. -. *Endpoint enablement* -- resolve the `enabled` flag with `ENDPOINT__ENABLED` env - precedence (mirrors topology resolution: env wins over file, default `true`); disabled - endpoints are dropped before route-table assembly and exempt from alias resolution; unit - tests incl. env-disables-file-enabled, env-enables-file-disabled, disabled-endpoint-alias- - need-not-resolve. -. *ConfigValidator* -- one small rule class per bullet of the validation-rules summary in - `configuration.adoc`, each with parameterized tests (valid + violating fixtures): - endpoint-id uniqueness, route-id uniqueness, alias resolution (enabled endpoints only), - endpoint-auth mandatory, effective-auth -> `token_validation`/`oidc` presence, same-prefix - disjointness (host/methods/header value), `allowed_methods` TRACE/CONNECT rejection + - `match.methods` within the effective `allowed_methods`, passthrough-SNI vs `match.host` collision, - passthrough alias base-path rule, trusted-proxies presence + trust-all rejection, - whole-second timeouts, `${ENV_VAR}`-only secrets, cookie/server session-mode conditionals, - CORS wildcard+credentials rejection. -. *RouteTable* -- merge/ordering/disjointness + effective-auth + effective-`allowed_methods` - materialization (enabled endpoints only); property-based tests with `@GeneratorsSource` for - prefix/host/method permutations. -. *CDI wiring* -- producer, startup observer, fail-fast test (`QuarkusUnitTest` with broken - config asserting startup failure), `CONFIG_LOADED` log assertion via - `cui-test-juli-logger`. -. *Integration tests* -- mount the example `sheriff-config/` into the container - (docker-compose volume), point the minimal proxy route at the Plan 01 upstream target via - `topology.properties`; IT asserts (a) gateway boots with the mounted config, (b) the routed - request reaches the upstream, (c) readiness (`/q/health/ready`) reflects loaded config. Add - one negative script-level check: container with an invalid config exits non-zero. -. *Docs & cleanup* -- `doc/LogMessages.adoc`, schema references in `configuration.adoc`, - remove any remaining Plan 01 hardcoded-route configuration. - -== Execution Workflow - -. Create a feature branch from up-to-date `main`: `feature/plan-02-configuration-management`. -. Implement the work breakdown above. -. Verify everything locally -- both must pass with zero errors/warnings: - `./mvnw -Ppre-commit clean verify -DskipTests` (quality gate) and `./mvnw clean install`. -. Commit and push the branch. -. Create the PR (`gh pr create --repo cuioss/API-Sheriff --base main ...`). -. Wait ~5 minutes for the AI reviewers to complete, and `gh pr checks --watch` until all - checks are green. -. Handle *every* review comment: fix + commit + push + reply + resolve, or reply with the - reason for not fixing + resolve; when uncertain, ask the user before acting. -. Merge the PR, then `git checkout main && git pull`. - -If the plan has to be split into multiple PRs, this workflow applies to each PR; the build -must be green after every one. - -== Acceptance Criteria - -* Both schema files exist, match `configuration.adoc` field-for-field, and every sample/test - YAML references them. -* All validation rules from the summary section are enforced with at least one negative test - each; error output names file + path for every violation in one pass. -* `GatewayConfig`/`RouteTable` are immutable, CDI-injectable, and consumed by the proxy. -* Invalid config => non-zero exit, no traffic served (verified by unit-level Quarkus test and - container-level negative IT). -* `./mvnw -Ppre-commit clean verify -DskipTests` and `./mvnw clean install` pass; coverage of - the new `config` packages >= 80%. -* `./mvnw clean verify -Pintegration-tests -pl integration-tests -am` passes locally and via - `.github/workflows/integration-tests.yml`. - -== Out of Scope - -* Consuming `oidc`, `tls.passthrough_sni`, `mtls`, `security_filter`, `forwarded`, or - `token_validation` at runtime -- the blocks are *parsed and validated* here; their runtime - behaviour is link:03-request-pipeline.adoc[Plan 03] (+ later variant plans). -* Dynamic configuration / discovery (deferred, ADR-0002). -* `rate_limit` behaviour (reserved field; accepted and ignored). diff --git a/doc/plan/03-request-pipeline.adoc b/doc/plan/03-request-pipeline.adoc index 0a80ddd..e79f929 100644 --- a/doc/plan/03-request-pipeline.adoc +++ b/doc/plan/03-request-pipeline.adoc @@ -39,8 +39,7 @@ the event system, metrics, structured logging, and the RFC 9457 error contract. `TokenValidator`). . `cui-http/doc/forwarded-header-resolution.adoc` (in the cui-http repository) -- the authoritative resolver behaviour contract. -. link:02-configuration-management.adoc[Plan 02] -- the `GatewayConfig`/`RouteTable` model - this plan consumes. +. Plan 02 (delivered) -- the `GatewayConfig`/`RouteTable` model this plan consumes. == Prerequisites @@ -49,20 +48,18 @@ the event system, metrics, structured logging, and the RFC 9457 error contract. == Design Decisions -=== D1 -- cui-http 2.1-SNAPSHOT (mandatory) +=== D1 -- cui-http 2.1 (mandatory) The `de.cuioss.http.forwarded` package (`ForwardedHeaderResolver`, `ResolvedForwarding`, -`ForwardedResolverConfig`) exists only in `de.cuioss:cui-http:2.1-SNAPSHOT` -- *verified: it -is absent from the released 2.0.0 jar*. This plan therefore pins the latest cui-http -snapshot. +`ForwardedResolverConfig`) exists only in `de.cuioss:cui-http:2.1` -- *it is absent from the +released 2.0.0 jar*. This plan therefore pins `cui-http:2.1`. [IMPORTANT] ==== Dependency changes -- *require explicit user approval before adding*: -* `de.cuioss:cui-http:2.1-SNAPSHOT` (security pipelines, forwarded resolver, HttpHandler - stack). Snapshot risk: CI must resolve it (Sonatype snapshots repository or a version bump - to a 2.1 release when available); pin and re-verify before merge. +* `de.cuioss:cui-http:2.1` (security pipelines, forwarded resolver, HttpHandler + stack). * `de.cuioss.sheriff.token:token-sheriff-validation-quarkus:1.0.0-SNAPSHOT` (direct; core `token-sheriff-validation` arrives transitively). Same snapshot caveat -- TokenSheriff is unreleased. @@ -186,7 +183,7 @@ when any bearer route exists, the `TokenValidator`'s issuer/JWKS loading status == Work Breakdown -. *Dependencies* (after approval): cui-http 2.1-SNAPSHOT, +. *Dependencies* (after approval): cui-http 2.1, token-sheriff-validation-quarkus 1.0.0-SNAPSHOT; verify CI snapshot resolution first. . *Event system + error edge* (D4) -- pure unit-tested base package first; problem+json rendering tests for every row of the error-contract table. diff --git a/doc/plan/README.adoc b/doc/plan/README.adoc index ed9e4ed..e4ca9e4 100644 --- a/doc/plan/README.adoc +++ b/doc/plan/README.adoc @@ -21,7 +21,7 @@ The plans build strictly on one another: integration setup, a proxied-route WRK benchmark, stale scaffolding removed. | -- -| link:02-configuration-management.adoc[02 -- Configuration Management] +| 02 -- Configuration Management (delivered) | JSON Schemas for `gateway.yaml` / `endpoints/*.yaml`, the loading/resolution/validation pipeline (topology indirection, fail-fast boot), immutable `GatewayConfig` + `RouteTable`, config mounted into the integration containers. @@ -30,7 +30,7 @@ The plans build strictly on one another: | link:03-request-pipeline.adoc[03 -- Request Pipeline] | The data plane for standard HTTP verbs: cui-http inbound filtering (basic + per-route), route selection, protocol-processor loading, offline bearer validation, zero-trust forward - policy with regenerated forwarding headers (cui-http 2.1-SNAPSHOT), dispatch with + policy with regenerated forwarding headers (cui-http:2.1), dispatch with retry/circuit breaker, plus the event system, metrics, logging, and RFC 9457 error edge. | Plans 01 + 02 |=== @@ -48,6 +48,6 @@ BFF variants (`token-sheriff-client`), rate limiting. not only at plan completion. * *Dependency approvals*: every new Maven dependency a plan needs is flagged inside the plan with an IMPORTANT block and requires explicit user approval before it is added - (CLAUDE.md rule). Snapshot dependencies (`cui-http:2.1-SNAPSHOT`, - `token-sheriff-*:1.0.0-SNAPSHOT`) additionally need a CI-resolvability check before merge. + (CLAUDE.md rule). The remaining snapshot dependency (`token-sheriff-*:1.0.0-SNAPSHOT`) + additionally needs a CI-resolvability check before merge. * Pre-1.0 rules apply throughout: delete instead of deprecate, break freely, keep APIs clean. diff --git a/integration-tests/docker-compose.benchmark.yml b/integration-tests/docker-compose.benchmark.yml index 0cdfc10..e4a9c69 100644 --- a/integration-tests/docker-compose.benchmark.yml +++ b/integration-tests/docker-compose.benchmark.yml @@ -15,7 +15,9 @@ services: api-sheriff: environment: - # Override the IT upstream (go-httpbin) with the static backend for benchmarks - - SHERIFF_PROXY_UPSTREAM_URL=http://nginx-static:8080 + # Override the UPSTREAM topology alias (go-httpbin) with the static backend + # for benchmarks. TOPOLOGY_ takes precedence over topology.properties, + # so the mounted config is reused unchanged and only the target is repointed. + - TOPOLOGY_UPSTREAM=http://nginx-static:8080 depends_on: - nginx-static diff --git a/integration-tests/docker-compose.yml b/integration-tests/docker-compose.yml index 2e36d1e..84ce1ae 100644 --- a/integration-tests/docker-compose.yml +++ b/integration-tests/docker-compose.yml @@ -67,9 +67,10 @@ services: - QUARKUS_HTTP_SSL_CERTIFICATE_KEY_FILES=/app/certificates/localhost.key - QUARKUS_TLS_DEFAULT_TRUST__STORE_P12_PATH=/app/certificates/localhost-truststore.p12 - QUARKUS_TLS_DEFAULT_TRUST__STORE_P12_PASSWORD=localhost-trust - # Interim proxy route -> go-httpbin echo backend (reached by service name) - - SHERIFF_PROXY_PATH_PREFIX=/proxy - - SHERIFF_PROXY_UPSTREAM_URL=http://go-httpbin:8080/anything + # File-based configuration mounted at /app/sheriff-config: the proxy edge + # sources its routes and upstreams from the assembled RouteTable, and the + # UPSTREAM topology alias resolves to the go-httpbin echo backend. + - SHERIFF_CONFIG_DIR=/app/sheriff-config depends_on: - keycloak @@ -78,6 +79,8 @@ services: volumes: # Read-only certificate mount (production pattern) - ./src/main/docker/certificates:/app/certificates:ro + # Read-only file-based gateway configuration mount (production pattern) + - ./src/main/docker/sheriff-config:/app/sheriff-config:ro # Mount target directory for log files (defaults to ./target) - ${LOG_TARGET_DIR:-./target}:/logs:rw diff --git a/integration-tests/scripts/verify-invalid-config-fails.sh b/integration-tests/scripts/verify-invalid-config-fails.sh new file mode 100755 index 0000000..ce4ae74 --- /dev/null +++ b/integration-tests/scripts/verify-invalid-config-fails.sh @@ -0,0 +1,72 @@ +#!/bin/bash +# Negative check: an invalid mounted configuration MUST make the api-sheriff +# container fail fast and exit non-zero, never serving on partial configuration. +# +# ConfigProducer validates the mounted gateway configuration at boot +# (StartupEvent). On any violation it logs structured ERROR records and throws, so +# Quarkus exits non-zero. This script mounts a deliberately invalid gateway.yaml +# (non-integer version + an unknown top-level key, both rejected by the D2 schema) +# and asserts the container exits with a non-zero code. + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +PROJECT_DIR="$(dirname "$SCRIPT_DIR")" + +IMAGE="api-sheriff:distroless" +CONTAINER_NAME="api-sheriff-invalid-config-check" +BOOT_TIMEOUT_SECONDS=60 +INVALID_CONFIG_DIR="" + +cleanup() { + docker rm -f "${CONTAINER_NAME}" >/dev/null 2>&1 || true + [[ -n "${INVALID_CONFIG_DIR}" ]] && rm -rf "${INVALID_CONFIG_DIR}" +} +trap cleanup EXIT + +if ! docker image inspect "${IMAGE}" >/dev/null 2>&1; then + echo "❌ Required image '${IMAGE}' not found." + echo " Build it first: ./mvnw verify -Pintegration-tests -pl integration-tests -am" + exit 1 +fi + +# Throwaway config dir whose gateway.yaml is invalid on two counts the schema +# rejects: version must be an integer, and unknown top-level keys are not allowed. +INVALID_CONFIG_DIR="$(mktemp -d)" +cat > "${INVALID_CONFIG_DIR}/gateway.yaml" <<'YAML' +version: "not-an-integer" +unknown_key: true +YAML + +echo "🚦 Starting '${CONTAINER_NAME}' with an invalid mounted configuration..." +docker rm -f "${CONTAINER_NAME}" >/dev/null 2>&1 || true +docker run -d --name "${CONTAINER_NAME}" \ + -e SHERIFF_CONFIG_DIR=/app/sheriff-config \ + -e QUARKUS_HTTP_SSL_CERTIFICATE_FILES=/app/certificates/localhost.crt \ + -e QUARKUS_HTTP_SSL_CERTIFICATE_KEY_FILES=/app/certificates/localhost.key \ + -v "${PROJECT_DIR}/src/main/docker/certificates:/app/certificates:ro" \ + -v "${INVALID_CONFIG_DIR}:/app/sheriff-config:ro" \ + "${IMAGE}" >/dev/null + +echo "⏳ Waiting up to ${BOOT_TIMEOUT_SECONDS}s for the container to exit..." +set +e +EXIT_CODE="$(timeout "${BOOT_TIMEOUT_SECONDS}" docker wait "${CONTAINER_NAME}")" +WAIT_STATUS=$? +set -e + +if [[ ${WAIT_STATUS} -ne 0 ]]; then + echo "❌ docker wait timed out — the container failed to exit and may be" + echo " serving despite the invalid configuration." + docker logs "${CONTAINER_NAME}" 2>&1 | tail -50 || true + exit 1 +fi + +echo "📄 Container exited with code ${EXIT_CODE}. Fail-fast markers (if any):" +docker logs "${CONTAINER_NAME}" 2>&1 | grep -E "Refusing to start|ApiSheriff-20[01]|configuration is invalid" || true + +if [[ "${EXIT_CODE}" == "0" ]]; then + echo "❌ Expected a non-zero exit for invalid configuration, but the container exited 0." + exit 1 +fi + +echo "✅ Invalid configuration correctly caused a fail-fast non-zero exit (${EXIT_CODE})." diff --git a/integration-tests/src/main/docker/sheriff-config/endpoints/httpbin.yaml b/integration-tests/src/main/docker/sheriff-config/endpoints/httpbin.yaml new file mode 100644 index 0000000..0642ae7 --- /dev/null +++ b/integration-tests/src/main/docker/sheriff-config/endpoints/httpbin.yaml @@ -0,0 +1,16 @@ +# The single proxied endpoint the integration suite exercises. Its /proxy route +# is the only route in the assembled RouteTable, so a request that echoes proves +# the gateway sourced its upstream from this mounted configuration (not from a +# removed static ProxyConfiguration). The UPSTREAM topology alias resolves to the +# go-httpbin echo backend (topology.properties), overridable per environment by +# TOPOLOGY_UPSTREAM (the benchmark overlay repoints it at nginx-static). +endpoint: + id: httpbin + enabled: true + base_url: UPSTREAM + auth: + require: none + routes: + - id: httpbin-proxy + match: + path_prefix: /proxy diff --git a/integration-tests/src/main/docker/sheriff-config/gateway.yaml b/integration-tests/src/main/docker/sheriff-config/gateway.yaml new file mode 100644 index 0000000..06e54b9 --- /dev/null +++ b/integration-tests/src/main/docker/sheriff-config/gateway.yaml @@ -0,0 +1,8 @@ +# Global gateway document mounted into the api-sheriff container at +# /app/sheriff-config (SHERIFF_CONFIG_DIR). Kept intentionally minimal: the proxy +# integration suite only needs the single httpbin endpoint's /proxy route, and +# require: none there means no token_validation / oidc block is required here. +version: 1 +metadata: + config_version: "integration-test" +allowed_methods: ["GET", "POST", "PUT", "DELETE"] diff --git a/integration-tests/src/main/docker/sheriff-config/topology.properties b/integration-tests/src/main/docker/sheriff-config/topology.properties new file mode 100644 index 0000000..66ea1d7 --- /dev/null +++ b/integration-tests/src/main/docker/sheriff-config/topology.properties @@ -0,0 +1,7 @@ +# Topology aliases for the mounted integration configuration. +# +# UPSTREAM is the go-httpbin echo backend, reached by the gateway on the internal +# compose network. Its /anything/* endpoint echoes the received request as JSON so +# the proxy ITs can assert exactly what was forwarded. The benchmark overlay +# overrides this at runtime with TOPOLOGY_UPSTREAM=http://nginx-static:8080. +UPSTREAM=http://go-httpbin:8080/anything diff --git a/integration-tests/src/test/java/de/cuioss/sheriff/api/integration/ConfigLoadedIntegrationIT.java b/integration-tests/src/test/java/de/cuioss/sheriff/api/integration/ConfigLoadedIntegrationIT.java new file mode 100644 index 0000000..92f229d --- /dev/null +++ b/integration-tests/src/test/java/de/cuioss/sheriff/api/integration/ConfigLoadedIntegrationIT.java @@ -0,0 +1,76 @@ +/* + * Copyright © 2022 CUI-OpenSource-Software (info@cuioss.de) + * + * 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 de.cuioss.sheriff.api.integration; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import static io.restassured.RestAssured.given; +import static org.hamcrest.Matchers.containsString; +import static org.hamcrest.Matchers.is; + +/** + * End-to-end proof that the gateway booted from the file-based configuration + * mounted into the container at {@code /app/sheriff-config} (the + * {@code SHERIFF_CONFIG_DIR} environment variable). + *

+ * The proxy edge no longer reads a static {@code ProxyConfiguration}: its routes + * and upstreams come from the {@code RouteTable} the configuration subsystem + * assembles at boot from the mounted {@code gateway.yaml}, + * {@code endpoints/httpbin.yaml} and {@code topology.properties}. The single + * mounted {@code /proxy} route being live — forwarding to the {@code go-httpbin} + * echo backend the {@code UPSTREAM} topology alias resolves to — is the observable + * proof the mounted configuration was loaded; any other path staying {@code 404} + * confirms deny-by-default over exactly the mounted route set. + */ +class ConfigLoadedIntegrationIT extends BaseIntegrationTest { + + @Test + @DisplayName("mounted-config /proxy route forwards to the resolved upstream") + void mountedConfigRouteForwardsToUpstream() { + given() + .when() + .get("/proxy/get?probe=config") + .then() + .statusCode(200) + .contentType(containsString("application/json")) + .body("method", is("GET")) + .body("url", containsString("/anything/get")) + .body("args.probe[0]", is("config")); + } + + @Test + @DisplayName("only the mounted route set is served — unmatched paths deny by default") + void unmountedPathDeniedByDefault() { + given() + .when() + .get("/no-such-route/resource") + .then() + .statusCode(404); + } + + @Test + @DisplayName("container boots healthy from the mounted configuration") + void managementHealthReportsUp() { + given() + .baseUri(managementBaseUri()) + .when() + .get("/q/health/live") + .then() + .statusCode(200) + .body("status", is("UP")); + } +} diff --git a/pom.xml b/pom.xml index d40e2ca..66a0b81 100644 --- a/pom.xml +++ b/pom.xml @@ -6,7 +6,7 @@ de.cuioss cui-java-parent - 1.5.0 + 1.5.1 de.cuioss.sheriff.api @@ -44,6 +44,7 @@ 25 3.37.2 0.8.0 + 1.5.9 @@ -71,6 +72,13 @@ pom import + + + com.networknt + json-schema-validator + ${version.json-schema-validator} + de.cuioss.sheriff.api