This is an automated email from the ASF dual-hosted git repository. zqr10159 pushed a commit to branch 2.0.0 in repository https://gitbox.apache.org/repos/asf/hertzbeat.git
commit 967ce7f574104b2cf8700c4716eca24b7cc0681d Author: Logic <[email protected]> AuthorDate: Sun Oct 11 17:37:58 2026 +0800 fix(security): restore reviewed label and config role fallbacks --- .../GeneralConfigRouteAuthorizationConfigTest.java | 58 +++++++++++--- .../config/LabelRouteAuthorizationConfigTest.java | 32 +++++++- .../manager/config/SurenessPolicyTestSupport.java | 92 ++++++++++++++++++++++ hertzbeat-startup/src/main/resources/sureness.yml | 8 ++ .../hertzbeat-mysql-iotdb/conf/sureness.yml | 8 ++ .../hertzbeat-mysql-tdengine/conf/sureness.yml | 8 ++ .../conf/sureness.yml | 8 ++ .../conf/sureness.yml | 8 ++ .../conf/sureness.yml | 8 ++ script/sureness.yml | 8 ++ 10 files changed, 224 insertions(+), 14 deletions(-) diff --git a/hertzbeat-manager/src/test/java/org/apache/hertzbeat/manager/config/GeneralConfigRouteAuthorizationConfigTest.java b/hertzbeat-manager/src/test/java/org/apache/hertzbeat/manager/config/GeneralConfigRouteAuthorizationConfigTest.java index 4eec7a12b5..8871941d0c 100644 --- a/hertzbeat-manager/src/test/java/org/apache/hertzbeat/manager/config/GeneralConfigRouteAuthorizationConfigTest.java +++ b/hertzbeat-manager/src/test/java/org/apache/hertzbeat/manager/config/GeneralConfigRouteAuthorizationConfigTest.java @@ -19,7 +19,6 @@ package org.apache.hertzbeat.manager.config; import static org.junit.jupiter.api.Assertions.assertArrayEquals; import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertFalse; import java.io.IOException; import java.nio.file.Files; @@ -29,10 +28,14 @@ import java.util.EnumMap; import java.util.EnumSet; import java.util.List; import java.util.Map; +import java.util.Set; +import java.util.stream.Stream; import org.apache.hertzbeat.common.constants.GeneralConfigTypeEnum; import org.apache.hertzbeat.manager.controller.GeneralConfigController; import org.apache.hertzbeat.manager.pojo.dto.TemplateConfig; import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.MethodSource; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.PutMapping; @@ -78,7 +81,7 @@ class GeneralConfigRouteAuthorizationConfigTest { } @Test - void shippedConfigsGiveEverySupportedTypeExactRolesAndStayInSync() throws IOException { + void shippedConfigsKeepEffectiveTypeRolesInSyncAndPreserveReleaseFallbacks() throws IOException { List<String> referenceRules = generalConfigRules(SURENESS_CONFIGS.get(0)); for (String config : SURENESS_CONFIGS) { List<String> lines = Files.readAllLines(repoRoot().resolve(config)); @@ -88,26 +91,57 @@ class GeneralConfigRouteAuthorizationConfigTest { String path = policy.getKey() == GeneralConfigTypeEnum.public_access ? "/api/config/public-access" : "/api/config/" + policy.getKey().name(); - assertExactRule(config, lines, path, "get", policy.getValue().getRoles()); - assertExactRule(config, lines, path, "post", policy.getValue().postRoles()); + assertOnlyExpectedRoles(config, lines, path, "get", policy.getValue().getRoles()); + assertOnlyExpectedRoles(config, lines, path, "post", policy.getValue().postRoles()); } - assertExactRule(config, lines, "/api/config/template/*", "put", ADMIN_ROLE); - assertFalse(lines.stream().anyMatch(line -> line.startsWith(" - /api/config/**===")), - () -> config + " must not use a broad general config wildcard"); + assertOnlyExpectedRoles(config, lines, "/api/config/template/*", "put", ADMIN_ROLE); + List<String> fallbacks = lines.stream().filter(line -> line.startsWith(" - /api/config/**===")) + .sorted().toList(); + List<String> expectedFallbacks = SurenessPolicyTestSupport.releaseProfile(config) ? List.of( + " - /api/config/**===delete===[admin]", " - /api/config/**===get===[admin,user,guest]", + " - /api/config/**===post===[admin]", " - /api/config/**===put===[admin]") : List.of(); + assertEquals(expectedFallbacks, fallbacks, () -> config + " must preserve its fallback profile"); } } - private static void assertExactRule( + private static void assertOnlyExpectedRoles( String config, List<String> lines, String path, String method, String roles) { String expected = " - " + path + "===" + method + "===" + roles; - long count = lines.stream().filter(expected::equals).count(); - assertEquals(1, count, () -> config + " must contain exactly one " + expected); + Set<String> matchingRules = lines.stream().filter(line -> line.startsWith(" - " + path + "===" + method + "===")) + .collect(java.util.stream.Collectors.toSet()); + // Identical duplicates have the same effective role policy; conflicting or missing rules do not. + assertEquals(Set.of(expected), matchingRules, () -> config + " must require only " + expected); } private static List<String> generalConfigRules(String config) throws IOException { return Files.readAllLines(repoRoot().resolve(config)).stream() - .filter(line -> line.startsWith(" - /api/config/")) - .toList(); + .filter(line -> line.startsWith(" - /api/config/") && !line.startsWith(" - /api/config/**===")) + .distinct().sorted().toList(); + } + + @ParameterizedTest + @MethodSource("configurations") + void realMatcherEnforcesExactTypeRolesBeforeFallbackAndKeepsUnmatchedSemantics(String config) throws Exception { + var matcher = SurenessPolicyTestSupport.matcher(config); + for (Map.Entry<GeneralConfigTypeEnum, RolePolicy> policy : ROLE_POLICIES.entrySet()) { + String path = policy.getKey() == GeneralConfigTypeEnum.public_access + ? "/api/config/public-access" : "/api/config/" + policy.getKey().name(); + SurenessPolicyTestSupport.assertPolicy(matcher, config, path, "get", policy.getValue().getRoles()); + SurenessPolicyTestSupport.assertPolicy(matcher, config, path, "post", policy.getValue().postRoles()); + } + SurenessPolicyTestSupport.assertPolicy(matcher, config, "/api/config/template/linux", "put", ADMIN_ROLE); + SurenessPolicyTestSupport.assertPolicy(matcher, config, "/api/config/timezones", "get", ALL_ROLES); + boolean release = SurenessPolicyTestSupport.releaseProfile(config); + SurenessPolicyTestSupport.assertPolicy(matcher, config, "/api/config/unlisted", "get", release ? ALL_ROLES : null); + for (String method : List.of("post", "put", "delete")) { + SurenessPolicyTestSupport.assertPolicy(matcher, config, "/api/config/unlisted", method, release ? ADMIN_ROLE : null); + } + // Null means no role requirement, not deny-all; retain this warning for genuinely unmatched paths. + SurenessPolicyTestSupport.assertPolicy(matcher, config, "/audit/unmatched", "post", null); + } + + private static Stream<String> configurations() { + return SURENESS_CONFIGS.stream(); } private static Map<GeneralConfigTypeEnum, RolePolicy> rolePolicies() { diff --git a/hertzbeat-manager/src/test/java/org/apache/hertzbeat/manager/config/LabelRouteAuthorizationConfigTest.java b/hertzbeat-manager/src/test/java/org/apache/hertzbeat/manager/config/LabelRouteAuthorizationConfigTest.java index 0fed84be03..2be5d38662 100644 --- a/hertzbeat-manager/src/test/java/org/apache/hertzbeat/manager/config/LabelRouteAuthorizationConfigTest.java +++ b/hertzbeat-manager/src/test/java/org/apache/hertzbeat/manager/config/LabelRouteAuthorizationConfigTest.java @@ -26,7 +26,10 @@ import java.nio.file.Path; import java.nio.file.Paths; import java.util.ArrayList; import java.util.List; +import java.util.stream.Stream; import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.MethodSource; import org.junit.jupiter.api.function.Executable; /** @@ -63,8 +66,33 @@ class LabelRouteAuthorizationConfigTest { List<String> actualRules = Files.readAllLines(repoRoot().resolve(config)).stream() .filter(line -> line.startsWith(" - /api/label") || line.startsWith(" - /api/tag/")) .toList(); - assertEquals(LABEL_RULES, actualRules, - () -> config + " must contain only the reviewed Label route policy"); + List<String> expected = new ArrayList<>(LABEL_RULES); + if (SurenessPolicyTestSupport.releaseProfile(config)) { + expected.addAll(List.of(" - /api/label/**===get===[admin,user,guest]", + " - /api/label/**===post===[admin,user]", " - /api/label/**===put===[admin,user]", + " - /api/label/**===delete===[admin]")); + } + assertEquals(expected, actualRules, () -> config + " must preserve exact roots and its fallback profile"); + } + + @ParameterizedTest + @MethodSource("configurations") + void realMatcherKeepsRootRolesAndAuthenticatedChildFallback(String config) throws Exception { + var matcher = SurenessPolicyTestSupport.matcher(config); + for (String method : List.of("get", "post", "put", "delete")) { + String roles = switch (method) { + case "get" -> "[admin,user,guest]"; + case "post", "put" -> "[admin,user]"; + default -> "[admin]"; + }; + SurenessPolicyTestSupport.assertPolicy(matcher, config, "/api/label", method, roles); + SurenessPolicyTestSupport.assertPolicy(matcher, config, "/api/label/child", method, + SurenessPolicyTestSupport.releaseProfile(config) ? roles : null); + } + } + + private static Stream<String> configurations() { + return SURENESS_CONFIGS.stream(); } private static Path repoRoot() { diff --git a/hertzbeat-manager/src/test/java/org/apache/hertzbeat/manager/config/SurenessPolicyTestSupport.java b/hertzbeat-manager/src/test/java/org/apache/hertzbeat/manager/config/SurenessPolicyTestSupport.java new file mode 100644 index 0000000000..73fca342d3 --- /dev/null +++ b/hertzbeat-manager/src/test/java/org/apache/hertzbeat/manager/config/SurenessPolicyTestSupport.java @@ -0,0 +1,92 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.apache.hertzbeat.manager.config; + +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +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 com.usthe.sureness.matcher.DefaultPathRoleMatcher; +import com.usthe.sureness.matcher.PathTreeProvider; +import com.usthe.sureness.processor.exception.UnauthorizedException; +import com.usthe.sureness.processor.support.PasswordProcessor; +import com.usthe.sureness.subject.support.PasswordSubject; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Arrays; +import java.util.List; +import java.util.Map; +import java.util.Set; +import org.yaml.snakeyaml.Yaml; + +/** Real policy matching and authorization only; subjects assume authentication with no credentials. */ +final class SurenessPolicyTestSupport { + private SurenessPolicyTestSupport() { + } + + static boolean releaseProfile(String config) { + return config.startsWith("script/") || config.equals("hertzbeat-startup/src/main/resources/sureness.yml"); + } + + @SuppressWarnings("unchecked") + static DefaultPathRoleMatcher matcher(String config) throws Exception { + Path root = Path.of("").toAbsolutePath(); + while (root != null && !Files.isRegularFile(root.resolve("mvnw"))) { + root = root.getParent(); + } + if (root == null) { + throw new IllegalStateException("Cannot locate repository root"); + } + Map<String, Object> policy = new Yaml().load(Files.readString(root.resolve(config))); + DefaultPathRoleMatcher matcher = new DefaultPathRoleMatcher(); + matcher.setPathTreeProvider(new PathTreeProvider() { + @Override + public Set<String> providePathData() { + return Set.copyOf((List<String>) policy.get("resourceRole")); + } + + @Override + public Set<String> provideExcludedResource() { + return Set.copyOf((List<String>) policy.get("excludedResource")); + } + }); + matcher.buildTree(); + return matcher; + } + + static void assertPolicy(DefaultPathRoleMatcher matcher, String config, + String path, String method, String expectedRoles) { + List<String> expected = expectedRoles == null ? null + : Arrays.asList(expectedRoles.substring(1, expectedRoles.length() - 1).split(",")); + String resource = path + "===" + method; + for (String role : List.of("admin", "user", "guest", "unrecognized")) { + PasswordSubject subject = PasswordSubject.builder("already-authenticated-fixture", null) + .setTargetResource(resource).setOwnRoles(List.of(role)).build(); + String context = config + " " + resource + " role=" + role; + assertFalse(matcher.isExcludedResource(subject), context); + matcher.matchRole(subject); + assertEquals(expected, subject.getSupportRoles(), context); + if (expected == null || expected.contains(role)) { + assertDoesNotThrow(() -> new PasswordProcessor().authorized(subject), context); + } else { + assertThrows(UnauthorizedException.class, () -> new PasswordProcessor().authorized(subject), context); + } + } + } +} diff --git a/hertzbeat-startup/src/main/resources/sureness.yml b/hertzbeat-startup/src/main/resources/sureness.yml index ca3953169a..023cf7fcc0 100644 --- a/hertzbeat-startup/src/main/resources/sureness.yml +++ b/hertzbeat-startup/src/main/resources/sureness.yml @@ -137,12 +137,20 @@ resourceRole: - /api/plugin/**===post===[admin] - /api/plugin/**===put===[admin] - /api/plugin/**===delete===[admin] + - /api/config/**===get===[admin,user,guest] + - /api/config/**===post===[admin] + - /api/config/**===put===[admin] + - /api/config/**===delete===[admin] # queue depth of the hertzbeat process itself, operational data - /api/metrics===get===[admin] # per account metric favourites rendered on the monitor pages - /api/metrics/**===get===[admin,user,guest] - /api/metrics/**===post===[admin,user,guest] - /api/metrics/**===delete===[admin,user,guest] + - /api/label/**===get===[admin,user,guest] + - /api/label/**===post===[admin,user] + - /api/label/**===put===[admin,user] + - /api/label/**===delete===[admin] # the storage availability probe is read by every monitor page, while the query # route forwards a raw promql expression straight to the time series database - /api/warehouse/**===get===[admin,user,guest] diff --git a/script/docker-compose/hertzbeat-mysql-iotdb/conf/sureness.yml b/script/docker-compose/hertzbeat-mysql-iotdb/conf/sureness.yml index ca3953169a..023cf7fcc0 100644 --- a/script/docker-compose/hertzbeat-mysql-iotdb/conf/sureness.yml +++ b/script/docker-compose/hertzbeat-mysql-iotdb/conf/sureness.yml @@ -137,12 +137,20 @@ resourceRole: - /api/plugin/**===post===[admin] - /api/plugin/**===put===[admin] - /api/plugin/**===delete===[admin] + - /api/config/**===get===[admin,user,guest] + - /api/config/**===post===[admin] + - /api/config/**===put===[admin] + - /api/config/**===delete===[admin] # queue depth of the hertzbeat process itself, operational data - /api/metrics===get===[admin] # per account metric favourites rendered on the monitor pages - /api/metrics/**===get===[admin,user,guest] - /api/metrics/**===post===[admin,user,guest] - /api/metrics/**===delete===[admin,user,guest] + - /api/label/**===get===[admin,user,guest] + - /api/label/**===post===[admin,user] + - /api/label/**===put===[admin,user] + - /api/label/**===delete===[admin] # the storage availability probe is read by every monitor page, while the query # route forwards a raw promql expression straight to the time series database - /api/warehouse/**===get===[admin,user,guest] diff --git a/script/docker-compose/hertzbeat-mysql-tdengine/conf/sureness.yml b/script/docker-compose/hertzbeat-mysql-tdengine/conf/sureness.yml index ca3953169a..023cf7fcc0 100644 --- a/script/docker-compose/hertzbeat-mysql-tdengine/conf/sureness.yml +++ b/script/docker-compose/hertzbeat-mysql-tdengine/conf/sureness.yml @@ -137,12 +137,20 @@ resourceRole: - /api/plugin/**===post===[admin] - /api/plugin/**===put===[admin] - /api/plugin/**===delete===[admin] + - /api/config/**===get===[admin,user,guest] + - /api/config/**===post===[admin] + - /api/config/**===put===[admin] + - /api/config/**===delete===[admin] # queue depth of the hertzbeat process itself, operational data - /api/metrics===get===[admin] # per account metric favourites rendered on the monitor pages - /api/metrics/**===get===[admin,user,guest] - /api/metrics/**===post===[admin,user,guest] - /api/metrics/**===delete===[admin,user,guest] + - /api/label/**===get===[admin,user,guest] + - /api/label/**===post===[admin,user] + - /api/label/**===put===[admin,user] + - /api/label/**===delete===[admin] # the storage availability probe is read by every monitor page, while the query # route forwards a raw promql expression straight to the time series database - /api/warehouse/**===get===[admin,user,guest] diff --git a/script/docker-compose/hertzbeat-mysql-victoria-metrics/conf/sureness.yml b/script/docker-compose/hertzbeat-mysql-victoria-metrics/conf/sureness.yml index ca3953169a..023cf7fcc0 100644 --- a/script/docker-compose/hertzbeat-mysql-victoria-metrics/conf/sureness.yml +++ b/script/docker-compose/hertzbeat-mysql-victoria-metrics/conf/sureness.yml @@ -137,12 +137,20 @@ resourceRole: - /api/plugin/**===post===[admin] - /api/plugin/**===put===[admin] - /api/plugin/**===delete===[admin] + - /api/config/**===get===[admin,user,guest] + - /api/config/**===post===[admin] + - /api/config/**===put===[admin] + - /api/config/**===delete===[admin] # queue depth of the hertzbeat process itself, operational data - /api/metrics===get===[admin] # per account metric favourites rendered on the monitor pages - /api/metrics/**===get===[admin,user,guest] - /api/metrics/**===post===[admin,user,guest] - /api/metrics/**===delete===[admin,user,guest] + - /api/label/**===get===[admin,user,guest] + - /api/label/**===post===[admin,user] + - /api/label/**===put===[admin,user] + - /api/label/**===delete===[admin] # the storage availability probe is read by every monitor page, while the query # route forwards a raw promql expression straight to the time series database - /api/warehouse/**===get===[admin,user,guest] diff --git a/script/docker-compose/hertzbeat-postgresql-greptimedb/conf/sureness.yml b/script/docker-compose/hertzbeat-postgresql-greptimedb/conf/sureness.yml index ca3953169a..023cf7fcc0 100644 --- a/script/docker-compose/hertzbeat-postgresql-greptimedb/conf/sureness.yml +++ b/script/docker-compose/hertzbeat-postgresql-greptimedb/conf/sureness.yml @@ -137,12 +137,20 @@ resourceRole: - /api/plugin/**===post===[admin] - /api/plugin/**===put===[admin] - /api/plugin/**===delete===[admin] + - /api/config/**===get===[admin,user,guest] + - /api/config/**===post===[admin] + - /api/config/**===put===[admin] + - /api/config/**===delete===[admin] # queue depth of the hertzbeat process itself, operational data - /api/metrics===get===[admin] # per account metric favourites rendered on the monitor pages - /api/metrics/**===get===[admin,user,guest] - /api/metrics/**===post===[admin,user,guest] - /api/metrics/**===delete===[admin,user,guest] + - /api/label/**===get===[admin,user,guest] + - /api/label/**===post===[admin,user] + - /api/label/**===put===[admin,user] + - /api/label/**===delete===[admin] # the storage availability probe is read by every monitor page, while the query # route forwards a raw promql expression straight to the time series database - /api/warehouse/**===get===[admin,user,guest] diff --git a/script/docker-compose/hertzbeat-postgresql-victoria-metrics/conf/sureness.yml b/script/docker-compose/hertzbeat-postgresql-victoria-metrics/conf/sureness.yml index ca3953169a..023cf7fcc0 100644 --- a/script/docker-compose/hertzbeat-postgresql-victoria-metrics/conf/sureness.yml +++ b/script/docker-compose/hertzbeat-postgresql-victoria-metrics/conf/sureness.yml @@ -137,12 +137,20 @@ resourceRole: - /api/plugin/**===post===[admin] - /api/plugin/**===put===[admin] - /api/plugin/**===delete===[admin] + - /api/config/**===get===[admin,user,guest] + - /api/config/**===post===[admin] + - /api/config/**===put===[admin] + - /api/config/**===delete===[admin] # queue depth of the hertzbeat process itself, operational data - /api/metrics===get===[admin] # per account metric favourites rendered on the monitor pages - /api/metrics/**===get===[admin,user,guest] - /api/metrics/**===post===[admin,user,guest] - /api/metrics/**===delete===[admin,user,guest] + - /api/label/**===get===[admin,user,guest] + - /api/label/**===post===[admin,user] + - /api/label/**===put===[admin,user] + - /api/label/**===delete===[admin] # the storage availability probe is read by every monitor page, while the query # route forwards a raw promql expression straight to the time series database - /api/warehouse/**===get===[admin,user,guest] diff --git a/script/sureness.yml b/script/sureness.yml index ca3953169a..023cf7fcc0 100644 --- a/script/sureness.yml +++ b/script/sureness.yml @@ -137,12 +137,20 @@ resourceRole: - /api/plugin/**===post===[admin] - /api/plugin/**===put===[admin] - /api/plugin/**===delete===[admin] + - /api/config/**===get===[admin,user,guest] + - /api/config/**===post===[admin] + - /api/config/**===put===[admin] + - /api/config/**===delete===[admin] # queue depth of the hertzbeat process itself, operational data - /api/metrics===get===[admin] # per account metric favourites rendered on the monitor pages - /api/metrics/**===get===[admin,user,guest] - /api/metrics/**===post===[admin,user,guest] - /api/metrics/**===delete===[admin,user,guest] + - /api/label/**===get===[admin,user,guest] + - /api/label/**===post===[admin,user] + - /api/label/**===put===[admin,user] + - /api/label/**===delete===[admin] # the storage availability probe is read by every monitor page, while the query # route forwards a raw promql expression straight to the time series database - /api/warehouse/**===get===[admin,user,guest] --------------------------------------------------------------------- To unsubscribe, e-mail: [email protected] For additional commands, e-mail: [email protected]
