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
+168 -5
View File
@@ -1,11 +1,14 @@
package model
import (
"errors"
"log"
"slices"
"sync"
"sync/atomic"
"time"
"github.com/gin-gonic/gin"
"github.com/goccy/go-json"
"gorm.io/gorm"
@@ -38,7 +41,12 @@ type Server struct {
// handler that reassigns the stream on every reconnect — a torn read of the
// two-word interface header would panic on a subsequent .Send call. The
// atomic.Pointer + holder struct lets us swap the stream lock-free while
// every reader observes a single, consistent value.
// every reader observes a single, consistent value. The holder also carries
// the send mutex so CopyFromRunningServer can share it across the old/new
// *Server objects that briefly co-exist during edit/transfer rotations —
// otherwise two *Server pointers would hold the same gRPC stream behind
// two independent mutexes, defeating the "one SendMsg goroutine per stream"
// invariant grpc-go requires.
taskStream atomic.Pointer[taskStreamHolder]
ConfigCache chan any `gorm:"-" json:"-"`
@@ -51,8 +59,14 @@ type Server struct {
// field `TaskStream pb.NezhaService_RequestTaskServer` was a plain interface
// value: two words on the heap (type ptr + data ptr). Concurrent assignment
// produced torn reads detectable by `go test -race` and crashable in production.
//
// sendMu lives on the holder (not on *Server) so it is bound to the stream
// itself: CopyFromRunningServer shares the same holder pointer with the new
// *Server, and SendTask locks via the holder, guaranteeing serialized SendMsg
// even when old/new *Server objects briefly co-exist during edit/transfer.
type taskStreamHolder struct {
s pb.NezhaService_RequestTaskServer
s pb.NezhaService_RequestTaskServer
sendMu sync.Mutex
}
// SetTaskStream publishes the agent's RequestTask stream so other goroutines
@@ -65,6 +79,13 @@ func (s *Server) SetTaskStream(stream pb.NezhaService_RequestTaskServer) {
s.taskStream.Store(&taskStreamHolder{s: stream})
}
// adoptTaskStreamHolder publishes an existing holder verbatim. Used by
// CopyFromRunningServer so the new *Server shares the send mutex (and the
// underlying stream identity) with the old *Server.
func (s *Server) adoptTaskStreamHolder(h *taskStreamHolder) {
s.taskStream.Store(h)
}
// ClearTaskStreamIfCurrent detaches stream only if it is still the published
// RequestTask stream. Disconnect cleanup uses this guard so an old stream
// returning after a reconnect cannot erase the newer live stream.
@@ -95,6 +116,31 @@ func (s *Server) GetTaskStream() pb.NezhaService_RequestTaskServer {
return h.s
}
// SendTask dispatches a task on the agent's RequestTask stream under the
// holder's sendMu so concurrent dispatchers (cron, server-transfer
// ApplyConfig, MCP CallAgent, MCP fs.transfer, force-update, report-config)
// cannot violate grpc-go's "one SendMsg goroutine per stream" rule. Returns
// ErrTaskStreamOffline if the agent has not published a stream yet; callers
// that need to distinguish offline from send failure should branch on that.
//
// The mutex is keyed by holder (= by stream) rather than by *Server so that
// edit/transfer rotations replacing *Server in the singleton map still share
// a single lock across the old and new objects pointing at the same stream.
func (s *Server) SendTask(task *pb.Task) error {
h := s.taskStream.Load()
if h == nil {
return ErrTaskStreamOffline
}
h.sendMu.Lock()
defer h.sendMu.Unlock()
return h.s.Send(task)
}
// ErrTaskStreamOffline is returned by SendTask when the agent has no
// published RequestTask stream. Defined here (rather than in service/rpc)
// so model-layer callers can branch on it without an import cycle.
var ErrTaskStreamOffline = errors.New("agent task stream offline")
func InitServer(s *Server) {
s.Host = &Host{}
s.State = &HostState{}
@@ -107,9 +153,12 @@ func (s *Server) CopyFromRunningServer(old *Server) {
s.State = old.State
s.GeoIP = old.GeoIP
s.LastActive = old.LastActive
// taskStream is an atomic.Pointer; copy the published value rather than
// the field itself (atomic.Pointer is not safe to copy by value).
s.SetTaskStream(old.GetTaskStream())
// Adopt the holder pointer verbatim so the new *Server shares the send
// mutex AND the stream identity with the old *Server; constructing a fresh
// holder via SetTaskStream(GetTaskStream()) would give the new object its
// own mutex, letting two *Server pointers race SendMsg on the same stream
// during the edit/transfer rotation window.
s.adoptTaskStreamHolder(old.taskStream.Load())
s.ConfigCache = old.ConfigCache
s.PrevTransferInSnapshot = old.PrevTransferInSnapshot
s.PrevTransferOutSnapshot = old.PrevTransferOutSnapshot
@@ -147,6 +196,39 @@ type ServerOwnerInfo struct {
// in tests / headless contexts so the JSON simply omits the owner field.
var ServerOwnerLookup func(uid uint64) (ServerOwnerInfo, bool)
// OwnerServerIDsLookup is installed by singleton at startup to enumerate the
// IDs of every in-memory Server whose UserID == ownerUID. It exists so that
// Cron.HasPermission / Service.HasPermission can faithfully replay the
// dispatch-side "CoverAll deny-list must cover every PAT-whitelisted-out
// owner server" rule without depending on controller helpers (model must
// not import service/singleton — cycle).
//
// Left nil in tests / headless contexts; callers MUST treat a nil hook as
// "unknown owner topology" and fall back to a conservative decision (the
// existing model.Cron / model.Service code rejects non-trivial CoverAll
// configs for limited PATs when the hook is nil, matching the historical
// behaviour for empty deny-lists).
var OwnerServerIDsLookup func(ownerUID uint64) []uint64
// OwnerIsAdminLookup reports whether ownerUID is an admin user. When the
// owner is admin the runtime dispatch path (CronTrigger, DispatchTask) gates
// on userIsAdmin(cr.UserID) / userIsAdmin(svc.UserID) and fans out across
// EVERY in-memory server — not just the owner's. DenyListSafeForLimitedPAT
// must mirror that fan-out widening or a limited PAT can pass safety check
// with a deny-list that covers only the admin's own servers while the
// runtime still ships the task to foreign-owned servers.
//
// Left nil in tests / headless contexts; callers fall back to
// "owner-set only" which matches the pre-C1 behaviour.
var OwnerIsAdminLookup func(ownerUID uint64) bool
// AllServerIDsLookup returns every in-memory server ID, regardless of
// owner. It is the system-wide fan-out set the runtime uses for
// admin-owned CoverAll cron/service dispatch and is the only correct
// containment set for a server-limited PAT operating on an admin-owned
// resource. Left nil in tests / headless contexts.
var AllServerIDsLookup func() []uint64
type serverJSON Server
type serverWithOwner struct {
@@ -175,6 +257,87 @@ func (s *Server) MarshalJSON() ([]byte, error) {
})
}
func (s *Server) HasPermission(ctx *gin.Context) bool {
if !s.Common.HasPermission(ctx) {
return false
}
v, ok := ctx.Get(CtxKeyAPIToken)
if !ok {
return true
}
tok, ok := v.(APITokenAccessor)
if !ok || tok == nil {
return true
}
return tok.CanAccessServer(s.GetID())
}
// APITokenWhitelistView is the optional shape an APITokenAccessor can
// implement so DenyListSafeForLimitedPAT can tell unscoped PATs (no
// whitelist → not limited) apart from server-limited ones. Accessors that
// do NOT expose ServerIDs() are treated as potentially limited; the safe
// dispatch path then requires denyList to cover every owner-visible server
// outside what the PAT can reach.
type APITokenWhitelistView interface {
ServerIDs() []uint64
}
// DenyListSafeForLimitedPAT reports whether a CoverAll/SkipServers deny-list
// keeps a server-limited PAT inside its server_ids whitelist. The runtime
// dispatch path (CronTrigger, DispatchTask) iterates every owner-visible
// server minus denyList; for the PAT to stay contained, every owner server
// outside its whitelist must already appear in denyList. JWT requests and
// PATs with no whitelist are unaffected. Nil OwnerServerIDsLookup forces
// the conservative "reject" branch instead of silently allowing a config
// the runtime would dispatch outside the whitelist.
func DenyListSafeForLimitedPAT(tok APITokenAccessor, ownerUID uint64, denyServers []uint64) bool {
if tok == nil {
return true
}
if wl, ok := tok.(APITokenWhitelistView); ok && len(wl.ServerIDs()) == 0 {
return true
}
fanout := ownerEffectiveFanoutServerIDs(ownerUID)
if fanout == nil {
return false
}
denySet := make(map[uint64]struct{}, len(denyServers))
for _, id := range denyServers {
denySet[id] = struct{}{}
}
for _, id := range fanout {
if tok.CanAccessServer(id) {
continue
}
if _, denied := denySet[id]; !denied {
return false
}
}
return true
}
// ownerEffectiveFanoutServerIDs returns the server set the runtime dispatch
// will actually fan out to for a resource owned by ownerUID. Admin owners
// short-circuit cronCanSendToServer / canSendServiceTask via userIsAdmin,
// so the safe containment set is the WHOLE system, not just the admin's
// own servers. Member owners stay bounded to their own server set.
//
// Returns nil to signal "topology unknown" — callers (DenyListSafeForLimitedPAT)
// fall back to fail-closed in that case, matching the historical conservative
// branch when OwnerServerIDsLookup was nil.
func ownerEffectiveFanoutServerIDs(ownerUID uint64) []uint64 {
if OwnerIsAdminLookup != nil && OwnerIsAdminLookup(ownerUID) {
if AllServerIDsLookup == nil {
return nil
}
return AllServerIDsLookup()
}
if OwnerServerIDsLookup == nil {
return nil
}
return OwnerServerIDsLookup(ownerUID)
}
func (s *Server) SplitList(x []*Server) ([]*Server, []*Server) {
pri := func(s *Server) bool {
return s.DisplayIndex == 0