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
+34 -15
View File
@@ -21,8 +21,9 @@ import (
// List server
// @Summary List server
// @Security BearerAuth
// @Security APITokenAuth
// @Schemes
// @Description List server
// @Description List server. PAT scope required: nezha:server:read.
// @Tags auth required
// @Param id query uint false "Resource ID"
// @Produce json
@@ -196,11 +197,15 @@ func forceUpdateServer(c *gin.Context) (*model.ServerTaskResponse, error) {
forceUpdateResp.Offline = append(forceUpdateResp.Offline, sid)
continue
}
if stream := server.GetTaskStream(); stream != nil {
if err := stream.Send(&pb.Task{
if server.GetTaskStream() != nil {
if err := server.SendTask(&pb.Task{
Type: model.TaskTypeUpgrade,
}); err != nil {
forceUpdateResp.Failure = append(forceUpdateResp.Failure, sid)
if errors.Is(err, model.ErrTaskStreamOffline) {
forceUpdateResp.Offline = append(forceUpdateResp.Offline, sid)
} else {
forceUpdateResp.Failure = append(forceUpdateResp.Failure, sid)
}
} else {
forceUpdateResp.Success = append(forceUpdateResp.Success, sid)
}
@@ -232,18 +237,19 @@ func getServerConfig(c *gin.Context) (string, error) {
if !ok {
return "", nil
}
stream := s.GetTaskStream()
if stream == nil {
return "", nil
}
if !s.HasPermission(c) {
return "", singleton.Localizer.ErrorT("permission denied")
}
if s.GetTaskStream() == nil {
return "", nil
}
if err := stream.Send(&pb.Task{
if err := s.SendTask(&pb.Task{
Type: model.TaskTypeReportConfig,
}); err != nil {
if errors.Is(err, model.ErrTaskStreamOffline) {
return "", nil
}
return "", err
}
@@ -308,21 +314,23 @@ func setServerConfig(c *gin.Context) (*model.ServerTaskResponse, error) {
go func(srvGroup []*model.Server) {
defer wg.Done()
for _, s := range srvGroup {
// Create and send the task.
task := &pb.Task{
Type: model.TaskTypeApplyConfig,
Data: configForm.Config,
}
stream := s.GetTaskStream()
if stream == nil {
if s.GetTaskStream() == nil {
respMu.Lock()
resp.Offline = append(resp.Offline, s.ID)
respMu.Unlock()
continue
}
if err := stream.Send(task); err != nil {
if err := s.SendTask(task); err != nil {
respMu.Lock()
resp.Failure = append(resp.Failure, s.ID)
if errors.Is(err, model.ErrTaskStreamOffline) {
resp.Offline = append(resp.Offline, s.ID)
} else {
resp.Failure = append(resp.Failure, s.ID)
}
respMu.Unlock()
continue
}
@@ -393,6 +401,17 @@ func batchMoveServer(c *gin.Context) ([]model.BatchMoveServerResult, error) {
continue
}
// PAT server_ids 白名单优先于 admin/owner 早返回:admin 给自己签发的
// 限定 server_ids PAT 必须只能 move 白名单内 server。前面的 admin/owner
// 检查只看 currentOwner,不会触达白名单,这里显式补一道。返回
// ServerNotFound 与未知/外部 server 的语义对齐,避免泄露白名单外
// server 是否存在。
if !patAllowsServer(c, sid) {
res.Status = model.BatchMoveServerResultServerNotFound
results = append(results, res)
continue
}
// Per-server permission: admin or current owner. We do NOT use the
// bulk CheckPermission because we want a partial-success response
// rather than rejecting the whole batch on the first unauthorized id.