Compare commits

...

19 Commits

Author SHA1 Message Date
grandwizard28
be964a61da fix: rebase with main 2026-04-01 14:01:50 +05:30
grandwizard28
30062b5a21 chore: remove json tags 2026-04-01 13:52:02 +05:30
grandwizard28
90186383d2 chore: fix formatting 2026-04-01 13:52:02 +05:30
grandwizard28
7057a734af refactor(audit): replace Sprintf with strings.Builder in newBody
Handle edge cases where principal email, ID, or resource ID may be
empty. The builder conditionally includes each segment, avoiding
empty parentheses or leading spaces in the audit body.

Add test cases covering all meaningful combinations: success/failure
with full/partial/empty principal, resource ID, and error details.
2026-04-01 13:52:02 +05:30
grandwizard28
400b921ad0 style(audit): rename want prefix to expected in test fields 2026-04-01 13:52:02 +05:30
grandwizard28
b1e4723b48 fix(audit): check rw.Write return values in response_test.go 2026-04-01 13:52:02 +05:30
grandwizard28
8ef68bcb53 test(audit): add unit tests for responseCapture
Test the four meaningful behaviors: success responses don't capture
body, error responses capture body, large error bodies truncate at
4096 bytes, and 204 No Content suppresses writes entirely.
2026-04-01 13:52:02 +05:30
grandwizard28
4afff35d59 fix(audit): add CodeUnset, use ErrorCodeFromBody in middleware
Add errors.CodeUnset for responses missing an error code. Update the
audit middleware to use render.ErrorCodeFromBody instead of the removed
render.ErrorFromBody.
2026-04-01 13:51:58 +05:30
grandwizard28
c9e52acceb fix(audit): fix gjson path in ErrorCodeFromBody, add tests
Fix ErrorCodeFromBody gjson path from "errors.code" to "error.code"
to match the ErrorResponse JSON structure. Add unit tests for valid
error response and invalid JSON cases.
2026-04-01 13:51:58 +05:30
grandwizard28
e416836787 fix(audit): update auditorserver test and otlphttp provider for new struct layout
Update newTestEvent in server_test.go to use nested AuditAttributes
and ResourceAttributes. Update otlphttpauditor provider to access
PrincipalOrgID via PrincipalAttributes. Fix godot lint on attribute
section comments.
2026-04-01 13:51:55 +05:30
grandwizard28
e92e3b3cca refactor(audit): shorten attribute struct names, drop error message
Rename AuditEventAuditAttributes to AuditAttributes,
AuditEventPrincipalAttributes to PrincipalAttributes, and likewise
for Resource, Error, and Transport. The package prefix already
disambiguates.

Remove ErrorMessage from ErrorAttributes to avoid leaking sensitive
or PII data into audit logs. Error type and code are sufficient for
filtering; investigators can correlate via trace ID.
2026-04-01 13:51:54 +05:30
grandwizard28
26bad4a617 refactor(audit): decompose AuditEvent into attribute sub-structs, add tests
Decompose flat AuditEvent fields into typed sub-structs
(AuditEventAuditAttributes, PrincipalAttributes, ResourceAttributes,
ErrorAttributes, TransportAttributes) each with a constructor and
Put(pcommon.Map) method. Simplify NewAuditEventFromHTTPRequest to
accept authtypes.Claims and oteltrace IDs directly. Simplify the
middleware caller accordingly.

Add unit tests for the factory, outcome boundary, and principal type
derivation.
2026-04-01 13:51:54 +05:30
grandwizard28
0fb8043396 feat(audit): add option.go with AuditDef, Option, and WithAuditDef 2026-04-01 13:51:46 +05:30
grandwizard28
ee06840969 refactor(audit): move AuditDef onto Handler interface, consolidate files
Move AuditDef() onto the Handler interface directly. All Handler
implementations now carry it: handler returns the configured def,
healthOpenAPIHandler returns nil. Delete the separate AuditDefProvider
interface and audit.go handler file. Move excludedRoutes check before
audit emission so excluded routes skip both logging and audit.
2026-04-01 13:51:46 +05:30
grandwizard28
57dbceee49 refactor(audit): move error parsing to render.ErrorFromBody and render.ErrorTypeFromStatusCode
Add render.ErrorFromBody to extract errors.JSON from a JSON-encoded
ErrorResponse body, and render.ErrorTypeFromStatusCode to reverse-map
HTTP status codes to error type strings. The middleware now uses these
instead of local duplicates.
2026-04-01 13:51:46 +05:30
grandwizard28
4aef46c2c5 refactor(audit): extract NewAuditEventFromHTTPRequest factory into audittypes
Move event construction to audittypes.NewAuditEventFromHTTPRequest with
an AuditEventContext struct for caller-provided fields. The audittypes
layer reads only transport fields from *http.Request and has no mux,
authtypes, or context dependencies. The middleware pre-extracts
principal, trace, error, and route fields before calling the factory.
2026-04-01 13:51:30 +05:30
grandwizard28
70def6e2e5 refactor(audit): rename Logging middleware to Audit, merge into single file
Delete logging.go and merge its contents into audit.go. Rename
Logging/NewLogging to Audit/NewAudit. The response.go file with
responseCapture is unchanged.
2026-04-01 13:51:30 +05:30
grandwizard28
71dbc06450 refactor(audit): move audit logic to middleware, merge with logging
Move audit event emission from handler to middleware layer. The handler
package keeps only the AuditDef struct and AuditDefProvider interface.
The logging middleware now handles both request logging and audit event
emission using a single response capture, avoiding double-wrapping.

Rename badResponseLoggingWriter to responseCapture with body capture
on all 4xx/5xx responses (previously only 400 and 5xx).
2026-04-01 13:51:30 +05:30
grandwizard28
2e3a4375f5 feat(audit): handler-level AuditDef and response-capturing wrapper
Add declarative audit instrumentation to the handler package. Routes
declare an AuditDef alongside OpenAPIDef; the handler automatically
captures the response status/body and emits an audit event via
auditor.Audit() after every request.
2026-04-01 13:51:29 +05:30
18 changed files with 1054 additions and 239 deletions

View File

