diff --git a/cmd/server/main.go b/cmd/server/main.go index a3b62dd..43f03d5 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -159,6 +159,10 @@ func main() { { adminGroup.POST("/masters", adminHandler.CreateMaster) adminGroup.POST("/masters/:id/keys", adminHandler.IssueChildKeyForMaster) + adminGroup.GET("/masters/:id/access", handler.GetMasterAccess) + adminGroup.PUT("/masters/:id/access", handler.UpdateMasterAccess) + adminGroup.GET("/keys/:id/access", handler.GetKeyAccess) + adminGroup.PUT("/keys/:id/access", handler.UpdateKeyAccess) adminGroup.GET("/features", featureHandler.ListFeatures) adminGroup.PUT("/features", featureHandler.UpdateFeatures) // Other admin routes for managing providers, models, etc. @@ -169,7 +173,9 @@ func main() { adminGroup.PUT("/models/:id", handler.UpdateModel) adminGroup.POST("/bindings", handler.CreateBinding) adminGroup.GET("/bindings", handler.ListBindings) + adminGroup.GET("/bindings/:id", handler.GetBinding) adminGroup.PUT("/bindings/:id", handler.UpdateBinding) + adminGroup.DELETE("/bindings/:id", handler.DeleteBinding) adminGroup.POST("/sync/snapshot", handler.SyncSnapshot) } diff --git a/internal/api/access_handler.go b/internal/api/access_handler.go new file mode 100644 index 0000000..365fb90 --- /dev/null +++ b/internal/api/access_handler.go @@ -0,0 +1,279 @@ +package api + +import ( + "net/http" + "strconv" + "strings" + + "github.com/ez-api/ez-api/internal/model" + "github.com/gin-gonic/gin" +) + +type AccessResponse struct { + DefaultNamespace string `json:"default_namespace"` + Namespaces []string `json:"namespaces"` +} + +type UpdateAccessRequest struct { + DefaultNamespace string `json:"default_namespace"` + Namespaces []string `json:"namespaces"` + PropagateToKeys bool `json:"propagate_to_keys,omitempty"` +} + +// GetMasterAccess godoc +// @Summary Get master access settings +// @Description Returns master default_namespace and namespaces +// @Tags admin +// @Produce json +// @Security AdminAuth +// @Param id path int true "Master ID" +// @Success 200 {object} AccessResponse +// @Failure 400 {object} gin.H +// @Failure 404 {object} gin.H +// @Failure 500 {object} gin.H +// @Router /admin/masters/{id}/access [get] +func (h *Handler) GetMasterAccess(c *gin.Context) { + id, ok := parseUintParam(c, "id") + if !ok { + return + } + + var m model.Master + if err := h.db.First(&m, id).Error; err != nil { + c.JSON(http.StatusNotFound, gin.H{"error": "master not found"}) + return + } + + c.JSON(http.StatusOK, AccessResponse{ + DefaultNamespace: normalizeDefaultNamespace(m.DefaultNamespace), + Namespaces: normalizeNamespaces(m.Namespaces, m.DefaultNamespace), + }) +} + +// UpdateMasterAccess godoc +// @Summary Update master access settings +// @Description Updates master default_namespace and namespaces; optionally propagate to existing keys +// @Tags admin +// @Accept json +// @Produce json +// @Security AdminAuth +// @Param id path int true "Master ID" +// @Param request body UpdateAccessRequest true "Access settings" +// @Success 200 {object} AccessResponse +// @Failure 400 {object} gin.H +// @Failure 404 {object} gin.H +// @Failure 500 {object} gin.H +// @Router /admin/masters/{id}/access [put] +func (h *Handler) UpdateMasterAccess(c *gin.Context) { + id, ok := parseUintParam(c, "id") + if !ok { + return + } + + var req UpdateAccessRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + var m model.Master + if err := h.db.First(&m, id).Error; err != nil { + c.JSON(http.StatusNotFound, gin.H{"error": "master not found"}) + return + } + + nextDefault := strings.TrimSpace(req.DefaultNamespace) + if nextDefault == "" { + nextDefault = strings.TrimSpace(m.DefaultNamespace) + } + nextDefault = normalizeDefaultNamespace(nextDefault) + + nextNamespaces := m.Namespaces + if req.Namespaces != nil { + nextNamespaces = strings.Join(req.Namespaces, ",") + } + nsList := normalizeNamespaces(nextNamespaces, nextDefault) + nextNamespaces = strings.Join(nsList, ",") + + if err := h.db.Model(&m).Updates(map[string]any{ + "default_namespace": nextDefault, + "namespaces": nextNamespaces, + }).Error; err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to update master access", "details": err.Error()}) + return + } + + if req.PropagateToKeys { + if err := h.db.Model(&model.Key{}).Where("master_id = ?", m.ID).Updates(map[string]any{ + "default_namespace": nextDefault, + "namespaces": nextNamespaces, + }).Error; err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to propagate access to keys", "details": err.Error()}) + return + } + + var keys []model.Key + if err := h.db.Where("master_id = ?", m.ID).Find(&keys).Error; err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to reload keys for sync", "details": err.Error()}) + return + } + for i := range keys { + if err := h.sync.SyncKey(&keys[i]); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to sync key access", "details": err.Error()}) + return + } + } + } + + c.JSON(http.StatusOK, AccessResponse{ + DefaultNamespace: nextDefault, + Namespaces: nsList, + }) +} + +// GetKeyAccess godoc +// @Summary Get key access settings +// @Description Returns key default_namespace and namespaces +// @Tags admin +// @Produce json +// @Security AdminAuth +// @Param id path int true "Key ID" +// @Success 200 {object} AccessResponse +// @Failure 400 {object} gin.H +// @Failure 404 {object} gin.H +// @Failure 500 {object} gin.H +// @Router /admin/keys/{id}/access [get] +func (h *Handler) GetKeyAccess(c *gin.Context) { + id, ok := parseUintParam(c, "id") + if !ok { + return + } + var k model.Key + if err := h.db.First(&k, id).Error; err != nil { + c.JSON(http.StatusNotFound, gin.H{"error": "key not found"}) + return + } + c.JSON(http.StatusOK, AccessResponse{ + DefaultNamespace: normalizeDefaultNamespace(k.DefaultNamespace), + Namespaces: normalizeNamespaces(k.Namespaces, k.DefaultNamespace), + }) +} + +// UpdateKeyAccess godoc +// @Summary Update key access settings +// @Description Updates key default_namespace and namespaces and syncs to Redis +// @Tags admin +// @Accept json +// @Produce json +// @Security AdminAuth +// @Param id path int true "Key ID" +// @Param request body UpdateAccessRequest true "Access settings" +// @Success 200 {object} AccessResponse +// @Failure 400 {object} gin.H +// @Failure 404 {object} gin.H +// @Failure 500 {object} gin.H +// @Router /admin/keys/{id}/access [put] +func (h *Handler) UpdateKeyAccess(c *gin.Context) { + id, ok := parseUintParam(c, "id") + if !ok { + return + } + + var req UpdateAccessRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + var k model.Key + if err := h.db.First(&k, id).Error; err != nil { + c.JSON(http.StatusNotFound, gin.H{"error": "key not found"}) + return + } + + nextDefault := strings.TrimSpace(req.DefaultNamespace) + if nextDefault == "" { + nextDefault = strings.TrimSpace(k.DefaultNamespace) + } + nextDefault = normalizeDefaultNamespace(nextDefault) + + nextNamespaces := k.Namespaces + if req.Namespaces != nil { + nextNamespaces = strings.Join(req.Namespaces, ",") + } + nsList := normalizeNamespaces(nextNamespaces, nextDefault) + nextNamespaces = strings.Join(nsList, ",") + + if err := h.db.Model(&k).Updates(map[string]any{ + "default_namespace": nextDefault, + "namespaces": nextNamespaces, + }).Error; err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to update key access", "details": err.Error()}) + return + } + if err := h.db.First(&k, id).Error; err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to reload key", "details": err.Error()}) + return + } + if err := h.sync.SyncKey(&k); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to sync key access", "details": err.Error()}) + return + } + c.JSON(http.StatusOK, AccessResponse{ + DefaultNamespace: nextDefault, + Namespaces: nsList, + }) +} + +func normalizeDefaultNamespace(ns string) string { + ns = strings.TrimSpace(ns) + if ns == "" { + return "default" + } + return ns +} + +func normalizeNamespaces(raw string, defaultNamespace string) []string { + defaultNamespace = normalizeDefaultNamespace(defaultNamespace) + raw = strings.TrimSpace(raw) + var parts []string + if raw != "" { + parts = strings.FieldsFunc(raw, func(r rune) bool { + return r == ',' || r == ' ' || r == ';' || r == '\t' || r == '\n' + }) + } + out := make([]string, 0, len(parts)+1) + seen := make(map[string]struct{}, len(parts)+1) + for _, p := range parts { + p = strings.TrimSpace(p) + if p == "" { + continue + } + if _, ok := seen[p]; ok { + continue + } + seen[p] = struct{}{} + out = append(out, p) + } + if _, ok := seen[defaultNamespace]; !ok { + out = append(out, defaultNamespace) + } + if len(out) == 0 { + out = append(out, defaultNamespace) + } + return out +} + +func parseUintParam(c *gin.Context, name string) (uint, bool) { + idRaw := strings.TrimSpace(c.Param(name)) + if idRaw == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": name + " required"}) + return 0, false + } + idU64, err := strconv.ParseUint(idRaw, 10, 64) + if err != nil || idU64 == 0 { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid " + name}) + return 0, false + } + return uint(idU64), true +} diff --git a/internal/api/binding_handler.go b/internal/api/binding_handler.go index f8f187f..5ba0708 100644 --- a/internal/api/binding_handler.go +++ b/internal/api/binding_handler.go @@ -157,3 +157,68 @@ func (h *Handler) UpdateBinding(c *gin.Context) { c.JSON(http.StatusOK, existing) } + +// GetBinding godoc +// @Summary Get a binding +// @Description Get a binding by id +// @Tags admin +// @Produce json +// @Security AdminAuth +// @Param id path int true "Binding ID" +// @Success 200 {object} model.Binding +// @Failure 400 {object} gin.H +// @Failure 404 {object} gin.H +// @Failure 500 {object} gin.H +// @Router /admin/bindings/{id} [get] +func (h *Handler) GetBinding(c *gin.Context) { + idParam := c.Param("id") + id, err := strconv.Atoi(idParam) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid id"}) + return + } + var existing model.Binding + if err := h.db.First(&existing, id).Error; err != nil { + c.JSON(http.StatusNotFound, gin.H{"error": "binding not found"}) + return + } + c.JSON(http.StatusOK, existing) +} + +// DeleteBinding godoc +// @Summary Delete a binding +// @Description Delete a binding by id and rebuild bindings snapshot +// @Tags admin +// @Produce json +// @Security AdminAuth +// @Param id path int true "Binding ID" +// @Success 200 {object} gin.H +// @Failure 400 {object} gin.H +// @Failure 404 {object} gin.H +// @Failure 500 {object} gin.H +// @Router /admin/bindings/{id} [delete] +func (h *Handler) DeleteBinding(c *gin.Context) { + idParam := c.Param("id") + id, err := strconv.Atoi(idParam) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid id"}) + return + } + + var existing model.Binding + if err := h.db.First(&existing, id).Error; err != nil { + c.JSON(http.StatusNotFound, gin.H{"error": "binding not found"}) + return + } + if err := h.db.Delete(&existing).Error; err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to delete binding", "details": err.Error()}) + return + } + + if err := h.sync.SyncBindings(h.db); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to sync bindings", "details": err.Error()}) + return + } + + c.JSON(http.StatusOK, gin.H{"status": "deleted"}) +} diff --git a/internal/service/sync.go b/internal/service/sync.go index 7fc44cf..2c1ad21 100644 --- a/internal/service/sync.go +++ b/internal/service/sync.go @@ -402,11 +402,6 @@ func (s *SyncService) writeBindingsSnapshot(ctx context.Context, pipe redis.Pipe snap.Upstreams[fmt.Sprintf("%d", p.id)] = up } - // Only write bindings that have at least one usable upstream mapping. - if len(snap.Upstreams) == 0 { - continue - } - key := ns + "." + pm payload, err := jsoncodec.Marshal(snap) if err != nil {