Files
nezha_domains/model/api_token.go
T
naibaandcloudcode 401713bab5 ci(gosec): silence G101 false positives on PAT context keys and scope const
gosec G101 flags apiTokenCtxKey, apiTokenLastUsedCtxKey, and the
ScopeNotificationGroupWrite identifier as hardcoded credentials. They are
context-key names and a scope string, not secrets. Annotate them with
#nosec G101 (matching the existing JWTSecretEnvKey precedent) so the
gosec CI step passes without disabling the rule globally.

Co-authored-by: cloudcode <cloudcode@users.noreply.github.com>
2026-05-31 08:16:55 +00:00

374 lines
12 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 model
import (
"crypto/sha256"
"encoding/hex"
"slices"
"strings"
"time"
"gorm.io/gorm"
)
// Scope 命名规范(唯一一套):nezha:{resource}:{verb}
//
// - resource: server / service / alertrule / cron / ddns / nat /
// notification / notification-group / transfer / admin
// - verb: read / write / delete / exec
//
// `*` 通配在 resource 或 verb 位均可:
// - nezha:server:* 给定资源的所有动作
// - nezha:* admin-only 全权
//
// 同一 scope 同时管 MCP tool 和 REST endpoint:例如 nezha:server:read 既允许
// `server.list` MCP tool,也允许 `GET /api/v1/server`。
//
// 历史上还有 mcp:* 一套,会被 HasScope 通过别名映射到 nezha:server:* 子集。
// 由于 HasScope 同时服务 MCP tool 调度与 REST scope middleware,旧 mcp:fs:write
// 等会静默扩到所有 nezha:server:write REST 路由——这是命名分裂带来的提权漏洞。
// 现在 mcp:* 不再在运行时被识别;createAPIToken 入口对老调用方做一次性归一化:
// 只读/exec 类(mcp:fs:read、mcp:server:read、mcp:server:exec)映射到对应
// nezha:* read/exec scopemcp:fs:write、mcp:fs:delete、mcp:* 一律拒签。
// 数据库已有的危险旧 scope 由 MigrateLegacyMCPScopes 在启动迁移阶段清理。
const (
ScopeNezhaAll = "nezha:*"
ScopeServerRead = "nezha:server:read"
ScopeServerWrite = "nezha:server:write"
ScopeServerDelete = "nezha:server:delete"
ScopeServerExec = "nezha:server:exec"
ScopeServiceRead = "nezha:service:read"
ScopeServiceWrite = "nezha:service:write"
ScopeServiceDelete = "nezha:service:delete"
ScopeAlertRuleRead = "nezha:alertrule:read"
ScopeAlertRuleWrite = "nezha:alertrule:write"
ScopeAlertRuleDelete = "nezha:alertrule:delete"
ScopeCronRead = "nezha:cron:read"
ScopeCronWrite = "nezha:cron:write"
ScopeCronDelete = "nezha:cron:delete"
ScopeCronExec = "nezha:cron:exec"
ScopeDDNSRead = "nezha:ddns:read"
ScopeDDNSWrite = "nezha:ddns:write"
ScopeDDNSDelete = "nezha:ddns:delete"
ScopeNATRead = "nezha:nat:read"
ScopeNATWrite = "nezha:nat:write"
ScopeNATDelete = "nezha:nat:delete"
ScopeNotificationRead = "nezha:notification:read"
ScopeNotificationWrite = "nezha:notification:write"
ScopeNotificationDelete = "nezha:notification:delete"
ScopeNotificationGroupRead = "nezha:notification-group:read"
ScopeNotificationGroupWrite = "nezha:notification-group:write" // #nosec G101 -- scope identifier, not a credential
ScopeNotificationGroupDelete = "nezha:notification-group:delete"
ScopeTransferRead = "nezha:transfer:read"
ScopeTransferWrite = "nezha:transfer:write"
ScopeTransferDelete = "nezha:transfer:delete"
ScopeAdminAll = "nezha:admin:*"
)
var AllScopes = []string{
ScopeServerRead, ScopeServerWrite, ScopeServerDelete, ScopeServerExec,
ScopeServiceRead, ScopeServiceWrite, ScopeServiceDelete,
ScopeAlertRuleRead, ScopeAlertRuleWrite, ScopeAlertRuleDelete,
ScopeCronRead, ScopeCronWrite, ScopeCronDelete, ScopeCronExec,
ScopeDDNSRead, ScopeDDNSWrite, ScopeDDNSDelete,
ScopeNATRead, ScopeNATWrite, ScopeNATDelete,
ScopeNotificationRead, ScopeNotificationWrite, ScopeNotificationDelete,
ScopeNotificationGroupRead, ScopeNotificationGroupWrite, ScopeNotificationGroupDelete,
ScopeTransferRead, ScopeTransferWrite, ScopeTransferDelete,
"nezha:server:*",
"nezha:service:*",
"nezha:alertrule:*",
"nezha:cron:*",
"nezha:ddns:*",
"nezha:nat:*",
"nezha:notification:*",
"nezha:notification-group:*",
"nezha:transfer:*",
}
var AdminOnlyScopes = []string{ScopeNezhaAll, ScopeAdminAll}
// legacyMCPReadOnlyRewrite 列出仍允许 createAPIToken 入口重写为 nezha:* 的旧 scope。
// 只有只读/exec 类被接受;write/delete/wildcard 一律拒签——保留映射等于扩权。
var legacyMCPReadOnlyRewrite = map[string]string{
"mcp:server:read": ScopeServerRead,
"mcp:server:exec": ScopeServerExec,
"mcp:fs:read": ScopeServerRead,
}
// NormalizeIncomingScope 把入参里的旧 mcp:* scope 重写到 nezha:* 命名。
// 第二个返回值表示该 scope 是否被允许(false = 危险旧 scope,调用方应拒签)。
func NormalizeIncomingScope(s string) (string, bool) {
if mapped, ok := legacyMCPReadOnlyRewrite[s]; ok {
return mapped, true
}
if strings.HasPrefix(s, "mcp:") {
return s, false
}
return s, true
}
// APITokenPrefix 是明文 token 的人类可识别前缀。`nzp_` = nezha personal access token。
const APITokenPrefix = "nzp_"
// APIToken 是用户用于程序化访问的长期凭证。MCP 接入点 /mcp 用它做鉴权。
// 双层鉴权:闸 1 用 UserID 复用 Server.HasPermission;闸 2 用 Scopes / ServerIDs。
type APIToken struct {
ID uint64 `gorm:"primaryKey" json:"id,omitempty"`
UserID uint64 `gorm:"index" json:"user_id,omitempty"`
Name string `gorm:"type:varchar(128)" json:"name,omitempty"`
TokenHash string `gorm:"uniqueIndex;type:char(64)" json:"-"`
ScopesCSV string `gorm:"type:text" json:"-"`
ServersCSV string `gorm:"type:text" json:"-"`
ExpiresAt *time.Time `gorm:"index" json:"expires_at,omitempty"`
LastUsedAt *time.Time `json:"last_used_at,omitempty"`
LastUsedIP string `gorm:"type:varchar(64)" json:"last_used_ip,omitempty"`
CreatedAt time.Time `json:"created_at,omitempty"`
UpdatedAt time.Time `gorm:"autoUpdateTime" json:"updated_at,omitempty"`
}
func (APIToken) TableName() string {
return "api_tokens"
}
// HashAPIToken 计算明文 token 的存储哈希。
func HashAPIToken(plaintext string) string {
sum := sha256.Sum256([]byte(plaintext))
return hex.EncodeToString(sum[:])
}
// Scopes 解码逗号分隔的 scope 列表。
func (t *APIToken) Scopes() []string {
if t.ScopesCSV == "" {
return nil
}
parts := strings.Split(t.ScopesCSV, ",")
out := parts[:0]
for _, p := range parts {
p = strings.TrimSpace(p)
if p != "" {
out = append(out, p)
}
}
return out
}
// SetScopes 编码 scope 列表为 CSV。
func (t *APIToken) SetScopes(scopes []string) {
t.ScopesCSV = strings.Join(scopes, ",")
}
// ServerIDs 解码服务器 ID 白名单。空切片 = 不限制(继承用户原有权限)。
func (t *APIToken) ServerIDs() []uint64 {
if t.ServersCSV == "" {
return nil
}
parts := strings.Split(t.ServersCSV, ",")
out := make([]uint64, 0, len(parts))
for _, p := range parts {
p = strings.TrimSpace(p)
if p == "" {
continue
}
var id uint64
for _, c := range p {
if c < '0' || c > '9' {
id = 0
break
}
id = id*10 + uint64(c-'0')
}
if id != 0 {
out = append(out, id)
}
}
return out
}
// SetServerIDs 编码服务器 ID 白名单。
func (t *APIToken) SetServerIDs(ids []uint64) {
parts := make([]string, 0, len(ids))
for _, id := range ids {
parts = append(parts, formatUint(id))
}
t.ServersCSV = strings.Join(parts, ",")
}
// HasScope 判定 token 是否携带某个 scope。
//
// 匹配规则:
// - nezha:* 覆盖整个 nezha 命名空间
// - 资源级通配:nezha:server:* 匹配所有 nezha:server:read/write/delete/exec
// - 精确匹配
//
// 不再做 mcp:* 别名展开;任何遗留的 mcp:* scope 都视为无效(已被
// MigrateLegacyMCPScopes 在启动迁移阶段清掉;运行时再遇到当作无权处理)。
func (t *APIToken) HasScope(scope string) bool {
for _, s := range t.Scopes() {
if scopeMatches(s, scope) {
return true
}
}
return false
}
// scopeMatches 判定 owned scope 是否覆盖 wanted scope。
func scopeMatches(owned, wanted string) bool {
if owned == wanted {
return true
}
if owned == ScopeNezhaAll {
return strings.HasPrefix(wanted, "nezha:")
}
if strings.HasSuffix(owned, ":*") {
prefix := strings.TrimSuffix(owned, ":*")
return strings.HasPrefix(wanted, prefix+":") || wanted == prefix
}
return false
}
// CanAccessServer 判定 token 是否被允许操作某 server(白名单层;
// 仍需上层调用 Server.HasPermission 做用户级权限校验)。
func (t *APIToken) CanAccessServer(serverID uint64) bool {
ids := t.ServerIDs()
if len(ids) == 0 {
return true
}
return slices.Contains(ids, serverID)
}
// IsExpired 判定 token 是否已过期。ExpiresAt 为 nil 表示永不过期。
func (t *APIToken) IsExpired(now time.Time) bool {
return t.ExpiresAt != nil && now.After(*t.ExpiresAt)
}
// BeforeCreate 在写入前强校验 TokenHash 必填,避免空哈希撞键。
func (t *APIToken) BeforeCreate(tx *gorm.DB) error {
if t.TokenHash == "" {
return gorm.ErrInvalidData
}
return nil
}
// MigrateLegacyMCPScopes 把数据库里残留的 mcp:* scope 一次性归一化:
// - 只读/exec 类映射到对应 nezha:* read/exec scope
// - mcp:fs:write / mcp:fs:delete / mcp:* 会被剥掉(不再赋予 REST write/delete),
// 若 token 因此 scope 列表清空则整体删除——避免出现一张 0 scope 但仍能命中
// auth middleware 的 PAT。
//
// 返回 (rewrittenTokens, deletedTokens, err)。生产路径在启动时调用一次;
// 测试也会用它构造 fixture。
func MigrateLegacyMCPScopes(db *gorm.DB) (int, int, error) {
if db == nil {
return 0, 0, nil
}
var rows []APIToken
if err := db.Where("scopes_csv LIKE ?", "%mcp:%").Find(&rows).Error; err != nil {
return 0, 0, err
}
rewritten, deleted := 0, 0
for i := range rows {
tok := &rows[i]
old := tok.Scopes()
next := make([]string, 0, len(old))
seen := make(map[string]struct{}, len(old))
for _, s := range old {
mapped, ok := NormalizeIncomingScope(s)
if !ok {
continue
}
if _, dup := seen[mapped]; dup {
continue
}
seen[mapped] = struct{}{}
next = append(next, mapped)
}
if len(next) == 0 {
if err := db.Delete(&APIToken{}, tok.ID).Error; err != nil {
return rewritten, deleted, err
}
deleted++
continue
}
joined := strings.Join(next, ",")
if joined == tok.ScopesCSV {
continue
}
if err := db.Model(&APIToken{}).Where("id = ?", tok.ID).
Update("scopes_csv", joined).Error; err != nil {
return rewritten, deleted, err
}
rewritten++
}
return rewritten, deleted, nil
}
// formatUint —— 小工具,避免引入 strconv。
func formatUint(v uint64) string {
if v == 0 {
return "0"
}
var buf [20]byte
i := len(buf)
for v > 0 {
i--
buf[i] = byte('0' + v%10)
v /= 10
}
return string(buf[i:])
}
// APITokenCreateRequest 是创建 PAT 接口的入参。
type APITokenCreateRequest struct {
Name string `json:"name" binding:"required,max=128"`
Scopes []string `json:"scopes" binding:"required,min=1,dive,max=64"`
ServerIDs []uint64 `json:"server_ids,omitempty"`
ExpiresInDays int `json:"expires_in_days,omitempty"` // 0 = 永不过期
}
// APITokenCreateResponse 创建 PAT 接口的出参;明文 token 仅在此刻返回一次。
type APITokenCreateResponse struct {
ID uint64 `json:"id"`
Name string `json:"name"`
Token string `json:"token"`
Scopes []string `json:"scopes"`
ServerIDs []uint64 `json:"server_ids,omitempty"`
ExpiresAt *time.Time `json:"expires_at,omitempty"`
}
// APITokenView 是 PAT 列表展示用的脱敏视图。
type APITokenView struct {
ID uint64 `json:"id"`
Name string `json:"name"`
Scopes []string `json:"scopes"`
ServerIDs []uint64 `json:"server_ids,omitempty"`
ExpiresAt *time.Time `json:"expires_at,omitempty"`
LastUsedAt *time.Time `json:"last_used_at,omitempty"`
LastUsedIP string `json:"last_used_ip,omitempty"`
CreatedAt time.Time `json:"created_at"`
}
// ToView 把数据库实体转为列表脱敏视图。
func (t *APIToken) ToView() APITokenView {
return APITokenView{
ID: t.ID,
Name: t.Name,
Scopes: t.Scopes(),
ServerIDs: t.ServerIDs(),
ExpiresAt: t.ExpiresAt,
LastUsedAt: t.LastUsedAt,
LastUsedIP: t.LastUsedIP,
CreatedAt: t.CreatedAt,
}
}