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