Skip to content

Commit 7ec34a9

Browse files
authored
feat(api): log session/audit subject from UserAccessControl (#4029)
Read identity from request context after handlers run so Basic, Bearer, OIDC, mTLS, etc. are covered; use subject "anonymous" when unset. Redact Authorization in SessionLogger without decoding credentials. Add session_test.go for SessionLogger and SessionAuditLogger. Signed-off-by: Andrei Aaron <andreifdaaron@gmail.com>
1 parent fa2960b commit 7ec34a9

2 files changed

Lines changed: 241 additions & 29 deletions

File tree

pkg/api/session.go

Lines changed: 12 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,16 @@
11
package api
22

33
import (
4-
"encoding/base64"
54
"net/http"
65
"strconv"
7-
"strings"
86
"time"
97

108
"github.com/didip/tollbooth/v7"
119
"github.com/gorilla/mux"
1210

1311
"zotregistry.dev/zot/v2/pkg/extensions/monitoring"
1412
"zotregistry.dev/zot/v2/pkg/log"
13+
reqCtx "zotregistry.dev/zot/v2/pkg/requestcontext"
1514
)
1615

1716
type statusWriter struct {
@@ -94,24 +93,18 @@ func SessionLogger(ctlr *Controller) mux.MiddlewareFunc {
9493

9594
for key, value := range request.Header {
9695
if key == "Authorization" { // anonymize from logs
97-
s := strings.SplitN(value[0], " ", 2) //nolint:mnd
98-
if len(s) == 2 && strings.EqualFold(s[0], "basic") {
99-
b, err := base64.StdEncoding.DecodeString(s[1])
100-
if err == nil {
101-
pair := strings.SplitN(string(b), ":", 2) //nolint:mnd
102-
//nolint:mnd
103-
if len(pair) == 2 {
104-
log = log.Str("username", pair[0])
105-
}
106-
}
107-
}
108-
10996
value = []string{"******"}
11097
}
11198

11299
headers[key] = value
113100
}
114101

102+
if userAc, err := reqCtx.UserAcFromContext(request.Context()); err == nil {
103+
if username := userAc.GetUsername(); username != "" {
104+
log = log.Str("username", username)
105+
}
106+
}
107+
115108
statusCode := stwr.status
116109
bodySize := stwr.length
117110

@@ -153,20 +146,10 @@ func SessionAuditLogger(audit *log.Logger) mux.MiddlewareFunc {
153146

154147
clientIP := request.RemoteAddr
155148
method := request.Method
156-
username := ""
157-
158-
for key, value := range request.Header {
159-
if key == "Authorization" { // anonymize from logs
160-
s := strings.SplitN(value[0], " ", 2) //nolint:mnd
161-
if len(s) == 2 && strings.EqualFold(s[0], "basic") {
162-
b, err := base64.StdEncoding.DecodeString(s[1])
163-
if err == nil {
164-
pair := strings.SplitN(string(b), ":", 2) //nolint:mnd
165-
if len(pair) == 2 { //nolint:mnd
166-
username = pair[0]
167-
}
168-
}
169-
}
149+
subject := "anonymous"
150+
if userAc, err := reqCtx.UserAcFromContext(request.Context()); err == nil {
151+
if username := userAc.GetUsername(); username != "" {
152+
subject = username
170153
}
171154
}
172155

@@ -182,7 +165,7 @@ func SessionAuditLogger(audit *log.Logger) mux.MiddlewareFunc {
182165
audit.Info().
183166
Str("component", "session").
184167
Str("clientIP", clientIP).
185-
Str("subject", username).
168+
Str("subject", subject).
186169
Str("action", method).
187170
Str("object", path).
188171
Int("status", statusCode).

pkg/api/session_test.go

Lines changed: 229 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,229 @@
1+
package api_test
2+
3+
import (
4+
"bytes"
5+
"encoding/json"
6+
"net/http"
7+
"net/http/httptest"
8+
"os"
9+
"path/filepath"
10+
"strings"
11+
"testing"
12+
13+
"github.com/stretchr/testify/assert"
14+
"github.com/stretchr/testify/require"
15+
16+
zotapi "zotregistry.dev/zot/v2/pkg/api"
17+
monitoring "zotregistry.dev/zot/v2/pkg/extensions/monitoring"
18+
"zotregistry.dev/zot/v2/pkg/log"
19+
reqCtx "zotregistry.dev/zot/v2/pkg/requestcontext"
20+
)
21+
22+
func TestSessionAuditLogger(t *testing.T) {
23+
t.Parallel()
24+
25+
tests := []struct {
26+
name string
27+
method string
28+
status int
29+
setUsername string
30+
wantAudit bool
31+
wantSubject string
32+
}{
33+
{
34+
name: "mutating POST 200 anonymous",
35+
method: http.MethodPost,
36+
status: http.StatusOK,
37+
wantAudit: true,
38+
wantSubject: "anonymous",
39+
},
40+
{
41+
name: "mutating PUT 201 authenticated",
42+
method: http.MethodPut,
43+
status: http.StatusCreated,
44+
setUsername: "alice",
45+
wantAudit: true,
46+
wantSubject: "alice",
47+
},
48+
{
49+
name: "mutating PATCH 202 anonymous",
50+
method: http.MethodPatch,
51+
status: http.StatusAccepted,
52+
wantAudit: true,
53+
wantSubject: "anonymous",
54+
},
55+
{
56+
name: "mutating DELETE 200 anonymous",
57+
method: http.MethodDelete,
58+
status: http.StatusOK,
59+
wantAudit: true,
60+
wantSubject: "anonymous",
61+
},
62+
{
63+
name: "GET 200 skipped",
64+
method: http.MethodGet,
65+
status: http.StatusOK,
66+
wantAudit: false,
67+
},
68+
{
69+
name: "POST 401 skipped",
70+
method: http.MethodPost,
71+
status: http.StatusUnauthorized,
72+
wantAudit: false,
73+
wantSubject: "",
74+
},
75+
{
76+
name: "POST 403 skipped even with username",
77+
method: http.MethodPost,
78+
status: http.StatusForbidden,
79+
setUsername: "bob",
80+
wantAudit: false,
81+
},
82+
}
83+
84+
for _, testCase := range tests {
85+
t.Run(testCase.name, func(t *testing.T) {
86+
t.Parallel()
87+
88+
auditPath := filepath.Join(t.TempDir(), "audit.log")
89+
audit := log.NewAuditLogger("info", auditPath)
90+
91+
inner := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
92+
if testCase.setUsername != "" {
93+
uac := reqCtx.NewUserAccessControl()
94+
uac.SetUsername(testCase.setUsername)
95+
uac.SaveOnRequest(r)
96+
}
97+
98+
w.WriteHeader(testCase.status)
99+
})
100+
101+
wrapped := zotapi.SessionAuditLogger(audit)(inner)
102+
103+
req := httptest.NewRequest(testCase.method, "/v2/repo/test/uploads", http.NoBody)
104+
req.RemoteAddr = "127.0.0.1:12345"
105+
106+
recorder := httptest.NewRecorder()
107+
wrapped.ServeHTTP(recorder, req)
108+
109+
data, err := os.ReadFile(auditPath)
110+
require.NoError(t, err)
111+
112+
if !testCase.wantAudit {
113+
assert.Empty(t, strings.TrimSpace(string(data)))
114+
115+
return
116+
}
117+
118+
lines := bytes.Split(bytes.TrimSpace(data), []byte("\n"))
119+
require.Len(t, lines, 1)
120+
121+
var payload map[string]any
122+
require.NoError(t, json.Unmarshal(lines[0], &payload))
123+
124+
statusVal, ok := payload["status"].(float64)
125+
require.True(t, ok, "JSON status should decode as float64")
126+
127+
assert.Equal(t, "HTTP API Audit", payload["message"])
128+
assert.Equal(t, testCase.wantSubject, payload["subject"])
129+
assert.Equal(t, testCase.method, payload["action"])
130+
assert.InDelta(t, float64(testCase.status), statusVal, 0)
131+
assert.Equal(t, "session", payload["component"])
132+
assert.Equal(t, "127.0.0.1:12345", payload["clientIP"])
133+
assert.Equal(t, "/v2/repo/test/uploads", payload["object"])
134+
})
135+
}
136+
}
137+
138+
func TestSessionAuditLogger_rawQueryAppendedToObject(t *testing.T) {
139+
t.Parallel()
140+
141+
auditPath := filepath.Join(t.TempDir(), "audit.log")
142+
audit := log.NewAuditLogger("info", auditPath)
143+
144+
inner := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
145+
w.WriteHeader(http.StatusCreated)
146+
})
147+
148+
wrapped := zotapi.SessionAuditLogger(audit)(inner)
149+
150+
req := httptest.NewRequest(http.MethodPost, "/v2/a/b", http.NoBody)
151+
req.URL.RawQuery = "digest=sha256:abc"
152+
153+
recorder := httptest.NewRecorder()
154+
wrapped.ServeHTTP(recorder, req)
155+
156+
data, err := os.ReadFile(auditPath)
157+
require.NoError(t, err)
158+
159+
var payload map[string]any
160+
require.NoError(t, json.Unmarshal(bytes.TrimSpace(data), &payload))
161+
162+
assert.Equal(t, "/v2/a/b?digest=sha256:abc", payload["object"])
163+
}
164+
165+
func TestSessionLogger_redactsAuthorizationAndLogsUsernameFromContext(t *testing.T) {
166+
t.Parallel()
167+
168+
var buf bytes.Buffer
169+
170+
ctlr := &zotapi.Controller{
171+
Log: log.NewLoggerWithWriter("info", &buf),
172+
Metrics: monitoring.NewMetricsServer(false, log.NewTestLogger()),
173+
}
174+
t.Cleanup(ctlr.Metrics.Stop)
175+
176+
inner := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
177+
uac := reqCtx.NewUserAccessControl()
178+
uac.SetUsername("alice")
179+
uac.SaveOnRequest(r)
180+
181+
w.WriteHeader(http.StatusOK)
182+
183+
_, _ = w.Write([]byte("ok"))
184+
})
185+
186+
wrapped := zotapi.SessionLogger(ctlr)(inner)
187+
188+
req := httptest.NewRequest(http.MethodGet, "/v2/_catalog", http.NoBody)
189+
req.Header.Set("Authorization", "Bearer super-secret-token")
190+
req.RemoteAddr = "10.0.0.1:4444"
191+
192+
recorder := httptest.NewRecorder()
193+
wrapped.ServeHTTP(recorder, req)
194+
195+
out := buf.String()
196+
197+
assert.Contains(t, out, `"message":"HTTP API"`)
198+
assert.Contains(t, out, `"username":"alice"`)
199+
assert.Contains(t, out, `"Authorization":["******"]`)
200+
assert.NotContains(t, out, "super-secret-token")
201+
}
202+
203+
func TestSessionLogger_omitsUsernameWhenAnonymous(t *testing.T) {
204+
t.Parallel()
205+
206+
var buf bytes.Buffer
207+
208+
ctlr := &zotapi.Controller{
209+
Log: log.NewLoggerWithWriter("info", &buf),
210+
Metrics: monitoring.NewMetricsServer(false, log.NewTestLogger()),
211+
}
212+
t.Cleanup(ctlr.Metrics.Stop)
213+
214+
inner := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
215+
w.WriteHeader(http.StatusNoContent)
216+
})
217+
218+
wrapped := zotapi.SessionLogger(ctlr)(inner)
219+
220+
req := httptest.NewRequest(http.MethodGet, "/v2/_catalog", http.NoBody)
221+
222+
recorder := httptest.NewRecorder()
223+
wrapped.ServeHTTP(recorder, req)
224+
225+
out := buf.String()
226+
227+
assert.Contains(t, out, `"message":"HTTP API"`)
228+
assert.NotContains(t, out, `"username"`)
229+
}

0 commit comments

Comments
 (0)