Browse Source

feat(logger): add excludeFields config for PII redaction

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/4621/head
Mark Liu 2 days ago
parent
commit
cfae31bd32
  1. 6
      cmd/dex/config.go
  2. 56
      cmd/dex/excluding_handler.go
  3. 141
      cmd/dex/excluding_handler_test.go
  4. 4
      cmd/dex/logger.go
  5. 2
      cmd/dex/serve.go
  6. 6
      cmd/dex/serve_test.go
  7. 2
      config.yaml.dist
  8. 5
      server/handlers.go

6
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 {

56
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}
}

141
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")
}
}

4
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
}

2
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)
}

6
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)

2
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.

5
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 {

Loading…
Cancel
Save