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

113 lines
3.3 KiB
Go

package model
import (
"strings"
"testing"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
)
func newMigrationTestDB(t *testing.T) *gorm.DB {
t.Helper()
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
if err != nil {
t.Fatalf("open db: %v", err)
}
if err := db.AutoMigrate(&APIToken{}); err != nil {
t.Fatalf("migrate: %v", err)
}
return db
}
func TestNormalizeIncomingScope_RewritesReadOnlyMCPVariants(t *testing.T) {
cases := map[string]string{
"mcp:fs:read": ScopeServerRead,
"mcp:server:read": ScopeServerRead,
"mcp:server:exec": ScopeServerExec,
}
for in, want := range cases {
got, ok := NormalizeIncomingScope(in)
if !ok {
t.Fatalf("NormalizeIncomingScope(%q) ok=false; legacy read/exec must remain creatable", in)
}
if got != want {
t.Fatalf("NormalizeIncomingScope(%q) = %q, want %q", in, got, want)
}
}
}
func TestNormalizeIncomingScope_RejectsDangerousLegacyVariants(t *testing.T) {
for _, in := range []string{"mcp:fs:write", "mcp:fs:delete", "mcp:*", "mcp:unknown"} {
if got, ok := NormalizeIncomingScope(in); ok {
t.Errorf("NormalizeIncomingScope(%q) = (%q, true); legacy write/delete/wildcard must be rejected", in, got)
}
}
}
func TestNormalizeIncomingScope_PassesThroughNezhaScopes(t *testing.T) {
got, ok := NormalizeIncomingScope(ScopeServerWrite)
if !ok || got != ScopeServerWrite {
t.Fatalf("nezha:* must pass through unchanged: got (%q, %v)", got, ok)
}
}
func TestMigrateLegacyMCPScopes_RewritesReadOnlyAndDropsDangerous(t *testing.T) {
db := newMigrationTestDB(t)
tokens := []APIToken{
{UserID: 1, Name: "read-only", TokenHash: HashAPIToken("nzp_a"), ScopesCSV: "mcp:fs:read"},
{UserID: 2, Name: "mixed", TokenHash: HashAPIToken("nzp_b"), ScopesCSV: "mcp:server:read,mcp:fs:write"},
{UserID: 3, Name: "purely-dangerous", TokenHash: HashAPIToken("nzp_c"), ScopesCSV: "mcp:fs:write,mcp:*"},
{UserID: 4, Name: "modern", TokenHash: HashAPIToken("nzp_d"), ScopesCSV: ScopeServerRead},
}
for i := range tokens {
if err := db.Create(&tokens[i]).Error; err != nil {
t.Fatalf("seed token %d: %v", i, err)
}
}
rewritten, deleted, err := MigrateLegacyMCPScopes(db)
if err != nil {
t.Fatalf("MigrateLegacyMCPScopes: %v", err)
}
if rewritten < 2 {
t.Fatalf("expected >=2 rewrites (read-only + mixed); got %d", rewritten)
}
if deleted != 1 {
t.Fatalf("expected 1 deleted token (purely-dangerous); got %d", deleted)
}
var got []APIToken
if err := db.Order("id ASC").Find(&got).Error; err != nil {
t.Fatalf("reload: %v", err)
}
if len(got) != 3 {
t.Fatalf("expected 3 surviving tokens; got %d", len(got))
}
for _, tok := range got {
if strings.Contains(tok.ScopesCSV, "mcp:") {
t.Fatalf("token %d still carries legacy scope after migration: %q", tok.ID, tok.ScopesCSV)
}
}
for _, tok := range got {
switch tok.UserID {
case 1:
if tok.ScopesCSV != ScopeServerRead {
t.Fatalf("uid=1 expected %q, got %q", ScopeServerRead, tok.ScopesCSV)
}
case 2:
if tok.ScopesCSV != ScopeServerRead {
t.Fatalf("uid=2 should keep only the safe read scope after drop; got %q", tok.ScopesCSV)
}
case 4:
if tok.ScopesCSV != ScopeServerRead {
t.Fatalf("uid=4 already-modern token must be untouched; got %q", tok.ScopesCSV)
}
default:
t.Fatalf("unexpected surviving token uid=%d", tok.UserID)
}
}
}