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:
naiba
2026-05-30 15:56:44 +00:00
co-authored by cloudcode
parent 029695344c
commit e8dabf5bc6
153 changed files with 16974 additions and 244 deletions
+82
View File
@@ -0,0 +1,82 @@
package model
import (
"slices"
"testing"
)
// 这些测试约束「scope 命名统一」契约:
// - 只有 nezha:* 一套是 first-class scope
// - mcp:* 不再作为 HasScope 的别名(避免 mcp:fs:write 静默扩到 REST 的 nezha:server:write);
// - AllScopes / AdminOnlyScopes 不再包含 mcp:*,新建 token 不能再签发它们。
//
// 旧 mcp:* 兼容由 createAPIToken 入口做一次性归一化(mcp:fs:read 等只读/exec 映射到
// 对应的 nezha:* read/exec),但 write/delete 类不再映射;详见 controller.createAPIToken。
func TestAllScopes_DoesNotExposeLegacyMCPScopes(t *testing.T) {
legacy := []string{
"mcp:*",
"mcp:server:read",
"mcp:server:exec",
"mcp:fs:read",
"mcp:fs:write",
"mcp:fs:delete",
}
for _, s := range legacy {
if slices.Contains(AllScopes, s) {
t.Errorf("AllScopes must not advertise legacy scope %q; only nezha:* is first-class", s)
}
if slices.Contains(AdminOnlyScopes, s) {
t.Errorf("AdminOnlyScopes must not advertise legacy scope %q", s)
}
}
}
func TestHasScope_LegacyMCPNoLongerAliasesNezhaWrite(t *testing.T) {
// 旧 token 数据库里残留 mcp:fs:write,绝不允许覆盖 REST 的 nezha:server:write。
tok := &APIToken{ScopesCSV: "mcp:fs:write"}
if tok.HasScope(ScopeServerWrite) {
t.Fatalf("legacy mcp:fs:write must NOT grant nezha:server:write via HasScope; " +
"REST routes (server/config, server/:id, batch-delete/server) would become reachable")
}
if tok.HasScope(ScopeServerDelete) {
t.Fatalf("legacy mcp:fs:write must NOT grant nezha:server:delete")
}
}
func TestHasScope_LegacyMCPDeleteNoLongerAliasesNezhaDelete(t *testing.T) {
tok := &APIToken{ScopesCSV: "mcp:fs:delete"}
if tok.HasScope(ScopeServerDelete) {
t.Fatalf("legacy mcp:fs:delete must NOT grant nezha:server:delete via HasScope")
}
}
func TestHasScope_LegacyMCPAllNoLongerWildcards(t *testing.T) {
tok := &APIToken{ScopesCSV: "mcp:*"}
for _, s := range []string{ScopeServerRead, ScopeServerWrite, ScopeServerDelete, ScopeServerExec} {
if tok.HasScope(s) {
t.Errorf("legacy mcp:* must not be treated as a nezha:* wildcard; granted %s", s)
}
}
}
func TestHasScope_NezhaWildcardStillWorks(t *testing.T) {
tok := &APIToken{ScopesCSV: ScopeNezhaAll}
for _, s := range []string{ScopeServerRead, ScopeServerWrite, ScopeServerDelete, ScopeServerExec} {
if !tok.HasScope(s) {
t.Errorf("nezha:* wildcard must still cover %s", s)
}
}
}
func TestHasScope_NezhaResourceWildcardStillWorks(t *testing.T) {
tok := &APIToken{ScopesCSV: "nezha:server:*"}
for _, s := range []string{ScopeServerRead, ScopeServerWrite, ScopeServerDelete, ScopeServerExec} {
if !tok.HasScope(s) {
t.Errorf("nezha:server:* must cover %s", s)
}
}
if tok.HasScope(ScopeServiceRead) {
t.Fatalf("nezha:server:* must NOT leak into nezha:service:* family")
}
}