From 6d8a12df342f6a42c65c73323d917f39c968ab73 Mon Sep 17 00:00:00 2001 From: zenfun Date: Wed, 17 Dec 2025 01:08:01 +0800 Subject: [PATCH] feat(api): add access control and binding endpoints Add endpoints for master and key access management to configure default and allowed namespaces, including propagation options. Implement GET and DELETE operations for individual bindings. Update sync service to persist bindings snapshots even when no upstreams are available. --- cmd/server/main.go | 6 + internal/api/access_handler.go | 279 ++++++++++++++++++++++++++++++++ internal/api/binding_handler.go | 65 ++++++++ internal/service/sync.go | 5 - 4 files changed, 350 insertions(+), 5 deletions(-) create mode 100644 internal/api/access_handler.go 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 {