package integration import ( "context" "crypto/sha1" "encoding/hex" "encoding/json" "fmt" "strings" "sync" "time" "github.com/seaweedfs/seaweedfs/weed/glog" "github.com/seaweedfs/seaweedfs/weed/pb" "github.com/seaweedfs/seaweedfs/weed/pb/filer_pb" "google.golang.org/grpc" ) // OIDCProviderAuditEventType enumerates the lifecycle events emitted when an // IAM-managed OIDC provider record is mutated. Use-events (every successful // token validation) are intentionally not emitted by the lifecycle path: // they're too hot to stream into a filer-backed sink without spikes. A // future PR can add a sampled-use sink behind an explicit opt-in flag. type OIDCProviderAuditEventType string const ( OIDCAuditEventCreated OIDCProviderAuditEventType = "Create" OIDCAuditEventDeleted OIDCProviderAuditEventType = "Delete" OIDCAuditEventClientIDAdded OIDCProviderAuditEventType = "AddClientID" OIDCAuditEventClientIDRemoved OIDCProviderAuditEventType = "RemoveClientID" OIDCAuditEventThumbprintsSet OIDCProviderAuditEventType = "UpdateThumbprints" OIDCAuditEventTagsAdded OIDCProviderAuditEventType = "Tag" OIDCAuditEventTagsRemoved OIDCProviderAuditEventType = "Untag" ) // OIDCProviderAuditEvent is the payload emitted for each lifecycle event. type OIDCProviderAuditEvent struct { Type OIDCProviderAuditEventType `json:"type"` ARN string `json:"arn"` URL string `json:"url,omitempty"` Detail map[string]string `json:"detail,omitempty"` OccurredAt time.Time `json:"occurredAt"` } // OIDCProviderAuditSink consumes lifecycle events. Implementations must be // safe for concurrent use; emit happens with the IAM manager's mutation lock // held, so a slow sink slows the IAM API. type OIDCProviderAuditSink interface { Emit(ctx context.Context, event *OIDCProviderAuditEvent) error } // GlogAuditSink writes one structured log line per event. Always-on default // when an explicit sink isn't configured — events still appear in stdout/the // log aggregator, just not in a queryable record. type GlogAuditSink struct{} func (GlogAuditSink) Emit(_ context.Context, event *OIDCProviderAuditEvent) error { if event == nil { return nil } data, err := json.Marshal(event) if err != nil { glog.V(0).Infof("oidc-audit: %s arn=%s err-marshal=%v", event.Type, event.ARN, err) return nil } glog.V(0).Infof("oidc-audit: %s", string(data)) return nil } // MemoryAuditSink keeps events in process memory for tests and short-lived // inspection. Not durable; drop entries by reading from Events() and // discarding. type MemoryAuditSink struct { mu sync.Mutex events []*OIDCProviderAuditEvent } func NewMemoryAuditSink() *MemoryAuditSink { return &MemoryAuditSink{} } func (m *MemoryAuditSink) Emit(_ context.Context, event *OIDCProviderAuditEvent) error { m.mu.Lock() defer m.mu.Unlock() cp := *event m.events = append(m.events, &cp) return nil } // Events returns a copy of the captured event log so tests can assert. func (m *MemoryAuditSink) Events() []*OIDCProviderAuditEvent { m.mu.Lock() defer m.mu.Unlock() out := make([]*OIDCProviderAuditEvent, len(m.events)) copy(out, m.events) return out } // FilerAuditSink appends event records as separate files under a filer // directory. Each event is its own file so concurrent writers don't conflict; // the filename is `--.json`. For higher volumes, // switch to an append-only journal — the contract is just `Emit`. type FilerAuditSink struct { grpcDialOption grpc.DialOption basePath string filerAddressProvider func() string } // NewFilerAuditSink returns a filer-backed sink. Default basePath // `/etc/iam/audit/oidc-providers` keeps audit records out of the active // IAM data directories. func NewFilerAuditSink(config map[string]interface{}, filerAddressProvider func() string) *FilerAuditSink { sink := &FilerAuditSink{ basePath: "/etc/iam/audit/oidc-providers", filerAddressProvider: filerAddressProvider, } if config != nil { if bp, ok := config["basePath"].(string); ok && bp != "" { sink.basePath = strings.TrimSuffix(bp, "/") } } return sink } func (f *FilerAuditSink) resolveFilerAddress() string { if f.filerAddressProvider != nil { return f.filerAddressProvider() } return "" } func (f *FilerAuditSink) Emit(ctx context.Context, event *OIDCProviderAuditEvent) error { addr := f.resolveFilerAddress() if addr == "" { return fmt.Errorf("filer address not available") } if event == nil { return nil } if event.OccurredAt.IsZero() { event.OccurredAt = time.Now().UTC() } data, err := json.MarshalIndent(event, "", " ") if err != nil { return fmt.Errorf("marshal audit event: %v", err) } // Filename: --.json. Two events at the same // nano against the same ARN+type are vanishingly rare, but two events // at the same nano against different ARNs are perfectly plausible // (audit-via-batch script). The ARN hash gives us an effectively- // collision-free name without leaking the ARN into the filer path. arnSum := sha1.Sum([]byte(event.ARN)) name := fmt.Sprintf("%d-%s-%s.json", event.OccurredAt.UnixNano(), event.Type, hex.EncodeToString(arnSum[:8])) occurredAt := event.OccurredAt.Unix() return pb.WithGrpcFilerClient(false, 0, pb.ServerAddress(addr), f.grpcDialOption, func(client filer_pb.SeaweedFilerClient) error { _, err := client.CreateEntry(ctx, &filer_pb.CreateEntryRequest{ Directory: f.basePath, Entry: &filer_pb.Entry{ Name: name, IsDirectory: false, Attributes: &filer_pb.FuseAttributes{ // Reflect the event's occurrence time so file metadata // matches the audit record itself, not the filer write. Mtime: occurredAt, Crtime: occurredAt, FileMode: uint32(0o600), }, Content: data, }, }) return err }) }