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.
This commit is contained in:
zenfun
2025-12-15 15:59:33 +08:00
parent 11f6e81798
commit aa69ce3659
8 changed files with 232 additions and 3 deletions

View File

@@ -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,
})
}

View File

@@ -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")
}
}

View File

@@ -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
}

View File

@@ -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.

View File

@@ -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 {

View File

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