feat: 流水线三级权限体系(手动多选+通配符授权)+ 菜单分级 + SSE 认证修复

权限模型(借鉴 Jenkins Role-Strategy 与云效,默认拒绝):
- 三级权限:查看(流水线+成功/失败日志)/ 发布(+构建、批量发布、重试)/ 管理(+配置、成员);admin 全局豁免
- 权限组(管理员配置):默认手动多选流水线(免正则,操作友好);高级模式仅 * 通配符(cex-*)
- 单流水线成员直授权作为例外补充;AI 批量发布逐 job 校验,越权明确拒绝

后端:
- 新增 PipelineRole/PipelineMember 表与 /api/pipeline-roles、/api/pipelines/:id/members CRUD
- RequirePipelineAccess/RequireRunAccess 中间件挂全流水线与运行路由;列表按可见性过滤并附 accessLevel/canRetry
- /api/me 会话接口;logs/stream SSE 支持 ?token= 认证(原先完全免鉴权)并校验查看权限
- 凭证/主机/集群/构建节点/工具/Webhook/Jenkins/GitOps/插件/设置全部 AdminOnly;触发人以会话身份落库

前端:
- useSession 会话 hook;Sidebar 菜单分级(资源/集成/系统管理 admin-only);admin 路由守卫
- 按钮级权限:执行/配置/删除/保存/重试按等级显隐;新页「流水线权限」与流水线成员弹窗(纯点选)
- 登录页记住密码;GUIDE/API 文档补权限章节
This commit is contained in:
Logan.Li committed 2026-10-27 17:34:41 +08:00
1 parent 7090c8efc2
commit 9103b13510
27 files changed
+1690 -190

No files matched your search

