diff --git a/platform-token-runtime/internal/auth/service/audit_store.go b/platform-token-runtime/internal/auth/service/audit_store.go new file mode 100644 index 00000000..6ca2afbb --- /dev/null +++ b/platform-token-runtime/internal/auth/service/audit_store.go @@ -0,0 +1,136 @@ +package service + +import ( + "context" + "crypto/rand" + "encoding/hex" + "sync" + "time" +) + +type MemoryAuditStore struct { + mu sync.RWMutex + events []AuditEvent + now func() time.Time +} + +type MemoryAuditEmitter = MemoryAuditStore + +func NewMemoryAuditStore() *MemoryAuditStore { + return &MemoryAuditStore{now: time.Now} +} + +func NewMemoryAuditEmitter() *MemoryAuditEmitter { + return NewMemoryAuditStore() +} + +func (e *MemoryAuditStore) Emit(_ context.Context, event AuditEvent) error { + if event.EventID == "" { + eventID, err := generateEventID() + if err != nil { + return err + } + event.EventID = eventID + } + if event.CreatedAt.IsZero() { + event.CreatedAt = e.now() + } + e.mu.Lock() + e.events = append(e.events, event) + e.mu.Unlock() + return nil +} + +func (e *MemoryAuditStore) Events() []AuditEvent { + e.mu.RLock() + defer e.mu.RUnlock() + copied := make([]AuditEvent, len(e.events)) + copy(copied, e.events) + return copied +} + +func (e *MemoryAuditStore) QueryEvents(_ context.Context, filter AuditEventFilter) ([]AuditEvent, error) { + e.mu.RLock() + defer e.mu.RUnlock() + + limit := filter.Limit + if limit <= 0 { + limit = 100 + } + if limit > 500 { + limit = 500 + } + + result := make([]AuditEvent, 0, minInt(limit, len(e.events))) + for idx := len(e.events) - 1; idx >= 0; idx-- { + ev := e.events[idx] + if !matchAuditFilter(ev, filter) { + continue + } + result = append(result, ev) + if len(result) >= limit { + break + } + } + + for i, j := 0, len(result)-1; i < j; i, j = i+1, j-1 { + result[i], result[j] = result[j], result[i] + } + return result, nil +} + +func (e *MemoryAuditStore) LastEvent() (AuditEvent, bool) { + e.mu.RLock() + defer e.mu.RUnlock() + if len(e.events) == 0 { + return AuditEvent{}, false + } + return e.events[len(e.events)-1], true +} + +func emitAudit(emitter AuditEmitter, event AuditEvent, now func() time.Time) { + if emitter == nil { + return + } + if now == nil { + now = time.Now + } + if event.CreatedAt.IsZero() { + event.CreatedAt = now() + } + _ = emitter.Emit(context.Background(), event) +} + +func matchAuditFilter(ev AuditEvent, filter AuditEventFilter) bool { + if filter.RequestID != "" && ev.RequestID != filter.RequestID { + return false + } + if filter.TokenID != "" && ev.TokenID != filter.TokenID { + return false + } + if filter.SubjectID != "" && ev.SubjectID != filter.SubjectID { + return false + } + if filter.EventName != "" && ev.EventName != filter.EventName { + return false + } + if filter.ResultCode != "" && ev.ResultCode != filter.ResultCode { + return false + } + return true +} + +func minInt(a, b int) int { + if a < b { + return a + } + return b +} + +func generateEventID() (string, error) { + var entropy [8]byte + if _, err := rand.Read(entropy[:]); err != nil { + return "", err + } + return "evt_" + hex.EncodeToString(entropy[:]), nil +} diff --git a/platform-token-runtime/internal/auth/service/audit_store_test.go b/platform-token-runtime/internal/auth/service/audit_store_test.go new file mode 100644 index 00000000..8d99a3b7 --- /dev/null +++ b/platform-token-runtime/internal/auth/service/audit_store_test.go @@ -0,0 +1,52 @@ +package service + +import ( + "context" + "testing" + "time" +) + +func TestMemoryAuditStore_QueryByTokenID(t *testing.T) { + store := NewMemoryAuditStore() + now := time.Date(2026, 4, 14, 10, 0, 0, 0, time.UTC) + + if err := store.Emit(context.Background(), AuditEvent{ + EventName: EventTokenIssueSuccess, + RequestID: "req-1", + TokenID: "tok-a", + SubjectID: "2001", + Route: "/issue", + ResultCode: "OK", + CreatedAt: now, + }); err != nil { + t.Fatalf("emit first event: %v", err) + } + if err := store.Emit(context.Background(), AuditEvent{ + EventName: EventTokenRevokeSuccess, + RequestID: "req-2", + TokenID: "tok-b", + SubjectID: "2002", + Route: "/revoke", + ResultCode: "OK", + CreatedAt: now.Add(time.Minute), + }); err != nil { + t.Fatalf("emit second event: %v", err) + } + + events, err := store.QueryEvents(context.Background(), AuditEventFilter{ + TokenID: "tok-b", + Limit: 10, + }) + if err != nil { + t.Fatalf("query events: %v", err) + } + if len(events) != 1 { + t.Fatalf("expected 1 event, got %d", len(events)) + } + if events[0].TokenID != "tok-b" { + t.Fatalf("unexpected token id: %s", events[0].TokenID) + } + if events[0].EventName != EventTokenRevokeSuccess { + t.Fatalf("unexpected event name: %s", events[0].EventName) + } +} diff --git a/platform-token-runtime/internal/auth/service/inmemory_runtime.go b/platform-token-runtime/internal/auth/service/inmemory_runtime.go index f4643ad7..7a34117c 100644 --- a/platform-token-runtime/internal/auth/service/inmemory_runtime.go +++ b/platform-token-runtime/internal/auth/service/inmemory_runtime.go @@ -38,11 +38,9 @@ type IssueTokenInput struct { } type InMemoryTokenRuntime struct { - mu sync.RWMutex - now func() time.Time - records map[string]*TokenRecord - tokenToID map[string]string - idempotencyByKey map[string]idempotencyEntry + mu sync.RWMutex + now func() time.Time + store *InMemoryRuntimeStore } type idempotencyEntry struct { @@ -55,10 +53,8 @@ func NewInMemoryTokenRuntime(now func() time.Time) *InMemoryTokenRuntime { now = time.Now } return &InMemoryTokenRuntime{ - now: now, - records: make(map[string]*TokenRecord), - tokenToID: make(map[string]string), - idempotencyByKey: make(map[string]idempotencyEntry), + now: now, + store: NewInMemoryRuntimeStore(), } } @@ -103,27 +99,20 @@ func (r *InMemoryTokenRuntime) Issue(_ context.Context, input IssueTokenInput) ( r.mu.Lock() if idempotencyKey != "" { - entry, ok := r.idempotencyByKey[idempotencyKey] + entry, ok := r.store.LookupIdempotency(idempotencyKey) if ok { if entry.RequestHash != requestHash { r.mu.Unlock() return TokenRecord{}, errors.New("idempotency key payload mismatch") } - existing, exists := r.records[entry.TokenID] + existing, exists := r.store.GetByTokenID(entry.TokenID) if exists { r.mu.Unlock() return cloneRecord(*existing), nil } } } - r.records[tokenID] = &record - r.tokenToID[accessToken] = tokenID - if idempotencyKey != "" { - r.idempotencyByKey[idempotencyKey] = idempotencyEntry{ - RequestHash: requestHash, - TokenID: tokenID, - } - } + r.store.Save(record, idempotencyKey, requestHash) r.mu.Unlock() return record, nil @@ -137,7 +126,7 @@ func (r *InMemoryTokenRuntime) Refresh(_ context.Context, tokenID string, ttl ti r.mu.Lock() defer r.mu.Unlock() - record, ok := r.records[tokenID] + record, ok := r.store.GetByTokenID(tokenID) if !ok { return TokenRecord{}, errors.New("token not found") } @@ -154,7 +143,7 @@ func (r *InMemoryTokenRuntime) Revoke(_ context.Context, tokenID, reason string) r.mu.Lock() defer r.mu.Unlock() - record, ok := r.records[tokenID] + record, ok := r.store.GetByTokenID(tokenID) if !ok { return TokenRecord{}, errors.New("token not found") } @@ -168,11 +157,10 @@ func (r *InMemoryTokenRuntime) Introspect(_ context.Context, accessToken string) r.mu.Lock() defer r.mu.Unlock() - tokenID, ok := r.tokenToID[accessToken] + record, ok := r.store.GetByAccessToken(accessToken) if !ok { return TokenRecord{}, errors.New("token not found") } - record := r.records[tokenID] r.applyExpiry(record) return cloneRecord(*record), nil } @@ -181,7 +169,7 @@ func (r *InMemoryTokenRuntime) Lookup(_ context.Context, tokenID string) (TokenR r.mu.Lock() defer r.mu.Unlock() - record, ok := r.records[tokenID] + record, ok := r.store.GetByTokenID(tokenID) if !ok { return TokenRecord{}, errors.New("token not found") } @@ -191,16 +179,11 @@ func (r *InMemoryTokenRuntime) Lookup(_ context.Context, tokenID string) (TokenR func (r *InMemoryTokenRuntime) Verify(_ context.Context, rawToken string) (VerifiedToken, error) { r.mu.RLock() - tokenID, ok := r.tokenToID[rawToken] + record, ok := r.store.GetByAccessToken(rawToken) if !ok { r.mu.RUnlock() return VerifiedToken{}, NewAuthError(CodeAuthInvalidToken, errors.New("token not found")) } - record, ok := r.records[tokenID] - if !ok { - r.mu.RUnlock() - return VerifiedToken{}, NewAuthError(CodeAuthInvalidToken, errors.New("token record not found")) - } claims := VerifiedToken{ TokenID: record.TokenID, SubjectID: record.SubjectID, @@ -217,7 +200,7 @@ func (r *InMemoryTokenRuntime) Resolve(_ context.Context, tokenID string) (Token r.mu.Lock() defer r.mu.Unlock() - record, ok := r.records[tokenID] + record, ok := r.store.GetByTokenID(tokenID) if !ok { return "", NewAuthError(CodeAuthInvalidToken, errors.New("token not found")) } @@ -228,7 +211,7 @@ func (r *InMemoryTokenRuntime) Resolve(_ context.Context, tokenID string) (Token func (r *InMemoryTokenRuntime) TokenCount() int { r.mu.RLock() defer r.mu.RUnlock() - return len(r.records) + return r.store.TokenCount() } func (r *InMemoryTokenRuntime) IssueAndAudit(ctx context.Context, input IssueTokenInput, auditor AuditEmitter) (TokenRecord, error) { @@ -367,125 +350,3 @@ func hasScope(scopes []string, required string) bool { } return false } - -type MemoryAuditEmitter struct { - mu sync.RWMutex - events []AuditEvent - now func() time.Time -} - -func NewMemoryAuditEmitter() *MemoryAuditEmitter { - return &MemoryAuditEmitter{now: time.Now} -} - -func (e *MemoryAuditEmitter) Emit(_ context.Context, event AuditEvent) error { - if event.EventID == "" { - eventID, err := generateEventID() - if err != nil { - return err - } - event.EventID = eventID - } - if event.CreatedAt.IsZero() { - event.CreatedAt = e.now() - } - e.mu.Lock() - e.events = append(e.events, event) - e.mu.Unlock() - return nil -} - -func (e *MemoryAuditEmitter) Events() []AuditEvent { - e.mu.RLock() - defer e.mu.RUnlock() - copied := make([]AuditEvent, len(e.events)) - copy(copied, e.events) - return copied -} - -func (e *MemoryAuditEmitter) QueryEvents(_ context.Context, filter AuditEventFilter) ([]AuditEvent, error) { - e.mu.RLock() - defer e.mu.RUnlock() - - limit := filter.Limit - if limit <= 0 { - limit = 100 - } - if limit > 500 { - limit = 500 - } - - result := make([]AuditEvent, 0, minInt(limit, len(e.events))) - for idx := len(e.events) - 1; idx >= 0; idx-- { - ev := e.events[idx] - if !matchAuditFilter(ev, filter) { - continue - } - result = append(result, ev) - if len(result) >= limit { - break - } - } - - // 按时间正序返回,便于前端/审计系统展示时间线。 - for i, j := 0, len(result)-1; i < j; i, j = i+1, j-1 { - result[i], result[j] = result[j], result[i] - } - return result, nil -} - -func (e *MemoryAuditEmitter) LastEvent() (AuditEvent, bool) { - e.mu.RLock() - defer e.mu.RUnlock() - if len(e.events) == 0 { - return AuditEvent{}, false - } - return e.events[len(e.events)-1], true -} - -func emitAudit(emitter AuditEmitter, event AuditEvent, now func() time.Time) { - if emitter == nil { - return - } - if now == nil { - now = time.Now - } - if event.CreatedAt.IsZero() { - event.CreatedAt = now() - } - _ = emitter.Emit(context.Background(), event) -} - -func matchAuditFilter(ev AuditEvent, filter AuditEventFilter) bool { - if filter.RequestID != "" && ev.RequestID != filter.RequestID { - return false - } - if filter.TokenID != "" && ev.TokenID != filter.TokenID { - return false - } - if filter.SubjectID != "" && ev.SubjectID != filter.SubjectID { - return false - } - if filter.EventName != "" && ev.EventName != filter.EventName { - return false - } - if filter.ResultCode != "" && ev.ResultCode != filter.ResultCode { - return false - } - return true -} - -func minInt(a, b int) int { - if a < b { - return a - } - return b -} - -func generateEventID() (string, error) { - var entropy [8]byte - if _, err := rand.Read(entropy[:]); err != nil { - return "", err - } - return "evt_" + hex.EncodeToString(entropy[:]), nil -} diff --git a/platform-token-runtime/internal/auth/service/runtime_store.go b/platform-token-runtime/internal/auth/service/runtime_store.go new file mode 100644 index 00000000..70dfd7df --- /dev/null +++ b/platform-token-runtime/internal/auth/service/runtime_store.go @@ -0,0 +1,50 @@ +package service + +type InMemoryRuntimeStore struct { + records map[string]*TokenRecord + tokenToID map[string]string + idempotencyByKey map[string]idempotencyEntry +} + +func NewInMemoryRuntimeStore() *InMemoryRuntimeStore { + return &InMemoryRuntimeStore{ + records: make(map[string]*TokenRecord), + tokenToID: make(map[string]string), + idempotencyByKey: make(map[string]idempotencyEntry), + } +} + +func (s *InMemoryRuntimeStore) Save(record TokenRecord, idempotencyKey, requestHash string) { + recordCopy := cloneRecord(record) + s.records[record.TokenID] = &recordCopy + s.tokenToID[record.AccessToken] = record.TokenID + if idempotencyKey != "" { + s.idempotencyByKey[idempotencyKey] = idempotencyEntry{ + RequestHash: requestHash, + TokenID: record.TokenID, + } + } +} + +func (s *InMemoryRuntimeStore) GetByTokenID(tokenID string) (*TokenRecord, bool) { + record, ok := s.records[tokenID] + return record, ok +} + +func (s *InMemoryRuntimeStore) GetByAccessToken(accessToken string) (*TokenRecord, bool) { + tokenID, ok := s.tokenToID[accessToken] + if !ok { + return nil, false + } + record, ok := s.records[tokenID] + return record, ok +} + +func (s *InMemoryRuntimeStore) LookupIdempotency(idempotencyKey string) (idempotencyEntry, bool) { + entry, ok := s.idempotencyByKey[idempotencyKey] + return entry, ok +} + +func (s *InMemoryRuntimeStore) TokenCount() int { + return len(s.records) +} diff --git a/platform-token-runtime/internal/auth/service/runtime_store_test.go b/platform-token-runtime/internal/auth/service/runtime_store_test.go new file mode 100644 index 00000000..77e91c25 --- /dev/null +++ b/platform-token-runtime/internal/auth/service/runtime_store_test.go @@ -0,0 +1,44 @@ +package service + +import "testing" + +func TestInMemoryRuntimeStore_SaveAndLookup(t *testing.T) { + store := NewInMemoryRuntimeStore() + record := TokenRecord{ + TokenID: "tok_123", + AccessToken: "ptk_123", + SubjectID: "2001", + Role: "owner", + Scope: []string{"supply:*"}, + } + + store.Save(record, "idem-1", "hash-1") + + byID, ok := store.GetByTokenID("tok_123") + if !ok { + t.Fatal("expected record by token id") + } + if byID.TokenID != "tok_123" { + t.Fatalf("unexpected token id: %s", byID.TokenID) + } + + byToken, ok := store.GetByAccessToken("ptk_123") + if !ok { + t.Fatal("expected record by access token") + } + if byToken.SubjectID != "2001" { + t.Fatalf("unexpected subject id: %s", byToken.SubjectID) + } + + entry, ok := store.LookupIdempotency("idem-1") + if !ok { + t.Fatal("expected idempotency entry") + } + if entry.RequestHash != "hash-1" { + t.Fatalf("unexpected request hash: %s", entry.RequestHash) + } + + if store.TokenCount() != 1 { + t.Fatalf("unexpected token count: %d", store.TokenCount()) + } +} diff --git a/platform-token-runtime/internal/httpapi/token_api_test.go b/platform-token-runtime/internal/httpapi/token_api_test.go index 780c5a59..d3935ed0 100644 --- a/platform-token-runtime/internal/httpapi/token_api_test.go +++ b/platform-token-runtime/internal/httpapi/token_api_test.go @@ -17,7 +17,7 @@ func TestTokenAPIIssueAndIntrospect(t *testing.T) { t.Parallel() runtime := service.NewInMemoryTokenRuntime(nil) - auditor := service.NewMemoryAuditEmitter() + auditor := service.NewMemoryAuditStore() api := NewTokenAPI(runtime, auditor, func() time.Time { return time.Date(2026, 3, 30, 15, 50, 0, 0, time.UTC) }) @@ -66,7 +66,7 @@ func TestTokenAPIIssueIdempotencyConflict(t *testing.T) { t.Parallel() runtime := service.NewInMemoryTokenRuntime(nil) - api := NewTokenAPI(runtime, service.NewMemoryAuditEmitter(), time.Now) + api := NewTokenAPI(runtime, service.NewMemoryAuditStore(), time.Now) mux := http.NewServeMux() api.Register(mux) @@ -107,7 +107,7 @@ func TestTokenAPIRefreshAndRevoke(t *testing.T) { now := time.Date(2026, 3, 30, 16, 0, 0, 0, time.UTC) runtime := service.NewInMemoryTokenRuntime(func() time.Time { return now }) - api := NewTokenAPI(runtime, service.NewMemoryAuditEmitter(), func() time.Time { return now }) + api := NewTokenAPI(runtime, service.NewMemoryAuditStore(), func() time.Time { return now }) mux := http.NewServeMux() api.Register(mux) @@ -162,7 +162,7 @@ func TestTokenAPIMissingHeaders(t *testing.T) { t.Parallel() runtime := service.NewInMemoryTokenRuntime(nil) - api := NewTokenAPI(runtime, service.NewMemoryAuditEmitter(), time.Now) + api := NewTokenAPI(runtime, service.NewMemoryAuditStore(), time.Now) mux := http.NewServeMux() api.Register(mux) @@ -184,7 +184,7 @@ func TestTokenAPIAuditEventsQuery(t *testing.T) { t.Parallel() runtime := service.NewInMemoryTokenRuntime(nil) - auditor := service.NewMemoryAuditEmitter() + auditor := service.NewMemoryAuditStore() api := NewTokenAPI(runtime, auditor, time.Now) mux := http.NewServeMux() api.Register(mux) @@ -227,6 +227,25 @@ func TestTokenAPIAuditEventsQuery(t *testing.T) { } } +func TestTokenAPIAuditEventsReady(t *testing.T) { + t.Parallel() + + runtime := service.NewInMemoryTokenRuntime(nil) + auditor := service.NewMemoryAuditStore() + api := NewTokenAPI(runtime, auditor, time.Now) + mux := http.NewServeMux() + api.Register(mux) + + req := httptest.NewRequest(http.MethodGet, "/api/v1/platform/tokens/audit-events?limit=3", nil) + req.Header.Set("X-Request-Id", "req-audit-ready") + rec := httptest.NewRecorder() + mux.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("expected ready audit query endpoint: code=%d body=%s", rec.Code, rec.Body.String()) + } +} + func TestTokenAPIAuditEventsNotReady(t *testing.T) { t.Parallel() diff --git a/platform-token-runtime/internal/token/audit_executable_test.go b/platform-token-runtime/internal/token/audit_executable_test.go index 7b2fb157..a941618d 100644 --- a/platform-token-runtime/internal/token/audit_executable_test.go +++ b/platform-token-runtime/internal/token/audit_executable_test.go @@ -16,7 +16,7 @@ import ( func TestTOKAud001IssueSuccessEvent(t *testing.T) { t.Parallel() - auditor := service.NewMemoryAuditEmitter() + auditor := service.NewMemoryAuditStore() rt := service.NewInMemoryTokenRuntime(nil) record, err := rt.IssueAndAudit(context.Background(), service.IssueTokenInput{ @@ -46,7 +46,7 @@ func TestTOKAud001IssueSuccessEvent(t *testing.T) { func TestTOKAud002IssueFailEvent(t *testing.T) { t.Parallel() - auditor := service.NewMemoryAuditEmitter() + auditor := service.NewMemoryAuditStore() rt := service.NewInMemoryTokenRuntime(nil) _, err := rt.IssueAndAudit(context.Background(), service.IssueTokenInput{ @@ -76,7 +76,7 @@ func TestTOKAud002IssueFailEvent(t *testing.T) { func TestTOKAud003AuthnFailEvent(t *testing.T) { t.Parallel() - auditor := service.NewMemoryAuditEmitter() + auditor := service.NewMemoryAuditStore() rt := service.NewInMemoryTokenRuntime(nil) authorizer := service.NewScopeRoleAuthorizer() @@ -112,7 +112,7 @@ func TestTOKAud003AuthnFailEvent(t *testing.T) { func TestTOKAud004AuthzDeniedEvent(t *testing.T) { t.Parallel() - auditor := service.NewMemoryAuditEmitter() + auditor := service.NewMemoryAuditStore() rt := service.NewInMemoryTokenRuntime(nil) authorizer := service.NewScopeRoleAuthorizer() @@ -159,7 +159,7 @@ func TestTOKAud004AuthzDeniedEvent(t *testing.T) { func TestTOKAud005RevokeSuccessEvent(t *testing.T) { t.Parallel() - auditor := service.NewMemoryAuditEmitter() + auditor := service.NewMemoryAuditStore() rt := service.NewInMemoryTokenRuntime(nil) record, err := rt.Issue(context.Background(), service.IssueTokenInput{ @@ -192,7 +192,7 @@ func TestTOKAud005RevokeSuccessEvent(t *testing.T) { func TestTOKAud006QueryKeyRejectedEvent(t *testing.T) { t.Parallel() - auditor := service.NewMemoryAuditEmitter() + auditor := service.NewMemoryAuditStore() rt := service.NewInMemoryTokenRuntime(nil) authorizer := service.NewScopeRoleAuthorizer() @@ -238,7 +238,7 @@ func TestTOKAud006QueryKeyRejectedEvent(t *testing.T) { func TestTOKAud007EventImmutability(t *testing.T) { t.Parallel() - auditor := service.NewMemoryAuditEmitter() + auditor := service.NewMemoryAuditStore() rt := service.NewInMemoryTokenRuntime(nil) issued, err := rt.IssueAndAudit(context.Background(), service.IssueTokenInput{