diff --git a/core-maven-plugins/httproutes-generator-maven-plugin/README.md b/core-maven-plugins/httproutes-generator-maven-plugin/README.md index 6710a6dd46..b136674684 100644 --- a/core-maven-plugins/httproutes-generator-maven-plugin/README.md +++ b/core-maven-plugins/httproutes-generator-maven-plugin/README.md @@ -2,14 +2,20 @@ This module contains `com.netcracker.cloud.plugins:httproutes-generator-maven-plugin`. It scans compiled Java classes and generates Gateway API `HTTPRoute` manifests -for Istio deployments. +and Istio `AuthorizationPolicy` manifests for Istio deployments. ## What It Does - Scans Spring MVC and Quarkus/JAX-RS endpoints from compiled classes. -- Includes only classes/methods annotated with `@Route`. -- Supports gateway path remapping via `@Gateway` and `@GatewayRequestMapping`. -- Groups generated routes by route type (`INTERNAL`, `PRIVATE`, `PUBLIC`, `FACADE`). +- Includes only classes/methods annotated with `@Route`, `@Routes`, `@FacadeRoute` or `@ForbiddenRoute`. +- Supports gateway path remapping via `@Gateway` and `@GatewayRequestMapping`, and facade gateway paths via + `@FacadeGateway` and `@FacadeGatewayRequestMapping`. +- Generates only `PathPrefix` path matches (see [How Paths Are Matched](#how-paths-are-matched)). +- Groups generated routes by route type (`PUBLIC`, `PRIVATE`, `INTERNAL`, `FACADE`). +- Generates `AuthorizationPolicy` resources with `DENY` rules for paths that must not be reachable + through a gateway (see [Forbidden Routes and AuthorizationPolicies](#forbidden-routes-and-authorizationpolicies)). +- Fails the build before writing anything when the generated routing can't keep the legacy behavior + (see [Route Errors](#route-errors)). - Emits Helm-ready YAML wrapped in: `{{- if eq .Values.SERVICE_MESH_TYPE "Istio" }}`. - Runs as an **aggregator** mojo, so it collects routes across reactor modules. @@ -31,7 +37,7 @@ Add the plugin to your `pom.xml`: com.netcracker.cloud.plugins httproutes-generator-maven-plugin - 1.0.0 + 1.0.0 @@ -45,7 +51,7 @@ Add the plugin to your `pom.xml`: 8080 helm-templates/my-service/templates/annotations-httproutes.yaml - {{ .Values.DEPLOYMENT_RESOURCE_NAME }} + {{ .Values.SERVICE_NAME }} + + org.jacoco + jacoco-maven-plugin + 0.8.15 + + + + prepare-agent + + + + report + verify + + report + + + + diff --git a/core-maven-plugins/httproutes-generator-maven-plugin/src/main/java/com/netcracker/routes/gateway/plugin/AuthorizationPolicyRenderer.java b/core-maven-plugins/httproutes-generator-maven-plugin/src/main/java/com/netcracker/routes/gateway/plugin/AuthorizationPolicyRenderer.java new file mode 100644 index 0000000000..1e08fa6f3f --- /dev/null +++ b/core-maven-plugins/httproutes-generator-maven-plugin/src/main/java/com/netcracker/routes/gateway/plugin/AuthorizationPolicyRenderer.java @@ -0,0 +1,193 @@ +package com.netcracker.routes.gateway.plugin; + +import com.fasterxml.jackson.annotation.JsonInclude; + +import java.util.*; +import java.util.stream.Collectors; +import java.util.stream.Stream; + +/** + * Renders one DENY {@code AuthorizationPolicy} per external gateway, public then private. Exposure on the internal + * gateway needs no rule, like that of facade routes. + *

+ * Legacy gateways forbid a route on the gateways wider than its type, and don't route a path no route matches. + * Istio routes by the {@code PathPrefix} cut from each gateway path, so it routes some of these paths: + *

