diff --git a/admin/app/chorus/api_keys.go b/admin/app/chorus/api_keys.go new file mode 100644 index 0000000..1b939af --- /dev/null +++ b/admin/app/chorus/api_keys.go @@ -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 +} diff --git a/admin/app/chorus/router.go b/admin/app/chorus/router.go index 2926665..0fbdb3d 100644 --- a/admin/app/chorus/router.go +++ b/admin/app/chorus/router.go @@ -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] diff --git a/admin/app/chorus/router_test.go b/admin/app/chorus/router_test.go index 282b9c3..3fc95ee 100644 --- a/admin/app/chorus/router_test.go +++ b/admin/app/chorus/router_test.go @@ -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) { diff --git a/admin/app/chorus/service_mysql_integration_test.go b/admin/app/chorus/service_mysql_integration_test.go index 95a5d92..026e5a3 100644 --- a/admin/app/chorus/service_mysql_integration_test.go +++ b/admin/app/chorus/service_mysql_integration_test.go @@ -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{} diff --git a/admin/app/chorus/types.go b/admin/app/chorus/types.go index 8d3e2ec..1e76a20 100644 --- a/admin/app/chorus/types.go +++ b/admin/app/chorus/types.go @@ -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"` +} diff --git a/docs/02-architecture-and-code-map.md b/docs/02-architecture-and-code-map.md index b178529..4966de9 100644 --- a/docs/02-architecture-and-code-map.md +++ b/docs/02-architecture-and-code-map.md @@ -2,8 +2,8 @@ generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件) wiki_page: Architecture-and-Code-Map wiki_url: https://git.ilapage.cn/OPC/chorus/wiki/Architecture-and-Code-Map.- -wiki_revision: e0125624a64ee0ed176215ca68009ec2efb5c25b -synchronized_at: 2026-08-25T01:11:57Z +wiki_revision: 47afee496f6d4105e51cddb47e9ba9dae7380293 +synchronized_at: 2026-08-25T01:52:58Z # 架构与代码地图 @@ -436,3 +436,12 @@ Provider 限流检查发生在真实上游调用前。放行后才执行 `BeginP - worker 在调用 Provider 前按 Provider ID 限流。本地 Provider 限流不创建真实 Provider attempt、不更新熔断状态,也不进入 retryable/failover 判定;同一任务的其他可用 Provider 仍可继续尝试。 - 所有候选 Provider 都只因本地限流暂不可用时,`queue.MySQLRepository.Defer` 以 `running + lease_token` CAS 把任务恢复为 `pending`,将 `available_at` 设置为最早可重试时间,并记录 `local_rate_limit` 队列事件。该事件不增加 `provider_attempt_count`。 - `ClaimNext` 仅认领 `available_at <= NOW(6)` 的 pending 任务,同时保留过期 running 租约的恢复逻辑。原有上游 `429 / 5xx / 超时 / 连接错误` 与 `400 / 401 / 内容策略拒绝` 分类没有改变。 + +## MVP-2 API 安全审计与管理端 API(#45) + +- `internal/core/apiaudit` 是 Portal 与 Admin 共用的追加写入口,只依赖 GORM、标准库和 core model。审计 `Summary` 是强类型结构,只允许 `source`、`kind`、`created`、`already_revoked`、`operator_id`;调用方没有字段可写入 Prompt、文件、完整 Key、Authorization、Cookie、响应正文或原始 IP。 +- Portal 的 API Key 创建、改名和用户撤销在原业务事务内写 `api_audit_events`;审计失败会回滚对应生命周期变更。幂等撤销记录 `already_revoked`,但不返回或记录凭据材料。 +- OpenAPI 认证成功后,提交审计中间件包围限流和提交 handler:成功、幂等重放、参数/幂等拒绝、限流拒绝和内部失败都记录同一 `openapi.generation.submit` 动作,并仅保存 kind、是否新建、响应状态与脱敏错误码。普通查询不逐条审计。 +- 缺失、格式非法、未知、过期、已撤销或所属用户停用的 Key 都返回同一 401。认证失败不会写数据库审计行,避免攻击者制造无界写入;受控安全日志/指标由后续运维采集处理。成功认证的 `last_used_at` 最多每分钟实际变更一次。 +- 管理端继续复用 go-admin JWT/Casbin:`GET /api/v1/chorus/api-keys` 支持 `keyword/status/page/page_size`,`GET /api/v1/chorus/api-keys/:id` 返回元数据和最近 20 条脱敏安全事件,`POST /api/v1/chorus/api-keys/:id/revoke` 幂等撤销。列表和详情不返回 `public_id`、`secret_hash` 或完整 Key,并设置 `no-store`。 +- 管理撤销用 `FOR UPDATE` 锁定 Key,在一个事务中更新 `revoked_at`,同时追加 `admin_audit_events` 的 `api_key.revoke` 和 `api_audit_events` 的 `api_key.admin_revoked`。Chorus 管理 API 只把合法 UUID 用作审计 request_id,其他客户端值替换为服务端 UUID,避免误把凭据写入关联字段。 diff --git a/docs/03-business-rules-and-glossary.md b/docs/03-business-rules-and-glossary.md index 8e58522..cf46888 100644 --- a/docs/03-business-rules-and-glossary.md +++ b/docs/03-business-rules-and-glossary.md @@ -2,8 +2,8 @@ generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件) wiki_page: Business-Rules-and-Glossary wiki_url: https://git.ilapage.cn/OPC/chorus/wiki/Business-Rules-and-Glossary.- -wiki_revision: 0e7981e5a175ace20cf406885050d701106963d3 -synchronized_at: 2026-08-25T01:12:03Z +wiki_revision: ea843c01b7549150a9aadde703ee07419cfe401d +synchronized_at: 2026-08-25T01:53:03Z # 业务规则与术语 @@ -180,3 +180,12 @@ synchronized_at: 2026-08-25T01:12:03Z - OpenAPI 生成提交同时受 API Key 和终端用户提交限流;任一维度超限时都返回 HTTP 429 和整数秒 `Retry-After`,且不得因为组合检查失败而消耗未超限维度的次数。 - Provider 本地限流发生在真实上游调用前,不算 Provider 调用失败,不写真实 Provider attempt,不触发熔断,不改变上游 retryable 规则。若仍有其他候选 Provider,继续选择;若全部候选都只受本地限流,则任务延后到最早窗口恢复时间。 - 限流状态只存在单个 portal 进程内,进程重启会清空。当前仍是单 portal 实例;多实例共享限流需要独立设计,不得把本实现当成分布式配额。 + +## API 安全审计已实现规则(#45) + +1. `api_audit_events` 只追加,不因 Key 或用户停用而删除;事件结果只允许 `succeeded|failed|denied`。 +2. Key 创建、改名、用户撤销、管理员撤销和 OpenAPI 生成提交必须审计;普通成功查询不逐条审计,`last_used_at` 使用一分钟节流更新。 +3. 未认证请求不写数据库审计。未知或不可用 Key 与其他认证失败返回相同 401,不允许攻击者借此制造无限审计行或确认 Key 是否存在。 +4. summary 只允许来源、生成种类、是否新建、是否已经撤销和管理员编号;禁止 Prompt、文件、完整 Key、public_id、secret_hash、Authorization、Cookie、响应正文和原始 IP。 +5. 管理员只能检索名称、前缀、状态、归属用户和时间元数据,不能创建用户 Key 或查看完整 Key;撤销必须同时写管理员审计与 API 安全审计。 +6. 管理端 Key 列表、详情和撤销响应禁止缓存。幂等撤销不得改变第一次撤销时间。 diff --git a/docs/04-local-development-and-verification.md b/docs/04-local-development-and-verification.md index f507e6d..185b625 100644 --- a/docs/04-local-development-and-verification.md +++ b/docs/04-local-development-and-verification.md @@ -2,8 +2,8 @@ generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件) wiki_page: Local-Development-and-Verification wiki_url: https://git.ilapage.cn/OPC/chorus/wiki/Local-Development-and-Verification.- -wiki_revision: ce59c50808fb1f9c8b80a49385962d4833cbe4da -synchronized_at: 2026-08-25T01:12:08Z +wiki_revision: 01d2c4aaf24e17d8b9f6cc90d59c1f7244941110 +synchronized_at: 2026-08-25T01:53:09Z # 本地开发与验证 @@ -507,3 +507,26 @@ python dev_scripts/harness.py check --strict ``` MySQL 集成测试需在受控测试库或确认无在途任务的本地开发库中设置 `CHORUS_TEST_DSN`,运行 `go test -count=1 -run TestMySQL ./internal/core/queue ./portal/worker`。测试使用 mock Provider,不消耗真实上游额度。限流为进程内固定窗口,重启 portal 会清空计数。 + +## #45 API 安全审计与管理 API 验证 + +常规验证: + +```powershell +go test ./... +go vet ./... +go test -race -count=1 ./internal/core/apiaudit ./portal/service ./portal/handler +go -C admin test ./... +go -C admin test -race -count=1 ./app/chorus +``` + +MySQL 8 集成验证需要受控测试 DSN,且运行 Portal 集成前暂停常驻 worker: + +```powershell +$env:CHORUS_TEST_DSN = $env:CHORUS_DSN +$env:CHORUS_MIGRATION_TEST_DATABASE = $env:CHORUS_MYSQL_DATABASE +go test -count=1 -run '^TestPortalAuthenticationSubmissionAndAuthorization$' ./portal/handler +go -C admin test -count=1 -run '^(TestAdminAPIKeyGovernanceMySQL|TestChorusAPIMySQLRejectsUnauthorizedAndRedactsCredentials)$' ./app/chorus +``` + +验证覆盖:生命周期事务审计、未知 Key 不写行、成功/拒绝/429 提交审计、summary 脱敏、`last_used_at` 节流、管理检索/详情、并发安全的幂等撤销、双审计和 JWT/Casbin 负向路径。管理 API 的真实 go-admin 鉴权失败沿用框架约定,可能返回 HTTP 200 且 JSON `code=401`;判断时必须同时检查响应 JSON,不能只看 HTTP 状态。 diff --git a/docs/06-troubleshooting.md b/docs/06-troubleshooting.md index fda7308..f1bb979 100644 --- a/docs/06-troubleshooting.md +++ b/docs/06-troubleshooting.md @@ -2,8 +2,8 @@ generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件) wiki_page: Troubleshooting wiki_url: https://git.ilapage.cn/OPC/chorus/wiki/Troubleshooting -wiki_revision: 604293744eb29bd1ec4ac03d939e0305336e1d48 -synchronized_at: 2026-08-22T03:08:35Z +wiki_revision: 343eedb7cb319e2de24725965817444454ef9b8d +synchronized_at: 2026-08-25T01:53:19Z # 故障排查 @@ -120,3 +120,13 @@ LIMIT 10; 2. 当前实现只读取 `sys_casbin_rule` 并关闭 Casbin adapter AutoMigrate;若旧进程曾生成空的 `casbin_rule`,它不是权限事实来源,不要向其中补数据。 3. 确认 bootstrap 账号绑定启用的 `chorus_operator`,重新登录取得新 JWT,再检查 `/api/v1/menurole`。 4. `settings.yml` 出现 `Unknown database` 时先创建空库并执行版本化 migration;出现 `Access denied` 时修正本地凭据,不运行 AutoMigrate 绕过。 + +## API Key 审计排查(#45) + +1. 先使用服务端 `request_id`、API Key 数据库编号或 generation 编号查询 `api_audit_events`,不要用完整 Key、Prompt、文件名或文件内容搜索日志和数据库。 +2. OpenAPI 提交没有事件时,先判断请求是否通过 API Key 认证。缺失、未知、过期、已撤销或停用用户的 Key 按设计不写数据库审计;这不是审计丢失。 +3. 已认证提交或限流拒绝没有事件时,检查 `api_audit_events` 写权限、外键目标、JSON CHECK 和应用日志中的 `api audit write failed request_id=...`。不要临时关闭审计或把请求正文写入日志。 +4. 管理员撤销应同时出现 `admin_audit_events.action=api_key.revoke` 和 `api_audit_events.action=api_key.admin_revoked`。只出现一类表示事务没有按设计提交,应停止重复操作并检查数据库错误。 +5. `last_used_at` 在一分钟内不变化是节流行为,不代表认证未发生。判断调用结果使用请求状态和对应提交审计。 +6. go-admin 鉴权失败可能使用 HTTP 200 包装 JSON `code=401|403`;排查权限时同时检查 JSON 业务码、JWT 和 Casbin,不把 HTTP 200 误判为已授权。 +7. 任何 summary 出现 Prompt、完整 Key、`public_id`、`secret_hash`、Authorization、Cookie、响应正文或原始 IP 都属于安全缺陷,应立即停止相关入口并建立缺陷工单,不直接清理审计数据。 diff --git a/internal/core/apiaudit/repository.go b/internal/core/apiaudit/repository.go new file mode 100644 index 0000000..f729a6c --- /dev/null +++ b/internal/core/apiaudit/repository.go @@ -0,0 +1,117 @@ +package apiaudit + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "strings" + "time" + + "git.ilapage.cn/OPC/chorus/internal/core/model" + "gorm.io/gorm" +) + +const ( + ActionKeyCreated = "api_key.created" + ActionKeyRenamed = "api_key.renamed" + ActionKeyRevoked = "api_key.revoked" + ActionKeyAdminRevoked = "api_key.admin_revoked" + ActionGenerationSubmitted = "openapi.generation.submit" + + ResultSucceeded = "succeeded" + ResultFailed = "failed" + ResultDenied = "denied" +) + +var ErrInvalidEvent = errors.New("api audit event is invalid") + +// Summary intentionally exposes only reviewed, non-sensitive audit fields. +type Summary struct { + Source string `json:"source"` + Kind string `json:"kind,omitempty"` + Created *bool `json:"created,omitempty"` + AlreadyRevoked *bool `json:"already_revoked,omitempty"` + OperatorID uint64 `json:"operator_id,omitempty"` +} + +type Event struct { + UserID *uint64 + APIKeyID *uint64 + GenerationID *uint64 + Action string + Result string + RequestID string + StatusCode int + ErrorCode string + Summary Summary + CreatedAt time.Time +} + +type Repository struct{ db *gorm.DB } + +func NewRepository(db *gorm.DB) (*Repository, error) { + if db == nil { + return nil, errors.New("api audit database is required") + } + return &Repository{db: db}, nil +} + +func (r *Repository) Append(ctx context.Context, event Event) error { + if err := validate(event); err != nil { + return err + } + summary, err := json.Marshal(event.Summary) + if err != nil { + return fmt.Errorf("encode api audit summary: %w", err) + } + requestID := strings.TrimSpace(event.RequestID) + if len(requestID) > 128 { + requestID = requestID[:128] + } + createdAt := event.CreatedAt.UTC() + if createdAt.IsZero() { + createdAt = time.Now().UTC() + } + row := model.APIAuditEvent{ + UserID: event.UserID, APIKeyID: event.APIKeyID, GenerationID: event.GenerationID, + Action: event.Action, Result: event.Result, RequestID: requestID, + Summary: summary, CreatedAt: createdAt, + } + if event.StatusCode != 0 { + status := uint16(event.StatusCode) + row.StatusCode = &status + } + if event.ErrorCode != "" { + code := event.ErrorCode + row.ErrorCode = &code + } + if err := r.db.WithContext(ctx).Create(&row).Error; err != nil { + return fmt.Errorf("write api audit event: %w", err) + } + return nil +} + +func validate(event Event) error { + actions := map[string]bool{ + ActionKeyCreated: true, ActionKeyRenamed: true, ActionKeyRevoked: true, + ActionKeyAdminRevoked: true, ActionGenerationSubmitted: true, + } + results := map[string]bool{ResultSucceeded: true, ResultFailed: true, ResultDenied: true} + requestID := strings.TrimSpace(event.RequestID) + if !actions[event.Action] || !results[event.Result] || requestID == "" || strings.TrimSpace(event.Summary.Source) == "" { + return ErrInvalidEvent + } + if event.StatusCode != 0 && (event.StatusCode < 100 || event.StatusCode > 599) { + return ErrInvalidEvent + } + if len(event.ErrorCode) > 64 || strings.ContainsAny(event.ErrorCode, "\r\n\t") { + return ErrInvalidEvent + } + for _, id := range []*uint64{event.UserID, event.APIKeyID, event.GenerationID} { + if id != nil && *id == 0 { + return ErrInvalidEvent + } + } + return nil +} diff --git a/internal/core/apiaudit/repository_test.go b/internal/core/apiaudit/repository_test.go new file mode 100644 index 0000000..33e71ff --- /dev/null +++ b/internal/core/apiaudit/repository_test.go @@ -0,0 +1,44 @@ +package apiaudit + +import ( + "encoding/json" + "reflect" + "testing" +) + +func TestSummaryHasOnlyReviewedFields(t *testing.T) { + typeOfSummary := reflect.TypeOf(Summary{}) + got := make([]string, 0, typeOfSummary.NumField()) + for index := 0; index < typeOfSummary.NumField(); index++ { + got = append(got, typeOfSummary.Field(index).Tag.Get("json")) + } + want := []string{"source", "kind,omitempty", "created,omitempty", "already_revoked,omitempty", "operator_id,omitempty"} + if !reflect.DeepEqual(got, want) { + t.Fatalf("audit summary fields = %#v, want %#v", got, want) + } + encoded, err := json.Marshal(Summary{Source: "openapi", Kind: "text"}) + if err != nil || string(encoded) != `{"source":"openapi","kind":"text"}` { + t.Fatalf("summary = %s, %v", encoded, err) + } +} + +func TestValidateRejectsUnknownOrUnsafeValues(t *testing.T) { + valid := Event{Action: ActionGenerationSubmitted, Result: ResultDenied, RequestID: "request", StatusCode: 429, ErrorCode: "rate_limited", Summary: Summary{Source: "openapi"}} + if err := validate(valid); err != nil { + t.Fatalf("valid event: %v", err) + } + for _, mutate := range []func(*Event){ + func(event *Event) { event.Action = "unreviewed" }, + func(event *Event) { event.Result = "unknown" }, + func(event *Event) { event.RequestID = "" }, + func(event *Event) { event.StatusCode = 700 }, + func(event *Event) { event.ErrorCode = "unsafe\nvalue" }, + func(event *Event) { event.Summary.Source = "" }, + } { + event := valid + mutate(&event) + if err := validate(event); err != ErrInvalidEvent { + t.Fatalf("invalid event error = %v", err) + } + } +} diff --git a/portal/handler/api_keys.go b/portal/handler/api_keys.go index 29b5aeb..435bde4 100644 --- a/portal/handler/api_keys.go +++ b/portal/handler/api_keys.go @@ -50,7 +50,7 @@ func (h *Handler) createAPIKey(c *gin.Context) { if !decodeAPIKeyJSON(c, &input) { return } - created, err := h.service.CreateAPIKey(c.Request.Context(), currentSession(c).UserID, input.Name, input.ExpiresInDays, time.Now().UTC()) + created, err := h.service.CreateAPIKey(c.Request.Context(), currentSession(c).UserID, input.Name, input.ExpiresInDays, time.Now().UTC(), requestID(c)) if err != nil { h.apiKeyServiceError(c, err) return @@ -70,7 +70,7 @@ func (h *Handler) renameAPIKey(c *gin.Context) { if !decodeAPIKeyJSON(c, &input) { return } - key, err := h.service.RenameAPIKey(c.Request.Context(), currentSession(c).UserID, id, input.Name, time.Now().UTC()) + key, err := h.service.RenameAPIKey(c.Request.Context(), currentSession(c).UserID, id, input.Name, time.Now().UTC(), requestID(c)) if err != nil { h.apiKeyServiceError(c, err) return @@ -84,7 +84,7 @@ func (h *Handler) revokeAPIKey(c *gin.Context) { if !ok { return } - key, err := h.service.RevokeAPIKey(c.Request.Context(), currentSession(c).UserID, id, time.Now().UTC()) + key, err := h.service.RevokeAPIKey(c.Request.Context(), currentSession(c).UserID, id, time.Now().UTC(), requestID(c)) if err != nil { h.apiKeyServiceError(c, err) return diff --git a/portal/handler/audit.go b/portal/handler/audit.go new file mode 100644 index 0000000..97c69d5 --- /dev/null +++ b/portal/handler/audit.go @@ -0,0 +1,82 @@ +package handler + +import ( + "context" + "log" + "net/http" + "time" + + "git.ilapage.cn/OPC/chorus/internal/core/apiaudit" + "github.com/gin-gonic/gin" +) + +const ( + requestIDContextKey = "request_id" + responseErrorCodeContextKey = "response_error_code" + auditGenerationIDContextKey = "audit_generation_id" + auditGenerationCreatedContextKey = "audit_generation_created" +) + +func (h *Handler) requestID(c *gin.Context) { + id := newOpenAPIRequestID() + c.Set(requestIDContextKey, id) + c.Header("X-Request-ID", id) + c.Next() +} + +func requestID(c *gin.Context) string { + value, _ := c.Get(requestIDContextKey) + id, _ := value.(string) + return id +} + +func (h *Handler) auditOpenAPISubmission(c *gin.Context) { + if c.Request.Method != http.MethodPost { + c.Next() + return + } + kind := "" + switch c.FullPath() { + case "/openapi/v1/generations/text": + kind = "text" + case "/openapi/v1/generations/image": + kind = "image" + default: + c.Next() + return + } + c.Next() + + principal := currentAPIPrincipal(c) + status := c.Writer.Status() + result := apiaudit.ResultSucceeded + if status >= 500 { + result = apiaudit.ResultFailed + } else if status >= 400 { + result = apiaudit.ResultDenied + } + var generationID *uint64 + if value, exists := c.Get(auditGenerationIDContextKey); exists { + if id, ok := value.(uint64); ok && id != 0 { + generationID = &id + } + } + var created *bool + if value, exists := c.Get(auditGenerationCreatedContextKey); exists { + if flag, ok := value.(bool); ok { + created = &flag + } + } + errorCode, _ := c.Get(responseErrorCodeContextKey) + code, _ := errorCode.(string) + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + defer cancel() + if err := h.service.RecordAPIAudit(ctx, apiaudit.Event{ + UserID: &principal.UserID, APIKeyID: &principal.APIKeyID, GenerationID: generationID, + Action: apiaudit.ActionGenerationSubmitted, Result: result, RequestID: requestID(c), + StatusCode: status, ErrorCode: code, Summary: apiaudit.Summary{Source: "openapi", Kind: kind, Created: created}, + CreatedAt: h.now(), + }); err != nil { + log.Printf("api audit write failed request_id=%s: %v", requestID(c), err) + } +} diff --git a/portal/handler/mysql_integration_test.go b/portal/handler/mysql_integration_test.go index fae21ae..32038db 100644 --- a/portal/handler/mysql_integration_test.go +++ b/portal/handler/mysql_integration_test.go @@ -128,6 +128,7 @@ func TestPortalAuthenticationSubmissionAndAuthorization(t *testing.T) { } routeIDs, providerID := createPortalRoutes(t, db, suffix, templates) defer func() { + db.Where("user_id IN ?", []uint64{users[0].ID, users[1].ID}).Delete(&model.APIAuditEvent{}) db.Where("user_id IN ?", []uint64{users[0].ID, users[1].ID}).Delete(&model.APIKey{}) db.Where("user_id IN ?", []uint64{users[0].ID, users[1].ID}).Delete(&model.Generation{}) db.Exec("DELETE FROM active_routes WHERE route_pool_id IN ?", routeIDs) @@ -256,14 +257,26 @@ func TestPortalAuthenticationSubmissionAndAuthorization(t *testing.T) { if err := db.First(&storedKey, createdKey.ID).Error; err != nil { t.Fatal(err) } + var createAudit model.APIAuditEvent + if err := db.Where("api_key_id = ? AND action = ?", createdKey.ID, "api_key.created").Take(&createAudit).Error; err != nil || strings.Contains(string(createAudit.Summary), createdKey.Token) || strings.Contains(string(createAudit.Summary), storedKey.PublicID) { + t.Fatalf("created key audit is missing or sensitive: %#v %v", createAudit, err) + } parsedKey, err := platformapikey.Parse(createdKey.Token) if err != nil || parsedKey.PublicID != storedKey.PublicID || !bytes.Equal(parsedKey.SecretHash, storedKey.SecretHash) { t.Fatalf("stored API key does not match one-time token: %v", err) } openAPIKeyA := storeTestAPIKey(t, db, users[0].ID, "OpenAPI A", nil, nil) openAPIKeyB := storeTestAPIKey(t, db, users[1].ID, "OpenAPI B", nil, nil) + var openAPIKeyARow model.APIKey + if err := db.Where("public_id = ?", openAPIKeyA.PublicID).Take(&openAPIKeyARow).Error; err != nil { + t.Fatal(err) + } expiredAt := time.Now().UTC().Add(-time.Minute) expiredKey := storeTestAPIKey(t, db, users[0].ID, "Expired OpenAPI", &expiredAt, nil) + var auditCountBeforeInvalid int64 + if err := db.Model(&model.APIAuditEvent{}).Where("user_id IN ?", []uint64{users[0].ID, users[1].ID}).Count(&auditCountBeforeInvalid).Error; err != nil { + t.Fatal(err) + } missingAuth := openAPIRequest(router, "", http.MethodGet, "/openapi/v1/openapi.json", nil, "", "") expiredAuth := openAPIRequest(router, expiredKey.Token, http.MethodGet, "/openapi/v1/openapi.json", nil, "", "") invalidAuth := openAPIRequest(router, "administrator-jwt", http.MethodGet, "/openapi/v1/openapi.json", nil, "", "") @@ -273,6 +286,32 @@ func TestPortalAuthenticationSubmissionAndAuthorization(t *testing.T) { t.Fatalf("%s OpenAPI auth=%d headers=%v body=%s", name, response.Code, response.Header(), response.Body.String()) } } + var auditCountAfterInvalid int64 + if err := db.Model(&model.APIAuditEvent{}).Where("user_id IN ?", []uint64{users[0].ID, users[1].ID}).Count(&auditCountAfterInvalid).Error; err != nil || auditCountAfterInvalid != auditCountBeforeInvalid { + t.Fatalf("unknown or unusable API keys wrote database audit rows: before=%d after=%d err=%v", auditCountBeforeInvalid, auditCountAfterInvalid, err) + } + auditHandler := &Handler{ + service: generationService, rateLimiter: ratelimit.New(), + userRatePolicy: ratelimit.Policy{Capacity: 10, Window: time.Minute}, + apiKeyRatePolicy: ratelimit.Policy{Capacity: 1, Window: time.Minute}, + now: func() time.Time { return time.Now().UTC() }, + } + auditRouter := gin.New() + auditRouter.Use(auditHandler.requestID, func(c *gin.Context) { + c.Set(openAPIPrincipalContextKey, service.APIPrincipal{UserID: users[0].ID, APIKeyID: openAPIKeyARow.ID}) + c.Next() + }) + auditRouter.POST("/openapi/v1/generations/text", auditHandler.auditOpenAPISubmission, auditHandler.limitOpenAPIRequest, func(c *gin.Context) { c.Status(http.StatusAccepted) }) + if response := rateRequest(auditRouter, http.MethodPost, "/openapi/v1/generations/text"); response.Code != http.StatusAccepted { + t.Fatalf("audit rate first request = %d", response.Code) + } + if response := rateRequest(auditRouter, http.MethodPost, "/openapi/v1/generations/text"); response.Code != http.StatusTooManyRequests { + t.Fatalf("audit rate limited request = %d", response.Code) + } + var rateAudit model.APIAuditEvent + if err := db.Where("api_key_id = ? AND action = ? AND status_code = ?", openAPIKeyARow.ID, "openapi.generation.submit", http.StatusTooManyRequests).Order("id DESC").Take(&rateAudit).Error; err != nil || rateAudit.Result != "denied" || rateAudit.ErrorCode == nil || *rateAudit.ErrorCode != "rate_limited" || strings.Contains(string(rateAudit.Summary), openAPIKeyA.Token) { + t.Fatalf("authenticated rate-limit audit is missing or sensitive: %#v %v", rateAudit, err) + } browserCookieRequest := httptest.NewRequest(http.MethodGet, "/openapi/v1/openapi.json", nil) browserCookieRequest.Header.Set("Authorization", "Bearer "+openAPIKeyA.Token) browserCookieRequest.AddCookie(clientA.cookie) @@ -292,6 +331,28 @@ func TestPortalAuthenticationSubmissionAndAuthorization(t *testing.T) { if err := db.Where("public_id = ?", openAPIKeyA.PublicID).First(&usedKey).Error; err != nil || usedKey.LastUsedAt == nil { t.Fatalf("OpenAPI key last_used_at was not updated: %#v %v", usedKey, err) } + recentUse := time.Now().UTC().Truncate(time.Microsecond).Add(-10 * time.Second) + if err := db.Model(&model.APIKey{}).Where("id = ?", usedKey.ID).Update("last_used_at", recentUse).Error; err != nil { + t.Fatal(err) + } + if response := openAPIRequest(router, openAPIKeyA.Token, http.MethodGet, "/openapi/v1/openapi.json", nil, "", ""); response.Code != http.StatusOK { + t.Fatalf("recent API key reuse=%d", response.Code) + } + var throttledUse model.APIKey + if err := db.First(&throttledUse, usedKey.ID).Error; err != nil || throttledUse.LastUsedAt == nil || !throttledUse.LastUsedAt.Equal(recentUse) { + t.Fatalf("last_used_at was not throttled: %#v %v", throttledUse.LastUsedAt, err) + } + staleUse := time.Now().UTC().Truncate(time.Microsecond).Add(-2 * time.Minute) + if err := db.Model(&model.APIKey{}).Where("id = ?", usedKey.ID).Update("last_used_at", staleUse).Error; err != nil { + t.Fatal(err) + } + if response := openAPIRequest(router, openAPIKeyA.Token, http.MethodGet, "/openapi/v1/openapi.json", nil, "", ""); response.Code != http.StatusOK { + t.Fatalf("stale API key reuse=%d", response.Code) + } + var refreshedUse model.APIKey + if err := db.First(&refreshedUse, usedKey.ID).Error; err != nil || refreshedUse.LastUsedAt == nil || !refreshedUse.LastUsedAt.After(staleUse) { + t.Fatalf("stale last_used_at was not refreshed: %#v %v", refreshedUse.LastUsedAt, err) + } browserOnly := clientA.do(http.MethodGet, "/openapi/v1/generations", nil, "") if browserOnly.Code != http.StatusUnauthorized { t.Fatalf("OpenAPI accepted browser session=%d", browserOnly.Code) @@ -308,6 +369,10 @@ func TestPortalAuthenticationSubmissionAndAuthorization(t *testing.T) { t.Fatalf("OpenAPI text submit=%d %s", openAPIText.Code, openAPIText.Body.String()) } openAPITextID := responseID(t, openAPIText) + var submitAudit model.APIAuditEvent + if err := db.Where("api_key_id = ? AND generation_id = ? AND action = ?", openAPIKeyARow.ID, openAPITextID, "openapi.generation.submit").Take(&submitAudit).Error; err != nil || submitAudit.Result != "succeeded" || strings.Contains(string(submitAudit.Summary), "OpenAPI text prompt") || strings.Contains(string(submitAudit.Summary), openAPIKeyA.Token) { + t.Fatalf("OpenAPI submit audit is missing or sensitive: %#v %v", submitAudit, err) + } openAPITextReplay := openAPIRequest(router, openAPIKeyA.Token, http.MethodPost, "/openapi/v1/generations/text", openAPITextBody, "application/json", openAPITextKey) if openAPITextReplay.Code != http.StatusOK || responseID(t, openAPITextReplay) != openAPITextID { t.Fatalf("OpenAPI text replay=%d %s", openAPITextReplay.Code, openAPITextReplay.Body.String()) @@ -544,6 +609,10 @@ func TestPortalAuthenticationSubmissionAndAuthorization(t *testing.T) { if firstRevoke.Code != http.StatusOK || secondRevoke.Code != http.StatusOK || firstRevoke.Body.String() != secondRevoke.Body.String() || !strings.Contains(firstRevoke.Body.String(), `"status":"revoked"`) || strings.Contains(firstRevoke.Body.String(), createdKey.Token) { t.Fatalf("idempotent API key revoke=%d/%d %s / %s", firstRevoke.Code, secondRevoke.Code, firstRevoke.Body.String(), secondRevoke.Body.String()) } + var lifecycleAudits int64 + if err := db.Model(&model.APIAuditEvent{}).Where("api_key_id = ? AND action IN ?", createdKey.ID, []string{"api_key.created", "api_key.renamed", "api_key.revoked"}).Count(&lifecycleAudits).Error; err != nil || lifecycleAudits != 4 { + t.Fatalf("API key lifecycle audits = %d, want 4: %v", lifecycleAudits, err) + } if response := clientB.do(http.MethodGet, "/api/generations?limit=2&cursor="+firstHistory.NextCursor, nil, ""); response.Code != 400 || !strings.Contains(response.Body.String(), `"code":"invalid_cursor"`) { t.Fatalf("cross-user cursor=%d %s", response.Code, response.Body.String()) } diff --git a/portal/handler/openapi.go b/portal/handler/openapi.go index 3923e68..9f22e84 100644 --- a/portal/handler/openapi.go +++ b/portal/handler/openapi.go @@ -25,7 +25,6 @@ const openAPIPrincipalContextKey = "openapi_principal" var openAPIRequestIDCounter atomic.Uint64 func (h *Handler) openAPIHeaders(c *gin.Context) { - c.Header("X-Request-ID", newOpenAPIRequestID()) noStore(c) c.Next() } @@ -122,6 +121,8 @@ func (h *Handler) openAPISubmitText(c *gin.Context) { if !created { status = http.StatusOK } + c.Set(auditGenerationIDContextKey, generation.ID) + c.Set(auditGenerationCreatedContextKey, created) c.JSON(status, openAPIGenerationResponse(generation)) } @@ -188,6 +189,8 @@ func (h *Handler) openAPISubmitImages(c *gin.Context) { if !created { status = http.StatusOK } + c.Set(auditGenerationIDContextKey, generation.ID) + c.Set(auditGenerationCreatedContextKey, created) c.JSON(status, openAPIGenerationResponse(generation)) } diff --git a/portal/handler/router.go b/portal/handler/router.go index 201c961..5227470 100644 --- a/portal/handler/router.go +++ b/portal/handler/router.go @@ -64,6 +64,7 @@ func NewRouter(sessions *session.Manager, authService *auth.Service, generationS router := gin.New() router.Use(gin.RecoveryWithWriter(io.Discard)) router.Use(securityHeaders) + router.Use(handler.requestID) _ = router.SetTrustedProxies(nil) staticFS, err := web.Static() if err != nil { @@ -102,7 +103,7 @@ func NewRouter(sessions *session.Manager, authService *auth.Service, generationS generations.GET("/:id/inputs/:inputID", handler.input) generations.GET("/:id/outputs/:outputID", handler.output) generations.GET("/:id/outputs/:outputID/thumbnail", handler.thumbnail) - openAPI := router.Group("/openapi/v1", handler.openAPIHeaders, handler.requireAPIKey, handler.limitOpenAPIRequest) + openAPI := router.Group("/openapi/v1", handler.openAPIHeaders, handler.requireAPIKey, handler.auditOpenAPISubmission, handler.limitOpenAPIRequest) openAPI.GET("/openapi.json", handler.openAPISpec) openAPI.POST("/generations/text", handler.openAPISubmitText) openAPI.POST("/generations/image", handler.openAPISubmitImages) @@ -456,9 +457,11 @@ func (h *Handler) serviceError(c *gin.Context, err error) { } } func writeFileError(c *gin.Context, index int, name, code string) { + c.Set(responseErrorCodeContextKey, code) c.JSON(400, gin.H{"error": gin.H{"code": code, "message": "image is invalid", "file": gin.H{"index": index, "name": name}}}) } func writeError(c *gin.Context, status int, code, message string) { + c.Set(responseErrorCodeContextKey, code) c.JSON(status, gin.H{"error": gin.H{"code": code, "message": message}}) } func uintParam(c *gin.Context, name string) (uint64, bool) { diff --git a/portal/service/api_keys.go b/portal/service/api_keys.go index ae92e5e..e9f09d9 100644 --- a/portal/service/api_keys.go +++ b/portal/service/api_keys.go @@ -8,6 +8,7 @@ import ( "time" "unicode/utf8" + "git.ilapage.cn/OPC/chorus/internal/core/apiaudit" coreapikey "git.ilapage.cn/OPC/chorus/internal/core/apikey" "git.ilapage.cn/OPC/chorus/internal/core/model" platformapikey "git.ilapage.cn/OPC/chorus/internal/platform/apikey" @@ -40,7 +41,7 @@ type CreatedAPIKey struct { func (s *Service) ListAPIKeys(ctx context.Context, userID uint64, now time.Time) ([]APIKeySummary, error) { var rows []model.APIKey - err := s.withActiveUser(ctx, userID, func(repository *coreapikey.GORMRepository) error { + err := s.withActiveUser(ctx, userID, func(_ *gorm.DB, repository *coreapikey.GORMRepository) error { var err error rows, err = repository.ListForUser(ctx, userID) return err @@ -55,7 +56,7 @@ func (s *Service) ListAPIKeys(ctx context.Context, userID uint64, now time.Time) return result, nil } -func (s *Service) CreateAPIKey(ctx context.Context, userID uint64, name string, expiresInDays *int, now time.Time) (CreatedAPIKey, error) { +func (s *Service) CreateAPIKey(ctx context.Context, userID uint64, name string, expiresInDays *int, now time.Time, requestID string) (CreatedAPIKey, error) { name, err := validateAPIKeyName(name) if err != nil { return CreatedAPIKey{}, err @@ -73,21 +74,32 @@ func (s *Service) CreateAPIKey(ctx context.Context, userID uint64, name string, return CreatedAPIKey{}, err } row := model.APIKey{UserID: userID, Name: name, PublicID: credential.PublicID, KeyPrefix: credential.KeyPrefix, SecretHash: credential.SecretHash, ExpiresAt: expiresAt} - if err := s.withActiveUser(ctx, userID, func(repository *coreapikey.GORMRepository) error { - return repository.Create(ctx, &row) + if err := s.withActiveUser(ctx, userID, func(tx *gorm.DB, repository *coreapikey.GORMRepository) error { + if err := repository.Create(ctx, &row); err != nil { + return err + } + audit, err := apiaudit.NewRepository(tx) + if err != nil { + return err + } + return audit.Append(ctx, apiaudit.Event{ + UserID: &userID, APIKeyID: &row.ID, Action: apiaudit.ActionKeyCreated, + Result: apiaudit.ResultSucceeded, RequestID: requestID, StatusCode: 201, + Summary: apiaudit.Summary{Source: "portal"}, CreatedAt: now, + }) }); err != nil { return CreatedAPIKey{}, err } return CreatedAPIKey{APIKeySummary: apiKeySummary(row, now), Token: credential.Token}, nil } -func (s *Service) RenameAPIKey(ctx context.Context, userID, keyID uint64, name string, now time.Time) (APIKeySummary, error) { +func (s *Service) RenameAPIKey(ctx context.Context, userID, keyID uint64, name string, now time.Time, requestID string) (APIKeySummary, error) { name, err := validateAPIKeyName(name) if err != nil { return APIKeySummary{}, err } var row model.APIKey - err = s.withActiveUser(ctx, userID, func(repository *coreapikey.GORMRepository) error { + err = s.withActiveUser(ctx, userID, func(tx *gorm.DB, repository *coreapikey.GORMRepository) error { updated, err := repository.Rename(ctx, keyID, userID, name) if err != nil { return err @@ -96,7 +108,18 @@ func (s *Service) RenameAPIKey(ctx context.Context, userID, keyID uint64, name s return ErrNotFound } row, err = repository.ByIDForUser(ctx, keyID, userID) - return err + if err != nil { + return err + } + audit, err := apiaudit.NewRepository(tx) + if err != nil { + return err + } + return audit.Append(ctx, apiaudit.Event{ + UserID: &userID, APIKeyID: &keyID, Action: apiaudit.ActionKeyRenamed, + Result: apiaudit.ResultSucceeded, RequestID: requestID, StatusCode: 200, + Summary: apiaudit.Summary{Source: "portal"}, CreatedAt: now, + }) }) if err != nil { return APIKeySummary{}, mapAPIKeyRepositoryError(err) @@ -104,9 +127,14 @@ func (s *Service) RenameAPIKey(ctx context.Context, userID, keyID uint64, name s return apiKeySummary(row, now), nil } -func (s *Service) RevokeAPIKey(ctx context.Context, userID, keyID uint64, now time.Time) (APIKeySummary, error) { +func (s *Service) RevokeAPIKey(ctx context.Context, userID, keyID uint64, now time.Time, requestID string) (APIKeySummary, error) { var row model.APIKey - err := s.withActiveUser(ctx, userID, func(repository *coreapikey.GORMRepository) error { + err := s.withActiveUser(ctx, userID, func(tx *gorm.DB, repository *coreapikey.GORMRepository) error { + before, err := repository.ByIDForUser(ctx, keyID, userID) + if err != nil { + return err + } + alreadyRevoked := before.RevokedAt != nil revoked, err := repository.Revoke(ctx, keyID, userID, now.UTC()) if err != nil { return err @@ -115,7 +143,18 @@ func (s *Service) RevokeAPIKey(ctx context.Context, userID, keyID uint64, now ti return ErrNotFound } row, err = repository.ByIDForUser(ctx, keyID, userID) - return err + if err != nil { + return err + } + audit, err := apiaudit.NewRepository(tx) + if err != nil { + return err + } + return audit.Append(ctx, apiaudit.Event{ + UserID: &userID, APIKeyID: &keyID, Action: apiaudit.ActionKeyRevoked, + Result: apiaudit.ResultSucceeded, RequestID: requestID, StatusCode: 200, + Summary: apiaudit.Summary{Source: "portal", AlreadyRevoked: &alreadyRevoked}, CreatedAt: now, + }) }) if err != nil { return APIKeySummary{}, mapAPIKeyRepositoryError(err) @@ -123,7 +162,7 @@ func (s *Service) RevokeAPIKey(ctx context.Context, userID, keyID uint64, now ti return apiKeySummary(row, now), nil } -func (s *Service) withActiveUser(ctx context.Context, userID uint64, operation func(*coreapikey.GORMRepository) error) error { +func (s *Service) withActiveUser(ctx context.Context, userID uint64, operation func(*gorm.DB, *coreapikey.GORMRepository) error) error { if userID == 0 { return ErrAccountDisabled } @@ -140,7 +179,7 @@ func (s *Service) withActiveUser(ctx context.Context, userID uint64, operation f if err != nil { return err } - return operation(repository) + return operation(tx, repository) }) } diff --git a/portal/service/audit.go b/portal/service/audit.go new file mode 100644 index 0000000..f04c191 --- /dev/null +++ b/portal/service/audit.go @@ -0,0 +1,15 @@ +package service + +import ( + "context" + + "git.ilapage.cn/OPC/chorus/internal/core/apiaudit" +) + +func (s *Service) RecordAPIAudit(ctx context.Context, event apiaudit.Event) error { + repository, err := apiaudit.NewRepository(s.db) + if err != nil { + return err + } + return repository.Append(ctx, event) +}