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.
This commit is contained in:
zenfun
2025-12-17 01:08:01 +08:00
parent 16fceec8e7
commit 6d8a12df34
4 changed files with 350 additions and 5 deletions

View File

@@ -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)
}

View File

@@ -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
}

View File

@@ -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"})
}

View File

@@ -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 {