feat: 实现 API 安全审计与管理端 API (#45)
This commit is contained in:
@@ -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
|
||||
}
|
||||
@@ -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]
|
||||
|
||||
@@ -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{}
|
||||
|
||||
@@ -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"`
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user