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
+458 -6
View File
@@ -1,12 +1,31 @@
package controller
import (
"slices"
"github.com/gin-gonic/gin"
"github.com/nezhahq/nezha/model"
"github.com/nezhahq/nezha/service/rpc"
"github.com/nezhahq/nezha/service/singleton"
)
// streamAttachAllowedForRequest combines the existing creator/admin check
// with a per-request PAT whitelist gate against the stream's target server.
// Terminal and FM endpoints attach to a long-lived stream and inherit any
// authority the creator held — without the second gate an admin's PAT
// scoped to [X] could hijack a stream targeting server Y.
func streamAttachAllowedForRequest(c *gin.Context, streamId string) bool {
if !rpc.NezhaHandlerSingleton.IsStreamAuthorizedForUser(streamId, getUid(c), callerIsAdmin(c)) {
return false
}
target, ok := rpc.NezhaHandlerSingleton.StreamTarget(streamId)
if !ok {
return false
}
return patAllowsServer(c, target)
}
func callerIsAdmin(c *gin.Context) bool {
auth, ok := c.Get(model.CtxKeyAuthorizedUser)
if !ok {
@@ -19,10 +38,441 @@ func callerIsAdmin(c *gin.Context) bool {
return user.Role.IsAdmin()
}
// patAllowsServer reports whether the caller's PAT (if any) is allowed to
// touch serverID. JWT callers (no PAT in context) always pass. Used as an
// extra guard before the admin / owner short-circuits so a PAT scoped to
// a server_ids whitelist cannot widen reach via the caller's admin role.
func patAllowsServer(c *gin.Context, serverID uint64) bool {
v, ok := c.Get(model.CtxKeyAPIToken)
if !ok {
return true
}
tok, _ := v.(model.APITokenAccessor)
if tok == nil {
return true
}
return tok.CanAccessServer(serverID)
}
// patHasServerWhitelist reports whether the caller is authenticated by a PAT
// that carries a non-empty server_ids whitelist. Cover-all semantics in
// Cron (CronCoverAll / CronCoverIgnoreAll-with-empty-Servers) and Service
// (ServiceCoverAll-with-empty-SkipServers) intentionally fan out to every
// server the cron/service's owner has — so a whitelisted PAT cannot create
// or update such configs without escaping its own whitelist. JWT callers
// and unscoped PATs have no whitelist to escape and pass through.
//
// This is the gate that turns the implicit-cover bypass at
// /api/v1/{cron,service} POST/PATCH into a 403; the dispatch side
// (CronTrigger, DispatchTask) does not re-check PAT context, so the only
// safe place to enforce it is at write time.
func patHasServerWhitelist(c *gin.Context) bool {
v, ok := c.Get(model.CtxKeyAPIToken)
if !ok {
return false
}
wl, ok := v.(model.APITokenWhitelistView)
if !ok || wl == nil {
return false
}
return len(wl.ServerIDs()) > 0
}
// patAccessorFromContext returns the request's PAT viewed as an
// APITokenAccessor, or nil for JWT requests. Routes that need to project
// server-keyed data through the PAT whitelist (server-group, ws/server,
// future stream/list endpoints) use this instead of poking c.Get directly.
func patAccessorFromContext(c *gin.Context) model.APITokenAccessor {
v, ok := c.Get(model.CtxKeyAPIToken)
if !ok {
return nil
}
tok, _ := v.(model.APITokenAccessor)
if tok == nil {
return nil
}
return tok
}
// checkCronServerListPermission validates the cron's Servers field. Under
// CronCoverIgnoreAll / CronCoverAlertTrigger the field is an allow-list and
// must satisfy Server.HasPermission (owner + PAT whitelist). Under
// CronCoverAll the field is a deny-list expressing exclusion; the caller
// only needs to own each listed server (PAT whitelist intersection is
// enforced separately by assertPATCoverFanoutWithinWhitelist).
func checkCronServerListPermission(c *gin.Context, cover uint8, servers []uint64, ownerUID uint64) error {
if cover == model.CronCoverAll {
denySet := make(map[uint64]bool, len(servers))
for _, id := range servers {
denySet[id] = true
}
if !denyListOwnedByCaller(ownerUID, denySet) {
return singleton.Localizer.ErrorT("permission denied")
}
return nil
}
if !singleton.ServerShared.CheckPermission(c, slices.Values(servers)) {
return singleton.Localizer.ErrorT("permission denied")
}
return nil
}
// checkServiceSkipServerPermission is the service-monitor analogue.
// ServiceCoverAll → SkipServers is a deny-set, only ownership required.
// ServiceCoverIgnoreAll → SkipServers is an allow-set, full Server.HasPermission.
//
// Runtime DispatchTask + skipServersToDenyList only consult entries whose
// bool value is true; false entries are no-ops. Filtering to true-only
// here keeps the write-side permission check aligned with the runtime
// fan-out (a member touching `{2: false}` for a foreign-owned server 2
// has no dispatch effect, so rejecting the request is over-restrictive
// and inconsistent with what listing / runtime see).
func checkServiceSkipServerPermission(c *gin.Context, cover uint8, skip map[uint64]bool, ownerUID uint64) error {
effective := make(map[uint64]bool, len(skip))
for id, enabled := range skip {
if enabled {
effective[id] = true
}
}
if cover == model.ServiceCoverAll {
if !denyListOwnedByCaller(ownerUID, effective) {
return singleton.Localizer.ErrorT("permission denied")
}
return nil
}
ids := make([]uint64, 0, len(effective))
for id := range effective {
ids = append(ids, id)
}
if !singleton.ServerShared.CheckPermission(c, slices.Values(ids)) {
return singleton.Localizer.ErrorT("permission denied")
}
return nil
}
// denyListOwnedByCaller verifies every id in denyList refers to a server
// owned by ownerUID. Under *CoverAll the deny-list expresses exclusion, not
// access, so it must not point at someone else's servers.
//
// Admin owners are special: runtime CronTrigger / DispatchTask fans out
// across the WHOLE system via userIsAdmin(owner), so a safe deny-list for
// an admin-owned resource must be allowed to include foreign-owned servers
// — that's the only way a limited PAT can contain the fan-out. We still
// require each id to refer to a real server, just not to be owned by the
// admin specifically.
func denyListOwnedByCaller(ownerUID uint64, denyList map[uint64]bool) bool {
ownerIsAdmin := model.OwnerIsAdminLookup != nil && model.OwnerIsAdminLookup(ownerUID)
for id := range denyList {
s, found := singleton.ServerShared.Get(id)
if !found || s == nil {
return false
}
if ownerIsAdmin {
continue
}
if s.GetUserID() != ownerUID {
return false
}
}
return true
}
// denyListCoversAllOwnerServersOutsidePATWhitelist reports whether every
// server visible to the cron/service owner that is NOT in the caller PAT's
// server_ids whitelist also appears in denyList. Under *CoverAll semantics
// the runtime dispatch (CronTrigger / DispatchTask) fans out to ServerShared
// minus denyList; the only way a server-limited PAT can stay inside its
// whitelist is if denyList already covers every owner-visible server outside
// that whitelist. Returning true means the configuration is safe.
func denyListCoversAllOwnerServersOutsidePATWhitelist(c *gin.Context, ownerUID uint64, denyList map[uint64]bool) bool {
tok := patAccessorFromContext(c)
if tok == nil {
return true
}
denyIDs := make([]uint64, 0, len(denyList))
for id, mark := range denyList {
if mark {
denyIDs = append(denyIDs, id)
}
}
return model.DenyListSafeForLimitedPAT(tok, ownerUID, denyIDs)
}
// coverMode 抽象「cover 字段在 dispatch 时如何解读 servers 字段」。
//
// 写侧 rejectImplicit* 与运行时 manual/batch-delete 入口共用同一条 PAT 收口
// 路径(assertPATCoverFanoutWithinWhitelist),靠它把两边的规则对齐。新增任
// 何带 cover 概念的资源时,只需在自己的资源专用入口里把 Cover 枚举翻译成
// 这三档之一即可。
type coverMode uint8
const (
// coverModePinnedByCaller: dispatch 阶段不按 servers 字段做 fan-out
// 真实目标在 fire 时由外部信号(如告警触发者 server)钉死。代表:
// CronCoverAlertTrigger。PAT 在这里不做额外收口。
coverModePinnedByCaller coverMode = iota
// coverModeAllMinusDeny: dispatch 时取 owner 全量 server 集合,再减去
// serversdeny-list)。代表 CronCoverAll / ServiceCoverAll。受限 PAT
// 必须确保 deny-list 已覆盖白名单外的全部 owner servers,否则 fan-out
// 会跑到 PAT 白名单之外。
coverModeAllMinusDeny
// coverModeAllowList: dispatch 时只在 serversallow-list)内 fan-out。
// 代表 CronCoverIgnoreAll / ServiceCoverIgnoreAll。受限 PAT 必须能访
// 问 allow-list 中的每一个 server。空 allow-list 是「matches nothing」
// 的退化形态,安全。
coverModeAllowList
)
// assertPATCoverFanoutWithinWhitelist 是 cover-all / cover-ignore-all 两类
// 「按 owner 全量 fan-out」资源的 PAT 收口。
//
// 任何会按「owner servers 减 denyList」或「allowList 自身」展开的资源都必须
// 在 dispatch 入口(manual 触发 / batch-delete / mutation)调用它;写侧
// rejectImplicit* 也走同一条路径,从根上保证两边不漂移。
//
// JWT 请求或不带 server 白名单的 PAT 直接放行——它们没有「白名单」可越过。
//
// 失败时统一返回 i18n "permission denied",与既有写侧 guard 行为一致。
func assertPATCoverFanoutWithinWhitelist(c *gin.Context, ownerUID uint64, mode coverMode, servers []uint64) error {
if !patHasServerWhitelist(c) {
return nil
}
switch mode {
case coverModePinnedByCaller:
return nil
case coverModeAllMinusDeny:
denySet := make(map[uint64]bool, len(servers))
for _, id := range servers {
denySet[id] = true
}
if !denyListCoversAllOwnerServersOutsidePATWhitelist(c, ownerUID, denySet) {
return singleton.Localizer.ErrorT("permission denied")
}
return nil
case coverModeAllowList:
tok := patAccessorFromContext(c)
if tok == nil {
return nil
}
for _, id := range servers {
if !tok.CanAccessServer(id) {
return singleton.Localizer.ErrorT("permission denied")
}
}
return nil
default:
// 未识别 cover 模式按拒绝处理;新增 coverMode 必须显式 wire 到
// 资源专用入口里,不允许沉默放行。
return singleton.Localizer.ErrorT("permission denied")
}
}
// coverModeUnknown 表示 Cron/Service 持久化里出现了当前代码不认识的 cover
// 常量。这一档专门让 assertPATCoverFanoutWithinWhitelist 走 default 分支
// fail-closed,保证「未知 cover 必须显式 wire,否则拒绝」的不变量。
const coverModeUnknown coverMode = 255
// patGroupMembershipAccessAllowed returns false when the caller's PAT
// carries a server_ids whitelist that does not cover every current member
// of groupID. JWT requests and unscoped PATs always pass. Used by
// updateServerGroup before the transactional DELETE+INSERT — otherwise a
// PAT scoped to [X] could indirectly remove server Y from a shared group.
func patGroupMembershipAccessAllowed(c *gin.Context, groupID uint64) bool {
tok := patAccessorFromContext(c)
if tok == nil || !patHasServerWhitelist(c) {
return true
}
var members []model.ServerGroupServer
if err := singleton.DB.Where("server_group_id = ?", groupID).Find(&members).Error; err != nil {
return false
}
for _, m := range members {
if !tok.CanAccessServer(m.ServerId) {
return false
}
}
return true
}
// isValidCronCover reports whether cover is one of the runtime-recognised
// Cron Cover constants. Unknown values must be rejected at write time —
// CronTrigger's periodic scheduler path has no PAT context, so any dirty
// row persisted with an unrecognised Cover still fans out via the default
// branch (no CoverAll/IgnoreAll match → broadcast to every server passing
// cronCanSendToServer). The same allowlist applies for batch-delete and
// manual-trigger guard wiring.
func isValidCronCover(cover uint8) bool {
switch cover {
case model.CronCoverIgnoreAll, model.CronCoverAll, model.CronCoverAlertTrigger:
return true
}
return false
}
// isValidServiceCover is the service-monitor analogue. ServiceCoverAll and
// ServiceCoverIgnoreAll are the only branches DispatchTask + Snapshot
// recognise; anything else degrades to "default fan-out" which silently
// escapes the PAT cover-fanout guard.
func isValidServiceCover(cover uint8) bool {
switch cover {
case model.ServiceCoverAll, model.ServiceCoverIgnoreAll:
return true
}
return false
}
// cronCoverMode 把 model.CronCover* 翻译成共享底座认识的 coverMode。
//
// 未来引入新的 Cron Cover 常量时必须在这里显式 wire,否则
// assertPATCoverFanoutWithinWhitelist 会按 default 分支拒绝,避免悄悄绕过。
func cronCoverMode(cover uint8) coverMode {
switch cover {
case model.CronCoverAll:
return coverModeAllMinusDeny
case model.CronCoverIgnoreAll:
return coverModeAllowList
case model.CronCoverAlertTrigger:
return coverModePinnedByCaller
default:
// 未识别 cover 不能降级成 pinned——pinned 会被 assert 直接放行,
// 让受限 PAT 借未知 cover 绕过 fan-out 收口。统一报告 unknown
// 由 assert 的 default 分支 fail-closed。
return coverModeUnknown
}
}
// serviceCoverMode 是 cronCoverMode 在 service monitor 侧的对照。Service 没
// 有 alert-trigger 这一档,只有 All 与 IgnoreAll。
func serviceCoverMode(cover uint8) coverMode {
switch cover {
case model.ServiceCoverAll:
return coverModeAllMinusDeny
case model.ServiceCoverIgnoreAll:
return coverModeAllowList
default:
// 同 cronCoverMode:未识别 cover 不允许借 pinned 旁路 PAT 收口。
return coverModeUnknown
}
}
// rejectImplicitCoverForLimitedPAT enforces the cover-all PAT guard for the
// cron write path. cf.Servers is the literal allow/deny list; under
// CronCoverAll it is a deny-list, under CronCoverIgnoreAll it is an
// allow-list, and under CronCoverAlertTrigger it does not gate dispatch at
// all (the alert trigger pins the target server at fire time). A PAT that
// carries a server_ids whitelist must therefore either (a) leave the deny-list
// empty under non-CoverAll modes — that's allow-list semantics, safe — or
// (b) under CronCoverAll, supply a deny-list that already covers every
// owner-visible server outside the PAT whitelist, otherwise CronTrigger fans
// out to those servers. Alert triggers stay unrestricted because their
// dispatch boundary is enforced by Cron.HasPermission against the trigger
// server id.
func rejectImplicitCoverForLimitedPAT(c *gin.Context, cover uint8, denyServers []uint64) error {
return rejectImplicitCoverForLimitedPATWithOwner(c, cover, denyServers, getUid(c))
}
// rejectImplicitCoverForLimitedPATWithOwner is the explicit-owner variant
// of rejectImplicitCoverForLimitedPAT. updateCron MUST use this with the
// existing cron's UserID — not the caller — because CronTrigger fans out
// to the cron OWNER's servers at dispatch time, regardless of who issued
// the PATCH. Defaulting to getUid(c) (as rejectImplicitCoverForLimitedPAT
// does for createCron) is only safe when the caller is the owner-to-be,
// i.e. the cron is being created with cr.UserID = getUid(c).
//
// 实现层只是把参数翻译到共享底座 assertPATCoverFanoutWithinWhitelist 上;
// 写侧/运行时入口共用同一裁决,避免两边语义漂移。
func rejectImplicitCoverForLimitedPATWithOwner(c *gin.Context, cover uint8, denyServers []uint64, ownerUID uint64) error {
// 写侧只关心 CronCoverAll 的 deny-list 是否充分——CoverIgnoreAll 的
// allow-list 在 checkCronServerListPermission 已经过 Server.HasPermission
// 收口;CoverAlertTrigger 在 fire 时再校验。保留这条提前 return 与
// 老语义完全一致,避免重复 403。
if cover != model.CronCoverAll {
return nil
}
return assertPATCoverFanoutWithinWhitelist(c, ownerUID, coverModeAllMinusDeny, denyServers)
}
// rejectImplicitServiceCoverForLimitedPAT is the service-monitor analogue.
// ServiceCoverAll treats SkipServers as a deny-set: DispatchTask iterates
// ServerShared.Range and probes every server owned by the service owner that
// is NOT marked true in SkipServers. A server-limited PAT must therefore mark
// every owner-visible server outside its whitelist as skipped.
//
// 同样靠 assertPATCoverFanoutWithinWhitelist 落地,与 cron 写侧/运行时入口
// 共用一条裁决路径。
func rejectImplicitServiceCoverForLimitedPAT(c *gin.Context, cover uint8, skipServers map[uint64]bool, ownerUID uint64) error {
if cover != model.ServiceCoverAll {
return nil
}
denyServers := skipServersToDenyList(skipServers)
return assertPATCoverFanoutWithinWhitelist(c, ownerUID, coverModeAllMinusDeny, denyServers)
}
// skipServersToDenyList 把 service monitor 用的 SkipServers map 展平成
// 共享底座需要的切片形态,并按 true 过滤。写侧/运行时入口共用,避免重复
// 写遍历逻辑。
func skipServersToDenyList(skip map[uint64]bool) []uint64 {
out := make([]uint64, 0, len(skip))
for id, mark := range skip {
if mark {
out = append(out, id)
}
}
return out
}
// enforcePATCronDispatchScope 是 cron 运行时入口(manualTriggerCron /
// batchDeleteCron)的 PAT 收口。把 cr.Cover / cr.Servers 翻译成 coverMode
// 后交给共享底座;语义与写侧 rejectImplicitCoverForLimitedPAT* 严格对齐,
// 闭合「写时拦下 / 运行时回放同一条规则」的不变量,避免历史脏数据 + 受
// 限 PAT 形成越权 fan-out。
func enforcePATCronDispatchScope(c *gin.Context, cr *model.Cron) error {
if cr == nil {
return nil
}
return assertPATCoverFanoutWithinWhitelist(c, cr.GetUserID(), cronCoverMode(cr.Cover), cr.Servers)
}
// enforcePATServiceDispatchScope 是 service monitor 运行时入口
// batchDeleteService 等)的 PAT 收口。SkipServers 是 map[uint64]bool
// 这里展开成 deny-list 切片喂给共享底座;语义与
// rejectImplicitServiceCoverForLimitedPAT 严格对齐。
func enforcePATServiceDispatchScope(c *gin.Context, svc *model.Service) error {
if svc == nil {
return nil
}
return assertPATCoverFanoutWithinWhitelist(c, svc.GetUserID(), serviceCoverMode(svc.Cover), skipServersToDenyList(svc.SkipServers))
}
// enforcePATTriggerTaskScope 阻止 service:write / alertrule:write 的 PAT 通过绑定
// trigger task 越权执行 cron。运行时 alertsentinel/servicesentinel 触发
// CronShared.SendTriggerTasks 时没有 PAT 上下文,CheckPermission 也只校验
// ownership/白名单而非 scope,所以必须在写侧对 PAT 额外要求 ScopeCronExec。
func enforcePATTriggerTaskScope(c *gin.Context, failTasks, recoverTasks []uint64) error {
if len(failTasks) == 0 && len(recoverTasks) == 0 {
return nil
}
tok := APITokenFromContext(c)
if tok == nil {
return nil
}
if !tok.HasScope(model.ScopeCronExec) {
return singleton.Localizer.ErrorT("permission denied")
}
return nil
}
func userCanViewServer(c *gin.Context, server *model.Server) bool {
if server == nil {
return false
}
// PAT 白名单优先于 admin/owner 早返回:admin 自己签发的 server_ids 受限 PAT
// 必须只能看见白名单里的 server,否则给自己设的硬边界形同虚设。
if !patAllowsServer(c, server.GetID()) {
return false
}
if callerIsAdmin(c) {
return true
}
@@ -39,16 +489,18 @@ func userCanViewService(c *gin.Context, service *model.Service) bool {
if service == nil {
return false
}
// EnableShowInService 是显式公开旗标:guest 都可看,PAT 白名单不收窄
// 公开视图(公开 service 本来就不绑特定 server)。其它分支才走 PAT。
if service.EnableShowInService {
return true
}
if callerIsAdmin(c) {
return true
if _, isMember := c.Get(model.CtxKeyAuthorizedUser); !isMember {
return false
}
if _, isMember := c.Get(model.CtxKeyAuthorizedUser); isMember {
return service.HasPermission(c)
}
return false
// 关键:必须先让 Service.HasPermission 跑 PAT 白名单收口,再让 admin
// 身份在没有 PAT 的请求上短路放行。否则 admin 自己签发的 server_ids
// 受限 PAT 会被 admin 早返回直接放过,绕过 list/history 入口的 PAT 边界。
return service.HasPermission(c)
}
func assertOwnsNotificationGroup(c *gin.Context, groupID uint64) error {