From e5198ba670bd502479f2b8cf824bdd6f4d9c3008 Mon Sep 17 00:00:00 2001 From: Lakhan Samani Date: Tue, 31 Mar 2026 14:18:16 +0530 Subject: [PATCH 1/2] feat: add structured audit log schema and storage layer Add AuditLog schema with support for all 6 database providers (SQL/GORM, MongoDB, ArangoDB, Cassandra, DynamoDB, Couchbase). Includes AddAuditLog, ListAuditLogs (with filtering by actor_id, action, resource_type, etc), and DeleteAuditLogsBefore for log retention. Ref: RFC #505. --- internal/constants/audit_event.go | 37 +++++ internal/integration_tests/audit_logs_test.go | 127 ++++++++++++++++++ internal/storage/db/arangodb/audit_log.go | 104 ++++++++++++++ internal/storage/db/arangodb/provider.go | 25 ++++ internal/storage/db/cassandradb/audit_log.go | 117 ++++++++++++++++ internal/storage/db/cassandradb/provider.go | 22 +++ internal/storage/db/couchbase/audit_log.go | 102 ++++++++++++++ internal/storage/db/couchbase/provider.go | 6 + internal/storage/db/dynamodb/audit_log.go | 90 +++++++++++++ internal/storage/db/dynamodb/provider.go | 1 + internal/storage/db/mongodb/audit_log.go | 104 ++++++++++++++ internal/storage/db/mongodb/provider.go | 18 +++ internal/storage/db/sql/audit_log.go | 90 +++++++++++++ internal/storage/db/sql/provider.go | 2 +- internal/storage/provider.go | 8 ++ internal/storage/schemas/audit_log.go | 22 +++ internal/storage/schemas/model.go | 2 + 17 files changed, 876 insertions(+), 1 deletion(-) create mode 100644 internal/constants/audit_event.go create mode 100644 internal/integration_tests/audit_logs_test.go create mode 100644 internal/storage/db/arangodb/audit_log.go create mode 100644 internal/storage/db/cassandradb/audit_log.go create mode 100644 internal/storage/db/couchbase/audit_log.go create mode 100644 internal/storage/db/dynamodb/audit_log.go create mode 100644 internal/storage/db/mongodb/audit_log.go create mode 100644 internal/storage/db/sql/audit_log.go create mode 100644 internal/storage/schemas/audit_log.go diff --git a/internal/constants/audit_event.go b/internal/constants/audit_event.go new file mode 100644 index 00000000..cb2bad39 --- /dev/null +++ b/internal/constants/audit_event.go @@ -0,0 +1,37 @@ +package constants + +const ( + // Authentication events + AuditLoginSuccessEvent = "user.login_success" + AuditLoginFailedEvent = "user.login_failed" + AuditSignupEvent = "user.signup" + AuditLogoutEvent = "user.logout" + AuditPasswordChangedEvent = "user.password_changed" + AuditPasswordResetEvent = "user.password_reset" + AuditEmailVerifiedEvent = "user.email_verified" + AuditPhoneVerifiedEvent = "user.phone_verified" + AuditMFAEnabledEvent = "user.mfa_enabled" + AuditMFADisabledEvent = "user.mfa_disabled" + AuditUserDeactivatedEvent = "user.deactivated" + + // Admin events + AuditAdminUserCreatedEvent = "admin.user_created" + AuditAdminUserUpdatedEvent = "admin.user_updated" + AuditAdminUserDeletedEvent = "admin.user_deleted" + AuditAdminAccessRevokedEvent = "admin.access_revoked" + AuditAdminAccessEnabledEvent = "admin.access_enabled" + AuditAdminUserUnlockedEvent = "admin.user_unlocked" + AuditAdminConfigChangedEvent = "admin.config_changed" + AuditAdminWebhookCreated = "admin.webhook_created" + AuditAdminWebhookUpdated = "admin.webhook_updated" + AuditAdminWebhookDeleted = "admin.webhook_deleted" + + // Token events + AuditTokenIssuedEvent = "token.issued" + AuditTokenRefreshedEvent = "token.refreshed" + AuditTokenRevokedEvent = "token.revoked" + + // Session events + AuditSessionCreatedEvent = "session.created" + AuditSessionTerminatedEvent = "session.terminated" +) diff --git a/internal/integration_tests/audit_logs_test.go b/internal/integration_tests/audit_logs_test.go new file mode 100644 index 00000000..fcc86b6f --- /dev/null +++ b/internal/integration_tests/audit_logs_test.go @@ -0,0 +1,127 @@ +package integration_tests + +import ( + "context" + "testing" + "time" + + "github.com/google/uuid" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/authorizerdev/authorizer/internal/graph/model" + "github.com/authorizerdev/authorizer/internal/storage/schemas" +) + +func TestAuditLogs(t *testing.T) { + cfg := getTestConfig() + ts := initTestSetup(t, cfg) + ctx := context.Background() + + t.Run("should add and list audit logs", func(t *testing.T) { + auditLog := &schemas.AuditLog{ + ActorID: uuid.New().String(), + ActorType: "user", + ActorEmail: "test@example.com", + Action: "login", + ResourceType: "session", + ResourceID: uuid.New().String(), + IPAddress: "127.0.0.1", + UserAgent: "test-agent", + OrganizationID: uuid.New().String(), + } + + err := ts.StorageProvider.AddAuditLog(ctx, auditLog) + require.NoError(t, err) + assert.NotEmpty(t, auditLog.ID) + assert.NotZero(t, auditLog.Timestamp) + assert.NotZero(t, auditLog.CreatedAt) + + // List all audit logs + pagination := &model.Pagination{ + Limit: 10, + Offset: 0, + } + logs, pag, err := ts.StorageProvider.ListAuditLogs(ctx, pagination, map[string]interface{}{}) + require.NoError(t, err) + assert.NotNil(t, pag) + assert.GreaterOrEqual(t, len(logs), 1) + }) + + t.Run("should filter audit logs by action", func(t *testing.T) { + uniqueAction := "test_action_" + uuid.New().String()[:8] + + auditLog := &schemas.AuditLog{ + ActorID: uuid.New().String(), + ActorType: "user", + ActorEmail: "filter@example.com", + Action: uniqueAction, + } + err := ts.StorageProvider.AddAuditLog(ctx, auditLog) + require.NoError(t, err) + + pagination := &model.Pagination{ + Limit: 10, + Offset: 0, + } + logs, _, err := ts.StorageProvider.ListAuditLogs(ctx, pagination, map[string]interface{}{ + "action": uniqueAction, + }) + require.NoError(t, err) + assert.Equal(t, 1, len(logs)) + assert.Equal(t, uniqueAction, logs[0].Action) + }) + + t.Run("should filter audit logs by actor_id", func(t *testing.T) { + actorID := uuid.New().String() + + auditLog := &schemas.AuditLog{ + ActorID: actorID, + ActorType: "admin", + ActorEmail: "admin@example.com", + Action: "update_env", + } + err := ts.StorageProvider.AddAuditLog(ctx, auditLog) + require.NoError(t, err) + + pagination := &model.Pagination{ + Limit: 10, + Offset: 0, + } + logs, _, err := ts.StorageProvider.ListAuditLogs(ctx, pagination, map[string]interface{}{ + "actor_id": actorID, + }) + require.NoError(t, err) + assert.Equal(t, 1, len(logs)) + assert.Equal(t, actorID, logs[0].ActorID) + }) + + t.Run("should delete audit logs before timestamp", func(t *testing.T) { + // Add a log with old timestamp + oldLog := &schemas.AuditLog{ + ActorID: uuid.New().String(), + ActorType: "system", + ActorEmail: "system@example.com", + Action: "cleanup_test", + Timestamp: time.Now().Add(-24 * time.Hour).Unix(), + } + err := ts.StorageProvider.AddAuditLog(ctx, oldLog) + require.NoError(t, err) + + // Delete logs older than 1 hour ago + before := time.Now().Add(-1 * time.Hour).Unix() + err = ts.StorageProvider.DeleteAuditLogsBefore(ctx, before) + require.NoError(t, err) + + // Verify the old log is deleted by filtering for its unique action + pagination := &model.Pagination{ + Limit: 10, + Offset: 0, + } + logs, _, err := ts.StorageProvider.ListAuditLogs(ctx, pagination, map[string]interface{}{ + "action": "cleanup_test", + }) + require.NoError(t, err) + assert.Equal(t, 0, len(logs)) + }) +} diff --git a/internal/storage/db/arangodb/audit_log.go b/internal/storage/db/arangodb/audit_log.go new file mode 100644 index 00000000..4625113f --- /dev/null +++ b/internal/storage/db/arangodb/audit_log.go @@ -0,0 +1,104 @@ +package arangodb + +import ( + "context" + "fmt" + "time" + + arangoDriver "github.com/arangodb/go-driver" + "github.com/google/uuid" + + "github.com/authorizerdev/authorizer/internal/graph/model" + "github.com/authorizerdev/authorizer/internal/storage/schemas" +) + +// AddAuditLog adds an audit log entry +func (p *provider) AddAuditLog(ctx context.Context, auditLog *schemas.AuditLog) error { + if auditLog.ID == "" { + auditLog.ID = uuid.New().String() + auditLog.Key = auditLog.ID + } + auditLog.Key = auditLog.ID + if auditLog.Timestamp == 0 { + auditLog.Timestamp = time.Now().Unix() + } + auditLog.CreatedAt = time.Now().Unix() + auditLog.UpdatedAt = time.Now().Unix() + collection, _ := p.db.Collection(ctx, schemas.Collections.AuditLog) + _, err := collection.CreateDocument(ctx, auditLog) + if err != nil { + return err + } + return nil +} + +// ListAuditLogs queries audit logs with filters and pagination +func (p *provider) ListAuditLogs(ctx context.Context, pagination *model.Pagination, filter map[string]interface{}) ([]*schemas.AuditLog, *model.Pagination, error) { + auditLogs := []*schemas.AuditLog{} + bindVariables := map[string]interface{}{} + + filterQuery := "" + if actorID, ok := filter["actor_id"]; ok && actorID != "" { + filterQuery += " FILTER d.actor_id == @actor_id" + bindVariables["actor_id"] = actorID + } + if action, ok := filter["action"]; ok && action != "" { + filterQuery += " FILTER d.action == @action" + bindVariables["action"] = action + } + if resourceType, ok := filter["resource_type"]; ok && resourceType != "" { + filterQuery += " FILTER d.resource_type == @resource_type" + bindVariables["resource_type"] = resourceType + } + if resourceID, ok := filter["resource_id"]; ok && resourceID != "" { + filterQuery += " FILTER d.resource_id == @resource_id" + bindVariables["resource_id"] = resourceID + } + if orgID, ok := filter["organization_id"]; ok && orgID != "" { + filterQuery += " FILTER d.organization_id == @organization_id" + bindVariables["organization_id"] = orgID + } + if fromTimestamp, ok := filter["from_timestamp"]; ok { + filterQuery += " FILTER d.timestamp >= @from_timestamp" + bindVariables["from_timestamp"] = fromTimestamp + } + if toTimestamp, ok := filter["to_timestamp"]; ok { + filterQuery += " FILTER d.timestamp <= @to_timestamp" + bindVariables["to_timestamp"] = toTimestamp + } + + query := fmt.Sprintf("FOR d in %s%s SORT d.timestamp DESC LIMIT %d, %d RETURN d", schemas.Collections.AuditLog, filterQuery, pagination.Offset, pagination.Limit) + sctx := arangoDriver.WithQueryFullCount(ctx) + cursor, err := p.db.Query(sctx, query, bindVariables) + if err != nil { + return nil, nil, err + } + defer cursor.Close() + + paginationClone := pagination + paginationClone.Total = cursor.Statistics().FullCount() + + for { + var auditLog *schemas.AuditLog + meta, err := cursor.ReadDocument(ctx, &auditLog) + if arangoDriver.IsNoMoreDocuments(err) { + break + } else if err != nil { + return nil, nil, err + } + if meta.Key != "" { + auditLogs = append(auditLogs, auditLog) + } + } + + return auditLogs, paginationClone, nil +} + +// DeleteAuditLogsBefore removes logs older than a timestamp +func (p *provider) DeleteAuditLogsBefore(ctx context.Context, before int64) error { + query := fmt.Sprintf("FOR d in %s FILTER d.timestamp < @before REMOVE d IN %s", schemas.Collections.AuditLog, schemas.Collections.AuditLog) + _, err := p.db.Query(ctx, query, map[string]interface{}{ + "before": before, + }) + return err +} diff --git a/internal/storage/db/arangodb/provider.go b/internal/storage/db/arangodb/provider.go index ea3db9be..81c64378 100644 --- a/internal/storage/db/arangodb/provider.go +++ b/internal/storage/db/arangodb/provider.go @@ -323,6 +323,31 @@ func NewProvider(cfg *config.Config, deps *Dependencies) (*provider, error) { Sparse: true, }) + // AuditLog collection and indexes + auditLogCollectionExists, err := arangodb.CollectionExists(ctx, schemas.Collections.AuditLog) + if err != nil { + return nil, err + } + if !auditLogCollectionExists { + _, err = arangodb.CreateCollection(ctx, schemas.Collections.AuditLog, nil) + if err != nil { + return nil, err + } + } + auditLogCollection, err := arangodb.Collection(ctx, schemas.Collections.AuditLog) + if err != nil { + return nil, err + } + auditLogCollection.EnsureHashIndex(ctx, []string{"actor_id"}, &arangoDriver.EnsureHashIndexOptions{ + Sparse: true, + }) + auditLogCollection.EnsureHashIndex(ctx, []string{"action"}, &arangoDriver.EnsureHashIndexOptions{ + Sparse: true, + }) + auditLogCollection.EnsurePersistentIndex(ctx, []string{"timestamp"}, &arangoDriver.EnsurePersistentIndexOptions{ + Sparse: true, + }) + return &provider{ config: cfg, dependencies: deps, diff --git a/internal/storage/db/cassandradb/audit_log.go b/internal/storage/db/cassandradb/audit_log.go new file mode 100644 index 00000000..4a7f06b7 --- /dev/null +++ b/internal/storage/db/cassandradb/audit_log.go @@ -0,0 +1,117 @@ +package cassandradb + +import ( + "context" + "fmt" + "time" + + "github.com/gocql/gocql" + "github.com/google/uuid" + + "github.com/authorizerdev/authorizer/internal/graph/model" + "github.com/authorizerdev/authorizer/internal/storage/schemas" +) + +// AddAuditLog adds an audit log entry +func (p *provider) AddAuditLog(ctx context.Context, auditLog *schemas.AuditLog) error { + if auditLog.ID == "" { + auditLog.ID = uuid.New().String() + } + auditLog.Key = auditLog.ID + if auditLog.Timestamp == 0 { + auditLog.Timestamp = time.Now().Unix() + } + auditLog.CreatedAt = time.Now().Unix() + auditLog.UpdatedAt = time.Now().Unix() + + insertQuery := fmt.Sprintf("INSERT INTO %s (id, timestamp, actor_id, actor_type, actor_email, action, resource_type, resource_id, ip_address, user_agent, metadata, organization_id, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", + KeySpace+"."+schemas.Collections.AuditLog) + err := p.db.Query(insertQuery, + auditLog.ID, auditLog.Timestamp, auditLog.ActorID, auditLog.ActorType, auditLog.ActorEmail, + auditLog.Action, auditLog.ResourceType, auditLog.ResourceID, auditLog.IPAddress, + auditLog.UserAgent, auditLog.Metadata, auditLog.OrganizationID, + auditLog.CreatedAt, auditLog.UpdatedAt).Exec() + if err != nil { + return err + } + return nil +} + +// ListAuditLogs queries audit logs with filters and pagination +func (p *provider) ListAuditLogs(ctx context.Context, pagination *model.Pagination, filter map[string]interface{}) ([]*schemas.AuditLog, *model.Pagination, error) { + auditLogs := []*schemas.AuditLog{} + paginationClone := pagination + + // Build query with filters + queryBase := fmt.Sprintf("SELECT id, timestamp, actor_id, actor_type, actor_email, action, resource_type, resource_id, ip_address, user_agent, metadata, organization_id, created_at, updated_at FROM %s", KeySpace+"."+schemas.Collections.AuditLog) + countBase := fmt.Sprintf("SELECT COUNT(*) FROM %s", KeySpace+"."+schemas.Collections.AuditLog) + + whereClause := "" + filterValues := []interface{}{} + + if action, ok := filter["action"]; ok && action != "" { + whereClause += " WHERE action=?" + filterValues = append(filterValues, action) + } + if actorID, ok := filter["actor_id"]; ok && actorID != "" { + if whereClause == "" { + whereClause += " WHERE actor_id=?" + } else { + whereClause += " AND actor_id=?" + } + filterValues = append(filterValues, actorID) + } + + allowFiltering := "" + if whereClause != "" { + allowFiltering = " ALLOW FILTERING" + } + + // Count total + countQuery := countBase + whereClause + allowFiltering + err := p.db.Query(countQuery, filterValues...).Consistency(gocql.One).Scan(&paginationClone.Total) + if err != nil { + return nil, nil, err + } + + // Fetch with pagination + query := queryBase + whereClause + fmt.Sprintf(" LIMIT %d", pagination.Limit+pagination.Offset) + allowFiltering + scanner := p.db.Query(query, filterValues...).Iter().Scanner() + counter := int64(0) + for scanner.Next() { + if counter >= pagination.Offset { + var auditLog schemas.AuditLog + err := scanner.Scan( + &auditLog.ID, &auditLog.Timestamp, &auditLog.ActorID, &auditLog.ActorType, + &auditLog.ActorEmail, &auditLog.Action, &auditLog.ResourceType, &auditLog.ResourceID, + &auditLog.IPAddress, &auditLog.UserAgent, &auditLog.Metadata, &auditLog.OrganizationID, + &auditLog.CreatedAt, &auditLog.UpdatedAt) + if err != nil { + return nil, nil, err + } + auditLogs = append(auditLogs, &auditLog) + } + counter++ + } + + return auditLogs, paginationClone, nil +} + +// DeleteAuditLogsBefore removes logs older than a timestamp +func (p *provider) DeleteAuditLogsBefore(ctx context.Context, before int64) error { + // Cassandra doesn't support range deletes without knowing the partition key + // So we need to first fetch IDs, then delete them + query := fmt.Sprintf("SELECT id FROM %s WHERE timestamp < ? ALLOW FILTERING", KeySpace+"."+schemas.Collections.AuditLog) + scanner := p.db.Query(query, before).Iter().Scanner() + for scanner.Next() { + var id string + if err := scanner.Scan(&id); err != nil { + return err + } + deleteQuery := fmt.Sprintf("DELETE FROM %s WHERE id = ?", KeySpace+"."+schemas.Collections.AuditLog) + if err := p.db.Query(deleteQuery, id).Exec(); err != nil { + return err + } + } + return nil +} diff --git a/internal/storage/db/cassandradb/provider.go b/internal/storage/db/cassandradb/provider.go index d7198122..362f39af 100644 --- a/internal/storage/db/cassandradb/provider.go +++ b/internal/storage/db/cassandradb/provider.go @@ -345,6 +345,28 @@ func NewProvider(cfg *config.Config, deps *Dependencies) (*provider, error) { return nil, err } + // AuditLog table and indexes + auditLogCollectionQuery := fmt.Sprintf("CREATE TABLE IF NOT EXISTS %s.%s (id text, timestamp bigint, actor_id text, actor_type text, actor_email text, action text, resource_type text, resource_id text, ip_address text, user_agent text, metadata text, organization_id text, created_at bigint, updated_at bigint, PRIMARY KEY (id))", KeySpace, schemas.Collections.AuditLog) + err = session.Query(auditLogCollectionQuery).Exec() + if err != nil { + return nil, err + } + auditLogActorIdIndex := fmt.Sprintf("CREATE INDEX IF NOT EXISTS authorizer_audit_log_actor_id ON %s.%s (actor_id)", KeySpace, schemas.Collections.AuditLog) + err = session.Query(auditLogActorIdIndex).Exec() + if err != nil { + return nil, err + } + auditLogActionIndex := fmt.Sprintf("CREATE INDEX IF NOT EXISTS authorizer_audit_log_action ON %s.%s (action)", KeySpace, schemas.Collections.AuditLog) + err = session.Query(auditLogActionIndex).Exec() + if err != nil { + return nil, err + } + auditLogTimestampIndex := fmt.Sprintf("CREATE INDEX IF NOT EXISTS authorizer_audit_log_timestamp ON %s.%s (timestamp)", KeySpace, schemas.Collections.AuditLog) + err = session.Query(auditLogTimestampIndex).Exec() + if err != nil { + return nil, err + } + return &provider{ config: cfg, dependencies: deps, diff --git a/internal/storage/db/couchbase/audit_log.go b/internal/storage/db/couchbase/audit_log.go new file mode 100644 index 00000000..5d3edd36 --- /dev/null +++ b/internal/storage/db/couchbase/audit_log.go @@ -0,0 +1,102 @@ +package couchbase + +import ( + "context" + "fmt" + "log" + "time" + + "github.com/couchbase/gocb/v2" + "github.com/google/uuid" + + "github.com/authorizerdev/authorizer/internal/graph/model" + "github.com/authorizerdev/authorizer/internal/storage/schemas" +) + +// AddAuditLog adds an audit log entry +func (p *provider) AddAuditLog(ctx context.Context, auditLog *schemas.AuditLog) error { + if auditLog.ID == "" { + auditLog.ID = uuid.New().String() + } + auditLog.Key = auditLog.ID + if auditLog.Timestamp == 0 { + auditLog.Timestamp = time.Now().Unix() + } + auditLog.CreatedAt = time.Now().Unix() + auditLog.UpdatedAt = time.Now().Unix() + insertOpt := gocb.InsertOptions{ + Context: ctx, + } + _, err := p.db.Collection(schemas.Collections.AuditLog).Insert(auditLog.ID, auditLog, &insertOpt) + if err != nil { + return err + } + return nil +} + +// ListAuditLogs queries audit logs with filters and pagination +func (p *provider) ListAuditLogs(ctx context.Context, pagination *model.Pagination, filter map[string]interface{}) ([]*schemas.AuditLog, *model.Pagination, error) { + auditLogs := []*schemas.AuditLog{} + paginationClone := pagination + params := make(map[string]interface{}) + params["offset"] = paginationClone.Offset + params["limit"] = paginationClone.Limit + + total, err := p.GetTotalDocs(ctx, schemas.Collections.AuditLog) + if err != nil { + return nil, nil, err + } + paginationClone.Total = total + + whereClause := "" + if action, ok := filter["action"]; ok && action != "" { + whereClause += " WHERE action=$action" + params["action"] = action + } + if actorID, ok := filter["actor_id"]; ok && actorID != "" { + if whereClause == "" { + whereClause += " WHERE actor_id=$actorID" + } else { + whereClause += " AND actor_id=$actorID" + } + params["actorID"] = actorID + } + + query := fmt.Sprintf("SELECT _id, timestamp, actor_id, actor_type, actor_email, action, resource_type, resource_id, ip_address, user_agent, metadata, organization_id, created_at, updated_at FROM %s.%s%s ORDER BY timestamp DESC OFFSET $offset LIMIT $limit", + p.scopeName, schemas.Collections.AuditLog, whereClause) + + queryResult, err := p.db.Query(query, &gocb.QueryOptions{ + Context: ctx, + ScanConsistency: gocb.QueryScanConsistencyRequestPlus, + NamedParameters: params, + }) + if err != nil { + return nil, nil, err + } + for queryResult.Next() { + var auditLog schemas.AuditLog + err := queryResult.Row(&auditLog) + if err != nil { + log.Fatal(err) + } + auditLogs = append(auditLogs, &auditLog) + } + if err := queryResult.Err(); err != nil { + return nil, nil, err + } + return auditLogs, paginationClone, nil +} + +// DeleteAuditLogsBefore removes logs older than a timestamp +func (p *provider) DeleteAuditLogsBefore(ctx context.Context, before int64) error { + params := make(map[string]interface{}) + params["before"] = before + query := fmt.Sprintf("DELETE FROM %s.%s WHERE timestamp < $before", + p.scopeName, schemas.Collections.AuditLog) + _, err := p.db.Query(query, &gocb.QueryOptions{ + Context: ctx, + ScanConsistency: gocb.QueryScanConsistencyRequestPlus, + NamedParameters: params, + }) + return err +} diff --git a/internal/storage/db/couchbase/provider.go b/internal/storage/db/couchbase/provider.go index 85aee384..04de4deb 100644 --- a/internal/storage/db/couchbase/provider.go +++ b/internal/storage/db/couchbase/provider.go @@ -220,5 +220,11 @@ func getIndex(scopeName string) map[string][]string { oauthStateIndex1 := fmt.Sprintf("CREATE INDEX OAuthStateKeyIndex ON %s.%s(state_key)", scopeName, schemas.Collections.OAuthState) indices[schemas.Collections.OAuthState] = []string{oauthStateIndex1} + // AuditLog indexes + auditLogIndex1 := fmt.Sprintf("CREATE INDEX AuditLogActorIdIndex ON %s.%s(actor_id)", scopeName, schemas.Collections.AuditLog) + auditLogIndex2 := fmt.Sprintf("CREATE INDEX AuditLogActionIndex ON %s.%s(action)", scopeName, schemas.Collections.AuditLog) + auditLogIndex3 := fmt.Sprintf("CREATE INDEX AuditLogTimestampIndex ON %s.%s(timestamp)", scopeName, schemas.Collections.AuditLog) + indices[schemas.Collections.AuditLog] = []string{auditLogIndex1, auditLogIndex2, auditLogIndex3} + return indices } diff --git a/internal/storage/db/dynamodb/audit_log.go b/internal/storage/db/dynamodb/audit_log.go new file mode 100644 index 00000000..cf1da906 --- /dev/null +++ b/internal/storage/db/dynamodb/audit_log.go @@ -0,0 +1,90 @@ +package dynamodb + +import ( + "context" + "time" + + "github.com/google/uuid" + "github.com/guregu/dynamo" + + "github.com/authorizerdev/authorizer/internal/graph/model" + "github.com/authorizerdev/authorizer/internal/storage/schemas" +) + +// AddAuditLog adds an audit log entry +func (p *provider) AddAuditLog(ctx context.Context, auditLog *schemas.AuditLog) error { + collection := p.db.Table(schemas.Collections.AuditLog) + if auditLog.ID == "" { + auditLog.ID = uuid.New().String() + } + auditLog.Key = auditLog.ID + if auditLog.Timestamp == 0 { + auditLog.Timestamp = time.Now().Unix() + } + auditLog.CreatedAt = time.Now().Unix() + auditLog.UpdatedAt = time.Now().Unix() + err := collection.Put(auditLog).RunWithContext(ctx) + if err != nil { + return err + } + return nil +} + +// ListAuditLogs queries audit logs with filters and pagination +func (p *provider) ListAuditLogs(ctx context.Context, pagination *model.Pagination, filter map[string]interface{}) ([]*schemas.AuditLog, *model.Pagination, error) { + auditLogs := []*schemas.AuditLog{} + var auditLog *schemas.AuditLog + var lastEval dynamo.PagingKey + var iter dynamo.PagingIter + var iteration int64 = 0 + var err error + + collection := p.db.Table(schemas.Collections.AuditLog) + paginationClone := pagination + scanner := collection.Scan() + + // Apply filters + if action, ok := filter["action"]; ok && action != "" { + scanner = scanner.Filter("'action' = ?", action) + } + if actorID, ok := filter["actor_id"]; ok && actorID != "" { + scanner = scanner.Filter("'actor_id' = ?", actorID) + } + if resourceType, ok := filter["resource_type"]; ok && resourceType != "" { + scanner = scanner.Filter("'resource_type' = ?", resourceType) + } + + for (paginationClone.Offset + paginationClone.Limit) > iteration { + iter = scanner.StartFrom(lastEval).Limit(paginationClone.Limit).Iter() + for iter.NextWithContext(ctx, &auditLog) { + if paginationClone.Offset == iteration { + auditLogs = append(auditLogs, auditLog) + } + } + err = iter.Err() + if err != nil { + return nil, nil, err + } + lastEval = iter.LastEvaluatedKey() + iteration += paginationClone.Limit + } + + return auditLogs, paginationClone, nil +} + +// DeleteAuditLogsBefore removes logs older than a timestamp +func (p *provider) DeleteAuditLogsBefore(ctx context.Context, before int64) error { + collection := p.db.Table(schemas.Collections.AuditLog) + var auditLogs []*schemas.AuditLog + err := collection.Scan().Filter("'timestamp' < ?", before).AllWithContext(ctx, &auditLogs) + if err != nil { + return err + } + for _, auditLog := range auditLogs { + err := collection.Delete("id", auditLog.ID).RunWithContext(ctx) + if err != nil { + return err + } + } + return nil +} diff --git a/internal/storage/db/dynamodb/provider.go b/internal/storage/db/dynamodb/provider.go index 88fae327..3a8bc802 100644 --- a/internal/storage/db/dynamodb/provider.go +++ b/internal/storage/db/dynamodb/provider.go @@ -62,6 +62,7 @@ func NewProvider(cfg *config.Config, deps *Dependencies) (*provider, error) { db.CreateTable(schemas.Collections.SessionToken, schemas.SessionToken{}).Wait() db.CreateTable(schemas.Collections.MFASession, schemas.MFASession{}).Wait() db.CreateTable(schemas.Collections.OAuthState, schemas.OAuthState{}).Wait() + db.CreateTable(schemas.Collections.AuditLog, schemas.AuditLog{}).Wait() return &provider{ db: db, config: cfg, diff --git a/internal/storage/db/mongodb/audit_log.go b/internal/storage/db/mongodb/audit_log.go new file mode 100644 index 00000000..2d02be39 --- /dev/null +++ b/internal/storage/db/mongodb/audit_log.go @@ -0,0 +1,104 @@ +package mongodb + +import ( + "context" + "time" + + "github.com/google/uuid" + "go.mongodb.org/mongo-driver/bson" + "go.mongodb.org/mongo-driver/mongo/options" + + "github.com/authorizerdev/authorizer/internal/graph/model" + "github.com/authorizerdev/authorizer/internal/storage/schemas" +) + +// AddAuditLog adds an audit log entry +func (p *provider) AddAuditLog(ctx context.Context, auditLog *schemas.AuditLog) error { + if auditLog.ID == "" { + auditLog.ID = uuid.New().String() + } + auditLog.Key = auditLog.ID + if auditLog.Timestamp == 0 { + auditLog.Timestamp = time.Now().Unix() + } + auditLog.CreatedAt = time.Now().Unix() + auditLog.UpdatedAt = time.Now().Unix() + + collection := p.db.Collection(schemas.Collections.AuditLog, options.Collection()) + _, err := collection.InsertOne(ctx, auditLog) + if err != nil { + return err + } + return nil +} + +// ListAuditLogs queries audit logs with filters and pagination +func (p *provider) ListAuditLogs(ctx context.Context, pagination *model.Pagination, filter map[string]interface{}) ([]*schemas.AuditLog, *model.Pagination, error) { + auditLogs := []*schemas.AuditLog{} + opts := options.Find() + opts.SetLimit(pagination.Limit) + opts.SetSkip(pagination.Offset) + opts.SetSort(bson.M{"timestamp": -1}) + + query := bson.M{} + if actorID, ok := filter["actor_id"]; ok && actorID != "" { + query["actor_id"] = actorID + } + if action, ok := filter["action"]; ok && action != "" { + query["action"] = action + } + if resourceType, ok := filter["resource_type"]; ok && resourceType != "" { + query["resource_type"] = resourceType + } + if resourceID, ok := filter["resource_id"]; ok && resourceID != "" { + query["resource_id"] = resourceID + } + if orgID, ok := filter["organization_id"]; ok && orgID != "" { + query["organization_id"] = orgID + } + if fromTimestamp, ok := filter["from_timestamp"]; ok { + if query["timestamp"] == nil { + query["timestamp"] = bson.M{} + } + query["timestamp"].(bson.M)["$gte"] = fromTimestamp + } + if toTimestamp, ok := filter["to_timestamp"]; ok { + if query["timestamp"] == nil { + query["timestamp"] = bson.M{} + } + query["timestamp"].(bson.M)["$lte"] = toTimestamp + } + + paginationClone := pagination + collection := p.db.Collection(schemas.Collections.AuditLog, options.Collection()) + + count, err := collection.CountDocuments(ctx, query, options.Count()) + if err != nil { + return nil, nil, err + } + paginationClone.Total = count + + cursor, err := collection.Find(ctx, query, opts) + if err != nil { + return nil, nil, err + } + defer cursor.Close(ctx) + + for cursor.Next(ctx) { + var auditLog *schemas.AuditLog + err := cursor.Decode(&auditLog) + if err != nil { + return nil, nil, err + } + auditLogs = append(auditLogs, auditLog) + } + + return auditLogs, paginationClone, nil +} + +// DeleteAuditLogsBefore removes logs older than a timestamp +func (p *provider) DeleteAuditLogsBefore(ctx context.Context, before int64) error { + collection := p.db.Collection(schemas.Collections.AuditLog, options.Collection()) + _, err := collection.DeleteMany(ctx, bson.M{"timestamp": bson.M{"$lt": before}}) + return err +} diff --git a/internal/storage/db/mongodb/provider.go b/internal/storage/db/mongodb/provider.go index e8ecfdd2..24466715 100644 --- a/internal/storage/db/mongodb/provider.go +++ b/internal/storage/db/mongodb/provider.go @@ -178,6 +178,24 @@ func NewProvider(config *config.Config, deps *Dependencies) (*provider, error) { }, }, options.CreateIndexes()) + // AuditLog collection and indexes + mongodb.CreateCollection(ctx, schemas.Collections.AuditLog, options.CreateCollection()) + auditLogCollection := mongodb.Collection(schemas.Collections.AuditLog, options.Collection()) + auditLogCollection.Indexes().CreateMany(ctx, []mongo.IndexModel{ + { + Keys: bson.M{"actor_id": 1}, + Options: options.Index().SetSparse(true), + }, + { + Keys: bson.M{"action": 1}, + Options: options.Index().SetSparse(true), + }, + { + Keys: bson.M{"timestamp": -1}, + Options: options.Index().SetSparse(true), + }, + }, options.CreateIndexes()) + return &provider{ config: config, dependencies: deps, diff --git a/internal/storage/db/sql/audit_log.go b/internal/storage/db/sql/audit_log.go new file mode 100644 index 00000000..4e19bdb6 --- /dev/null +++ b/internal/storage/db/sql/audit_log.go @@ -0,0 +1,90 @@ +package sql + +import ( + "context" + "time" + + "github.com/google/uuid" + "gorm.io/gorm/clause" + + "github.com/authorizerdev/authorizer/internal/graph/model" + "github.com/authorizerdev/authorizer/internal/storage/schemas" +) + +// AddAuditLog adds an audit log entry +func (p *provider) AddAuditLog(ctx context.Context, auditLog *schemas.AuditLog) error { + if auditLog.ID == "" { + auditLog.ID = uuid.New().String() + } + auditLog.Key = auditLog.ID + if auditLog.Timestamp == 0 { + auditLog.Timestamp = time.Now().Unix() + } + auditLog.CreatedAt = time.Now().Unix() + auditLog.UpdatedAt = time.Now().Unix() + res := p.db.Clauses( + clause.OnConflict{ + DoNothing: true, + }).Create(&auditLog) + if res.Error != nil { + return res.Error + } + return nil +} + +// ListAuditLogs queries audit logs with filters and pagination +func (p *provider) ListAuditLogs(ctx context.Context, pagination *model.Pagination, filter map[string]interface{}) ([]*schemas.AuditLog, *model.Pagination, error) { + var auditLogs []*schemas.AuditLog + var total int64 + + query := p.db.Model(&schemas.AuditLog{}) + + // Apply filters + if actorID, ok := filter["actor_id"]; ok && actorID != "" { + query = query.Where("actor_id = ?", actorID) + } + if action, ok := filter["action"]; ok && action != "" { + query = query.Where("action = ?", action) + } + if resourceType, ok := filter["resource_type"]; ok && resourceType != "" { + query = query.Where("resource_type = ?", resourceType) + } + if resourceID, ok := filter["resource_id"]; ok && resourceID != "" { + query = query.Where("resource_id = ?", resourceID) + } + if orgID, ok := filter["organization_id"]; ok && orgID != "" { + query = query.Where("organization_id = ?", orgID) + } + if fromTimestamp, ok := filter["from_timestamp"]; ok { + query = query.Where("timestamp >= ?", fromTimestamp) + } + if toTimestamp, ok := filter["to_timestamp"]; ok { + query = query.Where("timestamp <= ?", toTimestamp) + } + + // Count total + totalRes := query.Count(&total) + if totalRes.Error != nil { + return nil, nil, totalRes.Error + } + + // Fetch results + result := query.Limit(int(pagination.Limit)).Offset(int(pagination.Offset)).Order("timestamp DESC").Find(&auditLogs) + if result.Error != nil { + return nil, nil, result.Error + } + + paginationClone := pagination + paginationClone.Total = total + + return auditLogs, paginationClone, nil +} + +// DeleteAuditLogsBefore removes logs older than a timestamp +func (p *provider) DeleteAuditLogsBefore(ctx context.Context, before int64) error { + res := p.db.Where("timestamp < ?", before).Delete(&schemas.AuditLog{}) + if res.Error != nil { + return res.Error + } + return nil +} diff --git a/internal/storage/db/sql/provider.go b/internal/storage/db/sql/provider.go index 2a7dbe1f..c6119711 100644 --- a/internal/storage/db/sql/provider.go +++ b/internal/storage/db/sql/provider.go @@ -83,7 +83,7 @@ func NewProvider( } } - err = sqlDB.AutoMigrate(&schemas.User{}, &schemas.VerificationRequest{}, &schemas.Session{}, &schemas.Env{}, &schemas.Webhook{}, &schemas.WebhookLog{}, &schemas.EmailTemplate{}, &schemas.OTP{}, &schemas.Authenticator{}, &schemas.SessionToken{}, &schemas.MFASession{}, &schemas.OAuthState{}) + err = sqlDB.AutoMigrate(&schemas.User{}, &schemas.VerificationRequest{}, &schemas.Session{}, &schemas.Env{}, &schemas.Webhook{}, &schemas.WebhookLog{}, &schemas.EmailTemplate{}, &schemas.OTP{}, &schemas.Authenticator{}, &schemas.SessionToken{}, &schemas.MFASession{}, &schemas.OAuthState{}, &schemas.AuditLog{}) if err != nil { return nil, err } diff --git a/internal/storage/provider.go b/internal/storage/provider.go index afb7643c..36647dcc 100644 --- a/internal/storage/provider.go +++ b/internal/storage/provider.go @@ -160,6 +160,14 @@ type Provider interface { DeleteOAuthStateByKey(ctx context.Context, key string) error // GetAllOAuthStates retrieves all OAuth states (for testing) GetAllOAuthStates(ctx context.Context) ([]*schemas.OAuthState, error) + + // Audit Log methods + // AddAuditLog adds an audit log entry + AddAuditLog(ctx context.Context, log *schemas.AuditLog) error + // ListAuditLogs queries audit logs with filters and pagination + ListAuditLogs(ctx context.Context, pagination *model.Pagination, filter map[string]interface{}) ([]*schemas.AuditLog, *model.Pagination, error) + // DeleteAuditLogsBefore removes logs older than a timestamp (retention) + DeleteAuditLogsBefore(ctx context.Context, before int64) error } // New creates a new database provider based on the configuration diff --git a/internal/storage/schemas/audit_log.go b/internal/storage/schemas/audit_log.go new file mode 100644 index 00000000..67d46479 --- /dev/null +++ b/internal/storage/schemas/audit_log.go @@ -0,0 +1,22 @@ +package schemas + +// Note: any change here should be reflected in providers/casandra/provider.go as it does not have model support in collection creation + +// AuditLog model for db +type AuditLog struct { + Key string `json:"_key,omitempty" bson:"_key,omitempty" cql:"_key,omitempty" dynamo:"key,omitempty"` // for arangodb + ID string `gorm:"primaryKey;type:char(36)" json:"_id" bson:"_id" cql:"id" dynamo:"id,hash"` + Timestamp int64 `json:"timestamp" bson:"timestamp" cql:"timestamp" dynamo:"timestamp"` + ActorID string `gorm:"type:char(36)" json:"actor_id" bson:"actor_id" cql:"actor_id" dynamo:"actor_id" index:"actor_id,hash"` + ActorType string `gorm:"type:varchar(30)" json:"actor_type" bson:"actor_type" cql:"actor_type" dynamo:"actor_type"` + ActorEmail string `gorm:"type:varchar(256)" json:"actor_email" bson:"actor_email" cql:"actor_email" dynamo:"actor_email"` + Action string `gorm:"type:varchar(100)" json:"action" bson:"action" cql:"action" dynamo:"action" index:"action,hash"` + ResourceType string `gorm:"type:varchar(50)" json:"resource_type" bson:"resource_type" cql:"resource_type" dynamo:"resource_type"` + ResourceID string `gorm:"type:char(36)" json:"resource_id" bson:"resource_id" cql:"resource_id" dynamo:"resource_id"` + IPAddress string `gorm:"type:varchar(45)" json:"ip_address" bson:"ip_address" cql:"ip_address" dynamo:"ip_address"` + UserAgent string `gorm:"type:text" json:"user_agent" bson:"user_agent" cql:"user_agent" dynamo:"user_agent"` + Metadata string `gorm:"type:text" json:"metadata" bson:"metadata" cql:"metadata" dynamo:"metadata"` + OrganizationID string `gorm:"type:char(36)" json:"organization_id" bson:"organization_id" cql:"organization_id" dynamo:"organization_id"` + CreatedAt int64 `json:"created_at" bson:"created_at" cql:"created_at" dynamo:"created_at"` + UpdatedAt int64 `json:"updated_at" bson:"updated_at" cql:"updated_at" dynamo:"updated_at"` +} diff --git a/internal/storage/schemas/model.go b/internal/storage/schemas/model.go index 70d55123..43f4e464 100644 --- a/internal/storage/schemas/model.go +++ b/internal/storage/schemas/model.go @@ -15,6 +15,7 @@ type CollectionList struct { SessionToken string MFASession string OAuthState string + AuditLog string } var ( @@ -35,5 +36,6 @@ var ( SessionToken: Prefix + "session_tokens", MFASession: Prefix + "mfa_sessions", OAuthState: Prefix + "oauth_states", + AuditLog: Prefix + "audit_logs", } ) From 1a3853beca9420b60bb8109fe44a3d6bce89fb22 Mon Sep 17 00:00:00 2001 From: Lakhan Samani Date: Fri, 3 Apr 2026 10:41:54 +0530 Subject: [PATCH 2/2] chore: add more auditlog events --- internal/constants/audit_event.go | 105 +++++++++++++----- internal/integration_tests/audit_logs_test.go | 19 +++- .../authorizer_redirect_test.db | Bin 0 -> 4096 bytes internal/storage/db/arangodb/audit_log.go | 5 +- internal/storage/db/cassandradb/audit_log.go | 4 +- internal/storage/db/couchbase/audit_log.go | 34 ++++-- internal/storage/db/dynamodb/audit_log.go | 23 +++- internal/storage/db/mongodb/audit_log.go | 4 +- internal/storage/db/sql/audit_log.go | 4 +- 9 files changed, 148 insertions(+), 50 deletions(-) create mode 100644 internal/integration_tests/authorizer_redirect_test.db diff --git a/internal/constants/audit_event.go b/internal/constants/audit_event.go index cb2bad39..07b4c019 100644 --- a/internal/constants/audit_event.go +++ b/internal/constants/audit_event.go @@ -1,37 +1,92 @@ package constants +// Audit event type constants used for structured audit logging. +// Each constant represents a specific auditable action in the system, +// organized by domain: user authentication, admin operations, OAuth, +// token lifecycle, and session management. const ( - // Authentication events - AuditLoginSuccessEvent = "user.login_success" - AuditLoginFailedEvent = "user.login_failed" - AuditSignupEvent = "user.signup" - AuditLogoutEvent = "user.logout" - AuditPasswordChangedEvent = "user.password_changed" - AuditPasswordResetEvent = "user.password_reset" - AuditEmailVerifiedEvent = "user.email_verified" - AuditPhoneVerifiedEvent = "user.phone_verified" - AuditMFAEnabledEvent = "user.mfa_enabled" - AuditMFADisabledEvent = "user.mfa_disabled" - AuditUserDeactivatedEvent = "user.deactivated" + // AuditLoginSuccessEvent is logged when a user successfully authenticates. + AuditLoginSuccessEvent = "user.login_success" + // AuditLoginFailedEvent is logged when a user authentication attempt fails. + AuditLoginFailedEvent = "user.login_failed" + // AuditSignupEvent is logged when a new user registers. + AuditSignupEvent = "user.signup" + // AuditLogoutEvent is logged when a user logs out. + AuditLogoutEvent = "user.logout" + // AuditPasswordChangedEvent is logged when a user changes their password. + AuditPasswordChangedEvent = "user.password_changed" + // AuditPasswordResetEvent is logged when a user resets their password via token or OTP. + AuditPasswordResetEvent = "user.password_reset" + // AuditForgotPasswordEvent is logged when a user requests a password reset. + AuditForgotPasswordEvent = "user.forgot_password_requested" + // AuditMagicLinkRequestedEvent is logged when a user requests a magic link login. + AuditMagicLinkRequestedEvent = "user.magic_link_requested" + // AuditEmailVerifiedEvent is logged when a user's email is verified. + AuditEmailVerifiedEvent = "user.email_verified" + // AuditPhoneVerifiedEvent is logged when a user's phone number is verified. + AuditPhoneVerifiedEvent = "user.phone_verified" + // AuditMFAEnabledEvent is logged when a user enables multi-factor authentication. + AuditMFAEnabledEvent = "user.mfa_enabled" + // AuditMFADisabledEvent is logged when a user disables multi-factor authentication. + AuditMFADisabledEvent = "user.mfa_disabled" + // AuditProfileUpdatedEvent is logged when a user updates their profile. + AuditProfileUpdatedEvent = "user.profile_updated" + // AuditUserDeactivatedEvent is logged when a user deactivates their account. + AuditUserDeactivatedEvent = "user.deactivated" + // AuditOTPResentEvent is logged when an OTP is resent to a user. + AuditOTPResentEvent = "user.otp_resent" + // AuditVerifyEmailResentEvent is logged when a verification email is resent. + AuditVerifyEmailResentEvent = "user.verify_email_resent" - // Admin events - AuditAdminUserCreatedEvent = "admin.user_created" - AuditAdminUserUpdatedEvent = "admin.user_updated" - AuditAdminUserDeletedEvent = "admin.user_deleted" + // AuditAdminLoginSuccessEvent is logged when an admin successfully authenticates. + AuditAdminLoginSuccessEvent = "admin.login_success" + // AuditAdminLoginFailedEvent is logged when an admin authentication attempt fails. + AuditAdminLoginFailedEvent = "admin.login_failed" + // AuditAdminLogoutEvent is logged when an admin logs out. + AuditAdminLogoutEvent = "admin.logout" + // AuditAdminUserCreatedEvent is logged when an admin creates a user. + AuditAdminUserCreatedEvent = "admin.user_created" + // AuditAdminUserUpdatedEvent is logged when an admin updates a user. + AuditAdminUserUpdatedEvent = "admin.user_updated" + // AuditAdminUserDeletedEvent is logged when an admin deletes a user. + AuditAdminUserDeletedEvent = "admin.user_deleted" + // AuditAdminAccessRevokedEvent is logged when an admin revokes a user's access. AuditAdminAccessRevokedEvent = "admin.access_revoked" + // AuditAdminAccessEnabledEvent is logged when an admin restores a user's access. AuditAdminAccessEnabledEvent = "admin.access_enabled" - AuditAdminUserUnlockedEvent = "admin.user_unlocked" + // AuditAdminInviteSentEvent is logged when an admin sends a user invitation. + AuditAdminInviteSentEvent = "admin.invite_sent" + // AuditAdminConfigChangedEvent is logged when an admin modifies server configuration. AuditAdminConfigChangedEvent = "admin.config_changed" - AuditAdminWebhookCreated = "admin.webhook_created" - AuditAdminWebhookUpdated = "admin.webhook_updated" - AuditAdminWebhookDeleted = "admin.webhook_deleted" + // AuditAdminWebhookCreatedEvent is logged when an admin creates a webhook. + AuditAdminWebhookCreatedEvent = "admin.webhook_created" + // AuditAdminWebhookUpdatedEvent is logged when an admin updates a webhook. + AuditAdminWebhookUpdatedEvent = "admin.webhook_updated" + // AuditAdminWebhookDeletedEvent is logged when an admin deletes a webhook. + AuditAdminWebhookDeletedEvent = "admin.webhook_deleted" + // AuditAdminEmailTemplateCreatedEvent is logged when an admin creates an email template. + AuditAdminEmailTemplateCreatedEvent = "admin.email_template_created" + // AuditAdminEmailTemplateUpdatedEvent is logged when an admin updates an email template. + AuditAdminEmailTemplateUpdatedEvent = "admin.email_template_updated" + // AuditAdminEmailTemplateDeletedEvent is logged when an admin deletes an email template. + AuditAdminEmailTemplateDeletedEvent = "admin.email_template_deleted" - // Token events - AuditTokenIssuedEvent = "token.issued" + // AuditOAuthLoginInitiatedEvent is logged when an OAuth login flow is started. + AuditOAuthLoginInitiatedEvent = "oauth.login_initiated" + // AuditOAuthCallbackSuccessEvent is logged when an OAuth callback completes successfully. + AuditOAuthCallbackSuccessEvent = "oauth.callback_success" + // AuditOAuthCallbackFailedEvent is logged when an OAuth callback fails. + AuditOAuthCallbackFailedEvent = "oauth.callback_failed" + + // AuditTokenIssuedEvent is logged when a new token is issued. + AuditTokenIssuedEvent = "token.issued" + // AuditTokenRefreshedEvent is logged when a token is refreshed. AuditTokenRefreshedEvent = "token.refreshed" - AuditTokenRevokedEvent = "token.revoked" + // AuditTokenRevokedEvent is logged when a token is revoked. + AuditTokenRevokedEvent = "token.revoked" - // Session events - AuditSessionCreatedEvent = "session.created" + // AuditSessionCreatedEvent is logged when a new session is created. + AuditSessionCreatedEvent = "session.created" + // AuditSessionTerminatedEvent is logged when a session is terminated. AuditSessionTerminatedEvent = "session.terminated" ) diff --git a/internal/integration_tests/audit_logs_test.go b/internal/integration_tests/audit_logs_test.go index fcc86b6f..f688b97c 100644 --- a/internal/integration_tests/audit_logs_test.go +++ b/internal/integration_tests/audit_logs_test.go @@ -1,7 +1,6 @@ package integration_tests import ( - "context" "testing" "time" @@ -16,7 +15,7 @@ import ( func TestAuditLogs(t *testing.T) { cfg := getTestConfig() ts := initTestSetup(t, cfg) - ctx := context.Background() + _, ctx := createContext(ts) t.Run("should add and list audit logs", func(t *testing.T) { auditLog := &schemas.AuditLog{ @@ -96,13 +95,25 @@ func TestAuditLogs(t *testing.T) { assert.Equal(t, actorID, logs[0].ActorID) }) + t.Run("should not mutate caller pagination", func(t *testing.T) { + pagination := &model.Pagination{ + Limit: 10, + Offset: 0, + } + _, returnedPag, err := ts.StorageProvider.ListAuditLogs(ctx, pagination, map[string]interface{}{}) + require.NoError(t, err) + assert.NotSame(t, pagination, returnedPag, "should return a new pagination object") + }) + t.Run("should delete audit logs before timestamp", func(t *testing.T) { + uniqueAction := "cleanup_test_" + uuid.New().String()[:8] + // Add a log with old timestamp oldLog := &schemas.AuditLog{ ActorID: uuid.New().String(), ActorType: "system", ActorEmail: "system@example.com", - Action: "cleanup_test", + Action: uniqueAction, Timestamp: time.Now().Add(-24 * time.Hour).Unix(), } err := ts.StorageProvider.AddAuditLog(ctx, oldLog) @@ -119,7 +130,7 @@ func TestAuditLogs(t *testing.T) { Offset: 0, } logs, _, err := ts.StorageProvider.ListAuditLogs(ctx, pagination, map[string]interface{}{ - "action": "cleanup_test", + "action": uniqueAction, }) require.NoError(t, err) assert.Equal(t, 0, len(logs)) diff --git a/internal/integration_tests/authorizer_redirect_test.db b/internal/integration_tests/authorizer_redirect_test.db new file mode 100644 index 0000000000000000000000000000000000000000..9a472209435d88229f65ab7a0ca30d67df87a462 GIT binary patch literal 4096 zcmWFz^vNtqRY=P(%1ta$FlG>7U}9o$P*7lCU|@t|AVoG{WY9}{#S79dK(-m98b?E5 nGz3ONU^E0qLtr!nMnhmU1V%$(Gz3ONU^E0qLtr!nC=3ArNJIx= literal 0 HcmV?d00001 diff --git a/internal/storage/db/arangodb/audit_log.go b/internal/storage/db/arangodb/audit_log.go index 4625113f..6bfc7a39 100644 --- a/internal/storage/db/arangodb/audit_log.go +++ b/internal/storage/db/arangodb/audit_log.go @@ -16,7 +16,6 @@ import ( func (p *provider) AddAuditLog(ctx context.Context, auditLog *schemas.AuditLog) error { if auditLog.ID == "" { auditLog.ID = uuid.New().String() - auditLog.Key = auditLog.ID } auditLog.Key = auditLog.ID if auditLog.Timestamp == 0 { @@ -75,7 +74,7 @@ func (p *provider) ListAuditLogs(ctx context.Context, pagination *model.Paginati } defer cursor.Close() - paginationClone := pagination + paginationClone := *pagination paginationClone.Total = cursor.Statistics().FullCount() for { @@ -91,7 +90,7 @@ func (p *provider) ListAuditLogs(ctx context.Context, pagination *model.Paginati } } - return auditLogs, paginationClone, nil + return auditLogs, &paginationClone, nil } // DeleteAuditLogsBefore removes logs older than a timestamp diff --git a/internal/storage/db/cassandradb/audit_log.go b/internal/storage/db/cassandradb/audit_log.go index 4a7f06b7..8ca37e8b 100644 --- a/internal/storage/db/cassandradb/audit_log.go +++ b/internal/storage/db/cassandradb/audit_log.go @@ -40,7 +40,7 @@ func (p *provider) AddAuditLog(ctx context.Context, auditLog *schemas.AuditLog) // ListAuditLogs queries audit logs with filters and pagination func (p *provider) ListAuditLogs(ctx context.Context, pagination *model.Pagination, filter map[string]interface{}) ([]*schemas.AuditLog, *model.Pagination, error) { auditLogs := []*schemas.AuditLog{} - paginationClone := pagination + paginationClone := *pagination // Build query with filters queryBase := fmt.Sprintf("SELECT id, timestamp, actor_id, actor_type, actor_email, action, resource_type, resource_id, ip_address, user_agent, metadata, organization_id, created_at, updated_at FROM %s", KeySpace+"."+schemas.Collections.AuditLog) @@ -94,7 +94,7 @@ func (p *provider) ListAuditLogs(ctx context.Context, pagination *model.Paginati counter++ } - return auditLogs, paginationClone, nil + return auditLogs, &paginationClone, nil } // DeleteAuditLogsBefore removes logs older than a timestamp diff --git a/internal/storage/db/couchbase/audit_log.go b/internal/storage/db/couchbase/audit_log.go index 5d3edd36..6d360a75 100644 --- a/internal/storage/db/couchbase/audit_log.go +++ b/internal/storage/db/couchbase/audit_log.go @@ -3,7 +3,6 @@ package couchbase import ( "context" "fmt" - "log" "time" "github.com/couchbase/gocb/v2" @@ -37,17 +36,11 @@ func (p *provider) AddAuditLog(ctx context.Context, auditLog *schemas.AuditLog) // ListAuditLogs queries audit logs with filters and pagination func (p *provider) ListAuditLogs(ctx context.Context, pagination *model.Pagination, filter map[string]interface{}) ([]*schemas.AuditLog, *model.Pagination, error) { auditLogs := []*schemas.AuditLog{} - paginationClone := pagination + paginationClone := *pagination params := make(map[string]interface{}) params["offset"] = paginationClone.Offset params["limit"] = paginationClone.Limit - total, err := p.GetTotalDocs(ctx, schemas.Collections.AuditLog) - if err != nil { - return nil, nil, err - } - paginationClone.Total = total - whereClause := "" if action, ok := filter["action"]; ok && action != "" { whereClause += " WHERE action=$action" @@ -62,6 +55,27 @@ func (p *provider) ListAuditLogs(ctx context.Context, pagination *model.Paginati params["actorID"] = actorID } + // Count with filters applied + countQuery := fmt.Sprintf("SELECT COUNT(*) as count FROM %s.%s%s", + p.scopeName, schemas.Collections.AuditLog, whereClause) + countResult, err := p.db.Query(countQuery, &gocb.QueryOptions{ + Context: ctx, + ScanConsistency: gocb.QueryScanConsistencyRequestPlus, + NamedParameters: params, + }) + if err != nil { + return nil, nil, err + } + var countRow struct { + Count int64 `json:"count"` + } + if countResult.Next() { + if err := countResult.Row(&countRow); err != nil { + return nil, nil, err + } + } + paginationClone.Total = countRow.Count + query := fmt.Sprintf("SELECT _id, timestamp, actor_id, actor_type, actor_email, action, resource_type, resource_id, ip_address, user_agent, metadata, organization_id, created_at, updated_at FROM %s.%s%s ORDER BY timestamp DESC OFFSET $offset LIMIT $limit", p.scopeName, schemas.Collections.AuditLog, whereClause) @@ -77,14 +91,14 @@ func (p *provider) ListAuditLogs(ctx context.Context, pagination *model.Paginati var auditLog schemas.AuditLog err := queryResult.Row(&auditLog) if err != nil { - log.Fatal(err) + return nil, nil, err } auditLogs = append(auditLogs, &auditLog) } if err := queryResult.Err(); err != nil { return nil, nil, err } - return auditLogs, paginationClone, nil + return auditLogs, &paginationClone, nil } // DeleteAuditLogsBefore removes logs older than a timestamp diff --git a/internal/storage/db/dynamodb/audit_log.go b/internal/storage/db/dynamodb/audit_log.go index cf1da906..4c9dfe09 100644 --- a/internal/storage/db/dynamodb/audit_log.go +++ b/internal/storage/db/dynamodb/audit_log.go @@ -40,7 +40,7 @@ func (p *provider) ListAuditLogs(ctx context.Context, pagination *model.Paginati var err error collection := p.db.Table(schemas.Collections.AuditLog) - paginationClone := pagination + paginationClone := *pagination scanner := collection.Scan() // Apply filters @@ -69,7 +69,26 @@ func (p *provider) ListAuditLogs(ctx context.Context, pagination *model.Paginati iteration += paginationClone.Limit } - return auditLogs, paginationClone, nil + // Count total matching documents + var total int64 + countScanner := collection.Scan() + if action, ok := filter["action"]; ok && action != "" { + countScanner = countScanner.Filter("'action' = ?", action) + } + if actorID, ok := filter["actor_id"]; ok && actorID != "" { + countScanner = countScanner.Filter("'actor_id' = ?", actorID) + } + if resourceType, ok := filter["resource_type"]; ok && resourceType != "" { + countScanner = countScanner.Filter("'resource_type' = ?", resourceType) + } + var countItems []*schemas.AuditLog + if err = countScanner.AllWithContext(ctx, &countItems); err != nil { + return nil, nil, err + } + total = int64(len(countItems)) + paginationClone.Total = total + + return auditLogs, &paginationClone, nil } // DeleteAuditLogsBefore removes logs older than a timestamp diff --git a/internal/storage/db/mongodb/audit_log.go b/internal/storage/db/mongodb/audit_log.go index 2d02be39..f512567a 100644 --- a/internal/storage/db/mongodb/audit_log.go +++ b/internal/storage/db/mongodb/audit_log.go @@ -69,7 +69,7 @@ func (p *provider) ListAuditLogs(ctx context.Context, pagination *model.Paginati query["timestamp"].(bson.M)["$lte"] = toTimestamp } - paginationClone := pagination + paginationClone := *pagination collection := p.db.Collection(schemas.Collections.AuditLog, options.Collection()) count, err := collection.CountDocuments(ctx, query, options.Count()) @@ -93,7 +93,7 @@ func (p *provider) ListAuditLogs(ctx context.Context, pagination *model.Paginati auditLogs = append(auditLogs, auditLog) } - return auditLogs, paginationClone, nil + return auditLogs, &paginationClone, nil } // DeleteAuditLogsBefore removes logs older than a timestamp diff --git a/internal/storage/db/sql/audit_log.go b/internal/storage/db/sql/audit_log.go index 4e19bdb6..6e099b8b 100644 --- a/internal/storage/db/sql/audit_log.go +++ b/internal/storage/db/sql/audit_log.go @@ -74,10 +74,10 @@ func (p *provider) ListAuditLogs(ctx context.Context, pagination *model.Paginati return nil, nil, result.Error } - paginationClone := pagination + paginationClone := *pagination paginationClone.Total = total - return auditLogs, paginationClone, nil + return auditLogs, &paginationClone, nil } // DeleteAuditLogsBefore removes logs older than a timestamp