diff --git a/cmd/dex/config.go b/cmd/dex/config.go index 913d4dfe..8efc3c38 100644 --- a/cmd/dex/config.go +++ b/cmd/dex/config.go @@ -571,6 +571,12 @@ type Logger struct { // Format specifies the format to be used for logging. Format string `json:"format"` + + // ExcludeFields specifies log attribute keys that should be dropped from all + // log output. This is useful for suppressing PII fields like email, username, + // preferred_username, or groups in environments subject to GDPR or similar + // data-handling constraints. + ExcludeFields []string `json:"excludeFields"` } type RefreshToken struct { diff --git a/cmd/dex/excluding_handler.go b/cmd/dex/excluding_handler.go new file mode 100644 index 00000000..c5d03e44 --- /dev/null +++ b/cmd/dex/excluding_handler.go @@ -0,0 +1,56 @@ +package main + +import ( + "context" + "log/slog" +) + +// excludingHandler is an slog.Handler wrapper that drops log attributes +// whose keys match a configured set. This allows PII fields like email, +// username, or groups to be redacted at the logger level rather than +// requiring per-callsite suppression logic. +type excludingHandler struct { + inner slog.Handler + exclude map[string]bool +} + +func newExcludingHandler(inner slog.Handler, fields []string) slog.Handler { + if len(fields) == 0 { + return inner + } + m := make(map[string]bool, len(fields)) + for _, f := range fields { + m[f] = true + } + return &excludingHandler{inner: inner, exclude: m} +} + +func (h *excludingHandler) Enabled(ctx context.Context, level slog.Level) bool { + return h.inner.Enabled(ctx, level) +} + +func (h *excludingHandler) Handle(ctx context.Context, record slog.Record) error { + // Rebuild the record without excluded attributes. + filtered := slog.NewRecord(record.Time, record.Level, record.Message, record.PC) + record.Attrs(func(a slog.Attr) bool { + if !h.exclude[a.Key] { + filtered.AddAttrs(a) + } + return true + }) + return h.inner.Handle(ctx, filtered) +} + +func (h *excludingHandler) WithAttrs(attrs []slog.Attr) slog.Handler { + var kept []slog.Attr + for _, a := range attrs { + if !h.exclude[a.Key] { + kept = append(kept, a) + } + } + return &excludingHandler{inner: h.inner.WithAttrs(kept), exclude: h.exclude} +} + +func (h *excludingHandler) WithGroup(name string) slog.Handler { + return &excludingHandler{inner: h.inner.WithGroup(name), exclude: h.exclude} +} diff --git a/cmd/dex/excluding_handler_test.go b/cmd/dex/excluding_handler_test.go new file mode 100644 index 00000000..e0306d60 --- /dev/null +++ b/cmd/dex/excluding_handler_test.go @@ -0,0 +1,141 @@ +package main + +import ( + "bytes" + "context" + "encoding/json" + "log/slog" + "testing" +) + +func TestExcludingHandler(t *testing.T) { + tests := []struct { + name string + exclude []string + logAttrs []slog.Attr + wantKeys []string + absentKeys []string + }{ + { + name: "no exclusions", + exclude: nil, + logAttrs: []slog.Attr{ + slog.String("email", "user@example.com"), + slog.String("connector_id", "github"), + }, + wantKeys: []string{"email", "connector_id"}, + }, + { + name: "exclude email", + exclude: []string{"email"}, + logAttrs: []slog.Attr{ + slog.String("email", "user@example.com"), + slog.String("connector_id", "github"), + }, + wantKeys: []string{"connector_id"}, + absentKeys: []string{"email"}, + }, + { + name: "exclude multiple fields", + exclude: []string{"email", "username", "groups"}, + logAttrs: []slog.Attr{ + slog.String("email", "user@example.com"), + slog.String("username", "johndoe"), + slog.String("connector_id", "github"), + slog.Any("groups", []string{"admin"}), + }, + wantKeys: []string{"connector_id"}, + absentKeys: []string{"email", "username", "groups"}, + }, + { + name: "exclude non-existent field is harmless", + exclude: []string{"nonexistent"}, + logAttrs: []slog.Attr{ + slog.String("email", "user@example.com"), + }, + wantKeys: []string{"email"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var buf bytes.Buffer + inner := slog.NewJSONHandler(&buf, &slog.HandlerOptions{Level: slog.LevelInfo}) + handler := newExcludingHandler(inner, tt.exclude) + logger := slog.New(handler) + + attrs := make([]any, 0, len(tt.logAttrs)*2) + for _, a := range tt.logAttrs { + attrs = append(attrs, a) + } + logger.Info("test message", attrs...) + + var result map[string]any + if err := json.Unmarshal(buf.Bytes(), &result); err != nil { + t.Fatalf("failed to parse log output: %v", err) + } + + for _, key := range tt.wantKeys { + if _, ok := result[key]; !ok { + t.Errorf("expected key %q in log output", key) + } + } + for _, key := range tt.absentKeys { + if _, ok := result[key]; ok { + t.Errorf("expected key %q to be absent from log output", key) + } + } + }) + } +} + +func TestExcludingHandlerWithAttrs(t *testing.T) { + var buf bytes.Buffer + inner := slog.NewJSONHandler(&buf, &slog.HandlerOptions{Level: slog.LevelInfo}) + handler := newExcludingHandler(inner, []string{"email"}) + logger := slog.New(handler) + + // Pre-bind an excluded attr via With + child := logger.With("email", "user@example.com", "connector_id", "github") + child.Info("login successful") + + var result map[string]any + if err := json.Unmarshal(buf.Bytes(), &result); err != nil { + t.Fatalf("failed to parse log output: %v", err) + } + + if _, ok := result["email"]; ok { + t.Error("expected email to be excluded from WithAttrs output") + } + if _, ok := result["connector_id"]; !ok { + t.Error("expected connector_id to be present") + } +} + +func TestExcludingHandlerEnabled(t *testing.T) { + inner := slog.NewJSONHandler(&bytes.Buffer{}, &slog.HandlerOptions{Level: slog.LevelWarn}) + handler := newExcludingHandler(inner, []string{"email"}) + + if handler.Enabled(context.Background(), slog.LevelInfo) { + t.Error("expected Info to be disabled when handler level is Warn") + } + if !handler.Enabled(context.Background(), slog.LevelWarn) { + t.Error("expected Warn to be enabled") + } +} + +func TestExcludingHandlerNilFields(t *testing.T) { + var buf bytes.Buffer + inner := slog.NewJSONHandler(&buf, &slog.HandlerOptions{Level: slog.LevelInfo}) + + // With nil/empty fields, should return the inner handler directly + handler := newExcludingHandler(inner, nil) + if _, ok := handler.(*excludingHandler); ok { + t.Error("expected nil fields to return inner handler directly, not wrap it") + } + + handler = newExcludingHandler(inner, []string{}) + if _, ok := handler.(*excludingHandler); ok { + t.Error("expected empty fields to return inner handler directly, not wrap it") + } +} diff --git a/cmd/dex/logger.go b/cmd/dex/logger.go index c1fe6b4a..fd4bd294 100644 --- a/cmd/dex/logger.go +++ b/cmd/dex/logger.go @@ -12,7 +12,7 @@ import ( var logFormats = []string{"json", "text"} -func newLogger(level slog.Level, format string) (*slog.Logger, error) { +func newLogger(level slog.Level, format string, excludeFields []string) (*slog.Logger, error) { var handler slog.Handler switch strings.ToLower(format) { case "", "text": @@ -27,6 +27,8 @@ func newLogger(level slog.Level, format string) (*slog.Logger, error) { return nil, fmt.Errorf("log format is not one of the supported values (%s): %s", strings.Join(logFormats, ", "), format) } + handler = newExcludingHandler(handler, excludeFields) + return slog.New(newRequestContextHandler(handler)), nil } diff --git a/cmd/dex/serve.go b/cmd/dex/serve.go index cd9d3839..5cc0877a 100644 --- a/cmd/dex/serve.go +++ b/cmd/dex/serve.go @@ -109,7 +109,7 @@ func runServe(options serveOptions) error { applyConfigOverrides(options, &c) - logger, err := newLogger(c.Logger.Level, c.Logger.Format) + logger, err := newLogger(c.Logger.Level, c.Logger.Format, c.Logger.ExcludeFields) if err != nil { return fmt.Errorf("invalid config: %v", err) } diff --git a/cmd/dex/serve_test.go b/cmd/dex/serve_test.go index 9e214480..12d0c0ff 100644 --- a/cmd/dex/serve_test.go +++ b/cmd/dex/serve_test.go @@ -9,19 +9,19 @@ import ( func TestNewLogger(t *testing.T) { t.Run("JSON", func(t *testing.T) { - logger, err := newLogger(slog.LevelInfo, "json") + logger, err := newLogger(slog.LevelInfo, "json", nil) require.NoError(t, err) require.NotEqual(t, (*slog.Logger)(nil), logger) }) t.Run("Text", func(t *testing.T) { - logger, err := newLogger(slog.LevelError, "text") + logger, err := newLogger(slog.LevelError, "text", nil) require.NoError(t, err) require.NotEqual(t, (*slog.Logger)(nil), logger) }) t.Run("Unknown", func(t *testing.T) { - logger, err := newLogger(slog.LevelError, "gofmt") + logger, err := newLogger(slog.LevelError, "gofmt", nil) require.Error(t, err) require.Equal(t, "log format is not one of the supported values (json, text): gofmt", err.Error()) require.Equal(t, (*slog.Logger)(nil), logger) diff --git a/config.yaml.dist b/config.yaml.dist index 5d2c37ea..917f8d1f 100644 --- a/config.yaml.dist +++ b/config.yaml.dist @@ -72,6 +72,8 @@ web: # logger: # level: "debug" # format: "text" # can also be "json" +# # Drop these attribute keys from all log output (useful for GDPR/PII suppression). +# # excludeFields: [email, username, preferred_username, groups] # gRPC API configuration # Uncomment this block to enable the gRPC API. diff --git a/server/handlers.go b/server/handlers.go index e60715d9..815b4451 100644 --- a/server/handlers.go +++ b/server/handlers.go @@ -669,8 +669,9 @@ func (s *Server) finalizeLogin(ctx context.Context, identity connector.Identity, } s.logger.InfoContext(ctx, "login successful", - "connector_id", authReq.ConnectorID, "username", claims.Username, - "preferred_username", claims.PreferredUsername, "email", email, "groups", claims.Groups) + "connector_id", authReq.ConnectorID, "user_id", claims.UserID, + "username", claims.Username, "preferred_username", claims.PreferredUsername, + "email", email, "groups", claims.Groups) offlineAccessRequested := false for _, scope := range authReq.Scopes {