This is an automated email from the ASF dual-hosted git repository.

zqr10159 pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/hertzbeat.git


The following commit(s) were added to refs/heads/master by this push:
     new 7a8156a2d0 [fix] validate sql reaching the alert query executor (#4275)
7a8156a2d0 is described below

commit 7a8156a2d0d5937e19298349af1ee3f74e2aa8f6
Author: Duansg <[email protected]>
AuthorDate: Mon Aug 24 18:48:50 2026 +0800

    [fix] validate sql reaching the alert query executor (#4275)
    
    Signed-off-by: Duansg <[email protected]>
    Co-authored-by: Claude Opus 5 (1M context) <[email protected]>
    Co-authored-by: Logic <[email protected]>
---
 .../alert/expr/AlertExpressionEvalVisitor.java     |  14 +-
 .../service/impl/AlertQueryBudgetExecutor.java     | 135 +++++++
 .../alert/service/impl/DataSourceServiceImpl.java  |  50 +--
 .../service/impl/SqlValidatingQueryExecutor.java   |  83 +++++
 .../alert/expr/AlertExpressionEvalVisitorTest.java |  47 ++-
 .../alert/service/DataSourceServiceTest.java       | 107 ++++++
 .../common/support/valid/SqlSecurityValidator.java | 403 ++++++++++++++++++++-
 .../support/valid/SqlSecurityValidatorTest.java    | 232 ++++++++++++
 hertzbeat-startup/src/main/resources/sureness.yml  |   6 +
 .../startup/security/SurenessResourceRuleTest.java |  48 +++
 .../hertzbeat-mysql-iotdb/conf/sureness.yml        |   6 +
 .../hertzbeat-mysql-tdengine/conf/sureness.yml     |   6 +
 .../conf/sureness.yml                              |   6 +
 .../conf/sureness.yml                              |   6 +
 .../conf/sureness.yml                              |   6 +
 script/sureness.yml                                |   6 +
 16 files changed, 1124 insertions(+), 37 deletions(-)

diff --git 
a/hertzbeat-alerter/src/main/java/org/apache/hertzbeat/alert/expr/AlertExpressionEvalVisitor.java
 
b/hertzbeat-alerter/src/main/java/org/apache/hertzbeat/alert/expr/AlertExpressionEvalVisitor.java
index bc84566083..1775ce6c46 100644
--- 
a/hertzbeat-alerter/src/main/java/org/apache/hertzbeat/alert/expr/AlertExpressionEvalVisitor.java
+++ 
b/hertzbeat-alerter/src/main/java/org/apache/hertzbeat/alert/expr/AlertExpressionEvalVisitor.java
@@ -39,6 +39,11 @@ public class AlertExpressionEvalVisitor extends 
AlertExpressionBaseVisitor<List<
     private static final String VALUE = "__value__";
     private static final String TIMESTAMP = "__timestamp__";
 
+    /**
+     * Every statement this visitor evaluates goes to this executor, so 
whether a statement is
+     * allowed to run is decided there rather than at each visit method, see
+     * {@code DataSourceServiceImpl}.
+     */
     private final QueryExecutor executor;
     private final CommonTokenStream tokens;
 
@@ -259,17 +264,16 @@ public class AlertExpressionEvalVisitor extends 
AlertExpressionBaseVisitor<List<
 
     @Override
     public List<Map<String, Object>> 
visitSqlCallExpr(AlertExpressionParser.SqlCallExprContext ctx) {
-        return callSqlOrPromql(tokens.getText(ctx.string()));
+        return executor.execute(unquote(tokens.getText(ctx.string())));
     }
 
     @Override
     public List<Map<String, Object>> 
visitPromqlCallExpr(AlertExpressionParser.PromqlCallExprContext ctx) {
-        return callSqlOrPromql(tokens.getText(ctx.string()));
+        return executor.execute(unquote(tokens.getText(ctx.string())));
     }
 
-    private List<Map<String, Object>> callSqlOrPromql(String text) {
-        String script = text.substring(1, text.length() - 1);
-        return executor.execute(script);
+    private String unquote(String text) {
+        return text.substring(1, text.length() - 1);
     }
 
     /**
diff --git 
a/hertzbeat-alerter/src/main/java/org/apache/hertzbeat/alert/service/impl/AlertQueryBudgetExecutor.java
 
b/hertzbeat-alerter/src/main/java/org/apache/hertzbeat/alert/service/impl/AlertQueryBudgetExecutor.java
new file mode 100644
index 0000000000..44552bed65
--- /dev/null
+++ 
b/hertzbeat-alerter/src/main/java/org/apache/hertzbeat/alert/service/impl/AlertQueryBudgetExecutor.java
@@ -0,0 +1,135 @@
+/*
+ * 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.alert.service.impl;
+
+import org.apache.hertzbeat.common.entity.dto.query.DatasourceQuery;
+import org.apache.hertzbeat.common.entity.dto.query.DatasourceQueryData;
+import org.apache.hertzbeat.common.support.exception.AlertExpressionException;
+import org.apache.hertzbeat.warehouse.db.QueryExecutor;
+
+import java.math.BigInteger;
+import java.util.List;
+import java.util.Locale;
+import java.util.Map;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+
+/**
+ * Applies the resource budget shared by alert previews and periodic 
evaluation.
+ *
+ * <p>The query endpoint uses warehouse credentials and an alert definition 
runs repeatedly,
+ * so accepting a read-only query is not sufficient on its own. This wrapper 
limits the input,
+ * the lookback selected by PromQL or Greptime range syntax, and the rows 
returned to the alert
+ * evaluator. The warehouse client supplies the execution timeout; these 
checks bound the work
+ * requested and the data retained by the caller.
+ */
+final class AlertQueryBudgetExecutor implements QueryExecutor {
+
+    static final int MAX_QUERY_LENGTH = 8_192;
+
+    static final int MAX_RESULT_ROWS = 1_000;
+
+    private static final BigInteger MAX_RANGE_MILLIS = 
BigInteger.valueOf(86_400_000L);
+
+    private static final Pattern PROMQL_RANGE = Pattern.compile(
+            
"\\[\\s*([0-9]+(?:ms|[smhdwy])(?:\\s*[0-9]+(?:ms|[smhdwy]))*)\\s*(?::[^]]*)?]",
+            Pattern.CASE_INSENSITIVE);
+
+    private static final Pattern SQL_RANGE = Pattern.compile(
+            "\\bRANGE\\s*['\"]\\s*([^'\"]+)\\s*['\"]",
+            Pattern.CASE_INSENSITIVE);
+
+    private static final Pattern DURATION_PART = 
Pattern.compile("([0-9]+)(ms|[smhdwy])",
+            Pattern.CASE_INSENSITIVE);
+
+    private final QueryExecutor delegate;
+
+    AlertQueryBudgetExecutor(QueryExecutor delegate) {
+        this.delegate = delegate;
+    }
+
+    static void validateInput(String query) {
+        if (query.length() > MAX_QUERY_LENGTH) {
+            throw new AlertExpressionException("Alert query exceeds the 8192 
character limit.");
+        }
+        validateRanges(PROMQL_RANGE.matcher(query));
+        validateRanges(SQL_RANGE.matcher(query));
+    }
+
+    private static void validateRanges(Matcher ranges) {
+        while (ranges.find()) {
+            String duration = ranges.group(1).replaceAll("\\s+", "");
+            if (durationMillis(duration).compareTo(MAX_RANGE_MILLIS) > 0) {
+                throw new AlertExpressionException("Alert query range exceeds 
the one day limit.");
+            }
+        }
+    }
+
+    private static BigInteger durationMillis(String duration) {
+        Matcher parts = DURATION_PART.matcher(duration);
+        BigInteger total = BigInteger.ZERO;
+        int end = 0;
+        while (parts.find()) {
+            if (parts.start() != end) {
+                return BigInteger.ZERO;
+            }
+            BigInteger value = new BigInteger(parts.group(1));
+            total = total.add(value.multiply(unitMillis(parts.group(2))));
+            end = parts.end();
+        }
+        return end == duration.length() ? total : BigInteger.ZERO;
+    }
+
+    private static BigInteger unitMillis(String unit) {
+        return switch (unit.toLowerCase(Locale.ROOT)) {
+            case "ms" -> BigInteger.ONE;
+            case "s" -> BigInteger.valueOf(1_000L);
+            case "m" -> BigInteger.valueOf(60_000L);
+            case "h" -> BigInteger.valueOf(3_600_000L);
+            case "d" -> BigInteger.valueOf(86_400_000L);
+            case "w" -> BigInteger.valueOf(604_800_000L);
+            case "y" -> BigInteger.valueOf(31_536_000_000L);
+            default -> BigInteger.ZERO;
+        };
+    }
+
+    @Override
+    public List<Map<String, Object>> execute(String query) {
+        validateInput(query);
+        List<Map<String, Object>> rows = delegate.execute(query);
+        if (rows != null && rows.size() > MAX_RESULT_ROWS) {
+            throw new AlertExpressionException("Alert query returned more than 
1000 rows.");
+        }
+        return rows;
+    }
+
+    @Override
+    public DatasourceQueryData query(DatasourceQuery datasourceQuery) {
+        return delegate.query(datasourceQuery);
+    }
+
+    @Override
+    public String getDatasource() {
+        return delegate.getDatasource();
+    }
+
+    @Override
+    public boolean support(String queryLanguage) {
+        return delegate.support(queryLanguage);
+    }
+}
diff --git 
a/hertzbeat-alerter/src/main/java/org/apache/hertzbeat/alert/service/impl/DataSourceServiceImpl.java
 
b/hertzbeat-alerter/src/main/java/org/apache/hertzbeat/alert/service/impl/DataSourceServiceImpl.java
index 136777c085..7f268d48cb 100644
--- 
a/hertzbeat-alerter/src/main/java/org/apache/hertzbeat/alert/service/impl/DataSourceServiceImpl.java
+++ 
b/hertzbeat-alerter/src/main/java/org/apache/hertzbeat/alert/service/impl/DataSourceServiceImpl.java
@@ -31,7 +31,6 @@ import org.apache.hertzbeat.alert.expr.AlertExpressionLexer;
 import org.apache.hertzbeat.alert.expr.AlertExpressionParser;
 import org.apache.hertzbeat.alert.service.DataSourceService;
 import org.apache.hertzbeat.common.support.exception.AlertExpressionException;
-import org.apache.hertzbeat.common.support.valid.SqlSecurityException;
 import org.apache.hertzbeat.common.support.valid.SqlSecurityValidator;
 import org.apache.hertzbeat.common.util.ResourceBundleUtil;
 import org.apache.hertzbeat.warehouse.constants.WarehouseConstants;
@@ -58,6 +57,13 @@ public class DataSourceServiceImpl implements 
DataSourceService {
      */
     private static final List<String> DEFAULT_ALLOWED_TABLES = 
List.of(WarehouseConstants.LOG_TABLE_NAME);
 
+    /**
+     * The policy for an alert expression is read only within the configured 
database. Metric
+     * tables are created per metric on demand, so an exact table whitelist 
would reject every
+     * legitimate metric query.
+     */
+    private static final SqlSecurityValidator EXPRESSION_SQL_VALIDATOR = 
SqlSecurityValidator.selectOnly();
+
     protected ResourceBundle bundle = ResourceBundleUtil.getBundle("alerter");
 
     @Setter
@@ -82,6 +88,7 @@ public class DataSourceServiceImpl implements 
DataSourceService {
         if (!StringUtils.hasText(expr)) {
             throw new IllegalArgumentException("Empty expression");
         }
+        AlertQueryBudgetExecutor.validateInput(expr);
         if (executors == null || executors.isEmpty()) {
             throw new 
IllegalArgumentException(bundle.getString("alerter.datasource.executor.not.found"));
         }
@@ -93,7 +100,7 @@ public class DataSourceServiceImpl implements 
DataSourceService {
         // replace all white space
         expr = expr.replaceAll("\\s+", " ");
         try {
-            return evaluate(expr, executor);
+            return evaluate(expr, guardSql(new 
AlertQueryBudgetExecutor(executor), EXPRESSION_SQL_VALIDATOR));
         } catch (AlertExpressionException ae) {
             log.error("Calculate query parse error, datasource: {}, expr: {}, 
msg: {}", datasource, expr, ae.getMessage(), ae);
             throw ae;
@@ -108,6 +115,7 @@ public class DataSourceServiceImpl implements 
DataSourceService {
         if (!StringUtils.hasText(expr)) {
             throw new IllegalArgumentException("Empty expression");
         }
+        AlertQueryBudgetExecutor.validateInput(expr);
         if (executors == null || executors.isEmpty()) {
             throw new 
IllegalArgumentException(bundle.getString("alerter.datasource.executor.not.found"));
         }
@@ -119,13 +127,11 @@ public class DataSourceServiceImpl implements 
DataSourceService {
         // replace all white space
         expr = expr.replaceAll("\\s+", " ");
 
-        // SQL security validation for SQL-based datasources
-        if (isSqlDatasource(datasource)) {
-            validateSqlSecurity(expr);
-        }
-
         try {
-            return executor.execute(expr);
+            return guardSql(new AlertQueryBudgetExecutor(executor), 
sqlSecurityValidator).execute(expr);
+        } catch (AlertExpressionException ae) {
+            // A statement the policy rejected, whose message names the part 
it broke.
+            throw ae;
         } catch (Exception e) {
             log.error("Error executing query on datasource {}: {}", 
datasource, e.getMessage());
             throw new AlertExpressionException(e.getMessage());
@@ -133,22 +139,22 @@ public class DataSourceServiceImpl implements 
DataSourceService {
     }
 
     /**
-     * Check if the datasource is SQL-based
-     */
-    private boolean isSqlDatasource(String datasource) {
-        return datasource != null && 
datasource.equalsIgnoreCase(WarehouseConstants.SQL);
-    }
-
-    /**
-     * Validate SQL statement for security
+     * Wraps an executor that speaks sql so that nothing runs on it 
unvalidated.
+     *
+     * <p>The decision is made from the executor rather than from the 
datasource string the
+     * caller passed, because the executor is what actually holds the database 
credentials.
+     * A datasource that does not speak sql is handed back untouched: a promql 
endpoint takes
+     * a query string, not a statement, and running it through a sql parser 
would only reject
+     * valid promql.
+     * @param executor Executor chosen for this datasource
+     * @param validator Policy to enforce, read only for expressions and 
whitelisting for raw log queries
+     * @return The executor, guarded when it speaks sql
      */
-    private void validateSqlSecurity(String sql) {
-        try {
-            sqlSecurityValidator.validate(sql);
-        } catch (SqlSecurityException e) {
-            log.warn("SQL security validation failed: {}", e.getMessage());
-            throw new AlertExpressionException("SQL security validation 
failed: " + e.getMessage());
+    private QueryExecutor guardSql(QueryExecutor executor, 
SqlSecurityValidator validator) {
+        if (!executor.support(WarehouseConstants.SQL)) {
+            return executor;
         }
+        return new SqlValidatingQueryExecutor(executor, validator);
     }
 
     private List<Map<String, Object>> evaluate(String expr, QueryExecutor 
executor) {
diff --git 
a/hertzbeat-alerter/src/main/java/org/apache/hertzbeat/alert/service/impl/SqlValidatingQueryExecutor.java
 
b/hertzbeat-alerter/src/main/java/org/apache/hertzbeat/alert/service/impl/SqlValidatingQueryExecutor.java
new file mode 100644
index 0000000000..020669594c
--- /dev/null
+++ 
b/hertzbeat-alerter/src/main/java/org/apache/hertzbeat/alert/service/impl/SqlValidatingQueryExecutor.java
@@ -0,0 +1,83 @@
+/*
+ * 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.alert.service.impl;
+
+import org.apache.hertzbeat.common.entity.dto.query.DatasourceQuery;
+import org.apache.hertzbeat.common.entity.dto.query.DatasourceQueryData;
+import org.apache.hertzbeat.common.support.exception.AlertExpressionException;
+import org.apache.hertzbeat.common.support.valid.SqlSecurityException;
+import org.apache.hertzbeat.common.support.valid.SqlSecurityValidator;
+import org.apache.hertzbeat.warehouse.db.QueryExecutor;
+
+import java.util.List;
+import java.util.Map;
+
+/**
+ * A sql executor that validates before it runs anything.
+ *
+ * <p>An alert expression reaches a query executor by several routes: the 
{@code sql("...")}
+ * and {@code promql("...")} spellings both carry an arbitrary string, a bare 
select is
+ * parsed by the expression grammar itself, and each of them is evaluated for 
the preview
+ * endpoint and for the periodic evaluation loop alike. All of them end at
+ * {@link QueryExecutor#execute(String)}, which runs the string with the 
server side database
+ * credentials.
+ *
+ * <p>Guarding that one method rather than each route is what makes the check 
complete: the
+ * spelling an expression happens to use does not decide whether the statement 
is checked,
+ * the database it lands on does. It also means a route added later is covered 
without anyone
+ * remembering to add a call.
+ */
+public class SqlValidatingQueryExecutor implements QueryExecutor {
+
+    private final QueryExecutor delegate;
+
+    private final SqlSecurityValidator validator;
+
+    public SqlValidatingQueryExecutor(QueryExecutor delegate, 
SqlSecurityValidator validator) {
+        this.delegate = delegate;
+        this.validator = validator;
+    }
+
+    @Override
+    public List<Map<String, Object>> execute(String query) {
+        try {
+            validator.validate(query);
+        } catch (SqlSecurityException e) {
+            // AlertExpressionException rather than a generic failure: it is 
the type
+            // DataSourceServiceImpl rethrows untouched and the preview 
endpoint turns into a
+            // 400, so the author of the rule sees which part of the policy 
the statement broke
+            throw new AlertExpressionException("SQL security validation 
failed: " + e.getMessage());
+        }
+        return delegate.execute(query);
+    }
+
+    @Override
+    public DatasourceQueryData query(DatasourceQuery datasourceQuery) {
+        return delegate.query(datasourceQuery);
+    }
+
+    @Override
+    public String getDatasource() {
+        return delegate.getDatasource();
+    }
+
+    @Override
+    public boolean support(String queryLanguage) {
+        return delegate.support(queryLanguage);
+    }
+}
diff --git 
a/hertzbeat-alerter/src/test/java/org/apache/hertzbeat/alert/expr/AlertExpressionEvalVisitorTest.java
 
b/hertzbeat-alerter/src/test/java/org/apache/hertzbeat/alert/expr/AlertExpressionEvalVisitorTest.java
index c12e23e80b..f384d501af 100644
--- 
a/hertzbeat-alerter/src/test/java/org/apache/hertzbeat/alert/expr/AlertExpressionEvalVisitorTest.java
+++ 
b/hertzbeat-alerter/src/test/java/org/apache/hertzbeat/alert/expr/AlertExpressionEvalVisitorTest.java
@@ -864,10 +864,55 @@ class AlertExpressionEvalVisitorTest {
         assertEquals(0, result.get(0).get("__value__"));
     }
 
+    @Test
+    void testSqlCallRunsPlainRead() {
+        when(mockExecutor.execute("select value from cpu where host = 
'server1'"))
+                .thenReturn(List.of(new HashMap<>(Map.of("__value__", 80.0))));
+
+        final List<Map<String, Object>> result =
+                evaluate("sql(\"select value from cpu where host = 
'server1'\") > 70");
+
+        assertEquals(1, result.size());
+        assertEquals(80.0, result.get(0).get("__value__"));
+    }
+
+    /**
+     * Subqueries and nested aggregation are supported in alert expressions, 
and with every
+     * metric table already readable, rejecting them would cost features 
without denying an
+     * attacker anything. The policy stops at read only on purpose.
+     */
+    @Test
+    void testSqlCallKeepsSubqueriesWorking() {
+        final String sql = "select value from cpu where host = (select host 
from hosts limit 1)";
+        when(mockExecutor.execute(sql)).thenReturn(List.of(new 
HashMap<>(Map.of("__value__", 80.0))));
+
+        final List<Map<String, Object>> result = evaluate("sql(\"" + sql + 
"\") > 70");
+
+        assertEquals(1, result.size());
+        assertEquals(80.0, result.get(0).get("__value__"));
+    }
+
+    /**
+     * The visitor hands both spellings to the executor as written. Whether a 
statement is
+     * allowed to run is decided by the executor it lands on, see
+     * {@code DataSourceServiceTest}, so promql keeps working for text that is 
not valid sql
+     * at all.
+     */
+    @Test
+    void testBothCallSpellingsReachTheExecutorAsWritten() {
+        when(mockExecutor.execute("rate(http_requests_total[5m])"))
+                .thenReturn(List.of(new HashMap<>(Map.of("__value__", 80.0))));
+
+        final List<Map<String, Object>> result = 
evaluate("promql(\"rate(http_requests_total[5m])\") > 70");
+
+        assertEquals(1, result.size());
+        assertEquals(80.0, result.get(0).get("__value__"));
+    }
+
     private List<Map<String, Object>> evaluate(String expression) {
         AlertExpressionLexer lexer = new 
AlertExpressionLexer(CharStreams.fromString(expression));
         CommonTokenStream tokens = new CommonTokenStream(lexer);
         AlertExpressionParser parser = new AlertExpressionParser(tokens);
         return new AlertExpressionEvalVisitor(mockExecutor, 
tokens).visit(parser.expression());
     }
-}
\ No newline at end of file
+}
diff --git 
a/hertzbeat-alerter/src/test/java/org/apache/hertzbeat/alert/service/DataSourceServiceTest.java
 
b/hertzbeat-alerter/src/test/java/org/apache/hertzbeat/alert/service/DataSourceServiceTest.java
index e9b098b1b0..af73e77e94 100644
--- 
a/hertzbeat-alerter/src/test/java/org/apache/hertzbeat/alert/service/DataSourceServiceTest.java
+++ 
b/hertzbeat-alerter/src/test/java/org/apache/hertzbeat/alert/service/DataSourceServiceTest.java
@@ -787,4 +787,111 @@ class DataSourceServiceTest {
                 () -> dataSourceService.query("sql", "SELEC * FORM 
hertzbeat_logs"));
         verify(mockExecutor, never()).execute(anyString());
     }
+
+    /**
+     * The expression grammar offers three ways to reach the executor, and the 
datasource the
+     * caller names is what picks that executor. So a statement written with 
the
+     * {@code promql("...")} spelling still lands on the sql executor, with 
the server side
+     * database credentials behind it, whenever the caller names the sql 
datasource: whether a
+     * statement may run cannot be decided from the spelling.
+     */
+    @Test
+    void calculateRejectsWritesWhateverSpellingTheyArrivedIn() {
+        final QueryExecutor mockExecutor = Mockito.mock(QueryExecutor.class);
+        when(mockExecutor.support("sql")).thenReturn(true);
+        dataSourceService.setExecutors(List.of(mockExecutor));
+
+        assertThrows(AlertExpressionException.class,
+                () -> dataSourceService.calculate("sql", "sql(\"drop table 
cpu\") > 0"));
+        assertThrows(AlertExpressionException.class,
+                () -> dataSourceService.calculate("sql", "promql(\"drop table 
cpu\") > 0"));
+        assertThrows(AlertExpressionException.class,
+                () -> dataSourceService.calculate("sql", "sql(\"select 1; drop 
table cpu\") > 0"));
+        verify(mockExecutor, never()).execute(anyString());
+    }
+
+    /**
+     * Only a datasource that speaks sql is guarded. A promql endpoint takes a 
query string
+     * rather than a statement, so running it through a sql parser would 
reject valid promql
+     * without denying an attacker anything.
+     */
+    @Test
+    void calculateLeavesPromqlDatasourcesAlone() {
+        final QueryExecutor mockExecutor = Mockito.mock(QueryExecutor.class);
+        when(mockExecutor.support("promql")).thenReturn(true);
+        when(mockExecutor.execute(anyString())).thenReturn(List.of(new 
HashMap<>(Map.of("__value__", 100.0))));
+        dataSourceService.setExecutors(List.of(mockExecutor));
+
+        final List<Map<String, Object>> result = dataSourceService.calculate(
+                "promql", "promql(\"rate(http_requests_total[5m])\") > 70");
+
+        assertEquals(1, result.size());
+        verify(mockExecutor).execute("rate(http_requests_total[5m])");
+    }
+
+    /**
+     * A read still has to run, including the GreptimeDB range query syntax 
that the sql
+     * parser cannot read.
+     */
+    @Test
+    void calculateStillRunsReadsOnSqlDatasources() {
+        final QueryExecutor mockExecutor = Mockito.mock(QueryExecutor.class);
+        when(mockExecutor.support("sql")).thenReturn(true);
+        when(mockExecutor.execute(anyString())).thenReturn(List.of(new 
HashMap<>(Map.of("__value__", 100.0))));
+        dataSourceService.setExecutors(List.of(mockExecutor));
+
+        final String rangeQuery = "select avg(value) RANGE '10s' from cpu 
ALIGN '5s'";
+        final List<Map<String, Object>> result =
+                dataSourceService.calculate("sql", "sql(\"" + rangeQuery + 
"\") > 70");
+
+        assertEquals(1, result.size());
+        verify(mockExecutor).execute(rangeQuery);
+    }
+
+    @Test
+    void rejectsQueriesThatExceedTheInputBudget() {
+        QueryExecutor mockExecutor = Mockito.mock(QueryExecutor.class);
+        when(mockExecutor.support("promql")).thenReturn(true);
+        dataSourceService.setExecutors(List.of(mockExecutor));
+
+        String oversized = "m".repeat(8193);
+        assertThrows(AlertExpressionException.class,
+                () -> dataSourceService.query("promql", oversized));
+        verify(mockExecutor, never()).execute(anyString());
+    }
+
+    @Test
+    void rejectsPromqlAndSqlRangesBeyondOneDay() {
+        QueryExecutor promqlExecutor = Mockito.mock(QueryExecutor.class);
+        when(promqlExecutor.support("promql")).thenReturn(true);
+        dataSourceService.setExecutors(List.of(promqlExecutor));
+
+        assertThrows(AlertExpressionException.class,
+                () -> dataSourceService.query("promql", 
"rate(http_requests_total[2d])"));
+        verify(promqlExecutor, never()).execute(anyString());
+
+        QueryExecutor sqlExecutor = Mockito.mock(QueryExecutor.class);
+        when(sqlExecutor.support("sql")).thenReturn(true);
+        dataSourceService.setExecutors(List.of(sqlExecutor));
+
+        assertThrows(AlertExpressionException.class,
+                () -> dataSourceService.calculate(
+                        "sql", "sql(\"select avg(value) RANGE '2d' from cpu 
ALIGN '5m'\") > 1"));
+        verify(sqlExecutor, never()).execute(anyString());
+    }
+
+    @Test
+    void rejectsResultSetsBeyondTheAlertBudget() {
+        QueryExecutor mockExecutor = Mockito.mock(QueryExecutor.class);
+        when(mockExecutor.support("promql")).thenReturn(true);
+        List<Map<String, Object>> rows = new ArrayList<>();
+        for (int index = 0; index < 1001; index++) {
+            rows.add(Map.of("__value__", index));
+        }
+        when(mockExecutor.execute("metric_name")).thenReturn(rows);
+        dataSourceService.setExecutors(List.of(mockExecutor));
+
+        assertThrows(AlertExpressionException.class,
+                () -> dataSourceService.query("promql", "metric_name"));
+    }
 }
diff --git 
a/hertzbeat-common-spring/src/main/java/org/apache/hertzbeat/common/support/valid/SqlSecurityValidator.java
 
b/hertzbeat-common-spring/src/main/java/org/apache/hertzbeat/common/support/valid/SqlSecurityValidator.java
index 6d58dff18b..0f01510461 100644
--- 
a/hertzbeat-common-spring/src/main/java/org/apache/hertzbeat/common/support/valid/SqlSecurityValidator.java
+++ 
b/hertzbeat-common-spring/src/main/java/org/apache/hertzbeat/common/support/valid/SqlSecurityValidator.java
@@ -21,32 +21,81 @@ import lombok.extern.slf4j.Slf4j;
 import net.sf.jsqlparser.JSQLParserException;
 import net.sf.jsqlparser.parser.CCJSqlParserUtil;
 import net.sf.jsqlparser.statement.Statement;
+import net.sf.jsqlparser.statement.Statements;
+import net.sf.jsqlparser.statement.delete.Delete;
+import net.sf.jsqlparser.statement.insert.Insert;
+import net.sf.jsqlparser.statement.merge.Merge;
 import net.sf.jsqlparser.statement.select.LateralSubSelect;
 import net.sf.jsqlparser.statement.select.ParenthesedSelect;
+import net.sf.jsqlparser.statement.select.PlainSelect;
 import net.sf.jsqlparser.statement.select.Select;
 import net.sf.jsqlparser.statement.select.SetOperationList;
 import net.sf.jsqlparser.statement.select.WithItem;
+import net.sf.jsqlparser.statement.update.Update;
 import net.sf.jsqlparser.util.TablesNamesFinder;
 import org.springframework.util.CollectionUtils;
 
 import java.util.Collection;
 import java.util.HashSet;
 import java.util.List;
+import java.util.Locale;
 import java.util.Set;
 import java.util.stream.Collectors;
+import java.util.regex.Pattern;
 
 /**
  * SQL Security Validator using JSqlParser 5.1+.
- * Security Policy:
- * 1. Only SELECT statements are allowed.
- * 2. All referenced tables must be in the whitelist.
- * 3. Subqueries, UNION, CTE, LATERAL are blocked.
+ *
+ * <p>Two modes, see {@link #SqlSecurityValidator(Collection)} and {@link 
#selectOnly()}:
+ * <ul>
+ *   <li>whitelisting: only SELECT, every referenced table must be 
whitelisted, and
+ *   subqueries, UNION, CTE and LATERAL are blocked because they are the ways 
a statement
+ *   can reach a table the whitelist never mentions.</li>
+ *   <li>read only: only SELECT, any table, nested reads kept.</li>
+ * </ul>
+ *
+ * <p>Both modes reject a string that carries more than one statement, and 
neither lets a
+ * write through at any depth of the statement.
  */
 @Slf4j
 public class SqlSecurityValidator {
 
+    private static final String SELECT_KEYWORD = "SELECT";
+
+    private static final String WITH_KEYWORD = "WITH";
+
+    /**
+     * Words that no read contains, matched as whole words outside literals 
and comments.
+     *
+     * <p>This is the check that holds when the parser cannot read the dialect 
and there is no
+     * tree to walk, so it has to catch a write wherever it sits, including 
nested in a cte or
+     * a subquery. It is deliberately coarse; the tree walk is the precise one.
+     *
+     * <p>Statements that can only stand alone, {@code TRUNCATE} and {@code 
CALL} among them,
+     * are absent: the leading keyword already rejects those, and several of 
them double as
+     * ordinary functions, {@code TRUNCATE(value, 2)} and {@code REPLACE(msg, 
'a', 'b')} being
+     * the ones a metric query really does use. An identifier that collides 
with a word listed
+     * here can still be quoted, which the scan skips over.
+     */
+    private static final Set<String> WRITE_KEYWORDS = Set.of(
+            "DELETE", "INSERT", "UPDATE", "MERGE", "INTO", "DROP", "ALTER",
+            "CREATE", "GRANT", "REVOKE", "COPY", "RENAME", "ATTACH", "DETACH");
+
+    private static final String DURATION = 
"[0-9]+(?:ms|[smhdwy])(?:\\s*[0-9]+(?:ms|[smhdwy]))*";
+
+    private static final Pattern GREPTIME_RANGE_CLAUSE = Pattern.compile(
+            "(?i)\\bRANGE\\s*'\\s*" + DURATION + "\\s*'");
+
+    private static final Pattern GREPTIME_ALIGN_CLAUSE = Pattern.compile(
+            "(?i)\\bALIGN\\s*'\\s*" + DURATION + "\\s*'");
+
+    private static final Pattern GREPTIME_FILL_CLAUSE = Pattern.compile(
+            "(?i)\\bFILL\\s+(?:LINEAR|PREV|NEXT|NULL)\\b");
+
     private final Set<String> allowedTables;
 
+    private final ValidationPolicy validationPolicy;
+
     public SqlSecurityValidator(Collection<String> allowedTables) {
         if (CollectionUtils.isEmpty(allowedTables)) {
             this.allowedTables = new HashSet<>();
@@ -55,18 +104,137 @@ public class SqlSecurityValidator {
                     .map(this::normalizeIdentifier)
                     .collect(Collectors.toSet());
         }
+        this.validationPolicy = ValidationPolicy.WHITELISTED_SELECT;
+    }
+
+    private SqlSecurityValidator() {
+        this.allowedTables = new HashSet<>();
+        this.validationPolicy = ValidationPolicy.READ_ONLY;
+    }
+
+    /**
+     * A validator whose whole policy is "this statement may only read".
+     *
+     * <p>Use it where there is no table list to validate against: metric 
tables are created
+     * on demand, one per metric, so the whitelisting constructor would reject 
every
+     * legitimate metric query.
+     *
+     * <p>It deliberately keeps subqueries, unions and ctes, unlike the 
whitelisting mode.
+     * Those structures are blocked there because they are the ways a 
statement can reach a
+     * table the whitelist never mentions; with every table already readable 
they buy no
+     * protection, while alert expressions do use subqueries and nested 
aggregation.
+     * @return A validator that accepts only statements proven to be read-only
+     */
+    public static SqlSecurityValidator selectOnly() {
+        return new SqlSecurityValidator();
     }
 
     public void validate(String sql) throws SqlSecurityException {
         if (sql == null || sql.trim().isEmpty()) {
             throw new SqlSecurityException("SQL statement cannot be empty");
         }
+        switch (validationPolicy) {
+            case WHITELISTED_SELECT -> validateAgainstWhitelist(sql);
+            case READ_ONLY -> validateReadOnly(sql);
+            // not dead: a policy added later that nobody wired up here must 
refuse the
+            // statement rather than fall through this switch having validated 
nothing
+            default -> throw new SqlSecurityException("Unknown SQL validation 
policy.");
+        }
+    }
+
+    /**
+     * Read only mode, which runs against a time series database whose sql 
dialect JSqlParser
+     * does not fully cover: GreptimeDB range queries such as
+     * {@code SELECT avg(v) RANGE '10s' FROM cpu ALIGN '5s'} are rejected by 
the parser
+     * although they are ordinary reads.
+     *
+     * <p>So the first properties this mode has to guarantee are established 
without the parser:
+     * statements are counted by scanning outside string literals and 
comments, the leading
+     * keyword decides whether the statement reads, and no word that only a 
write contains may
+     * appear anywhere. All three are dialect independent, and the last one is 
what covers a
+     * write nested where the scan has no structure to reason about, as in
+     * {@code WITH x AS (DELETE FROM cpu RETURNING *) SELECT * FROM x}.
+     *
+     * <p>The parser must still prove the complete statement tree and 
enumerate every table.
+     * For the Greptime clauses JSqlParser does not understand, a 
validation-only copy has the
+     * bounded {@code RANGE}, {@code ALIGN}, and {@code FILL} clauses removed 
before parsing.
+     * Any other unsupported syntax fails closed instead of relying on a 
keyword denylist as
+     * the sole proof of read-only behavior.
+     * @param sql Statement to validate
+     * @throws SqlSecurityException If the statement writes, or carries more 
than one statement
+     */
+    private void validateReadOnly(String sql) throws SqlSecurityException {
+        final StatementShape shape = scan(sql);
+        if (shape.statementCount() != 1) {
+            throw new SqlSecurityException("Only a single statement is 
allowed.");
+        }
+        if (shape.writeKeyword() != null) {
+            throw new SqlSecurityException("'" + shape.writeKeyword() + "' is 
not allowed, only reads are.");
+        }
+        if (!SELECT_KEYWORD.equals(shape.leadingKeyword()) && 
!WITH_KEYWORD.equals(shape.leadingKeyword())) {
+            throw new SqlSecurityException("Only SELECT statements are 
allowed.");
+        }
+        final Statement statement = parseReadOnlyStatement(sql);
 
-        Statement statement;
+        if (!(statement instanceof Select)) {
+            throw new SqlSecurityException("Only SELECT statements are 
allowed.");
+        }
+        final List<String> tables = assertNothingWrites(statement);
+        if (tables.stream().map(this::normalizeIdentifier).anyMatch(table -> 
table.contains("."))) {
+            throw new SqlSecurityException("Schema-qualified tables are not 
allowed.");
+        }
+    }
+
+    private Statement parseReadOnlyStatement(String sql) throws 
SqlSecurityException {
+        try {
+            return parseSingleStatement(sql);
+        } catch (JSQLParserException originalFailure) {
+            String parserCompatibleSql = 
GREPTIME_RANGE_CLAUSE.matcher(sql).replaceAll("");
+            parserCompatibleSql = 
GREPTIME_ALIGN_CLAUSE.matcher(parserCompatibleSql).replaceAll("");
+            parserCompatibleSql = 
GREPTIME_FILL_CLAUSE.matcher(parserCompatibleSql).replaceAll("");
+            if (parserCompatibleSql.equals(sql)) {
+                throw new SqlSecurityException("Invalid SQL syntax: " + 
originalFailure.getMessage(), originalFailure);
+            }
+            try {
+                return parseSingleStatement(parserCompatibleSql);
+            } catch (JSQLParserException normalizedFailure) {
+                log.debug("Failed to parse SQL after removing Greptime range 
clauses: {}", sql, normalizedFailure);
+                throw new SqlSecurityException(
+                        "SQL structure could not be verified as a read: " + 
normalizedFailure.getMessage(),
+                        normalizedFailure);
+            }
+        }
+    }
+
+    /**
+     * Walks the whole statement rather than its outermost node, because a 
write hides at any
+     * depth: {@code SELECT * INTO backup FROM cpu UNION SELECT * FROM cpu} 
puts the write in a
+     * branch of a set operation, and {@code SELECT * FROM (SELECT * INTO 
backup FROM cpu) t}
+     * puts it in a subquery, so an outermost node that is a plain select 
proves nothing.
+     *
+     * <p>Any other failure of the walk is a rejection too. A data modifying 
cte makes
+     * JSqlParser's own finder cast a {@code ParenthesedDelete} to a {@code 
ParenthesedSelect},
+     * and a walk that ended in an exception established nothing about the 
statement.
+     * @param statement Parsed statement to walk
+     * @throws SqlSecurityException If any part of the statement writes, or 
could not be walked
+     */
+    private List<String> assertNothingWrites(Statement statement) throws 
SqlSecurityException {
         try {
-            statement = CCJSqlParserUtil.parse(sql);
+            return new ReadOnlyStatementFinder().getTableList(statement);
+        } catch (SecurityViolationException e) {
+            throw new SqlSecurityException(e.getMessage());
+        } catch (RuntimeException e) {
+            log.debug("Failed to walk SQL, so nothing about it is established: 
{}", statement, e);
+            throw new SqlSecurityException("SQL structure could not be 
verified as a read.");
+        }
+    }
+
+    private void validateAgainstWhitelist(String sql) throws 
SqlSecurityException {
+        final Statement statement;
+        try {
+            statement = parseSingleStatement(sql);
         } catch (JSQLParserException e) {
-            log.warn("Failed to parse SQL: {}", sql, e);
+            log.debug("Failed to parse SQL: {}", sql, e);
             throw new SqlSecurityException("Invalid SQL syntax: " + 
e.getMessage(), e);
         }
 
@@ -74,14 +242,18 @@ public class SqlSecurityValidator {
             throw new SqlSecurityException("Only SELECT statements are 
allowed.");
         }
 
+        // The whitelist is about which tables a statement may touch, so on 
its own it lets
+        // "select * into backup from hertzbeat_logs" through: every table it 
names is allowed
+        assertNothingWrites(statement);
+
         // Check for CTE at top level
         if (select.getWithItemsList() != null && 
!select.getWithItemsList().isEmpty()) {
             throw new SqlSecurityException("CTE (WITH clause) is not allowed");
         }
 
         // Use custom TablesNamesFinder that throws on dangerous structures
-        SecurityTablesNamesFinder finder = new SecurityTablesNamesFinder();
-        List<String> tables;
+        final SecurityTablesNamesFinder finder = new 
SecurityTablesNamesFinder();
+        final List<String> tables;
         try {
             tables = finder.getTableList(statement);
         } catch (SecurityViolationException e) {
@@ -91,6 +263,179 @@ public class SqlSecurityValidator {
         validateTables(tables);
     }
 
+    /**
+     * @param sql Statement to parse
+     * @return The only statement the string carries
+     * @throws JSQLParserException If the string does not parse
+     * @throws SqlSecurityException If the string carries more than one 
statement
+     */
+    private Statement parseSingleStatement(String sql) throws 
JSQLParserException, SqlSecurityException {
+        final Statements statements = CCJSqlParserUtil.parseStatements(sql);
+        // ParseStatements rather than parse: parse() returns only the first 
statement.
+        // Otherwise, "select 1; drop table x" would validate as a plain 
select.
+        // The caller would still hand the whole string to the database.
+        if (statements.getStatements().size() != 1) {
+            throw new SqlSecurityException("Only a single statement is 
allowed.");
+        }
+        return statements.getStatements().get(0);
+    }
+
+    /**
+     * What a statement string looks like from outside any sql dialect.
+     * @param statementCount Statements the string carries, a trailing 
semicolon not counting as one
+     * @param leadingKeyword First word of the first statement, upper cased, 
empty when it does not start with a word
+     * @param writeKeyword First word from {@link #WRITE_KEYWORDS} found 
anywhere, null when there is none
+     */
+    private record StatementShape(int statementCount, String leadingKeyword, 
String writeKeyword) {
+    }
+
+    /**
+     * Counts the statements a string carries and reads the word it opens 
with, skipping over
+     * string literals, quoted identifiers and comments so that a semicolon 
inside them is not
+     * mistaken for a statement separator.
+     *
+     * <p>A backslash is not treated as an escape, because assuming it escapes 
the closing
+     * quote in a dialect where it does not would let {@code 'a\'; DROP TABLE 
t} hide a second
+     * statement inside what this scan thinks is one literal. Not assuming it 
costs at worst a
+     * rejection of a statement that uses backslash escapes, which errs the 
safe way.
+     * @param sql Statement string to scan
+     * @return The shape of the string
+     * @throws SqlSecurityException If a literal or a block comment is left 
open
+     */
+    private StatementShape scan(String sql) throws SqlSecurityException {
+        int statementCount = 0;
+        boolean statementHasContent = false;
+        String leadingKeyword = "";
+        String writeKeyword = null;
+        int index = 0;
+        while (index < sql.length()) {
+            final char current = sql.charAt(index);
+            if (current == '-' && index + 1 < sql.length() && sql.charAt(index 
+ 1) == '-') {
+                final int lineEnd = sql.indexOf('\n', index);
+                index = lineEnd < 0 ? sql.length() : lineEnd + 1;
+            } else if (current == '/' && index + 1 < sql.length() && 
sql.charAt(index + 1) == '*') {
+                final int commentEnd = sql.indexOf("*/", index + 2);
+                if (commentEnd < 0) {
+                    throw new SqlSecurityException("Unterminated block 
comment.");
+                }
+                index = commentEnd + 2;
+            } else if (current == '$') {
+                rejectDollarQuote(sql, index);
+                statementHasContent = true;
+                index++;
+            } else if (current == '\'' || current == '"' || current == '`') {
+                index = skipQuoted(sql, index, current);
+                statementHasContent = true;
+            } else if (current == ';') {
+                if (statementHasContent) {
+                    statementCount++;
+                }
+                statementHasContent = false;
+                index++;
+            } else if (Character.isLetter(current) || current == '_') {
+                // Read the whole word and step past it.
+                // Match a write keyword only on its own, never inside an 
identifier like delete_count.
+                final int wordEnd = wordEnd(sql, index);
+                final String word = sql.substring(index, 
wordEnd).toUpperCase(Locale.ROOT);
+                if (statementCount == 0 && !statementHasContent) {
+                    leadingKeyword = word;
+                }
+                if (writeKeyword == null && WRITE_KEYWORDS.contains(word)) {
+                    writeKeyword = word;
+                }
+                statementHasContent = true;
+                index = wordEnd;
+            } else {
+                if (!Character.isWhitespace(current)) {
+                    statementHasContent = true;
+                }
+                index++;
+            }
+        }
+        if (statementHasContent) {
+            statementCount++;
+        }
+        return new StatementShape(statementCount, leadingKeyword, 
writeKeyword);
+    }
+
+    /**
+     * @param sql Statement string being scanned
+     * @param start Index of the opening quote
+     * @param quote Quote character to close on, a doubled one being an 
escaped quote rather than the close
+     * @return Index just past the closing quote
+     * @throws SqlSecurityException If the quote is never closed
+     */
+    private int skipQuoted(String sql, int start, char quote) throws 
SqlSecurityException {
+        int index = start + 1;
+        while (index < sql.length()) {
+            if (sql.charAt(index) == quote) {
+                if (index + 1 < sql.length() && sql.charAt(index + 1) == 
quote) {
+                    index += 2;
+                    continue;
+                }
+                return index + 1;
+            }
+            index++;
+        }
+        throw new SqlSecurityException("Unterminated quoted literal.");
+    }
+
+    /**
+     * Rejects a PostgreSQL dollar-quoted literal, {@code $$...$$} or {@code 
$tag$...$tag$}.
+     *
+     * <p>Comment markers and semicolons inside such a literal are data, so a 
scan that reads
+     * them as syntax loses the rest of the input: {@code SELECT $$--$$; DROP 
TABLE cpu} looks
+     * like one statement once the {@code --} is taken for a comment.
+     *
+     * <p>Rejecting rather than skipping, because skipping would be the same 
assumption in
+     * reverse. A dialect without dollar quoting means the semicolon inside 
one really does
+     * separate statements, and a scan that skipped the literal would be the 
thing hiding
+     * them. Rejecting needs no assumption either way: a dialect that has 
dollar quoting is
+     * refused, and one that does not was going to fail on the syntax 
regardless. No read of a
+     * metric table spells anything this way.
+     * @param sql Statement string being scanned
+     * @param start Index of the dollar sign
+     * @throws SqlSecurityException If a dollar-quoted literal opens here
+     */
+    private void rejectDollarQuote(String sql, int start) throws 
SqlSecurityException {
+        int tagEnd = start + 1;
+        if (tagEnd >= sql.length()) {
+            return;
+        }
+        final char firstTagCharacter = sql.charAt(tagEnd);
+        if (firstTagCharacter != '$'
+                && !Character.isLetter(firstTagCharacter)
+                && firstTagCharacter != '_') {
+            // a positional parameter such as $1, or a dollar sign that is 
just a character
+            return;
+        }
+        while (tagEnd < sql.length()
+                && (Character.isLetterOrDigit(sql.charAt(tagEnd)) || 
sql.charAt(tagEnd) == '_')) {
+            tagEnd++;
+        }
+        if (tagEnd >= sql.length() || sql.charAt(tagEnd) != '$') {
+            return;
+        }
+        throw new SqlSecurityException("Dollar-quoted literals are not 
allowed.");
+    }
+
+    /**
+     * @param sql Statement string being scanned
+     * @param start Index of the first character of a word
+     * @return Index just past the word, digits and underscores counting as 
part of it so that
+     *     {@code delete_count} is one word rather than a {@code delete} 
followed by a remainder.
+     *     A dollar sign ends the word, so that a literal opening right after 
an identifier is
+     *     still seen by {@link #rejectDollarQuote}
+     */
+    private int wordEnd(String sql, int start) {
+        int index = start;
+        while (index < sql.length()
+                && (Character.isLetterOrDigit(sql.charAt(index)) || 
sql.charAt(index) == '_')) {
+            index++;
+        }
+        return index;
+    }
+
     private void validateTables(List<String> tables) throws 
SqlSecurityException {
         if (CollectionUtils.isEmpty(tables)) {
             return;
@@ -121,6 +466,46 @@ public class SqlSecurityValidator {
         }
     }
 
+    private enum ValidationPolicy {
+        WHITELISTED_SELECT,
+        READ_ONLY
+    }
+
+    /**
+     * Walks a statement and throws as soon as it finds a part of it that 
writes, at any depth.
+     */
+    private static class ReadOnlyStatementFinder extends 
TablesNamesFinder<Void> {
+
+        @Override
+        public Void visit(PlainSelect plainSelect, Object context) {
+            // SELECT ... INTO writes a new table in the dialects that support 
it, so it is not a read.
+            if (!CollectionUtils.isEmpty(plainSelect.getIntoTables())) {
+                throw new SecurityViolationException("SELECT ... INTO is not 
allowed.");
+            }
+            return super.visit(plainSelect, context);
+        }
+
+        @Override
+        public Void visit(Delete delete, Object context) {
+            throw new SecurityViolationException("DELETE is not allowed, only 
reads are.");
+        }
+
+        @Override
+        public Void visit(Insert insert, Object context) {
+            throw new SecurityViolationException("INSERT is not allowed, only 
reads are.");
+        }
+
+        @Override
+        public Void visit(Update update, Object context) {
+            throw new SecurityViolationException("UPDATE is not allowed, only 
reads are.");
+        }
+
+        @Override
+        public Void visit(Merge merge, Object context) {
+            throw new SecurityViolationException("MERGE is not allowed, only 
reads are.");
+        }
+    }
+
     /**
      * Custom TablesNamesFinder that throws exceptions on dangerous SQL 
structures.
      * Extends TablesNamesFinder with proper generic type to avoid raw type 
warnings.
diff --git 
a/hertzbeat-common-spring/src/test/java/org/apache/hertzbeat/common/support/valid/SqlSecurityValidatorTest.java
 
b/hertzbeat-common-spring/src/test/java/org/apache/hertzbeat/common/support/valid/SqlSecurityValidatorTest.java
index 782ee31383..4c7063d7c9 100644
--- 
a/hertzbeat-common-spring/src/test/java/org/apache/hertzbeat/common/support/valid/SqlSecurityValidatorTest.java
+++ 
b/hertzbeat-common-spring/src/test/java/org/apache/hertzbeat/common/support/valid/SqlSecurityValidatorTest.java
@@ -264,4 +264,236 @@ class SqlSecurityValidatorTest {
         assertThrows(SqlSecurityException.class, () -> validator.validate(
             "SELECT * FROM hertzbeat_logs WHERE id = 1 + (SELECT id FROM 
secret_table)"));
     }
+
+    /**
+     * `CCJSqlParserUtil.parse` returns the first statement and silently 
discards the rest,
+     * so a stacked statement used to validate as a plain select while the 
caller still
+     * handed the whole string to the database.
+     */
+    @Test
+    void testStackedStatementIsRejected() {
+        assertThrows(SqlSecurityException.class, () -> validator.validate(
+            "SELECT * FROM hertzbeat_logs; DROP TABLE hertzbeat_logs"));
+        assertThrows(SqlSecurityException.class, () -> 
SqlSecurityValidator.selectOnly().validate(
+            "SELECT 1; DROP TABLE cpu"));
+        assertThrows(SqlSecurityException.class, () -> 
SqlSecurityValidator.selectOnly().validate(
+            "SELECT $$--$$; DROP TABLE cpu"));
+        assertThrows(SqlSecurityException.class, () -> 
SqlSecurityValidator.selectOnly().validate(
+            "SELECT $body$--$body$; DROP TABLE cpu"));
+    }
+
+    @Test
+    void testTrailingSemicolonIsStillAcceptedAsOneStatement() {
+        assertDoesNotThrow(() -> validator.validate("SELECT * FROM 
hertzbeat_logs ; "));
+    }
+
+    @Test
+    void testSelectOnlyRejectsWrites() {
+        final SqlSecurityValidator selectOnly = 
SqlSecurityValidator.selectOnly();
+        assertThrows(SqlSecurityException.class, () -> 
selectOnly.validate("DROP TABLE cpu"));
+        assertThrows(SqlSecurityException.class, () -> 
selectOnly.validate("DELETE FROM cpu"));
+        assertThrows(SqlSecurityException.class, () -> 
selectOnly.validate("INSERT INTO cpu VALUES (1)"));
+        assertThrows(SqlSecurityException.class, () -> 
selectOnly.validate("UPDATE cpu SET value = 1"));
+        assertThrows(SqlSecurityException.class, () -> 
selectOnly.validate("TRUNCATE TABLE cpu"));
+    }
+
+    /**
+     * Metric tables are created per metric on demand, so this mode constrains 
what a
+     * statement may do, not which table it may touch.
+     */
+    @Test
+    void testSelectOnlyAcceptsAnyTableAndNestedReads() {
+        final SqlSecurityValidator selectOnly = 
SqlSecurityValidator.selectOnly();
+        assertDoesNotThrow(() -> selectOnly.validate("SELECT value FROM 
any_metric_table"));
+        assertDoesNotThrow(() -> selectOnly.validate(
+            "SELECT value FROM cpu WHERE host = (SELECT host FROM hosts LIMIT 
1)"));
+        assertDoesNotThrow(() -> selectOnly.validate("SELECT a FROM t1 UNION 
ALL SELECT b FROM t2"));
+    }
+
+    /**
+     * The only sql executor today talks to GreptimeDB, whose range query 
syntax JSqlParser
+     * cannot parse. These are ordinary reads and used to run, so read only 
mode has to keep
+     * accepting them rather than turn a richer dialect into a rule that no 
longer fires.
+     */
+    @Test
+    void testSelectOnlyAcceptsDialectTheParserDoesNotUnderstand() {
+        final SqlSecurityValidator selectOnly = 
SqlSecurityValidator.selectOnly();
+        assertDoesNotThrow(() -> selectOnly.validate(
+            "SELECT ts, avg(value) RANGE '10s' FROM cpu ALIGN '5s' FILL 
LINEAR"));
+        assertDoesNotThrow(() -> selectOnly.validate("SELECT * FROM cpu ALIGN 
'5s'"));
+    }
+
+    /**
+     * Accepting what the parser cannot read must not become a way through: 
the statement
+     * count and the leading keyword are established without the parser, so 
they still hold
+     * for a string it never understood.
+     */
+    @Test
+    void testUnparsableStatementMustStillBeOneRead() {
+        final SqlSecurityValidator selectOnly = 
SqlSecurityValidator.selectOnly();
+        assertThrows(SqlSecurityException.class, () -> selectOnly.validate(
+            "SELECT avg(value) RANGE '10s' FROM cpu ALIGN '5s'; DROP TABLE 
cpu"));
+        assertThrows(SqlSecurityException.class, () -> 
selectOnly.validate("DROP TABLE cpu ALIGN '5s'"));
+        assertThrows(SqlSecurityException.class, () -> 
selectOnly.validate("TQL EVAL (0, 10, '5s') sum(cpu)"));
+    }
+
+    /**
+     * A semicolon that is data or commentary is not a statement separator, 
and a statement
+     * scan that cannot tell the difference would reject ordinary queries.
+     */
+    @Test
+    void testSemicolonInsideLiteralOrCommentDoesNotSplitTheStatement() {
+        final SqlSecurityValidator selectOnly = 
SqlSecurityValidator.selectOnly();
+        assertDoesNotThrow(() -> selectOnly.validate("SELECT * FROM cpu WHERE 
msg = 'a; DROP TABLE cpu'"));
+        assertDoesNotThrow(() -> selectOnly.validate("SELECT * FROM cpu WHERE 
msg = 'it''s; fine'"));
+        assertDoesNotThrow(() -> selectOnly.validate("SELECT * FROM cpu -- ; 
DROP TABLE cpu"));
+        assertDoesNotThrow(() -> selectOnly.validate("SELECT * FROM cpu /* ; 
DROP TABLE cpu */ LIMIT 1"));
+        assertDoesNotThrow(() -> selectOnly.validate("SELECT value FROM 
\"cpu;usage\""));
+    }
+
+    @Test
+    void testUnclosedLiteralOrCommentIsRejected() {
+        final SqlSecurityValidator selectOnly = 
SqlSecurityValidator.selectOnly();
+        assertThrows(SqlSecurityException.class, () -> 
selectOnly.validate("SELECT * FROM cpu WHERE msg = 'open"));
+        assertThrows(SqlSecurityException.class, () -> 
selectOnly.validate("SELECT * FROM cpu /* open"));
+    }
+
+    /**
+     * A dollar-quoted literal is refused outright rather than skipped over, 
so that neither
+     * answer to "does this dialect have dollar quoting" can hide a statement.
+     *
+     * <p>Skipping would lose a stacked statement to a dialect that has it, 
since the comment
+     * marker in {@code SELECT $$--$$; DROP TABLE cpu} is data rather than a 
comment. Skipping
+     * would equally lose one to a dialect that does not, since the semicolon 
in
+     * {@code SELECT 1 $$;DROP TABLE cpu$$} really does separate statements 
there.
+     */
+    @Test
+    void testDollarQuotedLiteralIsRejectedRatherThanSkipped() {
+        final SqlSecurityValidator selectOnly = 
SqlSecurityValidator.selectOnly();
+        assertThrows(SqlSecurityException.class, () -> 
selectOnly.validate("SELECT $$--$$; DROP TABLE cpu"));
+        assertThrows(SqlSecurityException.class, () -> 
selectOnly.validate("SELECT 1 $$;DROP TABLE cpu$$"));
+        assertThrows(SqlSecurityException.class, () -> 
selectOnly.validate("SELECT 1 $t$;DROP TABLE cpu$t$"));
+        assertThrows(SqlSecurityException.class, () -> 
selectOnly.validate("SELECT $$a; -- DROP TABLE cpu$$"));
+        assertThrows(SqlSecurityException.class, () -> 
selectOnly.validate("SELECT $body$a; -- DROP TABLE cpu$body$"));
+        assertThrows(SqlSecurityException.class, () -> 
selectOnly.validate("SELECT $$open"));
+        assertThrows(SqlSecurityException.class, () -> 
selectOnly.validate("SELECT $body$open"));
+        // a literal opening right after an identifier is still a literal
+        assertThrows(SqlSecurityException.class, () -> 
selectOnly.validate("SELECT a$$b;c$$ FROM cpu"));
+    }
+
+    /**
+     * A dollar sign that opens nothing is an ordinary character, so reads 
keep working.
+     */
+    @Test
+    void testLoneDollarSignIsNotTreatedAsLiteral() {
+        final SqlSecurityValidator selectOnly = 
SqlSecurityValidator.selectOnly();
+        assertDoesNotThrow(() -> selectOnly.validate("SELECT $1 FROM cpu"));
+        assertDoesNotThrow(() -> selectOnly.validate("SELECT a$b FROM cpu"));
+        assertDoesNotThrow(() -> selectOnly.validate("SELECT * FROM cpu WHERE 
cost = '$5; x'"));
+    }
+
+    /**
+     * `SELECT ... INTO` parses as a select but writes a table in the dialects 
that support it.
+     */
+    @Test
+    void testSelectOnlyRejectsSelectInto() {
+        assertThrows(SqlSecurityException.class, () -> 
SqlSecurityValidator.selectOnly()
+            .validate("SELECT * INTO backup FROM cpu"));
+    }
+
+    @Test
+    void testSelectOnlyAcceptsCte() {
+        final SqlSecurityValidator selectOnly = 
SqlSecurityValidator.selectOnly();
+        assertDoesNotThrow(() -> selectOnly.validate("WITH x AS (SELECT 1 AS 
v) SELECT * FROM x"));
+        assertDoesNotThrow(() -> selectOnly.validate(
+            "WITH x AS (SELECT avg(v) RANGE '10s' FROM cpu ALIGN '5s') SELECT 
* FROM x"));
+    }
+
+    /**
+     * An outermost node that is a plain select proves nothing about the rest 
of the tree: a
+     * write hides in a cte, in a branch of a set operation, or in a subquery, 
and JSqlParser
+     * reports the outermost node of all three as a select.
+     */
+    @Test
+    void testSelectOnlyRejectsWritesNestedInsideReads() {
+        final SqlSecurityValidator selectOnly = 
SqlSecurityValidator.selectOnly();
+        assertThrows(SqlSecurityException.class, () -> selectOnly.validate(
+            "WITH x AS (DELETE FROM cpu RETURNING *) SELECT * FROM x"));
+        assertThrows(SqlSecurityException.class, () -> selectOnly.validate(
+            "WITH x AS (INSERT INTO cpu VALUES (1) RETURNING *) SELECT * FROM 
x"));
+        assertThrows(SqlSecurityException.class, () -> selectOnly.validate(
+            "WITH x AS (SELECT id FROM t) DELETE FROM cpu WHERE id IN (SELECT 
id FROM x)"));
+        assertThrows(SqlSecurityException.class, () -> selectOnly.validate(
+            "SELECT * INTO backup FROM cpu UNION SELECT * FROM cpu"));
+        assertThrows(SqlSecurityException.class, () -> selectOnly.validate(
+            "SELECT * FROM cpu UNION SELECT * INTO backup FROM cpu"));
+        assertThrows(SqlSecurityException.class, () -> selectOnly.validate(
+            "SELECT * FROM (SELECT * INTO backup FROM cpu) t"));
+    }
+
+    /**
+     * The nested writes above have to stay rejected when the parser cannot 
read the dialect
+     * and there is no tree to walk, which is the case the whole read only 
mode exists for.
+     */
+    @Test
+    void testNestedWritesStayRejectedWithoutTheParser() {
+        final SqlSecurityValidator selectOnly = 
SqlSecurityValidator.selectOnly();
+        assertThrows(SqlSecurityException.class, () -> selectOnly.validate(
+            "SELECT * FROM (DELETE FROM cpu RETURNING *) t ALIGN '5s'"));
+        assertThrows(SqlSecurityException.class, () -> selectOnly.validate(
+            "WITH x AS (DELETE FROM cpu RETURNING *) SELECT avg(v) RANGE '10s' 
FROM x ALIGN '5s'"));
+        assertThrows(SqlSecurityException.class, () -> selectOnly.validate(
+            "SELECT * INTO backup FROM cpu ALIGN '5s'"));
+    }
+
+    /**
+     * The word scan matches whole words only, so ordinary reads whose 
identifiers or functions
+     * merely contain one keep working. An identifier that collides outright 
can be quoted.
+     */
+    @Test
+    void testWriteWordScanDoesNotCatchOrdinaryReads() {
+        final SqlSecurityValidator selectOnly = 
SqlSecurityValidator.selectOnly();
+        assertDoesNotThrow(() -> selectOnly.validate("SELECT delete_count, 
insert_rate FROM cpu"));
+        assertDoesNotThrow(() -> selectOnly.validate("SELECT truncate(value, 
2) FROM cpu"));
+        assertDoesNotThrow(() -> selectOnly.validate("SELECT replace(msg, 'a', 
'b') FROM logs"));
+        assertDoesNotThrow(() -> selectOnly.validate("SELECT * FROM cpu WHERE 
msg = 'drop table cpu'"));
+        assertDoesNotThrow(() -> selectOnly.validate("SELECT \"drop\" FROM 
cpu"));
+    }
+
+    /**
+     * The whitelist says which tables a statement may touch, so on its own it 
lets a write
+     * through as long as every table it names is allowed.
+     */
+    @Test
+    void testWhitelistModeRejectsSelectIntoOnAnAllowedTable() {
+        assertThrows(SqlSecurityException.class, () -> validator.validate(
+            "SELECT * INTO backup FROM hertzbeat_logs"));
+    }
+
+    /**
+     * The whitelisting mode needs the parse tree to enumerate table names, so 
unlike read
+     * only mode it has nothing to fall back on and keeps rejecting what it 
cannot parse.
+     */
+    @Test
+    void testWhitelistModeStillRejectsWhatItCannotParse() {
+        assertThrows(SqlSecurityException.class, () -> validator.validate(
+            "SELECT avg(value) RANGE '10s' FROM hertzbeat_logs ALIGN '5s'"));
+    }
+
+    @Test
+    void testSelectOnlyCannotEscapeTheConfiguredDatabase() {
+        final SqlSecurityValidator selectOnly = 
SqlSecurityValidator.selectOnly();
+
+        assertThrows(SqlSecurityException.class,
+            () -> selectOnly.validate("SELECT * FROM 
information_schema.tables"));
+        assertThrows(SqlSecurityException.class,
+            () -> selectOnly.validate("SELECT * FROM pg_catalog.pg_tables"));
+        assertThrows(SqlSecurityException.class,
+            () -> selectOnly.validate("SELECT * FROM public.cpu"));
+        assertThrows(SqlSecurityException.class,
+            () -> selectOnly.validate("SELECT * FROM 
\"information_schema\".\"tables\""));
+        assertThrows(SqlSecurityException.class,
+            () -> selectOnly.validate(
+                "SELECT * FROM cpu, information_schema.tables RANGE '1m' ALIGN 
'1m'"));
+    }
 }
diff --git a/hertzbeat-startup/src/main/resources/sureness.yml 
b/hertzbeat-startup/src/main/resources/sureness.yml
index e8aed38e31..2749420cbe 100644
--- a/hertzbeat-startup/src/main/resources/sureness.yml
+++ b/hertzbeat-startup/src/main/resources/sureness.yml
@@ -36,6 +36,12 @@ resourceRole:
   - /api/monitors/**===post===[admin,user]
   - /api/monitors/**===put===[admin,user]
   - /api/monitors/**===delete===[admin]
+  - /api/alert/define/preview/**===get===[admin]
+  - /api/alert/define/**===get===[admin,user]
+  - /api/alert/define===post===[admin]
+  - /api/alert/define===put===[admin]
+  - /api/alert/defines/**===get===[admin,user]
+  - /api/alert/defines/**===post===[admin]
   - /api/alert/**===get===[admin,user,guest]
   - /api/alert/**===post===[admin,user]
   - /api/alert/**===put===[admin,user]
diff --git 
a/hertzbeat-startup/src/test/java/org/apache/hertzbeat/startup/security/SurenessResourceRuleTest.java
 
b/hertzbeat-startup/src/test/java/org/apache/hertzbeat/startup/security/SurenessResourceRuleTest.java
index 17301b30b7..564cd5c179 100644
--- 
a/hertzbeat-startup/src/test/java/org/apache/hertzbeat/startup/security/SurenessResourceRuleTest.java
+++ 
b/hertzbeat-startup/src/test/java/org/apache/hertzbeat/startup/security/SurenessResourceRuleTest.java
@@ -117,6 +117,28 @@ class SurenessResourceRuleTest {
         assertEquals("[admin,user,guest]", 
roleTree.searchPathFilterRoles("/api/monitors" + SEPARATOR + "get"));
     }
 
+    @Test
+    void readingAlertDefinitionsExcludesGuest() {
+        assertEquals("[admin,user]",
+                roleTree.searchPathFilterRoles("/api/alert/define/1" + 
SEPARATOR + "get"));
+        assertEquals("[admin,user]",
+                roleTree.searchPathFilterRoles("/api/alert/defines" + 
SEPARATOR + "get"));
+        assertEquals("[admin,user]",
+                roleTree.searchPathFilterRoles("/api/alert/defines/export" + 
SEPARATOR + "get"));
+    }
+
+    @Test
+    void previewingAndAuthoringPeriodicQueriesIsRestrictedToAdmin() {
+        assertEquals("[admin]",
+                roleTree.searchPathFilterRoles("/api/alert/define/preview/sql" 
+ SEPARATOR + "get"));
+        assertEquals("[admin]",
+                roleTree.searchPathFilterRoles("/api/alert/define" + SEPARATOR 
+ "post"));
+        assertEquals("[admin]",
+                roleTree.searchPathFilterRoles("/api/alert/define" + SEPARATOR 
+ "put"));
+        assertEquals("[admin]",
+                roleTree.searchPathFilterRoles("/api/alert/defines/import" + 
SEPARATOR + "post"));
+    }
+
     /**
      * Only the {@code get} verb under {@code /api/apps} used to be listed, so 
the monitoring
      * template writes behind {@code AppController} reached any authenticated 
caller, down to
@@ -200,6 +222,32 @@ class SurenessResourceRuleTest {
         }
     }
 
+    @Test
+    void deploymentCopiesRestrictAlertQueryAuthoring() throws IOException {
+        for (Path copy : deploymentCopies()) {
+            TirePathTree copyTree = new TirePathTree();
+            copyTree.buildTree(new LinkedHashSet<>(sectionOf(copy, 
"resourceRole")));
+            assertEquals("[admin,user]",
+                    copyTree.searchPathFilterRoles("/api/alert/define/1" + 
SEPARATOR + "get"),
+                    "alert definitions expose query expressions to guest in " 
+ copy);
+            assertEquals("[admin,user]",
+                    copyTree.searchPathFilterRoles("/api/alert/defines" + 
SEPARATOR + "get"),
+                    "alert definition lists expose query expressions to guest 
in " + copy);
+            assertEquals("[admin]",
+                    
copyTree.searchPathFilterRoles("/api/alert/define/preview/sql" + SEPARATOR + 
"get"),
+                    "alert query preview is over-granted in " + copy);
+            assertEquals("[admin]",
+                    copyTree.searchPathFilterRoles("/api/alert/define" + 
SEPARATOR + "post"),
+                    "alert query authoring is over-granted in " + copy);
+            assertEquals("[admin]",
+                    copyTree.searchPathFilterRoles("/api/alert/define" + 
SEPARATOR + "put"),
+                    "alert query modification is over-granted in " + copy);
+            assertEquals("[admin]",
+                    copyTree.searchPathFilterRoles("/api/alert/defines/import" 
+ SEPARATOR + "post"),
+                    "alert query import is over-granted in " + copy);
+        }
+    }
+
     /**
      * @return the {@code sureness.yml} copies shipped by the deployment 
scripts
      */
diff --git a/script/docker-compose/hertzbeat-mysql-iotdb/conf/sureness.yml 
b/script/docker-compose/hertzbeat-mysql-iotdb/conf/sureness.yml
index a13913e44c..bc25d799ad 100644
--- a/script/docker-compose/hertzbeat-mysql-iotdb/conf/sureness.yml
+++ b/script/docker-compose/hertzbeat-mysql-iotdb/conf/sureness.yml
@@ -36,6 +36,12 @@ resourceRole:
   - /api/monitors/**===post===[admin,user]
   - /api/monitors/**===put===[admin,user]
   - /api/monitors/**===delete===[admin]
+  - /api/alert/define/preview/**===get===[admin]
+  - /api/alert/define/**===get===[admin,user]
+  - /api/alert/define===post===[admin]
+  - /api/alert/define===put===[admin]
+  - /api/alert/defines/**===get===[admin,user]
+  - /api/alert/defines/**===post===[admin]
   - /api/alert/**===get===[admin,user,guest]
   - /api/alert/**===post===[admin,user]
   - /api/alert/**===put===[admin,user]
diff --git a/script/docker-compose/hertzbeat-mysql-tdengine/conf/sureness.yml 
b/script/docker-compose/hertzbeat-mysql-tdengine/conf/sureness.yml
index a13913e44c..bc25d799ad 100644
--- a/script/docker-compose/hertzbeat-mysql-tdengine/conf/sureness.yml
+++ b/script/docker-compose/hertzbeat-mysql-tdengine/conf/sureness.yml
@@ -36,6 +36,12 @@ resourceRole:
   - /api/monitors/**===post===[admin,user]
   - /api/monitors/**===put===[admin,user]
   - /api/monitors/**===delete===[admin]
+  - /api/alert/define/preview/**===get===[admin]
+  - /api/alert/define/**===get===[admin,user]
+  - /api/alert/define===post===[admin]
+  - /api/alert/define===put===[admin]
+  - /api/alert/defines/**===get===[admin,user]
+  - /api/alert/defines/**===post===[admin]
   - /api/alert/**===get===[admin,user,guest]
   - /api/alert/**===post===[admin,user]
   - /api/alert/**===put===[admin,user]
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 a13913e44c..bc25d799ad 100644
--- a/script/docker-compose/hertzbeat-mysql-victoria-metrics/conf/sureness.yml
+++ b/script/docker-compose/hertzbeat-mysql-victoria-metrics/conf/sureness.yml
@@ -36,6 +36,12 @@ resourceRole:
   - /api/monitors/**===post===[admin,user]
   - /api/monitors/**===put===[admin,user]
   - /api/monitors/**===delete===[admin]
+  - /api/alert/define/preview/**===get===[admin]
+  - /api/alert/define/**===get===[admin,user]
+  - /api/alert/define===post===[admin]
+  - /api/alert/define===put===[admin]
+  - /api/alert/defines/**===get===[admin,user]
+  - /api/alert/defines/**===post===[admin]
   - /api/alert/**===get===[admin,user,guest]
   - /api/alert/**===post===[admin,user]
   - /api/alert/**===put===[admin,user]
diff --git 
a/script/docker-compose/hertzbeat-postgresql-greptimedb/conf/sureness.yml 
b/script/docker-compose/hertzbeat-postgresql-greptimedb/conf/sureness.yml
index 37174e7ecc..74a4ce4358 100644
--- a/script/docker-compose/hertzbeat-postgresql-greptimedb/conf/sureness.yml
+++ b/script/docker-compose/hertzbeat-postgresql-greptimedb/conf/sureness.yml
@@ -36,6 +36,12 @@ resourceRole:
   - /api/monitors/**===post===[admin,user]
   - /api/monitors/**===put===[admin,user]
   - /api/monitors/**===delete===[admin]
+  - /api/alert/define/preview/**===get===[admin]
+  - /api/alert/define/**===get===[admin,user]
+  - /api/alert/define===post===[admin]
+  - /api/alert/define===put===[admin]
+  - /api/alert/defines/**===get===[admin,user]
+  - /api/alert/defines/**===post===[admin]
   - /api/alert/**===get===[admin,user,guest]
   - /api/alert/**===post===[admin,user]
   - /api/alert/**===put===[admin,user]
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 a13913e44c..bc25d799ad 100644
--- 
a/script/docker-compose/hertzbeat-postgresql-victoria-metrics/conf/sureness.yml
+++ 
b/script/docker-compose/hertzbeat-postgresql-victoria-metrics/conf/sureness.yml
@@ -36,6 +36,12 @@ resourceRole:
   - /api/monitors/**===post===[admin,user]
   - /api/monitors/**===put===[admin,user]
   - /api/monitors/**===delete===[admin]
+  - /api/alert/define/preview/**===get===[admin]
+  - /api/alert/define/**===get===[admin,user]
+  - /api/alert/define===post===[admin]
+  - /api/alert/define===put===[admin]
+  - /api/alert/defines/**===get===[admin,user]
+  - /api/alert/defines/**===post===[admin]
   - /api/alert/**===get===[admin,user,guest]
   - /api/alert/**===post===[admin,user]
   - /api/alert/**===put===[admin,user]
diff --git a/script/sureness.yml b/script/sureness.yml
index 37174e7ecc..74a4ce4358 100644
--- a/script/sureness.yml
+++ b/script/sureness.yml
@@ -36,6 +36,12 @@ resourceRole:
   - /api/monitors/**===post===[admin,user]
   - /api/monitors/**===put===[admin,user]
   - /api/monitors/**===delete===[admin]
+  - /api/alert/define/preview/**===get===[admin]
+  - /api/alert/define/**===get===[admin,user]
+  - /api/alert/define===post===[admin]
+  - /api/alert/define===put===[admin]
+  - /api/alert/defines/**===get===[admin,user]
+  - /api/alert/defines/**===post===[admin]
   - /api/alert/**===get===[admin,user,guest]
   - /api/alert/**===post===[admin,user]
   - /api/alert/**===put===[admin,user]


---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]

Reply via email to