Files
nezha_domains/cmd/dashboard/controller/permissions.go
T
naiba 836f1db4b7 feat(service): replace EnableShowInService with HideForGuest
Mirror the server HideForGuest flag on services: rename the field
(dropping the gorm default), invert userCanViewService so a service is
visible to guests by default and hidden only when HideForGuest is set,
and flip the public-stats filter in servicesentinel accordingly. Update
the visibility and permission-matrix tests to the new semantics.
2026-06-02 03:31:36 +00:00

518 lines
20 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 {
return false
}
user, ok := auth.(*model.User)
if !ok || user == nil {
return false
}
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
}
if _, isMember := c.Get(model.CtxKeyAuthorizedUser); isMember {
if server.HasPermission(c) {
return true
}
return !server.HideForGuest
}
return !server.HideForGuest
}
func userCanViewService(c *gin.Context, service *model.Service) bool {
if service == nil {
return false
}
// HideForGuest 默认公开,置 true 才对 guest 隐藏,语义与 Server.HideForGuest 对齐。
if service.HideForGuest {
if _, isMember := c.Get(model.CtxKeyAuthorizedUser); !isMember {
return false
}
// 必须先让 Service.HasPermission 跑 PAT 白名单收口,再让 admin 在无 PAT 请求上
// 短路放行,否则 admin 自签的受限 PAT 会被早返回绕过 list/history 的 PAT 边界。
return service.HasPermission(c)
}
return true
}
func assertOwnsNotificationGroup(c *gin.Context, groupID uint64) error {
if groupID == 0 {
return nil
}
var ng model.NotificationGroup
if err := singleton.DB.First(&ng, groupID).Error; err != nil {
return singleton.Localizer.ErrorT("notification group id %d does not exist", groupID)
}
if !ng.HasPermission(c) {
return singleton.Localizer.ErrorT("permission denied")
}
return nil
}