Compare commits

...

9 Commits

Author SHA1 Message Date
grandwizard28
fa599a3f28 test(auditor): move shared helpers from suite conftest to fixtures/auditor.py
Mirrors the existing fixtures/audit.py / fixtures/auth.py / fixtures/signoz.py
layout — domain-named module under fixtures/ registered via pytest_plugins,
suite-local conftest.py kept thin (only the signoz fixture override that
configures the auditor container).
2026-04-26 16:14:20 +05:30
grandwizard28
2c57b63164 test(auditor): bind-mount audit dir and atomic batch write
Mirror the sqlite/clickhouse pattern: the audit log file lives in a tmpfs
directory bind-mounted into the SigNoz container at the same absolute path,
so tests read it via a plain open() instead of docker exec. The directory
path is cached via reuse.wrap so the long-lived auditor container under
--reuse keeps using the matching mount across pytest runs.

The file provider's export now writes payload + newline in a single Write
call so a concurrent host reader cannot observe a torn JSON object.
2026-04-26 16:03:13 +05:30
grandwizard28
d2ece5ce01 test(auditor): integration tests for file provider event emission 2026-04-26 15:44:46 +05:30
grandwizard28
cbbe3a2dd6 feat(auditor): add file provider for audit logs 2026-04-25 17:49:35 +05:30
grandwizard28
bcc6511341 feat(apiserver): instrument rule and planned-maintenance routes with AuditDef 2026-04-25 15:47:36 +05:30
grandwizard28
9d3b8ba18d feat(apiserver): instrument service account API key routes with AuditDef 2026-04-25 15:36:08 +05:30
grandwizard28
9c01d0aa0e feat(apiserver): instrument user v2 P0 routes with AuditDef 2026-04-25 15:30:27 +05:30
grandwizard28
9774a2e476 style(apiserver): split handler.New args onto separate lines for session routes 2026-04-25 15:16:36 +05:30
grandwizard28
722b7c22cf feat(apiserver): instrument session P0 routes with AuditDef 2026-04-25 15:13:25 +05:30
15 changed files with 1026 additions and 252 deletions

View File

@@ -8,6 +8,7 @@ import (
"github.com/spf13/cobra"
"github.com/SigNoz/signoz/cmd"
"github.com/SigNoz/signoz/ee/auditor/fileauditor"
"github.com/SigNoz/signoz/ee/auditor/otlphttpauditor"
"github.com/SigNoz/signoz/ee/authn/callbackauthn/oidccallbackauthn"
"github.com/SigNoz/signoz/ee/authn/callbackauthn/samlcallbackauthn"
@@ -155,6 +156,9 @@ func runServer(ctx context.Context, config signoz.Config, logger *slog.Logger) e
if err := factories.Add(otlphttpauditor.NewFactory(licensing, version.Info)); err != nil {
panic(err)
}
if err := factories.Add(fileauditor.NewFactory(licensing, version.Info)); err != nil {
panic(err)
}
return factories
},
func(ps factory.ProviderSettings, q querier.Querier, a analytics.Analytics) querier.Handler {

View File

@@ -0,0 +1,38 @@
package fileauditor
import (
"context"
"log/slog"
"github.com/SigNoz/signoz/pkg/auditor"
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/types/audittypes"
)
// export converts a batch of audit events to OTLP-JSON log records and appends
// the encoded payload to the configured file as one NDJSON line per batch.
// Mirrors the wire format that otlphttpauditor sends, so downstream tools can
// consume both transports interchangeably.
func (provider *provider) export(ctx context.Context, events []audittypes.AuditEvent) error {
logs := audittypes.NewPLogsFromAuditEvents(events, "signoz", provider.build.Version(), "signoz.audit")
payload, err := provider.marshaler.MarshalLogs(logs)
if err != nil {
return errors.Wrapf(err, errors.TypeInternal, auditor.ErrCodeAuditExportFailed, "failed to marshal audit logs")
}
// Combine the payload and trailing newline into one Write call so the line
// is emitted in a single syscall — concurrent readers see either the full
// line or nothing, never a torn JSON object.
payload = append(payload, '\n')
provider.mu.Lock()
defer provider.mu.Unlock()
if _, err := provider.file.Write(payload); err != nil {
provider.settings.Logger().ErrorContext(ctx, "audit export failed", errors.Attr(err), slog.Int("dropped_log_records", len(events)))
return errors.Wrapf(err, errors.TypeInternal, auditor.ErrCodeAuditExportFailed, "failed to write audit logs")
}
return provider.file.Sync()
}

View File

@@ -0,0 +1,99 @@
package fileauditor
import (
"context"
"os"
"sync"
"github.com/SigNoz/signoz/pkg/auditor"
"github.com/SigNoz/signoz/pkg/auditor/auditorserver"
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/factory"
"github.com/SigNoz/signoz/pkg/licensing"
"github.com/SigNoz/signoz/pkg/types/audittypes"
"github.com/SigNoz/signoz/pkg/version"
"go.opentelemetry.io/collector/pdata/plog"
)
var _ auditor.Auditor = (*provider)(nil)
type provider struct {
settings factory.ScopedProviderSettings
config auditor.Config
licensing licensing.Licensing
build version.Build
server *auditorserver.Server
marshaler plog.JSONMarshaler
file *os.File
mu sync.Mutex
}
func NewFactory(licensing licensing.Licensing, build version.Build) factory.ProviderFactory[auditor.Auditor, auditor.Config] {
return factory.NewProviderFactory(factory.MustNewName("file"), func(ctx context.Context, providerSettings factory.ProviderSettings, config auditor.Config) (auditor.Auditor, error) {
return newProvider(ctx, providerSettings, config, licensing, build)
})
}
func newProvider(_ context.Context, providerSettings factory.ProviderSettings, config auditor.Config, licensing licensing.Licensing, build version.Build) (auditor.Auditor, error) {
settings := factory.NewScopedProviderSettings(providerSettings, "github.com/SigNoz/signoz/ee/auditor/fileauditor")
file, err := os.OpenFile(config.File.Path, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o644)
if err != nil {
return nil, errors.Wrapf(err, errors.TypeInvalidInput, auditor.ErrCodeAuditExportFailed, "failed to open audit file %q", config.File.Path)
}
provider := &provider{
settings: settings,
config: config,
licensing: licensing,
build: build,
marshaler: plog.JSONMarshaler{},
file: file,
}
server, err := auditorserver.New(settings,
auditorserver.Config{
BufferSize: config.BufferSize,
BatchSize: config.BatchSize,
FlushInterval: config.FlushInterval,
},
provider.export,
)
if err != nil {
_ = file.Close()
return nil, err
}
provider.server = server
return provider, nil
}
func (provider *provider) Start(ctx context.Context) error {
return provider.server.Start(ctx)
}
func (provider *provider) Audit(ctx context.Context, event audittypes.AuditEvent) {
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.PrincipalAttributes.PrincipalOrgID); err != nil {
return
}
provider.server.Add(ctx, event)
}
func (provider *provider) Healthy() <-chan struct{} {
return provider.server.Healthy()
}
func (provider *provider) Stop(ctx context.Context) error {
serverErr := provider.server.Stop(ctx)
fileErr := provider.file.Close()
if serverErr != nil {
return serverErr
}
return fileErr
}

