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:
1 parent
7090c8efc2
commit
9103b13510
27 files changed
+1690
-190
No files matched your search
@@ -36,6 +36,7 @@ func Init() error {
|
||||
|
||||
if err := DB.AutoMigrate(
|
||||
&model.User{},
|
||||
&model.PipelineRole{}, &model.PipelineMember{},
|
||||
&model.Pipeline{}, &model.PipelineNode{}, &model.PipelineEdge{},
|
||||
&model.PipelineRun{}, &model.RunStage{}, &model.LogEntry{},
|
||||
&model.Application{}, &model.Plugin{},
|
||||
|
||||
@@ -362,6 +362,7 @@ func AiRelease(hub *Hub) gin.HandlerFunc {
|
||||
return
|
||||
}
|
||||
autoCreate := body.AutoCreate == nil || *body.AutoCreate
|
||||
username, isAdmin := currentUser(c)
|
||||
|
||||
candidates := allCandidates()
|
||||
type triggered struct {
|
||||
@@ -378,12 +379,24 @@ func AiRelease(hub *Hub) gin.HandlerFunc {
|
||||
var failList []failed
|
||||
|
||||
for _, name := range body.Jobs {
|
||||
// 权限预检(operator 及以上):已存在的流水线按完整权限判定;
|
||||
// 未导入的 Jenkins Job 按正则角色命中判定,且必须先于自动创建——避免建出请求者自己都无权可见的流水线
|
||||
var existing model.Pipeline
|
||||
if database.DB.Where("name = ? OR jenkins_job_name = ?", name, name).First(&existing).Error == nil {
|
||||
if pipelineAccessLevel(username, isAdmin, &existing) < LevelRank(LevelOperator) {
|
||||
failList = append(failList, failed{Job: name, Reason: "无该流水线的执行权限"})
|
||||
continue
|
||||
}
|
||||
} else if !isAdmin && roleLevelForName(username, name) < LevelRank(LevelOperator) {
|
||||
failList = append(failList, failed{Job: name, Reason: "无该流水线的执行权限(权限组未匹配该名称)"})
|
||||
continue
|
||||
}
|
||||
p, created, err := resolvePipeline(name, candidates, autoCreate, body.JenkinsServerID)
|
||||
if err != nil {
|
||||
failList = append(failList, failed{Job: name, Reason: err.Error()})
|
||||
continue
|
||||
}
|
||||
run, err := triggerPipelineRun(hub, *p, body.Branch, body.Env, "admin", "batch")
|
||||
run, err := triggerPipelineRun(hub, *p, body.Branch, body.Env, username, "batch")
|
||||
if err != nil {
|
||||
failList = append(failList, failed{Job: name, Reason: "触发失败: " + err.Error()})
|
||||
continue
|
||||
|
||||
@@ -68,23 +68,28 @@ func VerifyToken(token string) (string, error) {
|
||||
return parts[0], nil
|
||||
}
|
||||
|
||||
// AuthMiddleware 会话鉴权:仅保护 /api/*(豁免登录/入站 Webhook/SSE 日志流);SPA 与静态资源放行
|
||||
// AuthMiddleware 会话鉴权:仅保护 /api/*(豁免登录/入站 Webhook);SPA 与静态资源放行。
|
||||
// SSE 日志流(*/logs/stream)的 EventSource 无法携带 Authorization 头,额外接受 ?token= 查询参数。
|
||||
func AuthMiddleware() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
p := c.Request.URL.Path
|
||||
if p == "/health" || !strings.HasPrefix(p, "/api/") ||
|
||||
p == "/api/auth/login" ||
|
||||
strings.HasPrefix(p, "/api/hooks/") ||
|
||||
strings.HasSuffix(p, "/logs/stream") {
|
||||
strings.HasPrefix(p, "/api/hooks/") {
|
||||
c.Next()
|
||||
return
|
||||
}
|
||||
auth := c.GetHeader("Authorization")
|
||||
if !strings.HasPrefix(auth, "Bearer ") {
|
||||
token := ""
|
||||
if auth := c.GetHeader("Authorization"); strings.HasPrefix(auth, "Bearer ") {
|
||||
token = strings.TrimPrefix(auth, "Bearer ")
|
||||
} else if strings.HasSuffix(p, "/logs/stream") {
|
||||
token = c.Query("token")
|
||||
}
|
||||
if token == "" {
|
||||
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "未登录"})
|
||||
return
|
||||
}
|
||||
username, err := VerifyToken(strings.TrimPrefix(auth, "Bearer "))
|
||||
username, err := VerifyToken(token)
|
||||
if err != nil {
|
||||
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "登录已失效,请重新登录"})
|
||||
return
|
||||
@@ -100,6 +105,15 @@ func AuthMiddleware() gin.HandlerFunc {
|
||||
}
|
||||
}
|
||||
|
||||
// Me GET /api/me —— 当前会话信息(前端菜单分级与按钮权限依据)
|
||||
func Me(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"username": c.GetString("username"),
|
||||
"role": c.GetString("role"),
|
||||
"isAdmin": c.GetString("role") == "admin",
|
||||
})
|
||||
}
|
||||
|
||||
// AdminOnly 管理员专用接口守卫
|
||||
func AdminOnly() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,212 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"flowstack/internal/database"
|
||||
"flowstack/internal/model"
|
||||
)
|
||||
|
||||
// ============================================================
|
||||
// 流水线权限组(默认手动多选流水线;高级模式用 * 通配符匹配名称)—— 仅管理员
|
||||
// ============================================================
|
||||
|
||||
type roleBody struct {
|
||||
Name string `json:"name"`
|
||||
ScopeMode string `json:"scopeMode"` // manual 手动多选 | pattern 名称匹配
|
||||
Pattern string `json:"pattern"` // scopeMode=pattern 时生效,支持 * 通配符
|
||||
PipelineIDs []uint `json:"pipelineIds"`
|
||||
Level string `json:"level"`
|
||||
Members []string `json:"members"`
|
||||
}
|
||||
|
||||
func validateRoleBody(b *roleBody) string {
|
||||
if b.Name == "" {
|
||||
return "名称不能为空"
|
||||
}
|
||||
if b.ScopeMode == "" {
|
||||
b.ScopeMode = "manual"
|
||||
}
|
||||
if b.ScopeMode != "manual" && b.ScopeMode != "pattern" {
|
||||
return "授权范围类型不合法"
|
||||
}
|
||||
if b.ScopeMode == "pattern" && b.Pattern == "" {
|
||||
return "名称匹配需要填写匹配规则,如 cex-*"
|
||||
}
|
||||
if b.ScopeMode == "manual" && len(b.PipelineIDs) == 0 {
|
||||
return "请至少选择一条流水线"
|
||||
}
|
||||
if LevelRank(b.Level) == 0 {
|
||||
return "请选择权限等级"
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// ListPipelineRoles GET /api/pipeline-roles —— 附匹配流水线数
|
||||
func ListPipelineRoles(c *gin.Context) {
|
||||
var roles []model.PipelineRole
|
||||
database.DB.Order("id").Find(&roles)
|
||||
var pipelines []model.Pipeline
|
||||
database.DB.Select("id", "name", "jenkins_job_name").Find(&pipelines)
|
||||
|
||||
type roleWithCount struct {
|
||||
model.PipelineRole
|
||||
MatchedPipelines int `json:"matchedPipelines"`
|
||||
}
|
||||
out := make([]roleWithCount, 0, len(roles))
|
||||
for i := range roles {
|
||||
role := &roles[i]
|
||||
n := 0
|
||||
for j := range pipelines {
|
||||
if roleMatchPipeline(role, &pipelines[j]) {
|
||||
n++
|
||||
}
|
||||
}
|
||||
out = append(out, roleWithCount{PipelineRole: *role, MatchedPipelines: n})
|
||||
}
|
||||
c.JSON(http.StatusOK, out)
|
||||
}
|
||||
|
||||
// CreatePipelineRole POST /api/pipeline-roles
|
||||
func CreatePipelineRole(c *gin.Context) {
|
||||
var body roleBody
|
||||
if err := c.ShouldBindJSON(&body); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if msg := validateRoleBody(&body); msg != "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": msg})
|
||||
return
|
||||
}
|
||||
role := model.PipelineRole{
|
||||
Name: body.Name, ScopeMode: body.ScopeMode, Pattern: body.Pattern,
|
||||
PipelineIDs: body.PipelineIDs, Level: body.Level, Members: body.Members,
|
||||
}
|
||||
if role.PipelineIDs == nil {
|
||||
role.PipelineIDs = []uint{}
|
||||
}
|
||||
if role.Members == nil {
|
||||
role.Members = []string{}
|
||||
}
|
||||
if err := database.DB.Create(&role).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "创建失败(名称可能已存在): " + err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, role)
|
||||
}
|
||||
|
||||
// UpdatePipelineRole PUT /api/pipeline-roles/:id
|
||||
func UpdatePipelineRole(c *gin.Context) {
|
||||
var role model.PipelineRole
|
||||
if err := database.DB.First(&role, c.Param("id")).Error; err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "权限组不存在"})
|
||||
return
|
||||
}
|
||||
var body roleBody
|
||||
if err := c.ShouldBindJSON(&body); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if msg := validateRoleBody(&body); msg != "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": msg})
|
||||
return
|
||||
}
|
||||
role.Name, role.ScopeMode, role.Pattern = body.Name, body.ScopeMode, body.Pattern
|
||||
role.PipelineIDs, role.Level, role.Members = body.PipelineIDs, body.Level, body.Members
|
||||
if role.PipelineIDs == nil {
|
||||
role.PipelineIDs = []uint{}
|
||||
}
|
||||
if role.Members == nil {
|
||||
role.Members = []string{}
|
||||
}
|
||||
if err := database.DB.Save(&role).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, role)
|
||||
}
|
||||
|
||||
// DeletePipelineRole DELETE /api/pipeline-roles/:id
|
||||
func DeletePipelineRole(c *gin.Context) {
|
||||
database.DB.Delete(&model.PipelineRole{}, c.Param("id"))
|
||||
c.JSON(http.StatusOK, gin.H{"success": true})
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 单流水线成员(云效式直接授权)—— owner 或 admin
|
||||
// ============================================================
|
||||
|
||||
// ListPipelineMembers GET /api/pipelines/:id/members —— 直接成员 + 命中的权限组(只读参考)
|
||||
func ListPipelineMembers(c *gin.Context) {
|
||||
p, _ := c.Get("pipeline")
|
||||
pipeline := p.(*model.Pipeline)
|
||||
|
||||
var members []model.PipelineMember
|
||||
database.DB.Where("pipeline_id = ?", pipeline.ID).Order("id").Find(&members)
|
||||
|
||||
var roles []model.PipelineRole
|
||||
database.DB.Find(&roles)
|
||||
type matchedRole struct {
|
||||
Name string `json:"name"`
|
||||
Level string `json:"level"`
|
||||
Pattern string `json:"pattern"`
|
||||
Mode string `json:"mode"`
|
||||
Members []string `json:"members"`
|
||||
}
|
||||
matched := make([]matchedRole, 0)
|
||||
for i := range roles {
|
||||
role := &roles[i]
|
||||
if roleMatchPipeline(role, pipeline) {
|
||||
matched = append(matched, matchedRole{Name: role.Name, Level: role.Level, Pattern: role.Pattern, Mode: role.ScopeMode, Members: role.Members})
|
||||
}
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"members": members, "matchedRoles": matched})
|
||||
}
|
||||
|
||||
// AddPipelineMember POST /api/pipelines/:id/members —— 已存在则更新等级
|
||||
func AddPipelineMember(c *gin.Context) {
|
||||
p, _ := c.Get("pipeline")
|
||||
pipeline := p.(*model.Pipeline)
|
||||
|
||||
var body struct {
|
||||
Username string `json:"username"`
|
||||
Level string `json:"level"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&body); err != nil || body.Username == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "请选择用户"})
|
||||
return
|
||||
}
|
||||
if LevelRank(body.Level) == 0 {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "请选择权限等级"})
|
||||
return
|
||||
}
|
||||
var user model.User
|
||||
if err := database.DB.Where("username = ?", body.Username).First(&user).Error; err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "用户不存在(LDAP 用户请先导入或登录一次)"})
|
||||
return
|
||||
}
|
||||
|
||||
var member model.PipelineMember
|
||||
if err := database.DB.Where("pipeline_id = ? AND username = ?", pipeline.ID, body.Username).First(&member).Error; err == nil {
|
||||
member.Level = body.Level
|
||||
database.DB.Save(&member)
|
||||
c.JSON(http.StatusOK, member)
|
||||
return
|
||||
}
|
||||
member = model.PipelineMember{PipelineID: pipeline.ID, Username: body.Username, Level: body.Level}
|
||||
if err := database.DB.Create(&member).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, member)
|
||||
}
|
||||
|
||||
// DeletePipelineMember DELETE /api/pipelines/:id/members/:mid
|
||||
func DeletePipelineMember(c *gin.Context) {
|
||||
p, _ := c.Get("pipeline")
|
||||
pipeline := p.(*model.Pipeline)
|
||||
database.DB.Where("pipeline_id = ? AND id = ?", pipeline.ID, c.Param("mid")).Delete(&model.PipelineMember{})
|
||||
c.JSON(http.StatusOK, gin.H{"success": true})
|
||||
}
|
||||
@@ -14,10 +14,17 @@ import (
|
||||
"flowstack/internal/model"
|
||||
)
|
||||
|
||||
// ListPipelines GET /api/pipelines —— 附带每条流水线最近一次运行信息
|
||||
// ListPipelines GET /api/pipelines —— 附带每条流水线最近一次运行信息与当前用户权限等级
|
||||
func ListPipelines(c *gin.Context) {
|
||||
username, isAdmin := currentUser(c)
|
||||
ids, filter := AccessiblePipelineIDs(username, isAdmin)
|
||||
|
||||
q := database.DB.Order("updated_at DESC")
|
||||
if filter {
|
||||
q = q.Where("id IN ?", ids)
|
||||
}
|
||||
var list []model.Pipeline
|
||||
database.DB.Order("updated_at DESC").Find(&list)
|
||||
q.Find(&list)
|
||||
|
||||
// 每流水线最近一次 run(一次查询取全表后内存分组,数据量小;jianmu 用反规范化表,此处无需)
|
||||
var runs []model.PipelineRun
|
||||
@@ -31,11 +38,12 @@ func ListPipelines(c *gin.Context) {
|
||||
|
||||
type pipelineWithLastRun struct {
|
||||
model.Pipeline
|
||||
LastRun *model.PipelineRun `json:"lastRun"`
|
||||
LastRun *model.PipelineRun `json:"lastRun"`
|
||||
AccessLevel int `json:"accessLevel"` // 1查看 2执行 3管理 4管理员
|
||||
}
|
||||
result := make([]pipelineWithLastRun, 0, len(list))
|
||||
for _, p := range list {
|
||||
item := pipelineWithLastRun{Pipeline: p}
|
||||
item := pipelineWithLastRun{Pipeline: p, AccessLevel: pipelineAccessLevel(username, isAdmin, &p)}
|
||||
if r, ok := lastByPipeline[p.ID]; ok {
|
||||
item.LastRun = &r
|
||||
}
|
||||
@@ -57,8 +65,10 @@ func GetPipelineDetail(c *gin.Context) {
|
||||
database.DB.Where("pipeline_id = ?", p.ID).Find(&edges)
|
||||
var lastRun model.PipelineRun
|
||||
hasRun := database.DB.Where("pipeline_id = ?", p.ID).Order("started_at DESC").First(&lastRun).Error == nil
|
||||
username, isAdmin := currentUser(c)
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"pipeline": p, "nodes": nodes, "edges": edges,
|
||||
"accessLevel": pipelineAccessLevel(username, isAdmin, &p),
|
||||
"lastRun": func() *model.PipelineRun {
|
||||
if hasRun {
|
||||
return &lastRun
|
||||
|
||||
@@ -29,7 +29,12 @@ func CreateRun(hub *Hub) gin.HandlerFunc {
|
||||
}
|
||||
_ = c.ShouldBindJSON(&body)
|
||||
|
||||
run, err := triggerPipelineRun(hub, p, body.Branch, body.Env, body.TriggerBy, body.TriggerType)
|
||||
// 触发人以会话身份为准(服务端可信,覆盖前端传入)
|
||||
triggerBy := c.GetString("username")
|
||||
if triggerBy == "" {
|
||||
triggerBy = body.TriggerBy
|
||||
}
|
||||
run, err := triggerPipelineRun(hub, p, body.Branch, body.Env, triggerBy, body.TriggerType)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
@@ -222,9 +227,15 @@ func stageScript(st *model.RunStage) []string {
|
||||
}
|
||||
}
|
||||
|
||||
// ListRuns GET /api/runs?pipelineId=&status=
|
||||
// ListRuns GET /api/runs?pipelineId=&status= —— 按流水线可见性过滤,附 canRetry
|
||||
func ListRuns(c *gin.Context) {
|
||||
username, isAdmin := currentUser(c)
|
||||
ids, filter := AccessiblePipelineIDs(username, isAdmin)
|
||||
|
||||
q := database.DB.Model(&model.PipelineRun{}).Order("started_at DESC").Limit(100)
|
||||
if filter {
|
||||
q = q.Where("pipeline_id IN ?", ids)
|
||||
}
|
||||
if v := c.Query("pipelineId"); v != "" {
|
||||
q = q.Where("pipeline_id = ?", v)
|
||||
}
|
||||
@@ -233,7 +244,25 @@ func ListRuns(c *gin.Context) {
|
||||
}
|
||||
var runs []model.PipelineRun
|
||||
q.Find(&runs)
|
||||
c.JSON(http.StatusOK, runs)
|
||||
|
||||
type runWithPerm struct {
|
||||
model.PipelineRun
|
||||
CanRetry bool `json:"canRetry"`
|
||||
}
|
||||
levelCache := map[uint]int{}
|
||||
out := make([]runWithPerm, 0, len(runs))
|
||||
for _, r := range runs {
|
||||
lv, ok := levelCache[r.PipelineID]
|
||||
if !ok {
|
||||
var p model.Pipeline
|
||||
if database.DB.First(&p, r.PipelineID).Error == nil {
|
||||
lv = pipelineAccessLevel(username, isAdmin, &p)
|
||||
}
|
||||
levelCache[r.PipelineID] = lv
|
||||
}
|
||||
out = append(out, runWithPerm{PipelineRun: r, CanRetry: lv >= LevelRank(LevelOperator)})
|
||||
}
|
||||
c.JSON(http.StatusOK, out)
|
||||
}
|
||||
|
||||
// GetRun GET /api/runs/:runId
|
||||
@@ -316,7 +345,7 @@ func RetryRun(hub *Hub) gin.HandlerFunc {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "pipeline not found"})
|
||||
return
|
||||
}
|
||||
run, err := triggerPipelineRun(hub, p, prev.Branch, prev.Env, "admin", "manual")
|
||||
run, err := triggerPipelineRun(hub, p, prev.Branch, prev.Env, c.GetString("username"), "manual")
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
|
||||
@@ -40,6 +40,30 @@ type Pipeline struct {
|
||||
UpdatedAt time.Time `json:"updatedAt"`
|
||||
}
|
||||
|
||||
// PipelineRole 流水线权限角色:默认手动多选流水线(scopeMode=manual),
|
||||
// 高级模式用通配符 Pattern(scopeMode=pattern,如 cex-*,免正则)批量匹配名称
|
||||
type PipelineRole struct {
|
||||
ID uint `gorm:"primaryKey" json:"id"`
|
||||
Name string `gorm:"uniqueIndex" json:"name"` // 如 cex 发布组
|
||||
ScopeMode string `gorm:"default:manual" json:"scopeMode"` // manual 手动多选 | pattern 名称匹配
|
||||
Pattern string `json:"pattern"` // scopeMode=pattern 时生效,支持 * 通配符
|
||||
PipelineIDs []uint `gorm:"serializer:json" json:"pipelineIds"` // scopeMode=manual 时生效
|
||||
Level string `gorm:"default:viewer" json:"level"` // viewer | operator | owner
|
||||
Members []string `gorm:"serializer:json" json:"members"` // 授权用户名列表
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
UpdatedAt time.Time `json:"updatedAt"`
|
||||
}
|
||||
|
||||
// PipelineMember 单流水线直接授权(云效式成员管理,作正则角色的例外补充)
|
||||
type PipelineMember struct {
|
||||
ID uint `gorm:"primaryKey" json:"id"`
|
||||
PipelineID uint `gorm:"uniqueIndex:idx_pipeline_member" json:"pipelineId"`
|
||||
Username string `gorm:"uniqueIndex:idx_pipeline_member" json:"username"`
|
||||
Level string `gorm:"default:viewer" json:"level"` // viewer | operator | owner
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
UpdatedAt time.Time `json:"updatedAt"`
|
||||
}
|
||||
|
||||
// PipelineNode 画布节点(JSON 字段名与前端 React Flow 对齐)
|
||||
type PipelineNode struct {
|
||||
ID uint `gorm:"primaryKey" json:"dbId"`
|
||||
|
||||
Reference in new issue
Block a user