Files
FlowStack/backend/main.go
T
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

226 lines
8.6 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package main
import (
"embed"
"io/fs"
"net/http"
"os"
"strings"
"github.com/gin-contrib/cors"
"github.com/gin-gonic/gin"
"flowstack/internal/database"
"flowstack/internal/handler"
)
// 前端产物在构建期拷贝到 static/ 并嵌入二进制(run.sh build)
//
//go:embed all:static
var staticFS embed.FS
// 使用指南与接口文档(运行目录 docs/ 优先)
//
//go:embed docs/GUIDE.md docs/API.md
var docsFS embed.FS
func main() {
if err := database.Init(); err != nil {
panic("数据库初始化失败: " + err.Error())
}
hub := handler.NewHub()
r := gin.Default()
// 会话鉴权:所有 /api 需登录(豁免登录/健康检查/入站 Webhook/SSE 日志流)
r.Use(handler.AuthMiddleware())
// ?_clear=1 时下发 Clear-Site-Data,用于清除浏览器缓存的错误重定向等脏缓存
// (历史 bug:/index.html 曾返回 301 → ./,浏览器永久缓存后反复触发 ERR_TOO_MANY_REDIRECTS)
r.Use(func(c *gin.Context) {
if c.Query("_clear") != "" {
c.Header("Clear-Site-Data", "\"cache\"")
}
c.Next()
})
r.Use(cors.New(cors.Config{
AllowAllOrigins: true,
AllowMethods: []string{"GET", "POST", "PUT", "DELETE", "OPTIONS"},
AllowHeaders: []string{"*"},
AllowCredentials: false,
}))
r.GET("/health", func(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"status": "ok", "app": "flowstack"})
})
api := r.Group("/api")
{
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.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.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.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.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))
// 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)
}
// 插件市场 → 管理员
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)
}
// 资源(主机/集群/凭证/构建节点/工具/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")
r.NoRoute(func(c *gin.Context) {
path := strings.TrimPrefix(c.Request.URL.Path, "/")
if path != "" {
if f, err := distFS.Open(path); err == nil {
f.Close()
c.FileFromFS("/"+path, http.FS(distFS))
return
}
}
// SPA 路由回退:直接返回 index.html 内容
// (不能用 FileFromFS,http.FileServer 会把 /index.html 301 到 ./ 造成死循环)
index, err := fs.ReadFile(distFS, "index.html")
if err != nil {
c.String(http.StatusNotFound, "前端资源未构建")
return
}
c.Data(http.StatusOK, "text/html; charset=utf-8", index)
})
port := os.Getenv("PORT")
if port == "" {
port = "9527"
}
if err := r.Run(":" + port); err != nil {
panic("服务启动失败: " + err.Error())
}
}