Files
nezha_domains/cmd/dashboard/controller/alertrule.go
T
naibaandcloudcode e8dabf5bc6 feat(auth): add PAT auth, scoped REST/MCP access, CSRF, and tenant isolation
Introduce Personal Access Tokens (nzp_*) as a stateless auth path alongside
JWT, gated per-endpoint by a scope middleware (nezha:{resource}:{verb}) with
fail-closed empty-scope defaults and a server-id whitelist. Self-management
endpoints (profile, api-tokens, oauth2 bind, refresh-token) explicitly reject
PATs to block privilege-escalation chains. A revoke registry tears down active
long-lived connections (terminal, fm, ws, transfer, mcp) the moment a PAT is
deleted, with a tombstone closing the revoke->register race.

Add an MCP endpoint that proxies tool calls (exec, fs read/write/delete,
transfer) to agents over gRPC, guarded by origin/DNS-rebinding checks, a
per-token rate limiter, audit logging, and a kill switch. Serialize all
sends through the IOStream wrapper to honour grpc-go's concurrency contract.

Add CSRF double-submit protection on unsafe cookie-authenticated methods,
exempting authenticated PAT requests by context identity (not a forgeable
Authorization header). Apply visibility/whitelist filtering consistently
across list, get-by-id, and mutate paths to enforce tenant isolation.

Migrate legacy mcp:* scopes: rewrite read/exec to nezha:* equivalents and
drop dangerous write/delete/wildcard grants.

Co-authored-by: cloudcode <cloudcode@users.noreply.github.com>
2026-05-30 15:56:44 +00:00

217 lines
5.4 KiB
Go

