mirror of
https://github.com/SigNoz/signoz.git
synced 2026-04-01 10:00:20 +01:00
Compare commits
19 Commits
refactor/c
...
platform-p
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
be964a61da | ||
|
|
30062b5a21 | ||
|
|
90186383d2 | ||
|
|
7057a734af | ||
|
|
400b921ad0 | ||
|
|
b1e4723b48 | ||
|
|
8ef68bcb53 | ||
|
|
4afff35d59 | ||
|
|
c9e52acceb | ||
|
|
e416836787 | ||
|
|
e92e3b3cca | ||
|
|
26bad4a617 | ||
|
|
0fb8043396 | ||
|
|
ee06840969 | ||
|
|
57dbceee49 | ||
|
|
4aef46c2c5 | ||
|
|
70def6e2e5 | ||
|
|
71dbc06450 | ||
|
|
2e3a4375f5 |
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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_]+$`)
|
||||
)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
24
pkg/http/handler/option.go
Normal file
24
pkg/http/handler/option.go
Normal 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
|
||||
}
|
||||
}
|
||||
169
pkg/http/middleware/audit.go
Normal file
169
pkg/http/middleware/audit.go
Normal 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]
|
||||
}
|
||||
@@ -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...)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
88
pkg/http/middleware/response_test.go
Normal file
88
pkg/http/middleware/response_test.go
Normal 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))
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
206
pkg/types/audittypes/attributes.go
Normal file
206
pkg/types/audittypes/attributes.go
Normal 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()
|
||||
}
|
||||
203
pkg/types/audittypes/attributes_test.go
Normal file
203
pkg/types/audittypes/attributes_test.go
Normal 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)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
99
pkg/types/audittypes/event_test.go
Normal file
99
pkg/types/audittypes/event_test.go
Normal 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)
|
||||
})
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user