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) + } +}