From aa69ce3659e879d96bb22021acc9415cf4fc7c01 Mon Sep 17 00:00:00 2001 From: zenfun Date: Mon, 15 Dec 2025 15:59:33 +0800 Subject: [PATCH] feat(api): add admin endpoint to issue keys for masters Add `POST /admin/masters/{id}/keys` allowing admins to issue child keys on behalf of a master. Introduce an `issued_by` field in the Key model to audit whether a key was issued by the master or an admin. Refactor master service to use typed errors for consistent HTTP status mapping and ensure validation logic (active status, group check) is shared. --- README.md | 1 + cmd/server/main.go | 1 + internal/api/admin_handler.go | 68 ++++++++++++++++++++++ internal/api/admin_issue_key_test.go | 85 ++++++++++++++++++++++++++++ internal/api/master_handler.go | 14 ++++- internal/model/models.go | 1 + internal/service/master.go | 37 +++++++++++- internal/service/master_test.go | 28 +++++++++ 8 files changed, 232 insertions(+), 3 deletions(-) create mode 100644 internal/api/admin_issue_key_test.go diff --git a/README.md b/README.md index dcdd20f..7f01fcc 100644 --- a/README.md +++ b/README.md @@ -25,6 +25,7 @@ EZ-API 是"大脑"。它管理着事实的来源 (Source of Truth)。 - `POST /keys`: 创建新的客户端 API 密钥。 - `POST /models`: 注册支持的模型。 - `GET /models`: 列出所有模型。 +- `POST /admin/masters/{id}/keys`: 代某个 master 签发子 key(子 key 仍归属该 master;仅审计 `issued_by=admin`)。 ### Feature Flags(给未来前端用) diff --git a/cmd/server/main.go b/cmd/server/main.go index 65b760b..6e0d7c1 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -158,6 +158,7 @@ func main() { adminGroup.Use(middleware.AdminAuthMiddleware(adminService)) { adminGroup.POST("/masters", adminHandler.CreateMaster) + adminGroup.POST("/masters/:id/keys", adminHandler.IssueChildKeyForMaster) adminGroup.GET("/features", featureHandler.ListFeatures) adminGroup.PUT("/features", featureHandler.UpdateFeatures) // Other admin routes for managing providers, models, etc. diff --git a/internal/api/admin_handler.go b/internal/api/admin_handler.go index 454550e..b68fc31 100644 --- a/internal/api/admin_handler.go +++ b/internal/api/admin_handler.go @@ -1,7 +1,10 @@ package api import ( + "errors" "net/http" + "strconv" + "strings" "github.com/ez-api/ez-api/internal/service" "github.com/gin-gonic/gin" @@ -70,3 +73,68 @@ func (h *AdminHandler) CreateMaster(c *gin.Context) { "global_qps": master.GlobalQPS, }) } + +// 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 + } + + key, rawChildKey, err := h.masterService.IssueChildKeyAsAdmin(masterID, req.Group, req.Scopes) + 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.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, + }) +} diff --git a/internal/api/admin_issue_key_test.go b/internal/api/admin_issue_key_test.go new file mode 100644 index 0000000..a3793af --- /dev/null +++ b/internal/api/admin_issue_key_test.go @@ -0,0 +1,85 @@ +package api + +import ( + "bytes" + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "testing" + + "github.com/alicebob/miniredis/v2" + "github.com/ez-api/ez-api/internal/model" + "github.com/ez-api/ez-api/internal/service" + "github.com/ez-api/foundation/tokenhash" + "github.com/gin-gonic/gin" + "github.com/redis/go-redis/v9" + "gorm.io/driver/sqlite" + "gorm.io/gorm" +) + +func TestAdmin_IssueChildKeyForMaster_IssuedByAdminAndSynced(t *testing.T) { + gin.SetMode(gin.TestMode) + + db, err := gorm.Open(sqlite.Open("file::memory:?cache=shared"), &gorm.Config{}) + if err != nil { + t.Fatalf("open sqlite: %v", err) + } + if err := db.AutoMigrate(&model.Master{}, &model.Key{}); err != nil { + t.Fatalf("migrate: %v", err) + } + + mr := miniredis.RunT(t) + rdb := redis.NewClient(&redis.Options{Addr: mr.Addr()}) + + syncService := service.NewSyncService(rdb) + masterService := service.NewMasterService(db) + adminHandler := NewAdminHandler(masterService, syncService) + + m, _, err := masterService.CreateMaster("m1", "default", 5, 10) + if err != nil { + t.Fatalf("CreateMaster: %v", err) + } + + r := gin.New() + r.POST("/admin/masters/:id/keys", adminHandler.IssueChildKeyForMaster) + + body := []byte(`{"scopes":"chat:write"}`) + req := httptest.NewRequest(http.MethodPost, fmt.Sprintf("/admin/masters/%d/keys", m.ID), bytes.NewReader(body)) + rr := httptest.NewRecorder() + r.ServeHTTP(rr, req) + + if rr.Code != http.StatusCreated { + t.Fatalf("unexpected status: got=%d body=%s", rr.Code, rr.Body.String()) + } + + var resp map[string]any + if err := json.Unmarshal(rr.Body.Bytes(), &resp); err != nil { + t.Fatalf("decode response: %v", err) + } + if resp["issued_by"] != "admin" { + t.Fatalf("expected issued_by=admin, got=%v", resp["issued_by"]) + } + rawKey, _ := resp["key_secret"].(string) + if rawKey == "" { + t.Fatalf("expected key_secret in response") + } + + // DB audit + var keys []model.Key + if err := db.Where("master_id = ?", m.ID).Find(&keys).Error; err != nil { + t.Fatalf("load keys: %v", err) + } + if len(keys) != 1 { + t.Fatalf("expected 1 key row, got %d", len(keys)) + } + if keys[0].IssuedBy != "admin" { + t.Fatalf("expected IssuedBy=admin, got %q", keys[0].IssuedBy) + } + + // Redis sync: auth:token:{hash} exists + hash := tokenhash.HashToken(rawKey) + if !mr.Exists("auth:token:" + hash) { + t.Fatalf("expected auth token hash key to exist in redis") + } +} diff --git a/internal/api/master_handler.go b/internal/api/master_handler.go index 6f7103c..7ad6240 100644 --- a/internal/api/master_handler.go +++ b/internal/api/master_handler.go @@ -1,6 +1,7 @@ package api import ( + "errors" "net/http" "strings" @@ -66,7 +67,18 @@ func (h *MasterHandler) IssueChildKey(c *gin.Context) { key, rawChildKey, err := h.masterService.IssueChildKey(masterModel.ID, group, req.Scopes) if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to issue child key", "details": err.Error()}) + 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.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 } diff --git a/internal/model/models.go b/internal/model/models.go index c8518a0..92814e2 100644 --- a/internal/model/models.go +++ b/internal/model/models.go @@ -31,6 +31,7 @@ type Key struct { Scopes string `gorm:"size:1024" json:"scopes"` // Comma-separated scopes IssuedAtEpoch int64 `gorm:"not null" json:"issued_at_epoch"` // copy of master epoch at issuance Status string `gorm:"size:50;default:'active'" json:"status"` // active, suspended + IssuedBy string `gorm:"size:20;default:'master'" json:"issued_by"` } // Provider remains the same. diff --git a/internal/service/master.go b/internal/service/master.go index 269f721..922ffb1 100644 --- a/internal/service/master.go +++ b/internal/service/master.go @@ -13,6 +13,13 @@ import ( "gorm.io/gorm" ) +var ( + ErrMasterNotFound = errors.New("master not found") + ErrMasterNotActive = errors.New("master is not active") + ErrChildKeyLimitReached = errors.New("child key limit reached") + ErrChildKeyGroupForbidden = errors.New("cannot issue key for a different group") +) + type MasterService struct { db *gorm.DB } @@ -92,15 +99,37 @@ verified: } func (s *MasterService) IssueChildKey(masterID uint, group string, scopes string) (*model.Key, string, error) { + return s.issueChildKey(masterID, group, scopes, "master") +} + +func (s *MasterService) IssueChildKeyAsAdmin(masterID uint, group string, scopes string) (*model.Key, string, error) { + return s.issueChildKey(masterID, group, scopes, "admin") +} + +func (s *MasterService) issueChildKey(masterID uint, group string, scopes string, issuedBy string) (*model.Key, string, error) { var master model.Master if err := s.db.First(&master, masterID).Error; err != nil { - return nil, "", fmt.Errorf("master not found: %w", err) + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, "", fmt.Errorf("%w: %d", ErrMasterNotFound, masterID) + } + return nil, "", fmt.Errorf("load master: %w", err) + } + if master.Status != "active" { + return nil, "", fmt.Errorf("%w", ErrMasterNotActive) + } + + group = strings.TrimSpace(group) + if group == "" { + group = master.Group + } + if group != master.Group { + return nil, "", fmt.Errorf("%w", ErrChildKeyGroupForbidden) } var count int64 s.db.Model(&model.Key{}).Where("master_id = ?", masterID).Count(&count) if count >= int64(master.MaxChildKeys) { - return nil, "", fmt.Errorf("child key limit reached for master %d", masterID) + return nil, "", fmt.Errorf("%w for master %d", ErrChildKeyLimitReached, masterID) } rawChildKey, err := generateRandomKey(32) @@ -123,6 +152,10 @@ func (s *MasterService) IssueChildKey(masterID uint, group string, scopes string Scopes: scopes, IssuedAtEpoch: master.Epoch, Status: "active", + IssuedBy: strings.TrimSpace(issuedBy), + } + if key.IssuedBy == "" { + key.IssuedBy = "master" } if err := s.db.Create(key).Error; err != nil { diff --git a/internal/service/master_test.go b/internal/service/master_test.go index 5dcdc06..b276e2d 100644 --- a/internal/service/master_test.go +++ b/internal/service/master_test.go @@ -67,3 +67,31 @@ func TestMasterService_IssueChildKey_RespectsLimit(t *testing.T) { } } +func TestMasterService_IssueChildKeyAsAdmin_SetsIssuedBy(t *testing.T) { + db := newTestDB(t) + svc := NewMasterService(db) + + m, _, err := svc.CreateMaster("m1", "default", 2, 10) + if err != nil { + t.Fatalf("CreateMaster: %v", err) + } + + key, raw, err := svc.IssueChildKeyAsAdmin(m.ID, "", "chat:write") + if err != nil { + t.Fatalf("IssueChildKeyAsAdmin: %v", err) + } + if raw == "" { + t.Fatalf("expected raw child key") + } + if key.IssuedBy != "admin" { + t.Fatalf("expected IssuedBy=admin, got %q", key.IssuedBy) + } + + var stored model.Key + if err := db.First(&stored, key.ID).Error; err != nil { + t.Fatalf("load key: %v", err) + } + if stored.IssuedBy != "admin" { + t.Fatalf("expected stored IssuedBy=admin, got %q", stored.IssuedBy) + } +}