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

+29 -13
View File
@@ -37,25 +37,41 @@ LDAP 用户名在目录验证通过后自动建号/同步(JIT);错误统
| 接口 | 方法 | 说明 |
|---|---|---|
| `/api/pipelines` | GET | 列表(每条附 `lastRun` 最近运行) |
| `/api/pipelines` | POST | 创建(name 必填;deployType: host/kubernetes 值为 `k8s`/gitops) |
| `/api/pipelines/:id` | GET | 详情(pipeline + nodes + edges + lastRun) |
| `/api/pipelines/:id` | PUT | 更新(白名单字段,含触发器/变量/缓存) |
| `/api/pipelines/:id` | DELETE | 删除(级联删节点连线) |
| `/api/pipelines/:id/nodes` | PUT | 全量保存画布节点 |
| `/api/pipelines/:id/edges` | PUT | 全量保存画布连线 |
| `/api/pipelines/:id/runs` | POST | 触发运行(body 可带 branch/env/triggerType) |
| `/api/pipelines` | GET | 列表(每条附 `lastRun` 与 `accessLevel`;普通用户仅返回被授权的) |
| `/api/pipelines` | POST | 创建(**管理员**) |
| `/api/pipelines/:id` | GET | 详情(pipeline + nodes + edges + lastRun + `accessLevel`;需「查看」) |
| `/api/pipelines/:id` | PUT | 更新(需「管理」) |
| `/api/pipelines/:id` | DELETE | 删除(**管理员**;级联删节点连线) |
| `/api/pipelines/:id/nodes` | PUT | 全量保存画布节点(需「管理」) |
| `/api/pipelines/:id/edges` | PUT | 全量保存画布连线(需「管理」) |
| `/api/pipelines/:id/runs` | POST | 触发运行(需「发布」;body 可带 branch/env/triggerType) |
`type=jenkins` 且已关联服务器的流水线触发时走真实 Jenkins 引擎(队列→构建→增量日志回传)。
## 流水线权限
三级等级:`viewer` 查看 / `operator` 发布 / `owner` 管理;管理员全局豁免。未被授权的普通用户默认不可见任何流水线。
| 接口 | 方法 | 说明 |
|---|---|---|
| `/api/me` | GET | 当前会话(username/role/isAdmin) |
| `/api/pipeline-roles` | GET | 权限组列表(**管理员**;附 matchedPipelines 匹配数) |
| `/api/pipeline-roles` | POST | 创建(name/scopeMode=manual|pattern/pattern/pipelineIds/level/members;pattern 仅支持 `*` 通配符) |
| `/api/pipeline-roles/:id` | PUT/DELETE | 更新 / 删除(**管理员**) |
| `/api/pipelines/:id/members` | GET | 单流水线直接成员 + 命中的权限组(需「查看」) |
| `/api/pipelines/:id/members` | POST | 添加/更新成员等级(需「管理」;body: username/level) |
| `/api/pipelines/:id/members/:mid` | DELETE | 移除直接授权(需「管理」) |
资源(主机/集群/凭证/构建节点/工具/Webhook)、Jenkins 服务器、GitOps、插件与系统设置接口均要求**管理员**。
## 运行记录
| 接口 | 方法 | 说明 |
|---|---|---|
| `/api/runs` | GET | 运行列表(`?pipelineId=&status=`) |
| `/api/runs/:runId` | GET | 详情(run + stages + logs;失败含 AI `analysis`) |
| `/api/runs/:runId/logs/stream` | GET | SSE 实时日志(免鉴权;事件 connected/log,历史先回放) |
| `/api/runs/:runId/retry` | POST | 以同分支重新触发 |
| `/api/runs` | GET | 运行列表(`?pipelineId=&status=`;普通用户仅返回被授权流水线的,每条附 `canRetry`) |
| `/api/runs/:runId` | GET | 详情(run + stages + logs;失败含 AI `analysis`;需「查看」) |
| `/api/runs/:runId/logs/stream` | GET | SSE 实时日志(需登录:`?token=` 传会话 token 且需「查看」;事件 connected/log) |
| `/api/runs/:runId/retry` | POST | 以同分支重新触发(需「发布」) |
## AI 与批量发布
@@ -133,7 +149,7 @@ curl -X POST http://localhost:9527/api/ai/release \
|---|---|
| 400 | 参数错误 / 未启用对应功能 |
| 401 | 未登录 / token 过期 / 用户名或密码错误 |
| 403 | 需要 admin 角色 |
| 403 | 需要 admin 角色 / 无该流水线的对应权限等级 |
| 404 | 资源不存在 |
| 409 | 名称重复等冲突 |
+27
View File
@@ -119,6 +119,33 @@ cex-agent-admin
- 凭证内容 AES 加密存储、只写不回显,编辑留空=不修改
- SSH 凭证关联构建节点后即可远程磁盘测试
## 流水线权限
**是什么**:按流水线控制「谁能看、谁能发布、谁能改」。未被授权的普通用户看不到任何流水线与发布记录(默认拒绝);管理员始终拥有全部权限。
**三级权限**:
| 等级 | 能做什么 |
|---|---|
| 查看 | 看流水线、发布记录、发布成功/失败日志(只读,不能构建) |
| 发布 | 查看 + 触发构建、批量发布、失败重试 |
| 管理 | 发布 + 编辑流水线配置、管理该流水线的成员授权 |
**怎么授权(管理员,系统 → 流水线权限)**:
- 新建权限组 → **选择流水线**:直接勾选要授权的流水线(推荐,全程点选)
- 或 **名称匹配(高级)**:只写 `cex-*` 这类通配符(`*` 代表任意字符,无需正则),一次授权整批,之后新建的同名流水线自动纳入
- 选好权限等级,勾选成员,保存即生效
- 也可以在单条流水线里:编辑器顶栏「成员」按钮,给个别人单独授权(作为权限组的例外补充)
**普通用户能看什么**:
- 菜单只剩「发布」组(流水线/批量发布/发布记录)和「文档中心」;资源、集成、GitOps、系统管理类菜单全部隐藏(直接输 URL 也会被拦回)
- 流水线列表只显示被授权的;按钮按等级显隐:查看级只有只读入口,发布级出现「执行/保存并运行/重试」,管理级才有「保存/成员」
- 新建/删除流水线、凭证/主机/集群/构建节点等资源管理仅管理员
**技巧**:
- AI 助手批量发布同样受控:对未授权的 Job 会在结果里明确标注「无该流水线的执行权限」
- 构建日志 SSE 已要求登录(`?token=` 传递会话),未登录直接访问返回 401
## 常见问题
**初始密码在哪**:服务器 `data/initial-admin-password.txt` 或 `grep -A6 "已生成管理员账号" logs/flowstack.log`
+1
View File
@@ -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{},
+14 -1
View File
@@ -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
+20 -6
View File
@@ -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) {
+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()
}
}
+212
View File
@@ -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 -4
View File
@@ -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
+33 -4
View File
@@ -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
+24
View File
@@ -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"`
+107 -68
View File
@@ -59,101 +59,140 @@ func main() {
{
api.POST("/auth/login", handler.Login)
api.POST("/auth/logout", func(c *gin.Context) { c.JSON(200, gin.H{"success": true}) })
api.GET("/me", handler.Me)
// 用户管理(管理员)
// 用户管理(列表登录即可读——权限成员选择器需要;增删改仅管理员)
api.GET("/users", handler.ListUsers)
users := api.Group("/users", handler.AdminOnly())
{
users.GET("", handler.ListUsers)
users.POST("", handler.CreateUser)
users.PUT("/:id", handler.UpdateUser)
users.DELETE("/:id", handler.DeleteUser)
users.POST("/:id/reset-password", handler.ResetPassword)
users.POST("/ldap-import", handler.LdapImport)
}
// 流水线权限角色(管理员;Jenkins Item-role 正则风格)
pipelineRoles := api.Group("/pipeline-roles", handler.AdminOnly())
{
pipelineRoles.GET("", handler.ListPipelineRoles)
pipelineRoles.POST("", handler.CreatePipelineRole)
pipelineRoles.PUT("/:id", handler.UpdatePipelineRole)
pipelineRoles.DELETE("/:id", handler.DeletePipelineRole)
}
api.POST("/ldap/users", handler.AdminOnly(), handler.LdapListUsers)
api.POST("/settings/ldap/test", handler.AdminOnly(), handler.TestLDAP)
api.GET("/docs", handler.GetDocs(docsFS))
api.GET("/summary", handler.GetSummary)
api.GET("/settings", handler.GetSettings)
api.PUT("/settings", handler.UpdateSettings)
api.POST("/settings/test-llm", handler.TestLLM)
api.POST("/settings/test-lark", handler.TestLark)
api.GET("/settings", handler.AdminOnly(), handler.GetSettings)
api.PUT("/settings", handler.AdminOnly(), handler.UpdateSettings)
api.POST("/settings/test-llm", handler.AdminOnly(), handler.TestLLM)
api.POST("/settings/test-lark", handler.AdminOnly(), handler.TestLark)
api.POST("/ai/chat", handler.AiChat)
api.POST("/ai/chat/stream", handler.AiChatStream)
api.POST("/ai/release", handler.AiRelease(hub))
api.POST("/batch/parse", handler.BatchParse)
// 流水线:查看 viewer / 编辑配置 owner / 触发构建 operator;新建与删除仅管理员
api.GET("/pipelines", handler.ListPipelines)
api.POST("/pipelines", handler.CreatePipeline)
api.GET("/pipelines/:id", handler.GetPipelineDetail)
api.PUT("/pipelines/:id", handler.UpdatePipeline)
api.DELETE("/pipelines/:id", handler.DeletePipeline)
api.PUT("/pipelines/:id/nodes", handler.SaveNodes)
api.PUT("/pipelines/:id/edges", handler.SaveEdges)
api.POST("/pipelines/:id/runs", handler.CreateRun(hub))
api.POST("/pipelines", handler.AdminOnly(), handler.CreatePipeline)
api.GET("/pipelines/:id", handler.RequirePipelineAccess(handler.LevelRank(handler.LevelViewer)), handler.GetPipelineDetail)
api.PUT("/pipelines/:id", handler.RequirePipelineAccess(handler.LevelRank(handler.LevelOwner)), handler.UpdatePipeline)
api.DELETE("/pipelines/:id", handler.AdminOnly(), handler.DeletePipeline)
api.PUT("/pipelines/:id/nodes", handler.RequirePipelineAccess(handler.LevelRank(handler.LevelOwner)), handler.SaveNodes)
api.PUT("/pipelines/:id/edges", handler.RequirePipelineAccess(handler.LevelRank(handler.LevelOwner)), handler.SaveEdges)
api.POST("/pipelines/:id/runs", handler.RequirePipelineAccess(handler.LevelRank(handler.LevelOperator)), handler.CreateRun(hub))
api.POST("/hooks/pipeline/:id", handler.InboundPipelineHook(hub))
// 单流水线成员:查看列表 viewer,增删 owner
api.GET("/pipelines/:id/members", handler.RequirePipelineAccess(handler.LevelRank(handler.LevelViewer)), handler.ListPipelineMembers)
api.POST("/pipelines/:id/members", handler.RequirePipelineAccess(handler.LevelRank(handler.LevelOwner)), handler.AddPipelineMember)
api.DELETE("/pipelines/:id/members/:mid", handler.RequirePipelineAccess(handler.LevelRank(handler.LevelOwner)), handler.DeletePipelineMember)
api.GET("/runs", handler.ListRuns)
api.GET("/runs/:runId", handler.GetRun)
api.GET("/runs/:runId/logs/stream", handler.StreamLogs(hub))
api.POST("/runs/:runId/retry", handler.RetryRun(hub))
api.GET("/runs/:runId", handler.RequireRunAccess(handler.LevelRank(handler.LevelViewer)), handler.GetRun)
api.GET("/runs/:runId/logs/stream", handler.RequireRunAccess(handler.LevelRank(handler.LevelViewer)), handler.StreamLogs(hub))
api.POST("/runs/:runId/retry", handler.RequireRunAccess(handler.LevelRank(handler.LevelOperator)), handler.RetryRun(hub))
api.GET("/gitops/apps", handler.ListGitOpsApps)
api.GET("/gitops/argo/apps", handler.ArgoList)
api.POST("/gitops/argo/sync", handler.ArgoSync)
api.GET("/gitops/argo/:appName/tree", handler.ArgoResourceTree)
api.GET("/gitops/argo/:appName/diff", handler.ArgoDiff)
// GitOps 部署(全局基础设施操作,未按流水线隔离)→ 管理员
gitops := api.Group("/gitops", handler.AdminOnly())
{
gitops.GET("/apps", handler.ListGitOpsApps)
gitops.GET("/argo/apps", handler.ArgoList)
gitops.POST("/argo/sync", handler.ArgoSync)
gitops.GET("/argo/:appName/tree", handler.ArgoResourceTree)
gitops.GET("/argo/:appName/diff", handler.ArgoDiff)
}
api.GET("/plugins", handler.ListPlugins)
api.GET("/plugins/available", handler.AvailablePlugins)
api.POST("/plugins/install", handler.InstallPlugin)
api.POST("/plugins/:id/toggle", handler.TogglePlugin)
api.DELETE("/plugins/:id", handler.UninstallPlugin)
// 插件市场 → 管理员
plugins := api.Group("/plugins", handler.AdminOnly())
{
plugins.GET("", handler.ListPlugins)
plugins.GET("/available", handler.AvailablePlugins)
plugins.POST("/install", handler.InstallPlugin)
plugins.POST("/:id/toggle", handler.TogglePlugin)
plugins.DELETE("/:id", handler.UninstallPlugin)
}
api.GET("/hosts", handler.ListHosts)
api.POST("/hosts", handler.CreateHost)
api.PUT("/hosts/:id", handler.UpdateHost)
api.DELETE("/hosts/:id", handler.DeleteHost)
api.POST("/hosts/:id/test", handler.TestHost)
api.GET("/clusters", handler.ListClusters)
api.POST("/clusters", handler.CreateCluster)
api.PUT("/clusters/:id", handler.UpdateCluster)
api.DELETE("/clusters/:id", handler.DeleteCluster)
api.POST("/clusters/:id/test", handler.TestCluster)
api.GET("/credentials", handler.ListCredentials)
api.POST("/credentials", handler.CreateCredential)
api.PUT("/credentials/:id", handler.UpdateCredential)
api.DELETE("/credentials/:id", handler.DeleteCredential)
api.GET("/build-nodes", handler.ListBuildNodes)
api.POST("/build-nodes", handler.CreateBuildNode)
api.PUT("/build-nodes/:id", handler.UpdateBuildNode)
api.DELETE("/build-nodes/:id", handler.DeleteBuildNode)
api.POST("/build-nodes/:id/test", handler.TestBuildNode)
api.POST("/build-nodes/:id/disk-test", handler.TestBuildNodeDisk)
api.GET("/tools", handler.ListTools)
api.PUT("/tools/:id", handler.UpdateTool)
api.POST("/tools/:id/check", handler.CheckTool)
api.POST("/tools/:id/install", handler.InstallTool)
api.GET("/webhooks", handler.ListWebhooks)
api.POST("/webhooks", handler.CreateWebhook)
api.PUT("/webhooks/:id", handler.UpdateWebhook)
api.DELETE("/webhooks/:id", handler.DeleteWebhook)
api.POST("/webhooks/:id/test", handler.TestWebhook)
api.GET("/jenkins-servers", handler.ListJenkinsServers)
api.POST("/jenkins-servers", handler.CreateJenkinsServer)
api.PUT("/jenkins-servers/:id", handler.UpdateJenkinsServer)
api.DELETE("/jenkins-servers/:id", handler.DeleteJenkinsServer)
api.POST("/jenkins-servers/:id/test", handler.TestJenkinsServer)
api.GET("/jenkins-servers/:id/jobs", handler.ListJenkinsJobs)
api.POST("/jenkins-servers/:id/import", handler.ImportJenkinsJobs)
// 资源(主机/集群/凭证/构建节点/工具/Webhook)与 Jenkins 集成 → 管理员
hosts := api.Group("/hosts", handler.AdminOnly())
{
hosts.GET("", handler.ListHosts)
hosts.POST("", handler.CreateHost)
hosts.PUT("/:id", handler.UpdateHost)
hosts.DELETE("/:id", handler.DeleteHost)
hosts.POST("/:id/test", handler.TestHost)
}
clusters := api.Group("/clusters", handler.AdminOnly())
{
clusters.GET("", handler.ListClusters)
clusters.POST("", handler.CreateCluster)
clusters.PUT("/:id", handler.UpdateCluster)
clusters.DELETE("/:id", handler.DeleteCluster)
clusters.POST("/:id/test", handler.TestCluster)
}
credentials := api.Group("/credentials", handler.AdminOnly())
{
credentials.GET("", handler.ListCredentials)
credentials.POST("", handler.CreateCredential)
credentials.PUT("/:id", handler.UpdateCredential)
credentials.DELETE("/:id", handler.DeleteCredential)
}
buildNodes := api.Group("/build-nodes", handler.AdminOnly())
{
buildNodes.GET("", handler.ListBuildNodes)
buildNodes.POST("", handler.CreateBuildNode)
buildNodes.PUT("/:id", handler.UpdateBuildNode)
buildNodes.DELETE("/:id", handler.DeleteBuildNode)
buildNodes.POST("/:id/test", handler.TestBuildNode)
buildNodes.POST("/:id/disk-test", handler.TestBuildNodeDisk)
}
tools := api.Group("/tools", handler.AdminOnly())
{
tools.GET("", handler.ListTools)
tools.PUT("/:id", handler.UpdateTool)
tools.POST("/:id/check", handler.CheckTool)
tools.POST("/:id/install", handler.InstallTool)
}
webhooks := api.Group("/webhooks", handler.AdminOnly())
{
webhooks.GET("", handler.ListWebhooks)
webhooks.POST("", handler.CreateWebhook)
webhooks.PUT("/:id", handler.UpdateWebhook)
webhooks.DELETE("/:id", handler.DeleteWebhook)
webhooks.POST("/:id/test", handler.TestWebhook)
}
jenkins := api.Group("/jenkins-servers", handler.AdminOnly())
{
jenkins.GET("", handler.ListJenkinsServers)
jenkins.POST("", handler.CreateJenkinsServer)
jenkins.PUT("/:id", handler.UpdateJenkinsServer)
jenkins.DELETE("/:id", handler.DeleteJenkinsServer)
jenkins.POST("/:id/test", handler.TestJenkinsServer)
jenkins.GET("/:id/jobs", handler.ListJenkinsJobs)
jenkins.POST("/:id/import", handler.ImportJenkinsJobs)
}
}
distFS, _ := fs.Sub(staticFS, "static")