mirror of
https://github.com/EZ-Api/ez-api.git
synced 2026-01-13 17:47:51 +00:00
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:
@@ -159,6 +159,10 @@ func main() {
|
|||||||
{
|
{
|
||||||
adminGroup.POST("/masters", adminHandler.CreateMaster)
|
adminGroup.POST("/masters", adminHandler.CreateMaster)
|
||||||
adminGroup.POST("/masters/:id/keys", adminHandler.IssueChildKeyForMaster)
|
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.GET("/features", featureHandler.ListFeatures)
|
||||||
adminGroup.PUT("/features", featureHandler.UpdateFeatures)
|
adminGroup.PUT("/features", featureHandler.UpdateFeatures)
|
||||||
// Other admin routes for managing providers, models, etc.
|
// Other admin routes for managing providers, models, etc.
|
||||||
@@ -169,7 +173,9 @@ func main() {
|
|||||||
adminGroup.PUT("/models/:id", handler.UpdateModel)
|
adminGroup.PUT("/models/:id", handler.UpdateModel)
|
||||||
adminGroup.POST("/bindings", handler.CreateBinding)
|
adminGroup.POST("/bindings", handler.CreateBinding)
|
||||||
adminGroup.GET("/bindings", handler.ListBindings)
|
adminGroup.GET("/bindings", handler.ListBindings)
|
||||||
|
adminGroup.GET("/bindings/:id", handler.GetBinding)
|
||||||
adminGroup.PUT("/bindings/:id", handler.UpdateBinding)
|
adminGroup.PUT("/bindings/:id", handler.UpdateBinding)
|
||||||
|
adminGroup.DELETE("/bindings/:id", handler.DeleteBinding)
|
||||||
adminGroup.POST("/sync/snapshot", handler.SyncSnapshot)
|
adminGroup.POST("/sync/snapshot", handler.SyncSnapshot)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
279
internal/api/access_handler.go
Normal file
279
internal/api/access_handler.go
Normal 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
|
||||||
|
}
|
||||||
@@ -157,3 +157,68 @@ func (h *Handler) UpdateBinding(c *gin.Context) {
|
|||||||
|
|
||||||
c.JSON(http.StatusOK, existing)
|
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"})
|
||||||
|
}
|
||||||
|
|||||||
@@ -402,11 +402,6 @@ func (s *SyncService) writeBindingsSnapshot(ctx context.Context, pipe redis.Pipe
|
|||||||
snap.Upstreams[fmt.Sprintf("%d", p.id)] = up
|
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
|
key := ns + "." + pm
|
||||||
payload, err := jsoncodec.Marshal(snap)
|
payload, err := jsoncodec.Marshal(snap)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
Reference in New Issue
Block a user