Files
nezha_domains/model/alertrule_pat_whitelist_test.go
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

228 lines
8.1 KiB
Go

package model
import (
"net/http/httptest"
"testing"
"github.com/gin-gonic/gin"
)
// H4 regression: AlertRule had no AlertRule.HasPermission override, so
// limited PATs could create / list / update rules that fan out to every
// owner server. A RuleCoverAll + empty Ignore rule monitors every server
// the owner can reach (admin owner: every server in the system), which is
// exactly the cover-fanout the PAT whitelist is supposed to contain.
func TestAlertRuleHasPermission_DeniesRuleCoverAllEmptyIgnoreForLimitedPAT(t *testing.T) {
saved := OwnerServerIDsLookup
savedAdmin := OwnerIsAdminLookup
savedAll := AllServerIDsLookup
t.Cleanup(func() {
OwnerServerIDsLookup = saved
OwnerIsAdminLookup = savedAdmin
AllServerIDsLookup = savedAll
})
OwnerServerIDsLookup = func(uid uint64) []uint64 {
if uid == 100 {
return []uint64{1, 2}
}
return nil
}
OwnerIsAdminLookup = func(uid uint64) bool { return false }
AllServerIDsLookup = func() []uint64 { return []uint64{1, 2, 3} }
rule := &AlertRule{
Common: Common{ID: 9, UserID: 100},
Rules: []*Rule{{
Type: "cpu",
Cover: RuleCoverAll,
Ignore: nil,
}},
}
ctx, _ := gin.CreateTestContext(httptest.NewRecorder())
ctx.Set(CtxKeyAuthorizedUser, &User{Common: Common{ID: 100}, Role: RoleMember})
ctx.Set(CtxKeyAPIToken, &stubPATAccessor{ids: []uint64{1}}) // doesn't cover 2
if rule.HasPermission(ctx) {
t.Fatal("server-limited PAT must not be allowed to operate on RuleCoverAll with empty Ignore — runtime fans out to owner server 2")
}
}
func TestAlertRuleHasPermission_AllowsRuleCoverIgnoreAllEmptyIgnore(t *testing.T) {
saved := OwnerServerIDsLookup
t.Cleanup(func() { OwnerServerIDsLookup = saved })
OwnerServerIDsLookup = func(uid uint64) []uint64 { return []uint64{1, 2} }
rule := &AlertRule{
Common: Common{ID: 10, UserID: 100},
Rules: []*Rule{{
Type: "cpu",
Cover: RuleCoverIgnoreAll,
Ignore: nil, // allow-list of zero ⇒ no-op
}},
}
ctx, _ := gin.CreateTestContext(httptest.NewRecorder())
ctx.Set(CtxKeyAuthorizedUser, &User{Common: Common{ID: 100}, Role: RoleMember})
ctx.Set(CtxKeyAPIToken, &stubPATAccessor{ids: []uint64{1}})
if !rule.HasPermission(ctx) {
t.Fatal("RuleCoverIgnoreAll + empty Ignore is a no-op rule; PAT must remain allowed")
}
}
func TestAlertRuleHasPermission_DeniesUnknownCover(t *testing.T) {
saved := OwnerServerIDsLookup
t.Cleanup(func() { OwnerServerIDsLookup = saved })
OwnerServerIDsLookup = func(uid uint64) []uint64 { return []uint64{1} }
rule := &AlertRule{
Common: Common{ID: 11, UserID: 100},
Rules: []*Rule{{
Type: "cpu",
Cover: 99, // unknown ⇒ runtime Snapshot falls through to "monitor everything"
}},
}
ctx, _ := gin.CreateTestContext(httptest.NewRecorder())
ctx.Set(CtxKeyAuthorizedUser, &User{Common: Common{ID: 100}, Role: RoleMember})
ctx.Set(CtxKeyAPIToken, &stubPATAccessor{ids: []uint64{1}})
if rule.HasPermission(ctx) {
t.Fatal("unknown Rule.Cover must fail-closed for limited PAT — Snapshot does not gate on it")
}
}
// Regression: AlertRule.HasPermission built the deny-list from every key in
// Rule.Ignore regardless of its bool value, but Rule.Snapshot only skips a
// server when Ignore[id] == true. A limited PAT whitelisted to {1} could
// submit RuleCoverAll with Ignore{2: false}; the permission check treated 2 as
// denied (safe) while the runtime still monitored server 2.
func TestAlertRuleHasPermission_DeniesRuleCoverAllIgnoreFalseForLimitedPAT(t *testing.T) {
saved := OwnerServerIDsLookup
savedAdmin := OwnerIsAdminLookup
savedAll := AllServerIDsLookup
t.Cleanup(func() {
OwnerServerIDsLookup = saved
OwnerIsAdminLookup = savedAdmin
AllServerIDsLookup = savedAll
})
OwnerServerIDsLookup = func(uid uint64) []uint64 {
if uid == 100 {
return []uint64{1, 2}
}
return nil
}
OwnerIsAdminLookup = func(uid uint64) bool { return false }
AllServerIDsLookup = func() []uint64 { return []uint64{1, 2, 3} }
rule := &AlertRule{
Common: Common{ID: 13, UserID: 100},
Rules: []*Rule{{
Type: "cpu",
Cover: RuleCoverAll,
Ignore: map[uint64]bool{2: false}, // key present but NOT actually ignored at runtime
}},
}
ctx, _ := gin.CreateTestContext(httptest.NewRecorder())
ctx.Set(CtxKeyAuthorizedUser, &User{Common: Common{ID: 100}, Role: RoleMember})
ctx.Set(CtxKeyAPIToken, &stubPATAccessor{ids: []uint64{1}}) // doesn't cover 2
if rule.HasPermission(ctx) {
t.Fatal("Ignore{2:false} does NOT exclude server 2 at runtime; limited PAT must be denied")
}
}
// A genuine deny entry (value true) for every out-of-whitelist server keeps the
// rule contained and must remain allowed.
func TestAlertRuleHasPermission_AllowsRuleCoverAllIgnoreTrueCoversWhitelistGap(t *testing.T) {
saved := OwnerServerIDsLookup
savedAdmin := OwnerIsAdminLookup
savedAll := AllServerIDsLookup
t.Cleanup(func() {
OwnerServerIDsLookup = saved
OwnerIsAdminLookup = savedAdmin
AllServerIDsLookup = savedAll
})
OwnerServerIDsLookup = func(uid uint64) []uint64 { return []uint64{1, 2} }
OwnerIsAdminLookup = func(uid uint64) bool { return false }
AllServerIDsLookup = func() []uint64 { return []uint64{1, 2} }
rule := &AlertRule{
Common: Common{ID: 14, UserID: 100},
Rules: []*Rule{{
Type: "cpu",
Cover: RuleCoverAll,
Ignore: map[uint64]bool{2: true}, // server 2 genuinely excluded
}},
}
ctx, _ := gin.CreateTestContext(httptest.NewRecorder())
ctx.Set(CtxKeyAuthorizedUser, &User{Common: Common{ID: 100}, Role: RoleMember})
ctx.Set(CtxKeyAPIToken, &stubPATAccessor{ids: []uint64{1}})
if !rule.HasPermission(ctx) {
t.Fatal("Ignore{2:true} excludes the only out-of-whitelist server; PAT must be allowed")
}
}
// Regression: the RuleCoverIgnoreAll branch checked CanAccessServer for every
// key in Rule.Ignore, but Rule.Snapshot only monitors a server when
// Ignore[id] == true. A limited PAT whitelisted to {1} submitting
// RuleCoverIgnoreAll with Ignore{2: false} (server 2 is NOT monitored at
// runtime) was wrongly denied because the foreign key 2 failed the whitelist.
func TestAlertRuleHasPermission_AllowsRuleCoverIgnoreAllIgnoreFalseForLimitedPAT(t *testing.T) {
saved := OwnerServerIDsLookup
t.Cleanup(func() { OwnerServerIDsLookup = saved })
OwnerServerIDsLookup = func(uid uint64) []uint64 { return []uint64{1, 2} }
rule := &AlertRule{
Common: Common{ID: 15, UserID: 100},
Rules: []*Rule{{
Type: "cpu",
Cover: RuleCoverIgnoreAll,
Ignore: map[uint64]bool{2: false}, // key present but server 2 is NOT monitored
}},
}
ctx, _ := gin.CreateTestContext(httptest.NewRecorder())
ctx.Set(CtxKeyAuthorizedUser, &User{Common: Common{ID: 100}, Role: RoleMember})
ctx.Set(CtxKeyAPIToken, &stubPATAccessor{ids: []uint64{1}}) // doesn't cover 2
if !rule.HasPermission(ctx) {
t.Fatal("Ignore{2:false} does NOT monitor server 2 at runtime; limited PAT must remain allowed")
}
}
// The genuine allow entry (value true) for an out-of-whitelist server is the
// case that must still be denied.
func TestAlertRuleHasPermission_DeniesRuleCoverIgnoreAllIgnoreTrueForLimitedPAT(t *testing.T) {
saved := OwnerServerIDsLookup
t.Cleanup(func() { OwnerServerIDsLookup = saved })
OwnerServerIDsLookup = func(uid uint64) []uint64 { return []uint64{1, 2} }
rule := &AlertRule{
Common: Common{ID: 16, UserID: 100},
Rules: []*Rule{{
Type: "cpu",
Cover: RuleCoverIgnoreAll,
Ignore: map[uint64]bool{2: true}, // server 2 IS monitored at runtime
}},
}
ctx, _ := gin.CreateTestContext(httptest.NewRecorder())
ctx.Set(CtxKeyAuthorizedUser, &User{Common: Common{ID: 100}, Role: RoleMember})
ctx.Set(CtxKeyAPIToken, &stubPATAccessor{ids: []uint64{1}}) // doesn't cover 2
if rule.HasPermission(ctx) {
t.Fatal("Ignore{2:true} monitors server 2; server-limited PAT must be denied")
}
}
func TestAlertRuleHasPermission_NoPATPassesViaCommonHasPermission(t *testing.T) {
rule := &AlertRule{
Common: Common{ID: 12, UserID: 100},
Rules: []*Rule{{Type: "cpu", Cover: RuleCoverAll}},
}
ctx, _ := gin.CreateTestContext(httptest.NewRecorder())
ctx.Set(CtxKeyAuthorizedUser, &User{Common: Common{ID: 100}, Role: RoleMember})
if !rule.HasPermission(ctx) {
t.Fatal("owner without PAT must keep the existing owner/admin pass")
}
}