Files
Logan.Li 9103b13510 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 文档补权限章节
2026-10-27 17:34:41 +08:00

213 lines
6.6 KiB
Go

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})
}