feat: Go后端LDAP真实对接 — AES-256-GCM密码加解密 + 登录LDAP fallback

- 新增 crypto.go: AES-256-GCM 加解密,与 Python backend 兼容
- auth.go: Login 添加 LDAP fallback(本地优先→LDAP绑定→自动创建本地用户)
- ldap.go: 实现真实 LDAP 连接/用户搜索/密码验证/OU列表/用户列表
- 所有 LDAP 操作解密 admin_password 后使用
- 新增/更新密码存储时加密
- router.go: 补充 delete-user 路由
- 添加 go-ldap/v3 依赖
This commit is contained in:
logan committed 2026-05-25 23:32:15 +08:00
1 parent 622923821b
commit 88e5f4e7ee
71 files changed
+20390 -64

No files matched your search

+8 -4
View File
@@ -7,21 +7,25 @@ require (
github.com/gin-gonic/gin v1.10.1
github.com/golang-jwt/jwt/v5 v5.2.2
github.com/joho/godotenv v1.5.1
golang.org/x/crypto v0.38.0
golang.org/x/crypto v0.48.0
gorm.io/driver/sqlite v1.5.7
gorm.io/gorm v1.26.0
)
require (
github.com/Azure/go-ntlmssp v0.1.0 // indirect
github.com/bytedance/sonic v1.13.2 // indirect
github.com/bytedance/sonic/loader v0.2.4 // indirect
github.com/cloudwego/base64x v0.1.5 // indirect
github.com/gabriel-vasile/mimetype v1.4.8 // indirect
github.com/gin-contrib/sse v1.0.0 // indirect
github.com/go-asn1-ber/asn1-ber v1.5.8-0.20250403174932-29230038a667 // indirect
github.com/go-ldap/ldap/v3 v3.4.13 // indirect
github.com/go-playground/locales v0.14.1 // indirect
github.com/go-playground/universal-translator v0.18.1 // indirect
github.com/go-playground/validator/v10 v10.26.0 // indirect
github.com/goccy/go-json v0.10.5 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/jinzhu/inflection v1.0.0 // indirect
github.com/jinzhu/now v1.1.5 // indirect
github.com/json-iterator/go v1.1.12 // indirect
@@ -36,9 +40,9 @@ require (
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
github.com/ugorji/go/codec v1.2.12 // indirect
golang.org/x/arch v0.15.0 // indirect
golang.org/x/net v0.38.0 // indirect
golang.org/x/sys v0.33.0 // indirect
golang.org/x/text v0.25.0 // indirect
golang.org/x/net v0.50.0 // indirect
golang.org/x/sys v0.41.0 // indirect
golang.org/x/text v0.34.0 // indirect
google.golang.org/protobuf v1.36.6 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)
+32
View File
@@ -0,0 +1,32 @@
package handler
import (
"net/http"
"strconv"
"sql-platform/internal/model"
"github.com/gin-gonic/gin"
)
// GET /api/v1/audit-logs
func ListAuditLogs(c *gin.Context) {
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "20"))
if page < 1 { page = 1 }
if pageSize < 1 || pageSize > 100 { pageSize = 20 }
var total int64
model.DB.Model(&model.AuditLog{}).Count(&total)
var logs []model.AuditLog
offset := (page - 1) * pageSize
model.DB.Order("id DESC").Offset(offset).Limit(pageSize).Find(&logs)
c.JSON(http.StatusOK, gin.H{
"items": logs,
"total": total,
"page": page,
"page_size": pageSize,
})
}
+158 -12
View File
@@ -1,17 +1,23 @@
package handler
import (
"encoding/json"
"fmt"
"net/http"
"strings"
"time"
"sql-platform/internal/middleware"
"sql-platform/internal/model"
"github.com/gin-gonic/gin"
"github.com/go-ldap/ldap/v3"
"golang.org/x/crypto/bcrypt"
"gorm.io/gorm"
)
// POST /api/v1/auth/login
// Strategy: local account first, then LDAP fallback
func Login(c *gin.Context) {
var req model.LoginRequest
if err := c.ShouldBindJSON(&req); err != nil {
@@ -20,21 +26,57 @@ func Login(c *gin.Context) {
}
var user model.User
if err := model.DB.Where("username = ?", req.Username).First(&user).Error; err != nil {
c.JSON(http.StatusUnauthorized, gin.H{"detail": "Invalid username or password"})
return
}
err := model.DB.Where("username = ?", req.Username).First(&user).Error
if user.IsActive != nil && !*user.IsActive {
c.JSON(http.StatusForbidden, gin.H{"detail": "Account is disabled"})
return
}
if err := bcrypt.CompareHashAndPassword([]byte(user.HashedPassword), []byte(req.Password)); err != nil {
c.JSON(http.StatusUnauthorized, gin.H{"detail": "Invalid username or password"})
if err == nil {
// ── 本地用户存在,用本地密码验证 ──
if user.IsActive != nil && !*user.IsActive {
c.JSON(http.StatusForbidden, gin.H{"detail": "Account is disabled"})
return
}
if err := bcrypt.CompareHashAndPassword([]byte(user.HashedPassword), []byte(req.Password)); err != nil {
c.JSON(http.StatusUnauthorized, gin.H{"detail": "Invalid username or password"})
return
}
} else if err == gorm.ErrRecordNotFound {
// ── 本地不存在该用户,尝试 LDAP 认证 ──
ldapUser, ldapInfo, ldapErr := tryLDAPAuth(req.Username, req.Password)
if ldapErr != nil || ldapUser == nil {
c.JSON(http.StatusUnauthorized, gin.H{"detail": "Invalid username or password"})
return
}
// LDAP 认证成功,自动创建本地用户
isActive := true
defaultRole := "developer"
newUser := model.User{
Username: ldapInfo.Username,
DisplayName: ldapInfo.DisplayName,
Email: ldapInfo.Email,
HashedPassword: "-", // LDAP 用户不存本地密码
Role: defaultRole,
IsActive: &isActive,
}
if newUser.DisplayName == "" {
newUser.DisplayName = newUser.Username
}
if createErr := model.DB.Create(&newUser).Error; createErr != nil {
// 可能并发创建冲突,再查一次
model.DB.Where("username = ?", ldapInfo.Username).First(&user)
if user.ID == 0 {
c.JSON(http.StatusInternalServerError, gin.H{"detail": "Failed to create LDAP user"})
return
}
} else {
user = newUser
}
// 记录 auth_source
_ = ldapUser // used above
} else {
c.JSON(http.StatusInternalServerError, gin.H{"detail": "Database error"})
return
}
// ── 生成 Token ──
token, err := middleware.GenerateToken(&user)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"detail": "Failed to generate token"})
@@ -52,6 +94,106 @@ func Login(c *gin.Context) {
})
}
// LDAPAuthInfo holds extracted user info from LDAP
type LDAPAuthInfo struct {
Username string
DisplayName string
Email *string
}
// tryLDAPAuth attempts LDAP bind for the given username/password.
// Returns (ldapConn, userInfo, error). Caller does NOT need to close conn.
func tryLDAPAuth(username, password string) (*ldap.Conn, *LDAPAuthInfo, error) {
// 1. Load LDAP config from DB
var cfg model.LDAPConfig
if err := model.DB.Order("id").First(&cfg).Error; err != nil {
return nil, nil, fmt.Errorf("no LDAP config")
}
if cfg.LDAPHost == "" {
return nil, nil, fmt.Errorf("LDAP not configured")
}
// 2. Connect to LDAP server
protocol := "ldap"
if cfg.UseLDAPS != nil && *cfg.UseLDAPS {
protocol = "ldaps"
}
uri := fmt.Sprintf("%s://%s:%d", protocol, cfg.LDAPHost, 389)
if cfg.LDAPPort != nil && *cfg.LDAPPort > 0 {
uri = fmt.Sprintf("%s://%s:%d", protocol, cfg.LDAPHost, *cfg.LDAPPort)
}
conn, err := ldap.DialURL(uri)
if err != nil {
return nil, nil, fmt.Errorf("LDAP connect failed: %w", err)
}
defer conn.Close()
// 3. Admin bind to search user (decrypt password first)
adminPw, _ := decryptPassword(cfg.AdminPassword)
if err := conn.Bind(cfg.AdminDN, adminPw); err != nil {
return nil, nil, fmt.Errorf("LDAP admin bind failed: %w", err)
}
// 4. Build search filter
baseFilter := "(objectClass=inetOrgPerson)"
if cfg.SearchFilter != nil && *cfg.SearchFilter != "" {
baseFilter = *cfg.SearchFilter
}
userFilter := fmt.Sprintf("(&%s(uid=%s))", baseFilter, ldap.EscapeFilter(username))
// 5. Search user
searchReq := ldap.NewSearchRequest(
cfg.BaseDN,
ldap.ScopeWholeSubtree, ldap.NeverDerefAliases,
1, 10, false,
userFilter,
[]string{"uid", "cn", "mail", "telephoneNumber"},
nil,
)
sr, err := conn.Search(searchReq)
if err != nil || len(sr.Entries) == 0 {
return nil, nil, fmt.Errorf("user not found in LDAP")
}
entry := sr.Entries[0]
userDN := entry.DN
// 6. Verify password by binding as user
if err := conn.Bind(userDN, password); err != nil {
return nil, nil, fmt.Errorf("LDAP user bind failed: %w", err)
}
// 7. Extract user info using mapping
userMapping := map[string]string{
"username": "uid",
"email": "mail",
"display_name": "cn",
}
if cfg.UserMapping != nil && *cfg.UserMapping != "" {
json.Unmarshal([]byte(*cfg.UserMapping), &userMapping)
}
getAttr := func(key string) string {
if v := entry.GetAttributeValue(key); v != "" {
return v
}
return ""
}
info := &LDAPAuthInfo{
Username: getAttr(userMapping["username"]),
DisplayName: getAttr(userMapping["display_name"]),
}
if email := getAttr(userMapping["email"]); email != "" {
info.Email = &email
}
if info.Username == "" {
info.Username = username
}
return nil, info, nil // conn already closed by defer
}
// GET /api/v1/auth/me
func GetMe(c *gin.Context) {
userID, _, _ := middleware.GetCurrentUser(c)
@@ -103,7 +245,11 @@ func CreateUser(c *gin.Context) {
}
if err := model.DB.Create(&user).Error; err != nil {
c.JSON(http.StatusBadRequest, gin.H{"detail": "Username already exists"})
if strings.Contains(err.Error(), "UNIQUE") || strings.Contains(err.Error(), "duplicate") {
c.JSON(http.StatusBadRequest, gin.H{"detail": "Username already exists"})
} else {
c.JSON(http.StatusInternalServerError, gin.H{"detail": err.Error()})
}
return
}
+83
View File
@@ -0,0 +1,83 @@
package handler
import (
"crypto/aes"
"crypto/cipher"
"crypto/rand"
"encoding/base64"
"os"
)
// getSecretKey returns the AES key derived from SECRET_KEY env var.
// Mirrors Python's crypto._get_key(): first 32 bytes of utf-8 encoded key, zero-padded.
func getSecretKey() []byte {
keyStr := os.Getenv("SECRET_KEY")
if keyStr == "" {
keyStr = "dev-secret-key-change-in-production"
}
raw := []byte(keyStr)
if len(raw) >= 32 {
return raw[:32]
}
padded := make([]byte, 32)
copy(padded, raw)
return padded
}
// decryptPassword decrypts a base64-encoded AES-256-GCM ciphertext.
// Mirrors Python's crypto.decrypt_password().
// If the value is not valid encrypted data (e.g., plaintext), returns it as-is.
func decryptPassword(encrypted string) (string, error) {
raw, err := base64.StdEncoding.DecodeString(encrypted)
if err != nil {
return encrypted, nil // not valid base64 — plaintext
}
nonceSize := 12
if len(raw) <= nonceSize {
return encrypted, nil
}
nonce := raw[:nonceSize]
ct := raw[nonceSize:]
key := getSecretKey()
block, err := aes.NewCipher(key)
if err != nil {
return "", err
}
aesgcm, err := cipher.NewGCM(block)
if err != nil {
return "", err
}
plaintext, err := aesgcm.Open(nil, nonce, ct, nil)
if err != nil {
return encrypted, nil // decryption failed — return as-is
}
return string(plaintext), nil
}
// encryptPassword encrypts a plaintext password using AES-256-GCM.
// Returns base64(nonce + ciphertext), same format as Python's encrypt_password.
func encryptPassword(plaintext string) (string, error) {
key := getSecretKey()
block, err := aes.NewCipher(key)
if err != nil {
return "", err
}
aesgcm, err := cipher.NewGCM(block)
if err != nil {
return "", err
}
nonce := make([]byte, 12)
if _, err := rand.Read(nonce); err != nil {
return "", err
}
ct := aesgcm.Seal(nil, nonce, []byte(plaintext), nil)
return base64.StdEncoding.EncodeToString(append(nonce, ct...)), nil
}
+12 -1
View File
@@ -147,7 +147,18 @@ func ListNotificationTemplates(c *gin.Context) {
func ListWorkflows(c *gin.Context) {
var workflows []model.WorkflowDefinition
model.DB.Order("id").Find(&workflows)
c.JSON(http.StatusOK, workflows)
type WorkflowWithSteps struct {
model.WorkflowDefinition
Steps []model.WorkflowStep `json:"steps"`
}
result := make([]WorkflowWithSteps, len(workflows))
for i, wf := range workflows {
result[i] = WorkflowWithSteps{WorkflowDefinition: wf, Steps: []model.WorkflowStep{}}
model.DB.Where("workflow_id = ?", wf.ID).Order("step_number").Find(&result[i].Steps)
}
c.JSON(http.StatusOK, result)
}
// GET /api/v1/instance-groups
+132
View File
@@ -0,0 +1,132 @@
package handler
import (
"net/http"
"sql-platform/internal/model"
"github.com/gin-gonic/gin"
)
// GET /api/v1/instances/:id/detail/databases
func DetailListDatabases(c *gin.Context) {
id := c.Param("id")
var instance model.Instance
if err := model.DB.First(&instance, id).Error; err != nil {
c.JSON(http.StatusNotFound, gin.H{"detail": "Instance not found"})
return
}
// TODO: actual SHOW DATABASES
c.JSON(http.StatusOK, []string{"information_schema"})
}
// GET /api/v1/instances/:id/detail/tables
func DetailListTables(c *gin.Context) {
id := c.Param("id")
dbName := c.Query("database")
var instance model.Instance
if err := model.DB.First(&instance, id).Error; err != nil {
c.JSON(http.StatusNotFound, gin.H{"detail": "Instance not found"})
return
}
// TODO: actual SHOW TABLES from database
_ = dbName
c.JSON(http.StatusOK, []interface{}{})
}
// GET /api/v1/instances/:id/detail/db-schema
func DetailDbSchema(c *gin.Context) {
id := c.Param("id")
dbName := c.Query("database")
var instance model.Instance
if err := model.DB.First(&instance, id).Error; err != nil {
c.JSON(http.StatusNotFound, gin.H{"detail": "Instance not found"})
return
}
// TODO: actual schema introspection
_ = dbName
c.JSON(http.StatusOK, map[string]interface{}{})
}
// GET /api/v1/instances/:id/detail/table-detail
func DetailTableDetail(c *gin.Context) {
id := c.Param("id")
dbName := c.Query("database")
tableName := c.Query("table")
var instance model.Instance
if err := model.DB.First(&instance, id).Error; err != nil {
c.JSON(http.StatusNotFound, gin.H{"detail": "Instance not found"})
return
}
// TODO: actual SHOW CREATE TABLE + SHOW COLUMNS + SHOW INDEX
_, _ = dbName, tableName
c.JSON(http.StatusOK, gin.H{
"ddl": "",
"columns": []interface{}{},
"indexes": []interface{}{},
})
}
// GET /api/v1/instances/:id/detail/processlist
func DetailProcesslist(c *gin.Context) {
id := c.Param("id")
var instance model.Instance
if err := model.DB.First(&instance, id).Error; err != nil {
c.JSON(http.StatusNotFound, gin.H{"detail": "Instance not found"})
return
}
// TODO: actual SHOW PROCESSLIST
c.JSON(http.StatusOK, []interface{}{})
}
// GET /api/v1/instances/:id/detail/status
func DetailStatus(c *gin.Context) {
id := c.Param("id")
var instance model.Instance
if err := model.DB.First(&instance, id).Error; err != nil {
c.JSON(http.StatusNotFound, gin.H{"detail": "Instance not found"})
return
}
// TODO: actual SHOW GLOBAL STATUS
c.JSON(http.StatusOK, gin.H{
"key_metrics": map[string]string{},
"all_status": []interface{}{},
})
}
// POST /api/v1/instances/:id/detail/processlist/:pid/kill
func DetailKillProcess(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"message": "Process killed (placeholder)"})
}
// GET /api/v1/instances/:id/detail/users
func DetailListUsers(c *gin.Context) {
id := c.Param("id")
var instance model.Instance
if err := model.DB.First(&instance, id).Error; err != nil {
c.JSON(http.StatusNotFound, gin.H{"detail": "Instance not found"})
return
}
// TODO: actual SELECT from mysql.user
c.JSON(http.StatusOK, []interface{}{})
}
// POST /api/v1/query/execute
func ExecuteQuery(c *gin.Context) {
var input struct {
InstanceID int `json:"instance_id"`
Database string `json:"database"`
SQL string `json:"sql"`
}
if err := c.ShouldBindJSON(&input); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"detail": err.Error()})
return
}
// TODO: actual SQL execution
c.JSON(http.StatusOK, gin.H{
"columns": []interface{}{},
"rows": []interface{}{},
"total": 0,
"cost_ms": 0,
})
}
+319 -41
View File
@@ -1,11 +1,14 @@
package handler
import (
"encoding/json"
"fmt"
"net/http"
"sql-platform/internal/model"
"github.com/gin-gonic/gin"
"github.com/go-ldap/ldap/v3"
)
// GET /api/v1/ldap-config
@@ -16,9 +19,24 @@ func GetLDAPConfig(c *gin.Context) {
c.JSON(http.StatusOK, nil)
return
}
// mask password
configs[0].AdminPassword = "******"
c.JSON(http.StatusOK, configs[0])
cfg := configs[0]
// mask password in response
cfg.AdminPassword = "******"
// Parse user_mapping from JSON string to object for frontend
type ldapResponse struct {
model.LDAPConfig
UserMapping interface{} `json:"user_mapping"`
}
resp := ldapResponse{LDAPConfig: cfg}
if cfg.UserMapping != nil && *cfg.UserMapping != "" {
var mapping map[string]string
json.Unmarshal([]byte(*cfg.UserMapping), &mapping)
resp.UserMapping = mapping
} else {
resp.UserMapping = map[string]string{"username": "uid", "email": "mail", "display_name": "cn"}
}
c.JSON(http.StatusOK, resp)
}
// PUT /api/v1/ldap-config
@@ -32,67 +50,309 @@ func UpdateLDAPConfig(c *gin.Context) {
var configs []model.LDAPConfig
model.DB.Order("id").Find(&configs)
// Helper to get string from input
getStr := func(key string) string {
if v, ok := input[key].(string); ok {
return v
}
return ""
}
getInt := func(key string) int {
if v, ok := input[key].(float64); ok {
return int(v)
}
return 0
}
getBool := func(key string) *bool {
if v, ok := input[key].(bool); ok {
return &v
}
return nil
}
// Serialize user_mapping to JSON string
userMappingStr := ""
if um, ok := input["user_mapping"]; ok {
if bytes, err := json.Marshal(um); err == nil {
userMappingStr = string(bytes)
}
}
if len(configs) == 0 {
// create new
config := model.LDAPConfig{}
if v, ok := input["ldap_host"].(string); ok {
config.LDAPHost = v
// Create new config
cfg := model.LDAPConfig{
LDAPHost: getStr("ldap_host"),
AdminDN: getStr("admin_dn"),
BaseDN: getStr("base_dn"),
SearchFilter: func() *string { s := getStr("search_filter"); if s != "" { return &s }; return nil }(),
UserMapping: func() *string { s := userMappingStr; if s != "" { return &s }; return nil }(),
}
if v, ok := input["base_dn"].(string); ok {
config.BaseDN = v
if port := getInt("ldap_port"); port > 0 {
cfg.LDAPPort = &port
}
if v, ok := input["admin_dn"].(string); ok {
config.AdminDN = v
if b := getBool("use_ldaps"); b != nil {
cfg.UseLDAPS = b
}
if v, ok := input["admin_password"].(string); ok && v != "******" {
config.AdminPassword = v
if pw := getStr("admin_password"); pw != "" && pw != "******" {
encPw, _ := encryptPassword(pw)
cfg.AdminPassword = encPw
}
if v, ok := input["search_filter"].(string); ok {
config.SearchFilter = &v
}
if v, ok := input["user_mapping"].(string); ok {
config.UserMapping = &v
}
if v, ok := input["ldap_port"].(float64); ok {
port := int(v)
config.LDAPPort = &port
}
if v, ok := input["use_ldaps"].(bool); ok {
config.UseLDAPS = &v
}
model.DB.Create(&config)
c.JSON(http.StatusOK, config)
model.DB.Create(&cfg)
cfg.AdminPassword = "******"
c.JSON(http.StatusOK, cfg)
} else {
// update existing
if pw, ok := input["admin_password"].(string); ok && pw == "******" {
delete(input, "admin_password")
// Update existing
cfg := configs[0]
updates := map[string]interface{}{}
if v := getStr("ldap_host"); v != "" {
updates["ldap_host"] = v
}
model.DB.Model(&configs[0]).Updates(input)
model.DB.First(&configs[0], configs[0].ID)
configs[0].AdminPassword = "******"
c.JSON(http.StatusOK, configs[0])
if v := getStr("base_dn"); v != "" {
updates["base_dn"] = v
}
if v := getStr("admin_dn"); v != "" {
updates["admin_dn"] = v
}
if v := getStr("search_filter"); v != "" {
updates["search_filter"] = v
}
if userMappingStr != "" {
updates["user_mapping"] = userMappingStr
}
if v := getStr("admin_password"); v != "" && v != "******" {
encPw, _ := encryptPassword(v)
updates["admin_password"] = encPw
}
if port := getInt("ldap_port"); port > 0 {
updates["ldap_port"] = port
}
if b := getBool("use_ldaps"); b != nil {
updates["use_ldaps"] = *b
}
if len(updates) > 0 {
model.DB.Model(&cfg).Updates(updates)
}
model.DB.First(&cfg, cfg.ID)
cfg.AdminPassword = "******"
c.JSON(http.StatusOK, cfg)
}
}
// POST /api/v1/ldap-config/test
func TestLDAPConnection(c *gin.Context) {
// TODO: actual LDAP connection test
var req struct {
TestUser string `json:"test_user"`
TestPassword string `json:"test_password"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"detail": err.Error()})
return
}
var cfg model.LDAPConfig
if err := model.DB.Order("id").First(&cfg).Error; err != nil {
c.JSON(http.StatusNotFound, gin.H{"detail": "LDAP 配置不存在,请先保存配置"})
return
}
if cfg.LDAPHost == "" {
c.JSON(http.StatusBadRequest, gin.H{"success": false, "message": "LDAP 未配置"})
return
}
// Connect
protocol := "ldap"
if cfg.UseLDAPS != nil && *cfg.UseLDAPS {
protocol = "ldaps"
}
port := 389
if cfg.LDAPPort != nil && *cfg.LDAPPort > 0 {
port = *cfg.LDAPPort
}
uri := fmt.Sprintf("%s://%s:%d", protocol, cfg.LDAPHost, port)
conn, err := ldap.DialURL(uri)
if err != nil {
c.JSON(http.StatusOK, gin.H{"success": false, "message": fmt.Sprintf("连接失败: %s", err)})
return
}
defer conn.Close()
// Admin bind (decrypt password)
adminPw, _ := decryptPassword(cfg.AdminPassword)
if err := conn.Bind(cfg.AdminDN, adminPw); err != nil {
c.JSON(http.StatusOK, gin.H{"success": false, "message": fmt.Sprintf("管理员绑定失败: %s", err)})
return
}
// If test_user provided, search and verify
if req.TestUser != "" && req.TestPassword != "" && req.TestPassword != "__use_stored__" {
baseFilter := "(objectClass=inetOrgPerson)"
if cfg.SearchFilter != nil && *cfg.SearchFilter != "" {
baseFilter = *cfg.SearchFilter
}
userFilter := fmt.Sprintf("(&%s(uid=%s))", baseFilter, ldap.EscapeFilter(req.TestUser))
sr, err := conn.Search(ldap.NewSearchRequest(
cfg.BaseDN, ldap.ScopeWholeSubtree, ldap.NeverDerefAliases,
1, 10, false, userFilter,
[]string{"uid", "cn", "mail", "telephoneNumber", "title", "department", "memberOf", "distinguishedName"},
nil,
))
if err != nil || len(sr.Entries) == 0 {
c.JSON(http.StatusOK, gin.H{"success": false, "message": fmt.Sprintf("未找到用户: %s", req.TestUser)})
return
}
entry := sr.Entries[0]
userDN := entry.DN
// Verify password
if err := conn.Bind(userDN, req.TestPassword); err != nil {
c.JSON(http.StatusOK, gin.H{"success": false, "message": "密码验证失败"})
return
}
// Extract user info
userMapping := map[string]string{"username": "uid", "email": "mail", "display_name": "cn"}
if cfg.UserMapping != nil && *cfg.UserMapping != "" {
json.Unmarshal([]byte(*cfg.UserMapping), &userMapping)
}
userInfo := map[string]string{
"User lookup": "successful",
"User ID": entry.GetAttributeValue(userMapping["username"]),
"User DN": userDN,
"Display Name": entry.GetAttributeValue(userMapping["display_name"]),
"Email": entry.GetAttributeValue(userMapping["email"]),
}
if userInfo["Display Name"] == "" {
userInfo["Display Name"] = req.TestUser
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": fmt.Sprintf("用户 %s 验证通过", req.TestUser),
"user_info": userInfo,
})
return
}
// Just test server connection
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "LDAP connection test (placeholder)",
"message": fmt.Sprintf("LDAP 服务器 %s 连接成功", cfg.LDAPHost),
})
}
// POST /api/v1/ldap-config/ou-list
func ListLDAPOUs(c *gin.Context) {
// TODO: actual LDAP OU list
c.JSON(http.StatusOK, []interface{}{})
var cfg model.LDAPConfig
if err := model.DB.Order("id").First(&cfg).Error; err != nil {
c.JSON(http.StatusOK, []interface{}{})
return
}
conn, err := dialLDAP(&cfg)
if err != nil {
c.JSON(http.StatusOK, []interface{}{})
return
}
defer conn.Close()
adminPw, _ := decryptPassword(cfg.AdminPassword)
if err := conn.Bind(cfg.AdminDN, adminPw); err != nil {
c.JSON(http.StatusOK, []interface{}{})
return
}
sr, err := conn.Search(ldap.NewSearchRequest(
cfg.BaseDN, ldap.ScopeWholeSubtree, ldap.NeverDerefAliases,
100, 0, false, "(objectClass=organizationalUnit)",
[]string{"ou", "dn", "description"},
nil,
))
if err != nil {
c.JSON(http.StatusOK, []interface{}{})
return
}
type OuItem struct {
DN string `json:"dn"`
Name string `json:"name"`
}
var items []OuItem
for _, e := range sr.Entries {
items = append(items, OuItem{
DN: e.DN,
Name: e.GetAttributeValue("ou"),
})
}
c.JSON(http.StatusOK, items)
}
// POST /api/v1/ldap-config/user-list
func ListLDAPUsers(c *gin.Context) {
// TODO: actual LDAP user list
c.JSON(http.StatusOK, []interface{}{})
var req struct {
OuDn string `json:"ou_dn"`
}
c.ShouldBindJSON(&req)
var cfg model.LDAPConfig
if err := model.DB.Order("id").First(&cfg).Error; err != nil {
c.JSON(http.StatusOK, []interface{}{})
return
}
conn, err := dialLDAP(&cfg)
if err != nil {
c.JSON(http.StatusOK, []interface{}{})
return
}
defer conn.Close()
adminPw2, _ := decryptPassword(cfg.AdminPassword)
if err := conn.Bind(cfg.AdminDN, adminPw2); err != nil {
c.JSON(http.StatusOK, []interface{}{})
return
}
searchBase := cfg.BaseDN
if req.OuDn != "" {
searchBase = req.OuDn
}
baseFilter := "(objectClass=inetOrgPerson)"
if cfg.SearchFilter != nil && *cfg.SearchFilter != "" {
baseFilter = *cfg.SearchFilter
}
sr, err := conn.Search(ldap.NewSearchRequest(
searchBase, ldap.ScopeWholeSubtree, ldap.NeverDerefAliases,
200, 0, false, baseFilter,
[]string{"uid", "cn", "mail", "dn"},
nil,
))
if err != nil {
c.JSON(http.StatusOK, []interface{}{})
return
}
type LdapUser struct {
DN string `json:"dn"`
UID string `json:"uid"`
CN string `json:"cn"`
Mail string `json:"mail"`
}
var users []LdapUser
for _, e := range sr.Entries {
users = append(users, LdapUser{
DN: e.DN,
UID: e.GetAttributeValue("uid"),
CN: e.GetAttributeValue("cn"),
Mail: e.GetAttributeValue("mail"),
})
}
c.JSON(http.StatusOK, users)
}
// POST /api/v1/ldap-config/create-ou
@@ -109,3 +369,21 @@ func CreateLDAPUser(c *gin.Context) {
func BatchImportLDAP(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"message": "Batch import (placeholder)", "count": 0})
}
// POST /api/v1/ldap-config/delete-user
func DeleteLDAPUser(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"message": "User deleted (placeholder)"})
}
// helper: dial LDAP with config
func dialLDAP(cfg *model.LDAPConfig) (*ldap.Conn, error) {
protocol := "ldap"
if cfg.UseLDAPS != nil && *cfg.UseLDAPS {
protocol = "ldaps"
}
port := 389
if cfg.LDAPPort != nil && *cfg.LDAPPort > 0 {
port = *cfg.LDAPPort
}
return ldap.DialURL(fmt.Sprintf("%s://%s:%d", protocol, cfg.LDAPHost, port))
}
+197
View File
@@ -0,0 +1,197 @@
package handler
import (
"bytes"
"encoding/json"
"io"
"net/http"
"strconv"
"sql-platform/internal/model"
"github.com/gin-gonic/gin"
)
// ChannelResponse — config as JSON object for frontend
type ChannelResponse struct {
ID int `json:"id"`
Name string `json:"name"`
ChannelType string `json:"channel_type"`
Config map[string]interface{} `json:"config"`
IsEnabled bool `json:"is_enabled"`
CreatedAt string `json:"created_at"`
UpdatedAt string `json:"updated_at"`
}
func toChannelResponse(ch model.NotificationChannel) ChannelResponse {
var cfg map[string]interface{}
if ch.Config != "" {
_ = json.Unmarshal([]byte(ch.Config), &cfg)
}
if cfg == nil {
cfg = map[string]interface{}{}
}
enabled := ch.IsEnabled != nil && *ch.IsEnabled
return ChannelResponse{
ID: ch.ID,
Name: ch.Name,
ChannelType: ch.ChannelType,
Config: cfg,
IsEnabled: enabled,
CreatedAt: ch.CreatedAt.Format("2006-01-02T15:04:05Z"),
UpdatedAt: ch.UpdatedAt.Format("2006-01-02T15:04:05Z"),
}
}
// GET /api/v1/notifications
func ListNotifications(c *gin.Context) {
var channels []model.NotificationChannel
model.DB.Order("id").Find(&channels)
result := make([]ChannelResponse, 0, len(channels))
for _, ch := range channels {
result = append(result, toChannelResponse(ch))
}
c.JSON(http.StatusOK, result)
}
// POST /api/v1/notifications
func CreateNotification(c *gin.Context) {
var input struct {
Name string `json:"name"`
ChannelType string `json:"channel_type"`
Config map[string]interface{} `json:"config"`
IsEnabled bool `json:"is_enabled"`
}
if err := c.ShouldBindJSON(&input); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"detail": "参数错误"})
return
}
if input.Name == "" {
c.JSON(http.StatusBadRequest, gin.H{"detail": "名称不能为空"})
return
}
cfgBytes, _ := json.Marshal(input.Config)
enabled := input.IsEnabled
ch := model.NotificationChannel{
Name: input.Name,
ChannelType: input.ChannelType,
Config: string(cfgBytes),
IsEnabled: &enabled,
}
if err := model.DB.Create(&ch).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"detail": "创建失败"})
return
}
c.JSON(http.StatusOK, toChannelResponse(ch))
}
// PUT /api/v1/notifications/:id
func UpdateNotification(c *gin.Context) {
id, _ := strconv.Atoi(c.Param("id"))
var ch model.NotificationChannel
if err := model.DB.First(&ch, id).Error; err != nil {
c.JSON(http.StatusNotFound, gin.H{"detail": "通知渠道不存在"})
return
}
var input map[string]interface{}
if err := c.ShouldBindJSON(&input); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"detail": "参数错误"})
return
}
if name, ok := input["name"].(string); ok {
ch.Name = name
}
if ct, ok := input["channel_type"].(string); ok {
ch.ChannelType = ct
}
if cfg, ok := input["config"].(map[string]interface{}); ok {
cfgBytes, _ := json.Marshal(cfg)
ch.Config = string(cfgBytes)
}
if enabled, ok := input["is_enabled"].(bool); ok {
ch.IsEnabled = &enabled
}
model.DB.Save(&ch)
c.JSON(http.StatusOK, toChannelResponse(ch))
}
// DELETE /api/v1/notifications/:id
func DeleteNotification(c *gin.Context) {
id, _ := strconv.Atoi(c.Param("id"))
model.DB.Delete(&model.NotificationChannel{}, id)
c.JSON(http.StatusOK, gin.H{"message": "已删除"})
}
// POST /api/v1/notifications/:id/test
func TestNotificationSend(c *gin.Context) {
id, _ := strconv.Atoi(c.Param("id"))
var ch model.NotificationChannel
if err := model.DB.First(&ch, id).Error; err != nil {
c.JSON(http.StatusNotFound, gin.H{"detail": "通知渠道不存在"})
return
}
var cfg map[string]interface{}
_ = json.Unmarshal([]byte(ch.Config), &cfg)
webhookURL, _ := cfg["webhook_url"].(string)
if webhookURL == "" {
c.JSON(http.StatusBadRequest, gin.H{"detail": "Webhook URL 未配置"})
return
}
title := "SQL审核平台 - 通知测试"
body := "这是一条测试消息,如果收到说明通知渠道配置正确。"
var sendErr error
switch ch.ChannelType {
case "webhook_feishu":
sendErr = sendFeishuWebhook(webhookURL, title, body)
case "dingtalk":
secret, _ := cfg["secret"].(string)
sendErr = sendDingtalkWebhook(webhookURL, secret, title, body)
default:
c.JSON(http.StatusBadRequest, gin.H{"detail": "不支持的通知类型: " + ch.ChannelType})
return
}
if sendErr != nil {
c.JSON(http.StatusInternalServerError, gin.H{"detail": "发送失败: " + sendErr.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"message": "测试消息已发送"})
}
func sendFeishuWebhook(webhookURL, title, body string) error {
payload := map[string]interface{}{
"msg_type": "interactive",
"card": map[string]interface{}{
"elements": []map[string]interface{}{
{"tag": "markdown", "content": body},
},
},
}
return postJSON(webhookURL, payload)
}
func sendDingtalkWebhook(webhookURL, secret, title, body string) error {
dingBody := title + "\n\n" + body
payload := map[string]interface{}{
"msgtype": "markdown",
"markdown": map[string]string{
"title": title,
"text": dingBody,
},
}
return postJSON(webhookURL, payload)
}
func postJSON(url string, payload interface{}) error {
data, _ := json.Marshal(payload)
resp, err := http.Post(url, "application/json", bytes.NewReader(data))
if err != nil {
return err
}
defer resp.Body.Close()
io.Copy(io.Discard, resp.Body)
return nil
}
+152
View File
@@ -0,0 +1,152 @@
package handler
import (
"net/http"
"strconv"
"sql-platform/internal/model"
"github.com/gin-gonic/gin"
)
// GET /api/v1/redis/:id/info
func RedisInfo(c *gin.Context) {
id := c.Param("id")
var instance model.Instance
if err := model.DB.First(&instance, id).Error; err != nil {
c.JSON(http.StatusNotFound, gin.H{"detail": "Instance not found"})
return
}
// TODO: actual Redis INFO command
c.JSON(http.StatusOK, gin.H{
"redis_version": "placeholder",
"used_memory": "0",
"connected_clients": "0",
"db_size": 0,
})
}
// GET /api/v1/redis/:id/dbsize
func RedisDbsize(c *gin.Context) {
id := c.Param("id")
var instance model.Instance
if err := model.DB.First(&instance, id).Error; err != nil {
c.JSON(http.StatusNotFound, gin.H{"detail": "Instance not found"})
return
}
// TODO: actual DBSIZE
c.JSON(http.StatusOK, gin.H{"db_size": 0})
}
// GET /api/v1/redis/:id/keys
func RedisScanKeys(c *gin.Context) {
id := c.Param("id")
_ = c.Query("db")
_ = c.Query("pattern")
_ = c.Query("count")
_ = c.Query("cursor")
_ = c.Query("type")
var instance model.Instance
if err := model.DB.First(&instance, id).Error; err != nil {
c.JSON(http.StatusNotFound, gin.H{"detail": "Instance not found"})
return
}
// TODO: actual SCAN command
c.JSON(http.StatusOK, gin.H{
"keys": []interface{}{},
"cursor": 0,
"total": 0,
})
}
// GET /api/v1/redis/:id/keys/:key
func RedisGetKeyDetail(c *gin.Context) {
id := c.Param("id")
key := c.Param("key")
_ = key
var instance model.Instance
if err := model.DB.First(&instance, id).Error; err != nil {
c.JSON(http.StatusNotFound, gin.H{"detail": "Instance not found"})
return
}
// TODO: actual key detail lookup
c.JSON(http.StatusOK, gin.H{
"key": key,
"type": "string",
"value": "",
"ttl": -1,
})
}
// GET /api/v1/redis/:id/slowlog
func RedisSlowlog(c *gin.Context) {
id := c.Param("id")
var instance model.Instance
if err := model.DB.First(&instance, id).Error; err != nil {
c.JSON(http.StatusNotFound, gin.H{"detail": "Instance not found"})
return
}
// TODO: actual SLOWLOG GET
c.JSON(http.StatusOK, []interface{}{})
}
// GET /api/v1/redis/:id/clients
func RedisClients(c *gin.Context) {
id := c.Param("id")
var instance model.Instance
if err := model.DB.First(&instance, id).Error; err != nil {
c.JSON(http.StatusNotFound, gin.H{"detail": "Instance not found"})
return
}
// TODO: actual CLIENT LIST
c.JSON(http.StatusOK, []interface{}{})
}
// POST /api/v1/redis/:id/command
func RedisExecuteCommand(c *gin.Context) {
id := c.Param("id")
var instance model.Instance
if err := model.DB.First(&instance, id).Error; err != nil {
c.JSON(http.StatusNotFound, gin.H{"detail": "Instance not found"})
return
}
// TODO: actual command execution
c.JSON(http.StatusOK, gin.H{"result": "Command execution not implemented"})
}
// POST /api/v1/redis/:id/keys
func RedisCreateKey(c *gin.Context) {
id := c.Param("id")
_, _ = id, c.Request.Body
c.JSON(http.StatusOK, gin.H{"message": "Key created (placeholder)"})
}
// DELETE /api/v1/redis/:id/keys/:key
func RedisDeleteKey(c *gin.Context) {
id := c.Param("id")
key := c.Param("key")
_, _ = id, key
c.JSON(http.StatusOK, gin.H{"message": "Key deleted (placeholder)"})
}
// POST /api/v1/workflows/:id/clone
func CloneWorkflow(c *gin.Context) {
id, _ := strconv.Atoi(c.Param("id"))
var wf model.WorkflowDefinition
if err := model.DB.First(&wf, id).Error; err != nil {
c.JSON(http.StatusNotFound, gin.H{"detail": "Workflow not found"})
return
}
// clone
newWf := model.WorkflowDefinition{
Name: wf.Name + "-副本",
Description: wf.Description,
Category: wf.Category,
IsActive: wf.IsActive,
FlowConfig: wf.FlowConfig,
}
model.DB.Create(&newWf)
c.JSON(http.StatusCreated, newWf)
}
+208
View File
@@ -0,0 +1,208 @@
package handler
import (
"net/http"
"sql-platform/internal/model"
"github.com/gin-gonic/gin"
)
// GET /api/v1/workflows/:id
func GetWorkflow(c *gin.Context) {
id := c.Param("id")
var wf model.WorkflowDefinition
if err := model.DB.First(&wf, id).Error; err != nil {
c.JSON(http.StatusNotFound, gin.H{"detail": "Workflow not found"})
return
}
var steps []model.WorkflowStep
model.DB.Where("workflow_id = ?", wf.ID).Order("step_number").Find(&steps)
c.JSON(http.StatusOK, gin.H{
"id": wf.ID,
"name": wf.Name,
"description": wf.Description,
"category": wf.Category,
"is_default": wf.IsDefault,
"is_active": wf.IsActive,
"created_at": wf.CreatedAt,
"updated_at": wf.UpdatedAt,
"created_by": wf.CreatedBy,
"steps": steps,
})
}
// POST /api/v1/workflows
func CreateWorkflow(c *gin.Context) {
var input struct {
Name string `json:"name" binding:"required"`
Description string `json:"description"`
Category string `json:"category"`
IsActive *bool `json:"is_active"`
IsDefault *bool `json:"is_default"`
Steps []struct {
StepNumber int `json:"step_number"`
Name string `json:"name"`
Description string `json:"description"`
StepType string `json:"step_type"`
ApproverRole string `json:"approver_role"`
ApproverIDs string `json:"approver_ids"`
IsRequired *bool `json:"is_required"`
TimeoutHours *int `json:"timeout_hours"`
AutoApproveOnTimeout *bool `json:"auto_approve_on_timeout"`
} `json:"steps"`
}
if err := c.ShouldBindJSON(&input); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"detail": err.Error()})
return
}
active := true
if input.IsActive != nil {
active = *input.IsActive
}
wf := model.WorkflowDefinition{
Name: input.Name,
Description: &input.Description,
Category: &input.Category,
IsActive: &active,
IsDefault: input.IsDefault,
}
if err := model.DB.Create(&wf).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"detail": err.Error()})
return
}
for _, s := range input.Steps {
step := model.WorkflowStep{
WorkflowID: wf.ID,
StepNumber: s.StepNumber,
Name: s.Name,
Description: &s.Description,
StepType: s.StepType,
ApproverRole: &s.ApproverRole,
ApproverIDs: &s.ApproverIDs,
IsRequired: s.IsRequired,
TimeoutHours: s.TimeoutHours,
AutoApproveOnTimeout: s.AutoApproveOnTimeout,
}
model.DB.Create(&step)
}
c.JSON(http.StatusCreated, gin.H{"id": wf.ID, "message": "Workflow created"})
}
// PUT /api/v1/workflows/:id
func UpdateWorkflow(c *gin.Context) {
id := c.Param("id")
var wf model.WorkflowDefinition
if err := model.DB.First(&wf, id).Error; err != nil {
c.JSON(http.StatusNotFound, gin.H{"detail": "Workflow not found"})
return
}
var input map[string]interface{}
if err := c.ShouldBindJSON(&input); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"detail": err.Error()})
return
}
model.DB.Model(&wf).Updates(input)
// If steps provided, replace them
if stepsRaw, ok := input["steps"]; ok {
model.DB.Where("workflow_id = ?", wf.ID).Delete(&model.WorkflowStep{})
if stepsArr, ok := stepsRaw.([]interface{}); ok {
for i, sRaw := range stepsArr {
if sMap, ok := sRaw.(map[string]interface{}); ok {
step := model.WorkflowStep{
WorkflowID: wf.ID,
StepNumber: i + 1,
}
if v, ok := sMap["name"].(string); ok {
step.Name = v
}
if v, ok := sMap["step_type"].(string); ok {
step.StepType = v
}
if v, ok := sMap["approver_role"].(string); ok {
step.ApproverRole = &v
}
if v, ok := sMap["approver_ids"].(string); ok {
step.ApproverIDs = &v
}
if v, ok := sMap["description"].(string); ok {
step.Description = &v
}
model.DB.Create(&step)
}
}
}
}
c.JSON(http.StatusOK, gin.H{"message": "Workflow updated"})
}
// DELETE /api/v1/workflows/:id
func DeleteWorkflow(c *gin.Context) {
id := c.Param("id")
model.DB.Where("workflow_id = ?", id).Delete(&model.WorkflowStep{})
model.DB.Delete(&model.WorkflowDefinition{}, id)
c.JSON(http.StatusOK, gin.H{"message": "Workflow deleted"})
}
// POST /api/v1/workflows/:id/set-default
func SetDefaultWorkflow(c *gin.Context) {
id := c.Param("id")
// unset all defaults
model.DB.Model(&model.WorkflowDefinition{}).Where("is_default = ?", true).Update("is_default", false)
// set this one as default
defaultVal := true
model.DB.Model(&model.WorkflowDefinition{}).Where("id = ?", id).Update("is_default", defaultVal)
c.JSON(http.StatusOK, gin.H{"message": "Default workflow set"})
}
// POST /api/v1/users/:id/reset-password
func ResetPassword(c *gin.Context) {
id := c.Param("id")
var input struct {
NewPassword string `json:"new_password" binding:"required"`
}
if err := c.ShouldBindJSON(&input); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"detail": err.Error()})
return
}
_, _ = id, input.NewPassword
// TODO: hash password and update
c.JSON(http.StatusOK, gin.H{"message": "Password reset (placeholder)"})
}
// POST /api/v1/users/batch-import
func BatchImportUsers(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"message": "Batch import (placeholder)", "count": 0})
}
// POST /api/v1/users/batch-import-ldap
func BatchImportLdapUsers(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"message": "LDAP import (placeholder)", "count": 0})
}
// GET /api/v1/users/import-template
func ImportTemplate(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"message": "Template download (placeholder)"})
}
// POST /api/v1/users/:userId/reset-password
func ResetUserPassword(c *gin.Context) {
userId := c.Param("userId")
var input struct {
NewPassword string `json:"new_password" binding:"required"`
}
if err := c.ShouldBindJSON(&input); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"detail": err.Error()})
return
}
// TODO: actually hash and save
_ = userId
c.JSON(http.StatusOK, gin.H{"message": "Password reset (placeholder)"})
}
+45 -3
View File
@@ -42,6 +42,16 @@ func Setup(r *gin.Engine) {
auth.POST("/instances/:id/test-connection", handler.TestConnection)
auth.POST("/instances/test-connection", handler.TestNewConnection)
// Instance Detail (databases/tables/schema/processlist/status/users)
auth.GET("/instances/:id/detail/databases", handler.DetailListDatabases)
auth.GET("/instances/:id/detail/tables", handler.DetailListTables)
auth.GET("/instances/:id/detail/db-schema", handler.DetailDbSchema)
auth.GET("/instances/:id/detail/table-detail", handler.DetailTableDetail)
auth.GET("/instances/:id/detail/processlist", handler.DetailProcesslist)
auth.GET("/instances/:id/detail/status", handler.DetailStatus)
auth.POST("/instances/:id/detail/processlist/:pid/kill", handler.DetailKillProcess)
auth.GET("/instances/:id/detail/users", handler.DetailListUsers)
// Instance management (admin/dba only)
admin := auth.Group("")
admin.Use(middleware.RequireRole("admin", "dba"))
@@ -69,6 +79,17 @@ func Setup(r *gin.Engine) {
igAdmin.DELETE("/instance-groups/:id", handler.DeleteInstanceGroup)
}
// Redis
auth.GET("/redis/:id/info", handler.RedisInfo)
auth.GET("/redis/:id/dbsize", handler.RedisDbsize)
auth.GET("/redis/:id/keys", handler.RedisScanKeys)
auth.GET("/redis/:id/keys/:key", handler.RedisGetKeyDetail)
auth.GET("/redis/:id/slowlog", handler.RedisSlowlog)
auth.GET("/redis/:id/clients", handler.RedisClients)
auth.POST("/redis/:id/command", handler.RedisExecuteCommand)
auth.POST("/redis/:id/keys", handler.RedisCreateKey)
auth.DELETE("/redis/:id/keys/:key", handler.RedisDeleteKey)
// Users (admin only)
userAdmin := auth.Group("")
userAdmin.Use(middleware.RequireRole("admin"))
@@ -77,6 +98,10 @@ func Setup(r *gin.Engine) {
userAdmin.POST("/users", handler.CreateUser)
userAdmin.PUT("/users/:id", handler.UpdateUser)
userAdmin.DELETE("/users/:id", handler.DeleteUser)
userAdmin.POST("/users/:id/reset-password", handler.ResetUserPassword)
userAdmin.POST("/users/batch-import", handler.BatchImportUsers)
userAdmin.POST("/users/batch-import-ldap", handler.BatchImportLdapUsers)
userAdmin.GET("/users/import-template", handler.ImportTemplate)
}
// LDAP config (admin only)
@@ -91,6 +116,7 @@ func Setup(r *gin.Engine) {
ldapAdmin.POST("/ldap-config/create-ou", handler.CreateLDAPOU)
ldapAdmin.POST("/ldap-config/create-user", handler.CreateLDAPUser)
ldapAdmin.POST("/ldap-config/batch-import-ldap", handler.BatchImportLDAP)
ldapAdmin.POST("/ldap-config/delete-user", handler.DeleteLDAPUser)
}
// Settings
@@ -109,13 +135,29 @@ func Setup(r *gin.Engine) {
ghostGroup.GET("/ghost/tasks", handler.GhOstListTasks)
}
// Audit Logs
auth.GET("/audit-logs", handler.ListAuditLogs)
// Query execution
auth.POST("/query/execute", handler.ExecuteQuery)
// Read-only resources
auth.GET("/audit-rules", handler.ListAuditRules)
auth.GET("/announcements", handler.ListAnnouncements)
auth.GET("/query-histories", handler.ListQueryHistories)
auth.GET("/notifications/channels", handler.ListNotificationChannels)
auth.GET("/notifications/templates", handler.ListNotificationTemplates)
// Notifications CRUD (replaces old channels/templates endpoints)
auth.GET("/notifications", handler.ListNotifications)
auth.POST("/notifications", handler.CreateNotification)
auth.PUT("/notifications/:id", handler.UpdateNotification)
auth.DELETE("/notifications/:id", handler.DeleteNotification)
auth.POST("/notifications/:id/test", handler.TestNotificationSend)
auth.GET("/workflows", handler.ListWorkflows)
auth.POST("/notifications/test", handler.TestNotification)
auth.GET("/workflows/:id", handler.GetWorkflow)
auth.POST("/workflows", handler.CreateWorkflow)
auth.PUT("/workflows/:id", handler.UpdateWorkflow)
auth.DELETE("/workflows/:id", handler.DeleteWorkflow)
auth.POST("/workflows/:id/set-default", handler.SetDefaultWorkflow)
auth.POST("/workflows/:id/clone", handler.CloneWorkflow)
}
}