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 masterService *service.MasterService syncService *service.SyncService } func NewMasterHandler(db *gorm.DB, masterService *service.MasterService, syncService *service.SyncService) *MasterHandler { return &MasterHandler{db: db, masterService: masterService, syncService: syncService} } 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} gin.H // @Failure 400 {object} gin.H // @Failure 401 {object} gin.H // @Failure 403 {object} gin.H // @Failure 500 {object} gin.H // @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} MasterView // @Failure 401 {object} gin.H // @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 // @Success 200 {array} TokenView // @Failure 401 {object} gin.H // @Failure 500 {object} gin.H // @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 if err := h.db.Where("master_id = ?", m.ID).Order("id desc").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} TokenView // @Failure 400 {object} gin.H // @Failure 401 {object} gin.H // @Failure 404 {object} gin.H // @Failure 500 {object} gin.H // @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} TokenView // @Failure 400 {object} gin.H // @Failure 401 {object} gin.H // @Failure 404 {object} gin.H // @Failure 500 {object} gin.H // @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} gin.H // @Failure 400 {object} gin.H // @Failure 401 {object} gin.H // @Failure 404 {object} gin.H // @Failure 500 {object} gin.H // @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"}) }