mirror of
https://github.com/Buriburizaem0n/nezha_domains.git
synced 2026-09-19 09:40:12 +00:00
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>
This commit is contained in:
@@ -0,0 +1,102 @@
|
||||
package model
|
||||
|
||||
import (
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// stubPATAccessor 是只在测试里用的最小 APITokenAccessor,仅按 ids
|
||||
// 字面包含判断。够用就行,不引入 *APIToken 在 model 包里转译 CSV。
|
||||
type stubPATAccessor struct {
|
||||
ids []uint64
|
||||
}
|
||||
|
||||
func (s *stubPATAccessor) CanAccessServer(id uint64) bool {
|
||||
for _, x := range s.ids {
|
||||
if x == id {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// ServerIDs 暴露白名单,使 DenyListSafeForLimitedPAT 能区分「unscoped PAT」
|
||||
// 与「server-limited PAT」;缺这个方法时所有 stub 都会被当作不受限放行。
|
||||
func (s *stubPATAccessor) ServerIDs() []uint64 {
|
||||
return s.ids
|
||||
}
|
||||
|
||||
// 钉死「server-limited PAT 不能通过 cover-all + 空 Servers 越过白名单」。
|
||||
// 老实现在 len(c.Servers)==0 时直接放行,但 CronCoverAll + 空 Servers 在
|
||||
// CronTrigger 里会 fan out 到 owner 的所有 server(包含白名单外的)。
|
||||
// HasPermission 是 cron 列表/手动触发/删除路径上唯一的 PAT 收口,
|
||||
// 因此这里必须拒绝。
|
||||
func TestCronHasPermission_DeniesCoverAllEmptyServersForLimitedPAT(t *testing.T) {
|
||||
cron := &Cron{
|
||||
Common: Common{ID: 9, UserID: 100},
|
||||
Cover: CronCoverAll,
|
||||
Servers: nil,
|
||||
}
|
||||
|
||||
ctx, _ := gin.CreateTestContext(httptest.NewRecorder())
|
||||
ctx.Set(CtxKeyAuthorizedUser, &User{Common: Common{ID: 100}, Role: RoleMember})
|
||||
ctx.Set(CtxKeyAPIToken, &stubPATAccessor{ids: []uint64{1}})
|
||||
|
||||
if cron.HasPermission(ctx) {
|
||||
t.Fatal("server-limited PAT must not be allowed to operate on a CronCoverAll cron with empty Servers")
|
||||
}
|
||||
}
|
||||
|
||||
// CoverIgnoreAll + 空 Servers 在 CronTrigger 里是 “allow-list of zero”,
|
||||
// 不会 fan out。允许 PAT 继续看到/触发是无害的,但 HasPermission 的
|
||||
// 老语义在这一组合下仍是 return true,所以这条测试是「保持现状」的金线,
|
||||
// 防止未来收紧时把这一无害情况也误拒。
|
||||
func TestCronHasPermission_AllowsCoverIgnoreAllEmptyServersForLimitedPAT(t *testing.T) {
|
||||
cron := &Cron{
|
||||
Common: Common{ID: 10, UserID: 100},
|
||||
Cover: CronCoverIgnoreAll,
|
||||
Servers: nil,
|
||||
}
|
||||
|
||||
ctx, _ := gin.CreateTestContext(httptest.NewRecorder())
|
||||
ctx.Set(CtxKeyAuthorizedUser, &User{Common: Common{ID: 100}, Role: RoleMember})
|
||||
ctx.Set(CtxKeyAPIToken, &stubPATAccessor{ids: []uint64{1}})
|
||||
|
||||
if !cron.HasPermission(ctx) {
|
||||
t.Fatal("CronCoverIgnoreAll + empty Servers is a no-op cron; server-limited PAT must remain allowed")
|
||||
}
|
||||
}
|
||||
|
||||
// 现有 non-empty Servers 路径必须保持不变:白名单内允许、白名单外拒绝。
|
||||
// 这条用例钉死「修复 cover-all 路径时不能误改这条已有的金线」。
|
||||
func TestCronHasPermission_KeepsExistingNonEmptyServersSemantics(t *testing.T) {
|
||||
t.Run("whitelisted", func(t *testing.T) {
|
||||
cron := &Cron{
|
||||
Common: Common{ID: 11, UserID: 100},
|
||||
Cover: CronCoverIgnoreAll,
|
||||
Servers: []uint64{1},
|
||||
}
|
||||
ctx, _ := gin.CreateTestContext(httptest.NewRecorder())
|
||||
ctx.Set(CtxKeyAuthorizedUser, &User{Common: Common{ID: 100}, Role: RoleMember})
|
||||
ctx.Set(CtxKeyAPIToken, &stubPATAccessor{ids: []uint64{1}})
|
||||
if !cron.HasPermission(ctx) {
|
||||
t.Fatal("cron bound to whitelisted server 1 must remain accessible to PAT [1]")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("outside whitelist", func(t *testing.T) {
|
||||
cron := &Cron{
|
||||
Common: Common{ID: 12, UserID: 100},
|
||||
Cover: CronCoverIgnoreAll,
|
||||
Servers: []uint64{2},
|
||||
}
|
||||
ctx, _ := gin.CreateTestContext(httptest.NewRecorder())
|
||||
ctx.Set(CtxKeyAuthorizedUser, &User{Common: Common{ID: 100}, Role: RoleMember})
|
||||
ctx.Set(CtxKeyAPIToken, &stubPATAccessor{ids: []uint64{1}})
|
||||
if cron.HasPermission(ctx) {
|
||||
t.Fatal("cron bound to non-whitelisted server 2 must be rejected for PAT [1]")
|
||||
}
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user