From 26d6de6d7ac9e2cd50d3f4612265fc33194fc6dd Mon Sep 17 00:00:00 2001 From: Juan Osorio Robles Date: Mon, 9 May 2022 09:13:13 +0300 Subject: [PATCH] Add ability to define custom outcome handler in middleware (#51) This allows library users to define their own heuristics on how they want outcomes to be defined. This could be using just the `gin.Context` object that the function takes, or any other heuristic based on the program's logic. Signed-off-by: Juan Antonio Osorio --- docs/middleware.md | 28 +++++++++++++++++++++ ginaudit/mdw.go | 31 ++++++++++------------- ginaudit/mdw_test.go | 59 +++++++++++++++++++++++++++++++++++++++++--- ginaudit/outcome.go | 46 ++++++++++++++++++++++++++++++++++ 4 files changed, 142 insertions(+), 22 deletions(-) create mode 100644 ginaudit/outcome.go diff --git a/docs/middleware.md b/docs/middleware.md index 30b32d71..05c99f5a 100644 --- a/docs/middleware.md +++ b/docs/middleware.md @@ -99,6 +99,34 @@ router.GET("/user/:name", mdw.AuditWithType("GetUserInfo"), userInfoHandler) to all events as audit events need to be uniquely identifiable actions. +### Audit event outcomes + +By default, the audit events generated by this middleware will be created +with the following heuristics: + +* For HTTP statuses 500 and above: `failed` + +* For HTTP statuses 400 and above, but below 500: `denied` + +* Anything else: `succeeded` + +If this pattern isn't appropriate for your server logic, it's possible to overwrite +the default outcome handler as follows: + +```golang +// Create middleware instance +mdw := ginaudit.NewMiddleware("my-test-component", eventwriter) + +// Overwrite handler. This one always succeeds +mdw.WithOutcomeHandler(func(c *gin.Context) string { + return auditevent.OutcomeSucceeded +}) +``` + +**NOTE**: It's recommended to not add random strings as an outcome, as one +normally wants predictable strings here. If a custom outcome is desired, make +sure to document it well. + ### Audit event metrics `ginaudit.Middleware` instances may generate metrics for events and errors. diff --git a/ginaudit/mdw.go b/ginaudit/mdw.go index bfa3f331..edef0504 100644 --- a/ginaudit/mdw.go +++ b/ginaudit/mdw.go @@ -18,7 +18,6 @@ package ginaudit import ( "fmt" "io" - "net/http" "sync" "github.com/gin-gonic/gin" @@ -28,16 +27,18 @@ import ( ) type Middleware struct { - component string - aew *auditevent.EventWriter - eventTypeMap sync.Map + component string + aew *auditevent.EventWriter + eventTypeMap sync.Map + outcomeHandler OutcomeHandler } // NewMiddleware returns a new instance of audit Middleware. func NewMiddleware(component string, aew *auditevent.EventWriter) *Middleware { return &Middleware{ - component: component, - aew: aew, + component: component, + aew: aew, + outcomeHandler: GetOutcomeDefault, } } @@ -63,6 +64,11 @@ func (m *Middleware) WithPrometheusMetricsForRegisterer(pr prometheus.Registerer return m } +func (m *Middleware) WithOutcomeHandler(handler OutcomeHandler) *Middleware { + m.outcomeHandler = handler + return m +} + // RegisterEventType registers an audit event type for a given HTTP method and path. func (m *Middleware) RegisterEventType(eventType, httpMethod, path string) { m.eventTypeMap.Store(keyFromHTTPMethodAndPath(httpMethod, path), eventType) @@ -95,7 +101,7 @@ func (m *Middleware) AuditWithType(t string) gin.HandlerFunc { // This already takes into account X-Forwarded-For and alike headers Value: c.ClientIP(), }, - m.getOutcome(c), + m.outcomeHandler(c), m.getSubject(c), m.component, ).WithTarget(map[string]string{ @@ -123,17 +129,6 @@ func (m *Middleware) getEventType(preferredType, httpMethod, path string) string return key } -func (m *Middleware) getOutcome(c *gin.Context) string { - status := c.Writer.Status() - if status >= http.StatusBadRequest && status < http.StatusInternalServerError { - return auditevent.OutcomeDenied - } - if status >= http.StatusInternalServerError { - return auditevent.OutcomeFailed - } - return auditevent.OutcomeSucceeded -} - func (m *Middleware) getSubject(c *gin.Context) map[string]string { // These context keys come from github.com/metal-toolbox/hollow-toolbox/ginjwt sub := c.GetString("jwt.subject") diff --git a/ginaudit/mdw_test.go b/ginaudit/mdw_test.go index ab15ad44..d4e00353 100644 --- a/ginaudit/mdw_test.go +++ b/ginaudit/mdw_test.go @@ -144,7 +144,7 @@ func getTestCases() []testCase { } } -func setFixtures(t *testing.T, w io.Writer, pr prometheus.Registerer) *gin.Engine { +func setFixtures(t *testing.T, w io.Writer, pr prometheus.Registerer) (*gin.Engine, *ginaudit.Middleware) { t.Helper() mdw := ginaudit.NewJSONMiddleware(comp, w) @@ -185,7 +185,7 @@ func setFixtures(t *testing.T, w io.Writer, pr prometheus.Registerer) *gin.Engin c.JSON(http.StatusForbidden, "denied") }) - return r + return r, mdw } func TestMiddleware(t *testing.T) { @@ -206,7 +206,7 @@ func TestMiddleware(t *testing.T) { pfd := <-fdchan defer pfd.Close() - r := setFixtures(t, pfd, nil) + r, _ := setFixtures(t, pfd, nil) w := httptest.NewRecorder() req := httptest.NewRequest(tc.method, tc.expectedEvent.Target["path"], nil) for k, v := range tc.headers { @@ -249,7 +249,7 @@ func TestParallelCallsToMiddleware(t *testing.T) { pr := prometheus.NewRegistry() - r := setFixtures(t, pfd, pr) + r, _ := setFixtures(t, pfd, pr) tcs := getTestCases() @@ -328,3 +328,54 @@ func TestCantRegisterMultipleTimesToSamePrometheus(t *testing.T) { ginaudit.NewJSONMiddleware(comp, &buf).WithPrometheusMetrics() }) } + +// Tests the that the middleware generates events with a custom +// outcome handler. +func TestMiddlewareWithCustomOutcomeHandler(t *testing.T) { + t.Parallel() + + for _, tc := range getTestCases() { + tc := tc + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + p := testtools.GetNamedPipe(t) + + fdchan := testtools.SetPipeReader(t, p) + + f, err := os.Open(p) + require.NoError(t, err) + + // receive pipe reader file descriptor + pfd := <-fdchan + defer pfd.Close() + + r, mdw := setFixtures(t, pfd, nil) + mdw.WithOutcomeHandler(func(c *gin.Context) string { + return "custom" + }) + w := httptest.NewRecorder() + req := httptest.NewRequest(tc.method, tc.expectedEvent.Target["path"], nil) + for k, v := range tc.headers { + req.Header.Set(k, v) + } + r.ServeHTTP(w, req) + + // wait for the event to be written + gotEvent := &auditevent.AuditEvent{} + dec := json.NewDecoder(f) + decErr := dec.Decode(gotEvent) + require.NoError(t, decErr) + + require.Equal(t, tc.expectedEvent.Type, gotEvent.Type, "type should match") + require.True(t, gotEvent.LoggedAt.Before(time.Now()), "logging time should be before now") + require.Equal(t, tc.expectedEvent.Source.Type, gotEvent.Source.Type, "source type should match") + require.Equal(t, tc.expectedEvent.Subjects, gotEvent.Subjects, "subjects should match") + require.Equal(t, tc.expectedEvent.Component, gotEvent.Component, "component should match") + require.Equal(t, tc.expectedEvent.Target, gotEvent.Target, "target should match") + require.Equal(t, tc.expectedEvent.Data, gotEvent.Data, "data should match") + + // This is the custom outcome we set above + require.Equal(t, "custom", gotEvent.Outcome, "outcome should match") + }) + } +} diff --git a/ginaudit/outcome.go b/ginaudit/outcome.go new file mode 100644 index 00000000..695cfc7a --- /dev/null +++ b/ginaudit/outcome.go @@ -0,0 +1,46 @@ +/* +Copyright 2022 Equinix, Inc. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ +package ginaudit + +import ( + "net/http" + + "github.com/gin-gonic/gin" + + "github.com/metal-toolbox/auditevent" +) + +// OutcomeHandler is a function that returns the AuditEvent outcome +// for a given request. This will be called after other middleware; e.g. +// the given gin context should already contain a result status. +// It is recommended to return one of the samples defined in +// `samples.go`. +type OutcomeHandler func(c *gin.Context) string + +// GetOutcomeDefault is the default outcome handler that's set in +// the middleware constructor. It will return `failed` for HTTP response +// statuses 500 and above, `denied` for requests 400 and above and +// `succeeded` otherwise. +func GetOutcomeDefault(c *gin.Context) string { + status := c.Writer.Status() + if status >= http.StatusBadRequest && status < http.StatusInternalServerError { + return auditevent.OutcomeDenied + } + if status >= http.StatusInternalServerError { + return auditevent.OutcomeFailed + } + return auditevent.OutcomeSucceeded +}