View File

@@ -5,6 +5,7 @@ import (
"github.com/SigNoz/signoz/pkg/http/handler"
"github.com/SigNoz/signoz/pkg/types"
"github.com/SigNoz/signoz/pkg/types/audittypes"
"github.com/SigNoz/signoz/pkg/types/ruletypes"
"github.com/gorilla/mux"
)
@@ -37,64 +38,99 @@ func (provider *provider) addRulerRoutes(router *mux.Router) error {
return err
}
if err := router.Handle("/api/v2/rules", handler.New(provider.authZ.EditAccess(provider.rulerHandler.CreateRule), handler.OpenAPIDef{
ID: "CreateRule",
Tags: []string{"rules"},
Summary: "Create alert rule",
Description: "This endpoint creates a new alert rule",
Request: new(ruletypes.PostableRule),
RequestContentType: "application/json",
RequestExamples: postableRuleExamples(),
Response: new(ruletypes.Rule),
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusCreated,
ErrorStatusCodes: []int{http.StatusBadRequest},
SecuritySchemes: newSecuritySchemes(types.RoleEditor),
})).Methods(http.MethodPost).GetError(); err != nil {
if err := router.Handle("/api/v2/rules", handler.New(
provider.authZ.EditAccess(provider.rulerHandler.CreateRule),
handler.OpenAPIDef{
ID: "CreateRule",
Tags: []string{"rules"},
Summary: "Create alert rule",
Description: "This endpoint creates a new alert rule",
Request: new(ruletypes.PostableRule),
RequestContentType: "application/json",
RequestExamples: postableRuleExamples(),
Response: new(ruletypes.Rule),
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusCreated,
ErrorStatusCodes: []int{http.StatusBadRequest},
SecuritySchemes: newSecuritySchemes(types.RoleEditor),
},
handler.WithAuditDef(handler.AuditDef{
ResourceKind: "rule",
Action: audittypes.ActionCreate,
Category: audittypes.ActionCategoryConfigurationChange,
}),
)).Methods(http.MethodPost).GetError(); err != nil {
return err
}
if err := router.Handle("/api/v2/rules/{id}", handler.New(provider.authZ.EditAccess(provider.rulerHandler.UpdateRuleByID), handler.OpenAPIDef{
ID: "UpdateRuleByID",
Tags: []string{"rules"},
Summary: "Update alert rule",
Description: "This endpoint updates an alert rule by ID",
Request: new(ruletypes.PostableRule),
RequestContentType: "application/json",
RequestExamples: postableRuleExamples(),
SuccessStatusCode: http.StatusNoContent,
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusNotFound},
SecuritySchemes: newSecuritySchemes(types.RoleEditor),
})).Methods(http.MethodPut).GetError(); err != nil {
if err := router.Handle("/api/v2/rules/{id}", handler.New(
provider.authZ.EditAccess(provider.rulerHandler.UpdateRuleByID),
handler.OpenAPIDef{
ID: "UpdateRuleByID",
Tags: []string{"rules"},
Summary: "Update alert rule",
Description: "This endpoint updates an alert rule by ID",
Request: new(ruletypes.PostableRule),
RequestContentType: "application/json",
RequestExamples: postableRuleExamples(),
SuccessStatusCode: http.StatusNoContent,
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusNotFound},
SecuritySchemes: newSecuritySchemes(types.RoleEditor),
},
handler.WithAuditDef(handler.AuditDef{
ResourceKind: "rule",
Action: audittypes.ActionUpdate,
Category: audittypes.ActionCategoryConfigurationChange,
ResourceIDParam: "id",
}),
)).Methods(http.MethodPut).GetError(); err != nil {
return err
}
if err := router.Handle("/api/v2/rules/{id}", handler.New(provider.authZ.EditAccess(provider.rulerHandler.DeleteRuleByID), handler.OpenAPIDef{
ID: "DeleteRuleByID",
Tags: []string{"rules"},
Summary: "Delete alert rule",
Description: "This endpoint deletes an alert rule by ID",
SuccessStatusCode: http.StatusNoContent,
ErrorStatusCodes: []int{http.StatusNotFound},
SecuritySchemes: newSecuritySchemes(types.RoleEditor),
})).Methods(http.MethodDelete).GetError(); err != nil {
if err := router.Handle("/api/v2/rules/{id}", handler.New(
provider.authZ.EditAccess(provider.rulerHandler.DeleteRuleByID),
handler.OpenAPIDef{
ID: "DeleteRuleByID",
Tags: []string{"rules"},
Summary: "Delete alert rule",
Description: "This endpoint deletes an alert rule by ID",
SuccessStatusCode: http.StatusNoContent,
ErrorStatusCodes: []int{http.StatusNotFound},
SecuritySchemes: newSecuritySchemes(types.RoleEditor),
},
handler.WithAuditDef(handler.AuditDef{
ResourceKind: "rule",
Action: audittypes.ActionDelete,
Category: audittypes.ActionCategoryConfigurationChange,
ResourceIDParam: "id",
}),
)).Methods(http.MethodDelete).GetError(); err != nil {
return err
}
if err := router.Handle("/api/v2/rules/{id}", handler.New(provider.authZ.EditAccess(provider.rulerHandler.PatchRuleByID), handler.OpenAPIDef{
ID: "PatchRuleByID",
Tags: []string{"rules"},
Summary: "Patch alert rule",
Description: "This endpoint applies a partial update to an alert rule by ID",
Request: new(ruletypes.PostableRule),
RequestContentType: "application/json",
RequestExamples: postableRuleExamples(),
Response: new(ruletypes.Rule),
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusOK,
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusNotFound},
SecuritySchemes: newSecuritySchemes(types.RoleEditor),
})).Methods(http.MethodPatch).GetError(); err != nil {
if err := router.Handle("/api/v2/rules/{id}", handler.New(
provider.authZ.EditAccess(provider.rulerHandler.PatchRuleByID),
handler.OpenAPIDef{
ID: "PatchRuleByID",
Tags: []string{"rules"},
Summary: "Patch alert rule",
Description: "This endpoint applies a partial update to an alert rule by ID",
Request: new(ruletypes.PostableRule),
RequestContentType: "application/json",
RequestExamples: postableRuleExamples(),
Response: new(ruletypes.Rule),
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusOK,
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusNotFound},
SecuritySchemes: newSecuritySchemes(types.RoleEditor),
},
handler.WithAuditDef(handler.AuditDef{
ResourceKind: "rule",
Action: audittypes.ActionUpdate,
Category: audittypes.ActionCategoryConfigurationChange,
ResourceIDParam: "id",
}),
)).Methods(http.MethodPatch).GetError(); err != nil {
return err
}
@@ -143,45 +179,71 @@ func (provider *provider) addRulerRoutes(router *mux.Router) error {
return err
}
if err := router.Handle("/api/v1/downtime_schedules", handler.New(provider.authZ.EditAccess(provider.rulerHandler.CreateDowntimeSchedule), handler.OpenAPIDef{
ID: "CreateDowntimeSchedule",
Tags: []string{"downtimeschedules"},
Summary: "Create downtime schedule",
Description: "This endpoint creates a new planned maintenance / downtime schedule",
Request: new(ruletypes.PostablePlannedMaintenance),
RequestContentType: "application/json",
Response: new(ruletypes.PlannedMaintenance),
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusCreated,
ErrorStatusCodes: []int{http.StatusBadRequest},
SecuritySchemes: newSecuritySchemes(types.RoleEditor),
})).Methods(http.MethodPost).GetError(); err != nil {
if err := router.Handle("/api/v1/downtime_schedules", handler.New(
provider.authZ.EditAccess(provider.rulerHandler.CreateDowntimeSchedule),
handler.OpenAPIDef{
ID: "CreateDowntimeSchedule",
Tags: []string{"downtimeschedules"},
Summary: "Create downtime schedule",
Description: "This endpoint creates a new planned maintenance / downtime schedule",
Request: new(ruletypes.PostablePlannedMaintenance),
RequestContentType: "application/json",
Response: new(ruletypes.PlannedMaintenance),
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusCreated,
ErrorStatusCodes: []int{http.StatusBadRequest},
SecuritySchemes: newSecuritySchemes(types.RoleEditor),
},
handler.WithAuditDef(handler.AuditDef{
ResourceKind: "planned-maintenance",
Action: audittypes.ActionCreate,
Category: audittypes.ActionCategoryConfigurationChange,
}),
)).Methods(http.MethodPost).GetError(); err != nil {
return err
}
if err := router.Handle("/api/v1/downtime_schedules/{id}", handler.New(provider.authZ.EditAccess(provider.rulerHandler.UpdateDowntimeScheduleByID), handler.OpenAPIDef{
ID: "UpdateDowntimeScheduleByID",
Tags: []string{"downtimeschedules"},
Summary: "Update downtime schedule",
Description: "This endpoint updates a downtime schedule by ID",
Request: new(ruletypes.PostablePlannedMaintenance),
RequestContentType: "application/json",
SuccessStatusCode: http.StatusNoContent,
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusNotFound},
SecuritySchemes: newSecuritySchemes(types.RoleEditor),
})).Methods(http.MethodPut).GetError(); err != nil {
if err := router.Handle("/api/v1/downtime_schedules/{id}", handler.New(
provider.authZ.EditAccess(provider.rulerHandler.UpdateDowntimeScheduleByID),
handler.OpenAPIDef{
ID: "UpdateDowntimeScheduleByID",
Tags: []string{"downtimeschedules"},
Summary: "Update downtime schedule",
Description: "This endpoint updates a downtime schedule by ID",
Request: new(ruletypes.PostablePlannedMaintenance),
RequestContentType: "application/json",
SuccessStatusCode: http.StatusNoContent,
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusNotFound},
SecuritySchemes: newSecuritySchemes(types.RoleEditor),
},
handler.WithAuditDef(handler.AuditDef{
ResourceKind: "planned-maintenance",
Action: audittypes.ActionUpdate,
Category: audittypes.ActionCategoryConfigurationChange,
ResourceIDParam: "id",
}),
)).Methods(http.MethodPut).GetError(); err != nil {
return err
}
if err := router.Handle("/api/v1/downtime_schedules/{id}", handler.New(provider.authZ.EditAccess(provider.rulerHandler.DeleteDowntimeScheduleByID), handler.OpenAPIDef{
ID: "DeleteDowntimeScheduleByID",
Tags: []string{"downtimeschedules"},
Summary: "Delete downtime schedule",
Description: "This endpoint deletes a downtime schedule by ID",
SuccessStatusCode: http.StatusNoContent,
ErrorStatusCodes: []int{http.StatusNotFound},
SecuritySchemes: newSecuritySchemes(types.RoleEditor),
})).Methods(http.MethodDelete).GetError(); err != nil {
if err := router.Handle("/api/v1/downtime_schedules/{id}", handler.New(
provider.authZ.EditAccess(provider.rulerHandler.DeleteDowntimeScheduleByID),
handler.OpenAPIDef{
ID: "DeleteDowntimeScheduleByID",
Tags: []string{"downtimeschedules"},
Summary: "Delete downtime schedule",
Description: "This endpoint deletes a downtime schedule by ID",
SuccessStatusCode: http.StatusNoContent,
ErrorStatusCodes: []int{http.StatusNotFound},
SecuritySchemes: newSecuritySchemes(types.RoleEditor),
},
handler.WithAuditDef(handler.AuditDef{
ResourceKind: "planned-maintenance",
Action: audittypes.ActionDelete,
Category: audittypes.ActionCategoryConfigurationChange,
ResourceIDParam: "id",
}),
)).Methods(http.MethodDelete).GetError(); err != nil {
return err
}

View File

@@ -5,6 +5,7 @@ import (
"github.com/SigNoz/signoz/pkg/http/handler"
"github.com/SigNoz/signoz/pkg/types"
"github.com/SigNoz/signoz/pkg/types/audittypes"
"github.com/SigNoz/signoz/pkg/types/authtypes"
"github.com/SigNoz/signoz/pkg/types/serviceaccounttypes"
"github.com/gorilla/mux"
@@ -181,20 +182,29 @@ func (provider *provider) addServiceAccountRoutes(router *mux.Router) error {
return err
}
if err := router.Handle("/api/v1/service_accounts/{id}/keys", handler.New(provider.authZ.AdminAccess(provider.serviceAccountHandler.CreateFactorAPIKey), handler.OpenAPIDef{
ID: "CreateServiceAccountKey",
Tags: []string{"serviceaccount"},
Summary: "Create a service account key",
Description: "This endpoint creates a service account key",
Request: new(serviceaccounttypes.PostableFactorAPIKey),
RequestContentType: "",
Response: new(serviceaccounttypes.GettableFactorAPIKeyWithKey),
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusCreated,
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusConflict},
Deprecated: false,
SecuritySchemes: newSecuritySchemes(types.RoleAdmin),
})).Methods(http.MethodPost).GetError(); err != nil {
if err := router.Handle("/api/v1/service_accounts/{id}/keys", handler.New(
provider.authZ.AdminAccess(provider.serviceAccountHandler.CreateFactorAPIKey),
handler.OpenAPIDef{
ID: "CreateServiceAccountKey",
Tags: []string{"serviceaccount"},
Summary: "Create a service account key",
Description: "This endpoint creates a service account key",
Request: new(serviceaccounttypes.PostableFactorAPIKey),
RequestContentType: "",
Response: new(serviceaccounttypes.GettableFactorAPIKeyWithKey),
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusCreated,
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusConflict},
Deprecated: false,
SecuritySchemes: newSecuritySchemes(types.RoleAdmin),
},
handler.WithAuditDef(handler.AuditDef{
ResourceKind: "factor-api-key",
Action: audittypes.ActionCreate,
Category: audittypes.ActionCategoryAccessControl,
ResourceIDParam: "id",
}),
)).Methods(http.MethodPost).GetError(); err != nil {
return err
}
@@ -232,20 +242,29 @@ func (provider *provider) addServiceAccountRoutes(router *mux.Router) error {
return err
}
if err := router.Handle("/api/v1/service_accounts/{id}/keys/{fid}", handler.New(provider.authZ.AdminAccess(provider.serviceAccountHandler.RevokeFactorAPIKey), handler.OpenAPIDef{
ID: "RevokeServiceAccountKey",
Tags: []string{"serviceaccount"},
Summary: "Revoke a service account key",
Description: "This endpoint revokes an existing service account key",
Request: nil,
RequestContentType: "",
Response: nil,
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusNoContent,
ErrorStatusCodes: []int{http.StatusNotFound},
Deprecated: false,
SecuritySchemes: newSecuritySchemes(types.RoleAdmin),
})).Methods(http.MethodDelete).GetError(); err != nil {
if err := router.Handle("/api/v1/service_accounts/{id}/keys/{fid}", handler.New(
provider.authZ.AdminAccess(provider.serviceAccountHandler.RevokeFactorAPIKey),
handler.OpenAPIDef{
ID: "RevokeServiceAccountKey",
Tags: []string{"serviceaccount"},
Summary: "Revoke a service account key",
Description: "This endpoint revokes an existing service account key",
Request: nil,
RequestContentType: "",
Response: nil,
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusNoContent,
ErrorStatusCodes: []int{http.StatusNotFound},
Deprecated: false,
SecuritySchemes: newSecuritySchemes(types.RoleAdmin),
},
handler.WithAuditDef(handler.AuditDef{
ResourceKind: "factor-api-key",
Action: audittypes.ActionDelete,
Category: audittypes.ActionCategoryAccessControl,
ResourceIDParam: "id",
}),
)).Methods(http.MethodDelete).GetError(); err != nil {
return err
}

View File

@@ -4,25 +4,34 @@ import (
"net/http"
"github.com/SigNoz/signoz/pkg/http/handler"
"github.com/SigNoz/signoz/pkg/types/audittypes"
"github.com/SigNoz/signoz/pkg/types/authtypes"
"github.com/gorilla/mux"
)
func (provider *provider) addSessionRoutes(router *mux.Router) error {
if err := router.Handle("/api/v2/sessions/email_password", handler.New(provider.authZ.OpenAccess(provider.sessionHandler.CreateSessionByEmailPassword), handler.OpenAPIDef{
ID: "CreateSessionByEmailPassword",
Tags: []string{"sessions"},
Summary: "Create session by email and password",
Description: "This endpoint creates a session for a user using email and password.",
Request: new(authtypes.PostableEmailPasswordSession),
RequestContentType: "application/json",
Response: new(authtypes.GettableToken),
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusOK,
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusNotFound},
Deprecated: false,
SecuritySchemes: []handler.OpenAPISecurityScheme{},
})).Methods(http.MethodPost).GetError(); err != nil {
if err := router.Handle("/api/v2/sessions/email_password", handler.New(
provider.authZ.OpenAccess(provider.sessionHandler.CreateSessionByEmailPassword),
handler.OpenAPIDef{
ID: "CreateSessionByEmailPassword",
Tags: []string{"sessions"},
Summary: "Create session by email and password",
Description: "This endpoint creates a session for a user using email and password.",
Request: new(authtypes.PostableEmailPasswordSession),
RequestContentType: "application/json",
Response: new(authtypes.GettableToken),
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusOK,
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusNotFound},
Deprecated: false,
SecuritySchemes: []handler.OpenAPISecurityScheme{},
},
handler.WithAuditDef(handler.AuditDef{
ResourceKind: "session",
Action: audittypes.ActionCreate,
Category: audittypes.ActionCategoryAccessControl,
}),
)).Methods(http.MethodPost).GetError(); err != nil {
return err
}
@@ -43,37 +52,53 @@ func (provider *provider) addSessionRoutes(router *mux.Router) error {
return err
}
if err := router.Handle("/api/v2/sessions/rotate", handler.New(provider.authZ.OpenAccess(provider.sessionHandler.RotateSession), handler.OpenAPIDef{
ID: "RotateSession",
Tags: []string{"sessions"},
Summary: "Rotate session",
Description: "This endpoint rotates the session",
Request: new(authtypes.PostableRotateToken),
RequestContentType: "application/json",
Response: new(authtypes.GettableToken),
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusOK,
ErrorStatusCodes: []int{http.StatusBadRequest},
Deprecated: false,
SecuritySchemes: []handler.OpenAPISecurityScheme{},
})).Methods(http.MethodPost).GetError(); err != nil {
if err := router.Handle("/api/v2/sessions/rotate", handler.New(
provider.authZ.OpenAccess(provider.sessionHandler.RotateSession),
handler.OpenAPIDef{
ID: "RotateSession",
Tags: []string{"sessions"},
Summary: "Rotate session",
Description: "This endpoint rotates the session",
Request: new(authtypes.PostableRotateToken),
RequestContentType: "application/json",
Response: new(authtypes.GettableToken),
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusOK,
ErrorStatusCodes: []int{http.StatusBadRequest},
Deprecated: false,
SecuritySchemes: []handler.OpenAPISecurityScheme{},
},
handler.WithAuditDef(handler.AuditDef{
ResourceKind: "session",
Action: audittypes.ActionUpdate,
Category: audittypes.ActionCategoryAccessControl,
}),
)).Methods(http.MethodPost).GetError(); err != nil {
return err
}
if err := router.Handle("/api/v2/sessions", handler.New(provider.authZ.OpenAccess(provider.sessionHandler.DeleteSession), handler.OpenAPIDef{
ID: "DeleteSession",
Tags: []string{"sessions"},
Summary: "Delete session",
Description: "This endpoint deletes the session",
Request: nil,
RequestContentType: "",
Response: nil,
ResponseContentType: "",
SuccessStatusCode: http.StatusNoContent,
ErrorStatusCodes: []int{http.StatusBadRequest},
Deprecated: false,
SecuritySchemes: []handler.OpenAPISecurityScheme{{Name: authtypes.IdentNProviderTokenizer.StringValue()}},
})).Methods(http.MethodDelete).GetError(); err != nil {
if err := router.Handle("/api/v2/sessions", handler.New(
provider.authZ.OpenAccess(provider.sessionHandler.DeleteSession),
handler.OpenAPIDef{
ID: "DeleteSession",
Tags: []string{"sessions"},
Summary: "Delete session",
Description: "This endpoint deletes the session",
Request: nil,
RequestContentType: "",
Response: nil,
ResponseContentType: "",
SuccessStatusCode: http.StatusNoContent,
ErrorStatusCodes: []int{http.StatusBadRequest},
Deprecated: false,
SecuritySchemes: []handler.OpenAPISecurityScheme{{Name: authtypes.IdentNProviderTokenizer.StringValue()}},
},
handler.WithAuditDef(handler.AuditDef{
ResourceKind: "session",
Action: audittypes.ActionDelete,
Category: audittypes.ActionCategoryAccessControl,
}),
)).Methods(http.MethodDelete).GetError(); err != nil {
return err
}

View File

@@ -5,6 +5,7 @@ import (
"github.com/SigNoz/signoz/pkg/http/handler"
"github.com/SigNoz/signoz/pkg/types"
"github.com/SigNoz/signoz/pkg/types/audittypes"
"github.com/SigNoz/signoz/pkg/types/authtypes"
"github.com/gorilla/mux"
)
@@ -111,20 +112,28 @@ func (provider *provider) addUserRoutes(router *mux.Router) error {
return err
}
if err := router.Handle("/api/v2/users/me", handler.New(provider.authZ.OpenAccess(provider.userHandler.UpdateMyUser), handler.OpenAPIDef{
ID: "UpdateMyUserV2",
Tags: []string{"users"},
Summary: "Update my user v2",
Description: "This endpoint updates the user I belong to",
Request: new(types.UpdatableUser),
RequestContentType: "application/json",
Response: nil,
ResponseContentType: "",
SuccessStatusCode: http.StatusNoContent,
ErrorStatusCodes: []int{},
Deprecated: false,
SecuritySchemes: []handler.OpenAPISecurityScheme{{Name: authtypes.IdentNProviderTokenizer.StringValue()}},
})).Methods(http.MethodPut).GetError(); err != nil {
if err := router.Handle("/api/v2/users/me", handler.New(
provider.authZ.OpenAccess(provider.userHandler.UpdateMyUser),
handler.OpenAPIDef{
ID: "UpdateMyUserV2",
Tags: []string{"users"},
Summary: "Update my user v2",
Description: "This endpoint updates the user I belong to",
Request: new(types.UpdatableUser),
RequestContentType: "application/json",
Response: nil,
ResponseContentType: "",
SuccessStatusCode: http.StatusNoContent,
ErrorStatusCodes: []int{},
Deprecated: false,
SecuritySchemes: []handler.OpenAPISecurityScheme{{Name: authtypes.IdentNProviderTokenizer.StringValue()}},
},
handler.WithAuditDef(handler.AuditDef{
ResourceKind: "user",
Action: audittypes.ActionUpdate,
Category: audittypes.ActionCategoryConfigurationChange,
}),
)).Methods(http.MethodPut).GetError(); err != nil {
return err
}
@@ -179,20 +188,29 @@ func (provider *provider) addUserRoutes(router *mux.Router) error {
return err
}
if err := router.Handle("/api/v2/users/{id}", handler.New(provider.authZ.AdminAccess(provider.userHandler.UpdateUser), handler.OpenAPIDef{
ID: "UpdateUser",
Tags: []string{"users"},
Summary: "Update user v2",
Description: "This endpoint updates the user by id",
Request: new(types.UpdatableUser),
RequestContentType: "application/json",
Response: nil,
ResponseContentType: "",
SuccessStatusCode: http.StatusNoContent,
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusNotFound},
Deprecated: false,
SecuritySchemes: newSecuritySchemes(types.RoleAdmin),
})).Methods(http.MethodPut).GetError(); err != nil {
if err := router.Handle("/api/v2/users/{id}", handler.New(
provider.authZ.AdminAccess(provider.userHandler.UpdateUser),
handler.OpenAPIDef{
ID: "UpdateUser",
Tags: []string{"users"},
Summary: "Update user v2",
Description: "This endpoint updates the user by id",
Request: new(types.UpdatableUser),
RequestContentType: "application/json",
Response: nil,
ResponseContentType: "",
SuccessStatusCode: http.StatusNoContent,
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusNotFound},
Deprecated: false,
SecuritySchemes: newSecuritySchemes(types.RoleAdmin),
},
handler.WithAuditDef(handler.AuditDef{
ResourceKind: "user",
Action: audittypes.ActionUpdate,
Category: audittypes.ActionCategoryConfigurationChange,
ResourceIDParam: "id",
}),
)).Methods(http.MethodPut).GetError(); err != nil {
return err
}
@@ -247,20 +265,29 @@ func (provider *provider) addUserRoutes(router *mux.Router) error {
return err
}
if err := router.Handle("/api/v2/users/{id}/reset_password_tokens", handler.New(provider.authZ.AdminAccess(provider.userHandler.CreateResetPasswordToken), handler.OpenAPIDef{
ID: "CreateResetPasswordToken",
Tags: []string{"users"},
Summary: "Create or regenerate reset password token for a user",
Description: "This endpoint creates or regenerates a reset password token for a user. If a valid token exists, it is returned. If expired, a new one is created.",
Request: nil,
RequestContentType: "",
Response: new(types.ResetPasswordToken),
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusCreated,
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusNotFound},
Deprecated: false,
SecuritySchemes: newSecuritySchemes(types.RoleAdmin),
})).Methods(http.MethodPut).GetError(); err != nil {
if err := router.Handle("/api/v2/users/{id}/reset_password_tokens", handler.New(
provider.authZ.AdminAccess(provider.userHandler.CreateResetPasswordToken),
handler.OpenAPIDef{
ID: "CreateResetPasswordToken",
Tags: []string{"users"},
Summary: "Create or regenerate reset password token for a user",
Description: "This endpoint creates or regenerates a reset password token for a user. If a valid token exists, it is returned. If expired, a new one is created.",
Request: nil,
RequestContentType: "",
Response: new(types.ResetPasswordToken),
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusCreated,
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusNotFound},
Deprecated: false,
SecuritySchemes: newSecuritySchemes(types.RoleAdmin),
},
handler.WithAuditDef(handler.AuditDef{
ResourceKind: "reset-password-token",
Action: audittypes.ActionCreate,
Category: audittypes.ActionCategoryAccessControl,
ResourceIDParam: "id",
}),
)).Methods(http.MethodPut).GetError(); err != nil {
return err
}
@@ -281,37 +308,53 @@ func (provider *provider) addUserRoutes(router *mux.Router) error {
return err
}
if err := router.Handle("/api/v2/users/me/factor_password", handler.New(provider.authZ.OpenAccess(provider.userHandler.ChangePassword), handler.OpenAPIDef{
ID: "UpdateMyPassword",
Tags: []string{"users"},
Summary: "Updates my password",
Description: "This endpoint updates the password of the user I belong to",
Request: new(types.ChangePasswordRequest),
RequestContentType: "application/json",
Response: nil,
ResponseContentType: "",
SuccessStatusCode: http.StatusNoContent,
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusNotFound},
Deprecated: false,
SecuritySchemes: newSecuritySchemes(types.RoleAdmin),
})).Methods(http.MethodPut).GetError(); err != nil {
if err := router.Handle("/api/v2/users/me/factor_password", handler.New(
provider.authZ.OpenAccess(provider.userHandler.ChangePassword),
handler.OpenAPIDef{
ID: "UpdateMyPassword",
Tags: []string{"users"},
Summary: "Updates my password",
Description: "This endpoint updates the password of the user I belong to",
Request: new(types.ChangePasswordRequest),
RequestContentType: "application/json",
Response: nil,
ResponseContentType: "",
SuccessStatusCode: http.StatusNoContent,
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusNotFound},
Deprecated: false,
SecuritySchemes: newSecuritySchemes(types.RoleAdmin),
},
handler.WithAuditDef(handler.AuditDef{
ResourceKind: "factor-password",
Action: audittypes.ActionUpdate,
Category: audittypes.ActionCategoryAccessControl,
}),
)).Methods(http.MethodPut).GetError(); err != nil {
return err
}
if err := router.Handle("/api/v2/factor_password/forgot", handler.New(provider.authZ.OpenAccess(provider.userHandler.ForgotPassword), handler.OpenAPIDef{
ID: "ForgotPassword",
Tags: []string{"users"},
Summary: "Forgot password",
Description: "This endpoint initiates the forgot password flow by sending a reset password email",
Request: new(types.PostableForgotPassword),
RequestContentType: "application/json",
Response: nil,
ResponseContentType: "",
SuccessStatusCode: http.StatusNoContent,
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusUnprocessableEntity},
Deprecated: false,
SecuritySchemes: []handler.OpenAPISecurityScheme{},
})).Methods(http.MethodPost).GetError(); err != nil {
if err := router.Handle("/api/v2/factor_password/forgot", handler.New(
provider.authZ.OpenAccess(provider.userHandler.ForgotPassword),
handler.OpenAPIDef{
ID: "ForgotPassword",
Tags: []string{"users"},
Summary: "Forgot password",
Description: "This endpoint initiates the forgot password flow by sending a reset password email",
Request: new(types.PostableForgotPassword),
RequestContentType: "application/json",
Response: nil,
ResponseContentType: "",
SuccessStatusCode: http.StatusNoContent,
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusUnprocessableEntity},
Deprecated: false,
SecuritySchemes: []handler.OpenAPISecurityScheme{},
},
handler.WithAuditDef(handler.AuditDef{
ResourceKind: "factor-password",
Action: audittypes.ActionUpdate,
Category: audittypes.ActionCategoryAccessControl,
}),
)).Methods(http.MethodPost).GetError(); err != nil {
return err
}
@@ -332,37 +375,55 @@ func (provider *provider) addUserRoutes(router *mux.Router) error {
return err
}
if err := router.Handle("/api/v2/users/{id}/roles", handler.New(provider.authZ.AdminAccess(provider.userHandler.SetRoleByUserID), handler.OpenAPIDef{
ID: "SetRoleByUserID",
Tags: []string{"users"},
Summary: "Set user roles",
Description: "This endpoint assigns the role to the user roles by user id",
Request: new(types.PostableRole),
RequestContentType: "application/json",
Response: nil,
ResponseContentType: "",
SuccessStatusCode: http.StatusOK,
ErrorStatusCodes: []int{http.StatusNotFound},
Deprecated: false,
SecuritySchemes: newSecuritySchemes(types.RoleAdmin),
})).Methods(http.MethodPost).GetError(); err != nil {
if err := router.Handle("/api/v2/users/{id}/roles", handler.New(
provider.authZ.AdminAccess(provider.userHandler.SetRoleByUserID),
handler.OpenAPIDef{
ID: "SetRoleByUserID",
Tags: []string{"users"},
Summary: "Set user roles",
Description: "This endpoint assigns the role to the user roles by user id",
Request: new(types.PostableRole),
RequestContentType: "application/json",
Response: nil,
ResponseContentType: "",
SuccessStatusCode: http.StatusOK,
ErrorStatusCodes: []int{http.StatusNotFound},
Deprecated: false,
SecuritySchemes: newSecuritySchemes(types.RoleAdmin),
},
handler.WithAuditDef(handler.AuditDef{
ResourceKind: "user-role",
Action: audittypes.ActionCreate,
Category: audittypes.ActionCategoryAccessControl,
ResourceIDParam: "id",
}),
)).Methods(http.MethodPost).GetError(); err != nil {
return err
}
if err := router.Handle("/api/v2/users/{id}/roles/{roleId}", handler.New(provider.authZ.AdminAccess(provider.userHandler.RemoveUserRoleByRoleID), handler.OpenAPIDef{
ID: "RemoveUserRoleByUserIDAndRoleID",
Tags: []string{"users"},
Summary: "Remove a role from user",
Description: "This endpoint removes a role from the user by user id and role id",
Request: nil,
RequestContentType: "",
Response: nil,
ResponseContentType: "",
SuccessStatusCode: http.StatusNoContent,
ErrorStatusCodes: []int{http.StatusNotFound},
Deprecated: false,
SecuritySchemes: newSecuritySchemes(types.RoleAdmin),
})).Methods(http.MethodDelete).GetError(); err != nil {
if err := router.Handle("/api/v2/users/{id}/roles/{roleId}", handler.New(
provider.authZ.AdminAccess(provider.userHandler.RemoveUserRoleByRoleID),
handler.OpenAPIDef{
ID: "RemoveUserRoleByUserIDAndRoleID",
Tags: []string{"users"},
Summary: "Remove a role from user",
Description: "This endpoint removes a role from the user by user id and role id",
Request: nil,
RequestContentType: "",
Response: nil,
ResponseContentType: "",
SuccessStatusCode: http.StatusNoContent,
ErrorStatusCodes: []int{http.StatusNotFound},
Deprecated: false,
SecuritySchemes: newSecuritySchemes(types.RoleAdmin),
},
handler.WithAuditDef(handler.AuditDef{
ResourceKind: "user-role",
Action: audittypes.ActionDelete,
Category: audittypes.ActionCategoryAccessControl,
ResourceIDParam: "id",
}),
)).Methods(http.MethodDelete).GetError(); err != nil {
return err
}

View File

@@ -25,6 +25,8 @@ type Config struct {
FlushInterval time.Duration `mapstructure:"flush_interval"`
OTLPHTTP OTLPHTTPConfig `mapstructure:"otlphttp"`
File FileConfig `mapstructure:"file"`
}
// OTLPHTTPConfig holds configuration for the OTLP HTTP exporter provider.
@@ -46,6 +48,14 @@ type OTLPHTTPConfig struct {
Retry RetryConfig `mapstructure:"retry"`
}
// FileConfig holds configuration for the file exporter provider.
// Audit events are encoded as OTLP-JSON log records and appended to the file.
type FileConfig struct {
// Path is the absolute path to the audit log file. The file is opened with
// O_APPEND|O_CREATE|O_WRONLY; existing contents are preserved across runs.
Path string `mapstructure:"path"`
}
// RetryConfig configures exponential backoff for the OTLP HTTP exporter.
type RetryConfig struct {
// Enabled controls whether retries are attempted on transient failures.
@@ -111,5 +121,11 @@ func (c Config) Validate() error {
}
}
if c.Provider == "file" {
if c.File.Path == "" {
return errors.New(errors.TypeInvalidInput, errors.CodeInvalidInput, "auditor::file::path must be set when provider is file")
}
}
return nil
}

View File

@@ -13,6 +13,7 @@ pytest_plugins = [
"fixtures.zookeeper",
"fixtures.signoz",
"fixtures.audit",
"fixtures.auditor",
"fixtures.logs",
"fixtures.traces",
"fixtures.metrics",

183
tests/fixtures/auditor.py vendored Normal file
View File

@@ -0,0 +1,183 @@
import json
import os
import time
from collections.abc import Callable
from dataclasses import dataclass
from http import HTTPStatus
from typing import Any
import pytest
import requests
from fixtures import reuse, types
from fixtures.auth import find_user_by_email
# Filename used for the audit log inside the host-mounted tmp dir. The same
# absolute path is mounted into the SigNoz container at the same location, so
# the file is visible to both the backend (writer) and the test runner (reader).
AUDIT_FILE_NAME = "audit.log"
@dataclass
class _AuditDir:
"""Cacheable wrapper around the audit directory path so the reuse.wrap
machinery can persist it across pytest runs alongside the SigNoz container."""
path: str
def __cache__(self) -> dict:
return {"path": self.path}
def __log__(self) -> str:
return f"AuditDir(path={self.path})"
@pytest.fixture(name="audit_dir", scope="package")
def audit_dir(
tmpfs: Callable[[str], types.LegacyPath],
request: pytest.FixtureRequest,
pytestconfig: pytest.Config,
) -> str:
"""Host tmp directory mounted into the SigNoz container as the auditor file path.
Mirrors the sqlite/clickhouse pattern: a tmpfs directory is created on the
host and bind-mounted into the container at the same absolute path, so the
audit log file is reachable from both sides without docker exec. The path
is cached via reuse.wrap so re-runs under --reuse keep using the same dir
that the long-lived SigNoz container has bind-mounted.
"""
def create() -> _AuditDir:
return _AuditDir(path=str(tmpfs("auditor")))
def delete(_: _AuditDir) -> None:
pass
def restore(cache: dict) -> _AuditDir:
return _AuditDir(path=cache["path"])
return reuse.wrap(
request,
pytestconfig,
"auditor_dir",
lambda: _AuditDir(path=""),
create,
delete,
restore,
).path
@pytest.fixture(name="audit_file_path", scope="package")
def audit_file_path(audit_dir: str) -> str: # pylint: disable=redefined-outer-name
return os.path.join(audit_dir, AUDIT_FILE_NAME)
def ensure_user_active(
signoz: types.SigNoz,
admin_token: str,
email: str,
role: str,
password: str,
name: str = "",
) -> str:
"""Invite + activate a user, or return the existing user's id if already present.
Idempotent counterpart to fixtures.auth.create_active_user — needed because the
auditor suite reuses a long-lived SigNoz container across pytest runs and would
otherwise hit a 409 on the second invite.
"""
invite = requests.post(
signoz.self.host_configs["8080"].get("/api/v1/invite"),
json={"email": email, "role": role, "name": name},
headers={"Authorization": f"Bearer {admin_token}"},
timeout=5,
)
if invite.status_code == HTTPStatus.CONFLICT:
return find_user_by_email(signoz, admin_token, email)["id"]
assert invite.status_code == HTTPStatus.CREATED, invite.text
invited_user = invite.json()["data"]
activate = requests.post(
signoz.self.host_configs["8080"].get("/api/v1/resetPassword"),
json={"password": password, "token": invited_user["token"]},
timeout=5,
)
assert activate.status_code == HTTPStatus.NO_CONTENT, activate.text
return invited_user["id"]
def read_audit_records(audit_file_path: str) -> list[dict[str, Any]]: # pylint: disable=redefined-outer-name
"""Read every audit log record from the host-side audit file.
Each line of the file is one OTLP-Logs JSON object containing all events
flushed in a single export batch. Returns the flattened list of LogRecord
dicts across every line, with the parent resource attributes merged into
each record's attributes so signoz.audit.resource.kind and
signoz.audit.resource.id are reachable via attr_value.
"""
if not os.path.exists(audit_file_path):
return []
records: list[dict[str, Any]] = []
with open(audit_file_path, encoding="utf-8") as handle:
for line in handle:
line = line.strip()
if not line:
continue
try:
payload = json.loads(line)
except json.JSONDecodeError:
# Partial line caught mid-write between flush syscalls; the
# next poll will re-read the full file once the write
# completes. Skip without failing the test.
continue
for resource_log in payload.get("resourceLogs", []):
resource_attrs = resource_log.get("resource", {}).get("attributes", [])
for scope_log in resource_log.get("scopeLogs", []):
for record in scope_log.get("logRecords", []):
merged = dict(record)
merged["attributes"] = list(record.get("attributes", [])) + list(resource_attrs)
records.append(merged)
return records
def attr_value(record: dict[str, Any], key: str) -> Any:
"""Return the value of an OTLP-JSON attribute by key, or None if absent."""
for kv in record.get("attributes", []):
if kv.get("key") != key:
continue
value = kv.get("value", {})
for kind in ("stringValue", "intValue", "boolValue", "doubleValue"):
if kind in value:
return value[kind]
return None
return None
def find_event(records: list[dict[str, Any]], event_name: str, **filters: Any) -> dict[str, Any] | None:
"""Find the first record whose eventName matches and whose audit attributes match every filter."""
for record in records:
if record.get("eventName") != event_name:
continue
if all(attr_value(record, k) == v for k, v in filters.items()):
return record
return None
def wait_for_event(
audit_file_path: str, # pylint: disable=redefined-outer-name
event_name: str,
timeout: float = 2.0,
interval: float = 0.1,
**filters: Any,
) -> dict[str, Any]:
"""Poll the audit file until an event matching event_name + filters appears."""
deadline = time.monotonic() + timeout
last_records: list[dict[str, Any]] = []
while time.monotonic() < deadline:
last_records = read_audit_records(audit_file_path)
event = find_event(last_records, event_name, **filters)
if event is not None:
return event
time.sleep(interval)
raise AssertionError(f"audit event {event_name!r} matching {filters} not found within {timeout}s (saw {len(last_records)} records: {[r.get('eventName') for r in last_records]})")

View File

@@ -16,7 +16,7 @@ from fixtures.logger import setup_logger
logger = setup_logger(__name__)
def create_signoz(
def create_signoz( # pylint: disable=too-many-arguments,too-many-positional-arguments
network: Network,
zeus: types.TestContainerDocker,
gateway: types.TestContainerDocker,
@@ -26,10 +26,15 @@ def create_signoz(
pytestconfig: pytest.Config,
cache_key: str = "signoz",
env_overrides: dict | None = None,
volume_mappings: list[tuple[str, str]] | None = None,
) -> types.SigNoz:
"""
Factory function for creating a SigNoz container.
Accepts optional env_overrides to customize the container environment.
Accepts optional env_overrides to customize the container environment, and
optional volume_mappings (host_path, container_path) tuples to mount host
directories into the container — mirrors how sqlite/clickhouse fixtures
expose tmp paths back to the test runner.
"""
def create() -> types.SigNoz:
@@ -104,6 +109,10 @@ def create_signoz(
"rw",
)
if volume_mappings:
for host_path, container_path in volume_mappings:
container.with_volume_mapping(host_path, container_path, "rw")
container.start()
def ready(container: DockerContainer) -> None:

View File

@@ -0,0 +1,91 @@
from collections.abc import Callable
from http import HTTPStatus
import requests
from fixtures import types
from fixtures.auditor import attr_value, ensure_user_active, wait_for_event
from fixtures.auth import (
USER_ADMIN_EMAIL,
USER_ADMIN_PASSWORD,
USER_VIEWER_EMAIL,
USER_VIEWER_NAME,
USER_VIEWER_PASSWORD,
find_user_by_email,
)
from fixtures.logger import setup_logger
logger = setup_logger(__name__)
def test_session_deleted_event_appears_in_file(
signoz: types.SigNoz,
apply_license: types.Operation, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
audit_file_path: str,
) -> None:
"""An admin logout posts to DELETE /api/v2/sessions; the audit middleware
captures the post-auth claims and the file provider writes session.deleted."""
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
response = requests.delete(
signoz.self.host_configs["8080"].get("/api/v2/sessions"),
headers={"Authorization": f"Bearer {admin_token}"},
timeout=5,
)
assert response.status_code == HTTPStatus.NO_CONTENT, response.text
record = wait_for_event(
audit_file_path,
"session.deleted",
**{
"signoz.audit.outcome": "success",
"signoz.audit.action": "delete",
"signoz.audit.principal.email": USER_ADMIN_EMAIL,
},
)
assert attr_value(record, "signoz.audit.resource.kind") == "session"
assert attr_value(record, "signoz.audit.action_category") == "access_control"
assert record["severityText"] == "INFO"
def test_audit_records_failure_outcome(
signoz: types.SigNoz,
apply_license: types.Operation, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
audit_file_path: str,
) -> None:
"""A viewer hitting an admin-only mutation must produce an audit record with
outcome=failure and the captured error.type. Proves the middleware writes
on the 4xx path, not just the happy path."""
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
ensure_user_active(
signoz,
admin_token,
USER_VIEWER_EMAIL,
"VIEWER",
USER_VIEWER_PASSWORD,
USER_VIEWER_NAME,
)
admin_user = find_user_by_email(signoz, admin_token, USER_ADMIN_EMAIL)
viewer_token = get_token(USER_VIEWER_EMAIL, USER_VIEWER_PASSWORD)
forbidden = requests.put(
signoz.self.host_configs["8080"].get(f"/api/v2/users/{admin_user['id']}"),
json={"displayName": "should not work"},
headers={"Authorization": f"Bearer {viewer_token}"},
timeout=5,
)
assert forbidden.status_code == HTTPStatus.FORBIDDEN, forbidden.text
record = wait_for_event(
audit_file_path,
"user.updated",
**{
"signoz.audit.outcome": "failure",
"signoz.audit.principal.email": USER_VIEWER_EMAIL,
"signoz.audit.resource.id": admin_user["id"],
},
)
assert record["severityText"] == "ERROR"
assert attr_value(record, "signoz.audit.error.type") is not None

View File

@@ -0,0 +1,122 @@
from collections.abc import Callable
from http import HTTPStatus
import requests
from fixtures import types
from fixtures.auditor import attr_value, ensure_user_active, wait_for_event
from fixtures.auth import (
USER_ADMIN_EMAIL,
USER_ADMIN_PASSWORD,
USER_EDITOR_EMAIL,
USER_EDITOR_NAME,
USER_EDITOR_PASSWORD,
change_user_role,
find_user_by_email,
)
from fixtures.logger import setup_logger
logger = setup_logger(__name__)
def test_user_updated_event_appears_in_file(
signoz: types.SigNoz,
apply_license: types.Operation, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
audit_file_path: str,
) -> None:
"""Admin renames an editor user via PUT /api/v2/users/{id}; the file provider
writes user.updated with the editor id as the resource."""
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
editor_id = ensure_user_active(
signoz,
admin_token,
USER_EDITOR_EMAIL,
"EDITOR",
USER_EDITOR_PASSWORD,
USER_EDITOR_NAME,
)
response = requests.put(
signoz.self.host_configs["8080"].get(f"/api/v2/users/{editor_id}"),
json={"displayName": "Renamed Editor"},
headers={"Authorization": f"Bearer {admin_token}"},
timeout=5,
)
assert response.status_code == HTTPStatus.NO_CONTENT, response.text
record = wait_for_event(
audit_file_path,
"user.updated",
**{
"signoz.audit.outcome": "success",
"signoz.audit.action": "update",
"signoz.audit.resource.id": editor_id,
"signoz.audit.principal.email": USER_ADMIN_EMAIL,
},
)
assert attr_value(record, "signoz.audit.action_category") == "configuration_change"
assert attr_value(record, "signoz.audit.resource.kind") == "user"
def test_user_role_change_emits_created_and_deleted_events(
signoz: types.SigNoz,
apply_license: types.Operation, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
audit_file_path: str,
) -> None:
"""Toggling the editor user's managed role between signoz-editor and signoz-viewer
fires both DELETE and POST against /api/v2/users/{id}/roles; the file provider
writes one user-role.deleted and one user-role.created tied to the editor id.
The toggle direction is computed from the current role so the test is idempotent
across re-runs of the long-lived auditor SigNoz container.
"""
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
editor = find_user_by_email(signoz, admin_token, USER_EDITOR_EMAIL)
editor_id = editor["id"]
roles_response = requests.get(
signoz.self.host_configs["8080"].get(f"/api/v2/users/{editor_id}/roles"),
headers={"Authorization": f"Bearer {admin_token}"},
timeout=5,
)
assert roles_response.status_code == HTTPStatus.OK, roles_response.text
managed_role = next(
(r for r in roles_response.json()["data"] if r["name"] in ("signoz-editor", "signoz-viewer")),
None,
)
assert managed_role is not None, "editor user is missing both managed roles"
if managed_role["name"] == "signoz-editor":
old_role, new_role = "signoz-editor", "signoz-viewer"
else:
old_role, new_role = "signoz-viewer", "signoz-editor"
change_user_role(signoz, admin_token, editor_id, old_role, new_role)
deleted = wait_for_event(
audit_file_path,
"user-role.deleted",
**{
"signoz.audit.outcome": "success",
"signoz.audit.action": "delete",
"signoz.audit.resource.id": editor_id,
"signoz.audit.principal.email": USER_ADMIN_EMAIL,
},
)
assert attr_value(deleted, "signoz.audit.action_category") == "access_control"
assert attr_value(deleted, "signoz.audit.resource.kind") == "user-role"
created = wait_for_event(
audit_file_path,
"user-role.created",
**{
"signoz.audit.outcome": "success",
"signoz.audit.action": "create",
"signoz.audit.resource.id": editor_id,
"signoz.audit.principal.email": USER_ADMIN_EMAIL,
},
)
assert attr_value(created, "signoz.audit.action_category") == "access_control"
assert attr_value(created, "signoz.audit.resource.kind") == "user-role"

View File

@@ -0,0 +1,44 @@
import pytest
from testcontainers.core.container import Network
from fixtures import types
from fixtures.signoz import create_signoz
@pytest.fixture(name="signoz", scope="package")
def signoz( # pylint: disable=too-many-arguments,too-many-positional-arguments
network: Network,
zeus: types.TestContainerDocker,
gateway: types.TestContainerDocker,
sqlstore: types.TestContainerSQL,
clickhouse: types.TestContainerClickhouse,
request: pytest.FixtureRequest,
pytestconfig: pytest.Config,
audit_dir: str,
audit_file_path: str,
) -> types.SigNoz:
"""Package-scoped SigNoz container configured with the file auditor.
BatchSize is set to 1 so every audited request flushes to disk on the moreC
path without waiting on the periodic ticker. FlushInterval stays short so
the periodic flush has bounded lag if BatchSize is ever raised. The audit
directory is bind-mounted from the host (see fixtures.auditor.audit_dir)
so tests can read the file with a plain open() call.
"""
return create_signoz(
network=network,
zeus=zeus,
gateway=gateway,
sqlstore=sqlstore,
clickhouse=clickhouse,
request=request,
pytestconfig=pytestconfig,
cache_key="signoz_auditor",
env_overrides={
"SIGNOZ_AUDITOR_PROVIDER": "file",
"SIGNOZ_AUDITOR_FILE_PATH": audit_file_path,
"SIGNOZ_AUDITOR_BATCH__SIZE": "1",
"SIGNOZ_AUDITOR_FLUSH__INTERVAL": "100ms",
},
volume_mappings=[(audit_dir, audit_dir)],
)