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 58c1ea7b0b
commit ab25662ddd
153 changed files with 16974 additions and 244 deletions
+49 -1
View File
@@ -2,11 +2,13 @@ package controller
import (
"errors"
"log"
"strings"
"github.com/gin-gonic/gin"
"github.com/nezhahq/nezha/model"
"github.com/nezhahq/nezha/service/rpc"
"github.com/nezhahq/nezha/service/singleton"
)
@@ -107,8 +109,15 @@ func updateConfig(c *gin.Context) (any, error) {
singleton.Conf.AgentRealIPHeader = sf.AgentRealIPHeader
singleton.Conf.AgentTLS = sf.AgentTLS
singleton.Conf.UserTemplate = sf.UserTemplate
mcpWasEnabled := singleton.Conf.MCPEnabled()
mcpNext := resolveSettingEnableMCP(sf.EnableMCP, mcpWasEnabled)
if err := singleton.Conf.Save(); err != nil {
if err := applyEnableMCPTransition(
mcpWasEnabled, mcpNext,
singleton.Conf.SetMCPEnabled,
singleton.Conf.Save,
fireMCPKillSwitch,
); err != nil {
return nil, newGormError("%v", err)
}
@@ -116,6 +125,45 @@ func updateConfig(c *gin.Context) (any, error) {
return nil, nil
}
// applyEnableMCPTransition commits the new EnableMCP value and persists it,
// guaranteeing the in-memory flag and the kill-switch cleanup stay consistent
// with what actually reached durable storage:
// - setVal(next) is applied so Save serialises the new value.
// - If save fails, the flag is rolled back to prev and no cleanup runs, so a
// failed disable cannot leave the dashboard half-disabled (new requests
// rejected while in-flight RPC/streams/URLs are never revoked).
// - cleanup runs only on a persisted enabled->disabled transition.
func applyEnableMCPTransition(prev, next bool, setVal func(bool), save func() error, cleanup func()) error {
setVal(next)
if err := save(); err != nil {
setVal(prev)
return err
}
if prev && !next {
cleanup()
}
return nil
}
func fireMCPKillSwitch() {
purgedURLs := PurgeTransferEntries()
revokedStreams := rpc.NezhaHandlerSingleton.RevokeStreamsForPurpose(rpc.PurposeMCPTransfer)
cancelledRPC := rpc.CancelAllMCPInflight()
log.Printf("NEZHA>> MCP kill switch fired: purged=%d urls, revoked=%d streams, cancelled=%d rpc",
purgedURLs, revokedStreams, cancelledRPC)
}
// resolveSettingEnableMCP picks the effective EnableMCP value for the
// update. A nil form pointer means "field absent" so we MUST preserve
// the current config to avoid accidentally tripping the kill switch on
// partial PATCH calls that omit enable_mcp.
func resolveSettingEnableMCP(formValue *bool, current bool) bool {
if formValue == nil {
return current
}
return *formValue
}
// Perform maintenance
// @Summary Perform maintenance
// @Security BearerAuth