Files
ez-api/internal/api/access_handler.go
zenfun 5349c9c833 feat(api): add admin master key listing/revoke
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.
2026-01-10 01:10:36 +08:00

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
}