mirror of
https://github.com/Buriburizaem0n/nezha_domains.git
synced 2026-09-19 09:40:12 +00:00
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>
228 lines
8.1 KiB
Go
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")
|
|
}
|
|
}
|