mirror of
https://github.com/Buriburizaem0n/nezha_domains.git
synced 2026-09-19 17:50:12 +00:00
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:
@@ -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 集合,再减去
|
||||
// servers(deny-list)。代表 CronCoverAll / ServiceCoverAll。受限 PAT
|
||||
// 必须确保 deny-list 已覆盖白名单外的全部 owner servers,否则 fan-out
|
||||
// 会跑到 PAT 白名单之外。
|
||||
coverModeAllMinusDeny
|
||||
|
||||
// coverModeAllowList: dispatch 时只在 servers(allow-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 {
|
||||
|
||||
Reference in New Issue
Block a user