diff --git a/backend/internal/handler/ghost.go b/backend/internal/handler/ghost.go index a62bf64..b86dd49 100644 --- a/backend/internal/handler/ghost.go +++ b/backend/internal/handler/ghost.go @@ -90,11 +90,43 @@ func ListQueryHistories(c *gin.Context) { c.JSON(http.StatusOK, histories) } -// GET /api/v1/settings +// GET /api/v1/settings — returns settings grouped by `group` field func ListSettings(c *gin.Context) { var settings []model.SystemSetting model.DB.Order("sort_order").Find(&settings) - c.JSON(http.StatusOK, settings) + + // group by `group` field + grouped := make(map[string][]model.SystemSetting) + for _, s := range settings { + grouped[s.Group] = append(grouped[s.Group], s) + } + c.JSON(http.StatusOK, grouped) +} + +// PUT /api/v1/settings — batch update settings +func UpdateSettings(c *gin.Context) { + var items []struct { + ID int `json:"id"` + Value string `json:"value"` + } + if err := c.ShouldBindJSON(&items); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"detail": err.Error()}) + return + } + for _, item := range items { + model.DB.Model(&model.SystemSetting{}).Where("id = ?", item.ID).Update("value", item.Value) + } + c.JSON(http.StatusOK, gin.H{"message": "Settings updated"}) +} + +// GET /api/v1/settings/cleanup/preview +func SettingsCleanupPreview(c *gin.Context) { + c.JSON(http.StatusOK, map[string]interface{}{}) +} + +// POST /api/v1/settings/cleanup/run +func SettingsCleanupRun(c *gin.Context) { + c.JSON(http.StatusOK, gin.H{"message": "Cleanup done (placeholder)", "stats": map[string]interface{}{}}) } // GET /api/v1/notifications/channels diff --git a/backend/internal/handler/instance_extra.go b/backend/internal/handler/instance_extra.go new file mode 100644 index 0000000..655634a --- /dev/null +++ b/backend/internal/handler/instance_extra.go @@ -0,0 +1,98 @@ +package handler + +import ( + "net/http" + + "sql-platform/internal/middleware" + "sql-platform/internal/model" + + "github.com/gin-gonic/gin" +) + +// GET /api/v1/instances/accessible?access_type=readonly|write +func AccessibleInstances(c *gin.Context) { + userID, _, role := middleware.GetCurrentUser(c) + accessType := c.DefaultQuery("access_type", "readonly") + + var instances []model.Instance + + if role == "admin" || role == "dba" { + // admin/dba see all instances, filter by access_mode if needed + query := model.DB.Order("id") + if accessType == "readonly" { + query = query.Where("access_mode IN ?", []string{"readwrite", "readonly"}) + } else { + query = query.Where("access_mode IN ?", []string{"readwrite", "write"}) + } + query.Find(&instances) + } else { + // developer/auditor: only instances in their groups + var groupIDs []int + model.DB.Model(&model.UserGroup{}).Where("user_id = ?", userID).Pluck("group_id", &groupIDs) + if len(groupIDs) == 0 { + c.JSON(http.StatusOK, []model.Instance{}) + return + } + var instanceIDs []int + model.DB.Model(&model.InstanceGroupMember{}). + Where("group_id IN ? AND access_type = ?", groupIDs, accessType). + Pluck("instance_id", &instanceIDs) + // also include readwrite + if accessType == "readonly" { + model.DB.Model(&model.InstanceGroupMember{}). + Where("group_id IN ? AND access_type = ?", groupIDs, "readwrite"). + Pluck("instance_id", &instanceIDs) + } else if accessType == "write" { + model.DB.Model(&model.InstanceGroupMember{}). + Where("group_id IN ? AND access_type = ?", groupIDs, "readwrite"). + Pluck("instance_id", &instanceIDs) + } + if len(instanceIDs) == 0 { + c.JSON(http.StatusOK, []model.Instance{}) + return + } + model.DB.Where("id IN ?", instanceIDs).Order("id").Find(&instances) + } + + // mask passwords + for i := range instances { + instances[i].Password = "******" + } + c.JSON(http.StatusOK, instances) +} + +// GET /api/v1/instances/:id/databases +func ListDatabases(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 database connection to list databases + // Return placeholder for now + c.JSON(http.StatusOK, []string{"information_schema"}) +} + +// GET /api/v1/instances/:id/databases/:db/tables +func ListTables(c *gin.Context) { + id := c.Param("id") + dbName := c.Param("db") + 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 database connection to list tables + _ = dbName + c.JSON(http.StatusOK, []interface{}{}) +} + +// POST /api/v1/instances/test-connection (new instance, not saved yet) +func TestNewConnection(c *gin.Context) { + // TODO: actual connection test + c.JSON(http.StatusOK, gin.H{ + "success": true, + "message": "Connection test passed (placeholder)", + }) +} diff --git a/backend/internal/handler/instance_group.go b/backend/internal/handler/instance_group.go new file mode 100644 index 0000000..04b022d --- /dev/null +++ b/backend/internal/handler/instance_group.go @@ -0,0 +1,241 @@ +package handler + +import ( + "net/http" + "strconv" + + "sql-platform/internal/model" + + "github.com/gin-gonic/gin" +) + +// GET /api/v1/instance-groups +// (already in ghost.go as stub, replace with real implementation) + +// GET /api/v1/instance-groups/:id +func GetInstanceGroup(c *gin.Context) { + id := c.Param("id") + var group model.InstanceGroup + if err := model.DB.First(&group, id).Error; err != nil { + c.JSON(http.StatusNotFound, gin.H{"detail": "Instance group not found"}) + return + } + c.JSON(http.StatusOK, enrichGroup(group)) +} + +// POST /api/v1/instance-groups +func CreateInstanceGroup(c *gin.Context) { + var group model.InstanceGroup + if err := c.ShouldBindJSON(&group); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"detail": err.Error()}) + return + } + if err := model.DB.Create(&group).Error; err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"detail": err.Error()}) + return + } + c.JSON(http.StatusCreated, enrichGroup(group)) +} + +// PUT /api/v1/instance-groups/:id +func UpdateInstanceGroup(c *gin.Context) { + id := c.Param("id") + var group model.InstanceGroup + if err := model.DB.First(&group, id).Error; err != nil { + c.JSON(http.StatusNotFound, gin.H{"detail": "Instance group 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 + } + + if err := model.DB.Model(&group).Updates(input).Error; err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"detail": err.Error()}) + return + } + + model.DB.First(&group, id) + c.JSON(http.StatusOK, enrichGroup(group)) +} + +// DELETE /api/v1/instance-groups/:id +func DeleteInstanceGroup(c *gin.Context) { + id := c.Param("id") + if err := model.DB.Delete(&model.InstanceGroup{}, id).Error; err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"detail": err.Error()}) + return + } + // also clean up members + model.DB.Where("group_id = ?", id).Delete(&model.InstanceGroupMember{}) + c.JSON(http.StatusOK, gin.H{"message": "Instance group deleted"}) +} + +// POST /api/v1/instance-groups/:id/instances +func AssignGroupInstances(c *gin.Context) { + groupID, _ := strconv.Atoi(c.Param("id")) + var input struct { + InstanceIDs []int `json:"instance_ids"` + AccessType string `json:"access_type"` + } + if err := c.ShouldBindJSON(&input); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"detail": err.Error()}) + return + } + if input.AccessType == "" { + input.AccessType = "readonly" + } + + for _, iid := range input.InstanceIDs { + member := model.InstanceGroupMember{ + GroupID: groupID, + InstanceID: iid, + AccessType: input.AccessType, + } + model.DB.Where("group_id = ? AND instance_id = ? AND access_type = ?", + groupID, iid, input.AccessType).FirstOrCreate(&member) + } + c.JSON(http.StatusOK, gin.H{"message": "Instances assigned"}) +} + +// DELETE /api/v1/instance-groups/:id/instances/:instanceId +func RemoveGroupInstance(c *gin.Context) { + groupID := c.Param("id") + instanceID := c.Param("instanceId") + accessType := c.Query("access_type") + + query := model.DB.Where("group_id = ? AND instance_id = ?", groupID, instanceID) + if accessType != "" { + query = query.Where("access_type = ?", accessType) + } + query.Delete(&model.InstanceGroupMember{}) + c.JSON(http.StatusOK, gin.H{"message": "Instance removed from group"}) +} + +// GET /api/v1/instance-groups/:id/users +func ListGroupUsers(c *gin.Context) { + groupID := c.Param("id") + var userGroups []model.UserGroup + model.DB.Where("group_id = ?", groupID).Find(&userGroups) + c.JSON(http.StatusOK, userGroups) +} + +// POST /api/v1/instance-groups/:id/users +func AssignGroupUsers(c *gin.Context) { + groupID, _ := strconv.Atoi(c.Param("id")) + var input struct { + UserIDs []int `json:"user_ids"` + Permissions []string `json:"permissions"` + } + if err := c.ShouldBindJSON(&input); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"detail": err.Error()}) + return + } + + // delete existing + model.DB.Where("group_id = ?", groupID).Delete(&model.UserGroup{}) + + perms := "" + if len(input.Permissions) > 0 { + // store as JSON array string + import_json_str := "[" + for i, p := range input.Permissions { + if i > 0 { + import_json_str += "," + } + import_json_str += `"` + p + `"` + } + import_json_str += "]" + perms = import_json_str + } + + for _, uid := range input.UserIDs { + ug := model.UserGroup{UserID: uid, GroupID: groupID} + if perms != "" { + ug.Permissions = &perms + } + model.DB.Create(&ug) + } + c.JSON(http.StatusOK, gin.H{"message": "Users assigned"}) +} + +// PUT /api/v1/instance-groups/users/:userId/groups +func UpdateUserGroups(c *gin.Context) { + userID := c.Param("userId") + var input struct { + GroupIDs []int `json:"group_ids"` + } + if err := c.ShouldBindJSON(&input); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"detail": err.Error()}) + return + } + + uid, _ := strconv.Atoi(userID) + model.DB.Where("user_id = ?", uid).Delete(&model.UserGroup{}) + for _, gid := range input.GroupIDs { + model.DB.Create(&model.UserGroup{UserID: uid, GroupID: gid}) + } + c.JSON(http.StatusOK, gin.H{"message": "User groups updated"}) +} + +// enrichGroup adds computed counts to InstanceGroup response +func enrichGroup(group model.InstanceGroup) gin.H { + gid := group.ID + + var readonlyCount, writeCount, userCount int64 + var readonlyMembers, writeMembers []model.InstanceGroupMember + + model.DB.Where("group_id = ? AND access_type IN ?", gid, []string{"readonly", "readwrite"}).Find(&readonlyMembers) + readonlyCount = int64(len(readonlyMembers)) + + model.DB.Where("group_id = ? AND access_type IN ?", gid, []string{"write", "readwrite"}).Find(&writeMembers) + writeCount = int64(len(writeMembers)) + + model.DB.Model(&model.UserGroup{}).Where("group_id = ?", gid).Count(&userCount) + + // collect instance details + allInstanceIDs := []int{} + for _, m := range readonlyMembers { + allInstanceIDs = append(allInstanceIDs, m.InstanceID) + } + for _, m := range writeMembers { + allInstanceIDs = append(allInstanceIDs, m.InstanceID) + } + + // unique + seen := map[int]bool{} + uniqueIDs := []int{} + for _, id := range allInstanceIDs { + if !seen[id] { + seen[id] = true + uniqueIDs = append(uniqueIDs, id) + } + } + + var instances []model.Instance + if len(uniqueIDs) > 0 { + model.DB.Where("id IN ?", uniqueIDs).Find(&instances) + } + for i := range instances { + instances[i].Password = "******" + } + + return gin.H{ + "id": group.ID, + "name": group.Name, + "description": group.Description, + "db_type": group.DBType, + "permissions": group.Permissions, + "is_active": group.IsActive, + "created_at": group.CreatedAt, + "updated_at": group.UpdatedAt, + "workflow_id": group.WorkflowID, + "access_type": group.AccessType, + "allowed_databases": group.AllowedDatabases, + "readonly_instance_count": readonlyCount, + "write_instance_count": writeCount, + "user_count": userCount, + } +} diff --git a/backend/internal/handler/ldap.go b/backend/internal/handler/ldap.go new file mode 100644 index 0000000..2b01b93 --- /dev/null +++ b/backend/internal/handler/ldap.go @@ -0,0 +1,111 @@ +package handler + +import ( + "net/http" + + "sql-platform/internal/model" + + "github.com/gin-gonic/gin" +) + +// GET /api/v1/ldap-config +func GetLDAPConfig(c *gin.Context) { + var configs []model.LDAPConfig + model.DB.Order("id").Find(&configs) + if len(configs) == 0 { + c.JSON(http.StatusOK, nil) + return + } + // mask password + configs[0].AdminPassword = "******" + c.JSON(http.StatusOK, configs[0]) +} + +// PUT /api/v1/ldap-config +func UpdateLDAPConfig(c *gin.Context) { + var input map[string]interface{} + if err := c.ShouldBindJSON(&input); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"detail": err.Error()}) + return + } + + var configs []model.LDAPConfig + model.DB.Order("id").Find(&configs) + + if len(configs) == 0 { + // create new + config := model.LDAPConfig{} + if v, ok := input["ldap_host"].(string); ok { + config.LDAPHost = v + } + if v, ok := input["base_dn"].(string); ok { + config.BaseDN = v + } + if v, ok := input["admin_dn"].(string); ok { + config.AdminDN = v + } + if v, ok := input["admin_password"].(string); ok && v != "******" { + config.AdminPassword = v + } + 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) + } else { + // update existing + if pw, ok := input["admin_password"].(string); ok && pw == "******" { + delete(input, "admin_password") + } + model.DB.Model(&configs[0]).Updates(input) + model.DB.First(&configs[0], configs[0].ID) + configs[0].AdminPassword = "******" + c.JSON(http.StatusOK, configs[0]) + } +} + +// POST /api/v1/ldap-config/test +func TestLDAPConnection(c *gin.Context) { + // TODO: actual LDAP connection test + c.JSON(http.StatusOK, gin.H{ + "success": true, + "message": "LDAP connection test (placeholder)", + }) +} + +// POST /api/v1/ldap-config/ou-list +func ListLDAPOUs(c *gin.Context) { + // TODO: actual LDAP OU list + c.JSON(http.StatusOK, []interface{}{}) +} + +// POST /api/v1/ldap-config/user-list +func ListLDAPUsers(c *gin.Context) { + // TODO: actual LDAP user list + c.JSON(http.StatusOK, []interface{}{}) +} + +// POST /api/v1/ldap-config/create-ou +func CreateLDAPOU(c *gin.Context) { + c.JSON(http.StatusOK, gin.H{"message": "OU created (placeholder)"}) +} + +// POST /api/v1/ldap-config/create-user +func CreateLDAPUser(c *gin.Context) { + c.JSON(http.StatusOK, gin.H{"message": "User created (placeholder)"}) +} + +// POST /api/v1/ldap-config/batch-import-ldap +func BatchImportLDAP(c *gin.Context) { + c.JSON(http.StatusOK, gin.H{"message": "Batch import (placeholder)", "count": 0}) +} diff --git a/backend/router/router.go b/backend/router/router.go index efe7813..e743da2 100644 --- a/backend/router/router.go +++ b/backend/router/router.go @@ -34,9 +34,13 @@ func Setup(r *gin.Engine) { // Instances auth.GET("/instances", handler.ListInstances) + auth.GET("/instances/accessible", handler.AccessibleInstances) auth.GET("/instances/stats", handler.InstanceStats) auth.GET("/instances/:id", handler.GetInstance) + auth.GET("/instances/:id/databases", handler.ListDatabases) + auth.GET("/instances/:id/databases/:db/tables", handler.ListTables) auth.POST("/instances/:id/test-connection", handler.TestConnection) + auth.POST("/instances/test-connection", handler.TestNewConnection) // Instance management (admin/dba only) admin := auth.Group("") @@ -47,6 +51,24 @@ func Setup(r *gin.Engine) { admin.DELETE("/instances/:id", handler.DeleteInstance) } + // Instance Groups + auth.GET("/instance-groups", handler.ListInstanceGroups) + auth.GET("/instance-groups/:id", handler.GetInstanceGroup) + auth.GET("/instance-groups/:id/users", handler.ListGroupUsers) + auth.POST("/instance-groups/:id/instances", handler.AssignGroupInstances) + auth.DELETE("/instance-groups/:id/instances/:instanceId", handler.RemoveGroupInstance) + auth.POST("/instance-groups/:id/users", handler.AssignGroupUsers) + auth.PUT("/instance-groups/users/:userId/groups", handler.UpdateUserGroups) + + // Instance Group management (admin/dba only) + igAdmin := auth.Group("") + igAdmin.Use(middleware.RequireRole("admin", "dba")) + { + igAdmin.POST("/instance-groups", handler.CreateInstanceGroup) + igAdmin.PUT("/instance-groups/:id", handler.UpdateInstanceGroup) + igAdmin.DELETE("/instance-groups/:id", handler.DeleteInstanceGroup) + } + // Users (admin only) userAdmin := auth.Group("") userAdmin.Use(middleware.RequireRole("admin")) @@ -57,6 +79,26 @@ func Setup(r *gin.Engine) { userAdmin.DELETE("/users/:id", handler.DeleteUser) } + // LDAP config (admin only) + ldapAdmin := auth.Group("") + ldapAdmin.Use(middleware.RequireRole("admin")) + { + ldapAdmin.GET("/ldap-config", handler.GetLDAPConfig) + ldapAdmin.PUT("/ldap-config", handler.UpdateLDAPConfig) + ldapAdmin.POST("/ldap-config/test", handler.TestLDAPConnection) + ldapAdmin.POST("/ldap-config/ou-list", handler.ListLDAPOUs) + ldapAdmin.POST("/ldap-config/user-list", handler.ListLDAPUsers) + 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) + } + + // Settings + auth.GET("/settings", handler.ListSettings) + auth.PUT("/settings", handler.UpdateSettings) + auth.GET("/settings/cleanup/preview", handler.SettingsCleanupPreview) + auth.POST("/settings/cleanup/run", handler.SettingsCleanupRun) + // gh-ost (admin/dba only) ghostGroup := auth.Group("") ghostGroup.Use(middleware.RequireRole("admin", "dba")) @@ -71,11 +113,9 @@ func Setup(r *gin.Engine) { auth.GET("/audit-rules", handler.ListAuditRules) auth.GET("/announcements", handler.ListAnnouncements) auth.GET("/query-histories", handler.ListQueryHistories) - auth.GET("/settings", handler.ListSettings) auth.GET("/notifications/channels", handler.ListNotificationChannels) auth.GET("/notifications/templates", handler.ListNotificationTemplates) auth.GET("/workflows", handler.ListWorkflows) - auth.GET("/instance-groups", handler.ListInstanceGroups) auth.POST("/notifications/test", handler.TestNotification) } }