package controller
import (
"slices"
"strconv"
"time"
"github.com/gin-gonic/gin"
"github.com/jinzhu/copier"
"github.com/nezhahq/nezha/model"
"github.com/nezhahq/nezha/service/singleton"
)
// List Alert rules
// @Summary List Alert rules
// @Security BearerAuth
// @Schemes
// @Description List Alert rules
// @Tags auth required
// @Param id query uint false "Resource ID"
// @Produce json
// @Success 200 {object} model.CommonResponse[[]model.AlertRule]
// @Router /alert-rule [get]
func listAlertRule(c *gin.Context) ([]*model.AlertRule, error) {
singleton.AlertsLock.RLock()
defer singleton.AlertsLock.RUnlock()
var ar []*model.AlertRule
if err := copier.Copy(&ar, &singleton.Alerts); err != nil {
return nil, err
}
return ar, nil
}
// Add Alert Rule
// @Summary Add Alert Rule
// @Security BearerAuth
// @Schemes
// @Description Add Alert Rule
// @Tags auth required
// @Accept json
// @param request body model.AlertRuleForm true "AlertRuleForm"
// @Produce json
// @Success 200 {object} model.CommonResponse[uint64]
// @Router /alert-rule [post]
func createAlertRule(c *gin.Context) (uint64, error) {
var arf model.AlertRuleForm
var r model.AlertRule
if err := c.ShouldBindJSON(&arf); err != nil {
return 0, err
}
uid := getUid(c)
r.UserID = uid
r.Name = arf.Name
r.Rules = arf.Rules
r.FailTriggerTasks = arf.FailTriggerTasks
r.RecoverTriggerTasks = arf.RecoverTriggerTasks
r.NotificationGroupID = arf.NotificationGroupID
enable := arf.Enable
r.TriggerMode = arf.TriggerMode
r.Enable = &enable
if err := validateRule(c, &r); err != nil {
return 0, err
}
if err := singleton.DB.Create(&r).Error; err != nil {
return 0, newGormError("%v", err)
}
singleton.OnRefreshOrAddAlert(&r)
return r.ID, nil
}
// Update Alert Rule
// @Summary Update Alert Rule
// @Security BearerAuth
// @Schemes
// @Description Update Alert Rule
// @Tags auth required
// @Accept json
// @param id path uint true "Alert ID"
// @param request body model.AlertRuleForm true "AlertRuleForm"
// @Produce json
// @Success 200 {object} model.CommonResponse[any]
// @Router /alert-rule/{id} [patch]
func updateAlertRule(c *gin.Context) (any, error) {
idStr := c.Param("id")
id, err := strconv.ParseUint(idStr, 10, 64)
if err != nil {
return nil, err
}
var arf model.AlertRuleForm
if err := c.ShouldBindJSON(&arf); err != nil {
return 0, err
}
var r model.AlertRule
if err := singleton.DB.First(&r, id).Error; err != nil {
return nil, singleton.Localizer.ErrorT("alert id %d does not exist", id)
}
if !r.HasPermission(c) {
return nil, singleton.Localizer.ErrorT("permission denied")
}
r.Name = arf.Name
r.Rules = arf.Rules
r.FailTriggerTasks = arf.FailTriggerTasks
r.RecoverTriggerTasks = arf.RecoverTriggerTasks
r.NotificationGroupID = arf.NotificationGroupID
enable := arf.Enable
r.TriggerMode = arf.TriggerMode
r.Enable = &enable
if err := validateRule(c, &r); err != nil {
return 0, err
}
if err := singleton.DB.Save(&r).Error; err != nil {
return 0, newGormError("%v", err)
}
singleton.OnRefreshOrAddAlert(&r)
return r.ID, nil
}
// Batch delete Alert rules
// @Summary Batch delete Alert rules
// @Security BearerAuth
// @Schemes
// @Description Batch delete Alert rules
// @Tags auth required
// @Accept json
// @param request body []uint64 true "id list"
// @Produce json
// @Success 200 {object} model.CommonResponse[any]
// @Router /batch-delete/alert-rule [post]
func batchDeleteAlertRule(c *gin.Context) (any, error) {
var ar []uint64
if err := c.ShouldBindJSON(&ar); err != nil {
return nil, err
}
var ars []model.AlertRule
if err := singleton.DB.Where("id in (?)", ar).Find(&ars).Error; err != nil {
return nil, err
}
for _, a := range ars {
if !a.HasPermission(c) {
return nil, singleton.Localizer.ErrorT("permission denied")
}
}
if err := singleton.DB.Unscoped().Delete(&model.AlertRule{}, "id in (?)", ar).Error; err != nil {
return nil, newGormError("%v", err)
}
singleton.OnDeleteAlert(ar)
return nil, nil
}
func validateRule(c *gin.Context, r *model.AlertRule) error {
if !r.HasPermission(c) {
return singleton.Localizer.ErrorT("permission denied")
}
if len(r.Rules) > 0 {
for _, rule := range r.Rules {
switch rule.Cover {
case model.RuleCoverAll, model.RuleCoverIgnoreAll:
default:
return singleton.Localizer.ErrorT("permission denied")
}
if !rule.IsTransferDurationRule() {
if rule.Duration < 3 {
return singleton.Localizer.ErrorT("duration need to be at least 3")
}
} else {
if rule.CycleInterval < 1 {
return singleton.Localizer.ErrorT("cycle_interval need to be at least 1")
}
if rule.CycleStart == nil {
return singleton.Localizer.ErrorT("cycle_start is not set")
}
if rule.CycleStart.After(time.Now()) {
return singleton.Localizer.ErrorT("cycle_start is a future value")
}
}
}
} else {
return singleton.Localizer.ErrorT("need to configure at least a single rule")
}
if !singleton.CronShared.CheckPermission(c, slices.Values(r.FailTriggerTasks)) {
return singleton.Localizer.ErrorT("permission denied")
}
if !singleton.CronShared.CheckPermission(c, slices.Values(r.RecoverTriggerTasks)) {
return singleton.Localizer.ErrorT("permission denied")
}
if err := enforcePATTriggerTaskScope(c, r.FailTriggerTasks, r.RecoverTriggerTasks); err != nil {
return err
}
if err := assertOwnsNotificationGroup(c, r.NotificationGroupID); err != nil {
return err
}
return nil
}