Files
ez-api/internal/api/admin_handler.go
2025-12-19 21:24:24 +08:00

476 lines
16 KiB
Go

package api
import (
"errors"
"net/http"
"strconv"
"strings"
"github.com/ez-api/ez-api/internal/model"
"github.com/ez-api/ez-api/internal/service"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
)
type AdminHandler struct {
db *gorm.DB
masterService *service.MasterService
syncService *service.SyncService
}
func NewAdminHandler(db *gorm.DB, masterService *service.MasterService, syncService *service.SyncService) *AdminHandler {
return &AdminHandler{db: db, masterService: masterService, syncService: syncService}
}
type CreateMasterRequest struct {
Name string `json:"name" binding:"required"`
Group string `json:"group" binding:"required"`
MaxChildKeys int `json:"max_child_keys"`
GlobalQPS int `json:"global_qps"`
}
// CreateMaster godoc
// @Summary Create a new master tenant
// @Description Create a new master account (tenant)
// @Tags admin
// @Accept json
// @Produce json
// @Security AdminAuth
// @Param master body CreateMasterRequest true "Master Info"
// @Success 201 {object} gin.H
// @Failure 400 {object} gin.H
// @Failure 500 {object} gin.H
// @Router /admin/masters [post]
func (h *AdminHandler) CreateMaster(c *gin.Context) {
var req CreateMasterRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
// Use defaults if not provided
if req.MaxChildKeys == 0 {
req.MaxChildKeys = 5
}
if req.GlobalQPS == 0 {
req.GlobalQPS = 3
}
master, rawMasterKey, err := h.masterService.CreateMaster(req.Name, req.Group, req.MaxChildKeys, req.GlobalQPS)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create master key", "details": err.Error()})
return
}
if err := h.syncService.SyncMaster(master); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to sync master key", "details": err.Error()})
return
}
c.JSON(http.StatusCreated, gin.H{
"id": master.ID,
"name": master.Name,
"group": master.Group,
"master_key": rawMasterKey, // Only show this on creation
"max_child_keys": master.MaxChildKeys,
"global_qps": master.GlobalQPS,
})
}
type MasterView struct {
ID uint `json:"id"`
Name string `json:"name"`
Group string `json:"group"`
DefaultNamespace string `json:"default_namespace"`
Namespaces string `json:"namespaces"`
Epoch int64 `json:"epoch"`
Status string `json:"status"`
MaxChildKeys int `json:"max_child_keys"`
GlobalQPS int `json:"global_qps"`
CreatedAt int64 `json:"created_at"`
UpdatedAt int64 `json:"updated_at"`
}
func toMasterView(m model.Master) MasterView {
return MasterView{
ID: m.ID,
Name: m.Name,
Group: m.Group,
DefaultNamespace: strings.TrimSpace(m.DefaultNamespace),
Namespaces: strings.TrimSpace(m.Namespaces),
Epoch: m.Epoch,
Status: m.Status,
MaxChildKeys: m.MaxChildKeys,
GlobalQPS: m.GlobalQPS,
CreatedAt: m.CreatedAt.UTC().Unix(),
UpdatedAt: m.UpdatedAt.UTC().Unix(),
}
}
// ListMasters godoc
// @Summary List masters
// @Description List all master tenants
// @Tags admin
// @Produce json
// @Security AdminAuth
// @Success 200 {array} MasterView
// @Failure 500 {object} gin.H
// @Router /admin/masters [get]
func (h *AdminHandler) ListMasters(c *gin.Context) {
var masters []model.Master
if err := h.db.Order("id desc").Find(&masters).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to list masters", "details": err.Error()})
return
}
out := make([]MasterView, 0, len(masters))
for _, m := range masters {
out = append(out, toMasterView(m))
}
c.JSON(http.StatusOK, out)
}
// GetMaster godoc
// @Summary Get master
// @Description Get a master tenant by id
// @Tags admin
// @Produce json
// @Security AdminAuth
// @Param id path int true "Master ID"
// @Success 200 {object} MasterView
// @Failure 400 {object} gin.H
// @Failure 404 {object} gin.H
// @Failure 500 {object} gin.H
// @Router /admin/masters/{id} [get]
func (h *AdminHandler) GetMaster(c *gin.Context) {
idRaw := strings.TrimSpace(c.Param("id"))
idU64, err := strconv.ParseUint(idRaw, 10, 64)
if err != nil || idU64 == 0 {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid master id"})
return
}
var m model.Master
if err := h.db.First(&m, uint(idU64)).Error; err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "master not found"})
return
}
c.JSON(http.StatusOK, toMasterView(m))
}
type UpdateMasterRequest struct {
Name *string `json:"name,omitempty"`
Group *string `json:"group,omitempty"`
MaxChildKeys *int `json:"max_child_keys,omitempty"`
GlobalQPS *int `json:"global_qps,omitempty"`
PropagateToKeys bool `json:"propagate_to_keys,omitempty"`
}
// UpdateMaster godoc
// @Summary Update master
// @Description Update master fields; optionally propagate group to existing keys
// @Tags admin
// @Accept json
// @Produce json
// @Security AdminAuth
// @Param id path int true "Master ID"
// @Param request body UpdateMasterRequest true "Update payload"
// @Success 200 {object} MasterView
// @Failure 400 {object} gin.H
// @Failure 404 {object} gin.H
// @Failure 500 {object} gin.H
// @Router /admin/masters/{id} [put]
func (h *AdminHandler) UpdateMaster(c *gin.Context) {
idRaw := strings.TrimSpace(c.Param("id"))
idU64, err := strconv.ParseUint(idRaw, 10, 64)
if err != nil || idU64 == 0 {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid master id"})
return
}
var req UpdateMasterRequest
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, uint(idU64)).Error; err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "master not found"})
return
}
update := make(map[string]any)
if req.Name != nil {
update["name"] = strings.TrimSpace(*req.Name)
}
if req.Group != nil {
g := strings.TrimSpace(*req.Group)
if g != "" {
update["group"] = g
}
}
if req.MaxChildKeys != nil && *req.MaxChildKeys > 0 {
update["max_child_keys"] = *req.MaxChildKeys
}
if req.GlobalQPS != nil && *req.GlobalQPS > 0 {
update["global_qps"] = *req.GlobalQPS
}
if len(update) == 0 && !req.PropagateToKeys {
c.JSON(http.StatusBadRequest, gin.H{"error": "no fields to update"})
return
}
if len(update) > 0 {
if err := h.db.Model(&m).Updates(update).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update master", "details": err.Error()})
return
}
}
if err := h.db.First(&m, uint(idU64)).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to reload master", "details": err.Error()})
return
}
if req.PropagateToKeys && req.Group != nil {
g := strings.TrimSpace(*req.Group)
if g != "" {
if err := h.db.Model(&model.Key{}).Where("master_id = ?", m.ID).Updates(map[string]any{"group": g}).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to propagate group to keys", "details": err.Error()})
return
}
}
}
// Sync master metadata and (if propagated) keys.
if err := h.syncService.SyncMaster(&m); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to sync master", "details": err.Error()})
return
}
if req.PropagateToKeys {
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.syncService.SyncKey(&keys[i]); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to sync key", "details": err.Error()})
return
}
}
}
c.JSON(http.StatusOK, toMasterView(m))
}
type ManageMasterRequest struct {
Action string `json:"action" binding:"required"` // freeze/unfreeze
}
// ManageMaster godoc
// @Summary Manage master status
// @Description Freeze or unfreeze a master tenant
// @Tags admin
// @Accept json
// @Produce json
// @Security AdminAuth
// @Param id path int true "Master ID"
// @Param request body ManageMasterRequest true "Action"
// @Success 200 {object} MasterView
// @Failure 400 {object} gin.H
// @Failure 404 {object} gin.H
// @Failure 500 {object} gin.H
// @Router /admin/masters/{id}/manage [post]
func (h *AdminHandler) ManageMaster(c *gin.Context) {
idRaw := strings.TrimSpace(c.Param("id"))
idU64, err := strconv.ParseUint(idRaw, 10, 64)
if err != nil || idU64 == 0 {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid master id"})
return
}
var req ManageMasterRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
action := strings.ToLower(strings.TrimSpace(req.Action))
var status string
switch action {
case "freeze":
status = "suspended"
case "unfreeze":
status = "active"
default:
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid action"})
return
}
var m model.Master
if err := h.db.First(&m, uint(idU64)).Error; err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "master not found"})
return
}
if err := h.db.Model(&m).Update("status", status).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update master status", "details": err.Error()})
return
}
if err := h.db.First(&m, uint(idU64)).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to reload master", "details": err.Error()})
return
}
if err := h.syncService.SyncMaster(&m); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to sync master", "details": err.Error()})
return
}
c.JSON(http.StatusOK, toMasterView(m))
}
// DeleteMaster godoc
// @Summary Delete (revoke) master
// @Description Suspends a master and revokes all existing keys by bumping epoch and syncing to Redis
// @Tags admin
// @Produce json
// @Security AdminAuth
// @Param id path int true "Master ID"
// @Success 200 {object} gin.H
// @Failure 400 {object} gin.H
// @Failure 404 {object} gin.H
// @Failure 500 {object} gin.H
// @Router /admin/masters/{id} [delete]
func (h *AdminHandler) DeleteMaster(c *gin.Context) {
idRaw := strings.TrimSpace(c.Param("id"))
idU64, err := strconv.ParseUint(idRaw, 10, 64)
if err != nil || idU64 == 0 {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid master id"})
return
}
var m model.Master
if err := h.db.First(&m, uint(idU64)).Error; err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "master not found"})
return
}
nextEpoch := m.Epoch + 1
if nextEpoch <= 0 {
nextEpoch = 1
}
if err := h.db.Model(&m).Updates(map[string]any{
"status": "suspended",
"epoch": nextEpoch,
}).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to revoke master", "details": err.Error()})
return
}
if err := h.db.First(&m, uint(idU64)).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to reload master", "details": err.Error()})
return
}
// Revoke all child keys too (defense-in-depth; master status already blocks in DP).
if err := h.db.Model(&model.Key{}).Where("master_id = ?", m.ID).Update("status", "suspended").Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to revoke child keys", "details": err.Error()})
return
}
if err := h.syncService.SyncMaster(&m); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to sync master", "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.syncService.SyncKey(&keys[i]); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to sync key", "details": err.Error()})
return
}
}
c.JSON(http.StatusOK, gin.H{"status": "revoked"})
}
// IssueChildKeyForMaster godoc
// @Summary Issue a child key on behalf of a master
// @Description Issue a new access token (child key) for a specified master. The key still belongs to the master; issuer is recorded as admin for audit.
// @Tags admin
// @Accept json
// @Produce json
// @Security AdminAuth
// @Param id path int true "Master ID"
// @Param request body IssueChildKeyRequest true "Key Request"
// @Success 201 {object} gin.H
// @Failure 400 {object} gin.H
// @Failure 403 {object} gin.H
// @Failure 404 {object} gin.H
// @Failure 500 {object} gin.H
// @Router /admin/masters/{id}/keys [post]
func (h *AdminHandler) IssueChildKeyForMaster(c *gin.Context) {
idRaw := strings.TrimSpace(c.Param("id"))
if idRaw == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "master id required"})
return
}
idU64, err := strconv.ParseUint(idRaw, 10, 64)
if err != nil || idU64 == 0 {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid master id"})
return
}
masterID := uint(idU64)
var req IssueChildKeyRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
modelLimits := strings.TrimSpace(req.ModelLimits)
modelLimitsEnabled := false
if req.ModelLimitsEnabled != nil {
modelLimitsEnabled = *req.ModelLimitsEnabled
} else if modelLimits != "" {
modelLimitsEnabled = true
}
key, rawChildKey, err := h.masterService.IssueChildKeyAsAdmin(masterID, service.IssueKeyOptions{
Group: strings.TrimSpace(req.Group),
Scopes: strings.TrimSpace(req.Scopes),
ModelLimits: modelLimits,
ModelLimitsEnabled: modelLimitsEnabled,
ExpiresAt: req.ExpiresAt,
AllowIPs: strings.TrimSpace(req.AllowIPs),
DenyIPs: strings.TrimSpace(req.DenyIPs),
})
if err != nil {
switch {
case errors.Is(err, service.ErrMasterNotFound):
c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
case errors.Is(err, service.ErrMasterNotActive):
c.JSON(http.StatusForbidden, gin.H{"error": err.Error()})
case errors.Is(err, service.ErrModelLimitForbidden):
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
case errors.Is(err, service.ErrChildKeyGroupForbidden):
c.JSON(http.StatusForbidden, gin.H{"error": err.Error()})
case errors.Is(err, service.ErrChildKeyLimitReached):
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
default:
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to issue child key", "details": err.Error()})
}
return
}
if err := h.syncService.SyncKey(key); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to sync child key", "details": err.Error()})
return
}
c.JSON(http.StatusCreated, gin.H{
"id": key.ID,
"key_secret": rawChildKey,
"group": key.Group,
"scopes": key.Scopes,
"issued_by": key.IssuedBy,
})
}