mirror of https://github.com/dexidp/dex.git
Browse Source
Adds an slog.Handler wrapper (excludingHandler) that drops log attributes matching a configured set of keys. This allows GDPR-sensitive deployments to suppress PII fields like email, username, preferred_username, or groups at the logger level rather than per-callsite. Also adds user_id to the "login successful" log line so operators who exclude PII fields still have a pseudonymous identifier. Closes #4391 --------- Signed-off-by: Mark Liu <mark@prove.com.au>pull/4648/head
8 changed files with 215 additions and 7 deletions
@ -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} |
||||||
|
} |
||||||
@ -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") |
||||||
|
} |
||||||
|
} |
||||||
Loading…
Reference in new issue