+ * These paths need DENY rules. They are generated if {@code autoGenerateAuthorizationPolicies} is set, else they must + * be declared with {@code @ForbiddenRoute}, as all other forbidden paths. + */ +public class AuthorizationPolicyRenderer { + + private static final List PORTS = List.of("8080"); + private static final String ACTION_DENY = "DENY"; + private static final List GATEWAYS = + List.of(HttpRoute.Type.PUBLIC, HttpRoute.Type.PRIVATE); + + private final Map labels; + private final boolean autoGenerateAuthorizationPolicies; + + public AuthorizationPolicyRenderer(Map labels, boolean autoGenerateAuthorizationPolicies) { + this.labels = labels == null ? Collections.emptyMap() : labels; + this.autoGenerateAuthorizationPolicies = autoGenerateAuthorizationPolicies; + } + + @JsonInclude(JsonInclude.Include.NON_NULL) + public record AuthorizationPolicyResource(String apiVersion, String kind, Metadata metadata, Spec spec) { + + public AuthorizationPolicyResource(Metadata metadata, Spec spec) { + this("security.istio.io/v1", "AuthorizationPolicy", metadata, spec); + } + + public record Metadata(String name, Map labels) { + } + + public record Spec(List targetRefs, String action, List rules) { + + public record TargetRef(String group, String kind, String name) { + } + + public record Rule(List to) { + + public record To(Operation operation) { + } + + @JsonInclude(JsonInclude.Include.NON_EMPTY) + public record Operation(List ports, List paths, List notPaths) { + } + } + } + } + + /** + * A path forbidden on a gateway that Istio would route without a DENY rule. + */ + private record Missing(String reason, Set gateways) { + } + + public String generateAuthorizationPoliciesYaml(Set routes, Set forbidden, Problems problems) { + List border = routes.stream().filter(r -> r.type() != HttpRoute.Type.FACADE).toList(); + Map missing = new TreeMap<>(); + Set inexpressible = new TreeSet<>(); + StringBuilder yaml = new StringBuilder(); + for (HttpRoute.Type gateway : GATEWAYS) { + Collection rules = + denyRules(gateway, border, forbidden, missing, inexpressible, problems); + if (!rules.isEmpty()) { + yaml.append(HttpRouteRenderer.writeYaml(toResource(gateway, List.copyOf(rules)))); + } + } + missing.forEach((path, m) -> problems.error(path + " " + m.reason() + " on " + gatewayNames(m.gateways()) + + ": add @ForbiddenRoute({" + m.gateways().stream().sorted(Comparator.reverseOrder()).map(Enum::name) + .collect(Collectors.joining(", ")) + "}) to the element mapped to " + path)); + inexpressible.forEach(path -> problems.error(path + " is a forbidden path or overlaps one, and an " + + "AuthorizationPolicy can't express it: variables must take up whole path segments")); + return yaml.toString(); + } + + private Collection denyRules(HttpRoute.Type gateway, List border, + Set forbidden, + Map missing, + Set inexpressible, Problems problems) { + Set allowed = border.stream().filter(r -> r.type().compareTo(gateway) >= 0) + .map(HttpRoute::gatewayPath).collect(Collectors.toCollection(TreeSet::new)); + Set allowedTemplates = allowed.stream().map(RoutePaths::template).collect(Collectors.toSet()); + Set implicit = border.stream().filter(r -> r.type().compareTo(gateway) < 0) + .map(HttpRoute::gatewayPath).filter(p -> !allowedTemplates.contains(RoutePaths.template(p))) + .collect(Collectors.toCollection(TreeSet::new)); + Set explicit = forbidden.stream().filter(f -> f.gateways().contains(gateway)) + .map(ForbiddenPath::gatewayPath).collect(Collectors.toCollection(TreeSet::new)); + explicit.stream().filter(p -> allowedTemplates.contains(RoutePaths.template(p))).forEach(p -> problems.error( + "@ForbiddenRoute forbids " + p + " on " + gateway.gatewayName() + ", where a route with this gateway path is exposed")); + + Map needed = new TreeMap<>(); + Set prefixes = allowed.stream().map(RoutePaths::cut).collect(Collectors.toCollection(TreeSet::new)); + for (String path : implicit) { + if (legacyRoute(RoutePaths.sample(path, path), allowed, implicit).filter(allowed::contains).isPresent()) { + continue; + } + prefixes.stream().filter(prefix -> RoutePaths.covers(prefix, path)).findFirst().ifPresent(prefix -> needed.put(path, + "is forbidden by legacy, as its route type is narrower, but Istio routes it by PathPrefix " + prefix)); + } + for (String path : allowed) { + String prefix = RoutePaths.cut(path); + if (!RoutePaths.hasVariable(path)) { + continue; + } + if (legacyRoute(prefix, allowed, implicit).filter(allowed::contains).isEmpty()) { + needed.putIfAbsent(prefix, + "is not routed by legacy, but Istio routes it by PathPrefix " + prefix + " cut from " + path); + } + } + + Set explicitTemplates = explicit.stream().map(RoutePaths::template).collect(Collectors.toSet()); + Set rulePaths = new TreeSet<>(explicit); + needed.forEach((path, reason) -> { + if (autoGenerateAuthorizationPolicies) { + rulePaths.add(path); + } else if (!explicitTemplates.contains(RoutePaths.template(path))) { + missing.computeIfAbsent(path, p -> new Missing(reason, EnumSet.noneOf(HttpRoute.Type.class))) + .gateways().add(gateway); + } + }); + + Map rules = new TreeMap<>(); + for (String path : rulePaths) { + List overlapping = allowed.stream() + .filter(route -> route.length() > path.length() && RoutePaths.overlaps(path, route)).toList(); + // Probes the rule path and its intersection with every overlapping allowed route: the route legacy routes + // a probe by, if the rule denies the probe, is excluded too, so that the rule denies nothing legacy routed + Set excluded = new TreeSet<>(overlapping); + Stream.concat(Stream.of(path), allowed.stream().filter(route -> RoutePaths.overlaps(path, route))) + .map(route -> RoutePaths.sample(path, route)) + .forEach(request -> legacyRoute(request, allowed, implicit).filter(allowed::contains) + .ifPresent(excluded::add)); + if (explicit.contains(path)) { + // An explicit rule denies its path even where legacy routed it by a wider route + excluded.removeIf(route -> RoutePaths.covers(route, RoutePaths.sample(path, path))); + } + List invalid = Stream.concat(Stream.of(path), excluded.stream()) + .filter(p -> !RoutePaths.expressible(p)).toList(); + if (!invalid.isEmpty()) { + inexpressible.addAll(invalid); + continue; + } + List notPaths = excluded.stream().map(RoutePaths::template).distinct().sorted() + .flatMap(t -> subtree(t).stream()).toList(); + rules.putIfAbsent(RoutePaths.template(path), new AuthorizationPolicyResource.Spec.Rule(List.of( + new AuthorizationPolicyResource.Spec.Rule.To(new AuthorizationPolicyResource.Spec.Rule.Operation( + PORTS, subtree(RoutePaths.template(path)), notPaths))))); + } + return rules.values(); + } + + /** + * @return the route legacy matches a literal request path by: the longest covering one, the forbidden one on a tie + */ + private static Optional legacyRoute(String request, Set allowed, Set implicit) { + return Stream.concat(allowed.stream(), implicit.stream()).filter(route -> RoutePaths.covers(route, request)) + .max(Comparator.comparingInt(String::length).thenComparing(route -> !allowed.contains(route))); + } + + private static List subtree(String template) { + return List.of(template, template.equals("/") ? "/{**}" : template + "/{**}"); + } + + private static String gatewayNames(Set gateways) { + return gateways.stream().sorted(Comparator.reverseOrder()).map(HttpRoute.Type::gatewayName) + .collect(Collectors.joining(", ")); + } + + private AuthorizationPolicyResource toResource(HttpRoute.Type gateway, List rules) { + AuthorizationPolicyResource.Metadata metadata = new AuthorizationPolicyResource.Metadata( + "{{ .Values.SERVICE_NAME }}-java-annotations-deny-" + gateway.name().toLowerCase(), + HttpRouteRenderer.buildRouteLabels(labels)); + HttpRouteRenderer.HTTPRouteResource.Spec.ParentRef ref = HttpRouteRenderer.gatewayParentRef(gateway.gatewayName()); + AuthorizationPolicyResource.Spec.TargetRef targetRef = + new AuthorizationPolicyResource.Spec.TargetRef(ref.group(), ref.kind(), ref.name()); + return new AuthorizationPolicyResource(metadata, + new AuthorizationPolicyResource.Spec(List.of(targetRef), ACTION_DENY, rules)); + } +} diff --git a/core-maven-plugins/httproutes-generator-maven-plugin/src/main/java/com/netcracker/routes/gateway/plugin/ForbiddenPath.java b/core-maven-plugins/httproutes-generator-maven-plugin/src/main/java/com/netcracker/routes/gateway/plugin/ForbiddenPath.java new file mode 100644 index 0000000000..3fff4b3a14 --- /dev/null +++ b/core-maven-plugins/httproutes-generator-maven-plugin/src/main/java/com/netcracker/routes/gateway/plugin/ForbiddenPath.java @@ -0,0 +1,14 @@ +package com.netcracker.routes.gateway.plugin; + +import java.util.Set; + +/** + * A gateway path that {@code @ForbiddenRoute} forbids on the given border gateways. + */ +public record ForbiddenPath(String gatewayPath, Set gateways) { + + public ForbiddenPath { + gatewayPath = gatewayPath.startsWith("/") ? gatewayPath : "/" + gatewayPath; + gateways = Set.copyOf(gateways); + } +} diff --git a/core-maven-plugins/httproutes-generator-maven-plugin/src/main/java/com/netcracker/routes/gateway/plugin/GenerateRoutesMojo.java b/core-maven-plugins/httproutes-generator-maven-plugin/src/main/java/com/netcracker/routes/gateway/plugin/GenerateRoutesMojo.java index a85fa7299f..26a625baec 100644 --- a/core-maven-plugins/httproutes-generator-maven-plugin/src/main/java/com/netcracker/routes/gateway/plugin/GenerateRoutesMojo.java +++ b/core-maven-plugins/httproutes-generator-maven-plugin/src/main/java/com/netcracker/routes/gateway/plugin/GenerateRoutesMojo.java @@ -2,16 +2,17 @@ import org.apache.maven.plugin.AbstractMojo; import org.apache.maven.plugin.MojoExecutionException; +import org.apache.maven.plugin.MojoFailureException; import org.apache.maven.plugins.annotations.LifecyclePhase; import org.apache.maven.plugins.annotations.Mojo; import org.apache.maven.plugins.annotations.Parameter; import org.apache.maven.project.MavenProject; import java.nio.file.Files; +import java.nio.file.Path; import java.util.Collections; import java.util.List; import java.util.Map; -import java.util.Set; import java.util.stream.Collectors; @Mojo( @@ -36,17 +37,45 @@ public class GenerateRoutesMojo extends AbstractMojo { @Parameter(defaultValue = "gateway-httproutes.yaml") private String outputFile; - @Parameter(defaultValue = "{{ .Values.DEPLOYMENT_RESOURCE_NAME }}") + @Parameter(defaultValue = "{{ .Values.SERVICE_NAME }}") private String backendRefVal; @Parameter private List