Compare commits

...

4 Commits

Author SHA1 Message Date
aks07
b01571ebcf chore(sentry): drop the tab-close case from the request failure comment 2026-11-11 11:51:27 +05:30
aks07
9139da3762 fix(sentry): stop reporting 401, 403 and network axios failures 2026-11-11 11:29:20 +05:30
Swapnil Nakade
da8d66b5b9 feat(audit): emit audit logs per signoz semantic conventions (#13477)
Some checks failed
build-staging / prepare (push) Has been cancelled
build-staging / js-build (push) Has been cancelled
build-staging / go-build (push) Has been cancelled
build-staging / staging (push) Has been cancelled
cacheci / tests (push) Has been cancelled
Release Drafter / update_release_draft (push) Has been cancelled
<!--A few plain bullets saying what changed and why, for a reviewer
skimming it - not a wall of text, not a restatement of the diff, not
generated boilerplate.-->
#### Description
- using semantic conventions described in `signoz-semanatic-conventions`
repository for audit

<!--Reference issues using `Closes #issue-number` to enable automatic
closure on merge. -->
#### Issues closed by this PR
contributes to https://github.com/SigNoz/keystone-pod/issues/188
2026-11-10 21:26:00 +00:00
Swapnil Nakade
f1d6c988c9 feat(audit): adding kafka audit exporter (#13357)
Some checks failed
build-staging / prepare (push) Has been cancelled
build-staging / js-build (push) Has been cancelled
build-staging / go-build (push) Has been cancelled
build-staging / staging (push) Has been cancelled
cacheci / tests (push) Has been cancelled
Release Drafter / update_release_draft (push) Has been cancelled
<!--A few plain bullets saying what changed and why, for a reviewer
skimming it - not a wall of text, not a restatement of the diff, not
generated boilerplate.-->
#### Description
- add `kafka` auditor provider that produces audit logs to a
kafka/redpanda topic as `otlp_proto`
- uses franz-go v1.21.7.
- no delivery timeout or retry cap: on franz-go < v1.22.2 these can ack
a record that was never written (twmb/franz-go#1484).
- added unit tests

<!--Reference issues using `Closes #issue-number` to enable automatic
closure on merge. -->
#### Issues closed by this PR
closes https://github.com/SigNoz/keystone-pod/issues/2

<!--Please delete paragraphs that you did not use before submitting.-->
2026-11-10 13:38:39 +00:00
25 changed files with 1154 additions and 299 deletions

View File

@@ -9,6 +9,7 @@ import (
"github.com/SigNoz/signoz/cmd"
"github.com/SigNoz/signoz/ee/auditor/fileauditor"
"github.com/SigNoz/signoz/ee/auditor/kafkaauditor"
"github.com/SigNoz/signoz/ee/auditor/otlphttpauditor"
"github.com/SigNoz/signoz/ee/authn/callbackauthn/oidccallbackauthn"
"github.com/SigNoz/signoz/ee/authn/callbackauthn/samlcallbackauthn"
@@ -156,6 +157,9 @@ func runServer(ctx context.Context, config signoz.Config, logger *slog.Logger) e
if err := factories.Add(fileauditor.NewFactory(licensing, version.Info)); err != nil {
panic(err)
}
if err := factories.Add(kafkaauditor.NewFactory(licensing, version.Info)); err != nil {
panic(err)
}
return factories
},
func(ctx context.Context, providerSettings factory.ProviderSettings, flagger pkgflagger.Flagger, licensing licensing.Licensing, telemetryStore telemetrystore.TelemetryStore, retentionGetter retention.Getter, orgGetter organization.Getter, zeus zeus.Zeus) (factory.NamedMap[factory.ProviderFactory[meterreporter.Reporter, meterreporter.Config]], string) {

View File

@@ -436,6 +436,7 @@ auditor:
# Specifies the auditor provider to use.
# noop: discards all audit events (community default).
# otlphttp: exports audit events via OTLP HTTP (enterprise).
# kafka: exports audit events to a Kafka topic as OTLP protobuf (enterprise).
provider: noop
# The async channel capacity for audit events. Events are dropped when full (fail-open).
buffer_size: 1000
@@ -461,6 +462,18 @@ auditor:
max_interval: 30s
# The total maximum time spent retrying.
max_elapsed_time: 60s
kafka:
# The seed broker addresses (host:port) used to discover the cluster.
brokers:
- localhost:9092
# The topic audit events are written to. It must already exist.
topic: ""
# The client id the producer reports to the brokers.
client_id: signoz
# The compression codec for produced batches: none, gzip, snappy, lz4 or zstd.
compression: lz4
# The maximum time to drain buffered events on shutdown. Undelivered events are dropped after it.
shutdown_timeout: 30s
##################### Cloud Integration #####################
cloudintegration:

View File

@@ -0,0 +1,31 @@
package kafkaauditor
import (
"context"
"log/slog"
"github.com/twmb/franz-go/pkg/kgo"
"github.com/SigNoz/signoz/pkg/auditor"
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/types/audittypes"
)
// Produces the whole batch as a single record so the collector's otlp_proto unmarshaler reads it as one ExportLogsServiceRequest.
func (provider *provider) export(ctx context.Context, events []audittypes.AuditEvent) error {
logs := audittypes.NewPLogsFromAuditEvents(events, "signoz", provider.build.Version(), "signoz.audit")
value, err := provider.marshaler.MarshalLogs(logs)
if err != nil {
return errors.Wrapf(err, errors.TypeInternal, auditor.ErrCodeAuditExportFailed, "failed to marshal audit logs")
}
record := &kgo.Record{Topic: provider.config.Kafka.Topic, Value: value}
if err := provider.client.ProduceSync(ctx, record).FirstErr(); 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 produce audit logs to topic %q", provider.config.Kafka.Topic)
}
return nil
}

View File

@@ -0,0 +1,132 @@
package kafkaauditor
import (
"context"
"log/slog"
"github.com/twmb/franz-go/pkg/kgo"
"github.com/twmb/franz-go/plugin/kslog"
"go.opentelemetry.io/collector/pdata/plog"
"github.com/SigNoz/signoz/pkg/auditor"
"github.com/SigNoz/signoz/pkg/auditor/auditorserver"
"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"
)
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.ProtoMarshaler
client *kgo.Client
}
func NewFactory(licensing licensing.Licensing, build version.Build) factory.ProviderFactory[auditor.Auditor, auditor.Config] {
return factory.NewProviderFactory(factory.MustNewName("kafka"), 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/kafkaauditor")
// Acks from all in-sync replicas, idempotent writes and unbounded record retries are franz-go defaults and are kept as is.
// A delivery timeout or retry limit lets franz-go < v1.22.2 ack a record that was never written (twmb/franz-go#1484).
client, err := kgo.NewClient(
kgo.SeedBrokers(config.Kafka.Brokers...),
kgo.ClientID(config.Kafka.ClientID),
kgo.ProducerBatchCompression(compressionCodec(config.Kafka.Compression)),
kgo.ProducerLinger(0),
kgo.WithLogger(kslog.New(settings.Logger())),
)
if err != nil {
return nil, err
}
provider := &provider{
settings: settings,
config: config,
licensing: licensing,
build: build,
marshaler: plog.ProtoMarshaler{},
client: client,
}
server, err := auditorserver.New(settings,
auditorserver.Config{
BufferSize: config.BufferSize,
BatchSize: config.BatchSize,
FlushInterval: config.FlushInterval,
},
provider.export,
)
if err != nil {
client.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 {
ctx, cancel := context.WithTimeout(ctx, provider.config.Kafka.ShutdownTimeout)
defer cancel()
stoppedC := make(chan error, 1)
go func() { stoppedC <- provider.server.Stop(ctx) }()
select {
case err := <-stoppedC:
provider.client.Close()
return err
case <-ctx.Done():
provider.settings.Logger().WarnContext(ctx, "audit drain timed out, closing kafka client", slog.Duration("shutdown_timeout", provider.config.Kafka.ShutdownTimeout))
// Closing fails buffered and in-flight records, so the blocked export and the rest of the drain return immediately.
provider.client.Close()
return <-stoppedC
}
}
func compressionCodec(name string) kgo.CompressionCodec {
switch name {
case "gzip":
return kgo.GzipCompression()
case "snappy":
return kgo.SnappyCompression()
case "lz4":
return kgo.Lz4Compression()
case "zstd":
return kgo.ZstdCompression()
default:
return kgo.NoCompression()
}
}

View File

@@ -0,0 +1,227 @@
package kafkaauditor
import (
"context"
"strings"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/twmb/franz-go/pkg/kerr"
"github.com/twmb/franz-go/pkg/kfake"
"github.com/twmb/franz-go/pkg/kgo"
"go.opentelemetry.io/collector/pdata/plog"
"github.com/SigNoz/signoz/pkg/auditor"
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/instrumentation/instrumentationtest"
"github.com/SigNoz/signoz/pkg/types/audittypes"
"github.com/SigNoz/signoz/pkg/types/coretypes"
"github.com/SigNoz/signoz/pkg/version"
)
func newTestCluster(t *testing.T, topics ...string) *kfake.Cluster {
t.Helper()
cluster, err := kfake.NewCluster(kfake.NumBrokers(1), kfake.SeedTopics(1, topics...))
require.NoError(t, err)
t.Cleanup(cluster.Close)
return cluster
}
func newTestProvider(t *testing.T, brokers []string, topic string, compression string) *provider {
t.Helper()
config := auditor.Config{
Provider: "kafka",
BufferSize: 10,
BatchSize: 5,
FlushInterval: 50 * time.Millisecond,
Kafka: auditor.KafkaConfig{
Brokers: brokers,
Topic: topic,
ClientID: "signoz-test",
Compression: compression,
ShutdownTimeout: 30 * time.Second,
},
}
auditor, err := newProvider(context.Background(), instrumentationtest.New().ToProviderSettings(), config, nil, version.Info)
require.NoError(t, err)
return auditor.(*provider)
}
func newTestEvent(resource coretypes.Resource, action coretypes.Verb, body string) audittypes.AuditEvent {
return audittypes.AuditEvent{
Timestamp: time.Now(),
Body: body,
EventName: audittypes.NewEventName(action),
AuditAttributes: audittypes.AuditAttributes{
Action: action,
Outcome: audittypes.OutcomeSuccess,
},
ResourceAttributes: audittypes.ResourceAttributes{
Resource: resource,
},
}
}
// Polls the topic until the consumed records hold wantLogRecords audit log records in total.
func consumeRecords(t *testing.T, brokers []string, topic string, wantLogRecords int) []*kgo.Record {
t.Helper()
client, err := kgo.NewClient(
kgo.SeedBrokers(brokers...),
kgo.ConsumeTopics(topic),
kgo.ConsumeResetOffset(kgo.NewOffset().AtStart()),
)
require.NoError(t, err)
defer client.Close()
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
var records []*kgo.Record
gotLogRecords := 0
for gotLogRecords < wantLogRecords {
fetches := client.PollFetches(ctx)
require.NoError(t, ctx.Err(), "timed out waiting for %d log records, got %d", wantLogRecords, gotLogRecords)
require.Empty(t, fetches.Errors())
for _, record := range fetches.Records() {
logs, err := (&plog.ProtoUnmarshaler{}).UnmarshalLogs(record.Value)
require.NoError(t, err)
gotLogRecords += logs.LogRecordCount()
records = append(records, record)
}
}
return records
}
func TestExport(t *testing.T) {
testCases := []struct {
name string
compression string
compressionType uint8
}{
{name: "CompressionNone", compression: "none", compressionType: 0},
{name: "CompressionGzip", compression: "gzip", compressionType: 1},
{name: "CompressionSnappy", compression: "snappy", compressionType: 2},
{name: "CompressionLz4", compression: "lz4", compressionType: 3},
{name: "CompressionZstd", compression: "zstd", compressionType: 4},
}
for _, testCase := range testCases {
t.Run(testCase.name, func(t *testing.T) {
cluster := newTestCluster(t, "tenant_audit")
provider := newTestProvider(t, cluster.ListenAddrs(), "tenant_audit", testCase.compression)
defer provider.client.Close()
events := []audittypes.AuditEvent{
newTestEvent(coretypes.ResourceUser, coretypes.VerbUpdate, "user updated"),
newTestEvent(coretypes.ResourceMetaResourceDashboard, coretypes.VerbCreate, "dashboard created"),
newTestEvent(coretypes.ResourceMetaResourceDashboard, coretypes.VerbDelete, "dashboard deleted"),
}
require.NoError(t, provider.export(context.Background(), events))
records := consumeRecords(t, cluster.ListenAddrs(), "tenant_audit", len(events))
require.Len(t, records, 1)
assert.Equal(t, "tenant_audit", records[0].Topic)
assert.Equal(t, testCase.compressionType, records[0].Attrs.CompressionType())
logs, err := (&plog.ProtoUnmarshaler{}).UnmarshalLogs(records[0].Value)
require.NoError(t, err)
assert.Equal(t, len(events), logs.LogRecordCount())
serviceName, ok := logs.ResourceLogs().At(0).Resource().Attributes().Get("service.name")
require.True(t, ok)
assert.Equal(t, "signoz", serviceName.Str())
})
}
}
func TestExport_RecordTooLarge(t *testing.T) {
cluster := newTestCluster(t, "large_audit")
provider := newTestProvider(t, cluster.ListenAddrs(), "large_audit", "none")
defer provider.client.Close()
event := newTestEvent(coretypes.ResourceUser, coretypes.VerbCreate, strings.Repeat("a", 2<<20))
err := provider.export(context.Background(), []audittypes.AuditEvent{event})
require.Error(t, err)
assert.True(t, errors.Is(err, kerr.MessageTooLarge))
}
func TestExport_UnknownTopic(t *testing.T) {
cluster := newTestCluster(t)
provider := newTestProvider(t, cluster.ListenAddrs(), "missing_audit", "lz4")
defer provider.client.Close()
err := provider.export(context.Background(), []audittypes.AuditEvent{newTestEvent(coretypes.ResourceUser, coretypes.VerbCreate, "user created")})
require.Error(t, err)
assert.True(t, errors.Is(err, kerr.UnknownTopicOrPartition))
}
func TestExport_BrokerDown_RetriesUntilContextDone(t *testing.T) {
cluster := newTestCluster(t, "down_audit")
brokers := cluster.ListenAddrs()
cluster.Close()
provider := newTestProvider(t, brokers, "down_audit", "lz4")
defer provider.client.Close()
ctx, cancel := context.WithTimeout(context.Background(), 500*time.Millisecond)
defer cancel()
start := time.Now()
err := provider.export(ctx, []audittypes.AuditEvent{newTestEvent(coretypes.ResourceUser, coretypes.VerbCreate, "user created")})
require.Error(t, err)
assert.True(t, errors.Is(err, context.DeadlineExceeded))
assert.GreaterOrEqual(t, time.Since(start), 500*time.Millisecond)
}
func TestStop_DrainsQueuedEvents(t *testing.T) {
cluster := newTestCluster(t, "drain_audit")
provider := newTestProvider(t, cluster.ListenAddrs(), "drain_audit", "lz4")
done := make(chan error, 1)
go func() { done <- provider.Start(context.Background()) }()
<-provider.Healthy()
for range 7 {
provider.server.Add(context.Background(), newTestEvent(coretypes.ResourceUser, coretypes.VerbUpdate, "user updated"))
}
require.NoError(t, provider.Stop(context.Background()))
require.NoError(t, <-done)
records := consumeRecords(t, cluster.ListenAddrs(), "drain_audit", 7)
assert.NotEmpty(t, records)
}
func TestStop_BrokerDown_ClosesAfterShutdownTimeout(t *testing.T) {
cluster := newTestCluster(t, "stuck_audit")
brokers := cluster.ListenAddrs()
cluster.Close()
provider := newTestProvider(t, brokers, "stuck_audit", "lz4")
provider.config.Kafka.ShutdownTimeout = 500 * time.Millisecond
done := make(chan error, 1)
go func() { done <- provider.Start(context.Background()) }()
<-provider.Healthy()
for range 7 {
provider.server.Add(context.Background(), newTestEvent(coretypes.ResourceUser, coretypes.VerbUpdate, "user updated"))
}
start := time.Now()
require.NoError(t, provider.Stop(context.Background()))
require.NoError(t, <-done)
assert.GreaterOrEqual(t, time.Since(start), 500*time.Millisecond)
assert.Less(t, time.Since(start), 5*time.Second)
}

View File

@@ -1,7 +1,9 @@
import type { ErrorEvent, StackFrame } from '@sentry/react';
import { AxiosError, AxiosHeaders } from 'axios';
import {
hasNoAppFrames,
isExpectedRequestFailure,
isExternalIntegrationError,
SENTRY_IGNORE_ERRORS,
} from '../sentryFilters';
@@ -138,3 +140,36 @@ describe('SENTRY_IGNORE_ERRORS', () => {
).toBe(true);
});
});
describe('isExpectedRequestFailure', () => {
function axiosErrorWithStatus(status: number): AxiosError {
const config = { headers: new AxiosHeaders() };
return new AxiosError(
`Request failed with status code ${status}`,
AxiosError.ERR_BAD_REQUEST,
config,
undefined,
{ status, statusText: '', headers: {}, config, data: {} },
);
}
it.each([401, 403])('drops a %i response', (status) => {
expect(isExpectedRequestFailure(axiosErrorWithStatus(status))).toBe(true);
});
it('drops a network error', () => {
expect(
isExpectedRequestFailure(
new AxiosError('Network Error', AxiosError.ERR_NETWORK),
),
).toBe(true);
});
it.each([400, 404, 500, 503])('keeps a %i response', (status) => {
expect(isExpectedRequestFailure(axiosErrorWithStatus(status))).toBe(false);
});
it('keeps an error that is not from axios', () => {
expect(isExpectedRequestFailure(new Error('Network Error'))).toBe(false);
});
});

View File

@@ -40,6 +40,7 @@ import defaultRoutes, {
} from './routes';
import {
hasNoAppFrames,
isExpectedRequestFailure,
isExternalIntegrationError,
SENTRY_IGNORE_ERRORS,
} from './sentryFilters';
@@ -341,6 +342,9 @@ function App(): JSX.Element {
if (error?.name === 'AbortError') {
return null;
}
if (isExpectedRequestFailure(hint?.originalException)) {
return null;
}
// Ignore benign Monaco cancellation errors (name 'Canceled').
if (error?.name === 'Canceled') {
return null;

View File

@@ -1,4 +1,5 @@
import type { ErrorEvent } from '@sentry/react';
import { AxiosError, isAxiosError } from 'axios';
export const SENTRY_IGNORE_ERRORS = [
// A tab that outlived a deploy requests hashed assets the new build no longer
@@ -14,6 +15,18 @@ export const SENTRY_IGNORE_ERRORS = [
/Called on script loaded before session recording is available/,
];
// Not UI bugs: 401/403 come from the session or role, ERR_NETWORK from a
// dropped connection.
export function isExpectedRequestFailure(error: unknown): boolean {
if (!isAxiosError(error)) {
return false;
}
const status = error.response?.status;
return (
error.code === AxiosError.ERR_NETWORK || status === 401 || status === 403
);
}
const APP_ASSETS_PATH = '/assets/';
// Frames without a position are `[native code]`, which says nothing about the

9
go.mod
View File

@@ -10,6 +10,7 @@ require (
github.com/SigNoz/clickhouse-go-mock v0.14.0
github.com/SigNoz/govaluate v0.0.0-20240203125216-988004ccc7fd
github.com/SigNoz/signoz-otel-collector v0.144.6
github.com/SigNoz/signoz-semantic-conventions/gen/go v0.0.1
github.com/antlr4-go/antlr/v4 v4.13.1
github.com/antonmedv/expr v1.15.3
github.com/cespare/xxhash/v2 v2.3.0
@@ -61,6 +62,9 @@ require (
github.com/swaggest/jsonschema-go v0.3.78
github.com/swaggest/rest v0.2.75
github.com/tidwall/gjson v1.18.0
github.com/twmb/franz-go v1.21.7
github.com/twmb/franz-go/pkg/kfake v0.0.0-20260421215025-4e7a1e1569ac
github.com/twmb/franz-go/plugin/kslog v1.0.0
github.com/uptrace/bun v1.2.9
github.com/uptrace/bun/dialect/pgdialect v1.2.9
github.com/uptrace/bun/dialect/sqlitedialect v1.2.9
@@ -144,6 +148,7 @@ require (
github.com/swaggest/refl v1.4.0 // indirect
github.com/swaggest/usecase v1.3.1 // indirect
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
github.com/twmb/franz-go/pkg/kmsg v1.13.1 // indirect
github.com/ugorji/go/codec v1.3.0 // indirect
github.com/uptrace/opentelemetry-go-extra/otelsql v0.3.2 // indirect
github.com/x448/float16 v0.8.4 // indirect
@@ -243,7 +248,7 @@ require (
github.com/josharian/intern v1.0.0 // indirect
github.com/jpillora/backoff v1.0.0 // indirect
github.com/jtolds/gls v4.20.0+incompatible // indirect
github.com/klauspost/compress v1.18.5 // indirect
github.com/klauspost/compress v1.19.2 // indirect
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
github.com/lann/builder v0.0.0-20180802200727-47ae307949d0 // indirect
github.com/lann/ps v0.0.0-20150810152359-62de8c46ede0 // indirect
@@ -272,7 +277,7 @@ require (
github.com/openfga/openfga v1.14.1
github.com/paulmach/orb v0.12.0 // indirect
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
github.com/pierrec/lz4/v4 v4.1.25 // indirect
github.com/pierrec/lz4/v4 v4.1.26 // indirect
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 // indirect
github.com/pressly/goose/v3 v3.27.0 // indirect

20
go.sum
View File

@@ -102,6 +102,8 @@ github.com/SigNoz/govaluate v0.0.0-20240203125216-988004ccc7fd h1:Bk43AsDYe0fhkb
github.com/SigNoz/govaluate v0.0.0-20240203125216-988004ccc7fd/go.mod h1:nxRcH/OEdM8QxzH37xkGzomr1O0JpYBRS6pwjsWW6Pc=
github.com/SigNoz/signoz-otel-collector v0.144.6 h1:AgblCIDlLKGtssS4ywvyEByH9fbJ7hFqZvPvUVJKwp8=
github.com/SigNoz/signoz-otel-collector v0.144.6/go.mod h1:YRQw6ETaq7fm8ts4Itgb6wC0bVhc0+nC6PFI0MaiH9k=
github.com/SigNoz/signoz-semantic-conventions/gen/go v0.0.1 h1:eKJ4vmSLYIfufgh+HaiJxXZts12Xz4jr81HMYYoQvMU=
github.com/SigNoz/signoz-semantic-conventions/gen/go v0.0.1/go.mod h1:RSsdmH4/6H+EHeNPyqJ59dGdpKS0hl2p3T5g1NbehOU=
github.com/Yiling-J/theine-go v0.6.2 h1:1GeoXeQ0O0AUkiwj2S9Jc0Mzx+hpqzmqsJ4kIC4M9AY=
github.com/Yiling-J/theine-go v0.6.2/go.mod h1:08QpMa5JZ2pKN+UJCRrCasWYO1IKCdl54Xa836rpmDU=
github.com/afex/hystrix-go v0.0.0-20180502004556-fa1af6a1f4f5/go.mod h1:SkGFH1ia65gfNATL8TAiHDNxPzPdmEL5uirI2Uyuz6c=
@@ -675,8 +677,8 @@ github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI
github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=
github.com/kisielk/sqlstruct v0.0.0-20201105191214-5f3e10d3ab46/go.mod h1:yyMNCyc/Ib3bDTKd379tNMpB/7/H5TjM2Y9QJ5THLbE=
github.com/klauspost/compress v1.13.6/go.mod h1:/3/Vjq9QcHkK5uEr5lBEmyoZ1iFhe47etQ6QUkpK6sk=
github.com/klauspost/compress v1.18.5 h1:/h1gH5Ce+VWNLSWqPzOVn6XBO+vJbCNGvjoaGBFW2IE=
github.com/klauspost/compress v1.18.5/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ=
github.com/klauspost/compress v1.19.2 h1:hMRETovs/pu/dVWN7zIT1PGG8t509MwT6bO7XSi26R8=
github.com/klauspost/compress v1.19.2/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ=
github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=
github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
github.com/knadh/koanf v1.5.0 h1:q2TSd/3Pyc/5yP9ldIrSdIz26MCcyNQzW0pEAugLPNs=
@@ -853,8 +855,8 @@ github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8
github.com/perses/spec v0.1.2 h1:yGoygcR3ZusuGDCmRMwsVXCMvMwi1qZndKV6NYNreEw=
github.com/perses/spec v0.1.2/go.mod h1:NoGI5jmGwRdkdPgyYSZJTBL4/Py+dqIPKS2QV8NOvGE=
github.com/pierrec/lz4 v2.0.5+incompatible/go.mod h1:pdkljMzZIN41W+lC3N2tnIh5sFi+IEE17M5jbnwPHcY=
github.com/pierrec/lz4/v4 v4.1.25 h1:kocOqRffaIbU5djlIBr7Wh+cx82C0vtFb0fOurZHqD0=
github.com/pierrec/lz4/v4 v4.1.25/go.mod h1:EoQMVJgeeEOMsCqCzqFm2O0cJvljX2nGZjcRIPL34O4=
github.com/pierrec/lz4/v4 v4.1.26 h1:GrpZw1gZttORinvzBdXPUXATeqlJjqUG/D87TKMnhjY=
github.com/pierrec/lz4/v4 v4.1.26/go.mod h1:EoQMVJgeeEOMsCqCzqFm2O0cJvljX2nGZjcRIPL34O4=
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c h1:+mdjkGKdHQG3305AYmdv1U2eRNDiU2ErMBj1gwrq8eQ=
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c/go.mod h1:7rwL4CYBLnjLxUqIJNnCWiEdr3bn6IUYi15bNlnbCCU=
github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA=
@@ -1055,6 +1057,16 @@ github.com/tmthrgd/go-hex v0.0.0-20190904060850-447a3041c3bc/go.mod h1:bciPuU6GH
github.com/tv42/httpunix v0.0.0-20150427012821-b75d8614f926/go.mod h1:9ESjWnEqriFuLhtthL60Sar/7RFoluCcXsuvEwTV5KM=
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
github.com/twmb/franz-go v1.21.7 h1:/DkA/o8wQN55gZWtpj2QNb9SIdxwFR7M+NecQWMdmc0=
github.com/twmb/franz-go v1.21.7/go.mod h1:89kLt1uhE1GkyossLHGdpAMFNK9mV8GYk1lfWu9FiNs=
github.com/twmb/franz-go/pkg/kadm v1.18.0 h1:WRf/LZmDdcDXwX7WMbtDU++v+b3NzYh2bCGoPMmzirw=
github.com/twmb/franz-go/pkg/kadm v1.18.0/go.mod h1:XeLhGoLXLFzK8/ryv5FfpxPxGwj4oFEGpPJMB/x6KDE=
github.com/twmb/franz-go/pkg/kfake v0.0.0-20260421215025-4e7a1e1569ac h1:6Dn8at7J4+5A8H7NgVLcPj4dfVm4pRtUZMHxXmBKxJ4=
github.com/twmb/franz-go/pkg/kfake v0.0.0-20260421215025-4e7a1e1569ac/go.mod h1:6ofUZL6jAhajJ5WAaJTX9rK3B1ozGDOwKyZczdGK8uE=
github.com/twmb/franz-go/pkg/kmsg v1.13.1 h1:fG5kItwysTk5UXqVwb64EpQEy3TydF3vYYK21nUQ+bI=
github.com/twmb/franz-go/pkg/kmsg v1.13.1/go.mod h1:+DPt4NC8RmI6hqb8G09+3giKObE6uD2Eya6CfqBpeJY=
github.com/twmb/franz-go/plugin/kslog v1.0.0 h1:I64oEmF+0PDvmyLgwrlOtg4mfpSE9GwlcLxM4af2t60=
github.com/twmb/franz-go/plugin/kslog v1.0.0/go.mod h1:8pMjK3OJJJNNYddBSbnXZkIK5dCKFIk9GcVVCDgvnQc=
github.com/twmb/murmur3 v1.1.8 h1:8Yt9taO/WN3l08xErzjeschgZU2QSrwm1kclYq+0aRg=
github.com/twmb/murmur3 v1.1.8/go.mod h1:Qq/R7NUyOfr65zD+6Q5IHKsJLwP7exErjN6lyyq3OSQ=
github.com/ua-parser/uap-go v0.0.0-20240611065828-3a4781585db6 h1:SIKIoA4e/5Y9ZOl0DCe3eVMLPOQzJxgZpfdHHeauNTM=

View File

@@ -23,7 +23,7 @@ func newTestSettings() factory.ScopedProviderSettings {
func newTestEvent(resource coretypes.Resource, action coretypes.Verb) audittypes.AuditEvent {
return audittypes.AuditEvent{
Timestamp: time.Now(),
EventName: audittypes.NewEventName(resource.Kind(), action),
EventName: audittypes.NewEventName(action),
AuditAttributes: audittypes.AuditAttributes{
Action: action,
Outcome: audittypes.OutcomeSuccess,

View File

@@ -27,6 +27,8 @@ type Config struct {
OTLPHTTP OTLPHTTPConfig `mapstructure:"otlphttp"`
File FileConfig `mapstructure:"file"`
Kafka KafkaConfig `mapstructure:"kafka"`
}
// OTLPHTTPConfig holds configuration for the OTLP HTTP exporter provider.
@@ -54,6 +56,23 @@ type FileConfig struct {
Path string `mapstructure:"path"`
}
type KafkaConfig struct {
// Brokers are the seed broker addresses (host:port) used to discover the cluster.
Brokers []string `mapstructure:"brokers"`
// Topic must already exist; the producer does not create it.
Topic string `mapstructure:"topic"`
// ClientID identifies this producer in broker logs and metrics.
ClientID string `mapstructure:"client_id"`
// Compression is the codec for produced batches: none, gzip, snappy, lz4 or zstd.
Compression string `mapstructure:"compression"`
// ShutdownTimeout bounds draining buffered events on stop; events still undelivered when it expires are dropped.
ShutdownTimeout time.Duration `mapstructure:"shutdown_timeout"`
}
// RetryConfig configures exponential backoff for the OTLP HTTP exporter.
type RetryConfig struct {
// Enabled controls whether retries are attempted on transient failures.
@@ -89,6 +108,12 @@ func newConfig() factory.Config {
MaxElapsedTime: time.Minute,
},
},
Kafka: KafkaConfig{
Brokers: []string{"localhost:9092"},
ClientID: "signoz",
Compression: "lz4",
ShutdownTimeout: 30 * time.Second,
},
}
}
@@ -125,5 +150,25 @@ func (c Config) Validate() error {
}
}
if c.Provider == "kafka" {
if len(c.Kafka.Brokers) == 0 {
return errors.New(errors.TypeInvalidInput, errors.CodeInvalidInput, "auditor::kafka::brokers must be set when provider is kafka")
}
if c.Kafka.Topic == "" {
return errors.New(errors.TypeInvalidInput, errors.CodeInvalidInput, "auditor::kafka::topic must be set when provider is kafka")
}
switch c.Kafka.Compression {
case "none", "gzip", "snappy", "lz4", "zstd":
default:
return errors.Newf(errors.TypeInvalidInput, errors.CodeInvalidInput, "auditor::kafka::compression must be one of none, gzip, snappy, lz4 or zstd, got %q", c.Kafka.Compression)
}
if c.Kafka.ShutdownTimeout <= 0 {
return errors.New(errors.TypeInvalidInput, errors.CodeInvalidInput, "auditor::kafka::shutdown_timeout must be greater than 0")
}
}
return nil
}

View File

@@ -109,10 +109,9 @@ func (middleware *Audit) emitAuditEvent(req *http.Request, writer responseCaptur
statusCode := writer.StatusCode()
span := trace.SpanFromContext(req.Context())
var errorType, errorCode string
var errorType string
if statusCode >= 400 {
errorType = render.ErrorTypeFromStatusCode(statusCode)
errorCode = render.ErrorCodeFromBody(writer.BodyBytes())
}
extractorCtx := coretypes.ExtractorContext{Request: req, ResponseBody: writer.BodyBytes()}
@@ -122,14 +121,18 @@ func (middleware *Audit) emitAuditEvent(req *http.Request, writer responseCaptur
continue
}
resource.ResolveResponse(extractorCtx)
verb, category := resource.Verb(), resource.Category()
if !audittypes.IsAudited(verb) {
continue
}
resource.ResolveResponse(extractorCtx)
switch typed := resource.(type) {
case coretypes.ResolvedResourceWithTargetResource:
for _, sourceID := range typed.SourceIDs() {
for _, targetID := range typed.TargetIDs() {
attributesList := []audittypes.ResourceAttributes{
attributesList := []audittypes.RelatedResourceAttributes{
audittypes.NewRelatedResourceAttributes(
typed.SourceResource(),
sourceID,
@@ -158,9 +161,9 @@ func (middleware *Audit) emitAuditEvent(req *http.Request, writer responseCaptur
verb,
category,
claims,
attributes,
attributes.Resource,
attributes.Target,
errorType,
errorCode,
))
}
}
@@ -179,8 +182,8 @@ func (middleware *Audit) emitAuditEvent(req *http.Request, writer responseCaptur
category,
claims,
attributes,
audittypes.TargetAttributes{},
errorType,
errorCode,
))
}
}

View File

@@ -7,7 +7,6 @@ import (
"github.com/SigNoz/signoz/pkg/errors"
jsoniter "github.com/json-iterator/go"
"github.com/tidwall/gjson"
)
const (
@@ -45,18 +44,6 @@ 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.

View File

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

View File

@@ -35,16 +35,15 @@ func auditFieldKeyMap() map[string][]*telemetrytypes.TelemetryFieldKey {
return map[string][]*telemetrytypes.TelemetryFieldKey{
"service.name": {key("service.name", res, str, false)},
"signoz.audit.action": {key("signoz.audit.action", attr, str, true)},
"signoz.audit.verb": {key("signoz.audit.verb", attr, str, true)},
"signoz.audit.outcome": {key("signoz.audit.outcome", attr, str, true)},
"signoz.audit.principal.email": {key("signoz.audit.principal.email", attr, str, true)},
"signoz.audit.principal.id": {key("signoz.audit.principal.id", attr, str, true)},
"signoz.audit.principal.type": {key("signoz.audit.principal.type", attr, str, true)},
"signoz.audit.resource.kind": {key("signoz.audit.resource.kind", res, str, false)},
"signoz.audit.resource.id": {key("signoz.audit.resource.id", res, str, false)},
"signoz.principal.email": {key("signoz.principal.email", attr, str, true)},
"signoz.principal.id": {key("signoz.principal.id", attr, str, true)},
"signoz.principal.type": {key("signoz.principal.type", attr, str, true)},
"signoz.resource.kind": {key("signoz.resource.kind", res, str, false)},
"signoz.resource.id": {key("signoz.resource.id", res, str, false)},
"signoz.audit.action_category": {key("signoz.audit.action_category", attr, str, false)},
"signoz.audit.error.type": {key("signoz.audit.error.type", attr, str, false)},
"signoz.audit.error.code": {key("signoz.audit.error.code", attr, str, false)},
"exception.type": {key("exception.type", attr, str, false)},
"http.request.method": {key("http.request.method", attr, str, false)},
"http.response.status_code": {key("http.response.status_code", attr, i64, false)},
}
@@ -88,12 +87,12 @@ func TestStatementBuilder(t *testing.T) {
Signal: telemetrytypes.SignalLogs,
Source: telemetrytypes.SourceAudit,
Filter: &qbtypes.Filter{
Expression: "signoz.audit.principal.id = '019a-1234-abcd-5678'",
Expression: "signoz.principal.id = '019a-1234-abcd-5678'",
},
Limit: 100,
},
expected: qbtypes.Statement{
Query: "SELECT timestamp, id, trace_id, span_id, trace_flags, severity_text, severity_number, scope_name, scope_version, body, event_name, attributes_string, attributes_number, attributes_bool, resource, scope_string FROM signoz_audit.distributed_logs WHERE (`attribute_string_signoz$$audit$$principal$$id` = ? AND `attribute_string_signoz$$audit$$principal$$id_exists` = true) AND timestamp >= ? AND ts_bucket_start >= ? AND timestamp < ? AND ts_bucket_start <= ? LIMIT ?",
Query: "SELECT timestamp, id, trace_id, span_id, trace_flags, severity_text, severity_number, scope_name, scope_version, body, event_name, attributes_string, attributes_number, attributes_bool, resource, scope_string FROM signoz_audit.distributed_logs WHERE (`attribute_string_signoz$$principal$$id` = ? AND `attribute_string_signoz$$principal$$id_exists` = true) AND timestamp >= ? AND ts_bucket_start >= ? AND timestamp < ? AND ts_bucket_start <= ? LIMIT ?",
Args: []any{"019a-1234-abcd-5678", "1747947419000000000", uint64(1747945619), "1747983448000000000", uint64(1747983448), 100},
},
},
@@ -122,13 +121,13 @@ func TestStatementBuilder(t *testing.T) {
Signal: telemetrytypes.SignalLogs,
Source: telemetrytypes.SourceAudit,
Filter: &qbtypes.Filter{
Expression: "signoz.audit.resource.kind = 'dashboard' AND signoz.audit.resource.id = '019b-5678-efgh-9012'",
Expression: "signoz.resource.kind = 'dashboard' AND signoz.resource.id = '019b-5678-efgh-9012'",
},
Limit: 100,
},
expected: qbtypes.Statement{
Query: "WITH __resource_filter AS (SELECT fingerprint FROM signoz_audit.distributed_logs_resource WHERE ((simpleJSONExtractString(labels, 'signoz.audit.resource.kind') = ? AND labels LIKE ? AND labels LIKE ?) AND (simpleJSONExtractString(labels, 'signoz.audit.resource.id') = ? AND labels LIKE ? AND labels LIKE ?)) AND seen_at_ts_bucket_start >= ? AND seen_at_ts_bucket_start <= ? GROUP BY fingerprint) SELECT timestamp, id, trace_id, span_id, trace_flags, severity_text, severity_number, scope_name, scope_version, body, event_name, attributes_string, attributes_number, attributes_bool, resource, scope_string FROM signoz_audit.distributed_logs WHERE resource_fingerprint GLOBAL IN (SELECT fingerprint FROM __resource_filter) AND timestamp >= ? AND ts_bucket_start >= ? AND timestamp < ? AND ts_bucket_start <= ? LIMIT ?",
Args: []any{"dashboard", "%signoz.audit.resource.kind%", "%signoz.audit.resource.kind\":\"dashboard%", "019b-5678-efgh-9012", "%signoz.audit.resource.id%", "%signoz.audit.resource.id\":\"019b-5678-efgh-9012%", uint64(1747945619), uint64(1747983448), "1747947419000000000", uint64(1747945619), "1747983448000000000", uint64(1747983448), 100},
Query: "WITH __resource_filter AS (SELECT fingerprint FROM signoz_audit.distributed_logs_resource WHERE ((simpleJSONExtractString(labels, 'signoz.resource.kind') = ? AND labels LIKE ? AND labels LIKE ?) AND (simpleJSONExtractString(labels, 'signoz.resource.id') = ? AND labels LIKE ? AND labels LIKE ?)) AND seen_at_ts_bucket_start >= ? AND seen_at_ts_bucket_start <= ? GROUP BY fingerprint) SELECT timestamp, id, trace_id, span_id, trace_flags, severity_text, severity_number, scope_name, scope_version, body, event_name, attributes_string, attributes_number, attributes_bool, resource, scope_string FROM signoz_audit.distributed_logs WHERE resource_fingerprint GLOBAL IN (SELECT fingerprint FROM __resource_filter) AND timestamp >= ? AND ts_bucket_start >= ? AND timestamp < ? AND ts_bucket_start <= ? LIMIT ?",
Args: []any{"dashboard", "%signoz.resource.kind%", "%signoz.resource.kind\":\"dashboard%", "019b-5678-efgh-9012", "%signoz.resource.id%", "%signoz.resource.id\":\"019b-5678-efgh-9012%", uint64(1747945619), uint64(1747983448), "1747947419000000000", uint64(1747945619), "1747983448000000000", uint64(1747983448), 100},
},
},
// List: all dashboard deletions (compliance — resource.kind + action AND)
@@ -139,13 +138,13 @@ func TestStatementBuilder(t *testing.T) {
Signal: telemetrytypes.SignalLogs,
Source: telemetrytypes.SourceAudit,
Filter: &qbtypes.Filter{
Expression: "signoz.audit.resource.kind = 'dashboard' AND signoz.audit.action = 'delete'",
Expression: "signoz.resource.kind = 'dashboard' AND signoz.audit.verb = 'delete'",
},
Limit: 100,
},
expected: qbtypes.Statement{
Query: "WITH __resource_filter AS (SELECT fingerprint FROM signoz_audit.distributed_logs_resource WHERE (simpleJSONExtractString(labels, 'signoz.audit.resource.kind') = ? AND labels LIKE ? AND labels LIKE ?) AND seen_at_ts_bucket_start >= ? AND seen_at_ts_bucket_start <= ? GROUP BY fingerprint) SELECT timestamp, id, trace_id, span_id, trace_flags, severity_text, severity_number, scope_name, scope_version, body, event_name, attributes_string, attributes_number, attributes_bool, resource, scope_string FROM signoz_audit.distributed_logs WHERE resource_fingerprint GLOBAL IN (SELECT fingerprint FROM __resource_filter) AND (`attribute_string_signoz$$audit$$action` = ? AND `attribute_string_signoz$$audit$$action_exists` = true) AND timestamp >= ? AND ts_bucket_start >= ? AND timestamp < ? AND ts_bucket_start <= ? LIMIT ?",
Args: []any{"dashboard", "%signoz.audit.resource.kind%", "%signoz.audit.resource.kind\":\"dashboard%", uint64(1747945619), uint64(1747983448), "delete", "1747947419000000000", uint64(1747945619), "1747983448000000000", uint64(1747983448), 100},
Query: "WITH __resource_filter AS (SELECT fingerprint FROM signoz_audit.distributed_logs_resource WHERE (simpleJSONExtractString(labels, 'signoz.resource.kind') = ? AND labels LIKE ? AND labels LIKE ?) AND seen_at_ts_bucket_start >= ? AND seen_at_ts_bucket_start <= ? GROUP BY fingerprint) SELECT timestamp, id, trace_id, span_id, trace_flags, severity_text, severity_number, scope_name, scope_version, body, event_name, attributes_string, attributes_number, attributes_bool, resource, scope_string FROM signoz_audit.distributed_logs WHERE resource_fingerprint GLOBAL IN (SELECT fingerprint FROM __resource_filter) AND (`attribute_string_signoz$$audit$$verb` = ? AND `attribute_string_signoz$$audit$$verb_exists` = true) AND timestamp >= ? AND ts_bucket_start >= ? AND timestamp < ? AND ts_bucket_start <= ? LIMIT ?",
Args: []any{"dashboard", "%signoz.resource.kind%", "%signoz.resource.kind\":\"dashboard%", uint64(1747945619), uint64(1747983448), "delete", "1747947419000000000", uint64(1747945619), "1747983448000000000", uint64(1747983448), 100},
},
},
// List: all actions by service accounts (materialized principal.type)
@@ -156,12 +155,12 @@ func TestStatementBuilder(t *testing.T) {
Signal: telemetrytypes.SignalLogs,
Source: telemetrytypes.SourceAudit,
Filter: &qbtypes.Filter{
Expression: "signoz.audit.principal.type = 'service_account'",
Expression: "signoz.principal.type = 'service_account'",
},
Limit: 100,
},
expected: qbtypes.Statement{
Query: "SELECT timestamp, id, trace_id, span_id, trace_flags, severity_text, severity_number, scope_name, scope_version, body, event_name, attributes_string, attributes_number, attributes_bool, resource, scope_string FROM signoz_audit.distributed_logs WHERE (`attribute_string_signoz$$audit$$principal$$type` = ? AND `attribute_string_signoz$$audit$$principal$$type_exists` = true) AND timestamp >= ? AND ts_bucket_start >= ? AND timestamp < ? AND ts_bucket_start <= ? LIMIT ?",
Query: "SELECT timestamp, id, trace_id, span_id, trace_flags, severity_text, severity_number, scope_name, scope_version, body, event_name, attributes_string, attributes_number, attributes_bool, resource, scope_string FROM signoz_audit.distributed_logs WHERE (`attribute_string_signoz$$principal$$type` = ? AND `attribute_string_signoz$$principal$$type_exists` = true) AND timestamp >= ? AND ts_bucket_start >= ? AND timestamp < ? AND ts_bucket_start <= ? LIMIT ?",
Args: []any{"service_account", "1747947419000000000", uint64(1747945619), "1747983448000000000", uint64(1747983448), 100},
},
},
@@ -174,14 +173,14 @@ func TestStatementBuilder(t *testing.T) {
Source: telemetrytypes.SourceAudit,
StepInterval: qbtypes.Step{Duration: 60 * time.Second},
Filter: &qbtypes.Filter{
Expression: "signoz.audit.outcome = 'failure' AND signoz.audit.action = 'update'",
Expression: "signoz.audit.outcome = 'failure' AND signoz.audit.verb = 'update'",
},
Aggregations: []qbtypes.LogAggregation{
{Expression: "count()"},
},
},
expected: qbtypes.Statement{
Query: "SELECT count() AS __result_0 FROM signoz_audit.distributed_logs WHERE ((`attribute_string_signoz$$audit$$outcome` = ? AND `attribute_string_signoz$$audit$$outcome_exists` = true) AND (`attribute_string_signoz$$audit$$action` = ? AND `attribute_string_signoz$$audit$$action_exists` = true)) AND timestamp >= ? AND ts_bucket_start >= ? AND timestamp < ? AND ts_bucket_start <= ? ORDER BY __result_0 DESC",
Query: "SELECT count() AS __result_0 FROM signoz_audit.distributed_logs WHERE ((`attribute_string_signoz$$audit$$outcome` = ? AND `attribute_string_signoz$$audit$$outcome_exists` = true) AND (`attribute_string_signoz$$audit$$verb` = ? AND `attribute_string_signoz$$audit$$verb_exists` = true)) AND timestamp >= ? AND ts_bucket_start >= ? AND timestamp < ? AND ts_bucket_start <= ? ORDER BY __result_0 DESC",
Args: []any{"failure", "update", "1747947419000000000", uint64(1747945619), "1747983448000000000", uint64(1747983448)},
},
},
@@ -200,12 +199,12 @@ func TestStatementBuilder(t *testing.T) {
Expression: "signoz.audit.outcome = 'failure'",
},
GroupBy: []qbtypes.GroupByKey{
{TelemetryFieldKey: telemetrytypes.TelemetryFieldKey{Name: "signoz.audit.principal.email"}},
{TelemetryFieldKey: telemetrytypes.TelemetryFieldKey{Name: "signoz.principal.email"}},
},
Limit: 5,
},
expected: qbtypes.Statement{
Query: "WITH __limit_cte AS (SELECT toString(multiIf(`attribute_string_signoz$$audit$$principal$$email_exists` = true, `attribute_string_signoz$$audit$$principal$$email`, NULL)) AS `signoz.audit.principal.email`, count() AS __result_0 FROM signoz_audit.distributed_logs WHERE (`attribute_string_signoz$$audit$$outcome` = ? AND `attribute_string_signoz$$audit$$outcome_exists` = true) AND timestamp >= ? AND ts_bucket_start >= ? AND timestamp < ? AND ts_bucket_start <= ? GROUP BY `signoz.audit.principal.email` ORDER BY __result_0 DESC LIMIT ?) SELECT toStartOfInterval(fromUnixTimestamp64Nano(timestamp), INTERVAL 60 SECOND) AS ts, toString(multiIf(`attribute_string_signoz$$audit$$principal$$email_exists` = true, `attribute_string_signoz$$audit$$principal$$email`, NULL)) AS `signoz.audit.principal.email`, count() AS __result_0 FROM signoz_audit.distributed_logs WHERE (`attribute_string_signoz$$audit$$outcome` = ? AND `attribute_string_signoz$$audit$$outcome_exists` = true) AND timestamp >= ? AND ts_bucket_start >= ? AND timestamp < ? AND ts_bucket_start <= ? AND (`signoz.audit.principal.email`) GLOBAL IN (SELECT `signoz.audit.principal.email` FROM __limit_cte) GROUP BY ts, `signoz.audit.principal.email`",
Query: "WITH __limit_cte AS (SELECT toString(multiIf(`attribute_string_signoz$$principal$$email_exists` = true, `attribute_string_signoz$$principal$$email`, NULL)) AS `signoz.principal.email`, count() AS __result_0 FROM signoz_audit.distributed_logs WHERE (`attribute_string_signoz$$audit$$outcome` = ? AND `attribute_string_signoz$$audit$$outcome_exists` = true) AND timestamp >= ? AND ts_bucket_start >= ? AND timestamp < ? AND ts_bucket_start <= ? GROUP BY `signoz.principal.email` ORDER BY __result_0 DESC LIMIT ?) SELECT toStartOfInterval(fromUnixTimestamp64Nano(timestamp), INTERVAL 60 SECOND) AS ts, toString(multiIf(`attribute_string_signoz$$principal$$email_exists` = true, `attribute_string_signoz$$principal$$email`, NULL)) AS `signoz.principal.email`, count() AS __result_0 FROM signoz_audit.distributed_logs WHERE (`attribute_string_signoz$$audit$$outcome` = ? AND `attribute_string_signoz$$audit$$outcome_exists` = true) AND timestamp >= ? AND ts_bucket_start >= ? AND timestamp < ? AND ts_bucket_start <= ? AND (`signoz.principal.email`) GLOBAL IN (SELECT `signoz.principal.email` FROM __limit_cte) GROUP BY ts, `signoz.principal.email`",
Args: []any{"failure", "1747947419000000000", uint64(1747945619), "1747983448000000000", uint64(1747983448), 5, "failure", "1747947419000000000", uint64(1747945619), "1747983448000000000", uint64(1747983448)},
},
},
@@ -243,13 +242,13 @@ func TestStatementBuilderHeatmap(t *testing.T) {
Source: telemetrytypes.SourceAudit,
StepInterval: qbtypes.Step{Duration: 60 * time.Second},
Aggregations: []qbtypes.LogAggregation{{Expression: "http.response.status_code"}},
Filter: &qbtypes.Filter{Expression: "signoz.audit.resource.kind = 'dashboard'"},
GroupBy: []qbtypes.GroupByKey{{TelemetryFieldKey: telemetrytypes.TelemetryFieldKey{Name: "signoz.audit.action"}}},
Filter: &qbtypes.Filter{Expression: "signoz.resource.kind = 'dashboard'"},
GroupBy: []qbtypes.GroupByKey{{TelemetryFieldKey: telemetrytypes.TelemetryFieldKey{Name: "signoz.audit.verb"}}},
BucketOptions: &qbtypes.BucketOptions{Kind: qbtypes.BucketsKindLinear, Spec: qbtypes.LinearBucketsSpec{MaxValue: 500, NumBuckets: 5}},
},
expected: qbtypes.Statement{
Query: "WITH __resource_filter AS (SELECT fingerprint FROM signoz_audit.distributed_logs_resource WHERE ((simpleJSONExtractString(labels, 'signoz.audit.resource.kind') = ? AND labels LIKE ? AND labels LIKE ?)) AND seen_at_ts_bucket_start >= ? AND seen_at_ts_bucket_start <= ? GROUP BY fingerprint), ifNull(multiIf(mapContains(attributes_number, 'http.response.status_code'), toFloat64(attributes_number['http.response.status_code']), NULL), 0) AS __bucket_value SELECT toStartOfInterval(fromUnixTimestamp64Nano(timestamp), INTERVAL 60 SECOND) AS ts, toString(multiIf(`attribute_string_signoz$$audit$$action_exists` = true, `attribute_string_signoz$$audit$$action`, NULL)) AS `signoz.audit.action`, multiIf(__bucket_value <= 0, toFloat64('-Inf'), __bucket_value > 500, toFloat64(500), (least(greatest(ceil(__bucket_value * 5 / 500), 1), 5) - 1) * 500 / 5) AS __bucket_min, multiIf(__bucket_value <= 0, toFloat64(0), __bucket_value > 500, toFloat64('+Inf'), least(greatest(ceil(__bucket_value * 5 / 500), 1), 5) * 500 / 5) AS __bucket_max, count() AS __result_0 FROM signoz_audit.distributed_logs WHERE resource_fingerprint GLOBAL IN (SELECT fingerprint FROM __resource_filter) AND mapContains(attributes_number, 'http.response.status_code') AND timestamp >= ? AND ts_bucket_start >= ? AND timestamp < ? AND ts_bucket_start <= ? GROUP BY ts, `signoz.audit.action`, __bucket_min, __bucket_max",
Args: []any{"dashboard", "%signoz.audit.resource.kind%", "%signoz.audit.resource.kind\":\"dashboard%", uint64(1747945619), uint64(1747983448), "1747947419000000000", uint64(1747945619), "1747983448000000000", uint64(1747983448)},
Query: "WITH __resource_filter AS (SELECT fingerprint FROM signoz_audit.distributed_logs_resource WHERE ((simpleJSONExtractString(labels, 'signoz.resource.kind') = ? AND labels LIKE ? AND labels LIKE ?)) AND seen_at_ts_bucket_start >= ? AND seen_at_ts_bucket_start <= ? GROUP BY fingerprint), ifNull(multiIf(mapContains(attributes_number, 'http.response.status_code'), toFloat64(attributes_number['http.response.status_code']), NULL), 0) AS __bucket_value SELECT toStartOfInterval(fromUnixTimestamp64Nano(timestamp), INTERVAL 60 SECOND) AS ts, toString(multiIf(`attribute_string_signoz$$audit$$verb_exists` = true, `attribute_string_signoz$$audit$$verb`, NULL)) AS `signoz.audit.verb`, multiIf(__bucket_value <= 0, toFloat64('-Inf'), __bucket_value > 500, toFloat64(500), (least(greatest(ceil(__bucket_value * 5 / 500), 1), 5) - 1) * 500 / 5) AS __bucket_min, multiIf(__bucket_value <= 0, toFloat64(0), __bucket_value > 500, toFloat64('+Inf'), least(greatest(ceil(__bucket_value * 5 / 500), 1), 5) * 500 / 5) AS __bucket_max, count() AS __result_0 FROM signoz_audit.distributed_logs WHERE resource_fingerprint GLOBAL IN (SELECT fingerprint FROM __resource_filter) AND mapContains(attributes_number, 'http.response.status_code') AND timestamp >= ? AND ts_bucket_start >= ? AND timestamp < ? AND ts_bucket_start <= ? GROUP BY ts, `signoz.audit.verb`, __bucket_min, __bucket_max",
Args: []any{"dashboard", "%signoz.resource.kind%", "%signoz.resource.kind\":\"dashboard%", uint64(1747945619), uint64(1747983448), "1747947419000000000", uint64(1747945619), "1747983448000000000", uint64(1747983448)},
},
},
{
@@ -260,13 +259,13 @@ func TestStatementBuilderHeatmap(t *testing.T) {
Source: telemetrytypes.SourceAudit,
StepInterval: qbtypes.Step{Duration: 60 * time.Second},
Aggregations: []qbtypes.LogAggregation{{Expression: "http.response.status_code"}},
Filter: &qbtypes.Filter{Expression: "signoz.audit.resource.kind = 'dashboard'"},
GroupBy: []qbtypes.GroupByKey{{TelemetryFieldKey: telemetrytypes.TelemetryFieldKey{Name: "signoz.audit.action"}}},
Filter: &qbtypes.Filter{Expression: "signoz.resource.kind = 'dashboard'"},
GroupBy: []qbtypes.GroupByKey{{TelemetryFieldKey: telemetrytypes.TelemetryFieldKey{Name: "signoz.audit.verb"}}},
BucketOptions: &qbtypes.BucketOptions{Kind: qbtypes.BucketsKindLinear, Spec: qbtypes.LinearBucketsSpec{MaxValue: 500, NumBuckets: 5}},
},
expected: qbtypes.Statement{
Query: "WITH __resource_filter AS (SELECT fingerprint FROM signoz_audit.distributed_logs_resource WHERE ((simpleJSONExtractString(labels, 'signoz.audit.resource.kind') = ? AND labels LIKE ? AND labels LIKE ?)) AND seen_at_ts_bucket_start >= ? AND seen_at_ts_bucket_start <= ? GROUP BY fingerprint), ifNull(multiIf(mapContains(attributes_number, 'http.response.status_code'), toFloat64(attributes_number['http.response.status_code']), NULL), 0) AS __bucket_value SELECT toStartOfInterval(fromUnixTimestamp64Nano(timestamp), INTERVAL 60 SECOND) AS ts, toString(multiIf(`attribute_string_signoz$$audit$$action_exists` = true, `attribute_string_signoz$$audit$$action`, NULL)) AS `signoz.audit.action`, multiIf(__bucket_value <= 0, toFloat64('-Inf'), __bucket_value > 500, toFloat64(500), (least(greatest(ceil(__bucket_value * 5 / 500), 1), 5) - 1) * 500 / 5) AS __bucket_min, multiIf(__bucket_value <= 0, toFloat64(0), __bucket_value > 500, toFloat64('+Inf'), least(greatest(ceil(__bucket_value * 5 / 500), 1), 5) * 500 / 5) AS __bucket_max, count() AS __result_0 FROM signoz_audit.distributed_logs WHERE resource_fingerprint GLOBAL IN (SELECT fingerprint FROM __resource_filter) AND mapContains(attributes_number, 'http.response.status_code') AND timestamp >= ? AND ts_bucket_start >= ? AND timestamp < ? AND ts_bucket_start <= ? GROUP BY ts, `signoz.audit.action`, __bucket_min, __bucket_max",
Args: []any{"dashboard", "%signoz.audit.resource.kind%", "%signoz.audit.resource.kind\":\"dashboard%", uint64(1747945619), uint64(1747983448), "1747947419000000000", uint64(1747945619), "1747983448000000000", uint64(1747983448)},
Query: "WITH __resource_filter AS (SELECT fingerprint FROM signoz_audit.distributed_logs_resource WHERE ((simpleJSONExtractString(labels, 'signoz.resource.kind') = ? AND labels LIKE ? AND labels LIKE ?)) AND seen_at_ts_bucket_start >= ? AND seen_at_ts_bucket_start <= ? GROUP BY fingerprint), ifNull(multiIf(mapContains(attributes_number, 'http.response.status_code'), toFloat64(attributes_number['http.response.status_code']), NULL), 0) AS __bucket_value SELECT toStartOfInterval(fromUnixTimestamp64Nano(timestamp), INTERVAL 60 SECOND) AS ts, toString(multiIf(`attribute_string_signoz$$audit$$verb_exists` = true, `attribute_string_signoz$$audit$$verb`, NULL)) AS `signoz.audit.verb`, multiIf(__bucket_value <= 0, toFloat64('-Inf'), __bucket_value > 500, toFloat64(500), (least(greatest(ceil(__bucket_value * 5 / 500), 1), 5) - 1) * 500 / 5) AS __bucket_min, multiIf(__bucket_value <= 0, toFloat64(0), __bucket_value > 500, toFloat64('+Inf'), least(greatest(ceil(__bucket_value * 5 / 500), 1), 5) * 500 / 5) AS __bucket_max, count() AS __result_0 FROM signoz_audit.distributed_logs WHERE resource_fingerprint GLOBAL IN (SELECT fingerprint FROM __resource_filter) AND mapContains(attributes_number, 'http.response.status_code') AND timestamp >= ? AND ts_bucket_start >= ? AND timestamp < ? AND ts_bucket_start <= ? GROUP BY ts, `signoz.audit.verb`, __bucket_min, __bucket_max",
Args: []any{"dashboard", "%signoz.resource.kind%", "%signoz.resource.kind\":\"dashboard%", uint64(1747945619), uint64(1747983448), "1747947419000000000", uint64(1747945619), "1747983448000000000", uint64(1747983448)},
},
},
}

View File

@@ -1,9 +1,12 @@
package audittypes
import (
"net"
"net/http"
"strconv"
"strings"
"github.com/SigNoz/signoz-semantic-conventions/gen/go/sigsemconv"
"github.com/SigNoz/signoz/pkg/types/authtypes"
"github.com/SigNoz/signoz/pkg/types/coretypes"
"github.com/SigNoz/signoz/pkg/valuer"
@@ -16,10 +19,9 @@ type AuditAttributes struct {
Action coretypes.Verb // guaranteed to be present
ActionCategory coretypes.ActionCategory // guaranteed to be present
Outcome Outcome // guaranteed to be present
IdentNProvider authtypes.IdentNProvider
}
func NewAuditAttributesFromHTTP(statusCode int, action coretypes.Verb, category coretypes.ActionCategory, claims authtypes.Claims) AuditAttributes {
func NewAuditAttributesFromHTTP(statusCode int, action coretypes.Verb, category coretypes.ActionCategory) AuditAttributes {
outcome := OutcomeFailure
if statusCode >= 200 && statusCode < 400 {
outcome = OutcomeSuccess
@@ -29,15 +31,13 @@ func NewAuditAttributesFromHTTP(statusCode int, action coretypes.Verb, category
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())
dest.PutStr(string(sigsemconv.SigNozAuditVerbKey), attributes.Action.StringValue())
dest.PutStr(string(sigsemconv.SigNozAuditActionCategoryKey), attributes.ActionCategory.StringValue())
dest.PutStr(string(sigsemconv.SigNozAuditOutcomeKey), attributes.Outcome.StringValue())
}
// Audit attributes — Principal (Who).
@@ -50,6 +50,9 @@ type PrincipalAttributes struct {
func NewPrincipalAttributesFromClaims(claims authtypes.Claims) PrincipalAttributes {
principalID, _ := valuer.NewUUID(claims.UserID)
if claims.Principal == authtypes.PrincipalServiceAccount {
principalID, _ = valuer.NewUUID(claims.ServiceAccountID)
}
principalEmail, _ := valuer.NewEmail(claims.Email)
principalOrgID, _ := valuer.NewUUID(claims.OrgID)
@@ -62,10 +65,12 @@ func NewPrincipalAttributesFromClaims(claims authtypes.Claims) PrincipalAttribut
}
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())
if !attributes.PrincipalID.IsZero() {
dest.PutStr(string(sigsemconv.SigNozPrincipalIDKey), attributes.PrincipalID.StringValue())
putStrIfNotEmpty(dest, string(sigsemconv.SigNozPrincipalTypeKey), attributes.PrincipalType.StringValue())
}
putStrIfNotEmpty(dest, string(sigsemconv.SigNozPrincipalEmailKey), attributes.PrincipalEmail.String())
dest.PutStr(string(sigsemconv.SigNozOrganizationIDKey), attributes.PrincipalOrgID.StringValue())
}
// Audit attributes — Resource (On What).
@@ -73,11 +78,6 @@ func (attributes PrincipalAttributes) Put(dest pcommon.Map) {
type ResourceAttributes struct {
Resource coretypes.Resource // guaranteed to be present
ResourceID string
// TargetResource names the counterpart of an attach/detach event (audit
// context only). nil when there is no relationship.
TargetResource coretypes.Resource
TargetResourceID string
}
func NewResourceAttributes(resource coretypes.Resource, resourceID string) ResourceAttributes {
@@ -87,55 +87,75 @@ func NewResourceAttributes(resource coretypes.Resource, resourceID string) Resou
}
}
// NewAttachResourceAttributes builds resource attributes that additionally name
// the target counterpart (used for attach/detach audit events).
func NewRelatedResourceAttributes(resource coretypes.Resource, resourceID string, targetResource coretypes.Resource, targetResourceID string) ResourceAttributes {
return ResourceAttributes{
Resource: resource,
ResourceID: resourceID,
// Put writes the resource attributes to an OTel Resource's attribute map.
// These are resource-level attributes (stored in the resource JSON column),
// not event-level attributes (stored in attributes_string).
func (attributes ResourceAttributes) Put(orgID valuer.UUID, dest pcommon.Map) {
putStrIfNotEmpty(dest, string(sigsemconv.SigNozResourceKindKey), attributes.Resource.Kind().String())
putStrIfNotEmpty(dest, string(sigsemconv.SigNozResourceIDKey), attributes.ResourceID)
if attributes.ResourceID != "" {
putStrIfNotEmpty(dest, string(sigsemconv.SigNozResourceObjectKey), attributes.Resource.Object(orgID, attributes.ResourceID))
}
}
// Audit attributes — Target (To/From What).
// The counterpart of an attach/detach event, placed on the log record.
type TargetAttributes struct {
// nil when there is no relationship.
TargetResource coretypes.Resource
TargetResourceID string
}
func NewTargetAttributes(targetResource coretypes.Resource, targetResourceID string) TargetAttributes {
return TargetAttributes{
TargetResource: targetResource,
TargetResourceID: targetResourceID,
}
}
// PutResource writes the resource attributes to an OTel Resource's attribute map.
// These are resource-level attributes (stored in the resource JSON column),
// not event-level attributes (stored in attributes_string).
func (attributes ResourceAttributes) PutResource(orgID valuer.UUID, dest pcommon.Map) {
putStrIfNotEmpty(dest, "signoz.audit.resource.kind", attributes.Resource.Kind().String())
putStrIfNotEmpty(dest, "signoz.audit.resource.id", attributes.ResourceID)
if attributes.ResourceID != "" {
putStrIfNotEmpty(dest, "signoz.audit.resource.object", attributes.Resource.Object(orgID, attributes.ResourceID))
func (attributes TargetAttributes) Put(orgID valuer.UUID, dest pcommon.Map) {
if attributes.TargetResource == nil {
return
}
if attributes.TargetResource != nil {
putStrIfNotEmpty(dest, "signoz.audit.resource.target.kind", attributes.TargetResource.Kind().String())
putStrIfNotEmpty(dest, "signoz.audit.resource.target.id", attributes.TargetResourceID)
if attributes.TargetResourceID != "" {
putStrIfNotEmpty(dest, "signoz.audit.resource.target.object", attributes.TargetResource.Object(orgID, attributes.TargetResourceID))
}
putStrIfNotEmpty(dest, string(sigsemconv.SigNozResourceTargetKindKey), attributes.TargetResource.Kind().String())
putStrIfNotEmpty(dest, string(sigsemconv.SigNozResourceTargetIDKey), attributes.TargetResourceID)
if attributes.TargetResourceID != "" {
putStrIfNotEmpty(dest, string(sigsemconv.SigNozResourceTargetObjectKey), attributes.TargetResource.Object(orgID, attributes.TargetResourceID))
}
}
// Audit attributes: Relation (attach/detach).
// One side of a relation: the resource the event is recorded from, placed on
// the Resource, and its counterpart, placed on the log record.
type RelatedResourceAttributes struct {
Resource ResourceAttributes
Target TargetAttributes
}
func NewRelatedResourceAttributes(resource coretypes.Resource, resourceID string, targetResource coretypes.Resource, targetResourceID string) RelatedResourceAttributes {
return RelatedResourceAttributes{
Resource: NewResourceAttributes(resource, resourceID),
Target: NewTargetAttributes(targetResource, targetResourceID),
}
}
// 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.
// PII data into audit logs. The error type is sufficient for filtering and
// alerting; investigators can correlate via trace ID.
type ErrorAttributes struct {
ErrorType string
ErrorCode string
}
func NewErrorAttributes(errorType, errorCode string) ErrorAttributes {
func NewErrorAttributes(errorType 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)
putStrIfNotEmpty(dest, string(semconv.ExceptionTypeKey), attributes.ErrorType)
}
// Audit attributes — Transport Context (Where/How).
@@ -148,6 +168,7 @@ type TransportAttributes struct {
HTTPStatusCode int
URLPath string
ClientAddress string
ClientPort int
UserAgent string
}
@@ -157,15 +178,18 @@ func NewTransportAttributesFromHTTP(req *http.Request, route string, statusCode
scheme = "https"
}
clientAddress, clientPort := splitHostPort(req.RemoteAddr)
return TransportAttributes{
NetworkProtocolName: "http",
NetworkProtocolVersion: req.Proto,
NetworkProtocolVersion: networkProtocolVersion(req),
URLScheme: scheme,
HTTPMethod: req.Method,
HTTPRoute: route,
HTTPStatusCode: statusCode,
URLPath: req.URL.Path,
ClientAddress: req.RemoteAddr,
ClientAddress: clientAddress,
ClientPort: clientPort,
UserAgent: req.UserAgent(),
}
}
@@ -181,16 +205,43 @@ func (attributes TransportAttributes) Put(dest pcommon.Map) {
}
putStrIfNotEmpty(dest, string(semconv.URLPathKey), attributes.URLPath)
putStrIfNotEmpty(dest, string(semconv.ClientAddressKey), attributes.ClientAddress)
if attributes.ClientPort != 0 {
dest.PutInt(string(semconv.ClientPortKey), int64(attributes.ClientPort))
}
putStrIfNotEmpty(dest, string(semconv.UserAgentOriginalKey), attributes.UserAgent)
}
// networkProtocolVersion drops the HTTP/ prefix, and the .0 of HTTP/2 and later.
func networkProtocolVersion(req *http.Request) string {
if req.ProtoMajor >= 2 && req.ProtoMinor == 0 {
return strconv.Itoa(req.ProtoMajor)
}
return strconv.Itoa(req.ProtoMajor) + "." + strconv.Itoa(req.ProtoMinor)
}
// splitHostPort returns addr unchanged with port 0 when it has no valid port.
func splitHostPort(addr string) (string, int) {
host, portStr, err := net.SplitHostPort(addr)
if err != nil {
return addr, 0
}
port, err := strconv.Atoi(portStr)
if err != nil {
return addr, 0
}
return host, port
}
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 {
func newBody(auditAttributes AuditAttributes, principalAttributes PrincipalAttributes, resourceAttributes ResourceAttributes, targetAttributes TargetAttributes, errorAttributes ErrorAttributes) string {
var b strings.Builder
// Principal: "email (id)" or "id" or "email" or omitted.
@@ -227,36 +278,25 @@ func newBody(auditAttributes AuditAttributes, principalAttributes PrincipalAttri
b.WriteString(")")
}
// Target (attach/detach context): " · target kind (id)" or " · target kind".
if resourceAttributes.TargetResource != nil {
b.WriteString(" to ")
b.WriteString(resourceAttributes.TargetResource.Kind().String())
if resourceAttributes.TargetResourceID != "" {
// Target (attach/detach context): " to target kind (id)" or " from target kind" for detach.
if targetAttributes.TargetResource != nil {
if auditAttributes.Action == coretypes.VerbDetach {
b.WriteString(" from ")
} else {
b.WriteString(" to ")
}
b.WriteString(targetAttributes.TargetResource.Kind().String())
if targetAttributes.TargetResourceID != "" {
b.WriteString(" (")
b.WriteString(resourceAttributes.TargetResourceID)
b.WriteString(targetAttributes.TargetResourceID)
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(")")
}
}
// Error suffix (failure only): ": type" or omitted.
if auditAttributes.Outcome == OutcomeFailure && errorAttributes.ErrorType != "" {
b.WriteString(": ")
b.WriteString(errorAttributes.ErrorType)
}
return b.String()

View File

@@ -3,15 +3,12 @@ package audittypes
import (
"testing"
"github.com/SigNoz/signoz/pkg/types/authtypes"
"github.com/SigNoz/signoz/pkg/types/coretypes"
"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
@@ -36,7 +33,7 @@ func TestNewAuditAttributesFromHTTP_OutcomeBoundary(t *testing.T) {
for _, testCase := range testCases {
t.Run(testCase.name, func(t *testing.T) {
attrs := NewAuditAttributesFromHTTP(testCase.statusCode, coretypes.VerbUpdate, coretypes.ActionCategoryConfigurationChange, claims)
attrs := NewAuditAttributesFromHTTP(testCase.statusCode, coretypes.VerbUpdate, coretypes.ActionCategoryConfigurationChange)
assert.Equal(t, testCase.expectedOutcome, attrs.Outcome)
})
}
@@ -48,6 +45,7 @@ func TestNewBody(t *testing.T) {
auditAttributes AuditAttributes
principalAttributes PrincipalAttributes
resourceAttributes ResourceAttributes
targetAttributes TargetAttributes
errorAttributes ErrorAttributes
expectedBody string
}{
@@ -154,9 +152,8 @@ func TestNewBody(t *testing.T) {
},
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)",
expectedBody: "viewer@acme.com (019aaaaa-bbbb-7000-8000-cccc00000002) failed to update dashboard (019b-5678): forbidden",
},
{
name: "Failure_ErrorTypeOnly",
@@ -193,11 +190,47 @@ func TestNewBody(t *testing.T) {
errorAttributes: ErrorAttributes{},
expectedBody: "test@acme.com (019a1234-abcd-7000-8000-567800000001) failed to create dashboard (019b-5678)",
},
{
name: "Success_Attach",
auditAttributes: AuditAttributes{
Action: coretypes.VerbAttach,
Outcome: OutcomeSuccess,
},
principalAttributes: PrincipalAttributes{},
resourceAttributes: NewResourceAttributes(coretypes.ResourceRole, "role-1"),
targetAttributes: NewTargetAttributes(coretypes.ResourceServiceAccount, "sa-1"),
errorAttributes: ErrorAttributes{},
expectedBody: "attached role (role-1) to serviceaccount (sa-1)",
},
{
name: "Success_Detach",
auditAttributes: AuditAttributes{
Action: coretypes.VerbDetach,
Outcome: OutcomeSuccess,
},
principalAttributes: PrincipalAttributes{},
resourceAttributes: NewResourceAttributes(coretypes.ResourceRole, "role-1"),
targetAttributes: NewTargetAttributes(coretypes.ResourceServiceAccount, "sa-1"),
errorAttributes: ErrorAttributes{},
expectedBody: "detached role (role-1) from serviceaccount (sa-1)",
},
{
name: "Failure_Detach",
auditAttributes: AuditAttributes{
Action: coretypes.VerbDetach,
Outcome: OutcomeFailure,
},
principalAttributes: PrincipalAttributes{},
resourceAttributes: NewResourceAttributes(coretypes.ResourceRole, "role-1"),
targetAttributes: NewTargetAttributes(coretypes.ResourceServiceAccount, "sa-1"),
errorAttributes: ErrorAttributes{},
expectedBody: "failed to detach role (role-1) from serviceaccount (sa-1)",
},
}
for _, testCase := range testCases {
t.Run(testCase.name, func(t *testing.T) {
body := newBody(testCase.auditAttributes, testCase.principalAttributes, testCase.resourceAttributes, testCase.errorAttributes)
body := newBody(testCase.auditAttributes, testCase.principalAttributes, testCase.resourceAttributes, testCase.targetAttributes, testCase.errorAttributes)
assert.Equal(t, testCase.expectedBody, body)
})
}

View File

@@ -37,6 +37,9 @@ type AuditEvent struct {
// Custom Audit Attributes - Resource
ResourceAttributes ResourceAttributes
// Custom Audit Attributes - Target
TargetAttributes TargetAttributes
// Custom Audit Attributes - Error
ErrorAttributes ErrorAttributes
@@ -56,23 +59,24 @@ func NewAuditEventFromHTTPRequest(
actionCategory coretypes.ActionCategory,
claims authtypes.Claims,
resourceAttributes ResourceAttributes,
targetAttributes TargetAttributes,
errorType string,
errorCode string,
) AuditEvent {
auditAttributes := NewAuditAttributesFromHTTP(statusCode, action, actionCategory, claims)
auditAttributes := NewAuditAttributesFromHTTP(statusCode, action, actionCategory)
principalAttributes := NewPrincipalAttributesFromClaims(claims)
errorAttributes := NewErrorAttributes(errorType, errorCode)
errorAttributes := NewErrorAttributes(errorType)
transportAttributes := NewTransportAttributesFromHTTP(req, route, statusCode)
return AuditEvent{
Timestamp: time.Now(),
TraceID: traceID,
SpanID: spanID,
Body: newBody(auditAttributes, principalAttributes, resourceAttributes, errorAttributes),
EventName: NewEventName(resourceAttributes.Resource.Kind(), auditAttributes.Action),
Body: newBody(auditAttributes, principalAttributes, resourceAttributes, targetAttributes, errorAttributes),
EventName: NewEventName(auditAttributes.Action),
AuditAttributes: auditAttributes,
PrincipalAttributes: principalAttributes,
ResourceAttributes: resourceAttributes,
TargetAttributes: targetAttributes,
ErrorAttributes: errorAttributes,
TransportAttributes: transportAttributes,
}
@@ -102,7 +106,7 @@ func NewPLogsFromAuditEvents(events []AuditEvent, name string, version string, s
resourceAttrs.PutStr(string(semconv.ServiceNameKey), name)
resourceAttrs.PutStr(string(semconv.ServiceVersionKey), version)
head := events[groups[key][0]]
head.ResourceAttributes.PutResource(head.PrincipalAttributes.PrincipalOrgID, resourceAttrs)
head.ResourceAttributes.Put(head.PrincipalAttributes.PrincipalOrgID, resourceAttrs)
scopeLogs := resourceLogs.ScopeLogs().AppendEmpty()
scopeLogs.Scope().SetName(scope)
@@ -148,6 +152,9 @@ func (event AuditEvent) ToLogRecord(dest plog.LogRecord) {
// Resource attributes are set at the ResourceLogs level, not per-LogRecord.
// See NewPLogsFromAuditEvents.
// Target attributes
event.TargetAttributes.Put(event.PrincipalAttributes.PrincipalOrgID, attrs)
// Error attributes
event.ErrorAttributes.Put(attrs)

View File

@@ -1,6 +1,17 @@
package audittypes
import "github.com/SigNoz/signoz/pkg/types/coretypes"
import (
"github.com/SigNoz/signoz-semantic-conventions/gen/go/sigsemconv"
"github.com/SigNoz/signoz/pkg/types/coretypes"
)
var eventNames = map[coretypes.Verb]EventName{
coretypes.VerbCreate: {s: sigsemconv.SigNozAuditCreateEventName},
coretypes.VerbUpdate: {s: sigsemconv.SigNozAuditUpdateEventName},
coretypes.VerbDelete: {s: sigsemconv.SigNozAuditDeleteEventName},
coretypes.VerbAttach: {s: sigsemconv.SigNozAuditAttachEventName},
coretypes.VerbDetach: {s: sigsemconv.SigNozAuditDetachEventName},
}
// EventName is a typed wrapper for audit event names, ensuring not every
// string qualifies as an event name.
@@ -8,15 +19,16 @@ type EventName struct {
s string
}
// NewEventName derives the audit event name from a resource kind and action.
// Format: {resource_kind}.{pastTense(action)}
//
// Examples:
//
// NewEventName("dashboard", coretypes.VerbCreate) → "dashboard.created"
// NewEventName("dashboard", coretypes.VerbUpdate) → "dashboard.updated"
func NewEventName(resourceKind coretypes.Kind, action coretypes.Verb) EventName {
return EventName{s: resourceKind.String() + "." + action.PastTense()}
// NewEventName returns the audit event name of an action, signoz.audit.{action}.
// It is zero for an action that is not audited; see IsAudited.
func NewEventName(action coretypes.Verb) EventName {
return eventNames[action]
}
// IsAudited reports whether an action records an audit event.
func IsAudited(action coretypes.Verb) bool {
_, ok := eventNames[action]
return ok
}
// String returns the string representation of the event name.

View File

@@ -7,7 +7,10 @@ import (
"github.com/SigNoz/signoz/pkg/types/authtypes"
"github.com/SigNoz/signoz/pkg/types/coretypes"
"github.com/SigNoz/signoz/pkg/valuer"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"go.opentelemetry.io/collector/pdata/plog"
oteltrace "go.opentelemetry.io/otel/trace"
)
@@ -31,7 +34,6 @@ func TestNewAuditEvent(t *testing.T) {
resource coretypes.Resource
resourceID string
errorType string
errorCode string
expectedOutcome Outcome
expectedBody string
}{
@@ -61,9 +63,8 @@ func TestNewAuditEvent(t *testing.T) {
resource: testDashboardResource,
resourceID: "019b-5678-efgh-9012",
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)",
expectedBody: "viewer@acme.com (019aaaaa-bbbb-7000-8000-cccc00000002) failed to update dashboard (019b-5678-efgh-9012): forbidden",
},
}
@@ -81,8 +82,8 @@ func TestNewAuditEvent(t *testing.T) {
testCase.category,
testCase.claims,
NewResourceAttributes(testCase.resource, testCase.resourceID),
TargetAttributes{},
testCase.errorType,
testCase.errorCode,
)
assert.Equal(t, testCase.expectedOutcome, event.AuditAttributes.Outcome)
@@ -97,7 +98,6 @@ func TestNewAuditEvent(t *testing.T) {
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)
})
}
}
@@ -105,7 +105,7 @@ func TestNewAuditEvent(t *testing.T) {
func newTestEvent(resource coretypes.Resource, resourceID string, action coretypes.Verb) AuditEvent {
return AuditEvent{
Body: resource.Kind().String() + "." + action.PastTense(),
EventName: NewEventName(resource.Kind(), action),
EventName: NewEventName(action),
AuditAttributes: AuditAttributes{
Action: action,
ActionCategory: coretypes.ActionCategoryConfigurationChange,
@@ -210,11 +210,11 @@ func TestNewPLogsFromAuditEvents(t *testing.T) {
assert.True(t, exists)
assert.Equal(t, "0.90.0", serviceVersion.Str())
kind, exists := resourceAttrs.Get("signoz.audit.resource.kind")
kind, exists := resourceAttrs.Get("signoz.resource.kind")
assert.True(t, exists)
assert.Equal(t, testCase.expectedResourceKinds[i], kind.Str())
id, exists := resourceAttrs.Get("signoz.audit.resource.id")
id, exists := resourceAttrs.Get("signoz.resource.id")
assert.True(t, exists)
assert.Equal(t, testCase.expectedResourceIDs[i], id.Str())
@@ -225,8 +225,8 @@ func TestNewPLogsFromAuditEvents(t *testing.T) {
for j := 0; j < resourceLogs.ScopeLogs().At(0).LogRecords().Len(); j++ {
recordAttrs := resourceLogs.ScopeLogs().At(0).LogRecords().At(j).Attributes()
_, hasKind := recordAttrs.Get("signoz.audit.resource.kind")
_, hasID := recordAttrs.Get("signoz.audit.resource.id")
_, hasKind := recordAttrs.Get("signoz.resource.kind")
_, hasID := recordAttrs.Get("signoz.resource.id")
assert.False(t, hasKind, "resource.kind must not be in log record attributes")
assert.False(t, hasID, "resource.id must not be in log record attributes")
}
@@ -234,3 +234,276 @@ func TestNewPLogsFromAuditEvents(t *testing.T) {
})
}
}
func TestNewEventName(t *testing.T) {
testCases := []struct {
name string
action coretypes.Verb
expected string
expectedAudited bool
}{
{name: "Create", action: coretypes.VerbCreate, expected: "signoz.audit.create", expectedAudited: true},
{name: "Detach", action: coretypes.VerbDetach, expected: "signoz.audit.detach", expectedAudited: true},
{name: "Read_NotAudited", action: coretypes.VerbRead, expected: "", expectedAudited: false},
{name: "List_NotAudited", action: coretypes.VerbList, expected: "", expectedAudited: false},
}
for _, testCase := range testCases {
t.Run(testCase.name, func(t *testing.T) {
assert.Equal(t, testCase.expected, NewEventName(testCase.action).String())
assert.Equal(t, testCase.expectedAudited, IsAudited(testCase.action))
})
}
}
func TestAuditEventToLogRecord(t *testing.T) {
testCases := []struct {
name string
claims authtypes.Claims
resourceAttributes ResourceAttributes
targetAttributes TargetAttributes
statusCode int
errorType string
remoteAddr string
proto string
protoMajor int
protoMinor int
expectedAttributes map[string]any
absentAttributes []string
}{
{
name: "Success_User_HTTP11",
claims: authtypes.Claims{UserID: "019a1234-abcd-7000-8000-567800000001", Principal: authtypes.PrincipalUser, Email: "alice@acme.com", OrgID: "019a1234-abcd-7000-8000-567800000002"},
resourceAttributes: NewResourceAttributes(testDashboardResource, "d-001"),
statusCode: http.StatusOK,
remoteAddr: "10.0.0.1:52341",
proto: "HTTP/1.1",
protoMajor: 1,
protoMinor: 1,
expectedAttributes: map[string]any{
"signoz.audit.verb": "create",
"signoz.audit.action_category": "configuration_changes",
"signoz.audit.outcome": "success",
"signoz.principal.id": "019a1234-abcd-7000-8000-567800000001",
"signoz.principal.type": "user",
"signoz.principal.email": "alice@acme.com",
"signoz.organization.id": "019a1234-abcd-7000-8000-567800000002",
"network.protocol.version": "1.1",
"client.address": "10.0.0.1",
"client.port": int64(52341),
"url.path": "/api/v1/resource",
},
absentAttributes: []string{"exception.type", "signoz.workspace.key.id", "signoz.resource.target.kind", "signoz.audit.identn_provider"},
},
{
name: "ServiceAccount_HTTP2",
claims: authtypes.Claims{UserID: "00000000-0000-0000-0000-000000000000", ServiceAccountID: "019a1234-abcd-7000-8000-567800000003", Principal: authtypes.PrincipalServiceAccount, OrgID: "019a1234-abcd-7000-8000-567800000002"},
resourceAttributes: NewResourceAttributes(testDashboardResource, "d-002"),
statusCode: http.StatusOK,
remoteAddr: "10.0.0.3:443",
proto: "HTTP/2.0",
protoMajor: 2,
protoMinor: 0,
expectedAttributes: map[string]any{
"signoz.principal.id": "019a1234-abcd-7000-8000-567800000003",
"signoz.principal.type": "service_account",
"network.protocol.version": "2",
},
absentAttributes: []string{"signoz.principal.email"},
},
{
name: "Failure_Anonymous_IngestionKey_AddressWithoutPort",
claims: authtypes.Claims{OrgID: "019a1234-abcd-7000-8000-567800000002"},
resourceAttributes: NewResourceAttributes(coretypes.ResourceMetaResourceIngestionKey, "key-001"),
statusCode: http.StatusForbidden,
errorType: "forbidden",
remoteAddr: "10.0.0.2",
proto: "HTTP/1.1",
protoMajor: 1,
protoMinor: 1,
expectedAttributes: map[string]any{
"signoz.audit.outcome": "failure",
"exception.type": "forbidden",
"client.address": "10.0.0.2",
},
absentAttributes: []string{"client.port", "signoz.principal.id", "signoz.principal.type", "signoz.principal.email", "signoz.workspace.key.id"},
},
{
name: "Attach_IngestionLimitToKey",
claims: authtypes.Claims{UserID: "019a1234-abcd-7000-8000-567800000001", Principal: authtypes.PrincipalUser, OrgID: "019a1234-abcd-7000-8000-567800000002"},
resourceAttributes: NewResourceAttributes(coretypes.ResourceMetaResourceIngestionLimit, "limit-001"),
targetAttributes: NewTargetAttributes(coretypes.ResourceMetaResourceIngestionKey, "key-002"),
statusCode: http.StatusOK,
remoteAddr: "10.0.0.4:1234",
proto: "HTTP/1.1",
protoMajor: 1,
protoMinor: 1,
expectedAttributes: map[string]any{
"signoz.resource.target.kind": "ingestion-key",
"signoz.resource.target.id": "key-002",
},
absentAttributes: []string{"signoz.resource.kind", "signoz.resource.id", "signoz.workspace.key.id"},
},
}
for _, testCase := range testCases {
t.Run(testCase.name, func(t *testing.T) {
req := httptest.NewRequest(http.MethodPost, "/api/v1/resource", nil)
req.RemoteAddr = testCase.remoteAddr
req.Proto, req.ProtoMajor, req.ProtoMinor = testCase.proto, testCase.protoMajor, testCase.protoMinor
event := NewAuditEventFromHTTPRequest(req, "/api/v1/resource", testCase.statusCode, oteltrace.TraceID{}, oteltrace.SpanID{}, coretypes.VerbCreate, coretypes.ActionCategoryConfigurationChange, testCase.claims, testCase.resourceAttributes, testCase.targetAttributes, testCase.errorType)
record := plog.NewLogRecord()
event.ToLogRecord(record)
attrs := record.Attributes().AsRaw()
for key, value := range testCase.expectedAttributes {
assert.Equal(t, value, attrs[key], key)
}
for _, key := range testCase.absentAttributes {
assert.NotContains(t, attrs, key)
}
})
}
}
func TestNewPLogsFromAuditEvents_TargetPerRecord(t *testing.T) {
first := NewAuditEventFromHTTPRequest(httptest.NewRequest(http.MethodPost, "/", nil), "/", http.StatusOK, oteltrace.TraceID{}, oteltrace.SpanID{}, coretypes.VerbAttach, coretypes.ActionCategoryConfigurationChange, authtypes.Claims{}, NewResourceAttributes(testDashboardResource, "d-001"), NewTargetAttributes(coretypes.ResourceMetaResourceRule, "r-001"), "")
second := NewAuditEventFromHTTPRequest(httptest.NewRequest(http.MethodPost, "/", nil), "/", http.StatusOK, oteltrace.TraceID{}, oteltrace.SpanID{}, coretypes.VerbAttach, coretypes.ActionCategoryConfigurationChange, authtypes.Claims{}, NewResourceAttributes(testDashboardResource, "d-001"), NewTargetAttributes(coretypes.ResourceMetaResourceRule, "r-002"), "")
logs := NewPLogsFromAuditEvents([]AuditEvent{first, second}, "signoz", "0.90.0", "signoz.audit")
require.Equal(t, 1, logs.ResourceLogs().Len())
resourceLogs := logs.ResourceLogs().At(0)
assert.NotContains(t, resourceLogs.Resource().Attributes().AsRaw(), "signoz.resource.target.id")
records := resourceLogs.ScopeLogs().At(0).LogRecords()
require.Equal(t, 2, records.Len())
targetIDs := []any{}
for i := 0; i < records.Len(); i++ {
targetIDs = append(targetIDs, records.At(i).Attributes().AsRaw()["signoz.resource.target.id"])
}
assert.Equal(t, []any{"r-001", "r-002"}, targetIDs)
}
func TestNewPLogsFromAuditEvents_Attributes(t *testing.T) {
testCases := []struct {
name string
method string
path string
route string
statusCode int
action coretypes.Verb
category coretypes.ActionCategory
claims authtypes.Claims
resourceAttributes ResourceAttributes
targetAttributes TargetAttributes
errorType string
expectedEventName string
expectedResourceAttributes map[string]any
expectedRecordAttributes map[string]any
}{
{
name: "Success_DashboardCreated",
method: http.MethodPost,
path: "/api/v1/dashboards",
route: "/api/v1/dashboards",
statusCode: http.StatusCreated,
action: coretypes.VerbCreate,
category: coretypes.ActionCategoryConfigurationChange,
claims: authtypes.Claims{UserID: "019a1234-abcd-7000-8000-567800000001", Email: "alice@acme.com", OrgID: "019a0000-0000-7000-8000-000000000001", Principal: authtypes.PrincipalUser},
resourceAttributes: NewResourceAttributes(testDashboardResource, "019b5678-0000-7000-8000-000000009012"),
expectedEventName: "signoz.audit.create",
expectedResourceAttributes: map[string]any{
"service.name": "signoz",
"service.version": "0.90.0",
"signoz.resource.kind": "dashboard",
"signoz.resource.id": "019b5678-0000-7000-8000-000000009012",
"signoz.resource.object": testDashboardResource.Object(valuer.MustNewUUID("019a0000-0000-7000-8000-000000000001"), "019b5678-0000-7000-8000-000000009012"),
},
expectedRecordAttributes: map[string]any{
"signoz.audit.verb": "create",
"signoz.audit.action_category": "configuration_changes",
"signoz.audit.outcome": "success",
"signoz.principal.id": "019a1234-abcd-7000-8000-567800000001",
"signoz.principal.email": "alice@acme.com",
"signoz.principal.type": "user",
"signoz.organization.id": "019a0000-0000-7000-8000-000000000001",
"network.protocol.name": "http",
"network.protocol.version": "1.1",
"url.scheme": "http",
"url.path": "/api/v1/dashboards",
"http.request.method": "POST",
"http.route": "/api/v1/dashboards",
"http.response.status_code": int64(201),
"client.address": "192.0.2.1",
"client.port": int64(1234),
},
},
{
name: "Failure_ForbiddenRoleAttachedToUser",
method: http.MethodPost,
path: "/api/v1/users/019c1111-0000-7000-8000-000000000020/roles",
route: "/api/v1/users/{id}/roles",
statusCode: http.StatusForbidden,
action: coretypes.VerbAttach,
category: coretypes.ActionCategoryAccessControl,
claims: authtypes.Claims{ServiceAccountID: "019aaaaa-bbbb-7000-8000-cccc00000002", OrgID: "019a0000-0000-7000-8000-000000000002", Principal: authtypes.PrincipalServiceAccount},
resourceAttributes: NewResourceAttributes(coretypes.ResourceUser, "019c1111-0000-7000-8000-000000000020"),
targetAttributes: NewTargetAttributes(coretypes.ResourceRole, "019d2222-0000-7000-8000-000000000030"),
errorType: "forbidden",
expectedEventName: "signoz.audit.attach",
expectedResourceAttributes: map[string]any{
"service.name": "signoz",
"service.version": "0.90.0",
"signoz.resource.kind": "user",
"signoz.resource.id": "019c1111-0000-7000-8000-000000000020",
"signoz.resource.object": coretypes.ResourceUser.Object(valuer.MustNewUUID("019a0000-0000-7000-8000-000000000002"), "019c1111-0000-7000-8000-000000000020"),
},
expectedRecordAttributes: map[string]any{
"signoz.audit.verb": "attach",
"signoz.audit.action_category": "access_control",
"signoz.audit.outcome": "failure",
"signoz.principal.id": "019aaaaa-bbbb-7000-8000-cccc00000002",
"signoz.principal.type": "service_account",
"signoz.organization.id": "019a0000-0000-7000-8000-000000000002",
"signoz.resource.target.kind": "role",
"signoz.resource.target.id": "019d2222-0000-7000-8000-000000000030",
"signoz.resource.target.object": coretypes.ResourceRole.Object(valuer.MustNewUUID("019a0000-0000-7000-8000-000000000002"), "019d2222-0000-7000-8000-000000000030"),
"exception.type": "forbidden",
"network.protocol.name": "http",
"network.protocol.version": "1.1",
"url.scheme": "http",
"url.path": "/api/v1/users/019c1111-0000-7000-8000-000000000020/roles",
"http.request.method": "POST",
"http.route": "/api/v1/users/{id}/roles",
"http.response.status_code": int64(403),
"client.address": "192.0.2.1",
"client.port": int64(1234),
},
},
}
for _, testCase := range testCases {
t.Run(testCase.name, func(t *testing.T) {
event := NewAuditEventFromHTTPRequest(httptest.NewRequest(testCase.method, testCase.path, nil), testCase.route, testCase.statusCode, oteltrace.TraceID{}, oteltrace.SpanID{}, testCase.action, testCase.category, testCase.claims, testCase.resourceAttributes, testCase.targetAttributes, testCase.errorType)
logs := NewPLogsFromAuditEvents([]AuditEvent{event}, "signoz", "0.90.0", "signoz.audit")
require.Equal(t, 1, logs.ResourceLogs().Len())
resourceLogs := logs.ResourceLogs().At(0)
assert.Equal(t, testCase.expectedResourceAttributes, resourceLogs.Resource().Attributes().AsRaw())
require.Equal(t, 1, resourceLogs.ScopeLogs().Len())
require.Equal(t, 1, resourceLogs.ScopeLogs().At(0).LogRecords().Len())
record := resourceLogs.ScopeLogs().At(0).LogRecords().At(0)
assert.Equal(t, testCase.expectedEventName, record.EventName())
assert.Equal(t, testCase.expectedRecordAttributes, record.Attributes().AsRaw())
})
}
}

View File

@@ -1,6 +1,7 @@
package audittypes
import (
"github.com/SigNoz/signoz-semantic-conventions/gen/go/sigsemconv"
"github.com/SigNoz/signoz/pkg/valuer"
"go.opentelemetry.io/collector/pdata/plog"
)
@@ -13,8 +14,8 @@ type Outcome struct {
}
var (
OutcomeSuccess = Outcome{valuer.NewString("success"), plog.SeverityNumberInfo, "INFO"}
OutcomeFailure = Outcome{valuer.NewString("failure"), plog.SeverityNumberError, "ERROR"}
OutcomeSuccess = Outcome{valuer.NewString(sigsemconv.SigNozAuditOutcomeSuccess.Value.AsString()), plog.SeverityNumberInfo, "INFO"}
OutcomeFailure = Outcome{valuer.NewString(sigsemconv.SigNozAuditOutcomeFailure.Value.AsString()), plog.SeverityNumberError, "ERROR"}
)
func (Outcome) Enum() []any {

View File

@@ -1,10 +1,13 @@
package coretypes
import "github.com/SigNoz/signoz/pkg/valuer"
import (
"github.com/SigNoz/signoz-semantic-conventions/gen/go/sigsemconv"
"github.com/SigNoz/signoz/pkg/valuer"
)
var (
ActionCategoryAccessControl = ActionCategory{valuer.NewString("access_control")}
ActionCategoryConfigurationChange = ActionCategory{valuer.NewString("configuration_change")}
ActionCategoryAccessControl = ActionCategory{valuer.NewString(sigsemconv.SigNozAuditActionCategoryAccessControl.Value.AsString())}
ActionCategoryConfigurationChange = ActionCategory{valuer.NewString(sigsemconv.SigNozAuditActionCategoryConfigurationChanges.Value.AsString())}
ActionCategoryDataAccess = ActionCategory{valuer.NewString("data_access")}
ActionCategorySystemEvent = ActionCategory{valuer.NewString("system_event")}
)

View File

@@ -30,55 +30,55 @@ def test_audit_list_all(
timestamp=now - timedelta(seconds=3),
resources={
"service.name": "signoz",
"signoz.audit.resource.kind": "alert-rule",
"signoz.audit.resource.id": "alert-001",
"signoz.resource.kind": "alert-rule",
"signoz.resource.id": "alert-001",
},
attributes={
"signoz.audit.principal.id": "user-010",
"signoz.audit.principal.email": "ops@acme.com",
"signoz.audit.principal.type": "user",
"signoz.audit.action": "create",
"signoz.principal.id": "user-010",
"signoz.principal.email": "ops@acme.com",
"signoz.principal.type": "user",
"signoz.audit.verb": "create",
"signoz.audit.outcome": "success",
},
body="ops@acme.com (user-010) created alert-rule (alert-001)",
event_name="alert-rule.created",
event_name="signoz.audit.create",
severity_text="INFO",
),
AuditLog(
timestamp=now - timedelta(seconds=2),
resources={
"service.name": "signoz",
"signoz.audit.resource.kind": "saved-view",
"signoz.audit.resource.id": "view-001",
"signoz.resource.kind": "saved-view",
"signoz.resource.id": "view-001",
},
attributes={
"signoz.audit.principal.id": "user-010",
"signoz.audit.principal.email": "ops@acme.com",
"signoz.audit.principal.type": "user",
"signoz.audit.action": "update",
"signoz.principal.id": "user-010",
"signoz.principal.email": "ops@acme.com",
"signoz.principal.type": "user",
"signoz.audit.verb": "update",
"signoz.audit.outcome": "success",
},
body="ops@acme.com (user-010) updated saved-view (view-001)",
event_name="saved-view.updated",
event_name="signoz.audit.update",
severity_text="INFO",
),
AuditLog(
timestamp=now - timedelta(seconds=1),
resources={
"service.name": "signoz",
"signoz.audit.resource.kind": "user",
"signoz.audit.resource.id": "user-020",
"signoz.resource.kind": "user",
"signoz.resource.id": "user-020",
},
attributes={
"signoz.audit.principal.id": "user-010",
"signoz.audit.principal.email": "ops@acme.com",
"signoz.audit.principal.type": "user",
"signoz.audit.action": "update",
"signoz.principal.id": "user-010",
"signoz.principal.email": "ops@acme.com",
"signoz.principal.type": "user",
"signoz.audit.verb": "update",
"signoz.audit.action_category": "access_control",
"signoz.audit.outcome": "success",
},
body="ops@acme.com (user-010) updated user (user-020)",
event_name="user.role.changed",
event_name="signoz.audit.update",
severity_text="INFO",
),
]
@@ -109,9 +109,12 @@ def test_audit_list_all(
assert len(rows) == 3
# Most recent first
assert rows[0]["data"]["event_name"] == "user.role.changed"
assert rows[1]["data"]["event_name"] == "saved-view.updated"
assert rows[2]["data"]["event_name"] == "alert-rule.created"
assert [row["data"]["event_name"] for row in rows] == ["signoz.audit.update", "signoz.audit.update", "signoz.audit.create"]
assert [row["data"]["body"] for row in rows] == [
"ops@acme.com (user-010) updated user (user-020)",
"ops@acme.com (user-010) updated saved-view (view-001)",
"ops@acme.com (user-010) created alert-rule (alert-001)",
]
# Verify event_name and body are present
assert rows[0]["data"]["body"] == "ops@acme.com (user-010) updated user (user-020)"
@@ -122,33 +125,33 @@ def test_audit_list_all(
"filter_expression,expected_count,expected_event_names",
[
pytest.param(
"signoz.audit.principal.id = 'user-001'",
"signoz.principal.id = 'user-001'",
3,
{"session.login", "dashboard.updated", "dashboard.created"},
{"signoz.audit.update", "signoz.audit.create"},
id="filter_by_principal_id",
),
pytest.param(
"signoz.audit.outcome = 'failure'",
1,
{"dashboard.deleted"},
{"signoz.audit.delete"},
id="filter_by_outcome_failure",
),
pytest.param(
"signoz.audit.resource.kind = 'dashboard' AND signoz.audit.resource.id = 'dash-001'",
"signoz.resource.kind = 'dashboard' AND signoz.resource.id = 'dash-001'",
3,
{"dashboard.deleted", "dashboard.updated", "dashboard.created"},
{"signoz.audit.delete", "signoz.audit.update", "signoz.audit.create"},
id="filter_by_resource_kind_and_id",
),
pytest.param(
"signoz.audit.principal.type = 'service_account'",
"signoz.principal.type = 'service_account'",
1,
{"serviceaccount.apikey.created"},
{"signoz.audit.create"},
id="filter_by_principal_type",
),
pytest.param(
"signoz.audit.resource.kind = 'dashboard' AND signoz.audit.action = 'delete'",
"signoz.resource.kind = 'dashboard' AND signoz.audit.verb = 'delete'",
1,
{"dashboard.deleted"},
{"signoz.audit.delete"},
id="filter_by_resource_kind_and_action",
),
],
@@ -170,94 +173,92 @@ def test_audit_filter(
timestamp=now - timedelta(seconds=5),
resources={
"service.name": "signoz",
"signoz.audit.resource.kind": "dashboard",
"signoz.audit.resource.id": "dash-001",
"signoz.resource.kind": "dashboard",
"signoz.resource.id": "dash-001",
},
attributes={
"signoz.audit.principal.id": "user-001",
"signoz.audit.principal.email": "alice@acme.com",
"signoz.audit.principal.type": "user",
"signoz.audit.action": "create",
"signoz.audit.action_category": "configuration_change",
"signoz.principal.id": "user-001",
"signoz.principal.email": "alice@acme.com",
"signoz.principal.type": "user",
"signoz.audit.verb": "create",
"signoz.audit.action_category": "configuration_changes",
"signoz.audit.outcome": "success",
},
body="alice@acme.com created dashboard",
event_name="dashboard.created",
event_name="signoz.audit.create",
),
AuditLog(
timestamp=now - timedelta(seconds=4),
resources={
"service.name": "signoz",
"signoz.audit.resource.kind": "dashboard",
"signoz.audit.resource.id": "dash-001",
"signoz.resource.kind": "dashboard",
"signoz.resource.id": "dash-001",
},
attributes={
"signoz.audit.principal.id": "user-001",
"signoz.audit.principal.email": "alice@acme.com",
"signoz.audit.principal.type": "user",
"signoz.audit.action": "update",
"signoz.audit.action_category": "configuration_change",
"signoz.principal.id": "user-001",
"signoz.principal.email": "alice@acme.com",
"signoz.principal.type": "user",
"signoz.audit.verb": "update",
"signoz.audit.action_category": "configuration_changes",
"signoz.audit.outcome": "success",
},
body="alice@acme.com updated dashboard",
event_name="dashboard.updated",
event_name="signoz.audit.update",
),
AuditLog(
timestamp=now - timedelta(seconds=3),
resources={
"service.name": "signoz",
"signoz.audit.resource.kind": "dashboard",
"signoz.audit.resource.id": "dash-001",
"signoz.resource.kind": "dashboard",
"signoz.resource.id": "dash-001",
},
attributes={
"signoz.audit.principal.id": "user-002",
"signoz.audit.principal.email": "viewer@acme.com",
"signoz.audit.principal.type": "user",
"signoz.audit.action": "delete",
"signoz.audit.action_category": "configuration_change",
"signoz.principal.id": "user-002",
"signoz.principal.email": "viewer@acme.com",
"signoz.principal.type": "user",
"signoz.audit.verb": "delete",
"signoz.audit.action_category": "configuration_changes",
"signoz.audit.outcome": "failure",
"signoz.audit.error.type": "forbidden",
"signoz.audit.error.code": "authz_forbidden",
"exception.type": "forbidden",
},
body="viewer@acme.com failed to delete dashboard",
event_name="dashboard.deleted",
event_name="signoz.audit.delete",
severity_text="ERROR",
),
AuditLog(
timestamp=now - timedelta(seconds=2),
resources={
"service.name": "signoz",
"signoz.audit.resource.kind": "serviceaccount",
"signoz.audit.resource.id": "sa-001",
"signoz.resource.kind": "serviceaccount",
"signoz.resource.id": "sa-001",
},
attributes={
"signoz.audit.principal.id": "sa-001",
"signoz.audit.principal.email": "",
"signoz.audit.principal.type": "service_account",
"signoz.audit.action": "create",
"signoz.principal.id": "sa-001",
"signoz.principal.type": "service_account",
"signoz.audit.verb": "create",
"signoz.audit.action_category": "access_control",
"signoz.audit.outcome": "success",
},
body="sa-001 created serviceaccount",
event_name="serviceaccount.apikey.created",
event_name="signoz.audit.create",
),
AuditLog(
timestamp=now - timedelta(seconds=1),
resources={
"service.name": "signoz",
"signoz.audit.resource.kind": "session",
"signoz.audit.resource.id": "*",
"signoz.resource.kind": "session",
"signoz.resource.id": "*",
},
attributes={
"signoz.audit.principal.id": "user-001",
"signoz.audit.principal.email": "alice@acme.com",
"signoz.audit.principal.type": "user",
"signoz.audit.action": "login",
"signoz.principal.id": "user-001",
"signoz.principal.email": "alice@acme.com",
"signoz.principal.type": "user",
"signoz.audit.verb": "create",
"signoz.audit.action_category": "access_control",
"signoz.audit.outcome": "success",
},
body="alice@acme.com login session",
event_name="session.login",
body="alice@acme.com created session",
event_name="signoz.audit.create",
),
]
)
@@ -304,51 +305,51 @@ def test_audit_scalar_count_failures(
timestamp=now - timedelta(seconds=3),
resources={
"service.name": "signoz",
"signoz.audit.resource.kind": "dashboard",
"signoz.audit.resource.id": "dash-100",
"signoz.resource.kind": "dashboard",
"signoz.resource.id": "dash-100",
},
attributes={
"signoz.audit.principal.id": "user-050",
"signoz.audit.principal.type": "user",
"signoz.audit.action": "delete",
"signoz.principal.id": "user-050",
"signoz.principal.type": "user",
"signoz.audit.verb": "delete",
"signoz.audit.outcome": "failure",
},
body="user-050 failed to delete dashboard",
event_name="dashboard.deleted",
event_name="signoz.audit.delete",
severity_text="ERROR",
),
AuditLog(
timestamp=now - timedelta(seconds=2),
resources={
"service.name": "signoz",
"signoz.audit.resource.kind": "alert-rule",
"signoz.audit.resource.id": "alert-200",
"signoz.resource.kind": "alert-rule",
"signoz.resource.id": "alert-200",
},
attributes={
"signoz.audit.principal.id": "user-060",
"signoz.audit.principal.type": "user",
"signoz.audit.action": "update",
"signoz.principal.id": "user-060",
"signoz.principal.type": "user",
"signoz.audit.verb": "update",
"signoz.audit.outcome": "failure",
},
body="user-060 failed to update alert-rule",
event_name="alert-rule.updated",
event_name="signoz.audit.update",
severity_text="ERROR",
),
AuditLog(
timestamp=now - timedelta(seconds=1),
resources={
"service.name": "signoz",
"signoz.audit.resource.kind": "dashboard",
"signoz.audit.resource.id": "dash-100",
"signoz.resource.kind": "dashboard",
"signoz.resource.id": "dash-100",
},
attributes={
"signoz.audit.principal.id": "user-050",
"signoz.audit.principal.type": "user",
"signoz.audit.action": "update",
"signoz.principal.id": "user-050",
"signoz.principal.type": "user",
"signoz.audit.verb": "update",
"signoz.audit.outcome": "success",
},
body="user-050 updated dashboard",
event_name="dashboard.updated",
event_name="signoz.audit.update",
),
]
)
@@ -394,17 +395,17 @@ def test_audit_does_not_leak_into_logs(
timestamp=now - timedelta(seconds=1),
resources={
"service.name": "signoz",
"signoz.audit.resource.kind": "organization",
"signoz.audit.resource.id": "org-999",
"signoz.resource.kind": "organization",
"signoz.resource.id": "org-999",
},
attributes={
"signoz.audit.principal.id": "user-admin",
"signoz.audit.principal.type": "user",
"signoz.audit.action": "update",
"signoz.principal.id": "user-admin",
"signoz.principal.type": "user",
"signoz.audit.verb": "update",
"signoz.audit.outcome": "success",
},
body="user-admin updated organization (org-999)",
event_name="organization.updated",
event_name="signoz.audit.update",
),
]
)
@@ -430,5 +431,5 @@ def test_audit_does_not_leak_into_logs(
rows = response.json()["data"]["data"]["results"][0].get("rows") or []
audit_bodies = [row["data"]["body"] for row in rows if "signoz.audit" in row["data"].get("attributes_string", {}).get("signoz.audit.action", "")]
audit_bodies = [row["data"]["body"] for row in rows if "signoz.audit" in row["data"].get("attributes_string", {}).get("signoz.audit.verb", "")]
assert len(audit_bodies) == 0

View File

@@ -32,9 +32,9 @@ def test_audit_heatmap(
[
AuditLog(
timestamp=now - timedelta(seconds=index + 1),
resources={"service.name": "signoz", "signoz.audit.resource.kind": "dashboard"},
attributes={"signoz.audit.action": action, "signoz.audit.fields_changed": fields_changed},
event_name=f"dashboard.{action}",
resources={"service.name": "signoz", "signoz.resource.kind": "dashboard"},
attributes={"signoz.audit.verb": action, "signoz.audit.fields_changed": fields_changed},
event_name=f"signoz.audit.{action}",
)
for action, values in changed_by_action.items()
for index, fields_changed in enumerate(values)
@@ -43,9 +43,9 @@ def test_audit_heatmap(
+ [
AuditLog(
timestamp=now - timedelta(seconds=5),
resources={"service.name": "signoz", "signoz.audit.resource.kind": "dashboard"},
attributes={"signoz.audit.action": "update"},
event_name="dashboard.update",
resources={"service.name": "signoz", "signoz.resource.kind": "dashboard"},
attributes={"signoz.audit.verb": "update"},
event_name="signoz.audit.update",
)
]
)
@@ -59,8 +59,8 @@ def test_audit_heatmap(
"logs",
"signoz.audit.fields_changed",
source="audit",
group_by=[build_group_by_field("signoz.audit.action", "string", "attribute")],
filter_expression="signoz.audit.resource.kind = 'dashboard'",
group_by=[build_group_by_field("signoz.audit.verb", "string", "attribute")],
filter_expression="signoz.resource.kind = 'dashboard'",
bucket_options=build_linear_bucket_options(20, 4),
)
]
@@ -75,7 +75,7 @@ def test_audit_heatmap(
buckets = get_buckets(data, "A")
assert buckets == pytest.approx([0.0, 5.0, 10.0])
series_by_action = index_series_by_label(get_all_series(data, "A"), "signoz.audit.action")
series_by_action = index_series_by_label(get_all_series(data, "A"), "signoz.audit.verb")
totals_by_action = {action: [sum(column["values"][slot] for column in series["values"]) for slot in range(len(buckets) + 1)] for action, series in series_by_action.items()}
assert totals_by_action == {
"update": [0, 1, 1, 0],