feat: 实现 API 安全审计与管理端 API (#45)

This commit is contained in:
ila
2026-08-25 09:57:19 +08:00
parent af4afcef20
commit b1f8bcb0cf
18 changed files with 785 additions and 28 deletions
+160
View File
@@ -0,0 +1,160 @@
package chorus
import (
"context"
"errors"
"fmt"
"strconv"
"strings"
"time"
"unicode/utf8"
"git.ilapage.cn/OPC/chorus/internal/core/apiaudit"
"git.ilapage.cn/OPC/chorus/internal/core/model"
"gorm.io/gorm"
"gorm.io/gorm/clause"
)
type adminAPIKeyRow struct {
model.APIKey
Username string `gorm:"column:username"`
DisplayName string `gorm:"column:display_name"`
}
func (s *Service) APIKeys(ctx context.Context, filter APIKeyFilter) (APIKeyPage, error) {
filter.Keyword = strings.TrimSpace(filter.Keyword)
if filter.Page <= 0 || filter.PageSize <= 0 || filter.PageSize > 100 || utf8.RuneCountInString(filter.Keyword) > 128 || (filter.Status != "" && filter.Status != "active" && filter.Status != "expired" && filter.Status != "revoked") {
return APIKeyPage{}, FieldError{Field: "query", Message: "contains invalid filters or pagination"}
}
now := s.nowUTC()
query := s.db.WithContext(ctx).Table("api_keys AS api_key").Joins("JOIN users AS portal_user ON portal_user.id = api_key.user_id")
if filter.Keyword != "" {
pattern := "%" + filter.Keyword + "%"
if userID, err := strconv.ParseUint(filter.Keyword, 10, 64); err == nil && userID != 0 {
query = query.Where("(api_key.name LIKE ? OR api_key.key_prefix LIKE ? OR portal_user.username LIKE ? OR api_key.user_id = ?)", pattern, pattern, pattern, userID)
} else {
query = query.Where("(api_key.name LIKE ? OR api_key.key_prefix LIKE ? OR portal_user.username LIKE ?)", pattern, pattern, pattern)
}
}
switch filter.Status {
case "active":
query = query.Where("api_key.revoked_at IS NULL AND (api_key.expires_at IS NULL OR api_key.expires_at > ?)", now)
case "expired":
query = query.Where("api_key.revoked_at IS NULL AND api_key.expires_at <= ?", now)
case "revoked":
query = query.Where("api_key.revoked_at IS NOT NULL")
}
var total int64
if err := query.Count(&total).Error; err != nil {
return APIKeyPage{}, fmt.Errorf("count api keys: %w", err)
}
var rows []adminAPIKeyRow
if err := query.Select("api_key.*, portal_user.username, portal_user.display_name").
Order("api_key.created_at DESC, api_key.id DESC").Offset((filter.Page - 1) * filter.PageSize).Limit(filter.PageSize).Scan(&rows).Error; err != nil {
return APIKeyPage{}, fmt.Errorf("list api keys: %w", err)
}
items := make([]APIKeyView, 0, len(rows))
for _, row := range rows {
items = append(items, adminAPIKeyView(row, now))
}
return APIKeyPage{Items: items, Total: total, Page: filter.Page, PageSize: filter.PageSize}, nil
}
func (s *Service) APIKey(ctx context.Context, id uint64) (APIKeyDetail, error) {
if id == 0 {
return APIKeyDetail{}, FieldError{Field: "id", Message: "must be positive"}
}
row, err := s.apiKeyRow(s.db.WithContext(ctx), id, false)
if err != nil {
return APIKeyDetail{}, err
}
var events []model.APIAuditEvent
if err := s.db.WithContext(ctx).Where("api_key_id = ?", id).Order("created_at DESC, id DESC").Limit(20).Find(&events).Error; err != nil {
return APIKeyDetail{}, fmt.Errorf("list api key audit events: %w", err)
}
views := make([]APIAuditEventView, 0, len(events))
for _, event := range events {
views = append(views, APIAuditEventView{
ID: event.ID, GenerationID: event.GenerationID, Action: event.Action, Result: event.Result,
RequestID: event.RequestID, StatusCode: event.StatusCode, ErrorCode: event.ErrorCode,
Summary: append([]byte(nil), event.Summary...), CreatedAt: event.CreatedAt.UTC(),
})
}
return APIKeyDetail{APIKeyView: adminAPIKeyView(row, s.nowUTC()), RecentEvents: views}, nil
}
func (s *Service) RevokeAPIKey(ctx context.Context, actor uint64, requestID string, id uint64) (APIKeyView, error) {
if actor == 0 || id == 0 {
return APIKeyView{}, FieldError{Field: "id", Message: "administrator and key id must be positive"}
}
var view APIKeyView
err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
row, err := s.apiKeyRow(tx, id, true)
if err != nil {
return err
}
alreadyRevoked := row.RevokedAt != nil
if !alreadyRevoked {
now := s.nowUTC()
if err := tx.Model(&model.APIKey{}).Where("id = ? AND revoked_at IS NULL", id).Updates(map[string]any{"revoked_at": now, "updated_at": now}).Error; err != nil {
return fmt.Errorf("revoke api key: %w", err)
}
row.RevokedAt, row.UpdatedAt = &now, now
}
if err := s.audit(tx, actor, requestID, "api_key.revoke", "api_key", id, "succeeded", map[string]any{"already_revoked": alreadyRevoked}); err != nil {
return err
}
repository, err := apiaudit.NewRepository(tx)
if err != nil {
return err
}
if err := repository.Append(ctx, apiaudit.Event{
UserID: &row.UserID, APIKeyID: &id, Action: apiaudit.ActionKeyAdminRevoked,
Result: apiaudit.ResultSucceeded, RequestID: requestID, StatusCode: 200,
Summary: apiaudit.Summary{Source: "admin", OperatorID: actor, AlreadyRevoked: &alreadyRevoked}, CreatedAt: s.nowUTC(),
}); err != nil {
return err
}
view = adminAPIKeyView(row, s.nowUTC())
return nil
})
return view, err
}
func (s *Service) apiKeyRow(db *gorm.DB, id uint64, lock bool) (adminAPIKeyRow, error) {
query := db.Table("api_keys AS api_key").Select("api_key.*, portal_user.username, portal_user.display_name").Joins("JOIN users AS portal_user ON portal_user.id = api_key.user_id")
if lock {
query = query.Clauses(clause.Locking{Strength: "UPDATE"})
}
var row adminAPIKeyRow
if err := query.Where("api_key.id = ?", id).Take(&row).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return adminAPIKeyRow{}, ErrNotFound
}
return adminAPIKeyRow{}, fmt.Errorf("read api key metadata: %w", err)
}
return row, nil
}
func adminAPIKeyView(row adminAPIKeyRow, now time.Time) APIKeyView {
status := "active"
if row.RevokedAt != nil {
status = "revoked"
} else if row.ExpiresAt != nil && !row.ExpiresAt.After(now) {
status = "expired"
}
return APIKeyView{
ID: row.ID, UserID: row.UserID, Username: row.Username, DisplayName: row.DisplayName,
Name: row.Name, KeyPrefix: row.KeyPrefix, Status: status, ExpiresAt: utc(row.ExpiresAt),
LastUsedAt: utc(row.LastUsedAt), RevokedAt: utc(row.RevokedAt),
CreatedAt: row.CreatedAt.UTC(), UpdatedAt: row.UpdatedAt.UTC(),
}
}
func utc(value *time.Time) *time.Time {
if value == nil {
return nil
}
result := value.UTC()
return &result
}
+41 -3
View File
@@ -4,7 +4,6 @@ import (
"errors"
"net/http"
"strconv"
"strings"
"git.ilapage.cn/OPC/chorus/admin/common/middleware"
"github.com/gin-gonic/gin"
@@ -129,6 +128,40 @@ func register(v1 *gin.RouterGroup, authentication, authorization gin.HandlerFunc
r.GET("/generations", withService(factory, func(c *gin.Context, service *Service, actor uint64, requestID string) (any, error) {
return service.Generations(c.Request.Context())
}))
r.GET("/api-keys", withService(factory, func(c *gin.Context, service *Service, actor uint64, requestID string) (any, error) {
adminNoStore(c)
filter, err := apiKeyFilter(c)
if err != nil {
return nil, err
}
return service.APIKeys(c.Request.Context(), filter)
}))
r.GET("/api-keys/:id", withID(factory, func(c *gin.Context, service *Service, actor uint64, requestID string, id uint64) (any, error) {
adminNoStore(c)
return service.APIKey(c.Request.Context(), id)
}))
r.POST("/api-keys/:id/revoke", withID(factory, func(c *gin.Context, service *Service, actor uint64, requestID string, id uint64) (any, error) {
adminNoStore(c)
return service.RevokeAPIKey(c.Request.Context(), actor, requestID, id)
}))
}
func apiKeyFilter(c *gin.Context) (APIKeyFilter, error) {
filter := APIKeyFilter{Keyword: c.Query("keyword"), Status: c.Query("status"), Page: 1, PageSize: 20}
var err error
if value := c.Query("page"); value != "" {
filter.Page, err = strconv.Atoi(value)
if err != nil {
return APIKeyFilter{}, FieldError{Field: "page", Message: "must be a positive integer"}
}
}
if value := c.Query("page_size"); value != "" {
filter.PageSize, err = strconv.Atoi(value)
if err != nil {
return APIKeyFilter{}, FieldError{Field: "page_size", Message: "must be a positive integer up to 100"}
}
}
return filter, nil
}
type endpoint func(*gin.Context, *Service, uint64, string) (any, error)
@@ -229,13 +262,18 @@ func requestScope(c *gin.Context, factory serviceFactory) (*Service, uint64, str
respond(c, nil, err)
return nil, 0, "", false
}
requestID := strings.TrimSpace(c.GetHeader(pkg.TrafficKey))
if requestID == "" {
requestID := c.GetHeader(pkg.TrafficKey)
if _, err := uuid.Parse(requestID); err != nil {
requestID = uuid.NewString()
}
return service, actor, requestID, true
}
func adminNoStore(c *gin.Context) {
c.Header("Cache-Control", "no-store")
c.Header("Pragma", "no-cache")
}
func actorID(c *gin.Context) (uint64, error) {
claims := jwt.ExtractClaims(c)
value, ok := claims[jwt.IdentityKey]
+3
View File
@@ -36,6 +36,9 @@ func TestChorusRoutesRequireAuthenticationAuthorizationAndHaveNoDeleteEndpoint(t
}{
{name: "unauthenticated", method: http.MethodGet, path: "/api/v1/chorus/providers", want: http.StatusUnauthorized},
{name: "not authorized", method: http.MethodGet, path: "/api/v1/chorus/providers", headers: map[string]string{"Authorization": "Bearer test"}, want: http.StatusForbidden},
{name: "API keys unauthenticated", method: http.MethodGet, path: "/api/v1/chorus/api-keys", want: http.StatusUnauthorized},
{name: "API keys not authorized", method: http.MethodGet, path: "/api/v1/chorus/api-keys", headers: map[string]string{"Authorization": "Bearer test"}, want: http.StatusForbidden},
{name: "API key revoke not authorized", method: http.MethodPost, path: "/api/v1/chorus/api-keys/1/revoke", headers: map[string]string{"Authorization": "Bearer test"}, want: http.StatusForbidden},
{name: "delete is not exposed", method: http.MethodDelete, path: "/api/v1/chorus/providers/1", headers: map[string]string{"Authorization": "Bearer test", "X-Chorus-Role": "operator"}, want: http.StatusNotFound},
} {
t.Run(test.name, func(t *testing.T) {
@@ -18,6 +18,7 @@ import (
"github.com/gin-gonic/gin"
jwt "github.com/go-admin-team/go-admin-core/sdk/pkg/jwtauth"
mysqldriver "github.com/go-sql-driver/mysql"
"github.com/google/uuid"
"gorm.io/driver/mysql"
"gorm.io/gorm"
"gorm.io/gorm/clause"
@@ -311,6 +312,92 @@ func TestChorusAPIMySQLRejectsUnauthorizedAndRedactsCredentials(t *testing.T) {
}
}
func TestAdminAPIKeyGovernanceMySQL(t *testing.T) {
db := openAdminTestDB(t)
ctx := context.Background()
suffix := fmt.Sprintf("key-%d", time.Now().UnixNano())
username := "admin-key-" + suffix
user := model.User{Username: username, Email: username + "@example.invalid", PasswordHash: "synthetic", DisplayName: "API Key User", Status: "active"}
if err := db.Create(&user).Error; err != nil {
t.Fatal(err)
}
publicID := fmt.Sprintf("%024d", time.Now().UnixNano())
key := model.APIKey{
UserID: user.ID, Name: "Automation " + suffix, PublicID: publicID,
KeyPrefix: "chorus_" + strings.Repeat("p", 25), SecretHash: bytes.Repeat([]byte{0x5a}, 32),
}
if err := db.Create(&key).Error; err != nil {
db.Delete(&user)
t.Fatal(err)
}
t.Cleanup(func() {
db.Where("api_key_id = ?", key.ID).Delete(&model.APIAuditEvent{})
db.Where("target_type = 'api_key' AND target_id = ?", fmt.Sprint(key.ID)).Delete(&auditRow{})
db.Delete(&key)
db.Delete(&user)
})
service := newAdminTestService(t, db, Config{})
page, err := service.APIKeys(ctx, APIKeyFilter{Keyword: username, Status: "active", Page: 1, PageSize: 20})
if err != nil || page.Total != 1 || len(page.Items) != 1 || page.Items[0].ID != key.ID {
t.Fatalf("list API keys = %#v, %v", page, err)
}
encoded, _ := json.Marshal(page)
if bytes.Contains(encoded, key.SecretHash) || strings.Contains(string(encoded), publicID) || strings.Contains(string(encoded), "secret_hash") {
t.Fatalf("admin API key metadata leaked credential material: %s", encoded)
}
gin.SetMode(gin.TestMode)
engine := gin.New()
authentication := func(c *gin.Context) {
c.Set(jwt.JwtPayloadKey, jwt.MapClaims{jwt.IdentityKey: float64(7)})
c.Next()
}
RegisterWithService(engine.Group("/api/v1"), authentication, func(c *gin.Context) { c.Next() }, service)
request := func(method, path, requestID string) *httptest.ResponseRecorder {
recorder := httptest.NewRecorder()
req := httptest.NewRequest(method, path, nil)
req.Header.Set("X-Request-Id", requestID)
engine.ServeHTTP(recorder, req)
return recorder
}
listResponse := request(http.MethodGet, "/api/v1/chorus/api-keys?keyword="+username+"&status=active", "admin-list-"+suffix)
if listResponse.Code != http.StatusOK || listResponse.Header().Get("Cache-Control") != "no-store" || strings.Contains(listResponse.Body.String(), publicID) || strings.Contains(listResponse.Body.String(), "secret_hash") {
t.Fatalf("admin API key list = %d %s", listResponse.Code, listResponse.Body.String())
}
firstResponse := request(http.MethodPost, fmt.Sprintf("/api/v1/chorus/api-keys/%d/revoke", key.ID), "admin-revoke-1-"+suffix)
secondResponse := request(http.MethodPost, fmt.Sprintf("/api/v1/chorus/api-keys/%d/revoke", key.ID), "admin-revoke-2-"+suffix)
var firstEnvelope, secondEnvelope struct {
Data APIKeyView `json:"data"`
}
if firstResponse.Code != http.StatusOK || secondResponse.Code != http.StatusOK || json.Unmarshal(firstResponse.Body.Bytes(), &firstEnvelope) != nil || json.Unmarshal(secondResponse.Body.Bytes(), &secondEnvelope) != nil || firstEnvelope.Data.RevokedAt == nil || secondEnvelope.Data.RevokedAt == nil || !secondEnvelope.Data.RevokedAt.Equal(*firstEnvelope.Data.RevokedAt) {
t.Fatalf("idempotent admin revoke = %d/%d %s / %s", firstResponse.Code, secondResponse.Code, firstResponse.Body.String(), secondResponse.Body.String())
}
detailResponse := request(http.MethodGet, fmt.Sprintf("/api/v1/chorus/api-keys/%d", key.ID), "admin-detail-"+suffix)
if detailResponse.Code != http.StatusOK || strings.Contains(detailResponse.Body.String(), publicID) || strings.Contains(detailResponse.Body.String(), "secret_hash") {
t.Fatalf("admin API key detail = %d %s", detailResponse.Code, detailResponse.Body.String())
}
detail, err := service.APIKey(ctx, key.ID)
if err != nil || detail.ID != key.ID || len(detail.RecentEvents) != 2 {
t.Fatalf("API key detail = %#v, %v", detail, err)
}
for _, event := range detail.RecentEvents {
if _, requestIDErr := uuid.Parse(event.RequestID); requestIDErr != nil || event.Action != "api_key.admin_revoked" || strings.Contains(string(event.Summary), publicID) || strings.Contains(string(event.Summary), string(key.SecretHash)) {
t.Fatalf("unsafe API audit event: %#v", event)
}
}
var adminAudits, apiAudits int64
if err := db.Model(&auditRow{}).Where("target_type = 'api_key' AND target_id = ? AND action = 'api_key.revoke'", fmt.Sprint(key.ID)).Count(&adminAudits).Error; err != nil {
t.Fatal(err)
}
if err := db.Model(&model.APIAuditEvent{}).Where("api_key_id = ? AND action = 'api_key.admin_revoked'", key.ID).Count(&apiAudits).Error; err != nil {
t.Fatal(err)
}
if adminAudits != 2 || apiAudits != 2 {
t.Fatalf("dual audit counts admin=%d api=%d, want 2 each", adminAudits, apiAudits)
}
}
type blockingProbe struct {
calls atomic.Int32
started chan struct{}
+46
View File
@@ -186,3 +186,49 @@ type GenerationView struct {
CreatedAt time.Time `json:"created_at"`
CompletedAt *time.Time `json:"completed_at,omitempty"`
}
type APIKeyFilter struct {
Keyword string
Status string
Page int
PageSize int
}
type APIKeyView struct {
ID uint64 `json:"id"`
UserID uint64 `json:"user_id"`
Username string `json:"username"`
DisplayName string `json:"display_name"`
Name string `json:"name"`
KeyPrefix string `json:"key_prefix"`
Status string `json:"status"`
ExpiresAt *time.Time `json:"expires_at"`
LastUsedAt *time.Time `json:"last_used_at"`
RevokedAt *time.Time `json:"revoked_at"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
type APIKeyPage struct {
Items []APIKeyView `json:"items"`
Total int64 `json:"total"`
Page int `json:"page"`
PageSize int `json:"page_size"`
}
type APIAuditEventView struct {
ID uint64 `json:"id"`
GenerationID *uint64 `json:"generation_id,omitempty"`
Action string `json:"action"`
Result string `json:"result"`
RequestID string `json:"request_id"`
StatusCode *uint16 `json:"status_code,omitempty"`
ErrorCode *string `json:"error_code,omitempty"`
Summary json.RawMessage `json:"summary"`
CreatedAt time.Time `json:"created_at"`
}
type APIKeyDetail struct {
APIKeyView
RecentEvents []APIAuditEventView `json:"recent_events"`
}