feat: add independent platform modules and multi-provider storage

This commit is contained in:
2026-09-23 14:51:14 +08:00
parent f91034477b
commit 208a9c3fbd
57 changed files with 4890 additions and 48 deletions

106
audit/audit.go Normal file
View File

@@ -0,0 +1,106 @@
// Package audit records explicitly selected business events without request bodies.
package audit
import (
"context"
"encoding/json"
"errors"
"git.yuntuo.hk/k2safe/go-omni/logx"
"io"
"sync"
"time"
)
type Event struct {
Time time.Time `json:"time"`
ActorID string `json:"actorId"`
TenantID string `json:"tenantId,omitempty"`
Action string `json:"action"`
Resource string `json:"resource"`
Outcome string `json:"outcome"`
RequestID string `json:"requestId,omitempty"`
TraceID string `json:"traceId,omitempty"`
Fields map[string]string `json:"fields,omitempty"`
}
type Sink interface {
Write(context.Context, Event) error
}
type Recorder struct {
sink Sink
allowed map[string]bool
}
func New(sink Sink, allowedFields ...string) (*Recorder, error) {
if sink == nil || len(allowedFields) > 32 {
return nil, errors.New("audit: sink and at most 32 fields required")
}
r := &Recorder{sink: sink, allowed: map[string]bool{}}
for _, name := range allowedFields {
if name == "" || len(name) > 64 {
return nil, errors.New("audit: invalid field")
}
r.allowed[name] = true
}
return r, nil
}
func (r *Recorder) Record(ctx context.Context, event Event) error {
if ctx == nil {
return errors.New("audit: nil context")
}
if err := ctx.Err(); err != nil {
return err
}
if event.ActorID == "" || event.Action == "" || event.Resource == "" || (event.Outcome != "success" && event.Outcome != "failure" && event.Outcome != "denied") {
return errors.New("audit: actor/action/resource and valid outcome required")
}
for _, value := range []string{event.ActorID, event.TenantID, event.Action, event.Resource} {
if len(value) > 512 {
return errors.New("audit: event field too large")
}
}
event.Time = time.Now().UTC()
event.RequestID = logx.RequestID(ctx)
event.TraceID = logx.TraceID(ctx)
filtered := map[string]string{}
for name, value := range event.Fields {
if r.allowed[name] {
if len(value) > 1024 {
return errors.New("audit: attribute too large")
}
filtered[name] = value
}
}
event.Fields = filtered
return r.sink.Write(ctx, event)
}
// JSONSink borrows its writer and serializes complete JSON lines. Write errors
// are returned; callers choose whether a failed audit must reject an operation.
type JSONSink struct {
mu sync.Mutex
writer io.Writer
}
func NewJSONSink(writer io.Writer) (*JSONSink, error) {
if writer == nil {
return nil, errors.New("audit: writer required")
}
return &JSONSink{writer: writer}, nil
}
func (s *JSONSink) Write(ctx context.Context, event Event) error {
data, err := json.Marshal(event)
if err != nil {
return err
}
data = append(data, '\n')
s.mu.Lock()
defer s.mu.Unlock()
if err := ctx.Err(); err != nil {
return err
}
n, err := s.writer.Write(data)
if err == nil && n != len(data) {
return io.ErrShortWrite
}
return err
}

50
audit/audit_test.go Normal file
View File

@@ -0,0 +1,50 @@
package audit
import (
"bytes"
"context"
"encoding/json"
"errors"
"git.yuntuo.hk/k2safe/go-omni/logx"
"io"
"sync"
"testing"
)
type failedWriter struct{}
func (failedWriter) Write([]byte) (int, error) { return 0, io.ErrClosedPipe }
func TestAllowlistCorrelationAndWriteFailure(t *testing.T) {
var buf bytes.Buffer
sink, _ := NewJSONSink(&buf)
recorder, _ := New(sink, "status")
ctx := logx.WithRequestID(context.Background(), "r1")
ctx = logx.WithTraceID(ctx, "t1")
event := Event{ActorID: "u1", Action: "update", Resource: "object:1", Outcome: "success", Fields: map[string]string{"status": "ok", "password": "secret"}}
var wg sync.WaitGroup
for i := 0; i < 20; i++ {
wg.Add(1)
go func() {
defer wg.Done()
if err := recorder.Record(ctx, event); err != nil {
t.Error(err)
}
}()
}
wg.Wait()
decoder := json.NewDecoder(&buf)
for i := 0; i < 20; i++ {
var got Event
if err := decoder.Decode(&got); err != nil {
t.Fatal(err)
}
if got.Fields["password"] != "" || got.RequestID != "r1" || got.TraceID != "t1" || got.Time.IsZero() {
t.Fatal(got)
}
}
bad, _ := NewJSONSink(failedWriter{})
recorder, _ = New(bad)
if err := recorder.Record(ctx, event); !errors.Is(err, io.ErrClosedPipe) {
t.Fatal(err)
}
}