mirror of
https://github.com/EZ-Api/ez-api.git
synced 2026-01-13 17:47:51 +00:00
Add admin endpoints to list and revoke child keys under a master. Standardize OpenAPI responses to use ResponseEnvelope with MapData for error payloads, and regenerate swagger specs accordingly.
334 lines
10 KiB
Go
334 lines
10 KiB
Go
package api
|
|
|
|
import (
|
|
"fmt"
|
|
"net/http"
|
|
"strconv"
|
|
"strings"
|
|
|
|
"github.com/ez-api/ez-api/internal/model"
|
|
"github.com/gin-gonic/gin"
|
|
"gorm.io/gorm"
|
|
)
|
|
|
|
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} ResponseEnvelope{data=AccessResponse}
|
|
// @Failure 400 {object} ResponseEnvelope{data=MapData}
|
|
// @Failure 404 {object} ResponseEnvelope{data=MapData}
|
|
// @Failure 500 {object} ResponseEnvelope{data=MapData}
|
|
// @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} ResponseEnvelope{data=AccessResponse}
|
|
// @Failure 400 {object} ResponseEnvelope{data=MapData}
|
|
// @Failure 404 {object} ResponseEnvelope{data=MapData}
|
|
// @Failure 500 {object} ResponseEnvelope{data=MapData}
|
|
// @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 := ensureNamespacesExist(h.db, nsList); err != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
|
return
|
|
}
|
|
|
|
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} ResponseEnvelope{data=AccessResponse}
|
|
// @Failure 400 {object} ResponseEnvelope{data=MapData}
|
|
// @Failure 404 {object} ResponseEnvelope{data=MapData}
|
|
// @Failure 500 {object} ResponseEnvelope{data=MapData}
|
|
// @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} ResponseEnvelope{data=AccessResponse}
|
|
// @Failure 400 {object} ResponseEnvelope{data=MapData}
|
|
// @Failure 404 {object} ResponseEnvelope{data=MapData}
|
|
// @Failure 500 {object} ResponseEnvelope{data=MapData}
|
|
// @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 := ensureNamespacesExist(h.db, nsList); err != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
|
return
|
|
}
|
|
|
|
var master model.Master
|
|
if err := h.db.First(&master, k.MasterID).Error; err != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "master not found"})
|
|
return
|
|
}
|
|
masterNamespaces := normalizeNamespaces(master.Namespaces, master.DefaultNamespace)
|
|
if !isSubset(nsList, masterNamespaces) {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "namespaces must be a subset of master namespaces"})
|
|
return
|
|
}
|
|
|
|
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 ensureNamespacesExist(db *gorm.DB, namespaces []string) error {
|
|
if db == nil {
|
|
return fmt.Errorf("db required")
|
|
}
|
|
if len(namespaces) == 0 {
|
|
return fmt.Errorf("namespaces required")
|
|
}
|
|
var rows []model.Namespace
|
|
if err := db.Where("name IN ?", namespaces).Find(&rows).Error; err != nil {
|
|
return fmt.Errorf("failed to load namespaces")
|
|
}
|
|
if len(rows) != len(namespaces) {
|
|
return fmt.Errorf("namespace not found")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func isSubset(child, parent []string) bool {
|
|
if len(child) == 0 {
|
|
return true
|
|
}
|
|
parentSet := make(map[string]struct{}, len(parent))
|
|
for _, p := range parent {
|
|
parentSet[strings.TrimSpace(p)] = struct{}{}
|
|
}
|
|
for _, c := range child {
|
|
if _, ok := parentSet[strings.TrimSpace(c)]; !ok {
|
|
return false
|
|
}
|
|
}
|
|
return true
|
|
}
|
|
|
|
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
|
|
}
|