权限模型(借鉴 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 文档补权限章节
213 lines
6.6 KiB
Go
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})
|
|
}
|