+226
View File
@@ -0,0 +1,226 @@
package handler
import (
"net/http"
"regexp"
"strings"
"github.com/gin-gonic/gin"
"flowstack/internal/database"
"flowstack/internal/model"
)
// 三级流水线权限:viewer 查看 / operator 执行 / owner 管理;admin 全局豁免(等级 4)
const (
LevelViewer = "viewer"
LevelOperator = "operator"
LevelOwner = "owner"
adminAccessLevel = 4
)
// LevelRank 数值化权限等级,用于比较
func LevelRank(level string) int {
switch level {
case LevelOwner:
return 3
case LevelOperator:
return 2
case LevelViewer:
return 1
}
return 0
}
// currentUser 从 AuthMiddleware 注入的上下文取当前用户
func currentUser(c *gin.Context) (username string, isAdmin bool) {
return c.GetString("username"), c.GetString("role") == "admin"
}
func memberInList(members []string, username string) bool {
for _, m := range members {
if m == username {
return true
}
}
return false
}
// globToRegexp 通配符(* 任意字符、? 单字符)转正则,其余字符按字面量——操作员无需正则知识
func globToRegexp(glob string) *regexp.Regexp {
expr := regexp.QuoteMeta(glob)
expr = strings.ReplaceAll(expr, `\*`, `.*`)
expr = strings.ReplaceAll(expr, `\?`, `.`)
return regexp.MustCompile(expr)
}
// roleMatchPipeline 权限组是否覆盖某流水线:manual 按多选 ID、pattern 按通配符匹配名称
func roleMatchPipeline(role *model.PipelineRole, p *model.Pipeline) bool {
if role.ScopeMode == "pattern" {
if role.Pattern == "" {
return false
}
re := globToRegexp(role.Pattern)
return re.MatchString(p.Name) || (p.JenkinsJobName != "" && re.MatchString(p.JenkinsJobName))
}
for _, id := range role.PipelineIDs {
if id == p.ID {
return true
}
}
return false
}
// roleLevelForPipeline 用户通过权限组对某流水线获得的最高等级(manual + pattern 一并计算)
func roleLevelForPipeline(username string, p *model.Pipeline) int {
if username == "" {
return 0
}
var roles []model.PipelineRole
database.DB.Find(&roles)
best := 0
for i := range roles {
role := &roles[i]
if !memberInList(role.Members, username) {
continue
}
if roleMatchPipeline(role, p) {
if r := LevelRank(role.Level); r > best {
best = r
}
}
}
return best
}
// roleLevelForName 按名称预检(AI 自动创建前):只有 pattern 模式能命中尚未创建的流水线
func roleLevelForName(username string, name string) int {
if username == "" {
return 0
}
var roles []model.PipelineRole
database.DB.Find(&roles)
best := 0
for _, role := range roles {
if !memberInList(role.Members, username) || role.ScopeMode != "pattern" || role.Pattern == "" {
continue
}
if globToRegexp(role.Pattern).MatchString(name) {
if r := LevelRank(role.Level); r > best {
best = r
}
}
}
return best
}
// pipelineAccessLevel 用户对某流水线的最高权限等级:
// admin 豁免 → 单流水线直接成员 → 权限组命中(manual/pattern,取最大值)
func pipelineAccessLevel(username string, isAdmin bool, p *model.Pipeline) int {
if isAdmin {
return adminAccessLevel
}
best := 0
var member model.PipelineMember
if err := database.DB.Where("pipeline_id = ? AND username = ?", p.ID, username).First(&member).Error; err == nil {
best = LevelRank(member.Level)
}
if r := roleLevelForPipeline(username, p); r > best {
best = r
}
return best
}
// AccessiblePipelineIDs 列表过滤用:直接成员 ∪ 权限组命中的流水线 ID。
// 第二个返回值表示是否需要过滤:admin 返回 (nil, false) 全量可见;
// 普通用户返回 (ids, true),ids 可为空 = 无任何可见流水线
func AccessiblePipelineIDs(username string, isAdmin bool) ([]uint, bool) {
if isAdmin {
return nil, false
}
if username == "" {
return []uint{}, true
}
ids := map[uint]bool{}
var memberIDs []uint
database.DB.Model(&model.PipelineMember{}).Where("username = ?", username).Pluck("pipeline_id", &memberIDs)
for _, id := range memberIDs {
ids[id] = true
}
var roles []model.PipelineRole
database.DB.Find(&roles)
patterns := make([]*regexp.Regexp, 0, len(roles))
for i := range roles {
role := &roles[i]
if !memberInList(role.Members, username) {
continue
}
if role.ScopeMode == "pattern" && role.Pattern != "" {
patterns = append(patterns, globToRegexp(role.Pattern))
} else {
for _, id := range role.PipelineIDs {
ids[id] = true
}
}
}
if len(patterns) > 0 {
var pipelines []model.Pipeline
database.DB.Select("id", "name", "jenkins_job_name").Find(&pipelines)
for _, p := range pipelines {
for _, re := range patterns {
if re.MatchString(p.Name) || (p.JenkinsJobName != "" && re.MatchString(p.JenkinsJobName)) {
ids[p.ID] = true
break
}
}
}
}
out := make([]uint, 0, len(ids))
for id := range ids {
out = append(out, id)
}
return out, true
}
// RequirePipelineAccess 流水线级权限守卫:解析 :id 后按最低等级校验,通过后注入 pipeline
func RequirePipelineAccess(minLevel int) gin.HandlerFunc {
return func(c *gin.Context) {
var p model.Pipeline
if err := database.DB.First(&p, c.Param("id")).Error; err != nil {
c.AbortWithStatusJSON(http.StatusNotFound, gin.H{"error": "pipeline not found"})
return
}
username, isAdmin := currentUser(c)
if pipelineAccessLevel(username, isAdmin, &p) < minLevel {
c.AbortWithStatusJSON(http.StatusForbidden, gin.H{"error": "无权访问该流水线"})
return
}
c.Set("pipeline", &p)
c.Next()
}
}
// RequireRunAccess 运行级权限守卫:runId → run → pipeline 再校验(防 runId 绕过流水线权限)
func RequireRunAccess(minLevel int) gin.HandlerFunc {
return func(c *gin.Context) {
var run model.PipelineRun
if err := database.DB.Where("run_id = ?", c.Param("runId")).First(&run).Error; err != nil {
c.AbortWithStatusJSON(http.StatusNotFound, gin.H{"error": "run not found"})
return
}
var p model.Pipeline
if err := database.DB.First(&p, run.PipelineID).Error; err != nil {
c.AbortWithStatusJSON(http.StatusNotFound, gin.H{"error": "pipeline not found"})
return
}
username, isAdmin := currentUser(c)
if pipelineAccessLevel(username, isAdmin, &p) < minLevel {
c.AbortWithStatusJSON(http.StatusForbidden, gin.H{"error": "无权访问该流水线的运行记录"})
return
}
c.Set("pipeline", &p)
c.Next()
}
}