@@ -76,12 +76,12 @@ func (provider *provider) Start(ctx context.Context) error {
}
func (provider *provider) Audit(ctx context.Context, event audittypes.AuditEvent) {
if event.PrincipalOrgID.IsZero() {
if event.PrincipalAttributes.PrincipalOrgID.IsZero() {
provider.settings.Logger().WarnContext(ctx, "audit event dropped as org_id is zero")
return
}
if _, err := provider.licensing.GetActive(ctx, event.PrincipalOrgID); err != nil {
if _, err := provider.licensing.GetActive(ctx, event.PrincipalAttributes.PrincipalOrgID); err != nil {
return
}

View File

@@ -229,7 +229,7 @@ func (s *Server) createPublicServer(apiHandler *api.APIHandler, web web.Web) (*h
s.config.APIServer.Timeout.Default,
s.config.APIServer.Timeout.Max,
).Wrap)
r.Use(middleware.NewLogging(s.signoz.Instrumentation.Logger(), s.config.APIServer.Logging.ExcludedRoutes).Wrap)
r.Use(middleware.NewAudit(s.signoz.Instrumentation.Logger(), s.config.APIServer.Logging.ExcludedRoutes, nil).Wrap)
r.Use(middleware.NewComment().Wrap)
apiHandler.RegisterRoutes(r, am)

View File

@@ -50,6 +50,11 @@ func (handler *healthOpenAPIHandler) ServeOpenAPI(opCtx openapi.OperationContext
)
}
func (handler *healthOpenAPIHandler) AuditDef() *pkghandler.AuditDef {
// Health endpoints are not audited since they don't represent user actions and are called frequently by monitoring systems, which would create noise in the audit logs.
return nil
}
func (provider *provider) addRegistryRoutes(router *mux.Router) error {
if err := router.Handle("/api/v2/healthz", newHealthOpenAPIHandler(
provider.authZ.OpenAccess(provider.factoryHandler.Healthz),

View File

@@ -21,11 +21,15 @@ func newTestSettings() factory.ScopedProviderSettings {
func newTestEvent(resource string, action audittypes.Action) audittypes.AuditEvent {
return audittypes.AuditEvent{
Timestamp: time.Now(),
EventName: audittypes.NewEventName(resource, action),
ResourceName: resource,
Action: action,
Outcome: audittypes.OutcomeSuccess,
Timestamp: time.Now(),
EventName: audittypes.NewEventName(resource, action),
AuditAttributes: audittypes.AuditAttributes{
Action: action,
Outcome: audittypes.OutcomeSuccess,
},
ResourceAttributes: audittypes.ResourceAttributes{
ResourceName: resource,
},
}
}

View File

@@ -20,6 +20,12 @@ var (
CodeLicenseUnavailable = Code{"license_unavailable"}
)
var (
// Used when reverse engineering an error from a response that doesn't have a code.
// This should never be used in the codebase, and if it is, it's a bug that should be fixed by using proper error handling and including error codes in responses.
CodeUnset = Code{"unset"}
)
var (
codeRegex = regexp.MustCompile(`^[a-z_]+$`)
)

View File

@@ -15,14 +15,16 @@ type ServeOpenAPIFunc func(openapi.OperationContext)
type Handler interface {
http.Handler
ServeOpenAPI(openapi.OperationContext)
AuditDef() *AuditDef
}
type handler struct {
handlerFunc http.HandlerFunc
openAPIDef OpenAPIDef
auditDef *AuditDef
}
func New(handlerFunc http.HandlerFunc, openAPIDef OpenAPIDef) Handler {
func New(handlerFunc http.HandlerFunc, openAPIDef OpenAPIDef, opts ...Option) Handler {
// Remove duplicate error status codes
openAPIDef.ErrorStatusCodes = slices.DeleteFunc(openAPIDef.ErrorStatusCodes, func(statusCode int) bool {
return statusCode == http.StatusUnauthorized || statusCode == http.StatusForbidden || statusCode == http.StatusInternalServerError
@@ -36,10 +38,16 @@ func New(handlerFunc http.HandlerFunc, openAPIDef OpenAPIDef) Handler {
openAPIDef.ErrorStatusCodes = append(openAPIDef.ErrorStatusCodes, http.StatusUnauthorized, http.StatusForbidden)
}
return &handler{
handler := &handler{
handlerFunc: handlerFunc,
openAPIDef: openAPIDef,
}
for _, opt := range opts {
opt(handler)
}
return handler
}
func (handler *handler) ServeHTTP(rw http.ResponseWriter, req *http.Request) {
@@ -120,5 +128,8 @@ func (handler *handler) ServeOpenAPI(opCtx openapi.OperationContext) {
openapi.WithHTTPStatus(statusCode),
)
}
}
func (handler *handler) AuditDef() *AuditDef {
return handler.auditDef
}

View File

@@ -0,0 +1,24 @@
package handler
import (
"github.com/SigNoz/signoz/pkg/types/audittypes"
)
// Option configures optional behaviour on a handler created by New.
type Option func(*handler)
type AuditDef struct {
ResourceName string // AuthZ Typeable.Name() value, e.g. "dashboard", "user".
Action audittypes.Action // create, update, delete, login, etc.
Category audittypes.ActionCategory // access_control, configuration_change, etc.
ResourceIDParam string // Gorilla mux path param name for the resource ID.
}
// WithAudit attaches an AuditDef to the handler. The actual audit event
// emission is handled by the middleware layer, which reads the AuditDef
// from the matched route's handler.
func WithAuditDef(def AuditDef) Option {
return func(h *handler) {
h.auditDef = &def
}
}

View File

@@ -0,0 +1,169 @@
package middleware
import (
"log/slog"
"net"
"net/http"
"time"
"github.com/gorilla/mux"
semconv "go.opentelemetry.io/otel/semconv/v1.26.0"
"go.opentelemetry.io/otel/trace"
"github.com/SigNoz/signoz/pkg/auditor"
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/http/handler"
"github.com/SigNoz/signoz/pkg/http/render"
"github.com/SigNoz/signoz/pkg/types/audittypes"
"github.com/SigNoz/signoz/pkg/types/authtypes"
)
const (
logMessage = "::RECEIVED-REQUEST::"
)
type Audit struct {
logger *slog.Logger
excludedRoutes map[string]struct{}
auditor auditor.Auditor
}
func NewAudit(logger *slog.Logger, excludedRoutes []string, auditor auditor.Auditor) *Audit {
excludedRoutesMap := make(map[string]struct{})
for _, route := range excludedRoutes {
excludedRoutesMap[route] = struct{}{}
}
return &Audit{
logger: logger.With(slog.String("pkg", pkgname)),
excludedRoutes: excludedRoutesMap,
auditor: auditor,
}
}
func (middleware *Audit) Wrap(next http.Handler) http.Handler {
return http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) {
start := time.Now()
host, port, _ := net.SplitHostPort(req.Host)
path, err := mux.CurrentRoute(req).GetPathTemplate()
if err != nil {
path = req.URL.Path
}
fields := []any{
string(semconv.ClientAddressKey), req.RemoteAddr,
string(semconv.UserAgentOriginalKey), req.UserAgent(),
string(semconv.ServerAddressKey), host,
string(semconv.ServerPortKey), port,
string(semconv.HTTPRequestSizeKey), req.ContentLength,
string(semconv.HTTPRouteKey), path,
}
responseBuffer := &byteBuffer{}
writer := newResponseCapture(rw, responseBuffer)
next.ServeHTTP(writer, req)
statusCode, writeErr := writer.StatusCode(), writer.WriteError()
// Logging or Audit: skip if the matched route is in the excluded list. This allows us to exclude noisy routes (e.g. health checks) from both logging and audit.
if _, ok := middleware.excludedRoutes[path]; ok {
return
}
middleware.emitAuditEvent(req, writer, path)
fields = append(fields,
string(semconv.HTTPResponseStatusCodeKey), statusCode,
string(semconv.HTTPServerRequestDurationName), time.Since(start),
)
if writeErr != nil {
fields = append(fields, errors.Attr(writeErr))
middleware.logger.ErrorContext(req.Context(), logMessage, fields...)
} else {
if responseBuffer.Len() != 0 {
fields = append(fields, "response.body", responseBuffer.String())
}
middleware.logger.InfoContext(req.Context(), logMessage, fields...)
}
})
}
func (middleware *Audit) emitAuditEvent(req *http.Request, writer responseCapture, routeTemplate string) {
if middleware.auditor == nil {
return
}
def := auditDefFromRequest(req)
if def == nil {
return
}
// extract claims
claims, _ := authtypes.ClaimsFromContext(req.Context())
// extract status code
statusCode := writer.StatusCode()
// extract traces.
span := trace.SpanFromContext(req.Context())
// extract error details.
var errorType, errorCode string
if statusCode >= 400 {
errorType = render.ErrorTypeFromStatusCode(statusCode)
errorCode = render.ErrorCodeFromBody(writer.BodyBytes())
}
event := audittypes.NewAuditEventFromHTTPRequest(
req,
routeTemplate,
statusCode,
span.SpanContext().TraceID(),
span.SpanContext().SpanID(),
def.Action,
def.Category,
claims,
resourceIDFromRequest(req, def.ResourceIDParam),
def.ResourceName,
errorType,
errorCode,
)
middleware.auditor.Audit(req.Context(), event)
}
func auditDefFromRequest(req *http.Request) *handler.AuditDef {
route := mux.CurrentRoute(req)
if route == nil {
return nil
}
actualHandler := route.GetHandler()
if actualHandler == nil {
return nil
}
// The type assertion is necessary because route.GetHandler() returns
// http.Handler, and not every http.Handler on the mux is a handler.Handler
// (e.g. middleware wrappers, raw http.HandlerFunc registrations).
provider, ok := actualHandler.(handler.Handler)
if !ok {
return nil
}
return provider.AuditDef()
}
func resourceIDFromRequest(req *http.Request, param string) string {
if param == "" {
return ""
}
vars := mux.Vars(req)
if vars == nil {
return ""
}
return vars[param]
}

View File

@@ -1,81 +0,0 @@
package middleware
import (
"bytes"
"log/slog"
"net"
"net/http"
"time"
"github.com/gorilla/mux"
semconv "go.opentelemetry.io/otel/semconv/v1.26.0"
"github.com/SigNoz/signoz/pkg/errors"
)
const (
logMessage string = "::RECEIVED-REQUEST::"
)
type Logging struct {
logger *slog.Logger
excludedRoutes map[string]struct{}
}
func NewLogging(logger *slog.Logger, excludedRoutes []string) *Logging {
excludedRoutesMap := make(map[string]struct{})
for _, route := range excludedRoutes {
excludedRoutesMap[route] = struct{}{}
}
return &Logging{
logger: logger.With(slog.String("pkg", pkgname)),
excludedRoutes: excludedRoutesMap,
}
}
func (middleware *Logging) Wrap(next http.Handler) http.Handler {
return http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) {
start := time.Now()
host, port, _ := net.SplitHostPort(req.Host)
path, err := mux.CurrentRoute(req).GetPathTemplate()
if err != nil {
path = req.URL.Path
}
fields := []any{
string(semconv.ClientAddressKey), req.RemoteAddr,
string(semconv.UserAgentOriginalKey), req.UserAgent(),
string(semconv.ServerAddressKey), host,
string(semconv.ServerPortKey), port,
string(semconv.HTTPRequestSizeKey), req.ContentLength,
string(semconv.HTTPRouteKey), path,
}
badResponseBuffer := new(bytes.Buffer)
writer := newBadResponseLoggingWriter(rw, badResponseBuffer)
next.ServeHTTP(writer, req)
// if the path is in the excludedRoutes map, don't log
if _, ok := middleware.excludedRoutes[path]; ok {
return
}
statusCode, err := writer.StatusCode(), writer.WriteError()
fields = append(fields,
string(semconv.HTTPResponseStatusCodeKey), statusCode,
string(semconv.HTTPServerRequestDurationName), time.Since(start),
)
if err != nil {
fields = append(fields, errors.Attr(err))
middleware.logger.ErrorContext(req.Context(), logMessage, fields...)
} else {
// when the status code is 400 or >=500, and the response body is not empty.
if badResponseBuffer.Len() != 0 {
fields = append(fields, "response.body", badResponseBuffer.String())
}
middleware.logger.InfoContext(req.Context(), logMessage, fields...)
}
})
}

View File

@@ -2,7 +2,6 @@ package middleware
import (
"bufio"
"io"
"net"
"net/http"
@@ -10,118 +9,156 @@ import (
)
const (
maxResponseBodyInLogs = 4096 // At most 4k bytes from response bodies in our logs.
maxResponseBodyCapture int = 4096 // At most 4k bytes from response bodies.
)
type badResponseLoggingWriter interface {
// Wraps an http.ResponseWriter to capture the status code,
// write errors, and (for error responses) a bounded slice of the body.
type responseCapture interface {
http.ResponseWriter
// Get the status code.
// StatusCode returns the HTTP status code written to the response.
StatusCode() int
// Get the error while writing.
// WriteError returns the error (if any) from the downstream Write call.
WriteError() error
// BodyBytes returns the captured response body bytes. Only populated
// for error responses (status >= 400).
BodyBytes() []byte
}
func newBadResponseLoggingWriter(rw http.ResponseWriter, buffer io.Writer) badResponseLoggingWriter {
b := nonFlushingBadResponseLoggingWriter{
func newResponseCapture(rw http.ResponseWriter, buffer *byteBuffer) responseCapture {
b := nonFlushingResponseCapture{
rw: rw,
buffer: buffer,
logBody: false,
bodyBytesLeft: maxResponseBodyInLogs,
captureBody: false,
bodyBytesLeft: maxResponseBodyCapture,
statusCode: http.StatusOK,
}
if f, ok := rw.(http.Flusher); ok {
return &flushingBadResponseLoggingWriter{b, f}
return &flushingResponseCapture{nonFlushingResponseCapture: b, f: f}
}
return &b
}
type nonFlushingBadResponseLoggingWriter struct {
rw http.ResponseWriter
buffer io.Writer
logBody bool
bodyBytesLeft int
statusCode int
writeError error // The error returned when downstream Write() fails.
// byteBuffer is a minimal write-only buffer used to capture response bodies.
type byteBuffer struct {
buf []byte
}
// Extends nonFlushingBadResponseLoggingWriter that implements http.Flusher.
type flushingBadResponseLoggingWriter struct {
nonFlushingBadResponseLoggingWriter
func (b *byteBuffer) Write(p []byte) (int, error) {
b.buf = append(b.buf, p...)
return len(p), nil
}
func (b *byteBuffer) WriteString(s string) (int, error) {
b.buf = append(b.buf, s...)
return len(s), nil
}
func (b *byteBuffer) Bytes() []byte {
return b.buf
}
func (b *byteBuffer) Len() int {
return len(b.buf)
}
func (b *byteBuffer) String() string {
return string(b.buf)
}
type nonFlushingResponseCapture struct {
rw http.ResponseWriter
buffer *byteBuffer
captureBody bool
bodyBytesLeft int
statusCode int
writeError error
}
type flushingResponseCapture struct {
nonFlushingResponseCapture
f http.Flusher
}
// Unwrap method is used by http.ResponseController to get access to original http.ResponseWriter.
func (writer *nonFlushingBadResponseLoggingWriter) Unwrap() http.ResponseWriter {
// Unwrap is used by http.ResponseController to get access to original http.ResponseWriter.
func (writer *nonFlushingResponseCapture) Unwrap() http.ResponseWriter {
return writer.rw
}
// Header returns the header map that will be sent by WriteHeader.
// Implements ResponseWriter.
func (writer *nonFlushingBadResponseLoggingWriter) Header() http.Header {
func (writer *nonFlushingResponseCapture) Header() http.Header {
return writer.rw.Header()
}
// WriteHeader writes the HTTP response header.
func (writer *nonFlushingBadResponseLoggingWriter) WriteHeader(statusCode int) {
func (writer *nonFlushingResponseCapture) WriteHeader(statusCode int) {
writer.statusCode = statusCode
if statusCode >= 500 || statusCode == 400 {
writer.logBody = true
if statusCode >= 400 {
writer.captureBody = true
}
writer.rw.WriteHeader(statusCode)
}
// Writes HTTP response data.
func (writer *nonFlushingBadResponseLoggingWriter) Write(data []byte) (int, error) {
// Write writes HTTP response data.
func (writer *nonFlushingResponseCapture) Write(data []byte) (int, error) {
if writer.statusCode == 0 {
// WriteHeader has (probably) not been called, so we need to call it with StatusOK to fulfill the interface contract.
// https://godoc.org/net/http#ResponseWriter
writer.WriteHeader(http.StatusOK)
}
// 204 No Content is a success response that indicates that the request has been successfully processed and that the response body is intentionally empty.
if writer.statusCode == 204 {
return 0, nil
}
n, err := writer.rw.Write(data)
if writer.logBody {
if writer.captureBody {
writer.captureResponseBody(data)
}
if err != nil {
writer.writeError = err
}
return n, err
}
// Hijack hijacks the first response writer that is a Hijacker.
func (writer *nonFlushingBadResponseLoggingWriter) Hijack() (net.Conn, *bufio.ReadWriter, error) {
func (writer *nonFlushingResponseCapture) Hijack() (net.Conn, *bufio.ReadWriter, error) {
hj, ok := writer.rw.(http.Hijacker)
if ok {
return hj.Hijack()
}
return nil, nil, errors.NewInternalf(errors.CodeInternal, "cannot cast underlying response writer to Hijacker")
}
func (writer *nonFlushingBadResponseLoggingWriter) StatusCode() int {
func (writer *nonFlushingResponseCapture) StatusCode() int {
return writer.statusCode
}
func (writer *nonFlushingBadResponseLoggingWriter) WriteError() error {
func (writer *nonFlushingResponseCapture) WriteError() error {
return writer.writeError
}
func (writer *flushingBadResponseLoggingWriter) Flush() {
func (writer *nonFlushingResponseCapture) BodyBytes() []byte {
return writer.buffer.Bytes()
}
func (writer *flushingResponseCapture) Flush() {
writer.f.Flush()
}
func (writer *nonFlushingBadResponseLoggingWriter) captureResponseBody(data []byte) {
func (writer *nonFlushingResponseCapture) captureResponseBody(data []byte) {
if len(data) > writer.bodyBytesLeft {
_, _ = writer.buffer.Write(data[:writer.bodyBytesLeft])
_, _ = io.WriteString(writer.buffer, "...")
_, _ = writer.buffer.WriteString("...")
writer.bodyBytesLeft = 0
writer.logBody = false
writer.captureBody = false
} else {
_, _ = writer.buffer.Write(data)
writer.bodyBytesLeft -= len(data)

View File

@@ -0,0 +1,88 @@
package middleware
import (
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/stretchr/testify/assert"
)
func TestResponseCapture(t *testing.T) {
t.Parallel()
testCases := []struct {
name string
handler http.HandlerFunc
expectedStatus int
expectedBodyBytes string
expectedClientBody string
}{
{
name: "Success_DoesNotCaptureBody",
handler: func(rw http.ResponseWriter, req *http.Request) {
rw.WriteHeader(http.StatusOK)
_, _ = rw.Write([]byte(`{"status":"success","data":{"id":"123"}}`))
},
expectedStatus: http.StatusOK,
expectedBodyBytes: "",
expectedClientBody: `{"status":"success","data":{"id":"123"}}`,
},
{
name: "Error_CapturesBody",
handler: func(rw http.ResponseWriter, req *http.Request) {
rw.WriteHeader(http.StatusForbidden)
_, _ = rw.Write([]byte(`{"status":"error","error":{"code":"authz_forbidden","message":"forbidden"}}`))
},
expectedStatus: http.StatusForbidden,
expectedBodyBytes: `{"status":"error","error":{"code":"authz_forbidden","message":"forbidden"}}`,
expectedClientBody: `{"status":"error","error":{"code":"authz_forbidden","message":"forbidden"}}`,
},
{
name: "Error_TruncatesAtMaxCapture",
handler: func(rw http.ResponseWriter, req *http.Request) {
rw.WriteHeader(http.StatusInternalServerError)
_, _ = rw.Write([]byte(strings.Repeat("x", maxResponseBodyCapture+100)))
},
expectedStatus: http.StatusInternalServerError,
expectedBodyBytes: strings.Repeat("x", maxResponseBodyCapture) + "...",
expectedClientBody: strings.Repeat("x", maxResponseBodyCapture+100),
},
{
name: "NoContent_SuppressesWrite",
handler: func(rw http.ResponseWriter, req *http.Request) {
rw.WriteHeader(http.StatusNoContent)
_, _ = rw.Write([]byte("should be suppressed"))
},
expectedStatus: http.StatusNoContent,
expectedBodyBytes: "",
expectedClientBody: "",
},
}
for _, testCase := range testCases {
t.Run(testCase.name, func(t *testing.T) {
t.Parallel()
var captured responseCapture
server := httptest.NewServer(http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) {
buf := &byteBuffer{}
captured = newResponseCapture(rw, buf)
testCase.handler(captured, req)
}))
defer server.Close()
resp, err := http.Get(server.URL)
assert.NoError(t, err)
defer resp.Body.Close()
clientBody, _ := io.ReadAll(resp.Body)
assert.Equal(t, testCase.expectedStatus, captured.StatusCode())
assert.Equal(t, testCase.expectedBodyBytes, string(captured.BodyBytes()))
assert.Equal(t, testCase.expectedClientBody, string(clientBody))
})
}
}

View File

@@ -5,6 +5,7 @@ import (
"github.com/SigNoz/signoz/pkg/errors"
jsoniter "github.com/json-iterator/go"
"github.com/tidwall/gjson"
)
const (
@@ -42,6 +43,45 @@ func Success(rw http.ResponseWriter, httpCode int, data interface{}) {
_, _ = rw.Write(body)
}
func ErrorCodeFromBody(body []byte) string {
code := gjson.GetBytes(body, "error.code").String()
// This should never return empty since we only call this function on responses that were generated by us.
// If it does return empty, the codebase has failed to use render package for error responses somewhere, and we should fix that instead of trying to handle it here.
if code == "" {
return errors.CodeUnset.String()
}
return code
}
func ErrorTypeFromStatusCode(statusCode int) string {
// We are losing the exact type information here, but we can at least capture the error code and message for better observability.
// To get the exact type, we would need some changes in the render package to include the error type in the response, which we can consider in the future if there is a need for it.
switch statusCode {
case http.StatusBadRequest:
return errors.TypeInvalidInput.String()
case http.StatusNotFound:
return errors.TypeNotFound.String()
case http.StatusConflict:
return errors.TypeAlreadyExists.String()
case http.StatusUnauthorized:
return errors.TypeUnauthenticated.String()
case http.StatusNotImplemented:
return errors.TypeUnsupported.String()
case http.StatusForbidden:
return errors.TypeForbidden.String()
case statusClientClosedConnection:
return errors.TypeCanceled.String()
case http.StatusGatewayTimeout:
return errors.TypeTimeout.String()
case http.StatusUnavailableForLegalReasons:
return errors.TypeLicenseUnavailable.String()
default:
return errors.TypeInternal.String()
}
}
func Error(rw http.ResponseWriter, cause error) {
// Derive the http code from the error type
t, _, _, _, _, _ := errors.Unwrapb(cause)

View File

@@ -58,6 +58,31 @@ func TestSuccess(t *testing.T) {
assert.Equal(t, expected, actual)
}
func TestErrorCodeFromBody(t *testing.T) {
testCases := []struct {
name string
body []byte
wantCode string
}{
{
name: "ValidErrorResponse",
body: []byte(`{"status":"error","error":{"code":"authz_forbidden","message":"only admins can access this resource"}}`),
wantCode: "authz_forbidden",
},
{
name: "InvalidJSON",
body: []byte(`not json`),
wantCode: "unset",
},
}
for _, testCase := range testCases {
t.Run(testCase.name, func(t *testing.T) {
assert.Equal(t, testCase.wantCode, ErrorCodeFromBody(testCase.body))
})
}
}
func TestError(t *testing.T) {
listener, err := net.Listen("tcp", "localhost:0")
require.NoError(t, err)

View File

@@ -208,7 +208,7 @@ func (s *Server) createPublicServer(api *APIHandler, web web.Web) (*http.Server,
s.config.APIServer.Timeout.Default,
s.config.APIServer.Timeout.Max,
).Wrap)
r.Use(middleware.NewLogging(s.signoz.Instrumentation.Logger(), s.config.APIServer.Logging.ExcludedRoutes).Wrap)
r.Use(middleware.NewAudit(s.signoz.Instrumentation.Logger(), s.config.APIServer.Logging.ExcludedRoutes, nil).Wrap)
r.Use(middleware.NewComment().Wrap)
am := middleware.NewAuthZ(s.signoz.Instrumentation.Logger(), s.signoz.Modules.OrgGetter, s.signoz.Authz)

View File

@@ -0,0 +1,206 @@
package audittypes
import (
"net/http"
"strings"
"github.com/SigNoz/signoz/pkg/types/authtypes"
"github.com/SigNoz/signoz/pkg/valuer"
"go.opentelemetry.io/collector/pdata/pcommon"
semconv "go.opentelemetry.io/otel/semconv/v1.26.0"
)
// Audit attributes — Action (What).
type AuditAttributes struct {
Action Action // guaranteed to be present
ActionCategory ActionCategory // guaranteed to be present
Outcome Outcome // guaranteed to be present
IdentNProvider authtypes.IdentNProvider
}
func NewAuditAttributesFromHTTP(statusCode int, action Action, category ActionCategory, claims authtypes.Claims) AuditAttributes {
outcome := OutcomeFailure
if statusCode >= 200 && statusCode < 400 {
outcome = OutcomeSuccess
}
return AuditAttributes{
Action: action,
ActionCategory: category,
Outcome: outcome,
IdentNProvider: claims.IdentNProvider,
}
}
func (attributes AuditAttributes) Put(dest pcommon.Map) {
dest.PutStr("signoz.audit.action", attributes.Action.StringValue())
dest.PutStr("signoz.audit.action_category", attributes.ActionCategory.StringValue())
dest.PutStr("signoz.audit.outcome", attributes.Outcome.StringValue())
putStrIfNotEmpty(dest, "signoz.audit.identn_provider", attributes.IdentNProvider.StringValue())
}
// Audit attributes — Principal (Who).
type PrincipalAttributes struct {
PrincipalType authtypes.Principal
PrincipalID valuer.UUID
PrincipalEmail valuer.Email
PrincipalOrgID valuer.UUID
}
func NewPrincipalAttributesFromClaims(claims authtypes.Claims) PrincipalAttributes {
principalID, _ := valuer.NewUUID(claims.UserID)
principalEmail, _ := valuer.NewEmail(claims.Email)
principalOrgID, _ := valuer.NewUUID(claims.OrgID)
return PrincipalAttributes{
PrincipalType: claims.Principal,
PrincipalID: principalID,
PrincipalEmail: principalEmail,
PrincipalOrgID: principalOrgID,
}
}
func (attributes PrincipalAttributes) Put(dest pcommon.Map) {
dest.PutStr("signoz.audit.principal.id", attributes.PrincipalID.StringValue())
dest.PutStr("signoz.audit.principal.email", attributes.PrincipalEmail.String())
dest.PutStr("signoz.audit.principal.type", attributes.PrincipalType.StringValue())
dest.PutStr("signoz.audit.principal.org_id", attributes.PrincipalOrgID.StringValue())
}
// Audit attributes — Resource (On What).
type ResourceAttributes struct {
ResourceID string
ResourceName string // guaranteed to be present
}
func NewResourceAttributes(resourceID, resourceName string) ResourceAttributes {
return ResourceAttributes{
ResourceID: resourceID,
ResourceName: resourceName,
}
}
func (attributes ResourceAttributes) Put(dest pcommon.Map) {
putStrIfNotEmpty(dest, "signoz.audit.resource.name", attributes.ResourceName)
putStrIfNotEmpty(dest, "signoz.audit.resource.id", attributes.ResourceID)
}
// Audit attributes — Error (When outcome is failure)
// Error messages are intentionally excluded to avoid leaking sensitive or
// PII data into audit logs. The error type and code are sufficient for
// filtering and alerting; investigators can correlate via trace ID.
type ErrorAttributes struct {
ErrorType string
ErrorCode string
}
func NewErrorAttributes(errorType, errorCode string) ErrorAttributes {
return ErrorAttributes{
ErrorType: errorType,
ErrorCode: errorCode,
}
}
func (attributes ErrorAttributes) Put(dest pcommon.Map) {
putStrIfNotEmpty(dest, "signoz.audit.error.type", attributes.ErrorType)
putStrIfNotEmpty(dest, "signoz.audit.error.code", attributes.ErrorCode)
}
// Audit attributes — Transport Context (Where/How).
type TransportAttributes struct {
HTTPMethod string
HTTPRoute string
HTTPStatusCode int
URLPath string
ClientAddress string
UserAgent string
}
func NewTransportAttributesFromHTTP(req *http.Request, route string, statusCode int) TransportAttributes {
return TransportAttributes{
HTTPMethod: req.Method,
HTTPRoute: route,
HTTPStatusCode: statusCode,
URLPath: req.URL.Path,
ClientAddress: req.RemoteAddr,
UserAgent: req.UserAgent(),
}
}
func (attributes TransportAttributes) Put(dest pcommon.Map) {
putStrIfNotEmpty(dest, string(semconv.HTTPRequestMethodKey), attributes.HTTPMethod)
putStrIfNotEmpty(dest, string(semconv.HTTPRouteKey), attributes.HTTPRoute)
if attributes.HTTPStatusCode != 0 {
dest.PutInt(string(semconv.HTTPResponseStatusCodeKey), int64(attributes.HTTPStatusCode))
}
putStrIfNotEmpty(dest, string(semconv.URLPathKey), attributes.URLPath)
putStrIfNotEmpty(dest, string(semconv.ClientAddressKey), attributes.ClientAddress)
putStrIfNotEmpty(dest, string(semconv.UserAgentOriginalKey), attributes.UserAgent)
}
func putStrIfNotEmpty(attrs pcommon.Map, key, value string) {
if value != "" {
attrs.PutStr(key, value)
}
}
func newBody(auditAttributes AuditAttributes, principalAttributes PrincipalAttributes, resourceAttributes ResourceAttributes, errorAttributes ErrorAttributes) string {
var b strings.Builder
// Principal: "email (id)" or "id" or "email" or omitted.
hasEmail := principalAttributes.PrincipalEmail.String() != ""
hasID := !principalAttributes.PrincipalID.IsZero()
if hasEmail {
b.WriteString(principalAttributes.PrincipalEmail.String())
if hasID {
b.WriteString(" (")
b.WriteString(principalAttributes.PrincipalID.StringValue())
b.WriteString(")")
}
} else if hasID {
b.WriteString(principalAttributes.PrincipalID.StringValue())
}
// Action: " created" or " failed to create".
if b.Len() > 0 {
b.WriteString(" ")
}
if auditAttributes.Outcome == OutcomeSuccess {
b.WriteString(auditAttributes.Action.PastTense())
} else {
b.WriteString("failed to ")
b.WriteString(auditAttributes.Action.StringValue())
}
// Resource: " name (id)" or " name".
b.WriteString(" ")
b.WriteString(resourceAttributes.ResourceName)
if resourceAttributes.ResourceID != "" {
b.WriteString(" (")
b.WriteString(resourceAttributes.ResourceID)
b.WriteString(")")
}
// Error suffix (failure only): ": type (code)" or ": type" or ": (code)" or omitted.
if auditAttributes.Outcome == OutcomeFailure {
errorType := errorAttributes.ErrorType
errorCode := errorAttributes.ErrorCode
if errorType != "" || errorCode != "" {
b.WriteString(": ")
if errorType != "" && errorCode != "" {
b.WriteString(errorType)
b.WriteString(" (")
b.WriteString(errorCode)
b.WriteString(")")
} else if errorType != "" {
b.WriteString(errorType)
} else {
b.WriteString("(")
b.WriteString(errorCode)
b.WriteString(")")
}
}
}
return b.String()
}

View File

@@ -0,0 +1,203 @@
package audittypes
import (
"testing"
"github.com/SigNoz/signoz/pkg/types/authtypes"
"github.com/SigNoz/signoz/pkg/valuer"
"github.com/stretchr/testify/assert"
)
func TestNewAuditAttributesFromHTTP_OutcomeBoundary(t *testing.T) {
claims := authtypes.Claims{IdentNProvider: authtypes.IdentNProviderTokenizer}
testCases := []struct {
name string
statusCode int
expectedOutcome Outcome
}{
{
name: "200_Success",
statusCode: 200,
expectedOutcome: OutcomeSuccess,
},
{
name: "399_Success",
statusCode: 399,
expectedOutcome: OutcomeSuccess,
},
{
name: "400_Failure",
statusCode: 400,
expectedOutcome: OutcomeFailure,
},
}
for _, testCase := range testCases {
t.Run(testCase.name, func(t *testing.T) {
attrs := NewAuditAttributesFromHTTP(testCase.statusCode, ActionUpdate, ActionCategoryConfigurationChange, claims)
assert.Equal(t, testCase.expectedOutcome, attrs.Outcome)
})
}
}
func TestNewBody(t *testing.T) {
testCases := []struct {
name string
auditAttributes AuditAttributes
principalAttributes PrincipalAttributes
resourceAttributes ResourceAttributes
errorAttributes ErrorAttributes
expectedBody string
}{
{
name: "Success_EmptyResourceID",
auditAttributes: AuditAttributes{
Action: ActionDelete,
ActionCategory: ActionCategoryConfigurationChange,
Outcome: OutcomeSuccess,
},
principalAttributes: PrincipalAttributes{
PrincipalID: valuer.MustNewUUID("019a1234-abcd-7000-8000-567800000001"),
PrincipalEmail: valuer.MustNewEmail("test@acme.com"),
},
resourceAttributes: ResourceAttributes{
ResourceID: "",
ResourceName: "dashboard",
},
errorAttributes: ErrorAttributes{},
expectedBody: "test@acme.com (019a1234-abcd-7000-8000-567800000001) deleted dashboard",
},
{
name: "Success_EmptyPrincipalEmail",
auditAttributes: AuditAttributes{
Action: ActionDelete,
ActionCategory: ActionCategoryConfigurationChange,
Outcome: OutcomeSuccess,
},
principalAttributes: PrincipalAttributes{
PrincipalID: valuer.MustNewUUID("019a1234-abcd-7000-8000-567800000001"),
PrincipalEmail: valuer.Email{},
},
resourceAttributes: ResourceAttributes{
ResourceID: "abd",
ResourceName: "dashboard",
},
errorAttributes: ErrorAttributes{},
expectedBody: "019a1234-abcd-7000-8000-567800000001 deleted dashboard (abd)",
},
{
name: "Success_EmptyPrincipalIDandEmail",
auditAttributes: AuditAttributes{
Action: ActionDelete,
ActionCategory: ActionCategoryConfigurationChange,
Outcome: OutcomeSuccess,
},
principalAttributes: PrincipalAttributes{
PrincipalID: valuer.UUID{},
PrincipalEmail: valuer.Email{},
},
resourceAttributes: ResourceAttributes{
ResourceID: "abd",
ResourceName: "dashboard",
},
errorAttributes: ErrorAttributes{},
expectedBody: "deleted dashboard (abd)",
},
{
name: "Success_AllPresent",
auditAttributes: AuditAttributes{
Action: ActionCreate,
ActionCategory: ActionCategoryConfigurationChange,
Outcome: OutcomeSuccess,
},
principalAttributes: PrincipalAttributes{
PrincipalID: valuer.MustNewUUID("019a1234-abcd-7000-8000-567800000001"),
PrincipalEmail: valuer.MustNewEmail("alice@acme.com"),
},
resourceAttributes: ResourceAttributes{
ResourceID: "019b-5678",
ResourceName: "dashboard",
},
errorAttributes: ErrorAttributes{},
expectedBody: "alice@acme.com (019a1234-abcd-7000-8000-567800000001) created dashboard (019b-5678)",
},
{
name: "Success_EmptyEverythingOptional",
auditAttributes: AuditAttributes{
Action: ActionUpdate,
ActionCategory: ActionCategoryConfigurationChange,
Outcome: OutcomeSuccess,
},
principalAttributes: PrincipalAttributes{},
resourceAttributes: ResourceAttributes{
ResourceName: "alert-rule",
},
errorAttributes: ErrorAttributes{},
expectedBody: "updated alert-rule",
},
{
name: "Failure_AllPresent",
auditAttributes: AuditAttributes{
Action: ActionUpdate,
ActionCategory: ActionCategoryConfigurationChange,
Outcome: OutcomeFailure,
},
principalAttributes: PrincipalAttributes{
PrincipalID: valuer.MustNewUUID("019aaaaa-bbbb-7000-8000-cccc00000002"),
PrincipalEmail: valuer.MustNewEmail("viewer@acme.com"),
},
resourceAttributes: ResourceAttributes{
ResourceID: "019b-5678",
ResourceName: "dashboard",
},
errorAttributes: ErrorAttributes{
ErrorType: "forbidden",
ErrorCode: "authz_forbidden",
},
expectedBody: "viewer@acme.com (019aaaaa-bbbb-7000-8000-cccc00000002) failed to update dashboard (019b-5678): forbidden (authz_forbidden)",
},
{
name: "Failure_ErrorTypeOnly",
auditAttributes: AuditAttributes{
Action: ActionDelete,
Outcome: OutcomeFailure,
},
principalAttributes: PrincipalAttributes{
PrincipalID: valuer.MustNewUUID("019a1234-abcd-7000-8000-567800000001"),
PrincipalEmail: valuer.MustNewEmail("test@acme.com"),
},
resourceAttributes: ResourceAttributes{
ResourceName: "user",
},
errorAttributes: ErrorAttributes{
ErrorType: "not-found",
},
expectedBody: "test@acme.com (019a1234-abcd-7000-8000-567800000001) failed to delete user: not-found",
},
{
name: "Failure_NoErrorDetails",
auditAttributes: AuditAttributes{
Action: ActionCreate,
Outcome: OutcomeFailure,
},
principalAttributes: PrincipalAttributes{
PrincipalID: valuer.MustNewUUID("019a1234-abcd-7000-8000-567800000001"),
PrincipalEmail: valuer.MustNewEmail("test@acme.com"),
},
resourceAttributes: ResourceAttributes{
ResourceID: "019b-5678",
ResourceName: "dashboard",
},
errorAttributes: ErrorAttributes{},
expectedBody: "test@acme.com (019a1234-abcd-7000-8000-567800000001) failed to create dashboard (019b-5678)",
},
}
for _, testCase := range testCases {
t.Run(testCase.name, func(t *testing.T) {
body := newBody(testCase.auditAttributes, testCase.principalAttributes, testCase.resourceAttributes, testCase.errorAttributes)
assert.Equal(t, testCase.expectedBody, body)
})
}
}

View File

@@ -1,54 +1,80 @@
package audittypes
import (
"encoding/hex"
"fmt"
"net/http"
"time"
"github.com/SigNoz/signoz/pkg/valuer"
"github.com/SigNoz/signoz/pkg/types/authtypes"
"go.opentelemetry.io/collector/pdata/pcommon"
"go.opentelemetry.io/collector/pdata/plog"
semconv "go.opentelemetry.io/otel/semconv/v1.10.0"
semconv "go.opentelemetry.io/otel/semconv/v1.26.0"
oteltrace "go.opentelemetry.io/otel/trace"
)
// AuditEvent represents a single audit log event.
// Fields are ordered following the OTel LogRecord structure.
type AuditEvent struct {
// OTel LogRecord intrinsic fields
Timestamp time.Time `json:"timestamp"`
TraceID string `json:"traceId,omitempty"`
SpanID string `json:"spanId,omitempty"`
Body string `json:"body"`
EventName EventName `json:"eventName"`
// OTel LogRecord Intrinsic
Timestamp time.Time
// Audit attributes — Principal (Who)
PrincipalID valuer.UUID `json:"principalId"`
PrincipalEmail valuer.Email `json:"principalEmail"`
PrincipalType PrincipalType `json:"principalType"`
PrincipalOrgID valuer.UUID `json:"principalOrgId"`
IdentNProvider string `json:"identnProvider,omitempty"`
// OTel LogRecord Intrinsic
TraceID oteltrace.TraceID
// Audit attributes — Action (What)
Action Action `json:"action"`
ActionCategory ActionCategory `json:"actionCategory"`
Outcome Outcome `json:"outcome"`
// OTel LogRecord Intrinsic
SpanID oteltrace.SpanID
// Audit attributes — Resource (On What)
ResourceName string `json:"resourceName"`
ResourceID string `json:"resourceId,omitempty"`
// OTel LogRecord Intrinsic
Body string
// Audit attributes — Error (When outcome is failure)
ErrorType string `json:"errorType,omitempty"`
ErrorCode string `json:"errorCode,omitempty"`
ErrorMessage string `json:"errorMessage,omitempty"`
// OTel LogRecord Intrinsic
EventName EventName
// Transport Context (Where/How)
HTTPMethod string `json:"httpMethod,omitempty"`
HTTPRoute string `json:"httpRoute,omitempty"`
HTTPStatusCode int `json:"httpStatusCode,omitempty"`
URLPath string `json:"urlPath,omitempty"`
ClientAddress string `json:"clientAddress,omitempty"`
UserAgent string `json:"userAgent,omitempty"`
// Custom Audit Attributes - Action
AuditAttributes AuditAttributes
// Custom Audit Attributes - Principal
PrincipalAttributes PrincipalAttributes
// Custom Audit Attributes - Resource
ResourceAttributes ResourceAttributes
// Custom Audit Attributes - Error
ErrorAttributes ErrorAttributes
// Custom Audit Attributes - Transport Context
TransportAttributes TransportAttributes
}
func NewAuditEventFromHTTPRequest(
req *http.Request,
route string,
statusCode int,
traceID oteltrace.TraceID,
spanID oteltrace.SpanID,
action Action,
actionCategory ActionCategory,
claims authtypes.Claims,
resourceID string,
resourceName string,
errorType string,
errorCode string,
) AuditEvent {
auditAttributes := NewAuditAttributesFromHTTP(statusCode, action, actionCategory, claims)
principalAttributes := NewPrincipalAttributesFromClaims(claims)
resourceAttributes := NewResourceAttributes(resourceID, resourceName)
errorAttributes := NewErrorAttributes(errorType, errorCode)
transportAttributes := NewTransportAttributesFromHTTP(req, route, statusCode)
return AuditEvent{
Timestamp: time.Now(),
TraceID: traceID,
SpanID: spanID,
Body: newBody(auditAttributes, principalAttributes, resourceAttributes, errorAttributes),
EventName: NewEventName(resourceAttributes.ResourceName, auditAttributes.Action),
AuditAttributes: auditAttributes,
PrincipalAttributes: principalAttributes,
ResourceAttributes: resourceAttributes,
ErrorAttributes: errorAttributes,
TransportAttributes: transportAttributes,
}
}
func NewPLogsFromAuditEvents(events []AuditEvent, name string, version string, scope string) plog.Logs {
@@ -68,88 +94,41 @@ func NewPLogsFromAuditEvents(events []AuditEvent, name string, version string, s
}
func (event AuditEvent) ToLogRecord(dest plog.LogRecord) {
// Set timestamps
dest.SetTimestamp(pcommon.NewTimestampFromTime(event.Timestamp))
dest.SetObservedTimestamp(pcommon.NewTimestampFromTime(event.Timestamp))
dest.Body().SetStr(event.setBody())
dest.SetEventName(event.EventName.String())
dest.SetSeverityNumber(event.Outcome.Severity())
dest.SetSeverityText(event.Outcome.SeverityText())
if tid, ok := parseTraceID(event.TraceID); ok {
dest.SetTraceID(tid)
// Set body and event name
dest.Body().SetStr(event.Body)
dest.SetEventName(event.EventName.String())
// Set severity based on outcome
dest.SetSeverityNumber(event.AuditAttributes.Outcome.Severity())
dest.SetSeverityText(event.AuditAttributes.Outcome.SeverityText())
// Set trace and span IDs if present
if event.TraceID.IsValid() {
dest.SetTraceID(pcommon.TraceID(event.TraceID))
}
if sid, ok := parseSpanID(event.SpanID); ok {
dest.SetSpanID(sid)
if event.SpanID.IsValid() {
dest.SetSpanID(pcommon.SpanID(event.SpanID))
}
attrs := dest.Attributes()
// Principal attributes
attrs.PutStr("signoz.audit.principal.id", event.PrincipalID.StringValue())
attrs.PutStr("signoz.audit.principal.email", event.PrincipalEmail.String())
attrs.PutStr("signoz.audit.principal.type", event.PrincipalType.StringValue())
attrs.PutStr("signoz.audit.principal.org_id", event.PrincipalOrgID.StringValue())
putStrIfNotEmpty(attrs, "signoz.audit.identn_provider", event.IdentNProvider)
// Audit attributes
event.AuditAttributes.Put(attrs)
// Action attributes
attrs.PutStr("signoz.audit.action", event.Action.StringValue())
attrs.PutStr("signoz.audit.action_category", event.ActionCategory.StringValue())
attrs.PutStr("signoz.audit.outcome", event.Outcome.StringValue())
// Principal attributes
event.PrincipalAttributes.Put(attrs)
// Resource attributes
attrs.PutStr("signoz.audit.resource.name", event.ResourceName)
putStrIfNotEmpty(attrs, "signoz.audit.resource.id", event.ResourceID)
event.ResourceAttributes.Put(attrs)
// Error attributes (on failure)
putStrIfNotEmpty(attrs, "signoz.audit.error.type", event.ErrorType)
putStrIfNotEmpty(attrs, "signoz.audit.error.code", event.ErrorCode)
putStrIfNotEmpty(attrs, "signoz.audit.error.message", event.ErrorMessage)
// Error attributes
event.ErrorAttributes.Put(attrs)
// Transport context attributes
putStrIfNotEmpty(attrs, "http.request.method", event.HTTPMethod)
putStrIfNotEmpty(attrs, "http.route", event.HTTPRoute)
if event.HTTPStatusCode != 0 {
attrs.PutInt("http.response.status_code", int64(event.HTTPStatusCode))
}
putStrIfNotEmpty(attrs, "url.path", event.URLPath)
putStrIfNotEmpty(attrs, "client.address", event.ClientAddress)
putStrIfNotEmpty(attrs, "user_agent.original", event.UserAgent)
}
func (event AuditEvent) setBody() string {
if event.Outcome == OutcomeSuccess {
return fmt.Sprintf("%s (%s) %s %s %s", event.PrincipalEmail, event.PrincipalID, event.Action.PastTense(), event.ResourceName, event.ResourceID)
}
return fmt.Sprintf("%s (%s) failed to %s %s %s: %s (%s)", event.PrincipalEmail, event.PrincipalID, event.Action.StringValue(), event.ResourceName, event.ResourceID, event.ErrorType, event.ErrorCode)
}
func putStrIfNotEmpty(attrs pcommon.Map, key, value string) {
if value != "" {
attrs.PutStr(key, value)
}
}
func parseTraceID(s string) (pcommon.TraceID, bool) {
b, err := hex.DecodeString(s)
if err != nil || len(b) != 16 {
return pcommon.TraceID{}, false
}
var tid pcommon.TraceID
copy(tid[:], b)
return tid, true
}
func parseSpanID(s string) (pcommon.SpanID, bool) {
b, err := hex.DecodeString(s)
if err != nil || len(b) != 8 {
return pcommon.SpanID{}, false
}
var sid pcommon.SpanID
copy(sid[:], b)
return sid, true
event.TransportAttributes.Put(attrs)
}

View File

@@ -0,0 +1,99 @@
package audittypes
import (
"net/http"
"net/http/httptest"
"testing"
"github.com/SigNoz/signoz/pkg/types/authtypes"
"github.com/stretchr/testify/assert"
oteltrace "go.opentelemetry.io/otel/trace"
)
func TestNewAuditEventFromHTTPRequest(t *testing.T) {
traceID := oteltrace.TraceID{1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16}
spanID := oteltrace.SpanID{1, 2, 3, 4, 5, 6, 7, 8}
testCases := []struct {
name string
method string
path string
route string
statusCode int
action Action
category ActionCategory
claims authtypes.Claims
resourceID string
resourceName string
errorType string
errorCode string
expectedOutcome Outcome
expectedBody string
}{
{
name: "Success_DashboardCreated",
method: http.MethodPost,
path: "/api/v1/dashboards",
route: "/api/v1/dashboards",
statusCode: http.StatusOK,
action: ActionCreate,
category: ActionCategoryConfigurationChange,
claims: authtypes.Claims{UserID: "019a1234-abcd-7000-8000-567800000001", Email: "alice@acme.com", OrgID: "019a-0000-0000-0001", IdentNProvider: authtypes.IdentNProviderTokenizer},
resourceID: "019b-5678-efgh-9012",
resourceName: "dashboard",
expectedOutcome: OutcomeSuccess,
expectedBody: "alice@acme.com (019a1234-abcd-7000-8000-567800000001) created dashboard (019b-5678-efgh-9012)",
},
{
name: "Failure_ForbiddenDashboardUpdate",
method: http.MethodPut,
path: "/api/v1/dashboards/019b-5678-efgh-9012",
route: "/api/v1/dashboards/{id}",
statusCode: http.StatusForbidden,
action: ActionUpdate,
category: ActionCategoryConfigurationChange,
claims: authtypes.Claims{UserID: "019aaaaa-bbbb-7000-8000-cccc00000002", Email: "viewer@acme.com", OrgID: "019a-0000-0000-0001", IdentNProvider: authtypes.IdentNProviderTokenizer},
resourceID: "019b-5678-efgh-9012",
resourceName: "dashboard",
errorType: "forbidden",
errorCode: "authz_forbidden",
expectedOutcome: OutcomeFailure,
expectedBody: "viewer@acme.com (019aaaaa-bbbb-7000-8000-cccc00000002) failed to update dashboard (019b-5678-efgh-9012): forbidden (authz_forbidden)",
},
}
for _, testCase := range testCases {
t.Run(testCase.name, func(t *testing.T) {
req := httptest.NewRequest(testCase.method, testCase.path, nil)
event := NewAuditEventFromHTTPRequest(
req,
testCase.route,
testCase.statusCode,
traceID,
spanID,
testCase.action,
testCase.category,
testCase.claims,
testCase.resourceID,
testCase.resourceName,
testCase.errorType,
testCase.errorCode,
)
assert.Equal(t, testCase.expectedOutcome, event.AuditAttributes.Outcome)
assert.Equal(t, testCase.expectedBody, event.Body)
assert.Equal(t, testCase.resourceName, event.ResourceAttributes.ResourceName)
assert.Equal(t, testCase.resourceID, event.ResourceAttributes.ResourceID)
assert.Equal(t, testCase.action, event.AuditAttributes.Action)
assert.Equal(t, testCase.category, event.AuditAttributes.ActionCategory)
assert.Equal(t, testCase.route, event.TransportAttributes.HTTPRoute)
assert.Equal(t, testCase.statusCode, event.TransportAttributes.HTTPStatusCode)
assert.Equal(t, testCase.method, event.TransportAttributes.HTTPMethod)
assert.Equal(t, traceID, event.TraceID)
assert.Equal(t, spanID, event.SpanID)
assert.Equal(t, testCase.errorType, event.ErrorAttributes.ErrorType)
assert.Equal(t, testCase.errorCode, event.ErrorAttributes.ErrorCode)
})
}
}