Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 26 additions & 0 deletions .agents/DECISIONS.md
Original file line number Diff line number Diff line change
Expand Up @@ -591,3 +591,29 @@ just the expression `response.header`.
being placed in the page (base64 in CEL, decoded by the button's JS) to
avoid HTML injection. The example uses `base64.encode(...)` and a
`__TARGET_B64__` placeholder.


## D-024 - Unified, engine-agnostic access logging

**Context.** Originally, only the extAuthz engine supported access logging using the `logging` block configuration. The extProc engine had no structured access logging capability.

**Decision.** Unify the access-logging capability across both engines using a new shared package `internal/accesslog`. The extProc engine will emit a structured access log record at INFO level ("extProc access") for every phase message Envoy sends, unconditionally.

- `requestHeaders` phase logs `accesslog.RequestAttrs`.
- `requestBody` phase logs `accesslog.RequestAttrs` (containing the accumulated body).
- `responseHeaders` phase logs `accesslog.RequestAttrs` and `accesslog.ResponseAttrs`.
- `responseBody` phase logs `accesslog.ResponseAttrs` (containing the accumulated response body).

The existing per-request custom headers, redactions, masking, and body log toggle configurations under the `logging` block apply symmetrically to both engines.

**Rationale.** The request validator is a generic gatekeeping product. The deployment-side EnvoyFilter configuration controls which messages and metadata reach the extProc server. Delegating traffic volume and level details to Envoy's filters is standard practice; therefore, the validator does not need to duplicate these knobs internally, and can log every received message uniformly.

**Alternative Rejected.**
- Logging only on a rule match: rejected because operators need visibility into all traffic flowing through the validator, not just match events.
- Configurable per-phase logging knobs: rejected to avoid redundant configuration overhead and keep the code path simple.

**Consequences.**
- Extracted common header exclusion/redaction and body formatting logic into a unified `internal/accesslog` package.
- Solved the logging gap where extProc was entirely blind to structured, redacted request/response auditing.
- No behavior or schema changes for extAuthz logs.

5 changes: 5 additions & 0 deletions .agents/POLICY_DSL.md
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,11 @@ logging:
redactQueryParams: [access_token, id_token, code]
```

The logging block applies to BOTH engines:

- extAuthz emits one access record per delegated request ("request decided").
- extProc emits one access record per phase message at INFO ("extProc access"). The request and/or response body appears in extProc logs only when Envoy's processing_mode actually sends the body AND logBody is true. extProc records carry a stream_id shared by all phases of one HTTP request (one ext_proc stream = one request) and a request_id copied from the x-request-id header when present.

Behaviour:

- Header keys are normalised to lowercase before exclude/redact checks.
Expand Down
61 changes: 55 additions & 6 deletions internal/httpserver/access.go → internal/accesslog/accesslog.go
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
// SPDX-FileCopyrightText: 2026 Alby Hernández <hola@achetronic.com>
// SPDX-License-Identifier: Apache-2.0

package httpserver
// Package accesslog provides utilities for structured access logging across both engines.
package accesslog

import (
"log/slog"
Expand All @@ -11,14 +12,11 @@ import (
"request-validator/internal/policy"
)

// accessLogAttrs builds the slog group describing a single request. The
// caller adds higher-level fields (decision, rule, reason, dryRun, duration)
// around it; this function is concerned only with what came in.
//
// RequestAttrs builds the slog group describing a single request.
// Header keys are always lowercase. Excluded headers are dropped, redacted
// headers have their values masked. The body is included only when
// logging.LogBody is true; the body size is included always.
func accessLogAttrs(req *policy.Request, lg policy.Logging) slog.Attr {
func RequestAttrs(req *policy.Request, lg policy.Logging) slog.Attr {
exclude := lowerSet(lg.ExcludeHeaders)
redact := lowerSet(lg.RedactHeaders)

Expand Down Expand Up @@ -61,6 +59,57 @@ func accessLogAttrs(req *policy.Request, lg policy.Logging) slog.Attr {
)
}

// ResponseAttrs builds the slog group describing a single response.
// Header keys are always lowercase. Excluded headers are dropped, redacted
// headers have their values masked. The body is included only when
// logging.LogBody is true; the body size is included always.
func ResponseAttrs(resp *policy.Response, lg policy.Logging) slog.Attr {
if resp == nil {
return slog.Group("response",
slog.Int("status", 0),
slog.Group("headers"),
slog.Group("body",
slog.Int("size", 0),
slog.String("content_type", ""),
),
)
}

exclude := lowerSet(lg.ExcludeHeaders)
redact := lowerSet(lg.RedactHeaders)

hdrs := make([]any, 0, len(resp.Headers)*2)
for k, vs := range resp.Headers {
lk := strings.ToLower(k)
if exclude[lk] {
continue
}
joined := strings.Join(vs, ", ")
if redact[lk] {
joined = mask(joined, lg.RedactReveal)
}
hdrs = append(hdrs, slog.String(lk, joined))
}

body := slog.Group("body",
slog.Int("size", len(resp.Body)),
slog.String("content_type", strings.ToLower(resp.Headers.Get("Content-Type"))),
)
if lg.LogBody && len(resp.Body) > 0 {
body = slog.Group("body",
slog.Int("size", len(resp.Body)),
slog.String("content_type", strings.ToLower(resp.Headers.Get("Content-Type"))),
slog.String("raw", string(resp.Body)),
)
}

return slog.Group("response",
slog.Int("status", resp.Status),
slog.Group("headers", hdrs...),
body,
)
}

// lowerSet builds a lowercase string set out of a slice. Empty entries are
// ignored.
func lowerSet(xs []string) map[string]bool {
Expand Down
Loading
Loading