Files
ez-api/internal/api/master_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

442 lines
16 KiB
Go

package api
import (
"errors"
"net/http"
"strconv"
"strings"
"time"
"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 MasterHandler struct {
db *gorm.DB
logDB *gorm.DB
masterService *service.MasterService
syncService *service.SyncService
statsService *service.StatsService
logPartitioner *service.LogPartitioner
}
func NewMasterHandler(db *gorm.DB, logDB *gorm.DB, masterService *service.MasterService, syncService *service.SyncService, statsService *service.StatsService, partitioner *service.LogPartitioner) *MasterHandler {
if logDB == nil {
logDB = db
}
return &MasterHandler{db: db, logDB: logDB, masterService: masterService, syncService: syncService, statsService: statsService, logPartitioner: partitioner}
}
func (h *MasterHandler) logDBConn() *gorm.DB {
if h == nil || h.logDB == nil {
return h.db
}
return h.logDB
}
func (h *MasterHandler) logBaseQuery() *gorm.DB {
return logBaseQuery(h.logDBConn(), h.logPartitioner)
}
type IssueChildKeyRequest struct {
Group string `json:"group"`
Scopes string `json:"scopes"`
ModelLimits string `json:"model_limits,omitempty"`
ModelLimitsEnabled *bool `json:"model_limits_enabled,omitempty"`
ExpiresAt *time.Time `json:"expires_at,omitempty"`
AllowIPs string `json:"allow_ips,omitempty"`
DenyIPs string `json:"deny_ips,omitempty"`
}
// IssueChildKey godoc
// @Summary Issue a child key
// @Description Issue a new access token (child key) for the authenticated master
// @Tags master
// @Accept json
// @Produce json
// @Security MasterAuth
// @Param request body IssueChildKeyRequest true "Key Request"
// @Success 201 {object} ResponseEnvelope{data=MapData}
// @Failure 400 {object} ResponseEnvelope{data=MapData}
// @Failure 401 {object} ResponseEnvelope{data=MapData}
// @Failure 403 {object} ResponseEnvelope{data=MapData}
// @Failure 500 {object} ResponseEnvelope{data=MapData}
// @Router /v1/tokens [post]
func (h *MasterHandler) IssueChildKey(c *gin.Context) {
master, exists := c.Get("master")
if !exists {
c.JSON(http.StatusUnauthorized, gin.H{"error": "master key not found in context"})
return
}
masterModel := master.(*model.Master)
var req IssueChildKeyRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
// If group is not specified, inherit from master
group := req.Group
if strings.TrimSpace(group) == "" {
group = masterModel.Group
}
// Security: Ensure the requested group is allowed for this master.
// For now, we'll just enforce it's the same group.
if group != masterModel.Group {
c.JSON(http.StatusForbidden, gin.H{"error": "cannot issue key for a different group"})
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.IssueChildKey(masterModel.ID, service.IssueKeyOptions{
Group: 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,
})
}
// GetSelf godoc
// @Summary Get current master info
// @Description Returns master metadata for the authenticated master key
// @Tags master
// @Produce json
// @Security MasterAuth
// @Success 200 {object} ResponseEnvelope{data=MasterView}
// @Failure 401 {object} ResponseEnvelope{data=MapData}
// @Router /v1/self [get]
func (h *MasterHandler) GetSelf(c *gin.Context) {
master, exists := c.Get("master")
if !exists {
c.JSON(http.StatusUnauthorized, gin.H{"error": "master key not found in context"})
return
}
m := master.(*model.Master)
c.JSON(http.StatusOK, toMasterView(*m))
}
type TokenView struct {
ID uint `json:"id"`
Group string `json:"group"`
Scopes string `json:"scopes"`
Status string `json:"status"`
IssuedBy string `json:"issued_by"`
IssuedAtEpoch int64 `json:"issued_at_epoch"`
DefaultNamespace string `json:"default_namespace"`
Namespaces string `json:"namespaces"`
ModelLimits string `json:"model_limits,omitempty"`
ModelLimitsEnabled bool `json:"model_limits_enabled"`
ExpiresAt *time.Time `json:"expires_at,omitempty"`
AllowIPs string `json:"allow_ips,omitempty"`
DenyIPs string `json:"deny_ips,omitempty"`
LastAccessedAt *time.Time `json:"last_accessed_at,omitempty"`
RequestCount int64 `json:"request_count"`
UsedTokens int64 `json:"used_tokens"`
QuotaLimit int64 `json:"quota_limit"`
QuotaUsed int64 `json:"quota_used"`
QuotaResetAt *time.Time `json:"quota_reset_at,omitempty"`
QuotaResetType string `json:"quota_reset_type,omitempty"`
CreatedAt int64 `json:"created_at"`
UpdatedAt int64 `json:"updated_at"`
}
func toTokenView(k model.Key) TokenView {
return TokenView{
ID: k.ID,
Group: k.Group,
Scopes: k.Scopes,
Status: k.Status,
IssuedBy: k.IssuedBy,
IssuedAtEpoch: k.IssuedAtEpoch,
DefaultNamespace: strings.TrimSpace(k.DefaultNamespace),
Namespaces: strings.TrimSpace(k.Namespaces),
ModelLimits: strings.TrimSpace(k.ModelLimits),
ModelLimitsEnabled: k.ModelLimitsEnabled,
ExpiresAt: k.ExpiresAt,
AllowIPs: strings.TrimSpace(k.AllowIPs),
DenyIPs: strings.TrimSpace(k.DenyIPs),
LastAccessedAt: k.LastAccessedAt,
RequestCount: k.RequestCount,
UsedTokens: k.UsedTokens,
QuotaLimit: k.QuotaLimit,
QuotaUsed: k.QuotaUsed,
QuotaResetAt: k.QuotaResetAt,
QuotaResetType: strings.TrimSpace(k.QuotaResetType),
CreatedAt: k.CreatedAt.UTC().Unix(),
UpdatedAt: k.UpdatedAt.UTC().Unix(),
}
}
// ListTokens godoc
// @Summary List child keys
// @Description List child keys issued under the authenticated master
// @Tags master
// @Produce json
// @Security MasterAuth
// @Param page query int false "page (1-based)"
// @Param limit query int false "limit (default 50, max 200)"
// @Param search query string false "search by group/scopes/namespaces/status"
// @Success 200 {object} ResponseEnvelope{data=[]TokenView}
// @Failure 401 {object} ResponseEnvelope{data=MapData}
// @Failure 500 {object} ResponseEnvelope{data=MapData}
// @Router /v1/tokens [get]
func (h *MasterHandler) ListTokens(c *gin.Context) {
master, exists := c.Get("master")
if !exists {
c.JSON(http.StatusUnauthorized, gin.H{"error": "master key not found in context"})
return
}
m := master.(*model.Master)
var keys []model.Key
q := h.db.Model(&model.Key{}).Where("master_id = ?", m.ID).Order("id desc")
query := parseListQuery(c)
q = applyListSearch(q, query.Search, `"group"`, "scopes", "default_namespace", "namespaces", "status")
q = applyListPagination(q, query)
if err := q.Find(&keys).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list tokens", "details": err.Error()})
return
}
out := make([]TokenView, 0, len(keys))
for _, k := range keys {
out = append(out, toTokenView(k))
}
c.JSON(http.StatusOK, out)
}
// GetToken godoc
// @Summary Get child key
// @Description Get a child key by id under the authenticated master
// @Tags master
// @Produce json
// @Security MasterAuth
// @Param id path int true "Token ID"
// @Success 200 {object} ResponseEnvelope{data=TokenView}
// @Failure 400 {object} ResponseEnvelope{data=MapData}
// @Failure 401 {object} ResponseEnvelope{data=MapData}
// @Failure 404 {object} ResponseEnvelope{data=MapData}
// @Failure 500 {object} ResponseEnvelope{data=MapData}
// @Router /v1/tokens/{id} [get]
func (h *MasterHandler) GetToken(c *gin.Context) {
master, exists := c.Get("master")
if !exists {
c.JSON(http.StatusUnauthorized, gin.H{"error": "master key not found in context"})
return
}
m := master.(*model.Master)
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 token id"})
return
}
var k model.Key
if err := h.db.Where("master_id = ? AND id = ?", m.ID, uint(idU64)).First(&k).Error; err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "token not found"})
return
}
c.JSON(http.StatusOK, toTokenView(k))
}
type UpdateTokenRequest struct {
Scopes *string `json:"scopes,omitempty"`
Status *string `json:"status,omitempty"` // active/suspended
ModelLimits *string `json:"model_limits,omitempty"`
ModelLimitsEnabled *bool `json:"model_limits_enabled,omitempty"`
ExpiresAt *time.Time `json:"expires_at,omitempty"`
AllowIPs *string `json:"allow_ips,omitempty"`
DenyIPs *string `json:"deny_ips,omitempty"`
}
// UpdateToken godoc
// @Summary Update child key
// @Description Update token scopes/status under the authenticated master
// @Tags master
// @Accept json
// @Produce json
// @Security MasterAuth
// @Param id path int true "Token ID"
// @Param request body UpdateTokenRequest true "Update payload"
// @Success 200 {object} ResponseEnvelope{data=TokenView}
// @Failure 400 {object} ResponseEnvelope{data=MapData}
// @Failure 401 {object} ResponseEnvelope{data=MapData}
// @Failure 404 {object} ResponseEnvelope{data=MapData}
// @Failure 500 {object} ResponseEnvelope{data=MapData}
// @Router /v1/tokens/{id} [put]
func (h *MasterHandler) UpdateToken(c *gin.Context) {
master, exists := c.Get("master")
if !exists {
c.JSON(http.StatusUnauthorized, gin.H{"error": "master key not found in context"})
return
}
m := master.(*model.Master)
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 token id"})
return
}
var req UpdateTokenRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
update := make(map[string]any)
if req.Scopes != nil {
update["scopes"] = strings.TrimSpace(*req.Scopes)
}
if req.ModelLimits != nil {
modelLimits := strings.TrimSpace(*req.ModelLimits)
if modelLimits != "" {
if err := h.masterService.ValidateModelLimits(m, modelLimits); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
}
update["model_limits"] = modelLimits
if req.ModelLimitsEnabled == nil {
update["model_limits_enabled"] = modelLimits != ""
}
}
if req.ModelLimitsEnabled != nil {
update["model_limits_enabled"] = *req.ModelLimitsEnabled
}
if req.ExpiresAt != nil {
update["expires_at"] = req.ExpiresAt
}
if req.AllowIPs != nil {
update["allow_ips"] = strings.TrimSpace(*req.AllowIPs)
}
if req.DenyIPs != nil {
update["deny_ips"] = strings.TrimSpace(*req.DenyIPs)
}
if req.Status != nil {
st := strings.ToLower(strings.TrimSpace(*req.Status))
if st != "active" && st != "suspended" {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid status"})
return
}
update["status"] = st
}
if len(update) == 0 {
c.JSON(http.StatusBadRequest, gin.H{"error": "no fields to update"})
return
}
var k model.Key
if err := h.db.Where("master_id = ? AND id = ?", m.ID, uint(idU64)).First(&k).Error; err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "token not found"})
return
}
if err := h.db.Model(&k).Updates(update).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to update token", "details": err.Error()})
return
}
if err := h.db.Where("master_id = ? AND id = ?", m.ID, uint(idU64)).First(&k).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to reload token", "details": err.Error()})
return
}
if err := h.syncService.SyncKey(&k); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to sync token", "details": err.Error()})
return
}
c.JSON(http.StatusOK, toTokenView(k))
}
// DeleteToken godoc
// @Summary Delete (revoke) child key
// @Description Suspends a child key under the authenticated master
// @Tags master
// @Produce json
// @Security MasterAuth
// @Param id path int true "Token ID"
// @Success 200 {object} ResponseEnvelope{data=MapData}
// @Failure 400 {object} ResponseEnvelope{data=MapData}
// @Failure 401 {object} ResponseEnvelope{data=MapData}
// @Failure 404 {object} ResponseEnvelope{data=MapData}
// @Failure 500 {object} ResponseEnvelope{data=MapData}
// @Router /v1/tokens/{id} [delete]
func (h *MasterHandler) DeleteToken(c *gin.Context) {
master, exists := c.Get("master")
if !exists {
c.JSON(http.StatusUnauthorized, gin.H{"error": "master key not found in context"})
return
}
m := master.(*model.Master)
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 token id"})
return
}
var k model.Key
if err := h.db.Where("master_id = ? AND id = ?", m.ID, uint(idU64)).First(&k).Error; err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "token not found"})
return
}
if err := h.db.Model(&k).Update("status", "suspended").Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to revoke token", "details": err.Error()})
return
}
if err := h.db.Where("master_id = ? AND id = ?", m.ID, uint(idU64)).First(&k).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to reload token", "details": err.Error()})
return
}
if err := h.syncService.SyncKey(&k); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to sync token", "details": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"status": "revoked"})
}